Skip to content

Recipes / Cookbook

Copy-paste patterns for the most common SceneView tasks. Every snippet targets SceneView 4.63.0 and uses Jetpack Compose.


Loading & Display

Load a model from a URL

rememberModelInstance accepts both asset paths and https URLs. It returns null while the file downloads, so always handle the null case.

@Composable
fun RemoteModelScreen() {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)

    SceneView(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader
    ) {
        val instance = rememberModelInstance(
            modelLoader,
            "https://example.com/models/robot.glb"
        )
        instance?.let {
            ModelNode(modelInstance = it, scaleToUnits = 1.0f)
        }
    }
}

Load multiple models

Call rememberModelInstance once per model. Each loads independently and appears when ready.

@Composable
fun MultiModelScreen() {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)

    SceneView(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader
    ) {
        val toyCar = rememberModelInstance(modelLoader, "models/toy_car.glb")
        val fox    = rememberModelInstance(modelLoader, "models/Fox.glb")

        toyCar?.let {
            ModelNode(
                modelInstance = it,
                scaleToUnits = 1.0f,
                position = Position(x = -1f, z = -2f)
            )
        }
        fox?.let {
            ModelNode(
                modelInstance = it,
                scaleToUnits = 1.0f,
                position = Position(x = 1f, z = -2f),
                autoAnimate = true
            )
        }
    }
}

Show a loading indicator while model loads

rememberModelInstance returns null while loading. Use that to drive a Compose overlay.

@Composable
fun ModelWithLoadingIndicator() {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)

    val instance = rememberModelInstance(modelLoader, "models/large_scene.glb")

    Box(modifier = Modifier.fillMaxSize()) {
        SceneView(
            modifier = Modifier.fillMaxSize(),
            engine = engine,
            modelLoader = modelLoader
        ) {
            instance?.let {
                ModelNode(modelInstance = it, scaleToUnits = 1.0f)
            }
        }

        // Overlay a spinner while the model is still null
        if (instance == null) {
            CircularProgressIndicator(
                modifier = Modifier.align(Alignment.Center)
            )
        }
    }
}

Show load progress and errors for large models

Generated or scanned models are often 5–60 MB with 4K textures. rememberModelLoadState reports how far the load is (Reading the file, Creating the asset, decoding Textures) and why it failed, instead of a bare null. Ready normally arrives once every texture is final, so the model does not pop in untextured. The exception is a stall: if no texture makes progress for 10 s, Ready arrives with a texture possibly still decoding, and the stall is logged.

@Composable
fun GeneratedModel(url: String) {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)

    when (val state = rememberModelLoadState(modelLoader, url)) {
        is ModelLoadState.Loading -> LinearProgressIndicator(
            progress = { state.progress.fraction }
        )
        is ModelLoadState.Ready -> SceneView(
            modifier = Modifier.fillMaxSize(),
            engine = engine,
            modelLoader = modelLoader
        ) {
            ModelNode(modelInstance = state.instance, scaleToUnits = 1.0f)
        }
        is ModelLoadState.Failed -> Text(
            if (state.error is UnsupportedGltfExtensionException) "This model format is not supported"
            else "Could not load the model"
        )
    }
}

Outside Compose, modelLoader.awaitModelInstance(location) { progress -> } does the same: it returns with the textures final (or after such a stall) and throws on failure.

Switch between models dynamically

Change the asset path via Compose state. rememberModelInstance automatically loads the new model when the path changes.

private val models = listOf(
    "models/toy_car.glb" to 1.0f,
    "models/Fox.glb" to 0.012f,
)

@Composable
fun ModelSwitcherScreen() {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)

    var selectedIndex by remember { mutableIntStateOf(0) }
    val (path, scale) = models[selectedIndex]
    val instance = rememberModelInstance(modelLoader, path)

    Box(modifier = Modifier.fillMaxSize()) {
        SceneView(
            modifier = Modifier.fillMaxSize(),
            engine = engine,
            modelLoader = modelLoader
        ) {
            instance?.let {
                ModelNode(modelInstance = it, scaleToUnits = scale)
            }
        }

        Row(
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .padding(16.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            models.forEachIndexed { index, (name, _) ->
                FilterChip(
                    selected = index == selectedIndex,
                    onClick = { selectedIndex = index },
                    label = { Text(name.substringAfterLast("/")) }
                )
            }
        }
    }
}

