| name | physics |
| description | Add BVH-based physics for static/kinematic geometry with @pmndrs/viverse — physics worlds, character controllers, ground detection, and sensor volumes. Use when a game needs collision, a character controller, or enter/exit trigger volumes. |
Physics
Physics using BVH (Bounding Volume Hierarchy) for static/kinematic geometry. Dynamic bodies cannot interact with each other yet.
Physics World
Creating a physics world
import { BvhPhysicsWorld } from '@pmndrs/viverse'
const world = new BvhPhysicsWorld()
addBody(object, kinematic) — the second argument is whether the body is kinematic (script-moved, like a platform or train), not whether it is static. Pass false for static world geometry (ground, walls), true for things you move yourself.
world.addBody(ground, false)
world.addBody(train, true)
Watch the inversion: addBody's flag is kinematic, but addSensor's second flag below is isStatic — the booleans mean opposite things.
Character Controller
BvhCharacterPhysics is the character controller — it moves and collides the character body. It does not animate the character and is not a camera. For a full controllable humanoid, compose it with CharacterCameraBehavior (camera) and Acta for all animation and the motion it produces (see the acta skill). Do not use SimpleCharacter — it bundles its own animation that conflicts with Acta.
Creating a character controller
import { BvhCharacterPhysics, type BvhCharacterPhysicsOptions } from '@pmndrs/viverse'
const characterPhysics = new BvhCharacterPhysics(world)
The controller assumes the character object's origin sits at its feet (the bottom of the capsule). If your model's origin is at its center or head, the character floats or sinks — wrap it in a Group whose origin is at the feet. Tuning lives in the optional BvhCharacterPhysicsOptions (third arg to update): capsuleRadius, capsuleHeight, gravity, maxGroundSlope, updatesPerSecond, linearDamping.
Driving it
characterPhysics.inputVelocity.copy(velocity)
characterPhysics.applyVelocity(jumpImpulse)
For an Acta character the path of information is input → Acta → physics: you pass Acta a movement intent (a camera-relative moveDirection, length 0..1), Acta turns the active animation into a desired velocity and hands it to you in its motion callback, and you set inputVelocity from that velocity and step the controller there. Do not set inputVelocity from raw input, and do not move the character anywhere else. See the acta skill.
Updating the controller
Call every frame after setting velocity:
characterPhysics.update(character, delta)
Ground State
characterPhysics.isGrounded
What the controller does not do — build these yourself
The controller gives you exactly two primitives — inputVelocity (continuous) and applyVelocity (impulse) — plus isGrounded. The rules a specific game layers on top are yours to write:
- Multi-jump / double-jump, coyote-time, jump buffering: there is no jump counter — gate the first
applyVelocity on isGrounded, track your own air-jump count, and reset it when grounded.
- Wall-jump / wall-slide:
isGrounded reports ground only, not walls. Detect a wall with a horizontal world.raycast(ray, far) from the capsule — the returned intersection gives the hit distance and surface normal — then applyVelocity away-from-wall + up.
- Moving-platform ride-along: standing on a kinematic platform does not carry the character. Add the platform's per-frame delta to the character yourself while it stands on it.
- Falling off the world / out of bounds:
isGrounded is true only when the capsule's downward shapecast hits collision geometry — past the edge of a finite ground mesh there is nothing to hit, so the character falls forever and stays stuck in its airborne/fall pose (which often reads as an unintended "flying" or glide state). The controller will not stop this. Keep the player on solid ground yourself: clamp position to the collidable area, add an out-of-bounds floor plane or a kill-barrier that respawns, rather than assuming the world is infinite. Bound the player to the physics mesh, which may extend past the visible play area.
These are gameplay rules, not missing features — the controller stays deliberately minimal so each game tunes its own feel.
Sensors
Sensors detect when the character enters or exits a volume.
Adding a sensor
world.addSensor(object, isStatic, (intersected) => {
if (intersected) {
} else {
}
})
Removing a sensor
world.removeSensor(object)
If you prefer a single callback that provides the sensor object that the character entered, do the following
function onEnter(object: Object3D) {}
world.addSensor(obj1, isStatic, (intersected) => intersected && onEnter(obj1))
world.addSensor(obj2, isStatic, (intersected) => intersected && onEnter(obj2))