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
5 changes: 4 additions & 1 deletion docs/lod-quality-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ The render distance preset is the supported user-facing control for distant LOD
containers. The cache worker evicts the oldest containers after atomic
writes and compacts live entries when sector growth reaches the cap.
- `horizontal_detail`: target horizontal detail per LOD. This is used as a floor for QEM triangle targets when the experimental QEM mesh path is enabled.
- `sample_density`: source-grid density per LOD. Medium uses half density for
LOD4 so its initial 512-chunk horizon has 33x33 source grids instead of
65x65 grids; finer LODs replace those 16-block cells as they stream in.
- `vertical_span_budget`: enables rich column/span source data when nonzero.
The numeric values are reserved preset policy; current source allocation is
bounded by the engine-wide `MAX_LOD_VERTICAL_SPANS` limit.
Expand All @@ -22,7 +25,7 @@ The render distance preset is the supported user-facing control for distant LOD
| Preset | Horizon | Horizontal detail LOD0–4 | Span setting | RAM budget | Store cap | Uploads/frame |
| --- | ---: | --- | ---: | ---: | ---: | ---: |
| Low | 256 chunks | 33/33/33/65/65 | 2 | 128 MB | 512 MB | 4 |
| Medium | 512 chunks | 33/49/49/65/65 | 2 | 256 MB | 1,024 MB | 8 |
| Medium | 512 chunks | 33/49/49/65/33 | 2 | 256 MB | 1,024 MB | 8 |
| High | 512 chunks | 33/65/65/97/97 | 3 | 384 MB | 1,536 MB | 8 |
| Ultra | 1,024 chunks | 33/65/65/129/129 | 4 | 512 MB | 3,072 MB | 12 |
| Extreme | 2,048 chunks | 33/65/65/129/129 | 4 | 1,024 MB | 4,096 MB | 16 |
Expand Down
22 changes: 20 additions & 2 deletions modules/engine-core/src/job_system.zig
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ pub const Job = struct {
/// Normal chunk jobs use chunk coords directly (1); LOD jobs store
/// region coords and set this to that LOD's chunks-per-side.
coord_scale: i32 = 1,
/// Keep an explicitly assigned bootstrap order instead of replacing
/// its low priority bits when the player position changes.
preserve_priority: bool = false,
/// Immutable LOD config snapshot captured when a generation job is queued.
lod_radius: i32 = 0,
use_vertical_spans: bool = false,
Expand Down Expand Up @@ -239,7 +242,7 @@ pub const JobQueue = struct {
var updated_job = job;

// Only update distance for chunk-based jobs
if (job.getChunkCoords()) |coords| {
if (job.getChunkCoords()) |coords| if (!job.data.chunk.preserve_priority) {
const scale: i32 = @max(updated_job.data.chunk.coord_scale, 1);
// Compute squared distance in i64 then clamp, matching
// lod_manager/lod_scheduler — i32 dx*dx can overflow at large
Expand All @@ -254,7 +257,7 @@ pub const JobQueue = struct {
// ordering.
const bias_bits = updated_job.dist_sq & ~@as(i32, 0x0FFFFFFF);
updated_job.dist_sq = bias_bits | new_dist;
}
};

temp.append(self.allocator, updated_job) catch {
log.log.warn("Job queue: dropped job during priority update (allocation failed)", .{});
Expand Down Expand Up @@ -543,6 +546,21 @@ test "JobQueue reprioritizes region-scaled chunk jobs" {
try testing.expectEqual(@as(i32, 50), first.data.chunk.x);
}

test "JobQueue retains explicit bootstrap priorities" {
var queue = JobQueue.init(testing.allocator);
defer queue.deinit();

try queue.push(.{
.type = .chunk_generation,
.dist_sq = 7,
.data = .{ .chunk = .{ .x = 100, .z = 0, .job_token = 1, .preserve_priority = true } },
});
try queue.updatePlayerPos(100, 0);

const job = queue.pop() orelse return error.TestExpectedEqual;
try testing.expectEqual(@as(i32, 7), job.dist_sq);
}

test "JobQueue.clear calls cleanup on generic jobs" {
var cleanup_count: usize = 0;
var queue = JobQueue.init(testing.allocator);
Expand Down
11 changes: 10 additions & 1 deletion modules/engine-rhi/src/render_settings.zig
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ pub const RenderDistancePresetConfig = struct {
horizon_radius: i32,
lod_store_size_cap_mb: u32,
horizontal_detail: [LODLevel.count]u32,
sample_density: [LODLevel.count]f32,
vertical_span_budget: u8,
mesh_path: LODMeshPath,
fog_start_percent: [LODLevel.count]f32,
Expand All @@ -55,6 +56,7 @@ pub const RENDER_DISTANCE_PRESETS = [_]RenderDistancePresetConfig{
.horizon_radius = 256,
.lod_store_size_cap_mb = 512,
.horizontal_detail = .{ 33, 33, 33, 65, 65 },
.sample_density = .{ 1.0, 1.0, 1.0, 0.5, 1.0 },
.vertical_span_budget = 2,
.mesh_path = .column_spans,
.fog_start_percent = .{ 0.5, 0.5, 0.4, 0.3, 0.22 },
Expand All @@ -70,7 +72,11 @@ pub const RENDER_DISTANCE_PRESETS = [_]RenderDistancePresetConfig{
.lod_radii = .{ 10, 64, 156, 375, 512 },
.horizon_radius = 512,
.lod_store_size_cap_mb = 1024,
.horizontal_detail = .{ 33, 49, 49, 65, 65 },
// The 512-chunk fallback is intentionally coarser than LOD3. A 33x33
// horizon grid cuts cold-start sampling by roughly 4x while retaining
// 16-block cells at distances where finer levels replace it.
.horizontal_detail = .{ 33, 49, 49, 65, 33 },
.sample_density = .{ 1.0, 1.0, 1.0, 0.5, 0.5 },
.vertical_span_budget = 2,
.mesh_path = .column_spans,
.fog_start_percent = .{ 0.5, 0.5, 0.4, 0.4, 0.24 },
Expand All @@ -87,6 +93,7 @@ pub const RENDER_DISTANCE_PRESETS = [_]RenderDistancePresetConfig{
.horizon_radius = 512,
.lod_store_size_cap_mb = 1536,
.horizontal_detail = .{ 33, 65, 65, 97, 97 },
.sample_density = .{ 1.0, 1.0, 1.0, 0.5, 1.0 },
.vertical_span_budget = 3,
.mesh_path = .column_spans,
.fog_start_percent = .{ 0.5, 0.5, 0.4, 0.3, 0.22 },
Expand All @@ -103,6 +110,7 @@ pub const RENDER_DISTANCE_PRESETS = [_]RenderDistancePresetConfig{
.horizon_radius = 1024,
.lod_store_size_cap_mb = 3072,
.horizontal_detail = .{ 33, 65, 65, 129, 129 },
.sample_density = .{ 1.0, 1.0, 1.0, 0.5, 1.0 },
.vertical_span_budget = 4,
.mesh_path = .column_spans,
.fog_start_percent = .{ 0.5, 0.5, 0.4, 0.3, 0.2 },
Expand All @@ -119,6 +127,7 @@ pub const RENDER_DISTANCE_PRESETS = [_]RenderDistancePresetConfig{
.horizon_radius = 2048,
.lod_store_size_cap_mb = 4096,
.horizontal_detail = .{ 33, 65, 65, 129, 129 },
.sample_density = .{ 1.0, 1.0, 1.0, 0.5, 1.0 },
.vertical_span_budget = 4,
.mesh_path = .column_spans,
.fog_start_percent = .{ 0.5, 0.5, 0.4, 0.3, 0.18 },
Expand Down
5 changes: 4 additions & 1 deletion modules/game-core/src/session.zig
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ pub const GameSession = struct {
phase5_motion_evidence_emitted: bool = false,
gpu_culling_scale_fixture_installed: bool = false,

pub fn init(allocator: std.mem.Allocator, rhi: *RHI, atlas: *const TextureAtlas, seed: u64, render_distance: i32, horizon_distance: i32, lod_enabled: bool, generator_index: usize, render_distance_preset: RenderDistancePreset, build_config: BuildConfig) !*GameSession {
pub fn init(allocator: std.mem.Allocator, rhi: *RHI, atlas: *const TextureAtlas, seed: u64, render_distance: i32, horizon_distance: i32, lod_enabled: bool, compact_tiles_enabled: bool, generator_index: usize, render_distance_preset: RenderDistancePreset, build_config: BuildConfig) !*GameSession {
const session = try allocator.create(GameSession);
errdefer allocator.destroy(session);
if (phase5EvidenceEnabled(build_config)) resetPhase5CaptureReady();
Expand Down Expand Up @@ -178,13 +178,16 @@ pub const GameSession = struct {
.radii = preset_radii,
.memory_budget_mb = @min(preset_cfg.memory_budget_mb, 256),
.max_uploads_per_frame = @min(preset_cfg.max_uploads_per_frame, 8),
.compact_tiles_enabled = compact_tiles_enabled,
}
else
LODConfig{
.chunk_render_radius = chunk_render_radius,
.radii = preset_radii,
.fog_start_percent = preset_cfg.fog_start_percent,
.horizontal_detail = preset_cfg.horizontal_detail,
.sample_density = preset_cfg.sample_density,
.compact_tiles_enabled = compact_tiles_enabled,
.vertical_span_budget = preset_cfg.vertical_span_budget,
.mesh_path = preset_cfg.mesh_path,
.qem_triangle_targets = preset_cfg.qem_targets,
Expand Down
4 changes: 3 additions & 1 deletion modules/game-ui/src/screens/home.zig
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ pub const HomeScreen = struct {
fn isReadyForPresentation(ptr: *anyopaque) bool {
const self: *@This() = @ptrCast(@alignCast(ptr));
const stats = self.preview.getWorldStats() orelse return false;
return stats.chunks_rendered > 0 and !self.preview.world.telemetry().isStartupBusy();
// Reveal once terrain is drawable; the remaining preview chunks keep
// streaming behind the menu instead of blocking the hidden window.
return stats.chunks_rendered > 0;
}

pub fn screen(self: *@This()) IScreen {
Expand Down
4 changes: 3 additions & 1 deletion modules/game-ui/src/screens/rml_home.zig
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ pub const RmlHomeScreen = struct {
fn isReadyForPresentation(ptr: *anyopaque) bool {
const self: *@This() = @ptrCast(@alignCast(ptr));
const stats = self.preview.getWorldStats() orelse return false;
return stats.chunks_rendered > 0 and !self.preview.world.telemetry().isStartupBusy();
// RmlUi can be presented as soon as its background has one drawable
// terrain batch; subsequent rings continue loading asynchronously.
return stats.chunks_rendered > 0;
}

fn onDocumentAction(context: *anyopaque, _: []const u8, target_id: []const u8) void {
Expand Down
17 changes: 9 additions & 8 deletions modules/game-ui/src/screens/world.zig
Original file line number Diff line number Diff line change
Expand Up @@ -80,30 +80,29 @@ pub const WorldScreen = struct {
};

pub fn init(allocator: std.mem.Allocator, context: EngineContext, seed: u64, generator_index: usize) !*WorldScreen {
return initWithDistance(allocator, context, seed, generator_index, context.settings.render_distance, context.settings.horizon_distance, context.settings.lod_enabled, false);
return initWithDistance(allocator, context, seed, generator_index, context.settings.render_distance, context.settings.horizon_distance, context.settings.lod_enabled, true, false);
}

pub fn initMenuPreview(allocator: std.mem.Allocator, context: EngineContext, seed: u64, generator_index: usize) !*WorldScreen {
// The rotating menu preview is intentionally bounded to full-detail
// chunks. It does not need the distant hierarchy, and continuously
// streaming compact tiles underneath a retained RmlUi overlay can
// trigger a RADV command-stream rejection on RDNA1. Normal worlds keep
// the user's LOD setting and the complete compact pipeline.
// Keep the distant hierarchy in the rotating preview, but use its
// expanded fallback: continuously streaming compact tiles underneath a
// retained RmlUi overlay can trigger a RADV rejection on RDNA1.
return initWithDistance(
allocator,
context,
seed,
generator_index,
context.settings.render_distance,
context.settings.horizon_distance,
context.settings.lod_enabled,
false,
true,
);
}

fn initWithDistance(allocator: std.mem.Allocator, context: EngineContext, seed: u64, generator_index: usize, render_distance: i32, horizon_distance: i32, lod_enabled: bool, menu_preview: bool) !*WorldScreen {
fn initWithDistance(allocator: std.mem.Allocator, context: EngineContext, seed: u64, generator_index: usize, render_distance: i32, horizon_distance: i32, lod_enabled: bool, compact_tiles_enabled: bool, menu_preview: bool) !*WorldScreen {
const render_system = context.render_system;
const session = try GameSession.init(allocator, render_system.getRHI(), render_system.getAtlas(), seed, render_distance, horizon_distance, lod_enabled, generator_index, context.settings.render_distance_preset, context.build_config);
const session = try GameSession.init(allocator, render_system.getRHI(), render_system.getAtlas(), seed, render_distance, horizon_distance, lod_enabled, compact_tiles_enabled, generator_index, context.settings.render_distance_preset, context.build_config);
errdefer session.deinit();
const world = session.world.interface();

Expand Down Expand Up @@ -170,6 +169,8 @@ pub const WorldScreen = struct {

const world_telemetry = self.world.telemetry();
if (!self.menu_preview) {
const preset = rhi_pkg.getPresetConfig(ctx.settings.render_distance_preset);
self.session.world.setLODChunkRenderRadiusLimit(preset.lod_radii[0]);
if (world_telemetry.getRenderDistance() != ctx.settings.render_distance) {
world_telemetry.setRenderDistance(ctx.settings.render_distance);
}
Expand Down
1 change: 1 addition & 0 deletions modules/world-core/src/chunk.zig
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub const Chunk = struct {
light_revision: std.atomic.Value(u64) = .init(0),
dirty: bool = true,
mesh_attempts: u8 = 0,
force_cpu_mesh: bool = false,
generated: bool = false,
modified: bool = false,
/// Persisted with chunk format v3; v2 chunks are treated as stale lighting.
Expand Down
21 changes: 21 additions & 0 deletions modules/world-lod/src/lod_chunk.zig
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ pub const LODChunk = struct {

/// Job token for tracking async work
job_token: u32,
/// Encoded scheduling priority retained across cache lookup and lifecycle
/// transitions so bootstrap horizon seeds keep their spatial ordering.
job_priority: i32,
preserve_job_priority: bool,

/// Pin count for preventing unload during async work
pin_count: std.atomic.Value(u32),
Expand Down Expand Up @@ -222,6 +226,8 @@ pub const LODChunk = struct {
.lod_level = lod,
.state = .missing,
.job_token = 0,
.job_priority = 0,
.preserve_job_priority = false,
.pin_count = std.atomic.Value(u32).init(0),
.cancel_requested = std.atomic.Value(bool).init(false),
.data = .{ .empty = {} },
Expand Down Expand Up @@ -487,6 +493,7 @@ pub const ILODConfig = struct {
getQEMMinInputTriangles: *const fn (ptr: *anyopaque) u32,
getHorizontalDetail: *const fn (ptr: *anyopaque, lod: LODLevel) u32,
getSampleDensity: *const fn (ptr: *anyopaque, lod: LODLevel) f32,
getCompactTilesEnabled: *const fn (ptr: *anyopaque) bool,
getVerticalSpanBudget: *const fn (ptr: *anyopaque) u8,
getMeshPath: *const fn (ptr: *anyopaque) LODMeshPath,
getFogStartPercent: *const fn (ptr: *anyopaque, lod: LODLevel) f32,
Expand Down Expand Up @@ -574,6 +581,11 @@ pub const ILODConfig = struct {
pub fn getSampleDensity(self: ILODConfig, lod: LODLevel) f32 {
return self.vtable.getSampleDensity(self.ptr, lod);
}
/// Returns whether far LODs may use compact GPU tiles. Callers can disable
/// them for retained-overlay scenes that require the expanded fallback.
pub fn getCompactTilesEnabled(self: ILODConfig) bool {
return self.vtable.getCompactTilesEnabled(self.ptr);
}

/// Returns the maximum number of vertical spans retained per LOD column.
/// Implementations clamp this to the storage capacity of `LODSimplifiedData`.
Expand Down Expand Up @@ -651,6 +663,8 @@ pub const LODConfig = struct {
/// samples creates 32-block plateaus and visibly detached height seams.
sample_density: [LODLevel.count]f32 = .{ 1.0, 1.0, 1.0, 0.5, 1.0 },

compact_tiles_enabled: bool = true,

vertical_span_budget: u8 = 4,

mesh_path: LODMeshPath = .column_spans,
Expand Down Expand Up @@ -773,6 +787,7 @@ pub const LODConfig = struct {
.getQEMMinInputTriangles = getQEMMinInputTrianglesWrapper,
.getHorizontalDetail = getHorizontalDetailWrapper,
.getSampleDensity = getSampleDensityWrapper,
.getCompactTilesEnabled = getCompactTilesEnabledWrapper,
.getVerticalSpanBudget = getVerticalSpanBudgetWrapper,
.getMeshPath = getMeshPathWrapper,
.getFogStartPercent = getFogStartPercentWrapper,
Expand Down Expand Up @@ -844,6 +859,10 @@ pub const LODConfig = struct {
const self: *LODConfig = @ptrCast(@alignCast(ptr));
return std.math.clamp(self.sample_density[@intFromEnum(lod)], 0.0625, 1.0);
}
fn getCompactTilesEnabledWrapper(ptr: *anyopaque) bool {
const self: *LODConfig = @ptrCast(@alignCast(ptr));
return self.compact_tiles_enabled;
}
fn getVerticalSpanBudgetWrapper(ptr: *anyopaque) u8 {
const self: *LODConfig = @ptrCast(@alignCast(ptr));
return @min(self.vertical_span_budget, @as(u8, @intCast(world_core.MAX_LOD_VERTICAL_SPANS)));
Expand Down Expand Up @@ -1028,10 +1047,12 @@ test "ILODConfig exposes LOD quality tuning controls" {
.horizontal_detail = .{ 16, 24, 32, 40, 24 },
.vertical_span_budget = 99,
.mesh_path = .qem,
.compact_tiles_enabled = false,
};
const interface = config.interface();

try std.testing.expectEqual(@as(u32, 32), interface.getHorizontalDetail(.lod2));
try std.testing.expectEqual(@as(u8, world_core.MAX_LOD_VERTICAL_SPANS), interface.getVerticalSpanBudget());
try std.testing.expectEqual(LODMeshPath.qem, interface.getMeshPath());
try std.testing.expect(!interface.getCompactTilesEnabled());
}
24 changes: 24 additions & 0 deletions modules/world-lod/src/lod_manager.zig
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,30 @@ pub const LODManager = struct {
return lod_manager_core.getStats(self);
}

/// Returns whether the coarsest active level has produced drawable fallback
/// terrain within the current horizon. Scoping this to the player prevents
/// stale regions after a teleport from releasing foreground prefetch early.
pub fn hasRenderableCoarsestNear(self: *Self, player_cx: i32, player_cz: i32) bool {
self.mutex.lockShared();
defer self.mutex.unlockShared();
const active_count = lod_chunk.activeLODCount(self.config);
if (active_count == 0) return false;
const lod: LODLevel = @enumFromInt(active_count - 1);
const scale: i64 = @intCast(lod.chunksPerSide());
const radius: i64 = self.config.getRadii()[active_count - 1];
var it = self.regions[active_count - 1].iterator();
while (it.next()) |entry| {
const chunk = entry.value_ptr.*;
if (chunk.getState() != .renderable) continue;
const center_x = @as(i64, chunk.region_x) * scale + @divFloor(scale, 2);
const center_z = @as(i64, chunk.region_z) * scale + @divFloor(scale, 2);
const dx = center_x - player_cx;
const dz = center_z - player_cz;
if (dx * dx + dz * dz <= radius * radius) return true;
}
return false;
}

/// Pauses new LOD generation and scheduling work.
/// Existing regions and meshes remain available for rendering while paused.
pub fn pause(self: *Self) void {
Expand Down
4 changes: 2 additions & 2 deletions modules/world-lod/src/lod_manager_context.zig
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,8 @@ pub const CHUNK_COVERAGE_PADDING: i32 = 1;
pub const LOD_UPDATE_DIVISOR: u32 = 2;
// 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 MIN_LOD_WORKERS: usize = 1;
pub const MAX_LOD_WORKERS: usize = 8;
pub const MAX_MEMORY_EVICTIONS_PER_UPDATE: usize = 32;
pub const MAX_MESH_DELETIONS_PER_SWEEP: usize = 64;
pub const DELETION_SWEEP_SECONDS: f32 = 1.0;
Expand Down
5 changes: 4 additions & 1 deletion modules/world-lod/src/lod_manager_core_ops.zig
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,10 @@ pub fn init(allocator: std.mem.Allocator, config: ILODConfig, gpu_bridge: LODGPU
errdefer mgr.cache_io.deinit();

const cpu_count = std.Thread.getCpuCount() catch MIN_LOD_WORKERS;
const lod_worker_count = std.math.clamp(cpu_count / 2, MIN_LOD_WORKERS, MAX_LOD_WORKERS);
const total_budget = @max(@as(usize, 5), cpu_count -| 1);
const foreground_minimum: usize = 4;
const lod_capacity = @max(MIN_LOD_WORKERS, total_budget -| foreground_minimum);
const lod_worker_count = @min(std.math.clamp(cpu_count / 2, MIN_LOD_WORKERS, MAX_LOD_WORKERS), lod_capacity);

// All LOD jobs go through one shared queue. LOD-aware priority bits keep
// fine near-detail jobs ahead of coarse fallback regions.
Expand Down
Loading
Loading