Skip to content

Commit 80ce85c

Browse files
committed
Track immediate primitive motion history
1 parent 5d5d9a1 commit 80ce85c

9 files changed

Lines changed: 409 additions & 21 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"schema": "bloom-temporal-motion-evidence-v1",
3+
"issue": 135,
4+
"producer": "immediate-primitives",
5+
"comparison_base": "5d5d9a1e4e15b991111984a8cefd5d9b1307d15e",
6+
"adapter": "Apple M1 Max",
7+
"backend": "Metal",
8+
"motion_sequence": {
9+
"meaningful_motion_pixels": 7234,
10+
"movement_mean": 15.8512,
11+
"frame_four_mean": 0.4309,
12+
"frame_four_outlier_fraction": 0.007599,
13+
"severe_trail_frames": 0,
14+
"stable_flicker": 0.4695
15+
},
16+
"performance": {
17+
"resolution": [1920, 1080],
18+
"warmup_frames": 300,
19+
"measured_frames": 900,
20+
"runs_per_revision": 3,
21+
"before": {
22+
"render_submit_mean_ms": [2.234708, 2.239589, 2.244215],
23+
"render_submit_p50_ms": [2.341041, 2.350833, 2.350458],
24+
"render_submit_p95_ms": [2.556375, 2.60725, 2.587625],
25+
"render_submit_p99_ms": [3.43775, 3.792833, 3.775958],
26+
"prepare_mean_ms": [0.034143, 0.035865, 0.033959]
27+
},
28+
"after": {
29+
"render_submit_mean_ms": [2.232462, 2.236045, 2.243486],
30+
"render_submit_p50_ms": [2.390333, 2.3855, 2.370917],
31+
"render_submit_p95_ms": [2.606375, 2.612417, 2.600292],
32+
"render_submit_p99_ms": [3.376, 3.61725, 3.632875],
33+
"prepare_mean_ms": [0.039801, 0.038652, 0.035247]
34+
},
35+
"steady_upload_bytes_per_frame_before": 23264,
36+
"steady_upload_bytes_per_frame_after": 23264
37+
},
38+
"resources": {
39+
"three_primitive_cpu_capacity_bytes": 1728,
40+
"gpu_bytes": 0,
41+
"added_passes": 0,
42+
"added_draws": 0,
43+
"vertex_stride_change_bytes": 0
44+
}
45+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Issue #135 immediate-primitive motion evidence
2+
3+
This qualification covers raylib-style immediate 3D submissions before and
4+
after adding previous-position ownership. The comparison base is
5+
`5d5d9a1e4e15b991111984a8cefd5d9b1307d15e`.
6+
7+
## Motion sequence
8+
9+
The headless Metal test warms a static pose for eight frames, translates an
10+
immediate cube from x=-1.6 to x=1.6, captures the first moved frame's temporal
11+
diagnostics, and evaluates 24 output frames:
12+
13+
- 7,234 pixels carry meaningful motion;
14+
- the visible move is strong (`movement_mean = 15.8512`);
15+
- severe trail duration is zero frames;
16+
- coherent frame-four outliers are 0.7599%, below the 2% gate;
17+
- settled jitter-cycle flicker is 0.4695, below the 2.0 gate.
18+
19+
First appearances, primitive-kind changes, vertex-count changes, empty
20+
intervening frames, and explicit temporal resets have unit-tested
21+
previous=current seeding. They cannot inherit an unrelated slot's motion.
22+
23+
## Performance and memory
24+
25+
The controlled `tools/render-perf` scene is the affected path: one immediate
26+
plane and cube, Ultra preset, 40 point lights, 300 warm-up plus 900 measured
27+
frames at 1920x1080 on Apple M1 Max / Metal. Values are medians of three runs
28+
per revision.
29+
30+
| CPU metric | Before | After | Change |
31+
|---|---:|---:|---:|
32+
| Full render-submit mean | 2.2396 ms | 2.2360 ms | -0.16% |
33+
| Full render-submit P50 | 2.3505 ms | 2.3855 ms | +1.49% |
34+
| Full render-submit P95 | 2.5876 ms | 2.6064 ms | +0.72% |
35+
| Full render-submit P99 | 3.7760 ms | 3.6173 ms | -4.20% |
36+
| Submission preparation mean | 0.0341 ms | 0.0387 ms | +0.0045 ms |
37+
38+
An immediate alternating fourth pair moved from 2.2342 to 2.2199 ms mean;
39+
the small percentile changes above are below run-to-run scheduling variance,
40+
while the complete-frame mean shows no regression.
41+
42+
The affected steady upload remains exactly 23,264 bytes per frame. There is no
43+
vertex-stride change, texture upload, GPU allocation, bind group, draw, or
44+
render pass. The three-primitive motion test retains 1,728 bytes of grow-only
45+
CPU capacity. Runtime telemetry reports the live entry count and capacity,
46+
plus zero GPU bytes and zero added passes.
47+
48+
## Commands
49+
50+
```sh
51+
BLOOM_RENDER_PERF_ENGINE_REVISION=<revision> \
52+
cargo run --release --manifest-path tools/render-perf/Cargo.toml -- \
53+
--width 1920 --height 1080 --warmup 300 --frames 900 --out <report.json>
54+
55+
cargo test --manifest-path native/shared/Cargo.toml --test golden_render \
56+
immediate_primitive_motion_writes_velocity_and_bounds_trails -- --nocapture
57+
```

