diff --git a/README.md b/README.md index 639bd25..1d722ff 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ npm install npm run dev # → http://localhost:5173 ``` -No API keys, no server, no account. It is a static page and three.js. +No API keys, no server, no account, and no libraries: it is a static page with +its own renderer, drawing with WebGPU or WebGL 2. ## Play @@ -90,7 +91,7 @@ comes back on the beat. The run is a plain object graph with no pixels in it — course, egg, lives, score — and the renderer reads a snapshot of it every frame. Nothing in -`src/core/` imports three.js or touches the DOM, which is why a seed can be +`src/core/` touches the GPU or the DOM, which is why a seed can be played out headlessly in a test and asserted on. ``` @@ -98,7 +99,7 @@ index.html Markup only — Vite's entry src/ main.js The wiring, and nothing else styles.css The HUD, and the CRT it pretends to be on - core/ The game. No three.js, no DOM, no randomness it did not seed + core/ The game. No GPU, no DOM, no randomness it did not seed game.js Lives, score, pickups, and real seconds → fixed ticks course.js The course, laid a pattern at a time, ahead of the egg player.js Gravity, lanes, jump, coyote time, landings @@ -107,7 +108,7 @@ src/ rng.js A seeded stream, so a seed is a course motion.js The spring and the chase everything eases on emitter.js - render/ three.js. Reads snapshots, owns no game state + render/ The Matrix, built out of gpu/. Reads snapshots, owns no game state scene.js Renderer, camera, fog, backdrop, and the light for the egg view.js Snapshot → scene graph, and the chase camera rig.js Where that camera sits and what it looks at, as arithmetic @@ -119,6 +120,20 @@ src/ rain.js The backdrop, painted on a canvas materials.js Four shared line materials theme.js One colour, and the two warnings + gpu/ The renderer. Knows nothing about eggs + renderer.js Picks WebGPU or WebGL 2, and draws a scene with either + frame.js What is visible, in what order, and every uniform byte + webgpu.js The WebGPU backend + webgl.js The WebGL 2 backend + graph.js Nodes, meshes, lines, lights, the camera + geometry.js Vertex data, and the spheres, cylinders and rings + material.js Lit, unlit and line materials, as plain data + texture.js A canvas and how to sample it + environment.js His studio, prefiltered into a ladder of blurs + dfg.js The table the specular highlight is read from + math.js Vectors and double-precision matrices + color.js sRGB in, linear light inside, sRGB out + shaders/ The GLSL and the WGSL, one of each ui/ hud.js The readouts and the panel between runs input.js Keys and swipes → one frame of intent @@ -147,12 +162,52 @@ There is also an autopilot in `test/helpers/pilot.js`. It plays badly on purpose — one frame of lookahead, no double jump — and the suite fails if it cannot get a few hundred metres down a seed. +### It draws its own pixels + +There is no 3D library in here. `src/gpu/` is a renderer written for this world +and nothing else: a scene graph, three shaders — lit, unlit, and the +backdrop — and two backends that draw them. Which one draws comes down to +antialiasing: WebGPU multisamples at 4× and no more, while Chrome gives a +WebGL canvas 8× on a desktop GPU, which is what the game was tuned under. So +where WebGL can sample more than 4× it draws, and elsewhere — most phones — +WebGPU does. Either falls back to the other if it will not start, and +`?renderer=webgl` or `?renderer=webgpu` in the address picks one by hand. The +shaders are in `src/gpu/shaders/`, as `.glsl` for WebGL and `.wgsl` for +WebGPU, and they are the same shaders twice: change a constant in one and +change it in the other. + +The two backends cannot disagree about what to draw, because neither of them +decides. `frame.js` walks the scene once a frame, culls it, sorts it — solid +things first, see-through things far to near — and packs every uniform into +blocks of vec4s and mat4s that std140 and WGSL lay out byte for byte the same. +A backend only says *draw this*. `test/gpu.test.js` reads the GLSL and the +WGSL and fails if either has drifted from what `frame.js` packs. + +Everything in the world but the egg is one-pixel lines added onto the black, +and that is what the GPU draws them as — native lines, additive, faded into +the black by the fog before they are added, so a slab at a hundred metres is +a slab at a tenth of the brightness rather than a slab behind a curtain. The +deck under them is a black fill pushed a hair back in depth, so a rung on its +top surface is never fighting it for a pixel. The rain is a canvas stretched +over the whole frame behind everything, at a third of its brightness. + +The lighting is the lighting Marc was made under, kept term for term: GGX specular with +multiple-scattering compensation read off the same DFG table, Charlie sheen +and the clearcoat on Marc's shell, fog mixed in after the encode. The +environment the lit surfaces reflect is prefiltered into a cube-UV ladder of +seven blurs by GGX importance sampling — on the CPU, in a worker, because the +cube is sixteen texels a side — and the shaders pick a rung by roughness +through the same curve as before. Rendered side by side with the build that +used a library, frame for frame down a seeded run, the WebGL picture agrees +with it to within one level in 255, and the WebGPU one differs only in which +pixels along an antialiased edge get a sample. + | Script | | |---|---| | `npm run dev` | Vite | | `npm run build` | Bundles to `dist/` | | `npm run preview` | Serves the build | -| `npm test` | `node:test` over the core, the HUD, the page and the music | +| `npm test` | `node:test` over the core, the renderer, the HUD, the page and the music | | `npm run lint` | ESLint | CI runs the lint, the tests on Node 22.12 and 24, and a build that then has to diff --git a/package-lock.json b/package-lock.json index 38eb1f8..3a5513a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,6 @@ "": { "name": "eggscape", "version": "0.1.0", - "dependencies": { - "three": "^0.185.1" - }, "devDependencies": { "@eslint/js": "^10.0.1", "eslint": "^10.9.1", @@ -2035,12 +2032,6 @@ "dev": true, "license": "MIT" }, - "node_modules/three": { - "version": "0.185.1", - "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", - "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/package.json b/package.json index 385f87b..ce9ef5e 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,6 @@ "test": "node --test \"test/**/*.test.js\"", "lint": "eslint ." }, - "dependencies": { - "three": "^0.185.1" - }, "devDependencies": { "@eslint/js": "^10.0.1", "eslint": "^10.9.1", diff --git a/src/gpu/color.js b/src/gpu/color.js new file mode 100644 index 0000000..d7052ac --- /dev/null +++ b/src/gpu/color.js @@ -0,0 +1,35 @@ +/** + * Colour, in the two encodings that matter. Every hex in the theme is sRGB — + * what a designer picked off a swatch — and every sum the shaders do is in + * linear light, so a colour is decoded once on the way in and encoded once + * on the way out, in the fragment shader, and nowhere in between. + */ + +export function srgbToLinear(c) { + return c < 0.04045 ? c * 0.0773993808 : Math.pow(c * 0.9478672986 + 0.0521327014, 2.4); +} + +export function linearToSrgb(c) { + return c < 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 0.41666) - 0.055; +} + +/** A hex number or a `#rrggbb` string, as its three sRGB channels in 0–1. */ +function channels(value) { + const hex = typeof value === 'string' ? parseInt(value.replace('#', ''), 16) : value; + return [((hex >> 16) & 255) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255]; +} + +/** A theme colour, decoded to linear light — what a material or a light holds. */ +export function linear(value, scale = 1) { + return channels(value).map((c) => srgbToLinear(c) * scale); +} + +/** + * A theme colour as the screen wants it. Fog and the clear colour are mixed + * after the shader has encoded its output, so they have to arrive encoded + * too; the round trip through linear is kept so the numbers land exactly + * where a decoded-then-encoded colour would. + */ +export function display(value) { + return linear(value).map(linearToSrgb); +} diff --git a/src/gpu/dfg.js b/src/gpu/dfg.js new file mode 100644 index 0000000..550bdea --- /dev/null +++ b/src/gpu/dfg.js @@ -0,0 +1,31 @@ +/** + * The split-sum DFG table for image-based lighting: for a roughness (across) + * and a viewing angle (down, as N·V), the scale and bias that turn a + * surface's reflectance at normal incidence into the fraction of prefiltered + * environment it actually reflects. Sixteen by sixteen, two half floats a + * texel, sampled with linear filtering. + * + * These are the values three.js r185 ships (MIT licence) — the egg was lit + * through this table before the renderer was written, and the highlight on + * the shell is only the same highlight if the table is the same table. + */ +export const DFG_SIZE = 16; + +export const DFG = new Uint16Array([ + 0x30b5, 0x3ad1, 0x314c, 0x3a4d, 0x33d2, 0x391c, 0x35ef, 0x3828, 0x37f3, 0x36a6, 0x38d1, 0x3539, 0x3979, 0x3410, 0x39f8, 0x3252, 0x3a53, 0x30f0, 0x3a94, 0x2fc9, 0x3abf, 0x2e35, 0x3ada, 0x2d05, 0x3ae8, 0x2c1f, 0x3aed, 0x2ae0, 0x3aea, 0x29d1, 0x3ae1, 0x28ff, + 0x3638, 0x38e4, 0x364a, 0x38ce, 0x3699, 0x385e, 0x374e, 0x372c, 0x3839, 0x35a4, 0x38dc, 0x3462, 0x396e, 0x32c4, 0x39de, 0x3134, 0x3a2b, 0x3003, 0x3a59, 0x2e3a, 0x3a6d, 0x2ce1, 0x3a6e, 0x2bba, 0x3a5f, 0x2a33, 0x3a49, 0x290a, 0x3a2d, 0x2826, 0x3a0a, 0x26e8, + 0x3894, 0x36d7, 0x3897, 0x36c9, 0x38a3, 0x3675, 0x38bc, 0x35ac, 0x38ee, 0x349c, 0x393e, 0x3332, 0x3997, 0x3186, 0x39e2, 0x3038, 0x3a13, 0x2e75, 0x3a29, 0x2cf5, 0x3a2d, 0x2bac, 0x3a21, 0x29ff, 0x3a04, 0x28bc, 0x39dc, 0x2790, 0x39ad, 0x261a, 0x3978, 0x24fa, + 0x39ac, 0x34a8, 0x39ac, 0x34a3, 0x39ae, 0x3480, 0x39ae, 0x3423, 0x39b1, 0x330e, 0x39c2, 0x31a9, 0x39e0, 0x3063, 0x39fc, 0x2eb5, 0x3a0c, 0x2d1d, 0x3a14, 0x2bcf, 0x3a07, 0x29ff, 0x39e9, 0x28a3, 0x39be, 0x273c, 0x3989, 0x25b3, 0x394a, 0x2488, 0x3907, 0x2345, + 0x3a77, 0x3223, 0x3a76, 0x321f, 0x3a73, 0x3204, 0x3a6a, 0x31b3, 0x3a58, 0x3114, 0x3a45, 0x303b, 0x3a34, 0x2eb6, 0x3a26, 0x2d31, 0x3a1e, 0x2bef, 0x3a0b, 0x2a0d, 0x39ec, 0x28a1, 0x39c0, 0x271b, 0x3987, 0x2580, 0x3944, 0x2449, 0x38fa, 0x22bd, 0x38ac, 0x2155, + 0x3b07, 0x2fca, 0x3b06, 0x2fca, 0x3b00, 0x2fb8, 0x3af4, 0x2f7c, 0x3adb, 0x2eea, 0x3ab4, 0x2e00, 0x3a85, 0x2cec, 0x3a5e, 0x2bc5, 0x3a36, 0x2a00, 0x3a0d, 0x2899, 0x39dc, 0x2707, 0x39a0, 0x2562, 0x395a, 0x2424, 0x390b, 0x2268, 0x38b7, 0x20fd, 0x385f, 0x1fd1, + 0x3b69, 0x2cb9, 0x3b68, 0x2cbb, 0x3b62, 0x2cbb, 0x3b56, 0x2cae, 0x3b3b, 0x2c78, 0x3b0d, 0x2c0a, 0x3acf, 0x2ae3, 0x3a92, 0x2998, 0x3a54, 0x2867, 0x3a17, 0x26d0, 0x39d3, 0x253c, 0x3989, 0x2402, 0x3935, 0x2226, 0x38dc, 0x20bd, 0x387d, 0x1f54, 0x381d, 0x1db3, + 0x3ba9, 0x296b, 0x3ba8, 0x296f, 0x3ba3, 0x297b, 0x3b98, 0x2987, 0x3b7f, 0x2976, 0x3b4e, 0x2927, 0x3b0e, 0x2895, 0x3ac2, 0x27b7, 0x3a73, 0x263b, 0x3a23, 0x24e7, 0x39d0, 0x239b, 0x3976, 0x21d9, 0x3917, 0x207e, 0x38b2, 0x1ee7, 0x384b, 0x1d53, 0x37c7, 0x1c1e, + 0x3bd2, 0x25cb, 0x3bd1, 0x25d3, 0x3bcd, 0x25f0, 0x3bc2, 0x261f, 0x3bad, 0x2645, 0x3b7d, 0x262d, 0x3b3e, 0x25c4, 0x3aec, 0x250f, 0x3a93, 0x243a, 0x3a32, 0x22ce, 0x39d0, 0x215b, 0x3969, 0x202a, 0x38fe, 0x1e6e, 0x388f, 0x1cf1, 0x381f, 0x1b9b, 0x3762, 0x19dd, + 0x3be9, 0x21ab, 0x3be9, 0x21b7, 0x3be5, 0x21e5, 0x3bdd, 0x2241, 0x3bc9, 0x22a7, 0x3ba0, 0x22ec, 0x3b62, 0x22cd, 0x3b0f, 0x2247, 0x3aae, 0x2175, 0x3a44, 0x2088, 0x39d4, 0x1f49, 0x3960, 0x1dbe, 0x38e9, 0x1c77, 0x3870, 0x1ae8, 0x37f1, 0x1953, 0x3708, 0x181b, + 0x3bf6, 0x1cea, 0x3bf6, 0x1cfb, 0x3bf3, 0x1d38, 0x3bec, 0x1dbd, 0x3bda, 0x1e7c, 0x3bb7, 0x1f25, 0x3b7d, 0x1f79, 0x3b2c, 0x1f4c, 0x3ac6, 0x1ea6, 0x3a55, 0x1dbb, 0x39da, 0x1cbd, 0x395a, 0x1b9d, 0x38d8, 0x1a00, 0x3855, 0x18ac, 0x37ab, 0x173c, 0x36b7, 0x1598, + 0x3bfc, 0x1736, 0x3bfc, 0x1759, 0x3bf9, 0x17e7, 0x3bf4, 0x1896, 0x3be4, 0x1997, 0x3bc6, 0x1aa8, 0x3b91, 0x1b84, 0x3b43, 0x1bd2, 0x3ade, 0x1b8a, 0x3a65, 0x1acd, 0x39e2, 0x19d3, 0x3957, 0x18cd, 0x38ca, 0x17b3, 0x383e, 0x1613, 0x376d, 0x14bf, 0x366f, 0x135e, + 0x3bff, 0x101b, 0x3bff, 0x1039, 0x3bfc, 0x10c8, 0x3bf9, 0x1226, 0x3bea, 0x1428, 0x3bcf, 0x1584, 0x3b9f, 0x16c5, 0x3b54, 0x179a, 0x3af0, 0x17ce, 0x3a76, 0x1771, 0x39ea, 0x16a4, 0x3956, 0x15a7, 0x38bf, 0x14a7, 0x3829, 0x1379, 0x3735, 0x11ea, 0x362d, 0x10a1, + 0x3c00, 0x061b, 0x3c00, 0x066a, 0x3bfe, 0x081c, 0x3bfa, 0x0a4c, 0x3bed, 0x0d16, 0x3bd5, 0x0fb3, 0x3ba9, 0x114d, 0x3b63, 0x127c, 0x3b01, 0x132f, 0x3a85, 0x1344, 0x39f4, 0x12d2, 0x3957, 0x120d, 0x38b5, 0x1122, 0x3817, 0x103c, 0x3703, 0x0ed3, 0x35f0, 0x0d6d, + 0x3c00, 0x007a, 0x3c00, 0x0089, 0x3bfe, 0x011d, 0x3bfb, 0x027c, 0x3bf0, 0x04fa, 0x3bda, 0x0881, 0x3bb1, 0x0acd, 0x3b6f, 0x0c97, 0x3b10, 0x0d7b, 0x3a93, 0x0df1, 0x39fe, 0x0def, 0x3959, 0x0d8a, 0x38af, 0x0ce9, 0x3808, 0x0c31, 0x36d5, 0x0af0, 0x35b9, 0x09a3, + 0x3c00, 0x0000, 0x3c00, 0x0001, 0x3bff, 0x0015, 0x3bfb, 0x0059, 0x3bf2, 0x00fd, 0x3bdd, 0x01df, 0x3bb7, 0x031c, 0x3b79, 0x047c, 0x3b1d, 0x05d4, 0x3aa0, 0x06d5, 0x3a08, 0x075a, 0x395d, 0x075e, 0x38aa, 0x06f7, 0x37f4, 0x0648, 0x36ac, 0x0576, 0x3586, 0x049f, +]); diff --git a/src/gpu/environment.js b/src/gpu/environment.js new file mode 100644 index 0000000..6aff8ff --- /dev/null +++ b/src/gpu/environment.js @@ -0,0 +1,442 @@ +import { srgbToLinear } from './color.js'; + +/** + * Prefiltered environment lighting, from a small equirectangular canvas. + * + * A rough surface reflects a blurred world, and how blurred depends on how + * rough: so the environment is filtered once, at boot, into a ladder of + * progressively blurrier copies, and the lit shader picks a rung by + * roughness. The layout is a "cube UV" atlas — the six faces of a small cube + * map laid out three across and two down, one block per rung, with a texel + * of padding round every face so a bilinear lookup never needs to know which + * face is next door. + * + * Every step here — the face layout, the padding, the GGX importance + * sampling, the order the rungs are built in — is the same as the + * environment this game was lit by before it drew its own pixels, so the + * shell keeps the light it was designed under. It runs on the CPU because the + * cube is sixteen texels a side and a run of it costs less than compiling the + * shaders a GPU pass would need. + */ + +const LOD_MIN = 4; +const EXTRA_LODS = 6; +const GGX_SAMPLES = 256; + +/** Round to the nearest half float, which is what the atlas is stored as. */ +const f32 = new Float32Array(1); +const u32 = new Uint32Array(f32.buffer); + +export function toHalf(value) { + f32[0] = value; + const x = u32[0]; + const sign = (x >>> 16) & 0x8000; + let exp = ((x >>> 23) & 0xff) - 127 + 15; + let mant = x & 0x7fffff; + if (exp <= 0) { + if (exp < -10) return sign; + mant |= 0x800000; + const shift = 14 - exp; + let half = mant >> shift; + if ((mant >> (shift - 1)) & 1) half += 1; + return sign | half; + } + if (exp >= 31) return sign | 0x7c00; + let half = sign | (exp << 10) | (mant >> 13); + if (mant & 0x1000) half += 1; + return half; +} + +export function fromHalf(h) { + const sign = h & 0x8000 ? -1 : 1; + const exp = (h >> 10) & 0x1f; + const mant = h & 0x3ff; + if (exp === 0) return sign * mant * 2 ** -24; + if (exp === 31) return mant ? NaN : sign * Infinity; + return sign * (1 + mant / 1024) * 2 ** (exp - 15); +} + +const quantise = (value) => fromHalf(toHalf(value)); + +/** The source picture as linear floats, rows from the bottom, with its + * mipmap chain — the lookup into it picks a level by screen footprint. */ +function sourceLevels({ width, height, data: pixels }) { + const decode = new Float32Array(256); + for (let i = 0; i < 256; i++) decode[i] = srgbToLinear(i / 255); + + const base = new Float32Array(width * height * 3); + for (let y = 0; y < height; y++) { + const row = height - 1 - y; + for (let x = 0; x < width; x++) { + const from = (row * width + x) * 4; + const to = (y * width + x) * 3; + base[to] = decode[pixels[from]]; + base[to + 1] = decode[pixels[from + 1]]; + base[to + 2] = decode[pixels[from + 2]]; + } + } + + const levels = [{ data: base, width, height }]; + for (let w = width, h = height; w > 1 || h > 1;) { + const prev = levels[levels.length - 1]; + w = Math.max(1, w >> 1); + h = Math.max(1, h >> 1); + const data = new Float32Array(w * h * 3); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const x0 = Math.min(x * 2, prev.width - 1), x1 = Math.min(x * 2 + 1, prev.width - 1); + const y0 = Math.min(y * 2, prev.height - 1), y1 = Math.min(y * 2 + 1, prev.height - 1); + for (let c = 0; c < 3; c++) { + data[(y * w + x) * 3 + c] = 0.25 * ( + prev.data[(y0 * prev.width + x0) * 3 + c] + prev.data[(y0 * prev.width + x1) * 3 + c] + + prev.data[(y1 * prev.width + x0) * 3 + c] + prev.data[(y1 * prev.width + x1) * 3 + c]); + } + } + } + levels.push({ data, width: w, height: h }); + } + return levels; +} + +function bilinear(level, u, v, out, weight) { + const { data, width, height } = level; + const x = u * width - 0.5; + const y = v * height - 0.5; + const fx = Math.floor(x), fy = Math.floor(y); + const tx = x - fx, ty = y - fy; + const x0 = Math.min(Math.max(fx, 0), width - 1), x1 = Math.min(Math.max(fx + 1, 0), width - 1); + const y0 = Math.min(Math.max(fy, 0), height - 1), y1 = Math.min(Math.max(fy + 1, 0), height - 1); + const a = (y0 * width + x0) * 3, b = (y0 * width + x1) * 3; + const c = (y1 * width + x0) * 3, d = (y1 * width + x1) * 3; + for (let k = 0; k < 3; k++) { + const top = data[a + k] + (data[b + k] - data[a + k]) * tx; + const bottom = data[c + k] + (data[d + k] - data[c + k]) * tx; + out[k] += (top + (bottom - top) * ty) * weight; + } +} + +function equirectUv(x, y, z, out) { + const len = Math.hypot(x, y, z); + out[0] = Math.atan2(z / len, x / len) / (2 * Math.PI) + 0.5; + out[1] = Math.asin(Math.min(Math.max(y / len, -1), 1)) / Math.PI + 0.5; +} + +/** Which way a texel of a face points, from its uv across that face. */ +function direction(u, v, face, out) { + u = 2 * u - 1; + v = 2 * v - 1; + switch (face) { + case 0: out[0] = 1; out[1] = v; out[2] = u; break; + case 1: out[0] = -u; out[1] = 1; out[2] = -v; break; + case 2: out[0] = -u; out[1] = v; out[2] = 1; break; + case 3: out[0] = -1; out[1] = v; out[2] = -u; break; + case 4: out[0] = -u; out[1] = -1; out[2] = v; break; + default: out[0] = u; out[1] = v; out[2] = -1; + } +} + +function radicalInverse(bits) { + bits = ((bits << 16) | (bits >>> 16)) >>> 0; + bits = (((bits & 0x55555555) << 1) | ((bits & 0xaaaaaaaa) >>> 1)) >>> 0; + bits = (((bits & 0x33333333) << 2) | ((bits & 0xcccccccc) >>> 2)) >>> 0; + bits = (((bits & 0x0f0f0f0f) << 4) | ((bits & 0xf0f0f0f0) >>> 4)) >>> 0; + bits = (((bits & 0x00ff00ff) << 8) | ((bits & 0xff00ff00) >>> 8)) >>> 0; + return bits * 2.3283064365386963e-10; +} + +/** + * The GGX kernel for one rung, as taps on the hemisphere round +z. + * + * Half vectors are importance-sampled from the GGX distribution of visible + * normals for a view straight down the normal (Heitz 2018), on a Hammersley + * set. With the view along the normal, each half vector reflects into a light + * direction whose height above the surface depends on nothing but the sample + * — so the reflection, the horizon test and the cosine weight are all worked + * out here, once, and a tap is just the light direction in the tangent frame. + */ +function ggxKernel(roughness) { + const alpha = roughness * roughness; + const taps = []; + let total = 0; + for (let i = 0; i < GGX_SAMPLES; i++) { + const r = Math.sqrt(i / GGX_SAMPLES); + const phi = 2 * Math.PI * radicalInverse(i); + const t1 = r * Math.cos(phi); + const t2 = r * Math.sin(phi); + const nz = Math.sqrt(Math.max(0, 1 - t1 * t1 - t2 * t2)); + const hx = alpha * t1, hy = alpha * t2, hz = Math.max(0, nz); + const len = Math.sqrt(hx * hx + hy * hy + hz * hz); + const x = hx / len, y = hy / len, z = hz / len; + /** L = 2(N·H)H − N, and N·H is the half vector's own height. */ + const lz = 2 * z * z - 1; + if (lz <= 0) continue; + taps.push(2 * z * x, 2 * z * y, lz); + total += lz; + } + return { taps: new Float64Array(taps), total }; +} + +/** + * The atlas lookup the shaders use, ported line for line: pick the face the + * direction leaves the cube through, find the texel on it, step over to the + * rung's block, and filter between the four nearest texels. + */ +function lookup(target, { width, cubeSize }, x, y, z, mipInt, out, weight) { + const ax = Math.abs(x), ay = Math.abs(y), az = Math.abs(z); + let face, u, v; + if (ax > az && ax > ay) { + face = x > 0 ? 0 : 3; + u = (x > 0 ? z : -z) / ax; + v = y / ax; + } else if (ax > az || ay >= az) { + face = y > 0 ? 1 : 4; + u = -x / ay; + v = (y > 0 ? -z : z) / ay; + } else { + face = z > 0 ? 2 : 5; + u = (z > 0 ? -x : x) / az; + v = y / az; + } + + const filterInt = mipInt < LOD_MIN ? LOD_MIN - mipInt : 0; + const faceSize = mipInt > LOD_MIN ? 1 << mipInt : 1 << LOD_MIN; + const half = (faceSize - 2) * 0.5; + let col = face, row = 0; + if (face > 2) { + col -= 3; + row = faceSize; + } + /** Texel-space position less the half texel a linear filter centres on. */ + const px = (u + 1) * half + 0.5 + col * faceSize + filterInt * 48; + const py = (v + 1) * half + 0.5 + row + 4 * (cubeSize - faceSize); + + const fx = Math.floor(px), fy = Math.floor(py); + const tx = px - fx, ty = py - fy; + const a = (fy * width + fx) * 3; + const c = a + width * 3; + const top0 = target[a] + (target[a + 3] - target[a]) * tx; + const top1 = target[a + 1] + (target[a + 4] - target[a + 1]) * tx; + const top2 = target[a + 2] + (target[a + 5] - target[a + 2]) * tx; + const bot0 = target[c] + (target[c + 3] - target[c]) * tx; + const bot1 = target[c + 1] + (target[c + 4] - target[c + 1]) * tx; + const bot2 = target[c + 2] + (target[c + 5] - target[c + 2]) * tx; + out[0] += (top0 + (bot0 - top0) * ty) * weight; + out[1] += (top1 + (bot1 - top1) * ty) * weight; + out[2] += (top2 + (bot2 - top2) * ty) * weight; +} + +/** + * One GGX-filtered texel: the kernel turned to face the texel's direction, + * every tap looked up in the rung below and weighted by how square-on it + * arrives. The lookup is `lookup` again, inlined by hand — this loop runs a + * couple of million times at boot, and a call per tap was most of the cost. + */ +function gather(atlas, { width, cubeSize }, mipIn, { taps, total }, dir, out) { + const len = Math.sqrt(dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]); + const nx = dir[0] / len, ny = dir[1] / len, nz = dir[2] / len; + const side = Math.abs(nz) < 0.999; + const ux = side ? 0 : 1, uz = side ? 1 : 0; + let tx = -uz * ny, ty = uz * nx - ux * nz, tz = ux * ny; + const tl = Math.sqrt(tx * tx + ty * ty + tz * tz); + tx /= tl; ty /= tl; tz /= tl; + const bx = ny * tz - nz * ty, by = nz * tx - nx * tz, bz = nx * ty - ny * tx; + + const filterInt = mipIn < LOD_MIN ? LOD_MIN - mipIn : 0; + const faceSize = mipIn > LOD_MIN ? 1 << mipIn : 1 << LOD_MIN; + const half = (faceSize - 2) * 0.5; + const baseX = 0.5 + filterInt * 48; + const baseY = 0.5 + 4 * (cubeSize - faceSize); + const stride = width * 3; + + let r = 0, g = 0, b = 0; + for (let i = 0; i < taps.length; i += 3) { + const ta = taps[i], tb = taps[i + 1], w = taps[i + 2]; + const x = ta * tx + tb * bx + w * nx; + const y = ta * ty + tb * by + w * ny; + const z = ta * tz + tb * bz + w * nz; + const ax = x < 0 ? -x : x, ay = y < 0 ? -y : y, az = z < 0 ? -z : z; + let u, v, col, row; + if (ax > az && ax > ay) { + const inv = 1 / ax; + u = (x > 0 ? z : -z) * inv; v = y * inv; + col = 0; row = x > 0 ? 0 : faceSize; + } else if (ax > az || ay >= az) { + const inv = 1 / ay; + u = -x * inv; v = (y > 0 ? -z : z) * inv; + col = faceSize; row = y > 0 ? 0 : faceSize; + } else { + const inv = 1 / az; + u = (z > 0 ? -x : x) * inv; v = y * inv; + col = 2 * faceSize; row = z > 0 ? 0 : faceSize; + } + const px = (u + 1) * half + baseX + col; + const py = (v + 1) * half + baseY + row; + const fx = px | 0, fy = py | 0; + const sx = px - fx, sy = py - fy; + const at = fy * stride + fx * 3; + const below = at + stride; + const t0 = atlas[at] + (atlas[at + 3] - atlas[at]) * sx; + const t1 = atlas[at + 1] + (atlas[at + 4] - atlas[at + 1]) * sx; + const t2 = atlas[at + 2] + (atlas[at + 5] - atlas[at + 2]) * sx; + const b0 = atlas[below] + (atlas[below + 3] - atlas[below]) * sx; + const b1 = atlas[below + 1] + (atlas[below + 4] - atlas[below + 1]) * sx; + const b2 = atlas[below + 2] + (atlas[below + 5] - atlas[below + 2]) * sx; + r += (t0 + (b0 - t0) * sy) * w; + g += (t1 + (b1 - t1) * sy) * w; + b += (t2 + (b2 - t2) * sy) * w; + } + out[0] = r / total; + out[1] = g / total; + out[2] = b / total; +} + +/** + * Filters `canvas` — an equirectangular picture of the surroundings, sky at + * the top — into an atlas the lit shader can sample. Returns the atlas as + * half floats with the numbers the shader needs to find its way round it. + */ +export function prefilter(canvas) { + const { width, height } = canvas; + return prefilterPixels(canvas.getContext('2d').getImageData(0, 0, width, height)); +} + +/** + * The same, off the main thread: a worker does the filtering while the rest + * of the page boots and the GPU starts, and the environment handed back says + * `ready: false` until it lands. The renderer holds its first frame until + * then, so nothing is ever drawn under the wrong light. A browser that will + * not start the worker gets it done here instead, as it always used to be. + */ +export function prefilterInBackground(canvas) { + const { width, height } = canvas; + const image = canvas.getContext('2d').getImageData(0, 0, width, height); + const env = { ready: false }; + const land = (result) => Object.assign(env, result, { ready: true }); + let worker = null; + try { + worker = new Worker(new URL('./environment.worker.js', import.meta.url), { type: 'module' }); + } catch { + return land(prefilterPixels(image)); + } + worker.onmessage = ({ data }) => { + land(data); + worker.terminate(); + }; + worker.onerror = (event) => { + event.preventDefault?.(); + if (!env.ready) land(prefilterPixels(image)); + worker.terminate(); + }; + worker.postMessage({ width, height, data: image.data }); + return env; +} + +/** `prefilter`, from the picture's pixels: `{ width, height, data }`. */ +export function prefilterPixels(image) { + const lodMax = Math.floor(Math.log2(image.width / 4)); + const cubeSize = 2 ** lodMax; + const width = 3 * Math.max(cubeSize, 16 * 7); + const height = 4 * cubeSize; + const texelWidth = 1 / width; + const texelHeight = 1 / height; + + const sizes = []; + for (let i = 0, lod = lodMax; i < lodMax - LOD_MIN + 1 + EXTRA_LODS; i++) { + sizes.push(2 ** lod); + if (lod > LOD_MIN) lod--; + } + const lods = sizes.length; + + const atlas = new Float32Array(width * height * 3); + const pingpong = new Float32Array(width * height * 3); + const sample = [0, 0, 0]; + + const layout = { width, height, cubeSize }; + const cubeUV = (target, x, y, z, mipInt, out, weight) => lookup(target, layout, x, y, z, mipInt, out, weight); + + /** + * Every texel of one rung, face by face, as the GPU would rasterise it: + * texel centres across a quad whose uv runs a padding texel past both + * edges of the face, so the border texels point into the neighbours. + */ + function rasterise(lod, shade) { + const size = sizes[lod]; + const ox = 3 * size * (lod > lodMax - LOD_MIN ? lod - lodMax + LOD_MIN : 0); + const oy = 4 * (cubeSize - size); + const pad = 1 / (size - 2); + const dir = [0, 0, 0]; + for (let face = 0; face < 6; face++) { + const fx = ox + (face % 3) * size; + const fy = oy + (face > 2 ? size : 0); + for (let py = 0; py < size; py++) { + for (let px = 0; px < size; px++) { + const u = -pad + ((px + 0.5) / size) * (1 + 2 * pad); + const v = -pad + ((py + 0.5) / size) * (1 + 2 * pad); + direction(u, v, face, dir); + shade((fy + py) * width + fx + px, dir, face, px, py, size, pad); + } + } + } + } + + /** Rung 0: the picture itself, looked up the way a GPU would, with the + * mip level picked from the footprint of each 2×2 block of texels. */ + const source = sourceLevels(image); + const top = source.length - 1; + const uv00 = [0, 0], uv10 = [0, 0], uv01 = [0, 0], uv = [0, 0]; + const corner = [0, 0, 0]; + rasterise(0, (at, dir, face, px, py, size, pad) => { + const qx = px & ~1, qy = py & ~1; + const across = (q) => -pad + ((q + 0.5) / size) * (1 + 2 * pad); + direction(across(qx), across(qy), face, corner); equirectUv(corner[0], corner[1], corner[2], uv00); + direction(across(qx + 1), across(qy), face, corner); equirectUv(corner[0], corner[1], corner[2], uv10); + direction(across(qx), across(qy + 1), face, corner); equirectUv(corner[0], corner[1], corner[2], uv01); + const W = source[0].width, H = source[0].height; + const footprint = Math.max( + Math.hypot((uv10[0] - uv00[0]) * W, (uv10[1] - uv00[1]) * H), + Math.hypot((uv01[0] - uv00[0]) * W, (uv01[1] - uv00[1]) * H), + ); + const lambda = Math.min(Math.max(Math.log2(footprint), 0), top); + const lo = Math.floor(lambda); + const t = lambda - lo; + + equirectUv(dir[0], dir[1], dir[2], uv); + sample[0] = sample[1] = sample[2] = 0; + bilinear(source[lo], uv[0], uv[1], sample, 1 - t); + if (t > 0) bilinear(source[Math.min(lo + 1, top)], uv[0], uv[1], sample, t); + for (let k = 0; k < 3; k++) atlas[at * 3 + k] = quantise(sample[k]); + }); + + /** Each rung after that is the one before it, blurred by GGX samples over + * just enough extra roughness to reach its own. */ + for (let lod = 1; lod < lods; lod++) { + const target = lod / (lods - 1); + const from = (lod - 1) / (lods - 1); + const roughness = Math.sqrt(target * target - from * from) * (target * 1.25); + const kernel = ggxKernel(roughness); + const mipIn = lodMax - (lod - 1); + + rasterise(lod, (at, dir) => { + gather(atlas, layout, mipIn, kernel, dir, sample); + for (let k = 0; k < 3; k++) pingpong[at * 3 + k] = quantise(sample[k]); + }); + + /** And copied back through the same lookup, which is what fills the + * padding from the neighbouring faces. */ + rasterise(lod, (at, dir) => { + sample[0] = sample[1] = sample[2] = 0; + cubeUV(pingpong, dir[0], dir[1], dir[2], lodMax - lod, sample, 1); + for (let k = 0; k < 3; k++) atlas[at * 3 + k] = quantise(sample[k]); + }); + } + + const data = new Uint16Array(width * height * 4); + for (let i = 0, j = 0; i < atlas.length; i += 3, j += 4) { + data[j] = toHalf(atlas[i]); + data[j + 1] = toHalf(atlas[i + 1]); + data[j + 2] = toHalf(atlas[i + 2]); + data[j + 3] = 0x3c00; + } + + return { data, width, height, texelWidth, texelHeight, maxMip: lodMax }; +} diff --git a/src/gpu/environment.worker.js b/src/gpu/environment.worker.js new file mode 100644 index 0000000..eff5b9d --- /dev/null +++ b/src/gpu/environment.worker.js @@ -0,0 +1,7 @@ +import { prefilterPixels } from './environment.js'; + +/** The environment prefilter, run where it cannot hold up a frame. */ +self.onmessage = ({ data }) => { + const env = prefilterPixels(data); + self.postMessage(env, [env.data.buffer]); +}; diff --git a/src/gpu/frame.js b/src/gpu/frame.js new file mode 100644 index 0000000..8537c8b --- /dev/null +++ b/src/gpu/frame.js @@ -0,0 +1,277 @@ +import { display, linear } from './color.js'; +import { DirectionalLight, HemisphereLight, Lines, Mesh } from './graph.js'; +import { frustum, mat4, maxScale, multiply, normalMatrix, perspective } from './math.js'; + +/** + * One frame's worth of decisions, made once and handed to whichever backend + * is drawing: what is visible, in what order, and the exact bytes of every + * uniform block. The two backends only differ in how they say "draw this"; + * nothing they draw is worked out twice, so they cannot disagree. + * + * The uniform layouts below are written out a second time in `shaders/` — + * in GLSL as std140 blocks and in WGSL as structs — using nothing but vec4 + * and mat4 so the two languages lay them out identically. The offsets here + * are in floats. + */ + +export const FRAME = { + projection: 0, + view: 16, + fogColor: 32, + fogParams: 36, + hemiSky: 40, + hemiGround: 44, + hemiDirection: 48, + lightDirection: 52, + lightColor: 68, + envParams: 84, + counts: 88, + size: 92, +}; + +export const DRAW = { + modelView: 0, + normalMatrix: 16, + color: 32, + emissive: 36, + surface: 40, + coat: 44, + sheen: 48, + flags: 52, + /** Every draw gets a 256-byte slot, which is the offset alignment both + * APIs guarantee for binding a range of a uniform buffer. */ + stride: 64, +}; + +export const MAX_LIGHTS = 4; + +const TONE_MAPPING = { none: 0, aces: 1 }; + +/** + * Pipeline state for a material on a kind of primitive — everything that has + * to be fixed before a draw rather than fed in as a uniform. + */ +export function stateOf(material, lines) { + const blending = material.blending === 'additive' + ? 'additive' + : material.transparent ? 'normal' : 'none'; + return { + shader: material.shader, + topology: lines ? 'lines' : 'triangles', + cull: lines || material.side === 'double' ? 'none' : material.side === 'back' ? 'front' : 'back', + blending, + depthTest: material.depthTest, + depthWrite: material.depthWrite, + /** Polygon offset moves filled triangles only, never lines. */ + biasFactor: !lines && material.polygonOffset ? material.polygonOffsetFactor : 0, + biasUnits: !lines && material.polygonOffset ? material.polygonOffsetUnits : 0, + }; +} + +export function stateKey(s) { + return `${s.shader}|${s.topology}|${s.cull}|${s.blending}|${s.depthTest}|${s.depthWrite}|${s.biasFactor}|${s.biasUnits}`; +} + +/** Opaque first and near to far; then see-through, far to near. */ +function opaqueOrder(a, b) { + return a.node.renderOrder - b.node.renderOrder + || a.material.id - b.material.id + || a.z - b.z + || a.node.id - b.node.id; +} + +function transparentOrder(a, b) { + return a.node.renderOrder - b.node.renderOrder + || b.z - a.z + || a.node.id - b.node.id; +} + +export function createFrameBuilder() { + const frame = new Float32Array(FRAME.size); + let draws = new Float32Array(DRAW.stride * 256); + + const view = mat4(); + const projection = mat4(); + const cull = mat4(); + const modelView = mat4(); + const normals = mat4(); + const planes = Array.from({ length: 6 }, () => new Float64Array(4)); + const opaque = []; + const transparent = []; + const hemis = []; + const directionals = []; + const pool = []; + let used = 0; + + function item() { + if (used === pool.length) pool.push({}); + return pool[used++]; + } + + function visible(node) { + if (!node.frustumCulled) return true; + const { center, radius } = node.geometry.boundingSphere; + const m = node.matrixWorld; + const x = m[0] * center[0] + m[4] * center[1] + m[8] * center[2] + m[12]; + const y = m[1] * center[0] + m[5] * center[1] + m[9] * center[2] + m[13]; + const z = m[2] * center[0] + m[6] * center[1] + m[10] * center[2] + m[14]; + const r = -radius * maxScale(m); + for (const p of planes) if (p[0] * x + p[1] * y + p[2] * z + p[3] < r) return false; + return true; + } + + function collect(node) { + if (!node.visible) return; + if (node instanceof Mesh) { + if (node.geometry && node.material && visible(node)) { + const { center } = node.geometry.boundingSphere; + const m = node.matrixWorld; + const wx = m[0] * center[0] + m[4] * center[1] + m[8] * center[2] + m[12]; + const wy = m[1] * center[0] + m[5] * center[1] + m[9] * center[2] + m[13]; + const wz = m[2] * center[0] + m[6] * center[1] + m[10] * center[2] + m[14]; + const it = item(); + it.node = node; + it.material = node.material; + it.lines = node instanceof Lines; + /** Distance in front of the camera, for sorting. */ + it.z = -(view[2] * wx + view[6] * wy + view[10] * wz + view[14]); + (node.material.transparent ? transparent : opaque).push(it); + } + } else if (node instanceof HemisphereLight) { + hemis.push(node); + } else if (node instanceof DirectionalLight) { + directionals.push(node); + } + for (const child of node.children) collect(child); + } + + /** A direction through the view's rotation, normalised. */ + function toView(out, at, x, y, z) { + const vx = view[0] * x + view[4] * y + view[8] * z; + const vy = view[1] * x + view[5] * y + view[9] * z; + const vz = view[2] * x + view[6] * y + view[10] * z; + const len = Math.hypot(vx, vy, vz) || 1; + out[at] = vx / len; + out[at + 1] = vy / len; + out[at + 2] = vz / len; + } + + function writeLights() { + frame.fill(0, FRAME.hemiSky, FRAME.envParams); + const hemi = hemis[0]; + if (hemi) { + frame.set(linear(hemi.skyColor, hemi.intensity), FRAME.hemiSky); + frame.set(linear(hemi.groundColor, hemi.intensity), FRAME.hemiGround); + frame[FRAME.hemiSky + 3] = 1; + const m = hemi.matrixWorld; + toView(frame, FRAME.hemiDirection, m[12], m[13], m[14]); + } + const count = Math.min(directionals.length, MAX_LIGHTS); + for (let i = 0; i < count; i++) { + const light = directionals[i]; + const m = light.matrixWorld; + const t = light.target.matrixWorld; + toView(frame, FRAME.lightDirection + i * 4, m[12] - t[12], m[13] - t[13], m[14] - t[14]); + frame.set(linear(light.color, light.intensity), FRAME.lightColor + i * 4); + } + return count; + } + + function writeDraw(it, offset) { + const { node, material } = it; + multiply(modelView, view, node.matrixWorld); + normalMatrix(normals, modelView); + draws.set(modelView, offset + DRAW.modelView); + draws.set(normals, offset + DRAW.normalMatrix); + + const c = material.color; + draws[offset + DRAW.color] = c[0]; + draws[offset + DRAW.color + 1] = c[1]; + draws[offset + DRAW.color + 2] = c[2]; + draws[offset + DRAW.color + 3] = material.opacity; + + if (material.shader === 'lit') { + const e = material.emissive; + const k = material.emissiveIntensity; + draws[offset + DRAW.emissive] = e[0] * k; + draws[offset + DRAW.emissive + 1] = e[1] * k; + draws[offset + DRAW.emissive + 2] = e[2] * k; + draws[offset + DRAW.surface] = material.roughness; + draws[offset + DRAW.surface + 1] = material.metalness; + draws[offset + DRAW.surface + 2] = material.bumpMap ? material.bumpScale : 0; + draws[offset + DRAW.surface + 3] = material.clearcoat; + draws[offset + DRAW.coat] = material.clearcoatRoughness; + draws[offset + DRAW.coat + 1] = material.sheenRoughness; + const s = material.sheenColor; + draws[offset + DRAW.sheen] = s[0] * material.sheen; + draws[offset + DRAW.sheen + 1] = s[1] * material.sheen; + draws[offset + DRAW.sheen + 2] = s[2] * material.sheen; + draws[offset + DRAW.sheen + 3] = material.sheen > 0 ? 1 : 0; + } + + draws[offset + DRAW.flags] = material.fog ? 1 : 0; + draws[offset + DRAW.flags + 1] = material.toneMapped ? 1 : 0; + draws[offset + DRAW.flags + 2] = !material.transparent && material.blending === 'normal' ? 1 : 0; + draws[offset + DRAW.flags + 3] = material.vertexColors ? 1 : 0; + } + + return { + frame, + get draws() { return draws; }, + + /** + * Everything the backend needs to draw `scene` from `camera`: the frame + * block filled in, the draw blocks packed in draw order, and the list of + * what to draw. `zeroToOne` is the clip-space depth range of the API. + */ + build(scene, camera, { zeroToOne, toneMapping = 'none', exposure = 1 }) { + scene.updateMatrixWorld(); + camera.updateMatrixWorld(); + view.set(camera.matrixWorldInverse); + perspective(projection, camera.fov, camera.aspect, camera.near, camera.far, zeroToOne); + multiply(cull, camera.projectionMatrix, view); + frustum(planes, cull); + + used = 0; + opaque.length = 0; + transparent.length = 0; + hemis.length = 0; + directionals.length = 0; + collect(scene); + opaque.sort(opaqueOrder); + transparent.sort(transparentOrder); + + frame.set(projection, FRAME.projection); + frame.set(view, FRAME.view); + const fog = scene.fog; + if (fog) frame.set([...display(fog.color), 1], FRAME.fogColor); + else frame.fill(0, FRAME.fogColor, FRAME.fogColor + 4); + frame[FRAME.fogParams] = fog ? fog.near : 0; + frame[FRAME.fogParams + 1] = fog ? fog.far : 1; + frame[FRAME.fogParams + 2] = exposure; + frame[FRAME.fogParams + 3] = TONE_MAPPING[toneMapping] ?? 0; + const lights = writeLights(); + const env = scene.environment; + frame[FRAME.envParams] = env ? env.texelWidth : 0; + frame[FRAME.envParams + 1] = env ? env.texelHeight : 0; + frame[FRAME.envParams + 2] = env ? env.maxMip : 0; + frame[FRAME.envParams + 3] = env ? 1 : 0; + frame[FRAME.counts] = lights; + frame[FRAME.counts + 1] = scene.backgroundIntensity; + + const list = [...opaque, ...transparent]; + const needed = list.length * DRAW.stride; + if (needed > draws.length) { + let size = draws.length; + while (size < needed) size *= 2; + draws = new Float32Array(size); + } + list.forEach((it, i) => { + it.offset = i * DRAW.stride; + writeDraw(it, it.offset); + }); + + return { list, count: list.length }; + }, + }; +} diff --git a/src/gpu/geometry.js b/src/gpu/geometry.js new file mode 100644 index 0000000..37d25f1 --- /dev/null +++ b/src/gpu/geometry.js @@ -0,0 +1,323 @@ +/** + * Vertex data, and the handful of shapes the city is built from. + * + * The generators lay their vertices out in exactly the order, winding and + * seam the shapes have always had — the egg's sphere in particular, since + * `shapeEgg` pushes its vertices around by index and the normals are worked + * out from the triangles afterwards, seam and poles and all. Change the + * layout and the shell's shading changes with it. + */ + +function indexArray(indices, vertexCount) { + return vertexCount > 65535 ? new Uint32Array(indices) : new Uint16Array(indices); +} + +export class Geometry { + /** + * Flat typed arrays: `position` and `normal` three floats a vertex, `uv` + * two, `color` three. `index` is optional — without one, every three + * vertices are a triangle, or every two a line. + */ + constructor({ position, normal = null, uv = null, color = null, index = null }) { + this.position = position instanceof Float32Array ? position : new Float32Array(position); + this.normal = normal && (normal instanceof Float32Array ? normal : new Float32Array(normal)); + this.uv = uv && (uv instanceof Float32Array ? uv : new Float32Array(uv)); + this.color = color && (color instanceof Float32Array ? color : new Float32Array(color)); + this.index = index && (ArrayBuffer.isView(index) ? index : indexArray(index, this.count)); + this.version = 0; + this.bounds = null; + this.listeners = new Set(); + } + + get count() { + return this.position.length / 3; + } + + /** Mark the arrays as changed, so the next frame uploads them again. */ + set needsUpdate(value) { + if (value) { + this.version += 1; + this.bounds = null; + } + } + + /** Centre of the box round every vertex, and the furthest vertex from it. */ + get boundingSphere() { + if (this.bounds) return this.bounds; + const p = this.position; + const lo = [Infinity, Infinity, Infinity]; + const hi = [-Infinity, -Infinity, -Infinity]; + for (let i = 0; i < p.length; i += 3) { + for (let k = 0; k < 3; k++) { + if (p[i + k] < lo[k]) lo[k] = p[i + k]; + if (p[i + k] > hi[k]) hi[k] = p[i + k]; + } + } + const center = [0, 1, 2].map((k) => (lo[k] + hi[k]) / 2); + let r2 = 0; + for (let i = 0; i < p.length; i += 3) { + const dx = p[i] - center[0]; + const dy = p[i + 1] - center[1]; + const dz = p[i + 2] - center[2]; + r2 = Math.max(r2, dx * dx + dy * dy + dz * dz); + } + this.bounds = { center, radius: Math.sqrt(r2) }; + return this.bounds; + } + + /** + * Smooth normals from the triangles: each face's unnormalised normal, so + * bigger faces count for more, summed onto its corners and normalised. + * Without an index every triangle keeps its own, which is flat shading. + */ + computeVertexNormals() { + const p = this.position; + const n = new Float32Array(p.length); + const index = this.index; + const triangles = index ? index.length : this.count; + + for (let i = 0; i < triangles; i += 3) { + const a = index ? index[i] : i; + const b = index ? index[i + 1] : i + 1; + const c = index ? index[i + 2] : i + 2; + const cbx = p[c * 3] - p[b * 3], cby = p[c * 3 + 1] - p[b * 3 + 1], cbz = p[c * 3 + 2] - p[b * 3 + 2]; + const abx = p[a * 3] - p[b * 3], aby = p[a * 3 + 1] - p[b * 3 + 1], abz = p[a * 3 + 2] - p[b * 3 + 2]; + const x = cby * abz - cbz * aby; + const y = cbz * abx - cbx * abz; + const z = cbx * aby - cby * abx; + for (const v of [a, b, c]) { + n[v * 3] += x; + n[v * 3 + 1] += y; + n[v * 3 + 2] += z; + } + } + + for (let i = 0; i < n.length; i += 3) { + const len = Math.hypot(n[i], n[i + 1], n[i + 2]) || 1; + n[i] /= len; + n[i + 1] /= len; + n[i + 2] /= len; + } + this.normal = n; + return this; + } + + addEventListener(type, listener) { + if (type === 'dispose') this.listeners.add(listener); + } + + removeEventListener(type, listener) { + if (type === 'dispose') this.listeners.delete(listener); + } + + /** Free the GPU's copy. Drawing it again uploads it again. */ + dispose() { + for (const listener of [...this.listeners]) listener({ type: 'dispose', target: this }); + } +} + +/** A sphere in latitude rows, with a duplicated seam column and a vertex per + * segment at each pole — so a texture wraps it without pinching. */ +export function sphere(radius = 1, widthSegments = 32, heightSegments = 16) { + const position = []; + const normal = []; + const uv = []; + const index = []; + const grid = []; + let next = 0; + + for (let iy = 0; iy <= heightSegments; iy++) { + const row = []; + const v = iy / heightSegments; + const theta = v * Math.PI; + const y = radius * Math.cos(theta); + const ring = Math.sqrt(radius * radius - y * y); + const offset = iy === 0 ? 0.5 / widthSegments : iy === heightSegments ? -0.5 / widthSegments : 0; + + for (let ix = 0; ix <= widthSegments; ix++) { + const u = ix / widthSegments; + const phi = u * Math.PI * 2; + const x = -ring * Math.cos(phi); + const z = ring * Math.sin(phi); + position.push(x, y, z); + const len = Math.hypot(x, y, z) || 1; + normal.push(x / len, y / len, z / len); + uv.push(u + offset, 1 - v); + row.push(next++); + } + grid.push(row); + } + + for (let iy = 0; iy < heightSegments; iy++) { + for (let ix = 0; ix < widthSegments; ix++) { + const a = grid[iy][ix + 1]; + const b = grid[iy][ix]; + const c = grid[iy + 1][ix]; + const d = grid[iy + 1][ix + 1]; + if (iy !== 0) index.push(a, b, d); + if (iy !== heightSegments - 1) index.push(b, c, d); + } + } + + return new Geometry({ position, normal, uv, index }); +} + +/** A cylinder, or a cone when the top radius is zero, with its caps. */ +export function cylinder(radiusTop = 1, radiusBottom = 1, height = 1, radialSegments = 32, heightSegments = 1) { + const position = []; + const normal = []; + const uv = []; + const index = []; + const rows = []; + const half = height / 2; + const slope = (radiusBottom - radiusTop) / height; + let next = 0; + + for (let y = 0; y <= heightSegments; y++) { + const row = []; + const v = y / heightSegments; + const radius = v * (radiusBottom - radiusTop) + radiusTop; + for (let x = 0; x <= radialSegments; x++) { + const u = x / radialSegments; + const theta = u * Math.PI * 2; + const sin = Math.sin(theta); + const cos = Math.cos(theta); + position.push(radius * sin, -v * height + half, radius * cos); + const len = Math.hypot(sin, slope, cos); + normal.push(sin / len, slope / len, cos / len); + uv.push(u, 1 - v); + row.push(next++); + } + rows.push(row); + } + + for (let x = 0; x < radialSegments; x++) { + for (let y = 0; y < heightSegments; y++) { + const a = rows[y][x]; + const b = rows[y + 1][x]; + const c = rows[y + 1][x + 1]; + const d = rows[y][x + 1]; + if (radiusTop > 0 || y !== 0) index.push(a, b, d); + if (radiusBottom > 0 || y !== heightSegments - 1) index.push(b, c, d); + } + } + + for (const top of [true, false]) { + const radius = top ? radiusTop : radiusBottom; + if (radius <= 0) continue; + const sign = top ? 1 : -1; + const centre = next; + for (let x = 1; x <= radialSegments; x++) { + position.push(0, half * sign, 0); + normal.push(0, sign, 0); + uv.push(0.5, 0.5); + next++; + } + const rim = next; + for (let x = 0; x <= radialSegments; x++) { + const theta = (x / radialSegments) * Math.PI * 2; + const cos = Math.cos(theta); + const sin = Math.sin(theta); + position.push(radius * sin, half * sign, radius * cos); + normal.push(0, sign, 0); + uv.push(cos * 0.5 + 0.5, sin * 0.5 * sign + 0.5); + next++; + } + for (let x = 0; x < radialSegments; x++) { + const c = centre + x; + const i = rim + x; + if (top) index.push(i, i + 1, c); + else index.push(i + 1, i, c); + } + } + + return new Geometry({ position, normal, uv, index }); +} + +export function cone(radius = 1, height = 1, radialSegments = 32) { + return cylinder(0, radius, height, radialSegments); +} + +/** A ring of tube lying in the XY plane, round the Z axis. */ +export function torus(radius = 1, tube = 0.4, radialSegments = 12, tubularSegments = 48) { + const position = []; + const normal = []; + const uv = []; + const index = []; + + for (let j = 0; j <= radialSegments; j++) { + const v = (j / radialSegments) * Math.PI * 2; + for (let i = 0; i <= tubularSegments; i++) { + const u = (i / tubularSegments) * Math.PI * 2; + const x = (radius + tube * Math.cos(v)) * Math.cos(u); + const y = (radius + tube * Math.cos(v)) * Math.sin(u); + const z = tube * Math.sin(v); + position.push(x, y, z); + const nx = x - radius * Math.cos(u); + const ny = y - radius * Math.sin(u); + const len = Math.hypot(nx, ny, z) || 1; + normal.push(nx / len, ny / len, z / len); + uv.push(i / tubularSegments, j / radialSegments); + } + } + + for (let j = 1; j <= radialSegments; j++) { + for (let i = 1; i <= tubularSegments; i++) { + const a = (tubularSegments + 1) * j + i - 1; + const b = (tubularSegments + 1) * (j - 1) + i - 1; + const c = (tubularSegments + 1) * (j - 1) + i; + const d = (tubularSegments + 1) * j + i; + index.push(a, b, d, b, c, d); + } + } + + return new Geometry({ position, normal, uv, index }); +} + +/** A flat rectangle facing +z, uv (0,1) at its top-left corner. */ +export function plane(width = 1, height = 1) { + const w = width / 2; + const h = height / 2; + return new Geometry({ + position: [-w, h, 0, w, h, 0, -w, -h, 0, w, -h, 0], + normal: [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1], + uv: [0, 1, 1, 1, 0, 0, 1, 0], + index: [0, 2, 1, 2, 3, 1], + }); +} + +/** The six corners of an octahedron, and its eight faces. */ +const OCTAHEDRON = { + vertices: [1, 0, 0, -1, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 1, 0, 0, -1], + faces: [0, 2, 4, 0, 4, 3, 0, 3, 5, 0, 5, 2, 1, 2, 5, 1, 5, 3, 1, 3, 4, 1, 4, 2], +}; + +/** Eight flat faces, each with its own three vertices, so it shades faceted. */ +export function octahedron(radius = 1) { + const { vertices, faces } = OCTAHEDRON; + const position = []; + for (let i = 0; i < faces.length; i += 3) { + /** Each face is wound b, c, a — the order it has always been drawn in. */ + for (const k of [faces[i + 1], faces[i + 2], faces[i]]) { + position.push(vertices[k * 3] * radius, vertices[k * 3 + 1] * radius, vertices[k * 3 + 2] * radius); + } + } + return new Geometry({ position }).computeVertexNormals(); +} + +/** The twelve edges of an octahedron, as line segments. */ +export function octahedronEdges(radius = 1) { + const { vertices } = OCTAHEDRON; + const position = []; + for (let a = 0; a < 6; a++) { + for (let b = a + 1; b < 6; b++) { + /** Every pair of corners is an edge except the three opposite pairs. */ + if ((a ^ 1) === b) continue; + position.push( + vertices[a * 3] * radius, vertices[a * 3 + 1] * radius, vertices[a * 3 + 2] * radius, + vertices[b * 3] * radius, vertices[b * 3 + 1] * radius, vertices[b * 3 + 2] * radius, + ); + } + } + return new Geometry({ position }); +} diff --git a/src/gpu/graph.js b/src/gpu/graph.js new file mode 100644 index 0000000..e99dc36 --- /dev/null +++ b/src/gpu/graph.js @@ -0,0 +1,206 @@ +import { compose, invertRigid, mat4, multiply, perspective, Vec3 } from './math.js'; + +/** + * The scene graph: nodes with a position, a rotation and a scale, parented + * into a tree, and a world matrix worked out from the root down once a frame. + * Meshes and lines are nodes that carry a geometry and a material; lights and + * the camera are nodes the renderer reads instead of draws. + */ + +let ids = 0; + +export class Node { + constructor() { + this.id = ids++; + this.name = ''; + this.parent = null; + this.children = []; + this.visible = true; + this.renderOrder = 0; + this.frustumCulled = true; + this.userData = {}; + this.position = new Vec3(); + /** Euler angles in radians, applied X, then Y, then Z. */ + this.rotation = new Vec3(); + this.scale = new Vec3(1, 1, 1); + this.matrixWorld = mat4(); + } + + add(...nodes) { + for (const node of nodes) { + node.parent?.remove(node); + node.parent = this; + this.children.push(node); + } + return this; + } + + remove(...nodes) { + for (const node of nodes) { + const at = this.children.indexOf(node); + if (at < 0) continue; + this.children.splice(at, 1); + node.parent = null; + } + return this; + } + + traverse(visit) { + visit(this); + for (const child of this.children) child.traverse(visit); + } + + /** This node, or the first one under it with the name, depth first. */ + getObjectByName(name) { + if (this.name === name) return this; + for (const child of this.children) { + const found = child.getObjectByName(name); + if (found) return found; + } + return undefined; + } + + updateMatrixWorld(parent = null) { + compose(this.matrixWorld, this.position, this.rotation, this.scale); + if (parent) multiply(this.matrixWorld, parent, this.matrixWorld); + for (const child of this.children) child.updateMatrixWorld(this.matrixWorld); + } + + /** A fresh node of the same kind, for `clone` to fill in. */ + blank() { + return new this.constructor(); + } + + /** + * A copy of this node and everything under it. Geometry and materials are + * shared with the original rather than copied — a clone is a new place to + * draw the same thing, which is the whole reason to clone. + */ + clone() { + const node = this.blank(); + node.name = this.name; + node.visible = this.visible; + node.renderOrder = this.renderOrder; + node.frustumCulled = this.frustumCulled; + node.userData = JSON.parse(JSON.stringify(this.userData)); + node.position.copy(this.position); + node.rotation.copy(this.rotation); + node.scale.copy(this.scale); + for (const child of this.children) node.add(child.clone()); + return node; + } +} + +export class Group extends Node {} + +export class Mesh extends Node { + constructor(geometry, material) { + super(); + this.geometry = geometry; + this.material = material; + } + + blank() { + return new this.constructor(this.geometry, this.material); + } +} + +/** Pairs of vertices drawn as one-pixel lines. */ +export class Lines extends Mesh {} + +/** + * Light from above and light from below, blended by which way a surface + * faces. The "up" it blends along is the light's own world position taken as + * a direction from the origin — so a rig carried down the course drags it + * with it, and the renderer reproduces that rather than tidying it away. + */ +export class HemisphereLight extends Node { + constructor(skyColor, groundColor, intensity = 1) { + super(); + this.skyColor = skyColor; + this.groundColor = groundColor; + this.intensity = intensity; + this.position.set(0, 1, 0); + } +} + +/** Parallel light, shining from its position towards its target's. */ +export class DirectionalLight extends Node { + constructor(color, intensity = 1) { + super(); + this.color = color; + this.intensity = intensity; + this.position.set(0, 1, 0); + this.target = new Node(); + } +} + +/** + * The root. `background` is a texture stretched over the whole frame behind + * everything, `environment` is what the lit materials reflect, and `fog` is + * linear between `near` and `far`. + */ +export class Scene extends Node { + constructor() { + super(); + this.background = null; + this.backgroundIntensity = 1; + this.environment = null; + this.fog = null; + } +} + +export class PerspectiveCamera { + constructor(fov = 50, aspect = 1, near = 0.1, far = 2000) { + this.fov = fov; + this.aspect = aspect; + this.near = near; + this.far = far; + this.position = new Vec3(); + this.up = new Vec3(0, 1, 0); + /** Rotation as three basis vectors, set by `lookAt`. */ + this.basis = [1, 0, 0, 0, 1, 0, 0, 0, 1]; + this.matrixWorld = mat4(); + this.matrixWorldInverse = mat4(); + this.projectionMatrix = mat4(); + this.updateProjectionMatrix(); + } + + updateProjectionMatrix() { + perspective(this.projectionMatrix, this.fov, this.aspect, this.near, this.far); + } + + /** Turn to face a point, keeping `up` as near to up as it will go. */ + lookAt(target) { + const p = this.position; + let zx = p.x - target.x, zy = p.y - target.y, zz = p.z - target.z; + if (zx === 0 && zy === 0 && zz === 0) zz = 1; + let len = Math.hypot(zx, zy, zz); + zx /= len; zy /= len; zz /= len; + + const u = this.up; + let xx = u.y * zz - u.z * zy, xy = u.z * zx - u.x * zz, xz = u.x * zy - u.y * zx; + if (xx === 0 && xy === 0 && xz === 0) { + if (Math.abs(u.z) === 1) zx += 0.0001; else zz += 0.0001; + len = Math.hypot(zx, zy, zz); + zx /= len; zy /= len; zz /= len; + xx = u.y * zz - u.z * zy; xy = u.z * zx - u.x * zz; xz = u.x * zy - u.y * zx; + } + len = Math.hypot(xx, xy, xz); + xx /= len; xy /= len; xz /= len; + + const yx = zy * xz - zz * xy, yy = zz * xx - zx * xz, yz = zx * xy - zy * xx; + this.basis = [xx, xy, xz, yx, yy, yz, zx, zy, zz]; + this.updateMatrixWorld(); + } + + updateMatrixWorld() { + const m = this.matrixWorld; + const b = this.basis; + m[0] = b[0]; m[1] = b[1]; m[2] = b[2]; m[3] = 0; + m[4] = b[3]; m[5] = b[4]; m[6] = b[5]; m[7] = 0; + m[8] = b[6]; m[9] = b[7]; m[10] = b[8]; m[11] = 0; + m[12] = this.position.x; m[13] = this.position.y; m[14] = this.position.z; m[15] = 1; + invertRigid(this.matrixWorldInverse, m); + } +} diff --git a/src/gpu/material.js b/src/gpu/material.js new file mode 100644 index 0000000..46bf1d2 --- /dev/null +++ b/src/gpu/material.js @@ -0,0 +1,92 @@ +import { linear } from './color.js'; + +/** + * What a surface is made of, as plain data the renderer turns into uniforms. + * There are three shaders behind these — lit, unlit, and the backdrop — and a + * material only says which one it wants and what to feed it. + * + * Colours are given as theme hex and held decoded to linear light. Opacity + * and emissive intensity are read every frame, so they can be animated; the + * rest is read once and should be treated as fixed. + */ + +let ids = 0; + +const COMMON = { + transparent: false, + opacity: 1, + /** 'normal' or 'additive'. Normal blending only blends when transparent. */ + blending: 'normal', + depthTest: true, + depthWrite: true, + /** 'front', 'back' or 'double': which faces are drawn. */ + side: 'front', + fog: true, + toneMapped: true, + polygonOffset: false, + polygonOffsetFactor: 0, + polygonOffsetUnits: 0, +}; + +const COLOURS = new Set(['color', 'emissive', 'sheenColor']); + +class Material { + constructor(shader, defaults, params) { + this.id = ids++; + this.shader = shader; + Object.assign(this, COMMON, defaults); + for (const [key, value] of Object.entries(params)) { + this[key] = COLOURS.has(key) ? linear(value) : value; + } + for (const key of COLOURS) { + if (typeof this[key] === 'number' || typeof this[key] === 'string') this[key] = linear(this[key]); + } + } + + /** The same material, as a separate thing to animate. */ + clone() { + const copy = Object.create(Object.getPrototypeOf(this)); + Object.assign(copy, this, { id: ids++ }); + for (const key of COLOURS) if (Array.isArray(this[key])) copy[key] = [...this[key]]; + return copy; + } +} + +/** Unlit: a colour, optionally a texture, optionally vertex colours. */ +export class BasicMaterial extends Material { + constructor(params = {}) { + super('basic', { color: 0xffffff, map: null, vertexColors: false }, params); + } +} + +/** Unlit, for one-pixel lines. */ +export class LineMaterial extends Material { + constructor(params = {}) { + super('basic', { color: 0xffffff, map: null, vertexColors: false }, params); + } +} + +/** Metal/rough PBR, lit by the scene's lights and its environment. */ +export class StandardMaterial extends Material { + constructor(params = {}) { + super('lit', { + color: 0xffffff, + map: null, + roughness: 1, + metalness: 0, + emissive: 0x000000, + emissiveIntensity: 1, + emissiveMap: null, + bumpMap: null, + bumpScale: 1, + clearcoat: 0, + clearcoatRoughness: 0, + sheen: 0, + sheenColor: 0x000000, + sheenRoughness: 1, + }, params); + } +} + +/** Standard, plus a clear lacquer over the top and a soft sheen at the edges. */ +export class PhysicalMaterial extends StandardMaterial {} diff --git a/src/gpu/math.js b/src/gpu/math.js new file mode 100644 index 0000000..a5621cb --- /dev/null +++ b/src/gpu/math.js @@ -0,0 +1,237 @@ +/** + * The arithmetic the renderer runs on: a three-component vector, and 4×4 + * matrices stored column-major in Float64Arrays — the order both GLSL and + * WGSL read them in, kept in double precision until the moment they are + * handed to the GPU. + * + * Double precision is not fussiness. The egg is thousands of metres down the + * course by the end of a run, and a float32 model matrix at z = 4000 cannot + * place a vertex closer than half a millimetre — which, under a bump map that + * reads screen-space derivatives of position, is a shell that crawls. So the + * model-view product is taken here, where it is exact, and the shader only + * ever sees positions relative to the camera. + */ + +export class Vec3 { + constructor(x = 0, y = 0, z = 0) { + this.x = x; + this.y = y; + this.z = z; + } + + set(x, y, z) { + this.x = x; + this.y = y; + this.z = z; + return this; + } + + setScalar(s) { + return this.set(s, s, s); + } + + copy(v) { + return this.set(v.x, v.y, v.z); + } + + clone() { + return new Vec3(this.x, this.y, this.z); + } + + multiplyScalar(s) { + return this.set(this.x * s, this.y * s, this.z * s); + } + + lerp(v, t) { + return this.set( + this.x + (v.x - this.x) * t, + this.y + (v.y - this.y) * t, + this.z + (v.z - this.z) * t, + ); + } + + length() { + return Math.hypot(this.x, this.y, this.z); + } + + /** Through a 4×4 matrix as a point, with the perspective divide. */ + applyMatrix4(m) { + const { x, y, z } = this; + const w = 1 / (m[3] * x + m[7] * y + m[11] * z + m[15]); + return this.set( + (m[0] * x + m[4] * y + m[8] * z + m[12]) * w, + (m[1] * x + m[5] * y + m[9] * z + m[13]) * w, + (m[2] * x + m[6] * y + m[10] * z + m[14]) * w, + ); + } + + /** World space to normalised device coordinates, through a camera. */ + project(camera) { + camera.updateMatrixWorld(); + return this.applyMatrix4(camera.matrixWorldInverse).applyMatrix4(camera.projectionMatrix); + } +} + +export function mat4() { + return identity(new Float64Array(16)); +} + +export function identity(out) { + out.fill(0); + out[0] = out[5] = out[10] = out[15] = 1; + return out; +} + +export function multiply(out, a, b) { + const a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]; + const a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7]; + const a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11]; + const a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15]; + for (let i = 0; i < 16; i += 4) { + const b0 = b[i], b1 = b[i + 1], b2 = b[i + 2], b3 = b[i + 3]; + out[i] = a00 * b0 + a10 * b1 + a20 * b2 + a30 * b3; + out[i + 1] = a01 * b0 + a11 * b1 + a21 * b2 + a31 * b3; + out[i + 2] = a02 * b0 + a12 * b1 + a22 * b2 + a32 * b3; + out[i + 3] = a03 * b0 + a13 * b1 + a23 * b2 + a33 * b3; + } + return out; +} + +/** + * Translation × rotation × scale. The rotation is three Euler angles applied + * X, then Y, then Z, about the parent's axes — the order every `rotation.set` + * in this game was written against. + */ +export function compose(out, p, r, s) { + const a = Math.cos(r.x), b = Math.sin(r.x); + const c = Math.cos(r.y), d = Math.sin(r.y); + const e = Math.cos(r.z), f = Math.sin(r.z); + const ae = a * e, af = a * f, be = b * e, bf = b * f; + + out[0] = c * e * s.x; + out[1] = (af + be * d) * s.x; + out[2] = (bf - ae * d) * s.x; + out[3] = 0; + out[4] = -c * f * s.y; + out[5] = (ae - bf * d) * s.y; + out[6] = (be + af * d) * s.y; + out[7] = 0; + out[8] = d * s.z; + out[9] = -b * c * s.z; + out[10] = a * c * s.z; + out[11] = 0; + out[12] = p.x; + out[13] = p.y; + out[14] = p.z; + out[15] = 1; + return out; +} + +/** The inverse of a rigid transform: rotation transposed, translation undone. */ +export function invertRigid(out, m) { + const x = m[12], y = m[13], z = m[14]; + out[0] = m[0]; out[1] = m[4]; out[2] = m[8]; out[3] = 0; + out[4] = m[1]; out[5] = m[5]; out[6] = m[9]; out[7] = 0; + out[8] = m[2]; out[9] = m[6]; out[10] = m[10]; out[11] = 0; + out[12] = -(m[0] * x + m[1] * y + m[2] * z); + out[13] = -(m[4] * x + m[5] * y + m[6] * z); + out[14] = -(m[8] * x + m[9] * y + m[10] * z); + out[15] = 1; + return out; +} + +/** + * The inverse transpose of a matrix's upper 3×3, written into the first three + * columns of `out`: what a normal has to go through when the model is scaled + * unevenly, which the squashed egg is on every landing. + */ +export function normalMatrix(out, m) { + const a00 = m[0], a01 = m[1], a02 = m[2]; + const a10 = m[4], a11 = m[5], a12 = m[6]; + const a20 = m[8], a21 = m[9], a22 = m[10]; + + const b01 = a22 * a11 - a12 * a21; + const b11 = -a22 * a10 + a12 * a20; + const b21 = a21 * a10 - a11 * a20; + const det = a00 * b01 + a01 * b11 + a02 * b21; + const inv = det === 0 ? 0 : 1 / det; + + out[0] = b01 * inv; + out[1] = b11 * inv; + out[2] = b21 * inv; + out[3] = 0; + out[4] = (-a22 * a01 + a02 * a21) * inv; + out[5] = (a22 * a00 - a02 * a20) * inv; + out[6] = (-a21 * a00 + a01 * a20) * inv; + out[7] = 0; + out[8] = (a12 * a01 - a02 * a11) * inv; + out[9] = (-a12 * a00 + a02 * a10) * inv; + out[10] = (a11 * a00 - a01 * a10) * inv; + out[11] = 0; + out[12] = out[13] = out[14] = 0; + out[15] = 1; + return out; +} + +/** + * A perspective projection from a vertical field of view in degrees. WebGL + * clips depth to [-1, 1] and WebGPU to [0, 1]; `zeroToOne` picks which, and + * nothing else about the picture changes. + */ +export function perspective(out, fov, aspect, near, far, zeroToOne = false) { + const top = near * Math.tan((Math.PI / 180) * 0.5 * fov); + const height = 2 * top; + const width = aspect * height; + const left = -0.5 * width; + const right = left + width; + const bottom = top - height; + + out.fill(0); + out[0] = (2 * near) / (right - left); + out[5] = (2 * near) / (top - bottom); + out[8] = (right + left) / (right - left); + out[9] = (top + bottom) / (top - bottom); + out[11] = -1; + if (zeroToOne) { + out[10] = -far / (far - near); + out[14] = (-far * near) / (far - near); + } else { + out[10] = -(far + near) / (far - near); + out[14] = (-2 * far * near) / (far - near); + } + return out; +} + +/** Largest scale along any axis of a transform, for growing a bounding sphere. */ +export function maxScale(m) { + return Math.sqrt(Math.max( + m[0] * m[0] + m[1] * m[1] + m[2] * m[2], + m[4] * m[4] + m[5] * m[5] + m[6] * m[6], + m[8] * m[8] + m[9] * m[9] + m[10] * m[10], + )); +} + +/** + * The six planes of a view frustum, from a projection × view matrix, each as + * [nx, ny, nz, d] with the normal pointing inwards. + */ +export function frustum(out, m) { + const rows = [ + [m[3] - m[0], m[7] - m[4], m[11] - m[8], m[15] - m[12]], + [m[3] + m[0], m[7] + m[4], m[11] + m[8], m[15] + m[12]], + [m[3] + m[1], m[7] + m[5], m[11] + m[9], m[15] + m[13]], + [m[3] - m[1], m[7] - m[5], m[11] - m[9], m[15] - m[13]], + [m[3] - m[2], m[7] - m[6], m[11] - m[10], m[15] - m[14]], + [m[3] + m[2], m[7] + m[6], m[11] + m[10], m[15] + m[14]], + ]; + for (let i = 0; i < 6; i++) { + const [a, b, c, d] = rows[i]; + const len = Math.hypot(a, b, c) || 1; + const plane = out[i]; + plane[0] = a / len; + plane[1] = b / len; + plane[2] = c / len; + plane[3] = d / len; + } + return out; +} diff --git a/src/gpu/renderer.js b/src/gpu/renderer.js new file mode 100644 index 0000000..b1cae53 --- /dev/null +++ b/src/gpu/renderer.js @@ -0,0 +1,142 @@ +import { display } from './color.js'; +import { createFrameBuilder } from './frame.js'; + +/** + * The renderer: WebGPU or WebGL 2, whichever draws this game better on the + * machine it is running on, and the same picture out of either. + * + * "Better" comes down to antialiasing. WebGPU multisamples at 4× and no + * more; Chrome on a desktop GPU gives a WebGL canvas 8×, which is what these + * games were tuned under. So where WebGL can sample more than 4×, WebGL draws + * — on a phone, where it usually cannot, WebGPU does — and either one falls + * back to the other if it will not start. + * + * Choosing takes a moment — WebGPU hands over its device asynchronously — so + * the renderer exists at once and starts drawing when a backend is ready. + * Until then `render` does nothing and the page shows its own background — + * and the same goes for an environment still being filtered in the + * background, so the first frame drawn is drawn under the right light. + * `?renderer=webgl` or `?renderer=webgpu` in the address forces one, which is + * how a device that gets WebGPU wrong is told to stop trying. + */ +export function createRenderer(canvas, { clearColor = 0x000000, antialias = true } = {}) { + const builder = createFrameBuilder(); + const settings = { + clear: display(clearColor), + antialias, + toneMapping: 'none', + exposure: 1, + }; + let pixelRatio = 1; + let backend = null; + + const ready = choose(canvas, settings).then((chosen) => { + backend = chosen; + return chosen?.name ?? null; + }); + + /** + * A GPU can be taken away — a driver reset, or a phone reclaiming memory + * from a tab in the background. Everything drawn is rebuilt from the scene + * on the next frame anyway, so coming back is a matter of making a new + * backend on the same canvas and letting it upload everything again. + */ + settings.lost = () => { + backend = null; + import('./webgpu.js') + .then(({ createWebGPU }) => createWebGPU(canvas, settings)) + .then((fresh) => { backend = fresh; }) + .catch((error) => console.error('WebGPU did not come back.', error)); + }; + canvas.addEventListener?.('webglcontextlost', (event) => { + event.preventDefault(); + backend = null; + }); + canvas.addEventListener?.('webglcontextrestored', () => { + import('./webgl.js') + .then(({ createWebGL }) => { backend = createWebGL(canvas, settings); }) + .catch((error) => console.error('WebGL did not come back.', error)); + }); + + return { + canvas, + /** Resolves to 'webgpu', 'webgl', or null when neither would start. */ + ready, + get backend() { return backend?.name ?? null; }, + + /** 'none' or 'aces'. */ + set toneMapping(value) { settings.toneMapping = value; }, + get toneMapping() { return settings.toneMapping; }, + set toneMappingExposure(value) { settings.exposure = value; }, + get toneMappingExposure() { return settings.exposure; }, + + setPixelRatio(value) { pixelRatio = value; }, + + /** The drawing buffer, in CSS pixels; the canvas's own size is left to + * the stylesheet unless `updateStyle` says otherwise. */ + setSize(width, height, updateStyle = true) { + canvas.width = Math.floor(width * pixelRatio); + canvas.height = Math.floor(height * pixelRatio); + if (updateStyle) { + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + } + }, + + render(scene, camera) { + if (!backend || scene.environment?.ready === false) return; + const { list } = builder.build(scene, camera, { + zeroToOne: backend.zeroToOne, + toneMapping: settings.toneMapping, + exposure: settings.exposure, + }); + backend.render(builder.frame, builder.draws, list, scene); + }, + }; +} + +function preference() { + try { + return new URLSearchParams(globalThis.location?.search ?? '').get('renderer'); + } catch { + return null; + } +} + +/** + * How many samples a WebGL canvas could multisample with here, asked of a + * throwaway context that is given straight back. + */ +function webglSamples() { + try { + const gl = document.createElement('canvas').getContext('webgl2'); + if (!gl) return 0; + const samples = gl.getParameter(gl.MAX_SAMPLES); + gl.getExtension('WEBGL_lose_context')?.loseContext(); + return samples; + } catch { + return 0; + } +} + +async function choose(canvas, settings) { + const wanted = preference() ?? (webglSamples() > 4 ? 'webgl' : 'webgpu'); + const order = wanted === 'webgl' ? ['webgl', 'webgpu'] : ['webgpu', 'webgl']; + + for (const name of order) { + try { + if (name === 'webgpu') { + if (!globalThis.navigator?.gpu) continue; + const { createWebGPU } = await import('./webgpu.js'); + return await createWebGPU(canvas, settings); + } + const { createWebGL } = await import('./webgl.js'); + return createWebGL(canvas, settings); + } catch (error) { + console.warn(`${name === 'webgpu' ? 'WebGPU' : 'WebGL 2'} would not start.`, error); + } + } + + console.error('Neither WebGPU nor WebGL 2 would start, so there is nothing to draw with.'); + return null; +} diff --git a/src/gpu/shaders/background.frag.glsl b/src/gpu/shaders/background.frag.glsl new file mode 100644 index 0000000..e5aa36b --- /dev/null +++ b/src/gpu/shaders/background.frag.glsl @@ -0,0 +1,12 @@ +uniform sampler2D colorMap; + +in vec2 vUv; + +out vec4 fragColor; + +// Already sRGB, so never tone mapped, and never fogged — it is the far away. +void main() { + vec4 texColor = texture(colorMap, vUv); + texColor.rgb *= counts.y; + fragColor = sRGBTransferOETF(texColor); +} diff --git a/src/gpu/shaders/background.vert.glsl b/src/gpu/shaders/background.vert.glsl new file mode 100644 index 0000000..d28e4e4 --- /dev/null +++ b/src/gpu/shaders/background.vert.glsl @@ -0,0 +1,10 @@ +// The backdrop: one triangle over the whole frame, at the far plane, with +// uvs running 0–1 across the visible part of it. + +out vec2 vUv; + +void main() { + vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + vUv = vec2(corner.x, 1.0 - corner.y); + gl_Position = vec4(corner * 2.0 - 1.0, 1.0, 1.0); +} diff --git a/src/gpu/shaders/background.wgsl b/src/gpu/shaders/background.wgsl new file mode 100644 index 0000000..66c1697 --- /dev/null +++ b/src/gpu/shaders/background.wgsl @@ -0,0 +1,24 @@ +// The backdrop: one triangle over the whole frame, at the far plane, with +// uvs running 0–1 across the visible part of it. Already sRGB, so never tone +// mapped, and never fogged — it is the far away. + +struct VertexOut { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, +} + +@vertex +fn vs(@builtin(vertex_index) index: u32) -> VertexOut { + let corner = vec2f(f32((index << 1u) & 2u), f32(index & 2u)); + var out: VertexOut; + out.uv = vec2f(corner.x, 1.0 - corner.y); + out.position = vec4f(corner * 2.0 - 1.0, 1.0, 1.0); + return out; +} + +@fragment +fn fs(input: VertexOut) -> @location(0) vec4f { + var texColor = textureSample(colorMap, colorSampler, input.uv); + texColor = vec4f(texColor.rgb * frame.counts.y, texColor.a); + return sRGBTransferOETF(texColor); +} diff --git a/src/gpu/shaders/basic.frag.glsl b/src/gpu/shaders/basic.frag.glsl new file mode 100644 index 0000000..81f3716 --- /dev/null +++ b/src/gpu/shaders/basic.frag.glsl @@ -0,0 +1,14 @@ +uniform sampler2D colorMap; + +in vec2 vUv; +in vec3 vColor; +in float vFogDepth; + +out vec4 fragColor; + +void main() { + vec4 diffuseColor = drawColor * texture(colorMap, vUv); + if (drawFlags.w > 0.5) diffuseColor.rgb *= vColor; + float alpha = drawFlags.z > 0.5 ? 1.0 : diffuseColor.a; + fragColor = finish(diffuseColor.rgb, alpha, vFogDepth); +} diff --git a/src/gpu/shaders/basic.vert.glsl b/src/gpu/shaders/basic.vert.glsl new file mode 100644 index 0000000..f782ec9 --- /dev/null +++ b/src/gpu/shaders/basic.vert.glsl @@ -0,0 +1,17 @@ +// Unlit surfaces and lines: a colour, a texture, vertex colours, fog. + +layout(location = 0) in vec3 position; +layout(location = 2) in vec2 uv; +layout(location = 3) in vec3 color; + +out vec2 vUv; +out vec3 vColor; +out float vFogDepth; + +void main() { + vUv = vec2(uv.x, 1.0 - uv.y); + vColor = color; + vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); + gl_Position = projectionMatrix * mvPosition; + vFogDepth = -mvPosition.z; +} diff --git a/src/gpu/shaders/basic.wgsl b/src/gpu/shaders/basic.wgsl new file mode 100644 index 0000000..742dc45 --- /dev/null +++ b/src/gpu/shaders/basic.wgsl @@ -0,0 +1,30 @@ +// Unlit surfaces and lines: a colour, a texture, vertex colours, fog — +// basic.vert.glsl and basic.frag.glsl in one. + +struct VertexOut { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, + @location(1) color: vec3f, + @location(2) fogDepth: f32, +} + +@vertex +fn vs(input: VertexIn) -> VertexOut { + var out: VertexOut; + out.uv = vec2f(input.uv.x, 1.0 - input.uv.y); + out.color = input.color; + let mvPosition = draw.modelView * vec4f(input.position, 1.0); + out.position = frame.projection * mvPosition; + out.fogDepth = -mvPosition.z; + return out; +} + +@fragment +fn fs(input: VertexOut) -> @location(0) vec4f { + var diffuseColor = draw.color * textureSample(colorMap, colorSampler, input.uv); + if (draw.flags.w > 0.5) { + diffuseColor = vec4f(diffuseColor.rgb * input.color, diffuseColor.a); + } + let alpha = select(diffuseColor.a, 1.0, draw.flags.z > 0.5); + return finish(diffuseColor.rgb, alpha, input.fogDepth); +} diff --git a/src/gpu/shaders/common.glsl b/src/gpu/shaders/common.glsl new file mode 100644 index 0000000..58530fe --- /dev/null +++ b/src/gpu/shaders/common.glsl @@ -0,0 +1,78 @@ +// Shared by every WebGL program: the two uniform blocks, and the last three +// things every fragment goes through on its way to the screen. +// +// The blocks mirror FRAME and DRAW in frame.js and the structs in +// common.wgsl. Nothing but vec4 and mat4, so std140 and WGSL agree on every +// offset without either of them being told. + +precision highp float; +precision highp int; + +layout(std140) uniform Frame { + mat4 projectionMatrix; + mat4 viewMatrix; + vec4 fogColor; // rgb already encoded for the screen; a = fog on + vec4 fogParams; // near, far, exposure, tone mapping (0 none, 1 ACES) + vec4 hemiSky; // rgb; a = there is a hemisphere light + vec4 hemiGround; + vec4 hemiDirection; // view space + vec4 lightDirection[4]; + vec4 lightColor[4]; + vec4 envParams; // atlas texel width, texel height, top mip; w = present + vec4 counts; // directional lights, background intensity +}; + +layout(std140) uniform Draw { + mat4 modelViewMatrix; + mat4 normalMatrix; + vec4 drawColor; // linear rgb, opacity + vec4 drawEmissive; // linear rgb × intensity + vec4 drawSurface; // roughness, metalness, bump scale, clearcoat + vec4 drawCoat; // clearcoat roughness, sheen roughness + vec4 drawSheen; // sheen colour × sheen; a = sheen on + vec4 drawFlags; // fog, tone mapped, opaque, vertex colours +}; + +#define saturate(a) clamp(a, 0.0, 1.0) + +vec3 RRTAndODTFit(vec3 v) { + vec3 a = v * (v + 0.0245786) - 0.000090537; + vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081; + return a / b; +} + +// ACES filmic, with the 1/0.6 brightening for a lit room rather than a +// cinema. +vec3 ACESFilmicToneMapping(vec3 color) { + const mat3 ACESInputMat = mat3( + vec3(0.59719, 0.07600, 0.02840), + vec3(0.35458, 0.90834, 0.13383), + vec3(0.04823, 0.01566, 0.83777) + ); + const mat3 ACESOutputMat = mat3( + vec3( 1.60475, -0.10208, -0.00327), + vec3(-0.53108, 1.10813, -0.07276), + vec3(-0.07367, -0.00605, 1.07602) + ); + color *= fogParams.z / 0.6; + color = ACESInputMat * color; + color = RRTAndODTFit(color); + color = ACESOutputMat * color; + return saturate(color); +} + +vec4 sRGBTransferOETF(vec4 value) { + return vec4(mix(pow(value.rgb, vec3(0.41666)) * 1.055 - vec3(0.055), value.rgb * 12.92, + vec3(lessThanEqual(value.rgb, vec3(0.0031308)))), value.a); +} + +// Tone map, encode for the screen, then fog — in that order, because the fog +// colour arrives already encoded and is mixed in after the encode. +vec4 finish(vec3 color, float alpha, float fogDepth) { + if (drawFlags.y > 0.5 && fogParams.w > 0.5) color = ACESFilmicToneMapping(color); + vec4 result = sRGBTransferOETF(vec4(color, alpha)); + if (drawFlags.x > 0.5 && fogColor.a > 0.5) { + result.rgb = mix(result.rgb, fogColor.rgb, smoothstep(fogParams.x, fogParams.y, fogDepth)); + } + return result; +} diff --git a/src/gpu/shaders/common.wgsl b/src/gpu/shaders/common.wgsl new file mode 100644 index 0000000..826b244 --- /dev/null +++ b/src/gpu/shaders/common.wgsl @@ -0,0 +1,100 @@ +// Shared by every WebGPU pipeline: the two uniform structs, the bindings +// every pipeline layout carries, and the last three things every fragment +// goes through on its way to the screen. +// +// The structs mirror FRAME and DRAW in frame.js and the std140 blocks in +// common.glsl. Nothing but vec4 and mat4, so both languages agree on every +// offset without either of them being told. + +struct Frame { + projection: mat4x4f, + view: mat4x4f, + fogColor: vec4f, // rgb already encoded for the screen; a = fog on + fogParams: vec4f, // near, far, exposure, tone mapping (0 none, 1 ACES) + hemiSky: vec4f, // rgb; a = there is a hemisphere light + hemiGround: vec4f, + hemiDirection: vec4f, // view space + lightDirection: array, + lightColor: array, + envParams: vec4f, // atlas texel width, texel height, top mip; w = present + counts: vec4f, // directional lights, background intensity +} + +struct Draw { + modelView: mat4x4f, + normalMatrix: mat4x4f, + color: vec4f, // linear rgb, opacity + emissive: vec4f, // linear rgb × intensity + surface: vec4f, // roughness, metalness, bump scale, clearcoat + coat: vec4f, // clearcoat roughness, sheen roughness + sheen: vec4f, // sheen colour × sheen; a = sheen on + flags: vec4f, // fog, tone mapped, opaque, vertex colours +} + +@group(0) @binding(0) var frame: Frame; +@group(0) @binding(1) var envMap: texture_2d; +@group(0) @binding(2) var clampSampler: sampler; +@group(0) @binding(3) var dfgLut: texture_2d; + +@group(1) @binding(0) var draw: Draw; + +@group(2) @binding(0) var colorMap: texture_2d; +@group(2) @binding(1) var colorSampler: sampler; +@group(2) @binding(2) var emissiveMap: texture_2d; +@group(2) @binding(3) var emissiveSampler: sampler; +@group(2) @binding(4) var bumpMap: texture_2d; +@group(2) @binding(5) var bumpSampler: sampler; + +struct VertexIn { + @location(0) position: vec3f, + @location(1) normal: vec3f, + @location(2) uv: vec2f, + @location(3) color: vec3f, +} + +fn RRTAndODTFit(v: vec3f) -> vec3f { + let a = v * (v + 0.0245786) - 0.000090537; + let b = v * (0.983729 * v + 0.4329510) + 0.238081; + return a / b; +} + +// ACES filmic, with the 1/0.6 brightening for a lit room rather than a +// cinema. +fn ACESFilmicToneMapping(input: vec3f) -> vec3f { + let ACESInputMat = mat3x3f( + vec3f(0.59719, 0.07600, 0.02840), + vec3f(0.35458, 0.90834, 0.13383), + vec3f(0.04823, 0.01566, 0.83777) + ); + let ACESOutputMat = mat3x3f( + vec3f( 1.60475, -0.10208, -0.00327), + vec3f(-0.53108, 1.10813, -0.07276), + vec3f(-0.07367, -0.00605, 1.07602) + ); + var color = input * (frame.fogParams.z / 0.6); + color = ACESInputMat * color; + color = RRTAndODTFit(color); + color = ACESOutputMat * color; + return saturate(color); +} + +fn sRGBTransferOETF(value: vec4f) -> vec4f { + let encoded = pow(value.rgb, vec3f(0.41666)) * 1.055 - vec3f(0.055); + let low = value.rgb * 12.92; + return vec4f(select(encoded, low, value.rgb <= vec3f(0.0031308)), value.a); +} + +// Tone map, encode for the screen, then fog — in that order, because the fog +// colour arrives already encoded and is mixed in after the encode. +fn finish(input: vec3f, alpha: f32, fogDepth: f32) -> vec4f { + var color = input; + if (draw.flags.y > 0.5 && frame.fogParams.w > 0.5) { + color = ACESFilmicToneMapping(color); + } + var result = sRGBTransferOETF(vec4f(color, alpha)); + if (draw.flags.x > 0.5 && frame.fogColor.a > 0.5) { + let amount = smoothstep(frame.fogParams.x, frame.fogParams.y, fogDepth); + result = vec4f(mix(result.rgb, frame.fogColor.rgb, amount), result.a); + } + return result; +} diff --git a/src/gpu/shaders/lit.frag.glsl b/src/gpu/shaders/lit.frag.glsl new file mode 100644 index 0000000..023e498 --- /dev/null +++ b/src/gpu/shaders/lit.frag.glsl @@ -0,0 +1,393 @@ +// Metal/rough PBR with a clearcoat and a sheen, lit by directional lights, a +// hemisphere light and a prefiltered environment. +// +// This is the lighting model the game's look was tuned under, kept term for +// term: GGX specular with multiscatter energy compensation off a DFG table, +// Charlie sheen with its energy compensation, a clearcoat layered on top, and +// the cube-UV environment lookup with its roughness-to-mip mapping. Change a +// constant here and the egg changes colour. + +uniform sampler2D colorMap; +uniform sampler2D emissiveMap; +uniform sampler2D bumpMap; +uniform sampler2D envMap; +uniform sampler2D dfgLut; + +in vec3 vViewPosition; +in vec3 vNormal; +in vec2 vUv; + +out vec4 fragColor; + +#define PI 3.141592653589793 +#define RECIPROCAL_PI 0.3183098861837907 +#define EPSILON 1e-6 + +float pow2(float x) { return x * x; } +float pow4(float x) { float x2 = x * x; return x2 * x2; } +float max3(vec3 v) { return max(max(v.x, v.y), v.z); } + +vec3 F_Schlick(vec3 f0, float f90, float dotVH) { + float fresnel = exp2((-5.55473 * dotVH - 6.98316) * dotVH); + return f0 * (1.0 - fresnel) + (f90 * fresnel); +} + +float V_GGX_SmithCorrelated(float alpha, float dotNL, float dotNV) { + float a2 = pow2(alpha); + float gv = dotNL * sqrt(a2 + (1.0 - a2) * pow2(dotNV)); + float gl = dotNV * sqrt(a2 + (1.0 - a2) * pow2(dotNL)); + return 0.5 / max(gv + gl, EPSILON); +} + +float D_GGX(float alpha, float dotNH) { + float a2 = pow2(alpha); + float denom = pow2(dotNH) * (a2 - 1.0) + 1.0; + return RECIPROCAL_PI * a2 / pow2(denom); +} + +// GGX distribution, Schlick Fresnel, height-correlated Smith visibility — +// the base layer and the clearcoat both, with their own F0 and roughness. +vec3 BRDF_GGX(vec3 lightDir, vec3 viewDir, vec3 normal, vec3 f0, float f90, float roughness) { + float alpha = pow2(roughness); + vec3 halfDir = normalize(lightDir + viewDir); + float dotNL = saturate(dot(normal, lightDir)); + float dotNV = saturate(dot(normal, viewDir)); + float dotNH = saturate(dot(normal, halfDir)); + float dotVH = saturate(dot(viewDir, halfDir)); + vec3 F = F_Schlick(f0, f90, dotVH); + float V = V_GGX_SmithCorrelated(alpha, dotNL, dotNV); + float D = D_GGX(alpha, dotNH); + return F * (V * D); +} + +float D_Charlie(float roughness, float dotNH) { + float alpha = pow2(roughness); + float invAlpha = 1.0 / alpha; + float cos2h = dotNH * dotNH; + float sin2h = max(1.0 - cos2h, 0.0078125); + return (2.0 + invAlpha) * pow(sin2h, invAlpha * 0.5) / (2.0 * PI); +} + +float V_Neubelt(float dotNV, float dotNL) { + return saturate(1.0 / (4.0 * (dotNL + dotNV - dotNL * dotNV))); +} + +vec3 BRDF_Sheen(vec3 lightDir, vec3 viewDir, vec3 normal, vec3 sheenColor, float sheenRoughness) { + vec3 halfDir = normalize(lightDir + viewDir); + float dotNL = saturate(dot(normal, lightDir)); + float dotNV = saturate(dot(normal, viewDir)); + float dotNH = saturate(dot(normal, halfDir)); + return sheenColor * (D_Charlie(sheenRoughness, dotNH) * V_Neubelt(dotNV, dotNL)); +} + +// A curve fit to the Charlie sheen BRDF integrated over the hemisphere. +float IBLSheenBRDF(vec3 normal, vec3 viewDir, float roughness) { + float dotNV = saturate(dot(normal, viewDir)); + float r2 = roughness * roughness; + float rInv = 1.0 / (roughness + 0.1); + float a = -1.9362 + 1.0678 * roughness + 0.4573 * r2 - 0.8469 * rInv; + float b = -0.6014 + 0.5538 * roughness - 0.4670 * r2 - 0.1255 * rInv; + return saturate(exp(a * dotNV + b)); +} + +vec2 dfg(float roughness, float dotNV) { + return textureLod(dfgLut, vec2(roughness, dotNV), 0.0).rg; +} + +vec3 EnvironmentBRDF(vec3 normal, vec3 viewDir, vec3 specularColor, float specularF90, float roughness) { + vec2 fab = dfg(roughness, saturate(dot(normal, viewDir))); + return specularColor * fab.x + specularF90 * fab.y; +} + +// Fdez-Agüera's multiple-scattering approximation for image-based light. +void computeMultiscattering(vec3 normal, vec3 viewDir, vec3 specularColor, float specularF90, + float roughness, inout vec3 singleScatter, inout vec3 multiScatter) { + vec2 fab = dfg(roughness, saturate(dot(normal, viewDir))); + vec3 FssEss = specularColor * fab.x + specularF90 * fab.y; + float Ess = fab.x + fab.y; + float Ems = 1.0 - Ess; + vec3 Favg = specularColor + (1.0 - specularColor) * 0.047619; + vec3 Fms = FssEss * Favg / (1.0 - Ems * Favg); + singleScatter += FssEss; + multiScatter += Fms * Ems; +} + +// GGX for direct light, with Turquin's multiple-scattering compensation. +vec3 BRDF_GGX_Multiscatter(vec3 lightDir, vec3 viewDir, vec3 normal, vec3 specularColor, + float specularF90, float roughness) { + vec3 singleScatter = BRDF_GGX(lightDir, viewDir, normal, specularColor, specularF90, roughness); + float dotNL = saturate(dot(normal, lightDir)); + float dotNV = saturate(dot(normal, viewDir)); + vec2 dfgV = dfg(roughness, dotNV); + vec2 dfgL = dfg(roughness, dotNL); + vec3 FssEss_V = specularColor * dfgV.x + specularF90 * dfgV.y; + vec3 FssEss_L = specularColor * dfgL.x + specularF90 * dfgL.y; + float Ems_V = 1.0 - (dfgV.x + dfgV.y); + float Ems_L = 1.0 - (dfgL.x + dfgL.y); + vec3 Favg = specularColor + (1.0 - specularColor) * 0.047619; + vec3 Fms = FssEss_V * FssEss_L * Favg / (1.0 - Ems_V * Ems_L * Favg + EPSILON); + return singleScatter + Fms * (Ems_V * Ems_L); +} + +// The cube-UV atlas: six faces three across and two down, one block per rung +// of blur, a texel of padding round every face. + +#define cubeUV_minMipLevel 4.0 +#define cubeUV_minTileSize 16.0 + +float getFace(vec3 direction) { + vec3 absDirection = abs(direction); + float face = -1.0; + if (absDirection.x > absDirection.z) { + if (absDirection.x > absDirection.y) face = direction.x > 0.0 ? 0.0 : 3.0; + else face = direction.y > 0.0 ? 1.0 : 4.0; + } else { + if (absDirection.z > absDirection.y) face = direction.z > 0.0 ? 2.0 : 5.0; + else face = direction.y > 0.0 ? 1.0 : 4.0; + } + return face; +} + +vec2 getUV(vec3 direction, float face) { + vec2 uv; + if (face == 0.0) uv = vec2(direction.z, direction.y) / abs(direction.x); + else if (face == 1.0) uv = vec2(-direction.x, -direction.z) / abs(direction.y); + else if (face == 2.0) uv = vec2(-direction.x, direction.y) / abs(direction.z); + else if (face == 3.0) uv = vec2(-direction.z, direction.y) / abs(direction.x); + else if (face == 4.0) uv = vec2(-direction.x, direction.z) / abs(direction.y); + else uv = vec2(direction.x, direction.y) / abs(direction.z); + return 0.5 * (uv + 1.0); +} + +vec3 bilinearCubeUV(vec3 direction, float mipInt) { + float face = getFace(direction); + float filterInt = max(cubeUV_minMipLevel - mipInt, 0.0); + mipInt = max(mipInt, cubeUV_minMipLevel); + float faceSize = exp2(mipInt); + highp vec2 uv = getUV(direction, face) * (faceSize - 2.0) + 1.0; + if (face > 2.0) { + uv.y += faceSize; + face -= 3.0; + } + uv.x += face * faceSize; + uv.x += filterInt * 3.0 * cubeUV_minTileSize; + uv.y += 4.0 * (exp2(envParams.z) - faceSize); + uv.x *= envParams.x; + uv.y *= envParams.y; + return textureLod(envMap, uv, 0.0).rgb; +} + +#define cubeUV_r0 1.0 +#define cubeUV_m0 -2.0 +#define cubeUV_r1 0.8 +#define cubeUV_m1 -1.0 +#define cubeUV_r4 0.4 +#define cubeUV_m4 2.0 +#define cubeUV_r5 0.305 +#define cubeUV_m5 3.0 +#define cubeUV_r6 0.21 +#define cubeUV_m6 4.0 + +float roughnessToMip(float roughness) { + float mip = 0.0; + if (roughness >= cubeUV_r1) { + mip = (cubeUV_r0 - roughness) * (cubeUV_m1 - cubeUV_m0) / (cubeUV_r0 - cubeUV_r1) + cubeUV_m0; + } else if (roughness >= cubeUV_r4) { + mip = (cubeUV_r1 - roughness) * (cubeUV_m4 - cubeUV_m1) / (cubeUV_r1 - cubeUV_r4) + cubeUV_m1; + } else if (roughness >= cubeUV_r5) { + mip = (cubeUV_r4 - roughness) * (cubeUV_m5 - cubeUV_m4) / (cubeUV_r4 - cubeUV_r5) + cubeUV_m4; + } else if (roughness >= cubeUV_r6) { + mip = (cubeUV_r5 - roughness) * (cubeUV_m6 - cubeUV_m5) / (cubeUV_r5 - cubeUV_r6) + cubeUV_m5; + } else { + mip = -2.0 * log2(1.16 * roughness); + } + return mip; +} + +vec3 textureCubeUV(vec3 sampleDir, float roughness) { + float mip = clamp(roughnessToMip(roughness), cubeUV_m0, envParams.z); + float mipF = fract(mip); + float mipInt = floor(mip); + vec3 color0 = bilinearCubeUV(sampleDir, mipInt); + if (mipF == 0.0) return color0; + vec3 color1 = bilinearCubeUV(sampleDir, mipInt + 1.0); + return mix(color0, color1, mipF); +} + +// The view matrix's rotation is orthonormal, so its transpose undoes it. +vec3 toWorld(vec3 dir) { + return normalize((vec4(dir, 0.0) * viewMatrix).xyz); +} + +vec3 getIBLIrradiance(vec3 normal) { + if (envParams.w < 0.5) return vec3(0.0); + return PI * textureCubeUV(toWorld(normal), 1.0); +} + +vec3 getIBLRadiance(vec3 viewDir, vec3 normal, float roughness) { + if (envParams.w < 0.5) return vec3(0.0); + vec3 reflectVec = reflect(-viewDir, normal); + // Leaning the reflection towards the normal keeps a rough surface from + // gathering light from behind its own tangent plane. + reflectVec = normalize(mix(reflectVec, normal, pow4(roughness))); + return textureCubeUV(toWorld(reflectVec), roughness); +} + +// Bump mapping from a height map, without tangents (Mikkelsen 2010): the +// height's screen-space slope, pushed through the surface's own slope. +vec2 dHdxy_fwd() { + vec2 dSTdx = dFdx(vUv); + vec2 dSTdy = dFdy(vUv); + float bumpScale = drawSurface.z; + float Hll = bumpScale * texture(bumpMap, vUv).x; + float dBx = bumpScale * texture(bumpMap, vUv + dSTdx).x - Hll; + float dBy = bumpScale * texture(bumpMap, vUv + dSTdy).x - Hll; + return vec2(dBx, dBy); +} + +vec3 perturbNormalArb(vec3 surf_pos, vec3 surf_norm, vec2 dHdxy, float faceDirection) { + vec3 vSigmaX = normalize(dFdx(surf_pos.xyz)); + vec3 vSigmaY = normalize(dFdy(surf_pos.xyz)); + vec3 vN = surf_norm; + vec3 R1 = cross(vSigmaY, vN); + vec3 R2 = cross(vN, vSigmaX); + float fDet = dot(vSigmaX, R1) * faceDirection; + vec3 vGrad = sign(fDet) * (dHdxy.x * R1 + dHdxy.y * R2); + return normalize(abs(fDet) * surf_norm - vGrad); +} + +void main() { + vec4 diffuseColor = drawColor * texture(colorMap, vUv); + vec3 totalEmissiveRadiance = drawEmissive.rgb * texture(emissiveMap, vUv).rgb; + float metalness = drawSurface.y; + + float faceDirection = gl_FrontFacing ? 1.0 : -1.0; + vec3 normal = normalize(vNormal); + vec3 nonPerturbedNormal = normal; + if (drawSurface.z != 0.0) normal = perturbNormalArb(-vViewPosition, normal, dHdxy_fwd(), faceDirection); + vec3 clearcoatNormal = nonPerturbedNormal; + + // How much the normal turns across a pixel is roughness the eye cannot + // resolve, so it is added on — it is what stops a curved edge sparkling. + vec3 dxy = max(abs(dFdx(nonPerturbedNormal)), abs(dFdy(nonPerturbedNormal))); + float geometryRoughness = max(max(dxy.x, dxy.y), dxy.z); + + vec3 diffuse = diffuseColor.rgb; + vec3 diffuseContribution = diffuse * (1.0 - metalness); + float roughness = min(max(drawSurface.x, 0.0525) + geometryRoughness, 1.0); + vec3 specularColor = vec3(0.04); + vec3 specularColorBlended = mix(specularColor, diffuse, metalness); + float specularF90 = 1.0; + + float clearcoat = saturate(drawSurface.w); + bool coated = drawSurface.w > 0.0; + float clearcoatRoughness = min(max(drawCoat.x, 0.0525) + geometryRoughness, 1.0); + vec3 clearcoatF0 = vec3(0.04); + float clearcoatF90 = 1.0; + + bool sheened = drawSheen.a > 0.5; + vec3 sheenColor = drawSheen.rgb; + float sheenRoughness = clamp(drawCoat.y, 0.0001, 1.0); + + vec3 geometryViewDir = normalize(vViewPosition); + + vec3 directDiffuse = vec3(0.0); + vec3 directSpecular = vec3(0.0); + vec3 indirectDiffuse = vec3(0.0); + vec3 indirectSpecular = vec3(0.0); + vec3 clearcoatSpecularDirect = vec3(0.0); + vec3 clearcoatSpecularIndirect = vec3(0.0); + vec3 sheenSpecularDirect = vec3(0.0); + vec3 sheenSpecularIndirect = vec3(0.0); + + int lights = int(counts.x); + for (int i = 0; i < 4; i++) { + if (i >= lights) break; + vec3 direction = lightDirection[i].xyz; + vec3 color = lightColor[i].rgb; + + float dotNL = saturate(dot(normal, direction)); + vec3 irradiance = dotNL * color; + + if (coated) { + float dotNLcc = saturate(dot(clearcoatNormal, direction)); + clearcoatSpecularDirect += dotNLcc * color + * BRDF_GGX(direction, geometryViewDir, clearcoatNormal, clearcoatF0, clearcoatF90, clearcoatRoughness); + } + + if (sheened) { + sheenSpecularDirect += irradiance * BRDF_Sheen(direction, geometryViewDir, normal, sheenColor, sheenRoughness); + float sheenAlbedoV = IBLSheenBRDF(normal, geometryViewDir, sheenRoughness); + float sheenAlbedoL = IBLSheenBRDF(normal, direction, sheenRoughness); + irradiance *= 1.0 - max3(sheenColor) * max(sheenAlbedoV, sheenAlbedoL); + } + + directSpecular += irradiance + * BRDF_GGX_Multiscatter(direction, geometryViewDir, normal, specularColorBlended, specularF90, roughness); + directDiffuse += irradiance * RECIPROCAL_PI * diffuseContribution; + } + + vec3 irradiance = vec3(0.0); + if (hemiSky.a > 0.5) { + float hemiDiffuseWeight = 0.5 * dot(normal, hemiDirection.xyz) + 0.5; + irradiance += mix(hemiGround.rgb, hemiSky.rgb, hemiDiffuseWeight); + } + + vec3 iblIrradiance = getIBLIrradiance(normal); + vec3 radiance = getIBLRadiance(geometryViewDir, normal, roughness); + + float sheenAlbedo = sheened ? IBLSheenBRDF(normal, geometryViewDir, sheenRoughness) : 0.0; + float sheenEnergyComp = 1.0 - max3(sheenColor) * sheenAlbedo; + + vec3 lambert = irradiance * RECIPROCAL_PI * diffuseContribution; + if (sheened) lambert *= sheenEnergyComp; + indirectDiffuse += lambert; + + if (coated) { + vec3 clearcoatRadiance = getIBLRadiance(geometryViewDir, clearcoatNormal, clearcoatRoughness); + clearcoatSpecularIndirect += clearcoatRadiance + * EnvironmentBRDF(clearcoatNormal, geometryViewDir, clearcoatF0, clearcoatF90, clearcoatRoughness); + } + + if (sheened) { + sheenSpecularIndirect += iblIrradiance * sheenColor * sheenAlbedo * RECIPROCAL_PI; + } + + vec3 singleScatteringDielectric = vec3(0.0); + vec3 multiScatteringDielectric = vec3(0.0); + vec3 singleScatteringMetallic = vec3(0.0); + vec3 multiScatteringMetallic = vec3(0.0); + computeMultiscattering(normal, geometryViewDir, specularColor, specularF90, roughness, + singleScatteringDielectric, multiScatteringDielectric); + computeMultiscattering(normal, geometryViewDir, diffuse, specularF90, roughness, + singleScatteringMetallic, multiScatteringMetallic); + + vec3 singleScattering = mix(singleScatteringDielectric, singleScatteringMetallic, metalness); + vec3 multiScattering = mix(multiScatteringDielectric, multiScatteringMetallic, metalness); + vec3 totalScatteringDielectric = singleScatteringDielectric + multiScatteringDielectric; + vec3 diffuseIBL = diffuseContribution * (1.0 - totalScatteringDielectric); + vec3 cosineWeightedIrradiance = iblIrradiance * RECIPROCAL_PI; + + vec3 specularIBL = radiance * singleScattering + multiScattering * cosineWeightedIrradiance; + vec3 diffuseFromIBL = diffuseIBL * cosineWeightedIrradiance; + if (sheened) { + specularIBL *= sheenEnergyComp; + diffuseFromIBL *= sheenEnergyComp; + } + indirectSpecular += specularIBL; + indirectDiffuse += diffuseFromIBL; + + vec3 outgoingLight = directDiffuse + indirectDiffuse + directSpecular + indirectSpecular + totalEmissiveRadiance; + + if (sheened) outgoingLight += sheenSpecularDirect + sheenSpecularIndirect; + + if (coated) { + float dotNVcc = saturate(dot(clearcoatNormal, geometryViewDir)); + vec3 Fcc = F_Schlick(clearcoatF0, clearcoatF90, dotNVcc); + outgoingLight = outgoingLight * (1.0 - clearcoat * Fcc) + + (clearcoatSpecularDirect + clearcoatSpecularIndirect) * clearcoat; + } + + float alpha = drawFlags.z > 0.5 ? 1.0 : diffuseColor.a; + fragColor = finish(outgoingLight, alpha, vViewPosition.z); +} diff --git a/src/gpu/shaders/lit.vert.glsl b/src/gpu/shaders/lit.vert.glsl new file mode 100644 index 0000000..bedbfc8 --- /dev/null +++ b/src/gpu/shaders/lit.vert.glsl @@ -0,0 +1,19 @@ +// Lit surfaces: everything is worked out in view space, from a model-view +// matrix the CPU multiplied in double precision. + +layout(location = 0) in vec3 position; +layout(location = 1) in vec3 normal; +layout(location = 2) in vec2 uv; + +out vec3 vViewPosition; +out vec3 vNormal; +out vec2 vUv; + +void main() { + // Canvases are uploaded top row first; the geometry's uvs put the top at 1. + vUv = vec2(uv.x, 1.0 - uv.y); + vNormal = normalize(mat3(normalMatrix) * normal); + vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); + gl_Position = projectionMatrix * mvPosition; + vViewPosition = -mvPosition.xyz; +} diff --git a/src/gpu/shaders/lit.wgsl b/src/gpu/shaders/lit.wgsl new file mode 100644 index 0000000..eabf8f5 --- /dev/null +++ b/src/gpu/shaders/lit.wgsl @@ -0,0 +1,409 @@ +// Metal/rough PBR with a clearcoat and a sheen, lit by directional lights, a +// hemisphere light and a prefiltered environment — lit.frag.glsl, term for +// term. Change a constant in one and change it in the other. + +const PI = 3.141592653589793; +const RECIPROCAL_PI = 0.3183098861837907; +const EPSILON = 1e-6; + +struct VertexOut { + @builtin(position) position: vec4f, + @location(0) viewPosition: vec3f, + @location(1) normal: vec3f, + @location(2) uv: vec2f, +} + +@vertex +fn vs(input: VertexIn) -> VertexOut { + var out: VertexOut; + // Canvases are uploaded top row first; the geometry's uvs put the top at 1. + out.uv = vec2f(input.uv.x, 1.0 - input.uv.y); + let n = draw.normalMatrix; + out.normal = normalize(mat3x3f(n[0].xyz, n[1].xyz, n[2].xyz) * input.normal); + let mvPosition = draw.modelView * vec4f(input.position, 1.0); + out.position = frame.projection * mvPosition; + out.viewPosition = -mvPosition.xyz; + return out; +} + +fn pow2(x: f32) -> f32 { return x * x; } +fn pow4(x: f32) -> f32 { let x2 = x * x; return x2 * x2; } +fn max3(v: vec3f) -> f32 { return max(max(v.x, v.y), v.z); } + +fn F_Schlick(f0: vec3f, f90: f32, dotVH: f32) -> vec3f { + let fresnel = exp2((-5.55473 * dotVH - 6.98316) * dotVH); + return f0 * (1.0 - fresnel) + (f90 * fresnel); +} + +fn V_GGX_SmithCorrelated(alpha: f32, dotNL: f32, dotNV: f32) -> f32 { + let a2 = pow2(alpha); + let gv = dotNL * sqrt(a2 + (1.0 - a2) * pow2(dotNV)); + let gl = dotNV * sqrt(a2 + (1.0 - a2) * pow2(dotNL)); + return 0.5 / max(gv + gl, EPSILON); +} + +fn D_GGX(alpha: f32, dotNH: f32) -> f32 { + let a2 = pow2(alpha); + let denom = pow2(dotNH) * (a2 - 1.0) + 1.0; + return RECIPROCAL_PI * a2 / pow2(denom); +} + +fn BRDF_GGX(lightDir: vec3f, viewDir: vec3f, normal: vec3f, f0: vec3f, f90: f32, roughness: f32) -> vec3f { + let alpha = pow2(roughness); + let halfDir = normalize(lightDir + viewDir); + let dotNL = saturate(dot(normal, lightDir)); + let dotNV = saturate(dot(normal, viewDir)); + let dotNH = saturate(dot(normal, halfDir)); + let dotVH = saturate(dot(viewDir, halfDir)); + let F = F_Schlick(f0, f90, dotVH); + let V = V_GGX_SmithCorrelated(alpha, dotNL, dotNV); + let D = D_GGX(alpha, dotNH); + return F * (V * D); +} + +fn D_Charlie(roughness: f32, dotNH: f32) -> f32 { + let alpha = pow2(roughness); + let invAlpha = 1.0 / alpha; + let cos2h = dotNH * dotNH; + let sin2h = max(1.0 - cos2h, 0.0078125); + return (2.0 + invAlpha) * pow(sin2h, invAlpha * 0.5) / (2.0 * PI); +} + +fn V_Neubelt(dotNV: f32, dotNL: f32) -> f32 { + return saturate(1.0 / (4.0 * (dotNL + dotNV - dotNL * dotNV))); +} + +fn BRDF_Sheen(lightDir: vec3f, viewDir: vec3f, normal: vec3f, sheenColor: vec3f, sheenRoughness: f32) -> vec3f { + let halfDir = normalize(lightDir + viewDir); + let dotNL = saturate(dot(normal, lightDir)); + let dotNV = saturate(dot(normal, viewDir)); + let dotNH = saturate(dot(normal, halfDir)); + return sheenColor * (D_Charlie(sheenRoughness, dotNH) * V_Neubelt(dotNV, dotNL)); +} + +fn IBLSheenBRDF(normal: vec3f, viewDir: vec3f, roughness: f32) -> f32 { + let dotNV = saturate(dot(normal, viewDir)); + let r2 = roughness * roughness; + let rInv = 1.0 / (roughness + 0.1); + let a = -1.9362 + 1.0678 * roughness + 0.4573 * r2 - 0.8469 * rInv; + let b = -0.6014 + 0.5538 * roughness - 0.4670 * r2 - 0.1255 * rInv; + return saturate(exp(a * dotNV + b)); +} + +fn dfg(roughness: f32, dotNV: f32) -> vec2f { + return textureSampleLevel(dfgLut, clampSampler, vec2f(roughness, dotNV), 0.0).rg; +} + +fn EnvironmentBRDF(normal: vec3f, viewDir: vec3f, specularColor: vec3f, specularF90: f32, roughness: f32) -> vec3f { + let fab = dfg(roughness, saturate(dot(normal, viewDir))); + return specularColor * fab.x + specularF90 * fab.y; +} + +struct Scattering { + single: vec3f, + multi: vec3f, +} + +fn computeMultiscattering(normal: vec3f, viewDir: vec3f, specularColor: vec3f, specularF90: f32, roughness: f32) -> Scattering { + let fab = dfg(roughness, saturate(dot(normal, viewDir))); + let FssEss = specularColor * fab.x + specularF90 * fab.y; + let Ess = fab.x + fab.y; + let Ems = 1.0 - Ess; + let Favg = specularColor + (1.0 - specularColor) * 0.047619; + let Fms = FssEss * Favg / (1.0 - Ems * Favg); + return Scattering(FssEss, Fms * Ems); +} + +fn BRDF_GGX_Multiscatter(lightDir: vec3f, viewDir: vec3f, normal: vec3f, specularColor: vec3f, specularF90: f32, roughness: f32) -> vec3f { + let singleScatter = BRDF_GGX(lightDir, viewDir, normal, specularColor, specularF90, roughness); + let dotNL = saturate(dot(normal, lightDir)); + let dotNV = saturate(dot(normal, viewDir)); + let dfgV = dfg(roughness, dotNV); + let dfgL = dfg(roughness, dotNL); + let FssEss_V = specularColor * dfgV.x + specularF90 * dfgV.y; + let FssEss_L = specularColor * dfgL.x + specularF90 * dfgL.y; + let Ems_V = 1.0 - (dfgV.x + dfgV.y); + let Ems_L = 1.0 - (dfgL.x + dfgL.y); + let Favg = specularColor + (1.0 - specularColor) * 0.047619; + let Fms = FssEss_V * FssEss_L * Favg / (1.0 - Ems_V * Ems_L * Favg + EPSILON); + return singleScatter + Fms * (Ems_V * Ems_L); +} + +const cubeUV_minMipLevel = 4.0; +const cubeUV_minTileSize = 16.0; + +fn getFace(direction: vec3f) -> f32 { + let absDirection = abs(direction); + var face = -1.0; + if (absDirection.x > absDirection.z) { + if (absDirection.x > absDirection.y) { + face = select(3.0, 0.0, direction.x > 0.0); + } else { + face = select(4.0, 1.0, direction.y > 0.0); + } + } else { + if (absDirection.z > absDirection.y) { + face = select(5.0, 2.0, direction.z > 0.0); + } else { + face = select(4.0, 1.0, direction.y > 0.0); + } + } + return face; +} + +fn getUV(direction: vec3f, face: f32) -> vec2f { + var uv: vec2f; + if (face == 0.0) { + uv = vec2f(direction.z, direction.y) / abs(direction.x); + } else if (face == 1.0) { + uv = vec2f(-direction.x, -direction.z) / abs(direction.y); + } else if (face == 2.0) { + uv = vec2f(-direction.x, direction.y) / abs(direction.z); + } else if (face == 3.0) { + uv = vec2f(-direction.z, direction.y) / abs(direction.x); + } else if (face == 4.0) { + uv = vec2f(-direction.x, direction.z) / abs(direction.y); + } else { + uv = vec2f(direction.x, direction.y) / abs(direction.z); + } + return 0.5 * (uv + 1.0); +} + +fn bilinearCubeUV(direction: vec3f, mipLevel: f32) -> vec3f { + var face = getFace(direction); + let filterInt = max(cubeUV_minMipLevel - mipLevel, 0.0); + let mipInt = max(mipLevel, cubeUV_minMipLevel); + let faceSize = exp2(mipInt); + var uv = getUV(direction, face) * (faceSize - 2.0) + 1.0; + if (face > 2.0) { + uv.y += faceSize; + face -= 3.0; + } + uv.x += face * faceSize; + uv.x += filterInt * 3.0 * cubeUV_minTileSize; + uv.y += 4.0 * (exp2(frame.envParams.z) - faceSize); + uv.x *= frame.envParams.x; + uv.y *= frame.envParams.y; + return textureSampleLevel(envMap, clampSampler, uv, 0.0).rgb; +} + +const cubeUV_r0 = 1.0; +const cubeUV_m0 = -2.0; +const cubeUV_r1 = 0.8; +const cubeUV_m1 = -1.0; +const cubeUV_r4 = 0.4; +const cubeUV_m4 = 2.0; +const cubeUV_r5 = 0.305; +const cubeUV_m5 = 3.0; +const cubeUV_r6 = 0.21; +const cubeUV_m6 = 4.0; + +fn roughnessToMip(roughness: f32) -> f32 { + var mip = 0.0; + if (roughness >= cubeUV_r1) { + mip = (cubeUV_r0 - roughness) * (cubeUV_m1 - cubeUV_m0) / (cubeUV_r0 - cubeUV_r1) + cubeUV_m0; + } else if (roughness >= cubeUV_r4) { + mip = (cubeUV_r1 - roughness) * (cubeUV_m4 - cubeUV_m1) / (cubeUV_r1 - cubeUV_r4) + cubeUV_m1; + } else if (roughness >= cubeUV_r5) { + mip = (cubeUV_r4 - roughness) * (cubeUV_m5 - cubeUV_m4) / (cubeUV_r4 - cubeUV_r5) + cubeUV_m4; + } else if (roughness >= cubeUV_r6) { + mip = (cubeUV_r5 - roughness) * (cubeUV_m6 - cubeUV_m5) / (cubeUV_r5 - cubeUV_r6) + cubeUV_m5; + } else { + mip = -2.0 * log2(1.16 * roughness); + } + return mip; +} + +fn textureCubeUV(sampleDir: vec3f, roughness: f32) -> vec3f { + let mip = clamp(roughnessToMip(roughness), cubeUV_m0, frame.envParams.z); + let mipF = fract(mip); + let mipInt = floor(mip); + let color0 = bilinearCubeUV(sampleDir, mipInt); + if (mipF == 0.0) { + return color0; + } + let color1 = bilinearCubeUV(sampleDir, mipInt + 1.0); + return mix(color0, color1, mipF); +} + +// The view matrix's rotation is orthonormal, so its transpose undoes it. +fn toWorld(dir: vec3f) -> vec3f { + return normalize((vec4f(dir, 0.0) * frame.view).xyz); +} + +fn getIBLIrradiance(normal: vec3f) -> vec3f { + if (frame.envParams.w < 0.5) { + return vec3f(0.0); + } + return PI * textureCubeUV(toWorld(normal), 1.0); +} + +fn getIBLRadiance(viewDir: vec3f, normal: vec3f, roughness: f32) -> vec3f { + if (frame.envParams.w < 0.5) { + return vec3f(0.0); + } + var reflectVec = reflect(-viewDir, normal); + reflectVec = normalize(mix(reflectVec, normal, pow4(roughness))); + return textureCubeUV(toWorld(reflectVec), roughness); +} + +fn dHdxy_fwd(uv: vec2f) -> vec2f { + let dSTdx = dpdx(uv); + let dSTdy = dpdy(uv); + let bumpScale = draw.surface.z; + let Hll = bumpScale * textureSample(bumpMap, bumpSampler, uv).x; + let dBx = bumpScale * textureSample(bumpMap, bumpSampler, uv + dSTdx).x - Hll; + let dBy = bumpScale * textureSample(bumpMap, bumpSampler, uv + dSTdy).x - Hll; + return vec2f(dBx, dBy); +} + +fn perturbNormalArb(surf_pos: vec3f, surf_norm: vec3f, dHdxy: vec2f, faceDirection: f32) -> vec3f { + let vSigmaX = normalize(dpdx(surf_pos)); + let vSigmaY = normalize(dpdy(surf_pos)); + let vN = surf_norm; + let R1 = cross(vSigmaY, vN); + let R2 = cross(vN, vSigmaX); + let fDet = dot(vSigmaX, R1) * faceDirection; + let vGrad = sign(fDet) * (dHdxy.x * R1 + dHdxy.y * R2); + return normalize(abs(fDet) * surf_norm - vGrad); +} + +@fragment +fn fs(input: VertexOut, @builtin(front_facing) front: bool) -> @location(0) vec4f { + let diffuseColor = draw.color * textureSample(colorMap, colorSampler, input.uv); + let totalEmissiveRadiance = draw.emissive.rgb * textureSample(emissiveMap, emissiveSampler, input.uv).rgb; + let metalness = draw.surface.y; + + let faceDirection = select(-1.0, 1.0, front); + var normal = normalize(input.normal); + let nonPerturbedNormal = normal; + if (draw.surface.z != 0.0) { + normal = perturbNormalArb(-input.viewPosition, normal, dHdxy_fwd(input.uv), faceDirection); + } + let clearcoatNormal = nonPerturbedNormal; + + let dxy = max(abs(dpdx(nonPerturbedNormal)), abs(dpdy(nonPerturbedNormal))); + let geometryRoughness = max(max(dxy.x, dxy.y), dxy.z); + + let diffuse = diffuseColor.rgb; + let diffuseContribution = diffuse * (1.0 - metalness); + let roughness = min(max(draw.surface.x, 0.0525) + geometryRoughness, 1.0); + let specularColor = vec3f(0.04); + let specularColorBlended = mix(specularColor, diffuse, metalness); + let specularF90 = 1.0; + + let clearcoat = saturate(draw.surface.w); + let coated = draw.surface.w > 0.0; + let clearcoatRoughness = min(max(draw.coat.x, 0.0525) + geometryRoughness, 1.0); + let clearcoatF0 = vec3f(0.04); + let clearcoatF90 = 1.0; + + let sheened = draw.sheen.a > 0.5; + let sheenColor = draw.sheen.rgb; + let sheenRoughness = clamp(draw.coat.y, 0.0001, 1.0); + + let geometryViewDir = normalize(input.viewPosition); + + var directDiffuse = vec3f(0.0); + var directSpecular = vec3f(0.0); + var indirectDiffuse = vec3f(0.0); + var indirectSpecular = vec3f(0.0); + var clearcoatSpecularDirect = vec3f(0.0); + var clearcoatSpecularIndirect = vec3f(0.0); + var sheenSpecularDirect = vec3f(0.0); + var sheenSpecularIndirect = vec3f(0.0); + + let lights = i32(frame.counts.x); + for (var i = 0; i < 4; i++) { + if (i >= lights) { + break; + } + let direction = frame.lightDirection[i].xyz; + let color = frame.lightColor[i].rgb; + + let dotNL = saturate(dot(normal, direction)); + var irradiance = dotNL * color; + + if (coated) { + let dotNLcc = saturate(dot(clearcoatNormal, direction)); + clearcoatSpecularDirect += dotNLcc * color + * BRDF_GGX(direction, geometryViewDir, clearcoatNormal, clearcoatF0, clearcoatF90, clearcoatRoughness); + } + + if (sheened) { + sheenSpecularDirect += irradiance * BRDF_Sheen(direction, geometryViewDir, normal, sheenColor, sheenRoughness); + let sheenAlbedoV = IBLSheenBRDF(normal, geometryViewDir, sheenRoughness); + let sheenAlbedoL = IBLSheenBRDF(normal, direction, sheenRoughness); + irradiance *= 1.0 - max3(sheenColor) * max(sheenAlbedoV, sheenAlbedoL); + } + + directSpecular += irradiance + * BRDF_GGX_Multiscatter(direction, geometryViewDir, normal, specularColorBlended, specularF90, roughness); + directDiffuse += irradiance * RECIPROCAL_PI * diffuseContribution; + } + + var irradiance = vec3f(0.0); + if (frame.hemiSky.a > 0.5) { + let hemiDiffuseWeight = 0.5 * dot(normal, frame.hemiDirection.xyz) + 0.5; + irradiance += mix(frame.hemiGround.rgb, frame.hemiSky.rgb, hemiDiffuseWeight); + } + + let iblIrradiance = getIBLIrradiance(normal); + let radiance = getIBLRadiance(geometryViewDir, normal, roughness); + + var sheenAlbedo = 0.0; + if (sheened) { + sheenAlbedo = IBLSheenBRDF(normal, geometryViewDir, sheenRoughness); + } + let sheenEnergyComp = 1.0 - max3(sheenColor) * sheenAlbedo; + + var lambert = irradiance * RECIPROCAL_PI * diffuseContribution; + if (sheened) { + lambert *= sheenEnergyComp; + } + indirectDiffuse += lambert; + + if (coated) { + let clearcoatRadiance = getIBLRadiance(geometryViewDir, clearcoatNormal, clearcoatRoughness); + clearcoatSpecularIndirect += clearcoatRadiance + * EnvironmentBRDF(clearcoatNormal, geometryViewDir, clearcoatF0, clearcoatF90, clearcoatRoughness); + } + + if (sheened) { + sheenSpecularIndirect += iblIrradiance * sheenColor * sheenAlbedo * RECIPROCAL_PI; + } + + let dielectric = computeMultiscattering(normal, geometryViewDir, specularColor, specularF90, roughness); + let metallic = computeMultiscattering(normal, geometryViewDir, diffuse, specularF90, roughness); + + let singleScattering = mix(dielectric.single, metallic.single, metalness); + let multiScattering = mix(dielectric.multi, metallic.multi, metalness); + let totalScatteringDielectric = dielectric.single + dielectric.multi; + let diffuseIBL = diffuseContribution * (1.0 - totalScatteringDielectric); + let cosineWeightedIrradiance = iblIrradiance * RECIPROCAL_PI; + + var specularIBL = radiance * singleScattering + multiScattering * cosineWeightedIrradiance; + var diffuseFromIBL = diffuseIBL * cosineWeightedIrradiance; + if (sheened) { + specularIBL *= sheenEnergyComp; + diffuseFromIBL *= sheenEnergyComp; + } + indirectSpecular += specularIBL; + indirectDiffuse += diffuseFromIBL; + + var outgoingLight = directDiffuse + indirectDiffuse + directSpecular + indirectSpecular + totalEmissiveRadiance; + + if (sheened) { + outgoingLight += sheenSpecularDirect + sheenSpecularIndirect; + } + + if (coated) { + let dotNVcc = saturate(dot(clearcoatNormal, geometryViewDir)); + let Fcc = F_Schlick(clearcoatF0, clearcoatF90, dotNVcc); + outgoingLight = outgoingLight * (1.0 - clearcoat * Fcc) + + (clearcoatSpecularDirect + clearcoatSpecularIndirect) * clearcoat; + } + + let alpha = select(diffuseColor.a, 1.0, draw.flags.z > 0.5); + return finish(outgoingLight, alpha, input.viewPosition.z); +} diff --git a/src/gpu/shaders/mipmap.wgsl b/src/gpu/shaders/mipmap.wgsl new file mode 100644 index 0000000..521e2aa --- /dev/null +++ b/src/gpu/shaders/mipmap.wgsl @@ -0,0 +1,27 @@ +// Mipmaps for WebGPU, which does not make its own: each level is the one +// above it drawn at half size through a linear filter, which for a +// power-of-two texture averages each 2×2 block. On an sRGB texture the +// sample is decoded and the write encoded, so the average is taken in linear +// light, the way WebGL's generateMipmap takes it. + +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var linearSampler: sampler; + +struct VertexOut { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, +} + +@vertex +fn vs(@builtin(vertex_index) index: u32) -> VertexOut { + let corner = vec2f(f32((index << 1u) & 2u), f32(index & 2u)); + var out: VertexOut; + out.uv = vec2f(corner.x, 1.0 - corner.y); + out.position = vec4f(corner * 2.0 - 1.0, 0.0, 1.0); + return out; +} + +@fragment +fn fs(input: VertexOut) -> @location(0) vec4f { + return textureSampleLevel(source, linearSampler, input.uv, 0.0); +} diff --git a/src/gpu/texture.js b/src/gpu/texture.js new file mode 100644 index 0000000..2f1e6bf --- /dev/null +++ b/src/gpu/texture.js @@ -0,0 +1,31 @@ +/** + * A picture for the GPU: a canvas, and how to sample it. Everything this game + * textures with is painted onto a canvas at boot, so a texture is never + * fetched, never decoded, and is ready the moment it exists. + * + * Mipmaps are built on upload and anisotropic filtering is asked for up to + * what the device allows. Set `needsUpdate` after painting on the canvas + * again and the next frame uploads it again. + */ +export class Texture { + constructor(image, { + srgb = true, + wrapS = 'clamp', + wrapT = 'clamp', + anisotropy = 1, + } = {}) { + this.image = image; + /** Colour pictures are sRGB and are decoded to linear when sampled; + * data — a height map — is read back exactly as painted. */ + this.srgb = srgb; + /** 'repeat' or 'clamp', across and down. */ + this.wrapS = wrapS; + this.wrapT = wrapT; + this.anisotropy = anisotropy; + this.version = 0; + } + + set needsUpdate(value) { + if (value) this.version += 1; + } +} diff --git a/src/gpu/webgl.js b/src/gpu/webgl.js new file mode 100644 index 0000000..fee3cc2 --- /dev/null +++ b/src/gpu/webgl.js @@ -0,0 +1,326 @@ +import { DFG, DFG_SIZE } from './dfg.js'; +import { DRAW, FRAME, stateOf } from './frame.js'; +import backgroundFragment from './shaders/background.frag.glsl?raw'; +import backgroundVertex from './shaders/background.vert.glsl?raw'; +import basicFragment from './shaders/basic.frag.glsl?raw'; +import basicVertex from './shaders/basic.vert.glsl?raw'; +import common from './shaders/common.glsl?raw'; +import litFragment from './shaders/lit.frag.glsl?raw'; +import litVertex from './shaders/lit.vert.glsl?raw'; + +/** + * The WebGL 2 backend. Uniforms go up as two std140 blocks — one for the + * frame, and one big buffer of per-draw blocks bound a 256-byte range at a + * time — so it reads exactly the bytes the WebGPU backend reads. + */ + +const UNITS = { colorMap: 0, emissiveMap: 1, bumpMap: 2, envMap: 3, dfgLut: 4 }; +const STRIDE = DRAW.stride * 4; + +export function createWebGL(canvas, { clear, antialias }) { + const gl = canvas.getContext('webgl2', { + antialias, + alpha: false, + depth: true, + stencil: false, + premultipliedAlpha: true, + preserveDrawingBuffer: false, + powerPreference: 'high-performance', + }); + if (!gl) throw new Error('WebGL 2 is not available'); + if (gl.getParameter(gl.UNIFORM_BUFFER_OFFSET_ALIGNMENT) > STRIDE) { + throw new Error('uniform buffer offsets are too coarse for 256-byte draw blocks'); + } + + const anisotropic = gl.getExtension('EXT_texture_filter_anisotropic'); + const maxAnisotropy = anisotropic ? gl.getParameter(anisotropic.MAX_TEXTURE_MAX_ANISOTROPY_EXT) : 1; + + function shader(type, source) { + const s = gl.createShader(type); + gl.shaderSource(s, `#version 300 es\n${common}\n${source}`); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { + throw new Error(`shader did not compile: ${gl.getShaderInfoLog(s)}`); + } + return s; + } + + function program(vertex, fragment) { + const p = gl.createProgram(); + gl.attachShader(p, shader(gl.VERTEX_SHADER, vertex)); + gl.attachShader(p, shader(gl.FRAGMENT_SHADER, fragment)); + gl.linkProgram(p); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { + throw new Error(`program did not link: ${gl.getProgramInfoLog(p)}`); + } + for (const [name, binding] of [['Frame', 0], ['Draw', 1]]) { + const block = gl.getUniformBlockIndex(p, name); + if (block !== gl.INVALID_INDEX) gl.uniformBlockBinding(p, block, binding); + } + gl.useProgram(p); + for (const [name, unit] of Object.entries(UNITS)) { + const location = gl.getUniformLocation(p, name); + if (location) gl.uniform1i(location, unit); + } + return p; + } + + const programs = { + lit: program(litVertex, litFragment), + basic: program(basicVertex, basicFragment), + background: program(backgroundVertex, backgroundFragment), + }; + + /** Attributes a geometry does not carry read these constants instead. */ + gl.vertexAttrib3f(1, 0, 0, 1); + gl.vertexAttrib2f(2, 0, 0); + gl.vertexAttrib3f(3, 1, 1, 1); + + const frameBuffer = gl.createBuffer(); + gl.bindBuffer(gl.UNIFORM_BUFFER, frameBuffer); + gl.bufferData(gl.UNIFORM_BUFFER, FRAME.size * 4, gl.DYNAMIC_DRAW); + const drawBuffer = gl.createBuffer(); + let drawCapacity = 0; + + function dataTexture(internal, format, width, height, data) { + const tex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage2D(gl.TEXTURE_2D, 0, internal, width, height, 0, format, data instanceof Uint16Array ? gl.HALF_FLOAT : gl.UNSIGNED_BYTE, data); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + return tex; + } + + const white = dataTexture(gl.RGBA8, gl.RGBA, 1, 1, new Uint8Array([255, 255, 255, 255])); + const black = dataTexture(gl.RGBA16F, gl.RGBA, 1, 1, new Uint16Array(4)); + const dfg = dataTexture(gl.RG16F, gl.RG, DFG_SIZE, DFG_SIZE, DFG); + const environments = new WeakMap(); + + function environment(env) { + if (!env) return black; + if (!environments.has(env)) environments.set(env, dataTexture(gl.RGBA16F, gl.RGBA, env.width, env.height, env.data)); + return environments.get(env); + } + + const textures = new WeakMap(); + + function texture(source) { + if (!source?.image) return white; + let entry = textures.get(source); + if (!entry) { + entry = { tex: gl.createTexture(), version: -1 }; + textures.set(source, entry); + } + if (entry.version !== source.version) { + gl.bindTexture(gl.TEXTURE_2D, entry.tex); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); + gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4); + gl.texImage2D(gl.TEXTURE_2D, 0, source.srgb ? gl.SRGB8_ALPHA8 : gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, source.image); + gl.generateMipmap(gl.TEXTURE_2D); + if (entry.version < 0) { + const wrap = (mode) => (mode === 'repeat' ? gl.REPEAT : gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap(source.wrapS)); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap(source.wrapT)); + if (anisotropic && source.anisotropy > 1) { + gl.texParameterf(gl.TEXTURE_2D, anisotropic.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(source.anisotropy, maxAnisotropy)); + } + } + entry.version = source.version; + } + return entry.tex; + } + + const geometries = new WeakMap(); + + function release(event) { + const entry = geometries.get(event.target); + if (!entry) return; + gl.deleteVertexArray(entry.vao); + for (const buffer of entry.buffers) gl.deleteBuffer(buffer); + geometries.delete(event.target); + event.target.removeEventListener('dispose', release); + } + + /** Attribute name, shader location, and floats per vertex. */ + const ATTRIBUTES = [['position', 0, 3], ['normal', 1, 3], ['uv', 2, 2], ['color', 3, 3]]; + + function geometry(source) { + let entry = geometries.get(source); + if (entry && entry.version !== source.version) { + for (const [buffer, name] of entry.attributes) { + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, source[name]); + } + entry.version = source.version; + } + if (entry) return entry; + + const vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + const buffers = []; + const attributes = []; + for (const [name, location, size] of ATTRIBUTES) { + if (!source[name]) continue; + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, source[name], gl.DYNAMIC_DRAW); + gl.enableVertexAttribArray(location); + gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0); + buffers.push(buffer); + attributes.push([buffer, name]); + } + let indexType = 0; + if (source.index) { + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffer); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, source.index, gl.STATIC_DRAW); + buffers.push(buffer); + indexType = source.index instanceof Uint32Array ? gl.UNSIGNED_INT : gl.UNSIGNED_SHORT; + } + gl.bindVertexArray(null); + + entry = { vao, buffers, attributes, indexType, version: source.version }; + geometries.set(source, entry); + source.addEventListener('dispose', release); + return entry; + } + + const current = {}; + + function apply(state) { + if (current.cull !== state.cull) { + if (state.cull === 'none') gl.disable(gl.CULL_FACE); + else { + gl.enable(gl.CULL_FACE); + gl.cullFace(state.cull === 'front' ? gl.FRONT : gl.BACK); + } + current.cull = state.cull; + } + if (current.blending !== state.blending) { + if (state.blending === 'none') gl.disable(gl.BLEND); + else { + gl.enable(gl.BLEND); + gl.blendEquation(gl.FUNC_ADD); + if (state.blending === 'additive') gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE, gl.ONE, gl.ONE); + else gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + } + current.blending = state.blending; + } + if (current.depthTest !== state.depthTest) { + if (state.depthTest) gl.enable(gl.DEPTH_TEST); else gl.disable(gl.DEPTH_TEST); + current.depthTest = state.depthTest; + } + if (current.depthWrite !== state.depthWrite) { + gl.depthMask(state.depthWrite); + current.depthWrite = state.depthWrite; + } + const bias = state.biasFactor !== 0 || state.biasUnits !== 0; + if (current.bias !== bias || (bias && (current.biasFactor !== state.biasFactor || current.biasUnits !== state.biasUnits))) { + if (bias) { + gl.enable(gl.POLYGON_OFFSET_FILL); + gl.polygonOffset(state.biasFactor, state.biasUnits); + } else gl.disable(gl.POLYGON_OFFSET_FILL); + current.bias = bias; + current.biasFactor = state.biasFactor; + current.biasUnits = state.biasUnits; + } + } + + function bind(unit, tex) { + if (current[`unit${unit}`] === tex) return; + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, tex); + current[`unit${unit}`] = tex; + } + + function use(p) { + if (current.program === p) return; + gl.useProgram(p); + current.program = p; + } + + gl.depthFunc(gl.LEQUAL); + gl.frontFace(gl.CCW); + + return { + name: 'webgl', + zeroToOne: false, + + render(frame, draws, list, scene) { + for (const key of Object.keys(current)) delete current[key]; + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(clear[0], clear[1], clear[2], 1); + gl.clearDepth(1); + gl.depthMask(true); + gl.colorMask(true, true, true, true); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + + gl.bindBuffer(gl.UNIFORM_BUFFER, frameBuffer); + gl.bufferSubData(gl.UNIFORM_BUFFER, 0, frame); + gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, frameBuffer); + + const bytes = list.length * STRIDE; + gl.bindBuffer(gl.UNIFORM_BUFFER, drawBuffer); + if (bytes > drawCapacity) { + drawCapacity = Math.max(bytes, drawCapacity * 2, STRIDE * 256); + gl.bufferData(gl.UNIFORM_BUFFER, drawCapacity, gl.DYNAMIC_DRAW); + } + if (bytes > 0) gl.bufferSubData(gl.UNIFORM_BUFFER, 0, draws, 0, list.length * DRAW.stride); + + /** Textures first, so their uploads do not disturb the units bound + * for drawing. */ + const env = environment(scene.environment); + const background = scene.background?.image ? texture(scene.background) : null; + for (const it of list) { + const m = it.material; + texture(m.map); + if (m.shader === 'lit') { + texture(m.emissiveMap); + texture(m.bumpMap); + } + } + + bind(UNITS.envMap, env); + bind(UNITS.dfgLut, dfg); + + if (background) { + use(programs.background); + apply({ cull: 'none', blending: 'none', depthTest: false, depthWrite: false, biasFactor: 0, biasUnits: 0 }); + bind(UNITS.colorMap, background); + gl.bindVertexArray(null); + gl.drawArrays(gl.TRIANGLES, 0, 3); + } + + for (const it of list) { + const m = it.material; + const state = stateOf(m, it.lines); + use(programs[m.shader]); + apply(state); + bind(UNITS.colorMap, texture(m.map)); + if (m.shader === 'lit') { + bind(UNITS.emissiveMap, texture(m.emissiveMap)); + bind(UNITS.bumpMap, texture(m.bumpMap)); + } + gl.bindBufferRange(gl.UNIFORM_BUFFER, 1, drawBuffer, it.offset * 4, STRIDE); + + const g = it.node.geometry; + const entry = geometry(g); + gl.bindVertexArray(entry.vao); + const mode = it.lines ? gl.LINES : gl.TRIANGLES; + if (g.index) gl.drawElements(mode, g.index.length, entry.indexType, 0); + else gl.drawArrays(mode, 0, g.count); + } + gl.bindVertexArray(null); + }, + }; +} diff --git a/src/gpu/webgpu.js b/src/gpu/webgpu.js new file mode 100644 index 0000000..ce520d6 --- /dev/null +++ b/src/gpu/webgpu.js @@ -0,0 +1,455 @@ +import { DFG, DFG_SIZE } from './dfg.js'; +import { DRAW, FRAME, stateKey, stateOf } from './frame.js'; +import background from './shaders/background.wgsl?raw'; +import basic from './shaders/basic.wgsl?raw'; +import common from './shaders/common.wgsl?raw'; +import lit from './shaders/lit.wgsl?raw'; +import mipmap from './shaders/mipmap.wgsl?raw'; + +/** + * The WebGPU backend. One pipeline layout serves every pipeline: the frame + * block and the environment in group 0, a 256-byte slice of the draw buffer + * in group 1 at a dynamic offset, and a material's three textures in group 2. + * Pipelines are made the first time a combination of state is drawn, and + * kept. + * + * The canvas is configured in a plain (not sRGB) format and the shaders + * encode their own output, exactly as they do under WebGL — so blending + * happens on the same numbers, and a translucent thing comes out the same + * shade on either. + */ + +const STRIDE = DRAW.stride * 4; +const DEPTH = 'depth24plus'; + +const VERTEX_BUFFERS = [ + { arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] }, + { arrayStride: 12, attributes: [{ shaderLocation: 1, offset: 0, format: 'float32x3' }] }, + { arrayStride: 8, attributes: [{ shaderLocation: 2, offset: 0, format: 'float32x2' }] }, + { arrayStride: 12, attributes: [{ shaderLocation: 3, offset: 0, format: 'float32x3' }] }, +]; + +const BLENDS = { + none: undefined, + normal: { + color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' }, + alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' }, + }, + additive: { + color: { srcFactor: 'src-alpha', dstFactor: 'one', operation: 'add' }, + alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }, + }, +}; + +export async function createWebGPU(canvas, { clear, antialias, lost }) { + const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' }); + if (!adapter) throw new Error('no WebGPU adapter'); + const device = await adapter.requestDevice(); + + /** Compile everything and look at the result before the canvas is + * claimed: a canvas that has handed out a WebGPU context can never hand + * out a WebGL one, so this is the last moment to change our minds. */ + const modules = {}; + for (const [name, code] of Object.entries({ lit, basic, background, mipmap })) { + modules[name] = device.createShaderModule({ label: name, code: name === 'mipmap' ? code : `${common}\n${code}` }); + const info = await modules[name].getCompilationInfo(); + const errors = info.messages.filter((m) => m.type === 'error'); + if (errors.length) { + device.destroy(); + throw new Error(`${name}.wgsl: ${errors.map((m) => `${m.lineNum}:${m.linePos} ${m.message}`).join('; ')}`); + } + } + + const context = canvas.getContext('webgpu'); + if (!context) { + device.destroy(); + throw new Error('the canvas would not give a WebGPU context'); + } + const format = navigator.gpu.getPreferredCanvasFormat(); + context.configure({ device, format, alphaMode: 'opaque' }); + + device.addEventListener('uncapturederror', (event) => console.error('WebGPU:', event.error.message)); + device.lost.then((info) => { + if (info.reason === 'destroyed') return; + console.error('WebGPU device lost:', info.message); + lost?.(); + }); + + const samples = antialias ? 4 : 1; + const both = GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT; + const fragment = GPUShaderStage.FRAGMENT; + + const frameLayout = device.createBindGroupLayout({ + entries: [ + { binding: 0, visibility: both, buffer: { type: 'uniform' } }, + { binding: 1, visibility: fragment, texture: { sampleType: 'float' } }, + { binding: 2, visibility: fragment, sampler: { type: 'filtering' } }, + { binding: 3, visibility: fragment, texture: { sampleType: 'float' } }, + ], + }); + const drawLayout = device.createBindGroupLayout({ + entries: [{ binding: 0, visibility: both, buffer: { type: 'uniform', hasDynamicOffset: true, minBindingSize: STRIDE } }], + }); + const materialLayout = device.createBindGroupLayout({ + entries: [0, 1, 2].flatMap((i) => [ + { binding: i * 2, visibility: fragment, texture: { sampleType: 'float' } }, + { binding: i * 2 + 1, visibility: fragment, sampler: { type: 'filtering' } }, + ]), + }); + const layout = device.createPipelineLayout({ bindGroupLayouts: [frameLayout, drawLayout, materialLayout] }); + + const frameBuffer = device.createBuffer({ size: FRAME.size * 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + let drawBuffer = null; + let drawGroup = null; + + function ensureDraws(bytes) { + if (drawBuffer && drawBuffer.size >= bytes) return; + const size = Math.max(bytes, (drawBuffer?.size ?? 0) * 2, STRIDE * 256); + drawBuffer?.destroy(); + drawBuffer = device.createBuffer({ size, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + drawGroup = device.createBindGroup({ + layout: drawLayout, + entries: [{ binding: 0, resource: { buffer: drawBuffer, size: STRIDE } }], + }); + } + + const samplers = new Map(); + function sampler(wrapS = 'clamp', wrapT = 'clamp', anisotropy = 1, mipmapped = true) { + const key = `${wrapS}|${wrapT}|${anisotropy}|${mipmapped}`; + if (!samplers.has(key)) { + const address = (mode) => (mode === 'repeat' ? 'repeat' : 'clamp-to-edge'); + samplers.set(key, device.createSampler({ + addressModeU: address(wrapS), + addressModeV: address(wrapT), + magFilter: 'linear', + minFilter: 'linear', + mipmapFilter: mipmapped ? 'linear' : 'nearest', + maxAnisotropy: Math.min(Math.max(Math.floor(anisotropy), 1), 16), + })); + } + return samplers.get(key); + } + + function dataTexture(texFormat, width, height, data, bytesPerTexel) { + const tex = device.createTexture({ + size: [width, height], + format: texFormat, + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + device.queue.writeTexture({ texture: tex }, data, { bytesPerRow: width * bytesPerTexel }, [width, height]); + return tex; + } + + const white = dataTexture('rgba8unorm', 1, 1, new Uint8Array([255, 255, 255, 255]), 4); + const black = dataTexture('rgba16float', 1, 1, new Uint16Array(4), 8); + const dfg = dataTexture('rg16float', DFG_SIZE, DFG_SIZE, DFG, 4); + const clampLinear = sampler('clamp', 'clamp', 1, false); + const environments = new WeakMap(); + const frameGroups = new WeakMap(); + let noEnvironmentGroup = null; + + function frameGroup(env) { + const cached = env ? frameGroups.get(env) : noEnvironmentGroup; + if (cached) return cached; + let tex = black; + if (env) { + tex = dataTexture('rgba16float', env.width, env.height, env.data, 8); + environments.set(env, tex); + } + const group = device.createBindGroup({ + layout: frameLayout, + entries: [ + { binding: 0, resource: { buffer: frameBuffer } }, + { binding: 1, resource: tex.createView() }, + { binding: 2, resource: clampLinear }, + { binding: 3, resource: dfg.createView() }, + ], + }); + if (env) frameGroups.set(env, group); + else noEnvironmentGroup = group; + return group; + } + + /** Mipmaps, a level at a time, each drawn from the one above. */ + const mipPipelines = {}; + const mipSampler = sampler('clamp', 'clamp', 1, false); + function generateMipmaps(encoder, tex) { + const texFormat = tex.format; + mipPipelines[texFormat] ??= device.createRenderPipeline({ + layout: 'auto', + vertex: { module: modules.mipmap, entryPoint: 'vs' }, + fragment: { module: modules.mipmap, entryPoint: 'fs', targets: [{ format: texFormat }] }, + primitive: { topology: 'triangle-list' }, + }); + const pipeline = mipPipelines[texFormat]; + for (let level = 1; level < tex.mipLevelCount; level++) { + const group = device.createBindGroup({ + layout: pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: tex.createView({ baseMipLevel: level - 1, mipLevelCount: 1 }) }, + { binding: 1, resource: mipSampler }, + ], + }); + const pass = encoder.beginRenderPass({ + colorAttachments: [{ + view: tex.createView({ baseMipLevel: level, mipLevelCount: 1 }), + loadOp: 'clear', + storeOp: 'store', + clearValue: [0, 0, 0, 0], + }], + }); + pass.setPipeline(pipeline); + pass.setBindGroup(0, group); + pass.draw(3); + pass.end(); + } + } + + const textures = new WeakMap(); + function texture(encoder, source) { + if (!source?.image) return null; + let entry = textures.get(source); + const { width, height } = source.image; + if (entry && (entry.width !== width || entry.height !== height)) { + entry.tex.destroy(); + entry = null; + } + if (!entry) { + const tex = device.createTexture({ + size: [width, height], + format: source.srgb ? 'rgba8unorm-srgb' : 'rgba8unorm', + mipLevelCount: Math.floor(Math.log2(Math.max(width, height))) + 1, + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, + }); + entry = { tex, view: tex.createView(), width, height, version: -1 }; + textures.set(source, entry); + } + if (entry.version !== source.version) { + device.queue.copyExternalImageToTexture( + { source: source.image, flipY: false }, + { texture: entry.tex, premultipliedAlpha: false }, + [width, height], + ); + generateMipmaps(encoder, entry.tex); + entry.version = source.version; + } + return entry; + } + + const whiteView = white.createView(); + + /** A bind group for up to three textures, the missing ones white. */ + function textureGroup(encoder, given) { + const sources = [0, 1, 2].map((i) => given[i] ?? null); + const entries = sources.map((source) => texture(encoder, source)); + return { + entries, + group: device.createBindGroup({ + layout: materialLayout, + entries: entries.flatMap((entry, i) => [ + { binding: i * 2, resource: entry ? entry.view : whiteView }, + { binding: i * 2 + 1, resource: entry ? sampler(sources[i].wrapS, sources[i].wrapT, sources[i].anisotropy) : clampLinear }, + ]), + }), + }; + } + + /** Kept per material, and rebuilt only if one of its textures had to be + * made again at a new size. */ + const materialGroups = new WeakMap(); + function materialGroup(encoder, owner, sources) { + const cached = materialGroups.get(owner); + if (cached) { + const fresh = sources.map((source) => texture(encoder, source)); + if (fresh.every((entry, i) => entry === cached.entries[i])) return cached.group; + } + const made = textureGroup(encoder, sources); + materialGroups.set(owner, made); + return made.group; + } + + const geometries = new WeakMap(); + function release(event) { + const entry = geometries.get(event.target); + if (!entry) return; + for (const buffer of entry.buffers) buffer?.destroy(); + entry.index?.destroy(); + geometries.delete(event.target); + event.target.removeEventListener('dispose', release); + } + + function upload(data, usage) { + const size = Math.ceil(data.byteLength / 4) * 4; + const buffer = device.createBuffer({ size, usage: usage | GPUBufferUsage.COPY_DST }); + write(buffer, data); + return buffer; + } + + function write(buffer, data) { + if (data.byteLength % 4 === 0) { + device.queue.writeBuffer(buffer, 0, data); + } else { + const padded = new Uint8Array(Math.ceil(data.byteLength / 4) * 4); + padded.set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); + device.queue.writeBuffer(buffer, 0, padded); + } + } + + const defaults = new Map(); + /** A constant attribute for a geometry that does not carry one: no normal, + * uv at the origin, white vertex colour. Shared by every geometry of the + * same vertex count. */ + function constant(location, count) { + const key = `${location}:${count}`; + if (!defaults.has(key)) { + const size = location === 2 ? 2 : 3; + const data = new Float32Array(count * size); + if (location === 1) for (let i = 2; i < data.length; i += 3) data[i] = 1; + if (location === 3) data.fill(1); + defaults.set(key, upload(data, GPUBufferUsage.VERTEX)); + } + return defaults.get(key); + } + + function geometry(source) { + let entry = geometries.get(source); + const arrays = [source.position, source.normal, source.uv, source.color]; + if (entry && entry.version !== source.version) { + arrays.forEach((data, i) => { if (data && entry.own[i]) write(entry.buffers[i], data); }); + entry.version = source.version; + } + if (entry) return entry; + + const count = source.count; + const own = arrays.map((data) => Boolean(data)); + const buffers = arrays.map((data) => (data ? upload(data, GPUBufferUsage.VERTEX) : null)); + const bound = buffers.map((buffer, i) => buffer ?? constant(i, count)); + const index = source.index ? upload(source.index, GPUBufferUsage.INDEX) : null; + entry = { + buffers, + bound, + own, + index, + indexFormat: source.index instanceof Uint32Array ? 'uint32' : 'uint16', + version: source.version, + }; + geometries.set(source, entry); + source.addEventListener('dispose', release); + return entry; + } + + const pipelines = new Map(); + function pipeline(state) { + const key = stateKey(state); + if (pipelines.has(key)) return pipelines.get(key); + const module = modules[state.shader]; + const lines = state.topology === 'lines'; + const made = device.createRenderPipeline({ + label: key, + layout, + vertex: { module, entryPoint: 'vs', buffers: state.shader === 'background' ? [] : VERTEX_BUFFERS }, + fragment: { module, entryPoint: 'fs', targets: [{ format, blend: BLENDS[state.blending] }] }, + primitive: { + topology: lines ? 'line-list' : 'triangle-list', + cullMode: state.cull, + frontFace: 'ccw', + }, + depthStencil: { + format: DEPTH, + depthWriteEnabled: state.depthTest && state.depthWrite, + depthCompare: state.depthTest ? 'less-equal' : 'always', + depthBias: lines ? 0 : state.biasUnits, + depthBiasSlopeScale: lines ? 0 : state.biasFactor, + }, + multisample: { count: samples }, + }); + pipelines.set(key, made); + return made; + } + + let colorTarget = null; + let depthTarget = null; + function targets() { + const width = Math.max(1, canvas.width); + const height = Math.max(1, canvas.height); + if (depthTarget && depthTarget.width === width && depthTarget.height === height) return; + colorTarget?.destroy(); + depthTarget?.destroy(); + colorTarget = samples > 1 + ? device.createTexture({ size: [width, height], format, sampleCount: samples, usage: GPUTextureUsage.RENDER_ATTACHMENT }) + : null; + depthTarget = device.createTexture({ size: [width, height], format: DEPTH, sampleCount: samples, usage: GPUTextureUsage.RENDER_ATTACHMENT }); + } + + const backgroundState = { + shader: 'background', topology: 'triangles', cull: 'none', blending: 'none', + depthTest: false, depthWrite: false, biasFactor: 0, biasUnits: 0, + }; + + return { + name: 'webgpu', + zeroToOne: true, + + render(frame, draws, list, scene) { + targets(); + const encoder = device.createCommandEncoder(); + + device.queue.writeBuffer(frameBuffer, 0, frame); + const bytes = list.length * STRIDE; + ensureDraws(bytes); + if (bytes > 0) device.queue.writeBuffer(drawBuffer, 0, draws, 0, list.length * DRAW.stride); + + const env = frameGroup(scene.environment); + const backdrop = scene.background?.image ? materialGroup(encoder, scene.background, [scene.background]) : null; + const prepared = list.map((it) => ({ + pipeline: pipeline(stateOf(it.material, it.lines)), + group: materialGroup(encoder, it.material, [it.material.map, it.material.emissiveMap, it.material.bumpMap]), + geometry: geometry(it.node.geometry), + source: it.node.geometry, + offset: it.offset * 4, + })); + + const view = context.getCurrentTexture().createView(); + const pass = encoder.beginRenderPass({ + colorAttachments: [{ + view: colorTarget ? colorTarget.createView() : view, + resolveTarget: colorTarget ? view : undefined, + clearValue: { r: clear[0], g: clear[1], b: clear[2], a: 1 }, + loadOp: 'clear', + storeOp: colorTarget ? 'discard' : 'store', + }], + depthStencilAttachment: { + view: depthTarget.createView(), + depthClearValue: 1, + depthLoadOp: 'clear', + depthStoreOp: 'discard', + }, + }); + pass.setBindGroup(0, env); + + if (backdrop) { + pass.setPipeline(pipeline(backgroundState)); + pass.setBindGroup(1, drawGroup, [0]); + pass.setBindGroup(2, backdrop); + pass.draw(3); + } + + for (const it of prepared) { + pass.setPipeline(it.pipeline); + pass.setBindGroup(1, drawGroup, [it.offset]); + pass.setBindGroup(2, it.group); + const g = it.geometry; + for (let i = 0; i < 4; i++) pass.setVertexBuffer(i, g.bound[i]); + if (g.index) { + pass.setIndexBuffer(g.index, g.indexFormat); + pass.drawIndexed(it.source.index.length); + } else { + pass.draw(it.source.count); + } + } + + pass.end(); + device.queue.submit([encoder.finish()]); + }, + }; +} diff --git a/src/render/egg.js b/src/render/egg.js index e3ae4e0..8eccd15 100644 --- a/src/render/egg.js +++ b/src/render/egg.js @@ -1,7 +1,6 @@ -import * as THREE from 'three'; - import { EGG_HEIGHT } from '../core/shape.js'; import { PLAYER } from '../core/tuning.js'; +import { Group } from '../gpu/graph.js'; import { segments, wire } from './materials.js'; import { createShell } from './shell.js'; import { THEME } from './theme.js'; @@ -16,12 +15,12 @@ export const EGG_SCALE = PLAYER.height / EGG_HEIGHT; * green and the shell should admit it. */ export function createEgg() { - const egg = new THREE.Group(); + const egg = new Group(); egg.name = 'egg'; const shell = createShell(); - const body = new THREE.Group(); + const body = new Group(); body.name = 'body'; body.add(shell.mesh); diff --git a/src/render/environment.js b/src/render/environment.js index 99cce37..167333e 100644 --- a/src/render/environment.js +++ b/src/render/environment.js @@ -1,4 +1,5 @@ -import * as THREE from 'three'; +import { prefilterInBackground } from '../gpu/environment.js'; +import { DirectionalLight, Group, HemisphereLight } from '../gpu/graph.js'; /** * Marc's studio, verbatim from src/client/egg/environment.js — a warm sky, a @@ -6,7 +7,7 @@ import * as THREE from 'three'; * shell is lit by the room it was made in, because that is what makes it read * as ceramic instead of as a pale blob. */ -export function buildEnvironment(scene, renderer) { +export function buildEnvironment(scene) { try { const c = document.createElement('canvas'); c.width = 64; c.height = 32; @@ -17,13 +18,7 @@ export function buildEnvironment(scene, renderer) { ctx.fillStyle = g; ctx.fillRect(0, 0, 64, 32); ctx.fillStyle = 'rgba(255,247,232,0.95)'; ctx.beginPath(); ctx.ellipse(20, 6, 12, 5, 0, 0, Math.PI * 2); ctx.fill(); - const tex = new THREE.Texture(c); - tex.mapping = THREE.EquirectangularReflectionMapping; - tex.colorSpace = THREE.SRGBColorSpace; - tex.needsUpdate = true; - const pmrem = new THREE.PMREMGenerator(renderer); - scene.environment = pmrem.fromEquirectangular(tex).texture; - pmrem.dispose(); tex.dispose(); + scene.environment = prefilterInBackground(c); } catch { } } @@ -37,15 +32,15 @@ export function buildEnvironment(scene, renderer) { * metres as it is at the start line. */ export function buildLights(scene) { - const rig = new THREE.Group(); + const rig = new Group(); rig.name = 'studio'; - const hemi = new THREE.HemisphereLight(0xffffff, 0xd8d2c4, 1.0); + const hemi = new HemisphereLight(0xffffff, 0xd8d2c4, 1.0); - const key = new THREE.DirectionalLight(0xffffff, 2.2); + const key = new DirectionalLight(0xffffff, 2.2); key.position.set(4, 7, 5); - const fill = new THREE.DirectionalLight(0xfff4e6, 0.5); + const fill = new DirectionalLight(0xfff4e6, 0.5); fill.position.set(-5, 3, -4); rig.add(hemi, key, key.target, fill, fill.target); diff --git a/src/render/materials.js b/src/render/materials.js index ad3c85e..479fc1a 100644 --- a/src/render/materials.js +++ b/src/render/materials.js @@ -1,5 +1,6 @@ -import * as THREE from 'three'; - +import { Geometry } from '../gpu/geometry.js'; +import { Lines } from '../gpu/graph.js'; +import { BasicMaterial, LineMaterial } from '../gpu/material.js'; import { THEME } from './theme.js'; const lines = new Map(); @@ -8,11 +9,11 @@ const lines = new Map(); export function wire(color, opacity = 1) { const key = `${color}:${opacity}`; if (!lines.has(key)) { - lines.set(key, new THREE.LineBasicMaterial({ + lines.set(key, new LineMaterial({ color, transparent: opacity < 1, opacity, - blending: THREE.AdditiveBlending, + blending: 'additive', depthWrite: false, })); } @@ -21,7 +22,7 @@ export function wire(color, opacity = 1) { /** The black the wireframe sits on, so the far side of a thing stays hidden. */ export function fill(color = THEME.void, opacity = 1) { - return new THREE.MeshBasicMaterial({ + return new BasicMaterial({ color, transparent: opacity < 1, opacity, @@ -32,7 +33,5 @@ export function fill(color = THEME.void, opacity = 1) { } export function segments(positions, material) { - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - return new THREE.LineSegments(geometry, material); + return new Lines(new Geometry({ position: positions }), material); } diff --git a/src/render/props.js b/src/render/props.js index deb5bab..6ee6878 100644 --- a/src/render/props.js +++ b/src/render/props.js @@ -1,6 +1,6 @@ -import * as THREE from 'three'; - import { HAZARD, LANE_WIDTH } from '../core/tuning.js'; +import { octahedronEdges, plane } from '../gpu/geometry.js'; +import { Group, Lines, Mesh } from '../gpu/graph.js'; import { fill, segments, wire } from './materials.js'; import { THEME } from './theme.js'; @@ -25,7 +25,7 @@ function box(out, x0, x1, y0, y1, z0, z1) { * keep their spacing, and a run only ever has a dozen slabs in the air. */ export function createSlab(seg) { - const group = new THREE.Group(); + const group = new Group(); group.name = `slab-${seg.id}`; const w = seg.xMax - seg.xMin; @@ -45,7 +45,7 @@ export function createSlab(seg) { } if (grid.length) group.add(segments(new Float32Array(grid), wire(THEME.dim, 0.75))); - const deck = new THREE.Mesh(new THREE.PlaneGeometry(w, l), fill(0x021007, 0.92)); + const deck = new Mesh(plane(w, l), fill(0x021007, 0.92)); deck.rotation.x = -Math.PI / 2; deck.position.y = -0.01; deck.renderOrder = -1; @@ -55,26 +55,21 @@ export function createSlab(seg) { return group; } -/** Line materials are shared and stay; the deck's fill belongs to this slab. */ +/** Materials are plain data and need no freeing; the geometry is this slab's + * own, and its buffers go back to the GPU with it. */ export function disposeSlab(group) { - group.traverse((node) => { - node.geometry?.dispose(); - if (node.isMesh) node.material.dispose(); - }); + group.traverse((node) => node.geometry?.dispose()); } /** An agent: a cage with something turning inside it. */ export function createHazard() { - const group = new THREE.Group(); + const group = new Group(); const cage = []; box(cage, -HAZARD.halfWidth, HAZARD.halfWidth, 0, HAZARD.height, -HAZARD.halfDepth, HAZARD.halfDepth); group.add(segments(new Float32Array(cage), wire(THEME.agent, 0.9))); - const core = new THREE.LineSegments( - new THREE.EdgesGeometry(new THREE.OctahedronGeometry(0.34)), - wire(THEME.agent, 0.55), - ); + const core = new Lines(octahedronEdges(0.34), wire(THEME.agent, 0.55)); core.position.y = HAZARD.height / 2; group.add(core); @@ -87,10 +82,7 @@ export function createHazard() { /** A bit: the loose data the egg is out here collecting. */ export function createBit() { - const bit = new THREE.LineSegments( - new THREE.EdgesGeometry(new THREE.OctahedronGeometry(0.3, 0)), - wire(THEME.bit, 0.95), - ); + const bit = new Lines(octahedronEdges(0.3), wire(THEME.bit, 0.95)); bit.name = 'bit'; return bit; } diff --git a/src/render/rain.js b/src/render/rain.js index 93b3d18..45f7364 100644 --- a/src/render/rain.js +++ b/src/render/rain.js @@ -1,4 +1,4 @@ -import * as THREE from 'three'; +import { Texture } from '../gpu/texture.js'; const GLYPHS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノ0123456789ハヒフヘホabcdefgh'; const SIZE = 512; @@ -28,8 +28,7 @@ export function createRain() { ctx.font = `${Math.round(step * 0.92)}px ui-monospace, monospace`; ctx.textBaseline = 'top'; - const texture = new THREE.CanvasTexture(canvas); - texture.colorSpace = THREE.SRGBColorSpace; + const texture = new Texture(canvas); let carry = 0; diff --git a/src/render/rig.js b/src/render/rig.js index b655f8d..5536a7f 100644 --- a/src/render/rig.js +++ b/src/render/rig.js @@ -2,7 +2,7 @@ * Where the chase camera sits and what it looks at, as plain arithmetic. * * It lives apart from the rest of the renderer so the framing can be asserted - * without a WebGL context, and there is one thing worth asserting: the camera + * without a GPU, and there is one thing worth asserting: the camera * sits *behind* the egg and looks the way the egg runs, down +z. That mirrors * the view — world +x lands on the left of the screen — which is the whole * reason `laneX` descends with the lane index. diff --git a/src/render/scene.js b/src/render/scene.js index 5991dfa..c681fd5 100644 --- a/src/render/scene.js +++ b/src/render/scene.js @@ -1,5 +1,5 @@ -import * as THREE from 'three'; - +import { PerspectiveCamera, Scene } from '../gpu/graph.js'; +import { createRenderer } from '../gpu/renderer.js'; import { buildEnvironment, buildLights } from './environment.js'; import { createRain } from './rain.js'; import { THEME } from './theme.js'; @@ -10,20 +10,19 @@ import { THEME } from './theme.js'; * fill and would look the same in the dark. */ export function createScene(canvas) { - const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); - renderer.setClearColor(THEME.void, 1); + const renderer = createRenderer(canvas, { clearColor: THEME.void, antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); - const scene = new THREE.Scene(); - scene.fog = new THREE.Fog(THEME.void, 26, 108); + const scene = new Scene(); + scene.fog = { color: THEME.void, near: 26, far: 108 }; /** 52°, not the 64° this started on: a wide lens stretches whatever sits * away from the middle of the frame, and what sits there is the egg. */ - const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 260); + const camera = new PerspectiveCamera(52, 1, 0.1, 260); camera.position.set(0, 3.2, -8.4); const studio = buildLights(scene); - buildEnvironment(scene, renderer); + buildEnvironment(scene); const rain = createRain(); if (rain) { diff --git a/src/render/shell.js b/src/render/shell.js index 2467ff3..ca51156 100644 --- a/src/render/shell.js +++ b/src/render/shell.js @@ -1,6 +1,7 @@ -import * as THREE from 'three'; - import { shapeEgg } from '../core/shape.js'; +import { sphere } from '../gpu/geometry.js'; +import { Mesh } from '../gpu/graph.js'; +import { PhysicalMaterial } from '../gpu/material.js'; import { createShellSkin } from './skin.js'; /** @@ -12,15 +13,15 @@ import { createShellSkin } from './skin.js'; * point: the egg is the thing that does not belong here. */ export function createShell() { - const skin = createShellSkin(THREE); + const skin = createShellSkin(); - const geometry = new THREE.SphereGeometry(1, 128, 96); - shapeEgg(geometry.attributes.position.array); + const geometry = sphere(1, 128, 96); + shapeEgg(geometry.position); geometry.computeVertexNormals(); - const material = new THREE.MeshPhysicalMaterial({ + const material = new PhysicalMaterial({ name: 'eggshell', - color: new THREE.Color(skin.map ? '#ffffff' : '#f0e3cd'), + color: skin.map ? '#ffffff' : '#f0e3cd', map: skin.map, bumpMap: skin.bumpMap, bumpScale: 0.7, @@ -29,10 +30,10 @@ export function createShell() { clearcoat: 0.35, clearcoatRoughness: 0.6, sheen: 0.4, - sheenColor: new THREE.Color('#fff2dd'), + sheenColor: '#fff2dd', }); - const mesh = new THREE.Mesh(geometry, material); + const mesh = new Mesh(geometry, material); mesh.name = 'shell'; return { mesh, geometry, material }; diff --git a/src/render/skin.js b/src/render/skin.js index f406a78..e7423c7 100644 --- a/src/render/skin.js +++ b/src/render/skin.js @@ -1,3 +1,5 @@ +import { Texture } from '../gpu/texture.js'; + /** * The speckled cream skin, verbatim from Marc (src/client/egg/skin.js): * painted once onto a canvas, a colour map and a bump map off the same @@ -18,7 +20,7 @@ function canvas2d(width, height) { return ctx ? { el, ctx } : null; } -export function createShellSkin(THREE, { width = WIDTH, height = HEIGHT, random = Math.random } = {}) { +export function createShellSkin({ width = WIDTH, height = HEIGHT, random = Math.random } = {}) { const colour = canvas2d(width, height); const bump = canvas2d(width, height); if (!colour || !bump) return INERT; @@ -49,10 +51,8 @@ export function createShellSkin(THREE, { width = WIDTH, height = HEIGHT, random b.fill(); } - const map = new THREE.CanvasTexture(colour.el); - map.colorSpace = THREE.SRGBColorSpace; - map.anisotropy = 4; - const bumpMap = new THREE.CanvasTexture(bump.el); + const map = new Texture(colour.el, { anisotropy: 4 }); + const bumpMap = new Texture(bump.el, { srgb: false }); return { map, bumpMap }; } diff --git a/src/render/view.js b/src/render/view.js index a9e1f93..e42bf5a 100644 --- a/src/render/view.js +++ b/src/render/view.js @@ -1,7 +1,6 @@ -import * as THREE from 'three'; - import { approach, clamp, spring } from '../core/motion.js'; import { laneX, PLAYER } from '../core/tuning.js'; +import { Vec3 } from '../gpu/math.js'; import { createEgg, createShadow, EGG_SCALE } from './egg.js'; import { createBit, createHazard, createSlab, createUnderGrid, disposeSlab } from './props.js'; import { focus as aimAt, rigFor, seat } from './rig.js'; @@ -57,8 +56,8 @@ export function createView({ scene, camera, studio }) { const bits = createPool(scene, createBit); const squash = { p: 0, v: 0 }; - const target = new THREE.Vector3(); - const aim = new THREE.Vector3(); + const target = new Vec3(); + const aim = new Vec3(); let level = 0; let placed = false; diff --git a/test/gpu.test.js b/test/gpu.test.js new file mode 100644 index 0000000..5fd7dca --- /dev/null +++ b/test/gpu.test.js @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; + +import { display, linear, linearToSrgb, srgbToLinear } from '../src/gpu/color.js'; +import { prefilter, fromHalf, toHalf } from '../src/gpu/environment.js'; +import { createFrameBuilder, DRAW, FRAME } from '../src/gpu/frame.js'; +import { cylinder, octahedron, octahedronEdges, plane, sphere, torus } from '../src/gpu/geometry.js'; +import { Group, Lines, Mesh, PerspectiveCamera, Scene } from '../src/gpu/graph.js'; +import { BasicMaterial, LineMaterial, StandardMaterial } from '../src/gpu/material.js'; + +const shader = (name) => readFileSync(new URL(`../src/gpu/shaders/${name}`, import.meta.url), 'utf8'); + +/** Field names and sizes in floats, in declaration order, from a GLSL block + * or a WGSL struct — enough to lay it out, given only vec4 and mat4 in it. */ +function fields(source, pattern) { + const body = source.match(pattern)[1]; + const out = []; + for (const line of body.split('\n')) { + const glsl = line.match(/^\s*(vec4|mat4)\s+(\w+)(?:\[(\d+)\])?;/); + const wgsl = line.match(/^\s*(\w+):\s*(vec4f|mat4x4f|array),/); + if (glsl) out.push([glsl[2], (glsl[1] === 'mat4' ? 16 : 4) * Number(glsl[3] ?? 1)]); + else if (wgsl) out.push([wgsl[1], wgsl[2] === 'mat4x4f' ? 16 : 4 * Number(wgsl[3] ?? 1)]); + } + return out; +} + +function offsets(list) { + let at = 0; + return list.map(([, size]) => { + const here = at; + at += size; + return here; + }).concat(at); +} + +function surfaceNormal(geometry, triangle) { + const p = geometry.position; + const i = geometry.index ? [...geometry.index.slice(triangle * 3, triangle * 3 + 3)] : [0, 1, 2].map((k) => triangle * 3 + k); + const [a, b, c] = i.map((v) => [p[v * 3], p[v * 3 + 1], p[v * 3 + 2]]); + const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + const v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + return { centre: [0, 1, 2].map((k) => (a[k] + b[k] + c[k]) / 3), normal: [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]] }; +} + +/** A canvas stand-in the prefilter can read pixels from. */ +function solid(width, height, [r, g, b]) { + const data = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < data.length; i += 4) data.set([r, g, b, 255], i); + return { width, height, getContext: () => ({ getImageData: () => ({ width, height, data }) }) }; +} + +describe('the uniform layouts', () => { + /** + * The frame and draw blocks are written out three times — packed by + * frame.js, declared in GLSL and declared again in WGSL. A field added to + * one and not the others reads garbage on one backend and is fine on the + * other, which is the worst way for it to go wrong. + */ + it('agree between frame.js, the GLSL blocks and the WGSL structs', () => { + const glsl = shader('common.glsl'); + const wgsl = shader('common.wgsl'); + for (const [name, layout, glslBlock, wgslStruct] of [ + ['Frame', FRAME, /uniform Frame \{([\s\S]*?)\};/, /struct Frame \{([\s\S]*?)\n\}/], + ['Draw', DRAW, /uniform Draw \{([\s\S]*?)\};/, /struct Draw \{([\s\S]*?)\n\}/], + ]) { + const fromGlsl = offsets(fields(glsl, glslBlock)); + const fromWgsl = offsets(fields(wgsl, wgslStruct)); + assert.deepEqual(fromGlsl, fromWgsl, `${name}: GLSL and WGSL disagree`); + const packed = Object.entries(layout).filter(([key]) => key !== 'size' && key !== 'stride').map(([, at]) => at); + assert.deepEqual(fromGlsl.slice(0, -1), packed, `${name}: frame.js packs it differently`); + if (layout.size) assert.equal(fromGlsl.at(-1), layout.size, `${name}: wrong size`); + if (layout.stride) assert.ok(fromGlsl.at(-1) <= layout.stride, `${name}: overflows its 256-byte slot`); + } + }); +}); + +describe('colour', () => { + it('decodes theme hex to linear light and encodes it back', () => { + assert.deepEqual(linear(0xffffff), [1, 1, 1]); + assert.deepEqual(linear(0x000000), [0, 0, 0]); + for (const c of [0, 0.02, 0.2, 0.5, 0.9, 1]) assert.ok(Math.abs(linearToSrgb(srgbToLinear(c)) - c) < 1e-4); + assert.ok(Math.abs(linear('#808080')[0] - 0.2158605) < 1e-6); + const [r, g, b] = display(0x101a2e); + assert.ok(Math.abs(r - 0x10 / 255) < 1e-3 && Math.abs(g - 0x1a / 255) < 1e-3 && Math.abs(b - 0x2e / 255) < 1e-3); + }); +}); + +describe('the shapes', () => { + /** A face wound the wrong way is culled from outside and solid from + * within, so every generator is checked from the outside in. */ + it('winds every triangle of a closed shape to face outwards', () => { + for (const [name, g] of [['sphere', sphere(1, 16, 12)], ['cylinder', cylinder(1, 1, 2, 12)], ['cone', cylinder(0, 1, 2, 12)], ['octahedron', octahedron(1)]]) { + const triangles = g.index ? g.index.length / 3 : g.count / 3; + for (let t = 0; t < triangles; t++) { + const { centre, normal } = surfaceNormal(g, t); + const out = normal[0] * centre[0] + normal[1] * centre[1] + normal[2] * centre[2]; + if (Math.hypot(...normal) < 1e-9) continue; + assert.ok(out > 0, `${name} triangle ${t} faces in`); + } + } + }); + + it('lays a plane facing +z with the top of the texture at the top', () => { + const g = plane(2, 4); + assert.ok(surfaceNormal(g, 0).normal[2] > 0); + const top = [...g.uv].filter((_, i) => i % 2 === 1 && g.position[Math.floor(i / 2) * 3 + 1] > 0); + assert.ok(top.every((v) => v === 1)); + }); + + it('gives a sphere its seam and its poles, as the egg was shaped on', () => { + const g = sphere(1, 128, 96); + assert.equal(g.count, 129 * 97); + assert.equal(g.index.length, 128 * 95 * 2 * 3); + }); + + it('draws an octahedron cage as its twelve edges', () => { + const edges = octahedronEdges(0.3); + assert.equal(edges.count, 24); + for (let i = 0; i < edges.count; i += 2) { + const p = edges.position; + const length = Math.hypot(p[i * 3] - p[i * 3 + 3], p[i * 3 + 1] - p[i * 3 + 4], p[i * 3 + 2] - p[i * 3 + 5]); + assert.ok(Math.abs(length - 0.3 * Math.SQRT2) < 1e-6, 'an edge runs through the middle'); + } + }); + + it('works out normals that point away from a torus tube', () => { + const g = torus(1, 0.25, 6, 12); + for (let v = 0; v < g.count; v++) { + const [x, y] = [g.position[v * 3], g.position[v * 3 + 1]]; + const ring = [x / Math.hypot(x, y), y / Math.hypot(x, y), 0]; + const away = [x - ring[0], y - ring[1], g.position[v * 3 + 2]]; + const n = [g.normal[v * 3], g.normal[v * 3 + 1], g.normal[v * 3 + 2]]; + assert.ok(n[0] * away[0] + n[1] * away[1] + n[2] * away[2] > 0); + } + }); +}); + +describe('the environment', () => { + it('stores half floats the way the GPU reads them', () => { + for (const v of [0, 1, 0.5, 0.04, 1e-3, 65504]) assert.ok(Math.abs(fromHalf(toHalf(v)) - v) <= v * 1e-3); + assert.equal(toHalf(1), 0x3c00); + }); + + /** However hard a uniform world is blurred, it is the same uniform world — + * so every rung of a solid grey environment has to come out that grey. */ + it('keeps a uniform environment uniform at every roughness', () => { + const env = prefilter(solid(64, 32, [128, 128, 128])); + assert.equal(env.width, 336); + assert.equal(env.height, 64); + assert.equal(env.maxMip, 4); + const expected = srgbToLinear(128 / 255); + for (let lod = 0; lod < 7; lod++) { + for (let y = 0; y < 32; y++) { + for (let x = lod * 48; x < lod * 48 + 48; x++) { + const v = fromHalf(env.data[(y * env.width + x) * 4]); + assert.ok(Math.abs(v - expected) < 2e-3, `rung ${lod} drifted to ${v} at ${x},${y}`); + } + } + } + }); +}); + +describe('a frame', () => { + function stage() { + const scene = new Scene(); + const camera = new PerspectiveCamera(52, 16 / 9, 0.1, 100); + camera.position.set(0, 0, -10); + camera.lookAt({ x: 0, y: 0, z: 0 }); + return { scene, camera, builder: createFrameBuilder() }; + } + + it('draws what is solid first, then what is see-through from far to near', () => { + const { scene, camera, builder } = stage(); + const glass = new BasicMaterial({ transparent: true, opacity: 0.5 }); + const near = new Mesh(plane(), glass); + const far = new Mesh(plane(), glass); + near.position.z = -5; + far.position.z = 5; + const solidOne = new Mesh(plane(), new StandardMaterial()); + scene.add(near, far, solidOne); + const { list } = builder.build(scene, camera, { zeroToOne: false }); + assert.deepEqual(list.map((it) => it.node), [solidOne, far, near]); + }); + + it('leaves out anything hidden, or behind the camera', () => { + const { scene, camera, builder } = stage(); + const hidden = new Group(); + hidden.visible = false; + hidden.add(new Mesh(plane(), new BasicMaterial())); + const behind = new Mesh(plane(), new BasicMaterial()); + behind.position.z = -30; + const seen = new Lines(octahedronEdges(1), new LineMaterial()); + scene.add(hidden, behind, seen); + const { list } = builder.build(scene, camera, { zeroToOne: false }); + assert.deepEqual(list.map((it) => it.node), [seen]); + assert.equal(list[0].lines, true); + }); + + it('reads a material’s opacity and glow every frame, and a clone keeps its own', () => { + const { scene, camera, builder } = stage(); + const shared = new StandardMaterial({ emissive: 0xffffff, emissiveIntensity: 1 }); + const own = shared.clone(); + scene.add(new Mesh(plane(), shared), new Mesh(plane(), own)); + own.emissiveIntensity = 3; + const { list } = builder.build(scene, camera, { zeroToOne: false }); + const glow = list.map((it) => builder.draws[it.offset + DRAW.emissive]); + assert.deepEqual(glow.sort(), [1, 3]); + }); +}); diff --git a/test/rig.test.js b/test/rig.test.js index 68c1c4b..b95fbb0 100644 --- a/test/rig.test.js +++ b/test/rig.test.js @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import * as THREE from 'three'; - import { LANES, laneX } from '../src/core/tuning.js'; +import { PerspectiveCamera } from '../src/gpu/graph.js'; +import { Vec3 } from '../src/gpu/math.js'; import { focus, rigFor, seat, TALL, WIDE } from '../src/render/rig.js'; /** @@ -17,13 +17,12 @@ import { focus, rigFor, seat, TALL, WIDE } from '../src/render/rig.js'; function ndcX(lane, { aspect = 16 / 9, z = 0, ahead = 0 } = {}) { const player = { x: laneX(1), y: 0, z, lane: 1 }; const rig = rigFor(aspect); - const camera = new THREE.PerspectiveCamera(aspect < 1 ? 64 : 52, aspect, 0.1, 260); + const camera = new PerspectiveCamera(aspect < 1 ? 64 : 52, aspect, 0.1, 260); - camera.position.copy(seat(new THREE.Vector3(), player, 0, rig)); - camera.lookAt(focus(new THREE.Vector3(), player, 0, rig)); - camera.updateMatrixWorld(true); + camera.position.copy(seat(new Vec3(), player, 0, rig)); + camera.lookAt(focus(new Vec3(), player, 0, rig)); - return new THREE.Vector3(laneX(lane), 0.7, z + ahead).project(camera).x; + return new Vec3(laneX(lane), 0.7, z + ahead).project(camera).x; } describe('the chase camera', () => { @@ -49,8 +48,8 @@ describe('the chase camera', () => { it('sits behind the egg and looks past it, which is what does the mirroring', () => { for (const rig of [WIDE, TALL]) { const player = { x: 0, y: 0, z: 100 }; - assert.ok(seat(new THREE.Vector3(), player, 0, rig).z < player.z, 'the camera got in front'); - assert.ok(focus(new THREE.Vector3(), player, 0, rig).z > player.z, 'it is looking backwards'); + assert.ok(seat(new Vec3(), player, 0, rig).z < player.z, 'the camera got in front'); + assert.ok(focus(new Vec3(), player, 0, rig).z > player.z, 'it is looking backwards'); } });