Animation

Auto-play all animations

Set autoAnimate = true on ModelNode. All glTF animations play simultaneously.

SceneView(...) {
    rememberModelInstance(modelLoader, "models/Fox.glb")?.let { instance ->
        ModelNode(
            modelInstance = instance,
            scaleToUnits = 1.0f,
            autoAnimate = true
        )
    }
}

Play a specific animation by name

Set autoAnimate = false and pass the animation name. The name must match one defined in the glTF file.

var currentAnimation by remember { mutableStateOf("Walk") }

SceneView(...) {
    rememberModelInstance(modelLoader, "models/Fox.glb")?.let { instance ->
        ModelNode(
            modelInstance = instance,
            scaleToUnits = 1.0f,
            autoAnimate = false,
            animationName = currentAnimation,
            animationLoop = true,
            animationSpeed = 1f
        )
    }
}

// Change currentAnimation to "Idle", "Run", etc. to switch animations.

Loop an animation

Set animationLoop = true. Works with both autoAnimate and named animations.

SceneView(...) {
    rememberModelInstance(modelLoader, "models/Fox.glb")?.let { instance ->
        ModelNode(
            modelInstance = instance,
            scaleToUnits = 1.0f,
            autoAnimate = false,
            animationName = "Walk",
            animationLoop = true,
            animationSpeed = 1.5f  // 1.5x speed
        )
    }
}

Rotate a model continuously

Use Compose's rememberInfiniteTransition with SceneView's animateRotation extension, then apply the rotation via an onFrame callback or a parent node.

@Composable
fun SpinningModelScreen() {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)

    val centerNode = rememberNode(engine)

    val cameraNode = rememberCameraNode(engine) {
        position = Position(y = -0.5f, z = 2.0f)
        lookAt(centerNode)
        centerNode.addChildNode(this)
    }

    val transition = rememberInfiniteTransition(label = "Spin")
    val rotation by transition.animateRotation(
        initialValue = Rotation(y = 0f),
        targetValue = Rotation(y = 360f),
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 7000)
        )
    )

    SceneView(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader,
        cameraNode = cameraNode,
        onFrame = {
            centerNode.rotation = rotation
            cameraNode.lookAt(centerNode)
        }
    ) {
        rememberModelInstance(modelLoader, "models/toy_car.glb")?.let {
            ModelNode(modelInstance = it, scaleToUnits = 1.0f)
        }
    }
}

Animate position with Compose

Drive a node's position from standard Compose animation APIs.

