Research: WebGL2 splat viewer performance inside iOS WKWebView #3

Closed
opened 2026-08-11 15:41:37 +00:00 by lars · 5 comments
Owner

Question

Which WebGL2 gaussian-splat viewer should run inside react-native-webview on iOS, and what is its performance ceiling?

Answer specifically:

  • Candidate viewers (@mkkellogg/GaussianSplats3D, gsplat.js, Spark, PlayCanvas, others) — maturity, licence, API for setting an external camera pose.
  • Measured or reported frame rate for a room-scale splat in WKWebView specifically, not Safari — and whether WebGPU is available in embedded WebViews on current iOS.
  • Memory limits: WKWebView is a separate process with its own budget. At what splat size does it get killed?
  • Splat compression formats (.ksplat, .splat, SOG, SPZ) — size vs quality vs load time, and which viewers read which.
  • The RN <-> WebView pose channel: what round-trip latency injectJavaScript + postMessage realistically achieves, and known techniques for keeping it low.

Part of the wayfinder map #1.

## Question Which WebGL2 gaussian-splat viewer should run inside `react-native-webview` on iOS, and what is its performance ceiling? Answer specifically: - Candidate viewers (`@mkkellogg/GaussianSplats3D`, `gsplat.js`, Spark, PlayCanvas, others) — maturity, licence, API for setting an external camera pose. - Measured or reported frame rate for a room-scale splat in **WKWebView** specifically, not Safari — and whether WebGPU is available in embedded WebViews on current iOS. - Memory limits: WKWebView is a separate process with its own budget. At what splat size does it get killed? - Splat compression formats (`.ksplat`, `.splat`, SOG, SPZ) — size vs quality vs load time, and which viewers read which. - The RN <-> WebView pose channel: what round-trip latency `injectJavaScript` + `postMessage` realistically achieves, and known techniques for keeping it low. --- Part of the wayfinder map #1.
lars added this to the Wayfinder: RN prototyping spec milestone 2026-08-11 15:41:37 +00:00
lars added the wayfinder:research label 2026-08-11 15:41:37 +00:00
lars added a new dependency 2026-08-11 15:42:13 +00:00
lars added a new dependency 2026-08-11 15:42:13 +00:00
lars added a new dependency 2026-08-11 15:42:14 +00:00
Author
Owner

Answer

Research date: 2026-08-11. All version/date claims are as-of that date. Each claim is tagged [measured], [vendor claim] or [inference].

Honesty note up front: I could not find a single published frame-rate measurement for a gaussian splat scene inside WKWebView in a third-party app. Every splat FPS number below is vendor guidance or a Safari/desktop number. The WKWebView-specific facts I could pin down are about the rAF rate cap, WebGPU gating, and the WebContent process memory budget — and those turn out to be the decisive ones anyway. Treat the FPS gate as unvalidated until measured on-device; the plan's "add an FPS counter and a latency probe from minute one" is the right instinct and this research does not replace it.


1. Candidate viewers

Viewer Version / last push Licence Backend Stars / open issues External-camera API
Spark (@sparkjsdev/spark, World Labs) 2.1.0; pushed 2026-07-15 MIT WebGL2 only 3,484 / 132 Plain three.js camera; controls are an opt-in class
@mkkellogg/GaussianSplats3D pushed 2025-10-19 MIT WebGL2 (three.js) 2,855 / 97 useBuiltInControls: false, pass own camera, selfDrivenMode: false + manual update()/render()
gsplat.js (Hugging Face) pushed 2026-05-26 MIT WebGL2 1,652 / 42 Own Camera object; controls opt-in
PlayCanvas engine + SuperSplat pushed 2026-08-08 MIT WebGL2 and WebGPU 16,462 / 539 Camera entity — but a whole-engine buy-in
Babylon.js GaussianSplattingMesh in-tree, v8+ Apache-2.0 WebGL2 / WebGPU Standard Babylon camera
antimatter15/splat reference impl. MIT WebGL2 Hand-rolled; useful as a memory baseline, not a product

Sources: Spark repo · Spark README · GaussianSplats3D · gsplat.js · playcanvas/engine · Babylon GaussianSplattingMesh

Licence risk: none of the shortlist carries the INRIA research-only clause. All runtimes are MIT (Babylon Apache-2.0) [measured — GitHub licence metadata]. The non-commercial 3DGS licence problem lives in the training/reconstruction side (original INRIA gaussian-splatting code), not in these viewers. That risk belongs to whatever tool captures the ship — flag it for the capture-pipeline decision, not this ticket.

On the external-pose requirement specifically, I read Spark's actual source rather than trusting the README. Spark ships SparkControls in controls.ts, but it is fully opt-in: you construct it yourself (new SparkControls({ canvas })) and you must call controls.update(control, camera) from your own loop. If you never construct it, nothing in Spark touches your camera — the camera is an ordinary THREE.Camera you own outright [measured — read from Spark source: export class SparkControls with explicit constructor + caller-driven update()]. So "drive the camera from a positioning system" is the default path, not something you switch off.

@mkkellogg/GaussianSplats3D reaches the same place but you must actively disable three separate built-in behaviours: useBuiltInControls: false, supply your own camera, and selfDrivenMode: false so you call update()/render() on your own rAF loop [measured — README options]. Both workable; Spark has less to fight.


2. Frame rate in WKWebView, and the WebGPU question

WebGPU is NOT safely available in embedded WebViews, and the reason is structural, not a version bump. WebGPU shipped in Safari 26 beta for macOS/iOS/iPadOS/visionOS (WebKit, WWDC25, June 2025) — but that announcement says nothing about WKWebView [measured — I fetched it and checked]. The decisive statement is an Apple engineer on the developer forums: "these feature flags only impact Safari and not WebKit generally. For WKWebView, the feature will work when its enabled by default" (Apple Developer Forums 770862, reply June 2025) [measured — first-party]. Same thread: "Can you please try on the iOS 26 beta? WebGPU is enabled by default there."

So on iOS 26+ WKWebView plausibly does get WebGPU, since it is on by default at OS level. But that sets a floor of iOS 26 for a feature we would then depend on, across passenger-owned phones we do not control. Verdict: target WebGL2. WebGPU is an optimisation to re-test later, never a dependency. This confirms the assumption already written into prototyping-session.md — that guess was correct, and now it has a first-party citation.

WKWebView is capped at 60Hz rAF where Safari is not. In WKWebView requestAnimationFrame is throttled to 60Hz even on ProMotion hardware, and the Safari flags that unlock higher rates explicitly do not apply to WKWebView (Apple Developer Forums 773222; bugs.webkit.org 272165 — filed 2024-04, RESOLVED FIXED 2024-05 for Safari, with a 120Hz regression re-reported on iPhone 16 Pro 2024-09) [measured].

For this project the 60Hz cap is good news and it moves the arithmetic in our favour: a 30fps gate needs only every second frame, and the frame budget is a fixed 16.7ms rather than 8.3ms. We are not fighting for 120fps. The gate to worry about is not FPS — it is the 100ms pose-to-photon budget (§5).

Splat budgets (vendor guidance; desktop/mobile Safari, NOT WKWebView-measured): Spark's performance doc recommends, at "60+ fps" — iPhone: 1–3 million splats; Android phone 1–2M; Quest 3 ≤1M; desktop 1–5M (10–20M+ on some) (sparkjs.dev/docs/performance) [vendor claim]. Knobs it names: maxStdDev (default Math.sqrt(8)≈2.8; Math.sqrt(5) is "perceptually very similar" and cheaper), antialias: false, and setPixelRatio [measured — from docs]. That last is the biggest free lever on a Retina phone: splat rasterisation is fill-rate bound, so rendering at devicePixelRatio 1.5 instead of 3 is close to a 4x fragment saving [inference].

A room-scale corridor splat sits comfortably inside 1–3M gaussians, so a single room is not the risk. Ship-scale is — and that is what Spark 2.0 exists for (§4).

Babylon's optimisation notes give the one concrete before/after: static textures for gaussian data took "complex scenes" from ~15 FPS to 60 FPS, and VRAM per splat from 64 bytes to 32 (Babylon GS updates) [vendor claim] — the per-gaussian anchor used in §3.


3. WKWebView memory and jetsam — the real ceiling

WKWebView renders in a separate com.apple.WebKit.WebContent process with its own jetsam budget; exceed it and the process is killed, the app seeing a blank WebView plus webViewWebContentProcessDidTerminate (RN surfaces this as a silently blank WebView / onContentProcessDidTerminate) [measured — Apple forums]. Developers report exactly this on large WebGL scenes: "getting the webViewWebContentProcessDidTerminate callback and unable to load their scene… sometimes it does, sometimes it does not" (Apple forums 678318). Note the nondeterminism — the signature of sitting near the limit rather than over it.

Numbers, weakest to strongest:

  • A memorystatus jetsam kill of WebContent at an ActiveHard 2048 MB limit on iPhone despite 12 GB physical RAM with free pages available; iPad gets a more generous budget [third-party report, iOS 26.4 era]. Related first-party thread: WebContent/GPU process crash on iPhone 15 / iOS 26.4 but stable on iPad, Apple's only reply being "file a Feedback Assistant issue" (Apple forums 822200, Apr 2026) — the iPhone-vs-iPad asymmetry reproduces first-party even though the number does not.
  • Practical per-device heap ceilings, ~2026-01: iPhone 11/12 ~350–400MB, iPhone 13/14 ~400–450MB, iPhone 15+ ~1GB+, from baseThreshold = min(3 GB, min(physical_RAM, jetsam_limit)), "typically 300–450MB for most devices currently in use" (Catch Metrics, 2026-01-05) [third-party measurement]. This source does not separate WKWebView from Safari — a real gap.
  • The limit is not a fixed constant and cannot be raised by the app; it depends on device RAM and system load [measured — Apple forums]. On a ship the app competes with whatever else the passenger has open, so assume the low end.

