diff --git a/docs/lod-quality-controls.md b/docs/lod-quality-controls.md index e3b05718..5c0d8e42 100644 --- a/docs/lod-quality-controls.md +++ b/docs/lod-quality-controls.md @@ -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. @@ -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 | diff --git a/modules/engine-core/src/job_system.zig b/modules/engine-core/src/job_system.zig index facce546..0fd44994 100644 --- a/modules/engine-core/src/job_system.zig +++ b/modules/engine-core/src/job_system.zig @@ -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, @@ -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 @@ -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)", .{}); @@ -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); diff --git a/modules/engine-rhi/src/render_settings.zig b/modules/engine-rhi/src/render_settings.zig index ed2936b1..3b25b502 100644 --- a/modules/engine-rhi/src/render_settings.zig +++ b/modules/engine-rhi/src/render_settings.zig @@ -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, @@ -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 }, @@ -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 }, @@ -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 }, @@ -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 }, @@ -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 }, diff --git a/modules/game-core/src/session.zig b/modules/game-core/src/session.zig index 78cf2842..d5eb4b54 100644 --- a/modules/game-core/src/session.zig +++ b/modules/game-core/src/session.zig @@ -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(); @@ -178,6 +178,7 @@ 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{ @@ -185,6 +186,8 @@ pub const GameSession = struct { .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, diff --git a/modules/game-ui/src/screens/home.zig b/modules/game-ui/src/screens/home.zig index d638ad0c..6ff18254 100644 --- a/modules/game-ui/src/screens/home.zig +++ b/modules/game-ui/src/screens/home.zig @@ -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 { diff --git a/modules/game-ui/src/screens/rml_home.zig b/modules/game-ui/src/screens/rml_home.zig index c9620e90..0c11f151 100644 --- a/modules/game-ui/src/screens/rml_home.zig +++ b/modules/game-ui/src/screens/rml_home.zig @@ -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 { diff --git a/modules/game-ui/src/screens/world.zig b/modules/game-ui/src/screens/world.zig index 52fe472e..5444a32e 100644 --- a/modules/game-ui/src/screens/world.zig +++ b/modules/game-ui/src/screens/world.zig @@ -80,15 +80,13 @@ 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, @@ -96,14 +94,15 @@ pub const WorldScreen = struct { 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(); @@ -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); } diff --git a/modules/world-core/src/chunk.zig b/modules/world-core/src/chunk.zig index ca49abb3..63f84ab0 100644 --- a/modules/world-core/src/chunk.zig +++ b/modules/world-core/src/chunk.zig @@ -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. diff --git a/modules/world-lod/src/lod_chunk.zig b/modules/world-lod/src/lod_chunk.zig index 6b335687..167f1302 100644 --- a/modules/world-lod/src/lod_chunk.zig +++ b/modules/world-lod/src/lod_chunk.zig @@ -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), @@ -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 = {} }, @@ -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, @@ -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`. @@ -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, @@ -773,6 +787,7 @@ pub const LODConfig = struct { .getQEMMinInputTriangles = getQEMMinInputTrianglesWrapper, .getHorizontalDetail = getHorizontalDetailWrapper, .getSampleDensity = getSampleDensityWrapper, + .getCompactTilesEnabled = getCompactTilesEnabledWrapper, .getVerticalSpanBudget = getVerticalSpanBudgetWrapper, .getMeshPath = getMeshPathWrapper, .getFogStartPercent = getFogStartPercentWrapper, @@ -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))); @@ -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()); } diff --git a/modules/world-lod/src/lod_manager.zig b/modules/world-lod/src/lod_manager.zig index c4e48a59..5276bc28 100644 --- a/modules/world-lod/src/lod_manager.zig +++ b/modules/world-lod/src/lod_manager.zig @@ -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 { diff --git a/modules/world-lod/src/lod_manager_context.zig b/modules/world-lod/src/lod_manager_context.zig index 3d29a0e9..8b89b8af 100644 --- a/modules/world-lod/src/lod_manager_context.zig +++ b/modules/world-lod/src/lod_manager_context.zig @@ -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; diff --git a/modules/world-lod/src/lod_manager_core_ops.zig b/modules/world-lod/src/lod_manager_core_ops.zig index 268b6891..561c49a2 100644 --- a/modules/world-lod/src/lod_manager_core_ops.zig +++ b/modules/world-lod/src/lod_manager_core_ops.zig @@ -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. diff --git a/modules/world-lod/src/lod_manager_generation_ops.zig b/modules/world-lod/src/lod_manager_generation_ops.zig index ec7f237f..e66c25d5 100644 --- a/modules/world-lod/src/lod_manager_generation_ops.zig +++ b/modules/world-lod/src/lod_manager_generation_ops.zig @@ -144,7 +144,6 @@ pub fn processQueuedGenerations(self: *Self, velocity: Vec3) !void { pub fn dispatchCacheMiss(self: *Self, key: LODRegionKey, token: u32) void { const lod_idx = @intFromEnum(key.lod); - const player = self.loadPlayerChunkPos(); self.mutex.lock(); const chunk = self.regions[lod_idx].get(key) orelse { self.mutex.unlock(); @@ -154,12 +153,11 @@ pub fn dispatchCacheMiss(self: *Self, key: LODRegionKey, token: u32) void { self.mutex.unlock(); return; } - const active_lod_count = lod_chunk.activeLODCount(self.config); const scale: i32 = @intCast(key.lod.chunksPerSide()); const candidate = GenerationCandidate{ .key = key, .chunk = chunk, - .encoded_priority = lod_scheduler.encodePriority(key.lod, key.rx * scale + @divFloor(scale, 2) - player.cx, key.rz * scale + @divFloor(scale, 2) - player.cz, Vec3.zero, active_lod_count), + .encoded_priority = chunk.job_priority, .level = @intCast(lod_idx), .coord_scale = scale, .job_token = token, @@ -173,19 +171,18 @@ pub fn dispatchCacheMiss(self: *Self, key: LODRegionKey, token: u32) void { } fn generationCandidateFromToken(self: *Self, token: LifecycleToken, velocity: Vec3) ?GenerationCandidate { + _ = velocity; if (token.stage != .generation) return null; const lod_idx = @intFromEnum(token.key.lod); - const player = self.loadPlayerChunkPos(); self.mutex.lock(); defer self.mutex.unlock(); const chunk = self.regions[lod_idx].get(token.key) orelse return null; if (chunk.getState() != .queued_for_generation or !token.matches(chunk)) return null; - const active = lod_chunk.activeLODCount(self.config); const scale: i32 = @intCast(token.key.lod.chunksPerSide()); return .{ .key = token.key, .chunk = chunk, - .encoded_priority = lod_scheduler.encodePriority(token.key.lod, token.key.rx * scale + @divFloor(scale, 2) - player.cx, token.key.rz * scale + @divFloor(scale, 2) - player.cz, velocity, active), + .encoded_priority = chunk.job_priority, .level = @intCast(lod_idx), .coord_scale = scale, .job_token = token.job_token, @@ -214,6 +211,7 @@ fn dispatchGenerationCandidate(self: *Self, candidate: GenerationCandidate) !voi .job_token = candidate.job_token, .lod_level = candidate.level, .coord_scale = candidate.coord_scale, + .preserve_priority = candidate.chunk.preserve_job_priority, .lod_radius = candidate.lod_radius, .use_vertical_spans = candidate.want_spans, } }, @@ -278,6 +276,7 @@ pub fn processStateTransitions(self: *Self, velocity: Vec3) !void { .job_token = token.job_token, .lod_level = @intCast(lod_idx), .coord_scale = scale, + .preserve_priority = chunk.preserve_job_priority, .lod_radius = self.config.getRadii()[lod_idx], }, }, @@ -306,7 +305,7 @@ pub fn processStateTransitions(self: *Self, velocity: Vec3) !void { fn reconcileLifecycleTokens(self: *Self) void { self.mutex.lock(); defer self.mutex.unlock(); - for (&self.regions, 0..) |*regions, lod_idx| { + for (&self.regions) |*regions| { var it = regions.iterator(); while (it.next()) |entry| { const chunk = entry.value_ptr.*; @@ -321,7 +320,7 @@ fn reconcileLifecycleTokens(self: *Self) void { .key = entry.key_ptr.*, .job_token = chunk.job_token, .source_revision = chunk.source_revision, - .priority = @as(i32, @intCast(lod_idx)) << 28, + .priority = chunk.job_priority, .stage = stage, }; if (stage == .generation) { @@ -334,14 +333,11 @@ fn reconcileLifecycleTokens(self: *Self) void { } pub fn enqueueTransition(self: *Self, key: LODRegionKey, chunk: *const LODChunk, stage: LifecycleStage) void { - const player = self.loadPlayerChunkPos(); - const scale: i32 = @intCast(key.lod.chunksPerSide()); - const priority = lod_scheduler.encodePriority(key.lod, key.rx * scale + @divFloor(scale, 2) - player.cx, key.rz * scale + @divFloor(scale, 2) - player.cz, Vec3.zero, lod_chunk.activeLODCount(self.config)); const token = LifecycleToken{ .key = key, .job_token = chunk.job_token, .source_revision = chunk.source_revision, - .priority = priority, + .priority = chunk.job_priority, .stage = stage, }; if (stage == .generation) { @@ -493,6 +489,7 @@ pub fn fallbackCompactMeshToCpu(self: *Self, mesh: *LODMesh, chunk: *LODChunk) ! fn shouldUseCompactTiles(self: *Self, chunk: *const LODChunk) bool { if (chunk.compact_disabled) return false; + if (!self.config.getCompactTilesEnabled()) return false; const lod = chunk.lodLevel(); if (lod != .lod3 and lod != .lod4) return false; const mode = engine_core.getenv("ZIGCRAFT_LOD_COMPACT") orelse "auto"; diff --git a/modules/world-lod/src/lod_manager_upload_ops.zig b/modules/world-lod/src/lod_manager_upload_ops.zig index 37451a20..8d2a5bb1 100644 --- a/modules/world-lod/src/lod_manager_upload_ops.zig +++ b/modules/world-lod/src/lod_manager_upload_ops.zig @@ -343,6 +343,9 @@ pub fn adjustParentReadyChildren(self: *Self, key: LODRegionKey, delta: i8) void pub fn markRegionRenderable(self: *Self, key: LODRegionKey, chunk: *LODChunk) void { if (chunk.isRenderable()) return; chunk.markRenderable(self.countRenderableChildren(key)); + if (engine_core.envFlag("ZIGCRAFT_LOD_DIAG", false)) { + log.log.warn("LOD_REGION_RENDERABLE: lod={} region=({}, {})", .{ @intFromEnum(key.lod), key.rx, key.rz }); + } self.enqueueFade(key, chunk); if (self.pending_region_count > 0) self.pending_region_count -= 1; if (self.regionContributesGeometry(key, chunk)) { diff --git a/modules/world-lod/src/lod_scheduler.zig b/modules/world-lod/src/lod_scheduler.zig index 537582ab..ef802770 100644 --- a/modules/world-lod/src/lod_scheduler.zig +++ b/modules/world-lod/src/lod_scheduler.zig @@ -66,11 +66,39 @@ const QueueDiag = struct { const LOD0_QUEUE_CANDIDATE_LIMIT: usize = 96; const LOD1_QUEUE_CANDIDATE_LIMIT: usize = 64; -const HORIZON_QUEUE_CANDIDATE_LIMIT: usize = 24; +const HORIZON_QUEUE_CANDIDATE_LIMIT: usize = 64; const REFINEMENT_QUEUE_CANDIDATE_LIMIT: usize = 48; const MAX_PENDING_LOD_REGIONS = @import("lod_manager_context.zig").MAX_PENDING_LOD_REGIONS; const MAX_LOD_REGIONS = @import("lod_manager_context.zig").MAX_LOD_REGIONS; +const HORIZON_SEED_DIRECTIONS = [_][2]i32{ + .{ 1024, 0 }, .{ 946, 392 }, .{ 724, 724 }, .{ 392, 946 }, + .{ 0, 1024 }, .{ -392, 946 }, .{ -724, 724 }, .{ -946, 392 }, + .{ -1024, 0 }, .{ -946, -392 }, .{ -724, -724 }, .{ -392, -946 }, + .{ 0, -1024 }, .{ 392, -946 }, .{ 724, -724 }, .{ 946, -392 }, +}; + +fn scaledSeedOffset(component: i32, radius: i32) i32 { + const product = component * radius; + return @divTrunc(product + (if (product >= 0) @as(i32, 512) else -512), 1024); +} + +fn initialHorizonSeedRank(rx: i32, rz: i32, player_rx: i32, player_rz: i32, region_radius: i32) ?usize { + const outer_radius = @max(1, region_radius - 1); + for (HORIZON_SEED_DIRECTIONS, 0..) |dir, i| { + if (rx == player_rx + scaledSeedOffset(dir[0], outer_radius) and + rz == player_rz + scaledSeedOffset(dir[1], outer_radius)) return i; + } + + const middle_radius = @max(1, @divFloor(outer_radius, 2)); + for (0..8) |i| { + const dir = HORIZON_SEED_DIRECTIONS[i * 2]; + if (rx == player_rx + scaledSeedOffset(dir[0], middle_radius) and + rz == player_rz + scaledSeedOffset(dir[1], middle_radius)) return HORIZON_SEED_DIRECTIONS.len + i; + } + return null; +} + pub fn priorityRank(lod: LODLevel, active_lod_count: usize) usize { const lod_idx: usize = @intFromEnum(lod); const coarsest_idx = if (active_lod_count == 0) 0 else active_lod_count - 1; @@ -124,6 +152,17 @@ pub fn encodePriority(lod: LODLevel, chunk_dx: i32, chunk_dz: i32, velocity: Vec /// Queue LOD regions that need generation. pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque) !void { + // Do not rescan thousands of horizon candidates while the bounded + // lifecycle pipeline cannot admit another region. + ctx.mutex.lockShared(); + if (ctx.pending_regions) |pending| { + if (pending.* >= MAX_PENDING_LOD_REGIONS) { + ctx.mutex.unlockShared(); + return; + } + } + ctx.mutex.unlockShared(); + const radii = ctx.radii; const idx: u32 = @intFromEnum(lod); // Apply dynamic radius reduction (hysteresis) to every level except the @@ -147,11 +186,18 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu // Keep only the bounded best candidates while walking the horizon. This // avoids allocating/sorting an entry for every potential region. - const Candidate = struct { key: LODRegionKey, encoded_priority: i32 }; + const Candidate = struct { key: LODRegionKey, encoded_priority: i32, selection_priority: i64, preserve_priority: bool }; const max_candidates = maxQueueCandidatesForLOD(lod, active_lod_count); var candidates = std.ArrayListUnmanaged(Candidate).empty; defer candidates.deinit(ctx.allocator); + // Existing active regions must not consume the bounded candidate window. + // Repeatedly selecting the same nearest horizon regions otherwise prevents + // the coarsest band from progressing beyond its first batch. + ctx.mutex.lockShared(); + const candidate_storage = &ctx.regions[@intFromEnum(lod)]; + const seed_initial_horizon = is_coarsest and candidate_storage.count() == 0; + var rz = player_rz - region_radius; while (rz <= player_rz + region_radius) : (rz += 1) { var rx = player_rx - region_radius; @@ -174,19 +220,48 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu } } + if (candidate_storage.get(key)) |chunk| { + diag.existing += 1; + if (chunk.getState() != .missing or chunk.isPinned()) continue; + } + const center_cx = key.rx * scale + @divFloor(scale, 2); const center_cz = key.rz * scale + @divFloor(scale, 2); - const encoded_priority = encodePriority(lod, center_cx - ctx.player_cx, center_cz - ctx.player_cz, velocity, active_lod_count); - const candidate = Candidate{ .key = key, .encoded_priority = encoded_priority }; + const distance_priority = encodePriority(lod, center_cx - ctx.player_cx, center_cz - ctx.player_cz, velocity, active_lod_count); + const seed_rank = if (seed_initial_horizon) initialHorizonSeedRank(rx, rz, player_rx, player_rz, region_radius) else null; + // Preserve the spatial seed order in the worker queue as well as + // candidate admission; otherwise distance reprioritization makes + // the newly admitted outer shell wait behind nearby coarse tiles. + const encoded_priority = if (seed_rank) |rank| + lodPriorityBias(lod, active_lod_count) | @as(i32, @intCast(rank)) + else + distance_priority; + const selection_priority: i64 = if (seed_initial_horizon) + if (seed_rank) |rank| + @intCast(rank) + else + @as(i64, 1_000_000_000) + @as(i64, distance_priority & 0x0FFFFFFF) + else + distance_priority; + const candidate = Candidate{ + .key = key, + .encoded_priority = encoded_priority, + .selection_priority = selection_priority, + .preserve_priority = seed_rank != null, + }; var insert_at: usize = 0; - while (insert_at < candidates.items.len and candidates.items[insert_at].encoded_priority <= encoded_priority) : (insert_at += 1) {} + while (insert_at < candidates.items.len and candidates.items[insert_at].selection_priority <= selection_priority) : (insert_at += 1) {} if (insert_at < max_candidates) { - try candidates.insert(ctx.allocator, insert_at, candidate); + candidates.insert(ctx.allocator, insert_at, candidate) catch |err| { + ctx.mutex.unlockShared(); + return err; + }; if (candidates.items.len > max_candidates) _ = candidates.pop(); } diag.candidates += 1; } } + ctx.mutex.unlockShared(); var queued_count: usize = 0; @@ -203,7 +278,6 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu } const existing = storage.get(cand.key); - if (existing != null) diag.existing += 1; if (existing == null and resident_regions >= ctx.resident_region_limit) break; if (existing == null) if (ctx.logical_memory_bytes) |logical| { const reservation = ctx.logical_region_reservation_bytes; @@ -229,6 +303,8 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu chunk.job_token = ctx.next_job_token.*; ctx.next_job_token.* += 1; + chunk.job_priority = cand.encoded_priority; + chunk.preserve_job_priority = cand.preserve_priority; if (ctx.defer_generation_dispatch) { chunk.setState(.queued_for_generation); if (ctx.generation_tokens) |tokens| { @@ -483,7 +559,72 @@ test "LOD scheduling caps LOD0 flood while still queuing horizon jobs" { try std.testing.expectEqual(LOD0_QUEUE_CANDIDATE_LIMIT, lod0_count); try std.testing.expectEqual(HORIZON_QUEUE_CANDIDATE_LIMIT, horizon_count); - try std.testing.expect(max_horizon_dist_sq <= 128 * 128); + // The bootstrap batch includes azimuthally distributed outer-horizon + // seeds instead of spending every admission near the player. + try std.testing.expect(max_horizon_dist_sq >= 400 * 400); +} + +test "LOD scheduling advances horizon beyond existing nearest batch" { + const allocator = std.testing.allocator; + + var regions: [LODLevel.count]RegionMap = undefined; + for (®ions) |*region_map| region_map.* = RegionMap.init(allocator); + defer { + for (®ions) |*region_map| { + var it = region_map.iterator(); + while (it.next()) |entry| { + const chunk = entry.value_ptr.*; + chunk.deinit(allocator); + allocator.destroy(chunk); + } + region_map.deinit(); + } + } + + var queues: [LODLevel.count]JobQueue = undefined; + var queue_ptrs: [LODLevel.count]*JobQueue = undefined; + for (&queues, 0..) |*queue, i| { + queue.* = JobQueue.init(allocator); + queue_ptrs[i] = queue; + } + defer for (&queues) |*queue| queue.deinit(); + + var config = LODConfig{ + .chunk_render_radius = 16, + .radii = .{ 64, 128, 256, 384, 512 }, + }; + const config_iface = config.interface(); + var mutex: sync.RwLock = .{}; + var next_job_token: u32 = 1; + var radius_reduction = [_]i32{0} ** LODLevel.count; + var coverage_ctx: u8 = 0; + const Coverage = struct { + fn neverCovered(_: *anyopaque, _: LODChunk.WorldBounds, _: ChunkChecker, _: *anyopaque) bool { + return false; + } + }; + const ctx = SchedulerContext{ + .allocator = allocator, + .config = config_iface, + .radii = config_iface.getRadii(), + .active_lod_count = lod_chunk.activeLODCount(config_iface), + .regions = ®ions, + .gen_queues = &queue_ptrs, + .mutex = &mutex, + .player_cx = 0, + .player_cz = 0, + .next_job_token = &next_job_token, + .cleanup_covered_regions = false, + .coverage_ptr = &coverage_ctx, + .are_all_chunks_loaded = Coverage.neverCovered, + .radius_reduction = &radius_reduction, + }; + + try queueLODRegions(ctx, .lod4, Vec3.zero, null, null); + try std.testing.expectEqual(HORIZON_QUEUE_CANDIDATE_LIMIT, queue_ptrs[LODLevel.count - 1].count()); + + try queueLODRegions(ctx, .lod4, Vec3.zero, null, null); + try std.testing.expectEqual(HORIZON_QUEUE_CANDIDATE_LIMIT * 2, queue_ptrs[LODLevel.count - 1].count()); } test "LOD scheduling biases priorities toward movement direction" { diff --git a/modules/world-lod/src/lod_streaming_coordinator.zig b/modules/world-lod/src/lod_streaming_coordinator.zig index e0f43a9a..a99e06b9 100644 --- a/modules/world-lod/src/lod_streaming_coordinator.zig +++ b/modules/world-lod/src/lod_streaming_coordinator.zig @@ -67,6 +67,7 @@ pub const StreamingFrame = struct { moved: bool, target_render_dist: i32, render_dist: i32, + stream_dist: i32, movement: PlayerMovement, }; @@ -84,9 +85,12 @@ pub const LODStreamingCoordinator = struct { effective_render_dist: i32 = 0, startup_stream_radius: i32 = 0, startup_mesh_finalized: bool = false, + horizon_bootstrap_ready: bool = false, const STARTUP_RADIUS_INITIAL = 3; const STARTUP_RADIUS_STEP = 2; + const STARTUP_PREFETCH_RINGS = 2; + const STARTUP_RADIUS_CHECK_PERIOD = 10; pub fn init(render_distance: i32) LODStreamingCoordinator { return .{ @@ -102,6 +106,7 @@ pub const LODStreamingCoordinator = struct { self.startup_stream_radius = @min(distance, STARTUP_RADIUS_INITIAL); self.effective_render_dist = 0; self.startup_mesh_finalized = false; + self.horizon_bootstrap_ready = false; self.forceRescan(); return true; } @@ -111,6 +116,7 @@ pub const LODStreamingCoordinator = struct { if (lod_manager != null) { self.startup_stream_radius = @min(self.render_distance, STARTUP_RADIUS_INITIAL); self.effective_render_dist = 0; + self.horizon_bootstrap_ready = false; } } @@ -135,6 +141,16 @@ pub const LODStreamingCoordinator = struct { self.updateStartupRadius(storage, pc.chunk_x, pc.chunk_z, target_render_dist, frame_counter); const render_dist = if (self.startup_stream_radius > 0) self.startup_stream_radius else target_render_dist; + if (self.lod_manager) |manager| { + self.horizon_bootstrap_ready = manager.hasRenderableCoarsestNear(pc.chunk_x, pc.chunk_z); + } + // Keep generation and meshing ahead of the visible startup radius so + // expansion does not serialize one ring behind another. + const prefetch_distance = if (self.lod_manager != null and !self.horizon_bootstrap_ready) + @as(i32, 0) + else + STARTUP_RADIUS_STEP * STARTUP_PREFETCH_RINGS; + const stream_dist = @min(target_render_dist, render_dist + prefetch_distance); self.effective_render_dist = render_dist; if (moved) { @@ -149,6 +165,7 @@ pub const LODStreamingCoordinator = struct { .moved = moved, .target_render_dist = target_render_dist, .render_dist = render_dist, + .stream_dist = stream_dist, .movement = self.player_movement, }; } @@ -191,7 +208,7 @@ pub const LODStreamingCoordinator = struct { return; } - if (frame_counter % 30 != 0) return; + if (frame_counter % STARTUP_RADIUS_CHECK_PERIOD != 0) return; var total_in_radius: u32 = 0; var ready_in_radius: u32 = 0; @@ -223,3 +240,16 @@ pub const LODStreamingCoordinator = struct { log.log.info("STARTUP_STREAM_RADIUS: expanded to {} / {}", .{ self.startup_stream_radius, target_render_dist }); } }; + +test "startup streaming prefetches two rings beyond visible radius" { + var coordinator = LODStreamingCoordinator.init(22); + coordinator.startup_stream_radius = 3; + + const render_dist = coordinator.startup_stream_radius; + const stream_dist = @min( + coordinator.targetRenderDistance(), + render_dist + LODStreamingCoordinator.STARTUP_RADIUS_STEP * LODStreamingCoordinator.STARTUP_PREFETCH_RINGS, + ); + try std.testing.expectEqual(@as(i32, 3), render_dist); + try std.testing.expectEqual(@as(i32, 7), stream_dist); +} diff --git a/modules/world-runtime/src/chunk_queue_coordinator.zig b/modules/world-runtime/src/chunk_queue_coordinator.zig index 1d149180..3d131f56 100644 --- a/modules/world-runtime/src/chunk_queue_coordinator.zig +++ b/modules/world-runtime/src/chunk_queue_coordinator.zig @@ -432,29 +432,50 @@ pub const ChunkQueueCoordinator = struct { // Main-thread invariant: only this upload path mutates `.uploading` // chunks until GPU meshing finalization runs from the render graph. - if (!self.gpu.queueGpuMesh(data)) { - data.render.mesh.upload(self.vertex_allocator); - - if (data.render.mesh.diag_tile0_count > 0) { - log.log.warn("TILE0_MESH: chunk ({},{}) has {}/{} vertices with tile_id=0 (WHITE)", .{ - data.chunk.chunk_x, data.chunk.chunk_z, - data.render.mesh.diag_tile0_count, data.render.mesh.diag_total_verts, - }); - } + switch (self.gpu.queueGpuMesh(data)) { + .queued => {}, + .deferred => { + data.chunk.mesh_attempts +|= 1; + if (data.chunk.mesh_attempts >= 8) { + // Persistent GPU queue or block-buffer pressure must + // not leave the chunk invisible forever. + data.chunk.force_cpu_mesh = true; + data.chunk.state = .generated; + self.enqueuePendingMesh(key.x, key.z, data.chunk.job_token); + } else { + self.enqueuePendingUpload(key.x, key.z); + } + }, + .unavailable => { + data.render.mesh.upload(self.vertex_allocator); + + if (data.render.mesh.diag_tile0_count > 0) { + log.log.warn("TILE0_MESH: chunk ({},{}) has {}/{} vertices with tile_id=0 (WHITE)", .{ + data.chunk.chunk_x, data.chunk.chunk_z, + data.render.mesh.diag_tile0_count, data.render.mesh.diag_total_verts, + }); + } - if (data.render.mesh.ready) { - data.chunk.state = .renderable; - data.chunk.dirty = false; - _ = self.chunks_uploaded_total.fetchAdd(1, .monotonic); - } else { - log.log.warn("CHUNK_UPLOAD: ({},{}) upload FAILED (ready=false), reverting to mesh_ready | solid={} cutout={} fluid={}", .{ - key.x, key.z, - data.render.mesh.solid_allocation != null, data.render.mesh.cutout_allocation != null, - data.render.mesh.fluid_allocation != null, - }); - data.chunk.state = .mesh_ready; - self.enqueuePendingUpload(key.x, key.z); - } + if (data.render.mesh.ready) { + if (data.chunk.dirty) { + data.chunk.state = .generated; + self.enqueuePendingMesh(key.x, key.z, data.chunk.job_token); + } else { + data.chunk.state = .renderable; + data.chunk.mesh_attempts = 0; + data.chunk.force_cpu_mesh = false; + _ = self.chunks_uploaded_total.fetchAdd(1, .monotonic); + } + } else { + log.log.warn("CHUNK_UPLOAD: ({},{}) upload FAILED (ready=false), reverting to mesh_ready | solid={} cutout={} fluid={}", .{ + key.x, key.z, + data.render.mesh.solid_allocation != null, data.render.mesh.cutout_allocation != null, + data.render.mesh.fluid_allocation != null, + }); + data.chunk.state = .mesh_ready; + self.enqueuePendingUpload(key.x, key.z); + } + }, } uploads += 1; } @@ -563,15 +584,29 @@ pub const ChunkQueueCoordinator = struct { chunk_data.chunk.lighting_valid = true; } + // Validate worker-owned block data before taking the global storage + // writer lock. Scanning all 65,536 blocks under that lock serialized + // otherwise independent generation completions and render access. + var non_air_count: u32 = 0; + if (chunk_data.chunk.generated) { + for (chunk_data.chunk.blocks) |block| { + if (block != .air) non_air_count += 1; + } + } + self.storage.chunks_mutex.lock(); + const publishable = if (self.storage.chunks.get(ChunkKey{ .x = cx, .z = cz })) |data| + data == chunk_data and data.chunk.state == .generating and data.chunk.job_token == job.data.chunk.job_token + else + false; + if (!publishable) { + self.storage.chunks_mutex.unlock(); + return; + } if (!chunk_data.chunk.generated) { log.log.warn("CHUNK_GEN_FAILED: ({},{}) generator returned without setting generated=true, resetting to missing", .{ cx, cz }); chunk_data.chunk.state = .missing; } else { - var non_air_count: u32 = 0; - for (chunk_data.chunk.blocks) |block| { - if (block != .air) non_air_count += 1; - } if (non_air_count == 0) { log.log.warn("CHUNK_GEN_EMPTY: ({},{}) generated chunk has ZERO non-air blocks, resetting to missing", .{ cx, cz }); chunk_data.chunk.generated = false; @@ -582,9 +617,9 @@ pub const ChunkQueueCoordinator = struct { } } self.storage.chunks_mutex.unlock(); - if (chunk_data.chunk.generated) { - self.enqueuePendingMesh(cx, cz, chunk_data.chunk.job_token); + if (chunk_data.chunk.state == .generated and chunk_data.chunk.job_token == job.data.chunk.job_token) { self.markNeighborsForRemesh(cx, cz); + self.enqueueReadyNeighborhood(cx, cz); // Feed the real chunk into the LOD system so distant terrain is // derived from actual blocks (chunk_derived provenance) instead // of worldgen sampling. The chunk is pinned for this call. @@ -673,13 +708,14 @@ pub const ChunkQueueCoordinator = struct { self.storage.chunks_mutex.unlock(); if (chunk_data.chunk.state == .meshing and chunk_data.chunk.job_token == job.data.chunk.job_token) { - if (self.gpu.shouldUseGpuMeshReadyPath()) { + if (self.gpu.shouldUseGpuMeshReadyPath() and !chunk_data.chunk.force_cpu_mesh) { self.storage.chunks_mutex.lock(); const publishable = if (self.storage.chunks.get(ChunkKey{ .x = cx, .z = cz })) |data| data.chunk.state == .meshing and data.chunk.job_token == job.data.chunk.job_token and mesh_revisions.matches(&data.chunk, neighbors) else false; chunk_data.chunk.state = if (publishable) .mesh_ready else .generated; + if (publishable) chunk_data.chunk.dirty = false; self.storage.chunks_mutex.unlock(); if (!publishable) { self.enqueuePendingMesh(cx, cz, chunk_data.chunk.job_token); @@ -710,6 +746,7 @@ pub const ChunkQueueCoordinator = struct { else false; chunk_data.chunk.state = if (publishable) .mesh_ready else .generated; + if (publishable) chunk_data.chunk.dirty = false; self.storage.chunks_mutex.unlock(); if (!publishable) { self.enqueuePendingMesh(cx, cz, chunk_data.chunk.job_token); @@ -723,24 +760,65 @@ pub const ChunkQueueCoordinator = struct { fn markNeighborsForRemesh(self: *ChunkQueueCoordinator, cx: i32, cz: i32) void { const offsets = [_][2]i32{ .{ 0, 1 }, .{ 0, -1 }, .{ 1, 0 }, .{ -1, 0 } }; - var enqueue_refs: [4]PendingMeshRef = undefined; - var enqueue_count: usize = 0; - self.storage.chunks_mutex.lock(); for (offsets) |off| { if (self.storage.chunks.get(ChunkKey{ .x = cx + off[0], .z = cz + off[1] })) |data| { switch (data.chunk.state) { - .renderable => { - data.chunk.state = .generated; - enqueue_refs[enqueue_count] = .{ .x = data.chunk.chunk_x, .z = data.chunk.chunk_z, .job_token = data.chunk.job_token }; - enqueue_count += 1; - }, + .renderable => data.chunk.state = .generated, .mesh_ready, .uploading, .meshing => data.chunk.dirty = true, else => {}, } } } self.storage.chunks_mutex.unlock(); + } + + /// Coalesce startup boundary invalidations by waiting until each generated + /// chunk's currently-required cardinal neighbors exist before its first or + /// replacement mesh is queued. The periodic recovery scan remains the + /// fallback for failed generation or dropped notifications. + fn enqueueReadyNeighborhood(self: *ChunkQueueCoordinator, cx: i32, cz: i32) void { + const offsets = [_][2]i32{ .{ 0, 0 }, .{ 0, 1 }, .{ 0, -1 }, .{ 1, 0 }, .{ -1, 0 } }; + const neighbor_offsets = [_][2]i32{ .{ 0, 1 }, .{ 0, -1 }, .{ 1, 0 }, .{ -1, 0 } }; + const pc_x = self.last_pc_x.load(.acquire); + const pc_z = self.last_pc_z.load(.acquire); + const render_dist = self.effective_render_dist.load(.acquire); + + var enqueue_refs: [offsets.len]PendingMeshRef = undefined; + var enqueue_count: usize = 0; + + self.storage.chunks_mutex.lockShared(); + for (offsets) |off| { + const candidate_x = cx + off[0]; + const candidate_z = cz + off[1]; + const data = self.storage.chunks.get(ChunkKey{ .x = candidate_x, .z = candidate_z }) orelse continue; + if (data.chunk.state != .generated) continue; + + var neighborhood_ready = true; + for (neighbor_offsets) |neighbor_off| { + const neighbor_x = candidate_x + neighbor_off[0]; + const neighbor_z = candidate_z + neighbor_off[1]; + const dx: i64 = @as(i64, neighbor_x) - pc_x; + const dz: i64 = @as(i64, neighbor_z) - pc_z; + const render_dist_i64: i64 = render_dist; + if (dx * dx + dz * dz > render_dist_i64 * render_dist_i64) continue; + + const neighbor = self.storage.chunks.get(ChunkKey{ .x = neighbor_x, .z = neighbor_z }); + if (neighbor == null or !neighbor.?.chunk.generated) { + neighborhood_ready = false; + break; + } + } + if (!neighborhood_ready) continue; + + enqueue_refs[enqueue_count] = .{ + .x = candidate_x, + .z = candidate_z, + .job_token = data.chunk.job_token, + }; + enqueue_count += 1; + } + self.storage.chunks_mutex.unlockShared(); // Enqueue outside chunks_mutex to keep lock ordering consistent with // drainPendingMesh (pending mutex first, then chunks_mutex). diff --git a/modules/world-runtime/src/gpu_acceleration_coordinator.zig b/modules/world-runtime/src/gpu_acceleration_coordinator.zig index 360c0b43..e2905363 100644 --- a/modules/world-runtime/src/gpu_acceleration_coordinator.zig +++ b/modules/world-runtime/src/gpu_acceleration_coordinator.zig @@ -13,6 +13,12 @@ fn getenv(name: [:0]const u8) ?[]const u8 { } pub const GpuAccelerationCoordinator = struct { + pub const QueueResult = enum { + unavailable, + queued, + deferred, + }; + gpu_block_buffer: ?*GpuBlockBuffer, gpu_mesher: ?*GpuMesher, @@ -64,9 +70,10 @@ pub const GpuAccelerationCoordinator = struct { /// Queues GPU meshing and updates the chunk state for success or CPU retry. /// Caller must uphold the main-thread upload invariant documented by ChunkQueueCoordinator. - pub fn queueGpuMesh(self: *GpuAccelerationCoordinator, data: *ChunkData) bool { - if (!self.isGpuMeshingEnabled()) return false; - const buf = self.gpu_block_buffer orelse return false; + pub fn queueGpuMesh(self: *GpuAccelerationCoordinator, data: *ChunkData) QueueResult { + if (data.chunk.force_cpu_mesh) return .unavailable; + if (!self.isGpuMeshingEnabled()) return .unavailable; + const buf = self.gpu_block_buffer orelse return .unavailable; const mesher = self.gpu_mesher.?; const slot = if (buf.getSlotForChunk(data.chunk.chunk_x, data.chunk.chunk_z)) |existing| @@ -74,24 +81,25 @@ pub const GpuAccelerationCoordinator = struct { else buf.allocate(data.chunk.chunk_x, data.chunk.chunk_z) catch |err| { log.log.err("GpuBlockBuffer allocation failed for chunk ({}, {}): {}", .{ data.chunk.chunk_x, data.chunk.chunk_z, err }); - data.chunk.state = .generated; - return true; + data.chunk.state = .mesh_ready; + return .deferred; }; const blocks_slice: []const u8 = @as([]const u8, @ptrCast(&data.chunk.blocks)); buf.upload(slot, blocks_slice) catch |upload_err| { log.log.err("GpuBlockBuffer upload failed for chunk ({}, {}): {}", .{ data.chunk.chunk_x, data.chunk.chunk_z, upload_err }); buf.free(slot); - data.chunk.state = .generated; - return true; + data.chunk.state = .mesh_ready; + return .deferred; }; if (mesher.queueMesh(data.chunk.chunk_x, data.chunk.chunk_z, slot, data.chunk.job_token)) { data.chunk.state = .uploading; + return .queued; } else { data.chunk.state = .mesh_ready; + return .deferred; } - return true; } pub fn freeChunk(self: *GpuAccelerationCoordinator, cx: i32, cz: i32) void { diff --git a/modules/world-runtime/src/gpu_mesher.zig b/modules/world-runtime/src/gpu_mesher.zig index 86715be9..e263851f 100644 --- a/modules/world-runtime/src/gpu_mesher.zig +++ b/modules/world-runtime/src/gpu_mesher.zig @@ -51,6 +51,8 @@ pub const GpuMesherStats = struct { }; pub const GpuMesher = struct { + pub const RemeshCallback = *const fn (context: *anyopaque, cx: i32, cz: i32, job_token: u32) void; + allocator: std.mem.Allocator, rhi: rhi_pkg.RHI, rm: rhi_pkg.ResourceManager, @@ -67,6 +69,8 @@ pub const GpuMesher = struct { submitted: [MAX_FRAMES_IN_FLIGHT]std.ArrayListUnmanaged(ChunkMeshRequest), stats: GpuMesherStats, + remesh_context: ?*anyopaque = null, + remesh_callback: ?RemeshCallback = null, pub fn init( allocator: std.mem.Allocator, @@ -145,6 +149,11 @@ pub const GpuMesher = struct { return self.stats; } + pub fn setRemeshCallback(self: *GpuMesher, context: ?*anyopaque, callback: ?RemeshCallback) void { + self.remesh_context = context; + self.remesh_callback = callback; + } + fn finalizeCompletedMeshes(self: *GpuMesher, vertex_allocator: *GlobalVertexAllocator, storage: *ChunkStorage) void { const fi = self.rhi.query().getFrameIndex(); const prev_fi = (fi + MAX_FRAMES_IN_FLIGHT - 1) % MAX_FRAMES_IN_FLIGHT; @@ -164,8 +173,10 @@ pub const GpuMesher = struct { return; }; + var remesh_requests: [MAX_GPU_MESH_BATCH]ChunkMeshRequest = undefined; + var remesh_count: usize = 0; + storage.chunks_mutex.lock(); - defer storage.chunks_mutex.unlock(); for (self.submitted[prev_fi].items, 0..) |req, idx| { if (idx >= results.len) break; @@ -176,7 +187,10 @@ pub const GpuMesher = struct { if (result.overflow_mask != 0) { log.log.warn("GpuMesher overflow for chunk ({}, {}), falling back to CPU meshing", .{ req.cx, req.cz }); + data.chunk.force_cpu_mesh = true; data.chunk.state = .generated; + remesh_requests[remesh_count] = req; + remesh_count += 1; continue; } @@ -191,19 +205,28 @@ pub const GpuMesher = struct { if (result.solid_count > 0 and solid_alloc == null) { log.log.warn("GPU_MESHER: ({},{}) FAILED to reserve solid allocation ({} verts)", .{ req.cx, req.cz, result.solid_count }); freeTempAllocations(vertex_allocator, solid_alloc, cutout_alloc, fluid_alloc); + data.chunk.force_cpu_mesh = true; data.chunk.state = .generated; + remesh_requests[remesh_count] = req; + remesh_count += 1; continue; } if (result.cutout_count > 0 and cutout_alloc == null) { log.log.warn("GPU_MESHER: ({},{}) FAILED to reserve cutout allocation ({} verts)", .{ req.cx, req.cz, result.cutout_count }); freeTempAllocations(vertex_allocator, solid_alloc, cutout_alloc, fluid_alloc); + data.chunk.force_cpu_mesh = true; data.chunk.state = .generated; + remesh_requests[remesh_count] = req; + remesh_count += 1; continue; } if (result.fluid_count > 0 and fluid_alloc == null) { log.log.warn("GPU_MESHER: ({},{}) FAILED to reserve fluid allocation ({} verts)", .{ req.cx, req.cz, result.fluid_count }); freeTempAllocations(vertex_allocator, solid_alloc, cutout_alloc, fluid_alloc); + data.chunk.force_cpu_mesh = true; data.chunk.state = .generated; + remesh_requests[remesh_count] = req; + remesh_count += 1; continue; } @@ -223,13 +246,23 @@ pub const GpuMesher = struct { } data.render.mesh.replaceAllocations(vertex_allocator, solid_alloc, cutout_alloc, fluid_alloc); - data.chunk.state = .renderable; - data.chunk.dirty = false; + data.chunk.state = if (data.chunk.dirty) .generated else .renderable; + if (data.chunk.dirty) { + remesh_requests[remesh_count] = req; + remesh_count += 1; + } self.stats.vertices_produced += result.solid_count + result.cutout_count + result.fluid_count; } } self.submitted[prev_fi].clearRetainingCapacity(); + storage.chunks_mutex.unlock(); + + if (self.remesh_context) |context| if (self.remesh_callback) |callback| { + for (remesh_requests[0..remesh_count]) |req| { + callback(context, req.cx, req.cz, req.job_token); + } + }; } fn dispatchQueuedMeshes(self: *GpuMesher, gpu_block_buffer: *GpuBlockBuffer) void { diff --git a/modules/world-runtime/src/world.zig b/modules/world-runtime/src/world.zig index f8c5c9f7..2b3b875d 100644 --- a/modules/world-runtime/src/world.zig +++ b/modules/world-runtime/src/world.zig @@ -618,6 +618,7 @@ pub const World = struct { allocator: std.mem.Allocator, generator: Generator, render_distance: i32, + lod_chunk_render_radius_limit: i32, horizon_distance: i32, rhi: RHI, paused: bool = false, @@ -671,6 +672,7 @@ pub const World = struct { .renderer = undefined, .allocator = allocator, .render_distance = safe_render_distance, + .lod_chunk_render_radius_limit = streamer_render_distance, .horizon_distance = if (options.lod_config) |lod_config| lod_config.getRadii()[LODLevel.count - 1] else LODConfig.default_horizon_radius, .generator = try registry.createGenerator(options.generator_index, options.seed, allocator), .rhi = options.rhi, @@ -880,17 +882,36 @@ pub const World = struct { } log.log.info("Render distance changed: {} -> {}", .{ self.render_distance, target }); self.render_distance = target; - self.streamer.setRenderDistance(target); + self.applyRenderDistance(); + } + } - if (self.lod) |lod| { - const radii = LODConfig.radiiForDistances(target, self.horizon_distance); - lod.setChunkRenderRadius(target); - lod.setRadii(radii); - lod.setActiveLODCount(LODConfig.activeCountForRenderDistance(target)); - } + /// Updates the preset-owned full-detail radius cap. This is separate from + /// the user-facing distance so manual values above a preset's LOD0 radius + /// still expand the horizon rather than flooding full-detail chunks. + pub fn setLODChunkRenderRadiusLimit(self: *World, limit: i32) void { + const target = @max(limit, 1); + if (self.lod_chunk_render_radius_limit == target) return; + self.lod_chunk_render_radius_limit = target; + self.applyRenderDistance(); + } + + fn applyRenderDistance(self: *World) void { + const chunk_render_radius = effectiveChunkRenderRadius(self.render_distance, self.lod_chunk_render_radius_limit, self.lod != null); + self.streamer.setRenderDistance(chunk_render_radius); + + if (self.lod) |lod| { + const radii = LODConfig.radiiForDistances(self.render_distance, self.horizon_distance); + lod.setChunkRenderRadius(chunk_render_radius); + lod.setRadii(radii); + lod.setActiveLODCount(LODConfig.activeCountForRenderDistance(self.render_distance)); } } + pub fn effectiveChunkRenderRadius(render_distance: i32, preset_limit: i32, lod_enabled: bool) i32 { + return if (lod_enabled) @min(render_distance, preset_limit) else render_distance; + } + /// Changes the distant-terrain horizon distance. /// LOD queues and visibility update on subsequent world ticks. pub fn setHorizonDistance(self: *World, distance: i32) void { diff --git a/modules/world-runtime/src/world_facade_tests.zig b/modules/world-runtime/src/world_facade_tests.zig index 9fc68d37..3ea1884c 100644 --- a/modules/world-runtime/src/world_facade_tests.zig +++ b/modules/world-runtime/src/world_facade_tests.zig @@ -10,6 +10,13 @@ const LpvGridBuilder = @import("lpv_grid_builder.zig").LpvGridBuilder; const RenderLayer = @import("world_renderer.zig").RenderLayer; const WorldMutationCoordinator = @import("world_mutation.zig").WorldMutationCoordinator; const SaveManager = @import("world-persistence").SaveManager; +const World = world_mod.World; + +test "full-detail radius follows active preset cap" { + try testing.expectEqual(@as(i32, 12), World.effectiveChunkRenderRadius(16, 12, true)); + try testing.expectEqual(@as(i32, 16), World.effectiveChunkRenderRadius(16, 16, true)); + try testing.expectEqual(@as(i32, 22), World.effectiveChunkRenderRadius(22, 10, false)); +} const MockWorld = struct { update_count: u32 = 0, diff --git a/modules/world-runtime/src/world_streamer.zig b/modules/world-runtime/src/world_streamer.zig index f1b9e1e0..67c55087 100644 --- a/modules/world-runtime/src/world_streamer.zig +++ b/modules/world-runtime/src/world_streamer.zig @@ -103,16 +103,20 @@ pub const WorldStreamer = struct { last_missing_scan_pc_x: i32 = 0, last_missing_scan_pc_z: i32 = 0, last_missing_scan_render_dist: i32 = 0, + has_processed_unloads: bool = false, + last_unload_pc_x: i32 = 0, + last_unload_pc_z: i32 = 0, + last_unload_render_dist: i32 = 0, last_diag_generated: u64 = 0, last_diag_meshed: u64 = 0, last_diag_uploaded: u64 = 0, const MIN_GEN_WORKERS = 2; - const MAX_GEN_WORKERS = 4; + const MAX_GEN_WORKERS = 10; const MIN_MESH_WORKERS = 2; - const MAX_MESH_WORKERS = 6; - const MIN_LOD_WORKERS = 2; - const MAX_LOD_WORKERS = 6; + const MAX_MESH_WORKERS = 8; + const MIN_LOD_WORKERS = 1; + const MAX_LOD_WORKERS = 8; pub fn init(allocator: std.mem.Allocator, storage: *ChunkStorage, generator: Generator, atlas: *const TextureAtlas, render_distance: i32, lod_enabled: bool, vertex_allocator: *GlobalVertexAllocator, max_uploads_per_frame: usize, gpu_block_buffer: ?*GpuBlockBuffer, gpu_mesher: ?*GpuMesher) !*WorldStreamer { const streamer = try allocator.create(WorldStreamer); @@ -124,14 +128,18 @@ pub const WorldStreamer = struct { // This keeps horizon generation responsive without creating two pools // that each try to saturate every core. const cpu_count = std.Thread.getCpuCount() catch MIN_GEN_WORKERS + MIN_MESH_WORKERS; - const total_budget = @max(@as(usize, 4), cpu_count -| 1); + const total_budget = @max(@as(usize, 5), cpu_count -| 1); const requested_lod_workers = if (lod_enabled) std.math.clamp(cpu_count / 2, MIN_LOD_WORKERS, MAX_LOD_WORKERS) else 0; const lod_worker_reserve = @min(requested_lod_workers, total_budget -| (MIN_GEN_WORKERS + MIN_MESH_WORKERS)); const foreground_budget = total_budget - lod_worker_reserve; - const default_gen = std.math.clamp(foreground_budget / 2, MIN_GEN_WORKERS, MAX_GEN_WORKERS); + // Generation is the dominant startup stage after coalescing boundary + // remeshes, so allocate roughly two thirds of the foreground workers + // to generation and the remainder to meshing. + const gen_capacity = @max(MIN_GEN_WORKERS, foreground_budget -| MIN_MESH_WORKERS); + const default_gen = @min(std.math.clamp((foreground_budget * 2 + 2) / 3, MIN_GEN_WORKERS, MAX_GEN_WORKERS), gen_capacity); const default_mesh = std.math.clamp(foreground_budget - default_gen, MIN_MESH_WORKERS, MAX_MESH_WORKERS); const gen_worker_count = engine_core.envInt("ZIGCRAFT_GEN_WORKERS", default_gen); const mesh_worker_count = engine_core.envInt("ZIGCRAFT_MESH_WORKERS", default_mesh); @@ -180,11 +188,13 @@ pub const WorldStreamer = struct { errdefer streamer.mesh_pool.deinit(); try streamer.warmupInitialChunks(); + if (gpu_mesher) |mesher| mesher.setRemeshCallback(&streamer.queue_coordinator, enqueueGpuRemesh); return streamer; } pub fn deinit(self: *WorldStreamer) void { + if (self.gpu_acceleration.gpu_mesher) |mesher| mesher.setRemeshCallback(null, null); self.gen_queue.stop(); self.mesh_queue.stop(); @@ -200,6 +210,11 @@ pub const WorldStreamer = struct { self.allocator.destroy(self); } + fn enqueueGpuRemesh(context: *anyopaque, cx: i32, cz: i32, job_token: u32) void { + const coordinator: *ChunkQueueCoordinator = @ptrCast(@alignCast(context)); + coordinator.enqueuePendingMesh(cx, cz, job_token); + } + pub fn setPaused(self: *WorldStreamer, paused: bool) void { self.paused = paused; self.gen_queue.setPaused(paused); @@ -209,6 +224,7 @@ pub const WorldStreamer = struct { self.queue_coordinator.resetPausedChunks(); } else { self.lod_coordinator.forceRescan(); + self.has_processed_unloads = false; } } @@ -221,11 +237,35 @@ pub const WorldStreamer = struct { } pub fn isStartupBusy(self: *WorldStreamer, target_render_dist: i32) bool { - return self.lod_coordinator.isStartupBusy(self.getStats(), target_render_dist); + if (self.lod_coordinator.isStartupBusy(self.getStats(), target_render_dist)) return true; + + const radius = @min(target_render_dist, self.lod_coordinator.targetRenderDistance()); + const pc_x = self.lod_coordinator.last_pc.x; + const pc_z = self.lod_coordinator.last_pc.z; + self.storage.chunks_mutex.lockShared(); + defer self.storage.chunks_mutex.unlockShared(); + + var cz = pc_z - radius; + while (cz <= pc_z + radius) : (cz += 1) { + var cx = pc_x - radius; + while (cx <= pc_x + radius) : (cx += 1) { + const dx: i64 = @as(i64, cx) - pc_x; + const dz: i64 = @as(i64, cz) - pc_z; + const radius_i64: i64 = radius; + if (dx * dx + dz * dz > radius_i64 * radius_i64) continue; + const data = self.storage.chunks.get(.{ .x = cx, .z = cz }) orelse return true; + if (data.chunk.state != .renderable or !data.render.mesh.ready) return true; + } + } + return false; } fn warmupInitialChunks(self: *WorldStreamer) !void { - const warmup_radius: i32 = 1; + // Keep only the center chunk on the synchronous startup path. The + // normal prioritized worker pipeline fills the surrounding ring after + // the first frame instead of serially generating and meshing nine + // chunks before the world can appear. + const warmup_radius: i32 = 0; var cz: i32 = -warmup_radius; while (cz <= warmup_radius) : (cz += 1) { @@ -343,9 +383,26 @@ pub const WorldStreamer = struct { log.log.warn("updateStreaming error (non-fatal): {}", .{err}); }; self.queue_coordinator.processUploads(); - self.processUnloads(player_pos) catch |err| { - log.log.warn("processUnloads error (non-fatal): {}", .{err}); - }; + const pc = worldToChunkFromFloat(player_pos.x, player_pos.z); + const unload_render_dist = self.lod_coordinator.targetRenderDistance(); + const needs_unload_scan = !self.has_processed_unloads or + self.last_unload_pc_x != pc.chunk_x or + self.last_unload_pc_z != pc.chunk_z or + self.last_unload_render_dist != unload_render_dist or + self.frame_counter % 60 == 0; + if (needs_unload_scan) { + var unload_succeeded = true; + self.processUnloads(player_pos) catch |err| { + log.log.warn("processUnloads error (non-fatal): {}", .{err}); + unload_succeeded = false; + }; + if (unload_succeeded) { + self.has_processed_unloads = true; + self.last_unload_pc_x = pc.chunk_x; + self.last_unload_pc_z = pc.chunk_z; + self.last_unload_render_dist = unload_render_dist; + } + } if (self.frame_counter % 300 == 0) { self.logChunkStateSummary(); } @@ -438,7 +495,7 @@ pub const WorldStreamer = struct { self.gpu_acceleration.refreshForceCpuMeshing(self.frame_counter, self.storage); const frame = self.lod_coordinator.beginFrame(self.storage, self.gen_queue, self.mesh_queue, player_pos, dt, self.frame_counter); - self.queue_coordinator.setView(frame.pc_x, frame.pc_z, frame.render_dist); + self.queue_coordinator.setView(frame.pc_x, frame.pc_z, frame.stream_dist); if (self.frame_counter % 600 == 0) { self.logMissingChunkDiagnostic(frame.pc_x, frame.pc_z); @@ -450,18 +507,18 @@ pub const WorldStreamer = struct { const needs_missing_scan = !self.has_scanned_missing_chunks or self.last_missing_scan_pc_x != frame.pc_x or self.last_missing_scan_pc_z != frame.pc_z or - self.last_missing_scan_render_dist != frame.render_dist or + self.last_missing_scan_render_dist != frame.stream_dist or self.frame_counter % 60 == 0; if (needs_missing_scan) { - self.queue_coordinator.scanForMissingChunks(frame.pc_x, frame.pc_z, frame.render_dist, frame.movement) catch |err| { + self.queue_coordinator.scanForMissingChunks(frame.pc_x, frame.pc_z, frame.stream_dist, frame.movement) catch |err| { log.log.warn("scanForMissingChunks error (non-fatal): {}", .{err}); }; self.has_scanned_missing_chunks = true; self.last_missing_scan_pc_x = frame.pc_x; self.last_missing_scan_pc_z = frame.pc_z; - self.last_missing_scan_render_dist = frame.render_dist; + self.last_missing_scan_render_dist = frame.stream_dist; } - self.queue_coordinator.processChunkStates(frame.pc_x, frame.pc_z, frame.render_dist, self.frame_counter); + self.queue_coordinator.processChunkStates(frame.pc_x, frame.pc_z, frame.stream_dist, self.frame_counter); self.lod_coordinator.updateLOD(player_pos, self.storage); if (!self.lod_coordinator.startup_mesh_finalized and !self.isStartupBusy(self.lod_coordinator.render_distance)) { @@ -481,16 +538,21 @@ pub const WorldStreamer = struct { } fn finalizeChunkMesh(self: *WorldStreamer, cx: i32, cz: i32) void { - self.storage.chunks_mutex.lockShared(); + self.storage.chunks_mutex.lock(); const chunk_data = self.storage.chunks.get(.{ .x = cx, .z = cz }) orelse { - self.storage.chunks_mutex.unlockShared(); + self.storage.chunks_mutex.unlock(); return; }; - if (!chunk_data.chunk.generated and chunk_data.chunk.state != .renderable) { - self.storage.chunks_mutex.unlockShared(); + // Claim mesh ownership through the same state machine used by workers. + // Pins prevent eviction, but do not prevent a queued worker from + // mutating the mesh concurrently. + const previous_state = chunk_data.chunk.state; + if (previous_state != .generated and previous_state != .renderable) { + self.storage.chunks_mutex.unlock(); return; } + chunk_data.chunk.state = .meshing; chunk_data.chunk.pin(); const neighbors = NeighborChunks{ @@ -511,7 +573,7 @@ pub const WorldStreamer = struct { break :d &d.chunk; } else null, }; - self.storage.chunks_mutex.unlockShared(); + self.storage.chunks_mutex.unlock(); defer { chunk_data.chunk.unpin(); @@ -523,15 +585,24 @@ pub const WorldStreamer = struct { chunk_data.render.mesh.buildWithNeighbors(&chunk_data.chunk, neighbors, self.atlas) catch |err| { log.log.warn("STARTUP_FINALIZE_MESH_FAILED: ({},{}) {}", .{ cx, cz, err }); + self.storage.chunks_mutex.lock(); + if (self.storage.chunks.get(.{ .x = cx, .z = cz })) |data| { + if (data.chunk.state == .meshing) data.chunk.state = previous_state; + } + self.storage.chunks_mutex.unlock(); return; }; chunk_data.render.mesh.upload(self.vertex_allocator); self.storage.chunks_mutex.lock(); if (self.storage.chunks.get(.{ .x = cx, .z = cz })) |data| { - data.chunk.state = .renderable; - data.chunk.dirty = false; - data.chunk.mesh_attempts = 0; + if (data.chunk.state == .meshing) { + data.chunk.state = if (data.render.mesh.ready) .renderable else previous_state; + if (data.render.mesh.ready) { + data.chunk.dirty = false; + data.chunk.mesh_attempts = 0; + } + } } self.storage.chunks_mutex.unlock(); } diff --git a/src/integration_test.zig b/src/integration_test.zig index 041dc10b..f400af22 100644 --- a/src/integration_test.zig +++ b/src/integration_test.zig @@ -104,6 +104,7 @@ fn initStorageOnlyPersistenceWorld(allocator: std.mem.Allocator) world_runtime.W .allocator = allocator, .generator = undefined, .render_distance = 8, + .lod_chunk_render_radius_limit = 8, .horizon_distance = 512, .rhi = undefined, .paused = false, diff --git a/src/job_system_tests.zig b/src/job_system_tests.zig index 96a65f13..77784859 100644 --- a/src/job_system_tests.zig +++ b/src/job_system_tests.zig @@ -109,3 +109,18 @@ test "Job.cleanup is no-op for chunk jobs (signature change)" { job.cleanup(); try testing.expectEqual(@as(usize, 0), test_cleanup_count); } + +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); +}