Skip to content

Commit fc7521a

Browse files
committed
Coalesce steady-state lighting uploads
1 parent 0bc6fab commit fc7521a

8 files changed

Lines changed: 255 additions & 87 deletions

File tree

docs/tickets.md

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1919,21 +1919,25 @@ adjacent but does not remove the duplication.
19191919
## EN-056 — Per-frame upload/allocation tail in the renderer 🟡 *(2026-07-16 audit)*
19201920

19211921
Individually small, collectively the class of waste the frame no longer has
1922-
budget for at 4K. All VERIFIED still present:
1923-
1924-
- Lighting UBO (~8.7 KB, 256 point-light slots) uploaded whole **8-9x/frame**
1925-
with no dirty flag (`mod.rs:10245,11526,11919-11955`; `shadow_pass.rs:73`
1926-
is explicitly unconditional).
1927-
- Bloom chain creates **9 uniform buffers + 9 bind groups per frame**
1928-
(`postfx_chain.rs:66-141` — the per-pass UBOs are argued for in comments;
1929-
the bind-group re-creation is not).
1930-
- The render graph is rebuilt from scratch **twice per frame** — 17 boxed
1931-
closures, fresh HashMap + HashSets, O(n^3) topo sort (`graph.rs:103-224`,
1932-
`mod.rs:10650,10824,11067`); composite/post bind groups rebuilt per frame
1933-
(`mod.rs:10862,10946`).
1934-
1935-
Fix: dirty-flag the lighting UBO, cache bloom + composite bind groups keyed
1936-
on the views they wrap, build the graph once and rebuild on topology change.
1922+
budget for at 4K.
1923+
1924+
- **Lighting UBO fixed in #139:** setters now update one CPU snapshot and the
1925+
renderer submits at most three aligned dirty ranges after the frame has
1926+
finalized camera and cascade data. Unchanged ranges submit nothing; exact
1927+
per-frame write/byte counts live in
1928+
`renderer_paths.steady_state_uploads.lighting`. The shader layout and bind
1929+
group are unchanged, and the many-light golden hard-gates a steady frame to
1930+
at most 512 bytes instead of the former 8-9 full ~8.7 KiB uploads.
1931+
- **Bloom pass resources fixed:** per-mip buffers and bind groups are rebuilt
1932+
only with the mip chain (initialization/resize); steady frames update only
1933+
the first threshold uniform when exposure policy changes.
1934+
- **Graph planning fixed in #129:** immutable topology is compiled and cached;
1935+
per-frame execution only binds closures to the cached pass slots. Composite
1936+
and post-pass bind-group creation remain part of this ticket's audit.
1937+
1938+
Remaining: cache bloom/composite bind groups only with complete
1939+
resource-generation keys, then instrument and eliminate the other
1940+
steady-state upload/allocation sites under #139.
19371941

