Skip to content

Add bird flock - #421

Merged
markaren merged 4 commits into
devfrom
boids
Aug 15, 2026
Merged

Add bird flock#421
markaren merged 4 commits into
devfrom
boids

Conversation

@markaren

Copy link
Copy Markdown
Owner

This PR adds a bird flock to threepp that can be used to populate scenes.

markaren and others added 4 commits August 15, 2026 17:26
Skinned, tet, displaced and grass meshes have always deformed inside the
frame command buffer. A PLAIN mesh whose attributes are rewritten every
frame — Flock's merged bird mesh, a CPU trail, a host-driven soft body —
fell to the occasional-edit path instead and paid, per dirty frame, one
vkDeviceWaitIdle plus two one-shot submit+vkQueueWaitIdle pairs. Three
full device drains to move 1.7k vertices, and the drains cost roughly a
GPU frame each because that is what they wait for. The particle
billboards' explicit "never flag them geomDirty, it would fire a
per-frame vkDeviceWaitIdle" exclusion was a patch over the same hole.

A BlasRecord dirty kDynamicGraduationStreak (3) frames in a row now
graduates to perFrameDynamic for the rest of its life and takes
recordDynamicGeomRefits instead: positions and normals are packed into a
per-frames-in-flight staging ring at RECORD time — past that slot's
fence, which is what makes the host write provably not race the GPU copy
a still-in-flight frame issued from the same slot — then the
vertex->prevVertex snapshot, the staging->vertex/normal copies and the
batched BLAS refit are recorded into the frame cb with barriers. Zero
submits, zero waits. Graduation is one-way; a topology change destroys
the record and its replacement starts cold. refreshGeomBlasBatch and its
drain remain for genuinely occasional edits, and only run when a
non-graduated op needs them.

No cross-frame WAR fence before the copies, on purpose. The prior frame
may still be reading vertex/normal when they execute, but a barrier wide
enough to cover every reader (deferred_shade's ray-query fetches are
COMPUTE) also fences the previous frame's whole post chain: measured
+6 ms/frame, handing back everything the drain removal bought. Every
existing deformer already writes its BLAS buffers under exactly this
exposure; this path matches their contract rather than inventing a
stricter one it cannot afford.

Auto-LOD stops churning on deforming geometry. A chain enqueued while a
mesh is being edited is stale on arrival — drainLodResults drops it on
the geomVersion mismatch, the dirty pass resets lodState, selection
re-enqueues, forever — so a full attribute snapshot plus a background
simplification were burned every frame and nothing was ever selectable.
Selection now requires a quiet window (kLodDirtyQuietFrames, 8) since the
last edit and skips graduated records outright.

Measured on flock_demo --vulkan, 24 birds, RTX 4070, headless with
presents suppressed, slopes taken at 600/1200/2400 frames so the
teardown constant falls out: 5.11 -> 4.53 ms/frame, against a 4.40 ms
GPU floor the new path now sits on (frame.0_fenceWait absorbs the rest,
which is what healthy GPU-bound pipelining looks like). The win scales
with GPU frame time, since that is what a drain waits for.

Naive wall-clock A/B says the opposite and is wrong three times over:
an occluded window's present paces the whole pipelined loop, a
per-frame device drain accidentally bypasses that pacing, and a
pipelined exit carries a ~4.3 s teardown constant an empty scene pays
too. THREEPP_VULKAN_SUPPRESS_PRESENT only engages on a headless canvas.

Verified: editor --selftest ALL PASS on GL and Vulkan; flock_demo
--vulkan strict validation clean (exit 0); GL and Vulkan captures of
the same deterministic frame agree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flock.hpp states three properties in prose and nothing enforced any of
them. Flock_test does, cheaply enough (0.3 s) to sit in every run.

Determinism is the headline claim and gets an EXACT float comparison,
because "bit-identical for the same binary, the same seed and the same
dt sequence" is what the banner promises and an epsilon would test a
weaker promise. The replay drives a three-period dt cycle so dtSmoothed
never settles and a mid-run startle so the disturbance path is covered,
then a second flock on a different seed must DIVERGE — without that
control the identity check would also pass for a flock pinned
motionless at home.

