Skip to content

Commit 4d403f9

Browse files
committed
fix: combine terrain+mesh on worker to eliminate pipeline holes
1 parent eeb74d4 commit 4d403f9

4 files changed

Lines changed: 161 additions & 91 deletions

File tree

src/client/world/ChunkLoadThread.zig

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const ChunkPos = shared.ChunkPos;
2121
const ChunkPosContext = shared.ChunkPosContext;
2222

2323
const SpiralIterator = @import("ChunkManager.zig").SpiralIterator;
24+
const ChunkStorage = @import("ChunkStorage.zig").ChunkStorage;
2425

2526
const tp = @import("ThreadPool.zig");
2627
const ThreadPool = tp.ThreadPool;
@@ -53,6 +54,9 @@ pub const ChunkLoadThread = struct {
5354
// Direct pool submission (thread-safe via mutex)
5455
pool: *ThreadPool,
5556

57+
// Read-only reference to chunk storage (uses getAtomic for safe cross-thread reads)
58+
storage: *ChunkStorage,
59+
5660
// Backpressure: tracks terrain tasks submitted but not yet completed
5761
// Incremented by this thread, decremented by worker threads after terrain generation
5862
in_flight_terrain: *std.atomic.Value(u32),
@@ -85,6 +89,7 @@ pub const ChunkLoadThread = struct {
8589
io: Io,
8690
pool: *ThreadPool,
8791
in_flight_terrain: *std.atomic.Value(u32),
92+
storage: *ChunkStorage,
8893
view_distance: u32,
8994
vertical_view_distance: u32,
9095
unload_distance: u32,
@@ -97,6 +102,7 @@ pub const ChunkLoadThread = struct {
97102
.io = io,
98103
.running = std.atomic.Value(bool).init(false),
99104
.pool = pool,
105+
.storage = storage,
100106
.in_flight_terrain = in_flight_terrain,
101107
.unload_requests = unload_requests,
102108
.player_chunk_x = std.atomic.Value(i32).init(0),
@@ -198,18 +204,14 @@ pub const ChunkLoadThread = struct {
198204
// Check if player position changed
199205
if (player.gen != self.last_gen) {
200206
self.last_gen = player.gen;
201-
202-
const pos_changed = !player.pos.eql(self.last_player);
203207
self.last_player = player.pos;
204208

205-
if (pos_changed) {
206-
// Reset spiral to new center
207-
self.spiral.reset(self.view_distance, self.vertical_view_distance);
208-
self.stalled_pos = null; // Stalled pos from old center is invalid
209-
210-
// Scan managed set for positions outside unload range
211-
self.scanForUnloads(player.pos);
212-
}
209+
// Always reset spiral and scan on any gen change.
210+
// Even if pos appears the same (round trip A→B→A), intermediate moves
211+
// may have caused terrain results to be discarded or chunks unloaded.
212+
self.spiral.reset(self.view_distance, self.vertical_view_distance);
213+
self.stalled_pos = null;
214+
self.scanForUnloads(player.pos);
213215
}
214216

215217
// Continue spiral iteration — submit terrain tasks directly to pool
@@ -293,9 +295,13 @@ pub const ChunkLoadThread = struct {
293295
.section_y = player_pos.section_y + offset.dy,
294296
};
295297

296-
// Skip if already managed
297298
const gop = self.managed.getOrPut(pos) catch continue;
298-
if (gop.found_existing) continue;
299+
if (gop.found_existing) {
300+
// Validate chunk actually exists in storage (terrain may have been
301+
// discarded as stale, or push to completed_terrain may have failed).
302+
// If chunk is gone, fall through to re-submit terrain.
303+
if (self.storage.getAtomic(pos) != null) continue;
304+
}
299305

300306
// Verify within view distance (spiral overshoots at corners)
301307
if (!pos.isWithinDistance(player_pos, self.view_distance)) {

src/client/world/ChunkManager.zig

Lines changed: 133 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ const MeshSchedulerThread = mesh_scheduler_thread.MeshSchedulerThread;
6666
pub const TerrainResult = struct {
6767
pos: ChunkPos,
6868
chunk: Chunk,
69+
/// Worker also generated and pushed an initial mesh to completed_queue.
70+
/// Main thread should set state .meshing (not .generated) and skip scheduler.
71+
mesh_submitted: bool = false,
6972
};
7073

7174
pub const ChunkConfig = struct {
@@ -252,7 +255,9 @@ pub const ChunkManager = struct {
252255
) !Self {
253256
logger.info("Initializing ChunkManager with view distance {}", .{config.view_distance});
254257

255-
const storage = try ChunkStorage.init(allocator, config.unload_distance, config.vertical_view_distance);
258+
// Vertical ring buffer must be sized for the actual unload threshold (vert + 2 hysteresis),
259+
// not just view distance. Otherwise positions at ±(vert+2) collide via floorMod.
260+
const storage = try ChunkStorage.init(allocator, config.unload_distance, config.vertical_view_distance + 2);
256261

257262
const self = Self{
258263
.allocator = allocator,
@@ -370,9 +375,6 @@ pub const ChunkManager = struct {
370375
rs.getTransferFamily(), // Use transfer queue family for upload command pool
371376
buf_mgr,
372377
&self.completed_queue,
373-
self.config.view_distance,
374-
self.config.vertical_view_distance,
375-
self.config.unload_distance,
376378
.{}, // Use default config (0.5ms budget)
377379
);
378380
try ut.start();
@@ -411,6 +413,7 @@ pub const ChunkManager = struct {
411413
self.io,
412414
&self.pool,
413415
&self.in_flight_terrain,
416+
&self.chunk_storage,
414417
self.config.view_distance,
415418
self.config.vertical_view_distance,
416419
self.config.unload_distance,
@@ -543,11 +546,6 @@ pub const ChunkManager = struct {
543546
// Ensures closest chunks are always processed first, even as player moves
544547
self.pool.updateCameraPos(new_chunk.x, new_chunk.z, new_chunk.section_y);
545548

546-
// Update upload thread for staleness checks
547-
if (self.upload_thread) |ut| {
548-
ut.updatePlayerPos(new_chunk.x, new_chunk.z, new_chunk.section_y);
549-
}
550-
551549
// Notify background load thread of new player position (C2ME-style)
552550
if (self.load_thread) |lt| {
553551
lt.updatePlayerPos(new_chunk.x, new_chunk.z, new_chunk.section_y);
@@ -570,25 +568,24 @@ pub const ChunkManager = struct {
570568
const ms = self.mesh_scheduler orelse return;
571569

572570
var processed: u32 = 0;
573-
var discarded: u32 = 0;
574571
while (processed < MAX_TERRAIN_PER_TICK) {
575572
const result = self.completed_terrain.tryPop() orelse break;
576573

577-
// Discard stale terrain results for positions outside unload range.
578-
// This prevents orphaned chunks: if the load thread already removed this position
579-
// from its managed set (via scanForUnloads), creating it here would leave it
580-
// with no owner — never unloaded, never re-loaded.
574+
// Discard stale terrain results far from player to prevent ring buffer collisions.
575+
// Without this, put(stale_pos) can evict in-range chunks at the same ring buffer slot
576+
// (two positions collide when they differ by horizontal_size=2*unload_distance+1).
581577
{
582578
const horizontally_distant = !result.pos.isWithinDistance(self.player_chunk, self.config.unload_distance);
583579
const dy = @abs(result.pos.section_y - self.player_chunk.section_y);
584580
const vertically_distant = dy > self.config.vertical_view_distance + 2;
585581
if (horizontally_distant or vertically_distant) {
586-
discarded += 1;
582+
processed += 1;
587583
continue;
588584
}
589585
}
590586

591587
// If chunk already exists in storage (e.g. rapid unload/reload), update terrain data
588+
// Always use scheduler for existing chunks (worker mesh gen would conflict)
592589
if (self.chunk_storage.get(result.pos)) |render_chunk_ptr| {
593590
render_chunk_ptr.chunk = result.chunk;
594591
render_chunk_ptr.setState(.generated);
@@ -604,7 +601,15 @@ pub const ChunkManager = struct {
604601
const render_chunk_ptr = self.allocator.create(RenderChunk) catch continue;
605602
render_chunk_ptr.* = RenderChunk.init(self.allocator, result.pos);
606603
render_chunk_ptr.chunk = result.chunk;
607-
render_chunk_ptr.setState(.generated);
604+
605+
// If the worker already generated + pushed a mesh, set state to .meshing
606+
// so processReadyUploads can apply it directly (gen 0 matches).
607+
// Otherwise fall back to scheduler for mesh generation.
608+
if (result.mesh_submitted) {
609+
render_chunk_ptr.setState(.meshing);
610+
} else {
611+
render_chunk_ptr.setState(.generated);
612+
}
608613

609614
const previous = self.chunk_storage.put(result.pos, render_chunk_ptr) catch {
610615
render_chunk_ptr.deinit();
@@ -632,14 +637,14 @@ pub const ChunkManager = struct {
632637
self.allocator.destroy(old_chunk);
633638
}
634639

635-
ms.queueMesh(.{ .pos = result.pos, .is_remesh = false });
640+
// If worker already submitted mesh, skip scheduler — mesh is in flight
641+
if (!result.mesh_submitted) {
642+
ms.queueMesh(.{ .pos = result.pos, .is_remesh = false });
643+
}
636644
// Remesh neighbors that were meshed without this chunk's data
637645
self.queueNeighborRemeshes(result.pos);
638646
processed += 1;
639647
}
640-
if (discarded > 0) {
641-
profiler.plotInt("TerrainDiscarded", @intCast(discarded));
642-
}
643648
zone.setValue(processed);
644649
}
645650

@@ -772,11 +777,19 @@ pub const ChunkManager = struct {
772777
continue;
773778
}
774779

775-
// Handle invalid/empty results (empty meshes)
776780
if (!result.valid) {
777-
render_chunk_ptr.setState(.ready);
778-
if (render_chunk_ptr.needs_remesh.swap(false, .acq_rel)) {
779-
self.queueChunkRemesh(result.pos);
781+
if (result.is_empty) {
782+
// Empty mesh (no geometry) — chunk goes to .ready with no visible mesh
783+
render_chunk_ptr.setState(.ready);
784+
if (render_chunk_ptr.needs_remesh.swap(false, .acq_rel)) {
785+
self.queueChunkRemesh(result.pos);
786+
}
787+
} else {
788+
// Upload failure (allocation/staging/validation) — retry mesh
789+
render_chunk_ptr.setState(.generated);
790+
if (self.mesh_scheduler) |ms| {
791+
ms.queueMesh(.{ .pos = result.pos, .is_remesh = false });
792+
}
780793
}
781794
continue;
782795
}
@@ -927,6 +940,15 @@ pub const ChunkManager = struct {
927940

928941
while (unloaded < MAX_UNLOADS_PER_TICK) {
929942
const pos = lt.unload_requests.tryPop() orelse break;
943+
944+
// Validate: skip stale unload requests for chunks now within range.
945+
// The load thread queues unloads asynchronously — by the time we drain,
946+
// the player may have moved back, making this chunk in-range again.
947+
const horizontally_close = pos.isWithinDistance(self.player_chunk, self.config.unload_distance);
948+
const dy = @abs(pos.section_y - self.player_chunk.section_y);
949+
const vertically_close = dy <= self.config.vertical_view_distance + 2;
950+
if (horizontally_close and vertically_close) continue;
951+
930952
self.unloadChunk(pos);
931953
unloaded += 1;
932954
}
@@ -1004,9 +1026,11 @@ pub const ChunkManager = struct {
10041026
const state = neighbor_chunk.getState();
10051027
if (state == .ready) {
10061028
self.queueChunkRemesh(neighbor_pos);
1007-
} else if (state == .meshing) {
1029+
} else if (state == .meshing or state == .dirty) {
10081030
// Don't cancel in-flight mesh (causes livelock in ReleaseFast).
10091031
// Flag for remesh after current mesh completes.
1032+
// Also covers .dirty: the pending remesh may run before this neighbor
1033+
// is visible to the worker, so needs_remesh ensures a follow-up.
10101034
neighbor_chunk.needs_remesh.store(true, .release);
10111035
}
10121036
}
@@ -1115,13 +1139,34 @@ pub const ChunkManager = struct {
11151139
chunk = Chunk.generateTestChunk();
11161140
}
11171141

1142+
// Combined terrain+mesh: generate initial mesh immediately to eliminate
1143+
// the round-trip through main thread → scheduler → worker pool.
1144+
// Saves ~2 frames of pipeline latency per chunk during initial loading.
1145+
var initial_mesh_result: ?CompletedMesh = null;
1146+
if (self.generateInitialMesh(ctx, &chunk, task.chunk_pos)) |mesh| {
1147+
initial_mesh_result = mesh;
1148+
}
1149+
1150+
// ORDERING: push terrain BEFORE mesh. The main thread processes completed_terrain
1151+
// before processReadyUploads in tick(). If we pushed mesh first, the upload thread
1152+
// could process it before the main thread creates the RenderChunk from terrain,
1153+
// causing the mesh result to be discarded (chunk not in storage).
11181154
self.completed_terrain.push(TerrainResult{
11191155
.pos = task.chunk_pos,
11201156
.chunk = chunk,
1157+
.mesh_submitted = initial_mesh_result != null,
11211158
}) catch |err| {
11221159
logger.warn("Failed to push completed terrain: {}", .{err});
11231160
};
11241161

1162+
// Push mesh after terrain so RenderChunk always exists when mesh result arrives
1163+
if (initial_mesh_result) |mesh| {
1164+
self.completed_queue.push(mesh) catch {
1165+
var m = mesh;
1166+
m.deinit();
1167+
};
1168+
}
1169+
11251170
// Release backpressure so load thread can submit more tasks
11261171
_ = self.in_flight_terrain.fetchSub(1, .release);
11271172
}
@@ -1226,6 +1271,68 @@ pub const ChunkManager = struct {
12261271
.layers = .{ CompletedLayerData.EMPTY, CompletedLayerData.EMPTY, CompletedLayerData.EMPTY },
12271272
.allocator = self.allocator,
12281273
.mesh_generation = gen,
1229-
}) catch {};
1274+
}) catch {
1275+
logger.warn("Failed to push empty mesh result for ({},{},{}), chunk stuck in .meshing", .{ pos.x, pos.z, pos.section_y });
1276+
};
1277+
}
1278+
1279+
/// Generate initial mesh on the worker thread right after terrain generation.
1280+
/// Captures available neighbors from storage via RCU — the chunk itself is not
1281+
/// yet in storage so it's passed directly. Uses mesh_generation=0 (initial).
1282+
fn generateInitialMesh(self: *Self, ctx: *WorkerContext, chunk: *const Chunk, pos: ChunkPos) ?CompletedMesh {
1283+
const worker_data: *WorkerData = @ptrCast(@alignCast(ctx.user_data orelse return null));
1284+
const mesh_ctx = worker_data.mesh_context;
1285+
const shaper = self.shared_model_shaper orelse return null;
1286+
const rcu_instance = self.rcu orelse return null;
1287+
1288+
// RCU read-side critical section for neighbor capture
1289+
_ = rcu_instance.readLock(@intCast(ctx.id));
1290+
1291+
var neighbors: [6]?Chunk = .{ null, null, null, null, null, null };
1292+
const offsets = [6]ChunkPos{
1293+
.{ .x = 0, .z = 0, .section_y = -1 }, // down
1294+
.{ .x = 0, .z = 0, .section_y = 1 }, // up
1295+
.{ .x = 0, .z = -1, .section_y = 0 }, // north
1296+
.{ .x = 0, .z = 1, .section_y = 0 }, // south
1297+
.{ .x = -1, .z = 0, .section_y = 0 }, // west
1298+
.{ .x = 1, .z = 0, .section_y = 0 }, // east
1299+
};
1300+
1301+
for (0..6) |i| {
1302+
const neighbor_pos = ChunkPos{
1303+
.x = pos.x + offsets[i].x,
1304+
.z = pos.z + offsets[i].z,
1305+
.section_y = pos.section_y + offsets[i].section_y,
1306+
};
1307+
if (self.chunk_storage.getAtomic(neighbor_pos)) |neighbor| {
1308+
const state = neighbor.getState();
1309+
if (state == .generated or state == .meshing or state == .ready or state == .dirty) {
1310+
neighbors[i] = neighbor.chunk;
1311+
}
1312+
}
1313+
}
1314+
1315+
rcu_instance.readUnlock(@intCast(ctx.id));
1316+
1317+
// Build pointer array from stack copies
1318+
var neighbor_ptrs: [6]?*const Chunk = .{ null, null, null, null, null, null };
1319+
for (0..6) |i| {
1320+
if (neighbors[i] != null) {
1321+
neighbor_ptrs[i] = &(neighbors[i].?);
1322+
}
1323+
}
1324+
1325+
var mesh = mesh_ctx.generateMesh(
1326+
chunk,
1327+
pos,
1328+
neighbor_ptrs,
1329+
shaper,
1330+
self.texture_manager,
1331+
) catch return null;
1332+
1333+
mesh.generated_chunk = null;
1334+
mesh.mesh_generation = 0; // Initial mesh always generation 0
1335+
1336+
return mesh;
12301337
}
12311338
};

src/client/world/RenderChunk.zig

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,9 +415,12 @@ pub const RenderChunk = struct {
415415
return self.future.isDirty();
416416
}
417417

418-
/// Check if this chunk is ready to render
418+
/// Check if this chunk is ready to render.
419+
/// Returns true whenever a valid mesh exists, even during remesh cycles
420+
/// (.dirty / .meshing). The old mesh + GPU allocations stay valid until
421+
/// setMesh replaces them, so rendering the stale mesh avoids holes.
419422
pub fn isReady(self: *const Self) bool {
420-
return self.future.isReady() and self.mesh != null;
423+
return self.mesh != null;
421424
}
422425

423426
/// Mark chunk as dirty (needs remeshing)

0 commit comments

Comments
 (0)