Skip to content

Instantly share code, notes, and snippets.

@greggman
Created April 20, 2024 19:50
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save greggman/a164b7516563cd27ee4f0757ae9c615d to your computer and use it in GitHub Desktop.
Save greggman/a164b7516563cd27ee4f0757ae9c615d to your computer and use it in GitHub Desktop.
WebGPU timing without rAF(requestAnimationFrame)
@import url(https://webgpufundamentals.org/webgpu/resources/webgpu-lesson.css);
html, body {
margin: 0; /* remove the default margin */
height: 100%; /* make the html,body fill the page */
}
canvas {
display: block; /* make the canvas act like a block */
width: 100%; /* make the canvas fill its container */
height: 100%;
}
#info {
position: absolute;
top: 0;
left: 0;
margin: 0;
padding: 0.5em;
background-color: rgba(0, 0, 0, 0.8);
color: white;
}
<canvas></canvas>
<pre id="info"></pre>
import GUI from 'https://webgpufundamentals.org/3rdparty/muigui-0.x.module.js';
function assert(cond, msg = '') {
if (!cond) {
throw new Error(msg);
}
}
class TimingHelper {
#canTimestamp;
#device;
#querySet;
#resolveBuffer;
#resultBuffer;
#resultBuffers = [];
// state can be 'free', 'need resolve', 'wait for result'
#state = 'free';
constructor(device) {
this.#device = device;
this.#canTimestamp = device.features.has('timestamp-query');
this.#querySet = device.createQuerySet({
type: 'timestamp',
count: 2,
});
this.#resolveBuffer = device.createBuffer({
size: this.#querySet.count * 8,
usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
});
}
#beginTimestampPass(encoder, fnName, descriptor) {
if (this.#canTimestamp) {
assert(this.#state === 'free', 'state not free');
this.#state = 'need resolve';
const pass = encoder[fnName]({
...descriptor,
...{
timestampWrites: {
querySet: this.#querySet,
beginningOfPassWriteIndex: 0,
endOfPassWriteIndex: 1,
},
},
});
const resolve = () => this.#resolveTiming(encoder);
pass.end = (function(origFn) {
return function() {
origFn.call(this);
resolve();
};
})(pass.end);
return pass;
} else {
return encoder[fnName](descriptor);
}
}
beginRenderPass(encoder, descriptor = {}) {
return this.#beginTimestampPass(encoder, 'beginRenderPass', descriptor);
}
beginComputePass(encoder, descriptor = {}) {
return this.#beginTimestampPass(encoder, 'beginComputePass', descriptor);
}
#resolveTiming(encoder) {
if (!this.#canTimestamp) {
return;
}
assert(this.#state === 'need resolve', 'must call addTimestampToPass');
this.#state = 'wait for result';
this.#resultBuffer = this.#resultBuffers.pop() || this.#device.createBuffer({
size: this.#resolveBuffer.size,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
encoder.resolveQuerySet(this.#querySet, 0, this.#querySet.count, this.#resolveBuffer, 0);
encoder.copyBufferToBuffer(this.#resolveBuffer, 0, this.#resultBuffer, 0, this.#resultBuffer.size);
}
async getResult() {
if (!this.#canTimestamp) {
return 0;
}
assert(this.#state === 'wait for result', 'must call resolveTiming');
this.#state = 'free';
const resultBuffer = this.#resultBuffer;
await resultBuffer.mapAsync(GPUMapMode.READ);
const times = new BigInt64Array(resultBuffer.getMappedRange());
const duration = Number(times[1] - times[0]);
resultBuffer.unmap();
this.#resultBuffers.push(resultBuffer);
return duration;
}
}
// A random number between [min and max)
// With 1 argument it will be [0 to min)
// With no arguments it will be [0 to 1)
const rand = (min, max) => {
if (min === undefined) {
min = 0;
max = 1;
} else if (max === undefined) {
max = min;
min = 0;
}
return min + Math.random() * (max - min);
};
class RollingAverage {
#total = 0;
#samples = [];
#cursor = 0;
#numSamples;
constructor(numSamples = 30) {
this.#numSamples = numSamples;
}
addSample(v) {
this.#total += v - (this.#samples[this.#cursor] || 0);
this.#samples[this.#cursor] = v;
this.#cursor = (this.#cursor + 1) % this.#numSamples;
}
get() {
return this.#total / this.#samples.length;
}
}
const fpsAverage = new RollingAverage();
const jsAverage = new RollingAverage();
const gpuAverage = new RollingAverage();
function createCircleVertices({
radius = 1,
numSubdivisions = 24,
innerRadius = 0,
startAngle = 0,
endAngle = Math.PI * 2,
} = {}) {
// 2 triangles per subdivision, 3 verts per tri
const numVertices = numSubdivisions * 3 * 2;
// 2 32-bit values for position (xy) and 1 32-bit value for color (rgb_)
// The 32-bit color value will be written/read as 4 8-bit values
const vertexData = new Float32Array(numVertices * (2 + 1));
const colorData = new Uint8Array(vertexData.buffer);
let offset = 0;
let colorOffset = 8;
const addVertex = (x, y, r, g, b) => {
vertexData[offset++] = x;
vertexData[offset++] = y;
offset += 1; // skip the color
colorData[colorOffset++] = r * 255;
colorData[colorOffset++] = g * 255;
colorData[colorOffset++] = b * 255;
colorOffset += 9; // skip extra byte and the position
};
const innerColor = [1, 1, 1];
const outerColor = [0.1, 0.1, 0.1];
// 2 vertices per subdivision
//
// 0--1 4
// | / /|
// |/ / |
// 2 3--5
for (let i = 0; i < numSubdivisions; ++i) {
const angle1 = startAngle + (i + 0) * (endAngle - startAngle) / numSubdivisions;
const angle2 = startAngle + (i + 1) * (endAngle - startAngle) / numSubdivisions;
const c1 = Math.cos(angle1);
const s1 = Math.sin(angle1);
const c2 = Math.cos(angle2);
const s2 = Math.sin(angle2);
// first triangle
addVertex(c1 * radius, s1 * radius, ...outerColor);
addVertex(c2 * radius, s2 * radius, ...outerColor);
addVertex(c1 * innerRadius, s1 * innerRadius, ...innerColor);
// second triangle
addVertex(c1 * innerRadius, s1 * innerRadius, ...innerColor);
addVertex(c2 * radius, s2 * radius, ...outerColor);
addVertex(c2 * innerRadius, s2 * innerRadius, ...innerColor);
}
return {
vertexData,
numVertices,
};
}
async function main() {
const adapter = await navigator.gpu?.requestAdapter();
const canTimestamp = adapter.features.has('timestamp-query');
const device = await adapter?.requestDevice({
requiredFeatures: [
...(canTimestamp ? ['timestamp-query'] : []),
],
});
if (!device) {
fail('need a browser that supports WebGPU');
return;
}
const timingHelper = new TimingHelper(device);
// Get a WebGPU context from the canvas and configure it
const canvas = document.querySelector('canvas');
const context = canvas.getContext('webgpu');
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device,
format: presentationFormat,
});
const module = device.createShaderModule({
code: `
struct Vertex {
@location(0) position: vec2f,
@location(1) color: vec4f,
@location(2) offset: vec2f,
@location(3) scale: vec2f,
@location(4) perVertexColor: vec3f,
};
struct VSOutput {
@builtin(position) position: vec4f,
@location(0) color: vec4f,
};
@vertex fn vs(
vert: Vertex,
) -> VSOutput {
var vsOut: VSOutput;
vsOut.position = vec4f(
vert.position * vert.scale + vert.offset, 0.0, 1.0);
vsOut.color = vert.color * vec4f(vert.perVertexColor, 1);
return vsOut;
}
@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
return vsOut.color;
}
`,
});
const pipeline = device.createRenderPipeline({
label: 'per vertex color',
layout: 'auto',
vertex: {
module,
entryPoint: 'vs',
buffers: [
{
arrayStride: 2 * 4 + 4, // 2 floats, 4 bytes each + 4 bytes
attributes: [
{shaderLocation: 0, offset: 0, format: 'float32x2'}, // position
{shaderLocation: 4, offset: 8, format: 'unorm8x4'}, // perVertexColor
],
},
{
arrayStride: 4, // 4 bytes
stepMode: 'instance',
attributes: [
{shaderLocation: 1, offset: 0, format: 'unorm8x4'}, // color
],
},
{
arrayStride: 4 * 4, // 4 floats, 4 bytes each
stepMode: 'instance',
attributes: [
{shaderLocation: 2, offset: 0, format: 'float32x2'}, // offset
{shaderLocation: 3, offset: 8, format: 'float32x2'}, // scale
],
},
],
},
fragment: {
module,
entryPoint: 'fs',
targets: [{ format: presentationFormat }],
},
});
const kNumObjects = 10000;
const objectInfos = [];
// create 2 vertex buffers
const staticUnitSize =
4; // color is 4 bytes
const changingUnitSize =
2 * 4 + // offset is 2 32bit floats (4bytes each)
2 * 4; // scale is 2 32bit floats (4bytes each)
const staticVertexBufferSize = staticUnitSize * kNumObjects;
const changingVertexBufferSize = changingUnitSize * kNumObjects;
const staticVertexBuffer = device.createBuffer({
label: 'static vertex for objects',
size: staticVertexBufferSize,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
const changingVertexBuffer = device.createBuffer({
label: 'changing storage for objects',
size: changingVertexBufferSize,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
// offsets to the various uniform values in float32 indices
const kColorOffset = 0;
const kOffsetOffset = 0;
const kScaleOffset = 2;
{
const staticVertexValuesU8 = new Uint8Array(staticVertexBufferSize);
for (let i = 0; i < kNumObjects; ++i) {
const staticOffsetU8 = i * staticUnitSize;
// These are only set once so set them now
staticVertexValuesU8.set( // set the color
[rand() * 255, rand() * 255, rand() * 255, 255],
staticOffsetU8 + kColorOffset);
objectInfos.push({
scale: rand(0.2, 0.5),
offset: [rand(-0.9, 0.9), rand(-0.9, 0.9)],
velocity: [rand(-0.1, 0.1), rand(-0.1, 0.1)],
});
}
device.queue.writeBuffer(staticVertexBuffer, 0, staticVertexValuesU8);
}
// a typed array we can use to update the changingStorageBuffer
const vertexValues = new Float32Array(changingVertexBufferSize / 4);
const { vertexData, numVertices } = createCircleVertices({
radius: 0.5,
innerRadius: 0.25,
});
const vertexBuffer = device.createBuffer({
label: 'vertex buffer vertices',
size: vertexData.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(vertexBuffer, 0, vertexData);
const renderPassDescriptor = {
label: 'our basic canvas renderPass with timing',
colorAttachments: [
{
// view: <- to be filled out when we render
clearValue: [0.3, 0.3, 0.3, 1],
loadOp: 'clear',
storeOp: 'store',
},
],
};
const infoElem = document.querySelector('#info');
let gpuTime = 0;
const settings = {
numObjects: 100,
useRAF: true,
};
const gui = new GUI();
gui.add(settings, 'numObjects', 0, kNumObjects, 1);
gui.add(settings, 'useRAF');
const euclideanModulo = (x, a) => x - a * Math.floor(x / a);
let then = 0;
function render() {
const now = performance.now();
const deltaTime = now - then;
then = now;
const startTime = performance.now();
// Get the current texture from the canvas context and
// set it as the texture to render to.
renderPassDescriptor.colorAttachments[0].view =
context.getCurrentTexture().createView();
const encoder = device.createCommandEncoder();
const pass = timingHelper.beginRenderPass(encoder, renderPassDescriptor);
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.setVertexBuffer(1, staticVertexBuffer);
pass.setVertexBuffer(2, changingVertexBuffer);
// Set the uniform values in our JavaScript side Float32Array
const aspect = canvas.width / canvas.height;
// set the scale and offset for each object
for (let ndx = 0; ndx < settings.numObjects; ++ndx) {
const {scale, offset, velocity} = objectInfos[ndx];
// -1.5 to 1.5
offset[0] = euclideanModulo(offset[0] + velocity[0] * deltaTime * 0.001 + 1.5, 3) - 1.5;
offset[1] = euclideanModulo(offset[1] + velocity[1] * deltaTime * 0.001 + 1.5, 3) - 1.5;
const off = ndx * (changingUnitSize / 4);
vertexValues.set(offset, off + kOffsetOffset);
vertexValues.set([scale / aspect, scale], off + kScaleOffset);
}
// upload all offsets and scales at once
device.queue.writeBuffer(
changingVertexBuffer, 0,
vertexValues, 0, settings.numObjects * changingUnitSize / 4);
pass.draw(numVertices, settings.numObjects);
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
timingHelper.getResult().then(gpuTime => {
gpuAverage.addSample(gpuTime / 1000);
});
const jsTime = performance.now() - startTime;
let fps = 1000 / deltaTime;
fps = Number.isFinite(fps) ? fps : 10000;
fpsAverage.addSample(fps);
jsAverage.addSample(jsTime);
infoElem.textContent = `\
fps: ${fpsAverage.get().toFixed(1)}
js: ${jsAverage.get().toFixed(1)}ms
gpu: ${canTimestamp ? `${gpuAverage.get().toFixed(1)}µs` : 'N/A'}
`;
if (settings.useRAF) {
requestAnimationFrame(render);
} else {
postMessage({});
}
}
requestAnimationFrame(render);
window.addEventListener('message', render);
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const canvas = entry.target;
const width = entry.contentBoxSize[0].inlineSize;
const height = entry.contentBoxSize[0].blockSize;
canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D));
canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D));
}
});
observer.observe(canvas);
}
function fail(msg) {
alert(msg);
}
main();
{"name":"WebGPU timing without rAF(requestAnimationFrame)","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