This commit is contained in:
Zack Buhman 2026-08-14 13:25:45 -05:00
commit 90b28c6396
9 changed files with 587 additions and 0 deletions

11
common.js Normal file
View File

@ -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 };

0
favicon.ico Normal file
View File

23
index.html Normal file
View File

@ -0,0 +1,23 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGPU</title>
<style>
canvas {
position: absolute;
left: 0;
top: 0;
padding: 0;
border: 0;
width: 100%;
height: 100%;
}
</style>
<script src="index.js" type="module"></script>
</head>
<body>
<canvas></canvas>
</body>
</html>

140
index.js Normal file
View File

@ -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);

BIN
texture.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

144
triangle.js Normal file
View File

@ -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 };

33
triangle.wgsl Normal file
View File

@ -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);
}

193
triangleTextured.js Normal file
View File

@ -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 };

43
triangleTextured.wgsl Normal file
View File

@ -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<f32>;
// 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);
}