docs / atmospheres / hehku

Hehku

one continuous ribbon of light heated like a filament, coiling slowly through the void with no ends and no seam. thin runs are cold violet; where the coil bunches and looks through itself the light gathers and blooms toward white.

intensity · roomrsc · client

provenance

authored in usva

layer

atmospheres

intensity

room · the whole environment. nothing competes with it

composition

behind a hero or a full section, children in normal flow above itone figure per view. it is the environment, not a decorationnever on a ground it cannot outshine. on savi it renders nothingnot a card or panel background. it needs a void to coil through

a11y

the canvas is aria-hidden and pointer-transparent · reduced motion paints one still frame · with no WebGL2 the canvas never mountsjest-axe

dependencies

ogl · atmospheres-core from the same package
live demo · try it out

one long coil

a single closed curve, cold where it runs thin and bright where it bunches

customize
speedwander and spin rate, keep it slow
1
opacityoverall strength, 0 to 1
1
modeauto reads the ground, or force emit / stain
coolthe thin cold runs
hotwhere the coil bunches and blooms
thicknessradius of the emitting core
0.16
glowfalloff, larger is thinner and colder
26
bloomhalo level counted as looking through coil
6
exposurebrightness of the ramp
1.9
advanced16
segmentscapsules in the chain, more is smoother
96
radius · majorsize of the torus the knot winds around
2.1
radius · minorthickness of that torus
1.05
winding · pturns about the axis, coprime with q
2
winding · qturns through the hole, coprime with p
3
scale · xwidth of the finished figure
1.85
scale · yheight of the finished figure
1.12
scale · zdepth of the finished figure
1
driftwander of the curve away from the pure knot
0.4
driftRatehow fast that wander and roll move
0.06
spinrevolutions per second about the vertical
0.01
tiltfixed lean of the whole figure, radians
0.6
disteye distance from the coil
2.9
focallens length, higher is flatter
1
offset · xlateral eye position, crops the coil
1.2
offset · yvertical eye position
1.9
usagecopy
import { Hehku } from "usva/atmospheres/hehku";

<Hehku
  speed={1}
>
  <Hero />
</Hehku>

props

proptypedefaultnotes
childrenReactNoderendered above the coil, in normal flow.
speednumber1rate of the coil's wander and spin. it is meant to stay slow.
opacitynumber1overall strength, 0 to 1.
mode"emissive" | "absorptive"resolved bgforces the blend. by default a dark ground emits and a light ground stains.
colors{ cool?; hot? }accent · accent-altthe two ends of the heat ramp. cool sinks under accent, hot lifts accent-alt toward white.
paramsPartial<FilamentParams>FILAMENT_DEFAULTSthe curve: segments, radius, winding, scale, drift, driftRate, spin, tilt, thickness, glow. the windings must stay coprime integers or the knot degenerates into one circle traced several times.
viewPartial<FilamentView>FILAMENT_VIEWthe camera and the ramp: dist, focal, offset, bloom, exposure. the offset holds the eye off-axis so the coil crops at the frame.

get it

npx shadcn add https://usva.build/r/hehku.jsoncopy
source · components/ui/filament-curve.tsexactly what this command copiescopy
/**
 * One closed torus knot, sampled into a chain of capsules. Every term is
 * periodic in an integer multiple of the curve parameter, so the last knot
 * lands exactly on the first and the filament has no ends: one continuous
 * ribbon that passes behind and in front of itself.
 */

const TAU = Math.PI * 2;

export interface FilamentParams {
  /** Capsules in the chain. Knots emitted is segments + 1. */
  segments: number;
  /** Major and minor radius of the torus the knot winds around. */
  radius: [number, number];
  /** Windings [about the axis, through the hole]. Keep them coprime, or the
   * knot degenerates into one circle traced several times. */
  winding: [number, number];
  /** Anisotropic scale of the finished figure. */
  scale: [number, number, number];
  /** Slow wander of the curve away from the pure knot. */
  drift: number;
  /** Rate of the wander, and of the roll of the strand around its tube. */
  driftRate: number;
  /** Revolutions per second about the vertical. */
  spin: number;
  /** Fixed tilt of the whole figure, radians. */
  tilt: number;
  /** Radius of the emitting core, world units. */
  thickness: number;
  /** Falloff of exp(-glow * d^2). Larger is thinner and colder. */
  glow: number;
}

