├── README.md ├── LICENSE ├── index.html └── tiny-webgpu-demo.js /README.md: -------------------------------------------------------------------------------- 1 | # Pristine Grid WebGPU 2 | 3 | A simple WebGPU implementation of the "Pristine Grid" technique described in this wonderful little blog post: https://bgolus.medium.com/the-best-darn-grid-shader-yet-727f9278b9d8 4 | 5 | Nothing fancy to see here, just a very direct port of the shader to WGSL and a minimal render loop to display it. 6 | 7 | [Live demo here](https://toji.github.io/pristine-grid-webgpu/) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Brandon Jones 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Pristine Grid - WebGPU 11 | 12 | 13 | 14 | 251 | 252 | 253 | -------------------------------------------------------------------------------- /tiny-webgpu-demo.js: -------------------------------------------------------------------------------- 1 | // This file contains the necessary structure for a minimalistic WebGPU demo app. 2 | // It uses dat.gui to offer a basic options panel and stats.js to display performance. 3 | 4 | import { vec3, mat4 } from 'https://cdn.jsdelivr.net/npm/gl-matrix@3.4.3/esm/index.js'; 5 | 6 | import { Pane } from 'https://cdn.jsdelivr.net/npm/tweakpane@4.0.1/dist/tweakpane.min.js'; 7 | 8 | // Style for elements used by the demo. 9 | const injectedStyle = document.createElement('style'); 10 | injectedStyle.innerText = ` 11 | html, body { 12 | height: 100%; 13 | margin: 0; 14 | font-family: sans-serif; 15 | } 16 | 17 | body { 18 | height: 100%; 19 | background-color: #222222; 20 | } 21 | 22 | canvas { 23 | position: absolute; 24 | z-index: 0; 25 | height: 100%; 26 | width: 100%; 27 | inset: 0; 28 | margin: 0; 29 | touch-action: none; 30 | } 31 | 32 | .error { 33 | position: absolute; 34 | z-index: 2; 35 | inset: 9em 3em; 36 | margin: 0; 37 | padding: 0; 38 | color: #FF8888; 39 | } 40 | 41 | .tp-dfwv { 42 | z-index: 3; 43 | width: 290px !important; 44 | } 45 | `; 46 | document.head.appendChild(injectedStyle); 47 | 48 | const FRAME_BUFFER_SIZE = Float32Array.BYTES_PER_ELEMENT * 36; 49 | 50 | export class TinyWebGpuDemo { 51 | #frameArrayBuffer = new ArrayBuffer(FRAME_BUFFER_SIZE); 52 | #projectionMatrix = new Float32Array(this.#frameArrayBuffer, 0, 16); 53 | #viewMatrix = new Float32Array(this.#frameArrayBuffer, 16 * Float32Array.BYTES_PER_ELEMENT, 16); 54 | #cameraPosition = new Float32Array(this.#frameArrayBuffer, 32 * Float32Array.BYTES_PER_ELEMENT, 3); 55 | #timeArray = new Float32Array(this.#frameArrayBuffer, 35 * Float32Array.BYTES_PER_ELEMENT, 1); 56 | 57 | static CAMERA_UNIFORM_STRUCT = ` 58 | struct CameraUniforms { 59 | projection: mat4x4f, 60 | view: mat4x4f, 61 | position: vec3f, 62 | time: f32, 63 | } 64 | `; 65 | 66 | #frameMs = new Array(20); 67 | #frameMsIndex = 0; 68 | 69 | // Configurable by extending classes 70 | colorFormat = navigator.gpu?.getPreferredCanvasFormat?.() || 'bgra8unorm'; 71 | depthFormat = 'depth24plus'; 72 | sampleCount = 4; 73 | clearColor = {r: 0, g: 0, b: 0, a: 1.0}; 74 | fov = Math.PI * 0.5; 75 | zNear = 0.01; 76 | zFar = 128; 77 | 78 | constructor() { 79 | this.canvas = document.querySelector('.webgpu-canvas'); 80 | 81 | if (!this.canvas) { 82 | this.canvas = document.createElement('canvas'); 83 | document.body.appendChild(this.canvas); 84 | } 85 | this.context = this.canvas.getContext('webgpu'); 86 | 87 | this.pane = new Pane({ 88 | title: document.title.split('|')[0], 89 | }); 90 | 91 | this.camera = new OrbitCamera(this.canvas); 92 | 93 | this.resizeObserver = new ResizeObserverHelper(this.canvas, (width, height) => { 94 | if (width == 0 || height == 0) { return; } 95 | 96 | this.canvas.width = width; 97 | this.canvas.height = height; 98 | 99 | this.updateProjection(); 100 | 101 | if (this.device) { 102 | const size = {width, height}; 103 | this.#allocateRenderTargets(size); 104 | this.onResize(this.device, size); 105 | } 106 | }); 107 | 108 | const frameCallback = (t) => { 109 | requestAnimationFrame(frameCallback); 110 | 111 | const frameStart = performance.now(); 112 | 113 | // Update the frame uniforms 114 | this.#viewMatrix.set(this.camera.viewMatrix); 115 | this.#cameraPosition.set(this.camera.position); 116 | this.#timeArray[0] = t; 117 | 118 | this.device.queue.writeBuffer(this.frameUniformBuffer, 0, this.#frameArrayBuffer); 119 | 120 | this.onFrame(this.device, this.context, t); 121 | 122 | this.#frameMs[this.#frameMsIndex++ % this.#frameMs.length] = performance.now() - frameStart; 123 | }; 124 | 125 | this.#initWebGPU().then(() => { 126 | // Make sure the resize callback has a chance to fire at least once now that the device is 127 | // initialized. 128 | this.resizeObserver.callback(this.canvas.width, this.canvas.height); 129 | // Start the render loop. 130 | requestAnimationFrame(frameCallback); 131 | }).catch((error) => { 132 | // If something goes wrong during initialization, put up a really simple error message. 133 | this.setError(error, 'initializing WebGPU'); 134 | throw error; 135 | }); 136 | } 137 | 138 | setError(error, contextString) { 139 | let prevError = document.querySelector('.error'); 140 | while (prevError) { 141 | this.canvas.parentElement.removeChild(document.querySelector('.error')); 142 | prevError = document.querySelector('.error'); 143 | } 144 | 145 | if (error) { 146 | const errorElement = document.createElement('p'); 147 | errorElement.classList.add('error'); 148 | errorElement.innerHTML = ` 149 |