Deriving a gaussian ceiling. Take Babylon's 32 bytes/gaussian VRAM: 3M gaussians ≈ 96MB of GPU-side texture — trivially fine. GPU storage is not the binding constraint; CPU-side load and sort scratch is. The evidence: @mkkellogg/GaussianSplats3D was measured at 1.7GB for a single .splat file where antimatter15/splat used 770MB for the identical file in the identical scene (issue #314) [measured — user report]. Its optimizeSplatData (default true) buys run-time speed at the cost of longer loads and "increased memory overhead during scene load" unless progressively loading [measured — docs].

The honest engineering rule [inference, from the above]:

Budget ≤300–400MB total inside the WebContent process to be safe on the low end of a passenger fleet. At Spark's texture density that is roughly 1–3M gaussians for a room/deck-section, and the load-time transient — not steady state — is what kills you. Stream; never load a whole deck at once. A viewer whose peak is 2.2x another's for the same asset is spending its margin in exactly the wrong place.

This reframes the ship-scale question: the ceiling is not "how many gaussians can we draw" but "how few can we hold resident." That makes LoD streaming an architectural requirement, not a later optimisation.


4. Compression formats

Format Owner / licence Size vs PLY Notes Read by
.ply (INRIA 3DGS) de-facto standard 1x Uncompressed, full precision, universal. Archival/training only everything
.splat antimatter15 ~4x smaller Simple, widely supported, no SH most
.ksplat mkkellogg tiered (0/1/2) L1 = 32→16-bit; L2 adds 8-bit SH. "Fastest loading times since its format matches the internal format" mkkellogg, Spark
SOG (Spatially Ordered Gaussians) PlayCanvas, open-sourced 15–20x smaller ~130 bits ≈ 16.25 bytes/gaussian without SH-AC (pos 3x16b, quat 26b, scale 3x8b codebook, colour+alpha 32b). Lossy by design PlayCanvas, Spark, SuperSplat
Streamed SOG PlayCanvas as SOG SOG chunks in a spatial tree with LoD levels; progressive on-demand PlayCanvas
SPZ Niantic / Scaniverse, MIT ~10x smaller pushed 2026-08-05; 903 stars PlayCanvas, Spark, Babylon (incl. SPZ V4 + WASM loader)
.RAD World Labs (Spark 2.0) gzip, column-oriented JSON header + 64K-splat chunks, random-access streaming Spark
GLB + Khronos ext. Khronos 1x Standards path, uncompressed PlayCanvas

Sources: PlayCanvas formats · SOG spec · nianticlabs/spz · Spark README · Spark 2.0 / World Labs

Concrete data point: a 4M-gaussian scene, 1GB as PLY → 42MB as SOG (~95% reduction) (PlayCanvas blog) [vendor claim]. Neither PlayCanvas nor Niantic publish a PSNR delta — quality loss is asserted, not quantified, by both. Verify visually against the real corridor before committing.

Spark reads .ply (incl. compressed), .spz, .splat, .ksplat and .sog [measured — README] — the only shortlisted three.js viewer reading both credible compressed formats, which removes format lock-in. That matters more than any single ratio: we do not yet know what the ship-capture vendor will hand us.

Spark 2.0 (released 2026-04-14) is the ship-scale answer (World Labs) [vendor claim]: a streamable LoD system holding a 16M-splat GPU page table in 64K-splat chunks with LRU eviction, built explicitly because consumer devices "only render 1-5M splats at interactive frame rates", and explicitly WebGL2 because "WebGPU isn't available on all devices" — the same conclusion as §2, reached independently by the vendor. Marketing claims 100M+ splat worlds streamed "to any device from mobile to VR"; no measured iPhone FPS is published and no iPhone model is named anywhere in the technical post. Do not plan on that number.


5. The RN ↔ WebView pose channel

Mechanics: injectJavaScript(code) for RN→web repeatedly after load; window.ReactNativeWebView.postMessage(string) + onMessage for web→RN. Two hard constraints from the docs [measured]: postMessage accepts exactly one argument and it must be a string, and onMessage must be set or postMessage is never injected at all (RN WebView Guide). The guide gives no performance, frequency or payload guidance — there is no official word on whether per-frame injection is supported use.

Latency — the weakest-sourced part of this ticket: a full round trip (web event → postMessage → RN state update → injectJavaScript back) is described as "tens of milliseconds at minimum, and hundreds on a slow device with a large payload" [third-party estimate, not a benchmark]. react-native-webview supports both Paper and Fabric, and RN's New Architecture has been default since 0.76; JSI direct calls are characterised as sub-millisecond vs 5–10ms for bridge serialisation [third-party claim]. But injectJavaScript is not a JSI fast path into the web JS context — it hands a string of source code to WKWebView to evaluate, so it pays parse+eval every call regardless of architecture [inference]. This repo is RN 0.81.5 / Expo ~54, so Fabric is in play, but I would not bank on it buying pose latency.

Against a 100ms pose-to-photon budget [inference]:

Stage Estimate
Positioning fix → RN JS 10–30ms (positioning system, out of scope here)
RN → WebView via injectJavaScript (parse+eval) ~5–30ms, high variance
WebView rAF wait (60Hz cap, §2) 0–16.7ms, avg ~8
Splat render + compositing 16–33ms
Total ~40–110ms — straddles the gate

The variance, not the mean, is the problem. So the architecture must remove the bridge from the per-frame path:

  1. Move the interpolation/prediction loop inside the WebView. Push sparse poses (positioning rate, e.g. 5–20Hz) and let a WebView-side rAF loop extrapolate position + heading from last pose and velocity. Highest-value technique by far: it decouples render smoothness from bridge latency and turns bridge jitter into a smoothing problem rather than a stutter. Gyro-derived heading especially should be integrated WebView-side.
  2. Never queue poses — always overwrite. One latestPose slot; a newer pose replaces a pending one. Stale poses are worse than no poses.
  3. Throttle sends to at most display rate, and below it if positioning is slower. Do not call injectJavaScript at 60Hz "just in case."
  4. Keep the payload tiny and fixed-shape — 7 floats (position + quaternion) plus a timestamp. Avoid JSON.stringify; inject a bare window.__pose(x,y,z,qx,qy,qz,qw,t) call.
  5. Timestamp at the RN send site and echo it back on render to measure true pose-to-photon rather than guessing. Already in the session plan — keep it.
  6. Fallback if injectJavaScript proves too jittery: a localhost WebSocket from the WebView sidesteps the eval path. More moving parts; only if measurement demands it.

I found no published project doing high-rate sensor→WebView pose streaming with numbers. This is the biggest measurement gap and the cheapest thing to de-risk first — an afternoon and a timestamp echo, no splat required.


Verdict

Use Spark (@sparkjsdev/spark 2.1.0, MIT, WebGL2) on three.js r0.180+, with assets as SOG (PlayCanvas SOG v2) and SPZ (nianticlabs/spz, MIT) accepted interchangeably; .RAD + Spark 2.0 LoD streaming for anything beyond a single room.

Why Spark over the alternatives:

  • WebGL2-only by deliberate design ("98%+ WebGL2 support"), matching the one hard platform constraint in §2 — WebGPU is not dependable in WKWebView below iOS 26.
  • Controls are opt-in and caller-driven (verified in source), so external-pose driving is the default path rather than three opt-outs (§1). Best API fit for the actual requirement.
  • Reads .ply/.spz/.splat/.ksplat/.sog — no format lock-in before the ship-capture vendor is chosen (§4).
  • The only shortlisted three.js viewer with a real answer to ship-scale: LoD streaming with a 16M page table and LRU eviction, which §3 shows is an architectural requirement.
  • MIT, 3.5k stars, pushed 2026-07-15 — the most actively developed splat-specific renderer of the set.

@mkkellogg/GaussianSplats3D is the runner-up and a fine prototype choice (it is what the session plan already names), but its last push was 2025-10-19 and it measured 2.2x another viewer's memory for the same asset — bad in the exact dimension that gets a WebContent process jetsam-killed. PlayCanvas is technically strong with first-class SOG, but adopting a whole engine is a larger commitment than a three.js module while the native-renderer fallback is still live.

Gate status: WebGL2 target CONFIRMED. 30fps and <100ms pose-to-photon are UNVALIDATED — plausible on paper, contingent on the bridge, and not answerable from published sources.

