|
| 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