Skip to main content

rustial_renderer_wgpu/gpu/
batch.rs

1// ---------------------------------------------------------------------------
2//! # GPU buffer batching -- merges per-tile geometry into per-page draw calls
3//!
4//! The batch module is the CPU-side counterpart to the tile atlas
5//! ([`crate::gpu::tile_atlas`]).  Where the atlas packs *textures* into
6//! shared GPU pages, this module packs *geometry* into shared vertex and
7//! index buffers so that every tile sharing an atlas page can be drawn
8//! with a single `draw_indexed` call.
9//!
10//! ## Tile batches ([`build_tile_batches`])
11//!
12//! Each visible tile is a textured quad (4 vertices, 6 indices).  The
13//! builder looks up the tile's [`AtlasRegion`](super::tile_atlas::AtlasRegion),
14//! computes the world-space quad corners (camera-relative, f32), remaps
15//! the UVs into the atlas sub-rectangle, and appends the vertices and
16//! indices into a per-page accumulator.  After iterating all visible tiles,
17//! each non-empty page accumulator is uploaded as a single vertex + index
18//! buffer pair ([`TileBatch`]).
19//!
20//! The renderer issues one `draw_indexed` per non-empty page, binding the
21//! page's atlas texture.  This reduces draw calls from N (one per tile) to
22//! P (one per atlas page, typically 1-2).
23//!
24//! ## Terrain batches ([`build_terrain_batches`])
25//!
26//! Same grouping strategy, but the input is [`TerrainMeshData`] instead of
27//! tile quads.  Each terrain mesh has its own vertex count and index list;
28//! the builder rebases indices and remaps UVs identically to tiles.
29//!
30//! ## Vector batches ([`build_vector_batch`])
31//!
32//! Vector layers are already pre-tessellated by the engine into a single
33//! [`VectorMeshData`] per layer.  This function converts the f64 world
34//! positions to camera-relative f32 and uploads a single vertex + index
35//! buffer pair.  No atlas grouping is needed because vector geometry is
36//! not textured.
37//!
38//! ## Memory pattern
39//!
40//! Tile and vector batch buffers are now **cached across frames** using
41//! invalidation-driven cache keys.  Tile batches are keyed by the
42//! visible tile set (target/actual pairs), quantised camera origin, and
43//! active projection.  Vector batches are keyed by layer vertex/index
44//! counts and quantised camera origin.  When the key matches the
45//! previous frame, existing GPU buffers are reused without any
46//! allocation or upload.  When the key differs (e.g. camera movement
47//! or tile set change), new buffers are created with
48//! `BufferUsages::VERTEX` / `INDEX` and the cache is updated.
49//!
50//! Terrain batches (for the legacy CPU-displaced fallback path) and
51//! hillshade batches remain per-frame temporaries because the shared-grid
52//! terrain path handles its own caching through `SharedTerrainGridMesh`
53//! and `CachedHeightTexture`.
54// ---------------------------------------------------------------------------
55
56use crate::gpu::terrain_vertex::TerrainVertex;
57use crate::gpu::tile_atlas::TileAtlas;
58use crate::gpu::vector_vertex::VectorVertex;
59use crate::gpu::vertex::TileVertex;
60use glam::DVec3;
61use rustial_engine::{
62    CameraProjection, PreparedHillshadeRaster, TerrainMeshData, TileId, VectorMeshData, VisibleTile,
63};
64use wgpu::util::DeviceExt;
65
66/// Maximum ancestor depth for renderer-side fallback texture search.
67const MAX_FALLBACK_DEPTH: u8 = 8;
68
69pub(crate) fn find_terrain_texture_actual(
70    tile: TileId,
71    visible_tiles: &[VisibleTile],
72) -> Option<TileId> {
73    if let Some(vt) = visible_tiles
74        .iter()
75        .find(|vt| vt.target == tile && vt.data.is_some())
76    {
77        return Some(vt.actual);
78    }
79
80    let mut current = tile;
81    let mut depth = 0u8;
82    while depth < MAX_FALLBACK_DEPTH {
83        let Some(parent) = current.parent() else {
84            break;
85        };
86        if let Some(vt) = visible_tiles
87            .iter()
88            .find(|vt| vt.target == parent && vt.data.is_some())
89        {
90            return Some(vt.actual);
91        }
92        if visible_tiles
93            .iter()
94            .any(|vt| vt.actual == parent && vt.data.is_some())
95        {
96            return Some(parent);
97        }
98        current = parent;
99        depth += 1;
100    }
101
102    None
103}
104
105// ---------------------------------------------------------------------------
106// TileBatch
107// ---------------------------------------------------------------------------
108
109/// GPU buffers for all flat tile quads that share a single atlas page.
110///
111/// Produced by [`build_tile_batches`] and consumed by
112/// [`WgpuMapRenderer::render_tile_batches`](crate::WgpuMapRenderer).
113/// One draw call per non-`None` batch.
114pub struct TileBatch {
115    /// Merged vertex buffer (4 [`TileVertex`] per tile).
116    pub vertex_buffer: wgpu::Buffer,
117    /// Merged index buffer (6 `u32` indices per tile).
118    pub index_buffer: wgpu::Buffer,
119    /// Total number of indices to draw.
120    pub index_count: u32,
121}
122
123/// Per-page tile draw buffers split by depth behavior.
124///
125/// Opaque tiles (full opacity) are drawn first with depth writes enabled.
126/// Translucent tiles (fade transitions and fallback softening) are drawn
127/// afterward with depth writes disabled so same-plane cross-fades do not
128/// self-occlude.
129pub struct TilePageBatches {
130    /// Full-opacity tiles for this atlas page.
131    pub opaque: Option<TileBatch>,
132    /// Fading or fallback tiles for this atlas page.
133    pub translucent: Option<TileBatch>,
134}
135
136fn tile_opacity_for(vt: &VisibleTile) -> f32 {
137    let fallback_zoom_delta = if vt.actual.zoom > vt.target.zoom {
138        (vt.actual.zoom - vt.target.zoom) as f32
139    } else {
140        vt.target.zoom.saturating_sub(vt.actual.zoom) as f32
141    };
142    let base_opacity = if vt.target == vt.actual {
143        1.0_f32
144    } else {
145        (0.9_f32 - 0.12_f32 * fallback_zoom_delta).clamp(0.55_f32, 0.9_f32)
146    };
147    base_opacity * vt.fade_opacity
148}
149
150fn tile_requires_blended_pass(vt: &VisibleTile) -> bool {
151    tile_opacity_for(vt) < 0.999_999
152}
153
154fn build_tile_batch(
155    device: &wgpu::Device,
156    verts: Vec<TileVertex>,
157    idxs: Vec<u32>,
158) -> Option<TileBatch> {
159    if verts.is_empty() {
160        return None;
161    }
162
163    let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
164        label: Some("tile_batch_vb"),
165        contents: bytemuck::cast_slice(&verts),
166        usage: wgpu::BufferUsages::VERTEX,
167    });
168    let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
169        label: Some("tile_batch_ib"),
170        contents: bytemuck::cast_slice(&idxs),
171        usage: wgpu::BufferUsages::INDEX,
172    });
173    Some(TileBatch {
174        vertex_buffer,
175        index_buffer,
176        index_count: idxs.len() as u32,
177    })
178}
179
180/// Build tile batches grouped by atlas page.
181///
182/// Returns a `Vec` indexed by atlas page number.  Entries are `None` for
183/// pages with no visible tiles this frame.  The renderer iterates this
184/// vector, binds the corresponding atlas page texture, and issues one
185/// `draw_indexed` per `Some` entry.
186///
187/// # Arguments
188///
189/// * `device`        -- WGPU device for buffer allocation.
190/// * `visible_tiles` -- The visible tile set for this frame (from engine).
191/// * `atlas`         -- The tile texture atlas (for region lookups).
192/// * `camera_origin` -- World-space camera origin subtracted from positions
193///   to produce camera-relative f32 coordinates.
194///
195/// # Skipped tiles
196///
197/// Tiles whose `actual` id is not present in the atlas are silently
198/// skipped.  This happens when a tile's texture has not finished
199/// uploading yet -- the tile simply does not render this frame.
200pub fn build_tile_batches(
201    device: &wgpu::Device,
202    visible_tiles: &[VisibleTile],
203    atlas: &TileAtlas,
204    camera_origin: DVec3,
205    projection: CameraProjection,
206) -> Vec<TilePageBatches> {
207    if atlas.page_count() == 0 {
208        return Vec::new();
209    }
210
211    // Pre-allocate per-page accumulators.  Typical visible tile counts are
212    // 20-60 at moderate zoom; at 4 verts / 6 indices per tile this is tiny.
213    let page_count = atlas.page_count();
214    let mut page_verts_opaque: Vec<Vec<TileVertex>> = vec![Vec::new(); page_count];
215    let mut page_idxs_opaque: Vec<Vec<u32>> = vec![Vec::new(); page_count];
216    let mut page_verts_translucent: Vec<Vec<TileVertex>> = vec![Vec::new(); page_count];
217    let mut page_idxs_translucent: Vec<Vec<u32>> = vec![Vec::new(); page_count];
218
219    for vt in visible_tiles {
220        let region = match atlas.get(&vt.actual) {
221            Some(r) => r,
222            None => continue,
223        };
224
225        // Geometry tile: for child-fallback entries (actual.zoom > target.zoom)
226        // we use the child's own grid-cell bounds so the quad covers only
227        // the appropriate sub-region of the target tile.  For all other
228        // cases the target tile bounds are used.
229        let geometry_tile = if vt.actual.zoom > vt.target.zoom {
230            vt.actual
231        } else {
232            vt.target
233        };
234        let [sw, se, ne, nw] = projected_tile_corners(geometry_tile, projection, camera_origin);
235
236        let texture_region = vt.texture_region();
237
238        let (page_verts, page_idxs) = if tile_requires_blended_pass(vt) {
239            (&mut page_verts_translucent, &mut page_idxs_translucent)
240        } else {
241            (&mut page_verts_opaque, &mut page_idxs_opaque)
242        };
243
244        // Base vertex index for this quad's indices.
245        let base = page_verts[region.page].len() as u32;
246        let tile_opacity = tile_opacity_for(vt);
247
248        // Quad vertices: CCW winding (BL, BR, TR, TL).
249        // UVs are first mapped to the parent sub-rect, then remapped into
250        // the atlas sub-rectangle with half-texel inset.
251        page_verts[region.page].extend_from_slice(&[
252            TileVertex {
253                position: [sw.x as f32, sw.y as f32, sw.z as f32],
254                uv: region.remap_uv(texture_region.u_min, texture_region.v_max),
255                opacity: tile_opacity,
256            },
257            TileVertex {
258                position: [se.x as f32, se.y as f32, se.z as f32],
259                uv: region.remap_uv(texture_region.u_max, texture_region.v_max),
260                opacity: tile_opacity,
261            },
262            TileVertex {
263                position: [ne.x as f32, ne.y as f32, ne.z as f32],
264                uv: region.remap_uv(texture_region.u_max, texture_region.v_min),
265                opacity: tile_opacity,
266            },
267            TileVertex {
268                position: [nw.x as f32, nw.y as f32, nw.z as f32],
269                uv: region.remap_uv(texture_region.u_min, texture_region.v_min),
270                opacity: tile_opacity,
271            },
272        ]);
273
274        // Two triangles per quad: (0,1,2) and (0,2,3).
275        page_idxs[region.page].extend_from_slice(&[
276            base,
277            base + 1,
278            base + 2,
279            base,
280            base + 2,
281            base + 3,
282        ]);
283    }
284
285    page_verts_opaque
286        .into_iter()
287        .zip(page_idxs_opaque)
288        .zip(
289            page_verts_translucent
290                .into_iter()
291                .zip(page_idxs_translucent),
292        )
293        .map(
294            |((opaque_verts, opaque_idxs), (translucent_verts, translucent_idxs))| {
295                TilePageBatches {
296                    opaque: build_tile_batch(device, opaque_verts, opaque_idxs),
297                    translucent: build_tile_batch(device, translucent_verts, translucent_idxs),
298                }
299            },
300        )
301        .collect()
302}
303
304fn projected_tile_corners(
305    tile: TileId,
306    projection: CameraProjection,
307    camera_origin: DVec3,
308) -> [glam::DVec3; 4] {
309    let southwest =
310        glam::DVec3::from_array(projection.project_tile_corner(&tile, 0.0, 1.0)) - camera_origin;
311    let southeast =
312        glam::DVec3::from_array(projection.project_tile_corner(&tile, 1.0, 1.0)) - camera_origin;
313    let northeast =
314        glam::DVec3::from_array(projection.project_tile_corner(&tile, 1.0, 0.0)) - camera_origin;
315    let northwest =
316        glam::DVec3::from_array(projection.project_tile_corner(&tile, 0.0, 0.0)) - camera_origin;
317    [southwest, southeast, northeast, northwest]
318}
319
320// ---------------------------------------------------------------------------
321// TerrainBatch
322// ---------------------------------------------------------------------------
323
324/// GPU buffers for all terrain meshes that share a single atlas page.
325///
326/// Produced by [`build_terrain_batches`] and consumed by
327/// [`WgpuMapRenderer::render_terrain_batches`](crate::WgpuMapRenderer).
328pub struct TerrainBatch {
329    /// Merged vertex buffer ([`TerrainVertex`] with position + UV + normal).
330    pub vertex_buffer: wgpu::Buffer,
331    /// Merged index buffer (`u32` indices).
332    pub index_buffer: wgpu::Buffer,
333    /// Total number of indices to draw.
334    pub index_count: u32,
335}
336
337/// GPU buffers for all terrain hillshade overlay meshes that share a single atlas page.
338pub struct HillshadeBatch {
339    /// Merged vertex buffer ([`TerrainVertex`] with position + UV + normal).
340    pub vertex_buffer: wgpu::Buffer,
341    /// Merged index buffer (`u32` indices).
342    pub index_buffer: wgpu::Buffer,
343    /// Total number of indices to draw.
344    pub index_count: u32,
345}
346
347/// Build terrain batches grouped by atlas page.
348///
349/// Same grouping strategy as [`build_tile_batches`], but the input is
350/// variable-size [`TerrainMeshData`] instead of fixed 4-vertex tile quads.
351///
352/// Each terrain mesh's positions are converted from f64 world-space to
353/// camera-relative f32.  UVs are remapped into the atlas sub-rectangle.
354/// Indices are rebased so that multiple meshes sharing a page form a
355/// single contiguous vertex/index stream.
356///
357/// # Skipped meshes
358///
359/// Meshes whose `tile` id is not present in the atlas are silently
360/// skipped (same rationale as tile batches).
361pub fn build_terrain_batches(
362    device: &wgpu::Device,
363    terrain_meshes: &[TerrainMeshData],
364    atlas: &TileAtlas,
365    camera_origin: DVec3,
366    visible_tiles: &[VisibleTile],
367) -> Vec<Option<TerrainBatch>> {
368    if atlas.page_count() == 0 || terrain_meshes.is_empty() {
369        return Vec::new();
370    }
371
372    let page_count = atlas.page_count();
373    let mut page_verts: Vec<Vec<TerrainVertex>> = vec![Vec::new(); page_count];
374    let mut page_idxs: Vec<Vec<u32>> = vec![Vec::new(); page_count];
375
376    for mesh in terrain_meshes {
377        let actual_tile =
378            find_terrain_texture_actual(mesh.tile, visible_tiles).unwrap_or(mesh.tile);
379        let texture_region = rustial_engine::VisibleTile {
380            target: mesh.tile,
381            actual: actual_tile,
382            data: None,
383            fade_opacity: 1.0,
384        }
385        .texture_region();
386
387        let region = match atlas.get(&actual_tile) {
388            Some(r) => r,
389            None => continue,
390        };
391
392        debug_assert_eq!(
393            mesh.positions.len(),
394            mesh.uvs.len(),
395            "TerrainMeshData positions/uvs length mismatch for tile {:?}",
396            mesh.tile,
397        );
398        debug_assert_eq!(
399            mesh.positions.len(),
400            mesh.normals.len(),
401            "TerrainMeshData positions/normals length mismatch for tile {:?}",
402            mesh.tile,
403        );
404
405        let vert_count = mesh.positions.len();
406        let base = page_verts[region.page].len() as u32;
407        page_verts[region.page].reserve(vert_count);
408
409        for i in 0..vert_count {
410            let pos = &mesh.positions[i];
411            let uv = &mesh.uvs[i];
412            let normal = &mesh.normals[i];
413
414            let mapped_u =
415                texture_region.u_min + (texture_region.u_max - texture_region.u_min) * uv[0];
416            let mapped_v =
417                texture_region.v_min + (texture_region.v_max - texture_region.v_min) * uv[1];
418
419            page_verts[region.page].push(TerrainVertex {
420                position: [
421                    (pos[0] - camera_origin.x) as f32,
422                    (pos[1] - camera_origin.y) as f32,
423                    (pos[2] - camera_origin.z) as f32,
424                ],
425                uv: region.remap_uv(mapped_u, mapped_v),
426                normal: *normal,
427            });
428        }
429
430        page_idxs[region.page].reserve(mesh.indices.len());
431        for idx in &mesh.indices {
432            debug_assert!(
433                (*idx as usize) < vert_count,
434                "TerrainMeshData index {} out of bounds (vertex_count={}) for tile {:?}",
435                idx,
436                vert_count,
437                mesh.tile,
438            );
439            page_idxs[region.page].push(base + idx);
440        }
441    }
442
443    page_verts
444        .into_iter()
445        .zip(page_idxs)
446        .map(|(verts, idxs)| {
447            if verts.is_empty() {
448                return None;
449            }
450            let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
451                label: Some("terrain_batch_vb"),
452                contents: bytemuck::cast_slice(&verts),
453                usage: wgpu::BufferUsages::VERTEX,
454            });
455            let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
456                label: Some("terrain_batch_ib"),
457                contents: bytemuck::cast_slice(&idxs),
458                usage: wgpu::BufferUsages::INDEX,
459            });
460            Some(TerrainBatch {
461                vertex_buffer,
462                index_buffer,
463                index_count: idxs.len() as u32,
464            })
465        })
466        .collect()
467}
468
469/// Build terrain hillshade batches grouped by the dedicated hillshade atlas.
470pub fn build_hillshade_batches(
471    device: &wgpu::Device,
472    terrain_meshes: &[TerrainMeshData],
473    hillshade_rasters: &[PreparedHillshadeRaster],
474    atlas: &TileAtlas,
475    camera_origin: DVec3,
476) -> Vec<Option<HillshadeBatch>> {
477    if atlas.page_count() == 0 || terrain_meshes.is_empty() || hillshade_rasters.is_empty() {
478        return Vec::new();
479    }
480
481    let available: std::collections::HashSet<TileId> =
482        hillshade_rasters.iter().map(|r| r.tile).collect();
483    let page_count = atlas.page_count();
484    let mut page_verts: Vec<Vec<TerrainVertex>> = vec![Vec::new(); page_count];
485    let mut page_idxs: Vec<Vec<u32>> = vec![Vec::new(); page_count];
486
487    for mesh in terrain_meshes {
488        if !available.contains(&mesh.tile) {
489            continue;
490        }
491
492        let region = match atlas.get(&mesh.tile) {
493            Some(r) => r,
494            None => continue,
495        };
496
497        let vert_count = mesh.positions.len();
498        let base = page_verts[region.page].len() as u32;
499        page_verts[region.page].reserve(vert_count);
500
501        for i in 0..vert_count {
502            let pos = &mesh.positions[i];
503            let uv = &mesh.uvs[i];
504            let normal = &mesh.normals[i];
505
506            page_verts[region.page].push(TerrainVertex {
507                position: [
508                    (pos[0] - camera_origin.x) as f32,
509                    (pos[1] - camera_origin.y) as f32,
510                    (pos[2] - camera_origin.z) as f32,
511                ],
512                uv: region.remap_uv(uv[0], uv[1]),
513                normal: *normal,
514            });
515        }
516
517        page_idxs[region.page].reserve(mesh.indices.len());
518        for idx in &mesh.indices {
519            page_idxs[region.page].push(base + idx);
520        }
521    }
522
523    page_verts
524        .into_iter()
525        .zip(page_idxs)
526        .map(|(verts, idxs)| {
527            if verts.is_empty() {
528                return None;
529            }
530            let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
531                label: Some("hillshade_batch_vb"),
532                contents: bytemuck::cast_slice(&verts),
533                usage: wgpu::BufferUsages::VERTEX,
534            });
535            let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
536                label: Some("hillshade_batch_ib"),
537                contents: bytemuck::cast_slice(&idxs),
538                usage: wgpu::BufferUsages::INDEX,
539            });
540            Some(HillshadeBatch {
541                vertex_buffer,
542                index_buffer,
543                index_count: idxs.len() as u32,
544            })
545        })
546        .collect()
547}
548
549// ---------------------------------------------------------------------------
550// VectorBatchEntry
551/// GPU buffers for a single vector layer's tessellated mesh.
552///
553/// Produced by [`build_vector_batch`] and consumed by
554/// [`WgpuMapRenderer::render_vector_batches`](crate::WgpuMapRenderer).
555///
556/// Vector geometry is not textured (each vertex carries its own RGBA
557/// colour), so there is no atlas grouping -- one entry per layer.
558pub struct VectorBatchEntry {
559    /// Vertex buffer ([`VectorVertex`] with position + colour).
560    pub vertex_buffer: wgpu::Buffer,
561    /// Index buffer (`u32` indices).
562    pub index_buffer: wgpu::Buffer,
563    /// Total number of indices to draw.
564    pub index_count: u32,
565}
566
567/// Build a vector batch entry from engine-tessellated mesh data.
568///
569/// Returns `None` if the mesh has no indices (nothing to draw).
570///
571/// # Arguments
572///
573/// * `device`        -- WGPU device for buffer allocation.
574/// * `mesh`          -- Tessellated vector geometry from the engine.
575/// * `camera_origin` -- Subtracted from positions for camera-relative f32.
576///
577/// # Panics (debug only)
578///
579/// Debug-asserts that `positions.len() == colors.len()`.  A mismatch would
580/// cause the `zip` to silently truncate vertices while indices still
581/// reference the original (larger) range, producing GPU-side out-of-bounds
582/// reads.
583pub fn build_vector_batch(
584    device: &wgpu::Device,
585    mesh: &VectorMeshData,
586    camera_origin: DVec3,
587) -> Option<VectorBatchEntry> {
588    if mesh.indices.is_empty() {
589        return None;
590    }
591
592    debug_assert_eq!(
593        mesh.positions.len(),
594        mesh.colors.len(),
595        "VectorMeshData positions/colors length mismatch ({} vs {})",
596        mesh.positions.len(),
597        mesh.colors.len(),
598    );
599
600    let vertices: Vec<VectorVertex> = mesh
601        .positions
602        .iter()
603        .zip(mesh.colors.iter())
604        .map(|(pos, color)| VectorVertex {
605            position: [
606                (pos[0] - camera_origin.x) as f32,
607                (pos[1] - camera_origin.y) as f32,
608                (pos[2] - camera_origin.z) as f32,
609            ],
610            color: *color,
611        })
612        .collect();
613
614    let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
615        label: Some("vector_batch_vb"),
616        contents: bytemuck::cast_slice(&vertices),
617        usage: wgpu::BufferUsages::VERTEX,
618    });
619    let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
620        label: Some("vector_batch_ib"),
621        contents: bytemuck::cast_slice(&mesh.indices),
622        usage: wgpu::BufferUsages::INDEX,
623    });
624
625    Some(VectorBatchEntry {
626        vertex_buffer,
627        index_buffer,
628        index_count: mesh.indices.len() as u32,
629    })
630}
631
632// ---------------------------------------------------------------------------
633// Fill-extrusion batches
634// ---------------------------------------------------------------------------
635
636/// GPU buffers for a single fill-extrusion layer's tessellated mesh.
637///
638/// Like [`VectorBatchEntry`] but with per-vertex normals for lighting.
639pub struct FillExtrusionBatchEntry {
640    /// Vertex buffer ([`FillExtrusionVertex`](super::fill_extrusion_vertex::FillExtrusionVertex)).
641    pub vertex_buffer: wgpu::Buffer,
642    /// Index buffer (`u32` indices).
643    pub index_buffer: wgpu::Buffer,
644    /// Total number of indices to draw.
645    pub index_count: u32,
646}
647
648/// Build a fill-extrusion batch entry from engine-tessellated mesh data.
649///
650/// Returns `None` if the mesh has no indices or no normals.
651pub fn build_fill_extrusion_batch(
652    device: &wgpu::Device,
653    mesh: &VectorMeshData,
654    camera_origin: DVec3,
655) -> Option<FillExtrusionBatchEntry> {
656    use crate::gpu::fill_extrusion_vertex::FillExtrusionVertex;
657
658    if mesh.indices.is_empty() || mesh.normals.is_empty() {
659        return None;
660    }
661
662    debug_assert_eq!(
663        mesh.positions.len(),
664        mesh.normals.len(),
665        "FillExtrusion positions/normals length mismatch ({} vs {})",
666        mesh.positions.len(),
667        mesh.normals.len(),
668    );
669
670    let vertices: Vec<FillExtrusionVertex> = mesh
671        .positions
672        .iter()
673        .zip(mesh.normals.iter())
674        .zip(mesh.colors.iter())
675        .map(|((pos, normal), color)| FillExtrusionVertex {
676            position: [
677                (pos[0] - camera_origin.x) as f32,
678                (pos[1] - camera_origin.y) as f32,
679                (pos[2] - camera_origin.z) as f32,
680            ],
681            normal: *normal,
682            color: *color,
683        })
684        .collect();
685
686    let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
687        label: Some("fill_extrusion_batch_vb"),
688        contents: bytemuck::cast_slice(&vertices),
689        usage: wgpu::BufferUsages::VERTEX,
690    });
691    let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
692        label: Some("fill_extrusion_batch_ib"),
693        contents: bytemuck::cast_slice(&mesh.indices),
694        usage: wgpu::BufferUsages::INDEX,
695    });
696
697    Some(FillExtrusionBatchEntry {
698        vertex_buffer,
699        index_buffer,
700        index_count: mesh.indices.len() as u32,
701    })
702}
703
704// ---------------------------------------------------------------------------
705// Fill batches
706// ---------------------------------------------------------------------------
707
708/// GPU buffers for a dedicated fill layer.
709pub struct FillBatchEntry {
710    /// Vertex buffer ([`FillVertex`]).
711    pub vertex_buffer: wgpu::Buffer,
712    /// Index buffer (`u32` indices).
713    pub index_buffer: wgpu::Buffer,
714    /// Total number of indices to draw.
715    pub index_count: u32,
716    /// Fill-specific parameter buffer for the fill-params uniform.
717    pub fill_params_buffer: wgpu::Buffer,
718    /// Bind group that holds view-proj (binding 0) + fill params (binding 1).
719    pub bind_group: wgpu::BindGroup,
720}
721
722/// Build a fill batch entry from engine-tessellated mesh data.
723///
724/// Returns `None` if the mesh has no indices.
725pub fn build_fill_batch(
726    device: &wgpu::Device,
727    mesh: &VectorMeshData,
728    camera_origin: DVec3,
729    uniform_buffer: &wgpu::Buffer,
730    fill_bind_group_layout: &wgpu::BindGroupLayout,
731) -> Option<FillBatchEntry> {
732    use crate::gpu::fill_vertex::FillVertex;
733    use crate::pipeline::fill_pipeline::FillParamsUniform;
734
735    if mesh.indices.is_empty() {
736        return None;
737    }
738
739    let vertices: Vec<FillVertex> = mesh
740        .positions
741        .iter()
742        .zip(mesh.colors.iter())
743        .map(|(pos, color)| FillVertex {
744            position: [
745                (pos[0] - camera_origin.x) as f32,
746                (pos[1] - camera_origin.y) as f32,
747                (pos[2] - camera_origin.z) as f32,
748            ],
749            color: *color,
750        })
751        .collect();
752
753    let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
754        label: Some("fill_batch_vb"),
755        contents: bytemuck::cast_slice(&vertices),
756        usage: wgpu::BufferUsages::VERTEX,
757    });
758    let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
759        label: Some("fill_batch_ib"),
760        contents: bytemuck::cast_slice(&mesh.indices),
761        usage: wgpu::BufferUsages::INDEX,
762    });
763
764    let fill_params = FillParamsUniform {
765        fill_translate: mesh.fill_translate,
766        fill_opacity: mesh.fill_opacity,
767        fill_antialias: if mesh.fill_antialias { 1.0 } else { 0.0 },
768        outline_color: mesh.fill_outline_color,
769    };
770    let fill_params_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
771        label: Some("fill_params_buf"),
772        contents: bytemuck::bytes_of(&fill_params),
773        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
774    });
775
776    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
777        label: Some("fill_batch_bg"),
778        layout: fill_bind_group_layout,
779        entries: &[
780            wgpu::BindGroupEntry {
781                binding: 0,
782                resource: uniform_buffer.as_entire_binding(),
783            },
784            wgpu::BindGroupEntry {
785                binding: 1,
786                resource: fill_params_buffer.as_entire_binding(),
787            },
788        ],
789    });
790
791    Some(FillBatchEntry {
792        vertex_buffer,
793        index_buffer,
794        index_count: mesh.indices.len() as u32,
795        fill_params_buffer,
796        bind_group,
797    })
798}
799
800// ---------------------------------------------------------------------------
801// Fill-pattern batches
802// ---------------------------------------------------------------------------
803
804/// GPU buffers for a single patterned fill layer.
805pub struct FillPatternBatchEntry {
806    /// Vertex buffer ([`FillPatternVertex`]).
807    pub vertex_buffer: wgpu::Buffer,
808    /// Index buffer (`u32` indices).
809    pub index_buffer: wgpu::Buffer,
810    /// Total number of indices to draw.
811    pub index_count: u32,
812    /// Bind group 0: view-proj (binding 0) + fill params (binding 1).
813    pub uniform_bind_group: wgpu::BindGroup,
814    /// Bind group 1: pattern texture (binding 0) + sampler (binding 1).
815    pub texture_bind_group: wgpu::BindGroup,
816    /// Retained pattern texture (must outlive the bind group).
817    pub _pattern_texture: wgpu::Texture,
818    /// Retained pattern texture view.
819    pub _pattern_texture_view: wgpu::TextureView,
820    /// Retained fill-params buffer.
821    pub _fill_params_buffer: wgpu::Buffer,
822}
823
824/// Build a fill-pattern batch entry from engine mesh data.
825///
826/// Returns `None` if the mesh has no indices, no pattern UVs, or no pattern
827/// image.
828#[allow(clippy::too_many_arguments)]
829pub fn build_fill_pattern_batch(
830    device: &wgpu::Device,
831    queue: &wgpu::Queue,
832    mesh: &VectorMeshData,
833    camera_origin: DVec3,
834    uniform_buffer: &wgpu::Buffer,
835    uniform_bgl: &wgpu::BindGroupLayout,
836    texture_bgl: &wgpu::BindGroupLayout,
837    pattern_sampler: &wgpu::Sampler,
838) -> Option<FillPatternBatchEntry> {
839    use crate::gpu::fill_pattern_vertex::FillPatternVertex;
840    use crate::pipeline::fill_pipeline::FillParamsUniform;
841
842    let pattern = mesh.fill_pattern.as_ref()?;
843    if mesh.indices.is_empty() || mesh.fill_pattern_uvs.is_empty() {
844        return None;
845    }
846
847    let vertices: Vec<FillPatternVertex> = (0..mesh.positions.len())
848        .map(|i| {
849            let pos = &mesh.positions[i];
850            FillPatternVertex {
851                position: [
852                    (pos[0] - camera_origin.x) as f32,
853                    (pos[1] - camera_origin.y) as f32,
854                    (pos[2] - camera_origin.z) as f32,
855                ],
856                color: mesh.colors[i],
857                uv: if i < mesh.fill_pattern_uvs.len() {
858                    mesh.fill_pattern_uvs[i]
859                } else {
860                    [0.0, 0.0]
861                },
862            }
863        })
864        .collect();
865
866    let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
867        label: Some("fill_pattern_batch_vb"),
868        contents: bytemuck::cast_slice(&vertices),
869        usage: wgpu::BufferUsages::VERTEX,
870    });
871    let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
872        label: Some("fill_pattern_batch_ib"),
873        contents: bytemuck::cast_slice(&mesh.indices),
874        usage: wgpu::BufferUsages::INDEX,
875    });
876
877    // Upload pattern image to GPU texture.
878    let texture_size = wgpu::Extent3d {
879        width: pattern.width,
880        height: pattern.height,
881        depth_or_array_layers: 1,
882    };
883    let pattern_texture = device.create_texture(&wgpu::TextureDescriptor {
884        label: Some("fill_pattern_tex"),
885        size: texture_size,
886        mip_level_count: 1,
887        sample_count: 1,
888        dimension: wgpu::TextureDimension::D2,
889        format: wgpu::TextureFormat::Rgba8UnormSrgb,
890        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
891        view_formats: &[],
892    });
893
894    queue.write_texture(
895        wgpu::TexelCopyTextureInfo {
896            texture: &pattern_texture,
897            mip_level: 0,
898            origin: wgpu::Origin3d::ZERO,
899            aspect: wgpu::TextureAspect::All,
900        },
901        &pattern.data,
902        wgpu::TexelCopyBufferLayout {
903            offset: 0,
904            bytes_per_row: Some(pattern.width * 4),
905            rows_per_image: Some(pattern.height),
906        },
907        texture_size,
908    );
909
910    let pattern_texture_view = pattern_texture.create_view(&wgpu::TextureViewDescriptor::default());
911
912    // Fill params uniform.
913    let fill_params = FillParamsUniform {
914        fill_translate: mesh.fill_translate,
915        fill_opacity: mesh.fill_opacity,
916        fill_antialias: if mesh.fill_antialias { 1.0 } else { 0.0 },
917        outline_color: mesh.fill_outline_color,
918    };
919    let fill_params_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
920        label: Some("fill_pattern_params_buf"),
921        contents: bytemuck::bytes_of(&fill_params),
922        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
923    });
924
925    // Bind group 0: uniforms.
926    let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
927        label: Some("fill_pattern_uniform_bg"),
928        layout: uniform_bgl,
929        entries: &[
930            wgpu::BindGroupEntry {
931                binding: 0,
932                resource: uniform_buffer.as_entire_binding(),
933            },
934            wgpu::BindGroupEntry {
935                binding: 1,
936                resource: fill_params_buffer.as_entire_binding(),
937            },
938        ],
939    });
940
941    // Bind group 1: texture + sampler.
942    let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
943        label: Some("fill_pattern_texture_bg"),
944        layout: texture_bgl,
945        entries: &[
946            wgpu::BindGroupEntry {
947                binding: 0,
948                resource: wgpu::BindingResource::TextureView(&pattern_texture_view),
949            },
950            wgpu::BindGroupEntry {
951                binding: 1,
952                resource: wgpu::BindingResource::Sampler(pattern_sampler),
953            },
954        ],
955    });
956
957    Some(FillPatternBatchEntry {
958        vertex_buffer,
959        index_buffer,
960        index_count: mesh.indices.len() as u32,
961        uniform_bind_group,
962        texture_bind_group,
963        _pattern_texture: pattern_texture,
964        _pattern_texture_view: pattern_texture_view,
965        _fill_params_buffer: fill_params_buffer,
966    })
967}
968
969// ---------------------------------------------------------------------------
970// Line batches
971// ---------------------------------------------------------------------------
972
973/// GPU buffers for a single line layer's tessellated mesh with dash data.
974pub struct LineBatchEntry {
975    /// Vertex buffer ([`LineVertex`]).
976    pub vertex_buffer: wgpu::Buffer,
977    /// Index buffer (`u32` indices).
978    pub index_buffer: wgpu::Buffer,
979    /// Total number of indices to draw.
980    pub index_count: u32,
981    /// Line style parameters `[dash_length, gap_length, cap_round, 0]`.
982    pub line_params: [f32; 4],
983}
984
985/// Build a line batch entry from engine-tessellated mesh data.
986///
987/// Returns `None` if the mesh has no indices or no line distances.
988pub fn build_line_batch(
989    device: &wgpu::Device,
990    mesh: &VectorMeshData,
991    camera_origin: DVec3,
992) -> Option<LineBatchEntry> {
993    use crate::gpu::line_vertex::LineVertex;
994
995    if mesh.indices.is_empty() || mesh.line_distances.is_empty() {
996        return None;
997    }
998
999    let vertices: Vec<LineVertex> = (0..mesh.positions.len())
1000        .map(|i| {
1001            let pos = &mesh.positions[i];
1002            LineVertex {
1003                position: [
1004                    (pos[0] - camera_origin.x) as f32,
1005                    (pos[1] - camera_origin.y) as f32,
1006                    (pos[2] - camera_origin.z) as f32,
1007                ],
1008                color: mesh.colors[i],
1009                line_normal: mesh.line_normals[i],
1010                line_distance: mesh.line_distances[i],
1011                cap_join: if i < mesh.line_cap_joins.len() {
1012                    mesh.line_cap_joins[i]
1013                } else {
1014                    0.0
1015                },
1016            }
1017        })
1018        .collect();
1019
1020    let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1021        label: Some("line_batch_vb"),
1022        contents: bytemuck::cast_slice(&vertices),
1023        usage: wgpu::BufferUsages::VERTEX,
1024    });
1025    let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1026        label: Some("line_batch_ib"),
1027        contents: bytemuck::cast_slice(&mesh.indices),
1028        usage: wgpu::BufferUsages::INDEX,
1029    });
1030
1031    Some(LineBatchEntry {
1032        vertex_buffer,
1033        index_buffer,
1034        index_count: mesh.indices.len() as u32,
1035        line_params: mesh.line_params,
1036    })
1037}
1038
1039// ---------------------------------------------------------------------------
1040// Line-pattern batches
1041// ---------------------------------------------------------------------------
1042
1043/// GPU buffers for a single patterned line layer.
1044pub struct LinePatternBatchEntry {
1045    /// Vertex buffer ([`LinePatternVertex`]).
1046    pub vertex_buffer: wgpu::Buffer,
1047    /// Index buffer (`u32` indices).
1048    pub index_buffer: wgpu::Buffer,
1049    /// Total number of indices to draw.
1050    pub index_count: u32,
1051    /// Bind group 0: view-proj + line-style uniforms.
1052    pub uniform_bind_group: wgpu::BindGroup,
1053    /// Bind group 1: pattern texture (binding 0) + sampler (binding 1).
1054    pub texture_bind_group: wgpu::BindGroup,
1055    /// Retained pattern texture (must outlive the bind group).
1056    pub _pattern_texture: wgpu::Texture,
1057    /// Retained pattern texture view.
1058    pub _pattern_texture_view: wgpu::TextureView,
1059    /// Line style parameters `[dash_length, gap_length, cap_round, 0]`.
1060    pub line_params: [f32; 4],
1061}
1062
1063/// Build a line-pattern batch entry from engine mesh data.
1064///
1065/// Returns `None` if the mesh has no indices, no pattern UVs, or no pattern
1066/// image.
1067#[allow(clippy::too_many_arguments)]
1068pub fn build_line_pattern_batch(
1069    device: &wgpu::Device,
1070    queue: &wgpu::Queue,
1071    mesh: &VectorMeshData,
1072    camera_origin: DVec3,
1073    uniform_buffer: &wgpu::Buffer,
1074    uniform_bgl: &wgpu::BindGroupLayout,
1075    texture_bgl: &wgpu::BindGroupLayout,
1076    pattern_sampler: &wgpu::Sampler,
1077) -> Option<LinePatternBatchEntry> {
1078    use crate::gpu::line_pattern_vertex::LinePatternVertex;
1079
1080    let pattern = mesh.line_pattern.as_ref()?;
1081    if mesh.indices.is_empty() || mesh.line_pattern_uvs.is_empty() {
1082        return None;
1083    }
1084
1085    let vertices: Vec<LinePatternVertex> = (0..mesh.positions.len())
1086        .map(|i| {
1087            let pos = &mesh.positions[i];
1088            LinePatternVertex {
1089                position: [
1090                    (pos[0] - camera_origin.x) as f32,
1091                    (pos[1] - camera_origin.y) as f32,
1092                    (pos[2] - camera_origin.z) as f32,
1093                ],
1094                color: mesh.colors[i],
1095                line_normal: if i < mesh.line_normals.len() {
1096                    mesh.line_normals[i]
1097                } else {
1098                    [0.0, 0.0]
1099                },
1100                line_distance: if i < mesh.line_distances.len() {
1101                    mesh.line_distances[i]
1102                } else {
1103                    0.0
1104                },
1105                cap_join: if i < mesh.line_cap_joins.len() {
1106                    mesh.line_cap_joins[i]
1107                } else {
1108                    0.0
1109                },
1110                uv: if i < mesh.line_pattern_uvs.len() {
1111                    mesh.line_pattern_uvs[i]
1112                } else {
1113                    [0.0, 0.0]
1114                },
1115            }
1116        })
1117        .collect();
1118
1119    let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1120        label: Some("line_pattern_batch_vb"),
1121        contents: bytemuck::cast_slice(&vertices),
1122        usage: wgpu::BufferUsages::VERTEX,
1123    });
1124    let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1125        label: Some("line_pattern_batch_ib"),
1126        contents: bytemuck::cast_slice(&mesh.indices),
1127        usage: wgpu::BufferUsages::INDEX,
1128    });
1129
1130    // Upload pattern image to GPU texture.
1131    let texture_size = wgpu::Extent3d {
1132        width: pattern.width,
1133        height: pattern.height,
1134        depth_or_array_layers: 1,
1135    };
1136    let pattern_texture = device.create_texture(&wgpu::TextureDescriptor {
1137        label: Some("line_pattern_tex"),
1138        size: texture_size,
1139        mip_level_count: 1,
1140        sample_count: 1,
1141        dimension: wgpu::TextureDimension::D2,
1142        format: wgpu::TextureFormat::Rgba8UnormSrgb,
1143        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1144        view_formats: &[],
1145    });
1146
1147    queue.write_texture(
1148        wgpu::TexelCopyTextureInfo {
1149            texture: &pattern_texture,
1150            mip_level: 0,
1151            origin: wgpu::Origin3d::ZERO,
1152            aspect: wgpu::TextureAspect::All,
1153        },
1154        &pattern.data,
1155        wgpu::TexelCopyBufferLayout {
1156            offset: 0,
1157            bytes_per_row: Some(pattern.width * 4),
1158            rows_per_image: Some(pattern.height),
1159        },
1160        texture_size,
1161    );
1162
1163    let pattern_texture_view = pattern_texture.create_view(&wgpu::TextureViewDescriptor::default());
1164
1165    // Bind group 0: uniforms (shared view-proj + line-style).
1166    let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1167        label: Some("line_pattern_uniform_bg"),
1168        layout: uniform_bgl,
1169        entries: &[wgpu::BindGroupEntry {
1170            binding: 0,
1171            resource: uniform_buffer.as_entire_binding(),
1172        }],
1173    });
1174
1175    // Bind group 1: texture + sampler.
1176    let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1177        label: Some("line_pattern_texture_bg"),
1178        layout: texture_bgl,
1179        entries: &[
1180            wgpu::BindGroupEntry {
1181                binding: 0,
1182                resource: wgpu::BindingResource::TextureView(&pattern_texture_view),
1183            },
1184            wgpu::BindGroupEntry {
1185                binding: 1,
1186                resource: wgpu::BindingResource::Sampler(pattern_sampler),
1187            },
1188        ],
1189    });
1190
1191    Some(LinePatternBatchEntry {
1192        vertex_buffer,
1193        index_buffer,
1194        index_count: mesh.indices.len() as u32,
1195        uniform_bind_group,
1196        texture_bind_group,
1197        _pattern_texture: pattern_texture,
1198        _pattern_texture_view: pattern_texture_view,
1199        line_params: mesh.line_params,
1200    })
1201}
1202
1203// ---------------------------------------------------------------------------
1204// Circle batches
1205// ---------------------------------------------------------------------------
1206
1207/// GPU buffers for a circle layer rendered via SDF quads.
1208pub struct CircleBatchEntry {
1209    /// Vertex buffer ([`CircleVertex`]).
1210    pub vertex_buffer: wgpu::Buffer,
1211    /// Index buffer (`u32` indices).
1212    pub index_buffer: wgpu::Buffer,
1213    /// Total number of indices to draw.
1214    pub index_count: u32,
1215}
1216
1217/// Build a circle batch from engine circle instance data.
1218///
1219/// Each circle becomes a screen-aligned quad (4 vertices, 6 indices).
1220/// Returns `None` if there are no circle instances.
1221pub fn build_circle_batch(
1222    device: &wgpu::Device,
1223    mesh: &VectorMeshData,
1224    camera_origin: DVec3,
1225) -> Option<CircleBatchEntry> {
1226    use crate::gpu::circle_vertex::CircleVertex;
1227
1228    if mesh.circle_instances.is_empty() {
1229        return None;
1230    }
1231
1232    let instance_count = mesh.circle_instances.len();
1233    let mut vertices: Vec<CircleVertex> = Vec::with_capacity(instance_count * 4);
1234    let mut indices: Vec<u32> = Vec::with_capacity(instance_count * 6);
1235
1236    let offsets: [[f32; 2]; 4] = [[-1.0, 1.0], [1.0, 1.0], [-1.0, -1.0], [1.0, -1.0]];
1237
1238    for (i, ci) in mesh.circle_instances.iter().enumerate() {
1239        let cx = (ci.center[0] - camera_origin.x) as f32;
1240        let cy = (ci.center[1] - camera_origin.y) as f32;
1241        let cz = (ci.center[2] - camera_origin.z) as f32;
1242        let params = [ci.radius, ci.stroke_width, ci.blur, 0.0];
1243
1244        for offset in &offsets {
1245            vertices.push(CircleVertex {
1246                position: [cx, cy, cz],
1247                quad_offset: *offset,
1248                color: ci.color,
1249                stroke_color: ci.stroke_color,
1250                params,
1251            });
1252        }
1253
1254        let base = (i as u32) * 4;
1255        indices.extend_from_slice(&[base, base + 2, base + 1, base + 1, base + 2, base + 3]);
1256    }
1257
1258    let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1259        label: Some("circle_batch_vb"),
1260        contents: bytemuck::cast_slice(&vertices),
1261        usage: wgpu::BufferUsages::VERTEX,
1262    });
1263    let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1264        label: Some("circle_batch_ib"),
1265        contents: bytemuck::cast_slice(&indices),
1266        usage: wgpu::BufferUsages::INDEX,
1267    });
1268
1269    Some(CircleBatchEntry {
1270        vertex_buffer,
1271        index_buffer,
1272        index_count: indices.len() as u32,
1273    })
1274}
1275
1276// ---------------------------------------------------------------------------
1277// Heatmap batches
1278// ---------------------------------------------------------------------------
1279
1280/// GPU buffers for a heatmap layer.
1281pub struct HeatmapBatchEntry {
1282    /// Vertex buffer ([`HeatmapVertex`]).
1283    pub vertex_buffer: wgpu::Buffer,
1284    /// Index buffer (`u32` indices).
1285    pub index_buffer: wgpu::Buffer,
1286    /// Total number of indices to draw.
1287    pub index_count: u32,
1288}
1289
1290/// Build a heatmap batch from engine heatmap point data.
1291///
1292/// Each point becomes a screen-aligned quad.  Returns `None` if there
1293/// are no heatmap points.
1294pub fn build_heatmap_batch(
1295    device: &wgpu::Device,
1296    mesh: &VectorMeshData,
1297    camera_origin: DVec3,
1298) -> Option<HeatmapBatchEntry> {
1299    use crate::gpu::heatmap_vertex::HeatmapVertex;
1300
1301    if mesh.heatmap_points.is_empty() {
1302        return None;
1303    }
1304
1305    let point_count = mesh.heatmap_points.len();
1306    let mut vertices: Vec<HeatmapVertex> = Vec::with_capacity(point_count * 4);
1307    let mut indices: Vec<u32> = Vec::with_capacity(point_count * 6);
1308
1309    let offsets: [[f32; 2]; 4] = [[-1.0, 1.0], [1.0, 1.0], [-1.0, -1.0], [1.0, -1.0]];
1310
1311    for (i, pt) in mesh.heatmap_points.iter().enumerate() {
1312        let cx = (pt[0] - camera_origin.x) as f32;
1313        let cy = (pt[1] - camera_origin.y) as f32;
1314        let cz = 0.0f32;
1315        let weight = pt[2] as f32;
1316        let radius = pt[3] as f32;
1317        let params = [weight, radius, mesh.heatmap_intensity, 0.0];
1318
1319        for offset in &offsets {
1320            vertices.push(HeatmapVertex {
1321                position: [cx, cy, cz],
1322                quad_offset: *offset,
1323                params,
1324            });
1325        }
1326
1327        let base = (i as u32) * 4;
1328        indices.extend_from_slice(&[base, base + 2, base + 1, base + 1, base + 2, base + 3]);
1329    }
1330
1331    let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1332        label: Some("heatmap_batch_vb"),
1333        contents: bytemuck::cast_slice(&vertices),
1334        usage: wgpu::BufferUsages::VERTEX,
1335    });
1336    let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1337        label: Some("heatmap_batch_ib"),
1338        contents: bytemuck::cast_slice(&indices),
1339        usage: wgpu::BufferUsages::INDEX,
1340    });
1341
1342    Some(HeatmapBatchEntry {
1343        vertex_buffer,
1344        index_buffer,
1345        index_count: indices.len() as u32,
1346    })
1347}
1348
1349// ---------------------------------------------------------------------------
1350// Placeholder quad batches
1351// ---------------------------------------------------------------------------
1352
1353/// Build a single merged [`VectorBatchEntry`] containing quads for all
1354/// loading placeholders.
1355///
1356/// Each placeholder becomes a flat coloured quad (4 vertices, 6 indices)
1357/// at Z = -0.01 (slightly behind the tile plane) so that loaded tiles
1358/// naturally occlude them via depth test.  The colour includes a shimmer
1359/// opacity multiplier derived from the placeholder's animation phase and
1360/// the active [`PlaceholderStyle`](rustial_engine::PlaceholderStyle).
1361///
1362/// Returns `None` when `placeholders` is empty.
1363pub fn build_placeholder_batches(
1364    device: &wgpu::Device,
1365    placeholders: &[rustial_engine::LoadingPlaceholder],
1366    style: &rustial_engine::PlaceholderStyle,
1367    camera_origin: DVec3,
1368) -> Option<VectorBatchEntry> {
1369    if placeholders.is_empty() {
1370        return None;
1371    }
1372
1373    let quad_count = placeholders.len();
1374    let mut vertices: Vec<VectorVertex> = Vec::with_capacity(quad_count * 4);
1375    let mut indices: Vec<u32> = Vec::with_capacity(quad_count * 6);
1376
1377    for ph in placeholders {
1378        let opacity = style.shimmer_opacity(ph.animation_phase);
1379        let color = [
1380            style.background_color[0],
1381            style.background_color[1],
1382            style.background_color[2],
1383            style.background_color[3] * opacity,
1384        ];
1385
1386        let min = ph.bounds.min.position;
1387        let max = ph.bounds.max.position;
1388        let ox = camera_origin.x;
1389        let oy = camera_origin.y;
1390        // Place slightly behind the tile plane so loaded tiles occlude.
1391        let z: f32 = -0.01;
1392
1393        let base = vertices.len() as u32;
1394        // v0 = SW, v1 = SE, v2 = NE, v3 = NW  (CCW front face)
1395        vertices.push(VectorVertex {
1396            position: [(min.x - ox) as f32, (min.y - oy) as f32, z],
1397            color,
1398        });
1399        vertices.push(VectorVertex {
1400            position: [(max.x - ox) as f32, (min.y - oy) as f32, z],
1401            color,
1402        });
1403        vertices.push(VectorVertex {
1404            position: [(max.x - ox) as f32, (max.y - oy) as f32, z],
1405            color,
1406        });
1407        vertices.push(VectorVertex {
1408            position: [(min.x - ox) as f32, (max.y - oy) as f32, z],
1409            color,
1410        });
1411        indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
1412    }
1413
1414    let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1415        label: Some("placeholder_batch_vb"),
1416        contents: bytemuck::cast_slice(&vertices),
1417        usage: wgpu::BufferUsages::VERTEX,
1418    });
1419    let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1420        label: Some("placeholder_batch_ib"),
1421        contents: bytemuck::cast_slice(&indices),
1422        usage: wgpu::BufferUsages::INDEX,
1423    });
1424
1425    Some(VectorBatchEntry {
1426        vertex_buffer,
1427        index_buffer,
1428        index_count: indices.len() as u32,
1429    })
1430}
1431
1432// ---------------------------------------------------------------------------
1433// Symbol batches
1434// ---------------------------------------------------------------------------
1435
1436/// Prepared GPU buffers for one symbol batch.
1437pub struct SymbolBatchEntry {
1438    /// Vertex buffer ([`SymbolVertex`]).
1439    pub vertex_buffer: wgpu::Buffer,
1440    /// Index buffer (`u32` indices).
1441    pub index_buffer: wgpu::Buffer,
1442    /// Total number of indices to draw.
1443    pub index_count: u32,
1444}
1445
1446/// Build a symbol batch from placed symbols and a populated glyph atlas.
1447///
1448/// When `PlacedSymbol::glyph_quads` is populated (via
1449/// [`layout_symbol_glyphs`](rustial_engine::symbols::layout_symbol_glyphs))
1450/// the pre-computed glyph positions are used directly.  Otherwise the
1451/// function falls back to a naive character-by-character left-to-right
1452/// layout using atlas advance metrics.
1453pub fn build_symbol_batch(
1454    device: &wgpu::Device,
1455    symbols: &[rustial_engine::symbols::PlacedSymbol],
1456    atlas: &rustial_engine::symbols::GlyphAtlas,
1457    camera_origin: DVec3,
1458    render_em_px: f32,
1459) -> Option<SymbolBatchEntry> {
1460    use crate::gpu::symbol_vertex::SymbolVertex;
1461
1462    let atlas_dims = atlas.dimensions();
1463    if atlas_dims[0] == 0 || atlas_dims[1] == 0 {
1464        return None;
1465    }
1466
1467    let atlas_w = atlas_dims[0] as f32;
1468    let atlas_h = atlas_dims[1] as f32;
1469
1470    let mut vertices: Vec<SymbolVertex> = Vec::new();
1471    let mut indices: Vec<u32> = Vec::new();
1472
1473    for symbol in symbols {
1474        if !symbol.visible || symbol.opacity <= 0.0 {
1475            continue;
1476        }
1477        if symbol.text.as_ref().is_none_or(|t| t.is_empty()) {
1478            continue;
1479        }
1480
1481        // World-space anchor, camera-relative.
1482        let ax = (symbol.world_anchor[0] - camera_origin.x) as f32;
1483        let ay = (symbol.world_anchor[1] - camera_origin.y) as f32;
1484        let az = (symbol.world_anchor[2] - camera_origin.z) as f32;
1485
1486        let scale = symbol.size_px / render_em_px.max(1.0);
1487
1488        let color = [
1489            symbol.fill_color[0],
1490            symbol.fill_color[1],
1491            symbol.fill_color[2],
1492            symbol.fill_color[3] * symbol.opacity,
1493        ];
1494        let halo_color = symbol.halo_color;
1495        let params = [1.0_f32, 1.0, 0.0, 0.0];
1496
1497        if !symbol.glyph_quads.is_empty() {
1498            // ---- Pre-computed glyph layout path ----
1499            for quad in &symbol.glyph_quads {
1500                let entry = match atlas.get(&symbol.font_stack, quad.codepoint) {
1501                    Some(e) => e,
1502                    None => continue,
1503                };
1504
1505                let gw = entry.size[0] as f32 * scale;
1506                let gh = entry.size[1] as f32 * scale;
1507                let bearing_x = entry.bearing_x as f32 * scale;
1508                let bearing_y = entry.bearing_y as f32 * scale;
1509
1510                // Glyph quad corners: quad.x/y are the pen position (pixels);
1511                // bearing adjusts to the glyph bitmap origin.
1512                let x0 = quad.x + bearing_x;
1513                let y0 = quad.y + bearing_y;
1514                let x1 = x0 + gw;
1515                let y1 = y0 - gh;
1516
1517                let u0 = entry.origin[0] as f32 / atlas_w;
1518                let v0 = entry.origin[1] as f32 / atlas_h;
1519                let u1 = (entry.origin[0] + entry.size[0]) as f32 / atlas_w;
1520                let v1 = (entry.origin[1] + entry.size[1]) as f32 / atlas_h;
1521
1522                let base = vertices.len() as u32;
1523                vertices.push(SymbolVertex {
1524                    position: [ax, ay, az],
1525                    glyph_offset: [x0, y0],
1526                    tex_coord: [u0, v0],
1527                    color,
1528                    halo_color,
1529                    params,
1530                });
1531                vertices.push(SymbolVertex {
1532                    position: [ax, ay, az],
1533                    glyph_offset: [x1, y0],
1534                    tex_coord: [u1, v0],
1535                    color,
1536                    halo_color,
1537                    params,
1538                });
1539                vertices.push(SymbolVertex {
1540                    position: [ax, ay, az],
1541                    glyph_offset: [x0, y1],
1542                    tex_coord: [u0, v1],
1543                    color,
1544                    halo_color,
1545                    params,
1546                });
1547                vertices.push(SymbolVertex {
1548                    position: [ax, ay, az],
1549                    glyph_offset: [x1, y1],
1550                    tex_coord: [u1, v1],
1551                    color,
1552                    halo_color,
1553                    params,
1554                });
1555                indices.extend_from_slice(&[
1556                    base,
1557                    base + 2,
1558                    base + 1,
1559                    base + 1,
1560                    base + 2,
1561                    base + 3,
1562                ]);
1563            }
1564        } else {
1565            // ---- Fallback: naive character-by-character layout ----
1566            let text = symbol.text.as_deref().unwrap_or("");
1567            let mut cursor_x: f32 = 0.0;
1568            for codepoint in text.chars() {
1569                let entry = match atlas.get(&symbol.font_stack, codepoint) {
1570                    Some(e) => e,
1571                    None => continue,
1572                };
1573
1574                let gw = entry.size[0] as f32 * scale;
1575                let gh = entry.size[1] as f32 * scale;
1576                let bearing_x = entry.bearing_x as f32 * scale;
1577                let bearing_y = entry.bearing_y as f32 * scale;
1578
1579                let x0 = cursor_x + bearing_x;
1580                let y0 = bearing_y;
1581                let x1 = x0 + gw;
1582                let y1 = y0 - gh;
1583
1584                let u0 = entry.origin[0] as f32 / atlas_w;
1585                let v0 = entry.origin[1] as f32 / atlas_h;
1586                let u1 = (entry.origin[0] + entry.size[0]) as f32 / atlas_w;
1587                let v1 = (entry.origin[1] + entry.size[1]) as f32 / atlas_h;
1588
1589                let base = vertices.len() as u32;
1590                vertices.push(SymbolVertex {
1591                    position: [ax, ay, az],
1592                    glyph_offset: [x0, y0],
1593                    tex_coord: [u0, v0],
1594                    color,
1595                    halo_color,
1596                    params,
1597                });
1598                vertices.push(SymbolVertex {
1599                    position: [ax, ay, az],
1600                    glyph_offset: [x1, y0],
1601                    tex_coord: [u1, v0],
1602                    color,
1603                    halo_color,
1604                    params,
1605                });
1606                vertices.push(SymbolVertex {
1607                    position: [ax, ay, az],
1608                    glyph_offset: [x0, y1],
1609                    tex_coord: [u0, v1],
1610                    color,
1611                    halo_color,
1612                    params,
1613                });
1614                vertices.push(SymbolVertex {
1615                    position: [ax, ay, az],
1616                    glyph_offset: [x1, y1],
1617                    tex_coord: [u1, v1],
1618                    color,
1619                    halo_color,
1620                    params,
1621                });
1622                indices.extend_from_slice(&[
1623                    base,
1624                    base + 2,
1625                    base + 1,
1626                    base + 1,
1627                    base + 2,
1628                    base + 3,
1629                ]);
1630
1631                cursor_x += entry.advance_x * scale;
1632            }
1633        }
1634    }
1635
1636    if vertices.is_empty() {
1637        return None;
1638    }
1639
1640    let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1641        label: Some("symbol_batch_vb"),
1642        contents: bytemuck::cast_slice(&vertices),
1643        usage: wgpu::BufferUsages::VERTEX,
1644    });
1645    let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1646        label: Some("symbol_batch_ib"),
1647        contents: bytemuck::cast_slice(&indices),
1648        usage: wgpu::BufferUsages::INDEX,
1649    });
1650
1651    Some(SymbolBatchEntry {
1652        vertex_buffer,
1653        index_buffer,
1654        index_count: indices.len() as u32,
1655    })
1656}
1657
1658// ---------------------------------------------------------------------------
1659// Tests
1660// ---------------------------------------------------------------------------
1661
1662#[cfg(test)]
1663mod tests {
1664    use super::*;
1665    use crate::gpu::tile_atlas::TileAtlas;
1666    use rustial_engine::{CameraProjection, DecodedImage};
1667    use std::sync::Arc;
1668
1669    fn create_test_device() -> Option<wgpu::Device> {
1670        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
1671            backends: wgpu::Backends::all(),
1672            ..Default::default()
1673        });
1674
1675        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
1676            power_preference: wgpu::PowerPreference::LowPower,
1677            compatible_surface: None,
1678            force_fallback_adapter: false,
1679        }))
1680        .ok()?;
1681
1682        let (device, _) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
1683            label: Some("batch_test_device"),
1684            ..Default::default()
1685        }))
1686        .ok()?;
1687
1688        Some(device)
1689    }
1690
1691    fn test_image() -> DecodedImage {
1692        DecodedImage {
1693            width: 1,
1694            height: 1,
1695            data: Arc::new(vec![255, 255, 255, 255]),
1696        }
1697    }
1698
1699    #[test]
1700    fn visible_tile_texture_region_matches_expected_subrect() {
1701        let tile = VisibleTile {
1702            target: TileId::new(3, 4, 2),
1703            actual: TileId::new(1, 1, 0),
1704            data: None,
1705            fade_opacity: 1.0,
1706        };
1707
1708        let region = tile.texture_region();
1709        assert!((region.u_min - 0.0).abs() < 1e-6);
1710        assert!((region.v_min - 0.5).abs() < 1e-6);
1711        assert!((region.u_max - 0.25).abs() < 1e-6);
1712        assert!((region.v_max - 0.75).abs() < 1e-6);
1713    }
1714
1715    #[test]
1716    fn terrain_texture_lookup_falls_back_to_visible_ancestor() {
1717        let target = TileId::new(4, 8, 4);
1718        let parent = TileId::new(3, 4, 2);
1719        let visible = vec![VisibleTile {
1720            target: parent,
1721            actual: parent,
1722            data: Some(rustial_engine::TileData::Raster(
1723                rustial_engine::DecodedImage {
1724                    width: 1,
1725                    height: 1,
1726                    data: vec![255, 255, 255, 255].into(),
1727                },
1728            )),
1729            fade_opacity: 1.0,
1730        }];
1731
1732        assert_eq!(find_terrain_texture_actual(target, &visible), Some(parent));
1733    }
1734
1735    #[test]
1736    fn projected_tile_corners_differ_between_planar_projections() {
1737        let tile = TileId::new(3, 4, 2);
1738        let merc = projected_tile_corners(tile, CameraProjection::WebMercator, DVec3::ZERO);
1739        let eq = projected_tile_corners(tile, CameraProjection::Equirectangular, DVec3::ZERO);
1740
1741        assert!((merc[0].y - eq[0].y).abs() > 1.0);
1742    }
1743
1744    #[test]
1745    fn exact_full_opacity_tile_uses_opaque_pass() {
1746        let tile = VisibleTile {
1747            target: TileId::new(3, 4, 2),
1748            actual: TileId::new(3, 4, 2),
1749            data: None,
1750            fade_opacity: 1.0,
1751        };
1752
1753        assert!(!tile_requires_blended_pass(&tile));
1754    }
1755
1756    #[test]
1757    fn fading_tile_uses_translucent_pass() {
1758        let tile = VisibleTile {
1759            target: TileId::new(3, 4, 2),
1760            actual: TileId::new(3, 4, 2),
1761            data: None,
1762            fade_opacity: 0.5,
1763        };
1764
1765        assert!(tile_requires_blended_pass(&tile));
1766    }
1767
1768    #[test]
1769    fn fallback_tile_uses_translucent_pass() {
1770        let tile = VisibleTile {
1771            target: TileId::new(4, 8, 4),
1772            actual: TileId::new(3, 4, 2),
1773            data: None,
1774            fade_opacity: 1.0,
1775        };
1776
1777        assert!(tile_requires_blended_pass(&tile));
1778    }
1779
1780    #[test]
1781    fn build_tile_batches_routes_exact_tile_to_opaque_batch() {
1782        let Some(device) = create_test_device() else {
1783            eprintln!("Skipping batch routing test: no suitable adapter/device available");
1784            return;
1785        };
1786
1787        let mut atlas = TileAtlas::new();
1788        let tile_id = TileId::new(0, 0, 0);
1789        atlas.insert(&device, tile_id, &test_image());
1790
1791        let visible = vec![VisibleTile {
1792            target: tile_id,
1793            actual: tile_id,
1794            data: None,
1795            fade_opacity: 1.0,
1796        }];
1797
1798        let batches = build_tile_batches(
1799            &device,
1800            &visible,
1801            &atlas,
1802            DVec3::ZERO,
1803            CameraProjection::WebMercator,
1804        );
1805
1806        assert_eq!(batches.len(), 1);
1807        assert!(batches[0].opaque.is_some());
1808        assert!(batches[0].translucent.is_none());
1809    }
1810
1811    #[test]
1812    fn build_tile_batches_routes_fading_crossfade_tiles_to_translucent_batch() {
1813        let Some(device) = create_test_device() else {
1814            eprintln!(
1815                "Skipping translucent batch routing test: no suitable adapter/device available"
1816            );
1817            return;
1818        };
1819
1820        let mut atlas = TileAtlas::new();
1821        let parent = TileId::new(0, 0, 0);
1822        let child = TileId::new(1, 0, 0);
1823        atlas.insert(&device, parent, &test_image());
1824        atlas.insert(&device, child, &test_image());
1825
1826        let visible = vec![
1827            VisibleTile {
1828                target: child,
1829                actual: child,
1830                data: None,
1831                fade_opacity: 0.5,
1832            },
1833            VisibleTile {
1834                target: child,
1835                actual: parent,
1836                data: None,
1837                fade_opacity: 0.5,
1838            },
1839        ];
1840
1841        let batches = build_tile_batches(
1842            &device,
1843            &visible,
1844            &atlas,
1845            DVec3::ZERO,
1846            CameraProjection::WebMercator,
1847        );
1848
1849        assert_eq!(batches.len(), 1);
1850        assert!(batches[0].opaque.is_none());
1851        assert!(batches[0].translucent.is_some());
1852    }
1853}