Skip to content

Character Controller and Vehicle

The Rapier edition ships with two high-level components: the character controller CharacterController for first-/third-person movement, and the vehicle controller VehicleController for vehicle simulation.

Character Controller (CharacterController)

The character controller is a kinematic controller with built-in handling for slope climbing, steps, ground snapping, and so on, making it suitable for playable, walkable player characters. Drive movement via controller.move(displacement):

ts
import { Object3D, Vector3 } from '@orillusion/core';
import { CharacterController } from '@orillusion/physics-rapier';

const player = new Object3D();
const cc = player.addComponent(CharacterController);
cc.maxSlopeClimbAngle = (50 * Math.PI) / 180; // Maximum climbable slope angle (radians)
cc.snapToGround = 0.5;                          // Ground snap distance
scene.addChild(player);

// Drive movement per frame based on input (displacement vector, already including gravity/jump components)
cc.move(new Vector3(vx * speed * dt, vy * dt, vz * speed * dt));
MemberDescription
move(displacement)Drives the character with a displacement vector (automatically handles collision sliding, slope climbing, steps)
maxSlopeClimbAngleMaximum climbable slope (radians)
snapToGroundGround snap distance when stepping down

Gravity and jumping must be accumulated into the Y component of the displacement yourself (accumulate vertical velocity vy, reset to zero after landing).

Example

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
import { View3D, Scene3D, CameraUtil, HoverCameraController, DirectLight, AtmosphericComponent, Object3D, LitMaterial, Engine3D, BoxGeometry, MeshRenderer, Vector3, PlaneGeometry, Color, KeyEvent, KeyCode, ComponentBase, Time } from "@orillusion/core";
import { Physics, Rigidbody, BodyType, CollisionShapeUtil, CharacterController, RAPIER } from "@orillusion/physics-rapier";

class CharacterDriver extends ComponentBase {
    public controller!: CharacterController;
    private keys: Record<string, boolean> = {};
    public speed: number = 4;
    public gravity: number = -9.8;
    public jumpVel: number = 6;
    private vy: number = 0;

    public start() {
        const input = this.transform.scene3D.view.engine3D.inputSystem;
        input.addEventListener(KeyEvent.KEY_DOWN, (e: KeyEvent) => this.keys[e.keyCode] = true, this);
        input.addEventListener(KeyEvent.KEY_UP, (e: KeyEvent) => this.keys[e.keyCode] = false, this);
    }

    public onUpdate() {
        if (!this.controller) return;
        const dt = Time.delta * 0.001;
        let vx = 0, vz = 0;
        if (this.keys[KeyCode.Key_W]) vz -= 1;
        if (this.keys[KeyCode.Key_S]) vz += 1;
        if (this.keys[KeyCode.Key_A]) vx -= 1;
        if (this.keys[KeyCode.Key_D]) vx += 1;
        const len = Math.hypot(vx, vz);
        if (len > 0) { vx /= len; vz /= len; }

        if (this.controller.isGrounded()) {
            this.vy = 0;
            if (this.keys[KeyCode.Key_Space]) this.vy = this.jumpVel;
        } else {
            this.vy += this.gravity * dt;
        }

        this.controller.move(new Vector3(vx * this.speed * dt, this.vy * dt, vz * this.speed * dt));
    }
}

