diff --git a/sample/wireframe/index.html b/sample/wireframe/index.html new file mode 100644 index 00000000..5ab83229 --- /dev/null +++ b/sample/wireframe/index.html @@ -0,0 +1,30 @@ + + + + + + webgpu-samples: wireframe + + + + + + + + diff --git a/sample/wireframe/main.ts b/sample/wireframe/main.ts new file mode 100644 index 00000000..c53145a7 --- /dev/null +++ b/sample/wireframe/main.ts @@ -0,0 +1,438 @@ +import { mat4, mat3 } from 'wgpu-matrix'; +import { GUI } from 'dat.gui'; +import { modelData } from './models'; +import { randElement, randColor } from './utils'; +import solidColorLitWGSL from './solidColorLit.wgsl'; +import wireframeWGSL from './wireframe.wgsl'; + +type TypedArrayView = Float32Array | Uint32Array; + +function createBufferWithData( + device: GPUDevice, + data: TypedArrayView, + usage: GPUBufferUsageFlags +) { + const buffer = device.createBuffer({ + size: data.byteLength, + usage, + }); + device.queue.writeBuffer(buffer, 0, data); + return buffer; +} + +type Model = { + vertexBuffer: GPUBuffer; + indexBuffer: GPUBuffer; + indexFormat: GPUIndexFormat; + vertexCount: number; +}; + +function createVertexAndIndexBuffer( + device: GPUDevice, + { vertices, indices }: { vertices: Float32Array; indices: Uint32Array } +): Model { + const vertexBuffer = createBufferWithData( + device, + vertices, + GPUBufferUsage.VERTEX | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST + ); + const indexBuffer = createBufferWithData( + device, + indices, + GPUBufferUsage.INDEX | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST + ); + return { + vertexBuffer, + indexBuffer, + indexFormat: 'uint32', + vertexCount: indices.length, + }; +} + +const adapter = await navigator.gpu.requestAdapter(); +const device = await adapter.requestDevice(); +const canvas = document.querySelector('canvas') as HTMLCanvasElement; +const context = canvas.getContext('webgpu') as GPUCanvasContext; +const devicePixelRatio = window.devicePixelRatio; +canvas.width = canvas.clientWidth * devicePixelRatio; +canvas.height = canvas.clientHeight * devicePixelRatio; +const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); +context.configure({ + device, + format: presentationFormat, + alphaMode: 'premultiplied', +}); +const depthFormat = 'depth24plus'; + +const models = Object.values(modelData).map((data) => + createVertexAndIndexBuffer(device, data) +); + +const litModule = device.createShaderModule({ + code: solidColorLitWGSL, +}); + +const wireframeModule = device.createShaderModule({ + code: wireframeWGSL, +}); + +const litPipeline = device.createRenderPipeline({ + label: 'lit pipeline', + layout: 'auto', + vertex: { + module: litModule, + buffers: [ + { + arrayStride: 6 * 4, // position, normal + attributes: [ + { + // position + shaderLocation: 0, + offset: 0, + format: 'float32x3', + }, + { + // normal + shaderLocation: 1, + offset: 3 * 4, + format: 'float32x3', + }, + ], + }, + ], + }, + fragment: { + module: litModule, + targets: [{ format: presentationFormat }], + }, + primitive: { + cullMode: 'back', + }, + depthStencil: { + depthWriteEnabled: true, + depthCompare: 'less', + format: depthFormat, + }, +}); + +const wireframePipeline = device.createRenderPipeline({ + label: 'wireframe pipeline', + layout: 'auto', + vertex: { + module: wireframeModule, + entryPoint: 'vsIndexedU32', + }, + fragment: { + module: wireframeModule, + entryPoint: 'fs', + targets: [{ format: presentationFormat }], + }, + primitive: { + topology: 'line-list', + }, + depthStencil: { + depthWriteEnabled: true, + depthCompare: 'less-equal', + format: depthFormat, + }, +}); + +const barycentricCoordinatesBasedWireframePipeline = + device.createRenderPipeline({ + label: 'barycentric coordinates based wireframe pipeline', + layout: 'auto', + vertex: { + module: wireframeModule, + entryPoint: 'vsIndexedU32BarycentricCoordinateBasedLines', + }, + fragment: { + module: wireframeModule, + entryPoint: 'fsBarycentricCoordinateBasedLines', + targets: [ + { + format: presentationFormat, + blend: { + color: { + srcFactor: 'one', + dstFactor: 'one-minus-src-alpha', + }, + alpha: { + srcFactor: 'one', + dstFactor: 'one-minus-src-alpha', + }, + }, + }, + ], + }, + primitive: { + topology: 'triangle-list', + }, + depthStencil: { + depthWriteEnabled: true, + depthCompare: 'less-equal', + format: depthFormat, + }, + }); + +type ObjectInfo = { + worldViewProjectionMatrixValue: Float32Array; + worldMatrixValue: Float32Array; + uniformValues: Float32Array; + uniformBuffer: GPUBuffer; + lineUniformValues: Float32Array; + lineUniformBuffer: GPUBuffer; + litBindGroup: GPUBindGroup; + wireframeBindGroups: GPUBindGroup[]; + model: Model; +}; + +const objectInfos: ObjectInfo[] = []; + +const numObjects = 200; +for (let i = 0; i < numObjects; ++i) { + // Make a uniform buffer and type array views + // for our uniforms. + const uniformValues = new Float32Array(16 + 16 + 4); + const uniformBuffer = device.createBuffer({ + size: uniformValues.byteLength, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + const kWorldViewProjectionMatrixOffset = 0; + const kWorldMatrixOffset = 16; + const kColorOffset = 32; + const worldViewProjectionMatrixValue = uniformValues.subarray( + kWorldViewProjectionMatrixOffset, + kWorldViewProjectionMatrixOffset + 16 + ); + const worldMatrixValue = uniformValues.subarray( + kWorldMatrixOffset, + kWorldMatrixOffset + 15 + ); + const colorValue = uniformValues.subarray(kColorOffset, kColorOffset + 4); + colorValue.set(randColor()); + + const model = randElement(models); + + // Make a bind group for this uniform + const litBindGroup = device.createBindGroup({ + layout: litPipeline.getBindGroupLayout(0), + entries: [{ binding: 0, resource: { buffer: uniformBuffer } }], + }); + + // Note: We're making one lineUniformBuffer per object. + // This is only because stride might be different per object. + // In this sample stride is the same across all objects so + // we could have made just a single shared uniform buffer for + // these settings. + const lineUniformValues = new Float32Array(3 + 1); + const lineUniformValuesAsU32 = new Uint32Array(lineUniformValues.buffer); + const lineUniformBuffer = device.createBuffer({ + size: lineUniformValues.byteLength, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + lineUniformValuesAsU32[0] = 6; // the array stride for positions for this model. + + // We're creating 2 bindGroups, one for each pipeline. + // We could create just one since they are identical. To do + // so we'd have to manually create a bindGroupLayout. + const wireframeBindGroup = device.createBindGroup({ + layout: wireframePipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: uniformBuffer } }, + { binding: 1, resource: { buffer: model.vertexBuffer } }, + { binding: 2, resource: { buffer: model.indexBuffer } }, + { binding: 3, resource: { buffer: lineUniformBuffer } }, + ], + }); + + const barycentricCoordinatesBasedWireframeBindGroup = device.createBindGroup({ + layout: barycentricCoordinatesBasedWireframePipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: uniformBuffer } }, + { binding: 1, resource: { buffer: model.vertexBuffer } }, + { binding: 2, resource: { buffer: model.indexBuffer } }, + { binding: 3, resource: { buffer: lineUniformBuffer } }, + ], + }); + + objectInfos.push({ + worldViewProjectionMatrixValue, + worldMatrixValue, + uniformValues, + uniformBuffer, + lineUniformValues, + lineUniformBuffer, + litBindGroup, + wireframeBindGroups: [ + wireframeBindGroup, + barycentricCoordinatesBasedWireframeBindGroup, + ], + model, + }); +} + +const renderPassDescriptor: GPURenderPassDescriptor = { + label: 'our basic canvas renderPass', + colorAttachments: [ + { + view: undefined, // <- to be filled out when we render + clearValue: [0.3, 0.3, 0.3, 1], + loadOp: 'clear', + storeOp: 'store', + }, + ], + depthStencilAttachment: { + view: undefined, // <- to be filled out when we render + depthClearValue: 1.0, + depthLoadOp: 'clear', + depthStoreOp: 'store', + }, +}; + +const settings = { + barycentricCoordinatesBased: false, + thickness: 2, + alphaThreshold: 0.5, + lines: true, + models: true, +}; + +const gui = new GUI(); +gui.add(settings, 'barycentricCoordinatesBased').onChange(addRemoveGUI); +gui.add(settings, 'lines'); +gui.add(settings, 'models'); + +const guis = []; +function addRemoveGUI() { + if (settings.barycentricCoordinatesBased) { + guis.push( + gui.add(settings, 'thickness', 0.0, 10).onChange(updateThickness), + gui.add(settings, 'alphaThreshold', 0, 1).onChange(updateThickness) + ); + } else { + guis.forEach((g) => g.remove()); + guis.length = 0; + } +} + +function updateThickness() { + objectInfos.forEach(({ lineUniformBuffer, lineUniformValues }) => { + lineUniformValues[1] = settings.thickness; + lineUniformValues[2] = settings.alphaThreshold; + device.queue.writeBuffer(lineUniformBuffer, 0, lineUniformValues); + }); +} +updateThickness(); + +let depthTexture: GPUTexture | undefined; + +function render(time: number) { + time *= 0.001; // convert to seconds; + + // Get the current texture from the canvas context and + // set it as the texture to render to. + const canvasTexture = context.getCurrentTexture(); + renderPassDescriptor.colorAttachments[0].view = canvasTexture.createView(); + + // If we don't have a depth texture OR if its size is different + // from the canvasTexture when make a new depth texture + if ( + !depthTexture || + depthTexture.width !== canvasTexture.width || + depthTexture.height !== canvasTexture.height + ) { + if (depthTexture) { + depthTexture.destroy(); + } + depthTexture = device.createTexture({ + size: [canvasTexture.width, canvasTexture.height], + format: 'depth24plus', + usage: GPUTextureUsage.RENDER_ATTACHMENT, + }); + } + renderPassDescriptor.depthStencilAttachment.view = depthTexture.createView(); + + const fov = (60 * Math.PI) / 180; + const aspect = canvas.clientWidth / canvas.clientHeight; + const projection = mat4.perspective(fov, aspect, 0.1, 1000); + + const view = mat4.lookAt( + [-300, 0, 300], // eye + [0, 0, 0], // target + [0, 1, 0] // up + ); + + const viewProjection = mat4.multiply(projection, view); + + // make a command encoder to start encoding commands + const encoder = device.createCommandEncoder(); + + // make a render pass encoder to encode render specific commands + const pass = encoder.beginRenderPass(renderPassDescriptor); + pass.setPipeline(litPipeline); + + objectInfos.forEach( + ( + { + uniformBuffer, + uniformValues, + worldViewProjectionMatrixValue, + worldMatrixValue, + litBindGroup, + model: { vertexBuffer, indexBuffer, indexFormat, vertexCount }, + }, + i + ) => { + const world = mat4.identity(); + mat4.translate( + world, + [0, 0, Math.sin(i * 3.721 + time * 0.1) * 200], + world + ); + mat4.rotateX(world, i * 4.567, world); + mat4.rotateY(world, i * 2.967, world); + mat4.translate( + world, + [0, 0, Math.sin(i * 9.721 + time * 0.1) * 200], + world + ); + mat4.rotateX(world, time * 0.53 + i, world); + + mat4.multiply(viewProjection, world, worldViewProjectionMatrixValue); + mat3.fromMat4(world, worldMatrixValue); + + // Upload our uniform values. + device.queue.writeBuffer(uniformBuffer, 0, uniformValues); + + if (settings.models) { + pass.setVertexBuffer(0, vertexBuffer); + pass.setIndexBuffer(indexBuffer, indexFormat); + pass.setBindGroup(0, litBindGroup); + pass.drawIndexed(vertexCount); + } + } + ); + + if (settings.lines) { + // Note: If we're using the line-list based pipeline then we need to + // multiply the vertex count by 2 since we need to emit 6 vertices + // for each triangle (3 edges). + const [bindGroupNdx, countMult, pipeline] = + settings.barycentricCoordinatesBased + ? [1, 1, barycentricCoordinatesBasedWireframePipeline] + : [0, 2, wireframePipeline]; + pass.setPipeline(pipeline); + objectInfos.forEach(({ wireframeBindGroups, model: { vertexCount } }) => { + pass.setBindGroup(0, wireframeBindGroups[bindGroupNdx]); + pass.draw(vertexCount * countMult); + }); + } + + pass.end(); + + const commandBuffer = encoder.finish(); + device.queue.submit([commandBuffer]); + + requestAnimationFrame(render); +} +requestAnimationFrame(render); diff --git a/sample/wireframe/meta.ts b/sample/wireframe/meta.ts new file mode 100644 index 00000000..4f9f0a27 --- /dev/null +++ b/sample/wireframe/meta.ts @@ -0,0 +1,21 @@ +export default { + name: 'Wireframe', + description: ` + This example demonstrates drawing a wireframe from triangles in 2 ways. + Both use the vertex and index buffers as storage buffers and the use \`@builtin(vertex_index)\` + to index the vertex data. One method generates 6 vertices per triangle and uses line-list to draw lines. + The other method draws triangles with a fragment shader that uses barycentric coordinates to draw edges + as detailed [here](https://web.archive.org/web/20130424093557/http://codeflow.org/entries/2012/aug/02/easy-wireframe-display-with-barycentric-coordinates/). + + `, + filename: __DIRNAME__, + sources: [ + { path: 'main.ts' }, + { path: 'wireframe.wgsl' }, + { path: 'solidColorLit.wgsl' }, + { path: 'models.ts' }, + { path: '../../meshes/box.ts' }, + { path: '../../meshes/mesh.ts' }, + { path: 'utils.ts' }, + ], +}; diff --git a/sample/wireframe/models.ts b/sample/wireframe/models.ts new file mode 100644 index 00000000..4b433e35 --- /dev/null +++ b/sample/wireframe/models.ts @@ -0,0 +1,112 @@ +// Ideally all the models would be the same format +// and we'd determine that format at build time or before +// but, we want to reuse the model data in this repo +// so we'll normalize it here + +import { vec3 } from 'wgpu-matrix'; +import { mesh as teapot } from '../../meshes/teapot'; +import { createSphereMesh } from '../../meshes/sphere'; + +type Mesh = { + positions: [number, number, number][]; + triangles: [number, number, number][]; + normals: [number, number, number][]; +}; + +export function convertMeshToTypedArrays( + mesh: Mesh, + scale: number, + offset = [0, 0, 0] +) { + const { positions, normals, triangles } = mesh; + const scaledPositions = positions.map((p) => + p.map((v, i) => v * scale + offset[i % 3]) + ); + const vertices = new Float32Array(scaledPositions.length * 6); + for (let i = 0; i < scaledPositions.length; ++i) { + vertices.set(scaledPositions[i], 6 * i); + vertices.set(normals[i], 6 * i + 3); + } + const indices = new Uint32Array(triangles.length * 3); + for (let i = 0; i < triangles.length; ++i) { + indices.set(triangles[i], 3 * i); + } + + return { + vertices, + indices, + }; +} + +function createSphereTypedArrays( + radius: number, + widthSegments = 32, + heightSegments = 16, + randomness = 0 +) { + const { vertices: verticesWithUVs, indices } = createSphereMesh( + radius, + widthSegments, + heightSegments, + randomness + ); + const numVertices = verticesWithUVs.length / 8; + const vertices = new Float32Array(numVertices * 6); + for (let i = 0; i < numVertices; ++i) { + const srcNdx = i * 8; + const dstNdx = i * 6; + vertices.set(verticesWithUVs.subarray(srcNdx, srcNdx + 6), dstNdx); + } + return { + vertices, + indices: new Uint32Array(indices), + }; +} + +function flattenNormals({ + vertices, + indices, +}: { + vertices: Float32Array; + indices: Uint32Array; +}) { + const newVertices = new Float32Array(indices.length * 6); + const newIndices = new Uint32Array(indices.length); + for (let i = 0; i < indices.length; i += 3) { + const positions = []; + for (let j = 0; j < 3; ++j) { + const ndx = indices[i + j]; + const srcNdx = ndx * 6; + const dstNdx = (i + j) * 6; + // copy position + const pos = vertices.subarray(srcNdx, srcNdx + 3); + newVertices.set(pos, dstNdx); + positions.push(pos); + newIndices[i + j] = i + j; + } + + const normal = vec3.normalize( + vec3.cross( + vec3.normalize(vec3.subtract(positions[1], positions[0])), + vec3.normalize(vec3.subtract(positions[2], positions[1])) + ) + ); + + for (let j = 0; j < 3; ++j) { + const dstNdx = (i + j) * 6; + newVertices.set(normal, dstNdx + 3); + } + } + + return { + vertices: newVertices, + indices: newIndices, + }; +} + +export const modelData = { + teapot: convertMeshToTypedArrays(teapot, 1.5), + sphere: createSphereTypedArrays(20), + jewel: flattenNormals(createSphereTypedArrays(20, 5, 3)), + rock: flattenNormals(createSphereTypedArrays(20, 32, 16, 0.1)), +}; diff --git a/sample/wireframe/solidColorLit.wgsl b/sample/wireframe/solidColorLit.wgsl new file mode 100644 index 00000000..c29c3f6c --- /dev/null +++ b/sample/wireframe/solidColorLit.wgsl @@ -0,0 +1,30 @@ +struct Uniforms { + worldViewProjectionMatrix: mat4x4f, + worldMatrix: mat4x4f, + color: vec4f, +}; + +struct Vertex { + @location(0) position: vec4f, + @location(1) normal: vec3f, +}; + +struct VSOut { + @builtin(position) position: vec4f, + @location(0) normal: vec3f, +}; + +@group(0) @binding(0) var uni: Uniforms; + +@vertex fn vs(vin: Vertex) -> VSOut { + var vOut: VSOut; + vOut.position = uni.worldViewProjectionMatrix * vin.position; + vOut.normal = (uni.worldMatrix * vec4f(vin.normal, 0)).xyz; + return vOut; +} + +@fragment fn fs(vin: VSOut) -> @location(0) vec4f { + let lightDirection = normalize(vec3f(4, 10, 6)); + let light = dot(normalize(vin.normal), lightDirection) * 0.5 + 0.5; + return vec4f(uni.color.rgb * light, uni.color.a); +} diff --git a/sample/wireframe/utils.ts b/sample/wireframe/utils.ts new file mode 100644 index 00000000..0557de92 --- /dev/null +++ b/sample/wireframe/utils.ts @@ -0,0 +1,22 @@ +export function rand(min?: number, max?: number) { + if (min === undefined) { + max = 1; + min = 0; + } else if (max === undefined) { + max = min; + min = 0; + } + return Math.random() * (max - min) + min; +} + +export function randInt(min: number, max?: number) { + return Math.floor(rand(min, max)); +} + +export function randColor() { + return [rand(), rand(), rand(), 1]; +} + +export function randElement(arr: T[]): T { + return arr[randInt(arr.length)]; +} diff --git a/sample/wireframe/wireframe.wgsl b/sample/wireframe/wireframe.wgsl new file mode 100644 index 00000000..de62b1e7 --- /dev/null +++ b/sample/wireframe/wireframe.wgsl @@ -0,0 +1,106 @@ +struct Uniforms { + worldViewProjectionMatrix: mat4x4f, + worldMatrix: mat4x4f, + color: vec4f, +}; + +struct LineUniforms { + stride: u32, + thickness: f32, + alphaThreshold: f32, +}; + +struct VSOut { + @builtin(position) position: vec4f, +}; + +@group(0) @binding(0) var uni: Uniforms; +@group(0) @binding(1) var positions: array; +@group(0) @binding(2) var indices: array; +@group(0) @binding(3) var line: LineUniforms; + +@vertex fn vsIndexedU32(@builtin(vertex_index) vNdx: u32) -> VSOut { + // indices make a triangle so for every 3 indices we need to output + // 6 values + let triNdx = vNdx / 6; + // 0 1 0 1 0 1 0 1 0 1 0 1 vNdx % 2 + // 0 0 1 1 2 2 3 3 4 4 5 5 vNdx / 2 + // 0 1 1 2 2 3 3 4 4 5 5 6 vNdx % 2 + vNdx / 2 + // 0 1 1 2 2 0 0 1 1 2 2 0 (vNdx % 2 + vNdx / 2) % 3 + let vertNdx = (vNdx % 2 + vNdx / 2) % 3; + let index = indices[triNdx * 3 + vertNdx]; + + // note: + // + // * if your indices are U16 you could use this + // + // let indexNdx = triNdx * 3 + vertNdx; + // let twoIndices = indices[indexNdx / 2]; // indices is u32 but we want u16 + // let index = (twoIndices >> ((indexNdx & 1) * 16)) & 0xFFFF; + // + // * if you're not using indices you could use this + // + // let index = triNdx * 3 + vertNdx; + + let pNdx = index * line.stride; + let position = vec4f(positions[pNdx], positions[pNdx + 1], positions[pNdx + 2], 1); + + var vOut: VSOut; + vOut.position = uni.worldViewProjectionMatrix * position; + return vOut; +} + +@fragment fn fs() -> @location(0) vec4f { + return uni.color + vec4f(0.5); +} + +struct BarycentricCoordinateBasedVSOutput { + @builtin(position) position: vec4f, + @location(0) barycenticCoord: vec3f, +}; + +@vertex fn vsIndexedU32BarycentricCoordinateBasedLines( + @builtin(vertex_index) vNdx: u32 +) -> BarycentricCoordinateBasedVSOutput { + let vertNdx = vNdx % 3; + let index = indices[vNdx]; + + // note: + // + // * if your indices are U16 you could use this + // + // let twoIndices = indices[vNdx / 2]; // indices is u32 but we want u16 + // let index = (twoIndices >> ((vNdx & 1) * 16)) & 0xFFFF; + // + // * if you're not using indices you could use this + // + // let index = vNdx; + + let pNdx = index * line.stride; + let position = vec4f(positions[pNdx], positions[pNdx + 1], positions[pNdx + 2], 1); + + var vsOut: BarycentricCoordinateBasedVSOutput; + vsOut.position = uni.worldViewProjectionMatrix * position; + + // emit a barycentric coordinate + vsOut.barycenticCoord = vec3f(0); + vsOut.barycenticCoord[vertNdx] = 1.0; + return vsOut; +} + +fn edgeFactor(bary: vec3f) -> f32 { + let d = fwidth(bary); + let a3 = smoothstep(vec3f(0.0), d * line.thickness, bary); + return min(min(a3.x, a3.y), a3.z); +} + +@fragment fn fsBarycentricCoordinateBasedLines( + v: BarycentricCoordinateBasedVSOutput +) -> @location(0) vec4f { + let a = 1.0 - edgeFactor(v.barycenticCoord); + if (a < line.alphaThreshold) { + discard; + } + + return vec4((uni.color.rgb + 0.5) * a, a); +} diff --git a/src/samples.ts b/src/samples.ts index 378bcf49..45db7cb2 100644 --- a/src/samples.ts +++ b/src/samples.ts @@ -34,6 +34,7 @@ import texturedCube from '../sample/texturedCube/meta'; import twoCubes from '../sample/twoCubes/meta'; import videoUploading from '../sample/videoUploading/meta'; import volumeRenderingTexture3D from '../sample/volumeRenderingTexture3D/meta'; +import wireframe from '../sample/wireframe/meta'; import worker from '../sample/worker/meta'; import workloadSimulator from '../sample/workloadSimulator/meta'; @@ -123,6 +124,7 @@ export const pageCategories: PageCategory[] = [ skinnedMesh, textRenderingMsdf, volumeRenderingTexture3D, + wireframe, }, },