Skip to content

SimdVerletWind.kt

src/jvmMain/kotlin/afield/simulation/physics/SimdVerletWind.kt · 480 lines

package afield.simulation.physics

import afield.geometry.path.FLine
import afield.math.linalg.Vec4
import afield.graphics.render.color
import afield.graphics.render.thickness
import afield.math.linalg.vec
import kotlin.math.abs
import kotlin.math.sin
import kotlin.math.sqrt

Wind for SimdVerlet: per-particle drag, per-quad cloth aerodynamics, and the reproducible gust field both of them sample.

The two force models answer different questions, and a sheet that is meant to flutter rather than lean needs the second one.

Per-particle drag (SimdVerlet.addWind) is F = drag * (w - v) per particle, where w is the wind velocity at that point and v = (x - px) / dt is the Verlet velocity. It applies to anything made of particles (a rope, a point cloud, a cloth), and it cannot see which way fabric is facing, because a particle has no orientation: a sheet edge-on to the wind is pushed exactly as hard as a sheet face-on, and under a steady wind such a cloth reaches an equilibrium lean and stays there.

Per-quad aerodynamics (Cloth.addWind) reads the sheet's own topology, which costs nothing: a Cloth is a contiguous row-major grid, so the quad at (col, row) is four adds. For each quad it takes the normal from the diagonals, the area from half that normal's length, the relative velocity from the mean of the four corners, and applies

F = 0.5 * rho * Cd * A * (n . v_rel) * |n . v_rel| * n

quartered onto the corners, plus an optional tangential term. Flutter comes out of the signed n . v_rel factor: a quad turning edge-on stops being pushed, overshoots, catches the wind on its other face, and the instability travels down the sheet as a wave.

SimdVerletWindTest measures both models through the same measurement. Two identical 12 x 12 sheets at rest in the same wind, one face-on and one edge-on, differ by more than twenty times in the force the per-quad pass puts into them, and what little the edge-on sheet gets is all skin friction: set ClothWind.tangentialCoefficient to zero and its normal force drops below a millionth of the face-on total. The drag pass gives the two sheets the same force to within a part in 100000, because a particle has no orientation to have.

The other measurement is sustained motion: a 20 x 12 flag in an 8-unit wind with 60 % gusts, scored as trailing-edge travel per frame once the transient has passed, with the per-quad pass on and off. What the test asserts is the shape rather than the figures. Per-quad always adds motion; it adds more than 2.5 times as much at Wind.drag = 0.05, and less than 1.5 times as much by 0.5. So the advantage is a low-drag one. Turn drag up and the drag pass is doing the forcing, and the sheet is being carried rather than flying.

The gust field

Wind.turbulence adds gusts on top of the mean Wind.velocityX/velocityY/velocityZ. The field is a small closed-form spectrum: a few sines per axis, with directions, spatial frequencies, temporal frequencies and phases all derived from the seed. It is not a noise lattice and not a fluid simulation, because what it has to be is reproducible and grid-free. Two runs at the same seed produce the same sheet, so a test can assert something about it and a capture can be re-taken; and a lattice can align its cells with the cloth's own grid spacing and stand a pattern in the sheet. A sum of sines has no lattice to align, and costs three sines per octave per sample.

Gust amplitude is turbulence times the mean wind's speed, so turbulence = 0.4f is gusts of about 40 % whatever the wind is set to, and 0f is a steady stream.

Wind time

Wind.time advances by dt on every pass, so a registered wind is a running one. Set it to drive the gusts from somewhere else (a transport, a scrub bar); reset it to zero to replay a gust exactly.

class Wind internal constructor

One wind: a mean velocity, a gust field, and the per-particle drag pass those two feed.

SimdVerlet.addWind builds one and registers the pass; every field here is live, so a direction, a drag or a gust setting can be changed between frames. accumulate is the pass itself for a caller who would rather drive it than leave it registered, sampleInto reads the field at a point, detach unregisters it. A wind with drag = 0f applies no force at all, which is how to get one purely as a field for something else to sample: a ClothWind, the whiskers of windLines, or a caller's own pass.

range limits the drag pass to a slice of the simulation, so two winds can blow on two different groups of particles in the same state.