Open / unresolved

  1. No measured splat FPS in WKWebView anywhere. Every number in §2 is vendor guidance or Safari. Must be measured on target hardware — highest-priority unknown for the render gate.
  2. No measured RN↔WebView pose latency. The "tens of ms" figure is a blog estimate, not a benchmark. Cheapest thing to de-risk (timestamp echo, no splat needed) and it gates the 100ms budget — do this first.
  3. WebContent jetsam budget not confirmed first-party. The 2048MB ActiveHard figure is third-party; the ~300–450MB practical range does not separate WKWebView from Safari. The iPhone-vs-iPad asymmetry does reproduce first-party. Needs empirical bisection: raise splat count until the WebView blanks.
  4. No PSNR/quality figures published for SOG or SPZ. Both vendors say "lossy by design" and stop. Needs a visual A/B against the real corridor.
  5. Does the 60Hz WKWebView rAF cap still hold on iOS 26? Sources are 2024–2025. Not a blocker (30fps gate) but it changes headroom arithmetic.
  6. WebGPU in WKWebView on iOS 26+ is probable-by-default but unverified on device. Re-test opportunistically; never depend on it.
  7. react-native-webview messaging reliability on Fabric — open reports of postMessage/onMessage breakage around 13.13.x and "known New Architecture limitations". Pin and smoke-test the exact version rather than tracking latest.
  8. Capture/training-side licence risk is out of scope here but unowned. All shortlisted viewers are MIT/Apache-2.0; the INRIA non-commercial clause attaches to reconstruction tooling. Needs an owner on the ship-capture decision.
  9. Spark's "100M+ splats on mobile" is unverified and names no iPhone model. Do not plan capacity against it.
