mirror of
https://github.com/webgpu/webgpufundamentals.git
synced 2026-05-16 05:41:01 -04:00
The tradtional way to do this is to set
```js
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, centerX, centerY);
```
For various reasons this doesn't actually draw "in the center".
Instead we have to measure the text using the canvas API and then
center ourselves.
```
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const m = ctx.measureText(text);
ctx.fillText(
text,
(canvas.width - m.actualBoundingBoxRight + m.actualBoundingBoxLeft) / 2,
(canvas.height - m.actualBoundingBoxDescent + m.actualBoundingBoxAscent) / 2
);
```
507 lines
15 KiB
HTML
507 lines
15 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>WebGL 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';
|
|
import * as twgl from '../3rdparty/twgl-full.module.js';
|
|
import NonNegativeRollingAverage from './resources/js/non-negative-rolling-average.js';
|
|
|
|
class TimingHelper {
|
|
#ext;
|
|
#query;
|
|
#gl;
|
|
#state = 'free';
|
|
#duration = 0;
|
|
|
|
constructor(gl) {
|
|
this.#gl = gl;
|
|
this.#ext = gl.getExtension('EXT_disjoint_timer_query_webgl2');
|
|
if (!this.#ext) {
|
|
return;
|
|
}
|
|
this.#query = gl.createQuery();
|
|
}
|
|
|
|
begin() {
|
|
if (!this.#ext || this.#state !== 'free') {
|
|
return;
|
|
}
|
|
this.#state = 'started';
|
|
const gl = this.#gl;
|
|
const ext = this.#ext;
|
|
const query = this.#query;
|
|
gl.beginQuery(ext.TIME_ELAPSED_EXT, query);
|
|
}
|
|
end() {
|
|
if (!this.#ext || this.#state === 'free') {
|
|
return;
|
|
}
|
|
|
|
const gl = this.#gl;
|
|
const ext = this.#ext;
|
|
const query = this.#query;
|
|
|
|
if (this.#state === 'started') {
|
|
gl.endQuery(ext.TIME_ELAPSED_EXT);
|
|
this.#state = 'waiting';
|
|
} else {
|
|
const available = gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE);
|
|
const disjoint = gl.getParameter(ext.GPU_DISJOINT_EXT);
|
|
if (available && !disjoint) {
|
|
this.#duration = gl.getQueryParameter(query, gl.QUERY_RESULT);
|
|
}
|
|
if (available || disjoint) {
|
|
this.#state = 'free';
|
|
}
|
|
}
|
|
}
|
|
getResult() {
|
|
return this.#duration;
|
|
}
|
|
}
|
|
|
|
const { m4: mat4, v3: vec3 } = twgl;
|
|
const mat3 = {
|
|
identity() {
|
|
return new Float32Array(9);
|
|
},
|
|
fromMat4(m, dst) {
|
|
dst = dst || new Float32Array(9);
|
|
|
|
dst[0] = m[ 0]; dst[1] = m[ 1]; dst[2] = m[ 2];
|
|
dst[3] = m[ 4]; dst[4] = m[ 5]; dst[5] = m[ 6];
|
|
dst[6] = m[ 8]; dst[7] = m[ 9]; dst[8] = m[10];
|
|
|
|
return dst;
|
|
},
|
|
};
|
|
|
|
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];
|
|
|
|
async function main() {
|
|
const infoElem = document.querySelector('#info');
|
|
|
|
// Get a WebGPU context from the canvas and configure it
|
|
const canvas = document.querySelector('canvas');
|
|
const gl = canvas.getContext('webgl2', {
|
|
alpha: false,
|
|
antialias: false,
|
|
powerPreference: 'high-performance',
|
|
});
|
|
const timingHelper = new TimingHelper(gl);
|
|
|
|
const vs = `#version 300 es
|
|
uniform mat3 normalMatrix;
|
|
uniform mat4 viewProjection;
|
|
uniform mat4 world;
|
|
uniform vec3 lightWorldPosition;
|
|
uniform vec3 viewWorldPosition;
|
|
|
|
layout(location = 0) in vec4 position;
|
|
layout(location = 1) in vec3 normal;
|
|
layout(location = 2) in vec2 texcoord;
|
|
|
|
out vec3 v_normal;
|
|
out vec3 v_surfaceToLight;
|
|
out vec3 v_surfaceToView;
|
|
out vec2 v_texcoord;
|
|
|
|
void main() {
|
|
gl_Position = viewProjection * world * position;
|
|
|
|
// Orient the normals and pass to the fragment shader
|
|
v_normal = normalMatrix * normal;
|
|
|
|
// Compute the world position of the surface
|
|
vec3 surfaceWorldPosition = (world * position).xyz;
|
|
|
|
// Compute the vector of the surface to the light
|
|
// and pass it to the fragment shader
|
|
v_surfaceToLight = lightWorldPosition - surfaceWorldPosition;
|
|
|
|
// Compute the vector of the surface to the light
|
|
// and pass it to the fragment shader
|
|
v_surfaceToView = viewWorldPosition - surfaceWorldPosition;
|
|
|
|
// Pass the texture coord on to the fragment shader
|
|
v_texcoord = texcoord;
|
|
}
|
|
`;
|
|
|
|
const fs = `#version 300 es
|
|
precision highp float;
|
|
|
|
in vec3 v_normal;
|
|
in vec3 v_surfaceToLight;
|
|
in vec3 v_surfaceToView;
|
|
in vec2 v_texcoord;
|
|
|
|
uniform vec4 color;
|
|
uniform float shininess;
|
|
uniform sampler2D diffuseTexture;
|
|
|
|
out vec4 fragColor;
|
|
|
|
void main() {
|
|
// 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
|
|
vec3 normal = normalize(v_normal);
|
|
|
|
vec3 surfaceToLightDirection = normalize(v_surfaceToLight);
|
|
vec3 surfaceToViewDirection = normalize(v_surfaceToView);
|
|
vec3 halfVector = normalize(
|
|
surfaceToLightDirection + surfaceToViewDirection);
|
|
|
|
// Compute the light by taking the dot product
|
|
// of the normal with the direction to the light
|
|
float light = dot(normal, surfaceToLightDirection);
|
|
|
|
float specular = dot(normal, halfVector);
|
|
specular = specular > 0.0 ?
|
|
pow(specular, shininess) :
|
|
0.0;
|
|
|
|
vec4 diffuse = color * texture(diffuseTexture, v_texcoord);
|
|
// Lets multiply just the color portion (not the alpha)
|
|
// by the light
|
|
vec3 c = diffuse.rgb * light + specular;
|
|
fragColor = vec4(c, diffuse.a);
|
|
}
|
|
`;
|
|
|
|
const prgInfo = twgl.createProgramInfo(gl, [vs, fs]);
|
|
|
|
const bufferInfo = twgl.createBufferInfoFromArrays(gl, {
|
|
position: new Float32Array([1, 1, -1, 1, 1, 1, 1, -1, 1, 1, -1, -1, -1, 1, 1, -1, 1, -1, -1, -1, -1, -1, -1, 1, -1, 1, 1, 1, 1, 1, 1, 1, -1, -1, 1, -1, -1, -1, -1, 1, -1, -1, 1, -1, 1, -1, -1, 1, 1, 1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1, -1, 1, -1, 1, 1, -1, 1, -1, -1, -1, -1, -1]),
|
|
normal: new Float32Array([1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1]),
|
|
texcoord: new Float32Array([1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1]),
|
|
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]),
|
|
});
|
|
|
|
gl.enable(gl.CULL_FACE);
|
|
gl.enable(gl.DEPTH_TEST);
|
|
|
|
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 twgl.createTexture(gl, {
|
|
src: ctx.canvas,
|
|
wrap: gl.CLAMP_TO_EDGE,
|
|
min: gl.LINEAR_MIPMAP_NEAREST,
|
|
mag: gl.LINEAR,
|
|
});
|
|
});
|
|
|
|
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);
|
|
materials.push({
|
|
color,
|
|
shininess,
|
|
texture: randomArrayElement(textures),
|
|
});
|
|
}
|
|
|
|
const maxObjects = 30000;
|
|
const objectInfos = [];
|
|
|
|
for (let i = 0; i < maxObjects; ++i) {
|
|
const material = randomArrayElement(materials);
|
|
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);
|
|
|
|
const uniforms = {
|
|
world: mat4.identity(),
|
|
viewProjection: mat4.identity(),
|
|
normalMatrix: mat3.identity(),
|
|
lightWorldPosition: vec3.create(),
|
|
viewWorldPosition: vec3.create(),
|
|
color: new Float32Array(4),
|
|
shininess: 0,
|
|
diffuseTexture: randomArrayElement(textures),
|
|
};
|
|
|
|
objectInfos.push({
|
|
uniforms,
|
|
|
|
axis,
|
|
material,
|
|
radius,
|
|
speed,
|
|
rotationSpeed,
|
|
scale,
|
|
});
|
|
}
|
|
|
|
gl.clearColor(0.3, 0.3, 0.3, 1);
|
|
|
|
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 then = 0;
|
|
|
|
function render(time) {
|
|
time *= 0.001; // convert to seconds
|
|
const deltaTime = time - then;
|
|
then = time;
|
|
|
|
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;
|
|
}
|
|
|
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
|
|
// Get the current texture from the canvas context and
|
|
// set it as the texture to render to.
|
|
timingHelper.begin();
|
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
|
|
gl.useProgram(prgInfo.program);
|
|
twgl.setBuffersAndAttributes(gl, prgInfo, bufferInfo);
|
|
|
|
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.inverse(mat4.lookAt(eye, target, up));
|
|
|
|
// Combine the view and projection matrixes
|
|
const viewProjectionMatrix = mat4.multiply(projection, viewMatrix);
|
|
|
|
let mathElapsedTimeMs = 0;
|
|
let currentActiveTexture = -1;
|
|
const currentTextures = [];
|
|
|
|
for (let i = 0; i < settings.numObjects; ++i) {
|
|
const {
|
|
uniforms,
|
|
|
|
axis,
|
|
material,
|
|
radius,
|
|
speed,
|
|
rotationSpeed,
|
|
scale,
|
|
} = objectInfos[i];
|
|
const mathTimeStartMs = performance.now();
|
|
|
|
// Copy the viewProjectionMatrix into the uniform values for this object
|
|
// uniforms.viewProjection.set(viewProjectionMatrix);
|
|
|
|
// Compute a world matrix
|
|
const worldValue = uniforms.world;
|
|
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)), uniforms.normalMatrix);
|
|
|
|
const {color, shininess} = material;
|
|
|
|
// uniforms.color.set(color);
|
|
// uniforms.lightWorldPosition.set([-10, 30, 300]);
|
|
// uniforms.viewWorldPosition.set(eye);
|
|
// uniforms.shininess = shininess;
|
|
|
|
mathElapsedTimeMs += performance.now() - mathTimeStartMs;
|
|
|
|
// update the uniforms
|
|
// twgl.setUniforms(prgInfo, uniforms);
|
|
|
|
// Do it manually since we're doing it manually in WebGPU
|
|
const loc = prgInfo.uniformLocations;
|
|
|
|
if (currentTextures[0] !== uniforms.diffuseTexture) {
|
|
if (currentActiveTexture !== 0) {
|
|
currentActiveTexture = 0;
|
|
gl.activeTexture(gl.TEXTURE0);
|
|
}
|
|
currentTextures[0] = uniforms.diffuseTexture;
|
|
gl.bindTexture(gl.TEXTURE_2D, uniforms.diffuseTexture);
|
|
gl.uniform1i(loc.diffuseTexture, 0);
|
|
}
|
|
|
|
gl.uniformMatrix4fv(loc.world, false, worldValue);
|
|
gl.uniformMatrix4fv(loc.viewProjection, false, viewProjectionMatrix);
|
|
gl.uniformMatrix3fv(loc.normalMatrix, false, uniforms.normalMatrix);
|
|
gl.uniform3fv(loc.lightWorldPosition, [-10, 30, 300]);
|
|
gl.uniform3fv(loc.viewWorldPosition, eye);
|
|
gl.uniform4fv(loc.color, color);
|
|
gl.uniform1f(loc.shininess, shininess);
|
|
|
|
// twgl.drawBufferInfo(gl, bufferInfo);
|
|
gl.drawElements(gl.TRIANGLES, bufferInfo.numElements, gl.UNSIGNED_SHORT, 0);
|
|
}
|
|
timingHelper.end();
|
|
|
|
const elapsedTimeMs = performance.now() - startTimeMs;
|
|
fpsAverage.addSample(1 / deltaTime);
|
|
jsAverage.addSample(elapsedTimeMs);
|
|
mathAverage.addSample(mathElapsedTimeMs);
|
|
gpuAverage.addSample(timingHelper.getResult());
|
|
|
|
infoElem.textContent = `\
|
|
js : ${jsAverage.get().toFixed(1)}ms
|
|
math: ${mathAverage.get().toFixed(1)}ms
|
|
fps : ${fpsAverage.get().toFixed(0)}
|
|
gpu : ${(gpuAverage.get() / 1000 / 1000).toFixed(1)}ms
|
|
`;
|
|
|
|
requestAnimationFrame(render);
|
|
}
|
|
requestAnimationFrame(render);
|
|
|
|
const observer = new ResizeObserver(entries => {
|
|
entries.forEach(entry => {
|
|
canvasToSizeMap.set(entry.target, {
|
|
width: Math.max(1, entry.contentBoxSize[0].inlineSize),
|
|
height: Math.max(1, entry.contentBoxSize[0].blockSize),
|
|
});
|
|
});
|
|
});
|
|
observer.observe(canvas);
|
|
}
|
|
|
|
main();
|
|
</script>
|
|
</html>
|