Labs Experiment

DOM, live on the GPU

Type in the card on the left. On every keystroke, SnapDOM re-captures it and a plugin uploads the result straight into a WebGL texture on the right — not a screenshot you take once, a texture that keeps up with you.

TL;DR

A defineExports plugin adds a webgl export that feeds a captured PNG straight into an existing WebGL texture, and a small dirty/working coalescing loop makes sure fast typing never queues up stale captures — only the latest edit ever wins the race to the GPU.

What you're looking at

Type in the card on the left and the panel on the right updates within a frame or two — that's a real SnapDOM capture of the live DOM element, decoded into an <img>, and uploaded as a WebGL TEXTURE_2D. The color-theme buttons change a CSS custom property read by the captured element itself, so switching themes is just another DOM mutation flowing through the same capture path. The shader dropdown is different — it never touches SnapDOM at all, it only changes how the fragment shader samples the texture that's already sitting on the GPU.

Try it

Edit the text, switch the accent color, or swap shaders while you type. The right pane is a live WebGL canvas with its own independent render loop; SnapDOM only ever refreshes what texture that loop is drawing.

Theme
Editable DOMcontenteditable
Live signal

Type here

Every edit becomes a WebGL texture through SnapDOM.

EDITABLE DOM_
WebGL textureidle
GPU texture
input updateMirror() snapdom() capture.toWebgl() GPU texture

How it works

  1. Typing fires an input listener

    Every keystroke in the contenteditable element dispatches a native input event. The only listener attached to it calls updateMirror().

  2. updateMirror() sets a dirty flag

    If a capture is already running, it just marks the work as dirty and returns — it never queues a second capture. If nothing is running, it enters a while (dirty) loop and captures the editable element with { plugins: [mirrorPlugin], cache: 'full', fast: true }.

  3. The plugin's defineExports(ctx) adds a webgl export

    SnapDOM wires that up automatically as capture.toWebgl(). Calling it hands the export function the capture's own context — including ctx.export.url, the rendered PNG data URL for this specific capture.

  4. The export loads that PNG into the existing texture

    The export function creates an Image, points it at ctx.export.url, and once it decodes, hands it to the WebGLMirror instance's update() method, which uploads it as a new TEXTURE_2D and returns the canvas.

  5. An independent render loop keeps drawing it

    The mirror's own requestAnimationFrame loop — started once, completely outside of SnapDOM — redraws that texture every frame, applying whichever shader is currently selected. SnapDOM never drives the render loop; it only refreshes what's on the texture.

  6. The dirty re-check collapses bursts of keystrokes

    Because dirty is checked again right after each capture finishes, typing a whole sentence quickly produces exactly one trailing capture instead of one per keystroke — the loop always converges on the latest DOM state.

Why this needed a plugin

SnapDOM's core exports — toPng, toCanvas, and the rest — hand you a finished image or canvas and stop there. They have no concept of "also push this into an existing WebGL texture and keep it around for a render loop," because that's not a rendering concern, it's a destination the caller invented.

defineExports is the seam SnapDOM exposes for inventing that new output shape: a plugin can return named async functions that receive the capture's context and get wired up as capture.to<Name>() automatically. The beforeSnap/afterRender hooks are used here too, but only to time each round trip for the on-screen readout — they're informational and have nothing to do with the texture update itself.

Copy-paste example

The plugin factory — it doesn't know or care what a "capture" is beyond ctx.export.url, so it drops into any project that already has a WebGL renderer with an update(url) method:

function webglMirror (renderer) {
  let started = 0
  let elapsed = 0

  return {
    name: 'webgl-live-mirror',
    beforeSnap () { started = performance.now() },
    afterRender () { elapsed = performance.now() - started },
    defineExports () {
      return {
        webgl: async ctx => {
          const canvas = await renderer.update(ctx.export.url)
          statsEl.textContent = `${elapsed.toFixed(0)} ms · GPU updated`
          return canvas
        }
      }
    }
  }
}

And the usage — a dirty/working loop guarantees a burst of edits collapses into one trailing capture instead of one per keystroke:

const mirrorPlugin = webglMirror(renderer)
let dirty = false
let working = false

async function updateMirror () {
  dirty = true
  if (working) return
  working = true
  while (dirty) {
    dirty = false
    const capture = await snapdom(editable, { plugins: [mirrorPlugin], cache: 'full', fast: true })
    await capture.toWebgl()
  }
  working = false
}

editable.addEventListener('input', updateMirror)

Limits and things to watch

Every keystroke can trigger a full SnapDOM capture — SVG serialize plus rasterize — so this pattern is best for elements that are cheap to capture: small, no heavy images, no font embedding. For anything bigger, debounce the input listener before calling updateMirror() rather than firing on every character. cache: 'full' and fast: true are doing real work here too: they keep repeated captures of the same element cheap by skipping re-computation that doesn't change between keystrokes — that's not the default cache policy, and it's a deliberate trade-off for this exact "recapture the same node constantly" use case.

Build your own plugin

Every hook this demo uses — beforeSnap, afterRender, plus defineExports for adding new export formats — is documented in the plugin spec.

Explore pluginsRead PLUGIN_SPEC.md