Skip to content

SimdVerlet.kt

src/jvmMain/kotlin/afield/simulation/physics/SimdVerlet.kt · 2118 lines

@file:Suppress("NOTHING_TO_INLINE")

package afield.simulation.physics

import afield.runtime.lifecycle.Generator
import afield.graphics.vulkan.util.MapOfGenerators
import afield.runtime.lifecycle.generator2
import afield.math.linalg.bulk.fmaRange
import afield.math.linalg.bulk.mulRange
import afield.math.linalg.bulk.subRange
import jdk.incubator.vector.FloatVector
import jdk.incubator.vector.VectorMask
import jdk.incubator.vector.VectorOperators.SQRT
import jdk.incubator.vector.VectorSpecies
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt

The particle solver: structure-of-arrays Verlet integration, directed distance constraints, and the vector kernels that run both.

SimdVerlet holds some thousands of particles in flat float arrays, pulls them toward each other along up to four constraints each, and integrates them with Verlet. It is a CPU solver with no renderer of its own and no geometry of its own: it deals in particle indices, and the sibling files in this package are what turn those into things. Cloth wires a rectangular grid of constraints, Strand a chain, Collider the world to hit, Tearing the edges that give way, Wind the air.

Three of its nested types carry the state:

  • State3 is structure-of-arrays: x/y/z, the previous positions px/py/pz, the force accumulators ax/ay/az, and an optional invMass. A particle is an index into all of those at once rather than an object, invMass == 0 is how one is pinned, and the accumulators are scratch that step zeroes at the top of every frame.
  • DirectedConstraints4 gives every particle four outgoing distance constraints: four neighbour indices, four rest lengths, four stiffnesses, in parallel arrays. Four is the up/down/left/right neighbourhood of an interior grid particle, so a cloth's whole topology fits with nothing spare. DirectedConstraints3 is the same with three.
  • ConstraintScratch is the gather buffer the 4-slot solve needs, one per worker thread.

Directed is literal. A constraint i -> j writes its correction only to i and never scatters back to j, so disjoint particle ranges can be solved on separate threads with a scratch each and the inner loop vectorises. The price is that a symmetric spring is two constraints, and that it is possible to wire only one of them.

The frame

step runs, in this order: queued removals, zero the accumulators, gravity, the per-particle force generators, the registered bulkForces, the caller's accumulateForces, iterations passes of the constraint solve, Verlet integration, the registered positionProjections, and the registered constraintEdits. Everything up to integration is force space; those last two stages are the only places in a frame where positions are written directly.

Those three maps are how a layer in another file joins the frame without step knowing about it: a whole-state force goes in bulkForces, a collider in positionProjections, tearing in constraintEdits. Each is named and iterated in registration order, and each property's own KDoc has why its stage sits where it does.

The kernels

The companion carries the passes themselves as functions over arrays, each in a whole-state form and a ...Range form taking start and endExclusive: zeroing, Verlet integration, the 3- and 4-slot constraint solves, a velocity readback, and a spring toward a target point. The range forms are what let ForceExecutor hand contiguous chunks to a fixed thread pool. That chunking is coarse, two tasks per worker over a 256-particle minimum, because the target here is thousands of particles rather than millions.

The solves and the bulk passes use the JDK Incubator Vector API, so a JVM that runs them needs --add-modules jdk.incubator.vector. Field's own launches pass it already.

The number to know before tuning anything is the product stiffness * iterations. These are not two independent knobs: the solve adds iterations corrections into the same accumulator with nothing moving in between, so stiffness and iteration count multiply, and the product has a ceiling past which an explicit integrator diverges. dev_docs/physics/SIMDVERLET_4_CONSTRAINTS.md has the measurements.

class SimdVerlet

SIMD Verlet kernels and coarse chunk-parallel utilities for small/medium particle counts.

Runtime requirement: --add-modules jdk.incubator.vector

class SimdVerlet(
    val state: State3,
    var constraints: DirectedConstraints3? = null
) {

var constraints4: DirectedConstraints4? = null

The 4-slot constraint set this simulation belongs to, if any. step's 4-slot overload still takes its constraints explicitly (defaulting it would make the two overloads ambiguous), so this is a place for a builder -- e.g. Cloth -- to hand its constraint set to whoever ends up driving the simulation:

sim.step(dt, damping, gravity, iterations, sim.constraints4)

Only one of constraints / constraints4 can be solved per step; setting this one does not make the 3-slot overload see it.

val bulkForces: MutableMap<String, (state: State3, dt: Float) -> Unit> = LinkedHashMap()

Named whole-state force passes, run on every step after gravity and the per-particle force generators and before the constraint solve -- the same slot as step()'s accumulateForces argument, but registered on the simulation rather than per call, so a force that belongs to a group of particles (see Cloth's curvature force) can be added once and then survive plain sim.step(...) calls made by somebody else.

Each pass accumulates into state.ax/ay/az exactly like accumulateForces; see step for the accumulator contract. Register with addBulkForce, drop with removeBulkForce. Don't add or remove entries from inside a pass -- the map is iterated live.

val positionProjections: MutableMap<String, (state: State3, dt: Float) -> Unit> = LinkedHashMap()

Named position-projection passes, run by step after integration, in registration order — the world layer's slot, and the one place in the frame where positions may be written directly.

This is where a collider goes, and the reason it is a stage of its own rather than one more entry in bulkForces is arithmetic rather than taste. The constraint solve is force-based: solveDirectedDist4InPlaceRange runs iterations passes that each add their correction to the same accumulator with nothing moving in between, so a cloth spring of stiffness k acts at k * iterations, while a penalty force registered in bulkForces runs exactly once. At the shipped 60 x 16 a penalty collider would be sixteen times softer than the springs it has to overpower, and stiffening it by sixteen walks into the same k * dt^2 / m divergence the stiffness table maps. A projection has no stiffness to get wrong: it puts the particle on the surface and rewrites its Verlet velocity, so it holds at any spring setting the cloth itself survives. Measured, with the numbers, in dev_docs/physics/SIMDVERLET_5_WORLD.md.

Each pass gets (state, dt) and may write x/y/z and px/py/pz for indices in the active range. Register with addPositionProjection, drop with removePositionProjection. Don't add or remove entries from inside a pass -- the map is iterated live.

    val positionProjections: MutableMap<String, (state: State3, dt: Float) -> Unit> = LinkedHashMap()

val constraintEdits: MutableMap<String, (state: State3, dt: Float) -> Unit> = LinkedHashMap()

Named constraint-editing passes, run by step after positionProjections, in registration order -- the last thing in a frame.

This is where tearing goes. It runs after the colliders on purpose: an edge that a collider has just stretched over the shoulder of a sphere really is that stretched, and a tear model that measured strain before the projection would be describing a configuration the frame never ended in.

Each pass gets (state, dt) and may retire slots in the simulation's constraint set. Same live-iteration caveat as bulkForces. Register with addConstraintEdit, drop with removeConstraintEdit.

class ForceContext

    class ForceContext(
        val state: State3,
        var index: Int,
        var dt: Float
    )

val size: Int

    val size: Int
        get() = state.size

fun addZeroed(count: Int = 1): Int = state.addZeroed(count)

fun addParticle

    fun addParticle(
        x0: Float = 0f,
        y0: Float = 0f,
        z0: Float = 0f,
        px0: Float = x0,
        py0: Float = y0,
        pz0: Float = z0,
        ax0: Float = 0f,
        ay0: Float = 0f,
        az0: Float = 0f,
        invMass0: Float = 1f
    ): Int = state.addParticle(x0, y0, z0, px0, py0, pz0, ax0, ay0, az0, invMass0)

fun addForceGenerator(index: Int, name: String, generator: Generator<ForceContext, Unit>)

    fun addForceGenerator(index: Int, name: String, generator: Generator<ForceContext, Unit>) {
        require(index in 0 until state.size) { "index out of bounds" }
        state.forceGenerators[index][name] = generator
    }

fun removeForceGenerator(index: Int, name: String)

Drops the named force generator from particle index, if it has one.

    fun removeForceGenerator(index: Int, name: String) {
        require(index in 0 until state.size) { "index out of bounds" }
        state.forceGenerators[index].remove(name)
    }

