SceneView cheat sheet

September 20, 2026 · View on GitHub

Verified against llms.txt at the repo root and the demos in samples/android-demo/src/main/java/io/github/sceneview/demo/demos/. When in doubt read the demo, do not improvise.

Top-level entrypoints

ComposableArtifactDemo
SceneView { … }io.github.sceneview:sceneview:4.38.0ModelViewerDemo.kt
ARSceneView { … }io.github.sceneview:arsceneview:4.38.0ARPlacementDemo.kt

SceneView parameters (most common)

SceneView(
    modifier = Modifier.fillMaxSize(),
    engine = rememberEngine(),
    modelLoader = rememberModelLoader(engine),
    materialLoader = rememberMaterialLoader(engine),
    environmentLoader = rememberEnvironmentLoader(engine),
    cameraNode = rememberCameraNode(engine),
    cameraManipulator = rememberCameraManipulator(),   // null to disable orbit
    environment = rememberEnvironment(environmentLoader) { /* HDR */ },
    mainLightNode = rememberMainLightNode(engine),     // null to disable
    fillLightNode = rememberFillLightNode(engine),     // null to disable
    isOpaque = true,
    frameRatePolicy = FrameRatePolicy.OnDemand(),       // default: draw on change, then park. Continuous(); maxFps caps either
    renderQuality = RenderQuality.Default,              // Cinematic / Default / Performance
    onGestureListener = rememberOnGestureListener(/* … */),
    onFrame = { frameTimeNanos -> /* … */ },
    surfaceMirrorer = rememberSurfaceMirrorer(),        // optional: record the rendered scene to MP4 in-app (no MediaProjection). Also on ARSceneView
) { /* SceneScope content */ }

Full signature is in llms.txt § Core Composables.

ARSceneView extras

ARSceneView(
    /* same as SceneView, plus: */
    planeRenderer = true,
    cameraExposure = null,                              // null = default (recommended). ABSOLUTE exposure scale (1.0 ≈ ISO 100), NOT EV stops — negative clamps to a black frame (#1179)
    sessionFeatures = setOf(/* Session.Feature.* */),
    sessionCameraConfig = ::frontCameraConfig,          // (Session) -> CameraConfig; default = ::highestResolutionCameraConfig
    sessionConfiguration = { session, config ->
        config.planeFindingMode = Config.PlaneFindingMode.HORIZONTAL_AND_VERTICAL
        config.lightEstimationMode = Config.LightEstimationMode.ENVIRONMENTAL_HDR
    },
    playbackDataset = File("arcore-session.mp4"),        // ARRecorder replay
    onSessionUpdated = { session, frame -> /* hit-test, etc. */ },
    onTrackingFailureChanged = { reason -> /* … */ },
)

There is no rememberARSession helper — configure via the sessionConfiguration lambda.