@Composable
fun BouncingModelScreen() {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)

    val transition = rememberInfiniteTransition(label = "Bounce")
    val yOffset by transition.animateFloat(
        initialValue = 0f,
        targetValue = 0.5f,
        animationSpec = infiniteRepeatable(
            animation = tween(1000, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "Y"
    )

    SceneView(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader
    ) {
        rememberModelInstance(modelLoader, "models/toy_car.glb")?.let {
            ModelNode(
                modelInstance = it,
                scaleToUnits = 1.0f,
                position = Position(y = yOffset, z = -2f)
            )
        }
    }
}

Camera

Orbit camera at a chosen distance

Use rememberCameraManipulator with orbitRadius — the camera starts that many metres from the target, along the default 3/4 viewing angle (orbitRadius = 2.78f is the stock framing). The user can orbit, pan, and zoom.

SceneView(
    modifier = Modifier.fillMaxSize(),
    engine = engine,
    modelLoader = modelLoader,
    cameraManipulator = rememberCameraManipulator(orbitRadius = 4.5f)
) {
    // nodes here
}

To place the eye yourself, pass orbitHomePosition and targetPosition instead (same behaviour as before 4.32; the name is kept as-is rather than renamed to eyePosition, since major version 4 forbids the binary break a rename of this Composable parameter would cause — there was never a "home" gesture):

cameraManipulator = rememberCameraManipulator(
    // Absolute eye position. Targets the origin here, so |orbitHomePosition| ≈ 4.47 m
    // is the distance the subject is framed from.
    orbitHomePosition = Position(x = 0f, y = 2f, z = 4f),
    targetPosition = Position(x = 0f, y = 0f, z = 0f)
)

orbitHomePosition is an absolute eye position, and its length is your framing distance

Filament's OrbitManipulator uses orbitHomePosition verbatim as the eye (mEye = mProps.orbitHomePosition, default (0, 0, 1)); it is never re-based on targetPosition, which only sets the orbit pivot and the initial look direction.

On top of that, SceneView's default autoCenterContent = true translates the DSL content so its bounding-box centre lands on the world origin. The distance your subject is framed from is therefore |orbitHomePosition| — the coordinates you gave your nodes do not survive, and targetPosition does not enter into it:

// Frames from |(0, 0.2, 1.2)| ≈ 1.22 m — NOT the 2.7 m that
// |orbitHomePosition − targetPosition| suggests.
rememberCameraManipulator(
    orbitHomePosition = Position(0f, 0.2f, 1.2f),
    targetPosition = Position(0f, 0f, -1.5f)
)

Prefer rememberCameraManipulator(orbitRadius = …), which takes the distance directly; otherwise pass a vector of that length, or set autoCenterContent = false so authored world positions survive (the framing distance then becomes |orbitHomePosition − contentCentre|). Both readings coincide when the target is the origin — which is why every example on this page looks fine and why the discrepancy went unnoticed until #2873.

There is no built-in gesture that returns the camera to orbitHomePosition: onDoubleTap is a plain callback SceneView forwards to your code, never wired to the camera.

Fixed camera looking at a point

Use rememberCameraNode instead of a manipulator for a static viewpoint.

val cameraNode = rememberCameraNode(engine) {
    position = Position(x = 3f, y = 2f, z = 5f)
    lookAt(Position(0f, 0f, 0f))
}

SceneView(
    modifier = Modifier.fillMaxSize(),
    engine = engine,
    modelLoader = modelLoader,
    cameraNode = cameraNode,
    cameraManipulator = null  // disable orbit gestures
) {
    // nodes here
}

Smooth camera transition

Use node.transform() with smooth = true to animate the camera to a new position.

val cameraNode = rememberCameraNode(engine) {
    position = Position(0f, 2f, 5f)
    lookAt(Position(0f, 0f, 0f))
}

// Call this from a button click or any event:
fun flyToPosition(target: Position) {
    cameraNode.transform(
        position = target,
        smooth = true,
        smoothSpeed = 3f
    )
}

Limit zoom range

Create the manipulator with a custom builder to control zoom speed. Combine with editableScaleRange on interactive nodes to limit pinch-to-zoom on individual objects.

// On the scene level — control orbit camera zoom speed
SceneView(
    modifier = Modifier.fillMaxSize(),
    engine = engine,
    modelLoader = modelLoader,
    cameraManipulator = rememberCameraManipulator(
        orbitHomePosition = Position(0f, 1f, 3f),
        targetPosition = Position(0f, 0f, 0f)
    )
) {
    // On a per-node level — clamp pinch-to-scale range
    rememberModelInstance(modelLoader, "models/toy_car.glb")?.let {
        ModelNode(
            modelInstance = it,
            scaleToUnits = 1.0f,
            isEditable = true,
            apply = { editableScaleRange = 0.5f..2.0f }
        )
    }
}

Interaction

Tap to select a node

Use onGestureListener with onSingleTapConfirmed. The node parameter is the tapped node (or null if the user tapped empty space).

var selectedNode by remember { mutableStateOf<String?>(null) }

SceneView(
    modifier = Modifier.fillMaxSize(),
    engine = engine,
    modelLoader = modelLoader,
    onGestureListener = rememberOnGestureListener(
        onSingleTapConfirmed = { event, node ->
            selectedNode = node?.name
        }
    )
) {
    rememberModelInstance(modelLoader, "models/toy_car.glb")?.let {
        ModelNode(
            modelInstance = it,
            scaleToUnits = 1.0f,
            isTouchable = true,
            apply = { name = "toy_car" }
        )
    }
}

Drag to move a node

Set isEditable = true on the node. Single-finger drag moves the node in the scene.

SceneView(...) {
    rememberModelInstance(modelLoader, "models/toy_car.glb")?.let {
        ModelNode(
            modelInstance = it,
            scaleToUnits = 1.0f,
            isEditable = true  // enables drag, pinch-scale, and two-finger rotate
        )
    }
}

Pinch to scale

isEditable = true enables pinch-to-scale automatically. Use editableScaleRange to clamp the allowed range.

SceneView(...) {
    rememberModelInstance(modelLoader, "models/toy_car.glb")?.let {
        ModelNode(
            modelInstance = it,
            scaleToUnits = 0.5f,
            isEditable = true,
            apply = {
                editableScaleRange = 0.2f..2.0f
            }
        )
    }
}

Double-tap to reset

Use the onDoubleTap gesture callback to reset a node's transform.

SceneView(
    modifier = Modifier.fillMaxSize(),
    engine = engine,
    modelLoader = modelLoader,
    onGestureListener = rememberOnGestureListener(
        onDoubleTap = { event, node ->
            node?.apply {
                position = Position(0f, 0f, -2f)
                rotation = Rotation(0f)
                scale = Scale(1f)
            }
        }
    )
) {
    rememberModelInstance(modelLoader, "models/toy_car.glb")?.let {
        ModelNode(
            modelInstance = it,
            scaleToUnits = 1.0f,
            isEditable = true,
            isTouchable = true
        )
    }
}

Long-press context menu

Combine onLongPress with Compose state to show a dropdown or bottom sheet.

var showMenu by remember { mutableStateOf(false) }
var menuNode by remember { mutableStateOf<String?>(null) }

Box(modifier = Modifier.fillMaxSize()) {
    SceneView(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader,
        onGestureListener = rememberOnGestureListener(
            onLongPress = { event, node ->
                node?.let {
                    menuNode = it.name
                    showMenu = true
                }
            }
        )
    ) {
        rememberModelInstance(modelLoader, "models/toy_car.glb")?.let {
            ModelNode(
                modelInstance = it,
                scaleToUnits = 1.0f,
                isTouchable = true,
                apply = { name = "toy_car" }
            )
        }
    }

    DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
        DropdownMenuItem(text = { Text("Delete ${menuNode}") }, onClick = { showMenu = false })
        DropdownMenuItem(text = { Text("Duplicate") }, onClick = { showMenu = false })
    }
}

