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
84 changes: 84 additions & 0 deletions docs/pt/PT-5-integration-and-restir.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# PT-5 — gameplay integration, fight-scene perf, specular NEE (+ PT-4 ReSTIR)

Status: **landed** (2026-07-14, 760M / DX12+DXC, 4K @ renderScale 0.75).

## Specular NEE (closing the PT-2 gap)

`direct_light` now evaluates a GGX highlight (`nee_spec`: same D/F/V
terms as `sample_brdf`) for BOTH the sun and the sampled point light,
at EVERY path vertex. The shadow ray was already paid for by the
diffuse term, so this is pure math cost. No double counting: analytic
lights cannot be hit by BSDF rays, and sky misses exclude the sun disc.
The old primary-only sun specular block is deleted — one code path.

## Progressive + combat: skip, don't burn

`tlas_version` bumps every frame during combat (enemy transforms).
Progressive resets on it (correct — its accumulation assumes a static
scene) but used to still DISPATCH: full-res trace cost, raster on
screen. The reset now also skips the dispatch, same as the camera-moved
case. Measured: progressive during a fight now costs raster + ~0
(15.1 ms vs 14.9 ms GPU).

## Fight-scene perf protocol (perf-round-3 rule: measure a FIGHT)

Shooter PERFTEST mode-1 harness (auto-start, immortal player, scripted
kills marching the waves), 60-frame windows, profiler-free windows
reported. 760M, 4K output, renderScale 0.75, wave 0-1 combat:

| Mode | Light combat (≤3 alive) | Heavy brawl (4-6 alive) | GPU avg |
|---|---|---|---|
| Raster (PT off) | ~45 fps | 32-36 fps | 14.9 ms |
| RT (realtime PT) | ~27.5 fps | 20-23 fps | 33.3 ms |
| PROG (progressive) | ~45 fps (raster shown) | 32-36 fps | 15.1 ms |

RT's fight cost is ~18 ms GPU on this iGPU — a deliberate quality
trade the settings row lets the player make. PROG is free during
combat by construction (see above) and converges when things go quiet.

## Settings / fallback matrix (verified on hardware)

- Shooter video menu row **PATH TRACING: OFF / QUALITY (STILLS) /
REALTIME** — persisted in settings.json (`video.pathTracing`),
applied at boot, live-applied on change, cycled by F9 and the `--pt`
CLI flag; all four surfaces share one settings value.
- `ray_query=false` → the row renders `N/A (NO RAY TRACING)` and the
value is inert (`isPathTracingSupported` gate); boot line says why.
- Mobile profile never applies PT (no headroom, no settings screen).
- `TEXTURE_BINDING_ARRAY` absent → card-albedo hit shading fallback
(unchanged since PT-2).
- Reference-diff CI hook: **deferred, documented**. The game scene
cannot reach number-parity with `bloom-reference` while skinned
enemies are absent from the TLAS (accepted PT-2 gap); the reference
oracle remains the tool for engine test scenes. Revisit when a CI
runner exists for GPU work.

## PT-4 — ReSTIR DI (experimental, BLOOM_PT_RESTIR=1)

Landed per the roadmap's own framing: the architecture for the day
emissive particles/muzzle flashes become lights — NOT a win today
(this game has ≤16 analytic lights; arena_02 has 5).

- RIS over 8 uniform candidates, target = luminance of the unshadowed
diffuse+specular contribution at the shading point.
- Temporal reservoir reuse through the SVGF reprojection (shared
rp_* basis), M-capped at 20×, target re-evaluated at the CURRENT
shading point — no geometric bias; visibility is never folded into
the reservoir, so no visibility-reuse bias either. Spatial reuse
deferred with the many-light content that would justify its bias
handling.
- One shadow ray for the winner; bounce vertices keep plain NEE
(reservoirs are per-primary-texel).
- Reservoir ping-pong buffers (bindings 20/21) raised the kernel to 9
storage buffers — `max_storage_buffers_per_shader_stage` (default 8)
is now requested from the adapter at device creation on both
platforms.
- Validated at parity against plain NEE: A/B stills differ by 40/765
mean — inside this live scene's ~38-50 run-to-run noise floor — with
no energy shift and no cost delta (26 vs 27 fps). Realtime mode
only; progressive stays pure NEE (it is the in-engine oracle).

## Tickets closed

PT-4 (experimental flag, as scoped), PT-5. The three-tier roadmap
(docs/pt/pt-roadmap.md) is complete.
6 changes: 6 additions & 0 deletions native/shared/src/attach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ pub unsafe fn attach_engine(
required_limits.max_binding_array_elements_per_shader_stage =
adapter_limits.max_binding_array_elements_per_shader_stage;
}
// PT-4: the path-trace kernel binds 9 storage buffers (accum +
// moments + reservoir ping-pongs on top of instance/geo data);
// the wgpu default limit is 8.
required_limits.max_storage_buffers_per_shader_stage = required_limits
.max_storage_buffers_per_shader_stage
.max(adapter_limits.max_storage_buffers_per_shader_stage.min(16));

