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
16 changes: 1 addition & 15 deletions assets/shaders/vulkan/g_pass.frag
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,10 @@ layout(set = 0, binding = 0) uniform GlobalUniforms {
vec4 lpv_origin;
} global;

// World-space hash for LOD transition masking.
// Using world-space noise avoids a fixed screen-space dot pattern.
float lodTransitionNoise(vec2 worldXZ) {
vec2 p = floor(worldXZ * 0.25);
p = fract(p * vec2(0.1031, 0.1030));
p += dot(p, p.yx + 33.33);
return fract((p.x + p.y) * p.x);
}

void main() {
const float LOD_TRANSITION_WIDTH = 32.0;
bool isLOD = vTileID < 0 || vMaskRadius > 0.0;
if (vMaskRadius >= 1.0) {
float distFromMask = length(vFragPosWorld.xz) - vMaskRadius;
float fade = clamp(distFromMask / LOD_TRANSITION_WIDTH, 0.0, 1.0);
vec2 worldXZ = vFragPosWorld.xz + global.cam_pos.xz;
float ditherThreshold = lodTransitionNoise(worldXZ);
if (fade < ditherThreshold) discard;
if (length(vFragPosWorld.xz) < vMaskRadius) discard;
}

vec3 N = normalize(vNormal);
Expand Down
15 changes: 3 additions & 12 deletions assets/shaders/vulkan/terrain.frag
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,6 @@ void main() {
float debugSkyFill = 0.0;
float debugBlockLight = clamp(max(vBlockLight.r, max(vBlockLight.g, vBlockLight.b)), 0.0, 1.0);
float debugOutdoor = baselineOutdoorFactor(vSkyLight);
const float LOD_TRANSITION_WIDTH = 32.0;
const float AO_FADE_DISTANCE = 128.0;
const float TEXTURE_FADE_START = 32.0;
const float TEXTURE_FADE_END = 128.0;
Expand All @@ -517,19 +516,11 @@ void main() {
}

if (vMaskRadius >= 1.0) {
vec2 worldXZ = vFragPosWorld.xz + global.cam_pos.xz;
float distFromMask = length(vFragPosWorld.xz) - vMaskRadius;
float fade = clamp(distFromMask / LOD_TRANSITION_WIDTH, 0.0, 1.0);
float ditherThreshold = lodTransitionNoise(worldXZ);
if (fade < ditherThreshold) discard;
// Full-detail chunks own this area. Dithering the handoff creates a
// camera-following grid of holes at the chunk/LOD boundary.
if (length(vFragPosWorld.xz) < vMaskRadius) discard;
}

if (isLOD && vLODFade < 0.999) {
vec2 worldXZ = vFragPosWorld.xz + global.cam_pos.xz;
float ditherThreshold = lodTransitionNoise(worldXZ + vec2(19.37, 41.91));
if (vLODFade < ditherThreshold) discard;
}

vec2 tileBase = vec2(mod(float(vTileID), 16.0), floor(float(vTileID) / 16.0)) * (1.0 / 16.0);
vec2 tiledUV = fract(vTexCoord);
tiledUV = clamp(tiledUV, 0.001, 0.999);
Expand Down
Binary file modified assets/shaders/vulkan/terrain.frag.spv
Binary file not shown.
11 changes: 1 addition & 10 deletions assets/shaders/vulkan/water.frag
Original file line number Diff line number Diff line change
Expand Up @@ -129,19 +129,10 @@ vec2 atlasUV(int tileID, vec2 texCoord) {
}

void main() {
const float LOD_TRANSITION_WIDTH = 32.0;
bool isLOD = vTileID < 0 || vMaskRadius > 0.0;
if (vMaskRadius >= 1.0) {
vec2 worldXZ = vFragPosWorld.xz + global.cam_pos.xz;
float distFromMask = length(vFragPosWorld.xz) - vMaskRadius;
float fade = clamp(distFromMask / LOD_TRANSITION_WIDTH, 0.0, 1.0);
if (fade < lodTransitionNoise(worldXZ)) discard;
if (length(vFragPosWorld.xz) < vMaskRadius) discard;
}
if (vMaskRadius > 0.0 && vLODFade < 0.999) {
vec2 worldXZ = vFragPosWorld.xz + global.cam_pos.xz;
if (vLODFade < lodTransitionNoise(worldXZ + vec2(19.37, 41.91))) discard;
}

float time = global.params.x;

vec3 base_normal = normalize(vNormal);
Expand Down
7 changes: 2 additions & 5 deletions modules/game-core/src/session.zig
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,9 @@ pub const GameSession = struct {
else if (effective_lod_enabled and manual_distance_expanded)
LODConfig.radiiForDistances(chunk_render_radius, effective_horizon_distance)
else
LODConfig.radiiForDistances(effective_render_distance, effective_horizon_distance);
preset_cfg.lod_radii;

const active_count = if (!strict_safe_mode and manual_distance_expanded)
LODConfig.activeCountForRadii(preset_radii)
else
preset_cfg.active_lod_count;
const active_count = preset_cfg.active_lod_count;
if (active_count < LODLevel.count) {
var i: usize = active_count;
while (i < LODLevel.count) : (i += 1) {
Expand Down
7 changes: 3 additions & 4 deletions modules/world-core/src/lod_data.zig
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,13 @@ pub const LODSimplifiedData = struct {

pub fn getGridSize(lod_level: LODLevel) u32 {
return switch (lod_level) {
// Use region_size / (width - 1). The far bands need a denser
// source grid so visible terrain doesn't collapse into 8/16-block
// slabs before fog hides it.
// LOD4 is the fast horizon fallback; LOD3 retains the dense grid
// and replaces it as refinement arrives.
.lod0 => 33,
.lod1 => 65,
.lod2 => 65,
.lod3 => 129,
.lod4 => 129,
.lod4 => 65,
};
}

Expand Down
14 changes: 13 additions & 1 deletion modules/world-lod/src/lod_chunk.zig
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,8 @@ pub const LODConfig = struct {

skip_lighting_lod3: bool = false,

// The coarsest level remains active even when it shares the horizon radius
// with its child: it is the fast, large-area fallback while that child loads.
active_lod_count: u32 = LODLevel.count,

/// Maximum fraction of direct finer child regions that may be missing before
Expand Down Expand Up @@ -639,7 +641,6 @@ pub const LODConfig = struct {
}

/// Returns how many LOD levels should be active for a render-distance setting.
/// The current policy keeps all levels active regardless of near render distance.
pub fn activeCountForRenderDistance(distance: i32) u32 {
_ = distance;
return LODLevel.count;
Expand Down Expand Up @@ -903,6 +904,17 @@ test "LODConfig expands render distance into distant LOD horizon" {
try std.testing.expectEqual(@as(i32, 1024), custom_horizon[4]);
}

test "LODConfig keeps the coarse fallback when tail radii match" {
var config = LODConfig{ .active_lod_count = LODLevel.count };
const interface = config.interface();

interface.setRadii(.{ 30, 96, 256, 512, 512 });
try std.testing.expectEqual(@as(u32, LODLevel.count), interface.getActiveLODCount());

interface.setRadii(.{ 36, 96, 256, 512, 1024 });
try std.testing.expectEqual(@as(u32, LODLevel.count), interface.getActiveLODCount());
}

test "ChunkBounds intersects radius radially" {
const axis_region = ChunkBounds{ .min_x = 16, .min_z = 0, .max_x = 31, .max_z = 15 };
try std.testing.expect(axis_region.intersectsRadius(0, 0, 16));
Expand Down
48 changes: 8 additions & 40 deletions modules/world-lod/src/lod_geometry.zig
Original file line number Diff line number Diff line change
Expand Up @@ -278,12 +278,13 @@ pub fn terrainHeightForPoint(data: *const LODSimplifiedData, gx: u32, gz: u32) f
return @min(height, floor_height);
}

/// Returns the stitched terrain surface height at a sample-grid point.
/// Coordinates are clamped and then passed through seam stitching so region boundaries agree.
/// Returns the authoritative terrain surface height at a sample-grid point.
/// Cross-region stitching requires real neighbor samples; blending against
/// unrelated samples inside this region creates visible edge distortion.
pub fn terrainSurfaceHeightForPoint(data: *const LODSimplifiedData, gx: u32, gz: u32) f32 {
const clamped_x = @min(gx, data.width - 1);
const clamped_z = @min(gz, data.width - 1);
return stitchedHeight(data, clamped_x, clamped_z);
return data.getHeight(clamped_x, clamped_z);
}

/// Returns the quantized solid terrain height for a sample-grid point.
Expand All @@ -298,7 +299,7 @@ pub fn quantizedCellTerrainHeight(data: *const LODSimplifiedData, gx: u32, gz: u
return quantizedHeight(terrainHeightForPoint(data, gx, gz));
}

/// Returns a quantized stitched surface height for an LOD cell.
/// Returns a quantized surface height for an LOD cell.
/// Unlike terrain-floor height, this keeps dry columns at the visible terrain surface.
pub fn quantizedCellSurfaceHeight(data: *const LODSimplifiedData, gx: u32, gz: u32) f32 {
return quantizedHeight(terrainSurfaceHeightForPoint(data, gx, gz));
Expand Down Expand Up @@ -564,6 +565,7 @@ pub fn collectColumnSpans(data: *const LODSimplifiedData, gx: u32, gz: u32, lod_
var has_water_span = false;
var has_solid_span = false;
var has_canopy_span = false;
const is_water_cell = isLODWaterCellForLOD(data, gx, gz, lod_level);
var i: u8 = 0;
while (i < data.verticalSpanCount(gx, gz)) : (i += 1) {
const raw = data.getVerticalSpan(gx, gz, i) orelse continue;
Expand All @@ -576,6 +578,7 @@ pub fn collectColumnSpans(data: *const LODSimplifiedData, gx: u32, gz: u32, lod_
if (!shouldEmitWaterSpanForLOD(data, gx, gz, lod_level, raw.water)) continue;
has_water_span = true;
} else {
if (is_water_cell and isLeafBlock(block)) continue;
has_solid_span = true;
const terrain_height = data.getHeight(gx, gz);
if (raw.vegetation.tree_coverage > 0.0 and max_height > terrain_height + 1.0) {
Expand All @@ -594,7 +597,7 @@ pub fn collectColumnSpans(data: *const LODSimplifiedData, gx: u32, gz: u32, lod_
if (gx < data.width and gz < data.width) {
const idx = gx + gz * data.width;
const vegetation = data.vegetation[idx];
if (!has_canopy_span and vegetation.tree_coverage >= LOD_TREE_COVERAGE_THRESHOLD and vegetation.avg_tree_height >= 2.0 and count < out.len) {
if (!is_water_cell and !has_canopy_span and vegetation.tree_coverage >= LOD_TREE_COVERAGE_THRESHOLD and vegetation.avg_tree_height >= 2.0 and count < out.len) {
const leaves = if (vegetation.leaves == .air) BlockType.leaves else vegetation.leaves;
const canopy = treeCanopyInterval(quantizedTerrainHeightForPoint(data, gx, gz), vegetation);
insertColumnSpan(out, &count, .{
Expand Down Expand Up @@ -1129,41 +1132,6 @@ pub fn shouldEmitWaterSpanForLOD(data: *const LODSimplifiedData, gx: u32, gz: u3
return stats.wet_samples >= 2 and stats.average_coverage >= 0.25 and stats.representative_depth >= 1.5;
}

/// Returns a heightmap sample adjusted to match neighboring LOD region boundaries.
/// Boundary samples may be clamped toward adjacent values to reduce visible cracks.
pub fn stitchedHeight(data: *const LODSimplifiedData, gx: u32, gz: u32) f32 {
const height = data.getHeight(gx, gz);
if (data.width < 5) return height;

const blend_cells: u32 = 2;
const max_idx = data.width - 1;
const edge_dist = @min(@min(gx, gz), @min(max_idx - gx, max_idx - gz));
if (edge_dist >= blend_cells) return height;

const coarse_x = @min(((gx + 1) / 2) * 2, max_idx);
const coarse_z = @min(((gz + 1) / 2) * 2, max_idx);
const coarse_height = data.getHeight(coarse_x, coarse_z);
const edge_weight = 1.0 - (@as(f32, @floatFromInt(edge_dist)) / @as(f32, @floatFromInt(blend_cells)));
const blend = edge_weight * 0.35;
return height * (1.0 - blend) + coarse_height * blend;
}

/// Returns the maximum seam-height adjustment allowed for a point in the sample grid.
/// Interior points allow no adjustment; boundary points allow bounded correction.
pub fn maxStitchedHeightAdjustment(data: *const LODSimplifiedData) f32 {
if (data.width < 5) return 0.0;

var max_adjust: f32 = 0.0;
var i: u32 = 0;
while (i < data.width) : (i += 1) {
max_adjust = @max(max_adjust, @abs(data.getHeight(i, 0) - stitchedHeight(data, i, 0)));
max_adjust = @max(max_adjust, @abs(data.getHeight(i, data.width - 1) - stitchedHeight(data, i, data.width - 1)));
max_adjust = @max(max_adjust, @abs(data.getHeight(0, i) - stitchedHeight(data, 0, i)));
max_adjust = @max(max_adjust, @abs(data.getHeight(data.width - 1, i) - stitchedHeight(data, data.width - 1, i)));
}
return max_adjust;
}

// Helper functions for unpacking colors
/// Extracts the red channel from a packed 0xRRGGBB color as a normalized float.
/// The result is in `[0, 1]` for direct use in vertex color data.
Expand Down
22 changes: 21 additions & 1 deletion modules/world-lod/src/lod_ingest.zig
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ pub fn writeIngestedColumn(
.ambient_occlusion = 1.0,
};

const vegetation: LODVegetationHint = if (sample.has_tree)
const vegetation: LODVegetationHint = if (sample.has_tree and !water_state.is_surface)
.{
.tree_coverage = 1.0,
.avg_tree_height = sample.tree_height,
Expand Down Expand Up @@ -616,6 +616,26 @@ test "downsampleChunkIntoRegion emits water state for a flooded column" {
try testing.expectEqual(@as(f32, 5.0), data.water[0].depth);
}

test "writeIngestedColumn clears vegetation from flooded samples" {
var data = try LODSimplifiedData.initWithVerticalSpans(testing.allocator, .lod1);
defer data.deinit();

try testing.expect(writeIngestedColumn(&data, 0, 0, .{
.terrain_height = 58.0,
.biome = .forest,
.has_water = true,
.water_surface_height = 63.0,
.water_depth = 5.0,
.water_coverage = 1.0,
.has_tree = true,
.tree_height = 8.0,
}, .chunk_derived));

try testing.expect(data.water[0].is_surface);
try testing.expectEqual(@as(f32, 0.0), data.vegetation[0].tree_coverage);
try testing.expectEqual(@as(u8, 2), data.verticalSpanCount(0, 0));
}

test "downsampleChunkIntoRegion keeps terrain surface above cave gaps" {
var data = try LODSimplifiedData.initWithVerticalSpans(testing.allocator, .lod1);
defer data.deinit();
Expand Down
7 changes: 6 additions & 1 deletion modules/world-lod/src/lod_manager_context.zig
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,14 @@ pub const ChunkResolver = struct {
};

pub const MAX_LOD_REGIONS = 2048;
// Bound queued and in-flight regions so a cold cache cannot bury the horizon
// fallback behind thousands of expensive generation jobs.
pub const MAX_PENDING_LOD_REGIONS: usize = 64;
pub const CHUNK_COVERAGE_PADDING: i32 = 1;
pub const LOD_UPDATE_DIVISOR: u32 = 2;
pub const MIN_LOD_WORKERS: usize = 4;
// WorldStreamer reserves these workers from its foreground pools whenever LOD
// is enabled, so horizon generation can be fast without oversubscribing CPUs.
pub const MIN_LOD_WORKERS: usize = 2;
pub const MAX_LOD_WORKERS: usize = 6;
pub const MAX_MEMORY_EVICTIONS_PER_UPDATE: usize = 32;
pub const MAX_MESH_DELETIONS_PER_SWEEP: usize = 64;
Expand Down
15 changes: 14 additions & 1 deletion modules/world-lod/src/lod_manager_generation_ops.zig
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ pub fn queueLODRegions(self: *Self, lod: LODLevel, velocity: Vec3, chunk_checker
const radii = self.config.getRadii();
const active_lod_count = lod_chunk.activeLODCount(self.config);
const use_vertical_spans = self.config.getVerticalSpanBudget() > 0 and self.effectiveMeshPath(lod) == .column_spans;
var pending_regions: usize = 0;
for (0..active_lod_count) |i| {
var iter = self.regions[i].iterator();
while (iter.next()) |entry| {
switch (entry.value_ptr.*.getState()) {
.missing, .renderable => {},
else => pending_regions += 1,
}
}
}
self.mutex.unlock();

const Coverage = struct {
Expand All @@ -96,7 +106,10 @@ pub fn queueLODRegions(self: *Self, lod: LODLevel, velocity: Vec3, chunk_checker
.coverage_ptr = self,
.are_all_chunks_loaded = Coverage.areAllLoaded,
.radius_reduction = &self.memory_governor.radius_shrink_chunks,
.defer_generation_dispatch = self.cacheEnabled(),
// Route every region through the bounded admission path, whether or
// not persistent LOD caching is enabled.
.defer_generation_dispatch = true,
.pending_regions = &pending_regions,
.use_vertical_spans = use_vertical_spans,
}, lod, velocity, chunk_checker, checker_ctx);
}
Expand Down
Loading
Loading