3D and AR as Jetpack Compose composables.
The #1 3D/AR SDK for Android. Built on Google Filament and ARCore.
Write a Scene { } the same way you write a Column { }. Nodes are composables.
Lifecycle is automatic. State drives everything.
Scene(modifier = Modifier.fillMaxSize()) {
rememberModelInstance(modelLoader, "models/helmet.glb")?.let { instance ->
ModelNode(modelInstance = instance, scaleToUnits = 1.0f, autoAnimate = true)
}
}Same pattern. Same Kotlin. Same mental model -- now with depth.
No engine lifecycle callbacks. No addChildNode / removeChildNode. No onResume/onPause
overrides. No manual cleanup. The Compose runtime handles all of it.
| SceneView | Sceneform | Raw Filament | Raw ARCore | Unity | Rajawali | |
|---|---|---|---|---|---|---|
| Compose-native | Yes | No | No | No | No | No |
| Actively maintained | Yes | Deprecated | Yes | Yes | Yes | Dormant |
| Declarative scene graph | Yes | No | No | No | No | No |
| glTF/GLB loading | 1 line | 10+ lines | 50+ lines | N/A | Built-in | Plugin |
| AR support | Built-in | Built-in | No | Low-level | Plugin | No |
| APK size overhead | ~5 MB | ~8 MB | ~3 MB | ~1 MB | 50+ MB | ~2 MB |
| Learning curve | Low | Medium | Very High | Very High | Medium | Medium |
| Compose lifecycle | Automatic | Manual | Manual | Manual | N/A | Manual |
| Physics | Built-in | No | No | No | Built-in | No |
| Android-native | Yes | Yes | Yes | Yes | No | Yes |
SceneView is the only Compose-native 3D/AR SDK for Android. It replaced Google Sceneform (deprecated 2021) and wraps Filament + ARCore into a high-level API that feels like writing regular Compose UI.
- Quick Start
- 3D with Compose
- AR with Compose
- SceneScope DSL Reference
- ARSceneScope DSL Reference
- Samples
- Platform Roadmap
- Made with SceneView
- Community
- Support the Project
// build.gradle.kts (app module)
dependencies {
// 3D only
implementation("io.github.sceneview:sceneview:3.2.0")
// AR + 3D (includes sceneview -- no need to add both)
implementation("io.github.sceneview:arsceneview:3.2.0")
}@Composable
fun ModelViewerScreen() {
val engine = rememberEngine()
val modelLoader = rememberModelLoader(engine)
val environmentLoader = rememberEnvironmentLoader(engine)
val modelInstance = rememberModelInstance(modelLoader, "models/damaged_helmet.glb")
val environment = rememberEnvironment(environmentLoader) {
environmentLoader.createHDREnvironment("environments/sky_2k.hdr")!!
}
Scene(
modifier = Modifier.fillMaxSize(),
engine = engine,
modelLoader = modelLoader,
environment = environment,
cameraManipulator = rememberCameraManipulator(),
mainLightNode = rememberMainLightNode(engine) { intensity = 100_000.0f },
onGestureListener = rememberOnGestureListener(
onDoubleTap = { _, node -> node?.apply { scale *= 2.0f } }
)
) {
modelInstance?.let { instance ->
ModelNode(modelInstance = instance, scaleToUnits = 1.0f, autoAnimate = true)
}
}
}That's it. Orbit camera, HDR lighting, PBR rendering, gesture interaction, automatic lifecycle management. Zero boilerplate.
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.ar" android:required="true" />
<application>
<meta-data android:name="com.google.ar.core" android:value="required" />
</application>Scene is a @Composable that renders a Filament 3D viewport. Think of it as a Box that adds
a third dimension -- everything inside its trailing block is declared with the SceneScope DSL.
rememberModelInstance returns null while the file loads on Dispatchers.IO, then triggers
recomposition. The node appears automatically when ready:
Scene {
rememberModelInstance(modelLoader, "models/helmet.glb")?.let { instance ->
ModelNode(modelInstance = instance, scaleToUnits = 0.5f)
}
}Nodes nest exactly like Compose UI -- every node accepts a content trailing lambda:
Scene {
Node(position = Position(y = 0.5f)) {
ModelNode(modelInstance = helmet)
CubeNode(size = Size(0.05f))
}
}Pass any State directly into node parameters. The scene updates on every state change:
var rotationY by remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) { while (true) { withFrameNanos { rotationY += 0.5f } } }
Scene {
ModelNode(
modelInstance = helmet,
rotation = Rotation(y = rotationY)
)
}ViewNode renders any composable onto a plane in the scene:
val windowManager = rememberViewNodeManager()
Scene(viewNodeWindowManager = windowManager) {
ViewNode(windowManager = windowManager) {
Card {
Text("Hello from 3D!")
Button(onClick = { /* ... */ }) { Text("Click me") }
}
}
}Add gravity, bounce, and collision:
Scene {
val ball = ModelNode(modelInstance = ballInstance, position = Position(y = 3f))
PhysicsNode(node = ball, restitution = 0.8f, floorY = 0f)
}Time-of-day lighting and atmospheric effects, fully reactive:
var timeOfDay by remember { mutableFloatStateOf(8f) }
Scene {
DynamicSkyNode(timeOfDay = timeOfDay, turbidity = 4f)
FogNode(view = view, density = 0.03f, height = 2f)
ModelNode(modelInstance = scene)
}
Slider(value = timeOfDay, onValueChange = { timeOfDay = it }, valueRange = 0f..24f)isEditable = true enables pinch-to-scale, drag-to-move, and two-finger-rotate:
Scene(
onGestureListener = rememberOnGestureListener(
onSingleTapConfirmed = { event, node -> println("Tapped: ${node?.name}") }
)
) {
ModelNode(modelInstance = helmet, isEditable = true)
}Scene(surfaceType = SurfaceType.Surface) // SurfaceView, best perf (default)
Scene(surfaceType = SurfaceType.TextureSurface, isOpaque = false) // TextureView, alphaARScene is Scene with ARCore wired in. The camera is driven by ARCore tracking. Everything
else is declared in the ARSceneScope content block. Normal Compose state decides what is in the scene.
var anchor by remember { mutableStateOf<Anchor?>(null) }
ARScene(
modifier = Modifier.fillMaxSize(),
planeRenderer = true,
onSessionUpdated = { _, frame ->
if (anchor == null) {
anchor = frame.getUpdatedPlanes()
.firstOrNull { it.type == Plane.Type.HORIZONTAL_UPWARD_FACING }
?.let { frame.createAnchorOrNull(it.centerPose) }
}
}
) {
anchor?.let { a ->
AnchorNode(anchor = a) {
ModelNode(modelInstance = helmet, scaleToUnits = 0.5f)
}
}
}When the plane is detected, anchor becomes non-null. Compose recomposes. AnchorNode enters
the composition. The model appears -- anchored to the physical world. When anchor is cleared, the
node is removed and destroyed automatically. Pure Compose semantics, in AR.
ARScene(
modifier = Modifier.fillMaxSize(),
engine = engine,
modelLoader = modelLoader,
cameraNode = rememberARCameraNode(engine),
planeRenderer = true,
sessionConfiguration = { session, config ->
config.depthMode =
if (session.isDepthModeSupported(Config.DepthMode.AUTOMATIC))
Config.DepthMode.AUTOMATIC
else Config.DepthMode.DISABLED
config.instantPlacementMode = Config.InstantPlacementMode.LOCAL_Y_UP
config.lightEstimationMode = Config.LightEstimationMode.ENVIRONMENTAL_HDR
},
onSessionUpdated = { _, frame -> /* per-frame AR logic */ }
) {
// ARSceneScope DSL -- all SceneScope nodes + AR-specific nodes
}ARScene(
sessionConfiguration = { session, config ->
config.augmentedImageDatabase = AugmentedImageDatabase(session).also { db ->
db.addImage("cover", coverBitmap)
}
},
onSessionUpdated = { _, frame ->
frame.getUpdatedTrackables(AugmentedImage::class.java)
.filter { it.trackingState == TrackingState.TRACKING }
.forEach { detectedImages += it }
}
) {
detectedImages.forEach { image ->
AugmentedImageNode(augmentedImage = image) {
ModelNode(modelInstance = rememberModelInstance(modelLoader, "drone.glb"))
}
}
}ARScene(
sessionFeatures = setOf(Session.Feature.FRONT_CAMERA),
sessionConfiguration = { _, config ->
config.augmentedFaceMode = Config.AugmentedFaceMode.MESH3D
},
onSessionUpdated = { session, _ ->
detectedFaces = session.getAllTrackables(AugmentedFace::class.java)
.filter { it.trackingState == TrackingState.TRACKING }
}
) {
detectedFaces.forEach { face ->
AugmentedFaceNode(augmentedFace = face, meshMaterialInstance = faceMaterial)
}
}ARScene(
sessionConfiguration = { _, config ->
config.geospatialMode = Config.GeospatialMode.ENABLED
config.streetscapeGeometryMode = Config.StreetscapeGeometryMode.ENABLED
},
onSessionUpdated = { _, frame ->
geometries = frame.getUpdatedTrackables(StreetscapeGeometry::class.java).toList()
}
) {
geometries.forEach { geo ->
StreetscapeGeometryNode(streetscapeGeometry = geo, meshMaterialInstance = buildingMat)
}
}All composables available inside Scene { }:
| Composable | Description |
|---|---|
ModelNode(modelInstance, scaleToUnits?) |
Renders a glTF/GLB model. isEditable = true for pinch-to-scale and drag-to-rotate. |
LightNode(type) |
Directional, point, spot, or sun light |
CameraNode() |
Named camera (e.g. imported from a glTF) |
CubeNode(size, materialInstance?) |
Box geometry |
SphereNode(radius, materialInstance?) |
Sphere geometry |
CylinderNode(radius, height, materialInstance?) |
Cylinder geometry |
PlaneNode(size, normal, materialInstance?) |
Flat quad geometry |
ImageNode(bitmap / fileLocation / resId) |
Image rendered on a plane |
VideoNode(player, size?) |
Video on a 3D plane with optional chroma key |
ViewNode(windowManager) { ComposeUI } |
Compose UI rendered as a 3D surface |
MeshNode(primitiveType, vertexBuffer, indexBuffer) |
Custom GPU mesh |
PhysicsNode(node, mass, restitution) |
Rigid body simulation -- gravity, floor collision, sleep detection |
DynamicSkyNode(timeOfDay, turbidity) |
Time-of-day sun light -- sunrise/sunset with warm color transitions |
FogNode(view, density, height, color) |
Atmospheric fog -- distance and height-based volumetric effect |
ReflectionProbeNode(filamentScene, environment) |
Local or global IBL (cubemap) override zones |
LineNode(start, end) |
Single 3D line segment |
PathNode(points, closed) |
3D polyline through ordered points |
BillboardNode(bitmap) |
Camera-facing image quad |
TextNode(text, fontSize, textColor) |
Camera-facing text label -- Canvas-rendered on a quad |
Node() |
Pivot / group node |
Gesture sensitivity -- Node exposes scaleGestureSensitivity: Float (default 0.5):
ModelNode(modelInstance = instance, isEditable = true, apply = {
scaleGestureSensitivity = 0.3f
editableScaleRange = 0.2f..1.0f
})ARScene { } provides everything from SceneScope plus:
| Composable | Description |
|---|---|
AnchorNode(anchor) |
Follows a real-world ARCore anchor |
PoseNode(pose) |
Follows a world-space pose (non-persistent) |
HitResultNode(xPx, yPx) |
Auto hit-tests at a screen coordinate each frame |
HitResultNode { frame -> hitResult } |
Custom hit-test lambda |
AugmentedImageNode(augmentedImage) |
Tracks a detected real-world image |
AugmentedFaceNode(augmentedFace) |
Renders a mesh aligned to a detected face |
CloudAnchorNode(anchor) |
Persistent cross-device anchor via Google Cloud |
TrackableNode(trackable) |
Follows any ARCore trackable |
StreetscapeGeometryNode(streetscapeGeometry) |
Renders a Geospatial streetscape mesh |
Try them all: Browse samples on the docs site | Download APKs from GitHub Releases
| Sample | What it shows | Links |
|---|---|---|
| Model Viewer | Orbit camera around a glTF model, HDR environment, double-tap to scale | Source |
| glTF Camera | Use a camera node imported directly from a glTF file | Source |
| Camera Manipulator | Orbit / pan / zoom camera interaction | Source |
| Autopilot Demo | Full animated scene built entirely with geometry nodes -- no model files needed | Source |
| Physics Demo | Tap to throw colored balls -- gravity, floor collision, sleep detection, bounciness control | Source |
| Dynamic Sky | Time-of-day sun cycle + turbidity + atmospheric fog slider controls | Source |
| Post-Processing | Bloom, depth of field, SSAO, vignette, fog, tone mapping, FXAA toggles | Source |
| Line & Path | 3-axis gizmo, spiral, animated sine-wave, Lissajous curves | Source |
| Text Labels | Camera-facing 3D planet labels -- tap to cycle display modes | Source |
| Reflection Probe | Metallic sphere with IBL override, material picker, roughness control | Source |
| Sample | What it shows | Links |
|---|---|---|
| AR Model Viewer | Tap-to-place on detected planes, model picker, pinch-to-scale, drag-to-rotate | Source |
| AR Augmented Image | Overlay 3D content on detected real-world images | Source |
| AR Cloud Anchors | Host and resolve persistent cross-device anchors via Google Cloud | Source |
| AR Point Cloud | Visualize ARCore feature points in real time | Source |
| Version | Focus | Status |
|---|---|---|
| 3.2.0 | Physics, dynamic sky, fog, reflections, lines, text labels | Released |
| 3.3.0 | Raycast, gesture improvements, collision callbacks, AR HDR upgrade | In progress |
| 3.4.0 | LOD, instanced rendering, preloading API | Planned |
| 4.0.0 | Multi-scene, PortalNode, Android XR / spatial computing, KMP iOS proof-of-concept |
Planned |
See ROADMAP.md for the full roadmap with details.
Using SceneView in your app? Open a PR to add it here.
| Channel | Link |
|---|---|
| Discord | Join the server -- ask questions, share projects, get help |
| GitHub Discussions | Discussions -- feature requests, Q&A |
| YouTube | SceneView tutorials |
| Website | sceneview.github.io |
- Google Filament -- real-time physically based rendering engine
- Google ARCore -- AR tracking and scene understanding
SceneView is open-source and community-funded. If it saves you time, consider giving back:
- Open Collective -- one-time or recurring support
- GitHub Sponsors -- sponsor the maintainer directly
- SceneView Merchandise
- Star the repo -- it helps discoverability
- Open a Pull Request -- contributions welcome
Built with Google Filament and ARCore. Apache 2.0 License.