class Wind internal constructor(
    val sim: SimdVerlet,
    val name: String,
    var velocityX: Float,
    var velocityY: Float,
    var velocityZ: Float,
    var drag: Float,
    var turbulence: Float,
    var turbulenceScale: Float,
    var turbulenceSpeed: Float,
    var range: IntRange?,
    val gusts: Turbulence
) {

var time: Float

Seconds of wind elapsed. Advanced by dt on every pass; assignable, so gusts can be scrubbed.

    @Volatile
    var time: Float = 0f

val speed: Float get()

Speed of the mean wind, which is what turbulence is a fraction of.

    val speed: Float get() = sqrt(velocityX * velocityX + velocityY * velocityY + velocityZ * velocityZ)

Written by sampleInto, read by the callers immediately. One pass, one thread.

    private val sample = FloatArray(3)

fun sampleInto(x: Float, y: Float, z: Float, out: FloatArray, t: Float = time)

The wind velocity at a world point and the current time, mean plus gust, into out[0..2].

Public because it is the thing a caller wants to draw: a field of little arrows sampled on a grid says more about why a sheet is doing what it is doing than any number of frames of the sheet. See windLines.

    fun sampleInto(x: Float, y: Float, z: Float, out: FloatArray, t: Float = time) {
        out[0] = velocityX
        out[1] = velocityY
        out[2] = velocityZ
        if (turbulence == 0f) return
        val amp = turbulence * speed
        if (amp == 0f) return
        val sx = x * turbulenceScale
        val sy = y * turbulenceScale
        val sz = z * turbulenceScale
        val st = t * turbulenceSpeed
        out[0] += amp * gusts.sample(0, sx, sy, sz, st)
        out[1] += amp * gusts.sample(1, sx, sy, sz, st)
        out[2] += amp * gusts.sample(2, sx, sy, sz, st)
    }

fun accumulate(state: SimdVerlet.State3, dt: Float)

The per-particle drag pass, exposed for callers who would rather drive it than register it.

    fun accumulate(state: SimdVerlet.State3, dt: Float) {
        time += dt
        if (drag == 0f || dt <= 0f) return
        val r = range
        val start = (r?.first ?: 0).coerceAtLeast(0)
        val end = (if (r == null) state.size else r.last + 1).coerceAtMost(state.size)
        if (end <= start) return
        val invDt = 1f / dt
        val steady = turbulence == 0f || speed == 0f
        val out = sample
        if (steady) {
            out[0] = velocityX
            out[1] = velocityY
            out[2] = velocityZ
        }
        for (i in start until end) {
            if (!steady) sampleInto(state.x[i], state.y[i], state.z[i], out)
            val vx = (state.x[i] - state.px[i]) * invDt
            val vy = (state.y[i] - state.py[i]) * invDt
            val vz = (state.z[i] - state.pz[i]) * invDt
            state.ax[i] += drag * (out[0] - vx)
            state.ay[i] += drag * (out[1] - vy)
            state.az[i] += drag * (out[2] - vz)
        }
    }

fun detach()

Unregisters the pass from sim.

    fun detach() {
        sim.removeBulkForce(name)
    }
}

class Turbulence(val seed: Int = 1, val octaves: Int = 3)

A small closed-form gust spectrum: three independent scalar fields over (x, y, z, t), each a sum of octaves sines whose direction, frequency and phase come from seed.

Deliberately not a noise lattice. What this has to be is reproducible and grid-free — a value or gradient noise on an integer lattice can align its cells with a cloth's grid spacing and put a standing pattern into the sheet, which is a bug that looks like a feature. A sum of sines has no lattice, is smooth to all orders, and is the same field on every machine and every run for a given seed.

Each successive octave doubles the frequency and halves the amplitude, and the result is normalised by the amplitude sum, so sample stays within [-1, 1] whatever octaves is.

class Turbulence(val seed: Int = 1, val octaves: Int = 3) {

    private val dirX = FloatArray(3 * octaves)
    private val dirY = FloatArray(3 * octaves)
    private val dirZ = FloatArray(3 * octaves)
    private val omega = FloatArray(3 * octaves)
    private val phase = FloatArray(3 * octaves)
    private val amplitude = FloatArray(3 * octaves)
    private val normalisation: Float

init