An error occured${contextString ? ' while ' + contextString : ''}:

150 |
${error?.message ? error.message : error}
`; 151 | this.canvas.parentElement.appendChild(errorElement); 152 | } 153 | } 154 | 155 | updateProjection() { 156 | const aspect = this.canvas.width / this.canvas.height; 157 | // Using mat4.perspectiveZO instead of mat4.perpective because WebGPU's 158 | // normalized device coordinates Z range is [0, 1], instead of WebGL's [-1, 1] 159 | mat4.perspectiveZO(this.#projectionMatrix, this.fov, aspect, this.zNear, this.zFar); 160 | } 161 | 162 | get frameMs() { 163 | let avg = 0; 164 | for (const value of this.#frameMs) { 165 | if (value === undefined) { return 0; } // Don't have enough sampled yet 166 | avg += value; 167 | } 168 | return avg / this.#frameMs.length; 169 | } 170 | 171 | async #initWebGPU() { 172 | const adapter = await navigator.gpu.requestAdapter();`` 173 | 174 | const requiredFeatures = []; 175 | const featureList = adapter.features; 176 | if (featureList.has('texture-compression-bc')) { 177 | requiredFeatures.push('texture-compression-bc'); 178 | } 179 | if (featureList.has('texture-compression-etc2')) { 180 | requiredFeatures.push('texture-compression-etc2'); 181 | } 182 | 183 | this.device = await adapter.requestDevice({ 184 | requiredFeatures, 185 | }); 186 | this.context.configure({ 187 | device: this.device, 188 | format: this.colorFormat, 189 | alphaMode: 'opaque', 190 | viewFormats: [`${this.colorFormat}-srgb`] 191 | }); 192 | 193 | this.frameUniformBuffer = this.device.createBuffer({ 194 | size: FRAME_BUFFER_SIZE, 195 | usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, 196 | }); 197 | 198 | this.frameBindGroupLayout = this.device.createBindGroupLayout({ 199 | label: `Frame BindGroupLayout`, 200 | entries: [{ 201 | binding: 0, // Camera/Frame uniforms 202 | visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, 203 | buffer: {}, 204 | }], 205 | }); 206 | 207 | this.frameBindGroup = this.device.createBindGroup({ 208 | label: `Frame BindGroup`, 209 | layout: this.frameBindGroupLayout, 210 | entries: [{ 211 | binding: 0, // Camera uniforms 212 | resource: { buffer: this.frameUniformBuffer }, 213 | }], 214 | }); 215 | 216 | this.statsFolder = this.pane.addFolder({ 217 | title: 'Stats', 218 | expanded: false, 219 | }); 220 | this.statsFolder.addBinding(this, 'frameMs', { 221 | readonly: true, 222 | view: 'graph', 223 | min: 0, 224 | max: 2 225 | }); 226 | 227 | await this.onInit(this.device); 228 | } 229 | 230 | #allocateRenderTargets(size) { 231 | if (this.msaaColorTexture) { 232 | this.msaaColorTexture.destroy(); 233 | } 234 | 235 | if (this.sampleCount > 1) { 236 | this.msaaColorTexture = this.device.createTexture({ 237 | size, 238 | sampleCount: this.sampleCount, 239 | format: `${this.colorFormat}-srgb`, 240 | usage: GPUTextureUsage.RENDER_ATTACHMENT, 241 | }); 242 | } 243 | 244 | if (this.depthTexture) { 245 | this.depthTexture.destroy(); 246 | } 247 | 248 | this.depthTexture = this.device.createTexture({ 249 | size, 250 | sampleCount: this.sampleCount, 251 | format: this.depthFormat, 252 | usage: GPUTextureUsage.RENDER_ATTACHMENT, 253 | }); 254 | 255 | this.colorAttachment = { 256 | // Appropriate target will be populated in onFrame 257 | view: this.sampleCount > 1 ? this.msaaColorTexture.createView() : undefined, 258 | resolveTarget: undefined, 259 | 260 | clearValue: this.clearColor, 261 | loadOp: 'clear', 262 | storeOp: this.sampleCount > 1 ? 'discard' : 'store', 263 | }; 264 | 265 | this.renderPassDescriptor = { 266 | colorAttachments: [this.colorAttachment], 267 | depthStencilAttachment: { 268 | view: this.depthTexture.createView(), 269 | depthClearValue: 1.0, 270 | depthLoadOp: 'clear', 271 | depthStoreOp: 'discard', 272 | } 273 | }; 274 | } 275 | 276 | get defaultRenderPassDescriptor() { 277 | const colorTexture = this.context.getCurrentTexture().createView({ format: `${this.colorFormat}-srgb` }); 278 | if (this.sampleCount > 1) { 279 | this.colorAttachment.resolveTarget = colorTexture; 280 | } else { 281 | this.colorAttachment.view = colorTexture; 282 | } 283 | return this.renderPassDescriptor; 284 | } 285 | 286 | async onInit(device) { 287 | // Override to handle initialization logic 288 | } 289 | 290 | onResize(device, size) { 291 | // Override to handle resizing logic 292 | } 293 | 294 | onFrame(device, context, timestamp) { 295 | // Override to handle frame logic 296 | } 297 | } 298 | 299 | class ResizeObserverHelper extends ResizeObserver { 300 | constructor(element, callback) { 301 | super(entries => { 302 | for (let entry of entries) { 303 | if (entry.target != element) { continue; } 304 | 305 | if (entry.devicePixelContentBoxSize) { 306 | // Should give exact pixel dimensions, but only works on Chrome. 307 | const devicePixelSize = entry.devicePixelContentBoxSize[0]; 308 | callback(devicePixelSize.inlineSize, devicePixelSize.blockSize); 309 | } else if (entry.contentBoxSize) { 310 | // Firefox implements `contentBoxSize` as a single content rect, rather than an array 311 | const contentBoxSize = Array.isArray(entry.contentBoxSize) ? entry.contentBoxSize[0] : entry.contentBoxSize; 312 | callback(contentBoxSize.inlineSize, contentBoxSize.blockSize); 313 | } else { 314 | callback(entry.contentRect.width, entry.contentRect.height); 315 | } 316 | } 317 | }); 318 | 319 | this.element = element; 320 | this.callback = callback; 321 | 322 | this.observe(element); 323 | } 324 | } 325 | 326 | export class OrbitCamera { 327 | orbitX = 0; 328 | orbitY = 0; 329 | maxOrbitX = Math.PI * 0.5; 330 | minOrbitX = -Math.PI * 0.5; 331 | maxOrbitY = Math.PI; 332 | minOrbitY = -Math.PI; 333 | constrainXOrbit = true; 334 | constrainYOrbit = false; 335 | 336 | maxDistance = 10; 337 | minDistance = 1; 338 | distanceStep = 0.005; 339 | constrainDistance = true; 340 | 341 | #distance = vec3.fromValues(0, 0, 1); 342 | #target = vec3.create(); 343 | #viewMat = mat4.create(); 344 | #cameraMat = mat4.create(); 345 | #position = vec3.create(); 346 | #dirty = true; 347 | 348 | #element; 349 | #registerElement; 350 | 351 | constructor(element = null) { 352 | let moving = false; 353 | let lastX, lastY; 354 | 355 | const downCallback = (event) => { 356 | if (event.isPrimary) { 357 | moving = true; 358 | } 359 | lastX = event.pageX; 360 | lastY = event.pageY; 361 | }; 362 | const moveCallback = (event) => { 363 | let xDelta, yDelta; 364 | 365 | if(document.pointerLockEnabled) { 366 | xDelta = event.movementX; 367 | yDelta = event.movementY; 368 | this.orbit(xDelta * 0.025, yDelta * 0.025); 369 | } else if (moving) { 370 | xDelta = event.pageX - lastX; 371 | yDelta = event.pageY - lastY; 372 | lastX = event.pageX; 373 | lastY = event.pageY; 374 | this.orbit(xDelta * 0.025, yDelta * 0.025); 375 | } 376 | }; 377 | const upCallback = (event) => { 378 | if (event.isPrimary) { 379 | moving = false; 380 | } 381 | }; 382 | const wheelCallback = (event) => { 383 | this.distance = this.#distance[2] + (-event.wheelDeltaY * this.distanceStep); 384 | event.preventDefault(); 385 | }; 386 | 387 | this.#registerElement = (value) => { 388 | if (this.#element && this.#element != value) { 389 | this.#element.removeEventListener('pointerdown', downCallback); 390 | this.#element.removeEventListener('pointermove', moveCallback); 391 | this.#element.removeEventListener('pointerup', upCallback); 392 | this.#element.removeEventListener('mousewheel', wheelCallback); 393 | } 394 | 395 | this.#element = value; 396 | if (this.#element) { 397 | this.#element.addEventListener('pointerdown', downCallback); 398 | this.#element.addEventListener('pointermove', moveCallback); 399 | this.#element.addEventListener('pointerup', upCallback); 400 | this.#element.addEventListener('mousewheel', wheelCallback); 401 | } 402 | } 403 | 404 | this.#element = element; 405 | this.#registerElement(element); 406 | } 407 | 408 | set element(value) { 409 | this.#registerElement(value); 410 | } 411 | 412 | get element() { 413 | return this.#element; 414 | } 415 | 416 | orbit(xDelta, yDelta) { 417 | if(xDelta || yDelta) { 418 | this.orbitY += xDelta; 419 | if(this.constrainYOrbit) { 420 | this.orbitY = Math.min(Math.max(this.orbitY, this.minOrbitY), this.maxOrbitY); 421 | } else { 422 | while (this.orbitY < -Math.PI) { 423 | this.orbitY += Math.PI * 2; 424 | } 425 | while (this.orbitY >= Math.PI) { 426 | this.orbitY -= Math.PI * 2; 427 | } 428 | } 429 | 430 | this.orbitX += yDelta; 431 | if(this.constrainXOrbit) { 432 | this.orbitX = Math.min(Math.max(this.orbitX, this.minOrbitX), this.maxOrbitX); 433 | } else { 434 | while (this.orbitX < -Math.PI) { 435 | this.orbitX += Math.PI * 2; 436 | } 437 | while (this.orbitX >= Math.PI) { 438 | this.orbitX -= Math.PI * 2; 439 | } 440 | } 441 | 442 | this.#dirty = true; 443 | } 444 | } 445 | 446 | get target() { 447 | return [this.#target[0], this.#target[1], this.#target[2]]; 448 | } 449 | 450 | set target(value) { 451 | this.#target[0] = value[0]; 452 | this.#target[1] = value[1]; 453 | this.#target[2] = value[2]; 454 | this.#dirty = true; 455 | }; 456 | 457 | get distance() { 458 | return this.#distance[2]; 459 | }; 460 | 461 | set distance(value) { 462 | this.#distance[2] = value; 463 | if(this.constrainDistance) { 464 | this.#distance[2] = Math.min(Math.max(this.#distance[2], this.minDistance), this.maxDistance); 465 | } 466 | this.#dirty = true; 467 | }; 468 | 469 | #updateMatrices() { 470 | if (this.#dirty) { 471 | var mv = this.#cameraMat; 472 | mat4.identity(mv); 473 | 474 | mat4.translate(mv, mv, this.#target); 475 | mat4.rotateY(mv, mv, -this.orbitY); 476 | mat4.rotateX(mv, mv, -this.orbitX); 477 | mat4.translate(mv, mv, this.#distance); 478 | mat4.invert(this.#viewMat, this.#cameraMat); 479 | 480 | this.#dirty = false; 481 | } 482 | } 483 | 484 | get position() { 485 | this.#updateMatrices(); 486 | vec3.set(this.#position, 0, 0, 0); 487 | vec3.transformMat4(this.#position, this.#position, this.#cameraMat); 488 | return this.#position; 489 | } 490 | 491 | get viewMatrix() { 492 | this.#updateMatrices(); 493 | return this.#viewMat; 494 | } 495 | } --------------------------------------------------------------------------------