Lighting & Environment

HDR environment from assets

Place your .hdr file in the assets/environments/ folder.

val engine = rememberEngine()
val environmentLoader = rememberEnvironmentLoader(engine)

val environment = rememberEnvironment(environmentLoader) {
    environmentLoader.createHDREnvironment("environments/sky_2k.hdr")
        ?: createEnvironment(environmentLoader)
}

SceneView(
    modifier = Modifier.fillMaxSize(),
    engine = engine,
    modelLoader = rememberModelLoader(engine),
    environment = environment,
    mainLightNode = rememberMainLightNode(engine) { intensity = 100_000f }
) {
    // nodes here
}

Dynamic time-of-day lighting

Use DynamicSkyNode to simulate a sun that moves across the sky.

var timeOfDay by remember { mutableFloatStateOf(14f) } // 0-24

SceneView(
    modifier = Modifier.fillMaxSize(),
    engine = engine,
    modelLoader = modelLoader
) {
    DynamicSkyNode(
        timeOfDay = timeOfDay,   // 0=midnight, 6=sunrise, 12=noon, 18=sunset
        turbidity = 2f,          // atmospheric haze [1-10]
        sunIntensity = 110_000f
    )

    rememberModelInstance(modelLoader, "models/toy_car.glb")?.let {
        ModelNode(modelInstance = it, scaleToUnits = 1.0f)
    }
}

// Drive timeOfDay from a Slider, animation, or system clock.

Add fog

Use FogNode with a rememberView reference.

val engine = rememberEngine()
val view = rememberView(engine)

var fogEnabled by remember { mutableStateOf(true) }

SceneView(
    modifier = Modifier.fillMaxSize(),
    engine = engine,
    modelLoader = rememberModelLoader(engine),
    view = view
) {
    FogNode(
        view = view,
        density = 0.05f,
        height = 1.0f,
        color = Color(0xFFCCDDFF),
        enabled = fogEnabled
    )

    // scene content...
}

Multiple lights in a scene

Combine the main directional light with additional point or spot lights using LightNode. Remember: apply is a named parameter, not a trailing lambda.

