Kinetic Halo

  • TypeGPU
  • WebGPU

A crown of chrome spikes that breathes in a slow wave, drawn with TypeGPU on WebGPU rather than Skia. Painting the spikes is the expensive part, so it happens once; after that, each frame uploads 16 bytes.

View plans

Purchases are not open yet.

A loop with no crossfade

Spike length is a three-lobe wave plus a six-lobe wave that runs at half the phase, so both repeat after a third of a turn. One 6.8 s loop moves the pattern by exactly one lobe and lands back on its first frame. Brightness shimmers at the same rate, offset by a random seed per spike, so it reads as a twinkle rather than a band sweeping around.

export function phaseAt(elapsedMs: number): number {  const wrapped = ((elapsedMs % PERIOD_MS) + PERIOD_MS) % PERIOD_MS;  return (wrapped / PERIOD_MS) * (TAU / 3);}// …const a = 3 * (s.theta - 102.1 * DEG);const b = 6 * (s.theta - 14.8 * DEG);const amplitude = Math.hypot(  66 * Math.cos(a) + 8 * Math.cos(b),  66 * Math.sin(a) + 8 * Math.sin(b),);

Fifteen layers, one shader

Each spike is about fifteen filled and stroked shapes with gradients and blend modes. Their curves are flattened into line segments on the CPU, with chord error under a quarter of a texel, and one fragment shader evaluates every shape by signed distance, so a whole tile is painted in a single pass. Mirrored spikes reflect the sample point about the spine instead of storing a flipped copy of each outline.

export interface SpikePath {  readonly start: readonly [number, number];  readonly cubics: readonly Cubic[];  /** Closed paths are FILLED (coverage by winding); open ones are STROKED (coverage by distance). */  readonly closed: boolean;}// …export const BODY: SpikePath = {  start: [0, 0],  cubics: [    c(-0.095, 0.02, -0.255, 0.09, -0.345, 0.205),

What a frame touches

Angle, radius, width, tile and seed sit in a 32-byte record per spike that is uploaded once, and the vertex shader derives each length and brightness from the phase. The texture keeps a full 13-level mip chain, because spikes are drawn rotated and minified and nearest sampling makes their edges crawl. Drawing stops when the screen loses focus: a deep link keeps the old route mounted, and drawing into its torn-down surface crashed.

export const SPIKE_PARAM_FLOATS = 16;export function packSpikeParams(): Float32Array<ArrayBuffer> {  const data = new Float32Array(    new ArrayBuffer(SPIKE_COUNT * SPIKE_PARAM_FLOATS * Float32Array.BYTES_PER_ELEMENT),  );  for (let i = 0; i < SPIKE_COUNT; i += 1) {    const p = spikeParams(i);    const o = i * SPIKE_PARAM_FLOATS;