export const FILAMENT_DEFAULTS: FilamentParams = {
  segments: 96,
  radius: [2.1, 1.05],
  winding: [2, 3],
  scale: [1.85, 1.12, 1],
  drift: 0.4,
  driftRate: 0.06,
  spin: 0.01,
  tilt: 0.6,
  thickness: 0.16,
  glow: 26,
};

export function resolveFilamentParams(
  overrides: Partial<FilamentParams> = {},
): FilamentParams {
  return { ...FILAMENT_DEFAULTS, ...overrides };
}

/** Flat xyz triples, segments + 1 knots, closed. The drift phase rolls the
 * strand around its tube, so the coil keeps threading through itself without
 * the figure ever changing character. */
export function filamentKnots(time: number, p: FilamentParams): number[] {
  const [major, minor] = p.radius;
  const [pw, qw] = p.winding;
  const [sx, sy, sz] = p.scale;
  const spin = time * p.spin * TAU;
  const cs = Math.cos(spin);
  const ss = Math.sin(spin);
  const ct = Math.cos(p.tilt);
  const st = Math.sin(p.tilt);
  const dt = time * p.driftRate;

  const knots: number[] = [];
  for (let i = 0; i <= p.segments; i++) {
    const u = (i % p.segments) * (TAU / p.segments);
    const tube = minor + p.drift * Math.sin(2 * u + dt * 1.3);
    const roll = qw * u + dt * 0.7;
    const w = major + tube * Math.cos(roll);

    const x = w * Math.cos(pw * u) * sx;
    const y = (w * Math.sin(pw * u) + p.drift * Math.sin(u + dt)) * sy;
    const z = tube * Math.sin(roll) * sz;

    const yt = y * ct - z * st;
    const zt = y * st + z * ct;
    knots.push(x * cs + zt * ss, yt, zt * cs - x * ss);
  }
  return knots;
}
source · components/ui/filament-shader.tsexactly what this command copiescopy
import { glsl } from "./atmospheres-glsl";

/**
 * Hehku is one object, not a field: a single filament coiling through void,
 * heated until the places where it bunches against itself glow green-white
 * while the long thin runs stay deep violet.
 *
 * There is no march. Around a thin capsule the squared distance along a ray is
 * a parabola, so the ray integral of the gaussian core has a closed form:
 * exp(-k * d0^2) / sin(theta), with d0 the closest approach and theta the angle
 * between ray and segment. That 1 / sin factor is the physics of the piece: a
 * ray running along the filament collects more light, which is why the coil
 * blooms exactly where it turns toward the eye or bunches against itself.
 */

const MAX_KNOTS = 97;

export const MAX_FILAMENT_SEGMENTS = MAX_KNOTS - 1;

