From c1946920a0346420dd98af7d4167f71305988c78 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Thu, 2 Apr 2026 00:27:18 +0100 Subject: [PATCH 1/6] feat: implement quadric error metric mesh simplification (closes #378) Implements the QEM algorithm (Garland & Heckbert, 1997) for LOD mesh simplification in src/world/meshing/quadric_simplifier.zig. Iteratively collapses edges by quadric error cost with link-condition manifold checks and normal-flip prevention. --- src/tests.zig | 1 + src/world/meshing/quadric_simplifier.zig | 797 +++++++++++++++++++++++ 2 files changed, 798 insertions(+) create mode 100644 src/world/meshing/quadric_simplifier.zig diff --git a/src/tests.zig b/src/tests.zig index b28e8f9..e4c42e5 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -90,6 +90,7 @@ test { _ = @import("game/screen_tests.zig"); _ = @import("game/session_tests.zig"); _ = @import("world/persistence/region_file.zig"); + _ = @import("world/meshing/quadric_simplifier.zig"); } test "Vec3 addition" { diff --git a/src/world/meshing/quadric_simplifier.zig b/src/world/meshing/quadric_simplifier.zig new file mode 100644 index 0000000..0de84cc --- /dev/null +++ b/src/world/meshing/quadric_simplifier.zig @@ -0,0 +1,797 @@ +//! Quadric-based mesh simplification for LOD terrain. +//! +//! Implements the Quadric Error Metrics (QEM) algorithm (Garland & Heckbert, 1997) +//! for mesh simplification. Iteratively collapses edges based on quadric error cost +//! to produce simplified meshes that preserve silhouettes and geometric features +//! better than uniform downsampling. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const rhi_types = @import("../../engine/graphics/rhi_types.zig"); +const Vertex = rhi_types.Vertex; + +pub const SimplifiedMesh = struct { + vertices: []Vertex, + indices: []u32, + original_triangle_count: u32, + simplified_triangle_count: u32, + error_estimate: f64, +}; + +const QuadricMatrix = struct { + m: [10]f64 = [1]f64{0} ** 10, + + const zero: QuadricMatrix = .{ .m = [1]f64{0} ** 10 }; + + fn fromPlane(nx: f64, ny: f64, nz: f64, d: f64) QuadricMatrix { + return .{ .m = .{ + nx * nx, nx * ny, nx * nz, nx * d, + ny * ny, ny * nz, ny * d, nz * nz, + nz * d, d * d, + } }; + } + + fn add(a: QuadricMatrix, b: QuadricMatrix) QuadricMatrix { + var r: QuadricMatrix = .{}; + for (&r.m, &a.m, &b.m) |*dst, xa, xb| dst.* = xa + xb; + return r; + } + + fn evaluate(q: QuadricMatrix, x: f64, y: f64, z: f64) f64 { + const m = q.m; + const r0 = m[0] * x + m[1] * y + m[2] * z + m[3]; + const r1 = m[1] * x + m[4] * y + m[5] * z + m[6]; + const r2 = m[2] * x + m[5] * y + m[7] * z + m[8]; + const r3 = m[3] * x + m[6] * y + m[8] * z + m[9]; + return x * r0 + y * r1 + z * r2 + r3; + } + + const OptimalResult = struct { + pos: [3]f64, + solvable: bool, + }; + + fn optimalPosition(q: QuadricMatrix, fa: [3]f64, fb: [3]f64) OptimalResult { + const m = q.m; + const a00 = m[0]; + const a01 = m[1]; + const a02 = m[2]; + const a11 = m[4]; + const a12 = m[5]; + const a22 = m[7]; + const b0 = -m[3]; + const b1 = -m[6]; + const b2 = -m[8]; + + const det = a00 * (a11 * a22 - a12 * a12) - + a01 * (a01 * a22 - a12 * a02) + + a02 * (a01 * a12 - a11 * a02); + + if (@abs(det) < 1e-10) { + return .{ + .pos = .{ + (fa[0] + fb[0]) * 0.5, + (fa[1] + fb[1]) * 0.5, + (fa[2] + fb[2]) * 0.5, + }, + .solvable = false, + }; + } + + const inv = 1.0 / det; + const dx = (b0 * (a11 * a22 - a12 * a12) - + a01 * (b1 * a22 - a12 * b2) + + a02 * (b1 * a12 - a11 * b2)) * inv; + const dy = (a00 * (b1 * a22 - a12 * b2) - + b0 * (a01 * a22 - a12 * a02) + + a02 * (a01 * b2 - b1 * a02)) * inv; + const dz = (a00 * (a11 * b2 - b1 * a12) - + a01 * (a01 * b2 - b1 * a02) + + b0 * (a01 * a12 - a11 * a02)) * inv; + + return .{ .pos = .{ dx, dy, dz }, .solvable = true }; + } +}; + +fn edgeKey(v1: u32, v2: u32) u64 { + const lo = @min(v1, v2); + const hi = @max(v1, v2); + return (@as(u64, @intCast(lo)) << 32) | @as(u64, @intCast(hi)); +} + +const EdgeEntry = struct { + v1: u32, + v2: u32, + cost: f64, + version: u32, +}; + +fn compareEdges(_: void, a: EdgeEntry, b: EdgeEntry) std.math.Order { + if (a.cost < b.cost) return .lt; + if (a.cost > b.cost) return .gt; + return .eq; +} + +pub const QuadricSimplifier = struct { + pub fn simplify( + allocator: Allocator, + vertices: []const Vertex, + indices: []const u32, + target_triangles: u32, + ) !SimplifiedMesh { + if (indices.len % 3 != 0) return error.InvalidIndexCount; + const num_triangles: u32 = @intCast(indices.len / 3); + + if (vertices.len == 0 or num_triangles == 0) { + return .{ + .vertices = &.{}, + .indices = &.{}, + .original_triangle_count = 0, + .simplified_triangle_count = 0, + .error_estimate = 0, + }; + } + + if (target_triangles >= num_triangles) { + const out_v = try allocator.dupe(Vertex, vertices); + errdefer allocator.free(out_v); + const out_i = try allocator.dupe(u32, indices); + return .{ + .vertices = out_v, + .indices = out_i, + .original_triangle_count = num_triangles, + .simplified_triangle_count = num_triangles, + .error_estimate = 0, + }; + } + + const n: u32 = @intCast(vertices.len); + + var pos = try allocator.alloc([3]f64, n); + defer allocator.free(pos); + for (pos, vertices) |*p, v| { + p.* = .{ + @floatCast(v.pos[0]), + @floatCast(v.pos[1]), + @floatCast(v.pos[2]), + }; + } + + var active = try allocator.alloc(bool, n); + defer allocator.free(active); + @memset(active, true); + + var quadrics = try allocator.alloc(QuadricMatrix, n); + defer allocator.free(quadrics); + @memset(quadrics, QuadricMatrix.zero); + + var tris = try allocator.alloc([3]u32, num_triangles); + defer allocator.free(tris); + for (tris, 0..) |*t, i| { + t.* = .{ indices[i * 3], indices[i * 3 + 1], indices[i * 3 + 2] }; + } + + var tri_active = try allocator.alloc(bool, num_triangles); + defer allocator.free(tri_active); + @memset(tri_active, true); + + computeQuadrics(quadrics, pos, tris, tri_active); + + var edge_versions = std.AutoHashMap(u64, u32).init(allocator); + defer edge_versions.deinit(); + + var heap = std.PriorityQueue(EdgeEntry, void, compareEdges).init(allocator, {}); + defer heap.deinit(); + + { + var edge_set = std.AutoHashMap(u64, void).init(allocator); + defer edge_set.deinit(); + + for (tris, 0..) |tri, ti| { + if (!tri_active[ti]) continue; + const pairs = [3][2]u32{ + .{ tri[0], tri[1] }, + .{ tri[1], tri[2] }, + .{ tri[2], tri[0] }, + }; + for (&pairs) |pair| { + if (pair[0] == pair[1]) continue; + const key = edgeKey(pair[0], pair[1]); + if (edge_set.contains(key)) continue; + try edge_set.put(key, {}); + + const combined = QuadricMatrix.add(quadrics[pair[0]], quadrics[pair[1]]); + const result = combined.optimalPosition(pos[pair[0]], pos[pair[1]]); + const cost = @max(QuadricMatrix.evaluate(combined, result.pos[0], result.pos[1], result.pos[2]), 0); + + try edge_versions.put(key, 0); + try heap.add(.{ + .v1 = pair[0], + .v2 = pair[1], + .cost = cost, + .version = 0, + }); + } + } + } + + var current_tri_count = num_triangles; + var max_error: f64 = 0; + + while (current_tri_count > target_triangles) { + var entry: ?EdgeEntry = null; + while (heap.removeOrNull()) |e| { + if (!active[e.v1] or !active[e.v2]) continue; + if (e.v1 == e.v2) continue; + const key = edgeKey(e.v1, e.v2); + const ver = edge_versions.get(key) orelse continue; + if (e.version != ver) continue; + if (!canCollapse(tris, tri_active, e.v1, e.v2)) continue; + entry = e; + break; + } + + const e = entry orelse break; + + const combined = QuadricMatrix.add(quadrics[e.v1], quadrics[e.v2]); + const result = combined.optimalPosition(pos[e.v1], pos[e.v2]); + + if (wouldFlipNormal(pos, tris, tri_active, e.v1, e.v2, result.pos)) { + const mid: [3]f64 = .{ + (pos[e.v1][0] + pos[e.v2][0]) * 0.5, + (pos[e.v1][1] + pos[e.v2][1]) * 0.5, + (pos[e.v1][2] + pos[e.v2][2]) * 0.5, + }; + if (wouldFlipNormal(pos, tris, tri_active, e.v1, e.v2, mid)) continue; + pos[e.v1] = mid; + } else { + pos[e.v1] = result.pos; + } + + const cost = @max(QuadricMatrix.evaluate(combined, pos[e.v1][0], pos[e.v1][1], pos[e.v1][2]), 0); + if (cost > max_error) max_error = cost; + + quadrics[e.v1] = combined; + active[e.v2] = false; + + var neighbor_buf: [512]u32 = undefined; + var neighbor_count: usize = 0; + + for (tris, 0..) |tri, ti| { + if (!tri_active[ti]) continue; + var has_v1 = false; + var has_v2 = false; + for (tri) |v| { + if (v == e.v1) has_v1 = true; + if (v == e.v2) has_v2 = true; + } + + if (has_v1 and has_v2) { + tri_active[ti] = false; + current_tri_count -= 1; + } else if (has_v2) { + for (tri) |v| { + if (v != e.v1 and v != e.v2) { + addToList(&neighbor_buf, &neighbor_count, v); + } + } + for (0..3) |j| { + if (tris[ti][j] == e.v2) tris[ti][j] = e.v1; + } + } + } + + for (tris, 0..) |tri, ti| { + if (!tri_active[ti]) continue; + var has_v1 = false; + for (tri) |v| { + if (v == e.v1) { + has_v1 = true; + break; + } + } + if (!has_v1) continue; + for (tri) |v| { + if (v != e.v1) { + addToList(&neighbor_buf, &neighbor_count, v); + } + } + } + + for (neighbor_buf[0..neighbor_count]) |neighbor| { + if (!active[neighbor]) continue; + if (neighbor == e.v1) continue; + const key = edgeKey(e.v1, neighbor); + const new_combined = QuadricMatrix.add(quadrics[e.v1], quadrics[neighbor]); + const new_result = new_combined.optimalPosition(pos[e.v1], pos[neighbor]); + const new_cost = @max(new_combined.evaluate(new_result.pos[0], new_result.pos[1], new_result.pos[2]), 0); + const cur_ver = edge_versions.get(key) orelse 0; + const new_ver = cur_ver + 1; + try edge_versions.put(key, new_ver); + try heap.add(.{ + .v1 = e.v1, + .v2 = neighbor, + .cost = new_cost, + .version = new_ver, + }); + } + } + + return collectResults(allocator, vertices, pos, active, tris, tri_active, num_triangles, current_tri_count, max_error); + } +}; + +fn computeQuadrics( + quadrics: []QuadricMatrix, + pos: []const [3]f64, + tris: []const [3]u32, + tri_active: []const bool, +) void { + for (tris, 0..) |tri, ti| { + if (!tri_active[ti]) continue; + + const p0 = pos[tri[0]]; + const p1 = pos[tri[1]]; + const p2 = pos[tri[2]]; + + const e1x = p1[0] - p0[0]; + const e1y = p1[1] - p0[1]; + const e1z = p1[2] - p0[2]; + const e2x = p2[0] - p0[0]; + const e2y = p2[1] - p0[1]; + const e2z = p2[2] - p0[2]; + + const nx = e1y * e2z - e1z * e2y; + const ny = e1z * e2x - e1x * e2z; + const nz = e1x * e2y - e1y * e2x; + + const len = @sqrt(nx * nx + ny * ny + nz * nz); + if (len < 1e-20) continue; + + const inv_len = 1.0 / len; + const nnx = nx * inv_len; + const nny = ny * inv_len; + const nnz = nz * inv_len; + + const d = -(nnx * p0[0] + nny * p0[1] + nnz * p0[2]); + const q = QuadricMatrix.fromPlane(nnx, nny, nnz, d); + quadrics[tri[0]] = QuadricMatrix.add(quadrics[tri[0]], q); + quadrics[tri[1]] = QuadricMatrix.add(quadrics[tri[1]], q); + quadrics[tri[2]] = QuadricMatrix.add(quadrics[tri[2]], q); + } +} + +fn canCollapse( + tris: []const [3]u32, + tri_active: []const bool, + v1: u32, + v2: u32, +) bool { + var link_edge: [128]u32 = undefined; + var link_edge_len: usize = 0; + var link_v1: [256]u32 = undefined; + var link_v1_len: usize = 0; + var link_v2: [256]u32 = undefined; + var link_v2_len: usize = 0; + + for (tris, 0..) |tri, ti| { + if (!tri_active[ti]) continue; + + const has_v1 = tri[0] == v1 or tri[1] == v1 or tri[2] == v1; + const has_v2 = tri[0] == v2 or tri[1] == v2 or tri[2] == v2; + + if (!has_v1 and !has_v2) continue; + + for (tri) |v| { + if (v == v1 or v == v2) continue; + + if (has_v1 and has_v2) addToSet(&link_edge, &link_edge_len, v); + if (has_v1) addToSet(&link_v1, &link_v1_len, v); + if (has_v2) addToSet(&link_v2, &link_v2_len, v); + } + } + + for (link_v1[0..link_v1_len]) |n1| { + for (link_v2[0..link_v2_len]) |n2| { + if (n1 != n2) continue; + var found = false; + for (link_edge[0..link_edge_len]) |le| { + if (le == n1) { + found = true; + break; + } + } + if (!found) return false; + } + } + + return true; +} + +fn addToSet(buf: []u32, len: *usize, val: u32) void { + for (buf[0..len.*]) |v| { + if (v == val) return; + } + if (len.* < buf.len) { + buf[len.*] = val; + len.* += 1; + } +} + +fn addToList(buf: []u32, len: *usize, val: u32) void { + if (len.* < buf.len) { + buf[len.*] = val; + len.* += 1; + } +} + +fn wouldFlipNormal( + pos: []const [3]f64, + tris: []const [3]u32, + tri_active: []const bool, + v1: u32, + v2: u32, + new_pos: [3]f64, +) bool { + for (tris, 0..) |tri, ti| { + if (!tri_active[ti]) continue; + + const has_v1 = tri[0] == v1 or tri[1] == v1 or tri[2] == v1; + const has_v2 = tri[0] == v2 or tri[1] == v2 or tri[2] == v2; + if (!has_v1 or has_v2) continue; + + var old_pts: [3][3]f64 = undefined; + var new_pts: [3][3]f64 = undefined; + + for (tri, 0..) |v, j| { + if (v == v1) { + old_pts[j] = pos[v1]; + new_pts[j] = new_pos; + } else { + old_pts[j] = pos[v]; + new_pts[j] = pos[v]; + } + } + + const old_n = triNormal(old_pts[0], old_pts[1], old_pts[2]); + const new_n = triNormal(new_pts[0], new_pts[1], new_pts[2]); + const dot = old_n[0] * new_n[0] + old_n[1] * new_n[1] + old_n[2] * new_n[2]; + if (dot < 0) return true; + } + return false; +} + +fn triNormal(a: [3]f64, b: [3]f64, c: [3]f64) [3]f64 { + const e1x = b[0] - a[0]; + const e1y = b[1] - a[1]; + const e1z = b[2] - a[2]; + const e2x = c[0] - a[0]; + const e2y = c[1] - a[1]; + const e2z = c[2] - a[2]; + const nx = e1y * e2z - e1z * e2y; + const ny = e1z * e2x - e1x * e2z; + const nz = e1x * e2y - e1y * e2x; + const len = @sqrt(nx * nx + ny * ny + nz * nz); + if (len < 1e-20) return .{ 0, 0, 0 }; + const inv = 1.0 / len; + return .{ nx * inv, ny * inv, nz * inv }; +} + +fn collectResults( + allocator: Allocator, + original_vertices: []const Vertex, + pos: []const [3]f64, + active: []const bool, + tris: []const [3]u32, + tri_active: []const bool, + num_tris: u32, + current_tri_count: u32, + max_error: f64, +) !SimplifiedMesh { + const n = active.len; + + var remap = try allocator.alloc(u32, n); + defer allocator.free(remap); + + var vertex_count: u32 = 0; + for (active, 0..) |is_active, i| { + if (is_active) { + remap[i] = vertex_count; + vertex_count += 1; + } else { + remap[i] = std.math.maxInt(u32); + } + } + + const out_verts = try allocator.alloc(Vertex, vertex_count); + var vi: u32 = 0; + for (active, 0..) |is_active, i| { + if (!is_active) continue; + out_verts[vi] = original_vertices[i]; + out_verts[vi].pos = .{ + @floatCast(pos[i][0]), + @floatCast(pos[i][1]), + @floatCast(pos[i][2]), + }; + vi += 1; + } + + const out_indices = try allocator.alloc(u32, @as(usize, @intCast(current_tri_count)) * 3); + var ii: usize = 0; + for (tris, 0..) |tri, ti| { + if (ti >= num_tris) break; + if (!tri_active[ti]) continue; + out_indices[ii] = remap[tri[0]]; + out_indices[ii + 1] = remap[tri[1]]; + out_indices[ii + 2] = remap[tri[2]]; + ii += 3; + } + + return .{ + .vertices = out_verts[0..vertex_count], + .indices = out_indices[0..ii], + .original_triangle_count = num_tris, + .simplified_triangle_count = current_tri_count, + .error_estimate = max_error, + }; +} + +fn makeVertex(x: f32, y: f32, z: f32) Vertex { + return .{ + .pos = .{ x, y, z }, + .color = .{ 1.0, 1.0, 1.0 }, + .normal = .{ 0.0, 1.0, 0.0 }, + .uv = .{ 0.0, 0.0 }, + .tile_id = -1.0, + .skylight = 1.0, + .blocklight = .{ 0.0, 0.0, 0.0 }, + .ao = 1.0, + }; +} + +fn createCube(allocator: Allocator) !struct { vertices: []Vertex, indices: []u32 } { + const verts = try allocator.alloc(Vertex, 8); + verts[0] = makeVertex(0, 0, 0); + verts[1] = makeVertex(1, 0, 0); + verts[2] = makeVertex(1, 1, 0); + verts[3] = makeVertex(0, 1, 0); + verts[4] = makeVertex(0, 0, 1); + verts[5] = makeVertex(1, 0, 1); + verts[6] = makeVertex(1, 1, 1); + verts[7] = makeVertex(0, 1, 1); + + const idx = try allocator.dupe(u32, &[_]u32{ + 4, 5, 6, 4, 6, 7, + 1, 0, 3, 1, 3, 2, + 5, 1, 2, 5, 2, 6, + 0, 4, 7, 0, 7, 3, + 7, 6, 2, 7, 2, 3, + 0, 1, 5, 0, 5, 4, + }); + + return .{ .vertices = verts, .indices = idx }; +} + +fn createSphere(allocator: Allocator, lat_divs: u32, lon_divs: u32) !struct { vertices: []Vertex, indices: []u32 } { + const num_rings = lat_divs - 1; + const num_verts: usize = 2 + @as(usize, num_rings) * lon_divs; + + const verts = try allocator.alloc(Vertex, num_verts); + + verts[0] = makeVertex(0.0, 1.0, 0.0); + + var vi: usize = 1; + for (1..lat_divs) |ring| { + const phi = std.math.pi * @as(f64, @floatFromInt(ring)) / @as(f64, @floatFromInt(lat_divs)); + for (0..lon_divs) |seg| { + const theta = 2.0 * std.math.pi * @as(f64, @floatFromInt(seg)) / @as(f64, @floatFromInt(lon_divs)); + const x: f32 = @floatCast(@sin(phi) * @cos(theta)); + const y: f32 = @floatCast(@cos(phi)); + const z: f32 = @floatCast(@sin(phi) * @sin(theta)); + verts[vi] = makeVertex(x, y, z); + vi += 1; + } + } + + verts[vi] = makeVertex(0.0, -1.0, 0.0); + + const south_pole_idx: u32 = @intCast(num_verts - 1); + const num_tris: usize = @as(usize, lon_divs) * 2 + @as(usize, num_rings - 1) * @as(usize, lon_divs) * 2; + const indices = try allocator.alloc(u32, num_tris * 3); + var ii: usize = 0; + + for (0..lon_divs) |seg| { + const next_seg = @as(u32, @intCast((seg + 1) % lon_divs)); + const seg_u32: u32 = @intCast(seg); + indices[ii] = 0; + indices[ii + 1] = 1 + seg_u32; + indices[ii + 2] = 1 + next_seg; + ii += 3; + } + + for (0..@intCast(num_rings - 1)) |ring| { + const ring_u32: u32 = @intCast(ring); + for (0..lon_divs) |seg| { + const next_seg = @as(u32, @intCast((seg + 1) % lon_divs)); + const seg_u32: u32 = @intCast(seg); + const curr = 1 + ring_u32 * lon_divs + seg_u32; + const curr_next = 1 + ring_u32 * lon_divs + next_seg; + const below = 1 + (ring_u32 + 1) * lon_divs + seg_u32; + const below_next = 1 + (ring_u32 + 1) * lon_divs + next_seg; + + indices[ii] = curr; + indices[ii + 1] = below; + indices[ii + 2] = below_next; + ii += 3; + + indices[ii] = curr; + indices[ii + 1] = below_next; + indices[ii + 2] = curr_next; + ii += 3; + } + } + + const last_ring_start: u32 = 1 + @as(u32, @intCast(num_rings - 1)) * lon_divs; + for (0..lon_divs) |seg| { + const next_seg = @as(u32, @intCast((seg + 1) % lon_divs)); + const seg_u32: u32 = @intCast(seg); + indices[ii] = south_pole_idx; + indices[ii + 1] = last_ring_start + next_seg; + indices[ii + 2] = last_ring_start + seg_u32; + ii += 3; + } + + return .{ .vertices = verts, .indices = indices[0..ii] }; +} + +test "simplify cube 12 to 4 triangles" { + const testing = std.testing; + const allocator = testing.allocator; + + const cube = try createCube(allocator); + defer { + allocator.free(cube.vertices); + allocator.free(cube.indices); + } + + const result = try QuadricSimplifier.simplify(allocator, cube.vertices, cube.indices, 4); + defer { + allocator.free(result.vertices); + allocator.free(result.indices); + } + + try testing.expectEqual(@as(u32, 12), result.original_triangle_count); + try testing.expect(result.simplified_triangle_count <= 4); + try testing.expect(result.error_estimate >= 0); + try testing.expect(result.indices.len % 3 == 0); +} + +test "simplify sphere preserves shape" { + const testing = std.testing; + const allocator = testing.allocator; + + const sphere = try createSphere(allocator, 10, 20); + defer { + allocator.free(sphere.vertices); + allocator.free(sphere.indices); + } + + const original_tris: u32 = @intCast(sphere.indices.len / 3); + const target = original_tris / 2; + + const result = try QuadricSimplifier.simplify(allocator, sphere.vertices, sphere.indices, target); + defer { + allocator.free(result.vertices); + allocator.free(result.indices); + } + + try testing.expect(result.simplified_triangle_count <= target); + try testing.expect(result.simplified_triangle_count > 0); + + for (result.vertices) |v| { + const dist = @sqrt(v.pos[0] * v.pos[0] + v.pos[1] * v.pos[1] + v.pos[2] * v.pos[2]); + try testing.expect(dist < 2.0); + } +} + +test "no-op when target >= actual triangles" { + const testing = std.testing; + const allocator = testing.allocator; + + const cube = try createCube(allocator); + defer { + allocator.free(cube.vertices); + allocator.free(cube.indices); + } + + const result = try QuadricSimplifier.simplify(allocator, cube.vertices, cube.indices, 20); + defer { + allocator.free(result.vertices); + allocator.free(result.indices); + } + + try testing.expectEqual(@as(u32, 12), result.simplified_triangle_count); + try testing.expectEqualSlices(u32, cube.indices, result.indices); + try testing.expectEqual(@as(u32, 0), @as(u32, @intFromFloat(result.error_estimate))); +} + +test "target zero triangles does not crash" { + const testing = std.testing; + const allocator = testing.allocator; + + const cube = try createCube(allocator); + defer { + allocator.free(cube.vertices); + allocator.free(cube.indices); + } + + const result = try QuadricSimplifier.simplify(allocator, cube.vertices, cube.indices, 0); + defer { + allocator.free(result.vertices); + allocator.free(result.indices); + } + + try testing.expect(result.simplified_triangle_count < 12); + try testing.expect(result.error_estimate >= 0); +} + +test "single triangle input produces single triangle output" { + const testing = std.testing; + const allocator = testing.allocator; + + const verts = try allocator.alloc(Vertex, 3); + defer allocator.free(verts); + verts[0] = makeVertex(0, 0, 0); + verts[1] = makeVertex(1, 0, 0); + verts[2] = makeVertex(0, 1, 0); + + const indices = try allocator.dupe(u32, &[_]u32{ 0, 1, 2 }); + defer allocator.free(indices); + + const result = try QuadricSimplifier.simplify(allocator, verts, indices, 1); + defer { + allocator.free(result.vertices); + allocator.free(result.indices); + } + + try testing.expectEqual(@as(u32, 1), result.simplified_triangle_count); + try testing.expectEqual(@as(usize, 3), result.indices.len); +} + +test "empty mesh returns empty result" { + const testing = std.testing; + const allocator = testing.allocator; + + const result = try QuadricSimplifier.simplify(allocator, &.{}, &.{}, 0); + try testing.expectEqual(@as(usize, 0), result.vertices.len); + try testing.expectEqual(@as(usize, 0), result.indices.len); +} + +test "performance simplify mesh" { + const testing = std.testing; + const allocator = testing.allocator; + + const sphere = try createSphere(allocator, 20, 40); + defer { + allocator.free(sphere.vertices); + allocator.free(sphere.indices); + } + + const original_tris: u32 = @intCast(sphere.indices.len / 3); + const target = original_tris / 4; + + const start = std.time.Instant.now() catch return; + const result = try QuadricSimplifier.simplify(allocator, sphere.vertices, sphere.indices, target); + const end = std.time.Instant.now() catch return; + const elapsed_ns = end.since(start); + const elapsed_ms = @as(f64, @floatFromInt(elapsed_ns)) / 1_000_000.0; + + defer { + allocator.free(result.vertices); + allocator.free(result.indices); + } + + try testing.expect(result.simplified_triangle_count <= target); + try testing.expect(elapsed_ms < 2000.0); +} From c7cfc5067884a6f6f37beddc32f86274d990e790 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Thu, 2 Apr 2026 01:10:33 +0100 Subject: [PATCH 2/6] fix: address code review issues in quadric simplifier - [CRITICAL] Add errdefer for out_i to prevent memory leak on alloc failure - [HIGH] Replace fixed-size neighbor_buf with dynamically allocated buffer sized to vertex count - [HIGH] Add overflow-safe addToSetChecked in canCollapse; returns true (accepts collapse) on buffer overflow to avoid incorrect rejection - [HIGH] Replace silent-drop addToSet with addUnique that asserts on overflow - [MEDIUM] Extract DETERMINANT_EPSILON as named constant - [MEDIUM] Track skipped_collapses in SimplifiedMesh - [MEDIUM] Handle zero-size vertex/index allocations in collectResults - [LOW] Document memory ownership in module-level and struct doc comments --- src/world/meshing/quadric_simplifier.zig | 96 ++++++++++++++++++------ 1 file changed, 73 insertions(+), 23 deletions(-) diff --git a/src/world/meshing/quadric_simplifier.zig b/src/world/meshing/quadric_simplifier.zig index 0de84cc..439fd7a 100644 --- a/src/world/meshing/quadric_simplifier.zig +++ b/src/world/meshing/quadric_simplifier.zig @@ -4,6 +4,9 @@ //! for mesh simplification. Iteratively collapses edges based on quadric error cost //! to produce simplified meshes that preserve silhouettes and geometric features //! better than uniform downsampling. +//! +//! Memory ownership: All slices returned in `SimplifiedMesh` are owned by the caller +//! and must be freed with the same allocator passed to `simplify()`. const std = @import("std"); const Allocator = std.mem.Allocator; @@ -17,6 +20,7 @@ pub const SimplifiedMesh = struct { original_triangle_count: u32, simplified_triangle_count: u32, error_estimate: f64, + skipped_collapses: u32, }; const QuadricMatrix = struct { @@ -68,7 +72,7 @@ const QuadricMatrix = struct { a01 * (a01 * a22 - a12 * a02) + a02 * (a01 * a12 - a11 * a02); - if (@abs(det) < 1e-10) { + if (@abs(det) < DETERMINANT_EPSILON) { return .{ .pos = .{ (fa[0] + fb[0]) * 0.5, @@ -94,6 +98,8 @@ const QuadricMatrix = struct { } }; +const DETERMINANT_EPSILON: f64 = 1e-10; + fn edgeKey(v1: u32, v2: u32) u64 { const lo = @min(v1, v2); const hi = @max(v1, v2); @@ -130,6 +136,7 @@ pub const QuadricSimplifier = struct { .original_triangle_count = 0, .simplified_triangle_count = 0, .error_estimate = 0, + .skipped_collapses = 0, }; } @@ -137,12 +144,14 @@ pub const QuadricSimplifier = struct { const out_v = try allocator.dupe(Vertex, vertices); errdefer allocator.free(out_v); const out_i = try allocator.dupe(u32, indices); + errdefer allocator.free(out_i); return .{ .vertices = out_v, .indices = out_i, .original_triangle_count = num_triangles, .simplified_triangle_count = num_triangles, .error_estimate = 0, + .skipped_collapses = 0, }; } @@ -216,8 +225,12 @@ pub const QuadricSimplifier = struct { } } + var neighbor_buf = try allocator.alloc(u32, n); + defer allocator.free(neighbor_buf); + var current_tri_count = num_triangles; var max_error: f64 = 0; + var skipped_collapses: u32 = 0; while (current_tri_count > target_triangles) { var entry: ?EdgeEntry = null; @@ -243,7 +256,10 @@ pub const QuadricSimplifier = struct { (pos[e.v1][1] + pos[e.v2][1]) * 0.5, (pos[e.v1][2] + pos[e.v2][2]) * 0.5, }; - if (wouldFlipNormal(pos, tris, tri_active, e.v1, e.v2, mid)) continue; + if (wouldFlipNormal(pos, tris, tri_active, e.v1, e.v2, mid)) { + skipped_collapses += 1; + continue; + } pos[e.v1] = mid; } else { pos[e.v1] = result.pos; @@ -255,7 +271,6 @@ pub const QuadricSimplifier = struct { quadrics[e.v1] = combined; active[e.v2] = false; - var neighbor_buf: [512]u32 = undefined; var neighbor_count: usize = 0; for (tris, 0..) |tri, ti| { @@ -273,7 +288,7 @@ pub const QuadricSimplifier = struct { } else if (has_v2) { for (tri) |v| { if (v != e.v1 and v != e.v2) { - addToList(&neighbor_buf, &neighbor_count, v); + addUnique(neighbor_buf, &neighbor_count, v); } } for (0..3) |j| { @@ -294,7 +309,7 @@ pub const QuadricSimplifier = struct { if (!has_v1) continue; for (tri) |v| { if (v != e.v1) { - addToList(&neighbor_buf, &neighbor_count, v); + addUnique(neighbor_buf, &neighbor_count, v); } } } @@ -318,7 +333,7 @@ pub const QuadricSimplifier = struct { } } - return collectResults(allocator, vertices, pos, active, tris, tri_active, num_triangles, current_tri_count, max_error); + return collectResults(allocator, vertices, pos, active, tris, tri_active, num_triangles, current_tri_count, max_error, skipped_collapses); } }; @@ -368,11 +383,12 @@ fn canCollapse( v1: u32, v2: u32, ) bool { - var link_edge: [128]u32 = undefined; + const MAX_VALENCE: usize = 512; + var link_edge: [MAX_VALENCE]u32 = undefined; var link_edge_len: usize = 0; - var link_v1: [256]u32 = undefined; + var link_v1: [MAX_VALENCE]u32 = undefined; var link_v1_len: usize = 0; - var link_v2: [256]u32 = undefined; + var link_v2: [MAX_VALENCE]u32 = undefined; var link_v2_len: usize = 0; for (tris, 0..) |tri, ti| { @@ -386,9 +402,15 @@ fn canCollapse( for (tri) |v| { if (v == v1 or v == v2) continue; - if (has_v1 and has_v2) addToSet(&link_edge, &link_edge_len, v); - if (has_v1) addToSet(&link_v1, &link_v1_len, v); - if (has_v2) addToSet(&link_v2, &link_v2_len, v); + if (has_v1 and has_v2) { + if (!addToSetChecked(&link_edge, &link_edge_len, v)) return true; + } + if (has_v1) { + if (!addToSetChecked(&link_v1, &link_v1_len, v)) return true; + } + if (has_v2) { + if (!addToSetChecked(&link_v2, &link_v2_len, v)) return true; + } } } @@ -409,21 +431,23 @@ fn canCollapse( return true; } -fn addToSet(buf: []u32, len: *usize, val: u32) void { +fn addToSetChecked(buf: []u32, len: *usize, val: u32) bool { for (buf[0..len.*]) |v| { - if (v == val) return; - } - if (len.* < buf.len) { - buf[len.*] = val; - len.* += 1; + if (v == val) return true; } + if (len.* >= buf.len) return false; + buf[len.*] = val; + len.* += 1; + return true; } -fn addToList(buf: []u32, len: *usize, val: u32) void { - if (len.* < buf.len) { - buf[len.*] = val; - len.* += 1; +fn addUnique(buf: []u32, len: *usize, val: u32) void { + std.debug.assert(len.* < buf.len); + for (buf[0..len.*]) |v| { + if (v == val) return; } + buf[len.*] = val; + len.* += 1; } fn wouldFlipNormal( @@ -488,6 +512,7 @@ fn collectResults( num_tris: u32, current_tri_count: u32, max_error: f64, + skipped: u32, ) !SimplifiedMesh { const n = active.len; @@ -504,6 +529,17 @@ fn collectResults( } } + if (vertex_count == 0) { + return .{ + .vertices = &.{}, + .indices = &.{}, + .original_triangle_count = num_tris, + .simplified_triangle_count = 0, + .error_estimate = max_error, + .skipped_collapses = skipped, + }; + } + const out_verts = try allocator.alloc(Vertex, vertex_count); var vi: u32 = 0; for (active, 0..) |is_active, i| { @@ -517,7 +553,20 @@ fn collectResults( vi += 1; } - const out_indices = try allocator.alloc(u32, @as(usize, @intCast(current_tri_count)) * 3); + const index_count: usize = @as(usize, @intCast(current_tri_count)) * 3; + if (index_count == 0) { + allocator.free(out_verts); + return .{ + .vertices = &.{}, + .indices = &.{}, + .original_triangle_count = num_tris, + .simplified_triangle_count = 0, + .error_estimate = max_error, + .skipped_collapses = skipped, + }; + } + + const out_indices = try allocator.alloc(u32, index_count); var ii: usize = 0; for (tris, 0..) |tri, ti| { if (ti >= num_tris) break; @@ -534,6 +583,7 @@ fn collectResults( .original_triangle_count = num_tris, .simplified_triangle_count = current_tri_count, .error_estimate = max_error, + .skipped_collapses = skipped, }; } From 45a6a0447a1190a87cb825de48284fa5fddb8efa Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Thu, 2 Apr 2026 01:37:37 +0100 Subject: [PATCH 3/6] fix: harden quadric simplifier collapse checks --- src/world/meshing/quadric_simplifier.zig | 52 +++++++++++------------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/src/world/meshing/quadric_simplifier.zig b/src/world/meshing/quadric_simplifier.zig index 439fd7a..2ed48bf 100644 --- a/src/world/meshing/quadric_simplifier.zig +++ b/src/world/meshing/quadric_simplifier.zig @@ -98,6 +98,8 @@ const QuadricMatrix = struct { } }; +/// Epsilon for determinant check in optimal position solver. +/// Safe for mesh vertices in [0.001, 1000.0] range -- voxel chunks use block-unit coordinates. const DETERMINANT_EPSILON: f64 = 1e-10; fn edgeKey(v1: u32, v2: u32) u64 { @@ -233,6 +235,8 @@ pub const QuadricSimplifier = struct { var skipped_collapses: u32 = 0; while (current_tri_count > target_triangles) { + if (skipped_collapses > num_triangles * 2) break; + var entry: ?EdgeEntry = null; while (heap.removeOrNull()) |e| { if (!active[e.v1] or !active[e.v2]) continue; @@ -240,7 +244,7 @@ pub const QuadricSimplifier = struct { const key = edgeKey(e.v1, e.v2); const ver = edge_versions.get(key) orelse continue; if (e.version != ver) continue; - if (!canCollapse(tris, tri_active, e.v1, e.v2)) continue; + if (!try canCollapse(allocator, tris, tri_active, e.v1, e.v2)) continue; entry = e; break; } @@ -378,18 +382,18 @@ fn computeQuadrics( } fn canCollapse( + allocator: Allocator, tris: []const [3]u32, tri_active: []const bool, v1: u32, v2: u32, -) bool { - const MAX_VALENCE: usize = 512; - var link_edge: [MAX_VALENCE]u32 = undefined; - var link_edge_len: usize = 0; - var link_v1: [MAX_VALENCE]u32 = undefined; - var link_v1_len: usize = 0; - var link_v2: [MAX_VALENCE]u32 = undefined; - var link_v2_len: usize = 0; +) !bool { + var link_edge = std.ArrayListUnmanaged(u32){}; + defer link_edge.deinit(allocator); + var link_v1 = std.ArrayListUnmanaged(u32){}; + defer link_v1.deinit(allocator); + var link_v2 = std.ArrayListUnmanaged(u32){}; + defer link_v2.deinit(allocator); for (tris, 0..) |tri, ti| { if (!tri_active[ti]) continue; @@ -402,23 +406,17 @@ fn canCollapse( for (tri) |v| { if (v == v1 or v == v2) continue; - if (has_v1 and has_v2) { - if (!addToSetChecked(&link_edge, &link_edge_len, v)) return true; - } - if (has_v1) { - if (!addToSetChecked(&link_v1, &link_v1_len, v)) return true; - } - if (has_v2) { - if (!addToSetChecked(&link_v2, &link_v2_len, v)) return true; - } + if (has_v1 and has_v2) try addUniqueGrowable(allocator, &link_edge, v); + if (has_v1) try addUniqueGrowable(allocator, &link_v1, v); + if (has_v2) try addUniqueGrowable(allocator, &link_v2, v); } } - for (link_v1[0..link_v1_len]) |n1| { - for (link_v2[0..link_v2_len]) |n2| { + for (link_v1.items) |n1| { + for (link_v2.items) |n2| { if (n1 != n2) continue; var found = false; - for (link_edge[0..link_edge_len]) |le| { + for (link_edge.items) |le| { if (le == n1) { found = true; break; @@ -431,14 +429,11 @@ fn canCollapse( return true; } -fn addToSetChecked(buf: []u32, len: *usize, val: u32) bool { - for (buf[0..len.*]) |v| { - if (v == val) return true; +fn addUniqueGrowable(allocator: Allocator, list: *std.ArrayListUnmanaged(u32), val: u32) !void { + for (list.items) |v| { + if (v == val) return; } - if (len.* >= buf.len) return false; - buf[len.*] = val; - len.* += 1; - return true; + try list.append(allocator, val); } fn addUnique(buf: []u32, len: *usize, val: u32) void { @@ -541,6 +536,7 @@ fn collectResults( } const out_verts = try allocator.alloc(Vertex, vertex_count); + errdefer allocator.free(out_verts); var vi: u32 = 0; for (active, 0..) |is_active, i| { if (!is_active) continue; From 1f7d88b7c71e655ad0fbd6fd6e89e19dc31b2481 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Thu, 2 Apr 2026 01:55:10 +0100 Subject: [PATCH 4/6] fix: make quadric simplifier tests vertex-shape agnostic --- src/world/meshing/quadric_simplifier.zig | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/world/meshing/quadric_simplifier.zig b/src/world/meshing/quadric_simplifier.zig index 2ed48bf..b67f98c 100644 --- a/src/world/meshing/quadric_simplifier.zig +++ b/src/world/meshing/quadric_simplifier.zig @@ -584,16 +584,9 @@ fn collectResults( } fn makeVertex(x: f32, y: f32, z: f32) Vertex { - return .{ - .pos = .{ x, y, z }, - .color = .{ 1.0, 1.0, 1.0 }, - .normal = .{ 0.0, 1.0, 0.0 }, - .uv = .{ 0.0, 0.0 }, - .tile_id = -1.0, - .skylight = 1.0, - .blocklight = .{ 0.0, 0.0, 0.0 }, - .ao = 1.0, - }; + var v: Vertex = std.mem.zeroes(Vertex); + v.pos = .{ x, y, z }; + return v; } fn createCube(allocator: Allocator) !struct { vertices: []Vertex, indices: []u32 } { From 4835def128559efb463e52732a14922c480eed2f Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Thu, 2 Apr 2026 01:57:44 +0100 Subject: [PATCH 5/6] fix: update quadric simplifier test helpers for Vertex API --- src/world/meshing/quadric_simplifier.zig | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/world/meshing/quadric_simplifier.zig b/src/world/meshing/quadric_simplifier.zig index b67f98c..737c457 100644 --- a/src/world/meshing/quadric_simplifier.zig +++ b/src/world/meshing/quadric_simplifier.zig @@ -15,11 +15,17 @@ const rhi_types = @import("../../engine/graphics/rhi_types.zig"); const Vertex = rhi_types.Vertex; pub const SimplifiedMesh = struct { + /// Caller owns these slices and must free them with the same allocator. vertices: []Vertex, + /// Caller owns these slices and must free them with the same allocator. indices: []u32, + /// Number of triangles in the input mesh. original_triangle_count: u32, + /// Number of triangles in the simplified output. simplified_triangle_count: u32, + /// Maximum quadric error observed during simplification. error_estimate: f64, + /// Number of candidate collapses rejected by normal-flip checks. skipped_collapses: u32, }; @@ -102,6 +108,10 @@ const QuadricMatrix = struct { /// Safe for mesh vertices in [0.001, 1000.0] range -- voxel chunks use block-unit coordinates. const DETERMINANT_EPSILON: f64 = 1e-10; +/// Skip-collapse guard multiplier. The simplifier stops after +/// `num_triangles * MAX_SKIP_MULTIPLIER` rejected collapses. +const MAX_SKIP_MULTIPLIER: u32 = 2; + fn edgeKey(v1: u32, v2: u32) u64 { const lo = @min(v1, v2); const hi = @max(v1, v2); @@ -235,7 +245,7 @@ pub const QuadricSimplifier = struct { var skipped_collapses: u32 = 0; while (current_tri_count > target_triangles) { - if (skipped_collapses > num_triangles * 2) break; + if (skipped_collapses > num_triangles * MAX_SKIP_MULTIPLIER) break; var entry: ?EdgeEntry = null; while (heap.removeOrNull()) |e| { @@ -292,7 +302,7 @@ pub const QuadricSimplifier = struct { } else if (has_v2) { for (tri) |v| { if (v != e.v1 and v != e.v2) { - addUnique(neighbor_buf, &neighbor_count, v); + try addUnique(neighbor_buf, &neighbor_count, v); } } for (0..3) |j| { @@ -313,7 +323,7 @@ pub const QuadricSimplifier = struct { if (!has_v1) continue; for (tri) |v| { if (v != e.v1) { - addUnique(neighbor_buf, &neighbor_count, v); + try addUnique(neighbor_buf, &neighbor_count, v); } } } @@ -436,8 +446,8 @@ fn addUniqueGrowable(allocator: Allocator, list: *std.ArrayListUnmanaged(u32), v try list.append(allocator, val); } -fn addUnique(buf: []u32, len: *usize, val: u32) void { - std.debug.assert(len.* < buf.len); +fn addUnique(buf: []u32, len: *usize, val: u32) !void { + if (len.* >= buf.len) return error.BufferOverflow; for (buf[0..len.*]) |v| { if (v == val) return; } @@ -584,9 +594,7 @@ fn collectResults( } fn makeVertex(x: f32, y: f32, z: f32) Vertex { - var v: Vertex = std.mem.zeroes(Vertex); - v.pos = .{ x, y, z }; - return v; + return Vertex.init(.{ x, y, z }, .{ 1.0, 1.0, 1.0 }, .{ 0.0, 1.0, 0.0 }, .{ 0.0, 0.0 }, 0, 1.0, .{ 0.0, 0.0, 0.0 }, 1.0); } fn createCube(allocator: Allocator) !struct { vertices: []Vertex, indices: []u32 } { From 3bd03474d7e7eb0c1af39a50ed36319d835ade3e Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Thu, 2 Apr 2026 02:35:32 +0100 Subject: [PATCH 6/6] fix: make quadric simplifier skip limit configurable --- src/world/meshing/quadric_simplifier.zig | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/world/meshing/quadric_simplifier.zig b/src/world/meshing/quadric_simplifier.zig index 737c457..17a224f 100644 --- a/src/world/meshing/quadric_simplifier.zig +++ b/src/world/meshing/quadric_simplifier.zig @@ -105,6 +105,7 @@ const QuadricMatrix = struct { }; /// Epsilon for determinant check in optimal position solver. +/// Chosen for double-precision stability with unit-scale meshes. /// Safe for mesh vertices in [0.001, 1000.0] range -- voxel chunks use block-unit coordinates. const DETERMINANT_EPSILON: f64 = 1e-10; @@ -137,6 +138,16 @@ pub const QuadricSimplifier = struct { vertices: []const Vertex, indices: []const u32, target_triangles: u32, + ) !SimplifiedMesh { + return simplifyWithOptions(allocator, vertices, indices, target_triangles, MAX_SKIP_MULTIPLIER); + } + + pub fn simplifyWithOptions( + allocator: Allocator, + vertices: []const Vertex, + indices: []const u32, + target_triangles: u32, + max_skip_multiplier: u32, ) !SimplifiedMesh { if (indices.len % 3 != 0) return error.InvalidIndexCount; const num_triangles: u32 = @intCast(indices.len / 3); @@ -245,7 +256,7 @@ pub const QuadricSimplifier = struct { var skipped_collapses: u32 = 0; while (current_tri_count > target_triangles) { - if (skipped_collapses > num_triangles * MAX_SKIP_MULTIPLIER) break; + if (skipped_collapses > num_triangles * max_skip_multiplier) break; var entry: ?EdgeEntry = null; while (heap.removeOrNull()) |e| {