Last active
August 15, 2025 17:38
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<canvas></canvas> | |
<div id="fail" style="display: none"> | |
<div class="content"></div> | |
</div> | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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(); | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{"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