SceneView(
    modifier = Modifier.fillMaxSize(),
    engine = engine,
    modelLoader = modelLoader,
    mainLightNode = rememberMainLightNode(engine) { intensity = 50_000f }
) {
    // Warm point light on the left
    LightNode(
        type = LightManager.Type.POINT,
        position = Position(x = -2f, y = 2f, z = 0f),
        apply = {
            color(1.0f, 0.8f, 0.6f)
            intensity(80_000f)
            falloff(10.0f)
        }
    )

    // Cool point light on the right
    LightNode(
        type = LightManager.Type.POINT,
        position = Position(x = 2f, y = 2f, z = 0f),
        apply = {
            color(0.6f, 0.8f, 1.0f)
            intensity(80_000f)
            falloff(10.0f)
        }
    )

    // Spot light from above
    LightNode(
        type = LightManager.Type.FOCUSED_SPOT,
        position = Position(y = 3f),
        apply = {
            intensity(100_000f)
            falloff(8.0f)
            castShadows(true)
        }
    )

    rememberModelInstance(modelLoader, "models/toy_car.glb")?.let {
        ModelNode(modelInstance = it, scaleToUnits = 1.0f)
    }
}

AR Patterns

Tap-to-place on a plane

Tap the screen to place a model on a detected AR plane.

@Composable
fun TapToPlaceScreen() {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)

    var anchor by remember { mutableStateOf<Anchor?>(null) }
    var frame by remember { mutableStateOf<Frame?>(null) }

    val instance = rememberModelInstance(modelLoader, "models/toy_car.glb")

    ARSceneView(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader,
        planeRenderer = true,
        sessionConfiguration = { session, config ->
            config.depthMode = Config.DepthMode.AUTOMATIC
            config.instantPlacementMode = Config.InstantPlacementMode.LOCAL_Y_UP
            config.lightEstimationMode = Config.LightEstimationMode.ENVIRONMENTAL_HDR
        },
        onSessionUpdated = { _, updatedFrame -> frame = updatedFrame },
        onGestureListener = rememberOnGestureListener(
            onSingleTapConfirmed = { event, node ->
                if (node == null) {
                    frame?.hitTest(event.x, event.y)
                        ?.firstOrNull { it.isValid(depthPoint = false, point = false) }
                        ?.createAnchorOrNull()
                        ?.let { anchor = it }
                }
            }
        )
    ) {
        anchor?.let { a ->
            AnchorNode(anchor = a) {
                instance?.let {
                    ModelNode(
                        modelInstance = it,
                        scaleToUnits = 0.5f,
                        isEditable = true
                    )
                }
            }
        }
    }
}

Place multiple objects

Store a list of anchors. Each tap adds a new anchor with its own model.

@Composable
fun MultiPlaceScreen() {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)

    var anchors by remember { mutableStateOf(listOf<Anchor>()) }
    var frame by remember { mutableStateOf<Frame?>(null) }

    val instance = rememberModelInstance(modelLoader, "models/toy_car.glb")

    ARSceneView(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader,
        planeRenderer = true,
        sessionConfiguration = { session, config ->
            config.instantPlacementMode = Config.InstantPlacementMode.LOCAL_Y_UP
            config.lightEstimationMode = Config.LightEstimationMode.ENVIRONMENTAL_HDR
        },
        onSessionUpdated = { _, updatedFrame -> frame = updatedFrame },
        onGestureListener = rememberOnGestureListener(
            onSingleTapConfirmed = { event, node ->
                if (node == null) {
                    frame?.hitTest(event.x, event.y)
                        ?.firstOrNull { it.isValid(depthPoint = false, point = false) }
                        ?.createAnchorOrNull()
                        ?.let { anchors = anchors + it }
                }
            }
        )
    ) {
        anchors.forEach { a ->
            AnchorNode(anchor = a) {
                instance?.let {
                    ModelNode(
                        modelInstance = it,
                        scaleToUnits = 0.3f,
                        isEditable = true
                    )
                }
            }
        }
    }
}

Show a reticle cursor

Use HitResultNode with the screen center coordinates to show a cursor that tracks the detected surface.

@Composable
fun ReticleScreen() {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)
    val materialLoader = rememberMaterialLoader(engine)
    val view = LocalView.current

    ARSceneView(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader,
        materialLoader = materialLoader,
        planeRenderer = true,
        sessionConfiguration = { _, config ->
            config.lightEstimationMode = Config.LightEstimationMode.ENVIRONMENTAL_HDR
        }
    ) {
        HitResultNode(
            xPx = view.width / 2f,
            yPx = view.height / 2f
        ) {
            SphereNode(radius = 0.02f)
        }
    }
}

