Shader filter
Grain, vignettes, and other WGSL passes drawn through WebGPU, layered over ordinary content.
import {Scene, Video} from "odori";
import {ShaderFilter} from "../components/shader-filter/shader-filter";
<Video>
<Scene id="shader-filter" duration="5s">
<ShaderFilter />
</Scene>
</Video>Installation
pnpm odori add component shader-filternpx odori add component shader-filterbunx odori add component shader-filterEdit the source in videos/components/shader-filter/. Use odori diff to compare upstream changes and odori update to apply updates. Also installs shader-stage. Requires vgpu, so add it to the project: npm install vgpu
Copy these files into your project
videos/components/shader-filter/shader-filter.tsx
import type {CSSProperties, ReactNode} from "react";
import {ShaderStage, type ShaderUniformState} from "../shader-stage/shader-stage";
export type ShaderFilterLook = {
wgsl: string;
uniforms: (state: ShaderUniformState) => Record<string, unknown>;
};
export type ShaderFilterProps = ShaderFilterLook & {
children: ReactNode;
style?: CSSProperties;
};
/**
* A WGSL post-processing pass over ordinary content.
*
* The children render as themselves — DOM, text, another canvas — and the
* shader draws onto a transparent surface stacked above them, compositing
* with premultiplied alpha. Nothing is captured or re-rasterized: the filter
* is a layer, so the content under it stays crisp, selectable in Studio, and
* cheap to change. Grain, vignettes, and any other full-frame treatment that
* can be expressed as "what to draw on top" fit this shape; a filter that
* must read the pixels underneath does not, and belongs in `ShaderStage`
* drawing the whole image itself.
*
* Spread a preset: `<ShaderFilter {...filmGrain(0.12)}>…</ShaderFilter>`.
*/
export const ShaderFilter = ({wgsl, uniforms, children, style}: ShaderFilterProps) => (
<div style={{height: "100%", position: "relative", width: "100%", ...style}}>
{children}
<div style={{inset: 0, pointerEvents: "none", position: "absolute"}}>
<ShaderStage wgsl={wgsl} uniforms={uniforms} alpha />
</div>
</div>
);
/**
* Animated film grain. Signed noise, so it lightens as often as it darkens:
* positive grain draws faint white, negative draws faint black, and the
* intensity is the amplitude of both.
*/
const GRAIN_WGSL = /* wgsl */ `
struct Params { time: f32, intensity: f32 }
@group(0) @binding(0) var<uniform> params: Params;
fn hash(p: vec2f) -> f32 {
return fract(sin(dot(p, vec2f(127.1, 311.7))) * 43758.5453123);
}
@fragment fn main(@location(0) uv: vec2f) -> @location(0) vec4f {
let jitter = vec2f(fract(params.time * 61.7), fract(params.time * 41.3)) * 512.0;
let grain = (hash(uv * 1024.0 + jitter) - 0.5) * 2.0 * params.intensity;
let lighten = max(grain, 0.0);
return vec4f(vec3f(lighten), abs(grain));
}
`;
export const filmGrain = (intensity = 0.12): ShaderFilterLook => ({
wgsl: GRAIN_WGSL,
uniforms: ({time}) => ({time, intensity}),
});
/** A soft elliptical darkening toward the edges. */
const VIGNETTE_WGSL = /* wgsl */ `
struct Params { strength: f32, radius: f32 }
@group(0) @binding(0) var<uniform> params: Params;
@fragment fn main(@location(0) uv: vec2f) -> @location(0) vec4f {
let fade = smoothstep(params.radius, 1.4, distance(uv, vec2f(0.5)) * 2.0);
return vec4f(vec3f(0.0), fade * params.strength);
}
`;
export const vignette = (strength = 0.5, radius = 0.6): ShaderFilterLook => ({
wgsl: VIGNETTE_WGSL,
uniforms: () => ({strength, radius}),
});
videos/components/shader-filter/shader-filter.preview.tsx
import {Fill, useBrand, useDesignScale} from "odori";
import {defineComponentPreview} from "odori/preview";
import {ShaderFilter, filmGrain, vignette} from "./shader-filter";
/** Plain DOM for the filter to sit over: the point is that it stays DOM. */
const DemoCard = () => {
const brand = useBrand();
const scale = useDesignScale();
return (
<Fill style={{alignItems: "center", background: brand.colors.background, display: "flex", justifyContent: "center"}}>
<div
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 20 * scale,
color: brand.colors.foreground,
fontFamily: brand.typography.sans,
fontSize: 44 * scale,
fontWeight: 560,
padding: `${40 * scale}px ${64 * scale}px`,
}}
>
Ordinary UI under a shader
</div>
</Fill>
);
};
export default defineComponentPreview({
title: "Shader filter",
category: "Shaders",
description: "Grain, vignettes, and other WGSL passes drawn through WebGPU, layered over ordinary content.",
component: ShaderFilter,
canvas: {width: 1920, height: 1080, duration: "6s"},
examples: [
{name: "Film grain", props: {...filmGrain(0.12), children: <DemoCard />}},
{name: "Vignette", props: {...vignette(0.6), children: <DemoCard />}},
{name: "Heavy grain", props: {...filmGrain(0.3), children: <DemoCard />}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ShaderFilter} from "../components/shader-filter/shader-filter";
<Video>
<Scene id="shader-filter" duration="5s">
<ShaderFilter />
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Shaders
- Recommended duration
- 150 frames · 5s
- Minimum duration
- 24 frames · 0.8s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- motion is the shader's own; pass calmer uniforms
- Requires
- vgpu (npm)
- Content limits
- none
Related components
All componentsShader stageA WGSL fragment shader drawn through WebGPU on the frame clock.Absolute stageFull-frame surface with stable clipping and positioning.AccordionSections opening one at a time, the outgoing one closing as the next grows.Area chartFilled trend reveal for volume and cumulative change.Aurora veilFlowing curtains of colored light.B-roll windowSupporting footage framed beside the main narrative.