Air Painting

Wipe fog off the glass with your hands. ONNX Runtime Web tracks both palms on WebGPU to clear a frosted camera feed.

Open fullscreen
/**
 * All vgpu resources and passes for the air-painting example.
 *
 * **ORT-free by contract.** `scripts/render-example-thumbs.mjs` bundles this
 * module for Node through `renderer.ts`, so it must never import
 * `onnxruntime-web` — not even dynamically. Session orchestration and the
 * borrowed-buffer lifetime live in `ort-runtime.ts`.
 *
 * Ownership split, so the VISUAL owner and this module never fight:
 *
 * - This module owns the mask, the brush state, the ROI buffer, the two model
 *   input buffers, the frame texture and the shader dispatches. All of them are
 *   vgpu-owned and disposed here.
 * - `consumeHandLandmarks()` takes **borrowed** buffers. It reads them inside the
 *   dispatches it submits and never retains them. The caller keeps ORT's tensors
 *   alive across the call and flushes before disposing the wrappers.
 * - `renderVisualFrame()` never touches a landmark buffer at all; it composites
 *   from the persistent mask and brush state, so the display loop is free to run
 *   at 60 Hz between inference results.
 *
 * ## The model inputs are built here, on the GPU
 *
 * `cropModelInput()` samples the camera texture through a rotated ROI straight
 * into a storage buffer that ORT consumes as a tensor. Both model inputs come
 * from that one call — the detector's letterbox is just the unrotated,
 * whole-frame case — so there is no CPU pixel work anywhere in this example.
 *
 * The ROI it crops through lives in a **GPU buffer that `hand.wgsl` writes**.
 * That is what keeps tracked frames off the CPU entirely: the host never learns
 * where the hand is, it just asks for slot 0 and slot 1 to be cropped again.
 */
import type { Buffer, Compute, Effect, Gpu, Surface, Target, Texture } from 'vgpu';
import { compute as createCompute, effect as createEffect, frame as runFrame, sampler as createSampler, target as createTarget } from 'vgpu';
import {
  BRUSH_BUFFER_BYTES,
  BRUSH_TUNING,
  FOG_TUNING,
  fogDecay,
  MASK_BYTES,
  MASK_HEIGHT,
  MASK_TEXELS,
  MASK_WIDTH,
  maxJumpDistance,
  ROI_BYTES,
  ROI_DETECTOR_SLOT,
  ROI_SLOT_COUNT,
  ROI_STRIDE_FLOATS,
  type BrushTuning,
  type FogTuning,
} from './brush-contract';
import {
  DETECTOR_INPUT_BYTES,
  DETECTOR_SIZE,
  LANDMARK_INPUT_BYTES,
  LANDMARK_POINTS_BUFFER_BYTES,
  LANDMARK_SIZE,
  MAX_HANDS,
} from './hand-model-contract';
import { detectorRoi } from './hand-preprocess';
import type { HandRoi } from './hand-pipeline';
import compositeWgsl from './composite.wgsl';
import frostWgsl from './frost.wgsl';
import handWgsl from './hand.wgsl';
import handCropWgsl from './hand-crop.wgsl';
import paintWgsl from './paint.wgsl';
 
/** Matches `@workgroup_size(64)` in paint.wgsl. */
const PAINT_WORKGROUP_SIZE = 64;
const PAINT_WORKGROUPS = Math.ceil(MASK_TEXELS / PAINT_WORKGROUP_SIZE);
/** Matches `@workgroup_size(8, 8)` in hand-crop.wgsl. */
const CROP_WORKGROUP_SIZE = 8;
 
/**
 * How much to grow the landmark bounding box into the next frame's ROI.
 *
 * 2.0 is MediaPipe's value and it is not generous padding for its own sake: the
 * crop has to still contain the hand *next* frame, after it has moved and
 * possibly opened, and a hand that clips out of its own ROI cannot be recovered
 * by the landmark model — only by rerunning the detector.
 */
export const ROI_LOOPBACK_SCALE = 2;
 
/** Byte view for `Buffer.write`; narrows TypeScript's ArrayBufferLike generic. */
function asWriteData(view: Float32Array | Uint8Array): Uint8Array<ArrayBuffer> {
  return new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength);
}
 
export interface VisualPipelineOptions {
  /** Camera frame width in pixels. */
  readonly sourceWidth: number;
  /** Camera frame height in pixels. */
  readonly sourceHeight: number;
  readonly label?: string;
  readonly tuning?: BrushTuning;
  readonly fog?: FogTuning;
}
 
