← All examples

PHYSICS

Sort them by colour.

Gold and teal marbles roll down a ramp onto a paddle wheel, then over a grate that holds the teal ones and lets the gold ones fall through. The wheel turns on a revolute joint driven by a motor: speed it up, slow it down or reverse it from the Model. The grate's collision groups decide which marbles it meets, named sensor colliders count what lands in each bin, and contact forces report how hard the marbles knock the wheel. The camera turns slowly on its own; drag to take over, and it carries on from where it was.

Joints, groups and sensors as data.

The motor's speed is a number in the Model; the grate's collision groups and the bins' named sensors are fields on bodies. Rapier simulates, and what happens comes back as Messages: which bin a marble entered, and how hard the wheel was hit.

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 { perspectiveCamera, SceneSchema } from '@opsydyn/effect-three';
import { Schema } from 'effect';

/** The two colours of marble: the grate holds teal and lets gold fall through. */
export const Colour = Schema.Literals(['gold', 'teal']);
export type Colour = typeof Colour.Type;

/** The two bins, as the sensor colliders that guard them are named. */
export const Bin = Schema.Literals(['gold-bin', 'teal-bin']);
export type Bin = typeof Bin.Type;

const Count = Schema.Struct({ gold: Schema.Number, teal: Schema.Number });

export const Model = Schema.Struct({
  assetBase: Schema.String,
  /** Emptying the machine starts a new round: every marble gets a new key. */
  round: Schema.Number,
  /** Marbles poured of each colour this round. Where each is belongs to Rapier. */
  poured: Count,
  /** Marbles that have dropped into each bin, by colour, as the sensors reported them. */
  goldBin: Count,
  tealBin: Count,
  /** The wheel's motor speed, radians a second; negative turns it clockwise. */
  wheelSpeed: Schema.Number,
  /** Hits on the wheel harder than its contact-force threshold, and the hardest. */
  knocks: Schema.Number,
  hardest: Schema.Number,
  showColliders: Schema.Boolean,
  camera: SceneSchema.Camera,
});
export type Model = typeof Model.Type;

/** Marbles per pour, and most of one colour in the machine. */
export const pour = 12;
export const mostOfOne = 60;

export const wheelSpeeds = { slowest: 0.5, fastest: 6, start: 2 } as const;

export const homeCamera = perspectiveCamera({
  fov: 38,
  position: [2.6, 4.2, 11.5],
  lookAt: [0, 3, 0],
  damping: 0.08,
  // Turns slowly (once every two minutes) while nobody touches it; a drag takes over
  // from where it got to.
  autoRotate: 0.25,
});

const none = { gold: 0, teal: 0 };

export const initialModel = (assetBase: string): Model => ({
  assetBase,
  round: 0,
  poured: { gold: pour, teal: pour },
  goldBin: none,
  tealBin: none,
  wheelSpeed: -wheelSpeeds.start,
  knocks: 0,
  hardest: 0,
  showColliders: false,
  camera: homeCamera,
});
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';

import { Colour } from './model';

export const Message = defineMessageUnion({
  ClickedPour: { colour: Colour },
  ClickedEmpty: {},
  ClickedFaster: {},
  ClickedSlower: {},
  ClickedReverse: {},
  ToggledColliders: {},
  /** Bodies began or stopped touching: a marble meeting a bin's sensor counts. */
  Collided: { collision: SceneSchema.CollisionDetail },
  /** Something hit the wheel harder than its threshold. */
  KnockedWheel: { force: Schema.Number },
  MadeGesture: { gesture: SceneSchema.Gesture },
  AutoRotated: { turned: SceneSchema.AutoRotatedDetail },
  ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
});
export type Message = typeof Message.Type;
update.ts
import {
  autoRotated,
  type BodyCollision,
  type Gesture,
  orbit,
  orbitOptions,
} from '@opsydyn/effect-three';
import { Option } from 'effect';
import { modifyFields } from 'foldkit/struct';
import type { Return } from 'foldkit/update';

import { Message } from './message';
import {
  type Bin,
  type Colour,
  initialModel,
  type Model,
  mostOfOne,
  pour,
  wheelSpeeds,
} from './model';

type AppReturn = Return<Model, Message>;

/** The instances node holding one colour's marbles this round. */
export const marblesKey = (model: Model, colour: Colour): string =>
  `marbles:${colour}:${model.round}`;

