react-native-cesium 1.2.7 → 1.2.9

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.
@@ -11,6 +11,7 @@
11
11
  #include <CesiumAsync/IAssetAccessor.h>
12
12
  #include <CesiumUtility/IntrusivePointer.h>
13
13
 
14
+ #include <chrono>
14
15
  #include <memory>
15
16
  #include <string>
16
17
 
@@ -54,9 +55,18 @@ struct EngineConfig {
54
55
  bool preloadSiblings = true;
55
56
 
56
57
  // Refuse to render holes in the terrain (Cesium will keep ancestor tiles
57
- // visible until all children load). Relax during fast pans to lower
58
- // upload pressure.
59
- bool forbidHoles = true;
58
+ // visible until all children load). Cesium Native's default is `false`.
59
+ //
60
+ // We default to `false` (matching Cesium Native) because `true` serialises
61
+ // the load tree on the deepest-child critical path: a parent at LOD N
62
+ // cannot be rendered until every descendant down to the selected LOD has
63
+ // arrived. For a typical Alps view that's 5-6 levels deep × ~4 tiles per
64
+ // level fan-out = a ~30-60 s critical path even with cache hits. Setting
65
+ // this to `false` lets the renderer show the best tile it has *now* and
66
+ // refine progressively — the same UX as CesiumJS / Google Earth.
67
+ // Brief low-LOD pop-ins during fast pans are an acceptable trade for
68
+ // first-frame correctness in seconds rather than tens of seconds.
69
+ bool forbidHoles = false;
60
70
 
61
71
  // Water-mask textures (carries coastline shading data). Cheap to disable on
62
72
  // memory-constrained devices.
@@ -71,12 +81,25 @@ struct EngineConfig {
71
81
  // Seconds an unused tile remains in memory before being evicted.
72
82
  double tileCacheUnloadTimeInSeconds = 5.0;
73
83
 
84
+ // Soft cap (ms) on per-frame main-thread tile finalisation work. Cesium
85
+ // Native's default of 0.0 means "complete every pending main-thread load
86
+ // every tick", which on a heavy startup burst can stall the render thread
87
+ // for hundreds of ms at a time — the user sees the scene "lock up" while
88
+ // tiles upload. 20 ms keeps the render thread responsive at 30 Hz minimum
89
+ // (16.67 ms = one frame at 60 Hz; we sacrifice the worst-case frame during
90
+ // startup bursts but never go below 30 Hz interactivity). Lower values
91
+ // (5–10 ms) make individual frames smoother but visibly extend total load
92
+ // time because finalisation is throughput-bound: ~5 ms per tile, so a
93
+ // 5 ms cap → only 1 tile finalised per frame → ~60 tiles/sec ceiling
94
+ // regardless of how many CPUs are decoding in parallel.
95
+ double mainThreadLoadingTimeLimitMs = 20.0;
96
+
74
97
  // ── Disk cache (SqliteCache) ─────────────────────────────────────────
75
98
  int32_t sqliteCacheMaxRows = tunables::kDefaultSqliteCacheMaxRows;
76
99
 
77
100
  // ── Worker pool ──────────────────────────────────────────────────────
78
101
  // 0 means "auto-detect" via std::thread::hardware_concurrency() clamped to
79
- // [2, 8]. Override only for benchmarking / regression testing.
102
+ // [2, 16]. Override only for benchmarking / regression testing.
80
103
  int32_t taskProcessorThreads = 0;
81
104
 
82
105
  // ── Camera constraints ───────────────────────────────────────────────
@@ -165,9 +188,55 @@ private:
165
188
  // frame. Initialised to 0 (sea level) until a terrain tile is first loaded.
166
189
  float terrainFloorEllipsoidM_ = 0.0f;
167
190
 
191
+ // Wall-clock timestamp of the previous updateFrame call. Used to compute
192
+ // deltaTime for Tileset::updateViewGroup, which drives LOD transition fading.
193
+ // Zero-initialised "not yet set" state — first frame passes 0 deltaTime.
194
+ std::chrono::steady_clock::time_point lastFrameTime_{};
195
+ bool hasLastFrameTime_ = false;
196
+
168
197
  // Pre-computed fallback ellipsoid geometry (ECEF, absolute).
169
198
  std::vector<glm::dvec3> ellipsoidPositions_;
170
199
  std::vector<uint32_t> ellipsoidIndices_;
200
+
201
+ // ── frame-signature cache ─────────────────────────────────────────
202
+ // Signature is hashed over the tilesToRenderThisFrame primitive pointers
203
+ // and counts. When the same signature appears for kMaxFramesInFlight
204
+ // consecutive frames *and* no LOD fades are active, we know every backend
205
+ // slot's persistent geometry buffer already holds the right bytes, so we
206
+ // can skip rebuilding the merged CPU arrays entirely. On the fast path we
207
+ // still emit one DrawPrimitive per visible primitive (re-reading overlay /
208
+ // water-mask state from TileGPUResources and recomputing per-tile MVP for
209
+ // camera motion), but the heavy per-vertex memcpys are gone.
210
+ struct CachedDraw {
211
+ const TileGPUResources* res;
212
+ glm::dvec3 rtcCenter;
213
+ uint32_t indexByteOffset;
214
+ uint32_t indexCount;
215
+ bool hasUVs;
216
+ glm::vec4 wmTileBounds;
217
+ };
218
+ std::vector<CachedDraw> cachedDraws_;
219
+ uint64_t lastDrawSignature_ = 0;
220
+ // Consecutive frames where geometrySignature has been the same non-zero
221
+ // value. Once this reaches kMaxFramesInFlight every persistent buffer slot
222
+ // is guaranteed to contain the right bytes — fast path becomes legal.
223
+ int stableSigFrames_ = 0;
224
+
225
+ // ── terrain-floor scan cache ──────────────────────────────────────
226
+ // The minAltitudeAboveTerrain clamp scans every vertex of every tile whose
227
+ // bounding region contains the camera lon/lat and picks the altitude of
228
+ // the vertex with the smallest 3D distance to the camera. That's an O(NV)
229
+ // scan over the visible primitives — dominant CPU cost when the camera
230
+ // sits over a dense LOD chain. Result depends only on the camera lon/lat
231
+ // and the set of visible primitives, so we cache it keyed on
232
+ // (camLon, camLat, geometrySignature). Threshold of ~1e-6 degrees (~10 cm)
233
+ // is far below the inter-vertex spacing of the densest tiles served by
234
+ // Cesium Ion world terrain, so the cached nearest vertex stays correct.
235
+ bool floorCacheValid_ = false;
236
+ double floorCacheCamLonDeg_ = 0.0;
237
+ double floorCacheCamLatDeg_ = 0.0;
238
+ uint64_t floorCacheSignature_ = 0;
239
+ float floorCacheValue_ = 0.0f;
171
240
  };
172
241
 
173
242
  } // namespace reactnativecesium
