feat: wire shell placeholders to backend actions #12

Merged
moldybits merged 11 commits from feat/terrain-gen-abstraction into main 2026-05-17 19:30:57 -04:00
2 changed files with 105 additions and 12 deletions
Showing only changes of commit b207cb75d4 - Show all commits
+76 -12
View File
@@ -1,5 +1,15 @@
use crate::terrain::{HeightGrid, TerrainError}; use crate::terrain::{HeightGrid, TerrainError};
const OCTAVE_COUNT: usize = 4;
const BASE_FREQUENCY: f32 = 2.0;
const LACUNARITY: f32 = 2.0;
const GAIN: f32 = 0.5;
const LATTICE_SEED_STEP: u64 = 0x9E37_79B9_7F4A_7C15;
const LATTICE_X_MUL: u64 = 0x9E37_79B9_7F4A_7C15;
const LATTICE_Y_MUL: u64 = 0xC2B2_AE3D_27D4_EB4F;
const FINALIZER_MUL1: u64 = 0xBF58_476D_1CE4_E5B9;
const FINALIZER_MUL2: u64 = 0x94D0_49BB_1331_11EB;
/// Describes a terrain generation request. /// Describes a terrain generation request.
/// ///
/// Specs are immutable once constructed; identical specs always yield /// Specs are immutable once constructed; identical specs always yield
@@ -44,9 +54,9 @@ pub trait TerrainGenerator {
/// A deterministic, in-memory reference generator. /// A deterministic, in-memory reference generator.
/// ///
/// Heights are derived purely from the spec's seed and each sample's /// Heights are derived from a small clean-room seeded value-noise fBm stack so
/// coordinates, so identical specs always produce identical grids. No noise /// identical specs always produce identical grids. The output stays in
/// algorithms are used yet; this is a stable baseline for CLI and rendering. /// `[0.0, 1.0]`, which keeps later renderer and palette integration simple.
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Clone, Copy, Default)]
pub struct DeterministicTerrainGenerator; pub struct DeterministicTerrainGenerator;
@@ -56,29 +66,83 @@ impl DeterministicTerrainGenerator {
} }
} }
/// Hashes seed and coordinates into a height in `[0.0, 1.0)`. #[inline]
fn sample_height(seed: u64, x: u32, y: u32) -> f32 { fn fade(t: f32) -> f32 {
// SplitMix64-style finalizer over a coordinate-mixed seed. t * t * (3.0 - 2.0 * t)
let mut h = seed }
^ (u64::from(x).wrapping_mul(0x9E37_79B9_7F4A_7C15))
^ (u64::from(y).wrapping_mul(0xC2B2_AE3D_27D4_EB4F)); #[inline]
fn normalized_coord(index: u32, size: u32) -> f32 {
if size <= 1 {
0.0
} else {
index as f32 / (size - 1) as f32
}
}
/// Hashes seed and lattice coordinates into a height in `[0.0, 1.0)`.
#[inline]
fn lattice_value(seed: u64, x: i64, y: i64) -> f32 {
let mut h =
seed ^ (x as u64).wrapping_mul(LATTICE_X_MUL) ^ (y as u64).wrapping_mul(LATTICE_Y_MUL);
h ^= h >> 30; h ^= h >> 30;
h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9); h = h.wrapping_mul(FINALIZER_MUL1);
h ^= h >> 27; h ^= h >> 27;
h = h.wrapping_mul(0x94D0_49BB_1331_11EB); h = h.wrapping_mul(FINALIZER_MUL2);
h ^= h >> 31; h ^= h >> 31;
// Take the top 24 bits for an exact f32 fraction in [0, 1). // Take the top 24 bits for an exact f32 fraction in [0, 1).
(h >> 40) as f32 / (1u64 << 24) as f32 (h >> 40) as f32 / (1u64 << 24) as f32
} }
#[inline]
fn value_noise(seed: u64, x: f32, y: f32) -> f32 {
let x0 = x.floor() as i64;
let y0 = y.floor() as i64;
let x1 = x0 + 1;
let y1 = y0 + 1;
let tx = x - x0 as f32;
let ty = y - y0 as f32;
let sx = fade(tx);
let sy = fade(ty);
let n00 = lattice_value(seed, x0, y0);
let n10 = lattice_value(seed, x1, y0);
let n01 = lattice_value(seed, x0, y1);
let n11 = lattice_value(seed, x1, y1);
let ix0 = n00 * (1.0 - sx) + n10 * sx;
let ix1 = n01 * (1.0 - sx) + n11 * sx;
ix0 * (1.0 - sy) + ix1 * sy
}
#[inline]
fn fbm_height(seed: u64, x: f32, y: f32) -> f32 {
let mut total = 0.0;
let mut amplitude = 1.0;
let mut frequency = BASE_FREQUENCY;
let mut amplitude_sum = 0.0;
for octave in 0..OCTAVE_COUNT {
let octave_seed = seed.wrapping_add((octave as u64).wrapping_mul(LATTICE_SEED_STEP));
total += amplitude * value_noise(octave_seed, x * frequency, y * frequency);
amplitude_sum += amplitude;
amplitude *= GAIN;
frequency *= LACUNARITY;
}
total / amplitude_sum
}
impl TerrainGenerator for DeterministicTerrainGenerator { impl TerrainGenerator for DeterministicTerrainGenerator {
fn generate(&self, spec: &TerrainGenerationSpec) -> Result<HeightGrid, TerrainError> { fn generate(&self, spec: &TerrainGenerationSpec) -> Result<HeightGrid, TerrainError> {
let width = spec.width(); let width = spec.width();
let height = spec.height(); let height = spec.height();
let mut samples = Vec::with_capacity((width as usize) * (height as usize)); let mut samples = Vec::with_capacity((width as usize) * (height as usize));
for y in 0..height { for y in 0..height {
let ny = normalized_coord(y, height);
for x in 0..width { for x in 0..width {
samples.push(sample_height(spec.seed(), x, y)); let nx = normalized_coord(x, width);
samples.push(fbm_height(spec.seed(), nx, ny));
} }
} }
HeightGrid::new(width, height, samples) HeightGrid::new(width, height, samples)
+29
View File
@@ -47,3 +47,32 @@ fn terrain_gen_deterministic_generator_rejects_zero_dimensions() {
let err = TerrainGenerationSpec::new(7, 4, 0).unwrap_err(); let err = TerrainGenerationSpec::new(7, 4, 0).unwrap_err();
assert_eq!(err, TerrainError::ZeroDimension); assert_eq!(err, TerrainError::ZeroDimension);
} }
#[test]
fn terrain_gen_deterministic_generator_produces_value_noise_fbm_sample() {
let spec = TerrainGenerationSpec::new(1337, 4, 4).expect("valid spec");
let generator = DeterministicTerrainGenerator::new();
let grid = generator.generate(&spec).expect("generation succeeds");
let expected = [
[0.706_783_f32, 0.390_681, 0.571_295, 0.454_375],
[0.667_478, 0.549_327, 0.476_506, 0.335_594],
[0.603_516, 0.451_503, 0.516_397, 0.357_081],
[0.308_838, 0.295_558, 0.570_510, 0.666_683],
];
for (y, row) in expected.iter().enumerate() {
for (x, expected_sample) in row.iter().enumerate() {
let actual = grid.sample(x as u32, y as u32).expect("sample in bounds");
assert!(
(actual - expected_sample).abs() < 1e-6,
"mismatch at ({x}, {y}): expected {expected_sample}, got {actual}"
);
}
}
let (min, max) = grid.min_max().expect("non-empty grid has min/max");
assert!(min >= 0.0);
assert!(max <= 1.0);
}