Skip to content

SimdVerletDyn4jBridge.kt

src/jvmMain/kotlin/afield/simulation/physics/SimdVerletDyn4jBridge.kt · 437 lines

package afield.simulation.physics

import org.dyn4j.dynamics.Body
import org.dyn4j.dynamics.BodyFixture
import org.dyn4j.geometry.Circle
import org.dyn4j.geometry.Geometry
import org.dyn4j.geometry.MassType
import org.dyn4j.geometry.Vector2 as DynVec2
import kotlin.math.PI
import kotlin.math.abs

Runs a SimdVerlet particle simulation and a Dyn4jScene in one frame, so that chosen particles collide with rigid bodies as circles while the rest stay ordinary particles.

The coupling is by nomination. nominate takes a particle index and gives it a circle body in the scene's dyn4j world; every other particle is untouched and costs nothing. Then each step advances the particle simulation, copies the nominated particles into their bodies, advances dyn4j, and copies the bodies back. A nominated particle is still a Verlet particle that constraints and force generators pull on; it spends the end of every frame being a rigid body as well.

The frame, and the SimdVerlet features it drops

step does not call SimdVerlet.step. It runs its own copy of that frame, because it has to decide per particle whether gravity applies. In order: queued removals, zero the accumulators, gravity, the per-particle force generators, the constraint solve, Verlet integration, then the dyn4j half.

What the copy leaves out is what this class does not support. There is no SimdVerlet.bulkForces pass, no accumulateForces hook, and neither post-integration stage runs, so a ColliderSet in SimdVerlet.positionProjections or a Tearing in SimdVerlet.constraintEdits is silent under step. It also solves only SimdVerlet.DirectedConstraints3, while Cloth and Strand build SimdVerlet.DirectedConstraints4: step a cloth through this bridge and its constraints go unsolved, because the default constraints = sim.constraints is null when the fabric is in constraints4. So this is a bridge for loose particles rather than for fabric.

Positions and velocities, both ways

A Verlet particle carries its velocity as the gap between x and px; a dyn4j body carries one directly, in units per second. So the push sets the body to the particle's previous position with (x - px) / dt as its velocity and lets dyn4j integrate that forward over the same dt: the dyn4j step redoes the frame's last integration, this time with contacts in it. The pull then writes x from the body's centre and rebuilds px as centre - velocity * dt, so the particle's history after a frame comes from the body rather than surviving the hop.

Only x, y, px and py are written. CollisionPlane has one case and the constructor requires it: this is the simulation's x/y plane, and z is left alone.

Gravity, counted once

DynGravityMode.MIRROR_SIM copies step's gravity argument into the dyn4j world and withholds it from the nominated particles in the Verlet pass, so dyn4j is the only thing accelerating them. DynGravityMode.ZERO does the reverse: the dyn4j world gets none, and every particle, nominated or not, gets the simulation's. Either way a particle falls once per frame.

The two are not the same fall. What the Verlet pass adds gravity to is the ay accumulator, which is a force whenever the state has an invMass array, as SimdVerlet.State3.empty allocates by default, so a heavy particle falls more slowly in the simulation. A dyn4j world gravity is an acceleration, which every body there feels equally. They agree at inverse mass 1.

Mass, radius, removal

Each frame the body's density is set so its mass is the particle's 1 / invMass spread over the circle's area, and an inverse mass of zero or less becomes MassType.INFINITE, the same convention that pins a particle in the solver. That conversion is cached and redone only when the particle's inverse mass changes.

Changing BulletSpec.radius destroys the body and builds a new one, at the next step rather than when the spec changes, because the fixture's circle is not resized in place. Friction and restitution are written to the existing fixture instead. unnominate and clearNominations take the bodies back out of the world.

Nominations are keyed by particle index, and SimdVerlet's removal is a swap compaction: a deleted slot is filled from the tail. Each frame drops the nominations whose index has fallen out of range, which is as much as an index can tell it — a nomination whose index survives a removal now names whichever particle was swapped into that slot. Re-nominate after removing particles.

