Skip to main content

viewport_lib/renderer/
prepare.rs

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