diff --git a/budgets.json b/budgets.json index 65b8c87..e9a7cef 100644 --- a/budgets.json +++ b/budgets.json @@ -66,6 +66,14 @@ "measured": 0.225852, "workload": "10 000 32-bit depth keys, one stable radix sort (M2-SORT-01, PRD 8.1)" }, + { + "name": "particle_render_10k", + "metric": "mean", + "unit": "ms", + "target": 2.0, + "measured": 1.07798, + "workload": "10 000-particle particle-to-sprite conversion, one O(n) declare pass (M2-PART-02)" + }, { "name": "sim_heap_allocs", "metric": "max", diff --git a/docs/README.md b/docs/README.md index 311e889..927e151 100644 --- a/docs/README.md +++ b/docs/README.md @@ -333,8 +333,17 @@ still to land. constant depth + velocity + life + size, the exact integer color fade, the once-per-sim-tick `update()` (ARCH-002), and the fixed-seed determinism contract (the 4-draw Prng contract, the - machine-greppable state hash). The render half (particles as - batched sprites) lands in M2-PART-02. + machine-greppable state hash). + - [Particle rendering](api/particle_render.md) — + `laige::render::declareParticles`: the render half of FR-2.7 + (M2-PART-02): the O(n) particle → sprite conversion pass — every + live particle becomes one batcher `SpriteItem` declaration (shared + particle atlas, one draw call per emitter set, the engine-owned + depth key from the particle's depth value — quantized to an + integer step height first, sub-unit depths ties-to-even), the + exact fade through the tint alpha, zero per-frame allocations, + the `particle_render_10k` conversion budget (the 10k declare + pass, both SimMath backends). ## Guides diff --git a/docs/api/particle_render.md b/docs/api/particle_render.md new file mode 100644 index 0000000..9bd8575 --- /dev/null +++ b/docs/api/particle_render.md @@ -0,0 +1,170 @@ +# Particle rendering (`laige::render` particle_render) + +The rendering half of FR-2.7 — "Lightweight GPU particle system +(CPU-simulated, 2D + depth), budgeted, pooled" (PRD §15 M2, FR-2.7; +AGENTS CORE-002/004/005/008, PERF-002/003, RENDER-001/003/006, +ARCH-009, ADR 0002). The O(n) particle → sprite conversion pass: +every LIVE particle of a +[`ParticleSystem`](particles.md) (M2-PART-01) becomes one +[`SpriteItem`](sprite_batcher.md) declaration in the sprite +batcher's frame window — shared particle atlas, one draw call per +emitter set (one system = one `(atlasId, materialId, blend)` group, +RENDER-001), depth from the particle's depth value (the M2-ISO-01 +key, the engine-owned `depthKey`, G-R11). Public header: +`src/laige-render/include/laige/render/particle_render.h` (header-only +— the API is a template over the SimMath backends, the +[`presentation.h`](../src/laige-sim/include/laige/sim/presentation.h) +pattern). Unit suite: `ctest -R particle_render` +(`tests/laige-render/particle_render_tests.cpp`) — pure data + batcher +bookkeeping, no GL environment required: it runs in every local tree +and in CI, and the goldens are hand-computed from the M2-ISO-01 key +formula (dyadic values in the cross-backend exactness zone). + +The canonical narrative home is +[`docs/concepts/coordinates.md`](../concepts/coordinates.md) §4.11 +(ARCH-008) + the §5 conversion row. The pass reads the +[`particles.md`](particles.md) read-only surface (`liveParticles()`, +`fadeAlpha`) — no write-back (ARCH-009: presentation never mutates +authoritative state); the depth goes through +[`iso_depth_key.md`](iso_depth_key.md) (M2-ISO-01) with the particle +depth quantized to an integer step height (this header's +`particleDepthToStepHeight`); the declarations land in +[`sprite_batcher.md`](sprite_batcher.md) (M2-SPRITE-01). + +## The API + +| Member | Returns | Notes | +|---|---|---| +| `particleDepthToStepHeight(depth)` | `int32` | the particle's depth value → the M2-ISO-01 step height: clamp to `[-kIsoDepthMaxStepHeight, +kIsoDepthMaxStepHeight]` (±2047) FIRST, then round nearest, ties-to-even (fpx: `fpx16_16::toInt32`; fp32: `std::nearbyint` in the pinned rounding mode — the two backends agree on dyadic depths); NaN (fp32) clamps to +2047 (the float-order fall-through — the key is presentation-only, ARCH-009); pure O(1), no allocation, no logging, no GL | +| `declareParticles(batcher, system, options)` | `Status` | the O(live) conversion pass: every live particle (live-array order — spawn order with swap removal, the deterministic declaration order) becomes one `SpriteItem` in the batcher's OPEN frame window (the frame-protocol section); the FIRST failed add fails the call with the batcher's error; empty system → no-op success (no adds attempted); O(live) batcher adds, no allocation, no logging, no GL | + +`ParticleDeclareOptions` is a plain value (the scene config — the +emitter set's shared render attributes): `atlasId` (the shared +particle atlas), `materialId`, `blend` (default `BlendMode::Additive` +— the particle convention: "Additive (src + dst): particles, light, +glow"), `uv` (the shared atlas sub-rect; default the full atlas), +`layer` (the M2-ISO-01 key layer value; default +`kIsoDepthGroundLayer` = 0 — parallax particle layers are out of +scope for this step). One emitter set = one `ParticleSystem` = one +`(atlasId, materialId, blend)` group = one instanced draw call +(RENDER-001, FR-2.7): multiple sets declare into the same frame +window and group independently. + +`kTintChannelDivisor` = 255.0f (the u8 → render-float tint divisor). + +## Per-particle declaration (the conversion contract) + +Each live particle `p` declares one `SpriteItem` (the batcher's +frame-scoped slot; read it back with the batcher's `at(slot)`): + +| `SpriteItem` field | Value | +|---|---| +| `pos` | `p.pos` at the RENDER-006 boundary (sim units → render floats — one documented rounding per component per backend: fpx `fpx16_16::toFloat`, fp32 identity) | +| `depthKey` | `isoDepthKey(p.pos, particleDepthToStepHeight(p.depth), options.layer)` — the engine-owned key (G-R11); `depthOverride` stays false | +| `scale` | `(p.size, p.size)` at the RENDER-006 boundary (the square sprite, world units) | +| `tint` | `p.tint[0..2] / 255` (RGB, u8 → float) + `fadeAlpha(p) / 255` (alpha — the M2-PART-01 exact u32 fade, one presentation-only rounding per channel) | +| `uv` | `options.uv` (the shared rect — no per-particle UV in this step) | +| `frameIndex` / `rotation` | 0 (no per-particle animation / rotation in this step) | +| `atlasId` / `materialId` / `blend` | `options` (the set's shared attributes) | + +Declaration order is the live-array order (spawn order with swap +removal, M2-PART-01) — the deterministic tie-break within equal keys +(RENDER-003, the batcher's stable sort keeps it). + +## The depth conversion (RENDER-006, ADR 0002) + +The particle's depth is a SIM-space value; the M2-ISO-01 key takes an +INTEGER step height (`|z| <= kIsoDepthMaxStepHeight` = 2047, the key's +documented z domain). `particleDepthToStepHeight` bridges the two: + +1. **Clamp FIRST** to `[-2047, +2047]` (per backend — SimMath has no + ordering: fpx compares the raw integers, fp32 the IEEE floats; a + NaN falls through to the +2047 clamp, the float-order + fall-through), +2. **Round** nearest, ties-to-even — fpx `fpx16_16::toInt32` (the + fpx16_16.h policy), fp32 `std::nearbyint` (the pinned default + rounding mode — the same policy, NOT `llround`, which rounds half + away from zero and would diverge on 0.5), + +so sub-unit depths land on the nearest integer step: 0.5 → 0, 1.5 → +2 (both backends agree on dyadic depths — the cross-backend exactness +zone; the key is presentation-only outside it, ARCH-009). Saturation: +±3000 → ±2047. The key then packs exactly as the M2-ISO-01 table: +`(layer + 512) << 22 | (round((x + y) * 16) - z * 16 + 2^21)`. + +## The frame protocol + +`declareParticles` declares into the batcher's frame window — the +window must be OPEN (after `create()`/`beginFrame()`, before +`build()`). The per-frame pattern (the M2-GL-02 cull/batch stage): + +```cpp +batcher.beginFrame(); +declareParticles(batcher, systemA, optionsA); // emitter set A +declareParticles(batcher, systemB, optionsB); // emitter set B +// ... other scene declarations (sprites, tiles, parallax) ... +batcher.build(); +// submit: batcher.batches() — one instanced draw call per group +``` + +- **Stopped batcher** (never created / capacity 0): the first add + fails → `BudgetExhausted` (an empty system still succeeds — no adds + are attempted). +- **Closed window** (after `build()`): the first add fails → + `InvalidArgument` (the batcher's frame-protocol contract). +- **Frame budget overflow** (more particles than the batcher's + `maxSprites`): the batcher's own policy applies — drop OLDEST + declaration + one rate-limited warn, the add succeeds, and + `declareParticles` returns success (the overflow is a + batcher-internal decision, not a conversion failure). +- **First failure wins**: a failed add returns the batcher's error + immediately (the conversion is a plain loop; no recovery). + +## Performance (DOC-004) + +- **Complexity**: O(live) per call — one key computation, one tint + computation, and one batcher `add` per live particle. No scan of + dead pool slots (the live prefix, M2-PART-01). +- **Allocations**: NONE — the particle state is pooled + (M2-PART-01) and the batcher's frame storage is pre-allocated at + `create` (FR-2.7 "pooled"). Verified: 1 000 frames of + 10 000-particle declare+build loops allocate 0 heap blocks on the + loop thread (`ParticleRenderZeroAlloc`, the alloc-watch gate). +- **Budget**: the conversion pass is budgeted — the + `particle_render_10k` entry in [`budgets.json`](../../budgets.json) + (mean, ms, target 2.0, the PRD §8.1-style gate; measured 1.09108 on + the canonical Debug tree, the worse of the two backends). The + batcher's `build`/sort cost is the separate + `depth_sort_10k` budget (the frame protocol keeps the two + quantities apart: the budget measures `declareParticles` alone — + `beginFrame`/`build` are outside the measured window). Baseline: + [`docs/benchmarks/baselines/m2-particle-render.md`](../benchmarks/baselines/m2-particle-render.md). +- **Batching**: one emitter set = one `(atlasId, materialId, blend)` + group = ONE instanced draw call (RENDER-001) regardless of particle + count — 10 000 particles of one set cost one draw call. Multiple + sets (different atlas/blend) group independently. +- **Common traps**: + - declaring into a CLOSED window (after `build()`) — + `InvalidArgument` on the first add (call `beginFrame()` first); + - forgetting `beginFrame()` between frames — the previous frame's + window is closed by `build()`; + - treating `depthKey` as authoritative state — it is + presentation-only (ARCH-009); the sim depth is the source of + truth; + - per-particle rotation / animation / UV in this step — out of + scope (`frameIndex`/`rotation` stay 0; the shared `uv` rect + covers the set); + - more live particles than the batcher's `maxSprites` — the + oldest particles are dropped (the batcher's budget; size the + batcher to the scene's worst-case visible count, the + SpriteBatcher `create` contract). + +## Determinism (ARCH-010, ADR 0002) + +The declared `(depthKey, pos.x, pos.y)` sequence is a pure function +of the system state (seed, emitter definitions, operation sequence) +per backend: same seed → identical declared sequence (the +`ParticleRenderDeterminism` suite pins a machine-greppable FNV-1a +state hash, the docs/testing.md §4 KAT convention); a different seed +diverges. Cross-backend agreement is promised on dyadic values (the +exactness zone) — not a cross-platform guarantee (ARCH-010 scope). diff --git a/docs/api/particles.md b/docs/api/particles.md index a89836a..438de34 100644 --- a/docs/api/particles.md +++ b/docs/api/particles.md @@ -8,8 +8,10 @@ per completed simulation tick: burst + continuous emitters, per- particle 2D position + constant depth, velocity, life, size, and an exact integer color fade. The rendering half — particles as batched sprites (shared particle atlas, one draw call per emitter set, depth -from the particle's depth value) — is **M2-PART-02**, which consumes -this header's read-only surface (`liveParticles()`). Public header: +from the particle's depth value) — is implemented by +[`particle_render.md`](particle_render.md) (**M2-PART-02**), which +consumes this header's read-only surface (`liveParticles()`, +`fadeAlpha`). Public header: `src/laige-sim/include/laige/sim/particles.h` (header-only — the API is a template over the SimMath backends, the [`presentation.h`](../../src/laige-sim/include/laige/sim/presentation.h) @@ -226,10 +228,15 @@ system.addEmitter(sparks); // Per completed sim tick (the tick driver calls update() once): system.update(); -// Render phase (M2-PART-02): read-only. -for (const auto& p : system.liveParticles()) { - // p.pos / p.depth / p.size / laige::ParticleSystem<...>::fadeAlpha(p) -} +// Render phase (M2-PART-02): read-only — the O(n) declare pass. +// (M2-PART-02: one emitter set = one (atlas, material, blend) group = +// one instanced draw call; the batcher's frame protocol applies.) +laige::render::ParticleDeclareOptions opts; +opts.atlasId = particleAtlas; +// opts defaults: materialId 0, blend Additive, full atlas, layer 0 +batcher.beginFrame(); +laige::render::declareParticles(batcher, system, opts); +batcher.build(); ``` **Misuse:** spawning from the render thread (the sim thread owns the @@ -244,8 +251,10 @@ sorts by the depth key). ## Related -- [`sprite_batcher.md`](sprite_batcher.md) — the render half - (M2-PART-02 consumes `liveParticles()`). +- [`particle_render.md`](particle_render.md) — the render half + (M2-PART-02: the O(n) declare pass over `liveParticles()`). +- [`sprite_batcher.md`](sprite_batcher.md) — the batcher the + declarations land in (M2-SPRITE-01). - [`iso_depth_key.md`](iso_depth_key.md) — the depth key the particle `depth` feeds (M2-ISO-01). - [`tilemap.md`](tilemap.md) — the `advanceAnimations` per-sim-tick diff --git a/docs/benchmarks/baselines/README.md b/docs/benchmarks/baselines/README.md index 05ebac2..05b5e89 100644 --- a/docs/benchmarks/baselines/README.md +++ b/docs/benchmarks/baselines/README.md @@ -83,3 +83,12 @@ Recorded so far: unchanged. **The eighth baseline** — supersedes `m2-iso-depth-table-workload-fix.md` as the latest recorded value of `iso_depthkey_rebuild`. +- [m2-particle-render.md](m2-particle-render.md) (M2-PART-02, + 2026-10-07) — the FR-2.7 particle → sprite conversion workload + (10 000-particle `declareParticles` pass, both SimMath backends, + the declare pass ONLY — the batcher's sort is the separate + `depth_sort_10k` budget): 1.07798 ms mean on the canonical Debug + tree (worse backend fpx16_16; 1.86× inside the 2.0 ms step-level + gate). **The tenth baseline** — updates `budgets.json` `measured` + for `particle_render_10k` to 1.07798 (the budgets.json entry count + moves 16 → 17 — the `budget_harness_tests.cpp` table pin). diff --git a/docs/benchmarks/baselines/m2-particle-render.md b/docs/benchmarks/baselines/m2-particle-render.md new file mode 100644 index 0000000..7d3683d --- /dev/null +++ b/docs/benchmarks/baselines/m2-particle-render.md @@ -0,0 +1,181 @@ +# Baseline: `m2-particle-render` — M2-PART-02 10k declare-pass budget + +Recorded by **M2-PART-02** (2026-10-07). This is the **tenth** +baseline file; it is immutable (methodology §4 — superseding it later +adds a new file, it is never edited). It retires the `0 = not yet +measured` M0 convention for the **`particle_render_10k`** budget +(10 000-particle particle → sprite conversion, one O(n) declare pass, +mean ≤ 2.0 ms — the FR-2.7 "particles render as batched sprites" +conversion cost; the step-level entry, not a PRD §8.1 table row, +needs no PRD revision — the `depth_sort_10k` precedent). + +## What this baseline measures + +The `ParticleRenderBudget` suite of `laige-render_tests` +(M2-PART-02; the suite is in +`tests/laige-render/particle_render_tests.cpp`): the FR-2.7 +conversion workload — **one `declareParticles` pass over a +10 000-particle `ParticleSystem`** — checked against the +**`particle_render_10k`** budget (mean ≤ 2.0 ms). + +Workload shape: + +- One measured sample is **one `declareParticles` call** over 10 000 + live particles: the O(n) particle → sprite conversion (one depth + key, one tint, one batcher `add` per particle — the batcher's + `build`/sort cost is the separate `depth_sort_10k` budget's + territory; `beginFrame`/`build` are OUTSIDE the measured window, + the frame protocol keeps the two quantities apart). +- The system is deterministic (seed `0xC0FFEE00`): one emitter with a + velocity box (0.25..0.5 world units/tick per axis, dyadic — the + cross-backend exactness zone), depth 0.5 (sub-unit — exercises the + depth quantization in the measured path), life 20..40, size + 0.25..0.5, tint (255, 128, 64, 255), fade window 8. A burst of + 10 000 particles, then 10 update ticks (the positions spread by + the velocity box; all alive — min life 20 > 10), both SimMath + backends (ADR 0002: the gate is backend-complete; the recorded + value is the worse of the two). +- **Warm-up:** 100 declare+build frames discarded; **measured:** + 3 000 declare passes (`n=3000`), histogram capacity 3 000, no + truncation. + +The gate (roadmap M2-PART-02 Verify): mean ≤ 2.0 ms, enforced on CI +by the `particle_render` ctest entry. The gated branch (load +`budgets.json`, `budgetCheck`, the stable 4-line report, +`EXPECT(passed)`) compiles only on **Linux non-instrumented trees** +(`LAIGE_PARTICLE_RENDER_BUDGET` — the `depth_sort` / `iso_picking` +precedent): instrumentation inflates the absolute cost +(methodology §4), so the sanitizer trees run the **same workload +ungated** and verify its safety properties instead (leak-free ASan, +race-free TSan); the absolute target is reference-platform-scoped +(methodology §5), and the P0 `linux-clang` job runs on the same +reference runner, so the gate is verified there too (the +cross-compiler run below). The zero-allocation property of the +declare loop is pinned separately by `ParticleRenderZeroAlloc` +(1 000 frames of 10 000-particle declare+build loops = 0 heap blocks +under the allocation watch, the non-sanitizer trees). + +## AGENTS §12 metadata + +| # | Field | Value | +|---|---|---| +| 1 | Hardware | AMD Ryzen 9 7950X3D (16 cores / 32 threads), max 5 763 MHz, 64 GB RAM | +| 2 | OS | CachyOS (Arch-based Linux), kernel `7.2.9-1-cachyos`, x86_64 | +| 3 | Compiler and version | `g++ (GCC) 16.2.1 20260810` (canonical gate run; the cross-compiler run below uses `Clang 22.1.8`) | +| 4 | Build type | `Debug` (canonical, `build/` tree — CMake Debug: `-O0 -g` + the engine policy flags) | +| 5 | Relevant flags | Engine policy (NFR-8.10): `-Wall -Werror -fno-exceptions -fno-rtti`. No sanitizers (canonical tree). | +| 6 | Dataset / workload | `particle_render_10k` — one `declareParticles` over 10 000 live particles (seed 0xC0FFEE00, one velocity-box emitter, 10 update ticks, both SimMath backends — the conversion pass ONLY) | +| 7 | Warm-up | 100 declare+build frames discarded | +| 8 | Sample count | `n=3000` declare passes (histogram capacity 3000, no truncation) | +| 9 | Summary statistics | fpx16_16: min=1.06083 mean=**1.07798** p50=1.07614 p95=1.09171 **p99=1.108** max=1.40197 · fp32_pinned: min=0.988138 mean=0.996543 p50=0.995282 p95=1.0048 p99=1.02189 max=1.06674 ms | +| 10 | Before / after | `before=0` (M0 convention — not yet measured) · `after` (mean, worse backend fpx16_16): **1.07798** → **recorded 1.07798** · `target`: mean ≤ 2.0 ms — **PASS** (1.86× inside the gate) | + +## Verbatim run output + +### Run — canonical tree (Debug, g++), budget-checked + +Command (run from the repository root; `budgets.json` resolved via +`LAIGE_BUDGETS_PATH`; `LAIGE_BENCH_MACHINE` carries the operator's +machine description into the report's `context.machine` field): + +```console +$ LAIGE_BUDGETS_PATH=$PWD/budgets.json \ + LAIGE_BENCH_MACHINE="AMD Ryzen 9 7950X3D (16 cores / 32 threads), max 5 763 MHz, 64 GB RAM" \ + ./build/bin/laige-render_tests --gtest_filter='ParticleRenderBudget*' +``` + +```text +budget=particle_render_10k result=PASS metric=mean unit=ms + after=1.07798 before=1.09108 target=2 + stats: n=3000 min=1.06083 mean=1.07798 p50=1.07614 p95=1.09171 p99=1.108 max=1.40197 + context: workload=10 000-particle particle-to-sprite conversion, one O(n) declare pass (M2-PART-02) build=GCC 16.2.1 20260810, CMake Debug, engine policy (NFR-8.10) machine=AMD Ryzen 9 7950X3D (16 cores / 32 threads), max 5 763 MHz, 64 GB RAM warmup=100 +budget=particle_render_10k result=PASS metric=mean unit=ms + after=0.996543 before=1.09108 target=2 + stats: n=3000 min=0.988138 mean=0.996543 p50=0.995282 p95=1.0048 p99=1.02189 max=1.06674 + context: workload=10 000-particle particle-to-sprite conversion, one O(n) declare pass (M2-PART-02) build=GCC 16.2.1 20260810, CMake Debug, engine policy (NFR-8.10) machine=AMD Ryzen 9 7950X3D (16 cores / 32 threads), max 5 763 MHz, 64 GB RAM warmup=100 +``` + +Exit code: `0`. (The report's `before=1.09108` is the first +recorded measurement of this budget — the pre-finalization run; +this run is the recorded value.) + +### Gate — CI shape, canonical tree, `ctest -R particle_render` + +```console +$ ctest --test-dir build -R "particle_render" --output-on-failure +``` + +```text +Test project /home/anon/devel/laige-cpp/build + Start 55: particle_render +1/1 Test #55: particle_render .................. Passed 24.60 sec + +100% tests passed out of 1 + +Total Test time (real) = 24.61 sec +``` + +Commit: the M2-PART-02 commit on branch `feat/m2-part-02-particle-render` +(the `particle_render` ctest entry is the CI perf lane — the P0 jobs +run the full `ctest` on the canonical tree and the `linux-clang` +job). + +## Cross-compiler run (same workload, same n=3000, clang -O0) + +The gate is reference-platform-scoped (methodology §5), and the P0 +`linux-clang` CI job runs on the same reference runner — so the gate +must clear the 2.0 ms bar on the clang -O0 tree as well. Recorded as +the cross-compiler evidence (the canonical-tree run above is the +recorded value; this run is the CI-shape check): + +Command (run from the repository root): + +```console +$ LAIGE_BUDGETS_PATH=$PWD/budgets.json \ + LAIGE_BENCH_MACHINE="AMD Ryzen 9 7950X3D (16 cores / 32 threads), max 5 763 MHz, 64 GB RAM" \ + ./build-clang/bin/laige-render_tests --gtest_filter='ParticleRenderBudget*' +``` + +```text +budget=particle_render_10k result=PASS metric=mean unit=ms + after=1.18578 before=1.07798 target=2 + stats: n=3000 min=1.15908 mean=1.18578 p50=1.18317 p95=1.20071 p99=1.21506 max=2.0245 + context: workload=10 000-particle particle-to-sprite conversion, one O(n) declare pass (M2-PART-02) build=Clang 23.1.1, CMake Debug, engine policy (NFR-8.10) machine=AMD Ryzen 9 7950X3D (16 cores / 32 threads), max 5 763 MHz, 64 GB RAM warmup=100 +budget=particle_render_10k result=PASS metric=mean unit=ms + after=1.04057 before=1.07798 target=2 + stats: n=3000 min=0.993628 mean=1.04057 p50=1.0405 p95=1.05659 p99=1.06883 max=1.78253 + context: workload=10 000-particle particle-to-sprite conversion, one O(n) declare pass (M2-PART-02) build=Clang 23.1.1, CMake Debug, engine policy (NFR-8.10) machine=AMD Ryzen 9 7950X3D (16 cores / 32 threads), max 5 763 MHz, 64 GB RAM warmup=100 +[ OK ] ParticleRenderBudget.TenThousandParticles (22115 ms) +[==========] 1 test from 1 test suite ran. (22115 ms total) +[ PASSED ] 1 test. +``` + +Exit code: `0`. The clang -O0 tree clears the gate with 1.69× headroom +(worse backend fpx16_16: 1.18578 ms against the 2.0 ms bar). + +## Interpretation + +- **1.86× inside the gate on the canonical tree.** The 2.0 ms target + is a step-level budget (the FR-2.7 conversion cost), calibrated at + ≈2× the canonical -O0 measurement (1.08 ms) — the depth_sort_10k + precedent's headroom discipline (the M2-ISO-02 rebaseline lesson: + calibrate the gate with margin on the CI reference toolchain, not + the local machine). The measured -O0 cost is division-bound: five + per-particle conversions (4 u8→float tint channels + the u32 fade + division) dominate at `-O0`; the depth key is integer math + (cheap in fpx, one rounding + pack in fp32). +- **Cross-backend spread ≈ 8%** (fpx 1.078 ms vs fp32 0.997 ms — + fpx slower: the fixed-point key path carries the Q16.16 rounding + + the raw clamps). The recorded value is the worse backend + (methodology §5 convention). +- **The conversion is O(live) with zero allocations** — the + `ParticleRenderZeroAlloc` suite pins 1 000 frames of 10 000-particle + declare+build loops at 0 heap blocks (PERF-003, FR-2.7 "pooled"); + the budget measures the per-particle conversion work, and the + batcher's sort cost is accounted separately (`depth_sort_10k`, + 0.225852 ms recorded — the two budgets keep the frame protocol's + phases apart). +- **One draw call per emitter set regardless of particle count** + (RENDER-001): 10 000 particles of one set declare into ONE + `(atlas, material, blend)` group — the conversion cost scales with + particle count, the GPU submission cost does not. diff --git a/docs/concepts/coordinates.md b/docs/concepts/coordinates.md index ff3c709..87ad8e9 100644 --- a/docs/concepts/coordinates.md +++ b/docs/concepts/coordinates.md @@ -424,9 +424,13 @@ each particle carries a 2D position `(x, y)` + a CONSTANT depth value advance (`age += 1`, `pos += vel` — one SimMath vector add, ADR 0002) runs in the simulation tick (ARCH-002); the render path reads the state read-only (`liveParticles()` — the M2-GL-02 cull/batch -stage). The particle's depth value is the §4.1 key input M2-PART-02 +stage). The particle's depth value is the §4.1 key input the +rendering half +([`particle_render.md`](../api/particle_render.md), M2-PART-02) feeds the batcher (particles render as batched sprites — one draw -call per emitter set, RENDER-001). The state is deterministic +call per emitter set, RENDER-001; the depth is quantized to an +integer step height first, then packed as the key). The state is +deterministic (ARCH-010): a pure function of (seed, emitter definitions, operation sequence) — fpx16_16 bit-identical across platforms/builds, fp32_pinned same-build/same-platform. @@ -443,7 +447,7 @@ fp32_pinned same-build/same-platform. | Atlas frame → UV sub-rect | animation frame index + sheet layout → UV rect | `laige-render` (`spriteFrameUv`, M2-SPRITE-03) | **Shipped (M2-SPRITE-03)** | | Tile grid → tile quads | tile data + table keys (+ the animation's frame state; the parallax layer's `worldOffset`) → declared sprites | `laige-render` (`TileMap::declareTo` — static + animated frames, the parallax tile layer overload, M2-TILE-01/02) | **Shipped (M2-TILE-01 + M2-TILE-02)** | | Camera position → parallax offset | camera (x, y) + layer def → world-space offset (the exact formula) + wrap quads | `laige-render` (`ParallaxLayers::worldOffsetAt` / `declareTo`, M2-PAR-01) | **Shipped (M2-PAR-01)** | -| Particle state → sprite items | particle (pos, depth, size, faded tint) → declared sprites (one batch per emitter set, the key from the particle's depth) | `laige-render` (M2-PART-02 — consumes `ParticleSystem::liveParticles()`, read-only after the sim phase) | **Planned (M2-PART-02)** | +| Particle state → sprite items | particle (pos, depth, size, faded tint) → declared sprites (one batch per emitter set, the key from the particle's depth) | `laige-render` (`declareParticles` / `particleDepthToStepHeight` — consumes `ParticleSystem::liveParticles()`, read-only after the sim phase, M2-PART-02) | **Shipped (M2-PART-02)** | ### 5.1 The isometric grid picking (M2-ISO-03) @@ -557,6 +561,11 @@ state → same keys → same order, every frame (RENDER-003). simulation contract: `ParticleSystem` (the bounded pool, the burst + continuous emitters, the per-tick advance, the exact integer fade, the determinism contract) (M2-PART-01). +- [`api/particle_render.md`](../api/particle_render.md) — the + particle rendering contract: `declareParticles` (the O(n) + particle → sprite pass — the engine-owned depth key from the + particle's depth, the exact fade through the tint, one draw call + per emitter set) (M2-PART-02). - [`api/matrices.md`](../api/matrices.md) — the matrix builders and NDC conventions (M2-GL-03). - [`decisions/0005-iso-default.md`](../decisions/0005-iso-default.md) — diff --git a/laige-api.json b/laige-api.json index 00eaf42..d4e243b 100644 --- a/laige-api.json +++ b/laige-api.json @@ -23,6 +23,7 @@ "src/laige-render/include/laige/render/iso_picking.h", "src/laige-render/include/laige/render/matrices.h", "src/laige-render/include/laige/render/parallax.h", + "src/laige-render/include/laige/render/particle_render.h", "src/laige-render/include/laige/render/projection.h", "src/laige-render/include/laige/render/sprite_batcher.h", "src/laige-render/include/laige/render/sprite_frames.h", @@ -766,6 +767,15 @@ {"name": "laige::render::ParallaxLayers::LayerSlot::def", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 536, "signature": "ParallaxLayerDef def", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::ParallaxLayers::LayerSlot::uvOffset", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 537, "signature": "Vec2 uvOffset{0.0f, 0.0f}", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::ParallaxLayers::LayerSlot::valid", "kind": "variable", "header": "src/laige-render/include/laige/render/parallax.h", "line": 538, "signature": "bool valid{false}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::render::kTintChannelDivisor", "kind": "variable", "header": "src/laige-render/include/laige/render/particle_render.h", "line": 219, "signature": "inline constexpr float kTintChannelDivisor = 255.0f", "summary": "The u8 color channel -> float [0, 1] divisor (one presentation-only rounding per channel — ARCH-009; 255/255 is exactly 1.0).", "budget": null, "experimental": false}, + {"name": "laige::render::ParticleDeclareOptions", "kind": "struct", "header": "src/laige-render/include/laige/render/particle_render.h", "line": 231, "signature": "struct ParticleDeclareOptions", "summary": "The options one `declareParticles` call declares under. Plain value (the scene config — the batcher's group key carries atlasId/materialId/blend unchanged; no validation here: the atlas id is a game-assigned stable handle, the UV rect is the caller's invariant (u1 > u0, v1 > v0 — the SpriteUvRect contract), the layer is the isoDepthKey's (clamped to its packing domain)).", "budget": null, "experimental": false}, + {"name": "laige::render::ParticleDeclareOptions::atlasId", "kind": "variable", "header": "src/laige-render/include/laige/render/particle_render.h", "line": 234, "signature": "std::uint32_t atlasId{}", "summary": "The shared particle atlas (the game assigns it — M3-ASSET-01 owns the asset system; for now the game assigns the texture id).", "budget": null, "experimental": false}, + {"name": "laige::render::ParticleDeclareOptions::materialId", "kind": "variable", "header": "src/laige-render/include/laige/render/particle_render.h", "line": 237, "signature": "std::uint32_t materialId{}", "summary": "The material id (0 = the default material — the group key carries it per FR-2.1).", "budget": null, "experimental": false}, + {"name": "laige::render::ParticleDeclareOptions::blend", "kind": "variable", "header": "src/laige-render/include/laige/render/particle_render.h", "line": 241, "signature": "BlendMode blend{BlendMode::Additive}", "summary": "The blend state every declared particle is drawn with. Additive is the particle convention (FR-2.7 additive effects — the BlendMode document).", "budget": null, "experimental": false}, + {"name": "laige::render::ParticleDeclareOptions::uv", "kind": "variable", "header": "src/laige-render/include/laige/render/particle_render.h", "line": 244, "signature": "SpriteUvRect uv{0.0f, 0.0f, 1.0f, 1.0f}", "summary": "The UV sub-rect every particle of the set draws from in the shared atlas (default: the whole atlas — a single-sprite sheet).", "budget": null, "experimental": false}, + {"name": "laige::render::ParticleDeclareOptions::layer", "kind": "variable", "header": "src/laige-render/include/laige/render/particle_render.h", "line": 248, "signature": "std::int32_t layer{kIsoDepthGroundLayer}", "summary": "The isoDepthKey layer (M2-ISO-01; default: the ground layer — the particle rides the world's layer; a parallax-layer particle set is a scene-setup choice, M2-PAR-01).", "budget": null, "experimental": false}, + {"name": "laige::render::particleDepthToStepHeight", "kind": "function", "header": "src/laige-render/include/laige/render/particle_render.h", "line": 262, "signature": "template [[nodiscard]] inline std::int32_t particleDepthToStepHeight( typename sim::SimMath::Scalar depth) noexcept", "summary": "The particle's constant depth (world units, the SimMath backend's Scalar) quantized to the M2-ISO-01 step-height domain: clamp to [−kIsoDepthMaxStepHeight, +kIsoDepthMaxStepHeight] (total function — saturation at the packing boundary, the isoDepthKey contract), then round to nearest, ties-to-even (the two backends agree for every dyadic depth in the domain — the header's depth-conversion section). O(1); no allocation; pure.", "budget": null, "experimental": false}, + {"name": "laige::render::declareParticles", "kind": "function", "header": "src/laige-render/include/laige/render/particle_render.h", "line": 327, "signature": "template [[nodiscard]] inline Status declareParticles( SpriteBatcher& batcher, const ParticleSystem& system, const ParticleDeclareOptions& options) noexcept", "summary": "Declares every live particle of `system` into `batcher` under `options` (the header's model section): O(live), no allocation, the first failed add fails the call with the batcher's error (the header's frame-protocol section). `batcher` must be alive (created with a nonzero capacity) and the frame window must be OPEN (beginFrame called, build not yet run) — otherwise the first add fails (BudgetExhausted / InvalidArgument respectively).", "budget": null, "experimental": false}, {"name": "laige::render::ProjectionMode", "kind": "enum", "header": "src/laige-render/include/laige/render/projection.h", "line": 200, "signature": "enum class ProjectionMode", "summary": "The per-scene/view projection mode (FR-2.5). iso is the engine default (ADR 0005, PRD v0.2) — the default member of ProjectionView.", "budget": null, "experimental": false}, {"name": "laige::render::ProjectionMode::Iso", "kind": "enumerator", "header": "src/laige-render/include/laige/render/projection.h", "line": 201, "signature": "Iso", "summary": null, "budget": null, "experimental": false}, {"name": "laige::render::ProjectionMode::SideView", "kind": "enumerator", "header": "src/laige-render/include/laige/render/projection.h", "line": 202, "signature": "SideView", "summary": null, "budget": null, "experimental": false}, diff --git a/roadmap/M2-rendering-2.5d.md b/roadmap/M2-rendering-2.5d.md index 536e212..ee4c8ae 100644 --- a/roadmap/M2-rendering-2.5d.md +++ b/roadmap/M2-rendering-2.5d.md @@ -238,7 +238,7 @@ if M2 slips, and its status is recorded in M2-EXIT-01. - **Verify:** `ctest -R particles` green. - **Size:** ~250 lines + tests -- [ ] **M2-PART-02 · Particle rendering through the batcher** +- [x] **M2-PART-02 · Particle rendering through the batcher** - **Refs:** FR-2.7, RENDER-001 - **Depends:** M2-PART-01, M2-SPRITE-02 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 1d200a2..0bf6d14 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -157,7 +157,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. |---|---|---|---| | M0 | 22 | 22 | ✅ complete (2026-09-13, M0-EXIT-01) | | M1 | 25 | 25 | ✅ complete (M1-EXIT-01, 2026-09-25) | -| M2 | 33 | 20 | ⬜ in progress | +| M2 | 33 | 21 | ⬜ in progress | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | | M5 | 21 | 0 | ⬜ not started | @@ -165,7 +165,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | M7 | 15 | 0 | ⬜ not started | | M8 | 8 | 0 | ⬜ not started | | M9 | 6 | 0 | ⬜ proposals only | -| **Total** | **194** | **66** | | +| **Total** | **194** | **67** | | --- @@ -244,6 +244,7 @@ One line per completed (or split/renumbered) step. | 2026-10-07 | M2-PAR-01 | `—` / PR (open) | Parallax layers (FR-2.3; M2-PAR-01 scope, nothing else): **API** — NEW `laige::render::ParallaxLayers` (header-only, templated over the SimMath backends — the `presentation.h` pattern; `parallax.h`) + `ParallaxLayerDef` (the named layer value: `id` < `maxLayers`, `source` (Image | Tilemap — the M2-TILE-02 hook), `factor` in [0, 1] (one source of truth — 0 = fixed in world space, 1 = fixed on screen), `center` (the reference camera position — where the layer sits at exactly `offset`; default (0,0)), `offset` (the world-space offset at `p = center`; default (0,0)), `size`/`uv` (Image only), `atlasId`, `tilemapId` (Tilemap only), `materialId`, `blend`, `depthLayer` (the M2-ISO-01 key layer value), `scrollMode` (Manual | Auto), `scrollSpeed` (UV units PER FRAME per axis — negative scrolls the other way), `enabled`) + `ParallaxScrollMode`/`ParallaxSource` + the named presets (`kParallaxLayerBackground/Midground/Foreground/CustomBase` = 0/1/2/3) + the depth-layer values (`kParallaxDepthLayerBackground` = -2, `kParallaxDepthLayerMidground` = -1, `kIsoDepthGroundLayer` = 0 (the ground), `kParallaxDepthLayerForeground` = +1; a custom layer picks any value in the M2-ISO-01 domain [-512, +511] — more negative = further back); `create(options)` (`maxLayers` in [1, `kParallaxLayersMaxLayers` = 64], default `kParallaxLayersDefaultLayers` = 8 — the pre-sized slot table; no log), `setLayer(def)` (validates the def in the documented order — id → factor → center → offset → scroll_speed → size (Image) → uv (Image) → depth_layer — first failure wins; a rejection leaves the slot unchanged + one rate-limited `parallax/layer_invalid` warn (fields `layer`, `field`) — LOG-004; a success RESETS the layer's UV offset to (0, 0)), `setUvOffset(id, uv)` (any finite value — WRAPPED to [0, 1)²; unknown id → `InvalidArgument` (no log); non-finite → `InvalidArgument` + one rate-limited `parallax/uv_offset_invalid` warn), `advanceScrolls()` (the AUTO layers only — once per frame, before the declarations; O(layers), no allocation, no logging, no GL), `declareTo(batcher, cameraPos)` (the render path / S-5: declares every SET, ENABLED Image-source layer's wrap quads — the 2 x 2 split (q00, q10, q01, q11 — a quad whose range is empty — uvOffset 0 on that axis — is skipped); each quad: its world CENTER `pos`, its extent `scale`, rotation 0, the full-default tint, the def's `atlasId`/`materialId`/`blend`, and the M2-ISO-01 key of the quad's world center at the def's `depthLayer` (engine-owned — G-R11, `depthOverride` stays false); Tilemap-source layers are SKIPPED (the M2-TILE-02 hook — no log); a built frame's window is closed → `InvalidArgument` (nothing declared); a stopped registry → `InvalidArgument` (no log); the first failed add fails the call), `worldOffsetAt(id, cameraPos)` (the EXACT formula `factor * (p - center) + offset` — O(1), no allocation, no logging, no GL), introspection (`valid()`, `maxLayers()`, `layerCount()`, `has()`, `layerAt()`, `uvOffsetAt()`); **the render order** (documented — background first): WITHIN a shared (atlas, material, blend) group the key's LAYER field dominates — every background-layer quad sorts before every ground object and every foreground quad after it, whatever the quads' v (the M2-ISO-01 "layer dominates" contract); ACROSS groups the draw order is the batcher's group order (ascending (atlas, material, blend) — RENDER-003), so the scene's SET-UP assigns the parallax layers' atlas ids so the group order matches the depth order: background ids BELOW the world content's, foreground ids ABOVE it (the M2-TILE-01 texture-id convention); **the UV scroll** (auto or manual): each layer carries a CURRENT UV OFFSET in [0, 1)² — Manual via `setUvOffset`, Auto via `scrollSpeed` on `advanceScrolls()` (frames are the presentation pace — frame-rate independence is the caller's concern, the M2-CAM-01 lerp precedent); the WRAP is exact at the texture boundary (`wrap(x) = x - floor(x)`: 1.0 → exactly 0.0; -0.25 → exactly 0.75 — dyadic values wrap bit-exactly, pinned); the texture's v axis (v = 0 = first uploaded texel row, the M2-SPRITE-02 contract) maps to the world +y direction; a scrolled layer renders through the 2 x 2 wrap split (a single `SpriteItem` carries ONE UV rect — no wrap): up to four quads per layer, one draw call per layer group (FR-2.1, RENDER-001); **semantics** — everything is WORLD space (PRD §4, RENDER-006 — the formula is a world-space translation); ARCH-009: headless-buildable, presentation-only (the layer state reads the camera's presentation position, never sim state — never part of replay state or the simulation state hash); one owner (the sim/scene-owner thread; the M2-GL-02 cull/batch stage owns the render-side declaration), sim-phase writes / render-phase reads (CONC-001); no per-frame allocation (FR-2.2 — the zero-allocation proof: 1000 frames × 3 layers (up to 4 quads each) + 2 sprites allocates NOTHING on the owner thread); the rejected-definition warn is the only logging (LOG-002/004 — the happy paths log nothing); **tests** — NEW `tests/laige-render/parallax_tests.cpp` (new CTest entry `parallax` = the step's Verify command; 12 tests / 6 suites, no GL — runs in every tree): `ParallaxCreate` (the create-domain edges + the stopped-state matrix), `ParallaxLayer` (the `setLayer` validation matrix — 15 rejection cases with the pinned warn fields (first failure wins), the domain edges accepted, the Tilemap-source size/uv NOT validated, state-unchanged-on-rejection, the replace + scroll-reset), `ParallaxOffset` (the EXACT offset formula — hand-computed dyadic goldens for factors 0/1/0.5/0.25 (the dyadic exactness zone, both backends agree) + factor 0.3f linearity within tolerance; the world-origin reference case (center/offset zeroed)), `ParallaxScroll` (the auto advance + the EXACT wrap at the 1.0 boundary (→ (0,0)) + the negative axis, the manual wrap (1.5 → 0.5, 1.0 → 0, -1/-2 → 0), the offset persists across frames, the non-finite rejection + the pinned warn, the unknown-id no-log), `ParallaxDeclare` (the roadmap's golden: the 4-layer + ground scene at camera (10,6) — both backends: the 5 declared quads pinned field-by-field (world centers, extents, the atlas uv mapping incl. the layer `uv` sub-rect, the hand-computed 32-bit keys — WITH the 2^21 base — against the independent M2-ISO-01 oracle), the batcher's group (draw) order (atlas 0..4 ascending), the layer-dominance orderings (bg < mid < ground < custom < fg, even when the quads' v is out of order), the cross-frame determinism (bit-identical second frame); the scrolled split — the FULL/half/un-scrolled cases (4/2/1 quads — the empty-range quads skipped; the world rects tile the rectangle exactly: the areas sum to the rectangle's area; the q00/q10/q01/q11 order pinned), the atlas SUB-RECT mapping (the layer uv (0.25,0,0.75,0.5) mapped over the quad's base range), the OFFSET layer's position, the `frameCounts` pin (6/4/3/6); the frame protocol — a built frame → `InvalidArgument`, a stopped batcher → `BudgetExhausted`, a stopped registry → `InvalidArgument` (no log), the disabled + tilemap-hook skips (no log)), `ParallaxZeroAlloc` (the 1000-frame loop under the allocation watch — 0 owner-thread allocs — FR-2.2); **docs** — NEW `docs/api/parallax.md` (the full contract: the API table, the model + the exact offset formula, the render-order section (within-group layer dominance + the across-group atlas-id convention), the UV scroll + the exact wrap + the 2 x 2 split table (world/uv per quad), ownership/lifetime/threading, the DOC-004 Performance table, the performant example + the misuse warnings), `docs/README.md` index entry, the module README status paragraph, `docs/concepts/coordinates.md` (NEW §4.10 + the §5 conversion row "Camera position → parallax offset" + the §6 render-order summary + the Related link); the stale "land with M2-PAR-01" references updated in the same change (`docs/api/iso_depth_key.md` layer field + Related, `docs/api/iso_depth_table.md`, `docs/api/tilemap.md`, the `tilemap.h` comment); **API surface** — `laige-api.json` regenerated LAST (1265 → 1315 symbols, 39 → 40 headers: the 2 enums, `ParallaxLayerDef` + 16 fields + the field-wise `operator==` (a defaulted `==` is deleted — `SpriteUvRect` has no `operator==`), `ParallaxLayers` + `Options` + 13 public members, the 6 `kParallax*` constants — the `TileSlot`-style private nested-type convention); **budget** — no standalone `budgets.json` entry (the count stays 16 — the `BudgetHarnessTable.LoadsTheRepoBudgetsFile` pin): the per-frame declare cost is PART of the composite 50k render-CPU budget (PRD §8.1, `sprites_50k_cpu` — M2-PERF-01 measures the reference scene with the parallax layers included — the M2-SCENE-01 reference scene has 3 parallax layers within the 50k-sprite / ≤30-draw-call budget); **compat** — additive only (no existing symbol's signature or meaning changed); **local verification** — all six local trees warning-clean + full `ctest` green: build 113/113, build-clang 113/113, build-release 102/102, build-shared 113/113, build-asan 110/110, build-tsan 110/110 (the prior counts +1 each — the new `parallax` entry); `ctest -R parallax` green (12/12, both backends); `api-real-tree`/`api-check-fresh` (6/6), `tools/laige-include-lint` (69 source files), and `tools/laige-determinism-lint` (28 sim source files, 0 violations) green; Progress Board M2 18/33, total 64/194 | | 2026-10-07 | M2-TILE-02 | `—` / PR (open) | Parallax tile layers + tile animation (FR-2.6; M2-TILE-02 scope, nothing else): **API** — `TileMap` (header-only, `tilemap.h`) gained: `TileAnimationDef` (`frameCount` in [1, `kTileAnimMaxFrames` = 64], `frameTicks` (the documented rate — the SIMULATION ticks per frame, ≥ 1), the tile sheet's `SpriteFrameLayout` (M2-SPRITE-03, texels)) + `kTileAnimMaxFrames`/`kTileMapDefaultAnimations` (8)/`kTileMapMaxAnimations` (256) + `Options::maxAnimations` (validated FIRST in `create`, domain [1, 256] — first failure wins, then the table's grid options) + the pre-sized animation slot table (id 0 = the STATIC sentinel; 1..maxAnimations = animation slots — the tile's `animationId` is now CONSUMED by the batch path, no longer inert data — the existing tilemap test goldens updated to static tiles); `setAnimation(id, def) → Status` (setup/config path, the parallax `setLayer` precedent: validates the id domain → frame count → tick rate → sheet extents → frameCount ≤ columns·rows → the tight sheet's float-exact domain (2^24 texels, the adversarial-layout u64 overflow guards, CPP-004) — first failure wins, no log, the slot unchanged; a success RESETS the phase and PRECOMPUTES all frame UVs (one `spriteFrameUv` per frame — the only per-animation allocation)); `advanceAnimations()` (the sim phase's per-tick call — ONCE PER SIM TICK, ARCH-002: every set animation's frame steps every `frameTicks` ticks, wrapping at `frameCount` — `frame(ticks) = (ticks / frameTicks) mod frameCount` — O(maxAnimations), zero allocation, no GL; the frame state is presentation state, ARCH-009); `hasAnimation(id)`/`animationAt(id)`/`animationFrame(id)` introspection; the `declareTo` frame fields: the STATIC tile carries `DeclareOptions::uv` + `frameIndex` 0, the ANIMATED tile its animation's CURRENT frame UV (the precomputed rect) + `frameIndex` (an animated tile whose slot is UNSET fails the declare — first failure wins, nothing declared past it); NEW `declareTo(batcher, options, layers, layerId, cameraPos) → Status` — the M2-PAR-01 Tilemap-source HOOK implemented: every quad TRANSLATED by the layer's `worldOffset(cameraPos)` (the M2-PAR-01 formula (1)), its key the M2-ISO-01 key of the TRANSLATED center at the TILEMAP's own `Options::layer` (the scene-setup convention: the layer's def `depthLayer` must equal the tilemap's `layer` — bg/mid/fg tilemaps get -2/-1/+1); the translated keys are computed per tile per frame (the camera-dependent translation is not precomputable — O(tileCount), zero allocation; the qBase + qOffset derivation is the documented upgrade path); protocol (first failure wins, nothing declared, no log): built frame / unset layer id / non-Tilemap-source layer → `InvalidArgument`, a DISABLED layer declares NOTHING (OK — the layer's documented skip); `setTile`/`rebuild` gained the `animationId ≤ maxAnimations` check (0 always valid; the WHOLE span validated before any write). No GL anywhere (pure data + batcher bookkeeping); no per-frame allocation (FR-2.2 — the frame UVs precomputed at `setAnimation`, the slot table at `create`); no standalone `budgets.json` entry (the per-frame declare + per-tick advance cost is part of the composite 50k render-CPU budget — M2-PERF-01). **Tests** — NEW ctest entry `tilemap_anim` (`tests/laige-render/tilemap_anim_tests.cpp`, 12 tests / 6 suites, both backends, no GL): `TileMapAnimSet` (the `setAnimation` validation matrix — the slot domain (0 / > maxAnimations rejected), frameCount [1, 64] (65 rejected, 64 on an 8×8 sheet accepted), frameTicks ≥ 1, the zero-sheet-extent rejections, frameCount beyond the sheet's frame count, the tight sheet beyond 2^24 (the adversarial layout), the rejected-set-leaves-no-state + phase-reset contract, no-log happy path), `TileMapAnimCycle` (the documented rate: hand-computed `frame = ticks / frameTicks mod frameCount` over two INDEPENDENT animations — the phase is per animation, not per tile), `TileMapAnimDeclare` (the hand-computed frame-UV goldens from the M2-SPRITE-03 tight-sheet formula (a 2×2 grid of 16×16 frames → 32×32 sheet), the frameIndex, the (atlas, material, blend) groups, the wrap at tick 8, and the cross-frame determinism — same animation state → bit-identical items), `TileMapAnimParallax` (the golden-verified offsets at given camera positions: factor 0.25, center (4,4), offset (1,2), camera (10,6) → offset (2.5,2.5); the hand-computed layer-(-2) key goldens (0x7FA00060/0x7FA00070 — the 2^21 bias adds into bit 21) + the independent oracle (`isoDepthKey` on the translated centers, both backends — the dyadic exactness zone) + the layer-dominance ordering (a ground probe sorts after every bg tile); camera (4,4) → 0x7FA00040, camera (14,8) → 0x7FA00078; the protocol paths (built frame / unset id / Image-source / disabled layer → nothing declared); the animated tile UNDER the translation (frame 1 UV + translated pos, frameIndex 1)), `TileMapAnimProtocol` (the `animationId` edit/load domain — out-of-range rejected, no state change; the boundary id accepted; the WHOLE span validated; the unset-slot declare failure + frameCount 0), and `TileMapAnimZeroAlloc` (1000 frames × (256 tiles + 64 parallax tiles) of `advanceAnimations` (30 ticks) + `beginFrame`/`declareTo` (standalone + parallax)/`build` under the owner-thread allocation window — 0 allocations, the dladdr site diagnostic). The existing `tilemap` suites updated to the new `animationId` semantics (static sentinel 0; the domain 0..maxAnimations). **Docs** — `docs/api/tilemap.md` (the tile animation + parallax tile layer sections, the new API rows, the failure/perf/ownership updates, the misuse warnings), `docs/api/parallax.md` + `parallax.h` comments (the hook is implemented: the tiles are declared through the tilemap's `declareTo` overload, not this registry's — the def's `depthLayer` must equal the tilemap's `Options::layer`), `docs/concepts/coordinates.md` (§4.9 the tile animation + parallax tile layer, §4.10 the Tilemap source, §5 the tile-grid → tile-quads row), `docs/README.md`, `src/laige-render/README.md`. **Verify** — all six local trees warning-clean (build 114/114, build-clang 114/114, build-release 103/103, build-shared 114/114, build-asan 111/111, build-tsan 111/111 — the prior counts +1 each, the new `tilemap_anim` entry); `ctest -R tilemap_anim` green (12/12, both backends); `ctest -R tilemap` still green (the unanchored regex also selects the `tilemap_anim` entry — intended); `api-real-tree`/`api-check-fresh` (6/6), `tools/laige-include-lint`, and `tools/laige-determinism-lint` green after the final `laige-api.json` regeneration; Progress Board M2 19/33, total 65/194 | | 2026-10-07 | M2-PART-01 | `—` / PR (open) | Particle simulation (CPU, 2D + depth) (FR-2.7 — "CPU-simulated, budgeted, pooled"; M2-PART-01 scope, nothing else): **API** — NEW `laige::ParticleSystem` (header-only, templated over the SimMath backends — the `presentation.h` pattern; `src/laige-sim/include/laige/sim/particles.h`) + `ParticleEmitterDef` (the spawn-domain value: `origin` (the spawn position — a particle 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 the batcher), `velMin`/`velMax` (the per-tick velocity box, componentwise), `lifeMin`/`lifeMax` (sim ticks), `sizeMin`/`sizeMax` (world units), `tint[4]` (base RGBA u8, default white), `fadeTicks` (0 = no fade), `continuousRate` (particles PER SIMULATION TICK; 0 = burst-only — unbounded by design: the pool budget bounds the work, overflow drops)) + the constants `kParticlePoolMaxParticles` (2^16)/`kParticlePoolDefaultMaxParticles` (4096)/`kParticleEmitterMaxEmitters` (256)/`kParticleEmitterDefaultMaxEmitters` (32)/`kParticleMaxLifeTicks` (2^20)/`kParticleSampleDenominator` (2^24 — the Prng's 24-bit tap resolution) + `ParticleEmitterId` (dense from 1, 0 = the unused sentinel) + `ParticleStats` (the live/capacity gauges + the u64 spawnedTotal/droppedTotal counters — the DBG-008 feed); `create(options)` (`maxParticles` in [1, 2^16], `maxEmitters` in [1, 256], any u64 seed — validates (no log — the M2-SPRITE-01 create precedent), two pre-allocations: the pool (maxParticles × 40 B) + the emitter table (maxEmitters + 1 slots, index 0 unused) — the setup path, nothing allocates afterward); the public default ctor = the stopped state (`valid()` false, `addEmitter`/`burst` → `InvalidArgument` (no log), `update()` a no-op, `liveParticles()` empty, `stats()` zero) + move-only (O(1) pointer swap; moved-from = stopped — its Prng copy shares the stream position but never draws again); `addEmitter(def)` (dense id in registration order; validates 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 — first failure wins; a rejection leaves the registry unchanged + one rate-limited `particles/emitter_invalid` warn (fields `emitter` = the would-be id, `field`) — LOG-004; registry full → `BudgetExhausted` + `particles/emitters_exhausted` warn (field `capacity`); stopped → `InvalidArgument` (no log)); `burst(id, count)` (spawns NOW in the sim phase — the game's spawn policy; stopped or unknown id → `InvalidArgument` (no log — the M2-PAR-01 setUvOffset precedent), count 0 a no-op success; O(count)); `update()` (EXACTLY ONCE per completed sim tick — ARCH-002, the `TileMap::advanceAnimations` pattern — in-tick order: advance (live-array order: `age += 1`, `pos += vel` (one SimMath vector add — ADR 0002), `age >= life` kills via swap removal (the swapped-in particle — not yet advanced this tick — re-examined at the same index)) → emit (registration order, continuousRate each) → overflow report (at most ONE rate-limited `particles/pool_overflow` warn per tick, fields `dropped`/`live`/`capacity` — LOG-004; the logger 1s window is a second layer); O(live + Σrates), zero allocation, no logging on the happy path (LOG-003)); **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 particle burst-spawned before tick T's update is advanced by that same tick (visible for life − 1 renders — the documented per-tick contract); **determinism (ARCH-010, ADR 0002)** — the state after N updates is a pure function of (seed, emitter definitions, operation sequence): each SUCCESSFUL spawn consumes exactly FOUR Prng draws in a fixed order (vx, vy, life, size) — each uniform scalar `lerp(min, max, u / 2^24)` (the Prng's 24-bit tap resolution, one documented rounding per backend), `life` an integer draw; a DROPPED spawn consumes NO draws (the pool check precedes the draws — a drop never perturbs the stream); fpx16_16 bit-identical across all builds/platforms/ISAs, fp32_pinned same-build/same-platform (the detcheck matrix owns cross-target claims); the Prng state is exposed (`prngSeed()`/`prngStatePart1()`/`prngStatePart2()`) for the World `stateHash` / replay identity (the M1-DET-03 pattern); **pool budget (FR-2.7, PERF-003/008)** — overflow = DROP (counted in `droppedTotal`, one warn per tick window) — never grows the pool, never throws, never mutates live state; the `stats()` feed is the budget's observability (DBG-008); **threading (CORE-009, CONC-001)** — one owner (the sim thread); the render pass reads `liveParticles()` (a non-owning `std::span` of the compact live prefix — spawn order with swap removal) read-only after the sim phase (the M2-GL-02 cull/batch stage, ARCH-009); **tests** — NEW `tests/laige-sim/particles_tests.cpp` (new CTest entry `particles` = the step's Verify command; 17 tests / 10 suites, both backends, no GL — runs in every tree): `ParticlesCreate` (the create domain edges (maxParticles 0 / 2^16 / 2^16+1, maxEmitters 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 case with the pinned warn fields (first failure wins, incl. the fp32 NaN cases + the fpx box cases), state-unchanged-on-rejection, the registry `BudgetExhausted` with the pinned `capacity` field, the no-log happy path), `ParticlesBurst` (a degenerate def's EXACT particle fields pinned (Q16.16 raws / dyadic floats — origin/depth/vel/life/size/tint/fade), count 0 no-op, unknown-id/stopped `InvalidArgument` no log, the pool drop with the 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(2,5) 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 line (FNV-1a 64, the docs/testing.md §4 KAT convention), a different seed diverges, an independent `Prng` advanced by 4 × spawnedTotal draws lands on the system's stream state (the no-draws-on-drop contract), `EXPECT_GT(droppedTotal, 0)` pinning the exercised drop path), `ParticlesFade` (the EXACT integer fade table: life 8/fade 4/tint.a 255 → age 0..7 alpha [255,255,255,255,255,191,127,63], tint.a 200 → 150/100/50 at age 5/6/7, fadeTicks 0 constant — direct `fadeAlpha` + 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] — one per tick where drops occur), `ParticlesStats` (the exact `ParticleStats` feed: {4 live, 4 capacity, 8 spawned, 2 dropped} after 5 ticks), `ParticlesZeroAlloc` (500 ticks × 6 spawns + a periodic burst under the owner-thread allocation watch — 0 heap allocations + the machine-greppable `particles-zeroalloc ticks=500 allocs=0` line; the steady-state counters pinned); **docs** — NEW `docs/api/particles.md` (the full contract: the API table, the per-tick contract, the pool budget, the determinism scope, the color fade, the failure table (first failure wins), ownership/lifetime/threading, the DOC-004 Performance section, the performant example + the misuse warnings), `docs/README.md` (the API index entry + the laige-sim doc list), `src/laige-sim/README.md` (the module status paragraph), `docs/concepts/coordinates.md` (NEW §4.11 + the §5 conversion row "Particle state → sprite items" (Planned, M2-PART-02) + the Related link); **API surface** — `laige-api.json` regenerated LAST (1342 → 1410 symbols, 40 → 41 headers); **budget** — no standalone `budgets.json` entry (the count stays 16 — the `BudgetHarnessTable.LoadsTheRepoBudgetsFile` pin): the particle→sprite budget is measured in M2-PART-02 against the composite 50k render-CPU budget (PRD §8.1); **compat** — additive only (no existing symbol's signature or meaning changed); **local 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 (the prior counts +1 each — the new `particles` entry); `ctest -R particles` green (17/17, both backends); `api-real-tree`/`api-check-fresh` (2/2), `tools/laige-include-lint` (70 source files), and `tools/laige-determinism-lint` (29 sim source files, 0 violations) green after the final `laige-api.json` regeneration; Progress Board M2 20/33, total 66/194 | +| 2026-10-07 | M2-PART-02 | `—` / PR #83 | Particle rendering through the batcher (FR-2.7 — "particles render as batched sprites"; M2-PART-02 scope, nothing else): **API** — NEW header-only `src/laige-render/include/laige/render/particle_render.h` (`laige::render`, public, additive; templated over the SimMath backends — the `presentation.h` pattern): `particleDepthToStepHeight(depth) → int32` (the sim depth value → the M2-ISO-01 integer step height: clamp to ±`kIsoDepthMaxStepHeight` (2047) FIRST — per backend, SimMath has no ordering: fpx raw integer compares, fp32 IEEE compares, NaN falls through to +2047 (presentation-only, ARCH-009) — then round nearest ties-to-even (fpx `fpx16_16::toInt32`, fp32 `std::nearbyint` in the pinned rounding mode — the two backends agree on dyadic depths; NOT `llround`, which would diverge on 0.5)) + `ParticleDeclareOptions` (the emitter set's shared render attributes: `atlasId`/`materialId`/`blend` (default `BlendMode::Additive` — the particle convention)/`uv` (default the full atlas)/`layer` (default `kIsoDepthGroundLayer` = 0)) + `kTintChannelDivisor` (255.0f) + `declareParticles(batcher, system, options) → Status` (the O(live) conversion pass: every live particle — live-array order, the deterministic tie-break (RENDER-003) — becomes one `SpriteItem`: the world position at the RENDER-006 boundary (one documented rounding per component per backend), the engine-owned `depthKey` = `isoDepthKey(p.pos, particleDepthToStepHeight(p.depth), options.layer)` (G-R11; `depthOverride` false), the square `scale (size, size)`, the tint (u8→float /255 RGB + the M2-PART-01 exact-u32 `fadeAlpha` /255), the shared `uv`/`atlasId`/`materialId`/`blend`, `frameIndex`/`rotation` 0 (no per-particle animation/rotation in this step); first failed add fails the call with the batcher's error — stopped batcher → `BudgetExhausted` (an empty system still succeeds: no adds attempted), closed window → `InvalidArgument`, frame-budget overflow → the batcher's own drop-oldest + warn policy (the conversion succeeds); O(live), no allocation, no logging, no GL); **build** — `laige-render` links `laige-sim` PUBLIC (the module stack's legal downward direction, PRD §10.1 — render MAY include sim public headers; the M2-GL-02 "no render→sim edge" note covers the frame-pipeline handoff, which stays an opaque pointer); **tests** — NEW ctest entry `particle_render` (`tests/laige-render/particle_render_tests.cpp`, 9 suites, both backends, no GL): the per-particle declaration goldens against the M2-ISO-01 oracle + the hand-computed dyadic key goldens (0x801FFFE0/0x80200000×3/0x80200020/0x80200030 — awk-verified before pinning), the depth conversion (sub-unit ties-to-even 0.5→0, 1.5→2; ±3000→±2047 saturation; the (key, live-order) in-group ordering with the A/B/F tie), the exact fade through the declaration (life 8/fade 4 → alphas 255×5/191/127/63), the multi-emitter-set group count (3 sets → 3 groups in ascending (atlas, material, blend) order — one draw call per set, RENDER-001), the failure paths, the fixed-seed determinism (identical declared (key, pos) sequence + the machine-greppable `particle-render-determinism` FNV-1a hash line; a different seed diverges), the 1 000-frame 10k-particle zero-allocation declare loop (`particle-render-zeroalloc ... allocs=0`, the alloc-watch gate), and the budget suite (below); **budget** — NEW `budgets.json` entry `particle_render_10k` (mean, ms, target 2.0, step-level — the `depth_sort_10k` precedent: no PRD revision): the 10k-particle conversion pass ONLY (the batcher's `build`/sort is the separate `depth_sort_10k` budget — `beginFrame`/`build` outside the measured window, the frame protocol keeps the phases apart), 100 warm-up + 3 000 measured declare passes, both backends (the gate is backend-complete, ADR 0002); gated on the Linux non-instrumented trees (`LAIGE_PARTICLE_RENDER_BUDGET` + `LAIGE_PARTICLE_RENDER_BUILD_TYPE`), ungated elsewhere (the sanitizer trees run the workload for its leak/race value); `measured` = **1.07798** (the worse of the two backends — fpx16_16 1.07798 / fp32_pinned 0.996543 — canonical Debug tree, n=3000) — 1.86× inside the 2.0 ms gate; **NEW baseline** `docs/benchmarks/baselines/m2-particle-render.md` (the tenth baseline, verbatim reports + cross-compiler clang -O0 run); budgets.json entry count 16 → 17 (the `BudgetHarnessTable.LoadsTheRepoBudgetsFile` pin updated); **docs** — NEW `docs/api/particle_render.md` (the full contract + the DOC-004 Performance section); `docs/api/particles.md` (the render half now implemented — link + the render-phase example + Related), `docs/concepts/coordinates.md` (§4.11 narrative + the §5 conversion row Planned → **Shipped** + Related), `docs/README.md` (the API index), `src/laige-render/README.md` (the M2-PART-02 status paragraph); **build fix** — the system clang 22.1.8 → 23.1.1 upgrade surfaced `-Werror,-Wunused-but-set-global` on pre-existing sim test files: removed the dead `MemorySink* sink` globals in `tests/laige-sim/game_loop_tests.cpp` + `presentation_tests.cpp` + `system_timing_tests.cpp` (written, never read — the tests use `installCaptureSink()`'s return value) and `LAIGE_ALLOC_COUNTER`-gated the `schReadPosSum` read-back in `tests/laige-sim/scheduler_tests.cpp` (its only read site is the zero-alloc window test, which compiles out on the sanitizer clang trees); **API surface** — `laige-api.json` regenerated LAST (1410 → 1419 symbols, 41 → 42 headers); **compat** — additive only (no existing symbol's signature or meaning changed); **local verification** — all six local trees warning-clean + full `ctest` green (build 116/116, build-clang 116/116, build-release 105/105, build-shared 116/116, build-asan 113/113, build-tsan 113/113 — the prior counts +1 each, the new `particle_render` entry; the counts above are the re-run after the sink fix); `ctest -R particle_render` green (9/9, both backends); `api-real-tree`/`api-check-fresh` (2/2), `tools/laige-include-lint` (71 source files), and `tools/laige-determinism-lint` (29 sim source files, 0 violations) green after the final `laige-api.json` regeneration; Progress Board M2 21/33, total 67/194 | --- diff --git a/src/laige-render/CMakeLists.txt b/src/laige-render/CMakeLists.txt index 592d57a..f3bbe60 100644 --- a/src/laige-render/CMakeLists.txt +++ b/src/laige-render/CMakeLists.txt @@ -93,6 +93,18 @@ laige_apply_engine_policy(laige-render) # Public internal dependency: laige-core (the public header uses # laige::Result/Status, CPP-010). target_link_libraries(laige-render PUBLIC laige-core) +# M2-PART-02: the particle render path (particle_render.h, public) +# reads the M2-PART-01 ParticleSystem to declare the live +# particles into the sprite batcher, so the public header includes +# the laige-sim public header laige/sim/particles.h. The module +# stack (PRD §10.1, NFR-8.11) places laige-render ABOVE laige-sim, +# so this downward edge is legal — tools/laige-include-lint R2 +# enforces it (public headers only, arrows only downward). The +# M2-GL-02 "no render-to-sim edge" note above covers the FRAME +# PIPELINE handoff (it stays a POD FrameDescriptor + opaque pointer); +# this edge is the render-phase read of the sim state, the +# PresentationSnapshot pattern (ARCH-009), not a pipeline coupling. +target_link_libraries(laige-render PUBLIC laige-sim) # GLFW and the GLAD loader are implementation detail behind a pimpl # (the public header never names vendor types) — PRIVATE. target_link_libraries(laige-render PRIVATE glfw laige-glad) diff --git a/src/laige-render/README.md b/src/laige-render/README.md index fe90d94..7e60cb8 100644 --- a/src/laige-render/README.md +++ b/src/laige-render/README.md @@ -508,4 +508,59 @@ zero-allocation loop (standalone + parallax)). No standalone `budgets.json` entry: the per-frame declare + per-tick advance cost is part of the composite 50k render-CPU budget, measured with M2-PERF-01. +M2-PART-02 landed the particle rendering — the render half of +FR-2.7: `laige::render::declareParticles(batcher, system, +options)` (public header `include/laige/render/particle_render.h`, +header-only — a template over the SimMath backends) turns every LIVE +particle of a `ParticleSystem` (M2-PART-01, read-only — +ARCH-009) into one `SpriteItem` declaration in the sprite batcher's +frame window (M2-SPRITE-01): the O(live) conversion pass, zero +allocation (PERF-003 — FR-2.7 "pooled"), no GL. Per particle: the +world position at the RENDER-006 boundary (one documented rounding per +component per backend), the engine-owned depth key (G-R11) — the +particle's constant depth is first quantized to an INTEGER step +height by `particleDepthToStepHeight` (clamp to ±2047 FIRST, +then round nearest ties-to-even — fpx `fpx16_16::toInt32`, fp32 +`std::nearbyint`; the two backends agree on dyadic depths, the +presentation-only zone per ARCH-009) and then packed by +`isoDepthKey` (M2-ISO-01, the `Options::layer` — default ground), +`depthOverride` false; the square scale `(size, size)`; the tint +(u8 → float /255 RGB + the M2-PART-01 exact-u32 fade alpha /255); the +shared `uv` rect + `atlasId` + `materialId` + `blend` from +`ParticleDeclareOptions` (default `BlendMode::Additive` — the particle +convention); `frameIndex`/`rotation` 0 (no per-particle animation or +rotation in this step). One emitter set = one system = one +`(atlasId, materialId, blend)` group = ONE instanced draw call +(RENDER-001) — 10 000 particles of one set cost one draw call. The +declaration order is the live-array order (spawn order with swap +removal) — the deterministic tie-break within equal keys (RENDER-003). +Failure protocol (the first failed add fails the call): a stopped +batcher → `BudgetExhausted` (an empty system still succeeds — no adds +attempted); a closed window (after `build`) → `InvalidArgument`; a +frame-budget overflow applies the batcher's own drop-oldest + warn +policy (the conversion itself succeeds). The `laige-render` → +`laige-sim` link edge is the module stack's legal downward direction +(PRD §10.1 — render MAY include sim public headers; the M2-GL-02 +"no render→sim edge" note covers the frame-pipeline HANDOFF, which +stays opaque). API contract in +[docs/api/particle_render.md](../docs/api/particle_render.md), tests +under [tests/laige-render](../tests/laige-render) (CTest entry +`particle_render` — pure data + batcher bookkeeping, no GL +environment required: the per-particle declaration goldens against the +M2-ISO-01 oracle + the hand-computed dyadic key goldens (both +backends), the depth conversion (the sub-unit ties-to-even rounding, +the ±2047 saturation), the exact fade through the declaration, the +in-group (key, live-order) ordering, the multi-set group count (one +draw call per emitter set), the failure paths, the fixed-seed +determinism (the machine-greppable declared-sequence hash), the +1 000-frame 10k-particle zero-allocation loop, and the +`particle_render_10k` budget gate — 10 000-particle declare passes, +both backends, gated on the Linux non-instrumented trees, ungated +elsewhere). Budget: the new `particle_render_10k` entry in +`budgets.json` (mean ≤ 2.0 ms, step-level — the conversion pass ONLY; +the batcher's sort is the separate `depth_sort_10k` budget): recorded +1.07798 ms (the worse of the two backends, canonical Debug tree — the +`docs/benchmarks/baselines/m2-particle-render.md` baseline, the +tenth). + The remaining M2 steps (text/UI, M2-PERF-01) land in later steps. diff --git a/src/laige-render/include/laige/render/particle_render.h b/src/laige-render/include/laige/render/particle_render.h new file mode 100644 index 0000000..91a32d9 --- /dev/null +++ b/src/laige-render/include/laige/render/particle_render.h @@ -0,0 +1,378 @@ +// laige-render particle rendering (M2-PART-02): the rendering half of +// FR-2.7 — the CPU-simulated particles (M2-PART-01) declared into the +// sprite batcher as sprites. +// +// FR-2.7: "Lightweight GPU particle system (CPU-simulated, 2D + +// depth), budgeted, pooled." The simulation half (M2-PART-01) owns +// the bounded pool + the per-tick advance (`laige/sim/particles.h`); +// this header is the render pass. S-5 (PRD §9.1): rendering goes +// through the batcher — the live particles are DECLARED into the +// sprite batcher as sprites, never drawn directly. One +// `declareParticles` call declares every live particle of one +// ParticleSystem under one shared (atlas, material, blend) group, so +// the whole emitter set costs ONE instanced draw call per frame +// (RENDER-001, FR-2.1) — the submit stage (M2-SPRITE-02) makes one +// instanced draw call per (atlas, material, blend) group. +// +// ParticleDeclareOptions The per-declare options (the shared +// atlas, material, blend, UV, layer) +// particleDepthToStepHeight The explicit particle-depth -> +// step-height conversion (RENDER-006) +// declareParticles The O(live) particle -> sprite pass +// +// --------------------------------------------------------------------------- +// The model +// --------------------------------------------------------------------------- +// +// An emitter SET is one ParticleSystem: the system's bounded pool + +// emitters are the set's budget and identity (M2-PART-01). A game +// with several particle styles (smoke, sparks, magic) keeps one +// ParticleSystem per style — each is declared with its own +// `ParticleDeclareOptions` (its own shared atlas, blend, layer) and +// therefore lands in its own (atlas, material, blend) group: one +// draw call per emitter set, the FR-2.7 promise, with no per-particle +// state changes and no texture switches inside the set (RENDER-001). +// +// The per-particle declaration (every field is presentation state — +// ARCH-009; the particle's simulation state is read, never touched): +// +// pos the particle's world position, sim units -> render +// floats at the RENDER-006 conversion boundary (one +// documented rounding per component, per backend — +// fpx16_16: `fpx16_16::toFloat`; fp32_pinned: identity, +// the fp32 Scalar IS float) +// depthKey the engine-owned M2-ISO-01 key: +// `isoDepthKey(pos, z, layer)` with +// `z = particleDepthToStepHeight(depth)` — the +// particle's CONSTANT depth value (world units) +// quantized to integer world units, the step-height +// domain (the section below). `depthOverride` stays +// FALSE (G-R11: the key is engine-owned; the counted + +// warned manual-override escape hatch is never used) +// scale (size, size) in world units (the particle's constant +// size — a square sprite) +// tint the base RGB channels (u8 -> float [0, 1]) and the +// CURRENT fade alpha (`ParticleSystem::fadeAlpha`, +// exact u32 arithmetic, M2-PART-01) — one +// presentation-only rounding per channel (ARCH-009) +// uv the shared atlas sub-rect (the options' UV — every +// particle of the set draws the same sprite frame) +// blend the options' blend (Additive is the particle +// convention — the BlendMode document) +// atlasId / materialId / layer the options, carried unchanged +// frameIndex 0 (particle frames are M3 animation work) +// rotation 0 (no per-particle rotation in this step) +// +// --------------------------------------------------------------------------- +// The depth conversion (RENDER-006 — the explicit sim -> render +// boundary) +// --------------------------------------------------------------------------- +// +// The M2-ISO-01 key takes the step height as INTEGER world units +// (the tile-map height domain — `isoDepthKey(pos, +// stepHeight, layer)`, `|stepHeight| <= kIsoDepthMaxStepHeight` +// (2047)). A particle's depth is a constant Scalar in world units +// (the emitter's depth, M2-PART-01), so the conversion is: +// +// 1. CLAMP to [−2047, +2047] FIRST — total function: a finite +// depth outside the key's z domain saturates at the boundary +// (the isoDepthKey packing-boundary saturation contract); the +// comparisons are per backend (fpx16_16: raw integer order — +// the backend has no NaN; fp32_pinned: IEEE — a NaN depth, +// unreachable in practice because the emitter's depth is +// validated finite at `addEmitter`, falls through to the +// rounding, which maps it to +0) +// 2. ROUND to nearest, ties-to-even — one rounding: +// fpx16_16: `fpx16_16::toInt32` (the fpx16_16.h rounding +// policy); fp32_pinned: `std::nearbyint` (the same +// round-nearest-even policy in the pinned default rounding +// mode) followed by the exact int32 conversion (|d| ≤ 2047 +// after the clamp). The two backends AGREE for every dyadic +// depth in the domain (1/16 world-unit lattice — the +// isoDepthKey cross-backend exactness zone). +// +// A sub-unit depth (e.g. 0.5 world units) therefore renders at the +// nearest integer height (0.5 -> 0, ties to even — 1.5 -> 2): the +// engine's depth model is integer world units end to end (the tile +// heights), and a sub-unit particle offset is below the key's +// 1/16-unit fine-depth resolution only when it rounds away to the +// same integer — the documented quantization, not an error. +// +// --------------------------------------------------------------------------- +// Order and determinism (RENDER-003, ARCH-010 scope: presentation +// only) +// --------------------------------------------------------------------------- +// +// The declaration order is the live-array order — the M2-PART-01 +// spawn order with swap removal (deterministic per backend, per +// seed; the span order is a SIMULATION fact, read here in the render +// phase). The batcher's stable sort (M2-SORT-01) then makes the +// in-group order the (key, declaration position) total order: equal +// keys (particles on the same screen row) keep spawn order — the +// particle analog of the (key, entity id) tie-break (particles have +// no entity ids; the live order is their stable identity). +// +// --------------------------------------------------------------------------- +// The frame protocol + failure contract +// --------------------------------------------------------------------------- +// +// One call per frame per emitter set, in the RENDER phase (the +// M2-GL-02 cull/batch stage — after the simulation phase, the +// `liveParticles()` read contract, M2-PART-01), inside an OPEN +// window: +// +// batcher.beginFrame() +// declareParticles(batcher, system, options); // per emitter set +// ... (sprites, tiles, parallax declarations) +// batcher.build(); +// +// `declareParticles` reads `system.liveParticles()` and adds each +// particle's item in live order. The FIRST failed add fails the call +// with the batcher's error (the `TileMap::declareTo` precedent): +// +// - stopped batcher (create failure / capacity 0) +// -> BudgetExhausted (the batcher's stopped-state contract) +// - closed window (build already ran / no beginFrame yet) +// -> InvalidArgument (the batcher's frame-protocol contract) +// +// On a partial failure the items already declared by the call REMAIN +// in the frame (the batcher owns the frame state — the game +// re-declares the whole frame every frame; there is no rollback and +// no partial-frame state to repair). The batcher's own overflow +// policy (drop the oldest declaration + the rate-limited warn) +// applies when the frame budget is exceeded — a truncated particle +// set is the documented degradation, never a crash (NFR-8.5). +// +// --------------------------------------------------------------------------- +// Performance (PERF-002/003/004, DOC-004) +// --------------------------------------------------------------------------- +// +// declareParticles O(live): one isoDepthKey + one batcher add per +// particle (the add is O(1) — one pool create or +// ring overwrite). No allocation, no logging on +// the happy path (the batcher's overflow warn is +// its own cold, rate-limited path, LOG-003/004), +// no GL. The O(live) pass is the step's budget: +// the `particle_render_10k` entry in +// budgets.json (the 10 000-particle conversion, +// mean, ms — the `DepthSortBudget`/ +// `IsoPickBudget` gate pattern). +// +// particleDepthToStepHeight O(1): two comparisons + one rounding; +// no allocation. Pure — callable from any thread at any phase. +// +// Call site: once per frame per emitter set, in the RENDER phase — +// never in the simulation tick (ARCH-002), never per particle from +// game code (S-5). +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// +// - Do not declare particles per frame from the SIMULATION tick +// (ARCH-002): `declareParticles` is a render-phase read of the +// sim state — call it in the cull/batch stage, after the tick. +// - Do not hand-roll the depth key (G-R11): the key comes from +// `isoDepthKey` through this header (the manual `depthKey` + +// `depthOverride` pair on `SpriteItem` is the counted + warned +// escape hatch — particles never need it). +// - Do not mix emitter sets under one options value: one options +// value = one (atlas, material, blend) group = one draw call; a +// different atlas or blend is a different set (declare it +// separately) — the group count is the draw-call count +// (RENDER-001). +// - Do not assume the span order is a render order (M2-PART-01): it +// is a spawn order with swap removal; the batcher's depth sort is +// the render order (the live order is only the stable tie-break). +// - Size the batcher's capacity for the scene's worst-case visible +// sprite count INCLUDING the particle sets: the overflow drops the +// OLDEST declaration of the whole frame (every declared content +// shares the one ring) — a particle-heavy scene needs the budget +// to fit (API-006). +// +// Canonical narrative: docs/concepts/coordinates.md §4.11 (ARCH-008); +// API contract: docs/api/particle_render.md. + +#pragma once + +#include +#include +#include +#include + +#include "laige/errors.h" +#include "laige/fpx16_16.h" +#include "laige/render/iso_depth_key.h" +#include "laige/render/sprite_batcher.h" +#include "laige/result.h" +#include "laige/sim/particles.h" +#include "laige/sim_math.h" + +namespace laige::render { + +// --------------------------------------------------------------------------- +// Named constants (CORE-005) +// --------------------------------------------------------------------------- + +// The u8 color channel -> float [0, 1] divisor (one presentation-only +// rounding per channel — ARCH-009; 255/255 is exactly 1.0). +inline constexpr float kTintChannelDivisor = 255.0f; + +// --------------------------------------------------------------------------- +// The per-declare options +// --------------------------------------------------------------------------- + +// The options one `declareParticles` call declares under. Plain +// value (the scene config — the batcher's group key carries +// atlasId/materialId/blend unchanged; no validation here: the atlas +// id is a game-assigned stable handle, the UV rect is the caller's +// invariant (u1 > u0, v1 > v0 — the SpriteUvRect contract), the +// layer is the isoDepthKey's (clamped to its packing domain)). +struct ParticleDeclareOptions { + // The shared particle atlas (the game assigns it — M3-ASSET-01 + // owns the asset system; for now the game assigns the texture id). + std::uint32_t atlasId{}; + // The material id (0 = the default material — the group key + // carries it per FR-2.1). + std::uint32_t materialId{}; + // The blend state every declared particle is drawn with. Additive + // is the particle convention (FR-2.7 additive effects — the + // BlendMode document). + BlendMode blend{BlendMode::Additive}; + // The UV sub-rect every particle of the set draws from in the + // shared atlas (default: the whole atlas — a single-sprite sheet). + SpriteUvRect uv{0.0f, 0.0f, 1.0f, 1.0f}; + // The isoDepthKey layer (M2-ISO-01; default: the ground layer — + // the particle rides the world's layer; a parallax-layer particle + // set is a scene-setup choice, M2-PAR-01). + std::int32_t layer{kIsoDepthGroundLayer}; +}; + +// --------------------------------------------------------------------------- +// The depth conversion (RENDER-006) +// --------------------------------------------------------------------------- + +// The particle's constant depth (world units, the SimMath backend's +// Scalar) quantized to the M2-ISO-01 step-height domain: clamp to +// [−kIsoDepthMaxStepHeight, +kIsoDepthMaxStepHeight] (total function — +// saturation at the packing boundary, the isoDepthKey contract), then +// round to nearest, ties-to-even (the two backends agree for every +// dyadic depth in the domain — the header's depth-conversion +// section). O(1); no allocation; pure. +template +[[nodiscard]] inline std::int32_t particleDepthToStepHeight( + typename sim::SimMath::Scalar depth) noexcept { + using Scalar = typename sim::SimMath::Scalar; + Scalar d = depth; + if constexpr (std::is_same_v) { + // Raw integer order (the backend has no NaN): the domain bounds + // as Q16.16 raws (|2047| * 2^16 fits int32 with room to spare). + constexpr std::int32_t kMinRaw = + static_cast(-kIsoDepthMaxStepHeight) << 16; + constexpr std::int32_t kMaxRaw = kIsoDepthMaxStepHeight << 16; + if (d.raw < kMinRaw) { + d = laige::fpx16_16{kMinRaw}; + } else if (d.raw > kMaxRaw) { + d = laige::fpx16_16{kMaxRaw}; + } + // One rounding: toInt32 (round to nearest, ties-to-even — the + // fpx16_16.h policy); |d| <= 2047 keeps the result in range. + return laige::fpx16_16::toInt32(d); + } else { + // IEEE order (a NaN depth falls through — the unreachable + // case, the header's section; nearbyint(NaN) is +0 by IEEE). + constexpr float kMinF = + static_cast(-kIsoDepthMaxStepHeight); + constexpr float kMaxF = static_cast(kIsoDepthMaxStepHeight); + if (d < kMinF) { + d = kMinF; + } else if (d > kMaxF) { + d = kMaxF; + } + // One rounding: nearbyint (round to nearest, ties-to-even in the + // pinned default rounding mode — the same policy as + // fpx16_16::toInt32, so both backends agree on dyadic depths), + // then the exact int32 conversion (|d| <= 2047 after the clamp). + return static_cast(std::nearbyint(d)); + } +} + +namespace detail { +// The sim Scalar -> render float conversion at the RENDER-006 +// boundary (one documented rounding per backend — fpx16_16: +// `fpx16_16::toFloat`; fp32_pinned: identity, the fp32 Scalar IS +// float). +template +[[nodiscard]] float scalarToRenderFloat( + typename sim::SimMath::Scalar v) noexcept { + if constexpr (std::is_same_v) { + return laige::fpx16_16::toFloat(v); + } else { + return v; + } +} +} // namespace detail + +// --------------------------------------------------------------------------- +// The particle -> sprite pass +// --------------------------------------------------------------------------- + +// Declares every live particle of `system` into `batcher` under +// `options` (the header's model section): O(live), no allocation, +// the first failed add fails the call with the batcher's error +// (the header's frame-protocol section). `batcher` must be alive +// (created with a nonzero capacity) and the frame window must be +// OPEN (beginFrame called, build not yet run) — otherwise the first +// add fails (BudgetExhausted / InvalidArgument respectively). +template +[[nodiscard]] inline Status declareParticles( + SpriteBatcher& batcher, const ParticleSystem& system, + const ParticleDeclareOptions& options) noexcept { + using P = ParticleSystem::Particle; + const std::span particles = system.liveParticles(); + for (std::size_t i = 0; i < particles.size(); ++i) { + const P& p = particles[i]; + SpriteItem item; + // World position (sim units -> render floats, the RENDER-006 + // boundary — one documented rounding per component). + item.pos = Vec2{ + detail::scalarToRenderFloat(p.pos.x), + detail::scalarToRenderFloat(p.pos.y)}; + // The engine-owned depth key (G-R11): the particle's constant + // depth feeds the M2-ISO-01 key as its step height (the + // header's depth-conversion section). depthOverride stays false. + item.depthKey = isoDepthKey( + p.pos, particleDepthToStepHeight(p.depth), + options.layer); + item.uv = options.uv; + item.frameIndex = 0; + item.rotation = 0.0f; + // The square sprite (the particle's constant size, world units). + const float size = detail::scalarToRenderFloat(p.size); + item.scale = Vec2{size, size}; + // The base RGB (u8 -> float) + the current fade alpha (exact u32 + // arithmetic, M2-PART-01) — one presentation-only rounding per + // channel. + item.tint = SpriteTint{ + static_cast(p.tint[0]) / kTintChannelDivisor, + static_cast(p.tint[1]) / kTintChannelDivisor, + static_cast(p.tint[2]) / kTintChannelDivisor, + static_cast(ParticleSystem::fadeAlpha(p)) / + kTintChannelDivisor}; + item.blend = options.blend; + item.atlasId = options.atlasId; + item.materialId = options.materialId; + const auto added = batcher.add(std::move(item)); + if (!added.ok()) { + // The first failed add fails the call (the TileMap::declareTo + // precedent): the items already declared by this call remain + // in the frame (the batcher owns the frame state — the game + // re-declares the whole frame every frame, the header's + // frame-protocol section). + return Status::failure(added.error()); + } + } + return Status{}; +} + +} // namespace laige::render diff --git a/tests/laige-core/budget_harness_tests.cpp b/tests/laige-core/budget_harness_tests.cpp index ca0b6a4..c963321 100644 --- a/tests/laige-core/budget_harness_tests.cpp +++ b/tests/laige-core/budget_harness_tests.cpp @@ -457,11 +457,11 @@ TEST(BudgetHarnessTable, LoadsTheRepoBudgetsFile) { ASSERT_TRUE(table.ok()) << laige::errorText(table.error()); const BudgetTable& t = table.value(); - // One entry per PRD 8.1 target (16 rows: sim tick, 50k sprites, + // One entry per PRD 8.1 target (17 rows: sim tick, 50k sprites, // cold start, build time, and zone server each carry two budgets, // plus the single-budget M2 render targets — iso_depthkey_rebuild, - // iso_picking, depth_sort_10k). - EXPECT_EQ(std::size_t(16), t.size()); + // iso_picking, depth_sort_10k, particle_render_10k). + EXPECT_EQ(std::size_t(17), t.size()); const BudgetEntry* frame = t.find("frame_time_render"); ASSERT_NE(frame, nullptr); diff --git a/tests/laige-render/CMakeLists.txt b/tests/laige-render/CMakeLists.txt index 33fc94d..b8cfc35 100644 --- a/tests/laige-render/CMakeLists.txt +++ b/tests/laige-render/CMakeLists.txt @@ -156,7 +156,22 @@ # layer) + the animated tile under the translation), the animationId # edit/load domain + the unset-slot declare failure, and the 1000-frame # advance + declare zero-allocation loop (standalone + parallax)) — -# pure data + batcher bookkeeping: no GL environment needed. +# pure data + batcher bookkeeping: no GL environment needed; and the +# particle rendering (M2-PART-02) — the M2-PART-01 live particles +# declared into the sprite batcher as sprites (particle_render_tests.cpp's +# ParticleRender* suites: the per-particle declaration goldens on both +# backends (the world position / square scale / tint + fade alpha / UV / +# atlas / blend / layer fields, the engine-owned depth key against the +# isoDepthKey oracle), the exact color fade through the declaration, the +# depth conversion (the dyadic-domain quantization, the sub-unit +# ties-to-even rounding, the packing-boundary saturation), the in-group +# (key, live-order) ordering, the multi-emitter-set group count (one +# draw call per emitter set), the stopped / closed-window failure +# paths, the determinism contract (fixed seed -> identical declared +# (key, position) sequence, the machine-greppable state hash), the +# 1 000-frame zero-allocation declare loop, and the PRD §8.1-style +# 10 000-particle conversion budget gate) — pure data + batcher +# bookkeeping: no GL environment needed. # # One executable per module (tests/README.md): laige-render_tests links # the module under test plus gtest_main. The unfiltered entry runs the @@ -181,11 +196,16 @@ # M2-SPRITE-04 Verify command (`ctest -R render_counters`), the # `tilemap` entry is the M2-TILE-01 Verify command (`ctest -R # tilemap`), the `tilemap_anim` entry is the M2-TILE-02 Verify command -# (`ctest -R tilemap_anim`), and the `parallax` entry is the M2-PAR-01 -# Verify command (`ctest -R parallax`), each selecting exactly its -# suites from the shared executable. (The unanchored `ctest -R tilemap` -# regex also selects the `tilemap_anim` entry — the M2-TILE-02 suites -# run under the M2-TILE-01 command too; that is intended.) +# (`ctest -R tilemap_anim`), the `parallax` entry is the M2-PAR-01 +# Verify command (`ctest -R parallax`), and the `particle_render` +# entry is the M2-PART-02 Verify command (`ctest -R +# particle_render`), each selecting exactly its suites from the +# shared executable. (The unanchored `ctest -R tilemap` regex also +# selects the `tilemap_anim` entry — the M2-TILE-02 suites run under +# the M2-TILE-01 command too; that is intended. Likewise the +# unanchored `ctest -R particle` regex selects the M2-PART-01 +# `particles` entry in tests/laige-sim alongside this one — both +# steps' suites running under one command is intended.) # # Environment note: the GlContextSmoke, RenderThreadOffscreen, # SpriteDraw{State,Smoke,Pipeline}, and RenderCounters{Scene,Cap, @@ -204,7 +224,7 @@ add_executable(laige-render_tests iso_camera_tests.cpp projection_tests.cpp iso_picking_tests.cpp depth_sort_tests.cpp sprite_batcher_tests.cpp sprite_draw_tests.cpp sprite_frames_tests.cpp render_counters_tests.cpp tilemap_tests.cpp - tilemap_anim_tests.cpp parallax_tests.cpp) + tilemap_anim_tests.cpp parallax_tests.cpp particle_render_tests.cpp) laige_apply_engine_policy(laige-render_tests) target_link_libraries(laige-render_tests PRIVATE gtest_main laige-render) # The randomized suites draw through the test-only seed helper @@ -367,10 +387,24 @@ add_test(NAME parallax COMMAND laige-render_tests add_test(NAME tilemap_anim COMMAND laige-render_tests --gtest_filter=TileMapAnim*) +# M2-PART-02: the step's Verify command is `ctest -R +# particle_render`. Pure data + batcher bookkeeping (no GL calls) — +# runs in every local tree and in CI. The LAIGE_BUDGETS_PATH env var +# points the budget suite at the repo budgets.json. The budget gate +# compiles only on Linux in the non-instrumented trees +# (LAIGE_PARTICLE_RENDER_BUDGET — the depth_sort / iso_picking +# precedent); other platforms run the budget WORKLOAD ungated. +add_test(NAME particle_render COMMAND laige-render_tests + --gtest_filter=ParticleRender*) +set_tests_properties(particle_render PROPERTIES + ENVIRONMENT "LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json") + if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") target_compile_definitions(laige-render_tests PRIVATE LAIGE_ISO_DEPTH_BUDGET=1) target_compile_definitions(laige-render_tests PRIVATE LAIGE_ISO_PICK_BUDGET=1) target_compile_definitions(laige-render_tests PRIVATE LAIGE_DEPTH_SORT_BUDGET=1) + target_compile_definitions(laige-render_tests + PRIVATE LAIGE_PARTICLE_RENDER_BUDGET=1) if(NOT CMAKE_BUILD_TYPE STREQUAL "") target_compile_definitions(laige-render_tests PRIVATE LAIGE_ISO_DEPTH_BUILD_TYPE="${CMAKE_BUILD_TYPE}") @@ -378,6 +412,8 @@ if(NOT LAIGE_ASAN AND NOT LAIGE_TSAN AND CMAKE_SYSTEM_NAME STREQUAL "Linux") LAIGE_ISO_PICK_BUILD_TYPE="${CMAKE_BUILD_TYPE}") target_compile_definitions(laige-render_tests PRIVATE LAIGE_DEPTH_SORT_BUILD_TYPE="${CMAKE_BUILD_TYPE}") + target_compile_definitions(laige-render_tests PRIVATE + LAIGE_PARTICLE_RENDER_BUILD_TYPE="${CMAKE_BUILD_TYPE}") endif() endif() @@ -403,7 +439,7 @@ if(LAIGE_TSAN) set_tests_properties(laige-render_tests gl_context render_thread matrices iso_depth_key iso_depth_table camera iso_camera projection iso_picking depth_sort batcher sprite_draw sprite_frames - render_counters tilemap tilemap_anim parallax PROPERTIES + render_counters tilemap tilemap_anim parallax particle_render PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1;LAIGE_BUDGETS_PATH=${CMAKE_SOURCE_DIR}/budgets.json") endif() @@ -467,10 +503,12 @@ set_tests_properties(sprite_frames PROPERTIES TIMEOUT 60) set_tests_properties(tilemap PROPERTIES TIMEOUT 60) set_tests_properties(tilemap_anim PROPERTIES TIMEOUT 60) set_tests_properties(parallax PROPERTIES TIMEOUT 60) -# The `iso_depth_table` and `depth_sort` entries include their budget -# gates: 2 backends x (100 warm-up + 3000 measured) iterations of 10 000 -# setTile calls, and 3 000 sorts of 10 000 keys respectively — a few -# seconds each on the canonical Debug tree; 120s is generous headroom -# for a slow CI runner. +# The `iso_depth_table`, `depth_sort`, and `particle_render` entries +# include their budget gates: 2 backends x (100 warm-up + 3000 +# measured) iterations of 10 000 setTile calls, 10 000-key sorts, and +# 10 000-particle conversion passes respectively — a few seconds each +# on the canonical Debug tree; 120s is generous headroom for a slow CI +# runner. set_tests_properties(iso_depth_table PROPERTIES TIMEOUT 120) set_tests_properties(depth_sort PROPERTIES TIMEOUT 120) +set_tests_properties(particle_render PROPERTIES TIMEOUT 120) diff --git a/tests/laige-render/particle_render_tests.cpp b/tests/laige-render/particle_render_tests.cpp new file mode 100644 index 0000000..eb520be --- /dev/null +++ b/tests/laige-render/particle_render_tests.cpp @@ -0,0 +1,1010 @@ +// M2-PART-02: particle rendering tests (the step's Verify command: +// `ctest -R particle_render`). +// +// The declareParticles contract pinned against: the per-particle +// declaration goldens on both backends (the world position / square +// scale / tint + fade alpha / UV / atlas / blend / layer fields, the +// engine-owned depth key against the M2-ISO-01 oracle + the +// hand-computed dyadic goldens), the depth conversion (the sub-unit +// ties-to-even rounding, the packing-boundary saturation), the exact +// color fade through the declaration, the in-group (key, live-order) +// ordering, the multi-emitter-set group count (one draw call per +// emitter set, RENDER-001), the failure paths (stopped batcher, +// closed window, the overflow policy), the determinism contract +// (fixed seed -> identical declared (key, position) sequence, the +// machine-greppable state hash), the 1 000-frame zero-allocation +// declare loop (PERF-003), and the PRD §8.1-style 10 000-particle +// conversion budget gate (LAIGE_PARTICLE_RENDER_BUDGET — gated on the +// Linux non-instrumented trees, ungated elsewhere). +// +// No GL needed — runs in every tree. + +#include +#include +#include +#include +#include + +#include + +#include "laige/alloc_watch.h" +#include "laige/budget_harness.h" +#include "laige/errors.h" +#include "laige/fpx16_16.h" +#include "laige/logging.h" +#include "laige/render/iso_depth_key.h" +#include "laige/render/particle_render.h" +#include "laige/render/sprite_batcher.h" +#include "laige/result.h" +#include "laige/sim/particles.h" +#include "laige/sim_math.h" + +namespace { + +using laige::ParticleSystem; +using laige::ParticleEmitterDef; +using laige::render::BlendMode; +using laige::render::SpriteBatch; +using laige::render::SpriteBatcher; +using laige::render::SpriteItem; +using laige::render::ParticleDeclareOptions; +using laige::render::declareParticles; +using laige::render::particleDepthToStepHeight; +using laige::render::kIsoDepthGroundLayer; + +template +using M_ = laige::sim::SimMath; + +template +using P_ = ParticleSystem::Particle; + +// --------------------------------------------------------------------------- +// Backend-agnostic scalar construction (dyadic test values only) +// --------------------------------------------------------------------------- + +template +typename M_::Scalar sc(std::int64_t num, std::int64_t den = 1) { + using Scalar = typename M_::Scalar; + if constexpr (std::is_same_v) { + // Exact for the dyadic test values used here (integer division + // truncation: the raw value num * 2^16 / den). + return laige::fpx16_16{ + static_cast(num * (std::int64_t(1) << 16) / den)}; + } else { + return static_cast(static_cast(num) / + static_cast(den)); + } +} + +template +const char* backendName() { + if constexpr (std::is_same_v) { + return "fpx16_16"; + } else { + return "fp32_pinned"; + } +} + +// A fully degenerate emitter def (min == max everywhere): a burst +// from it is deterministic without any Prng value (the lerp +// endpoints coincide). +template +ParticleEmitterDef degenerateDef(std::int64_t ox, std::int64_t oy, + std::int64_t depthNum, + std::int64_t depthDen, + std::uint32_t life, + std::int64_t sizeNum, + std::int64_t sizeDen, + std::uint8_t r, std::uint8_t g, + std::uint8_t b, std::uint8_t a) { + using Vec2 = typename M_::Vec2; + ParticleEmitterDef d; + d.origin = Vec2{sc(ox), sc(oy)}; + d.depth = sc(depthNum, depthDen); + d.velMin = Vec2{sc(0), sc(0)}; + d.velMax = Vec2{sc(0), sc(0)}; + d.lifeMin = life; + d.lifeMax = life; + d.sizeMin = sc(sizeNum, sizeDen); + d.sizeMax = sc(sizeNum, sizeDen); + d.tint[0] = r; + d.tint[1] = g; + d.tint[2] = b; + d.tint[3] = a; + d.fadeTicks = 0; + d.continuousRate = 0; + return d; +} + +// The default options (the particle convention: the shared atlas 7, +// additive blend, the full atlas, the ground layer). +ParticleDeclareOptions baseOptions() { + ParticleDeclareOptions o; + o.atlasId = 7; + o.materialId = 0; + o.blend = BlendMode::Additive; + o.uv = laige::render::SpriteUvRect{0.0f, 0.0f, 1.0f, 1.0f}; + o.layer = kIsoDepthGroundLayer; + return o; +} + +// The machine-greppable state hash (the docs/testing.md §4 KAT +// convention: FNV-1a 64, big-endian byte order per u64). +std::uint64_t particlesFnv1a64(const std::uint64_t* values, std::size_t n) { + std::uint64_t h = 0xcbf29ce484222325ull; // FNV offset basis (FNV-1a spec) + for (std::size_t i = 0; i < n; ++i) { + for (int shift = 56; shift >= 0; shift -= 8) { + h ^= (values[i] >> shift) & 0xFFull; + h *= 0x10000001b3ull; // FNV prime (FNV-1a spec) + } + } + return h; +} + +// The float bit pattern (the KAT hash's payload — the declared render +// position, exact per backend). +std::uint32_t floatBits(float v) { + std::uint32_t bits; + std::memcpy(&bits, &v, sizeof(bits)); + return bits; +} + +// --------------------------------------------------------------------------- +// The budget report context helpers (the iso_depth_table_tests.cpp +// pattern — BudgetsFilePath is gated builds only; MachineLine and +// kCompilerId are unconditional: the run function's gated code is +// compiled in every tree, unreachable when ungated) +// --------------------------------------------------------------------------- + +// The budgets.json path (the repo convention): LAIGE_BUDGETS_PATH, +// then "budgets.json" in the working directory (the ctest gate sets +// the env var to the repo root — tests/laige-render/CMakeLists.txt). +// Platform boundary (the budget_harness_tests.cpp pattern): MSVC +// deprecates plain getenv (C4996, fatal under /WX). Gated builds only +// (the ungated trees never load the table — the helper is defined +// only where it is used, -Wunused-function under -Werror): +#if defined(LAIGE_PARTICLE_RENDER_BUDGET) +std::string BudgetsFilePath() { +#if defined(_MSC_VER) + constexpr std::size_t kMax = 4096; + char buf[kMax]; + std::size_t len = 0; + if (getenv_s(&len, buf, sizeof(buf), "LAIGE_BUDGETS_PATH") != 0) { + return std::string("budgets.json"); + } + if (len == 0) return std::string("budgets.json"); + return std::string(buf, len); +#else + const char* env = std::getenv("LAIGE_BUDGETS_PATH"); + return (env != nullptr && *env != '\0') ? std::string(env) + : std::string("budgets.json"); +#endif +} +#endif // LAIGE_PARTICLE_RENDER_BUDGET + +// The machine line for the budget report context (the laige-bench +// operator convention: LAIGE_BENCH_MACHINE, empty when unset — the +// baseline document records the machine facts): +std::string MachineLine() { +#if defined(_MSC_VER) + constexpr std::size_t kMax = 4096; + char buf[kMax]; + std::size_t len = 0; + if (getenv_s(&len, buf, sizeof(buf), "LAIGE_BENCH_MACHINE") != 0) { + return std::string(); + } + return std::string(buf, len); +#else + const char* env = std::getenv("LAIGE_BENCH_MACHINE"); + return (env != nullptr) ? std::string(env) : std::string(); +#endif +} + +#define LAIGE_PARTICLE_STR2(x) #x +#define LAIGE_PARTICLE_STR(x) LAIGE_PARTICLE_STR2(x) +#if defined(__clang__) +constexpr char kCompilerId[] = "Clang " LAIGE_PARTICLE_STR(__clang_major__) + "." LAIGE_PARTICLE_STR(__clang_minor__) "." + LAIGE_PARTICLE_STR(__clang_patchlevel__); +#elif defined(__GNUC__) +constexpr char kCompilerId[] = "GCC " __VERSION__; +#elif defined(_MSC_VER) +constexpr char kCompilerId[] = "MSVC " LAIGE_PARTICLE_STR(_MSC_FULL_VER); +#else +constexpr char kCompilerId[] = "unknown compiler"; +#endif + +// --------------------------------------------------------------------------- +// ParticleRenderDeclare — the per-particle declaration goldens +// --------------------------------------------------------------------------- + +// The hand-computed dyadic goldens (layer 0: key = 0x80200000 + d, +// d = round((x + y) * 16) - z * 16 — the M2-ISO-01 formula): +// +// A (1, 2) depth 3 d = 48 - 48 = 0 -> 0x80200000 +// B (0, 0) depth 0 d = 0 - 0 = 0 -> 0x80200000 (ties A) +// C (2, 2) depth 1 d = 64 - 16 = 48 -> 0x80200030 +// D (0, 0) depth 2 d = 0 - 32 = -32 -> 0x801FFFE0 +// E (1, 1) depth 0.5 z = 0 (ties-to-even), d = 32 -> 0x80200020 +// F (1, 1) depth 1.5 z = 2 (ties-to-even), d = 0 -> 0x80200000 +// +// Expected in-group order (back-to-front, equal keys keep spawn +// order A, B, C, D, E, F): D, A, B, F, E, C. +template +void declareGolden() { + using Vec2 = typename M_::Vec2; + constexpr std::uint32_t kSeed = 1; + auto rc = ParticleSystem::create({16, 6, kSeed}); + ASSERT_TRUE(rc.ok()); + auto sys = std::move(rc).takeValue(); + const auto eA = sys.addEmitter(degenerateDef(1, 2, 3, 1, 4, 1, 2, + 255, 128, 64, 200)); + const auto eB = sys.addEmitter(degenerateDef(0, 0, 0, 1, 4, 1, 1, + 10, 20, 30, 40)); + const auto eC = sys.addEmitter(degenerateDef(2, 2, 1, 1, 4, 1, 4, + 255, 0, 0, 255)); + const auto eD = sys.addEmitter(degenerateDef(0, 0, 2, 1, 4, 1, 1, + 0, 255, 255, 128)); + const auto eE = sys.addEmitter(degenerateDef(1, 1, 1, 2, 2, 1, 1, + 1, 2, 3, 4)); + const auto eF = sys.addEmitter(degenerateDef(1, 1, 3, 2, 4, 2, 1, + 7, 7, 7, 255)); + ASSERT_TRUE(eA.ok()); + ASSERT_TRUE(eB.ok()); + ASSERT_TRUE(eC.ok()); + ASSERT_TRUE(eD.ok()); + ASSERT_TRUE(eE.ok()); + ASSERT_TRUE(eF.ok()); + ASSERT_TRUE(sys.burst(eA.value(), 1).ok()); + ASSERT_TRUE(sys.burst(eB.value(), 1).ok()); + ASSERT_TRUE(sys.burst(eC.value(), 1).ok()); + ASSERT_TRUE(sys.burst(eD.value(), 1).ok()); + ASSERT_TRUE(sys.burst(eE.value(), 1).ok()); + ASSERT_TRUE(sys.burst(eF.value(), 1).ok()); + ASSERT_EQ(sys.liveCount(), 6u); + + auto bc = SpriteBatcher::create({16}); + ASSERT_TRUE(bc.ok()); + auto batcher = std::move(bc).takeValue(); + batcher.beginFrame(); + const ParticleDeclareOptions opts = baseOptions(); + ASSERT_TRUE(declareParticles(batcher, sys, opts).ok()); + ASSERT_EQ(batcher.frameCount(), 6u); + ASSERT_TRUE(batcher.build().ok()); + ASSERT_TRUE(batcher.frameBuilt()); + // One emitter set -> ONE (atlas, material, blend) group -> one draw + // call (RENDER-001, FR-2.7). + ASSERT_EQ(batcher.batchCount(), 1u); + const SpriteBatch& batch = batcher.batches().front(); + EXPECT_EQ(7u, batch.atlasId); + EXPECT_EQ(0u, batch.materialId); + EXPECT_EQ(BlendMode::Additive, batch.blend); + ASSERT_EQ(6u, batch.instances.size()); + + // The expected (key, origin, depth-int) table in the documented + // in-group order (back-to-front, equal keys keep spawn order): + struct Expected { + std::uint32_t key; + Vec2 origin; + std::int32_t depthInt; + float size; + float tint[4]; + }; + const Expected kExpected[6] = { + {0x801FFFE0u, Vec2{sc(0), sc(0)}, 2, 1.0f, + {0.0f, static_cast(255) / 255.0f, static_cast(255) / 255.0f, + static_cast(128) / 255.0f}}, + {0x80200000u, Vec2{sc(1), sc(2)}, 3, 0.5f, + {1.0f, static_cast(128) / 255.0f, static_cast(64) / 255.0f, + static_cast(200) / 255.0f}}, + {0x80200000u, Vec2{sc(0), sc(0)}, 0, 1.0f, + {static_cast(10) / 255.0f, static_cast(20) / 255.0f, + static_cast(30) / 255.0f, static_cast(40) / 255.0f}}, + {0x80200000u, Vec2{sc(1), sc(1)}, 2, 2.0f, + {static_cast(7) / 255.0f, static_cast(7) / 255.0f, + static_cast(7) / 255.0f, 1.0f}}, + {0x80200020u, Vec2{sc(1), sc(1)}, 0, 1.0f, + {static_cast(1) / 255.0f, static_cast(2) / 255.0f, + static_cast(3) / 255.0f, static_cast(4) / 255.0f}}, + {0x80200030u, Vec2{sc(2), sc(2)}, 1, 0.25f, + {1.0f, 0.0f, 0.0f, 1.0f}}, + }; + for (std::size_t i = 0; i < batch.instances.size(); ++i) { + const SpriteItem& item = batcher.at(batch.instances[i]); + const Expected& e = kExpected[i]; + EXPECT_EQ(e.key, item.depthKey) + << "instance " << i << " (key " << item.depthKey << " vs expected " + << e.key << ")"; + // The engine-owned key == the M2-ISO-01 oracle on the same input: + EXPECT_EQ(laige::render::isoDepthKey(e.origin, e.depthInt, + kIsoDepthGroundLayer), + item.depthKey); + EXPECT_FALSE(item.depthOverride); + EXPECT_EQ(e.size, item.scale.x); + EXPECT_EQ(e.size, item.scale.y); + const float itemTint[4] = {item.tint.r, item.tint.g, item.tint.b, + item.tint.a}; + for (int c = 0; c < 4; ++c) { + EXPECT_FLOAT_EQ(e.tint[c], itemTint[c]) << "instance " << i + << " tint channel " << c; + } + EXPECT_EQ(0.0f, item.rotation); + EXPECT_EQ(0u, item.frameIndex); + // Field-wise (SpriteUvRect has no operator== — the parallax + // convention): + EXPECT_FLOAT_EQ(opts.uv.u0, item.uv.u0); + EXPECT_FLOAT_EQ(opts.uv.v0, item.uv.v0); + EXPECT_FLOAT_EQ(opts.uv.u1, item.uv.u1); + EXPECT_FLOAT_EQ(opts.uv.v1, item.uv.v1); + EXPECT_EQ(7u, item.atlasId); + EXPECT_EQ(0u, item.materialId); + EXPECT_EQ(BlendMode::Additive, item.blend); + // The world position at the RENDER-006 boundary (one documented + // rounding per backend — exact here: dyadic in the exactness zone). + if constexpr (std::is_same_v) { + EXPECT_FLOAT_EQ(laige::fpx16_16::toFloat(e.origin.x), item.pos.x); + EXPECT_FLOAT_EQ(laige::fpx16_16::toFloat(e.origin.y), item.pos.y); + } else { + EXPECT_FLOAT_EQ(e.origin.x, item.pos.x); + EXPECT_FLOAT_EQ(e.origin.y, item.pos.y); + } + } +} + +TEST(ParticleRenderDeclare, GoldenFields) { + declareGolden(); + declareGolden(); +} + +// --------------------------------------------------------------------------- +// ParticleRenderDepth — the depth conversion (RENDER-006) +// --------------------------------------------------------------------------- + +template +void depthConversion() { + // The quantization table (dyadic values — the cross-backend exactness + // zone: both backends must agree, the header's depth-conversion + // section): + // 3 -> 3, 0.5 -> 0 (ties-to-even), 1.5 -> 2, -0.5 -> 0, + // -1.5 -> -2, 2046.5 -> 2046 (ties-to-even), 3000 -> 2047 + // (clamped), -3000 -> -2047 (clamped), 0 -> 0, 2047 -> 2047. + struct Case { + std::int64_t num; + std::int64_t den; + std::int32_t expect; + }; + const Case kCases[] = { + {3, 1, 3}, {1, 2, 0}, {3, 2, 2}, {-1, 2, 0}, {-3, 2, -2}, + {4093, 2, 2046}, {3000, 1, 2047}, {-3000, 1, -2047}, {0, 1, 0}, + {2047, 1, 2047}, + }; + for (const Case& c : kCases) { + EXPECT_EQ(c.expect, particleDepthToStepHeight(sc(c.num, c.den))) + << backendName() << " depth " << c.num << "/" << c.den; + } + + // Through the declaration: a depth-0.5 particle renders at z = 0 + // (the tie-to-even rounding) and a depth-1.5 particle at z = 2. + auto rc = ParticleSystem::create({4, 2, 42}); + ASSERT_TRUE(rc.ok()); + auto sys = std::move(rc).takeValue(); + const auto eHalf = + sys.addEmitter(degenerateDef(0, 0, 1, 2, 2, 1, 1, 255, 255, 255, 255)); + const auto eOneHalf = + sys.addEmitter(degenerateDef(0, 0, 3, 2, 2, 1, 1, 255, 255, 255, 255)); + ASSERT_TRUE(eHalf.ok()); + ASSERT_TRUE(eOneHalf.ok()); + ASSERT_TRUE(sys.burst(eHalf.value(), 1).ok()); + ASSERT_TRUE(sys.burst(eOneHalf.value(), 1).ok()); + auto bc = SpriteBatcher::create({4}); + ASSERT_TRUE(bc.ok()); + auto batcher = std::move(bc).takeValue(); + batcher.beginFrame(); + ASSERT_TRUE(declareParticles(batcher, sys, baseOptions()).ok()); + ASSERT_TRUE(batcher.build().ok()); + // The exact keys (hand-computed: (0, 0) z = 0 -> 0x80200000; z = 2 -> + // 0x80200000 - 32 = 0x801FFFE0). The z = 2 particle is back-most (d = + // -32 < d = 0) so it sorts BEFORE the z = 0 one: + const SpriteBatch& batch = batcher.batches().front(); + ASSERT_EQ(2u, batch.instances.size()); + EXPECT_EQ(0x801FFFE0u, batcher.at(batch.instances[0]).depthKey); + EXPECT_EQ(0x80200000u, batcher.at(batch.instances[1]).depthKey); +} + +TEST(ParticleRenderDepth, Conversion) { + depthConversion(); + depthConversion(); +} + +// --------------------------------------------------------------------------- +// ParticleRenderFade — the exact color fade through the declaration +// --------------------------------------------------------------------------- + +template +void fadeGolden() { + // life 8, fadeTicks 4, tint.a 255: a particle with `remaining` ticks + // left renders alpha = remaining >= fadeTicks ? 255 : 255 * + // remaining / fadeTicks (exact u32, M2-PART-01). Eight burst + // particles, declared once per tick (before each update): + // age 0..7 -> remaining 8..1 -> alpha {255, 255, 255, 255, 255, + // 191, 127, 63}. + constexpr std::uint8_t kExpected[8] = {255, 255, 255, 255, 255, 191, 127, + 63}; + auto rc = ParticleSystem::create({8, 1, 7}); + ASSERT_TRUE(rc.ok()); + auto sys = std::move(rc).takeValue(); + // The fade def (fadeTicks 4 — degenerateDef's fade is always 0): + using Vec2 = typename M_::Vec2; + ParticleEmitterDef d; + d.origin = Vec2{sc(0), sc(0)}; + d.depth = sc(0); + d.velMin = Vec2{sc(0), sc(0)}; + d.velMax = Vec2{sc(0), sc(0)}; + d.lifeMin = 8; + d.lifeMax = 8; + d.sizeMin = sc(1, 2); + d.sizeMax = sc(1, 2); + d.tint[0] = 255; + d.tint[1] = 0; + d.tint[2] = 255; + d.tint[3] = 255; + d.fadeTicks = 4; + d.continuousRate = 0; + const auto e = sys.addEmitter(d); + ASSERT_TRUE(e.ok()); + ASSERT_TRUE(sys.burst(e.value(), 8).ok()); + auto bc = SpriteBatcher::create({8}); + ASSERT_TRUE(bc.ok()); + auto batcher = std::move(bc).takeValue(); + for (std::uint32_t k = 0; k < 8; ++k) { + batcher.beginFrame(); + ASSERT_TRUE(declareParticles(batcher, sys, baseOptions()).ok()); + ASSERT_TRUE(batcher.build().ok()); + ASSERT_EQ(8u, batcher.frameCount()); + const SpriteBatch& batch = batcher.batches().front(); + for (std::size_t i = 0; i < batch.instances.size(); ++i) { + const SpriteItem& item = batcher.at(batch.instances[i]); + EXPECT_FLOAT_EQ(static_cast(kExpected[k]) / 255.0f, item.tint.a) + << "age " << k; + // The RGB channels are constant (the fade touches alpha only): + EXPECT_FLOAT_EQ(1.0f, item.tint.r); + EXPECT_FLOAT_EQ(0.0f, item.tint.g); + EXPECT_FLOAT_EQ(1.0f, item.tint.b); + } + sys.update(); + } + // After the 8th update all particles are dead: the declaration is a + // no-op success (the empty span declares nothing). + batcher.beginFrame(); + ASSERT_TRUE(declareParticles(batcher, sys, baseOptions()).ok()); + EXPECT_EQ(0u, batcher.frameCount()); + ASSERT_TRUE(batcher.build().ok()); + EXPECT_EQ(0u, batcher.batchCount()); +} + +TEST(ParticleRenderFade, ExactAlpha) { + fadeGolden(); + fadeGolden(); +} + +// --------------------------------------------------------------------------- +// ParticleRenderOrder — the in-group (key, live-order) total order +// --------------------------------------------------------------------------- + +template +void orderGolden() { + // Spawn order A, B, C, D (the live order — the tie-break): + // A (1, 2) depth 3 -> 0x80200000 + // B (0, 0) depth 0 -> 0x80200000 (ties A: spawn order keeps A first) + // C (2, 2) depth 1 -> 0x80200030 + // D (0, 0) depth 2 -> 0x801FFFE0 (back-most: drawn first) + // Expected in-group order: D, A, B, C. + auto rc = ParticleSystem::create({4, 4, 9}); + ASSERT_TRUE(rc.ok()); + auto sys = std::move(rc).takeValue(); + const auto eA = sys.addEmitter(degenerateDef(1, 2, 3, 1, 4, 1, 1, 255, 255, 255, 255)); + const auto eB = sys.addEmitter(degenerateDef(0, 0, 0, 1, 4, 1, 1, 255, 255, 255, 255)); + const auto eC = sys.addEmitter(degenerateDef(2, 2, 1, 1, 4, 1, 1, 255, 255, 255, 255)); + const auto eD = sys.addEmitter(degenerateDef(0, 0, 2, 1, 4, 1, 1, 255, 255, 255, 255)); + ASSERT_TRUE(eA.ok()); + ASSERT_TRUE(eB.ok()); + ASSERT_TRUE(eC.ok()); + ASSERT_TRUE(eD.ok()); + ASSERT_TRUE(sys.burst(eA.value(), 1).ok()); + ASSERT_TRUE(sys.burst(eB.value(), 1).ok()); + ASSERT_TRUE(sys.burst(eC.value(), 1).ok()); + ASSERT_TRUE(sys.burst(eD.value(), 1).ok()); + auto bc = SpriteBatcher::create({4}); + ASSERT_TRUE(bc.ok()); + auto batcher = std::move(bc).takeValue(); + batcher.beginFrame(); + ASSERT_TRUE(declareParticles(batcher, sys, baseOptions()).ok()); + ASSERT_TRUE(batcher.build().ok()); + ASSERT_EQ(1u, batcher.batchCount()); + const SpriteBatch& batch = batcher.batches().front(); + ASSERT_EQ(4u, batch.instances.size()); + // The keys back-to-front (non-decreasing, the M2-SORT-01 contract): + const std::uint32_t kExpectedKeys[4] = {0x801FFFE0u, 0x80200000u, + 0x80200000u, 0x80200030u}; + for (std::size_t i = 0; i < 4; ++i) { + EXPECT_EQ(kExpectedKeys[i], batcher.at(batch.instances[i]).depthKey); + } + // The tie (A, B at 0x80200000) keeps spawn order: A's origin (1, 2) + // before B's (0, 0). + const SpriteItem& tieA = batcher.at(batch.instances[1]); + const SpriteItem& tieB = batcher.at(batch.instances[2]); + if constexpr (std::is_same_v) { + EXPECT_FLOAT_EQ(laige::fpx16_16::toFloat(sc(1)), tieA.pos.x); + EXPECT_FLOAT_EQ(laige::fpx16_16::toFloat(sc(0)), tieB.pos.x); + } else { + EXPECT_FLOAT_EQ(1.0f, tieA.pos.x); + EXPECT_FLOAT_EQ(0.0f, tieB.pos.x); + } +} + +TEST(ParticleRenderOrder, KeyLiveOrder) { + orderGolden(); + orderGolden(); +} + +// --------------------------------------------------------------------------- +// ParticleRenderEmitterSets — one (atlas, material, blend) group per +// emitter set (one draw call per set, RENDER-001) +// --------------------------------------------------------------------------- + +template +void emitterSets() { + auto rcA = ParticleSystem::create({8, 1, 101}); + auto rcB = ParticleSystem::create({8, 1, 102}); + auto rcC = ParticleSystem::create({8, 1, 103}); + ASSERT_TRUE(rcA.ok()); + ASSERT_TRUE(rcB.ok()); + ASSERT_TRUE(rcC.ok()); + auto sysA = std::move(rcA).takeValue(); + auto sysB = std::move(rcB).takeValue(); + auto sysC = std::move(rcC).takeValue(); + const auto eA = sysA.addEmitter(degenerateDef(0, 0, 0, 1, 4, 1, 1, 255, 255, 255, 255)); + const auto eB = sysB.addEmitter(degenerateDef(3, 0, 1, 1, 4, 1, 1, 255, 255, 255, 255)); + const auto eC = sysC.addEmitter(degenerateDef(0, 3, 0, 1, 4, 1, 1, 255, 255, 255, 255)); + ASSERT_TRUE(eA.ok()); + ASSERT_TRUE(eB.ok()); + ASSERT_TRUE(eC.ok()); + ASSERT_TRUE(sysA.burst(eA.value(), 2).ok()); + ASSERT_TRUE(sysB.burst(eB.value(), 3).ok()); + ASSERT_TRUE(sysC.burst(eC.value(), 1).ok()); + // Three sets: (atlas 5, Additive), (atlas 5, Alpha), (atlas 9, + // Additive) — the group order is ascending (atlas, material, + // blend): (5, Alpha), (5, Additive), (9, Additive). + ParticleDeclareOptions optsA = baseOptions(); + optsA.atlasId = 5; + ParticleDeclareOptions optsB = baseOptions(); + optsB.atlasId = 5; + optsB.blend = BlendMode::Alpha; + ParticleDeclareOptions optsC = baseOptions(); + optsC.atlasId = 9; + auto bc = SpriteBatcher::create({8}); + ASSERT_TRUE(bc.ok()); + auto batcher = std::move(bc).takeValue(); + batcher.beginFrame(); + ASSERT_TRUE(declareParticles(batcher, sysA, optsA).ok()); + ASSERT_TRUE(declareParticles(batcher, sysB, optsB).ok()); + ASSERT_TRUE(declareParticles(batcher, sysC, optsC).ok()); + ASSERT_TRUE(batcher.build().ok()); + ASSERT_EQ(3u, batcher.batchCount()); + const auto batches = batcher.batches(); + EXPECT_EQ(5u, batches[0].atlasId); + EXPECT_EQ(BlendMode::Alpha, batches[0].blend); + EXPECT_EQ(3u, batches[0].instances.size()); + EXPECT_EQ(5u, batches[1].atlasId); + EXPECT_EQ(BlendMode::Additive, batches[1].blend); + EXPECT_EQ(2u, batches[1].instances.size()); + EXPECT_EQ(9u, batches[2].atlasId); + EXPECT_EQ(BlendMode::Additive, batches[2].blend); + EXPECT_EQ(1u, batches[2].instances.size()); +} + +TEST(ParticleRenderEmitterSets, OneGroupPerSet) { + emitterSets(); + emitterSets(); +} + +// --------------------------------------------------------------------------- +// ParticleRenderFailure — the failure paths +// --------------------------------------------------------------------------- + +template +void failurePaths() { + // A one-particle system (the non-empty case). + auto rc = ParticleSystem::create({4, 1, 55}); + ASSERT_TRUE(rc.ok()); + auto sys = std::move(rc).takeValue(); + const auto e = sys.addEmitter(degenerateDef(0, 0, 0, 1, 4, 1, 1, 255, 255, 255, 255)); + ASSERT_TRUE(e.ok()); + ASSERT_TRUE(sys.burst(e.value(), 1).ok()); + // The stopped batcher (default-constructed, capacity 0): the first + // add fails with BudgetExhausted (the batcher's stopped-state + // contract). + SpriteBatcher stopped; + const auto rStopped = declareParticles(stopped, sys, baseOptions()); + EXPECT_FALSE(rStopped.ok()); + EXPECT_EQ(laige::ErrorCode::BudgetExhausted, rStopped.error()); + // The closed window (build already ran): InvalidArgument (the + // batcher's frame-protocol contract). + auto bc = SpriteBatcher::create({4}); + ASSERT_TRUE(bc.ok()); + auto batcher = std::move(bc).takeValue(); + batcher.beginFrame(); + ASSERT_TRUE(batcher.build().ok()); // closes the window + const auto rClosed = declareParticles(batcher, sys, baseOptions()); + EXPECT_FALSE(rClosed.ok()); + EXPECT_EQ(laige::ErrorCode::InvalidArgument, rClosed.error()); + // The batcher's overflow policy: a capacity-2 frame with 3 + // particles drops the OLDEST declaration (the batcher's own + // rate-limited warn) — declareParticles itself succeeds (no failed + // add; the overflow is a batcher-internal policy). + auto rc3 = ParticleSystem::create({4, 1, 56}); + ASSERT_TRUE(rc3.ok()); + auto sys3 = std::move(rc3).takeValue(); + const auto e3 = sys3.addEmitter(degenerateDef(0, 0, 0, 1, 4, 1, 1, 255, 255, 255, 255)); + ASSERT_TRUE(e3.ok()); + ASSERT_TRUE(sys3.burst(e3.value(), 3).ok()); + auto bc2 = SpriteBatcher::create({2}); + ASSERT_TRUE(bc2.ok()); + auto small = std::move(bc2).takeValue(); + small.beginFrame(); + ASSERT_TRUE(declareParticles(small, sys3, baseOptions()).ok()); + EXPECT_EQ(2u, small.frameCount()); +} + +TEST(ParticleRenderFailure, Paths) { + failurePaths(); + failurePaths(); +} + +// --------------------------------------------------------------------------- +// ParticleRenderDeterminism — fixed seed -> identical declared sequence +// --------------------------------------------------------------------------- + +// Builds a two-emitter system with a velocity box (non-degenerate — +// the Prng draws the positions) and bursts 5 particles per emitter. +template +ParticleSystem buildSeededSystem(std::uint64_t seed) { + auto rc = ParticleSystem::create({64, 2, seed}); + if (!rc.ok()) { + std::fprintf(stderr, "buildSeededSystem: create failed\n"); + std::abort(); + } + auto sys = std::move(rc).takeValue(); + using Vec2 = typename M_::Vec2; + ParticleEmitterDef d; + d.origin = Vec2{sc(0), sc(0)}; + d.depth = sc(0, 2); + d.velMin = Vec2{sc(1, 4), sc(1, 4)}; + d.velMax = Vec2{sc(1, 2), sc(1, 2)}; + d.lifeMin = 20; + d.lifeMax = 40; + d.sizeMin = sc(1, 4); + d.sizeMax = sc(1, 2); + d.tint[0] = 255; + d.tint[1] = 128; + d.tint[2] = 64; + d.tint[3] = 255; + d.fadeTicks = 8; + d.continuousRate = 0; + auto e1 = sys.addEmitter(d); + if (!e1.ok()) { + std::fprintf(stderr, "buildSeededSystem: addEmitter failed\n"); + std::abort(); + } + d.origin = Vec2{sc(5), sc(-2)}; + d.depth = sc(2); + auto e2 = sys.addEmitter(d); + if (!e2.ok()) { + std::fprintf(stderr, "buildSeededSystem: addEmitter failed\n"); + std::abort(); + } + if (!sys.burst(e1.value(), 5).ok() || !sys.burst(e2.value(), 5).ok()) { + std::fprintf(stderr, "buildSeededSystem: burst failed\n"); + std::abort(); + } + for (int i = 0; i < 3; ++i) sys.update(); + return sys; +} + +// Hashes one frame's declared (key, pos.x, pos.y) sequence — the +// machine-greppable render-state fingerprint (docs/testing.md §4). +template +std::uint64_t declaredStateHash(const ParticleSystem& sys, + SpriteBatcher& batcher) { + batcher.beginFrame(); + const auto r = declareParticles(batcher, sys, baseOptions()); + if (!r.ok()) { + std::fprintf(stderr, "declaredStateHash: declare failed\n"); + std::abort(); + } + if (!batcher.build().ok()) { + std::fprintf(stderr, "declaredStateHash: build failed\n"); + std::abort(); + } + // (the declared sequence — one key + two float positions per + // particle, in the frame's rendered order) + std::vector payload; + payload.reserve(batcher.frameCount() * 3); + const auto batches = batcher.batches(); + for (const SpriteBatch& batch : batches) { + for (std::uint32_t slot : batch.instances) { + const SpriteItem& item = batcher.at(slot); + payload.push_back(item.depthKey); + payload.push_back(floatBits(item.pos.x)); + payload.push_back(floatBits(item.pos.y)); + } + } + return particlesFnv1a64(payload.data(), payload.size()); +} + +template +void determinism() { + constexpr std::uint64_t kSeed = 0xC0FFEE11ull; + auto sys1 = buildSeededSystem(kSeed); + auto sys2 = buildSeededSystem(kSeed); + auto sys3 = buildSeededSystem(kSeed + 1); + auto bc = SpriteBatcher::create({64}); + ASSERT_TRUE(bc.ok()); + auto batcher = std::move(bc).takeValue(); + const std::uint64_t h1 = declaredStateHash(sys1, batcher); + const std::uint64_t h2 = declaredStateHash(sys2, batcher); + const std::uint64_t h3 = declaredStateHash(sys3, batcher); + std::printf("particle-render-determinism backend=%s seed=0x%016llx " + "live=%llu fnv1a=0x%016llx\n", + backendName(), + static_cast(kSeed), + static_cast(sys1.liveCount()), + static_cast(h1)); + EXPECT_EQ(h1, h2); + EXPECT_NE(h1, h3); // a different seed diverges +} + +TEST(ParticleRenderDeterminism, SeedStable) { + determinism(); + determinism(); +} + +// --------------------------------------------------------------------------- +// ParticleRenderZeroAlloc — the per-frame declare loop allocates +// nothing (PERF-003 — FR-2.7 "pooled") +// --------------------------------------------------------------------------- + +template +void zeroAllocLoop() { + constexpr std::uint32_t kFrames = 1000; + constexpr std::uint32_t kParticles = 10000; + auto rc = ParticleSystem::create({kParticles, 1, 0xB00D}); + ASSERT_TRUE(rc.ok()); + auto sys = std::move(rc).takeValue(); + using Vec2 = typename M_::Vec2; + ParticleEmitterDef d; + d.origin = Vec2{sc(0), sc(0)}; + d.depth = sc(0, 2); + d.velMin = Vec2{sc(1, 4), sc(1, 4)}; + d.velMax = Vec2{sc(1, 2), sc(1, 2)}; + d.lifeMin = 20; + d.lifeMax = 40; + d.sizeMin = sc(1, 4); + d.sizeMax = sc(1, 2); + d.tint[0] = 255; + d.tint[1] = 128; + d.tint[2] = 64; + d.tint[3] = 255; + d.fadeTicks = 8; + d.continuousRate = 0; + const auto e = sys.addEmitter(d); + ASSERT_TRUE(e.ok()); + ASSERT_TRUE(sys.burst(e.value(), kParticles).ok()); + auto bc = SpriteBatcher::create({kParticles}); + ASSERT_TRUE(bc.ok()); + auto batcher = std::move(bc).takeValue(); + const ParticleDeclareOptions opts = baseOptions(); + auto runFrames = [&]() { + for (std::uint32_t f = 0; f < kFrames; ++f) { + batcher.beginFrame(); + if (!declareParticles(batcher, sys, opts).ok()) { + std::fprintf(stderr, "zeroAllocLoop: declare failed\n"); + std::abort(); + } + if (!batcher.build().ok()) { + std::fprintf(stderr, "zeroAllocLoop: build failed\n"); + std::abort(); + } + } + }; + if (laige::allocWatchLive()) { + laige::allocWatchArm(); + runFrames(); + const laige::AllocWatchReading reading = laige::allocWatchRead(); + EXPECT_EQ(0ull, reading.allocs) + << kFrames << " frames of " << kParticles << "-particle declare " + << "allocated " << reading.allocs << " heap blocks on the loop " + << "thread"; + std::printf("particle-render-zeroalloc backend=%s frames=%u " + "particles=%u allocs=%llu\n", + backendName(), kFrames, kParticles, + static_cast(reading.allocs)); + } else { + runFrames(); // sanitizer tree: the leak-free run covers it + } +} + +TEST(ParticleRenderZeroAlloc, DeclareLoopAllocatesNothing) { + zeroAllocLoop(); + zeroAllocLoop(); +} + +// --------------------------------------------------------------------------- +// ParticleRenderBudget — the PRD §8.1-style 10k-particle conversion +// budget (the `particle_render_10k` entry) +// --------------------------------------------------------------------------- + +// The workload shape (named constants — CORE-005): +constexpr std::uint32_t kBudgetParticles = 10000; +constexpr std::uint32_t kBudgetTicks = 10; // the position spread +constexpr std::uint64_t kBudgetSeed = 0xC0FFEE00ull; +constexpr std::int32_t kBudgetWarmup = 100; +constexpr std::int32_t kBudgetRuns = 3000; + +// One backend run of the budget workload: a 10 000-particle system +// (one emitter with a velocity box — the positions spread over the +// kBudgetTicks update ticks; all alive: min life 20 > 10), declared +// into a capacity-10 000 batcher per frame. `entry == nullptr` +// selects the UNGATED run (the sanitizer and non-reference +// platforms): the workload runs for its leak/correctness value, no +// timing. A non-null `entry` selects the GATED run: warm-up + the +// measured window (the O(live) conversion pass ONLY — the batcher's +// build/sort cost is the `depth_sort_10k` budget's territory) + +// budgetCheck; the result carries the report. +template +laige::BudgetCheckResult runBudgetBackend(const laige::BudgetEntry* entry) { + using Vec2 = typename M_::Vec2; + auto rc = ParticleSystem::create({kBudgetParticles, 1, + kBudgetSeed}); + if (!rc.ok()) { + std::fprintf(stderr, "runBudgetBackend: create failed\n"); + std::abort(); + } + auto sys = std::move(rc).takeValue(); + ParticleEmitterDef d; + d.origin = Vec2{sc(0), sc(0)}; + d.depth = sc(0, 2); + d.velMin = Vec2{sc(1, 4), sc(1, 4)}; + d.velMax = Vec2{sc(1, 2), sc(1, 2)}; + d.lifeMin = 20; + d.lifeMax = 40; + d.sizeMin = sc(1, 4); + d.sizeMax = sc(1, 2); + d.tint[0] = 255; + d.tint[1] = 128; + d.tint[2] = 64; + d.tint[3] = 255; + d.fadeTicks = 8; + d.continuousRate = 0; + const auto e = sys.addEmitter(d); + if (!e.ok()) { + std::fprintf(stderr, "runBudgetBackend: addEmitter failed\n"); + std::abort(); + } + if (!sys.burst(e.value(), kBudgetParticles).ok()) { + std::fprintf(stderr, "runBudgetBackend: burst failed\n"); + std::abort(); + } + for (std::uint32_t t = 0; t < kBudgetTicks; ++t) sys.update(); + // The workload shape (CORE-005): all 10 000 must be alive (min life + // 20 > kBudgetTicks) — a shape failure is a test failure: + if (sys.liveCount() != kBudgetParticles) { + std::fprintf(stderr, "runBudgetBackend: liveCount %u != %u after " + "the %u update ticks\n", + sys.liveCount(), kBudgetParticles, kBudgetTicks); + std::abort(); + } + auto bc = SpriteBatcher::create({kBudgetParticles}); + if (!bc.ok()) { + std::fprintf(stderr, "runBudgetBackend: batcher create failed\n"); + std::abort(); + } + auto batcher = std::move(bc).takeValue(); + const ParticleDeclareOptions opts = baseOptions(); + auto frame = [&]() { + batcher.beginFrame(); + if (!declareParticles(batcher, sys, opts).ok()) { + std::fprintf(stderr, "runBudgetBackend: declare failed\n"); + std::abort(); + } + if (!batcher.build().ok()) { + std::fprintf(stderr, "runBudgetBackend: build failed\n"); + std::abort(); + } + }; + // The ungated run (leak/correctness only, no timing — entry is + // always nullptr when ungated, so the gated code below compiles but + // is unreachable there): + if (entry == nullptr) { + for (std::int32_t i = 0; i < kBudgetWarmup; ++i) frame(); + return {}; + } + // The gated run: warm-up (first-touch costs) then the measured + // window (the conversion pass ONLY — beginFrame/build are the + // frame protocol, outside the measured quantity): + for (std::int32_t i = 0; i < kBudgetWarmup; ++i) frame(); + laige::Histogram hist( + laige::Histogram::Options{static_cast(kBudgetRuns)}); + for (std::int32_t i = 0; i < kBudgetRuns; ++i) { + batcher.beginFrame(); + laige::TimeIt timer; + if (!declareParticles(batcher, sys, opts).ok()) { + std::fprintf(stderr, "runBudgetBackend: declare failed\n"); + std::abort(); + } + hist.record(timer.elapsedMs()); + if (!batcher.build().ok()) { + std::fprintf(stderr, "runBudgetBackend: build failed\n"); + std::abort(); + } + } + // The AGENTS §12 context (stable pointers — the ctx fields are + // const char*; the strings live to the end of the call): + const std::string buildLine = std::string(kCompilerId) + +#if defined(LAIGE_PARTICLE_RENDER_BUILD_TYPE) + ", CMake " LAIGE_PARTICLE_RENDER_BUILD_TYPE +#else + ", CMake build type unknown" +#endif + ", engine policy (NFR-8.10)"; + const std::string machine = MachineLine(); + laige::BudgetReportContext ctx; + ctx.workload = entry->workload.c_str(); + ctx.build = buildLine.c_str(); + ctx.machine = machine.c_str(); + ctx.warmup = static_cast(kBudgetWarmup); + return laige::budgetCheck(*entry, hist, ctx); +} + +TEST(ParticleRenderBudget, TenThousandParticles) { + // The gated run loads the entry (the reference platform); the + // ungated run passes a null entry (the workload still runs): + const laige::BudgetEntry* entry = nullptr; +#if defined(LAIGE_PARTICLE_RENDER_BUDGET) + const std::string path = BudgetsFilePath(); + const laige::Result loaded = + laige::loadBudgets(path); + ASSERT_TRUE(loaded.ok()) << "loadBudgets(\"" << path << "\") failed: " + << laige::errorText(loaded.error()); + entry = loaded.value().find("particle_render_10k"); + ASSERT_NE(entry, nullptr) + << "budgets.json has no particle_render_10k entry"; + ASSERT_EQ(entry->metric, laige::BudgetMetric::Mean); +#endif + // Both backends run the workload (ADR 0002: the gate is backend- + // complete); the budgets.json `measured` records the worse of the + // two when gated: + const laige::BudgetCheckResult fpx16 = + runBudgetBackend(entry); +#if defined(LAIGE_PARTICLE_RENDER_BUDGET) + // Land the stable report in the ctest log (AGENTS §12): + std::fputs(fpx16.report.c_str(), stdout); + std::fflush(stdout); + EXPECT_TRUE(fpx16.passed) << "fpx16_16: " << fpx16.report; +#endif + const laige::BudgetCheckResult fp32 = + runBudgetBackend(entry); +#if defined(LAIGE_PARTICLE_RENDER_BUDGET) + std::fputs(fp32.report.c_str(), stdout); + std::fflush(stdout); + EXPECT_TRUE(fp32.passed) << "fp32_pinned: " << fp32.report; +#else + // The ungated run: a test body without assertions passes — the + // workload's value here is the leak/race/correctness coverage the + // sanitizer runtimes (and the non-reference runners) provide. + (void)fpx16; + (void)fp32; +#endif +} + +} // namespace diff --git a/tests/laige-sim/game_loop_tests.cpp b/tests/laige-sim/game_loop_tests.cpp index 593ec3f..b302c24 100644 --- a/tests/laige-sim/game_loop_tests.cpp +++ b/tests/laige-sim/game_loop_tests.cpp @@ -230,12 +230,11 @@ class MemorySink : public laige::log::Sink { std::vector entries; }; -MemorySink* sink = nullptr; - // Install the capture sink (a 60 s rate window: the tests' repeated // events stay within one window, so the rate-limited repeats are // suppressed and summarized at shutdown — the scheduler_tests -// pattern). +// pattern). The tests read the entries back through the return +// value. MemorySink* installCaptureSink() { auto mem = std::make_unique(); MemorySink* memPtr = mem.get(); @@ -246,7 +245,6 @@ MemorySink* installCaptureSink() { ADD_FAILURE() << "Logger::init failed"; std::abort(); } - sink = memPtr; return memPtr; } @@ -256,7 +254,6 @@ void restoreConsoleSink() { ADD_FAILURE() << "Logger re-init with the default console sink failed"; std::abort(); } - sink = nullptr; } std::size_t countEvents(const MemorySink& s, const char* event) { @@ -368,7 +365,6 @@ TEST(GameLoop, CreateValidatesTheConfig) { EXPECT_EQ(mem->entries[2].event, "rate_limited"); EXPECT_STREQ(fieldValue(mem->entries[2], "event"), "tick_rate_invalid"); EXPECT_STREQ(fieldValue(mem->entries[2], "suppressed"), "1"); - sink = nullptr; } // --------------------------------------------------------------------------- @@ -518,7 +514,6 @@ TEST(GameLoop, OverloadDropsExactlyTheDocumentedAmountAndLogsOnce) { static_cast(st.droppedFrames), static_cast(st.ticks), static_cast(st.droppedTicks)); - sink = nullptr; } // --------------------------------------------------------------------------- diff --git a/tests/laige-sim/presentation_tests.cpp b/tests/laige-sim/presentation_tests.cpp index 6126082..eeee6a7 100644 --- a/tests/laige-sim/presentation_tests.cpp +++ b/tests/laige-sim/presentation_tests.cpp @@ -266,12 +266,11 @@ class MemorySink : public laige::log::Sink { std::vector entries; }; -MemorySink* sink = nullptr; - // Install the capture sink (a 60 s rate window: the tests' repeated // events stay within one window, so the rate-limited repeats are // suppressed and summarized at shutdown — the game_loop_tests -// pattern). +// pattern). The tests read the entries back through the return +// value. MemorySink* installCaptureSink() { auto mem = std::make_unique(); MemorySink* memPtr = mem.get(); @@ -282,7 +281,6 @@ MemorySink* installCaptureSink() { ADD_FAILURE() << "Logger::init failed"; std::abort(); } - sink = memPtr; return memPtr; } @@ -292,7 +290,6 @@ void restoreConsoleSink() { ADD_FAILURE() << "Logger re-init with the default console sink failed"; std::abort(); } - sink = nullptr; } std::size_t countEvents(const MemorySink& s, const char* event) { @@ -396,7 +393,6 @@ TEST(Presentation, CreateValidatesTheTickRate) { EXPECT_EQ(mem->entries[1].event, "rate_limited"); EXPECT_STREQ(fieldValue(mem->entries[1], "event"), "tick_rate_invalid"); EXPECT_STREQ(fieldValue(mem->entries[1], "suppressed"), "1"); - sink = nullptr; } // --------------------------------------------------------------------------- diff --git a/tests/laige-sim/scheduler_tests.cpp b/tests/laige-sim/scheduler_tests.cpp index 0d688a9..c4e33ba 100644 --- a/tests/laige-sim/scheduler_tests.cpp +++ b/tests/laige-sim/scheduler_tests.cpp @@ -149,7 +149,14 @@ void markRun(std::uint32_t token) { } std::uint64_t schReadVelSum = 0; +#if defined(LAIGE_ALLOC_COUNTER) +// The zero-alloc test's second-component read-back: its read site is +// LAIGE_ALLOC_COUNTER-gated (the zero-alloc window only builds in the +// non-instrumented trees), and SchReadPos is registered only by that +// test — without the gate, -Wunused-but-set-global on the +// sanitizer clang trees: std::uint64_t schReadPosSum = 0; +#endif } // namespace @@ -214,6 +221,7 @@ LAIGE_SYSTEM(SchReadPos, 1) void SchReadPos(laige::World& world, laige::SystemContext& ctx) { static_cast(world); markRun(6); +#if defined(LAIGE_ALLOC_COUNTER) schReadPosSum = 0; static_cast(ctx.each( [](laige::Entity e, const SchPos& p) { @@ -221,6 +229,9 @@ void SchReadPos(laige::World& world, laige::SystemContext& ctx) { schReadPosSum += static_cast(static_cast(p.x)); }, laige::Read{})); +#else + static_cast(ctx); +#endif } // The no-op system (an ordering marker that declares no I/O). diff --git a/tests/laige-sim/system_timing_tests.cpp b/tests/laige-sim/system_timing_tests.cpp index 443c550..c2adfda 100644 --- a/tests/laige-sim/system_timing_tests.cpp +++ b/tests/laige-sim/system_timing_tests.cpp @@ -226,12 +226,11 @@ class MemorySink : public laige::log::Sink { std::vector entries; }; -MemorySink* sink = nullptr; - // Install the capture sink (a 60 s rate window: the tests' repeated // events stay within one window, so the rate-limited repeats are // suppressed and summarized at shutdown — the scheduler_tests -// pattern). +// pattern). The tests read the entries back through the return +// value. MemorySink* installCaptureSink() { auto mem = std::make_unique(); MemorySink* memPtr = mem.get(); @@ -242,7 +241,6 @@ MemorySink* installCaptureSink() { ADD_FAILURE() << "Logger::init failed"; std::abort(); } - sink = memPtr; return memPtr; } @@ -252,7 +250,6 @@ void restoreConsoleSink() { ADD_FAILURE() << "Logger re-init with the default console sink failed"; std::abort(); } - sink = nullptr; } std::size_t countEvents(const MemorySink& s, const char* event) { @@ -459,7 +456,6 @@ TEST(SystemTiming, OverBudgetSystemWarnsAtTheDocumentedMultiplier) { } else { EXPECT_TRUE(criticalSummary == nullptr); } - sink = nullptr; } TEST(SystemTiming, CriticallyOverBudgetSystemErrorsAfterTheWarn) { @@ -659,7 +655,6 @@ TEST(SystemTiming, EventsFollowTheErrorGrammar) { EXPECT_STREQ(fieldValue(*overrunSummary, "suppressed"), "1"); ASSERT_TRUE(criticalSummary != nullptr); EXPECT_STREQ(fieldValue(*criticalSummary, "suppressed"), "1"); - sink = nullptr; } // ---------------------------------------------------------------------------