Effects

Wrap React content in EffectSurface to apply lens distortion, color separation, or other shader effects:

import {EffectSurface, barrelDistortion, rgbSplit, scanlines} from "odori/effects";

<EffectSurface
  effects={[
    barrelDistortion({amount: 0.32}),
    rgbSplit({amount: 5}),
    scanlines({opacity: 0.08}),
  ]}
>
  <TitleArtwork />
</EffectSurface>

The browser captures the children once per frame and applies each effect in order. Combine effects in one list to avoid repeated captures.

To layer a surface over other content, position it: a surface's canvas is an ordinary block element, so two of them in a scene stack in flow like anything else. Wrap each layer in position: absolute; inset: 0 and they share the frame. A surface used as an overlay may be fully transparent on frames before its content enters, and that is fine: odori test fails a canvas only when it paints nothing on any sampled frame.

Built-in effects

Available effects are barrelDistortion, magnify, rgbSplit, pixelate, scanlines, and filmGrain. Each exposes named settings.

Write a shader effect

Use defineShaderEffect to supply a fragment shader and its uniforms. Odori manages the graphics context and input texture:

const vignette = defineShaderEffect({
  name: "vignette",
  uniforms: {amount: numberUniform(0.4)},
  fragmentShader: `
uniform float amount;
void main() {
  vec2 uv = gl_FragCoord.xy / resolution;
  float edge = 1.0 - amount * length(uv - 0.5);
  odoriColour = tex(uv) * edge;
}`,
});

tex(uv) reads the captured picture, and resolution, frame, fps, seconds and progress are already in scope. The helper is tex rather than sample because sample is a reserved word in GLSL ES 3.00. A shader that fails to compile puts the compiler's message and the offending line on the stage rather than rendering black.

Use a custom renderer

Use HtmlInCanvas to keep a custom renderer, such as Three.js, alive across frames:

<HtmlInCanvas
  onInit={({canvas, width, height}) => createRenderer(canvas, width, height)}
  onPaint={({renderer, source, frame, fps, progress}) => renderer.draw(source, frame)}
  onDispose={(renderer) => renderer.dispose()}
>
  <Overlay />
</HtmlInCanvas>

onInit runs once and its return value comes back to every onPaint. Two rules make a Three.js scene safe here: drive everything from the frame you are given, and never call setAnimationLoop. A renderer that animates itself is reading a wall clock, and two workers rendering neighbouring chunks will disagree about what frame 200 looks like.

Run odori test to check whether repeated renders of sampled frames match.

Use seeded noise

Use the shader prelude's hash(vec2) helper. Seed it from frame to produce consistent noise when seeking or rendering in parallel.

Avoid sine-based hashes such as fract(sin(dot(p, k)) * 43758.5453). Their output varies across graphics backends. defineShaderEffect rejects this pattern.

Determinism

Everything in a surface derives from the frame. No requestAnimationFrame, no Date.now(), no unseeded randomness: filmGrain seeds from the frame index so the same frame has the same grain every time it is rendered.

Capture waits for fonts to load.

Browser requirements

EffectSurface captures content through SVG foreignObject, with computed styles copied onto the subtree. Preview and export use this capture path.

Effects require WebGL2. Exports use software rendering by default. Pass --fast to use the machine's graphics processor (GPU), which can improve performance for expensive shaders.

Software and GPU rendering can produce slightly different pixels. The frame cache separates their output by backend.

WebGPU

A video that draws with WebGPU declares it:

export const metadata = defineVideoMetadata({
  id: "shader-demo",
  duration: "240f",
  webgpu: true,
});

webgpu: true selects the full render browser, downloaded on first use. Exports use a software WebGPU adapter by default; --fast selects the machine's adapter. Studio requires a browser with WebGPU support.

Install shader-stage and shader-filter to use WebGPU Shading Language (WGSL) shaders:

odori add component shader-stage shader-filter
npm install vgpu
<ShaderStage
  wgsl={AURORA}
  uniforms={({time, width, height}) => ({time, aspect: width / height})}
/>

Capture waits for each GPU frame to finish. Use ShaderFilter to layer a transparent effect over React content:

<ShaderFilter {...filmGrain(0.12)}>
  <ProductPanel />
</ShaderFilter>

filmGrain and vignette ship as presets; a custom look is a WGSL string returning premultiplied alpha. odori test reads WebGPU canvases back the same way it reads 2d and WebGL ones, so the blank check and the same-frame-twice parity check both hold.

On this page