use std::sync::OnceLock;
use bevy::asset::RenderAssetUsages;
use bevy::math::{IVec3, Quat, Vec3};
use bevy::mesh::{Indices, Mesh, PrimitiveTopology};
use bevy::platform::collections::HashMap;
use super::model::{SHAPES_PER_FAMILY, mix64, unit};
pub(crate) const SHAPES: usize = 2 * SHAPES_PER_FAMILY;
pub(crate) const NEAR_SUBDIVISIONS: u32 = 3;
pub(crate) const FAR_SUBDIVISIONS: u32 = 2;
pub(crate) const TONES: [Vec3; 4] = [
Vec3::new(0.848, 1.015, 1.309),
Vec3::new(1.223, 1.083, 0.832),
Vec3::new(1.214, 1.270, 1.256),
Vec3::new(0.715, 0.632, 0.603),
];
const TONE_STRENGTH: f32 = 1.0;
const TONE_JOINT: f32 = 0.004;
const B_RANGE: [f32; 2] = [0.55, 0.85];
const C_ANGULAR: [f32; 2] = [0.30, 0.45];
const C_ROUNDED: [f32; 2] = [0.38, 0.55];
const ROUND_ANGULAR: f32 = 0.015;
const ROUND_ROUNDED: f32 = 0.11;
const SPHERE_ANGULAR: f32 = 1.6;
const SPHERE_ROUNDED: f32 = 1.0;
const JOINTS_ANGULAR: [f32; 2] = [0.58, 0.80];
const JOINTS_ROUNDED: [f32; 2] = [0.72, 0.92];
const JOINT_JITTER: f32 = 0.18;
const CHIPS_ANGULAR: usize = 6;
const CHIPS_ROUNDED: usize = 2;
const CHIP_ANGULAR: [f32; 2] = [0.70, 0.92];
const ROUGH_ANGULAR: f32 = 0.035;
const ROUGH_ROUNDED: f32 = 0.05;
const BASE_DARK: f32 = 0.55;
const BASE_RISE: [f32; 2] = [0.18, 0.55];
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ShapeInfo {
pub angular: bool,
pub b: f32,
pub c: f32,
}
pub(crate) struct RockGeometry {
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub colours: Vec<[f32; 4]>,
pub indices: Vec<u32>,
pub info: ShapeInfo,
pub tones: Vec<Vec3>,
}
pub(crate) fn icosphere(subdivisions: u32) -> (Vec<Vec3>, Vec<[u32; 3]>) {
let t = (1.0 + 5f32.sqrt()) / 2.0;
let mut v: Vec<Vec3> = [
[-1.0, t, 0.0],
[1.0, t, 0.0],
[-1.0, -t, 0.0],
[1.0, -t, 0.0],
[0.0, -1.0, t],
[0.0, 1.0, t],
[0.0, -1.0, -t],
[0.0, 1.0, -t],
[t, 0.0, -1.0],
[t, 0.0, 1.0],
[-t, 0.0, -1.0],
[-t, 0.0, 1.0],
]
.iter()
.map(|p| Vec3::from_array(*p).normalize())
.collect();
let mut faces: Vec<[u32; 3]> = vec![
[0, 11, 5],
[0, 5, 1],
[0, 1, 7],
[0, 7, 10],
[0, 10, 11],
[1, 5, 9],
[5, 11, 4],
[11, 10, 2],
[10, 7, 6],
[7, 1, 8],
[3, 9, 4],
[3, 4, 2],
[3, 2, 6],
[3, 6, 8],
[3, 8, 9],
[4, 9, 5],
[2, 4, 11],
[6, 2, 10],
[8, 6, 7],
[9, 8, 1],
];
for _ in 0..subdivisions {
let mut midpoints: HashMap<(u32, u32), u32> = HashMap::default();
let mut midpoint = |a: u32, b: u32, v: &mut Vec<Vec3>| -> u32 {
let key = (a.min(b), a.max(b));
*midpoints.entry(key).or_insert_with(|| {
v.push(((v[a as usize] + v[b as usize]) * 0.5).normalize());
(v.len() - 1) as u32
})
};
let mut next = Vec::with_capacity(faces.len() * 4);
for [a, b, c] in faces {
let ab = midpoint(a, b, &mut v);
let bc = midpoint(b, c, &mut v);
let ca = midpoint(c, a, &mut v);
next.extend_from_slice(&[[a, ab, ca], [b, bc, ab], [c, ca, bc], [ab, bc, ca]]);
}
faces = next;
}
(v, faces)
}
fn value_noise3(p: Vec3, seed: u64) -> f32 {
let base = p.floor();
let f = p - base;
let w = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
let c = base.as_ivec3();
let v = |d: IVec3| {
let q = c + d;
unit(mix64(
seed ^ (q.x as i64 as u64).wrapping_mul(0xD6E8_FEB8_6659_FD93)
^ (q.y as i64 as u64).wrapping_mul(0xA076_1D64_78BD_642F)
^ (q.z as i64 as u64).wrapping_mul(0xE703_7ED1_A0B4_28DB),
))
};
let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
let x00 = lerp(v(IVec3::new(0, 0, 0)), v(IVec3::new(1, 0, 0)), w.x);
let x10 = lerp(v(IVec3::new(0, 1, 0)), v(IVec3::new(1, 1, 0)), w.x);
let x01 = lerp(v(IVec3::new(0, 0, 1)), v(IVec3::new(1, 0, 1)), w.x);
let x11 = lerp(v(IVec3::new(0, 1, 1)), v(IVec3::new(1, 1, 1)), w.x);
lerp(lerp(x00, x10, w.y), lerp(x01, x11, w.y), w.z)
}
fn fbm3(p: Vec3, seed: u64) -> f32 {
(2.0 * value_noise3(p, seed) + value_noise3(p * 2.07 + 11.3, seed ^ 0x3F)) / 3.0
}
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
fn facet_areas(facets: &[usize], count: usize, points: &[Vec3], faces: &[[u32; 3]]) -> Vec<f32> {
let mut areas = vec![0.0; count];
for &[i, j, k] in faces {
let (a, b, c) = (points[i as usize], points[j as usize], points[k as usize]);
let votes = [facets[i as usize], facets[j as usize], facets[k as usize]];
let facet = votes
.iter()
.fold((votes[0], 0usize), |(best, n), &v| {
let m = votes.iter().filter(|&&w| w == v).count();
if m > n { (v, m) } else { (best, n) }
})
.0;
areas[facet] += (b - a).cross(c - a).length() * 0.5;
}
let total = areas.iter().sum::<f32>().max(1e-6);
for a in &mut areas {
*a /= total;
}
areas
}
fn facet_tones(facet_areas: &[f32], seed: u64) -> Vec<Vec3> {
let start = (unit(mix64(seed ^ 0x70)) * TONES.len() as f32) as usize % TONES.len();
let mut order: Vec<usize> = (0..facet_areas.len()).collect();
order.sort_by(|&a, &b| facet_areas[b].total_cmp(&facet_areas[a]));
let mut tones = vec![Vec3::ONE; facet_areas.len()];
for (k, &facet) in order.iter().enumerate() {
tones[facet] = Vec3::ONE.lerp(TONES[(start + k) % TONES.len()], TONE_STRENGTH);
}
let brightness = tones
.iter()
.zip(facet_areas)
.map(|(t, a)| luminance(*t) * *a)
.sum::<f32>()
.max(1e-3);
for t in &mut tones {
*t /= brightness;
}
tones
}
fn luminance(c: Vec3) -> f32 {
0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z
}
pub(crate) fn rock_geometry(shape: usize, subdivisions: u32) -> RockGeometry {
rock_geometry_with(shape, subdivisions, Some(shape_tones(shape)))
}
fn shape_tones(shape: usize) -> &'static [Vec3] {
static CACHE: [OnceLock<Vec<Vec3>>; SHAPES] = [const { OnceLock::new() }; SHAPES];
CACHE[shape]
.get_or_init(|| rock_geometry_with(shape, NEAR_SUBDIVISIONS, None).tones)
.as_slice()
}
fn rock_geometry_with(shape: usize, subdivisions: u32, tones: Option<&[Vec3]>) -> RockGeometry {
let seed = mix64(0x5EED_0000 + shape as u64);
let draw = |k: u64| unit(mix64(seed ^ k.wrapping_mul(0x2545_F491_4F6C_DD1D)));
let range = |r: [f32; 2], k: u64| r[0] + (r[1] - r[0]) * draw(k);
let angular = shape < SHAPES_PER_FAMILY;
let (joints, round, rough, c_range, sphere, chips, chip) = if angular {
(
JOINTS_ANGULAR,
ROUND_ANGULAR,
ROUGH_ANGULAR,
C_ANGULAR,
SPHERE_ANGULAR,
CHIPS_ANGULAR,
CHIP_ANGULAR,
)
} else {
(
JOINTS_ROUNDED,
ROUND_ROUNDED,
ROUGH_ROUNDED,
C_ROUNDED,
SPHERE_ROUNDED,
CHIPS_ROUNDED,
[JOINTS_ROUNDED[1], JOINTS_ROUNDED[1] + 0.1],
)
};
let turn = Quat::from_axis_angle(
Vec3::new(draw(1) - 0.5, draw(2) - 0.5, draw(3) - 0.5).normalize_or(Vec3::Y),
(draw(4) - 0.5) * 0.7,
);
let unit_vector = |k: u64| {
let z = draw(k) * 2.0 - 1.0;
let phi = draw(k + 1) * std::f32::consts::TAU;
let r = (1.0 - z * z).max(0.0).sqrt();
Vec3::new(r * phi.cos(), z, r * phi.sin())
};
let mut planes: Vec<(Vec3, f32)> = [
Vec3::X,
Vec3::NEG_X,
Vec3::Y,
Vec3::NEG_Y,
Vec3::Z,
Vec3::NEG_Z,
]
.iter()
.enumerate()
.map(|(i, axis)| {
let i = i as u64;
let n = (turn * *axis + unit_vector(60 + 2 * i) * JOINT_JITTER).normalize();
(n, range(joints, 10 + i))
})
.collect();
for i in 0..chips as u64 {
planes.push((unit_vector(20 + 2 * i), range(chip, 40 + i)));
}
let (directions, faces) = icosphere(subdivisions);
let sphere_facet = planes.len();
let mut points: Vec<Vec3> = Vec::with_capacity(directions.len());
let mut analytic: Vec<Vec3> = Vec::with_capacity(directions.len());
let mut facets: Vec<usize> = Vec::with_capacity(directions.len());
let mut facet_weights: Vec<Vec<(usize, f32)>> = Vec::with_capacity(directions.len());
for &d in &directions {
let mut terms = vec![(sphere, d, sphere_facet)];
for (i, &(n, offset)) in planes.iter().enumerate() {
let facing = d.dot(n);
if facing > 1e-3 {
terms.push((offset / facing, n, i));
}
}
let min = terms.iter().map(|t| t.0).fold(f32::MAX, f32::min);
let mut sum = 0.0;
let mut normal = Vec3::ZERO;
for &(t, n, _) in &terms {
let w = (-(t - min) / round).exp();
sum += w;
normal += n * w;
}
let mut tone_sum = 0.0;
let mut weights: Vec<(usize, f32)> = Vec::with_capacity(terms.len());
for &(t, _, facet) in &terms {
let w = (-(t - min) / TONE_JOINT).exp();
tone_sum += w;
weights.push((facet, w));
}
for (_, w) in &mut weights {
*w /= tone_sum;
}
let facet = terms
.iter()
.min_by(|a, b| a.0.total_cmp(&b.0))
.map_or(sphere_facet, |t| t.2);
let r = min - round * sum.ln();
let lumps = 1.0 + rough * (fbm3(d * 2.2, seed) - 0.5) * 2.0;
points.push(d * r * lumps);
analytic.push(normal.normalize_or(d));
facets.push(facet);
facet_weights.push(weights);
}
let (lo, hi) = points.iter().fold((Vec3::MAX, Vec3::MIN), |(lo, hi), p| {
(lo.min(*p), hi.max(*p))
});
let centre = (lo + hi) * 0.5;
let extent = (hi - lo).max(Vec3::splat(1e-3));
let b = range(B_RANGE, 50);
let c = range(c_range, 51).min(b * 0.9);
let scale = Vec3::new(1.0 / extent.x, c / extent.y, b / extent.z);
for p in &mut points {
*p = (*p - centre) * scale;
}
for n in &mut analytic {
*n = (*n / scale).normalize_or(Vec3::Y);
}
let mut normals = vec![Vec3::ZERO; points.len()];
for &[i, j, k] in &faces {
let (a, b, c) = (points[i as usize], points[j as usize], points[k as usize]);
let n = (b - a).cross(c - a);
normals[i as usize] += n;
normals[j as usize] += n;
normals[k as usize] += n;
}
for (n, a) in normals.iter_mut().zip(&analytic) {
*n = 2.0 * *a + n.normalize_or(*a);
}
let tones = match tones {
Some(tones) => tones.to_vec(),
None => facet_tones(&facet_areas(&facets, sphere_facet + 1, &points, &faces), seed),
};
let colours = points
.iter()
.zip(&facet_weights)
.map(|(p, weights)| {
let tone = weights
.iter()
.map(|(facet, w)| tones[*facet] * *w)
.sum::<Vec3>();
let mottle = 0.88 + 0.24 * fbm3(*p * 3.0 + 5.0, seed ^ 0xC0);
let height = (p.y + c * 0.5) / c;
let base =
BASE_DARK + (1.0 - BASE_DARK) * smoothstep(BASE_RISE[0], BASE_RISE[1], height);
let rgb = tone * mottle * base;
[rgb.x, rgb.y, rgb.z, 1.0]
})
.collect();
RockGeometry {
positions: points.iter().map(|p| p.to_array()).collect(),
normals: normals
.iter()
.map(|n| n.normalize_or(Vec3::Y).to_array())
.collect(),
colours,
indices: faces.iter().flatten().copied().collect(),
info: ShapeInfo { angular, b, c },
tones,
}
}
pub(crate) fn rock_mesh(geometry: RockGeometry) -> Mesh {
Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, geometry.positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, geometry.normals)
.with_inserted_attribute(Mesh::ATTRIBUTE_COLOR, geometry.colours)
.with_inserted_indices(Indices::U32(geometry.indices))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shapes_are_slabs_and_blocks_not_spheres() {
for shape in 0..SHAPES {
let g = rock_geometry(shape, NEAR_SUBDIVISIONS);
let (lo, hi) = g
.positions
.iter()
.fold((Vec3::MAX, Vec3::MIN), |(lo, hi), p| {
let p = Vec3::from_array(*p);
(lo.min(p), hi.max(p))
});
let e = hi - lo;
assert!((e.x - 1.0).abs() < 1e-3, "shape {shape}: long axis {}", e.x);
assert!(
(0.55..=0.85).contains(&(e.z / e.x)),
"shape {shape}: b:a {}",
e.z
);
assert!(
(0.30..=0.55).contains(&(e.y / e.x)),
"shape {shape}: c:a {}",
e.y
);
assert!(e.y < e.z, "shape {shape} does not lie on its largest face");
assert!((g.info.b - e.z).abs() < 1e-4 && (g.info.c - e.y).abs() < 1e-4);
}
}
#[test]
fn the_meshes_are_closed_and_face_out() {
for shape in 0..SHAPES {
for subdivisions in [FAR_SUBDIVISIONS, NEAR_SUBDIVISIONS] {
let g = rock_geometry(shape, subdivisions);
let p = |i: u32| Vec3::from_array(g.positions[i as usize]);
let volume: f32 = g
.indices
.chunks(3)
.map(|t| p(t[0]).dot(p(t[1]).cross(p(t[2]))) / 6.0)
.sum();
assert!(
volume > 0.0,
"shape {shape} at {subdivisions}: volume {volume}"
);
for (q, n) in g.positions.iter().zip(&g.normals) {
assert!(Vec3::from_array(*q).dot(Vec3::from_array(*n)) > 0.0);
}
}
}
}
#[test]
fn the_angular_family_has_flat_faces_and_the_rounded_one_does_not() {
let flatness = |shape: usize| {
let g = rock_geometry(shape, NEAR_SUBDIVISIONS);
let mut agree = 0;
let mut total = 0;
for t in g.indices.chunks(3) {
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
let na = Vec3::from_array(g.normals[a as usize]);
let nb = Vec3::from_array(g.normals[b as usize]);
total += 1;
if na.dot(nb) > 3f32.to_radians().cos() {
agree += 1;
}
}
}
agree as f32 / total as f32
};
let angular: f32 =
(0..SHAPES_PER_FAMILY).map(flatness).sum::<f32>() / SHAPES_PER_FAMILY as f32;
let rounded: f32 =
(SHAPES_PER_FAMILY..SHAPES).map(flatness).sum::<f32>() / SHAPES_PER_FAMILY as f32;
assert!(
angular > rounded + 0.1,
"angular {angular} vs rounded {rounded}"
);
}
#[test]
fn the_palette_is_the_rocks_mean() {
let mean = TONES.iter().sum::<Vec3>() / TONES.len() as f32;
assert!((mean - Vec3::ONE).abs().max_element() < 1e-3, "{mean}");
}
#[test]
fn a_stone_wears_several_rocks_and_the_change_lands_on_its_joints() {
for shape in 0..SHAPES {
let g = rock_geometry(shape, NEAR_SUBDIVISIONS);
let seed = mix64(0x5EED_0000 + shape as u64);
let worn: Vec<usize> = (0..g.positions.len() as u32)
.map(|i| tone_of_vertex(&g, i, seed))
.collect();
let distinct = {
let mut v = worn.clone();
v.sort_unstable();
v.dedup();
v.len()
};
assert!(
distinct >= 3,
"shape {shape}: {distinct} rocks over the whole body"
);
let mut across = 0;
let mut edges = 0;
for t in g.indices.chunks(3) {
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
edges += 1;
if worn[a as usize] != worn[b as usize] {
across += 1;
}
}
}
let share = across as f32 / edges as f32;
assert!(
(0.02..0.5).contains(&share),
"shape {shape}: {share} of the edges cross a rock"
);
}
}
#[test]
fn every_stone_keeps_the_palettes_brightness() {
for shape in 0..SHAPES {
let seed = mix64(0x5EED_0000 + shape as u64);
let g = rock_geometry(shape, NEAR_SUBDIVISIONS);
let p = |i: u32| Vec3::from_array(g.positions[i as usize]);
let mut total = 0.0;
let mut sum = 0.0;
for t in g.indices.chunks(3) {
let a = (p(t[1]) - p(t[0])).cross(p(t[2]) - p(t[0])).length() * 0.5;
total += a;
for v in t {
sum += a / 3.0 * luminance(tone_of_vertex_direction(&g, *v, seed));
}
}
let brightness = sum / total;
assert!(
(brightness - 1.0).abs() < 2e-2,
"shape {shape}: brightness {brightness}"
);
}
}
#[test]
fn a_rocks_two_meshes_wear_the_same_rock() {
for shape in 0..SHAPES {
let near = rock_geometry(shape, NEAR_SUBDIVISIONS);
let far = rock_geometry(shape, FAR_SUBDIVISIONS);
assert_eq!(near.tones.len(), far.tones.len(), "shape {shape}: facet count");
for (facet, (n, f)) in near.tones.iter().zip(&far.tones).enumerate() {
assert_eq!(n, f, "shape {shape}: facet {facet} wears {f} in the far mesh, {n} near");
}
assert!((near.info.b - far.info.b).abs() < 1e-6 && (near.info.c - far.info.c).abs() < 1e-6);
}
}
fn tone_of_vertex_direction(g: &RockGeometry, i: u32, seed: u64) -> Vec3 {
let p = Vec3::from_array(g.positions[i as usize]);
let mottle = 0.88 + 0.24 * fbm3(p * 3.0 + 5.0, seed ^ 0xC0);
let height = (p.y + g.info.c * 0.5) / g.info.c;
let base = BASE_DARK + (1.0 - BASE_DARK) * smoothstep(BASE_RISE[0], BASE_RISE[1], height);
let c = g.colours[i as usize];
Vec3::new(c[0], c[1], c[2]) / (mottle * base)
}
fn tone_of_vertex(g: &RockGeometry, i: u32, seed: u64) -> usize {
let tone = tone_of_vertex_direction(g, i, seed).normalize_or(Vec3::ONE);
TONES
.iter()
.enumerate()
.map(|(k, t)| (k, t.normalize_or(Vec3::ONE).dot(tone)))
.fold((0, f32::MIN), |a, b| if b.1 > a.1 { b } else { a })
.0
}
}