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        let vp_size = frame.camera.viewport_size;
660        if !frame.scene.polylines.is_empty() {
661            resources.ensure_polyline_pipeline(device);
662            for item in &frame.scene.polylines {
663                if item.positions.is_empty() {
664                    continue;
665                }
666                let gpu_data = resources.upload_polyline(device, queue, item, vp_size);
667                self.polyline_gpu_data.push(gpu_data);
668            }
669        }
670
671        // ------------------------------------------------------------------
672        // SciVis Phase L — isoline extraction and upload via polyline pipeline.
673        // ------------------------------------------------------------------
674        if !frame.scene.isolines.is_empty() {
675            resources.ensure_polyline_pipeline(device);
676            for item in &frame.scene.isolines {
677                if item.positions.is_empty() || item.indices.is_empty() || item.scalars.is_empty() {
678                    continue;
679                }
680                let (positions, strip_lengths) = crate::geometry::isoline::extract_isolines(item);
681                if positions.is_empty() {
682                    continue;
683                }
684                let polyline = PolylineItem {
685                    positions,
686                    scalars: Vec::new(),
687                    strip_lengths,
688                    scalar_range: None,
689                    colormap_id: None,
690                    default_color: item.color,
691                    line_width: item.line_width,
692                    id: 0,
693                };
694                let gpu_data = resources.upload_polyline(device, queue, &polyline, vp_size);
695                self.polyline_gpu_data.push(gpu_data);
696            }
697        }
698
699        // ------------------------------------------------------------------
700        // SciVis Phase M — streamtube GPU data upload.
701        // ------------------------------------------------------------------
702        self.streamtube_gpu_data.clear();
703        if !frame.scene.streamtube_items.is_empty() {
704            resources.ensure_streamtube_pipeline(device);
705            for item in &frame.scene.streamtube_items {
706                if item.positions.is_empty() || item.strip_lengths.is_empty() {
707                    continue;
708                }
709                let gpu_data = resources.upload_streamtube(device, queue, item);
710                if gpu_data.index_count > 0 {
711                    self.streamtube_gpu_data.push(gpu_data);
712                }
713            }
714        }
715
716        // ------------------------------------------------------------------
717        // SciVis Phase D — volume GPU data upload.
718        // Phase 1 note: clip_planes are per-viewport but passed here for culling.
719        // Fix in Phase 2/3: upload clip-plane-agnostic data; apply planes in shader.
720        // ------------------------------------------------------------------
721        self.volume_gpu_data.clear();
722        if !frame.scene.volumes.is_empty() {
723            resources.ensure_volume_pipeline(device);
724            for item in &frame.scene.volumes {
725                let gpu =
726                    resources.upload_volume_frame(device, queue, item, &frame.effects.clip_planes);
727                self.volume_gpu_data.push(gpu);
728            }
729        }
730
731        // -- Frame stats --
732        {
733            let total = scene_items.len() as u32;
734            let visible = scene_items.iter().filter(|i| i.visible).count() as u32;
735            let mut draw_calls = 0u32;
736            let mut triangles = 0u64;
737            let instanced_batch_count = if self.use_instancing {
738                self.instanced_batches.len() as u32
739            } else {
740                0
741            };
742
743            if self.use_instancing {
744                for batch in &self.instanced_batches {
745                    if let Some(mesh) = resources
746                        .mesh_store
747                        .get(crate::resources::mesh_store::MeshId(batch.mesh_index))
748                    {
749                        draw_calls += 1;
750                        triangles += (mesh.index_count / 3) as u64 * batch.instance_count as u64;
751                    }
752                }
753            } else {
754                for item in scene_items {
755                    if !item.visible {
756                        continue;
757                    }
758                    if let Some(mesh) = resources
759                        .mesh_store
760                        .get(crate::resources::mesh_store::MeshId(item.mesh_index))
761                    {
762                        draw_calls += 1;
763                        triangles += (mesh.index_count / 3) as u64;
764                    }
765                }
766            }
767
768            self.last_stats = crate::renderer::stats::FrameStats {
769                total_objects: total,
770                visible_objects: visible,
771                culled_objects: total.saturating_sub(visible),
772                draw_calls,
773                instanced_batches: instanced_batch_count,
774                triangles_submitted: triangles,
775                shadow_draw_calls: 0, // Updated below in shadow pass.
776            };
777        }
778
779        // ------------------------------------------------------------------
780        // Shadow depth pass — CSM: render each cascade into its atlas tile.
781        // ------------------------------------------------------------------
782        if lighting.shadows_enabled && !scene_items.is_empty() {
783            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
784                label: Some("shadow_pass_encoder"),
785            });
786            {
787                let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
788                    label: Some("shadow_pass"),
789                    color_attachments: &[],
790                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
791                        view: &resources.shadow_map_view,
792                        depth_ops: Some(wgpu::Operations {
793                            load: wgpu::LoadOp::Clear(1.0),
794                            store: wgpu::StoreOp::Store,
795                        }),
796                        stencil_ops: None,
797                    }),
798                    timestamp_writes: None,
799                    occlusion_query_set: None,
800                });
801
802                let mut shadow_draws = 0u32;
803                let tile_px = tile_size as f32;
804
805                if self.use_instancing {
806                    if let (Some(pipeline), Some(instance_bg)) = (
807                        &resources.shadow_instanced_pipeline,
808                        self.instanced_batches.first().and_then(|b| {
809                            resources.instance_bind_groups.get(&(
810                                b.texture_id.unwrap_or(u64::MAX),
811                                b.normal_map_id.unwrap_or(u64::MAX),
812                                b.ao_map_id.unwrap_or(u64::MAX),
813                            ))
814                        }),
815                    ) {
816                        for cascade in 0..effective_cascade_count {
817                            let tile_col = (cascade % 2) as f32;
818                            let tile_row = (cascade / 2) as f32;
819                            shadow_pass.set_viewport(
820                                tile_col * tile_px,
821                                tile_row * tile_px,
822                                tile_px,
823                                tile_px,
824                                0.0,
825                                1.0,
826                            );
827                            shadow_pass.set_scissor_rect(
828                                (tile_col * tile_px) as u32,
829                                (tile_row * tile_px) as u32,
830                                tile_size,
831                                tile_size,
832                            );
833
834                            shadow_pass.set_pipeline(pipeline);
835
836                            queue.write_buffer(
837                                resources.shadow_instanced_cascade_bufs[cascade]
838                                    .as_ref()
839                                    .expect("shadow_instanced_cascade_bufs not allocated"),
840                                0,
841                                bytemuck::cast_slice(
842                                    &cascade_view_projs[cascade].to_cols_array_2d(),
843                                ),
844                            );
845
846                            let cascade_bg = resources.shadow_instanced_cascade_bgs[cascade]
847                                .as_ref()
848                                .expect("shadow_instanced_cascade_bgs not allocated");
849                            shadow_pass.set_bind_group(0, cascade_bg, &[]);
850                            shadow_pass.set_bind_group(1, instance_bg, &[]);
851
852                            for batch in &self.instanced_batches {
853                                if batch.is_transparent {
854                                    continue;
855                                }
856                                let Some(mesh) = resources
857                                    .mesh_store
858                                    .get(crate::resources::mesh_store::MeshId(batch.mesh_index))
859                                else {
860                                    continue;
861                                };
862                                shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
863                                shadow_pass.set_index_buffer(
864                                    mesh.index_buffer.slice(..),
865                                    wgpu::IndexFormat::Uint32,
866                                );
867                                shadow_pass.draw_indexed(
868                                    0..mesh.index_count,
869                                    0,
870                                    batch.instance_offset
871                                        ..batch.instance_offset + batch.instance_count,
872                                );
873                                shadow_draws += 1;
874                            }
875                        }
876                    }
877                } else {
878                    for cascade in 0..effective_cascade_count {
879                        let tile_col = (cascade % 2) as f32;
880                        let tile_row = (cascade / 2) as f32;
881                        shadow_pass.set_viewport(
882                            tile_col * tile_px,
883                            tile_row * tile_px,
884                            tile_px,
885                            tile_px,
886                            0.0,
887                            1.0,
888                        );
889                        shadow_pass.set_scissor_rect(
890                            (tile_col * tile_px) as u32,
891                            (tile_row * tile_px) as u32,
892                            tile_size,
893                            tile_size,
894                        );
895
896                        shadow_pass.set_pipeline(&resources.shadow_pipeline);
897                        shadow_pass.set_bind_group(
898                            0,
899                            &resources.shadow_bind_group,
900                            &[cascade as u32 * 256],
901                        );
902
903                        let cascade_frustum = crate::camera::frustum::Frustum::from_view_proj(
904                            &cascade_view_projs[cascade],
905                        );
906
907                        for item in scene_items.iter() {
908                            if !item.visible {
909                                continue;
910                            }
911                            if item.material.opacity < 1.0 {
912                                continue;
913                            }
914                            let Some(mesh) = resources
915                                .mesh_store
916                                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
917                            else {
918                                continue;
919                            };
920
921                            let world_aabb = mesh
922                                .aabb
923                                .transformed(&glam::Mat4::from_cols_array_2d(&item.model));
924                            if cascade_frustum.cull_aabb(&world_aabb) {
925                                continue;
926                            }
927
928                            shadow_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
929                            shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
930                            shadow_pass.set_index_buffer(
931                                mesh.index_buffer.slice(..),
932                                wgpu::IndexFormat::Uint32,
933                            );
934                            shadow_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
935                            shadow_draws += 1;
936                        }
937                    }
938                }
939                drop(shadow_pass);
940                self.last_stats.shadow_draw_calls = shadow_draws;
941            }
942            queue.submit(std::iter::once(encoder.finish()));
943        }
944    }
945
946    /// Per-viewport prepare stage: camera, clip planes, clip volume, grid, overlays, cap geometry, axes.
947    ///
948    /// Call once per viewport per frame, after `prepare_scene_internal`.
949    /// Reads `viewport_fx` for clip planes, clip volume, cap fill, and post-process settings.
950    pub(super) fn prepare_viewport_internal(
951        &mut self,
952        device: &wgpu::Device,
953        queue: &wgpu::Queue,
954        frame: &FrameData,
955        viewport_fx: &ViewportEffects<'_>,
956    ) {
957        // Ensure a per-viewport camera slot exists for this viewport index.
958        // Must happen before the `resources` borrow below.
959        self.ensure_viewport_slot(device, frame.camera.viewport_index);
960
961        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
962            SurfaceSubmission::Flat(items) => items,
963        };
964
965        {
966            let resources = &mut self.resources;
967
968            // Upload clip planes uniform to per-viewport slot buffer (binding 4).
969            {
970                let mut planes = [[0.0f32; 4]; 6];
971                let mut count = 0u32;
972                for plane in viewport_fx.clip_planes.iter().filter(|p| p.enabled).take(6) {
973                    planes[count as usize] = [
974                        plane.normal[0],
975                        plane.normal[1],
976                        plane.normal[2],
977                        plane.distance,
978                    ];
979                    count += 1;
980                }
981                let clip_uniform = ClipPlanesUniform {
982                    planes,
983                    count,
984                    _pad0: 0,
985                    viewport_width: frame.camera.viewport_size[0].max(1.0),
986                    viewport_height: frame.camera.viewport_size[1].max(1.0),
987                };
988                // Write to per-viewport slot buffer.
989                if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
990                    queue.write_buffer(
991                        &slot.clip_planes_buf,
992                        0,
993                        bytemuck::cast_slice(&[clip_uniform]),
994                    );
995                }
996                // Also write to shared buffer for legacy single-viewport callers.
997                queue.write_buffer(
998                    &resources.clip_planes_uniform_buf,
999                    0,
1000                    bytemuck::cast_slice(&[clip_uniform]),
1001                );
1002            }
1003
1004            // Upload clip volume uniform to per-viewport slot buffer (binding 6).
1005            {
1006                let clip_vol_uniform = ClipVolumeUniform::from_clip_volume(viewport_fx.clip_volume);
1007                if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1008                    queue.write_buffer(
1009                        &slot.clip_volume_buf,
1010                        0,
1011                        bytemuck::cast_slice(&[clip_vol_uniform]),
1012                    );
1013                }
1014                queue.write_buffer(
1015                    &resources.clip_volume_uniform_buf,
1016                    0,
1017                    bytemuck::cast_slice(&[clip_vol_uniform]),
1018                );
1019            }
1020
1021            // Upload camera uniform to per-viewport slot buffer.
1022            let camera_uniform = frame.camera.render_camera.camera_uniform();
1023            // Write to shared buffer for legacy single-viewport callers.
1024            queue.write_buffer(
1025                &resources.camera_uniform_buf,
1026                0,
1027                bytemuck::cast_slice(&[camera_uniform]),
1028            );
1029            // Write to the per-viewport slot buffer.
1030            if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1031                queue.write_buffer(&slot.camera_buf, 0, bytemuck::cast_slice(&[camera_uniform]));
1032            }
1033
1034            // Upload grid uniform (full-screen analytical shader — no vertex buffers needed).
1035            if frame.viewport.show_grid {
1036                let eye = glam::Vec3::from(frame.camera.render_camera.eye_position);
1037                if !eye.is_finite() {
1038                    tracing::warn!(
1039                        eye_x = eye.x,
1040                        eye_y = eye.y,
1041                        eye_z = eye.z,
1042                        "grid skipped: eye_position is non-finite (camera distance overflow?)"
1043                    );
1044                } else {
1045                    let view_proj_mat = frame.camera.render_camera.view_proj().to_cols_array_2d();
1046
1047                    let (spacing, minor_fade) = if frame.viewport.grid_cell_size > 0.0 {
1048                        (frame.viewport.grid_cell_size, 1.0_f32)
1049                    } else {
1050                        let vertical_depth = (eye.z - frame.viewport.grid_z).abs().max(1.0);
1051                        let world_per_pixel =
1052                            2.0 * (frame.camera.render_camera.fov / 2.0).tan() * vertical_depth
1053                                / frame.camera.viewport_size[1].max(1.0);
1054                        let target = (world_per_pixel * 60.0).max(1e-9_f32);
1055                        let mut s = 1.0_f32;
1056                        let mut iters = 0u32;
1057                        while s < target {
1058                            s *= 10.0;
1059                            iters += 1;
1060                        }
1061                        let ratio = (target / s).clamp(0.0, 1.0);
1062                        let fade = if ratio < 0.5 {
1063                            1.0_f32
1064                        } else {
1065                            let t = (ratio - 0.5) * 2.0;
1066                            1.0 - t * t * (3.0 - 2.0 * t)
1067                        };
1068                        tracing::debug!(
1069                            eye_z = eye.z,
1070                            vertical_depth,
1071                            world_per_pixel,
1072                            target,
1073                            spacing = s,
1074                            lod_iters = iters,
1075                            ratio,
1076                            minor_fade = fade,
1077                            "grid LOD"
1078                        );
1079                        (s, fade)
1080                    };
1081
1082                    let spacing_major = spacing * 10.0;
1083                    let snap_x = (eye.x / spacing_major).floor() * spacing_major;
1084                    let snap_y = (eye.y / spacing_major).floor() * spacing_major;
1085                    tracing::debug!(
1086                        spacing_minor = spacing,
1087                        spacing_major,
1088                        snap_x,
1089                        snap_y,
1090                        eye_x = eye.x,
1091                        eye_y = eye.y,
1092                        eye_z = eye.z,
1093                        "grid snap"
1094                    );
1095
1096                    let orient = frame.camera.render_camera.orientation;
1097                    let right = orient * glam::Vec3::X;
1098                    let up = orient * glam::Vec3::Y;
1099                    let back = orient * glam::Vec3::Z;
1100                    let cam_to_world = [
1101                        [right.x, right.y, right.z, 0.0_f32],
1102                        [up.x, up.y, up.z, 0.0_f32],
1103                        [back.x, back.y, back.z, 0.0_f32],
1104                    ];
1105                    let aspect =
1106                        frame.camera.viewport_size[0] / frame.camera.viewport_size[1].max(1.0);
1107                    let tan_half_fov = (frame.camera.render_camera.fov / 2.0).tan();
1108
1109                    let uniform = GridUniform {
1110                        view_proj: view_proj_mat,
1111                        cam_to_world,
1112                        tan_half_fov,
1113                        aspect,
1114                        _pad_ivp: [0.0; 2],
1115                        eye_pos: frame.camera.render_camera.eye_position,
1116                        grid_z: frame.viewport.grid_z,
1117                        spacing_minor: spacing,
1118                        spacing_major,
1119                        snap_origin: [snap_x, snap_y],
1120                        color_minor: [0.35, 0.35, 0.35, 0.4 * minor_fade],
1121                        color_major: [0.40, 0.40, 0.40, 0.4 + 0.2 * minor_fade],
1122                    };
1123                    // Write to per-viewport slot buffer.
1124                    if let Some(slot) = self.viewport_slots.get(frame.camera.viewport_index) {
1125                        queue.write_buffer(&slot.grid_buf, 0, bytemuck::cast_slice(&[uniform]));
1126                    }
1127                    // Also write to shared buffer for legacy callers.
1128                    queue.write_buffer(
1129                        &resources.grid_uniform_buf,
1130                        0,
1131                        bytemuck::cast_slice(&[uniform]),
1132                    );
1133                }
1134            }
1135        } // `resources` mutable borrow dropped here.
1136
1137        // ------------------------------------------------------------------
1138        // Build per-viewport interaction state into local variables.
1139        // Uses &self.resources (immutable) for BGL lookups; no conflict with
1140        // the slot borrow that follows.
1141        // ------------------------------------------------------------------
1142
1143        let vp_idx = frame.camera.viewport_index;
1144
1145        // Outline buffers for selected objects.
1146        let mut outline_object_buffers: Vec<OutlineObjectBuffers> = Vec::new();
1147        if frame.interaction.outline_selected {
1148            let resources = &self.resources;
1149            for item in scene_items {
1150                if !item.visible || !item.selected {
1151                    continue;
1152                }
1153                let m = &item.material;
1154                let stencil_uniform = ObjectUniform {
1155                    model: item.model,
1156                    color: [m.base_color[0], m.base_color[1], m.base_color[2], m.opacity],
1157                    selected: 1,
1158                    wireframe: 0,
1159                    ambient: m.ambient,
1160                    diffuse: m.diffuse,
1161                    specular: m.specular,
1162                    shininess: m.shininess,
1163                    has_texture: if m.texture_id.is_some() { 1 } else { 0 },
1164                    use_pbr: if m.use_pbr { 1 } else { 0 },
1165                    metallic: m.metallic,
1166                    roughness: m.roughness,
1167                    has_normal_map: if m.normal_map_id.is_some() { 1 } else { 0 },
1168                    has_ao_map: if m.ao_map_id.is_some() { 1 } else { 0 },
1169                    has_attribute: 0,
1170                    scalar_min: 0.0,
1171                    scalar_max: 1.0,
1172                    _pad_scalar: 0,
1173                    nan_color: [0.0; 4],
1174                    use_nan_color: 0,
1175                    _pad_nan: [0; 3],
1176                };
1177                let stencil_buf = device.create_buffer(&wgpu::BufferDescriptor {
1178                    label: Some("outline_stencil_object_uniform_buf"),
1179                    size: std::mem::size_of::<ObjectUniform>() as u64,
1180                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1181                    mapped_at_creation: false,
1182                });
1183                queue.write_buffer(&stencil_buf, 0, bytemuck::cast_slice(&[stencil_uniform]));
1184
1185                let albedo_view = match m.texture_id {
1186                    Some(id) if (id as usize) < resources.textures.len() => {
1187                        &resources.textures[id as usize].view
1188                    }
1189                    _ => &resources.fallback_texture.view,
1190                };
1191                let normal_view = match m.normal_map_id {
1192                    Some(id) if (id as usize) < resources.textures.len() => {
1193                        &resources.textures[id as usize].view
1194                    }
1195                    _ => &resources.fallback_normal_map_view,
1196                };
1197                let ao_view = match m.ao_map_id {
1198                    Some(id) if (id as usize) < resources.textures.len() => {
1199                        &resources.textures[id as usize].view
1200                    }
1201                    _ => &resources.fallback_ao_map_view,
1202                };
1203                let stencil_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1204                    label: Some("outline_stencil_object_bg"),
1205                    layout: &resources.object_bind_group_layout,
1206                    entries: &[
1207                        wgpu::BindGroupEntry {
1208                            binding: 0,
1209                            resource: stencil_buf.as_entire_binding(),
1210                        },
1211                        wgpu::BindGroupEntry {
1212                            binding: 1,
1213                            resource: wgpu::BindingResource::TextureView(albedo_view),
1214                        },
1215                        wgpu::BindGroupEntry {
1216                            binding: 2,
1217                            resource: wgpu::BindingResource::Sampler(&resources.material_sampler),
1218                        },
1219                        wgpu::BindGroupEntry {
1220                            binding: 3,
1221                            resource: wgpu::BindingResource::TextureView(normal_view),
1222                        },
1223                        wgpu::BindGroupEntry {
1224                            binding: 4,
1225                            resource: wgpu::BindingResource::TextureView(ao_view),
1226                        },
1227                        wgpu::BindGroupEntry {
1228                            binding: 5,
1229                            resource: wgpu::BindingResource::TextureView(
1230                                &resources.fallback_lut_view,
1231                            ),
1232                        },
1233                        wgpu::BindGroupEntry {
1234                            binding: 6,
1235                            resource: resources.fallback_scalar_buf.as_entire_binding(),
1236                        },
1237                    ],
1238                });
1239
1240                let uniform = OutlineUniform {
1241                    model: item.model,
1242                    color: frame.interaction.outline_color,
1243                    pixel_offset: frame.interaction.outline_width_px,
1244                    _pad: [0.0; 3],
1245                };
1246                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1247                    label: Some("outline_uniform_buf"),
1248                    size: std::mem::size_of::<OutlineUniform>() as u64,
1249                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1250                    mapped_at_creation: false,
1251                });
1252                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
1253                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1254                    label: Some("outline_object_bg"),
1255                    layout: &resources.outline_bind_group_layout,
1256                    entries: &[wgpu::BindGroupEntry {
1257                        binding: 0,
1258                        resource: buf.as_entire_binding(),
1259                    }],
1260                });
1261                outline_object_buffers.push(OutlineObjectBuffers {
1262                    mesh_index: item.mesh_index,
1263                    two_sided: item.two_sided,
1264                    _stencil_uniform_buf: stencil_buf,
1265                    stencil_bind_group: stencil_bg,
1266                    _outline_uniform_buf: buf,
1267                    outline_bind_group: bg,
1268                });
1269            }
1270        }
1271
1272        // X-ray buffers for selected objects.
1273        let mut xray_object_buffers: Vec<(usize, wgpu::Buffer, wgpu::BindGroup)> = Vec::new();
1274        if frame.interaction.xray_selected {
1275            let resources = &self.resources;
1276            for item in scene_items {
1277                if !item.visible || !item.selected {
1278                    continue;
1279                }
1280                let uniform = OutlineUniform {
1281                    model: item.model,
1282                    color: frame.interaction.xray_color,
1283                    pixel_offset: 0.0,
1284                    _pad: [0.0; 3],
1285                };
1286                let buf = device.create_buffer(&wgpu::BufferDescriptor {
1287                    label: Some("xray_uniform_buf"),
1288                    size: std::mem::size_of::<OutlineUniform>() as u64,
1289                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1290                    mapped_at_creation: false,
1291                });
1292                queue.write_buffer(&buf, 0, bytemuck::cast_slice(&[uniform]));
1293                let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1294                    label: Some("xray_object_bg"),
1295                    layout: &resources.outline_bind_group_layout,
1296                    entries: &[wgpu::BindGroupEntry {
1297                        binding: 0,
1298                        resource: buf.as_entire_binding(),
1299                    }],
1300                });
1301                xray_object_buffers.push((item.mesh_index, buf, bg));
1302            }
1303        }
1304
1305        // Constraint guide lines.
1306        let mut constraint_line_buffers = Vec::new();
1307        for overlay in &frame.interaction.constraint_overlays {
1308            constraint_line_buffers.push(self.resources.create_constraint_overlay(device, overlay));
1309        }
1310
1311        // Clip plane overlays.
1312        let mut clip_plane_fill_buffers = Vec::new();
1313        let mut clip_plane_line_buffers = Vec::new();
1314        for overlay in &frame.interaction.clip_plane_overlays {
1315            clip_plane_fill_buffers.push(
1316                self.resources
1317                    .create_clip_plane_fill_overlay(device, overlay),
1318            );
1319            clip_plane_line_buffers.push(
1320                self.resources
1321                    .create_clip_plane_line_overlay(device, overlay),
1322            );
1323        }
1324
1325        // Cap geometry for section-view cross-section fill.
1326        let mut cap_buffers = Vec::new();
1327        if viewport_fx.cap_fill_enabled {
1328            let active_planes: Vec<_> = viewport_fx
1329                .clip_planes
1330                .iter()
1331                .filter(|p| p.enabled)
1332                .collect();
1333            for plane in &active_planes {
1334                let plane_n = glam::Vec3::from(plane.normal);
1335                for item in scene_items.iter().filter(|i| i.visible) {
1336                    let Some(mesh) = self
1337                        .resources
1338                        .mesh_store
1339                        .get(crate::resources::mesh_store::MeshId(item.mesh_index))
1340                    else {
1341                        continue;
1342                    };
1343                    let model = glam::Mat4::from_cols_array_2d(&item.model);
1344                    let world_aabb = mesh.aabb.transformed(&model);
1345                    if !world_aabb.intersects_plane(plane_n, plane.distance) {
1346                        continue;
1347                    }
1348                    let (Some(pos), Some(idx)) = (&mesh.cpu_positions, &mesh.cpu_indices) else {
1349                        continue;
1350                    };
1351                    if let Some(cap) = crate::geometry::cap_geometry::generate_cap_mesh(
1352                        pos,
1353                        idx,
1354                        &model,
1355                        plane_n,
1356                        plane.distance,
1357                    ) {
1358                        let bc = item.material.base_color;
1359                        let color = plane.cap_color.unwrap_or([bc[0], bc[1], bc[2], 1.0]);
1360                        let buf = self.resources.upload_cap_geometry(device, &cap, color);
1361                        cap_buffers.push(buf);
1362                    }
1363                }
1364            }
1365        }
1366
1367        // Axes indicator geometry (built here, written to slot buffer below).
1368        let axes_verts = if frame.viewport.show_axes_indicator
1369            && frame.camera.viewport_size[0] > 0.0
1370            && frame.camera.viewport_size[1] > 0.0
1371        {
1372            let verts = crate::widgets::axes_indicator::build_axes_geometry(
1373                frame.camera.viewport_size[0],
1374                frame.camera.viewport_size[1],
1375                frame.camera.render_camera.orientation,
1376            );
1377            if verts.is_empty() { None } else { Some(verts) }
1378        } else {
1379            None
1380        };
1381
1382        // Gizmo mesh + uniform (built here, written to slot buffers below).
1383        let gizmo_update = frame.interaction.gizmo_model.map(|model| {
1384            let (verts, indices) = crate::interaction::gizmo::build_gizmo_mesh(
1385                frame.interaction.gizmo_mode,
1386                frame.interaction.gizmo_hovered,
1387                frame.interaction.gizmo_space_orientation,
1388            );
1389            (verts, indices, model)
1390        });
1391
1392        // ------------------------------------------------------------------
1393        // Assign all interaction state to the per-viewport slot.
1394        // ------------------------------------------------------------------
1395        {
1396            let slot = &mut self.viewport_slots[vp_idx];
1397            slot.outline_object_buffers = outline_object_buffers;
1398            slot.xray_object_buffers = xray_object_buffers;
1399            slot.constraint_line_buffers = constraint_line_buffers;
1400            slot.clip_plane_fill_buffers = clip_plane_fill_buffers;
1401            slot.clip_plane_line_buffers = clip_plane_line_buffers;
1402            slot.cap_buffers = cap_buffers;
1403
1404            // Axes: resize buffer if needed, then upload.
1405            if let Some(verts) = axes_verts {
1406                let byte_size = std::mem::size_of_val(verts.as_slice()) as u64;
1407                if byte_size > slot.axes_vertex_buffer.size() {
1408                    slot.axes_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1409                        label: Some("vp_axes_vertex_buf"),
1410                        size: byte_size,
1411                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1412                        mapped_at_creation: false,
1413                    });
1414                }
1415                queue.write_buffer(&slot.axes_vertex_buffer, 0, bytemuck::cast_slice(&verts));
1416                slot.axes_vertex_count = verts.len() as u32;
1417            } else {
1418                slot.axes_vertex_count = 0;
1419            }
1420
1421            // Gizmo: resize buffers if needed, then upload mesh + uniform.
1422            if let Some((verts, indices, model)) = gizmo_update {
1423                let vert_bytes: &[u8] = bytemuck::cast_slice(&verts);
1424                let idx_bytes: &[u8] = bytemuck::cast_slice(&indices);
1425                if vert_bytes.len() as u64 > slot.gizmo_vertex_buffer.size() {
1426                    slot.gizmo_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1427                        label: Some("vp_gizmo_vertex_buf"),
1428                        size: vert_bytes.len() as u64,
1429                        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1430                        mapped_at_creation: false,
1431                    });
1432                }
1433                if idx_bytes.len() as u64 > slot.gizmo_index_buffer.size() {
1434                    slot.gizmo_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1435                        label: Some("vp_gizmo_index_buf"),
1436                        size: idx_bytes.len() as u64,
1437                        usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
1438                        mapped_at_creation: false,
1439                    });
1440                }
1441                queue.write_buffer(&slot.gizmo_vertex_buffer, 0, vert_bytes);
1442                queue.write_buffer(&slot.gizmo_index_buffer, 0, idx_bytes);
1443                slot.gizmo_index_count = indices.len() as u32;
1444                let uniform = crate::interaction::gizmo::GizmoUniform {
1445                    model: model.to_cols_array_2d(),
1446                };
1447                queue.write_buffer(&slot.gizmo_uniform_buf, 0, bytemuck::cast_slice(&[uniform]));
1448            }
1449        }
1450
1451        // ------------------------------------------------------------------
1452        // Outline offscreen pass — render stencil-based outline ring into a
1453        // dedicated RGBA texture so the paint() path can composite it later.
1454        //
1455        // Uses the per-viewport camera bind group and per-viewport HDR views.
1456        // ------------------------------------------------------------------
1457        if frame.interaction.outline_selected
1458            && !self.viewport_slots[vp_idx]
1459                .outline_object_buffers
1460                .is_empty()
1461        {
1462            let w = frame.camera.viewport_size[0] as u32;
1463            let h = frame.camera.viewport_size[1] as u32;
1464
1465            // Ensure per-viewport HDR state exists (provides outline color + depth views).
1466            self.ensure_viewport_hdr(device, queue, vp_idx, w.max(1), h.max(1));
1467
1468            // Extract raw pointers for slot fields needed inside the render pass
1469            // alongside &self.resources borrows (borrow-checker trick: slot and resources
1470            // are independent fields of self).
1471            let slot_ref = &self.viewport_slots[vp_idx];
1472            let outlines_ptr = &slot_ref.outline_object_buffers as *const Vec<OutlineObjectBuffers>;
1473            let camera_bg_ptr = &slot_ref.camera_bind_group as *const wgpu::BindGroup;
1474            let slot_hdr = slot_ref.hdr.as_ref().unwrap();
1475            let color_view_ptr = &slot_hdr.outline_color_view as *const wgpu::TextureView;
1476            let depth_view_ptr = &slot_hdr.outline_depth_view as *const wgpu::TextureView;
1477            // SAFETY: slot fields remain valid for the duration of this function;
1478            // no other code modifies these fields here.
1479            let (outlines, camera_bg, color_view, depth_view) = unsafe {
1480                (
1481                    &*outlines_ptr,
1482                    &*camera_bg_ptr,
1483                    &*color_view_ptr,
1484                    &*depth_view_ptr,
1485                )
1486            };
1487
1488            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1489                label: Some("outline_offscreen_encoder"),
1490            });
1491            {
1492                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1493                    label: Some("outline_offscreen_pass"),
1494                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1495                        view: color_view,
1496                        resolve_target: None,
1497                        ops: wgpu::Operations {
1498                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
1499                            store: wgpu::StoreOp::Store,
1500                        },
1501                        depth_slice: None,
1502                    })],
1503                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1504                        view: depth_view,
1505                        depth_ops: Some(wgpu::Operations {
1506                            load: wgpu::LoadOp::Clear(1.0),
1507                            store: wgpu::StoreOp::Discard,
1508                        }),
1509                        stencil_ops: Some(wgpu::Operations {
1510                            load: wgpu::LoadOp::Clear(0),
1511                            store: wgpu::StoreOp::Discard,
1512                        }),
1513                    }),
1514                    timestamp_writes: None,
1515                    occlusion_query_set: None,
1516                });
1517
1518                // Pass 1: write stencil=1 for selected objects.
1519                pass.set_stencil_reference(1);
1520                pass.set_bind_group(0, camera_bg, &[]);
1521                for outlined in outlines {
1522                    let Some(mesh) = self
1523                        .resources
1524                        .mesh_store
1525                        .get(crate::resources::mesh_store::MeshId(outlined.mesh_index))
1526                    else {
1527                        continue;
1528                    };
1529                    let pipeline = if outlined.two_sided {
1530                        &self.resources.stencil_write_two_sided_pipeline
1531                    } else {
1532                        &self.resources.stencil_write_pipeline
1533                    };
1534                    pass.set_pipeline(pipeline);
1535                    pass.set_bind_group(1, &outlined.stencil_bind_group, &[]);
1536                    pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1537                    pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1538                    pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1539                }
1540
1541                // Pass 2: draw expanded outline ring where stencil != 1.
1542                pass.set_pipeline(&self.resources.outline_pipeline);
1543                pass.set_stencil_reference(1);
1544                for outlined in outlines {
1545                    let Some(mesh) = self
1546                        .resources
1547                        .mesh_store
1548                        .get(crate::resources::mesh_store::MeshId(outlined.mesh_index))
1549                    else {
1550                        continue;
1551                    };
1552                    pass.set_bind_group(1, &outlined.outline_bind_group, &[]);
1553                    pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1554                    pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1555                    pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1556                }
1557            }
1558            queue.submit(std::iter::once(encoder.finish()));
1559        }
1560    }
1561
1562    /// Upload per-frame data to GPU buffers and render the shadow pass.
1563    /// Call before `paint()`.
1564    pub fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, frame: &FrameData) {
1565        let (scene_fx, viewport_fx) = frame.effects.split();
1566        self.prepare_scene_internal(device, queue, frame, &scene_fx);
1567        self.prepare_viewport_internal(device, queue, frame, &viewport_fx);
1568    }
1569}