SceneView on Compose Multiplatform — scope & decision¶
Decision record for sceneview-compose, a Compose Multiplatform façade over the
existing per-platform renderers.
Last updated: 2026-08-03 · Answers: #558, #486
The ask¶
Two external users asked for Compose Multiplatform support and were closed without it ever existing:
- #486 (2024-05) — "Support for sceneview for Compose Multiplatform iOS"
- #558 (2024-09) — "Compose Multiplatform support"
Compose Multiplatform for iOS has been stable since CMP 1.8.0 (May 2025), so a
developer writing a CMP app today has no way to reach SceneView from commonMain.
Decision¶
Ship a thin commonMain façade that delegates to the existing native renderers.
It is a viewer subset, published as a separate, additive artifact. Nothing in
sceneview/, arsceneview/, SceneViewSwift/ or sceneview-web/ changes.
| Target | actual implementation |
Renderer | Status |
|---|---|---|---|
| Android | delegates to the existing SceneView { } composable |
Filament | implemented |
| iOS | UIKitView hosting an app-supplied UIView (SceneViewerBridge → SceneViewerHostView) |
RealityKit | implemented — the app registers the factory |
| Desktop (JVM) | offscreen render → pipelined readPixels → Skia image |
Filament, via an FFM binding still to be vendored | planned — draws a placeholder today |
The point is that one API does not imply one renderer. RealityKit stays the Apple renderer — it is what ARKit and visionOS align with, and it is what the published App Store app uses.
Scope: the viewer subset, and nothing more¶
The honest intersection of Filament, RealityKit and Filament.js is narrow. Measured on the current tree:
- Android exposes 27 node types (31 files in
sceneview/src/main/java/io/github/sceneview/node/, four of which are theNodebase and its delegates/state rather than node types), Swift 20, web ~10. The intersection of all three is 5: Camera, Geometry, Light, Model, SpatialAudio. - 77 of the 178 Kotlin files in
arsceneview/importcom.google.ar.coredirectly (58 undersrc/main, 19 undersrc/test); 100 reference the package at all. On Apple the equivalent surface is ARKit/RealityKit.
The façade covers 4 of those 5. SpatialAudio is left out deliberately: it is not part of the viewer case, and each platform's audio session has its own lifecycle to own.
So the façade covers the model viewer case — load a model, orbit it, light it, tap it — which is roughly 80% of real usage, and says so plainly.
Explicit non-goals¶
These are not coming to commonMain, in this release or a later one:
- AR. Abstracting
ARCore ConfigandARKit ARConfigurationbehind one type produces a lowest-common-denominator API that lies about both.ARScenestays per-platform. - Materials, shaders, post-processing.
.filamatblobs and RealityKit ShaderGraph have no common ground. - The full node catalogue. Splat, Video, View, ContactShadow, Physics and Text stay platform-native.
- Migrating
sceneview/to Compose Multiplatform. The Android library keeps targeting Compose Android. Existing consumers are untouched.
A failed load is reported, not just logged (decided 2026-08-06)¶
SceneViewer shipped with no error surface: a load that failed left the viewport showing
the environment, which is pixel-identical to a load still in progress, and the only
trace was a logcat line. That was flagged at review and consciously accepted — a viewer
subset can reasonably decide that failures are the app's problem to detect.
It was reconsidered and reversed, on one measurable fact: the module is unreleased
(no sceneview-compose artifact on Maven Central, its changelog fragment still pending in
changelog.d/). Adding a parameter costs nothing today and costs compatibility forever
after the first publication, so deferring the decision was strictly worse than taking it.
The shape is deliberately small — onError: ((SceneViewerError) -> Unit)? = null, opt-in,
with the log line unchanged when it is absent. It is not a general state machine: no
loading/loaded/failed enum, no retry policy, no error taxonomy. Those would be a viewer
API growing into an app framework, which the non-goals above rule out. What it guarantees
is only this: an app can tell "failed" from "still loading", which it previously could not.
Both shapes of failure reach it — an exception, and a loader answering null without
throwing. The second is not a nicety: moving asset reads off the main thread in the same
change swapped createModelInstance (which raised IllegalArgumentException when Filament
refused to parse a buffer) for the suspending loadModelInstance (which returns null for
the same input). Reporting only exceptions would have made every malformed model fail
silently — a fix for one defect quietly creating another.
Hard guardrails¶
These are load-bearing. Breaking one turns a bounded, reversible module into a liability.
- No renderer type in
commonMain. Not Filament, not RealityKit, not LWJGL, not any third-party binding. Enforced by binary-compatibility-validator plus a CI check on the committed.apidumps. - No
public inlinefunctions touching a renderer. Inline bodies get baked into consumer bytecode and walk straight around the façade. - Purely additive. The published Android, Apple and web surfaces do not change by one byte, and neither do the store apps.
- Scope stated in the module README, at commit 1. "Viewer subset" is a promise to keep, not a phase to grow out of quietly.
The iOS UIView wrapper¶
SceneViewerHostView lives in SceneViewSwift/Sources/SceneViewSwift/Bridge/. It is a
@objc UIView that hosts SceneView in a UIHostingController and is configured
entirely through primitives on SceneViewerConfiguration — no Swift enums, no SIMD3,
no optionals-of-scalars, because a bridge boundary cannot carry them.
One wrapper, three bridges. sceneview-compose needs a UIView because KMP cannot
see SwiftUI through cinterop; the Flutter plugin and the React Native module need one
because their host frameworks hand them a UIView. Solving "host a SwiftUI scene in
UIKit correctly" once — retain cycles, Swift 6 actor isolation, the persistent content
root that async-loaded models attach to, per-field change gating — is the point, and all
three consume it: sceneview-compose, SceneViewPlugin.swift and
SceneViewModule.swift all render their 3D path through this host.
Each bridge still has a platform-view class of its own, because each does more than
viewing. What stayed behind is what is genuinely theirs: the Flutter method channel, the
React Native prop bag, and the AR path — ARSceneView is anchor-driven and shares
nothing with the 3D viewer, so ARSceneViewPlatformView is untouched. What left is the
part all three had written three times: hosting a SwiftUI scene in UIKit, and loading
models into it.
What the wrapper needed from SceneViewSwift, and why it is additive¶
The published Swift surface could seed an orbit pose but never report one back, so a
hoisted CameraState could only ever echo its own writes. Four additions close that,
none of them changing existing behaviour:
SceneView.cameraPose(_:)— continuous camera write-through, applied only when the value changes so it coexists with a live drag instead of fighting it.SceneView.onCameraChanged(_:)— the read-back, fired from the single point that already knows the camera really moved (the far side of the #2331 diff-guard), so it covers drag, pinch, auto-rotate and re-framing rather than just the gesture handlers.SceneView.cameraGesturesEnabled(_:)— freezes the gestures without handing the camera to Apple'srealityViewCameraControls(_:), whichCameraControlMode.nonedoes and which would silently switch offcameraPose(_:)too.SceneView.onEntityTapHit(_:)— the tap plus a world-space position. A distinct base name, arrived at the hard way: an overload distinguished only by ahit:label does not protect existing call sites, because an unlabelled trailing closure ignores the label. Measured, every published.onEntityTapped { entity in }snippet — inllms.txt, both iOS cheatsheets and the SwiftUI codelab — stopped compiling under the labelled overload.
One pre-existing bug it surfaced¶
.onEntityTapped never fired on iOS, and neither did any NodeGesture handler. Nodes
generated collision shapes — the documented purpose of enableCollision is "for hit
testing" — but SwiftUI's targetedToAnyEntity() gestures additionally require an
InputTargetComponent, which nothing in the package ever set. The failure is completely
silent: no error, no warning, a scene that looks correct until someone taps it. The
repo's own CollisionHitTestDemo had never been tappable, and its header comment
asserted the opposite.
The first attempt at this fix touched ModelNode.load only, while the changelog
announced the class — which the review caught. InputTargetComponent now goes on the
whole content subtree in buildContent (covering GeometryNode, MeshNode, TextNode,
ImageNode, ShapeNode, ViewNode and PhysicsNode), on the loaded model under
enableCollision, and on the entity a NodeGesture handler registers against. Measured
on the iOS 26.3 simulator: a tap on a loaded .usdz and on an inline GeometryNode.cube
produced no callback before and fired on the first try after. This also repairs the
Flutter bridge's onTap (#2051).
The other face of it is a behaviour change to code that did not ask: handlers registered
through NodeGesture that were silently dead will now fire. Camera orbit and pinch are
unaffected — the entity gestures are attached with .simultaneousGesture, and a drag
over a model was verified to still orbit by the expected amount.
Divergences the wrapper reports rather than hides¶
Stated in sceneview-compose/README.md and in the KDoc of each affected parameter:
onFrame is never called (RealityKit publishes no per-frame callback); iOS reads
.usdz / .reality and no glTF; a tap that misses produces no callback rather than a
null hit; the hit position is the tapped entity's bounds centre, not the surface point;
EnvironmentSource.Color still gets RealityKit's default IBL; ambientIntensity applies
only to an HDR environment; and CameraState.distance is clamped to RealityKit's dolly
envelope — with the clamped value reported back, so it is visible rather than a silent
disagreement between the state and the screen.
Desktop bindings: vendored from filament-kmp, with attribution¶
Google removed Filament's Java/desktop support in 2021 and closed the KMP request as not planned, so there is no official Filament JVM desktop binding. The alternatives were: depend on Erkko68/filament-kmp, write our own binding, or write a non-Filament renderer.
Decision: vendor the desktop path of filament-kmp under third_party/, under the
terms of its Apache-2.0 licence. It is genuinely good work — Google's official C++
prebuilts, a hand-written combined C wrapper, and FFM bindings generated with
jextract — and copying it beats both re-deriving it and taking a runtime dependency.
Why vendor rather than depend:
- Its
filament-composemodule is already a high-level declarative CMP API. Building our façade on top of the published artifact would make SceneView a rename of its own dependency, permanently one release behind. We take the bindings, not the DSL. - It would otherwise put a single-maintainer, pre-1.0 dependency (~10 source-breaking
changes in
0.3.0alone) on the critical path. - Vendoring lets the desktop track its own Filament version deliberately. Upstream ships
1.74.0 (
MATERIAL_VERSION74) while SceneView Android pins 1.72.1 (73) and web pins 1.52.3 (52); the repo holds 28 committed.filamatblobs across those tracks (git ls-files '*.filamat'), and v4.1.0 already crashed 10 demos over exactly one such mismatch.
The copy is taken when the spike starts, not before¶
The tree was vendored, landed on main in c01ae5d87, and was removed again days later
without ever being built. No settings.gradle referenced it, so 31 700 lines sat in
every checkout compiling nothing — while its own §4(b) guard git cloned a
single-maintainer GitHub repository on every CI run and failed the job if that clone
failed, putting every pull request in the monorepo behind one individual's repo staying
reachable.
The decision above is unchanged; only its timing moved. Restoring the copy is one command and the full procedure — including the attribution and CI obligations that must land in the same PR — is in desktop-filament.md § Re-vendoring the binding.
Attribution is mandatory, not optional¶
Apache-2.0 permits this copy and sets the conditions for it. All of the following are required whenever the tree is present, and a missing one makes the copy a licence violation:
- The upstream
LICENSEships insidethird_party/filament-kmp/. - A
NOTICEnames Èric Bitriá Ribes as the original author, links https://github.com/Erkko68/filament-kmp, and records the copied version, both in that directory and in the repository's rootNOTICE. - Files we change carry a prominent notice that they were modified.
- Upstream sources carry no per-file copyright headers, so attribution lives at directory level — that is the correct form here, not an omission.
- A CI job actually runs
diff-upstream.sh. Attribution that nothing verifies is a claim, not a compliance position.
assets/CREDITS.md is generated; the third-party entry must flow through its generator
rather than being hand-edited.
Inherited costs, stated up front¶
- JDK 22+ on the desktop module. Project Panama's FFM API is what makes the binding possible; copying the code does not remove the floor.
- A native build chain: Filament prebuilt download, CMake for the C wrapper, and a
pinned
jextractearly-access build. - Three-OS CI. The repo has 66
ubuntu-latestjobs and 5 macOS jobs and no Windows runner; one has to be added for the native leg. - Recurring upkeep at each Filament release we choose to follow — the work upstream would otherwise have absorbed.
Desktop gates¶
The desktop actual ships only when it clears all of these:
- ≥60 FPS at 1080p with one glTF model; CPU cost measured, not asserted.
- Zero per-frame allocation in steady state, double-buffered readback.
- Resize storm: 30 s of continuous drag, no crash, memory back to baseline.
- 100 open/close cycles, RSS stable.
- Seam proof: the
.apidump contains no renderer type, and aNoopimplementation satisfies the interface. bash .claude/scripts/check-vendored-download-safety.shis green with the binding restored and actually wired. The0.3.0build-logic downloads Filament andjextractarchives with no integrity check, andextractAllcreates symlinks from an unvalidatedentry.linkName— a tarball carryinga -> /tmp/evilfollowed bya/xwrites outside the destination, becausenormalize()does not resolve symlinks and the entry-path check therefore passes. Both are build-time code execution, and both are harmless only while nothing compiles the tree. The gate exits 0 on the tree as it stands (absent since #3015) and fails from the instant asettings.gradleinclude lands, so this cannot be deferred to a follow-up PR: the hardening lands with the build chain, not after it. Full remediation in desktop-filament.md § Re-vendoring the binding, item 4.
Increments¶
Each one is independently shippable. No big bang.
sceneview-composemodule,commonMainAPI, Android actual — the delegation is near-free (on Android, Compose Multiplatform is androidx.compose).- iOS actual — shipped, but not the way this section originally planned. The plan
was to add an
@objcfaçade insideSceneViewSwiftand depend on it. That is not buildable: a KMP module cannot depend on a Swift Package at all, so the dependency direction had to be inverted.SceneViewernow declares what it needs — a factory producing aUIView(SceneViewerBridge) — and the app supplies it once at launch, whereSceneViewSwiftis already linked. The@objcfaçade did land insideSceneViewSwiftafter all, asSceneViewerHostView; what changed is that nothing depends on it at build time — the app links it and hands it over. See the iOS wrapper below. - Desktop actual, on the vendored Filament binding, behind the gates above. This is the long pole: ~26 000 lines to vendor plus a three-OS native build chain.
- CMP sample,
llms.txtsection, honest platform matrix.
Web is deliberately last and unscheduled: sceneview-core's wasmJs target is still
blocked on dev.romainguy:kotlin-math publishing a wasmJs variant.
References¶
- #558 · #486 — the original asks
- desktop-filament.md — desktop renderer analysis and the filament-kmp evaluation
- google/filament#4263 — Java/desktop support removed (2021)
- CMP 1.8.0 — iOS stable