if required_features.intersects(rt_mask) {
required_limits =
Expand Down
28 changes: 28 additions & 0 deletions native/shared/src/renderer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1296,6 +1296,10 @@ pub struct Renderer {
pt_last_tlas_version: u64,
/// BLOOM_PT_DEBUG view selector, forwarded to the kernel via cfg.w.
pt_debug: f32,
/// BLOOM_PT_RESTIR=1 — PT-4 experimental ReSTIR DI (kernel ext.w).
pt_restir: bool,
/// PT-4 — ReSTIR reservoir ping-pong, sized with the accum pair.
pt_resv_buffers: [Option<wgpu::Buffer>; 2],
/// PT-2 — concatenated Vertex3D words (f32) / indices (u32) for
/// interpolated hit shading. Grow-only, rebuilt alongside the
/// instance-data buffer.
Expand Down Expand Up @@ -5220,6 +5224,21 @@ impl Renderer {
has_dynamic_offset: false, min_binding_size: None,
}, count: None,
},
// PT-4 — ReSTIR reservoir ping-pong (20/21).
wgpu::BindGroupLayoutEntry {
binding: 20, visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false, min_binding_size: None,
}, count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 21, visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false, min_binding_size: None,
}, count: None,
},
];
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("pt_layout"),
Expand Down Expand Up @@ -5387,6 +5406,12 @@ impl Renderer {
.ok()
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(0.0);
// PT-4 — ReSTIR DI stays behind an env flag until the many-light
// content that justifies it exists (roadmap: with ≤16 analytic
// lights plain NEE is nearly as good).
let pt_restir = std::env::var("BLOOM_PT_RESTIR")
.map(|v| v == "1")
.unwrap_or(false);

// --- Ticket 014 V3: SW SDF sphere-trace pipeline ---
// Always built. At dispatch time we pick SDF over Hi-Z when
Expand Down Expand Up @@ -6995,6 +7020,8 @@ impl Renderer {
pt_prev_vp: [[0.0; 4]; 4],
pt_last_tlas_version: 0,
pt_debug,
pt_restir,
pt_resv_buffers: [None, None],
pt_geo_vertex_buffer: None,
pt_geo_index_buffer: None,
pt_texture_arrays_enabled,
Expand Down Expand Up @@ -7512,6 +7539,7 @@ impl Renderer {
self.pt_bg = [None, None];
self.pt_accum_buffers = [None, None];
self.pt_moments_buffers = [None, None];
self.pt_resv_buffers = [None, None];
self.pt_accum_count = 0;
self.pt_atrous_scratch = None;
self.pt_atrous_scratch2 = None;
Expand Down
32 changes: 25 additions & 7 deletions native/shared/src/renderer/pt_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,21 @@ impl Renderer {
// debug-20 history-length view; the frozen-seed era masked it).
// Mode 2's per-tap depth validation already rejects exactly the
// texels whose surface actually changed.
let mut tlas_reset = false;
if self.tlas_built_version != self.pt_last_tlas_version {
self.pt_last_tlas_version = self.tlas_built_version;
if self.pt_mode == 1 {
self.pt_accum_count = 0;
tlas_reset = true;
}
}
// Progressive mode + camera in motion: the raster frame stays on
// screen (kernel write threshold) and any sample traced now is
// discarded by next frame's reset — skip the dispatch entirely.
// Moving costs nothing; standing still starts the accumulation.
if self.pt_mode == 1 && moved {
// Progressive mode + camera in motion OR scene churn: the raster
// frame stays on screen (kernel write threshold) and any sample
// traced now is discarded by next frame's reset — skip the
// dispatch entirely. During combat the TLAS bumps every frame
// (enemy transforms), so without the tlas_reset arm progressive
// paid the full-res trace cost while displaying raster.
if self.pt_mode == 1 && (moved || tlas_reset) {
self.pt_wrote_frame = false;
return;
}
Expand All @@ -115,7 +119,7 @@ impl Renderer {
// rejects almost every frame — texels never accumulate past
// 1 spp and read as white speckle. A consistent owner pixel
// keeps history valid; the upsample covers the other three.
let phase = [0u32, 0u32];
let _phase = [0u32, 0u32];

// ---- accumulation buffers (vec4<f32> per pixel, ping-pong) ----
// Sized to the TRACE grid; a mode switch changes the size and
Expand Down Expand Up @@ -150,6 +154,16 @@ impl Renderer {
mapped_at_creation: false,
}));
}
// PT-4 — ReSTIR reservoirs (light idx, W, M, target pdf).
// Zero-init M = 0 marks every reservoir empty.
for (i, slot) in self.pt_resv_buffers.iter_mut().enumerate() {
*slot = Some(self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(if i == 0 { "pt_resv_a" } else { "pt_resv_b" }),
size: needed,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
}
// COPY_SRC: the first à-trous iteration's output is copied
// back over the accum buffer as next frame's colour history
// (SVGF feeds back the once-filtered signal).
Expand Down Expand Up @@ -252,7 +266,8 @@ impl Renderer {
surf_w,
surf_h,
if self.pt_mode >= 2 && self.shadow_map.enabled { 1 } else { 0 },
phase[1],
// PT-4 experimental flag (BLOOM_PT_RESTIR=1), realtime only.
if self.pt_restir && self.pt_mode >= 2 { 1 } else { 0 },
],
// RAW upload, unlike inv_vp: the shadow VPs are consumed as
// M*v by every existing WGSL user (scene shader, WSRC
Expand Down Expand Up @@ -297,6 +312,9 @@ impl Renderer {
// side), write out (paired with the write side).
wgpu::BindGroupEntry { binding: 18, resource: self.pt_moments_buffers[i].as_ref().unwrap().as_entire_binding() },
wgpu::BindGroupEntry { binding: 19, resource: self.pt_moments_buffers[1 - i].as_ref().unwrap().as_entire_binding() },
// PT-4 ReSTIR reservoirs, same ping-pong pairing.
wgpu::BindGroupEntry { binding: 20, resource: self.pt_resv_buffers[i].as_ref().unwrap().as_entire_binding() },
wgpu::BindGroupEntry { binding: 21, resource: self.pt_resv_buffers[1 - i].as_ref().unwrap().as_entire_binding() },
];
self.pt_bg[i] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("pt_bg"),
Expand Down
Loading
Loading