const bins: ReadonlyArray<Bin> = ['gold-bin', 'teal-bin'];

/** The marble colour a node key names, this round. */
const colourOf = (model: Model, key: string): Option.Option<Colour> =>
  Option.fromNullishOr(
    (['gold', 'teal'] as const).find((colour) => key === marblesKey(model, colour)),
  );

/** The bin a collision's part names, on either side. */
const binOf = (collision: BodyCollision): Option.Option<Bin> =>
  Option.fromNullishOr(bins.find((bin) => collision.aPart === bin || collision.bPart === bin));

/** A marble starting to touch a bin's sensor has dropped into it: count it by colour. */
const binned = (model: Model, collision: BodyCollision): Model => {
  if (!collision.started) return model;
  const colour = Option.orElse(colourOf(model, collision.a), () => colourOf(model, collision.b));
  return Option.match(Option.all({ bin: binOf(collision), colour }), {
    onNone: () => model,
    onSome: ({ bin, colour: which }) => {
      const field = bin === 'gold-bin' ? 'goldBin' : 'tealBin';
      return { ...model, [field]: { ...model[field], [which]: model[field][which] + 1 } };
    },
  });
};

const poured = (model: Model, colour: Colour): Model => ({
  ...model,
  poured: { ...model.poured, [colour]: Math.min(mostOfOne, model.poured[colour] + pour) },
});

/** A new round: every marble gets a new key, so the machine empties and pours again. */
const emptied = (model: Model): Model => ({
  ...initialModel(model.assetBase),
  round: model.round + 1,
  wheelSpeed: model.wheelSpeed,
  showColliders: model.showColliders,
  camera: model.camera,
});

/** The wheel a step faster or slower, keeping its direction, within its range. */
const geared = (model: Model, factor: number): Model => {
  const size = Math.abs(model.wheelSpeed) * factor;
  const clamped = Math.min(wheelSpeeds.fastest, Math.max(wheelSpeeds.slowest, size));
  return { ...model, wheelSpeed: Math.sign(model.wheelSpeed || -1) * clamped };
};

const orbitLimits = orbitOptions({ minDistance: 5, maxDistance: 18 });

/** Orbit unless the gesture began on something (nothing here is clickable, but be safe). */
const orbited = (model: Model, gesture: Gesture): Model =>
  'key' in gesture && gesture.key !== undefined
    ? model
    : { ...model, camera: orbit(model.camera, gesture, orbitLimits) };

export const init = (props: { readonly assetBase?: string } = {}) => ({
  model: initialModel(props.assetBase ?? '/models/'),
});

export const update = (model: Model, message: Message): AppReturn =>
  Message.match<AppReturn>(message, {
    ClickedPour: ({ colour }) => ({ model: poured(model, colour) }),
    ClickedEmpty: () => ({ model: emptied(model) }),
    ClickedFaster: () => ({ model: geared(model, 1.5) }),
    ClickedSlower: () => ({ model: geared(model, 1 / 1.5) }),
    ClickedReverse: () => ({
      model: modifyFields(model, { wheelSpeed: (speed) => -speed }),
    }),
    ToggledColliders: () => ({
      model: modifyFields(model, { showColliders: (shown) => !shown }),
    }),
    Collided: ({ collision }) => ({ model: binned(model, collision) }),
    KnockedWheel: ({ force }) => ({
      model: modifyFields(model, {
        knocks: (knocks) => knocks + 1,
        hardest: (hardest) => Math.max(hardest, force),
      }),
    }),
    MadeGesture: ({ gesture }) => ({ model: orbited(model, gesture) }),
    // The camera turned by itself; keep where it got to, so the gesture after goes on from there.
    AutoRotated: ({ turned }) => ({
      model: modifyFields(model, { camera: (camera) => autoRotated(camera, turned) }),
    }),
    ReceivedSceneLifecycle: () => ({ model }),
  });
scene.ts
/**
 * The marble machine: two colours of marble roll down a ramp onto a paddle wheel turned
 * by a motor, then over a grate that holds teal marbles (they roll off to the left bin)
 * and lets gold ones fall through (to the right). It is all bodies and joints described
 * as data:
 *
 * - the wheel is a group whose children are its colliders, hinged to a fixed axle by a
 *   revolute joint whose velocity motor the Model sets;
 * - the grate's collision groups admit teal marbles only, so gold ones pass through it;
 * - each bin is guarded by a sensor collider named for it, so a collision says which;
 * - the wheel reports contact forces above a threshold: how hard the marbles hit it.
 */
