Draw WebGPU effects into your published camera Mobile
This tutorial is exclusively for Mobile (React Native) applications. On the web, publishing WebGPU output only requires capturing a canvas; see the web tab of the WebGPU effects how-to.
This tutorial continues from the React Native Quick Start. You'll take the video call app you built there and route its camera through your own WebGPU pipeline: first unchanged, then with a watermark image drawn on top, and finally recolored by a shader you write yourself.
What you'll build​
The Quick Start app, now publishing its camera through your own GPU pipeline: a camera feed with a "LIVE" watermark in the corner, recolored to grayscale by your own shader, switched on and off with a button.
What you'll learn​
- How to render the published camera yourself with a camera middleware and
createCameraFrameProcessorSession - How to load a PNG into a GPU texture and composite it over the camera as a second render pass
- How to write TypeGPU shaders that change the published camera pixels
Prerequisites​
- The finished app from the React Native Quick Start, on Expo SDK 57 (React Native 0.86) with the New Architecture enabled
@fishjam-cloud/react-native-client0.30.2 or newer,@fishjam-cloud/video-effects0.1.5 or newer andreact-native-webgpu0.10.1 or newer- A physical device (iOS 16.4+ or Android 8.0+), or the iOS Simulator with a virtual camera from SimCam
- A second device or simulator to join the room and see the result
Step 1: Install and configure​
Install the packages​
- npm
- Yarn
- pnpm
- Bun
npm install @fishjam-cloud/video-effects @fishjam-cloud/react-native-worklets react-native-worklets react-native-webgpu typegpu expo-asset expo-file-system expo-build-properties npm install --save-dev unplugin-typegpu @babel/plugin-transform-class-static-block
yarn add @fishjam-cloud/video-effects @fishjam-cloud/react-native-worklets react-native-worklets react-native-webgpu typegpu expo-asset expo-file-system expo-build-properties yarn add --dev unplugin-typegpu @babel/plugin-transform-class-static-block
pnpm add @fishjam-cloud/video-effects @fishjam-cloud/react-native-worklets react-native-worklets react-native-webgpu typegpu expo-asset expo-file-system expo-build-properties pnpm add --save-dev unplugin-typegpu @babel/plugin-transform-class-static-block
bun add @fishjam-cloud/video-effects @fishjam-cloud/react-native-worklets react-native-worklets react-native-webgpu typegpu expo-asset expo-file-system expo-build-properties bun add --dev unplugin-typegpu @babel/plugin-transform-class-static-block
@fishjam-cloud/video-effectshands you every camera frame as a GPU texture and publishes what you draw.@fishjam-cloud/react-native-workletsandreact-native-workletsrun your drawing code on every camera frame, off the JS thread.react-native-webgpuis the GPU API you draw with, andtypegpulets you write shaders in TypeScript.expo-assetandexpo-file-systemload the watermark image in Step 3.expo-build-propertiesraises the Android minimum SDK version.
Update the Babel config​
The shaders in this tutorial are written in TypeGPU (TGSL): typed TypeScript functions compiled to WGSL at build time by unplugin-typegpu. TypeGPU also needs class static blocks and import.meta, and your drawing code needs the worklets plugin. Replace your babel.config.js, keeping react-native-worklets/plugin last:
module.exports = function (api) { api.cache(true); return { presets: [["babel-preset-expo", { unstable_transformImportMeta: true }]], plugins: [ "@babel/plugin-transform-class-static-block", "unplugin-typegpu/babel", "react-native-worklets/plugin", ], }; };
Raise the minimum OS versions​
Expo SDK 57 needs iOS 16.4, and react-native-webgpu needs Android 8.0 (API 26), while Expo builds for API 24 by default. Set both in app.json: the iOS version in the Fishjam config plugin, the Android one with expo-build-properties:
{ "expo": { "plugins": [ [ "@fishjam-cloud/react-native-client", { "ios": { "iphoneDeploymentTarget": "16.4" } } ], [ "expo-build-properties", { "android": { "minSdkVersion": 26 } } ] ] } }
Rebuild the app​
- Expo
- Bare workflow
npx expo prebuild npx expo run:ios # or run:android
cd ios && pod install
Start Metro with a cleared cache afterwards (npx expo start --clear).
Step 2: Route the camera through WebGPU​
Fishjam lets you put a middleware between your camera and the room: a function that receives the camera track and returns the track to publish instead. Your middleware will return a track that you draw yourself. Three pieces make that work:
getCameraWebGpuDeviceprovides a sharedGPUDevice, requested with the features the camera import needs.createCameraFrameProcessorSessioncalls yourframeKernelworklet for every camera frame. Callingrender(...)inside it gives you a context with the command encoder, the live camera texture and the output texture. What you draw into the output texture is what peers receive.- For now, draw the camera unchanged with the ready-made passthrough pass:
createCameraPassthroughPipelinebuilds it once,encodeCameraPassthroughencodes it each frame, andcomputeAspectFillCropfills the output likeobjectFit: "cover".
Create cameraEffect.ts next to App.tsx:
import type {TrackMiddleware } from "@fishjam-cloud/react-native-client"; import {computeAspectFillCrop ,createCameraFrameProcessorSession ,createCameraPassthroughPipeline ,encodeCameraPassthrough ,getCameraWebGpuDevice , typeCameraFrameKernel , } from "@fishjam-cloud/video-effects/fishjam-react-native"; export constcameraEffect :TrackMiddleware = async (track ) => { constdevice = awaitgetCameraWebGpuDevice (); // A ready-made camera → output render pass. The Fishjam camera arrives as RGB. constpassthrough =createCameraPassthroughPipeline (device , {cameraPixelLayout : "rgb", }); constframeKernel :CameraFrameKernel = (frame ,render ) => { "worklet";render ((context ) => { // Crop the camera to fill the output, like `objectFit: "cover"`. constcrop =computeAspectFillCrop (context .cameraWidth ,context .cameraHeight ,context .outputWidth /context .outputHeight , );encodeCameraPassthrough (context .device ,passthrough ,context .cameraTexture ,context .outputView ,context .commandEncoder ,crop , ); }); }; constsession = awaitcreateCameraFrameProcessorSession ({track ,device ,width : 720,height : 1280,frameKernel , }); // Publish the session's track; dispose it when the middleware is removed. return {track :session .track ,onClear : () => voidsession .dispose () }; };
Then add a button to App.tsx that switches the middleware on and off, the same way as any camera middleware:
importReact from "react"; import {Button } from "react-native"; import {useCamera } from "@fishjam-cloud/react-native-client"; functionEffectButton () { const {currentCameraMiddleware ,setCameraTrackMiddleware } =useCamera (); constisEffectOn =currentCameraMiddleware ===cameraEffect ; consttoggleEffect = async () => { try { awaitsetCameraTrackMiddleware (isEffectOn ? null :cameraEffect ); } catch (error ) {console .warn ("Camera effect failed",error ); awaitsetCameraTrackMiddleware (null); // go back to the plain camera } }; return ( <Button title ={isEffectOn ? "Effect off" : "Effect on"}onPress ={toggleEffect } /> ); }
Render <EffectButton /> in VideoCall, right above your own video (<VideoPlayer stream={cameraStream} />).
Rebuild, join the room from two devices and tap Effect on. The feed looks the same as before, but every published pixel now flows through a pipeline you control. The next two steps build on it.
From here on, the rules inside the frame kernel apply: draw into the provided output view, call render(...) at most once per frame, and never call queue.submit() yourself.
Step 3: Draw a watermark on top​
Make a watermark image​
Any PNG with transparency works. Make one with red "LIVE" text on a transparent background, roughly 512×128, and save it as assets/watermark.png in your project. Keep the alpha straight (non-premultiplied), which is what image editors export by default. A colored watermark will make the effect in Step 4 easier to see.
Build the watermark pipeline​
The watermark needs its own pipeline: a texture holding the image, and a textured quad drawn over the bottom-right corner with alpha blending.
Note the following:
- Unlike the camera, which arrives as an external texture that expires every frame, the watermark is a plain
texture_2d<f32>. It's uploaded once withcopyExternalImageToTexture, and its bind group is built once and reused for every frame. - The quad's corner placement is computed in plain JavaScript and baked into the vertex shader as constants. The output size is fixed, so no uniform buffer is needed.
tgpu.resolvetakes the shader functions as an array. The.$name(...)calls pin the WGSL entry-point names thatcreateRenderPipelinelooks up. If a name doesn't match, the failure is silent: WebGPU reports it as an asynchronous validation error, not a thrown exception, and the pass simply draws nothing.
Add the pipeline to cameraEffect.ts:
importtgpu from "typegpu"; import * asd from "typegpu/data"; import {getOutputSurfaceFormat } from "@fishjam-cloud/video-effects/fishjam-react-native"; // A plain 2D texture never expires (unlike the camera's external texture), // so it is bound once and reused every frame. constwatermarkBindingDeclarations = /* wgsl */ ` @group(0) @binding(0) var watermarkTexture: texture_2d<f32>; @group(0) @binding(1) var watermarkSampler: sampler; `; constsampleWatermark =tgpu .fn ( [d .vec2f ],d .vec4f , )(/* wgsl */ `(uv: vec2f) -> vec4f { return textureSample(watermarkTexture, watermarkSampler, uv); }`); // A quad over the given clip-space rect; the corners are baked in as // constants when the shader is built. functionmakeWatermarkVertexMain (rect : {x0 : number;y0 : number;x1 : number;y1 : number; }) { const {x0 ,y0 ,x1 ,y1 } =rect ; returntgpu .vertexFn ({in : {vertexIndex :d .builtin .vertexIndex },out : {position :d .builtin .position ,uv :d .location (0,d .vec2f ) }, })((input ) => { constpositions = [d .vec2f (x0 ,y0 ),d .vec2f (x1 ,y0 ),d .vec2f (x0 ,y1 ),d .vec2f (x1 ,y1 ), ]; constuvs = [d .vec2f (0, 1),d .vec2f (1, 1),d .vec2f (0, 0),d .vec2f (1, 0)]; constp =positions [input .vertexIndex ]; return {position :d .vec4f (p .x ,p .y , 0, 1),uv :uvs [input .vertexIndex ], }; }) .$name ("vertexMain"); } constwatermarkFragmentMain =tgpu .fragmentFn ({in : {uv :d .location (0,d .vec2f ) },out :d .vec4f , })((input ) =>sampleWatermark (input .uv )) .$name ("fragmentMain"); functioncreateWatermarkPipeline (device :GPUDevice ,bitmap :ImageBitmap ,outputWidth : number,outputHeight : number, ) { // 1. Upload the image into a GPU texture (a one-time copy). consttexture =device .createTexture ({size : [bitmap .width ,bitmap .height ],format : "rgba8unorm",usage :GPUTextureUsage .TEXTURE_BINDING |GPUTextureUsage .COPY_DST |GPUTextureUsage .RENDER_ATTACHMENT , });device .queue .copyExternalImageToTexture ({source :bitmap }, {texture }, [bitmap .width ,bitmap .height , ]); // 2. Bottom-right placement: 24 px margin, 40% of the output width, // height from the image's aspect ratio, converted to clip space. constwidthPx =outputWidth * 0.4; constheightPx =widthPx * (bitmap .height /bitmap .width ); constmarginPx = 24; constx1 = 1 - (2 *marginPx ) /outputWidth ; constx0 =x1 - (2 *widthPx ) /outputWidth ; consty0 = -1 + (2 *marginPx ) /outputHeight ; consty1 =y0 + (2 *heightPx ) /outputHeight ; // 3. The pipeline: a textured quad with straight-alpha blending. constbindGroupLayout =device .createBindGroupLayout ({entries : [ {binding : 0,visibility :GPUShaderStage .FRAGMENT ,texture : {} }, {binding : 1,visibility :GPUShaderStage .FRAGMENT ,sampler : {} }, ], }); constmodule =device .createShaderModule ({code :watermarkBindingDeclarations +tgpu .resolve ([makeWatermarkVertexMain ({x0 ,y0 ,x1 ,y1 }),watermarkFragmentMain , ]), }); constpipeline =device .createRenderPipeline ({layout :device .createPipelineLayout ({bindGroupLayouts : [bindGroupLayout ], }),vertex : {module ,entryPoint : "vertexMain" },fragment : {module ,entryPoint : "fragmentMain",targets : [ {format :getOutputSurfaceFormat (),blend : {color : {srcFactor : "src-alpha",dstFactor : "one-minus-src-alpha",operation : "add", },alpha : {srcFactor : "one",dstFactor : "one-minus-src-alpha",operation : "add", }, }, }, ], },primitive : {topology : "triangle-strip" }, }); constbindGroup =device .createBindGroup ({layout :bindGroupLayout ,entries : [ {binding : 0,resource :texture .createView () }, {binding : 1,resource :device .createSampler ({magFilter : "linear",minFilter : "linear", }), }, ], }); return {pipeline ,bindGroup }; }
TypeGPU is not required; you can hand-write the same shaders in WGSL. See the WebGPU effects how-to for the trade-offs.
Load the image and encode the overlay​
Loading is asynchronous: read the bundled asset with expo-asset and expo-file-system, then decode it with createImageBitmap (provided globally by react-native-webgpu). Reading a local copy of the asset instead of fetching it by URL keeps working in Android release builds. There, expo-asset leaves a bundled image as a drawable resource name instead of a file, so the loader creates a second Asset without the image size, which downloadAsync copies to a local file. Add the loader to cameraEffect.ts:
import {Asset } from "expo-asset"; import {File } from "expo-file-system"; constwatermarkAsset =Asset .fromModule (require ("./assets/watermark.png")); async functionloadWatermarkBitmap ():Promise <ImageBitmap > { // Android release builds keep bundled images as drawable resources, which `File` can't // open. An asset described without the image size is copied to a local file instead. constasset = newAsset ({name :watermarkAsset .name ,type :watermarkAsset .type ,hash :watermarkAsset .hash ,uri :watermarkAsset .uri , }); awaitasset .downloadAsync (); if (!asset .localUri ) { throw newError ("The watermark image is not available."); } constbytes = await newFile (asset .localUri ).arrayBuffer (); returncreateImageBitmap (bytes ); }
Then update the middleware. It builds the watermark pipeline before it creates the session, and waits for the GPU to finish the upload. copyExternalImageToTexture submits GPU work itself, and once frames flow the session owns the device queue, so a concurrent upload from the JS thread can crash the app. Inside the kernel, after the passthrough and still inside the same render callback, encode a second pass on the same command encoder. loadOp: "load" keeps the camera pixels underneath, so the quad blends on top:
export constcameraEffect :TrackMiddleware = async (track ) => { constdevice = awaitgetCameraWebGpuDevice (); constpassthrough =createCameraPassthroughPipeline (device , {cameraPixelLayout : "rgb", }); // Upload the watermark before frames flow, and wait until the GPU is done. constbitmap = awaitloadWatermarkBitmap (); constwatermark =createWatermarkPipeline (device ,bitmap , 720, 1280); awaitdevice .queue .onSubmittedWorkDone (); constframeKernel :CameraFrameKernel = (frame ,render ) => { "worklet";render ((context ) => { constcrop =computeAspectFillCrop (context .cameraWidth ,context .cameraHeight ,context .outputWidth /context .outputHeight , );encodeCameraPassthrough (context .device ,passthrough ,context .cameraTexture ,context .outputView ,context .commandEncoder ,crop , ); // `loadOp: "load"` keeps the camera pass underneath. constpass =context .commandEncoder .beginRenderPass ({colorAttachments : [ {view :context .outputView ,loadOp : "load",storeOp : "store" }, ], });pass .setPipeline (watermark .pipeline );pass .setBindGroup (0,watermark .bindGroup );pass .draw (4);pass .end (); }); }; constsession = awaitcreateCameraFrameProcessorSession ({track ,device ,width : 720,height : 1280,frameKernel , }); return {track :session .track ,onClear : () => voidsession .dispose () }; };
The worklet captures the watermark's pipeline and bind group once, which is safe because a plain 2D texture never expires. A single render(...) call may contain any number of passes; only calling render more than once per frame is not allowed.
Rebuild, switch the effect on and check the receiving side: "LIVE" sits over the bottom-right corner of the camera.
Step 4: Change the colors with your own shader​
An overlay draws on top of the camera pixels. To change the pixels themselves, replace the passthrough with your own pipeline: a full-screen triangle whose fragment shader samples the camera and returns a new color. createCameraShaderBindings gives your shader sampleCamera(uv), which returns the upright camera color.
Add the effect next to the watermark code in cameraEffect.ts:
importtgpu from "typegpu"; import * asd from "typegpu/data"; import {dot } from "typegpu/std"; import {createCameraShaderBindings ,getOutputSurfaceFormat , } 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), }; }); functioncreateGrayscaleEffect (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 `texture_external` binding itself, // so prepend the declarations that come with the bindings. 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 }; }
In the middleware, build the effect and pass its cameraBindings to the session. In return, the render context carries a ready-made cameraBindGroup, rebuilt every frame because the camera's external texture expires with each frame. The kernel now encodes the grayscale pass instead of the passthrough, with the watermark pass unchanged on top. The passthrough and crop imports are no longer needed:
export constcameraEffect :TrackMiddleware = async (track ) => { constdevice = awaitgetCameraWebGpuDevice (); constgrayscale =createGrayscaleEffect (device ); constbitmap = awaitloadWatermarkBitmap (); constwatermark =createWatermarkPipeline (device ,bitmap , 720, 1280); awaitdevice .queue .onSubmittedWorkDone (); constframeKernel :CameraFrameKernel = (frame ,render ) => { "worklet";render ((context ) => { // 1. Your shader draws the recolored camera into the output. constpass =context .commandEncoder .beginRenderPass ({colorAttachments : [ {view :context .outputView ,loadOp : "clear",storeOp : "store" }, ], });pass .setPipeline (grayscale .pipeline );pass .setBindGroup (0,context .cameraBindGroup !);pass .draw (3);pass .end (); // 2. The watermark still goes on top, unchanged. constoverlay =context .commandEncoder .beginRenderPass ({colorAttachments : [ {view :context .outputView ,loadOp : "load",storeOp : "store" }, ], });overlay .setPipeline (watermark .pipeline );overlay .setBindGroup (0,watermark .bindGroup );overlay .draw (4);overlay .end (); }); }; constsession = awaitcreateCameraFrameProcessorSession ({track ,device ,width : 720,height : 1280, // Provides a per-frame `cameraBindGroup` in the render context.cameraShaderBindings :grayscale .cameraBindings ,frameKernel , }); return {track :session .track ,onClear : () => voidsession .dispose () }; };
Sampling the full frame stretches the camera when its aspect ratio differs from 720×1280. The aspect ratio helpers in the how-to cover aspect-correct sampling.
Rebuild one last time and switch the effect on: the receiving side shows a grayscale camera with the watermark in full color on top. The watermark pass runs after your shader, so its pixels are drawn as-is.
Complete example​
The final cameraEffect.ts, top to bottom. In App.tsx, the Quick Start app gets only EffectButton from Step 2, rendered above your own video:
importtgpu from "typegpu"; import * asd from "typegpu/data"; import {dot } from "typegpu/std"; import {Asset } from "expo-asset"; import {File } from "expo-file-system"; import type {TrackMiddleware } from "@fishjam-cloud/react-native-client"; import {createCameraFrameProcessorSession ,createCameraShaderBindings ,getCameraWebGpuDevice ,getOutputSurfaceFormat , typeCameraFrameKernel , } from "@fishjam-cloud/video-effects/fishjam-react-native"; // --- Watermark: a textured quad blended over the camera --- constwatermarkAsset =Asset .fromModule (require ("./assets/watermark.png")); async functionloadWatermarkBitmap ():Promise <ImageBitmap > { // Android release builds keep bundled images as drawable resources, which `File` can't // open. An asset described without the image size is copied to a local file instead. constasset = newAsset ({name :watermarkAsset .name ,type :watermarkAsset .type ,hash :watermarkAsset .hash ,uri :watermarkAsset .uri , }); awaitasset .downloadAsync (); if (!asset .localUri ) { throw newError ("The watermark image is not available."); } constbytes = await newFile (asset .localUri ).arrayBuffer (); returncreateImageBitmap (bytes ); } constwatermarkBindingDeclarations = /* wgsl */ ` @group(0) @binding(0) var watermarkTexture: texture_2d<f32>; @group(0) @binding(1) var watermarkSampler: sampler; `; constsampleWatermark =tgpu .fn ( [d .vec2f ],d .vec4f , )(/* wgsl */ `(uv: vec2f) -> vec4f { return textureSample(watermarkTexture, watermarkSampler, uv); }`); functionmakeWatermarkVertexMain (rect : {x0 : number;y0 : number;x1 : number;y1 : number; }) { const {x0 ,y0 ,x1 ,y1 } =rect ; returntgpu .vertexFn ({in : {vertexIndex :d .builtin .vertexIndex },out : {position :d .builtin .position ,uv :d .location (0,d .vec2f ) }, })((input ) => { constpositions = [d .vec2f (x0 ,y0 ),d .vec2f (x1 ,y0 ),d .vec2f (x0 ,y1 ),d .vec2f (x1 ,y1 ), ]; constuvs = [d .vec2f (0, 1),d .vec2f (1, 1),d .vec2f (0, 0),d .vec2f (1, 0)]; constp =positions [input .vertexIndex ]; return {position :d .vec4f (p .x ,p .y , 0, 1),uv :uvs [input .vertexIndex ], }; }) .$name ("vertexMain"); } constwatermarkFragmentMain =tgpu .fragmentFn ({in : {uv :d .location (0,d .vec2f ) },out :d .vec4f , })((input ) =>sampleWatermark (input .uv )) .$name ("fragmentMain"); functioncreateWatermarkPipeline (device :GPUDevice ,bitmap :ImageBitmap ,outputWidth : number,outputHeight : number, ) { consttexture =device .createTexture ({size : [bitmap .width ,bitmap .height ],format : "rgba8unorm",usage :GPUTextureUsage .TEXTURE_BINDING |GPUTextureUsage .COPY_DST |GPUTextureUsage .RENDER_ATTACHMENT , });device .queue .copyExternalImageToTexture ({source :bitmap }, {texture }, [bitmap .width ,bitmap .height , ]); constwidthPx =outputWidth * 0.4; constheightPx =widthPx * (bitmap .height /bitmap .width ); constmarginPx = 24; constx1 = 1 - (2 *marginPx ) /outputWidth ; constx0 =x1 - (2 *widthPx ) /outputWidth ; consty0 = -1 + (2 *marginPx ) /outputHeight ; consty1 =y0 + (2 *heightPx ) /outputHeight ; constbindGroupLayout =device .createBindGroupLayout ({entries : [ {binding : 0,visibility :GPUShaderStage .FRAGMENT ,texture : {} }, {binding : 1,visibility :GPUShaderStage .FRAGMENT ,sampler : {} }, ], }); constmodule =device .createShaderModule ({code :watermarkBindingDeclarations +tgpu .resolve ([makeWatermarkVertexMain ({x0 ,y0 ,x1 ,y1 }),watermarkFragmentMain , ]), }); constpipeline =device .createRenderPipeline ({layout :device .createPipelineLayout ({bindGroupLayouts : [bindGroupLayout ], }),vertex : {module ,entryPoint : "vertexMain" },fragment : {module ,entryPoint : "fragmentMain",targets : [ {format :getOutputSurfaceFormat (),blend : {color : {srcFactor : "src-alpha",dstFactor : "one-minus-src-alpha",operation : "add", },alpha : {srcFactor : "one",dstFactor : "one-minus-src-alpha",operation : "add", }, }, }, ], },primitive : {topology : "triangle-strip" }, }); constbindGroup =device .createBindGroup ({layout :bindGroupLayout ,entries : [ {binding : 0,resource :texture .createView () }, {binding : 1,resource :device .createSampler ({magFilter : "linear",minFilter : "linear", }), }, ], }); return {pipeline ,bindGroup }; } // --- Grayscale: your own shader instead of the passthrough --- 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), }; }); functioncreateGrayscaleEffect (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); }); 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 }; } // --- The camera middleware --- export constcameraEffect :TrackMiddleware = async (track ) => { constdevice = awaitgetCameraWebGpuDevice (); constgrayscale =createGrayscaleEffect (device ); constbitmap = awaitloadWatermarkBitmap (); constwatermark =createWatermarkPipeline (device ,bitmap , 720, 1280); awaitdevice .queue .onSubmittedWorkDone (); constframeKernel :CameraFrameKernel = (frame ,render ) => { "worklet";render ((context ) => { constpass =context .commandEncoder .beginRenderPass ({colorAttachments : [ {view :context .outputView ,loadOp : "clear",storeOp : "store" }, ], });pass .setPipeline (grayscale .pipeline );pass .setBindGroup (0,context .cameraBindGroup !);pass .draw (3);pass .end (); constoverlay =context .commandEncoder .beginRenderPass ({colorAttachments : [ {view :context .outputView ,loadOp : "load",storeOp : "store" }, ], });overlay .setPipeline (watermark .pipeline );overlay .setBindGroup (0,watermark .bindGroup );overlay .draw (4);overlay .end (); }); }; constsession = awaitcreateCameraFrameProcessorSession ({track ,device ,width : 720,height : 1280,cameraShaderBindings :grayscale .cameraBindings ,frameKernel , }); return {track :session .track ,onClear : () => voidsession .dispose () }; };
Next steps​
- See the WebGPU effects how-to for the rules inside the frame kernel, platform notes and aspect ratio helpers
- Use ready-made background blur or a background image next to your own effects
- Learn how camera effects work under the hood
- API reference: Video Effects package