Skip to main content

cvkg_render_gpu/kvasir/
resource.rs

1/// Opaque handle to a GPU resource (Texture, Buffer, etc.) managed by the graph.
2#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3pub struct ResourceId(pub u32);
4
5/// P1-20 fix: resource access type for hazard analysis.
6///
7/// Each pass declares which resources it reads and which it writes.
8/// The graph planner can then detect:
9///   - Write-after-write (WAW): two passes write the same resource
10///     without a barrier between them
11///   - Read-after-write (RAW): a pass reads a resource that was
12///     written by an earlier pass without a barrier
13///   - Write-after-read (WAR): a pass writes a resource that is
14///     still being read by an earlier pass
15///
16/// Hazards are detected at graph compilation time, not at runtime,
17/// so they don't add frame-time overhead.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ResourceAccess {
20    Read,
21    Write,
22}
23
24impl ResourceAccess {
25    /// Returns true if this access conflicts with another in the
26    /// sense that they cannot be reordered. The caller is responsible
27    /// for inserting the appropriate barrier (wgpu render pass
28    /// boundary, compute pass boundary, or queue.submit) between
29    /// the two accesses.
30    pub fn conflicts_with(self, other: ResourceAccess) -> bool {
31        // The conflict table:
32        //   Read  + Read  = false (parallel reads are fine)
33        //   Read  + Write = true  (WAR hazard)
34        //   Write + Read  = true  (RAW hazard)
35        //   Write + Write = true  (WAW hazard)
36        !matches!((self, other), (ResourceAccess::Read, ResourceAccess::Read))
37    }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ResourceKind {
42    Image {
43        format: wgpu::TextureFormat,
44        width: u32,
45        height: u32,
46        mip_level_count: u32,
47        usage: wgpu::TextureUsages,
48    },
49    Buffer {
50        size: u64,
51        usage: wgpu::BufferUsages,
52    },
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ResourceLifetime {
57    /// Destroyed automatically at the end of the frame.
58    Frame,
59    /// Lives until explicitly destroyed or window is closed.
60    Persistent,
61}
62
63#[derive(Debug, Clone)]
64pub struct ResourceDescriptor {
65    pub label: Option<String>,
66    pub kind: ResourceKind,
67    pub lifetime: ResourceLifetime,
68}
69
70#[cfg(test)]
71mod p1_20_hazard_tracking_tests {
72    use super::ResourceAccess;
73
74    #[test]
75    fn read_read_does_not_conflict() {
76        // P1-20: two parallel reads of the same resource are safe.
77        assert!(!ResourceAccess::Read.conflicts_with(ResourceAccess::Read));
78    }
79
80    #[test]
81    fn read_write_conflicts_war() {
82        // P1-20: write-after-read is a hazard (the write could
83        // clobber the read result if not synchronized).
84        assert!(ResourceAccess::Read.conflicts_with(ResourceAccess::Write));
85    }
86
87    #[test]
88    fn write_read_conflicts_raw() {
89        // P1-20: read-after-write is a hazard (the read could
90        // see stale data if not synchronized).
91        assert!(ResourceAccess::Write.conflicts_with(ResourceAccess::Read));
92    }
93
94    #[test]
95    fn write_write_conflicts_waw() {
96        // P1-20: write-after-write is a hazard (one write could
97        // overwrite the other's result).
98        assert!(ResourceAccess::Write.conflicts_with(ResourceAccess::Write));
99    }
100
101    #[test]
102    fn conflict_table_is_symmetric() {
103        // P1-20: the conflict relation must be symmetric. If A
104        // conflicts with B, B must conflict with A.
105        for a in [ResourceAccess::Read, ResourceAccess::Write] {
106            for b in [ResourceAccess::Read, ResourceAccess::Write] {
107                assert_eq!(
108                    a.conflicts_with(b),
109                    b.conflicts_with(a),
110                    "conflict table not symmetric for {a:?} vs {b:?}"
111                );
112            }
113        }
114    }
115}
116
117// =========================================================================
118// P1-25: CPU/Shader Material ID drift detection
119// =========================================================================
120
121#[cfg(test)]
122mod p1_25_material_id_consistency_tests {
123    // Reference values from the Rust `material_id` module in
124    // cvkg-render-gpu/src/renderer.rs. If you change the Rust
125    // constants, you MUST also update the WGSL shader files (and
126    // these expected values).
127    const RUST_GLASS: u32 = 7;
128    const RUST_DROP_SHADOW: u32 = 18;
129    const RUST_MESH_3D: u32 = 21;
130
131    /// Scan a WGSL file for `Nu` literal patterns and return the
132    /// set of material_id values it references.
133    fn scan_wgsl_for_material_ids(source: &str) -> std::collections::HashSet<u32> {
134        let mut ids = std::collections::HashSet::new();
135        let bytes = source.as_bytes();
136        let mut i = 0;
137        while i < bytes.len() {
138            if bytes[i].is_ascii_digit() {
139                let start = i;
140                while i < bytes.len() && bytes[i].is_ascii_digit() {
141                    i += 1;
142                }
143                let num_str = std::str::from_utf8(&bytes[start..i]).unwrap();
144                if let Ok(n) = num_str.parse::<u32>() {
145                    let mut j = i;
146                    while j < bytes.len() && bytes[j].is_ascii_whitespace() {
147                        j += 1;
148                    }
149                    if j < bytes.len() && bytes[j] == b'u' {
150                        ids.insert(n);
151                    }
152                }
153            } else {
154                i += 1;
155            }
156        }
157        ids
158    }
159
160    #[test]
161    fn glass_id_appears_in_wgsl() {
162        let source = include_str!("../shaders/material_opaque.wgsl");
163        let ids = scan_wgsl_for_material_ids(source);
164        assert!(
165            ids.contains(&RUST_GLASS),
166            "WGSL must reference material_id {RUST_GLASS} (GLASS)"
167        );
168    }
169
170    #[test]
171    fn drop_shadow_id_appears_in_wgsl() {
172        let source = include_str!("../shaders/material_opaque.wgsl");
173        let ids = scan_wgsl_for_material_ids(source);
174        assert!(
175            ids.contains(&RUST_DROP_SHADOW),
176            "WGSL must reference material_id {RUST_DROP_SHADOW} (DROP_SHADOW)"
177        );
178    }
179
180    #[test]
181    fn mesh_3d_id_appears_in_wgsl() {
182        let source = include_str!("../shaders/material_opaque.wgsl");
183        let ids = scan_wgsl_for_material_ids(source);
184        assert!(
185            ids.contains(&RUST_MESH_3D),
186            "WGSL must reference material_id {RUST_MESH_3D} (MESH_3D)"
187        );
188    }
189
190    #[test]
191    fn scanner_works() {
192        let src = "if (in.material_id == 18u) { } else if (in.material_id == 21u) { }";
193        let ids = scan_wgsl_for_material_ids(src);
194        assert!(ids.contains(&18));
195        assert!(ids.contains(&21));
196        let src2 = "let x = 18;";
197        let ids2 = scan_wgsl_for_material_ids(src2);
198        assert!(!ids2.contains(&18));
199    }
200}
201
202// =========================================================================
203// P2-7: Scissor rect math for zero-dimension edge case
204// =========================================================================
205
206#[cfg(test)]
207mod p2_7_scissor_rect_tests {
208    /// Pure function that reproduces the scissor rect computation.
209    /// Returns (x, y, w, h) where (0,0,0,0) means zero-area scissor.
210    fn compute_scissor(
211        rect: Option<(f32, f32, f32, f32)>, // (x, y, width, height)
212        scale: f32,
213        rt_w: i32,
214        rt_h: i32,
215    ) -> Option<(u32, u32, u32, u32)> {
216        let (x, y, w, h) = rect?;
217        if rt_w <= 0 || rt_h <= 0 {
218            return None;
219        }
220        let x1 = (x * scale).round() as i32;
221        let y1 = (y * scale).round() as i32;
222        let x2 = ((x + w) * scale).round() as i32;
223        let y2 = ((y + h) * scale).round() as i32;
224        let sw = (x2 - x1).clamp(0, rt_w);
225        let sh = (y2 - y1).clamp(0, rt_h);
226        // P2-7: zero dimensions use zero-area scissor (0,0,0,0).
227        if sw > 0 && sh > 0 {
228            Some((x1 as u32, y1 as u32, sw as u32, sh as u32))
229        } else {
230            Some((0, 0, 0, 0))
231        }
232    }
233
234    #[test]
235    fn normal_rect_produces_correct_scissor() {
236        let sc = compute_scissor(Some((10.0, 20.0, 100.0, 50.0)), 1.0, 800, 600);
237        assert_eq!(sc, Some((10, 20, 100, 50)));
238    }
239
240    #[test]
241    fn zero_width_rect_produces_zero_scissor() {
242        let sc = compute_scissor(Some((10.0, 20.0, 0.0, 50.0)), 1.0, 800, 600);
243        assert_eq!(sc, Some((0, 0, 0, 0)));
244    }
245
246    #[test]
247    fn zero_height_rect_produces_zero_scissor() {
248        let sc = compute_scissor(Some((10.0, 20.0, 50.0, 0.0)), 1.0, 800, 600);
249        assert_eq!(sc, Some((0, 0, 0, 0)));
250    }
251
252    #[test]
253    fn negative_dimensions_clamp_to_zero() {
254        let sc = compute_scissor(Some((100.0, 100.0, -50.0, 50.0)), 1.0, 800, 600);
255        assert_eq!(sc, Some((0, 0, 0, 0)));
256    }
257
258    #[test]
259    fn none_rect_returns_none() {
260        let sc = compute_scissor(None, 1.0, 800, 600);
261        assert_eq!(sc, None);
262    }
263
264    #[test]
265    fn scissor_clamps_to_render_target() {
266        // Rect at (700, 500) with size 200x200 in an 800x600 target.
267        // x1=700, x2=900, w=clamp(900-700, 0, 800)=200 (clamp to
268        // upper bound of rt_w is not applied here -- clamp(0, rt_w)
269        // means the upper bound is rt_w but w=200 <= 800 so the
270        // clamp doesn't affect it). The actual behavior is that w
271        // is the difference x2-x1 = 200, which fits within 800.
272        let sc = compute_scissor(Some((700.0, 500.0, 200.0, 200.0)), 1.0, 800, 600);
273        assert_eq!(sc, Some((700, 500, 200, 200)));
274    }
275
276    #[test]
277    fn scissor_extending_past_target_clamps() {
278        // Rect at (700, 500) with size 500x500. w = 1200-700 = 500.
279        // The clamp(0, 800) doesn't trigger because 500 <= 800.
280        // Note: the original code does NOT actually clamp the rect
281        // to the render target bounds; it just ensures w/h is
282        // non-negative. A separate audit (P3-*) would be needed
283        // to add proper bounds clamping. For now, verify the
284        // current (non-bounds-clamping) behavior.
285        let sc = compute_scissor(Some((700.0, 500.0, 500.0, 500.0)), 1.0, 800, 600);
286        assert_eq!(sc, Some((700, 500, 500, 500)));
287    }
288}