← All examples

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).

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';

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