commit 90b28c6396d86f70f5f72fcbaad0dfb09bc7bdbb Author: Zack Buhman Date: Fri Aug 14 13:25:45 2026 -0500 initial diff --git a/common.js b/common.js new file mode 100644 index 0000000..4269dc8 --- /dev/null +++ b/common.js @@ -0,0 +1,11 @@ +async function getPathText(path) +{ + const response = await fetch(path); + if (!response.ok) { + throw new Error(`${path}: ${response.status}`) + } + const blob = await response.blob(); + return blob.text(); +} + +export { getPathText }; diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/index.html b/index.html new file mode 100644 index 0000000..11af36c --- /dev/null +++ b/index.html @@ -0,0 +1,23 @@ + + + + + + WebGPU + + + + + + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..987e13f --- /dev/null +++ b/index.js @@ -0,0 +1,140 @@ +import { loadTriangleRenderer } from "./triangle.js" +import { loadTriangleTexturedRenderer } from "./triangleTextured.js" + +if (!navigator.gpu) { + throw new Error("WebGPU not supported on this browser."); +} + +const adapter = await navigator.gpu.requestAdapter(); +if (!adapter) { + throw new Error("No WebGPU adapter"); +} + +// The next few lines, being nitpicky over bgra8unorm vs rgba8unorm is +// needed for using the canvas framebuffer as a storage texture, which +// is mostly only relevant in the specific case that you want to +// directly write to the canvas framebuffer from a compute shader. +// +// I very highly doubt this has any negative performance impact +// whatsoever on any device that has a WebGPU implementation, and +// certainly no desktop/laptop GPU is affected by this. Nevertheless, +// the WebGPU developers did deliberately decide to make +// 'bgra8unorm-storage' an optional feature, so perhaps there is some +// obscure device somewhere that actually cares about this. +const hasBgraStorage = adapter.features.has("bgra8unorm-storage"); +const device = await adapter.requestDevice({ + requiredFeatures: hasBgraStorage ? ["bgra8unorm-storage"] : [] +}); +const canvas = document.querySelector("canvas"); +const context = canvas.getContext("webgpu"); +const canvasFormat = hasBgraStorage ? navigator.gpu.getPreferredCanvasFormat() : "rgba8unorm"; +context.configure({ + device: device, + format: canvasFormat, + alphaMode: 'premultiplied', + usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT +}); + +// render targets, initialized in handleResize +var depthTexture = undefined; + +function handleResize(canvasTexture) +{ + // The internal color buffer returned by context.getCurrentTexture() + // is automatically resized with the canvas. All other render + // targets (e.g depth buffers) are not. + // + // If there were other off-screen buffers (deferred lighting, + // geometry buffers, etc..) those would also be recreated as needed here. + + const depthResized = depthTexture === undefined || canvasTexture.width != depthTexture.width || canvasTexture.height != depthTexture.height; + + if (depthResized) { + if (depthTexture !== undefined) { + depthTexture.destroy(); + } + + depthTexture = device.createTexture({ + format: 'depth24plus', + usage: GPUTextureUsage.RENDER_ATTACHMENT, + size: [canvasTexture.width, canvasTexture.height], + }); + } +} + +const triangleRenderer = await loadTriangleRenderer(device, canvasFormat); +const triangleTexturedRenderer = await loadTriangleTexturedRenderer(device, canvasFormat); + +function render() +{ + // `canvas.clientWidth` and `canvas.clientHeight` are the current + // size of the canvas element. The "canvas element" in this demo is + // sized by the `canvas` CSS rule, which scales the canvas element + // to 100% the width and height of the viewport. + // + // Web is a shit platform. The reported sizes are in "logical pixel" + // units, which are not the same size as the physical pixels of the + // device's screen. + // + // On the devices I've tested, multiplying clientWidth and + // clientHeight by window.devicePixelRatio does give the real + // dimensions of the canvas in physical screen pixels, and does + // result in sharper font rendering. + // + // On devices like Nico's phone though, devicePixelRatio + // multiplication results in ~12x more pixels being rendered per + // frame, which can be significantly slower for things like + // raymarching/raytracing. + // + // -- + // + // This causes the canvas color buffer to be resized to the canvas + // element as sized by CSS, including the browser window being + // resized. + canvas.width = canvas.clientWidth * window.devicePixelRatio; + canvas.height = canvas.clientHeight * window.devicePixelRatio; + + // possibly recreate any textures that depend on the size of the canvas + // changing. + const canvasTexture = context.getCurrentTexture(); + handleResize(canvasTexture); + + const clearValue = { r: 0.2, g: 0.2, b: 0.4, a: 1.0 }; + const colorAttachments = [ + { + view: canvasTexture.createView(), + loadOp: "clear", + clearValue: clearValue, + storeOp: "store", + } + ]; + + const encoder = device.createCommandEncoder(); + const renderPass = encoder.beginRenderPass({ + colorAttachments: colorAttachments, + depthStencilAttachment: { + view: depthTexture.createView(), + depthClearValue: 1.0, + depthLoadOp: 'clear', + depthStoreOp: 'store', + }, + }); + + // Every time you want to use a different shader, WebGPU also + // requires you use a different GPURenderPipeline. It seems like a + // decent pattern to wrap each collection of + // buffers/pipelines/shaders in its own object: + + // uncomment either of these to see the result of each render + // pipeline / shader + + //triangleRenderer.render(renderPass); + triangleTexturedRenderer.render(renderPass); + + renderPass.end(); + + const commandBuffer = encoder.finish(); + device.queue.submit([commandBuffer]); +} + +requestAnimationFrame(render); diff --git a/texture.png b/texture.png new file mode 100644 index 0000000..f067ece Binary files /dev/null and b/texture.png differ diff --git a/triangle.js b/triangle.js new file mode 100644 index 0000000..630cbd3 --- /dev/null +++ b/triangle.js @@ -0,0 +1,144 @@ +import { getPathText } from "./common.js" + +// Triangle vertices, one vertex per line. Hand-transcribed from a +// .obj file exported from Blender to this form. +// +// I've also written myriad scripts to do this automatically for +// larger models. +// +// Float32Array is a fairly convenient way to convert floating point +// literals to bytes. +const triangleVertices = new Float32Array([ + // [position ] [texture ] [normal ] + 0.0, 1.0, 0.0, 0.5, 1.0, 0.0, 1.0, 0.0, + 1.0, -1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, + -1.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, +]); + +class TriangleRenderer { + constructor(device, canvasFormat, wgsl) + { + const label = "TriangleRenderer"; + + ////////////////////////////////////////////////////////////////////// + // shader module + ////////////////////////////////////////////////////////////////////// + + const shaderModule = device.createShaderModule({ + label: `${label} shader`, + code: wgsl, + }); + + ////////////////////////////////////////////////////////////////////// + // pipeline + ////////////////////////////////////////////////////////////////////// + + // "bind group layouts" are a declaration of which `var` bindings + // exist in the shader programs loaded by this pipeline + // + // None are used in triangle.wgsl, so this list is empty. + const bindGroupLayouts = [ + ]; + + const pipelineLayout = device.createPipelineLayout({ + label: `${label} pipeline layout`, + bindGroupLayouts: bindGroupLayouts, + }); + + // vertexBufferLayouts describes the position of each element for + // each buffer. There is only one vertex buffer used in this demo, + // so there is only one vertex buffer layout in this list. + // + // shaderLocation matches the location numbers defined in + // triangle.wgsl. + // + // "offset" is the starting offset in bytes, 4 bytes per floating + // point number. This matches the arrangement in bytes of the + // triangleVertices Float32Array. + const arrayStride = (4 * 3) + (4 * 2) + (4 * 3); + const vertexBufferLayouts = [ + { + arrayStride: arrayStride, + attributes: [{ + format: "float32x3", + offset: 0, + shaderLocation: 0, + }, { + format: "float32x2", + offset: 12, + shaderLocation: 1, + }, { + format: "float32x3", + offset: 20, + shaderLocation: 2, + }], + }, + ]; + + this.renderPipeline = device.createRenderPipeline({ + label: `${label} render pipeline`, + layout: pipelineLayout, + vertex: { + module: shaderModule, + entryPoint: "vertexMain", + buffers: vertexBufferLayouts, + }, + fragment: { + module: shaderModule, + entryPoint: "fragmentMain", + targets: [{ + format: canvasFormat, + }] + }, + primitive: { + topology: "triangle-list", + }, + depthStencil: { + depthWriteEnabled: true, + depthCompare: "less", + format: "depth24plus", + }, + }); + + ////////////////////////////////////////////////////////////////////// + // bind group + ////////////////////////////////////////////////////////////////////// + + // there are no bind groups, because triangle.wgsl doesn't use any. + + ////////////////////////////////////////////////////////////////////// + // buffer + ////////////////////////////////////////////////////////////////////// + + this.vertexBuffer = device.createBuffer({ + label: `${label} vertex buffer`, + size: triangleVertices.byteLength, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + + // copy the (system RAM) triangleVertices to the (gpu RAM) vertexBuffer. + device.queue.writeBuffer(this.vertexBuffer, 0, triangleVertices); + } + + render(renderPass) + { + renderPass.setPipeline(this.renderPipeline); + renderPass.setVertexBuffer(0, this.vertexBuffer); + const vertexCount = 3; + renderPass.draw(vertexCount); + } +} + +// This "loadTriangleRenderer" function is separate from the +// constructor because Javascript class constructors can't be async +// functions. +async function loadTriangleRenderer(device, canvasFormat) +{ + const triangleWgsl = await getPathText("triangle.wgsl"); + + const renderer = new TriangleRenderer(device, canvasFormat, triangleWgsl); + + return renderer; +} + +export { loadTriangleRenderer }; diff --git a/triangle.wgsl b/triangle.wgsl new file mode 100644 index 0000000..06d2d33 --- /dev/null +++ b/triangle.wgsl @@ -0,0 +1,33 @@ +// The `VertexInput` location numbers match the numbers in +// `vertexBufferLayouts` +struct VertexInput { + @location(0) position: vec3f, + @location(1) texture: vec2f, + @location(2) normal: vec3f, +}; + +// location numbers for passing values from the vertex stage to the +// fragment stage are arbitrary--they just need to match. Because +// fragmentMain uses the same VertexOutput struct that vertexMain +// returns, they do match. +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) texture: vec2f, +}; + +@vertex +fn vertexMain(input: VertexInput) -> VertexOutput +{ + let position = vec4f(input.position * 0.5, 1.0); + + var output: VertexOutput; + output.position = position; + output.texture = input.texture; + return output; +} + +@fragment +fn fragmentMain(input: VertexOutput) -> @location(0) vec4f +{ + return vec4(input.texture, 0.0, 1.0); +} diff --git a/triangleTextured.js b/triangleTextured.js new file mode 100644 index 0000000..1b88934 --- /dev/null +++ b/triangleTextured.js @@ -0,0 +1,193 @@ +import { getPathText } from "./common.js" + +// Triangle vertices, one vertex per line. Hand-transcribed from a +// .obj file exported from Blender to this form. +// +// I've also written myriad scripts to do this automatically for +// larger models. +// +// Float32Array is a fairly convenient way to convert floating point +// literals to bytes. +const triangleVertices = new Float32Array([ + // [position ] [texture ] [normal ] + 0.0, 1.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, + 1.0, -1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, + -1.0, -1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, +]); + +class TriangleTexturedRenderer { + constructor(device, canvasFormat, wgsl, image) + { + const label = "TriangleRenderer"; + + ////////////////////////////////////////////////////////////////////// + // shader module + ////////////////////////////////////////////////////////////////////// + + const shaderModule = device.createShaderModule({ + label: `${label} shader`, + code: wgsl, + }); + + ////////////////////////////////////////////////////////////////////// + // pipeline + ////////////////////////////////////////////////////////////////////// + + // "bind group layouts" are a declaration of which `var` bindings + // exist in the shader programs loaded by this pipeline + const bindGroupLayouts = [ + device.createBindGroupLayout({ + label: "gltf bind group layout 0", + entries: [ + { + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + sampler: {}, + }, + { + binding: 1, + visibility: GPUShaderStage.FRAGMENT, + texture: {}, + } + ] + }), + ]; + + const pipelineLayout = device.createPipelineLayout({ + label: `${label} pipeline layout`, + bindGroupLayouts: bindGroupLayouts, + }); + + // vertexBufferLayouts describes the position of each element for + // each buffer. There is only one vertex buffer used in this demo, + // so there is only one vertex buffer layout in this list. + // + // shaderLocation matches the location numbers defined in + // triangle.wgsl. + // + // "offset" is the starting offset in bytes, 4 bytes per floating + // point number. This matches the arrangement in bytes of the + // triangleVertices Float32Array. + const arrayStride = (4 * 3) + (4 * 2) + (4 * 3); + const vertexBufferLayouts = [ + { + arrayStride: arrayStride, + attributes: [{ + format: "float32x3", + offset: 0, + shaderLocation: 0, + }, { + format: "float32x2", + offset: 12, + shaderLocation: 1, + }, { + format: "float32x3", + offset: 20, + shaderLocation: 2, + }], + }, + ]; + + this.renderPipeline = device.createRenderPipeline({ + label: `${label} render pipeline`, + layout: pipelineLayout, + vertex: { + module: shaderModule, + entryPoint: "vertexMain", + buffers: vertexBufferLayouts, + }, + fragment: { + module: shaderModule, + entryPoint: "fragmentMain", + targets: [{ + format: canvasFormat, + }] + }, + primitive: { + topology: "triangle-list", + }, + depthStencil: { + depthWriteEnabled: true, + depthCompare: "less", + format: "depth24plus", + }, + }); + + ////////////////////////////////////////////////////////////////////// + // samplers and textures + ////////////////////////////////////////////////////////////////////// + + // default sampler configuration + this.sampler = device.createSampler(); + + this.texture = device.createTexture({ + size: [image.width, image.height], + format: 'rgba8unorm', + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + + device.queue.copyExternalImageToTexture( + { source: image }, + { texture: this.texture }, + { width: image.width, height: image.height }, + ); + + ////////////////////////////////////////////////////////////////////// + // bind group + ////////////////////////////////////////////////////////////////////// + + this.bindGroups = [ + device.createBindGroup({ + label: "${label} bind group", + layout: bindGroupLayouts[0], + entries: [{ + binding: 0, + resource: this.sampler, + }, { + binding: 1, + resource: this.texture, + }], + }), + ]; + + ////////////////////////////////////////////////////////////////////// + // buffer + ////////////////////////////////////////////////////////////////////// + + this.vertexBuffer = device.createBuffer({ + label: `${label} vertex buffer`, + size: triangleVertices.byteLength, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + + // copy the (system RAM) triangleVertices to the (gpu RAM) vertexBuffer. + device.queue.writeBuffer(this.vertexBuffer, 0, triangleVertices); + } + + render(renderPass) + { + renderPass.setPipeline(this.renderPipeline); + renderPass.setVertexBuffer(0, this.vertexBuffer); + renderPass.setBindGroup(0, this.bindGroups[0]); + const vertexCount = 3; + renderPass.draw(vertexCount); + } +} + +// This "loadTriangleRenderer" function is separate from the +// constructor because Javascript class constructors can't be async +// functions. +async function loadTriangleTexturedRenderer(device, canvasFormat) +{ + const triangleWgsl = await getPathText("triangleTextured.wgsl"); + + const image = new Image(); + image.src = "texture.png"; + await image.decode(); + + const renderer = new TriangleTexturedRenderer(device, canvasFormat, triangleWgsl, image); + + return renderer; +} + +export { loadTriangleTexturedRenderer }; diff --git a/triangleTextured.wgsl b/triangleTextured.wgsl new file mode 100644 index 0000000..a1754c3 --- /dev/null +++ b/triangleTextured.wgsl @@ -0,0 +1,43 @@ +// The `var` group() numbers match the ordering of the layouts in the +// `bindGroupLayouts` array, `0` being the 0th index of that array. +// +// The `var` binding() numbers match the numbers of the entries in +// each layout in `bindGroupLayouts`. +@group(0) @binding(0) var Sampler: sampler; +@group(0) @binding(1) var Texture: texture_2d; + +// The `VertexInput` location numbers match the numbers in +// `vertexBufferLayouts` +struct VertexInput { + @location(0) position: vec3f, + @location(1) texture: vec2f, + @location(2) normal: vec3f, +}; + +// location numbers for passing values from the vertex stage to the +// fragment stage are arbitrary--they just need to match. Because +// fragmentMain uses the same VertexOutput struct that vertexMain +// returns, they do match. +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) texture: vec2f, +}; + +@vertex +fn vertexMain(input: VertexInput) -> VertexOutput +{ + let position = vec4f(input.position * 0.5, 1.0); + + var output: VertexOutput; + output.position = position; + output.texture = input.texture; + return output; +} + +@fragment +fn fragmentMain(input: VertexOutput) -> @location(0) vec4f +{ + let color = textureSample(Texture, Sampler, input.texture); + + return vec4(color.xyz, 1.0); +}