docs/temporal-history.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,25 @@ matrix per live cached instance; its allocated capacity is reported as
175175
`cached_model_motion_cpu_capacity_bytes`. The current entry count, zero GPU
176176
bytes, and zero added passes are reported alongside it.
177177

178+
## Immediate-primitive motion
179+
180+
Raylib-style `drawCube`, `drawSphere`, `drawCylinder`, `drawPlane`, wire,
181+
grid, and ray submissions rebuild world-space vertices each frame. Their
182+
stable submission slot now owns the previous vertex positions, so moving,
183+
scaling, or deforming an immediate primitive writes the same true
184+
current-minus-previous velocity as retained geometry. Call order is the
185+
identity contract, matching the cached-model submission-slot API. A new slot,
186+
a different primitive kind in that slot, a changed vertex count, an empty
187+
intervening frame, or an explicit temporal reset seeds previous=current; none
188+
can inherit unrelated motion.
189+
190+
Previous positions use xyz of the immediate pipeline's otherwise-unused
191+
tangent lane with a marker in w. `Vertex3D` stride and the existing upload are
192+
unchanged, and there is no GPU allocation, bind group, draw, or pass. CPU
193+
history is two grow-on-demand position streams plus compact slot ranges.
194+
Telemetry reports `immediate_motion_entries`,
195+
`immediate_motion_cpu_capacity_bytes`, zero GPU bytes, and zero added passes.
196+
178197
## Per-pixel TAA/TSR diagnostics
179198

