react-native-cesium 1.2.3 → 1.2.5

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.
Files changed (44) hide show
  1. package/README.md +100 -27
  2. package/android/CMakeLists.txt +1 -2
  3. package/android/src/main/cpp/CesiumBridgeAndroid.cpp +264 -130
  4. package/android/src/main/cpp/CesiumBridgeAndroid.h +70 -27
  5. package/android/src/main/java/com/margelo/nitro/reactnativecesium/CesiumBridgeJNI.kt +68 -44
  6. package/android/src/main/java/com/margelo/nitro/reactnativecesium/HybridCesiumView.kt +199 -187
  7. package/cpp/engine/CameraIntegrator.cpp +374 -0
  8. package/cpp/engine/CameraIntegrator.hpp +146 -0
  9. package/cpp/engine/EngineTunables.hpp +90 -31
  10. package/cpp/engine/GlobeCamera.cpp +17 -6
  11. package/cpp/engine/GlobeCamera.hpp +11 -8
  12. package/cpp/engine/test/CameraIntegratorTest.cpp +205 -0
  13. package/cpp/engine/test/build_and_run.sh +27 -0
  14. package/example/README.md +1 -1
  15. package/ios/CesiumBridge.h +53 -28
  16. package/ios/CesiumBridge.mm +172 -86
  17. package/ios/HybridCesiumView.swift +256 -145
  18. package/lib/typescript/src/specs/cesium-view.nitro.d.ts +85 -13
  19. package/lib/typescript/src/specs/cesium-view.nitro.d.ts.map +1 -1
  20. package/nitrogen/generated/android/ReactNativeCesiumOnLoad.cpp +2 -0
  21. package/nitrogen/generated/android/c++/JFunc_void_CameraState.hpp +77 -0
  22. package/nitrogen/generated/android/c++/JHybridCesiumViewSpec.cpp +108 -9
  23. package/nitrogen/generated/android/c++/JHybridCesiumViewSpec.hpp +21 -3
  24. package/nitrogen/generated/android/c++/views/JHybridCesiumViewStateUpdater.cpp +24 -0
  25. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativecesium/Func_void_CameraState.kt +80 -0
  26. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativecesium/HybridCesiumViewSpec.kt +71 -3
  27. package/nitrogen/generated/ios/ReactNativeCesium-Swift-Cxx-Bridge.cpp +3 -3
  28. package/nitrogen/generated/ios/ReactNativeCesium-Swift-Cxx-Bridge.hpp +43 -28
  29. package/nitrogen/generated/ios/c++/HybridCesiumViewSpecSwift.hpp +89 -9
  30. package/nitrogen/generated/ios/c++/views/HybridCesiumViewComponent.mm +30 -0
  31. package/nitrogen/generated/ios/swift/Func_void_CameraState.swift +5 -5
  32. package/nitrogen/generated/ios/swift/HybridCesiumViewSpec.swift +15 -3
  33. package/nitrogen/generated/ios/swift/HybridCesiumViewSpec_cxx.swift +242 -16
  34. package/nitrogen/generated/shared/c++/HybridCesiumViewSpec.cpp +21 -3
  35. package/nitrogen/generated/shared/c++/HybridCesiumViewSpec.hpp +22 -4
  36. package/nitrogen/generated/shared/c++/views/HybridCesiumViewComponent.cpp +66 -0
  37. package/nitrogen/generated/shared/c++/views/HybridCesiumViewComponent.hpp +6 -0
  38. package/nitrogen/generated/shared/json/CesiumViewConfig.json +6 -0
  39. package/package.json +1 -1
  40. package/src/specs/cesium-view.nitro.ts +102 -14
  41. package/cpp/engine/CameraSmoother.cpp +0 -68
  42. package/cpp/engine/CameraSmoother.hpp +0 -28
  43. package/cpp/engine/CameraTargetState.cpp +0 -205
  44. package/cpp/engine/CameraTargetState.hpp +0 -126
@@ -2,8 +2,18 @@ import Foundation
2
2
  import Metal
3
3
  import MetalKit
4
4
  import NitroModules
5
+ import QuartzCore
5
6
  import UIKit
