luv

Workshop wiki

physics-and-simd.org

Physics data and SIMD execution

A seam, not a specification #K2F6WD

This page thinks about a Catto-inspired physics experiment for the little block world, using three studies as material: the Box3D field notes (Box3D's architecture), the sb-simd field notes (sb-simd), and the domain/bundle model of #D5M8BZ. It tries to identify the seam: where CLOS objects, generic functions, and live inspection belong, and where dense columnar state and closed loops belong, such that neither side has to apologize to the other.

The first narrow decision is now executable. The block-world sequence #S2K5HN has a scalar fixed-step player AABB with voxel collision, grounding, gravity, and jumping. It is intentionally a behavioral oracle, not the general engine described below; this page keeps that small playable step from accidentally foreclosing the later one.

The general engine now exists as well, at the size #G7Q3XR asked for: #V090DQ records what was built and #IDVK7G through #NZCNHC what it turned out to be. The proposals below stand as the reasoning that led there.

The first executable seam #H7P4CX

The current block-world-player is separate from both terrain and camera. It owns one position, velocity, shape, and grounded reading. At 120 Hz a closed scalar step accelerates horizontal velocity from input, applies gravity, and resolves its AABB axis by axis against candidate voxel cells. The camera is then synchronized to the player's eye height. Space is an edge-triggered jump request rather than upward flight. Grounded movement also jumps automatically when a horizontal contact has a clear body-sized space one block above it; a two-block wall remains a wall.

This is deliberately less machinery than a general rigid-body engine, but it establishes several contracts that survive the next representations:

The next physics step should not elaborate this controller into a framework. It should introduce the first dense awake-body columns and a terrain-contact domain beside it, run the same scalar behavior through those columns, and retain this controller as the small oracle used to compare later four-wide kernels.

Bodies, contacts, and constraints are domains of their own #Y5H8KC

The domain/bundle vocabulary was developed for terrain, but its real claim is broader: a finite domain identifies sites, and fields have values over those sites (#B8R3KF). Box3D, read in this vocabulary, is a physics engine built from exactly such domains — with the twist that domain membership is dynamic:

The important structural observation: these domains have different sites, different lifecycles, and different legal operations, even though they all describe one world. A body is not a block; a contact is not a body; none of them wants to live inside the terrain bundle. This is the same conclusion #A6X2RT reached for terrain products — ownership and invalidation, not layout, decide what is stored together — now applied to simulation state.

For luv the immediate consequence is pleasantly concrete: "add physics" does not mean adding fields to chunks. It means introducing a body domain (with a bundle of kinematic columns), a contact domain derived jointly from bodies and terrain, and keeping the block-content bundle as what it already is — the static environment those domains query.

The sparse–dense split is the row/column distinction with a forwarding address #B5N8JT

Box3D splits every simulated thing into a stable sparse struct (identity, topology, user data) and moving dense rows (hot numerical state), with the sparse side holding a forwarding address that swap-removal must patch (#W2M9FJ). In the vocabulary of #C9L2WA this is precisely the distinction between a persistent object and a row projected from columns — except that Box3D, lacking a garbage collector, also had to invent checkable identity: ids carry generations so staleness is detectable data (#N7Q4XS).

A luv body can therefore be an honest CLOS object — the sparse side — owning its name, its shape references, its joints, its userland meaning, and a set-index=/=local-index forwarding address into whichever materialization currently holds its numbers. The columns never contain object references; the object knows where its row is. Inspection composes naturally: describe on the body can present its row as if it were slots, the way Box3D's natvis visualizers reunite what the layout separated.

What Lisp does not get for free is the rest of the id story:

The forwarding-address invariant — every relocation immediately patches the owner — is a five-line discipline in Lisp, but it is the invariant that makes compaction, sleep-as-relocation, and dense iteration compatible with stable identity. It deserves a validation walker from day one, in the spirit of Box3D's b3ValidateSolverSets, because a live image will violate it in creative ways no C program can.

Select the phase once, then run closed loops #R9M4PV

The hot path of a physics step must not dispatch per site. The arrangement #R3F8YC anticipates — a generic operation selects a representation or phase once, borrows specialized arrays, and runs an ordinary optimized loop — maps directly onto how Box3D structures a step: prepare (gather from domains into per-step constraint arrays), then substep iterations of closed loops over those arrays, then store (scatter results back) (#R7F2QH).

Generic functions own the boundaries: which integrator, which solver flavor per constraint kind, which manifold function per shape pair — Box3D's own narrow phase is a type-pair function table, that is, a manual generic function. Inside a phase, the loop is closed: locally declared, non-consing, over specialized arrays it borrowed from bundles. Bodies never become tiny dispatching objects because bodies never appear in the loop at all — only columns do.

The first sb-simd study forced honesty (#L2W6KT): the archived library and the machine's old SBCL 2.5.10 had no arm64 Lisp-level SIMD. That is now history, not a deployment constraint. Luv pins and supplies SBCL 2.6.7; sb-simd is part of the project environment on both arm64 and x86-64, and the live arm64 image has executed f32.4 arithmetic as a NEON FADD (#W9K4DN). The implementation order can therefore be simpler:

  1. A declared scalar reference kernel remains the executable specification, tail implementation, and debugging path. It is not a substitute forced on one of luv's supported CPU architectures.
  2. A common native four-float path uses 128-bit f32.4: NEON on arm64 and SSE-family instructions on x86-64. This is exactly Box3D's chosen width (#T3C8FV), so four-wide constraint batches can be a real design target without making the physics layout architecture-specific.
  3. Optional wider x86-64 paths may use AVX or AVX-512 when runtime instruction-set availability and measurement justify them. They are specializations of the same phase, not a reason to make a separate kind of image or change the logical bundle schema.
  4. The GPU, which luv already programs through its own SPIR-V assembler, remains a possible home for sufficiently wide regular work, at the price of crossing the completion frontier (#T9K4RC).
  5. A foreign kernel is now an escape hatch only for a measured limitation in SBCL or sb-simd, not the expected route to NEON. The Lisp definition remains its oracle in sb-simd's scalar-mirror style (#E8M3JC).

The design consequence is the same at every tier, and this is the point: what a kernel needs is not merely an instruction set but a layout contract — struct-of-arrays columns and, if constraints are ever packed four-wide, the coloring disjointness invariant that makes gather/scatter safe (#T3C8FV). The shared 128-bit width makes the first wide layout portable across luv's CPU targets even though the emitted instructions differ. Generic functions can select the phase once; instruction-set-case can select the best native kernel for the current CPU without exposing that choice to bodies, contacts, or bundle schemas (#C3F7XM).

The smallest solver worth building #G7Q3XR

A first slice that exercises the seam without importing an engine:

Everything here stays live: the solver phases are generic functions one can redefine mid-pile, the contact domain is inspectable, and a describe of a body shows its row. The CLOS block world already proved this style renders; the question this slice answers is whether it simulates.

DONE The smallest solver was built: spheres, coloured contacts, four-wide lanes #V090DQ

Intent. Build the slice #G7Q3XR proposed, no wider: spheres against the voxel lattice and each other, the Soft Step skeleton, persistent contacts, colouring from the first day, one scalar reference kernel and one four-wide kernel per instruction family, and enough game around it that the block world gains a ball to throw, a fountain, and a lava spring.

Evidence. luvcraft/physics.lisp is the engine, luvcraft/physics-simd.lisp the wide kernels, luvcraft/balls.lisp the game's side, and luvcraft/physics-tests.lisp the claims. A dropped ball rests a slop under its radius and sleeps; a bouncy ball rises again and a dead one does not; a ball rolling across cell seams neither hops nor slows; a pile of six sleeps and a thrown ball wakes it; a walking box pushes a ball ahead of it; a mortal body's contacts begin and end and it expires; a terrain edit wakes what stood on it; and the NEON family reaches the same state hash as the scalar family after 120 steps of a 200-ball pile (#7PAQ3M). In the game, the ball hand item throws and scatters, the fountain and the lava spring throw drops and gobbets, and a turtle struck by a ball turns away.

Done when. The eight tests above pass and the game runs them live. They do, at commit f39a687 and after. What remains is listed in #NZCNHC.

The step as built #IDVK7G

One step-physics-world runs, in this order and with these owners:

  1. Reset. The event buffer empties and the terrain probe forgets its chunks: nothing borrowed survives a step.
  2. Grid. Every body, awake or asleep, is dropped into a uniform hash grid keyed by its centre, cell size a little over the largest diameter.
  3. Pairs. Each awake body walks the twenty-seven cells around it. A sleeping neighbour within reach is woken if the awake body is moving faster than the wake speed and is otherwise leaned on as if it were terrain; each awake pair is generated once, from the lower row. Woken bodies are appended to the awake set and this same pass reaches them.
  4. Terrain. Each awake body asks the probe about the solid cells within its radius plus a margin and offers one contact per exposed face, edge, or corner that passes the overshoot rule of #MYWH16.
  5. Boxes. Kinematic boxes the client posted -- the player, the animals -- first wake the sleepers they have walked into, then contact the awake set as moving terrain with a velocity.
  6. Prune. Contact rows whose pair was not seen this step are swap-removed and their hash entries patched; a touching one emits an end event.
  7. Prepare. Contacts are coloured (#SSMPYW) and the constraint buffer is written in colour order: anchors, tangent frame, masses, softness, the pre-solve approach speed, and the impulses carried from last step.
  8. Substeps. Four times: integrate velocities; warm start; solve with bias; integrate positions into the delta lanes; relax without bias, where friction and rolling resistance also act. Static contacts sit in the highest colours and are therefore solved last in each pass.
  9. Restitution, once, only for contacts that pushed and arrived above the threshold; then the impulses go back to their contact rows, touching flips emit begin and end events, and hard arrivals on boxes or on bodies that asked emit hit events.
  10. Finalize. Deltas fold into positions, the mortal age (the sleepers too), the slow accumulate sleep time and relocate to the sleeping set.

Bodies are rows of one generated columnar layout in two buffers -- awake and sleeping -- with a handle table as forwarding address (#B5N8JT); the awake buffer keeps one row past its end as the static dummy every one-sided contact reads. Contacts are rows of another layout, persistent, keyed by a packed pair; the constraint buffer is a third layout rebuilt each step. Every column is a specialized array; every kernel borrows them and runs a closed loop. The seam #K2F6WD asked for holds: the world, its parameters, its events, and its kernel family are CLOS and specials; nothing inside a substep dispatches.

defun step-physics-world physics.lisp:2257
defunstep-physics-world
world&optionaldt

Advance world by dt seconds (its step by default) and return it. Events from the step are then readable until the next step begins. #IDVK7G

let*
dt
coerce
ordt
physics-world-step-secondsworld
'single-float
h
/dtsubsteps
inv-h
/h
kernels
physics-world-kernelsworld
awake
physics-world-awakeworld
constraints
physics-world-constraintsworld
starts
physics-world-color-startsworld
push-max
threshold
started
get-internal-real-time
declare
single-floatdthinv-hpush-maxthreshold
fixnumsubsteps
physics-event-columns-reset
physics-world-eventsworld
incf
physics-world-step-countworld
reset-terrain-probe
physics-world-probeworld
physics-world-terrainworld

Contacts.

let

The Soft Step loop.

dotimes
substepsubsteps
let
elapsed
*hsubstep
declare
single-floatelapsed
physics-warm-startkernelsconstraintsawakestarts
physics-solve-contactskernelsconstraintsawakestartsinv-htelapsedpush-max
physics-solve-contactskernelsconstraintsawakestartsinv-hnil
+elapsedh
push-max
physics-apply-restitutionkernelsconstraintsawakestartsthreshold
setf
physics-world-last-step-contact-countworld
physics-constraint-columns-lengthconstraints
physics-world-last-step-color-countworld
color-count
setf
physics-world-last-step-real-secondsworld
/
-
get-internal-real-time
started
coerceinternal-time-units-per-second'double-float
world

The voxel grid is the static tree, and the overshoot rule keeps seams smooth #MYWH16

A sphere near the lattice does not query a tree (#D9W4CH): it enumerates the cells within its reach through a probe that keeps up to eight chunks' storage borrowed for the step, so a neighbourhood that straddles a chunk edge costs a lookup only the first time. For each solid cell the closest point of the cell's box to the centre gives the contact: on a face, an edge, or a corner, or -- when the centre is inside the box -- through the face of least depth.

The rule that makes rolling across a floor of cells silent is about which of these contacts to keep. Let the overshoot of an axis be the direction in which the centre lies outside the box on that axis. A contact is offered only if no solid cell lies in any non-empty combination of the overshoot directions. A ball resting on cell A and reaching over the seam to cell B finds its closest point on B's top edge; but the centre overshoots B in the direction of A, A is solid, and so B offers nothing -- A's own top face, which is coplanar and nearer, is the only contact made. Convex edges and corners, where the overshoot cells are air, still offer their contacts, which is what lets a ball roll smoothly off a ledge. The rule is uniform: one neighbour to check for a face, three for an edge, seven for a corner; it needs the reach to stay under one cell, which bounds the radii this engine serves.

The same closest-point machinery serves kinematic boxes with every face exposed and no neighbour rule. Missing terrain is a wall, as it is for the walking bodies: a body near ground that has not streamed in leans on the boundary rather than falling through the world (#H2W9DJ).

defun generate-physics-terrain-contacts physics.lisp:1292
defungenerate-physics-terrain-contacts
world

Give every awake body a contact with each exposed terrain face it is near.

The voxel grid is the static tree (#D9W4CH). A cell's face, edge, or corner is only offered when no solid cell lies in the direction the sphere's centre overshoots it, which is what keeps a ball rolling across a floor of cells from catching on the seams between them: the neighbouring cell's own face is always the nearer, truer contact, and it is the only one made. #MYWH16

let*
awake
physics-world-awakeworld
probe
physics-world-probeworld
margin
coerce*physics-terrain-margin*'single-float
declare
single-floatmargin
unless
physics-world-terrainworld
records:with-columnar-buffer-storage
lengthrow
xsx
ysy
zsz
radiiradius
handleshandle
awakephysics-body-columns
declare
ignorerow
dotimes
let*
cx
arefxsi
cy
arefysi
cz
arefzsi
radius
arefradiii
handle
arefhandlesi

The neighbour rule needs the centre within one cell of the cells it looks at.

reach
min0.98f0
+radiusmargin
min-x
floor
-cxreach
max-x
floor
+cxreach
min-y
floor
-cyreach
max-y
floor
+cyreach
min-z
floor
-czreach
max-z
floor
+czreach
declare
single-floatcxcyczradiusreach
fixnummin-xmax-xmin-ymax-ymin-zmax-zindex-a
loopforxfixnumfrommin-xtomax-xdo
loopforyfixnumfrommin-ytomax-ydo
loopforzfixnumfrommin-ztomax-zdo
when
let
box-min-x
floatx0f0
box-min-y
floaty0f0
box-min-z
floatz0f0
declare
single-floatbox-min-xbox-min-ybox-min-z
%with-sphere-box-contact
nxnynzpxpypzseparation
cxcyczradiusbox-min-xbox-min-ybox-min-z
+box-min-x1f0
+box-min-y1f0
+box-min-z1f0
reach:accept-p

No solid neighbour in any overshoot direction.

not
or
and
/=outside-x0
terrain-probe-solid-pprobe
+xoutside-x
yz
and
/=outside-y0
terrain-probe-solid-pprobex
+youtside-y
z
and
/=outside-z0
terrain-probe-solid-pprobexy
+zoutside-z
and
/=outside-x0
/=outside-y0
terrain-probe-solid-pprobe
+xoutside-x
+youtside-y
z
and
/=outside-x0
/=outside-z0
terrain-probe-solid-pprobe
+xoutside-x
y
+zoutside-z
and
/=outside-y0
/=outside-z0
terrain-probe-solid-pprobex
+youtside-y
+zoutside-z
and
/=outside-x0
/=outside-y0
/=outside-z0
terrain-probe-solid-pprobe
+xoutside-x
+youtside-y
+zoutside-z
world

Colours are the lanes: the constraint buffer is written in colour order #SSMPYW

Each step every live contact is given a colour by greedy bitset probing over awake rows (#G3W7KD): a body–body contact takes the lowest colour in which neither body is marked, from twenty colours; a contact with terrain or a box takes the highest free colour, marking only its body; what fits nowhere goes to the overflow colour. A counting sort then writes the constraint buffer so that each colour's constraints are contiguous, and a small array of colour starts is all a kernel needs.

Two things follow. Because static contacts hold the top colours, they are solved last within every pass, the ordering Box3D found reduces push-through. And because no two constraints of a colour touch the same awake body, four consecutive constraints of a colour can be gathered into f32.4 lanes -- twelve gathers of body state per side, the constraint columns loaded straight -- solved together, and scattered back with no lane's write landing on another lane's read. A colour's tail of fewer than four goes to the scalar kernel. Kernels are generic functions on the world's kernel family, dispatched once per phase, never per contact.

The measured colouring of a 3000-ball pile uses ten to twelve colours, so the twenty-four available are not close to overflow.

defun prepare-physics-constraints physics.lisp:1472
defunprepare-physics-constraints
worldh

Colour the live contacts and fill the constraint buffer in colour order. Return the number of colours in use, the overflow colour counted. #SSMPYW

declare
single-floath
optimize
speed3
safety1
let*
awake
physics-world-awakeworld
contacts
physics-world-contactsworld
constraints
physics-world-constraintsworld
ids
physics-world-idsworld
awake-count
physics-body-columns-lengthawake
contact-count
physics-contact-columns-lengthcontacts
dynamic-colors
-max-colors4
overflowmax-colors
starts
physics-world-color-startsworld
declare
fixnumawake-countcontact-countmax-colorsdynamic-colorsoverflow
ensure-physics-color-scratchworldawake-countcontact-count
let
bits
physics-world-color-bitsworld
colors
physics-world-contact-colorsworld
handle-as
physics-contact-columns-handle-a-lanecontacts
handle-bs
physics-contact-columns-handle-b-lanecontacts
kinds
physics-contact-columns-kind-lanecontacts
declare
type
simple-arrayfixnum
colorshandle-ashandle-bskinds
fillstarts0

Pass one: colour.

dotimes
rowcontact-count
let*
handle-a
arefhandle-asrow
local-a
aref
physics-id-table-localids
dynamic-b-p
and
=
aref
physics-id-table-setids
physics-handle-index
arefhandle-bsrow
+physics-set-awake+
local-b
ifdynamic-b-p
aref
physics-id-table-localids
physics-handle-index
arefhandle-bsrow
-1
coloroverflow
declare
fixnumlocal-alocal-bcolor
ifdynamic-b-p
loopforcfixnumfrom0belowdynamic-colorsdo
let
vector
arefbitsc
declare
simple-bit-vectorvector
when
and
zerop
sbitvectorlocal-a
zerop
sbitvectorlocal-b
setf
sbitvectorlocal-a
1
sbitvectorlocal-b
1
colorc

Static contacts colour from the top down: solved last.

loopforcfixnumfrom
1-max-colors
downto0do
let
vector
arefbitsc
declare
simple-bit-vectorvector
when
zerop
sbitvectorlocal-a
setf
sbitvectorlocal-a
1
colorc
setf
arefcolorsrow
color
incf
arefstarts
1+color

Prefix sums: STARTS[c] is where colour C begins.

loopforcfrom1to
1+overflow
do
incf
arefstartsc
arefstarts
1-c
%ensure-physics-constraint-capacityconstraintscontact-count

Pass two: fill, with a moving cursor per colour.

let
cursor
make-array
+2max-colors
:element-type'fixnum
declare
dynamic-extentcursor
replacecursorstarts
multiple-value-bind
dynamic-biasdynamic-mass-scaledynamic-impulse-scale
multiple-value-bind
static-biasstatic-mass-scalestatic-impulse-scale
physics-softness
min
*2f0
coerce*physics-contact-hertz*'single-float
*0.125f0
/h
*0.5f0
h
let
slop
coerce*physics-linear-slop*'single-float
dummyawake-count
declare
single-floatslop
fixnumdummy
dotimes
rowcontact-count
let*
color
arefcolorsrow
slot
arefcursorcolor
handle-a
arefhandle-asrow
local-a
aref
physics-id-table-localids
dynamic-b-p
and
=
aref
physics-id-table-setids
physics-handle-index
arefhandle-bsrow
+physics-set-awake+
local-b
ifdynamic-b-p
aref
physics-id-table-localids
physics-handle-index
arefhandle-bsrow
dummy
nx
ny
nz
ra
aref
%lanephysics-body-columnsawakeradius
local-a
rb
ifdynamic-b-p
aref
%lanephysics-body-columnsawakeradius
local-b
0f0
ima
aref
%lanephysics-body-columnsawakeinverse-mass
local-a
imb
aref
%lanephysics-body-columnsawakeinverse-mass
local-b
iia
aref
%lanephysics-body-columnsawakeinverse-inertia
local-a
iib
aref
%lanephysics-body-columnsawakeinverse-inertia
local-b

Anchors: from each centre to the contact point. A's runs along the normal; B's against it.

rax
*ranx
ray
*rany
raz
*ranz
rbx
-
*rbnx
rby
-
*rbny
rbz
-
*rbnz
normal-mass
let
k
+imaimb
if
pluspk
/k
0f0
tangent-mass
let
k
+imaimb
*iiarara
*iibrbrb
if
pluspk
/k
0f0
rolling-mass
let
k
+iiaiib
if
pluspk
/k
0f0

A tangent frame from the normal.

t1x0f0
t1y0f0
t1z0f0
declare
fixnumcolorslotlocal-alocal-b
single-floatnxnynzrarbimaimbiiaiibraxrayrazrbxrbyrbznormal-masstangent-massrolling-masst1xt1yt1z
if
>
absny
0.9f0

The normal is near vertical: cross with X.

let*
cynz
cz
-ny
l
sqrt
the
single-float0f0
+
*cycy
*czcz
setft1x0f0t1y
/cyl
t1z
/czl

Cross with Y.

let*
cx
-nz
cznx
l
sqrt
the
single-float0f0
+
*cxcx
*czcz
setft1x
/cxl
t1y0f0t1z
/czl
let*
t2x
-
*nyt1z
*nzt1y
t2y
-
*nzt1x
*nxt1z
t2z
-
*nxt1y
*nyt1x
kvx
kvy
kvz

Approach speed now, for restitution later.

vax
aref
%lanephysics-body-columnsawakevx
local-a
vay
aref
%lanephysics-body-columnsawakevy
local-a
vaz
aref
%lanephysics-body-columnsawakevz
local-a
vbx
+
aref
%lanephysics-body-columnsawakevx
local-b
kvx
vby
+
aref
%lanephysics-body-columnsawakevy
local-b
kvy
vbz
+
aref
%lanephysics-body-columnsawakevz
local-b
kvz
relative-velocity
+
*
-vbxvax
nx
*
-vbyvay
ny
*
-vbzvaz
nz
restitution
ifdynamic-b-p
max
aref
%lanephysics-body-columnsawakerestitution
local-a
aref
%lanephysics-body-columnsawakerestitution
local-b
aref
%lanephysics-body-columnsawakerestitution
local-a
friction
ifdynamic-b-p
sqrt
the
single-float0f0
*
aref
%lanephysics-body-columnsawakefriction
local-a
aref
%lanephysics-body-columnsawakefriction
local-b
aref
%lanephysics-body-columnsawakefriction
local-a
rolling
ifdynamic-b-p
max
aref
%lanephysics-body-columnsawakerolling-resistance
local-a
aref
%lanephysics-body-columnsawakerolling-resistance
local-b
aref
%lanephysics-body-columnsawakerolling-resistance
local-a
declare
single-floatt2xt2yt2zkvxkvykvzvaxvayvazvbxvbyvbzrelative-velocityrestitutionfrictionrolling
macrolet
put
lanevalue
`
setf
arefslot
,value
putcontactrow
putbody-alocal-a
putbody-blocal-b
putnxnx
putnyny
putnznz
putt1xt1x
putt1yt1y
putt1zt1z
putt2xt2x
putt2yt2y
putt2zt2z
putraxrax
putrayray
putrazraz
putrbxrbx
putrbyrby
putrbzrbz
putkvxkvx
putkvykvy
putkvzkvz
putseparation
+
arefrow
slop
putnormal-massnormal-mass
puttangent-masstangent-mass
putrolling-massrolling-mass
putrestitutionrestitution
putfrictionfriction
putrolling-resistancerolling
putrelative-velocityrelative-velocity
puttotal-normal-impulse0f0
ifdynamic-b-p
progn
putbias-rate
*dynamic-mass-scaledynamic-bias
putmass-scaledynamic-mass-scale
putimpulse-scaledynamic-impulse-scale
progn
putbias-rate
*static-mass-scalestatic-bias
putmass-scalestatic-mass-scale
putimpulse-scalestatic-impulse-scale

Warm start from what the pair carried.

putnormal-impulse
aref
%lanephysics-contact-columnscontactsnormal-impulse
row
puttangent-impulse-1
aref
%lanephysics-contact-columnscontactstangent-impulse-1
row
puttangent-impulse-2
aref
%lanephysics-contact-columnscontactstangent-impulse-2
row
putrolling-impulse-x
aref
%lanephysics-contact-columnscontactsrolling-impulse-x
row
putrolling-impulse-y
aref
%lanephysics-contact-columnscontactsrolling-impulse-y
row
putrolling-impulse-z
aref
%lanephysics-contact-columnscontactsrolling-impulse-z
row
setf
arefcursorcolor
1+slot

How many colours actually got contacts.

loopforcfrom0tooverflowcount
<
arefstartsc
arefstarts
1+c

Bitwise agreement is the wide kernel's contract #7PAQ3M

The claim #E8M3JC recommends -- the scalar definition is the oracle of the fast one -- is made strictly here: after 120 steps of a 200-ball pile, or 200 steps of a 1000-ball one, the state hash of every position and velocity is identical between the scalar and the NEON families. What buys it:

One sharp edge was met and routed around: sb-simd 2.6.7's NEON f32.4-sqrt is mis-encoded and raises SIGILL (#4UVLSQ), so the two square roots a lane group needs -- the friction and rolling clamps -- go through the scalar unit lane by lane, which is bit-identical to what the vector instruction would give.

Sleep is relocation, and boxes and edits wake #QKS4GZ

A body slower than the sleep speed for half a second -- speed measured as Box3D does, the larger of its velocity and half its position correction rate (#P8N4TC) -- is copied to the sleeping buffer and swap-removed from the awake one, with both moved rows' handles patched (#S5K3WM). Its velocity is zeroed on the way; the eye draws both buffers. There are no islands: a sleeping body is woken by any awake body faster than the wake speed whose reach touches it, by any moving kinematic box that overlaps it, by a terrain edit within a few cells (the client calls wake-physics-bodies-near), or by having its velocity set. A slow awake body resting on a sleeper leans on it as if it were terrain. Sleepers still age: a mortal drop that comes to rest expires on time.

The consequence for a pile is Box3D's, one step at a time rather than by island: a thrown ball wakes what it hits, those wake their neighbours next step, and the whole heap sleeps again half a second after it stops. validate-physics-world walks every forwarding address and every contact key, the way b3ValidateSolverSets does, and the tests call it after each relocation-heavy scenario.

defun sleep-physics-body physics.lisp:675
defunsleep-physics-body
worldhandle

Move handle's body out of the awake set; return T if it was awake. #QKS4GZ

when
multiple-value-bind
setlocal
declare
ignorelocal
when
let*
sleeping
physics-world-sleepingworld

A sleeper is still: its velocity is not merely unread.

setf
aref
physics-body-columns-vx-lanesleeping
new-local
0f0
aref
physics-body-columns-vy-lanesleeping
new-local
0f0
aref
physics-body-columns-vz-lanesleeping
new-local
0f0
aref
physics-body-columns-wx-lanesleeping
new-local
0f0
aref
physics-body-columns-wy-lanesleeping
new-local
0f0
aref
physics-body-columns-wz-lanesleeping
new-local
0f0
aref
physics-body-columns-dx-lanesleeping
new-local
0f0
aref
physics-body-columns-dy-lanesleeping
new-local
0f0
aref
physics-body-columns-dz-lanesleeping
new-local
0f0
physics-event-columns-push
physics-world-eventsworld
:slepthandle+physics-no-body+nil0f00f00f00f0
t

What a step costs #YYJGYO

Measured on the arm64 development machine, four substeps, no game running, each figure the mean over a run in which every body stays awake:

bodiescontactsscalar msNEON mscolours
100023421.501.159
300066874.563.9610

The same 1000 balls settle into a sleeping pile in about two seconds, after which a step costs 0.1 ms; the wide kernels are worth about a fifth of the whole step because the solve is not the whole step. A statistical profile of the awake 3000-body case put the pair pass first, the NEON solve second, and preparation third; the pair pass is the twenty-seven-cell walk and its hash lookups, the natural next target if larger populations matter.

The game runs a fountain (two drops a step, life 2.6 s), a lava spring, and a few dozen balls at three to four hundred awake bodies for about 0.25 ms a step, and draws every body as a cube through the block pipeline in another 0.2 ms of vertex building.

Allocation: what the profile taught, and how not to be misled by it #QFMBFO

The step allocates only with change: a settled pile costs about a kilobyte a step, a violently churning 3000-body pile up to a hundred kilobytes. Three findings on the way there are worth keeping:

What still allocates under churn was not run to ground; it is proportional to contacts made and dropped and to events, and small enough to leave for now (#NZCNHC).

Things with weight, as the player meets them #JAU0EF

The engine knows spheres; the game knows kinds (*body-kinds* in luvcraft/balls.lisp): a ball, a marble, a water drop, a lava gobbet, each a tile of the atlas, a radius, a mass, a bounce, a friction, a life. Every body is drawn as a cube of its tile turned by its orientation, written by one closed loop into the block pipeline's vertex format, so a rolling ball visibly rolls and a gobbet glows through the same bloom as a crystal.

The ball is a hand item: B takes it out, left click throws one from the eye along the view with the player's own velocity added, right click scatters a handful of marbles. The fountain and the lava spring are placeable blocks of a spring-block-kind; the session finds them in the world's authored edits, learns of new ones through luvcraft-block-placed, and each step lets each one throw what it throws, deterministically from its coordinate and a count. Water drops meet only terrain and boxes, not each other, which is what lets a fountain be hundreds of bodies for a fraction of a millisecond. The player and every animal are posted into the step as kinematic boxes: a walking player kicks what is underfoot, and a ball arriving hard on a turtle's box startles it (critter-struck), which is a step event read back into an animal's mind.

Open after the first pile #NZCNHC

The block world is the static tree #D9W4CH

Box3D queries static geometry through an AABB tree because its static world is a soup of arbitrary shapes. A voxel world is better than a tree for this purpose: the grid is the acceleration structure, and a swept AABB enumerates candidate cells directly. The broad phase against terrain degenerates to arithmetic, and only body–body pairs need anything like Box3D's proxy machinery — at little-block-world scale, brute force over a handful of fat AABBs is honest and inspectable.

Two Box3D ideas still transfer:

The chunk-boundary environment question returns with force here: a body standing on an absent neighbor chunk must not fall through the world. Missing is not air (#H2W9DJ); for physics the safe boundary policy is closer to "missing is temporarily solid" or "bodies near non-resident terrain do not simulate" — which is, pleasingly, exactly a sleep mechanism: put bodies whose support is absent into a not-yet-simulable set and wake them when residency arrives.

defun generate-physics-terrain-contacts physics.lisp:1292
defungenerate-physics-terrain-contacts
world

Give every awake body a contact with each exposed terrain face it is near.

The voxel grid is the static tree (#D9W4CH). A cell's face, edge, or corner is only offered when no solid cell lies in the direction the sphere's centre overshoots it, which is what keeps a ball rolling across a floor of cells from catching on the seams between them: the neighbouring cell's own face is always the nearer, truer contact, and it is the only one made. #MYWH16

let*
awake
physics-world-awakeworld
probe
physics-world-probeworld
margin
coerce*physics-terrain-margin*'single-float
declare
single-floatmargin
unless
physics-world-terrainworld
records:with-columnar-buffer-storage
lengthrow
xsx
ysy
zsz
radiiradius
handleshandle
awakephysics-body-columns
declare
ignorerow
dotimes
let*
cx
arefxsi
cy
arefysi
cz
arefzsi
radius
arefradiii
handle
arefhandlesi

The neighbour rule needs the centre within one cell of the cells it looks at.

reach
min0.98f0
+radiusmargin
min-x
floor
-cxreach
max-x
floor
+cxreach
min-y
floor
-cyreach
max-y
floor
+cyreach
min-z
floor
-czreach
max-z
floor
+czreach
declare
single-floatcxcyczradiusreach
fixnummin-xmax-xmin-ymax-ymin-zmax-zindex-a
loopforxfixnumfrommin-xtomax-xdo
loopforyfixnumfrommin-ytomax-ydo
loopforzfixnumfrommin-ztomax-zdo
when
let
box-min-x
floatx0f0
box-min-y
floaty0f0
box-min-z
floatz0f0
declare
single-floatbox-min-xbox-min-ybox-min-z
%with-sphere-box-contact
nxnynzpxpypzseparation
cxcyczradiusbox-min-xbox-min-ybox-min-z
+box-min-x1f0
+box-min-y1f0
+box-min-z1f0
reach:accept-p

No solid neighbour in any overshoot direction.

not
or
and
/=outside-x0
terrain-probe-solid-pprobe
+xoutside-x
yz
and
/=outside-y0
terrain-probe-solid-pprobex
+youtside-y
z
and
/=outside-z0
terrain-probe-solid-pprobexy
+zoutside-z
and
/=outside-x0
/=outside-y0
terrain-probe-solid-pprobe
+xoutside-x
+youtside-y
z
and
/=outside-x0
/=outside-z0
terrain-probe-solid-pprobe
+xoutside-x
y
+zoutside-z
and
/=outside-y0
/=outside-z0
terrain-probe-solid-pprobex
+youtside-y
+zoutside-z
and
/=outside-x0
/=outside-y0
/=outside-z0
terrain-probe-solid-pprobe
+xoutside-x
+youtside-y
+zoutside-z
world

Sleep is residency, and it prices determinism #S6T2MV

Box3D's deepest structural rhyme with the block-world design: sleeping is relocation out of the iterated arrays (#S5K3WM), and chunk eviction is relocation out of the resident world (#W8D2MT). Both say that "inactive" should mean absent from the hot path, not flagged within it. A luv simulation adopting the domain model gets the shape of sleep almost for free: a sleeping island is one more materialization with a smaller schema.

Determinism is the decision this page must flag rather than make. Box3D shows it is a whole-design property — reduction shapes, ordering funnels, even the math library (#D2V7MK) — and that it is cheap to police once a state hash exists, but expensive to retrofit. Luv's situation is genuinely different: a live image where the user redefines a solver method mid-pile is gloriously, intentionally non-replayable. The honest options seem to be:

  1. declare determinism a non-goal and enjoy the freedom (Box3D's no-FMA, no-libm-trig diet stops applying);
  2. aim for same-image, fixed-code reproducibility — deterministic ordering and reductions, a per-step state hash as a regression tool — without cross-platform ambitions; or
  3. full Box3D-style determinism, priced into every kernel and every future backend tier of #R9M4PV (a foreign shim or GPU pass would have to match the Lisp oracle bitwise, which Accelerate and GPUs will not promise).

Option 2 looks like the workshop's natural stance: the state hash is a debugging instrument and a test oracle, not a shipped contract. But it should be chosen, not drifted into — the first parallel or foreign kernel sets it.

defun physics-world-state-hash physics.lisp:2332
defunphysics-world-state-hash
world

A hash of every body's position and velocity, in set and row order, so two runs of the same code can be compared. Same-image reproducibility is the claim (#S6T2MV); this is how it is policed.

let
hash0
declare
type
unsigned-byte62
hash
flet
mix
value
setfhash
logand
+
*hash1099511628211
sb-kernel:single-float-bits
coercevalue'single-float
#x3fffffffffffffff
dolist
columns
list
physics-world-awakeworld
physics-world-sleepingworld
records:with-columnar-buffer-storage
countrow
xsx
ysy
zsz
vxsvx
vysvy
vzsvz
columnsphysics-body-columns
declare
ignorerow
dotimes
icount
mix
arefxsi
mix
arefysi
mix
arefzsi
mix
arefvxsi
mix
arefvysi
mix
arefvzsi
hash

Questions to keep open #X3K8FP