@@ -12,7 +12,6 @@
12
12
  namespace reactnativecesium {
13
13
 
14
14
  static constexpr double kNearPlane = 1.0;
15
- static constexpr double kFarPlane = 50000000.0;
16
15
  static constexpr double kMinVfov = 20.0;
17
16
  static constexpr double kMaxVfov = 100.0;
18
17
 
@@ -71,6 +71,11 @@ struct DrawPrimitive {
71
71
  // reconstruct the world position as wp = localPos + rtcCenterEcef, which
72
72
  // avoids the catastrophic cancellation of eyeRelPos + cameraEcef at altitude.
73
73
  glm::vec3 rtcCenterEcef{0.0f};
74
+
75
+ // LOD transition dither threshold from TileRenderContent::getLodTransitionFadePercentage().
76
+ // 1.0 = fully visible (no dither); 0.0 = fully hidden. Both tilesToRenderThisFrame
77
+ // (fading in, 0→1) and tilesFadingOut (fading out, →0) use this field.
78
+ float lodFade = 1.0f;
74
79
  };
75
80
 
76
81
  struct FrameResult {
@@ -101,6 +106,14 @@ struct FrameResult {
101
106
  bool tilesetActive = false;
102
107
  double verticalFovDeg = 60.0;
103
108
  std::vector<std::string> creditHtmlLines;
109
+
110
+ // hash over the bytes that get merged into localPositions / altitudes /
111
+ // uvs / indices. Backends compare this against their per-slot cache and
112
+ // skip the host→GPU memcpy when it matches (the buffers already hold the
113
+ // exact same bytes). 0 means "force upload" — set by the engine whenever
114
+ // anything that affects the merged arrays may have changed (active LOD
115
+ // fade, tileset rebuild, overlay swap, ellipsoid fallback path, etc).
116
+ uint64_t geometrySignature = 0;
104
117
  };
105
118
 
106
119
  // ── Frame setup ─────────────────────────────────────────────────────────────
@@ -8,10 +8,21 @@ NS_ASSUME_NONNULL_BEGIN
8
8
  // in shared C++ next to CesiumEngine.
9
9
  @interface CesiumBridge : NSObject
10
10
 
11
+ /// Designated initialiser.
12
+ /// Pass `ionAccessToken` and `ionAssetId` here so the engine can start the
13
+ /// async tileset.json network round-trip immediately, before Metal pipeline
14
+ /// compilation begins — letting the two operations overlap in time.
15
+ /// `sqliteCacheMaxRows` and `taskProcessorThreads` must also be provided here
16
+ /// because they are consumed once when the SqliteCache / TaskProcessor are
17
+ /// constructed inside `buildEngine`; setting them after init has no effect.
11
18
  - (instancetype)initWithMetalLayer:(CAMetalLayer *)layer
12
- width:(int)width
13
- height:(int)height
14
- cacheDir:(NSString *)cacheDir;
19
+ width:(int)width
20
+ height:(int)height
21
+ cacheDir:(NSString *)cacheDir
22
+ ionAccessToken:(NSString *)token
23
+ ionAssetId:(int64_t)assetId
24
+ sqliteCacheMaxRows:(int32_t)sqliteRows
25
+ taskProcessorThreads:(int32_t)threads;
15
26
 
16
27
  - (void)updateIonAccessToken:(NSString *)token assetId:(int64_t)assetId;
17
28
  - (void)updateImageryAssetId:(int64_t)assetId;
@@ -41,13 +41,24 @@
41
41
  // and recreates the bridge does not pollute the new bridge's first frame.
42
42
  double _lastTickSec;
43
43
 
44
+ // Last time tilesLoading was > 0. Used to keep the render loop at full speed
45
+ // for a grace period after loading finishes so LOD transitions can complete
46
+ // quickly and updateViewGroup can immediately discover the next refinement
47
+ // level — preventing the cascade of 5-second idle gaps between load phases.
48
+ // Sentinel (epoch) means "no loading seen yet".
49
+ std::chrono::steady_clock::time_point _lastLoadingTime;
50
+
44
51
  NSString* _cacheDir;
45
52
  }
46
53
 
47
54
  - (instancetype)initWithMetalLayer:(CAMetalLayer *)layer
48
- width:(int)width
49
- height:(int)height
50
- cacheDir:(NSString *)cacheDir {
55
+ width:(int)width
56
+ height:(int)height
57
+ cacheDir:(NSString *)cacheDir
58
+ ionAccessToken:(NSString *)token
59
+ ionAssetId:(int64_t)assetId
60
+ sqliteCacheMaxRows:(int32_t)sqliteRows
61
+ taskProcessorThreads:(int32_t)threads {
51
62
  self = [super init];
52
63
  if (self) {
53
64
  _viewportWidth = width;
@@ -72,10 +83,33 @@
72
83
  @"ios/cacert.pem (run pod install).");
73
84
  }
74
85
 
86
+ // Seed the ion credentials before buildEngine so createTileset() can
87
+ // immediately start the async tileset.json HTTP round-trip. This request
88
+ // (~1.7 s network) will then run concurrently with Metal PSO compilation
89
+ // below rather than serially after it.
90
+ if (token.length > 0) {
91
+ _config.ionAccessToken = std::string([token UTF8String]);
92
+ _config.ionAssetId = assetId;
93
+ }
94
+
95
+ // These two params are consumed once inside CesiumEngine::initialize()
96
+ // (SqliteCache constructor and TaskProcessor thread-pool sizing). Setting
97
+ // them after buildEngine() has no effect, so they must be seeded here.
98
+ if (sqliteRows > 0) {
99
+ _config.sqliteCacheMaxRows = std::max<int32_t>(64, sqliteRows);
100
+ }
101
+ if (threads >= 0) {
102
+ _config.taskProcessorThreads = threads;
103
+ }
104
+
75
105
  _metalBackend = std::make_unique<reactnativecesium::MetalBackend>();
76
- _metalBackend->initialize((__bridge void*)layer, width, height);
77
106
 
107
+ // Start the async tileset.json fetch before Metal PSO compilation so both
108
+ // operations run concurrently. The network round-trip (~1.7 s) overlaps
109
+ // with pipeline compilation rather than waiting behind it.
78
110
  [self buildEngine];
111
+ _metalBackend->initialize((__bridge void*)layer, width, height);
112
+
79
113
  _initialized = YES;
80
114
 
81
115
  [[NSNotificationCenter defaultCenter]
@@ -306,8 +340,34 @@
306
340
  - (BOOL)shouldRenderNextFrame {
307
341
  if (!_initialized || _suspended || !_engine) return NO;
308
342
  if (_forceRender.load(std::memory_order_acquire)) return YES;
309
- if (_frameResult.tilesLoading > 0 || !_frameResult.tilesetActive) return YES;
343
+ if (!_frameResult.tilesetActive) return YES;
344
+ if (_frameResult.tilesLoading > 0) return YES;
310
345
  if (_integrator && _integrator->isActive()) return YES;
346
+ // ── Post-loading grace period ─────────────────────────────────────────────
347
+ // Keep the render loop at full speed for 2 seconds after the last non-zero
348
+ // loading frame. This fixes a cascade of multi-second idle gaps that
349
+ // otherwise appear between load phases:
350
+ //
351
+ // 1. Loading queue drains → shouldRenderNextFrame returns NO
352
+ // 2. Loop drops to 4 fps (0.25 s idle probe)
353
+ // 3. deltaTime is clamped to 100 ms → a 0.5 s LOD fade takes 5 idle
354
+ // frames × 0.25 s = 1.25 s of wall-clock time instead of 0.5 s
355
+ // 4. kickDescendantsWhileFadingIn=true means descendants can't be
356
+ // *requested* until the parent finishes fading
357
+ // 5. loadTiles() is only called inside renderFrame, so tile requests are
358
+ // throttled to 4 per second during idle
359
+ // The net effect: each of the ~5 refinement levels adds a 5-second gap.
360
+ //
361
+ // With a 2-second grace period:
362
+ // • LOD fades complete at full speed (0.5 s real, not 1.25 s)
363
+ // • updateViewGroup discovers the next level immediately after the fade
364
+ // • Workers start downloading before we ever slow down
365
+ // → The 5-second inter-phase gaps collapse to ~0.5 s
366
+ if (_lastLoadingTime.time_since_epoch().count() > 0) {
367
+ const double secSinceLoad = std::chrono::duration<double>(
368
+ std::chrono::steady_clock::now() - _lastLoadingTime).count();
369
+ if (secSinceLoad < 2.0) return YES;
370
+ }
311
371
  return NO;
312
372
  }
313
373
 
@@ -344,6 +404,13 @@
344
404
  _engine->camera().setParams(actual);
345
405
 
346
406
  _engine->updateFrame(_viewportWidth, _viewportHeight, _frameResult);
407
+
408
+ // Track the last time tiles were actively loading so shouldRenderNextFrame
409
+ // can suppress the idle transition for a grace period (see that method).
410
+ if (_frameResult.tilesLoading > 0) {
411
+ _lastLoadingTime = std::chrono::steady_clock::now();
412
+ }
413
+
347
414
  // For metrics purposes only — keeps the FPS EMA in something close to the
348
415
  // wall-clock cadence. Not used by the integrator.
349
416
  double dt = (_lastTickSec > 0.0) ? (nowSeconds - _lastTickSec) : (1.0 / 60.0);
@@ -372,20 +372,24 @@ class HybridCesiumView: HybridCesiumViewSpec {
372
372
  .cachesDirectory, .userDomainMask, true
373
373
  ).first ?? NSTemporaryDirectory()
374
374
 
375
+ // Pass the ion token at construction time so the engine can start the
376
+ // tileset.json network round-trip immediately, before Metal PSO compilation
377
+ // begins (the two operations then overlap rather than run in series).
375
378
  bridge = CesiumBridge(
376
379
  metalLayer: metalLayer,
377
380
  width: Int32(w),
378
381
  height: Int32(h),
379
- cacheDir: cacheDir
382
+ cacheDir: cacheDir,
383
+ ionAccessToken: ionAccessToken,
384
+ ionAssetId: Int64(ionAssetId),
385
+ sqliteCacheMaxRows: Int32(sqliteCacheMaxRows ?? 0),
386
+ taskProcessorThreads: Int32(taskProcessorThreads ?? 0)
380
387
  )
381
388
 
382
389
  syncBridgeOptionsFromProps()
383
390
 
384
- if !ionAccessToken.isEmpty {
385
- bridge?.updateIonAccessToken(ionAccessToken, assetId: Int64(ionAssetId))
386
- }
387
- // Seed from initialCamera, then replay anything the consumer set before
388
- // we were ready.
391
+ // Seed the camera so the very first updateViewGroup call (which fires in
392
+ // the first renderFrame after init) uses the correct view position.
389
393
  let seed = pendingTeleport ?? initialCamera
390
394
  bridge?.teleportLatitude(seed.latitude,
391
395
  longitude: seed.longitude,
@@ -396,6 +400,13 @@ class HybridCesiumView: HybridCesiumViewSpec {
396
400
  verticalFovDeg: seed.verticalFovDeg)
397
401
  pendingTeleport = nil
398
402
 
403
+ // updateIonAccessToken is a no-op here (token already in config from the
404
+ // constructor) but is kept for safety in case of hot-reload where the
405
+ // token changes between bridge teardown and reconstruction.
406
+ if !ionAccessToken.isEmpty {
407
+ bridge?.updateIonAccessToken(ionAccessToken, assetId: Int64(ionAssetId))
408
+ }
409
+
399
410
  if let la = pendingLat, let lo = pendingLon {
400
411
  bridge?.setPositionLatitude(la, longitude: lo); pendingLat = nil; pendingLon = nil
401
412
  }
@@ -407,9 +418,7 @@ class HybridCesiumView: HybridCesiumViewSpec {
407
418
  if let f = pendingVfov { bridge?.setVerticalFovDeg(f); pendingVfov = nil }
408
419
  if let q = pendingViewCorrection { bridge?.setViewCorrectionW(q.w, x: q.x, y: q.y, z: q.z); pendingViewCorrection = nil }
409
420
 
410
- if ionImageryAssetId != 1 {
411
- bridge?.updateImageryAssetId(Int64(ionImageryAssetId))
412
- }
421
+ bridge?.updateImageryAssetId(Int64(ionImageryAssetId))
413
422
 
414
423
  let sc = Int32(msaaSampleCount)
415
424
  bridge?.setMsaaSampleCount(sc)
@@ -448,8 +457,8 @@ class HybridCesiumView: HybridCesiumViewSpec {
448
457
  if let v = culledScreenSpaceError { bridge?.setCulledScreenSpaceError(v) }
449
458
  if let v = enableLodTransitionPeriod { bridge?.setEnableLodTransitionPeriod(v) }
450
459
  if let v = lodTransitionLength { bridge?.setLodTransitionLength(v) }
451
- if let v = sqliteCacheMaxRows { bridge?.setSqliteCacheMaxRows(Int32(v)) }
452
- if let v = taskProcessorThreads { bridge?.setTaskProcessorThreads(Int32(v)) }
460
+ // sqliteCacheMaxRows and taskProcessorThreads are applied at construction
461
+ // time via the CesiumBridge initializer; calling them here has no effect.
453
462
  if let v = minAltitudeAboveTerrain { bridge?.setMinAltitudeAboveTerrain(Float(v)) }
454
463
  }
455
464
 
@@ -471,9 +480,17 @@ class HybridCesiumView: HybridCesiumViewSpec {
471
480
  if hasConfiguredFrameRate && usingLowRefreshRate == idle { return }
472
481
  hasConfiguredFrameRate = true
473
482
  usingLowRefreshRate = idle
483
+ // Active range tops out at ProMotion's 120 Hz — on a 60 Hz panel iOS just
484
+ // clamps to 60 and ignores the upper bound, so this is safe on every
485
+ // device. The lower bound (60 Hz) prevents iOS from dynamically dropping
486
+ // to 30 Hz mid-gesture if the system thinks the scene is "static enough"
487
+ // (which is what produced the visible camera/terrain judder during
488
+ // continuous pitch/roll motion — the input was smooth, the render rate
489
+ // wasn't). Idle range stays low to preserve power when the camera and
490
+ // tileset are at rest.
474
491
  dl.preferredFrameRateRange = idle
475
- ? CAFrameRateRange(minimum: 5, maximum: 15, preferred: 10)
476
- : CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60)
492
+ ? CAFrameRateRange(minimum: 5, maximum: 15, preferred: 10)
493
+ : CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120)
477
494
  }
478
495
 
479
496
  /// Invoked by `DisplayLinkProxy` on every CADisplayLink tick (main thread).
@@ -43,8 +43,13 @@ private:
43
43
  void* device_; // id<MTLDevice>
44
44
  void* commandQueue_; // id<MTLCommandQueue>
45
45
  void* metalLayer_; // CAMetalLayer* (weak — owned by the view)
46
- void* terrainPipeline_; // id<MTLRenderPipelineState>
47
- void* skyPipeline_; // id<MTLRenderPipelineState>
46
+ // Two terrain pipelines built from the same MSL source. The solid pipeline
47
+ // omits the LOD-fade discard so Apple TBDR Hidden Surface Removal stays
48
+ // enabled at full speed for the 99% of tiles not currently transitioning.
49
+ // The dither pipeline is used only for draws where DrawPrimitive::lodFade<1.
50
+ void* terrainSolidPipeline_; // id<MTLRenderPipelineState> (no discard)
51
+ void* terrainDitherPipeline_; // id<MTLRenderPipelineState> (LOD-fade dither)
52
+ void* skyPipeline_; // id<MTLRenderPipelineState>
48
53
  void* terrainDepthState_; // reversed-Z GREATER, write=YES
49
54
  void* skyDepthState_; // Always, write=NO
50
55
  void* depthTexture_; // id<MTLTexture>
@@ -77,6 +82,21 @@ private:
77
82
  size_t idxCaps_[kMaxFramesInFlight];
78
83
  size_t uvCaps_[kMaxFramesInFlight];
79
84
  size_t altCaps_[kMaxFramesInFlight];
85
+
86
+ // per-draw uniforms ring. One MTLBuffer per frame slot, filled with
87
+ // [draw0 VS uniforms][draw0 FS uniforms][draw1 VS][draw1 FS]… each padded
88
+ // to kUniformAlign so setVertexBufferOffset / setFragmentBufferOffset can
89
+ // index into it without re-binding the buffer each draw. Replaces the
90
+ // per-draw setVertexBytes/setFragmentBytes calls that show up as encoder
91
+ // overhead on the GPU frame capture.
92
+ static constexpr size_t kUniformAlign = 256;
93
+ void* uniformsBufs_[kMaxFramesInFlight]; // id<MTLBuffer>
94
+ size_t uniformsCaps_[kMaxFramesInFlight];
95
+
96
+ // per-slot geometry signature cache. Same semantics as the Vulkan
97
+ // backend's geomSlotSig_: when FrameResult::geometrySignature matches the
98
+ // value last written to this slot we skip the merged-geometry memcpy.
99
+ uint64_t geomSlotSig_[kMaxFramesInFlight] = {};
80
100
  };
81
101
 
82
102
  } // namespace reactnativecesium