Render WebGPU effects into published video
This guide shows how to render each frame of the published video yourself with WebGPU. Whatever you draw is what other peers receive.
The rendering code is plain WebGPU on both platforms. The integration differs:
- On the web, render into a
<canvas>, capture it withcaptureStream, and publish the stream withuseCustomSource. - In React Native, use the
/webgpuentry point of@fishjam-cloud/react-native-vision-camera-source. For every camera frame, your worklet receives the camera as a GPU texture and an output texture to draw into. TheuseVisionCameraWebGpuSourcehook handles publishing, GPU synchronization with the video encoder, timestamps and frame lifetimes.
- React (Web)
- React Native (Mobile)
Publish a canvas
If you already render with WebGPU (or Three.js, WebGL, or another canvas-based library), this is all the Fishjam-specific code you need:
const {setStream } =useCustomSource ("my-canvas");setStream (canvas .captureStream (30));
setStream comes from the same useCustomSource API as any custom source, and other peers receive the feed among their customVideoTracks.
The rest of this guide builds a worked example that renders the camera in grayscale. Any render pass works the same way: replace the shaders and keep the rest.
Browser support
WebGPU is available in modern Chromium browsers, Safari 26+, and Firefox 141+. It requires a secure context (HTTPS or localhost). Always feature-detect with navigator.gpu and fall back to a WebGL/canvas path where it matters.
Example: the camera in grayscale
The example has two parts: the shaders, and a component that sets up WebGPU, renders every frame, and publishes the canvas.
1. Write the shaders
There are two shader stages:
- The vertex stage draws a full-screen triangle, so the fragment stage covers the whole canvas.
uvspans the visible area. - The fragment stage samples the camera at
uvand weighs the color channels into gray.
The camera arrives as a texture_external, which TypeGPU cannot declare itself. The camera bindings are therefore written in raw WGSL, and sampling is wrapped in a WGSL-bodied tgpu.fn that the fragment shader can call. (On React Native, createCameraShaderBindings provides this recipe ready-made.)
importtgpu from "typegpu"; import * asd from "typegpu/data"; import {dot } from "typegpu/std"; // TypeGPU cannot emit `texture_external` bindings itself, so declare them in // WGSL and sample through a WGSL-bodied `tgpu.fn` that TGSL shaders can call. constbindingDeclarations = /* wgsl */ ` @group(0) @binding(0) var cameraTexture: texture_external; @group(0) @binding(1) var cameraSampler: sampler; `; constsampleCamera =tgpu .fn ( [d .vec2f ],d .vec4f , )(/* wgsl */ `(uv: vec2f) -> vec4f { return textureSampleBaseClampToEdge(cameraTexture, cameraSampler, uv); }`); // Full-screen triangle; uv spans the visible area. constvertexMain =tgpu .vertexFn ({in : {vertexIndex :d .builtin .vertexIndex },out : {position :d .builtin .position ,uv :d .location (0,d .vec2f ) }, })((input ) => { constpositions = [d .vec2f (-1, -1),d .vec2f (3, -1),d .vec2f (-1, 3)]; constp =positions [input .vertexIndex ]; return {position :d .vec4f (p .x ,p .y , 0, 1),uv :d .vec2f ((p .x + 1) * 0.5, 1 - (p .y + 1) * 0.5), }; }); constfragmentMain =tgpu .fragmentFn ({in : {uv :d .location (0,d .vec2f ) },out :d .vec4f , })((input ) => { constcolor =sampleCamera (input .uv ); constgray =dot (color .xyz ,d .vec3f (0.299, 0.587, 0.114)); returnd .vec4f (gray ,gray ,gray , 1); });
The shaders are written in TypeGPU (TGSL): typed TypeScript functions compiled to WGSL by the unplugin-typegpu plugin in your bundler (for Vite: unplugin-typegpu/vite). TypeGPU is not required; plain WGSL strings work the same.
2. Render and publish
The component has five stages, marked in the code: get a GPU device, open the camera, build the pipeline, draw every frame, publish the canvas.
Note stage 4: camera external textures expire with the frame, so the loop imports the current camera frame and rebuilds the bind group on every iteration.
importReact , {useEffect ,useRef } from "react"; import {useCustomSource } from "@fishjam-cloud/react-client"; export functionGrayscaleCameraPublisher () { constcanvasRef =useRef <HTMLCanvasElement >(null); const {setStream } =useCustomSource ("my-camera");useEffect (() => { constcanvas =canvasRef .current ; if (!canvas || !navigator .gpu ) return; // no WebGPU on this browser letdisposed = false; letframeHandle = 0; letcamera :MediaStream | undefined; construn = async () => { // 1. Get a GPU device. constadapter = awaitnavigator .gpu .requestAdapter (); if (!adapter ) return; constdevice = awaitadapter .requestDevice (); // 2. Open the camera and play it into a hidden <video>.camera = awaitnavigator .mediaDevices .getUserMedia ({video : true }); constvideo =document .createElement ("video");video .srcObject =camera ;video .muted = true; awaitvideo .play (); if (disposed ) return; // 3. Configure the canvas and build the render pipeline. constcontext =canvas .getContext ("webgpu") asGPUCanvasContext ; constformat =navigator .gpu .getPreferredCanvasFormat ();context .configure ({device ,format ,alphaMode : "opaque" }); constbindGroupLayout =device .createBindGroupLayout ({entries : [ {binding : 0,visibility :GPUShaderStage .FRAGMENT ,externalTexture : {}, }, {binding : 1,visibility :GPUShaderStage .FRAGMENT ,sampler : {} }, ], }); constsampler =device .createSampler (); constmodule =device .createShaderModule ({code :bindingDeclarations +tgpu .resolve ({externals : {vertexMain ,fragmentMain } }), }); constpipeline =device .createRenderPipeline ({layout :device .createPipelineLayout ({bindGroupLayouts : [bindGroupLayout ], }),vertex : {module ,entryPoint : "vertexMain" },fragment : {module ,entryPoint : "fragmentMain",targets : [{format }] }, }); // 4. Draw every frame. constrenderFrame = () => { if (disposed ) return; // External textures expire with the frame; import and bind every frame. constbindGroup =device .createBindGroup ({layout :bindGroupLayout ,entries : [ {binding : 0,resource :device .importExternalTexture ({source :video }), }, {binding : 1,resource :sampler }, ], }); constencoder =device .createCommandEncoder (); constpass =encoder .beginRenderPass ({colorAttachments : [ {view :context .getCurrentTexture ().createView (),loadOp : "clear",storeOp : "store", }, ], });pass .setPipeline (pipeline );pass .setBindGroup (0,bindGroup );pass .draw (3);pass .end ();device .queue .submit ([encoder .finish ()]);frameHandle =requestAnimationFrame (renderFrame ); };frameHandle =requestAnimationFrame (renderFrame ); // 5. Publish what the loop draws.setStream (canvas .captureStream (30)); }; voidrun (); return () => {disposed = true;cancelAnimationFrame (frameHandle );setStream (null);camera ?.getTracks ().forEach ((track ) =>track .stop ()); }; }, [setStream ]); return <canvas ref ={canvasRef }width ={1280}height ={720} />; // self-view }
The same canvas serves as the self-view, so peers see exactly what you see.
Unlike React Native, the browser handles encoder synchronization, timestamps and frame lifetimes, so there are no surface pools or fences to manage. The camera also arrives as decoded RGB; no YUV handling is needed.
Going further
Sampling without external textures
Pipelines written against plain 2D textures can copy the video in with queue.copyExternalImageToTexture instead of importExternalTexture. This costs one copy per frame, but avoids texture_external bindings in your shaders.
Frame pacing and background tabs
captureStream(30) samples the canvas at up to 30 fps. For exact pacing, use captureStream(0) and call track.requestFrame() after each render. To render only when the camera produces a frame, drive the loop with video.requestVideoFrameCallback instead of requestAnimationFrame.
requestAnimationFrame stops when the page is hidden, which freezes the published video for other peers. If streaming must continue in a background tab, drive rendering from a worker with an OffscreenCanvas transferred from the captured canvas.
Every camera frame reaches your onFrame worklet. Calling render(...) gives you the live camera as a GPU texture and an output texture to draw into. Whatever you draw is what peers receive.
Prerequisites
On top of the Vision Camera setup:
react-native-webgpu≥ 0.5.15unplugin-typegpuin your app's Babel config (the TGSL shaders need its build-time transform)- iOS 17+: the camera-import path relies on Metal external-texture features not guaranteed on earlier versions. The base integration has no such requirement, so you can keep your
ios.deploymentTargetunchanged and gate WebGPU usage at runtime.
- npm
- Yarn
- pnpm
- Bun
npm install react-native-webgpu typegpu unplugin-typegpu
yarn add react-native-webgpu typegpu unplugin-typegpu
pnpm add react-native-webgpu typegpu unplugin-typegpu
bun add react-native-webgpu typegpu unplugin-typegpu
module.exports = { presets: ["babel-preset-expo"], plugins: ["unplugin-typegpu/babel", "react-native-worklets/plugin"], };
Keep react-native-worklets/plugin as the last plugin.
Get a camera-capable GPU device
useCameraWebGpuDevice returns an app-wide shared GPUDevice, requested with the features the camera-import path needs:
const {device ,error } =useCameraWebGpuDevice ();
To use your own device instead, pass it as the device option of useVisionCameraWebGpuSource. It is validated against getRequiredWebGpuCameraFeatures(); a device missing any of the features surfaces a descriptive error instead of failing per frame.
Publish the camera through your own shaders
The WebGPU effects tutorial builds this pipeline step by step in a working app, from a passthrough render pass to a watermark overlay and a color effect.
The example below publishes the camera in grayscale. To apply your own effect, replace the fragment stage. Your component only does the drawing; the hook handles publishing, GPU synchronization with the video encoder, timestamps and frame lifetimes.
The shaders are written in TypeGPU (TGSL): typed TypeScript functions compiled to WGSL by unplugin-typegpu. TypeGPU is not required; you can hand-write WGSL and prepend the bindings' bindingDeclarations yourself.
importReact , {useCallback ,useMemo } from "react"; importtgpu from "typegpu"; import * asd from "typegpu/data"; import {dot } from "typegpu/std"; import {useCamera asuseVisionCamera ,useCameraPermission , typeFrame , } from "react-native-vision-camera"; import {RTCView } from "@fishjam-cloud/react-native-client"; import {useVisionCameraWebGpuSource ,useCameraWebGpuDevice ,createCameraShaderBindings ,getOutputSurfaceFormat , typeWebGpuFrameRenderFunction , } from "@fishjam-cloud/react-native-vision-camera-source/webgpu"; // Full-screen triangle; uv spans the visible area. constvertexMain =tgpu .vertexFn ({in : {vertexIndex :d .builtin .vertexIndex },out : {position :d .builtin .position ,uv :d .location (0,d .vec2f ) }, })((input ) => { constpositions = [d .vec2f (-1, -1),d .vec2f (3, -1),d .vec2f (-1, 3)]; constp =positions [input .vertexIndex ]; return {position :d .vec4f (p .x ,p .y , 0, 1),uv :d .vec2f ((p .x + 1) * 0.5, 1 - (p .y + 1) * 0.5), }; }); export functionGrayscaleCameraPublisher () { const {hasPermission } =useCameraPermission (); const {device } =useCameraWebGpuDevice (); consteffect =useMemo (() => { if (device == null) return null; constcameraBindings =createCameraShaderBindings (device ); constfragmentMain =tgpu .fragmentFn ({in : {uv :d .location (0,d .vec2f ) },out :d .vec4f , })((input ) => { constcolor =cameraBindings .sampleCamera (input .uv ); constgray =dot (color .xyz ,d .vec3f (0.299, 0.587, 0.114)); returnd .vec4f (gray ,gray ,gray , 1); }); // TypeGPU cannot emit the external-texture binding itself, so prepend // cameraBindings.bindingDeclarations to the resolved WGSL. constmodule =device .createShaderModule ({code :cameraBindings .bindingDeclarations +tgpu .resolve ({externals : {vertexMain ,fragmentMain } }), }); constpipeline =device .createRenderPipeline ({layout :device .createPipelineLayout ({bindGroupLayouts : [cameraBindings .bindGroupLayout ], }),vertex : {module ,entryPoint : "vertexMain" },fragment : {module ,entryPoint : "fragmentMain",targets : [{format :getOutputSurfaceFormat () }], }, }); return {cameraBindings ,pipeline }; }, [device ]); constonFrame =useCallback ( (frame :Frame ,render :WebGpuFrameRenderFunction ) => { "worklet"; if (effect == null) return; // drop frames until the pipeline is readyrender (({commandEncoder ,outputView ,cameraBindGroup }) => { constpass =commandEncoder .beginRenderPass ({colorAttachments : [ {view :outputView ,loadOp : "clear",storeOp : "store" }, ], });pass .setPipeline (effect .pipeline );pass .setBindGroup (0,cameraBindGroup !);pass .draw (3);pass .end (); }); }, [effect ], ); const {frameOutput ,stream } =useVisionCameraWebGpuSource ("my-camera", {width : 720,height : 1280,cameraShaderBindings :effect ?.cameraBindings ,onFrame , });useVisionCamera ({device : "front",isActive :hasPermission ,outputs : [frameOutput ], }); if (!stream ) return null; return ( <RTCView mediaStream ={stream }style ={{height : 200,width : 200 }}objectFit ="cover" /> ); }
How the example works:
createCameraShaderBindings(device)gives your shaderssampleCamera(uv), which returns upright RGB on both platforms and handles the YUV decode for you.- Everything created from the
device(bindings, shaders, pipeline) lives in oneuseMemokeyed by the device. TypeGPU cannot emit the camera'stexture_externalbinding, socameraBindings.bindingDeclarationsis prepended to the resolved WGSL, and the fragment targetsgetOutputSurfaceFormat()(rgba8unormon Android,bgra8unormon iOS). - Passing
cameraShaderBindingsto the hook makes the render context carry a ready-madecameraBindGroup, rebuilt each frame because the camera's external texture expires with every frame. - The worklet encodes one render pass; the hook submits it and synchronizes with the video encoder.
frameOutputplugs into VisionCamera'suseCamera;streamis the self-view.
Other peers receive the feed among their customVideoTracks.
Rules inside onFrame
The callback you pass to render(...) receives a WebGpuFrameRenderContext with the device, queue, commandEncoder, the live cameraTexture (a GPUExternalTexture), the output surface (outputTexture, outputView, outputWidth, outputHeight), and camera metadata (cameraWidth, cameraHeight, cameraIsMirrored).
- Always draw into the provided
outputView. CallingoutputTexture.createView()per frame leaks native wrappers on the frame runtime, becauseGPUTextureViewhas no release API. - Call
render(...)at most once per frame. Skipping it drops the frame; nothing is published for it. - Don't call
queue.submit()yourself. The hook submits your passes and synchronizes with the video encoder. - Finish GPU uploads before frames flow. Helpers like
queue.copyExternalImageToTexturesubmit work internally. Running them from the JS thread while the source is active races the hook's submissions and can crash the app, so upload textures before activating the camera. - Camera bind groups cannot be cached across frames, because the external texture changes every frame. The hook rebuilds
cameraBindGroupfor you; if you build your own, callcreateCameraBindGroupinside the worklet each frame. - Keep
onFrame's identity stable (useCallbackor module scope).
After render(...) returns you may keep using the frame (for example, to run inference on it), but only until your callback returns; the hook releases it afterwards.
Going further
- Overlays: a frame may contain more than one render pass. Encode additional passes into the same
outputView(withloadOp: "load") after the camera pass to draw watermarks or other content on top. - Cropping helpers: the grayscale example stretches the camera to the output.
computeAspectFillCropandcomputeSquareCropcompute the crop that fills your output aspect ratio (likeobjectFit: "cover");packFrameCropParamspacks a crop for your own uniform buffers. - Pipelines that cannot sample
texture_externalcan resolve the camera into an ownedrgba8unormtexture withcreateCameraTextureResolverandresolveCameraTexture, at the cost of one extra render pass per frame.
The full toolkit is documented in the Custom Video Source API reference.
Platform notes
sampleCamera(uv)returns upright RGB on both platforms. On Android it performs the BT.709 limited-range YUV→RGB decode in-shader; on iOS the camera already arrives as RGB.getOutputSurfaceFormat()returns the published surface format:rgba8unormon Android,bgra8unormon iOS. Use it for your fragment targets instead of hard-coding a format.- The context's
cameraIsMirroredtells you whether the camera feed is mirrored (typically the front camera).
Related guides
- WebGPU effects tutorial: build this pipeline step by step in a working app
- Custom sources on the web: other ways to obtain a
MediaStreamon the web - Vision Camera: publish the camera without custom rendering
- Low-level frame API: the pooled-surface layer the React Native hook builds on
- How custom sources work
- API reference: Vision Camera Source package, Custom Video Source package