    init {
        require(octaves in 1..16) { "octaves must be in 1..16, got $octaves" }
        var ampSum = 0f
        for (component in 0 until 3) {
            for (o in 0 until octaves) {
                val k = component * octaves + o
                // A deterministic direction on the sphere from three hashes, then normalised. Not
                // uniformly distributed and it does not need to be: it needs to be the same
                // direction every time and not axis-aligned.
                var dx = hashUnit(k, 0)
                var dy = hashUnit(k, 1)
                var dz = hashUnit(k, 2)
                var len = sqrt(dx * dx + dy * dy + dz * dz)
                if (len < 1e-6f) {
                    dx = 1f; dy = 0f; dz = 0f; len = 1f
                }
                val freq = (1 shl o).toFloat() * (0.75f + 0.5f * (hashUnit(k, 3) * 0.5f + 0.5f))
                dirX[k] = dx / len * freq
                dirY[k] = dy / len * freq
                dirZ[k] = dz / len * freq
                omega[k] = freq * (0.6f + 0.8f * (hashUnit(k, 4) * 0.5f + 0.5f))
                phase[k] = hashUnit(k, 5) * 3.14159265f
                amplitude[k] = 1f / (1 shl o)
                if (component == 0) ampSum += amplitude[k]
            }
        }
        normalisation = 1f / ampSum
    }

fun sample(component: Int, x: Float, y: Float, z: Float, t: Float): Float

Component component (0, 1, 2) of the gust field at (x, y, z) and time t, in [-1, 1]. The coordinates are expected to be pre-scaled by the caller's spatial frequency.

    fun sample(component: Int, x: Float, y: Float, z: Float, t: Float): Float {
        var acc = 0f
        val first = component * octaves
        for (o in 0 until octaves) {
            val k = first + o
            acc += amplitude[k] * sin(dirX[k] * x + dirY[k] * y + dirZ[k] * z + omega[k] * t + phase[k])
        }
        return acc * normalisation
    }

A stable float in [-1, 1] from two ints and the seed. Integer-only, so it is bit-exact everywhere.

    private fun hashUnit(a: Int, b: Int): Float {
        var h = seed * 0x9E3779B1.toInt()
        h = h xor (a * 0x85EBCA6B.toInt())
        h = h xor (b * 0xC2B2AE35.toInt())
        h = h xor (h ushr 15)
        h *= 0x2545F491
        h = h xor (h ushr 13)
        return ((h and 0xFFFFFF) / 8388608.0f) - 1f
    }
}

fun SimdVerlet.addWind

Registers per-particle drag on this simulation and hands back the live handle.

val wind = sim.addWind(velocityX = 6f, drag = 0.9f, turbulence = 0.5f)

Read Wind's KDoc before using this on a cloth: per-particle drag has no notion of which way the fabric is facing, so it leans a sheet rather than fluttering it. Cloth.addWind is the one that flutters.

@param drag force per unit of velocity difference. 0f registers the pass but applies nothing, which is how you get a Wind purely as a field to sample from something else. @param turbulence gust amplitude as a fraction of the mean wind's speed. @param turbulenceScale spatial frequency of the gusts, in inverse world units — the reciprocal is roughly a gust's size, so 0.35f on a 10-unit-wide sheet gives gusts of about a third of it. @param turbulenceSpeed how fast the gust field evolves, in radians per second at the base octave. @param seed everything about the gust field. Same seed, same gusts, forever.

fun SimdVerlet.addWind(
    velocityX: Float = 0f,
    velocityY: Float = 0f,
    velocityZ: Float = 0f,
    drag: Float = 0.5f,
    turbulence: Float = 0f,
    turbulenceScale: Float = 0.35f,
    turbulenceSpeed: Float = 1.6f,
    octaves: Int = 3,
    seed: Int = 1,
    range: IntRange? = null,
    name: String = "wind"
): Wind {
    val wind = Wind(
        sim = this, name = name,
        velocityX = velocityX, velocityY = velocityY, velocityZ = velocityZ,
        drag = drag,
        turbulence = turbulence, turbulenceScale = turbulenceScale, turbulenceSpeed = turbulenceSpeed,
        range = range, gusts = Turbulence(seed, octaves)
    )
    addBulkForce(name) { state, dt -> wind.accumulate(state, dt) }
    return wind
}

fun Cloth.addWind