Track a real-world image

Use AugmentedImageNode to overlay 3D content on a detected real-world image.

@Composable
fun ImageTrackingScreen() {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)
    val context = LocalContext.current

    var augmentedImages by remember {
        mutableStateOf<Map<String, AugmentedImage>>(emptyMap())
    }

    val instance = rememberModelInstance(modelLoader, "models/rabbit.glb")

    ARSceneView(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader,
        sessionConfiguration = { session, config ->
            config.addAugmentedImage(
                session,
                "target",
                context.assets.open("augmentedimages/target.jpg")
                    .use(BitmapFactory::decodeStream)
            )
        },
        onSessionUpdated = { _, frame ->
            frame.getUpdatedAugmentedImages().forEach { image ->
                augmentedImages = augmentedImages.toMutableMap().apply {
                    this[image.name] = image
                }
            }
        }
    ) {
        augmentedImages.values.forEach { image ->
            AugmentedImageNode(augmentedImage = image) {
                instance?.let {
                    ModelNode(
                        modelInstance = it,
                        scaleToUnits = image.extentX
                    )
                }
            }
        }
    }
}

Face filter with front camera

Use the front camera with AugmentedFaceNode for face mesh effects.

@Composable
fun FaceFilterScreen() {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)
    val materialLoader = rememberMaterialLoader(engine)

    var trackedFaces by remember {
        mutableStateOf(listOf<AugmentedFace>())
    }

    val faceMaterial = remember(materialLoader) {
        materialLoader.createColorInstance(
            colorOf(r = 0.5f, g = 0.8f, b = 1.0f, a = 0.4f)
        )
    }

    ARSceneView(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader,
        materialLoader = materialLoader,
        sessionFeatures = setOf(Session.Feature.FRONT_CAMERA),
        sessionConfiguration = { _, config ->
            config.augmentedFaceMode = Config.AugmentedFaceMode.MESH3D
        },
        onSessionUpdated = { session, _ ->
            trackedFaces = session.getAllTrackables(AugmentedFace::class.java)
                .filter { it.trackingState == TrackingState.TRACKING }
        }
    ) {
        trackedFaces.forEach { face ->
            AugmentedFaceNode(
                augmentedFace = face,
                meshMaterialInstance = faceMaterial
            )
        }
    }
}

Layout & Composition

3D viewer in a scrollable list

Wrap the Scene in a fixed-height container inside a LazyColumn.

@Composable
fun ProductListScreen(products: List<Product>) {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)

    LazyColumn(modifier = Modifier.fillMaxSize()) {
        items(products) { product ->
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp)
            ) {
                Column {
                    val instance = rememberModelInstance(modelLoader, product.modelPath)
                    SceneView(
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(250.dp),
                        engine = engine,
                        modelLoader = modelLoader,
                        cameraManipulator = rememberCameraManipulator(
                            orbitHomePosition = Position(0f, 0.5f, 2f),
                            targetPosition = Position(0f)
                        )
                    ) {
                        instance?.let {
                            ModelNode(
                                modelInstance = it,
                                scaleToUnits = 1.0f
                            )
                        }
                    }
                    Text(
                        product.name,
                        modifier = Modifier.padding(16.dp),
                        style = MaterialTheme.typography.titleMedium
                    )
                }
            }
        }
    }
}

Split screen: 3D + Compose UI

Use a Column or Row to place the 3D viewport alongside regular Compose UI.

@Composable
fun SplitScreen() {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)
    var scale by remember { mutableFloatStateOf(1.0f) }

    Column(modifier = Modifier.fillMaxSize()) {
        // Top half: 3D scene
        SceneView(
            modifier = Modifier
                .fillMaxWidth()
                .weight(1f),
            engine = engine,
            modelLoader = modelLoader
        ) {
            rememberModelInstance(modelLoader, "models/toy_car.glb")?.let {
                ModelNode(modelInstance = it, scaleToUnits = scale)
            }
        }

        // Bottom half: Controls
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .weight(1f)
                .padding(16.dp)
        ) {
            Text("Scale: %.1f".format(scale))
            Slider(
                value = scale,
                onValueChange = { scale = it },
                valueRange = 0.1f..3.0f
            )
        }
    }
}