fun addBulkForce(name: String, force: (state: State3, dt: Float) -> Unit) {

Registers (or replaces) a named whole-state force pass. See bulkForces.

    fun addBulkForce(name: String, force: (state: State3, dt: Float) -> Unit) {
        bulkForces[name] = force
    }

fun removeBulkForce(name: String)

Drops the named whole-state force pass, if it is registered. See bulkForces.

    fun removeBulkForce(name: String) {
        bulkForces.remove(name)
    }

fun addPositionProjection(name: String, projection: (state: State3, dt: Float) -> Unit) {

Registers (or replaces) a named post-integration projection. See positionProjections.

    fun addPositionProjection(name: String, projection: (state: State3, dt: Float) -> Unit) {
        positionProjections[name] = projection
    }

fun removePositionProjection(name: String)

Drops the named position-projection pass, if it is registered. See positionProjections.

    fun removePositionProjection(name: String) {
        positionProjections.remove(name)
    }

fun addConstraintEdit(name: String, edit: (state: State3, dt: Float) -> Unit) {

Registers (or replaces) a named constraint-editing pass. See constraintEdits.

    fun addConstraintEdit(name: String, edit: (state: State3, dt: Float) -> Unit) {
        constraintEdits[name] = edit
    }

fun removeConstraintEdit(name: String)

Drops the named constraint-editing pass, if it is registered. See constraintEdits.

    fun removeConstraintEdit(name: String) {
        constraintEdits.remove(name)
    }

var diverged: Boolean

Set once this simulation has reported a non-finite position, so it says so once.

    @Volatile
    var diverged: Boolean = false
        private set

A run whose positions go non-finite draws nothing at all, which looks like a quiet stop. Say so, once: on the running box's output and the warning,physics log channel. Nothing is clamped.

    private fun reportDivergence(dt: Float, iterations: Int) {
        if (diverged) return
        val n = state.size
        for (i in 0 until n) {
            if (state.x[i].isFinite() && state.y[i].isFinite() && state.z[i].isFinite()) continue
            diverged = true
            val message = "SimdVerlet diverged: particle $i is no longer finite (dt=$dt, iterations=$iterations). " +
                "A stiffness × iterations × dt² product well past 1, or a force that grows with distance, blows up; " +
                "lower the stiffness or dt, or raise the damping."
            afield.app.diagnostics.logging.lprintln("warning,physics", message)
            afield.document.graph.Box.context.get()?.println(message)
            return
        }
    }

The two post-integration stages, in their fixed order: every positionProjections pass, then every constraintEdits pass. Both step overloads end here.

    private fun runPostIntegration(dt: Float) {
        if (positionProjections.isNotEmpty()) {
            for (projection in positionProjections.values) {
                projection(state, dt)
            }
        }
        if (constraintEdits.isNotEmpty()) {
            for (edit in constraintEdits.values) {
                edit(state, dt)
            }
        }
    }

fun addPidPositionController

body — 71 lines, from line 263
    fun addPidPositionController(
        index: Int,
        name: String,
        targetX: () -> Float,
        targetY: () -> Float,
        targetZ: () -> Float,
        kp: Float = 5f,
        ki: Float = 0.1f,
        kd: Float = 1.5f,
        integralLimit: Float = 10f,
        maxForce: Float = 1000f,
    ) {
        addForceGenerator(
            index = index,
            name = name,
            generator = generator2 {
                var ix = 0f
                var iy = 0f
                var iz = 0f
                var prevEx = 0f
                var prevEy = 0f
                var prevEz = 0f

                while (true) {
                    val ctx = yield()
                    val dt = ctx.dt
                    if (dt <= 0f) continue

                    val i = ctx.index
                    val sx = ctx.state.x[i]
                    val sy = ctx.state.y[i]
                    val sz = ctx.state.z[i]

                    val ex = targetX() - sx
                    val ey = targetY() - sy
                    val ez = targetZ() - sz

                    ix += ex * dt
                    iy += ey * dt
                    iz += ez * dt

                    if (integralLimit.isFinite()) {
                        if (ix > integralLimit) ix = integralLimit else if (ix < -integralLimit) ix = -integralLimit
                        if (iy > integralLimit) iy = integralLimit else if (iy < -integralLimit) iy = -integralLimit
                        if (iz > integralLimit) iz = integralLimit else if (iz < -integralLimit) iz = -integralLimit
                    }

                    val dx = (ex - prevEx) / dt
                    val dy = (ey - prevEy) / dt
                    val dz = (ez - prevEz) / dt
                    prevEx = ex
                    prevEy = ey
                    prevEz = ez

                    var fx = kp * ex + ki * ix + kd * dx
                    var fy = kp * ey + ki * iy + kd * dy
                    var fz = kp * ez + ki * iz + kd * dz

                    if (maxForce.isFinite()) {
                        if (fx > maxForce) fx = maxForce else if (fx < -maxForce) fx = -maxForce
                        if (fy > maxForce) fy = maxForce else if (fy < -maxForce) fy = -maxForce
                        if (fz > maxForce) fz = maxForce else if (fz < -maxForce) fz = -maxForce
                    }

                    ctx.state.ax[i] += fx
                    ctx.state.ay[i] += fy
                    ctx.state.az[i] += fz
                }
            }
        )
    }

fun resize(newSize: Int, zeroNew: Boolean = true)

    fun resize(newSize: Int, zeroNew: Boolean = true) {
        state.resize(newSize, zeroNew)
    }

internal val clothRanges = ArrayList<Pair<String, IntRange>>()

The index ranges of the cloths added to this simulation, by name; see queueRemove.

A removal compacts and renumbers the particles above it, which would leave a cloth's contiguous index range pointing at the wrong particles. So a removal inside or below a cloth is refused; remove particles above every cloth, or add the cloth last.

    private fun refuseRemovalUnderCloth(lowest: Int) {
        val hit = clothRanges.firstOrNull { (_, range) -> lowest <= range.last } ?: return
        throw IllegalArgumentException(
            "removing particle $lowest would renumber cloth '${hit.first}' (particles ${hit.second.first}..${hit.second.last}); " +
                "only particles above every cloth can be removed"
        )
    }

fun queueRemove(index: Int)

    fun queueRemove(index: Int) {
        refuseRemovalUnderCloth(index)
        state.queueRemove(index)
    }

fun queueRemoveRange(start: Int, count: Int)

    fun queueRemoveRange(start: Int, count: Int) {
        if (count > 0) refuseRemovalUnderCloth(start)
        state.queueRemoveRange(start, count)
    }

fun applyQueuedRemovalsSwap()

    fun applyQueuedRemovalsSwap() {
        val c4 = constraints4
        if (c4 != null) {
            state.applyQueuedRemovalsSwap(c4)
        } else {
            state.applyQueuedRemovalsSwap(constraints)
        }
    }

fun step

Advances the simulation one frame. The force accumulators (ax/ay/az) are treated as scratch: they are zeroed at the start of every step, then rebuilt from gravity, the per-particle force generators, accumulateForces, and the constraint solve, and finally consumed by integration. So anything written to ax/ay/az before calling step() is discarded — inject per-frame forces through a force generator (per particle) or through accumulateForces (whole-range, e.g. the bulk SIMD kernels), never by pre-accumulating.

The whole frame, in order: queued removals, zero the accumulators, gravity, the per-particle force generators, bulkForces, accumulateForces, iterations of the constraint solve, Verlet integration, positionProjections (the colliders), and finally constraintEdits (tearing). Everything up to integration is force space; the last two stages are the only ones that touch positions directly.

@param gravity added to every particle's y acceleration. Pass it negative (-9.8f) for a +y-up world, which is what the renderers and the path tracer use; positive suits a y-down screen layout. @param accumulateForces optional bulk force pass invoked as (state, dt) after gravity and the per-particle generators but before the constraint solve, so its contribution feeds the solve and integration. This is the hook for the generated bulk kernels, e.g. sim.step(dt, damping, g, iters) { s, _ -> SimdVerlet.addSpringForceToTarget3(s, tx, ty, tz, k, sx, sy, sz) }. Stack several bulk kernels here to combine force fields in one frame.

body — 53 lines, from line 397
    fun step(
        dt: Float,
        damping: Float,
        gravity: Float,
        iterations: Int,
        constraints: DirectedConstraints3? = this.constraints,
        accumulateForces: ((state: State3, dt: Float) -> Unit)? = null
    ) {
        // With no 3-slot set, a published 4-slot one (what Cloth builds) is the set to solve; skipping
        // it would let the sheet fall apart with no error.
        val published4 = constraints4
        if (constraints == null && published4 != null) {
            step(dt, damping, gravity, iterations, published4, accumulateForces)
            return
        }
        state.applyQueuedRemovalsSwap(constraints)
        zero3(state)

        if (gravity != 0f) {
            val ay = state.ay
            val n = state.size
            for (i in 0 until n) {
                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)
        }

        for (force in bulkForces.values) {
            force(state, dt)
        }

        accumulateForces?.invoke(state, dt)

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

        integrateVerlet3(state, dt = dt, damping = damping)
        runPostIntegration(dt)
        reportDivergence(dt, iterations)
    }

fun step

4-slot-constraint variant of step; see that overload for the force-accumulation contract.

body — 46 lines, from line 452
    fun step(
        dt: Float,
        damping: Float,
        gravity: Float,
        iterations: Int,
        constraints: DirectedConstraints4?,
        accumulateForces: ((state: State3, dt: Float) -> Unit)? = null
    ) {
        state.applyQueuedRemovalsSwap(constraints)
        zero3(state)

        if (gravity != 0f) {
            val ay = state.ay
            val n = state.size
            for (i in 0 until n) {
                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)
        }

        for (force in bulkForces.values) {
            force(state, dt)
        }

        accumulateForces?.invoke(state, dt)

        if (constraints != null && iterations > 0) {
            solveDirectedDist4InPlace(
                state = state,
                constraints = constraints,
                iterations = iterations
            )
        }

        integrateVerlet3(state, dt = dt, damping = damping)
        runPostIntegration(dt)
        reportDivergence(dt, iterations)
    }

class State3

Particle state in SoA form. invMass is optional. If present, integration treats ax/ay/az as forces and scales by invMass. If absent, ax/ay/az are treated as accelerations.

size is the active particle count; limit is the backing array capacity.

    class State3(
        var x: FloatArray,
        var y: FloatArray,
        var z: FloatArray,
        var px: FloatArray,
        var py: FloatArray,
        var pz: FloatArray,
        var ax: FloatArray,
        var ay: FloatArray,
        var az: FloatArray,
        var invMass: FloatArray? = null
    ) {

var size: Int

        var size: Int = x.size
            private set

var limit: Int

        var limit: Int = x.size
            private set

        private var pendingRemovals = IntArray(16)
        private var pendingRemovalCount = 0

internal var forceGenerators: Array<MapOfGenerators<ForceContext, Unit>>

        internal var forceGenerators: Array<MapOfGenerators<ForceContext, Unit>> =
            Array(limit) { MapOfGenerators() }

internal var forceContexts: Array<ForceContext>

init

        init {
            require(y.size == size && z.size == size) { "x/y/z size mismatch" }
            require(px.size == size && py.size == size && pz.size == size) { "prev position size mismatch" }
            require(ax.size == size && ay.size == size && az.size == size) { "accumulator size mismatch" }
            require(invMass == null || invMass!!.size == size) { "invMass size mismatch" }
            forceContexts = Array(limit) { ForceContext(this, it, 0f) }
        }

fun ensureCapacity(minCapacity: Int)

body — 44 lines, from line 538
        fun ensureCapacity(minCapacity: Int) {
            if (minCapacity <= limit) return

            val grown = limit + max(1, limit / 2)
            val newLimit = max(minCapacity, grown)

            fun grow(src: FloatArray): FloatArray {
                val dst = FloatArray(newLimit)
                System.arraycopy(src, 0, dst, 0, size)
                return dst
            }

            x = grow(x)
            y = grow(y)
            z = grow(z)
            px = grow(px)
            py = grow(py)
            pz = grow(pz)
            ax = grow(ax)
            ay = grow(ay)
            az = grow(az)
            invMass = invMass?.let { grow(it) }
            if (forceGenerators.size < newLimit) {
                val next = arrayOfNulls<MapOfGenerators<ForceContext, Unit>>(newLimit)
                System.arraycopy(forceGenerators, 0, next, 0, forceGenerators.size)
                for (i in forceGenerators.size until newLimit) {
                    next[i] = MapOfGenerators()
                }
                @Suppress("UNCHECKED_CAST")
                val updated = next as Array<MapOfGenerators<ForceContext, Unit>>
                forceGenerators = updated
            }
            if (forceContexts.size < newLimit) {
                val next = arrayOfNulls<ForceContext>(newLimit)
                System.arraycopy(forceContexts, 0, next, 0, forceContexts.size)
                for (i in forceContexts.size until newLimit) {
                    next[i] = ForceContext(this, i, 0f)
                }
                @Suppress("UNCHECKED_CAST")
                val updated = next as Array<ForceContext>
                forceContexts = updated
            }
            limit = newLimit
        }

fun resize(newSize: Int, zeroNew: Boolean = true)

        fun resize(newSize: Int, zeroNew: Boolean = true) {
            require(newSize >= 0) { "size must be >= 0" }
            if (newSize > limit) {
                ensureCapacity(newSize)
            }

            if (zeroNew && newSize > size) {
                for (i in size until newSize) {
                    x[i] = 0f
                    y[i] = 0f
                    z[i] = 0f
                    px[i] = 0f
                    py[i] = 0f
                    pz[i] = 0f
                    ax[i] = 0f
                    ay[i] = 0f
                    az[i] = 0f
                    invMass?.let { it[i] = 1f }
                }
            }

            size = newSize
        }

fun addZeroed(count: Int = 1): Int

        fun addZeroed(count: Int = 1): Int {
            require(count >= 0) { "count must be >= 0" }
            if (count == 0) return size
            val start = size
            resize(size + count, zeroNew = true)
            return start
        }

fun addParticle

        fun addParticle(
            x0: Float = 0f,
            y0: Float = 0f,
            z0: Float = 0f,
            px0: Float = x0,
            py0: Float = y0,
            pz0: Float = z0,
            ax0: Float = 0f,
            ay0: Float = 0f,
            az0: Float = 0f,
            invMass0: Float = 1f
        ): Int {
            val index = addZeroed(1)
            x[index] = x0
            y[index] = y0
            z[index] = z0
            px[index] = px0
            py[index] = py0
            pz[index] = pz0
            ax[index] = ax0
            ay[index] = ay0
            az[index] = az0
            invMass?.let { it[index] = invMass0 }
            return index
        }

fun removeAt(index: Int, count: Int = 1, preserveOrder: Boolean = true)

body — 48 lines, from line 641
        fun removeAt(index: Int, count: Int = 1, preserveOrder: Boolean = true) {
            require(index >= 0 && count >= 0 && index + count <= size) {
                "remove range out of bounds"
            }
            if (count == 0) return

            if (preserveOrder) {
                val tailStart = index + count
                val tailCount = size - tailStart
                if (tailCount > 0) {
                    System.arraycopy(x, tailStart, x, index, tailCount)
                    System.arraycopy(y, tailStart, y, index, tailCount)
                    System.arraycopy(z, tailStart, z, index, tailCount)
                    System.arraycopy(px, tailStart, px, index, tailCount)
                    System.arraycopy(py, tailStart, py, index, tailCount)
                    System.arraycopy(pz, tailStart, pz, index, tailCount)
                    System.arraycopy(ax, tailStart, ax, index, tailCount)
                    System.arraycopy(ay, tailStart, ay, index, tailCount)
                    System.arraycopy(az, tailStart, az, index, tailCount)
                    System.arraycopy(forceGenerators, tailStart, forceGenerators, index, tailCount)
                    invMass?.let { System.arraycopy(it, tailStart, it, index, tailCount) }
                }
            } else {
                var dst = index
                var src = size - 1
                var remaining = count
                while (remaining > 0 && dst <= src) {
                    if (dst != src) {
                        x[dst] = x[src]
                        y[dst] = y[src]
                        z[dst] = z[src]
                        px[dst] = px[src]
                        py[dst] = py[src]
                        pz[dst] = pz[src]
                        ax[dst] = ax[src]
                        ay[dst] = ay[src]
                        az[dst] = az[src]
                        forceGenerators[dst] = forceGenerators[src]
                        invMass?.let { it[dst] = it[src] }
                    }
                    dst++
                    src--
                    remaining--
                }
            }

            size -= count
        }

fun queueRemove(index: Int)

        fun queueRemove(index: Int) {
            require(index in 0 until size) { "remove index out of bounds" }
            ensureRemovalCapacity(pendingRemovalCount + 1)
            pendingRemovals[pendingRemovalCount] = index
            pendingRemovalCount++
        }

fun queueRemoveRange(start: Int, count: Int)

        fun queueRemoveRange(start: Int, count: Int) {
            require(count >= 0) { "count must be >= 0" }
            require(start >= 0 && start + count <= size) { "remove range out of bounds" }
            if (count == 0) return
            ensureRemovalCapacity(pendingRemovalCount + count)
            for (i in 0 until count) {
                pendingRemovals[pendingRemovalCount + i] = start + i
            }
            pendingRemovalCount += count
        }

companion object

        companion object {
fun empty(capacity: Int = 0, withInvMass: Boolean = true): State3

An empty state, ready to be filled with addParticle — the ten FloatArray(0) arguments the SoA constructor otherwise wants spelled out at every call site.

@param capacity room to reserve up front; the arrays grow on demand either way. @param withInvMass allocate the optional invMass array. Keep it (the default): without it every particle has unit mass and nothing can be pinned, because pinning is an inverse mass of zero.

            fun empty(capacity: Int = 0, withInvMass: Boolean = true): State3 {
                require(capacity >= 0) { "capacity must be >= 0" }
                val state = State3(
                    x = FloatArray(0), y = FloatArray(0), z = FloatArray(0),
                    px = FloatArray(0), py = FloatArray(0), pz = FloatArray(0),
                    ax = FloatArray(0), ay = FloatArray(0), az = FloatArray(0),
                    invMass = if (withInvMass) FloatArray(0) else null
                )
                if (capacity > 0) state.ensureCapacity(capacity)
                return state
            }
        }

fun applyQueuedRemovalsSwap(constraints: DirectedConstraints3? = null)

body — 52 lines, from line 731
        fun applyQueuedRemovalsSwap(constraints: DirectedConstraints3? = null) {
            if (pendingRemovalCount == 0) return

            val oldSize = size
            val deleted = BooleanArray(oldSize)
            for (i in 0 until pendingRemovalCount) {
                val idx = pendingRemovals[i]
                if (idx in 0 until oldSize) {
                    deleted[idx] = true
                }
            }

            val indexMap = IntArray(oldSize) { it }
            var last = oldSize - 1
            var i = 0
            while (i <= last) {
                if (!deleted[i]) {
                    i++
                    continue
                }

                while (last >= i && deleted[last]) {
                    last--
                }
                if (last < i) {
                    break
                }

                swapParticles(i, last)
                constraints?.swapConstraints(i, last)
                val tmp = indexMap[i]
                indexMap[i] = indexMap[last]
                indexMap[last] = tmp
                deleted[i] = false
                last--
                i++
            }

            while (last >= 0 && deleted[last]) {
                last--
            }

            val newSize = last + 1
            val remap = IntArray(oldSize) { -1 }
            for (pos in 0 until newSize) {
                remap[indexMap[pos]] = pos
            }

            constraints?.remapNeighbors(remap, newSize)
            size = newSize
            pendingRemovalCount = 0
        }

fun applyQueuedRemovalsSwap(constraints: DirectedConstraints4? = null)

body — 79 lines, from line 784
        fun applyQueuedRemovalsSwap(constraints: DirectedConstraints4? = null) {
            if (pendingRemovalCount == 0) return

            val oldSize = size
            val deleted = BooleanArray(oldSize)
            for (i in 0 until pendingRemovalCount) {
                val idx = pendingRemovals[i]
                if (idx in 0 until oldSize) {
                    deleted[idx] = true
                }
            }

            val indexMap = IntArray(oldSize) { it }
            var last = oldSize - 1
            var i = 0
            while (i <= last) {
                if (!deleted[i]) {
                    i++
                    continue
                }

                while (last >= i && deleted[last]) {
                    last--
                }
                if (last < i) {
                    break
                }

                swapParticles(i, last)
                constraints?.swapConstraints(i, last)
                val tmp = indexMap[i]
                indexMap[i] = indexMap[last]
                indexMap[last] = tmp
                deleted[i] = false
                last--
                i++
            }

            while (last >= 0 && deleted[last]) {
                last--
            }

            val newSize = last + 1
            val remap = IntArray(oldSize) { -1 }
            for (pos in 0 until newSize) {
                remap[indexMap[pos]] = pos
            }

            constraints?.remapNeighbors(remap, newSize)
            size = newSize
            pendingRemovalCount = 0
        }

        private fun ensureRemovalCapacity(minCapacity: Int) {
            if (minCapacity <= pendingRemovals.size) return
            var newCap = pendingRemovals.size
            while (newCap < minCapacity) {
                newCap = max(1, newCap * 2)
            }
            val next = IntArray(newCap)
            System.arraycopy(pendingRemovals, 0, next, 0, pendingRemovalCount)
            pendingRemovals = next
        }

        private fun swapParticles(i: Int, j: Int) {
            if (i == j) return
            swap(x, i, j)
            swap(y, i, j)
            swap(z, i, j)
            swap(px, i, j)
            swap(py, i, j)
            swap(pz, i, j)
            swap(ax, i, j)
            swap(ay, i, j)
            swap(az, i, j)
            swap(forceGenerators, i, j)
            invMass?.let { swap(it, i, j) }
        }
    }

class DirectedConstraints3

Directed 3-slot constraint layout: i -> n0i, i -> n1i, i -> n2i Unused slot: neighbor = -1 or stiffness = 0.

    class DirectedConstraints3(
        val n0: IntArray,
        val n1: IntArray,
        val n2: IntArray,
        val r0: FloatArray,
        val r1: FloatArray,
        val r2: FloatArray,
        val s0: FloatArray,
        val s1: FloatArray,
        val s2: FloatArray
    ) {

val size: Int = n0.size

init

        init {
            require(n1.size == size && n2.size == size) { "neighbor slot size mismatch" }
            require(r0.size == size && r1.size == size && r2.size == size) { "rest-length slot size mismatch" }
            require(s0.size == size && s1.size == size && s2.size == size) { "stiffness slot size mismatch" }
        }

internal fun swapConstraints(i: Int, j: Int)

        internal fun swapConstraints(i: Int, j: Int) {
            if (i == j) return
            swap(n0, i, j)
            swap(n1, i, j)
            swap(n2, i, j)
            swap(r0, i, j)
            swap(r1, i, j)
            swap(r2, i, j)
            swap(s0, i, j)
            swap(s1, i, j)
            swap(s2, i, j)
        }

internal fun remapNeighbors(remap: IntArray, newSize: Int)

        internal fun remapNeighbors(remap: IntArray, newSize: Int) {
            for (i in 0 until newSize) {
                val j0 = n0[i]
                n0[i] = if (j0 in remap.indices) remap[j0] else -1
                val j1 = n1[i]
                n1[i] = if (j1 in remap.indices) remap[j1] else -1
                val j2 = n2[i]
                n2[i] = if (j2 in remap.indices) remap[j2] else -1
            }
        }

fun setSlot

        fun setSlot(
            index: Int,
            slot: Int,
            neighbor: Int,
            restLength: Float,
            stiffness: Float
        ) {
            require(index in 0 until size) { "index out of bounds" }
            when (slot) {
                0 -> {
                    n0[index] = neighbor
                    r0[index] = restLength
                    s0[index] = stiffness
                }
                1 -> {
                    n1[index] = neighbor
                    r1[index] = restLength
                    s1[index] = stiffness
                }
                2 -> {
                    n2[index] = neighbor
                    r2[index] = restLength
                    s2[index] = stiffness
                }
                else -> error("slot must be 0, 1, or 2")
            }
        }
    }

class DirectedConstraints4

Directed distance constraints with up to 4 neighbor slots per particle (the 4-slot sibling of DirectedConstraints3). Each particle i owns outgoing constraints to n0[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240), n1[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240), n2[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240), n3[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240); an unused slot is marked with neighbor = -1 or stiffness = 0. Slot arrays are parallel: n* are neighbor indices, r* rest lengths, s* stiffnesses, all of length size. The 4th slot is what lets a regular cloth grid attach all four of its axis neighbors (up/down/left/right) to a single particle.

API surface: - setSlot (index, slot 0..3, neighbor, restLength, stiffness) — populate one slot. - clearSlots (index) — return one particle to having no outgoing constraints. - ofCapacity / ensureCapacity — allocate, and grow in place as the state grows. - swapConstraints / remapNeighbors (internal) — keep the swap-remove compaction in State.applyQueuedRemovalsSwap consistent when particles are deleted.

Solved by solveDirectedDist4InPlace / solveDirectedDist4InPlaceRange (JDK Incubator Vector API): neighbor positions are gathered through the per-slot maps in ConstraintScratch (map0..map3), distances use SIMD sqrt, and a scalar remainder-lane fallback finishes the tail. The solver is range-parallel safe because each particle only accumulates onto its own position — there are no scatter writes to neighbors — so non-overlapping particle ranges can be solved on separate threads with one scratch each.

The reusable consumer is Cloth, which wires a whole grid patch of these slots into an existing simulation (optional curvature flattening force, interactive grabbing via PID position controllers). Runnable usage: afield.examples.ClothSimulationExample (a thin demo over Cloth), afield.examples.TestDirectedConstraints4, and afield.examples.SimdVerletTinyExample (the 3-slot warmup).

    class DirectedConstraints4(
        var n0: IntArray,
        var n1: IntArray,
        var n2: IntArray,
        var n3: IntArray,
        var r0: FloatArray,
        var r1: FloatArray,
        var r2: FloatArray,
        var r3: FloatArray,
        var s0: FloatArray,
        var s1: FloatArray,
        var s2: FloatArray,
        var s3: FloatArray
    ) {

var size: Int

        var size: Int = n0.size
            private set

init

        init {
            require(n1.size == size && n2.size == size && n3.size == size) { "neighbor slot size mismatch" }
            require(r0.size == size && r1.size == size && r2.size == size && r3.size == size) { "rest-length slot size mismatch" }
            require(s0.size == size && s1.size == size && s2.size == size && s3.size == size) { "stiffness slot size mismatch" }
        }

internal fun swapConstraints(i: Int, j: Int)

        internal fun swapConstraints(i: Int, j: Int) {
            if (i == j) return
            swap(n0, i, j)
            swap(n1, i, j)
            swap(n2, i, j)
            swap(n3, i, j)
            swap(r0, i, j)
            swap(r1, i, j)
            swap(r2, i, j)
            swap(r3, i, j)
            swap(s0, i, j)
            swap(s1, i, j)
            swap(s2, i, j)
            swap(s3, i, j)
        }

internal fun remapNeighbors(remap: IntArray, newSize: Int)

        internal fun remapNeighbors(remap: IntArray, newSize: Int) {
            for (i in 0 until newSize) {
                val j0 = n0[i]
                n0[i] = if (j0 in remap.indices) remap[j0] else -1
                val j1 = n1[i]
                n1[i] = if (j1 in remap.indices) remap[j1] else -1
                val j2 = n2[i]
                n2[i] = if (j2 in remap.indices) remap[j2] else -1
                val j3 = n3[i]
                n3[i] = if (j3 in remap.indices) remap[j3] else -1
            }
        }

fun setSlot

        fun setSlot(
            index: Int,
            slot: Int,
            neighbor: Int,
            restLength: Float,
            stiffness: Float
        ) {
            require(index in 0 until size) { "index out of bounds" }
            when (slot) {
                0 -> {
                    n0[index] = neighbor
                    r0[index] = restLength
                    s0[index] = stiffness
                }
                1 -> {
                    n1[index] = neighbor
                    r1[index] = restLength
                    s1[index] = stiffness
                }
                2 -> {
                    n2[index] = neighbor
                    r2[index] = restLength
                    s2[index] = stiffness
                }
                3 -> {
                    n3[index] = neighbor
                    r3[index] = restLength
                    s3[index] = stiffness
                }
                else -> error("slot must be 0, 1, 2, or 3")
            }
        }

fun clearSlots(index: Int)

Clears every slot of particle index back to unused (neighbor -1, zero stiffness).

        fun clearSlots(index: Int) {
            require(index in 0 until size) { "index out of bounds" }
            for (slot in 0 until 4) {
                setSlot(index, slot, -1, 0f, 0f)
            }
        }

fun ensureCapacity(minCapacity: Int)

Grows every slot array so it covers at least minCapacity particles, which is what solveDirectedDist4InPlace requires of a constraint set stepped alongside a growing State3 (it solves 0 until state.size and indexes these arrays by particle index).

Existing entries and this object's identity are preserved -- the arrays are var so that everyone already holding this constraint set (a SimdVerlet.constraints4, several Clothes sharing one set) keeps seeing the grown arrays. New entries are the unused slot: neighbor -1, zero rest length, zero stiffness.

        fun ensureCapacity(minCapacity: Int) {
            if (minCapacity <= size) return
            val newSize = max(minCapacity, size + max(1, size / 2))
            val oldSize = size

            fun growNeighbors(src: IntArray): IntArray {
                val dst = IntArray(newSize) { -1 }
                System.arraycopy(src, 0, dst, 0, oldSize)
                return dst
            }

            fun growFloats(src: FloatArray): FloatArray {
                val dst = FloatArray(newSize)
                System.arraycopy(src, 0, dst, 0, oldSize)
                return dst
            }

            n0 = growNeighbors(n0)
            n1 = growNeighbors(n1)
            n2 = growNeighbors(n2)
            n3 = growNeighbors(n3)
            r0 = growFloats(r0)
            r1 = growFloats(r1)
            r2 = growFloats(r2)
            r3 = growFloats(r3)
            s0 = growFloats(s0)
            s1 = growFloats(s1)
            s2 = growFloats(s2)
            s3 = growFloats(s3)
            size = newSize
        }

companion object

        companion object {
fun ofCapacity(capacity: Int): DirectedConstraints4

An all-slots-unused constraint set covering capacity particles.

            fun ofCapacity(capacity: Int): DirectedConstraints4 {
                require(capacity >= 0) { "capacity must be >= 0" }
                return DirectedConstraints4(
                    n0 = IntArray(capacity) { -1 },
                    n1 = IntArray(capacity) { -1 },
                    n2 = IntArray(capacity) { -1 },
                    n3 = IntArray(capacity) { -1 },
                    r0 = FloatArray(capacity),
                    r1 = FloatArray(capacity),
                    r2 = FloatArray(capacity),
                    r3 = FloatArray(capacity),
                    s0 = FloatArray(capacity),
                    s1 = FloatArray(capacity),
                    s2 = FloatArray(capacity),
                    s3 = FloatArray(capacity)
                )
            }
        }
    }

class ConstraintScratch(private val species: VectorSpecies<Float>)

Scratch buffers for constraint gather maps, reusable across frames. Keep one per worker thread to avoid allocations in the hot loop.

internal val map0 = IntArray(species.length())

internal val map1 = IntArray(species.length())

internal val map2 = IntArray(species.length())

internal val map3 = IntArray(species.length())

companion object

    companion object {

Best default for contiguous, non-gather passes.

        private val SP: VectorSpecies<Float> = FloatVector.SPECIES_PREFERRED

Gather-heavy constraints are often better with fixed 128-bit vectors.

        private val SG: VectorSpecies<Float> = FloatVector.SPECIES_128

        private val scratchTLS = ThreadLocal.withInitial { ConstraintScratch(SG) }

fun createConstraintScratch(): ConstraintScratch = ConstraintScratch(SG)

fun zero3(state: State3) = zero3Range(state.ax, state.ay, state.az, 0, state.size)

1) Zero accumulators (SIMD)

fun zero3(ax: FloatArray, ay: FloatArray, az: FloatArray)

    fun zero3(ax: FloatArray, ay: FloatArray, az: FloatArray) {
        require(ay.size == ax.size && az.size == ax.size) { "accumulator size mismatch" }
        zero3Range(ax, ay, az, 0, ax.size)
    }

fun zero3Range(ax: FloatArray, ay: FloatArray, az: FloatArray, start: Int, endExclusive: Int)

    fun zero3Range(ax: FloatArray, ay: FloatArray, az: FloatArray, start: Int, endExclusive: Int) {
        require(ay.size == ax.size && az.size == ax.size) { "accumulator size mismatch" }
        val n = ax.size
        val startIdx = start.coerceIn(0, n)
        val endIdx = endExclusive.coerceIn(startIdx, n)

        val zero = FloatVector.zero(SP)
        val lanes = SP.length()

        var i = startIdx
        while (i + lanes <= endIdx) {
            zero.intoArray(ax, i)
            zero.intoArray(ay, i)
            zero.intoArray(az, i)
            i += lanes
        }
        while (i < endIdx) {
            ax[i] = 0f
            ay[i] = 0f
            az[i] = 0f
            i++
        }
    }

fun integrateVerlet3(state: State3, dt: Float, damping: Float)

2) Verlet integrate + damping (SIMD) new = x + (x - px)damping + acceldt^2 px = old x

    fun integrateVerlet3(state: State3, dt: Float, damping: Float) {
        integrateVerlet3Range(
            x = state.x, y = state.y, z = state.z,
            px = state.px, py = state.py, pz = state.pz,
            ax = state.ax, ay = state.ay, az = state.az,
            start = 0,
            endExclusive = state.size,
            dt = dt,
            damping = damping,
            invMass = state.invMass
        )
    }

fun integrateVerlet3

    fun integrateVerlet3(
        x: FloatArray, y: FloatArray, z: FloatArray,
        px: FloatArray, py: FloatArray, pz: FloatArray,
        ax: FloatArray, ay: FloatArray, az: FloatArray,
        dt: Float,
        damping: Float,
        invMass: FloatArray? = null
    ) {
        val n = x.size
        require(y.size == n && z.size == n) { "position size mismatch" }
        require(px.size == n && py.size == n && pz.size == n) { "prev position size mismatch" }
        require(ax.size == n && ay.size == n && az.size == n) { "accumulator size mismatch" }
        require(invMass == null || invMass.size == n) { "invMass size mismatch" }

        integrateVerlet3Range(
            x, y, z,
            px, py, pz,
            ax, ay, az,
            start = 0,
            endExclusive = n,
            dt = dt,
            damping = damping,
            invMass = invMass
        )
    }

fun integrateVerlet3Range

body — 134 lines, from line 1230
    fun integrateVerlet3Range(
        x: FloatArray, y: FloatArray, z: FloatArray,
        px: FloatArray, py: FloatArray, pz: FloatArray,
        ax: FloatArray, ay: FloatArray, az: FloatArray,
        start: Int,
        endExclusive: Int,
        dt: Float,
        damping: Float,
        invMass: FloatArray? = null
    ) {
        val n = x.size
        require(y.size == n && z.size == n) { "position size mismatch" }
        require(px.size == n && py.size == n && pz.size == n) { "prev position size mismatch" }
        require(ax.size == n && ay.size == n && az.size == n) { "accumulator size mismatch" }
        require(invMass == null || invMass.size == n) { "invMass size mismatch" }

        val startIdx = start.coerceIn(0, n)
        val endIdx = endExclusive.coerceIn(startIdx, n)
        val dt2 = dt * dt

        val lanes = SP.length()
        val vDamping = FloatVector.broadcast(SP, damping)
        val vDt2 = FloatVector.broadcast(SP, dt2)

        var i = startIdx
        if (invMass == null) {
            while (i + lanes <= endIdx) {
                val vx = FloatVector.fromArray(SP, x, i)
                val vy = FloatVector.fromArray(SP, y, i)
                val vz = FloatVector.fromArray(SP, z, i)

                val vpx = FloatVector.fromArray(SP, px, i)
                val vpy = FloatVector.fromArray(SP, py, i)
                val vpz = FloatVector.fromArray(SP, pz, i)

                val vax = FloatVector.fromArray(SP, ax, i)
                val vay = FloatVector.fromArray(SP, ay, i)
                val vaz = FloatVector.fromArray(SP, az, i)

                val velx = vx.sub(vpx).mul(vDamping)
                val vely = vy.sub(vpy).mul(vDamping)
                val velz = vz.sub(vpz).mul(vDamping)

                val nx = vx.add(velx).add(vax.mul(vDt2))
                val ny = vy.add(vely).add(vay.mul(vDt2))
                val nz = vz.add(velz).add(vaz.mul(vDt2))

                vx.intoArray(px, i)
                vy.intoArray(py, i)
                vz.intoArray(pz, i)

                nx.intoArray(x, i)
                ny.intoArray(y, i)
                nz.intoArray(z, i)

                i += lanes
            }

            while (i < endIdx) {
                val ox = x[i]
                val oy = y[i]
                val oz = z[i]

                val velx = (ox - px[i]) * damping
                val vely = (oy - py[i]) * damping
                val velz = (oz - pz[i]) * damping

                px[i] = ox
                py[i] = oy
                pz[i] = oz

                x[i] = ox + velx + ax[i] * dt2
                y[i] = oy + vely + ay[i] * dt2
                z[i] = oz + velz + az[i] * dt2
                i++
            }
            return
        }

        while (i + lanes <= endIdx) {
            val vx = FloatVector.fromArray(SP, x, i)
            val vy = FloatVector.fromArray(SP, y, i)
            val vz = FloatVector.fromArray(SP, z, i)

            val vpx = FloatVector.fromArray(SP, px, i)
            val vpy = FloatVector.fromArray(SP, py, i)
            val vpz = FloatVector.fromArray(SP, pz, i)

            val vax = FloatVector.fromArray(SP, ax, i)
            val vay = FloatVector.fromArray(SP, ay, i)
            val vaz = FloatVector.fromArray(SP, az, i)

            val vim = FloatVector.fromArray(SP, invMass, i)
            val accelScale = vim.mul(vDt2)

            val velx = vx.sub(vpx).mul(vDamping)
            val vely = vy.sub(vpy).mul(vDamping)
            val velz = vz.sub(vpz).mul(vDamping)

            val nx = vx.add(velx).add(vax.mul(accelScale))
            val ny = vy.add(vely).add(vay.mul(accelScale))
            val nz = vz.add(velz).add(vaz.mul(accelScale))

            vx.intoArray(px, i)
            vy.intoArray(py, i)
            vz.intoArray(pz, i)

            nx.intoArray(x, i)
            ny.intoArray(y, i)
            nz.intoArray(z, i)

            i += lanes
        }

        while (i < endIdx) {
            val ox = x[i]
            val oy = y[i]
            val oz = z[i]
            val im = invMass[i]

            val velx = (ox - px[i]) * damping
            val vely = (oy - py[i]) * damping
            val velz = (oz - pz[i]) * damping

            px[i] = ox
            py[i] = oy
            pz[i] = oz

            x[i] = ox + velx + ax[i] * dt2 * im
            y[i] = oy + vely + ay[i] * dt2 * im
            z[i] = oz + velz + az[i] * dt2 * im
            i++
        }
    }

fun solveDirectedDist3InPlace

3) Directed 3-slot distance constraints (SIMD, force accumulation) Each i has up to 3 outgoing constraints to neighbor j. We update only i's accumulators (no scatter), so range-parallel calls are safe.

    fun solveDirectedDist3InPlace(
        state: State3,
        constraints: DirectedConstraints3,
        iterations: Int,
        scratch: ConstraintScratch = scratchTLS.get()
    ) {
        solveDirectedDist3InPlaceRange(
            x = state.x, y = state.y, z = state.z,
            ax = state.ax, ay = state.ay, az = state.az,
            n0 = constraints.n0, n1 = constraints.n1, n2 = constraints.n2,
            r0 = constraints.r0, r1 = constraints.r1, r2 = constraints.r2,
            s0 = constraints.s0, s1 = constraints.s1, s2 = constraints.s2,
            start = 0,
            endExclusive = state.size,
            iterations = iterations,
            scratch = scratch
        )
    }

fun solveDirectedDist3InPlace

    fun solveDirectedDist3InPlace(
        x: FloatArray, y: FloatArray, z: FloatArray,
        ax: FloatArray, ay: FloatArray, az: FloatArray,
        n0: IntArray, n1: IntArray, n2: IntArray,
        r0: FloatArray, r1: FloatArray, r2: FloatArray,
        s0: FloatArray, s1: FloatArray, s2: FloatArray,
        iterations: Int,
        scratch: ConstraintScratch = scratchTLS.get()
    ) {
        solveDirectedDist3InPlaceRange(
            x, y, z,
            ax, ay, az,
            n0, n1, n2,
            r0, r1, r2,
            s0, s1, s2,
            start = 0,
            endExclusive = x.size,
            iterations = iterations,
            scratch = scratch
        )
    }

fun solveDirectedDist3InPlaceRange

body — 226 lines, from line 1411
    fun solveDirectedDist3InPlaceRange(
        x: FloatArray, y: FloatArray, z: FloatArray,
        ax: FloatArray, ay: FloatArray, az: FloatArray,
        n0: IntArray, n1: IntArray, n2: IntArray,
        r0: FloatArray, r1: FloatArray, r2: FloatArray,
        s0: FloatArray, s1: FloatArray, s2: FloatArray,
        start: Int,
        endExclusive: Int,
        iterations: Int,
        scratch: ConstraintScratch = scratchTLS.get()
    ) {
        require(iterations >= 0) { "iterations must be >= 0" }
        if (iterations == 0) return

        val n = x.size
        require(y.size == n && z.size == n) { "position size mismatch" }
        require(ax.size == n && ay.size == n && az.size == n) { "accumulator size mismatch" }
        require(endExclusive <= n) { "endExclusive exceeds position size" }
        require(n0.size >= endExclusive && n1.size >= endExclusive && n2.size >= endExclusive) {
            "neighbor size mismatch"
        }
        require(r0.size >= endExclusive && r1.size >= endExclusive && r2.size >= endExclusive) {
            "rest size mismatch"
        }
        require(s0.size >= endExclusive && s1.size >= endExclusive && s2.size >= endExclusive) {
            "stiffness size mismatch"
        }

        val startIdx = start.coerceIn(0, endExclusive)
        val endIdx = endExclusive.coerceIn(startIdx, endExclusive)

        val lanes = SG.length()
        val zero = FloatVector.zero(SG)
        val eps = 1.0e-8f
        val vEps = FloatVector.broadcast(SG, eps)
        val vOne = FloatVector.broadcast(SG, 1f)

        val map0 = scratch.map0
        val map1 = scratch.map1
        val map2 = scratch.map2
        require(map0.size == lanes && map1.size == lanes && map2.size == lanes) {
            "ConstraintScratch species mismatch: expected lane count $lanes"
        }

        repeat(iterations) {
            var i = startIdx
            while (i + lanes <= endIdx) {
                var xi = FloatVector.fromArray(SG, x, i)
                var yi = FloatVector.fromArray(SG, y, i)
                var zi = FloatVector.fromArray(SG, z, i)

                var bits0 = 0L
                var bits1 = 0L
                var bits2 = 0L

                for (lane in 0 until lanes) {
                    val idx = i + lane
                    val j0 = n0[idx]
                    val j1 = n1[idx]
                    val j2 = n2[idx]

                    if (j0 in 0 until n) {
                        bits0 = bits0 or (1L shl lane)
                        map0[lane] = j0
                    } else {
                        map0[lane] = 0
                    }
                    if (j1 in 0 until n) {
                        bits1 = bits1 or (1L shl lane)
                        map1[lane] = j1
                    } else {
                        map1[lane] = 0
                    }
                    if (j2 in 0 until n) {
                        bits2 = bits2 or (1L shl lane)
                        map2[lane] = j2
                    } else {
                        map2[lane] = 0
                    }
                }

                var cx = FloatVector.zero(SG)
                var cy = FloatVector.zero(SG)
                var cz = FloatVector.zero(SG)

                if (bits0 != 0L) {
                    val m0 = VectorMask.fromLong(SG, bits0)
                    val xj = FloatVector.fromArray(SG, x, 0, map0, 0)
                    val yj = FloatVector.fromArray(SG, y, 0, map0, 0)
                    val zj = FloatVector.fromArray(SG, z, 0, map0, 0)

                    val dx = xj.sub(xi)
                    val dy = yj.sub(yi)
                    val dz = zj.sub(zi)

                    val d2 = dx.fma(dx, dy.fma(dy, dz.mul(dz)))
                    val dist = d2.lanewise(SQRT)

                    val rest = FloatVector.fromArray(SG, r0, i)
                    val stiff = FloatVector.fromArray(SG, s0, i)
                    val invDist = vOne.div(dist.add(vEps))
                    val s = dist.sub(rest).mul(invDist).mul(stiff)

                    cx = cx.add(dx.mul(s).blend(zero, m0.not()))
                    cy = cy.add(dy.mul(s).blend(zero, m0.not()))
                    cz = cz.add(dz.mul(s).blend(zero, m0.not()))
                }

                if (bits1 != 0L) {
                    val m1 = VectorMask.fromLong(SG, bits1)
                    val xj = FloatVector.fromArray(SG, x, 0, map1, 0)
                    val yj = FloatVector.fromArray(SG, y, 0, map1, 0)
                    val zj = FloatVector.fromArray(SG, z, 0, map1, 0)

                    val dx = xj.sub(xi)
                    val dy = yj.sub(yi)
                    val dz = zj.sub(zi)

                    val d2 = dx.fma(dx, dy.fma(dy, dz.mul(dz)))
                    val dist = d2.lanewise(SQRT)

                    val rest = FloatVector.fromArray(SG, r1, i)
                    val stiff = FloatVector.fromArray(SG, s1, i)
                    val invDist = vOne.div(dist.add(vEps))
                    val s = dist.sub(rest).mul(invDist).mul(stiff)

                    cx = cx.add(dx.mul(s).blend(zero, m1.not()))
                    cy = cy.add(dy.mul(s).blend(zero, m1.not()))
                    cz = cz.add(dz.mul(s).blend(zero, m1.not()))
                }

                if (bits2 != 0L) {
                    val m2 = VectorMask.fromLong(SG, bits2)
                    val xj = FloatVector.fromArray(SG, x, 0, map2, 0)
                    val yj = FloatVector.fromArray(SG, y, 0, map2, 0)
                    val zj = FloatVector.fromArray(SG, z, 0, map2, 0)

                    val dx = xj.sub(xi)
                    val dy = yj.sub(yi)
                    val dz = zj.sub(zi)

                    val d2 = dx.fma(dx, dy.fma(dy, dz.mul(dz)))
                    val dist = d2.lanewise(SQRT)

                    val rest = FloatVector.fromArray(SG, r2, i)
                    val stiff = FloatVector.fromArray(SG, s2, i)
                    val invDist = vOne.div(dist.add(vEps))
                    val s = dist.sub(rest).mul(invDist).mul(stiff)

                    cx = cx.add(dx.mul(s).blend(zero, m2.not()))
                    cy = cy.add(dy.mul(s).blend(zero, m2.not()))
                    cz = cz.add(dz.mul(s).blend(zero, m2.not()))
                }

                val vax = FloatVector.fromArray(SG, ax, i).add(cx)
                val vay = FloatVector.fromArray(SG, ay, i).add(cy)
                val vaz = FloatVector.fromArray(SG, az, i).add(cz)

                vax.intoArray(ax, i)
                vay.intoArray(ay, i)
                vaz.intoArray(az, i)

                i += lanes
            }

            while (i < endIdx) {
                val xiS = x[i]
                val yiS = y[i]
                val ziS = z[i]

                var fx = 0f
                var fy = 0f
                var fz = 0f

                val j0 = n0[i]
                if (j0 in 0 until n) {
                    val w = s0[i]
                    if (w != 0f) {
                        val dx = x[j0] - xiS
                        val dy = y[j0] - yiS
                        val dz = z[j0] - ziS
                        val dist = sqrt(dx * dx + dy * dy + dz * dz + eps)
                        val s = (dist - r0[i]) / dist * w
                        fx += dx * s
                        fy += dy * s
                        fz += dz * s
                    }
                }

                val j1 = n1[i]
                if (j1 in 0 until n) {
                    val w = s1[i]
                    if (w != 0f) {
                        val dx = x[j1] - xiS
                        val dy = y[j1] - yiS
                        val dz = z[j1] - ziS
                        val dist = sqrt(dx * dx + dy * dy + dz * dz + eps)
                        val s = (dist - r1[i]) / dist * w
                        fx += dx * s
                        fy += dy * s
                        fz += dz * s
                    }
                }

                val j2 = n2[i]
                if (j2 in 0 until n) {
                    val w = s2[i]
                    if (w != 0f) {
                        val dx = x[j2] - xiS
                        val dy = y[j2] - yiS
                        val dz = z[j2] - ziS
                        val dist = sqrt(dx * dx + dy * dy + dz * dz + eps)
                        val s = (dist - r2[i]) / dist * w
                        fx += dx * s
                        fy += dy * s
                        fz += dz * s
                    }
                }

                ax[i] += fx
                ay[i] += fy
                az[i] += fz
                i++
            }
        }
    }

fun solveDirectedDist4InPlace

3b) Directed 4-slot distance constraints (SIMD, force accumulation) Each i has up to 4 outgoing constraints to neighbor j. We update only i's accumulators (no scatter), so range-parallel calls are safe.

    fun solveDirectedDist4InPlace(
        state: State3,
        constraints: DirectedConstraints4,
        iterations: Int,
        scratch: ConstraintScratch = scratchTLS.get()
    ) {
        solveDirectedDist4InPlaceRange(
            x = state.x, y = state.y, z = state.z,
            ax = state.ax, ay = state.ay, az = state.az,
            n0 = constraints.n0, n1 = constraints.n1, n2 = constraints.n2, n3 = constraints.n3,
            r0 = constraints.r0, r1 = constraints.r1, r2 = constraints.r2, r3 = constraints.r3,
            s0 = constraints.s0, s1 = constraints.s1, s2 = constraints.s2, s3 = constraints.s3,
            start = 0,
            endExclusive = state.size,
            iterations = iterations,
            scratch = scratch
        )
    }

