From 665dc7f7b32abd3ed16fbb0b8806c125402ebd78 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sun, 3 May 2026 22:01:27 +0100 Subject: [PATCH] feat: add LOD quality diagnostics --- docs/lod-quality-controls.md | 23 ++++ modules/engine-core/src/job_system.zig | 10 +- modules/engine-rhi/src/render_settings.zig | 27 +++++ modules/engine-rhi/src/root.zig | 1 + modules/game-core/src/session.zig | 3 + modules/world-lod/src/lod_chunk.zig | 50 +++++++++ modules/world-lod/src/lod_manager.zig | 116 ++++++++++++++++++--- modules/world-lod/src/lod_stats.zig | 27 +++++ 8 files changed, 240 insertions(+), 17 deletions(-) create mode 100644 docs/lod-quality-controls.md diff --git a/docs/lod-quality-controls.md b/docs/lod-quality-controls.md new file mode 100644 index 00000000..d641bdfd --- /dev/null +++ b/docs/lod-quality-controls.md @@ -0,0 +1,23 @@ +# LOD Quality Controls + +The render distance preset is the supported user-facing control for distant LOD quality. Presets intentionally expose a small set of stable knobs: + +- `lod_radii`: chunk radii for LOD0 through LOD3. +- `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. +- `vertical_span_budget`: enables rich column/span source data when greater than zero. Defaults keep this disabled to preserve current memory and generation cost. +- `mesh_path`: selects the stable heightfield path by default. `column_spans` and `qem` are available for controlled testing. +- `fog_start_percent`: controls the fade band for each LOD level. +- `memory_budget_mb` and `max_uploads_per_frame`: bound cache pressure and per-frame GPU upload work. + +Default presets preserve existing performance expectations by keeping `mesh_path = .heightfield` and `vertical_span_budget = 0`. + +## Diagnostics + +Set `ZIGCRAFT_LOD_DIAG=1` to log LOD queue, render, and aggregate stats diagnostics. The output includes generation and upload queue depth, cache hits and misses, cache hit rate, mesh counts, vertex counts, visible region filtering, and fallback/culling reasons. + +Runtime mesh path overrides are available while alternate mesh paths stabilize: + +- `ZIGCRAFT_LOD_MESH_PATH_QEM=1` forces the QEM decimation path. +- `ZIGCRAFT_LOD_MESH_PATH_SPANS=1` forces the column/span mesh path. + +Use the stable heightfield defaults for normal gameplay. Use the override flags only when comparing visual quality or diagnosing LOD mesh regressions. diff --git a/modules/engine-core/src/job_system.zig b/modules/engine-core/src/job_system.zig index be10e2b0..26f00a12 100644 --- a/modules/engine-core/src/job_system.zig +++ b/modules/engine-core/src/job_system.zig @@ -177,6 +177,12 @@ pub const JobQueue = struct { self.cond.signal(); } + pub fn count(self: *JobQueue) usize { + self.mutex.lock(); + defer self.mutex.unlock(); + return self.jobs.count(); + } + pub fn pop(self: *JobQueue) ?Job { self.mutex.lock(); defer self.mutex.unlock(); @@ -198,8 +204,8 @@ pub const JobQueue = struct { /// Internal: rebuild queue with updated distances (called under lock) fn doReprioritize(self: *JobQueue) void { - const count = self.jobs.count(); - if (count == 0) return; + const job_count = self.jobs.count(); + if (job_count == 0) return; var temp = std.ArrayListUnmanaged(Job).empty; defer temp.deinit(self.allocator); diff --git a/modules/engine-rhi/src/render_settings.zig b/modules/engine-rhi/src/render_settings.zig index f9c66d5e..6b66464e 100644 --- a/modules/engine-rhi/src/render_settings.zig +++ b/modules/engine-rhi/src/render_settings.zig @@ -23,8 +23,20 @@ pub const RenderDistancePreset = enum(u32) { } }; +pub const LODMeshPath = enum(u8) { + /// Stable heightfield mesh path used by current defaults. + heightfield, + /// Rich column/span mesh path for vertical detail where source data provides spans. + column_spans, + /// Quadric error metric decimation path for experimentation while it stabilizes. + qem, +}; + pub const RenderDistancePresetConfig = struct { lod_radii: [LODLevel.count]i32, + horizontal_detail: [LODLevel.count]u32, + vertical_span_budget: u8, + mesh_path: LODMeshPath, fog_start_percent: [LODLevel.count]f32, active_lod_count: u32, qem_targets: [LODLevel.count]u32, @@ -38,6 +50,9 @@ pub const RenderDistancePresetConfig = struct { pub const RENDER_DISTANCE_PRESETS = [_]RenderDistancePresetConfig{ .{ .lod_radii = .{ 6, 6, 6, 160 }, + .horizontal_detail = .{ 16, 32, 48, 48 }, + .vertical_span_budget = 0, + .mesh_path = .heightfield, .fog_start_percent = .{ 0.5, 0.5, 0.4, 0.3 }, .active_lod_count = 4, .qem_targets = .{ 0, 1200, 300, 48 }, @@ -49,6 +64,9 @@ pub const RENDER_DISTANCE_PRESETS = [_]RenderDistancePresetConfig{ }, .{ .lod_radii = .{ 10, 10, 10, 160 }, + .horizontal_detail = .{ 16, 32, 48, 48 }, + .vertical_span_budget = 0, + .mesh_path = .heightfield, .fog_start_percent = .{ 0.5, 0.5, 0.4, 0.4 }, .active_lod_count = 4, .qem_targets = .{ 0, 2000, 800, 200 }, @@ -60,6 +78,9 @@ pub const RENDER_DISTANCE_PRESETS = [_]RenderDistancePresetConfig{ }, .{ .lod_radii = .{ 12, 12, 12, 160 }, + .horizontal_detail = .{ 16, 32, 48, 48 }, + .vertical_span_budget = 0, + .mesh_path = .heightfield, .fog_start_percent = .{ 0.5, 0.5, 0.4, 0.3 }, .active_lod_count = 4, .qem_targets = .{ 0, 2000, 800, 200 }, @@ -71,6 +92,9 @@ pub const RENDER_DISTANCE_PRESETS = [_]RenderDistancePresetConfig{ }, .{ .lod_radii = .{ 14, 14, 14, 160 }, + .horizontal_detail = .{ 16, 32, 48, 48 }, + .vertical_span_budget = 0, + .mesh_path = .heightfield, .fog_start_percent = .{ 0.5, 0.5, 0.4, 0.3 }, .active_lod_count = 4, .qem_targets = .{ 0, 2000, 800, 200 }, @@ -82,6 +106,9 @@ pub const RENDER_DISTANCE_PRESETS = [_]RenderDistancePresetConfig{ }, .{ .lod_radii = .{ 16, 16, 16, 160 }, + .horizontal_detail = .{ 16, 32, 48, 48 }, + .vertical_span_budget = 0, + .mesh_path = .heightfield, .fog_start_percent = .{ 0.5, 0.5, 0.4, 0.3 }, .active_lod_count = 4, .qem_targets = .{ 0, 2000, 800, 200 }, diff --git a/modules/engine-rhi/src/root.zig b/modules/engine-rhi/src/root.zig index 562735df..9634e856 100644 --- a/modules/engine-rhi/src/root.zig +++ b/modules/engine-rhi/src/root.zig @@ -62,6 +62,7 @@ pub const Texture = texture.Texture; pub const Config = texture.Config; pub const RenderDistancePreset = render_settings.RenderDistancePreset; pub const RenderDistancePresetConfig = render_settings.RenderDistancePresetConfig; +pub const LODMeshPath = render_settings.LODMeshPath; pub const RENDER_DISTANCE_PRESETS = render_settings.RENDER_DISTANCE_PRESETS; pub const RenderSettingsAdapter = render_settings.RenderSettingsAdapter; pub const getPresetConfig = render_settings.getPresetConfig; diff --git a/modules/game-core/src/session.zig b/modules/game-core/src/session.zig index 1b4f4af5..0ecab9cd 100644 --- a/modules/game-core/src/session.zig +++ b/modules/game-core/src/session.zig @@ -147,6 +147,9 @@ pub const GameSession = struct { LODConfig{ .radii = preset_radii, .fog_start_percent = preset_cfg.fog_start_percent, + .horizontal_detail = preset_cfg.horizontal_detail, + .vertical_span_budget = preset_cfg.vertical_span_budget, + .mesh_path = preset_cfg.mesh_path, .qem_triangle_targets = preset_cfg.qem_targets, .memory_budget_mb = preset_cfg.memory_budget_mb, .max_uploads_per_frame = preset_cfg.max_uploads_per_frame, diff --git a/modules/world-lod/src/lod_chunk.zig b/modules/world-lod/src/lod_chunk.zig index 4ac50633..65533072 100644 --- a/modules/world-lod/src/lod_chunk.zig +++ b/modules/world-lod/src/lod_chunk.zig @@ -12,6 +12,7 @@ const Chunk = world_core.Chunk; const CHUNK_SIZE_X = world_core.CHUNK_SIZE_X; const CHUNK_SIZE_Z = world_core.CHUNK_SIZE_Z; const CHUNK_SIZE_Y = world_core.CHUNK_SIZE_Y; +pub const LODMeshPath = @import("engine-rhi").LODMeshPath; /// LOD level enum - higher values = more simplified pub const LODLevel = @import("lod_types.zig").LODLevel; @@ -219,6 +220,9 @@ pub const ILODConfig = struct { calculateMaskRadius: *const fn (ptr: *anyopaque) f32, getQEMTarget: *const fn (ptr: *anyopaque, lod: LODLevel) u32, getQEMMinInputTriangles: *const fn (ptr: *anyopaque) u32, + getHorizontalDetail: *const fn (ptr: *anyopaque, lod: LODLevel) u32, + getVerticalSpanBudget: *const fn (ptr: *anyopaque) u8, + getMeshPath: *const fn (ptr: *anyopaque) LODMeshPath, getFogStartPercent: *const fn (ptr: *anyopaque, lod: LODLevel) f32, getFallbackMissingChildThreshold: *const fn (ptr: *anyopaque) f32, }; @@ -262,6 +266,18 @@ pub const ILODConfig = struct { return self.vtable.getQEMMinInputTriangles(self.ptr); } + pub fn getHorizontalDetail(self: ILODConfig, lod: LODLevel) u32 { + return self.vtable.getHorizontalDetail(self.ptr, lod); + } + + pub fn getVerticalSpanBudget(self: ILODConfig) u8 { + return self.vtable.getVerticalSpanBudget(self.ptr); + } + + pub fn getMeshPath(self: ILODConfig) LODMeshPath { + return self.vtable.getMeshPath(self.ptr); + } + pub fn getFogStartPercent(self: ILODConfig, lod: LODLevel) f32 { return self.vtable.getFogStartPercent(self.ptr, lod); } @@ -292,6 +308,12 @@ pub const LODConfig = struct { /// Values closer to 0.0 start fog near the player; 1.0 disables fog for that level. fog_start_percent: [LODLevel.count]f32 = .{ 0.55, 0.48, 0.38, 0.28 }, + horizontal_detail: [LODLevel.count]u32 = .{ 16, 32, 48, 48 }, + + vertical_span_budget: u8 = 0, + + mesh_path: LODMeshPath = .heightfield, + qem_triangle_targets: [LODLevel.count]u32 = .{ 0, 2000, 800, 200 }, qem_min_input_triangles: u32 = 50, @@ -363,6 +385,9 @@ pub const LODConfig = struct { .calculateMaskRadius = calculateMaskRadiusWrapper, .getQEMTarget = getQEMTargetWrapper, .getQEMMinInputTriangles = getQEMMinInputTrianglesWrapper, + .getHorizontalDetail = getHorizontalDetailWrapper, + .getVerticalSpanBudget = getVerticalSpanBudgetWrapper, + .getMeshPath = getMeshPathWrapper, .getFogStartPercent = getFogStartPercentWrapper, .getFallbackMissingChildThreshold = getFallbackMissingChildThresholdWrapper, }; @@ -414,6 +439,18 @@ pub const LODConfig = struct { const self: *LODConfig = @ptrCast(@alignCast(ptr)); return self.qem_min_input_triangles; } + fn getHorizontalDetailWrapper(ptr: *anyopaque, lod: LODLevel) u32 { + const self: *LODConfig = @ptrCast(@alignCast(ptr)); + return self.horizontal_detail[@intFromEnum(lod)]; + } + 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))); + } + fn getMeshPathWrapper(ptr: *anyopaque) LODMeshPath { + const self: *LODConfig = @ptrCast(@alignCast(ptr)); + return self.mesh_path; + } fn getFogStartPercentWrapper(ptr: *anyopaque, lod: LODLevel) f32 { const self: *LODConfig = @ptrCast(@alignCast(ptr)); return self.fog_start_percent[@intFromEnum(lod)]; @@ -529,3 +566,16 @@ test "ILODConfig exposes fallback missing child threshold" { config.fallback_missing_child_threshold = 2.0; try std.testing.expectEqual(@as(f32, 1.0), interface.getFallbackMissingChildThreshold()); } + +test "ILODConfig exposes LOD quality tuning controls" { + var config = LODConfig{ + .horizontal_detail = .{ 16, 24, 32, 40 }, + .vertical_span_budget = 99, + .mesh_path = .qem, + }; + 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()); +} diff --git a/modules/world-lod/src/lod_manager.zig b/modules/world-lod/src/lod_manager.zig index c4337b9e..e1890f6b 100644 --- a/modules/world-lod/src/lod_manager.zig +++ b/modules/world-lod/src/lod_manager.zig @@ -119,6 +119,8 @@ pub const LODManager = struct { // Stats stats: LODStats, + cache_hits: u32, + cache_misses: u32, // Mutex for thread safety mutex: sync.RwLock, @@ -212,6 +214,8 @@ pub const LODManager = struct { .player_cz = 0, .next_job_token = 1, .stats = .{}, + .cache_hits = 0, + .cache_misses = 0, .mutex = .{}, .gpu_bridge = gpu_bridge, .generator = generator, @@ -580,12 +584,40 @@ pub const LODManager = struct { // Add mesh memory var mesh_iter = self.meshes[i].iterator(); while (mesh_iter.next()) |entry| { - mem_usage += entry.value_ptr.*.capacity * @sizeOf(Vertex); + const mesh = entry.value_ptr.*; + self.stats.mesh_count[i] += 1; + self.stats.mesh_vertices[i] += mesh.vertex_count; + mem_usage += mesh.capacity * @sizeOf(Vertex); } + + self.stats.gen_queue_depth[i] = @intCast(self.gen_queues[i].count()); + self.stats.upload_queue_depth[i] = @intCast(self.upload_queues[i].count()); } self.stats.addMemory(mem_usage); + self.stats.cache_hits = self.cache_hits; + self.stats.cache_misses = self.cache_misses; self.memory_used_bytes = mem_usage; + + if (engine_core.envFlag("ZIGCRAFT_LOD_DIAG", false)) { + const S = struct { + var counter: u64 = 0; + }; + S.counter += 1; + if (S.counter % 120 == 1) { + log.log.info("LOD_STATS_DIAG gen_q={any} upload_q={any} meshes={any} verts={any} cache_hits={} cache_misses={} cache_hit_rate={d:.2} mem_mb={} upload_failures={}", .{ + self.stats.gen_queue_depth, + self.stats.upload_queue_depth, + self.stats.mesh_count, + self.stats.mesh_vertices, + self.stats.cache_hits, + self.stats.cache_misses, + self.stats.cacheHitRate(), + self.stats.memory_used_mb, + self.stats.upload_failures, + }); + } + } } /// Get current statistics @@ -762,10 +794,21 @@ pub const LODManager = struct { switch (chunk.data) { .simplified => |*data| { const bounds = chunk.worldBounds(); - // QEM decimation currently introduces visible cracks and terraced holes - // in distant terrain. Prefer the stable heightfield path until the - // simplifier preserves continuous coverage again. - try mesh.buildFromSimplifiedData(data, bounds.min_x, bounds.min_z, self.atlas); + switch (self.effectiveMeshPath()) { + .heightfield => try mesh.buildFromSimplifiedData(data, bounds.min_x, bounds.min_z, self.atlas), + .column_spans => try mesh.buildFromColumnSpans(data, bounds.min_x, bounds.min_z, self.atlas), + .qem => { + const lod = chunk.lod_level; + const horizontal_detail = self.config.getHorizontalDetail(lod); + const detail_target = horizontal_detail * horizontal_detail; + const target = @max(self.config.getQEMTarget(lod), detail_target); + if (target == 0) { + try mesh.buildFromSimplifiedData(data, bounds.min_x, bounds.min_z, self.atlas); + } else { + try mesh.buildFromSimplifiedDataWithQEM(data, bounds.min_x, bounds.min_z, target, self.config.getQEMMinInputTriangles(), self.atlas); + } + }, + } }, .full => { // LOD0 meshes handled by World, not LODManager @@ -776,6 +819,12 @@ pub const LODManager = struct { } } + fn effectiveMeshPath(self: *Self) lod_chunk.LODMeshPath { + if (engine_core.envFlag("ZIGCRAFT_LOD_MESH_PATH_QEM", false)) return .qem; + if (engine_core.envFlag("ZIGCRAFT_LOD_MESH_PATH_SPANS", false)) return .column_spans; + return self.config.getMeshPath(); + } + fn cacheKey(self: *const Self, key: LODRegionKey) lod_cache.Key { return .{ .seed = self.generator.seed, @@ -808,6 +857,12 @@ pub const LODManager = struct { }; } + fn cacheEnabled(self: *Self) bool { + self.mutex.lockShared(); + defer self.mutex.unlockShared(); + return self.cache_dir_path != null; + } + fn loadCachedSourceData(self: *Self, key: LODRegionKey) ?LODSimplifiedData { const cache_dir_path = self.cacheDirPathSnapshot() orelse return null; defer self.allocator.free(cache_dir_path); @@ -839,6 +894,18 @@ pub const LODManager = struct { }; } + fn recordCacheHit(self: *Self) void { + self.mutex.lock(); + defer self.mutex.unlock(); + self.cache_hits += 1; + } + + fn recordCacheMiss(self: *Self) void { + self.mutex.lock(); + defer self.mutex.unlock(); + self.cache_misses += 1; + } + fn saveCachedSourceData(self: *Self, key: LODRegionKey, data: *const LODSimplifiedData) void { const cache_dir_path = self.cacheDirPathSnapshot() orelse return; defer self.allocator.free(cache_dir_path); @@ -894,6 +961,8 @@ pub const LODManager = struct { .player_cz = 0, .next_job_token = 1, .stats = .{}, + .cache_hits = 0, + .cache_misses = 0, .mutex = .{}, .gpu_bridge = undefined, .generator = .{ @@ -995,16 +1064,33 @@ pub const LODManager = struct { .chunk_generation => { // Initialize simplified data if needed if (needs_data_init) { - const data = self.loadCachedSourceData(key) orelse blk: { - var generated = LODSimplifiedData.init(self.allocator, lod_level) catch { - new_state = .missing; - chunk.unpin(); - // Acquire lock briefly to update state - self.mutex.lock(); - chunk.state = new_state; - self.mutex.unlock(); - return; - }; + const cache_enabled = self.cacheEnabled(); + const cached_data = if (cache_enabled) self.loadCachedSourceData(key) else null; + const data = if (cached_data) |cached| blk: { + self.recordCacheHit(); + break :blk cached; + } else blk: { + if (cache_enabled) self.recordCacheMiss(); + var generated = if (self.config.getVerticalSpanBudget() > 0) + LODSimplifiedData.initWithVerticalSpans(self.allocator, lod_level) catch { + new_state = .missing; + chunk.unpin(); + // Acquire lock briefly to update state + self.mutex.lock(); + chunk.state = new_state; + self.mutex.unlock(); + return; + } + else + LODSimplifiedData.init(self.allocator, lod_level) catch { + new_state = .missing; + chunk.unpin(); + // Acquire lock briefly to update state + self.mutex.lock(); + chunk.state = new_state; + self.mutex.unlock(); + return; + }; // Generate heightmap data (expensive, done without lock) self.generator.generateHeightmapOnly(&generated, chunk.region_x, chunk.region_z, lod_level); diff --git a/modules/world-lod/src/lod_stats.zig b/modules/world-lod/src/lod_stats.zig index 89b958d8..d4236118 100644 --- a/modules/world-lod/src/lod_stats.zig +++ b/modules/world-lod/src/lod_stats.zig @@ -14,6 +14,12 @@ pub const LODStats = struct { uploading: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, memory_used_mb: u32 = 0, + mesh_count: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, + mesh_vertices: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, + gen_queue_depth: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, + upload_queue_depth: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, + cache_hits: u32 = 0, + cache_misses: u32 = 0, upgrades_pending: u32 = 0, downgrades_pending: u32 = 0, upload_failures: u32 = 0, @@ -38,6 +44,10 @@ pub const LODStats = struct { self.mesh_ready = [_]u32{0} ** LODLevel.count; self.uploading = [_]u32{0} ** LODLevel.count; self.memory_used_mb = 0; + self.mesh_count = [_]u32{0} ** LODLevel.count; + self.mesh_vertices = [_]u32{0} ** LODLevel.count; + self.gen_queue_depth = [_]u32{0} ** LODLevel.count; + self.upload_queue_depth = [_]u32{0} ** LODLevel.count; self.upgrades_pending = 0; self.downgrades_pending = 0; self.upload_failures = 0; @@ -59,4 +69,21 @@ pub const LODStats = struct { const mb = bytes / (1024 * 1024); self.memory_used_mb += @intCast(mb); } + + pub fn cacheHitRate(self: *const LODStats) f32 { + const total = self.cache_hits + self.cache_misses; + if (total == 0) return 0.0; + return @as(f32, @floatFromInt(self.cache_hits)) / @as(f32, @floatFromInt(total)); + } }; + +const std = @import("std"); + +test "LODStats reports cache hit rate" { + var stats = LODStats{}; + try std.testing.expectEqual(@as(f32, 0.0), stats.cacheHitRate()); + + stats.cache_hits = 3; + stats.cache_misses = 1; + try std.testing.expectEqual(@as(f32, 0.75), stats.cacheHitRate()); +}