Skip to content

[M2-PART-02] Particle rendering through the batcher - #83

Merged
offdev merged 1 commit into
masterfrom
feat/m2-part-02-particle-render
Oct 8, 2026
Merged

offdev merged 1 commit into
masterfrom
feat/m2-part-02-particle-render

Conversation

@offdev

@offdev offdev commented Oct 8, 2026

Copy link
Copy Markdown
Owner

What

M2-PART-02 — the render half of FR-2.7: particles render as batched
sprites through the existing sprite batcher (M2-SPRITE-01); depth
from the particle depth value.

  • New laige::render::declareParticles<Backend> (header-only,
    src/laige-render/include/laige/render/particle_render.h): the
    O(live) particle → sprite conversion pass — zero allocation
    (PERF-003), no GL. One emitter set (one ParticleSystem) = one
    (atlasId, materialId, blend) group = one instanced draw call
    (RENDER-001) — 10 000 particles of one set cost one draw call.
  • Depth: the particle's depth value is first quantized to an
    INTEGER step height (particleDepthToStepHeight: clamp to ±2047
    FIRST, then round nearest ties-to-even — fpx
    fpx16_16::toInt32, fp32 std::nearbyint; the backends agree on
    dyadic depths) and then packed by isoDepthKey (M2-ISO-01) at the
    options' layer (default ground); depthOverride false (G-R11
    engine-owned).
  • Tint: u8 → float /255 RGB + the M2-PART-01 exact-u32 fade
    alpha /255.
  • Build: laige-render links laige-sim PUBLIC — the module
    stack's legal downward direction (PRD §10.1); the frame-pipeline
    handoff stays opaque (the M2-GL-02 note is about that edge).
  • Budget: NEW budgets.json entry particle_render_10k
    (step-level, mean ≤ 2.0 ms — the depth_sort_10k precedent, no
    PRD revision): the declare pass ONLY (beginFrame/build outside
    the measured window — the sort is depth_sort_10k's). Recorded
    1.07798 ms (worse backend, canonical Debug tree) — 1.86×
    inside the gate; the clang -O0 cross-run 1.18578 ms (1.69×).
  • 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 (game_loop /
    presentation / system_timing tests) and LAIGE_ALLOC_COUNTER-gated
    the schReadPosSum read-back in the scheduler test (its only read
    site compiles out on the sanitizer clang trees).

Docs: NEW docs/api/particle_render.md; the tenth baseline
docs/benchmarks/baselines/m2-particle-render.md (+ index entry);
docs/concepts/coordinates.md §5 conversion row Planned → Shipped;
docs/api/particles.md (render half implemented); docs/README.md

  • src/laige-render/README.md status; roadmap checkbox + board
    (M2 21/33, total 67/194) + change-log row. laige-api.json
    regenerated LAST (1410 → 1419 symbols, 41 → 42 headers).

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 (prior counts +1
    each — the new particle_render entry).
  • ctest -R particle_render green (9 suites, both SimMath
    backends, no GL): the per-particle declaration goldens (the
    M2-ISO-01 oracle + hand-computed dyadic key goldens), the depth
    conversion (sub-unit ties-to-even, ±2047 saturation), the exact
    fade through the declaration, the in-group (key, live-order)
    ordering, the multi-set group count (3 sets → 3 groups — one draw
    call per emitter set), the failure paths, the fixed-seed
    determinism (machine-greppable declared-sequence hash), the
    1 000-frame 10k-particle zero-allocation declare loop, and the
    budget gate.
  • api-real-tree / api-check-fresh, laige-include-lint (71
    source files), laige-determinism-lint (29 sim source files, 0
    violations) — all green.

Untested locally: GL/GPU execution (no usable GPU on this machine —
the suites are GL-free by design; the P0 CI runners carry the GL
environment).

@offdev offdev added the ci:macos label Oct 8, 2026
NEW laige::render::declareParticles<Backend> (header-only public
header src/laige-render/include/laige/render/particle_render.h,
templated over the SimMath backends): the FR-2.7 render half — every
LIVE particle of a ParticleSystem<Backend> (M2-PART-01, read-only)
becomes one SpriteItem declaration in the sprite batcher's frame
window (M2-SPRITE-01): the O(live) particle-to-sprite 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<Backend> (clamp to ±2047 FIRST — fpx raw
integer compares, fp32 IEEE compares, NaN falls through to +2047 —
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 — NOT llround, which would
diverge on 0.5) — then packed by isoDepthKey (M2-ISO-01) at the
options' layer (default ground); depthOverride false (engine-owned).
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 from ParticleDeclareOptions (default
BlendMode::Additive — the particle convention); frameIndex/rotation
0 (no per-particle animation or rotation in this step).

Batching (RENDER-001): one emitter set = one system = one
(atlasId, materialId, blend) group = one instanced draw call —
10 000 particles of one set cost one draw call. Declaration order =
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).

Build: laige-render links laige-sim PUBLIC — the module stack's
legal downward direction (PRD §10.1: render MAY include sim public
headers; the include-lint R2 edge). The M2-GL-02 "no render→sim
edge" note covers the frame-pipeline handoff, which stays an opaque
pointer.

Budget — NEW budgets.json entry particle_render_10k (step-level —
the depth_sort_10k precedent; no PRD revision): the 10k-particle
conversion pass ONLY (beginFrame/build outside the measured window —
the batcher's sort is the separate depth_sort_10k budget), 100
warm-up + 3000 measured declare passes, both backends (the gate is
backend-complete, ADR 0002). Target 2.0 ms (mean); recorded 1.07798
ms (the worse of the two backends, canonical Debug tree) — 1.86×
inside the gate; the clang -O0 cross-run clears it at 1.18578 ms
(1.69×). 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).

Tests — NEW ctest entry particle_render (9 suites, both backends,
no GL): the per-particle declaration goldens against the M2-ISO-01
oracle + the hand-computed dyadic key goldens; the depth conversion
(sub-unit ties-to-even, ±2047 saturation); the exact fade through
the declaration; the in-group (key, live-order) ordering with the
equal-key tie; the multi-set group count (3 sets → 3 groups — one
draw call per emitter set); the failure paths; the fixed-seed
determinism (the machine-greppable declared-sequence hash line; a
different seed diverges); the 1 000-frame 10k-particle
zero-allocation declare loop (the alloc-watch gate); the budget gate
(including a workload-shape assert: all 10 000 alive).

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). NEW
baseline docs/benchmarks/baselines/m2-particle-render.md (the tenth,
verbatim reports + the cross-compiler clang -O0 run) + the baselines
index entry; budgets.json entry count 16 → 17 (the
BudgetHarnessTable.LoadsTheRepoBudgetsFile pin updated).

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 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
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); api-real-tree / api-check-fresh green after the
regeneration. include-lint (71 source files) and determinism-lint
(29 sim source files, 0 violations) green.

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); ctest -R
particle_render green (9/9, both backends). GL/GPU paths untested
locally (no usable GPU — the suites are GL-free by design; the P0
CI runners carry the GL environment).
@offdev
offdev force-pushed the feat/m2-part-02-particle-render branch from a3f4227 to 45351b7 Compare October 8, 2026 12:32
@offdev
offdev merged commit bdb323b into master Oct 8, 2026
21 of 22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant