MODELS, TEXTURES, CLIPS
Load it, play it, let it go.
The scene asks for a glTF robot, a floor texture and the sign's font (Inter, OFL) in its description, and hears when each has loaded. The robot and floor arrive coarse first and sharpen when the full versions land. Under the sign, a distance-field caption drawn by troika names the clip playing. Play its clips at any speed, click a part to name it, then unload everything from the GPU and load it again. Unload while it is still loading, and the load is cancelled.
Your objects, our lifetimes.
Loaded assets are lent to the scene's resource store, so the description only names them by id: a borrowed node shows the model the moment it arrives, and the floor picks up its texture when it settles. Clips play as a function of scene time. Unloading is the Model no longer asking: the environment cancels what is still loading and disposes what arrived, and an asset two scenes ask for loads once and stays while either does.
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 type { AsyncData } from 'foldkit/asyncData';
/** The robot's clips, as the generated model names them. */
export const Clip = Schema.Literals(['wave', 'roll']);
export type Clip = typeof Clip.Type;
export const clips: ReadonlyArray<Clip> = Clip.literals;
/** What the loaded robot offers, and whether it is the finest of its levels yet. */
export type RobotInfo = {
readonly parts: ReadonlyArray<string>;
readonly clips: ReadonlyArray<string>;
readonly final: boolean;
};
export const Model = Schema.Struct({
/** Where the site serves the model and texture from, ending in `/`. */
assetBase: Schema.String,
/** Whether the scene asks for its assets: unloading is no longer asking. */
wanted: Schema.Boolean,
robot: Schema.Unknown,
floor: Schema.Unknown,
font: Schema.Unknown,
clip: Schema.NullOr(Clip),
speed: Schema.Number,
part: Schema.Option(Schema.String),
});
export type Model = Omit<typeof Model.Type, 'robot' | 'floor' | 'font'> & {
readonly robot: AsyncData<RobotInfo, string>;
/** The floor texture, settled once lent: whether it is the finest of its levels yet. */
readonly floor: AsyncData<boolean, string>;
/** The sign's font (Inter Bold, OFL), settled to its id once lent. */
readonly font: AsyncData<string, string>;
};
/** Ids the assets are lent under in the shared store. */
export const assetIds = {
robot: 'workshop-robot',
floor: 'workshop-floor',
font: 'workshop-font',
} as const;
export const initialModel = (assetBase: string): Model => ({
assetBase,
wanted: true,
robot: { _tag: 'Loading' },
floor: { _tag: 'Loading' },
font: { _tag: 'Loading' },
clip: 'wave',
speed: 1,
part: Option.none(),
});
update.ts
import { Match, Option } from 'effect';
import { fail, succeed } from 'foldkit/asyncData';
import { modifyFields } from 'foldkit/struct';
import type { Return } from 'foldkit/update';
import { Message } from './message';
import { assetIds, initialModel, type Model, type RobotInfo } from './model';
type AppReturn = Return<Model, Message>;
export const init = (props: { readonly assetBase?: string } = {}) => ({
// The scene asks for its assets (see scene.ts): nothing to run here.
model: initialModel(props.assetBase ?? '/models/'),
});
/** A level of an asset the scene asked for has loaded: its status settled, by id. */
const loadedAsset = (model: Model, id: string, info: RobotInfo): Model =>
Match.value(id).pipe(
Match.when(assetIds.robot, () => ({ ...model, robot: succeed(info) })),
Match.when(assetIds.floor, () => ({ ...model, floor: succeed(info.final) })),
Match.when(assetIds.font, () => ({ ...model, font: succeed(id) })),
Match.orElse(() => model),
);
const failedAsset = (model: Model, id: string, reason: string): Model =>
Match.value(id).pipe(
Match.when(assetIds.robot, () => ({ ...model, robot: fail(reason) })),
Match.when(assetIds.floor, () => ({ ...model, floor: fail(reason) })),
Match.when(assetIds.font, () => ({ ...model, font: fail(reason) })),
Match.orElse(() => model),
);
/** Unloading is no longer asking: the environment cancels or frees what the scene had. */
const unload = (model: Model): Model => ({
...model,
wanted: false,
robot: { _tag: 'Idle' },
floor: { _tag: 'Idle' },
font: { _tag: 'Idle' },
part: Option.none(),
});
const reload = (model: Model): Model => ({
...model,
wanted: true,
robot: { _tag: 'Loading' },
floor: { _tag: 'Loading' },
font: { _tag: 'Loading' },
});
/** What the scene hears only counts while it asks: an answer to an old request is let be. */
const whileWanted = (model: Model, next: () => Model): Model => (model.wanted ? next() : model);
export const update = (model: Model, message: Message): AppReturn =>
Message.match<AppReturn>(message, {
LoadedAsset: ({ id, final, parts, clips }) => ({
model: whileWanted(model, () => loadedAsset(model, id, { parts, clips, final })),
}),
FailedAsset: ({ id, reason }) => ({
model: whileWanted(model, () => failedAsset(model, id, reason)),
}),
ClickedClip: ({ clip }) => ({ model: modifyFields(model, { clip: () => clip }) }),
ChangedSpeed: ({ speed }) => ({ model: modifyFields(model, { speed: () => speed }) }),
ClickedPart: ({ part }) => ({ model: modifyFields(model, { part: () => Option.some(part) }) }),
ClickedUnload: () => ({ model: unload(model) }),
ClickedReload: () => ({ model: reload(model) }),
ReceivedSceneLifecycle: () => ({ model }),
});
scene.ts
/**
* The workshop: the loaded robot on a tiled floor under a sign. The scene asks for its
* assets (`scene({ assets })`) while the Model wants them, the robot and floor each with a
* coarse level shown until the full one arrives: the environment loads each,
* cancels a load the scene stops asking for, and frees what it no longer asks for. The
* robot is a borrowed node, so it appears when its model is lent and goes when it is
* freed; the floor uses the lent texture as its map once it has settled; the sign is text
* in the lent font, empty until the font arrives. The caption under it is a distance-field
* label naming the clip playing, drawn by troika from a WOFF font.
*/
import {
type Animation,
type Asset,
borrowed,
directionalLight,
fixed,
fontAsset,
hemisphereLight,
label,
mesh,
modelAsset,
perspectiveCamera,
plane,
play,
scene,
standard,
text,
textureAsset,
} from '@opsydyn/effect-three';
import { Option } from 'effect';
import { matchData } from 'foldkit/asyncData';
import { assetIds, type Model } from './model';
/** What the scene asks for while the Model wants it: nothing, once unloaded. */
const assetsOf = (model: Model): ReadonlyArray<Asset> =>
model.wanted
? [
modelAsset({
id: assetIds.robot,
url: `${model.assetBase}robot.glb`,
levels: [`${model.assetBase}robot-low.glb`],
}),
textureAsset({
id: assetIds.floor,
url: `${model.assetBase}tiles.png`,
levels: [`${model.assetBase}tiles-low.png`],
}),
fontAsset({ id: assetIds.font, url: `${model.assetBase}inter-bold.typeface.json` }),
]
: [];
/** The floor's map: the lent texture once it has settled, none before. */
const floorMap = (model: Model): string | null =>
matchData(model.floor, {
onEmpty: () => null,
onFailure: () => null,
onData: () => assetIds.floor,
});
const captionOf = (model: Model): string =>
Option.match(Option.fromNullishOr(model.clip), {
onNone: () => 'Standing still',
onSome: (clip) => `Playing “${clip}” at ${model.speed}×`,
});
const animationOf = (model: Model): Animation | null =>
Option.match(Option.fromNullishOr(model.clip), {
onNone: () => null,
onSome: (clip) => play(clip, { speed: model.speed }),
});
export const sceneFor = (model: Model) =>
scene({
camera: perspectiveCamera({ fov: 40, position: [2.2, 2.6, 5.4], lookAt: [0, 1, 0] }),
assets: assetsOf(model),
children: [
hemisphereLight({ key: 'sky', color: 0xffffff, groundColor: 0x8d8a83, intensity: 1.4 }),
directionalLight({ key: 'sun', intensity: 2.2, position: [3, 6, 4] }),
mesh({
key: 'floor',
rotation: fixed([-Math.PI / 2, 0, 0]),
geometry: plane({ width: 8, height: 8 }),
material: standard({ color: 0xffffff, roughness: 0.9, map: floorMap(model) }),
}),
mesh({
key: 'sign',
position: [0, 2.85, -2.6],
geometry: text({
text: 'WORKSHOP',
font: assetIds.font,
size: 0.42,
depth: 0.12,
bevel: { thickness: 0.02, size: 0.012 },
center: true,
}),
material: standard({ color: 0xf2a541, roughness: 0.35, metalness: 0.2 }),
}),
label({
key: 'caption',
position: [0, 2.42, -2.6],
text: captionOf(model),
font: `${model.assetBase}inter-latin-400-normal.woff`,
fontSize: 0.24,
color: 0xf2a541,
outlineWidth: 0.014,
outlineColor: 0x2b2a28,
}),
borrowed({
key: 'robot',
id: assetIds.robot,
animation: animationOf(model),
interactive: true,
}),
],
});
view.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Option } from 'effect';
import { match } from 'foldkit/asyncData';
import type { Document, Html, HtmlBuilder } from 'foldkit/html';
import { sceneMount } from '../labelledEnvironment';
import { Message } from './message';
import { type Clip, clips, type Model, type RobotInfo } from './model';
import { sceneFor } from './scene';
const clipLabel = { wave: 'Wave', roll: 'Roll' } satisfies Record<Clip, string>;
const clipButton = (model: Model, h: HtmlBuilder<Message>, clip: Clip | null): Html =>
h.button(
[
h.Class('e3-button'),
h.Type('button'),
h.AriaPressed(String(model.clip === clip)),
h.OnClick(Message.ClickedClip({ clip })),
],
[
Option.match(Option.fromNullishOr(clip), {
onNone: () => 'Still',
onSome: (c) => clipLabel[c],
}),
],
);
const button = (h: HtmlBuilder<Message>, label: string, message: Message): Html =>
h.button([h.Class('e3-button'), h.Type('button'), h.OnClick(message)], [label]);
const detailOf = (info: RobotInfo): string => (info.final ? 'Loaded' : 'Low detail, sharpening');
const loaded = (model: Model, info: RobotInfo): string =>
Option.match(model.part, {
onNone: () =>
`${detailOf(info)}: ${info.parts.length} named parts and the clips ${info.clips.join(' and ')}. Click a part to name it.`,
onSome: (part) => `You clicked the ${part}.`,
});
const readout = (model: Model): string =>
match(model.robot, {
onIdle: () => 'Unloaded: the model, texture and sign font are gone.',
onLoading: () => 'Loading the robot…',
onRefreshing: (info) => loaded(model, info),
onFailure: (error) => error,
onStale: ({ error }) => error,
onSuccess: (info) => loaded(model, info),
});
/** The loading controls: unload when anything is loaded, reload when not. */
const loadButton = (model: Model, h: HtmlBuilder<Message>): Html =>
match(model.robot, {
onIdle: () => button(h, 'Reload', Message.ClickedReload()),
onLoading: () => button(h, 'Unload', Message.ClickedUnload()),
onRefreshing: () => button(h, 'Unload', Message.ClickedUnload()),
onFailure: () => button(h, 'Reload', Message.ClickedReload()),
onStale: () => button(h, 'Unload', Message.ClickedUnload()),
onSuccess: () => button(h, 'Unload', Message.ClickedUnload()),
});
export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
title: 'Workshop — effect-three',
body: h.div(
[h.Class('e3-sample')],
[
EffectThreeFoldkit.view(
{
scene: sceneFor(model),
mount: sceneMount,
className: 'e3-stage',
onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
onClick: ({ part }) => Message.ClickedPart({ part: part ?? 'robot' }),
onAssetLoaded: ({ id, final, parts, clips }) =>
Message.LoadedAsset({ id, final, parts, clips }),
onAssetFailed: ({ id, reason }) => Message.FailedAsset({ id, reason }),
},
h,
),
h.p([h.Class('e3-readout'), h.AriaLive('polite')], [readout(model)]),
h.div(
[h.Class('e3-controls'), h.AriaLabel('Animation')],
[...clips.map((clip) => clipButton(model, h, clip)), clipButton(model, h, null)],
),
h.div(
[h.Class('e3-controls')],
[
h.label(
[h.Class('e3-note')],
[
'Speed ',
h.input([
h.Class('e3-range'),
h.Type('range'),
h.Min('-2'),
h.Max('2'),
h.Step('0.25'),
h.Value(String(model.speed)),
h.OnInput((value) =>
Message.ChangedSpeed({ speed: Number.parseFloat(value) || 0 }),
),
]),
],
),
loadButton(model, h),
],
),
h.p(
[h.Class('e3-note')],
[
'The robot is a glTF model we generate from three.js primitives. The scene asks for it, ',
'the floor texture and the sign font in its description, and the environment loads ',
'them; unloading stops asking, which cancels a load still under way and frees what ',
'had arrived. The robot shows up through a borrowed node. Its clips are a function ',
'of scene time, so they pause with the scene and rewind with DevTools time travel.',
],
),
],
),
});