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.scene.generation == self.last_scene_generation
525                && frame.interaction.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.scene.generation;
635                self.last_selection_generation = frame.interaction.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 {
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        resources.constraint_line_buffers.clear();
947        for overlay in &frame.interaction.constraint_overlays {
948            let buf = resources.create_constraint_overlay(device, overlay);
949            resources.constraint_line_buffers.push(buf);
950        }
951
952        // Cap geometry for section-view cross-section fill.
953        resources.cap_buffers.clear();
954        if frame.effects.cap_fill_enabled {
955            let active_planes: Vec<_> = frame.effects.clip_planes.iter().filter(|p| p.enabled).collect();
956            for plane in &active_planes {
957                let plane_n = glam::Vec3::from(plane.normal);
958                for item in scene_items.iter().filter(|i| i.visible) {
959                    let Some(mesh) = resources
960                        .mesh_store
961                        .get(crate::resources::mesh_store::MeshId(item.mesh_index))
962                    else {
963                        continue;
964                    };
965                    let model = glam::Mat4::from_cols_array_2d(&item.model);
966                    let world_aabb = mesh.aabb.transformed(&model);
967                    if !world_aabb.intersects_plane(plane_n, plane.distance) {
968                        continue;
969                    }
970                    let (Some(pos), Some(idx)) = (&mesh.cpu_positions, &mesh.cpu_indices) else {
971                        continue;
972                    };
973                    if let Some(cap) = crate::geometry::cap_geometry::generate_cap_mesh(
974                        pos,
975                        idx,
976                        &model,
977                        plane_n,
978                        plane.distance,
979                    ) {
980                        let bc = item.material.base_color;
981                        let color = plane.cap_color.unwrap_or([bc[0], bc[1], bc[2], 1.0]);
982                        let buf = resources.upload_cap_geometry(device, &cap, color);
983                        resources.cap_buffers.push(buf);
984                    }
985                }
986            }
987        }
988
989        // Axes indicator.
990        if frame.viewport.show_axes_indicator && frame.camera.viewport_size[0] > 0.0 && frame.camera.viewport_size[1] > 0.0
991        {
992            let verts = crate::widgets::axes_indicator::build_axes_geometry(
993                frame.camera.viewport_size[0],
994                frame.camera.viewport_size[1],
995                frame.camera.render_camera.orientation,
996            );
997            let byte_size = std::mem::size_of_val(verts.as_slice()) as u64;
998            if byte_size > resources.axes_vertex_buffer.size() {
999                // Reallocate if too small.
1000                resources.axes_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1001                    label: Some("axes_vertex_buf"),
1002                    size: byte_size,
1003                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1004                    mapped_at_creation: false,
1005                });
1006            }
1007            if !verts.is_empty() {
1008                queue.write_buffer(
1009                    &resources.axes_vertex_buffer,
1010                    0,
1011                    bytemuck::cast_slice(&verts),
1012                );
1013            }
1014            resources.axes_vertex_count = verts.len() as u32;
1015        } else {
1016            resources.axes_vertex_count = 0;
1017        }
1018
1019        // ------------------------------------------------------------------
1020        // SciVis Phase B — point cloud and glyph GPU data upload.
1021        // Zero-cost when both vecs are empty (no pipelines created, no uploads).
1022        // ------------------------------------------------------------------
1023        self.point_cloud_gpu_data.clear();
1024        if !frame.scene.point_clouds.is_empty() {
1025            resources.ensure_point_cloud_pipeline(device);
1026            for item in &frame.scene.point_clouds {
1027                if item.positions.is_empty() {
1028                    continue;
1029                }
1030                let gpu_data = resources.upload_point_cloud(device, queue, item);
1031                self.point_cloud_gpu_data.push(gpu_data);
1032            }
1033        }
1034
1035        self.glyph_gpu_data.clear();
1036        if !frame.scene.glyphs.is_empty() {
1037            resources.ensure_glyph_pipeline(device);
1038            for item in &frame.scene.glyphs {
1039                if item.positions.is_empty() || item.vectors.is_empty() {
1040                    continue;
1041                }
1042                let gpu_data = resources.upload_glyph_set(device, queue, item);
1043                self.glyph_gpu_data.push(gpu_data);
1044            }
1045        }
1046
1047        // ------------------------------------------------------------------
1048        // SciVis Phase M8 — polyline GPU data upload.
1049        // Zero-cost when polylines vec is empty (no pipeline created, no uploads).
1050        // ------------------------------------------------------------------
1051        self.polyline_gpu_data.clear();
1052        if !frame.scene.polylines.is_empty() {
1053            resources.ensure_polyline_pipeline(device);
1054            for item in &frame.scene.polylines {
1055                if item.positions.is_empty() {
1056                    continue;
1057                }
1058                let gpu_data = resources.upload_polyline(device, queue, item);
1059                self.polyline_gpu_data.push(gpu_data);
1060            }
1061        }
1062
1063        // ------------------------------------------------------------------
1064        // SciVis Phase L — isoline extraction and upload via polyline pipeline.
1065        // Zero-cost when isoline_items is empty (no pipeline init, no uploads).
1066        // ------------------------------------------------------------------
1067        if !frame.scene.isolines.is_empty() {
1068            resources.ensure_polyline_pipeline(device);
1069            for item in &frame.scene.isolines {
1070                if item.positions.is_empty() || item.indices.is_empty() || item.scalars.is_empty() {
1071                    continue;
1072                }
1073                let (positions, strip_lengths) = crate::geometry::isoline::extract_isolines(item);
1074                if positions.is_empty() {
1075                    continue;
1076                }
1077                let polyline = PolylineItem {
1078                    positions,
1079                    scalars: Vec::new(), // solid color — no per-vertex scalar coloring
1080                    strip_lengths,
1081                    scalar_range: None,
1082                    colormap_id: None,
1083                    default_color: item.color,
1084                    line_width: item.line_width,
1085                    id: 0, // isolines are not individually pickable
1086                };
1087                let gpu_data = resources.upload_polyline(device, queue, &polyline);
1088                self.polyline_gpu_data.push(gpu_data);
1089            }
1090        }
1091
1092        // ------------------------------------------------------------------
1093        // SciVis Phase M — streamtube GPU data upload.
1094        // Zero-cost when streamtube_items is empty (no pipeline init, no uploads).
1095        // ------------------------------------------------------------------
1096        self.streamtube_gpu_data.clear();
1097        if !frame.scene.streamtube_items.is_empty() {
1098            resources.ensure_streamtube_pipeline(device);
1099            for item in &frame.scene.streamtube_items {
1100                if item.positions.is_empty() || item.strip_lengths.is_empty() {
1101                    continue;
1102                }
1103                let gpu_data = resources.upload_streamtube(device, queue, item);
1104                if gpu_data.instance_count > 0 {
1105                    self.streamtube_gpu_data.push(gpu_data);
1106                }
1107            }
1108        }
1109
1110        // ------------------------------------------------------------------
1111        // SciVis Phase D -- volume GPU data upload.
1112        // Zero-cost when volumes vec is empty (no pipeline created, no uploads).
1113        // ------------------------------------------------------------------
1114        self.volume_gpu_data.clear();
1115        if !frame.scene.volumes.is_empty() {
1116            resources.ensure_volume_pipeline(device);
1117            for item in &frame.scene.volumes {
1118                let gpu = resources.upload_volume_frame(device, queue, item, &frame.effects.clip_planes);
1119                self.volume_gpu_data.push(gpu);
1120            }
1121        }
1122
1123        // -- Frame stats --
1124        {
1125            let total = scene_items.len() as u32;
1126            let visible = scene_items.iter().filter(|i| i.visible).count() as u32;
1127            let mut draw_calls = 0u32;
1128            let mut triangles = 0u64;
1129            let instanced_batch_count = if self.use_instancing {
1130                self.instanced_batches.len() as u32
1131            } else {
1132                0
1133            };
1134
1135            if self.use_instancing {
1136                for batch in &self.instanced_batches {
1137                    if let Some(mesh) = resources
1138                        .mesh_store
1139                        .get(crate::resources::mesh_store::MeshId(batch.mesh_index))
1140                    {
1141                        draw_calls += 1;
1142                        triangles += (mesh.index_count / 3) as u64 * batch.instance_count as u64;
1143                    }
1144                }
1145            } else {
1146                for item in scene_items {
1147                    if !item.visible {
1148                        continue;
1149                    }
1150                    if let Some(mesh) = resources
1151                        .mesh_store
1152                        .get(crate::resources::mesh_store::MeshId(item.mesh_index))
1153                    {
1154                        draw_calls += 1;
1155                        triangles += (mesh.index_count / 3) as u64;
1156                    }
1157                }
1158            }
1159
1160            self.last_stats = crate::renderer::stats::FrameStats {
1161                total_objects: total,
1162                visible_objects: visible,
1163                culled_objects: total.saturating_sub(visible),
1164                draw_calls,
1165                instanced_batches: instanced_batch_count,
1166                triangles_submitted: triangles,
1167                shadow_draw_calls: 0, // Updated below in shadow pass.
1168            };
1169        }
1170
1171        // ------------------------------------------------------------------
1172        // Shadow depth pass — CSM: render each cascade into its atlas tile.
1173        // Uses set_viewport() to target different regions of the shadow atlas.
1174        // Submitted as a separate command buffer before the main pass.
1175        // ------------------------------------------------------------------
1176        if frame.effects.lighting.shadows_enabled && !scene_items.is_empty() {
1177            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1178                label: Some("shadow_pass_encoder"),
1179            });
1180            {
1181                let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1182                    label: Some("shadow_pass"),
1183                    color_attachments: &[],
1184                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1185                        view: &resources.shadow_map_view,
1186                        depth_ops: Some(wgpu::Operations {
1187                            load: wgpu::LoadOp::Clear(1.0),
1188                            store: wgpu::StoreOp::Store,
1189                        }),
1190                        stencil_ops: None,
1191                    }),
1192                    timestamp_writes: None,
1193                    occlusion_query_set: None,
1194                });
1195
1196                let mut shadow_draws = 0u32;
1197                let tile_px = tile_size as f32;
1198
1199                if self.use_instancing {
1200                    // Instanced shadow pass: one draw call per InstancedBatch per cascade.
1201                    // No per-item limit — all instances in the storage buffer are drawn.
1202                    if let (Some(pipeline), Some(instance_bg)) = (
1203                        &resources.shadow_instanced_pipeline,
1204                        self.instanced_batches.first().and_then(|b| {
1205                            resources.instance_bind_groups.get(&(
1206                                b.texture_id.unwrap_or(u64::MAX),
1207                                b.normal_map_id.unwrap_or(u64::MAX),
1208                                b.ao_map_id.unwrap_or(u64::MAX),
1209                            ))
1210                        }),
1211                    ) {
1212                        for cascade in 0..effective_cascade_count {
1213                            let tile_col = (cascade % 2) as f32;
1214                            let tile_row = (cascade / 2) as f32;
1215                            shadow_pass.set_viewport(
1216                                tile_col * tile_px,
1217                                tile_row * tile_px,
1218                                tile_px,
1219                                tile_px,
1220                                0.0,
1221                                1.0,
1222                            );
1223                            shadow_pass.set_scissor_rect(
1224                                (tile_col * tile_px) as u32,
1225                                (tile_row * tile_px) as u32,
1226                                tile_size,
1227                                tile_size,
1228                            );
1229
1230                            shadow_pass.set_pipeline(pipeline);
1231
1232                            // Write this cascade's view-projection matrix into its dedicated buffer.
1233                            queue.write_buffer(
1234                                resources.shadow_instanced_cascade_bufs[cascade]
1235                                    .as_ref()
1236                                    .expect("shadow_instanced_cascade_bufs not allocated"),
1237                                0,
1238                                bytemuck::cast_slice(
1239                                    &cascade_view_projs[cascade].to_cols_array_2d(),
1240                                ),
1241                            );
1242
1243                            let cascade_bg = resources.shadow_instanced_cascade_bgs[cascade]
1244                                .as_ref()
1245                                .expect("shadow_instanced_cascade_bgs not allocated");
1246                            shadow_pass.set_bind_group(0, cascade_bg, &[]);
1247                            shadow_pass.set_bind_group(1, instance_bg, &[]);
1248
1249                            for batch in &self.instanced_batches {
1250                                // OIT: transparent items do not cast shadows.
1251                                if batch.is_transparent {
1252                                    continue;
1253                                }
1254                                let Some(mesh) = resources
1255                                    .mesh_store
1256                                    .get(crate::resources::mesh_store::MeshId(batch.mesh_index))
1257                                else {
1258                                    continue;
1259                                };
1260                                shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1261                                shadow_pass.set_index_buffer(
1262                                    mesh.index_buffer.slice(..),
1263                                    wgpu::IndexFormat::Uint32,
1264                                );
1265                                shadow_pass.draw_indexed(
1266                                    0..mesh.index_count,
1267                                    0,
1268                                    batch.instance_offset
1269                                        ..batch.instance_offset + batch.instance_count,
1270                                );
1271                                shadow_draws += 1;
1272                            }
1273                        }
1274                    }
1275                } else {
1276                    // Per-item shadow pass (legacy path, used when instancing is disabled).
1277                    for cascade in 0..effective_cascade_count {
1278                        // Set viewport to this cascade's tile in the atlas.
1279                        let tile_col = (cascade % 2) as f32;
1280                        let tile_row = (cascade / 2) as f32;
1281                        shadow_pass.set_viewport(
1282                            tile_col * tile_px,
1283                            tile_row * tile_px,
1284                            tile_px,
1285                            tile_px,
1286                            0.0,
1287                            1.0,
1288                        );
1289                        shadow_pass.set_scissor_rect(
1290                            (tile_col * tile_px) as u32,
1291                            (tile_row * tile_px) as u32,
1292                            tile_size,
1293                            tile_size,
1294                        );
1295
1296                        shadow_pass.set_pipeline(&resources.shadow_pipeline);
1297                        // Dynamic offset selects this cascade's pre-uploaded matrix slot.
1298                        shadow_pass.set_bind_group(
1299                            0,
1300                            &resources.shadow_bind_group,
1301                            &[cascade as u32 * 256],
1302                        );
1303
1304                        // Frustum-cull against this cascade's frustum.
1305                        let cascade_frustum = crate::camera::frustum::Frustum::from_view_proj(
1306                            &cascade_view_projs[cascade],
1307                        );
1308
1309                        for item in scene_items.iter() {
1310                            if !item.visible {
1311                                continue;
1312                            }
1313                            // OIT: transparent items do not cast shadows.
1314                            if item.material.opacity < 1.0 {
1315                                continue;
1316                            }
1317                            let Some(mesh) = resources
1318                                .mesh_store
1319                                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
1320                            else {
1321                                continue;
1322                            };
1323
1324                            let world_aabb = mesh
1325                                .aabb
1326                                .transformed(&glam::Mat4::from_cols_array_2d(&item.model));
1327                            if cascade_frustum.cull_aabb(&world_aabb) {
1328                                continue;
1329                            }
1330
1331                            // Use the per-mesh object bind group (already uploaded during
1332                            // the main pass prepare step) to supply the model matrix.
1333                            shadow_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1334                            shadow_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1335                            shadow_pass.set_index_buffer(
1336                                mesh.index_buffer.slice(..),
1337                                wgpu::IndexFormat::Uint32,
1338                            );
1339                            shadow_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1340                            shadow_draws += 1;
1341                        }
1342                    }
1343                }
1344                drop(shadow_pass);
1345                self.last_stats.shadow_draw_calls = shadow_draws;
1346            }
1347            queue.submit(std::iter::once(encoder.finish()));
1348        }
1349
1350        // ------------------------------------------------------------------
1351        // Outline offscreen pass — render stencil-based outline ring into a
1352        // dedicated RGBA texture so the paint() path (which may lack a
1353        // depth/stencil attachment, e.g. eframe) can composite it later.
1354        // ------------------------------------------------------------------
1355        if frame.interaction.outline_selected && !resources.outline_object_buffers.is_empty() {
1356            let w = frame.camera.viewport_size[0] as u32;
1357            let h = frame.camera.viewport_size[1] as u32;
1358            resources.ensure_outline_target(device, w, h);
1359
1360            if let (Some(color_view), Some(depth_view)) =
1361                (&resources.outline_color_view, &resources.outline_depth_view)
1362            {
1363                let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1364                    label: Some("outline_offscreen_encoder"),
1365                });
1366                {
1367                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1368                        label: Some("outline_offscreen_pass"),
1369                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1370                            view: color_view,
1371                            resolve_target: None,
1372                            ops: wgpu::Operations {
1373                                load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
1374                                store: wgpu::StoreOp::Store,
1375                            },
1376                            depth_slice: None,
1377                        })],
1378                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1379                            view: depth_view,
1380                            depth_ops: Some(wgpu::Operations {
1381                                load: wgpu::LoadOp::Clear(1.0),
1382                                store: wgpu::StoreOp::Discard,
1383                            }),
1384                            stencil_ops: Some(wgpu::Operations {
1385                                load: wgpu::LoadOp::Clear(0),
1386                                store: wgpu::StoreOp::Discard,
1387                            }),
1388                        }),
1389                        timestamp_writes: None,
1390                        occlusion_query_set: None,
1391                    });
1392
1393                    // Pass 1: write stencil=1 for selected objects.
1394                    // mesh.object_bind_group (group 1) contains both the object uniform and
1395                    // fallback textures — no separate group 2 bind group needed.
1396                    pass.set_pipeline(&resources.stencil_write_pipeline);
1397                    pass.set_stencil_reference(1);
1398                    pass.set_bind_group(0, &resources.camera_bind_group, &[]);
1399                    for outlined in &resources.outline_object_buffers {
1400                        let Some(mesh) = resources
1401                            .mesh_store
1402                            .get(crate::resources::mesh_store::MeshId(outlined.mesh_index))
1403                        else {
1404                            continue;
1405                        };
1406                        pass.set_bind_group(1, &outlined.stencil_bind_group, &[]);
1407                        pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1408                        pass.set_index_buffer(
1409                            mesh.index_buffer.slice(..),
1410                            wgpu::IndexFormat::Uint32,
1411                        );
1412                        pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1413                    }
1414
1415                    // Pass 2: draw expanded outline ring where stencil != 1.
1416                    pass.set_pipeline(&resources.outline_pipeline);
1417                    pass.set_stencil_reference(1);
1418                    for outlined in &resources.outline_object_buffers {
1419                        let Some(mesh) = resources
1420                            .mesh_store
1421                            .get(crate::resources::mesh_store::MeshId(outlined.mesh_index))
1422                        else {
1423                            continue;
1424                        };
1425                        pass.set_bind_group(1, &outlined.outline_bind_group, &[]);
1426                        pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1427                        pass.set_index_buffer(
1428                            mesh.index_buffer.slice(..),
1429                            wgpu::IndexFormat::Uint32,
1430                        );
1431                        pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1432                    }
1433                }
1434                queue.submit(std::iter::once(encoder.finish()));
1435            }
1436        }
1437    }
1438}