Skip to main content

unity_formats/
terrain.rs

1//! Unity terrains as stored: `TerrainData` (the heightmap, holes, splat layers and their alphamaps, tree prototypes
2//! and instances) and `TerrainLayer`.
3//!
4//! The heightmap is `resolution` x `resolution` samples, row `z` then column `x`, each 0..=32766 for 0..1 of the
5//! terrain's height (`scale.y` metres; `TerrainData.GetHeight` is `h / 32766 * size.y`); samples are `scale.x` and
6//! `scale.z` metres apart from the terrain's position, so the terrain is `(resolution - 1) * scale` wide. Holes are
7//! one byte per cell, 0 a hole (none stored: no holes). Alphamaps hold four layer weights per texel (RGBA), texel
8//! row 0 at z = 0 as Unity stores rows.
9
10use crate::player::{ObjRef, Player};
11use crate::value::{Array, Value};
12use crate::{Result, invalid};
13
14/// The highest stored height sample: 0..=32766 is 0..1 of the terrain's height (`TerrainData.GetHeight`).
15pub const MAX_HEIGHT: i16 = 32766;
16
17/// Why [`check_heightmap`] refuses a heightmap.
18#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
19pub enum HeightmapError {
20    /// The resolution leaves no cell.
21    #[error("resolution {resolution}: a heightmap has at least 2 x 2 samples")]
22    Resolution {
23        /// The stated resolution.
24        resolution: u32,
25    },
26    /// The resolution squared does not fit the address space.
27    #[error("resolution {resolution}: the heightmap's sample count overflows")]
28    SampleCount {
29        /// The stated resolution.
30        resolution: u32,
31    },
32    /// The number of height samples is not the resolution squared.
33    #[error("{found} heights for resolution {resolution} ({expected} expected)")]
34    Heights {
35        /// The stated resolution.
36        resolution: u32,
37        /// The samples stored.
38        found: usize,
39        /// The samples the resolution needs.
40        expected: usize,
41    },
42    /// A sample is outside 0..=[`MAX_HEIGHT`].
43    #[error("height sample {index} (row {row}, column {column}) is {value}, outside 0..={MAX_HEIGHT}")]
44    HeightRange {
45        /// The sample's place in the array.
46        index: usize,
47        /// Its row.
48        row: usize,
49        /// Its column.
50        column: usize,
51        /// Its value.
52        value: i16,
53    },
54    /// The number of holes is not the number of cells.
55    #[error("{found} holes for {cells} cells")]
56    Holes {
57        /// The holes stored.
58        found: usize,
59        /// The cells of the heightmap.
60        cells: usize,
61    },
62}
63
64impl From<HeightmapError> for crate::Error {
65    fn from(e: HeightmapError) -> Self {
66        invalid(format!("terrain: {e}"))
67    }
68}
69
70/// What a heightmap must be to be read: `resolution` x `resolution` samples (at least 2 x 2: one cell), each
71/// 0..=[`MAX_HEIGHT`], and, where it has holes, one per cell ((resolution - 1)^2).
72pub fn check_heightmap(resolution: u32, heights: &[i16], holes: Option<&[u8]>) -> Result<(), HeightmapError> {
73    if resolution < 2 {
74        return Err(HeightmapError::Resolution { resolution });
75    }
76    let n = usize::try_from(resolution).map_err(|_| HeightmapError::SampleCount { resolution })?;
77    let samples = n
78        .checked_mul(n)
79        .ok_or(HeightmapError::SampleCount { resolution })?;
80    if heights.len() != samples {
81        return Err(HeightmapError::Heights {
82            resolution,
83            found: heights.len(),
84            expected: samples,
85        });
86    }
87    if let Some((index, &value)) = heights
88        .iter()
89        .enumerate()
90        .find(|(_, h)| !(0..=MAX_HEIGHT).contains(*h))
91    {
92        return Err(HeightmapError::HeightRange {
93            index,
94            row: index.div_euclid(n),
95            column: index.rem_euclid(n),
96            value,
97        });
98    }
99    // n >= 2 and n * n did not overflow, so (n - 1)^2 does not either
100    let cells = n.saturating_sub(1).saturating_mul(n.saturating_sub(1));
101    match holes {
102        Some(h) if h.len() != cells => Err(HeightmapError::Holes {
103            found: h.len(),
104            cells,
105        }),
106        _ => Ok(()),
107    }
108}
109
110/// A tree placed on a terrain: position as 0..1 of the terrain's size, scales, rotation about y (radians), colour,
111/// and its prototype's index.
112#[derive(Clone, Debug, PartialEq)]
113pub struct TreeInstance {
114    /// Position as 0 to 1 of the terrain's size along each axis.
115    pub position: [f32; 3],
116    /// `widthScale`.
117    pub width_scale: f32,
118    /// `heightScale`.
119    pub height_scale: f32,
120    /// Rotation about the y axis, in radians.
121    pub rotation: f32,
122    /// The tint, packed as stored (`color.rgba`).
123    pub color: u32,
124    /// Index into [`TerrainData::tree_prototypes`].
125    pub prototype: usize,
126}
127
128/// A `TerrainData` as stored.
129#[derive(Clone, Debug, PartialEq)]
130pub struct TerrainData {
131    /// `m_Name`.
132    pub name: String,
133    /// Samples along each side of the heightmap.
134    pub resolution: u32,
135    /// Metres between samples along x, the height in metres along y, metres between samples along z (`m_Scale`).
136    pub scale: [f32; 3],
137    /// The height samples, row `z` then column `x`, each 0 to [`MAX_HEIGHT`].
138    pub heights: Vec<i16>,
139    /// One byte per cell, 0 a hole; `None` when none are stored.
140    pub holes: Option<Vec<u8>>,
141    /// The splat layers (`m_TerrainLayers`), read with [`TerrainLayerData::read`]; `None` for a null pointer.
142    pub layers: Vec<Option<ObjRef>>,
143    /// The splat weight textures (`m_AlphaTextures`), four layers' weights each as RGBA.
144    pub alphamaps: Vec<Option<ObjRef>>,
145    /// `m_AlphamapResolution`: the side of an alphamap in texels.
146    pub alphamap_resolution: u32,
147    /// Each tree prototype's prefab.
148    pub tree_prototypes: Vec<Option<ObjRef>>,
149    /// The tree instances.
150    pub trees: Vec<TreeInstance>,
151}
152
153/// A `TerrainLayer` as stored.
154#[derive(Clone, Debug, PartialEq)]
155pub struct TerrainLayerData {
156    /// `m_Name`.
157    pub name: String,
158    /// The diffuse texture.
159    pub diffuse: Option<ObjRef>,
160    /// The normal map.
161    pub normal: Option<ObjRef>,
162    /// The mask map.
163    pub mask: Option<ObjRef>,
164    /// `m_TileSize`: how many metres one repeat covers.
165    pub tile_size: [f32; 2],
166    /// `m_TileOffset`.
167    pub tile_offset: [f32; 2],
168    /// `m_Specular`, RGBA.
169    pub specular: [f32; 4],
170    /// `m_Metallic`.
171    pub metallic: f32,
172    /// `m_Smoothness`.
173    pub smoothness: f32,
174    /// `m_NormalScale`.
175    pub normal_scale: f32,
176    /// Min and max, RGBA.
177    pub diffuse_remap: [[f32; 4]; 2],
178    /// `m_MaskMapRemapMin` and `m_MaskMapRemapMax`: min and max, RGBA.
179    pub mask_remap: [[f32; 4]; 2],
180}
181
182fn get<'a>(v: &'a Value, k: &str) -> Result<&'a Value> {
183    v.get(k).ok_or_else(|| invalid(format!("terrain: no {k}")))
184}
185
186fn float(v: &Value, k: &str) -> Result<f32> {
187    get(v, k)?
188        .as_f32()
189        .ok_or_else(|| invalid(format!("terrain: {k} is not a float")))
190}
191
192fn int(v: &Value, k: &str) -> Result<i64> {
193    get(v, k)?
194        .as_i64()
195        .ok_or_else(|| invalid(format!("terrain: {k} is not an integer")))
196}
197
198fn vec<const N: usize>(v: &Value, k: &str, names: [&str; N]) -> Result<[f32; N]> {
199    let x = get(v, k)?;
200    let mut out = [0.0; N];
201    for (o, n) in out.iter_mut().zip(names) {
202        *o = float(x, n)?;
203    }
204    Ok(out)
205}
206
207fn list<'a>(v: &'a Value, k: &str) -> Result<&'a [Value]> {
208    match get(v, k)? {
209        Value::Array(Array::Values(items)) => Ok(items),
210        Value::Array(a) if a.is_empty() => Ok(&[]),
211        _ => Err(invalid(format!("terrain: {k} is not a list"))),
212    }
213}
214
215fn pptrs(player: &Player, file: &str, items: &[Value], field: Option<&str>) -> Result<Vec<Option<ObjRef>>> {
216    items
217        .iter()
218        .map(|p| {
219            let p = match field {
220                Some(f) => get(p, f)?,
221                None => p,
222            };
223            player.resolve(file, p)
224        })
225        .collect()
226}
227
228/// The tree instances `items` states, each naming one of `prototypes` prototypes and a colour that fits 32 bits.
229fn read_trees(items: &[Value], prototypes: usize) -> Result<Vec<TreeInstance>> {
230    items
231        .iter()
232        .enumerate()
233        .map(|(n, i)| {
234            let index = int(i, "index")?;
235            let prototype = usize::try_from(index)
236                .ok()
237                .filter(|&p| p < prototypes)
238                .ok_or_else(|| {
239                    invalid(format!(
240                        "terrain: tree {n} uses prototype {index} of {prototypes}"
241                    ))
242                })?;
243            let rgba = int(get(i, "color")?, "rgba")?;
244            let color = u32::try_from(rgba).map_err(|_| {
245                invalid(format!(
246                    "terrain: tree {n}: colour {rgba} is not a 32-bit unsigned number"
247                ))
248            })?;
249            Ok(TreeInstance {
250                position: vec(i, "position", ["x", "y", "z"])?,
251                width_scale: float(i, "widthScale")?,
252                height_scale: float(i, "heightScale")?,
253                rotation: float(i, "rotation")?,
254                color,
255                prototype,
256            })
257        })
258        .collect()
259}
260
261impl TerrainData {
262    /// Read the TerrainData `r` of `player`, its heightmap as [`check_heightmap`] requires.
263    pub fn read(player: &Player, r: &ObjRef) -> Result<Self> {
264        let t = player.decode(r)?;
265        let hm = get(&t, "m_Heightmap")?;
266        let resolution = int(hm, "m_Resolution")?;
267        let resolution = u32::try_from(resolution)
268            .map_err(|_| invalid(format!("terrain: resolution {resolution} is not a sample count")))?;
269        let heights = match get(hm, "m_Heights")? {
270            Value::Array(Array::I16(h)) => h.clone(),
271            _ => return Err(invalid("terrain: m_Heights is not 16-bit")),
272        };
273        let holes: Option<Vec<u8>> = match hm.get("m_Holes") {
274            Some(Value::Array(Array::U8(h))) if !h.is_empty() => Some(h.clone()),
275            Some(Value::Array(Array::Bool(h))) if !h.is_empty() => {
276                Some(h.iter().map(|&b| u8::from(b)).collect())
277            }
278            _ => None,
279        };
280        check_heightmap(resolution, &heights, holes.as_deref())?;
281        let splat = get(&t, "m_SplatDatabase")?;
282        let detail = get(&t, "m_DetailDatabase")?;
283        let prototypes = list(detail, "m_TreePrototypes")?;
284        let trees = read_trees(list(detail, "m_TreeInstances")?, prototypes.len())?;
285        let alphamap_resolution = int(splat, "m_AlphamapResolution")?;
286        let alphamap_resolution = u32::try_from(alphamap_resolution).map_err(|_| {
287            invalid(format!(
288                "terrain: m_AlphamapResolution {alphamap_resolution} is not a 32-bit unsigned number"
289            ))
290        })?;
291        Ok(TerrainData {
292            name: t.get("m_Name").and_then(Value::as_str).unwrap_or("").to_owned(),
293            resolution,
294            scale: vec(hm, "m_Scale", ["x", "y", "z"])?,
295            heights,
296            holes,
297            layers: pptrs(player, &r.file, list(splat, "m_TerrainLayers")?, None)?,
298            alphamaps: pptrs(player, &r.file, list(splat, "m_AlphaTextures")?, None)?,
299            alphamap_resolution,
300            tree_prototypes: pptrs(player, &r.file, prototypes, Some("prefab"))?,
301            trees,
302        })
303    }
304
305    /// The height of sample (x, z) in metres above the terrain's position; `None` when (x, z) is not a sample of the
306    /// heightmap.
307    pub fn height(&self, x: usize, z: usize) -> Option<f64> {
308        let side = usize::try_from(self.resolution).ok()?;
309        if x >= side || z >= side {
310            return None;
311        }
312        let sample = self.heights.get(z.checked_mul(side)?.checked_add(x)?)?;
313        Some(f64::from(*sample) / f64::from(MAX_HEIGHT) * f64::from(self.scale[1]))
314    }
315
316    /// The terrain's size in metres: (resolution - 1) * scale along x and z, scale.y high.
317    pub fn size(&self) -> [f64; 3] {
318        let cells = f64::from(self.resolution.saturating_sub(1));
319        [
320            cells * f64::from(self.scale[0]),
321            f64::from(self.scale[1]),
322            cells * f64::from(self.scale[2]),
323        ]
324    }
325}
326
327impl TerrainLayerData {
328    /// Read the TerrainLayer `r` of `player`.
329    pub fn read(player: &Player, r: &ObjRef) -> Result<Self> {
330        let l = player.decode(r)?;
331        let tex = |k: &str| -> Result<Option<ObjRef>> {
332            match l.get(k) {
333                Some(p) => player.resolve(&r.file, p),
334                None => Ok(None),
335            }
336        };
337        let rgba = ["x", "y", "z", "w"];
338        Ok(TerrainLayerData {
339            name: l.get("m_Name").and_then(Value::as_str).unwrap_or("").to_owned(),
340            diffuse: tex("m_DiffuseTexture")?,
341            normal: tex("m_NormalMapTexture")?,
342            mask: tex("m_MaskMapTexture")?,
343            tile_size: vec(&l, "m_TileSize", ["x", "y"])?,
344            tile_offset: vec(&l, "m_TileOffset", ["x", "y"])?,
345            specular: vec(&l, "m_Specular", ["r", "g", "b", "a"])?,
346            metallic: float(&l, "m_Metallic")?,
347            smoothness: float(&l, "m_Smoothness")?,
348            normal_scale: float(&l, "m_NormalScale")?,
349            diffuse_remap: [
350                vec(&l, "m_DiffuseRemapMin", rgba)?,
351                vec(&l, "m_DiffuseRemapMax", rgba)?,
352            ],
353            mask_remap: [
354                vec(&l, "m_MaskMapRemapMin", rgba)?,
355                vec(&l, "m_MaskMapRemapMax", rgba)?,
356            ],
357        })
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    /// A heightmap has at least one cell, its samples within 0..=32766, and one hole per cell where it has holes;
366    /// resolution 0 and 1 are refused before any cell count is taken.
367    #[test]
368    fn a_heightmap_is_checked_before_its_cells_are_counted() {
369        assert!(check_heightmap(2, &[0, 1, 2, MAX_HEIGHT], None).is_ok());
370        assert!(check_heightmap(2, &[0; 4], Some(&[1])).is_ok());
371        for resolution in [0, 1] {
372            assert_eq!(
373                check_heightmap(resolution, &[], Some(&[])).unwrap_err(),
374                HeightmapError::Resolution { resolution }
375            );
376        }
377        assert_eq!(
378            check_heightmap(2, &[0; 3], None).unwrap_err(),
379            HeightmapError::Heights {
380                resolution: 2,
381                found: 3,
382                expected: 4
383            }
384        );
385        let range = check_heightmap(2, &[0, 0, -1, 0], None).unwrap_err();
386        assert_eq!(
387            range,
388            HeightmapError::HeightRange {
389                index: 2,
390                row: 1,
391                column: 0,
392                value: -1
393            }
394        );
395        assert_eq!(
396            range.to_string(),
397            "height sample 2 (row 1, column 0) is -1, outside 0..=32766"
398        );
399        assert!(check_heightmap(2, &[0, 0, 0, i16::MAX], None).is_err());
400        let holes = check_heightmap(3, &[0; 9], Some(&[1; 3])).unwrap_err();
401        assert_eq!(holes, HeightmapError::Holes { found: 3, cells: 4 });
402        assert_eq!(holes.to_string(), "3 holes for 4 cells");
403        // the typed error becomes the crate's refusal
404        let e = crate::Error::from(holes);
405        assert!(matches!(e, crate::Error::Invalid(m) if m == "terrain: 3 holes for 4 cells"));
406    }
407
408    fn terrain(resolution: u32, heights: Vec<i16>, scale_y: f32) -> TerrainData {
409        TerrainData {
410            name: String::new(),
411            resolution,
412            scale: [1.0, scale_y, 1.0],
413            heights,
414            holes: None,
415            layers: vec![],
416            alphamaps: vec![],
417            alphamap_resolution: 0,
418            tree_prototypes: vec![],
419            trees: vec![],
420        }
421    }
422
423    /// A sample is the stored height over [`MAX_HEIGHT`] of the terrain's height; a sample outside the heightmap is
424    /// `None`, also where `z * resolution + x` would land in the next row.
425    #[test]
426    fn heights_are_scaled_by_max_height_and_bounded() {
427        let t = terrain(2, vec![0, MAX_HEIGHT, 16383, 0], 100.0);
428        assert_eq!(t.height(1, 0), Some(100.0));
429        assert_eq!(
430            t.height(0, 1),
431            Some(f64::from(16383) / f64::from(MAX_HEIGHT) * 100.0)
432        );
433        assert_eq!(t.height(2, 0), None);
434        assert_eq!(t.height(0, 2), None);
435        assert_eq!(t.height(usize::MAX, usize::MAX), None);
436        assert_eq!(terrain(0, vec![], 1.0).size(), [0.0, 1.0, 0.0]);
437    }
438
439    fn tree(index: i64, rgba: i64) -> Value {
440        let f = |k: &str, v: Value| (std::sync::Arc::<str>::from(k), v);
441        let v3 = Value::Struct(vec![
442            f("x", Value::Float(0.5)),
443            f("y", Value::Float(0.0)),
444            f("z", Value::Float(0.5)),
445        ]);
446        Value::Struct(vec![
447            f("position", v3),
448            f("widthScale", Value::Float(1.0)),
449            f("heightScale", Value::Float(1.0)),
450            f("rotation", Value::Float(0.0)),
451            f("color", Value::Struct(vec![f("rgba", Value::Int(rgba))])),
452            f("index", Value::Int(index)),
453        ])
454    }
455
456    /// A tree's prototype must be one of the prototypes and its colour fit 32 bits: neither is cast to fit.
457    #[test]
458    fn trees_name_a_prototype_and_a_colour_that_exist() {
459        let ok = read_trees(&[tree(1, 0xff00_ff00)], 2).unwrap();
460        assert_eq!((ok[0].prototype, ok[0].color), (1, 0xff00_ff00));
461        for (index, rgba, wants) in [
462            (2, 0, "tree 1 uses prototype 2 of 2"),
463            (-1, 0, "tree 1 uses prototype -1 of 2"),
464            (i64::MAX, 0, "prototype 9223372036854775807 of 2"),
465            (0, 1 << 32, "colour 4294967296"),
466            (0, -1, "colour -1"),
467        ] {
468            let e = read_trees(&[tree(0, 0), tree(index, rgba)], 2)
469                .unwrap_err()
470                .to_string();
471            assert!(e.contains(wants), "{e}");
472        }
473    }
474}