| … | … | @@ -1,4 +1,5 @@ |
| 1 | 1 | #include "dc_terrain.h" |
| 2 | #include <thread> |
| 2 | 3 | #include "dc_octree.h" |
| 3 | 4 | #include "dc_edit.h" |
| 4 | 5 | #include "dc_contour.h" |
| … | … | @@ -523,6 +524,26 @@ void DCTerrain::_process(double delta) { |
| 523 | 524 | |
| 524 | 525 | frame_counter_++; |
| 525 | 526 | |
| 527 | // Deferred worker spawn: the pool was held back at ENTER_TREE so the GPU |
| 528 | // thread's create_local_rendering_device() could have the cores to itself |
| 529 | // (measured 30.2 s contended vs 4.17 s free). Spawn the frame it lands. |
| 530 | // The 90 s escape hatch keeps a GPU that never initialises from producing a |
| 531 | // terrain-less world — we lose the GPU mesher, not the terrain. |
| 532 | if (workers_deferred_) { |
| 533 | double waited = std::chrono::duration<double>( |
| 534 | std::chrono::steady_clock::now() - workers_deferred_since_).count(); |
| 535 | bool ready = gpu_thread_ && gpu_thread_->is_available(); |
| 536 | if (ready || waited > 90.0) { |
| 537 | workers_deferred_ = false; |
| 538 | if (debug_logging_ || !ready) { |
| 539 | fprintf(stderr, "[GPU-FIRST] deferred workers %.2f s (gpu_available=%d)\n", |
| 540 | waited, (int)ready); |
| 541 | fflush(stderr); |
| 542 | } |
| 543 | _spawn_workers(); |
| 544 | } |
| 545 | } |
| 546 | |
| 526 | 547 | // 1. Get camera position |
| 527 | 548 | float cam_pos[3] = {0.0f, 0.0f, 0.0f}; |
| 528 | 549 | bool has_camera = false; |
| … | … | @@ -567,8 +588,13 @@ void DCTerrain::_process(double delta) { |
| 567 | 588 | } |
| 568 | 589 | } |
| 569 | 590 | |
| 570 | | // Debug: log status on first frames, then every 60 frames |
| 571 | | if (debug_logging_ && (debug_frames_ < 5 || (frame_counter_ % 60 == 0 && debug_frames_ < 20))) { |
| 591 | // Debug: log status on first frames, then every 60 frames for the WHOLE run. |
| 592 | // The `debug_frames_ < 20` cap used to stop this line ~39 s in, which meant |
| 593 | // the resident-set counters (leaves / blocked / dealloc_q) went dark for |
| 594 | // exactly the part of a session where they matter — cost that grows with |
| 595 | // accumulated world rather than with what the camera is doing right now is |
| 596 | // invisible without them (2026-09-03). |
| 597 | if (debug_logging_ && (debug_frames_ < 5 || frame_counter_ % 60 == 0)) { |
| 572 | 598 | auto stats = octree_clipmap_.get_stats(); |
| 573 | 599 | const char* mode_str = (lod_mode_ == LOD_BLOCKING_SWAP) ? "BLOCKING" : "EAGER"; |
| 574 | 600 | UtilityFunctions::print(String("DCTerrain[{0}]: mode={1} cam={2} pos=({3},{4},{5}) leaves={6} pending={7} blocked={8} dealloc_q={9} workers={10}").format( |
| … | … | @@ -1272,9 +1298,10 @@ void DCTerrain::_process(double delta) { |
| 1272 | 1298 | // Log frame timing if total _process exceeds 4ms |
| 1273 | 1299 | double total_frame = frame_ms(t_frame, t_end); |
| 1274 | 1300 | if (debug_logging_ && total_frame > 4.0) { |
| 1275 | | fprintf(stderr, "FRAME: traverse=%.1f dispatch=%.1f(%d) upload=%.1f(%d) seam=%.1f(%d,q=%d) collision=%.1f(%d,q=%d) dealloc=%.1f(%d) TOTAL=%.1fms\n", |
| 1301 | fprintf(stderr, "FRAME: traverse=%.1f dispatch=%.1f(%d,pm=%d) upload=%.1f(%d) seam=%.1f(%d,q=%d) collision=%.1f(%d,q=%d) dealloc=%.1f(%d) TOTAL=%.1fms\n", |
| 1276 | 1302 | frame_ms(t_frame, t_traverse), |
| 1277 | 1303 | frame_ms(t_traverse, t_dispatch), static_cast<int>(pending.size()), |
| 1304 | premesh_pending_last_frame_, |
| 1278 | 1305 | frame_ms(t_dispatch, t_upload), uploaded, |
| 1279 | 1306 | frame_ms(t_upload, t_seam), seams_built, static_cast<int>(seam_pending_.size()), |
| 1280 | 1307 | frame_ms(t_seam, t_collision), collisions_built, collision_trimesh_.queue_size(), |
| … | … | @@ -1288,28 +1315,6 @@ void DCTerrain::_process(double delta) { |
| 1288 | 1315 | // BLOCKING_SWAP: dispatch pre-mesh requests for speculative mesh generation |
| 1289 | 1316 | // --------------------------------------------------------------------------- |
| 1290 | 1317 | void DCTerrain::_dispatch_pre_mesh_requests(int regular_dispatched_this_frame) { |
| 1291 | | if (!_has_workers()) return; |
| 1292 | | |
| 1293 | | auto& cache = octree_clipmap_.pre_mesh_cache_ref(); |
| 1294 | | |
| 1295 | | // Sort pending by distance to camera — near chunks first |
| 1296 | | std::vector<uint64_t> pending_mortons; |
| 1297 | | for (auto& [morton, entry] : cache) { |
| 1298 | | if (entry.state == OctreeClipmapNode::MESH_PENDING) |
| 1299 | | pending_mortons.push_back(morton); |
| 1300 | | } |
| 1301 | | std::sort(pending_mortons.begin(), pending_mortons.end(), |
| 1302 | | [&](uint64_t a, uint64_t b) { |
| 1303 | | auto& ea = cache[a]; auto& eb = cache[b]; |
| 1304 | | float ax = ea.position.x * voxel_size_ - prev_cam_pos_[0]; |
| 1305 | | float ay = ea.position.y * voxel_size_ - prev_cam_pos_[1]; |
| 1306 | | float az = ea.position.z * voxel_size_ - prev_cam_pos_[2]; |
| 1307 | | float bx = eb.position.x * voxel_size_ - prev_cam_pos_[0]; |
| 1308 | | float by = eb.position.y * voxel_size_ - prev_cam_pos_[1]; |
| 1309 | | float bz = eb.position.z * voxel_size_ - prev_cam_pos_[2]; |
| 1310 | | return (ax*ax+ay*ay+az*az) < (bx*bx+by*by+bz*bz); |
| 1311 | | }); |
| 1312 | | |
| 1313 | 1318 | // Queue-depth-based throttle: keep total worker queue depth at a small |
| 1314 | 1319 | // target so workers never idle (LOD streams fast), but edit chunks can |
| 1315 | 1320 | // queue immediately when they arrive and only wait the brief drain time |
| … | … | @@ -1317,6 +1322,60 @@ void DCTerrain::_dispatch_pre_mesh_requests(int regular_dispatched_this_frame) { |
| 1317 | 1322 | // Static per-frame caps couldn't balance both — too low starved LOD, |
| 1318 | 1323 | // too high let pre-mesh saturate the queue ahead of edits. |
| 1319 | 1324 | constexpr size_t TARGET_QUEUE_DEPTH = 8; |
| 1325 | // Always dispatch at least MIN_PREMESH_PER_FRAME of the CLOSEST pre-mesh |
| 1326 | // (the head of the distance-ordered list) so heavy regular generation |
| 1327 | // (spawn burst / creatures / edits) can't fully starve near-camera LOD |
| 1328 | // refinement and leave chunks frozen coarse (the LOD "chunk freeze"). |
| 1329 | // The throttle still governs the bulk, preserving edit responsiveness. |
| 1330 | constexpr int MIN_PREMESH_PER_FRAME = 4; |
| 1331 | |
| 1332 | auto& cache = octree_clipmap_.pre_mesh_cache_ref(); |
| 1333 | |
| 1334 | // Collect the pending set WITH each entry's camera distance in the SAME |
| 1335 | // pass we already make over the cache. |
| 1336 | // |
| 1337 | // This used to push bare mortons and then std::sort them with a comparator |
| 1338 | // that did TWO unordered_map lookups per comparison — O(N log N) hash |
| 1339 | // lookups EVERY FRAME across the whole backlog. Since the loop below is |
| 1340 | // distance-ordered and dispatches at most TARGET_QUEUE_DEPTH per frame, and |
| 1341 | // pre_mesh_cache_ has no distance eviction, the far tail is never |
| 1342 | // dispatched and never dropped: it just gets re-sorted for ever, so the |
| 1343 | // cost grows with exploration and never comes back down. |
| 1344 | // |
| 1345 | // Measured 2026-09-03 on map_dc_diffusion (841-tile set, same map both |
| 1346 | // runs): the dispatch phase averaged 6.12 ms while dispatching ZERO regular |
| 1347 | // chunks, climbing 3.61 -> 6.86 -> 9.05 ms over the last 60 s of the run, |
| 1348 | // against 1.46 ms before. 11.0 hitches/s (>4 ms) vs 2.9. |
| 1349 | std::vector<std::pair<float, uint64_t>> pending; // (distance^2, morton) |
| 1350 | pending.reserve(cache.size()); |
| 1351 | for (auto& [morton, entry] : cache) { |
| 1352 | if (entry.state != OctreeClipmapNode::MESH_PENDING) continue; |
| 1353 | float dx = entry.position.x * voxel_size_ - prev_cam_pos_[0]; |
| 1354 | float dy = entry.position.y * voxel_size_ - prev_cam_pos_[1]; |
| 1355 | float dz = entry.position.z * voxel_size_ - prev_cam_pos_[2]; |
| 1356 | pending.emplace_back(dx * dx + dy * dy + dz * dz, morton); |
| 1357 | } |
| 1358 | // Instrumentation only — surfaced in the FRAME line as pm=<n>. Computed |
| 1359 | // BEFORE the no-workers return so the backlog is visible during the GPU |
| 1360 | // bring-up window, when the pool is deliberately not spawned yet. |
| 1361 | premesh_pending_last_frame_ = static_cast<int>(pending.size()); |
| 1362 | |
| 1363 | if (!_has_workers()) return; |
| 1364 | |
| 1365 | // Only the HEAD of the distance-ordered list can ever be dispatched: every |
| 1366 | // iteration below either dispatches or breaks, and the break fires once the |
| 1367 | // queue is at TARGET_QUEUE_DEPTH and MIN_PREMESH_PER_FRAME have gone out — |
| 1368 | // so at most TARGET_QUEUE_DEPTH iterations run. partial_sort produces that |
| 1369 | // head in EXACTLY the order a full sort would, so which chunks get |
| 1370 | // dispatched, and in what order, is unchanged. Only the cost of finding |
| 1371 | // them drops, from O(N log N) to O(N log K). |
| 1372 | const size_t k = std::min(pending.size(), |
| 1373 | TARGET_QUEUE_DEPTH + static_cast<size_t>(MIN_PREMESH_PER_FRAME)); |
| 1374 | std::partial_sort(pending.begin(), pending.begin() + k, pending.end(), |
| 1375 | [](const std::pair<float, uint64_t>& a, const std::pair<float, uint64_t>& b) { |
| 1376 | return a.first < b.first; |
| 1377 | }); |
| 1378 | |
| 1320 | 1379 | size_t total_queue_depth = 0; |
| 1321 | 1380 | if (using_editor_worker_ && s_editor_worker_) { |
| 1322 | 1381 | total_queue_depth = s_editor_worker_->request_queue_size(); |
| … | … | @@ -1325,16 +1384,11 @@ void DCTerrain::_dispatch_pre_mesh_requests(int regular_dispatched_this_frame) { |
| 1325 | 1384 | } |
| 1326 | 1385 | (void)regular_dispatched_this_frame; // queue-depth approach makes this redundant |
| 1327 | 1386 | |
| 1328 | | // Always dispatch at least MIN_PREMESH_PER_FRAME of the CLOSEST pre-mesh |
| 1329 | | // (pending_mortons is distance-sorted) so heavy regular generation (spawn |
| 1330 | | // burst / creatures / edits) can't fully starve near-camera LOD refinement |
| 1331 | | // and leave chunks frozen coarse (the LOD "chunk freeze"). The throttle |
| 1332 | | // still governs the bulk, preserving edit responsiveness. |
| 1333 | | constexpr int MIN_PREMESH_PER_FRAME = 4; |
| 1334 | 1387 | int premesh_dispatched = 0; |
| 1335 | | for (uint64_t morton : pending_mortons) { |
| 1388 | for (const auto& pm : pending) { |
| 1336 | 1389 | if (total_queue_depth >= TARGET_QUEUE_DEPTH && |
| 1337 | 1390 | premesh_dispatched >= MIN_PREMESH_PER_FRAME) break; |
| 1391 | const uint64_t morton = pm.second; |
| 1338 | 1392 | auto& entry = cache[morton]; |
| 1339 | 1393 | |
| 1340 | 1394 | CellRequest req; |
| … | … | @@ -1568,6 +1622,35 @@ void DCTerrain::_update_debug_clipmap() { |
| 1568 | 1622 | debug_clipmap_node_->set_mesh(im); |
| 1569 | 1623 | } |
| 1570 | 1624 | |
| 1625 | void DCTerrain::_spawn_workers() { |
| 1626 | // Auto-scale worker count to available CPU cores, capped at 8. |
| 1627 | // Higher counts (tested 14 on a 16-core box) actually hurt |
| 1628 | // frame times: workers complete meshes faster than the main |
| 1629 | // thread can build their collision trimeshes, leaving a |
| 1630 | // backlog that produces frame hitches > 100ms. |
| 1631 | // The real ceiling is main-thread downstream (collision + upload), |
| 1632 | // not worker compute. Until that's parallelized too, 8 workers |
| 1633 | // is the sweet spot. |
| 1634 | int cpu_count = OS::get_singleton()->get_processor_count(); |
| 1635 | int worker_count = std::clamp(cpu_count - 2, 2, 8); |
| 1636 | if (debug_logging_) { |
| 1637 | UtilityFunctions::print("DCTerrain: spawning ", worker_count, |
| 1638 | " workers (", cpu_count, " logical cores detected)"); |
| 1639 | } |
| 1640 | for (int i = 0; i < worker_count; i++) { |
| 1641 | auto worker = std::make_unique<DCWorkerCPU>(); |
| 1642 | worker->set_generation(generation_); |
| 1643 | // Always pass GPU thread pointer. submit() checks |
| 1644 | // gpu_available_ atomically — workers fall back to CPU |
| 1645 | // until GPU init completes, then use GPU automatically. |
| 1646 | if (gpu_thread_) { |
| 1647 | worker->set_gpu_thread(gpu_thread_.get()); |
| 1648 | } |
| 1649 | worker->start(i); |
| 1650 | workers_.push_back(std::move(worker)); |
| 1651 | } |
| 1652 | } |
| 1653 | |
| 1571 | 1654 | void DCTerrain::_notification(int p_what) { |
| 1572 | 1655 | switch (p_what) { |
| 1573 | 1656 | case NOTIFICATION_ENTER_TREE: { |
| … | … | @@ -1601,31 +1684,30 @@ void DCTerrain::_notification(int p_what) { |
| 1601 | 1684 | gpu_thread_ = std::make_unique<DCGpuThread>(); |
| 1602 | 1685 | gpu_thread_->start(); |
| 1603 | 1686 | } |
| 1604 | | // Auto-scale worker count to available CPU cores, capped at 8. |
| 1605 | | // Higher counts (tested 14 on a 16-core box) actually hurt |
| 1606 | | // frame times: workers complete meshes faster than the main |
| 1607 | | // thread can build their collision trimeshes, leaving a |
| 1608 | | // backlog that produces frame hitches > 100ms. |
| 1609 | | // The real ceiling is main-thread downstream (collision + upload), |
| 1610 | | // not worker compute. Until that's parallelized too, 8 workers |
| 1611 | | // is the sweet spot. |
| 1612 | | int cpu_count = OS::get_singleton()->get_processor_count(); |
| 1613 | | int worker_count = std::clamp(cpu_count - 2, 2, 8); |
| 1614 | | if (debug_logging_) { |
| 1615 | | UtilityFunctions::print("DCTerrain: spawning ", worker_count, |
| 1616 | | " workers (", cpu_count, " logical cores detected)"); |
| 1617 | | } |
| 1618 | | for (int i = 0; i < worker_count; i++) { |
| 1619 | | auto worker = std::make_unique<DCWorkerCPU>(); |
| 1620 | | worker->set_generation(generation_); |
| 1621 | | // Always pass GPU thread pointer. submit() checks |
| 1622 | | // gpu_available_ atomically — workers fall back to CPU |
| 1623 | | // until GPU init completes, then use GPU automatically. |
| 1624 | | if (gpu_thread_) { |
| 1625 | | worker->set_gpu_thread(gpu_thread_.get()); |
| 1626 | | } |
| 1627 | | worker->start(i); |
| 1628 | | workers_.push_back(std::move(worker)); |
| 1687 | // ⛔ DO NOT SPAWN WORKERS INTO A GPU THAT IS STILL COMING UP. |
| 1688 | // |
| 1689 | // start() only spawns the thread; compute_.initialize() then runs |
| 1690 | // create_local_rendering_device() — a whole second VkDevice. With |
| 1691 | // 8 workers already saturating 16 cores that measured 30.2 s; with |
| 1692 | // the cores free it is 4.17 s. A 7.2x difference, and it is pure |
| 1693 | // CPU starvation (2026-09-03). |
| 1694 | // |
| 1695 | // While the GPU is down submit() returns false and every chunk |
| 1696 | // falls back to the CPU MESHER — a different dual-contouring |
| 1697 | // implementation from qef_solve.glsl. Measured: 47 solid chunks |
| 1698 | // came from dc_contour.cpp and then stayed in the world next to |
| 1699 | // GPU-meshed neighbours. Waiting takes that to 0. |
| 1700 | // |
| 1701 | // ⚠ DEFERRED, NOT BLOCKED. A first cut blocked here and cost |
| 1702 | // 4.6 s of frozen map load (0.81 s -> 5.44 s) — the right total, |
| 1703 | // the wrong shape. _process() spawns them the frame the GPU is |
| 1704 | // ready, so load stays responsive and nothing meshes early. |
| 1705 | if (gpu_thread_ && !gpu_thread_->is_available() && |
| 1706 | !getenv("DC_NO_GPU_FIRST")) { |
| 1707 | workers_deferred_ = true; |
| 1708 | workers_deferred_since_ = std::chrono::steady_clock::now(); |
| 1709 | } else { |
| 1710 | _spawn_workers(); |
| 1629 | 1711 | } |
| 1630 | 1712 | } else { |
| 1631 | 1713 | generation_++; |