react-native-cesium 1.2.7 → 1.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,12 +5,6 @@
5
5
 
6
6
  #include "CesiumEngine.hpp"
7
7
 
8
- #ifdef __ANDROID__
9
- #include <android/log.h>
10
- #define ENGINE_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "CesiumEngine", __VA_ARGS__)
11
- #else
12
- #define ENGINE_LOGI(...)
13
- #endif
14
8
 
15
9
  #include <Cesium3DTilesContent/registerAllTileContentTypes.h>
16
10
  #include <Cesium3DTilesSelection/BoundingVolume.h>
@@ -38,8 +32,10 @@
38
32
 
39
33
  #include <algorithm>
40
34
  #include <cmath>
35
+ #include <cstring>
41
36
  #include <thread>
42
37
 
38
+
43
39
  namespace reactnativecesium {
44
40
 
45
41
  int32_t CesiumEngine::resolveTaskThreadCount(int32_t requested) {
@@ -47,9 +43,15 @@ int32_t CesiumEngine::resolveTaskThreadCount(int32_t requested) {
47
43
  unsigned int hw = std::thread::hardware_concurrency();
48
44
  if (hw == 0) return 4;
49
45
  // Reserve one core for the UI / display thread; clamp into a sensible band.
46
+ // The upper bound was 8 historically but modern phones / tablets routinely
47
+ // ship 8-10 cores (iPhone 15 Pro, iPad M2/M4, Pixel 8 Pro, Galaxy S24
48
+ // Ultra) and tile decoding (Draco, KTX2, glTF parse) is embarrassingly
49
+ // parallel — capping at 8 left half the CPU idle during cold-start tile
50
+ // burst. 16 covers everything up to a Mac M4 Pro and is harmless on
51
+ // smaller chips because we simply create empty workers.
50
52
  int32_t target = static_cast<int32_t>(hw) - 1;
51
- if (target < 2) target = 2;
52
- if (target > 8) target = 8;
53
+ if (target < 2) target = 2;
54
+ if (target > 16) target = 16;
53
55
  return target;
54
56
  }
55
57
 
@@ -276,6 +278,10 @@ void CesiumEngine::createTileset() {
276
278
  opts.culledScreenSpaceError = std::max(1.0, config_.culledScreenSpaceError);
277
279
  opts.enableLodTransitionPeriod = config_.enableLodTransitionPeriod;
278
280
  opts.lodTransitionLength = std::max(0.0, config_.lodTransitionLength);
281
+ // Rate-limit per-frame main-thread tile finalisation so the render thread
282
+ // stays responsive during heavy startup bursts (see EngineConfig docs).
283
+ opts.mainThreadLoadingTimeLimit =
284
+ std::max(0.0, config_.mainThreadLoadingTimeLimitMs);
279
285
 
280
286
  tileset_ = std::make_unique<Cesium3DTilesSelection::Tileset>(
281
287
  externals, config_.ionAssetId, config_.ionAccessToken, opts);
@@ -359,6 +365,11 @@ bool CesiumEngine::applyRuntimeConfig(const EngineConfig& cfg) {
359
365
  opts.lodTransitionLength = newLodLen;
360
366
  changed = true;
361
367
  }
368
+ const double newMainLimit = std::max(0.0, cfg.mainThreadLoadingTimeLimitMs);
369
+ if (opts.mainThreadLoadingTimeLimit != newMainLimit) {
370
+ opts.mainThreadLoadingTimeLimit = newMainLimit;
371
+ changed = true;
372
+ }
362
373
  return changed;
363
374
  }
364
375
 
@@ -379,6 +390,18 @@ void CesiumEngine::applyImageryOverlay(int64_t targetAssetId) {
379
390
  tileset_->getOverlays().add(ov);
380
391
  currentOverlay_ = ov;
381
392
  }
393
+
394
+ // an overlay swap mutates res->overlayTexture pointers on already-loaded
395
+ // tiles. The fast path re-reads the texture from `res` each frame so output
396
+ // pixels stay correct, but burn one stale frame to be safe: drop the cache
397
+ // and force the engine to take the slow path again until N stable frames
398
+ // confirm the new state.
399
+ lastDrawSignature_ = 0;
400
+ stableSigFrames_ = 0;
401
+ cachedDraws_.clear();
402
+ // floor cache is keyed on signature — drop it too so the next frame
403
+ // re-scans before establishing a new entry.
404
+ floorCacheValid_ = false;
382
405
  }
383
406
 
384
407
  void CesiumEngine::destroyTileset() {
@@ -388,6 +411,14 @@ void CesiumEngine::destroyTileset() {
388
411
  if (taskProcessor_) {
389
412
  taskProcessor_->waitUntilIdle();
390
413
  }
414
+ // every TileGPUResources* baked into cachedDraws_ has just been freed.
415
+ // Clearing the cache guarantees no dangling pointer survives into the next
416
+ // updateFrame and forces a clean slow-path rebuild.
417
+ lastDrawSignature_ = 0;
418
+ stableSigFrames_ = 0;
419
+ cachedDraws_.clear();
420
+ // P5: the cached value was keyed on TileGPUResources we just freed.
421
+ floorCacheValid_ = false;
391
422
  }
392
423
 
393
424
  void CesiumEngine::setImageryAssetId(int64_t assetId) {
@@ -396,6 +427,19 @@ void CesiumEngine::setImageryAssetId(int64_t assetId) {
396
427
  applyImageryOverlay(config_.ionImageryAssetId);
397
428
  }
398
429
 
430
+ namespace {
431
+ // FNV-1a 64-bit hash mix. Used to compute the geometry signature from the
432
+ // ordered list of (TileGPUResources*, primitive*, primitive.indices.size())
433
+ // triples for tilesToRenderThisFrame. Two frames whose visible primitives
434
+ // hash to the same value are guaranteed (modulo astronomically unlikely
435
+ // 64-bit collision) to produce identical merged vertex / index / UV buffers.
436
+ inline uint64_t fnv1a64Mix(uint64_t h, uint64_t v) {
437
+ h ^= v;
438
+ h *= 0x100000001b3ULL;
439
+ return h;
440
+ }
441
+ } // namespace
442
+
399
443
  void CesiumEngine::updateFrame(double w, double h, FrameResult& result) {
400
444
  result.localPositions.clear();
401
445
  result.altitudes.clear();
@@ -403,6 +447,7 @@ void CesiumEngine::updateFrame(double w, double h, FrameResult& result) {
403
447
  result.indices.clear();
404
448
  result.draws.clear();
405
449
  result.creditHtmlLines.clear();
450
+ result.geometrySignature = 0;
406
451
 
407
452
  result.ionTokenConfigured = !config_.ionAccessToken.empty();
408
453
  result.tilesetActive = (tileset_ != nullptr);
@@ -422,6 +467,9 @@ void CesiumEngine::updateFrame(double w, double h, FrameResult& result) {
422
467
 
423
468
  if (!tileset_) {
424
469
  appendEllipsoidDraws(result);
470
+ lastDrawSignature_ = 0;
471
+ stableSigFrames_ = 0;
472
+ cachedDraws_.clear();
425
473
  lifecycle_.advanceFrame();
426
474
  return;
427
475
  }
@@ -433,9 +481,22 @@ void CesiumEngine::updateFrame(double w, double h, FrameResult& result) {
433
481
 
434
482
  asyncSystem_.dispatchMainThreadTasks();
435
483
 
484
+ // Wall-clock deltaTime since previous frame. Cesium Native requires this to
485
+ // advance LOD transition fade percentages (without it, fade stays at 0 and
486
+ // newly-selected tiles never become visible). Clamped to a max of 100ms so a
487
+ // long pause / app foreground transition doesn't instantly complete fades.
488
+ const auto now = std::chrono::steady_clock::now();
489
+ float deltaTime = 0.0f;
490
+ if (hasLastFrameTime_) {
491
+ const auto diff = std::chrono::duration<float>(now - lastFrameTime_).count();
492
+ deltaTime = std::min(diff, 0.1f);
493
+ }
494
+ lastFrameTime_ = now;
495
+ hasLastFrameTime_ = true;
496
+
436
497
  const auto viewState = camera_.computeViewState(w, h);
437
498
  const auto& updateResult =
438
- tileset_->updateViewGroup(tileset_->getDefaultViewGroup(), {viewState});
499
+ tileset_->updateViewGroup(tileset_->getDefaultViewGroup(), {viewState}, deltaTime);
439
500
  tileset_->loadTiles();
440
501
 
441
502
  // Only emit the fallback ellipsoid when no real terrain is being rendered
@@ -444,6 +505,12 @@ void CesiumEngine::updateFrame(double w, double h, FrameResult& result) {
444
505
  const bool noVisibleTerrain = updateResult.tilesToRenderThisFrame.empty();
445
506
  if (noVisibleTerrain) {
446
507
  appendEllipsoidDraws(result);
508
+ // Ellipsoid fallback emits its own geometry; any cached terrain state is
509
+ // stale until real tiles arrive. Reset so fast path can't fire on the
510
+ // very next frame after a tile re-appears.
511
+ lastDrawSignature_ = 0;
512
+ stableSigFrames_ = 0;
513
+ cachedDraws_.clear();
447
514
  }
448
515
 
449
516
  if (creditSystem_) {
@@ -457,6 +524,81 @@ void CesiumEngine::updateFrame(double w, double h, FrameResult& result) {
457
524
  }
458
525
  }
459
526
 
527
+ // ── geometry signature ────────────────────────────────────────────
528
+ // Fingerprint the ordered visible primitive list. When the same fingerprint
529
+ // shows up for kMaxFramesInFlight consecutive frames *and* there are no
530
+ // LOD-fade draws, every backend slot's persistent buffer already holds the
531
+ // right bytes — the fast path can then skip the entire merge loop and the
532
+ // memcpys it produces.
533
+ uint64_t signature = 0xcbf29ce484222325ULL; // FNV-1a 64-bit offset basis
534
+ if (!noVisibleTerrain) {
535
+ for (const auto& tile : updateResult.tilesToRenderThisFrame) {
536
+ if (!tile) continue;
537
+ const auto* rc = tile->getContent().getRenderContent();
538
+ if (!rc) continue;
539
+ const auto* res = static_cast<const TileGPUResources*>(rc->getRenderResources());
540
+ if (!res) continue;
541
+ signature = fnv1a64Mix(signature, reinterpret_cast<uint64_t>(res));
542
+ signature = fnv1a64Mix(signature,
543
+ static_cast<uint64_t>(res->primitives.size()));
544
+ for (const auto& prim : res->primitives) {
545
+ signature = fnv1a64Mix(signature, reinterpret_cast<uint64_t>(&prim));
546
+ signature = fnv1a64Mix(signature,
547
+ static_cast<uint64_t>(prim.indices.size()));
548
+ }
549
+ }
550
+ // Reserve 0 as the "force upload" sentinel; we should never emit 0 as a
551
+ // valid signature.
552
+ if (signature == 0ULL) signature = 1ULL;
553
+ } else {
554
+ signature = 0ULL;
555
+ }
556
+
557
+ // ── scan tilesFadingOut for any in-flight fades ──────────────────────
558
+ // Fast path is illegal whenever a single fading-out draw would be emitted
559
+ // (lodFade < 1 means the per-tile dither cutoff changes every frame and the
560
+ // emitted set of draws may grow/shrink mid-transition).
561
+ bool anyFadeDraws = false;
562
+ for (const auto& tile : updateResult.tilesFadingOut) {
563
+ if (!tile) continue;
564
+ const auto* rc = tile->getContent().getRenderContent();
565
+ if (!rc) continue;
566
+ if (1.0f - rc->getLodTransitionFadePercentage() > 0.0f) {
567
+ anyFadeDraws = true;
568
+ break;
569
+ }
570
+ }
571
+
572
+ // ── update stability counter ─────────────────────────────────────────
573
+ // IMPORTANT: reset to 0, NOT 1. Setting it to 1 was an off-by-one that
574
+ // let the fast path fire while one slot still held the previous signature:
575
+ // fi=A (reset to 1): slow path → geomSlotSig_[A]=sig
576
+ // fi=B (counter=2) : slow path → geomSlotSig_[B]=sig
577
+ // fi=C (counter=3≥3): fast path → geomSlotSig_[C] STILL stale → blank!
578
+ // With reset=0 the fast path is deferred until ALL kMaxFramesInFlight
579
+ // slots have been written with the current signature.
580
+ if (!noVisibleTerrain && signature != 0ULL) {
581
+ if (signature == lastDrawSignature_) {
582
+ ++stableSigFrames_;
583
+ } else {
584
+ stableSigFrames_ = 0;
585
+ lastDrawSignature_ = signature;
586
+ cachedDraws_.clear();
587
+ }
588
+ }
589
+
590
+ // NOTE: must be STRICTLY GREATER (>) than kMaxFramesInFlight, not >=.
591
+ // With >= kMaxFramesInFlight the fast path fires on the Nth stable frame,
592
+ // which is exactly the frame where the Nth backend slot *would* have been
593
+ // written in the slow path. At that point the slot still holds the previous
594
+ // (or zero) signature and the backend returns a blank frame.
595
+ // With > kMaxFramesInFlight the Nth slow-path frame writes the last slot,
596
+ // and the fast path activates on frame N+1 when all slots are primed.
597
+ const bool canFastPath = !noVisibleTerrain && !anyFadeDraws &&
598
+ signature != 0ULL &&
599
+ stableSigFrames_ > tunables::kMaxFramesInFlight &&
600
+ !cachedDraws_.empty();
601
+
460
602
  // ── Terrain floor tracking (for minAltitudeAboveTerrain clamp) ───────────
461
603
  // Computed once before the tile loop and updated per-tile inside it.
462
604
  // Only active when the feature is enabled to avoid any overhead otherwise.
@@ -467,6 +609,125 @@ void CesiumEngine::updateFrame(double w, double h, FrameResult& result) {
467
609
  float bestTerrainFloor = terrainFloorEllipsoidM_; // carry previous value if no tile matches
468
610
  double bestTerrainDist = std::numeric_limits<double>::max();
469
611
 
612
+ // ── floor-scan cache lookup ─────────────────────────────────────────
613
+ // Skip the per-vertex scan when the camera has moved less than ~10 cm in
614
+ // both lat and lon AND the visible primitive set is unchanged (same
615
+ // geometry signature). The cached value is only trusted for non-zero
616
+ // signatures — sig==0 means a fade or rebuild is in flight, in which case
617
+ // we always re-scan and refresh the cache afterwards.
618
+ bool floorScanFromCache = false;
619
+ if (trackTerrainFloor && floorCacheValid_ && signature != 0ULL &&
620
+ signature == floorCacheSignature_) {
621
+ const double dLon = std::abs(camParams.longitude - floorCacheCamLonDeg_);
622
+ const double dLat = std::abs(camParams.latitude - floorCacheCamLatDeg_);
623
+ if (dLon < 1e-6 && dLat < 1e-6) {
624
+ bestTerrainFloor = floorCacheValue_;
625
+ floorScanFromCache = true;
626
+ }
627
+ }
628
+ // When tracking is off, suppress the per-tile scan branches (legacy
629
+ // behaviour already gated on trackTerrainFloor; this just keeps the cache
630
+ // semantics aligned).
631
+ const bool runFloorScan = trackTerrainFloor && !floorScanFromCache;
632
+
633
+ // ── fast path ────────────────────────────────────────────────────────
634
+ // The visible primitive list has been identical for kMaxFramesInFlight
635
+ // frames in a row, so every backend slot is guaranteed to hold a copy of
636
+ // the merged buffers. Skip the merge entirely: re-emit one DrawPrimitive
637
+ // per cached entry with a fresh per-tile MVP and the latest overlay /
638
+ // water-mask state read straight from TileGPUResources. The leftover
639
+ // result.localPositions / altitudes / uvs / indices stay empty — backends
640
+ // detect this via result.geometrySignature and use their cached slot data.
641
+ if (canFastPath) {
642
+ result.draws.reserve(cachedDraws_.size());
643
+ const TileGPUResources* lastScanRes = nullptr;
644
+ for (const auto& c : cachedDraws_) {
645
+ lifecycle_.stampTileUsed(const_cast<TileGPUResources*>(c.res));
646
+
647
+ // Floor scan (run once per res so multi-primitive tiles aren't scanned
648
+ // twice). Skipped when the P5 cache hit above produced a usable value.
649
+ if (runFloorScan && c.res != lastScanRes) {
650
+ lastScanRes = c.res;
651
+ const bool inside =
652
+ (c.wmTileBounds.x <= static_cast<float>(camLonRad) &&
653
+ static_cast<float>(camLonRad) <= c.wmTileBounds.z) &&
654
+ (c.wmTileBounds.y <= static_cast<float>(camLatRad) &&
655
+ static_cast<float>(camLatRad) <= c.wmTileBounds.w);
656
+ if (inside) {
657
+ for (const auto& prim : c.res->primitives) {
658
+ if (prim.altitudes.empty() || prim.localPositions.empty()) continue;
659
+ const size_t vc = prim.localPositions.size();
660
+ for (size_t vi = 0; vi < vc; ++vi) {
661
+ const glm::dvec3 vECEF =
662
+ glm::dvec3(prim.localPositions[vi]) + prim.rtcCenter;
663
+ const double dist = glm::length(vECEF - cameraPos);
664
+ if (dist < bestTerrainDist) {
665
+ bestTerrainDist = dist;
666
+ bestTerrainFloor = prim.altitudes[vi];
667
+ }
668
+ }
669
+ }
670
+ }
671
+ }
672
+
673
+ const glm::dvec3 rtcCamRel = c.rtcCenter - glm::dvec3(cameraPos);
674
+ const glm::dmat4 translateD = glm::translate(glm::dmat4(1.0), rtcCamRel);
675
+
676
+ DrawPrimitive draw;
677
+ draw.indexByteOffset = c.indexByteOffset;
678
+ draw.indexCount = c.indexCount;
679
+ draw.hasUVs = c.hasUVs;
680
+ draw.overlayTexture = c.res->overlayTexture;
681
+ draw.overlayTranslation = c.res->overlayTranslation;
682
+ draw.overlayScale = c.res->overlayScale;
683
+ draw.waterMaskTexture = c.res->waterMaskTexture;
684
+ draw.isOnlyWater = c.res->isOnlyWater;
685
+ draw.wmTileBounds = c.wmTileBounds;
686
+ draw.wmTranslation = c.res->wmTranslation;
687
+ draw.wmScale = c.res->wmScale;
688
+ draw.mvpMatrix = glm::mat4(vpDouble * translateD);
689
+ draw.rtcCenterEcef = glm::vec3(c.rtcCenter);
690
+ draw.lodFade = 1.0f;
691
+ result.draws.push_back(draw);
692
+ }
693
+
694
+ result.geometrySignature = signature;
695
+
696
+ result.tilesRendered = static_cast<int>(updateResult.tilesToRenderThisFrame.size());
697
+ result.tilesLoading = updateResult.workerThreadTileLoadQueueLength +
698
+ updateResult.mainThreadTileLoadQueueLength;
699
+ result.tilesVisited = static_cast<int>(updateResult.tilesVisited);
700
+
701
+ const auto paramsFP = camera_.getParams();
702
+ result.cameraLat = paramsFP.latitude;
703
+ result.cameraLon = paramsFP.longitude;
704
+ result.cameraAlt = paramsFP.altitude;
705
+
706
+ if (trackTerrainFloor) {
707
+ terrainFloorEllipsoidM_ = bestTerrainFloor;
708
+ // P5: refresh the floor cache on a fresh scan; skip when we hit the
709
+ // cache (the stored value already reflects this signature).
710
+ if (runFloorScan && signature != 0ULL) {
711
+ floorCacheValid_ = true;
712
+ floorCacheSignature_ = signature;
713
+ floorCacheCamLonDeg_ = camParams.longitude;
714
+ floorCacheCamLatDeg_ = camParams.latitude;
715
+ floorCacheValue_ = bestTerrainFloor;
716
+ }
717
+ }
718
+
719
+ lifecycle_.advanceFrame();
720
+ return;
721
+ }
722
+
723
+ // ── Slow path: rebuild cachedDraws_ inline ───────────────────────────────
724
+ // Wipe so the loop below can append. After the loop, if there were no
725
+ // fade-out draws we keep it for the fast path; otherwise we clear and
726
+ // reset the stability counter (the cache is only valid for stable
727
+ // no-fade frames).
728
+ cachedDraws_.clear();
729
+ if (!noVisibleTerrain) cachedDraws_.reserve(updateResult.tilesToRenderThisFrame.size());
730
+
470
731
  for (const auto& tile : updateResult.tilesToRenderThisFrame) {
471
732
  if (!tile) continue;
472
733
 
@@ -492,7 +753,9 @@ void CesiumEngine::updateFrame(double w, double h, FrameResult& result) {
492
753
  static_cast<float>(rect.getNorth()));
493
754
 
494
755
  // ── Terrain floor: find nearest vertex to camera nadir ────────────
495
- if (trackTerrainFloor &&
756
+ // Suppressed when the cache returned a usable value for the
757
+ // current (signature, camLon, camLat) tuple.
758
+ if (runFloorScan &&
496
759
  rect.contains(CesiumGeospatial::Cartographic(camLonRad, camLatRad, 0.0))) {
497
760
  for (const auto& prim : res->primitives) {
498
761
  if (prim.altitudes.empty() || prim.localPositions.empty()) continue;
@@ -520,32 +783,47 @@ void CesiumEngine::updateFrame(double w, double h, FrameResult& result) {
520
783
  const bool hasPrimUVs =
521
784
  !prim.uvs.empty() && prim.uvs.size() == vertexCount;
522
785
 
523
- // ── RTC: copy tile-local positions & altitudes ────────────────────────
524
- result.localPositions.resize(result.localPositions.size() + vertexCount * 3);
525
- result.altitudes.resize(result.altitudes.size() + vertexCount);
526
- result.uvs.resize(result.uvs.size() + vertexCount * 2);
527
-
528
- float* posOut = result.localPositions.data() + baseVertex * 3;
529
- float* altOut = result.altitudes.data() + baseVertex;
530
- float* uvOut = result.uvs.data() + baseVertex * 2;
531
-
532
- for (size_t vi = 0; vi < vertexCount; ++vi) {
533
- const glm::vec3& lp = prim.localPositions[vi];
534
- *posOut++ = lp.x;
535
- *posOut++ = lp.y;
536
- *posOut++ = lp.z;
537
- *altOut++ = prim.altitudes.empty() ? 0.0f : prim.altitudes[vi];
538
- if (hasPrimUVs) {
539
- *uvOut++ = prim.uvs[vi].x;
540
- *uvOut++ = prim.uvs[vi].y;
541
- } else {
542
- *uvOut++ = 0.5f;
543
- *uvOut++ = 0.5f;
544
- }
786
+ // ── RTC: bulk-copy tile-local positions, altitudes, UVs ─────────────────
787
+ // Use insert(end(), ptr, ptr+n) rather than resize()+memcpy: libc++
788
+ // dispatches insert for trivially-copyable float ranges to memmove
789
+ // (NEON-accelerated even at -O0), skipping the per-element zero-init
790
+ // loop that resize() performs before we'd overwrite the same bytes
791
+ // with memcpy. This halves the memory writes and eliminates the
792
+ // un-vectorised zero-init bottleneck in Debug builds.
793
+ static_assert(sizeof(glm::vec3) == 3 * sizeof(float), "glm::vec3 must be packed");
794
+ static_assert(sizeof(glm::vec2) == 2 * sizeof(float), "glm::vec2 must be packed");
795
+
796
+ {
797
+ const float* posData = reinterpret_cast<const float*>(prim.localPositions.data());
798
+ result.localPositions.insert(result.localPositions.end(),
799
+ posData, posData + vertexCount * 3);
545
800
  }
546
801
 
547
- for (uint32_t idx : prim.indices) {
548
- result.indices.push_back(static_cast<uint32_t>(baseVertex) + idx);
802
+ if (!prim.altitudes.empty()) {
803
+ result.altitudes.insert(result.altitudes.end(),
804
+ prim.altitudes.data(),
805
+ prim.altitudes.data() + vertexCount);
806
+ } else {
807
+ result.altitudes.resize(result.altitudes.size() + vertexCount, 0.0f);
808
+ }
809
+
810
+ if (hasPrimUVs) {
811
+ const float* uvData = reinterpret_cast<const float*>(prim.uvs.data());
812
+ result.uvs.insert(result.uvs.end(), uvData, uvData + vertexCount * 2);
813
+ } else {
814
+ result.uvs.resize(result.uvs.size() + vertexCount * 2, 0.5f);
815
+ }
816
+
817
+ // Indices: add base-vertex offset then insert. Use a temporary vector
818
+ // so the transform writes directly into pre-sized storage.
819
+ {
820
+ const size_t nIdx = prim.indices.size();
821
+ const size_t idxBase = result.indices.size();
822
+ result.indices.resize(idxBase + nIdx);
823
+ const uint32_t bv32 = static_cast<uint32_t>(baseVertex);
824
+ const uint32_t* __restrict srcIdx = prim.indices.data();
825
+ uint32_t* __restrict dstIdx = result.indices.data() + idxBase;
826
+ for (size_t ii = 0; ii < nIdx; ++ii) { dstIdx[ii] = srcIdx[ii] + bv32; }
549
827
  }
550
828
 
551
829
  // ── RTC: per-tile MVP matrix (double → float) ─────────────────────────
@@ -570,14 +848,171 @@ void CesiumEngine::updateFrame(double w, double h, FrameResult& result) {
570
848
  draw.wmScale = res->wmScale;
571
849
  draw.mvpMatrix = perTileMVP;
572
850
  draw.rtcCenterEcef = glm::vec3(prim.rtcCenter);
851
+ // ── LOD fade strategy: incoming tiles are SOLID, outgoing tiles dither ──
852
+ // We deliberately ignore Cesium Native's "fade-in" percentage on the
853
+ // tilesToRenderThisFrame list and always render newly-selected tiles at
854
+ // full opacity. The reason is visibility correctness: if the incoming
855
+ // child were also dithering, both child (fading in) and parent (fading
856
+ // out, see loop below) would simultaneously have holes — and behind the
857
+ // holes is the sky. By rendering the incoming tile fully solid we
858
+ // guarantee a complete opaque "floor" for every screen pixel covered by
859
+ // terrain, and the outgoing tile dithers OUT on top of it. The original
860
+ // ring artefact is still masked because the outgoing tile is the
861
+ // coarser/older surface fading away (its disappearance is what used to
862
+ // be visible as a hard ring).
863
+ draw.lodFade = 1.0f;
864
+ result.draws.push_back(draw);
865
+
866
+ // capture the per-primitive bits the fast path needs to re-emit
867
+ // this draw next frame (camera-dependent MVP is recomputed; everything
868
+ // else is stable as long as the signature is unchanged).
869
+ CachedDraw cd;
870
+ cd.res = res;
871
+ cd.rtcCenter = prim.rtcCenter;
872
+ cd.indexByteOffset = indexByteOff;
873
+ cd.indexCount = static_cast<uint32_t>(prim.indices.size());
874
+ cd.hasUVs = hasPrimUVs;
875
+ cd.wmTileBounds = wmTileBounds;
876
+ cachedDraws_.push_back(cd);
877
+ }
878
+ }
879
+
880
+ // ── Fading-out tiles (LOD transition) ──────────────────────────────────────
881
+ // The outgoing (de-selected) tiles dither out on top of the already-solid
882
+ // incoming tiles drawn in the loop above. Because the layer behind every
883
+ // dithered pixel is the new solid tile, the dither holes never reveal the
884
+ // sky — eliminating the bleed-through artefacts visible with the previous
885
+ // "both layers fade simultaneously" approach.
886
+ //
887
+ // Cesium Native fade semantics (Tileset.cpp _updateLodTransitions):
888
+ // "We always fade tiles from 0.0 --> 1.0. Whether the tile is fading in or
889
+ // out is determined by whether the tile is in the tilesToRenderThisFrame
890
+ // or tilesFadingOut list."
891
+ //
892
+ // For tilesFadingOut, raw fade goes 0→1 but means "just stopped being
893
+ // selected → fully gone". Our shader treats lodFade as visibility
894
+ // (1=visible, 0=hide), so we invert: visibility = 1 - rawFade.
895
+ // Terrain-floor tracking is skipped (these tiles are being replaced).
896
+ for (const auto& tile : updateResult.tilesFadingOut) {
897
+ if (!tile) continue;
898
+ const auto* renderContent = tile->getContent().getRenderContent();
899
+ if (!renderContent) continue;
900
+ const float fade = 1.0f - renderContent->getLodTransitionFadePercentage();
901
+ if (fade <= 0.0f) continue; // fully faded out — "hide right away" per Cesium Native docs
902
+
903
+ const auto* res = static_cast<const TileGPUResources*>(
904
+ renderContent->getRenderResources());
905
+ if (!res) continue;
906
+ lifecycle_.stampTileUsed(const_cast<TileGPUResources*>(res));
907
+
908
+ glm::vec4 wmTileBounds(0.0f);
909
+ const auto& bv = tile->getBoundingVolume();
910
+ const CesiumGeospatial::BoundingRegion* bRegion =
911
+ Cesium3DTilesSelection::getBoundingRegionFromBoundingVolume(bv);
912
+ if (bRegion) {
913
+ const auto& rect = bRegion->getRectangle();
914
+ wmTileBounds = glm::vec4(
915
+ static_cast<float>(rect.getWest()),
916
+ static_cast<float>(rect.getSouth()),
917
+ static_cast<float>(rect.getEast()),
918
+ static_cast<float>(rect.getNorth()));
919
+ }
920
+
921
+ for (const auto& prim : res->primitives) {
922
+ if (prim.indices.empty() || prim.localPositions.empty()) continue;
923
+
924
+ const size_t baseVertex = result.localPositions.size() / 3;
925
+ const uint32_t indexByteOff =
926
+ static_cast<uint32_t>(result.indices.size() * sizeof(uint32_t));
927
+ const size_t vertexCount = prim.localPositions.size();
928
+ const bool hasPrimUVs =
929
+ !prim.uvs.empty() && prim.uvs.size() == vertexCount;
930
+
931
+ {
932
+ const float* posData = reinterpret_cast<const float*>(prim.localPositions.data());
933
+ result.localPositions.insert(result.localPositions.end(),
934
+ posData, posData + vertexCount * 3);
935
+ }
936
+
937
+ if (!prim.altitudes.empty()) {
938
+ result.altitudes.insert(result.altitudes.end(),
939
+ prim.altitudes.data(),
940
+ prim.altitudes.data() + vertexCount);
941
+ } else {
942
+ result.altitudes.resize(result.altitudes.size() + vertexCount, 0.0f);
943
+ }
944
+
945
+ if (hasPrimUVs) {
946
+ const float* uvData = reinterpret_cast<const float*>(prim.uvs.data());
947
+ result.uvs.insert(result.uvs.end(), uvData, uvData + vertexCount * 2);
948
+ } else {
949
+ result.uvs.resize(result.uvs.size() + vertexCount * 2, 0.5f);
950
+ }
951
+
952
+ {
953
+ const size_t nIdx = prim.indices.size();
954
+ const size_t idxBase = result.indices.size();
955
+ result.indices.resize(idxBase + nIdx);
956
+ const uint32_t bv32 = static_cast<uint32_t>(baseVertex);
957
+ const uint32_t* __restrict srcIdx = prim.indices.data();
958
+ uint32_t* __restrict dstIdx = result.indices.data() + idxBase;
959
+ for (size_t ii = 0; ii < nIdx; ++ii) { dstIdx[ii] = srcIdx[ii] + bv32; }
960
+ }
961
+
962
+ const glm::dvec3 rtcCamRel =
963
+ prim.rtcCenter - glm::dvec3(cameraPos);
964
+ const glm::dmat4 translateD =
965
+ glm::translate(glm::dmat4(1.0), rtcCamRel);
966
+ const glm::mat4 perTileMVP =
967
+ glm::mat4(vpDouble * translateD);
968
+
969
+ DrawPrimitive draw;
970
+ draw.indexByteOffset = indexByteOff;
971
+ draw.indexCount = static_cast<uint32_t>(prim.indices.size());
972
+ draw.hasUVs = hasPrimUVs;
973
+ draw.overlayTexture = res->overlayTexture;
974
+ draw.overlayTranslation = res->overlayTranslation;
975
+ draw.overlayScale = res->overlayScale;
976
+ draw.waterMaskTexture = res->waterMaskTexture;
977
+ draw.isOnlyWater = res->isOnlyWater;
978
+ draw.wmTileBounds = wmTileBounds;
979
+ draw.wmTranslation = res->wmTranslation;
980
+ draw.wmScale = res->wmScale;
981
+ draw.mvpMatrix = perTileMVP;
982
+ draw.rtcCenterEcef = glm::vec3(prim.rtcCenter);
983
+ draw.lodFade = fade;
573
984
  result.draws.push_back(draw);
574
985
  }
575
986
  }
576
987
 
988
+ // ── Partition draws so all solid (lodFade==1) tiles render first ────────────
989
+ // Backends use this to bind the solid pipeline first (early-Z enabled on
990
+ // TBDR / Adreno / Mali) and switch to the dither pipeline at most once per
991
+ // frame when the first fading tile is reached. Stable to keep tile order
992
+ // deterministic so identical scenes produce identical command buffers.
993
+ std::stable_partition(result.draws.begin(), result.draws.end(),
994
+ [](const DrawPrimitive& d) { return d.lodFade >= 1.0f; });
995
+
996
+ // ── finalise cache state for next frame ──────────────────────────────
997
+ // Backends consume `geometrySignature` to short-circuit the host→GPU
998
+ // memcpy when their per-slot cached signature matches. We emit a non-zero
999
+ // signature only when this frame is byte-for-byte reproducible from the
1000
+ // cached state — i.e. terrain is present and no LOD fades are in flight.
1001
+ // Any fading-out draw forces a full upload because the dither cutoff
1002
+ // changes every frame.
1003
+ if (anyFadeDraws || noVisibleTerrain) {
1004
+ result.geometrySignature = 0ULL;
1005
+ cachedDraws_.clear();
1006
+ stableSigFrames_ = 0;
1007
+ } else {
1008
+ result.geometrySignature = signature;
1009
+ }
1010
+
577
1011
  result.tilesRendered = static_cast<int>(updateResult.tilesToRenderThisFrame.size());
578
1012
  result.tilesLoading = updateResult.workerThreadTileLoadQueueLength +
579
1013
  updateResult.mainThreadTileLoadQueueLength;
580
1014
  result.tilesVisited = static_cast<int>(updateResult.tilesVisited);
1015
+
581
1016
  const auto params = camera_.getParams();
582
1017
  result.cameraLat = params.latitude;
583
1018
  result.cameraLon = params.longitude;
@@ -585,6 +1020,24 @@ void CesiumEngine::updateFrame(double w, double h, FrameResult& result) {
585
1020
 
586
1021
  if (trackTerrainFloor) {
587
1022
  terrainFloorEllipsoidM_ = bestTerrainFloor;
1023
+ // refresh the floor cache on a fresh scan (slow path always runs a
1024
+ // scan unless the cache short-circuit fired above). Only memoise when
1025
+ // the geometry signature is stable enough to key on.
1026
+ if (runFloorScan && signature != 0ULL) {
1027
+ floorCacheValid_ = true;
1028
+ floorCacheSignature_ = signature;
1029
+ floorCacheCamLonDeg_ = camParams.longitude;
1030
+ floorCacheCamLatDeg_ = camParams.latitude;
1031
+ floorCacheValue_ = bestTerrainFloor;
1032
+ } else if (!runFloorScan) {
1033
+ // Cache hit on the slow path is unusual but possible (e.g. fade
1034
+ // started this frame after a stable cache was warm). Nothing to do
1035
+ // — terrainFloorEllipsoidM_ is already the cached value.
1036
+ } else {
1037
+ // Cannot key on signature=0; invalidate so the next stable frame
1038
+ // re-populates the cache deterministically.
1039
+ floorCacheValid_ = false;
1040
+ }
588
1041
  }
589
1042
 
590
1043
  lifecycle_.advanceFrame();