From 3d1a11d6b97f9ee8b6bf5956d2d9a1a9cb0230c6 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Thu, 2 Apr 2026 22:13:12 +0100 Subject: [PATCH 1/5] feat: integrate quadric decimation into LOD mesh pipeline (#381) Replace naive heightmap downsampling with QEM-based mesh simplification for LOD1/2/3 terrain. Generates full-detail indexed heightmap mesh first, then runs QuadricSimplifier to target triangle counts per LOD level (LOD1: 2000, LOD2: 800, LOD3: 200). Falls back to naive downsample when QEM input is too small or simplification fails. Files modified: - lod_chunk.zig: add QEM config fields to LODConfig/ILODConfig - lod_mesh.zig: add buildFullDetailHeightmapMesh + buildFromSimplifiedDataWithQEM - lod_manager.zig: wire QEM build path through buildMeshForChunk --- src/world/lod_chunk.zig | 35 ++++++- src/world/lod_manager.zig | 4 +- src/world/lod_mesh.zig | 207 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 3 deletions(-) diff --git a/src/world/lod_chunk.zig b/src/world/lod_chunk.zig index 959442f0..35392e33 100644 --- a/src/world/lod_chunk.zig +++ b/src/world/lod_chunk.zig @@ -293,6 +293,8 @@ pub const ILODConfig = struct { isInRange: *const fn (ptr: *anyopaque, dist_chunks: i32) bool, getMaxUploadsPerFrame: *const fn (ptr: *anyopaque) u32, calculateMaskRadius: *const fn (ptr: *anyopaque) f32, + getQEMTarget: *const fn (ptr: *anyopaque, lod: LODLevel) u32, + getQEMMinInputTriangles: *const fn (ptr: *anyopaque) u32, }; pub fn getRadii(self: ILODConfig) [LODLevel.count]i32 { @@ -316,6 +318,14 @@ pub const ILODConfig = struct { pub fn calculateMaskRadius(self: ILODConfig) f32 { return self.vtable.calculateMaskRadius(self.ptr); } + + pub fn getQEMTarget(self: ILODConfig, lod: LODLevel) u32 { + return self.vtable.getQEMTarget(self.ptr, lod); + } + + pub fn getQEMMinInputTriangles(self: ILODConfig) u32 { + return self.vtable.getQEMMinInputTriangles(self.ptr); + } }; /// Concrete implementation of LOD system configuration. @@ -329,11 +339,23 @@ pub const LODConfig = struct { memory_budget_mb: u32 = 256, /// Maximum uploads per frame per LOD level - max_uploads_per_frame: u32 = 8, // Increased from 4 for faster loading + max_uploads_per_frame: u32 = 8, /// Enable fog-masked transitions fog_transitions: bool = true, + /// Target triangle count per LOD region for QEM simplification. + /// Index 0 (LOD0) is unused. Higher LOD levels get fewer triangles. + qem_triangle_targets: [LODLevel.count]u32 = .{ 0, 2000, 800, 200 }, + + /// Minimum triangles required to attempt QEM simplification. + /// Below this threshold the naive heightmap mesh is used directly. + qem_min_input_triangles: u32 = 50, + + pub fn getQEMTarget(self: *const LODConfig, lod: LODLevel) u32 { + return self.qem_triangle_targets[@intFromEnum(lod)]; + } + pub fn getLODForDistance(self: *const LODConfig, dist_chunks: i32) LODLevel { inline for (0..LODLevel.count) |i| { if (dist_chunks <= self.radii[i]) return @enumFromInt(@as(u3, @intCast(i))); @@ -360,6 +382,8 @@ pub const LODConfig = struct { .isInRange = isInRangeWrapper, .getMaxUploadsPerFrame = getMaxUploadsPerFrameWrapper, .calculateMaskRadius = calculateMaskRadiusWrapper, + .getQEMTarget = getQEMTargetWrapper, + .getQEMMinInputTriangles = getQEMMinInputTrianglesWrapper, }; fn getRadiiWrapper(ptr: *anyopaque) [LODLevel.count]i32 { @@ -384,9 +408,16 @@ pub const LODConfig = struct { } fn calculateMaskRadiusWrapper(ptr: *anyopaque) f32 { const self: *LODConfig = @ptrCast(@alignCast(ptr)); - // Return radii[0] - 2.0 to ensure a 2-chunk overlap between LODs and block chunks return @as(f32, @floatFromInt(self.radii[0])) - 2.0; } + fn getQEMTargetWrapper(ptr: *anyopaque, lod: LODLevel) u32 { + const self: *LODConfig = @ptrCast(@alignCast(ptr)); + return self.getQEMTarget(lod); + } + fn getQEMMinInputTrianglesWrapper(ptr: *anyopaque) u32 { + const self: *LODConfig = @ptrCast(@alignCast(ptr)); + return self.qem_min_input_triangles; + } }; // Tests diff --git a/src/world/lod_manager.zig b/src/world/lod_manager.zig index a0f34804..2adea6fa 100644 --- a/src/world/lod_manager.zig +++ b/src/world/lod_manager.zig @@ -842,7 +842,9 @@ pub const LODManager = struct { switch (chunk.data) { .simplified => |*data| { const bounds = chunk.worldBounds(); - try mesh.buildFromSimplifiedData(data, bounds.min_x, bounds.min_z); + const target_tris = self.config.getQEMTarget(chunk.lod_level); + const min_tris = self.config.getQEMMinInputTriangles(); + try mesh.buildFromSimplifiedDataWithQEM(data, bounds.min_x, bounds.min_z, target_tris, min_tris); }, .full => { // LOD0 meshes handled by World, not LODManager diff --git a/src/world/lod_mesh.zig b/src/world/lod_mesh.zig index de733d80..50ba2382 100644 --- a/src/world/lod_mesh.zig +++ b/src/world/lod_mesh.zig @@ -26,6 +26,8 @@ const encodeColor = rhi_types.encodeColor; const encodeNormal = rhi_types.encodeNormal; const encodeMeta = rhi_types.encodeMeta; const encodeBlocklight = rhi_types.encodeBlocklight; +const QuadricSimplifier = @import("meshing/quadric_simplifier.zig").QuadricSimplifier; +const log = @import("../engine/core/log.zig"); /// Size of each LOD mesh grid cell in blocks pub fn getCellSize(lod: LODLevel) u32 { @@ -143,6 +145,81 @@ pub const LODMesh = struct { } } + /// Build mesh from simplified LOD data using QEM decimation. + /// Generates a full-detail heightmap mesh first, then simplifies via quadric error metrics. + /// Falls back to naive `buildFromSimplifiedData` if QEM input is too small or fails. + pub fn buildFromSimplifiedDataWithQEM( + self: *LODMesh, + data: *const LODSimplifiedData, + world_x: i32, + world_z: i32, + target_triangles: u32, + min_input_triangles: u32, + ) !void { + const full_mesh = buildFullDetailHeightmapMesh(self.allocator, data) catch |err| { + log.log.warn("LOD{} full-detail mesh build failed, falling back: {}", .{ @intFromEnum(self.lod_level), err }); + return self.buildFromSimplifiedData(data, world_x, world_z); + }; + defer { + self.allocator.free(full_mesh.vertices); + self.allocator.free(full_mesh.indices); + } + + const input_triangles: u32 = @intCast(full_mesh.indices.len / 3); + if (input_triangles < min_input_triangles) { + return self.buildFromSimplifiedData(data, world_x, world_z); + } + + const effective_target = @min(target_triangles, input_triangles); + if (effective_target >= input_triangles) { + self.setPendingFromIndexed(full_mesh.vertices, full_mesh.indices); + return; + } + + const simplified = QuadricSimplifier.simplify( + self.allocator, + full_mesh.vertices, + full_mesh.indices, + effective_target, + ) catch |err| { + log.log.warn("LOD{} QEM simplification failed, falling back to naive: {}", .{ @intFromEnum(self.lod_level), err }); + return self.buildFromSimplifiedData(data, world_x, world_z); + }; + defer { + self.allocator.free(simplified.vertices); + self.allocator.free(simplified.indices); + } + + if (simplified.indices.len == 0) { + return self.buildFromSimplifiedData(data, world_x, world_z); + } + + log.log.debug("LOD{} QEM: {} -> {} triangles (error={d:.2})", .{ + @intFromEnum(self.lod_level), + simplified.original_triangle_count, + simplified.simplified_triangle_count, + simplified.error_estimate, + }); + + self.setPendingFromIndexed(simplified.vertices, simplified.indices); + } + + /// Convert indexed triangle mesh to non-indexed vertex list and store as pending. + fn setPendingFromIndexed(self: *LODMesh, vertices: []const Vertex, indices: []const u32) void { + self.mutex.lock(); + defer self.mutex.unlock(); + + if (self.pending_vertices) |p| { + self.allocator.free(p); + } + + const expanded = self.allocator.alloc(Vertex, indices.len) catch return; + for (expanded, 0..) |*dst, i| { + dst.* = vertices[indices[i]]; + } + self.pending_vertices = expanded; + } + /// Build mesh from full chunk heightmap data pub fn buildFromHeightmap( self: *LODMesh, @@ -266,6 +343,136 @@ pub const LODMesh = struct { } }; +const FullDetailMesh = struct { + vertices: []Vertex, + indices: []u32, +}; + +/// Build a full-detail indexed triangle mesh from LOD heightmap data. +/// Produces fine-grained quads with per-vertex heights suitable for QEM simplification. +/// The mesh uses 1-block resolution: each cell in the heightmap grid becomes a quad +/// subdivided into 2 triangles with separate indices for QEM edge collapse. +fn buildFullDetailHeightmapMesh( + allocator: std.mem.Allocator, + data: *const LODSimplifiedData, +) !FullDetailMesh { + const w = data.width; + const grid_total = w * w; + if (grid_total == 0) return error.EmptyData; + + const region_size_32: u32 = 32; + const cell_size: u32 = if (w > 0 and w <= region_size_32) region_size_32 / w else 2; + + var vertices = std.ArrayListUnmanaged(Vertex){}; + errdefer vertices.deinit(allocator); + var indices = std.ArrayListUnmanaged(u32){}; + errdefer indices.deinit(allocator); + + var gz: u32 = 0; + while (gz < w) : (gz += 1) { + var gx: u32 = 0; + while (gx < w) : (gx += 1) { + const h00 = data.heightmap[gx + gz * w]; + const h10 = if (gx + 1 < w) data.heightmap[(gx + 1) + gz * w] else h00; + const h01 = if (gz + 1 < w) data.heightmap[gx + (gz + 1) * w] else h00; + const h11 = if (gx + 1 < w and gz + 1 < w) data.heightmap[(gx + 1) + (gz + 1) * w] else h00; + + const c00 = biome_mod.getBiomeColor(data.biomes[gx + gz * w]); + const c10 = if (gx + 1 < w) biome_mod.getBiomeColor(data.biomes[(gx + 1) + gz * w]) else c00; + const c01 = if (gz + 1 < w) biome_mod.getBiomeColor(data.biomes[gx + (gz + 1) * w]) else c00; + const c11 = if (gx + 1 < w and gz + 1 < w) biome_mod.getBiomeColor(data.biomes[(gx + 1) + (gz + 1) * w]) else c00; + + const wx: f32 = @floatFromInt(gx * cell_size); + const wz: f32 = @floatFromInt(gz * cell_size); + const size: f32 = @floatFromInt(cell_size); + + const base: u32 = @intCast(vertices.items.len); + + try vertices.appendSlice(allocator, &.{ + makeLODVertex(.{ wx, h00, wz }, .{ unpackR(c00), unpackG(c00), unpackB(c00) }, .{ 0, 1, 0 }, .{ 0, 0 }), + makeLODVertex(.{ wx + size, h10, wz }, .{ unpackR(c10), unpackG(c10), unpackB(c10) }, .{ 0, 1, 0 }, .{ 1, 0 }), + makeLODVertex(.{ wx + size, h11, wz + size }, .{ unpackR(c11), unpackG(c11), unpackB(c11) }, .{ 0, 1, 0 }, .{ 1, 1 }), + makeLODVertex(.{ wx, h01, wz + size }, .{ unpackR(c01), unpackG(c01), unpackB(c01) }, .{ 0, 1, 0 }, .{ 0, 1 }), + }); + + try indices.appendSlice(allocator, &.{ + base, base + 1, base + 2, + base, base + 2, base + 3, + }); + + if (gz == 0) { + const avg_h = (h00 + h10) * 0.5; + const avg_c = averageColor(c00, c10, c00, c10); + const skirt_bottom = avg_h - size * 4.0; + const side_base: u32 = @intCast(vertices.items.len); + try vertices.appendSlice(allocator, &.{ + makeLODVertex(.{ wx + size, skirt_bottom, wz }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, -1 }, .{ 0, 0 }), + makeLODVertex(.{ wx, skirt_bottom, wz }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, -1 }, .{ 1, 0 }), + makeLODVertex(.{ wx, avg_h, wz }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, -1 }, .{ 1, 1 }), + makeLODVertex(.{ wx + size, avg_h, wz }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, -1 }, .{ 0, 1 }), + }); + try indices.appendSlice(allocator, &.{ + side_base, side_base + 1, side_base + 2, + side_base, side_base + 2, side_base + 3, + }); + } + if (gz == w - 1) { + const avg_h = (h01 + h11) * 0.5; + const avg_c = averageColor(c01, c11, c01, c11); + const skirt_bottom = avg_h - size * 4.0; + const side_base: u32 = @intCast(vertices.items.len); + try vertices.appendSlice(allocator, &.{ + makeLODVertex(.{ wx, skirt_bottom, wz + size }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, 1 }, .{ 0, 0 }), + makeLODVertex(.{ wx + size, skirt_bottom, wz + size }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, 1 }, .{ 1, 0 }), + makeLODVertex(.{ wx + size, avg_h, wz + size }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, 1 }, .{ 1, 1 }), + makeLODVertex(.{ wx, avg_h, wz + size }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, 1 }, .{ 0, 1 }), + }); + try indices.appendSlice(allocator, &.{ + side_base, side_base + 1, side_base + 2, + side_base, side_base + 2, side_base + 3, + }); + } + if (gx == 0) { + const avg_h = (h00 + h01) * 0.5; + const avg_c = averageColor(c00, c01, c00, c01); + const skirt_bottom = avg_h - size * 4.0; + const side_base: u32 = @intCast(vertices.items.len); + try vertices.appendSlice(allocator, &.{ + makeLODVertex(.{ wx, skirt_bottom, wz }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ -1, 0, 0 }, .{ 0, 0 }), + makeLODVertex(.{ wx, skirt_bottom, wz + size }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ -1, 0, 0 }, .{ 1, 0 }), + makeLODVertex(.{ wx, avg_h, wz + size }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ -1, 0, 0 }, .{ 1, 1 }), + makeLODVertex(.{ wx, avg_h, wz }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ -1, 0, 0 }, .{ 0, 1 }), + }); + try indices.appendSlice(allocator, &.{ + side_base, side_base + 1, side_base + 2, + side_base, side_base + 2, side_base + 3, + }); + } + if (gx == w - 1) { + const avg_h = (h10 + h11) * 0.5; + const avg_c = averageColor(c10, c11, c10, c11); + const skirt_bottom = avg_h - size * 4.0; + const side_base: u32 = @intCast(vertices.items.len); + try vertices.appendSlice(allocator, &.{ + makeLODVertex(.{ wx + size, skirt_bottom, wz + size }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ 1, 0, 0 }, .{ 0, 0 }), + makeLODVertex(.{ wx + size, skirt_bottom, wz }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ 1, 0, 0 }, .{ 1, 0 }), + makeLODVertex(.{ wx + size, avg_h, wz }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ 1, 0, 0 }, .{ 1, 1 }), + makeLODVertex(.{ wx + size, avg_h, wz + size }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ 1, 0, 0 }, .{ 0, 1 }), + }); + try indices.appendSlice(allocator, &.{ + side_base, side_base + 1, side_base + 2, + side_base, side_base + 2, side_base + 3, + }); + } + } + } + + return .{ + .vertices = try vertices.toOwnedSlice(allocator), + .indices = try indices.toOwnedSlice(allocator), + }; +} + const FaceDir = enum { north, south, east, west }; // Helper functions for unpacking colors From 75c2a34a5d66b503c6fdc9db12a687da4affc77d Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Thu, 2 Apr 2026 22:28:09 +0100 Subject: [PATCH 2/5] fix: address review feedback for QEM LOD integration - Propagate errors from setPendingFromIndexed instead of silent catch - Fix dangling pointer: set pending_vertices=null after free - Add validation assert for grid width in buildFullDetailHeightmapMesh - Extract makeSkirtQuad/appendIndexedQuad helpers to reduce repetition - Add inline comments documenting QEM triangle target rationale - Add fallback for setPendingFromIndexed allocation failure --- src/world/lod_chunk.zig | 3 +- src/world/lod_mesh.zig | 196 ++++++++++++++++++++++++---------------- 2 files changed, 120 insertions(+), 79 deletions(-) diff --git a/src/world/lod_chunk.zig b/src/world/lod_chunk.zig index 35392e33..42d4b348 100644 --- a/src/world/lod_chunk.zig +++ b/src/world/lod_chunk.zig @@ -345,7 +345,8 @@ pub const LODConfig = struct { fog_transitions: bool = true, /// Target triangle count per LOD region for QEM simplification. - /// Index 0 (LOD0) is unused. Higher LOD levels get fewer triangles. + /// LOD0 is unused. Targets based on % of typical ~8000 tri input: + /// LOD1=~25% (2000), LOD2=~12% (800), LOD3=~3% (200) qem_triangle_targets: [LODLevel.count]u32 = .{ 0, 2000, 800, 200 }, /// Minimum triangles required to attempt QEM simplification. diff --git a/src/world/lod_mesh.zig b/src/world/lod_mesh.zig index 50ba2382..44d7e0bd 100644 --- a/src/world/lod_mesh.zig +++ b/src/world/lod_mesh.zig @@ -172,7 +172,7 @@ pub const LODMesh = struct { const effective_target = @min(target_triangles, input_triangles); if (effective_target >= input_triangles) { - self.setPendingFromIndexed(full_mesh.vertices, full_mesh.indices); + try self.setPendingFromIndexed(full_mesh.vertices, full_mesh.indices); return; } @@ -201,19 +201,25 @@ pub const LODMesh = struct { simplified.error_estimate, }); - self.setPendingFromIndexed(simplified.vertices, simplified.indices); + self.setPendingFromIndexed(simplified.vertices, simplified.indices) catch |err| { + log.log.warn("LOD{} failed to expand simplified mesh, falling back: {}", .{ @intFromEnum(self.lod_level), err }); + return self.buildFromSimplifiedData(data, world_x, world_z); + }; } /// Convert indexed triangle mesh to non-indexed vertex list and store as pending. - fn setPendingFromIndexed(self: *LODMesh, vertices: []const Vertex, indices: []const u32) void { + fn setPendingFromIndexed(self: *LODMesh, vertices: []const Vertex, indices: []const u32) !void { self.mutex.lock(); defer self.mutex.unlock(); if (self.pending_vertices) |p| { self.allocator.free(p); + self.pending_vertices = null; } - const expanded = self.allocator.alloc(Vertex, indices.len) catch return; + if (indices.len == 0) return; + + const expanded = try self.allocator.alloc(Vertex, indices.len); for (expanded, 0..) |*dst, i| { dst.* = vertices[indices[i]]; } @@ -352,6 +358,73 @@ const FullDetailMesh = struct { /// Produces fine-grained quads with per-vertex heights suitable for QEM simplification. /// The mesh uses 1-block resolution: each cell in the heightmap grid becomes a quad /// subdivided into 2 triangles with separate indices for QEM edge collapse. +fn appendIndexedQuad( + vertices: *std.ArrayListUnmanaged(Vertex), + indices: *std.ArrayListUnmanaged(u32), + allocator: std.mem.Allocator, + quad: *const [4]Vertex, +) !void { + const base: u32 = @intCast(vertices.items.len); + try vertices.appendSlice(allocator, quad); + try indices.appendSlice(allocator, &.{ + base, base + 1, base + 2, + base, base + 2, base + 3, + }); +} + +const SkirtParams = struct { + x: f32, + z: f32, + size: f32, + avg_h: f32, + avg_c: u32, + brightness: f32, + dir: SkirtDir, +}; + +const SkirtDir = enum { north, south, east, west }; + +fn makeSkirtQuad(params: SkirtParams) [4]Vertex { + const p = params; + const cr = unpackR(p.avg_c) * p.brightness; + const cg = unpackG(p.avg_c) * p.brightness; + const cb = unpackB(p.avg_c) * p.brightness; + const skirt_bottom = p.avg_h - p.size * 4.0; + const normal: [3]f32 = switch (p.dir) { + .north => .{ 0, 0, -1 }, + .south => .{ 0, 0, 1 }, + .west => .{ -1, 0, 0 }, + .east => .{ 1, 0, 0 }, + }; + const col = [3]f32{ cr, cg, cb }; + return switch (p.dir) { + .north => .{ + makeLODVertex(.{ p.x + p.size, skirt_bottom, p.z }, col, normal, .{ 0, 0 }), + makeLODVertex(.{ p.x, skirt_bottom, p.z }, col, normal, .{ 1, 0 }), + makeLODVertex(.{ p.x, p.avg_h, p.z }, col, normal, .{ 1, 1 }), + makeLODVertex(.{ p.x + p.size, p.avg_h, p.z }, col, normal, .{ 0, 1 }), + }, + .south => .{ + makeLODVertex(.{ p.x, skirt_bottom, p.z + p.size }, col, normal, .{ 0, 0 }), + makeLODVertex(.{ p.x + p.size, skirt_bottom, p.z + p.size }, col, normal, .{ 1, 0 }), + makeLODVertex(.{ p.x + p.size, p.avg_h, p.z + p.size }, col, normal, .{ 1, 1 }), + makeLODVertex(.{ p.x, p.avg_h, p.z + p.size }, col, normal, .{ 0, 1 }), + }, + .west => .{ + makeLODVertex(.{ p.x, skirt_bottom, p.z }, col, normal, .{ 0, 0 }), + makeLODVertex(.{ p.x, skirt_bottom, p.z + p.size }, col, normal, .{ 1, 0 }), + makeLODVertex(.{ p.x, p.avg_h, p.z + p.size }, col, normal, .{ 1, 1 }), + makeLODVertex(.{ p.x, p.avg_h, p.z }, col, normal, .{ 0, 1 }), + }, + .east => .{ + makeLODVertex(.{ p.x + p.size, skirt_bottom, p.z + p.size }, col, normal, .{ 0, 0 }), + makeLODVertex(.{ p.x + p.size, skirt_bottom, p.z }, col, normal, .{ 1, 0 }), + makeLODVertex(.{ p.x + p.size, p.avg_h, p.z }, col, normal, .{ 1, 1 }), + makeLODVertex(.{ p.x + p.size, p.avg_h, p.z + p.size }, col, normal, .{ 0, 1 }), + }, + }; +} + fn buildFullDetailHeightmapMesh( allocator: std.mem.Allocator, data: *const LODSimplifiedData, @@ -359,6 +432,7 @@ fn buildFullDetailHeightmapMesh( const w = data.width; const grid_total = w * w; if (grid_total == 0) return error.EmptyData; + std.debug.assert(w <= data.heightmap.len and w <= data.biomes.len); const region_size_32: u32 = 32; const cell_size: u32 = if (w > 0 and w <= region_size_32) region_size_32 / w else 2; @@ -386,84 +460,50 @@ fn buildFullDetailHeightmapMesh( const wz: f32 = @floatFromInt(gz * cell_size); const size: f32 = @floatFromInt(cell_size); - const base: u32 = @intCast(vertices.items.len); - - try vertices.appendSlice(allocator, &.{ + const top_quad = [4]Vertex{ makeLODVertex(.{ wx, h00, wz }, .{ unpackR(c00), unpackG(c00), unpackB(c00) }, .{ 0, 1, 0 }, .{ 0, 0 }), makeLODVertex(.{ wx + size, h10, wz }, .{ unpackR(c10), unpackG(c10), unpackB(c10) }, .{ 0, 1, 0 }, .{ 1, 0 }), makeLODVertex(.{ wx + size, h11, wz + size }, .{ unpackR(c11), unpackG(c11), unpackB(c11) }, .{ 0, 1, 0 }, .{ 1, 1 }), makeLODVertex(.{ wx, h01, wz + size }, .{ unpackR(c01), unpackG(c01), unpackB(c01) }, .{ 0, 1, 0 }, .{ 0, 1 }), - }); - - try indices.appendSlice(allocator, &.{ - base, base + 1, base + 2, - base, base + 2, base + 3, - }); - - if (gz == 0) { - const avg_h = (h00 + h10) * 0.5; - const avg_c = averageColor(c00, c10, c00, c10); - const skirt_bottom = avg_h - size * 4.0; - const side_base: u32 = @intCast(vertices.items.len); - try vertices.appendSlice(allocator, &.{ - makeLODVertex(.{ wx + size, skirt_bottom, wz }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, -1 }, .{ 0, 0 }), - makeLODVertex(.{ wx, skirt_bottom, wz }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, -1 }, .{ 1, 0 }), - makeLODVertex(.{ wx, avg_h, wz }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, -1 }, .{ 1, 1 }), - makeLODVertex(.{ wx + size, avg_h, wz }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, -1 }, .{ 0, 1 }), - }); - try indices.appendSlice(allocator, &.{ - side_base, side_base + 1, side_base + 2, - side_base, side_base + 2, side_base + 3, - }); - } - if (gz == w - 1) { - const avg_h = (h01 + h11) * 0.5; - const avg_c = averageColor(c01, c11, c01, c11); - const skirt_bottom = avg_h - size * 4.0; - const side_base: u32 = @intCast(vertices.items.len); - try vertices.appendSlice(allocator, &.{ - makeLODVertex(.{ wx, skirt_bottom, wz + size }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, 1 }, .{ 0, 0 }), - makeLODVertex(.{ wx + size, skirt_bottom, wz + size }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, 1 }, .{ 1, 0 }), - makeLODVertex(.{ wx + size, avg_h, wz + size }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, 1 }, .{ 1, 1 }), - makeLODVertex(.{ wx, avg_h, wz + size }, .{ unpackR(avg_c) * 0.7, unpackG(avg_c) * 0.7, unpackB(avg_c) * 0.7 }, .{ 0, 0, 1 }, .{ 0, 1 }), - }); - try indices.appendSlice(allocator, &.{ - side_base, side_base + 1, side_base + 2, - side_base, side_base + 2, side_base + 3, - }); - } - if (gx == 0) { - const avg_h = (h00 + h01) * 0.5; - const avg_c = averageColor(c00, c01, c00, c01); - const skirt_bottom = avg_h - size * 4.0; - const side_base: u32 = @intCast(vertices.items.len); - try vertices.appendSlice(allocator, &.{ - makeLODVertex(.{ wx, skirt_bottom, wz }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ -1, 0, 0 }, .{ 0, 0 }), - makeLODVertex(.{ wx, skirt_bottom, wz + size }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ -1, 0, 0 }, .{ 1, 0 }), - makeLODVertex(.{ wx, avg_h, wz + size }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ -1, 0, 0 }, .{ 1, 1 }), - makeLODVertex(.{ wx, avg_h, wz }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ -1, 0, 0 }, .{ 0, 1 }), - }); - try indices.appendSlice(allocator, &.{ - side_base, side_base + 1, side_base + 2, - side_base, side_base + 2, side_base + 3, - }); - } - if (gx == w - 1) { - const avg_h = (h10 + h11) * 0.5; - const avg_c = averageColor(c10, c11, c10, c11); - const skirt_bottom = avg_h - size * 4.0; - const side_base: u32 = @intCast(vertices.items.len); - try vertices.appendSlice(allocator, &.{ - makeLODVertex(.{ wx + size, skirt_bottom, wz + size }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ 1, 0, 0 }, .{ 0, 0 }), - makeLODVertex(.{ wx + size, skirt_bottom, wz }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ 1, 0, 0 }, .{ 1, 0 }), - makeLODVertex(.{ wx + size, avg_h, wz }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ 1, 0, 0 }, .{ 1, 1 }), - makeLODVertex(.{ wx + size, avg_h, wz + size }, .{ unpackR(avg_c) * 0.6, unpackG(avg_c) * 0.6, unpackB(avg_c) * 0.6 }, .{ 1, 0, 0 }, .{ 0, 1 }), - }); - try indices.appendSlice(allocator, &.{ - side_base, side_base + 1, side_base + 2, - side_base, side_base + 2, side_base + 3, - }); - } + }; + try appendIndexedQuad(&vertices, &indices, allocator, &top_quad); + + if (gz == 0) try appendIndexedQuad(&vertices, &indices, allocator, &makeSkirtQuad(.{ + .x = wx, + .z = wz, + .size = size, + .avg_h = (h00 + h10) * 0.5, + .avg_c = averageColor(c00, c10, c00, c10), + .brightness = 0.7, + .dir = .north, + })); + if (gz == w - 1) try appendIndexedQuad(&vertices, &indices, allocator, &makeSkirtQuad(.{ + .x = wx, + .z = wz, + .size = size, + .avg_h = (h01 + h11) * 0.5, + .avg_c = averageColor(c01, c11, c01, c11), + .brightness = 0.7, + .dir = .south, + })); + if (gx == 0) try appendIndexedQuad(&vertices, &indices, allocator, &makeSkirtQuad(.{ + .x = wx, + .z = wz, + .size = size, + .avg_h = (h00 + h01) * 0.5, + .avg_c = averageColor(c00, c01, c00, c01), + .brightness = 0.6, + .dir = .west, + })); + if (gx == w - 1) try appendIndexedQuad(&vertices, &indices, allocator, &makeSkirtQuad(.{ + .x = wx, + .z = wz, + .size = size, + .avg_h = (h10 + h11) * 0.5, + .avg_c = averageColor(c10, c11, c10, c11), + .brightness = 0.6, + .dir = .east, + })); } } From 9ca9680a8ccd1bcceeea591dca36f83a4a35ce80 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Fri, 3 Apr 2026 00:09:53 +0100 Subject: [PATCH 3/5] fix: address code review feedback for QEM LOD integration - Add bounds assertion in setPendingFromIndexed to catch invalid QEM indices - Restructure simplification early-return with clear comment (no-op path) - Add debug assert for cell_size >= 1 in buildFullDetailHeightmapMesh - Confirmed SimplifiedMesh field names match (original_triangle_count, simplified_triangle_count, error_estimate) - no fix needed - Confirmed world_x/world_z params are used in fallback paths - no fix needed --- src/world/lod_mesh.zig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/world/lod_mesh.zig b/src/world/lod_mesh.zig index 44d7e0bd..dc9f944d 100644 --- a/src/world/lod_mesh.zig +++ b/src/world/lod_mesh.zig @@ -170,11 +170,12 @@ pub const LODMesh = struct { return self.buildFromSimplifiedData(data, world_x, world_z); } - const effective_target = @min(target_triangles, input_triangles); - if (effective_target >= input_triangles) { + // No simplification needed — target already meets or exceeds input + if (target_triangles >= input_triangles) { try self.setPendingFromIndexed(full_mesh.vertices, full_mesh.indices); return; } + const effective_target = target_triangles; const simplified = QuadricSimplifier.simplify( self.allocator, @@ -221,7 +222,9 @@ pub const LODMesh = struct { const expanded = try self.allocator.alloc(Vertex, indices.len); for (expanded, 0..) |*dst, i| { - dst.* = vertices[indices[i]]; + const idx = indices[i]; + std.debug.assert(idx < vertices.len); + dst.* = vertices[idx]; } self.pending_vertices = expanded; } @@ -436,6 +439,7 @@ fn buildFullDetailHeightmapMesh( const region_size_32: u32 = 32; const cell_size: u32 = if (w > 0 and w <= region_size_32) region_size_32 / w else 2; + std.debug.assert(cell_size >= 1); var vertices = std.ArrayListUnmanaged(Vertex){}; errdefer vertices.deinit(allocator); From 52cddebd751871c7a78103f31932a65b21a4da12 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Fri, 3 Apr 2026 00:37:28 +0100 Subject: [PATCH 4/5] fix: use runtime bounds check instead of debug assert in setPendingFromIndexed std.debug.assert is compiled out in release builds, so invalid QEM indices could cause out-of-bounds access. Use error.InvalidIndex return instead to catch corruption in all build modes. --- src/world/lod_mesh.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/world/lod_mesh.zig b/src/world/lod_mesh.zig index dc9f944d..e7ffe3e5 100644 --- a/src/world/lod_mesh.zig +++ b/src/world/lod_mesh.zig @@ -223,7 +223,7 @@ pub const LODMesh = struct { const expanded = try self.allocator.alloc(Vertex, indices.len); for (expanded, 0..) |*dst, i| { const idx = indices[i]; - std.debug.assert(idx < vertices.len); + if (idx >= vertices.len) return error.InvalidIndex; dst.* = vertices[idx]; } self.pending_vertices = expanded; From 29b89ed94713ac5046983a3f0e2d4cde298088dd Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Fri, 3 Apr 2026 00:52:51 +0100 Subject: [PATCH 5/5] fix: validate index count divisible by 3 before triangle count calc Fallback to naive mesh if full-detail mesh produces malformed index buffer, preventing incorrect QEM target decisions. --- src/world/lod_mesh.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/world/lod_mesh.zig b/src/world/lod_mesh.zig index e7ffe3e5..5eea660f 100644 --- a/src/world/lod_mesh.zig +++ b/src/world/lod_mesh.zig @@ -165,6 +165,10 @@ pub const LODMesh = struct { self.allocator.free(full_mesh.indices); } + if (full_mesh.indices.len % 3 != 0) { + log.log.warn("LOD{} mesh has invalid index count {}, falling back", .{ @intFromEnum(self.lod_level), full_mesh.indices.len }); + return self.buildFromSimplifiedData(data, world_x, world_z); + } const input_triangles: u32 = @intCast(full_mesh.indices.len / 3); if (input_triangles < min_input_triangles) { return self.buildFromSimplifiedData(data, world_x, world_z);