fun solveDirectedDist4InPlace

    fun solveDirectedDist4InPlace(
        x: FloatArray, y: FloatArray, z: FloatArray,
        ax: FloatArray, ay: FloatArray, az: FloatArray,
        n0: IntArray, n1: IntArray, n2: IntArray, n3: IntArray,
        r0: FloatArray, r1: FloatArray, r2: FloatArray, r3: FloatArray,
        s0: FloatArray, s1: FloatArray, s2: FloatArray, s3: FloatArray,
        iterations: Int,
        scratch: ConstraintScratch = scratchTLS.get()
    ) {
        solveDirectedDist4InPlaceRange(
            x, y, z,
            ax, ay, az,
            n0, n1, n2, n3,
            r0, r1, r2, r3,
            s0, s1, s2, s3,
            start = 0,
            endExclusive = x.size,
            iterations = iterations,
            scratch = scratch
        )
    }

fun solveDirectedDist4InPlaceRange

body — 273 lines, from line 1684
    fun solveDirectedDist4InPlaceRange(
        x: FloatArray, y: FloatArray, z: FloatArray,
        ax: FloatArray, ay: FloatArray, az: FloatArray,
        n0: IntArray, n1: IntArray, n2: IntArray, n3: IntArray,
        r0: FloatArray, r1: FloatArray, r2: FloatArray, r3: FloatArray,
        s0: FloatArray, s1: FloatArray, s2: FloatArray, s3: FloatArray,
        start: Int,
        endExclusive: Int,
        iterations: Int,
        scratch: ConstraintScratch = scratchTLS.get()
    ) {
        require(iterations >= 0) { "iterations must be >= 0" }
        if (iterations == 0) return

        val n = x.size
        require(y.size == n && z.size == n) { "position size mismatch" }
        require(ax.size == n && ay.size == n && az.size == n) { "accumulator size mismatch" }
        require(endExclusive <= n) { "endExclusive exceeds position size" }
        require(n0.size >= endExclusive && n1.size >= endExclusive && n2.size >= endExclusive && n3.size >= endExclusive) {
            "neighbor size mismatch"
        }
        require(r0.size >= endExclusive && r1.size >= endExclusive && r2.size >= endExclusive && r3.size >= endExclusive) {
            "rest size mismatch"
        }
        require(s0.size >= endExclusive && s1.size >= endExclusive && s2.size >= endExclusive && s3.size >= endExclusive) {
            "stiffness size mismatch"
        }

        val startIdx = start.coerceIn(0, endExclusive)
        val endIdx = endExclusive.coerceIn(startIdx, endExclusive)

        val lanes = SG.length()
        val zero = FloatVector.zero(SG)
        val eps = 1.0e-8f
        val vEps = FloatVector.broadcast(SG, eps)
        val vOne = FloatVector.broadcast(SG, 1f)

        val map0 = scratch.map0
        val map1 = scratch.map1
        val map2 = scratch.map2
        val map3 = scratch.map3
        require(map0.size == lanes && map1.size == lanes && map2.size == lanes && map3.size == lanes) {
            "ConstraintScratch species mismatch: expected lane count $lanes"
        }

        repeat(iterations) {
            var i = startIdx
            while (i + lanes <= endIdx) {
                var xi = FloatVector.fromArray(SG, x, i)
                var yi = FloatVector.fromArray(SG, y, i)
                var zi = FloatVector.fromArray(SG, z, i)

                var bits0 = 0L
                var bits1 = 0L
                var bits2 = 0L
                var bits3 = 0L

                for (lane in 0 until lanes) {
                    val idx = i + lane
                    val j0 = n0[idx]
                    val j1 = n1[idx]
                    val j2 = n2[idx]
                    val j3 = n3[idx]

                    if (j0 in 0 until n) {
                        bits0 = bits0 or (1L shl lane)
                        map0[lane] = j0
                    } else {
                        map0[lane] = 0
                    }
                    if (j1 in 0 until n) {
                        bits1 = bits1 or (1L shl lane)
                        map1[lane] = j1
                    } else {
                        map1[lane] = 0
                    }
                    if (j2 in 0 until n) {
                        bits2 = bits2 or (1L shl lane)
                        map2[lane] = j2
                    } else {
                        map2[lane] = 0
                    }
                    if (j3 in 0 until n) {
                        bits3 = bits3 or (1L shl lane)
                        map3[lane] = j3
                    } else {
                        map3[lane] = 0
                    }
                }

                var cx = FloatVector.zero(SG)
                var cy = FloatVector.zero(SG)
                var cz = FloatVector.zero(SG)

                if (bits0 != 0L) {
                    val m0 = VectorMask.fromLong(SG, bits0)
                    val xj = FloatVector.fromArray(SG, x, 0, map0, 0)
                    val yj = FloatVector.fromArray(SG, y, 0, map0, 0)
                    val zj = FloatVector.fromArray(SG, z, 0, map0, 0)

                    val dx = xj.sub(xi)
                    val dy = yj.sub(yi)
                    val dz = zj.sub(zi)

                    val d2 = dx.fma(dx, dy.fma(dy, dz.mul(dz)))
                    val dist = d2.lanewise(SQRT)

                    val rest = FloatVector.fromArray(SG, r0, i)
                    val stiff = FloatVector.fromArray(SG, s0, i)
                    val invDist = vOne.div(dist.add(vEps))
                    val s = dist.sub(rest).mul(invDist).mul(stiff)

                    cx = cx.add(dx.mul(s).blend(zero, m0.not()))
                    cy = cy.add(dy.mul(s).blend(zero, m0.not()))
                    cz = cz.add(dz.mul(s).blend(zero, m0.not()))
                }

                if (bits1 != 0L) {
                    val m1 = VectorMask.fromLong(SG, bits1)
                    val xj = FloatVector.fromArray(SG, x, 0, map1, 0)
                    val yj = FloatVector.fromArray(SG, y, 0, map1, 0)
                    val zj = FloatVector.fromArray(SG, z, 0, map1, 0)

                    val dx = xj.sub(xi)
                    val dy = yj.sub(yi)
                    val dz = zj.sub(zi)

                    val d2 = dx.fma(dx, dy.fma(dy, dz.mul(dz)))
                    val dist = d2.lanewise(SQRT)

                    val rest = FloatVector.fromArray(SG, r1, i)
                    val stiff = FloatVector.fromArray(SG, s1, i)
                    val invDist = vOne.div(dist.add(vEps))
                    val s = dist.sub(rest).mul(invDist).mul(stiff)

                    cx = cx.add(dx.mul(s).blend(zero, m1.not()))
                    cy = cy.add(dy.mul(s).blend(zero, m1.not()))
                    cz = cz.add(dz.mul(s).blend(zero, m1.not()))
                }

                if (bits2 != 0L) {
                    val m2 = VectorMask.fromLong(SG, bits2)
                    val xj = FloatVector.fromArray(SG, x, 0, map2, 0)
                    val yj = FloatVector.fromArray(SG, y, 0, map2, 0)
                    val zj = FloatVector.fromArray(SG, z, 0, map2, 0)

                    val dx = xj.sub(xi)
                    val dy = yj.sub(yi)
                    val dz = zj.sub(zi)

                    val d2 = dx.fma(dx, dy.fma(dy, dz.mul(dz)))
                    val dist = d2.lanewise(SQRT)

                    val rest = FloatVector.fromArray(SG, r2, i)
                    val stiff = FloatVector.fromArray(SG, s2, i)
                    val invDist = vOne.div(dist.add(vEps))
                    val s = dist.sub(rest).mul(invDist).mul(stiff)

                    cx = cx.add(dx.mul(s).blend(zero, m2.not()))
                    cy = cy.add(dy.mul(s).blend(zero, m2.not()))
                    cz = cz.add(dz.mul(s).blend(zero, m2.not()))
                }

                if (bits3 != 0L) {
                    val m3 = VectorMask.fromLong(SG, bits3)
                    val xj = FloatVector.fromArray(SG, x, 0, map3, 0)
                    val yj = FloatVector.fromArray(SG, y, 0, map3, 0)
                    val zj = FloatVector.fromArray(SG, z, 0, map3, 0)

                    val dx = xj.sub(xi)
                    val dy = yj.sub(yi)
                    val dz = zj.sub(zi)

                    val d2 = dx.fma(dx, dy.fma(dy, dz.mul(dz)))
                    val dist = d2.lanewise(SQRT)

                    val rest = FloatVector.fromArray(SG, r3, i)
                    val stiff = FloatVector.fromArray(SG, s3, i)
                    val invDist = vOne.div(dist.add(vEps))
                    val s = dist.sub(rest).mul(invDist).mul(stiff)

                    cx = cx.add(dx.mul(s).blend(zero, m3.not()))
                    cy = cy.add(dy.mul(s).blend(zero, m3.not()))
                    cz = cz.add(dz.mul(s).blend(zero, m3.not()))
                }

                val vax = FloatVector.fromArray(SG, ax, i).add(cx)
                val vay = FloatVector.fromArray(SG, ay, i).add(cy)
                val vaz = FloatVector.fromArray(SG, az, i).add(cz)

                vax.intoArray(ax, i)
                vay.intoArray(ay, i)
                vaz.intoArray(az, i)

                i += lanes
            }

            while (i < endIdx) {
                val xiS = x[i]
                val yiS = y[i]
                val ziS = z[i]

                var fx = 0f
                var fy = 0f
                var fz = 0f

                val j0 = n0[i]
                if (j0 in 0 until n) {
                    val w = s0[i]
                    if (w != 0f) {
                        val dx = x[j0] - xiS
                        val dy = y[j0] - yiS
                        val dz = z[j0] - ziS
                        val dist = sqrt(dx * dx + dy * dy + dz * dz + eps)
                        val s = (dist - r0[i]) / dist * w
                        fx += dx * s
                        fy += dy * s
                        fz += dz * s
                    }
                }

                val j1 = n1[i]
                if (j1 in 0 until n) {
                    val w = s1[i]
                    if (w != 0f) {
                        val dx = x[j1] - xiS
                        val dy = y[j1] - yiS
                        val dz = z[j1] - ziS
                        val dist = sqrt(dx * dx + dy * dy + dz * dz + eps)
                        val s = (dist - r1[i]) / dist * w
                        fx += dx * s
                        fy += dy * s
                        fz += dz * s
                    }
                }

                val j2 = n2[i]
                if (j2 in 0 until n) {
                    val w = s2[i]
                    if (w != 0f) {
                        val dx = x[j2] - xiS
                        val dy = y[j2] - yiS
                        val dz = z[j2] - ziS
                        val dist = sqrt(dx * dx + dy * dy + dz * dz + eps)
                        val s = (dist - r2[i]) / dist * w
                        fx += dx * s
                        fy += dy * s
                        fz += dz * s
                    }
                }

                val j3 = n3[i]
                if (j3 in 0 until n) {
                    val w = s3[i]
                    if (w != 0f) {
                        val dx = x[j3] - xiS
                        val dy = y[j3] - yiS
                        val dz = z[j3] - ziS
                        val dist = sqrt(dx * dx + dy * dy + dz * dz + eps)
                        val s = (dist - r3[i]) / dist * w
                        fx += dx * s
                        fy += dy * s
                        fz += dz * s
                    }
                }

                ax[i] += fx
                ay[i] += fy
                az[i] += fz
                i++
            }
        }
    }

