Skip to content

Commit ad9d1f2

Browse files
feat: integrate quadric decimation into LOD mesh pipeline (#408)
* 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 * 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 * 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 * 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. * 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.
1 parent ecb92ee commit ad9d1f2

3 files changed

Lines changed: 292 additions & 3 deletions

File tree

src/world/lod_chunk.zig

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,8 @@ pub const ILODConfig = struct {
293293
isInRange: *const fn (ptr: *anyopaque, dist_chunks: i32) bool,
294294
getMaxUploadsPerFrame: *const fn (ptr: *anyopaque) u32,
295295
calculateMaskRadius: *const fn (ptr: *anyopaque) f32,
296+
getQEMTarget: *const fn (ptr: *anyopaque, lod: LODLevel) u32,
297+
getQEMMinInputTriangles: *const fn (ptr: *anyopaque) u32,
296298
};
297299

298300
pub fn getRadii(self: ILODConfig) [LODLevel.count]i32 {
@@ -316,6 +318,14 @@ pub const ILODConfig = struct {
316318
pub fn calculateMaskRadius(self: ILODConfig) f32 {
317319
return self.vtable.calculateMaskRadius(self.ptr);
318320
}
321+
322+
pub fn getQEMTarget(self: ILODConfig, lod: LODLevel) u32 {
323+
return self.vtable.getQEMTarget(self.ptr, lod);
324+
}
325+
326+
pub fn getQEMMinInputTriangles(self: ILODConfig) u32 {
327+
return self.vtable.getQEMMinInputTriangles(self.ptr);
328+
}
319329
};
320330

321331
/// Concrete implementation of LOD system configuration.
@@ -329,11 +339,24 @@ pub const LODConfig = struct {
329339
memory_budget_mb: u32 = 256,
330340

331341
/// Maximum uploads per frame per LOD level
332-
max_uploads_per_frame: u32 = 8, // Increased from 4 for faster loading
342+
max_uploads_per_frame: u32 = 8,
333343

334344
/// Enable fog-masked transitions
335345
fog_transitions: bool = true,
336346

347+
/// Target triangle count per LOD region for QEM simplification.
348+
/// LOD0 is unused. Targets based on % of typical ~8000 tri input:
349+
/// LOD1=~25% (2000), LOD2=~12% (800), LOD3=~3% (200)
350+
qem_triangle_targets: [LODLevel.count]u32 = .{ 0, 2000, 800, 200 },
351+
352+
/// Minimum triangles required to attempt QEM simplification.
353+
/// Below this threshold the naive heightmap mesh is used directly.
354+
qem_min_input_triangles: u32 = 50,
355+
356+
pub fn getQEMTarget(self: *const LODConfig, lod: LODLevel) u32 {
357+
return self.qem_triangle_targets[@intFromEnum(lod)];
358+
}
359+
337360
pub fn getLODForDistance(self: *const LODConfig, dist_chunks: i32) LODLevel {
338361
inline for (0..LODLevel.count) |i| {
339362
if (dist_chunks <= self.radii[i]) return @enumFromInt(@as(u3, @intCast(i)));
@@ -360,6 +383,8 @@ pub const LODConfig = struct {
360383
.isInRange = isInRangeWrapper,
361384
.getMaxUploadsPerFrame = getMaxUploadsPerFrameWrapper,
362385
.calculateMaskRadius = calculateMaskRadiusWrapper,
386+
.getQEMTarget = getQEMTargetWrapper,
387+
.getQEMMinInputTriangles = getQEMMinInputTrianglesWrapper,
363388
};
364389

365390
fn getRadiiWrapper(ptr: *anyopaque) [LODLevel.count]i32 {
@@ -384,9 +409,16 @@ pub const LODConfig = struct {
384409
}
385410
fn calculateMaskRadiusWrapper(ptr: *anyopaque) f32 {
386411
const self: *LODConfig = @ptrCast(@alignCast(ptr));
387-
// Return radii[0] - 2.0 to ensure a 2-chunk overlap between LODs and block chunks
388412
return @as(f32, @floatFromInt(self.radii[0])) - 2.0;
389413
}
414+
fn getQEMTargetWrapper(ptr: *anyopaque, lod: LODLevel) u32 {
415+
const self: *LODConfig = @ptrCast(@alignCast(ptr));
416+
return self.getQEMTarget(lod);
417+
}
418+
fn getQEMMinInputTrianglesWrapper(ptr: *anyopaque) u32 {
419+
const self: *LODConfig = @ptrCast(@alignCast(ptr));
420+
return self.qem_min_input_triangles;
421+
}
390422
};
391423

392424
// Tests

src/world/lod_manager.zig

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -842,7 +842,9 @@ pub const LODManager = struct {
842842
switch (chunk.data) {
843843
.simplified => |*data| {
844844
const bounds = chunk.worldBounds();
845-
try mesh.buildFromSimplifiedData(data, bounds.min_x, bounds.min_z);
845+
const target_tris = self.config.getQEMTarget(chunk.lod_level);
846+
const min_tris = self.config.getQEMMinInputTriangles();
847+
try mesh.buildFromSimplifiedDataWithQEM(data, bounds.min_x, bounds.min_z, target_tris, min_tris);
846848
},
847849
.full => {
848850
// LOD0 meshes handled by World, not LODManager

src/world/lod_mesh.zig

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const encodeColor = rhi_types.encodeColor;
2626
const encodeNormal = rhi_types.encodeNormal;
2727
const encodeMeta = rhi_types.encodeMeta;
2828
const encodeBlocklight = rhi_types.encodeBlocklight;
29+
const QuadricSimplifier = @import("meshing/quadric_simplifier.zig").QuadricSimplifier;
30+
const log = @import("../engine/core/log.zig");
2931

3032
/// Size of each LOD mesh grid cell in blocks
3133
pub fn getCellSize(lod: LODLevel) u32 {
@@ -143,6 +145,94 @@ pub const LODMesh = struct {
143145
}
144146
}
145147

148+
/// Build mesh from simplified LOD data using QEM decimation.
149+
/// Generates a full-detail heightmap mesh first, then simplifies via quadric error metrics.
150+
/// Falls back to naive `buildFromSimplifiedData` if QEM input is too small or fails.
151+
pub fn buildFromSimplifiedDataWithQEM(
152+
self: *LODMesh,
153+
data: *const LODSimplifiedData,
154+
world_x: i32,
155+
world_z: i32,
156+
target_triangles: u32,
157+
min_input_triangles: u32,
158+
) !void {
159+
const full_mesh = buildFullDetailHeightmapMesh(self.allocator, data) catch |err| {
160+
log.log.warn("LOD{} full-detail mesh build failed, falling back: {}", .{ @intFromEnum(self.lod_level), err });
161+
return self.buildFromSimplifiedData(data, world_x, world_z);
162+
};
163+
defer {
164+
self.allocator.free(full_mesh.vertices);
165+
self.allocator.free(full_mesh.indices);
166+
}
167+
168+
if (full_mesh.indices.len % 3 != 0) {
169+
log.log.warn("LOD{} mesh has invalid index count {}, falling back", .{ @intFromEnum(self.lod_level), full_mesh.indices.len });
170+
return self.buildFromSimplifiedData(data, world_x, world_z);
171+
}
172+
const input_triangles: u32 = @intCast(full_mesh.indices.len / 3);
173+
if (input_triangles < min_input_triangles) {
174+
return self.buildFromSimplifiedData(data, world_x, world_z);
175+
}
176+
177+
// No simplification needed — target already meets or exceeds input
178+
if (target_triangles >= input_triangles) {
179+
try self.setPendingFromIndexed(full_mesh.vertices, full_mesh.indices);
180+
return;
181+
}
182+
const effective_target = target_triangles;
183+
184+
const simplified = QuadricSimplifier.simplify(
185+
self.allocator,
186+
full_mesh.vertices,
187+
full_mesh.indices,
188+
effective_target,
189+
) catch |err| {
190+
log.log.warn("LOD{} QEM simplification failed, falling back to naive: {}", .{ @intFromEnum(self.lod_level), err });
191+
return self.buildFromSimplifiedData(data, world_x, world_z);
192+
};
193+
defer {
194+
self.allocator.free(simplified.vertices);
195+
self.allocator.free(simplified.indices);
196+
}
197+
198+
if (simplified.indices.len == 0) {
199+
return self.buildFromSimplifiedData(data, world_x, world_z);
200+
}
201+
202+
log.log.debug("LOD{} QEM: {} -> {} triangles (error={d:.2})", .{
203+
@intFromEnum(self.lod_level),
204+
simplified.original_triangle_count,
205+
simplified.simplified_triangle_count,
206+
simplified.error_estimate,
207+
});
208+
209+
self.setPendingFromIndexed(simplified.vertices, simplified.indices) catch |err| {
210+
log.log.warn("LOD{} failed to expand simplified mesh, falling back: {}", .{ @intFromEnum(self.lod_level), err });
211+
return self.buildFromSimplifiedData(data, world_x, world_z);
212+
};
213+
}
214+
215+
/// Convert indexed triangle mesh to non-indexed vertex list and store as pending.
216+
fn setPendingFromIndexed(self: *LODMesh, vertices: []const Vertex, indices: []const u32) !void {
217+
self.mutex.lock();
218+
defer self.mutex.unlock();
219+
220+
if (self.pending_vertices) |p| {
221+
self.allocator.free(p);
222+
self.pending_vertices = null;
223+
}
224+
225+
if (indices.len == 0) return;
226+
227+
const expanded = try self.allocator.alloc(Vertex, indices.len);
228+
for (expanded, 0..) |*dst, i| {
229+
const idx = indices[i];
230+
if (idx >= vertices.len) return error.InvalidIndex;
231+
dst.* = vertices[idx];
232+
}
233+
self.pending_vertices = expanded;
234+
}
235+
146236
/// Build mesh from full chunk heightmap data
147237
pub fn buildFromHeightmap(
148238
self: *LODMesh,
@@ -266,6 +356,171 @@ pub const LODMesh = struct {
266356
}
267357
};
268358

359+
const FullDetailMesh = struct {
360+
vertices: []Vertex,
361+
indices: []u32,
362+
};
363+
364+
/// Build a full-detail indexed triangle mesh from LOD heightmap data.
365+
/// Produces fine-grained quads with per-vertex heights suitable for QEM simplification.
366+
/// The mesh uses 1-block resolution: each cell in the heightmap grid becomes a quad
367+
/// subdivided into 2 triangles with separate indices for QEM edge collapse.
368+
fn appendIndexedQuad(
369+
vertices: *std.ArrayListUnmanaged(Vertex),
370+
indices: *std.ArrayListUnmanaged(u32),
371+
allocator: std.mem.Allocator,
372+
quad: *const [4]Vertex,
373+
) !void {
374+
const base: u32 = @intCast(vertices.items.len);
375+
try vertices.appendSlice(allocator, quad);
376+
try indices.appendSlice(allocator, &.{
377+
base, base + 1, base + 2,
378+
base, base + 2, base + 3,
379+
});
380+
}
381+
382+
const SkirtParams = struct {
383+
x: f32,
384+
z: f32,
385+
size: f32,
386+
avg_h: f32,
387+
avg_c: u32,
388+
brightness: f32,
389+
dir: SkirtDir,
390+
};
391+
392+
const SkirtDir = enum { north, south, east, west };
393+
394+
fn makeSkirtQuad(params: SkirtParams) [4]Vertex {
395+
const p = params;
396+
const cr = unpackR(p.avg_c) * p.brightness;
397+
const cg = unpackG(p.avg_c) * p.brightness;
398+
const cb = unpackB(p.avg_c) * p.brightness;
399+
const skirt_bottom = p.avg_h - p.size * 4.0;
400+
const normal: [3]f32 = switch (p.dir) {
401+
.north => .{ 0, 0, -1 },
402+
.south => .{ 0, 0, 1 },
403+
.west => .{ -1, 0, 0 },
404+
.east => .{ 1, 0, 0 },
405+
};
406+
const col = [3]f32{ cr, cg, cb };
407+
return switch (p.dir) {
408+
.north => .{
409+
makeLODVertex(.{ p.x + p.size, skirt_bottom, p.z }, col, normal, .{ 0, 0 }),
410+
makeLODVertex(.{ p.x, skirt_bottom, p.z }, col, normal, .{ 1, 0 }),
411+
makeLODVertex(.{ p.x, p.avg_h, p.z }, col, normal, .{ 1, 1 }),
412+
makeLODVertex(.{ p.x + p.size, p.avg_h, p.z }, col, normal, .{ 0, 1 }),
413+
},
414+
.south => .{
415+
makeLODVertex(.{ p.x, skirt_bottom, p.z + p.size }, col, normal, .{ 0, 0 }),
416+
makeLODVertex(.{ p.x + p.size, skirt_bottom, p.z + p.size }, col, normal, .{ 1, 0 }),
417+
makeLODVertex(.{ p.x + p.size, p.avg_h, p.z + p.size }, col, normal, .{ 1, 1 }),
418+
makeLODVertex(.{ p.x, p.avg_h, p.z + p.size }, col, normal, .{ 0, 1 }),
419+
},
420+
.west => .{
421+
makeLODVertex(.{ p.x, skirt_bottom, p.z }, col, normal, .{ 0, 0 }),
422+
makeLODVertex(.{ p.x, skirt_bottom, p.z + p.size }, col, normal, .{ 1, 0 }),
423+
makeLODVertex(.{ p.x, p.avg_h, p.z + p.size }, col, normal, .{ 1, 1 }),
424+
makeLODVertex(.{ p.x, p.avg_h, p.z }, col, normal, .{ 0, 1 }),
425+
},
426+
.east => .{
427+
makeLODVertex(.{ p.x + p.size, skirt_bottom, p.z + p.size }, col, normal, .{ 0, 0 }),
428+
makeLODVertex(.{ p.x + p.size, skirt_bottom, p.z }, col, normal, .{ 1, 0 }),
429+
makeLODVertex(.{ p.x + p.size, p.avg_h, p.z }, col, normal, .{ 1, 1 }),
430+
makeLODVertex(.{ p.x + p.size, p.avg_h, p.z + p.size }, col, normal, .{ 0, 1 }),
431+
},
432+
};
433+
}
434+
435+
fn buildFullDetailHeightmapMesh(
436+
allocator: std.mem.Allocator,
437+
data: *const LODSimplifiedData,
438+
) !FullDetailMesh {
439+
const w = data.width;
440+
const grid_total = w * w;
441+
if (grid_total == 0) return error.EmptyData;
442+
std.debug.assert(w <= data.heightmap.len and w <= data.biomes.len);
443+
444+
const region_size_32: u32 = 32;
445+
const cell_size: u32 = if (w > 0 and w <= region_size_32) region_size_32 / w else 2;
446+
std.debug.assert(cell_size >= 1);
447+
448+
var vertices = std.ArrayListUnmanaged(Vertex){};
449+
errdefer vertices.deinit(allocator);
450+
var indices = std.ArrayListUnmanaged(u32){};
451+
errdefer indices.deinit(allocator);
452+
453+
var gz: u32 = 0;
454+
while (gz < w) : (gz += 1) {
455+
var gx: u32 = 0;
456+
while (gx < w) : (gx += 1) {
457+
const h00 = data.heightmap[gx + gz * w];
458+
const h10 = if (gx + 1 < w) data.heightmap[(gx + 1) + gz * w] else h00;
459+
const h01 = if (gz + 1 < w) data.heightmap[gx + (gz + 1) * w] else h00;
460+
const h11 = if (gx + 1 < w and gz + 1 < w) data.heightmap[(gx + 1) + (gz + 1) * w] else h00;
461+
462+
const c00 = biome_mod.getBiomeColor(data.biomes[gx + gz * w]);
463+
const c10 = if (gx + 1 < w) biome_mod.getBiomeColor(data.biomes[(gx + 1) + gz * w]) else c00;
464+
const c01 = if (gz + 1 < w) biome_mod.getBiomeColor(data.biomes[gx + (gz + 1) * w]) else c00;
465+
const c11 = if (gx + 1 < w and gz + 1 < w) biome_mod.getBiomeColor(data.biomes[(gx + 1) + (gz + 1) * w]) else c00;
466+
467+
const wx: f32 = @floatFromInt(gx * cell_size);
468+
const wz: f32 = @floatFromInt(gz * cell_size);
469+
const size: f32 = @floatFromInt(cell_size);
470+
471+
const top_quad = [4]Vertex{
472+
makeLODVertex(.{ wx, h00, wz }, .{ unpackR(c00), unpackG(c00), unpackB(c00) }, .{ 0, 1, 0 }, .{ 0, 0 }),
473+
makeLODVertex(.{ wx + size, h10, wz }, .{ unpackR(c10), unpackG(c10), unpackB(c10) }, .{ 0, 1, 0 }, .{ 1, 0 }),
474+
makeLODVertex(.{ wx + size, h11, wz + size }, .{ unpackR(c11), unpackG(c11), unpackB(c11) }, .{ 0, 1, 0 }, .{ 1, 1 }),
475+
makeLODVertex(.{ wx, h01, wz + size }, .{ unpackR(c01), unpackG(c01), unpackB(c01) }, .{ 0, 1, 0 }, .{ 0, 1 }),
476+
};
477+
try appendIndexedQuad(&vertices, &indices, allocator, &top_quad);
478+
479+
if (gz == 0) try appendIndexedQuad(&vertices, &indices, allocator, &makeSkirtQuad(.{
480+
.x = wx,
481+
.z = wz,
482+
.size = size,
483+
.avg_h = (h00 + h10) * 0.5,
484+
.avg_c = averageColor(c00, c10, c00, c10),
485+
.brightness = 0.7,
486+
.dir = .north,
487+
}));
488+
if (gz == w - 1) try appendIndexedQuad(&vertices, &indices, allocator, &makeSkirtQuad(.{
489+
.x = wx,
490+
.z = wz,
491+
.size = size,
492+
.avg_h = (h01 + h11) * 0.5,
493+
.avg_c = averageColor(c01, c11, c01, c11),
494+
.brightness = 0.7,
495+
.dir = .south,
496+
}));
497+
if (gx == 0) try appendIndexedQuad(&vertices, &indices, allocator, &makeSkirtQuad(.{
498+
.x = wx,
499+
.z = wz,
500+
.size = size,
501+
.avg_h = (h00 + h01) * 0.5,
502+
.avg_c = averageColor(c00, c01, c00, c01),
503+
.brightness = 0.6,
504+
.dir = .west,
505+
}));
506+
if (gx == w - 1) try appendIndexedQuad(&vertices, &indices, allocator, &makeSkirtQuad(.{
507+
.x = wx,
508+
.z = wz,
509+
.size = size,
510+
.avg_h = (h10 + h11) * 0.5,
511+
.avg_c = averageColor(c10, c11, c10, c11),
512+
.brightness = 0.6,
513+
.dir = .east,
514+
}));
515+
}
516+
}
517+
518+
return .{
519+
.vertices = try vertices.toOwnedSlice(allocator),
520+
.indices = try indices.toOwnedSlice(allocator),
521+
};
522+
}
523+
269524
const FaceDir = enum { north, south, east, west };
270525

271526
// Helper functions for unpacking colors

0 commit comments

Comments
 (0)