Skip to content

Commit 820a221

Browse files
crowlKatsclaude
andauthored
docs: add desktop raw backend WebGPU rendering example (#3396)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 35442a5 commit 820a221

5 files changed

Lines changed: 353 additions & 4 deletions

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

0 commit comments

Comments
 (0)