AR placement UX kit (#2241, v4.19+)

Prefer these built-ins over hand-rolled banners/reticles/shadows:

Box {
    ARSceneView(...) {
        PlacementReticle(xPx = viewWidth/2f, yPx = viewHeight/2f,
            onHitResultChanged = { reticleHit = it })      // smoothed cursor (slerp 0.75)
        detectedPlanes.forEach { key(it) { ShadowReceiverPlane(plane = it) } }  // grounded shadows
    }
    PlaneDiscoveryGuide(cameraReady, isTracking, anyPlaneTracked, failure)  // onboarding overlay
}

Never keep ShadowReceiverPlanes AND planeRenderer = true live on the same plane — the plane renderer attaches its own coplanar shadow receiver, so stacking both z-fights and double-darkens shadows (#2657). Gate them mutually exclusively (grid while scanning, shadow receivers after placement), or just use PlacementScene, which handles this for you.

PlaneDiscoveryGuide = timed hand-hint/help onboarding (replaces static "Scanning…" banners). snapToPlane=false on the reticle = free placement (points accepted, planes in-polygon). Vertical surfaces (#2740): WallPlacementScene(mountHeight = …, onSeamChanged = …, onPlaced = …) — wall-flush upright orientation + floor-relative height; exposes the floor↔wall seam for the "align to the edge" guide. Pure math helpers: wallFacingRotation / roomFacingNormal / floorWallSeam / wallAnchorPose. Grounding (#2740): ContactShadow(size, context = ContactShadowContext.Wall|Floor|TableTop, normal) — procedural gradient, no shadow map, so it works on a WALL where a real shadow can't (ceiling light grazes it). size decides the quad plane and must agree with normal: Size(x, 0f, z)+Direction(y=1f) floor, Size(x, y, 0f)+Direction(z=1f) wall. In sceneview, not arsceneview. Real floor shadows stay ShadowReceiverPlane. iOS: coaching overlay = ARSceneView(showCoachingOverlay: true) (native); reticle = showPlacementReticle: true, contact shadows = groundingShadows (default on) — #894 shipped.

Remember helpers (always use these)

HelperReturnsNotes
rememberEngine()EngineOne per SceneView
rememberModelLoader(engine)ModelLoaderAsync GLB/GLTF and .3mf loader — 3MF is detected by ZIP magic and converted in memory, so there is no separate call (#3482)
rememberMaterialLoader(engine)MaterialLoader.filamat loader
rememberEnvironmentLoader(engine)EnvironmentLoaderIBL loader
rememberCameraNode(engine) { … }CameraNodeConfigure in trailing lambda
rememberModelInstance(modelLoader, "asset.glb")ModelInstance?Nullable while loading. Takes .glb, .gltf or .3mf — asset path, URL, or a file:// / content:// URI shared in from another app
rememberMainLightNode(engine) { … }LightNodeDefault key light (use null to disable)
rememberFillLightNode(engine) { … }LightNodeDefault fill (use null to disable)
rememberCameraManipulator(orbitRadius = 2.5f)CameraGestureDetector.CameraManipulator?Orbit/pan controller, camera orbitRadius m from the target; null to lock. orbitHomePosition = Position(…) for an explicit eye (no "home" gesture exists despite the name)
rememberOnGestureListener(onSingleTapConfirmed = …)GestureDetector.OnGestureListenerWire AR taps
rememberARRecorder()ARRecorderRecord/replay AR sessions — ARCore dataset for deterministic replay (no args)
rememberSurfaceMirrorer()SurfaceMirrorerRecord the rendered scene to MP4 in-app (no MediaProjection). Pass to SceneView/ARSceneView(surfaceMirrorer = …), then startMirroring(surface) / stopMirroring(surface)

Common 3D nodes

NodeWhere verifiedNotes
ModelNode(modelInstance, scaleToUnits, centerOrigin, isEditable)ModelViewerDemo.ktRender a GLB. isEditable = true enables drag/scale/rotate
LightNode(type, intensity, direction, position, color, apply = { … })LightingDemo.kttype is LightManager.Type.POINT / SPOT / DIRECTIONAL / FOCUSED_SPOT / SUN
CubeNode / SphereNode / CylinderNode / PlaneNode / CapsuleNode / TorusNode / ConeNodeGeometryDemo.kt, LightingDemo.kt (Sphere)Each takes materialInstance and shape-specific dimensions (e.g. SphereNode(radius, ...))
BillboardNodeARMLObjectLabelDemo.ktAlways faces the camera — but ONLY when handed a cameraPositionProvider. Without one it never turns
ImageNodeARImageDemo.kt, LightingLabDemo.kt2D image quad. size = null normalises the bitmap's longest edge to 1 unit
TextNodeARMeasureDemo.kt3D text. Uses widthMeters / heightMeters, NOT scaleToUnits. Billboards only with a cameraPositionProvider, same as BillboardNode
ViewNodeTwoDInThreeDDemo.kt, PickingAndCollisionDemo.ktEmbeds a Compose UI inside 3D. Requires viewNodeWindowManager on the SceneView, an explicit content size, and the theme re-applied inside (it inherits no CompositionLocal). Interactive since #2845: Button.onClick fires. A Surface/Card consumes the touch even when nothing inside is clickable, and a consumed touch never reaches onSingleTapUp — opt out per node with isTouchForwardingEnabled = false. Always-on-top = materialInstance.setDepthCulling(false) and setPriority(PRIORITY_LAST)
TubeNode(points, radius, closed, caps)LinesPathsDemo.ktA polyline with a real width — the one to reach for. Sweeps a cross-section of radius metres along points
LineNode / PathNodePrimitiveType.LINES: a 1-device-pixel hairline with no width control, invisible at phone density (#3397). Debug gizmos only — use TubeNode for anything a user sees
PhysicsNode(node, mass, restitution, …)AnimationPhysicsDemo.ktWraps an existing node; experimental (Physics tab of unified Animation & Physics demo)
ReflectionProbeNodeLightingLabDemo.ktLocal IBL probe (Reflections tab of unified Lighting Lab demo)

For collision, use the rememberCollisionSystem(view) helper (it's already plumbed by default in SceneView's param list above) — not a node type. See PickingAndCollisionDemo.kt (the unified Picking & Collision demo — one scene, shapes + card) for the API in action.

AR-only nodes (in arsceneview)

NodeWhere verifiedNotes
AnchorNode(anchor: Anchor) { … }ARPlacementDemo.ktWraps a com.google.ar.core.Anchor
AugmentedImageNode(augmentedImage = …) { … }ARImageDemo.ktTracked image marker — takes the detected AugmentedImage trackable
AugmentedFaceNode(augmentedFace = …) { … }ARFaceDemo.ktFace mesh overlay — takes the detected AugmentedFace trackable
HitResultNode(xPx, yPx) { … }llms.txt § HitResultNodeContinuous screen-coordinate surface cursor; for one-shot taps prefer frame.hitTest(event) + AnchorNode

There are NO AnchorNode.image() / .face() / .plane() / .body() factory functions on Android in v4.2. Those are iOS-only via SceneViewSwift.

Threading rule

Filament JNI is main-thread-only. Use the remember* helpers (they handle this). For imperative code, use modelLoader.loadModelInstanceAsync.

Apple parity

iOS / macOS / visionOS export SceneView { } and ARSceneView { } from the SceneViewSwift package with SwiftUI semantics (@NodeBuilder, modifier-style configuration). The API names overlap but the SwiftUI shape differs — never copy a Kotlin snippet verbatim to Swift. See docs/docs/cheatsheet-ios.md.