← All examples

WATCH EVERY UPDATE

Change little. Touch little.

Add, move, reorder, repaint and remove keyed cubes; each one glides to its new place. They stand on Drei's fading shader grid under a daylight sky with soft contact shadows, all plain scene data. The panel shows what each update did: which objects were created, updated, moved or removed, and which GPU resources are still alive.

Identity follows the key.

A cube keeps its three.js object for as long as its key exists, even when it moves to another shelf. Colours are shared materials: when no cube uses a colour, its material is disposed, and it comes back when a cube needs it again.

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 * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Option, Schema } from 'effect';

import { Paint, paintAt } from '../palette';

export const Cube = Schema.Struct({ key: Schema.String, paint: Paint });
export type Cube = typeof Cube.Type;

export const Model = Schema.Struct({
  left: Schema.Array(Cube),
  right: Schema.Array(Cube),
  nextId: Schema.Number,
  hovered: Schema.Option(Schema.String),
  last: Schema.Option(EffectThreeFoldkit.ReconciledDetail),
});
export type Model = typeof Model.Type;

/** A new cube with a stable key: its three.js object lives exactly as long as the key does. */
export const cubeNumbered = (id: number): Cube => ({ key: `cube-${id}`, paint: paintAt(id) });

export const init = () => ({
  model: {
    left: [0, 1, 2, 3, 4].map(cubeNumbered),
    right: [5, 6].map(cubeNumbered),
    nextId: 7,
    hovered: Option.none(),
    last: Option.none(),
  } satisfies Model,
});
message.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Schema } from 'effect';
import { defineMessageUnion } from 'foldkit/message';

export const Message = defineMessageUnion({
  ClickedAdd: {},
  ClickedMoveAcross: {},
  ClickedRotate: {},
  ClickedRemove: {},
  ClickedCube: { key: Schema.String },
  HoveredCube: { key: Schema.String },
  LeftCube: { key: Schema.String },
  ReceivedReconciled: { detail: EffectThreeFoldkit.ReconciledDetail },
  ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
});
export type Message = typeof Message.Type;
update.ts
import { Array as Arr, Option } from 'effect';
import { modifyFields } from 'foldkit/struct';
import type { Return } from 'foldkit/update';

import { nextPaint } from '../palette';
import { Message } from './message';
import { type Cube, cubeNumbered, type Model } from './model';

type AppReturn = Return<Model, Message>;

const repainted = (cube: Cube): Cube => ({ ...cube, paint: nextPaint(cube.paint) });

const recolour = (key: string) => (cubes: ReadonlyArray<Cube>) =>
  cubes.map((cube) =>
    Option.liftPredicate(cube, (candidate: Cube) => candidate.key === key).pipe(
      Option.match({ onNone: () => cube, onSome: repainted }),
    ),
  );

/** Takes the first cube of `from` and appends it to `to`. */
const shift = (from: ReadonlyArray<Cube>, to: ReadonlyArray<Cube>) =>
  Option.map(Arr.head(from), (cube) => ({ from: from.slice(1), to: [...to, cube] }));

const moveBack = (model: Model): Model =>
  Option.match(shift(model.right, model.left), {
    onNone: () => model,
    onSome: ({ from, to }) => ({ ...model, right: from, left: to }),
  });

/** Moves a cube from the left shelf to the right; once the left is empty, back again. */
const moveAcross = (model: Model): Model =>
  Option.match(shift(model.left, model.right), {
    onSome: ({ from, to }) => ({ ...model, left: from, right: to }),
    onNone: () => moveBack(model),
  });

const dropLast = (cubes: ReadonlyArray<Cube>): ReadonlyArray<Cube> => cubes.slice(0, -1);

/** Removes the last cube of the right shelf, or of the left once the right is empty. */
const removeOne = (model: Model): Model =>
  Option.match(Arr.last(model.right), {
    onSome: () => modifyFields(model, { right: dropLast }),
    onNone: () => modifyFields(model, { left: dropLast }),
  });