import {
  ambientLight,
  basic,
  bloom,
  body,
  box,
  colorRange,
  contactShadows,
  cuboidCollider,
  cylinder,
  directionalLight,
  environment,
  fixed,
  group,
  instance,
  instances,
  interactionGroups,
  joint,
  label,
  type MaterialRef,
  mesh,
  physical,
  physics,
  renderSettings,
  revoluteJoint,
  scene,
  type SceneNode,
  sphere,
  standard,
  velocityMotor,
  type Vec3,
} from '@opsydyn/effect-three';

import { paintColour } from '../palette';
import type { Colour, Model } from './model';
import { marblesKey } from './update';

/** The machine's frame: inside, x from −3 to 3 and y from 0 up; the channel z ±0.3. */
const width = 6;
const height = 7;
const channel = 0.3;

/** Collision groups: marbles of each colour, and the grate that only teal ones meet. */
const groups = { gold: 1, teal: 2, grate: 3 } as const;

const marbleColour = { gold: paintColour.gold, teal: 0x2fb5a3 } satisfies Record<Colour, number>;

const frame = standard({ color: 0xb9b4aa, roughness: 0.85 });
const glass = physical({ color: 0xeef4ff, opacity: 0.05, transparent: true, roughness: 0.05 });

/** A fixed box: a wall, ramp or pane. */
const slab = (
  key: string,
  position: Vec3,
  size: Vec3,
  options: {
    readonly tilt?: number;
    readonly material?: MaterialRef;
    readonly friction?: number;
  } = {},
): SceneNode =>
  mesh({
    key,
    position,
    rotation: fixed([0, 0, options.tilt ?? 0]),
    geometry: box({ width: size[0], height: size[1], depth: size[2] }),
    material: options.material ?? frame,
    castShadow: true,
    receiveShadow: true,
    body: body({ type: 'fixed', friction: options.friction ?? 0.3 }),
  });

const shell: ReadonlyArray<SceneNode> = [
  slab('floor', [0, -0.1, 0], [width + 0.3, 0.2, channel * 2 + 0.2]),
  slab('wall-left', [-width / 2 - 0.08, height / 2, 0], [0.16, height, channel * 2 + 0.2]),
  // Frictionless, so a marble in the corner where the grate meets it rolls back down.
  slab('wall-right', [width / 2 + 0.08, height / 2, 0], [0.16, height, channel * 2 + 0.2], {
    friction: 0,
  }),
  // The bins' divider, under the grate's low end.
  slab('divider', [0.15, 0.55, 0], [0.12, 1.1, channel * 2]),
  // Glass front and back keep the marbles in the channel, and let you see in.
  slab('glass-front', [0, height / 2, channel + 0.03], [width, height, 0.06], { material: glass }),
  slab('glass-back', [0, height / 2, -channel - 0.03], [width, height, 0.06], { material: glass }),
];

/** The ramp under the hopper, falling to the right towards the wheel. */
const ramp = slab('ramp', [-1.25, 4.95, 0], [3.7, 0.12, channel * 2], { tilt: -0.2 });

const wheelAt: Vec3 = [1.35, 3.45, 0];
const armLength = 1.9;

/** The paddle wheel: a hub and two crossed paddles, one body, turned by its joint's motor. */
const wheel: SceneNode = group({
  key: 'wheel',
  position: wheelAt,
  body: body({ mass: 3, gravityScale: 0, collider: 'cuboid', contactForces: 16 }),
  children: [
    mesh({
      key: 'wheel:paddle-a',
      geometry: box({ width: armLength, height: 0.1, depth: channel * 2 - 0.04 }),
      material: standard({ color: paintColour.coral, roughness: 0.35, metalness: 0.3 }),
      castShadow: true,
    }),
    mesh({
      key: 'wheel:paddle-b',
      geometry: box({ width: 0.1, height: armLength, depth: channel * 2 - 0.04 }),
      material: standard({ color: paintColour.coral, roughness: 0.35, metalness: 0.3 }),
      castShadow: true,
    }),
    mesh({
      key: 'wheel:hub',
      rotation: fixed([Math.PI / 2, 0, 0]),
      geometry: cylinder({ radiusTop: 0.18, radiusBottom: 0.18, height: channel * 2 }),
      material: standard({ color: 0x3a3f4b, metalness: 0.7, roughness: 0.3 }),
    }),
  ],
});