/** One slot's contribution to a consumed result. */
export interface HandResultInput {
  /**
   * Borrowed `[1,63]` landmark buffer, or `undefined` when this slot did not run
   * the landmark model this frame.
   */
  readonly landmarks?: Buffer;
  /** Hand presence from the landmark model; ORT returns this one float on the CPU. */
  readonly presence: number;
}
 
export interface ConsumeOptions {
  /** Drop stroke continuity for this result, e.g. right after Clear. */
  readonly reset?: boolean;
}
 
export interface VisualFrameOptions {
  /** Clamped device pixel ratio; keeps the grain cell a fixed logical size. */
  readonly dpr?: number;
  /** False before any camera/canned frame has been uploaded. */
  readonly hasFrame?: boolean;
  /** Draw the brush cursor. */
  readonly showCursor?: boolean;
}
 
export interface VisualPipeline {
  readonly sourceWidth: number;
  readonly sourceHeight: number;
  /** Persistent f32 coverage mask in `brush` space, 960x540. */
  readonly mask: Buffer;
  /** Persistent per-hand stroke state, one 64-byte slot per hand; only the GPU writes it. */
  readonly brushes: Buffer;
  /** Rotated crop regions in source pixels; written by the GPU on tracked frames. */
  readonly rois: Buffer;
  /** NHWC float32 `[1,192,192,3]`, ready to be handed to ORT as a tensor. */
  readonly detectorInput: Buffer;
  readonly frameTexture: Texture;
 
  /** NHWC float32 `[1,224,224,3]` for one hand slot. */
  landmarkInput(slot: number): Buffer;
 
  /** Overwrites one ROI slot from the host. Only used on reacquisition and resize. */
  writeRoi(slot: number, roi: HandRoi): void;
 
  /**
   * Samples the camera texture into the detector's input buffer.
   * Submitted immediately; the caller flushes before ORT reads it.
   */
  cropDetectorInput(): void;
 
  /** Samples the camera texture into one hand slot's landmark input buffer. */
  cropLandmarkInput(slot: number): void;
 
  /**
   * Runs `hand.wgsl` then `paint.wgsl` against the borrowed landmark buffers.
   *
   * Both dispatches are submitted before this returns, so the caller only has to
   * flush the queue before releasing the wrappers.
   */
  consumeHandLandmarks(
    results: readonly HandResultInput[],
    dtSeconds: number,
    options?: ConsumeOptions,
  ): void;
 
  /** Composites the newest frame, the persistent mask and the fixed grain. */
  renderVisualFrame(output: Surface | Target, options?: VisualFrameOptions): void;
 
  /** Uploads tightly packed RGBA8 of exactly the current source size. */
  writeFrame(rgba: Uint8Array): void;
 
  /** Copies a video frame straight into the device texture (browser only). */
  copyExternalFrame(source: GPUCopyExternalImageSource): void;
 
  /** Zeroes the mask and drops stroke continuity. Nothing else is reset. */
  clearMask(): void;
 
  /** Rebuilds the frame texture after the camera renegotiates its resolution. */
  resizeSource(sourceWidth: number, sourceHeight: number): void;
 
  dispose(): void;
}
 
/** Allocates a landmark-shaped storage buffer for fixture-driven modes. */
export function createLandmarkBuffer(gpu: Gpu, label = 'air-painting', slot = 0): Buffer {
  return gpu.device.createBuffer({
    size: LANDMARK_POINTS_BUFFER_BYTES,
    usage: ['storage', 'copy_dst'],
    label: `${label}-landmarks-${slot}`,
  });
}
 
/** Writes a golden `[1,63]` array into a fixture landmark buffer. */
export function writeLandmarks(buffer: Buffer, landmarks: Float32Array): void {
  buffer.write(asWriteData(landmarks));
}
 