6
7
 
8
+ /// Weak proxy for CADisplayLink so the timer does not strongly retain its
9
+ /// owning view. Without this, CADisplayLink → HybridCesiumView → displayLink
10
+ /// forms a cycle that prevents `deinit` from ever firing, which in turn
11
+ /// prevents the C++ engine and SqliteCache handle from being released.
12
+ private final class DisplayLinkProxy {
13
+ weak var owner: HybridCesiumView?
14
+ @objc func tick() { owner?.handleDisplayLinkTick() }
15
+ }
16
+
7
17
  class HybridCesiumView: HybridCesiumViewSpec {
8
18
  // MARK: - Props
9
19
 
@@ -22,9 +32,9 @@ class HybridCesiumView: HybridCesiumViewSpec {
22
32
  }
23
33
  }
24
34
 
25
- /// Construction-time seed camera. Used exactly once when the native bridge
26
- /// is created; subsequent prop writes are ignored. Use `setCamera(_:)` to
27
- /// drive the camera at runtime — it does NOT mutate this value.
35
+ /// Construction-time seed camera. Used exactly once via `teleport(...)`
36
+ /// when the native bridge is created; subsequent prop writes are ignored.
37
+ /// Use the per-DoF setters (or `teleport(...)`) for runtime updates.
28
38
  var initialCamera: CameraState = CameraState(
29
39
  latitude: 46.15,
30
40
  longitude: 7.35,
@@ -55,7 +65,6 @@ class HybridCesiumView: HybridCesiumViewSpec {
55
65
  var msaaSampleCount: Double = 1 {
56
66
  didSet {
57
67
  let s = Int32(msaaSampleCount)
58
- // applyMtkViewMsaa touches MTKView/UIKit so it stays on main.
59
68
  applyMtkViewMsaa(Int(s))
60
69
  postToRender { [weak self] in self?.bridge?.setMsaaSampleCount(s) }
61
70
  }
@@ -105,21 +114,80 @@ class HybridCesiumView: HybridCesiumViewSpec {
105
114
  didSet { if let v = minAltitudeAboveTerrain { postToRender { [weak self] in self?.bridge?.setMinAltitudeAboveTerrain(Float(v)) } } }
106
115
  }
107
116
 
117
+ // ── Rate caps ────────────────────────────────────────────────────────────
118
+ var maxYawRateDegSec: Double? { didSet { pushRateCaps() } }
119
+ var maxPitchRateDegSec: Double? { didSet { pushRateCaps() } }
120
+ var maxRollRateDegSec: Double? { didSet { pushRateCaps() } }
121
+ var maxClimbRateMps: Double? { didSet { pushRateCaps() } }
122
+ var maxGroundSpeedMps: Double? { didSet { pushRateCaps() } }
123
+
124
+ private func pushRateCaps() {
125
+ let yaw = maxYawRateDegSec ?? 0
126
+ let pitch = maxPitchRateDegSec ?? 0
127
+ let roll = maxRollRateDegSec ?? 0
128
+ let climb = maxClimbRateMps ?? 0
129
+ let gnd = maxGroundSpeedMps ?? 0
130
+ postToRender { [weak self] in
131
+ self?.bridge?.setRateCapsYaw(yaw, pitch: pitch, roll: roll,
132
+ climb: climb, groundSpeed: gnd)
133
+ }
134
+ }
135
+
108
136
  // MARK: - Render-thread dispatch helper
109
137
 
110
138
  /// Posts a closure to the dedicated render queue. Used by every prop setter
111
- /// that mutates engine state, so the EngineConfig + GPU resources are only
112
- /// ever touched from one thread (renderQueue) — the render path itself, the
113
- /// surface init, and bridge teardown all run there.
139
+ /// that mutates engine state. Camera demand setters do **not** hop here —
140
+ /// the CameraIntegrator's mutex makes per-DoF writes safe from any thread,
141
+ /// and skipping the dispatch keeps latency minimal for high-rate updates.
114
142
  private func postToRender(_ action: @escaping () -> Void) {
115
143
  renderQueue.async { action() }
116
144
  }
117
145
 
118
146
  var onMetrics: ((CesiumMetrics) -> Void)?
119
147
 
148
+ var onActualCamera: ((CameraState) -> Void)?
149
+ // Both accessed only on the render queue; no extra locking needed.
150
+ private var actualCameraLastEmitTime: CFTimeInterval = 0
151
+ private var actualCameraLastSent: CameraState? = nil
152
+
153
+ // Returns true when the two snapshots differ enough to warrant a JS dispatch.
154
+ // Thresholds mirror EngineTunables.hpp kActualCameraCallbackInterval* constants.
155
+ private func actualCameraChanged(_ a: CameraState, from b: CameraState) -> Bool {
156
+ abs(a.latitude - b.latitude) > 1e-7 ||
157
+ abs(a.longitude - b.longitude) > 1e-7 ||
158
+ abs(a.altitude - b.altitude) > 0.05 ||
159
+ abs(a.heading - b.heading) > 0.01 ||
160
+ abs(a.pitch - b.pitch) > 0.01 ||
161
+ abs(a.roll - b.roll) > 0.01 ||
162
+ abs(a.verticalFovDeg - b.verticalFovDeg) > 0.01
163
+ }
164
+
120
165
  // MARK: - Methods
121
166
 
122
- func getCameraState() throws -> Promise<CameraState> {
167
+ func getActualCamera() throws -> Promise<CameraState> {
168
+ guard let b = bridge else {
169
+ return Promise.rejected(
170
+ withError: NSError(
171
+ domain: "HybridCesiumView",
172
+ code: 1,
173
+ userInfo: [NSLocalizedDescriptionKey: "Native bridge not initialized"]
174
+ )
175
+ )
176
+ }
177
+ return Promise.resolved(
178
+ withResult: CameraState(
179
+ latitude: b.readActualLatitude(),
180
+ longitude: b.readActualLongitude(),
181
+ altitude: b.readActualAltitude(),
182
+ heading: b.readActualHeading(),
183
+ pitch: b.readActualPitch(),
184
+ roll: b.readActualRoll(),
185
+ verticalFovDeg: b.readActualVerticalFovDeg()
186
+ )
187
+ )
188
+ }
189
+
190
+ func getDemandCamera() throws -> Promise<CameraState> {
123
191
  guard let b = bridge else {
124
192
  return Promise.rejected(
125
193
  withError: NSError(
@@ -129,47 +197,79 @@ class HybridCesiumView: HybridCesiumViewSpec {
129
197
  )
130
198
  )
131
199
  }
132
- let s = CameraState(
133
- latitude: b.readCameraLatitude(),
134
- longitude: b.readCameraLongitude(),
135
- altitude: b.readCameraAltitude(),
136
- heading: b.readCameraHeading(),
137
- pitch: b.readCameraPitch(),
138
- roll: b.readCameraRoll(),
139
- verticalFovDeg: b.readVerticalFovDeg()
200
+ return Promise.resolved(
201
+ withResult: CameraState(
202
+ latitude: b.readDemandLatitude(),
203
+ longitude: b.readDemandLongitude(),
204
+ altitude: b.readDemandAltitude(),
205
+ heading: b.readDemandHeading(),
206
+ pitch: b.readDemandPitch(),
207
+ roll: b.readDemandRoll(),
208
+ verticalFovDeg: b.readDemandVerticalFovDeg()
209
+ )
140
210
  )
141
- return Promise.resolved(withResult: s)
142
211
  }
143
212
 
144
- func setCamera(camera: CameraState) throws {
145
- // Runtime camera updates flow through this method. We do *not* mutate
146
- // `initialCamera` (it is construction-time only). If the view is not
147
- // ready yet, the value is buffered in `pendingRuntimeCamera` and pushed
148
- // during initialization.
149
- pendingRuntimeCamera = camera
150
- pendingRuntimeViewCorrection = nil
151
- postToRender { [weak self] in self?.pushCameraStateIfChanged(camera) }
213
+ func setPosition(latitude: Double, longitude: Double) throws {
214
+ if let b = bridge {
215
+ b.setPositionLatitude(latitude, longitude: longitude)
216
+ } else {
217
+ pendingLat = latitude
218
+ pendingLon = longitude
219
+ }
220
+ }
221
+
222
+ func setAltitude(altitudeMeters: Double) throws {
223
+ if let b = bridge { b.setAltitude(altitudeMeters) }
224
+ else { pendingAltitude = altitudeMeters }
225
+ }
226
+
227
+ func setHeading(headingDeg: Double) throws {
228
+ if let b = bridge { b.setHeadingDeg(headingDeg) }
229
+ else { pendingHeading = headingDeg }
230
+ }
231
+
232
+ func setAttitude(pitchDeg: Double, rollDeg: Double) throws {
233
+ if let b = bridge { b.setAttitudePitch(pitchDeg, roll: rollDeg) }
234
+ else { pendingPitch = pitchDeg; pendingRoll = rollDeg }
235
+ }
236
+
237
+ func setViewCorrection(q: Quaternion) throws {
238
+ if let b = bridge { b.setViewCorrectionW(q.w, x: q.x, y: q.y, z: q.z) }
239
+ else { pendingViewCorrection = q }
152
240
  }
153
241
 
154
- func setCameraQuaternion(camera: CameraState, viewCorrection: Quaternion) throws {
155
- pendingRuntimeCamera = camera
156
- pendingRuntimeViewCorrection = viewCorrection
157
- postToRender { [weak self] in self?.pushCameraQuaternionIfChanged(camera, viewCorrection: viewCorrection) }
242
+ func setVerticalFov(deg: Double) throws {
243
+ if let b = bridge { b.setVerticalFovDeg(deg) }
244
+ else { pendingVfov = deg }
245
+ }
246
+
247
+ func teleport(camera: CameraState) throws {
248
+ if let b = bridge {
249
+ b.teleportLatitude(camera.latitude,
250
+ longitude: camera.longitude,
251
+ altitude: camera.altitude,
252
+ heading: camera.heading,
253
+ pitch: camera.pitch,
254
+ roll: camera.roll,
255
+ verticalFovDeg: camera.verticalFovDeg)
256
+ } else {
257
+ pendingTeleport = camera
258
+ }
158
259
  }
159
260
 
160
261
  func getViewCorrection() throws -> Promise<Quaternion> {
161
262
  guard let b = bridge else {
162
- return Promise.resolved(
163
- withResult: Quaternion(w: 1, x: 0, y: 0, z: 0)
164
- )
263
+ return Promise.resolved(withResult: Quaternion(w: 1, x: 0, y: 0, z: 0))
165
264
  }
166
- let q = Quaternion(
167
- w: b.readViewCorrectionW(),
168
- x: b.readViewCorrectionX(),
169
- y: b.readViewCorrectionY(),
170
- z: b.readViewCorrectionZ()
265
+ return Promise.resolved(
266
+ withResult: Quaternion(
267
+ w: b.readViewCorrectionW(),
268
+ x: b.readViewCorrectionX(),
269
+ y: b.readViewCorrectionY(),
270
+ z: b.readViewCorrectionZ()
271
+ )
171
272
  )
172
- return Promise.resolved(withResult: q)
173
273
  }
174
274
 
175
275
  // MARK: - View
@@ -177,36 +277,50 @@ class HybridCesiumView: HybridCesiumViewSpec {
177
277
  private let metalView: MTKView
178
278
  private var bridge: CesiumBridge?
179
279
  private var displayLink: CADisplayLink?
280
+ private let displayLinkProxy = DisplayLinkProxy()
180
281
  private var layoutPollTimer: Timer?
181
282
  private var metricsFrameCounter: Int = 0
182
283
  private var idleProbeAccumulator: Double = 0
183
284
  private var usingLowRefreshRate = false
184
285
  private var hasConfiguredFrameRate = false
185
- private var lastPushedCameraState: CameraState?
186
- /// Last quaternion pushed via `setCameraQuaternion`; `nil` if only `setCamera` has been used.
187
- private var lastPushedViewCorrection: Quaternion?
188
- /// Camera state requested by `setCamera` before the bridge was ready.
189
- private var pendingRuntimeCamera: CameraState?
190
- private var pendingRuntimeViewCorrection: Quaternion?
191
286
  private var lastBridgePixelSize: CGSize = .zero
192
287
 
288
+ /// Demand values that arrived before the bridge was created. Drained once
289
+ /// in `ensureInitialized()` after the seed teleport.
290
+ private var pendingTeleport: CameraState?
291
+ private var pendingLat: Double?
292
+ private var pendingLon: Double?
293
+ private var pendingAltitude: Double?
294
+ private var pendingHeading: Double?
295
+ private var pendingPitch: Double?
296
+ private var pendingRoll: Double?
297
+ private var pendingVfov: Double?
298
+ private var pendingViewCorrection: Quaternion?
299
+
193
300
  /// Dedicated serial render queue. CADisplayLink fires on `.main`, but the
194
301
  /// actual `bridge.shouldRenderNextFrame()` / `bridge.renderFrame(...)` work
195
302
  /// is dispatched here so the main thread is never blocked on Cesium tile
196
303
  /// loading or Metal command-buffer encoding. All bridge prop setters also
197
304
  /// dispatch here so they serialize with rendering and never race with
198
- /// engine state.
305
+ /// engine state. Per-DoF camera writes deliberately bypass this hop —
306
+ /// CameraIntegrator's internal mutex handles cross-thread safety.
199
307
  private let renderQueue = DispatchQueue(
200
308
  label: "com.margelo.cesium.render",
201
309
  qos: .userInteractive
202
310
  )
203
311
  /// Drops a CADisplayLink tick if the render queue is still finishing the
204
- /// previous frame — keeps it from queuing up an unbounded backlog under
205
- /// stutter (Metal's frameSemaphore would already throttle, but this saves
206
- /// the dispatch hop and keeps `dt` accurate).
312
+ /// previous frame.
207
313
  private var renderInFlight = false
208
314
  private let renderInFlightLock = NSLock()
209
315
 
316
+ /// Set to true at the start of `deinit`. Any work still queued on the
317
+ /// render or main queues checks this before touching the bridge or
318
+ /// invoking JS callbacks. Avoids two classes of warnings during fast
319
+ /// refresh / unmount: Nitro "Dispatcher has already been destroyed"
320
+ /// when emitting `onMetrics` into a dying JS runtime, and stray frame
321
+ /// ticks running after the bridge has been torn down.
322
+ private var isShutdown = false
323
+
210
324
  var view: UIView { metalView }
211
325
 
212
326
  override init() {
@@ -270,17 +384,29 @@ class HybridCesiumView: HybridCesiumViewSpec {
270
384
  if !ionAccessToken.isEmpty {
271
385
  bridge?.updateIonAccessToken(ionAccessToken, assetId: Int64(ionAssetId))
272
386
  }
273
- // Apply construction-time seed first; if the consumer already invoked
274
- // setCamera / setCameraQuaternion before we were ready, replay it last so
275
- // the runtime value wins.
276
- pushCameraStateIfChanged(initialCamera)
277
- if let runtime = pendingRuntimeCamera {
278
- if let q = pendingRuntimeViewCorrection {
279
- pushCameraQuaternionIfChanged(runtime, viewCorrection: q)
280
- } else {
281
- pushCameraStateIfChanged(runtime)
282
- }
387
+ // Seed from initialCamera, then replay anything the consumer set before
388
+ // we were ready.
389
+ let seed = pendingTeleport ?? initialCamera
390
+ bridge?.teleportLatitude(seed.latitude,
391
+ longitude: seed.longitude,
392
+ altitude: seed.altitude,
393
+ heading: seed.heading,
394
+ pitch: seed.pitch,
395
+ roll: seed.roll,
396
+ verticalFovDeg: seed.verticalFovDeg)
397
+ pendingTeleport = nil
398
+
399
+ if let la = pendingLat, let lo = pendingLon {
400
+ bridge?.setPositionLatitude(la, longitude: lo); pendingLat = nil; pendingLon = nil
283
401
  }
402
+ if let a = pendingAltitude { bridge?.setAltitude(a); pendingAltitude = nil }
403
+ if let h = pendingHeading { bridge?.setHeadingDeg(h); pendingHeading = nil }
404
+ if let p = pendingPitch, let r = pendingRoll {
405
+ bridge?.setAttitudePitch(p, roll: r); pendingPitch = nil; pendingRoll = nil
406
+ }
407
+ if let f = pendingVfov { bridge?.setVerticalFovDeg(f); pendingVfov = nil }
408
+ if let q = pendingViewCorrection { bridge?.setViewCorrectionW(q.w, x: q.x, y: q.y, z: q.z); pendingViewCorrection = nil }
409
+
284
410
  if ionImageryAssetId != 1 {
285
411
  bridge?.updateImageryAssetId(Int64(ionImageryAssetId))
286
412
  }
@@ -289,6 +415,8 @@ class HybridCesiumView: HybridCesiumViewSpec {
289
415
  bridge?.setMsaaSampleCount(sc)
290
416
  applyMtkViewMsaa(Int(sc))
291
417
 
418
+ pushRateCaps()
419
+
292
420
  startRenderLoop()
293
421
  }
294
422
 
@@ -325,69 +453,14 @@ class HybridCesiumView: HybridCesiumViewSpec {
325
453
  if let v = minAltitudeAboveTerrain { bridge?.setMinAltitudeAboveTerrain(Float(v)) }
326
454
  }
327
455
 
328
- /// Pushes camera state into native bridge only when values changed.
329
- private func pushCameraStateIfChanged(_ camera: CameraState) {
330
- guard bridge != nil else { return }
331
- if let last = lastPushedCameraState,
332
- last.latitude == camera.latitude,
333
- last.longitude == camera.longitude,
334
- last.altitude == camera.altitude,
335
- last.heading == camera.heading,
336
- last.pitch == camera.pitch,
337
- last.roll == camera.roll,
338
- last.verticalFovDeg == camera.verticalFovDeg {
339
- return
340
- }
341
- lastPushedCameraState = camera
342
- bridge?.updateCameraLatitude(
343
- camera.latitude,
344
- longitude: camera.longitude,
345
- altitude: camera.altitude,
346
- heading: camera.heading,
347
- pitch: camera.pitch,
348
- roll: camera.roll
349
- )
350
- bridge?.setVerticalFovDeg(camera.verticalFovDeg)
351
- }
352
-
353
- private func pushCameraQuaternionIfChanged(_ camera: CameraState, viewCorrection: Quaternion) {
354
- guard bridge != nil else { return }
355
- if let lastCam = lastPushedCameraState,
356
- let lastQ = lastPushedViewCorrection,
357
- lastCam.latitude == camera.latitude,
358
- lastCam.longitude == camera.longitude,
359
- lastCam.altitude == camera.altitude,
360
- lastCam.heading == camera.heading,
361
- lastCam.pitch == camera.pitch,
362
- lastCam.roll == camera.roll,
363
- lastCam.verticalFovDeg == camera.verticalFovDeg,
364
- lastQ.w == viewCorrection.w,
365
- lastQ.x == viewCorrection.x,
366
- lastQ.y == viewCorrection.y,
367
- lastQ.z == viewCorrection.z {
368
- return
369
- }
370
- lastPushedCameraState = camera
371
- lastPushedViewCorrection = viewCorrection
372
- bridge?.updateCameraQuaternionLatitude(
373
- camera.latitude,
374
- longitude: camera.longitude,
375
- altitude: camera.altitude,
376
- heading: camera.heading,
377
- pitch: camera.pitch,
378
- roll: camera.roll,
379
- viewCorrectionW: viewCorrection.w,
380
- x: viewCorrection.x,
381
- y: viewCorrection.y,
382
- z: viewCorrection.z
383
- )
384
- bridge?.setVerticalFovDeg(camera.verticalFovDeg)
385
- }
386
-
387
456
  // MARK: - Render Loop
388
457
 
389
458
  private func startRenderLoop() {
390
- displayLink = CADisplayLink(target: self, selector: #selector(renderFrame))
459
+ displayLinkProxy.owner = self
460
+ displayLink = CADisplayLink(
461
+ target: displayLinkProxy,
462
+ selector: #selector(DisplayLinkProxy.tick)
463
+ )
391
464
  setDisplayLinkFrameRate(idle: false)
392
465
  displayLink?.add(to: .main, forMode: .common)
393
466
  displayLink?.isPaused = pauseRendering
@@ -403,13 +476,12 @@ class HybridCesiumView: HybridCesiumViewSpec {
403
476
  : CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60)
404
477
  }
405
478
 
406
- @objc private func renderFrame() {
479
+ /// Invoked by `DisplayLinkProxy` on every CADisplayLink tick (main thread).
480
+ fileprivate func handleDisplayLinkTick() {
407
481
  guard !pauseRendering,
408
- let dl = displayLink,
482
+ !isShutdown,
409
483
  let bridge else { return }
410
484
 
411
- let dt = max(dl.targetTimestamp - dl.timestamp, 1.0 / 120.0)
412
-
413
485
  // metalView.bounds / drawableSize are UIKit, must stay on main.
414
486
  let scale = metalView.contentScaleFactor
415
487
  let w = Int(metalView.bounds.width * scale)
@@ -424,9 +496,7 @@ class HybridCesiumView: HybridCesiumViewSpec {
424
496
  }
425
497
  }
426
498
 
427
- // Throttle: drop the tick if the render queue hasn't finished the previous
428
- // frame yet. This keeps the dispatch backlog at zero and lets the next
429
- // CADisplayLink fire pick up a fresh dt.
499
+ // Drop the tick if the render queue hasn't finished the previous frame yet.
430
500
  renderInFlightLock.lock()
431
501
  if renderInFlight {
432
502
  renderInFlightLock.unlock()
@@ -435,16 +505,24 @@ class HybridCesiumView: HybridCesiumViewSpec {
435
505
  renderInFlight = true
436
506
  renderInFlightLock.unlock()
437
507
 
508
+ // Wall-clock time used for the metrics-only frame-dt EMA inside the
509
+ // bridge. The camera integrator owns its own clock — see
510
+ // CameraIntegrator::step().
511
+ let nowSeconds = CACurrentMediaTime()
512
+
438
513
  let resizedW = w
439
514
  let resizedH = h
440
- let weakSelf = self // captured for main-thread post-frame work
441
515
  renderQueue.async { [weak self, weak bridge] in
442
- guard let self = self, let bridge = bridge else {
443
- weakSelf.renderInFlightLock.lock()
444
- weakSelf.renderInFlight = false
445
- weakSelf.renderInFlightLock.unlock()
446
- return
516
+ guard let self = self else { return }
517
+ // `renderInFlight` must always be cleared so a single dropped tick
518
+ // doesn't permanently wedge the render loop. defer guarantees it
519
+ // regardless of which early-return path we take below.
520
+ defer {
521
+ self.renderInFlightLock.lock()
522
+ self.renderInFlight = false
523
+ self.renderInFlightLock.unlock()
447
524
  }
525
+ guard let bridge = bridge else { return }
448
526
 
449
527
  if didResize {
450
528
  bridge.resize(Int32(resizedW), height: Int32(resizedH))
@@ -455,12 +533,13 @@ class HybridCesiumView: HybridCesiumViewSpec {
455
533
  let nextIsIdle: Bool
456
534
  if shouldRenderNow {
457
535
  self.idleProbeAccumulator = 0
458
- bridge.renderFrame(withDt: dt)
536
+ bridge.renderFrame(at: nowSeconds)
459
537
  nextIsIdle = false
460
538
  } else {
461
- self.idleProbeAccumulator += dt
539
+ // Approximate dt for the idle probe. We don't need precision here.
540
+ let probeDt = 1.0 / 60.0
541
+ self.idleProbeAccumulator += probeDt
462
542
  if self.idleProbeAccumulator >= 0.25 {
463
- // Safety probe: occasionally tick engine state for late tile completions.
464
543
  self.idleProbeAccumulator = 0
465
544
  bridge.markNeedsRender()
466
545
  }
@@ -469,7 +548,7 @@ class HybridCesiumView: HybridCesiumViewSpec {
469
548
 
470
549
  self.metricsFrameCounter += 1
471
550
  let shouldEmitMetrics =
472
- self.metricsFrameCounter >= 20 && self.onMetrics != nil
551
+ self.metricsFrameCounter >= 20 && self.onMetrics != nil && !self.isShutdown
473
552
  let metricsSnapshot: CesiumMetrics? = shouldEmitMetrics ? CesiumMetrics(
474
553
  fps: bridge.metricsFps,
475
554
  tilesRendered: Double(bridge.metricsTilesRendered),
@@ -482,32 +561,64 @@ class HybridCesiumView: HybridCesiumViewSpec {
482
561
  ) : nil
483
562
  if shouldEmitMetrics { self.metricsFrameCounter = 0 }
484
563
 
485
- // Hop back to main to update CADisplayLink rate (UIKit) and emit the
486
- // metrics callback (consumer may touch UI from it).
564
+ // onActualCamera: poll at 5 Hz (kActualCameraCallbackIntervalSec = 0.2 s),
565
+ // but only dispatch when at least one field has moved beyond its epsilon.
566
+ var actualCameraSnapshot: CameraState? = nil
567
+ if self.onActualCamera != nil && !self.isShutdown &&
568
+ (nowSeconds - self.actualCameraLastEmitTime) >= 0.2 {
569
+ self.actualCameraLastEmitTime = nowSeconds
570
+ let candidate = CameraState(
571
+ latitude: bridge.readActualLatitude(),
572
+ longitude: bridge.readActualLongitude(),
573
+ altitude: bridge.readActualAltitude(),
574
+ heading: bridge.readActualHeading(),
575
+ pitch: bridge.readActualPitch(),
576
+ roll: bridge.readActualRoll(),
577
+ verticalFovDeg: bridge.readActualVerticalFovDeg()
578
+ )
579
+ if self.actualCameraLastSent.map({ self.actualCameraChanged(candidate, from: $0) }) ?? true {
580
+ actualCameraSnapshot = candidate
581
+ self.actualCameraLastSent = candidate
582
+ }
583
+ }
584
+
487
585
  DispatchQueue.main.async { [weak self] in
488
- guard let self = self else { return }
586
+ guard let self = self, !self.isShutdown else { return }
489
587
  self.setDisplayLinkFrameRate(idle: nextIsIdle)
490
588
  if let m = metricsSnapshot, let cb = self.onMetrics {
491
589
  cb(m)
492
590
  }
591
+ if let cam = actualCameraSnapshot, let cb = self.onActualCamera {
592
+ cb(cam)
593
+ }
493
594
  }
494
-
495
- self.renderInFlightLock.lock()
496
- self.renderInFlight = false
497
- self.renderInFlightLock.unlock()
595
+ // renderInFlight is cleared by the defer at the top of this closure.
498
596
  }
499
597
  }
500
598
 
501
599
  deinit {
600
+ // Set the shutdown gate first so any in-flight render-queue tick and
601
+ // any queued main-queue follow-on (metric emission, frame-rate update)
602
+ // bail out before they touch the bridge or invoke onMetrics into a
603
+ // JS dispatcher that may already be gone.
604
+ isShutdown = true
605
+ onMetrics = nil
606
+ onActualCamera = nil
607
+ actualCameraLastSent = nil
608
+
502
609
  layoutPollTimer?.invalidate()
610
+ layoutPollTimer = nil
503
611
  displayLink?.invalidate()
612
+ displayLink = nil
613
+ displayLinkProxy.owner = nil
504
614
 
505
- // Drain the render queue so we don't release `bridge` while it's
506
- // mid-encode. `sync` here is bounded by Metal's frameSemaphore wait —
507
- // which itself is already bounded to 1 s in MetalBackend::shutdown().
508
615
  let bridgeRef = bridge
509
616
  bridge = nil
510
617
  if let b = bridgeRef {
618
+ // Drain anything already on the render queue, then ask the bridge
619
+ // to release its engine + Metal backend synchronously. We do this
620
+ // inside the sync hop so the SqliteCache file handle is closed
621
+ // before any subsequent CesiumView mount can open the same cache.
511
622
  renderQueue.sync {
512
623
  b.shutdown()
513
624
  }