react-native-cesium 1.0.6 → 1.2.0

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 (59) hide show
  1. package/README.md +64 -11
  2. package/ReactNativeCesium.podspec +1 -1
  3. package/android/CMakeLists.txt +5 -3
  4. package/android/src/main/cpp/CesiumBridgeAndroid.cpp +197 -227
  5. package/android/src/main/cpp/CesiumBridgeAndroid.h +48 -49
  6. package/android/src/main/cpp/cpp-adapter.cpp +2 -2
  7. package/android/src/main/cpp/vulkan/VulkanBackend.cpp +1010 -569
  8. package/android/src/main/cpp/vulkan/VulkanBackend.h +121 -12
  9. package/android/src/main/cpp/vulkan/shaders/sky.frag +20 -10
  10. package/android/src/main/java/com/margelo/nitro/reactnativecesium/CesiumBridgeJNI.kt +28 -0
  11. package/android/src/main/java/com/margelo/nitro/reactnativecesium/HybridCesiumView.kt +295 -58
  12. package/cpp/engine/CameraSmoother.cpp +99 -0
  13. package/cpp/engine/CameraSmoother.hpp +29 -0
  14. package/cpp/engine/CameraTargetState.cpp +49 -0
  15. package/cpp/engine/CameraTargetState.hpp +121 -0
  16. package/cpp/engine/CesiumEngine.cpp +206 -60
  17. package/cpp/engine/CesiumEngine.hpp +77 -10
  18. package/cpp/engine/EngineTunables.hpp +79 -0
  19. package/cpp/engine/GeoidConverter.cpp +36 -0
  20. package/cpp/engine/GeoidConverter.hpp +10 -0
  21. package/cpp/engine/GlobeCamera.cpp +7 -2
  22. package/cpp/engine/MetricsAggregator.cpp +71 -0
  23. package/cpp/engine/MetricsAggregator.hpp +54 -0
  24. package/cpp/engine/TileLifecycleManager.cpp +0 -12
  25. package/cpp/engine/TileLifecycleManager.hpp +0 -3
  26. package/cpp/third_party/egm96/EGM96.c +289 -0
  27. package/cpp/third_party/egm96/EGM96.h +45 -0
  28. package/cpp/third_party/egm96/EGM96_data.h +65372 -0
  29. package/ios/CesiumBridge.h +24 -5
  30. package/ios/CesiumBridge.mm +149 -271
  31. package/ios/HybridCesiumView.swift +197 -40
  32. package/ios/metal/MetalBackend.mm +72 -23
  33. package/lib/typescript/src/specs/cesium-view.nitro.d.ts +26 -0
  34. package/lib/typescript/src/specs/cesium-view.nitro.d.ts.map +1 -1
  35. package/nitrogen/generated/android/c++/JCesiumMetrics.hpp +5 -1
  36. package/nitrogen/generated/android/c++/JHybridCesiumViewSpec.cpp +109 -1
  37. package/nitrogen/generated/android/c++/JHybridCesiumViewSpec.hpp +24 -0
  38. package/nitrogen/generated/android/c++/views/JHybridCesiumViewStateUpdater.cpp +48 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativecesium/CameraState.kt +25 -0
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativecesium/CesiumMetrics.kt +32 -2
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativecesium/HybridCesiumViewSpec.kt +72 -0
  42. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativecesium/Quaternion.kt +19 -0
  43. package/nitrogen/generated/ios/ReactNativeCesium-Swift-Cxx-Bridge.hpp +30 -0
  44. package/nitrogen/generated/ios/c++/HybridCesiumViewSpecSwift.hpp +85 -1
  45. package/nitrogen/generated/ios/c++/views/HybridCesiumViewComponent.mm +60 -0
  46. package/nitrogen/generated/ios/swift/CesiumMetrics.swift +7 -2
  47. package/nitrogen/generated/ios/swift/HybridCesiumViewSpec.swift +12 -0
  48. package/nitrogen/generated/ios/swift/HybridCesiumViewSpec_cxx.swift +288 -0
  49. package/nitrogen/generated/shared/c++/CesiumMetrics.hpp +5 -1
  50. package/nitrogen/generated/shared/c++/HybridCesiumViewSpec.cpp +24 -0
  51. package/nitrogen/generated/shared/c++/HybridCesiumViewSpec.hpp +25 -1
  52. package/nitrogen/generated/shared/c++/views/HybridCesiumViewComponent.cpp +132 -0
  53. package/nitrogen/generated/shared/c++/views/HybridCesiumViewComponent.hpp +13 -1
  54. package/nitrogen/generated/shared/json/CesiumViewConfig.json +12 -0
  55. package/package.json +4 -4
  56. package/scripts/cesium/clone.mjs +1 -1
  57. package/src/specs/cesium-view.nitro.ts +29 -0
  58. package/cpp/engine/DeletionQueue.cpp +0 -28
  59. package/cpp/engine/DeletionQueue.hpp +0 -28