class Sample_RapierCharacter {
    async run() {
        await Physics.init();
        const engine = await Engine3D.init({ renderLoop: () => Physics.update() });
        let scene = new Scene3D();

        let camera = CameraUtil.createCamera3DObject(scene);
        camera.perspective(60, engine.aspect, 0.1, 800.0);

        let hoverCtrl = camera.object3D.addComponent(HoverCameraController);
        hoverCtrl.setCamera(0, -25, 15);
        hoverCtrl.dragSmooth = 4;

        let lightObj3D = new Object3D();
        lightObj3D.localRotation = new Vector3(-35, -143, 92);

        let light = lightObj3D.addComponent(DirectLight);
        light.lightColor = Color.COLOR_WHITE;
        light.castShadow = true;
        light.enableCSM = true;
        light.intensity = 2.2;
        scene.addChild(light.object3D);

        let atmosphericSky = scene.addComponent(AtmosphericComponent);
        atmosphericSky.sunY = 0.6;

        const floor = new Object3D();
        const fr = floor.addComponent(MeshRenderer);
        fr.geometry = new PlaneGeometry(60, 60);
        const fm = new LitMaterial(); fm.baseColor = new Color(0.4, 0.4, 0.45); fm.roughness = 0.8; fr.material = fm;
        const fb = floor.addComponent(Rigidbody);
        fb.bodyType = BodyType.Static; fb.shape = CollisionShapeUtil.createPlaneShape(30, 0.05);
        scene.addChild(floor);

        for (let i = 0; i < 6; i++) {
            const step = new Object3D(); step.x = 8; step.y = 0.25 + i * 0.5; step.z = -i * 1.2;
            const mr = step.addComponent(MeshRenderer);
            mr.geometry = new BoxGeometry(3, 0.5, 1.2);
            const mat = new LitMaterial(); mat.baseColor = new Color(0.6, 0.5, 0.4); mr.material = mat;
            const rb = step.addComponent(Rigidbody);
            rb.bodyType = BodyType.Static; rb.shape = CollisionShapeUtil.createBoxShape(step, new Vector3(3, 0.5, 1.2));
            scene.addChild(step);
        }

        const player = new Object3D(); player.y = 2;
        const pmr = player.addComponent(MeshRenderer);
        pmr.geometry = new BoxGeometry(0.6, 1.6, 0.6);
        const pm = new LitMaterial(); pm.baseColor = new Color(0.3, 0.5, 0.9); pmr.material = pm;
        const cc = player.addComponent(CharacterController);
        cc.shape = RAPIER.ColliderDesc.capsule(0.6, 0.3);
        cc.maxSlopeClimbAngle = (50 * Math.PI) / 180;
        cc.snapToGround = 0.5;
        cc.autoStep = { maxHeight: 0.6, minWidth: 0.2, includeDynamic: true };
        const drv = player.addComponent(CharacterDriver);
        drv.controller = cc;
        scene.addChild(player);

        let view = new View3D();
        view.camera = camera;
        view.scene = scene;
        engine.startRenderView(view);
    }
}

new Sample_RapierCharacter().run();

Vehicle Controller (VehicleController)

The vehicle controller is based on a raycast wheel model: add a VehicleController to the chassis rigidbody, then add wheels one by one via addWheel, and control them at runtime with setEngineForce / setBrake / setSteering.

ts
import { Object3D, Vector3 } from '@orillusion/core';
import { Rigidbody, BodyType, CollisionShapeUtil, VehicleController } from '@orillusion/physics-rapier';

// Chassis (dynamic rigidbody)
const chassis = new Object3D();
chassis.y = 2;
const crb = chassis.addComponent(Rigidbody);
crb.bodyType = BodyType.Dynamic;
crb.shape = CollisionShapeUtil.createBoxShape(chassis, new Vector3(2, 0.6, 4));
scene.addChild(chassis);

// Vehicle controller + four wheels
const vc = chassis.addComponent(VehicleController);
const wheelOpts = {
    suspensionRestLength: 0.3,
    radius: 0.4,
    stiffness: 30,
    dampingCompression: 4.4,
    dampingRelaxation: 2.3,
    frictionSlip: 1000,
};
vc.addWheel({ ...wheelOpts, chassisConnection: new Vector3(-1, -0.3, -1.5) }); // 0 front left
vc.addWheel({ ...wheelOpts, chassisConnection: new Vector3( 1, -0.3, -1.5) }); // 1 front right
vc.addWheel({ ...wheelOpts, chassisConnection: new Vector3(-1, -0.3,  1.5) }); // 2 rear left
vc.addWheel({ ...wheelOpts, chassisConnection: new Vector3( 1, -0.3,  1.5) }); // 3 rear right

Runtime control (by wheel index):

