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
6 changes: 6 additions & 0 deletions modules/world-worldgen/src/biome_registry.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ pub const TreeType = tree_registry.TreeType;
/// Minimum sum threshold for biome blend calculation to avoid division by near-zero values
pub const BLEND_EPSILON: f32 = 0.0001;

/// Normalized elevation produced by computeClimateParams at sea level.
pub const NORMALIZED_SEA_LEVEL: f32 = 0.30;

/// Represents a range of values for biome parameter matching
pub const Range = struct {
min: f32,
Expand Down Expand Up @@ -387,6 +390,7 @@ pub const BIOME_REGISTRY: []const BiomeDefinition = &.{
.humidity = Range.any(),
.elevation = .{ .min = 0.28, .max = 0.38 },
.continentalness = .{ .min = 0.35, .max = 0.42 }, // NARROW beach band
.max_height = 70,
.max_slope = 2,
.priority = 10,
.surface = .{ .top = .sand, .filler = .sand, .depth_range = 2 },
Expand All @@ -399,6 +403,7 @@ pub const BIOME_REGISTRY: []const BiomeDefinition = &.{
.humidity = Range.any(),
.elevation = .{ .min = 0.28, .max = 0.45 },
.continentalness = .{ .min = 0.35, .max = 0.45 },
.max_height = 82,
.max_slope = 8,
.priority = 11,
.surface = .{ .top = .stone, .filler = .gravel, .depth_range = 2 },
Expand All @@ -413,6 +418,7 @@ pub const BIOME_REGISTRY: []const BiomeDefinition = &.{
.humidity = Range.any(),
.elevation = .{ .min = 0.28, .max = 0.38 },
.continentalness = .{ .min = 0.35, .max = 0.42 },
.max_height = 70,
.max_slope = 2,
.priority = 12,
.surface = .{ .top = .snow_block, .filler = .sand, .depth_range = 2 },
Expand Down
31 changes: 26 additions & 5 deletions modules/world-worldgen/src/biome_selector.zig
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const StructuralParams = registry.StructuralParams;
const BIOME_REGISTRY = registry.BIOME_REGISTRY;
const BIOME_POINTS = registry.BIOME_POINTS;
const BLEND_EPSILON = registry.BLEND_EPSILON;
const NORMALIZED_SEA_LEVEL = registry.NORMALIZED_SEA_LEVEL;

const OCEAN_CONTINENTALNESS_MAX: f32 = 0.35;

// ============================================================================
// Voronoi Biome Selection (Issue #106)
Expand Down Expand Up @@ -107,6 +110,7 @@ pub fn selectBiome(params: ClimateParams) BiomeId {
var best_biome: BiomeId = .plains; // Default fallback

for (BIOME_REGISTRY) |biome| {
if (isOceanBiome(biome.id) and !isOceanClimate(params)) continue;
const s = biome.scoreClimate(params);
if (s > best_score) {
best_score = s;
Expand All @@ -129,6 +133,7 @@ pub fn selectBiomeMultiParam(climate: ClimateParams, structural: StructuralParam
var best_biome: BiomeId = .plains;

for (BIOME_REGISTRY) |biome| {
if (isOceanBiome(biome.id) and !isOceanStructure(params, structural)) continue;
if (!biome.meetsStructuralConstraints(structural.height, structural.slope, structural.continentalness, structural.ridge_mask)) continue;

const s = biome.scoreClimate(params);
Expand All @@ -141,6 +146,21 @@ pub fn selectBiomeMultiParam(climate: ClimateParams, structural: StructuralParam
return best_biome;
}

fn isOceanStructure(climate: ClimateParams, structural: StructuralParams) bool {
return structural.continentalness < OCEAN_CONTINENTALNESS_MAX and climate.elevation <= NORMALIZED_SEA_LEVEL;
}

fn isOceanClimate(climate: ClimateParams) bool {
return climate.continentalness < OCEAN_CONTINENTALNESS_MAX and climate.elevation <= NORMALIZED_SEA_LEVEL;
}

fn isOceanBiome(biome: BiomeId) bool {
return switch (biome) {
.deep_ocean, .ocean, .frozen_ocean, .cold_ocean, .warm_ocean => true,
else => false,
};
}

/// Select biome with river override
pub fn selectBiomeWithRiver(params: ClimateParams, river_mask: f32) BiomeId {
// River biome takes priority when river mask is active
Expand All @@ -161,18 +181,18 @@ pub fn computeClimateParams(
sea_level: i32,
max_height: i32,
) ClimateParams {
// Normalize elevation: 0 = below sea, 0.3 = sea level, 1.0 = max height
// Normalize elevation: 0 = below sea, NORMALIZED_SEA_LEVEL = sea level, 1.0 = max height
// Use conditional to avoid integer overflow when height < sea_level
const height_above_sea: i32 = if (height > sea_level) height - sea_level else 0;
const elevation_range = max_height - sea_level;
const elevation = if (elevation_range > 0)
0.3 + 0.7 * @as(f32, @floatFromInt(height_above_sea)) / @as(f32, @floatFromInt(elevation_range))
NORMALIZED_SEA_LEVEL + 0.7 * @as(f32, @floatFromInt(height_above_sea)) / @as(f32, @floatFromInt(elevation_range))
else
0.3;
NORMALIZED_SEA_LEVEL;

// For underwater: scale 0-0.3
// For underwater: scale 0-NORMALIZED_SEA_LEVEL
const final_elevation = if (height < sea_level)
0.3 * @as(f32, @floatFromInt(@max(0, height))) / @as(f32, @floatFromInt(sea_level))
NORMALIZED_SEA_LEVEL * @as(f32, @floatFromInt(@max(0, height))) / @as(f32, @floatFromInt(sea_level))
else
elevation;

Expand Down Expand Up @@ -206,6 +226,7 @@ pub fn selectBiomeBlended(params: ClimateParams) BiomeSelection {
var second_biome: ?BiomeId = null;

for (BIOME_REGISTRY) |biome| {
if (isOceanBiome(biome.id) and !isOceanClimate(params)) continue;
const s = biome.scoreClimate(params);
if (s > best_score) {
second_score = best_score;
Expand Down
124 changes: 124 additions & 0 deletions modules/world-worldgen/src/biome_selector_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,114 @@ test "selectBiomeWithConstraints locks structural edge cases" {
}));
}

test "selectBiomeWithConstraints preserves ocean sea-level boundary" {
const underwater_ocean = ClimateParams{
.temperature = 0.5,
.humidity = 0.5,
.elevation = 0.30,
.continentalness = 0.25,
.ruggedness = 0.1,
};
const inland_ocean_candidate = ClimateParams{
.temperature = 0.5,
.humidity = 0.5,
.elevation = 0.31,
.continentalness = 0.25,
.ruggedness = 0.1,
};
const structural = StructuralParams{
.height = 64,
.slope = 1,
.continentalness = 0.25,
.ridge_mask = 0.1,
};

try testing.expectEqual(BiomeId.ocean, selectBiomeWithConstraints(underwater_ocean, structural));
try testing.expect(selectBiomeWithConstraints(inland_ocean_candidate, structural) != .ocean);
try testing.expect(selectBiomeWithConstraints(inland_ocean_candidate, structural) != .deep_ocean);
try testing.expect(selectBiomeWithConstraints(inland_ocean_candidate, structural) != .frozen_ocean);
try testing.expect(selectBiomeWithConstraints(inland_ocean_candidate, structural) != .cold_ocean);
try testing.expect(selectBiomeWithConstraints(inland_ocean_candidate, structural) != .warm_ocean);
}

test "selectBiomeWithConstraints preserves coast and inland water boundaries" {
const coast_climate = ClimateParams{
.temperature = 0.6,
.humidity = 0.5,
.elevation = 0.3,
.continentalness = 0.35,
.ruggedness = 0.1,
};
const coast_structural = StructuralParams{
.height = 64,
.slope = 1,
.continentalness = 0.35,
.ridge_mask = 0.1,
};
const steep_coast = StructuralParams{
.height = 64,
.slope = 3,
.continentalness = 0.35,
.ridge_mask = 0.1,
};
const high_coast = StructuralParams{
.height = 71,
.slope = 1,
.continentalness = 0.35,
.ridge_mask = 0.1,
};
const inland_water = ClimateParams{
.temperature = 0.6,
.humidity = 0.5,
.elevation = 0.2,
.continentalness = 0.6,
.ruggedness = 0.1,
};

try testing.expectEqual(BiomeId.beach, selectBiomeWithConstraints(coast_climate, coast_structural));
try testing.expect(selectBiomeWithConstraints(coast_climate, steep_coast) != .beach);
try testing.expect(selectBiomeWithConstraints(coast_climate, high_coast) != .beach);
try testing.expect(selectBiomeWithConstraints(inland_water, .{
.height = 50,
.slope = 1,
.continentalness = 0.6,
.ridge_mask = 0.1,
}) != .beach);
}

test "selectBiomeWithConstraintsAndRiver preserves river thresholds" {
const temperate = ClimateParams{
.temperature = 0.21,
.humidity = 0.7,
.elevation = 0.3,
.continentalness = 0.6,
.ruggedness = 0.2,
};
const frozen = ClimateParams{
.temperature = 0.20,
.humidity = 0.7,
.elevation = 0.3,
.continentalness = 0.6,
.ruggedness = 0.2,
};
const structural = StructuralParams{
.height = 119,
.slope = 2,
.continentalness = 0.6,
.ridge_mask = 0.1,
};

try testing.expect(selectBiomeWithConstraintsAndRiver(temperate, structural, 0.5) != .river);
try testing.expectEqual(BiomeId.river, selectBiomeWithConstraintsAndRiver(temperate, structural, 0.5001));
try testing.expectEqual(BiomeId.frozen_river, selectBiomeWithConstraintsAndRiver(frozen, structural, 0.5001));
try testing.expect(selectBiomeWithConstraintsAndRiver(temperate, .{
.height = 120,
.slope = 2,
.continentalness = 0.6,
.ridge_mask = 0.1,
}, 0.5001) != .river);
}

test "selectBiomeWithConstraints documents ridge mask baseline behavior" {
const climate = ClimateParams{
.temperature = 0.4,
Expand Down Expand Up @@ -547,6 +655,22 @@ test "selectBiomeSimple returns ocean for low continentalness" {
try testing.expect(biome == .deep_ocean or biome == .ocean);
}

test "selectBiomeSimple does not return ocean above sea level" {
const params = ClimateParams{
.temperature = 0.5,
.humidity = 0.5,
.elevation = 0.31,
.continentalness = 0.15,
.ruggedness = 0.3,
};
const biome = selectBiomeSimple(params);
try testing.expect(biome != .deep_ocean);
try testing.expect(biome != .ocean);
try testing.expect(biome != .frozen_ocean);
try testing.expect(biome != .cold_ocean);
try testing.expect(biome != .warm_ocean);
}

test "selectBiomeSimple returns deep_ocean for very low continentalness" {
const params = ClimateParams{
.temperature = 0.5,
Expand Down
Loading