4) Derived kinematics & bulk force helpers

These delegate to the auto-generated, correctness-tested bulk kernels in afield.math.linalg.bulk, which operate on exactly the flat SoA FloatArray layout this class already uses. They add capability the hand-tuned kernels above deliberately don't cover, without touching them: integrateVerlet3 stays a single fused pass (a chain of per-op kernel passes would re-stream the same arrays through memory), and the constraint solve needs the gather+masking the gather-free elementwise kernels don't model.

Force-space helpers (e.g. addSpringForceToTarget3) accumulate into ax/ay/az, so run them through step()'s accumulateForces hook -- step() zeroes the accumulators at its start, so a force written before step() is discarded. See step()'s KDoc.

Note on uniform quantities: the kernels only carry scalar overloads for mul/div, so a broadcast add (uniform gravity, clamp-to-box) isn't expressible here yet without a filler array. That's a clean future generator extension, not a reason to hand-roll it -- the existing scalar gravity loop in step() stays as-is.

fun velocity3(state: State3, dt: Float, vx: FloatArray, vy: FloatArray, vz: FloatArray)

Recovers per-particle velocity v = (x - px) / dt into vx/vy/vz for the whole active range, in two vectorized passes (subtract, then scale by 1/dt).

Verlet stores the previous position rather than an explicit velocity, so callers that want momentum/kinetic-energy/speed-gating had to reconstruct it by hand; this does it over the whole state with the SIMD kernels. The output arrays may alias px/py/pz but must be distinct from x/y/z, and sized >= state.size.

    fun velocity3(state: State3, dt: Float, vx: FloatArray, vy: FloatArray, vz: FloatArray) =
        velocity3Range(state.x, state.y, state.z, state.px, state.py, state.pz, dt, vx, vy, vz, 0, state.size)

