Skip to main content

viewport_lib_terrain/
item.rs

1//! Terrain item types submitted on `SceneFrame::plugin_items`.
2
3use std::any::Any;
4use std::sync::Arc;
5
6use viewport_lib::ItemSettings;
7use viewport_lib::plugin_api::PluginItemCollection;
8use viewport_lib::renderer::PickId;
9
10/// Number of surface layers in a [`TerrainItem`]. Two splatmaps carry
11/// four channels each; channels map to layers in order.
12pub const LAYER_COUNT: usize = 8;
13
14/// One surface layer in a splatmap-blended terrain.
15///
16/// When `textured` is false the layer shades as a flat `albedo` colour.
17/// When true it samples slot `i` of the terrain's
18/// [`LayerTextures`](TerrainItem::layer_textures) array, tiled across the
19/// surface by `uv_scale` (world metres per tile) and shifted by
20/// `uv_offset`. `albedo` still acts as a tint fallback if the terrain has
21/// no texture array bound.
22#[derive(Copy, Clone, Debug)]
23pub struct TerrainLayer {
24    pub albedo: [f32; 3],
25    pub metallic: f32,
26    pub roughness: f32,
27    pub height_bias: f32,
28    /// Whether this layer samples the diffuse texture array.
29    pub textured: bool,
30    /// Whether this layer samples the normal-map array. When false the
31    /// layer uses the flat geometric surface normal.
32    pub normal_mapped: bool,
33    /// Strength of the tangent-space normal perturbation. 1.0 is the
34    /// authored strength; 0.0 flattens it.
35    pub normal_scale: f32,
36    /// Whether this layer samples the mask-map array for per-pixel
37    /// metallic, ambient occlusion, and smoothness. When false the layer
38    /// uses its flat `metallic` / `roughness` scalars.
39    pub mask_mapped: bool,
40    /// Per-channel remap minimums applied to the mask sample
41    /// (R metallic, G AO, B detail, A smoothness).
42    pub mask_remap_min: [f32; 4],
43    /// Per-channel remap maximums applied to the mask sample.
44    pub mask_remap_max: [f32; 4],
45    /// World metres covered by one tile of the layer's textures, along X
46    /// and Y. Values at or below zero are clamped to keep the tiling
47    /// finite. Shared by the diffuse, normal, and mask maps.
48    pub uv_scale: [f32; 2],
49    /// Constant shift added to the tiled coordinate, in tile units.
50    pub uv_offset: [f32; 2],
51}
52
53impl Default for TerrainLayer {
54    fn default() -> Self {
55        Self {
56            albedo: [0.5, 0.5, 0.5],
57            metallic: 0.0,
58            roughness: 0.9,
59            height_bias: 0.0,
60            textured: false,
61            normal_mapped: false,
62            normal_scale: 1.0,
63            mask_mapped: false,
64            mask_remap_min: [0.0, 0.0, 0.0, 0.0],
65            mask_remap_max: [1.0, 1.0, 1.0, 1.0],
66            uv_scale: [1.0, 1.0],
67            uv_offset: [0.0, 0.0],
68        }
69    }
70}
71
72/// A per-layer texture array (diffuse or normal), packed so every layer
73/// samples from a single binding.
74///
75/// All layers share `dims`; the buffer holds [`LAYER_COUNT`] slots laid
76/// out one after another, RGBA8. Diffuse arrays hold sRGB colour, normal
77/// arrays hold linear tangent-space vectors: the renderer picks the
78/// texture format per binding. Slots without a source are transparent
79/// black and should be paired with a [`TerrainLayer`] whose matching flag
80/// (`textured` / `normal_mapped`) is false.
81///
82/// `rgba` sits behind an [`Arc`] so per-frame clones of the owning
83/// [`TerrainItem`] do not copy the pixels. Bump [`version`](Self::version)
84/// when the bytes change so the renderer re-uploads.
85#[derive(Clone)]
86pub struct LayerTextures {
87    rgba: Arc<[u8]>,
88    /// Per-slot texture resolution (width, height).
89    pub dims: [u32; 2],
90    /// Bumped by the consumer when `rgba` changes.
91    pub version: u64,
92}
93
94impl LayerTextures {
95    /// Assemble from up to [`LAYER_COUNT`] source textures, each an RGBA8
96    /// buffer of `dims[0] * dims[1] * 4` bytes in layer order. Missing or
97    /// trailing slots are filled with transparent black.
98    ///
99    /// Returns `None` if `sources` is empty, holds more than
100    /// [`LAYER_COUNT`] entries, or any buffer has the wrong length.
101    pub fn new(sources: &[Vec<u8>], dims: [u32; 2]) -> Option<Self> {
102        if sources.is_empty() || sources.len() > LAYER_COUNT {
103            return None;
104        }
105        let slot_len = (dims[0] as usize) * (dims[1] as usize) * 4;
106        if slot_len == 0 {
107            return None;
108        }
109        for buf in sources {
110            if buf.len() != slot_len {
111                return None;
112            }
113        }
114        let mut rgba = vec![0u8; slot_len * LAYER_COUNT];
115        for (slot, src) in sources.iter().enumerate() {
116            let start = slot * slot_len;
117            rgba[start..start + slot_len].copy_from_slice(src);
118        }
119        Some(Self {
120            rgba: Arc::from(rgba.into_boxed_slice()),
121            dims,
122            version: 0,
123        })
124    }
125
126    /// Borrow the packed RGBA bytes: [`LAYER_COUNT`] slots of
127    /// `dims[0] * dims[1] * 4` each.
128    pub fn rgba(&self) -> &[u8] {
129        &self.rgba
130    }
131}
132
133/// Per-channel layer weights painted across the terrain.
134///
135/// `rgba` is held behind an [`Arc`] so consumers can clone the
136/// `SplatmapData` per frame without copying the underlying bytes.
137/// Edit by building a new `Vec<u8>` and calling
138/// [`SplatmapData::replace`], or by `Arc::make_mut`ing in place; in
139/// either case bump [`version`](Self::version) so the renderer
140/// detects the change and re-uploads the texture.
141#[derive(Clone)]
142pub struct SplatmapData {
143    rgba: Arc<[u8]>,
144    /// Splatmap resolution (width, height).
145    pub dims: [u32; 2],
146    /// Bumped by the consumer when `rgba` changes. The renderer
147    /// compares the stored version against the last upload to decide
148    /// whether to push new texture data; no byte-level comparison or
149    /// hashing happens on the hot path.
150    pub version: u64,
151}
152
153impl SplatmapData {
154    /// Construct from an owned byte vector. The vector is moved into
155    /// an `Arc<[u8]>` so subsequent clones of the `SplatmapData`
156    /// share the bytes.
157    pub fn new(rgba: Vec<u8>, dims: [u32; 2]) -> Self {
158        Self {
159            rgba: Arc::from(rgba.into_boxed_slice()),
160            dims,
161            version: 0,
162        }
163    }
164
165    /// Borrow the RGBA bytes.
166    pub fn rgba(&self) -> &[u8] {
167        &self.rgba
168    }
169
170    /// Replace the bytes with a new buffer. Bumps `version` so the
171    /// renderer re-uploads on the next frame.
172    pub fn replace(&mut self, rgba: Vec<u8>, dims: [u32; 2]) {
173        self.rgba = Arc::from(rgba.into_boxed_slice());
174        self.dims = dims;
175        self.version = self.version.wrapping_add(1);
176    }
177
178    /// A 1x1 splatmap with all-zero channels. Use this for the second
179    /// splatmap when a terrain only uses layers 0..4.
180    pub fn empty() -> Self {
181        Self::new(vec![0, 0, 0, 0], [1, 1])
182    }
183
184    /// A 1x1 splatmap that selects layer 0 (channel R) everywhere.
185    pub fn solid_layer0() -> Self {
186        Self::new(vec![255, 0, 0, 0], [1, 1])
187    }
188
189    /// Pack per-layer single-channel weight maps into the two-splatmap
190    /// DRAKE layout.
191    ///
192    /// `per_layer` is one byte buffer per layer, each of length
193    /// `dims[0] * dims[1]`, holding `u8` weights in `[0, 255]`. Up to
194    /// the first eight layers are packed: layers 0..4 land in splatmap
195    /// A (channels R, G, B, A in that order); layers 4..8 land in
196    /// splatmap B. Missing trailing layers are zero-filled.
197    ///
198    /// Returns `None` if any provided buffer has the wrong length or
199    /// `per_layer` is empty.
200    pub fn pack_layers(per_layer: &[Vec<u8>], dims: [u32; 2]) -> Option<[Self; 2]> {
201        let pixel_count = (dims[0] as usize) * (dims[1] as usize);
202        if per_layer.is_empty() {
203            return None;
204        }
205        for buf in per_layer {
206            if buf.len() != pixel_count {
207                return None;
208            }
209        }
210        let pack = |range: std::ops::Range<usize>| -> Vec<u8> {
211            let mut out = vec![0u8; pixel_count * 4];
212            for (slot, layer_idx) in range.enumerate() {
213                if let Some(src) = per_layer.get(layer_idx) {
214                    for (px, value) in src.iter().enumerate() {
215                        out[px * 4 + slot] = *value;
216                    }
217                }
218            }
219            out
220        };
221        Some([Self::new(pack(0..4), dims), Self::new(pack(4..8), dims)])
222    }
223}
224
225/// One terrain submission for the current frame.
226///
227/// `heightmap` is held behind an [`Arc`] so per-frame submissions
228/// clone cheaply. Bump [`heightmap_version`](Self::heightmap_version)
229/// whenever you replace the heightmap so the renderer re-bakes the
230/// patch meshes.
231#[derive(Clone)]
232pub struct TerrainItem {
233    /// 16-bit heightmap samples in row-major order, shared via `Arc`.
234    pub heightmap: Arc<[u16]>,
235    /// Bumped when [`heightmap`](Self::heightmap) bytes change.
236    pub heightmap_version: u64,
237    /// Grid resolution (width, height).
238    pub dims: [u32; 2],
239    /// World-space extents along X and Y. Z-up.
240    pub world_size: [f32; 2],
241    /// World-space height range; samples are remapped from `u16` to
242    /// `[height_range[0], height_range[1]]`.
243    pub height_range: [f32; 2],
244    /// World-space origin (lower-left corner in XY).
245    pub origin: glam::Vec3,
246    /// Eight surface layers.
247    pub surface_layers: [TerrainLayer; LAYER_COUNT],
248    /// Diffuse textures for the surface layers. `None` shades every
249    /// layer as its flat `albedo`; `Some` lets layers with `textured`
250    /// set sample the array.
251    pub layer_textures: Option<LayerTextures>,
252    /// Normal-map textures for the surface layers. `None` uses the flat
253    /// geometric normal; `Some` lets layers with `normal_mapped` set
254    /// perturb the surface. Shares the `uv_scale` / `uv_offset` tiling
255    /// with the diffuse array.
256    pub normal_textures: Option<LayerTextures>,
257    /// Mask-map textures for the surface layers (R metallic, G AO,
258    /// B detail, A smoothness). `None` uses the flat per-layer scalars;
259    /// `Some` lets layers with `mask_mapped` set drive metallic / AO /
260    /// smoothness per pixel. Shares the diffuse array's tiling.
261    pub mask_textures: Option<LayerTextures>,
262    /// Two splatmaps. Channels: A.r -> 0, A.g -> 1, ..., B.a -> 7.
263    pub splatmaps: [SplatmapData; 2],
264    /// Sharpness of the height-blend pass. 0 = pure weight blend.
265    pub height_blend_strength: f32,
266    /// Scale of the procedural noise feeding the height-blend.
267    pub height_blend_noise_scale: f32,
268    pub settings: ItemSettings,
269}
270
271impl TerrainItem {
272    /// Construct with `heightmap_version` set to 0. Subsequent
273    /// edits to the heightmap should call [`replace_heightmap`].
274    pub fn new(heightmap: Vec<u16>, dims: [u32; 2]) -> Self {
275        Self {
276            heightmap: Arc::from(heightmap.into_boxed_slice()),
277            heightmap_version: 0,
278            dims,
279            world_size: [1.0, 1.0],
280            height_range: [0.0, 1.0],
281            origin: glam::Vec3::ZERO,
282            surface_layers: [TerrainLayer::default(); LAYER_COUNT],
283            layer_textures: None,
284            normal_textures: None,
285            mask_textures: None,
286            splatmaps: [SplatmapData::solid_layer0(), SplatmapData::empty()],
287            height_blend_strength: 0.0,
288            height_blend_noise_scale: 64.0,
289            settings: ItemSettings::default(),
290        }
291    }
292
293    /// Replace the heightmap and bump [`heightmap_version`].
294    pub fn replace_heightmap(&mut self, heightmap: Vec<u16>, dims: [u32; 2]) {
295        self.heightmap = Arc::from(heightmap.into_boxed_slice());
296        self.dims = dims;
297        self.heightmap_version = self.heightmap_version.wrapping_add(1);
298    }
299
300    /// Construct a fully-specified `TerrainItem` from a raw little-endian
301    /// `u16` heightmap blob.
302    ///
303    /// Convenience for loaders that produce on-disk heightmap files (raw
304    /// `.r16`, Unity `TerrainData` exports, Terragen, etc.): owns the
305    /// `u16` LE decode once so callers do not redo the byte handling.
306    ///
307    /// `bytes.len()` must equal `dims[0] * dims[1] * 2`; otherwise this
308    /// returns `None`.
309    pub fn from_u16_le_bytes(
310        bytes: &[u8],
311        dims: [u32; 2],
312        world_size: [f32; 2],
313        height_range: [f32; 2],
314        origin: glam::Vec3,
315    ) -> Option<Self> {
316        let expected = (dims[0] as usize) * (dims[1] as usize) * 2;
317        if bytes.len() != expected {
318            return None;
319        }
320        let mut heights: Vec<u16> = Vec::with_capacity(expected / 2);
321        for chunk in bytes.chunks_exact(2) {
322            heights.push(u16::from_le_bytes([chunk[0], chunk[1]]));
323        }
324        let mut item = Self::new(heights, dims);
325        item.world_size = world_size;
326        item.height_range = height_range;
327        item.origin = origin;
328        Some(item)
329    }
330
331    /// Replace the eight surface layers from a normalised descriptor list.
332    ///
333    /// Slots `0..descriptors.len().min(LAYER_COUNT)` are overwritten;
334    /// the remainder fall back to [`TerrainLayer::default`]. Useful for
335    /// imported terrains where the source authored fewer than eight
336    /// layers and the rest should stay neutral.
337    pub fn set_layers_from_descriptors(&mut self, descriptors: &[TerrainLayer]) {
338        let mut layers = [TerrainLayer::default(); LAYER_COUNT];
339        for (slot, src) in layers.iter_mut().zip(descriptors.iter().take(LAYER_COUNT)) {
340            *slot = *src;
341        }
342        self.surface_layers = layers;
343    }
344}
345
346/// Per-frame collection of terrains.
347pub struct TerrainCollection {
348    pub items: Vec<TerrainItem>,
349}
350
351impl PluginItemCollection for TerrainCollection {
352    fn len(&self) -> usize {
353        self.items.len()
354    }
355
356    fn item_settings(&self, index: usize) -> &ItemSettings {
357        &self.items[index].settings
358    }
359
360    fn pick_id(&self, index: usize) -> PickId {
361        self.items[index].settings.pick_id
362    }
363
364    fn as_any(&self) -> &dyn Any {
365        self
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn from_u16_le_bytes_round_trips_a_ramp() {
375        let dims = [4u32, 3u32];
376        let heights: Vec<u16> = (0u16..12u16).map(|i| i * 1000).collect();
377        let mut bytes = Vec::with_capacity(heights.len() * 2);
378        for h in &heights {
379            bytes.extend_from_slice(&h.to_le_bytes());
380        }
381        let item = TerrainItem::from_u16_le_bytes(
382            &bytes,
383            dims,
384            [10.0, 20.0],
385            [-5.0, 100.0],
386            glam::Vec3::new(1.0, 2.0, 3.0),
387        )
388        .expect("decode succeeds");
389        assert_eq!(item.dims, dims);
390        assert_eq!(item.world_size, [10.0, 20.0]);
391        assert_eq!(item.height_range, [-5.0, 100.0]);
392        assert_eq!(item.origin, glam::Vec3::new(1.0, 2.0, 3.0));
393        assert_eq!(item.heightmap.as_ref(), heights.as_slice());
394    }
395
396    #[test]
397    fn from_u16_le_bytes_rejects_wrong_length() {
398        let bytes = vec![0u8; 7];
399        assert!(
400            TerrainItem::from_u16_le_bytes(
401                &bytes,
402                [2, 2],
403                [1.0, 1.0],
404                [0.0, 1.0],
405                glam::Vec3::ZERO,
406            )
407            .is_none()
408        );
409    }
410
411    #[test]
412    fn pack_layers_routes_channels_correctly() {
413        let dims = [2u32, 2u32];
414        let pixel_count = 4;
415        let mut per_layer = Vec::new();
416        for layer in 0..6 {
417            per_layer.push(vec![(layer * 10) as u8; pixel_count]);
418        }
419        let [a, b] = SplatmapData::pack_layers(&per_layer, dims).expect("pack");
420        for px in 0..pixel_count {
421            assert_eq!(a.rgba()[px * 4], 0);
422            assert_eq!(a.rgba()[px * 4 + 1], 10);
423            assert_eq!(a.rgba()[px * 4 + 2], 20);
424            assert_eq!(a.rgba()[px * 4 + 3], 30);
425            assert_eq!(b.rgba()[px * 4], 40);
426            assert_eq!(b.rgba()[px * 4 + 1], 50);
427            assert_eq!(b.rgba()[px * 4 + 2], 0);
428            assert_eq!(b.rgba()[px * 4 + 3], 0);
429        }
430    }
431
432    #[test]
433    fn pack_layers_rejects_short_buffers() {
434        let dims = [2u32, 2u32];
435        let per_layer = vec![vec![0u8; 3]];
436        assert!(SplatmapData::pack_layers(&per_layer, dims).is_none());
437    }
438
439    #[test]
440    fn set_layers_from_descriptors_pads_with_default() {
441        let mut item = TerrainItem::new(vec![0; 1], [1, 1]);
442        let custom = TerrainLayer {
443            albedo: [0.1, 0.2, 0.3],
444            metallic: 0.5,
445            roughness: 0.4,
446            height_bias: 0.7,
447            textured: true,
448            normal_mapped: false,
449            normal_scale: 1.0,
450            mask_mapped: false,
451            mask_remap_min: [0.0, 0.0, 0.0, 0.0],
452            mask_remap_max: [1.0, 1.0, 1.0, 1.0],
453            uv_scale: [4.0, 4.0],
454            uv_offset: [0.0, 0.0],
455        };
456        item.set_layers_from_descriptors(&[custom]);
457        assert_eq!(item.surface_layers[0].albedo, [0.1, 0.2, 0.3]);
458        assert!(item.surface_layers[0].textured);
459        let default = TerrainLayer::default();
460        assert_eq!(item.surface_layers[1].albedo, default.albedo);
461        assert!(!item.surface_layers[1].textured);
462        assert_eq!(item.surface_layers[7].albedo, default.albedo);
463    }
464
465    #[test]
466    fn layer_textures_pack_slots_in_order() {
467        let dims = [2u32, 2u32];
468        let slot_len = 2 * 2 * 4;
469        let red = vec![255u8; slot_len];
470        let green: Vec<u8> = (0..slot_len)
471            .map(|i| if i % 4 == 1 { 255 } else { 0 })
472            .collect();
473        let tex = LayerTextures::new(&[red.clone(), green.clone()], dims).expect("pack");
474        assert_eq!(tex.dims, dims);
475        let packed = tex.rgba();
476        assert_eq!(packed.len(), slot_len * LAYER_COUNT);
477        assert_eq!(&packed[..slot_len], red.as_slice());
478        assert_eq!(&packed[slot_len..slot_len * 2], green.as_slice());
479        // Trailing slots are zero-filled.
480        assert!(packed[slot_len * 2..].iter().all(|&b| b == 0));
481    }
482
483    #[test]
484    fn layer_textures_reject_bad_input() {
485        let dims = [2u32, 2u32];
486        assert!(LayerTextures::new(&[], dims).is_none());
487        assert!(LayerTextures::new(&[vec![0u8; 3]], dims).is_none());
488        let ok = vec![0u8; 16];
489        let too_many = vec![ok.clone(); LAYER_COUNT + 1];
490        assert!(LayerTextures::new(&too_many, dims).is_none());
491    }
492}