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
// Fogged glass you wipe clean with your hands.
//
// The default state is the camera feed behind frosted glass. Your hands wipe it
// clear, and it slowly fogs back up. That is the whole metaphor, and it is why
// the idle state is the *stylized* one: the first wipe reveals reality, which
// reads far better than smearing an effect onto an already-normal feed. There is
// no effect selector.
//
// The frozen contract is the bindings, the coordinate spaces, and the polarity:
// `mask 1 = clean glass, mask 0 = fully frosted`. Everything else here -- the
// lift, the grain, the rim, the cursor -- is tuning.
//
// The single most important line in this file is the `mix()` at the bottom: the
// mask *lerps the blur amount* rather than selecting between two images. Because
// the mask decays continuously (see paint.wgsl), a wiped patch does not blink
// back to frosted, it silts up gradually, which is what makes the re-fog read as
// condensation rather than as a light being switched off.
//
// Palette: monochrome, flat, no gradients. Frost lifts toward white because that
// is what scattered light does in condensation, never toward a colour. The one
// accent (blue-10) is spent solely on the live cursor, so colour always means
// "your hand is here", never decoration.
 
struct Uniforms {
  /// Surface size in device pixels.
  resolution: vec2f,
  /// Paint mask dimensions in texels.
  mask_size: vec2f,
  /// Camera frame size in pixels; drives the aspect-fill crop.
  source_size: vec2f,
  /// 1.0 once a real frame has been uploaded to `frame_tex`.
  has_frame: f32,
  /// 1.0 to draw the hand cursors.
  show_cursor: f32,
  /// Cursor ring radius in mask texels.
  cursor_radius: f32,
  /// How far the frost lifts toward white.
  frost_lift: f32,
  /// Amplitude of the static frost grain.
  frost_grain: f32,
  /// Grain cell side in device pixels (logical cell * dpr).
  grain_cell: f32,
};
 
struct BrushState {
  prev: vec2f,
  current: vec2f,
  confidence: f32,
  tracking: f32,
  invalid: f32,
  has_prev: f32,
  stroke: f32,
  /// `@size(28)` pads the 40-byte struct to a 64-byte array stride.
  @size(28) strokes: f32,
};
 
/// One brush per hand, indexed by persistent track slot.
/// The array length is spelled as a literal on the binding below because
/// vgpu's auto-layout reflection requires one (VGPU-WGSL-REFLECT-ARRAY-LENGTH).
const BRUSH_COUNT: u32 = 2u;
 
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var frame_tex: texture_2d<f32>;
@group(0) @binding(2) var frame_samp: sampler;
@group(0) @binding(3) var<storage, read> mask: array<f32>;
@group(0) @binding(4) var<storage, read> brushes: array<BrushState, 2>;
/// The twice-blurred, quarter-resolution feed from frost.wgsl.
@group(0) @binding(5) var frost_tex: texture_2d<f32>;
 
/// blue-10 (#3b9eff): the one accent, spent only on the live cursor.
const ACCENT_LIVE = vec3f(0.231, 0.62, 1.0);
/// gray-1 (#0a0a0a): the docs' component background, shown before the first frame.
const NO_SIGNAL = vec3f(0.039, 0.039, 0.039);
 
/// Half-width of the condensation rim traced just outside a wipe, in texels.
const RIM_TEXELS = 3.0;
/// How much brighter the rim is. Wiping real glass piles moisture at the edge;
/// this is that, kept far enough down that it reads as physics, not as a stroke.
const RIM_GAIN = 0.14;
 
/// Surface uv -> `brush` space (mirrored, normalized camera frame), aspect-fill.
fn brush_uv(uv: vec2f) -> vec2f {
  let surface_aspect = uniforms.resolution.x / max(uniforms.resolution.y, 1.0);
  let source_aspect = uniforms.source_size.x / max(uniforms.source_size.y, 1.0);
  var frame_uv = uv;
  if (source_aspect > surface_aspect) {
    // Frame is wider than the canvas: crop the sides.
    frame_uv.x = (uv.x - 0.5) * (surface_aspect / source_aspect) + 0.5;
  } else {
    frame_uv.y = (uv.y - 0.5) * (source_aspect / surface_aspect) + 0.5;
  }
  return frame_uv;
}
 