/** Moves the last cube of the left shelf to the front. */
const rotate = (cubes: ReadonlyArray<Cube>): ReadonlyArray<Cube> =>
  Option.match(Arr.last(cubes), {
    onNone: () => cubes,
    onSome: (last) => [last, ...cubes.slice(0, -1)],
  });

export const update = (model: Model, message: Message): AppReturn =>
  Message.match<AppReturn>(message, {
    ClickedAdd: () => ({
      model: modifyFields(model, {
        left: (left) => [...left, cubeNumbered(model.nextId)],
        nextId: (id) => id + 1,
      }),
    }),
    ClickedMoveAcross: () => ({ model: moveAcross(model) }),
    ClickedRotate: () => ({ model: modifyFields(model, { left: rotate }) }),
    ClickedRemove: () => ({ model: removeOne(model) }),
    ClickedCube: ({ key }) => ({
      model: modifyFields(model, { left: recolour(key), right: recolour(key) }),
    }),
    HoveredCube: ({ key }) => ({ model: modifyFields(model, { hovered: () => Option.some(key) }) }),
    LeftCube: ({ key }) => ({
      model: modifyFields(model, {
        hovered: (hovered) => Option.filter(hovered, (current) => current !== key),
      }),
    }),
    ReceivedReconciled: ({ detail }) => ({
      model: modifyFields(model, { last: () => Option.some(detail) }),
    }),
    ReceivedSceneLifecycle: () => ({ model }),
  });
scene.ts
/**
 * Two shelves of keyed cubes. Each cube's position comes from its index on its shelf, so
 * reordering produces position updates plus one Order; moving a cube between shelves keeps
 * its three.js object (a Move, not a remove and re-create). Every cube eases to its new
 * place, so the Model stays the target while the scene shows the journey. They stand on
 * Drei's fading grid under a daylight sky whose sun is the scene's directional light.
 */
import {
  ambientLight,
  box,
  contactShadows,
  directionalLight,
  ease,
  fixed,
  shaderGrid,
  group,
  mesh,
  perspectiveCamera,
  renderSettings,
  scene,
  type SceneNode,
  sharedGeometry,
  sharedMaterial,
  sky,
  standard,
} from '@opsydyn/effect-three';
import { Option } from 'effect';

import { paintColour, paints } from '../palette';
import type { Cube, Model } from './model';

const columns = 3;
const spacing = 1.1;
const glide = ease({ seconds: 0.35 });

/** Where the sun is: the directional light and the sky agree. */
const sun = [4, 6, 6] as const;

const hoverScale = (model: Model, key: string): number =>
  Option.match(
    Option.filter(model.hovered, (hovered) => hovered === key),
    { onNone: () => 1, onSome: () => 1.12 },
  );

const cubeNode = (model: Model, cube: Cube, index: number): SceneNode => {
  const scale = hoverScale(model, cube.key);
  return mesh({
    key: cube.key,
    position: [((index % columns) - 1) * spacing, Math.floor(index / columns) * spacing, 0],
    rotation: fixed([0.35, 0.6, 0]),
    scale: [scale, scale, scale],
    transition: glide,
    geometry: sharedGeometry('cube'),
    material: sharedMaterial(cube.paint),
    interactive: true,
  });
};

const shelf = (model: Model, key: string, x: number, cubes: ReadonlyArray<Cube>): SceneNode =>
  group({
    key,
    position: [x, -0.75, 0],
    children: cubes.map((cube, index) => cubeNode(model, cube, index)),
  });