Nothing in the repository constructs one of these and no test covers it. It is a surface for box code to call, and there is no 3D equivalent: JoltPhysicsWorld is a rigid-body world of its own, with nothing wiring it to a particle simulation.

enum class CollisionPlane

Which plane of the simulation the rigid-body scene lives in. Only XY is implemented.

enum class CollisionPlane {
    XY
}

enum class DynGravityMode

Which of the two engines accelerates a nominated particle, so that gravity is applied once rather than twice or not at all. See the file overview above for the difference between the two falls.

enum class DynGravityMode {
    ZERO,
    MIRROR_SIM
}

data class BulletSpec

The rigid body a nominated particle becomes: a circle of radius, with dyn4j's contact parameters.

Mass is not here. It is read from the particle's inverse mass every frame, and radius only sets the area that mass is spread over. bullet is dyn4j's flag for a body fast enough to need its continuous-collision check; it defaults to true here, against false in DynBodyDef.

data class BulletSpec(
    val radius: Float = 0.05f,
    val friction: Double = 0.2,
    val restitution: Double = 0.0,
    val linearDamping: Double = 0.0,
    val angularDamping: Double = 0.0,
    val bullet: Boolean = true,
    val userData: Any? = null
)

class SimdVerletDyn4jBridge

One particle simulation, one rigid-body scene, and the nomination table joining them. The file overview above has the frame in order and what it leaves out.

Both halves stay usable on their own: sim can be read and edited as an ordinary SimdVerlet and scene can hold as many static and dynamic bodies as it likes, added through Dyn4jScene.addBody rather than through here. What this class owns is only the bodies standing in for nominated particles.

