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