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