export function createVisualPipeline(gpu: Gpu, options: VisualPipelineOptions): VisualPipeline {
  const label = options.label ?? 'air-painting';
  const tuning = options.tuning ?? BRUSH_TUNING;
  const fog = options.fog ?? FOG_TUNING;
  let sourceWidth = options.sourceWidth;
  let sourceHeight = options.sourceHeight;
  if (!(sourceWidth > 0) || !(sourceHeight > 0)) {
    throw new Error(`Frame size must be positive, received ${sourceWidth}x${sourceHeight}.`);
  }
 
  const mask = gpu.device.createBuffer({
    size: MASK_BYTES,
    usage: ['storage', 'copy_dst'],
    label: `${label}-mask`,
  });
  // WebGPU zero-initializes buffers, so the idle state is "nothing painted".
  const brushes = gpu.device.createBuffer({
    size: BRUSH_BUFFER_BYTES,
    usage: ['storage', 'copy_dst'],
    label: `${label}-brushes`,
  });
  const rois = gpu.device.createBuffer({
    size: ROI_BYTES,
    usage: ['storage', 'copy_dst'],
    label: `${label}-rois`,
  });
 
  // `copy_src` is required as well as `storage`: ONNX Runtime reads these as
  // tensor inputs, and the gate's GPU-input probe pinned that usage set.
  const detectorInput = gpu.device.createBuffer({
    size: DETECTOR_INPUT_BYTES,
    usage: ['storage', 'copy_src', 'copy_dst'],
    label: `${label}-detector-input`,
  });
  const landmarkInputs = Array.from({ length: MAX_HANDS }, (_unused, slot) =>
    gpu.device.createBuffer({
      size: LANDMARK_INPUT_BYTES,
      usage: ['storage', 'copy_src', 'copy_dst'],
      label: `${label}-landmark-input-${slot}`,
    }),
  );
 
  /**
   * Stand-in for a slot that did not run this frame.
   *
   * The bind group layout is static, so both landmark bindings must always
   * resolve to a real buffer even when only one hand is up. `hand.wgsl` is told
   * which slots actually ran and never reads the zeros, but WebGPU still has to
   * be handed something.
   */
  const idleLandmarks = gpu.device.createBuffer({
    size: LANDMARK_POINTS_BUFFER_BYTES,
    usage: ['storage', 'copy_dst'],
    label: `${label}-landmarks-idle`,
  });
 
  let frameTexture = createFrameTexture(gpu, label, sourceWidth, sourceHeight);
  // Clamped so the 9-tap kernel cannot wrap the frame's own edge into the frost,
  // and so a rotated crop at the frame border does not fold the far side in.
  const sampler = createSampler(gpu, {
    minFilter: 'linear',
    magFilter: 'linear',
    addressModeU: 'clamp-to-edge',
    addressModeV: 'clamp-to-edge',
  });
 
  let frostA = createFrostTarget(gpu, label, sourceWidth, sourceHeight, fog, 'a');
  let frostB = createFrostTarget(gpu, label, sourceWidth, sourceHeight, fog, 'b');
 
  const crop: Compute = createCompute(gpu, handCropWgsl, { label: `${label}-crop` });
  const hand: Compute = createCompute(gpu, handWgsl, { label: `${label}-hand` });
  const paint: Compute = createCompute(gpu, paintWgsl, { label: `${label}-paint` });
  // Two instances of one shader: the horizontal pass downsamples out of the
  // full-resolution camera texture, the vertical pass runs target-to-target.
  const frostH: Effect = createEffect(gpu, frostWgsl, { label: `${label}-frost-h` });
  const frostV: Effect = createEffect(gpu, frostWgsl, { label: `${label}-frost-v` });
  const composite: Effect = createEffect(gpu, compositeWgsl, { label: `${label}-composite` });
 
  const roiScratch = new Float32Array(ROI_STRIDE_FLOATS);
 
  const writeRoi = (slot: number, roi: HandRoi) => {
    if (!Number.isInteger(slot) || slot < 0 || slot >= ROI_SLOT_COUNT) {
      throw new Error(`ROI slot ${slot} is out of range (0..${ROI_SLOT_COUNT - 1}).`);
    }
    roiScratch[0] = roi.cx;
    roiScratch[1] = roi.cy;
    roiScratch[2] = roi.size;
    roiScratch[3] = roi.rotation;
    rois.write(asWriteData(roiScratch), slot * ROI_STRIDE_FLOATS * 4);
  };
 
  /** The detector always crops the whole frame, so its ROI only moves on resize. */
  const writeDetectorRoi = () => {
    writeRoi(ROI_DETECTOR_SLOT, detectorRoi(sourceWidth, sourceHeight));
  };
  writeDetectorRoi();
 
  const dispatchCrop = (roiIndex: number, outSize: number, out: Buffer) => {
    crop.set({
      crop: {
        source: [sourceWidth, sourceHeight],
        out_size: outSize,
        roi_index: roiIndex,
      },
      src: frameTexture,
      samp: sampler,
      rois,
      out_buf: out,
    });
    const groups = Math.ceil(outSize / CROP_WORKGROUP_SIZE);
    crop.dispatch(groups, groups);
  };
 
  const pipeline: VisualPipeline = {
    get sourceWidth() {
      return sourceWidth;
    },
    get sourceHeight() {
      return sourceHeight;
    },
    mask,
    brushes,
    rois,
    detectorInput,
    get frameTexture() {
      return frameTexture;
    },
 
    landmarkInput(slot) {
      const buffer = landmarkInputs[slot];
      if (!buffer) throw new Error(`Landmark slot ${slot} is out of range.`);
      return buffer;
    },
 
    writeRoi,
 
    cropDetectorInput() {
      dispatchCrop(ROI_DETECTOR_SLOT, DETECTOR_SIZE, detectorInput);
    },
 
    cropLandmarkInput(slot) {
      const buffer = landmarkInputs[slot];
      if (!buffer) throw new Error(`Landmark slot ${slot} is out of range.`);
      dispatchCrop(slot, LANDMARK_SIZE, buffer);
    },
 
    consumeHandLandmarks(results, dtSeconds, consumeOptions = {}) {
      const presence = [0, 0];
      const ran = [0, 0];
      const buffers: (Buffer | undefined)[] = [undefined, undefined];
      for (const result of results) {
        const slot = results.indexOf(result);
        if (slot < 0 || slot >= MAX_HANDS) continue;
        if (!result.landmarks) continue;
        buffers[slot] = result.landmarks;
        presence[slot] = Number.isFinite(result.presence) ? result.presence : 0;
        ran[slot] = 1;
      }
 
      hand.set({
        uniforms: {
          source: [sourceWidth, sourceHeight],
          presence,
          ran,
          dt: dtSeconds,
          enter_confidence: tuning.enterConfidence,
          stay_confidence: tuning.stayConfidence,
          ema_tau: tuning.emaTauSeconds,
          max_jump: maxJumpDistance(tuning),
          reset: consumeOptions.reset ? 1 : 0,
          crop_size: LANDMARK_SIZE,
          loopback_scale: ROI_LOOPBACK_SCALE,
        },
        lm0: buffers[0] ?? idleLandmarks,
        lm1: buffers[1] ?? idleLandmarks,
        rois,
        brushes,
      });
      // One workgroup of BRUSH_COUNT invocations: one independent hand each.
      hand.dispatch(1);
 
      paint.set({
        uniforms: {
          mask_size: [MASK_WIDTH, MASK_HEIGHT],
          radius: tuning.radiusTexels,
          feather: tuning.featherTexels,
          // The re-fog rides the inference clock. Being multiplicative it
          // composes, so the fog curve is the same whether results arrive at
          // 15 Hz or 60 Hz.
          decay: fogDecay(dtSeconds, fog.refogTauSeconds),
          clear_epsilon: fog.clearEpsilon,
        },
        brushes,
        mask,
      });
      // Submitted after the hand dispatch on the same queue, so the ordering is
      // guaranteed without an explicit barrier.
      paint.dispatch(PAINT_WORKGROUPS);
    },
 
    renderVisualFrame(output, frameOptions = {}) {
      const dpr = Math.min(2, Math.max(1, frameOptions.dpr ?? 1));
      // Horizontal pass reads the full-resolution camera texture and lands in the
      // quarter-resolution target; vertical pass runs target-to-target.
      frostH.set({
        src: frameTexture,
        samp: sampler,
        frost: {
          texel_size: [1 / sourceWidth, 1 / sourceHeight],
          direction: [1, 0],
          sigma: fog.blurSigmaTexels,
        },
      });
      frostV.set({
        src: frostA.color,
        samp: sampler,
        frost: {
          texel_size: frostA.texelSize,
          direction: [0, 1],
          sigma: fog.blurSigmaTexels,
        },
      });
      composite.set({
        uniforms: {
          resolution: output.size,
          mask_size: [MASK_WIDTH, MASK_HEIGHT],
          source_size: [sourceWidth, sourceHeight],
          has_frame: frameOptions.hasFrame === false ? 0 : 1,
          show_cursor: frameOptions.showCursor === false ? 0 : 1,
          cursor_radius: tuning.radiusTexels + 6,
          frost_lift: fog.frostLift,
          frost_grain: fog.frostGrain,
          grain_cell: fog.grainCellLogicalPx * dpr,
        },
        frame_tex: frameTexture,
        frame_samp: sampler,
        mask,
        brushes,
        frost_tex: frostB.color,
      });
      // All three passes in one frame: the frost chain has to complete before the
      // compositor samples it, and same-frame ordering gives that for free.
      runFrame(gpu, (frame) => {
        frame.pass({ target: frostA }, (pass) => pass.draw(frostH));
        frame.pass({ target: frostB }, (pass) => pass.draw(frostV));
        frame.pass({ target: output }, (pass) => pass.draw(composite));
      });
    },
 
    writeFrame(rgba) {
      const expected = sourceWidth * sourceHeight * 4;
      if (rgba.byteLength !== expected) {
        throw new Error(
          `Frame upload expects ${expected} bytes for ${sourceWidth}x${sourceHeight}, received ${rgba.byteLength}.`,
        );
      }
      gpu.gpu.queue.writeTexture(
        { texture: frameTexture.gpu },
        asWriteData(rgba),
        { bytesPerRow: sourceWidth * 4, rowsPerImage: sourceHeight },
        { width: sourceWidth, height: sourceHeight },
      );
    },
 
    copyExternalFrame(source) {
      gpu.gpu.queue.copyExternalImageToTexture(
        { source },
        { texture: frameTexture.gpu },
        { width: sourceWidth, height: sourceHeight },
      );
    },
 
    clearMask() {
      mask.write(new Uint8Array(MASK_BYTES));
      // Continuity is dropped by the next consumeHandLandmarks({ reset: true });
      // the brush position itself is deliberately preserved so tracking survives.
    },
 
    resizeSource(nextWidth, nextHeight) {
      if (!(nextWidth > 0) || !(nextHeight > 0)) return;
      if (nextWidth === sourceWidth && nextHeight === sourceHeight) return;
      sourceWidth = nextWidth;
      sourceHeight = nextHeight;
      const previous = frameTexture;
      frameTexture = createFrameTexture(gpu, label, sourceWidth, sourceHeight);
      previous.destroy();
      // The frost chain is sized off the camera, so it has to follow.
      const previousA = frostA;
      const previousB = frostB;
      frostA = createFrostTarget(gpu, label, sourceWidth, sourceHeight, fog, 'a');
      frostB = createFrostTarget(gpu, label, sourceWidth, sourceHeight, fog, 'b');
      destroyTarget(previousA);
      destroyTarget(previousB);
      // The detector's letterbox is defined by the frame, so it moves with it.
      // Hand ROIs are in source pixels and are left alone: the host reacquires
      // them from the detector rather than trying to rescale a stale crop.
      writeDetectorRoi();
      // The mask keeps its strokes: it lives in normalized brush space, so a
      // camera resolution change does not invalidate what the user wiped.
    },
 
    dispose() {
      destroyTarget(frostB);
      destroyTarget(frostA);
      frameTexture.destroy();
      idleLandmarks.dispose();
      for (const buffer of landmarkInputs) buffer.dispose();
      detectorInput.dispose();
      rois.dispose();
      brushes.dispose();
      mask.dispose();
    },
  };
 
  return pipeline;
}
 
