From d1ad24e25bb1e4b6dcbb308914742773132add7f Mon Sep 17 00:00:00 2001 From: Thomas Gorisse Date: Fri, 20 Mar 2026 20:01:34 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(samples):=20add=20post-processing=20sa?= =?UTF-8?q?mple=20=E2=80=94=20Bloom,=20DoF,=20SSAO,=20Fog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive demo of four Filament View post-processing effects using the existing View parameter on Scene {}. No new SceneView API required. Effects: - Bloom with lens flare, starburst, chromatic aberration - Depth of Field (physical, circle-of-confusion) - Screen-Space Ambient Occlusion with contact shadows - Atmospheric Fog with IBL colour sampling Model: Damaged Helmet (CC0, KhronosGroup glTF-Sample-Assets) Co-Authored-By: Claude Sonnet 4.6 --- samples/post-processing/build.gradle | 52 +++ .../src/main/AndroidManifest.xml | 21 ++ samples/post-processing/src/main/assets | 1 + .../sample/postprocessing/MainActivity.kt | 350 ++++++++++++++++++ .../src/main/res/values/strings.xml | 3 + settings.gradle | 1 + 6 files changed, 428 insertions(+) create mode 100644 samples/post-processing/build.gradle create mode 100644 samples/post-processing/src/main/AndroidManifest.xml create mode 120000 samples/post-processing/src/main/assets create mode 100644 samples/post-processing/src/main/java/io/github/sceneview/sample/postprocessing/MainActivity.kt create mode 100644 samples/post-processing/src/main/res/values/strings.xml diff --git a/samples/post-processing/build.gradle b/samples/post-processing/build.gradle new file mode 100644 index 00000000..e49c5e00 --- /dev/null +++ b/samples/post-processing/build.gradle @@ -0,0 +1,52 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.compose' +} + +android { + namespace 'io.github.sceneview.sample.postprocessing' + + compileSdk 36 + + defaultConfig { + applicationId "io.github.sceneview.sample.postprocessing" + minSdk 28 + targetSdk 36 + versionCode 1 + versionName "1.0.0" + } + + buildTypes { + release { + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + } + buildFeatures { + compose true + } + androidResources { + noCompress 'filamat', 'ktx' + } +} + +dependencies { + implementation project(":samples:common") + + implementation "androidx.compose.ui:ui:1.10.5" + implementation "androidx.compose.foundation:foundation:1.10.5" + implementation 'androidx.activity:activity-compose:1.10.1' + implementation 'androidx.compose.material:material:1.10.5' + implementation 'androidx.compose.material:material-icons-extended:1.7.8' + implementation "androidx.compose.ui:ui-tooling-preview:1.10.5" + debugImplementation "androidx.compose.ui:ui-tooling:1.10.5" + + // SceneView + implementation project(":sceneview") +} diff --git a/samples/post-processing/src/main/AndroidManifest.xml b/samples/post-processing/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a3846089 --- /dev/null +++ b/samples/post-processing/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/samples/post-processing/src/main/assets b/samples/post-processing/src/main/assets new file mode 120000 index 00000000..3cc019c1 --- /dev/null +++ b/samples/post-processing/src/main/assets @@ -0,0 +1 @@ +../../../model-viewer/src/main/assets \ No newline at end of file diff --git a/samples/post-processing/src/main/java/io/github/sceneview/sample/postprocessing/MainActivity.kt b/samples/post-processing/src/main/java/io/github/sceneview/sample/postprocessing/MainActivity.kt new file mode 100644 index 00000000..5f9de87b --- /dev/null +++ b/samples/post-processing/src/main/java/io/github/sceneview/sample/postprocessing/MainActivity.kt @@ -0,0 +1,350 @@ +package io.github.sceneview.sample.postprocessing + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.google.android.filament.View +import io.github.sceneview.Scene +import io.github.sceneview.animation.Transition.animateRotation +import io.github.sceneview.createView +import io.github.sceneview.math.Position +import io.github.sceneview.math.Rotation +import io.github.sceneview.rememberCameraManipulator +import io.github.sceneview.rememberCameraNode +import io.github.sceneview.rememberEngine +import io.github.sceneview.rememberEnvironment +import io.github.sceneview.rememberEnvironmentLoader +import io.github.sceneview.rememberMainLightNode +import io.github.sceneview.rememberModelInstance +import io.github.sceneview.rememberModelLoader +import io.github.sceneview.rememberNode +import io.github.sceneview.rememberView +import io.github.sceneview.sample.SceneviewTheme +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit.MILLISECONDS + +/** + * Post-Processing showcase — demonstrates Bloom, Depth-of-Field, Screen-Space Ambient Occlusion + * and Fog, all of which are available in Filament 1.56.0 via [View] options. + * + * ### Technique + * Pass a custom [View] to [Scene] via `rememberView(engine) { createView(engine).apply { … } }`, + * then mutate the view options reactively from Compose state. Because [Scene] re-renders every + * frame the new option values are picked up immediately. + * + * None of these effects require new SceneView API — they are fully surfaced through the existing + * `view` parameter on [Scene]. + */ +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + SceneviewTheme { + Surface(modifier = Modifier.fillMaxSize()) { + PostProcessingScreen() + } + } + } + } +} + +@Composable +fun PostProcessingScreen() { + // ── Post-processing controls (Compose state) ───────────────────────────────────────────────── + + // Bloom + var bloomEnabled by remember { mutableStateOf(true) } + var bloomStrength by remember { mutableFloatStateOf(0.35f) } + var bloomLensFlare by remember { mutableStateOf(true) } + + // Depth of Field + var dofEnabled by remember { mutableStateOf(false) } + var dofCocScale by remember { mutableFloatStateOf(1.0f) } + + // Ambient Occlusion (SSAO) + var ssaoEnabled by remember { mutableStateOf(true) } + var ssaoIntensity by remember { mutableFloatStateOf(1.0f) } + + // Fog + var fogEnabled by remember { mutableStateOf(false) } + var fogDensity by remember { mutableFloatStateOf(0.05f) } + + // ── Filament / SceneView resources ─────────────────────────────────────────────────────────── + val engine = rememberEngine() + val modelLoader = rememberModelLoader(engine) + val environmentLoader = rememberEnvironmentLoader(engine) + + // Pivot node — the camera orbits around it + val centerNode = rememberNode(engine) + val cameraNode = rememberCameraNode(engine) { + position = Position(y = -0.3f, z = 2.2f) + lookAt(centerNode) + centerNode.addChildNode(this) + } + + val cameraTransition = rememberInfiniteTransition(label = "CameraOrbit") + val cameraRotation by cameraTransition.animateRotation( + initialValue = Rotation(y = 0.0f), + targetValue = Rotation(y = 360.0f), + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 12.seconds.toInt(MILLISECONDS)) + ) + ) + + val modelInstance = rememberModelInstance(modelLoader, "models/damaged_helmet.glb") + val environment = rememberEnvironment(environmentLoader) { + environmentLoader.createHDREnvironment("environments/sky_2k.hdr")!! + } + + // Custom View — enables shadowing so SSAO contact shadows work. + // `createView` is the SceneView default factory; we enable shadows on top. + val view = rememberView(engine) { + createView(engine).apply { + setShadowingEnabled(true) + } + } + + // Apply post-processing options reactively from Compose state. + // These are plain field-writes on data-class options; cheap to call every recomposition. + // Filament reads the current option values on each rendered frame. + view.bloomOptions = view.bloomOptions.also { + it.enabled = bloomEnabled + it.strength = bloomStrength + it.lensFlare = bloomLensFlare + it.starburst = bloomLensFlare + it.chromaticAberration = if (bloomLensFlare) 0.005f else 0.0f + } + + view.depthOfFieldOptions = view.depthOfFieldOptions.also { + it.enabled = dofEnabled + it.cocScale = dofCocScale + it.maxApertureDiameter = 0.01f + } + + @Suppress("DEPRECATION") + view.ambientOcclusion = + if (ssaoEnabled) View.AmbientOcclusion.SSAO else View.AmbientOcclusion.NONE + view.ambientOcclusionOptions = view.ambientOcclusionOptions.also { + it.enabled = ssaoEnabled + it.intensity = ssaoIntensity + it.radius = 0.3f + it.power = 1.0f + } + + view.fogOptions = view.fogOptions.also { + it.enabled = fogEnabled + it.density = fogDensity + it.distance = 0.5f + it.cutOffDistance = 10f + it.fogColorFromIbl = true + } + + // ── Layout: 3D viewport (top half) + controls panel (bottom half) ─────────────────────────── + Column(modifier = Modifier.fillMaxSize()) { + + // 3D viewport + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Scene( + modifier = Modifier.fillMaxSize(), + engine = engine, + modelLoader = modelLoader, + view = view, + cameraNode = cameraNode, + cameraManipulator = rememberCameraManipulator( + orbitHomePosition = cameraNode.worldPosition, + targetPosition = centerNode.worldPosition + ), + environment = environment, + mainLightNode = rememberMainLightNode(engine) { + intensity = 150_000f + }, + onFrame = { + centerNode.rotation = cameraRotation + cameraNode.lookAt(centerNode) + } + ) { + // Attach the pivot node to the scene's root + Node(apply = { centerNode.addChildNode(this) }) + // Model appears when loaded; null-safe handles the async loading state + modelInstance?.let { instance -> + ModelNode(modelInstance = instance, scaleToUnits = 0.5f) + } + } + + Text( + text = "Drag to orbit • Pinch to zoom", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.6f), + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 12.dp, bottom = 10.dp) + .background( + color = Color.Black.copy(alpha = 0.35f), + shape = MaterialTheme.shapes.small + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + + // Controls panel + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .background(MaterialTheme.colorScheme.surface) + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + + Text( + "Post-Processing Controls", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // ── Bloom ────────────────────────────────────────────────────────────────────────── + SectionHeader("Bloom (View.setBloomOptions)") + ToggleRow("Enabled", bloomEnabled) { bloomEnabled = it } + if (bloomEnabled) { + SliderRow("Strength", bloomStrength, 0f, 1f) { bloomStrength = it } + ToggleRow("Lens Flare + Starburst + Chromatic Aberration", bloomLensFlare) { + bloomLensFlare = it + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // ── Depth of Field ───────────────────────────────────────────────────────────────── + SectionHeader("Depth of Field (View.setDepthOfFieldOptions)") + ToggleRow("Enabled", dofEnabled) { dofEnabled = it } + if (dofEnabled) { + SliderRow("Circle of Confusion scale", dofCocScale, 0.1f, 5f) { + dofCocScale = it + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // ── SSAO ─────────────────────────────────────────────────────────────────────────── + SectionHeader("Ambient Occlusion (View.setAmbientOcclusionOptions)") + ToggleRow("Enabled", ssaoEnabled) { ssaoEnabled = it } + if (ssaoEnabled) { + SliderRow("Intensity", ssaoIntensity, 0f, 4f) { ssaoIntensity = it } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // ── Fog ──────────────────────────────────────────────────────────────────────────── + SectionHeader("Atmospheric Fog (View.setFogOptions)") + ToggleRow("Enabled", fogEnabled) { fogEnabled = it } + if (fogEnabled) { + SliderRow("Density", fogDensity, 0.001f, 0.3f) { fogDensity = it } + } + + Spacer(Modifier.height(16.dp)) + + Text( + text = "All effects use Filament 1.56.0 View APIs. " + + "No new SceneView wrapper needed — pass a custom view via " + + "rememberView(engine) { createView(engine).apply { … } }.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + title, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp, bottom = 4.dp) + ) +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun SliderRow( + label: String, + value: Float, + min: Float, + max: Float, + onValueChange: (Float) -> Unit +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(label, style = MaterialTheme.typography.bodySmall) + Text("%.3f".format(value), style = MaterialTheme.typography.bodySmall) + } + Slider( + value = value, + onValueChange = onValueChange, + valueRange = min..max, + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/samples/post-processing/src/main/res/values/strings.xml b/samples/post-processing/src/main/res/values/strings.xml new file mode 100644 index 00000000..3727d2fc --- /dev/null +++ b/samples/post-processing/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Post-Processing + diff --git a/settings.gradle b/settings.gradle index 252afe5a..bb3db70d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -20,3 +20,4 @@ include ':samples:gltf-camera' include ':samples:model-viewer' include ':samples:camera-manipulator' include ':samples:autopilot-demo' +include ':samples:post-processing' From 54d3c72a9c3a09852c49e925854da3e78c763500 Mon Sep 17 00:00:00 2001 From: Thomas Gorisse Date: Fri, 20 Mar 2026 20:04:53 +0100 Subject: [PATCH 2/3] feat(physics): add PhysicsNode + physics-demo sample (3.2.0 roadmap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pure-Kotlin rigid-body physics prototype with no external library: - `sceneview/…/node/PhysicsNode.kt`: `PhysicsBody` class (Euler integration, 9.8 m/s² gravity, floor collision, restitution, sleep detection) and `PhysicsNode` composable that hooks into `Node.onFrame` to drive position each frame. - `samples/physics-demo`: standalone sample — tap to spawn bouncing spheres that fall and bounce off a floor slab; up to 10 simultaneous balls. - `settings.gradle`: register `:samples:physics-demo` module. Build verified: `:samples:physics-demo:assembleDebug` passes clean. Co-Authored-By: Claude Sonnet 4.6 --- samples/physics-demo/build.gradle | 51 +++++ .../physics-demo/src/main/AndroidManifest.xml | 20 ++ .../sample/physicsdemo/MainActivity.kt | 205 ++++++++++++++++++ .../src/main/res/values/strings.xml | 3 + .../io/github/sceneview/node/PhysicsNode.kt | 159 ++++++++++++++ settings.gradle | 1 + 6 files changed, 439 insertions(+) create mode 100644 samples/physics-demo/build.gradle create mode 100644 samples/physics-demo/src/main/AndroidManifest.xml create mode 100644 samples/physics-demo/src/main/java/io/github/sceneview/sample/physicsdemo/MainActivity.kt create mode 100644 samples/physics-demo/src/main/res/values/strings.xml create mode 100644 sceneview/src/main/java/io/github/sceneview/node/PhysicsNode.kt diff --git a/samples/physics-demo/build.gradle b/samples/physics-demo/build.gradle new file mode 100644 index 00000000..8b612d6a --- /dev/null +++ b/samples/physics-demo/build.gradle @@ -0,0 +1,51 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.compose' +} + +android { + namespace "io.github.sceneview.sample.physicsdemo" + + compileSdk 36 + + defaultConfig { + applicationId "io.github.sceneview.sample.physicsdemo" + minSdk 28 + targetSdk 36 + versionCode 1 + versionName "1.0.0" + } + + buildTypes { + release { + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + } + buildFeatures { + compose true + } + androidResources { + noCompress 'filamat', 'ktx' + } +} + +dependencies { + implementation project(":samples:common") + + implementation "androidx.compose.ui:ui:1.10.5" + implementation "androidx.compose.foundation:foundation:1.10.5" + implementation 'androidx.activity:activity-compose:1.10.1' + implementation 'androidx.compose.material:material:1.10.5' + implementation "androidx.compose.ui:ui-tooling-preview:1.10.5" + debugImplementation "androidx.compose.ui:ui-tooling:1.10.5" + + // SceneView + implementation project(":sceneview") +} diff --git a/samples/physics-demo/src/main/AndroidManifest.xml b/samples/physics-demo/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0f176cff --- /dev/null +++ b/samples/physics-demo/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/samples/physics-demo/src/main/java/io/github/sceneview/sample/physicsdemo/MainActivity.kt b/samples/physics-demo/src/main/java/io/github/sceneview/sample/physicsdemo/MainActivity.kt new file mode 100644 index 00000000..b739b5ed --- /dev/null +++ b/samples/physics-demo/src/main/java/io/github/sceneview/sample/physicsdemo/MainActivity.kt @@ -0,0 +1,205 @@ +package io.github.sceneview.sample.physicsdemo + +import android.os.Bundle +import android.view.MotionEvent +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.github.sceneview.Scene +import io.github.sceneview.math.Position +import io.github.sceneview.math.Size +import io.github.sceneview.node.PhysicsNode +import io.github.sceneview.node.SphereNode +import io.github.sceneview.rememberCameraNode +import io.github.sceneview.rememberEngine +import io.github.sceneview.rememberEnvironment +import io.github.sceneview.rememberEnvironmentLoader +import io.github.sceneview.rememberMainLightNode +import io.github.sceneview.rememberMaterialLoader +import io.github.sceneview.rememberModelLoader +import io.github.sceneview.rememberOnGestureListener +import io.github.sceneview.sample.SceneviewTheme + +/** + * Physics Demo — tap anywhere to throw a ball that falls and bounces off the floor. + * + * - The floor is a flat CubeNode (thick slab at y = 0). + * - Each tap spawns a SphereNode at y = 2.5 m above the floor with a small random + * horizontal impulse so balls spread out. + * - Each sphere is driven by a pure-Kotlin PhysicsBody (Euler integration, 9.8 m/s² gravity, + * configurable restitution). + * - Old balls (beyond MAX_BALLS) are removed from the list so the scene stays light. + */ +class MainActivity : ComponentActivity() { + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + SceneviewTheme { + Box(modifier = Modifier.fillMaxSize()) { + + val engine = rememberEngine() + val modelLoader = rememberModelLoader(engine) + val materialLoader = rememberMaterialLoader(engine) + val environmentLoader = rememberEnvironmentLoader(engine) + val environment = rememberEnvironment(environmentLoader) + + // Camera sits slightly above and behind the scene, looking at the origin. + val cameraNode = rememberCameraNode(engine) { + position = Position(x = 0f, y = 1.5f, z = 4f) + lookAt(Position(0f, 0.5f, 0f)) + } + + val mainLightNode = rememberMainLightNode(engine) { + intensity = 100_000f + } + + // ── Physics state ───────────────────────────────────────────────────────── + + /** + * Each entry is a SphereNode whose position will be driven by a PhysicsBody. + * We store the node directly in a SnapshotStateList so that recomposition + * is triggered when balls are added/removed. + */ + val balls = remember { mutableStateListOf() } + + // Counter for giving each ball a slightly different horizontal velocity. + var ballCount by remember { mutableStateOf(0) } + + // ── Scene ───────────────────────────────────────────────────────────────── + + Scene( + modifier = Modifier.fillMaxSize(), + engine = engine, + modelLoader = modelLoader, + cameraNode = cameraNode, + environment = environment, + mainLightNode = mainLightNode, + onGestureListener = rememberOnGestureListener( + onSingleTapConfirmed = { event, _ -> + // Spawn a new ball at every tap. + val index = ballCount++ + + // Alternate horizontal direction so balls spread left/right. + val sign = if (index % 2 == 0) 1f else -1f + val lateralSpeed = 0.3f + (index % 5) * 0.15f + + val ball = SphereNode( + engine = engine, + radius = BALL_RADIUS, + materialInstance = null + ).apply { + position = Position( + x = sign * lateralSpeed * 0.5f, + y = SPAWN_HEIGHT, + z = 0f + ) + } + balls.add(ball) + + // Keep the scene lean: drop the oldest ball once we hit the cap. + if (balls.size > MAX_BALLS) { + balls.removeAt(0) + } + true + } + ) + ) { + // ── Floor slab ──────────────────────────────────────────────────────── + // A thin box centred at y = -FLOOR_HALF_THICKNESS. + // The top surface is at y = 0 — matching PhysicsBody.floorY default. + CubeNode( + size = Size(FLOOR_WIDTH, FLOOR_THICKNESS, FLOOR_DEPTH), + position = Position(y = -FLOOR_HALF_THICKNESS), + materialInstance = null + ) + + // ── Balls ───────────────────────────────────────────────────────────── + for ((idx, ball) in balls.withIndex()) { + // Alternate horizontal launch velocity per ball. + val sign = if (idx % 2 == 0) 1f else -1f + val lateralSpeed = 0.3f + (idx % 5) * 0.15f + + // Attach the ball node into the scene. + Node(apply = { addChildNode(ball) }) + + // Drive it with physics. + PhysicsNode( + node = ball, + mass = 1f, + restitution = RESTITUTION, + linearVelocity = Position( + x = sign * lateralSpeed, + y = 0f, + z = 0f + ), + floorY = 0f, + radius = BALL_RADIUS + ) + } + } + + // ── UI overlay ──────────────────────────────────────────────────────────── + + TopAppBar( + title = { Text(text = stringResource(id = R.string.app_name)) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.25f), + titleContentColor = MaterialTheme.colorScheme.onPrimary + ) + ) + + Text( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 24.dp), + text = "Tap anywhere to throw a ball", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + } + } + } + + companion object { + /** Maximum simultaneous balls in the scene. */ + const val MAX_BALLS = 10 + + /** World-space Y at which new balls spawn. */ + const val SPAWN_HEIGHT = 2.5f + + /** Radius of each physics ball, metres. */ + const val BALL_RADIUS = 0.15f + + /** Bounciness: 0 = dead stop, 1 = perfect bounce. */ + const val RESTITUTION = 0.65f + + // Floor geometry + const val FLOOR_WIDTH = 6f + const val FLOOR_DEPTH = 6f + const val FLOOR_THICKNESS = 0.1f + const val FLOOR_HALF_THICKNESS = FLOOR_THICKNESS / 2f + } +} diff --git a/samples/physics-demo/src/main/res/values/strings.xml b/samples/physics-demo/src/main/res/values/strings.xml new file mode 100644 index 00000000..eb5c51c1 --- /dev/null +++ b/samples/physics-demo/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Physics Demo + diff --git a/sceneview/src/main/java/io/github/sceneview/node/PhysicsNode.kt b/sceneview/src/main/java/io/github/sceneview/node/PhysicsNode.kt new file mode 100644 index 00000000..76a53d27 --- /dev/null +++ b/sceneview/src/main/java/io/github/sceneview/node/PhysicsNode.kt @@ -0,0 +1,159 @@ +package io.github.sceneview.node + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import io.github.sceneview.math.Position +import io.github.sceneview.utils.intervalSeconds + +/** + * Rigid-body physics state attached to a [Node]. + * + * Uses a simple Euler integration step executed every frame via the node's [Node.onFrame] + * callback. There are no external library dependencies — gravity and floor collision are + * handled with pure Kotlin arithmetic. + * + * Physics coordinate system matches SceneView / Filament: + * +Y is up, gravity pulls in the -Y direction. + * + * @param node The node whose [Node.position] is driven by the simulation. + * @param mass Mass in kg. Currently unused in Euler integration but reserved for + * future impulse/force API. + * @param restitution Coefficient of restitution in [0, 1]. 0 = fully inelastic, + * 1 = perfectly elastic. Applied on each floor bounce. + * @param floorY World-space Y coordinate of the floor plane. Default 0.0 (scene origin). + * @param radius Collision radius of the object in meters. Used to offset the floor + * contact point so the surface of the sphere sits on the floor, not + * its centre. + * @param initialVelocity Initial linear velocity in m/s (world space). + */ +class PhysicsBody( + val node: Node, + val mass: Float = 1f, + val restitution: Float = 0.6f, + val floorY: Float = 0f, + val radius: Float = 0f, + initialVelocity: Position = Position(0f, 0f, 0f) +) { + companion object { + const val GRAVITY = -9.8f // m/s² downward (-Y) + + /** Velocities below this threshold are zeroed to stop micro-bouncing. */ + const val SLEEP_THRESHOLD = 0.05f + } + + /** Current linear velocity in m/s (world space). */ + var velocity: Position = initialVelocity + + /** True once the body has come to rest on the floor. */ + var isAsleep: Boolean = false + private set + + /** + * Advance the simulation by [frameTimeNanos] nanoseconds from [prevFrameTimeNanos]. + * + * Call this from a [Node.onFrame] lambda or from a [Scene] `onFrame` block. + */ + fun step(frameTimeNanos: Long, prevFrameTimeNanos: Long?) { + if (isAsleep) return + + val dt = frameTimeNanos.intervalSeconds(prevFrameTimeNanos).toFloat() + // Clamp dt to avoid huge jumps after e.g. a GC pause or first frame. + val safeDt = dt.coerceIn(0f, 0.05f) + + // Apply gravity to vertical velocity. + velocity = Position( + x = velocity.x, + y = velocity.y + GRAVITY * safeDt, + z = velocity.z + ) + + // Integrate position. + val pos = node.position + var newPos = Position( + x = pos.x + velocity.x * safeDt, + y = pos.y + velocity.y * safeDt, + z = pos.z + velocity.z * safeDt + ) + + // Floor collision: the bottom of the sphere is at (centre.y - radius). + val contactY = floorY + radius + if (newPos.y < contactY) { + newPos = Position(newPos.x, contactY, newPos.z) + val reboundVy = -velocity.y * restitution + velocity = Position(velocity.x, reboundVy, velocity.z) + + // Put the body to sleep when the rebound speed is negligible. + if (kotlin.math.abs(reboundVy) < SLEEP_THRESHOLD) { + velocity = Position(velocity.x, 0f, velocity.z) + isAsleep = true + } + } + + node.position = newPos + } +} + +// ── Composable DSL helper ───────────────────────────────────────────────────────────────────────── + +/** + * Attaches a [PhysicsBody] to [node] and steps the simulation each frame via [Node.onFrame]. + * + * This is a pure-Kotlin, no-library physics integration intended as a lightweight prototype. + * It supports: + * - Gravity (9.8 m/s²) along -Y + * - Bouncy floor collision with a configurable coefficient of restitution + * - Sleep detection to halt integration once the body comes to rest + * + * Usage inside a `Scene { }` block: + * ```kotlin + * Scene(...) { + * val sphereNode = remember(engine) { SphereNode(engine, radius = 0.15f) } + * PhysicsNode( + * node = sphereNode, + * mass = 1f, + * restitution = 0.7f, + * linearVelocity = Position(x = 0.5f, y = 2f, z = 0f) + * ) + * } + * ``` + * + * @param node The [Node] to animate. Must already be added to the scene by the caller. + * @param mass Object mass in kg (reserved; not yet used in force calculations). + * @param restitution Bounciness in [0, 1]. + * @param linearVelocity Initial velocity in m/s. + * @param floorY World Y of the floor plane (default 0). + * @param radius Collision radius in metres — offsets the contact point so the sphere + * surface, not its centre, lands on the floor. + */ +@Composable +fun PhysicsNode( + node: Node, + mass: Float = 1f, + restitution: Float = 0.6f, + linearVelocity: Position = Position(0f, 0f, 0f), + floorY: Float = 0f, + radius: Float = 0f +) { + val body = remember(node) { + PhysicsBody( + node = node, + mass = mass, + restitution = restitution, + floorY = floorY, + radius = radius, + initialVelocity = linearVelocity + ) + } + + DisposableEffect(node) { + var prevFrameTime: Long? = null + node.onFrame = { frameTimeNanos -> + body.step(frameTimeNanos, prevFrameTime) + prevFrameTime = frameTimeNanos + } + onDispose { + node.onFrame = null + } + } +} diff --git a/settings.gradle b/settings.gradle index bb3db70d..c4201412 100644 --- a/settings.gradle +++ b/settings.gradle @@ -21,3 +21,4 @@ include ':samples:model-viewer' include ':samples:camera-manipulator' include ':samples:autopilot-demo' include ':samples:post-processing' +include ':samples:physics-demo' From 561c9cd07fa9267c7fa0186a5814184f9773204e Mon Sep 17 00:00:00 2001 From: Thomas Gorisse Date: Fri, 20 Mar 2026 20:22:04 +0100 Subject: [PATCH 3/3] feat(sceneview): add BillboardNode, TextNode, and text-labels sample (3.4.0 roadmap) - BillboardNode: ImageNode subclass that rotates to face the camera every frame via onFrame + lookAt(cameraPositionProvider()). Accepts bitmap, widthMeters/heightMeters for world-space quad sizing. - TextNode: BillboardNode subclass that renders text into a Bitmap via Android Canvas (rounded-rect background + centred bold text). Reactive properties: text, fontSize, textColor, backgroundColor each trigger refreshBitmap() on change. - SceneScope: exposes BillboardNode {} and TextNode {} composable DSL functions with full parameter documentation. - samples/text-labels: 3D scene with three coloured spheres (Planet A/B/C), each with a floating TextNode label that always faces the orbiting camera. Tap a sphere to cycle its label through a set of strings. Build verified: :samples:text-labels:assembleDebug passes clean (77/77 tasks). Co-Authored-By: Claude Sonnet 4.6 --- samples/text-labels/build.gradle | 52 +++++ .../text-labels/src/main/AndroidManifest.xml | 20 ++ .../sample/textlabels/MainActivity.kt | 183 ++++++++++++++++++ .../src/main/res/values/strings.xml | 3 + .../java/io/github/sceneview/SceneScope.kt | 101 +++++++++- .../io/github/sceneview/node/BillboardNode.kt | 71 +++++++ .../java/io/github/sceneview/node/TextNode.kt | 160 +++++++++++++++ settings.gradle | 1 + 8 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 samples/text-labels/build.gradle create mode 100644 samples/text-labels/src/main/AndroidManifest.xml create mode 100644 samples/text-labels/src/main/java/io/github/sceneview/sample/textlabels/MainActivity.kt create mode 100644 samples/text-labels/src/main/res/values/strings.xml create mode 100644 sceneview/src/main/java/io/github/sceneview/node/BillboardNode.kt create mode 100644 sceneview/src/main/java/io/github/sceneview/node/TextNode.kt diff --git a/samples/text-labels/build.gradle b/samples/text-labels/build.gradle new file mode 100644 index 00000000..11fc8c85 --- /dev/null +++ b/samples/text-labels/build.gradle @@ -0,0 +1,52 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.compose' +} + +android { + namespace "io.github.sceneview.sample.textlabels" + + compileSdk 36 + + defaultConfig { + applicationId "io.github.sceneview.sample.textlabels" + minSdk 28 + targetSdk 36 + versionCode 1 + versionName "1.0.0" + } + + buildTypes { + release { + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + } + buildFeatures { + compose true + } + androidResources { + noCompress 'filamat', 'ktx' + } +} + +dependencies { + implementation project(":samples:common") + + implementation "androidx.compose.ui:ui:1.10.5" + implementation "androidx.compose.foundation:foundation:1.10.5" + implementation 'androidx.activity:activity-compose:1.10.1' + implementation 'androidx.compose.material:material:1.10.5' + implementation "androidx.compose.material3:material3:1.3.2" + implementation "androidx.compose.ui:ui-tooling-preview:1.10.5" + debugImplementation "androidx.compose.ui:ui-tooling:1.10.5" + + // SceneView + implementation project(":sceneview") +} diff --git a/samples/text-labels/src/main/AndroidManifest.xml b/samples/text-labels/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0f176cff --- /dev/null +++ b/samples/text-labels/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/samples/text-labels/src/main/java/io/github/sceneview/sample/textlabels/MainActivity.kt b/samples/text-labels/src/main/java/io/github/sceneview/sample/textlabels/MainActivity.kt new file mode 100644 index 00000000..13f8fef1 --- /dev/null +++ b/samples/text-labels/src/main/java/io/github/sceneview/sample/textlabels/MainActivity.kt @@ -0,0 +1,183 @@ +package io.github.sceneview.sample.textlabels + +import android.os.Bundle +import android.view.MotionEvent +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.github.sceneview.Scene +import io.github.sceneview.animation.Transition.animateRotation +import io.github.sceneview.math.Position +import io.github.sceneview.math.Rotation +import io.github.sceneview.rememberCameraManipulator +import io.github.sceneview.rememberCameraNode +import io.github.sceneview.rememberEngine +import io.github.sceneview.rememberEnvironment +import io.github.sceneview.rememberEnvironmentLoader +import io.github.sceneview.rememberMaterialLoader +import io.github.sceneview.rememberModelLoader +import io.github.sceneview.rememberNode +import io.github.sceneview.rememberOnGestureListener +import io.github.sceneview.sample.SceneviewTheme +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit.MILLISECONDS + +/** Describes one labelled 3D object in the scene. */ +private data class LabelledObject( + val position: Position, + val color: Int, // ARGB packed colour for the sphere material + val defaultLabel: String, + val radius: Float = 0.15f +) + +private val objects = listOf( + LabelledObject(Position(x = -0.8f, y = 0f, z = 0f), 0xFF4C8BF5.toInt(), "Planet A"), + LabelledObject(Position(x = 0f, y = 0f, z = 0f), 0xFF34A853.toInt(), "Planet B"), + LabelledObject(Position(x = 0.8f, y = 0f, z = 0f), 0xFFEA4335.toInt(), "Planet C"), +) + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + SceneviewTheme { + Box(modifier = Modifier.fillMaxSize()) { + + val engine = rememberEngine() + val materialLoader = rememberMaterialLoader(engine) + val environmentLoader = rememberEnvironmentLoader(engine) + + // One mutable label string per object — drives recomposition + val labels = remember { + mutableStateListOf(*objects.map { it.defaultLabel }.toTypedArray()) + } + + // Camera world position updated every frame so TextNodes can face the camera + var cameraPos by remember { mutableStateOf(Position(x = 0f, y = 1f, z = 3f)) } + + val centerNode = rememberNode(engine) + val cameraNode = rememberCameraNode(engine) { + position = Position(x = 0f, y = 1f, z = 3f) + lookAt(centerNode) + centerNode.addChildNode(this) + } + + // Slowly orbit the camera around the scene centre + val cameraTransition = rememberInfiniteTransition(label = "CameraOrbit") + val cameraRotation by cameraTransition.animateRotation( + initialValue = Rotation(y = 0f), + targetValue = Rotation(y = 360f), + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 12.seconds.toInt(MILLISECONDS)) + ) + ) + + val environment = rememberEnvironment(environmentLoader) { + environmentLoader.createHDREnvironment("environments/sky_2k.hdr")!! + } + + Scene( + modifier = Modifier.fillMaxSize(), + engine = engine, + modelLoader = rememberModelLoader(engine), + materialLoader = materialLoader, + cameraNode = cameraNode, + cameraManipulator = rememberCameraManipulator( + orbitHomePosition = cameraNode.worldPosition, + targetPosition = centerNode.worldPosition + ), + environment = environment, + onFrame = { + centerNode.rotation = cameraRotation + cameraNode.lookAt(centerNode) + cameraPos = cameraNode.worldPosition + }, + onGestureListener = rememberOnGestureListener() + ) { + // Invisible pivot — the camera's orbit parent + Node(apply = { centerNode.addChildNode(this) }) + + objects.forEachIndexed { index, obj -> + + // Pre-built PBR material for this sphere's colour + val sphereMaterial = remember(materialLoader, obj.color) { + materialLoader.createColorInstance(color = obj.color) + } + + // The 3D sphere — tap it to cycle the label + SphereNode( + radius = obj.radius, + materialInstance = sphereMaterial, + apply = { + position = obj.position + isTouchable = true + onSingleTapConfirmed = { _: MotionEvent -> + labels[index] = nextLabel(labels[index], obj.defaultLabel) + true + } + } + ) + + // Floating text label above the sphere — always faces the camera + TextNode( + text = labels[index], + fontSize = 52f, + textColor = android.graphics.Color.WHITE, + backgroundColor = 0xCC1A1A2E.toInt(), + widthMeters = 0.55f, + heightMeters = 0.18f, + position = Position( + x = obj.position.x, + y = obj.position.y + obj.radius + 0.22f, + z = obj.position.z + ), + cameraPositionProvider = { cameraPos } + ) + } + } + + // Bottom hint + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp, start = 16.dp, end = 16.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.85f), + shape = MaterialTheme.shapes.medium + ) { + Text( + text = "Tap a planet to change its label", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + } + } +} + +/** Cycles the current label through a small set of values. */ +private fun nextLabel(current: String, default: String): String { + val options = listOf(default, "Tap again!", "Relabelled", default) + val idx = options.indexOf(current) + return options[(idx + 1) % options.size] +} diff --git a/samples/text-labels/src/main/res/values/strings.xml b/samples/text-labels/src/main/res/values/strings.xml new file mode 100644 index 00000000..8e8e8179 --- /dev/null +++ b/samples/text-labels/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Text Labels + diff --git a/sceneview/src/main/java/io/github/sceneview/SceneScope.kt b/sceneview/src/main/java/io/github/sceneview/SceneScope.kt index c0c1fc7e..12a65191 100644 --- a/sceneview/src/main/java/io/github/sceneview/SceneScope.kt +++ b/sceneview/src/main/java/io/github/sceneview/SceneScope.kt @@ -29,6 +29,7 @@ import io.github.sceneview.math.Rotation import io.github.sceneview.math.Scale import io.github.sceneview.math.Size import io.github.sceneview.model.ModelInstance +import io.github.sceneview.node.BillboardNode as BillboardNodeImpl import io.github.sceneview.node.CameraNode as CameraNodeImpl import io.github.sceneview.node.CubeNode as CubeNodeImpl import io.github.sceneview.node.CylinderNode as CylinderNodeImpl @@ -39,6 +40,7 @@ import io.github.sceneview.node.ModelNode as ModelNodeImpl import io.github.sceneview.node.Node as NodeImpl import io.github.sceneview.node.PlaneNode as PlaneNodeImpl import io.github.sceneview.node.SphereNode as SphereNodeImpl +import io.github.sceneview.node.TextNode as TextNodeImpl import io.github.sceneview.node.VideoNode as VideoNodeImpl import io.github.sceneview.node.ViewNode as ViewNodeImpl @@ -569,7 +571,104 @@ open class SceneScope @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) constru NodeLifecycle(node, content) } - // ── VideoNode ───────────────────────────────────────────────────────────────────────────────── + // ── BillboardNode ───────────────────────────────────────────────────────────────────────────── + + /** + * A flat quad node that always faces the camera (billboard behaviour). + * + * Pass a [Bitmap] and optionally explicit [widthMeters]/[heightMeters] to control the world- + * space size of the quad. Provide [cameraPositionProvider] so the node can rotate toward the + * camera every frame. + * + * @param bitmap The bitmap texture to display. + * @param widthMeters Quad width in meters (`null` derives from bitmap aspect ratio). + * @param heightMeters Quad height in meters (`null` derives from bitmap aspect ratio). + * @param position Local position. + * @param cameraPositionProvider Lambda returning the camera world position every frame. + * @param apply Additional configuration on the [BillboardNodeImpl]. + * @param content Optional child nodes. + */ + @Composable + fun BillboardNode( + bitmap: Bitmap, + widthMeters: Float? = null, + heightMeters: Float? = null, + position: Position = Position(x = 0f), + cameraPositionProvider: (() -> Position)? = null, + apply: BillboardNodeImpl.() -> Unit = {}, + content: (@Composable NodeScope.() -> Unit)? = null + ) { + val node = remember(engine, materialLoader, bitmap) { + BillboardNodeImpl( + materialLoader = materialLoader, + bitmap = bitmap, + widthMeters = widthMeters, + heightMeters = heightMeters, + cameraPositionProvider = cameraPositionProvider + ).apply(apply) + } + SideEffect { + node.bitmap = bitmap + node.position = position + } + NodeLifecycle(node, content) + } + + // ── TextNode ────────────────────────────────────────────────────────────────────────────────── + + /** + * A 3D text-label node that always faces the camera. + * + * Text is rendered to an Android [android.graphics.Bitmap] via [android.graphics.Canvas] and + * displayed on a flat quad that rotates toward the camera each frame. + * + * @param text The string to display. + * @param fontSize Font size in pixels used when rendering the bitmap (default 48). + * @param textColor ARGB text colour (default opaque white). + * @param backgroundColor ARGB background fill colour (default semi-transparent black). + * @param widthMeters Quad width in meters (default 0.6). + * @param heightMeters Quad height in meters (default 0.2). + * @param position Local position. + * @param cameraPositionProvider Lambda returning the camera world position every frame. + * @param apply Additional configuration on the [TextNodeImpl]. + * @param content Optional child nodes. + */ + @Composable + fun TextNode( + text: String, + fontSize: Float = 48f, + textColor: Int = android.graphics.Color.WHITE, + backgroundColor: Int = 0xCC000000.toInt(), + widthMeters: Float = 0.6f, + heightMeters: Float = 0.2f, + position: Position = Position(x = 0f), + cameraPositionProvider: (() -> Position)? = null, + apply: TextNodeImpl.() -> Unit = {}, + content: (@Composable NodeScope.() -> Unit)? = null + ) { + val node = remember(engine, materialLoader) { + TextNodeImpl( + materialLoader = materialLoader, + text = text, + fontSize = fontSize, + textColor = textColor, + backgroundColor = backgroundColor, + widthMeters = widthMeters, + heightMeters = heightMeters, + cameraPositionProvider = cameraPositionProvider + ).apply(apply) + } + SideEffect { + node.text = text + node.fontSize = fontSize + node.textColor = textColor + node.backgroundColor = backgroundColor + node.position = position + } + NodeLifecycle(node, content) + } + + // ── VideoNode ───────────────────────────────────────────────────────────────────────────────── /** * A node that renders video from an Android [android.media.MediaPlayer] onto a flat plane in diff --git a/sceneview/src/main/java/io/github/sceneview/node/BillboardNode.kt b/sceneview/src/main/java/io/github/sceneview/node/BillboardNode.kt new file mode 100644 index 00000000..c9cb43fa --- /dev/null +++ b/sceneview/src/main/java/io/github/sceneview/node/BillboardNode.kt @@ -0,0 +1,71 @@ +package io.github.sceneview.node + +import android.graphics.Bitmap +import io.github.sceneview.loaders.MaterialLoader +import io.github.sceneview.math.Position +import io.github.sceneview.math.Size + +/** + * A node that always faces the camera (billboard behaviour). + * + * A [BillboardNode] is an [ImageNode] (flat quad with a bitmap texture) that rotates toward the + * camera every frame. Pass a [Bitmap] to display on the quad; call [setBitmap] to update it at + * any time. + * + * Usage inside a [io.github.sceneview.SceneScope]: + * ```kotlin + * Scene(onFrame = { cameraPos = cameraNode.worldPosition }) { + * BillboardNode( + * materialLoader = materialLoader, + * bitmap = myBitmap, + * widthMeters = 0.5f, + * heightMeters = 0.25f, + * cameraPositionProvider = { cameraPos } + * ) + * } + * ``` + * + * @param materialLoader MaterialLoader used to create the image material instance. + * @param bitmap The bitmap texture to display on the quad. + * @param widthMeters Width of the quad in world-space meters. Pass `null` to derive from + * the bitmap's aspect ratio (longer edge = 1 unit). + * @param heightMeters Height of the quad in world-space meters. Pass `null` to derive + * from the bitmap's aspect ratio. + * @param cameraPositionProvider Lambda invoked every frame to obtain the current camera world + * position. The node rotates to face this position. + */ +open class BillboardNode( + materialLoader: MaterialLoader, + bitmap: Bitmap, + widthMeters: Float? = null, + heightMeters: Float? = null, + private val cameraPositionProvider: (() -> Position)? = null +) : ImageNode( + materialLoader = materialLoader, + bitmap = bitmap, + size = if (widthMeters != null && heightMeters != null) Size(widthMeters, heightMeters) else null +) { + + init { + // Register a per-frame callback to keep the node facing the camera. + onFrame = { _ -> + cameraPositionProvider?.invoke()?.let { camPos -> + lookAt(targetWorldPosition = camPos) + } + } + } + + /** + * Convenience setter — updates the displayed bitmap and (optionally) re-sizes the quad. + */ + fun updateBitmap( + newBitmap: Bitmap, + widthMeters: Float? = null, + heightMeters: Float? = null + ) { + bitmap = newBitmap + if (widthMeters != null && heightMeters != null) { + updateGeometry(size = Size(widthMeters, heightMeters)) + } + } +} diff --git a/sceneview/src/main/java/io/github/sceneview/node/TextNode.kt b/sceneview/src/main/java/io/github/sceneview/node/TextNode.kt new file mode 100644 index 00000000..91ff8247 --- /dev/null +++ b/sceneview/src/main/java/io/github/sceneview/node/TextNode.kt @@ -0,0 +1,160 @@ +package io.github.sceneview.node + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import io.github.sceneview.loaders.MaterialLoader +import io.github.sceneview.math.Position + +/** + * A node that displays a text label in 3D world space. + * + * [TextNode] internally renders a [Bitmap] with [android.graphics.Canvas] and delegates to + * [BillboardNode] for camera-facing behaviour. The bitmap is re-rendered whenever [text], + * [fontSize], [textColor], or [backgroundColor] changes. + * + * Usage inside a [io.github.sceneview.SceneScope]: + * ```kotlin + * Scene(onFrame = { cameraPos = cameraNode.worldPosition }) { + * TextNode( + * materialLoader = materialLoader, + * text = "Hello 3D!", + * fontSize = 48f, + * textColor = android.graphics.Color.WHITE, + * backgroundColor = 0xCC000000.toInt(), + * widthMeters = 0.6f, + * heightMeters = 0.2f, + * cameraPositionProvider = { cameraPos } + * ) + * } + * ``` + * + * @param materialLoader MaterialLoader used to create the image material instance. + * @param text The string to display. + * @param fontSize Font size in pixels used when rendering the bitmap texture. + * @param textColor ARGB text colour (default opaque white). + * @param backgroundColor ARGB background fill colour (default semi-transparent black). + * @param widthMeters Width of the quad in world-space meters. + * @param heightMeters Height of the quad in world-space meters. + * @param cameraPositionProvider Lambda invoked every frame to obtain the current camera world + * position so the label can face the camera. + * @param bitmapWidth Resolution width of the rendered bitmap in pixels (default 512). + * @param bitmapHeight Resolution height of the rendered bitmap in pixels (default 128). + */ +open class TextNode( + materialLoader: MaterialLoader, + text: String, + fontSize: Float = 48f, + textColor: Int = android.graphics.Color.WHITE, + backgroundColor: Int = 0xCC000000.toInt(), + widthMeters: Float = 0.6f, + heightMeters: Float = 0.2f, + cameraPositionProvider: (() -> Position)? = null, + val bitmapWidth: Int = 512, + val bitmapHeight: Int = 128, +) : BillboardNode( + materialLoader = materialLoader, + bitmap = renderTextBitmap( + text = text, + fontSize = fontSize, + textColor = textColor, + backgroundColor = backgroundColor, + bitmapWidth = 512, + bitmapHeight = 128 + ), + widthMeters = widthMeters, + heightMeters = heightMeters, + cameraPositionProvider = cameraPositionProvider +) { + + var text: String = text + set(value) { + if (field != value) { + field = value + refreshBitmap() + } + } + + var fontSize: Float = fontSize + set(value) { + if (field != value) { + field = value + refreshBitmap() + } + } + + var textColor: Int = textColor + set(value) { + if (field != value) { + field = value + refreshBitmap() + } + } + + var backgroundColor: Int = backgroundColor + set(value) { + if (field != value) { + field = value + refreshBitmap() + } + } + + private fun refreshBitmap() { + bitmap = renderTextBitmap( + text = text, + fontSize = fontSize, + textColor = textColor, + backgroundColor = backgroundColor, + bitmapWidth = bitmapWidth, + bitmapHeight = bitmapHeight + ) + } + + companion object { + /** + * Renders [text] into a new [Bitmap] using Android [Canvas]. + * + * The bitmap has a rounded-rectangle background and horizontally/vertically centred text. + * Alpha is preserved so the material's blending mode controls transparency. + */ + fun renderTextBitmap( + text: String, + fontSize: Float, + textColor: Int, + backgroundColor: Int, + bitmapWidth: Int, + bitmapHeight: Int + ): Bitmap { + val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + // Background with rounded corners + val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = backgroundColor + style = Paint.Style.FILL + } + val cornerRadius = bitmapHeight * 0.2f + canvas.drawRoundRect( + RectF(0f, 0f, bitmapWidth.toFloat(), bitmapHeight.toFloat()), + cornerRadius, + cornerRadius, + bgPaint + ) + + // Centred bold text + val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = textColor + textSize = fontSize + typeface = Typeface.DEFAULT_BOLD + textAlign = Paint.Align.CENTER + } + val xPos = bitmapWidth / 2f + val yPos = (bitmapHeight / 2f) - ((textPaint.descent() + textPaint.ascent()) / 2f) + canvas.drawText(text, xPos, yPos, textPaint) + + return bitmap + } + } +} diff --git a/settings.gradle b/settings.gradle index c4201412..666f0659 100644 --- a/settings.gradle +++ b/settings.gradle @@ -22,3 +22,4 @@ include ':samples:camera-manipulator' include ':samples:autopilot-demo' include ':samples:post-processing' include ':samples:physics-demo' +include ':samples:text-labels'