Overlay Compose UI on 3D scene

Use a Box to layer Compose widgets on top of the Scene.

@Composable
fun OverlayScreen() {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)

    Box(modifier = Modifier.fillMaxSize()) {
        SceneView(
            modifier = Modifier.fillMaxSize(),
            engine = engine,
            modelLoader = modelLoader
        ) {
            rememberModelInstance(modelLoader, "models/toy_car.glb")?.let {
                ModelNode(modelInstance = it, scaleToUnits = 1.0f)
            }
        }

        // Floating action button overlay
        FloatingActionButton(
            onClick = { /* action */ },
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(16.dp)
        ) {
            Icon(Icons.Default.Add, contentDescription = "Add")
        }

        // Top status bar overlay
        Surface(
            modifier = Modifier
                .align(Alignment.TopCenter)
                .padding(top = 48.dp),
            color = Color.Black.copy(alpha = 0.5f),
            shape = RoundedCornerShape(50)
        ) {
            Text(
                "Model Viewer",
                color = Color.White,
                modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)
            )
        }
    }
}

ViewNode: Compose inside 3D space

Use ViewNode to render Compose UI as a texture mapped onto a plane in the 3D scene.

@Composable
fun ViewNodeScreen() {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)
    val windowManager = rememberViewNodeManager()

    SceneView(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader,
        viewNodeWindowManager = windowManager
    ) {
        ViewNode(windowManager = windowManager) {
            Card(
                modifier = Modifier.padding(8.dp),
                colors = CardDefaults.cardColors(
                    containerColor = Color.White.copy(alpha = 0.9f)
                )
            ) {
                Column(modifier = Modifier.padding(16.dp)) {
                    Text("Hello 3D World!", style = MaterialTheme.typography.titleLarge)
                    Text("This is a Compose Card rendered in 3D space.")
                }
            }
        }

        rememberModelInstance(modelLoader, "models/toy_car.glb")?.let {
            ModelNode(
                modelInstance = it,
                scaleToUnits = 1.0f,
                position = Position(x = 1f)
            )
        }
    }
}

Materials

Create a solid color material

Use materialLoader.createColorInstance() to create a material with a flat color. Use colorOf() to convert from Compose Color.

val engine = rememberEngine()
val materialLoader = rememberMaterialLoader(engine)

val redMaterial = remember(materialLoader) {
    materialLoader.createColorInstance(colorOf(Color.Red))
}

val customMaterial = remember(materialLoader) {
    materialLoader.createColorInstance(
        colorOf(r = 0.2f, g = 0.6f, b = 1.0f, a = 1.0f)
    )
}

Apply a material to geometry nodes

Pass the materialInstance to any geometry node: CubeNode, SphereNode, CylinderNode, PlaneNode, LineNode, or PathNode.

@Composable
fun MaterialDemoScreen() {
    val engine = rememberEngine()
    val materialLoader = rememberMaterialLoader(engine)

    val redMaterial = remember(materialLoader) {
        materialLoader.createColorInstance(colorOf(Color.Red))
    }
    val blueMaterial = remember(materialLoader) {
        materialLoader.createColorInstance(colorOf(Color.Blue))
    }
    val greenMaterial = remember(materialLoader) {
        materialLoader.createColorInstance(colorOf(Color.Green))
    }

    SceneView(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = rememberModelLoader(engine),
        materialLoader = materialLoader
    ) {
        CubeNode(
            size = Size(0.5f, 0.5f, 0.5f),
            materialInstance = redMaterial,
            position = Position(x = -1f, z = -2f)
        )
        SphereNode(
            radius = 0.3f,
            materialInstance = blueMaterial,
            position = Position(x = 0f, z = -2f)
        )
        CylinderNode(
            radius = 0.2f,
            height = 0.8f,
            materialInstance = greenMaterial,
            position = Position(x = 1f, z = -2f)
        )
        PlaneNode(
            size = Size(5f, 5f),
            materialInstance = remember(materialLoader) {
                materialLoader.createColorInstance(colorOf(rgb = 0.3f))
            },
            position = Position(y = -0.5f)
        )
    }
}