SHARE WHAT THE GPU HOLDS
One geometry. One material. Two cubes.
Both cubes use one shared geometry and one shared material. Click a cube to repaint the material and both change. Grow them, remove one, or unmount the whole scene.
Released exactly once.
Shared resources are reference-counted across every scene on the page and disposed when their last user goes. Unmounting a scene also releases its renderer and its WebGL context, so mounting and unmounting never runs out of contexts.
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 { Option, Schema } from 'effect';
import { Paint } from '../palette';
export const CubeSize = Schema.Literals(['small', 'large']);
export type CubeSize = typeof CubeSize.Type;
export const SceneStatus = Schema.Literals(['starting', 'running', 'failed']);
export type SceneStatus = typeof SceneStatus.Type;
export const Model = Schema.Struct({
paint: Paint,
size: CubeSize,
showSecondCube: Schema.Boolean,
showScene: Schema.Boolean,
hovered: Schema.Option(Schema.String),
clicks: Schema.Number,
status: SceneStatus,
});
export type Model = typeof Model.Type;
export const init = () => ({
model: {
paint: 'blue',
size: 'small',
showSecondCube: true,
showScene: true,
hovered: Option.none(),
clicks: 0,
status: 'starting',
} 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({
ClickedCube: { key: Schema.String },
HoveredCube: { key: Schema.String },
LeftCube: { key: Schema.String },
ClickedToggleSize: {},
ClickedToggleSecondCube: {},
ClickedToggleScene: {},
ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
});
export type Message = typeof Message.Type;
update.ts
import { Match, Option } from 'effect';
import { modifyFields } from 'foldkit/struct';
import type { Return } from 'foldkit/update';
import { nextPaint } from '../palette';
import { Message } from './message';
import type { CubeSize, Model, SceneStatus } from './model';
type AppReturn = Return<Model, Message>;
const nextSize = { small: 'large', large: 'small' } satisfies Record<CubeSize, CubeSize>;
const statusFor = (lifecycle: Extract<Message, { _tag: 'ReceivedSceneLifecycle' }>['lifecycle']) =>
Match.value(lifecycle).pipe(
Match.tag('StartedScene', (): SceneStatus => 'running'),
Match.tag('FailedScene', (): SceneStatus => 'failed'),
Match.exhaustive,
);
export const update = (model: Model, message: Message): AppReturn =>
Message.match<AppReturn>(message, {
ClickedCube: () => ({
model: modifyFields(model, { paint: nextPaint, clicks: (clicks) => clicks + 1 }),
}),
HoveredCube: ({ key }) => ({ model: modifyFields(model, { hovered: () => Option.some(key) }) }),
LeftCube: ({ key }) => ({
model: modifyFields(model, {
hovered: (hovered) => Option.filter(hovered, (current) => current !== key),
}),
}),
ClickedToggleSize: () => ({ model: modifyFields(model, { size: (size) => nextSize[size] }) }),
ClickedToggleSecondCube: () => ({
model: modifyFields(model, { showSecondCube: (shown) => !shown }),
}),
ClickedToggleScene: () => ({
model: modifyFields(model, {
showScene: (shown) => !shown,
status: (): SceneStatus => 'starting',
}),
}),
ReceivedSceneLifecycle: ({ lifecycle }) => ({
model: modifyFields(model, { status: () => statusFor(lifecycle) }),
}),
});
scene.ts
/**
* Both cubes share one geometry and one material, so a repaint or resize updates shared
* GPU resources in place and both cubes change together.
*/
import {
ambientLight,
box,
directionalLight,
mesh,
perspectiveCamera,
scene,
type SceneNode,
sharedGeometry,
sharedMaterial,
spin,
standard,
} from '@opsydyn/effect-three';
import { Array as Arr, Option } from 'effect';
import { paintColour } from '../palette';
import type { CubeSize, Model } from './model';
const edges = { small: 1, large: 1.6 } satisfies Record<CubeSize, number>;
const hoverScale = (model: Model, key: string): number =>
Option.match(
Option.filter(model.hovered, (hovered) => hovered === key),
{ onNone: () => 1, onSome: () => 1.15 },
);
const cube = (
model: Model,
key: string,
position: readonly [number, number, number],
axis: 'x' | 'y',
): SceneNode => {
const scale = hoverScale(model, key);
return mesh({
key,
position,
scale: [scale, scale, scale],
rotation: spin({ axis, radiansPerSecond: 0.8 }),
geometry: sharedGeometry('cube'),
material: sharedMaterial('paint'),
interactive: true,
});
};
export const sceneFor = (model: Model) => {
const edge = edges[model.size];
return scene({
camera: perspectiveCamera({ position: [0, 1.2, 6], lookAt: [0, 0, 0] }),
resources: {
geometries: [{ id: 'cube', geometry: box({ width: edge, height: edge, depth: edge }) }],
materials: [
{ id: 'paint', material: standard({ color: paintColour[model.paint], roughness: 0.45 }) },
],
},
children: [
ambientLight({ key: 'ambient', intensity: 0.5 }),
directionalLight({ key: 'sun', intensity: 2.2, position: [3, 4, 5] }),
cube(model, 'left', [-1.4, 0, 0], 'y'),
...Arr.filter([cube(model, 'right', [1.4, 0, 0], 'x')], () => model.showSecondCube),
],
});
};
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 { CubeSize, Model } from './model';
import { sceneFor } from './scene';
const liveStage = (model: Model, h: HtmlBuilder<Message>): Html =>
EffectThreeFoldkit.view(
{
scene: sceneFor(model),
mount: sceneMount,
className: 'e3-stage',
onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
onClick: ({ key }) => Message.ClickedCube({ key }),
onPointerEnter: ({ key }) => Message.HoveredCube({ key }),
onPointerLeave: ({ key }) => Message.LeftCube({ key }),
},
h,
);
const placeholder = (h: HtmlBuilder<Message>): Html =>
h.div(
[h.Class('e3-stage e3-stage-placeholder')],
['Scene unmounted: its renderer, WebGL context and GPU resources are released.'],
);
const stage = (model: Model, h: HtmlBuilder<Message>): Html =>
Option.liftPredicate(model, (candidate: Model) => candidate.showScene).pipe(
Option.match({ onSome: (shown) => liveStage(shown, h), onNone: () => placeholder(h) }),
);
const sizeLabel = { small: 'Grow cubes', large: 'Shrink cubes' } satisfies Record<CubeSize, string>;
/** The label for a toggle that is currently on or off. */
const toggleLabel = (on: boolean, whenOn: string, whenOff: string): string =>
Match.value(on).pipe(
Match.when(true, () => whenOn),
Match.orElse(() => whenOff),
);
const button = (h: HtmlBuilder<Message>, label: string, message: Message): Html =>
h.button([h.Class('e3-button'), h.Type('button'), h.OnClick(message)], [label]);
export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
title: 'Shared resources — effect-three',
body: h.div(
[h.Class('e3-sample')],
[
stage(model, h),
h.div(
[h.Class('e3-controls')],
[
button(h, sizeLabel[model.size], Message.ClickedToggleSize()),
button(
h,
toggleLabel(model.showSecondCube, 'Remove right cube', 'Add right cube'),
Message.ClickedToggleSecondCube(),
),
button(
h,
toggleLabel(model.showScene, 'Unmount scene', 'Mount scene'),
Message.ClickedToggleScene(),
),
],
),
h.p(
[h.Class('e3-note')],
[
`Scene ${model.status}. Clicks: ${model.clicks}. Hovering: ${Option.getOrElse(model.hovered, () => 'nothing')}. `,
'Click a cube to repaint the shared material: both cubes change.',
],
),
],
),
});