The aerodynamic surface of one cloth: registers a per-quad pass that reads its wind from wind and hands back the handle for it.

The two passes are independent and composable. A cloth with only this one has no skin drag at all and can slide sideways through still air forever; a cloth with both has the sheet-shaped force and a little damping. The usual pairing is a low Wind.drag (0.1 or so) and this.

@param wind where the air comes from. Making a separate one per cloth is fine and normal; two cloths in the same weather should share. @param density rho in the force law, folded together with Cd, so this and dragCoefficient are one knob split in two for readability. @param dragCoefficient the normal-facing coefficient. 1.28 is a flat plate. @param tangentialCoefficient skin friction along the surface, which stops a sheet from sliding frictionlessly across a steady wind. Small: a few percent of dragCoefficient.

fun Cloth.addWind(
    wind: Wind,
    density: Float = 1.2f,
    dragCoefficient: Float = 1.28f,
    tangentialCoefficient: Float = 0.05f,
    passName: String = "$name.aero"
): ClothWind {
    val aero = ClothWind(this, wind, density, dragCoefficient, tangentialCoefficient, passName)
    sim.addBulkForce(passName) { state, dt -> aero.accumulate(state, dt) }
    return aero
}

class ClothWind internal constructor

The per-quad aerodynamic pass for one Cloth. See Cloth.addWind and Wind.

The quad normal comes from the diagonals, n = (d10 x d01) where d10 is corner (1,1) minus corner (0,0) and d01 is corner (0,1) minus corner (1,0). Using the diagonals rather than two edges is not a micro-optimisation: it is the average of the two triangulations of the quad, so a quad creased along one diagonal does not get a normal that depends on which way the code happens to have split it. |n| / 2 is the area, exactly for a planar quad and close enough for a creased one.

class ClothWind internal constructor(
    val cloth: Cloth,
    val wind: Wind,
    var density: Float,
    var dragCoefficient: Float,
    var tangentialCoefficient: Float,
    val name: String
) {

var lastImpulse: Float

Total force magnitude the last pass put into the sheet — the "is the wind on" readout, and what the tests floor so that a silently-dead aero pass fails instead of passing as a sheet that happens to hang the right way. Zeroed by detach, so it means "what the air did last frame" rather than "the last time the air did anything".

    var lastImpulse: Float = 0f
        private set

    private val sample = FloatArray(3)

fun accumulate(state: SimdVerlet.State3, dt: Float)

The pass itself, exposed for callers who would rather drive it than leave it registered.

body — 87 lines, from line 341
    fun accumulate(state: SimdVerlet.State3, dt: Float) {
        lastImpulse = 0f
        if (dt <= 0f) return
        if (density == 0f || (dragCoefficient == 0f && tangentialCoefficient == 0f)) return
        val w = cloth.width
        val h = cloth.height
        val base = cloth.base
        if (state.size < base + w * h) return

        val x = state.x
        val y = state.y
        val z = state.z
        val px = state.px
        val py = state.py
        val pz = state.pz
        val ax = state.ax
        val ay = state.ay
        val az = state.az
        val invDt = 1f / dt
        val kNormal = 0.5f * density * dragCoefficient
        val kTangent = 0.5f * density * tangentialCoefficient
        val out = sample

        for (row in 0 until h - 1) {
            for (col in 0 until w - 1) {
                val i00 = base + row * w + col
                val i10 = i00 + 1
                val i01 = i00 + w
                val i11 = i01 + 1

                // Diagonals, and the normal as their cross product.
                val ux = x[i11] - x[i00]
                val uy = y[i11] - y[i00]
                val uz = z[i11] - z[i00]
                val vx = x[i01] - x[i10]
                val vy = y[i01] - y[i10]
                val vz = z[i01] - z[i10]
                var nx = uy * vz - uz * vy
                var ny = uz * vx - ux * vz
                var nz = ux * vy - uy * vx
                val nLen = sqrt(nx * nx + ny * ny + nz * nz)
                if (nLen < 1e-12f) continue
                val area = 0.5f * nLen
                nx /= nLen; ny /= nLen; nz /= nLen

                // Mean corner velocity and the centroid the wind is sampled at.
                val cvx = ((x[i00] - px[i00]) + (x[i10] - px[i10]) + (x[i01] - px[i01]) + (x[i11] - px[i11])) * 0.25f * invDt
                val cvy = ((y[i00] - py[i00]) + (y[i10] - py[i10]) + (y[i01] - py[i01]) + (y[i11] - py[i11])) * 0.25f * invDt
                val cvz = ((z[i00] - pz[i00]) + (z[i10] - pz[i10]) + (z[i01] - pz[i01]) + (z[i11] - pz[i11])) * 0.25f * invDt
                val cx = (x[i00] + x[i10] + x[i01] + x[i11]) * 0.25f
                val cy = (y[i00] + y[i10] + y[i01] + y[i11]) * 0.25f
                val cz = (z[i00] + z[i10] + z[i01] + z[i11]) * 0.25f
                wind.sampleInto(cx, cy, cz, out)

                val rx = out[0] - cvx
                val ry = out[1] - cvy
                val rz = out[2] - cvz
                val vn = rx * nx + ry * ny + rz * nz

                // Quadratic in the normal component, signed: a quad turning edge-on stops being
                // pushed, which is the flutter.
                val fn = kNormal * area * vn * abs(vn)
                var fx = fn * nx
                var fy = fn * ny
                var fz = fn * nz
                if (kTangent != 0f) {
                    val tx = rx - vn * nx
                    val ty = ry - vn * ny
                    val tz = rz - vn * nz
                    val tLen = sqrt(tx * tx + ty * ty + tz * tz)
                    val ft = kTangent * area * tLen
                    fx += ft * tx
                    fy += ft * ty
                    fz += ft * tz
                }
                lastImpulse += sqrt(fx * fx + fy * fy + fz * fz)

                val qx = fx * 0.25f
                val qy = fy * 0.25f
                val qz = fz * 0.25f
                ax[i00] += qx; ay[i00] += qy; az[i00] += qz
                ax[i10] += qx; ay[i10] += qy; az[i10] += qz
                ax[i01] += qx; ay[i01] += qy; az[i01] += qz
                ax[i11] += qx; ay[i11] += qy; az[i11] += qz
            }
        }
    }

fun detach()

Unregisters the pass from the cloth's simulation, and zeroes lastImpulse.

    fun detach() {
        cloth.sim.removeBulkForce(name)
        lastImpulse = 0f
    }
}