fun velocity3Range

    fun velocity3Range(
        x: FloatArray, y: FloatArray, z: FloatArray,
        px: FloatArray, py: FloatArray, pz: FloatArray,
        dt: Float,
        vx: FloatArray, vy: FloatArray, vz: FloatArray,
        start: Int,
        endExclusive: Int
    ) {
        require(dt != 0f) { "dt must be non-zero to recover velocity" }
        subRange(x, y, z, px, py, pz, vx, vy, vz, start, endExclusive)
        mulRange(vx, vy, vz, 1f / dt, vx, vy, vz, start, endExclusive)
    }

fun addSpringForceToTarget3

Accumulates a per-particle Hookean pull toward a target pose into the force accumulators: a += stiffness * (target - position) across the active range, as two vectorized passes (subtract into scratch, then fused-multiply-add).

It writes ax/ay/az (force space, not position), so it composes correctly with gravity, force generators, distance constraints and integration inside step -- unlike moving positions directly, which would silently inject Verlet velocity.

scratchX/scratchY/scratchZ are working buffers (sized >= endExclusive); keep one triple per worker and reuse it across frames to stay allocation-free.

    fun addSpringForceToTarget3(
        state: State3,
        targetX: FloatArray, targetY: FloatArray, targetZ: FloatArray,
        stiffness: Float,
        scratchX: FloatArray, scratchY: FloatArray, scratchZ: FloatArray
    ) = addSpringForceToTarget3Range(
        state.x, state.y, state.z,
        state.ax, state.ay, state.az,
        targetX, targetY, targetZ,
        stiffness,
        scratchX, scratchY, scratchZ,
        0, state.size
    )