export const filamentFragmentShader = /* glsl */ `#version 300 es
precision highp float;

uniform vec2  uResolution;
uniform vec3  uKnots[${MAX_KNOTS}];
uniform int   uSegments;
uniform float uDist;
uniform float uFocal;
uniform vec2  uOffset;
uniform float uThickness;
uniform float uGlow;
uniform float uBloom;
uniform vec3  uCool;
uniform vec3  uHot;
uniform float uExposure;
uniform float uAlpha;
uniform float uBlend;

out vec4 fragColor;

${glsl("dither", "composite")}

const int MAX_SEGMENTS = ${MAX_KNOTS - 1};

void main() {
  vec2 uv = (gl_FragCoord.xy * 2.0 - uResolution) / uResolution.y;

  vec3 ro = vec3(uOffset, uDist);
  vec3 rd = normalize(vec3(uv, -uFocal));

  float spark = 0.0;
  float core = 0.0;
  float halo = 0.0;

  for (int s = 0; s < MAX_SEGMENTS; s++) {
    if (s >= uSegments) break;
    vec3 a = uKnots[s];
    vec3 ba = uKnots[s + 1] - a;
    float ba2 = max(dot(ba, ba), 1e-5);
    vec3 w = ro - a;
    float bd = dot(rd, ba);
    // The denominator is ba2 * sin^2(theta); the floor caps the alignment
    // bloom so a ray staring straight down a segment stays finite.
    float denom = max(ba2 - bd * bd, ba2 * 0.1);
    float h = clamp((dot(w, ba) - bd * dot(w, rd)) / denom, 0.0, 1.0);
    vec3 q = a + ba * h;
    float t = max(dot(q - ro, rd), 0.0);
    vec3 pc = ro + rd * t - q;
    float dr = length(pc);
    float d = max(dr - uThickness, 0.0);
    // The coil's slow wander can sweep the strand through the eye point, and
    // a pass through the camera would white out the whole frame; the near
    // fade lets it slide past the lens instead.
    float boost = inversesqrt(max(1.0 - bd * bd / ba2, 0.1))
      * smoothstep(0.1, 0.9, t);
    spark += exp(-uGlow * 4.0 * dr * dr) * boost;
    core += exp(-uGlow * d * d) * boost;
    halo += exp(-uGlow * 0.14 * d * d) * boost;

    // The chain is closed, so every knot is the clamped end of two segments
    // and would be counted twice. One point term per segment start pays the
    // whole loop back exactly once.
    float ta = max(dot(-w, rd), 0.0);
    float dra = length(w + rd * ta);
    float da = max(dra - uThickness, 0.0);
    float nfa = smoothstep(0.1, 0.9, ta);
    spark -= exp(-uGlow * 4.0 * dra * dra) * nfa;
    core -= exp(-uGlow * da * da) * nfa;
    halo -= exp(-uGlow * 0.14 * da * da) * nfa;
  }

  spark = max(spark, 0.0);
  core = max(core, 0.0);
  halo = max(halo, 0.0);

  float bunch = max(halo - uBloom, 0.0);
  float heat = 1.0 - exp(-bunch * bunch * 0.1);
  // The heat ramp runs violet body, green-white strand centre, white where the
  // coil bunches: incandescence, not a flat white clip.
  vec3 glowRamp = mix(uHot, vec3(1.0), heat * 0.7);
  vec3 col = (uCool * (core * 0.5 + halo * 0.06)
    + glowRamp * (spark * (0.45 + 1.1 * heat) + 0.4 * core * heat))
    * uExposure;

  // Tonemapped on luminance, not per channel, so the bright passes saturate
  // toward the heat ramp instead of clipping to flat white.
  float lum = max(col.r, max(col.g, col.b));
  float peak = 1.0 - exp(-lum);
  vec3 display = col / max(lum, 1e-4);
  display = mix(display, display * display, uBlend);
  display = dither(display, gl_FragCoord.xy, 1.0 / 255.0);

  fragColor = composite(display, peak * uAlpha);
}
`;
source · components/ui/filament.tsexactly what this command copiescopy
import type { Rgb } from "./atmospheres-color";
import {
  setUniform,
  type Uniforms,
} from "./atmospheres-gl";
import type { FilamentParams } from "./filament-curve";
import { MAX_FILAMENT_SEGMENTS } from "./filament-shader";

export interface FilamentColors {
  /** The thin cold runs. */
  cool: Rgb;
  /** Where the coil bunches and light accumulates. */
  hot: Rgb;
}

export interface FilamentView {
  /** Eye distance along +z. */
  dist: number;
  focal: number;
  /** Lateral eye position. Off-axis, so the coil crops at the frame instead
   * of sitting whole in the middle of it. */
  offset: [number, number];
  /** Halo level above which a ray counts as looking through bunched coil. */
  bloom: number;
  exposure: number;
}

