Research: WebGL2 splat viewer performance inside iOS WKWebView #3
Notifications
Due Date
No due date set.
Blocks
#12 Prototype: does a rendered-splat route view read as navigation?
lars/splat-indoor-navigation
#11 Decide: success gates and the measurement protocol
lars/splat-indoor-navigation
#14 Write the Session 1 spec (pose to splat rendering)
lars/splat-indoor-navigation
Reference: lars/splat-indoor-navigation#3
Reference in New Issue
Block a user
Question
Which WebGL2 gaussian-splat viewer should run inside
react-native-webviewon iOS, and what is its performance ceiling?Answer specifically:
@mkkellogg/GaussianSplats3D,gsplat.js, Spark, PlayCanvas, others) — maturity, licence, API for setting an external camera pose..ksplat,.splat, SOG, SPZ) — size vs quality vs load time, and which viewers read which.injectJavaScript+postMessagerealistically achieves, and known techniques for keeping it low.Part of the wayfinder map #1.
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
WKWebViewin 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
@sparkjsdev/spark, World Labs)@mkkellogg/GaussianSplats3DuseBuiltInControls: false, pass owncamera,selfDrivenMode: false+ manualupdate()/render()gsplat.js(Hugging Face)Cameraobject; controls opt-inGaussianSplattingMeshantimatter15/splatSources: 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-splattingcode), 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
SparkControlsincontrols.ts, but it is fully opt-in: you construct it yourself (new SparkControls({ canvas })) and you must callcontrols.update(control, camera)from your own loop. If you never construct it, nothing in Spark touches your camera — the camera is an ordinaryTHREE.Camerayou own outright [measured — read from Spark source:export class SparkControlswith explicit constructor + caller-drivenupdate()]. So "drive the camera from a positioning system" is the default path, not something you switch off.@mkkellogg/GaussianSplats3Dreaches the same place but you must actively disable three separate built-in behaviours:useBuiltInControls: false, supply your owncamera, andselfDrivenMode: falseso you callupdate()/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
requestAnimationFrameis 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(defaultMath.sqrt(8)≈2.8;Math.sqrt(5)is "perceptually very similar" and cheaper),antialias: false, andsetPixelRatio[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.WebContentprocess with its own jetsam budget; exceed it and the process is killed, the app seeing a blank WebView pluswebViewWebContentProcessDidTerminate(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:
memorystatusjetsam 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.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.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/GaussianSplats3Dwas measured at 1.7GB for a single.splatfile whereantimatter15/splatused 770MB for the identical file in the identical scene (issue #314) [measured — user report]. ItsoptimizeSplatData(defaulttrue) 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]:
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
.ply(INRIA 3DGS).splat.ksplat.RADSources: 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,.ksplatand.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)+onMessagefor web→RN. Two hard constraints from the docs [measured]:postMessageaccepts exactly one argument and it must be a string, andonMessagemust be set orpostMessageis 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 →injectJavaScriptback) 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-webviewsupports 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]. ButinjectJavaScriptis 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]:
injectJavaScript(parse+eval)The variance, not the mean, is the problem. So the architecture must remove the bridge from the per-frame path:
latestPoseslot; a newer pose replaces a pending one. Stale poses are worse than no poses.injectJavaScriptat 60Hz "just in case."JSON.stringify; inject a barewindow.__pose(x,y,z,qx,qy,qz,qw,t)call.injectJavaScriptproves 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/spark2.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:
.ply/.spz/.splat/.ksplat/.sog— no format lock-in before the ship-capture vendor is chosen (§4).@mkkellogg/GaussianSplats3Dis 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
react-native-webviewmessaging reliability on Fabric — open reports ofpostMessage/onMessagebreakage around 13.13.x and "known New Architecture limitations". Pin and smoke-test the exact version rather than tracking latest.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.jsis effectively abandoned, not "pushed 2026-05-26" as I wrote. The GitHubpushed_atfield I relied on reflects a non-default branch. The default-branch HEAD commit is 2025-07-12, matching npmgsplat@1.2.9published 2025-07-12 — 13 months stale. My table overstated its liveness by ~10 months. It drops from "third option" to "prototype only". Lesson:pushed_atis not a maintenance signal; check default-branch HEAD.Additions that affect implementation
@mkkellogg/GaussianSplats3Dhas a WebView-specific footgun I missed. It defaultssharedMemoryForWorkers: 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 needssharedMemoryForWorkers: falseandgpuAcceleratedSort: 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 inViewer.jsdisables SIMD sort below iOS 17 and shared memory below iOS 16. Source: README, Viewer.js. Its pinned release is npm0.4.7, 2025-01-25.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), andmaxPagedSplatsdefaults 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.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.
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 (?webglforces the fallback). Driving external pose means forkingcamera-manager.ts. Useplaycanvas/engine+gsplatdirectly 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-splattingreportsspdx_id: NOASSERTIONwith 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 (nerfstudiogsplat, Apache-2.0; or Brush; or a capture vendor with commercial terms). Second trap: common demo datasets (Mip-NeRF 360, INRIA-hostedbicycle/garden/bonsaiPLYs,dylanebert/3dgsHF 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 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-webviewand 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:
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.postMessagecoerces withString(data)— an object becomes"[object Object]", so you mustJSON.stringifyyourself. There is no backpressure: over-driving the channel accumulates unbounded stale poses. Coalesce to a latest-only slot, never queue. AndinjectJavaScripthas a history of double-dispatch (#3305, #3656) — the pose handler must be idempotent.enableApplePaysilently disablesinjectJavaScriptentirely.3. Format: prefer SOG over SPZ — this refines the verdict above
lod-meta.json) is the only true LOD-streaming option — a 24M-Gaussian scene "loads immediately on mobile"..ksplatis a dead end — see §4.KHR_gaussian_splattingis 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..sog. If quality matters, measure it on our own captures.4.
@mkkellogg/GaussianSplats3Dis abandonedREADME, 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/splatalso 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/DeviceMotionEventnatively.react-native-webviewdoesn't implementrequestDeviceOrientationAndMotionPermissionForOrigin, so iOS 15+ re-prompts forever — a known gap (#3408) with a widely-used one-method patch returningWKPermissionDecisionGrant.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.mdof feedingexpo-sensorsorientation 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.__setPosehandler, never through React state.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):
evaluateJavaScriptround tripWKScriptMessageHandlerround tripThe 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
postMessageprop path, not iOSevaluateJavaScript. 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) ·
evaluateJavaScript0.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:
requestAnimationFrameis hard-capped at 60Hz in WKWebView —PreferPageRenderingUpdatesNear60FPSEnableddefaults on for embedders and off for Safari, with no public API to change it (WebKit bug 294338, filed 2025-06-11, still NEW;CADisableMinimumFrameDurationOnPhonedoes not help). Sending poses above 60Hz is pure waste. Private-API workarounds exist with App Store risk.onMessageis gated on the main run loop reachingkCFRunLoopBeforeWaitingviaAppleEventBeat, 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
ARSessionand theWKWebView, callingevaluateJavaScriptdirectly fromsession(_:didUpdate:)on the main thread. This is exactly what Mozilla's WebXR Viewer does — oneevaluateJavaScriptper ARKit frame at 60Hz, JSON built natively, and criticallycompletionHandler: nil. Notereact-native-webviewalways 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:
injectedJavaScriptObjectcannot be a live channel (it's aWKUserScriptat document-start;removeAllUserScripts+ re-add only takes effect on the next load).SharedArrayBufferneedscrossOriginIsolated, which needs COOP/COEP response headers you cannot set onloadFileURL/loadHTMLString, and custom scheme handlers aren't secure contexts — the only unlock is a realhttp://localhostserver.Version note: npm
latestis 14.0.1; 16.0.0 (legacy arch removed) is on thenexttag. README requires RN 0.76+ and iOS 15.1+.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_DEFAULTis 1 on iOS, so WebGL textures/VBOs are charged tocom.apple.WebKit.GPUwhile JS heap, ArrayBuffers and sort memory are charged tocom.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 isPLATFORM(MAC)only. The kernel's jetsam does it atmemlimit_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:
.splat, no SHinMemoryCompressionLevel:2,freeIntermediateSplatData:true)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
inMemoryCompressionLeveldefault was changed from 2 to 0 in PR #319 — the default got hungrier — andfreeIntermediateSplatDatadefaults tofalse. 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::processDidTerminatereturns true iff the app implementswebViewWebContentProcessDidTerminate:, anddispatchProcessDidTerminatethen skips the reload.react-native-webviewimplements 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 wherereload()after a kill restores the wrong URL, becauseWKWebView.URLis cleared whilebackForwardList.currentItemis 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 asonContentProcessDidTerminate. The RN event carries no reason code either way.Two method notes for #16 and #11:
navigator.gpubeing defined inside a third-party WKWebView on shipped iOS 26+; MDN'swebview_iosvalue is inherited, not tested. Keep the WebGL2 path regardless.