├── 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 |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 | }
--------------------------------------------------------------------------------