Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ add_executable(test_fire_engine
tests/render/test_descriptors.cpp
tests/render/test_device_plan.cpp
tests/render/test_pipeline_config.cpp
tests/render/test_gpu_profiler.cpp
tests/render/test_shadow_raster_policy.cpp
tests/render/test_vdpm_gpu.cpp
tests/render/test_vdpm_gpu_front.cpp
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ I've no doubt these are all solved problems nowadays with the Unreal engine et a
- **Temporal anti-aliasing (TAA)** — sub-pixel Halton(2,3) projection jitter plus velocity-buffer history accumulation anti-aliases geometry edges *and* specular/shading shimmer (unlike MSAA, which only covers geometry edges). The forward + transmission passes write a screen-space motion-vector attachment; the resolve reprojects the previous frame's history along it (`historyUV = uv − velocity`), neighbourhood-clamps to the current 3×3 to suppress ghosting/disocclusion, and blends. Motion vectors are jitter-free so the jitter cancels in accumulation. Per-node previous-world-matrix tracking feeds rigid + animated motion (skinned deformation is camera-motion-only in v1); particles render after the resolve, kept out of history. `--no-taa` reverts to the raw image, `--debug-velocity` visualises the buffer
- **Frustum culling (camera + shadow casters)** — built on a reusable fat-AABB BVH (`AabbBvh<T>`, the same core the physics broadphase and static-mesh triangle index use). Two stages: a **persistent scene BVH** (`SceneCuller`, an `AabbBvh<Node*>` over rigid renderables) pre-culls each frame against the union of the camera frustum and every shadow caster's frustum, so off-screen nodes skip draw-building entirely (no UBO writes, no per-vertex bounds) — `O(log N + visible)` instead of `O(N)`; then a **precise per-pass cull** drops the survivors that fall outside a given pass's frustum (`buildDrawBuckets` for the camera, per-cascade/spot/point-face in the shadow pass). Frustums are 6 Gribb–Hartmann planes (Vulkan `[0,1]` depth) with a conservative positive-vertex AABB test (no false negatives). Deformable (skinned/morph) meshes skip the coarse BVH (bind-pose bounds under-cover the animated pose) and rely on the precise stage's exact per-frame world bounds. The coarse union is a strict superset of what any pass keeps, so culling never drops a visible draw. Live overlay toggle + tracked/visible/culled counts; off submits everything (A/B + regression escape hatch)
- **Progressive mesh level-of-detail (discrete → VIPM → VDPM)** — a from-scratch **attribute-aware Garland–Heckbert quadric-error simplifier** (`graphics/mesh_simplifier`) records an ordered edge-collapse stream per static mesh at load time; all levels index the *same* vertex buffer (only index data is added). The error quadric lives in **R⁵ (position + weighted UV)**, so collapses that would stretch the texture parameterisation are ordered *last*; **position welding** restores connectivity across glTF's seam-split vertices, a **wedge-preserving emit** keeps each corner's own UV (nearest-wedge), and a **chart veto** stops a collapse from crossing a UV/normal seam. That one collapse stream drives three selectable modes (overlay: Discrete / Continuous / View-dependent): **Discrete** picks the coarsest whole-mesh level whose screen-space error fits a pixel budget; **Continuous (VIPM)** geomorphs the collapsing vertices' full render attributes into the exact next level to dissolve the pop; **View-dependent (VDPM)** promotes the stream to a per-instance **vertex forest + active front** that refines *different regions of one mesh to different detail* each frame — from four screen-space channels (geometry, UV-seam, shading-normal, tangent) plus silhouette boost and a conservative back-face gate, with a joint refinement-only foldover/coverage repair — so it matches the discrete mesh's silhouette and shading at a fraction of the triangles. Overlay toggle + mode selector + pixel error budget slider + triangles-drawn readout + a per-LOD debug tint; `--lod-mode discrete|continuous|view-dependent` selects the starting mode at launch. VDPM draws are issued via **`drawIndexedIndirect`** from a per-instance indirect-command buffer (the count on the GPU) — the plumbing the GPU-driven front consumes; every other draw stays direct. A **GPU-driven front** backend (the **"GPU-driven front" overlay checkbox** in the Mesh LOD panel — a reload-free runtime toggle, needs `--lod-mode view-dependent` and a compute-capable device; shown "unsupported" otherwise) runs the whole per-frame front lifecycle — score, refine/coarsen, foldover/coverage repair, and seam-preserving emit — on the GPU in compute, and the draw consumes the GPU-emitted index/indirect buffers directly (the per-instance CPU front work is skipped); the CPU front is retained as an automatic per-mesh fallback and the same-camera A/B reference. It is **on by default** wherever the device supports it; force it with `--vdpm-gpu` or disable it with `--no-vdpm-gpu` (repeated flags are last-one-wins). See [`docs/lod.md`](docs/lod.md)
- **Debug + profiling overlay (Dear ImGui)** — a runtime overlay (Dear ImGui 1.92 on the Vulkan dynamic-rendering backend, drawn into the swapchain after post-process) toggled with **F1** (`--overlay` to start visible). Shows a **CPU frame-time/FPS plot** and **per-pass GPU timings** via a timestamp `VkQueryPool` (`GpuProfiler`, with graceful fallback when the device/queue doesn't support timestamps), plus a **live tunables panel** (`RenderTunables`) for TAA (history blend, sharpen, on/off), frustum culling (on/off + tracked/visible/culled counts), mesh LOD (on/off + mode + pixel error budget + triangles-drawn + a reload-free GPU-driven-front backend toggle), a **Shadows (SH-01)** panel (per shadow-view-family and per physical slot: raster passes, drawn/candidate draws + triangles, per-view LOD histograms, and the family's GPU time), the debug-view dropdown (incl. a **LOD tint** (`--debug-lod`), a **Shadow LOD tint** (`--debug-shadow-lod`, with `--no-shadow-lod` as the full-detail control and `--shadow-budget` / `--shadow-ratio` to sweep the calibration — see `tools/shadow_lod_sweep.sh`, with `--shadow-focus <group>:<slot>` to pick the view it follows — e.g. `cascade:3`, `point:0:4` — resolved once at startup to that slot's logical view and then followed across slot compaction) that colours each mesh by the level ONE shadow view picked for it — the view focused in the Shadows panel, or cascade 0 by default, since after SH-03 a caster holds a different level per view — with neutral grey for meshes that view has no level for, and a **Joints** view — `--debug-joints` — that replaces the scene mesh with a per-link RGB axis gizmo + "index: bone-name" labels to identify ragdoll joints for hinge authoring) + no-shadows, bloom/diffuse-IBL/specular-IBL/sun-intensity, and particle emitter rate/lifetime/size — all editable without a recompile. Camera input is suppressed while a widget is being driven
- **Debug + profiling overlay (Dear ImGui)** — a runtime overlay (Dear ImGui 1.92 on the Vulkan dynamic-rendering backend, drawn into the swapchain after post-process) toggled with **F1** (`--overlay` to start visible). Shows a **CPU frame-time/FPS plot** and **per-pass GPU timings** via a timestamp `VkQueryPool` (`GpuProfiler`), which distinguishes a device that cannot time (zero `timestampPeriod` / `timestampValidBits`, reported once at startup with both numbers) from a ring slot that has nothing to report yet — the panel says which. Every pass stamps both boundaries at bottom-of-pipe, so the values are consecutive deltas on one timeline, and deltas are modular in the queue's `timestampValidBits` (Vulkan defines timestamp overflow as wrapping inside that width). The figure below the rows is labelled **measured pass sum**, not a total: it adds the instrumented passes only, plus a **live tunables panel** (`RenderTunables`) for TAA (history blend, sharpen, on/off), frustum culling (on/off + tracked/visible/culled counts), mesh LOD (on/off + mode + pixel error budget + triangles-drawn + a reload-free GPU-driven-front backend toggle), a **Shadows (SH-01)** panel (per shadow-view-family and per physical slot: raster passes, drawn/candidate draws + triangles, per-view LOD histograms, and the family's GPU time), the debug-view dropdown (incl. a **LOD tint** (`--debug-lod`), a **Shadow LOD tint** (`--debug-shadow-lod`, with `--no-shadow-lod` as the full-detail control and `--shadow-budget` / `--shadow-ratio` to sweep the calibration — see `tools/shadow_lod_sweep.sh`, with `--shadow-focus <group>:<slot>` to pick the view it follows — e.g. `cascade:3`, `point:0:4` — resolved once at startup to that slot's logical view and then followed across slot compaction) that colours each mesh by the level ONE shadow view picked for it — the view focused in the Shadows panel, or cascade 0 by default, since after SH-03 a caster holds a different level per view — with neutral grey for meshes that view has no level for, and a **Joints** view — `--debug-joints` — that replaces the scene mesh with a per-link RGB axis gizmo + "index: bone-name" labels to identify ragdoll joints for hinge authoring) + no-shadows, bloom/diffuse-IBL/specular-IBL/sun-intensity, and particle emitter rate/lifetime/size — all editable without a recompile. Camera input is suppressed while a widget is being driven
- **Reproducible frame capture** — `--capture <path.png>` writes the numbered frame (`--capture-frame N`, counted in frames rather than seconds, so any machine captures the same render ordinal — identical *content* additionally needs a static scene, since animation and physics still advance on wall-clock `dt`) and exits (non-zero if the file could not be written), copying the **final swapchain image** — post-process and overlay included — straight before present. Swapchain `TRANSFER_SRC` is requested only when capture is asked for, after checking the surface supports it; 8-bit BGRA/RGBA formats are supported and anything else is rejected rather than guessed. With `--no-lod` (full detail, forward *and* shadow selection) it makes an A/B pair, which is how the shadow-LOD reference images in [`docs/acceptance-testing.md`](docs/acceptance-testing.md) are regenerated
- **Runtime logging** — diagnostics route through `core/log.hpp` with `debug`/`info`/`warn`/`error`/`off` levels and categories (`app`, `general`, `gltf`, `physics`, `ragdoll`, `render`). `FE_LOG` controls the global threshold and per-category overrides, e.g. `FE_LOG=ragdoll:debug` for ragdoll settle diagnostics or `FE_LOG=render:debug` for Vulkan extension dumps
- **Keyframe animation** with per-channel interpolation (LINEAR with SLERP for quaternions, STEP, CUBICSPLINE with in/out tangents) across rotation, translation, scale, and morph weight channels; looping playback; runtime animation selection via `AnimationState`
Expand Down
8 changes: 6 additions & 2 deletions docs/lod.md
Original file line number Diff line number Diff line change
Expand Up @@ -914,8 +914,12 @@ at a fraction of the triangles. The remaining residuals and follow-ons, in rough
(full / apply-only / zero-split), shuffled order, N=1, a job-array growth boundary, and two frame slots.
**Measured (state-fair GPU-timestamp benchmark, 13 fronts, `[.][gpu][BatchBench]`): apply serial 3.07 →
batched 0.25 ms (~12×), repair serial 4.99 → batched 0.41 ms (~12×)** — the serialised multi-front cost
collapses toward the concurrent floor. (The benchmark is authoritative because the in-app `VdpmCompute`
GPU timestamp reads `gpuValid=false` on heavier multi-front frames.) 0-VUID on TransmissionTest +
collapses toward the concurrent floor. (The benchmark was authoritative because the in-app `VdpmCompute`
GPU timestamp read `gpuValid=false` on heavier multi-front frames — **now explained and fixed**: the
readback treated `VK_NOT_READY` as failure, which a frame containing any pass that did not run
always produces, so EVERY in-app per-pass timing was suppressed rather than these frames
specifically. See `render/gpu_profiler.cpp`. The benchmark numbers above stand; the in-app timing is
now usable as a cross-check.) 0-VUID on TransmissionTest +
DamagedHelmet + DamagedHelmetBlend (`--vdpm-gpu`; AlphaBlendModeTest sits below the VDPM eligibility
threshold, so it does not exercise this path).
- **Apply+repair FUSION — SKIPPED (complexity/value judgment).** A fused kernel (apply then repair per
Expand Down
22 changes: 20 additions & 2 deletions docs/onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,11 @@ The render layer is where most difficult bugs live.
(dynamic rendering, after post-process) and forwards `WantCaptureMouse/Keyboard` so the main loop
can gate camera input. Non-movable (owns ImGui global state).
- `GpuProfiler`: timestamp `VkQueryPool` ring. Each pass writes a begin/end pair; results are read
back a frame-cycle later into a `FrameStats`. Disabled gracefully when timestamps are unsupported.
back a frame-cycle later into a `FrameStats`. Disabled gracefully when timestamps are unsupported —
and `FrameStats::gpuTiming` says WHICH of unsupported / warming-up / valid applies, because
collapsing those three into one "unavailable" message is how a readback bug (see § Sharp Edges)
survived as a supposed driver limitation. The pure half — availability policy and tick arithmetic —
is the free `resolveTimestampWords`, unit-tested without a GPU.
- `RenderTunables` (`render_tunables.hpp`): the live, overlay-editable render parameters (TAA,
debug view, bloom/IBL/sun, particle scales, cloth solver substeps/compliance/damping/gravity/wind).
Seeded from `constants.hpp` + the CLI debug flags; the `Renderer` reads it every frame instead of
Expand All @@ -581,7 +585,11 @@ Per-frame render order:
12. draw the ImGui debug overlay over the swap image, then transition it to present
13. submit and present

Each GPU pass is bracketed by `GpuProfiler` timestamp writes; the results feed the overlay one frame-cycle later.
Each GPU pass is bracketed by `GpuProfiler` timestamp writes — both boundaries at bottom-of-pipe, so
the per-pass values are consecutive deltas along one timeline rather than a mix of conventions — and
the results feed the overlay one frame-cycle later. The panel's figure is labelled **"Measured pass
sum"**, not "Total": it adds the instrumented passes only, and a frame also contains setup, present
and anything nobody bracketed, so it is not GPU frame latency.

Interesting detail: shadow rendering replays compatible draw commands through different
pipelines. Skinning and morph targets still run in the shadow vertex shader so animated
Expand Down Expand Up @@ -1177,6 +1185,16 @@ the same change — most have a test or guard that will catch you, but not all.
## Sharp Edges

- `graphics/` must remain Vulkan-free, and `render/`/`scene/` are siblings that meet only through `graphics/` — enforced by the `layering_guards` CTest case (`cmake/check_layering.cmake`): `graphics/` and `scene/` *headers* must not include `render/`, and `render/` headers must not include `scene/` (CR-09). The scene reaches the renderer through the Vulkan-free `graphics/renderable_scene.hpp` `RenderableScene` interface, not a concrete type. Shared GPU data-layout limits live in `graphics/gpu_limits.hpp` so graphics headers can size arrays without reaching into `render/`. Graphics `.cpp` files may still include `render/resources.hpp` to allocate GPU resources.
- **`VK_NOT_READY` from `vkGetQueryPoolResults` is not an error — it is the expected result of a
polling read.** Without `VK_QUERY_RESULT_WAIT_BIT` the call returns it whenever ANY query in the
requested range is unavailable, and with `VK_QUERY_RESULT_WITH_AVAILABILITY_BIT` the per-query
availability words are written anyway, which is the whole point of asking for them. `GpuProfiler`
treated it as failure and returned early, so per-pass GPU timings were dead on every device and
every driver — a frame always has passes that did not run (no transmissive draw, no spot light, no
VDPM front), and their queries stay reset and unavailable for that slot. The symptom ("GPU
timestamps unavailable" under both MoltenVK and KosmicKrisp) read as a platform limitation and was
parked as one for months. If you add a query-pool read, decide explicitly whether you are polling
(accept NotReady, filter on availability) or waiting (`WAIT` bit, and accept the stall).
- Vulkan-Hpp is built with `VULKAN_HPP_NO_CONSTRUCTORS`; use designated initializers.
- Vulkan structs often contain pointers. Keep pointed-to arrays and descriptor infos alive
until the Vulkan call using them has returned.
Expand Down
Loading
Loading