PHYSICS
Throw the toys around.
Crates and balls drop into a tray and tumble to rest, simulated by Rapier. Click a toy to kick it, drop another, or tidy up and watch them all fall again. Drag around them to orbit. Each toy is a mesh with a body described as data; Rapier owns where it is, and frames stop when everything comes to rest. A chain hangs from a hook, its links held by spherical joints, and Pour marbles adds one instanced mesh with a body per marble. Show colliders draws what Rapier simulates. In a browser with a headset, Enter VR stands you in front of the tray: point a controller at a toy and pull the trigger to kick it, glide with the left thumbstick and turn with the right; when you let go, the Model keeps where you stand.
Bodies as data.
The Model says what each toy is and where it was dropped, never where it is now: that belongs to the simulation, stepped on scene time with fixed steps, as react-three-rapier steps it. A kick is data too, an impulse with a new id that the simulation applies once. Collisions come back as Messages.
Explore the example source →See how it works.
The source running this example. Expand a file to follow the Model, Messages and view.
model.ts
import { glide, perspectiveCamera, SceneSchema, xr } from '@opsydyn/effect-three';
import { Schema } from 'effect';
import { Paint, paintAt } from '../palette';
/** What a toy is: a crate or a ball. */
export const Kind = Schema.Literals(['crate', 'ball']);
export type Kind = typeof Kind.Type;
/**
* A toy in the box. Where it is belongs to the simulation once it is dropped: the Model
* holds where it was dropped from, and how often it has been kicked.
*/
export const Toy = Schema.Struct({
id: Schema.Number,
kind: Kind,
paint: Paint,
x: Schema.Number,
z: Schema.Number,
/** Each kick has a new id, so the simulation applies it once. */
kick: Schema.NullOr(
Schema.Struct({
id: Schema.Number,
impulse: Schema.Tuple([Schema.Number, Schema.Number, Schema.Number]),
}),
),
});
export type Toy = typeof Toy.Type;
/**
* Virtual reality: whether the browser can present it, and whether it is. The button
* shows only when it can.
*/
export const Vr = Schema.Literals(['checking', 'unavailable', 'ready', 'entering', 'presenting']);
export type Vr = typeof Vr.Type;
export const Model = Schema.Struct({
toys: Schema.Array(Toy),
nextId: Schema.Number,
/** Tidying up starts a new round: every toy gets a new key and drops again. */
round: Schema.Number,
/** Collisions that began between toys and anything but marbles, as the simulation reported them. */
bumps: Schema.Number,
/** Marbles poured in: one instanced node, a body per marble. */
marbles: Schema.Number,
/** Whether the physics world draws its colliders and joints as lines. */
showColliders: Schema.Boolean,
camera: SceneSchema.Camera,
vr: Vr,
/** Where the player stands in VR: the sticks glide them, and the Model keeps where. */
player: SceneSchema.Xr,
});
export type Model = typeof Model.Type;
/** The tray's inner half-width: toys drop inside it. */
export const reach = 2.6;
/** Where the `index`th toy drops from: a loose spiral over the middle of the tray. */
export const dropPoint = (index: number) => {
const angle = index * 2.4;
const radius = Math.min(reach - 0.6, 0.5 + index * 0.18);
return { x: Math.cos(angle) * radius, z: Math.sin(angle) * radius };
};
export const toyAt = (id: number): Toy => ({
id,
kind: id % 3 === 0 ? 'ball' : 'crate',
paint: paintAt(id),
...dropPoint(id),
kick: null,
});
export const firstToys = 8;
/** Marbles per pour, and most in the box. */
export const pour = 16;
export const mostMarbles = 96;
export const homeCamera = perspectiveCamera({
fov: 40,
position: [0, 6.5, 8.5],
lookAt: [0, 0.4, 0],
damping: 0.1,
});
export const initialModel: Model = {
toys: Array.from({ length: firstToys }, (_, id) => toyAt(id)),
nextId: firstToys,
round: 0,
bumps: 0,
marbles: 0,
showColliders: false,
camera: homeCamera,
vr: 'checking',
// In front of the tray, facing it.
player: xr({ origin: [0, 0, 4.2], glide: glide() }),
};
message.ts
import { SceneSchema } from '@opsydyn/effect-three';
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Schema } from 'effect';
import { defineMessageUnion } from 'foldkit/message';
export const Vec3 = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number]);
export const Message = defineMessageUnion({
ClickedToy: { key: Schema.String, direction: Vec3 },
ClickedDrop: {},
ClickedTidy: {},
ClickedPour: {},
ToggledColliders: {},
/** Two bodies began or stopped touching; `marble` if either was a marble. */
Bumped: { started: Schema.Boolean, marble: Schema.Boolean },
MadeGesture: { gesture: SceneSchema.Gesture },
CheckedVr: { supported: Schema.Boolean },
ClickedEnterVr: {},
ClickedExitVr: {},
FailedVr: {},
ChangedVrSession: { presenting: Schema.Boolean },
Glided: { moved: SceneSchema.XrMovedDetail },
ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
});
export type Message = typeof Message.Type;
update.ts
import { type Gesture, glided, orbit, orbitOptions, type Vec3 } from '@opsydyn/effect-three';
import { Option } from 'effect';
import { modifyFields } from 'foldkit/struct';
import type { Return } from 'foldkit/update';
import { CheckVr, EnterVr, ExitVr } from './command';
import { Message } from './message';
import { homeCamera, initialModel, type Model, mostMarbles, pour, toyAt, type Toy } from './model';
type AppReturn = Return<Model, Message>;
export const toyKey = (model: Model, toy: Toy): string => `toy:${model.round}:${toy.id}`;
/** The toy a scene key names, in this round. */
const toyOf = (model: Model, key: string): Option.Option<Toy> =>
Option.fromNullishOr(model.toys.find((toy) => toyKey(model, toy) === key));
/**
* A kick away from the camera and up: the click's ray, flattened, plus a lift. The toys
* weigh one unit, so this is also the change in velocity.
*/
const kickAlong = (direction: Vec3): Vec3 => {
const length = Math.hypot(direction[0], direction[2]) || 1;
return [(direction[0] / length) * 2.5, 5, (direction[2] / length) * 2.5];
};
const kicked = (toy: Toy, direction: Vec3): Toy => ({
...toy,
kick: { id: (toy.kick?.id ?? 0) + 1, impulse: kickAlong(direction) },
});
const kick = (model: Model, key: string, direction: Vec3): Model =>
Option.match(toyOf(model, key), {
onNone: () => model,
onSome: (target) => ({
...model,
toys: model.toys.map((toy) => (toy.id === target.id ? kicked(toy, direction) : toy)),
}),
});
/** Most toys a box holds before the oldest goes. */
const capacity = 24;
const dropped = (model: Model): Model => ({
...model,
toys: [...model.toys, toyAt(model.nextId)].slice(-capacity),
nextId: model.nextId + 1,
});
/** Every toy back on its drop point: a new round, so each one drops afresh. */
const tidied = (model: Model): Model => ({
...model,
toys: model.toys.map((toy) => ({ ...toyAt(toy.id), kick: null })),
round: model.round + 1,
});
const orbitLimits = orbitOptions({
maxPolarAngle: Math.PI / 2 - 0.1,
minDistance: 4,
maxDistance: 18,
});
/** Orbit unless the gesture began on a toy (a click on it is a kick). */
const orbited = (model: Model, gesture: Gesture): Model =>
'key' in gesture && gesture.key !== undefined
? model
: { ...model, camera: orbit(model.camera, gesture, orbitLimits) };
export const init = () => ({ model: initialModel, commands: [CheckVr()] });
const vrAvailability = { true: 'ready', false: 'unavailable' } as const;
const vrSession = { true: 'presenting', false: 'ready' } as const;
export const update = (model: Model, message: Message): AppReturn =>
Message.match<AppReturn>(message, {
ClickedToy: ({ key, direction }) => ({ model: kick(model, key, direction) }),
ClickedDrop: () => ({ model: dropped(model) }),
ClickedTidy: () => ({ model: tidied(model) }),
ToggledColliders: () => ({
model: modifyFields(model, { showColliders: (shown) => !shown }),
}),
ClickedPour: () => ({
model: modifyFields(model, { marbles: (marbles) => Math.min(mostMarbles, marbles + pour) }),
}),
Bumped: ({ started, marble }) => ({
// Toys bumping into things; marbles roll in and out of contact all the time.
model: modifyFields(model, { bumps: (bumps) => bumps + Number(started && !marble) }),
}),
MadeGesture: ({ gesture }) => ({ model: orbited(model, gesture) }),
CheckedVr: ({ supported }) => ({
model: modifyFields(model, { vr: () => vrAvailability[`${supported}`] }),
}),
ClickedEnterVr: () => ({
model: modifyFields(model, { vr: () => 'entering' as const }),
commands: [EnterVr()],
}),
ClickedExitVr: () => ({ model, commands: [ExitVr()] }),
FailedVr: () => ({ model: modifyFields(model, { vr: () => 'ready' as const }) }),
ChangedVrSession: ({ presenting }) => ({
model: modifyFields(model, { vr: () => vrSession[`${presenting}`] }),
}),
// The session glides the player with the sticks (pmndrs/xr's controller locomotion);
// when they let go, the Model keeps where they stand.
Glided: ({ moved }) => ({
model: modifyFields(model, { player: (player) => glided(player, moved) }),
}),
ReceivedSceneLifecycle: () => ({ model }),
});
export { homeCamera };
scene.ts
/**
* The toybox: crates and balls dropped into a tray, simulated by Rapier. Each toy is a
* mesh with a dynamic body; the tray is fixed bodies. The Model never holds where a toy
* is now, only where it was dropped and the kicks it has had. Marbles are one instances
* node with a body per instance, and a chain of links hangs from a hook by joints.
*/
import {
body,
box,
contactShadows,
environment,
instance,
instances,
joint,
mesh,
physics,
scene,
type SceneNode,
sphere,
sphericalJoint,
standard,
} from '@opsydyn/effect-three';
import { paintAt, paintColour } from '../palette';
import { type Model, reach, type Toy } from './model';
import { toyKey } from './update';
const dropHeight = 4;
const toyNode = (model: Model, toy: Toy): SceneNode =>
mesh({
key: toyKey(model, toy),
// Staggered upwards by id, so toys dropped together land one after another. (Not by
// place in the list: that shifts as toys come and go, and a new position teleports.)
position: [toy.x, dropHeight + (toy.id % 6) * 0.6, toy.z],
geometry:
toy.kind === 'ball' ? sphere({ radius: 0.35 }) : box({ width: 0.6, height: 0.6, depth: 0.6 }),
material: standard({ color: paintColour[toy.paint], roughness: 0.45 }),
interactive: true,
castShadow: true,
body: body({
collider: toy.kind === 'ball' ? 'ball' : 'cuboid',
mass: 1,
restitution: toy.kind === 'ball' ? 0.6 : 0.2,
friction: 0.7,
impulse: toy.kick,
}),
});
const wall = (
key: string,
position: readonly [number, number, number],
width: number,
depth: number,
): SceneNode =>
mesh({
key,
position,
geometry: box({ width, height: 0.5, depth }),
material: standard({ color: 0xd9d6cf, roughness: 0.9 }),
body: body({ type: 'fixed' }),
});
const side = reach * 2 + 0.3;
const tray: ReadonlyArray<SceneNode> = [
mesh({
key: 'tray-floor',
position: [0, -0.15, 0],
geometry: box({ width: side, height: 0.3, depth: side }),
material: standard({ color: 0xeceae4, roughness: 0.95 }),
body: body({ type: 'fixed', friction: 0.8 }),
}),
wall('tray-north', [0, 0.25, -reach - 0.075], side, 0.15),
wall('tray-south', [0, 0.25, reach + 0.075], side, 0.15),
wall('tray-east', [reach + 0.075, 0.25, 0], 0.15, side),
wall('tray-west', [-reach - 0.075, 0.25, 0], 0.15, side),
];
/** Where the `index`th marble pours from: a tight spiral above the tray's far side. */
const marbleAt = (index: number) => {
const angle = index * 2.4;
const radius = 0.15 + (index % 16) * 0.05;
return {
key: `m${index}`,
position: [
Math.cos(angle) * radius + 0.8,
dropHeight + 1.5 + Math.floor(index / 16) * 0.4,
Math.sin(angle) * radius - 0.8,
] as const,
color: paintColour[paintAt(index)],
};
};
const marbleNode = (model: Model): SceneNode =>
instances({
// New per round, as the toys' keys are, so tidying pours them again.
key: `marbles:${model.round}`,
geometry: sphere({ radius: 0.12 }),
material: standard({ roughness: 0.2 }),
castShadow: true,
instances: Array.from({ length: model.marbles }, (_, index) => instance(marbleAt(index))),
body: body({ collider: 'ball', mass: 0.05, restitution: 0.3, friction: 0.4 }),
});
const hook: readonly [number, number, number] = [-1.6, 3.4, -1.6];
const linkLength = 0.45;
const links = 5;
/** A link hanging below the hook, its top on the one above's bottom; the last is a heavy ball. */
const chainLink = (index: number): SceneNode =>
mesh({
key: `chain:${index}`,
position: [hook[0], hook[1] - (index + 0.5) * linkLength, hook[2]],
geometry:
index === links - 1
? sphere({ radius: 0.28 })
: box({ width: 0.08, height: linkLength - 0.05, depth: 0.08 }),
material: standard({ color: index === links - 1 ? 0x3a3f4b : 0x8a8f99, metalness: 0.6 }),
castShadow: true,
body: body({ mass: index === links - 1 ? 3 : 0.2, angularDamping: 0.2 }),
});
const chain: ReadonlyArray<SceneNode> = [
mesh({
key: 'chain:hook',
position: hook,
geometry: sphere({ radius: 0.06 }),
material: standard({ color: 0x3a3f4b }),
body: body({ type: 'fixed', sensor: true }),
}),
...Array.from({ length: links }, (_, index) => chainLink(index)),
];
/** Each link hangs from the one above (the first from the hook) by its top end. */
const chainJoints = Array.from({ length: links }, (_, index) =>
joint({
key: `chain:joint:${index}`,
a: index === 0 ? 'chain:hook' : `chain:${index - 1}`,
b: `chain:${index}`,
anchorA: index === 0 ? [0, 0, 0] : [0, -linkLength / 2, 0],
anchorB: [0, linkLength / 2, 0],
kind: sphericalJoint(),
// Neighbouring links overlap where they meet: let them pass through each other.
contacts: false,
}),
);
export const sceneFor = (model: Model) =>
scene({
camera: model.camera,
gestures: 'scene',
physics: physics({ joints: chainJoints, debug: model.showColliders }),
// In VR the player stands where the Model says: in front of the tray, to begin with.
xr: model.player,
environment: environment({ environmentIntensity: 0.6 }),
contactShadows: contactShadows({
position: [0, 0.01, 0],
size: side,
near: -0.05,
far: 1.5,
blur: 2,
opacity: 0.7,
}),
children: [
...tray,
...chain,
marbleNode(model),
...model.toys.map((toy) => toyNode(model, toy)),
],
});
view.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import type { Document, Html, HtmlBuilder } from 'foldkit/html';
import { sceneMount } from './environment';
import { Message } from './message';
import type { Model } from './model';
import { sceneFor } from './scene';
const button = (h: HtmlBuilder<Message>, label: string, message: Message): Html =>
h.button([h.Class('e3-button'), h.Type('button'), h.OnClick(message)], [label]);
/** Shown only where the browser can present VR; in VR, the way back out. */
const vrButtons = {
checking: () => [],
unavailable: () => [],
ready: (h: HtmlBuilder<Message>) => [button(h, 'Enter VR', Message.ClickedEnterVr())],
entering: (h: HtmlBuilder<Message>) => [button(h, 'Entering VR…', Message.ClickedExitVr())],
presenting: (h: HtmlBuilder<Message>) => [button(h, 'Exit VR', Message.ClickedExitVr())],
} satisfies Record<Model['vr'], (h: HtmlBuilder<Message>) => ReadonlyArray<Html>>;
const vrButton = (model: Model, h: HtmlBuilder<Message>): ReadonlyArray<Html> =>
vrButtons[model.vr](h);
const readout = (model: Model): string =>
`${model.toys.length} toys, ${model.marbles} marbles, ${model.bumps} bumps. Click a toy to kick it; drag around them to orbit.`;
export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
title: 'Toybox — effect-three',
body: h.div(
[h.Class('e3-sample')],
[
EffectThreeFoldkit.view(
{
scene: sceneFor(model),
mount: sceneMount,
className: 'e3-stage',
onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
onClick: ({ key, ray }) => Message.ClickedToy({ key, direction: ray.direction }),
onCollision: ({ started, aInstance, bInstance }) =>
Message.Bumped({ started, marble: aInstance !== undefined || bInstance !== undefined }),
onGesture: (gesture) => Message.MadeGesture({ gesture }),
onXRSession: ({ mode }) => Message.ChangedVrSession({ presenting: mode !== null }),
onXRMoved: (moved) => Message.Glided({ moved }),
},
h,
),
h.p([h.Class('e3-readout'), h.AriaLive('polite')], [readout(model)]),
h.div(
[h.Class('e3-controls')],
[
button(h, 'Drop a toy', Message.ClickedDrop()),
button(h, 'Pour marbles', Message.ClickedPour()),
button(h, 'Tidy up', Message.ClickedTidy()),
button(
h,
model.showColliders ? 'Hide colliders' : 'Show colliders',
Message.ToggledColliders(),
),
...vrButton(model, h),
],
),
h.p(
[h.Class('e3-note')],
[
'Each toy is a mesh with a dynamic body; Rapier owns where it is. The Model keeps ',
'where it was dropped and its kicks, each with a new id, applied once. The marbles ',
'are one instanced mesh with a body per instance; the chain is links held by ',
'spherical joints.',
],
),
],
),
});