From 8fb6a88af0276152e43e05871081b1e64e19752a Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sat, 2 May 2026 22:26:09 +0100 Subject: [PATCH] feat: apply biome terrain modifiers --- modules/world-worldgen/src/height_sampler.zig | 100 ++++++++++++++++-- .../src/terrain_shape_generator.zig | 92 ++++++++++++---- src/worldgen_tests.zig | 4 +- 3 files changed, 165 insertions(+), 31 deletions(-) diff --git a/modules/world-worldgen/src/height_sampler.zig b/modules/world-worldgen/src/height_sampler.zig index 3290626f..7f2d5e26 100644 --- a/modules/world-worldgen/src/height_sampler.zig +++ b/modules/world-worldgen/src/height_sampler.zig @@ -16,6 +16,7 @@ const ColumnNoiseValues = noise_sampler_mod.ColumnNoiseValues; const region_pkg = @import("region.zig"); const PathInfo = region_pkg.PathInfo; const RegionControls = region_pkg.RegionControls; +const TerrainModifier = @import("biome_registry.zig").TerrainModifier; const world_class = @import("world_class.zig"); const ContinentalZone = world_class.ContinentalZone; @@ -195,6 +196,17 @@ pub const HeightSampler = struct { return std.math.lerp(base_modulated, alt_modulated, blend) * mood_mult; } + fn applyPeakCompression(self: *const HeightSampler, height: f32) f32 { + const p = self.params; + const sea: f32 = @floatFromInt(p.sea_level); + const peak_start = sea + p.peak_compression_offset; + if (height <= peak_start) return height; + + const h_above = height - peak_start; + const compressed = p.peak_compression_range * (1.0 - std.math.exp(-h_above / p.peak_compression_range)); + return peak_start + compressed; + } + /// STRUCTURE-FIRST height computation with V7-style multi-layer terrain. /// /// The KEY change: Ocean is decided by continentalness ALONE. @@ -212,6 +224,18 @@ pub const HeightSampler = struct { controls: RegionControls, path_info: PathInfo, reduction: u8, + ) f32 { + return self.computeHeightWithTerrainModifier(noise_sampler, noise, controls, path_info, reduction, null); + } + + pub fn computeHeightWithTerrainModifier( + self: *const HeightSampler, + noise_sampler: *const NoiseSampler, + noise: ColumnNoiseValues, + controls: RegionControls, + path_info: PathInfo, + reduction: u8, + terrain_modifier: ?TerrainModifier, ) f32 { // Validate reduction is in expected range (0-4 for LOD0-LOD3) std.debug.assert(reduction <= 4); @@ -290,12 +314,10 @@ pub const HeightSampler = struct { // ============================================================ // STEP 7: Post-Processing - Peak compression // ============================================================ - const peak_start = sea + p.peak_compression_offset; - if (height > peak_start) { - const h_above = height - peak_start; - const compressed = p.peak_compression_range * (1.0 - std.math.exp(-h_above / p.peak_compression_range)); - height = peak_start + compressed; + if (terrain_modifier) |modifier| { + height = modifier.applyHeight(height, sea); } + height = self.applyPeakCompression(height); // ============================================================ // STEP 8: River Carving - REGION-CONSTRAINED @@ -344,12 +366,7 @@ pub const HeightSampler = struct { } // Peak compression - const peak_start = sea + p.peak_compression_offset; - if (height > peak_start) { - const h_above = height - peak_start; - const compressed = p.peak_compression_range * (1.0 - std.math.exp(-h_above / p.peak_compression_range)); - height = peak_start + compressed; - } + height = self.applyPeakCompression(height); return height; } @@ -400,3 +417,64 @@ test "HeightSampler mountain mask range" { const m2 = sampler.getMountainMask(0.2, 0.8, 0.4); try std.testing.expect(m2 >= 0.0 and m2 <= 1.0); } + +fn testNoiseValues(continentalness: f32, terrain: f32) ColumnNoiseValues { + return .{ + .warp = .{ .x = 0.0, .z = 0.0 }, + .warped_x = 128.0, + .warped_z = 256.0, + .continentalness = continentalness, + .erosion = 0.5, + .peaks_valleys = 0.75, + .temperature = 0.5, + .humidity = 0.5, + .river_mask = 0.0, + .terrain_base = terrain, + .terrain_alt = terrain, + .height_select = 0.0, + .terrain_persist = 1.0, + .variant = 0.0, + }; +} + +const test_controls = RegionControls{ + .height_mult = 1.0, + .vegetation_mult = 1.0, + .drama_mask = 0.0, + .river_mask = 0.0, + .subbiome_mask = 0.0, +}; + +const test_path = PathInfo{ + .path_type = .none, + .influence = 0.0, + .direction = .{ 0.0, 0.0 }, +}; + +test "HeightSampler applies biome terrain modifiers to land height" { + const sampler = HeightSampler.init(); + const noise_sampler = NoiseSampler.init(1234); + const noise = testNoiseValues(0.55, 20.0); + const sea = sampler.getSeaLevelFloat(); + + const base = sampler.computeHeight(&noise_sampler, noise, test_controls, test_path, 0); + const flattened = sampler.computeHeightWithTerrainModifier(&noise_sampler, noise, test_controls, test_path, 0, .{ .smoothing = 1.0 }); + const amplified = sampler.computeHeightWithTerrainModifier(&noise_sampler, noise, test_controls, test_path, 0, .{ .height_amplitude = 1.25 }); + const clamped = sampler.computeHeightWithTerrainModifier(&noise_sampler, noise, test_controls, test_path, 0, .{ .clamp_to_sea_level = true, .height_offset = -2.0 }); + + try std.testing.expect(base > sea); + try std.testing.expectApproxEqAbs(sea, flattened, 0.0001); + try std.testing.expect(amplified > base); + try std.testing.expectApproxEqAbs(sea - 2.0, clamped, 0.0001); +} + +test "HeightSampler applies peak compression after terrain amplification" { + const sampler = HeightSampler.init(); + const noise_sampler = NoiseSampler.init(5678); + const noise = testNoiseValues(0.9, 300.0); + const peak_limit = sampler.getSeaLevelFloat() + sampler.params.peak_compression_offset + sampler.params.peak_compression_range; + + const amplified = sampler.computeHeightWithTerrainModifier(&noise_sampler, noise, test_controls, test_path, 0, .{ .height_amplitude = 2.0 }); + + try std.testing.expect(amplified < peak_limit); +} diff --git a/modules/world-worldgen/src/terrain_shape_generator.zig b/modules/world-worldgen/src/terrain_shape_generator.zig index cf155b47..b4071d82 100644 --- a/modules/world-worldgen/src/terrain_shape_generator.zig +++ b/modules/world-worldgen/src/terrain_shape_generator.zig @@ -140,6 +140,20 @@ pub const TerrainShapeGenerator = struct { } pub fn sampleColumnDataWithControls(self: *const TerrainShapeGenerator, wx: f32, wz: f32, reduction: u8, controls: region_pkg.RegionControls) ColumnData { + const base_column = self.sampleColumnDataWithControlsAndTerrainModifier(wx, wz, reduction, controls, null); + const preliminary_biome = self.selectBiomeForColumn(base_column, 1); + const biome_def = biome_mod.getBiomeDefinition(preliminary_biome); + return self.sampleColumnDataWithControlsAndTerrainModifier(wx, wz, reduction, controls, biome_def.terrain); + } + + fn sampleColumnDataWithControlsAndTerrainModifier( + self: *const TerrainShapeGenerator, + wx: f32, + wz: f32, + reduction: u8, + controls: region_pkg.RegionControls, + terrain_modifier: ?biome_mod.TerrainModifier, + ) ColumnData { const sea: f32 = @floatFromInt(self.params.sea_level); var noise = self.noise_sampler.sampleColumn(wx, wz, reduction); const cj_octaves: u16 = if (2 > reduction) 2 - @as(u16, reduction) else 1; @@ -153,7 +167,7 @@ pub const TerrainShapeGenerator = struct { const wz_i: i32 = @intFromFloat(@floor(wz)); const region = region_pkg.getRegion(region_seed, wx_i, wz_i); const path_info = region_pkg.getPathInfo(region_seed, wx_i, wz_i, region); - const terrain_height = self.height_sampler.computeHeight(&self.noise_sampler, noise, controls, path_info, reduction); + const terrain_height = self.height_sampler.computeHeightWithTerrainModifier(&self.noise_sampler, noise, controls, path_info, reduction, terrain_modifier); const terrain_height_i: i32 = @intFromFloat(terrain_height); const altitude_offset: f32 = @max(0, terrain_height - sea); @@ -182,6 +196,24 @@ pub const TerrainShapeGenerator = struct { }; } + fn selectBiomeForColumn(self: *const TerrainShapeGenerator, column: ColumnData, slope: i32) BiomeId { + const climate = self.biome_source.computeClimate( + column.temperature, + column.humidity, + column.terrain_height_i, + column.continentalness, + column.erosion, + CHUNK_SIZE_Y, + ); + const structural = biome_mod.StructuralParams{ + .height = column.terrain_height_i, + .slope = slope, + .continentalness = column.continentalness, + .ridge_mask = column.ridge_mask, + }; + return self.biome_source.selectBiome(climate, structural, column.river_mask); + } + pub fn prepareChunkPhaseData( self: *const TerrainShapeGenerator, phase_data: *ChunkPhaseData, @@ -209,7 +241,7 @@ pub const TerrainShapeGenerator = struct { const wz_i = world_z + @as(i32, @intCast(local_z)); const wx: f32 = @floatFromInt(wx_i); const wz: f32 = @floatFromInt(wz_i); - const column = self.sampleColumnDataWithControls(wx, wz, 0, controls.sample(wx_i, wz_i)); + const column = self.sampleColumnDataWithControlsAndTerrainModifier(wx, wz, 0, controls.sample(wx_i, wz_i), null); phase_data.surface_heights[idx] = column.terrain_height_i; phase_data.is_underwater_flags[idx] = column.is_underwater; @@ -266,6 +298,43 @@ pub const TerrainShapeGenerator = struct { phase_data.biome_ids[idx] = biome_id; phase_data.secondary_biome_ids[idx] = biome_id; phase_data.biome_blends[idx] = 0.0; + + const biome_def = biome_mod.getBiomeDefinition(biome_id); + const wx_i = world_x + @as(i32, @intCast(local_x)); + const wz_i = world_z + @as(i32, @intCast(local_z)); + const column = self.sampleColumnDataWithControlsAndTerrainModifier( + @floatFromInt(wx_i), + @floatFromInt(wz_i), + 0, + controls.sample(wx_i, wz_i), + biome_def.terrain, + ); + phase_data.surface_heights[idx] = column.terrain_height_i; + phase_data.is_underwater_flags[idx] = column.is_underwater; + phase_data.is_ocean_water_flags[idx] = column.is_ocean; + phase_data.cave_region_values[idx] = column.cave_region; + phase_data.temperatures[idx] = column.temperature; + phase_data.humidities[idx] = column.humidity; + phase_data.continentalness_values[idx] = column.continentalness; + phase_data.erosion_values[idx] = column.erosion; + phase_data.ridge_masks[idx] = column.ridge_mask; + phase_data.river_masks[idx] = column.river_mask; + } + } + + local_z = 0; + while (local_z < CHUNK_SIZE_Z) : (local_z += 1) { + if (stop_flag) |sf| if (sf.*) return false; + var local_x: u32 = 0; + while (local_x < CHUNK_SIZE_X) : (local_x += 1) { + const idx = local_x + local_z * CHUNK_SIZE_X; + const terrain_h = phase_data.surface_heights[idx]; + var max_slope: i32 = 0; + if (local_x > 0) max_slope = @max(max_slope, @as(i32, @intCast(@abs(terrain_h - phase_data.surface_heights[idx - 1])))); + if (local_x < CHUNK_SIZE_X - 1) max_slope = @max(max_slope, @as(i32, @intCast(@abs(terrain_h - phase_data.surface_heights[idx + 1])))); + if (local_z > 0) max_slope = @max(max_slope, @as(i32, @intCast(@abs(terrain_h - phase_data.surface_heights[idx - CHUNK_SIZE_X])))); + if (local_z < CHUNK_SIZE_Z - 1) max_slope = @max(max_slope, @as(i32, @intCast(@abs(terrain_h - phase_data.surface_heights[idx + CHUNK_SIZE_X])))); + phase_data.slopes[idx] = max_slope; } } @@ -404,22 +473,9 @@ pub const TerrainShapeGenerator = struct { pub fn sampleBiomeAtWorld(self: *const TerrainShapeGenerator, wx: i32, wz: i32) BiomeId { const wxf: f32 = @floatFromInt(wx); const wzf: f32 = @floatFromInt(wz); - const column = self.sampleColumnData(wxf, wzf, 0); - const climate = self.biome_source.computeClimate( - column.temperature, - column.humidity, - column.terrain_height_i, - column.continentalness, - column.erosion, - CHUNK_SIZE_Y, - ); - const structural = biome_mod.StructuralParams{ - .height = column.terrain_height_i, - .slope = 1, - .continentalness = column.continentalness, - .ridge_mask = column.ridge_mask, - }; - return self.biome_source.selectBiome(climate, structural, column.river_mask); + const controls = region_pkg.getBlendedControls(self.getRegionSeed(), wx, wz); + const column = self.sampleColumnDataWithControlsAndTerrainModifier(wxf, wzf, 0, controls, null); + return self.selectBiomeForColumn(column, 1); } pub fn detectBiomeEdge( diff --git a/src/worldgen_tests.zig b/src/worldgen_tests.zig index 059cd405..bbeecdfc 100644 --- a/src/worldgen_tests.zig +++ b/src/worldgen_tests.zig @@ -218,9 +218,9 @@ test "WorldGen stable chunk fingerprints for known seed" { }; const expected = [_]u64{ - 16067496184749289293, + 1589085686761579315, 10106071382427144696, - 12094872152942993063, + 12283863255206387139, }; for (positions, 0..) |pos, i| {