Skip to main content

rustial_renderer_wgpu/
renderer.rs

1// ---------------------------------------------------------------------------
2//! # Top-level WGPU renderer
3//!
4//! [`WgpuMapRenderer`] is the single entry-point for drawing the map with
5//! a pure-WGPU backend.  The host application creates it once during
6//! initialisation and calls [`render`](WgpuMapRenderer::render) (or the
7//! richer [`render_full`](WgpuMapRenderer::render_full)) each frame.
8//!
9//! ## Ownership model
10//!
11//! The renderer **does not** own the `wgpu::Device`, `wgpu::Queue`, or
12//! surface.  These are provided by the host (typically via winit + WGPU
13//! surface setup) and passed in by reference.  This keeps the renderer
14//! framework-agnostic: it works identically inside a winit event loop,
15//! an egui integration, or a headless offscreen test.
16//!
17//! ## Camera-relative rendering
18//!
19//! All world-space positions (tiles, terrain, vectors, models) are
20#![allow(clippy::many_single_char_names)]
21//! transformed to *camera-relative* f32 coordinates before upload.
22//! The camera's world-space origin is subtracted on the CPU so that
23//! vertices near the camera are close to `(0, 0, 0)`.  This avoids
24//! catastrophic f32 precision loss when the camera is far from the
25//! Web Mercator origin (e.g. at longitude 170 degrees, x ~ 19 million meters).
26//!
27//! The view-projection matrix is similarly computed relative to the
28//! camera origin and uploaded as a single shared uniform buffer.
29//!
30//! ## GPU resource layout
31//!
32//! | Resource | Lifetime | Notes |
33//! |----------|----------|-------|
34//! | Pipelines (tile, terrain, vector, model) | Renderer | Created once |
35//! | Uniform buffer + bind groups | Renderer | One buffer, four BGs (shared layout) |
36//! | Tile atlas (`TileAtlas`) | Renderer | Grows lazily, evicted per-frame |
37//! | Page bind groups | Renderer | Rebuilt when atlas pages are added |
38//! | Tile batch buffers | Cached | Invalidated when visible set or camera origin changes |
39//! | Vector batch buffers | Cached | Invalidated when mesh data or camera origin changes |
40//! | Terrain batch buffers (fallback) | Per-frame | Only used for non-standard projections |
41//! | Model mesh buffers | Cached | Keyed by `ModelMeshKey` fingerprint |
42//! | Model transform buffers | Cached | Invalidated when instance set or camera origin changes |
43//! | Shared terrain grid meshes | Cached | One per resolution, reused across all tiles |
44//! | Elevation textures | Cached | Per-tile R32Float, invalidated on generation change |
45//! | Depth texture | Renderer | Recreated on `resize()` |
46//! | Sampler | Renderer | Bilinear, clamp-to-edge |
47//!
48//! ## Frame lifecycle
49//!
50//! ```text
51//! render_full(params)
52//!   1. Upload view-proj uniform
53//!   2. Upload new tile textures into the atlas
54//!   3. Mark visible tiles + terrain tiles as used
55//!   4. Cache model mesh GPU buffers (avoids re-upload)
56//!   5. Build batched geometry (tiles, terrain, vectors)
57//!   6. Begin render pass (clear colour + depth)
58//!      a. Draw terrain batches  -- OR --  flat tile batches
59//!      b. Draw vector overlays
60//!      c. Draw 3D model instances
61//!   7. Submit command buffer
62//!   8. Atlas end-of-frame eviction
63//! ```
64// ---------------------------------------------------------------------------
65
66use crate::gpu::batch::{
67    build_circle_batch, build_fill_batch, build_fill_extrusion_batch, build_fill_pattern_batch,
68    build_heatmap_batch, build_hillshade_batches, build_line_batch, build_line_pattern_batch,
69    build_placeholder_batches, build_symbol_batch, build_terrain_batches, build_tile_batches,
70    build_vector_batch, find_terrain_texture_actual, CircleBatchEntry, FillBatchEntry,
71    FillExtrusionBatchEntry, FillPatternBatchEntry, HeatmapBatchEntry, HillshadeBatch,
72    LineBatchEntry, LinePatternBatchEntry, SymbolBatchEntry, TerrainBatch, TilePageBatches,
73    VectorBatchEntry,
74};
75use crate::gpu::column_vertex::{ColumnInstanceData, ColumnVertex};
76use crate::gpu::depth::create_depth_texture;
77use crate::gpu::grid_extrusion_vertex::GridExtrusionVertex;
78use crate::gpu::grid_scalar_vertex::GridScalarVertex;
79use crate::gpu::image_overlay_vertex::ImageOverlayVertex;
80use crate::gpu::model_vertex::ModelVertex;
81use crate::gpu::terrain_buffers::TerrainInteractionBuffers;
82use crate::gpu::terrain_grid_vertex::TerrainGridVertex;
83use crate::gpu::tile_atlas::TileAtlas;
84use crate::painter::{PainterPass, PainterPlan};
85use crate::pipeline::circle_pipeline::CirclePipeline;
86use crate::pipeline::column_pipeline::ColumnPipeline;
87use crate::pipeline::fill_extrusion_pipeline::FillExtrusionPipeline;
88use crate::pipeline::fill_pattern_pipeline::FillPatternPipeline;
89use crate::pipeline::fill_pipeline::FillPipeline;
90use crate::pipeline::grid_extrusion_pipeline::GridExtrusionPipeline;
91use crate::pipeline::grid_scalar_pipeline::GridScalarPipeline;
92use crate::pipeline::heatmap_colormap_pipeline::HeatmapColormapPipeline;
93use crate::pipeline::heatmap_pipeline::HeatmapPipeline;
94use crate::pipeline::hillshade_pipeline::HillshadePipeline;
95use crate::pipeline::image_overlay_pipeline::ImageOverlayPipeline;
96use crate::pipeline::line_pattern_pipeline::LinePatternPipeline;
97use crate::pipeline::line_pipeline::LinePipeline;
98use crate::pipeline::model_pipeline::ModelPipeline;
99use crate::pipeline::sky_pipeline::{SkyPipeline, SkyUniform};
100use crate::pipeline::symbol_pipeline::SymbolPipeline;
101use crate::pipeline::terrain_data_pipeline::TerrainDataPipeline;
102use crate::pipeline::terrain_pipeline::TerrainPipeline;
103use crate::pipeline::tile_pipeline::TilePipeline;
104use crate::pipeline::uniforms::ViewProjUniform;
105use crate::pipeline::vector_pipeline::VectorPipeline;
106use glam::{DVec3, Mat4};
107use rustial_engine as rustial_math;
108use rustial_engine::TileId;
109use rustial_engine::{
110    materialize_terrain_mesh, DecodedImage, LayerId, MapState, ModelInstance, TerrainMeshData,
111    TileData, VectorMeshData, VectorRenderMode, VisibleTile, VisualizationOverlay,
112};
113use std::sync::Arc;
114use wgpu::util::DeviceExt;
115
116#[repr(C)]
117#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
118struct TerrainTileUniform {
119    geo_bounds: [f32; 4],
120    scene_origin: [f32; 4],
121    elev_params: [f32; 4],
122    elev_region: [f32; 4],
123}
124
125struct SharedTerrainGridMesh {
126    vertex_buffer: wgpu::Buffer,
127    index_buffer: wgpu::Buffer,
128    index_count: u32,
129}
130
131struct CachedHeightTexture {
132    generation: u64,
133    view: wgpu::TextureView,
134}
135
136#[repr(C)]
137#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
138struct GridScalarUniform {
139    origin_counts: [f32; 4],
140    grid_params: [f32; 4],
141    scene_origin: [f32; 4],
142    value_params: [f32; 4],
143    base_altitude: [f32; 4],
144}
145
146struct SharedColumnMesh {
147    vertex_buffer: wgpu::Buffer,
148    index_buffer: wgpu::Buffer,
149    index_count: u32,
150}
151
152struct CachedGridScalarOverlay {
153    vertex_buffer: wgpu::Buffer,
154    index_buffer: wgpu::Buffer,
155    index_count: u32,
156    vertex_count: usize,
157    #[allow(dead_code)]
158    uniform_buffer: wgpu::Buffer,
159    bind_group: wgpu::BindGroup,
160    #[allow(dead_code)]
161    scalar_texture: wgpu::Texture,
162    #[allow(dead_code)]
163    ramp_texture: wgpu::Texture,
164    generation: u64,
165    value_generation: u64,
166    ramp_fingerprint: u64,
167    grid_fingerprint: u64,
168    terrain_fingerprint: u64,
169    projection: rustial_engine::CameraProjection,
170    origin_key: [i64; 3],
171}
172
173struct CachedGridExtrusionOverlay {
174    vertex_buffer: wgpu::Buffer,
175    index_buffer: wgpu::Buffer,
176    index_count: u32,
177    vertex_count: usize,
178    generation: u64,
179    value_generation: u64,
180    origin_key: [i64; 3],
181    grid_fingerprint: u64,
182    params_fingerprint: u64,
183    ramp_fingerprint: u64,
184    terrain_fingerprint: u64,
185}
186
187struct CachedColumnOverlay {
188    instance_buffer: wgpu::Buffer,
189    instance_count: u32,
190    generation: u64,
191    origin_key: [i64; 3],
192    columns_fingerprint: u64,
193    ramp_fingerprint: u64,
194    instance_data: Vec<ColumnInstanceData>,
195}
196
197struct CachedPointCloudOverlay {
198    instance_buffer: wgpu::Buffer,
199    instance_count: u32,
200    generation: u64,
201    origin_key: [i64; 3],
202    points_fingerprint: u64,
203    ramp_fingerprint: u64,
204    instance_data: Vec<ColumnInstanceData>,
205}
206
207/// Per-frame visualization cache activity recorded during the last render.
208#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
209pub struct VisualizationPerfStats {
210    /// Number of grid-scalar overlay cache rebuilds.
211    pub grid_scalar_rebuilds: u32,
212    /// Number of grid-scalar value-texture updates.
213    pub grid_scalar_value_updates: u32,
214    /// Number of grid-extrusion overlay rebuilds.
215    pub grid_extrusion_rebuilds: u32,
216    /// Number of grid-extrusion vertex-buffer updates.
217    pub grid_extrusion_value_updates: u32,
218    /// Number of column overlay rebuilds.
219    pub column_rebuilds: u32,
220    /// Number of partial column buffer writes.
221    pub column_partial_writes: u32,
222    /// Number of changed contiguous column ranges written this frame.
223    pub column_partial_write_ranges: u32,
224    /// Number of point-cloud overlay rebuilds.
225    pub point_cloud_rebuilds: u32,
226    /// Number of point-cloud partial retained writes.
227    pub point_cloud_partial_writes: u32,
228    /// Number of changed contiguous point-cloud ranges written this frame.
229    pub point_cloud_partial_write_ranges: u32,
230}
231
232/// Cached per-tile terrain uniform buffer + bind group.
233///
234/// Matches MapLibre's approach of retaining per-tile GPU state across
235/// frames and only recreating when the tile's data or the camera origin
236/// changes.  This avoids allocating a fresh `wgpu::Buffer` and
237/// `wgpu::BindGroup` for every terrain tile on every frame.
238struct CachedTerrainTileBind {
239    #[allow(dead_code)]
240    uniform_buffer: wgpu::Buffer,
241    bind_group: wgpu::BindGroup,
242    /// Quantised scene origin used when this entry was created.
243    origin_key: [i64; 3],
244    /// Elevation data generation when this entry was created.
245    generation: u64,
246}
247
248/// Cache key for per-tile terrain uniform/bind-group entries.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
250struct TerrainTileBindKey {
251    tile: TileId,
252    /// Which pipeline family created this entry.
253    pipeline: TerrainPipelineKind,
254}
255
256/// Distinguish terrain vs terrain-data vs hillshade pipeline bind groups.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
258enum TerrainPipelineKind {
259    Terrain,
260    TerrainData,
261    Hillshade,
262}
263
264/// Dirty-tracking state for the terrain-data interaction pass.
265///
266/// Mirrors MapLibre's `terrainFacilitator` pattern: the depth/coordinate
267/// framebuffers are only redrawn when the VP matrix or the visible terrain
268/// set has changed, rather than unconditionally every frame.
269struct TerrainDataDirtyState {
270    /// Whether an explicit dirty flag has been set (e.g. after resize).
271    dirty: bool,
272    /// Last VP matrix (f32, column-major) that was rendered into the
273    /// terrain-data buffers.
274    last_vp: [f32; 16],
275    /// Tile set fingerprint (sorted tile IDs + generations).
276    last_terrain_fingerprint: u64,
277}
278
279impl Default for TerrainDataDirtyState {
280    fn default() -> Self {
281        Self {
282            dirty: true,
283            last_vp: [0.0; 16],
284            last_terrain_fingerprint: 0,
285        }
286    }
287}
288
289impl TerrainDataDirtyState {
290    /// Check whether the terrain-data pass needs to be redrawn.
291    fn needs_update(&self, vp: &glam::DMat4, terrain_meshes: &[TerrainMeshData]) -> bool {
292        if self.dirty {
293            return true;
294        }
295        let vp_f32 = vp.to_cols_array().map(|v| v as f32);
296        if vp_f32 != self.last_vp {
297            return true;
298        }
299        let fp = Self::terrain_fingerprint(terrain_meshes);
300        fp != self.last_terrain_fingerprint
301    }
302
303    /// Record that the terrain-data pass was just rendered with these inputs.
304    fn mark_clean(&mut self, vp: &glam::DMat4, terrain_meshes: &[TerrainMeshData]) {
305        self.dirty = false;
306        self.last_vp = vp.to_cols_array().map(|v| v as f32);
307        self.last_terrain_fingerprint = Self::terrain_fingerprint(terrain_meshes);
308    }
309
310    fn terrain_fingerprint(terrain_meshes: &[TerrainMeshData]) -> u64 {
311        let mut h: u64 = terrain_meshes.len() as u64;
312        for mesh in terrain_meshes {
313            h = h
314                .wrapping_mul(31)
315                .wrapping_add(mesh.tile.zoom as u64)
316                .wrapping_mul(31)
317                .wrapping_add(mesh.tile.x as u64)
318                .wrapping_mul(31)
319                .wrapping_add(mesh.tile.y as u64)
320                .wrapping_mul(31)
321                .wrapping_add(mesh.generation);
322        }
323        h
324    }
325}
326
327#[allow(clippy::single_range_in_vec_init)]
328fn diff_column_instance_ranges(
329    old: &[ColumnInstanceData],
330    new: &[ColumnInstanceData],
331) -> Vec<std::ops::Range<usize>> {
332    if old.len() != new.len() {
333        return if new.is_empty() {
334            Vec::new()
335        } else {
336            vec![(0..new.len())]
337        };
338    }
339
340    let mut ranges = Vec::new();
341    let mut current_start: Option<usize> = None;
342
343    for (index, (old_item, new_item)) in old.iter().zip(new.iter()).enumerate() {
344        if old_item != new_item {
345            if current_start.is_none() {
346                current_start = Some(index);
347            }
348        } else if let Some(start) = current_start.take() {
349            ranges.push(start..index);
350        }
351    }
352
353    if let Some(start) = current_start {
354        ranges.push(start..new.len());
355    }
356
357    ranges
358}
359
360/// Cache key for tile batch buffers.  When the visible tile set and camera
361/// origin haven't changed, the GPU buffers from the previous frame are
362/// reused instead of being rebuilt.
363#[derive(Debug, Clone, PartialEq)]
364struct TileBatchCacheKey {
365    /// Ordered list of (target, actual, fade_opacity_bits) tuples.
366    ///
367    /// `fade_opacity` is part of the key because tile vertex opacity is
368    /// baked into the cached tile batch geometry.  Omitting it would allow
369    /// a retained batch to freeze an in-progress fade transition until some
370    /// unrelated input invalidated the cache.
371    tiles: Vec<(TileId, TileId, u32)>,
372    /// Camera origin quantised to avoid float drift invalidation.
373    origin: [i64; 3],
374    /// Active projection.
375    projection: rustial_engine::CameraProjection,
376}
377
378impl TileBatchCacheKey {
379    fn new(
380        visible_tiles: &[VisibleTile],
381        camera_origin: DVec3,
382        projection: rustial_engine::CameraProjection,
383    ) -> Self {
384        let tiles: Vec<(TileId, TileId, u32)> = visible_tiles
385            .iter()
386            .map(|vt| (vt.target, vt.actual, vt.fade_opacity.to_bits()))
387            .collect();
388        let origin = [
389            (camera_origin.x * 100.0) as i64,
390            (camera_origin.y * 100.0) as i64,
391            (camera_origin.z * 100.0) as i64,
392        ];
393        Self {
394            tiles,
395            origin,
396            projection,
397        }
398    }
399}
400
401/// Cache key for vector batch buffers.  Captures a fingerprint of the
402/// vector mesh data so that unchanged layers reuse their GPU buffers.
403#[derive(Debug, Clone, PartialEq)]
404struct VectorBatchCacheKey {
405    /// Per-layer fingerprint: (vertex_count, index_count).
406    layers: Vec<(usize, usize)>,
407    /// Camera origin quantised to avoid float drift invalidation.
408    origin: [i64; 3],
409}
410
411impl VectorBatchCacheKey {
412    fn new(vector_meshes: &[VectorMeshData], camera_origin: DVec3) -> Self {
413        let layers: Vec<(usize, usize)> = vector_meshes
414            .iter()
415            .map(|m| (m.positions.len(), m.indices.len()))
416            .collect();
417        let origin = [
418            (camera_origin.x * 100.0) as i64,
419            (camera_origin.y * 100.0) as i64,
420            (camera_origin.z * 100.0) as i64,
421        ];
422        Self { layers, origin }
423    }
424}
425
426// ---------------------------------------------------------------------------
427// RenderParams
428// ---------------------------------------------------------------------------
429
430/// All inputs needed for a full render frame.
431///
432/// Aggregated into a single struct to keep [`WgpuMapRenderer::render_full`]'s
433/// signature manageable.  Lifetimes are tied to the caller's frame data.
434pub struct RenderParams<'a> {
435    /// Engine map state (camera, layers, terrain manager).
436    pub state: &'a MapState,
437    /// WGPU device for buffer/texture/bind-group creation.
438    pub device: &'a wgpu::Device,
439    /// WGPU queue for `write_buffer` / `write_texture` / `submit`.
440    pub queue: &'a wgpu::Queue,
441    /// Colour attachment view (the surface texture for this frame).
442    pub color_view: &'a wgpu::TextureView,
443    /// Visible tile set for this frame (from engine's tile manager).
444    pub visible_tiles: &'a [VisibleTile],
445    /// Tessellated vector meshes to render (one per visible vector layer).
446    pub vector_meshes: &'a [VectorMeshData],
447    /// 3D model instances to render.
448    pub model_instances: &'a [ModelInstance],
449    /// Background / clear colour `[r, g, b, a]` in linear sRGB.
450    ///
451    /// Also used as the fog horizon colour.  Defaults to white if not set.
452    pub clear_color: [f32; 4],
453}
454
455// ---------------------------------------------------------------------------
456// WgpuMapRenderer
457// ---------------------------------------------------------------------------
458
459/// The WGPU-based map renderer.
460///
461/// Owns all persistent GPU resources (pipelines, uniform buffer, atlas,
462/// sampler, depth texture) and provides [`render`](Self::render) /
463/// [`render_full`](Self::render_full) to draw one frame.
464///
465/// See the [module-level documentation](self) for the full resource
466/// layout and frame lifecycle.
467///
468/// ## Construction
469///
470/// ```ignore
471/// let renderer = WgpuMapRenderer::new(&device, &queue, surface_format, width, height);
472/// ```
473///
474/// ## Resize
475///
476/// Call [`resize`](Self::resize) whenever the surface dimensions change.
477/// This recreates the depth texture.  Passing `width=0` or `height=0` is
478/// clamped to 1x1 to avoid WGPU validation errors.
479///
480/// ## GPU batching
481///
482/// Tile textures are packed into shared 4096x4096 atlas pages
483/// ([`TileAtlas`]).  All tiles on the same page are drawn in a single
484/// batched draw call, reducing draw calls from N (one per tile) to P
485/// (one per atlas page, typically 1-2).  Terrain meshes are batched
486/// identically.  Vector layers are one draw call each.  Model mesh
487/// GPU buffers are cached by identity fingerprint across frames.
488pub struct WgpuMapRenderer {
489    // -- Pipelines --------------------------------------------------------
490    tile_pipeline: TilePipeline,
491    terrain_pipeline: TerrainPipeline,
492    terrain_data_pipeline: TerrainDataPipeline,
493    hillshade_pipeline: HillshadePipeline,
494    grid_scalar_pipeline: GridScalarPipeline,
495    grid_extrusion_pipeline: GridExtrusionPipeline,
496    column_pipeline: ColumnPipeline,
497    vector_pipeline: VectorPipeline,
498    fill_pipeline: FillPipeline,
499    fill_pattern_pipeline: FillPatternPipeline,
500    fill_extrusion_pipeline: FillExtrusionPipeline,
501    line_pipeline: LinePipeline,
502    line_pattern_pipeline: LinePatternPipeline,
503    circle_pipeline: CirclePipeline,
504    heatmap_pipeline: HeatmapPipeline,
505    /// Heatmap colour-mapping pipeline (Pass 2: fullscreen ramp composite).
506    heatmap_colormap_pipeline: HeatmapColormapPipeline,
507    symbol_pipeline: SymbolPipeline,
508    model_pipeline: ModelPipeline,
509    image_overlay_pipeline: ImageOverlayPipeline,
510    /// Sky / atmosphere fullscreen pipeline.
511    sky_pipeline: SkyPipeline,
512
513    // -- Shared uniform ---------------------------------------------------
514    /// A single 64-byte uniform buffer holding the view-projection matrix.
515    /// Shared by all four pipelines (each has its own bind group pointing
516    /// to this buffer).
517    uniform_buffer: wgpu::Buffer,
518    /// Tile pipeline's uniform bind group (group 0).
519    uniform_bind_group: wgpu::BindGroup,
520    /// Terrain pipeline's uniform bind group (group 0).
521    terrain_uniform_bind_group: wgpu::BindGroup,
522    /// Terrain data pipeline's uniform bind group (group 0).
523    terrain_data_uniform_bind_group: wgpu::BindGroup,
524    /// Hillshade pipeline's uniform bind group (group 0).
525    hillshade_uniform_bind_group: wgpu::BindGroup,
526    /// Grid scalar pipeline's uniform bind group (group 0).
527    grid_scalar_uniform_bind_group: wgpu::BindGroup,
528    /// Grid extrusion pipeline's uniform bind group (group 0).
529    grid_extrusion_uniform_bind_group: wgpu::BindGroup,
530    /// Column pipeline's uniform bind group (group 0).
531    column_uniform_bind_group: wgpu::BindGroup,
532    /// Vector pipeline's uniform bind group (group 0).
533    vector_uniform_bind_group: wgpu::BindGroup,
534    /// Fill-extrusion pipeline's uniform bind group (group 0).
535    fill_extrusion_uniform_bind_group: wgpu::BindGroup,
536    /// Model pipeline's uniform bind group (group 0).
537    model_uniform_bind_group: wgpu::BindGroup,
538    /// Line pipeline's uniform bind group (group 0).
539    line_uniform_bind_group: wgpu::BindGroup,
540    /// Circle pipeline's uniform bind group (group 0).
541    circle_uniform_bind_group: wgpu::BindGroup,
542    /// Heatmap pipeline's uniform bind group (group 0).
543    heatmap_uniform_bind_group: wgpu::BindGroup,
544    /// Heatmap colormap pipeline's uniform bind group (group 0).
545    heatmap_colormap_uniform_bind_group: wgpu::BindGroup,
546    /// Off-screen R16Float accumulation texture for heatmap Pass 1.
547    heatmap_accum_texture: wgpu::Texture,
548    /// View into the accumulation texture.
549    heatmap_accum_view: wgpu::TextureView,
550    /// 256×1 Rgba8Unorm colour ramp texture for heatmap Pass 2.
551    _heatmap_ramp_texture: wgpu::Texture,
552    /// View into the colour ramp texture.
553    heatmap_ramp_view: wgpu::TextureView,
554    /// Heatmap colormap textures bind group (group 1: heat + ramp + sampler).
555    heatmap_colormap_textures_bind_group: wgpu::BindGroup,
556    /// Symbol pipeline's uniform bind group (group 0).
557    symbol_uniform_bind_group: wgpu::BindGroup,
558    /// Image overlay pipeline's uniform bind group (group 0).
559    image_overlay_uniform_bind_group: wgpu::BindGroup,
560    /// Sky pipeline's dedicated uniform buffer.
561    sky_uniform_buffer: wgpu::Buffer,
562    /// Sky pipeline's uniform bind group (group 0).
563    sky_uniform_bind_group: wgpu::BindGroup,
564
565    // -- Shadow map resources ---------------------------------------------
566    /// Shadow depth pipeline for fill-extrusion casters.
567    shadow_fill_extrusion_pipeline: crate::pipeline::shadow_pipeline::ShadowFillExtrusionPipeline,
568    /// Shadow depth pipeline for model casters.
569    shadow_model_pipeline: crate::pipeline::shadow_pipeline::ShadowModelPipeline,
570    /// Per-cascade depth textures (one per cascade, kept alive for GPU).
571    _shadow_map_textures: Vec<wgpu::Texture>,
572    /// Per-cascade depth texture views.
573    shadow_map_views: Vec<wgpu::TextureView>,
574    /// Comparison sampler for shadow map sampling (kept alive for GPU).
575    _shadow_comparison_sampler: wgpu::Sampler,
576    /// Shadow params uniform buffer (2 light matrices + config).
577    shadow_params_buffer: wgpu::Buffer,
578    /// Shadow depth pass uniform buffer (single light VP per cascade).
579    shadow_depth_uniform_buffers: Vec<wgpu::Buffer>,
580    /// Shadow depth pass bind groups (group 0 for depth-only pipelines, one per cascade).
581    shadow_depth_bind_groups: Vec<wgpu::BindGroup>,
582    /// Shadow receiver bind group (group 1/2 for fill-extrusion / model).
583    shadow_receiver_bind_group: wgpu::BindGroup,
584
585    // -- Shared sampler ---------------------------------------------------
586    /// Bilinear, clamp-to-edge sampler shared across all atlas page bind
587    /// groups (tile + terrain).
588    sampler: wgpu::Sampler,
589    /// Filtering sampler for grid scalar ramp textures.
590    grid_scalar_ramp_sampler: wgpu::Sampler,
591    /// Repeat-mode sampler for fill-pattern textures.
592    fill_pattern_sampler: wgpu::Sampler,
593
594    // -- Depth ------------------------------------------------------------
595    /// Depth texture view (`Depth32Float`), recreated on [`resize`](Self::resize).
596    depth_view: wgpu::TextureView,
597    /// Current surface width in pixels (? 1).
598    width: u32,
599    /// Current surface height in pixels (? 1).
600    height: u32,
601    /// Renderer-owned terrain depth / coordinate buffers.
602    terrain_interaction_buffers: TerrainInteractionBuffers,
603
604    // -- Atlas + page bind groups -----------------------------------------
605    /// Tile texture atlas (persists across frames, evicted per-frame).
606    tile_atlas: TileAtlas,
607    /// Prepared hillshade texture atlas.
608    hillshade_atlas: TileAtlas,
609    /// Per-atlas-page bind groups for the **tile** pipeline (group 1:
610    /// texture view + sampler).  Rebuilt incrementally when new pages are
611    /// allocated.
612    page_bind_groups: Vec<wgpu::BindGroup>,
613    /// Per-atlas-page bind groups for the **terrain** pipeline (group 1).
614    page_terrain_bind_groups: Vec<wgpu::BindGroup>,
615    /// Per-atlas-page bind groups for the **hillshade** pipeline (group 1).
616    page_hillshade_bind_groups: Vec<wgpu::BindGroup>,
617
618    // -- Model mesh cache -------------------------------------------------
619    /// Cached model mesh GPU buffers keyed by [`ModelMeshKey`] fingerprint.
620    /// Avoids re-uploading identical mesh geometry every frame.
621    model_mesh_cache: std::collections::HashMap<ModelMeshKey, CachedModelMesh>,
622    /// Shared reusable terrain grid meshes keyed by grid resolution.
623    shared_terrain_grids: std::collections::HashMap<u16, SharedTerrainGridMesh>,
624    /// Cached GPU elevation textures keyed by terrain tile id.
625    height_texture_cache: std::collections::HashMap<TileId, CachedHeightTexture>,
626    /// Shared unit-box mesh for instanced columns.
627    shared_column_mesh: Option<SharedColumnMesh>,
628    /// Cached per-layer grid scalar GPU state.
629    grid_scalar_overlay_cache: std::collections::HashMap<LayerId, CachedGridScalarOverlay>,
630    /// Cached per-layer grid extrusion GPU state.
631    grid_extrusion_overlay_cache: std::collections::HashMap<LayerId, CachedGridExtrusionOverlay>,
632    /// Cached per-layer instanced column GPU state.
633    column_overlay_cache: std::collections::HashMap<LayerId, CachedColumnOverlay>,
634    /// Cached per-layer point-cloud GPU state.
635    point_cloud_overlay_cache: std::collections::HashMap<LayerId, CachedPointCloudOverlay>,
636
637    // -- Batch buffer caches ----------------------------------------------
638    /// Cached tile batch GPU buffers from the previous frame.
639    cached_tile_batches: Vec<TilePageBatches>,
640    /// Cache key for the current tile batch buffers.
641    tile_batch_cache_key: Option<TileBatchCacheKey>,
642    /// Cached vector batch GPU buffers from the previous frame.
643    cached_vector_batches: Vec<Option<VectorBatchEntry>>,
644    /// Cache key for the current vector batch buffers.
645    vector_batch_cache_key: Option<VectorBatchCacheKey>,
646    /// Cached fill-extrusion batch GPU buffers from the previous frame.
647    cached_fill_extrusion_batches: Vec<Option<FillExtrusionBatchEntry>>,
648    /// Cached fill batch GPU buffers from the previous frame.
649    cached_fill_batches: Vec<Option<FillBatchEntry>>,
650    /// Cached fill-pattern batch GPU buffers from the previous frame.
651    cached_fill_pattern_batches: Vec<Option<FillPatternBatchEntry>>,
652    /// Cached line batch GPU buffers from the previous frame.
653    cached_line_batches: Vec<Option<LineBatchEntry>>,
654    /// Cached line-pattern batch GPU buffers from the previous frame.
655    cached_line_pattern_batches: Vec<Option<LinePatternBatchEntry>>,
656    /// Cached circle batch GPU buffers from the previous frame.
657    cached_circle_batches: Vec<Option<CircleBatchEntry>>,
658    /// Cached heatmap batch GPU buffers from the previous frame.
659    cached_heatmap_batches: Vec<Option<HeatmapBatchEntry>>,
660    /// Cached symbol batch GPU buffers from the previous frame.
661    cached_symbol_batch: Option<SymbolBatchEntry>,
662    /// GPU glyph atlas texture and view for the symbol pipeline.
663    symbol_atlas_texture: Option<(wgpu::Texture, wgpu::TextureView)>,
664    /// Symbol atlas bind group (group 1: texture + sampler).
665    symbol_atlas_bind_group: Option<wgpu::BindGroup>,
666    /// Engine-side glyph atlas used for symbol rendering.
667    symbol_glyph_atlas: rustial_engine::symbols::GlyphAtlas,
668    /// Glyph provider for symbol rendering (font-based or procedural).
669    symbol_glyph_provider: Box<dyn rustial_engine::symbols::GlyphProvider>,
670
671    // -- Per-tile terrain bind caches -------------------------------------
672    /// Cached per-tile terrain uniform buffers and bind groups.
673    terrain_tile_bind_cache: std::collections::HashMap<TerrainTileBindKey, CachedTerrainTileBind>,
674
675    // -- Terrain-data dirty tracking --------------------------------------
676    /// Dirty-tracking for the terrain-data interaction pass (MapLibre's
677    /// `maybeDrawDepthAndCoords` pattern).
678    terrain_data_dirty: TerrainDataDirtyState,
679
680    // -- Model transform cache --------------------------------------------
681    /// Cached model instance transform buffer + bind group.
682    cached_model_transforms: Option<CachedModelTransforms>,
683    /// Cached placeholder quad batch from the previous frame.
684    cached_placeholder_batch: Option<VectorBatchEntry>,
685    /// Cached image overlay GPU resources from the previous frame.
686    cached_image_overlay_batches: Vec<CachedImageOverlayBatch>,
687    /// Visualization cache activity from the last render.
688    visualization_perf_stats: VisualizationPerfStats,
689}
690
691// ---------------------------------------------------------------------------
692// ModelMeshKey / CachedModelMesh
693// ---------------------------------------------------------------------------
694
695/// Identity key for deduplicating model mesh GPU uploads.
696///
697/// Two meshes with the same `(pos_len, idx_len, fingerprint)` are assumed
698/// identical.  The fingerprint is a cheap rolling hash of the first
699/// position and first index -- sufficient for typical usage where distinct
700/// meshes have different vertex counts.
701///
702/// **Limitation:** hash collisions are theoretically possible between two
703/// meshes with identical lengths and coincidentally identical first
704/// elements but different interiors.  A future improvement could hash the
705/// full data or use an explicit user-provided mesh ID.
706#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
707struct ModelMeshKey {
708    pos_len: usize,
709    idx_len: usize,
710    fingerprint: u64,
711}
712
713impl ModelMeshKey {
714    fn from_mesh(mesh: &rustial_engine::ModelMesh) -> Self {
715        let mut fingerprint: u64 = mesh.positions.len() as u64;
716        if let Some(first) = mesh.positions.first() {
717            fingerprint = fingerprint
718                .wrapping_mul(31)
719                .wrapping_add(first[0].to_bits() as u64)
720                .wrapping_mul(31)
721                .wrapping_add(first[1].to_bits() as u64)
722                .wrapping_mul(31)
723                .wrapping_add(first[2].to_bits() as u64);
724        }
725        if let Some(&first_idx) = mesh.indices.first() {
726            fingerprint = fingerprint.wrapping_mul(31).wrapping_add(first_idx as u64);
727        }
728        Self {
729            pos_len: mesh.positions.len(),
730            idx_len: mesh.indices.len(),
731            fingerprint,
732        }
733    }
734}
735
736/// A model mesh whose vertex + index buffers have been uploaded to the GPU.
737struct CachedModelMesh {
738    vertex_buffer: wgpu::Buffer,
739    index_buffer: wgpu::Buffer,
740    index_count: u32,
741}
742
743/// Cached model instance transform buffer and bind group.
744///
745/// Avoids re-creating the GPU buffer and bind group every frame when
746/// the model instance list and camera origin haven't changed.
747struct CachedModelTransforms {
748    #[allow(dead_code)]
749    buffer: wgpu::Buffer,
750    bind_group: wgpu::BindGroup,
751    /// Stride in bytes between consecutive instance transforms.
752    stride: usize,
753    /// Instance count at the time this was created.
754    instance_count: usize,
755    /// Rolling fingerprint of (instance positions + rotations + scales
756    /// + camera origin).
757    fingerprint: u64,
758}
759
760/// Cached GPU resources for a single image overlay.
761struct CachedImageOverlayBatch {
762    vertex_buffer: wgpu::Buffer,
763    index_buffer: wgpu::Buffer,
764    texture: wgpu::Texture,
765    #[allow(dead_code)]
766    texture_view: wgpu::TextureView,
767    texture_bind_group: wgpu::BindGroup,
768    /// Layer id that produced this overlay (for cache key matching).
769    layer_id: rustial_engine::LayerId,
770    /// Texture dimensions `(width, height)` for reuse checks.
771    tex_dimensions: (u32, u32),
772    /// Data pointer identity for fast same-frame skip.
773    data_arc_ptr: usize,
774}
775
776// ---------------------------------------------------------------------------
777// impl WgpuMapRenderer
778// ---------------------------------------------------------------------------
779
780impl WgpuMapRenderer {
781    /// Create a new renderer.
782    ///
783    /// # Arguments
784    ///
785    /// * `device` -- WGPU device.
786    /// * `_queue` -- WGPU queue (reserved for future lazy init; unused today).
787    /// * `format` -- Colour target format (must match the surface's preferred format).
788    /// * `width`  -- Initial surface width in pixels.
789    /// * `height` -- Initial surface height in pixels.
790    pub fn new(
791        device: &wgpu::Device,
792        _queue: &wgpu::Queue,
793        format: wgpu::TextureFormat,
794        width: u32,
795        height: u32,
796    ) -> Self {
797        let tile_pipeline = TilePipeline::new(device, format);
798        let terrain_pipeline =
799            TerrainPipeline::new(device, format, &tile_pipeline.uniform_bind_group_layout);
800        let terrain_data_pipeline = TerrainDataPipeline::new(device);
801        let hillshade_pipeline = HillshadePipeline::new(device, format);
802        let grid_scalar_pipeline = GridScalarPipeline::new(device, format);
803        let grid_extrusion_pipeline = GridExtrusionPipeline::new(device, format);
804        let column_pipeline = ColumnPipeline::new(device, format);
805        let vector_pipeline = VectorPipeline::new(device, format);
806        let fill_pipeline = FillPipeline::new(device, format);
807        let fill_pattern_pipeline = FillPatternPipeline::new(device, format);
808        let fill_extrusion_pipeline = FillExtrusionPipeline::new(device, format);
809        let line_pipeline = LinePipeline::new(device, format);
810        let line_pattern_pipeline = LinePatternPipeline::new(device, format);
811        let circle_pipeline = CirclePipeline::new(device, format);
812        let heatmap_pipeline = HeatmapPipeline::new(device);
813        let heatmap_colormap_pipeline = HeatmapColormapPipeline::new(device, format);
814        let symbol_pipeline = SymbolPipeline::new(device, format);
815        let model_pipeline = ModelPipeline::new(device, format);
816        let image_overlay_pipeline = ImageOverlayPipeline::new(device, format);
817        let sky_pipeline = SkyPipeline::new(device, format);
818
819        // Shared uniform buffer (view-projection + fog parameters).
820        let uniform_data = ViewProjUniform::from_dmat4(&glam::DMat4::IDENTITY);
821        let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
822            label: Some("rustial_uniform_buf"),
823            contents: bytemuck::bytes_of(&uniform_data),
824            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
825        });
826
827        // Four bind groups pointing to the same buffer -- one per pipeline.
828        // Each pipeline may have a different `BindGroupLayout` (even though
829        // the layout *happens* to be identical today) so we create separate
830        // bind groups to stay correct if layouts diverge.
831        let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
832            label: Some("rustial_uniform_bg"),
833            layout: &tile_pipeline.uniform_bind_group_layout,
834            entries: &[wgpu::BindGroupEntry {
835                binding: 0,
836                resource: uniform_buffer.as_entire_binding(),
837            }],
838        });
839
840        let column_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
841            label: Some("rustial_column_uniform_bg"),
842            layout: &column_pipeline.uniform_bind_group_layout,
843            entries: &[wgpu::BindGroupEntry {
844                binding: 0,
845                resource: uniform_buffer.as_entire_binding(),
846            }],
847        });
848
849        let grid_extrusion_uniform_bind_group =
850            device.create_bind_group(&wgpu::BindGroupDescriptor {
851                label: Some("rustial_grid_extrusion_uniform_bg"),
852                layout: &grid_extrusion_pipeline.uniform_bind_group_layout,
853                entries: &[wgpu::BindGroupEntry {
854                    binding: 0,
855                    resource: uniform_buffer.as_entire_binding(),
856                }],
857            });
858
859        let terrain_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
860            label: Some("rustial_terrain_uniform_bg"),
861            layout: &terrain_pipeline.uniform_bind_group_layout,
862            entries: &[wgpu::BindGroupEntry {
863                binding: 0,
864                resource: uniform_buffer.as_entire_binding(),
865            }],
866        });
867
868        let hillshade_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
869            label: Some("rustial_hillshade_uniform_bg"),
870            layout: &hillshade_pipeline.uniform_bind_group_layout,
871            entries: &[wgpu::BindGroupEntry {
872                binding: 0,
873                resource: uniform_buffer.as_entire_binding(),
874            }],
875        });
876
877        let grid_scalar_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
878            label: Some("rustial_grid_scalar_uniform_bg"),
879            layout: &grid_scalar_pipeline.uniform_bind_group_layout,
880            entries: &[wgpu::BindGroupEntry {
881                binding: 0,
882                resource: uniform_buffer.as_entire_binding(),
883            }],
884        });
885
886        let vector_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
887            label: Some("rustial_vector_uniform_bg"),
888            layout: &vector_pipeline.uniform_bind_group_layout,
889            entries: &[wgpu::BindGroupEntry {
890                binding: 0,
891                resource: uniform_buffer.as_entire_binding(),
892            }],
893        });
894
895        let fill_extrusion_uniform_bind_group =
896            device.create_bind_group(&wgpu::BindGroupDescriptor {
897                label: Some("rustial_fill_extrusion_uniform_bg"),
898                layout: &fill_extrusion_pipeline.uniform_bind_group_layout,
899                entries: &[wgpu::BindGroupEntry {
900                    binding: 0,
901                    resource: uniform_buffer.as_entire_binding(),
902                }],
903            });
904
905        let model_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
906            label: Some("rustial_model_uniform_bg"),
907            layout: &model_pipeline.uniform_bind_group_layout,
908            entries: &[wgpu::BindGroupEntry {
909                binding: 0,
910                resource: uniform_buffer.as_entire_binding(),
911            }],
912        });
913
914        let line_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
915            label: Some("rustial_line_uniform_bg"),
916            layout: &line_pipeline.uniform_bind_group_layout,
917            entries: &[wgpu::BindGroupEntry {
918                binding: 0,
919                resource: uniform_buffer.as_entire_binding(),
920            }],
921        });
922
923        let circle_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
924            label: Some("rustial_circle_uniform_bg"),
925            layout: &circle_pipeline.uniform_bind_group_layout,
926            entries: &[wgpu::BindGroupEntry {
927                binding: 0,
928                resource: uniform_buffer.as_entire_binding(),
929            }],
930        });
931
932        let heatmap_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
933            label: Some("rustial_heatmap_uniform_bg"),
934            layout: &heatmap_pipeline.uniform_bind_group_layout,
935            entries: &[wgpu::BindGroupEntry {
936                binding: 0,
937                resource: uniform_buffer.as_entire_binding(),
938            }],
939        });
940
941        let heatmap_colormap_uniform_bind_group =
942            device.create_bind_group(&wgpu::BindGroupDescriptor {
943                label: Some("rustial_heatmap_colormap_uniform_bg"),
944                layout: &heatmap_colormap_pipeline.uniform_bind_group_layout,
945                entries: &[wgpu::BindGroupEntry {
946                    binding: 0,
947                    resource: uniform_buffer.as_entire_binding(),
948                }],
949            });
950
951        let symbol_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
952            label: Some("rustial_symbol_uniform_bg"),
953            layout: &symbol_pipeline.uniform_bind_group_layout,
954            entries: &[wgpu::BindGroupEntry {
955                binding: 0,
956                resource: uniform_buffer.as_entire_binding(),
957            }],
958        });
959
960        let image_overlay_uniform_bind_group =
961            device.create_bind_group(&wgpu::BindGroupDescriptor {
962                label: Some("rustial_image_overlay_uniform_bg"),
963                layout: &image_overlay_pipeline.uniform_bind_group_layout,
964                entries: &[wgpu::BindGroupEntry {
965                    binding: 0,
966                    resource: uniform_buffer.as_entire_binding(),
967                }],
968            });
969
970        // Sky pipeline has its own dedicated uniform buffer (different layout).
971        let sky_uniform_data = SkyUniform::default();
972        let sky_uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
973            label: Some("rustial_sky_uniform_buf"),
974            contents: bytemuck::bytes_of(&sky_uniform_data),
975            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
976        });
977        let sky_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
978            label: Some("rustial_sky_uniform_bg"),
979            layout: &sky_pipeline.uniform_bind_group_layout,
980            entries: &[wgpu::BindGroupEntry {
981                binding: 0,
982                resource: sky_uniform_buffer.as_entire_binding(),
983            }],
984        });
985
986        let terrain_data_uniform_bind_group =
987            device.create_bind_group(&wgpu::BindGroupDescriptor {
988                label: Some("rustial_terrain_data_uniform_bg"),
989                layout: &terrain_data_pipeline.uniform_bind_group_layout,
990                entries: &[wgpu::BindGroupEntry {
991                    binding: 0,
992                    resource: uniform_buffer.as_entire_binding(),
993                }],
994            });
995
996        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
997            label: Some("rustial_sampler"),
998            address_mode_u: wgpu::AddressMode::ClampToEdge,
999            address_mode_v: wgpu::AddressMode::ClampToEdge,
1000            mag_filter: wgpu::FilterMode::Linear,
1001            min_filter: wgpu::FilterMode::Linear,
1002            mipmap_filter: wgpu::FilterMode::Linear,
1003            anisotropy_clamp: 16,
1004            ..Default::default()
1005        });
1006
1007        let grid_scalar_ramp_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1008            label: Some("rustial_grid_scalar_ramp_sampler"),
1009            address_mode_u: wgpu::AddressMode::ClampToEdge,
1010            address_mode_v: wgpu::AddressMode::ClampToEdge,
1011            mag_filter: wgpu::FilterMode::Linear,
1012            min_filter: wgpu::FilterMode::Linear,
1013            mipmap_filter: wgpu::FilterMode::Nearest,
1014            ..Default::default()
1015        });
1016
1017        let fill_pattern_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1018            label: Some("rustial_fill_pattern_sampler"),
1019            address_mode_u: wgpu::AddressMode::Repeat,
1020            address_mode_v: wgpu::AddressMode::Repeat,
1021            mag_filter: wgpu::FilterMode::Linear,
1022            min_filter: wgpu::FilterMode::Linear,
1023            mipmap_filter: wgpu::FilterMode::Linear,
1024            ..Default::default()
1025        });
1026
1027        // Clamp to at least 1x1 so the depth texture is always valid.
1028        let w = width.max(1);
1029        let h = height.max(1);
1030        let depth_view = create_depth_texture(device, w, h);
1031        let terrain_interaction_buffers = TerrainInteractionBuffers::new(device, w, h);
1032
1033        // -- Heatmap off-screen resources ---------------------------------
1034        let (heatmap_accum_texture, heatmap_accum_view) =
1035            create_heatmap_accum_texture(device, w, h);
1036        let heatmap_ramp_texture = create_default_heatmap_ramp_texture(device, _queue);
1037        let heatmap_ramp_view =
1038            heatmap_ramp_texture.create_view(&wgpu::TextureViewDescriptor::default());
1039        let heatmap_colormap_textures_bind_group = create_heatmap_colormap_bind_group(
1040            device,
1041            &heatmap_colormap_pipeline.textures_bind_group_layout,
1042            &heatmap_accum_view,
1043            &heatmap_ramp_view,
1044            &sampler,
1045        );
1046
1047        // -- Shadow map resources -----------------------------------------
1048        let shadow_fill_extrusion_pipeline =
1049            crate::pipeline::shadow_pipeline::ShadowFillExtrusionPipeline::new(device);
1050        let shadow_model_pipeline =
1051            crate::pipeline::shadow_pipeline::ShadowModelPipeline::new(device);
1052
1053        let shadow_map_resolution = 2048u32;
1054        let (shadow_map_textures, shadow_map_views) =
1055            create_shadow_map_textures(device, shadow_map_resolution, 2);
1056
1057        let shadow_comparison_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1058            label: Some("rustial_shadow_comparison_sampler"),
1059            compare: Some(wgpu::CompareFunction::LessEqual),
1060            mag_filter: wgpu::FilterMode::Linear,
1061            min_filter: wgpu::FilterMode::Linear,
1062            ..Default::default()
1063        });
1064
1065        let shadow_params_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1066            label: Some("rustial_shadow_params_buffer"),
1067            size: 160, // 2 × mat4 (128) + 2 × vec4 (32) = 160
1068            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1069            mapped_at_creation: false,
1070        });
1071
1072        let shadow_depth_uniform_buffers: Vec<wgpu::Buffer> = (0..2)
1073            .map(|i| {
1074                device.create_buffer(&wgpu::BufferDescriptor {
1075                    label: Some(&format!("rustial_shadow_depth_uniform_{i}")),
1076                    size: 64, // mat4x4<f32>
1077                    usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1078                    mapped_at_creation: false,
1079                })
1080            })
1081            .collect();
1082
1083        let shadow_depth_bind_groups: Vec<wgpu::BindGroup> = shadow_depth_uniform_buffers
1084            .iter()
1085            .enumerate()
1086            .map(|(i, buf)| {
1087                device.create_bind_group(&wgpu::BindGroupDescriptor {
1088                    label: Some(&format!("rustial_shadow_depth_bg_{i}")),
1089                    layout: &shadow_fill_extrusion_pipeline.uniform_bind_group_layout,
1090                    entries: &[wgpu::BindGroupEntry {
1091                        binding: 0,
1092                        resource: buf.as_entire_binding(),
1093                    }],
1094                })
1095            })
1096            .collect();
1097
1098        let shadow_receiver_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1099            label: Some("rustial_shadow_receiver_bg"),
1100            layout: &fill_extrusion_pipeline.shadow_bind_group_layout,
1101            entries: &[
1102                wgpu::BindGroupEntry {
1103                    binding: 0,
1104                    resource: shadow_params_buffer.as_entire_binding(),
1105                },
1106                wgpu::BindGroupEntry {
1107                    binding: 1,
1108                    resource: wgpu::BindingResource::TextureView(&shadow_map_views[0]),
1109                },
1110                wgpu::BindGroupEntry {
1111                    binding: 2,
1112                    resource: wgpu::BindingResource::TextureView(&shadow_map_views[1]),
1113                },
1114                wgpu::BindGroupEntry {
1115                    binding: 3,
1116                    resource: wgpu::BindingResource::Sampler(&shadow_comparison_sampler),
1117                },
1118            ],
1119        });
1120
1121        Self {
1122            tile_pipeline,
1123            terrain_pipeline,
1124            terrain_data_pipeline,
1125            hillshade_pipeline,
1126            grid_scalar_pipeline,
1127            grid_extrusion_pipeline,
1128            column_pipeline,
1129            vector_pipeline,
1130            fill_pipeline,
1131            fill_pattern_pipeline,
1132            fill_extrusion_pipeline,
1133            line_pipeline,
1134            line_pattern_pipeline,
1135            circle_pipeline,
1136            heatmap_pipeline,
1137            heatmap_colormap_pipeline,
1138            symbol_pipeline,
1139            model_pipeline,
1140            image_overlay_pipeline,
1141            sky_pipeline,
1142            uniform_buffer,
1143            uniform_bind_group,
1144            terrain_uniform_bind_group,
1145            terrain_data_uniform_bind_group,
1146            hillshade_uniform_bind_group,
1147            grid_scalar_uniform_bind_group,
1148            grid_extrusion_uniform_bind_group,
1149            column_uniform_bind_group,
1150            vector_uniform_bind_group,
1151            fill_extrusion_uniform_bind_group,
1152            model_uniform_bind_group,
1153            line_uniform_bind_group,
1154            circle_uniform_bind_group,
1155            heatmap_uniform_bind_group,
1156            heatmap_colormap_uniform_bind_group,
1157            heatmap_accum_texture,
1158            heatmap_accum_view,
1159            _heatmap_ramp_texture: heatmap_ramp_texture,
1160            heatmap_ramp_view,
1161            heatmap_colormap_textures_bind_group,
1162            symbol_uniform_bind_group,
1163            image_overlay_uniform_bind_group,
1164            sky_uniform_buffer,
1165            sky_uniform_bind_group,
1166            shadow_fill_extrusion_pipeline,
1167            shadow_model_pipeline,
1168            _shadow_map_textures: shadow_map_textures,
1169            shadow_map_views,
1170            _shadow_comparison_sampler: shadow_comparison_sampler,
1171            shadow_params_buffer,
1172            shadow_depth_uniform_buffers,
1173            shadow_depth_bind_groups,
1174            shadow_receiver_bind_group,
1175            sampler,
1176            grid_scalar_ramp_sampler,
1177            fill_pattern_sampler,
1178            depth_view,
1179            width: w,
1180            height: h,
1181            terrain_interaction_buffers,
1182            tile_atlas: TileAtlas::new(),
1183            hillshade_atlas: TileAtlas::new(),
1184            page_bind_groups: Vec::new(),
1185            page_terrain_bind_groups: Vec::new(),
1186            page_hillshade_bind_groups: Vec::new(),
1187            model_mesh_cache: std::collections::HashMap::new(),
1188            shared_terrain_grids: std::collections::HashMap::new(),
1189            height_texture_cache: std::collections::HashMap::new(),
1190            shared_column_mesh: None,
1191            grid_scalar_overlay_cache: std::collections::HashMap::new(),
1192            grid_extrusion_overlay_cache: std::collections::HashMap::new(),
1193            column_overlay_cache: std::collections::HashMap::new(),
1194            point_cloud_overlay_cache: std::collections::HashMap::new(),
1195            cached_tile_batches: Vec::new(),
1196            tile_batch_cache_key: None,
1197            cached_vector_batches: Vec::new(),
1198            vector_batch_cache_key: None,
1199            cached_fill_extrusion_batches: Vec::new(),
1200            cached_fill_batches: Vec::new(),
1201            cached_fill_pattern_batches: Vec::new(),
1202            cached_line_batches: Vec::new(),
1203            cached_line_pattern_batches: Vec::new(),
1204            cached_circle_batches: Vec::new(),
1205            cached_heatmap_batches: Vec::new(),
1206            cached_symbol_batch: None,
1207            symbol_atlas_texture: None,
1208            symbol_atlas_bind_group: None,
1209            symbol_glyph_atlas: rustial_engine::symbols::GlyphAtlas::new(),
1210            symbol_glyph_provider: Box::new(rustial_engine::symbols::ProceduralGlyphProvider::new()),
1211            terrain_tile_bind_cache: std::collections::HashMap::new(),
1212            terrain_data_dirty: TerrainDataDirtyState::default(),
1213            cached_model_transforms: None,
1214            cached_placeholder_batch: None,
1215            cached_image_overlay_batches: Vec::new(),
1216            visualization_perf_stats: VisualizationPerfStats::default(),
1217        }
1218    }
1219
1220    // -- Surface management -----------------------------------------------
1221
1222    /// Notify the renderer that the surface was resized.
1223    ///
1224    /// Recreates the depth texture.  Dimensions are clamped to at least
1225    /// 1x1 -- passing `0` is safe and produces a 1-pixel texture.
1226    pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) {
1227        self.width = width.max(1);
1228        self.height = height.max(1);
1229        self.depth_view = create_depth_texture(device, self.width, self.height);
1230        self.terrain_interaction_buffers
1231            .resize(device, self.width, self.height);
1232        self.terrain_data_dirty.dirty = true;
1233
1234        // Recreate heatmap accumulation texture at new size and rebuild the
1235        // colour-map bind group that references it.
1236        let (tex, view) = create_heatmap_accum_texture(device, self.width, self.height);
1237        self.heatmap_accum_texture = tex;
1238        self.heatmap_accum_view = view;
1239        self.heatmap_colormap_textures_bind_group = create_heatmap_colormap_bind_group(
1240            device,
1241            &self.heatmap_colormap_pipeline.textures_bind_group_layout,
1242            &self.heatmap_accum_view,
1243            &self.heatmap_ramp_view,
1244            &self.sampler,
1245        );
1246    }
1247
1248    /// Replace the glyph provider used for symbol text rendering.
1249    ///
1250    /// By default the renderer uses [`ProceduralGlyphProvider`] which
1251    /// produces placeholder glyphs.  Pass a
1252    /// [`ShapedGlyphProvider`](rustial_engine::symbols::text_shaper::ShapedGlyphProvider)
1253    /// (when the `text-shaping` feature is enabled) to render real
1254    /// font-based SDF text.
1255    pub fn set_glyph_provider(
1256        &mut self,
1257        provider: Box<dyn rustial_engine::symbols::GlyphProvider>,
1258    ) {
1259        self.symbol_glyph_provider = provider;
1260    }
1261
1262    // -- Tile upload ------------------------------------------------------
1263
1264    /// Enqueue a decoded tile image for deferred GPU upload.
1265    ///
1266    /// If the tile is already present this is a no-op.  Otherwise the slot
1267    /// is allocated and a deferred upload is enqueued.  The actual GPU
1268    /// `write_texture` happens when [`flush_atlas_uploads`] is called
1269    /// during the frame.  Page bind groups are rebuilt if a new atlas page
1270    /// was created.
1271    pub fn upload_tile(&mut self, device: &wgpu::Device, tile_id: TileId, image: &DecodedImage) {
1272        if self.tile_atlas.contains(&tile_id) {
1273            return;
1274        }
1275
1276        if let Err(err) = image.validate_rgba8() {
1277            log::warn!(
1278                "wgpu upload_tile: skipping invalid tile {:?}: {}",
1279                tile_id,
1280                err
1281            );
1282            return;
1283        }
1284
1285        self.tile_atlas.insert(device, tile_id, image);
1286        self.tile_batch_cache_key = None;
1287
1288        // Rebuild page bind groups if new pages were created.
1289        self.rebuild_page_bind_groups(device);
1290    }
1291
1292    /// Enqueue a prepared hillshade texture for deferred GPU upload.
1293    pub fn upload_hillshade(
1294        &mut self,
1295        device: &wgpu::Device,
1296        tile_id: TileId,
1297        image: &DecodedImage,
1298    ) {
1299        if self.hillshade_atlas.contains(&tile_id) {
1300            return;
1301        }
1302        if let Err(err) = image.validate_rgba8() {
1303            log::warn!(
1304                "wgpu upload_hillshade: skipping invalid tile {:?}: {}",
1305                tile_id,
1306                err
1307            );
1308            return;
1309        }
1310        self.hillshade_atlas.insert(device, tile_id, image);
1311        self.rebuild_page_bind_groups(device);
1312    }
1313
1314    /// Flush all pending atlas texture uploads to the GPU.
1315    ///
1316    /// Writes only the affected slot pixel rectangles (partial writes)
1317    /// rather than re-uploading full atlas pages.  Call once per frame
1318    /// after all [`upload_tile`](Self::upload_tile) /
1319    /// [`upload_hillshade`](Self::upload_hillshade) calls and before
1320    /// building batched geometry.
1321    pub fn flush_atlas_uploads(&mut self, queue: &wgpu::Queue) {
1322        self.tile_atlas.flush_uploads(queue);
1323        self.hillshade_atlas.flush_uploads(queue);
1324    }
1325
1326    fn get_or_create_shared_column_mesh(&mut self, device: &wgpu::Device) -> &SharedColumnMesh {
1327        if self.shared_column_mesh.is_none() {
1328            let (vertices, indices) = build_unit_column_mesh();
1329            let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1330                label: Some("column_unit_box_vb"),
1331                contents: bytemuck::cast_slice(&vertices),
1332                usage: wgpu::BufferUsages::VERTEX,
1333            });
1334            let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1335                label: Some("column_unit_box_ib"),
1336                contents: bytemuck::cast_slice(&indices),
1337                usage: wgpu::BufferUsages::INDEX,
1338            });
1339            self.shared_column_mesh = Some(SharedColumnMesh {
1340                vertex_buffer,
1341                index_buffer,
1342                index_count: indices.len() as u32,
1343            });
1344        }
1345        self.shared_column_mesh.as_ref().expect("column mesh")
1346    }
1347
1348    fn get_or_create_grid_scalar_overlay(
1349        &mut self,
1350        device: &wgpu::Device,
1351        queue: &wgpu::Queue,
1352        overlay: &VisualizationOverlay,
1353        state: &MapState,
1354        scene_origin: DVec3,
1355        terrain_fingerprint: u64,
1356    ) -> Option<()> {
1357        let VisualizationOverlay::GridScalar {
1358            layer_id,
1359            grid,
1360            field,
1361            ramp,
1362        } = overlay
1363        else {
1364            return None;
1365        };
1366
1367        let origin_key = [
1368            (scene_origin.x * 100.0) as i64,
1369            (scene_origin.y * 100.0) as i64,
1370            (scene_origin.z * 100.0) as i64,
1371        ];
1372        let ramp_fingerprint = grid_scalar_ramp_fingerprint(ramp);
1373        let grid_fingerprint = grid_extrusion_grid_fingerprint(grid);
1374        let projection = state.camera().projection();
1375        let (vertices, indices) = build_grid_scalar_geometry(grid, state, scene_origin);
1376
1377        let recreate = if let Some(cached) = self.grid_scalar_overlay_cache.get(layer_id) {
1378            cached.generation != field.generation
1379                || cached.ramp_fingerprint != ramp_fingerprint
1380                || cached.grid_fingerprint != grid_fingerprint
1381                || cached.projection != projection
1382                || cached.index_count as usize != indices.len()
1383                || cached.vertex_count != vertices.len()
1384        } else {
1385            true
1386        };
1387
1388        if recreate {
1389            self.visualization_perf_stats.grid_scalar_rebuilds += 1;
1390            let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1391                label: Some(&format!("grid_scalar_vb_{layer_id}")),
1392                contents: bytemuck::cast_slice(&vertices),
1393                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1394            });
1395            let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1396                label: Some(&format!("grid_scalar_ib_{layer_id}")),
1397                contents: bytemuck::cast_slice(&indices),
1398                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
1399            });
1400            let scalar_texture = create_grid_scalar_texture(device, queue, field);
1401            let scalar_view = scalar_texture.create_view(&wgpu::TextureViewDescriptor::default());
1402            let ramp_texture = create_grid_scalar_ramp_texture(device, queue, ramp);
1403            let ramp_view = ramp_texture.create_view(&wgpu::TextureViewDescriptor::default());
1404            let uniform = build_grid_scalar_uniform(grid, field, state, scene_origin, 1.0);
1405            let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1406                label: Some(&format!("grid_scalar_uniform_{layer_id}")),
1407                contents: bytemuck::bytes_of(&uniform),
1408                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1409            });
1410            let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1411                label: Some(&format!("grid_scalar_bg_{layer_id}")),
1412                layout: &self.grid_scalar_pipeline.overlay_bind_group_layout,
1413                entries: &[
1414                    wgpu::BindGroupEntry {
1415                        binding: 0,
1416                        resource: uniform_buffer.as_entire_binding(),
1417                    },
1418                    wgpu::BindGroupEntry {
1419                        binding: 1,
1420                        resource: wgpu::BindingResource::TextureView(&scalar_view),
1421                    },
1422                    wgpu::BindGroupEntry {
1423                        binding: 2,
1424                        resource: wgpu::BindingResource::TextureView(&ramp_view),
1425                    },
1426                    wgpu::BindGroupEntry {
1427                        binding: 3,
1428                        resource: wgpu::BindingResource::Sampler(&self.grid_scalar_ramp_sampler),
1429                    },
1430                ],
1431            });
1432            self.grid_scalar_overlay_cache.insert(
1433                *layer_id,
1434                CachedGridScalarOverlay {
1435                    vertex_buffer,
1436                    index_buffer,
1437                    index_count: indices.len() as u32,
1438                    vertex_count: vertices.len(),
1439                    uniform_buffer,
1440                    bind_group,
1441                    scalar_texture,
1442                    ramp_texture,
1443                    generation: field.generation,
1444                    value_generation: field.value_generation,
1445                    ramp_fingerprint,
1446                    grid_fingerprint,
1447                    terrain_fingerprint,
1448                    projection,
1449                    origin_key,
1450                },
1451            );
1452            return Some(());
1453        }
1454
1455        if let Some(cached) = self.grid_scalar_overlay_cache.get_mut(layer_id) {
1456            let uniform = build_grid_scalar_uniform(grid, field, state, scene_origin, 1.0);
1457            if cached.value_generation != field.value_generation {
1458                self.visualization_perf_stats.grid_scalar_value_updates += 1;
1459                write_grid_scalar_texture(queue, &cached.scalar_texture, field);
1460                cached.value_generation = field.value_generation;
1461                queue.write_buffer(&cached.uniform_buffer, 0, bytemuck::bytes_of(&uniform));
1462            }
1463            if cached.origin_key != origin_key || cached.terrain_fingerprint != terrain_fingerprint
1464            {
1465                queue.write_buffer(
1466                    &cached.vertex_buffer,
1467                    0,
1468                    bytemuck::cast_slice::<GridScalarVertex, u8>(&vertices),
1469                );
1470                queue.write_buffer(&cached.uniform_buffer, 0, bytemuck::bytes_of(&uniform));
1471                cached.origin_key = origin_key;
1472                cached.terrain_fingerprint = terrain_fingerprint;
1473            }
1474        }
1475
1476        Some(())
1477    }
1478
1479    fn get_or_create_point_cloud_overlay(
1480        &mut self,
1481        device: &wgpu::Device,
1482        queue: &wgpu::Queue,
1483        overlay: &VisualizationOverlay,
1484        state: &MapState,
1485        scene_origin: DVec3,
1486    ) -> Option<()> {
1487        let VisualizationOverlay::Points {
1488            layer_id,
1489            points,
1490            ramp,
1491        } = overlay
1492        else {
1493            return None;
1494        };
1495
1496        let origin_key = [
1497            (scene_origin.x * 100.0) as i64,
1498            (scene_origin.y * 100.0) as i64,
1499            (scene_origin.z * 100.0) as i64,
1500        ];
1501        let points_fingerprint = point_set_fingerprint(points);
1502        let ramp_fingerprint = grid_scalar_ramp_fingerprint(ramp);
1503        let instances = build_point_instances(points, ramp, state, scene_origin);
1504
1505        let needs_rebuild = if let Some(cached) = self.point_cloud_overlay_cache.get(layer_id) {
1506            cached.generation != points.generation
1507                || cached.ramp_fingerprint != ramp_fingerprint
1508                || cached.instance_count as usize != instances.len()
1509        } else {
1510            true
1511        };
1512
1513        if needs_rebuild {
1514            self.visualization_perf_stats.point_cloud_rebuilds += 1;
1515            let instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1516                label: Some(&format!("point_cloud_instances_{layer_id}")),
1517                contents: bytemuck::cast_slice(&instances),
1518                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1519            });
1520            self.point_cloud_overlay_cache.insert(
1521                *layer_id,
1522                CachedPointCloudOverlay {
1523                    instance_buffer,
1524                    instance_count: instances.len() as u32,
1525                    generation: points.generation,
1526                    origin_key,
1527                    points_fingerprint,
1528                    ramp_fingerprint,
1529                    instance_data: instances,
1530                },
1531            );
1532            return Some(());
1533        }
1534
1535        if let Some(cached) = self.point_cloud_overlay_cache.get_mut(layer_id) {
1536            let ranges = diff_column_instance_ranges(&cached.instance_data, &instances);
1537            if !ranges.is_empty() {
1538                self.visualization_perf_stats.point_cloud_partial_writes += 1;
1539                self.visualization_perf_stats
1540                    .point_cloud_partial_write_ranges += ranges.len() as u32;
1541            }
1542            for range in ranges {
1543                let start = range.start;
1544                let end = range.end;
1545                let byte_offset =
1546                    (start * std::mem::size_of::<ColumnInstanceData>()) as wgpu::BufferAddress;
1547                queue.write_buffer(
1548                    &cached.instance_buffer,
1549                    byte_offset,
1550                    bytemuck::cast_slice::<ColumnInstanceData, u8>(&instances[start..end]),
1551                );
1552            }
1553            cached.instance_data = instances;
1554            cached.origin_key = origin_key;
1555            cached.points_fingerprint = points_fingerprint;
1556        }
1557
1558        Some(())
1559    }
1560
1561    fn get_or_create_grid_extrusion_overlay(
1562        &mut self,
1563        device: &wgpu::Device,
1564        queue: &wgpu::Queue,
1565        overlay: &VisualizationOverlay,
1566        state: &MapState,
1567        scene_origin: DVec3,
1568        terrain_fingerprint: u64,
1569    ) -> Option<()> {
1570        let VisualizationOverlay::GridExtrusion {
1571            layer_id,
1572            grid,
1573            field,
1574            ramp,
1575            params,
1576        } = overlay
1577        else {
1578            return None;
1579        };
1580
1581        let origin_key = [
1582            (scene_origin.x * 100.0) as i64,
1583            (scene_origin.y * 100.0) as i64,
1584            (scene_origin.z * 100.0) as i64,
1585        ];
1586        let grid_fingerprint = grid_extrusion_grid_fingerprint(grid);
1587        let params_fingerprint = grid_extrusion_params_fingerprint(params);
1588        let ramp_fingerprint = grid_scalar_ramp_fingerprint(ramp);
1589
1590        let (vertices, indices) =
1591            build_grid_extrusion_geometry(grid, field, ramp, params, state, scene_origin);
1592
1593        let needs_rebuild = if let Some(cached) = self.grid_extrusion_overlay_cache.get(layer_id) {
1594            cached.generation != field.generation
1595                || cached.grid_fingerprint != grid_fingerprint
1596                || cached.params_fingerprint != params_fingerprint
1597                || cached.ramp_fingerprint != ramp_fingerprint
1598                || cached.terrain_fingerprint != terrain_fingerprint
1599                || cached.index_count as usize != indices.len()
1600                || cached.vertex_count != vertices.len()
1601        } else {
1602            true
1603        };
1604
1605        if needs_rebuild {
1606            self.visualization_perf_stats.grid_extrusion_rebuilds += 1;
1607            let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1608                label: Some(&format!("grid_extrusion_vb_{layer_id}")),
1609                contents: bytemuck::cast_slice(&vertices),
1610                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1611            });
1612            let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1613                label: Some(&format!("grid_extrusion_ib_{layer_id}")),
1614                contents: bytemuck::cast_slice(&indices),
1615                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
1616            });
1617
1618            self.grid_extrusion_overlay_cache.insert(
1619                *layer_id,
1620                CachedGridExtrusionOverlay {
1621                    vertex_buffer,
1622                    index_buffer,
1623                    index_count: indices.len() as u32,
1624                    vertex_count: vertices.len(),
1625                    generation: field.generation,
1626                    value_generation: field.value_generation,
1627                    origin_key,
1628                    grid_fingerprint,
1629                    params_fingerprint,
1630                    ramp_fingerprint,
1631                    terrain_fingerprint,
1632                },
1633            );
1634            return Some(());
1635        }
1636
1637        if let Some(cached) = self.grid_extrusion_overlay_cache.get_mut(layer_id) {
1638            if cached.value_generation != field.value_generation || cached.origin_key != origin_key
1639            {
1640                self.visualization_perf_stats.grid_extrusion_value_updates += 1;
1641                let vertex_bytes = bytemuck::cast_slice::<GridExtrusionVertex, u8>(&vertices);
1642                queue.write_buffer(&cached.vertex_buffer, 0, vertex_bytes);
1643            }
1644            cached.value_generation = field.value_generation;
1645            cached.origin_key = origin_key;
1646            cached.terrain_fingerprint = terrain_fingerprint;
1647        }
1648        Some(())
1649    }
1650
1651    fn get_or_create_column_overlay(
1652        &mut self,
1653        device: &wgpu::Device,
1654        queue: &wgpu::Queue,
1655        overlay: &VisualizationOverlay,
1656        state: &MapState,
1657        scene_origin: DVec3,
1658    ) -> Option<()> {
1659        let VisualizationOverlay::Columns {
1660            layer_id,
1661            columns,
1662            ramp,
1663        } = overlay
1664        else {
1665            return None;
1666        };
1667
1668        let origin_key = [
1669            (scene_origin.x * 100.0) as i64,
1670            (scene_origin.y * 100.0) as i64,
1671            (scene_origin.z * 100.0) as i64,
1672        ];
1673        let columns_fingerprint = column_set_fingerprint(columns);
1674        let ramp_fingerprint = grid_scalar_ramp_fingerprint(ramp);
1675        let instances = build_column_instances(columns, ramp, state, scene_origin);
1676
1677        let needs_rebuild = if let Some(cached) = self.column_overlay_cache.get(layer_id) {
1678            cached.generation != columns.generation
1679                || cached.ramp_fingerprint != ramp_fingerprint
1680                || cached.instance_count as usize != instances.len()
1681        } else {
1682            true
1683        };
1684
1685        if needs_rebuild {
1686            self.visualization_perf_stats.column_rebuilds += 1;
1687            let instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1688                label: Some(&format!("column_instances_{layer_id}")),
1689                contents: bytemuck::cast_slice(&instances),
1690                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1691            });
1692            self.column_overlay_cache.insert(
1693                *layer_id,
1694                CachedColumnOverlay {
1695                    instance_buffer,
1696                    instance_count: instances.len() as u32,
1697                    generation: columns.generation,
1698                    origin_key,
1699                    columns_fingerprint,
1700                    ramp_fingerprint,
1701                    instance_data: instances,
1702                },
1703            );
1704            return Some(());
1705        }
1706
1707        if let Some(cached) = self.column_overlay_cache.get_mut(layer_id) {
1708            let ranges = diff_column_instance_ranges(&cached.instance_data, &instances);
1709            if !ranges.is_empty() {
1710                self.visualization_perf_stats.column_partial_writes += 1;
1711                self.visualization_perf_stats.column_partial_write_ranges += ranges.len() as u32;
1712            }
1713            for range in ranges {
1714                let start = range.start;
1715                let end = range.end;
1716                let byte_offset =
1717                    (start * std::mem::size_of::<ColumnInstanceData>()) as wgpu::BufferAddress;
1718                queue.write_buffer(
1719                    &cached.instance_buffer,
1720                    byte_offset,
1721                    bytemuck::cast_slice::<ColumnInstanceData, u8>(&instances[start..end]),
1722                );
1723            }
1724            cached.instance_data = instances;
1725            cached.origin_key = origin_key;
1726            cached.columns_fingerprint = columns_fingerprint;
1727        }
1728
1729        Some(())
1730    }
1731
1732    // -- Render entry points ----------------------------------------------
1733
1734    /// Render one frame of the map (tiles only, no vectors or models).
1735    ///
1736    /// Convenience wrapper around [`render_full`](Self::render_full) that
1737    /// passes empty slices for `vector_meshes` and `model_instances`.
1738    pub fn render(
1739        &mut self,
1740        state: &MapState,
1741        device: &wgpu::Device,
1742        queue: &wgpu::Queue,
1743        color_view: &wgpu::TextureView,
1744        visible_tiles: &[VisibleTile],
1745    ) {
1746        let clear_color = state.computed_fog().clear_color;
1747        self.render_full(&RenderParams {
1748            state,
1749            device,
1750            queue,
1751            color_view,
1752            visible_tiles,
1753            vector_meshes: &[],
1754            model_instances: &[],
1755            clear_color,
1756        });
1757    }
1758
1759    /// Render one full frame: tiles (or terrain), vectors, and models.
1760    ///
1761    /// See the [module-level frame lifecycle](self) for the step-by-step
1762    /// breakdown.
1763    ///
1764    /// ## Batching strategy
1765    ///
1766    /// 1. **Tiles / terrain** -- all quads / meshes sharing the same atlas
1767    ///    page are merged into a single vertex + index buffer and drawn
1768    ///    with one `draw_indexed` call per page.
1769    /// 2. **Vectors** -- each vector layer produces one draw call (already
1770    ///    pre-merged by the engine tessellator).
1771    /// 3. **Models** -- mesh GPU buffers are cached by identity fingerprint;
1772    ///    per-instance transform uniform + bind group are allocated
1773    ///    per-frame (future: dynamic UBO).
1774    pub fn render_full(&mut self, params: &RenderParams<'_>) {
1775        self.visualization_perf_stats = VisualizationPerfStats::default();
1776        // ?? 1. Camera-relative view-projection ??????????????????????????
1777        let scene_camera_origin = params.state.scene_world_origin();
1778        let frame = params.state.frame_output();
1779        let visualization = &frame.visualization;
1780        let view = params.state.camera().view_matrix(DVec3::ZERO);
1781        let proj = params.state.camera().projection_matrix();
1782        let vp = proj * view;
1783
1784        // Fog: read pre-computed fog from the engine (centralised,
1785        // replaces the duplicated fog math that was previously inline here).
1786        let cam = params.state.camera();
1787        let eye = cam.eye_offset();
1788        let fog = params.state.computed_fog();
1789        let clear_color = fog.clear_color;
1790
1791        let mut uniform = ViewProjUniform::from_dmat4(&vp);
1792        uniform.fog_color = fog.fog_color;
1793        uniform.eye_pos = [eye.x as f32, eye.y as f32, eye.z as f32, 0.0];
1794        uniform.fog_params = [fog.fog_start, fog.fog_end, fog.fog_density, 0.0];
1795        if let Some(hillshade) = params.state.hillshade() {
1796            uniform.hillshade_highlight = hillshade.highlight_color;
1797            uniform.hillshade_shadow = hillshade.shadow_color;
1798            uniform.hillshade_accent = hillshade.accent_color;
1799            uniform.hillshade_light = [
1800                hillshade.illumination_direction,
1801                hillshade.illumination_altitude,
1802                hillshade.exaggeration,
1803                hillshade.opacity,
1804            ];
1805        }
1806
1807        // Lighting: read pre-computed lighting from the engine.
1808        let lighting = params.state.computed_lighting();
1809        uniform.ambient_color = [
1810            lighting.ambient_color[0],
1811            lighting.ambient_color[1],
1812            lighting.ambient_color[2],
1813            lighting.lighting_enabled,
1814        ];
1815        uniform.directional_dir = [
1816            lighting.directional_dir[0],
1817            lighting.directional_dir[1],
1818            lighting.directional_dir[2],
1819            0.0,
1820        ];
1821        uniform.directional_color = [
1822            lighting.directional_color[0],
1823            lighting.directional_color[1],
1824            lighting.directional_color[2],
1825            0.0,
1826        ];
1827
1828        params
1829            .queue
1830            .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform));
1831
1832        // Upload sky uniform (separate buffer, different layout).
1833        {
1834            let sky = params.state.computed_sky();
1835            let inv_vp = vp.inverse();
1836            let inv_vp_f32 = inv_vp.as_mat4();
1837            let sky_uniform = SkyUniform {
1838                inv_view_proj: inv_vp_f32.to_cols_array_2d(),
1839                sun_dir: [
1840                    sky.sun_direction[0],
1841                    sky.sun_direction[1],
1842                    sky.sun_direction[2],
1843                    sky.sun_intensity,
1844                ],
1845                rayleigh_tint: [
1846                    sky.rayleigh_color[0],
1847                    sky.rayleigh_color[1],
1848                    sky.rayleigh_color[2],
1849                    sky.sky_enabled,
1850                ],
1851                mie_tint: [sky.mie_color[0], sky.mie_color[1], sky.mie_color[2], 1.0],
1852            };
1853            params.queue.write_buffer(
1854                &self.sky_uniform_buffer,
1855                0,
1856                bytemuck::bytes_of(&sky_uniform),
1857            );
1858        }
1859
1860        // Upload shadow params uniform.
1861        {
1862            let shadow = params.state.computed_shadow();
1863            let shadow_uniform = ShadowParamsUniform {
1864                light_matrix_0: shadow.light_matrices[0],
1865                light_matrix_1: shadow.light_matrices[1],
1866                shadow_config: [
1867                    shadow.intensity,
1868                    shadow.texel_size,
1869                    shadow.normal_offset,
1870                    shadow.cascade_split,
1871                ],
1872                shadow_dir: [
1873                    lighting.directional_dir[0],
1874                    lighting.directional_dir[1],
1875                    lighting.directional_dir[2],
1876                    if shadow.enabled { 1.0 } else { 0.0 },
1877                ],
1878            };
1879            params.queue.write_buffer(
1880                &self.shadow_params_buffer,
1881                0,
1882                bytemuck::bytes_of(&shadow_uniform),
1883            );
1884        }
1885
1886        // ?? 2. Enqueue new tile textures into the atlas ??????????????????
1887        for vt in params.visible_tiles {
1888            if let Some(TileData::Raster(ref img)) = vt.data {
1889                self.upload_tile(params.device, vt.actual, img);
1890            }
1891        }
1892        for raster in params.state.hillshade_rasters() {
1893            self.upload_hillshade(params.device, raster.tile, &raster.image);
1894        }
1895
1896        // ?? 2b. Flush deferred atlas uploads (partial texture writes) ??
1897        self.flush_atlas_uploads(params.queue);
1898
1899        // ?? 3. Mark visible + terrain tiles as used (prevents eviction) ?
1900        for vt in params.visible_tiles {
1901            self.tile_atlas.mark_used(&vt.actual);
1902        }
1903        let terrain_meshes = params.state.terrain_meshes();
1904        for mesh in terrain_meshes {
1905            if let Some(actual_tile) = find_terrain_texture_actual(mesh.tile, params.visible_tiles)
1906            {
1907                self.tile_atlas.mark_used(&actual_tile);
1908            }
1909        }
1910        for raster in params.state.hillshade_rasters() {
1911            self.hillshade_atlas.mark_used(&raster.tile);
1912        }
1913
1914        let use_shared_terrain = !terrain_meshes.is_empty()
1915            && matches!(
1916                params.state.camera().projection(),
1917                rustial_engine::CameraProjection::WebMercator
1918                    | rustial_engine::CameraProjection::Equirectangular
1919            )
1920            && terrain_meshes
1921                .iter()
1922                .all(|mesh| mesh.elevation_texture.is_some());
1923
1924        let materialized_terrain_meshes: Vec<TerrainMeshData> = if use_shared_terrain {
1925            Vec::new()
1926        } else {
1927            terrain_meshes
1928                .iter()
1929                .map(|mesh| {
1930                    materialize_terrain_mesh(
1931                        mesh,
1932                        params.state.camera().projection(),
1933                        rustial_engine::skirt_height(
1934                            mesh.tile.zoom,
1935                            mesh.vertical_exaggeration as f64,
1936                        ),
1937                    )
1938                })
1939                .collect()
1940        };
1941
1942        // ?? 4. Cache model mesh GPU buffers ?????????????????????????????
1943        if !params.model_instances.is_empty() {
1944            self.cache_model_meshes(params.device, params.model_instances);
1945            self.cache_model_transforms(
1946                params.device,
1947                params.model_instances,
1948                scene_camera_origin,
1949                params.state,
1950            );
1951        } else {
1952            self.cached_model_transforms = None;
1953        }
1954
1955        // ?? 5. Build batched geometry (tiles, terrain, vectors) ??????
1956        let tile_batch_key = TileBatchCacheKey::new(
1957            params.visible_tiles,
1958            scene_camera_origin,
1959            params.state.camera().projection(),
1960        );
1961        if self.tile_batch_cache_key.as_ref() != Some(&tile_batch_key) {
1962            self.cached_tile_batches = build_tile_batches(
1963                params.device,
1964                params.visible_tiles,
1965                &self.tile_atlas,
1966                scene_camera_origin,
1967                params.state.camera().projection(),
1968            );
1969            self.tile_batch_cache_key = Some(tile_batch_key);
1970        }
1971
1972        let terrain_batches = if !use_shared_terrain && !materialized_terrain_meshes.is_empty() {
1973            build_terrain_batches(
1974                params.device,
1975                &materialized_terrain_meshes,
1976                &self.tile_atlas,
1977                scene_camera_origin,
1978                params.visible_tiles,
1979            )
1980        } else {
1981            Vec::new()
1982        };
1983
1984        let hillshade_batches = if !materialized_terrain_meshes.is_empty()
1985            && !params.state.hillshade_rasters().is_empty()
1986        {
1987            build_hillshade_batches(
1988                params.device,
1989                &materialized_terrain_meshes,
1990                params.state.hillshade_rasters(),
1991                &self.hillshade_atlas,
1992                scene_camera_origin,
1993            )
1994        } else {
1995            Vec::new()
1996        };
1997
1998        let vector_batch_key = VectorBatchCacheKey::new(params.vector_meshes, scene_camera_origin);
1999        if self.vector_batch_cache_key.as_ref() != Some(&vector_batch_key) {
2000            self.cached_vector_batches = params
2001                .vector_meshes
2002                .iter()
2003                .filter(|mesh| mesh.render_mode == VectorRenderMode::Generic)
2004                .map(|mesh| build_vector_batch(params.device, mesh, scene_camera_origin))
2005                .collect();
2006            self.cached_fill_batches = params
2007                .vector_meshes
2008                .iter()
2009                .filter(|mesh| {
2010                    mesh.render_mode == VectorRenderMode::Fill && mesh.fill_pattern.is_none()
2011                })
2012                .map(|mesh| {
2013                    build_fill_batch(
2014                        params.device,
2015                        mesh,
2016                        scene_camera_origin,
2017                        &self.uniform_buffer,
2018                        &self.fill_pipeline.uniform_bind_group_layout,
2019                    )
2020                })
2021                .collect();
2022            self.cached_fill_pattern_batches = params
2023                .vector_meshes
2024                .iter()
2025                .filter(|mesh| {
2026                    mesh.render_mode == VectorRenderMode::Fill && mesh.fill_pattern.is_some()
2027                })
2028                .map(|mesh| {
2029                    build_fill_pattern_batch(
2030                        params.device,
2031                        params.queue,
2032                        mesh,
2033                        scene_camera_origin,
2034                        &self.uniform_buffer,
2035                        &self.fill_pattern_pipeline.uniform_bind_group_layout,
2036                        &self.fill_pattern_pipeline.texture_bind_group_layout,
2037                        &self.fill_pattern_sampler,
2038                    )
2039                })
2040                .collect();
2041            self.cached_fill_extrusion_batches = params
2042                .vector_meshes
2043                .iter()
2044                .filter(|mesh| mesh.render_mode == VectorRenderMode::FillExtrusion)
2045                .map(|mesh| build_fill_extrusion_batch(params.device, mesh, scene_camera_origin))
2046                .collect();
2047            self.cached_line_batches = params
2048                .vector_meshes
2049                .iter()
2050                .filter(|mesh| {
2051                    mesh.render_mode == VectorRenderMode::Line && mesh.line_pattern.is_none()
2052                })
2053                .map(|mesh| build_line_batch(params.device, mesh, scene_camera_origin))
2054                .collect();
2055            self.cached_line_pattern_batches = params
2056                .vector_meshes
2057                .iter()
2058                .filter(|mesh| {
2059                    mesh.render_mode == VectorRenderMode::Line && mesh.line_pattern.is_some()
2060                })
2061                .map(|mesh| {
2062                    build_line_pattern_batch(
2063                        params.device,
2064                        params.queue,
2065                        mesh,
2066                        scene_camera_origin,
2067                        &self.uniform_buffer,
2068                        &self.line_pattern_pipeline.uniform_bind_group_layout,
2069                        &self.line_pattern_pipeline.texture_bind_group_layout,
2070                        &self.fill_pattern_sampler,
2071                    )
2072                })
2073                .collect();
2074            self.cached_circle_batches = params
2075                .vector_meshes
2076                .iter()
2077                .filter(|mesh| mesh.render_mode == VectorRenderMode::Circle)
2078                .map(|mesh| build_circle_batch(params.device, mesh, scene_camera_origin))
2079                .collect();
2080            self.cached_heatmap_batches = params
2081                .vector_meshes
2082                .iter()
2083                .filter(|mesh| mesh.render_mode == VectorRenderMode::Heatmap)
2084                .map(|mesh| build_heatmap_batch(params.device, mesh, scene_camera_origin))
2085                .collect();
2086            self.vector_batch_cache_key = Some(vector_batch_key);
2087        }
2088
2089        // Build symbol batch from placed symbols.
2090        {
2091            let symbols = &frame.symbols;
2092            if !symbols.is_empty() {
2093                // Request glyphs for all visible symbols.
2094                self.symbol_glyph_atlas = rustial_engine::symbols::GlyphAtlas::new();
2095                for symbol in symbols.iter() {
2096                    if symbol.visible && symbol.opacity > 0.0 {
2097                        if let Some(text) = &symbol.text {
2098                            self.symbol_glyph_atlas
2099                                .request_text(&symbol.font_stack, text);
2100                        }
2101                    }
2102                }
2103                // Rasterize requested glyphs.
2104                self.symbol_glyph_atlas
2105                    .load_requested(&*self.symbol_glyph_provider);
2106
2107                let dims = self.symbol_glyph_atlas.dimensions();
2108                if dims[0] > 0 && dims[1] > 0 {
2109                    // Upload atlas texture.
2110                    let tex = params.device.create_texture(&wgpu::TextureDescriptor {
2111                        label: Some("symbol_atlas_tex"),
2112                        size: wgpu::Extent3d {
2113                            width: dims[0] as u32,
2114                            height: dims[1] as u32,
2115                            depth_or_array_layers: 1,
2116                        },
2117                        mip_level_count: 1,
2118                        sample_count: 1,
2119                        dimension: wgpu::TextureDimension::D2,
2120                        format: wgpu::TextureFormat::R8Unorm,
2121                        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
2122                        view_formats: &[],
2123                    });
2124                    params.queue.write_texture(
2125                        wgpu::TexelCopyTextureInfo {
2126                            texture: &tex,
2127                            mip_level: 0,
2128                            origin: wgpu::Origin3d::ZERO,
2129                            aspect: wgpu::TextureAspect::All,
2130                        },
2131                        self.symbol_glyph_atlas.alpha(),
2132                        wgpu::TexelCopyBufferLayout {
2133                            offset: 0,
2134                            bytes_per_row: Some(dims[0] as u32),
2135                            rows_per_image: Some(dims[1] as u32),
2136                        },
2137                        wgpu::Extent3d {
2138                            width: dims[0] as u32,
2139                            height: dims[1] as u32,
2140                            depth_or_array_layers: 1,
2141                        },
2142                    );
2143                    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
2144                    let atlas_bg = params.device.create_bind_group(&wgpu::BindGroupDescriptor {
2145                        label: Some("symbol_atlas_bg"),
2146                        layout: &self.symbol_pipeline.atlas_bind_group_layout,
2147                        entries: &[
2148                            wgpu::BindGroupEntry {
2149                                binding: 0,
2150                                resource: wgpu::BindingResource::TextureView(&view),
2151                            },
2152                            wgpu::BindGroupEntry {
2153                                binding: 1,
2154                                resource: wgpu::BindingResource::Sampler(&self.sampler),
2155                            },
2156                        ],
2157                    });
2158                    self.symbol_atlas_texture = Some((tex, view));
2159                    self.symbol_atlas_bind_group = Some(atlas_bg);
2160                }
2161
2162                // Lay out glyph positions using atlas metrics.
2163                let mut laid_out_symbols: Vec<rustial_engine::symbols::PlacedSymbol> =
2164                    symbols.to_vec();
2165                rustial_engine::symbols::layout_symbol_glyphs(
2166                    &mut laid_out_symbols,
2167                    &self.symbol_glyph_atlas,
2168                );
2169
2170                // Build the symbol geometry batch.
2171                self.cached_symbol_batch = build_symbol_batch(
2172                    params.device,
2173                    &laid_out_symbols,
2174                    &self.symbol_glyph_atlas,
2175                    scene_camera_origin,
2176                    self.symbol_glyph_atlas.render_em_px(),
2177                );
2178            } else {
2179                self.cached_symbol_batch = None;
2180                self.symbol_atlas_bind_group = None;
2181                self.symbol_atlas_texture = None;
2182            }
2183        }
2184
2185        // Build placeholder quad batch for loading tiles.
2186        self.cached_placeholder_batch = build_placeholder_batches(
2187            params.device,
2188            &frame.placeholders,
2189            params.state.placeholder_style(),
2190            scene_camera_origin,
2191        );
2192
2193        // Build image overlay batches.
2194        self.build_image_overlay_batches(
2195            params.device,
2196            params.queue,
2197            &frame.image_overlays,
2198            scene_camera_origin,
2199        );
2200
2201        // Ensure shared terrain resources are prepared before render pass.
2202        if use_shared_terrain {
2203            for mesh in terrain_meshes {
2204                self.get_or_create_shared_grid(params.device, mesh.grid_resolution);
2205                let scene_origin = scene_camera_origin;
2206                // Prepare terrain tile bind groups for all three pipeline kinds.
2207                self.get_or_create_terrain_tile_bind(
2208                    params.device,
2209                    params.queue,
2210                    mesh,
2211                    params.state,
2212                    scene_origin,
2213                    TerrainPipelineKind::Terrain,
2214                );
2215                self.get_or_create_terrain_tile_bind(
2216                    params.device,
2217                    params.queue,
2218                    mesh,
2219                    params.state,
2220                    scene_origin,
2221                    TerrainPipelineKind::TerrainData,
2222                );
2223                self.get_or_create_terrain_tile_bind(
2224                    params.device,
2225                    params.queue,
2226                    mesh,
2227                    params.state,
2228                    scene_origin,
2229                    TerrainPipelineKind::Hillshade,
2230                );
2231            }
2232        }
2233
2234        let grid_scalar_overlays: Vec<_> = visualization
2235            .iter()
2236            .filter(|overlay| matches!(overlay, VisualizationOverlay::GridScalar { .. }))
2237            .collect();
2238        let visible_grid_scalar_overlays: Vec<_> = grid_scalar_overlays
2239            .iter()
2240            .copied()
2241            .filter(|overlay| {
2242                visualization_overlay_intersects_scene_viewport(overlay, params.state)
2243            })
2244            .collect();
2245        let grid_extrusion_overlays: Vec<_> = visualization
2246            .iter()
2247            .filter(|overlay| matches!(overlay, VisualizationOverlay::GridExtrusion { .. }))
2248            .collect();
2249        let visible_grid_extrusion_overlays: Vec<_> = grid_extrusion_overlays
2250            .iter()
2251            .copied()
2252            .filter(|overlay| {
2253                visualization_overlay_intersects_scene_viewport(overlay, params.state)
2254            })
2255            .collect();
2256        let column_overlays: Vec<_> = visualization
2257            .iter()
2258            .filter(|overlay| matches!(overlay, VisualizationOverlay::Columns { .. }))
2259            .collect();
2260        let visible_column_overlays: Vec<_> = column_overlays
2261            .iter()
2262            .copied()
2263            .filter(|overlay| {
2264                visualization_overlay_intersects_scene_viewport(overlay, params.state)
2265            })
2266            .collect();
2267        let point_cloud_overlays: Vec<_> = visualization
2268            .iter()
2269            .filter(|overlay| matches!(overlay, VisualizationOverlay::Points { .. }))
2270            .collect();
2271        let visible_point_cloud_overlays: Vec<_> = point_cloud_overlays
2272            .iter()
2273            .copied()
2274            .filter(|overlay| {
2275                visualization_overlay_intersects_scene_viewport(overlay, params.state)
2276            })
2277            .collect();
2278        let terrain_fingerprint = TerrainDataDirtyState::terrain_fingerprint(terrain_meshes);
2279        if !visible_grid_scalar_overlays.is_empty() {
2280            for overlay in &visible_grid_scalar_overlays {
2281                self.get_or_create_grid_scalar_overlay(
2282                    params.device,
2283                    params.queue,
2284                    overlay,
2285                    params.state,
2286                    scene_camera_origin,
2287                    terrain_fingerprint,
2288                );
2289            }
2290        }
2291        if !visible_column_overlays.is_empty() {
2292            self.get_or_create_shared_column_mesh(params.device);
2293            for overlay in &visible_column_overlays {
2294                self.get_or_create_column_overlay(
2295                    params.device,
2296                    params.queue,
2297                    overlay,
2298                    params.state,
2299                    scene_camera_origin,
2300                );
2301            }
2302        }
2303        if !visible_point_cloud_overlays.is_empty() {
2304            self.get_or_create_shared_column_mesh(params.device);
2305            for overlay in &visible_point_cloud_overlays {
2306                self.get_or_create_point_cloud_overlay(
2307                    params.device,
2308                    params.queue,
2309                    overlay,
2310                    params.state,
2311                    scene_camera_origin,
2312                );
2313            }
2314        }
2315        if !visible_grid_extrusion_overlays.is_empty() {
2316            for overlay in &visible_grid_extrusion_overlays {
2317                self.get_or_create_grid_extrusion_overlay(
2318                    params.device,
2319                    params.queue,
2320                    overlay,
2321                    params.state,
2322                    scene_camera_origin,
2323                    terrain_fingerprint,
2324                );
2325            }
2326        }
2327
2328        // ?? 6. Render pass ??????????????????????????????????????????????
2329        let mut encoder = params
2330            .device
2331            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2332                label: Some("rustial_encoder"),
2333            });
2334
2335        let has_heatmap = !self.cached_heatmap_batches.is_empty()
2336            && self.cached_heatmap_batches.iter().any(|b| b.is_some());
2337
2338        let painter_plan = PainterPlan::with_shadows(
2339            !terrain_meshes.is_empty(),
2340            !params.state.hillshade_rasters().is_empty(),
2341            has_heatmap,
2342            params.state.computed_shadow().enabled,
2343        );
2344
2345        for painter_pass in painter_plan.iter() {
2346            match painter_pass {
2347                PainterPass::SkyAtmosphere => {
2348                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2349                        label: Some("rustial_pass_sky_atmosphere"),
2350                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2351                            view: params.color_view,
2352                            resolve_target: None,
2353                            ops: wgpu::Operations {
2354                                load: wgpu::LoadOp::Clear(wgpu::Color {
2355                                    r: clear_color[0] as f64,
2356                                    g: clear_color[1] as f64,
2357                                    b: clear_color[2] as f64,
2358                                    a: clear_color[3] as f64,
2359                                }),
2360                                store: wgpu::StoreOp::Store,
2361                            },
2362                        })],
2363                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2364                            view: &self.depth_view,
2365                            depth_ops: Some(wgpu::Operations {
2366                                load: wgpu::LoadOp::Clear(1.0),
2367                                store: wgpu::StoreOp::Store,
2368                            }),
2369                            stencil_ops: None,
2370                        }),
2371                        ..Default::default()
2372                    });
2373
2374                    // Draw the atmosphere sky (fullscreen triangle at depth 1.0).
2375                    // The sky shader discards when sky_enabled < 0.5.
2376                    if params.state.computed_sky().sky_enabled > 0.5 {
2377                        pass.set_pipeline(&self.sky_pipeline.pipeline);
2378                        pass.set_bind_group(0, &self.sky_uniform_bind_group, &[]);
2379                        pass.draw(0..3, 0..1);
2380                    }
2381                }
2382                PainterPass::TerrainData => {
2383                    // Conditional terrain-data refresh: skip redrawing
2384                    // the depth / coordinate interaction buffers when the
2385                    // view-projection matrix and the terrain tile set
2386                    // haven't changed since the last render.  This mirrors
2387                    // MapLibre's `maybeDrawDepthAndCoords(false)` pattern.
2388                    if !self.terrain_data_dirty.needs_update(&vp, terrain_meshes) {
2389                        continue;
2390                    }
2391                    {
2392                        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2393                            label: Some("rustial_pass_terrain_data"),
2394                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2395                                view: self.terrain_interaction_buffers.coord_view(),
2396                                resolve_target: None,
2397                                ops: wgpu::Operations {
2398                                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
2399                                    store: wgpu::StoreOp::Store,
2400                                },
2401                            })],
2402                            depth_stencil_attachment: Some(
2403                                wgpu::RenderPassDepthStencilAttachment {
2404                                    view: self.terrain_interaction_buffers.depth_view(),
2405                                    depth_ops: Some(wgpu::Operations {
2406                                        load: wgpu::LoadOp::Clear(1.0),
2407                                        store: wgpu::StoreOp::Store,
2408                                    }),
2409                                    stencil_ops: None,
2410                                },
2411                            ),
2412                            ..Default::default()
2413                        });
2414                        if use_shared_terrain {
2415                            self.render_shared_terrain_data_tiles(
2416                                &mut pass,
2417                                params.state,
2418                                terrain_meshes,
2419                            );
2420                        } else {
2421                            self.render_terrain_data_batches(&mut pass, &terrain_batches);
2422                        }
2423                    }
2424                    self.terrain_data_dirty.mark_clean(&vp, terrain_meshes);
2425                }
2426                PainterPass::ShadowDepth => {
2427                    let shadow = params.state.computed_shadow();
2428                    let cascade_count = (shadow.cascade_count as usize)
2429                        .min(self.shadow_map_views.len())
2430                        .min(self.shadow_depth_bind_groups.len());
2431
2432                    // Upload all cascade light VPs upfront.
2433                    for cascade_idx in 0..cascade_count {
2434                        let light_vp_bytes: &[u8] =
2435                            bytemuck::cast_slice(&shadow.light_matrices[cascade_idx]);
2436                        params.queue.write_buffer(
2437                            &self.shadow_depth_uniform_buffers[cascade_idx],
2438                            0,
2439                            light_vp_bytes,
2440                        );
2441                    }
2442
2443                    for cascade_idx in 0..cascade_count {
2444                        // Shadow depth render pass (depth-only, no colour attachment).
2445                        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2446                            label: Some("rustial_pass_shadow_depth"),
2447                            color_attachments: &[],
2448                            depth_stencil_attachment: Some(
2449                                wgpu::RenderPassDepthStencilAttachment {
2450                                    view: &self.shadow_map_views[cascade_idx],
2451                                    depth_ops: Some(wgpu::Operations {
2452                                        load: wgpu::LoadOp::Clear(1.0),
2453                                        store: wgpu::StoreOp::Store,
2454                                    }),
2455                                    stencil_ops: None,
2456                                },
2457                            ),
2458                            ..Default::default()
2459                        });
2460
2461                        // Render fill-extrusion casters into shadow map.
2462                        if self
2463                            .cached_fill_extrusion_batches
2464                            .iter()
2465                            .any(|b| b.is_some())
2466                        {
2467                            pass.set_pipeline(&self.shadow_fill_extrusion_pipeline.pipeline);
2468                            pass.set_bind_group(
2469                                0,
2470                                &self.shadow_depth_bind_groups[cascade_idx],
2471                                &[],
2472                            );
2473                            for batch in self.cached_fill_extrusion_batches.iter().flatten() {
2474                                pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
2475                                pass.set_index_buffer(
2476                                    batch.index_buffer.slice(..),
2477                                    wgpu::IndexFormat::Uint32,
2478                                );
2479                                pass.draw_indexed(0..batch.index_count, 0, 0..1);
2480                            }
2481                        }
2482
2483                        // Render model casters into shadow map.
2484                        if let Some(ref cached) = self.cached_model_transforms {
2485                            if cached.instance_count > 0 {
2486                                pass.set_pipeline(&self.shadow_model_pipeline.pipeline);
2487                                pass.set_bind_group(
2488                                    0,
2489                                    &self.shadow_depth_bind_groups[cascade_idx],
2490                                    &[],
2491                                );
2492                                for i in 0..cached.instance_count {
2493                                    let dyn_offset = (i * cached.stride) as u32;
2494                                    if let Some(instance) = params.model_instances.get(i) {
2495                                        let mesh_key = ModelMeshKey::from_mesh(&instance.mesh);
2496                                        if let Some(cached_mesh) =
2497                                            self.model_mesh_cache.get(&mesh_key)
2498                                        {
2499                                            pass.set_bind_group(
2500                                                1,
2501                                                &cached.bind_group,
2502                                                &[dyn_offset],
2503                                            );
2504                                            pass.set_vertex_buffer(
2505                                                0,
2506                                                cached_mesh.vertex_buffer.slice(..),
2507                                            );
2508                                            pass.set_index_buffer(
2509                                                cached_mesh.index_buffer.slice(..),
2510                                                wgpu::IndexFormat::Uint32,
2511                                            );
2512                                            pass.draw_indexed(0..cached_mesh.index_count, 0, 0..1);
2513                                        }
2514                                    }
2515                                }
2516                            }
2517                        }
2518                    }
2519                }
2520                PainterPass::OpaqueScene => {
2521                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2522                        label: Some("rustial_pass_opaque"),
2523                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2524                            view: params.color_view,
2525                            resolve_target: None,
2526                            ops: wgpu::Operations {
2527                                load: wgpu::LoadOp::Load,
2528                                store: wgpu::StoreOp::Store,
2529                            },
2530                        })],
2531                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2532                            view: &self.depth_view,
2533                            depth_ops: Some(wgpu::Operations {
2534                                load: wgpu::LoadOp::Load,
2535                                store: wgpu::StoreOp::Store,
2536                            }),
2537                            stencil_ops: None,
2538                        }),
2539                        ..Default::default()
2540                    });
2541
2542                    // Draw loading placeholder quads behind tiles so
2543                    // loaded imagery naturally occludes them.
2544                    if let Some(ref ph_batch) = self.cached_placeholder_batch {
2545                        pass.set_pipeline(&self.vector_pipeline.pipeline);
2546                        pass.set_bind_group(0, &self.vector_uniform_bind_group, &[]);
2547                        pass.set_vertex_buffer(0, ph_batch.vertex_buffer.slice(..));
2548                        pass.set_index_buffer(
2549                            ph_batch.index_buffer.slice(..),
2550                            wgpu::IndexFormat::Uint32,
2551                        );
2552                        pass.draw_indexed(0..ph_batch.index_count, 0, 0..1);
2553                    }
2554
2555                    if use_shared_terrain {
2556                        self.render_shared_terrain_tiles(
2557                            &mut pass,
2558                            params.state,
2559                            terrain_meshes,
2560                            params.visible_tiles,
2561                        );
2562                    } else if !terrain_batches.is_empty() {
2563                        self.render_terrain_batches(&mut pass, &terrain_batches);
2564                    } else {
2565                        self.render_tile_batches(&mut pass, &self.cached_tile_batches);
2566                    }
2567
2568                    if !visible_grid_scalar_overlays.is_empty() {
2569                        self.render_grid_scalar_overlays(&mut pass, &visible_grid_scalar_overlays);
2570                    }
2571                    if !visible_grid_extrusion_overlays.is_empty() {
2572                        self.render_grid_extrusion_overlays(
2573                            &mut pass,
2574                            &visible_grid_extrusion_overlays,
2575                        );
2576                    }
2577                    if !visible_column_overlays.is_empty() {
2578                        self.render_column_overlays(&mut pass, &visible_column_overlays);
2579                    }
2580                    if !visible_point_cloud_overlays.is_empty() {
2581                        self.render_point_cloud_overlays(&mut pass, &visible_point_cloud_overlays);
2582                    }
2583
2584                    self.render_vector_batches(&mut pass, &self.cached_vector_batches);
2585                    self.render_fill_batches(&mut pass, &self.cached_fill_batches);
2586                    self.render_fill_pattern_batches(&mut pass, &self.cached_fill_pattern_batches);
2587                    self.render_fill_extrusion_batches(
2588                        &mut pass,
2589                        &self.cached_fill_extrusion_batches,
2590                    );
2591                    self.render_line_batches(&mut pass, &self.cached_line_batches);
2592                    self.render_line_pattern_batches(&mut pass, &self.cached_line_pattern_batches);
2593                    self.render_circle_batches(&mut pass, &self.cached_circle_batches);
2594                    // Heatmap is now rendered in its own two-pass pipeline
2595                    // (HeatmapAccumulation → HeatmapColormap) rather than
2596                    // directly into the opaque scene.
2597                    self.render_image_overlay_batches(&mut pass);
2598                    self.render_symbol_batch(&mut pass);
2599
2600                    if !params.model_instances.is_empty() {
2601                        self.render_models(&mut pass, params.model_instances, params.device);
2602                    }
2603                }
2604                PainterPass::HeatmapAccumulation => {
2605                    // Pass 1: Render Gaussian-weighted heatmap points into
2606                    // the off-screen R16Float accumulation texture.
2607                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2608                        label: Some("rustial_pass_heatmap_accum"),
2609                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2610                            view: &self.heatmap_accum_view,
2611                            resolve_target: None,
2612                            ops: wgpu::Operations {
2613                                load: wgpu::LoadOp::Clear(wgpu::Color {
2614                                    r: 0.0,
2615                                    g: 0.0,
2616                                    b: 0.0,
2617                                    a: 0.0,
2618                                }),
2619                                store: wgpu::StoreOp::Store,
2620                            },
2621                        })],
2622                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2623                            view: &self.depth_view,
2624                            depth_ops: Some(wgpu::Operations {
2625                                load: wgpu::LoadOp::Load,
2626                                store: wgpu::StoreOp::Store,
2627                            }),
2628                            stencil_ops: None,
2629                        }),
2630                        ..Default::default()
2631                    });
2632                    self.render_heatmap_batches(&mut pass, &self.cached_heatmap_batches);
2633                }
2634                PainterPass::HeatmapColormap => {
2635                    // Pass 2: Fullscreen triangle reads the accumulated
2636                    // weight texture and maps it through a colour ramp,
2637                    // compositing onto the main surface.
2638                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2639                        label: Some("rustial_pass_heatmap_colormap"),
2640                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2641                            view: params.color_view,
2642                            resolve_target: None,
2643                            ops: wgpu::Operations {
2644                                load: wgpu::LoadOp::Load,
2645                                store: wgpu::StoreOp::Store,
2646                            },
2647                        })],
2648                        depth_stencil_attachment: None,
2649                        ..Default::default()
2650                    });
2651                    pass.set_pipeline(&self.heatmap_colormap_pipeline.pipeline);
2652                    pass.set_bind_group(0, &self.heatmap_colormap_uniform_bind_group, &[]);
2653                    pass.set_bind_group(1, &self.heatmap_colormap_textures_bind_group, &[]);
2654                    pass.draw(0..3, 0..1); // fullscreen triangle
2655                }
2656                PainterPass::HillshadeOverlay => {
2657                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2658                        label: Some("rustial_pass_hillshade_overlay"),
2659                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2660                            view: params.color_view,
2661                            resolve_target: None,
2662                            ops: wgpu::Operations {
2663                                load: wgpu::LoadOp::Load,
2664                                store: wgpu::StoreOp::Store,
2665                            },
2666                        })],
2667                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2668                            view: &self.depth_view,
2669                            depth_ops: Some(wgpu::Operations {
2670                                load: wgpu::LoadOp::Load,
2671                                store: wgpu::StoreOp::Store,
2672                            }),
2673                            stencil_ops: None,
2674                        }),
2675                        ..Default::default()
2676                    });
2677
2678                    if use_shared_terrain {
2679                        self.render_shared_hillshade_tiles(&mut pass, params.state, terrain_meshes);
2680                    } else {
2681                        self.render_hillshade_batches(&mut pass, &hillshade_batches);
2682                    }
2683                }
2684            }
2685        }
2686
2687        // ?? 7. Submit ???????????????????????????????????????????????????
2688        params.queue.submit(std::iter::once(encoder.finish()));
2689
2690        self.prune_height_texture_cache(terrain_meshes);
2691        self.prune_terrain_tile_bind_cache(terrain_meshes);
2692        self.prune_grid_scalar_overlay_cache(&grid_scalar_overlays);
2693        self.prune_grid_extrusion_overlay_cache(&grid_extrusion_overlays);
2694        self.prune_column_overlay_cache(&column_overlays);
2695        self.prune_point_cloud_overlay_cache(&point_cloud_overlays);
2696
2697        // ?? 8. Atlas end-of-frame eviction ??????????????????????????????
2698        let tile_count_before = self.tile_atlas.len();
2699        self.tile_atlas.end_frame();
2700        if self.tile_atlas.len() != tile_count_before {
2701            self.tile_batch_cache_key = None;
2702        }
2703        self.hillshade_atlas.end_frame();
2704    }
2705
2706    // -- Batched tile rendering -------------------------------------------
2707
2708    /// Issue one `draw_indexed` per atlas page that has visible tile geometry.
2709    fn render_tile_batches<'a>(
2710        &'a self,
2711        pass: &mut wgpu::RenderPass<'a>,
2712        batches: &'a [TilePageBatches],
2713    ) {
2714        pass.set_bind_group(0, &self.uniform_bind_group, &[]);
2715
2716        for (page_idx, batch) in batches.iter().enumerate() {
2717            let bg = match self.page_bind_groups.get(page_idx) {
2718                Some(bg) => bg,
2719                None => continue,
2720            };
2721
2722            if let Some(batch) = batch.opaque.as_ref() {
2723                pass.set_pipeline(&self.tile_pipeline.pipeline);
2724                pass.set_bind_group(1, bg, &[]);
2725                pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
2726                pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2727                pass.draw_indexed(0..batch.index_count, 0, 0..1);
2728            }
2729
2730            if let Some(batch) = batch.translucent.as_ref() {
2731                pass.set_pipeline(&self.tile_pipeline.translucent_pipeline);
2732                pass.set_bind_group(1, bg, &[]);
2733                pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
2734                pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2735                pass.draw_indexed(0..batch.index_count, 0, 0..1);
2736            }
2737        }
2738    }
2739
2740    fn render_grid_scalar_overlays<'a>(
2741        &'a self,
2742        pass: &mut wgpu::RenderPass<'a>,
2743        overlays: &[&'a VisualizationOverlay],
2744    ) {
2745        pass.set_pipeline(&self.grid_scalar_pipeline.pipeline);
2746        pass.set_bind_group(0, &self.grid_scalar_uniform_bind_group, &[]);
2747
2748        for overlay in overlays {
2749            let VisualizationOverlay::GridScalar { layer_id, .. } = overlay else {
2750                continue;
2751            };
2752            let Some(cached) = self.grid_scalar_overlay_cache.get(layer_id) else {
2753                continue;
2754            };
2755            pass.set_bind_group(1, &cached.bind_group, &[]);
2756            pass.set_vertex_buffer(0, cached.vertex_buffer.slice(..));
2757            pass.set_index_buffer(cached.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2758            pass.draw_indexed(0..cached.index_count, 0, 0..1);
2759        }
2760    }
2761
2762    fn render_grid_extrusion_overlays<'a>(
2763        &'a self,
2764        pass: &mut wgpu::RenderPass<'a>,
2765        overlays: &[&'a VisualizationOverlay],
2766    ) {
2767        pass.set_pipeline(&self.grid_extrusion_pipeline.pipeline);
2768        pass.set_bind_group(0, &self.grid_extrusion_uniform_bind_group, &[]);
2769
2770        for overlay in overlays {
2771            let VisualizationOverlay::GridExtrusion { layer_id, .. } = overlay else {
2772                continue;
2773            };
2774            let Some(cached) = self.grid_extrusion_overlay_cache.get(layer_id) else {
2775                continue;
2776            };
2777            pass.set_vertex_buffer(0, cached.vertex_buffer.slice(..));
2778            pass.set_index_buffer(cached.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2779            pass.draw_indexed(0..cached.index_count, 0, 0..1);
2780        }
2781    }
2782
2783    fn render_column_overlays<'a>(
2784        &'a self,
2785        pass: &mut wgpu::RenderPass<'a>,
2786        overlays: &[&'a VisualizationOverlay],
2787    ) {
2788        let Some(mesh) = self.shared_column_mesh.as_ref() else {
2789            return;
2790        };
2791
2792        pass.set_pipeline(&self.column_pipeline.pipeline);
2793        pass.set_bind_group(0, &self.column_uniform_bind_group, &[]);
2794
2795        for overlay in overlays {
2796            let VisualizationOverlay::Columns { layer_id, .. } = overlay else {
2797                continue;
2798            };
2799            let Some(cached) = self.column_overlay_cache.get(layer_id) else {
2800                continue;
2801            };
2802            if cached.instance_count == 0 {
2803                continue;
2804            }
2805            pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
2806            pass.set_vertex_buffer(1, cached.instance_buffer.slice(..));
2807            pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2808            pass.draw_indexed(0..mesh.index_count, 0, 0..cached.instance_count);
2809        }
2810    }
2811
2812    fn render_point_cloud_overlays<'a>(
2813        &'a self,
2814        pass: &mut wgpu::RenderPass<'a>,
2815        overlays: &[&'a VisualizationOverlay],
2816    ) {
2817        let Some(mesh) = self.shared_column_mesh.as_ref() else {
2818            return;
2819        };
2820
2821        pass.set_pipeline(&self.column_pipeline.pipeline);
2822        pass.set_bind_group(0, &self.column_uniform_bind_group, &[]);
2823
2824        for overlay in overlays {
2825            let VisualizationOverlay::Points { layer_id, .. } = overlay else {
2826                continue;
2827            };
2828            let Some(cached) = self.point_cloud_overlay_cache.get(layer_id) else {
2829                continue;
2830            };
2831            if cached.instance_count == 0 {
2832                continue;
2833            }
2834            pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
2835            pass.set_vertex_buffer(1, cached.instance_buffer.slice(..));
2836            pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2837            pass.draw_indexed(0..mesh.index_count, 0, 0..cached.instance_count);
2838        }
2839    }
2840
2841    // -- Batched terrain rendering ----------------------------------------
2842
2843    /// Issue one `draw_indexed` per atlas page that has terrain geometry.
2844    fn render_terrain_batches<'a>(
2845        &'a self,
2846        pass: &mut wgpu::RenderPass<'a>,
2847        batches: &'a [Option<TerrainBatch>],
2848    ) {
2849        pass.set_pipeline(&self.terrain_pipeline.pipeline);
2850        pass.set_bind_group(0, &self.terrain_uniform_bind_group, &[]);
2851
2852        for (page_idx, batch) in batches.iter().enumerate() {
2853            let batch = match batch {
2854                Some(b) => b,
2855                None => continue,
2856            };
2857            let bg = match self.page_terrain_bind_groups.get(page_idx) {
2858                Some(bg) => bg,
2859                None => continue,
2860            };
2861
2862            pass.set_bind_group(1, bg, &[]);
2863            pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
2864            pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2865            pass.draw_indexed(0..batch.index_count, 0, 0..1);
2866        }
2867    }
2868
2869    /// Issue one `draw_indexed` per terrain batch into the renderer-owned
2870    /// depth / coordinate buffers.
2871    fn render_terrain_data_batches<'a>(
2872        &'a self,
2873        pass: &mut wgpu::RenderPass<'a>,
2874        batches: &'a [Option<TerrainBatch>],
2875    ) {
2876        pass.set_pipeline(&self.terrain_data_pipeline.pipeline);
2877        pass.set_bind_group(0, &self.terrain_data_uniform_bind_group, &[]);
2878
2879        for batch in batches.iter().flatten() {
2880            pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
2881            pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2882            pass.draw_indexed(0..batch.index_count, 0, 0..1);
2883        }
2884    }
2885
2886    // -- Batched hillshade rendering -------------------------------------
2887
2888    /// Issue one `draw_indexed` per atlas page that has hillshade geometry.
2889    fn render_hillshade_batches<'a>(
2890        &'a self,
2891        pass: &mut wgpu::RenderPass<'a>,
2892        batches: &'a [Option<HillshadeBatch>],
2893    ) {
2894        pass.set_pipeline(&self.hillshade_pipeline.pipeline);
2895        pass.set_bind_group(0, &self.hillshade_uniform_bind_group, &[]);
2896
2897        for (page_idx, batch) in batches.iter().enumerate() {
2898            let batch = match batch {
2899                Some(b) => b,
2900                None => continue,
2901            };
2902            let bg = match self.page_hillshade_bind_groups.get(page_idx) {
2903                Some(bg) => bg,
2904                None => continue,
2905            };
2906
2907            pass.set_bind_group(1, bg, &[]);
2908            pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
2909            pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2910            pass.draw_indexed(0..batch.index_count, 0, 0..1);
2911        }
2912    }
2913
2914    // -- Batched vector rendering -----------------------------------------
2915
2916    /// Draw each non-empty vector layer.  Pipeline state is set lazily on
2917    /// the first actual draw to avoid overhead when there are no vectors.
2918    fn render_vector_batches<'a>(
2919        &'a self,
2920        pass: &mut wgpu::RenderPass<'a>,
2921        batches: &'a [Option<VectorBatchEntry>],
2922    ) {
2923        let mut pipeline_set = false;
2924
2925        for batch in batches.iter().flatten() {
2926            if !pipeline_set {
2927                pass.set_pipeline(&self.vector_pipeline.pipeline);
2928                pass.set_bind_group(0, &self.vector_uniform_bind_group, &[]);
2929                pipeline_set = true;
2930            }
2931
2932            pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
2933            pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2934            pass.draw_indexed(0..batch.index_count, 0, 0..1);
2935        }
2936    }
2937
2938    // -- Batched fill rendering -------------------------------------------
2939
2940    /// Draw each non-empty fill layer with per-batch fill params.
2941    fn render_fill_batches<'a>(
2942        &'a self,
2943        pass: &mut wgpu::RenderPass<'a>,
2944        batches: &'a [Option<FillBatchEntry>],
2945    ) {
2946        for batch in batches.iter().flatten() {
2947            pass.set_pipeline(&self.fill_pipeline.pipeline);
2948            // Each batch has its own bind group (view-proj + fill params).
2949            pass.set_bind_group(0, &batch.bind_group, &[]);
2950            pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
2951            pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2952            pass.draw_indexed(0..batch.index_count, 0, 0..1);
2953        }
2954    }
2955
2956    // -- Batched fill-pattern rendering ------------------------------------
2957
2958    /// Draw each non-empty fill-pattern layer with texture sampling.
2959    fn render_fill_pattern_batches<'a>(
2960        &'a self,
2961        pass: &mut wgpu::RenderPass<'a>,
2962        batches: &'a [Option<FillPatternBatchEntry>],
2963    ) {
2964        for batch in batches.iter().flatten() {
2965            pass.set_pipeline(&self.fill_pattern_pipeline.pipeline);
2966            pass.set_bind_group(0, &batch.uniform_bind_group, &[]);
2967            pass.set_bind_group(1, &batch.texture_bind_group, &[]);
2968            pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
2969            pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2970            pass.draw_indexed(0..batch.index_count, 0, 0..1);
2971        }
2972    }
2973
2974    // -- Batched fill-extrusion rendering ---------------------------------
2975
2976    /// Draw each non-empty fill-extrusion layer with per-face lighting.
2977    fn render_fill_extrusion_batches<'a>(
2978        &'a self,
2979        pass: &mut wgpu::RenderPass<'a>,
2980        batches: &'a [Option<FillExtrusionBatchEntry>],
2981    ) {
2982        let mut pipeline_set = false;
2983
2984        for batch in batches.iter().flatten() {
2985            if !pipeline_set {
2986                pass.set_pipeline(&self.fill_extrusion_pipeline.pipeline);
2987                pass.set_bind_group(0, &self.fill_extrusion_uniform_bind_group, &[]);
2988                pass.set_bind_group(1, &self.shadow_receiver_bind_group, &[]);
2989                pipeline_set = true;
2990            }
2991
2992            pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
2993            pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2994            pass.draw_indexed(0..batch.index_count, 0, 0..1);
2995        }
2996    }
2997
2998    fn render_line_batches<'a>(
2999        &'a self,
3000        pass: &mut wgpu::RenderPass<'a>,
3001        batches: &'a [Option<LineBatchEntry>],
3002    ) {
3003        let mut pipeline_set = false;
3004
3005        for batch in batches.iter().flatten() {
3006            if !pipeline_set {
3007                pass.set_pipeline(&self.line_pipeline.pipeline);
3008                pass.set_bind_group(0, &self.line_uniform_bind_group, &[]);
3009                pipeline_set = true;
3010            }
3011
3012            pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
3013            pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
3014            pass.draw_indexed(0..batch.index_count, 0, 0..1);
3015        }
3016    }
3017
3018    fn render_line_pattern_batches<'a>(
3019        &'a self,
3020        pass: &mut wgpu::RenderPass<'a>,
3021        batches: &'a [Option<LinePatternBatchEntry>],
3022    ) {
3023        for batch in batches.iter().flatten() {
3024            pass.set_pipeline(&self.line_pattern_pipeline.pipeline);
3025            pass.set_bind_group(0, &batch.uniform_bind_group, &[]);
3026            pass.set_bind_group(1, &batch.texture_bind_group, &[]);
3027            pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
3028            pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
3029            pass.draw_indexed(0..batch.index_count, 0, 0..1);
3030        }
3031    }
3032
3033    fn render_circle_batches<'a>(
3034        &'a self,
3035        pass: &mut wgpu::RenderPass<'a>,
3036        batches: &'a [Option<CircleBatchEntry>],
3037    ) {
3038        let mut pipeline_set = false;
3039
3040        for batch in batches.iter().flatten() {
3041            if !pipeline_set {
3042                pass.set_pipeline(&self.circle_pipeline.pipeline);
3043                pass.set_bind_group(0, &self.circle_uniform_bind_group, &[]);
3044                pipeline_set = true;
3045            }
3046
3047            pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
3048            pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
3049            pass.draw_indexed(0..batch.index_count, 0, 0..1);
3050        }
3051    }
3052
3053    fn render_heatmap_batches<'a>(
3054        &'a self,
3055        pass: &mut wgpu::RenderPass<'a>,
3056        batches: &'a [Option<HeatmapBatchEntry>],
3057    ) {
3058        let mut pipeline_set = false;
3059
3060        for batch in batches.iter().flatten() {
3061            if !pipeline_set {
3062                pass.set_pipeline(&self.heatmap_pipeline.pipeline);
3063                pass.set_bind_group(0, &self.heatmap_uniform_bind_group, &[]);
3064                pipeline_set = true;
3065            }
3066
3067            pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
3068            pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
3069            pass.draw_indexed(0..batch.index_count, 0, 0..1);
3070        }
3071    }
3072
3073    // -- Image overlay batches ---------------------------------------------
3074
3075    /// Build GPU batches for image overlays.  Reuses textures and bind
3076    /// groups from the previous frame when the overlay data has not
3077    /// changed (Arc pointer identity) or when only the geometry moved
3078    /// (same dimensions → `write_texture` instead of recreating).
3079    fn build_image_overlay_batches(
3080        &mut self,
3081        device: &wgpu::Device,
3082        queue: &wgpu::Queue,
3083        overlays: &[rustial_engine::layers::ImageOverlayData],
3084        camera_origin: glam::DVec3,
3085    ) {
3086        // Take the old cache so we can harvest reusable entries.
3087        let mut old_cache: Vec<CachedImageOverlayBatch> =
3088            std::mem::take(&mut self.cached_image_overlay_batches);
3089
3090        for overlay in overlays {
3091            if overlay.width == 0 || overlay.height == 0 || overlay.data.is_empty() {
3092                continue;
3093            }
3094
3095            let data_arc_ptr = Arc::as_ptr(&overlay.data) as usize;
3096
3097            // Try to find a matching cached entry (by layer id).
3098            let cached_idx = old_cache
3099                .iter()
3100                .position(|c| c.layer_id == overlay.layer_id);
3101
3102            // Corner UVs: [top-left, top-right, bottom-right, bottom-left]
3103            let uvs = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
3104            let vertices: Vec<ImageOverlayVertex> = overlay
3105                .corners
3106                .iter()
3107                .zip(uvs.iter())
3108                .map(|(corner, uv)| {
3109                    let rel = [
3110                        (corner[0] - camera_origin.x) as f32,
3111                        (corner[1] - camera_origin.y) as f32,
3112                        (corner[2] - camera_origin.z) as f32,
3113                    ];
3114                    ImageOverlayVertex {
3115                        position: rel,
3116                        uv: *uv,
3117                        opacity: overlay.opacity,
3118                    }
3119                })
3120                .collect();
3121            let indices: Vec<u32> = vec![0, 1, 2, 0, 2, 3];
3122
3123            if let Some(idx) = cached_idx {
3124                let mut cached = old_cache.remove(idx);
3125
3126                // Always rebuild vertex/index (positions may have changed).
3127                cached.vertex_buffer =
3128                    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3129                        label: Some("image_overlay_vb"),
3130                        contents: bytemuck::cast_slice(&vertices),
3131                        usage: wgpu::BufferUsages::VERTEX,
3132                    });
3133                cached.index_buffer =
3134                    device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3135                        label: Some("image_overlay_ib"),
3136                        contents: bytemuck::cast_slice(&indices),
3137                        usage: wgpu::BufferUsages::INDEX,
3138                    });
3139
3140                // If data pointer is identical, skip texture re-upload.
3141                if cached.data_arc_ptr != data_arc_ptr {
3142                    if cached.tex_dimensions == (overlay.width, overlay.height) {
3143                        // Same dimensions → write_texture (no reallocation).
3144                        queue.write_texture(
3145                            wgpu::TexelCopyTextureInfo {
3146                                texture: &cached.texture,
3147                                mip_level: 0,
3148                                origin: wgpu::Origin3d::ZERO,
3149                                aspect: wgpu::TextureAspect::All,
3150                            },
3151                            &overlay.data,
3152                            wgpu::TexelCopyBufferLayout {
3153                                offset: 0,
3154                                bytes_per_row: Some(overlay.width * 4),
3155                                rows_per_image: Some(overlay.height),
3156                            },
3157                            wgpu::Extent3d {
3158                                width: overlay.width,
3159                                height: overlay.height,
3160                                depth_or_array_layers: 1,
3161                            },
3162                        );
3163                    } else {
3164                        // Dimensions changed → recreate texture + bind group.
3165                        let (texture, texture_view, texture_bind_group) =
3166                            self.create_overlay_texture(device, queue, overlay);
3167                        cached.texture = texture;
3168                        cached.texture_view = texture_view;
3169                        cached.texture_bind_group = texture_bind_group;
3170                        cached.tex_dimensions = (overlay.width, overlay.height);
3171                    }
3172                    cached.data_arc_ptr = data_arc_ptr;
3173                }
3174
3175                self.cached_image_overlay_batches.push(cached);
3176            } else {
3177                // New overlay — full creation.
3178                let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3179                    label: Some("image_overlay_vb"),
3180                    contents: bytemuck::cast_slice(&vertices),
3181                    usage: wgpu::BufferUsages::VERTEX,
3182                });
3183                let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3184                    label: Some("image_overlay_ib"),
3185                    contents: bytemuck::cast_slice(&indices),
3186                    usage: wgpu::BufferUsages::INDEX,
3187                });
3188                let (texture, texture_view, texture_bind_group) =
3189                    self.create_overlay_texture(device, queue, overlay);
3190
3191                self.cached_image_overlay_batches
3192                    .push(CachedImageOverlayBatch {
3193                        vertex_buffer,
3194                        index_buffer,
3195                        texture,
3196                        texture_view,
3197                        texture_bind_group,
3198                        layer_id: overlay.layer_id,
3199                        tex_dimensions: (overlay.width, overlay.height),
3200                        data_arc_ptr,
3201                    });
3202            }
3203        }
3204        // old_cache entries not matched are dropped (stale overlays).
3205    }
3206
3207    /// Create a new GPU texture + bind group for an image overlay.
3208    fn create_overlay_texture(
3209        &self,
3210        device: &wgpu::Device,
3211        queue: &wgpu::Queue,
3212        overlay: &rustial_engine::layers::ImageOverlayData,
3213    ) -> (wgpu::Texture, wgpu::TextureView, wgpu::BindGroup) {
3214        let texture = device.create_texture(&wgpu::TextureDescriptor {
3215            label: Some("image_overlay_tex"),
3216            size: wgpu::Extent3d {
3217                width: overlay.width,
3218                height: overlay.height,
3219                depth_or_array_layers: 1,
3220            },
3221            mip_level_count: 1,
3222            sample_count: 1,
3223            dimension: wgpu::TextureDimension::D2,
3224            format: wgpu::TextureFormat::Rgba8UnormSrgb,
3225            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
3226            view_formats: &[],
3227        });
3228        queue.write_texture(
3229            wgpu::TexelCopyTextureInfo {
3230                texture: &texture,
3231                mip_level: 0,
3232                origin: wgpu::Origin3d::ZERO,
3233                aspect: wgpu::TextureAspect::All,
3234            },
3235            &overlay.data,
3236            wgpu::TexelCopyBufferLayout {
3237                offset: 0,
3238                bytes_per_row: Some(overlay.width * 4),
3239                rows_per_image: Some(overlay.height),
3240            },
3241            wgpu::Extent3d {
3242                width: overlay.width,
3243                height: overlay.height,
3244                depth_or_array_layers: 1,
3245            },
3246        );
3247        let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3248        let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3249            label: Some("image_overlay_tex_bg"),
3250            layout: &self.image_overlay_pipeline.texture_bind_group_layout,
3251            entries: &[
3252                wgpu::BindGroupEntry {
3253                    binding: 0,
3254                    resource: wgpu::BindingResource::TextureView(&texture_view),
3255                },
3256                wgpu::BindGroupEntry {
3257                    binding: 1,
3258                    resource: wgpu::BindingResource::Sampler(&self.sampler),
3259                },
3260            ],
3261        });
3262        (texture, texture_view, texture_bind_group)
3263    }
3264
3265    /// Draw all cached image overlay batches.
3266    fn render_image_overlay_batches<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
3267        if self.cached_image_overlay_batches.is_empty() {
3268            return;
3269        }
3270        pass.set_pipeline(&self.image_overlay_pipeline.pipeline);
3271        pass.set_bind_group(0, &self.image_overlay_uniform_bind_group, &[]);
3272        for batch in &self.cached_image_overlay_batches {
3273            pass.set_bind_group(1, &batch.texture_bind_group, &[]);
3274            pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
3275            pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
3276            pass.draw_indexed(0..6, 0, 0..1);
3277        }
3278    }
3279
3280    fn render_symbol_batch<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
3281        let batch = match &self.cached_symbol_batch {
3282            Some(b) => b,
3283            None => return,
3284        };
3285        let atlas_bg = match &self.symbol_atlas_bind_group {
3286            Some(bg) => bg,
3287            None => return,
3288        };
3289
3290        pass.set_pipeline(&self.symbol_pipeline.pipeline);
3291        pass.set_bind_group(0, &self.symbol_uniform_bind_group, &[]);
3292        pass.set_bind_group(1, atlas_bg, &[]);
3293        pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
3294        pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
3295        pass.draw_indexed(0..batch.index_count, 0, 0..1);
3296    }
3297
3298    fn render_models<'a>(
3299        &'a self,
3300        pass: &mut wgpu::RenderPass<'a>,
3301        model_instances: &[ModelInstance],
3302        device: &wgpu::Device,
3303    ) {
3304        if model_instances.is_empty() {
3305            return;
3306        }
3307
3308        let cached = match &self.cached_model_transforms {
3309            Some(c) if c.instance_count == model_instances.len() => c,
3310            _ => return,
3311        };
3312
3313        pass.set_pipeline(&self.model_pipeline.pipeline);
3314        pass.set_bind_group(0, &self.model_uniform_bind_group, &[]);
3315        pass.set_bind_group(2, &self.shadow_receiver_bind_group, &[]);
3316
3317        for (i, instance) in model_instances.iter().enumerate() {
3318            let dyn_offset = (i * cached.stride) as u32;
3319            let mesh_key = ModelMeshKey::from_mesh(&instance.mesh);
3320            let cached_mesh = self.model_mesh_cache.get(&mesh_key);
3321
3322            if let Some(cached_mesh) = cached_mesh {
3323                pass.set_bind_group(1, &cached.bind_group, &[dyn_offset]);
3324                pass.set_vertex_buffer(0, cached_mesh.vertex_buffer.slice(..));
3325                pass.set_index_buffer(
3326                    cached_mesh.index_buffer.slice(..),
3327                    wgpu::IndexFormat::Uint32,
3328                );
3329                pass.draw_indexed(0..cached_mesh.index_count, 0, 0..1);
3330            } else {
3331                let vertices = build_model_vertices(&instance.mesh);
3332                let vb = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3333                    label: Some("model_inline_vb"),
3334                    contents: bytemuck::cast_slice(&vertices),
3335                    usage: wgpu::BufferUsages::VERTEX,
3336                });
3337                let ib = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3338                    label: Some("model_inline_ib"),
3339                    contents: bytemuck::cast_slice(&instance.mesh.indices),
3340                    usage: wgpu::BufferUsages::INDEX,
3341                });
3342                let index_count = instance.mesh.indices.len() as u32;
3343                pass.set_bind_group(1, &cached.bind_group, &[dyn_offset]);
3344                pass.set_vertex_buffer(0, vb.slice(..));
3345                pass.set_index_buffer(ib.slice(..), wgpu::IndexFormat::Uint32);
3346                pass.draw_indexed(0..index_count, 0, 0..1);
3347            }
3348        }
3349    }
3350
3351    /// Pre-cache model mesh GPU buffers before the render pass begins.
3352    ///
3353    /// Called automatically by [`render_full`](Self::render_full). This keeps
3354    /// stable model meshes resident on the GPU instead of re-uploading them
3355    /// every frame.
3356    pub fn cache_model_meshes(&mut self, device: &wgpu::Device, model_instances: &[ModelInstance]) {
3357        for instance in model_instances {
3358            let key = ModelMeshKey::from_mesh(&instance.mesh);
3359            if self.model_mesh_cache.contains_key(&key) {
3360                continue;
3361            }
3362
3363            let vertices = build_model_vertices(&instance.mesh);
3364            let vb = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3365                label: Some("cached_model_vb"),
3366                contents: bytemuck::cast_slice(&vertices),
3367                usage: wgpu::BufferUsages::VERTEX,
3368            });
3369            let ib = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3370                label: Some("cached_model_ib"),
3371                contents: bytemuck::cast_slice(&instance.mesh.indices),
3372                usage: wgpu::BufferUsages::INDEX,
3373            });
3374
3375            self.model_mesh_cache.insert(
3376                key,
3377                CachedModelMesh {
3378                    vertex_buffer: vb,
3379                    index_buffer: ib,
3380                    index_count: instance.mesh.indices.len() as u32,
3381                },
3382            );
3383        }
3384    }
3385
3386    /// Pre-build the model instance transform dynamic-UBO buffer and bind
3387    /// group.
3388    ///
3389    /// The buffer is only rebuilt when the transform fingerprint changes
3390    /// (instance positions, rotations, scales, or camera origin moved).
3391    /// On steady-state frames with a static model set and a stationary
3392    /// camera, this avoids the cost of `create_buffer_init` +
3393    /// `create_bind_group` entirely.
3394    fn cache_model_transforms(
3395        &mut self,
3396        device: &wgpu::Device,
3397        model_instances: &[ModelInstance],
3398        camera_origin: DVec3,
3399        state: &MapState,
3400    ) {
3401        let min_align = device.limits().min_uniform_buffer_offset_alignment as usize;
3402        let stride = 64_usize.div_ceil(min_align) * min_align;
3403
3404        // Build a rolling fingerprint of the model transform inputs.
3405        let mut fp: u64 = model_instances.len() as u64;
3406        let origin_key = [
3407            (camera_origin.x * 100.0) as i64,
3408            (camera_origin.y * 100.0) as i64,
3409            (camera_origin.z * 100.0) as i64,
3410        ];
3411        fp = fp
3412            .wrapping_mul(31)
3413            .wrapping_add(origin_key[0] as u64)
3414            .wrapping_mul(31)
3415            .wrapping_add(origin_key[1] as u64)
3416            .wrapping_mul(31)
3417            .wrapping_add(origin_key[2] as u64);
3418        for instance in model_instances {
3419            fp = fp
3420                .wrapping_mul(31)
3421                .wrapping_add(instance.position.lat.to_bits())
3422                .wrapping_mul(31)
3423                .wrapping_add(instance.position.lon.to_bits())
3424                .wrapping_mul(31)
3425                .wrapping_add(instance.scale.to_bits())
3426                .wrapping_mul(31)
3427                .wrapping_add(instance.heading.to_bits());
3428        }
3429
3430        if let Some(ref cached) = self.cached_model_transforms {
3431            if cached.fingerprint == fp && cached.instance_count == model_instances.len() {
3432                return;
3433            }
3434        }
3435
3436        let mut transform_bytes = vec![0u8; stride * model_instances.len()];
3437        for (i, instance) in model_instances.iter().enumerate() {
3438            let terrain_elev = state.elevation_at(&instance.position);
3439            let altitude = instance.resolve_altitude(terrain_elev);
3440
3441            let world_pos = state.camera().projection().project(&instance.position);
3442            let rel_x = (world_pos.position.x - camera_origin.x) as f32;
3443            let rel_y = (world_pos.position.y - camera_origin.y) as f32;
3444            let rel_z = (altitude - camera_origin.z) as f32;
3445
3446            let scale = instance.scale as f32;
3447            let heading = instance.heading as f32;
3448            let pitch = instance.pitch as f32;
3449            let roll = instance.roll as f32;
3450
3451            let rotation = glam::Quat::from_rotation_z(heading)
3452                * glam::Quat::from_rotation_x(pitch)
3453                * glam::Quat::from_rotation_y(roll);
3454            let transform = Mat4::from_translation(glam::Vec3::new(rel_x, rel_y, rel_z))
3455                * Mat4::from_quat(rotation)
3456                * Mat4::from_scale(glam::Vec3::splat(scale));
3457
3458            let mat = transform.to_cols_array_2d();
3459            let mat_bytes = bytemuck::cast_slice(&mat);
3460            let offset = i * stride;
3461            transform_bytes[offset..offset + 64].copy_from_slice(mat_bytes);
3462        }
3463
3464        let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3465            label: Some("model_transforms_cached_buf"),
3466            contents: &transform_bytes,
3467            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3468        });
3469
3470        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3471            label: Some("model_transforms_cached_bg"),
3472            layout: &self.model_pipeline.model_bind_group_layout,
3473            entries: &[wgpu::BindGroupEntry {
3474                binding: 0,
3475                resource: buffer.as_entire_binding(),
3476            }],
3477        });
3478
3479        self.cached_model_transforms = Some(CachedModelTransforms {
3480            buffer,
3481            bind_group,
3482            stride,
3483            instance_count: model_instances.len(),
3484            fingerprint: fp,
3485        })
3486    }
3487
3488    // -- Page bind group management ---------------------------------------
3489
3490    /// Rebuild per-atlas-page bind groups when new atlas pages are created.
3491    fn rebuild_page_bind_groups(&mut self, device: &wgpu::Device) {
3492        let tile_pages = self.tile_atlas.page_count();
3493        while self.page_bind_groups.len() < tile_pages {
3494            let idx = self.page_bind_groups.len();
3495            let view = &self.tile_atlas.pages[idx].view;
3496            let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
3497                label: Some(&format!("rustial_tile_page_bg_{idx}")),
3498                layout: &self.tile_pipeline.texture_bind_group_layout,
3499                entries: &[
3500                    wgpu::BindGroupEntry {
3501                        binding: 0,
3502                        resource: wgpu::BindingResource::TextureView(view),
3503                    },
3504                    wgpu::BindGroupEntry {
3505                        binding: 1,
3506                        resource: wgpu::BindingResource::Sampler(&self.sampler),
3507                    },
3508                ],
3509            });
3510            self.page_bind_groups.push(bg);
3511
3512            let terrain_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
3513                label: Some(&format!("rustial_terrain_page_bg_{idx}")),
3514                layout: &self.terrain_pipeline.texture_bind_group_layout,
3515                entries: &[
3516                    wgpu::BindGroupEntry {
3517                        binding: 0,
3518                        resource: wgpu::BindingResource::TextureView(view),
3519                    },
3520                    wgpu::BindGroupEntry {
3521                        binding: 1,
3522                        resource: wgpu::BindingResource::Sampler(&self.sampler),
3523                    },
3524                ],
3525            });
3526            self.page_terrain_bind_groups.push(terrain_bg);
3527        }
3528
3529        let hillshade_pages = self.hillshade_atlas.page_count();
3530        while self.page_hillshade_bind_groups.len() < hillshade_pages {
3531            let idx = self.page_hillshade_bind_groups.len();
3532            let view = &self.hillshade_atlas.pages[idx].view;
3533            let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
3534                label: Some(&format!("rustial_hillshade_page_bg_{idx}")),
3535                layout: &self.hillshade_pipeline.texture_bind_group_layout,
3536                entries: &[
3537                    wgpu::BindGroupEntry {
3538                        binding: 0,
3539                        resource: wgpu::BindingResource::TextureView(view),
3540                    },
3541                    wgpu::BindGroupEntry {
3542                        binding: 1,
3543                        resource: wgpu::BindingResource::Sampler(&self.sampler),
3544                    },
3545                ],
3546            });
3547            self.page_hillshade_bind_groups.push(bg);
3548        }
3549    }
3550
3551    // -- Shared-grid terrain rendering ------------------------------------
3552
3553    fn get_or_create_shared_grid(
3554        &mut self,
3555        device: &wgpu::Device,
3556        resolution: u16,
3557    ) -> &SharedTerrainGridMesh {
3558        self.shared_terrain_grids
3559            .entry(resolution)
3560            .or_insert_with(|| {
3561                let (vertices, indices) = build_shared_terrain_grid(resolution as usize);
3562                let vb = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3563                    label: Some(&format!("terrain_grid_vb_{resolution}")),
3564                    contents: bytemuck::cast_slice(&vertices),
3565                    usage: wgpu::BufferUsages::VERTEX,
3566                });
3567                let ib = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3568                    label: Some(&format!("terrain_grid_ib_{resolution}")),
3569                    contents: bytemuck::cast_slice(&indices),
3570                    usage: wgpu::BufferUsages::INDEX,
3571                });
3572                SharedTerrainGridMesh {
3573                    vertex_buffer: vb,
3574                    index_buffer: ib,
3575                    index_count: indices.len() as u32,
3576                }
3577            });
3578        #[allow(clippy::unwrap_used)] // Just inserted above.
3579        self.shared_terrain_grids.get(&resolution).unwrap()
3580    }
3581
3582    fn get_or_create_terrain_tile_bind(
3583        &mut self,
3584        device: &wgpu::Device,
3585        queue: &wgpu::Queue,
3586        mesh: &TerrainMeshData,
3587        state: &MapState,
3588        scene_origin: DVec3,
3589        pipeline_kind: TerrainPipelineKind,
3590    ) -> Option<()> {
3591        let elevation = mesh.elevation_texture.as_ref()?;
3592        let key = TerrainTileBindKey {
3593            tile: mesh.tile,
3594            pipeline: pipeline_kind,
3595        };
3596        let origin_key = [
3597            (scene_origin.x * 100.0) as i64,
3598            (scene_origin.y * 100.0) as i64,
3599            (scene_origin.z * 100.0) as i64,
3600        ];
3601
3602        // Check if already cached and valid.
3603        if let Some(cached) = self.terrain_tile_bind_cache.get(&key) {
3604            if cached.origin_key == origin_key && cached.generation == mesh.generation {
3605                return Some(());
3606            }
3607        }
3608
3609        // Ensure height texture is cached.
3610        let tile = mesh.tile;
3611        let gen = mesh.generation;
3612        let needs_height = self
3613            .height_texture_cache
3614            .get(&tile)
3615            .is_none_or(|c| c.generation != gen);
3616        if needs_height {
3617            let size = wgpu::Extent3d {
3618                width: elevation.width.max(1),
3619                height: elevation.height.max(1),
3620                depth_or_array_layers: 1,
3621            };
3622            let texture = device.create_texture(&wgpu::TextureDescriptor {
3623                label: Some(&format!("height_tex_{:?}", tile)),
3624                size,
3625                mip_level_count: 1,
3626                sample_count: 1,
3627                dimension: wgpu::TextureDimension::D2,
3628                format: wgpu::TextureFormat::R32Float,
3629                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
3630                view_formats: &[],
3631            });
3632            queue.write_texture(
3633                wgpu::TexelCopyTextureInfo {
3634                    texture: &texture,
3635                    mip_level: 0,
3636                    origin: wgpu::Origin3d::ZERO,
3637                    aspect: wgpu::TextureAspect::All,
3638                },
3639                bytemuck::cast_slice(&elevation.data),
3640                wgpu::TexelCopyBufferLayout {
3641                    offset: 0,
3642                    bytes_per_row: Some(elevation.width.max(1) * 4),
3643                    rows_per_image: None,
3644                },
3645                size,
3646            );
3647            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3648            self.height_texture_cache.insert(
3649                tile,
3650                CachedHeightTexture {
3651                    generation: gen,
3652                    view,
3653                },
3654            );
3655        }
3656
3657        // Build the tile uniform and bind group.
3658        let tile_uniform = build_terrain_tile_uniform(mesh, elevation, state, scene_origin);
3659
3660        let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
3661            label: Some(&format!(
3662                "terrain_tile_uniform_{:?}_{:?}",
3663                mesh.tile, pipeline_kind
3664            )),
3665            contents: bytemuck::bytes_of(&tile_uniform),
3666            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3667        });
3668
3669        let layout = match pipeline_kind {
3670            TerrainPipelineKind::Terrain => &self.terrain_pipeline.tile_bind_group_layout,
3671            TerrainPipelineKind::TerrainData => &self.terrain_data_pipeline.tile_bind_group_layout,
3672            TerrainPipelineKind::Hillshade => &self.hillshade_pipeline.tile_bind_group_layout,
3673        };
3674
3675        // Obtain the height texture view pointer safely.
3676        let height_view_ref = &self.height_texture_cache.get(&tile)?.view;
3677        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3678            label: Some(&format!(
3679                "terrain_tile_bg_{:?}_{:?}",
3680                mesh.tile, pipeline_kind
3681            )),
3682            layout,
3683            entries: &[
3684                wgpu::BindGroupEntry {
3685                    binding: 0,
3686                    resource: uniform_buffer.as_entire_binding(),
3687                },
3688                wgpu::BindGroupEntry {
3689                    binding: 1,
3690                    resource: wgpu::BindingResource::TextureView(height_view_ref),
3691                },
3692            ],
3693        });
3694
3695        self.terrain_tile_bind_cache.insert(
3696            key,
3697            CachedTerrainTileBind {
3698                uniform_buffer,
3699                bind_group,
3700                origin_key,
3701                generation: mesh.generation,
3702            },
3703        );
3704        Some(())
3705    }
3706
3707    fn render_shared_terrain_tiles<'a>(
3708        &'a self,
3709        pass: &mut wgpu::RenderPass<'a>,
3710        _state: &MapState,
3711        terrain_meshes: &[TerrainMeshData],
3712        visible_tiles: &[VisibleTile],
3713    ) {
3714        pass.set_pipeline(&self.terrain_pipeline.pipeline);
3715        pass.set_bind_group(0, &self.terrain_uniform_bind_group, &[]);
3716
3717        for mesh in terrain_meshes {
3718            let grid = match self.shared_terrain_grids.get(&mesh.grid_resolution) {
3719                Some(g) => g,
3720                None => continue,
3721            };
3722
3723            if let Some(actual) = find_terrain_texture_actual(mesh.tile, visible_tiles) {
3724                if let Some(region) = self.tile_atlas.get(&actual) {
3725                    if let Some(bg) = self.page_terrain_bind_groups.get(region.page) {
3726                        pass.set_bind_group(1, bg, &[]);
3727                    }
3728                }
3729            }
3730
3731            let key = TerrainTileBindKey {
3732                tile: mesh.tile,
3733                pipeline: TerrainPipelineKind::Terrain,
3734            };
3735            if let Some(cached) = self.terrain_tile_bind_cache.get(&key) {
3736                pass.set_bind_group(2, &cached.bind_group, &[]);
3737                pass.set_vertex_buffer(0, grid.vertex_buffer.slice(..));
3738                pass.set_index_buffer(grid.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
3739                pass.draw_indexed(0..grid.index_count, 0, 0..1);
3740            }
3741        }
3742    }
3743
3744    fn render_shared_terrain_data_tiles<'a>(
3745        &'a self,
3746        pass: &mut wgpu::RenderPass<'a>,
3747        _state: &MapState,
3748        terrain_meshes: &[TerrainMeshData],
3749    ) {
3750        pass.set_pipeline(&self.terrain_data_pipeline.pipeline);
3751        pass.set_bind_group(0, &self.terrain_data_uniform_bind_group, &[]);
3752
3753        for mesh in terrain_meshes {
3754            let grid = match self.shared_terrain_grids.get(&mesh.grid_resolution) {
3755                Some(g) => g,
3756                None => continue,
3757            };
3758
3759            let key = TerrainTileBindKey {
3760                tile: mesh.tile,
3761                pipeline: TerrainPipelineKind::TerrainData,
3762            };
3763            if let Some(cached) = self.terrain_tile_bind_cache.get(&key) {
3764                pass.set_bind_group(1, &cached.bind_group, &[]);
3765                pass.set_vertex_buffer(0, grid.vertex_buffer.slice(..));
3766                pass.set_index_buffer(grid.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
3767                pass.draw_indexed(0..grid.index_count, 0, 0..1);
3768            }
3769        }
3770    }
3771
3772    fn render_shared_hillshade_tiles<'a>(
3773        &'a self,
3774        pass: &mut wgpu::RenderPass<'a>,
3775        _state: &MapState,
3776        terrain_meshes: &[TerrainMeshData],
3777    ) {
3778        pass.set_pipeline(&self.hillshade_pipeline.pipeline);
3779        pass.set_bind_group(0, &self.hillshade_uniform_bind_group, &[]);
3780
3781        for mesh in terrain_meshes {
3782            let grid = match self.shared_terrain_grids.get(&mesh.grid_resolution) {
3783                Some(g) => g,
3784                None => continue,
3785            };
3786
3787            if let Some(region) = self.hillshade_atlas.get(&mesh.tile) {
3788                if let Some(bg) = self.page_hillshade_bind_groups.get(region.page) {
3789                    pass.set_bind_group(1, bg, &[]);
3790                }
3791            }
3792
3793            let key = TerrainTileBindKey {
3794                tile: mesh.tile,
3795                pipeline: TerrainPipelineKind::Hillshade,
3796            };
3797            if let Some(cached) = self.terrain_tile_bind_cache.get(&key) {
3798                pass.set_bind_group(2, &cached.bind_group, &[]);
3799                pass.set_vertex_buffer(0, grid.vertex_buffer.slice(..));
3800                pass.set_index_buffer(grid.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
3801                pass.draw_indexed(0..grid.index_count, 0, 0..1);
3802            }
3803        }
3804    }
3805
3806    // -- Cache pruning ----------------------------------------------------
3807
3808    fn prune_height_texture_cache(&mut self, terrain_meshes: &[TerrainMeshData]) {
3809        let live: std::collections::HashSet<TileId> =
3810            terrain_meshes.iter().map(|m| m.tile).collect();
3811        self.height_texture_cache
3812            .retain(|tile, _| live.contains(tile));
3813    }
3814
3815    fn prune_terrain_tile_bind_cache(&mut self, terrain_meshes: &[TerrainMeshData]) {
3816        let live: std::collections::HashSet<TileId> =
3817            terrain_meshes.iter().map(|m| m.tile).collect();
3818        self.terrain_tile_bind_cache
3819            .retain(|key, _| live.contains(&key.tile));
3820    }
3821
3822    fn prune_grid_scalar_overlay_cache(&mut self, overlays: &[&VisualizationOverlay]) {
3823        let live: std::collections::HashSet<LayerId> = overlays
3824            .iter()
3825            .filter_map(|overlay| match overlay {
3826                VisualizationOverlay::GridScalar { layer_id, .. } => Some(*layer_id),
3827                _ => None,
3828            })
3829            .collect();
3830        self.grid_scalar_overlay_cache
3831            .retain(|layer_id, _| live.contains(layer_id));
3832    }
3833
3834    fn prune_grid_extrusion_overlay_cache(&mut self, overlays: &[&VisualizationOverlay]) {
3835        let live: std::collections::HashSet<LayerId> = overlays
3836            .iter()
3837            .filter_map(|overlay| match overlay {
3838                VisualizationOverlay::GridExtrusion { layer_id, .. } => Some(*layer_id),
3839                _ => None,
3840            })
3841            .collect();
3842        self.grid_extrusion_overlay_cache
3843            .retain(|layer_id, _| live.contains(layer_id));
3844    }
3845
3846    fn prune_column_overlay_cache(&mut self, overlays: &[&VisualizationOverlay]) {
3847        let live: std::collections::HashSet<LayerId> = overlays
3848            .iter()
3849            .filter_map(|overlay| match overlay {
3850                VisualizationOverlay::Columns { layer_id, .. } => Some(*layer_id),
3851                _ => None,
3852            })
3853            .collect();
3854        self.column_overlay_cache
3855            .retain(|layer_id, _| live.contains(layer_id));
3856    }
3857
3858    fn prune_point_cloud_overlay_cache(&mut self, overlays: &[&VisualizationOverlay]) {
3859        let live: std::collections::HashSet<LayerId> = overlays
3860            .iter()
3861            .filter_map(|overlay| match overlay {
3862                VisualizationOverlay::Points { layer_id, .. } => Some(*layer_id),
3863                _ => None,
3864            })
3865            .collect();
3866        self.point_cloud_overlay_cache
3867            .retain(|layer_id, _| live.contains(layer_id));
3868    }
3869
3870    // -- Headless capture -------------------------------------------------
3871
3872    /// Render one full frame to an offscreen texture and return the pixel
3873    /// data as a `Vec<u8>` in RGBA8 format (4 bytes per pixel,
3874    /// `width * height * 4` total bytes).
3875    ///
3876    /// This is the primary entry-point for headless rendering and
3877    /// cross-renderer comparison tests.  It creates a transient
3878    /// colour texture, calls [`render_full`](Self::render_full), copies
3879    /// the result to a readback buffer, and returns the pixels.
3880    ///
3881    /// # Arguments
3882    ///
3883    /// * `state` - Engine map state (camera, layers, terrain).
3884    /// * `device` - WGPU device.
3885    /// * `queue` - WGPU queue.
3886    /// * `visible_tiles` - Tile set for this frame.
3887    /// * `vector_meshes` - Tessellated vector layers.
3888    /// * `model_instances` - 3D model instances.
3889    ///
3890    /// # Returns
3891    ///
3892    /// `Some(pixels)` on success, `None` if the GPU readback fails.
3893    pub fn render_to_buffer(
3894        &mut self,
3895        state: &MapState,
3896        device: &wgpu::Device,
3897        queue: &wgpu::Queue,
3898        visible_tiles: &[VisibleTile],
3899        vector_meshes: &[VectorMeshData],
3900        model_instances: &[ModelInstance],
3901    ) -> Option<Vec<u8>> {
3902        let format = wgpu::TextureFormat::Rgba8UnormSrgb;
3903
3904        let color_tex = device.create_texture(&wgpu::TextureDescriptor {
3905            label: Some("rustial_render_to_buffer_color"),
3906            size: wgpu::Extent3d {
3907                width: self.width,
3908                height: self.height,
3909                depth_or_array_layers: 1,
3910            },
3911            mip_level_count: 1,
3912            sample_count: 1,
3913            dimension: wgpu::TextureDimension::D2,
3914            format,
3915            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
3916            view_formats: &[],
3917        });
3918        let color_view = color_tex.create_view(&wgpu::TextureViewDescriptor::default());
3919
3920        let clear_color = state.computed_fog().clear_color;
3921
3922        self.render_full(&RenderParams {
3923            state,
3924            device,
3925            queue,
3926            color_view: &color_view,
3927            visible_tiles,
3928            vector_meshes,
3929            model_instances,
3930            clear_color,
3931        });
3932
3933        let bytes_per_row = self.width * 4;
3934        let buffer_size = (bytes_per_row * self.height) as wgpu::BufferAddress;
3935        let readback = device.create_buffer(&wgpu::BufferDescriptor {
3936            label: Some("rustial_render_to_buffer_readback"),
3937            size: buffer_size,
3938            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
3939            mapped_at_creation: false,
3940        });
3941
3942        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
3943            label: Some("rustial_render_to_buffer_encoder"),
3944        });
3945        encoder.copy_texture_to_buffer(
3946            wgpu::TexelCopyTextureInfo {
3947                texture: &color_tex,
3948                mip_level: 0,
3949                origin: wgpu::Origin3d::ZERO,
3950                aspect: wgpu::TextureAspect::All,
3951            },
3952            wgpu::TexelCopyBufferInfo {
3953                buffer: &readback,
3954                layout: wgpu::TexelCopyBufferLayout {
3955                    offset: 0,
3956                    bytes_per_row: Some(bytes_per_row),
3957                    rows_per_image: Some(self.height),
3958                },
3959            },
3960            wgpu::Extent3d {
3961                width: self.width,
3962                height: self.height,
3963                depth_or_array_layers: 1,
3964            },
3965        );
3966        queue.submit(std::iter::once(encoder.finish()));
3967
3968        let slice = readback.slice(..);
3969        let (tx, rx) = std::sync::mpsc::channel();
3970        slice.map_async(wgpu::MapMode::Read, move |res| {
3971            let _ = tx.send(res);
3972        });
3973        let _ = device.poll(wgpu::PollType::wait());
3974        rx.recv().ok()?.ok()?;
3975
3976        let data = slice.get_mapped_range().to_vec();
3977        readback.unmap();
3978        Some(data)
3979    }
3980
3981    /// Return the current renderer width in pixels.
3982    pub fn width(&self) -> u32 {
3983        self.width
3984    }
3985
3986    /// Return the current renderer height in pixels.
3987    pub fn height(&self) -> u32 {
3988        self.height
3989    }
3990
3991    /// Return per-frame visualization cache activity from the last render.
3992    pub fn visualization_perf_stats(&self) -> VisualizationPerfStats {
3993        self.visualization_perf_stats
3994    }
3995
3996    /// Return atlas health diagnostics for the tile atlas.
3997    ///
3998    /// Useful for performance overlays and automated tests.  Call after
3999    /// [`render_full`](Self::render_full) for post-frame metrics, or at
4000    /// any time for the current snapshot.
4001    pub fn tile_atlas_diagnostics(&self) -> crate::gpu::tile_atlas::AtlasDiagnostics {
4002        self.tile_atlas.diagnostics()
4003    }
4004
4005    /// Return atlas health diagnostics for the hillshade atlas.
4006    pub fn hillshade_atlas_diagnostics(&self) -> crate::gpu::tile_atlas::AtlasDiagnostics {
4007        self.hillshade_atlas.diagnostics()
4008    }
4009}
4010
4011// ---------------------------------------------------------------------------
4012// Helpers (module-private)
4013// ---------------------------------------------------------------------------
4014
4015fn build_grid_scalar_geometry(
4016    grid: &rustial_engine::GeoGrid,
4017    state: &MapState,
4018    scene_origin: DVec3,
4019) -> (Vec<GridScalarVertex>, Vec<u32>) {
4020    let rows = grid.rows.max(1);
4021    let cols = grid.cols.max(1);
4022    let mut vertices = Vec::with_capacity((rows + 1) * (cols + 1));
4023    let mut indices = Vec::with_capacity(rows * cols * 6);
4024
4025    for row in 0..=rows {
4026        for col in 0..=cols {
4027            let u = col as f32 / cols as f32;
4028            let v = row as f32 / rows as f32;
4029            let coord = grid_corner_coord(grid, row, col, state);
4030            let projected = state.camera().projection().project(&coord);
4031            vertices.push(GridScalarVertex {
4032                position: [
4033                    (projected.position.x - scene_origin.x) as f32,
4034                    (projected.position.y - scene_origin.y) as f32,
4035                    (projected.position.z - scene_origin.z + 0.05) as f32,
4036                ],
4037                uv: [u, v],
4038            });
4039        }
4040    }
4041
4042    for row in 0..rows {
4043        for col in 0..cols {
4044            let tl = (row * (cols + 1) + col) as u32;
4045            let tr = tl + 1;
4046            let bl = ((row + 1) * (cols + 1) + col) as u32;
4047            let br = bl + 1;
4048            indices.extend_from_slice(&[tl, bl, tr, tr, bl, br]);
4049        }
4050    }
4051
4052    (vertices, indices)
4053}
4054
4055fn grid_corner_coord(
4056    grid: &rustial_engine::GeoGrid,
4057    row: usize,
4058    col: usize,
4059    state: &MapState,
4060) -> rustial_math::GeoCoord {
4061    let dx = col as f64 * grid.cell_width;
4062    let dy = row as f64 * grid.cell_height;
4063    let (sin_r, cos_r) = grid.rotation.sin_cos();
4064    let rx = dx * cos_r - dy * sin_r;
4065    let ry = dx * sin_r + dy * cos_r;
4066    let coord = offset_geo_coord(&grid.origin, rx, ry);
4067    let altitude = resolve_grid_surface_altitude(grid, &coord, state);
4068    rustial_math::GeoCoord::new(coord.lat, coord.lon, altitude)
4069}
4070
4071fn create_grid_scalar_texture(
4072    device: &wgpu::Device,
4073    queue: &wgpu::Queue,
4074    field: &rustial_engine::ScalarField2D,
4075) -> wgpu::Texture {
4076    let size = wgpu::Extent3d {
4077        width: field.cols.max(1) as u32,
4078        height: field.rows.max(1) as u32,
4079        depth_or_array_layers: 1,
4080    };
4081    let texture = device.create_texture(&wgpu::TextureDescriptor {
4082        label: Some("grid_scalar_field_texture"),
4083        size,
4084        mip_level_count: 1,
4085        sample_count: 1,
4086        dimension: wgpu::TextureDimension::D2,
4087        format: wgpu::TextureFormat::R32Float,
4088        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4089        view_formats: &[],
4090    });
4091    write_grid_scalar_texture(queue, &texture, field);
4092    texture
4093}
4094
4095fn write_grid_scalar_texture(
4096    queue: &wgpu::Queue,
4097    texture: &wgpu::Texture,
4098    field: &rustial_engine::ScalarField2D,
4099) {
4100    let size = wgpu::Extent3d {
4101        width: field.cols.max(1) as u32,
4102        height: field.rows.max(1) as u32,
4103        depth_or_array_layers: 1,
4104    };
4105    queue.write_texture(
4106        wgpu::TexelCopyTextureInfo {
4107            texture,
4108            mip_level: 0,
4109            origin: wgpu::Origin3d::ZERO,
4110            aspect: wgpu::TextureAspect::All,
4111        },
4112        bytemuck::cast_slice(&field.data),
4113        wgpu::TexelCopyBufferLayout {
4114            offset: 0,
4115            bytes_per_row: Some(field.cols.max(1) as u32 * 4),
4116            rows_per_image: Some(field.rows.max(1) as u32),
4117        },
4118        size,
4119    );
4120}
4121
4122fn create_grid_scalar_ramp_texture(
4123    device: &wgpu::Device,
4124    queue: &wgpu::Queue,
4125    ramp: &rustial_engine::ColorRamp,
4126) -> wgpu::Texture {
4127    let width = 256u32;
4128    let data = ramp.as_texture_data(width);
4129    let size = wgpu::Extent3d {
4130        width,
4131        height: 1,
4132        depth_or_array_layers: 1,
4133    };
4134    let texture = device.create_texture(&wgpu::TextureDescriptor {
4135        label: Some("grid_scalar_ramp_texture"),
4136        size,
4137        mip_level_count: 1,
4138        sample_count: 1,
4139        dimension: wgpu::TextureDimension::D2,
4140        format: wgpu::TextureFormat::Rgba8UnormSrgb,
4141        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4142        view_formats: &[],
4143    });
4144    queue.write_texture(
4145        wgpu::TexelCopyTextureInfo {
4146            texture: &texture,
4147            mip_level: 0,
4148            origin: wgpu::Origin3d::ZERO,
4149            aspect: wgpu::TextureAspect::All,
4150        },
4151        &data,
4152        wgpu::TexelCopyBufferLayout {
4153            offset: 0,
4154            bytes_per_row: Some(width * 4),
4155            rows_per_image: Some(1),
4156        },
4157        size,
4158    );
4159    texture
4160}
4161
4162// ---------------------------------------------------------------------------
4163// Heatmap two-pass helpers
4164// ---------------------------------------------------------------------------
4165
4166/// Create the off-screen R16Float accumulation texture for heatmap Pass 1.
4167fn create_heatmap_accum_texture(
4168    device: &wgpu::Device,
4169    width: u32,
4170    height: u32,
4171) -> (wgpu::Texture, wgpu::TextureView) {
4172    let texture = device.create_texture(&wgpu::TextureDescriptor {
4173        label: Some("heatmap_accum_texture"),
4174        size: wgpu::Extent3d {
4175            width,
4176            height,
4177            depth_or_array_layers: 1,
4178        },
4179        mip_level_count: 1,
4180        sample_count: 1,
4181        dimension: wgpu::TextureDimension::D2,
4182        format: wgpu::TextureFormat::R16Float,
4183        usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
4184        view_formats: &[],
4185    });
4186    let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
4187    (texture, view)
4188}
4189
4190/// Create a default 256×1 Rgba8Unorm heatmap colour ramp texture.
4191///
4192/// The ramp interpolates through: transparent → royal blue → cyan → lime →
4193/// yellow → red, matching the MapLibre default heat stops.
4194#[allow(clippy::needless_range_loop)]
4195fn create_default_heatmap_ramp_texture(
4196    device: &wgpu::Device,
4197    queue: &wgpu::Queue,
4198) -> wgpu::Texture {
4199    const WIDTH: u32 = 256;
4200    let stops: &[(f32, [u8; 4])] = &[
4201        (0.00, [0, 0, 0, 0]),
4202        (0.10, [65, 105, 225, 255]),
4203        (0.30, [0, 255, 255, 255]),
4204        (0.50, [0, 255, 0, 255]),
4205        (0.70, [255, 255, 0, 255]),
4206        (1.00, [255, 0, 0, 255]),
4207    ];
4208
4209    let mut data = vec![0u8; WIDTH as usize * 4];
4210    for i in 0..WIDTH as usize {
4211        let t = i as f32 / (WIDTH - 1) as f32;
4212        // Find the two surrounding stops.
4213        let mut lo = 0;
4214        for s in 1..stops.len() {
4215            if stops[s].0 >= t {
4216                lo = s - 1;
4217                break;
4218            }
4219        }
4220        let hi = (lo + 1).min(stops.len() - 1);
4221        let range = stops[hi].0 - stops[lo].0;
4222        let frac = if range > 0.0 {
4223            (t - stops[lo].0) / range
4224        } else {
4225            0.0
4226        };
4227        for c in 0..4 {
4228            let a = stops[lo].1[c] as f32;
4229            let b = stops[hi].1[c] as f32;
4230            data[i * 4 + c] = (a + (b - a) * frac).round() as u8;
4231        }
4232    }
4233
4234    let size = wgpu::Extent3d {
4235        width: WIDTH,
4236        height: 1,
4237        depth_or_array_layers: 1,
4238    };
4239    let texture = device.create_texture(&wgpu::TextureDescriptor {
4240        label: Some("heatmap_ramp_texture"),
4241        size,
4242        mip_level_count: 1,
4243        sample_count: 1,
4244        dimension: wgpu::TextureDimension::D2,
4245        format: wgpu::TextureFormat::Rgba8Unorm,
4246        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4247        view_formats: &[],
4248    });
4249    queue.write_texture(
4250        wgpu::TexelCopyTextureInfo {
4251            texture: &texture,
4252            mip_level: 0,
4253            origin: wgpu::Origin3d::ZERO,
4254            aspect: wgpu::TextureAspect::All,
4255        },
4256        &data,
4257        wgpu::TexelCopyBufferLayout {
4258            offset: 0,
4259            bytes_per_row: Some(WIDTH * 4),
4260            rows_per_image: Some(1),
4261        },
4262        size,
4263    );
4264    texture
4265}
4266
4267/// Create the bind group for heatmap colour-mapping Pass 2 (group 1).
4268fn create_heatmap_colormap_bind_group(
4269    device: &wgpu::Device,
4270    layout: &wgpu::BindGroupLayout,
4271    accum_view: &wgpu::TextureView,
4272    ramp_view: &wgpu::TextureView,
4273    sampler: &wgpu::Sampler,
4274) -> wgpu::BindGroup {
4275    device.create_bind_group(&wgpu::BindGroupDescriptor {
4276        label: Some("heatmap_colormap_textures_bg"),
4277        layout,
4278        entries: &[
4279            wgpu::BindGroupEntry {
4280                binding: 0,
4281                resource: wgpu::BindingResource::TextureView(accum_view),
4282            },
4283            wgpu::BindGroupEntry {
4284                binding: 1,
4285                resource: wgpu::BindingResource::TextureView(ramp_view),
4286            },
4287            wgpu::BindGroupEntry {
4288                binding: 2,
4289                resource: wgpu::BindingResource::Sampler(sampler),
4290            },
4291        ],
4292    })
4293}
4294
4295fn build_grid_scalar_uniform(
4296    grid: &rustial_engine::GeoGrid,
4297    field: &rustial_engine::ScalarField2D,
4298    state: &MapState,
4299    scene_origin: DVec3,
4300    opacity: f32,
4301) -> GridScalarUniform {
4302    let projection_kind = match state.camera().projection() {
4303        rustial_engine::CameraProjection::WebMercator => 0.0,
4304        rustial_engine::CameraProjection::Equirectangular => 1.0,
4305        _ => 0.0,
4306    };
4307    let base_altitude = match grid.altitude_mode {
4308        rustial_engine::AltitudeMode::ClampToGround => 0.0,
4309        rustial_engine::AltitudeMode::RelativeToGround => grid.origin.alt as f32,
4310        rustial_engine::AltitudeMode::Absolute => grid.origin.alt as f32,
4311    };
4312    GridScalarUniform {
4313        origin_counts: [
4314            grid.origin.lat as f32,
4315            grid.origin.lon as f32,
4316            grid.rows as f32,
4317            grid.cols as f32,
4318        ],
4319        grid_params: [
4320            grid.cell_width as f32,
4321            grid.cell_height as f32,
4322            grid.rotation as f32,
4323            opacity,
4324        ],
4325        scene_origin: [
4326            scene_origin.x as f32,
4327            scene_origin.y as f32,
4328            scene_origin.z as f32,
4329            projection_kind,
4330        ],
4331        value_params: [
4332            field.min,
4333            field.max,
4334            field.nan_value.unwrap_or(0.0),
4335            if field.nan_value.is_some() { 1.0 } else { 0.0 },
4336        ],
4337        base_altitude: [base_altitude, 0.0, 0.0, 0.0],
4338    }
4339}
4340
4341fn grid_scalar_ramp_fingerprint(ramp: &rustial_engine::ColorRamp) -> u64 {
4342    let mut h = ramp.stops.len() as u64;
4343    for stop in &ramp.stops {
4344        h = h
4345            .wrapping_mul(31)
4346            .wrapping_add(stop.value.to_bits() as u64)
4347            .wrapping_mul(31)
4348            .wrapping_add(stop.color[0].to_bits() as u64)
4349            .wrapping_mul(31)
4350            .wrapping_add(stop.color[1].to_bits() as u64)
4351            .wrapping_mul(31)
4352            .wrapping_add(stop.color[2].to_bits() as u64)
4353            .wrapping_mul(31)
4354            .wrapping_add(stop.color[3].to_bits() as u64);
4355    }
4356    h
4357}
4358
4359fn grid_extrusion_params_fingerprint(params: &rustial_engine::ExtrusionParams) -> u64 {
4360    (params.height_scale.to_bits())
4361        .wrapping_mul(31)
4362        .wrapping_add(params.base_meters.to_bits())
4363}
4364
4365fn grid_extrusion_grid_fingerprint(grid: &rustial_engine::GeoGrid) -> u64 {
4366    let mut h = 17u64;
4367    h = h.wrapping_mul(31).wrapping_add(grid.origin.lat.to_bits());
4368    h = h.wrapping_mul(31).wrapping_add(grid.origin.lon.to_bits());
4369    h = h.wrapping_mul(31).wrapping_add(grid.origin.alt.to_bits());
4370    h = h.wrapping_mul(31).wrapping_add(grid.rows as u64);
4371    h = h.wrapping_mul(31).wrapping_add(grid.cols as u64);
4372    h = h.wrapping_mul(31).wrapping_add(grid.cell_width.to_bits());
4373    h = h.wrapping_mul(31).wrapping_add(grid.cell_height.to_bits());
4374    h = h.wrapping_mul(31).wrapping_add(grid.rotation.to_bits());
4375    h = h.wrapping_mul(31).wrapping_add(match grid.altitude_mode {
4376        rustial_engine::AltitudeMode::ClampToGround => 0,
4377        rustial_engine::AltitudeMode::RelativeToGround => 1,
4378        rustial_engine::AltitudeMode::Absolute => 2,
4379    });
4380    h
4381}
4382
4383fn build_grid_extrusion_geometry(
4384    grid: &rustial_engine::GeoGrid,
4385    field: &rustial_engine::ScalarField2D,
4386    ramp: &rustial_engine::ColorRamp,
4387    params: &rustial_engine::ExtrusionParams,
4388    state: &MapState,
4389    scene_origin: DVec3,
4390) -> (Vec<GridExtrusionVertex>, Vec<u32>) {
4391    let mut vertices = Vec::new();
4392    let mut indices = Vec::new();
4393
4394    for row in 0..grid.rows {
4395        for col in 0..grid.cols {
4396            let Some(value) = field.sample(row, col) else {
4397                continue;
4398            };
4399
4400            let t = field.normalized(row, col).unwrap_or(0.5);
4401            let color = ramp.evaluate(t);
4402            let corners = grid_cell_corners_world(grid, row, col, state, scene_origin, params);
4403            append_extruded_cell_geometry(
4404                &mut vertices,
4405                &mut indices,
4406                corners,
4407                value * params.height_scale as f32,
4408                color,
4409            );
4410        }
4411    }
4412
4413    (vertices, indices)
4414}
4415
4416fn append_extruded_cell_geometry(
4417    vertices: &mut Vec<GridExtrusionVertex>,
4418    indices: &mut Vec<u32>,
4419    corners: [[f32; 3]; 4],
4420    extrusion_height: f32,
4421    color: [f32; 4],
4422) {
4423    let [nw, ne, sw, se] = corners;
4424    let top = [
4425        [nw[0], nw[1], nw[2] + extrusion_height],
4426        [ne[0], ne[1], ne[2] + extrusion_height],
4427        [sw[0], sw[1], sw[2] + extrusion_height],
4428        [se[0], se[1], se[2] + extrusion_height],
4429    ];
4430    let base = [nw, ne, sw, se];
4431
4432    append_quad(
4433        vertices,
4434        indices,
4435        top[0],
4436        top[1],
4437        top[2],
4438        top[3],
4439        [0.0, 0.0, 1.0],
4440        color,
4441    );
4442    append_quad(
4443        vertices,
4444        indices,
4445        base[0],
4446        base[1],
4447        top[0],
4448        top[1],
4449        [0.0, -1.0, 0.0],
4450        color,
4451    );
4452    append_quad(
4453        vertices,
4454        indices,
4455        top[2],
4456        top[3],
4457        base[2],
4458        base[3],
4459        [0.0, 1.0, 0.0],
4460        color,
4461    );
4462    append_quad(
4463        vertices,
4464        indices,
4465        base[0],
4466        top[0],
4467        base[2],
4468        top[2],
4469        [-1.0, 0.0, 0.0],
4470        color,
4471    );
4472    append_quad(
4473        vertices,
4474        indices,
4475        top[1],
4476        base[1],
4477        top[3],
4478        base[3],
4479        [1.0, 0.0, 0.0],
4480        color,
4481    );
4482}
4483
4484#[allow(clippy::too_many_arguments)]
4485fn append_quad(
4486    vertices: &mut Vec<GridExtrusionVertex>,
4487    indices: &mut Vec<u32>,
4488    a: [f32; 3],
4489    b: [f32; 3],
4490    c: [f32; 3],
4491    d: [f32; 3],
4492    normal: [f32; 3],
4493    color: [f32; 4],
4494) {
4495    let base_index = vertices.len() as u32;
4496    vertices.extend_from_slice(&[
4497        GridExtrusionVertex {
4498            position: a,
4499            normal,
4500            color,
4501        },
4502        GridExtrusionVertex {
4503            position: b,
4504            normal,
4505            color,
4506        },
4507        GridExtrusionVertex {
4508            position: c,
4509            normal,
4510            color,
4511        },
4512        GridExtrusionVertex {
4513            position: d,
4514            normal,
4515            color,
4516        },
4517    ]);
4518    indices.extend_from_slice(&[
4519        base_index,
4520        base_index + 2,
4521        base_index + 1,
4522        base_index + 1,
4523        base_index + 2,
4524        base_index + 3,
4525    ]);
4526}
4527
4528fn grid_cell_corners_world(
4529    grid: &rustial_engine::GeoGrid,
4530    row: usize,
4531    col: usize,
4532    state: &MapState,
4533    scene_origin: DVec3,
4534    params: &rustial_engine::ExtrusionParams,
4535) -> [[f32; 3]; 4] {
4536    let nw = project_grid_offset(
4537        grid,
4538        col as f64 * grid.cell_width,
4539        row as f64 * grid.cell_height,
4540        state,
4541        scene_origin,
4542        params,
4543    );
4544    let ne = project_grid_offset(
4545        grid,
4546        (col + 1) as f64 * grid.cell_width,
4547        row as f64 * grid.cell_height,
4548        state,
4549        scene_origin,
4550        params,
4551    );
4552    let sw = project_grid_offset(
4553        grid,
4554        col as f64 * grid.cell_width,
4555        (row + 1) as f64 * grid.cell_height,
4556        state,
4557        scene_origin,
4558        params,
4559    );
4560    let se = project_grid_offset(
4561        grid,
4562        (col + 1) as f64 * grid.cell_width,
4563        (row + 1) as f64 * grid.cell_height,
4564        state,
4565        scene_origin,
4566        params,
4567    );
4568    [nw, ne, sw, se]
4569}
4570
4571fn project_grid_offset(
4572    grid: &rustial_engine::GeoGrid,
4573    dx: f64,
4574    dy: f64,
4575    state: &MapState,
4576    scene_origin: DVec3,
4577    params: &rustial_engine::ExtrusionParams,
4578) -> [f32; 3] {
4579    let (sin_r, cos_r) = grid.rotation.sin_cos();
4580    let rx = dx * cos_r - dy * sin_r;
4581    let ry = dx * sin_r + dy * cos_r;
4582    let coord = offset_geo_coord(&grid.origin, rx, ry);
4583    let altitude = resolve_grid_base_altitude(grid, &coord, state, params) as f64;
4584    let elevated_coord = rustial_math::GeoCoord::new(coord.lat, coord.lon, altitude);
4585    let projected = state.camera().projection().project(&elevated_coord);
4586    [
4587        (projected.position.x - scene_origin.x) as f32,
4588        (projected.position.y - scene_origin.y) as f32,
4589        (projected.position.z - scene_origin.z) as f32,
4590    ]
4591}
4592
4593fn offset_geo_coord(
4594    origin: &rustial_math::GeoCoord,
4595    dx_meters: f64,
4596    dy_meters: f64,
4597) -> rustial_math::GeoCoord {
4598    const METERS_PER_DEG_LAT: f64 = 111_320.0;
4599    let lat = origin.lat - dy_meters / METERS_PER_DEG_LAT;
4600    let cos_lat = origin.lat.to_radians().cos().max(1e-10);
4601    let lon = origin.lon + dx_meters / (METERS_PER_DEG_LAT * cos_lat);
4602    rustial_math::GeoCoord::new(lat, lon, origin.alt)
4603}
4604
4605fn resolve_grid_base_altitude(
4606    grid: &rustial_engine::GeoGrid,
4607    coord: &rustial_math::GeoCoord,
4608    state: &MapState,
4609    params: &rustial_engine::ExtrusionParams,
4610) -> f32 {
4611    let terrain = state.elevation_at(coord).unwrap_or(0.0);
4612    match grid.altitude_mode {
4613        rustial_engine::AltitudeMode::ClampToGround => (terrain + params.base_meters) as f32,
4614        rustial_engine::AltitudeMode::RelativeToGround => {
4615            (terrain + grid.origin.alt + params.base_meters) as f32
4616        }
4617        rustial_engine::AltitudeMode::Absolute => (grid.origin.alt + params.base_meters) as f32,
4618    }
4619}
4620
4621fn resolve_grid_surface_altitude(
4622    grid: &rustial_engine::GeoGrid,
4623    coord: &rustial_math::GeoCoord,
4624    state: &MapState,
4625) -> f64 {
4626    let terrain = state.elevation_at(coord).unwrap_or(0.0);
4627    match grid.altitude_mode {
4628        rustial_engine::AltitudeMode::ClampToGround => terrain,
4629        rustial_engine::AltitudeMode::RelativeToGround => terrain + grid.origin.alt,
4630        rustial_engine::AltitudeMode::Absolute => grid.origin.alt,
4631    }
4632}
4633
4634fn build_unit_column_mesh() -> (Vec<ColumnVertex>, Vec<u32>) {
4635    let vertices = vec![
4636        // top
4637        ColumnVertex {
4638            position: [-0.5, -0.5, 1.0],
4639            normal: [0.0, 0.0, 1.0],
4640        },
4641        ColumnVertex {
4642            position: [0.5, -0.5, 1.0],
4643            normal: [0.0, 0.0, 1.0],
4644        },
4645        ColumnVertex {
4646            position: [-0.5, 0.5, 1.0],
4647            normal: [0.0, 0.0, 1.0],
4648        },
4649        ColumnVertex {
4650            position: [0.5, 0.5, 1.0],
4651            normal: [0.0, 0.0, 1.0],
4652        },
4653        // bottom
4654        ColumnVertex {
4655            position: [-0.5, -0.5, 0.0],
4656            normal: [0.0, 0.0, -1.0],
4657        },
4658        ColumnVertex {
4659            position: [0.5, -0.5, 0.0],
4660            normal: [0.0, 0.0, -1.0],
4661        },
4662        ColumnVertex {
4663            position: [-0.5, 0.5, 0.0],
4664            normal: [0.0, 0.0, -1.0],
4665        },
4666        ColumnVertex {
4667            position: [0.5, 0.5, 0.0],
4668            normal: [0.0, 0.0, -1.0],
4669        },
4670        // north
4671        ColumnVertex {
4672            position: [-0.5, -0.5, 0.0],
4673            normal: [0.0, -1.0, 0.0],
4674        },
4675        ColumnVertex {
4676            position: [0.5, -0.5, 0.0],
4677            normal: [0.0, -1.0, 0.0],
4678        },
4679        ColumnVertex {
4680            position: [-0.5, -0.5, 1.0],
4681            normal: [0.0, -1.0, 0.0],
4682        },
4683        ColumnVertex {
4684            position: [0.5, -0.5, 1.0],
4685            normal: [0.0, -1.0, 0.0],
4686        },
4687        // south
4688        ColumnVertex {
4689            position: [-0.5, 0.5, 1.0],
4690            normal: [0.0, 1.0, 0.0],
4691        },
4692        ColumnVertex {
4693            position: [0.5, 0.5, 1.0],
4694            normal: [0.0, 1.0, 0.0],
4695        },
4696        ColumnVertex {
4697            position: [-0.5, 0.5, 0.0],
4698            normal: [0.0, 1.0, 0.0],
4699        },
4700        ColumnVertex {
4701            position: [0.5, 0.5, 0.0],
4702            normal: [0.0, 1.0, 0.0],
4703        },
4704        // west
4705        ColumnVertex {
4706            position: [-0.5, -0.5, 0.0],
4707            normal: [-1.0, 0.0, 0.0],
4708        },
4709        ColumnVertex {
4710            position: [-0.5, -0.5, 1.0],
4711            normal: [-1.0, 0.0, 0.0],
4712        },
4713        ColumnVertex {
4714            position: [-0.5, 0.5, 0.0],
4715            normal: [-1.0, 0.0, 0.0],
4716        },
4717        ColumnVertex {
4718            position: [-0.5, 0.5, 1.0],
4719            normal: [-1.0, 0.0, 0.0],
4720        },
4721        // east
4722        ColumnVertex {
4723            position: [0.5, -0.5, 1.0],
4724            normal: [1.0, 0.0, 0.0],
4725        },
4726        ColumnVertex {
4727            position: [0.5, -0.5, 0.0],
4728            normal: [1.0, 0.0, 0.0],
4729        },
4730        ColumnVertex {
4731            position: [0.5, 0.5, 1.0],
4732            normal: [1.0, 0.0, 0.0],
4733        },
4734        ColumnVertex {
4735            position: [0.5, 0.5, 0.0],
4736            normal: [1.0, 0.0, 0.0],
4737        },
4738    ];
4739    let indices = vec![
4740        0, 2, 1, 1, 2, 3, 4, 5, 6, 5, 7, 6, 8, 10, 9, 9, 10, 11, 12, 14, 13, 13, 14, 15, 16, 18,
4741        17, 17, 18, 19, 20, 22, 21, 21, 22, 23,
4742    ];
4743    (vertices, indices)
4744}
4745
4746fn build_column_instances(
4747    columns: &rustial_engine::ColumnInstanceSet,
4748    ramp: &rustial_engine::ColorRamp,
4749    state: &MapState,
4750    scene_origin: DVec3,
4751) -> Vec<ColumnInstanceData> {
4752    let (min_height, max_height) = column_height_range(columns);
4753    columns
4754        .columns
4755        .iter()
4756        .map(|column| {
4757            let projected = state.camera().projection().project(&column.position);
4758            let base_z = resolve_column_base_altitude(column, state);
4759            let normalized = if (max_height - min_height).abs() < f64::EPSILON {
4760                0.5
4761            } else {
4762                ((column.height - min_height) / (max_height - min_height)).clamp(0.0, 1.0)
4763            } as f32;
4764            let color = column.color.unwrap_or_else(|| ramp.evaluate(normalized));
4765            ColumnInstanceData {
4766                base_position: [
4767                    (projected.position.x - scene_origin.x) as f32,
4768                    (projected.position.y - scene_origin.y) as f32,
4769                    (base_z - scene_origin.z) as f32,
4770                ],
4771                dimensions: [column.width as f32, column.height as f32, 0.0, 0.0],
4772                color,
4773            }
4774        })
4775        .collect()
4776}
4777
4778fn build_point_instances(
4779    points: &rustial_engine::PointInstanceSet,
4780    ramp: &rustial_engine::ColorRamp,
4781    state: &MapState,
4782    scene_origin: DVec3,
4783) -> Vec<ColumnInstanceData> {
4784    points
4785        .points
4786        .iter()
4787        .map(|point| {
4788            let projected = state.camera().projection().project(&point.position);
4789            let center_z = resolve_point_altitude(point, state);
4790            let diameter = (point.radius * 2.0) as f32;
4791            let color = point
4792                .color
4793                .unwrap_or_else(|| ramp.evaluate(point.intensity.clamp(0.0, 1.0)));
4794            ColumnInstanceData {
4795                base_position: [
4796                    (projected.position.x - scene_origin.x) as f32,
4797                    (projected.position.y - scene_origin.y) as f32,
4798                    (center_z - scene_origin.z - point.radius) as f32,
4799                ],
4800                dimensions: [diameter, diameter, 0.0, 0.0],
4801                color,
4802            }
4803        })
4804        .collect()
4805}
4806
4807fn column_height_range(columns: &rustial_engine::ColumnInstanceSet) -> (f64, f64) {
4808    let mut min_height = f64::INFINITY;
4809    let mut max_height = f64::NEG_INFINITY;
4810    for column in &columns.columns {
4811        min_height = min_height.min(column.height);
4812        max_height = max_height.max(column.height);
4813    }
4814    if min_height.is_infinite() || max_height.is_infinite() {
4815        (0.0, 0.0)
4816    } else {
4817        (min_height, max_height)
4818    }
4819}
4820
4821fn resolve_point_altitude(point: &rustial_engine::PointInstance, state: &MapState) -> f64 {
4822    let terrain = state.elevation_at(&point.position).unwrap_or(0.0);
4823    match point.altitude_mode {
4824        rustial_engine::AltitudeMode::ClampToGround => terrain,
4825        rustial_engine::AltitudeMode::RelativeToGround => terrain + point.position.alt,
4826        rustial_engine::AltitudeMode::Absolute => point.position.alt,
4827    }
4828}
4829
4830fn resolve_column_base_altitude(column: &rustial_engine::ColumnInstance, state: &MapState) -> f64 {
4831    let terrain = state.elevation_at(&column.position).unwrap_or(0.0);
4832    match column.altitude_mode {
4833        rustial_engine::AltitudeMode::ClampToGround => terrain + column.base,
4834        rustial_engine::AltitudeMode::RelativeToGround => {
4835            terrain + column.position.alt + column.base
4836        }
4837        rustial_engine::AltitudeMode::Absolute => column.position.alt + column.base,
4838    }
4839}
4840
4841fn column_set_fingerprint(columns: &rustial_engine::ColumnInstanceSet) -> u64 {
4842    let mut h = columns.columns.len() as u64;
4843    for column in &columns.columns {
4844        h = h
4845            .wrapping_mul(31)
4846            .wrapping_add(column.position.lat.to_bits());
4847        h = h
4848            .wrapping_mul(31)
4849            .wrapping_add(column.position.lon.to_bits());
4850        h = h
4851            .wrapping_mul(31)
4852            .wrapping_add(column.position.alt.to_bits());
4853        h = h.wrapping_mul(31).wrapping_add(column.height.to_bits());
4854        h = h.wrapping_mul(31).wrapping_add(column.base.to_bits());
4855        h = h.wrapping_mul(31).wrapping_add(column.width.to_bits());
4856        h = h.wrapping_mul(31).wrapping_add(column.pick_id);
4857        h = h.wrapping_mul(31).wrapping_add(match column.altitude_mode {
4858            rustial_engine::AltitudeMode::ClampToGround => 0,
4859            rustial_engine::AltitudeMode::RelativeToGround => 1,
4860            rustial_engine::AltitudeMode::Absolute => 2,
4861        });
4862        if let Some(color) = column.color {
4863            h = h.wrapping_mul(31).wrapping_add(color[0].to_bits() as u64);
4864            h = h.wrapping_mul(31).wrapping_add(color[1].to_bits() as u64);
4865            h = h.wrapping_mul(31).wrapping_add(color[2].to_bits() as u64);
4866            h = h.wrapping_mul(31).wrapping_add(color[3].to_bits() as u64);
4867        }
4868    }
4869    h
4870}
4871
4872fn point_set_fingerprint(points: &rustial_engine::PointInstanceSet) -> u64 {
4873    let mut h = points.points.len() as u64;
4874    for point in &points.points {
4875        h = h
4876            .wrapping_mul(31)
4877            .wrapping_add(point.position.lat.to_bits());
4878        h = h
4879            .wrapping_mul(31)
4880            .wrapping_add(point.position.lon.to_bits());
4881        h = h
4882            .wrapping_mul(31)
4883            .wrapping_add(point.position.alt.to_bits());
4884        h = h.wrapping_mul(31).wrapping_add(point.radius.to_bits());
4885        h = h
4886            .wrapping_mul(31)
4887            .wrapping_add(point.intensity.to_bits() as u64);
4888        h = h.wrapping_mul(31).wrapping_add(point.pick_id);
4889        h = h.wrapping_mul(31).wrapping_add(match point.altitude_mode {
4890            rustial_engine::AltitudeMode::ClampToGround => 0,
4891            rustial_engine::AltitudeMode::RelativeToGround => 1,
4892            rustial_engine::AltitudeMode::Absolute => 2,
4893        });
4894        if let Some(color) = point.color {
4895            h = h.wrapping_mul(31).wrapping_add(color[0].to_bits() as u64);
4896            h = h.wrapping_mul(31).wrapping_add(color[1].to_bits() as u64);
4897            h = h.wrapping_mul(31).wrapping_add(color[2].to_bits() as u64);
4898            h = h.wrapping_mul(31).wrapping_add(color[3].to_bits() as u64);
4899        }
4900    }
4901    h
4902}
4903
4904fn visualization_overlay_intersects_scene_viewport(
4905    overlay: &VisualizationOverlay,
4906    state: &MapState,
4907) -> bool {
4908    let scene_origin = state.scene_world_origin();
4909    let Some(bounds) = visualization_overlay_world_bounds(overlay, state, scene_origin) else {
4910        return false;
4911    };
4912    bounds.intersects(state.scene_viewport_bounds())
4913}
4914
4915fn visualization_overlay_world_bounds(
4916    overlay: &VisualizationOverlay,
4917    state: &MapState,
4918    scene_origin: DVec3,
4919) -> Option<rustial_math::WorldBounds> {
4920    match overlay {
4921        VisualizationOverlay::GridScalar { grid, .. }
4922        | VisualizationOverlay::GridExtrusion { grid, .. } => {
4923            Some(grid_world_bounds(grid, state, scene_origin))
4924        }
4925        VisualizationOverlay::Columns { columns, .. } => {
4926            column_world_bounds(columns, state, scene_origin)
4927        }
4928        VisualizationOverlay::Points { points, .. } => {
4929            point_world_bounds(points, state, scene_origin)
4930        }
4931    }
4932}
4933
4934fn grid_world_bounds(
4935    grid: &rustial_engine::GeoGrid,
4936    state: &MapState,
4937    scene_origin: DVec3,
4938) -> rustial_math::WorldBounds {
4939    let corners = [
4940        grid_corner_coord(grid, 0, 0, state),
4941        grid_corner_coord(grid, 0, grid.cols, state),
4942        grid_corner_coord(grid, grid.rows, 0, state),
4943        grid_corner_coord(grid, grid.rows, grid.cols, state),
4944    ];
4945    let projected: Vec<_> = corners
4946        .iter()
4947        .map(|coord| state.camera().projection().project(coord))
4948        .collect();
4949    let mut bounds = rustial_math::WorldBounds::new(
4950        rustial_math::WorldCoord::new(
4951            projected[0].position.x - scene_origin.x,
4952            projected[0].position.y - scene_origin.y,
4953            projected[0].position.z - scene_origin.z,
4954        ),
4955        rustial_math::WorldCoord::new(
4956            projected[0].position.x - scene_origin.x,
4957            projected[0].position.y - scene_origin.y,
4958            projected[0].position.z - scene_origin.z,
4959        ),
4960    );
4961    for projected in projected.into_iter().skip(1) {
4962        bounds.extend_point(&rustial_math::WorldCoord::new(
4963            projected.position.x - scene_origin.x,
4964            projected.position.y - scene_origin.y,
4965            projected.position.z - scene_origin.z,
4966        ));
4967    }
4968    bounds
4969}
4970
4971fn point_world_bounds(
4972    points: &rustial_engine::PointInstanceSet,
4973    state: &MapState,
4974    scene_origin: DVec3,
4975) -> Option<rustial_math::WorldBounds> {
4976    let mut bounds: Option<rustial_math::WorldBounds> = None;
4977    for point in &points.points {
4978        let projected = state.camera().projection().project(&point.position);
4979        let radius = point.radius;
4980        let center_z = resolve_point_altitude(point, state) - scene_origin.z;
4981        let point_bounds = rustial_math::WorldBounds::new(
4982            rustial_math::WorldCoord::new(
4983                projected.position.x - scene_origin.x - radius,
4984                projected.position.y - scene_origin.y - radius,
4985                center_z - radius,
4986            ),
4987            rustial_math::WorldCoord::new(
4988                projected.position.x - scene_origin.x + radius,
4989                projected.position.y - scene_origin.y + radius,
4990                center_z + radius,
4991            ),
4992        );
4993        if let Some(existing) = bounds.as_mut() {
4994            existing.extend(&point_bounds);
4995        } else {
4996            bounds = Some(point_bounds);
4997        }
4998    }
4999    bounds
5000}
5001
5002fn column_world_bounds(
5003    columns: &rustial_engine::ColumnInstanceSet,
5004    state: &MapState,
5005    scene_origin: DVec3,
5006) -> Option<rustial_math::WorldBounds> {
5007    let mut bounds: Option<rustial_math::WorldBounds> = None;
5008    for column in &columns.columns {
5009        let projected = state.camera().projection().project(&column.position);
5010        let base_z = resolve_column_base_altitude(column, state) - scene_origin.z;
5011        let half_width = column.width * 0.5;
5012        let column_bounds = rustial_math::WorldBounds::new(
5013            rustial_math::WorldCoord::new(
5014                projected.position.x - scene_origin.x - half_width,
5015                projected.position.y - scene_origin.y - half_width,
5016                base_z,
5017            ),
5018            rustial_math::WorldCoord::new(
5019                projected.position.x - scene_origin.x + half_width,
5020                projected.position.y - scene_origin.y + half_width,
5021                base_z + column.height,
5022            ),
5023        );
5024        if let Some(existing) = bounds.as_mut() {
5025            existing.extend(&column_bounds);
5026        } else {
5027            bounds = Some(column_bounds);
5028        }
5029    }
5030    bounds
5031}
5032
5033fn build_shared_terrain_grid(resolution: usize) -> (Vec<TerrainGridVertex>, Vec<u32>) {
5034    let res = resolution.max(2);
5035    let mut vertices = Vec::with_capacity(res * res);
5036    let mut indices = Vec::with_capacity((res - 1) * (res - 1) * 6);
5037
5038    for row in 0..res {
5039        for col in 0..res {
5040            let u = col as f32 / (res - 1) as f32;
5041            let v = row as f32 / (res - 1) as f32;
5042            vertices.push(TerrainGridVertex {
5043                uv: [u, v],
5044                skirt: 0.0,
5045            });
5046        }
5047    }
5048
5049    for row in 0..(res - 1) {
5050        for col in 0..(res - 1) {
5051            let tl = (row * res + col) as u32;
5052            let tr = tl + 1;
5053            let bl = ((row + 1) * res + col) as u32;
5054            let br = bl + 1;
5055            indices.extend_from_slice(&[tl, bl, tr, tr, bl, br]);
5056        }
5057    }
5058
5059    let edges: [Vec<usize>; 4] = [
5060        (0..res).collect(),
5061        ((res - 1) * res..res * res).collect(),
5062        (0..res).map(|r| r * res).collect(),
5063        (0..res).map(|r| r * res + res - 1).collect(),
5064    ];
5065
5066    for edge in &edges {
5067        for i in 0..edge.len() - 1 {
5068            let a = edge[i] as u32;
5069            let b = edge[i + 1] as u32;
5070            let uv_a = vertices[edge[i]].uv;
5071            let uv_b = vertices[edge[i + 1]].uv;
5072            let base_a = vertices.len() as u32;
5073            let base_b = base_a + 1;
5074            vertices.push(TerrainGridVertex {
5075                uv: uv_a,
5076                skirt: 1.0,
5077            });
5078            vertices.push(TerrainGridVertex {
5079                uv: uv_b,
5080                skirt: 1.0,
5081            });
5082            indices.extend_from_slice(&[a, base_a, b, b, base_a, base_b]);
5083        }
5084    }
5085
5086    (vertices, indices)
5087}
5088
5089fn build_terrain_tile_uniform(
5090    mesh: &TerrainMeshData,
5091    elevation: &rustial_engine::TerrainElevationTexture,
5092    state: &MapState,
5093    scene_origin: DVec3,
5094) -> TerrainTileUniform {
5095    let nw = rustial_math::tile_to_geo(&mesh.tile);
5096    let se = rustial_math::tile_xy_to_geo(
5097        mesh.tile.zoom,
5098        mesh.tile.x as f64 + 1.0,
5099        mesh.tile.y as f64 + 1.0,
5100    );
5101    let projection_kind = match state.camera().projection() {
5102        rustial_engine::CameraProjection::WebMercator => 0.0,
5103        rustial_engine::CameraProjection::Equirectangular => 1.0,
5104        _ => 0.0,
5105    };
5106    let skirt =
5107        rustial_engine::skirt_height(mesh.tile.zoom, mesh.vertical_exaggeration as f64) as f32;
5108    let skirt_base = (elevation.min_elev * mesh.vertical_exaggeration - skirt).max(-skirt * 3.0);
5109    let elev_region = if mesh.tile != mesh.elevation_source_tile {
5110        rustial_engine::elevation_region_in_texture_space(
5111            mesh.elevation_region,
5112            elevation.width,
5113            elevation.height,
5114        )
5115    } else {
5116        mesh.elevation_region
5117    };
5118    TerrainTileUniform {
5119        geo_bounds: [nw.lat as f32, nw.lon as f32, se.lat as f32, se.lon as f32],
5120        scene_origin: [
5121            scene_origin.x as f32,
5122            scene_origin.y as f32,
5123            scene_origin.z as f32,
5124            projection_kind,
5125        ],
5126        elev_params: [
5127            mesh.vertical_exaggeration,
5128            skirt_base,
5129            elevation.min_elev,
5130            elevation.max_elev,
5131        ],
5132        elev_region: [
5133            elev_region.u_min,
5134            elev_region.v_min,
5135            elev_region.u_max,
5136            elev_region.v_max,
5137        ],
5138    }
5139}
5140
5141fn build_model_vertices(mesh: &rustial_engine::ModelMesh) -> Vec<ModelVertex> {
5142    debug_assert_eq!(mesh.positions.len(), mesh.normals.len());
5143    debug_assert_eq!(mesh.positions.len(), mesh.uvs.len());
5144
5145    mesh.positions
5146        .iter()
5147        .zip(mesh.normals.iter())
5148        .zip(mesh.uvs.iter())
5149        .map(|((pos, normal), uv)| ModelVertex {
5150            position: *pos,
5151            normal: *normal,
5152            uv: *uv,
5153        })
5154        .collect()
5155}
5156
5157#[cfg(test)]
5158mod tests {
5159    use super::*;
5160    use rustial_engine::{
5161        ColorRamp, ColorStop, ColumnInstance, ColumnInstanceSet, GeoCoord, GeoGrid,
5162        VisualizationOverlay,
5163    };
5164
5165    fn visible_tile_with_fade(fade_opacity: f32) -> VisibleTile {
5166        let id = TileId::new(3, 4, 2);
5167        VisibleTile {
5168            target: id,
5169            actual: id,
5170            data: None,
5171            fade_opacity,
5172        }
5173    }
5174
5175    fn test_ramp() -> ColorRamp {
5176        ColorRamp::new(vec![
5177            ColorStop {
5178                value: 0.0,
5179                color: [0.0, 0.0, 1.0, 0.5],
5180            },
5181            ColorStop {
5182                value: 1.0,
5183                color: [1.0, 0.0, 0.0, 0.8],
5184            },
5185        ])
5186    }
5187
5188    #[test]
5189    fn tile_batch_cache_key_changes_when_fade_opacity_changes() {
5190        let a = [visible_tile_with_fade(0.25)];
5191        let b = [visible_tile_with_fade(0.75)];
5192
5193        let key_a = TileBatchCacheKey::new(
5194            &a,
5195            DVec3::ZERO,
5196            rustial_engine::CameraProjection::WebMercator,
5197        );
5198        let key_b = TileBatchCacheKey::new(
5199            &b,
5200            DVec3::ZERO,
5201            rustial_engine::CameraProjection::WebMercator,
5202        );
5203
5204        assert_ne!(
5205            key_a, key_b,
5206            "tile batch cache key must include fade-sensitive inputs"
5207        );
5208    }
5209
5210    #[test]
5211    fn tile_batch_cache_key_stays_equal_when_fade_opacity_matches() {
5212        let a = [visible_tile_with_fade(1.0)];
5213        let b = [visible_tile_with_fade(1.0)];
5214
5215        let key_a = TileBatchCacheKey::new(
5216            &a,
5217            DVec3::ZERO,
5218            rustial_engine::CameraProjection::WebMercator,
5219        );
5220        let key_b = TileBatchCacheKey::new(
5221            &b,
5222            DVec3::ZERO,
5223            rustial_engine::CameraProjection::WebMercator,
5224        );
5225
5226        assert_eq!(key_a, key_b);
5227    }
5228
5229    #[test]
5230    fn diff_column_instance_ranges_tracks_contiguous_changes() {
5231        let old = vec![
5232            ColumnInstanceData {
5233                base_position: [0.0, 0.0, 0.0],
5234                dimensions: [1.0, 2.0, 0.0, 0.0],
5235                color: [1.0, 0.0, 0.0, 1.0],
5236            },
5237            ColumnInstanceData {
5238                base_position: [1.0, 0.0, 0.0],
5239                dimensions: [1.0, 2.0, 0.0, 0.0],
5240                color: [0.0, 1.0, 0.0, 1.0],
5241            },
5242            ColumnInstanceData {
5243                base_position: [2.0, 0.0, 0.0],
5244                dimensions: [1.0, 2.0, 0.0, 0.0],
5245                color: [0.0, 0.0, 1.0, 1.0],
5246            },
5247            ColumnInstanceData {
5248                base_position: [3.0, 0.0, 0.0],
5249                dimensions: [1.0, 2.0, 0.0, 0.0],
5250                color: [1.0, 1.0, 0.0, 1.0],
5251            },
5252        ];
5253        let mut new = old.clone();
5254        new[1].dimensions[1] = 4.0;
5255        new[2].color = [1.0, 0.0, 1.0, 1.0];
5256
5257        assert_eq!(diff_column_instance_ranges(&old, &new), vec![1..3]);
5258    }
5259
5260    #[test]
5261    fn diff_column_instance_ranges_splits_disjoint_changes() {
5262        let old = vec![
5263            ColumnInstanceData {
5264                base_position: [0.0, 0.0, 0.0],
5265                dimensions: [1.0, 2.0, 0.0, 0.0],
5266                color: [1.0, 0.0, 0.0, 1.0],
5267            },
5268            ColumnInstanceData {
5269                base_position: [1.0, 0.0, 0.0],
5270                dimensions: [1.0, 2.0, 0.0, 0.0],
5271                color: [0.0, 1.0, 0.0, 1.0],
5272            },
5273            ColumnInstanceData {
5274                base_position: [2.0, 0.0, 0.0],
5275                dimensions: [1.0, 2.0, 0.0, 0.0],
5276                color: [0.0, 0.0, 1.0, 1.0],
5277            },
5278        ];
5279        let mut new = old.clone();
5280        new[0].dimensions[0] = 3.0;
5281        new[2].base_position[2] = 5.0;
5282
5283        assert_eq!(diff_column_instance_ranges(&old, &new), vec![0..1, 2..3]);
5284    }
5285
5286    #[test]
5287    fn visualization_overlay_visibility_rejects_far_grid() {
5288        let mut state = MapState::new();
5289        state.set_viewport(1280, 720);
5290        state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
5291        state.set_camera_distance(1_000.0);
5292        state.update_camera(1.0 / 60.0);
5293
5294        let overlay = VisualizationOverlay::GridScalar {
5295            layer_id: LayerId::next(),
5296            grid: GeoGrid::new(GeoCoord::from_lat_lon(70.0, 120.0), 2, 2, 50.0, 50.0),
5297            field: rustial_engine::ScalarField2D::from_data(2, 2, vec![1.0; 4]),
5298            ramp: test_ramp(),
5299        };
5300
5301        assert!(!visualization_overlay_intersects_scene_viewport(
5302            &overlay, &state
5303        ));
5304    }
5305
5306    #[test]
5307    fn visualization_overlay_visibility_accepts_near_columns() {
5308        let mut state = MapState::new();
5309        state.set_viewport(1280, 720);
5310        state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
5311        state.set_camera_distance(1_000.0);
5312        state.update_camera(1.0 / 60.0);
5313
5314        let overlay = VisualizationOverlay::Columns {
5315            layer_id: LayerId::next(),
5316            columns: ColumnInstanceSet::new(vec![ColumnInstance::new(
5317                GeoCoord::from_lat_lon(0.0, 0.0),
5318                10.0,
5319                5.0,
5320            )]),
5321            ramp: test_ramp(),
5322        };
5323
5324        assert!(visualization_overlay_intersects_scene_viewport(
5325            &overlay, &state
5326        ));
5327    }
5328}
5329
5330// ---------------------------------------------------------------------------
5331// Shadow map texture helpers
5332// ---------------------------------------------------------------------------
5333
5334/// Create `cascade_count` Depth32Float textures (one per shadow cascade) and
5335/// their views.
5336fn create_shadow_map_textures(
5337    device: &wgpu::Device,
5338    resolution: u32,
5339    cascade_count: u32,
5340) -> (Vec<wgpu::Texture>, Vec<wgpu::TextureView>) {
5341    let mut textures = Vec::with_capacity(cascade_count as usize);
5342    let mut views = Vec::with_capacity(cascade_count as usize);
5343    for i in 0..cascade_count {
5344        let tex = device.create_texture(&wgpu::TextureDescriptor {
5345            label: Some(&format!("shadow_cascade_{i}")),
5346            size: wgpu::Extent3d {
5347                width: resolution,
5348                height: resolution,
5349                depth_or_array_layers: 1,
5350            },
5351            mip_level_count: 1,
5352            sample_count: 1,
5353            dimension: wgpu::TextureDimension::D2,
5354            format: wgpu::TextureFormat::Depth32Float,
5355            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
5356            view_formats: &[],
5357        });
5358        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
5359        textures.push(tex);
5360        views.push(view);
5361    }
5362    (textures, views)
5363}
5364
5365/// GPU-side shadow parameters uniform (matches WGSL `ShadowParams`).
5366///
5367/// Layout: 2 × mat4x4 (128 B) + 2 × vec4 (32 B) = 160 B total.
5368#[repr(C)]
5369#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
5370struct ShadowParamsUniform {
5371    /// Light-space VP matrix for cascade 0.
5372    light_matrix_0: [[f32; 4]; 4],
5373    /// Light-space VP matrix for cascade 1.
5374    light_matrix_1: [[f32; 4]; 4],
5375    /// `[intensity, texel_size, normal_offset, cascade_split]`
5376    shadow_config: [f32; 4],
5377    /// `[dir.x, dir.y, dir.z, enabled (0 or 1)]`
5378    shadow_dir: [f32; 4],
5379}