/// One mask texel, zero outside the grid. Keeps the border from smearing.
fn mask_texel(t: vec2i) -> f32 {
  let size = vec2i(uniforms.mask_size);
  if (t.x < 0 || t.y < 0 || t.x >= size.x || t.y >= size.y) {
    return 0.0;
  }
  return mask[u32(t.y) * u32(uniforms.mask_size.x) + u32(t.x)];
}
 
/// Bilinear coverage in `brush` space.
///
/// The mask is 960x540 while the canvas is usually larger, so a nearest fetch
/// would stair-step every wipe edge into visible blocks.
fn mask_at(p: vec2f) -> f32 {
  if (any(p < vec2f(0.0)) || any(p > vec2f(1.0))) {
    return 0.0;
  }
  let texel = p * uniforms.mask_size - vec2f(0.5);
  let base = floor(texel);
  let f = texel - base;
  let b = vec2i(base);
  let c00 = mask_texel(b);
  let c10 = mask_texel(b + vec2i(1, 0));
  let c01 = mask_texel(b + vec2i(0, 1));
  let c11 = mask_texel(b + vec2i(1, 1));
  return mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y);
}
 
/// Coverage dilated by `RIM_TEXELS`; the difference against the undilated value
/// is the condensation rim. Deriving it from the mask keeps it a constant width
/// in texels rather than a resolution-dependent glow.
fn mask_dilated(p: vec2f) -> f32 {
  let step = RIM_TEXELS / uniforms.mask_size;
  var m = mask_at(p + vec2f(step.x, 0.0));
  m = max(m, mask_at(p - vec2f(step.x, 0.0)));
  m = max(m, mask_at(p + vec2f(0.0, step.y)));
  m = max(m, mask_at(p - vec2f(0.0, step.y)));
  return m;
}
 
/// Static value noise in fixed-size cells. Deliberately not animated: this is
/// frost sitting on glass, and moving grain would read as sensor noise.
///
/// Quantizing to a cell is what makes it look like droplets rather than
/// television static, and it is also what keeps the committed thumbnail
/// compressible -- per-pixel noise is, by definition, incompressible.
fn grain(p: vec2f) -> f32 {
  let cell = floor(p / max(uniforms.grain_cell, 1.0));
  return fract(sin(dot(cell, vec2f(12.9898, 78.233))) * 43758.5453) - 0.5;
}
 
@fragment
fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
  let uv = position.xy / uniforms.resolution;
  let p = brush_uv(uv);
 
  // `p` is already mirrored, so un-mirror to read the raw (un-mirrored) frame.
  let source_uv = vec2f(1.0 - p.x, p.y);
  var sharp = NO_SIGNAL;
  var blurred = NO_SIGNAL;
  if (uniforms.has_frame > 0.5) {
    sharp = textureSample(frame_tex, frame_samp, source_uv).rgb;
    blurred = textureSample(frost_tex, frame_samp, source_uv).rgb;
  }
 
  // Frost: the blurred feed, lifted toward white and dusted with fixed grain.
  // The lift is what separates "out of focus" from "behind glass".
  var frost = mix(blurred, vec3f(1.0), uniforms.frost_lift);
  frost = frost + vec3f(grain(position.xy) * uniforms.frost_grain);
  frost = clamp(frost, vec3f(0.0), vec3f(1.0));
 
  // The wipe. Coverage lerps the blur amount directly -- no threshold, no
  // smoothstep window -- so a decaying mask fogs back up continuously.
  let wipe = clamp(mask_at(p), 0.0, 1.0);
  var color = mix(frost, sharp, wipe);
 
  // Condensation piled at the edge of the wipe.
  let rim = clamp(mask_dilated(p) - wipe, 0.0, 1.0);
  color = color + vec3f(rim * RIM_GAIN);
 
  // One cursor per tracked hand. Distance is measured in mask texels so the ring
  // stays round regardless of canvas shape.
  if (uniforms.show_cursor > 0.5) {
    for (var i = 0u; i < BRUSH_COUNT; i = i + 1u) {
      let brush = brushes[i];
      if (brush.tracking < 0.5) {
        continue;
      }
      let d = distance(p * uniforms.mask_size, brush.current * uniforms.mask_size);
      let r = uniforms.cursor_radius;
      let ring = smoothstep(r + 2.2, r + 1.0, d) *
                 smoothstep(r - 2.2, r - 1.0, d);
      color = mix(color, ACCENT_LIVE, ring * clamp(brush.confidence, 0.0, 1.0));
    }
  }
 
  return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), 1.0);
}