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.',
],
),
],
),
});