/** A fixed, touchless axle for the wheel to hinge on. */
const axle: SceneNode = mesh({
  key: 'axle',
  position: wheelAt,
  geometry: sphere({ radius: 0.05 }),
  material: basic({ color: 0x3a3f4b }),
  body: body({ type: 'fixed', sensor: true }),
});

const wheelJoint = (model: Model) =>
  joint({
    key: 'wheel:hinge',
    a: 'axle',
    b: 'wheel',
    kind: revoluteJoint({ axis: [0, 0, 1], motor: velocityMotor(model.wheelSpeed, 40) }),
    contacts: false,
  });

const grateLook = standard({
  color: marbleColour.teal,
  opacity: 0.45,
  transparent: true,
  roughness: 0.3,
});

/** A plate of the grate: fixed, and met by teal marbles only. */
const gratePlate = (key: string, position: Vec3, length: number, tilt: number): SceneNode =>
  mesh({
    key,
    position,
    rotation: fixed([0, 0, tilt]),
    geometry: box({ width: length, height: 0.08, depth: channel * 2 }),
    material: grateLook,
    body: body({
      type: 'fixed',
      friction: 0.1,
      collisionGroups: interactionGroups(groups.grate, groups.teal),
    }),
  });

/**
 * The grate: rising to the right under the wheel, and a steep kicker where it meets the
 * wall, so no teal marble can settle in the corner.
 */
const grate: ReadonlyArray<SceneNode> = [
  gratePlate('grate', [0.95, 1.93, 0], 3.4, 0.17),
  gratePlate('grate-kicker', [2.8, 2.38, 0], 0.55, 0.75),
];

/** Sensors over each bin, named, so a collision says which bin a marble dropped into. */
const binSensors: SceneNode = group({
  key: 'bins',
  body: body({
    type: 'fixed',
    sensor: true,
    colliders: [
      cuboidCollider({
        name: 'teal-bin',
        halfExtents: [1.45, 0.5, channel],
        position: [-1.5, 0.55, 0],
      }),
      cuboidCollider({
        name: 'gold-bin',
        halfExtents: [1.35, 0.5, channel],
        position: [1.6, 0.55, 0],
      }),
    ],
  }),
  children: [],
});

const binLabel = (model: Model, key: string, text: string, x: number, colour: number): SceneNode =>
  label({
    key,
    text,
    font: `${model.assetBase}inter-latin-400-normal.woff`,
    fontSize: 0.32,
    position: [x, 1.45, channel + 0.1],
    color: 0x2a2d34,
    // The count in the marbles' colour.
    colorRanges: [colorRange(text.indexOf(' ') + 1, colour)],
    outlineWidth: 0.012,
    outlineColor: 0xffffff,
  });

/** Where the `index`th marble of a colour pours from: over the ramp's high end. */
const pourPoint = (colour: Colour, index: number): Vec3 => [
  -2.75 + (index % 3) * 0.22 + (colour === 'teal' ? 0.11 : 0),
  5.9 + Math.floor(index / 3) * 0.3 + (colour === 'teal' ? 0.15 : 0),
  ((index % 2) - 0.5) * 0.24,
];

const marbles = (model: Model, colour: Colour): SceneNode =>
  instances({
    key: marblesKey(model, colour),
    geometry: sphere({ radius: 0.12, widthSegments: 20, heightSegments: 14 }),
    material:
      colour === 'gold'
        ? standard({
            color: marbleColour.gold,
            emissive: marbleColour.gold,
            // Past 1, so the bloom picks out the gold and nothing else.
            emissiveIntensity: 1.15,
            metalness: 0.4,
            roughness: 0.25,
          })
        : standard({ color: marbleColour.teal, metalness: 0.1, roughness: 0.15 }),
    castShadow: true,
    instances: Array.from({ length: model.poured[colour] }, (_, index) =>
      instance({ key: `${colour[0]}${index}`, position: pourPoint(colour, index) }),
    ),
    body: body({
      collider: 'ball',
      mass: 0.05,
      restitution: 0.25,
      friction: 0.2,
      ccd: true,
      collisionGroups: interactionGroups(groups[colour]),
    }),
  });

