Render WebGPU effects into the camera
This guide shows how to render each frame of your published camera yourself with WebGPU. Whatever you draw is what other peers receive.
The rendering code is plain WebGPU on both platforms. The integration differs:
- In React Native, return a WebGPU-rendered track from a camera track middleware.
createCameraFrameProcessorSessionfrom@fishjam-cloud/video-effectscalls your worklet for every frame of the Fishjam camera, with the camera as a GPU texture and an output texture to draw into. It handles the output track, GPU synchronization with the video encoder, timestamps and frame lifetimes. Peers receive the result as your regular camera track. - On the web, render into a
<canvas>, capture it withcaptureStream, and publish the stream as a custom source.
If you only need background blur or a background image, use the ready-made background effects instead of writing shaders.
- React Native (Mobile)
- React (Web)
Prerequisites
- The packages (
@fishjam-cloud/video-effects0.1.5 or newer,react-native-webgpu0.10.1 or newer) and the Babel and minimum OS configuration from Background effects. The segmentation model and the Metro andexpo-assetsetup are not needed. typegpuin your app, if you write your shaders in TypeGPU as below
- npm
- Yarn
- pnpm
- Bun
npm install typegpu
yarn add typegpu
pnpm add typegpu
bun add typegpu
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. It is a camera track middleware: it builds the pipeline, starts a frame processor on the raw camera track, and returns the processor's track in its place. To apply your own effect, replace the fragment stage.
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.
importtgpu from "typegpu"; import * asd from "typegpu/data"; import {dot } from "typegpu/std"; import type {TrackMiddleware } from "@fishjam-cloud/react-native-client"; import {createCameraFrameProcessorSession ,createCameraShaderBindings ,getCameraWebGpuDevice ,getOutputSurfaceFormat , typeCameraFrameKernel , } from "@fishjam-cloud/video-effects/fishjam-react-native"; // 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), }; }); functioncreateGrayscalePipeline (device :GPUDevice ) { constcameraBindings =createCameraShaderBindings (device , {cameraPixelLayout : "rgb", }); 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 camera's external-texture binding itself, so // prepend cameraBindings.bindingDeclarations to the resolved WGSL. constmodule =device .createShaderModule ({code :cameraBindings .bindingDeclarations +tgpu .resolve ([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 }; } export constgrayscale :TrackMiddleware = async (track ) => { constdevice = awaitgetCameraWebGpuDevice (); const {cameraBindings ,pipeline } =createGrayscalePipeline (device ); constframeKernel :CameraFrameKernel = (frame ,render ) => { "worklet";render (({commandEncoder ,outputView ,cameraBindGroup }) => { constpass =commandEncoder .beginRenderPass ({colorAttachments : [ {view :outputView ,loadOp : "clear",storeOp : "store" }, ], });pass .setPipeline (pipeline );pass .setBindGroup (0,cameraBindGroup !);pass .draw (3);pass .end (); }); }; constsession = awaitcreateCameraFrameProcessorSession ({track ,device ,width : 720,height : 1280,cameraShaderBindings :cameraBindings ,frameKernel , }); return {track :session .track ,onClear : () => voidsession .dispose () }; };
Switch it on like any camera middleware:
import {useCamera } from "@fishjam-cloud/react-native-client"; const {setCameraTrackMiddleware } =useCamera (); awaitsetCameraTrackMiddleware (grayscale ); // null restores the plain camera
How the example works:
getCameraWebGpuDevice()returns the app-wideGPUDevice, requested with the features the camera import needs. Build your pipelines, textures and bind groups on this device.createCameraShaderBindings(device, { cameraPixelLayout: "rgb" })gives your shaderssampleCamera(uv), which returns upright RGB. The Fishjam camera arrives as RGB on both platforms, so the layout is always"rgb".- TypeGPU cannot emit the camera's
texture_externalbinding, socameraBindings.bindingDeclarationsis prepended to the resolved WGSL. The fragment stage targetsgetOutputSurfaceFormat()(rgba8unormon Android,bgra8unormon iOS). - Passing
cameraShaderBindingsto the session makes the render context carry a ready-madecameraBindGroup, rebuilt every frame because the camera's external texture expires with each frame. frameKernelis a worklet. It runs on the camera frame thread for every frame, encodes one render pass, and the session submits it. The pipeline it uses is built on the JS thread and copied into the worklet.- The middleware returns
session.track, andonCleardisposes the session when the middleware is replaced, cleared, or the camera stops.
Rules inside the frame kernel
The kernel receives frame, with timestampNanoseconds, isFrontCamera, width, height and rotationDegrees, and render. The function you pass to render(...) receives a WebGpuFrameRenderContext with the device, queue, commandEncoder, the live cameraTexture (a GPUExternalTexture, already upright), the output surface (outputTexture, outputView, outputWidth, outputHeight), and the upright camera size (cameraWidth, cameraHeight).
- 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 session submits your passes and synchronizes with the video encoder. - Finish GPU uploads before you create the session. Helpers like
queue.copyExternalImageToTexturesubmit work internally. Running them from the JS thread while frames flow races the session's submissions and can crash the app, so upload textures in the middleware beforecreateCameraFrameProcessorSession. - Camera bind groups cannot be cached across frames, because the external texture changes every frame. Use
cameraBindGroup, or callcreateCameraBindGroupinside the worklet each frame. - Capture only what a worklet can copy. The kernel's closure is copied to the camera frame runtime when the session starts: GPU objects, numbers, strings and other worklets work. React state and TypeGPU root objects don't, and later changes on the JS thread are not seen by the kernel.
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. - Aspect ratio: the grayscale example stretches the camera to 720×1280.
createCameraPassthroughPipelineandencodeCameraPassthroughdraw the camera cropped to fill the output, given a crop fromcomputeAspectFillCrop. To sample a cropped camera from your own shaders,createCameraTextureResolverandresolveCameraTexturerender it into an ownedrgba8unormtexture first, at the cost of one extra render pass per frame. - Reading frames without drawing: to run on-device inference on camera frames without changing the published video, see Process camera frames in a worklet.
- Output size:
widthandheightset the published resolution. The camera is imported at its own resolution and your passes decide how it maps onto the output.
The full toolkit is documented in the Video Effects API reference.
Platform notes
- The Fishjam camera arrives as RGB on both platforms. On Android the camera tap converts the camera texture before handing it over; on iOS the camera buffer is imported directly.
getOutputSurfaceFormat()returns the published surface format:rgba8unormon Android,bgra8unormon iOS. Use it for your fragment targets instead of hard-coding a format.- The published frames are not mirrored. The local self-view mirrors the front camera when you render it with
mirror={true}.
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 ([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.
The browser handles encoder synchronization, timestamps and frame lifetimes, so there is no frame processor session to create.
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.
Related guides
- WebGPU effects tutorial: build this pipeline step by step in a working app
- Background effects: ready-made background blur and background image
- Process camera frames in a worklet: read camera frames without changing the video
- Custom sources on the web: other ways to obtain a
MediaStreamon the web - WebGPU effects with VisionCamera: the same toolkit on a VisionCamera feed
- How camera effects work
- API reference:
createCameraFrameProcessorSession,createCameraShaderBindings, Video Effects package