Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 89 additions & 11 deletions modules/world-worldgen/src/height_sampler.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
92 changes: 74 additions & 18 deletions modules/world-worldgen/src/terrain_shape_generator.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions src/worldgen_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
Loading