export const sceneFor = (model: Model) =>
  scene({
    camera: perspectiveCamera({ fov: 40, position: [0, 1.4, 7.4], lookAt: [0, 0.35, 0] }),
    // The Preetham sky is high dynamic range: it needs tone mapping, as in three's example.
    sky: sky({ sunPosition: sun, turbidity: 4, rayleigh: 1.2 }),
    render: renderSettings({ toneMapping: 'aces', exposure: 0.85 }),
    // Soft shadows under the shelves, just above the grid; the turned cubes' lowest corners
    // dip below the plane, so the shadow camera starts a little under it.
    contactShadows: contactShadows({
      position: [0, -1.17, 0],
      size: 14,
      near: -0.4,
      far: 1.2,
      blur: 2,
      opacity: 0.85,
    }),
    resources: {
      geometries: [{ id: 'cube', geometry: box({ width: 0.85, height: 0.85, depth: 0.85 }) }],
      materials: paints.map((paint) => ({
        id: paint,
        material: standard({ color: paintColour[paint], roughness: 0.5 }),
      })),
    },
    children: [
      ambientLight({ key: 'ambient', intensity: 0.55 }),
      directionalLight({ key: 'sun', intensity: 2, position: sun }),
      shaderGrid({
        key: 'floor',
        position: [0, -1.18, 0],
        infinite: true,
        cellSize: 0.5,
        cellColor: 0xb9c2d0,
        sectionSize: 2,
        sectionColor: 0x8a94a6,
        fadeDistance: 18,
        fadeStrength: 1.5,
      }),
      shelf(model, 'left-shelf', -2.2, model.left),
      shelf(model, 'right-shelf', 2.2, model.right),
    ],
  });
view.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Match, Option } from 'effect';
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 statClass = (value: number): string =>
  Match.value(value).pipe(
    Match.when(0, () => 'e3-stat'),
    Match.orElse(() => 'e3-stat e3-stat-active'),
  );

const stat = (h: HtmlBuilder<Message>, label: string, value: number): Html =>
  h.div([h.Class(statClass(value))], [h.dt([], [label]), h.dd([], [String(value)])]);

const report = (model: Model, h: HtmlBuilder<Message>): ReadonlyArray<Html> =>
  Option.match(model.last, {
    onNone: () => [h.p([h.Class('e3-note')], ['Waiting for the first update…'])],
    onSome: ({ ops, resources }) => [
      h.p([h.Class('e3-note')], ['Last update, by operation:']),
      h.dl(
        [h.Class('e3-stats')],
        [
          stat(h, 'Created', ops.created),
          stat(h, 'Updated', ops.updated),
          stat(h, 'Moved', ops.moved),
          stat(h, 'Reordered', ops.reordered),
          stat(h, 'Removed', ops.removed),
          stat(h, 'Replaced', ops.replaced),
        ],
      ),
      h.p(
        [h.Class('e3-note')],
        [
          `GPU now: ${resources.geometries.live} geometry, ${resources.materials.live} materials. `,
          `Since start: ${resources.materials.created} materials created, ${resources.materials.disposed} disposed.`,
        ],
      ),
    ],
  });

export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
  title: 'Reconciliation playground — effect-three',
  body: h.div(
    [h.Class('e3-sample')],
    [
      EffectThreeFoldkit.view(
        {
          scene: sceneFor(model),
          mount: sceneMount,
          className: 'e3-stage',
          onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
          onReconciled: (detail) => Message.ReceivedReconciled({ detail }),
          onClick: ({ key }) => Message.ClickedCube({ key }),
          onPointerEnter: ({ key }) => Message.HoveredCube({ key }),
          onPointerLeave: ({ key }) => Message.LeftCube({ key }),
        },
        h,
      ),
      h.div(
        [h.Class('e3-controls')],
        [
          button(h, 'Add cube', Message.ClickedAdd()),
          button(h, 'Move one across', Message.ClickedMoveAcross()),
          button(h, 'Rotate left shelf', Message.ClickedRotate()),
          button(h, 'Remove cube', Message.ClickedRemove()),
        ],
      ),
      ...report(model, h),
      h.p(
        [h.Class('e3-note')],
        [
          'Click a cube to repaint it. Colours are shared materials: a colour no cube uses is disposed, ',
          'and comes back when a cube needs it again.',
        ],
      ),
    ],
  ),
});