19381942
## EN-057 — Hi-Z occlusion runs every frame for zero consumers ✅ *(shipped same day)*
19391943

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
//! Dirty-range planning for the renderer's large lighting uniform buffer.
2+
//!
3+
//! Lighting setters mutate the CPU snapshot throughout a frame. Uploading the
4+
//! complete ~9 KiB block after every setter made one ordinary frame enqueue the
5+
//! same data many times. This tracker compares the final snapshot against the
6+
//! last submitted bytes and emits at most one aligned range for each logical
7+
//! region: fixed/directional fields, point lights, and view/shadow/frame data.
8+
9+
use std::ops::Range;
10+
11+
use super::types::LightingUniforms;
12+
13+
const POINT_LIGHTS_OFFSET: usize = std::mem::offset_of!(LightingUniforms, point_lights);
14+
const VIEW_DATA_OFFSET: usize = std::mem::offset_of!(LightingUniforms, camera_pos);
15+
const LIGHTING_BYTES: usize = std::mem::size_of::<LightingUniforms>();
16+
const REGIONS: [Range<usize>; 3] = [
17+
0..POINT_LIGHTS_OFFSET,
18+
POINT_LIGHTS_OFFSET..VIEW_DATA_OFFSET,
19+
VIEW_DATA_OFFSET..LIGHTING_BYTES,
20+
];
21+
22+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
23+
pub(super) struct LightingUploadStats {
24+
pub(super) write_count: u32,
25+
pub(super) byte_count: u64,
26+
}
27+
28+
pub(super) struct LightingUploadBatch {
29+
ranges: [Option<Range<usize>>; 3],
30+
}
31+
32+
impl LightingUploadBatch {
33+
pub(super) fn ranges(&self) -> impl Iterator<Item = &Range<usize>> {
34+
self.ranges.iter().flatten()
35+
}
36+
}
37+
38+
pub(super) struct LightingUploadTracker {
39+
last_uploaded: LightingUniforms,
40+
frame_stats: LightingUploadStats,
41+
}
42+
43+
impl LightingUploadTracker {
44+
pub(super) fn new(initial: LightingUniforms) -> Self {
45+
Self {
46+
last_uploaded: initial,
47+
frame_stats: LightingUploadStats::default(),
48+
}
49+
}
50+
51+
pub(super) fn begin_frame(&mut self) {
52+
self.frame_stats = LightingUploadStats::default();
53+
}
54+
55+
pub(super) fn plan(&mut self, current: LightingUniforms) -> LightingUploadBatch {
56+
let current_bytes = bytemuck::bytes_of(&current);
57+
let previous_bytes = bytemuck::bytes_of(&self.last_uploaded);
58+
let ranges = REGIONS
59+
.clone()
60+
.map(|region| changed_word_range(current_bytes, previous_bytes, region));
61+
for range in ranges.iter().flatten() {
62+
self.frame_stats.write_count = self.frame_stats.write_count.saturating_add(1);
63+
self.frame_stats.byte_count = self
64+
.frame_stats
65+
.byte_count
66+
.saturating_add((range.end - range.start) as u64);
67+
}
68+
self.last_uploaded = current;
69+
LightingUploadBatch { ranges }
70+
}
71+
72+
#[cfg(not(target_arch = "wasm32"))]
73+
pub(super) fn frame_stats(&self) -> LightingUploadStats {
74+
self.frame_stats
75+
}
76+
}
77+
78+
fn changed_word_range(
79+
current: &[u8],
80+
previous: &[u8],
81+
region: Range<usize>,
82+
) -> Option<Range<usize>> {
83+
debug_assert_eq!(region.start % wgpu::COPY_BUFFER_ALIGNMENT as usize, 0);
84+
debug_assert_eq!(region.end % wgpu::COPY_BUFFER_ALIGNMENT as usize, 0);
85+
let alignment = wgpu::COPY_BUFFER_ALIGNMENT as usize;
86+
let first = (region.start..region.end)
87+
.step_by(alignment)
88+
.find(|&offset| {
89+
current[offset..offset + alignment] != previous[offset..offset + alignment]
90+
})?;
91+
let last = (region.start..region.end)
92+
.step_by(alignment)
93+
.rev()
94+
.find(|&offset| current[offset..offset + alignment] != previous[offset..offset + alignment])
95+
.expect("the first changed word proves a last changed word exists");
96+
Some(first..last + alignment)
97+
}
98+
99+
#[cfg(test)]
100+
mod tests {
101+
use super::{LightingUploadTracker, LIGHTING_BYTES};
102+
use crate::renderer::types::LightingUniforms;
103+
104+
#[test]
105+
fn unchanged_snapshot_schedules_no_upload() {
106+
let lighting = LightingUniforms::defaults();
107+
let mut tracker = LightingUploadTracker::new(lighting);
108+
tracker.begin_frame();
109+
110+
assert_eq!(tracker.plan(lighting).ranges().count(), 0);
111+
assert_eq!(tracker.frame_stats().write_count, 0);
112+
assert_eq!(tracker.frame_stats().byte_count, 0);
113+
}
114+
115+
#[test]
116+
fn changes_are_bounded_to_three_non_overlapping_regions() {
117+
let initial = LightingUniforms::defaults();
118+
let mut changed = initial;
119+
changed.ambient[0] = 0.25;
120+
changed.point_lights[7].position[2] = 42.0;
121+
changed.camera_pos[0] = 3.0;
122+
123+
let mut tracker = LightingUploadTracker::new(initial);
124+
tracker.begin_frame();
125+
let batch = tracker.plan(changed);
126+
let ranges: Vec<_> = batch.ranges().cloned().collect();
127+
128+
assert_eq!(ranges.len(), 3);
129+
assert!(ranges.windows(2).all(|pair| pair[0].end <= pair[1].start));
130+
assert_eq!(tracker.frame_stats().write_count, 3);
131+
assert!(tracker.frame_stats().byte_count < LIGHTING_BYTES as u64);
132+
}
133+
134+
#[test]
135+
fn repeated_setter_mutations_coalesce_before_planning() {
136+
let initial = LightingUniforms::defaults();
137+
let mut changed = initial;
138+
changed.point_lights[3].position = [1.0, 2.0, 3.0, 4.0];
139+
changed.point_lights[3].color = [0.2, 0.4, 0.6, 8.0];
140+
141+
let mut tracker = LightingUploadTracker::new(initial);
142+
tracker.begin_frame();
143+
let batch = tracker.plan(changed);
144+
145+
assert_eq!(batch.ranges().count(), 1);
146+
assert_eq!(tracker.frame_stats().write_count, 1);
147+
assert_eq!(tracker.frame_stats().byte_count, 32);
148+
}
149+
}