export const FILAMENT_VIEW: FilamentView = {
  dist: 2.9,
  focal: 1,
  offset: [1.2, 1.9],
  bloom: 6,
  exposure: 1.9,
};

export function resolveFilamentView(
  overrides: Partial<FilamentView> = {},
): FilamentView {
  return { ...FILAMENT_VIEW, ...overrides };
}

/** The thin runs sink well under the token: deep cold violet, not lavender. */
export function coolFrom(accent: Rgb): Rgb {
  return [accent[0] * 0.5, accent[1] * 0.3, accent[2] * 0.9];
}

/** Green heated toward white: the bunched passes are the only bright thing. */
export function hotFrom(accentAlt: Rgb): Rgb {
  const lift = (c: number) => c + (1 - c) * 0.35;
  return [lift(accentAlt[0]), lift(accentAlt[1]), lift(accentAlt[2])];
}

/** ogl reads array uniforms only when Array.isArray passes, so the knots reach
 * the GPU as a plain flat number[]. A Float32Array uploads nothing at all. */
function padKnots(knots: readonly number[]): number[] {
  const size = (MAX_FILAMENT_SEGMENTS + 1) * 3;
  const out = knots.slice(0, size);
  const tail = out.slice(-3);
  while (out.length < size) out.push(...(tail.length === 3 ? tail : [0, 0, 0]));
  return out;
}

export interface FilamentFrame {
  knots: readonly number[];
  segments: number;
  alpha: number;
  blend: number;
}

export function filamentUniforms(
  colors: FilamentColors,
  params: FilamentParams,
  view: FilamentView,
): Uniforms {
  return {
    uResolution: { value: [1, 1] },
    uKnots: { value: padKnots([]) },
    uSegments: { value: Math.min(params.segments, MAX_FILAMENT_SEGMENTS) },
    uDist: { value: view.dist },
    uFocal: { value: view.focal },
    uOffset: { value: [view.offset[0], view.offset[1]] },
    uThickness: { value: params.thickness },
    uGlow: { value: params.glow },
    uBloom: { value: view.bloom },
    uCool: { value: colors.cool },
    uHot: { value: colors.hot },
    uExposure: { value: view.exposure },
    uAlpha: { value: 1 },
    uBlend: { value: 0 },
  };
}

export function setFilamentColors(u: Uniforms, colors: FilamentColors): void {
  setUniform(u, "uCool", colors.cool);
  setUniform(u, "uHot", colors.hot);
}

export function setFilamentShape(
  u: Uniforms,
  params: FilamentParams,
  view: FilamentView,
): void {
  setUniform(u, "uThickness", params.thickness);
  setUniform(u, "uGlow", params.glow);
  setUniform(u, "uDist", view.dist);
  setUniform(u, "uFocal", view.focal);
  setUniform(u, "uOffset", [view.offset[0], view.offset[1]]);
  setUniform(u, "uBloom", view.bloom);
  setUniform(u, "uExposure", view.exposure);
}

export function setFilamentFrame(u: Uniforms, frame: FilamentFrame): void {
  setUniform(u, "uKnots", padKnots(frame.knots));
  setUniform(u, "uSegments", Math.min(frame.segments, MAX_FILAMENT_SEGMENTS));
  setUniform(u, "uAlpha", frame.alpha);
  setUniform(u, "uBlend", frame.blend);
}
source · components/ui/hehku.tsxexactly what this command copiescopy
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
  type BlendMode,
  blendStyleFor,
  blendUniform,
  resolveBlendMode,
  resolveColor,
} from "./atmospheres-color";
import { hiddenOnGround } from "./atmospheres-ground";
import { useGlCanvas } from "./use-gl-canvas";
import { useTokenColors } from "./use-token-colors";
import {
  coolFrom,
  type FilamentColors,
  type FilamentView,
  filamentUniforms,
  hotFrom,
  resolveFilamentView,
  setFilamentColors,
  setFilamentFrame,
  setFilamentShape,
} from "./filament";
import {
  type FilamentParams,
  filamentKnots,
  resolveFilamentParams,
} from "./filament-curve";
import { filamentFragmentShader } from "./filament-shader";