ts
// Rear-wheel drive
vc.setEngineForce(engineForce, 2);
vc.setEngineForce(engineForce, 3);
// Front-wheel steering
vc.setSteering(steerAngle, 0);
vc.setSteering(steerAngle, 1);
// Braking (all wheels)
for (let i = 0; i < vc.numWheels(); i++) vc.setBrake(brakeForce, i);
MethodDescription
addWheel(opts)Adds a wheel (with suspension, radius, connection point, etc.)
setEngineForce(force, wheelIndex)Sets the engine drive force for a wheel
setBrake(force, wheelIndex)Sets the brake force for a wheel
setSteering(angle, wheelIndex)Sets the steering angle for a wheel
numWheels()Number of wheels

Example

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
import { Scene3D, CameraUtil, HoverCameraController, DirectLight, View3D, AtmosphericComponent, Object3D, LitMaterial, Engine3D, BoxGeometry, CylinderGeometry, MeshRenderer, Vector3, Color, KeyEvent, KeyCode, ComponentBase } from "@orillusion/core";
import { Physics, Rigidbody, BodyType, CollisionShapeUtil, VehicleController } from "@orillusion/physics-rapier";

const RAD2DEG = 180 / Math.PI;

class VehicleDriver extends ComponentBase {
    public vehicle!: VehicleController;
    private keys: Record<string, boolean> = {};
    public engineForce: number = 1500;
    public brakeForce: number = 100;
    public maxSteer: number = 0.5;

    public start() {
        const input = this.transform.scene3D.view.engine3D.inputSystem;
        input.addEventListener(KeyEvent.KEY_DOWN, (e: KeyEvent) => this.keys[e.keyCode] = true, this);
        input.addEventListener(KeyEvent.KEY_UP, (e: KeyEvent) => this.keys[e.keyCode] = false, this);
    }

    public onUpdate() {
        if (!this.vehicle || this.vehicle.numWheels() < 4) return;
        const fwd = this.keys[KeyCode.Key_W] ? this.engineForce : (this.keys[KeyCode.Key_S] ? -this.engineForce : 0);
        const brake = this.keys[KeyCode.Key_Space] ? this.brakeForce : 0;
        const steer = this.keys[KeyCode.Key_A] ? this.maxSteer : (this.keys[KeyCode.Key_D] ? -this.maxSteer : 0);

        this.vehicle.setEngineForce(fwd, 2);
        this.vehicle.setEngineForce(fwd, 3);
        for (let i = 0; i < 4; i++) this.vehicle.setBrake(brake, i);
        this.vehicle.setSteering(steer, 0);
        this.vehicle.setSteering(steer, 1);
    }
}

class WheelVisualSync extends ComponentBase {
    public vehicle!: VehicleController;
    public steerPivots: Object3D[] = [];
    public spinPivots: Object3D[] = [];

    public onUpdate() {
        const ctrl = this.vehicle?.native;
        if (!ctrl) return;
        const n = Math.min(this.steerPivots.length, ctrl.numWheels());
        for (let i = 0; i < n; i++) {
            const conn = ctrl.wheelChassisConnectionPointCs(i);
            const dir = ctrl.wheelDirectionCs(i);
            const len = ctrl.wheelSuspensionLength(i) ?? 0;
            if (!conn || !dir) continue;
            const sp = this.steerPivots[i];
            sp.x = conn.x + dir.x * len;
            sp.y = conn.y + dir.y * len;
            sp.z = conn.z + dir.z * len;
            sp.rotationY = (ctrl.wheelSteering(i) ?? 0) * RAD2DEG;
            this.spinPivots[i].rotationX = (ctrl.wheelRotation(i) ?? 0) * RAD2DEG;
        }
    }
}

