use glam::{Vec2, Vec3, Vec4};
use super::{Material, MaterialDesc, Mesh, MeshData, TextureArray};
use crate::Ctx;
pub const TERRAIN_LAYERS: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TerrainMaterialDesc {
pub layers: TextureArray,
pub normals: Option<TextureArray>,
pub normal_strength: f32,
pub weights: TextureArray,
pub weights_corner: Vec2,
pub weights_size: Vec2,
pub layer_size: [f32; TERRAIN_LAYERS],
pub roughness: f32,
}
impl TerrainMaterialDesc {
#[must_use]
pub fn new(layers: TextureArray, weights: TextureArray, corner: Vec2, size: Vec2) -> Self {
Self {
layers,
normals: None,
normal_strength: 1.0,
weights,
weights_corner: corner,
weights_size: size,
layer_size: [4.0; TERRAIN_LAYERS],
roughness: 0.9,
}
}
pub(crate) fn check(&self) -> Result<(), String> {
let count = self.layers.layers() as usize;
if count > TERRAIN_LAYERS {
return Err(format!(
"a terrain material blends at most {TERRAIN_LAYERS} layers (got {count})"
));
}
let needed = count.div_ceil(4) as u32;
if self.weights.layers() < needed {
return Err(format!(
"{count} terrain layers need {needed} weight images (4 layers each), got {}",
self.weights.layers()
));
}
if let Some(normals) = self.normals
&& normals.layers() as usize != count
{
return Err(format!(
"{count} terrain layers need {count} normal maps, got {}",
normals.layers()
));
}
let positive = |v: f32| v > 0.0 && v.is_finite();
if !positive(self.weights_size.x) || !positive(self.weights_size.y) {
return Err(format!(
"the weight map's size must be above 0 (got {})",
self.weights_size
));
}
if let Some(bad) = self.layer_size[..count].iter().find(|s| !positive(**s)) {
return Err(format!("each terrain layer's size must be above 0 (got {bad})"));
}
if !(0.0..=1.0).contains(&self.roughness) {
return Err(format!("roughness goes from 0 to 1 (got {})", self.roughness));
}
Ok(())
}
}
pub const TERRAIN_LODS: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(C)] pub struct TerrainTile {
pub lods: [Mesh; TERRAIN_LODS],
pub lod_distance: f32,
}
impl TerrainTile {
#[must_use]
pub fn lod(&self, distance: f32) -> usize {
lod_at(distance, self.lod_distance)
}
}
pub(crate) fn lod_at(distance: f32, lod_distance: f32) -> usize {
if lod_distance <= 0.0 || distance.is_nan() || distance < lod_distance {
return 0;
}
let doublings = (distance / lod_distance).log2().floor() as usize;
(doublings + 1).min(TERRAIN_LODS - 1)
}
impl Ctx {
pub fn create_terrain_material(&mut self, desc: &TerrainMaterialDesc) -> Material {
self.try_create_terrain_material(desc)
.unwrap_or_else(|e| panic!("ponko: create_terrain_material: {e}"))
}
#[doc(hidden)]
pub fn try_create_terrain_material(&mut self, desc: &TerrainMaterialDesc) -> Result<Material, String> {
desc.check()?;
let arrays = [Some(desc.layers), desc.normals, Some(desc.weights)];
if arrays.into_iter().flatten().any(|a| !self.has_texture_array(a)) {
return Err("that texture array wasn't made by this game".into());
}
let shader = match self.assets3d.terrain_shader {
Some(shader) => shader,
None => {
let shader = self.try_create_shader("terrain.wgsl (built in)", include_str!("terrain.wgsl"))?;
self.assets3d.terrain_shader = Some(shader);
shader
}
};
let inverse = desc.layer_size.map(|s| if s > 0.0 { 1.0 / s } else { 0.0 });
let custom = [
Vec4::new(
desc.layers.layers() as f32,
desc.normal_strength,
if desc.normals.is_some() { 1.0 } else { 0.0 },
0.0,
),
Vec4::from_slice(&inverse[..4]),
Vec4::from_slice(&inverse[4..]),
Vec4::new(
desc.weights_corner.x,
desc.weights_corner.y,
1.0 / desc.weights_size.x,
1.0 / desc.weights_size.y,
),
];
Ok(self.create_material(MaterialDesc {
roughness: desc.roughness,
shader: Some(shader),
custom,
custom_arrays: arrays,
..Default::default()
}))
}
pub fn create_terrain_tile(&mut self, heights: &[f32], size: f32, material: Material) -> TerrainTile {
self.upload_terrain_tile(&TerrainTileData::new(heights, size), material)
}
pub fn upload_terrain_tile(&mut self, data: &TerrainTileData, material: Material) -> TerrainTile {
let lods = std::array::from_fn(|i| self.create_mesh(&data.lods[i], material));
TerrainTile {
lods,
lod_distance: data.size(),
}
}
pub fn free_terrain_tile(&mut self, tile: TerrainTile) {
for mesh in tile.lods {
self.free_mesh(mesh);
}
}
}
#[derive(Debug, Clone)]
pub struct TerrainTileData {
lods: [MeshData; TERRAIN_LODS],
ground: Heightfield,
}
impl TerrainTileData {
#[must_use]
pub fn new(heights: &[f32], size: f32) -> Self {
assert!(
size > 0.0 && size.is_finite(),
"ponko: create_terrain_tile: size must be above 0 (got {size})"
);
Self {
lods: std::array::from_fn(|i| MeshData::heightfield(heights, size, 1 << i)),
ground: Heightfield::new(heights, size),
}
}
#[must_use]
pub fn size(&self) -> f32 {
self.ground.size()
}
#[must_use]
pub fn heightfield(&self) -> &Heightfield {
&self.ground
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Heightfield {
heights: Vec<f32>,
n: usize,
size: f32,
}
impl Heightfield {
#[must_use]
pub fn new(heights: &[f32], size: f32) -> Self {
let n = (heights.len() as f64).sqrt() as usize;
assert!(
n >= 2 && n * n == heights.len(),
"ponko: Heightfield: {} heights isn't a square grid of at least 2 x 2",
heights.len()
);
assert!(
size > 0.0 && size.is_finite(),
"ponko: Heightfield: size must be above 0 (got {size})"
);
Self {
heights: heights.to_vec(),
n,
size,
}
}
#[must_use]
pub fn size(&self) -> f32 {
self.size
}
#[must_use]
pub fn heights(&self) -> &[f32] {
&self.heights
}
fn triangle(&self, x: f32, z: f32) -> Option<[Vec3; 3]> {
if !(0.0..=self.size).contains(&x) || !(0.0..=self.size).contains(&z) {
return None;
}
let cells = self.n - 1;
let cell = self.size / cells as f32;
let (gx, gz) = (x / cell, z / cell);
let (i, j) = ((gx as usize).min(cells - 1), (gz as usize).min(cells - 1));
let corner = |i: usize, j: usize| {
let h = self.heights[j * self.n + i];
Vec3::new(i as f32 * cell, h, j as f32 * cell)
};
let [a, b, c, d] = [corner(i, j), corner(i + 1, j), corner(i, j + 1), corner(i + 1, j + 1)];
if [a, b, c, d].iter().any(|p| p.y.is_nan()) {
return None;
}
if (gx - i as f32) + (gz - j as f32) <= 1.0 {
Some([a, c, b])
} else {
Some([b, c, d])
}
}
#[must_use]
pub fn height_at(&self, x: f32, z: f32) -> Option<f32> {
let [p, q, r] = self.triangle(x, z)?;
let normal = (q - p).cross(r - p);
Some(p.y - (normal.x * (x - p.x) + normal.z * (z - p.z)) / normal.y)
}
#[must_use]
pub fn normal_at(&self, x: f32, z: f32) -> Option<Vec3> {
let [p, q, r] = self.triangle(x, z)?;
Some((q - p).cross(r - p).normalize())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heights_match_the_drawn_triangles() {
let n = 9;
let heights: Vec<f32> = (0..n * n).map(|i| ((i * 37 % 11) as f32) * 0.7).collect();
let ground = Heightfield::new(&heights, 16.0);
let mesh = MeshData::heightfield(&heights, 16.0, 1);
let on_mesh = |x: f32, z: f32, y: f32| {
mesh.indices.chunks(3).any(|t| {
let [a, b, c] = [0, 1, 2].map(|k| mesh.positions[t[k] as usize]);
let area = |p: Vec3, q: Vec3, r: Vec3| (q.x - p.x) * (r.z - p.z) - (r.x - p.x) * (q.z - p.z);
let at = Vec3::new(x, 0.0, z);
let (w0, w1, w2) = (area(b, c, at), area(c, a, at), area(a, b, at));
let inside = (w0 >= -1e-4 && w1 >= -1e-4 && w2 >= -1e-4) || (w0 <= 1e-4 && w1 <= 1e-4 && w2 <= 1e-4);
let total = w0 + w1 + w2;
inside && total.abs() > 1e-6 && {
let h = (a.y * w0 + b.y * w1 + c.y * w2) / total;
(h - y).abs() < 1e-3
}
})
};
for k in 0..400 {
let (x, z) = ((k * 13 % 160) as f32 * 0.1, (k * 29 % 160) as f32 * 0.1);
let y = ground.height_at(x, z).unwrap();
assert!(on_mesh(x, z, y), "({x}, {z}) -> {y}");
}
assert!((ground.height_at(4.0, 6.0).unwrap() - heights[3 * n + 2]).abs() < 1e-5);
assert!((ground.height_at(16.0, 16.0).unwrap() - heights[n * n - 1]).abs() < 1e-5);
}
#[test]
fn holes_and_the_outside_have_no_ground() {
let mut heights = vec![1.0; 9];
heights[4] = f32::NAN; let ground = Heightfield::new(&heights, 2.0);
assert_eq!(ground.height_at(0.5, 0.5), None);
assert_eq!(ground.height_at(-0.1, 0.5), None);
assert_eq!(ground.normal_at(1.5, 2.1), None);
}
#[test]
fn normals_face_up_the_slope_side() {
let ground = Heightfield::new(&[0.0, 1.0, 0.0, 1.0], 1.0);
let n = ground.normal_at(0.3, 0.3).unwrap();
assert!(n.x < 0.0 && n.y > 0.0 && n.z.abs() < 1e-6);
assert!((n.length() - 1.0).abs() < 1e-6);
}
#[test]
fn detail_drops_once_per_doubling_of_distance() {
let at = |d| lod_at(d, 100.0);
assert_eq!(at(0.0), 0);
assert_eq!(at(99.9), 0);
assert_eq!(at(100.0), 1);
assert_eq!(at(199.0), 1);
assert_eq!(at(200.0), 2);
assert_eq!(at(399.0), 2);
assert_eq!(at(400.0), 3);
assert_eq!(at(1e9), 3);
assert_eq!(at(f32::NAN), 0);
assert_eq!(lod_at(50.0, 0.0), 0, "no distance: always the finest");
}
}