From cc2ae31c986693bc1de662ee1ff02b0711411783 Mon Sep 17 00:00:00 2001 From: Thomas Gorisse Date: Fri, 20 Mar 2026 20:04:53 +0100 Subject: [PATCH] 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 000000000..8b612d6a0 --- /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 000000000..0f176cffe --- /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 000000000..b739b5ed0 --- /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 000000000..eb5c51c12 --- /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 000000000..76a53d278 --- /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 bb3db70d7..c4201412a 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'