Examples

World-space lines

Strokes with width in metres, an antialiased edge, and a buffer rewritten in place.

Starts examples/lines in this page, on WebGPU where your browser has it.

Open on its own · Source

Splines, ribbons and lines is the chapter that walks through it.

From a checkout of the engine, npm run examples serves it at /lines/.

Source

main.ts

examples/lines/main.ts
/**
 * Vector drawing in the world: a polyline with real width, thickness in metres, and an
 * antialiased edge the renderer widens for you.
 *
 * This is the shape used for trajectories, debug rays, track centrelines and anything else
 * that is a stroke rather than a surface. Three things make it different from drawing a thin
 * box: the stroke keeps its width in *world* units so it thins with distance; `minWidthPerMetre`
 * is the floor that stops a far segment falling below a pixel and flickering; and `softness`
 * widens the antialiased edge inward, so a stroke can be crisp or feathered without touching
 * the scene's own multisampling.
 *
 * The buffer is allocated once at a fixed capacity and rewritten in place every frame —
 * `setPolyline` writes into arrays that already exist. Nothing here allocates per frame,
 * which is the rule the whole engine is built to.
 */
import { MeshBuilder, SceneNode, createLineSegments, setPolyline } from '@driftengine/core';
import { DAYLIGHT, controls, flagNumber, openStage } from '../common/stage';

/** Both are per-draw values, so the switches change the next frame's stroke. */
let softness = flagNumber('softness', 0);
let width = flagNumber('width', 0.06);

const stage = await openStage({ directionalShadows: true });

controls([
  {
    key: 'softness',
    label: 'softness',
    value: String(softness),
    options: [
      { text: 'crisp', value: '0' },
      { text: 'feathered', value: '0.8' },
    ],
    change: (value) => {
      softness = Number(value);
    },
  },
  {
    key: 'width',
    label: 'width',
    value: String(width),
    options: [
      { text: 'thin', value: '0.03' },
      { text: 'medium', value: '0.06' },
      { text: 'thick', value: '0.12' },
    ],
    change: (value) => {
      width = Number(value);
    },
  },
]);

const slab = new MeshBuilder();
slab.addBox([0, -0.25, 0], [9, 0.25, 9], [0.3, 0.32, 0.38]);
const ground = stage.renderer.createMesh(slab.build());

const still = new SceneNode();
still.updateWorld();

// #region buffer
/** One stroke of 220 segments, allocated once. `capacity` is segments, not points. */
const POINTS = 221;
const segments = createLineSegments(POINTS - 1);
const batch = stage.renderer.createLines(POINTS - 1, 'example-trail');
const path = new Float32Array(POINTS * 3);
// #endregion

stage.camera.position[0] = 6.5;
stage.camera.position[1] = 4.6;
stage.camera.position[2] = 7.5;
stage.camera.lookAt(0, 1.1, 0);

let seconds = 0;

stage.run({
  simulate(dt) {
    seconds += dt;
  },
  render() {
    /*
     * A Lissajous ribbon, rewritten in place. The whole curve moves every frame, which is the
     * demanding case: a trail that only appends would rewrite one segment.
     */
    for (let i = 0; i < POINTS; i += 1) {
      const t = (i / (POINTS - 1)) * Math.PI * 2;
      path[i * 3] = Math.sin(t * 3 + seconds * 0.7) * 3.4;
      path[i * 3 + 1] = 1.4 + Math.sin(t * 2 + seconds * 0.9) * 0.9;
      path[i * 3 + 2] = Math.cos(t * 2 + seconds * 0.5) * 3.4;
    }
    // #region draw
    segments.count = setPolyline(segments, path, POINTS);

    stage.renderer.beginFrame([0.05, 0.06, 0.09]);
    stage.renderer.bindMeshPass(stage.camera, DAYLIGHT);
    stage.renderer.drawMesh(ground, still.worldMatrix);
    stage.renderer.drawLines(
      batch,
      segments,
      still.worldMatrix,
      stage.camera,
      DAYLIGHT,
      [1, 0.72, 0.32],
      width,
      1,
      softness,
      /* The floor: at least two millimetres of half-width per metre away, about a pixel. */
      0.002,
    );
    // #endregion
    stage.renderer.endFrame();
  },
});