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