class SimdVerletDyn4jBridge(
    val sim: SimdVerlet,
    val scene: Dyn4jScene,
    val plane: CollisionPlane = CollisionPlane.XY,
    val gravityMode: DynGravityMode = DynGravityMode.MIRROR_SIM,
    val defaultSpec: BulletSpec = BulletSpec()
) {
    private data class BulletBinding(
        var spec: BulletSpec,
        var body: Body,
        var fixture: BodyFixture,
        var cachedInvMass: Float? = null
    )

    private val nominations = linkedMapOf<Int, BulletBinding>()

init

    init {
        require(plane == CollisionPlane.XY) { "Only XY collision plane is implemented in v1" }
    }

fun nominate(index: Int, spec: BulletSpec = defaultSpec)

Gives the particle at index a circle rigid body, so that it collides with the scene.

The body joins the dyn4j world immediately and is placed on the particle by the next step, so it is that step which first has it in the right place.

Nominating an index that is already nominated replaces its BulletSpec, which is what updateNomination does with the stricter requirement that the index be nominated already. The particle keeps its position, its constraints and its force generators either way.

@throws IllegalArgumentException if index is not a live particle, or spec asks for a radius of zero or less.

    fun nominate(index: Int, spec: BulletSpec = defaultSpec) {
        require(index in 0 until sim.state.size) { "Particle index out of bounds: $index" }
        validateSpec(spec)

        val existing = nominations[index]
        if (existing != null) {
            existing.spec = spec
            existing.cachedInvMass = null
            return
        }

        val (body, fixture) = createBulletBody(spec)
        nominations[index] = BulletBinding(spec = spec, body = body, fixture = fixture)
    }

fun updateNomination(index: Int, spec: BulletSpec)

Replaces the BulletSpec of an already-nominated index, keeping its body where possible.

A changed radius is applied by rebuilding the body at the next step; friction and restitution reach the existing fixture.

@throws IllegalStateException if index is not nominated. Use nominate to say "either way".

    fun updateNomination(index: Int, spec: BulletSpec) {
        validateSpec(spec)
        val existing = nominations[index] ?: error("Index $index is not nominated")
        existing.spec = spec
        existing.cachedInvMass = null
    }

fun unnominate(index: Int): Boolean

Takes the particle at index back to being an ordinary particle, removing its body from the dyn4j world. false if it was not nominated.

The particle's own position and velocity are whatever the last step left, which is the body's, so it carries on from where the rigid body stopped.

    fun unnominate(index: Int): Boolean {
        val removed = nominations.remove(index) ?: return false
        scene.world.removeBody(removed.body)
        return true
    }

fun clearNominations()

Unnominates every particle, removing all of this bridge's bodies from the dyn4j world.

Bodies added to scene directly are left where they are; this only clears what nomination put there.

    fun clearNominations() {
        val all = nominations.values.toList()
        nominations.clear()
        for (binding in all) {
            scene.world.removeBody(binding.body)
        }
    }

fun nominatedIndices(): Set<Int> = nominations.keys.toSet()

The currently nominated particle indices, as a copy, so it is safe to iterate while calling nominate or unnominate.

fun step

Advances both engines by dt and reconciles them: a Verlet frame, then the dyn4j world, then the nominated particles written back from their bodies.

This is not SimdVerlet.step with something added. It re-runs an abbreviated version of that frame, and the file overview above lists the stages it does not have, of which the post-integration ones — colliders and tearing — are the two most likely to be missed.

@param gravity a per-particle force when the state has an invMass array, as it does by default. Under DynGravityMode.MIRROR_SIM the same number becomes the dyn4j world's gravity, which is an acceleration there. @param constraints the 3-slot set to solve. The default is SimdVerlet.constraints, which is null for a simulation whose fabric was built by Cloth or Strand into constraints4; passing null solves nothing. @throws IllegalArgumentException if dt is not positive.

body — 196 lines, from line 241
    fun step(
        dt: Float,
        damping: Float,
        gravity: Float,
        iterations: Int,
        constraints: SimdVerlet.DirectedConstraints3? = sim.constraints
    ) {
        require(dt > 0f) { "dt must be > 0" }
        runSimdStep(
            dt = dt,
            damping = damping,
            gravity = gravity,
            iterations = iterations,
            constraints = constraints
        )

        configureWorldGravity(gravity)
        pushNominatedParticlesToDyn4j(dt)
        scene.step(dt.toDouble())
        pullNominatedParticlesFromDyn4j(dt)
    }

    private fun runSimdStep(
        dt: Float,
        damping: Float,
        gravity: Float,
        iterations: Int,
        constraints: SimdVerlet.DirectedConstraints3?
    ) {
        val state = sim.state
        state.applyQueuedRemovalsSwap(constraints)
        pruneInvalidNominations(state.size)

        SimdVerlet.zero3(state)

        if (gravity != 0f) {
            val ay = state.ay
            val size = state.size
            val selectiveGravity = gravityMode == DynGravityMode.MIRROR_SIM && nominations.isNotEmpty()
            if (!selectiveGravity) {
                for (i in 0 until size) {
                    ay[i] += gravity
                }
            } else {
                for (i in 0 until size) {
                    if (!nominations.containsKey(i)) {
                        ay[i] += gravity
                    }
                }
            }
        }

        val forceGenerators = state.forceGenerators
        val forceContexts = state.forceContexts
        for (i in 0 until state.size) {
            val ctx = forceContexts[i]
            ctx.index = i
            ctx.dt = dt
            forceGenerators[i].updateNoCollect(ctx)
        }

        if (constraints != null && iterations > 0) {
            SimdVerlet.solveDirectedDist3InPlace(
                state = state,
                constraints = constraints,
                iterations = iterations
            )
        }

        SimdVerlet.integrateVerlet3(state, dt = dt, damping = damping)
    }

    private fun configureWorldGravity(simGravity: Float) {
        when (gravityMode) {
            DynGravityMode.ZERO -> {
                scene.world.gravity = DynVec2(0.0, 0.0)
            }

            DynGravityMode.MIRROR_SIM -> {
                scene.world.gravity = DynVec2(0.0, simGravity.toDouble())
            }
        }
    }

    private fun pushNominatedParticlesToDyn4j(dt: Float) {
        val state = sim.state
        for ((index, binding) in nominations) {
            if (index !in 0 until state.size) continue

            val body = ensureBodyForSpec(binding)
            updateMassFromInvMass(index, binding)

            val px = state.px[index].toDouble()
            val py = state.py[index].toDouble()
            body.transform.setTranslation(px, py)
            body.transform.setRotation(0.0)

            val vx = ((state.x[index] - state.px[index]) / dt).toDouble()
            val vy = ((state.y[index] - state.py[index]) / dt).toDouble()
            body.setLinearVelocity(vx, vy)
            body.angularVelocity = 0.0
            body.linearDamping = binding.spec.linearDamping
            body.angularDamping = binding.spec.angularDamping
            body.isBullet = binding.spec.bullet
            body.userData = binding.spec.userData
            body.setAtRest(false)
        }
    }

    private fun pullNominatedParticlesFromDyn4j(dt: Float) {
        val state = sim.state
        val dtDouble = dt.toDouble()
        for ((index, binding) in nominations) {
            if (index !in 0 until state.size) continue

            val center = binding.body.worldCenter
            val velocity = binding.body.linearVelocity

            state.x[index] = center.x.toFloat()
            state.y[index] = center.y.toFloat()
            state.px[index] = (center.x - velocity.x * dtDouble).toFloat()
            state.py[index] = (center.y - velocity.y * dtDouble).toFloat()
        }
    }

    private fun ensureBodyForSpec(binding: BulletBinding): Body {
        val body = binding.body
        val currentRadius = (binding.fixture.shape as Circle).radius
        val requestedRadius = binding.spec.radius.toDouble()

        if (abs(currentRadius - requestedRadius) > 1.0e-9) {
            scene.world.removeBody(binding.body)
            val (newBody, newFixture) = createBulletBody(binding.spec)
            binding.body = newBody
            binding.fixture = newFixture
            binding.cachedInvMass = null
        } else {
            binding.fixture.friction = binding.spec.friction
            binding.fixture.restitution = binding.spec.restitution
        }

        return binding.body
    }

    private fun updateMassFromInvMass(index: Int, binding: BulletBinding) {
        val invMass = sim.state.invMass?.get(index) ?: 1f
        if (binding.cachedInvMass == invMass) return

        if (invMass <= 0f) {
            binding.body.setMass(MassType.INFINITE)
            binding.cachedInvMass = invMass
            return
        }

        val radius = binding.spec.radius.toDouble()
        val area = PI * radius * radius
        val mass = 1.0 / invMass.toDouble()
        val density = (mass / area).coerceAtLeast(1.0e-9)

        binding.fixture.density = density
        binding.body.updateMass()
        binding.body.setMassType(MassType.NORMAL)
        binding.cachedInvMass = invMass
    }

    private fun createBulletBody(spec: BulletSpec): Pair<Body, BodyFixture> {
        val body = Body()
        val fixture = body.addFixture(Geometry.createCircle(spec.radius.toDouble()))
        fixture.friction = spec.friction
        fixture.restitution = spec.restitution
        fixture.density = 1.0

        body.linearDamping = spec.linearDamping
        body.angularDamping = spec.angularDamping
        body.isBullet = spec.bullet
        body.userData = spec.userData
        body.setMass(MassType.NORMAL)
        scene.world.addBody(body)
        return body to fixture
    }

    private fun pruneInvalidNominations(size: Int) {
        val it = nominations.iterator()
        while (it.hasNext()) {
            val (index, binding) = it.next()
            if (index < 0 || index >= size) {
                scene.world.removeBody(binding.body)
                it.remove()
            }
        }
    }

    private fun validateSpec(spec: BulletSpec) {
        require(spec.radius > 0f) { "Bullet radius must be > 0" }
    }
}