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 Stream Vision Camera to Fishjam. You'll take the app you built there and route its published 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 Vision Camera app, now publishing through your own GPU pipeline: a camera feed with a "LIVE" watermark in the corner, recolored to grayscale by your own shader.
What you'll learnβ
- How to route published camera frames through
useVisionCameraWebGpuSource - 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 Vision Camera tutorial; this tutorial rewrites only its
CameraPublisher, whileJoinRoomButton,RemoteStreamsandAppstay unchanged - iOS 17+ on the publishing device; the WebGPU camera path relies on Metal features not guaranteed on earlier versions (the setup from the previous tutorial keeps working below that)
- A physical device, or the iOS Simulator with a virtual camera from SimCam
- As before: the New Architecture
Step 1: Install and configureβ
Install the packagesβ
On top of the packages from the previous tutorial:
- 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
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. Add its Babel plugin, keeping react-native-worklets/plugin last:
module.exports = { presets: ["babel-preset-expo"], plugins: ["unplugin-typegpu/babel", "react-native-worklets/plugin"], };
Restart Metro with a cleared cache afterwards (npx expo start --clear).
Rebuild the appβ
- Expo
- Bare workflow
npx expo prebuild npx expo run:ios # or run:android
cd ios && pod install
Step 2: Route the camera through WebGPUβ
Replace useVisionCameraSource with useVisionCameraWebGpuSource. This changes three things:
useCameraWebGpuDeviceprovides a sharedGPUDevice, requested with the features the camera-import path needs.- Every camera frame now reaches your
onFrameworklet, where callingrender(...)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".
Keep the source ID "vision-camera", so RemoteStreams keeps working without changes:
importReact , {useCallback ,useEffect ,useMemo } from "react"; import {Text } from "react-native"; import {useCamera asuseVisionCamera ,useCameraPermission , typeFrame , } from "react-native-vision-camera"; import {RTCView } from "@fishjam-cloud/react-native-client"; import {useVisionCameraWebGpuSource ,useCameraWebGpuDevice ,createCameraPassthroughPipeline ,encodeCameraPassthrough ,computeAspectFillCrop , typeWebGpuFrameRenderFunction , } from "@fishjam-cloud/react-native-vision-camera-source/webgpu"; export functionCameraPublisher () { const {hasPermission ,canRequestPermission ,requestPermission } =useCameraPermission ();useEffect (() => { if (!hasPermission &&canRequestPermission ) {requestPermission (); } }, [hasPermission ,canRequestPermission ,requestPermission ]); const {device } =useCameraWebGpuDevice (); // A ready-made camera β output render pass, built once per device. constpassthrough =useMemo ( () => (device ?createCameraPassthroughPipeline (device ) : null), [device ], ); constonFrame =useCallback ( (frame :Frame ,render :WebGpuFrameRenderFunction ) => { "worklet"; if (device == null ||passthrough == null) return;render ((context ) => { // Crop the camera to fill the output, like `objectFit: "cover"`. constcrop =computeAspectFillCrop (context .cameraWidth ,context .cameraHeight ,context .outputWidth /context .outputHeight , );encodeCameraPassthrough (device ,passthrough ,context .cameraTexture ,context .outputView ,context .commandEncoder ,crop , ); }); }, [device ,passthrough ], ); const {frameOutput ,stream } =useVisionCameraWebGpuSource ("vision-camera", {width : 720,height : 1280,onFrame , });useVisionCamera ({device : "front",isActive :hasPermission ,outputs : [frameOutput ], }); if (!stream ) return <Text >Starting cameraβ¦</Text >; return ( <RTCView mediaStream ={stream }style ={{height : 300,width : 300 }}objectFit ="cover"mirror ={true} /> ); }
Rebuild and join the room from a second device. 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 onFrame 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.
- The
.$name(...)calls pin the WGSL entry-point names. TypeGPU otherwise names functions after their JavaScript identifiers, andcreateRenderPipelinewould look for an entry point that doesn't exist. This failure is silent: WebGPU reports it as an asynchronous validation error, not a thrown exception, and the pass simply draws nothing.
importtgpu from "typegpu"; import * asd from "typegpu/data"; import {GPUShaderStage ,GPUTextureUsage } from "react-native-webgpu"; import {getOutputSurfaceFormat } from "@fishjam-cloud/react-native-vision-camera-source/webgpu"; // 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"); export 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 ({externals : {vertexMain :makeWatermarkVertexMain ({x0 ,y0 ,x1 ,y1 }),fragmentMain :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: fetch the bundled asset, then decode it with createImageBitmap (provided globally by react-native-webgpu). It lives in a useEffect and lands in state. Inside CameraPublisher, add:
const [watermark ,setWatermark ] =useState <ReturnType < typeofcreateWatermarkPipeline > | null>(null);useEffect (() => { if (device == null) return; letcancelled = false; constload = async () => { constasset =Image .resolveAssetSource (require ("./assets/watermark.png")); constresponse = awaitfetch (asset .uri ); constbitmap = awaitcreateImageBitmap (awaitresponse .arrayBuffer ()); if (!cancelled ) {setWatermark (createWatermarkPipeline (device ,bitmap , 720, 1280)); } }; voidload (); return () => {cancelled = true; }; }, [device ]);
Then extend onFrame: after the passthrough, 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:
constonFrame =useCallback ( (frame :Frame ,render :WebGpuFrameRenderFunction ) => { "worklet"; if (device == null ||passthrough == null) return;render ((context ) => { constcrop =computeAspectFillCrop (context .cameraWidth ,context .cameraHeight ,context .outputWidth /context .outputHeight , );encodeCameraPassthrough (device ,passthrough ,context .cameraTexture ,context .outputView ,context .commandEncoder ,crop , ); if (watermark != null) { // `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 (); } }); }, [device ,passthrough ,watermark ], );
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.
One last change: keep the camera inactive until the upload has finished. copyExternalImageToTexture submits GPU work internally, and while frames flow the hook owns the device queue, so a concurrent upload from the JS thread can crash the app. Gating isActive serializes the two:
useVisionCamera ({device : "front", // The hook owns the GPU queue while frames flow; wait for the upload.isActive :hasPermission &&watermark != null,outputs : [frameOutput ], });
Rebuild 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 upright RGB on both platforms and handles the YUV decode for you.
Add the effect next to the watermark code at module scope:
importtgpu from "typegpu"; import * asd from "typegpu/data"; import {dot } from "typegpu/std"; import {createCameraShaderBindings ,getOutputSurfaceFormat , } 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 functioncreateGrayscaleEffect (device :GPUDevice ) { 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 camera's `texture_external` binding itself, // so prepend the declarations that come with the bindings. 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 }; }
In the component, build the effect once per device and pass its cameraBindings to the hook. In return, the render context carries a ready-made cameraBindGroup, rebuilt every frame because the camera's external texture expires with each frame. The worklet 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:
consteffect =useMemo ( () => (device ?createGrayscaleEffect (device ) : null), [device ], ); constonFrame =useCallback ( (frame :Frame ,render :WebGpuFrameRenderFunction ) => { "worklet"; if (effect == null) return; // drop frames until the pipeline is readyrender ((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 (effect .pipeline );pass .setBindGroup (0,context .cameraBindGroup !);pass .draw (3);pass .end (); // 2. The watermark still goes on top, unchanged. if (watermark != null) { 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 (); } }); }, [effect ,watermark ], ); const {frameOutput ,stream } =useVisionCameraWebGpuSource ("vision-camera", {width : 720,height : 1280, // Provides a per-frame `cameraBindGroup` in the render context.cameraShaderBindings :effect ?.cameraBindings ,onFrame , });
Sampling the full frame stretches the camera when its aspect ratio differs from 720Γ1280. The cropping helpers in the how-to cover aspect-correct sampling.
Rebuild one last time: 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 CameraPublisher file, top to bottom. Room and App are unchanged from the Vision Camera tutorial:
importReact , {useCallback ,useEffect ,useMemo ,useState } from "react"; import {Image ,Text } from "react-native"; importtgpu from "typegpu"; import * asd from "typegpu/data"; import {dot } from "typegpu/std"; import {GPUShaderStage ,GPUTextureUsage } from "react-native-webgpu"; 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"; // --- Watermark: a textured quad blended over the camera --- 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 ({externals : {vertexMain :makeWatermarkVertexMain ({x0 ,y0 ,x1 ,y1 }),fragmentMain :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 ); 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 ({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 }; } // --- The publisher --- export functionCameraPublisher () { const {hasPermission ,canRequestPermission ,requestPermission } =useCameraPermission ();useEffect (() => { if (!hasPermission &&canRequestPermission ) {requestPermission (); } }, [hasPermission ,canRequestPermission ,requestPermission ]); const {device } =useCameraWebGpuDevice (); consteffect =useMemo ( () => (device ?createGrayscaleEffect (device ) : null), [device ], ); const [watermark ,setWatermark ] =useState <ReturnType < typeofcreateWatermarkPipeline > | null>(null);useEffect (() => { if (device == null) return; letcancelled = false; constload = async () => { constasset =Image .resolveAssetSource (require ("./assets/watermark.png")); constresponse = awaitfetch (asset .uri ); constbitmap = awaitcreateImageBitmap (awaitresponse .arrayBuffer ()); if (!cancelled ) {setWatermark (createWatermarkPipeline (device ,bitmap , 720, 1280)); } }; voidload (); return () => {cancelled = true; }; }, [device ]); constonFrame =useCallback ( (frame :Frame ,render :WebGpuFrameRenderFunction ) => { "worklet"; if (effect == null) return; // drop frames until the pipeline is readyrender ((context ) => { constpass =context .commandEncoder .beginRenderPass ({colorAttachments : [ {view :context .outputView ,loadOp : "clear",storeOp : "store" }, ], });pass .setPipeline (effect .pipeline );pass .setBindGroup (0,context .cameraBindGroup !);pass .draw (3);pass .end (); if (watermark != null) { 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 (); } }); }, [effect ,watermark ], ); const {frameOutput ,stream } =useVisionCameraWebGpuSource ("vision-camera", {width : 720,height : 1280,cameraShaderBindings :effect ?.cameraBindings ,onFrame , });useVisionCamera ({device : "front", // The hook owns the GPU queue while frames flow; wait for the upload.isActive :hasPermission &&watermark != null,outputs : [frameOutput ], }); if (!stream ) return <Text >Starting cameraβ¦</Text >; return ( <RTCView mediaStream ={stream }style ={{height : 300,width : 300 }}objectFit ="cover"mirror ={true} /> ); }
Next stepsβ
- See the WebGPU effects how-to for the rules inside
onFrame, platform notes and cropping helpers - Produce frames without a camera with the low-level frame API
- Learn how custom sources work under the hood
- API reference: Vision Camera Source package, Custom Video Source package