/**
 * One stage of the frost chain, at `1 / blurDownsample` of the camera resolution.
 *
 * Rounded up and floored at 1 so a very small or very odd camera resolution
 * cannot produce a zero-sized target.
 */
function createFrostTarget(
  gpu: Gpu,
  label: string,
  sourceWidth: number,
  sourceHeight: number,
  fog: FogTuning,
  suffix: string,
): Target {
  const divisor = Math.max(1, Math.floor(fog.blurDownsample));
  const width = Math.max(1, Math.ceil(sourceWidth / divisor));
  const height = Math.max(1, Math.ceil(sourceHeight / divisor));
  return createTarget(gpu, {
    size: [width, height],
    format: 'rgba8unorm',
    label: `${label}-frost-${suffix}`,
  });
}
 
/** `Target` does not declare `destroy()` on the public interface; it has one. */
function destroyTarget(target: Target | undefined): void {
  (target as { destroy?: () => void } | undefined)?.destroy?.();
}
 
function createFrameTexture(
  gpu: Gpu,
  label: string,
  sourceWidth: number,
  sourceHeight: number,
): Texture {
  return gpu.device.createTexture({
    size: [sourceWidth, sourceHeight],
    format: 'rgba8unorm',
    usage: ['texture_binding', 'copy_dst', 'render_attachment'],
    label: `${label}-frame`,
  });
}