SimdVerletTearing.kt
src/jvmMain/kotlin/afield/simulation/physics/SimdVerletTearing.kt · 432 lines
package afield.simulation.physics
import kotlin.math.max
import kotlin.math.sqrt
Tearing for SimdVerlet: retiring over-stretched distance constraints, and the two measurements that say where to set the threshold.
Tearing retires a constraint once it has been stretched past a fraction of its rest length, so
a sheet under load rips instead of stretching forever. It runs as a constraint-edit pass,
registered by SimdVerlet.addTearing on a bare simulation or by Cloth.enableTearing on a
Cloth. Both hand back the live object, so a threshold can be retuned or an edge cut by hand
between frames, and the cloth version additionally drops the curvature stencil at each lip of a
tear, without which the curvature force goes on flattening the sheet across a rip the solver has
already made.
Strain is (distance - rest) / rest throughout, so a threshold is a fraction rather than a
length, and it can be read off a measurement instead of found by bisection.
SimdVerlet.peakStrain is that measurement over a whole simulation: a threshold below the strain
a sheet settles at tears it while it hangs at rest. SimdVerlet.liveEdgeCount counts surviving
edges the way the debug drawing does, a symmetric pair counted once, so a line lost from the
picture and an edge torn in the solver are the same number.
class Tearing internal constructor
Constraint tearing: retiring a distance constraint that has been stretched past a strain threshold, so a sheet under load rips instead of stretching forever.
Registered as a SimdVerlet.constraintEdits pass — normally by SimdVerlet.addTearing — which
runs last in the frame, after integration and after the colliders. That ordering is the whole
design: an edge stretched over the shoulder of a SphereCollider really is that stretched, and
a tear model that measured strain before the projection would be deciding on a configuration
the frame never ended in.
Strain
strain = (|x_i - x_j| - rest) / rest, so threshold is a fraction: 0.5f breaks an edge half
again as long as it should be. That is the same quantity the stiffness table in
dev_docs/physics/SIMDVERLET_4_CONSTRAINTS.md reports as "peak strain", which is what makes it
a threshold you can pick from a measurement rather than by bisection — the shipped cloth
settles at 11 %, so anything above about 0.2 tears only under real load. peakStrain measures
it for a whole simulation.
Symmetry, and why breaking is not just clearing a slot
A Cloth wires every grid edge twice, i -> j and j -> i, because the solver is directed and
only ever writes to the particle that owns the slot. Retiring one direction would leave a
one-way spring: j still pulled toward i, i free. That is a physically absurd object —
it accelerates its own centre of mass — and it draws as a live edge in
SimdVerlet.debugLines, which deduplicates only genuinely symmetric pairs. So breakEdge
always retires both directions, and reports one broken edge for the pair.
Determinism
A torn sheet must tear the same way twice, or nothing about it can be tested. With
maxBreaksPerStep < 0 (the default, unlimited) every edge over threshold goes in one pass, in
ascending (particle, slot) order, and order cannot matter because the set is fixed before any
of them move. With a limit, the candidates are ranked by strain descending and ties are broken
by (particle, slot) ascending — never by hash or float-equality luck — so a rate-limited tear
is as reproducible as an unlimited one. SimdVerletTearingTest.tearsTheSameWayTwice replays a
whole scene and compares the surviving constraint set slot for slot.
@param constraints the set to edit; this must be the set the simulation actually solves.
@param range which owning particles to scan. null is the whole state. Note that an edge is
scanned if its owner is in range, so a range boundary can leave an edge scanned from one end
only — which is fine, because breaking is symmetric.
@param onTear called once per broken edge with the pair (i, j), i < j. The hook Cloth uses
to drop the curvature stencil at both ends of the tear; see enableTearing.
class Tearing internal constructor(
val sim: SimdVerlet,
val constraints: SimdVerlet.DirectedConstraints4,
val name: String,
var threshold: Float,
var range: IntRange?,
var maxBreaksPerStep: Int,
var onTear: ((i: Int, j: Int) -> Unit)?
) {
var brokenTotal: Int
Edges broken since this object was made.
var brokenTotal: Int = 0
private set
var brokenLastStep: Int
Edges broken in the most recent scan.
var brokenLastStep: Int = 0
private set
var peakStrainLastStep: Float
The largest strain seen in the most recent scan, broken edges included. -1f before the
first scan, and after a scan that found no live constraints at all.
var peakStrainLastStep: Float = -1f
private set
Rate-limited scans rank candidates; kept between frames so a torn cloth isn't allocating three arrays a frame.
private var candOwner = IntArray(0)
private var candSlot = IntArray(0)
private var candStrain = FloatArray(0)
fun scan(state: SimdVerlet.State3): Int
The pass itself, exposed for callers who would rather drive it than leave it registered.
body — 69 lines, from line 102
fun scan(state: SimdVerlet.State3): Int {
brokenLastStep = 0
peakStrainLastStep = -1f
val r = range
val start = (r?.first ?: 0).coerceAtLeast(0)
val end = (if (r == null) state.size else r.last + 1)
.coerceAtMost(minOf(state.size, constraints.size))
if (end <= start) return 0
val limited = maxBreaksPerStep >= 0
var candidates = 0
if (limited && maxBreaksPerStep == 0) {
// Still worth walking: peakStrainLastStep is a readout callers watch while tuning.
for (i in start until end) {
for (slot in 0 until 4) {
val s = slotStrain(state, i, slot) ?: continue
if (s > peakStrainLastStep) peakStrainLastStep = s
}
}
return 0
}
for (i in start until end) {
for (slot in 0 until 4) {
val s = slotStrain(state, i, slot) ?: continue
if (s > peakStrainLastStep) peakStrainLastStep = s
if (s <= threshold) continue
if (!limited) {
if (breakSlot(i, slot)) brokenLastStep++
} else {
if (candidates == candOwner.size) growCandidates()
candOwner[candidates] = i
candSlot[candidates] = slot
candStrain[candidates] = s
candidates++
}
}
}
if (limited && candidates > 0) {
// Strain descending, then (owner, slot) ascending. An insertion sort: the candidate
// list is the edges *over threshold* in one frame, which is a handful even in a
// catastrophic tear, and a stable comparator here is worth more than an O(n log n)
// that would need boxing to carry three parallel arrays.
for (a in 1 until candidates) {
val o = candOwner[a]
val sl = candSlot[a]
val st = candStrain[a]
var b = a - 1
while (b >= 0 && (candStrain[b] < st || (candStrain[b] == st && (candOwner[b] > o || (candOwner[b] == o && candSlot[b] > sl))))) {
candOwner[b + 1] = candOwner[b]
candSlot[b + 1] = candSlot[b]
candStrain[b + 1] = candStrain[b]
b--
}
candOwner[b + 1] = o
candSlot[b + 1] = sl
candStrain[b + 1] = st
}
var k = 0
while (k < candidates && brokenLastStep < maxBreaksPerStep) {
if (breakSlot(candOwner[k], candSlot[k])) brokenLastStep++
k++
}
}
brokenTotal += brokenLastStep
return brokenLastStep
}
fun breakEdge(i: Int, j: Int): Boolean
Breaks the edge between i and j by hand, in both directions, whether or not it is
strained. Returns false if there was no live constraint between them. Fires onTear.
fun breakEdge(i: Int, j: Int): Boolean {
var found = false
for (slot in 0 until 4) {
if (neighbor(i, slot) == j && stiffness(i, slot) != 0f) {
retire(i, slot)
found = true
}
if (neighbor(j, slot) == i && stiffness(j, slot) != 0f) {
retire(j, slot)
found = true
}
}
if (found) {
brokenTotal++
onTear?.invoke(minOf(i, j), maxOf(i, j))
}
return found
}
fun detach()
Unregisters the pass from sim. Already-broken edges stay broken.
fun detach() {
sim.removeConstraintEdit(name)
}
Retires slot slot of particle i and its reverse. Returns false if the slot was already
dead — which happens routinely, because the reverse direction of an edge broken earlier in
this same scan is still in the loop's way.
private fun breakSlot(i: Int, slot: Int): Boolean {
val j = neighbor(i, slot)
if (j < 0 || stiffness(i, slot) == 0f) return false
retire(i, slot)
if (j < constraints.size) {
for (s in 0 until 4) {
if (neighbor(j, s) == i) retire(j, s)
}
}
onTear?.invoke(minOf(i, j), maxOf(i, j))
return true
}
Strain of one slot, or null if the slot is unused, dead, or has no rest length to divide by.
source — 44 lines, from line 219
private fun slotStrain(state: SimdVerlet.State3, i: Int, slot: Int): Float? {
val j = neighbor(i, slot)
if (j < 0 || j >= state.size) return null
if (stiffness(i, slot) == 0f) return null
val rest = restLength(i, slot)
if (rest <= 0f) return null
val dx = state.x[j] - state.x[i]
val dy = state.y[j] - state.y[i]
val dz = state.z[j] - state.z[i]
val dist = sqrt(dx * dx + dy * dy + dz * dz)
if (!dist.isFinite()) return null
return (dist - rest) / rest
}
private fun neighbor(i: Int, slot: Int): Int = when (slot) {
0 -> constraints.n0[i]
1 -> constraints.n1[i]
2 -> constraints.n2[i]
else -> constraints.n3[i]
}
private fun stiffness(i: Int, slot: Int): Float = when (slot) {
0 -> constraints.s0[i]
1 -> constraints.s1[i]
2 -> constraints.s2[i]
else -> constraints.s3[i]
}
private fun restLength(i: Int, slot: Int): Float = when (slot) {
0 -> constraints.r0[i]
1 -> constraints.r1[i]
2 -> constraints.r2[i]
else -> constraints.r3[i]
}
private fun retire(i: Int, slot: Int) = constraints.setSlot(i, slot, -1, 0f, 0f)
private fun growCandidates() {
val next = max(16, candOwner.size * 2)
candOwner = candOwner.copyOf(next)
candSlot = candSlot.copyOf(next)
candStrain = candStrain.copyOf(next)
}
}
fun SimdVerlet.addTearing
Registers tearing on this simulation and hands back the live handle: retune Tearing.threshold, read Tearing.brokenTotal, call Tearing.breakEdge to cut something by hand, all between frames.
val tear = sim.addTearing(threshold = 0.35f)
repeat(400) { cloth.step(gravity = 4f, iterations = 16) }
println("${tear.brokenTotal} edges gone")
@param threshold strain (a fraction of rest length) above which an edge is retired.
@param maxBreaksPerStep < 0 (the default) tears everything over threshold at once, which is
what a real sheet failing under load does. A small positive number spreads the failure over
frames, most strained edge first, which is what you want when the tear is the thing being
watched. It is a different rip and not merely a slower one, and that is worth knowing before
reaching for it: a sheet failing one edge per frame goes on carrying load while it fails, so
more of it reaches the threshold. Measured on SimdVerletTearingTest's 14 x 9 sheet, 14 edges
go unthrottled and 22 at one per step. Deterministic either way.
@param constraints the set to edit; defaults to the one the simulation is solving.
fun SimdVerlet.addTearing(
threshold: Float = 0.5f,
range: IntRange? = null,
maxBreaksPerStep: Int = -1,
name: String = "tearing",
constraints: SimdVerlet.DirectedConstraints4? = this.constraints4,
onTear: ((i: Int, j: Int) -> Unit)? = null
): Tearing {
val set = requireNotNull(constraints) {
"cannot tear: this simulation has no 4-slot constraint set (add a Cloth, or set constraints4)"
}
val tearing = Tearing(this, set, name, threshold, range, maxBreaksPerStep, onTear)
addConstraintEdit(name) { state, _ -> tearing.scan(state) }
return tearing
}
fun Cloth.enableTearing
Tearing for one cloth, wired to its curvature stencil.
The extra thing this does over SimdVerlet.addTearing is the reason the hook exists: Cloth's
curvature force pulls each interior node toward the mean of its four axis neighbours, and that
stencil is written from the grid, not from the constraint set. Tear an edge and the curvature
force goes on flattening the sheet across the rip — the two lips of the tear are held together
by a force that no longer has a spring behind it, and the sheet heals in the picture while the
solver says it is torn. So the tear callback suppresses curvature at both endpoints
(Cloth.suppressCurvatureAt), which removes exactly the stencils that spanned the broken edge.
@param range defaults to this cloth's own particles, so a tear in one cloth doesn't cut another.
fun Cloth.enableTearing(
threshold: Float = 0.5f,
maxBreaksPerStep: Int = -1,
passName: String = "$name.tearing",
alsoOnTear: ((i: Int, j: Int) -> Unit)? = null
): Tearing = sim.addTearing(
threshold = threshold,
range = base until end,
maxBreaksPerStep = maxBreaksPerStep,
name = passName,
constraints = constraints,
onTear = { i, j ->
suppressCurvatureAt(i)
suppressCurvatureAt(j)
alsoOnTear?.invoke(i, j)
}
)
fun SimdVerlet.peakStrain(range: IntRange? = null): Float
The largest (distance - rest) / rest over every live constraint the simulation is solving, or
-1f if it is solving none.
The measurement behind the stiffness table in dev_docs/physics/SIMDVERLET_4_CONSTRAINTS.md,
which had to be re-derived by hand each time it was wanted. It is also the number to read before
choosing a tear Tearing.threshold: whatever a sheet's settled peak strain is, a threshold
below it tears the sheet at rest.
Returns Float.NaN if any live constraint's endpoints have gone non-finite — the diverged case
is worth surfacing rather than skipping, because a solver that has blown up otherwise reports
a perfectly reasonable-looking peak strain from whichever edges are still numbers.
fun SimdVerlet.peakStrain(range: IntRange? = null): Float {
val c4 = constraints4
val c3 = if (c4 == null) constraints else null
if (c4 == null && c3 == null) return -1f
val n = state.size
val start = (range?.first ?: 0).coerceAtLeast(0)
val end = (if (range == null) n else range.last + 1).coerceAtMost(n)
var peak = -1f
var sawNonFinite = false
fun consider(i: Int, j: Int, rest: Float, stiff: Float) {
if (j < 0 || j >= n || stiff == 0f || rest <= 0f) return
val dx = state.x[j] - state.x[i]
val dy = state.y[j] - state.y[i]
val dz = state.z[j] - state.z[i]
val dist = sqrt(dx * dx + dy * dy + dz * dz)
if (!dist.isFinite()) {
sawNonFinite = true
return
}
val s = (dist - rest) / rest
if (s > peak) peak = s
}
for (i in start until end) {
if (c4 != null) {
if (i >= c4.size) break
consider(i, c4.n0[i], c4.r0[i], c4.s0[i])
consider(i, c4.n1[i], c4.r1[i], c4.s1[i])
consider(i, c4.n2[i], c4.r2[i], c4.s2[i])
consider(i, c4.n3[i], c4.r3[i], c4.s3[i])
} else if (c3 != null) {
if (i >= c3.size) break
consider(i, c3.n0[i], c3.r0[i], c3.s0[i])
consider(i, c3.n1[i], c3.r1[i], c3.s1[i])
consider(i, c3.n2[i], c3.r2[i], c3.s2[i])
}
}
return if (sawNonFinite) Float.NaN else peak
}
fun SimdVerlet.liveEdgeCount(range: IntRange? = null): Int
How many live distance constraints the simulation is solving, counting a symmetric pair once —
the same edge count SimdVerlet.debugLines draws, so "the picture lost 40 lines" and "40 edges
tore" are the same number.
body — 43 lines, from line 389
fun SimdVerlet.liveEdgeCount(range: IntRange? = null): Int {
val c4 = constraints4
val c3 = if (c4 == null) constraints else null
if (c4 == null && c3 == null) return 0
val n = state.size
val start = (range?.first ?: 0).coerceAtLeast(0)
val end = (if (range == null) n else range.last + 1).coerceAtMost(n)
var count = 0
fun reverseExists(from: Int, to: Int): Boolean {
if (c4 != null) {
if (from >= c4.size) return false
return (c4.n0[from] == to && c4.s0[from] != 0f) || (c4.n1[from] == to && c4.s1[from] != 0f) ||
(c4.n2[from] == to && c4.s2[from] != 0f) || (c4.n3[from] == to && c4.s3[from] != 0f)
}
val c = c3 ?: return false
if (from >= c.size) return false
return (c.n0[from] == to && c.s0[from] != 0f) || (c.n1[from] == to && c.s1[from] != 0f) ||
(c.n2[from] == to && c.s2[from] != 0f)
}
fun consider(i: Int, j: Int, stiff: Float) {
if (j < 0 || j >= n || stiff == 0f) return
if (j < i && j >= start && j < end && reverseExists(j, i)) return
count++
}
for (i in start until end) {
if (c4 != null) {
if (i >= c4.size) break
consider(i, c4.n0[i], c4.s0[i])
consider(i, c4.n1[i], c4.s1[i])
consider(i, c4.n2[i], c4.s2[i])
consider(i, c4.n3[i], c4.s3[i])
} else if (c3 != null) {
if (i >= c3.size) break
consider(i, c3.n0[i], c3.s0[i])
consider(i, c3.n1[i], c3.s1[i])
consider(i, c3.n2[i], c3.s2[i])
}
}
return count
}