## Answer Research date: **2026-08-11**. All version/date claims are as-of that date. Each claim is tagged **[measured]**, **[vendor claim]** or **[inference]**. **Honesty note up front:** I could not find a single published frame-rate measurement for a gaussian splat scene inside `WKWebView` in a third-party app. Every splat FPS number below is vendor guidance or a Safari/desktop number. The WKWebView-specific facts I *could* pin down are about the rAF rate cap, WebGPU gating, and the WebContent process memory budget — and those turn out to be the decisive ones anyway. Treat the FPS gate as **unvalidated until measured on-device**; the plan's "add an FPS counter and a latency probe from minute one" is the right instinct and this research does not replace it. --- ### 1. Candidate viewers | Viewer | Version / last push | Licence | Backend | Stars / open issues | External-camera API | |---|---|---|---|---|---| | **Spark** (`@sparkjsdev/spark`, World Labs) | 2.1.0; pushed 2026-07-15 | MIT | **WebGL2 only** | 3,484 / 132 | Plain three.js camera; controls are an **opt-in** class | | `@mkkellogg/GaussianSplats3D` | pushed 2025-10-19 | MIT | WebGL2 (three.js) | 2,855 / 97 | `useBuiltInControls: false`, pass own `camera`, `selfDrivenMode: false` + manual `update()`/`render()` | | `gsplat.js` (Hugging Face) | pushed 2026-05-26 | MIT | WebGL2 | 1,652 / 42 | Own `Camera` object; controls opt-in | | **PlayCanvas engine** + SuperSplat | pushed 2026-08-08 | MIT | WebGL2 **and** WebGPU | 16,462 / 539 | Camera entity — but a whole-engine buy-in | | **Babylon.js** `GaussianSplattingMesh` | in-tree, v8+ | Apache-2.0 | WebGL2 / WebGPU | — | Standard Babylon camera | | `antimatter15/splat` | reference impl. | MIT | WebGL2 | — | Hand-rolled; useful as a memory baseline, not a product | Sources: [Spark repo](https://github.com/sparkjsdev/spark) · [Spark README](https://raw.githubusercontent.com/sparkjsdev/spark/main/README.md) · [GaussianSplats3D](https://github.com/mkkellogg/GaussianSplats3D) · [gsplat.js](https://github.com/huggingface/gsplat.js) · [playcanvas/engine](https://github.com/playcanvas/engine) · [Babylon GaussianSplattingMesh](https://github.com/BabylonJS/Babylon.js/blob/master/packages/dev/core/src/Meshes/GaussianSplatting/gaussianSplattingMesh.ts) **Licence risk: none of the shortlist carries the INRIA research-only clause.** All runtimes are MIT (Babylon Apache-2.0) **[measured — GitHub licence metadata]**. The non-commercial 3DGS licence problem lives in the *training/reconstruction* side (original INRIA `gaussian-splatting` code), not in these viewers. That risk belongs to whatever tool captures the ship — flag it for the capture-pipeline decision, not this ticket. **On the external-pose requirement specifically**, I read Spark's actual source rather than trusting the README. Spark ships `SparkControls` in `controls.ts`, but it is **fully opt-in**: you construct it yourself (`new SparkControls({ canvas })`) *and* you must call `controls.update(control, camera)` from your own loop. If you never construct it, nothing in Spark touches your camera — the camera is an ordinary `THREE.Camera` you own outright **[measured — read from Spark source: `export class SparkControls` with explicit constructor + caller-driven `update()`]**. So "drive the camera from a positioning system" is the *default* path, not something you switch off. `@mkkellogg/GaussianSplats3D` reaches the same place but you must actively disable three separate built-in behaviours: `useBuiltInControls: false`, supply your own `camera`, and `selfDrivenMode: false` so you call `update()`/`render()` on your own rAF loop **[measured — README options]**. Both workable; Spark has less to fight. --- ### 2. Frame rate in WKWebView, and the WebGPU question **WebGPU is NOT safely available in embedded WebViews, and the reason is structural, not a version bump.** WebGPU shipped in Safari 26 beta for macOS/iOS/iPadOS/visionOS ([WebKit, WWDC25, June 2025](https://webkit.org/blog/16993/news-from-wwdc25-webkit-in-safari-26-beta/)) — but that announcement says nothing about WKWebView **[measured — I fetched it and checked]**. The decisive statement is an Apple engineer on the developer forums: *"these feature flags only impact Safari and not WebKit generally. For WKWebView, the feature will work when its enabled by default"* ([Apple Developer Forums 770862](https://developer.apple.com/forums/thread/770862), reply June 2025) **[measured — first-party]**. Same thread: *"Can you please try on the iOS 26 beta? WebGPU is enabled by default there."* So on iOS 26+ WKWebView plausibly *does* get WebGPU, since it is on by default at OS level. But that sets a floor of iOS 26 for a feature we would then depend on, across passenger-owned phones we do not control. **Verdict: target WebGL2. WebGPU is an optimisation to re-test later, never a dependency.** This confirms the assumption already written into `prototyping-session.md` — that guess was correct, and now it has a first-party citation. **WKWebView is capped at 60Hz rAF where Safari is not.** In WKWebView `requestAnimationFrame` is throttled to 60Hz even on ProMotion hardware, and the Safari flags that unlock higher rates explicitly do not apply to WKWebView ([Apple Developer Forums 773222](https://developer.apple.com/forums/thread/773222); [bugs.webkit.org 272165](https://bugs.webkit.org/show_bug.cgi?id=272165) — filed 2024-04, RESOLVED FIXED 2024-05 for *Safari*, with a 120Hz regression re-reported on iPhone 16 Pro 2024-09) **[measured]**. For this project the 60Hz cap is **good news and it moves the arithmetic in our favour**: a 30fps gate needs only every second frame, and the frame budget is a fixed 16.7ms rather than 8.3ms. We are not fighting for 120fps. The gate to worry about is not FPS — it is the 100ms pose-to-photon budget (§5). **Splat budgets (vendor guidance; desktop/mobile Safari, NOT WKWebView-measured):** Spark's performance doc recommends, at "60+ fps" — **iPhone: 1–3 million splats**; Android phone 1–2M; Quest 3 ≤1M; desktop 1–5M (10–20M+ on some) ([sparkjs.dev/docs/performance](https://sparkjs.dev/docs/performance/)) **[vendor claim]**. Knobs it names: `maxStdDev` (default `Math.sqrt(8)`≈2.8; `Math.sqrt(5)` is "perceptually very similar" and cheaper), `antialias: false`, and `setPixelRatio` **[measured — from docs]**. That last is the biggest free lever on a Retina phone: splat rasterisation is fill-rate bound, so rendering at devicePixelRatio 1.5 instead of 3 is close to a 4x fragment saving **[inference]**. A room-scale corridor splat sits comfortably inside 1–3M gaussians, so **a single room is not the risk. Ship-scale is** — and that is what Spark 2.0 exists for (§4). Babylon's optimisation notes give the one concrete before/after: static textures for gaussian data took "complex scenes" from ~15 FPS to 60 FPS, and VRAM per splat from **64 bytes to 32** ([Babylon GS updates](https://github.com/BabylonJS/Babylon.js/issues/16671)) **[vendor claim]** — the per-gaussian anchor used in §3. --- ### 3. WKWebView memory and jetsam — the real ceiling WKWebView renders in a separate `com.apple.WebKit.WebContent` process with its own jetsam budget; exceed it and the process is killed, the app seeing a blank WebView plus `webViewWebContentProcessDidTerminate` (RN surfaces this as a silently blank WebView / `onContentProcessDidTerminate`) **[measured — Apple forums]**. Developers report exactly this on large WebGL scenes: *"getting the webViewWebContentProcessDidTerminate callback and unable to load their scene… sometimes it does, sometimes it does not"* ([Apple forums 678318](https://developer.apple.com/forums/thread/678318)). Note the **nondeterminism** — the signature of sitting *near* the limit rather than over it. Numbers, weakest to strongest: - A `memorystatus` jetsam kill of WebContent at an **ActiveHard 2048 MB** limit on iPhone despite 12 GB physical RAM with free pages available; iPad gets a more generous budget **[third-party report, iOS 26.4 era]**. Related first-party thread: WebContent/GPU process crash on iPhone 15 / iOS 26.4 but stable on iPad, Apple's only reply being "file a Feedback Assistant issue" ([Apple forums 822200](https://developer.apple.com/forums/thread/822200), Apr 2026) — **the iPhone-vs-iPad asymmetry reproduces first-party even though the number does not.** - Practical per-device heap ceilings, ~2026-01: iPhone 11/12 ~350–400MB, iPhone 13/14 ~400–450MB, **iPhone 15+ ~1GB+**, from `baseThreshold = min(3 GB, min(physical_RAM, jetsam_limit))`, "typically 300–450MB for most devices currently in use" ([Catch Metrics, 2026-01-05](https://www.catchmetrics.io/blog/deep-dive-ram-internals-webkit)) **[third-party measurement]**. This source does **not** separate WKWebView from Safari — a real gap. - The limit is not a fixed constant and **cannot be raised by the app**; it depends on device RAM and system load **[measured — Apple forums]**. On a ship the app competes with whatever else the passenger has open, so assume the low end. **Deriving a gaussian ceiling.** Take Babylon's 32 bytes/gaussian VRAM: 3M gaussians ≈ 96MB of GPU-side texture — trivially fine. **GPU storage is not the binding constraint; CPU-side load and sort scratch is.** The evidence: `@mkkellogg/GaussianSplats3D` was measured at **1.7GB** for a single `.splat` file where `antimatter15/splat` used **770MB** for the identical file in the identical scene ([issue #314](https://github.com/mkkellogg/GaussianSplats3D/issues/314)) **[measured — user report]**. Its `optimizeSplatData` (default `true`) buys run-time speed at the cost of longer loads and "increased memory overhead during scene load" unless progressively loading **[measured — docs]**. The honest engineering rule **[inference, from the above]**: > Budget **≤300–400MB total** inside the WebContent process to be safe on the low end of a passenger fleet. At Spark's texture density that is roughly **1–3M gaussians for a room/deck-section**, and **the load-time transient — not steady state — is what kills you**. Stream; never load a whole deck at once. A viewer whose peak is 2.2x another's for the same asset is spending its margin in exactly the wrong place. **This reframes the ship-scale question:** the ceiling is not "how many gaussians can we draw" but "how few can we hold resident." That makes LoD streaming an architectural requirement, not a later optimisation. --- ### 4. Compression formats | Format | Owner / licence | Size vs PLY | Notes | Read by | |---|---|---|---|---| | **`.ply`** (INRIA 3DGS) | de-facto standard | 1x | Uncompressed, full precision, universal. Archival/training only | everything | | `.splat` | antimatter15 | ~4x smaller | Simple, widely supported, no SH | most | | `.ksplat` | mkkellogg | tiered (0/1/2) | L1 = 32→16-bit; L2 adds 8-bit SH. **"Fastest loading times since its format matches the internal format"** | mkkellogg, Spark | | **SOG** (Spatially Ordered Gaussians) | PlayCanvas, open-sourced | **15–20x smaller** | ~130 bits ≈ **16.25 bytes/gaussian** without SH-AC (pos 3x16b, quat 26b, scale 3x8b codebook, colour+alpha 32b). Lossy by design | PlayCanvas, Spark, SuperSplat | | **Streamed SOG** | PlayCanvas | as SOG | SOG chunks in a spatial tree with LoD levels; progressive on-demand | PlayCanvas | | **SPZ** | Niantic / Scaniverse, **MIT** | ~10x smaller | pushed 2026-08-05; 903 stars | PlayCanvas, Spark, Babylon (incl. SPZ V4 + WASM loader) | | `.RAD` | World Labs (Spark 2.0) | gzip, column-oriented | JSON header + 64K-splat chunks, **random-access streaming** | Spark | | GLB + Khronos ext. | Khronos | 1x | Standards path, uncompressed | PlayCanvas | Sources: [PlayCanvas formats](https://developer.playcanvas.com/user-manual/gaussian-splatting/formats/) · [SOG spec](https://developer.playcanvas.com/user-manual/gaussian-splatting/formats/sog/) · [nianticlabs/spz](https://github.com/nianticlabs/spz) · [Spark README](https://raw.githubusercontent.com/sparkjsdev/spark/main/README.md) · [Spark 2.0 / World Labs](https://www.worldlabs.ai/blog/spark-2.0) Concrete data point: a **4M-gaussian scene, 1GB as PLY → 42MB as SOG** (~95% reduction) ([PlayCanvas blog](https://blog.playcanvas.com/playcanvas-open-sources-sog-format-for-gaussian-splatting/)) **[vendor claim]**. Neither PlayCanvas nor Niantic publish a PSNR delta — **quality loss is asserted, not quantified, by both.** Verify visually against the real corridor before committing. **Spark reads `.ply` (incl. compressed), `.spz`, `.splat`, `.ksplat` and `.sog`** **[measured — README]** — the only shortlisted three.js viewer reading *both* credible compressed formats, which removes format lock-in. That matters more than any single ratio: we do not yet know what the ship-capture vendor will hand us. **Spark 2.0 (released 2026-04-14) is the ship-scale answer** ([World Labs](https://www.worldlabs.ai/blog/spark-2.0)) **[vendor claim]**: a streamable LoD system holding a **16M-splat GPU page table in 64K-splat chunks with LRU eviction**, built explicitly because consumer devices "only render 1-5M splats at interactive frame rates", and explicitly WebGL2 because *"WebGPU isn't available on all devices"* — the same conclusion as §2, reached independently by the vendor. Marketing claims 100M+ splat worlds streamed "to any device from mobile to VR"; **no measured iPhone FPS is published and no iPhone model is named anywhere in the technical post.** Do not plan on that number. --- ### 5. The RN ↔ WebView pose channel Mechanics: `injectJavaScript(code)` for RN→web repeatedly after load; `window.ReactNativeWebView.postMessage(string)` + `onMessage` for web→RN. Two hard constraints from the docs **[measured]**: `postMessage` **accepts exactly one argument and it must be a string**, and **`onMessage` must be set or `postMessage` is never injected at all** ([RN WebView Guide](https://github.com/react-native-webview/react-native-webview/blob/master/docs/Guide.md)). The guide gives **no** performance, frequency or payload guidance — there is no official word on whether per-frame injection is supported use. Latency — the weakest-sourced part of this ticket: a full round trip (web event → `postMessage` → RN state update → `injectJavaScript` back) is described as **"tens of milliseconds at minimum, and hundreds on a slow device with a large payload"** **[third-party estimate, not a benchmark]**. `react-native-webview` supports both Paper and Fabric, and RN's New Architecture has been default since 0.76; JSI direct calls are characterised as **sub-millisecond vs 5–10ms for bridge serialisation** **[third-party claim]**. But `injectJavaScript` is **not** a JSI fast path into the *web* JS context — it hands a **string of source code to WKWebView to evaluate**, so it pays parse+eval every call regardless of architecture **[inference]**. This repo is RN 0.81.5 / Expo ~54, so Fabric is in play, but I would not bank on it buying pose latency. Against a **100ms pose-to-photon** budget **[inference]**: | Stage | Estimate | |---|---| | Positioning fix → RN JS | 10–30ms (positioning system, out of scope here) | | RN → WebView via `injectJavaScript` (parse+eval) | ~5–30ms, **high variance** | | WebView rAF wait (60Hz cap, §2) | 0–16.7ms, avg ~8 | | Splat render + compositing | 16–33ms | | **Total** | **~40–110ms — straddles the gate** | The variance, not the mean, is the problem. So the architecture must remove the bridge from the per-frame path: 1. **Move the interpolation/prediction loop inside the WebView.** Push *sparse* poses (positioning rate, e.g. 5–20Hz) and let a WebView-side rAF loop extrapolate position + heading from last pose and velocity. Highest-value technique by far: it decouples render smoothness from bridge latency and turns bridge jitter into a smoothing problem rather than a stutter. Gyro-derived heading especially should be integrated WebView-side. 2. **Never queue poses — always overwrite.** One `latestPose` slot; a newer pose replaces a pending one. Stale poses are worse than no poses. 3. **Throttle sends to at most display rate**, and below it if positioning is slower. Do not call `injectJavaScript` at 60Hz "just in case." 4. **Keep the payload tiny and fixed-shape** — 7 floats (position + quaternion) plus a timestamp. Avoid `JSON.stringify`; inject a bare `window.__pose(x,y,z,qx,qy,qz,qw,t)` call. 5. **Timestamp at the RN send site and echo it back on render** to measure true pose-to-photon rather than guessing. Already in the session plan — keep it. 6. **Fallback if `injectJavaScript` proves too jittery:** a localhost WebSocket from the WebView sidesteps the eval path. More moving parts; only if measurement demands it. I found **no** published project doing high-rate sensor→WebView pose streaming with numbers. **This is the biggest measurement gap and the cheapest thing to de-risk first** — an afternoon and a timestamp echo, no splat required. --- ### Verdict **Use Spark (`@sparkjsdev/spark` 2.1.0, MIT, WebGL2) on three.js r0.180+, with assets as SOG (PlayCanvas SOG v2) and SPZ (`nianticlabs/spz`, MIT) accepted interchangeably; `.RAD` + Spark 2.0 LoD streaming for anything beyond a single room.** Why Spark over the alternatives: - **WebGL2-only by deliberate design** ("98%+ WebGL2 support"), matching the one hard platform constraint in §2 — WebGPU is not dependable in WKWebView below iOS 26. - **Controls are opt-in and caller-driven** (verified in source), so external-pose driving is the default path rather than three opt-outs (§1). Best API fit for the actual requirement. - **Reads `.ply`/`.spz`/`.splat`/`.ksplat`/`.sog`** — no format lock-in before the ship-capture vendor is chosen (§4). - **The only shortlisted three.js viewer with a real answer to ship-scale**: LoD streaming with a 16M page table and LRU eviction, which §3 shows is an architectural requirement. - MIT, 3.5k stars, pushed 2026-07-15 — the most actively developed splat-specific renderer of the set. `@mkkellogg/GaussianSplats3D` is the runner-up and a fine *prototype* choice (it is what the session plan already names), but its last push was 2025-10-19 and it measured **2.2x** another viewer's memory for the same asset — bad in the exact dimension that gets a WebContent process jetsam-killed. PlayCanvas is technically strong with first-class SOG, but adopting a whole engine is a larger commitment than a three.js module while the native-renderer fallback is still live. **Gate status: WebGL2 target CONFIRMED. 30fps and <100ms pose-to-photon are UNVALIDATED** — plausible on paper, contingent on the bridge, and not answerable from published sources. ### Open / unresolved 1. **No measured splat FPS in WKWebView anywhere.** Every number in §2 is vendor guidance or Safari. Must be measured on target hardware — highest-priority unknown for the render gate. 2. **No measured RN↔WebView pose latency.** The "tens of ms" figure is a blog estimate, not a benchmark. Cheapest thing to de-risk (timestamp echo, no splat needed) and it gates the 100ms budget — **do this first**. 3. **WebContent jetsam budget not confirmed first-party.** The 2048MB ActiveHard figure is third-party; the ~300–450MB practical range does not separate WKWebView from Safari. The iPhone-vs-iPad asymmetry *does* reproduce first-party. Needs empirical bisection: raise splat count until the WebView blanks. 4. **No PSNR/quality figures published for SOG or SPZ.** Both vendors say "lossy by design" and stop. Needs a visual A/B against the real corridor. 5. **Does the 60Hz WKWebView rAF cap still hold on iOS 26?** Sources are 2024–2025. Not a blocker (30fps gate) but it changes headroom arithmetic. 6. **WebGPU in WKWebView on iOS 26+** is probable-by-default but unverified on device. Re-test opportunistically; never depend on it. 7. **`react-native-webview` messaging reliability on Fabric** — open reports of `postMessage`/`onMessage` breakage around 13.13.x and "known New Architecture limitations". Pin and smoke-test the exact version rather than tracking latest. 8. **Capture/training-side licence risk is out of scope here but unowned.** All shortlisted *viewers* are MIT/Apache-2.0; the INRIA non-commercial clause attaches to reconstruction tooling. Needs an owner on the ship-capture decision. 9. **Spark's "100M+ splats on mobile" is unverified** and names no iPhone model. Do not plan capacity against it.
lars closed this issue 2026-08-11 15:52:01 +00:00
Author
Owner