180199
`captureDebugIntermediates(directory)` now adds four surface-resolution PNGs
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
//! Previous-position ownership for raylib-style immediate 3D primitives.
2+
//!
3+
//! Immediate vertices are rebuilt in world space every frame, so a previous
4+
//! model matrix cannot describe their motion. Stable submission slots own a
5+
//! compact CPU copy of the prior positions instead. The existing tangent lane
6+
//! is unused by `pipeline_3d`; xyz carries the previous world position and w
7+
//! marks that payload for the vertex shader. This changes neither the vertex
8+
//! stride nor GPU upload size.
9+
10+
use super::{Renderer, Vertex3D};
11+
12+
pub(super) const PREVIOUS_POSITION_MARKER: f32 = 2.0;
13+
14+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15+
pub(super) enum PrimitiveKind {
16+
Cube,
17+
CubeWires,
18+
Sphere,
19+
SphereWires,
20+
Cylinder,
21+
Plane,
22+
Grid,
23+
Ray,
24+
}
25+
26+
#[derive(Clone, Copy, Debug)]
27+
struct Range {
28+
kind: PrimitiveKind,
29+
start: usize,
30+
count: usize,
31+
}
32+
33+
#[derive(Default)]
34+
pub(super) struct History {
35+
previous_positions: Vec<[f32; 3]>,
36+
current_positions: Vec<[f32; 3]>,
37+
previous_ranges: Vec<Range>,
38+
current_ranges: Vec<Range>,
39+
next_slot: usize,
40+
}
41+
42+
impl History {
43+
pub(super) fn begin_frame(&mut self) {
44+
std::mem::swap(&mut self.previous_positions, &mut self.current_positions);
45+
self.current_positions.clear();
46+
std::mem::swap(&mut self.previous_ranges, &mut self.current_ranges);
47+
self.current_ranges.clear();
48+
self.next_slot = 0;
49+
}
50+
51+
pub(super) fn reset(&mut self) {
52+
self.previous_positions.clear();
53+
self.current_positions.clear();
54+
self.previous_ranges.clear();
55+
self.current_ranges.clear();
56+
self.next_slot = 0;
57+
}
58+
59+
/// Attach the matching prior-frame positions to one completed primitive.
60+
///
61+
/// Kind and vertex count are a topology fence. A first appearance, a
62+
/// reordered primitive of another kind, or a changed tessellation seeds
63+
/// previous=current so it cannot create a false motion vector.
64+
pub(super) fn record(&mut self, kind: PrimitiveKind, vertices: &mut [Vertex3D]) {
65+
let previous = self
66+
.previous_ranges
67+
.get(self.next_slot)
68+
.copied()
69+
.filter(|range| {
70+
range.kind == kind
71+
&& range.count == vertices.len()
72+
&& range.start.saturating_add(range.count) <= self.previous_positions.len()
73+
});
74+
let current_start = self.current_positions.len();
75+
for (index, vertex) in vertices.iter_mut().enumerate() {
76+
let prior = previous
77+
.map(|range| self.previous_positions[range.start + index])
78+
.unwrap_or(vertex.position);
79+
vertex.tangent = [prior[0], prior[1], prior[2], PREVIOUS_POSITION_MARKER];
80+
self.current_positions.push(vertex.position);
81+
}
82+
self.current_ranges.push(Range {
83+
kind,
84+
start: current_start,
85+
count: vertices.len(),
86+
});
87+
self.next_slot += 1;
88+
}
89+
90+
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
91+
pub(super) fn stats(&self) -> (usize, usize) {
92+
let entries = self.current_ranges.len();
93+
let bytes = (self.previous_positions.capacity() + self.current_positions.capacity())
94+
* std::mem::size_of::<[f32; 3]>()
95+
+ (self.previous_ranges.capacity() + self.current_ranges.capacity())
96+
* std::mem::size_of::<Range>();
97+
(entries, bytes)
98+
}
99+
}
100+
101+
impl Renderer {
102+
pub(super) fn record_immediate_motion(&mut self, kind: PrimitiveKind, vertex_start: usize) {
103+
self.immediate_motion
104+
.record(kind, &mut self.vertices_3d[vertex_start..]);
105+
}
106+
}
107+
108+
#[cfg(test)]
109+
mod tests {
110+
use super::*;
111+
112+
fn vertex(position: [f32; 3]) -> Vertex3D {
113+
Vertex3D {
114+
position,
115+
tangent: [9.0; 4],
116+
..Default::default()
117+
}
118+
}
119+
120+
fn prior(vertex: &Vertex3D) -> [f32; 3] {
121+
[vertex.tangent[0], vertex.tangent[1], vertex.tangent[2]]
122+
}
123+
124+
#[test]
125+
fn first_appearance_seeds_zero_motion_then_stable_slot_uses_prior_position() {
126+
let mut history = History::default();
127+
history.begin_frame();
128+
let mut first = [vertex([1.0, 2.0, 3.0])];
129+
history.record(PrimitiveKind::Cube, &mut first);
130+
assert_eq!(prior(&first[0]), first[0].position);
131+
assert_eq!(first[0].tangent[3], PREVIOUS_POSITION_MARKER);
132+
133+
history.begin_frame();
134+
let mut moved = [vertex([4.0, 5.0, 6.0])];
135+
history.record(PrimitiveKind::Cube, &mut moved);
136+
assert_eq!(prior(&moved[0]), [1.0, 2.0, 3.0]);
137+
}
138+
139+
#[test]
140+
fn kind_and_topology_mismatches_cannot_inherit_unrelated_motion() {
141+
let mut history = History::default();
142+
history.begin_frame();
143+
history.record(
144+
PrimitiveKind::Cube,
145+
&mut [vertex([1.0, 0.0, 0.0]), vertex([2.0, 0.0, 0.0])],
146+
);
147+
148+
history.begin_frame();
149+
let mut wrong_kind = [vertex([8.0, 0.0, 0.0]), vertex([9.0, 0.0, 0.0])];
150+
history.record(PrimitiveKind::Sphere, &mut wrong_kind);
151+
assert_eq!(prior(&wrong_kind[0]), wrong_kind[0].position);
152+
153+
history.begin_frame();
154+
let mut wrong_count = [vertex([12.0, 0.0, 0.0])];
155+
history.record(PrimitiveKind::Sphere, &mut wrong_count);
156+
assert_eq!(prior(&wrong_count[0]), wrong_count[0].position);
157+
}
158+
159+
#[test]
160+
fn an_empty_frame_breaks_submission_history() {
161+
let mut history = History::default();
162+
history.begin_frame();
163+
history.record(PrimitiveKind::Ray, &mut [vertex([1.0, 0.0, 0.0])]);
164+
history.begin_frame();
165+
history.begin_frame();
166+
167+
let mut reappeared = [vertex([7.0, 0.0, 0.0])];
168+
history.record(PrimitiveKind::Ray, &mut reappeared);
169+
assert_eq!(prior(&reappeared[0]), reappeared[0].position);
170+
}
171+
}

0 commit comments

Comments
 (0)