Skip to content

Commit 90d9c3d

Browse files
crowlKatsclaude
andcommitted
docs: add desktop raw backend WebGPU rendering example
Adds a WebGPU rendering guide for the desktop raw backend, addressing denoland/deno#35579 (request for a simple raw backend example). The new runtime/desktop/webgpu.md walks through wrapping a native window as an UnsafeWindowSurface and drawing to it with WebGPU: a minimal solid-color clear, a hello-triangle render pipeline, and an animated render loop driven by a uniform buffer. It also documents the raw backend gotchas: request the adapter before getNativeWindow(), size and resize the surface, present() every frame, and close() being downgraded to hide() once a surface is taken. Wires the page into the desktop sidebar and index, and cross-links it from the Backends and Windows pages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 318ecce commit 90d9c3d

5 files changed

Lines changed: 344 additions & 1 deletion

File tree

runtime/_data.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ export const sidebar = [
156156
title: "Windows",
157157
href: "/runtime/desktop/windows/",
158158
},
159+
{
160+
title: "WebGPU rendering",
161+
href: "/runtime/desktop/webgpu/",
162+
},
159163
{
160164
title: "Bindings",
161165
href: "/runtime/desktop/bindings/",

runtime/desktop/backends.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ auto-binding, and no `bindings.<name>()` proxy.
6969
Useful for apps that draw their own UI (WebGPU, Skia, custom rendering) or as a
7070
foundation for non-web desktop programs. The `raw` backend is selected through
7171
the `desktop.backend` field in `deno.json`; the `--backend` flag accepts only
72-
`cef` and `webview`.
72+
`cef` and `webview`. See [WebGPU rendering](/runtime/desktop/webgpu/) for a
73+
complete example of drawing to a window on this backend.
7374

7475
## Picking a backend
7576

runtime/desktop/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ webview navigates to, so you do not need to pass a port or hostname. See
8686
- [Windows](/runtime/desktop/windows/):
8787
[`Deno.BrowserWindow`](/api/deno/~/Deno.BrowserWindow) lifecycle, multiple
8888
windows, events.
89+
- [WebGPU rendering](/runtime/desktop/webgpu/): draw to a native window with
90+
WebGPU on the raw backend.
8991
- [Bindings](/runtime/desktop/bindings/): calling Deno code from the webview via
9092
`bindings.<name>()`.
9193
- [Menus](/runtime/desktop/menus/): application and context menus.

runtime/desktop/webgpu.md

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
---
2+
last_modified: 2026-07-08
3+
title: "WebGPU rendering"
4+
description: "Draw to a native window with WebGPU on the raw backend: request an adapter, wrap the window as an UnsafeWindowSurface, configure a canvas context, and run a render loop."
5+
---
6+
7+
:::info Available in Deno 2.9
8+
9+
`deno desktop` is available starting in Deno v2.9.0. If you're on an earlier
10+
version, [update Deno](/runtime/reference/cli/upgrade/) to use it.
11+
12+
:::
13+
14+
The [raw backend](/runtime/desktop/backends/#raw) gives you a native window with
15+
no web engine attached. Instead of loading HTML, you draw to the window yourself
16+
with [WebGPU](/api/web/~/GPUDevice). This is the right backend for games,
17+
visualizations, emulators, and any app that renders its own pixels rather than a
18+
document.
19+
20+
The bridge between a window and WebGPU is
21+
[`Deno.BrowserWindow.getNativeWindow()`](/api/deno/~/Deno.BrowserWindow.prototype.getNativeWindow),
22+
which hands back a [`Deno.UnsafeWindowSurface`](/api/deno/~/Deno.UnsafeWindowSurface).
23+
That surface exposes a WebGPU canvas context, so the same
24+
`context.configure()` / `getCurrentTexture()` / `present()` flow you'd use in a
25+
browser works against a real OS window.
26+
27+
## Setup
28+
29+
WebGPU is behind an unstable flag, and the raw backend is selected in
30+
`deno.json`:
31+
32+
```json title="deno.json"
33+
{
34+
"desktop": {
35+
"backend": "raw"
36+
},
37+
"unstable": ["webgpu"]
38+
}
39+
```
40+
41+
Unlike `cef` and `webview`, `raw` cannot be passed with `--backend` on the
42+
command line — it is only selectable through the `desktop.backend` field. See
43+
[Backends](/runtime/desktop/backends/#raw).
44+
45+
## A minimal example
46+
47+
The smallest useful program: open a window and clear it to a solid color. This
48+
proves the whole pipeline — adapter, surface, context, present — is wired up
49+
before you add any drawing.
50+
51+
```ts title="main.ts"
52+
// A WebGPU context must exist before the native surface can be wrapped, so
53+
// acquire the adapter and device first.
54+
const adapter = await navigator.gpu.requestAdapter();
55+
if (!adapter) throw new Error("no WebGPU adapter available");
56+
const device = await adapter.requestDevice();
57+
58+
const win = new Deno.BrowserWindow({
59+
title: "WebGPU",
60+
width: 640,
61+
height: 480,
62+
});
63+
64+
// Wrap the native window as a surface and configure a WebGPU context on it.
65+
const surface = win.getNativeWindow();
66+
const format = navigator.gpu.getPreferredCanvasFormat();
67+
const context = surface.getContext("webgpu");
68+
context.configure({ device, format, alphaMode: "opaque" });
69+
70+
// Match the surface to the window before the first frame.
71+
const [width, height] = win.getSize();
72+
surface.width = width;
73+
surface.height = height;
74+
75+
// Clear the frame to teal and present it.
76+
const encoder = device.createCommandEncoder();
77+
encoder.beginRenderPass({
78+
colorAttachments: [{
79+
view: context.getCurrentTexture().createView(),
80+
clearValue: { r: 0, g: 0.5, b: 0.5, a: 1 },
81+
loadOp: "clear",
82+
storeOp: "store",
83+
}],
84+
}).end();
85+
device.queue.submit([encoder.finish()]);
86+
surface.present();
87+
```
88+
89+
Build and run it:
90+
91+
```sh
92+
deno desktop main.ts
93+
./main # macOS / Linux
94+
.\main.exe # Windows
95+
```
96+
97+
`surface.present()` is what actually pushes the encoded frame to the display;
98+
without it the window stays blank. Calling it once, as above, leaves a static
99+
frame on screen until the window closes.
100+
101+
## Drawing geometry
102+
103+
Clearing to a color exercises the surface but draws nothing. A render pipeline
104+
with a WGSL shader is the "hello world" of GPU graphics. This example draws a
105+
single triangle whose vertex colors are interpolated across its face — no vertex
106+
buffers, the positions are baked into the shader.
107+
108+
```ts title="triangle.ts"
109+
const adapter = await navigator.gpu.requestAdapter();
110+
if (!adapter) throw new Error("no WebGPU adapter available");
111+
const device = await adapter.requestDevice();
112+
113+
const win = new Deno.BrowserWindow({ title: "Triangle", width: 640, height: 480 });
114+
115+
const surface = win.getNativeWindow();
116+
const format = navigator.gpu.getPreferredCanvasFormat();
117+
const context = surface.getContext("webgpu");
118+
context.configure({ device, format, alphaMode: "opaque" });
119+
120+
const [width, height] = win.getSize();
121+
surface.width = width;
122+
surface.height = height;
123+
124+
// The vertex stage emits three corners; the fragment stage receives the
125+
// color interpolated between them.
126+
const shader = device.createShaderModule({
127+
code: `
128+
struct VertexOut {
129+
@builtin(position) pos: vec4f,
130+
@location(0) color: vec3f,
131+
};
132+
133+
@vertex
134+
fn vs(@builtin(vertex_index) i: u32) -> VertexOut {
135+
var positions = array<vec2f, 3>(
136+
vec2f( 0.0, 0.6),
137+
vec2f(-0.6, -0.6),
138+
vec2f( 0.6, -0.6),
139+
);
140+
var colors = array<vec3f, 3>(
141+
vec3f(1.0, 0.0, 0.0),
142+
vec3f(0.0, 1.0, 0.0),
143+
vec3f(0.0, 0.0, 1.0),
144+
);
145+
var out: VertexOut;
146+
out.pos = vec4f(positions[i], 0.0, 1.0);
147+
out.color = colors[i];
148+
return out;
149+
}
150+
151+
@fragment
152+
fn fs(in: VertexOut) -> @location(0) vec4f {
153+
return vec4f(in.color, 1.0);
154+
}
155+
`,
156+
});
157+
158+
const pipeline = device.createRenderPipeline({
159+
layout: "auto",
160+
vertex: { module: shader, entryPoint: "vs" },
161+
fragment: { module: shader, entryPoint: "fs", targets: [{ format }] },
162+
primitive: { topology: "triangle-list" },
163+
});
164+
165+
const encoder = device.createCommandEncoder();
166+
const pass = encoder.beginRenderPass({
167+
colorAttachments: [{
168+
view: context.getCurrentTexture().createView(),
169+
clearValue: { r: 0.05, g: 0.05, b: 0.08, a: 1 },
170+
loadOp: "clear",
171+
storeOp: "store",
172+
}],
173+
});
174+
pass.setPipeline(pipeline);
175+
pass.draw(3);
176+
pass.end();
177+
device.queue.submit([encoder.finish()]);
178+
surface.present();
179+
```
180+
181+
## Animating with a render loop
182+
183+
For anything that moves, draw repeatedly. The raw backend has no DOM, so there
184+
is no `requestAnimationFrame` — schedule frames yourself. This example reuses the
185+
triangle pipeline and passes the elapsed time into the shader through a uniform
186+
buffer to spin it.
187+
188+
```ts title="spin.ts"
189+
const adapter = await navigator.gpu.requestAdapter();
190+
if (!adapter) throw new Error("no WebGPU adapter available");
191+
const device = await adapter.requestDevice();
192+
193+
const win = new Deno.BrowserWindow({ title: "Spin", width: 640, height: 480 });
194+
195+
const surface = win.getNativeWindow();
196+
const format = navigator.gpu.getPreferredCanvasFormat();
197+
const context = surface.getContext("webgpu");
198+
context.configure({ device, format, alphaMode: "opaque" });
199+
200+
// Keep the surface sized to the window, and reconfigure whenever it resizes.
201+
function resize() {
202+
const [width, height] = win.getSize();
203+
surface.width = width;
204+
surface.height = height;
205+
}
206+
resize();
207+
win.addEventListener("resize", resize);
208+
209+
const shader = device.createShaderModule({
210+
code: `
211+
@group(0) @binding(0) var<uniform> angle: f32;
212+
213+
struct VertexOut {
214+
@builtin(position) pos: vec4f,
215+
@location(0) color: vec3f,
216+
};
217+
218+
@vertex
219+
fn vs(@builtin(vertex_index) i: u32) -> VertexOut {
220+
var base = array<vec2f, 3>(
221+
vec2f( 0.0, 0.6),
222+
vec2f(-0.6, -0.6),
223+
vec2f( 0.6, -0.6),
224+
);
225+
var colors = array<vec3f, 3>(
226+
vec3f(1.0, 0.0, 0.0),
227+
vec3f(0.0, 1.0, 0.0),
228+
vec3f(0.0, 0.0, 1.0),
229+
);
230+
let s = sin(angle);
231+
let c = cos(angle);
232+
let p = base[i];
233+
var out: VertexOut;
234+
out.pos = vec4f(p.x * c - p.y * s, p.x * s + p.y * c, 0.0, 1.0);
235+
out.color = colors[i];
236+
return out;
237+
}
238+
239+
@fragment
240+
fn fs(in: VertexOut) -> @location(0) vec4f {
241+
return vec4f(in.color, 1.0);
242+
}
243+
`,
244+
});
245+
246+
const uniform = device.createBuffer({
247+
size: 4, // one f32
248+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
249+
});
250+
251+
const pipeline = device.createRenderPipeline({
252+
layout: "auto",
253+
vertex: { module: shader, entryPoint: "vs" },
254+
fragment: { module: shader, entryPoint: "fs", targets: [{ format }] },
255+
primitive: { topology: "triangle-list" },
256+
});
257+
258+
const bindGroup = device.createBindGroup({
259+
layout: pipeline.getBindGroupLayout(0),
260+
entries: [{ binding: 0, resource: { buffer: uniform } }],
261+
});
262+
263+
const start = performance.now();
264+
265+
function frame() {
266+
if (win.isClosed()) return;
267+
268+
const angle = (performance.now() - start) / 1000;
269+
device.queue.writeBuffer(uniform, 0, new Float32Array([angle]));
270+
271+
const encoder = device.createCommandEncoder();
272+
const pass = encoder.beginRenderPass({
273+
colorAttachments: [{
274+
view: context.getCurrentTexture().createView(),
275+
clearValue: { r: 0.05, g: 0.05, b: 0.08, a: 1 },
276+
loadOp: "clear",
277+
storeOp: "store",
278+
}],
279+
});
280+
pass.setPipeline(pipeline);
281+
pass.setBindGroup(0, bindGroup);
282+
pass.draw(3);
283+
pass.end();
284+
device.queue.submit([encoder.finish()]);
285+
surface.present();
286+
287+
setTimeout(frame, 16); // ~60 fps
288+
}
289+
290+
win.addEventListener("close", () => Deno.exit(0));
291+
frame();
292+
```
293+
294+
A self-scheduling `setTimeout` gives you a frame roughly every 16 ms. The
295+
`win.isClosed()` guard stops the loop once the window goes away, and the `close`
296+
listener exits the process; otherwise the pending timer would keep the runtime
297+
alive with nothing on screen.
298+
299+
## Key details
300+
301+
- **Request the adapter before wrapping the window.**
302+
[`getNativeWindow()`](/api/deno/~/Deno.BrowserWindow.prototype.getNativeWindow)
303+
needs an active WebGPU context and throws if you call it before
304+
[`navigator.gpu.requestAdapter()`](/api/web/~/GPU.prototype.requestAdapter).
305+
306+
- **Size the surface, and resize it.** Set `surface.width` / `surface.height`
307+
before the first frame, and update them (and let the context reconfigure)
308+
whenever the window's [`resize`](/runtime/desktop/windows/#events) event fires.
309+
A surface that doesn't match the window is stretched or clipped.
310+
311+
- **`present()` every frame.** Encoding and submitting a render pass draws into
312+
the swapchain texture; [`present()`](/api/deno/~/Deno.UnsafeWindowSurface.prototype.present)
313+
is what puts it on screen. Skip it and the window stays blank.
314+
315+
- **Get a fresh texture each frame.** Call
316+
`context.getCurrentTexture().createView()` inside the loop — the swapchain
317+
hands you a different texture per frame.
318+
319+
- **Closing is downgraded to hiding.** Once a surface has been taken from a
320+
window, [`close()`](/runtime/desktop/windows/#lifecycle) hides the window
321+
instead of destroying it, so the native handles WebGPU is rendering into are
322+
not freed underneath it. Call [`Deno.exit()`](/api/deno/~/Deno.exit) to end
323+
the process, as the render loop above does on the `close` event.
324+
325+
## Related
326+
327+
- [Backends](/runtime/desktop/backends/) — when to choose `raw` over `cef` /
328+
`webview`.
329+
- [Windows](/runtime/desktop/windows/)
330+
[`Deno.BrowserWindow`](/api/deno/~/Deno.BrowserWindow) lifecycle, sizing, and
331+
events.
332+
- [WebGPU API](/api/web/~/GPUDevice) and the
333+
[WGSL specification](https://www.w3.org/TR/WGSL/).

runtime/desktop/windows.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,9 @@ const context = surface.getContext("webgpu");
247247
Once a surface has been taken, `close()` is downgraded to `hide()` so the native
248248
handles backing the surface are not destroyed out from under WebGPU.
249249

250+
For a full walkthrough — configuring the context, drawing geometry, and running
251+
a render loop — see [WebGPU rendering](/runtime/desktop/webgpu/).
252+
250253
## DevTools
251254

252255
```ts

0 commit comments

Comments
 (0)