Addendum — corrections and source-level additions

A deeper source-level pass (reading viewer source, not just READMEs, plus npm registry and default-branch commit dates) landed after the answer above. The verdict does not change — Spark is reinforced. But one published fact was wrong, and there are four additions material enough to affect implementation. Recording both rather than quietly editing.

Correction

  • gsplat.js is effectively abandoned, not "pushed 2026-05-26" as I wrote. The GitHub pushed_at field I relied on reflects a non-default branch. The default-branch HEAD commit is 2025-07-12, matching npm gsplat@1.2.9 published 2025-07-12 — 13 months stale. My table overstated its liveness by ~10 months. It drops from "third option" to "prototype only". Lesson: pushed_at is not a maintenance signal; check default-branch HEAD.

Additions that affect implementation

  1. @mkkellogg/GaussianSplats3D has a WebView-specific footgun I missed. It defaults sharedMemoryForWorkers: true, which requires cross-origin isolation (Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp) or it throws "SharedArrayBuffer transfer requires self.crossOriginIsolated". You generally cannot set those headers on a bundled/file:// page in a WebView — so it needs sharedMemoryForWorkers: false and gpuAcceleratedSort: false (the README pairs them). This is a silent-failure trap for anyone prototyping with the viewer the session plan currently names, and worth knowing before an afternoon is lost to it. Also: explicit iOS branching in Viewer.js disables SIMD sort below iOS 17 and shared memory below iOS 16. Source: README, Viewer.js. Its pinned release is npm 0.4.7, 2025-01-25.

  2. Spark already encodes iOS memory budgets as defaults — it is the only candidate that does. defaultSplatTarget() returns 1,500,000 splats on iOS (vs 2.5M desktop, 1M Android), and maxPagedSplats defaults to 6,291,456 on iOS (vs 16,777,216 desktop). Source: SparkRenderer.ts, spark-renderer docs. Note this contradicts the perf-doc reading in §2 above ("iPhone: 1–3 million splats"): the shipped iOS default is 1.5M, i.e. the conservative end of that range. Take 1.5M as the planning figure, not 3M. Pinned version: npm @sparkjsdev/spark@2.1.0, published 2026-05-18.

  3. Caveat on the recommendation, in Spark's own tracker: open issue #262 (2026-01-20) — "spz, sog, or sogzip cannot be displayed or run on iOS 16.2". That is precisely the Spark + SOG/SPZ combination recommended in the Verdict, failing on an old iOS. Establish an iOS floor early; if the passenger fleet must include iOS 16, validate before committing. Does not change the pick, but it is the one open thread aimed straight at it.

  4. PlayCanvas SuperSplat Viewer is not a candidate at all — worth stating so nobody re-litigates it. Its public surface is URL params + settings.json; camera control is limited to an initial {position, target, fov} plus pre-baked animation keyframes, with no per-frame pose API, and it defaults to WebGPU (?webgl forces the fallback). Driving external pose means forking camera-manager.ts. Use playcanvas/engine + gsplat directly if PlayCanvas is ever revisited (app.autoRender = false + setPositionAndRotation() + renderNextFrame = true). Source: supersplat-viewer README. Separately, engine PR #8235 (merged 2025-12-08) fixes gsplat asset CORS specifically for iOS — iOS is stricter on the texture-upload path, relevant if splats are ever served cross-origin rather than bundled.

Licence finding, now precise

§1's claim holds and can be sharpened. graphdeco-inria/gaussian-splatting reports spdx_id: NOASSERTION with a bespoke licence stating "Inria and the Max Planck Institut for Informatik (MPII) hold all the ownership rights" and that the goal is "to allow the research community to use, test and evaluate the Software" (LICENSE.md). Every shortlisted viewer is an independent reimplementation — format compatibility is not code derivation — so the viewers are clean and the exposure is entirely in the capture/training pipeline. Use a permissive trainer (nerfstudio gsplat, Apache-2.0; or Brush; or a capture vendor with commercial terms). Second trap: common demo datasets (Mip-NeRF 360, INRIA-hosted bicycle/garden/bonsai PLYs, dylanebert/3dgs HF mirrors) carry research-only dataset terms — fine for prototyping, not shippable. Not legal advice; get sign-off before commercial deployment.

Open item 8 in the list above is therefore still open, but now has a concrete shape: it is a capture-vendor contract question, not a code-audit question.