@@ -1,6 +1,10 @@
1
1
  package com.margelo.nitro.reactnativecesium
2
2
 
3
3
  import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.HandlerThread
6
+ import android.os.Process
7
+ import android.util.Log
4
8
  import android.view.Choreographer
5
9
  import android.view.SurfaceHolder
6
10
  import android.view.SurfaceView
@@ -8,28 +12,53 @@ import android.view.View
8
12
  import com.margelo.nitro.core.Promise
9
13
  import java.io.File
10
14
  import java.io.FileOutputStream
15
+ import java.util.concurrent.atomic.AtomicBoolean
11
16
 
12
17
  class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec() {
13
18
  private val surfaceView = SurfaceView(appContext)
14
- private var bridge: CesiumBridgeJNI? = null
19
+ /// Bridge is only ever read on the render thread. The UI thread posts
20
+ /// requests via `renderHandler`. Marked volatile so init/shutdown observers
21
+ /// (`bridge != null`) on the UI thread see the latest reference, but real
22
+ /// work is always serialized through the render thread.
23
+ @Volatile private var bridge: CesiumBridgeJNI? = null
15
24
  private var metricsFrameCounter = 0
16
25
  private var idleProbeAccumulator = 0.0
17
- private var usingLowRefreshRate = false
18
26
  private var lastFrameTimeNanos = 0L
27
+ /// Last-pushed camera state — read/written on the render thread (via
28
+ /// `renderHandler.post`), never directly on the UI thread.
19
29
  private var lastPushedCamera: CameraState? = null
20
30
  private var lastPushedViewCorrection: Quaternion? = null
31
+ /// Camera state requested by `setCamera` before the bridge was ready.
32
+ /// Updated on the UI thread, drained on the render thread.
33
+ @Volatile private var pendingRuntimeCamera: CameraState? = null
34
+ @Volatile private var pendingRuntimeViewCorrection: Quaternion? = null
35
+ /// True while a Choreographer frame callback is pending. Mutated on the UI
36
+ /// thread inside the callback / scheduleNextFrame.
21
37
  private var renderLoopActive = false
38
+ /// Drops Choreographer ticks while the render thread is still finishing the
39
+ /// previous frame. Set on UI thread when posting; cleared on render thread
40
+ /// after the bridge call completes.
41
+ private val renderInFlight = AtomicBoolean(false)
42
+
43
+ /// Dedicated render thread — owns all CesiumEngine + GPU work. Created lazily
44
+ /// when the first SurfaceHolder.Callback fires. The Choreographer (UI thread)
45
+ /// posts render requests here; bridge config setters likewise post here so
46
+ /// they serialize with rendering and never race with engine state.
47
+ private var renderThread: HandlerThread? = null
48
+ private var renderHandler: Handler? = null
22
49
 
23
50
  override var ionAccessToken: String = ""
24
51
  set(value) {
25
52
  field = value
26
- bridge?.updateIonAccessToken(value, ionAssetId.toLong())
53
+ val assetId = ionAssetId.toLong()
54
+ postToRender { bridge?.updateIonAccessToken(value, assetId) }
27
55
  }
28
56
 
29
57
  override var ionAssetId: Double = 1.0
30
58
  set(value) {
31
59
  field = value
32
- bridge?.updateIonAccessToken(ionAccessToken, value.toLong())
60
+ val token = ionAccessToken
61
+ postToRender { bridge?.updateIonAccessToken(token, value.toLong()) }
33
62
  }
34
63
 
35
64
  override var initialCamera: CameraState = CameraState(
@@ -46,25 +75,51 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
46
75
  set(value) {
47
76
  field = value
48
77
  if (!value) {
49
- bridge?.markNeedsRender()
50
- scheduleNextFrame()
78
+ postToRender { bridge?.markNeedsRender() }
79
+ scheduleNextFrame(immediate = true)
51
80
  }
52
81
  }
53
82
 
54
83
  override var maximumScreenSpaceError: Double = 32.0
55
- set(value) { field = value; bridge?.setMaxSSE(value) }
84
+ set(value) { field = value; postToRender { bridge?.setMaxSSE(value) } }
56
85
 
57
86
  override var maximumSimultaneousTileLoads: Double = 12.0
58
- set(value) { field = value; bridge?.setMaxSimLoads(value.toInt()) }
87
+ set(value) { field = value; postToRender { bridge?.setMaxSimLoads(value.toInt()) } }
59
88
 
60
89
  override var loadingDescendantLimit: Double = 20.0
61
- set(value) { field = value; bridge?.setLoadDescLim(value.toInt()) }
90
+ set(value) { field = value; postToRender { bridge?.setLoadDescLim(value.toInt()) } }
62
91
 
63
92
  override var msaaSampleCount: Double = 1.0
64
- set(value) { field = value; bridge?.setMsaa(value.toInt()) }
93
+ set(value) { field = value; postToRender { bridge?.setMsaa(value.toInt()) } }
65
94
 
66
95
  override var ionImageryAssetId: Double = 1.0
67
- set(value) { field = value; bridge?.updateImageryAssetId(value.toLong()) }
96
+ set(value) { field = value; postToRender { bridge?.updateImageryAssetId(value.toLong()) } }
97
+
98
+ // ── Optional perf / quality knobs ────────────────────────────────────────
99
+ override var maximumCachedMiB: Double? = null
100
+ set(value) { field = value; if (value != null) postToRender { bridge?.setMaximumCachedMiB(value.toInt()) } }
101
+ override var preloadAncestors: Boolean? = null
102
+ set(value) { field = value; if (value != null) postToRender { bridge?.setPreloadAncestors(value) } }
103
+ override var preloadSiblings: Boolean? = null
104
+ set(value) { field = value; if (value != null) postToRender { bridge?.setPreloadSiblings(value) } }
105
+ override var forbidHoles: Boolean? = null
106
+ set(value) { field = value; if (value != null) postToRender { bridge?.setForbidHoles(value) } }
107
+ override var enableWaterMask: Boolean? = null
108
+ set(value) { field = value; if (value != null) postToRender { bridge?.setEnableWaterMask(value) } }
109
+ override var enableFogCulling: Boolean? = null
110
+ set(value) { field = value; if (value != null) postToRender { bridge?.setEnableFogCulling(value) } }
111
+ override var enforceCulledScreenSpaceError: Boolean? = null
112
+ set(value) { field = value; if (value != null) postToRender { bridge?.setEnforceCulledScreenSpaceError(value) } }
113
+ override var culledScreenSpaceError: Double? = null
114
+ set(value) { field = value; if (value != null) postToRender { bridge?.setCulledScreenSpaceError(value) } }
115
+ override var enableLodTransitionPeriod: Boolean? = null
116
+ set(value) { field = value; if (value != null) postToRender { bridge?.setEnableLodTransitionPeriod(value) } }
117
+ override var lodTransitionLength: Double? = null
118
+ set(value) { field = value; if (value != null) postToRender { bridge?.setLodTransitionLength(value) } }
119
+ override var sqliteCacheMaxRows: Double? = null
120
+ set(value) { field = value; if (value != null) postToRender { bridge?.setSqliteCacheMaxRows(value.toInt()) } }
121
+ override var taskProcessorThreads: Double? = null
122
+ set(value) { field = value; if (value != null) postToRender { bridge?.setTaskProcessorThreads(value.toInt()) } }
68
123
 
69
124
  override var onMetrics: ((metrics: CesiumMetrics) -> Unit)? = null
70
125
 
@@ -72,7 +127,9 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
72
127
 
73
128
  private val frameCallback = Choreographer.FrameCallback { frameTimeNanos ->
74
129
  renderLoopActive = false
75
- if (pauseRendering || bridge == null) return@FrameCallback
130
+ if (pauseRendering || bridge == null) {
131
+ return@FrameCallback
132
+ }
76
133
 
77
134
  val dt = if (lastFrameTimeNanos > 0) {
78
135
  (frameTimeNanos - lastFrameTimeNanos).coerceAtLeast(1_000_000L) / 1_000_000_000.0
@@ -81,37 +138,71 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
81
138
  }
82
139
  lastFrameTimeNanos = frameTimeNanos
83
140
 
84
- val shouldRender = bridge?.shouldRenderNextFrame() ?: false
141
+ // Drop the tick if the previous frame's render hasn't finished — keeps the
142
+ // render thread from queuing up an unbounded backlog under stutter.
143
+ if (!renderInFlight.compareAndSet(false, true)) {
144
+ // Re-arm so we try again next vsync.
145
+ scheduleNextFrame(immediate = true)
146
+ return@FrameCallback
147
+ }
148
+
149
+ val rh = renderHandler
150
+ if (rh == null) {
151
+ renderInFlight.set(false)
152
+ return@FrameCallback
153
+ }
154
+ rh.post { runFrameOnRenderThread(dt) }
155
+ }
156
+
157
+ /// Called on the render thread. Owns ALL CesiumEngine + GPU access; the UI
158
+ /// thread only signals demand here. Safe to read `onMetrics` because Nitro
159
+ /// callbacks dispatch internally to the JS thread.
160
+ private fun runFrameOnRenderThread(dt: Double) {
161
+ val b = bridge
162
+ if (b == null) {
163
+ renderInFlight.set(false)
164
+ return
165
+ }
166
+
167
+ val shouldRender = b.shouldRenderNextFrame()
168
+ val nextImmediate: Boolean
85
169
  if (shouldRender) {
86
- usingLowRefreshRate = false
87
170
  idleProbeAccumulator = 0.0
88
- bridge?.renderFrame(dt)
171
+ b.renderFrame(dt)
172
+ nextImmediate = true
89
173
  } else {
90
- usingLowRefreshRate = true
91
174
  idleProbeAccumulator += dt
92
175
  if (idleProbeAccumulator >= 0.25) {
93
176
  idleProbeAccumulator = 0.0
94
- bridge?.markNeedsRender()
177
+ b.markNeedsRender()
95
178
  }
179
+ nextImmediate = false
96
180
  }
97
181
 
98
182
  metricsFrameCounter++
99
183
  if (metricsFrameCounter >= 20) {
100
184
  metricsFrameCounter = 0
101
- val b = bridge ?: return@FrameCallback
102
- val cb = onMetrics ?: return@FrameCallback
103
- cb(CesiumMetrics(
104
- fps = b.getMetricsFps(),
105
- tilesRendered = b.getMetricsTilesRendered().toDouble(),
106
- tilesLoading = b.getMetricsTilesLoading().toDouble(),
107
- tilesVisited = b.getMetricsTilesVisited().toDouble(),
108
- ionTokenConfigured = b.getMetricsIonTokenConfigured(),
109
- tilesetReady = b.getMetricsTilesetReady(),
110
- creditsPlainText = b.getMetricsCreditsPlainText(),
111
- ))
185
+ val cb = onMetrics
186
+ if (cb != null) {
187
+ cb(CesiumMetrics(
188
+ fps = b.getMetricsFps(),
189
+ tilesRendered = b.getMetricsTilesRendered().toDouble(),
190
+ tilesLoading = b.getMetricsTilesLoading().toDouble(),
191
+ tilesVisited = b.getMetricsTilesVisited().toDouble(),
192
+ ionTokenConfigured = b.getMetricsIonTokenConfigured(),
193
+ tlsConfigured = b.getMetricsTlsConfigured(),
194
+ tilesetReady = b.getMetricsTilesetReady(),
195
+ creditsPlainText = b.getMetricsCreditsPlainText(),
196
+ ))
197
+ }
112
198
  }
113
199
 
114
- scheduleNextFrame()
200
+ renderInFlight.set(false)
201
+
202
+ // Re-arm on the UI thread so Choreographer remains the source of truth
203
+ // for vsync timing. Posting a no-op runnable to surfaceView keeps us off
204
+ // the render thread for that small piece of bookkeeping.
205
+ surfaceView.post { scheduleNextFrame(immediate = nextImmediate) }
115
206
  }
116
207
 
117
208
  private val surfaceCallback = object : SurfaceHolder.Callback {
@@ -121,35 +212,109 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
121
212
  val h = surfaceView.height
122
213
  if (w <= 0 || h <= 0) return
123
214
 
124
- val b = CesiumBridgeJNI()
215
+ // Spin up the render thread on first surfaceCreated. If we previously
216
+ // tore it down (e.g. on backgrounding), re-spawn cleanly.
217
+ ensureRenderThread()
218
+ val rh = renderHandler ?: return
219
+
125
220
  val cacheDir = appContext.cacheDir.absolutePath
126
221
  val cacertPath = extractCacert()
127
- b.init(surface, w, h, cacheDir, cacertPath)
128
- bridge = b
222
+ // Init runs on the render thread because Vulkan instance/device creation
223
+ // and Cesium Native init both touch state we'd rather only ever poke
224
+ // from one thread.
225
+ rh.post {
226
+ val b = CesiumBridgeJNI()
227
+ b.init(surface, w, h, cacheDir, cacertPath)
228
+ bridge = b
229
+ syncBridgePropsOnRenderThread(b)
230
+
231
+ b.updateCamera(
232
+ initialCamera.latitude, initialCamera.longitude, initialCamera.altitude,
233
+ initialCamera.heading, initialCamera.pitch, initialCamera.roll,
234
+ )
235
+ b.setVerticalFovDeg(initialCamera.verticalFovDeg)
236
+ lastPushedCamera = initialCamera
237
+
238
+ val pending = pendingRuntimeCamera
239
+ if (pending != null) {
240
+ val q = pendingRuntimeViewCorrection
241
+ if (q != null) {
242
+ b.updateCameraQuaternion(
243
+ pending.latitude, pending.longitude, pending.altitude,
244
+ pending.heading, pending.pitch, pending.roll,
245
+ q.w, q.x, q.y, q.z,
246
+ )
247
+ lastPushedViewCorrection = q
248
+ } else {
249
+ b.updateCamera(
250
+ pending.latitude, pending.longitude, pending.altitude,
251
+ pending.heading, pending.pitch, pending.roll,
252
+ )
253
+ }
254
+ b.setVerticalFovDeg(pending.verticalFovDeg)
255
+ lastPushedCamera = pending
256
+ }
129
257
 
130
- syncPropsTobridge()
131
- if (ionAccessToken.isNotEmpty()) {
132
- b.updateIonAccessToken(ionAccessToken, ionAssetId.toLong())
133
- }
134
- pushCameraIfChanged(initialCamera)
135
- if (ionImageryAssetId != 1.0) {
136
- b.updateImageryAssetId(ionImageryAssetId.toLong())
137
- }
138
- b.setMsaa(msaaSampleCount.toInt())
258
+ if (ionAccessToken.isNotEmpty()) {
259
+ b.updateIonAccessToken(ionAccessToken, ionAssetId.toLong())
260
+ }
261
+ if (ionImageryAssetId != 1.0) {
262
+ b.updateImageryAssetId(ionImageryAssetId.toLong())
263
+ }
264
+ b.setMsaa(msaaSampleCount.toInt())
139
265
 
140
- lastFrameTimeNanos = 0L
141
- scheduleNextFrame()
266
+ // Start the rendering loop here, after the bridge is fully
267
+ // initialized. scheduleNextFrame must be invoked on the UI thread
268
+ // because Choreographer.postFrameCallback() requires it.
269
+ surfaceView.post { scheduleNextFrame(immediate = true) }
270
+ }
142
271
  }
143
272
 
144
273
  override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
145
- bridge?.resize(width, height)
146
- bridge?.markNeedsRender()
274
+ postToRender {
275
+ bridge?.resize(width, height)
276
+ bridge?.markNeedsRender()
277
+ // Re-arm the loop in case surfaceChanged fires before the bridge was
278
+ // created (rare, but possible on cold starts). Idempotent: scheduleNextFrame
279
+ // early-returns if the loop is already active.
280
+ surfaceView.post { scheduleNextFrame(immediate = true) }
281
+ }
147
282
  }
148
283
 
149
284
  override fun surfaceDestroyed(holder: SurfaceHolder) {
150
- bridge?.shutdown()
151
- bridge?.destroy()
152
- bridge = null
285
+ val rh = renderHandler
286
+ val ht = renderThread
287
+
288
+ if (rh == null || ht == null) {
289
+ // Nothing to drain; clear bridge reference if it somehow exists.
290
+ bridge?.shutdown()
291
+ bridge?.destroy()
292
+ bridge = null
293
+ return
294
+ }
295
+
296
+ // Block the UI thread (briefly) until the render thread has shut the
297
+ // bridge down — surfaceDestroyed contract requires the surface to be
298
+ // released before this returns. We cap our wait so a stuck GPU teardown
299
+ // can't hang the UI thread indefinitely (matches our 1s shutdown
300
+ // semaphore policy on the Vulkan / Metal side).
301
+ val done = java.util.concurrent.CountDownLatch(1)
302
+ rh.post {
303
+ try {
304
+ bridge?.shutdown()
305
+ bridge?.destroy()
306
+ } catch (t: Throwable) {
307
+ Log.w("CesiumView", "render-thread shutdown threw", t)
308
+ } finally {
309
+ bridge = null
310
+ done.countDown()
311
+ }
312
+ }
313
+ if (!done.await(1500, java.util.concurrent.TimeUnit.MILLISECONDS)) {
314
+ Log.w("CesiumView", "Render-thread shutdown did not complete in 1.5s; "
315
+ + "tearing down handler thread anyway.")
316
+ }
317
+ stopRenderThread()
153
318
  }
154
319
  }
