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/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/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/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/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 252afe5a..666f0659 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -20,3 +20,6 @@ include ':samples:gltf-camera'
include ':samples:model-viewer'
include ':samples:camera-manipulator'
include ':samples:autopilot-demo'
+include ':samples:post-processing'
+include ':samples:physics-demo'
+include ':samples:text-labels'