44 Canvas ,
55 useCanvasRef ,
66 useDevice ,
7+ type GPUSharedTextureMemory ,
78 type NativeCanvas ,
9+ type VideoFrame ,
810} from "react-native-wgpu" ;
911
1012const SHADER = /* wgsl */ `
@@ -41,37 +43,44 @@ fn fs_main(in: VsOut) -> @location(0) vec4f {
4143}
4244` ;
4345
44- const REQUIRED_FEATURE =
46+ // On Metal, EndAccess on an IOSurface-backed SharedTextureMemory always
47+ // produces an MTLSharedEvent fence (so the producer can wait on the GPU). Even
48+ // though we don't currently expose the fence to JS, Dawn validates that the
49+ // fence feature is enabled before letting EndAccess succeed. Android has the
50+ // equivalent pairing with sync fds.
51+ const REQUIRED_FEATURES =
4552 Platform . OS === "ios"
46- ? "shared-texture-memory-iosurface"
47- : "shared-texture-memory-ahardware-buffer" ;
53+ ? [ "shared-texture-memory-iosurface" , "shared-fence-mtl-shared-event" ]
54+ : [
55+ "shared-texture-memory-ahardware-buffer" ,
56+ "shared-fence-vk-semaphore-sync-fd" ,
57+ ] ;
4858
4959export const SharedTextureMemory = ( ) => {
5060 const ref = useCanvasRef ( ) ;
5161 const [ error , setError ] = useState < string | null > ( null ) ;
5262 const rafRef = useRef < number | null > ( null ) ;
5363
54- // Request the shared-memory feature when constructing the device so the
55- // shared-texture-memory* extension is enabled.
5664 const { device, adapter } = useDevice ( undefined , {
5765 // Cast: GPUFeatureName in @webgpu /types doesn't include the Dawn-specific
58- // extension name yet, but Dawn accepts it .
59- requiredFeatures : [ REQUIRED_FEATURE as GPUFeatureName ] ,
66+ // extension names yet, but Dawn accepts them .
67+ requiredFeatures : REQUIRED_FEATURES as unknown as GPUFeatureName [ ] ,
6068 } ) ;
6169
6270 useEffect ( ( ) => {
6371 if ( ! device ) {
6472 return ;
6573 }
66- if ( ! device . features . has ( REQUIRED_FEATURE ) ) {
74+ const missing = REQUIRED_FEATURES . filter ( ( f ) => ! device . features . has ( f ) ) ;
75+ if ( missing . length > 0 ) {
6776 setError (
68- `Device is missing the ' ${ REQUIRED_FEATURE } ' feature (adapter supports: ${
77+ `Device is missing required features [ ${ missing . join ( ", " ) } ]. Adapter supports: ${
6978 adapter
7079 ? [ ...adapter . features ]
7180 . filter ( ( f ) => f . toString ( ) . startsWith ( "shared-" ) )
7281 . join ( ", " ) || "none"
7382 : "n/a"
74- } ) `,
83+ } `,
7584 ) ;
7685 return ;
7786 }
@@ -90,38 +99,14 @@ export const SharedTextureMemory = () => {
9099 alphaMode : "premultiplied" ,
91100 } ) ;
92101
93- // 1. Acquire a native, GPU-shareable surface. In production this would
94- // come from a camera frame processor or video decoder. The test helper
95- // synthesizes a 256x256 RGB-gradient pattern in an IOSurface.
96- const frame = RNWebGPU . createTestVideoFrame ( 256 , 256 ) ;
97-
98- // 2. Import the raw native handle into a SharedTextureMemory.
99- const sharedMemory = device . importSharedTextureMemory ( {
100- handle : frame . handle ,
101- label : "video-frame-shared-memory" ,
102- } ) ;
103-
104- // 3. Create a regular GPUTexture that aliases the surface's pixels.
105- // No descriptor needed: the format/size are inferred from the surface.
106- const texture = sharedMemory . createTexture ( ) ;
107-
108- // 4. beginAccess declares that we're about to read or write the texture on
109- // the GPU timeline. `initialized: true` means "the surface already has
110- // meaningful pixels", which is correct for an incoming video frame.
111- //
112- // Because this example owns a *static* IOSurface (no external producer
113- // is writing new pixels between frames), we keep one access window open
114- // for the lifetime of the texture and call endAccess only on unmount.
115- //
116- // For a live camera or video feed, you'd instead wrap each frame:
117- // beginAccess(tex, true) -> submit -> endAccess(tex)
118- // around every render to hand ownership back to the producer. That's
119- // also where fence support (not yet wired through this binding) becomes
120- // important to avoid races with the producer.
121- if ( ! sharedMemory . beginAccess ( texture , true ) ) {
122- setError ( "beginAccess() failed" ) ;
123- return ;
124- }
102+ // 1. Open the video and start playback. AVPlayer accepts local file paths
103+ // as well as http(s):// URLs and keeps the IOSurface pool up to date
104+ // in the background. For a fully offline demo, swap this URL for
105+ // RNWebGPU.writeTestVideoFile() which generates a tiny mp4 on disk.
106+ const VIDEO_URL =
107+ "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/1080/Big_Buck_Bunny_1080_10s_5MB.mp4" ;
108+ const player = RNWebGPU . createVideoPlayer ( VIDEO_URL ) ;
109+ player . play ( ) ;
125110
126111 const module = device . createShaderModule ( { code : SHADER } ) ;
127112 const pipeline = device . createRenderPipeline ( {
@@ -138,15 +123,77 @@ export const SharedTextureMemory = () => {
138123 magFilter : "linear" ,
139124 minFilter : "linear" ,
140125 } ) ;
141- const bindGroup = device . createBindGroup ( {
142- layout : pipeline . getBindGroupLayout ( 0 ) ,
143- entries : [
144- { binding : 0 , resource : texture . createView ( ) } ,
145- { binding : 1 , resource : sampler } ,
146- ] ,
147- } ) ;
126+
127+ // We hold the *current* frame across rAF ticks so that when the video
128+ // hasn't produced a new frame yet (between decoder timestamps), we keep
129+ // rendering the last one rather than dropping to a black screen.
130+ //
131+ // For each new IOSurface we:
132+ // - create a SharedTextureMemory + texture + bindGroup
133+ // - beginAccess(initialized: true) to declare "the producer has written
134+ // these pixels and we're now sampling them"
135+ // - sample in the shader
136+ // - endAccess to hand ownership back to the producer
137+ //
138+ // We close out the previous frame's access window first. In a fence-aware
139+ // build we'd plumb an AVPlayer fence through beginAccess/endAccess; for the
140+ // demo we rely on AVPlayer recycling its IOSurface pool, which is safe as
141+ // long as we end-access before letting the player reclaim the buffer.
142+ type Bound = {
143+ frame : VideoFrame ;
144+ memory : GPUSharedTextureMemory ;
145+ texture : GPUTexture ;
146+ bindGroup : GPUBindGroup ;
147+ } ;
148+ let current : Bound | null = null ;
149+
150+ const bindFrame = ( frame : VideoFrame ) : Bound | null => {
151+ try {
152+ const memory = device . importSharedTextureMemory ( {
153+ handle : frame . handle ,
154+ label : "video-frame" ,
155+ } ) ;
156+ const texture = memory . createTexture ( ) ;
157+ if ( ! memory . beginAccess ( texture , true ) ) {
158+ texture . destroy ( ) ;
159+ frame . release ( ) ;
160+ return null ;
161+ }
162+ const bindGroup = device . createBindGroup ( {
163+ layout : pipeline . getBindGroupLayout ( 0 ) ,
164+ entries : [
165+ { binding : 0 , resource : texture . createView ( ) } ,
166+ { binding : 1 , resource : sampler } ,
167+ ] ,
168+ } ) ;
169+ return { frame, memory, texture, bindGroup } ;
170+ } catch ( e ) {
171+ console . warn ( "[SharedTextureMemory] bindFrame failed:" , e ) ;
172+ frame . release ( ) ;
173+ return null ;
174+ }
175+ } ;
176+
177+ const releaseBound = ( b : Bound ) => {
178+ b . memory . endAccess ( b . texture ) ;
179+ b . texture . destroy ( ) ;
180+ b . frame . release ( ) ;
181+ } ;
148182
149183 const render = ( ) => {
184+ // Pull the latest frame from the player. Null means "no new frame since
185+ // we last asked", in which case we keep using the existing one.
186+ const newFrame = player . copyLatestFrame ( ) ;
187+ if ( newFrame ) {
188+ const next = bindFrame ( newFrame ) ;
189+ if ( next ) {
190+ if ( current ) {
191+ releaseBound ( current ) ;
192+ }
193+ current = next ;
194+ }
195+ }
196+
150197 const encoder = device . createCommandEncoder ( ) ;
151198 const pass = encoder . beginRenderPass ( {
152199 colorAttachments : [
@@ -158,9 +205,11 @@ export const SharedTextureMemory = () => {
158205 } ,
159206 ] ,
160207 } ) ;
161- pass . setPipeline ( pipeline ) ;
162- pass . setBindGroup ( 0 , bindGroup ) ;
163- pass . draw ( 3 ) ;
208+ if ( current ) {
209+ pass . setPipeline ( pipeline ) ;
210+ pass . setBindGroup ( 0 , current . bindGroup ) ;
211+ pass . draw ( 3 ) ;
212+ }
164213 pass . end ( ) ;
165214 device . queue . submit ( [ encoder . finish ( ) ] ) ;
166215 context . present ( ) ;
@@ -172,9 +221,11 @@ export const SharedTextureMemory = () => {
172221 if ( rafRef . current !== null ) {
173222 cancelAnimationFrame ( rafRef . current ) ;
174223 }
175- sharedMemory . endAccess ( texture ) ;
176- texture . destroy ( ) ;
177- frame . release ( ) ;
224+ if ( current ) {
225+ releaseBound ( current ) ;
226+ current = null ;
227+ }
228+ player . release ( ) ;
178229 } ;
179230 } , [ device , adapter , ref ] ) ;
180231
0 commit comments