Repository navigation
[M2-PART-01] Particle simulation (CPU, 2D + depth) - #81
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)presentation.hpattern);Particleis 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)—maxParticlesin [1, 2^16] (default 4096),maxEmittersin [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-limitedparticles/emitter_invalidwarn (fieldsemitter,field); registry full →BudgetExhausted+particles/emitters_exhaustedwarn; 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, theadvanceAnimationspattern): (1) advance live (live-array order:age += 1,pos += vel— one SimMath add, ADR 0002;age >= lifekills 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.liferenders (age 0..life-1, dies during tick T+life's update); a burst-spawned particle is advanced by the same tick (visible forlife − 1renders — documented in the per-tick contract).liveParticles()— non-owningspan<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 / fadeTicksinside the fade window (max product 255 × 2^20 < 2^32) — no floating point, bit-identical.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-limitedparticles/pool_overflowwarn per tick — fieldsdropped/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, registryBudgetExhausted(pinnedcapacityfield), 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 (pinneddropped/live/capacityfields, 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 independentPrngoracle (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-greppableparticles-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 exactParticleStatsfeed 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-greppableparticles-zeroalloc ticks=500 allocs=0line.Verification
particlesentry).ctest -R particlesgreen (17/17, both backends).laige-api.jsonregenerated 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.budgets.jsonentry (count stays 16 — theBudgetHarnessTablepin; the particle→sprite budget is measured in M2-PART-02 against the composite 50k render-CPU budget).Docs (same patch)
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).