Files
webgpufundamentals/webgpu/webgpu-optimization-step7-double-buffer.html
Gregg Tavares dcfd514975 Revert "Use direct attachment binding"
This reverts commit bdce233195.
2026-02-04 12:17:44 -08:00

631 lines
20 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title>WebGPU Optimization - None</title>
<style>
@import url(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%;
}
:root {
--bg-color: #fff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #000;
}
}
canvas {
background-color: var(--bg-color);
}
#info {
position: absolute;
left: 0;
top: 0;
padding: 0.5em;
margin: 0;
background-color: rgba(0, 0, 0, 0.8);
color: white;
min-width: 8em;
}
</style>
</head>
<body>
<canvas></canvas>
<pre id="info"></pre>
</body>
<script type="module">
import GUI from '../3rdparty/muigui-0.x.module.js';
// see https://webgpufundamentals.org/webgpu/lessons/webgpu-utils.html#webgpu-utils
import {createTextureFromSource} from '../3rdparty/webgpu-utils-1.x.module.js';
// see https://webgpufundamentals.org/webgpu/lessons/webgpu-matrix-math.html
import {mat4, mat3, vec3} from '../3rdparty/wgpu-matrix.module.js';
// see https://webgpufundamentals.org/webgpu/lessons/webgpu-timing.html
import TimingHelper from './resources/js/timing-helper.js';
// see https://webgpufundamentals.org/webgpu/lessons/webgpu-timing.html
import NonNegativeRollingAverage from './resources/js/non-negative-rolling-average.js';
const fpsAverage = new NonNegativeRollingAverage();
const jsAverage = new NonNegativeRollingAverage();
const gpuAverage = new NonNegativeRollingAverage();
const mathAverage = new NonNegativeRollingAverage();
/** Given a css color string, return an array of 4 values from 0 to 255 */
const cssColorToRGBA8 = (() => {
const canvas = new OffscreenCanvas(1, 1);
const ctx = canvas.getContext('2d', {willReadFrequently: true});
return cssColor => {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = cssColor;
ctx.fillRect(0, 0, 1, 1);
return Array.from(ctx.getImageData(0, 0, 1, 1).data);
};
})();
/** Given a css color string, return an array of 4 values from 0 to 1 */
const cssColorToRGBA = cssColor => cssColorToRGBA8(cssColor).map(v => v / 255);
/**
* Given hue, saturation, and luminance values in the range of 0 to 1
* return the corresponding CSS hsl string
*/
const hsl = (h, s, l) => `hsl(${h * 360 | 0}, ${s * 100}%, ${l * 100 | 0}%)`;
/**
* Given hue, saturation, and luminance values in the range of 0 to 1
* returns an array of 4 values from 0 to 1
*/
const hslToRGBA = (h, s, l) => cssColorToRGBA(hsl(h, s, l));
/**
* Returns a random number between min and max.
* If min and max are not specified, returns 0 to 1
* If max is not specified, return 0 to min.
*/
function rand(min, max) {
if (min === undefined) {
max = 1;
min = 0;
} else if (max === undefined) {
max = min;
min = 0;
}
return Math.random() * (max - min) + min;
}
/** Selects a random array element */
const randomArrayElement = arr => arr[Math.random() * arr.length | 0];
/** Rounds up v to a multiple of alignment */
const roundUp = (v, alignment) => Math.ceil(v / alignment) * alignment;
async function main() {
const adapter = await navigator.gpu?.requestAdapter({powerPreference: 'high-performance'});
const canTimestamp = adapter.features.has('timestamp-query');
const device = await adapter?.requestDevice({
requiredFeatures: [
...(canTimestamp ? ['timestamp-query'] : []),
],
});
if (!device) {
fail('could not init WebGPU');
}
const timingHelper = new TimingHelper(device);
const infoElem = document.querySelector('#info');
// 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,
alphaMode: 'premultiplied',
});
const module = device.createShaderModule({
code: /* wgsl */ `
struct GlobalUniforms {
viewProjection: mat4x4f,
lightWorldPosition: vec3f,
viewWorldPosition: vec3f,
};
struct MaterialUniforms {
color: vec4f,
shininess: f32,
};
struct PerObjectUniforms {
normalMatrix: mat3x3f,
world: mat4x4f,
};
struct Vertex {
@location(0) position: vec4f,
@location(1) normal: vec3f,
@location(2) texcoord: vec2f,
};
struct VSOutput {
@builtin(position) position: vec4f,
@location(0) normal: vec3f,
@location(1) surfaceToLight: vec3f,
@location(2) surfaceToView: vec3f,
@location(3) texcoord: vec2f,
};
@group(0) @binding(0) var diffuseTexture: texture_2d<f32>;
@group(0) @binding(1) var diffuseSampler: sampler;
@group(0) @binding(2) var<uniform> obj: PerObjectUniforms;
@group(0) @binding(3) var<uniform> glb: GlobalUniforms;
@group(0) @binding(4) var<uniform> material: MaterialUniforms;
@vertex fn vs(vert: Vertex) -> VSOutput {
var vsOut: VSOutput;
vsOut.position = glb.viewProjection * obj.world * vert.position;
// Orient the normals and pass to the fragment shader
vsOut.normal = obj.normalMatrix * vert.normal;
// Compute the world position of the surface
let surfaceWorldPosition = (obj.world * vert.position).xyz;
// Compute the vector of the surface to the light
// and pass it to the fragment shader
vsOut.surfaceToLight = glb.lightWorldPosition - surfaceWorldPosition;
// Compute the vector of the surface to the light
// and pass it to the fragment shader
vsOut.surfaceToView = glb.viewWorldPosition - surfaceWorldPosition;
// Pass the texture coord on to the fragment shader
vsOut.texcoord = vert.texcoord;
return vsOut;
}
@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
// Because vsOut.normal is an inter-stage variable
// it's interpolated so it will not be a unit vector.
// Normalizing it will make it a unit vector again
let normal = normalize(vsOut.normal);
let surfaceToLightDirection = normalize(vsOut.surfaceToLight);
let surfaceToViewDirection = normalize(vsOut.surfaceToView);
let halfVector = normalize(
surfaceToLightDirection + surfaceToViewDirection);
// Compute the light by taking the dot product
// of the normal with the direction to the light
let light = dot(normal, surfaceToLightDirection);
var specular = dot(normal, halfVector);
specular = select(
0.0, // value if condition is false
pow(specular, material.shininess), // value if condition is true
specular > 0.0); // condition
let diffuse = material.color * textureSample(diffuseTexture, diffuseSampler, vsOut.texcoord);
// Lets multiply just the color portion (not the alpha)
// by the light
let color = diffuse.rgb * light + specular;
return vec4f(color, diffuse.a);
}
`,
});
function createBufferWithData(device, data, usage) {
const buffer = device.createBuffer({
size: data.byteLength,
usage: usage,
mappedAtCreation: true,
});
const dst = new Uint8Array(buffer.getMappedRange());
dst.set(new Uint8Array(data.buffer));
buffer.unmap();
return buffer;
}
const vertexData = new Float32Array([
// position normal texcoord
1, 1, -1, 1, 0, 0, 1, 0,
1, 1, 1, 1, 0, 0, 0, 0,
1, -1, 1, 1, 0, 0, 0, 1,
1, -1, -1, 1, 0, 0, 1, 1,
-1, 1, 1, -1, 0, 0, 1, 0,
-1, 1, -1, -1, 0, 0, 0, 0,
-1, -1, -1, -1, 0, 0, 0, 1,
-1, -1, 1, -1, 0, 0, 1, 1,
-1, 1, 1, 0, 1, 0, 1, 0,
1, 1, 1, 0, 1, 0, 0, 0,
1, 1, -1, 0, 1, 0, 0, 1,
-1, 1, -1, 0, 1, 0, 1, 1,
-1, -1, -1, 0, -1, 0, 1, 0,
1, -1, -1, 0, -1, 0, 0, 0,
1, -1, 1, 0, -1, 0, 0, 1,
-1, -1, 1, 0, -1, 0, 1, 1,
1, 1, 1, 0, 0, 1, 1, 0,
-1, 1, 1, 0, 0, 1, 0, 0,
-1, -1, 1, 0, 0, 1, 0, 1,
1, -1, 1, 0, 0, 1, 1, 1,
-1, 1, -1, 0, 0, -1, 1, 0,
1, 1, -1, 0, 0, -1, 0, 0,
1, -1, -1, 0, 0, -1, 0, 1,
-1, -1, -1, 0, 0, -1, 1, 1,
]);
const indices = new Uint16Array([0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7, 8, 9, 10, 8, 10, 11, 12, 13, 14, 12, 14, 15, 16, 17, 18, 16, 18, 19, 20, 21, 22, 20, 22, 23]);
const vertexBuffer = createBufferWithData(device, vertexData, GPUBufferUsage.VERTEX);
const indicesBuffer = createBufferWithData(device, indices, GPUBufferUsage.INDEX);
const numVertices = indices.length;
const pipeline = device.createRenderPipeline({
label: 'textured model with point light w/specular highlight',
layout: 'auto',
vertex: {
module,
buffers: [
{
arrayStride: (3 + 3 + 2) * 4, // 8 floats
attributes: [
{shaderLocation: 0, offset: 0 * 4, format: 'float32x3'}, // position
{shaderLocation: 1, offset: 3 * 4, format: 'float32x3'}, // normal
{shaderLocation: 2, offset: 6 * 4, format: 'float32x2'}, // texcoord
],
},
],
},
fragment: {
module,
targets: [{ format: presentationFormat }],
},
primitive: {
cullMode: 'back',
},
depthStencil: {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus',
},
});
const textures = [
'😂', '👾', '👍', '👀', '🌞', '🛟',
].map(s => {
const size = 128;
const ctx = new OffscreenCanvas(size, size).getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, size, size);
ctx.font = `${size * 0.9}px sans-serif`;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const m = ctx.measureText(s);
ctx.fillText(
s,
(size - m.actualBoundingBoxRight + m.actualBoundingBoxLeft) / 2,
(size - m.actualBoundingBoxDescent + m.actualBoundingBoxAscent) / 2
);
return createTextureFromSource(device, ctx.canvas, {mips: true});
});
const sampler = device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
mipmapFilter: 'nearest',
});
const numMaterials = 20;
const materials = [];
for (let i = 0; i < numMaterials; ++i) {
const color = hslToRGBA(rand(), rand(0.5, 0.8), rand(0.5, 0.7));
const shininess = rand(10, 120);
const materialValues = new Float32Array([
...color,
shininess,
0, 0, 0, // padding
]);
const materialUniformBuffer = createBufferWithData(
device,
materialValues,
GPUBufferUsage.UNIFORM,
);
materials.push({
materialUniformBuffer,
texture: randomArrayElement(textures),
sampler,
});
}
const globalUniformBufferSize = (16 + 4 + 4) * 4;
const globalUniformBuffer = device.createBuffer({
label: 'global uniforms',
size: globalUniformBufferSize,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const globalUniformValues = new Float32Array(globalUniformBufferSize / 4);
const kViewProjectionOffset = 0;
const kLightWorldPositionOffset = 16;
const kViewWorldPositionOffset = 20;
const viewProjectionValue = globalUniformValues.subarray(
kViewProjectionOffset, kViewProjectionOffset + 16);
const lightWorldPositionValue = globalUniformValues.subarray(
kLightWorldPositionOffset, kLightWorldPositionOffset + 3);
const viewWorldPositionValue = globalUniformValues.subarray(
kViewWorldPositionOffset, kViewWorldPositionOffset + 3);
const maxObjects = 30000;
const objectInfos = [];
const uniformBufferSize = (12 + 16) * 4;
const uniformBufferSpace = roundUp(uniformBufferSize, device.limits.minUniformBufferOffsetAlignment);
const uniformBuffers = [0, 1].map(() => device.createBuffer({
label: 'uniforms',
size: uniformBufferSpace * maxObjects,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
}));
const mappedTransferBuffers = [];
const getMappedTransferBuffer = () => {
return mappedTransferBuffers.pop() || device.createBuffer({
label: 'transfer buffer',
size: uniformBufferSpace * maxObjects,
usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC,
mappedAtCreation: true,
});
};
// offsets to the various uniform values in float32 indices
const kNormalMatrixOffset = 0;
const kWorldOffset = 12;
for (let i = 0; i < maxObjects; ++i) {
const uniformBufferOffset = i * uniformBufferSpace;
const material = randomArrayElement(materials);
const bindGroups = [0, 1].map(i => device.createBindGroup({
label: 'bind group for object',
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: material.texture.createView() },
{ binding: 1, resource: material.sampler },
{ binding: 2, resource: { buffer: uniformBuffers[i], offset: uniformBufferOffset, size: uniformBufferSize }},
{ binding: 3, resource: globalUniformBuffer },
{ binding: 4, resource: { buffer: material.materialUniformBuffer }},
],
}));
const axis = vec3.normalize([rand(-1, 1), rand(-1, 1), rand(-1, 1)]);
const radius = rand(10, 100);
const speed = rand(0.1, 0.4);
const rotationSpeed = rand(-1, 1);
const scale = rand(2, 10);
objectInfos.push({
bindGroups,
axis,
radius,
speed,
rotationSpeed,
scale,
});
}
const renderPassDescriptor = {
label: 'our basic canvas renderPass',
colorAttachments: [
{
// view: <- to be filled out when we render
clearValue: [0.3, 0.3, 0.3, 1],
loadOp: 'clear',
storeOp: 'store',
},
],
depthStencilAttachment: {
// view: <- to be filled out when we render
depthClearValue: 1.0,
depthLoadOp: 'clear',
depthStoreOp: 'store',
},
};
const canvasToSizeMap = new WeakMap();
const degToRad = d => d * Math.PI / 180;
const settings = {
numObjects: 1000,
render: true,
};
const gui = new GUI();
gui.add(settings, 'numObjects', { min: 0, max: maxObjects, step: 1});
gui.add(settings, 'render');
let depthTexture;
let then = 0;
let frameCount = 0;
function render(time) {
time *= 0.001; // convert to seconds
const deltaTime = time - then;
then = time;
++frameCount;
const startTimeMs = performance.now();
const {width, height} = settings.render
? canvasToSizeMap.get(canvas) ?? canvas
: { width: 1, height: 1 };
// Don't set the canvas size if it's already that size as it may be slow.
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
// 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 encoder = device.createCommandEncoder();
let mathElapsedTimeMs = 0;
const transferBuffer = getMappedTransferBuffer();
const uniformValues = new Float32Array(transferBuffer.getMappedRange());
for (let i = 0; i < settings.numObjects; ++i) {
const {
axis,
radius,
speed,
rotationSpeed,
scale,
} = objectInfos[i];
const mathTimeStartMs = performance.now();
const uniformBufferOffset = i * uniformBufferSpace;
const f32Offset = uniformBufferOffset / 4;
const normalMatrixValue = uniformValues.subarray(
f32Offset + kNormalMatrixOffset, f32Offset + kNormalMatrixOffset + 12);
const worldValue = uniformValues.subarray(
f32Offset + kWorldOffset, f32Offset + kWorldOffset + 16);
// Compute a world matrix
mat4.identity(worldValue);
mat4.axisRotate(worldValue, axis, i + time * speed, worldValue);
mat4.translate(worldValue, [0, 0, Math.sin(i * 3.721 + time * speed) * radius], worldValue);
mat4.translate(worldValue, [0, 0, Math.sin(i * 9.721 + time * 0.1) * radius], worldValue);
mat4.rotateX(worldValue, time * rotationSpeed + i, worldValue);
mat4.scale(worldValue, [scale, scale, scale], worldValue);
// Inverse and transpose it into the normalMatrix value
mat3.fromMat4(mat4.transpose(mat4.inverse(worldValue)), normalMatrixValue);
mathElapsedTimeMs += performance.now() - mathTimeStartMs;
}
transferBuffer.unmap();
const resourceIndex = frameCount % 2;
const uniformBuffer = uniformBuffers[resourceIndex];
// copy the uniform values from the transfer buffer to the uniform buffer
if (settings.numObjects) {
const size = (settings.numObjects - 1) * uniformBufferSpace + uniformBufferSize;
encoder.copyBufferToBuffer(transferBuffer, 0, uniformBuffer, 0, size);
}
const aspect = canvas.clientWidth / canvas.clientHeight;
const projection = mat4.perspective(
degToRad(60),
aspect,
1, // zNear
2000, // zFar
);
const eye = [100, 150, 200];
const target = [0, 0, 0];
const up = [0, 1, 0];
// Compute a view matrix
const viewMatrix = mat4.lookAt(eye, target, up);
// Combine the view and projection matrixes
mat4.multiply(projection, viewMatrix, viewProjectionValue);
lightWorldPositionValue.set([-10, 30, 300]);
viewWorldPositionValue.set(eye);
device.queue.writeBuffer(globalUniformBuffer, 0, globalUniformValues);
const pass = timingHelper.beginRenderPass(encoder, renderPassDescriptor);
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.setIndexBuffer(indicesBuffer, 'uint16');
for (let i = 0; i < settings.numObjects; ++i) {
const { bindGroups } = objectInfos[i];
pass.setBindGroup(0, bindGroups[resourceIndex]);
pass.drawIndexed(numVertices);
}
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
transferBuffer.mapAsync(GPUMapMode.WRITE).then(() => {
mappedTransferBuffers.push(transferBuffer);
});
timingHelper.getResult().then(gpuTime => {
gpuAverage.addSample(gpuTime / 1000);
});
const elapsedTimeMs = performance.now() - startTimeMs;
fpsAverage.addSample(1 / deltaTime);
jsAverage.addSample(elapsedTimeMs);
mathAverage.addSample(mathElapsedTimeMs);
infoElem.textContent = `\
js : ${jsAverage.get().toFixed(1)}ms
math: ${mathAverage.get().toFixed(1)}ms
fps : ${fpsAverage.get().toFixed(0)}
gpu : ${canTimestamp ? `${(gpuAverage.get() / 1000).toFixed(1)}ms` : 'N/A'}
`;
requestAnimationFrame(render);
}
requestAnimationFrame(render);
const observer = new ResizeObserver(entries => {
entries.forEach(entry => {
canvasToSizeMap.set(entry.target, {
width: Math.max(1, Math.min(entry.contentBoxSize[0].inlineSize, device.limits.maxTextureDimension2D)),
height: Math.max(1, Math.min(entry.contentBoxSize[0].blockSize, device.limits.maxTextureDimension2D)),
});
});
});
observer.observe(canvas);
}
function fail(msg) {
alert(msg);
}
main();
</script>
</html>