Skip to content

[M2-PART-01] Particle simulation (CPU, 2D + depth) - #81

Merged
offdev merged 1 commit into
masterfrom
feat/m2-part-01-particle-simulation
Oct 7, 2026
Merged

offdev merged 1 commit into
masterfrom
feat/m2-part-01-particle-simulation

Conversation

@offdev

@offdev offdev commented Oct 7, 2026

Copy link
Copy Markdown
Owner

M2-PART-01 · Particle simulation (CPU, 2D + depth) (FR-2.7)

Depends: M1-ECS-03 (merged). Scope only — nothing else.

The CPU-simulation half of FR-2.7 ("CPU-simulated, 2D + depth, budgeted, pooled"). The render half (particles as batched sprites — one draw call per emitter set, the key from the particle's depth) is M2-PART-02 and consumes this step's read-only surface (liveParticles()).

API — NEW laige::ParticleSystem<Backend> (header-only, sim side)

  • Templated over the SimMath backends (the presentation.h pattern); Particle is 40 B on both backends (verified by static assert).
  • ParticleEmitterDef — the spawn domain: origin (spawns exactly here — no position jitter in M2-PART-01), depth (constant over life — the M2-ISO-01 depth-key input M2-PART-02 feeds), velMin/velMax (per-tick box), lifeMin/lifeMax (sim ticks), sizeMin/sizeMax, tint[4] (RGBA u8), fadeTicks (0 = no fade), continuousRate (particles per sim tick; 0 = burst-only — unbounded by design: the pool budget bounds the work, overflow drops).
  • create(options) — maxParticles in [1, 2^16] (default 4096), maxEmitters in [1, 256] (default 32), any u64 seed; two pre-allocations (the pool, the emitter table) — the setup path, nothing allocates afterward. Public default ctor = stopped state; move-only (moved-from = stopped).
  • addEmitter(def) — dense ids (1..N, registration order); first-failure-wins validation in the documented order (origin → depth → vel_min → vel_max → vel_box → life_min → life_box → life_max → size_min → size_max → size_box → fade_ticks); rejection leaves the registry unchanged + one rate-limited particles/emitter_invalid warn (fields emitter, field); registry full → BudgetExhausted + particles/emitters_exhausted warn; stopped → InvalidArgument (no log).
  • burst(id, count) — explicit spawn in the sim phase; stopped/unknown id → InvalidArgument (no log); count 0 no-op; O(count).
  • update() — exactly once per completed sim tick (ARCH-002, the advanceAnimations pattern): (1) advance live (live-array order: age += 1, pos += vel — one SimMath add, ADR 0002; age >= life kills by swap removal — the swapped-in particle is re-examined at the same index), (2) emit from every emitter in registration order, (3) overflow report. O(live + Σrates), zero allocation, no logging on the happy path.
  • Lifetime semantics — a particle emitted during tick T's update is visible for EXACTLY life renders (age 0..life-1, dies during tick T+life's update); a burst-spawned particle is advanced by the same tick (visible for life − 1 renders — documented in the per-tick contract).
  • liveParticles() — non-owning span<const Particle> (the compact live prefix — spawn order with swap removal); the render path reads it read-only after the sim phase (M2-GL-02 cull/batch stage, ARCH-009).
  • fadeAlpha(p) — exact u32 integer math: remaining = life − age; alpha = tint.a * remaining / fadeTicks inside the fade window (max product 255 × 2^20 < 2^32) — no floating point, bit-identical.
  • Introspection: valid(), maxParticles(), maxEmitters(), seed(), liveCount(), spawnedTotal()/droppedTotal() (u64), stats() (the DBG-008 feed), emitterCount(), hasEmitter(id), emitterAt(id), prngSeed()/prngStatePart1()/prngStatePart2() (the replay identity — the M1-DET-03 pattern).

Determinism (ARCH-010, ADR 0002)