fun addSpringForceToTarget3Range

    fun addSpringForceToTarget3Range(
        x: FloatArray, y: FloatArray, z: FloatArray,
        ax: FloatArray, ay: FloatArray, az: FloatArray,
        targetX: FloatArray, targetY: FloatArray, targetZ: FloatArray,
        stiffness: Float,
        scratchX: FloatArray, scratchY: FloatArray, scratchZ: FloatArray,
        start: Int,
        endExclusive: Int
    ) {
        subRange(targetX, targetY, targetZ, x, y, z, scratchX, scratchY, scratchZ, start, endExclusive)
        fmaRange(ax, ay, az, stiffness, scratchX, scratchY, scratchZ, ax, ay, az, start, endExclusive)
    }

5) Parallel range execution with coarse chunking

class ForceExecutor(threads: Int) : AutoCloseable

    class ForceExecutor(threads: Int) : AutoCloseable {
        private val workerCount = max(1, threads)
        private val pool: ExecutorService = Executors.newFixedThreadPool(workerCount)

fun runRanges

Generic contiguous range execution utility.

        fun runRanges(
            n: Int,
            minChunk: Int = 256,
            task: (start: Int, endExclusive: Int) -> Unit
        ) {
            if (n <= 0) return
            val tasks = min(workerCount * 2, max(1, n / max(1, minChunk)))
            if (tasks <= 1) {
                task(0, n)
                return
            }

            val chunk = (n + tasks - 1) / tasks
            val futures = ArrayList<Future<*>>(tasks)

            var start = 0
            while (start < n) {
                val endExclusive = min(n, start + chunk)
                val s = start
                futures += pool.submit { task(s, endExclusive) }
                start = endExclusive
            }

            for (future in futures) {
                future.get()
            }
        }

fun runForces

Force accumulation helper (serial per point, parallel by range). accumulate must only write to indices inside [start, endExclusive).

        fun runForces(
            n: Int,
            minChunk: Int = 256,
            accumulate: (start: Int, endExclusive: Int) -> Unit
        ) {
            runRanges(n = n, minChunk = minChunk, task = accumulate)
        }

override fun close()

        override fun close() {
            pool.shutdown()
        }
    }
}

private fun swap(a: FloatArray, i: Int, j: Int) {
    val tmp = a[i]
    a[i] = a[j]
    a[j] = tmp
}

private fun swap(a: IntArray, i: Int, j: Int) {
    val tmp = a[i]
    a[i] = a[j]
    a[j] = tmp
}

private fun swap(a: Array<MapOfGenerators<SimdVerlet.ForceContext, Unit>>, i: Int, j: Int) {
    val tmp = a[i]
    a[i] = a[j]
    a[j] = tmp
}