Skip to main content

viewport_lib/renderer/
prepare.rs

1use super::types::{SceneEffects, ViewportEffects};
2use super::*;
3
4impl ViewportRenderer {
5    /// Scene-global prepare stage: compute filters, lighting, shadow pass, batching, scivis.
6    ///
7    /// Call once per frame before any `prepare_viewport_internal` calls.
8    ///
9    /// Reads `scene_fx` for lighting, IBL, and compute filters.  Still reads
10    /// `frame.camera` for shadow cascade computation (Phase 1 coupling — see
11    /// multi-viewport-plan.md § shadow strategy; decoupled in Phase 2).
12    pub(super) fn prepare_scene_internal(
13        &mut self,
14        device: &wgpu::Device,
15        queue: &wgpu::Queue,
16        frame: &FrameData,
17        scene_fx: &SceneEffects<'_>,
18    ) {
19        // Phase G — GPU compute filtering.
20        // Dispatch before the render pass. Completely skipped when list is empty (zero overhead).
21        if !scene_fx.compute_filter_items.is_empty() {
22            self.compute_filter_results =
23                self.resources
24                    .run_compute_filters(device, queue, scene_fx.compute_filter_items);
25        } else {
26            self.compute_filter_results.clear();
27        }
28
29        // Ensure built-in colormaps are uploaded on first frame.
30        self.resources.ensure_colormaps_initialized(device, queue);
31
32        let resources = &mut self.resources;
33        let lighting = scene_fx.lighting;
34
35        // Resolve scene items from the SurfaceSubmission seam.
36        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
37            SurfaceSubmission::Flat(items) => items,
38        };
39
40        // Compute scene center / extent for shadow framing.
41        let (shadow_center, shadow_extent) = if let Some(extent) = lighting.shadow_extent_override {
42            (glam::Vec3::ZERO, extent)
43        } else {
44            (glam::Vec3::ZERO, 20.0)
45        };
46
47        /// Build a light-space view-projection matrix for shadow mapping.
48        fn compute_shadow_matrix(
49            kind: &LightKind,
50            shadow_center: glam::Vec3,
51            shadow_extent: f32,
52        ) -> glam::Mat4 {
53            match kind {
54                LightKind::Directional { direction } => {
55                    let dir = glam::Vec3::from(*direction).normalize();
56                    let light_up = if dir.z.abs() > 0.99 {
57                        glam::Vec3::Y
58                    } else {
59                        glam::Vec3::Z
60                    };
61                    let light_pos = shadow_center + dir * shadow_extent * 2.0;
62                    let light_view = glam::Mat4::look_at_rh(light_pos, shadow_center, light_up);
63                    let light_proj = glam::Mat4::orthographic_rh(
64                        -shadow_extent,
65                        shadow_extent,
66                        -shadow_extent,
67                        shadow_extent,
68                        0.01,
69                        shadow_extent * 5.0,
70                    );
71                    light_proj * light_view
72                }
73                LightKind::Point { position, range } => {
74                    let pos = glam::Vec3::from(*position);
75                    let to_center = (shadow_center - pos).normalize();
76                    let light_up = if to_center.z.abs() > 0.99 {
77                        glam::Vec3::Y
78                    } else {
79                        glam::Vec3::Z
80                    };
81                    let light_view = glam::Mat4::look_at_rh(pos, shadow_center, light_up);
82                    let light_proj =
83                        glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_2, 1.0, 0.1, *range);
84                    light_proj * light_view
85                }
86                LightKind::Spot {
87                    position,
88                    direction,
89                    range,
90                    ..
91                } => {
92                    let pos = glam::Vec3::from(*position);
93                    let dir = glam::Vec3::from(*direction).normalize();
94                    let look_target = pos + dir;
95                    let up = if dir.z.abs() > 0.99 {
96                        glam::Vec3::Y
97                    } else {
98                        glam::Vec3::Z
99                    };
100                    let light_view = glam::Mat4::look_at_rh(pos, look_target, up);
101                    let light_proj =
102                        glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_2, 1.0, 0.1, *range);
103                    light_proj * light_view
104                }
105            }
106        }
107
108        /// Convert a `LightSource` to `SingleLightUniform`, computing shadow matrix for lights[0].
109        fn build_single_light_uniform(
110            src: &LightSource,
111            shadow_center: glam::Vec3,
112            shadow_extent: f32,
113            compute_shadow: bool,
114        ) -> SingleLightUniform {
115            let shadow_mat = if compute_shadow {
116                compute_shadow_matrix(&src.kind, shadow_center, shadow_extent)
117            } else {
118                glam::Mat4::IDENTITY
119            };
120
121            match &src.kind {
122                LightKind::Directional { direction } => SingleLightUniform {
123                    light_view_proj: shadow_mat.to_cols_array_2d(),
124                    pos_or_dir: *direction,
125                    light_type: 0,
126                    color: src.color,
127                    intensity: src.intensity,
128                    range: 0.0,
129                    inner_angle: 0.0,
130                    outer_angle: 0.0,
131                    _pad_align: 0,
132                    spot_direction: [0.0, -1.0, 0.0],
133                    _pad: [0.0; 5],
134                },
135                LightKind::Point { position, range } => SingleLightUniform {
136                    light_view_proj: shadow_mat.to_cols_array_2d(),
137                    pos_or_dir: *position,
138                    light_type: 1,
139                    color: src.color,
140                    intensity: src.intensity,
141                    range: *range,
142                    inner_angle: 0.0,
143                    outer_angle: 0.0,
144                    _pad_align: 0,
145                    spot_direction: [0.0, -1.0, 0.0],
146                    _pad: [0.0; 5],
147                },
148                LightKind::Spot {
149                    position,
150                    direction,
151                    range,
152                    inner_angle,
153                    outer_angle,
154                } => SingleLightUniform {
155                    light_view_proj: shadow_mat.to_cols_array_2d(),
156                    pos_or_dir: *position,
157                    light_type: 2,
158                    color: src.color,
159                    intensity: src.intensity,
160                    range: *range,
161                    inner_angle: *inner_angle,
162                    outer_angle: *outer_angle,
163                    _pad_align: 0,
164                    spot_direction: *direction,
165                    _pad: [0.0; 5],
166                },
167            }
168        }
169
170        // Build the LightsUniform for all active lights (max 8).
171        let light_count = lighting.lights.len().min(8) as u32;
172        let mut lights_arr = [SingleLightUniform {
173            light_view_proj: glam::Mat4::IDENTITY.to_cols_array_2d(),
174            pos_or_dir: [0.0; 3],
175            light_type: 0,
176            color: [1.0; 3],
177            intensity: 1.0,
178            range: 0.0,
179            inner_angle: 0.0,
180            outer_angle: 0.0,
181            _pad_align: 0,
182            spot_direction: [0.0, -1.0, 0.0],
183            _pad: [0.0; 5],
184        }; 8];
185
186        for (i, src) in lighting.lights.iter().take(8).enumerate() {
187            lights_arr[i] = build_single_light_uniform(src, shadow_center, shadow_extent, i == 0);
188        }
189
190        // -------------------------------------------------------------------
191        // Compute CSM cascade matrices for lights[0] (directional).
192        // Phase 1 note: uses frame.camera — see multi-viewport-plan.md § shadow strategy.
193        // -------------------------------------------------------------------
194        let cascade_count = lighting.shadow_cascade_count.clamp(1, 4) as usize;
195        let atlas_res = lighting.shadow_atlas_resolution.max(64);
196        let tile_size = atlas_res / 2;
197
198        let cascade_splits = compute_cascade_splits(
199            frame.camera.render_camera.near.max(0.01),
200            frame.camera.render_camera.far.max(1.0),
201            cascade_count as u32,
202            lighting.cascade_split_lambda,
203        );
204
205        let light_dir_for_csm = if light_count > 0 {
206            match &lighting.lights[0].kind {
207                LightKind::Directional { direction } => glam::Vec3::from(*direction).normalize(),
208                LightKind::Point { position, .. } => {
209                    (glam::Vec3::from(*position) - shadow_center).normalize()
210                }
211                LightKind::Spot {
212                    position,
213                    direction,
214                    ..
215                } => {
216                    let _ = position;
217                    glam::Vec3::from(*direction).normalize()
218                }
219            }
220        } else {
221            glam::Vec3::new(0.3, 1.0, 0.5).normalize()
222        };
223
224        let mut cascade_view_projs = [glam::Mat4::IDENTITY; 4];
225        // Distance-based splits for fragment shader cascade selection.
226        let mut cascade_split_distances = [0.0f32; 4];
227
228        // Determine if we should use CSM (directional light + valid camera data).
229        let use_csm = light_count > 0
230            && matches!(lighting.lights[0].kind, LightKind::Directional { .. })
231            && frame.camera.render_camera.view != glam::Mat4::IDENTITY;
232
233        if use_csm {
234            for i in 0..cascade_count {
235                let split_near = if i == 0 {
236                    frame.camera.render_camera.near.max(0.01)
237                } else {
238                    cascade_splits[i - 1]
239                };
240                let split_far = cascade_splits[i];
241                cascade_view_projs[i] = compute_cascade_matrix(
242                    light_dir_for_csm,
243                    frame.camera.render_camera.view,
244                    frame.camera.render_camera.fov,
245                    frame.camera.render_camera.aspect,
246                    split_near,
247                    split_far,
248                    tile_size as f32,
249                );
250                cascade_split_distances[i] = split_far;
251            }
252        } else {
253            // Fallback: single shadow map covering the whole scene (legacy behavior).
254            let primary_shadow_mat = if light_count > 0 {
255                compute_shadow_matrix(&lighting.lights[0].kind, shadow_center, shadow_extent)
256            } else {
257                glam::Mat4::IDENTITY
258            };
259            cascade_view_projs[0] = primary_shadow_mat;
260            cascade_split_distances[0] = frame.camera.render_camera.far;
261        }
262        let effective_cascade_count = if use_csm { cascade_count } else { 1 };
263
264        // Atlas tile layout (2x2 grid):
265        // [0] = top-left, [1] = top-right, [2] = bottom-left, [3] = bottom-right
266        let atlas_rects: [[f32; 4]; 8] = [
267            [0.0, 0.0, 0.5, 0.5], // cascade 0
268            [0.5, 0.0, 1.0, 0.5], // cascade 1
269            [0.0, 0.5, 0.5, 1.0], // cascade 2
270            [0.5, 0.5, 1.0, 1.0], // cascade 3
271            [0.0; 4],
272            [0.0; 4],
273            [0.0; 4],
274            [0.0; 4], // unused slots
275        ];
276
277        // Upload ShadowAtlasUniform (binding 5).
278        {
279            let mut vp_data = [[0.0f32; 4]; 16]; // 4 mat4s flattened
280            for c in 0..4 {
281                let cols = cascade_view_projs[c].to_cols_array_2d();
282                for row in 0..4 {
283                    vp_data[c * 4 + row] = cols[row];
284                }
285            }
286            let shadow_atlas_uniform = ShadowAtlasUniform {
287                cascade_view_proj: vp_data,
288                cascade_splits: cascade_split_distances,
289                cascade_count: effective_cascade_count as u32,
290                atlas_size: atlas_res as f32,
291                shadow_filter: match lighting.shadow_filter {
292                    ShadowFilter::Pcf => 0,
293                    ShadowFilter::Pcss => 1,
294                },
295                pcss_light_radius: lighting.pcss_light_radius,
296                atlas_rects,
297            };
298            queue.write_buffer(
299                &resources.shadow_info_buf,
300                0,
301                bytemuck::cast_slice(&[shadow_atlas_uniform]),
302            );
303            // Write to all per-viewport slot buffers so each viewport's bind group
304            // references correctly populated shadow info.
305            for slot in &self.viewport_slots {
306                queue.write_buffer(
307                    &slot.shadow_info_buf,
308                    0,
309                    bytemuck::cast_slice(&[shadow_atlas_uniform]),
310                );
311            }
312        }
313
314        // The primary shadow matrix is still stored in lights[0].light_view_proj for
315        // backward compat with the non-instanced shadow pass uniform.
316        let _primary_shadow_mat = cascade_view_projs[0];
317
318        // Upload lights uniform.
319        // IBL fields from environment map settings.
320        let (ibl_enabled, ibl_intensity, ibl_rotation, show_skybox) =
321            if let Some(env) = scene_fx.environment {
322                if resources.ibl_irradiance_view.is_some() {
323                    (
324                        1u32,
325                        env.intensity,
326                        env.rotation,
327                        if env.show_skybox { 1u32 } else { 0 },
328                    )
329                } else {
330                    (0, 0.0, 0.0, 0)
331                }
332            } else {
333                (0, 0.0, 0.0, 0)
334            };
335
336        let lights_uniform = LightsUniform {
337            count: light_count,
338            shadow_bias: lighting.shadow_bias,
339            shadows_enabled: if lighting.shadows_enabled { 1 } else { 0 },
340            _pad: 0,
341            sky_color: lighting.sky_color,
342            hemisphere_intensity: lighting.hemisphere_intensity,
343            ground_color: lighting.ground_color,
344            _pad2: 0.0,
345            lights: lights_arr,
346            ibl_enabled,
347            ibl_intensity,
348            ibl_rotation,
349            show_skybox,
350        };
351        queue.write_buffer(
352            &resources.light_uniform_buf,
353            0,
354            bytemuck::cast_slice(&[lights_uniform]),
355        );
356
357        // Upload all cascade matrices to the shadow uniform buffer before the shadow pass.
358        // wgpu batches write_buffer calls before the command buffer, so we must write ALL
359        // cascade slots up-front; the cascade loop then selects per-slot via dynamic offset.
360        const SHADOW_SLOT_STRIDE: u64 = 256;
361        for c in 0..4usize {
362            queue.write_buffer(
363                &resources.shadow_uniform_buf,
364                c as u64 * SHADOW_SLOT_STRIDE,
365                bytemuck::cast_slice(&cascade_view_projs[c].to_cols_array_2d()),
366            );
367        }
368
369        // -- Instancing preparation --
370        // Determine instancing mode BEFORE per-object uniforms so we can skip them.
371        let visible_count = scene_items.iter().filter(|i| i.visible).count();
372        let prev_use_instancing = self.use_instancing;
373        self.use_instancing = visible_count > INSTANCING_THRESHOLD;
374
375        // If instancing mode changed (e.g. objects added/removed crossing the threshold),
376        // clear batches so the generation check below forces a rebuild.
377        if self.use_instancing != prev_use_instancing {
378            self.instanced_batches.clear();
379            self.last_scene_generation = u64::MAX;
380            self.last_scene_items_count = usize::MAX;
381        }
382
383        // Per-object uniform writes — needed for the non-instanced path, wireframe mode,
384        // and for any items with active scalar attributes or two-sided materials
385        // (both bypass the instanced path).
386        let has_scalar_items = scene_items.iter().any(|i| i.active_attribute.is_some());
387        let has_two_sided_items = scene_items.iter().any(|i| i.two_sided);
388        if !self.use_instancing
389            || frame.viewport.wireframe_mode
390            || has_scalar_items
391            || has_two_sided_items
392        {
393            for item in scene_items {
394                if resources
395                    .mesh_store
396                    .get(crate::resources::mesh_store::MeshId(item.mesh_index))
397                    .is_none()
398                {
399                    tracing::warn!(
400                        mesh_index = item.mesh_index,
401                        "scene item mesh_index invalid, skipping"
402                    );
403                    continue;
404                };
405                let m = &item.material;
406                // Compute scalar attribute range.
407                let (has_attr, s_min, s_max) = if let Some(attr_ref) = &item.active_attribute {
408                    let range = item
409                        .scalar_range
410                        .or_else(|| {
411                            resources
412                                .mesh_store
413                                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
414                                .and_then(|mesh| mesh.attribute_ranges.get(&attr_ref.name).copied())
415                        })
416                        .unwrap_or((0.0, 1.0));
417                    (1u32, range.0, range.1)
418                } else {
419                    (0u32, 0.0, 1.0)
420                };
421                let obj_uniform = ObjectUniform {
422                    model: item.model,
423                    color: [m.base_color[0], m.base_color[1], m.base_color[2], m.opacity],
424                    selected: if item.selected { 1 } else { 0 },
425                    wireframe: if frame.viewport.wireframe_mode { 1 } else { 0 },
426                    ambient: m.ambient,
427                    diffuse: m.diffuse,
428                    specular: m.specular,
429                    shininess: m.shininess,
430                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
431                    use_pbr: if m.use_pbr { 1 } else { 0 },
432                    metallic: m.metallic,
433                    roughness: m.roughness,
434                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
435                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
436                    has_attribute: has_attr,
437                    scalar_min: s_min,
438                    scalar_max: s_max,
439                    _pad_scalar: 0,
440                    nan_color: item.nan_color.unwrap_or([0.0; 4]),
441                    use_nan_color: if item.nan_color.is_some() { 1 } else { 0 },
442                    _pad_nan: [0; 3],
443                };
444
445                let normal_obj_uniform = ObjectUniform {
446                    model: item.model,
447                    color: [1.0, 1.0, 1.0, 1.0],
448                    selected: 0,
449                    wireframe: 0,
450                    ambient: 0.15,
451                    diffuse: 0.75,
452                    specular: 0.4,
453                    shininess: 32.0,
454                    has_texture: 0,
455                    use_pbr: 0,
456                    metallic: 0.0,
457                    roughness: 0.5,
458                    has_normal_map: 0,
459                    has_ao_map: 0,
460                    has_attribute: 0,
461                    scalar_min: 0.0,
462                    scalar_max: 1.0,
463                    _pad_scalar: 0,
464                    nan_color: [0.0; 4],
465                    use_nan_color: 0,
466                    _pad_nan: [0; 3],
467                };
468
469                // Write uniform data — use get() to read buffer references, then drop.
470                {
471                    let mesh = resources
472                        .mesh_store
473                        .get(crate::resources::mesh_store::MeshId(item.mesh_index))
474                        .unwrap();
475                    queue.write_buffer(
476                        &mesh.object_uniform_buf,
477                        0,
478                        bytemuck::cast_slice(&[obj_uniform]),
479                    );
480                    queue.write_buffer(
481                        &mesh.normal_uniform_buf,
482                        0,
483                        bytemuck::cast_slice(&[normal_obj_uniform]),
484                    );
485                } // mesh borrow dropped here
486
487                // Rebuild the object bind group if material/attribute/LUT changed.
488                resources.update_mesh_texture_bind_group(
489                    device,
490                    item.mesh_index,
491                    item.material.texture_id,
492                    item.material.normal_map_id,
493                    item.material.ao_map_id,
494                    item.colormap_id,
495                    item.active_attribute.as_ref().map(|a| a.name.as_str()),
496                );
497            }
498        }
499
500        if self.use_instancing {
501            resources.ensure_instanced_pipelines(device);
502
503            // Generation-based cache: skip batch rebuild and GPU upload when nothing changed.
504            // Phase 2: wireframe_mode removed from cache key — wireframe rendering
505            // uses the per-object wireframe_pipeline, not the instanced path, so
506            // instance data is now viewport-agnostic.
507            let cache_valid = frame.scene.generation == self.last_scene_generation
508                && frame.interaction.selection_generation == self.last_selection_generation
509                && scene_items.len() == self.last_scene_items_count;
510
511            if !cache_valid {
512                // Cache miss — rebuild batches and upload instance data.
513                let mut sorted_items: Vec<&SceneRenderItem> = scene_items
514                    .iter()
515                    .filter(|item| {
516                        item.visible
517                            && item.active_attribute.is_none()
518                            && !item.two_sided
519                            && resources
520                                .mesh_store
521                                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
522                                .is_some()
523                    })
524                    .collect();
525
526                sorted_items.sort_unstable_by_key(|item| {
527                    (
528                        item.mesh_index,
529                        item.material.texture_id,
530                        item.material.normal_map_id,
531                        item.material.ao_map_id,
532                    )
533                });
534
535                let mut all_instances: Vec<InstanceData> = Vec::with_capacity(sorted_items.len());
536                let mut instanced_batches: Vec<InstancedBatch> = Vec::new();
537
538                if !sorted_items.is_empty() {
539                    let mut batch_start = 0usize;
540                    for i in 1..=sorted_items.len() {
541                        let at_end = i == sorted_items.len();
542                        let key_changed = !at_end && {
543                            let a = sorted_items[batch_start];
544                            let b = sorted_items[i];
545                            a.mesh_index != b.mesh_index
546                                || a.material.texture_id != b.material.texture_id
547                                || a.material.normal_map_id != b.material.normal_map_id
548                                || a.material.ao_map_id != b.material.ao_map_id
549                        };
550
551                        if at_end || key_changed {
552                            let batch_items = &sorted_items[batch_start..i];
553                            let rep = batch_items[0];
554                            let instance_offset = all_instances.len() as u32;
555                            let is_transparent = rep.material.opacity < 1.0;
556
557                            for item in batch_items {
558                                let m = &item.material;
559                                all_instances.push(InstanceData {
560                                    model: item.model,
561                                    color: [
562                                        m.base_color[0],
563                                        m.base_color[1],
564                                        m.base_color[2],
565                                        m.opacity,
566                                    ],
567                                    selected: if item.selected { 1 } else { 0 },
568                                    wireframe: 0, // Phase 2: always 0 — wireframe uses per-object pipeline
569                                    ambient: m.ambient,
570                                    diffuse: m.diffuse,
571                                    specular: m.specular,
572                                    shininess: m.shininess,
573                                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
574                                    use_pbr: if m.use_pbr { 1 } else { 0 },
575                                    metallic: m.metallic,
576                                    roughness: m.roughness,
577                                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
578                                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
579                                });
580                            }
581
582                            instanced_batches.push(InstancedBatch {
583                                mesh_index: rep.mesh_index,
584                                texture_id: rep.material.texture_id,
585                                normal_map_id: rep.material.normal_map_id,
586                                ao_map_id: rep.material.ao_map_id,
587                                instance_offset,
588                                instance_count: batch_items.len() as u32,
589                                is_transparent,
590                            });
591
592                            batch_start = i;
593                        }
594                    }
595                }
596
597                self.cached_instance_data = all_instances;
598                self.cached_instanced_batches = instanced_batches;
599
600                resources.upload_instance_data(device, queue, &self.cached_instance_data);
601
602                self.instanced_batches = self.cached_instanced_batches.clone();
603
604                self.last_scene_generation = frame.scene.generation;
605                self.last_selection_generation = frame.interaction.selection_generation;
606                self.last_scene_items_count = scene_items.len();
607
608                for batch in &self.instanced_batches {
609                    resources.get_instance_bind_group(
610                        device,
611                        batch.texture_id,
612                        batch.normal_map_id,
613                        batch.ao_map_id,
614                    );
615                }
616            } else {
617                for batch in &self.instanced_batches {
618                    resources.get_instance_bind_group(
619                        device,
620                        batch.texture_id,
621                        batch.normal_map_id,
622                        batch.ao_map_id,
623                    );
624                }
625            }
626        }
627
628        // ------------------------------------------------------------------
629        // SciVis Phase B — point cloud and glyph GPU data upload.
630        // ------------------------------------------------------------------
631        self.point_cloud_gpu_data.clear();
632        if !frame.scene.point_clouds.is_empty() {
633            resources.ensure_point_cloud_pipeline(device);
634            for item in &frame.scene.point_clouds {
635                if item.positions.is_empty() {
636                    continue;
637                }
638                let gpu_data = resources.upload_point_cloud(device, queue, item);
639                self.point_cloud_gpu_data.push(gpu_data);
640            }
641        }
642
643        self.glyph_gpu_data.clear();
644        if !frame.scene.glyphs.is_empty() {
645            resources.ensure_glyph_pipeline(device);
646            for item in &frame.scene.glyphs {
647                if item.positions.is_empty() || item.vectors.is_empty() {
648                    continue;
649                }
650                let gpu_data = resources.upload_glyph_set(device, queue, item);
651                self.glyph_gpu_data.push(gpu_data);
652            }
653        }
654
655        // ------------------------------------------------------------------
656        // SciVis Phase M8 — polyline GPU data upload.
657        // ------------------------------------------------------------------
658        self.polyline_gpu_data.clear();
659        if !frame.scene.polylines.is_empty() {
660            resources.ensure_polyline_pipeline(device);
661            for item in &frame.scene.polylines {
662                if item.positions.is_empty() {
663                    continue;
664                }
665                let gpu_data = resources.upload_polyline(device, queue, item);
666                self.polyline_gpu_data.push(gpu_data);
667            }
668        }
669
670        // ------------------------------------------------------------------
671        // SciVis Phase L — isoline extraction and upload via polyline pipeline.
672        // ------------------------------------------------------------------
673        if !frame.scene.isolines.is_empty() {
674            resources.ensure_polyline_pipeline(device);
675            for item in &frame.scene.isolines {
676                if item.positions.is_empty() || item.indices.is_empty() || item.scalars.is_empty() {
677                    continue;
678                }
679                let (positions, strip_lengths) = crate::geometry::isoline::extract_isolines(item);
680                if positions.is_empty() {
681                    continue;
682                }
683                let polyline = PolylineItem {
684                    positions,
685                    scalars: Vec::new(),
686                    strip_lengths,
687                    scalar_range: None,
688                    colormap_id: None,
689                    default_color: item.color,
690                    line_width: item.line_width,
691                    id: 0,
692                };
693                let gpu_data = resources.upload_polyline(device, queue, &polyline);
694                self.polyline_gpu_data.push(gpu_data);
695            }
696        }
697
698        // ------------------------------------------------------------------
699        // SciVis Phase M — streamtube GPU data upload.
700        // ------------------------------------------------------------------
701        self.streamtube_gpu_data.clear();
702        if !frame.scene.streamtube_items.is_empty() {
703            resources.ensure_streamtube_pipeline(device);
704            for item in &frame.scene.streamtube_items {
705                if item.positions.is_empty() || item.strip_lengths.is_empty() {
706                    continue;
707                }
708                let gpu_data = resources.upload_streamtube(device, queue, item);
709                if gpu_data.instance_count > 0 {
710                    self.streamtube_gpu_data.push(gpu_data);
711                }
712            }
713        }
714
715        // ------------------------------------------------------------------
716        // SciVis Phase D — volume GPU data upload.
717        // Phase 1 note: clip_planes are per-viewport but passed here for culling.
718        // Fix in Phase 2/3: upload clip-plane-agnostic data; apply planes in shader.
719        // ------------------------------------------------------------------
720        self.volume_gpu_data.clear();
721        if !frame.scene.volumes.is_empty() {
722            resources.ensure_volume_pipeline(device);
723            for item in &frame.scene.volumes {
724                let gpu =
725                    resources.upload_volume_frame(device, queue, item, &frame.effects.clip_planes);
726                self.volume_gpu_data.push(gpu);
727            }
728        }
729
730        // -- Frame stats --
731        {
732            let total = scene_items.len() as u32;
733            let visible = scene_items.iter().filter(|i| i.visible).count() as u32;
734            let mut draw_calls = 0u32;
735            let mut triangles = 0u64;
736            let instanced_batch_count = if self.use_instancing {
737                self.instanced_batches.len() as u32
738            } else {
739                0
740            };
741
742            if self.use_instancing {
743                for batch in &self.instanced_batches {
744                    if let Some(mesh) = resources
745                        .mesh_store
746                        .get(crate::resources::mesh_store::MeshId(batch.mesh_index))
747                    {
748                        draw_calls += 1;
749                        triangles += (mesh.index_count / 3) as u64 * batch.instance_count as u64;
750                    }
751                }
752            } else {
753                for item in scene_items {
754                    if !item.visible {
755                        continue;
756                    }
757                    if let Some(mesh) = resources
758                        .mesh_store
759                        .get(crate::resources::mesh_store::MeshId(item.mesh_index))
760                    {
761                        draw_calls += 1;
762                        triangles += (mesh.index_count / 3) as u64;
763                    }
764                }
765            }
766
767            self.last_stats = crate::renderer::stats::FrameStats {
768                total_objects: total,
769                visible_objects: visible,
770                culled_objects: total.saturating_sub(visible),
771                draw_calls,
772                instanced_batches: instanced_batch_count,
773                triangles_submitted: triangles,
774                shadow_draw_calls: 0, // Updated below in shadow pass.
775            };
776        }
777
778        // ------------------------------------------------------------------
779        // Shadow depth pass — CSM: render each cascade into its atlas tile.
780        // ------------------------------------------------------------------
781        if lighting.shadows_enabled && !scene_items.is_empty() {
782            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
783                label: Some("shadow_pass_encoder"),
784            });
785            {
786                let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
787                    label: Some("shadow_pass"),
788                    color_attachments: &[],
789                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
790                        view: &resources.shadow_map_view,
791                        depth_ops: Some(wgpu::Operations {
792                            load: wgpu::LoadOp::Clear(1.0),
793                            store: wgpu::StoreOp::Store,
794                        }),
795                        stencil_ops: None,
796                    }),
797                    timestamp_writes: None,
798                    occlusion_query_set: None,
799                });
800
801                let mut shadow_draws = 0u32;
802                let tile_px = tile_size as f32;
803
804                if self.use_instancing {
805                    if let (Some(pipeline), Some(instance_bg)) = (
806                        &resources.shadow_instanced_pipeline,
807                        self.instanced_batches.first().and_then(|b| {
808                            resources.instance_bind_groups.get(&(
809                                b.texture_id.unwrap_or(u64::MAX),
810                                b.normal_map_id.unwrap_or(u64::MAX),
811                                b.ao_map_id.unwrap_or(u64::MAX),
812                            ))
813                        }),
814                    ) {
815                        for cascade in 0..effective_cascade_count {
816                            let tile_col = (cascade % 2) as f32;
817                            let tile_row = (cascade / 2) as f32;
818                            shadow_pass.set_viewport(
819                                tile_col * tile_px,
820                                tile_row * tile_px,
821                                tile_px,
822                                tile_px,
823                                0.0,
824                                1.0,
825                            );
826                            shadow_pass.set_scissor_rect(
827                                (tile_col * tile_px) as u32,
828                                (tile_row * tile_px) as u32,
829                                tile_size,
830                                tile_size,
831                            );
832
833                            shadow_pass.set_pipeline(pipeline);
834
835                            queue.write_buffer(
836                                resources.shadow_instanced_cascade_bufs[cascade]
837                                    .as_ref()
838                                    .expect("shadow_instanced_cascade_bufs not allocated"),
839                                0,
840                                bytemuck::cast_slice(
841                                    &cascade_view_projs[cascade].to_cols_array_2d(),
842                                ),
843                            );
844
845                            let cascade_bg = resources.shadow_instanced_cascade_bgs[cascade]
846                                .as_ref()
847                                .expect("shadow_instanced_cascade_bgs not allocated");
848                            shadow_pass.set_bind_group(0, cascade_bg, &[]);
849                            shadow_pass.set_bind_group(1, instance_bg, &[]);
850
851                            for batch in &self.instanced_batches {
852                                if batch.is_transparent {
853                                    continue;
854                                }
855                                let Some(mesh) = resources
856                                    .mesh_store
857                                    .get(crate::resources::mesh_store::MeshId(batch.mesh_index))
858                                else {
859                                    continue;
860                                };
861                                shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
862                                shadow_pass.set_index_buffer(
863                                    mesh.index_buffer.slice(..),
864                                    wgpu::IndexFormat::Uint32,
865                                );
866                                shadow_pass.draw_indexed(
867                                    0..mesh.index_count,
868                                    0,
869                                    batch.instance_offset
870                                        ..batch.instance_offset + batch.instance_count,
871                                );
872                                shadow_draws += 1;
873                            }
874                        }
875                    }
876                } else {
877                    for cascade in 0..effective_cascade_count {
878                        let tile_col = (cascade % 2) as f32;
879                        let tile_row = (cascade / 2) as f32;
880                        shadow_pass.set_viewport(
881                            tile_col * tile_px,
882                            tile_row * tile_px,
883                            tile_px,
884                            tile_px,
885                            0.0,
886                            1.0,
887                        );
888                        shadow_pass.set_scissor_rect(
889                            (tile_col * tile_px) as u32,
890                            (tile_row * tile_px) as u32,
891                            tile_size,
892                            tile_size,
893                        );
894
895                        shadow_pass.set_pipeline(&resources.shadow_pipeline);
896                        shadow_pass.set_bind_group(
897                            0,
898                            &resources.shadow_bind_group,
899                            &[cascade as u32 * 256],
900                        );
901
902                        let cascade_frustum = crate::camera::frustum::Frustum::from_view_proj(
903                            &cascade_view_projs[cascade],
904                        );
905
906                        for item in scene_items.iter() {
907                            if !item.visible {
908                                continue;
909                            }
910                            if item.material.opacity < 1.0 {
911                                continue;
912                            }
913                            let Some(mesh) = resources
914                                .mesh_store
915                                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
916                            else {
917                                continue;
918                            };
919
920                            let world_aabb = mesh
921                                .aabb
922                                .transformed(&glam::Mat4::from_cols_array_2d(&item.model));
923                            if cascade_frustum.cull_aabb(&world_aabb) {
924                                continue;
925                            }
926
927                            shadow_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
928                            shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
929                            shadow_pass.set_index_buffer(
930                                mesh.index_buffer.slice(..),
931                                wgpu::IndexFormat::Uint32,
932                            );
933                            shadow_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
934                            shadow_draws += 1;
935                        }
936                    }
937                }
938                drop(shadow_pass);
939                self.last_stats.shadow_draw_calls = shadow_draws;
940            }
941            queue.submit(std::iter::once(encoder.finish()));
942        }
943    }
944
945    /// Per-viewport prepare stage: camera, clip planes, clip volume, grid, overlays, cap geometry, axes.
946    ///
947    /// Call once per viewport per frame, after `prepare_scene_internal`.
948    /// Reads `viewport_fx` for clip planes, clip volume, cap fill, and post-process settings.
949    pub(super) fn prepare_viewport_internal(
950        &mut self,
951        device: &wgpu::Device,
952        queue: &wgpu::Queue,
953        frame: &FrameData,
954        viewport_fx: &ViewportEffects<'_>,
955    ) {
956        // Ensure a per-viewport camera slot exists for this viewport index.
957        // Must happen before the `resources` borrow below.
958        self.ensure_viewport_slot(device, frame.camera.viewport_index);
959
960        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
961            SurfaceSubmission::Flat(items) => items,
962        };
963
964        {
965            let resources = &mut self.resources;
966
967            // Upload clip planes uniform to per-viewport slot buffer (binding 4).
968            {
969                let mut planes = [[0.0f32; 4]; 6];
970                let mut count = 0u32;
971                for plane in viewport_fx.clip_planes.iter().filter(|p| p.enabled).take(6) {
972                    planes[count as usize] = [
973                        plane.normal[0],
974                        plane.normal[1],
975                        plane.normal[2],
976                        plane.distance,
977                    ];
978                    count += 1;
979                }
980                let clip_uniform = ClipPlanesUniform {
981                    planes,
982                    count,
983                    _pad0: 0,
984                    viewport_width: frame.camera.viewport_size[0].max(1.0),
985                    viewport_height: frame.camera.viewport_size[1].max(1.0),
986                };
987                // Write to per-viewport slot buffer.
988                if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
989                    queue.write_buffer(
990                        &slot.clip_planes_buf,
991                        0,
992                        bytemuck::cast_slice(&[clip_uniform]),
993                    );
994                }
995                // Also write to shared buffer for legacy single-viewport callers.
996                queue.write_buffer(
997                    &resources.clip_planes_uniform_buf,
998                    0,
999                    bytemuck::cast_slice(&[clip_uniform]),
1000                );
1001            }
1002
1003            // Upload clip volume uniform to per-viewport slot buffer (binding 6).
1004            {
1005                let clip_vol_uniform = ClipVolumeUniform::from_clip_volume(viewport_fx.clip_volume);
1006                if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1007                    queue.write_buffer(
1008                        &slot.clip_volume_buf,
1009                        0,
1010                        bytemuck::cast_slice(&[clip_vol_uniform]),
1011                    );
1012                }
1013                queue.write_buffer(
1014                    &resources.clip_volume_uniform_buf,
1015                    0,
1016                    bytemuck::cast_slice(&[clip_vol_uniform]),
1017                );
1018            }
1019
1020            // Upload camera uniform to per-viewport slot buffer.
1021            let camera_uniform = frame.camera.render_camera.camera_uniform();
1022            // Write to shared buffer for legacy single-viewport callers.
1023            queue.write_buffer(
1024                &resources.camera_uniform_buf,
1025                0,
1026                bytemuck::cast_slice(&[camera_uniform]),
1027            );
1028            // Write to the per-viewport slot buffer.
1029            if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1030                queue.write_buffer(&slot.camera_buf, 0, bytemuck::cast_slice(&[camera_uniform]));
1031            }
1032
1033            // Upload grid uniform (full-screen analytical shader — no vertex buffers needed).
1034            if frame.viewport.show_grid {
1035                let eye = glam::Vec3::from(frame.camera.render_camera.eye_position);
1036                if !eye.is_finite() {
1037                    tracing::warn!(
1038                        eye_x = eye.x,
1039                        eye_y = eye.y,
1040                        eye_z = eye.z,
1041                        "grid skipped: eye_position is non-finite (camera distance overflow?)"
1042                    );
1043                } else {
1044                    let view_proj_mat = frame.camera.render_camera.view_proj().to_cols_array_2d();
1045
1046                    let (spacing, minor_fade) = if frame.viewport.grid_cell_size > 0.0 {
1047                        (frame.viewport.grid_cell_size, 1.0_f32)
1048                    } else {
1049                        let vertical_depth = (eye.z - frame.viewport.grid_z).abs().max(1.0);
1050                        let world_per_pixel =
1051                            2.0 * (frame.camera.render_camera.fov / 2.0).tan() * vertical_depth
1052                                / frame.camera.viewport_size[1].max(1.0);
1053                        let target = (world_per_pixel * 60.0).max(1e-9_f32);
1054                        let mut s = 1.0_f32;
1055                        let mut iters = 0u32;
1056                        while s < target {
1057                            s *= 10.0;
1058                            iters += 1;
1059                        }
1060                        let ratio = (target / s).clamp(0.0, 1.0);
1061                        let fade = if ratio < 0.5 {
1062                            1.0_f32
1063                        } else {
1064                            let t = (ratio - 0.5) * 2.0;
1065                            1.0 - t * t * (3.0 - 2.0 * t)
1066                        };
1067                        tracing::debug!(
1068                            eye_z = eye.z,
1069                            vertical_depth,
1070                            world_per_pixel,
1071                            target,
1072                            spacing = s,
1073                            lod_iters = iters,
1074                            ratio,
1075                            minor_fade = fade,
1076                            "grid LOD"
1077                        );
1078                        (s, fade)
1079                    };
1080
1081                    let spacing_major = spacing * 10.0;
1082                    let snap_x = (eye.x / spacing_major).floor() * spacing_major;
1083                    let snap_y = (eye.y / spacing_major).floor() * spacing_major;
1084                    tracing::debug!(
1085                        spacing_minor = spacing,
1086                        spacing_major,
1087                        snap_x,
1088                        snap_y,
1089                        eye_x = eye.x,
1090                        eye_y = eye.y,
1091                        eye_z = eye.z,
1092                        "grid snap"
1093                    );
1094
1095                    let orient = frame.camera.render_camera.orientation;
1096                    let right = orient * glam::Vec3::X;
1097                    let up = orient * glam::Vec3::Y;
1098                    let back = orient * glam::Vec3::Z;
1099                    let cam_to_world = [
1100                        [right.x, right.y, right.z, 0.0_f32],
1101                        [up.x, up.y, up.z, 0.0_f32],
1102                        [back.x, back.y, back.z, 0.0_f32],
1103                    ];
1104                    let aspect =
1105                        frame.camera.viewport_size[0] / frame.camera.viewport_size[1].max(1.0);
1106                    let tan_half_fov = (frame.camera.render_camera.fov / 2.0).tan();
1107
1108                    let uniform = GridUniform {
1109                        view_proj: view_proj_mat,
1110                        cam_to_world,
1111                        tan_half_fov,
1112                        aspect,
1113                        _pad_ivp: [0.0; 2],
1114                        eye_pos: frame.camera.render_camera.eye_position,
1115                        grid_z: frame.viewport.grid_z,
1116                        spacing_minor: spacing,
1117                        spacing_major,
1118                        snap_origin: [snap_x, snap_y],
1119                        color_minor: [0.35, 0.35, 0.35, 0.4 * minor_fade],
1120                        color_major: [0.40, 0.40, 0.40, 0.4 + 0.2 * minor_fade],
1121                    };
1122                    // Write to per-viewport slot buffer.
1123                    if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1124                        queue.write_buffer(&slot.grid_buf, 0, bytemuck::cast_slice(&[uniform]));
1125                    }
1126                    // Also write to shared buffer for legacy callers.
1127                    queue.write_buffer(
1128                        &resources.grid_uniform_buf,
1129                        0,
1130                        bytemuck::cast_slice(&[uniform]),
1131                    );
1132                }
1133            }
1134        } // `resources` mutable borrow dropped here.
1135
1136        // ------------------------------------------------------------------
1137        // Build per-viewport interaction state into local variables.
1138        // Uses &self.resources (immutable) for BGL lookups; no conflict with
1139        // the slot borrow that follows.
1140        // ------------------------------------------------------------------
1141
1142        let vp_idx = frame.camera.viewport_index;
1143
1144        // Outline buffers for selected objects.
1145        let mut outline_object_buffers: Vec<OutlineObjectBuffers> = Vec::new();
1146        if frame.interaction.outline_selected {
1147            let resources = &self.resources;
1148            for item in scene_items {
1149                if !item.visible || !item.selected {
1150                    continue;
1151                }
1152                let m = &item.material;
1153                let stencil_uniform = ObjectUniform {
1154                    model: item.model,
1155                    color: [m.base_color[0], m.base_color[1], m.base_color[2], m.opacity],
1156                    selected: 1,
1157                    wireframe: 0,
1158                    ambient: m.ambient,
1159                    diffuse: m.diffuse,
1160                    specular: m.specular,
1161                    shininess: m.shininess,
1162                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
1163                    use_pbr: if m.use_pbr { 1 } else { 0 },
1164                    metallic: m.metallic,
1165                    roughness: m.roughness,
1166                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
1167                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
1168                    has_attribute: 0,
1169                    scalar_min: 0.0,
1170                    scalar_max: 1.0,
1171                    _pad_scalar: 0,
1172                    nan_color: [0.0; 4],
1173                    use_nan_color: 0,
1174                    _pad_nan: [0; 3],
1175                };
1176                let stencil_buf = device.create_buffer(&wgpu::BufferDescriptor {
1177                    label: Some("outline_stencil_object_uniform_buf"),
1178                    size: std::mem::size_of::<ObjectUniform>() as u64,
1179                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1180                    mapped_at_creation: false,
1181                });
1182                queue.write_buffer(&stencil_buf, 0, bytemuck::cast_slice(&[stencil_uniform]));
1183
1184                let albedo_view = match m.texture_id {
1185                    Some(id) if (id as usize) < resources.textures.len() => {
1186                        &resources.textures[id as usize].view
1187                    }
1188                    _ => &resources.fallback_texture.view,
1189                };
1190                let normal_view = match m.normal_map_id {
1191                    Some(id) if (id as usize) < resources.textures.len() => {
1192                        &resources.textures[id as usize].view
1193                    }
1194                    _ => &resources.fallback_normal_map_view,
1195                };
1196                let ao_view = match m.ao_map_id {
1197                    Some(id) if (id as usize) < resources.textures.len() => {
1198                        &resources.textures[id as usize].view
1199                    }
1200                    _ => &resources.fallback_ao_map_view,
1201                };
1202                let stencil_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1203                    label: Some("outline_stencil_object_bg"),
1204                    layout: &resources.object_bind_group_layout,
1205                    entries: &[
1206                        wgpu::BindGroupEntry {
1207                            binding: 0,
1208                            resource: stencil_buf.as_entire_binding(),
1209                        },
1210                        wgpu::BindGroupEntry {
1211                            binding: 1,
1212                            resource: wgpu::BindingResource::TextureView(albedo_view),
1213                        },
1214                        wgpu::BindGroupEntry {
1215                            binding: 2,
1216                            resource: wgpu::BindingResource::Sampler(&resources.material_sampler),
1217                        },
1218                        wgpu::BindGroupEntry {
1219                            binding: 3,
1220                            resource: wgpu::BindingResource::TextureView(normal_view),
1221                        },
1222                        wgpu::BindGroupEntry {
1223                            binding: 4,
1224                            resource: wgpu::BindingResource::TextureView(ao_view),
1225                        },
1226                        wgpu::BindGroupEntry {
1227                            binding: 5,
1228                            resource: wgpu::BindingResource::TextureView(
1229                                &resources.fallback_lut_view,
1230                            ),
1231                        },
1232                        wgpu::BindGroupEntry {
1233                            binding: 6,
1234                            resource: resources.fallback_scalar_buf.as_entire_binding(),
1235                        },
1236                    ],
1237                });
1238
1239                let uniform = OutlineUniform {
1240                    model: item.model,
1241                    color: frame.interaction.outline_color,
1242                    pixel_offset: frame.interaction.outline_width_px,
1243                    _pad: [0.0; 3],
1244                };
1245                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1246                    label: Some("outline_uniform_buf"),
1247                    size: std::mem::size_of::<OutlineUniform>() as u64,
1248                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1249                    mapped_at_creation: false,
1250                });
1251                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
1252                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1253                    label: Some("outline_object_bg"),
1254                    layout: &resources.outline_bind_group_layout,
1255                    entries: &[wgpu::BindGroupEntry {
1256                        binding: 0,
1257                        resource: buf.as_entire_binding(),
1258                    }],
1259                });
1260                outline_object_buffers.push(OutlineObjectBuffers {
1261                    mesh_index: item.mesh_index,
1262                    two_sided: item.two_sided,
1263                    _stencil_uniform_buf: stencil_buf,
1264                    stencil_bind_group: stencil_bg,
1265                    _outline_uniform_buf: buf,
1266                    outline_bind_group: bg,
1267                });
1268            }
1269        }
1270
1271        // X-ray buffers for selected objects.
1272        let mut xray_object_buffers: Vec<(usize, wgpu::Buffer, wgpu::BindGroup)> = Vec::new();
1273        if frame.interaction.xray_selected {
1274            let resources = &self.resources;
1275            for item in scene_items {
1276                if !item.visible || !item.selected {
1277                    continue;
1278                }
1279                let uniform = OutlineUniform {
1280                    model: item.model,
1281                    color: frame.interaction.xray_color,
1282                    pixel_offset: 0.0,
1283                    _pad: [0.0; 3],
1284                };
1285                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1286                    label: Some("xray_uniform_buf"),
1287                    size: std::mem::size_of::<OutlineUniform>() as u64,
1288                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1289                    mapped_at_creation: false,
1290                });
1291                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
1292                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1293                    label: Some("xray_object_bg"),
1294                    layout: &resources.outline_bind_group_layout,
1295                    entries: &[wgpu::BindGroupEntry {
1296                        binding: 0,
1297                        resource: buf.as_entire_binding(),
1298                    }],
1299                });
1300                xray_object_buffers.push((item.mesh_index, buf, bg));
1301            }
1302        }
1303
1304        // Constraint guide lines.
1305        let mut constraint_line_buffers = Vec::new();
1306        for overlay in &frame.interaction.constraint_overlays {
1307            constraint_line_buffers.push(self.resources.create_constraint_overlay(device, overlay));
1308        }
1309
1310        // Clip plane overlays.
1311        let mut clip_plane_fill_buffers = Vec::new();
1312        let mut clip_plane_line_buffers = Vec::new();
1313        for overlay in &frame.interaction.clip_plane_overlays {
1314            clip_plane_fill_buffers.push(
1315                self.resources
1316                    .create_clip_plane_fill_overlay(device, overlay),
1317            );
1318            clip_plane_line_buffers.push(
1319                self.resources
1320                    .create_clip_plane_line_overlay(device, overlay),
1321            );
1322        }
1323
1324        // Cap geometry for section-view cross-section fill.
1325        let mut cap_buffers = Vec::new();
1326        if viewport_fx.cap_fill_enabled {
1327            let active_planes: Vec<_> = viewport_fx
1328                .clip_planes
1329                .iter()
1330                .filter(|p| p.enabled)
1331                .collect();
1332            for plane in &active_planes {
1333                let plane_n = glam::Vec3::from(plane.normal);
1334                for item in scene_items.iter().filter(|i| i.visible) {
1335                    let Some(mesh) = self
1336                        .resources
1337                        .mesh_store
1338                        .get(crate::resources::mesh_store::MeshId(item.mesh_index))
1339                    else {
1340                        continue;
1341                    };
1342                    let model = glam::Mat4::from_cols_array_2d(&item.model);
1343                    let world_aabb = mesh.aabb.transformed(&model);
1344                    if !world_aabb.intersects_plane(plane_n, plane.distance) {
1345                        continue;
1346                    }
1347                    let (Some(pos), Some(idx)) = (&mesh.cpu_positions, &mesh.cpu_indices) else {
1348                        continue;
1349                    };
1350                    if let Some(cap) = crate::geometry::cap_geometry::generate_cap_mesh(
1351                        pos,
1352                        idx,
1353                        &model,
1354                        plane_n,
1355                        plane.distance,
1356                    ) {
1357                        let bc = item.material.base_color;
1358                        let color = plane.cap_color.unwrap_or([bc[0], bc[1], bc[2], 1.0]);
1359                        let buf = self.resources.upload_cap_geometry(device, &cap, color);
1360                        cap_buffers.push(buf);
1361                    }
1362                }
1363            }
1364        }
1365
1366        // Axes indicator geometry (built here, written to slot buffer below).
1367        let axes_verts = if frame.viewport.show_axes_indicator
1368            && frame.camera.viewport_size[0] > 0.0
1369            && frame.camera.viewport_size[1] > 0.0
1370        {
1371            let verts = crate::widgets::axes_indicator::build_axes_geometry(
1372                frame.camera.viewport_size[0],
1373                frame.camera.viewport_size[1],
1374                frame.camera.render_camera.orientation,
1375            );
1376            if verts.is_empty() { None } else { Some(verts) }
1377        } else {
1378            None
1379        };
1380
1381        // Gizmo mesh + uniform (built here, written to slot buffers below).
1382        let gizmo_update = frame.interaction.gizmo_model.map(|model| {
1383            let (verts, indices) = crate::interaction::gizmo::build_gizmo_mesh(
1384                frame.interaction.gizmo_mode,
1385                frame.interaction.gizmo_hovered,
1386                frame.interaction.gizmo_space_orientation,
1387            );
1388            (verts, indices, model)
1389        });
1390
1391        // ------------------------------------------------------------------
1392        // Assign all interaction state to the per-viewport slot.
1393        // ------------------------------------------------------------------
1394        {
1395            let slot = &mut self.viewport_slots[vp_idx];
1396            slot.outline_object_buffers = outline_object_buffers;
1397            slot.xray_object_buffers = xray_object_buffers;
1398            slot.constraint_line_buffers = constraint_line_buffers;
1399            slot.clip_plane_fill_buffers = clip_plane_fill_buffers;
1400            slot.clip_plane_line_buffers = clip_plane_line_buffers;
1401            slot.cap_buffers = cap_buffers;
1402
1403            // Axes: resize buffer if needed, then upload.
1404            if let Some(verts) = axes_verts {
1405                let byte_size = std::mem::size_of_val(verts.as_slice()) as u64;
1406                if byte_size > slot.axes_vertex_buffer.size() {
1407                    slot.axes_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1408                        label: Some("vp_axes_vertex_buf"),
1409                        size: byte_size,
1410                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1411                        mapped_at_creation: false,
1412                    });
1413                }
1414                queue.write_buffer(&slot.axes_vertex_buffer, 0, bytemuck::cast_slice(&verts));
1415                slot.axes_vertex_count = verts.len() as u32;
1416            } else {
1417                slot.axes_vertex_count = 0;
1418            }
1419
1420            // Gizmo: resize buffers if needed, then upload mesh + uniform.
1421            if let Some((verts, indices, model)) = gizmo_update {
1422                let vert_bytes: &[u8] = bytemuck::cast_slice(&verts);
1423                let idx_bytes: &[u8] = bytemuck::cast_slice(&indices);
1424                if vert_bytes.len() as u64 > slot.gizmo_vertex_buffer.size() {
1425                    slot.gizmo_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1426                        label: Some("vp_gizmo_vertex_buf"),
1427                        size: vert_bytes.len() as u64,
1428                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1429                        mapped_at_creation: false,
1430                    });
1431                }
1432                if idx_bytes.len() as u64 > slot.gizmo_index_buffer.size() {
1433                    slot.gizmo_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1434                        label: Some("vp_gizmo_index_buf"),
1435                        size: idx_bytes.len() as u64,
1436                        usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
1437                        mapped_at_creation: false,
1438                    });
1439                }
1440                queue.write_buffer(&slot.gizmo_vertex_buffer, 0, vert_bytes);
1441                queue.write_buffer(&slot.gizmo_index_buffer, 0, idx_bytes);
1442                slot.gizmo_index_count = indices.len() as u32;
1443                let uniform = crate::interaction::gizmo::GizmoUniform {
1444                    model: model.to_cols_array_2d(),
1445                };
1446                queue.write_buffer(&slot.gizmo_uniform_buf, 0, bytemuck::cast_slice(&[uniform]));
1447            }
1448        }
1449
1450        // ------------------------------------------------------------------
1451        // Outline offscreen pass — render stencil-based outline ring into a
1452        // dedicated RGBA texture so the paint() path can composite it later.
1453        //
1454        // Uses the per-viewport camera bind group and per-viewport HDR views.
1455        // ------------------------------------------------------------------
1456        if frame.interaction.outline_selected
1457            && !self.viewport_slots[vp_idx]
1458                .outline_object_buffers
1459                .is_empty()
1460        {
1461            let w = frame.camera.viewport_size[0] as u32;
1462            let h = frame.camera.viewport_size[1] as u32;
1463
1464            // Ensure per-viewport HDR state exists (provides outline color + depth views).
1465            self.ensure_viewport_hdr(device, queue, vp_idx, w.max(1), h.max(1));
1466
1467            // Extract raw pointers for slot fields needed inside the render pass
1468            // alongside &self.resources borrows (borrow-checker trick: slot and resources
1469            // are independent fields of self).
1470            let slot_ref = &self.viewport_slots[vp_idx];
1471            let outlines_ptr = &slot_ref.outline_object_buffers as *const Vec<OutlineObjectBuffers>;
1472            let camera_bg_ptr = &slot_ref.camera_bind_group as *const wgpu::BindGroup;
1473            let slot_hdr = slot_ref.hdr.as_ref().unwrap();
1474            let color_view_ptr = &slot_hdr.outline_color_view as *const wgpu::TextureView;
1475            let depth_view_ptr = &slot_hdr.outline_depth_view as *const wgpu::TextureView;
1476            // SAFETY: slot fields remain valid for the duration of this function;
1477            // no other code modifies these fields here.
1478            let (outlines, camera_bg, color_view, depth_view) = unsafe {
1479                (
1480                    &*outlines_ptr,
1481                    &*camera_bg_ptr,
1482                    &*color_view_ptr,
1483                    &*depth_view_ptr,
1484                )
1485            };
1486
1487            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1488                label: Some("outline_offscreen_encoder"),
1489            });
1490            {
1491                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1492                    label: Some("outline_offscreen_pass"),
1493                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1494                        view: color_view,
1495                        resolve_target: None,
1496                        ops: wgpu::Operations {
1497                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
1498                            store: wgpu::StoreOp::Store,
1499                        },
1500                        depth_slice: None,
1501                    })],
1502                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1503                        view: depth_view,
1504                        depth_ops: Some(wgpu::Operations {
1505                            load: wgpu::LoadOp::Clear(1.0),
1506                            store: wgpu::StoreOp::Discard,
1507                        }),
1508                        stencil_ops: Some(wgpu::Operations {
1509                            load: wgpu::LoadOp::Clear(0),
1510                            store: wgpu::StoreOp::Discard,
1511                        }),
1512                    }),
1513                    timestamp_writes: None,
1514                    occlusion_query_set: None,
1515                });
1516
1517                // Pass 1: write stencil=1 for selected objects.
1518                pass.set_stencil_reference(1);
1519                pass.set_bind_group(0, camera_bg, &[]);
1520                for outlined in outlines {
1521                    let Some(mesh) = self
1522                        .resources
1523                        .mesh_store
1524                        .get(crate::resources::mesh_store::MeshId(outlined.mesh_index))
1525                    else {
1526                        continue;
1527                    };
1528                    let pipeline = if outlined.two_sided {
1529                        &self.resources.stencil_write_two_sided_pipeline
1530                    } else {
1531                        &self.resources.stencil_write_pipeline
1532                    };
1533                    pass.set_pipeline(pipeline);
1534                    pass.set_bind_group(1, &outlined.stencil_bind_group, &[]);
1535                    pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1536                    pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1537                    pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1538                }
1539
1540                // Pass 2: draw expanded outline ring where stencil != 1.
1541                pass.set_pipeline(&self.resources.outline_pipeline);
1542                pass.set_stencil_reference(1);
1543                for outlined in outlines {
1544                    let Some(mesh) = self
1545                        .resources
1546                        .mesh_store
1547                        .get(crate::resources::mesh_store::MeshId(outlined.mesh_index))
1548                    else {
1549                        continue;
1550                    };
1551                    pass.set_bind_group(1, &outlined.outline_bind_group, &[]);
1552                    pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1553                    pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1554                    pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1555                }
1556            }
1557            queue.submit(std::iter::once(encoder.finish()));
1558        }
1559    }
1560
1561    /// Upload per-frame data to GPU buffers and render the shadow pass.
1562    /// Call before `paint()`.
1563    pub fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, frame: &FrameData) {
1564        let (scene_fx, viewport_fx) = frame.effects.split();
1565        self.prepare_scene_internal(device, queue, frame, &scene_fx);
1566        self.prepare_viewport_internal(device, queue, frame, &viewport_fx);
1567    }
1568}