class Sample_RapierVehicle {
    async run() {
        await Physics.init();
        const engine = await Engine3D.init({ renderLoop: () => Physics.update() });
        let scene = new Scene3D();

        let camera = CameraUtil.createCamera3DObject(scene);
        camera.perspective(60, engine.aspect, 0.1, 800.0);

        let hoverCtrl = camera.object3D.addComponent(HoverCameraController);
        hoverCtrl.setCamera(0, -25, 15);
        hoverCtrl.dragSmooth = 4;

        let lightObj3D = new Object3D();
        lightObj3D.localRotation = new Vector3(-35, -143, 92);

        let light = lightObj3D.addComponent(DirectLight);
        light.lightColor = Color.COLOR_WHITE;
        light.castShadow = true;
        light.enableCSM = true;
        light.intensity = 2.2;
        scene.addChild(light.object3D);

        let atmosphericSky = scene.addComponent(AtmosphericComponent);
        atmosphericSky.sunY = 0.6;

        const floor = new Object3D();
        const fr = floor.addComponent(MeshRenderer);
        fr.geometry = new BoxGeometry(200, 0.2, 60);
        const fm = new LitMaterial(); fm.baseColor = new Color(0.35, 0.35, 0.4); fr.material = fm;
        const fb = floor.addComponent(Rigidbody);
        fb.bodyType = BodyType.Static;
        fb.shape = CollisionShapeUtil.createBoxShape(floor, new Vector3(200, 0.2, 60));
        fb.friction = 1.0;
        scene.addChild(floor);

        const chassis = new Object3D(); chassis.y = 2;
        const cmr = chassis.addComponent(MeshRenderer);
        cmr.geometry = new BoxGeometry(2, 0.6, 4);
        const cm = new LitMaterial(); cm.baseColor = new Color(0.8, 0.2, 0.2); cmr.material = cm;
        const crb = chassis.addComponent(Rigidbody);
        crb.bodyType = BodyType.Dynamic; crb.mass = 200;
        crb.shape = CollisionShapeUtil.createBoxShape(chassis, new Vector3(2, 0.6, 4));
        crb.linearDamping = 0.1;
        crb.angularDamping = 0.5;
        scene.addChild(chassis);

        const vc = chassis.addComponent(VehicleController);
        const wheelOpts = {
            suspensionRestLength: 0.3,
            radius: 0.4,
            stiffness: 30,
            dampingCompression: 4.4,
            dampingRelaxation: 2.3,
            frictionSlip: 1000,
        };
        vc.addWheel({ ...wheelOpts, chassisConnection: new Vector3(-1, -0.3, -1.5) }); // 0 FL
        vc.addWheel({ ...wheelOpts, chassisConnection: new Vector3(1, -0.3, -1.5) });  // 1 FR
        vc.addWheel({ ...wheelOpts, chassisConnection: new Vector3(-1, -0.3, 1.5) });  // 2 RL
        vc.addWheel({ ...wheelOpts, chassisConnection: new Vector3(1, -0.3, 1.5) });   // 3 RR

        const drv = chassis.addComponent(VehicleDriver);
        drv.vehicle = vc;

        const wheelMat = new LitMaterial();
        wheelMat.baseColor = new Color(0.08, 0.08, 0.08);
        const wheelGeo = new CylinderGeometry(wheelOpts.radius, wheelOpts.radius, 0.3, 24);
        const sync = chassis.addComponent(WheelVisualSync);
        sync.vehicle = vc;
        for (let i = 0; i < 4; i++) {
            const steer = new Object3D();
            const spin = new Object3D();
            const mesh = new Object3D();
            const mr = mesh.addComponent(MeshRenderer);
            mr.geometry = wheelGeo;
            mr.material = wheelMat;
            mesh.rotationZ = 90;
            spin.addChild(mesh);
            steer.addChild(spin);
            chassis.addChild(steer);
            sync.steerPivots.push(steer);
            sync.spinPivots.push(spin);
        }

        let view = new View3D();
        view.camera = camera;
        view.scene = scene;
        engine.startRenderView(view);
    }
}

new Sample_RapierVehicle().run();

Debugging and Dragging

The physics plugin also provides out-of-the-box debug wireframes and a mouse-drag tool:

ts
import { Graphic3D } from '@orillusion/graphic';

// Draw collider wireframes in real time
const graphic = new Graphic3D();
scene.addChild(graphic);
Physics.initDebugDrawer(graphic, { enable: true, updateFreq: 1 });

// Allow dragging dynamic rigidbodies with the mouse
Physics.enableDragger(view);

Released under the MIT License