|
| 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/). |
0 commit comments