Skip to main content

wde_terrain/render/
renderer.rs

1use std::collections::HashMap;
2
3use bevy::{prelude::*, window::PrimaryWindow};
4use wde_camera::prelude::*;
5use wde_renderer::prelude::*;
6
7use crate::{
8    manager::{
9        CHUNK_COUNT, CHUNK_MAP_SIZE, CHUNK_SIZE, ChunkPos, SPLAT_MAPS, Terrain, TerrainDirtyTile
10    },
11    render::dependencies::terrain_buffer::TerrainTileDescription
12};
13
14/// Number of mesh levels of detail: level `k` has `CHUNK_RENDER_SUBDIVISIONS >> k` quads per side.
15pub const TERRAIN_LOD_COUNT: usize = 5;
16/// Distance from the camera within which chunks are drawn at full detail; each next level of
17/// detail starts twice as far.
18const LOD_BASE_DISTANCE: f32 = 48.0;
19
20/// Chunks to draw this frame, sorted by level of detail.
21#[derive(Clone, Debug, Default)]
22pub struct TerrainDrawList {
23    pub tiles: Vec<TerrainTileDescription>,
24    /// Range of `tiles` drawn with each level of detail mesh.
25    pub lod_ranges: [std::ops::Range<u32>; TERRAIN_LOD_COUNT]
26}
27
28/// CPU-side terrain renderer.
29/// Holds the shared texture arrays (one heightmap array, `SPLAT_MAPS` splat map arrays), the
30/// dirty queue forwarded to the GPU each frame, and the chunks to draw.
31#[derive(Component, Reflect)]
32#[reflect(Component)]
33pub struct TerrainRenderer {
34    /// Maps chunk position → array layer index (= tile index).
35    pub pos_to_layer: HashMap<ChunkPos, u32>,
36    /// Single R32Float texture array (normalized heights): one layer per chunk.
37    #[reflect(ignore)]
38    pub heightmap_array: Option<Handle<Texture>>,
39    /// Rgba8Unorm texture arrays of the materials weights: one layer per chunk.
40    #[reflect(ignore)]
41    pub splatmap_arrays: Vec<Handle<Texture>>,
42    /// Dirty tiles forwarded from `Terrain` each frame.
43    #[reflect(ignore)]
44    pub dirty: Vec<TerrainDirtyTile>,
45    /// Chunks to draw this frame.
46    #[reflect(ignore)]
47    pub draw_list: TerrainDrawList,
48    /// Lowest and highest world height of each chunk, for culling.
49    #[reflect(ignore)]
50    bounds: HashMap<ChunkPos, (f32, f32)>
51}
52
53impl TerrainRenderer {
54    /// Create the shared texture arrays.
55    pub fn new(asset_server: &AssetServer) -> Self {
56        let layer_count = CHUNK_COUNT * CHUNK_COUNT;
57        let usages = TextureUsages::COPY_SRC
58            | TextureUsages::COPY_DST
59            | TextureUsages::TEXTURE_BINDING
60            | TextureUsages::STORAGE_BINDING;
61
62        let heightmap_array = asset_server.add(Texture {
63            label: "terrain_heightmap_array".to_string(),
64            size: (CHUNK_MAP_SIZE, CHUNK_MAP_SIZE),
65            format: TextureFormat::R32Float,
66            usages,
67            layer_count,
68            filterable: false,
69            ..Default::default()
70        });
71        let splatmap_arrays = (0..SPLAT_MAPS)
72            .map(|k| {
73                asset_server.add(Texture {
74                    label: format!("terrain_splatmap_array_{k}"),
75                    size: (CHUNK_MAP_SIZE, CHUNK_MAP_SIZE),
76                    format: TextureFormat::Rgba8Unorm,
77                    usages,
78                    layer_count,
79                    ..Default::default()
80                })
81            })
82            .collect();
83
84        // Build the position→layer map in the same iteration order used by Terrain::load.
85        let mut pos_to_layer = HashMap::new();
86        let mut layer = 0u32;
87        for i in 0..CHUNK_COUNT {
88            for j in 0..CHUNK_COUNT {
89                let px = i as i32 - (CHUNK_COUNT as i32) / 2;
90                let pz = j as i32 - (CHUNK_COUNT as i32) / 2;
91                pos_to_layer.insert(IVec2::new(px, pz), layer);
92                layer += 1;
93            }
94        }
95
96        Self {
97            pos_to_layer,
98            heightmap_array: Some(heightmap_array),
99            splatmap_arrays,
100            dirty: Vec::new(),
101            draw_list: TerrainDrawList::default(),
102            bounds: HashMap::new()
103        }
104    }
105
106    /// Move dirty tiles from `Terrain` into this renderer's queue.
107    pub fn extract_dirty(
108        mut renderer: Query<&mut TerrainRenderer>,
109        mut terrain: Query<&mut Terrain>
110    ) {
111        let (Ok(mut renderer), Ok(mut terrain)) = (renderer.single_mut(), terrain.single_mut())
112        else {
113            return;
114        };
115        renderer.dirty.clear();
116        renderer.dirty.append(&mut terrain.dirty_render);
117
118        // Height bounds of the chunks whose heightmap changed
119        let renderer = renderer.as_mut();
120        for (pos, map_type, _, _) in &renderer.dirty {
121            if *map_type == 0
122                && let Some(heights) = terrain.heights.get(pos)
123            {
124                let bounds = heights
125                    .iter()
126                    .fold((f32::MAX, f32::MIN), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
127                renderer.bounds.insert(*pos, bounds);
128            }
129        }
130    }
131
132    /// Chooses the chunks to draw, outside of the camera frustum excluded, and their level of
133    /// detail from their distance to the camera.
134    pub fn update_draw_list(
135        mut renderer: Query<&mut TerrainRenderer>,
136        camera: Query<(&GlobalTransform, &CameraView), With<ActiveCamera>>,
137        window: Query<&Window, With<PrimaryWindow>>
138    ) {
139        let (Ok(mut renderer), Ok((camera_transform, view))) =
140            (renderer.single_mut(), camera.single())
141        else {
142            return;
143        };
144        let aspect = window
145            .single()
146            .map_or(1.0, |w| w.width() / w.height().max(1.0));
147        let view_proj = Mat4::perspective_rh(view.fov.to_radians(), aspect, view.znear, view.zfar)
148            * camera_transform.to_matrix().inverse();
149        let eye = camera_transform.translation();
150
151        let renderer = renderer.as_mut();
152        let half = CHUNK_SIZE / 2.0;
153        let lod_of = |pos: ChunkPos| -> Option<u32> {
154            let (lo, hi) = *renderer.bounds.get(&pos)?;
155            let center = pos.as_vec2() * CHUNK_SIZE;
156            let min = Vec3::new(center.x - half, lo, center.y - half);
157            let max = Vec3::new(center.x + half, hi, center.y + half);
158            let distance = eye.clamp(min, max).distance(eye);
159            let lod = (distance / LOD_BASE_DISTANCE).max(1.0).log2().floor() as u32 + 1;
160            Some(match distance < LOD_BASE_DISTANCE {
161                true => 0,
162                false => lod.min(TERRAIN_LOD_COUNT as u32 - 1)
163            })
164        };
165        let visible = |pos: ChunkPos| -> bool {
166            let Some(&(lo, hi)) = renderer.bounds.get(&pos) else {
167                return false;
168            };
169            let center = pos.as_vec2() * CHUNK_SIZE;
170            let corners = (0..8).map(|c| {
171                let x = center.x + if c & 1 == 0 { -half } else { half };
172                let z = center.y + if c & 2 == 0 { -half } else { half };
173                let y = if c & 4 == 0 { lo } else { hi };
174                view_proj * Vec4::new(x, y, z, 1.0)
175            });
176            // Outside when every corner is beyond the same frustum plane
177            let corners: Vec<Vec4> = corners.collect();
178            let outside = |test: &dyn Fn(&Vec4) -> bool| corners.iter().all(test);
179            !(outside(&|p| p.x < -p.w)
180                || outside(&|p| p.x > p.w)
181                || outside(&|p| p.y < -p.w)
182                || outside(&|p| p.y > p.w)
183                || outside(&|p| p.z < 0.0)
184                || outside(&|p| p.z > p.w))
185        };
186
187        let lods: HashMap<ChunkPos, u32> = renderer
188            .pos_to_layer
189            .keys()
190            .filter_map(|pos| Some((*pos, lod_of(*pos)?)))
191            .collect();
192        let mut tiles: Vec<TerrainTileDescription> = renderer
193            .pos_to_layer
194            .iter()
195            .filter(|(pos, _)| visible(**pos))
196            .filter_map(|(pos, layer)| {
197                let lod = *lods.get(pos)?;
198                // Neighbors absent from the terrain do not constrain the edges
199                let neighbor = |offset: IVec2| *lods.get(&(*pos + offset)).unwrap_or(&lod) as f32;
200                Some(TerrainTileDescription {
201                    pos: [pos.x as f32, pos.y as f32],
202                    lod: lod as f32,
203                    tile_layer: *layer,
204                    neighbor_lods: [
205                        neighbor(IVec2::NEG_X),
206                        neighbor(IVec2::X),
207                        neighbor(IVec2::NEG_Y),
208                        neighbor(IVec2::Y)
209                    ]
210                })
211            })
212            .collect();
213        tiles.sort_by_key(|t| t.lod as u32);
214
215        let mut lod_ranges: [std::ops::Range<u32>; TERRAIN_LOD_COUNT] = Default::default();
216        for (lod, range) in lod_ranges.iter_mut().enumerate() {
217            let start = tiles.partition_point(|t| (t.lod as usize) < lod) as u32;
218            let end = tiles.partition_point(|t| (t.lod as usize) <= lod) as u32;
219            *range = start..end;
220        }
221        renderer.draw_list = TerrainDrawList { tiles, lod_ranges };
222    }
223}