## Addendum — corrections and source-level additions A deeper source-level pass (reading viewer *source*, not just READMEs, plus npm registry and default-branch commit dates) landed after the answer above. **The verdict does not change — Spark is reinforced.** But one published fact was wrong, and there are four additions material enough to affect implementation. Recording both rather than quietly editing. ### Correction - **`gsplat.js` is effectively abandoned, not "pushed 2026-05-26" as I wrote.** The GitHub `pushed_at` field I relied on reflects a *non-default* branch. The default-branch HEAD commit is **2025-07-12**, matching npm `gsplat@1.2.9` published 2025-07-12 — **13 months stale**. My table overstated its liveness by ~10 months. It drops from "third option" to "prototype only". Lesson: `pushed_at` is not a maintenance signal; check default-branch HEAD. ### Additions that affect implementation 1. **`@mkkellogg/GaussianSplats3D` has a WebView-specific footgun I missed.** It defaults `sharedMemoryForWorkers: true`, which requires cross-origin isolation (`Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp`) or it throws `"SharedArrayBuffer transfer requires self.crossOriginIsolated"`. You generally **cannot set those headers on a bundled/`file://` page in a WebView** — so it needs `sharedMemoryForWorkers: false` **and** `gpuAcceleratedSort: false` (the README pairs them). This is a silent-failure trap for anyone prototyping with the viewer the session plan currently names, and worth knowing before an afternoon is lost to it. Also: explicit iOS branching in `Viewer.js` disables SIMD sort below iOS 17 and shared memory below iOS 16. Source: [README](https://github.com/mkkellogg/GaussianSplats3D/blob/main/README.md), [Viewer.js](https://github.com/mkkellogg/GaussianSplats3D/blob/main/src/Viewer.js). Its pinned release is npm `0.4.7`, **2025-01-25**. 2. **Spark already encodes iOS memory budgets as defaults — it is the only candidate that does.** `defaultSplatTarget()` returns **1,500,000 splats on iOS** (vs 2.5M desktop, 1M Android), and `maxPagedSplats` defaults to **6,291,456 on iOS** (vs 16,777,216 desktop). Source: [SparkRenderer.ts](https://github.com/sparkjsdev/spark/blob/main/src/SparkRenderer.ts), [spark-renderer docs](https://github.com/sparkjsdev/spark/blob/main/docs/docs/spark-renderer.md). Note this **contradicts the perf-doc reading in §2 above** ("iPhone: 1–3 million splats"): the shipped iOS default is 1.5M, i.e. the conservative end of that range. Take **1.5M as the planning figure**, not 3M. Pinned version: npm `@sparkjsdev/spark@2.1.0`, published **2026-05-18**. 3. **Caveat on the recommendation, in Spark's own tracker:** open issue [#262](https://github.com/sparkjsdev/spark/issues/262) (2026-01-20) — *"spz, sog, or sogzip cannot be displayed or run on iOS 16.2"*. That is precisely the Spark + SOG/SPZ combination recommended in the Verdict, failing on an old iOS. Establish an iOS floor early; if the passenger fleet must include iOS 16, validate before committing. Does not change the pick, but it is the one open thread aimed straight at it. 4. **PlayCanvas SuperSplat *Viewer* is not a candidate at all** — worth stating so nobody re-litigates it. Its public surface is URL params + `settings.json`; camera control is limited to an initial `{position, target, fov}` plus pre-baked animation keyframes, with **no per-frame pose API**, and it **defaults to WebGPU** (`?webgl` forces the fallback). Driving external pose means forking `camera-manager.ts`. Use `playcanvas/engine` + `gsplat` directly if PlayCanvas is ever revisited (`app.autoRender = false` + `setPositionAndRotation()` + `renderNextFrame = true`). Source: [supersplat-viewer README](https://github.com/playcanvas/supersplat-viewer/blob/main/README.md). Separately, [engine PR #8235](https://github.com/playcanvas/engine/pull/8235) (merged 2025-12-08) fixes gsplat asset CORS specifically for iOS — iOS is stricter on the texture-upload path, relevant if splats are ever served cross-origin rather than bundled. ### Licence finding, now precise §1's claim holds and can be sharpened. `graphdeco-inria/gaussian-splatting` reports **`spdx_id: NOASSERTION`** with a bespoke licence stating *"Inria and the Max Planck Institut for Informatik (MPII) hold all the ownership rights"* and that the goal is *"to allow the research community to use, test and evaluate the Software"* ([LICENSE.md](https://github.com/graphdeco-inria/gaussian-splatting/blob/main/LICENSE.md)). Every shortlisted viewer is an independent reimplementation — format compatibility is not code derivation — so **the viewers are clean and the exposure is entirely in the capture/training pipeline**. Use a permissive trainer (nerfstudio `gsplat`, Apache-2.0; or Brush; or a capture vendor with commercial terms). Second trap: common demo datasets (Mip-NeRF 360, INRIA-hosted `bicycle/garden/bonsai` PLYs, `dylanebert/3dgs` HF mirrors) carry research-only *dataset* terms — fine for prototyping, not shippable. Not legal advice; get sign-off before commercial deployment. Open item 8 in the list above is therefore still open, but now has a concrete shape: it is a capture-vendor contract question, not a code-audit question.
Author
Owner

Addendum 2 — measured bridge costs, format refinement, and a bypass worth considering

A second late sub-agent pass landed after this issue closed. It read react-native-webview and RN source directly and found the published measurements the original pass could not. Four things change.

1. The bridge has a measured floor, and it is not small

From discussion #3669 (Cozy Cloud, harness at cozy/react-native-benchmark), Android device:

payload postMessage round trip localhost HTTP
100 chars 31 ms 5.9 ms
10 000 chars 33 ms 15 ms
1 000 000 chars 57 ms 75 ms

Per-leg at 20KB: RN→WebView 14 ms, of which 11 ms (79%) is the RN→native command itself; WebView→RN 18 ms, of which 14 ms (78%) is the thread switch. Payload size was ruled out as the cause — routing the blob through a Nitro module and sending only a short ID still cost 10–15 ms. Author's conclusion, verbatim: "What takes time here is just the time spent calling the method which takes a minimum of 10 ms on all my tests."

Consequence: do not architect per-frame injectJavaScript. A 16.7ms frame budget against a ~10ms command floor leaves nothing. Note this is Android; no published iOS per-call figure exists — see #16.

2. Why the New Architecture does not fix it

Traced through source: injectJavaScriptCommands.injectJavaScriptdispatchCommand (synchronous JSI, no serialisation) → Fabric mount-item queue → drained on the UI threadevaluateJavaScript:cross-process IPC to WebContent. The New Architecture removed the serialisation cost, not the thread hop. Two async hops remain regardless.

