Skip to main content

viewport_lib/resources/
extra_impls.rs

1use super::*;
2
3pub(super) fn generate_edge_indices(triangle_indices: &[u32]) -> Vec<u32> {
4    use std::collections::HashSet;
5    let mut edges: HashSet<(u32, u32)> = HashSet::new();
6    let mut result = Vec::new();
7
8    for tri in triangle_indices.chunks(3) {
9        if tri.len() < 3 {
10            continue;
11        }
12        let pairs = [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])];
13        for (a, b) in &pairs {
14            // Canonical form: smaller index first, so (a,b) and (b,a) map to the same edge.
15            let edge = if a < b { (*a, *b) } else { (*b, *a) };
16            if edges.insert(edge) {
17                result.push(*a);
18                result.push(*b);
19            }
20        }
21    }
22    result
23}
24
25// ---------------------------------------------------------------------------
26// Procedural unit cube mesh (24 vertices, 4 per face, 36 indices)
27// ---------------------------------------------------------------------------
28
29/// Generate a unit cube centered at the origin.
30///
31/// 24 vertices (4 per face with shared normals), 36 indices (2 triangles per face).
32/// All vertices are white [1,1,1,1].
33pub(super) fn build_unit_cube() -> (Vec<Vertex>, Vec<u32>) {
34    let white = [1.0f32, 1.0, 1.0, 1.0];
35    let mut verts: Vec<Vertex> = Vec::with_capacity(24);
36    let mut idx: Vec<u32> = Vec::with_capacity(36);
37
38    // Helper: add a face quad (4 vertices in CCW order) and its 2 triangles.
39    let mut add_face = |positions: [[f32; 3]; 4], normal: [f32; 3]| {
40        let base = verts.len() as u32;
41        for pos in &positions {
42            verts.push(Vertex {
43                position: *pos,
44                normal,
45                color: white,
46                uv: [0.0, 0.0],
47                tangent: [0.0, 0.0, 0.0, 1.0],
48            });
49        }
50        // Two triangles: (base, base+1, base+2) and (base, base+2, base+3)
51        idx.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
52    };
53
54    // +X face (right), normal [1, 0, 0]
55    add_face(
56        [
57            [0.5, -0.5, -0.5],
58            [0.5, 0.5, -0.5],
59            [0.5, 0.5, 0.5],
60            [0.5, -0.5, 0.5],
61        ],
62        [1.0, 0.0, 0.0],
63    );
64
65    // -X face (left), normal [-1, 0, 0]
66    add_face(
67        [
68            [-0.5, -0.5, 0.5],
69            [-0.5, 0.5, 0.5],
70            [-0.5, 0.5, -0.5],
71            [-0.5, -0.5, -0.5],
72        ],
73        [-1.0, 0.0, 0.0],
74    );
75
76    // +Y face (top), normal [0, 1, 0]
77    add_face(
78        [
79            [-0.5, 0.5, -0.5],
80            [-0.5, 0.5, 0.5],
81            [0.5, 0.5, 0.5],
82            [0.5, 0.5, -0.5],
83        ],
84        [0.0, 1.0, 0.0],
85    );
86
87    // -Y face (bottom), normal [0, -1, 0]
88    add_face(
89        [
90            [-0.5, -0.5, 0.5],
91            [-0.5, -0.5, -0.5],
92            [0.5, -0.5, -0.5],
93            [0.5, -0.5, 0.5],
94        ],
95        [0.0, -1.0, 0.0],
96    );
97
98    // +Z face (front), normal [0, 0, 1]
99    add_face(
100        [
101            [-0.5, -0.5, 0.5],
102            [0.5, -0.5, 0.5],
103            [0.5, 0.5, 0.5],
104            [-0.5, 0.5, 0.5],
105        ],
106        [0.0, 0.0, 1.0],
107    );
108
109    // -Z face (back), normal [0, 0, -1]
110    add_face(
111        [
112            [0.5, -0.5, -0.5],
113            [-0.5, -0.5, -0.5],
114            [-0.5, 0.5, -0.5],
115            [0.5, 0.5, -0.5],
116        ],
117        [0.0, 0.0, -1.0],
118    );
119
120    (verts, idx)
121}
122
123// ---------------------------------------------------------------------------
124// Procedural glyph arrow mesh (cone tip + cylinder shaft, local +Y axis)
125// ---------------------------------------------------------------------------
126
127/// Generate a unit arrow mesh aligned to local +Y.
128///
129/// The arrow consists of:
130/// - A cylinder shaft from Y=0 to Y=0.7, radius 0.05.
131/// - A cone tip from Y=0.7 to Y=1.0, base radius 0.12.
132///
133/// 16 segments around the circumference gives ~300 vertices.
134pub(super) fn build_glyph_arrow() -> (Vec<Vertex>, Vec<u32>) {
135    let white = [1.0f32, 1.0, 1.0, 1.0];
136    let segments = 16usize;
137    let mut verts: Vec<Vertex> = Vec::new();
138    let mut idx: Vec<u32> = Vec::new();
139
140    let shaft_r = 0.05f32;
141    let shaft_bot = 0.0f32;
142    let shaft_top = 0.7f32;
143    let cone_r = 0.12f32;
144    let cone_bot = shaft_top;
145    let cone_tip = 1.0f32;
146
147    // Helper: append ring vertices at a given Y and radius with outward normals.
148    let ring_verts = |verts: &mut Vec<Vertex>, y: f32, r: f32, normal_y: f32| {
149        for i in 0..segments {
150            let angle = 2.0 * std::f32::consts::PI * (i as f32) / (segments as f32);
151            let (s, c) = angle.sin_cos();
152            let nx = if r > 0.0 { c } else { 0.0 };
153            let nz = if r > 0.0 { s } else { 0.0 };
154            let len = (nx * nx + normal_y * normal_y + nz * nz).sqrt();
155            verts.push(Vertex {
156                position: [c * r, y, s * r],
157                normal: [nx / len, normal_y / len, nz / len],
158                color: white,
159                uv: [0.0, 0.0],
160                tangent: [0.0, 0.0, 0.0, 1.0],
161            });
162        }
163    };
164
165    // --- Shaft ---
166    // Bottom ring (face down for the cap).
167    let shaft_bot_base = verts.len() as u32;
168    ring_verts(&mut verts, shaft_bot, shaft_r, 0.0);
169
170    // Bottom cap center.
171    let shaft_bot_center = verts.len() as u32;
172    verts.push(Vertex {
173        position: [0.0, shaft_bot, 0.0],
174        normal: [0.0, -1.0, 0.0],
175        color: white,
176        uv: [0.0, 0.0],
177        tangent: [0.0, 0.0, 0.0, 1.0],
178    });
179
180    // Bottom cap triangles.
181    for i in 0..segments {
182        let a = shaft_bot_base + i as u32;
183        let b = shaft_bot_base + ((i + 1) % segments) as u32;
184        idx.extend_from_slice(&[shaft_bot_center, b, a]);
185    }
186
187    // Side quads: two rings of shaft.
188    let shaft_top_ring_base = verts.len() as u32;
189    ring_verts(&mut verts, shaft_bot, shaft_r, 0.0); // duplicate bottom ring for side normals
190    let shaft_top_ring_top = verts.len() as u32;
191    ring_verts(&mut verts, shaft_top, shaft_r, 0.0);
192    for i in 0..segments {
193        let a = shaft_top_ring_base + i as u32;
194        let b = shaft_top_ring_base + ((i + 1) % segments) as u32;
195        let c = shaft_top_ring_top + i as u32;
196        let d = shaft_top_ring_top + ((i + 1) % segments) as u32;
197        idx.extend_from_slice(&[a, b, d, a, d, c]);
198    }
199
200    // --- Cone ---
201    // Slanted normal angle for cone surface: rise=(cone_tip-cone_bot), run=cone_r.
202    let cone_len = ((cone_tip - cone_bot).powi(2) + cone_r * cone_r).sqrt();
203    let normal_y_cone = cone_r / cone_len; // outward Y component of slanted normal
204    let normal_r_cone = (cone_tip - cone_bot) / cone_len;
205
206    let cone_base_ring = verts.len() as u32;
207    for i in 0..segments {
208        let angle = 2.0 * std::f32::consts::PI * (i as f32) / (segments as f32);
209        let (s, c) = angle.sin_cos();
210        verts.push(Vertex {
211            position: [c * cone_r, cone_bot, s * cone_r],
212            normal: [c * normal_r_cone, normal_y_cone, s * normal_r_cone],
213            color: white,
214            uv: [0.0, 0.0],
215            tangent: [0.0, 0.0, 0.0, 1.0],
216        });
217    }
218
219    // Cone tip vertex (normals averaged around tip — just point up).
220    let cone_tip_v = verts.len() as u32;
221    verts.push(Vertex {
222        position: [0.0, cone_tip, 0.0],
223        normal: [0.0, 1.0, 0.0],
224        color: white,
225        uv: [0.0, 0.0],
226        tangent: [0.0, 0.0, 0.0, 1.0],
227    });
228
229    for i in 0..segments {
230        let a = cone_base_ring + i as u32;
231        let b = cone_base_ring + ((i + 1) % segments) as u32;
232        idx.extend_from_slice(&[a, b, cone_tip_v]);
233    }
234
235    // Cone base cap (flat, faces -Y).
236    let cone_cap_base = verts.len() as u32;
237    for i in 0..segments {
238        let angle = 2.0 * std::f32::consts::PI * (i as f32) / (segments as f32);
239        let (s, c) = angle.sin_cos();
240        verts.push(Vertex {
241            position: [c * cone_r, cone_bot, s * cone_r],
242            normal: [0.0, -1.0, 0.0],
243            color: white,
244            uv: [0.0, 0.0],
245            tangent: [0.0, 0.0, 0.0, 1.0],
246        });
247    }
248    let cone_cap_center = verts.len() as u32;
249    verts.push(Vertex {
250        position: [0.0, cone_bot, 0.0],
251        normal: [0.0, -1.0, 0.0],
252        color: white,
253        uv: [0.0, 0.0],
254        tangent: [0.0, 0.0, 0.0, 1.0],
255    });
256    for i in 0..segments {
257        let a = cone_cap_base + i as u32;
258        let b = cone_cap_base + ((i + 1) % segments) as u32;
259        idx.extend_from_slice(&[cone_cap_center, a, b]);
260    }
261
262    (verts, idx)
263}
264
265// ---------------------------------------------------------------------------
266// Procedural icosphere (2 subdivisions, ~240 triangles)
267// ---------------------------------------------------------------------------
268
269/// Generate a unit sphere as an icosphere with 2 subdivisions.
270///
271/// Starts from a regular icosahedron and subdivides each triangle 2×.
272pub(super) fn build_glyph_sphere() -> (Vec<Vertex>, Vec<u32>) {
273    let white = [1.0f32, 1.0, 1.0, 1.0];
274
275    // Icosahedron constants.
276    let t = (1.0 + 5.0f32.sqrt()) / 2.0;
277
278    // 12 vertices of a regular icosahedron (not yet normalised).
279    let raw_verts = [
280        [-1.0, t, 0.0],
281        [1.0, t, 0.0],
282        [-1.0, -t, 0.0],
283        [1.0, -t, 0.0],
284        [0.0, -1.0, t],
285        [0.0, 1.0, t],
286        [0.0, -1.0, -t],
287        [0.0, 1.0, -t],
288        [t, 0.0, -1.0],
289        [t, 0.0, 1.0],
290        [-t, 0.0, -1.0],
291        [-t, 0.0, 1.0],
292    ];
293
294    let mut positions: Vec<[f32; 3]> = raw_verts
295        .iter()
296        .map(|v| {
297            let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
298            [v[0] / l, v[1] / l, v[2] / l]
299        })
300        .collect();
301
302    // 20 base triangles.
303    let mut triangles: Vec<[usize; 3]> = vec![
304        [0, 11, 5],
305        [0, 5, 1],
306        [0, 1, 7],
307        [0, 7, 10],
308        [0, 10, 11],
309        [1, 5, 9],
310        [5, 11, 4],
311        [11, 10, 2],
312        [10, 7, 6],
313        [7, 1, 8],
314        [3, 9, 4],
315        [3, 4, 2],
316        [3, 2, 6],
317        [3, 6, 8],
318        [3, 8, 9],
319        [4, 9, 5],
320        [2, 4, 11],
321        [6, 2, 10],
322        [8, 6, 7],
323        [9, 8, 1],
324    ];
325
326    // Subdivide 2 times.
327    for _ in 0..2 {
328        let mut mid_cache: std::collections::HashMap<(usize, usize), usize> =
329            std::collections::HashMap::new();
330        let mut new_triangles: Vec<[usize; 3]> = Vec::with_capacity(triangles.len() * 4);
331
332        let midpoint = |positions: &mut Vec<[f32; 3]>,
333                        a: usize,
334                        b: usize,
335                        cache: &mut std::collections::HashMap<(usize, usize), usize>|
336         -> usize {
337            let key = if a < b { (a, b) } else { (b, a) };
338            if let Some(&idx) = cache.get(&key) {
339                return idx;
340            }
341            let pa = positions[a];
342            let pb = positions[b];
343            let mx = (pa[0] + pb[0]) * 0.5;
344            let my = (pa[1] + pb[1]) * 0.5;
345            let mz = (pa[2] + pb[2]) * 0.5;
346            let l = (mx * mx + my * my + mz * mz).sqrt();
347            let idx = positions.len();
348            positions.push([mx / l, my / l, mz / l]);
349            cache.insert(key, idx);
350            idx
351        };
352
353        for tri in &triangles {
354            let a = tri[0];
355            let b = tri[1];
356            let c = tri[2];
357            let ab = midpoint(&mut positions, a, b, &mut mid_cache);
358            let bc = midpoint(&mut positions, b, c, &mut mid_cache);
359            let ca = midpoint(&mut positions, c, a, &mut mid_cache);
360            new_triangles.push([a, ab, ca]);
361            new_triangles.push([b, bc, ab]);
362            new_triangles.push([c, ca, bc]);
363            new_triangles.push([ab, bc, ca]);
364        }
365        triangles = new_triangles;
366    }
367
368    let verts: Vec<Vertex> = positions
369        .iter()
370        .map(|&p| Vertex {
371            position: p,
372            normal: p, // unit sphere: position = normal
373            color: white,
374            uv: [0.0, 0.0],
375            tangent: [0.0, 0.0, 0.0, 1.0],
376        })
377        .collect();
378
379    let idx: Vec<u32> = triangles
380        .iter()
381        .flat_map(|t| [t[0] as u32, t[1] as u32, t[2] as u32])
382        .collect();
383
384    (verts, idx)
385}
386
387// ---------------------------------------------------------------------------
388// Attribute interpolation utilities
389// ---------------------------------------------------------------------------
390
391// ---------------------------------------------------------------------------
392// Phase G — in-place attribute hot-swap
393// ---------------------------------------------------------------------------
394
395impl ViewportGpuResources {
396    /// Write new scalar data into an existing attribute buffer in-place.
397    ///
398    /// No GPU buffer reallocation, no mesh re-upload, no bind group rebuild is
399    /// required. The attribute bind group *will* be rebuilt on the next
400    /// `prepare()` call if the scalar range changes (tracked via `last_tex_key`).
401    ///
402    /// # Errors
403    ///
404    /// - [`ViewportError::MeshSlotEmpty`](crate::error::ViewportError::MeshSlotEmpty) — `mesh_id` not found in the store.
405    /// - [`ViewportError::AttributeNotFound`](crate::error::ViewportError::AttributeNotFound) — `name` not present on the mesh.
406    /// - [`ViewportError::AttributeLengthMismatch`](crate::error::ViewportError::AttributeLengthMismatch) — `data.len()` differs from
407    ///   the original upload (same-topology requirement).
408    pub fn replace_attribute(
409        &mut self,
410        queue: &wgpu::Queue,
411        mesh_id: crate::resources::mesh_store::MeshId,
412        name: &str,
413        data: &[f32],
414    ) -> crate::error::ViewportResult<()> {
415        // Resolve the mesh.
416        let gpu_mesh =
417            self.mesh_store
418                .get_mut(mesh_id)
419                .ok_or(crate::error::ViewportError::MeshSlotEmpty {
420                    index: mesh_id.index(),
421                })?;
422
423        // Find the existing attribute buffer.
424        let buffer = gpu_mesh.attribute_buffers.get(name).ok_or_else(|| {
425            crate::error::ViewportError::AttributeNotFound {
426                mesh_id: mesh_id.index(),
427                name: name.to_string(),
428            }
429        })?;
430
431        // Validate same topology (buffer size must match).
432        let expected_elems = (buffer.size() / 4) as usize;
433        if data.len() != expected_elems {
434            return Err(crate::error::ViewportError::AttributeLengthMismatch {
435                expected: expected_elems,
436                got: data.len(),
437            });
438        }
439
440        // Zero-copy in-place write via the wgpu staging belt.
441        queue.write_buffer(buffer, 0, bytemuck::cast_slice(data));
442
443        // Recompute scalar range so LUT mapping stays accurate.
444        let (min, max) = data
445            .iter()
446            .fold((f32::MAX, f32::MIN), |(mn, mx), &v| (mn.min(v), mx.max(v)));
447        let range = if min > max { (0.0, 1.0) } else { (min, max) };
448        gpu_mesh.attribute_ranges.insert(name.to_string(), range);
449
450        // Force bind group rebuild on next prepare() by invalidating the key.
451        gpu_mesh.last_tex_key = (
452            gpu_mesh.last_tex_key.0,
453            gpu_mesh.last_tex_key.1,
454            gpu_mesh.last_tex_key.2,
455            gpu_mesh.last_tex_key.3,
456            u64::MAX, // attribute hash component
457        );
458
459        Ok(())
460    }
461
462    /// Create a camera bind group (group 0) for the given per-viewport buffers.
463    ///
464    /// Per-viewport buffers (camera, clip planes, shadow info, clip volume) are
465    /// passed explicitly. Scene-global resources (lights, shadow atlas, IBL) come
466    /// from shared resources on `self`.
467    ///
468    /// NOTE: The initial bind group in `init.rs` is constructed inline (before
469    /// `Self` exists). Keep the binding layout in sync when modifying either site.
470    pub(crate) fn create_camera_bind_group(
471        &self,
472        device: &wgpu::Device,
473        camera_buf: &wgpu::Buffer,
474        clip_planes_buf: &wgpu::Buffer,
475        shadow_info_buf: &wgpu::Buffer,
476        clip_volume_buf: &wgpu::Buffer,
477        label: &str,
478    ) -> wgpu::BindGroup {
479        let irr = self
480            .ibl_irradiance_view
481            .as_ref()
482            .unwrap_or(&self.ibl_fallback_view);
483        let spec = self
484            .ibl_prefiltered_view
485            .as_ref()
486            .unwrap_or(&self.ibl_fallback_view);
487        let brdf = self
488            .ibl_brdf_lut_view
489            .as_ref()
490            .unwrap_or(&self.ibl_fallback_brdf_view);
491        let skybox = self
492            .ibl_skybox_view
493            .as_ref()
494            .unwrap_or(&self.ibl_fallback_view);
495
496        device.create_bind_group(&wgpu::BindGroupDescriptor {
497            label: Some(label),
498            layout: &self.camera_bind_group_layout,
499            entries: &[
500                wgpu::BindGroupEntry {
501                    binding: 0,
502                    resource: camera_buf.as_entire_binding(),
503                },
504                wgpu::BindGroupEntry {
505                    binding: 1,
506                    resource: wgpu::BindingResource::TextureView(&self.shadow_map_view),
507                },
508                wgpu::BindGroupEntry {
509                    binding: 2,
510                    resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
511                },
512                wgpu::BindGroupEntry {
513                    binding: 3,
514                    resource: self.light_uniform_buf.as_entire_binding(),
515                },
516                wgpu::BindGroupEntry {
517                    binding: 4,
518                    resource: clip_planes_buf.as_entire_binding(),
519                },
520                wgpu::BindGroupEntry {
521                    binding: 5,
522                    resource: shadow_info_buf.as_entire_binding(),
523                },
524                wgpu::BindGroupEntry {
525                    binding: 6,
526                    resource: clip_volume_buf.as_entire_binding(),
527                },
528                wgpu::BindGroupEntry {
529                    binding: 7,
530                    resource: wgpu::BindingResource::TextureView(irr),
531                },
532                wgpu::BindGroupEntry {
533                    binding: 8,
534                    resource: wgpu::BindingResource::TextureView(spec),
535                },
536                wgpu::BindGroupEntry {
537                    binding: 9,
538                    resource: wgpu::BindingResource::TextureView(brdf),
539                },
540                wgpu::BindGroupEntry {
541                    binding: 10,
542                    resource: wgpu::BindingResource::Sampler(&self.ibl_sampler),
543                },
544                wgpu::BindGroupEntry {
545                    binding: 11,
546                    resource: wgpu::BindingResource::TextureView(skybox),
547                },
548            ],
549        })
550    }
551}
552
553// ---------------------------------------------------------------------------
554// Phase G — GPU compute filter pipeline and dispatch
555// ---------------------------------------------------------------------------
556
557/// Output from a single GPU compute filter dispatch.
558///
559/// Contains a compacted index buffer (triangles that passed the filter)
560/// and the count of valid indices. The renderer swaps this in during draw.
561pub struct ComputeFilterResult {
562    /// Output index buffer containing only passing triangles.
563    pub index_buffer: wgpu::Buffer,
564    /// Number of valid indices in `index_buffer` (may be 0 if all filtered).
565    pub index_count: u32,
566    /// Mesh index this result corresponds to.
567    pub mesh_index: usize,
568}
569
570impl ViewportGpuResources {
571    /// Lazily create the GPU compute filter pipeline on first use.
572    fn ensure_compute_filter_pipeline(&mut self, device: &wgpu::Device) {
573        if self.compute_filter_pipeline.is_some() {
574            return;
575        }
576
577        // Build bind group layout.
578        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
579            label: Some("compute_filter_bgl"),
580            entries: &[
581                // binding 0: params uniform
582                wgpu::BindGroupLayoutEntry {
583                    binding: 0,
584                    visibility: wgpu::ShaderStages::COMPUTE,
585                    ty: wgpu::BindingType::Buffer {
586                        ty: wgpu::BufferBindingType::Uniform,
587                        has_dynamic_offset: false,
588                        min_binding_size: None,
589                    },
590                    count: None,
591                },
592                // binding 1: vertices (f32 storage, read)
593                wgpu::BindGroupLayoutEntry {
594                    binding: 1,
595                    visibility: wgpu::ShaderStages::COMPUTE,
596                    ty: wgpu::BindingType::Buffer {
597                        ty: wgpu::BufferBindingType::Storage { read_only: true },
598                        has_dynamic_offset: false,
599                        min_binding_size: None,
600                    },
601                    count: None,
602                },
603                // binding 2: source indices (u32 storage, read)
604                wgpu::BindGroupLayoutEntry {
605                    binding: 2,
606                    visibility: wgpu::ShaderStages::COMPUTE,
607                    ty: wgpu::BindingType::Buffer {
608                        ty: wgpu::BufferBindingType::Storage { read_only: true },
609                        has_dynamic_offset: false,
610                        min_binding_size: None,
611                    },
612                    count: None,
613                },
614                // binding 3: scalars (f32 storage, read) — dummy for Clip
615                wgpu::BindGroupLayoutEntry {
616                    binding: 3,
617                    visibility: wgpu::ShaderStages::COMPUTE,
618                    ty: wgpu::BindingType::Buffer {
619                        ty: wgpu::BufferBindingType::Storage { read_only: true },
620                        has_dynamic_offset: false,
621                        min_binding_size: None,
622                    },
623                    count: None,
624                },
625                // binding 4: output compacted indices (read_write)
626                wgpu::BindGroupLayoutEntry {
627                    binding: 4,
628                    visibility: wgpu::ShaderStages::COMPUTE,
629                    ty: wgpu::BindingType::Buffer {
630                        ty: wgpu::BufferBindingType::Storage { read_only: false },
631                        has_dynamic_offset: false,
632                        min_binding_size: None,
633                    },
634                    count: None,
635                },
636                // binding 5: atomic counter (read_write)
637                wgpu::BindGroupLayoutEntry {
638                    binding: 5,
639                    visibility: wgpu::ShaderStages::COMPUTE,
640                    ty: wgpu::BindingType::Buffer {
641                        ty: wgpu::BufferBindingType::Storage { read_only: false },
642                        has_dynamic_offset: false,
643                        min_binding_size: None,
644                    },
645                    count: None,
646                },
647            ],
648        });
649
650        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
651            label: Some("compute_filter_layout"),
652            bind_group_layouts: &[&bgl],
653            push_constant_ranges: &[],
654        });
655
656        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
657            label: Some("compute_filter_shader"),
658            source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/compute_filter.wgsl").into()),
659        });
660
661        let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
662            label: Some("compute_filter_pipeline"),
663            layout: Some(&pipeline_layout),
664            module: &shader,
665            entry_point: Some("main"),
666            compilation_options: Default::default(),
667            cache: None,
668        });
669
670        self.compute_filter_bgl = Some(bgl);
671        self.compute_filter_pipeline = Some(pipeline);
672    }
673
674    // -----------------------------------------------------------------------
675    // Phase J: OIT (order-independent transparency) resource management
676    // -----------------------------------------------------------------------
677
678    /// Ensure OIT accum/reveal textures, pipelines, and composite bind group exist
679    /// for the given viewport size.  Call once per frame before the OIT pass.
680    ///
681    /// Early-returns immediately if the size is unchanged and all resources are present.
682    #[allow(dead_code)]
683    pub(crate) fn ensure_oit_targets(&mut self, device: &wgpu::Device, w: u32, h: u32) {
684        let w = w.max(1);
685        let h = h.max(1);
686
687        // Only recreate textures and the composite bind group when size changes.
688        let need_textures = self.oit_size != [w, h] || self.oit_accum_texture.is_none();
689
690        if need_textures {
691            self.oit_size = [w, h];
692
693            // Accum texture: Rgba16Float for accumulation of weighted color+alpha.
694            let accum_tex = device.create_texture(&wgpu::TextureDescriptor {
695                label: Some("oit_accum_texture"),
696                size: wgpu::Extent3d {
697                    width: w,
698                    height: h,
699                    depth_or_array_layers: 1,
700                },
701                mip_level_count: 1,
702                sample_count: 1,
703                dimension: wgpu::TextureDimension::D2,
704                format: wgpu::TextureFormat::Rgba16Float,
705                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
706                    | wgpu::TextureUsages::TEXTURE_BINDING,
707                view_formats: &[],
708            });
709            let accum_view = accum_tex.create_view(&wgpu::TextureViewDescriptor::default());
710
711            // Reveal texture: R8Unorm for transmittance accumulation.
712            let reveal_tex = device.create_texture(&wgpu::TextureDescriptor {
713                label: Some("oit_reveal_texture"),
714                size: wgpu::Extent3d {
715                    width: w,
716                    height: h,
717                    depth_or_array_layers: 1,
718                },
719                mip_level_count: 1,
720                sample_count: 1,
721                dimension: wgpu::TextureDimension::D2,
722                format: wgpu::TextureFormat::R8Unorm,
723                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
724                    | wgpu::TextureUsages::TEXTURE_BINDING,
725                view_formats: &[],
726            });
727            let reveal_view = reveal_tex.create_view(&wgpu::TextureViewDescriptor::default());
728
729            // Create or reuse the OIT sampler.
730            let sampler = if self.oit_composite_sampler.is_none() {
731                device.create_sampler(&wgpu::SamplerDescriptor {
732                    label: Some("oit_composite_sampler"),
733                    address_mode_u: wgpu::AddressMode::ClampToEdge,
734                    address_mode_v: wgpu::AddressMode::ClampToEdge,
735                    address_mode_w: wgpu::AddressMode::ClampToEdge,
736                    mag_filter: wgpu::FilterMode::Linear,
737                    min_filter: wgpu::FilterMode::Linear,
738                    ..Default::default()
739                })
740            } else {
741                // We can't move out of self here, so create a new one.
742                device.create_sampler(&wgpu::SamplerDescriptor {
743                    label: Some("oit_composite_sampler"),
744                    address_mode_u: wgpu::AddressMode::ClampToEdge,
745                    address_mode_v: wgpu::AddressMode::ClampToEdge,
746                    address_mode_w: wgpu::AddressMode::ClampToEdge,
747                    mag_filter: wgpu::FilterMode::Linear,
748                    min_filter: wgpu::FilterMode::Linear,
749                    ..Default::default()
750                })
751            };
752
753            // Create BGL once.
754            let bgl = if self.oit_composite_bgl.is_none() {
755                let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
756                    label: Some("oit_composite_bgl"),
757                    entries: &[
758                        wgpu::BindGroupLayoutEntry {
759                            binding: 0,
760                            visibility: wgpu::ShaderStages::FRAGMENT,
761                            ty: wgpu::BindingType::Texture {
762                                sample_type: wgpu::TextureSampleType::Float { filterable: true },
763                                view_dimension: wgpu::TextureViewDimension::D2,
764                                multisampled: false,
765                            },
766                            count: None,
767                        },
768                        wgpu::BindGroupLayoutEntry {
769                            binding: 1,
770                            visibility: wgpu::ShaderStages::FRAGMENT,
771                            ty: wgpu::BindingType::Texture {
772                                sample_type: wgpu::TextureSampleType::Float { filterable: true },
773                                view_dimension: wgpu::TextureViewDimension::D2,
774                                multisampled: false,
775                            },
776                            count: None,
777                        },
778                        wgpu::BindGroupLayoutEntry {
779                            binding: 2,
780                            visibility: wgpu::ShaderStages::FRAGMENT,
781                            ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
782                            count: None,
783                        },
784                    ],
785                });
786                self.oit_composite_bgl = Some(bgl);
787                self.oit_composite_bgl.as_ref().unwrap()
788            } else {
789                self.oit_composite_bgl.as_ref().unwrap()
790            };
791
792            // Composite bind group referencing the new texture views.
793            let composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
794                label: Some("oit_composite_bind_group"),
795                layout: bgl,
796                entries: &[
797                    wgpu::BindGroupEntry {
798                        binding: 0,
799                        resource: wgpu::BindingResource::TextureView(&accum_view),
800                    },
801                    wgpu::BindGroupEntry {
802                        binding: 1,
803                        resource: wgpu::BindingResource::TextureView(&reveal_view),
804                    },
805                    wgpu::BindGroupEntry {
806                        binding: 2,
807                        resource: wgpu::BindingResource::Sampler(&sampler),
808                    },
809                ],
810            });
811
812            self.oit_accum_texture = Some(accum_tex);
813            self.oit_accum_view = Some(accum_view);
814            self.oit_reveal_texture = Some(reveal_tex);
815            self.oit_reveal_view = Some(reveal_view);
816            self.oit_composite_sampler = Some(sampler);
817            self.oit_composite_bind_group = Some(composite_bg);
818        }
819
820        // Create pipelines once (they don't depend on viewport size).
821        if self.oit_pipeline.is_none() {
822            // Non-instanced OIT pipeline (mesh_oit.wgsl, group 0 = camera BGL, group 1 = object BGL).
823            let oit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
824                label: Some("mesh_oit_shader"),
825                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/mesh_oit.wgsl").into()),
826            });
827            let oit_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
828                label: Some("oit_pipeline_layout"),
829                bind_group_layouts: &[
830                    &self.camera_bind_group_layout,
831                    &self.object_bind_group_layout,
832                ],
833                push_constant_ranges: &[],
834            });
835
836            // Accum blend: src=One, dst=One, Add (additive accumulation).
837            let accum_blend = wgpu::BlendState {
838                color: wgpu::BlendComponent {
839                    src_factor: wgpu::BlendFactor::One,
840                    dst_factor: wgpu::BlendFactor::One,
841                    operation: wgpu::BlendOperation::Add,
842                },
843                alpha: wgpu::BlendComponent {
844                    src_factor: wgpu::BlendFactor::One,
845                    dst_factor: wgpu::BlendFactor::One,
846                    operation: wgpu::BlendOperation::Add,
847                },
848            };
849
850            // Reveal blend: src=Zero, dst=OneMinusSrcColor (multiplicative transmittance).
851            let reveal_blend = wgpu::BlendState {
852                color: wgpu::BlendComponent {
853                    src_factor: wgpu::BlendFactor::Zero,
854                    dst_factor: wgpu::BlendFactor::OneMinusSrc,
855                    operation: wgpu::BlendOperation::Add,
856                },
857                alpha: wgpu::BlendComponent {
858                    src_factor: wgpu::BlendFactor::Zero,
859                    dst_factor: wgpu::BlendFactor::OneMinusSrc,
860                    operation: wgpu::BlendOperation::Add,
861                },
862            };
863
864            let oit_depth_stencil = wgpu::DepthStencilState {
865                format: wgpu::TextureFormat::Depth24PlusStencil8,
866                depth_write_enabled: false,
867                depth_compare: wgpu::CompareFunction::LessEqual,
868                stencil: wgpu::StencilState::default(),
869                bias: wgpu::DepthBiasState::default(),
870            };
871
872            let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
873                label: Some("oit_pipeline"),
874                layout: Some(&oit_layout),
875                vertex: wgpu::VertexState {
876                    module: &oit_shader,
877                    entry_point: Some("vs_main"),
878                    buffers: &[Vertex::buffer_layout()],
879                    compilation_options: wgpu::PipelineCompilationOptions::default(),
880                },
881                fragment: Some(wgpu::FragmentState {
882                    module: &oit_shader,
883                    entry_point: Some("fs_oit_main"),
884                    targets: &[
885                        Some(wgpu::ColorTargetState {
886                            format: wgpu::TextureFormat::Rgba16Float,
887                            blend: Some(accum_blend),
888                            write_mask: wgpu::ColorWrites::ALL,
889                        }),
890                        Some(wgpu::ColorTargetState {
891                            format: wgpu::TextureFormat::R8Unorm,
892                            blend: Some(reveal_blend),
893                            write_mask: wgpu::ColorWrites::RED,
894                        }),
895                    ],
896                    compilation_options: wgpu::PipelineCompilationOptions::default(),
897                }),
898                primitive: wgpu::PrimitiveState {
899                    topology: wgpu::PrimitiveTopology::TriangleList,
900                    cull_mode: Some(wgpu::Face::Back),
901                    ..Default::default()
902                },
903                depth_stencil: Some(oit_depth_stencil.clone()),
904                multisample: wgpu::MultisampleState {
905                    count: 1,
906                    ..Default::default()
907                },
908                multiview: None,
909                cache: None,
910            });
911            self.oit_pipeline = Some(pipeline);
912
913            // Instanced OIT pipeline (mesh_instanced_oit.wgsl, two OIT targets).
914            if let Some(ref instance_bgl) = self.instance_bind_group_layout {
915                let instanced_oit_shader =
916                    device.create_shader_module(wgpu::ShaderModuleDescriptor {
917                        label: Some("mesh_instanced_oit_shader"),
918                        source: wgpu::ShaderSource::Wgsl(
919                            include_str!("../shaders/mesh_instanced_oit.wgsl").into(),
920                        ),
921                    });
922                let instanced_oit_layout =
923                    device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
924                        label: Some("oit_instanced_pipeline_layout"),
925                        bind_group_layouts: &[&self.camera_bind_group_layout, instance_bgl],
926                        push_constant_ranges: &[],
927                    });
928                let instanced_pipeline =
929                    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
930                        label: Some("oit_instanced_pipeline"),
931                        layout: Some(&instanced_oit_layout),
932                        vertex: wgpu::VertexState {
933                            module: &instanced_oit_shader,
934                            entry_point: Some("vs_main"),
935                            buffers: &[Vertex::buffer_layout()],
936                            compilation_options: wgpu::PipelineCompilationOptions::default(),
937                        },
938                        fragment: Some(wgpu::FragmentState {
939                            module: &instanced_oit_shader,
940                            entry_point: Some("fs_oit_main"),
941                            targets: &[
942                                Some(wgpu::ColorTargetState {
943                                    format: wgpu::TextureFormat::Rgba16Float,
944                                    blend: Some(accum_blend),
945                                    write_mask: wgpu::ColorWrites::ALL,
946                                }),
947                                Some(wgpu::ColorTargetState {
948                                    format: wgpu::TextureFormat::R8Unorm,
949                                    blend: Some(reveal_blend),
950                                    write_mask: wgpu::ColorWrites::RED,
951                                }),
952                            ],
953                            compilation_options: wgpu::PipelineCompilationOptions::default(),
954                        }),
955                        primitive: wgpu::PrimitiveState {
956                            topology: wgpu::PrimitiveTopology::TriangleList,
957                            cull_mode: Some(wgpu::Face::Back),
958                            ..Default::default()
959                        },
960                        depth_stencil: Some(oit_depth_stencil),
961                        multisample: wgpu::MultisampleState {
962                            count: 1,
963                            ..Default::default()
964                        },
965                        multiview: None,
966                        cache: None,
967                    });
968                self.oit_instanced_pipeline = Some(instanced_pipeline);
969            }
970        }
971
972        if self.oit_composite_pipeline.is_none() {
973            let comp_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
974                label: Some("oit_composite_shader"),
975                source: wgpu::ShaderSource::Wgsl(
976                    include_str!("../shaders/oit_composite.wgsl").into(),
977                ),
978            });
979            let bgl = self
980                .oit_composite_bgl
981                .as_ref()
982                .expect("oit_composite_bgl must exist");
983            let comp_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
984                label: Some("oit_composite_pipeline_layout"),
985                bind_group_layouts: &[bgl],
986                push_constant_ranges: &[],
987            });
988            // Premultiplied alpha blend: One / OneMinusSrcAlpha — composites avg_color*(1-r) onto HDR.
989            let premul_blend = wgpu::BlendState {
990                color: wgpu::BlendComponent {
991                    src_factor: wgpu::BlendFactor::One,
992                    dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
993                    operation: wgpu::BlendOperation::Add,
994                },
995                alpha: wgpu::BlendComponent {
996                    src_factor: wgpu::BlendFactor::One,
997                    dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
998                    operation: wgpu::BlendOperation::Add,
999                },
1000            };
1001            let comp_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1002                label: Some("oit_composite_pipeline"),
1003                layout: Some(&comp_layout),
1004                vertex: wgpu::VertexState {
1005                    module: &comp_shader,
1006                    entry_point: Some("vs_main"),
1007                    buffers: &[],
1008                    compilation_options: wgpu::PipelineCompilationOptions::default(),
1009                },
1010                fragment: Some(wgpu::FragmentState {
1011                    module: &comp_shader,
1012                    entry_point: Some("fs_main"),
1013                    targets: &[Some(wgpu::ColorTargetState {
1014                        format: wgpu::TextureFormat::Rgba16Float,
1015                        blend: Some(premul_blend),
1016                        write_mask: wgpu::ColorWrites::ALL,
1017                    })],
1018                    compilation_options: wgpu::PipelineCompilationOptions::default(),
1019                }),
1020                primitive: wgpu::PrimitiveState {
1021                    topology: wgpu::PrimitiveTopology::TriangleList,
1022                    ..Default::default()
1023                },
1024                depth_stencil: None,
1025                multisample: wgpu::MultisampleState {
1026                    count: 1,
1027                    ..Default::default()
1028                },
1029                multiview: None,
1030                cache: None,
1031            });
1032            self.oit_composite_pipeline = Some(comp_pipeline);
1033        }
1034    }
1035
1036    /// Dispatch GPU compute filters for all items in the list.
1037    ///
1038    /// Returns one [`ComputeFilterResult`] per item. The renderer uses these
1039    /// during `paint()` to override the mesh's default index buffer.
1040    ///
1041    /// This is a synchronous v1 implementation: it submits each dispatch
1042    /// individually and polls the device to read back the counter. This is
1043    /// acceptable for v1; async readback can be added later.
1044    pub fn run_compute_filters(
1045        &mut self,
1046        device: &wgpu::Device,
1047        queue: &wgpu::Queue,
1048        items: &[crate::renderer::ComputeFilterItem],
1049    ) -> Vec<ComputeFilterResult> {
1050        if items.is_empty() {
1051            return Vec::new();
1052        }
1053
1054        self.ensure_compute_filter_pipeline(device);
1055
1056        // Dummy 4-byte buffer used as the scalar binding when doing a Clip filter.
1057        let dummy_scalar_buf = device.create_buffer(&wgpu::BufferDescriptor {
1058            label: Some("compute_filter_dummy_scalar"),
1059            size: 4,
1060            usage: wgpu::BufferUsages::STORAGE,
1061            mapped_at_creation: false,
1062        });
1063
1064        let mut results = Vec::with_capacity(items.len());
1065
1066        for item in items {
1067            // Resolve the mesh.
1068            let gpu_mesh = match self
1069                .mesh_store
1070                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
1071            {
1072                Some(m) => m,
1073                None => continue,
1074            };
1075
1076            let triangle_count = gpu_mesh.index_count / 3;
1077            if triangle_count == 0 {
1078                continue;
1079            }
1080
1081            // Vertex stride: the Vertex struct is 64 bytes = 16 f32s.
1082            const VERTEX_STRIDE_F32: u32 = 16;
1083
1084            // Build params uniform matching compute_filter.wgsl Params struct layout.
1085            #[repr(C)]
1086            #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
1087            struct FilterParams {
1088                mode: u32,
1089                clip_type: u32,
1090                threshold_min: f32,
1091                threshold_max: f32,
1092                triangle_count: u32,
1093                vertex_stride_f32: u32,
1094                _pad: [u32; 2],
1095                // Plane params
1096                plane_nx: f32,
1097                plane_ny: f32,
1098                plane_nz: f32,
1099                plane_dist: f32,
1100                // Box params
1101                box_cx: f32,
1102                box_cy: f32,
1103                box_cz: f32,
1104                _padb0: f32,
1105                box_hex: f32,
1106                box_hey: f32,
1107                box_hez: f32,
1108                _padb1: f32,
1109                box_col0x: f32,
1110                box_col0y: f32,
1111                box_col0z: f32,
1112                _padb2: f32,
1113                box_col1x: f32,
1114                box_col1y: f32,
1115                box_col1z: f32,
1116                _padb3: f32,
1117                box_col2x: f32,
1118                box_col2y: f32,
1119                box_col2z: f32,
1120                _padb4: f32,
1121                // Sphere params
1122                sphere_cx: f32,
1123                sphere_cy: f32,
1124                sphere_cz: f32,
1125                sphere_radius: f32,
1126            }
1127
1128            let mut params: FilterParams = bytemuck::Zeroable::zeroed();
1129            params.triangle_count = triangle_count;
1130            params.vertex_stride_f32 = VERTEX_STRIDE_F32;
1131
1132            match item.kind {
1133                crate::renderer::ComputeFilterKind::Clip {
1134                    plane_normal,
1135                    plane_dist,
1136                } => {
1137                    params.mode = 0;
1138                    params.clip_type = 1;
1139                    params.plane_nx = plane_normal[0];
1140                    params.plane_ny = plane_normal[1];
1141                    params.plane_nz = plane_normal[2];
1142                    params.plane_dist = plane_dist;
1143                }
1144                crate::renderer::ComputeFilterKind::ClipBox {
1145                    center,
1146                    half_extents,
1147                    orientation,
1148                } => {
1149                    params.mode = 0;
1150                    params.clip_type = 2;
1151                    params.box_cx = center[0];
1152                    params.box_cy = center[1];
1153                    params.box_cz = center[2];
1154                    params.box_hex = half_extents[0];
1155                    params.box_hey = half_extents[1];
1156                    params.box_hez = half_extents[2];
1157                    params.box_col0x = orientation[0][0];
1158                    params.box_col0y = orientation[0][1];
1159                    params.box_col0z = orientation[0][2];
1160                    params.box_col1x = orientation[1][0];
1161                    params.box_col1y = orientation[1][1];
1162                    params.box_col1z = orientation[1][2];
1163                    params.box_col2x = orientation[2][0];
1164                    params.box_col2y = orientation[2][1];
1165                    params.box_col2z = orientation[2][2];
1166                }
1167                crate::renderer::ComputeFilterKind::ClipSphere { center, radius } => {
1168                    params.mode = 0;
1169                    params.clip_type = 3;
1170                    params.sphere_cx = center[0];
1171                    params.sphere_cy = center[1];
1172                    params.sphere_cz = center[2];
1173                    params.sphere_radius = radius;
1174                }
1175                crate::renderer::ComputeFilterKind::Threshold { min, max } => {
1176                    params.mode = 1;
1177                    params.threshold_min = min;
1178                    params.threshold_max = max;
1179                }
1180            }
1181
1182            let params_buf = device.create_buffer(&wgpu::BufferDescriptor {
1183                label: Some("compute_filter_params"),
1184                size: std::mem::size_of::<FilterParams>() as u64,
1185                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1186                mapped_at_creation: false,
1187            });
1188            queue.write_buffer(&params_buf, 0, bytemuck::bytes_of(&params));
1189
1190            // Output index buffer (worst-case: all triangles pass).
1191            let out_index_size = (gpu_mesh.index_count as u64) * 4;
1192            let out_index_buf = device.create_buffer(&wgpu::BufferDescriptor {
1193                label: Some("compute_filter_out_indices"),
1194                size: out_index_size.max(4),
1195                usage: wgpu::BufferUsages::STORAGE
1196                    | wgpu::BufferUsages::INDEX
1197                    | wgpu::BufferUsages::COPY_SRC,
1198                mapped_at_creation: false,
1199            });
1200
1201            // 4-byte atomic counter buffer (cleared to 0).
1202            let counter_buf = device.create_buffer(&wgpu::BufferDescriptor {
1203                label: Some("compute_filter_counter"),
1204                size: 4,
1205                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
1206                mapped_at_creation: true,
1207            });
1208            {
1209                let mut view = counter_buf.slice(..).get_mapped_range_mut();
1210                view[0..4].copy_from_slice(&0u32.to_le_bytes());
1211            }
1212            counter_buf.unmap();
1213
1214            // Staging buffer to read back the counter.
1215            let staging_buf = device.create_buffer(&wgpu::BufferDescriptor {
1216                label: Some("compute_filter_counter_staging"),
1217                size: 4,
1218                usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
1219                mapped_at_creation: false,
1220            });
1221
1222            // Pick the scalar buffer: named attribute or dummy.
1223            let scalar_buf_ref: &wgpu::Buffer = match &item.kind {
1224                crate::renderer::ComputeFilterKind::Threshold { .. } => {
1225                    if let Some(attr_name) = &item.attribute_name {
1226                        gpu_mesh
1227                            .attribute_buffers
1228                            .get(attr_name.as_str())
1229                            .unwrap_or(&dummy_scalar_buf)
1230                    } else {
1231                        &dummy_scalar_buf
1232                    }
1233                }
1234                // Clip variants don't use the scalar buffer.
1235                _ => &dummy_scalar_buf,
1236            };
1237
1238            // Build bind group.
1239            let bgl = self.compute_filter_bgl.as_ref().unwrap();
1240            let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1241                label: Some("compute_filter_bg"),
1242                layout: bgl,
1243                entries: &[
1244                    wgpu::BindGroupEntry {
1245                        binding: 0,
1246                        resource: params_buf.as_entire_binding(),
1247                    },
1248                    wgpu::BindGroupEntry {
1249                        binding: 1,
1250                        resource: gpu_mesh.vertex_buffer.as_entire_binding(),
1251                    },
1252                    wgpu::BindGroupEntry {
1253                        binding: 2,
1254                        resource: gpu_mesh.index_buffer.as_entire_binding(),
1255                    },
1256                    wgpu::BindGroupEntry {
1257                        binding: 3,
1258                        resource: scalar_buf_ref.as_entire_binding(),
1259                    },
1260                    wgpu::BindGroupEntry {
1261                        binding: 4,
1262                        resource: out_index_buf.as_entire_binding(),
1263                    },
1264                    wgpu::BindGroupEntry {
1265                        binding: 5,
1266                        resource: counter_buf.as_entire_binding(),
1267                    },
1268                ],
1269            });
1270
1271            // Encode and submit compute + counter copy.
1272            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1273                label: Some("compute_filter_encoder"),
1274            });
1275
1276            {
1277                let pipeline = self.compute_filter_pipeline.as_ref().unwrap();
1278                let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1279                    label: Some("compute_filter_pass"),
1280                    timestamp_writes: None,
1281                });
1282                cpass.set_pipeline(pipeline);
1283                cpass.set_bind_group(0, &bind_group, &[]);
1284                let workgroups = triangle_count.div_ceil(64);
1285                cpass.dispatch_workgroups(workgroups, 1, 1);
1286            }
1287
1288            encoder.copy_buffer_to_buffer(&counter_buf, 0, &staging_buf, 0, 4);
1289            queue.submit(std::iter::once(encoder.finish()));
1290
1291            // Synchronous readback (v1 — acceptable; async readback can follow later).
1292            let slice = staging_buf.slice(..);
1293            slice.map_async(wgpu::MapMode::Read, |_| {});
1294            let _ = device.poll(wgpu::PollType::Wait {
1295                submission_index: None,
1296                timeout: Some(std::time::Duration::from_secs(5)),
1297            });
1298
1299            let index_count = {
1300                let data = slice.get_mapped_range();
1301                u32::from_le_bytes([data[0], data[1], data[2], data[3]])
1302            };
1303            staging_buf.unmap();
1304
1305            results.push(ComputeFilterResult {
1306                index_buffer: out_index_buf,
1307                index_count,
1308                mesh_index: item.mesh_index,
1309            });
1310        }
1311
1312        results
1313    }
1314
1315    // -----------------------------------------------------------------------
1316    // Phase K: GPU object-ID picking pipeline (lazily created)
1317    // -----------------------------------------------------------------------
1318
1319    /// Lazily create the GPU pick pipeline and associated bind group layouts.
1320    ///
1321    /// No-op if already created. Called from `ViewportRenderer::pick_scene_gpu`
1322    /// on first invocation — zero overhead when GPU picking is never used.
1323    pub(crate) fn ensure_pick_pipeline(&mut self, device: &wgpu::Device) {
1324        if self.pick_pipeline.is_some() {
1325            return;
1326        }
1327
1328        // --- group 0: minimal camera-only bind group layout ---
1329        // The pick shader only uses binding 0 (CameraUniform); the full
1330        // camera_bind_group_layout has 6 bindings and would require binding a
1331        // compatible full bind group. A separate minimal layout is cleaner.
1332        let pick_camera_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1333            label: Some("pick_camera_bgl"),
1334            entries: &[wgpu::BindGroupLayoutEntry {
1335                binding: 0,
1336                visibility: wgpu::ShaderStages::VERTEX,
1337                ty: wgpu::BindingType::Buffer {
1338                    ty: wgpu::BufferBindingType::Uniform,
1339                    has_dynamic_offset: false,
1340                    min_binding_size: None,
1341                },
1342                count: None,
1343            }],
1344        });
1345
1346        // --- group 1: PickInstance storage buffer ---
1347        let pick_instance_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1348            label: Some("pick_instance_bgl"),
1349            entries: &[wgpu::BindGroupLayoutEntry {
1350                binding: 0,
1351                visibility: wgpu::ShaderStages::VERTEX,
1352                ty: wgpu::BindingType::Buffer {
1353                    ty: wgpu::BufferBindingType::Storage { read_only: true },
1354                    has_dynamic_offset: false,
1355                    min_binding_size: None,
1356                },
1357                count: None,
1358            }],
1359        });
1360
1361        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1362            label: Some("pick_id_shader"),
1363            source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/pick_id.wgsl").into()),
1364        });
1365
1366        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1367            label: Some("pick_pipeline_layout"),
1368            bind_group_layouts: &[&pick_camera_bgl, &pick_instance_bgl],
1369            push_constant_ranges: &[],
1370        });
1371
1372        // Vertex layout: reuse the 64-byte Vertex stride but only declare position (location 0).
1373        let pick_vertex_layout = wgpu::VertexBufferLayout {
1374            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress, // 64 bytes
1375            step_mode: wgpu::VertexStepMode::Vertex,
1376            attributes: &[wgpu::VertexAttribute {
1377                offset: 0,
1378                shader_location: 0,
1379                format: wgpu::VertexFormat::Float32x3,
1380            }],
1381        };
1382
1383        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1384            label: Some("pick_pipeline"),
1385            layout: Some(&layout),
1386            vertex: wgpu::VertexState {
1387                module: &shader,
1388                entry_point: Some("vs_main"),
1389                buffers: &[pick_vertex_layout],
1390                compilation_options: wgpu::PipelineCompilationOptions::default(),
1391            },
1392            fragment: Some(wgpu::FragmentState {
1393                module: &shader,
1394                entry_point: Some("fs_main"),
1395                targets: &[
1396                    // location 0: R32Uint object ID
1397                    Some(wgpu::ColorTargetState {
1398                        format: wgpu::TextureFormat::R32Uint,
1399                        blend: None, // replace — no blending for integer targets
1400                        write_mask: wgpu::ColorWrites::ALL,
1401                    }),
1402                    // location 1: R32Float depth
1403                    Some(wgpu::ColorTargetState {
1404                        format: wgpu::TextureFormat::R32Float,
1405                        blend: None,
1406                        write_mask: wgpu::ColorWrites::ALL,
1407                    }),
1408                ],
1409                compilation_options: wgpu::PipelineCompilationOptions::default(),
1410            }),
1411            primitive: wgpu::PrimitiveState {
1412                topology: wgpu::PrimitiveTopology::TriangleList,
1413                front_face: wgpu::FrontFace::Ccw,
1414                cull_mode: Some(wgpu::Face::Back),
1415                ..Default::default()
1416            },
1417            depth_stencil: Some(wgpu::DepthStencilState {
1418                format: wgpu::TextureFormat::Depth24PlusStencil8,
1419                depth_write_enabled: true,
1420                depth_compare: wgpu::CompareFunction::Less,
1421                stencil: wgpu::StencilState::default(),
1422                bias: wgpu::DepthBiasState::default(),
1423            }),
1424            multisample: wgpu::MultisampleState {
1425                count: 1, // pick pass is always 1x (no MSAA)
1426                ..Default::default()
1427            },
1428            multiview: None,
1429            cache: None,
1430        });
1431
1432        self.pick_camera_bgl = Some(pick_camera_bgl);
1433        self.pick_bind_group_layout_1 = Some(pick_instance_bgl);
1434        self.pick_pipeline = Some(pipeline);
1435    }
1436}
1437
1438// ---------------------------------------------------------------------------
1439// Attribute interpolation utilities
1440// ---------------------------------------------------------------------------
1441
1442// ---------------------------------------------------------------------------
1443// Streamtube cylinder mesh builder
1444// ---------------------------------------------------------------------------
1445
1446/// Build an 8-sided cylinder mesh for streamtube rendering.
1447///
1448/// The cylinder runs from Y = −1 to Y = +1 (total length 2) with XZ radius = 1.
1449/// The per-instance transform in the shader scales it to `(radius, half_len, radius)`
1450/// and rotates it so the +Y axis aligns with the segment direction.
1451///
1452/// Returns (vertices, indices) ready for upload using the full `Vertex` layout.
1453/// Index count: 96 (32 triangles — 16 for the tube surface, 8+8 for caps).
1454pub(super) fn build_streamtube_cylinder() -> (Vec<Vertex>, Vec<u32>) {
1455    let sides = 8usize;
1456    let white = [1.0f32, 1.0, 1.0, 1.0];
1457
1458    // We need separate vertices for the tube sides (outward normals) and the
1459    // caps (axial normals), so we duplicate the ring positions.
1460    //
1461    // Vertex layout:
1462    //   0..8   — bottom tube ring (y = -1, normals radially outward)
1463    //   8..16  — top    tube ring (y = +1, normals radially outward)
1464    //  16..24  — bottom cap  ring (y = -1, normals (0,-1,0))
1465    //  24..32  — top    cap  ring (y = +1, normals (0,+1,0))
1466    //  32      — bottom center    (y = -1, normal (0,-1,0))
1467    //  33      — top    center    (y = +1, normal (0,+1,0))
1468    //  Total: 34 vertices
1469
1470    let mut verts: Vec<Vertex> = Vec::with_capacity(34);
1471    let mut indices: Vec<u32> = Vec::with_capacity(96);
1472
1473    let zero_uv = [0.0f32, 0.0];
1474    let zero_tan = [1.0f32, 0.0, 0.0, 1.0];
1475
1476    // ---- ring vertices ----
1477    for ring in 0..2 {
1478        let y = if ring == 0 { -1.0f32 } else { 1.0 };
1479        for s in 0..sides {
1480            let angle = (s as f32) * std::f32::consts::TAU / (sides as f32);
1481            let (sin_a, cos_a) = angle.sin_cos();
1482            // tube ring — outward normals
1483            verts.push(Vertex {
1484                position: [cos_a, y, sin_a],
1485                normal: [cos_a, 0.0, sin_a],
1486                color: white,
1487                uv: zero_uv,
1488                tangent: zero_tan,
1489            });
1490        }
1491    }
1492    // cap rings (same XZ positions, axial normals)
1493    for ring in 0..2 {
1494        let y = if ring == 0 { -1.0f32 } else { 1.0 };
1495        let ny = if ring == 0 { -1.0f32 } else { 1.0 };
1496        for s in 0..sides {
1497            let angle = (s as f32) * std::f32::consts::TAU / (sides as f32);
1498            let (sin_a, cos_a) = angle.sin_cos();
1499            verts.push(Vertex {
1500                position: [cos_a, y, sin_a],
1501                normal: [0.0, ny, 0.0],
1502                color: white,
1503                uv: zero_uv,
1504                tangent: zero_tan,
1505            });
1506        }
1507    }
1508    // cap centers
1509    verts.push(Vertex {
1510        position: [0.0, -1.0, 0.0],
1511        normal: [0.0, -1.0, 0.0],
1512        color: white,
1513        uv: zero_uv,
1514        tangent: zero_tan,
1515    });
1516    verts.push(Vertex {
1517        position: [0.0, 1.0, 0.0],
1518        normal: [0.0, 1.0, 0.0],
1519        color: white,
1520        uv: zero_uv,
1521        tangent: zero_tan,
1522    });
1523
1524    let bottom_tube = 0u32;
1525    let top_tube = sides as u32;
1526    let bottom_cap = (sides * 2) as u32;
1527    let top_cap = (sides * 3) as u32;
1528    let bot_center = (sides * 4) as u32;
1529    let top_center = (sides * 4 + 1) as u32;
1530
1531    // ---- tube side quads: 2 triangles per quad, 8 quads = 48 indices ----
1532    for s in 0..sides as u32 {
1533        let next = (s + 1) % sides as u32;
1534        let b0 = bottom_tube + s;
1535        let b1 = bottom_tube + next;
1536        let t0 = top_tube + s;
1537        let t1 = top_tube + next;
1538        // CCW winding viewed from outside.
1539        indices.extend_from_slice(&[b0, t0, b1]);
1540        indices.extend_from_slice(&[b1, t0, t1]);
1541    }
1542
1543    // ---- bottom cap (fan from bot_center): 8 triangles = 24 indices ----
1544    for s in 0..sides as u32 {
1545        let next = (s + 1) % sides as u32;
1546        let c0 = bottom_cap + s;
1547        let c1 = bottom_cap + next;
1548        // CCW when viewed from below (looking up +Y, normal is -Y).
1549        indices.extend_from_slice(&[bot_center, c1, c0]);
1550    }
1551
1552    // ---- top cap (fan from top_center): 8 triangles = 24 indices ----
1553    for s in 0..sides as u32 {
1554        let next = (s + 1) % sides as u32;
1555        let c0 = top_cap + s;
1556        let c1 = top_cap + next;
1557        // CCW when viewed from above (normal +Y).
1558        indices.extend_from_slice(&[top_center, c0, c1]);
1559    }
1560
1561    (verts, indices)
1562}
1563
1564/// Linearly interpolate between two attribute buffers element-wise.
1565///
1566/// Both slices must have the same length. `t` is clamped to `[0.0, 1.0]`.
1567/// Returns a new `Vec<f32>` with `a[i] * (1 - t) + b[i] * t`.
1568///
1569/// Use this to blend per-vertex scalar attributes between two consecutive
1570/// timesteps when scrubbing the timeline at sub-frame resolution.
1571pub fn lerp_attributes(a: &[f32], b: &[f32], t: f32) -> Vec<f32> {
1572    let t = t.clamp(0.0, 1.0);
1573    let one_minus_t = 1.0 - t;
1574    a.iter()
1575        .zip(b.iter())
1576        .map(|(&av, &bv)| av * one_minus_t + bv * t)
1577        .collect()
1578}