native/shared/src/renderer/mod.rs

Lines changed: 22 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ mod layered_pbr_refraction;
3232
pub(crate) mod layered_pbr_scene;
3333
pub(crate) mod layered_pbr_ssr;
3434
mod lighting;
35+
mod lighting_upload;
3536
mod material_api;
3637
pub mod material_indirection;
3738
mod material_instancing;
@@ -459,6 +460,7 @@ pub struct Renderer {
459460

460461
// Lighting uniforms
461462
lighting_uniforms: LightingUniforms,
463+
lighting_upload_tracker: lighting_upload::LightingUploadTracker,
462464
lighting_buffer: wgpu::Buffer,
463465
lighting_bind_group: wgpu::BindGroup,
464466

@@ -7543,6 +7545,7 @@ impl Renderer {
75437545
uniform_buffer_3d,
75447546
uniform_bind_group_3d,
75457547
lighting_uniforms,
7548+
lighting_upload_tracker: lighting_upload::LightingUploadTracker::new(lighting_uniforms),
75467549
lighting_buffer,
75477550
lighting_bind_group,
75487551
joint_buffer,
@@ -10005,11 +10008,6 @@ impl Renderer {
1000510008

1000610009
pub fn set_env_intensity(&mut self, intensity: f32) {
1000710010
self.lighting_uniforms.camera_pos[3] = intensity;
10008-
self.queue.write_buffer(
10009-
&self.lighting_buffer,
10010-
0,
10011-
bytemuck::bytes_of(&self.lighting_uniforms),
10012-
);
1001310011
}
1001410012

1001510013
// ============================================================
@@ -11673,7 +11671,20 @@ impl Renderer {
1167311671
Some((physical_uniform, bind_group, uses_uv1))
1167411672
}
1167511673

11674+
fn flush_lighting_uniforms(&mut self) {
11675+
let batch = self.lighting_upload_tracker.plan(self.lighting_uniforms);
11676+
let bytes = bytemuck::bytes_of(&self.lighting_uniforms);
11677+
for range in batch.ranges() {
11678+
self.queue.write_buffer(
11679+
&self.lighting_buffer,
11680+
range.start as u64,
11681+
&bytes[range.clone()],
11682+
);
11683+
}
11684+
}
11685+
1167611686
pub fn begin_frame(&mut self) {
11687+
self.lighting_upload_tracker.begin_frame();
1167711688
self.vertices_2d.clear();
1167811689
self.indices_2d.clear();
1167911690
self.draw_calls_2d.clear();
@@ -11728,11 +11739,6 @@ impl Renderer {
1172811739
let preserved_env_intensity = self.lighting_uniforms.camera_pos[3];
1172911740
self.lighting_uniforms = LightingUniforms::defaults();
1173011741
self.lighting_uniforms.camera_pos[3] = preserved_env_intensity;
11731-
self.queue.write_buffer(
11732-
&self.lighting_buffer,
11733-
0,
11734-
bytemuck::bytes_of(&self.lighting_uniforms),
11735-
);
1173611742
self.clear_additional_lights();
1173711743

1173811744
// DEBUG: joint animation disabled for iOS port
@@ -12106,6 +12112,7 @@ impl Renderer {
1210612112
}
1210712113
}
1210812114

12115+
self.flush_lighting_uniforms();
1210912116
self.submit_frame_commands(encoder.finish());
1211012117
if let Some(out) = surface_output {
1211112118
self.present_frame(out);
@@ -12490,6 +12497,11 @@ impl Renderer {
1249012497
}
1249112498
}
1249212499

12500+
// Queue writes are ordered before the command buffer submitted below.
12501+
// Flush once after every pass has finalized the CPU lighting snapshot
12502+
// (notably the shadow cascade fit), so all encoded consumers see one
12503+
// coherent set of dirty ranges.
12504+
self.flush_lighting_uniforms();
1249312505
profiler.resolve(&mut encoder);
1249412506

1249512507
// Capture copies were encoded by the terminal graph node. Mapping must
@@ -13056,11 +13068,6 @@ impl Renderer {
1305613068
0.0,
1305713069
];
1305813070
self.lighting_uniforms.shadow_view_matrix = self.current_view_matrix;
13059-
self.queue.write_buffer(
13060-
&self.lighting_buffer,
13061-
0,
13062-
bytemuck::bytes_of(&self.lighting_uniforms),
13063-
);
1306413071

1306513072
self.queue.write_buffer(
1306613073
&self.uniform_buffer_3d,
@@ -13732,11 +13739,6 @@ impl Renderer {
1373213739
(b / 255.0) as f32,
1373313740
intensity as f32,
1373413741
];
13735-
self.queue.write_buffer(
13736-
&self.lighting_buffer,
13737-
0,
13738-
bytemuck::bytes_of(&self.lighting_uniforms),
13739-
);
1374013742
}
1374113743

1374213744
pub fn set_directional_light(
@@ -13760,11 +13762,6 @@ impl Renderer {
1376013762
(b / 255.0) as f32,
1376113763
0.0,
1376213764
];
13763-
self.queue.write_buffer(
13764-
&self.lighting_buffer,
13765-
0,
13766-
bytemuck::bytes_of(&self.lighting_uniforms),
13767-
);
1376813765
}
1376913766

1377013767
/// Add an additional directional light (up to MAX_DIR_LIGHTS).
@@ -13788,11 +13785,6 @@ impl Renderer {
1378813785
color: [r, g, b, 0.0],
1378913786
};
1379013787
self.lighting_uniforms.dir_light_count[0] = (idx + 1) as f32;
13791-
self.queue.write_buffer(
13792-
&self.lighting_buffer,
13793-
0,
13794-
bytemuck::bytes_of(&self.lighting_uniforms),
13795-
);
1379613788
}
1379713789

1379813790
/// Add a point light (up to MAX_POINT_LIGHTS).
@@ -13817,11 +13809,6 @@ impl Renderer {
1381713809
color: [r, g, b, intensity],
1381813810
};
1381913811
self.lighting_uniforms.point_light_count[0] = (idx + 1) as f32;
13820-
self.queue.write_buffer(
13821-
&self.lighting_buffer,
13822-
0,
13823-
bytemuck::bytes_of(&self.lighting_uniforms),
13824-
);
1382513812
}
1382613813

1382713814
/// Clear all additional lights (called at begin_frame).

native/shared/src/renderer/quality_capture.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,6 +1265,15 @@ impl Renderer {
12651265
out.push_str(",\"sample_count\":1");
12661266
out.push_str(",\"alpha_to_coverage_supported\":false");
12671267
out.push_str(",\"single_sample_fallback\":\"coverage-mips-bayer-4x4\"}");
1268+
let lighting_uploads = self.lighting_upload_tracker.frame_stats();
1269+
out.push_str(",\"steady_state_uploads\":{\"lighting\":{");
1270+
out.push_str("\"write_count\":");
1271+
out.push_str(&lighting_uploads.write_count.to_string());
1272+
out.push_str(",\"byte_count\":");
1273+
out.push_str(&lighting_uploads.byte_count.to_string());
1274+
out.push_str(",\"full_buffer_bytes\":");
1275+
out.push_str(&std::mem::size_of::<super::types::LightingUniforms>().to_string());
1276+
out.push_str("}}");
12681277
let graph_stats = self.render_graph_cache_stats();
12691278
out.push_str(",\"render_graph\":{");
12701279
out.push_str("\"compile_count\":");

native/shared/src/renderer/shadow_pass.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,6 @@ impl Renderer {
7575
},
7676
];
7777
self.lighting_uniforms.shadow_view_matrix = self.current_view_matrix;
78-
self.queue.write_buffer(
79-
&self.lighting_buffer,
80-
0,
81-
bytemuck::bytes_of(&self.lighting_uniforms),
82-
);
8378
// Shadow-flicker fix: the material system's PerView buffer was
8479
// uploaded before this fit ran and still carries LAST frame's
8580
// cascade VPs. Patch its shadow fields so material-path

0 commit comments

Comments
 (0)