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