The perch bake's blocking and amortised forms must produce byte-equal
tables, which is the whole reason PerchIndex budgets by work-unit count
rather than milliseconds: a time budget would make a bird's trajectory
depend on machine speed. PerchSpot's defaulted operator== takes that
claim literally.

The soak asserts a landing actually FIRES, which is the bug class
--selftest was built around: a state machine that compiles, runs,
produces no NaN and never once perches.

One fixture lesson worth stating, because it cost an hour and is not a
library bug: authored perches packed closer than about twice
separationDistance, with half the flock committed at once, turn the
final-metre capture into a shoving match no approach survives — zero
landings in 40 s. The first rail put 8 spots 2 m apart in a line; the
same 8 on a 14 m circle land immediately. Baked scenes scatter spots
and never hit it; a designer authoring one railing through addPerch
can.

Flock also opts out of renderer auto-LOD. The whole mesh is rebaked in
place every update(), so a chain would simplify a pose one frame from
gone. The renderer now refuses to build one for a per-frame deformer
anyway; this is the feature saying so itself, the way terrain tiles do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add > Flock creates a Group carrying userData["flock"], and the node's
POSITION is the territory's home — the transform gizmo is the authoring
tool for where the birds live, the way a particle field's node is its
emitter frame. The birds are never document nodes: FlockPlaySession
builds one Flock per authored node at Play, adds it at the scene root
(the simulation is world-space and the mesh wants an identity parent,
which Flock.hpp states in capitals), starts an AMORTISED perch bake so
a heavy scene costs a few frames of birds-not-landing-yet instead of a
hitch on the button, and Stop restores a document that never saw them.
A saved scene therefore carries eleven scalars, not 2,256 vertices of
bird, and save/load/prefabs/.tpz work for free because the config rides
the same userData channel Tree and Granular already round-trip.

THE PERCH BAKE MUST NOT SEE THE EDITOR'S OWN CHROME, and this is the
bug the wiring was written around. Gizmo handles, light markers and
waypoint pucks are real meshes in the graph the bake traverses;
heightAt reports the highest sampled surface in a column, so a marker
hovering at altitude becomes the ground under it and the floor clamp
launches the whole flock onto a phantom floor. Measured over the flat
template scene: centroid y=12 -> y=430 within one second of the bake
completing, which on Vulkan reads as the birds vanishing upward and on
GL as them behaving strangely. FlockPlaySession::setMeshFilter carries
the exclusion to Flock::setPerchFilter and EditorApp wires
isEditorOnly. Any future session that bakes against the editor scene
needs the same filter, so the selftest keeps a trap: after four
seconds of play the birds must be under y=60 and above y=-5, bounds no
honest cruise band reaches and only a poisoned bake crosses.

Selecting a flock draws its extents through the existing spawn-box
helper, which now serves three configs: the roam edge and the 0.75x
ring where the bounds force starts to bite, the same circle again at
expected-ground level with a drop line and tick (a tick off the real
floor is the one spatial fact a misplaced flock node gets wrong), the
altitude band at +/- cruiseAltitude*altitudeSpread, and the wind arrow.
altitudeSpread is surfaced in the inspector for that reason: it was in
Flock::Params, it is the thickness of the band the helper draws, and
0 flies a plane that reads as a formation rather than a flock. The new
node spawns at y=14, the default cruise altitude, so a fresh flock over
a ground-at-origin scene reads as placed right.

Verified: editor --selftest ALL PASS on GL and Vulkan (factory, config
round trip, undo, helper, and the phantom-floor trap),
EditorFlockConfig_test 15 assertions, Flock_test 8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@markaren
markaren marked this pull request as ready for review August 15, 2026 20:10
@markaren
markaren merged commit d8e21a2 into dev Aug 15, 2026
10 checks passed
@markaren
markaren deleted the boids branch August 15, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant