@@ -24,6 +24,7 @@ def _configure_dirs(webgpu_env):
2424import shutil
2525import tempfile
2626import threading
27+ from contextlib import contextmanager
2728from functools import partial
2829from http .server import HTTPServer , SimpleHTTPRequestHandler
2930from pathlib import Path
@@ -137,6 +138,64 @@ def log_message(self, format, *args):
137138 pass
138139
139140
141+ def readback_scene (scene ):
142+ """Read back a scene's current rendered frame as an HxWx4 RGBA array.
143+
144+ Uses the JS render engine (the default backend): it renders into an
145+ offscreen capture target and the texture is read back on the JS side via
146+ ``window._doReadback`` (which the test page must provide — see
147+ :data:`_READBACK_JS`). Falls back to the legacy Python render path
148+ (render to ``target_texture`` + read it back) when no engine is active.
149+
150+ Reusable outside this module (e.g. by ngapp's e2e helpers) so the readback
151+ lives in one place.
152+ """
153+ import numpy as np
154+
155+ from webgpu import platform
156+
157+ engine = getattr (scene , "_js_engine" , None )
158+ if engine is None and getattr (scene , "_use_js_engine" , False ):
159+ try :
160+ scene ._install_live_engine ()
161+ engine = getattr (scene , "_js_engine" , None )
162+ except Exception as e :
163+ print (f"warning: JS engine install failed, using Python path: { e } " )
164+ engine = None
165+
166+ if engine is not None :
167+ engine .enableHeadlessCapture ()
168+ w , h = scene .canvas .width , scene .canvas .height
169+ fmt = str (scene .canvas .format )
170+ # count-then-fill renderers (e.g. clipping) size their output buffer
171+ # reactively: a render's async counter readback resizes the buffer and
172+ # the next render fills it. That readback only progresses when the GPU
173+ # queue is ticked, so a plain wait isn't enough over the bridge — pump a
174+ # tiny readback after each render to drive it to convergence before we
175+ # capture (matches the interactive path, where the lag is not noticeable).
176+ for _ in range (5 ):
177+ engine .render ()
178+ platform .js ._doReadback (scene .device .handle , engine .captureTexture (), 8 , 8 )
179+ tex = engine .captureTexture ()
180+ else :
181+ # Ensure target_texture reflects current scene state.
182+ with scene ._render_mutex :
183+ scene ._render_objects (to_canvas = False )
184+ tex = scene .canvas .target_texture
185+ w , h = tex .width , tex .height
186+ fmt = str (tex .format )
187+
188+ bytes_per_row = (w * 4 + 255 ) // 256 * 256
189+ b64_data = platform .js ._doReadback (scene .device .handle , tex , w , h )
190+
191+ raw = base64 .b64decode (b64_data )
192+ data = np .frombuffer (raw , dtype = np .uint8 ).reshape ((h , bytes_per_row // 4 , 4 ))
193+ data = data [:, :w , :]
194+ if fmt == "bgra8unorm" :
195+ data = data [:, :, [2 , 1 , 0 , 3 ]]
196+ return data
197+
198+
140199# ---------------------------------------------------------------------------
141200# WebGPUTestEnv – helpers available via the webgpu_env fixture
142201# ---------------------------------------------------------------------------
@@ -245,58 +304,11 @@ def assert_matches_baseline(self, scene, filename, *, threshold=0.01):
245304 )
246305
247306 def readback_texture (self , scene , path ):
248- """Read back the rendered frame via JS-side readback."""
249- import numpy as np
307+ """Read back the rendered frame and save it as a PNG at *path*."""
250308 from PIL import Image
251- from webgpu import platform
252-
253- engine = getattr (scene , '_js_engine' , None )
254- if engine is None and getattr (scene , '_use_js_engine' , False ):
255- try :
256- scene ._install_live_engine ()
257- engine = getattr (scene , '_js_engine' , None )
258- except Exception as e :
259- print (f"warning: JS engine install failed, using Python path: { e } " )
260- engine = None
261-
262- if engine is not None :
263- engine .enableHeadlessCapture ()
264- w , h = scene .canvas .width , scene .canvas .height
265- fmt = str (scene .canvas .format )
266- # count-then-fill renderers (e.g. clipping) size their output buffer
267- # reactively: a render's async counter readback resizes the buffer and
268- # the next render fills it. That readback only progresses when the GPU
269- # queue is ticked, so a plain wait isn't enough over the bridge — pump a
270- # tiny readback after each render to drive it to convergence before we
271- # capture (matches the interactive path, where the lag is not noticeable).
272- for _ in range (5 ):
273- engine .render ()
274- platform .js ._doReadback (scene .device .handle , engine .captureTexture (), 8 , 8 )
275- tex = engine .captureTexture ()
276- else :
277- # Ensure target_texture reflects current scene state.
278- # The debounced scene.render() from _draw_scene may not have
279- # re-run after state changes (e.g. toggling renderer.active).
280- with scene ._render_mutex :
281- scene ._render_objects (to_canvas = False )
282- tex = scene .canvas .target_texture
283- w , h = tex .width , tex .height
284- fmt = str (tex .format )
285-
286- bytes_per_row = (w * 4 + 255 ) // 256 * 256
287- b64_data = platform .js ._doReadback (scene .device .handle , tex , w , h )
288309
289- raw = base64 .b64decode (b64_data )
290- data = np .frombuffer (raw , dtype = np .uint8 ).reshape (
291- (h , bytes_per_row // 4 , 4 )
292- )
293- data = data [:, :w , :]
294-
295- if fmt == "bgra8unorm" :
296- data = data [:, :, [2 , 1 , 0 , 3 ]]
297-
298- img = Image .fromarray (data [:, :, :3 ])
299- img .save (str (path ))
310+ data = readback_scene (scene )
311+ Image .fromarray (data [:, :, :3 ]).save (str (path ))
300312 return path
301313
302314 def assert_min_fps (self , scene , min_fps = 60 , * , frames = 20 , warmup = 5 , label = None ):
@@ -400,11 +412,7 @@ def _playwright():
400412
401413@pytest .fixture (scope = "session" )
402414def browser (_playwright ):
403- b = _playwright .chromium .launch (
404- channel = "chrome" ,
405- headless = False ,
406- args = ["--headless=new" ] + CHROMIUM_WEBGPU_ARGS ,
407- )
415+ b = launch_webgpu_browser (_playwright )
408416 yield b
409417 b .close ()
410418
@@ -417,9 +425,18 @@ def page(browser):
417425 p .close ()
418426
419427
420- @pytest .fixture (scope = "session" )
421- def webgpu_env (browser ):
422- """Full webgpu environment: platform + device + browser page with canvas.
428+ def launch_webgpu_browser (playwright ):
429+ """Launch a headless Chromium configured for WebGPU (Vulkan/Dawn)."""
430+ return playwright .chromium .launch (
431+ channel = "chrome" ,
432+ headless = False ,
433+ args = ["--headless=new" ] + CHROMIUM_WEBGPU_ARGS ,
434+ )
435+
436+
437+ @contextmanager
438+ def _open_env (browser ):
439+ """Bring up the WS bridge + device on an existing browser, yield the env.
423440
424441 Threading choreography::
425442
@@ -484,7 +501,7 @@ def run_init():
484501
485502 # Navigate browser -> triggers WS connection -> unblocks platform.init
486503 test_page = browser .new_page ()
487- # Forward browser console to pytest output for debugging.
504+ # Forward browser console for debugging.
488505 test_page .on ("console" , lambda msg : print (f"[browser:{ msg .type } ] { msg .text } " ))
489506 test_page .on ("pageerror" , lambda exc : print (f"[browser:pageerror] { exc } " ))
490507 test_page .goto (f"http://127.0.0.1:{ http_port } /index.html" )
@@ -497,16 +514,50 @@ def run_init():
497514
498515 init_device_sync ()
499516
500- # Flush any init intermediates so tests start with a clean release queue
517+ # Flush any init intermediates so we start with a clean release queue
501518 import gc
502519 gc .collect ()
503520 gc .collect ()
504521 platform .link ._flush_release_queue ()
505522 import time
506523 time .sleep (0.1 )
507524
508- yield WebGPUTestEnv (page = test_page , wj = wj , platform = platform )
525+ try :
526+ yield WebGPUTestEnv (page = test_page , wj = wj , platform = platform )
527+ finally :
528+ test_page .close ()
529+ http_server .shutdown ()
530+ shutil .rmtree (tmpdir , ignore_errors = True )
509531
510- test_page .close ()
511- http_server .shutdown ()
512- shutil .rmtree (tmpdir , ignore_errors = True )
532+
533+ @contextmanager
534+ def headless_session (width = 600 , height = 600 ):
535+ """Standalone headless WebGPU environment, usable **without pytest**.
536+
537+ Launches its own Playwright + Chromium, brings up the WS bridge and device,
538+ and yields a :class:`WebGPUTestEnv` with a canvas ready. Lets render scripts
539+ live anywhere (not just under the package's test tree)::
540+
541+ from webgpu.testing import headless_session
542+ from ngsolve_webgpu.jupyter import Draw
543+
544+ with headless_session(1500, 1100) as env:
545+ scene = Draw(geo, width=1500, height=1100)
546+ env.readback_texture(scene, "out.png")
547+ """
548+ with sync_playwright () as pw :
549+ browser = launch_webgpu_browser (pw )
550+ try :
551+ with _open_env (browser ) as env :
552+ if width and height :
553+ env .ensure_canvas (width , height )
554+ yield env
555+ finally :
556+ browser .close ()
557+
558+
559+ @pytest .fixture (scope = "session" )
560+ def webgpu_env (browser ):
561+ """Full webgpu environment: platform + device + browser page with canvas."""
562+ with _open_env (browser ) as env :
563+ yield env
0 commit comments