Skip to main content

viewport_lib/resources/scivis/
glyph.rs

1use super::*;
2
3/// Arrow/sphere/cube glyph pipelines, layouts, and cached base meshes.
4/// All lazily built; the uploaded glyph sets live in a separate flat store.
5#[derive(Default)]
6pub(crate) struct GlyphResources {
7    /// Glyph render pipeline. None until first glyph set is submitted.
8    pub(crate) pipeline: Option<DualPipeline>,
9    /// Glyph wireframe pipeline (LineList, same bind groups as `pipeline`).
10    pub(crate) wireframe_pipeline: Option<DualPipeline>,
11    /// Bind group layout for glyph uniforms (group 1).
12    pub(crate) bgl: Option<wgpu::BindGroupLayout>,
13    /// Bind group layout for glyph instance storage (group 2).
14    pub(crate) instance_bgl: Option<wgpu::BindGroupLayout>,
15    /// Cached glyph base mesh for the Arrow shape.
16    pub(crate) arrow_mesh: Option<GlyphBaseMesh>,
17    /// Cached glyph base mesh for the Sphere shape.
18    pub(crate) sphere_mesh: Option<GlyphBaseMesh>,
19    /// Cached glyph base mesh for the Cube shape.
20    pub(crate) cube_mesh: Option<GlyphBaseMesh>,
21    /// Instanced mask pipeline for arrow/sphere glyph outlines.
22    pub(crate) outline_mask_pipeline: Option<wgpu::RenderPipeline>,
23}
24
25/// Tensor glyph (ellipsoid / superquadric) pipelines and layouts.
26#[derive(Default)]
27pub(crate) struct TensorGlyphResources {
28    /// Tensor glyph render pipeline. None until first tensor glyph set is submitted.
29    pub(crate) pipeline: Option<DualPipeline>,
30    /// Tensor glyph wireframe pipeline (LineList, same bind groups as `pipeline`).
31    pub(crate) wireframe_pipeline: Option<DualPipeline>,
32    /// Bind group layout for tensor glyph uniforms (group 1).
33    pub(crate) bgl: Option<wgpu::BindGroupLayout>,
34    /// Bind group layout for tensor glyph instance storage (group 2).
35    pub(crate) instance_bgl: Option<wgpu::BindGroupLayout>,
36    /// Instanced mask pipeline for tensor glyph outlines.
37    pub(crate) outline_mask_pipeline: Option<wgpu::RenderPipeline>,
38}
39
40impl DeviceResources {
41    /// Lazily create the glyph render pipeline (instanced TriangleList).
42    ///
43    /// No-op if already created. Called from `prepare()` when `frame.scene.glyphs` is non-empty.
44    pub(crate) fn ensure_glyph_pipeline(&mut self, device: &wgpu::Device) {
45        if self.glyph.pipeline.is_some() {
46            return;
47        }
48
49        let glyph_bgl = crate::resources::builders::uniform_texture_sampler_bgl(
50            device,
51            "glyph_bgl",
52            wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
53            wgpu::ShaderStages::VERTEX,
54        );
55
56        let glyph_instance_bgl =
57            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
58                label: Some("glyph_instance_bgl"),
59                entries: &[wgpu::BindGroupLayoutEntry {
60                    binding: 0,
61                    visibility: wgpu::ShaderStages::VERTEX,
62                    ty: wgpu::BindingType::Buffer {
63                        ty: wgpu::BufferBindingType::Storage { read_only: true },
64                        has_dynamic_offset: false,
65                        min_binding_size: None,
66                    },
67                    count: None,
68                }],
69            });
70
71        let shader = crate::resources::builders::wgsl_module(
72            device,
73            "glyph_shader",
74            crate::resources::builders::wgsl_source!("glyph"),
75        );
76
77        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
78            label: Some("glyph_pipeline_layout"),
79            bind_group_layouts: &[
80                &self.camera_bind_group_layout,
81                &glyph_bgl,
82                &glyph_instance_bgl,
83            ],
84            push_constant_ranges: &[],
85        });
86
87        self.glyph.bgl = Some(glyph_bgl);
88        self.glyph.instance_bgl = Some(glyph_instance_bgl);
89        self.glyph.pipeline = Some(crate::resources::builders::build_dual_pipeline(
90            device,
91            &crate::resources::builders::DualPipelineDesc {
92                label: "glyph_pipeline",
93                layout: &layout,
94                shader: &shader,
95                vertex_entry: "vs_main",
96                fragment_entry: "fs_main",
97                vertex_buffers: &[Vertex::buffer_layout()],
98                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
99                topology: wgpu::PrimitiveTopology::TriangleList,
100                cull_mode: Some(wgpu::Face::Back),
101                depth_write: true,
102                depth_compare: wgpu::CompareFunction::Less,
103                sample_count: self.sample_count,
104                ldr_format: self.target_format,
105            },
106        ));
107
108        // Wireframe variant: same bind groups, LineList topology, no culling.
109        self.glyph.wireframe_pipeline = Some(crate::resources::builders::build_dual_pipeline(
110            device,
111            &crate::resources::builders::DualPipelineDesc {
112                label: "glyph_wireframe_pipeline",
113                layout: &layout,
114                shader: &shader,
115                vertex_entry: "vs_main",
116                fragment_entry: "fs_main",
117                vertex_buffers: &[Vertex::buffer_layout()],
118                blend: None,
119                topology: wgpu::PrimitiveTopology::LineList,
120                cull_mode: None,
121                depth_write: true,
122                depth_compare: wgpu::CompareFunction::Less,
123                sample_count: self.sample_count,
124                ldr_format: self.target_format,
125            },
126        ));
127    }
128
129    /// Upload one [`GlyphItem`] to the GPU and return draw data.
130    ///
131    /// Called from `prepare()` for each non-empty item in `frame.scene.glyphs`.
132    /// The glyph base mesh is cached in `glyph_arrow_mesh` / `glyph_sphere_mesh` / `glyph_cube_mesh`.
133    pub(crate) fn upload_glyph_set_per_frame(
134        &mut self,
135        device: &wgpu::Device,
136        queue: &wgpu::Queue,
137        item: &crate::renderer::GlyphItem,
138        wireframe: bool,
139    ) -> GlyphGpuData {
140        let instance_count = item.positions.len() as u32;
141
142        self.ensure_glyph_mesh(device, item.glyph_type);
143
144        let (mesh_vbuf, mesh_ibuf, mesh_idx_count, mesh_edge_ibuf, mesh_edge_count) = {
145            let mesh = match item.glyph_type {
146                crate::renderer::GlyphType::Arrow => self.glyph.arrow_mesh.as_ref(),
147                crate::renderer::GlyphType::Sphere => self.glyph.sphere_mesh.as_ref(),
148                crate::renderer::GlyphType::Cube => self.glyph.cube_mesh.as_ref(),
149            }
150            .expect("glyph mesh should have been created by ensure_glyph_mesh");
151
152            let vbuf: &'static wgpu::Buffer = unsafe { &*(&mesh.vertex_buffer as *const _) };
153            let ibuf: &'static wgpu::Buffer = unsafe { &*(&mesh.index_buffer as *const _) };
154            let eibuf: &'static wgpu::Buffer = unsafe { &*(&mesh.edge_index_buffer as *const _) };
155            (vbuf, ibuf, mesh.index_count, eibuf, mesh.edge_index_count)
156        };
157
158        let mags: Vec<f32> = item
159            .vectors
160            .iter()
161            .map(|v| glam::Vec3::from(*v).length())
162            .collect();
163
164        let (scalar_min, scalar_max) = if !item.scalars.is_empty() {
165            item.scalar_range.unwrap_or_else(|| {
166                let min = item.scalars.iter().cloned().fold(f32::INFINITY, f32::min);
167                let max = item
168                    .scalars
169                    .iter()
170                    .cloned()
171                    .fold(f32::NEG_INFINITY, f32::max);
172                (min, max)
173            })
174        } else {
175            item.scalar_range.unwrap_or_else(|| {
176                let min = mags.iter().cloned().fold(f32::INFINITY, f32::min);
177                let max = mags.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
178                (min, max)
179            })
180        };
181
182        let (mag_clamp_min, mag_clamp_max, has_mag_clamp) = item
183            .magnitude_clamp
184            .map(|(mn, mx)| (mn, mx, 1u32))
185            .unwrap_or((0.0, 1.0, 0u32));
186
187        #[repr(C)]
188        #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
189        struct GlyphInstance {
190            position: [f32; 3],
191            _pad0: f32,
192            direction: [f32; 3],
193            scalar: f32,
194        }
195
196        let instances: Vec<GlyphInstance> = (0..item.positions.len())
197            .map(|i| GlyphInstance {
198                position: item.positions[i],
199                _pad0: 0.0,
200                direction: item.vectors.get(i).copied().unwrap_or([0.0, 0.0, 1.0]),
201                scalar: item
202                    .scalars
203                    .get(i)
204                    .copied()
205                    .unwrap_or(mags.get(i).copied().unwrap_or(0.0)),
206            })
207            .collect();
208
209        let instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
210            label: Some("glyph_instance_buf"),
211            size: (std::mem::size_of::<GlyphInstance>() * instances.len()).max(32) as u64,
212            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
213            mapped_at_creation: false,
214        });
215        queue.write_buffer(&instance_buf, 0, bytemuck::cast_slice(&instances));
216
217        #[repr(C)]
218        #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
219        struct GlyphUniform {
220            model: [[f32; 4]; 4],
221            global_scale: f32,
222            scale_by_magnitude: u32,
223            has_scalars: u32,
224            scalar_min: f32,
225            scalar_max: f32,
226            mag_clamp_min: f32,
227            mag_clamp_max: f32,
228            has_mag_clamp: u32,
229            default_colour: [f32; 4],
230            use_default_colour: u32,
231            unlit: u32,
232            opacity: f32,
233            wireframe: u32,
234        }
235        let uniform_data = GlyphUniform {
236            model: item.model,
237            global_scale: item.scale,
238            scale_by_magnitude: if item.scale_by_magnitude { 1 } else { 0 },
239            has_scalars: if !item.scalars.is_empty() { 1 } else { 0 },
240            scalar_min,
241            scalar_max,
242            mag_clamp_min,
243            mag_clamp_max,
244            has_mag_clamp,
245            default_colour: item.default_colour,
246            use_default_colour: if item.default_colour[3] > 0.0 && item.use_default_colour {
247                1
248            } else {
249                0
250            },
251            unlit: if item.settings.unlit { 1 } else { 0 },
252            opacity: item.settings.opacity,
253            wireframe: if wireframe { 1 } else { 0 },
254        };
255        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
256            label: Some("glyph_uniform_buf"),
257            size: std::mem::size_of::<GlyphUniform>() as u64,
258            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
259            mapped_at_creation: false,
260        });
261        queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniform_data));
262
263        let lut_view = self
264            .content
265            .builtin_colourmap_ids
266            .and_then(|ids| {
267                let preset_id = item
268                    .colourmap_id
269                    .unwrap_or(ids[crate::resources::BuiltinColourmap::Viridis as usize]);
270                self.content.colourmap_views.get(preset_id.0)
271            })
272            .unwrap_or(&self.content.fallback_lut_view);
273
274        let lut_sampler = &self.material_sampler;
275
276        let bgl1 = self
277            .glyph
278            .bgl
279            .as_ref()
280            .expect("ensure_glyph_pipeline not called");
281        let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
282            label: Some("glyph_uniform_bg"),
283            layout: bgl1,
284            entries: &[
285                wgpu::BindGroupEntry {
286                    binding: 0,
287                    resource: uniform_buf.as_entire_binding(),
288                },
289                wgpu::BindGroupEntry {
290                    binding: 1,
291                    resource: wgpu::BindingResource::TextureView(lut_view),
292                },
293                wgpu::BindGroupEntry {
294                    binding: 2,
295                    resource: wgpu::BindingResource::Sampler(lut_sampler),
296                },
297            ],
298        });
299
300        let bgl2 = self
301            .glyph
302            .instance_bgl
303            .as_ref()
304            .expect("ensure_glyph_pipeline not called");
305        let instance_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
306            label: Some("glyph_instance_bg"),
307            layout: bgl2,
308            entries: &[wgpu::BindGroupEntry {
309                binding: 0,
310                resource: instance_buf.as_entire_binding(),
311            }],
312        });
313
314        GlyphGpuData {
315            mesh_vertex_buffer: mesh_vbuf,
316            mesh_index_buffer: mesh_ibuf,
317            mesh_index_count: mesh_idx_count,
318            mesh_edge_index_buffer: mesh_edge_ibuf,
319            mesh_edge_index_count: mesh_edge_count,
320            instance_count,
321            wireframe,
322            uniform_bind_group,
323            instance_bind_group,
324            _uniform_buf: uniform_buf,
325            _instance_buf: instance_buf,
326        }
327    }
328
329    /// Ensure a glyph base mesh is cached for the given [`GlyphType`].
330    /// Creates and uploads the mesh on first call for that type.
331    fn ensure_glyph_mesh(&mut self, device: &wgpu::Device, glyph_type: crate::renderer::GlyphType) {
332        use crate::renderer::GlyphType;
333
334        let already_cached = match glyph_type {
335            GlyphType::Arrow => self.glyph.arrow_mesh.is_some(),
336            GlyphType::Sphere => self.glyph.sphere_mesh.is_some(),
337            GlyphType::Cube => self.glyph.cube_mesh.is_some(),
338        };
339        if already_cached {
340            return;
341        }
342
343        let (verts, indices) = match glyph_type {
344            GlyphType::Arrow => build_glyph_arrow(),
345            GlyphType::Sphere => build_glyph_sphere(),
346            GlyphType::Cube => build_unit_cube(),
347        };
348
349        let vbuf = device.create_buffer(&wgpu::BufferDescriptor {
350            label: Some("glyph_mesh_vbuf"),
351            size: (std::mem::size_of::<Vertex>() * verts.len()).max(64) as u64,
352            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
353            mapped_at_creation: true,
354        });
355        vbuf.slice(..)
356            .get_mapped_range_mut()
357            .copy_from_slice(bytemuck::cast_slice(&verts));
358        vbuf.unmap();
359
360        let ibuf = device.create_buffer(&wgpu::BufferDescriptor {
361            label: Some("glyph_mesh_ibuf"),
362            size: (std::mem::size_of::<u32>() * indices.len()).max(12) as u64,
363            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
364            mapped_at_creation: true,
365        });
366        ibuf.slice(..)
367            .get_mapped_range_mut()
368            .copy_from_slice(bytemuck::cast_slice(&indices));
369        ibuf.unmap();
370
371        let edge_indices = crate::resources::mesh::geometry::generate_edge_indices(&indices);
372        let edge_buf_size = (std::mem::size_of::<u32>() * edge_indices.len().max(2)) as u64;
373        let edge_ibuf = device.create_buffer(&wgpu::BufferDescriptor {
374            label: Some("glyph_mesh_edge_ibuf"),
375            size: edge_buf_size,
376            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
377            mapped_at_creation: true,
378        });
379        {
380            let mut mapped = edge_ibuf.slice(..).get_mapped_range_mut();
381            let bytes = bytemuck::cast_slice::<u32, u8>(&edge_indices);
382            mapped[..bytes.len()].copy_from_slice(bytes);
383        }
384        edge_ibuf.unmap();
385
386        let mesh = GlyphBaseMesh {
387            vertex_buffer: vbuf,
388            index_buffer: ibuf,
389            index_count: indices.len() as u32,
390            edge_index_buffer: edge_ibuf,
391            edge_index_count: edge_indices.len() as u32,
392        };
393
394        match glyph_type {
395            GlyphType::Arrow => self.glyph.arrow_mesh = Some(mesh),
396            GlyphType::Sphere => self.glyph.sphere_mesh = Some(mesh),
397            GlyphType::Cube => self.glyph.cube_mesh = Some(mesh),
398        }
399    }
400
401    /// Lazily create the tensor glyph render pipeline (instanced ellipsoids).
402    ///
403    /// No-op if already created. Called from `prepare()` when `frame.scene.tensor_glyphs`
404    /// is non-empty.
405    pub(crate) fn ensure_tensor_glyph_pipeline(&mut self, device: &wgpu::Device) {
406        if self.tensor_glyph.pipeline.is_some() {
407            return;
408        }
409
410        let tg_bgl = crate::resources::builders::uniform_texture_sampler_bgl(
411            device,
412            "tensor_glyph_bgl",
413            wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
414            wgpu::ShaderStages::VERTEX,
415        );
416
417        let tg_instance_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
418            label: Some("tensor_glyph_instance_bgl"),
419            entries: &[wgpu::BindGroupLayoutEntry {
420                binding: 0,
421                visibility: wgpu::ShaderStages::VERTEX,
422                ty: wgpu::BindingType::Buffer {
423                    ty: wgpu::BufferBindingType::Storage { read_only: true },
424                    has_dynamic_offset: false,
425                    min_binding_size: None,
426                },
427                count: None,
428            }],
429        });
430
431        let shader = crate::resources::builders::wgsl_module(
432            device,
433            "tensor_glyph_shader",
434            crate::resources::builders::wgsl_source!("tensor_glyph"),
435        );
436
437        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
438            label: Some("tensor_glyph_pipeline_layout"),
439            bind_group_layouts: &[&self.camera_bind_group_layout, &tg_bgl, &tg_instance_bgl],
440            push_constant_ranges: &[],
441        });
442
443        self.tensor_glyph.bgl = Some(tg_bgl);
444        self.tensor_glyph.instance_bgl = Some(tg_instance_bgl);
445        self.tensor_glyph.pipeline = Some(crate::resources::builders::build_dual_pipeline(
446            device,
447            &crate::resources::builders::DualPipelineDesc {
448                label: "tensor_glyph_pipeline",
449                layout: &layout,
450                shader: &shader,
451                vertex_entry: "vs_main",
452                fragment_entry: "fs_main",
453                vertex_buffers: &[Vertex::buffer_layout()],
454                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
455                topology: wgpu::PrimitiveTopology::TriangleList,
456                cull_mode: Some(wgpu::Face::Back),
457                depth_write: true,
458                depth_compare: wgpu::CompareFunction::Less,
459                sample_count: self.sample_count,
460                ldr_format: self.target_format,
461            },
462        ));
463
464        // Wireframe variant: same bind groups, LineList topology, no culling.
465        self.tensor_glyph.wireframe_pipeline =
466            Some(crate::resources::builders::build_dual_pipeline(
467                device,
468                &crate::resources::builders::DualPipelineDesc {
469                    label: "tensor_glyph_wireframe_pipeline",
470                    layout: &layout,
471                    shader: &shader,
472                    vertex_entry: "vs_main",
473                    fragment_entry: "fs_main",
474                    vertex_buffers: &[Vertex::buffer_layout()],
475                    blend: None,
476                    topology: wgpu::PrimitiveTopology::LineList,
477                    cull_mode: None,
478                    depth_write: true,
479                    depth_compare: wgpu::CompareFunction::Less,
480                    sample_count: self.sample_count,
481                    ldr_format: self.target_format,
482                },
483            ));
484    }
485
486    /// Upload one [`TensorGlyphItem`] to the GPU and return draw data.
487    ///
488    /// Called from `prepare()` for each non-empty item in `frame.scene.tensor_glyphs`.
489    /// Reuses the sphere base mesh cached by the glyph pipeline.
490    pub(crate) fn upload_tensor_glyph_set_per_frame(
491        &mut self,
492        device: &wgpu::Device,
493        queue: &wgpu::Queue,
494        item: &crate::renderer::TensorGlyphItem,
495        wireframe: bool,
496    ) -> TensorGlyphGpuData {
497        use crate::renderer::GlyphType;
498
499        let instance_count = item.positions.len() as u32;
500
501        // Reuse the cached sphere mesh from the glyph pipeline.
502        self.ensure_glyph_mesh(device, GlyphType::Sphere);
503        let (mesh_vbuf, mesh_ibuf, mesh_idx_count, mesh_edge_ibuf, mesh_edge_count) = {
504            let mesh = self
505                .glyph
506                .sphere_mesh
507                .as_ref()
508                .expect("sphere mesh should be present after ensure_glyph_mesh");
509            let vbuf: &'static wgpu::Buffer = unsafe { &*(&mesh.vertex_buffer as *const _) };
510            let ibuf: &'static wgpu::Buffer = unsafe { &*(&mesh.index_buffer as *const _) };
511            let eibuf: &'static wgpu::Buffer = unsafe { &*(&mesh.edge_index_buffer as *const _) };
512            (vbuf, ibuf, mesh.index_count, eibuf, mesh.edge_index_count)
513        };
514
515        // Pre-compute per-instance model and normal matrices on the CPU.
516        #[repr(C)]
517        #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
518        struct TensorInstance {
519            model_col0: [f32; 4],
520            model_col1: [f32; 4],
521            model_col2: [f32; 4],
522            model_col3: [f32; 4],
523            normal_col0: [f32; 4],
524            normal_col1: [f32; 4],
525            normal_col2: [f32; 4],
526            scalar: f32,
527            _pad: [f32; 3],
528        }
529
530        // `item.model` is uploaded into `TensorGlyphUniform.model`; the
531        // shader composes it on top of the per-instance ellipsoid model so
532        // pre-uploaded sets can be moved per frame without rebuilding the
533        // instance buffer.
534
535        // Determine scalars for LUT lookup.
536        let has_scalars = item.colour_attribute.is_some();
537        let (scalar_min, scalar_max) = if let Some(ref scalars) = item.colour_attribute {
538            item.scalar_range.unwrap_or_else(|| {
539                let mn = scalars.iter().cloned().fold(f32::INFINITY, f32::min);
540                let mx = scalars.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
541                (mn, mx)
542            })
543        } else {
544            // Sign colouring: map [-1, 1] so LUT midpoint = neutral.
545            item.scalar_range.unwrap_or((-1.0, 1.0))
546        };
547
548        let instances: Vec<TensorInstance> = (0..item.positions.len())
549            .map(|i| {
550                let pos = glam::Vec3::from(item.positions[i]);
551                let ev = if i < item.eigenvalues.len() {
552                    item.eigenvalues[i]
553                } else {
554                    [1.0, 1.0, 1.0]
555                };
556                let vecs = if i < item.eigenvectors.len() {
557                    item.eigenvectors[i]
558                } else {
559                    [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
560                };
561
562                // Scale by |eigenvalue| * global_scale, minimum 1e-6 to avoid degenerate.
563                let s0 = (ev[0].abs() * item.scale).max(1e-6_f32);
564                let s1 = (ev[1].abs() * item.scale).max(1e-6_f32);
565                let s2 = (ev[2].abs() * item.scale).max(1e-6_f32);
566
567                // Rotation matrix: columns are the eigenvectors.
568                let col0 = glam::Vec3::from(vecs[0]);
569                let col1 = glam::Vec3::from(vecs[1]);
570                let col2 = glam::Vec3::from(vecs[2]);
571
572                // Rotation-scale block: RS = R * diag(s0, s1, s2).
573                let rs = glam::Mat3::from_cols(col0 * s0, col1 * s1, col2 * s2);
574
575                // 4x4 model matrix.
576                let local_model = glam::Mat4::from_mat3(rs) * glam::Mat4::IDENTITY;
577                let mut world_model = local_model;
578                world_model.w_axis = glam::Vec4::new(pos.x, pos.y, pos.z, 1.0);
579
580                // Normal matrix: R * diag(1/s0, 1/s1, 1/s2).
581                let nm = glam::Mat3::from_cols(col0 / s0, col1 / s1, col2 / s2);
582
583                // Scalar for LUT.
584                let scalar = if has_scalars {
585                    item.colour_attribute
586                        .as_ref()
587                        .and_then(|sc| sc.get(i))
588                        .copied()
589                        .unwrap_or(0.0)
590                } else {
591                    // Sign of dominant eigenvalue.
592                    if i < item.eigenvalues.len() {
593                        item.eigenvalues[i][0]
594                    } else {
595                        0.0
596                    }
597                };
598
599                let mc = world_model.to_cols_array_2d();
600                TensorInstance {
601                    model_col0: mc[0],
602                    model_col1: mc[1],
603                    model_col2: mc[2],
604                    model_col3: mc[3],
605                    normal_col0: [nm.x_axis.x, nm.x_axis.y, nm.x_axis.z, 0.0],
606                    normal_col1: [nm.y_axis.x, nm.y_axis.y, nm.y_axis.z, 0.0],
607                    normal_col2: [nm.z_axis.x, nm.z_axis.y, nm.z_axis.z, 0.0],
608                    scalar,
609                    _pad: [0.0; 3],
610                }
611            })
612            .collect();
613
614        let instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
615            label: Some("tensor_glyph_instance_buf"),
616            size: (std::mem::size_of::<TensorInstance>() * instances.len()).max(128) as u64,
617            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
618            mapped_at_creation: false,
619        });
620        queue.write_buffer(&instance_buf, 0, bytemuck::cast_slice(&instances));
621
622        #[repr(C)]
623        #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
624        struct TensorGlyphUniform {
625            model: [[f32; 4]; 4],
626            has_scalars: u32,
627            scalar_min: f32,
628            scalar_max: f32,
629            unlit: u32,
630            opacity: f32,
631            wireframe: u32,
632            _pad1b: f32,
633            _pad1c: f32,
634            _pad2: [[f32; 4]; 2],
635        }
636        let uniform_data = TensorGlyphUniform {
637            model: item.model,
638            has_scalars: if has_scalars { 1 } else { 0 },
639            scalar_min,
640            scalar_max,
641            unlit: item.settings.unlit as u32,
642            opacity: item.settings.opacity,
643            wireframe: if wireframe { 1 } else { 0 },
644            _pad1b: 0.0,
645            _pad1c: 0.0,
646            _pad2: [[0.0; 4]; 2],
647        };
648        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
649            label: Some("tensor_glyph_uniform_buf"),
650            size: std::mem::size_of::<TensorGlyphUniform>() as u64,
651            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
652            mapped_at_creation: false,
653        });
654        queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniform_data));
655
656        let lut_view = self
657            .content
658            .builtin_colourmap_ids
659            .and_then(|ids| {
660                let preset_id = item
661                    .colourmap_id
662                    .unwrap_or(ids[crate::resources::BuiltinColourmap::Viridis as usize]);
663                self.content.colourmap_views.get(preset_id.0)
664            })
665            .unwrap_or(&self.content.fallback_lut_view);
666
667        let lut_sampler = &self.material_sampler;
668
669        let bgl1 = self
670            .tensor_glyph
671            .bgl
672            .as_ref()
673            .expect("ensure_tensor_glyph_pipeline not called");
674        let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
675            label: Some("tensor_glyph_uniform_bg"),
676            layout: bgl1,
677            entries: &[
678                wgpu::BindGroupEntry {
679                    binding: 0,
680                    resource: uniform_buf.as_entire_binding(),
681                },
682                wgpu::BindGroupEntry {
683                    binding: 1,
684                    resource: wgpu::BindingResource::TextureView(lut_view),
685                },
686                wgpu::BindGroupEntry {
687                    binding: 2,
688                    resource: wgpu::BindingResource::Sampler(lut_sampler),
689                },
690            ],
691        });
692
693        let bgl2 = self
694            .tensor_glyph
695            .instance_bgl
696            .as_ref()
697            .expect("ensure_tensor_glyph_pipeline not called");
698        let instance_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
699            label: Some("tensor_glyph_instance_bg"),
700            layout: bgl2,
701            entries: &[wgpu::BindGroupEntry {
702                binding: 0,
703                resource: instance_buf.as_entire_binding(),
704            }],
705        });
706
707        TensorGlyphGpuData {
708            mesh_vertex_buffer: mesh_vbuf,
709            mesh_index_buffer: mesh_ibuf,
710            mesh_index_count: mesh_idx_count,
711            mesh_edge_index_buffer: mesh_edge_ibuf,
712            mesh_edge_index_count: mesh_edge_count,
713            instance_count,
714            wireframe,
715            uniform_bind_group,
716            instance_bind_group,
717            _uniform_buf: uniform_buf,
718            _instance_buf: instance_buf,
719        }
720    }
721
722    /// Lazily create the glyph outline mask pipeline.
723    ///
724    /// Renders the instanced glyph mesh into the R8 outline mask texture so
725    /// outlines follow the actual arrow/sphere shape.  Reuses the bind group
726    /// layouts from the main glyph pipeline (must be called after
727    /// `ensure_glyph_pipeline`).
728    pub(crate) fn ensure_glyph_outline_mask_pipeline(&mut self, device: &wgpu::Device) {
729        if self.glyph.outline_mask_pipeline.is_some() {
730            return;
731        }
732        let glyph_bgl = self
733            .glyph
734            .bgl
735            .as_ref()
736            .expect("ensure_glyph_pipeline must be called first");
737        let glyph_instance_bgl = self
738            .glyph
739            .instance_bgl
740            .as_ref()
741            .expect("ensure_glyph_pipeline must be called first");
742
743        let shader = crate::resources::builders::wgsl_module(
744            device,
745            "glyph_outline_mask_shader",
746            crate::resources::builders::wgsl_source!("glyph_outline_mask"),
747        );
748
749        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
750            label: Some("glyph_outline_mask_pipeline_layout"),
751            bind_group_layouts: &[
752                &self.camera_bind_group_layout,
753                glyph_bgl,
754                glyph_instance_bgl,
755            ],
756            push_constant_ranges: &[],
757        });
758
759        self.glyph.outline_mask_pipeline =
760            Some(crate::resources::builders::build_outline_mask_pipeline(
761                device,
762                "glyph_outline_mask_pipeline",
763                &layout,
764                &shader,
765                wgpu::TextureFormat::R8Unorm,
766                &[Vertex::buffer_layout()],
767                Some(wgpu::Face::Back),
768                true,
769                wgpu::CompareFunction::Less,
770            ));
771    }
772
773    /// Lazily create the tensor glyph outline mask pipeline.
774    ///
775    /// Same idea as `ensure_glyph_outline_mask_pipeline` but for tensor
776    /// glyph ellipsoids.  Must be called after `ensure_tensor_glyph_pipeline`.
777    pub(crate) fn ensure_tensor_glyph_outline_mask_pipeline(&mut self, device: &wgpu::Device) {
778        if self.tensor_glyph.outline_mask_pipeline.is_some() {
779            return;
780        }
781        let tg_bgl = self
782            .tensor_glyph
783            .bgl
784            .as_ref()
785            .expect("ensure_tensor_glyph_pipeline must be called first");
786        let tg_instance_bgl = self
787            .tensor_glyph
788            .instance_bgl
789            .as_ref()
790            .expect("ensure_tensor_glyph_pipeline must be called first");
791
792        let shader = crate::resources::builders::wgsl_module(
793            device,
794            "tensor_glyph_outline_mask_shader",
795            crate::resources::builders::wgsl_source!("tensor_glyph_outline_mask"),
796        );
797
798        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
799            label: Some("tensor_glyph_outline_mask_pipeline_layout"),
800            bind_group_layouts: &[&self.camera_bind_group_layout, tg_bgl, tg_instance_bgl],
801            push_constant_ranges: &[],
802        });
803
804        self.tensor_glyph.outline_mask_pipeline =
805            Some(crate::resources::builders::build_outline_mask_pipeline(
806                device,
807                "tensor_glyph_outline_mask_pipeline",
808                &layout,
809                &shader,
810                wgpu::TextureFormat::R8Unorm,
811                &[Vertex::buffer_layout()],
812                Some(wgpu::Face::Back),
813                true,
814                wgpu::CompareFunction::Less,
815            ));
816    }
817
818    /// Pre-upload a glyph set and return a typed handle.
819    pub fn upload_glyph_set(
820        &mut self,
821        device: &wgpu::Device,
822        queue: &wgpu::Queue,
823        item: &crate::renderer::GlyphItem,
824    ) -> crate::resources::GlyphSetId {
825        self.ensure_glyph_pipeline(device);
826        let gpu = self.upload_glyph_set_per_frame(device, queue, item, false);
827        self.content.glyph_set_store.insert(gpu)
828    }
829
830    /// Remove a pre-uploaded glyph set.
831    pub fn drop_glyph_set(&mut self, id: crate::resources::GlyphSetId) -> bool {
832        self.content.glyph_set_store.remove(id)
833    }
834
835    /// Replace the geometry of a pre-uploaded glyph set, keeping the same id.
836    pub fn replace_glyph_set(
837        &mut self,
838        device: &wgpu::Device,
839        queue: &wgpu::Queue,
840        id: crate::resources::GlyphSetId,
841        item: &crate::renderer::GlyphItem,
842    ) -> bool {
843        if !self.content.glyph_set_store.contains(id) {
844            return false;
845        }
846        self.ensure_glyph_pipeline(device);
847        let gpu = self.upload_glyph_set_per_frame(device, queue, item, false);
848        self.content.glyph_set_store.replace(id, gpu)
849    }
850
851    /// Start an asynchronous glyph set upload.
852    pub fn begin_upload_glyph_set(
853        &mut self,
854        device: &wgpu::Device,
855        queue: &wgpu::Queue,
856        item: crate::renderer::GlyphItem,
857    ) -> crate::resources::JobId {
858        let slot = crate::resources::ResultSlot::<crate::resources::GlyphSetId>::new();
859        let slot_for_apply = slot.clone();
860        let device_for_apply = device.clone();
861        let queue_for_apply = queue.clone();
862        let id = {
863            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
864            runner.submit_cpu(move |progress| {
865                progress.set(0.9);
866                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
867                    Box::new(move |resources: &mut DeviceResources| {
868                        let gid =
869                            resources.upload_glyph_set(&device_for_apply, &queue_for_apply, &item);
870                        slot_for_apply.set(gid);
871                    }),
872                ))
873            })
874        };
875        self.job_results
876            .glyph_set
877            .lock()
878            .expect("glyph set result map poisoned")
879            .insert(id, slot);
880        id
881    }
882
883    /// Take the [`GlyphSetId`](crate::resources::GlyphSetId) produced by a
884    /// completed [`begin_upload_glyph_set`](Self::begin_upload_glyph_set) job.
885    pub fn upload_result_glyph_set(
886        &mut self,
887        id: crate::resources::JobId,
888    ) -> crate::error::ViewportResult<crate::resources::GlyphSetId> {
889        let mut map = self
890            .job_results
891            .glyph_set
892            .lock()
893            .expect("glyph set result map poisoned");
894        let slot = match map.get(&id) {
895            Some(s) => s.clone(),
896            None => {
897                return Err(crate::error::ViewportError::JobResultMissing {
898                    reason: "unknown id or wrong upload type",
899                });
900            }
901        };
902        match slot.take() {
903            Some(gid) => {
904                map.remove(&id);
905                Ok(gid)
906            }
907            None => Err(crate::error::ViewportError::JobNotReady),
908        }
909    }
910
911    /// Pre-upload a tensor glyph set and return a typed handle.
912    pub fn upload_tensor_glyph_set(
913        &mut self,
914        device: &wgpu::Device,
915        queue: &wgpu::Queue,
916        item: &crate::renderer::TensorGlyphItem,
917    ) -> crate::resources::TensorGlyphSetId {
918        self.ensure_tensor_glyph_pipeline(device);
919        let gpu = self.upload_tensor_glyph_set_per_frame(device, queue, item, false);
920        self.content.tensor_glyph_set_store.insert(gpu)
921    }
922
923    /// Remove a pre-uploaded tensor glyph set.
924    pub fn drop_tensor_glyph_set(&mut self, id: crate::resources::TensorGlyphSetId) -> bool {
925        self.content.tensor_glyph_set_store.remove(id)
926    }
927
928    /// Replace the geometry of a pre-uploaded tensor glyph set, keeping the same id.
929    pub fn replace_tensor_glyph_set(
930        &mut self,
931        device: &wgpu::Device,
932        queue: &wgpu::Queue,
933        id: crate::resources::TensorGlyphSetId,
934        item: &crate::renderer::TensorGlyphItem,
935    ) -> bool {
936        if !self.content.tensor_glyph_set_store.contains(id) {
937            return false;
938        }
939        self.ensure_tensor_glyph_pipeline(device);
940        let gpu = self.upload_tensor_glyph_set_per_frame(device, queue, item, false);
941        self.content.tensor_glyph_set_store.replace(id, gpu)
942    }
943
944    /// Start an asynchronous tensor glyph set upload.
945    pub fn begin_upload_tensor_glyph_set(
946        &mut self,
947        device: &wgpu::Device,
948        queue: &wgpu::Queue,
949        item: crate::renderer::TensorGlyphItem,
950    ) -> crate::resources::JobId {
951        let slot = crate::resources::ResultSlot::<crate::resources::TensorGlyphSetId>::new();
952        let slot_for_apply = slot.clone();
953        let device_for_apply = device.clone();
954        let queue_for_apply = queue.clone();
955        let id = {
956            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
957            runner.submit_cpu(move |progress| {
958                progress.set(0.9);
959                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
960                    Box::new(move |resources: &mut DeviceResources| {
961                        let tid = resources.upload_tensor_glyph_set(
962                            &device_for_apply,
963                            &queue_for_apply,
964                            &item,
965                        );
966                        slot_for_apply.set(tid);
967                    }),
968                ))
969            })
970        };
971        self.job_results
972            .tensor_glyph_set
973            .lock()
974            .expect("tensor glyph set result map poisoned")
975            .insert(id, slot);
976        id
977    }
978
979    /// Take the [`TensorGlyphSetId`](crate::resources::TensorGlyphSetId) produced by a
980    /// completed [`begin_upload_tensor_glyph_set`](Self::begin_upload_tensor_glyph_set) job.
981    pub fn upload_result_tensor_glyph_set(
982        &mut self,
983        id: crate::resources::JobId,
984    ) -> crate::error::ViewportResult<crate::resources::TensorGlyphSetId> {
985        let mut map = self
986            .job_results
987            .tensor_glyph_set
988            .lock()
989            .expect("tensor glyph set result map poisoned");
990        let slot = match map.get(&id) {
991            Some(s) => s.clone(),
992            None => {
993                return Err(crate::error::ViewportError::JobResultMissing {
994                    reason: "unknown id or wrong upload type",
995                });
996            }
997        };
998        match slot.take() {
999            Some(tid) => {
1000                map.remove(&id);
1001                Ok(tid)
1002            }
1003            None => Err(crate::error::ViewportError::JobNotReady),
1004        }
1005    }
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010    use crate::DeviceResources;
1011    use crate::renderer::{GlyphItem, TensorGlyphItem};
1012    use crate::resources::UploadStatus;
1013
1014    fn try_make_device() -> Option<(wgpu::Device, wgpu::Queue)> {
1015        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
1016        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
1017            power_preference: wgpu::PowerPreference::LowPower,
1018            compatible_surface: None,
1019            force_fallback_adapter: false,
1020        }))
1021        .ok()?;
1022        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
1023    }
1024
1025    fn sample_glyph_set() -> GlyphItem {
1026        let mut item = GlyphItem::default();
1027        item.positions = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
1028        item.vectors = vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1029        item
1030    }
1031
1032    fn sample_tensor_glyph_set() -> TensorGlyphItem {
1033        let mut item = TensorGlyphItem::default();
1034        item.positions = vec![[0.0, 0.0, 0.0]];
1035        item.eigenvalues = vec![[1.0, 0.5, 0.25]];
1036        item.eigenvectors = vec![[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]];
1037        item
1038    }
1039
1040    fn drive_until_ready(
1041        resources: &mut DeviceResources,
1042        device: &wgpu::Device,
1043        queue: &wgpu::Queue,
1044        id: crate::resources::JobId,
1045        label: &str,
1046    ) {
1047        for _ in 0..200 {
1048            resources.process_uploads(device, queue);
1049            match resources.upload_status(id) {
1050                UploadStatus::Ready => return,
1051                UploadStatus::Failed(e) => panic!("{label} upload failed: {e:?}"),
1052                UploadStatus::Pending { .. } => {
1053                    std::thread::sleep(std::time::Duration::from_millis(5));
1054                }
1055                UploadStatus::Unknown => panic!("{label} job id disappeared"),
1056            }
1057        }
1058        panic!("{label} upload did not complete in time");
1059    }
1060
1061    #[test]
1062    fn upload_glyph_set_returns_valid_handle() {
1063        let Some((device, queue)) = try_make_device() else {
1064            eprintln!("skipping: no wgpu adapter available");
1065            return;
1066        };
1067        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1068        let id = resources.upload_glyph_set(&device, &queue, &sample_glyph_set());
1069        assert!(resources.content.glyph_set_store.contains(id));
1070        assert!(resources.drop_glyph_set(id));
1071    }
1072
1073    #[test]
1074    fn upload_tensor_glyph_set_returns_valid_handle() {
1075        let Some((device, queue)) = try_make_device() else {
1076            eprintln!("skipping: no wgpu adapter available");
1077            return;
1078        };
1079        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1080        let id = resources.upload_tensor_glyph_set(&device, &queue, &sample_tensor_glyph_set());
1081        assert!(resources.content.tensor_glyph_set_store.contains(id));
1082        assert!(resources.drop_tensor_glyph_set(id));
1083    }
1084
1085    #[test]
1086    fn begin_upload_glyph_set_drains_to_handle() {
1087        let Some((device, queue)) = try_make_device() else {
1088            eprintln!("skipping: no wgpu adapter available");
1089            return;
1090        };
1091        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1092        let job = resources.begin_upload_glyph_set(&device, &queue, sample_glyph_set());
1093        drive_until_ready(&mut resources, &device, &queue, job, "glyph_set");
1094        let id = resources.upload_result_glyph_set(job).expect("ready");
1095        assert!(resources.content.glyph_set_store.contains(id));
1096    }
1097
1098    #[test]
1099    fn begin_upload_tensor_glyph_set_drains_to_handle() {
1100        let Some((device, queue)) = try_make_device() else {
1101            eprintln!("skipping: no wgpu adapter available");
1102            return;
1103        };
1104        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1105        let job =
1106            resources.begin_upload_tensor_glyph_set(&device, &queue, sample_tensor_glyph_set());
1107        drive_until_ready(&mut resources, &device, &queue, job, "tensor_glyph_set");
1108        let id = resources
1109            .upload_result_tensor_glyph_set(job)
1110            .expect("ready");
1111        assert!(resources.content.tensor_glyph_set_store.contains(id));
1112    }
1113}
1114
1115/// Cached GPU vertex + index buffers for a glyph base mesh (arrow, sphere, cube).
1116pub(crate) struct GlyphBaseMesh {
1117    /// Vertex buffer using the full `Vertex` layout (64 bytes stride).
1118    pub vertex_buffer: wgpu::Buffer,
1119    /// Triangle index buffer.
1120    pub index_buffer: wgpu::Buffer,
1121    /// Number of indices.
1122    pub index_count: u32,
1123    /// Edge index buffer (deduplicated pairs) for wireframe LineList rendering.
1124    pub edge_index_buffer: wgpu::Buffer,
1125    /// Number of indices in the edge buffer.
1126    pub edge_index_count: u32,
1127}
1128/// Per-frame GPU data for one glyph item, created in `prepare()`.
1129#[derive(Clone)]
1130pub struct GlyphGpuData {
1131    /// Vertex buffer for the glyph base mesh (borrowed from cached `GlyphBaseMesh`).
1132    /// We keep a reference via raw pointer : `DeviceResources` owns the mesh.
1133    /// Safety: the mesh lives as long as `DeviceResources`.
1134    pub(crate) mesh_vertex_buffer: &'static wgpu::Buffer,
1135    /// Triangle index buffer for the glyph base mesh.
1136    pub(crate) mesh_index_buffer: &'static wgpu::Buffer,
1137    /// Number of triangle mesh indices.
1138    pub(crate) mesh_index_count: u32,
1139    /// Edge index buffer for wireframe LineList rendering (borrowed from cached `GlyphBaseMesh`).
1140    pub(crate) mesh_edge_index_buffer: &'static wgpu::Buffer,
1141    /// Number of edge indices.
1142    pub(crate) mesh_edge_index_count: u32,
1143    /// Number of glyph instances.
1144    pub(crate) instance_count: u32,
1145    /// Whether this batch should be drawn with the wireframe pipeline.
1146    pub(crate) wireframe: bool,
1147    /// Bind group (group 1): glyph uniform + LUT texture + sampler.
1148    pub(crate) uniform_bind_group: wgpu::BindGroup,
1149    /// Bind group (group 2): instance storage buffer.
1150    pub(crate) instance_bind_group: wgpu::BindGroup,
1151    // Keep the buffers alive.
1152    pub(crate) _uniform_buf: wgpu::Buffer,
1153    pub(crate) _instance_buf: wgpu::Buffer,
1154}
1155
1156/// Per-frame GPU data for one tensor glyph item, created in `prepare()`.
1157///
1158/// The sphere base mesh is borrowed from `glyph_sphere_mesh` (owned by `DeviceResources`).
1159#[derive(Clone)]
1160pub struct TensorGlyphGpuData {
1161    /// Vertex buffer for the sphere base mesh (borrowed).
1162    pub(crate) mesh_vertex_buffer: &'static wgpu::Buffer,
1163    /// Triangle index buffer for the sphere base mesh (borrowed).
1164    pub(crate) mesh_index_buffer: &'static wgpu::Buffer,
1165    /// Number of triangle mesh indices.
1166    pub(crate) mesh_index_count: u32,
1167    /// Edge index buffer for wireframe LineList rendering (borrowed from sphere mesh).
1168    pub(crate) mesh_edge_index_buffer: &'static wgpu::Buffer,
1169    /// Number of edge indices.
1170    pub(crate) mesh_edge_index_count: u32,
1171    /// Number of tensor glyph instances.
1172    pub(crate) instance_count: u32,
1173    /// Whether this batch should be drawn with the wireframe pipeline.
1174    pub(crate) wireframe: bool,
1175    /// Bind group (group 1): uniform + LUT texture + sampler.
1176    pub(crate) uniform_bind_group: wgpu::BindGroup,
1177    /// Bind group (group 2): per-instance storage buffer.
1178    pub(crate) instance_bind_group: wgpu::BindGroup,
1179    // Keep buffers alive.
1180    pub(crate) _uniform_buf: wgpu::Buffer,
1181    pub(crate) _instance_buf: wgpu::Buffer,
1182}