SOP: Video Frame Processing with WebGPU + WebCodecs
Fresh 🌱Version: 1.0 | Chrome: 113+
Overview
WebGPU integrates natively with the WebCodecs API for GPU video processing. Unlike JavaScript/Canvas approaches, WebGPU processes video frames directly on the GPU — no CPU round-trips, minimal latency, full parallelism.
Flowchart
Step-by-Step
Step 1 — Access Camera Stream
javascript
const constraints = { video: { frameRate: 60 } };
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const video = document.createElement('video');
video.srcObject = stream;
await video.play();Step 2 — Initialize WebGPU for Video
javascript
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const format = navigator.gpu.getPreferredCanvasFormat();
const context = canvas.getContext('webgpu');
context.configure({ device, format });Step 3 — WGSL Shader for Video Processing
wgsl
// Grayscale filter: left half grayscale, right half color
@vertex
fn vertexMain(@builtin(vertex_index) i : u32) -> @builtin(position) vec4f {
const quadPos = array(
vec2f(-1, 1), vec2f(-1, -1),
vec2f( 1, 1), vec2f( 1, -1)
);
return vec4f(quadPos[i], 0, 1);
}
@group(0) @binding(0) var videoTexture: texture_external;
@fragment
fn fragmentMain(@builtin(position) position : vec4f) -> @location(0) vec4f {
let texCoord = vec2u(position.xy);
let color = textureLoad(videoTexture, texCoord);
// Right half: color | Left half: grayscale
if (position.x > f32(textureDimensions(videoTexture).x / 2)) {
return color;
}
let gray = dot(color.xyz, vec3f(0.299, 0.587, 0.114));
return vec4f(gray, gray, gray, 1.0);
}Step 4 — Create Render Pipeline
javascript
const shaderModule = device.createShaderModule({ code: wgslCode });
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: { module: shaderModule, entryPoint: 'vertexMain' },
fragment: {
module: shaderModule,
entryPoint: 'fragmentMain',
targets: [{ format }]
},
primitive: { topology: 'triangle-strip' }
});Step 5 — Per-Frame Processing Loop
javascript
function applyFilter(videoFrame) {
// Import video frame as external GPU texture
const texture = device.importExternalTexture({ source: videoFrame });
// Create bind group with this frame's texture
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: texture }]
});
const commandEncoder = device.createCommandEncoder();
const renderPass = commandEncoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: 'clear',
storeOp: 'store'
}]
});
renderPass.setPipeline(pipeline);
renderPass.setBindGroup(0, bindGroup);
renderPass.draw(4); // Full-screen quad
renderPass.end();
device.queue.submit([commandEncoder.finish()]);
// CRITICAL: always close the frame when done
videoFrame.close();
}
// Animation loop
(function render() {
const videoFrame = new VideoFrame(video);
applyFilter(videoFrame);
requestAnimationFrame(render);
})();Zero-Copy Check (Developer Feature)
javascript
const externalTexture = device.importExternalTexture({ source: video });
if (externalTexture.isZeroCopy) {
console.log('Optimal: Video frame accessed directly by GPU (no copy)');
} else {
console.log('Suboptimal: Intermediate copy created');
}Zero-Copy Requirement
Zero-copy requires enabling the WebGPU Developer Features flag at chrome://flags/#enable-webgpu-developer-features.
Performance Tips
- Always call
videoFrame.close()— failure to close causes memory leaks - Create bind group per frame — external textures are single-use
- Use
triangle-striptopology — more efficient for full-screen quads (4 vertices vs 6) - Match canvas size to video — prevents scaling overhead
Verification Checklist
- [ ] Video is playing before first VideoFrame creation
- [ ]
videoFrame.close()called after every frame - [ ] Canvas configured with
deviceand correctformat - [ ] External texture binding is
texture_externalin WGSL (nottexture_2d) - [ ] New bind group created every frame (external textures expire)