Skip to main content

viewport_lib/renderer/
prepare.rs

1use super::types::{ClipShape, SceneEffects, ViewportEffects};
2use super::*;
3use wgpu::util::DeviceExt;
4
5impl ViewportRenderer {
6    /// Scene-global prepare stage: compute filters, lighting, shadow pass, batching, scivis.
7    ///
8    /// Call once per frame before any `prepare_viewport_internal` calls.
9    ///
10    /// Reads `scene_fx` for lighting, IBL, and compute filters.  Still reads
11    /// `frame.camera` for shadow cascade computation (Phase 1 coupling : see
12    /// multi-viewport-plan.md § shadow strategy; decoupled in Phase 2).
13    pub(super) fn prepare_scene_internal(
14        &mut self,
15        device: &wgpu::Device,
16        queue: &wgpu::Queue,
17        frame: &FrameData,
18        scene_fx: &SceneEffects<'_>,
19    ) {
20        // Phase G : GPU compute filtering.
21        // Dispatch before the render pass. Completely skipped when list is empty (zero overhead).
22        if !scene_fx.compute_filter_items.is_empty() {
23            self.compute_filter_results =
24                self.resources
25                    .run_compute_filters(device, queue, scene_fx.compute_filter_items);
26        } else {
27            self.compute_filter_results.clear();
28        }
29
30        // Ensure built-in colormaps and matcaps are uploaded on first frame.
31        self.resources.ensure_colormaps_initialized(device, queue);
32        self.resources.ensure_matcaps_initialized(device, queue);
33
34        let resources = &mut self.resources;
35        let lighting = scene_fx.lighting;
36
37        // Read scene items from the surface submission.
38        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
39            SurfaceSubmission::Flat(items) => items.as_ref(),
40        };
41
42        // Compute scene center / extent for shadow framing.
43        let (shadow_center, shadow_extent) = if let Some(extent) = lighting.shadow_extent_override {
44            (glam::Vec3::ZERO, extent)
45        } else {
46            (glam::Vec3::ZERO, 20.0)
47        };
48
49        /// Build a light-space view-projection matrix for shadow mapping.
50        fn compute_shadow_matrix(
51            kind: &LightKind,
52            shadow_center: glam::Vec3,
53            shadow_extent: f32,
54        ) -> glam::Mat4 {
55            match kind {
56                LightKind::Directional { direction } => {
57                    let dir = glam::Vec3::from(*direction).normalize();
58                    let light_up = if dir.z.abs() > 0.99 {
59                        glam::Vec3::Y
60                    } else {
61                        glam::Vec3::Z
62                    };
63                    let light_pos = shadow_center + dir * shadow_extent * 2.0;
64                    let light_view = glam::Mat4::look_at_rh(light_pos, shadow_center, light_up);
65                    let light_proj = glam::Mat4::orthographic_rh(
66                        -shadow_extent,
67                        shadow_extent,
68                        -shadow_extent,
69                        shadow_extent,
70                        0.01,
71                        shadow_extent * 5.0,
72                    );
73                    light_proj * light_view
74                }
75                LightKind::Point { position, range } => {
76                    let pos = glam::Vec3::from(*position);
77                    let to_center = (shadow_center - pos).normalize();
78                    let light_up = if to_center.z.abs() > 0.99 {
79                        glam::Vec3::Y
80                    } else {
81                        glam::Vec3::Z
82                    };
83                    let light_view = glam::Mat4::look_at_rh(pos, shadow_center, light_up);
84                    let light_proj =
85                        glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_2, 1.0, 0.1, *range);
86                    light_proj * light_view
87                }
88                LightKind::Spot {
89                    position,
90                    direction,
91                    range,
92                    ..
93                } => {
94                    let pos = glam::Vec3::from(*position);
95                    let dir = glam::Vec3::from(*direction).normalize();
96                    let look_target = pos + dir;
97                    let up = if dir.z.abs() > 0.99 {
98                        glam::Vec3::Y
99                    } else {
100                        glam::Vec3::Z
101                    };
102                    let light_view = glam::Mat4::look_at_rh(pos, look_target, up);
103                    let light_proj =
104                        glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_2, 1.0, 0.1, *range);
105                    light_proj * light_view
106                }
107            }
108        }
109
110        /// Convert a `LightSource` to `SingleLightUniform`, computing shadow matrix for lights[0].
111        fn build_single_light_uniform(
112            src: &LightSource,
113            shadow_center: glam::Vec3,
114            shadow_extent: f32,
115            compute_shadow: bool,
116        ) -> SingleLightUniform {
117            let shadow_mat = if compute_shadow {
118                compute_shadow_matrix(&src.kind, shadow_center, shadow_extent)
119            } else {
120                glam::Mat4::IDENTITY
121            };
122
123            match &src.kind {
124                LightKind::Directional { direction } => SingleLightUniform {
125                    light_view_proj: shadow_mat.to_cols_array_2d(),
126                    pos_or_dir: *direction,
127                    light_type: 0,
128                    color: src.color,
129                    intensity: src.intensity,
130                    range: 0.0,
131                    inner_angle: 0.0,
132                    outer_angle: 0.0,
133                    _pad_align: 0,
134                    spot_direction: [0.0, -1.0, 0.0],
135                    _pad: [0.0; 5],
136                },
137                LightKind::Point { position, range } => SingleLightUniform {
138                    light_view_proj: shadow_mat.to_cols_array_2d(),
139                    pos_or_dir: *position,
140                    light_type: 1,
141                    color: src.color,
142                    intensity: src.intensity,
143                    range: *range,
144                    inner_angle: 0.0,
145                    outer_angle: 0.0,
146                    _pad_align: 0,
147                    spot_direction: [0.0, -1.0, 0.0],
148                    _pad: [0.0; 5],
149                },
150                LightKind::Spot {
151                    position,
152                    direction,
153                    range,
154                    inner_angle,
155                    outer_angle,
156                } => SingleLightUniform {
157                    light_view_proj: shadow_mat.to_cols_array_2d(),
158                    pos_or_dir: *position,
159                    light_type: 2,
160                    color: src.color,
161                    intensity: src.intensity,
162                    range: *range,
163                    inner_angle: *inner_angle,
164                    outer_angle: *outer_angle,
165                    _pad_align: 0,
166                    spot_direction: *direction,
167                    _pad: [0.0; 5],
168                },
169            }
170        }
171
172        // Build the LightsUniform for all active lights (max 8).
173        let light_count = lighting.lights.len().min(8) as u32;
174        let mut lights_arr = [SingleLightUniform {
175            light_view_proj: glam::Mat4::IDENTITY.to_cols_array_2d(),
176            pos_or_dir: [0.0; 3],
177            light_type: 0,
178            color: [1.0; 3],
179            intensity: 1.0,
180            range: 0.0,
181            inner_angle: 0.0,
182            outer_angle: 0.0,
183            _pad_align: 0,
184            spot_direction: [0.0, -1.0, 0.0],
185            _pad: [0.0; 5],
186        }; 8];
187
188        for (i, src) in lighting.lights.iter().take(8).enumerate() {
189            lights_arr[i] = build_single_light_uniform(src, shadow_center, shadow_extent, i == 0);
190        }
191
192        // -------------------------------------------------------------------
193        // Compute CSM cascade matrices for lights[0] (directional).
194        // Phase 1 note: uses frame.camera : see multi-viewport-plan.md § shadow strategy.
195        // -------------------------------------------------------------------
196        let cascade_count = lighting.shadow_cascade_count.clamp(1, 4) as usize;
197        let atlas_res = lighting.shadow_atlas_resolution.max(64);
198        let tile_size = atlas_res / 2;
199
200        let cascade_splits = compute_cascade_splits(
201            frame.camera.render_camera.near.max(0.01),
202            frame.camera.render_camera.far.max(1.0),
203            cascade_count as u32,
204            lighting.cascade_split_lambda,
205        );
206
207        let light_dir_for_csm = if light_count > 0 {
208            match &lighting.lights[0].kind {
209                LightKind::Directional { direction } => glam::Vec3::from(*direction).normalize(),
210                LightKind::Point { position, .. } => {
211                    (glam::Vec3::from(*position) - shadow_center).normalize()
212                }
213                LightKind::Spot {
214                    position,
215                    direction,
216                    ..
217                } => {
218                    let _ = position;
219                    glam::Vec3::from(*direction).normalize()
220                }
221            }
222        } else {
223            glam::Vec3::new(0.3, 1.0, 0.5).normalize()
224        };
225
226        let mut cascade_view_projs = [glam::Mat4::IDENTITY; 4];
227        // Distance-based splits for fragment shader cascade selection.
228        let mut cascade_split_distances = [0.0f32; 4];
229
230        // Determine if we should use CSM (directional light + valid camera data).
231        let use_csm = light_count > 0
232            && matches!(lighting.lights[0].kind, LightKind::Directional { .. })
233            && frame.camera.render_camera.view != glam::Mat4::IDENTITY;
234
235        if use_csm {
236            for i in 0..cascade_count {
237                let split_near = if i == 0 {
238                    frame.camera.render_camera.near.max(0.01)
239                } else {
240                    cascade_splits[i - 1]
241                };
242                let split_far = cascade_splits[i];
243                cascade_view_projs[i] = compute_cascade_matrix(
244                    light_dir_for_csm,
245                    frame.camera.render_camera.view,
246                    frame.camera.render_camera.fov,
247                    frame.camera.render_camera.aspect,
248                    split_near,
249                    split_far,
250                    tile_size as f32,
251                );
252                cascade_split_distances[i] = split_far;
253            }
254        } else {
255            // Fallback: single shadow map covering the whole scene (legacy behavior).
256            let primary_shadow_mat = if light_count > 0 {
257                compute_shadow_matrix(&lighting.lights[0].kind, shadow_center, shadow_extent)
258            } else {
259                glam::Mat4::IDENTITY
260            };
261            cascade_view_projs[0] = primary_shadow_mat;
262            cascade_split_distances[0] = frame.camera.render_camera.far;
263        }
264        let effective_cascade_count = if use_csm { cascade_count } else { 1 };
265
266        // Atlas tile layout (2x2 grid):
267        // [0] = top-left, [1] = top-right, [2] = bottom-left, [3] = bottom-right
268        let atlas_rects: [[f32; 4]; 8] = [
269            [0.0, 0.0, 0.5, 0.5], // cascade 0
270            [0.5, 0.0, 1.0, 0.5], // cascade 1
271            [0.0, 0.5, 0.5, 1.0], // cascade 2
272            [0.5, 0.5, 1.0, 1.0], // cascade 3
273            [0.0; 4],
274            [0.0; 4],
275            [0.0; 4],
276            [0.0; 4], // unused slots
277        ];
278
279        // Upload ShadowAtlasUniform (binding 5).
280        {
281            let mut vp_data = [[0.0f32; 4]; 16]; // 4 mat4s flattened
282            for c in 0..4 {
283                let cols = cascade_view_projs[c].to_cols_array_2d();
284                for row in 0..4 {
285                    vp_data[c * 4 + row] = cols[row];
286                }
287            }
288            let shadow_atlas_uniform = ShadowAtlasUniform {
289                cascade_view_proj: vp_data,
290                cascade_splits: cascade_split_distances,
291                cascade_count: effective_cascade_count as u32,
292                atlas_size: atlas_res as f32,
293                shadow_filter: match lighting.shadow_filter {
294                    ShadowFilter::Pcf => 0,
295                    ShadowFilter::Pcss => 1,
296                },
297                pcss_light_radius: lighting.pcss_light_radius,
298                atlas_rects,
299            };
300            queue.write_buffer(
301                &resources.shadow_info_buf,
302                0,
303                bytemuck::cast_slice(&[shadow_atlas_uniform]),
304            );
305            // Write to all per-viewport slot buffers so each viewport's bind group
306            // references correctly populated shadow info.
307            for slot in &self.viewport_slots {
308                queue.write_buffer(
309                    &slot.shadow_info_buf,
310                    0,
311                    bytemuck::cast_slice(&[shadow_atlas_uniform]),
312                );
313            }
314        }
315
316        // The primary shadow matrix is still stored in lights[0].light_view_proj for
317        // backward compat with the non-instanced shadow pass uniform.
318        let _primary_shadow_mat = cascade_view_projs[0];
319        // Cache for ground plane ShadowOnly mode.
320        self.last_cascade0_shadow_mat = cascade_view_projs[0];
321
322        // Upload lights uniform.
323        // IBL fields from environment map settings.
324        let (ibl_enabled, ibl_intensity, ibl_rotation, show_skybox) =
325            if let Some(env) = scene_fx.environment {
326                if resources.ibl_irradiance_view.is_some() {
327                    (
328                        1u32,
329                        env.intensity,
330                        env.rotation,
331                        if env.show_skybox { 1u32 } else { 0 },
332                    )
333                } else {
334                    (0, 0.0, 0.0, 0)
335                }
336            } else {
337                (0, 0.0, 0.0, 0)
338            };
339
340        let lights_uniform = LightsUniform {
341            count: light_count,
342            shadow_bias: lighting.shadow_bias,
343            shadows_enabled: if lighting.shadows_enabled { 1 } else { 0 },
344            _pad: 0,
345            sky_color: lighting.sky_color,
346            hemisphere_intensity: lighting.hemisphere_intensity,
347            ground_color: lighting.ground_color,
348            _pad2: 0.0,
349            lights: lights_arr,
350            ibl_enabled,
351            ibl_intensity,
352            ibl_rotation,
353            show_skybox,
354        };
355        queue.write_buffer(
356            &resources.light_uniform_buf,
357            0,
358            bytemuck::cast_slice(&[lights_uniform]),
359        );
360
361        // Upload all cascade matrices to the shadow uniform buffer before the shadow pass.
362        // wgpu batches write_buffer calls before the command buffer, so we must write ALL
363        // cascade slots up-front; the cascade loop then selects per-slot via dynamic offset.
364        const SHADOW_SLOT_STRIDE: u64 = 256;
365        for c in 0..4usize {
366            queue.write_buffer(
367                &resources.shadow_uniform_buf,
368                c as u64 * SHADOW_SLOT_STRIDE,
369                bytemuck::cast_slice(&cascade_view_projs[c].to_cols_array_2d()),
370            );
371        }
372
373        // -- Instancing preparation --
374        // Determine instancing mode BEFORE per-object uniforms so we can skip them.
375        let visible_count = scene_items.iter().filter(|i| i.visible).count();
376        let prev_use_instancing = self.use_instancing;
377        self.use_instancing = visible_count > INSTANCING_THRESHOLD;
378
379        // If instancing mode changed (e.g. objects added/removed crossing the threshold),
380        // clear batches so the generation check below forces a rebuild.
381        if self.use_instancing != prev_use_instancing {
382            self.instanced_batches.clear();
383            self.last_scene_generation = u64::MAX;
384            self.last_scene_items_count = usize::MAX;
385        }
386
387        // Per-object uniform writes : needed for the non-instanced path, wireframe mode,
388        // and for any items with active scalar attributes or two-sided materials
389        // (both bypass the instanced path).
390        let has_scalar_items = scene_items.iter().any(|i| i.active_attribute.is_some());
391        let has_two_sided_items = scene_items
392            .iter()
393            .any(|i| i.material.is_two_sided());
394        let has_matcap_items = scene_items.iter().any(|i| i.material.matcap_id.is_some());
395        let has_param_vis_items = scene_items.iter().any(|i| i.material.param_vis.is_some());
396        let has_wireframe_items = scene_items.iter().any(|i| i.render_as_wireframe);
397        if !self.use_instancing
398            || frame.viewport.wireframe_mode
399            || has_scalar_items
400            || has_two_sided_items
401            || has_matcap_items
402            || has_param_vis_items
403            || has_wireframe_items
404        {
405            for item in scene_items {
406                // When instancing is active, skip items that will be rendered
407                // via the instanced path. They don't need per-object uniform
408                // writes; writing them anyway causes O(n) write_buffer calls
409                // for the whole scene whenever any single item is two-sided.
410                if self.use_instancing
411                    && !frame.viewport.wireframe_mode
412                    && item.active_attribute.is_none()
413                    && !item.material.is_two_sided()
414                    && item.material.matcap_id.is_none()
415                    && item.material.param_vis.is_none()
416                    && !item.render_as_wireframe
417                    && item.warp_attribute.is_none()
418                {
419                    continue;
420                }
421
422                if resources
423                    .mesh_store
424                    .get(item.mesh_id)
425                    .is_none()
426                {
427                    tracing::warn!(
428                        mesh_index = item.mesh_id.index(),
429                        "scene item mesh_index invalid, skipping"
430                    );
431                    continue;
432                };
433                let m = &item.material;
434                // Compute scalar attribute range.
435                let (has_attr, s_min, s_max) = if let Some(attr_ref) = &item.active_attribute {
436                    let range = item
437                        .scalar_range
438                        .or_else(|| {
439                            resources
440                                .mesh_store
441                                .get(item.mesh_id)
442                                .and_then(|mesh| mesh.attribute_ranges.get(&attr_ref.name).copied())
443                        })
444                        .unwrap_or((0.0, 1.0));
445                    (1u32, range.0, range.1)
446                } else {
447                    (0u32, 0.0, 1.0)
448                };
449                let obj_uniform = ObjectUniform {
450                    model: item.model,
451                    color: [m.base_color[0], m.base_color[1], m.base_color[2], m.opacity],
452                    selected: if item.selected { 1 } else { 0 },
453                    wireframe: if frame.viewport.wireframe_mode || item.render_as_wireframe { 1 } else { 0 },
454                    ambient: m.ambient,
455                    diffuse: m.diffuse,
456                    specular: m.specular,
457                    shininess: m.shininess,
458                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
459                    use_pbr: if m.use_pbr { 1 } else { 0 },
460                    metallic: m.metallic,
461                    roughness: m.roughness,
462                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
463                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
464                    has_attribute: has_attr,
465                    scalar_min: s_min,
466                    scalar_max: s_max,
467                    _pad_scalar: 0,
468                    nan_color: item.nan_color.unwrap_or([0.0; 4]),
469                    use_nan_color: if item.nan_color.is_some() { 1 } else { 0 },
470                    use_matcap: if m.matcap_id.is_some() { 1 } else { 0 },
471                    matcap_blendable: m.matcap_id.map_or(0, |id| if id.blendable { 1 } else { 0 }),
472                    unlit: if m.unlit { 1 } else { 0 },
473                    use_face_color: u32::from(item.active_attribute.as_ref().map_or(false, |a| {
474                        a.kind == crate::resources::AttributeKind::FaceColor
475                    })),
476                    uv_vis_mode: m.param_vis.map_or(0, |pv| pv.mode as u32),
477                    uv_vis_scale: m.param_vis.map_or(8.0, |pv| pv.scale),
478                    backface_policy: match m.backface_policy {
479                        crate::scene::material::BackfacePolicy::Cull => 0,
480                        crate::scene::material::BackfacePolicy::Identical => 1,
481                        crate::scene::material::BackfacePolicy::DifferentColor(_) => 2,
482                        crate::scene::material::BackfacePolicy::Tint(_) => 3,
483                        crate::scene::material::BackfacePolicy::Pattern(cfg) => {
484                            4 + cfg.pattern as u32
485                        }
486                    },
487                    backface_color: match m.backface_policy {
488                        crate::scene::material::BackfacePolicy::DifferentColor(c) => {
489                            [c[0], c[1], c[2], 1.0]
490                        }
491                        crate::scene::material::BackfacePolicy::Tint(factor) => {
492                            [factor, 0.0, 0.0, 1.0]
493                        }
494                        crate::scene::material::BackfacePolicy::Pattern(cfg) => {
495                            let world_extent = resources
496                                .mesh_store
497                                .get(item.mesh_id)
498                                .map(|mesh| {
499                                    mesh.aabb
500                                        .transformed(&glam::Mat4::from_cols_array_2d(&item.model))
501                                        .longest_side()
502                                })
503                                .unwrap_or(1.0)
504                                .max(1e-6);
505                            let world_scale = cfg.scale / world_extent;
506                            [cfg.color[0], cfg.color[1], cfg.color[2], world_scale]
507                        }
508                        _ => [0.0; 4],
509                    },
510                    has_warp: if item.warp_attribute.is_some() { 1 } else { 0 },
511                    warp_scale: item.warp_scale,
512                    _pad_warp: [0; 2],
513                };
514
515                let normal_obj_uniform = ObjectUniform {
516                    model: item.model,
517                    color: [1.0, 1.0, 1.0, 1.0],
518                    selected: 0,
519                    wireframe: 0,
520                    ambient: 0.15,
521                    diffuse: 0.75,
522                    specular: 0.4,
523                    shininess: 32.0,
524                    has_texture: 0,
525                    use_pbr: 0,
526                    metallic: 0.0,
527                    roughness: 0.5,
528                    has_normal_map: 0,
529                    has_ao_map: 0,
530                    has_attribute: 0,
531                    scalar_min: 0.0,
532                    scalar_max: 1.0,
533                    _pad_scalar: 0,
534                    nan_color: [0.0; 4],
535                    use_nan_color: 0,
536                    use_matcap: 0,
537                    matcap_blendable: 0,
538                    unlit: 0,
539                    use_face_color: 0,
540                    uv_vis_mode: 0,
541                    uv_vis_scale: 8.0,
542                    backface_policy: 0,
543                    backface_color: [0.0; 4],
544                    has_warp: 0,
545                    warp_scale: 1.0,
546                    _pad_warp: [0; 2],
547                };
548
549                // Write uniform data : use get() to read buffer references, then drop.
550                {
551                    let mesh = resources
552                        .mesh_store
553                        .get(item.mesh_id)
554                        .unwrap();
555                    queue.write_buffer(
556                        &mesh.object_uniform_buf,
557                        0,
558                        bytemuck::cast_slice(&[obj_uniform]),
559                    );
560                    queue.write_buffer(
561                        &mesh.normal_uniform_buf,
562                        0,
563                        bytemuck::cast_slice(&[normal_obj_uniform]),
564                    );
565                } // mesh borrow dropped here
566
567                // Rebuild the object bind group if material/attribute/LUT/matcap/warp changed.
568                resources.update_mesh_texture_bind_group(
569                    device,
570                    item.mesh_id,
571                    item.material.texture_id,
572                    item.material.normal_map_id,
573                    item.material.ao_map_id,
574                    item.colormap_id,
575                    item.active_attribute.as_ref().map(|a| a.name.as_str()),
576                    item.material.matcap_id,
577                    item.warp_attribute.as_deref(),
578                );
579            }
580        }
581
582        if self.use_instancing {
583            resources.ensure_instanced_pipelines(device);
584
585            // Generation-based cache: skip batch rebuild and GPU upload when nothing changed.
586            // Phase 2: wireframe_mode removed from cache key : wireframe rendering
587            // uses the per-object wireframe_pipeline, not the instanced path, so
588            // instance data is now viewport-agnostic.
589            //
590            // Items with active_attribute, two-sided policy, matcap, or param_vis are
591            // excluded from the instanced batch filter. These flags are set on render
592            // items AFTER collect_render_items() (per-frame mutations), so they do NOT
593            // bump the scene generation. Use last_instancable_count as a cache key
594            // instead of a blanket has_per_frame_mutations flag; this allows scenes
595            // that mix instanced and non-instanced items (e.g. one two-sided mesh +
596            // many static boxes) to still hit the instanced batch cache on frames
597            // where the filtered set is unchanged.
598            let instancable_count = scene_items.iter().filter(|item| {
599                item.visible
600                    && item.active_attribute.is_none()
601                    && !item.material.is_two_sided()
602                    && item.material.matcap_id.is_none()
603                    && item.material.param_vis.is_none()
604                    && resources.mesh_store.get(item.mesh_id).is_some()
605            }).count();
606            let cache_valid = instancable_count == self.last_instancable_count
607                && frame.scene.generation == self.last_scene_generation
608                && frame.interaction.selection_generation == self.last_selection_generation
609                && scene_items.len() == self.last_scene_items_count;
610
611            if !cache_valid {
612                // Cache miss : rebuild batches and upload instance data.
613                let mut sorted_items: Vec<&SceneRenderItem> = scene_items
614                    .iter()
615                    .filter(|item| {
616                        item.visible
617                            && item.active_attribute.is_none()
618                            && !item.material.is_two_sided()
619                            && item.material.matcap_id.is_none()
620                            && item.material.param_vis.is_none()
621                            && resources
622                                .mesh_store
623                                .get(item.mesh_id)
624                                .is_some()
625                    })
626                    .collect();
627
628                sorted_items.sort_unstable_by_key(|item| {
629                    (
630                        item.mesh_id.index(),
631                        item.material.texture_id,
632                        item.material.normal_map_id,
633                        item.material.ao_map_id,
634                    )
635                });
636
637                let mut all_instances: Vec<InstanceData> = Vec::with_capacity(sorted_items.len());
638                let mut all_aabbs: Vec<InstanceAabb> = Vec::with_capacity(sorted_items.len());
639                let mut batch_metas: Vec<BatchMeta> = Vec::new();
640                let mut instanced_batches: Vec<InstancedBatch> = Vec::new();
641
642                if !sorted_items.is_empty() {
643                    let mut batch_start = 0usize;
644                    for i in 1..=sorted_items.len() {
645                        let at_end = i == sorted_items.len();
646                        let key_changed = !at_end && {
647                            let a = sorted_items[batch_start];
648                            let b = sorted_items[i];
649                            a.mesh_id != b.mesh_id
650                                || a.material.texture_id != b.material.texture_id
651                                || a.material.normal_map_id != b.material.normal_map_id
652                                || a.material.ao_map_id != b.material.ao_map_id
653                        };
654
655                        if at_end || key_changed {
656                            let batch_items = &sorted_items[batch_start..i];
657                            let rep = batch_items[0];
658                            let instance_offset = all_instances.len() as u32;
659                            let is_transparent = rep.material.opacity < 1.0;
660
661                            for item in batch_items {
662                                let m = &item.material;
663                                all_instances.push(InstanceData {
664                                    model: item.model,
665                                    color: [
666                                        m.base_color[0],
667                                        m.base_color[1],
668                                        m.base_color[2],
669                                        m.opacity,
670                                    ],
671                                    selected: if item.selected { 1 } else { 0 },
672                                    wireframe: 0, // Phase 2: always 0 : wireframe uses per-object pipeline
673                                    ambient: m.ambient,
674                                    diffuse: m.diffuse,
675                                    specular: m.specular,
676                                    shininess: m.shininess,
677                                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
678                                    use_pbr: if m.use_pbr { 1 } else { 0 },
679                                    metallic: m.metallic,
680                                    roughness: m.roughness,
681                                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
682                                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
683                                    unlit: if m.unlit { 1 } else { 0 },
684                                    _pad_inst: [0; 3],
685                                });
686                            }
687
688                            // Build per-instance AABBs alongside instance data.
689                            // All items in a batch share the same mesh_id (batch key), so
690                            // mesh.index_count is the same for every item — look it up once.
691                            let batch_idx = instanced_batches.len() as u32;
692                            let mesh_index_count = resources
693                                .mesh_store
694                                .get(rep.mesh_id)
695                                .map(|m| m.index_count)
696                                .unwrap_or(0);
697                            for item in batch_items {
698                                if let Some(mesh) = resources.mesh_store.get(item.mesh_id) {
699                                    let model =
700                                        glam::Mat4::from_cols_array_2d(&item.model);
701                                    let world_aabb = mesh.aabb.transformed(&model);
702                                    all_aabbs.push(InstanceAabb {
703                                        min: world_aabb.min.into(),
704                                        batch_index: batch_idx,
705                                        max: world_aabb.max.into(),
706                                        _pad: 0,
707                                    });
708                                }
709                            }
710
711                            // vis_offset is the prefix sum of instance counts; since
712                            // instances are laid out contiguously per batch, it equals
713                            // instance_offset.
714                            batch_metas.push(BatchMeta {
715                                index_count: mesh_index_count,
716                                first_index: 0,
717                                instance_offset,
718                                instance_count: batch_items.len() as u32,
719                                vis_offset: instance_offset,
720                                is_transparent: if is_transparent { 1 } else { 0 },
721                                _pad: [0, 0],
722                            });
723
724                            instanced_batches.push(InstancedBatch {
725                                mesh_id: rep.mesh_id,
726                                texture_id: rep.material.texture_id,
727                                normal_map_id: rep.material.normal_map_id,
728                                ao_map_id: rep.material.ao_map_id,
729                                instance_offset,
730                                instance_count: batch_items.len() as u32,
731                                is_transparent,
732                            });
733
734                            batch_start = i;
735                        }
736                    }
737                }
738
739                self.cached_instance_data = all_instances;
740                self.cached_instanced_batches = instanced_batches;
741
742                resources.upload_instance_data(device, queue, &self.cached_instance_data);
743                resources.upload_aabb_and_batch_meta(device, queue, &all_aabbs, &batch_metas);
744
745                self.instanced_batches = self.cached_instanced_batches.clone();
746
747                self.last_scene_generation = frame.scene.generation;
748                self.last_selection_generation = frame.interaction.selection_generation;
749                self.last_scene_items_count = scene_items.len();
750                self.last_instancable_count = sorted_items.len();
751
752                for batch in &self.instanced_batches {
753                    resources.get_instance_bind_group(
754                        device,
755                        batch.texture_id,
756                        batch.normal_map_id,
757                        batch.ao_map_id,
758                    );
759                }
760            } else {
761                for batch in &self.instanced_batches {
762                    resources.get_instance_bind_group(
763                        device,
764                        batch.texture_id,
765                        batch.normal_map_id,
766                        batch.ao_map_id,
767                    );
768                }
769            }
770
771            // ------------------------------------------------------------------
772            // GPU cull dispatch (Phase 3)
773            //
774            // Run `cull_instances` + `write_indirect_args` whenever GPU culling
775            // is active and all required buffers are allocated.
776            // ------------------------------------------------------------------
777            if self.gpu_culling_enabled
778                && !self.instanced_batches.is_empty()
779                && !self.cached_instance_data.is_empty()
780            {
781                let instance_count = self.cached_instance_data.len() as u32;
782                let batch_count = self.instanced_batches.len() as u32;
783
784                // Do all mutable borrows before taking immutable borrows from resources.
785                if self.cull_resources.is_none() {
786                    self.cull_resources =
787                        Some(crate::renderer::indirect::CullResources::new(device));
788                }
789                resources.ensure_cull_instance_pipelines(device);
790                for batch in &self.instanced_batches.clone() {
791                    resources.get_instance_cull_bind_group(
792                        device,
793                        batch.texture_id,
794                        batch.normal_map_id,
795                        batch.ao_map_id,
796                    );
797                }
798
799                // Now take immutable borrows to the GPU buffers for dispatch.
800                if let (
801                    Some(aabb_buf),
802                    Some(meta_buf),
803                    Some(counter_buf),
804                    Some(vis_buf),
805                    Some(indirect_buf),
806                ) = (
807                    resources.instance_aabb_buf.as_ref(),
808                    resources.batch_meta_buf.as_ref(),
809                    resources.batch_counter_buf.as_ref(),
810                    resources.visibility_index_buf.as_ref(),
811                    resources.indirect_args_buf.as_ref(),
812                ) {
813                    // Build the FrustumUniform from the current camera view-projection.
814                    let vp_mat = frame.camera.render_camera.view_proj();
815                    let cpu_frustum =
816                        crate::camera::frustum::Frustum::from_view_proj(&vp_mat);
817                    let frustum_uniform = crate::resources::FrustumUniform {
818                        planes: std::array::from_fn(|i| crate::resources::FrustumPlane {
819                            normal: cpu_frustum.planes[i].normal.into(),
820                            distance: cpu_frustum.planes[i].d,
821                        }),
822                        instance_count,
823                        batch_count,
824                        _pad: [0; 2],
825                    };
826
827                    let cull = self.cull_resources.as_ref().unwrap();
828                    let mut encoder =
829                        device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
830                            label: Some("cull_encoder"),
831                        });
832                    cull.dispatch(
833                        &mut encoder,
834                        device,
835                        queue,
836                        &frustum_uniform,
837                        aabb_buf,
838                        meta_buf,
839                        counter_buf,
840                        vis_buf,
841                        indirect_buf,
842                        instance_count,
843                        batch_count,
844                    );
845
846                    // Copy indirect_args_buf to the CPU-readable staging buffer so the
847                    // visible instance count can be read back next frame (one-frame lag).
848                    let indirect_bytes = batch_count as u64 * 20;
849                    if self
850                        .indirect_readback_buf
851                        .as_ref()
852                        .map_or(0, |b| b.size())
853                        < indirect_bytes
854                    {
855                        self.indirect_readback_buf =
856                            Some(device.create_buffer(&wgpu::BufferDescriptor {
857                                label: Some("indirect_readback_buf"),
858                                size: indirect_bytes,
859                                usage: wgpu::BufferUsages::COPY_DST
860                                    | wgpu::BufferUsages::MAP_READ,
861                                mapped_at_creation: false,
862                            }));
863                    }
864                    if let Some(ref rb_buf) = self.indirect_readback_buf {
865                        encoder.copy_buffer_to_buffer(
866                            indirect_buf,
867                            0,
868                            rb_buf,
869                            0,
870                            indirect_bytes,
871                        );
872                    }
873                    queue.submit(std::iter::once(encoder.finish()));
874                    self.indirect_readback_batch_count = batch_count;
875                    self.indirect_readback_pending = true;
876                }
877            }
878        }
879
880        // ------------------------------------------------------------------
881        // SciVis Phase B : point cloud and glyph GPU data upload.
882        // ------------------------------------------------------------------
883        self.point_cloud_gpu_data.clear();
884        if !frame.scene.point_clouds.is_empty() {
885            resources.ensure_point_cloud_pipeline(device);
886            for item in &frame.scene.point_clouds {
887                if item.positions.is_empty() {
888                    continue;
889                }
890                let gpu_data = resources.upload_point_cloud(device, queue, item);
891                self.point_cloud_gpu_data.push(gpu_data);
892            }
893        }
894
895        self.glyph_gpu_data.clear();
896        if !frame.scene.glyphs.is_empty() {
897            resources.ensure_glyph_pipeline(device);
898            for item in &frame.scene.glyphs {
899                if item.positions.is_empty() || item.vectors.is_empty() {
900                    continue;
901                }
902                let gpu_data = resources.upload_glyph_set(device, queue, item);
903                self.glyph_gpu_data.push(gpu_data);
904            }
905        }
906
907        // ------------------------------------------------------------------
908        // Sprite billboard GPU data upload.
909        // ------------------------------------------------------------------
910        self.sprite_gpu_data.clear();
911        if !frame.scene.sprite_items.is_empty() {
912            resources.ensure_sprite_pipelines(device);
913            for item in &frame.scene.sprite_items {
914                if item.positions.is_empty() {
915                    continue;
916                }
917                let gd = resources.upload_sprite(device, queue, item);
918                self.sprite_gpu_data.push(gd);
919            }
920        }
921
922        // ------------------------------------------------------------------
923        // SciVis Phase 5 : tensor glyph GPU data upload.
924        // ------------------------------------------------------------------
925        self.tensor_glyph_gpu_data.clear();
926        if !frame.scene.tensor_glyphs.is_empty() {
927            resources.ensure_tensor_glyph_pipeline(device);
928            for item in &frame.scene.tensor_glyphs {
929                if item.positions.is_empty() {
930                    continue;
931                }
932                let gd = resources.upload_tensor_glyph_set(device, queue, item);
933                self.tensor_glyph_gpu_data.push(gd);
934            }
935        }
936
937        // ------------------------------------------------------------------
938        // SciVis Phase M8 : polyline GPU data upload.
939        // ------------------------------------------------------------------
940        self.polyline_gpu_data.clear();
941        let vp_size = frame.camera.viewport_size;
942        if !frame.scene.polylines.is_empty() {
943            resources.ensure_polyline_pipeline(device);
944            for item in &frame.scene.polylines {
945                if item.positions.is_empty() {
946                    continue;
947                }
948                let gpu_data = resources.upload_polyline(device, queue, item, vp_size);
949                self.polyline_gpu_data.push(gpu_data);
950
951                // Phase 11: auto-generate GlyphItems for node/edge vector quantities.
952                if !item.node_vectors.is_empty() {
953                    resources.ensure_glyph_pipeline(device);
954                    let g = crate::quantities::polyline_node_vectors_to_glyphs(item);
955                    if !g.positions.is_empty() {
956                        let gd = resources.upload_glyph_set(device, queue, &g);
957                        self.glyph_gpu_data.push(gd);
958                    }
959                }
960                if !item.edge_vectors.is_empty() {
961                    resources.ensure_glyph_pipeline(device);
962                    let g = crate::quantities::polyline_edge_vectors_to_glyphs(item);
963                    if !g.positions.is_empty() {
964                        let gd = resources.upload_glyph_set(device, queue, &g);
965                        self.glyph_gpu_data.push(gd);
966                    }
967                }
968            }
969        }
970
971        // ------------------------------------------------------------------
972        // SciVis Phase L : isoline extraction and upload via polyline pipeline.
973        // ------------------------------------------------------------------
974        if !frame.scene.isolines.is_empty() {
975            resources.ensure_polyline_pipeline(device);
976            for item in &frame.scene.isolines {
977                if item.positions.is_empty() || item.indices.is_empty() || item.scalars.is_empty() {
978                    continue;
979                }
980                let (positions, strip_lengths) = crate::geometry::isoline::extract_isolines(item);
981                if positions.is_empty() {
982                    continue;
983                }
984                let polyline = PolylineItem {
985                    positions,
986                    scalars: Vec::new(),
987                    strip_lengths,
988                    scalar_range: None,
989                    colormap_id: None,
990                    default_color: item.color,
991                    line_width: item.line_width,
992                    id: 0,
993                    ..Default::default()
994                };
995                let gpu_data = resources.upload_polyline(device, queue, &polyline, vp_size);
996                self.polyline_gpu_data.push(gpu_data);
997            }
998        }
999
1000        // ------------------------------------------------------------------
1001        // Phase 10A : camera frustum wireframes (converted to polylines).
1002        // ------------------------------------------------------------------
1003        if !frame.scene.camera_frustums.is_empty() {
1004            resources.ensure_polyline_pipeline(device);
1005            for item in &frame.scene.camera_frustums {
1006                let polyline = item.to_polyline();
1007                if !polyline.positions.is_empty() {
1008                    let gpu_data = resources.upload_polyline(device, queue, &polyline, vp_size);
1009                    self.polyline_gpu_data.push(gpu_data);
1010                }
1011            }
1012        }
1013
1014        // ------------------------------------------------------------------
1015        // Phase 16 : GPU implicit surface items.
1016        // ------------------------------------------------------------------
1017        self.implicit_gpu_data.clear();
1018        if !frame.scene.gpu_implicit.is_empty() {
1019            resources.ensure_implicit_pipeline(device);
1020            for item in &frame.scene.gpu_implicit {
1021                if item.primitives.is_empty() {
1022                    continue;
1023                }
1024                let gpu = resources.upload_implicit_item(device, item);
1025                self.implicit_gpu_data.push(gpu);
1026            }
1027        }
1028
1029        // ------------------------------------------------------------------
1030        // Phase 17 : GPU marching cubes compute dispatch.
1031        // ------------------------------------------------------------------
1032        self.mc_gpu_data.clear();
1033        if !frame.scene.gpu_mc_jobs.is_empty() {
1034            resources.ensure_mc_pipelines(device);
1035            self.mc_gpu_data =
1036                resources.run_mc_jobs(device, queue, &frame.scene.gpu_mc_jobs);
1037        }
1038
1039        // ------------------------------------------------------------------
1040        // Phase 10B : screen-space image overlays.
1041        // ------------------------------------------------------------------
1042        self.screen_image_gpu_data.clear();
1043        if !frame.scene.screen_images.is_empty() {
1044            resources.ensure_screen_image_pipeline(device);
1045            // Phase 12: ensure dc pipeline if any item carries depth data.
1046            if frame.scene.screen_images.iter().any(|i| i.depth.is_some()) {
1047                resources.ensure_screen_image_dc_pipeline(device);
1048            }
1049            let vp_w = vp_size[0];
1050            let vp_h = vp_size[1];
1051            for item in &frame.scene.screen_images {
1052                if item.width == 0 || item.height == 0 || item.pixels.is_empty() {
1053                    continue;
1054                }
1055                let gpu = resources.upload_screen_image(device, queue, item, vp_w, vp_h);
1056                self.screen_image_gpu_data.push(gpu);
1057            }
1058        }
1059
1060        // ------------------------------------------------------------------
1061        // Phase 7 : overlay image overlays (OverlayFrame).
1062        // ------------------------------------------------------------------
1063        self.overlay_image_gpu_data.clear();
1064        if !frame.overlays.images.is_empty() {
1065            resources.ensure_screen_image_pipeline(device);
1066            let vp_w = vp_size[0];
1067            let vp_h = vp_size[1];
1068            for item in &frame.overlays.images {
1069                if item.width == 0 || item.height == 0 || item.pixels.is_empty() {
1070                    continue;
1071                }
1072                let gpu = resources.upload_overlay_image(device, queue, item, vp_w, vp_h);
1073                self.overlay_image_gpu_data.push(gpu);
1074            }
1075        }
1076
1077        // ------------------------------------------------------------------
1078        // SciVis Phase M : streamtube GPU data upload.
1079        // ------------------------------------------------------------------
1080        self.streamtube_gpu_data.clear();
1081        if !frame.scene.streamtube_items.is_empty() {
1082            resources.ensure_streamtube_pipeline(device);
1083            for item in &frame.scene.streamtube_items {
1084                if item.positions.is_empty() || item.strip_lengths.is_empty() {
1085                    continue;
1086                }
1087                let gpu_data = resources.upload_streamtube(device, queue, item);
1088                if gpu_data.index_count > 0 {
1089                    self.streamtube_gpu_data.push(gpu_data);
1090                }
1091            }
1092        }
1093
1094        // ------------------------------------------------------------------
1095        // Phase 3.3 : General Tube GPU data upload.
1096        // ------------------------------------------------------------------
1097        self.tube_gpu_data.clear();
1098        if !frame.scene.tube_items.is_empty() {
1099            resources.ensure_streamtube_pipeline(device);
1100            for item in &frame.scene.tube_items {
1101                if item.positions.is_empty() || item.strip_lengths.is_empty() {
1102                    continue;
1103                }
1104                let gpu_data = resources.upload_tube(device, queue, item);
1105                if gpu_data.index_count > 0 {
1106                    self.tube_gpu_data.push(gpu_data);
1107                }
1108            }
1109        }
1110
1111        // ------------------------------------------------------------------
1112        // Phase 8.1 : Ribbon GPU data upload.
1113        // ------------------------------------------------------------------
1114        self.ribbon_gpu_data.clear();
1115        if !frame.scene.ribbon_items.is_empty() {
1116            resources.ensure_streamtube_pipeline(device);
1117            for item in &frame.scene.ribbon_items {
1118                if item.positions.is_empty() || item.strip_lengths.is_empty() {
1119                    continue;
1120                }
1121                let gpu_data = resources.upload_ribbon(device, queue, item);
1122                if gpu_data.index_count > 0 {
1123                    self.ribbon_gpu_data.push(gpu_data);
1124                }
1125            }
1126        }
1127
1128        // ------------------------------------------------------------------
1129        // Phase 3.2 : Image Slice GPU data upload.
1130        // ------------------------------------------------------------------
1131        self.image_slice_gpu_data.clear();
1132        if !frame.scene.image_slices.is_empty() {
1133            resources.ensure_image_slice_pipeline(device);
1134            for item in &frame.scene.image_slices {
1135                if let Some(gpu_data) = resources.upload_image_slice(device, queue, item) {
1136                    self.image_slice_gpu_data.push(gpu_data);
1137                }
1138            }
1139        }
1140
1141        // ------------------------------------------------------------------
1142        // Phase 10 : Volume Surface Slice GPU data upload.
1143        // ------------------------------------------------------------------
1144        self.volume_surface_slice_gpu_data.clear();
1145        if !frame.scene.volume_surface_slices.is_empty() {
1146            resources.ensure_volume_surface_slice_pipeline(device);
1147            for item in &frame.scene.volume_surface_slices {
1148                if let Some(gpu_data) = resources.upload_volume_surface_slice(device, queue, item) {
1149                    self.volume_surface_slice_gpu_data.push(gpu_data);
1150                }
1151            }
1152        }
1153
1154        // ------------------------------------------------------------------
1155        // Phase 4: Surface LIC GPU data upload.
1156        // ------------------------------------------------------------------
1157        self.lic_gpu_data.clear();
1158        if !frame.scene.lic_items.is_empty() {
1159            // The LIC surface pipeline is created inside ensure_hdr_shared (already called before
1160            // prepare_scene_internal runs), so no separate ensure call is needed here.
1161            for item in &frame.scene.lic_items {
1162                if item.vector_attribute.is_empty() {
1163                    continue;
1164                }
1165                if let Some(mesh) = resources.mesh_store.get(item.mesh_id) {
1166                    // Verify the vector attribute buffer exists before committing to this item.
1167                    if mesh.vector_attribute_buffers.contains_key(&item.vector_attribute) {
1168                        if let Some(bgl) = &resources.lic_surface_bgl {
1169                            use crate::resources::LicObjectUniform;
1170                            let model = item.model;
1171                            let obj_data = LicObjectUniform { model };
1172                            let obj_buf = device.create_buffer(&wgpu::BufferDescriptor {
1173                                label: Some("lic_object_uniform"),
1174                                size: std::mem::size_of::<LicObjectUniform>() as u64,
1175                                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1176                                mapped_at_creation: false,
1177                            });
1178                            queue.write_buffer(&obj_buf, 0, bytemuck::cast_slice(&[obj_data]));
1179                            // Bind group (group 1): object uniform only.
1180                            // Flow vectors are bound as vertex buffer 1 in the render pass.
1181                            let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1182                                label: Some("lic_surface_item_bg"),
1183                                layout: bgl,
1184                                entries: &[
1185                                    wgpu::BindGroupEntry {
1186                                        binding: 0,
1187                                        resource: obj_buf.as_entire_binding(),
1188                                    },
1189                                ],
1190                            });
1191                            self.lic_gpu_data.push(crate::resources::LicSurfaceGpuData {
1192                                bind_group: bg,
1193                                _object_uniform_buf: obj_buf,
1194                                mesh_id: item.mesh_id,
1195                                vector_attribute: item.vector_attribute.clone(),
1196                            });
1197                        }
1198                    }
1199                }
1200            }
1201            // Write LicAdvectUniform to the per-viewport buffer.
1202            if let Some(hdr) = self.viewport_slots[frame.camera.viewport_index].hdr.as_ref() {
1203                if let Some(first) = frame.scene.lic_items.first() {
1204                    let [vw, vh] = hdr.size;
1205                    let u = crate::resources::LicAdvectUniform {
1206                        steps: first.config.steps,
1207                        step_size: first.config.step_size,
1208                        vp_width: vw as f32,
1209                        vp_height: vh as f32,
1210                    };
1211                    queue.write_buffer(&hdr.lic_uniform_buf, 0, bytemuck::cast_slice(&[u]));
1212                }
1213            }
1214        }
1215
1216        // ------------------------------------------------------------------
1217        // SciVis Phase D : volume GPU data upload.
1218        // Phase 1 note: clip_planes are per-viewport but passed here for culling.
1219        // Fix in Phase 2/3: upload clip-plane-agnostic data; apply planes in shader.
1220        // ------------------------------------------------------------------
1221        self.volume_gpu_data.clear();
1222        if !frame.scene.volumes.is_empty() {
1223            resources.ensure_volume_pipeline(device);
1224            let clip_objects_for_vol = &frame.effects.clip_objects;
1225            // Phase 5: under budget pressure with allow_volume_quality_reduction, double the
1226            // step size (half the sample count) to reduce GPU raymarch cost.
1227            let vol_step_multiplier = if self.degradation_volume_quality_reduced {
1228                2.0_f32
1229            } else {
1230                1.0_f32
1231            };
1232            for item in &frame.scene.volumes {
1233                let gpu = resources.upload_volume_frame(
1234                    device,
1235                    queue,
1236                    item,
1237                    clip_objects_for_vol,
1238                    vol_step_multiplier,
1239                );
1240                self.volume_gpu_data.push(gpu);
1241            }
1242        }
1243
1244        // -- Frame stats --
1245        {
1246            let total = scene_items.len() as u32;
1247            let visible = scene_items.iter().filter(|i| i.visible).count() as u32;
1248            let mut draw_calls = 0u32;
1249            let mut triangles = 0u64;
1250            let instanced_batch_count = if self.use_instancing {
1251                self.instanced_batches.len() as u32
1252            } else {
1253                0
1254            };
1255
1256            if self.use_instancing {
1257                for batch in &self.instanced_batches {
1258                    if let Some(mesh) = resources
1259                        .mesh_store
1260                        .get(batch.mesh_id)
1261                    {
1262                        draw_calls += 1;
1263                        triangles += (mesh.index_count / 3) as u64 * batch.instance_count as u64;
1264                    }
1265                }
1266            } else {
1267                for item in scene_items {
1268                    if !item.visible {
1269                        continue;
1270                    }
1271                    if let Some(mesh) = resources
1272                        .mesh_store
1273                        .get(item.mesh_id)
1274                    {
1275                        draw_calls += 1;
1276                        triangles += (mesh.index_count / 3) as u64;
1277                    }
1278                }
1279            }
1280
1281            self.last_stats = crate::renderer::stats::FrameStats {
1282                total_objects: total,
1283                visible_objects: visible,
1284                culled_objects: total.saturating_sub(visible),
1285                draw_calls,
1286                instanced_batches: instanced_batch_count,
1287                triangles_submitted: triangles,
1288                shadow_draw_calls: 0, // Updated below in shadow pass.
1289                gpu_culling_active: self.gpu_culling_enabled,
1290                // Clear stale readback if GPU culling is off this frame.
1291                gpu_visible_instances: if self.gpu_culling_enabled {
1292                    self.last_stats.gpu_visible_instances
1293                } else {
1294                    None
1295                },
1296                ..self.last_stats
1297            };
1298        }
1299
1300        // ------------------------------------------------------------------
1301        // Shadow depth pass : CSM: render each cascade into its atlas tile.
1302        // Phase 5: skip the pass entirely when over budget and shadow reduction is allowed.
1303        // ------------------------------------------------------------------
1304        let skip_shadows = self.degradation_shadows_skipped;
1305
1306        // When skipping the shadow pass (budget pressure or empty scene), clear the
1307        // atlas to max depth so that stale values from a previous frame or a previous
1308        // showcase don't produce phantom shadows.
1309        if lighting.shadows_enabled && (skip_shadows || scene_items.is_empty()) {
1310            let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1311                label: Some("shadow_clear_encoder"),
1312            });
1313            let _ = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
1314                label: Some("shadow_clear_pass"),
1315                color_attachments: &[],
1316                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1317                    view: &resources.shadow_map_view,
1318                    depth_ops: Some(wgpu::Operations {
1319                        load: wgpu::LoadOp::Clear(1.0),
1320                        store: wgpu::StoreOp::Store,
1321                    }),
1322                    stencil_ops: None,
1323                }),
1324                timestamp_writes: None,
1325                occlusion_query_set: None,
1326            });
1327            queue.submit(std::iter::once(enc.finish()));
1328        }
1329
1330        if lighting.shadows_enabled && !scene_items.is_empty() && !skip_shadows {
1331            // ------------------------------------------------------------------
1332            // Shadow GPU cull dispatch (Phase 4)
1333            //
1334            // For each active cascade, dispatch `cull_instances` + `write_indirect_args`
1335            // with the cascade frustum. Results land in `shadow_vis_bufs[c]` and
1336            // `shadow_indirect_bufs[c]`, consumed by the shadow render pass below.
1337            // All cascade dispatches share the same `batch_counter_buf`; each
1338            // `write_indirect_args` dispatch resets the counters for the next cascade.
1339            // ------------------------------------------------------------------
1340            if self.gpu_culling_enabled
1341                && self.use_instancing
1342                && !self.instanced_batches.is_empty()
1343                && !self.cached_instance_data.is_empty()
1344            {
1345                // Mutable operations first.
1346                if self.cull_resources.is_none() {
1347                    self.cull_resources =
1348                        Some(crate::renderer::indirect::CullResources::new(device));
1349                }
1350                resources.ensure_cull_instance_pipelines(device);
1351                for c in 0..effective_cascade_count {
1352                    resources.get_shadow_cull_instance_bind_group(device, c);
1353                }
1354
1355                let instance_count = self.cached_instance_data.len() as u32;
1356                let batch_count = self.instanced_batches.len() as u32;
1357
1358                if let (Some(aabb_buf), Some(meta_buf), Some(counter_buf)) = (
1359                    resources.instance_aabb_buf.as_ref(),
1360                    resources.batch_meta_buf.as_ref(),
1361                    resources.batch_counter_buf.as_ref(),
1362                ) {
1363                    let cull = self.cull_resources.as_ref().unwrap();
1364                    let mut shadow_cull_encoder =
1365                        device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1366                            label: Some("shadow_cull_encoder"),
1367                        });
1368                    for c in 0..effective_cascade_count {
1369                        if let (Some(shadow_vis_buf), Some(shadow_indirect_buf)) = (
1370                            resources.shadow_vis_bufs[c].as_ref(),
1371                            resources.shadow_indirect_bufs[c].as_ref(),
1372                        ) {
1373                            let cpu_frustum =
1374                                crate::camera::frustum::Frustum::from_view_proj(
1375                                    &cascade_view_projs[c],
1376                                );
1377                            let frustum_uniform = crate::resources::FrustumUniform {
1378                                planes: std::array::from_fn(|i| crate::resources::FrustumPlane {
1379                                    normal: cpu_frustum.planes[i].normal.into(),
1380                                    distance: cpu_frustum.planes[i].d,
1381                                }),
1382                                instance_count,
1383                                batch_count,
1384                                _pad: [0; 2],
1385                            };
1386                            cull.dispatch_shadow(
1387                                &mut shadow_cull_encoder,
1388                                device,
1389                                queue,
1390                                c,
1391                                &frustum_uniform,
1392                                aabb_buf,
1393                                meta_buf,
1394                                counter_buf,
1395                                shadow_vis_buf,
1396                                shadow_indirect_buf,
1397                                instance_count,
1398                                batch_count,
1399                            );
1400                        }
1401                    }
1402                    queue.submit(std::iter::once(shadow_cull_encoder.finish()));
1403                }
1404            }
1405
1406            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1407                label: Some("shadow_pass_encoder"),
1408            });
1409            {
1410                let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1411                    label: Some("shadow_pass"),
1412                    color_attachments: &[],
1413                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1414                        view: &resources.shadow_map_view,
1415                        depth_ops: Some(wgpu::Operations {
1416                            load: wgpu::LoadOp::Clear(1.0),
1417                            store: wgpu::StoreOp::Store,
1418                        }),
1419                        stencil_ops: None,
1420                    }),
1421                    timestamp_writes: None,
1422                    occlusion_query_set: None,
1423                });
1424
1425                let mut shadow_draws = 0u32;
1426                let tile_px = tile_size as f32;
1427
1428                if self.use_instancing {
1429                    let use_shadow_indirect = self.gpu_culling_enabled
1430                        && resources.shadow_instanced_cull_pipeline.is_some()
1431                        && resources.shadow_vis_bufs[0].is_some();
1432
1433                    if use_shadow_indirect {
1434                        // GPU-culled indirect shadow path (Phase 4).
1435                        for cascade in 0..effective_cascade_count {
1436                            let tile_col = (cascade % 2) as f32;
1437                            let tile_row = (cascade / 2) as f32;
1438                            shadow_pass.set_viewport(
1439                                tile_col * tile_px,
1440                                tile_row * tile_px,
1441                                tile_px,
1442                                tile_px,
1443                                0.0,
1444                                1.0,
1445                            );
1446                            shadow_pass.set_scissor_rect(
1447                                (tile_col * tile_px) as u32,
1448                                (tile_row * tile_px) as u32,
1449                                tile_size,
1450                                tile_size,
1451                            );
1452
1453                            // Write cascade view-projection matrix.
1454                            queue.write_buffer(
1455                                resources.shadow_instanced_cascade_bufs[cascade]
1456                                    .as_ref()
1457                                    .expect("shadow_instanced_cascade_bufs not allocated"),
1458                                0,
1459                                bytemuck::cast_slice(
1460                                    &cascade_view_projs[cascade].to_cols_array_2d(),
1461                                ),
1462                            );
1463
1464                            let Some(pipeline) =
1465                                resources.shadow_instanced_cull_pipeline.as_ref()
1466                            else {
1467                                continue;
1468                            };
1469                            let Some(cascade_bg) =
1470                                resources.shadow_instanced_cascade_bgs[cascade].as_ref()
1471                            else {
1472                                continue;
1473                            };
1474                            let Some(inst_cull_bg) =
1475                                resources.shadow_cull_instance_bgs[cascade].as_ref()
1476                            else {
1477                                continue;
1478                            };
1479                            let Some(shadow_indirect_buf) =
1480                                resources.shadow_indirect_bufs[cascade].as_ref()
1481                            else {
1482                                continue;
1483                            };
1484
1485                            shadow_pass.set_pipeline(pipeline);
1486                            shadow_pass.set_bind_group(0, cascade_bg, &[]);
1487                            shadow_pass.set_bind_group(1, inst_cull_bg, &[]);
1488
1489                            for (bi, batch) in self.instanced_batches.iter().enumerate() {
1490                                if batch.is_transparent {
1491                                    continue;
1492                                }
1493                                let Some(mesh) = resources.mesh_store.get(batch.mesh_id) else {
1494                                    continue;
1495                                };
1496                                shadow_pass
1497                                    .set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1498                                shadow_pass.set_index_buffer(
1499                                    mesh.index_buffer.slice(..),
1500                                    wgpu::IndexFormat::Uint32,
1501                                );
1502                                shadow_pass.draw_indexed_indirect(
1503                                    shadow_indirect_buf,
1504                                    bi as u64 * 20,
1505                                );
1506                                shadow_draws += 1;
1507                            }
1508                        }
1509                    } else if let (Some(pipeline), Some(instance_bg)) = (
1510                        &resources.shadow_instanced_pipeline,
1511                        self.instanced_batches.first().and_then(|b| {
1512                            resources.instance_bind_groups.get(&(
1513                                b.texture_id.unwrap_or(u64::MAX),
1514                                b.normal_map_id.unwrap_or(u64::MAX),
1515                                b.ao_map_id.unwrap_or(u64::MAX),
1516                            ))
1517                        }),
1518                    ) {
1519                        // Direct draw shadow path (fallback when GPU culling is off).
1520                        for cascade in 0..effective_cascade_count {
1521                            let tile_col = (cascade % 2) as f32;
1522                            let tile_row = (cascade / 2) as f32;
1523                            shadow_pass.set_viewport(
1524                                tile_col * tile_px,
1525                                tile_row * tile_px,
1526                                tile_px,
1527                                tile_px,
1528                                0.0,
1529                                1.0,
1530                            );
1531                            shadow_pass.set_scissor_rect(
1532                                (tile_col * tile_px) as u32,
1533                                (tile_row * tile_px) as u32,
1534                                tile_size,
1535                                tile_size,
1536                            );
1537
1538                            shadow_pass.set_pipeline(pipeline);
1539
1540                            queue.write_buffer(
1541                                resources.shadow_instanced_cascade_bufs[cascade]
1542                                    .as_ref()
1543                                    .expect("shadow_instanced_cascade_bufs not allocated"),
1544                                0,
1545                                bytemuck::cast_slice(
1546                                    &cascade_view_projs[cascade].to_cols_array_2d(),
1547                                ),
1548                            );
1549
1550                            let cascade_bg = resources.shadow_instanced_cascade_bgs[cascade]
1551                                .as_ref()
1552                                .expect("shadow_instanced_cascade_bgs not allocated");
1553                            shadow_pass.set_bind_group(0, cascade_bg, &[]);
1554                            shadow_pass.set_bind_group(1, instance_bg, &[]);
1555
1556                            for batch in &self.instanced_batches {
1557                                if batch.is_transparent {
1558                                    continue;
1559                                }
1560                                let Some(mesh) = resources
1561                                    .mesh_store
1562                                    .get(batch.mesh_id)
1563                                else {
1564                                    continue;
1565                                };
1566                                shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1567                                shadow_pass.set_index_buffer(
1568                                    mesh.index_buffer.slice(..),
1569                                    wgpu::IndexFormat::Uint32,
1570                                );
1571                                shadow_pass.draw_indexed(
1572                                    0..mesh.index_count,
1573                                    0,
1574                                    batch.instance_offset
1575                                        ..batch.instance_offset + batch.instance_count,
1576                                );
1577                                shadow_draws += 1;
1578                            }
1579                        }
1580                    }
1581                } else {
1582                    for cascade in 0..effective_cascade_count {
1583                        let tile_col = (cascade % 2) as f32;
1584                        let tile_row = (cascade / 2) as f32;
1585                        shadow_pass.set_viewport(
1586                            tile_col * tile_px,
1587                            tile_row * tile_px,
1588                            tile_px,
1589                            tile_px,
1590                            0.0,
1591                            1.0,
1592                        );
1593                        shadow_pass.set_scissor_rect(
1594                            (tile_col * tile_px) as u32,
1595                            (tile_row * tile_px) as u32,
1596                            tile_size,
1597                            tile_size,
1598                        );
1599
1600                        shadow_pass.set_pipeline(&resources.shadow_pipeline);
1601                        shadow_pass.set_bind_group(
1602                            0,
1603                            &resources.shadow_bind_group,
1604                            &[cascade as u32 * 256],
1605                        );
1606
1607                        let cascade_frustum = crate::camera::frustum::Frustum::from_view_proj(
1608                            &cascade_view_projs[cascade],
1609                        );
1610
1611                        for item in scene_items.iter() {
1612                            if !item.visible {
1613                                continue;
1614                            }
1615                            if item.material.opacity < 1.0 {
1616                                continue;
1617                            }
1618                            let Some(mesh) = resources
1619                                .mesh_store
1620                                .get(item.mesh_id)
1621                            else {
1622                                continue;
1623                            };
1624
1625                            let world_aabb = mesh
1626                                .aabb
1627                                .transformed(&glam::Mat4::from_cols_array_2d(&item.model));
1628                            if cascade_frustum.cull_aabb(&world_aabb) {
1629                                continue;
1630                            }
1631
1632                            shadow_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1633                            shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1634                            shadow_pass.set_index_buffer(
1635                                mesh.index_buffer.slice(..),
1636                                wgpu::IndexFormat::Uint32,
1637                            );
1638                            shadow_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1639                            shadow_draws += 1;
1640                        }
1641                    }
1642                }
1643                drop(shadow_pass);
1644                self.last_stats.shadow_draw_calls = shadow_draws;
1645            }
1646            queue.submit(std::iter::once(encoder.finish()));
1647        }
1648    }
1649
1650    /// Per-viewport prepare stage: camera, clip planes, clip volume, grid, overlays, cap geometry, axes.
1651    ///
1652    /// Call once per viewport per frame, after `prepare_scene_internal`.
1653    /// Reads `viewport_fx` for clip planes, clip volume, cap fill, and post-process settings.
1654    pub(super) fn prepare_viewport_internal(
1655        &mut self,
1656        device: &wgpu::Device,
1657        queue: &wgpu::Queue,
1658        frame: &FrameData,
1659        viewport_fx: &ViewportEffects<'_>,
1660    ) {
1661        // Ensure a per-viewport camera slot exists for this viewport index.
1662        // Must happen before the `resources` borrow below.
1663        self.ensure_viewport_slot(device, frame.camera.viewport_index);
1664
1665        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
1666            SurfaceSubmission::Flat(items) => items.as_ref(),
1667        };
1668
1669        // Capture before the resources mutable borrow so it's accessible inside the block.
1670        let gp_cascade0_mat = self.last_cascade0_shadow_mat.to_cols_array_2d();
1671
1672        {
1673            let resources = &mut self.resources;
1674
1675            // Upload clip planes + clip volume uniforms from clip_objects.
1676            {
1677                let mut planes = [[0.0f32; 4]; 6];
1678                let mut count = 0u32;
1679                let mut clip_vols_uniform: ClipVolumesUniform = bytemuck::Zeroable::zeroed();
1680
1681                for obj in viewport_fx
1682                    .clip_objects
1683                    .iter()
1684                    .filter(|o| o.enabled && o.clip_geometry)
1685                {
1686                    match obj.shape {
1687                        ClipShape::Plane {
1688                            normal, distance, ..
1689                        } if count < 6 => {
1690                            planes[count as usize] = [normal[0], normal[1], normal[2], distance];
1691                            count += 1;
1692                        }
1693                        ClipShape::Box {
1694                            center,
1695                            half_extents,
1696                            orientation,
1697                        } if (clip_vols_uniform.count as usize) < CLIP_VOLUME_MAX => {
1698                            let idx = clip_vols_uniform.count as usize;
1699                            clip_vols_uniform.volumes[idx] =
1700                                ClipVolumeEntry::from_box(center, half_extents, orientation);
1701                            clip_vols_uniform.count += 1;
1702                        }
1703                        ClipShape::Sphere { center, radius }
1704                            if (clip_vols_uniform.count as usize) < CLIP_VOLUME_MAX =>
1705                        {
1706                            let idx = clip_vols_uniform.count as usize;
1707                            clip_vols_uniform.volumes[idx] =
1708                                ClipVolumeEntry::from_sphere(center, radius);
1709                            clip_vols_uniform.count += 1;
1710                        }
1711                        ClipShape::Cylinder {
1712                            center,
1713                            axis,
1714                            radius,
1715                            half_length,
1716                        } if (clip_vols_uniform.count as usize) < CLIP_VOLUME_MAX => {
1717                            let idx = clip_vols_uniform.count as usize;
1718                            clip_vols_uniform.volumes[idx] =
1719                                ClipVolumeEntry::from_cylinder(center, axis, radius, half_length);
1720                            clip_vols_uniform.count += 1;
1721                        }
1722                        _ => {}
1723                    }
1724                }
1725
1726                let clip_uniform = ClipPlanesUniform {
1727                    planes,
1728                    count,
1729                    _pad0: 0,
1730                    viewport_width: frame.camera.viewport_size[0].max(1.0),
1731                    viewport_height: frame.camera.viewport_size[1].max(1.0),
1732                };
1733                // Write to per-viewport slot buffer.
1734                if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1735                    queue.write_buffer(
1736                        &slot.clip_planes_buf,
1737                        0,
1738                        bytemuck::cast_slice(&[clip_uniform]),
1739                    );
1740                    queue.write_buffer(
1741                        &slot.clip_volume_buf,
1742                        0,
1743                        bytemuck::cast_slice(&[clip_vols_uniform]),
1744                    );
1745                }
1746                // Also write to shared buffers for legacy single-viewport callers.
1747                queue.write_buffer(
1748                    &resources.clip_planes_uniform_buf,
1749                    0,
1750                    bytemuck::cast_slice(&[clip_uniform]),
1751                );
1752                queue.write_buffer(
1753                    &resources.clip_volume_uniform_buf,
1754                    0,
1755                    bytemuck::cast_slice(&[clip_vols_uniform]),
1756                );
1757            }
1758
1759            // Upload camera uniform to per-viewport slot buffer.
1760            let camera_uniform = frame.camera.render_camera.camera_uniform();
1761            // Write to shared buffer for legacy single-viewport callers.
1762            queue.write_buffer(
1763                &resources.camera_uniform_buf,
1764                0,
1765                bytemuck::cast_slice(&[camera_uniform]),
1766            );
1767            // Write to the per-viewport slot buffer.
1768            if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1769                queue.write_buffer(&slot.camera_buf, 0, bytemuck::cast_slice(&[camera_uniform]));
1770            }
1771
1772            // Upload grid uniform (full-screen analytical shader : no vertex buffers needed).
1773            if frame.viewport.show_grid {
1774                let eye = glam::Vec3::from(frame.camera.render_camera.eye_position);
1775                if !eye.is_finite() {
1776                    tracing::warn!(
1777                        eye_x = eye.x,
1778                        eye_y = eye.y,
1779                        eye_z = eye.z,
1780                        "grid skipped: eye_position is non-finite (camera distance overflow?)"
1781                    );
1782                } else {
1783                    let view_proj_mat = frame.camera.render_camera.view_proj().to_cols_array_2d();
1784
1785                    let (spacing, minor_fade) = if frame.viewport.grid_cell_size > 0.0 {
1786                        (frame.viewport.grid_cell_size, 1.0_f32)
1787                    } else {
1788                        let vertical_depth = (eye.z - frame.viewport.grid_z).abs().max(1.0);
1789                        let world_per_pixel =
1790                            2.0 * (frame.camera.render_camera.fov / 2.0).tan() * vertical_depth
1791                                / frame.camera.viewport_size[1].max(1.0);
1792                        let target = (world_per_pixel * 60.0).max(1e-9_f32);
1793                        let mut s = 1.0_f32;
1794                        let mut iters = 0u32;
1795                        while s < target {
1796                            s *= 10.0;
1797                            iters += 1;
1798                        }
1799                        let ratio = (target / s).clamp(0.0, 1.0);
1800                        let fade = if ratio < 0.5 {
1801                            1.0_f32
1802                        } else {
1803                            let t = (ratio - 0.5) * 2.0;
1804                            1.0 - t * t * (3.0 - 2.0 * t)
1805                        };
1806                        tracing::debug!(
1807                            eye_z = eye.z,
1808                            vertical_depth,
1809                            world_per_pixel,
1810                            target,
1811                            spacing = s,
1812                            lod_iters = iters,
1813                            ratio,
1814                            minor_fade = fade,
1815                            "grid LOD"
1816                        );
1817                        (s, fade)
1818                    };
1819
1820                    let spacing_major = spacing * 10.0;
1821                    let snap_x = (eye.x / spacing_major).floor() * spacing_major;
1822                    let snap_y = (eye.y / spacing_major).floor() * spacing_major;
1823                    tracing::debug!(
1824                        spacing_minor = spacing,
1825                        spacing_major,
1826                        snap_x,
1827                        snap_y,
1828                        eye_x = eye.x,
1829                        eye_y = eye.y,
1830                        eye_z = eye.z,
1831                        "grid snap"
1832                    );
1833
1834                    let orient = frame.camera.render_camera.orientation;
1835                    let right = orient * glam::Vec3::X;
1836                    let up = orient * glam::Vec3::Y;
1837                    let back = orient * glam::Vec3::Z;
1838                    let cam_to_world = [
1839                        [right.x, right.y, right.z, 0.0_f32],
1840                        [up.x, up.y, up.z, 0.0_f32],
1841                        [back.x, back.y, back.z, 0.0_f32],
1842                    ];
1843                    let aspect =
1844                        frame.camera.viewport_size[0] / frame.camera.viewport_size[1].max(1.0);
1845                    let tan_half_fov = (frame.camera.render_camera.fov / 2.0).tan();
1846
1847                    let uniform = GridUniform {
1848                        view_proj: view_proj_mat,
1849                        cam_to_world,
1850                        tan_half_fov,
1851                        aspect,
1852                        _pad_ivp: [0.0; 2],
1853                        eye_pos: frame.camera.render_camera.eye_position,
1854                        grid_z: frame.viewport.grid_z,
1855                        spacing_minor: spacing,
1856                        spacing_major,
1857                        snap_origin: [snap_x, snap_y],
1858                        color_minor: [0.35, 0.35, 0.35, 0.4 * minor_fade],
1859                        color_major: [0.40, 0.40, 0.40, 0.4 + 0.2 * minor_fade],
1860                    };
1861                    // Write to per-viewport slot buffer.
1862                    if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1863                        queue.write_buffer(&slot.grid_buf, 0, bytemuck::cast_slice(&[uniform]));
1864                    }
1865                    // Also write to shared buffer for legacy callers.
1866                    queue.write_buffer(
1867                        &resources.grid_uniform_buf,
1868                        0,
1869                        bytemuck::cast_slice(&[uniform]),
1870                    );
1871                }
1872            }
1873            // ------------------------------------------------------------------
1874            // Ground plane uniform upload.
1875            // ------------------------------------------------------------------
1876            {
1877                let gp = &viewport_fx.ground_plane;
1878                let mode_u32: u32 = match gp.mode {
1879                    crate::renderer::types::GroundPlaneMode::None => 0,
1880                    crate::renderer::types::GroundPlaneMode::ShadowOnly => 1,
1881                    crate::renderer::types::GroundPlaneMode::Tile => 2,
1882                    crate::renderer::types::GroundPlaneMode::SolidColor => 3,
1883                };
1884                let orient = frame.camera.render_camera.orientation;
1885                let right = orient * glam::Vec3::X;
1886                let up = orient * glam::Vec3::Y;
1887                let back = orient * glam::Vec3::Z;
1888                let aspect = frame.camera.viewport_size[0] / frame.camera.viewport_size[1].max(1.0);
1889                let tan_half_fov = (frame.camera.render_camera.fov / 2.0).tan();
1890                let vp = frame.camera.render_camera.view_proj().to_cols_array_2d();
1891                let gp_uniform = crate::resources::GroundPlaneUniform {
1892                    view_proj: vp,
1893                    cam_right: [right.x, right.y, right.z, 0.0],
1894                    cam_up: [up.x, up.y, up.z, 0.0],
1895                    cam_back: [back.x, back.y, back.z, 0.0],
1896                    eye_pos: frame.camera.render_camera.eye_position,
1897                    height: gp.height,
1898                    color: gp.color,
1899                    shadow_color: gp.shadow_color,
1900                    light_vp: gp_cascade0_mat,
1901                    tan_half_fov,
1902                    aspect,
1903                    tile_size: gp.tile_size,
1904                    shadow_bias: 0.002,
1905                    mode: mode_u32,
1906                    shadow_opacity: gp.shadow_opacity,
1907                    _pad: [0.0; 2],
1908                };
1909                queue.write_buffer(
1910                    &resources.ground_plane_uniform_buf,
1911                    0,
1912                    bytemuck::cast_slice(&[gp_uniform]),
1913                );
1914            }
1915        } // `resources` mutable borrow dropped here.
1916
1917        // ------------------------------------------------------------------
1918        // Build per-viewport interaction state into local variables.
1919        // Uses &self.resources (immutable) for BGL lookups; no conflict with
1920        // the slot borrow that follows.
1921        // ------------------------------------------------------------------
1922
1923        let vp_idx = frame.camera.viewport_index;
1924
1925        // Outline mask buffers for selected objects (one per selected object).
1926        let mut outline_object_buffers: Vec<OutlineObjectBuffers> = Vec::new();
1927        if frame.interaction.outline_selected {
1928            let resources = &self.resources;
1929            for item in scene_items {
1930                if !item.visible || !item.selected {
1931                    continue;
1932                }
1933                let uniform = OutlineUniform {
1934                    model: item.model,
1935                    color: [0.0; 4], // unused by mask shader
1936                    pixel_offset: 0.0,
1937                    _pad: [0.0; 3],
1938                };
1939                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1940                    label: Some("outline_mask_uniform_buf"),
1941                    size: std::mem::size_of::<OutlineUniform>() as u64,
1942                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1943                    mapped_at_creation: false,
1944                });
1945                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
1946                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1947                    label: Some("outline_mask_object_bg"),
1948                    layout: &resources.outline_bind_group_layout,
1949                    entries: &[wgpu::BindGroupEntry {
1950                        binding: 0,
1951                        resource: buf.as_entire_binding(),
1952                    }],
1953                });
1954                outline_object_buffers.push(OutlineObjectBuffers {
1955                    mesh_id: item.mesh_id,
1956                    two_sided: item.material.is_two_sided(),
1957                    _mask_uniform_buf: buf,
1958                    mask_bind_group: bg,
1959                });
1960            }
1961        }
1962
1963        // X-ray buffers for selected objects.
1964        let mut xray_object_buffers: Vec<(crate::resources::mesh_store::MeshId, wgpu::Buffer, wgpu::BindGroup)> = Vec::new();
1965        if frame.interaction.xray_selected {
1966            let resources = &self.resources;
1967            for item in scene_items {
1968                if !item.visible || !item.selected {
1969                    continue;
1970                }
1971                let uniform = OutlineUniform {
1972                    model: item.model,
1973                    color: frame.interaction.xray_color,
1974                    pixel_offset: 0.0,
1975                    _pad: [0.0; 3],
1976                };
1977                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1978                    label: Some("xray_uniform_buf"),
1979                    size: std::mem::size_of::<OutlineUniform>() as u64,
1980                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1981                    mapped_at_creation: false,
1982                });
1983                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
1984                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1985                    label: Some("xray_object_bg"),
1986                    layout: &resources.outline_bind_group_layout,
1987                    entries: &[wgpu::BindGroupEntry {
1988                        binding: 0,
1989                        resource: buf.as_entire_binding(),
1990                    }],
1991                });
1992                xray_object_buffers.push((item.mesh_id, buf, bg));
1993            }
1994        }
1995
1996        // Constraint guide lines.
1997        let mut constraint_line_buffers = Vec::new();
1998        for overlay in &frame.interaction.constraint_overlays {
1999            constraint_line_buffers.push(self.resources.create_constraint_overlay(device, overlay));
2000        }
2001
2002        // Clip plane overlays : generated automatically from clip_objects with a color set.
2003        let mut clip_plane_fill_buffers = Vec::new();
2004        let mut clip_plane_line_buffers = Vec::new();
2005        for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
2006            // Skip if neither fill nor edge color is set.
2007            if obj.color.is_none() && obj.edge_color.is_none() {
2008                continue;
2009            }
2010            if let ClipShape::Plane {
2011                normal, distance, ..
2012            } = obj.shape
2013            {
2014                let n = glam::Vec3::from(normal);
2015                // Shader plane equation: dot(p, n) + distance = 0, so the plane
2016                // sits at -n * distance from the origin.
2017                let center = n * (-distance);
2018                let active = obj.active;
2019                let hovered = obj.hovered || active;
2020
2021                // Fill quad: derived from `color`; transparent if not set.
2022                let fill_color = if let Some(base_color) = obj.color {
2023                    if active {
2024                        [
2025                            base_color[0] * 0.5,
2026                            base_color[1] * 0.5,
2027                            base_color[2] * 0.5,
2028                            base_color[3] * 0.5,
2029                        ]
2030                    } else if hovered {
2031                        [
2032                            base_color[0] * 0.8,
2033                            base_color[1] * 0.8,
2034                            base_color[2] * 0.8,
2035                            base_color[3] * 0.6,
2036                        ]
2037                    } else {
2038                        [
2039                            base_color[0] * 0.5,
2040                            base_color[1] * 0.5,
2041                            base_color[2] * 0.5,
2042                            base_color[3] * 0.3,
2043                        ]
2044                    }
2045                } else {
2046                    [0.0, 0.0, 0.0, 0.0]
2047                };
2048
2049                // Border edge: use `edge_color` when set, otherwise derive from `color`.
2050                let border_base = obj
2051                    .edge_color
2052                    .or(obj.color)
2053                    .unwrap_or([1.0, 1.0, 1.0, 1.0]);
2054                let border_color = if active {
2055                    [border_base[0], border_base[1], border_base[2], 0.9]
2056                } else if hovered {
2057                    [border_base[0], border_base[1], border_base[2], 0.8]
2058                } else {
2059                    [
2060                        border_base[0] * 0.9,
2061                        border_base[1] * 0.9,
2062                        border_base[2] * 0.9,
2063                        0.6,
2064                    ]
2065                };
2066
2067                let overlay = crate::interaction::clip_plane::ClipPlaneOverlay {
2068                    center,
2069                    normal: n,
2070                    extent: obj.extent,
2071                    fill_color,
2072                    border_color,
2073                    _hovered: hovered,
2074                    _active: active,
2075                };
2076                if obj.color.is_some() {
2077                    clip_plane_fill_buffers.push(
2078                        self.resources
2079                            .create_clip_plane_fill_overlay(device, &overlay),
2080                    );
2081                }
2082                clip_plane_line_buffers.push(
2083                    self.resources
2084                        .create_clip_plane_line_overlay(device, &overlay),
2085                );
2086            } else {
2087                // Box/Sphere/Cylinder: generate wireframe polyline overlay.
2088                // These use the clip-exempt pipeline so the outline is always fully visible,
2089                // even when multiple clip volumes are active (the user needs to see where each
2090                // clip is positioned to understand the combined result).
2091                let base_color = obj.color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
2092                self.resources.ensure_polyline_no_clip_pipeline(device);
2093                match obj.shape {
2094                    ClipShape::Box {
2095                        center,
2096                        half_extents,
2097                        orientation,
2098                    } => {
2099                        let polyline =
2100                            clip_box_outline(center, half_extents, orientation, base_color);
2101                        let vp_size = frame.camera.viewport_size;
2102                        let mut gpu = self
2103                            .resources
2104                            .upload_polyline(device, queue, &polyline, vp_size);
2105                        gpu.skip_clip = true;
2106                        self.polyline_gpu_data.push(gpu);
2107                    }
2108                    ClipShape::Sphere { center, radius } => {
2109                        let polyline = clip_sphere_outline(center, radius, base_color);
2110                        let vp_size = frame.camera.viewport_size;
2111                        let mut gpu = self
2112                            .resources
2113                            .upload_polyline(device, queue, &polyline, vp_size);
2114                        gpu.skip_clip = true;
2115                        self.polyline_gpu_data.push(gpu);
2116                    }
2117                    ClipShape::Cylinder {
2118                        center,
2119                        axis,
2120                        radius,
2121                        half_length,
2122                    } => {
2123                        let polyline =
2124                            clip_cylinder_outline(center, axis, radius, half_length, base_color);
2125                        let vp_size = frame.camera.viewport_size;
2126                        let mut gpu = self
2127                            .resources
2128                            .upload_polyline(device, queue, &polyline, vp_size);
2129                        gpu.skip_clip = true;
2130                        self.polyline_gpu_data.push(gpu);
2131                    }
2132                    _ => {}
2133                }
2134            }
2135        }
2136
2137        // Cap geometry for section-view cross-section fill.
2138        let mut cap_buffers = Vec::new();
2139        if viewport_fx.cap_fill_enabled {
2140            for obj in viewport_fx.clip_objects.iter().filter(|o| o.enabled) {
2141                if let ClipShape::Plane {
2142                    normal,
2143                    distance,
2144                    cap_color,
2145                } = obj.shape
2146                {
2147                    let plane_n = glam::Vec3::from(normal);
2148                    for item in scene_items.iter().filter(|i| i.visible) {
2149                        let Some(mesh) = self
2150                            .resources
2151                            .mesh_store
2152                            .get(item.mesh_id)
2153                        else {
2154                            continue;
2155                        };
2156                        let model = glam::Mat4::from_cols_array_2d(&item.model);
2157                        let world_aabb = mesh.aabb.transformed(&model);
2158                        if !world_aabb.intersects_plane(plane_n, distance) {
2159                            continue;
2160                        }
2161                        let (Some(pos), Some(idx)) = (&mesh.cpu_positions, &mesh.cpu_indices)
2162                        else {
2163                            continue;
2164                        };
2165                        if let Some(cap) = crate::geometry::cap_geometry::generate_cap_mesh(
2166                            pos, idx, &model, plane_n, distance,
2167                        ) {
2168                            let bc = item.material.base_color;
2169                            let color = cap_color.unwrap_or([bc[0], bc[1], bc[2], 1.0]);
2170                            let buf = self.resources.upload_cap_geometry(device, &cap, color);
2171                            cap_buffers.push(buf);
2172                        }
2173                    }
2174                }
2175            }
2176        }
2177
2178        // Axes indicator geometry (built here, written to slot buffer below).
2179        let axes_verts = if frame.viewport.show_axes_indicator
2180            && frame.camera.viewport_size[0] > 0.0
2181            && frame.camera.viewport_size[1] > 0.0
2182        {
2183            let verts = crate::widgets::axes_indicator::build_axes_geometry(
2184                frame.camera.viewport_size[0],
2185                frame.camera.viewport_size[1],
2186                frame.camera.render_camera.orientation,
2187            );
2188            if verts.is_empty() { None } else { Some(verts) }
2189        } else {
2190            None
2191        };
2192
2193        // Gizmo mesh + uniform (built here, written to slot buffers below).
2194        let gizmo_update = frame.interaction.gizmo_model.map(|model| {
2195            let (verts, indices) = crate::interaction::gizmo::build_gizmo_mesh(
2196                frame.interaction.gizmo_mode,
2197                frame.interaction.gizmo_hovered,
2198                frame.interaction.gizmo_space_orientation,
2199            );
2200            (verts, indices, model)
2201        });
2202
2203        // ------------------------------------------------------------------
2204        // Assign all interaction state to the per-viewport slot.
2205        // ------------------------------------------------------------------
2206        {
2207            let slot = &mut self.viewport_slots[vp_idx];
2208            slot.outline_object_buffers = outline_object_buffers;
2209            slot.xray_object_buffers = xray_object_buffers;
2210            slot.constraint_line_buffers = constraint_line_buffers;
2211            slot.clip_plane_fill_buffers = clip_plane_fill_buffers;
2212            slot.clip_plane_line_buffers = clip_plane_line_buffers;
2213            slot.cap_buffers = cap_buffers;
2214
2215            // Axes: resize buffer if needed, then upload.
2216            if let Some(verts) = axes_verts {
2217                let byte_size = std::mem::size_of_val(verts.as_slice()) as u64;
2218                if byte_size > slot.axes_vertex_buffer.size() {
2219                    slot.axes_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
2220                        label: Some("vp_axes_vertex_buf"),
2221                        size: byte_size,
2222                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
2223                        mapped_at_creation: false,
2224                    });
2225                }
2226                queue.write_buffer(&slot.axes_vertex_buffer, 0, bytemuck::cast_slice(&verts));
2227                slot.axes_vertex_count = verts.len() as u32;
2228            } else {
2229                slot.axes_vertex_count = 0;
2230            }
2231
2232            // Gizmo: resize buffers if needed, then upload mesh + uniform.
2233            if let Some((verts, indices, model)) = gizmo_update {
2234                let vert_bytes: &[u8] = bytemuck::cast_slice(&verts);
2235                let idx_bytes: &[u8] = bytemuck::cast_slice(&indices);
2236                if vert_bytes.len() as u64 > slot.gizmo_vertex_buffer.size() {
2237                    slot.gizmo_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
2238                        label: Some("vp_gizmo_vertex_buf"),
2239                        size: vert_bytes.len() as u64,
2240                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
2241                        mapped_at_creation: false,
2242                    });
2243                }
2244                if idx_bytes.len() as u64 > slot.gizmo_index_buffer.size() {
2245                    slot.gizmo_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
2246                        label: Some("vp_gizmo_index_buf"),
2247                        size: idx_bytes.len() as u64,
2248                        usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
2249                        mapped_at_creation: false,
2250                    });
2251                }
2252                queue.write_buffer(&slot.gizmo_vertex_buffer, 0, vert_bytes);
2253                queue.write_buffer(&slot.gizmo_index_buffer, 0, idx_bytes);
2254                slot.gizmo_index_count = indices.len() as u32;
2255                let uniform = crate::interaction::gizmo::GizmoUniform {
2256                    model: model.to_cols_array_2d(),
2257                };
2258                queue.write_buffer(&slot.gizmo_uniform_buf, 0, bytemuck::cast_slice(&[uniform]));
2259            }
2260        }
2261
2262        // ------------------------------------------------------------------
2263        // Outline offscreen pass : screen-space edge detection.
2264        //
2265        // 1. Render selected objects to an R8 mask texture (white on black).
2266        // 2. Run a fullscreen edge-detection pass reading the mask and writing
2267        //    an anti-aliased outline ring to the outline color texture.
2268        //
2269        // The outline color texture is later composited onto the main target
2270        // by the composite pass in paint()/render().
2271        // ------------------------------------------------------------------
2272        if frame.interaction.outline_selected
2273            && !self.viewport_slots[vp_idx]
2274                .outline_object_buffers
2275                .is_empty()
2276        {
2277            let w = frame.camera.viewport_size[0] as u32;
2278            let h = frame.camera.viewport_size[1] as u32;
2279
2280            // Ensure per-viewport HDR state exists (provides outline textures).
2281            self.ensure_viewport_hdr(
2282                device,
2283                queue,
2284                vp_idx,
2285                w.max(1),
2286                h.max(1),
2287                frame.effects.post_process.ssaa_factor.max(1),
2288            );
2289
2290            // Write edge-detection uniform (color, radius, viewport size).
2291            {
2292                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
2293                let edge_uniform = OutlineEdgeUniform {
2294                    color: frame.interaction.outline_color,
2295                    radius: frame.interaction.outline_width_px,
2296                    viewport_w: w as f32,
2297                    viewport_h: h as f32,
2298                    _pad: 0.0,
2299                };
2300                queue.write_buffer(
2301                    &slot_hdr.outline_edge_uniform_buf,
2302                    0,
2303                    bytemuck::cast_slice(&[edge_uniform]),
2304                );
2305            }
2306
2307            // Extract raw pointers for slot fields needed inside the render
2308            // passes alongside &self.resources borrows.
2309            let slot_ref = &self.viewport_slots[vp_idx];
2310            let outlines_ptr =
2311                &slot_ref.outline_object_buffers as *const Vec<OutlineObjectBuffers>;
2312            let camera_bg_ptr = &slot_ref.camera_bind_group as *const wgpu::BindGroup;
2313            let slot_hdr = slot_ref.hdr.as_ref().unwrap();
2314            let mask_view_ptr = &slot_hdr.outline_mask_view as *const wgpu::TextureView;
2315            let color_view_ptr = &slot_hdr.outline_color_view as *const wgpu::TextureView;
2316            let depth_view_ptr = &slot_hdr.outline_depth_view as *const wgpu::TextureView;
2317            let edge_bg_ptr = &slot_hdr.outline_edge_bind_group as *const wgpu::BindGroup;
2318            // SAFETY: slot fields remain valid for the duration of this function;
2319            // no other code modifies these fields here.
2320            let (outlines, camera_bg, mask_view, color_view, depth_view, edge_bg) = unsafe {
2321                (
2322                    &*outlines_ptr,
2323                    &*camera_bg_ptr,
2324                    &*mask_view_ptr,
2325                    &*color_view_ptr,
2326                    &*depth_view_ptr,
2327                    &*edge_bg_ptr,
2328                )
2329            };
2330
2331            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
2332                label: Some("outline_offscreen_encoder"),
2333            });
2334
2335            // Pass 1: render selected objects to R8 mask texture.
2336            {
2337                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2338                    label: Some("outline_mask_pass"),
2339                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2340                        view: mask_view,
2341                        resolve_target: None,
2342                        ops: wgpu::Operations {
2343                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
2344                            store: wgpu::StoreOp::Store,
2345                        },
2346                        depth_slice: None,
2347                    })],
2348                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2349                        view: depth_view,
2350                        depth_ops: Some(wgpu::Operations {
2351                            load: wgpu::LoadOp::Clear(1.0),
2352                            store: wgpu::StoreOp::Discard,
2353                        }),
2354                        stencil_ops: None,
2355                    }),
2356                    timestamp_writes: None,
2357                    occlusion_query_set: None,
2358                });
2359
2360                pass.set_bind_group(0, camera_bg, &[]);
2361                for outlined in outlines {
2362                    let Some(mesh) = self
2363                        .resources
2364                        .mesh_store
2365                        .get(outlined.mesh_id)
2366                    else {
2367                        continue;
2368                    };
2369                    let pipeline = if outlined.two_sided {
2370                        &self.resources.outline_mask_two_sided_pipeline
2371                    } else {
2372                        &self.resources.outline_mask_pipeline
2373                    };
2374                    pass.set_pipeline(pipeline);
2375                    pass.set_bind_group(1, &outlined.mask_bind_group, &[]);
2376                    pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
2377                    pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2378                    pass.draw_indexed(0..mesh.index_count, 0, 0..1);
2379                }
2380            }
2381
2382            // Pass 2: fullscreen edge detection (reads mask, writes color).
2383            {
2384                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2385                    label: Some("outline_edge_pass"),
2386                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2387                        view: color_view,
2388                        resolve_target: None,
2389                        ops: wgpu::Operations {
2390                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
2391                            store: wgpu::StoreOp::Store,
2392                        },
2393                        depth_slice: None,
2394                    })],
2395                    depth_stencil_attachment: None,
2396                    timestamp_writes: None,
2397                    occlusion_query_set: None,
2398                });
2399                pass.set_pipeline(&self.resources.outline_edge_pipeline);
2400                pass.set_bind_group(0, edge_bg, &[]);
2401                pass.draw(0..3, 0..1);
2402            }
2403
2404            queue.submit(std::iter::once(encoder.finish()));
2405        }
2406
2407        // ------------------------------------------------------------------
2408        // Sub-object highlight prepare: build GPU geometry from sub-selection
2409        // snapshot when the version has changed since the last frame.
2410        // ------------------------------------------------------------------
2411        {
2412            let w = frame.camera.viewport_size[0];
2413            let h = frame.camera.viewport_size[1];
2414            if let Some(sel_ref) = &frame.interaction.sub_selection {
2415                let needs_rebuild = {
2416                    let slot = &self.viewport_slots[vp_idx];
2417                    slot.sub_highlight_generation != sel_ref.version
2418                        || slot.sub_highlight.is_none()
2419                };
2420                if needs_rebuild {
2421                    self.resources.ensure_sub_highlight_pipelines(device);
2422                    let data = self.resources.build_sub_highlight(
2423                        device,
2424                        queue,
2425                        sel_ref,
2426                        frame.interaction.sub_highlight_face_fill_color,
2427                        frame.interaction.sub_highlight_edge_color,
2428                        frame.interaction.sub_highlight_edge_width_px,
2429                        frame.interaction.sub_highlight_vertex_size_px,
2430                        w,
2431                        h,
2432                    );
2433                    let slot = &mut self.viewport_slots[vp_idx];
2434                    slot.sub_highlight = Some(data);
2435                    slot.sub_highlight_generation = sel_ref.version;
2436                }
2437            } else {
2438                let slot = &mut self.viewport_slots[vp_idx];
2439                slot.sub_highlight = None;
2440                slot.sub_highlight_generation = u64::MAX;
2441            }
2442        }
2443
2444        // ---------------------------------------------------------------
2445        // Overlay labels
2446        // ---------------------------------------------------------------
2447        self.label_gpu_data = None;
2448        if !frame.overlays.labels.is_empty() {
2449            self.resources.ensure_overlay_text_pipeline(device);
2450            let vp_w = frame.camera.viewport_size[0];
2451            let vp_h = frame.camera.viewport_size[1];
2452            if vp_w > 0.0 && vp_h > 0.0 {
2453                let view = &frame.camera.render_camera.view;
2454                let proj = &frame.camera.render_camera.projection;
2455
2456                // Sort by z_order for correct draw ordering.
2457                let mut sorted_labels: Vec<&crate::renderer::types::LabelItem> =
2458                    frame.overlays.labels.iter().collect();
2459                sorted_labels.sort_by_key(|l| l.z_order);
2460
2461                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
2462
2463                for label in &sorted_labels {
2464                    if label.text.is_empty() || label.opacity <= 0.0 {
2465                        continue;
2466                    }
2467
2468                    // Resolve screen position from anchor.
2469                    let screen_pos = if let Some(sa) = label.screen_anchor {
2470                        Some(sa)
2471                    } else if let Some(wa) = label.world_anchor {
2472                        project_to_screen(wa, view, proj, vp_w, vp_h)
2473                    } else {
2474                        continue;
2475                    };
2476                    let Some(anchor_px) = screen_pos else {
2477                        continue;
2478                    };
2479
2480                    let opacity = label.opacity.clamp(0.0, 1.0);
2481
2482                    // Layout text (with optional word wrapping).
2483                    let layout = if let Some(max_w) = label.max_width {
2484                        self.resources.glyph_atlas.layout_text_wrapped(
2485                            &label.text,
2486                            label.font_size,
2487                            label.font,
2488                            max_w,
2489                            device,
2490                        )
2491                    } else {
2492                        self.resources.glyph_atlas.layout_text(
2493                            &label.text,
2494                            label.font_size,
2495                            label.font,
2496                            device,
2497                        )
2498                    };
2499
2500                    // Compute ascent so glyphs are positioned below the anchor.
2501                    let font_index = label.font.map_or(0, |h| h.0);
2502                    let ascent = self.resources.glyph_atlas.font_ascent(font_index, label.font_size);
2503
2504                    // Horizontal alignment.
2505                    let align_offset = match label.anchor_align {
2506                        crate::renderer::types::LabelAnchor::Leading => 6.0,
2507                        crate::renderer::types::LabelAnchor::Center => -layout.total_width * 0.5,
2508                        crate::renderer::types::LabelAnchor::Trailing => -layout.total_width - 6.0,
2509                    };
2510
2511                    // Text origin with alignment + user offset.
2512                    let text_x = anchor_px[0] + align_offset + label.offset[0];
2513                    let text_y = anchor_px[1] - layout.height * 0.5 + label.offset[1];
2514
2515                    // Background box (drawn first, behind text).
2516                    if label.background {
2517                        let pad = label.padding;
2518                        let bx0 = text_x - pad;
2519                        let by0 = text_y - pad;
2520                        let bx1 = text_x + layout.total_width + pad;
2521                        let by1 = text_y + layout.height + pad;
2522                        let bg_color = apply_opacity(label.background_color, opacity);
2523                        if label.border_radius > 0.0 {
2524                            emit_rounded_quad(
2525                                &mut verts,
2526                                bx0, by0, bx1, by1,
2527                                label.border_radius,
2528                                bg_color,
2529                                vp_w, vp_h,
2530                            );
2531                        } else {
2532                            emit_solid_quad(
2533                                &mut verts,
2534                                bx0, by0, bx1, by1,
2535                                bg_color,
2536                                vp_w, vp_h,
2537                            );
2538                        }
2539                    }
2540
2541                    // Leader line.
2542                    if label.leader_line {
2543                        if let Some(wa) = label.world_anchor {
2544                            let world_px = project_to_screen(wa, view, proj, vp_w, vp_h);
2545                            if let Some(wp) = world_px {
2546                                emit_line_quad(
2547                                    &mut verts,
2548                                    wp[0], wp[1],
2549                                    text_x, text_y + layout.height * 0.5,
2550                                    1.5,
2551                                    apply_opacity(label.leader_color, opacity),
2552                                    vp_w, vp_h,
2553                                );
2554                            }
2555                        }
2556                    }
2557
2558                    // Glyph quads.
2559                    let text_color = apply_opacity(label.color, opacity);
2560                    for gq in &layout.quads {
2561                        let gx = text_x + gq.pos[0];
2562                        let gy = text_y + ascent + gq.pos[1];
2563                        emit_textured_quad(
2564                            &mut verts,
2565                            gx, gy,
2566                            gx + gq.size[0], gy + gq.size[1],
2567                            gq.uv_min, gq.uv_max,
2568                            text_color,
2569                            vp_w, vp_h,
2570                        );
2571                    }
2572                }
2573
2574                // Upload atlas if new glyphs were rasterized.
2575                self.resources.glyph_atlas.upload_if_dirty(queue);
2576
2577                if !verts.is_empty() {
2578                    let vertex_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2579                        label: Some("overlay_label_vbuf"),
2580                        contents: bytemuck::cast_slice(&verts),
2581                        usage: wgpu::BufferUsages::VERTEX,
2582                    });
2583                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
2584                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
2585                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2586                        label: Some("overlay_label_bg"),
2587                        layout: bgl,
2588                        entries: &[
2589                            wgpu::BindGroupEntry {
2590                                binding: 0,
2591                                resource: wgpu::BindingResource::TextureView(
2592                                    &self.resources.glyph_atlas.view,
2593                                ),
2594                            },
2595                            wgpu::BindGroupEntry {
2596                                binding: 1,
2597                                resource: wgpu::BindingResource::Sampler(sampler),
2598                            },
2599                        ],
2600                    });
2601                    self.label_gpu_data = Some(crate::resources::LabelGpuData {
2602                        vertex_buf,
2603                        vertex_count: verts.len() as u32,
2604                        bind_group,
2605                    });
2606                }
2607            }
2608        }
2609
2610        // ---------------------------------------------------------------
2611        // Scalar bars
2612        // ---------------------------------------------------------------
2613        self.scalar_bar_gpu_data = None;
2614        if !frame.overlays.scalar_bars.is_empty() {
2615            self.resources.ensure_overlay_text_pipeline(device);
2616            let vp_w = frame.camera.viewport_size[0];
2617            let vp_h = frame.camera.viewport_size[1];
2618            if vp_w > 0.0 && vp_h > 0.0 {
2619                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
2620
2621                for bar in &frame.overlays.scalar_bars {
2622                    // Clone the LUT immediately so the immutable borrow on self.resources
2623                    // is released before the mutable glyph_atlas borrow below.
2624                    let Some(lut) = self.resources.get_colormap_rgba(bar.colormap_id).map(|l| l.to_vec()) else {
2625                        continue;
2626                    };
2627
2628                    let is_vertical = matches!(
2629                        bar.orientation,
2630                        crate::renderer::types::ScalarBarOrientation::Vertical
2631                    );
2632                    let reversed = bar.ticks_reversed;
2633
2634                    // Effective font sizes.
2635                    let tick_fs = bar.font_size;
2636                    let title_fs = bar.title_font_size.unwrap_or(bar.font_size);
2637                    let font_index = bar.font.map_or(0, |h| h.0);
2638
2639                    // Actual pixel dimensions of the gradient strip.
2640                    let (strip_w, strip_h) = if is_vertical {
2641                        (bar.bar_width_px, bar.bar_length_px)
2642                    } else {
2643                        (bar.bar_length_px, bar.bar_width_px)
2644                    };
2645
2646                    // Pre-compute tick texts and their widths so the background box
2647                    // can be sized to cover the tick labels.
2648                    let tick_count = bar.tick_count.max(2);
2649                    let mut tick_data: Vec<(String, f32, f32)> = Vec::new(); // (text, total_w, height)
2650                    let mut max_tick_w = 0.0f32;
2651                    let mut tick_h = 0.0f32;
2652                    for i in 0..tick_count {
2653                        let t = i as f32 / (tick_count - 1) as f32;
2654                        let value = bar.scalar_min + t * (bar.scalar_max - bar.scalar_min);
2655                        let text = format!("{value:.2}");
2656                        let layout = self.resources.glyph_atlas.layout_text(
2657                            &text, tick_fs, bar.font, device,
2658                        );
2659                        max_tick_w = max_tick_w.max(layout.total_width);
2660                        tick_h = layout.height;
2661                        tick_data.push((text, layout.total_width, layout.height));
2662                    }
2663
2664                    // Vertical space reserved above the gradient strip.
2665                    // In vertical mode the top/bottom tick labels are centred on the strip
2666                    // endpoints, so they each overhang by tick_h/2. title_h must absorb the
2667                    // top overhang AND leave a gap so the title text does not touch the tick.
2668                    let half_tick = tick_h / 2.0;
2669                    let title_h = if bar.title.is_some() {
2670                        // title text height + small gap + top-tick overhang
2671                        title_fs + 4.0 + half_tick
2672                    } else {
2673                        // no title, but still need room for the top-tick overhang
2674                        half_tick
2675                    };
2676
2677                    // Pre-compute title width before bar_x/bar_y so the overhang can
2678                    // be used to push the strip inward and prevent clipping.
2679                    let title_w = if let Some(ref t) = bar.title {
2680                        self.resources.glyph_atlas.layout_text(t, title_fs, bar.font, device).total_width
2681                    } else {
2682                        0.0
2683                    };
2684
2685                    // How far title / tick labels spill beyond the strip on each side.
2686                    // Vertical: title centred on the narrow strip, ticks to the right.
2687                    //   left side: title overhang only.
2688                    //   right side: ticks dominate (strip_w + 4 + max_tick_w).
2689                    // Horizontal: both title and tick labels can overhang left/right equally.
2690                    let bg_pad = 4.0;
2691                    let (inset_left, inset_right) = if is_vertical {
2692                        let title_oh = ((title_w - strip_w) / 2.0).max(0.0);
2693                        let right_extent = 4.0 + max_tick_w + bg_pad; // relative to strip right edge
2694                        (title_oh + bg_pad, right_extent)
2695                    } else {
2696                        let title_oh = ((title_w - strip_w) / 2.0).max(0.0);
2697                        let tick_oh  = max_tick_w / 2.0;
2698                        let side = title_oh.max(tick_oh) + bg_pad;
2699                        (side, side)
2700                    };
2701
2702                    // How far content hangs below the strip bottom (used to keep the
2703                    // background box flush with margin_px on the bottom-anchored side).
2704                    // Vertical: bottom tick label is centred on the strip endpoint -> half_tick.
2705                    // Horizontal: tick labels sit fully below the strip -> 3 + tick_h.
2706                    let bottom_overhang = if is_vertical { half_tick } else { 3.0 + tick_h };
2707
2708                    // Top-left of the gradient strip.
2709                    // bg_pad is added/subtracted here so that the background box edge lands
2710                    // exactly at margin_px from the viewport edge on the anchored side.
2711                    //   Top anchor:    bg_y0 = bar_y - title_h - bg_pad  =>  set bar_y = margin_px + title_h + bg_pad
2712                    //   Bottom anchor: bg_y1 = bar_y + strip_h + bottom_overhang + bg_pad  =>  bar_y = vp_h - margin_px - strip_h - bottom_overhang - bg_pad
2713                    let (bar_x, bar_y) = match bar.anchor {
2714                        crate::renderer::types::ScalarBarAnchor::TopLeft => (
2715                            bar.margin_px + inset_left,
2716                            bar.margin_px + title_h + bg_pad,
2717                        ),
2718                        crate::renderer::types::ScalarBarAnchor::TopRight => (
2719                            vp_w - bar.margin_px - strip_w - inset_right,
2720                            bar.margin_px + title_h + bg_pad,
2721                        ),
2722                        crate::renderer::types::ScalarBarAnchor::BottomLeft => (
2723                            bar.margin_px + inset_left,
2724                            vp_h - bar.margin_px - strip_h - bottom_overhang - bg_pad,
2725                        ),
2726                        crate::renderer::types::ScalarBarAnchor::BottomRight => (
2727                            vp_w - bar.margin_px - strip_w - inset_right,
2728                            vp_h - bar.margin_px - strip_h - bottom_overhang - bg_pad,
2729                        ),
2730                    };
2731
2732                    // Background box: now that bar_x/bar_y are inset, the box stays on screen.
2733                    let (bg_x0, bg_y0, bg_x1, bg_y1) = if is_vertical {
2734                        let title_right = bar_x + (strip_w + title_w) / 2.0;
2735                        let ticks_right = bar_x + strip_w + 4.0 + max_tick_w;
2736                        (
2737                            bar_x - bg_pad - ((title_w - strip_w) / 2.0).max(0.0),
2738                            bar_y - title_h - bg_pad,
2739                            ticks_right.max(title_right) + bg_pad,
2740                            bar_y + strip_h + half_tick + bg_pad,
2741                        )
2742                    } else {
2743                        let title_overhang = ((title_w - strip_w) / 2.0).max(0.0);
2744                        let tick_overhang  = max_tick_w / 2.0;
2745                        let side_pad = title_overhang.max(tick_overhang);
2746                        let bottom = bar_y + strip_h + 3.0 + tick_h + bg_pad;
2747                        (
2748                            bar_x - bg_pad - side_pad,
2749                            bar_y - title_h - bg_pad,
2750                            bar_x + strip_w + bg_pad + side_pad,
2751                            bottom,
2752                        )
2753                    };
2754                    emit_rounded_quad(
2755                        &mut verts,
2756                        bg_x0, bg_y0, bg_x1, bg_y1,
2757                        3.0,
2758                        bar.background_color,
2759                        vp_w, vp_h,
2760                    );
2761
2762                    // Gradient strip: 64 solid quads sampled from the colormap LUT.
2763                    let steps: usize = 64;
2764                    for s in 0..steps {
2765                        let (qx0, qy0, qx1, qy1, t) = if is_vertical {
2766                            // Default: top = max (t=1). Reversed: top = min (t=0).
2767                            let t = if reversed {
2768                                s as f32 / (steps - 1) as f32
2769                            } else {
2770                                1.0 - s as f32 / (steps - 1) as f32
2771                            };
2772                            let step_h = strip_h / steps as f32;
2773                            let sy = bar_y + s as f32 * step_h;
2774                            (bar_x, sy, bar_x + strip_w, sy + step_h + 0.5, t)
2775                        } else {
2776                            // Default: left = min (t=0). Reversed: left = max (t=1).
2777                            let t = if reversed {
2778                                1.0 - s as f32 / (steps - 1) as f32
2779                            } else {
2780                                s as f32 / (steps - 1) as f32
2781                            };
2782                            let step_w = strip_w / steps as f32;
2783                            let sx = bar_x + s as f32 * step_w;
2784                            (sx, bar_y, sx + step_w + 0.5, bar_y + strip_h, t)
2785                        };
2786                        let lut_idx = (t * 255.0).clamp(0.0, 255.0) as usize;
2787                        let [r, g, b, a] = lut[lut_idx];
2788                        let color = [
2789                            r as f32 / 255.0,
2790                            g as f32 / 255.0,
2791                            b as f32 / 255.0,
2792                            a as f32 / 255.0,
2793                        ];
2794                        emit_solid_quad(&mut verts, qx0, qy0, qx1, qy1, color, vp_w, vp_h);
2795                    }
2796
2797                    // Tick labels.
2798                    let ascent = self.resources.glyph_atlas.font_ascent(font_index, tick_fs);
2799                    for (i, (text, tw, th)) in tick_data.iter().enumerate() {
2800                        let t = i as f32 / (tick_count - 1) as f32;
2801                        let layout = self.resources.glyph_atlas.layout_text(
2802                            text, tick_fs, bar.font, device,
2803                        );
2804
2805                        let (lx, ly) = if is_vertical {
2806                            // Place text to the right of the strip, vertically centered
2807                            // on its tick position.
2808                            // Default: top=max -> progress = 1.0-t puts max at top.
2809                            // Reversed: top=min -> progress = t puts min at top.
2810                            let progress = if reversed { t } else { 1.0 - t };
2811                            let tick_y = bar_y + progress * strip_h;
2812                            (bar_x + strip_w + 4.0, tick_y - th * 0.5)
2813                        } else {
2814                            // Place text below the strip, horizontally centered on its tick.
2815                            // Default: left=min -> tick at t*strip_w.
2816                            // Reversed: left=max -> tick at (1-t)*strip_w.
2817                            let frac = if reversed { 1.0 - t } else { t };
2818                            let tick_x = bar_x + frac * strip_w;
2819                            (tick_x - tw * 0.5, bar_y + strip_h + 3.0)
2820                        };
2821                        let _ = (tw, th); // used above
2822
2823                        for gq in &layout.quads {
2824                            let gx = lx + gq.pos[0];
2825                            let gy = ly + ascent + gq.pos[1];
2826                            emit_textured_quad(
2827                                &mut verts,
2828                                gx, gy,
2829                                gx + gq.size[0], gy + gq.size[1],
2830                                gq.uv_min, gq.uv_max,
2831                                bar.label_color,
2832                                vp_w, vp_h,
2833                            );
2834                        }
2835                    }
2836
2837                    // Optional title above the gradient strip.
2838                    if let Some(ref title_text) = bar.title {
2839                        let layout = self.resources.glyph_atlas.layout_text(
2840                            title_text, title_fs, bar.font, device,
2841                        );
2842                        let title_ascent = self.resources.glyph_atlas.font_ascent(font_index, title_fs);
2843                        // Centre the title over the gradient strip.
2844                        let tx = bar_x + (strip_w - layout.total_width) * 0.5;
2845                        let ty = bar_y - title_h;
2846                        for gq in &layout.quads {
2847                            let gx = tx + gq.pos[0];
2848                            let gy = ty + title_ascent + gq.pos[1];
2849                            emit_textured_quad(
2850                                &mut verts,
2851                                gx, gy,
2852                                gx + gq.size[0], gy + gq.size[1],
2853                                gq.uv_min, gq.uv_max,
2854                                bar.label_color,
2855                                vp_w, vp_h,
2856                            );
2857                        }
2858                    }
2859                }
2860
2861                // Upload any newly rasterized glyphs (may overlap with label upload above).
2862                self.resources.glyph_atlas.upload_if_dirty(queue);
2863
2864                if !verts.is_empty() {
2865                    let vertex_buf =
2866                        device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
2867                            label: Some("overlay_scalar_bar_vbuf"),
2868                            contents: bytemuck::cast_slice(&verts),
2869                            usage: wgpu::BufferUsages::VERTEX,
2870                        });
2871                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
2872                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
2873                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2874                        label: Some("overlay_scalar_bar_bg"),
2875                        layout: bgl,
2876                        entries: &[
2877                            wgpu::BindGroupEntry {
2878                                binding: 0,
2879                                resource: wgpu::BindingResource::TextureView(
2880                                    &self.resources.glyph_atlas.view,
2881                                ),
2882                            },
2883                            wgpu::BindGroupEntry {
2884                                binding: 1,
2885                                resource: wgpu::BindingResource::Sampler(sampler),
2886                            },
2887                        ],
2888                    });
2889                    self.scalar_bar_gpu_data = Some(crate::resources::LabelGpuData {
2890                        vertex_buf,
2891                        vertex_count: verts.len() as u32,
2892                        bind_group,
2893                    });
2894                }
2895            }
2896        }
2897
2898        // ---------------------------------------------------------------
2899        // Rulers
2900        // ---------------------------------------------------------------
2901        self.ruler_gpu_data = None;
2902        if !frame.overlays.rulers.is_empty() {
2903            self.resources.ensure_overlay_text_pipeline(device);
2904            let vp_w = frame.camera.viewport_size[0];
2905            let vp_h = frame.camera.viewport_size[1];
2906            if vp_w > 0.0 && vp_h > 0.0 {
2907                let view = &frame.camera.render_camera.view;
2908                let proj = &frame.camera.render_camera.projection;
2909
2910                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
2911
2912                for ruler in &frame.overlays.rulers {
2913                    // Project both endpoints to NDC (returns None only if behind camera).
2914                    let start_ndc = project_to_ndc(ruler.start, view, proj);
2915                    let end_ndc   = project_to_ndc(ruler.end,   view, proj);
2916
2917                    // Cull entirely when either endpoint is behind the camera.
2918                    let (Some(sndc), Some(endc)) = (start_ndc, end_ndc) else { continue };
2919
2920                    // Clip the segment to the viewport NDC box [-1,1]^2.
2921                    // This keeps the line visible when only one end is off-screen sideways.
2922                    let Some((csndc, cendc)) = clip_line_ndc(sndc, endc) else { continue };
2923
2924                    let [sx, sy] = ndc_to_screen_px(csndc, vp_w, vp_h);
2925                    let [ex, ey] = ndc_to_screen_px(cendc, vp_w, vp_h);
2926
2927                    // Track which original endpoints are within the viewport (for end caps).
2928                    let start_on_screen = ndc_in_viewport(sndc);
2929                    let end_on_screen   = ndc_in_viewport(endc);
2930
2931                    // Main ruler line.
2932                    emit_line_quad(
2933                        &mut verts,
2934                        sx, sy, ex, ey,
2935                        ruler.line_width_px,
2936                        ruler.color,
2937                        vp_w, vp_h,
2938                    );
2939
2940                    // End caps only at endpoints that are actually on screen.
2941                    if ruler.end_caps {
2942                        let dx = ex - sx;
2943                        let dy = ey - sy;
2944                        let len = (dx * dx + dy * dy).sqrt().max(0.001);
2945                        let cap_half = 5.0;
2946                        let px = -dy / len * cap_half;
2947                        let py =  dx / len * cap_half;
2948
2949                        if start_on_screen {
2950                            emit_line_quad(
2951                                &mut verts,
2952                                sx - px, sy - py,
2953                                sx + px, sy + py,
2954                                ruler.line_width_px,
2955                                ruler.color,
2956                                vp_w, vp_h,
2957                            );
2958                        }
2959                        if end_on_screen {
2960                            emit_line_quad(
2961                                &mut verts,
2962                                ex - px, ey - py,
2963                                ex + px, ey + py,
2964                                ruler.line_width_px,
2965                                ruler.color,
2966                                vp_w, vp_h,
2967                            );
2968                        }
2969                    }
2970
2971                    // Distance label: always shows true 3D distance.
2972                    // Place it at the midpoint of the visible (clipped) segment.
2973                    let start_world = glam::Vec3::from(ruler.start);
2974                    let end_world = glam::Vec3::from(ruler.end);
2975                    let distance = (end_world - start_world).length();
2976                    let text = format_ruler_distance(distance, ruler.label_format.as_deref());
2977
2978                    let mid_x = (sx + ex) * 0.5;
2979                    let mid_y = (sy + ey) * 0.5;
2980
2981                    let layout = self.resources.glyph_atlas.layout_text(
2982                        &text,
2983                        ruler.font_size,
2984                        ruler.font,
2985                        device,
2986                    );
2987                    let font_index = ruler.font.map_or(0, |h| h.0);
2988                    let ascent = self.resources.glyph_atlas.font_ascent(font_index, ruler.font_size);
2989
2990                    // Center the label above the midpoint with a small gap.
2991                    let lx = mid_x - layout.total_width * 0.5;
2992                    let ly = mid_y - layout.height - 6.0;
2993
2994                    // Semi-transparent background box.
2995                    let pad = 3.0;
2996                    emit_solid_quad(
2997                        &mut verts,
2998                        lx - pad, ly - pad,
2999                        lx + layout.total_width + pad, ly + layout.height + pad,
3000                        [0.0, 0.0, 0.0, 0.55],
3001                        vp_w, vp_h,
3002                    );
3003
3004                    // Glyph quads.
3005                    for gq in &layout.quads {
3006                        let gx = lx + gq.pos[0];
3007                        let gy = ly + ascent + gq.pos[1];
3008                        emit_textured_quad(
3009                            &mut verts,
3010                            gx, gy,
3011                            gx + gq.size[0], gy + gq.size[1],
3012                            gq.uv_min, gq.uv_max,
3013                            ruler.label_color,
3014                            vp_w, vp_h,
3015                        );
3016                    }
3017                }
3018
3019                // Upload any newly rasterized glyphs.
3020                self.resources.glyph_atlas.upload_if_dirty(queue);
3021
3022                if !verts.is_empty() {
3023                    let vertex_buf =
3024                        device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3025                            label: Some("overlay_ruler_vbuf"),
3026                            contents: bytemuck::cast_slice(&verts),
3027                            usage: wgpu::BufferUsages::VERTEX,
3028                        });
3029                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
3030                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
3031                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3032                        label: Some("overlay_ruler_bg"),
3033                        layout: bgl,
3034                        entries: &[
3035                            wgpu::BindGroupEntry {
3036                                binding: 0,
3037                                resource: wgpu::BindingResource::TextureView(
3038                                    &self.resources.glyph_atlas.view,
3039                                ),
3040                            },
3041                            wgpu::BindGroupEntry {
3042                                binding: 1,
3043                                resource: wgpu::BindingResource::Sampler(sampler),
3044                            },
3045                        ],
3046                    });
3047                    self.ruler_gpu_data = Some(crate::resources::LabelGpuData {
3048                        vertex_buf,
3049                        vertex_count: verts.len() as u32,
3050                        bind_group,
3051                    });
3052                }
3053            }
3054        }
3055
3056        // ---------------------------------------------------------------
3057        // Loading bars
3058        // ---------------------------------------------------------------
3059        self.loading_bar_gpu_data = None;
3060        if !frame.overlays.loading_bars.is_empty() {
3061            self.resources.ensure_overlay_text_pipeline(device);
3062            let vp_w = frame.camera.viewport_size[0];
3063            let vp_h = frame.camera.viewport_size[1];
3064            if vp_w > 0.0 && vp_h > 0.0 {
3065                let mut verts: Vec<crate::resources::OverlayTextVertex> = Vec::new();
3066
3067                for bar in &frame.overlays.loading_bars {
3068                    // Bar top-left corner based on anchor.
3069                    let bar_x = vp_w * 0.5 - bar.width_px * 0.5;
3070                    let bar_y = match bar.anchor {
3071                        crate::renderer::types::LoadingBarAnchor::TopCenter => bar.margin_px,
3072                        crate::renderer::types::LoadingBarAnchor::Center => {
3073                            vp_h * 0.5 - bar.height_px * 0.5
3074                        }
3075                        crate::renderer::types::LoadingBarAnchor::BottomCenter => {
3076                            vp_h - bar.margin_px - bar.height_px
3077                        }
3078                    };
3079
3080                    // Label above (TopCenter: below) the bar.
3081                    if let Some(ref text) = bar.label {
3082                        let layout = self.resources.glyph_atlas.layout_text(
3083                            text,
3084                            bar.font_size,
3085                            None,
3086                            device,
3087                        );
3088                        let ascent =
3089                            self.resources.glyph_atlas.font_ascent(0, bar.font_size);
3090                        let label_gap = 5.0;
3091                        let lx = bar_x + bar.width_px * 0.5 - layout.total_width * 0.5;
3092                        let ly = match bar.anchor {
3093                            crate::renderer::types::LoadingBarAnchor::TopCenter => {
3094                                bar_y + bar.height_px + label_gap
3095                            }
3096                            _ => bar_y - layout.height - label_gap,
3097                        };
3098                        for gq in &layout.quads {
3099                            let gx = lx + gq.pos[0];
3100                            let gy = ly + ascent + gq.pos[1];
3101                            emit_textured_quad(
3102                                &mut verts,
3103                                gx, gy,
3104                                gx + gq.size[0], gy + gq.size[1],
3105                                gq.uv_min, gq.uv_max,
3106                                bar.label_color,
3107                                vp_w, vp_h,
3108                            );
3109                        }
3110                    }
3111
3112                    // Background rectangle.
3113                    emit_rounded_quad(
3114                        &mut verts,
3115                        bar_x, bar_y,
3116                        bar_x + bar.width_px, bar_y + bar.height_px,
3117                        bar.corner_radius,
3118                        bar.background_color,
3119                        vp_w, vp_h,
3120                    );
3121
3122                    // Fill rectangle clipped to progress fraction.
3123                    let fill_w = bar.width_px * bar.progress.clamp(0.0, 1.0);
3124                    if fill_w > 0.5 {
3125                        emit_rounded_quad(
3126                            &mut verts,
3127                            bar_x, bar_y,
3128                            bar_x + fill_w, bar_y + bar.height_px,
3129                            bar.corner_radius,
3130                            bar.fill_color,
3131                            vp_w, vp_h,
3132                        );
3133                    }
3134                }
3135
3136                self.resources.glyph_atlas.upload_if_dirty(queue);
3137
3138                if !verts.is_empty() {
3139                    let vertex_buf =
3140                        device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3141                            label: Some("loading_bar_vbuf"),
3142                            contents: bytemuck::cast_slice(&verts),
3143                            usage: wgpu::BufferUsages::VERTEX,
3144                        });
3145                    let bgl = self.resources.overlay_text_bgl.as_ref().unwrap();
3146                    let sampler = self.resources.overlay_text_sampler.as_ref().unwrap();
3147                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3148                        label: Some("loading_bar_bg"),
3149                        layout: bgl,
3150                        entries: &[
3151                            wgpu::BindGroupEntry {
3152                                binding: 0,
3153                                resource: wgpu::BindingResource::TextureView(
3154                                    &self.resources.glyph_atlas.view,
3155                                ),
3156                            },
3157                            wgpu::BindGroupEntry {
3158                                binding: 1,
3159                                resource: wgpu::BindingResource::Sampler(sampler),
3160                            },
3161                        ],
3162                    });
3163                    self.loading_bar_gpu_data = Some(crate::resources::LabelGpuData {
3164                        vertex_buf,
3165                        vertex_count: verts.len() as u32,
3166                        bind_group,
3167                    });
3168                }
3169            }
3170        }
3171
3172        // ------------------------------------------------------------------
3173        // Gaussian splat: per-viewport GPU sort.
3174        // ------------------------------------------------------------------
3175        self.gaussian_splat_draw_data.clear();
3176        if !frame.scene.gaussian_splats.is_empty() {
3177            self.resources.ensure_gaussian_splat_pipelines(device);
3178            let vp_idx = frame.camera.viewport_index;
3179            let eye = frame.camera.render_camera.eye_position;
3180            let vp_w = frame.camera.viewport_size[0].max(1.0);
3181            let vp_h = frame.camera.viewport_size[1].max(1.0);
3182            for item in &frame.scene.gaussian_splats {
3183                let store_index = item.id.0;
3184                if self.resources.gaussian_splat_store.get(store_index).is_none() {
3185                    continue;
3186                }
3187                let sh_degree = self
3188                    .resources
3189                    .gaussian_splat_store
3190                    .get(store_index)
3191                    .unwrap()
3192                    .sh_degree;
3193                let count = self
3194                    .resources
3195                    .gaussian_splat_store
3196                    .get(store_index)
3197                    .unwrap()
3198                    .count;
3199                self.resources.run_gaussian_splat_sort(
3200                    device,
3201                    queue,
3202                    store_index,
3203                    vp_idx,
3204                    eye,
3205                    item.model,
3206                    vp_w,
3207                    vp_h,
3208                    sh_degree,
3209                );
3210                self.gaussian_splat_draw_data.push(
3211                    crate::resources::GaussianSplatDrawData {
3212                        store_index,
3213                        viewport_index: vp_idx,
3214                        model: item.model,
3215                        count,
3216                    },
3217                );
3218            }
3219        }
3220    }
3221
3222    /// Upload per-frame data to GPU buffers and render the shadow pass.
3223    /// Call before `paint()`.
3224    ///
3225    /// Returns [`crate::FrameStats`] with per-frame timing and upload metrics.
3226    pub fn prepare(
3227        &mut self,
3228        device: &wgpu::Device,
3229        queue: &wgpu::Queue,
3230        frame: &FrameData,
3231    ) -> crate::renderer::stats::FrameStats {
3232        let prepare_start = std::time::Instant::now();
3233
3234        // Phase 4 : read back GPU timestamps from the previous frame, if available.
3235        // By the time prepare() is called, the previous frame's queue.submit() has
3236        // already happened, so it is safe to initiate the map here.
3237        if self.ts_needs_readback {
3238            if let Some(ref stg_buf) = self.ts_staging_buf {
3239                let (tx, rx) = std::sync::mpsc::channel::<Result<(), wgpu::BufferAsyncError>>();
3240                stg_buf.slice(..).map_async(wgpu::MapMode::Read, move |r| {
3241                    let _ = tx.send(r);
3242                });
3243                // Non-blocking poll: flush any completed callbacks. GPU work from the
3244                // previous frame is almost certainly done by the time CPU reaches here.
3245                device
3246                    .poll(wgpu::PollType::Wait {
3247                        submission_index: None,
3248                        timeout: Some(std::time::Duration::from_millis(100)),
3249                    })
3250                    .ok();
3251                if rx.try_recv().unwrap_or(Err(wgpu::BufferAsyncError)).is_ok() {
3252                    let data = stg_buf.slice(..).get_mapped_range();
3253                    let t0 = u64::from_le_bytes(data[0..8].try_into().unwrap());
3254                    let t1 = u64::from_le_bytes(data[8..16].try_into().unwrap());
3255                    drop(data);
3256                    // ts_period is nanoseconds/tick; convert delta to milliseconds.
3257                    let gpu_ms = t1.saturating_sub(t0) as f32 * self.ts_period / 1_000_000.0;
3258                    self.last_stats.gpu_frame_ms = Some(gpu_ms);
3259                }
3260                stg_buf.unmap();
3261            }
3262            self.ts_needs_readback = false;
3263        }
3264
3265        // Read back GPU-visible instance count from the previous frame's indirect args copy.
3266        // The cull pass from the previous frame has already been submitted and is almost
3267        // certainly done by the time prepare() is called; a short poll is enough.
3268        if self.indirect_readback_pending {
3269            if let Some(ref stg_buf) = self.indirect_readback_buf {
3270                let bytes = self.indirect_readback_batch_count as u64 * 20;
3271                if bytes > 0 {
3272                    let (tx, rx) =
3273                        std::sync::mpsc::channel::<Result<(), wgpu::BufferAsyncError>>();
3274                    stg_buf.slice(..bytes).map_async(wgpu::MapMode::Read, move |r| {
3275                        let _ = tx.send(r);
3276                    });
3277                    device
3278                        .poll(wgpu::PollType::Wait {
3279                            submission_index: None,
3280                            timeout: Some(std::time::Duration::from_millis(100)),
3281                        })
3282                        .ok();
3283                    if rx.try_recv().unwrap_or(Err(wgpu::BufferAsyncError)).is_ok() {
3284                        let data = stg_buf.slice(..bytes).get_mapped_range();
3285                        let mut visible: u32 = 0;
3286                        for i in 0..self.indirect_readback_batch_count as usize {
3287                            // DrawIndexedIndirect layout: [index_count, instance_count, first_index, base_vertex, first_instance]
3288                            // instance_count is at byte offset 4 within each 20-byte entry.
3289                            let off = i * 20 + 4;
3290                            let n = u32::from_le_bytes(data[off..off + 4].try_into().unwrap());
3291                            visible = visible.saturating_add(n);
3292                        }
3293                        drop(data);
3294                        self.last_stats.gpu_visible_instances = Some(visible);
3295                    }
3296                    stg_buf.unmap();
3297                }
3298            }
3299            self.indirect_readback_pending = false;
3300        }
3301
3302        // Wall-clock duration since the previous prepare() call approximates the frame interval.
3303        let total_frame_ms = self
3304            .last_prepare_instant
3305            .map(|t| t.elapsed().as_secs_f32() * 1000.0)
3306            .unwrap_or(0.0);
3307
3308        // Snapshot geometry upload bytes accumulated since the last frame, then reset.
3309        let upload_bytes = self.resources.frame_upload_bytes;
3310        self.resources.frame_upload_bytes = 0;
3311
3312        // Resolve effective scale bounds and degradation flags.
3313        // When a preset is set it overrides the individual fields; the individual
3314        // fields are preserved so they restore when switching back to None.
3315        let policy = self.performance_policy;
3316        let (eff_min_scale, eff_max_scale, eff_allow_shadows, eff_allow_volumes, eff_allow_effects) =
3317            match policy.preset {
3318                Some(crate::renderer::stats::QualityPreset::High) => {
3319                    (1.0_f32, 1.0_f32, false, false, false)
3320                }
3321                Some(crate::renderer::stats::QualityPreset::Medium) => {
3322                    (0.75_f32, 1.0_f32, true, false, true)
3323                }
3324                Some(crate::renderer::stats::QualityPreset::Low) => {
3325                    (0.5_f32, 0.75_f32, true, true, true)
3326                }
3327                None => (
3328                    policy.min_render_scale,
3329                    policy.max_render_scale,
3330                    policy.allow_shadow_reduction,
3331                    policy.allow_volume_quality_reduction,
3332                    policy.allow_effect_throttling,
3333                ),
3334            };
3335
3336        // Capture mode: force max render scale and suppress all degradation.
3337        // The adaptation controller is paused for the duration of the frame.
3338        let in_capture = self.runtime_mode == crate::renderer::stats::RuntimeMode::Capture;
3339        if in_capture {
3340            self.current_render_scale = eff_max_scale;
3341        }
3342
3343        // HDR path detection: post_process.enabled means render()/render_viewport()
3344        // will be called. Dynamic resolution is not implemented for the HDR path
3345        // (post-tonemap passes pair output_view with hdr_depth_view and require
3346        // matching dimensions). Suppress the controller and pin render_scale to 1.0
3347        // so FrameStats does not report a misleading value.
3348        let hdr_active = frame.effects.post_process.enabled;
3349
3350        // When a preset is active, clamp current_render_scale to the preset's bounds
3351        // immediately, without requiring allow_dynamic_resolution. This ensures the
3352        // preset has a visible effect even when the adaptation controller is off.
3353        // The controller can still adjust within these bounds when enabled.
3354        if !in_capture && !hdr_active && policy.preset.is_some() {
3355            self.current_render_scale =
3356                self.current_render_scale.clamp(eff_min_scale, eff_max_scale);
3357        }
3358
3359        // Tiered degradation ladder.
3360        // Order: render scale -> shadows -> volumes -> effects.
3361        // The tier advances one step per over-budget frame once render scale has
3362        // reached its minimum (nothing more the controller can reduce).
3363        // The tier retreats one step per frame that is comfortably under budget,
3364        // reversing the ladder in the same order (effects first).
3365        // Capture mode resets the tier; HDR path leaves it unchanged but flags
3366        // are suppressed below regardless.
3367        let missed_prev = self.last_stats.missed_budget;
3368        let under_prev = !self.last_stats.missed_budget
3369            && policy
3370                .target_fps
3371                .map(|fps| {
3372                    let budget = 1000.0 / fps;
3373                    let sig = self
3374                        .last_stats
3375                        .gpu_frame_ms
3376                        .unwrap_or(self.last_stats.total_frame_ms);
3377                    sig < budget * 0.8
3378                })
3379                .unwrap_or(true);
3380        if in_capture {
3381            self.degradation_tier = 0;
3382        } else if !hdr_active {
3383            let at_min = !policy.allow_dynamic_resolution
3384                || self.current_render_scale <= eff_min_scale + 0.001;
3385            if missed_prev && at_min {
3386                self.degradation_tier = (self.degradation_tier + 1).min(3);
3387            } else if under_prev {
3388                self.degradation_tier = self.degradation_tier.saturating_sub(1);
3389            }
3390        }
3391
3392        // Derive per-pass flags from the current tier and effective policy.
3393        // All flags are suppressed in Capture mode regardless of tier.
3394        self.degradation_shadows_skipped =
3395            !in_capture && self.degradation_tier >= 1 && eff_allow_shadows;
3396        self.degradation_volume_quality_reduced =
3397            !in_capture && self.degradation_tier >= 2 && eff_allow_volumes;
3398        self.degradation_effects_throttled =
3399            !in_capture && self.degradation_tier >= 3 && eff_allow_effects;
3400
3401        let (scene_fx, viewport_fx) = frame.effects.split();
3402        self.prepare_scene_internal(device, queue, frame, &scene_fx);
3403        self.prepare_viewport_internal(device, queue, frame, &viewport_fx);
3404
3405        let cpu_prepare_ms = prepare_start.elapsed().as_secs_f32() * 1000.0;
3406
3407        let budget_ms = policy.target_fps.map(|fps| 1000.0 / fps);
3408
3409        // Controller signal: prefer gpu_frame_ms (excludes vsync wait, one-frame lag is
3410        // acceptable). Fall back to total_frame_ms when GPU timestamps are unavailable:
3411        // it reflects wall-clock frame duration and correctly fires over-budget at low
3412        // frame rates. cpu_prepare_ms is not used as a fallback because it only measures
3413        // CPU-side work and is low even when the GPU or driver is the bottleneck.
3414        let controller_ms = self
3415            .last_stats
3416            .gpu_frame_ms
3417            .unwrap_or(total_frame_ms);
3418
3419        // Capture mode always reports missed_budget = false; degradation is suppressed.
3420        let missed_budget = !in_capture
3421            && budget_ms.map(|b| controller_ms > b).unwrap_or(false);
3422
3423        // Adaptation controller: adjust render scale within effective bounds when enabled.
3424        // Uses controller_ms from the previous frame (gpu_frame_ms when available,
3425        // otherwise total_frame_ms). Paused in Capture mode and when the HDR path is
3426        // active (see hdr_active above).
3427        if policy.allow_dynamic_resolution && !in_capture && !hdr_active {
3428            if let Some(budget) = budget_ms {
3429                if controller_ms > budget {
3430                    // Over budget: step down quickly.
3431                    self.current_render_scale =
3432                        (self.current_render_scale - 0.1).max(eff_min_scale);
3433                } else if controller_ms < budget * 0.8 {
3434                    // Comfortably under budget: recover slowly to avoid oscillation.
3435                    self.current_render_scale =
3436                        (self.current_render_scale + 0.05).min(eff_max_scale);
3437                }
3438            }
3439        }
3440
3441        self.last_prepare_instant = Some(prepare_start);
3442        self.frame_counter = self.frame_counter.wrapping_add(1);
3443
3444        // On the HDR path the render_scale has no effect on output; report 1.0
3445        // so consumers are not misled by a value that is changing but doing nothing.
3446        let reported_render_scale = if hdr_active { 1.0 } else { self.current_render_scale };
3447
3448        let stats = crate::renderer::stats::FrameStats {
3449            cpu_prepare_ms,
3450            // gpu_frame_ms is updated by the timestamp readback above when available;
3451            // propagate the most recent value from last_stats.
3452            gpu_frame_ms: self.last_stats.gpu_frame_ms,
3453            total_frame_ms,
3454            render_scale: reported_render_scale,
3455            missed_budget,
3456            upload_bytes,
3457            shadows_skipped: self.degradation_shadows_skipped,
3458            volume_quality_reduced: self.degradation_volume_quality_reduced,
3459            // effects_throttled is set by the render path; carry forward here so
3460            // prepare()-only callers still see the previous frame's value until
3461            // paint_to()/render() updates it.
3462            effects_throttled: self.degradation_effects_throttled,
3463            ..self.last_stats
3464        };
3465        self.last_stats = stats;
3466        stats
3467    }
3468}
3469
3470// ---------------------------------------------------------------------------
3471// Clip boundary wireframe helpers (used by prepare_viewport_internal)
3472// ---------------------------------------------------------------------------
3473
3474/// Wireframe outline for a clip box (12 edges as 2-point polyline strips).
3475fn clip_box_outline(
3476    center: [f32; 3],
3477    half: [f32; 3],
3478    orientation: [[f32; 3]; 3],
3479    color: [f32; 4],
3480) -> PolylineItem {
3481    let ax = glam::Vec3::from(orientation[0]) * half[0];
3482    let ay = glam::Vec3::from(orientation[1]) * half[1];
3483    let az = glam::Vec3::from(orientation[2]) * half[2];
3484    let c = glam::Vec3::from(center);
3485
3486    let corners = [
3487        c - ax - ay - az,
3488        c + ax - ay - az,
3489        c + ax + ay - az,
3490        c - ax + ay - az,
3491        c - ax - ay + az,
3492        c + ax - ay + az,
3493        c + ax + ay + az,
3494        c - ax + ay + az,
3495    ];
3496    let edges: [(usize, usize); 12] = [
3497        (0, 1),
3498        (1, 2),
3499        (2, 3),
3500        (3, 0), // bottom face
3501        (4, 5),
3502        (5, 6),
3503        (6, 7),
3504        (7, 4), // top face
3505        (0, 4),
3506        (1, 5),
3507        (2, 6),
3508        (3, 7), // verticals
3509    ];
3510
3511    let mut positions = Vec::with_capacity(24);
3512    let mut strip_lengths = Vec::with_capacity(12);
3513    for (a, b) in edges {
3514        positions.push(corners[a].to_array());
3515        positions.push(corners[b].to_array());
3516        strip_lengths.push(2u32);
3517    }
3518
3519    let mut item = PolylineItem::default();
3520    item.positions = positions;
3521    item.strip_lengths = strip_lengths;
3522    item.default_color = color;
3523    item.line_width = 2.0;
3524    item
3525}
3526
3527/// Wireframe outline for a clip sphere (three great circles).
3528fn clip_sphere_outline(center: [f32; 3], radius: f32, color: [f32; 4]) -> PolylineItem {
3529    let c = glam::Vec3::from(center);
3530    let segs = 64usize;
3531    let mut positions = Vec::with_capacity((segs + 1) * 3);
3532    let mut strip_lengths = Vec::with_capacity(3);
3533
3534    for axis in 0..3usize {
3535        let start = positions.len();
3536        for i in 0..=segs {
3537            let t = i as f32 / segs as f32 * std::f32::consts::TAU;
3538            let (s, cs) = t.sin_cos();
3539            let p = c + match axis {
3540                0 => glam::Vec3::new(cs * radius, s * radius, 0.0),
3541                1 => glam::Vec3::new(cs * radius, 0.0, s * radius),
3542                _ => glam::Vec3::new(0.0, cs * radius, s * radius),
3543            };
3544            positions.push(p.to_array());
3545        }
3546        strip_lengths.push((positions.len() - start) as u32);
3547    }
3548
3549    let mut item = PolylineItem::default();
3550    item.positions = positions;
3551    item.strip_lengths = strip_lengths;
3552    item.default_color = color;
3553    item.line_width = 2.0;
3554    item
3555}
3556
3557/// Wireframe outline for a clip cylinder (two end-cap circles + longitudinal lines).
3558fn clip_cylinder_outline(
3559    center: [f32; 3],
3560    axis: [f32; 3],
3561    radius: f32,
3562    half_length: f32,
3563    color: [f32; 4],
3564) -> PolylineItem {
3565    let c = glam::Vec3::from(center);
3566    let ax = glam::Vec3::from(axis).normalize();
3567
3568    // Build an orthonormal frame around the axis.
3569    let ref_v = if ax.y.abs() < 0.99 {
3570        glam::Vec3::Y
3571    } else {
3572        glam::Vec3::X
3573    };
3574    let perp_u = ref_v.cross(ax).normalize();
3575    let perp_v = ax.cross(perp_u);
3576
3577    let segs = 32usize;
3578    let long_lines = 8usize;
3579    let cap_verts = segs + 1;
3580    let total_cap = cap_verts * 2 + long_lines * 2;
3581    let mut positions = Vec::with_capacity(total_cap);
3582    let mut strip_lengths = Vec::with_capacity(2 + long_lines);
3583
3584    // Two end-cap circles.
3585    for sign in [-1.0f32, 1.0] {
3586        let cap_center = c + ax * (sign * half_length);
3587        let start = positions.len();
3588        for i in 0..=segs {
3589            let t = i as f32 / segs as f32 * std::f32::consts::TAU;
3590            let (s, cs) = t.sin_cos();
3591            let p = cap_center + perp_u * (cs * radius) + perp_v * (s * radius);
3592            positions.push(p.to_array());
3593        }
3594        strip_lengths.push((positions.len() - start) as u32);
3595    }
3596
3597    // Longitudinal lines connecting the two caps.
3598    for i in 0..long_lines {
3599        let t = i as f32 / long_lines as f32 * std::f32::consts::TAU;
3600        let (s, cs) = t.sin_cos();
3601        let offset = perp_u * (cs * radius) + perp_v * (s * radius);
3602        positions.push((c + ax * (-half_length) + offset).to_array());
3603        positions.push((c + ax *   half_length  + offset).to_array());
3604        strip_lengths.push(2);
3605    }
3606
3607    let mut item = PolylineItem::default();
3608    item.positions = positions;
3609    item.strip_lengths = strip_lengths;
3610    item.default_color = color;
3611    item.line_width = 2.0;
3612    item
3613}
3614
3615// ---------------------------------------------------------------------------
3616// Overlay label helpers
3617// ---------------------------------------------------------------------------
3618
3619/// Project a world-space position to NDC.
3620/// Returns `None` only if the point is behind the camera (`clip.w <= 0`).
3621/// Does NOT reject points outside the [-1,1] viewport box.
3622fn project_to_ndc(
3623    pos: [f32; 3],
3624    view: &glam::Mat4,
3625    proj: &glam::Mat4,
3626) -> Option<[f32; 2]> {
3627    let clip = *proj * *view * glam::Vec3::from(pos).extend(1.0);
3628    if clip.w <= 0.0 { return None; }
3629    Some([clip.x / clip.w, clip.y / clip.w])
3630}
3631
3632/// Convert NDC coordinates to screen pixels (top-left origin).
3633fn ndc_to_screen_px(ndc: [f32; 2], vp_w: f32, vp_h: f32) -> [f32; 2] {
3634    [
3635        (ndc[0] * 0.5 + 0.5) * vp_w,
3636        (1.0 - (ndc[1] * 0.5 + 0.5)) * vp_h,
3637    ]
3638}
3639
3640/// Returns true when the NDC point lies within the viewport square.
3641fn ndc_in_viewport(ndc: [f32; 2]) -> bool {
3642    ndc[0] >= -1.0 && ndc[0] <= 1.0 && ndc[1] >= -1.0 && ndc[1] <= 1.0
3643}
3644
3645/// Clip a line segment [a, b] in NDC to the [-1,1]^2 viewport box
3646/// using the Liang-Barsky algorithm.
3647/// Returns the clipped endpoints, or `None` if the segment is entirely outside.
3648fn clip_line_ndc(a: [f32; 2], b: [f32; 2]) -> Option<([f32; 2], [f32; 2])> {
3649    let dx = b[0] - a[0];
3650    let dy = b[1] - a[1];
3651    let mut t0 = 0.0f32;
3652    let mut t1 = 1.0f32;
3653
3654    // (p, q) pairs for left, right, bottom, top boundaries.
3655    for (p, q) in [
3656        (-dx, a[0] + 1.0),
3657        ( dx, 1.0 - a[0]),
3658        (-dy, a[1] + 1.0),
3659        ( dy, 1.0 - a[1]),
3660    ] {
3661        if p == 0.0 {
3662            if q < 0.0 { return None; }
3663        } else {
3664            let r = q / p;
3665            if p < 0.0 { t0 = t0.max(r); } else { t1 = t1.min(r); }
3666        }
3667    }
3668
3669    if t0 > t1 { return None; }
3670    Some((
3671        [a[0] + t0 * dx, a[1] + t0 * dy],
3672        [a[0] + t1 * dx, a[1] + t1 * dy],
3673    ))
3674}
3675
3676/// Project a world-space position to screen pixels (top-left origin).
3677/// Returns `None` if behind the camera or outside the frustum.
3678fn project_to_screen(
3679    pos: [f32; 3],
3680    view: &glam::Mat4,
3681    proj: &glam::Mat4,
3682    vp_w: f32,
3683    vp_h: f32,
3684) -> Option<[f32; 2]> {
3685    let p = glam::Vec3::from(pos);
3686    let clip = *proj * *view * p.extend(1.0);
3687    if clip.w <= 0.0 {
3688        return None;
3689    }
3690    let ndc_x = clip.x / clip.w;
3691    let ndc_y = clip.y / clip.w;
3692    if ndc_x < -1.0 || ndc_x > 1.0 || ndc_y < -1.0 || ndc_y > 1.0 {
3693        return None;
3694    }
3695    let x = (ndc_x * 0.5 + 0.5) * vp_w;
3696    let y = (1.0 - (ndc_y * 0.5 + 0.5)) * vp_h;
3697    Some([x, y])
3698}
3699
3700/// Convert screen pixel coordinates to NDC.
3701#[inline]
3702fn px_to_ndc(px_x: f32, px_y: f32, vp_w: f32, vp_h: f32) -> [f32; 2] {
3703    [
3704        px_x / vp_w * 2.0 - 1.0,
3705        1.0 - px_y / vp_h * 2.0,
3706    ]
3707}
3708
3709/// Emit a solid-colour quad (6 vertices) in screen pixel coordinates.
3710fn emit_solid_quad(
3711    verts: &mut Vec<crate::resources::OverlayTextVertex>,
3712    x0: f32, y0: f32,
3713    x1: f32, y1: f32,
3714    color: [f32; 4],
3715    vp_w: f32, vp_h: f32,
3716) {
3717    let tl = px_to_ndc(x0, y0, vp_w, vp_h);
3718    let tr = px_to_ndc(x1, y0, vp_w, vp_h);
3719    let bl = px_to_ndc(x0, y1, vp_w, vp_h);
3720    let br = px_to_ndc(x1, y1, vp_w, vp_h);
3721    let uv = [0.0, 0.0];
3722    let tex = 0.0;
3723    let v = |pos: [f32; 2]| crate::resources::OverlayTextVertex {
3724        position: pos, uv, color, use_texture: tex, _pad: 0.0,
3725    };
3726    verts.extend_from_slice(&[v(tl), v(bl), v(tr), v(tr), v(bl), v(br)]);
3727}
3728
3729/// Emit a textured quad (6 vertices) for a glyph in screen pixel coordinates.
3730fn emit_textured_quad(
3731    verts: &mut Vec<crate::resources::OverlayTextVertex>,
3732    x0: f32, y0: f32,
3733    x1: f32, y1: f32,
3734    uv_min: [f32; 2],
3735    uv_max: [f32; 2],
3736    color: [f32; 4],
3737    vp_w: f32, vp_h: f32,
3738) {
3739    let tl = px_to_ndc(x0, y0, vp_w, vp_h);
3740    let tr = px_to_ndc(x1, y0, vp_w, vp_h);
3741    let bl = px_to_ndc(x0, y1, vp_w, vp_h);
3742    let br = px_to_ndc(x1, y1, vp_w, vp_h);
3743    let tex = 1.0;
3744    let v = |pos: [f32; 2], uv: [f32; 2]| crate::resources::OverlayTextVertex {
3745        position: pos, uv, color, use_texture: tex, _pad: 0.0,
3746    };
3747    // UV layout: top-left = uv_min, bottom-right = uv_max.
3748    verts.extend_from_slice(&[
3749        v(tl, uv_min),
3750        v(bl, [uv_min[0], uv_max[1]]),
3751        v(tr, [uv_max[0], uv_min[1]]),
3752        v(tr, [uv_max[0], uv_min[1]]),
3753        v(bl, [uv_min[0], uv_max[1]]),
3754        v(br, uv_max),
3755    ]);
3756}
3757
3758/// Emit a thin screen-space line as a quad (6 vertices).
3759fn emit_line_quad(
3760    verts: &mut Vec<crate::resources::OverlayTextVertex>,
3761    x0: f32, y0: f32,
3762    x1: f32, y1: f32,
3763    thickness: f32,
3764    color: [f32; 4],
3765    vp_w: f32, vp_h: f32,
3766) {
3767    let dx = x1 - x0;
3768    let dy = y1 - y0;
3769    let len = (dx * dx + dy * dy).sqrt();
3770    if len < 0.001 {
3771        return;
3772    }
3773    let half = thickness * 0.5;
3774    let nx = -dy / len * half;
3775    let ny = dx / len * half;
3776
3777    let p0 = px_to_ndc(x0 + nx, y0 + ny, vp_w, vp_h);
3778    let p1 = px_to_ndc(x0 - nx, y0 - ny, vp_w, vp_h);
3779    let p2 = px_to_ndc(x1 + nx, y1 + ny, vp_w, vp_h);
3780    let p3 = px_to_ndc(x1 - nx, y1 - ny, vp_w, vp_h);
3781    let uv = [0.0, 0.0];
3782    let tex = 0.0;
3783    let v = |pos: [f32; 2]| crate::resources::OverlayTextVertex {
3784        position: pos, uv, color, use_texture: tex, _pad: 0.0,
3785    };
3786    verts.extend_from_slice(&[v(p0), v(p1), v(p2), v(p2), v(p1), v(p3)]);
3787}
3788
3789/// Apply an opacity multiplier to a colour's alpha channel.
3790#[inline]
3791fn apply_opacity(color: [f32; 4], opacity: f32) -> [f32; 4] {
3792    [color[0], color[1], color[2], color[3] * opacity]
3793}
3794
3795/// Emit a rounded rectangle as solid quads: one center rect + four edge rects +
3796/// four corner fans.  This is a CPU tessellation approach that avoids shader
3797/// changes.
3798fn emit_rounded_quad(
3799    verts: &mut Vec<crate::resources::OverlayTextVertex>,
3800    x0: f32, y0: f32,
3801    x1: f32, y1: f32,
3802    radius: f32,
3803    color: [f32; 4],
3804    vp_w: f32, vp_h: f32,
3805) {
3806    let w = x1 - x0;
3807    let h = y1 - y0;
3808    let r = radius.min(w * 0.5).min(h * 0.5).max(0.0);
3809
3810    if r < 0.5 {
3811        emit_solid_quad(verts, x0, y0, x1, y1, color, vp_w, vp_h);
3812        return;
3813    }
3814
3815    // Center cross (two rects that cover everything except the corners).
3816    // Horizontal bar (full width, inset top/bottom by r).
3817    emit_solid_quad(verts, x0, y0 + r, x1, y1 - r, color, vp_w, vp_h);
3818    // Top bar (inset left/right by r, top edge).
3819    emit_solid_quad(verts, x0 + r, y0, x1 - r, y0 + r, color, vp_w, vp_h);
3820    // Bottom bar.
3821    emit_solid_quad(verts, x0 + r, y1 - r, x1 - r, y1, color, vp_w, vp_h);
3822
3823    // Four corner fans.
3824    let corners = [
3825        (x0 + r, y0 + r, std::f32::consts::PI, std::f32::consts::FRAC_PI_2 * 3.0),       // top-left
3826        (x1 - r, y0 + r, std::f32::consts::FRAC_PI_2 * 3.0, std::f32::consts::TAU),      // top-right
3827        (x1 - r, y1 - r, 0.0, std::f32::consts::FRAC_PI_2),                               // bottom-right
3828        (x0 + r, y1 - r, std::f32::consts::FRAC_PI_2, std::f32::consts::PI),              // bottom-left
3829    ];
3830    let segments = 6;
3831    let uv = [0.0, 0.0];
3832    let tex = 0.0;
3833    let v = |pos: [f32; 2]| crate::resources::OverlayTextVertex {
3834        position: pos, uv, color, use_texture: tex, _pad: 0.0,
3835    };
3836    for (cx, cy, start, end) in corners {
3837        let center = px_to_ndc(cx, cy, vp_w, vp_h);
3838        for i in 0..segments {
3839            let a0 = start + (end - start) * i as f32 / segments as f32;
3840            let a1 = start + (end - start) * (i + 1) as f32 / segments as f32;
3841            let p0 = px_to_ndc(cx + a0.cos() * r, cy + a0.sin() * r, vp_w, vp_h);
3842            let p1 = px_to_ndc(cx + a1.cos() * r, cy + a1.sin() * r, vp_w, vp_h);
3843            verts.extend_from_slice(&[v(center), v(p0), v(p1)]);
3844        }
3845    }
3846}
3847
3848// ---------------------------------------------------------------------------
3849// Ruler label formatting
3850// ---------------------------------------------------------------------------
3851
3852/// Format a distance value using a caller-supplied format pattern.
3853///
3854/// The pattern may contain one `{...}` placeholder with an optional precision
3855/// specifier, e.g. `"{:.3}"` or `"{:.2} m"`.  Anything outside the braces is
3856/// treated as a literal prefix / suffix.  Unrecognised patterns fall back to
3857/// three decimal places.
3858fn format_ruler_distance(distance: f32, fmt: Option<&str>) -> String {
3859    let pattern = fmt.unwrap_or("{:.3}");
3860    // Find the first `{...}` block.
3861    if let Some(open) = pattern.find('{') {
3862        if let Some(close_rel) = pattern[open..].find('}') {
3863            let close = open + close_rel;
3864            let spec = &pattern[open + 1..close]; // e.g. ":.3" or ""
3865            let prefix = &pattern[..open];
3866            let suffix = &pattern[close + 1..];
3867            let formatted = if let Some(prec_str) = spec.strip_prefix(":.") {
3868                // Strip trailing 'f' for patterns like "{:.3f}".
3869                let prec_str = prec_str.trim_end_matches('f');
3870                if let Ok(prec) = prec_str.parse::<usize>() {
3871                    format!("{distance:.prec$}")
3872                } else {
3873                    format!("{distance:.3}")
3874                }
3875            } else if spec.is_empty() || spec == ":" {
3876                format!("{distance}")
3877            } else {
3878                format!("{distance:.3}")
3879            };
3880            return format!("{prefix}{formatted}{suffix}");
3881        }
3882    }
3883    format!("{distance:.3}")
3884}