State after N updates = pure function of (seed, defs, operation sequence). Each successful spawn consumes exactly four Prng draws in a fixed order (vx, vy, life, size); each uniform scalar is lerp(min, max, u / 2^24) (the Prng's 24-bit tap resolution — one documented rounding per backend). A dropped spawn consumes no draws (the pool check precedes the draws — drops never perturb the stream). fpx16_16: bit-identical across all builds/platforms/ISAs; fp32_pinned: same build/platform (the detcheck matrix owns cross-target claims).

Pool budget (FR-2.7 "budgeted, pooled", PERF-003/008)

Pre-allocated at create; overflow = drop (counted in droppedTotal, at most one rate-limited particles/pool_overflow warn per tick — fields dropped/live/capacity; the LOG-004 logger window is a second layer). Never grows, never throws, never mutates live state.

Tests — new ctest entry particles (17 tests / 10 suites, both backends, no GL)

  • ParticlesCreate — create domain edges (0 / 2^16 / 2^16+1; 0 / 256 / 257 — first failure wins, no log on rejection), the stopped-state matrix, move-stops-source (no log).
  • ParticlesEmitter — dense ids, the validation matrix (every rejection with pinned warn fields; fp32 NaN cases + fpx box cases), state-unchanged-on-rejection, registry BudgetExhausted (pinned capacity field), no-log happy path.
  • ParticlesBurst — a degenerate def's EXACT particle fields (Q16.16 raws / dyadic floats), count-0 no-op, unknown-id/stopped no-log, the pool drop (pinned dropped/live/capacity fields, one warn).
  • ParticlesUpdate — hand-computed per-tick advance goldens both backends (vel (0.5, −0.25) from (1,2): Q16.16 raw x = 65536 + 32768k, y = 131072 − 16384k; float 1.5/2.0/2.5/3.0 & 1.75/1.5/1.25/1.0), death at exactly age == life (5 renders for life 5), the swap-removal live order.
  • ParticlesContinuous — the rate + the first two spawns' (vel, life, size) cross-checked against an independent Prng oracle (the 4-draw contract) + the pool-full one-warn-per-tick pin.
  • ParticlesDeterminism — 50 ticks + a burst every 10th tick on a capacity-8 pool (drops occur): same seed → bit-identical live set (every field, both backends) + the machine-greppable particles-determinism … fnv1a=0x… state hash (FNV-1a 64); a different seed diverges; an independent Prng advanced by 4 × spawnedTotal lands on the system's stream state (the no-draws-on-drop contract).
  • ParticlesFade — the EXACT integer fade table (life 8/fade 4/tint.a 255 → [255,255,255,255,255,191,127,63]; tint.a 200 → 150/100/50; fade 0 constant) — direct + through the system.
  • ParticlesPoolExhaustion — the EXACT per-tick table (capacity 3, rate 2, life 4): live [2,3,3,3,3,3,3,3,3,3], droppedTotal [0,1,3,5,5,6,8,10,10,11], cumulative warns [0,1,2,3,3,4,5,6,6,7].
  • ParticlesStats — the exact ParticleStats feed after 5 ticks ({4 live, 4 capacity, 8 spawned, 2 dropped}).
  • ParticlesZeroAlloc — 500 ticks × 6 spawns + periodic bursts under the owner-thread allocation watch: 0 heap allocations + the machine-greppable particles-zeroalloc ticks=500 allocs=0 line.

Verification

  • All six local trees warning-clean + full ctest green: build 115/115, build-clang 115/115, build-release 104/104, build-shared 115/115, build-asan 112/112, build-tsan 112/112 (prior counts +1 each — the new particles entry).
  • ctest -R particles green (17/17, both backends).
  • laige-api.json regenerated LAST (1342 → 1410 symbols, 40 → 41 headers); api-real-tree/api-check-fresh, tools/laige-include-lint (70 files), tools/laige-determinism-lint (29 sim files, 0 violations) all green.
  • No new budgets.json entry (count stays 16 — the BudgetHarnessTable pin; the particle→sprite budget is measured in M2-PART-02 against the composite 50k render-CPU budget).
  • Additive only — no existing symbol's signature or meaning changed.

Docs (same patch)

  • NEW docs/api/particles.md (the full contract: API table, per-tick contract, pool budget, determinism scope, color fade, failure table, ownership/threading, DOC-004 Performance, performant example + misuse warnings).
  • docs/README.md (API index entry + laige-sim doc list), src/laige-sim/README.md (module status paragraph), docs/concepts/coordinates.md (NEW §4.11 + the §5 "Particle state → sprite items" row (Planned, M2-PART-02) + Related).
  • Roadmap box checked; progress board M2 20/33, total 66/194; change-log row.

NEW laige::ParticleSystem<Backend> (header-only, templated over the
SimMath backends — the presentation.h pattern; sim side, render never
depends on it): the bounded pre-allocated pool (maxParticles, 40 B
per particle), the dense-id emitter registry (ParticleEmitterDef:
origin, depth, velocity box, life box, size box, tint, fade window,
continuousRate), burst (explicit) + continuous (per sim tick)
emission, and the once-per-sim-tick update() (ARCH-002: advance ->
emit -> overflow report).

Per-tick contract: advance (age += 1, pos += vel — one SimMath add,
ADR 0002; age >= life kills by swap removal — the swapped-in particle
re-examined at the same index), emit in registration order, then at
most one rate-limited pool_overflow warn (LOG-004). A particle
emitted in tick T is visible for exactly life renders; a
burst-spawned particle is advanced by the same tick (life - 1 renders
— documented).

Pool budget (FR-2.7 budgeted/pooled, PERF-003/008): pre-allocated at
create (pool + emitter table — the only allocations); overflow =
drop (counted, warned) — never grows, never throws, never mutates
live state; stats() is the DBG-008 feed.

Determinism (ARCH-010, ADR 0002): state = f(seed, defs, operations).
Each successful spawn = exactly FOUR Prng draws (vx, vy, life, size);
each uniform scalar = lerp(min, max, u/2^24) (the Prng's 24-bit tap);
a DROPPED spawn consumes no draws (the pool check precedes the draws
— drops never perturb the stream). fpx16_16 bit-identical across
platforms/builds; fp32_pinned same-build/same-platform. The Prng
state is exposed for the replay identity (the M1-DET-03 pattern).

Color fade: exact u32 integer arithmetic (remaining = life - age;
alpha = tint.a * remaining / fadeTicks inside the fade window) — no
floating point, bit-identical on every platform.

Stopped state / move-only / first-failure-wins validation (emitter
order: origin -> depth -> vel_min -> vel_max -> vel_box -> life_min
-> life_box -> life_max -> size_min -> size_max -> size_box ->
fade_ticks; registry full -> BudgetExhausted + warn; unknown burst id
/ stopped -> InvalidArgument, no log — the house precedents).

Tests: new ctest entry particles (17 tests / 10 suites, both
backends, no GL): create domain + stopped + move-stops-source, the
emitter validation matrix (pinned warn fields), the exact burst
fields, hand-computed per-tick advance/death goldens + the swap
removal live order, the continuous rate vs an independent Prng
oracle (the 4-draw contract), fixed-seed determinism (bit-identical
live sets + the machine-greppable FNV-1a state hash + different-seed
divergence + the 4*spawnedTotal oracle), the exact fade table, the
EXACT per-tick pool-exhaustion table (live/dropped/warns), the stats
feed, and the zero-allocation 500-tick window (0 owner-thread allocs).

Docs: NEW docs/api/particles.md, docs/README index + laige-sim doc
list, src/laige-sim/README, coordinates.md (NEW 4.11 + the 5
conversion row), roadmap box + board (M2 20/33, total 66/194) +
change-log row. No budgets.json entry (count stays 16 — the
BudgetHarnessTable pin; the particle->sprite budget is measured in
M2-PART-02). laige-api.json regenerated LAST (1342 -> 1410 symbols,
40 -> 41 headers).

All six local trees warning-clean + full ctest green (build 115/115,
build-clang 115/115, build-release 104/104, build-shared 115/115,
build-asan 112/112, build-tsan 112/112); ctest -R particles green
(17/17); api-real-tree/api-check-fresh, include-lint (70 files), and
determinism-lint (29 sim files, 0 violations) green.
@offdev
offdev merged commit f8343b4 into master Oct 7, 2026
11 checks passed
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