diff --git a/assets/world/tree/mangroves/4.vox b/assets/world/tree/mangroves/4.vox new file mode 100644 index 0000000000..ab916453c5 --- /dev/null +++ b/assets/world/tree/mangroves/4.vox @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e444818089ac3a9ad2f9deb374e563fb048fe29a7be671a21e5547497808d1a6 +size 45336 diff --git a/assets/world/tree/mangroves/5.vox b/assets/world/tree/mangroves/5.vox new file mode 100644 index 0000000000..ded6222b31 --- /dev/null +++ b/assets/world/tree/mangroves/5.vox @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4188d62ee30b4c73f285455ec7bd4ed8935cd142b687cb4dff42ad336666faa +size 34116 diff --git a/assets/world/tree/mangroves/6.vox b/assets/world/tree/mangroves/6.vox new file mode 100644 index 0000000000..ef6da43e32 --- /dev/null +++ b/assets/world/tree/mangroves/6.vox @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51ef11e2eb547f64920f5362450604bc5f1271fd56dc2dd41f2b9d96a635868b +size 61512 diff --git a/assets/world/tree/mangroves/7.vox b/assets/world/tree/mangroves/7.vox new file mode 100644 index 0000000000..b68cceb099 --- /dev/null +++ b/assets/world/tree/mangroves/7.vox @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a364b03a01c540f0794fd9eb70c3bd7f2d73c66c1e9f034660ba1dda2ac853e6 +size 51656 diff --git a/assets/world/tree/mangroves/8.vox b/assets/world/tree/mangroves/8.vox new file mode 100644 index 0000000000..71b324a493 --- /dev/null +++ b/assets/world/tree/mangroves/8.vox @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:408e61be6fbaf6afb89c046c4d043683d1f70b3308abc268b8cfe0d1460c3275 +size 71816 diff --git a/world/src/all.rs b/world/src/all.rs index f687672ccb..5c9e4fbe4c 100644 --- a/world/src/all.rs +++ b/world/src/all.rs @@ -1,8 +1,9 @@ -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] pub enum ForestKind { Palm, Savannah, Oak, Pine, SnowPine, + Mangrove, } diff --git a/world/src/block/natural.rs b/world/src/block/natural.rs index 102a8c6c42..86ee632ea5 100644 --- a/world/src/block/natural.rs +++ b/world/src/block/natural.rs @@ -58,6 +58,7 @@ pub fn structure_gen<'a>( ForestKind::Oak => &OAKS, ForestKind::Pine => &PINES, ForestKind::SnowPine => &SNOW_PINES, + ForestKind::Mangrove => &MANGROVE_TREES, } }; @@ -421,6 +422,34 @@ lazy_static! { ]; */ + pub static ref MANGROVE_TREES: Vec> = vec![ + // oak stumps + assets::load_map("world.tree.mangroves.1", |s: Structure| s + .with_center(Vec3::new(18, 18, 8))) + .unwrap(), + assets::load_map("world.tree.mangroves.2", |s: Structure| s + .with_center(Vec3::new(16, 17, 7))) + .unwrap(), + assets::load_map("world.tree.mangroves.3", |s: Structure| s + .with_center(Vec3::new(18, 18, 8))) + .unwrap(), + assets::load_map("world.tree.mangroves.4", |s: Structure| s + .with_center(Vec3::new(18, 16, 8))) + .unwrap(), + assets::load_map("world.tree.mangroves.5", |s: Structure| s + .with_center(Vec3::new(16, 17, 8))) + .unwrap(), + assets::load_map("world.tree.mangroves.6", |s: Structure| s + .with_center(Vec3::new(18, 18, 8))) + .unwrap(), + assets::load_map("world.tree.mangroves.7", |s: Structure| s + .with_center(Vec3::new(18, 17, 8))) + .unwrap(), + assets::load_map("world.tree.mangroves.8", |s: Structure| s + .with_center(Vec3::new(18, 18, 8))) + .unwrap(), + ]; + pub static ref QUIRKY: Vec> = vec![ st_asset("world.structure.natural.tower-ruin", (11, 14, 5)), st_asset("world.structure.natural.witch-hut", (10, 13, 9)), @@ -431,4 +460,6 @@ lazy_static! { st_asset("world.structure.natural.ribcage-large", (13, 19, 8)), st_asset("world.structure.natural.skull-large", (15, 20, 4)), ]; + + } diff --git a/world/src/column/mod.rs b/world/src/column/mod.rs index 9319fdd2f9..e8d58b7c47 100644 --- a/world/src/column/mod.rs +++ b/world/src/column/mod.rs @@ -76,7 +76,11 @@ impl<'a> ColumnGen<'a> { }); let chunk = self.world.sim().get(chunk_pos)?; - if seed % 5 == 2 && chunk.temp > CONFIG.desert_temp && chunk.alt > CONFIG.sea_level + 5.0 { + if seed % 5 == 2 + && chunk.temp > CONFIG.desert_temp + && chunk.humidity < CONFIG.desert_hum + && chunk.alt > CONFIG.sea_level + 5.0 + { Some(StructureData { pos, seed, @@ -139,6 +143,7 @@ impl<'a> Sampler for ColumnGen<'a> { let chaos = sim.get_interpolated(wpos, |chunk| chunk.chaos)?; let temp = sim.get_interpolated(wpos, |chunk| chunk.temp)?; let dryness = sim.get_interpolated(wpos, |chunk| chunk.dryness)?; + let humidity = sim.get_interpolated(wpos, |chunk| chunk.humidity)?; let rockiness = sim.get_interpolated(wpos, |chunk| chunk.rockiness)?; let tree_density = sim.get_interpolated(wpos, |chunk| chunk.tree_density)?; let spawn_rate = sim.get_interpolated(wpos, |chunk| chunk.spawn_rate)?; @@ -158,11 +163,17 @@ impl<'a> Sampler for ColumnGen<'a> { */ let river = 0.0; - let cliff_hill = - (sim.gen_ctx.small_nz.get((wposf.div(128.0)).into_array()) as f32).mul(16.0); + let cliff_hill = (sim + .gen_ctx + .small_nz + .get((wposf_turb.div(128.0)).into_array()) as f32) + .mul(24.0); let riverless_alt = sim.get_interpolated(wpos, |chunk| chunk.alt)? - + (sim.gen_ctx.small_nz.get((wposf.div(256.0)).into_array()) as f32) + + (sim + .gen_ctx + .small_nz + .get((wposf_turb.div(150.0)).into_array()) as f32) .abs() .mul(chaos.max(0.15)) .mul(64.0); @@ -201,41 +212,168 @@ impl<'a> Sampler for ColumnGen<'a> { .mul(0.5) .add(marble_small.sub(0.5).mul(0.25)); + let temp = temp.add((marble - 0.5) * 0.25); + let humidity = humidity.add((marble - 0.5) * 0.25); + // Colours - let cold_grass = Rgb::new(0.0, 0.49, 0.42); + let cold_grass = Rgb::new(0.0, 0.5, 0.25); let warm_grass = Rgb::new(0.03, 0.8, 0.0); + let dark_grass = Rgb::new(0.01, 0.3, 0.0); + let wet_grass = Rgb::new(0.1, 0.8, 0.2); let cold_stone = Rgb::new(0.57, 0.67, 0.8); let warm_stone = Rgb::new(0.77, 0.77, 0.64); let beach_sand = Rgb::new(0.89, 0.87, 0.64); let desert_sand = Rgb::new(0.93, 0.80, 0.54); - let snow = Rgb::broadcast(0.77); + let snow = Rgb::new(0.8, 0.85, 1.0); let dirt = Lerp::lerp( Rgb::new(0.078, 0.078, 0.20), Rgb::new(0.61, 0.49, 0.0), marble, ); + let tundra = Lerp::lerp(snow, Rgb::new(0.01, 0.3, 0.0), 0.4 + marble * 0.6); + let dead_tundra = Lerp::lerp(warm_stone, Rgb::new(0.3, 0.12, 0.2), marble); let cliff = Rgb::lerp(cold_stone, warm_stone, marble); - let grass = Rgb::lerp(cold_grass, warm_grass, marble.powf(1.5)); + let grass = Rgb::lerp( + cold_grass, + warm_grass, + marble.sub(0.5).add(1.0.sub(humidity).mul(0.5)).powf(1.5), + ); + let snow_moss = Rgb::lerp(snow, cold_grass, 0.4 + marble.powf(1.5) * 0.6); + let moss = Rgb::lerp(dark_grass, cold_grass, marble.powf(1.5)); + let rainforest = Rgb::lerp(wet_grass, warm_grass, marble.powf(1.5)); let sand = Rgb::lerp(beach_sand, desert_sand, marble); let tropical = Rgb::lerp( - grass, + Rgb::lerp( + grass, + Rgb::new(0.15, 0.2, 0.15), + marble_small + .sub(0.5) + .mul(0.2) + .add(0.75.mul(1.0.sub(humidity))) + .powf(0.667), + ), Rgb::new(0.87, 0.62, 0.56), - marble_small.sub(0.5).mul(0.2).add(0.75).powf(0.667), + marble.powf(1.5).sub(0.5).mul(4.0), ); + // For below desert humidity, we are always sand or rock, depending on altitude and + // temperature. let ground = Rgb::lerp( Rgb::lerp( - snow, - grass, + dead_tundra, + sand, temp.sub(CONFIG.snow_temp) - .sub((marble - 0.5) * 0.05) - .mul(256.0), + .div(CONFIG.desert_temp.sub(CONFIG.snow_temp)) + .mul(0.5), ), - Rgb::lerp(tropical, sand, temp.sub(CONFIG.desert_temp).mul(32.0)), - temp.sub(CONFIG.tropical_temp).mul(16.0), + cliff, + alt.sub(CONFIG.mountain_scale * 0.25) + .div(CONFIG.mountain_scale * 0.125), + ); + // From desert to forest humidity, we go from tundra to dirt to grass to moss to sand, + // depending on temperature. + let ground = Rgb::lerp( + ground, + Rgb::lerp( + Rgb::lerp( + Rgb::lerp( + Rgb::lerp( + tundra, + // snow_temp to 0 + dirt, + temp.sub(CONFIG.snow_temp) + .div(CONFIG.snow_temp.neg()) + /*.sub((marble - 0.5) * 0.05) + .mul(256.0)*/ + .mul(1.0), + ), + // 0 to tropical_temp + grass, + temp.div(CONFIG.tropical_temp).mul(4.0), + ), + // tropical_temp to desert_temp + moss, + temp.sub(CONFIG.tropical_temp) + .div(CONFIG.desert_temp.sub(CONFIG.tropical_temp)) + .mul(1.0), + ), + // above desert_temp + sand, + temp.sub(CONFIG.desert_temp) + .div(1.0 - CONFIG.desert_temp) + .mul(4.0), + ), + humidity + .sub(CONFIG.desert_hum) + .div(CONFIG.forest_hum.sub(CONFIG.desert_hum)) + .mul(1.0), + ); + // From forest to jungle humidity, we go from snow to dark grass to grass to tropics to sand + // depending on temperature. + let ground = Rgb::lerp( + ground, + Rgb::lerp( + Rgb::lerp( + Rgb::lerp( + snow_moss, + // 0 to tropical_temp + grass, + temp.div(CONFIG.tropical_temp).mul(4.0), + ), + // tropical_temp to desert_temp + tropical, + temp.sub(CONFIG.tropical_temp) + .div(CONFIG.desert_temp.sub(CONFIG.tropical_temp)) + .mul(1.0), + ), + // above desert_temp + sand, + temp.sub(CONFIG.desert_temp) + .div(1.0 - CONFIG.desert_temp) + .mul(4.0), + ), + humidity + .sub(CONFIG.forest_hum) + .div(CONFIG.jungle_hum.sub(CONFIG.forest_hum)) + .mul(1.0), + ); + // From jungle humidity upwards, we go from snow to grass to rainforest to tropics to sand. + let ground = Rgb::lerp( + ground, + Rgb::lerp( + Rgb::lerp( + Rgb::lerp( + snow_moss, + // 0 to tropical_temp + rainforest, + temp.div(CONFIG.tropical_temp).mul(4.0), + ), + // tropical_temp to desert_temp + tropical, + temp.sub(CONFIG.tropical_temp) + .div(CONFIG.desert_temp.sub(CONFIG.tropical_temp)) + .mul(4.0), + ), + // above desert_temp + sand, + temp.sub(CONFIG.desert_temp) + .div(1.0 - CONFIG.desert_temp) + .mul(4.0), + ), + humidity.sub(CONFIG.jungle_hum).mul(1.0), + ); + + // Snow covering + let ground = Rgb::lerp( + snow, + ground, + temp.sub(CONFIG.snow_temp) + .max(-humidity.sub(CONFIG.desert_hum)) + .mul(16.0) + .add((marble_small - 0.5) * 0.5), ); // Work out if we're on a path or near a town @@ -346,7 +484,7 @@ impl<'a> Sampler for ColumnGen<'a> { / 12.0, ), (alt - CONFIG.sea_level - 0.25 * CONFIG.mountain_scale + marble * 128.0) - / 100.0, + / (0.25 * CONFIG.mountain_scale), ), // Beach ((alt - CONFIG.sea_level - 1.0) / 2.0) diff --git a/world/src/config.rs b/world/src/config.rs index fc3199d0de..22a488a7c3 100644 --- a/world/src/config.rs +++ b/world/src/config.rs @@ -4,12 +4,18 @@ pub struct Config { pub snow_temp: f32, pub tropical_temp: f32, pub desert_temp: f32, + pub desert_hum: f32, + pub forest_hum: f32, + pub jungle_hum: f32, } pub const CONFIG: Config = Config { sea_level: 140.0, mountain_scale: 1000.0, - snow_temp: -0.4, - tropical_temp: 0.25, - desert_temp: 0.45, + snow_temp: -0.6, + tropical_temp: 0.2, + desert_temp: 0.6, + desert_hum: 0.15, + forest_hum: 0.5, + jungle_hum: 0.85, }; diff --git a/world/src/lib.rs b/world/src/lib.rs index d3de575a89..5a58627c9c 100644 --- a/world/src/lib.rs +++ b/world/src/lib.rs @@ -1,5 +1,10 @@ #![deny(unsafe_code)] -#![feature(euclidean_division, bind_by_move_pattern_guards, option_flattening)] +#![feature( + const_generics, + euclidean_division, + bind_by_move_pattern_guards, + option_flattening +)] mod all; mod block; diff --git a/world/src/sim/mod.rs b/world/src/sim/mod.rs index 5c22d573c4..50fa320036 100644 --- a/world/src/sim/mod.rs +++ b/world/src/sim/mod.rs @@ -1,9 +1,11 @@ mod location; mod settlement; +mod util; // Reexports pub use self::location::Location; pub use self::settlement::Settlement; +use self::util::{cdf_irwin_hall, uniform_idx_as_vec2, uniform_noise, InverseCdf}; use crate::{ all::ForestKind, @@ -14,14 +16,45 @@ use common::{ terrain::{BiomeKind, TerrainChunkSize}, vol::VolSize, }; -use noise::{BasicMulti, HybridMulti, MultiFractal, NoiseFn, RidgedMulti, Seedable, SuperSimplex}; +use noise::{ + BasicMulti, Billow, HybridMulti, MultiFractal, NoiseFn, RidgedMulti, Seedable, SuperSimplex, +}; use rand::{Rng, SeedableRng}; use rand_chacha::ChaChaRng; -use std::ops::{Add, Div, Mul, Neg, Sub}; +use std::{ + f32, + ops::{Add, Div, Mul, Neg, Sub}, +}; use vek::*; pub const WORLD_SIZE: Vec2 = Vec2 { x: 1024, y: 1024 }; +/// Calculates the smallest distance along an axis (x, y) from an edge of +/// the world. This value is maximal at WORLD_SIZE / 2 and minimized at the extremes +/// (0 or WORLD_SIZE on one or more axes). It then divides the quantity by cell_size, +/// so the final result is 1 when we are not in a cell along the edge of the world, and +/// ranges between 0 and 1 otherwise (lower when the chunk is closer to the edge). +fn map_edge_factor(posi: usize) -> f32 { + uniform_idx_as_vec2(posi) + .map2(WORLD_SIZE.map(|e| e as i32), |e, sz| { + (sz / 2 - (e - sz / 2).abs()) as f32 / 16.0 + }) + .reduce_partial_min() + .max(0.0) + .min(1.0) +} + +/// A structure that holds cached noise values and cumulative distribution functions for the input +/// that led to those values. See the definition of InverseCdf for a description of how to +/// interpret the types of its fields. +struct GenCdf { + humid_base: InverseCdf, + temp_base: InverseCdf, + alt_base: InverseCdf, + chaos: InverseCdf, + alt: InverseCdf, +} + pub(crate) struct GenCtx { pub turb_x_nz: SuperSimplex, pub turb_y_nz: SuperSimplex, @@ -29,7 +62,11 @@ pub(crate) struct GenCtx { pub alt_nz: HybridMulti, pub hill_nz: SuperSimplex, pub temp_nz: SuperSimplex, + // Fresh groundwater (currently has no effect, but should influence humidity) pub dry_nz: BasicMulti, + // Humidity noise + pub humid_nz: Billow, + // Small amounts of noise for simulating rough terrain. pub small_nz: BasicMulti, pub rock_nz: HybridMulti, pub cliff_nz: HybridMulti, @@ -86,13 +123,134 @@ impl WorldSim { structure_gen: StructureGen2d::new(gen_seed(), 32, 24), region_gen: StructureGen2d::new(gen_seed(), 400, 96), cliff_gen: StructureGen2d::new(gen_seed(), 80, 56), + humid_nz: Billow::new() + .set_octaves(12) + .set_persistence(0.125) + .set_frequency(1.0) + // .set_octaves(6) + // .set_persistence(0.5) + .set_seed(gen_seed()), + }; + + // From 0 to 1.6, but the distribution before the max is from -1 and 1, so there is a 50% + // chance that hill will end up at 0. + let hill = uniform_noise(|_, wposf| { + (0.0 + gen_ctx + .hill_nz + .get((wposf.div(1_500.0)).into_array()) + .mul(1.0) as f32 + + gen_ctx + .hill_nz + .get((wposf.div(400.0)).into_array()) + .mul(0.3) as f32) + .add(0.3) + .max(0.0) + }); + + // 0 to 1, hopefully. + let humid_base = uniform_noise(|_, wposf| { + (gen_ctx.humid_nz.get(wposf.div(1024.0).into_array()) as f32) + .add(1.0) + .mul(0.5) + }); + + // -1 to 1. + let temp_base = uniform_noise(|_, wposf| { + (gen_ctx.temp_nz.get((wposf.div(12000.0)).into_array()) as f32) + }); + + // "Base" of the chunk, to be multiplied by CONFIG.mountain_scale (multiplied value is + // from -0.25 * (CONFIG.mountain_scale * 1.1) to 0.25 * (CONFIG.mountain_scale * 0.9), + // but value here is from -0.275 to 0.225). + let alt_base = uniform_noise(|_, wposf| { + (gen_ctx.alt_nz.get((wposf.div(12_000.0)).into_array()) as f32) + .sub(0.1) + .mul(0.25) + }); + + // chaos produces a value in [0.1, 1.24]. It is a meta-level factor intended to reflect how + // "chaotic" the region is--how much weird stuff is going on on this terrain. + let chaos = uniform_noise(|posi, wposf| { + (gen_ctx.chaos_nz.get((wposf.div(3_000.0)).into_array()) as f32) + .add(1.0) + .mul(0.5) + // [0, 1] * [0.25, 1] = [0, 1] (but probably towards the lower end) + .mul( + (gen_ctx.chaos_nz.get((wposf.div(6_000.0)).into_array()) as f32) + .abs() + .max(0.25) + .min(1.0), + ) + // Chaos is always increased by a little when we're on a hill (but remember that + // hill is 0 about 50% of the time). + // [0, 1] + 0.15 * [0, 1.6] = [0, 1.24] + .add(0.2 * hill[posi].1) + // [0, 1.24] * [0.35, 1.0] = [0, 1.24]. + // Sharply decreases (towards 0.35) when temperature is near desert_temp (from below), + // then saturates just before it actually becomes desert. Otherwise stays at 1. + // Note that this is not the *final* temperature, only the initial noise value for + // temperature. + .mul( + temp_base[posi] + .1 + .sub(0.45) + .neg() + .mul(12.0) + .max(0.35) + .min(1.0), + ) + // We can't have *no* chaos! + .max(0.1) + }); + + // We ignore sea level because we actually want to be relative to sea level here and want + // things in CONFIG.mountain_scale units, but otherwise this is a correct altitude + // calculation. Note that this is using the "unadjusted" temperature. + let alt = uniform_noise(|posi, wposf| { + // This is the extension upwards from the base added to some extra noise from -1 to 1. + // The extra noise is multiplied by alt_main (the mountain part of the extension) + // clamped to [0.25, 1], and made 60% larger (so the extra noise is between [-1.6, 1.6], + // and the final noise is never more than 160% or less than 40% of the original noise, + // depending on altitude). + // Adding this to alt_main thus yields a value between -0.4 (if alt_main = 0 and + // gen_ctx = -1) and 2.6 (if alt_main = 1 and gen_ctx = 1). When the generated small_nz + // value hits -0.625 the value crosses 0, so most of the points are above 0. + // + // Then, we add 1 and divide by 2 to get a value between 0.3 and 1.8. + let alt_main = { + // Extension upwards from the base. A positive number from 0 to 1 curved to be + // maximal at 0. Also to be multiplied by CONFIG.mountain_scale. + let alt_main = (gen_ctx.alt_nz.get((wposf.div(2_000.0)).into_array()) as f32) + .abs() + .powf(1.45); + + (0.0 + alt_main + + (gen_ctx.small_nz.get((wposf.div(300.0)).into_array()) as f32) + .mul(alt_main.max(0.25)) + .mul(0.3)) + .add(1.0) + .mul(0.5) + }; + + // Now we can compute the final altitude using chaos. + // We multiply by chaos clamped to [0.1, 1.24] to get a value between 0.03 and 2.232 for + // alt_pre, then multiply by CONFIG.mountain_scale and add to the base and sea level to + // get an adjusted value, then multiply the whole thing by map_edge_factor + // (TODO: compute final bounds). + (alt_base[posi].1 + alt_main.mul(chaos[posi].1)).mul(map_edge_factor(posi)) + }); + + let gen_cdf = GenCdf { + humid_base, + temp_base, + alt_base, + chaos, + alt, }; let mut chunks = Vec::new(); - for x in 0..WORLD_SIZE.x as i32 { - for y in 0..WORLD_SIZE.y as i32 { - chunks.push(SimChunk::generate(Vec2::new(x, y), &mut gen_ctx)); - } + for i in 0..WORLD_SIZE.x * WORLD_SIZE.y { + chunks.push(SimChunk::generate(i, &mut gen_ctx, &gen_cdf)); } let mut this = Self { @@ -304,6 +462,7 @@ pub struct SimChunk { pub alt: f32, pub temp: f32, pub dryness: f32, + pub humidity: f32, pub rockiness: f32, pub is_cliffs: bool, pub near_cliffs: bool, @@ -328,23 +487,13 @@ pub struct LocationInfo { } impl SimChunk { - fn generate(pos: Vec2, gen_ctx: &mut GenCtx) -> Self { + fn generate(posi: usize, gen_ctx: &mut GenCtx, gen_cdf: &GenCdf) -> Self { + let pos = uniform_idx_as_vec2(posi); let wposf = (pos * TerrainChunkSize::SIZE.map(|e| e as i32)).map(|e| e as f64); - let hill = (0.0 - + gen_ctx - .hill_nz - .get((wposf.div(1_500.0)).into_array()) - .mul(1.0) as f32 - + gen_ctx - .hill_nz - .get((wposf.div(500.0)).into_array()) - .mul(0.3) as f32) - .add(0.3) - .max(0.0); - - let temp = gen_ctx.temp_nz.get((wposf.div(12000.0)).into_array()) as f32; - + // FIXME: Currently unused, but should represent fresh groundwater level. + // Should be correlated a little with humidity, somewhat negatively with altitude, + // and very negatively with difference in temperature from zero. let dryness = gen_ctx.dry_nz.get( (wposf .add(Vec2::new( @@ -358,62 +507,76 @@ impl SimChunk { .into_array(), ) as f32; - let chaos = (gen_ctx.chaos_nz.get((wposf.div(3_000.0)).into_array()) as f32) - .add(1.0) - .mul(0.5) - .mul( - (gen_ctx.chaos_nz.get((wposf.div(6_000.0)).into_array()) as f32) - .abs() - .max(0.25) - .min(1.0), - ) - .add(0.15 * hill) - .mul( - temp.sub(CONFIG.desert_temp) - .neg() - .mul(12.0) - .max(0.35) - .min(1.0), - ) - .max(0.1); + let (_, alt_base) = gen_cdf.alt_base[posi]; + let map_edge_factor = map_edge_factor(posi); + let (_, chaos) = gen_cdf.chaos[posi]; + let (humid_uniform, _) = gen_cdf.humid_base[posi]; + let (alt_uniform, alt_pre) = gen_cdf.alt[posi]; + let (temp_uniform, _) = gen_cdf.temp_base[posi]; - let alt_base = (gen_ctx.alt_nz.get((wposf.div(12_000.0)).into_array()) as f32) - .mul(250.0) - .sub(25.0); + // Take the weighted average of our randomly generated base humidity, the scaled + // negative altitude, and other random variable (to add some noise) to yield the + // final humidity. Note that we are using the "old" version of chaos here. + const HUMID_WEIGHTS: [f32; 2] = [1.0, 1.0]; + let humidity = cdf_irwin_hall(&HUMID_WEIGHTS, [humid_uniform, 1.0 - alt_uniform]); - let alt_main = (gen_ctx.alt_nz.get((wposf.div(2_000.0)).into_array()) as f32) - .abs() - .powf(1.35); + // We also correlate temperature negatively with altitude using different weighting than we + // use for humidity. + const TEMP_WEIGHTS: [f32; 2] = [2.0, 1.0]; + let temp = cdf_irwin_hall(&TEMP_WEIGHTS, [temp_uniform, 1.0 - alt_uniform]) + // Convert to [-1, 1] + .sub(0.5) + .mul(2.0); - let map_edge_factor = pos - .map2(WORLD_SIZE.map(|e| e as i32), |e, sz| { - (sz / 2 - (e - sz / 2).abs()) as f32 / 16.0 - }) - .reduce_partial_min() - .max(0.0) - .min(1.0); - - let alt = (CONFIG.sea_level - + alt_base - + (0.0 - + alt_main - + (gen_ctx.small_nz.get((wposf.div(300.0)).into_array()) as f32) - .mul(alt_main.max(0.25)) - .mul(1.6)) - .add(1.0) - .mul(0.5) - .mul(chaos) - .mul(CONFIG.mountain_scale)) - * map_edge_factor; + let alt_base = alt_base.mul(CONFIG.mountain_scale); + let alt = CONFIG + .sea_level + .mul(map_edge_factor) + .add(alt_pre.mul(CONFIG.mountain_scale)); let cliff = gen_ctx.cliff_nz.get((wposf.div(2048.0)).into_array()) as f32 + chaos * 0.2; + // Logistic regression. Make sure x ∈ (0, 1). + let logit = |x: f32| x.ln() - x.neg().ln_1p(); + // 0.5 + 0.5 * tanh(ln(1 / (1 - 0.1) - 1) / (2 * (sqrt(3)/pi))) + let logistic_2_base = 3.0f32.sqrt().mul(f32::consts::FRAC_2_PI); + // Assumes μ = 0, σ = 1 + let logistic_cdf = |x: f32| x.div(logistic_2_base).tanh().mul(0.5).add(0.5); + + // No trees in the ocean or with zero humidity (currently) + let tree_density = if alt <= CONFIG.sea_level + 5.0 { + 0.0 + } else { + let tree_density = (gen_ctx.tree_nz.get((wposf.div(1024.0)).into_array()) as f32) + .mul(1.5) + .add(1.0) + .mul(0.5) + .mul(1.2 - chaos * 0.95) + .add(0.05) + .max(0.0) + .min(1.0); + // Tree density should go (by a lot) with humidity. + if humidity <= 0.0 || tree_density <= 0.0 { + 0.0 + } else if humidity >= 1.0 || tree_density >= 1.0 { + 1.0 + } else { + // Weighted logit sum. + logistic_cdf(logit(humidity) + 0.5 * logit(tree_density)) + } + // rescale to (-0.9, 0.9) + .sub(0.5) + .mul(0.9) + .add(0.5) + }; + Self { chaos, alt_base, alt, temp, dryness, + humidity, rockiness: (gen_ctx.rock_nz.get((wposf.div(1024.0)).into_array()) as f32) .sub(0.1) .mul(1.3) @@ -423,31 +586,63 @@ impl SimChunk { && alt > CONFIG.sea_level + 5.0 && dryness.abs() > 0.075, near_cliffs: cliff > 0.25, - tree_density: (gen_ctx.tree_nz.get((wposf.div(1024.0)).into_array()) as f32) - .mul(1.5) - .add(1.0) - .mul(0.5) - .mul(1.2 - chaos * 0.95) - .add(0.05) - .mul(if alt > CONFIG.sea_level + 5.0 { - 1.0 - } else { - 0.0 - }) - .max(0.0), + tree_density, forest_kind: if temp > 0.0 { if temp > CONFIG.desert_temp { - ForestKind::Palm + if humidity > CONFIG.jungle_hum { + // Forests in desert temperatures with extremely high humidity + // should probably be different from palm trees, but we use them + // for now. + ForestKind::Palm + } else if humidity > CONFIG.forest_hum { + ForestKind::Palm + } else if humidity > CONFIG.desert_hum { + // Low but not desert humidity, so we should really have some other + // terrain... + ForestKind::Savannah + } else { + ForestKind::Savannah + } } else if temp > CONFIG.tropical_temp { - ForestKind::Savannah + if humidity > CONFIG.jungle_hum { + ForestKind::Mangrove + } else if humidity > CONFIG.forest_hum { + // NOTE: Probably the wrong kind of tree for this climate. + ForestKind::Oak + } else if humidity > CONFIG.desert_hum { + // Low but not desert... need something besides savannah. + ForestKind::Savannah + } else { + ForestKind::Savannah + } } else { - ForestKind::Oak + if humidity > CONFIG.jungle_hum { + // Temperate climate with jungle humidity... + // https://en.wikipedia.org/wiki/Humid_subtropical_climates are often + // densely wooded and full of water. Semitropical rainforests, basically. + // For now we just treet them like other rainforests. + ForestKind::Oak + } else if humidity > CONFIG.forest_hum { + // Moderate climate, moderate humidity. + ForestKind::Oak + } else if humidity > CONFIG.desert_hum { + // With moderate temperature and low humidity, we should probably see + // something different from savannah, but oh well... + ForestKind::Savannah + } else { + ForestKind::Savannah + } } } else { - if temp > CONFIG.snow_temp { + // For now we don't take humidity into account for cold climates (but we really + // should!) except that we make sure we only have snow pines when there is snow. + if temp <= CONFIG.snow_temp && humidity > CONFIG.forest_hum { + ForestKind::SnowPine + } else if humidity > CONFIG.desert_hum { ForestKind::Pine } else { - ForestKind::SnowPine + // Should really have something like tundra. + ForestKind::Pine } }, spawn_rate: 1.0, diff --git a/world/src/sim/util.rs b/world/src/sim/util.rs new file mode 100644 index 0000000000..66a553b8db --- /dev/null +++ b/world/src/sim/util.rs @@ -0,0 +1,160 @@ +use super::WORLD_SIZE; +use common::{terrain::TerrainChunkSize, vol::VolSize}; +use vek::*; + +/// Computes the cumulative distribution function of the weighted sum of k independent, +/// uniformly distributed random variables between 0 and 1. For each variable i, we use weights[i] +/// as the weight to give samples[i] (the weights should all be positive). +/// +/// If the precondition is met, the distribution of the result of calling this function will be +/// uniformly distributed while preserving the same information that was in the original average. +/// +/// For N > 33 the function will no longer return correct results since we will overflow u32. +/// +/// NOTE: +/// +/// Per [1], the problem of determing the CDF of +/// the sum of uniformly distributed random variables over *different* ranges is considerably more +/// complicated than it is for the same-range case. Fortunately, it also provides a reference to +/// [2], which contains a complete derivation of an exact rule for the density function for +/// this case. The CDF is just the integral of the cumulative distribution function [3], +/// which we use to convert this into a CDF formula. +/// +/// This allows us to sum weighted, uniform, independent random variables. +/// +/// At some point, we should probably contribute this back to stats-rs. +/// +/// 1. https://www.r-bloggers.com/sums-of-random-variables/, +/// 2. Sadooghi-Alvandi, S., A. Nematollahi, & R. Habibi, 2009. +/// On the Distribution of the Sum of Independent Uniform Random Variables. +/// Statistical Papers, 50, 171-175. +/// 3. hhttps://en.wikipedia.org/wiki/Cumulative_distribution_function +pub fn cdf_irwin_hall(weights: &[f32; N], samples: [f32; N]) -> f32 { + // Let J_k = {(j_1, ... , j_k) : 1 ≤ j_1 < j_2 < ··· < j_k ≤ N }. + // + // Let A_N = Π{k = 1 to n}a_k. + // + // The density function for N ≥ 2 is: + // + // 1/(A_N * (N - 1)!) * (x^(N-1) + Σ{k = 1 to N}((-1)^k * + // Σ{(j_1, ..., j_k) ∈ J_k}(max(0, x - Σ{l = 1 to k}(a_(j_l)))^(N - 1)))) + // + // So the cumulative distribution function is its integral, i.e. (I think) + // + // 1/(product{k in A}(k) * N!) * (x^N + sum(k in 1 to N)((-1)^k * + // sum{j in Subsets[A, {k}]}(max(0, x - sum{l in j}(l))^N))) + // + // which is also equivalent to + // + // (letting B_k = { a in Subsets[A, {k}] : sum {l in a} l }, B_(0,1) = 0 and + // H_k = { i : 1 ≤ 1 ≤ N! / (k! * (N - k)!) }) + // + // 1/(product{k in A}(k) * N!) * sum(k in 0 to N)((-1)^k * + // sum{l in H_k}(max(0, x - B_(k,l))^N)) + // + // We should be able to iterate through the whole power set + // instead, and figure out K by calling count_ones(), so we can compute the result in O(2^N) + // iterations. + let x: f32 = weights + .iter() + .zip(samples.iter()) + .map(|(weight, sample)| weight * sample) + .sum(); + + let mut y = 0.0f32; + for subset in 0u32..(1 << N) { + // Number of set elements + let k = subset.count_ones(); + // Add together exactly the set elements to get B_subset + let z = weights + .iter() + .enumerate() + .filter(|(i, _)| subset & (1 << i) as u32 != 0) + .map(|(_, k)| k) + .sum::(); + // Compute max(0, x - B_subset)^N + let z = (x - z).max(0.0).powi(N as i32); + // The parity of k determines whether the sum is negated. + y += if k & 1 == 0 { z } else { -z }; + } + + // Divide by the product of the weights. + y /= weights.iter().product::(); + + // Remember to multiply by 1 / N! at the end. + y / (1..=N as i32).product::() as f32 +} + +/// First component of each element of the vector is the computed CDF of the noise function at this +/// index (i.e. its position in a sorted list of value returned by the noise function applied to +/// every chunk in the game). Second component is the cached value of the noise function that +/// generated the index. +/// +/// NOTE: Length should always be WORLD_SIZE.x * WORLD_SIZE.y. +pub type InverseCdf = Box<[(f32, f32)]>; + +/// Computes the position Vec2 of a SimChunk from an index, where the index was generated by +/// uniform_noise. +pub fn uniform_idx_as_vec2(idx: usize) -> Vec2 { + Vec2::new((idx / WORLD_SIZE.x) as i32, (idx % WORLD_SIZE.x) as i32) +} + +/// Compute inverse cumulative distribution function for arbitrary function f, the hard way. We +/// pre-generate noise values prior to worldgen, then sort them in order to determine the correct +/// position in the sorted order. That lets us use `(index + 1) / (WORLDSIZE.y * WORLDSIZE.x)` as +/// a uniformly distributed (from almost-0 to 1) regularization of the chunks. That is, if we +/// apply the computed "function" F⁻¹(x, y) to (x, y) and get out p, it means that approximately +/// (100 * p)% of chunks have a lower value for F⁻¹ than p. The main purpose of doing this is to +/// make sure we are using the entire range we want, and to allow us to apply the numerous results +/// about distributions on uniform functions to the procedural noise we generate, which lets us +/// much more reliably control the *number* of features in the world while still letting us play +/// with the *shape* of those features, without having arbitrary cutoff points / discontinuities +/// (which tend to produce ugly-looking / unnatural terrain). +/// +/// As a concrete example, before doing this it was very hard to tweak humidity so that either most +/// of the world wasn't dry, or most of it wasn't wet, by combining the billow noise function and +/// the computed altitude. This is because the billow noise function has a very unusual +/// distribution that is heavily skewed towards 0. By correcting for this tendency, we can start +/// with uniformly distributed billow noise and altitudes and combine them to get uniformly +/// distributed humidity, while still preserving the existing shapes that the billow noise and +/// altitude functions produce. +/// +/// f takes an index, which represents the index corresponding to this chunk in any any SimChunk +/// vector returned by uniform_noise, and (for convenience) the float-translated version of those +/// coordinates. +/// f should return a value with no NaNs. If there is a NaN, it will panic. There are no other +/// conditions on f. +/// +/// Returns a vec of (f32, f32) pairs consisting of the percentage of chunks with a value lower than +/// this one, and the actual noise value (we don't need to cache it, but it makes ensuring that +/// subsequent code that needs the noise value actually uses the same one we were using here +/// easier). +pub fn uniform_noise(f: impl Fn(usize, Vec2) -> f32) -> InverseCdf { + let mut noise = (0..WORLD_SIZE.x * WORLD_SIZE.y) + .map(|i| { + ( + i, + f( + i, + (uniform_idx_as_vec2(i) * TerrainChunkSize::SIZE.map(|e| e as i32)) + .map(|e| e as f64), + ), + ) + }) + .collect::>(); + + // sort_unstable_by is equivalent to sort_by here since we include the index in the + // comparison. We could leave out the index, but this might make the order not + // reproduce the same way between different versions of Rust (for example). + noise.sort_unstable_by(|f, g| (f.1, f.0).partial_cmp(&(g.1, g.0)).unwrap()); + + // Construct a vector that associates each chunk position with the 1-indexed + // position of the noise in the sorted vector (divided by the vector length). + // This guarantees a uniform distribution among the samples. + let mut uniform_noise = vec![(0.0, 0.0); WORLD_SIZE.x * WORLD_SIZE.y].into_boxed_slice(); + let total = (WORLD_SIZE.x * WORLD_SIZE.y) as f32; + for (noise_idx, (chunk_idx, noise_val)) in noise.into_iter().enumerate() { + uniform_noise[chunk_idx] = ((1 + noise_idx) as f32 / total, noise_val); + } + uniform_noise +}