Skip to content

Commit d5e54fd

Browse files
committed
lazy loading webgpu content in docs
1 parent e7e7c62 commit d5e54fd

5 files changed

Lines changed: 320 additions & 1 deletion

File tree

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import zipfile
88

99
os.environ["WEBGPU_EXPORTING"] = "1"
10+
os.environ["WEBGPU_LAZY_LOAD"] = "1"
1011
master_doc = "index"
1112
source_suffix = [".rst", ".md"]
1213

webgpu/engine/engine.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ class RenderEngine {
123123
device: this.device,
124124
format: this.canvasFormat,
125125
alphaMode: 'premultiplied',
126+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
126127
});
127128

128129
// --- Create GPU resources ---

webgpu/export/screenshot.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Persistent screenshot worker for exported scene blobs.
2+
3+
Launches one Chrome instance on a virtual display (Xvfb) and handles multiple
4+
screenshot requests over stdin/stdout.
5+
6+
Protocol (line-based):
7+
Request: <width> <height> <blob_b64>\n
8+
Response: <png_b64>\n
9+
Shutdown: (close stdin)
10+
11+
Requires: Xvfb, playwright, chrome
12+
"""
13+
14+
import sys
15+
import os
16+
import base64
17+
import subprocess
18+
import tempfile
19+
import threading
20+
from pathlib import Path
21+
from http.server import HTTPServer, SimpleHTTPRequestHandler
22+
23+
24+
def main():
25+
# Use Xvfb for a private display so Chrome stays invisible
26+
for disp_num in range(99, 120):
27+
if not os.path.exists(f'/tmp/.X11-unix/X{disp_num}'):
28+
break
29+
xvfb_proc = subprocess.Popen(
30+
['Xvfb', f':{disp_num}', '-screen', '0', '1280x1024x24', '-ac'],
31+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
32+
)
33+
os.environ['DISPLAY'] = f':{disp_num}'
34+
os.environ.pop('WAYLAND_DISPLAY', None)
35+
import time
36+
time.sleep(0.3)
37+
38+
try:
39+
_run_worker()
40+
finally:
41+
xvfb_proc.terminate()
42+
xvfb_proc.wait()
43+
44+
45+
def _run_worker():
46+
from playwright.sync_api import sync_playwright
47+
48+
ARGS = [
49+
"--no-sandbox",
50+
"--enable-unsafe-webgpu",
51+
"--enable-features=Vulkan,UnsafeWebGPU",
52+
"--use-vulkan=native",
53+
"--ignore-gpu-blocklist",
54+
"--disable-dev-shm-usage",
55+
"--enable-dawn-features=allow_unsafe_apis,disable_adapter_blocklist",
56+
"--ozone-platform=x11",
57+
]
58+
59+
# Load engine JS once
60+
engine_js_path = Path(__file__).parent.parent / "engine"
61+
js_files = ["format.js", "compute.js", "camera.js", "input.js", "interactions.js", "engine.js"]
62+
engine_js = "\n".join(
63+
(engine_js_path / f).read_text().replace("export ", "") for f in js_files
64+
)
65+
engine_js += "\nif (typeof window !== 'undefined') { window.RenderEngine = RenderEngine; }\n"
66+
67+
# Start HTTP server for serving pages to Chrome
68+
tmpdir = Path(tempfile.mkdtemp(prefix="webgpu_ss_"))
69+
70+
class Quiet(SimpleHTTPRequestHandler):
71+
def __init__(self, *a, **kw):
72+
super().__init__(*a, directory=str(tmpdir), **kw)
73+
def log_message(self, *a):
74+
pass
75+
76+
server = HTTPServer(("127.0.0.1", 0), Quiet)
77+
port = server.server_address[1]
78+
threading.Thread(target=server.serve_forever, daemon=True).start()
79+
80+
# Launch browser once (not headless — uses the Xvfb virtual display)
81+
pw = sync_playwright().start()
82+
browser = pw.chromium.launch(
83+
channel="chrome", headless=False,
84+
args=ARGS,
85+
)
86+
page = browser.new_page()
87+
88+
# Signal ready
89+
sys.stdout.write("READY\n")
90+
sys.stdout.flush()
91+
92+
# Process requests from stdin
93+
for line in sys.stdin:
94+
line = line.strip()
95+
if not line:
96+
continue
97+
98+
parts = line.split(' ', 2)
99+
if len(parts) != 3:
100+
sys.stdout.write("\n")
101+
sys.stdout.flush()
102+
continue
103+
104+
width, height, blob_b64 = int(parts[0]), int(parts[1]), parts[2]
105+
106+
html = f"""<!DOCTYPE html><html><body style="margin:0;padding:0;overflow:hidden;">
107+
<canvas id="c" width="{width}" height="{height}" style="display:block;"></canvas>
108+
<script>
109+
{engine_js}
110+
(async () => {{
111+
try {{
112+
const engine = await RenderEngine.create('c', `{blob_b64}`);
113+
document.title = 'READY';
114+
}} catch(e) {{
115+
document.title = 'ERROR:' + (e.stack || e.message || e);
116+
}}
117+
}})();
118+
</script></body></html>"""
119+
120+
(tmpdir / "index.html").write_text(html)
121+
page.goto(f"http://127.0.0.1:{port}/index.html")
122+
123+
try:
124+
page.wait_for_function(
125+
"document.title === 'READY' || document.title.startsWith('ERROR:')",
126+
timeout=30000,
127+
)
128+
title = page.title()
129+
if title.startswith('ERROR:'):
130+
print(f"[screenshot] JS error: {title}", file=sys.stderr)
131+
sys.stdout.write("\n")
132+
sys.stdout.flush()
133+
continue
134+
135+
# Wait for rAF compositing then screenshot the canvas element
136+
page.wait_for_timeout(100)
137+
el = page.query_selector('#c')
138+
png_bytes = el.screenshot() if el else b''
139+
sys.stdout.write(base64.b64encode(png_bytes).decode() + "\n")
140+
sys.stdout.flush()
141+
except Exception as e:
142+
print(f"[screenshot] error: {e}", file=sys.stderr)
143+
sys.stdout.write("\n")
144+
sys.stdout.flush()
145+
146+
browser.close()
147+
pw.stop()
148+
server.shutdown()
149+
150+
151+
if __name__ == "__main__":
152+
main()

webgpu/jupyter.py

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,163 @@ def _DrawHTML(scene, width=640, height=600):
126126
return scene
127127

128128

129+
def _DrawHTMLLazy(scene, width=640, height=600):
130+
"""Export scene as a lazy-loading HTML snippet with a pre-baked screenshot.
131+
132+
The screenshot is rendered in a separate subprocess (own Chrome + GPU device)
133+
so it doesn't interfere with the main build process's mapAsync calls.
134+
The scene blob and engine JS are saved as separate static files and only
135+
fetched when the user clicks to interact.
136+
"""
137+
global _engine_emitted
138+
from IPython.display import HTML, Javascript, display
139+
140+
from .engine import engine_js
141+
142+
if isinstance(scene, Renderer):
143+
scene = [scene]
144+
if isinstance(scene, list):
145+
scene = Scene(scene)
146+
147+
id_ = f"__webgpu_{next(_id_counter)}_"
148+
canvas_id = f"{id_}canvas"
149+
150+
# Create a canvas in the headless Chrome DOM for GPU initialization.
151+
js = platform.js
152+
html_canvas = js.document.createElement("canvas")
153+
html_canvas.width = width
154+
html_canvas.height = height
155+
html_canvas.id = canvas_id
156+
js.document.body.appendChild(html_canvas)
157+
158+
canvas = Canvas(init_device_sync(), html_canvas)
159+
scene.init(canvas)
160+
161+
# Disable render() — patchedRequestAnimationFrame hangs in headless Chrome.
162+
scene.render = lambda *a, **kw: None
163+
scene._on_camera_changed = lambda *a, **kw: None
164+
165+
# Export scene to blob (scene.init already filled all GPU buffers)
166+
blob = scene.export()
167+
blob_b64 = base64.b64encode(blob).decode()
168+
169+
# Save blob and engine JS as static files for deferred loading.
170+
# Use a unique hash to avoid collisions across notebooks.
171+
import hashlib
172+
blob_hash = hashlib.md5(blob).hexdigest()[:10]
173+
static_dir = _get_static_dir()
174+
175+
# Save blob as a JS file (script src works on file:// unlike fetch)
176+
scene_filename = f"scene_{blob_hash}.js"
177+
scene_var = f"__webgpu_blob_{blob_hash}"
178+
(static_dir / scene_filename).write_text(
179+
f"window.{scene_var} = \"{blob_b64}\";"
180+
)
181+
scene_url = f"_static/webgpu_scenes/{scene_filename}"
182+
183+
if not _engine_emitted:
184+
(static_dir / "engine.js").write_text(engine_js)
185+
_engine_emitted = True
186+
engine_url = "_static/webgpu_scenes/engine.js"
187+
188+
# Capture screenshot in a separate subprocess
189+
screenshot_b64 = _capture_screenshot_subprocess(blob_b64, width, height)
190+
screenshot_filename = f"screenshot_{blob_hash}.png"
191+
if screenshot_b64:
192+
(static_dir / screenshot_filename).write_bytes(base64.b64decode(screenshot_b64))
193+
screenshot_url = f"_static/webgpu_scenes/{screenshot_filename}"
194+
195+
# Emit the lazy-load HTML: only screenshot + overlay, everything else loaded on click
196+
lazy_html = f"""
197+
<div id='{id_}root'
198+
style="position: relative; width: {width}px; max-width: 100%; overflow: hidden;"
199+
>
200+
<img id='{id_}img'
201+
src='{screenshot_url}'
202+
style='width: {width}px; height: {height}px; max-width: 100%; display: block;'
203+
/>
204+
<div id='{id_}overlay'
205+
style='position: absolute; top: 0; left: 0; width: 100%; height: 100%;
206+
display: flex; align-items: center; justify-content: center;
207+
background: rgba(0,0,0,0); cursor: pointer; transition: background 0.2s;'
208+
onmouseover="this.style.background='rgba(0,0,0,0.18)'; this.querySelector('span').style.opacity='1'"
209+
onmouseout="this.style.background='rgba(0,0,0,0)'; this.querySelector('span').style.opacity='0'"
210+
onclick="(function() {{ var r = document.getElementById('{id_}root'); if (r.__activated) return; r.__activated = true; function activate() {{ document.getElementById('{id_}img').style.display = 'none'; document.getElementById('{id_}overlay').style.display = 'none'; document.getElementById('{canvas_id}').style.display = 'block'; RenderEngine.create('{canvas_id}', window.{scene_var}); }} function loadBlob() {{ var s = document.createElement('script'); s.src = '{scene_url}'; s.onload = activate; document.head.appendChild(s); }} if (typeof RenderEngine === 'undefined') {{ var s = document.createElement('script'); s.src = '{engine_url}'; s.onload = loadBlob; document.head.appendChild(s); }} else {{ loadBlob(); }} }})()"
211+
>
212+
<span style='color: white; font-size: 1.3em; font-weight: bold;
213+
text-shadow: 0 1px 4px rgba(0,0,0,0.7); pointer-events: none;
214+
opacity: 0; transition: opacity 0.2s;'
215+
>&#9654; Click to interact</span>
216+
</div>
217+
<canvas
218+
id='{canvas_id}'
219+
style='background-color: white; width: {width}px; height: {height}px;
220+
touch-action: none; max-width: 100%; display: none;'
221+
></canvas>
222+
<div id='{id_}lilgui'
223+
style='position: absolute; top: 0; right: 0; z-index: 10;'
224+
></div>
225+
</div>
226+
"""
227+
228+
display(HTML(lazy_html))
229+
return scene
230+
231+
232+
def _get_static_dir():
233+
"""Get or create the static directory for webgpu scene assets."""
234+
from pathlib import Path
235+
# Try to find the docs source _static directory
236+
for candidate in [
237+
Path("docs/_static/webgpu_scenes"),
238+
Path("_static/webgpu_scenes"),
239+
Path("webgpu_scenes"),
240+
]:
241+
# Check if the parent _static dir exists (we're in a docs build)
242+
if candidate.parent.exists():
243+
candidate.mkdir(parents=True, exist_ok=True)
244+
return candidate
245+
# Fallback: create in current directory
246+
fallback = Path("_static/webgpu_scenes")
247+
fallback.mkdir(parents=True, exist_ok=True)
248+
return fallback
249+
250+
251+
# Persistent screenshot worker process (launched once, handles all screenshots)
252+
_screenshot_worker = None
253+
254+
255+
def _capture_screenshot_subprocess(blob_b64, width, height):
256+
"""Send a screenshot request to the persistent worker process."""
257+
import subprocess
258+
import sys
259+
260+
global _screenshot_worker
261+
if _screenshot_worker is None or _screenshot_worker.poll() is not None:
262+
_screenshot_worker = subprocess.Popen(
263+
[sys.executable, "-m", "webgpu.export.screenshot"],
264+
stdin=subprocess.PIPE,
265+
stdout=subprocess.PIPE,
266+
stderr=subprocess.PIPE,
267+
text=True,
268+
)
269+
# Wait for READY signal
270+
ready = _screenshot_worker.stdout.readline().strip()
271+
if ready != "READY":
272+
print(f"Warning: screenshot worker failed to start: {ready}")
273+
_screenshot_worker = None
274+
return ""
275+
276+
try:
277+
_screenshot_worker.stdin.write(f"{width} {height} {blob_b64}\n")
278+
_screenshot_worker.stdin.flush()
279+
result = _screenshot_worker.stdout.readline().strip()
280+
return result
281+
except Exception as e:
282+
print(f"Warning: screenshot capture failed: {e}")
283+
return ""
284+
285+
129286
def _init_export_gpu():
130287
"""Start headless Chrome with WebGPU for buffer export during nbconvert.
131288
@@ -265,7 +422,11 @@ def Draw(
265422
if is_exporting:
266423
# Launch headless Chrome for GPU access during export
267424
_init_export_gpu()
268-
Draw = _DrawHTML
425+
is_lazy_load = "WEBGPU_LAZY_LOAD" in os.environ
426+
if is_lazy_load:
427+
Draw = _DrawHTMLLazy
428+
else:
429+
Draw = _DrawHTML
269430
else:
270431
# Not exporting and not running in pyodide -> Start a websocket server
271432
# and wait for the client to connect.

webgpu/notebook_to_html.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ def main():
3636
parser.add_argument("-o", "--output", help="Output HTML path (default: same name .html)")
3737
parser.add_argument("--timeout", type=int, default=120, help="Cell execution timeout in seconds")
3838
parser.add_argument("--kernel", default=None, help="Jupyter kernel name")
39+
parser.add_argument("--lazy-load", action="store_true", default=False,
40+
help="Embed screenshot previews that load the WebGPU scene on click")
3941
args = parser.parse_args()
4042

4143
if not os.path.exists(args.notebook):
@@ -45,6 +47,8 @@ def main():
4547
# Build environment
4648
env = os.environ.copy()
4749
env["WEBGPU_EXPORTING"] = "1"
50+
if args.lazy_load:
51+
env["WEBGPU_LAZY_LOAD"] = "1"
4852

4953
if not _has_real_gpu():
5054
icd = _find_lavapipe_icd()

0 commit comments

Comments
 (0)