Skip to content

Integrations

How to use SceneView with the rest of your Android app stack.


Jetpack Compose Navigation

Use SceneView inside navigation destinations. The scene is created when you navigate to it and destroyed when you leave — no manual cleanup.

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(navController, startDestination = "home") {
        composable("home") {
            HomeScreen(onViewProduct = { id ->
                navController.navigate("product/$id")
            })
        }
        composable("product/{id}") { backStackEntry ->
            val productId = backStackEntry.arguments?.getString("id") ?: return@composable
            ProductViewerScreen(productId)
        }
        composable("ar-preview") {
            ARPreviewScreen()
        }
    }
}

@Composable
fun ProductViewerScreen(productId: String) {
    val engine = rememberEngine()
    val modelLoader = rememberModelLoader(engine)
    val model = rememberModelInstance(modelLoader, "models/$productId.glb")

    Scene(
        modifier = Modifier.fillMaxSize(),
        engine = engine,
        modelLoader = modelLoader,
        cameraManipulator = rememberCameraManipulator()
    ) {
        model?.let { ModelNode(modelInstance = it, scaleToUnits = 1.0f) }
    }
}

Engine lifecycle

Each rememberEngine() call creates a new Filament engine. If you navigate between multiple 3D screens frequently, consider sharing the engine via a ViewModel or CompositionLocal to avoid repeated initialization.

Shared engine across destinations

// In your Application or top-level composable
val LocalEngine = staticCompositionLocalOf<Engine> { error("No engine") }

@Composable
fun App() {
    val engine = rememberEngine()

    CompositionLocalProvider(LocalEngine provides engine) {
        AppNavigation()
    }
}

// In any destination
@Composable
fun ProductViewer() {
    val engine = LocalEngine.current
    val modelLoader = rememberModelLoader(engine)
    // ...
}

Material 3 / Material Design

SceneView renders inside a standard Compose layout. Wrap it with Material 3 components freely.

3D viewer in a Material 3 card

@Composable
fun ProductCard(product: Product) {
    Card(
        modifier = Modifier.fillMaxWidth().padding(16.dp),
        shape = RoundedCornerShape(16.dp)
    ) {
        Column {
            // 3D viewer as the card hero
            Scene(
                modifier = Modifier.fillMaxWidth().height(250.dp),
                cameraManipulator = rememberCameraManipulator()
            ) {
                rememberModelInstance(modelLoader, product.modelPath)?.let {
                    ModelNode(modelInstance = it, scaleToUnits = 1.0f)
                }
            }

            // Standard Material 3 content below
            Column(modifier = Modifier.padding(16.dp)) {
                Text(product.name, style = MaterialTheme.typography.headlineSmall)
                Text(product.price, style = MaterialTheme.typography.bodyLarge)
                Button(onClick = { /* add to cart */ }) {
                    Text("Add to Cart")
                }
            }
        }
    }
}

Bottom sheet with AR

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ARWithBottomSheet() {
    val sheetState = rememberModalBottomSheetState()
    var showSheet by remember { mutableStateOf(false) }

    Box(modifier = Modifier.fillMaxSize()) {
        ARScene(
            modifier = Modifier.fillMaxSize(),
            planeRenderer = true
        ) {
            // AR content
        }

        // Floating action button
        FloatingActionButton(
            onClick = { showSheet = true },
            modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
        ) {
            Icon(Icons.Default.Settings, "Settings")
        }
    }

    if (showSheet) {
        ModalBottomSheet(onDismissRequest = { showSheet = false }, sheetState = sheetState) {
            // Model picker, settings, etc.
            ModelPickerContent()
        }
    }
}

ViewModel integration

Keep scene state in a ViewModel so it survives configuration changes.

class SceneViewModel : ViewModel() {
    var selectedModel by mutableStateOf("helmet")
        private set

    var isAnimating by mutableStateOf(true)
        private set

    var lightIntensity by mutableFloatStateOf(100_000f)
        private set

    fun selectModel(name: String) { selectedModel = name }
    fun toggleAnimation() { isAnimating = !isAnimating }
    fun setLight(intensity: Float) { lightIntensity = intensity }
}

@Composable
fun SceneScreen(viewModel: SceneViewModel = viewModel()) {
    val model = rememberModelInstance(modelLoader, "models/${viewModel.selectedModel}.glb")

    Scene(modifier = Modifier.fillMaxSize()) {
        model?.let {
            ModelNode(
                modelInstance = it,
                scaleToUnits = 1.0f,
                autoAnimate = viewModel.isAnimating
            )
        }
        LightNode(
            type = LightManager.Type.SUN,
            apply = { intensity(viewModel.lightIntensity) }
        )
    }
}

Hilt / dependency injection

Inject model paths, environment configurations, or feature flags.

@HiltViewModel
class ProductViewModel @Inject constructor(
    private val productRepository: ProductRepository
) : ViewModel() {
    val product = productRepository.getProduct(productId)
    val modelUrl get() = product.value?.modelUrl
}

@Composable
fun ProductScreen(viewModel: ProductViewModel = hiltViewModel()) {
    val product by viewModel.product.collectAsStateWithLifecycle()

    product?.modelUrl?.let { url ->
        Scene(modifier = Modifier.fillMaxSize()) {
            rememberModelInstance(modelLoader, url)?.let {
                ModelNode(modelInstance = it, scaleToUnits = 1.0f)
            }
        }
    }
}

Room / local database

Store anchor data for persistent AR experiences.

@Entity
data class SavedAnchor(
    @PrimaryKey val id: String,
    val cloudAnchorId: String,
    val label: String,
    val timestamp: Long
)

@Dao
interface AnchorDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun save(anchor: SavedAnchor)

    @Query("SELECT * FROM SavedAnchor ORDER BY timestamp DESC")
    fun getAll(): Flow<List<SavedAnchor>>
}

// In your AR composable
ARScene(...) {
    CloudAnchorNode(
        anchor = localAnchor,
        onHosted = { cloudId, state ->
            if (state == CloudAnchorState.SUCCESS && cloudId != null) {
                scope.launch {
                    anchorDao.save(SavedAnchor(
                        id = UUID.randomUUID().toString(),
                        cloudAnchorId = cloudId,
                        label = "My anchor",
                        timestamp = System.currentTimeMillis()
                    ))
                }
            }
        }
    ) {
        ModelNode(modelInstance = model!!)
    }
}