Also from source: window.ReactNativeWebView.postMessage coerces with String(data) — an object becomes "[object Object]", so you must JSON.stringify yourself. There is no backpressure: over-driving the channel accumulates unbounded stale poses. Coalesce to a latest-only slot, never queue. And injectJavaScript has a history of double-dispatch (#3305, #3656) — the pose handler must be idempotent. enableApplePay silently disables injectJavaScript entirely.

3. Format: prefer SOG over SPZ — this refines the verdict above

  • SOG v2 (PlayCanvas, spec public, MIT tooling): ~13–16 B/Gaussian, 15–20× vs raw PLY, WebP-backed so it decodes off the main JS thread, already Morton-ordered so it uploads GPU-direct with no load-time repacking. Streamed SOG (lod-meta.json) is the only true LOD-streaming option — a 24M-Gaussian scene "loads immediately on mobile".
  • SPZ v4 (Niantic, MIT): 20 B/Gaussian at SH0, ~10×, but needs a ZSTD WASM module, is not built into PlayCanvas by default, and rejects v1–v3 files. PlayCanvas explicitly recommends SOG over SPZ for web delivery.
  • .ksplat is a dead end — see §4.
  • glTF KHR_gaussian_splatting is an RC (ratification targeted Q2 2026) but its compression extension is still open and pinned to SPZ v2 while Niantic has moved to v4. Treat GLB as interchange/archival, not runtime.
  • No shipping delivery format publishes PSNR. The 19.9–39.5× figures from the Self-Organizing Gaussians paper (ECCV 2024) describe a different algorithm from PlayCanvas's shipped .sog. If quality matters, measure it on our own captures.

4. @mkkellogg/GaussianSplats3D is abandoned

README, verbatim: "this repo is no longer in active development… I recommend checking out Spark". Last push 2025-10-19; known issues still listed include CPU-only sorting and "Sub-optimal performance on mobile devices". antimatter15/splat also now points at Spark. This independently confirms the Spark choice for a third time, and retires the SharedArrayBuffer footgun in Addendum 1 as moot — we weren't going to use that viewer anyway.

5. The bypass — orientation need not cross the bridge at all

WKWebView supports DeviceOrientationEvent/DeviceMotionEvent natively. react-native-webview doesn't implement requestDeviceOrientationAndMotionPermissionForOrigin, so iOS 15+ re-prompts forever — a known gap (#3408) with a widely-used one-method patch returning WKPermissionDecisionGrant.

This is 3DoF only — ARKit's translation still has to cross the bridge — but orientation is the DoF the eye is most sensitive to, and this gives it a zero-hop path. It contradicts the plan in prototyping-session.md of feeding expo-sensors orientation through the bridge. Now ticketed as a decision.

6. Assembled latency budget: ~47–79 ms, so <100 ms is reachable

ARKit pose (IMU-reintegrated) 1–5ms · RN native→JS 1–3ms · RN→WebView 10–20ms · WebView handler + sort 2–10ms · render 8–16ms · rAF→compositor ~16.7ms · scanout to mid-screen ~8ms. The bridge is 10–25% of the whole budget and the one term that can be engineered away rather than merely optimised. Drive pose through a resident window.__setPose handler, never through React state.

## Addendum 2 — measured bridge costs, format refinement, and a bypass worth considering A second late sub-agent pass landed after this issue closed. It read `react-native-webview` and RN source directly and found the published measurements the original pass could not. Four things change. ### 1. The bridge has a measured floor, and it is not small From [discussion #3669](https://github.com/react-native-webview/react-native-webview/discussions/3669) (Cozy Cloud, harness at [cozy/react-native-benchmark](https://github.com/cozy/react-native-benchmark)), **Android** device: | payload | postMessage round trip | localhost HTTP | |---|---|---| | 100 chars | **31 ms** | **5.9 ms** | | 10 000 chars | 33 ms | 15 ms | | 1 000 000 chars | 57 ms | 75 ms | Per-leg at 20KB: RN→WebView **14 ms**, of which **11 ms (79%) is the RN→native command itself**; WebView→RN **18 ms**, of which **14 ms (78%) is the thread switch**. Payload size was ruled out as the cause — routing the blob through a Nitro module and sending only a short ID still cost 10–15 ms. Author's conclusion, verbatim: *"What takes time here is just the time spent calling the method which takes a minimum of 10 ms on all my tests."* **Consequence: do not architect per-frame `injectJavaScript`.** A 16.7ms frame budget against a ~10ms command floor leaves nothing. Note this is Android; **no published iOS per-call figure exists** — see #16. ### 2. Why the New Architecture does not fix it Traced through source: `injectJavaScript` → `Commands.injectJavaScript` → `dispatchCommand` (synchronous JSI, no serialisation) → **Fabric mount-item queue → drained on the UI thread** → `evaluateJavaScript:` → **cross-process IPC to WebContent**. The New Architecture removed the *serialisation* cost, not the *thread hop*. Two async hops remain regardless. Also from source: `window.ReactNativeWebView.postMessage` coerces with **`String(data)`** — an object becomes `"[object Object]"`, so you must `JSON.stringify` yourself. There is **no backpressure**: over-driving the channel accumulates unbounded stale poses. Coalesce to a latest-only slot, never queue. And `injectJavaScript` has a history of double-dispatch ([#3305](https://github.com/react-native-webview/react-native-webview/issues/3305), [#3656](https://github.com/react-native-webview/react-native-webview/issues/3656)) — **the pose handler must be idempotent**. `enableApplePay` silently disables `injectJavaScript` entirely. ### 3. Format: prefer SOG over SPZ — this refines the verdict above - **SOG v2** (PlayCanvas, spec public, MIT tooling): ~13–16 B/Gaussian, 15–20× vs raw PLY, WebP-backed so it decodes off the main JS thread, already Morton-ordered so it uploads GPU-direct with no load-time repacking. **Streamed SOG** (`lod-meta.json`) is the only true LOD-streaming option — a 24M-Gaussian scene "loads immediately on mobile". - **SPZ v4** (Niantic, MIT): 20 B/Gaussian at SH0, ~10×, but **needs a ZSTD WASM module, is not built into PlayCanvas by default, and rejects v1–v3 files**. PlayCanvas explicitly recommends SOG over SPZ for web delivery. - **`.ksplat` is a dead end** — see §4. - glTF `KHR_gaussian_splatting` is an RC (ratification targeted Q2 2026) but its *compression* extension is still open and **pinned to SPZ v2** while Niantic has moved to v4. Treat GLB as interchange/archival, not runtime. - **No shipping delivery format publishes PSNR.** The 19.9–39.5× figures from the Self-Organizing Gaussians paper (ECCV 2024) describe a *different* algorithm from PlayCanvas's shipped `.sog`. If quality matters, measure it on our own captures. ### 4. `@mkkellogg/GaussianSplats3D` is abandoned README, verbatim: *"this repo is no longer in active development… I recommend checking out Spark"*. Last push 2025-10-19; known issues still listed include CPU-only sorting and *"Sub-optimal performance on mobile devices"*. `antimatter15/splat` also now points at Spark. **This independently confirms the Spark choice for a third time**, and retires the SharedArrayBuffer footgun in Addendum 1 as moot — we weren't going to use that viewer anyway. ### 5. The bypass — orientation need not cross the bridge at all **WKWebView supports `DeviceOrientationEvent`/`DeviceMotionEvent` natively.** `react-native-webview` doesn't implement `requestDeviceOrientationAndMotionPermissionForOrigin`, so iOS 15+ re-prompts forever — a known gap ([#3408](https://github.com/react-native-webview/react-native-webview/issues/3408)) with a widely-used one-method patch returning `WKPermissionDecisionGrant`. This is 3DoF only — ARKit's translation still has to cross the bridge — but **orientation is the DoF the eye is most sensitive to**, and this gives it a zero-hop path. It contradicts the plan in `prototyping-session.md` of feeding `expo-sensors` orientation through the bridge. Now ticketed as a decision. ### 6. Assembled latency budget: ~47–79 ms, so <100 ms is reachable ARKit pose (IMU-reintegrated) 1–5ms · RN native→JS 1–3ms · **RN→WebView 10–20ms** · WebView handler + sort 2–10ms · render 8–16ms · rAF→compositor ~16.7ms · scanout to mid-screen ~8ms. The bridge is **10–25% of the whole budget** and the one term that can be engineered away rather than merely optimised. Drive pose through a resident `window.__setPose` handler, never through React state.
Author
Owner

Addendum 3 — correction: the iOS bridge is cheap. Addendum 2's 10ms floor is Android-only.

A third pass found the iOS measurements Addendum 2 said didn't exist. This reverses that addendum's headline.

Measured iOS per-call latency (blog.persistent.info, 2015, iPad Air 2):

mechanism iOS 8.4 iOS 9.0b5
evaluateJavaScript round trip 2.60 ms 0.39 ms
WKScriptMessageHandler round trip 2.94 ms 0.63 ms

The iOS 9 jump came from WebKit reusing JSContext instances. One-way is roughly half. On A17/A18-class silicon, ~0.2–1.5 ms one-way is the realistic floor — so 60Hz injection costs single-digit percent of a frame. The ~10ms floor measured in #3669 is the Android postMessage prop path, not iOS evaluateJavaScript. Per-frame injection on iOS is viable after all. Caveat: these are 2015 benchmarks on iOS 8/9 hardware; nobody has published modern numbers, which is what #16 is for.

What actually dominates the budget — reassembled: camera exposure + readout 10–15ms · ARKit VIO solve 10–25ms · RN→main-queue 0–16.7ms (0 if injected natively) · evaluateJavaScript 0.2–1.5ms · wait for next rAF 0–16.7ms · splat render ≤16.7ms · WebContent→UIProcess CA commit ~16.7ms · scanout 8–17ms. Total ≈45–100ms best case, 70–130ms typical. The bridge is ≤1.5% of it. Stop optimizing the channel and optimize ARKit pose age, forward prediction, and keeping the main thread free.

Three structural findings that matter more than the channel:

  1. requestAnimationFrame is hard-capped at 60Hz in WKWebViewPreferPageRenderingUpdatesNear60FPSEnabled defaults on for embedders and off for Safari, with no public API to change it (WebKit bug 294338, filed 2025-06-11, still NEW; CADisableMinimumFrameDurationOnPhone does not help). Sending poses above 60Hz is pure waste. Private-API workarounds exist with App Store risk.
  2. You cannot late-warp. WKWebView's out-of-process compositing exposes no reprojection hook, so in-WebView extrapolation is the only latency compensation available. rAF at time t should render the pose predicted for ~25ms ahead.
  3. Don't put pose on the web→RN direction. Fabric's onMessage is gated on the main run loop reaching kCFRunLoopBeforeWaiting via AppleEventBeat, with no coalescing (isUnique == false, so every message becomes its own JS callback) and no backpressure. With a busy main thread that gate slips past a frame. Use it for rare control events only. Cf. #3991, open, where repeated messages progressively delay to ~1 minute.

The strongest architectural option: skip the RN JS thread entirely. A native module that holds both the ARSession and the WKWebView, calling evaluateJavaScript directly from session(_:didUpdate:) on the main thread. This is exactly what Mozilla's WebXR Viewer does — one evaluateJavaScript per ARKit frame at 60Hz, JSON built natively, and critically completionHandler: nil. Note react-native-webview always passes a non-nil completion block (it only nil-checks inside), so every injection pays the WebKit return-path cost — result serialization across IPC plus a JSContext to own the deserialized value (rdar://17956460).

Two dead ends to not waste time on: injectedJavaScriptObject cannot be a live channel (it's a WKUserScript at document-start; removeAllUserScripts + re-add only takes effect on the next load). SharedArrayBuffer needs crossOriginIsolated, which needs COOP/COEP response headers you cannot set on loadFileURL/loadHTMLString, and custom scheme handlers aren't secure contexts — the only unlock is a real http://localhost server.

Version note: npm latest is 14.0.1; 16.0.0 (legacy arch removed) is on the next tag. README requires RN 0.76+ and iOS 15.1+.

## Addendum 3 — **correction: the iOS bridge is cheap. Addendum 2's 10ms floor is Android-only.** A third pass found the iOS measurements Addendum 2 said didn't exist. This **reverses** that addendum's headline. **Measured iOS per-call latency** ([blog.persistent.info](https://blog.persistent.info/2015/08/wkwebview-communication-latency.html), 2015, iPad Air 2): | mechanism | iOS 8.4 | iOS 9.0b5 | |---|---|---| | `evaluateJavaScript` round trip | 2.60 ms | **0.39 ms** | | `WKScriptMessageHandler` round trip | 2.94 ms | **0.63 ms** | The iOS 9 jump came from WebKit reusing JSContext instances. One-way is roughly half. On A17/A18-class silicon, **~0.2–1.5 ms one-way is the realistic floor** — so 60Hz injection costs single-digit percent of a frame. The ~10ms floor measured in #3669 is the **Android `postMessage` prop path**, not iOS `evaluateJavaScript`. Per-frame injection on iOS is viable after all. Caveat: these are 2015 benchmarks on iOS 8/9 hardware; nobody has published modern numbers, which is what #16 is for. **What actually dominates the budget** — reassembled: camera exposure + readout 10–15ms · **ARKit VIO solve 10–25ms** · RN→main-queue 0–16.7ms (0 if injected natively) · `evaluateJavaScript` **0.2–1.5ms** · wait for next rAF 0–16.7ms · splat render ≤16.7ms · WebContent→UIProcess CA commit ~16.7ms · scanout 8–17ms. **Total ≈45–100ms best case, 70–130ms typical.** The bridge is ≤1.5% of it. Stop optimizing the channel and optimize ARKit pose age, forward prediction, and keeping the main thread free. **Three structural findings that matter more than the channel:** 1. **`requestAnimationFrame` is hard-capped at 60Hz in WKWebView** — `PreferPageRenderingUpdatesNear60FPSEnabled` defaults on for embedders and off for Safari, with no public API to change it ([WebKit bug 294338](https://bugs.webkit.org/show_bug.cgi?id=294338), filed 2025-06-11, still NEW; `CADisableMinimumFrameDurationOnPhone` does not help). Sending poses above 60Hz is pure waste. Private-API workarounds exist with App Store risk. 2. **You cannot late-warp.** WKWebView's out-of-process compositing exposes no reprojection hook, so **in-WebView extrapolation is the only latency compensation available**. rAF at time *t* should render the pose predicted for ~25ms ahead. 3. **Don't put pose on the web→RN direction.** Fabric's `onMessage` is gated on the main run loop reaching `kCFRunLoopBeforeWaiting` via `AppleEventBeat`, with **no coalescing** (`isUnique == false`, so every message becomes its own JS callback) and no backpressure. With a busy main thread that gate slips past a frame. Use it for rare control events only. Cf. [#3991](https://github.com/react-native-webview/react-native-webview/issues/3991), open, where repeated messages progressively delay to ~1 minute. **The strongest architectural option: skip the RN JS thread entirely.** A native module that holds both the `ARSession` and the `WKWebView`, calling `evaluateJavaScript` directly from `session(_:didUpdate:)` on the main thread. This is exactly what **Mozilla's WebXR Viewer does** — one `evaluateJavaScript` per ARKit frame at 60Hz, JSON built natively, and critically **`completionHandler: nil`**. Note `react-native-webview` *always* passes a non-nil completion block (it only nil-checks inside), so every injection pays the WebKit return-path cost — result serialization across IPC plus a JSContext to own the deserialized value ([rdar://17956460](https://openradar.appspot.com/17956460)). **Two dead ends to not waste time on:** `injectedJavaScriptObject` cannot be a live channel (it's a `WKUserScript` at document-start; `removeAllUserScripts` + re-add only takes effect on the next load). `SharedArrayBuffer` needs `crossOriginIsolated`, which needs COOP/COEP response headers you cannot set on `loadFileURL`/`loadHTMLString`, and custom scheme handlers aren't secure contexts — the only unlock is a real `http://localhost` server. **Version note:** npm `latest` is **14.0.1**; 16.0.0 (legacy arch removed) is on the `next` tag. README requires **RN 0.76+ and iOS 15.1+**.
Author
Owner

Addendum 4 — memory: you have two budgets, and one footgun that blanks the WebView permanently

Refines the 300–400MB figure in the original answer, from a WebKit-source pass.

There are two separate jetsam budgets. ENABLE_GPU_PROCESS_WEBGL_BY_DEFAULT is 1 on iOS, so WebGL textures/VBOs are charged to com.apple.WebKit.GPU while JS heap, ArrayBuffers and sort memory are charged to com.apple.WebKit.WebContent — each with its own limit and a different failure mode. The iOS GPU-process budget is completely undocumented and apparently unmeasured by anyone in the splat ecosystem. That is the biggest open number in this whole area.

Nothing in WebKit kills the WebContent process for memory on iPhone — the macOS 7GB/3GB constants people quote live inside ENABLE(PERIODIC_MEMORY_MONITOR), which is PLATFORM(MAC) only. The kernel's jetsam does it at memlimit_active. WebKit's own fallback constant when it can't read the limit is 840MB, which is the best available proxy. Reported field ceilings (~1.5GB iPhone 12 Pro, ~3GB iPhone 15 Pro) are Apple-forum hearsay, unverified.

Per-stack ceilings, from measured bytes-per-splat and a ~1GB WebContent budget, halved for the load-time peak:

stack practical ceiling
PlayCanvas SOG, SH0 ~7M
antimatter15 .splat, no SH ~5M
mkkellogg tuned (inMemoryCompressionLevel:2, freeIntermediateSplatData:true) ~5M
mkkellogg at defaults ~1.8M
uncompressed PLY, SH3 ~1M

This corroborates the 1.5M working figure and shows the headroom is in format choice. SOG at ~16 B/splat on disk and ~20 B/splat on the GPU is the cheapest option measured. Note mkkellogg's inMemoryCompressionLevel default was changed from 2 to 0 in PR #319 — the default got hungrier — and freeIntermediateSplatData defaults to false. Moot for us since Spark won, but it explains the field OOM reports.

Real-world datapoints: a ~250MB inline-SH asset was dead on an iPhone 13 while the same scene at ~65MB without SH worked (supersplat#337); a 1.4GB PLY (~5.6M splats) OOMed on an iPhone 14 Pro Max (supersplat#42); and in mkkellogg#245 progressive loading worked while the data-optimization step was what triggered the reload — the load-time transient again.

Footgun — implementing the terminate handler disables WebKit's auto-reload. NavigationState::NavigationClient::processDidTerminate returns true iff the app implements webViewWebContentProcessDidTerminate:, and dispatchProcessDidTerminate then skips the reload. react-native-webview implements it and does nothing but log and emit. So an RN app gets a permanently blank white WebView after a jetsam kill unless it reloads itself. There is also an open bug where reload() after a kill restores the wrong URL, because WKWebView.URL is cleared while backForwardList.currentItem is retained (PR #3993).

Handle two different death signals. A WebGL OOM on iOS most likely surfaces first as webglcontextlost / black canvas with the DOM intact (GPU process died and gets relaunched), not as onContentProcessDidTerminate. The RN event carries no reason code either way.

Two method notes for #16 and #11:

  • Measure frame-interval distribution, not mean fps. A Babylon report showed a steady 60 counter with visibly jittery presentation in an embedded WebView — an in-page rAF counter cannot detect this.
  • Feature-detect WebGPU rather than trusting the version gate. WebGPU is on by default from iOS 26 and Safari's feature flags are Safari-process-only ("These feature flags only impact Safari and not WebKit generally… for WKWebView, the feature will work when it's enabled by default" — Apple engineer). But nobody has published confirmation of navigator.gpu being defined inside a third-party WKWebView on shipped iOS 26+; MDN's webview_ios value is inherited, not tested. Keep the WebGL2 path regardless.
## Addendum 4 — memory: you have **two** budgets, and one footgun that blanks the WebView permanently Refines the 300–400MB figure in the original answer, from a WebKit-source pass. **There are two separate jetsam budgets.** `ENABLE_GPU_PROCESS_WEBGL_BY_DEFAULT` is 1 on iOS, so **WebGL textures/VBOs are charged to `com.apple.WebKit.GPU`** while **JS heap, ArrayBuffers and sort memory are charged to `com.apple.WebKit.WebContent`** — each with its own limit and a different failure mode. The iOS GPU-process budget is **completely undocumented and apparently unmeasured by anyone in the splat ecosystem**. That is the biggest open number in this whole area. **Nothing in WebKit kills the WebContent process for memory on iPhone** — the macOS 7GB/3GB constants people quote live inside `ENABLE(PERIODIC_MEMORY_MONITOR)`, which is `PLATFORM(MAC)` only. The kernel's jetsam does it at `memlimit_active`. WebKit's own fallback constant when it can't read the limit is **840MB**, which is the best available proxy. Reported field ceilings (~1.5GB iPhone 12 Pro, ~3GB iPhone 15 Pro) are Apple-forum hearsay, unverified. **Per-stack ceilings**, from measured bytes-per-splat and a ~1GB WebContent budget, halved for the load-time peak: | stack | practical ceiling | |---|---| | PlayCanvas SOG, SH0 | **~7M** | | antimatter15 `.splat`, no SH | ~5M | | mkkellogg tuned (`inMemoryCompressionLevel:2`, `freeIntermediateSplatData:true`) | ~5M | | mkkellogg **at defaults** | **~1.8M** | | uncompressed PLY, SH3 | ~1M | This corroborates the 1.5M working figure and shows the headroom is in format choice. SOG at ~16 B/splat on disk and ~20 B/splat on the GPU is the cheapest option measured. Note mkkellogg's `inMemoryCompressionLevel` default was **changed from 2 to 0** in PR #319 — the default got hungrier — and `freeIntermediateSplatData` defaults to `false`. Moot for us since Spark won, but it explains the field OOM reports. **Real-world datapoints:** a ~250MB inline-SH asset was dead on an iPhone 13 while the same scene at ~65MB without SH worked ([supersplat#337](https://github.com/playcanvas/supersplat/issues/337)); a 1.4GB PLY (~5.6M splats) OOMed on an iPhone 14 Pro Max ([supersplat#42](https://github.com/playcanvas/supersplat/issues/42)); and in mkkellogg#245 progressive loading worked while **the data-optimization step** was what triggered the reload — the load-time transient again. **Footgun — implementing the terminate handler disables WebKit's auto-reload.** `NavigationState::NavigationClient::processDidTerminate` returns true iff the app implements `webViewWebContentProcessDidTerminate:`, and `dispatchProcessDidTerminate` then **skips the reload**. `react-native-webview` implements it and does nothing but log and emit. So an RN app gets a **permanently blank white WebView** after a jetsam kill unless it reloads itself. There is also an open bug where `reload()` after a kill restores the *wrong* URL, because `WKWebView.URL` is cleared while `backForwardList.currentItem` is retained ([PR #3993](https://github.com/react-native-webview/react-native-webview/pull/3993)). **Handle two different death signals.** A WebGL OOM on iOS most likely surfaces first as **`webglcontextlost` / black canvas with the DOM intact** (GPU process died and gets relaunched), *not* as `onContentProcessDidTerminate`. The RN event carries **no reason code** either way. **Two method notes for #16 and #11:** - **Measure frame-interval distribution, not mean fps.** A Babylon report showed a steady 60 counter with visibly jittery presentation in an embedded WebView — an in-page rAF counter cannot detect this. - **Feature-detect WebGPU rather than trusting the version gate.** WebGPU is on by default from **iOS 26** and Safari's feature flags are Safari-process-only ("These feature flags only impact Safari and not WebKit generally… for WKWebView, the feature will work when it's enabled by default" — Apple engineer). But **nobody has published confirmation of `navigator.gpu` being defined inside a third-party WKWebView on shipped iOS 26+**; MDN's `webview_ios` value is inherited, not tested. Keep the WebGL2 path regardless.
Sign in to join this conversation.