@@ -66,6 +66,9 @@ const MeshSchedulerThread = mesh_scheduler_thread.MeshSchedulerThread;
6666pub 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
7174pub 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};
0 commit comments