← All examples

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