fun Wind.windLines

The wind field itself, as FLine whiskers on a grid — what to draw underneath a fluttering sheet so that the gusts are visible rather than inferred.

The grid runs corner to corner between the two points, columns steps along x and rows steps along y and z together. That covers both views a plane figure of this ever wants without a projection argument: hold z fixed for the front view, hold y fixed and let z run for the view from above — which is the only one a flag flapping in z is visible in, and the one where a front-view grid collapses every row onto the same point and draws fans.

@param scale seconds of wind travel a whisker represents, so the arrows are in the same units as the sheet they sit behind.

fun Wind.windLines(
    x0: Float, y0: Float, z0: Float,
    x1: Float, y1: Float, z1: Float,
    columns: Int = 14,
    rows: Int = 10,
    scale: Float = 0.06f,
    color: Vec4 = vec(0.45, 0.95, 0.72, 0.30),
    thickness: Double = 1.0
): List<FLine> {
    require(columns >= 1 && rows >= 1) { "windLines needs at least a 1 x 1 grid" }
    val line = FLine()
    val out = FloatArray(3)
    for (r in 0 until rows) {
        for (c in 0 until columns) {
            val fx = if (columns == 1) 0.5f else c / (columns - 1f)
            val fy = if (rows == 1) 0.5f else r / (rows - 1f)
            val sx = x0 + (x1 - x0) * fx
            val sy = y0 + (y1 - y0) * fy
            val sz = z0 + (z1 - z0) * fy
            sampleInto(sx, sy, sz, out)
            val len = sqrt(out[0] * out[0] + out[1] * out[1] + out[2] * out[2])
            if (len < 1e-6f) continue
            line.moveTo(sx, sy, sz)
            line.lineTo(sx + out[0] * scale, sy + out[1] * scale, sz + out[2] * scale)
        }
    }
    if (line.nodes.isEmpty()) return emptyList()
    line.color = color
    line.thickness = thickness
    return listOf(line)
}