155
320
 
@@ -157,6 +322,37 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
157
322
  surfaceView.holder.addCallback(surfaceCallback)
158
323
  }
159
324
 
325
+ // ── Render-thread helpers ────────────────────────────────────────────────
326
+
327
+ private fun ensureRenderThread() {
328
+ if (renderThread != null) return
329
+ val ht = HandlerThread("CesiumRender", Process.THREAD_PRIORITY_DISPLAY)
330
+ ht.start()
331
+ renderHandler = Handler(ht.looper)
332
+ renderThread = ht
333
+ }
334
+
335
+ private fun stopRenderThread() {
336
+ val ht = renderThread ?: return
337
+ renderHandler = null
338
+ renderThread = null
339
+ ht.quitSafely()
340
+ }
341
+
342
+ /// Posts an action to the render thread. When the render thread isn't
343
+ /// running yet (early prop assignment, or after teardown), we silently drop
344
+ /// the action — bridges always take their initial config from the
345
+ /// `syncBridgePropsOnRenderThread` step inside surfaceCreated.
346
+ private fun postToRender(action: () -> Unit) {
347
+ renderHandler?.post(action)
348
+ }
349
+
350
+ /// Camera reads cross the JNI from the JS thread (Nitro promise resolution).
351
+ /// They read snapshot scalar values through CesiumBridgeAndroid → engine →
352
+ /// GlobeCamera, none of which is mutated outside the render thread, so the
353
+ /// worst that can happen is observing a value from a frame slightly behind
354
+ /// what the render thread is encoding right now — acceptable for a UI-driven
355
+ /// camera read.
160
356
  override fun getCameraState(): Promise<CameraState> {
161
357
  val b = bridge ?: return Promise.resolved(initialCamera)
162
358
  return Promise.resolved(CameraState(
@@ -171,13 +367,25 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
171
367
  }
172
368
 
173
369
  override fun setCamera(camera: CameraState) {
174
- initialCamera = camera
175
- pushCameraIfChanged(camera)
370
+ // Runtime camera control. Does NOT mutate `initialCamera` (which is
371
+ // construction-time only).
372
+ pendingRuntimeCamera = camera
373
+ pendingRuntimeViewCorrection = null
374
+ postToRender {
375
+ pushCameraIfChanged(camera)
376
+ bridge?.markNeedsRender()
377
+ }
378
+ scheduleNextFrame(immediate = true)
176
379
  }
177
380
 
178
381
  override fun setCameraQuaternion(camera: CameraState, viewCorrection: Quaternion) {
179
- initialCamera = camera
180
- pushCameraQuaternionIfChanged(camera, viewCorrection)
382
+ pendingRuntimeCamera = camera
383
+ pendingRuntimeViewCorrection = viewCorrection
384
+ postToRender {
385
+ pushCameraQuaternionIfChanged(camera, viewCorrection)
386
+ bridge?.markNeedsRender()
387
+ }
388
+ scheduleNextFrame(immediate = true)
181
389
  }
182
390
 
183
391
  override fun getViewCorrection(): Promise<Quaternion> {
@@ -248,16 +456,45 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
248
456
  b.setVerticalFovDeg(camera.verticalFovDeg)
249
457
  }
250
458
 
251
- private fun syncPropsTobridge() {
252
- bridge?.setMaxSSE(maximumScreenSpaceError)
253
- bridge?.setMaxSimLoads(maximumSimultaneousTileLoads.toInt())
254
- bridge?.setLoadDescLim(loadingDescendantLimit.toInt())
459
+ /// Run on the render thread immediately after the bridge is created, before
460
+ /// the first frame. Matches Swift's syncBridgeOptionsFromProps and pushes
461
+ /// every prop into the freshly-built CesiumEngine so the consumer doesn't
462
+ /// have to re-set them after the surface comes up.
463
+ private fun syncBridgePropsOnRenderThread(b: CesiumBridgeJNI) {
464
+ b.setMaxSSE(maximumScreenSpaceError)
465
+ b.setMaxSimLoads(maximumSimultaneousTileLoads.toInt())
466
+ b.setLoadDescLim(loadingDescendantLimit.toInt())
467
+ maximumCachedMiB?.let { b.setMaximumCachedMiB(it.toInt()) }
468
+ preloadAncestors?.let { b.setPreloadAncestors(it) }
469
+ preloadSiblings?.let { b.setPreloadSiblings(it) }
470
+ forbidHoles?.let { b.setForbidHoles(it) }
471
+ enableWaterMask?.let { b.setEnableWaterMask(it) }
472
+ enableFogCulling?.let { b.setEnableFogCulling(it) }
473
+ enforceCulledScreenSpaceError?.let { b.setEnforceCulledScreenSpaceError(it) }
474
+ culledScreenSpaceError?.let { b.setCulledScreenSpaceError(it) }
475
+ enableLodTransitionPeriod?.let { b.setEnableLodTransitionPeriod(it) }
476
+ lodTransitionLength?.let { b.setLodTransitionLength(it) }
477
+ sqliteCacheMaxRows?.let { b.setSqliteCacheMaxRows(it.toInt()) }
478
+ taskProcessorThreads?.let { b.setTaskProcessorThreads(it.toInt()) }
255
479
  }
256
480
 
257
- private fun scheduleNextFrame() {
481
+ /**
482
+ * Schedule the next frame callback.
483
+ *
484
+ * - `immediate = true`: the engine wants to render this vsync (active pan,
485
+ * tile loading, prop change). Re-post on the next available vsync.
486
+ * - `immediate = false`: the engine is idle. Re-post in 250ms — matches the
487
+ * iOS low-refresh range and keeps the main thread mostly asleep while
488
+ * still picking up late tile completions / network responses.
489
+ */
490
+ private fun scheduleNextFrame(immediate: Boolean) {
258
491
  if (renderLoopActive || pauseRendering || bridge == null) return
259
492
  renderLoopActive = true
260
- Choreographer.getInstance().postFrameCallback(frameCallback)
493
+ if (immediate) {
494
+ Choreographer.getInstance().postFrameCallback(frameCallback)
495
+ } else {
496
+ Choreographer.getInstance().postFrameCallbackDelayed(frameCallback, 250L)
497
+ }
261
498
  }
262
499
 
263
500
  private fun extractCacert(): String {
@@ -0,0 +1,99 @@
1
+ #include "CameraSmoother.hpp"
2
+
3
+ #include "EngineTunables.hpp"
4
+
5
+ #include <algorithm>
6
+ #include <cmath>
7
+
8
+ namespace reactnativecesium {
9
+
10
+ namespace {
11
+
12
+ inline double lerpAngleDeg(double a, double b, double t) {
13
+ // Wraps difference to [-180, 180] before applying the mix factor so we
14
+ // always rotate the short way around the unit circle.
15
+ double diff = std::fmod(b - a + 540.0, 360.0) - 180.0;
16
+ return a + diff * t;
17
+ }
18
+
19
+ inline double angleDeltaAbsDeg(double a, double b) {
20
+ return std::abs(std::fmod(b - a + 540.0, 360.0) - 180.0);
21
+ }
22
+
23
+ inline double quatDotAbs(const glm::dquat& a, const glm::dquat& b) {
24
+ return std::abs(glm::dot(glm::normalize(a), glm::normalize(b)));
25
+ }
26
+
27
+ inline double wrapLonDeltaDeg(double a, double b) {
28
+ return std::fmod(b - a + 540.0, 360.0) - 180.0;
29
+ }
30
+
31
+ } // namespace
32
+
33
+ CameraParams CameraSmoother::step(
34
+ const CameraParams& current, const CameraParams& target, double dt,
35
+ double recentIntervalSec) {
36
+ CameraParams next = current;
37
+
38
+ // ── lat/lon: adaptive exponential smoothing ─────────────────────────────
39
+ // τ scales with the cadence of incoming setCamera calls so gestures feel
40
+ // instant (τ at the floor) while sparse GPS-rate updates ease across each
41
+ // interval (τ near the ceiling).
42
+ const double tau = std::clamp(
43
+ tunables::kSmoothPositionAlpha * recentIntervalSec,
44
+ tunables::kSmoothPositionTauMin,
45
+ tunables::kSmoothPositionTauMax);
46
+ const double aPos = 1.0 - std::exp(-dt / tau);
47
+
48
+ const double dLat = target.latitude - current.latitude;
49
+ const double dLon = wrapLonDeltaDeg(current.longitude, target.longitude);
50
+ const bool snapThrough =
51
+ std::abs(dLat) > tunables::kPosSnapThroughDeg ||
52
+ std::abs(dLon) > tunables::kPosSnapThroughDeg;
53
+
54
+ if (snapThrough) {
55
+ next.latitude = target.latitude;
56
+ next.longitude = target.longitude;
57
+ } else {
58
+ next.latitude = current.latitude + aPos * dLat;
59
+ next.longitude = lerpAngleDeg(current.longitude, target.longitude, aPos);
60
+ }
61
+
62
+ const double aAlt = 1.0 - std::exp(-tunables::kSmoothAltitude * dt);
63
+ const double aHdg = 1.0 - std::exp(-tunables::kSmoothHeading * dt);
64
+ const double aPitch = 1.0 - std::exp(-tunables::kSmoothPitch * dt);
65
+ const double aRoll = 1.0 - std::exp(-tunables::kSmoothRoll * dt);
66
+ const double aViewQ = 1.0 - std::exp(-tunables::kSmoothViewCorr * dt);
67
+
68
+ next.altitude = current.altitude + aAlt * (target.altitude - current.altitude);
69
+ next.heading = lerpAngleDeg(current.heading, target.heading, aHdg);
70
+ next.pitch = lerpAngleDeg(current.pitch, target.pitch, aPitch);
71
+ next.roll = lerpAngleDeg(current.roll, target.roll, aRoll);
72
+
73
+ glm::dquat cq = glm::normalize(current.viewCorrection);
74
+ glm::dquat tq = glm::normalize(target.viewCorrection);
75
+ if (glm::dot(cq, tq) < 0.0) tq = -tq;
76
+ next.viewCorrection = glm::normalize(glm::slerp(cq, tq, aViewQ));
77
+
78
+ // Eventually-exact convergence: snap once the residual is sub-epsilon.
79
+ if (std::abs(target.latitude - next.latitude) <= tunables::kEpsLatLon)
80
+ next.latitude = target.latitude;
81
+ if (std::abs(wrapLonDeltaDeg(next.longitude, target.longitude))
82
+ <= tunables::kEpsLatLon)
83
+ next.longitude = target.longitude;
84
+ if (std::abs(target.altitude - next.altitude) <= tunables::kEpsAltitudeMeters)
85
+ next.altitude = target.altitude;
86
+ if (angleDeltaAbsDeg(next.heading, target.heading) <= tunables::kEpsAngleDeg)
87
+ next.heading = target.heading;
88
+ if (angleDeltaAbsDeg(next.pitch, target.pitch) <= tunables::kEpsAngleDeg)
89
+ next.pitch = target.pitch;
90
+ if (angleDeltaAbsDeg(next.roll, target.roll) <= tunables::kEpsAngleDeg)
91
+ next.roll = target.roll;
92
+ if ((1.0 - quatDotAbs(next.viewCorrection, target.viewCorrection))
93
+ <= tunables::kEpsQuatDot)
94
+ next.viewCorrection = target.viewCorrection;
95
+
96
+ return next;
97
+ }
98
+
99
+ } // namespace reactnativecesium
@@ -0,0 +1,29 @@
1
+ #pragma once
2
+
3
+ #include "GlobeCamera.hpp"
4
+
5
+ namespace reactnativecesium {
6
+
7
+ // Stateless helper. Given a current camera state and a target camera state,
8
+ // returns the next camera state for a frame of duration `dt` seconds.
9
+ //
10
+ // Per-DoF smoothing rules:
11
+ // - lat / lon: adaptive exponential smoothing `1 - exp(-dt/τ)` where
12
+ // τ = clamp(α · recentIntervalSec, τmin, τmax). High update rates (60 Hz
13
+ // gestures) drive τ to the floor so the camera tracks the finger without
14
+ // perceptible lag; sparse updates (1 Hz GPS) widen τ so each sample
15
+ // interpolates smoothly across its interval. Snap-through guard for
16
+ // genuine teleports keeps cross-planet `setCamera` jumps instant.
17
+ // - altitude / heading / pitch / roll / viewCorrection: exponential decay
18
+ // `1 - exp(-k * dt)` per DoF with k taken from EngineTunables.
19
+ // - When the delta drops below the matching epsilon, the value is snapped
20
+ // to the target so we eventually reach exact equality.
21
+ class CameraSmoother {
22
+ public:
23
+ static CameraParams step(const CameraParams& current,
24
+ const CameraParams& target,
25
+ double dt,
26
+ double recentIntervalSec);
27
+ };
28
+
29
+ } // namespace reactnativecesium
@@ -0,0 +1,49 @@
1
+ #include "CameraTargetState.hpp"
2
+
3
+ #include <cmath>
4
+
5
+ namespace reactnativecesium {
6
+
7
+ namespace {
8
+ inline double angleDeltaAbsDeg(double a, double b) {
9
+ return std::abs(std::fmod(b - a + 540.0, 360.0) - 180.0);
10
+ }
11
+ inline double quatDotAbs(const glm::dquat& a, const glm::dquat& b) {
12
+ return std::abs(glm::dot(glm::normalize(a), glm::normalize(b)));
13
+ }
14
+ } // namespace
15
+
16
+ bool CameraTargetState::deltaExceedsEpsilon(
17
+ const CameraParams& a, const CameraParams& b) {
18
+ return std::abs(a.latitude - b.latitude) > tunables::kEpsLatLon
19
+ || std::abs(a.longitude - b.longitude) > tunables::kEpsLatLon
20
+ || std::abs(a.altitude - b.altitude) > tunables::kEpsAltitudeMeters
21
+ || angleDeltaAbsDeg(a.heading, b.heading) > tunables::kEpsAngleDeg
22
+ || angleDeltaAbsDeg(a.pitch, b.pitch) > tunables::kEpsAngleDeg
23
+ || angleDeltaAbsDeg(a.roll, b.roll) > tunables::kEpsAngleDeg
24
+ || (1.0 - quatDotAbs(a.viewCorrection, b.viewCorrection))
25
+ > tunables::kEpsQuatDot;
26
+ }
27
+
28
+ void CameraTargetState::noteUpdateCadence() noexcept {
29
+ const auto now = std::chrono::steady_clock::now();
30
+ if (!hasLastUpdate_) {
31
+ // Seed the EWMA with τmax so the first sample after a quiet period uses
32
+ // the full smoothing budget rather than the floor (5 ms ≈ snap).
33
+ ewmaIntervalSec_.store(tunables::kSmoothPositionTauMax / tunables::kSmoothPositionAlpha,
34
+ std::memory_order_release);
35
+ lastUpdateTime_ = now;
36
+ hasLastUpdate_ = true;
37
+ return;
38
+ }
39
+
40
+ const double dt = std::chrono::duration<double>(now - lastUpdateTime_).count();
41
+ lastUpdateTime_ = now;
42
+ if (dt <= 0.0) return; // back-to-back writes from the same frame: ignore.
43
+
44
+ const double prev = ewmaIntervalSec_.load(std::memory_order_relaxed);
45
+ const double next = prev + tunables::kEwmaIntervalAlpha * (dt - prev);
46
+ ewmaIntervalSec_.store(next, std::memory_order_release);
47
+ }
48
+
49
+ } // namespace reactnativecesium