LINES AND POINTS
Draw with data.
A closed curve through five handles, five pixels wide with a colour gradient, its control polygon dashed beneath it and a Bézier swinging under the stage. Drag a handle and the curve, the polygon and the comet's path all follow. The comet rides the curve by a behaviour, trailing a tapering, fading ribbon and a cloud of sparkles, under a field of stars.
Lines, trails, sparkles and stars as data.
line draws Drei's fat lines and catmullRomPoints and quadraticBezierPoints give its curves; handles are boxes whose edges are drawn; trail, sparkles and stars are Drei's, seeded so they are the same on every load, and moving in scene time. Every one draws with WebGL or WebGPU (add ?renderer=webgpu).
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';
export const Vec3 = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number]);
export type Vec3 = typeof Vec3.Type;
export const Model = Schema.Struct({
/** The curve's control points, in the plane z = 0: each a handle you can drag. */
controls: Schema.Array(Vec3),
/** The handle being dragged, by its index. */
held: Schema.Option(Schema.Number),
/** Whether the curve itself is drawn dashed. */
dashed: Schema.Boolean,
/** Whether the comet glitters (sparkles round it). */
glitter: Schema.Boolean,
});
export type Model = typeof Model.Type;
/** Where the handles start: a wave across the stage. */
export const startControls: ReadonlyArray<Vec3> = [
[-3, -0.4, 0],
[-1.6, 1.1, 0],
[0, -0.5, 0],
[1.6, 1.2, 0],
[3, 0.1, 0],
];
export const init = () => ({
model: {
controls: startControls,
held: Option.none(),
dashed: false,
glitter: true,
} satisfies Model,
});
message.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Schema } from 'effect';
import { defineMessageUnion } from 'foldkit/message';
import { Vec3 } from './model';
export const Message = defineMessageUnion({
PressedHandle: { key: Schema.String, origin: Vec3, direction: Vec3 },
DraggedHandle: { key: Schema.String, origin: Vec3, direction: Vec3 },
DroppedHandle: {},
ClickedDashes: {},
ClickedGlitter: {},
ClickedReset: {},
ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
});
export type Message = typeof Message.Type;
update.ts
import { pointOnPlane } from '@opsydyn/effect-three';
import { Option } from 'effect';
import { modifyFields } from 'foldkit/struct';
import type { Return } from 'foldkit/update';
import { Message } from './message';
import { type Model, startControls, type Vec3 } from './model';
type AppReturn = Return<Model, Message>;
/** The plane the handles move in: z = 0, facing the camera. */
const stage = { normal: [0, 0, 1], constant: 0 } as const;
const handlePrefix = 'handle:';
/** A handle's key: the index of the control point it moves. */
export const handleKey = (index: number): string => `${handlePrefix}${index}`;
const indexOf = (key: string): Option.Option<number> =>
key.startsWith(handlePrefix)
? Option.some(Number(key.slice(handlePrefix.length)))
: Option.none();
/** Keeps a dragged point on the stage. */
const clamp = ([x, y]: Vec3): Vec3 => [
Math.min(4, Math.max(-4, x)),
Math.min(2.4, Math.max(-2.4, y)),
0,
];
/** Moves the held handle to where the drag ray meets the stage. */
const drag = (model: Model, origin: Vec3, direction: Vec3): Model =>
Option.match(
Option.all([model.held, Option.fromNullishOr(pointOnPlane({ origin, direction }, stage))]),
{
onNone: () => model,
onSome: ([held, point]) =>
modifyFields(model, {
controls: (controls) =>
controls.map((control, index) => (index === held ? clamp(point) : control)),
}),
},
);
export const update = (model: Model, message: Message): AppReturn =>
Message.match<AppReturn>(message, {
PressedHandle: ({ key }) => ({ model: modifyFields(model, { held: () => indexOf(key) }) }),
DraggedHandle: ({ origin, direction }) => ({ model: drag(model, origin, direction) }),
DroppedHandle: () => ({ model: modifyFields(model, { held: () => Option.none() }) }),
ClickedDashes: () => ({ model: modifyFields(model, { dashed: (dashed) => !dashed }) }),
ClickedGlitter: () => ({ model: modifyFields(model, { glitter: (glitter) => !glitter }) }),
ClickedReset: () => ({ model: modifyFields(model, { controls: () => startControls }) }),
ReceivedSceneLifecycle: () => ({ model }),
});
ride.ts
/**
* The comet's `ride` behaviour: once round the closed Catmull-Rom curve through the
* handles every `lap` seconds of scene time. The handles come as its parameters (`x0`,
* `y0` …), so dragging one changes the path at once, with no Message a frame.
*/
import { type Behaviour, catmullRomPointAt, type Vec3 } from '@opsydyn/effect-three';
/** How many control points the behaviour reads: `x0`, `y0` … up to five. */
export const ridePoints = 5;
/** Once round the closed curve every `lap` seconds. */
export const ride: Behaviour = (pose, seconds, params) => {
const controls = Array.from(
{ length: ridePoints },
(_, index): Vec3 => [params[`x${index}`] ?? 0, params[`y${index}`] ?? 0, 0],
);
const lap = (((seconds / (params['lap'] ?? 8)) % 1) + 1) % 1;
const [x, y, z] = catmullRomPointAt(controls, lap, { closed: true });
pose.offset.set(x, y, z);
};
scene.ts
/**
* Curves, drawn as data:
*
* - a closed Catmull-Rom curve through five handles (`catmullRomPoints`), five pixels wide
* with a colour gradient (`catmullRomColors`), or dashed;
* - the handles' control polygon as a thin dashed line, and a quadratic Bézier swinging
* under it (`quadraticBezierPoints`);
* - handles that are boxes with their edges drawn (`edges`), dragged in the plane z = 0;
* - a comet riding the curve by a behaviour, with a tapering, fading trail (`trail`) and,
* glittering, sparkles round it (`sparkles`);
* - and stars behind it all (`stars`).
*/
import {
basic,
behave,
box,
catmullRomColors,
catmullRomPoints,
edges,
group,
line,
mesh,
perspectiveCamera,
quadraticBezierPoints,
type SceneNode,
scene,
sparkles,
sphere,
standard,
stars,
trail,
} from '@opsydyn/effect-three';
import { paintColour } from '../palette';
import type { Model } from './model';
import { ridePoints } from './ride';
import { handleKey } from './update';
const curveSegments = 120;
const curve = (model: Model): SceneNode =>
line({
key: 'curve',
points: catmullRomPoints(model.controls, { closed: true, segments: curveSegments }),
vertexColors: catmullRomColors(
[paintColour.coral, paintColour.gold, paintColour.mint, paintColour.blue],
{ segments: curveSegments, closed: true },
),
width: 5,
dashed: model.dashed,
dashSize: 0.25,
gapSize: 0.12,
});
/** The handles joined in order, dashed and faint: the polygon the curve smooths. */
const polygon = (model: Model): SceneNode =>
line({
key: 'polygon',
points: [...model.controls, ...model.controls.slice(0, 1)],
color: 0x8a90a0,
width: 1.5,
dashed: true,
dashSize: 0.08,
gapSize: 0.08,
});
/** A quadratic Bézier from the first handle to the last, swinging down under the stage. */
const swing = (model: Model): ReadonlyArray<SceneNode> => {
const [first] = model.controls;
const last = model.controls.at(-1);
if (first === undefined || last === undefined) return [];
return [
line({
key: 'swing',
points: quadraticBezierPoints({ start: first, end: last, mid: [0, -3.2, 0], segments: 40 }),
color: paintColour.gold,
width: 2,
dashed: true,
dashSize: 0.18,
gapSize: 0.1,
transparent: true,
opacity: 0.7,
}),
];
};
const handles = (model: Model): ReadonlyArray<SceneNode> =>
model.controls.map((at, index) =>
mesh({
key: handleKey(index),
position: at,
geometry: box({ width: 0.24, height: 0.24, depth: 0.24 }),
material: standard({ color: 0x20242c, roughness: 0.5 }),
edges: edges({ color: 0xffffff, width: 1.5 }),
interactive: true,
}),
);
/** The comet's ride: the handles as the behaviour's parameters. */
const rideParams = (model: Model): Readonly<Record<string, number>> =>
Object.fromEntries([
['lap', 8],
...model.controls.slice(0, ridePoints).flatMap(([x, y], index) => [
[`x${index}`, x],
[`y${index}`, y],
]),
]);
const comet = (model: Model): SceneNode =>
group({
key: 'comet',
behaviour: behave('ride', rideParams(model)),
trail: trail({
width: 3,
length: 12,
color: paintColour.gold,
taper: 'linear',
fade: true,
}),
children: [
mesh({
key: 'comet:head',
geometry: sphere({ radius: 0.11 }),
material: basic({ color: 0xfff4d6 }),
}),
...(model.glitter
? [
sparkles({
key: 'comet:glitter',
count: 40,
spread: 0.9,
size: 3,
speed: 0.6,
color: paintColour.gold,
}),
]
: []),
],
});
export const sceneFor = (model: Model) =>
scene({
// Far enough for the stars: Drei draws them at twice their radius (React Three Fiber's
// default camera reaches 1000).
camera: perspectiveCamera({ fov: 45, position: [0, 0.2, 9], lookAt: [0, 0.2, 0], far: 400 }),
children: [
stars({ key: 'stars', radius: 40, depth: 30, count: 3000, factor: 3, fade: true }),
curve(model),
polygon(model),
...swing(model),
...handles(model),
comet(model),
],
});
view.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
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 toggle = (h: HtmlBuilder<Message>, label: string, on: boolean, message: Message): Html =>
h.button(
[h.Class('e3-button'), h.Type('button'), h.AriaPressed(String(on)), h.OnClick(message)],
[label],
);
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: 'Curves — effect-three',
body: h.div(
[h.Class('e3-sample')],
[
EffectThreeFoldkit.view(
{
scene: sceneFor(model),
mount: sceneMount,
className: 'e3-stage e3-stage-tall e3-stage-night',
onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
onPointerDown: ({ key, ray }) =>
Message.PressedHandle({ key, origin: ray.origin, direction: ray.direction }),
onDrag: ({ key, ray }) =>
Message.DraggedHandle({ key, origin: ray.origin, direction: ray.direction }),
onDragEnd: () => Message.DroppedHandle(),
},
h,
),
h.p(
[h.Class('e3-readout'), h.AriaLive('polite')],
['Drag a handle to reshape the curve; the comet follows the new path at once.'],
),
h.div(
[h.Class('e3-controls')],
[
toggle(h, 'Dashed curve', model.dashed, Message.ClickedDashes()),
toggle(h, 'Glitter', model.glitter, Message.ClickedGlitter()),
button(h, 'Reset the handles', Message.ClickedReset()),
],
),
],
),
});