Skip to content

Recipes / Cookbook

Copy-paste patterns for the most common SceneView tasks. Every snippet targets SceneView 3.2.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)

    Scene(
        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)

    Scene(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader
    ) {
        val helmet = rememberModelInstance(modelLoader, "models/damaged_helmet.glb")
        val fox    = rememberModelInstance(modelLoader, "models/Fox.glb")

        helmet?.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()) {
        Scene(
            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)
            )
        }
    }
}

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/damaged_helmet.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()) {
        Scene(
            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.

Scene(...) {
    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") }

Scene(...) {
    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.

Scene(...) {
    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)
        )
    )

    Scene(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader,
        cameraNode = cameraNode,
        onFrame = {
            centerNode.rotation = rotation
            cameraNode.lookAt(centerNode)
        }
    ) {
        rememberModelInstance(modelLoader, "models/damaged_helmet.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"
    )

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

Camera

Orbit camera with custom home position

Use rememberCameraManipulator with orbitHomePosition and targetPosition. The user can orbit, pan, and zoom. Double-tap resets to the home position.

Scene(
    modifier = Modifier.fillMaxSize(),
    engine = engine,
    modelLoader = modelLoader,
    cameraManipulator = rememberCameraManipulator(
        orbitHomePosition = Position(x = 0f, y = 2f, z = 4f),
        targetPosition = Position(x = 0f, y = 0f, z = 0f)
    )
) {
    // nodes here
}

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))
}

Scene(
    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
Scene(
    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/damaged_helmet.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) }

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

Drag to move a node

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

Scene(...) {
    rememberModelInstance(modelLoader, "models/damaged_helmet.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.

Scene(...) {
    rememberModelInstance(modelLoader, "models/damaged_helmet.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.

Scene(
    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/damaged_helmet.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()) {
    Scene(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader,
        onGestureListener = rememberOnGestureListener(
            onLongPress = { event, node ->
                node?.let {
                    menuNode = it.name
                    showMenu = true
                }
            }
        )
    ) {
        rememberModelInstance(modelLoader, "models/damaged_helmet.glb")?.let {
            ModelNode(
                modelInstance = it,
                scaleToUnits = 1.0f,
                isTouchable = true,
                apply = { name = "helmet" }
            )
        }
    }

    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")!!
}

Scene(
    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

Scene(
    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/damaged_helmet.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) }

Scene(
    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.

Scene(
    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/damaged_helmet.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/damaged_helmet.glb")

    ARScene(
        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/damaged_helmet.glb")

    ARScene(
        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

    ARScene(
        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")

    ARScene(
        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)
        )
    }

    ARScene(
        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)
                    Scene(
                        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
        Scene(
            modifier = Modifier
                .fillMaxWidth()
                .weight(1f),
            engine = engine,
            modelLoader = modelLoader
        ) {
            rememberModelInstance(modelLoader, "models/damaged_helmet.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()) {
        Scene(
            modifier = Modifier.fillMaxSize(),
            engine = engine,
            modelLoader = modelLoader
        ) {
            rememberModelInstance(modelLoader, "models/damaged_helmet.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()

    Scene(
        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/damaged_helmet.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))
    }

    Scene(
        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)
        )
    }
}