Skip to content

Instantly share code, notes, and snippets.

@greggman
Last active August 15, 2025 17:38
Show Gist options
  • Save greggman/ac04b72311bae6ad22ebb4672b597ae4 to your computer and use it in GitHub Desktop.
Save greggman/ac04b72311bae6ad22ebb4672b597ae4 to your computer and use it in GitHub Desktop.
WebGPU Cube with different indices for position, normal, uvs by using storage buffers

WebGPU Cube with different indices for position, normal, uvs by using storage buffers

view on jsgist

html, body { margin: 0; height: 100% }
canvas { width: 100%; height: 100%; display: block; }
#fail {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: red;
color: white;
font-weight: bold;
font-family: monospace;
font-size: 16pt;
text-align: center;
}
<canvas></canvas>
<div id="fail" style="display: none">
<div class="content"></div>
</div>
// WebGPU Cube
/* global GPUBufferUsage */
/* global GPUTextureUsage */
import {vec3, mat4} from 'https://webgpufundamentals.org/3rdparty/wgpu-matrix.module.js';
const range = (num, fn) => new Array(num).fill(0).map((_, i) => fn(i));
async function main() {
const adapter = await navigator.gpu?.requestAdapter();
const device = await adapter?.requestDevice();
if (!device) {
fail('need webgpu');
return;
}
device.addEventListener('uncapturederror', e => console.error(e.error.message));
const canvas = document.querySelector('canvas');
const context = canvas.getContext('webgpu');
const presentationFormat = navigator.gpu.getPreferredCanvasFormat(adapter);
context.configure({
alphaMode: "opaque",
format: presentationFormat,
device,
});
const shaderSrc = `
struct Attribute {
dataOffset: u32,
indexOffset: u32,
};
struct VSUniforms {
worldViewProjection: mat4x4f,
worldInverseTranspose: mat4x4f,
@align(16) positionAttr: Attribute,
@align(16) normalAttr: Attribute,
@align(16) texcoordAttr: Attribute,
};
@group(0) @binding(0) var<uniform> vsUniforms: VSUniforms;
@group(0) @binding(1) var<storage, read> vertexData: array<f32>;
@group(0) @binding(2) var<storage, read> indexData: array<u32>;
fn getVec3f(attr: Attribute, ndx: u32) -> vec3f {
let dataNdx = indexData[attr.indexOffset + ndx];
let off = attr.dataOffset + dataNdx * 3;
return vec3f(
vertexData[off + 0],
vertexData[off + 1],
vertexData[off + 2],
);
}
fn getVec2f(attr: Attribute, ndx: u32) -> vec2f {
let dataNdx = indexData[attr.indexOffset + ndx];
let off = attr.dataOffset + dataNdx * 2;
return vec2f(
vertexData[off + 0],
vertexData[off + 1],
);
}
struct MyVSOutput {
@builtin(position) position: vec4f,
@location(0) normal: vec3f,
@location(1) texcoord: vec2f,
};
@vertex
fn myVSMain(@builtin(vertex_index) vNdx: u32) -> MyVSOutput {
var vsOut: MyVSOutput;
let position = getVec3f(vsUniforms.positionAttr, vNdx);
let normal = getVec3f(vsUniforms.normalAttr, vNdx);
let texcoord = getVec2f(vsUniforms.texcoordAttr, vNdx);
vsOut.position = vsUniforms.worldViewProjection * vec4f(position, 1);
vsOut.normal = (vsUniforms.worldInverseTranspose * vec4f(normal, 0)).xyz;
vsOut.texcoord = texcoord;
return vsOut;
}
struct FSUniforms {
lightDirection: vec3f,
};
@group(0) @binding(3) var<uniform> fsUniforms: FSUniforms;
@group(0) @binding(4) var diffuseSampler: sampler;
@group(0) @binding(5) var diffuseTexture: texture_2d<f32>;
@fragment
fn myFSMain(v: MyVSOutput) -> @location(0) vec4f {
var diffuseColor = textureSample(diffuseTexture, diffuseSampler, v.texcoord);
var a_normal = normalize(v.normal);
var l = dot(a_normal, fsUniforms.lightDirection) * 0.5 + 0.5;
return vec4f(diffuseColor.rgb * l, diffuseColor.a);
}
`;
const shaderModule = device.createShaderModule({code: shaderSrc});
const vUniformBufferSize = 2 * 16 * 4 + 3 * 16; // 2 mat4s * 16 floats per mat * 4 bytes per float + 3 Attribute structures
const fUniformBufferSize = 3 * 4 + 4; // 1 vec3 * 3 floats per vec3 * 4 bytes per float + pad
const vsUniformBuffer = device.createBuffer({
size: vUniformBufferSize,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const fsUniformBuffer = device.createBuffer({
size: fUniformBufferSize,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const vsUniformValuesF32 = new Float32Array(vUniformBufferSize / 4); // 2 mat4s
const vsUniformValuesU32 = new Uint32Array(vsUniformValuesF32.buffer);
const worldViewProjection = vsUniformValuesF32.subarray(0, 16);
const worldInverseTranspose = vsUniformValuesF32.subarray(16, 32);
const positionAttr = vsUniformValuesU32.subarray(32, 34);
const normalAttr = vsUniformValuesU32.subarray(36, 38);
const texcoordAttr = vsUniformValuesU32.subarray(40, 42);
const fsUniformValues = new Float32Array(fUniformBufferSize / 4); // 1 vec3
const lightDirection = fsUniformValues.subarray(0, 3);
function createBuffer(device, data, usage) {
const buffer = device.createBuffer({
size: data.byteLength,
usage,
mappedAtCreation: true,
});
const dst = new data.constructor(buffer.getMappedRange());
dst.set(data);
buffer.unmap();
return buffer;
}
const CUBE_FACE_INDICES = [
[3, 7, 5, 1], // right
[6, 2, 0, 4], // left
[6, 7, 3, 2], // ??
[0, 1, 5, 4], // ??
[7, 6, 4, 5], // front
[2, 3, 1, 0], // back
];
const cornerVertices = [
[-1, -1, -1],
[+1, -1, -1],
[-1, +1, -1],
[+1, +1, -1],
[-1, -1, +1],
[+1, -1, +1],
[-1, +1, +1],
[+1, +1, +1],
];
const faceNormals = [
[+1, +0, +0],
[-1, +0, +0],
[+0, +1, +0],
[+0, -1, +0],
[+0, +0, +1],
[+0, +0, -1],
];
const uvCoords = [
[1, 0],
[0, 0],
[0, 1],
[1, 1],
];
// turn each quad of the cube into 2 triangles
const positionIndices = CUBE_FACE_INDICES.map(i => [
i[0], i[1], i[2],
i[0], i[2], i[3],
]).flat();
// turn each face normal into vertex normals for 2 triangls
const normalIndices = range(6, f => [
f, f, f,
f, f, f,
]).flat();
// repeat the uv indices for each face
const uvIndices = range(6, _ => [0, 1, 2, 0, 2, 3]).flat();
const positions = cornerVertices.flat();
const normals = faceNormals.flat();
const uvs = uvCoords.flat();
const vertexData = new Float32Array([
...positions,
...normals,
...uvs,
]);
const indexData = new Uint32Array([
...positionIndices,
...normalIndices,
...uvIndices,
]);
const numIndices = positionIndices.length;
console.log('position indices:', positionIndices);
console.log('normal indices:', normalIndices);
console.log('uv indices', uvIndices);
const vertexBuffer = createBuffer(device, vertexData, GPUBufferUsage.STORAGE);
const indexBuffer = createBuffer(device, indexData, GPUBufferUsage.STORAGE);
// set the attribute offsets
positionAttr.set([0, 0]); // dataOffset, indexOffset
normalAttr.set([positions.length, positionIndices.length]), // dataOffset, indexOffset
texcoordAttr.set([positions.length + normals.length, positionIndices.length + normalIndices.length]); // dataOffset, indexOffset
const tex = device.createTexture({
size: [2, 2, 1],
format: 'rgba8unorm',
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST,
});
device.queue.writeTexture(
{ texture: tex },
new Uint8Array([
255, 255, 128, 255,
128, 255, 255, 255,
255, 128, 255, 255,
255, 128, 128, 255,
]),
{ bytesPerRow: 8, rowsPerImage: 2 },
{ width: 2, height: 2 },
);
const sampler = device.createSampler({
magFilter: 'nearest',
minFilter: 'nearest',
});
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: {
module: shaderModule,
},
fragment: {
module: shaderModule,
targets: [
{format: presentationFormat},
],
},
primitive: {
topology: 'triangle-list',
cullMode: 'back',
},
depthStencil: {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus',
},
});
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: vsUniformBuffer } },
{ binding: 1, resource: { buffer: vertexBuffer } },
{ binding: 2, resource: { buffer: indexBuffer } },
{ binding: 3, resource: { buffer: fsUniformBuffer } },
{ binding: 4, resource: sampler },
{ binding: 5, resource: tex.createView() },
],
});
const renderPassDescriptor = {
colorAttachments: [
{
// view: undefined, // Assigned later
clearValue: [0.5, 0.5, 0.5, 1],
loadOp: 'clear',
storeOp: 'store',
},
],
depthStencilAttachment: {
// view: undefined, // Assigned later
depthClearValue: 1.0,
depthLoadOp: 'clear',
depthStoreOp: 'store',
},
};
function resizeToDisplaySize(device, canvas) {
const width = Math.min(device.limits.maxTextureDimension2D, canvas.clientWidth);
const height = Math.min(device.limits.maxTextureDimension2D, canvas.clientHeight);
const needResize = width !== canvas.width || height !== canvas.height;
if (needResize) {
canvas.width = width;
canvas.height = height;
}
return needResize;
}
let depthTexture;
function render(time) {
time *= 0.001;
resizeToDisplaySize(device, canvas);
const projection = mat4.perspective(30 * Math.PI / 180, canvas.clientWidth / canvas.clientHeight, 0.5, 10);
const eye = [1, 4, -6];
const target = [0, 0, 0];
const up = [0, 1, 0];
const view = mat4.lookAt(eye, target, up);
const viewProjection = mat4.multiply(projection, view);
const world = mat4.rotationY(time);
mat4.transpose(mat4.inverse(world), worldInverseTranspose);
mat4.multiply(viewProjection, world, worldViewProjection);
vec3.normalize([1, 8, -10], lightDirection);
device.queue.writeBuffer(vsUniformBuffer, 0, vsUniformValuesF32);
device.queue.writeBuffer(fsUniformBuffer, 0, fsUniformValues);
const canvasTexture = context.getCurrentTexture();;
if (!depthTexture || depthTexture.width !== canvasTexture.width || depthTexture.height !== canvasTexture.height) {
depthTexture?.destroy();
depthTexture = device.createTexture({
size: canvasTexture, // canvasTexture has width, height, and depthOrArrayLayers properties
format: 'depth24plus',
usage: GPUTextureUsage.RENDER_ATTACHMENT,
});
}
const colorTexture = context.getCurrentTexture();
renderPassDescriptor.colorAttachments[0].view = colorTexture.createView();
renderPassDescriptor.depthStencilAttachment.view = depthTexture.createView();
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(numIndices);
pass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
}
function fail(msg) {
const elem = document.querySelector('#fail');
const contentElem = elem.querySelector('.content');
elem.style.display = '';
contentElem.textContent = msg;
}
main();
{"name":"WebGPU Cube with different indices for position, normal, uvs by using storage buffers","settings":{},"filenames":["index.html","index.css","index.js"]}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment