Migration Guide¶
SceneView 4.37.x to 4.38.0 (Android) — isRendering replaced by frameRatePolicy¶
SceneView(isRendering:) is removed; render-on-demand is the default (#3108)¶
A SceneView no longer renders every vsync for as long as it is composed. The new
frameRatePolicy parameter defaults to FrameRatePolicy.OnDemand(): the library tracks what makes
the picture change, holds the display's full cadence while anything is happening, then draws a
short tail of settle frames and parks the loop.
isRendering: Boolean is removed outright — there is no deprecated overload. Keeping one
would have meant two parameters that can disagree about the same thing, and the boolean's whole
contract ("you work out when the scene is dirty") is precisely what the new default replaces.
// Before — you computed "is anything dirty" yourself and fed it in
var isDirty by remember { mutableStateOf(true) }
LaunchedEffect(dirtyToken) {
isDirty = true
delay(200)
isDirty = false
}
SceneView(isRendering = isAnimating || isInteracting || isDirty) { /* … */ }
// After — that is the library's job now
SceneView { /* … */ }
Key differences:
isRendering = true(or omitting the parameter) becomesframeRatePolicy = FrameRatePolicy.Continuous()if you genuinely want a frame every vsync. If you never passed the parameter at all, the honest migration is usually to change nothing and take the new default.- The
isDirtystate, thedirtyToken, theLaunchedEffect { delay(200) }window — delete them, do not translate them. Every source they were standing in for (touch, camera coast, animation, smooth transform, video,ViewNode, splat sort, async load, mirrorer, auto-fit, node added/moved/removed, visibility, geometry, material swap, surface resize, lifecycle resume) is now tracked by the library. - Never invalidate from a recomposition, and never write Compose state from
onFrame. The library asks for a frame from the thing that changed, never from the fact that a recomposition happened — and your screen should do the same. A counter that writes snapshot state on every presented frame recomposes its host on every presented frame; if any of that feeds back into the scene, the screen is measuring its own loop rather than the scene's.DebugStatsholds plain fields andDebugOverlayreads them on a 250 ms tick, which is the pattern to copy. - A
ViewNodekeeps the scene awake while its view is drawing, and only then. The hosted AndroidViewanimates on its own schedule — a ripple, a spinner, a cursor, an inner fling, a recomposition of hosted Compose content — and the library cannot see inside it, so the node reports itself active from the one thing that is observable: every buffer the view hierarchy queues onto itsSurfaceTexture. An animating view therefore holds the full cadence for as long as it animates, and a view that has finished drawing parks with the rest of the scene. AVideoNodeanswers the same way, plus a direct read ofplayer.isPlaying. - Pre-compiled consumers must recompile. A caller passing nine or more positional arguments
gets a type error at slot 9 rather than a silent behaviour change — the slot went from
BooleantoFrameRatePolicy. maxFpsis new, and it is an argument of both modes rather than a policy of its own:OnDemand(maxFps = 30)renders on demand and, when it does render, never faster than 30 fps;Continuous(maxFps = 30)is a steady 30 fps. Either way the display is votedmaxFpsrather than the panel maximum.null(the default) means the display's own cadence.- Direct Filament edits are the one thing the default cannot see. Anything written below the scene graph leaves nothing to invalidate on. The full list, and it is a list rather than an example, because the cost of missing one is a frozen image:
- a
MaterialInstanceparameter (setParameter) on an instance you already hold; - a light property written through
LightManagerrather than through the node; - a
SkyboxorIndirectLightassigned straight onto the FilamentScene; - morph-target weights and bone transforms written through
RenderableManager; - an external
Stream— a camera or video surface — pushing new content; Viewoptions changed at runtime (bloom, ambient occlusion, dynamic resolution, blend mode) on aViewyou own.
For all of them: call node.requestRender() on a node you hold, or take the new
renderInvalidator parameter with rememberRenderInvalidator() and call
invalidator.requestRender() after the edit. Call it on the main thread — post to it from a
background upload rather than calling across. Before a PixelCopy or a screenshot, request a
frame and then wait for your next onFrame: the request is fire-and-forget, and there is no
"await one frame" API.
- ARSceneView does not take this parameter, and its loop never parks. A live camera feed is
never idle, so there is no idle frame to skip. What it does do is skip the GPU submit on a
vsync where ARCore hands back a duplicate Frame.timestamp and nothing in the scene changed —
session.update() still runs every vsync, so tracking, anchors and plane detection are
unaffected.
Action: delete the isRendering argument and the dirty-tracking behind it. Pass
frameRatePolicy = FrameRatePolicy.Continuous() only if your scene is driven by something the
library cannot observe — an external simulation writing into Filament each frame, a custom
Renderer hook, a texture updated off-thread.
Where the line is: SDK mutators invalidate, raw Filament objects do not¶
This is the one rule to carry out of the migration, and it is the adoption risk the whole default turns on — a missed invalidation does not crash, log or fail a test. It shows the previous frame, forever, and reads as "the slider is broken".
Anything you change through a SceneView type asks for its own frame. Setting
lightNode.intensity, lightNode.color, lightNode.lightDirection, cameraNode.setExposure(…),
a projection, focusDistance, a node's transform, materialInstance / setMaterialInstanceAt,
setGeometry, setLayerVisible, morph weights or bone matrices through the node, shadow flags,
priority, culling, blend order — all of these invalidate on their own. You do not call
requestRender() after them, and you do not need Continuous() to make them show.
Anything you change on a raw Filament object does not. The SDK hands these out and never sees them again, so nothing is left to observe the write:
// Raw Filament object → the SDK cannot see this. Ask for the frame yourself.
indirectLight.intensity = 30_000f
indirectLight.setRotation(rotation)
materialInstance.setParameter("baseColorFactor", color)
view.ambientOcclusionOptions = options
scene.skybox = skybox
lightManager.setIntensity(instance, lux) // through the manager, not the node
renderInvalidator.requestRender() // ← the missing line
Take the invalidator with rememberRenderInvalidator() and pass it as
SceneView(renderInvalidator = …), or call requestRender() on any node you hold. The rule of
thumb that costs nothing to apply: if the type you are writing to came from com.google.android
.filament, ask for a frame.
Measured, so it is not hypothetical: a demo screen dragging Environment rotation from 302° to
100° and Exposure from 1.00 to 2.72 on a parked scene produced 0 frames and a viewport still
lit the old way. Both writes went into an IndirectLight.
Check your app for heuristics that read the frame rate¶
This is the failure mode that survives the mechanical migration, because it compiles, it is in your code, and it looks like it is measuring something else.
Any heuristic built on a run of closely spaced frames breaks under OnDemand. The pattern is
always some form of "I will believe the scene is up once I have seen N frames within M
milliseconds of each other" — a loading cover, a splash dismissal, a warm-up probe, an "is the GPU
keeping up" check. It worked because every SceneView drew every vsync forever, so frames were a
clock you could count on. They are not a clock any more. A finished scene presents a short settle
tail and parks: our own demo app measured 3 frames in 10 s on a fully drawn model, and its
loading cover — which wanted 8 frames within 250 ms of each other — never lifted. At 12 s the
scaffold replaced the spinner with a "Still loading…" card, over a scene that had been complete
for ten seconds. Nudging the camera restarted the loop, satisfied the streak and dismissed the
card, which is the tell: the signal was reading the frame rate and reporting it as progress.
A readiness signal must never depend on cadence — a parked scene is a ready scene. Rewrite the
rule to count frames without looking at the intervals between them, and if what you actually need
is "has the GPU finished", ask the GPU: Engine.flushAndWait() blocks until the backend has
executed the queued work, which is the fact the timing was being used to guess. Two presented
frames are enough to start that question, because Filament refuses a new frame while the driver is
behind — a second accepted submission is itself the evidence that the first was drained.
Do not fix this by holding the loop awake to feed the heuristic. That re-creates exactly the drain this release exists to remove, and hides the bug instead of closing it.
onFrame cannot be what keeps your loop awake¶
The same shape, one level down, and it is the one that costs you a screen rather than a cover.
onFrame fires once per presented frame, right after that frame reached the surface. Under
Continuous that was indistinguishable from "every vsync, forever", so it became the natural home
for anything per-frame: advancing an animation clock, stepping a physics simulation, driving a
turntable, syncing an audio listener pose. Under OnDemand it closes on itself. Nothing else
invalidates the scene, so the loop settles and parks; parked means no presented frame; no presented
frame means no onFrame; and the clock that was going to ask for the next frame is the one that
just stopped running. The screen does not stutter — it freezes on open, with a fully drawn, entirely
correct first frame, which is why it reads as "the animation is broken" and not as "the loop
stopped".
Two things to check in every per-frame callback you own:
- Does it drive motion? Then say so:
frameRatePolicy = FrameRatePolicy.Continuous()while it runs, and back toOnDemand()when the user pauses it. This is what the mode is for, and it also lets the cadence vote tell the display. ARenderInvalidator.requestRender()called from insideonFrameworks too — each rendered frame buys the next — but it says "one more frame" sixty times a second to mean "keep going", and nothing tells the panel. - Does it apply a change the user just made? Then apply it outside
onFrameand request the frame after:onFrameruns after presentation, so a pose written there lands in the next frame. OnerequestRender()would draw the old pose, then park one frame behind, permanently.
The state that decides — isPlaying, isReplaying, "is this band on screen" — also needs a rising
edge. Going from paused to playing while the loop is parked changes a flag no one is reading;
request one frame from a LaunchedEffect on that flag, and the callback takes it from there.
And it must follow the motion, not a flag only a gesture clears. A Continuous() held by
isReplaying, where isReplaying goes false only when the user presses Reset, is the same lie as a
loader that never resolves: the spheres stop bouncing, the flag stays true, and the screen renders a
frozen picture at 57 fps until someone touches it. Tie the declaration to the thing that is actually
moving.
Node.onFrame holds the loop open; SceneView(onFrame = …) does not¶
They share a name and nothing else, and the asymmetry is deliberate:
| when it runs | effect on the loop | |
|---|---|---|
SceneView(onFrame = …) |
after its frame was presented | none — it is an observer |
node.onFrame = { … } |
before the frame is drawn | pins — a non-null slot asks for a frame every tick |
Node.onFrame is a driver: PhysicsNode steps its simulation there, and a driver that only runs
when a frame happens could never produce the first one. So setting it is read as "keep rendering",
and node.onFrame = null is how you stop asking. A transform written there is on screen in the same
frame — it runs ahead of the GPU submit, not after it.
Two consequences worth knowing before you go looking for a leak:
- A node you hand an
onFrameto will not let its scene park. That is the safe default, and it is under your control. If the callback only observes, hold the value in Compose state instead. - The library's own per-frame work does not go through that slot and does not pin anything.
BillboardNode(andTextNode, which is one) re-orients only when the camera has actually moved; a settledPhysicsNodereports itself idle;rememberModelAnimationStateobserves without costing you a frame. Before 4.37 every one of them quietly held its scene at full cadence — a screen with twoTextNodes on it ran at 57 fps on a still picture. None of this needs a change in your code.
SceneView 4.14.x to 4.15.1 (iOS) — native Apple camera modes added to CameraControlMode¶
Three new CameraControlMode cases — .none, .tilt, .dolly (#1049)¶
CameraControlMode now exposes three native Apple cases that delegate to
realityViewCameraControls(_:) introduced in iOS 18 / macOS 15:
| New case | Behaviour | SDK requirement |
|---|---|---|
.none |
Disables all gesture interaction | iOS 18+, macOS 15+ |
.tilt |
Tilt camera up/down about horizontal axis | iOS 18+, macOS 15+ |
.dolly |
Move camera forward/back along look direction | iOS 18+, macOS 15+ |
This is additive. Existing code using .orbit, .pan, or .firstPerson
compiles and runs unchanged.
visionOS note. realityViewCameraControls(_:) is @available(visionOS, unavailable).
On visionOS, SceneView automatically falls back to its custom gesture handlers.
No #if os() guards are required in app code.
// Before v4.15.1 — only 3 modes
.cameraControls(.orbit) // .orbit | .pan | .firstPerson
// v4.15.1+ — 6 modes; native Apple modes available on iOS 18+ / macOS 15+
.cameraControls(.orbit) // existing modes unchanged
.cameraControls(.none) // lock all camera gestures (iOS 18+ / macOS 15+)
.cameraControls(.tilt) // native tilt — Apple's realityViewCameraControls (iOS 18+ / macOS 15+)
.cameraControls(.dolly) // native dolly — Apple's realityViewCameraControls (iOS 18+ / macOS 15+)
Action: no migration required. If you previously worked around the absence of
a "no gestures" mode by disabling the modifier entirely, you can now use
.cameraControls(.none).
SceneView 4.10.x to 4.11.0 (Android) — CloudAnchorNode.host() returns HostCloudAnchorFuture¶
CloudAnchorNode.host() now returns the underlying ARCore HostCloudAnchorFuture (#1768)¶
Pre-v4.11.0, CloudAnchorNode.host(session, ttlDays, onCompleted) returned Unit
and stored the in-flight future privately — apps had no way to cancel a pending
host request when the UI scope was leaving. The network round-trip ran to
completion regardless, accruing a Google Cloud billing event whether or not the
caller was still listening.
v4.11.0 surfaces the underlying ARCore HostCloudAnchorFuture
as the return value so callers can cancel via future.cancel() — typically from
DisposableEffect.onDispose { future?.cancel() }. The same change applies to
CloudAnchorNode.resolve(...), TerrainAnchorNode.resolve(...), and
RooftopAnchorNode.resolve(...).
Billing rationale. Each pending host/resolve future is one Google Cloud
ARCore API request. Without explicit cancellation, the request runs to
completion (a few seconds, sometimes longer for cross-continent resolves) and
the billing event lands on your Google Cloud project even after the user has
navigated away from the screen that started it. For apps that surface a "Place
anchor" CTA inside a navigable hierarchy (Compose / Fragment / Activity stack)
this is a measurable cost on cancelled user flows — see
Google's ARCore Cloud Anchors documentation.
Cancellation does not invoke the onCompleted callback (matches ARCore
semantics), so observers stay clean.
Action — source compatibility. Most call sites are unaffected — the new
return type is purely additive. The only break is for callers that wrote
val unused: Unit = node.host(...) or any test that asserts the return type is
Unit. Adjust the local binding and (recommended) cancel the future on dispose:
// pre-v4.11.0
DisposableEffect(anchorNode) {
anchorNode.host(session, ttlDays = 7) { id, state -> /* ... */ }
onDispose { /* nothing to cancel — the future was internal */ }
}
// v4.11.0 — capture the future, cancel on dispose to free the billing event
DisposableEffect(anchorNode) {
val future = anchorNode.host(session, ttlDays = 7) { id, state -> /* ... */ }
onDispose { future.cancel() }
}
The same pattern applies to CloudAnchorNode.resolve(engine, session, id) { ... },
TerrainAnchorNode.resolve(...), and RooftopAnchorNode.resolve(...). The
node's own destroy() lifecycle still calls cancelHost() internally, so an
app that doesn't cancel explicitly still cleans up on scene disposal — but it
pays the round-trip until destroy fires.
SceneView 4.3.x to 4.4.0 (iOS) — true camera motion + skybox renders + mirror retired¶
Environment.showSkybox = true now actually paints the HDR as background (PR #1215)¶
Pre-v4.4.0 the flag set up IBL lighting from the HDR but never rendered the HDR
as the scene background — the void stayed neutral. v4.4.0 wires the HDR resource
through RealityViewContent.environment = .skybox(...).
Action:
- If your app set
showSkybox: falseto explicitly hide a background that was already invisible, you can leave the code as-is —falsecontinues to render the neutral default. - If your app set
showSkybox: trueand was depending on the void-background bug (e.g. compositing the scene over a custom SwiftUI background), explicitly flip toshowSkybox: false. The IBL still lights your scene.
// pre-v4.4.0 (the bug)
.environment(.custom(name: "city", hdrFile: "city.hdr", showSkybox: true))
// ^^^^ silently ignored
// v4.4.0 (intended behaviour)
.environment(.custom(name: "city", hdrFile: "city.hdr", showSkybox: true))
// ^^^^ now renders HDR as background
.environment(.custom(name: "city", hdrFile: "city.hdr", showSkybox: false))
// ^^^^ neutral background, IBL still applies
Orbit + pan modes now physically move the perspective camera (PR #1215)¶
Pre-v4.4.0 applyCamera() rotated + scaled the scene root while the perspective
camera stayed pinned at [0, 0.3, 2]. v4.4.0 positions the camera in world-space
via CameraControls.cameraPosition() so the skybox correctly wraps around the
camera as it orbits.
Action:
- Most apps need no code change — the apparent on-screen framing of existing
scenes is preserved at the new defaults (the SceneView's internal
CameraControlsdefaultorbitRadiuswas bumped from5.0to2.0to match the old camera distance, so a 1m model fills the same fraction of viewport). - If your code reads
entities.root.scale/.orientationvia reflection, debug-overlay, or a custom modifier, those now stay at identity in orbit / pan modes. Migrate toCameraControls.cameraTransform()to recover the camera matrix.
CameraControls defaults changed (BREAKING for direct constructors)¶
If your app constructs CameraControls() directly (rather than using the
.cameraControls(_:) modifier):
orbitRadiusdefault:5.0→2.0(camera-to-target distance, was unreachable through any public modifier before so this aligns with the SceneView's internal default)minRadiusdefault:0.5→1.0(pinch-in floor —0.5clipped into 1m-extent models under the new true-camera path;1.0matches typical bundled demo content)
Action:
// pre-v4.4.0 implicit defaults
var controls = CameraControls()
// controls.orbitRadius == 5.0, controls.minRadius == 0.5
// v4.4.0 — bump explicitly if you want the pre-v4.4.0 framing for non-bundled content
var controls = CameraControls()
controls.orbitRadius = 5.0
controls.minRadius = 0.5
FOV no longer bleeds from firstPerson pinch into orbit / pan¶
Pre-v4.4.0, pinching FOV down to e.g. 30° in .firstPerson then switching to
.orbit kept the 30° pinched FOV on the perspective camera (visible as a stuck
zoom-in). v4.4.0 writes the baseline 60° FOV in orbit/pan regardless, and
mirrors camera.fov only in .firstPerson. On .firstPerson exit, camera.fov
itself is reset to 60 so the next entry starts fresh.
Action: no code change required — the previous behaviour was a bug.
Swift Package Manager URL changed¶
The sceneview/sceneview-swift mirror has been archived read-only. SPM consumers
should re-add the package in Xcode pointing at the monorepo directly:
- .package(url: "https://github.com/sceneview/sceneview-swift.git", from: "4.0.0")
+ .package(url: "https://github.com/sceneview/sceneview.git", from: "4.4.0")
The frozen v4.0.0 tag on the mirror still resolves for any consumer that hasn't
migrated, but no further releases will be cut there. The root Package.swift in
the monorepo (added in #920)
declares the SceneViewSwift product, so the import statement
(import SceneViewSwift) is unchanged.
Attribution¶
The skybox-renders + true-orbit camera fixes were ported with Co-authored-by
credit from @radcli14's
sceneview-swift PR #1.
SceneView 4.2.x to 4.3.0 (iOS) — silent-stub modes now active¶
.cameraControls(.pan) and .cameraControls(.firstPerson) no longer silently orbit (#1034)¶
In v4.2.0 these two modes existed in the enum but applyCamera() ignored
them — they produced orbit behaviour at runtime. v4.3.0 wires them to
real per-mode handlers.
Action:
- Apps that called
.cameraControls(.pan)expecting orbit behaviour should switch to.cameraControls(.orbit)(or drop the modifier entirely — orbit is the default). - Apps that called
.cameraControls(.firstPerson)get FOV-zoom pinch instead of dolly. To keep the v4.2.0 orbit-with-dolly behaviour, switch to.orbit.
// v4.2.0 (silent stub)
SceneView { /* ... */ }
.cameraControls(.pan) // actually orbited
// v4.3.0 (wired)
SceneView { /* ... */ }
.cameraControls(.pan) // now translates target; drop modifier for orbit
Library-level auto-center is on by default (#1026)¶
iOS v4.3.0 introduces an intermediate contentRoot Entity and translates
it on the first frame the scene's visualBounds is non-empty so the
centroid lands on the world origin. Most demos benefit; scenes that rely
on intentional off-centre placement (carousels, dioramas, story-mode)
will see content re-centre.
Action: append .autoCenterContent(false) for off-centre-by-design
scenes.
// v4.2.0 — manual per-demo centring
SceneView { root in
let model = ModelNode.load("hero.usdz")
model.entity.position = .init(x: 0, y: -0.5, z: -2) // intentional offset
root.addChild(model.entity)
}
// v4.3.0 — opt out of library centring to keep the offset
SceneView { root in
let model = ModelNode.load("hero.usdz")
model.entity.position = .init(x: 0, y: -0.5, z: -2)
root.addChild(model.entity)
}
.autoCenterContent(false) // ← restore strict v4.2.0 placement
SceneView 3.6.x to 4.0.0 (Release Candidate)¶
Status: v4.0.0 is live as a release candidate. Maven Central and Swift Package Manager artifacts are not built from the RC tag — pin to the tag manually to test, or wait for the v4.0.0 stable tag.
4.0.0 is a strictly additive release for the SDK side. Existing 3.6.x code compiles and runs unchanged. The version bump reflects two new capabilities, not breaking changes.
1. New: AR Debug — Rerun.io integration¶
SceneView can now stream an ARCore / ARKit session to the Rerun viewer for scrub-and-replay debugging. See the full guide in the llms.txt reference or the AR Debug (Rerun) example in the playground.
Quick wire-up on Android:
import io.github.sceneview.ar.rerun.rememberRerunBridge
@Composable
fun MyARScreen() {
val bridge = rememberRerunBridge(enabled = BuildConfig.DEBUG)
ARSceneView(
modifier = Modifier.fillMaxSize(),
onSessionUpdated = { session, frame ->
bridge.logFrame(session, frame)
}
)
}
And on iOS:
import SceneViewSwift
struct MyARView: View {
@StateObject private var bridge = RerunBridge(host: "192.168.1.42")
var body: some View {
ARSceneView()
.onFrame { frame, _ in bridge.logFrame(frame) }
.onAppear { bridge.connect() }
.onDisappear { bridge.disconnect() }
}
}
No breaking changes — this is a new io.github.sceneview.ar.rerun package on Android and a new SceneViewSwift.RerunBridge type on iOS. Nothing existing was touched.
2. New: ARSceneView.onFrame hook on iOS¶
SceneViewSwift.ARSceneView gained a new onFrame(_ handler:) modifier mirroring Android's onSessionUpdated callback. Fires from the existing ARSessionDelegate.session(_:didUpdate:) without adding a second delegate. Used by the Rerun bridge, but useful for any per-frame custom logic (pose streaming, custom tracking analytics, etc.).
3. Version bump — just the number¶
Update your Gradle / SPM / pubspec / package.json references:
// Before
implementation("io.github.sceneview:sceneview:4.44.0")
implementation("io.github.sceneview:arsceneview:4.44.0")
// After (4.0.0)
implementation("io.github.sceneview:sceneview:4.44.0")
implementation("io.github.sceneview:arsceneview:4.44.0")
4.0.0 and every later 4.x release are on Maven Central — use the current version shown above.
4. sceneview-mcp moved to the 4.x line¶
During the 4.0.0 release candidate, the 4.x line of the sceneview-mcp npm package shipped under the @next tag while @latest stayed on 3.6.4. That is over: @latest is now on the 4.x line (with the Rerun integration docs), so the plain package name is all you need:
There is also a new rerun-3d-mcp package (@latest = 1.0.0) that generates the Rerun integration boilerplate on demand:
SceneView 3.5.x to 3.6.0¶
SceneView 3.6.0 simplifies the API surface so that AI assistants and developers can write correct
code on the first try. All changes use @Deprecated(replaceWith = ...) — the Kotlin compiler will
auto-suggest fixes via IDE quick actions.
1. Scene composable renamed to SceneView, ARScene to ARSceneView¶
The Scene { } composable is renamed to SceneView { } and ARScene { } is renamed to
ARSceneView { }. This aligns Android naming with Apple (SwiftUI already uses SceneView and
ARSceneView) and avoids confusion with Filament's internal Scene class.
// Before
Scene(modifier = Modifier.fillMaxSize()) { /* nodes */ }
ARScene(modifier = Modifier.fillMaxSize()) { /* AR nodes */ }
// After (3.6.0)
SceneView(modifier = Modifier.fillMaxSize()) { /* nodes */ }
ARSceneView(modifier = Modifier.fillMaxSize()) { /* AR nodes */ }
SceneScope and ARSceneScope are unchanged — only the top-level composable names changed.
2. CameraNode composable renamed to SecondaryCamera¶
The CameraNode { } composable inside SceneView { } creates a non-active camera — it does NOT
become the scene's rendering camera. The name was misleading, so it's renamed to SecondaryCamera.
// Before
SceneView(cameraNode = rememberCameraNode(engine)) {
CameraNode { /* secondary camera — name was confusing */ }
}
// After (3.6.0)
SceneView(cameraNode = rememberCameraNode(engine)) {
SecondaryCamera { /* clearly named as non-primary */ }
}
The scene's active camera is still set via SceneView(cameraNode = rememberCameraNode(engine)) —
this has NOT changed.
3. All geometry nodes now have uniform transform params¶
Every geometry composable (CubeNode, SphereNode, CylinderNode, PlaneNode, LineNode,
PathNode) now accepts the same position, rotation, scale trio. Previously, some were missing.
// Before — SphereNode had no rotation or scale
SphereNode(radius = 0.2f, materialInstance = mat)
// After (3.6.0) — all geometry nodes have the full trio
SphereNode(
radius = 0.2f,
materialInstance = mat,
position = Position(x = 1f),
rotation = Rotation(y = 45f),
scale = Scale(2f)
)
4. LightNode — explicit params instead of dual lambdas¶
LightNode now exposes intensity, direction, and position as direct parameters instead of
requiring two separate apply / nodeApply lambdas.
// Before
LightNode(
type = LightManager.Type.DIRECTIONAL,
apply = { intensity(100_000f); direction(0f, -1f, 0f) },
nodeApply = { position = Position(0f, 5f, 0f) }
)
// After (3.6.0)
LightNode(
type = LightManager.Type.DIRECTIONAL,
intensity = 100_000f,
direction = Direction(0f, -1f, 0f),
position = Position(0f, 5f, 0f)
)
5. VideoNode — convenience overload with asset path¶
New overload that handles MediaPlayer lifecycle automatically:
// Before — manual MediaPlayer setup
val player = rememberMediaPlayer(context, assetFileLocation = "videos/promo.mp4")
SceneView {
player?.let { VideoNode(player = it, position = Position(z = -2f)) }
}
// After (3.6.0) — one-liner
SceneView {
VideoNode(videoPath = "videos/promo.mp4", position = Position(z = -2f))
}
6. New composables: ShapeNode and PhysicsNode¶
Both are now available directly in the SceneView { } DSL:
SceneView {
ShapeNode(
polygonPath = listOf(Position2(0f, 0f), Position2(1f, 0f), Position2(0.5f, 1f)),
color = Color(0xFF2196F3.toInt())
)
PhysicsNode(gravity = -9.81f, floorY = 0f) {
SphereNode(radius = 0.1f, materialInstance = mat)
}
}
7. Swift: Declarative SceneView with @NodeBuilder¶
SwiftUI SceneView now supports a declarative builder matching Android's SceneView { }:
// Before — imperative
SceneView { root in
root.addChild(cube.entity)
root.addChild(sphere.entity)
}
// After (3.6.0) — declarative
SceneView {
GeometryNode.cube(size: 0.3, color: .red)
.position(.init(x: -1, y: 0, z: -2))
GeometryNode.sphere(radius: 0.2, color: .blue)
.position(.init(x: 1, y: 0, z: -2))
}
8. Swift: NodeGesture automatic cleanup¶
NodeGesture now tracks entities with weak references and automatically purges stale handlers.
Entity fluent extensions are available for cleaner syntax:
// Before — static calls only
NodeGesture.onTap(entity) { print("Tapped!") }
// After (3.6.0) — fluent chaining
entity.onTap { print("Tapped!") }
.onDrag { translation in entity.position += translation }
SceneView 2.x to 3.x¶
SceneView 3.0 is a ground-up rewrite around Jetpack Compose. The core concepts are the same (Filament engine, ARCore session, node graph), but the API is fully Compose-native. This guide walks through every breaking change with before/after examples.
SceneView 3.0 is a ground-up rewrite around Jetpack Compose. The core concepts are the same (Filament engine, ARCore session, node graph), but the API is fully Compose-native. This guide walks through every breaking change with before/after examples.
1. Dependency version¶
// Before
implementation("io.github.sceneview:sceneview:2.3.0")
implementation("io.github.sceneview:arsceneview:2.3.0")
// After
implementation("io.github.sceneview:sceneview:4.44.0")
implementation("io.github.sceneview:arsceneview:4.44.0")
2. SceneView — nodes move into the content block¶
The childNodes parameter is gone. Declare nodes directly inside the SceneView { } trailing lambda.
// Before — nodes passed as a list
SceneView(
modifier = Modifier.fillMaxSize(),
engine = engine,
modelLoader = modelLoader,
childNodes = rememberNodes {
add(
ModelNode(
modelInstance = modelLoader.createModelInstance("models/helmet.glb"),
scaleToUnits = 1.0f
)
)
add(CylinderNode(engine = engine, radius = 0.1f, height = 1.0f))
},
cameraManipulator = rememberCameraManipulator()
)
// After — nodes declared as composables in the DSL
val modelInstance = rememberModelInstance(modelLoader, "models/helmet.glb")
SceneView(
modifier = Modifier.fillMaxSize(),
cameraManipulator = rememberCameraManipulator()
) {
modelInstance?.let { instance ->
ModelNode(modelInstance = instance, scaleToUnits = 1.0f)
}
CylinderNode(radius = 0.1f, height = 1.0f)
}
Key differences:
- engine and modelLoader parameters have sensible defaults — you only need to provide them
explicitly if you're sharing resources across multiple scenes.
- rememberModelInstance is async and returns null while loading. Use ?.let { } to show the
node only when ready. It triggers recomposition automatically.
- No more add() calls. The Compose runtime manages the node lifecycle.
3. Node hierarchy — NodeScope replaces addChildNode¶
// Before — imperative parent/child wiring
val parentNode = Node(engine).apply {
addChildNode(
ModelNode(modelInstance = helmet).apply {
position = Position(y = 0.1f)
}
)
}
// After — declarative nesting via NodeScope
Node(position = Position(y = 0.0f)) { // trailing lambda opens a NodeScope
ModelNode(modelInstance = helmet, position = Position(y = 0.1f))
}
Every node composable in SceneScope accepts an optional content trailing lambda. Nodes
declared inside that lambda are automatically parented to the enclosing node.
4. ARSceneView — AR nodes move into the content block¶
// Before
var anchor: Anchor? = null
ARSceneView(
modifier = Modifier.fillMaxSize(),
childNodes = rememberNodes { /* populated imperatively in onSessionUpdated */ },
onSessionUpdated = { session, frame ->
if (anchor == null) {
anchor = frame.hitTest(centerX, centerY)
.firstOrNull { it.trackable is Plane }
?.createAnchor()
?.also { a ->
childNodes.add(AnchorNode(engine, a).apply {
addChildNode(ModelNode(modelInstance = helmet))
})
}
}
}
)
// After — state drives composition
var anchor by remember { mutableStateOf<Anchor?>(null) }
ARSceneView(
modifier = Modifier.fillMaxSize(),
onSessionUpdated = { _, frame ->
if (anchor == null) {
anchor = frame.getUpdatedPlanes()
.firstOrNull { it.type == Plane.Type.HORIZONTAL_UPWARD_FACING }
?.let { it.createAnchorOrNull(it.centerPose) }
}
}
) {
anchor?.let { a ->
AnchorNode(anchor = a) {
ModelNode(modelInstance = helmet, scaleToUnits = 0.5f)
}
}
}
The anchor state variable drives everything. When anchor becomes non-null, AnchorNode enters
the composition. When it is cleared, the node is removed and destroyed automatically.
5. Model loading — rememberModelInstance replaces synchronous creation¶
// Before — blocking, called inside rememberNodes or init
val instance = modelLoader.createModelInstance("models/helmet.glb")
// After — async, null while loading
val instance = rememberModelInstance(modelLoader, "models/helmet.glb")
// instance is null until the file is loaded, then recomposition fires
rememberModelInstance reads the file on Dispatchers.IO and creates the Filament asset on the
main thread, so it is both non-blocking and thread-safe.
6. SurfaceType — replaces boolean flags¶
// Before (if the flag existed in your version)
SceneView(isOpaque = false)
// After — explicit enum
SceneView(surfaceType = SurfaceType.TextureSurface) // TextureView, supports alpha blending
SceneView(surfaceType = SurfaceType.Surface) // SurfaceView, best performance (default)
7. ViewNode — Compose UI as a 3D surface¶
ViewNode is now a first-class composable in SceneScope. It requires a WindowManager
obtained with rememberViewNodeManager().
// After
val windowManager = rememberViewNodeManager()
SceneView {
ViewNode(windowManager = windowManager) {
Card { Text("Hello from 3D!") }
}
}
8. Activity / Fragment structure¶
All samples (and the recommended app structure) have moved from Fragment + XML layout to a single
ComponentActivity with setContent { }. There is no Fragment API in 3.0.
// Before — Fragment with layout inflation
class MainFragment : Fragment() {
override fun onCreateView(...) = layoutInflater.inflate(R.layout.fragment_main, ...)
override fun onViewCreated(view: View, ...) {
val sceneView = view.findViewById<ARSceneView>(R.id.sceneView)
sceneView.onSessionUpdated = { ... }
}
}
// After — Activity with Compose
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ARSceneView(modifier = Modifier.fillMaxSize()) {
// AR content here
}
}
}
}
9. Sceneform / legacy Java classes¶
All classes that were still present under com.google.ar.sceneform.* and
io.github.sceneview.collision.* as Java files have been converted to Kotlin. The class names
and package paths are unchanged — only the file extension changes from .java to .kt.
If you were importing these classes directly (e.g. com.google.ar.sceneform.rendering.Color),
the imports continue to work. No action required.
Summary checklist¶
| Change | Action |
|---|---|
Bump dependency to 4.0.0 |
Update build.gradle |
Remove childNodes = rememberNodes { } |
Move node declarations into SceneView { } |
Replace add(ModelNode(...)) |
Use ModelNode(...) composable directly |
Replace addChildNode(...) |
Use nested NodeScope content lambda |
Replace modelLoader.createModelInstance(...) |
Use rememberModelInstance(modelLoader, path) |
Replace isOpaque = false |
Use surfaceType = SurfaceType.TextureSurface |
| Replace Fragment + XML layout | Use ComponentActivity + setContent { } |
Replace imperative anchor node wiring |
Drive with mutableStateOf<Anchor?> |
v3.1.x → v3.2.x¶
New node types (non-breaking)¶
v3.2.0 adds 8 new node composables in SceneScope. No migration required — these are additive.
| Node | Purpose |
|---|---|
PhysicsNode |
Rigid body simulation (gravity, floor collision, sleep) |
DynamicSkyNode |
Time-of-day sun positioning and coloring |
FogNode |
Atmospheric fog (density, height, color) |
ReflectionProbeNode |
Local/global IBL override zones |
LineNode |
Single line segment between two points |
PathNode |
Polyline through ordered points |
BillboardNode |
Camera-facing image quad |
TextNode |
Camera-facing text label |
Dependency management change¶
Sample apps now use the Gradle version catalog (libs.*) instead of hardcoded versions.
If you copied a sample build.gradle as a starting point, update your dependencies:
// Before (hardcoded)
implementation "androidx.compose.ui:ui:1.10.5"
implementation "androidx.compose.material3:material3:1.3.2"
// After (version catalog)
implementation libs.androidx.compose.ui
implementation libs.androidx.compose.material3
Or if you're not using a version catalog, bump to the latest versions listed in
gradle/libs.versions.toml.
Edge-to-edge¶
All sample activities now call enableEdgeToEdge() before setContent {}. If you're
building on a sample, add it to your onCreate:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { /* ... */ }
}
16 KB page-size alignment (API 35+, required for Play Store)¶
Google Play has enforced 16 KB ELF page-size alignment for all APKs targeting Android 15+
(API 35) since January 2026. SceneView adds the AGP flag to its own library modules; your
app-level build.gradle / build.gradle.kts must also opt in:
// app/build.gradle (Groovy DSL)
android {
experimentalProperties["android.nativeLibraryAlignmentPageSize"] = "16k"
// …
}
// app/build.gradle.kts (Kotlin DSL)
android {
experimentalProperties["android.nativeLibraryAlignmentPageSize"] = "16k"
// …
}
This rewrites ELF PT_LOAD alignment headers for all native .so files packaged
into the APK/AAB at build time, including the Filament and ARCore prebuilt libraries.
Without this flag, Play Store will reject the upload with:
Artifact does not support 16KB page size.