export const sceneFor = (model: Model) =>
  scene({
    camera: model.camera,
    gestures: 'scene',
    physics: physics({ joints: [wheelJoint(model)], debug: model.showColliders }),
    environment: environment({ environmentIntensity: 0.4 }),
    contactShadows: contactShadows({
      position: [0, 0.005, 0],
      size: 9,
      far: 3,
      blur: 2.5,
      opacity: 0.55,
    }),
    render: renderSettings({
      toneMapping: 'aces',
      exposure: 0.9,
      effects: [bloom({ strength: 0.45, radius: 0.3, threshold: 1 })],
    }),
    children: [
      ambientLight({ key: 'ambient', intensity: 0.35 }),
      directionalLight({ key: 'sun', position: [4, 8, 6], intensity: 1.4 }),
      ...shell,
      ramp,
      axle,
      wheel,
      ...grate,
      binSensors,
      // The counts in deeper shades of the marbles' colours, legible on any background.
      binLabel(model, 'label:teal', `Teal ${model.tealBin.teal}`, -1.5, 0x168a7c),
      binLabel(model, 'label:gold', `Gold ${model.goldBin.gold}`, 1.6, 0xc08a12),
      marbles(model, 'gold'),
      marbles(model, 'teal'),
    ],
  });
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]);

const stat = (h: HtmlBuilder<Message>, term: string, value: string): Html =>
  h.div([h.Class('e3-stat')], [h.dt([], [term]), h.dd([], [value])]);

/** Marbles in the wrong bin: none, unless the grate let one through. */
const strays = (model: Model): number => model.goldBin.teal + model.tealBin.gold;

const direction = (model: Model): string => (model.wheelSpeed < 0 ? 'clockwise' : 'anticlockwise');

export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
  title: 'Marble machine — effect-three',
  body: h.div(
    [h.Class('e3-sample')],
    [
      EffectThreeFoldkit.view(
        {
          scene: sceneFor(model),
          mount: sceneMount,
          className: 'e3-stage e3-stage-tall',
          onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
          onCollision: (collision) => Message.Collided({ collision }),
          onContactForce: ({ totalForceMagnitude }) =>
            Message.KnockedWheel({ force: totalForceMagnitude }),
          onGesture: (gesture) => Message.MadeGesture({ gesture }),
          onAutoRotated: (turned) => Message.AutoRotated({ turned }),
        },
        h,
      ),
      h.dl(
        [h.Class('e3-stats')],
        [
          stat(h, 'Gold binned', `${model.goldBin.gold} of ${model.poured.gold}`),
          stat(h, 'Teal binned', `${model.tealBin.teal} of ${model.poured.teal}`),
          stat(h, 'Strays', `${strays(model)}`),
          stat(h, 'Knocks', `${model.knocks}`),
          stat(h, 'Hardest', `${model.hardest.toFixed(1)} N`),
          stat(h, 'Wheel', `${Math.abs(model.wheelSpeed).toFixed(1)} rad/s ${direction(model)}`),
        ],
      ),
      h.div(
        [h.Class('e3-controls')],
        [
          button(h, 'Pour gold', Message.ClickedPour({ colour: 'gold' })),
          button(h, 'Pour teal', Message.ClickedPour({ colour: 'teal' })),
          button(h, 'Faster', Message.ClickedFaster()),
          button(h, 'Slower', Message.ClickedSlower()),
          button(h, 'Reverse', Message.ClickedReverse()),
          button(h, 'Empty', Message.ClickedEmpty()),
          button(
            h,
            model.showColliders ? 'Hide colliders' : 'Show colliders',
            Message.ToggledColliders(),
          ),
        ],
      ),
      h.p(
        [h.Class('e3-note')],
        [
          'The wheel is hinged to its axle by a revolute joint whose motor speed is in the Model. ',
          'The grate’s collision groups admit teal marbles only, so gold ones fall through it. ',
          'Each bin is a named sensor collider, so collisions say which bin a marble entered, and ',
          'the wheel reports contact forces above a threshold. Reverse the wheel and it flings gold ',
          'marbles left, past the grate: watch the strays. The camera turns by itself until you ',
          'drag it, then carries on from where it was.',
        ],
      ),
    ],
  ),
});