react-native-cesium 1.2.6 → 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.
@@ -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
@@ -118,6 +118,13 @@ inline constexpr double kVelocityBleedTauFactor = 2.0;
118
118
  // EWMA blend coefficient for the inter-arrival mean. Higher = faster adapt
119
119
  // to cadence changes (e.g. user switches from gesture to sparse GPS feed).
120
120
  inline constexpr double kIntervalEwmaAlpha = 0.3;
121
+ // Demand-pull time constant (seconds). When measurements have stopped
122
+ // arriving (silence > grace), the actual value is gently pulled toward
123
+ // the last demand at this rate. This guarantees convergence for one-shot
124
+ // API calls (e.g. setAltitude from a button) without affecting continuous
125
+ // sensor or gesture streams — those always deliver a new measurement before
126
+ // the grace window expires, so this term is never active. 0 = disabled.
127
+ inline constexpr double kDemandPullTauSec = 0.8;
121
128
 
122
129
  // Optional outlier rejection (off if 0). When > 0, a single measurement whose
123
130
  // residual against the predicted state exceeds the threshold is dropped (and
@@ -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,16 @@ 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.
11
15
  - (instancetype)initWithMetalLayer:(CAMetalLayer *)layer
12
- width:(int)width
13
- height:(int)height
14
- cacheDir:(NSString *)cacheDir;
16
+ width:(int)width
17
+ height:(int)height
18
+ cacheDir:(NSString *)cacheDir
19
+ ionAccessToken:(NSString *)token
20
+ ionAssetId:(int64_t)assetId;
15
21
 
16
22
  - (void)updateIonAccessToken:(NSString *)token assetId:(int64_t)assetId;
17
23
  - (void)updateImageryAssetId:(int64_t)assetId;
@@ -41,13 +41,22 @@
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 {
51
60
  self = [super init];
52
61
  if (self) {
53
62
  _viewportWidth = width;
@@ -72,10 +81,23 @@
72
81
  @"ios/cacert.pem (run pod install).");
73
82
  }
74
83
 
84
+ // Seed the ion credentials before buildEngine so createTileset() can
85
+ // immediately start the async tileset.json HTTP round-trip. This request
86
+ // (~1.7 s network) will then run concurrently with Metal PSO compilation
87
+ // below rather than serially after it.
88
+ if (token.length > 0) {
89
+ _config.ionAccessToken = std::string([token UTF8String]);
90
+ _config.ionAssetId = assetId;
91
+ }
92
+
75
93
  _metalBackend = std::make_unique<reactnativecesium::MetalBackend>();
76
- _metalBackend->initialize((__bridge void*)layer, width, height);
77
94
 
95
+ // Start the async tileset.json fetch before Metal PSO compilation so both
96
+ // operations run concurrently. The network round-trip (~1.7 s) overlaps
97
+ // with pipeline compilation rather than waiting behind it.
78
98
  [self buildEngine];
99
+ _metalBackend->initialize((__bridge void*)layer, width, height);
100
+
79
101
  _initialized = YES;
80
102
 
81
103
  [[NSNotificationCenter defaultCenter]
@@ -306,8 +328,34 @@
306
328
  - (BOOL)shouldRenderNextFrame {
307
329
  if (!_initialized || _suspended || !_engine) return NO;
308
330
  if (_forceRender.load(std::memory_order_acquire)) return YES;
309
- if (_frameResult.tilesLoading > 0 || !_frameResult.tilesetActive) return YES;
331
+ if (!_frameResult.tilesetActive) return YES;
332
+ if (_frameResult.tilesLoading > 0) return YES;
310
333
  if (_integrator && _integrator->isActive()) return YES;
334
+ // ── Post-loading grace period ─────────────────────────────────────────────
335
+ // Keep the render loop at full speed for 2 seconds after the last non-zero
336
+ // loading frame. This fixes a cascade of multi-second idle gaps that
337
+ // otherwise appear between load phases:
338
+ //
339
+ // 1. Loading queue drains → shouldRenderNextFrame returns NO
340
+ // 2. Loop drops to 4 fps (0.25 s idle probe)
341
+ // 3. deltaTime is clamped to 100 ms → a 0.5 s LOD fade takes 5 idle
342
+ // frames × 0.25 s = 1.25 s of wall-clock time instead of 0.5 s
343
+ // 4. kickDescendantsWhileFadingIn=true means descendants can't be
344
+ // *requested* until the parent finishes fading
345
+ // 5. loadTiles() is only called inside renderFrame, so tile requests are
346
+ // throttled to 4 per second during idle
347
+ // The net effect: each of the ~5 refinement levels adds a 5-second gap.
348
+ //
349
+ // With a 2-second grace period:
350
+ // • LOD fades complete at full speed (0.5 s real, not 1.25 s)
351
+ // • updateViewGroup discovers the next level immediately after the fade
352
+ // • Workers start downloading before we ever slow down
353
+ // → The 5-second inter-phase gaps collapse to ~0.5 s
354
+ if (_lastLoadingTime.time_since_epoch().count() > 0) {
355
+ const double secSinceLoad = std::chrono::duration<double>(
356
+ std::chrono::steady_clock::now() - _lastLoadingTime).count();
357
+ if (secSinceLoad < 2.0) return YES;
358
+ }
311
359
  return NO;
312
360
  }
313
361
 
@@ -344,6 +392,13 @@
344
392
  _engine->camera().setParams(actual);
345
393
 
346
394
  _engine->updateFrame(_viewportWidth, _viewportHeight, _frameResult);
395
+
396
+ // Track the last time tiles were actively loading so shouldRenderNextFrame
397
+ // can suppress the idle transition for a grace period (see that method).
398
+ if (_frameResult.tilesLoading > 0) {
399
+ _lastLoadingTime = std::chrono::steady_clock::now();
400
+ }
401
+
347
402
  // For metrics purposes only — keeps the FPS EMA in something close to the
348
403
  // wall-clock cadence. Not used by the integrator.
349
404
  double dt = (_lastTickSec > 0.0) ? (nowSeconds - _lastTickSec) : (1.0 / 60.0);
@@ -372,20 +372,22 @@ 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)
380
385
  )
381
386
 
382
387
  syncBridgeOptionsFromProps()
383
388
 
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.
389
+ // Seed the camera so the very first updateViewGroup call (which fires in
390
+ // the first renderFrame after init) uses the correct view position.
389
391
  let seed = pendingTeleport ?? initialCamera
390
392
  bridge?.teleportLatitude(seed.latitude,
391
393
  longitude: seed.longitude,
@@ -396,6 +398,13 @@ class HybridCesiumView: HybridCesiumViewSpec {
396
398
  verticalFovDeg: seed.verticalFovDeg)
397
399
  pendingTeleport = nil
398
400
 
401
+ // updateIonAccessToken is a no-op here (token already in config from the
402
+ // constructor) but is kept for safety in case of hot-reload where the
403
+ // token changes between bridge teardown and reconstruction.
404
+ if !ionAccessToken.isEmpty {
405
+ bridge?.updateIonAccessToken(ionAccessToken, assetId: Int64(ionAssetId))
406
+ }
407
+
399
408
  if let la = pendingLat, let lo = pendingLon {
400
409
  bridge?.setPositionLatitude(la, longitude: lo); pendingLat = nil; pendingLon = nil
401
410
  }
@@ -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