const ROLES = ["accent", "accent-alt"] as const;

export interface HehkuProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Rate of the coil's wander and spin. Defaults to 1. It should stay slow. */
  speed?: number;
  /** Overall strength, 0..1. Defaults to 1. */
  opacity?: number;
  /** Dark grounds emit, light grounds stain. Defaults to the resolved bg. */
  mode?: BlendMode;
  /** Override the two ends of the heat ramp. */
  colors?: { cool?: string; hot?: string };
  /** Escape hatch for the curve, for tuning demos. */
  params?: Partial<FilamentParams>;
  /** Escape hatch for the camera and the heat ramp. */
  view?: Partial<FilamentView>;
  children?: React.ReactNode;
}

/** Seconds into the coil the reduced-motion still frame is taken from. */
const STILL_TIME = 14;

export const Hehku = React.forwardRef<HTMLDivElement, HehkuProps>(
  (
    {
      speed = 1,
      opacity = 1,
      mode,
      colors,
      params,
      view,
      className,
      children,
      ...props
    },
    forwardedRef,
  ) => {
    const cCool = colors?.cool;
    const cHot = colors?.hot;

    const scopeRef = React.useRef<HTMLDivElement | null>(null);
    const tokens = useTokenColors(ROLES, { scopeRef });
    const blend = resolveBlendMode(mode, tokens.bg);

    const heat = React.useMemo<FilamentColors>(
      () => ({
        cool: cCool ? resolveColor(cCool) : coolFrom(tokens.colors.accent),
        hot: cHot ? resolveColor(cHot) : hotFrom(tokens.colors["accent-alt"]),
      }),
      [cCool, cHot, tokens],
    );

    const heatRef = React.useRef(heat);
    heatRef.current = heat;
    const speedRef = React.useRef(speed);
    speedRef.current = speed;
    const opacityRef = React.useRef(opacity);
    opacityRef.current = opacity;
    const blendRef = React.useRef(blend);
    blendRef.current = blend;

    const curve = resolveFilamentParams(params);
    const curveRef = React.useRef(curve);
    curveRef.current = curve;
    const camera = resolveFilamentView(view);
    const cameraRef = React.useRef(camera);
    cameraRef.current = camera;

    const canvas = useGlCanvas({
      fragment: filamentFragmentShader,
      uniforms: () =>
        filamentUniforms(heatRef.current, curveRef.current, cameraRef.current),
      enabled: !hiddenOnGround("hehku", blend),
      maxDpr: 1.5,
      stillTime: STILL_TIME,
      onFrame: (u, frame) => {
        const p = curveRef.current;
        const time = frame.time * speedRef.current;
        setFilamentColors(u, heatRef.current);
        setFilamentShape(u, p, cameraRef.current);
        setFilamentFrame(u, {
          knots: filamentKnots(time, p),
          segments: p.segments,
          alpha: opacityRef.current,
          blend: blendUniform(blendRef.current),
        });
      },
    });

    const { redraw } = canvas;
    // biome-ignore lint/correctness/useExhaustiveDependencies: the still frame must repaint when the theme repaints the heat ramp.
    React.useEffect(() => {
      redraw();
    }, [redraw, heat, blend]);

    const coilOn = canvas.active;

    return (
      <div
        ref={(node) => {
          canvas.containerRef.current = node;
          scopeRef.current = node;
          if (typeof forwardedRef === "function") forwardedRef(node);
          else if (forwardedRef) forwardedRef.current = node;
        }}
        data-fluid={coilOn ? "on" : "off"}
        className={cn("relative isolate overflow-hidden", className)}
        {...props}
      >
        {coilOn ? (
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 -z-10"
          >
            <canvas
              ref={canvas.canvasRef}
              className="block h-full w-full"
              style={blendStyleFor(blend)}
            />
          </div>
        ) : null}
        {children}
      </div>
    );
  },
);
Hehku.displayName = "Hehku";