diff --git a/app/src/main/res/raw/bugdroid_animated_wave.glb b/app/src/main/assets/models/bugdroid_animated_wave.glb similarity index 100% rename from app/src/main/res/raw/bugdroid_animated_wave.glb rename to app/src/main/assets/models/bugdroid_animated_wave.glb diff --git a/app/src/main/assets/white.png b/app/src/main/assets/textures/white.png similarity index 100% rename from app/src/main/assets/white.png rename to app/src/main/assets/textures/white.png diff --git a/app/src/main/java/com/example/helloandroidxr/bugdroid/BugdroidController.kt b/app/src/main/java/com/example/helloandroidxr/bugdroid/BugdroidController.kt deleted file mode 100644 index ddd8ce3..0000000 --- a/app/src/main/java/com/example/helloandroidxr/bugdroid/BugdroidController.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.helloandroidxr.bugdroid - -import android.annotation.SuppressLint -import android.content.Context -import android.util.Log -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.xr.runtime.Session -import androidx.xr.scenecore.GltfModel -import com.example.helloandroidxr.R -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import java.io.InputStream - -class BugdroidController( - private val xrSession: Session?, - private val context: Context, - private val coroutineScope: CoroutineScope -) { - var gltfModel by mutableStateOf(null) - - init { - loadBugdroidModel() - } - - private fun loadBugdroidModel() { - coroutineScope.launch { - gltfModel = BugdroidGltfModelCache.getOrLoadModel(xrSession, context) - } - } -} - -private object BugdroidGltfModelCache { - private var cachedModel: GltfModel? = null - @SuppressLint("RestrictedApi") - suspend fun getOrLoadModel( - xrCoreSession: Session?, context: Context - ): GltfModel? { - xrCoreSession ?: run { - Log.w(TAG, "Cannot load model, session is null.") - return null - } - return if (cachedModel == null) { - try { - val inputStream: InputStream = - context.resources.openRawResource(R.raw.bugdroid_animated_wave) - cachedModel = GltfModel.create( - xrCoreSession, inputStream.readBytes(), "BUGDROID" - ) - cachedModel - } catch (e: Exception) { - Log.e(TAG, "Error loading GLTF model", e) - null - } - } else { - cachedModel - } - } - - fun clearCache() { - cachedModel = null - } - - const val TAG = "BugdroidGltfModelCache" -} \ No newline at end of file diff --git a/app/src/main/java/com/example/helloandroidxr/ui/HelloAndroidXRApp.kt b/app/src/main/java/com/example/helloandroidxr/ui/HelloAndroidXRApp.kt index fd729d4..3666e13 100644 --- a/app/src/main/java/com/example/helloandroidxr/ui/HelloAndroidXRApp.kt +++ b/app/src/main/java/com/example/helloandroidxr/ui/HelloAndroidXRApp.kt @@ -55,24 +55,23 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowSizeClass -import androidx.window.core.layout.WindowWidthSizeClass import androidx.xr.compose.platform.LocalSpatialCapabilities import androidx.xr.compose.platform.LocalSpatialConfiguration import androidx.xr.compose.spatial.ContentEdge import androidx.xr.compose.spatial.Orbiter import androidx.xr.compose.spatial.Subspace -import androidx.xr.compose.subspace.MovePolicy -import androidx.xr.compose.subspace.ResizePolicy import androidx.xr.compose.subspace.SpatialColumn import androidx.xr.compose.subspace.SpatialPanel import androidx.xr.compose.subspace.SpatialRow +import androidx.xr.compose.subspace.draw.alpha import androidx.xr.compose.subspace.layout.SubspaceModifier -import androidx.xr.compose.subspace.layout.alpha import androidx.xr.compose.subspace.layout.fillMaxSize import androidx.xr.compose.subspace.layout.fillMaxWidth import androidx.xr.compose.subspace.layout.height +import androidx.xr.compose.subspace.layout.movable import androidx.xr.compose.subspace.layout.offset import androidx.xr.compose.subspace.layout.padding +import androidx.xr.compose.subspace.layout.resizable import androidx.xr.compose.subspace.layout.rotate import androidx.xr.compose.subspace.layout.size import androidx.xr.compose.subspace.layout.width @@ -181,18 +180,18 @@ private fun SpatialLayout( SubspaceModifier .alpha(animatedAlpha.value) .size(400.dp) - .padding(bottom = 16.dp), - dragPolicy = MovePolicy(isEnabled = true), - resizePolicy = ResizePolicy(isEnabled = true) + .padding(bottom = 16.dp) + .movable() + .resizable(), ) { firstSupportingContent() } SpatialPanel( SubspaceModifier .alpha(animatedAlpha.value) - .weight(1f), - dragPolicy = MovePolicy(isEnabled = true), - resizePolicy = ResizePolicy(isEnabled = true) + .weight(1f) + .movable() + .resizable(), ) { secondSupportingContent() } @@ -201,9 +200,9 @@ private fun SpatialLayout( modifier = SubspaceModifier .alpha(animatedAlpha.value) .fillMaxSize() - .padding(left = 16.dp), - dragPolicy = MovePolicy(isEnabled = true), - resizePolicy = ResizePolicy(isEnabled = true) + .padding(end = 16.dp) + .movable() + .resizable(), ) { Column { TopAppBar() @@ -222,7 +221,7 @@ private fun NonSpatialTwoPaneLayout( primaryPane: @Composable () -> Unit, secondaryPane: @Composable () -> Unit, modifier: Modifier = Modifier, - windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true).windowSizeClass ) { val animatedAlpha = remember { Animatable(0.5f) } LaunchedEffect(Unit) { @@ -241,7 +240,7 @@ private fun NonSpatialTwoPaneLayout( ) { TopAppBar() Spacer(Modifier.height(16.dp)) - if (windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT) { + if (windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.HEIGHT_DP_EXPANDED_LOWER_BOUND)) { TopAndBottomPaneLayout(primaryPane, secondaryPane) } else { SideBySidePaneLayout(primaryPane, secondaryPane) @@ -305,6 +304,7 @@ private fun TopAndBottomPaneLayout( /** * Contains controls that decompose into Orbiters when spatial UI is enabled */ +@Suppress("DEPRECATION") @Composable private fun TopAppBar() { Row( diff --git a/app/src/main/java/com/example/helloandroidxr/ui/components/BugdroidModel.kt b/app/src/main/java/com/example/helloandroidxr/ui/components/BugdroidModel.kt index 7e7725d..bb51bd5 100644 --- a/app/src/main/java/com/example/helloandroidxr/ui/components/BugdroidModel.kt +++ b/app/src/main/java/com/example/helloandroidxr/ui/components/BugdroidModel.kt @@ -17,31 +17,30 @@ package com.example.helloandroidxr.ui.components import android.annotation.SuppressLint -import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.xr.compose.platform.LocalSession import androidx.xr.compose.spatial.PlanarEmbeddedSubspace -import androidx.xr.compose.subspace.SceneCoreEntity -import androidx.xr.compose.subspace.SceneCoreEntitySizeAdapter +import androidx.xr.compose.subspace.SpatialGltfModel +import androidx.xr.compose.subspace.SpatialGltfModelAnimation +import androidx.xr.compose.subspace.SpatialGltfModelSource +import androidx.xr.compose.subspace.draw.scale import androidx.xr.compose.subspace.layout.SubspaceModifier -import androidx.xr.compose.subspace.layout.scale +import androidx.xr.compose.subspace.layout.onSizeChanged +import androidx.xr.compose.subspace.rememberSpatialGltfModelState import androidx.xr.compose.unit.Meter import androidx.xr.runtime.math.Vector4 import androidx.xr.scenecore.AlphaMode -import androidx.xr.scenecore.GltfModelEntity import androidx.xr.scenecore.KhronosPbrMaterial import androidx.xr.scenecore.Texture -import com.example.helloandroidxr.bugdroid.BugdroidController import com.example.helloandroidxr.viewmodel.ModelTransform +import java.nio.file.Paths import kotlin.io.path.Path // Bugdroid glb height in meters @@ -50,9 +49,7 @@ private const val bugdroidHeight = 2.08f // The desired amount of the available layout height to use for the bugdroid private const val fillRatio = 0.5f -const val TAG = "BugdroidModel" - -@SuppressLint("RestrictedApi") +@SuppressLint("NewApi", "RestrictedApi") @Composable fun BugdroidModel( modelTransform: ModelTransform, @@ -61,93 +58,113 @@ fun BugdroidModel( modifier: SubspaceModifier = SubspaceModifier, ) { val xrSession = LocalSession.current + val density = LocalDensity.current + var scaleFromLayout by remember { mutableFloatStateOf(1f) } + if (xrSession != null && showBugdroid) { - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - val bugdroidController = remember(xrSession, context, coroutineScope) { - BugdroidController(xrSession, context, coroutineScope) + // Initialize and remember the state of the glTF model, loading it from the assets folder. + val bugdroidModelState = rememberSpatialGltfModelState( + source = SpatialGltfModelSource.fromPath( + Paths.get("models/bugdroid_animated_wave.glb") + ) + ) + + // Find a specific node by name to apply modifications, such as material overrides. + val bugdroidNode = remember(bugdroidModelState.nodes) { + bugdroidModelState.nodes.find { it.name == "Droid_Solo:Bugdroid" } } - val gltfModel = bugdroidController.gltfModel - gltfModel?.let { model -> - PlanarEmbeddedSubspace { - val density = LocalDensity.current - var scaleFromLayout by remember { mutableFloatStateOf(1f) } - var pbrMaterial by remember { mutableStateOf(null) } - LaunchedEffect(xrSession) { - try { - pbrMaterial = KhronosPbrMaterial.create( - session = xrSession, - alphaMode = AlphaMode.OPAQUE - ) - val texture = Texture.create( - session = xrSession, - path = Path("white.png") // Used as a base texture for material properties. - ) - pbrMaterial?.setOcclusionTexture( - texture = texture, - strength = modelTransform.materialProperties.ambientOcclusion - ) - pbrMaterial?.setBaseColorFactor( - Vector4( - x = modelTransform.materialColor.x, - y = modelTransform.materialColor.y, - z = modelTransform.materialColor.z, - w = modelTransform.materialColor.w - ) - ) - } catch (e: Exception) { - Log.e(TAG, "Error creating material", e) - } - } - LaunchedEffect( - modelTransform.materialColor.x, - modelTransform.materialColor.y, - modelTransform.materialColor.z, - modelTransform.materialColor.w, - modelTransform.materialProperties.metallic, - modelTransform.materialProperties.roughness, - ) { - pbrMaterial?.setBaseColorFactor( - Vector4( - x = modelTransform.materialColor.x, - y = modelTransform.materialColor.y, - z = modelTransform.materialColor.z, - w = modelTransform.materialColor.w - ) + + // Maintain a reference to the custom material to avoid re-creating it on every recomposition. + var pbrMaterial by remember { mutableStateOf(null) } + + // Create and apply a custom PBR material to the model when the XR session or target node changes. + LaunchedEffect(xrSession, bugdroidNode) { + val material = KhronosPbrMaterial.create( + session = xrSession, + alphaMode = AlphaMode.OPAQUE + ).also { + pbrMaterial = it + // Load a texture; using a plain white texture for visibility of the base color factor + val texture = Texture.create( + session = xrSession, + path = Path("textures/white.png") + ) + + // Apply the texture and configure occlusion to define ambient lighting strength. + it.setOcclusionTexture( + texture = texture, + strength = modelTransform.materialProperties.ambientOcclusion + ) + + // Apply the initial material properties. Base Color is RGBA value + it.setBaseColorFactor( + Vector4( + x = modelTransform.materialColor.x, + y = modelTransform.materialColor.y, + z = modelTransform.materialColor.z, + w = modelTransform.materialColor.w ) - pbrMaterial?.setMetallicFactor(modelTransform.materialProperties.metallic) - pbrMaterial?.setRoughnessFactor(modelTransform.materialProperties.roughness) + ) + it.setMetallicFactor(modelTransform.materialProperties.metallic) + it.setRoughnessFactor(modelTransform.materialProperties.roughness) + } + + // Apply the custom PBR material to the specific node, overriding original glTF material. + bugdroidNode?.setMaterialOverride( + material = material + ) + } + + // Update the base color material properties whenever the model transform state changes. + LaunchedEffect(modelTransform.materialColor, pbrMaterial) { + pbrMaterial?.setBaseColorFactor( + Vector4( + x = modelTransform.materialColor.x, + y = modelTransform.materialColor.y, + z = modelTransform.materialColor.z, + w = modelTransform.materialColor.w + ) + ) + } + + // Update the metallic factor property whenever the model transform state changes. + LaunchedEffect(modelTransform.materialProperties.metallic, pbrMaterial) { + pbrMaterial?.setMetallicFactor(modelTransform.materialProperties.metallic) + } + + // Update the roughness property whenever the model transform state changes. + LaunchedEffect(modelTransform.materialProperties.roughness, pbrMaterial) { + pbrMaterial?.setRoughnessFactor(modelTransform.materialProperties.roughness) + } + + // Control the model's animation state based on the animateBugdroid flag. + LaunchedEffect(bugdroidModelState.animations) { + val animation = bugdroidModelState.animations.find { + it.name == "Armature|Take 001|BaseLayer" + } + if (animateBugdroid) { + if (animation?.animationState != SpatialGltfModelAnimation.AnimationState.Playing) { + animation?.loop() } - SceneCoreEntity( - factory = { - GltfModelEntity.create(xrSession, model) - }, - update = { entity: GltfModelEntity -> - pbrMaterial?.let { newMaterial -> - entity.setMaterialOverride( - material = newMaterial, - "Droid_Solo:Bugdroid" - ) - if (animateBugdroid) { - entity.startAnimation( - loop = true, animationName = "Armature|Take 001|BaseLayer" - ) - } else { - entity.stopAnimation() - } - } - }, - sizeAdapter = SceneCoreEntitySizeAdapter(onLayoutSizeChanged = { size -> - // Calculate the scale we should use for the entity based on the size the - // layout is setting on the SceneCoreEntity + } else { + animation?.stop() + } + } + + // Use a PlanarEmbeddedSubspace to anchor the 3D model within the 2D layout. + PlanarEmbeddedSubspace { + SpatialGltfModel( + state = bugdroidModelState, + modifier = modifier + .onSizeChanged { size -> + // Calculate the scale to use for the entity based on the layout size val scaleToFillLayoutHeight = Meter .fromPixel(size.height.toFloat(), density).toM() / bugdroidHeight - //Limit the scale to a ratio of the available space + // Limit the scale to a ratio of the available space. scaleFromLayout = scaleToFillLayoutHeight * fillRatio - }), - modifier = modifier.scale(scaleFromLayout * modelTransform.scale) - ) - } + } + .scale(scaleFromLayout * modelTransform.scale) + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/helloandroidxr/viewmodel/BugdroidViewModel.kt b/app/src/main/java/com/example/helloandroidxr/viewmodel/BugdroidViewModel.kt index 9f7f486..0f57ed4 100644 --- a/app/src/main/java/com/example/helloandroidxr/viewmodel/BugdroidViewModel.kt +++ b/app/src/main/java/com/example/helloandroidxr/viewmodel/BugdroidViewModel.kt @@ -33,7 +33,7 @@ private const val DEFAULT_Z_OFFSET = 400.0f private const val DEFAULT_X_MATERIAL_COLOR = 0.0f private const val DEFAULT_Y_MATERIAL_COLOR = 1.0f private const val DEFAULT_Z_MATERIAL_COLOR = 0.0f -private const val DEFAULT_W_MATERIAL_COLOR = 0.0f +private const val DEFAULT_W_MATERIAL_COLOR = 1.0f private const val DEFAULT_AMBIENT_OCCLUSION = 1.0f private const val DEFAULT_METALLIC = 0.0f private const val DEFAULT_ROUGHNESS = 0.0f @@ -224,7 +224,8 @@ class BugdroidViewModel : ViewModel() { fun resetModel() { _uiState.update { currentState -> currentState.copy( - modelTransform = ModelTransform() + modelTransform = ModelTransform(), + animateBugdroid = false ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a699c5f..19c26ca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,15 @@ [versions] -androidx-runtime = "1.10.2" -agp = "9.0.0" -arcore = "1.0.0-alpha10" -compose = "1.0.0-alpha10" -extensionsXr = "1.2.0" -scenecore = "1.0.0-alpha11" +androidx-runtime = "1.11.1" +agp = "9.2.1" +arcore = "1.0.0-alpha13" +compose = "1.0.0-alpha13" +extensionsXr = "1.3.0" +scenecore = "1.0.0-alpha14" kotlinxCoroutinesGuava = "1.10.2" -kotlin = "2.3.0" +kotlin = "2.3.21" concurrentFuturesKtx = "1.3.0" -activityCompose = "1.12.2" -composeBom = "2026.01.00" +activityCompose = "1.13.0" +composeBom = "2026.05.00" material = "1.13.0" adaptiveAndroid = "1.2.0" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index fb00f2c..0c24fcc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -17,6 +17,6 @@ #Mon Aug 26 16:14:13 PDT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists