@driftengine/core 4.11.1 → 4.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.json +36 -0
- package/README.md +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/render/backend/webgl2/renderer.d.ts +16 -1
- package/dist/render/backend/webgl2/renderer.js +41 -2
- package/dist/render/backend/webgpu/renderer.d.ts +2 -0
- package/dist/render/backend/webgpu/renderer.js +9 -0
- package/dist/render/shaders/flat/areaLight.d.ts +1 -1
- package/dist/render/shaders/flat/areaLight.js +1 -1
- package/dist/render/shaders/flat/glassTint.d.ts +1 -1
- package/dist/render/shaders/flat/glassTint.js +1 -0
- package/dist/render/shaders/flat/index.d.ts +5 -0
- package/dist/render/shaders/flat/index.js +1 -1
- package/dist/render/shaders/flat/layerLooks.d.ts +16 -0
- package/dist/render/shaders/flat/layerLooks.js +116 -0
- package/dist/render/shaders/flat/layered.d.ts +6 -4
- package/dist/render/shaders/flat/layered.js +62 -13
- package/dist/render/shaders/flat/main.d.ts +1 -1
- package/dist/render/shaders/flat/main.js +7 -2
- package/dist/render/shaders/flat/models.js +1 -1
- package/dist/render/shaders/flat/preamble.js +6 -0
- package/dist/render/shaders/generated/flat.wgsl.d.ts +144 -16
- package/dist/render/shaders/generated/flat.wgsl.js +183 -55
- package/dist/render/surfaceLayerLooks.d.ts +80 -0
- package/dist/render/surfaceLayerLooks.js +83 -0
- package/dist/render/surfaceLayers.d.ts +47 -6
- package/dist/render/surfaceLayers.js +5 -1
- package/package.json +6 -6
- package/scripts/browser.d.mts +5 -0
- package/scripts/browser.mjs +68 -14
- package/scripts/look.d.mts +81 -0
- package/scripts/look.mjs +380 -0
- package/src/index.ts +2 -0
- package/src/render/backend/webgl2/renderer.ts +40 -2
- package/src/render/backend/webgpu/renderer.ts +8 -0
- package/src/render/shaders/flat/areaLight.ts +1 -1
- package/src/render/shaders/flat/glassTint.ts +3 -1
- package/src/render/shaders/flat/index.ts +6 -1
- package/src/render/shaders/flat/layerLooks.ts +116 -0
- package/src/render/shaders/flat/layered.ts +63 -13
- package/src/render/shaders/flat/main.ts +7 -2
- package/src/render/shaders/flat/models.ts +1 -1
- package/src/render/shaders/flat/preamble.ts +6 -0
- package/src/render/shaders/generated/flat.wgsl.ts +183 -55
- package/src/render/surfaceLayerLooks.ts +155 -0
- package/src/render/surfaceLayers.ts +52 -7
package/scripts/look.mjs
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Look at a running game on both backends and say, in words and in exit codes, what was seen.
|
|
3
|
+
*
|
|
4
|
+
* **Written for whoever cannot see the screen, which is a coding agent first.** An agent can
|
|
5
|
+
* typecheck a game and run its tests and still have no idea whether anything drew: a renderer
|
|
6
|
+
* that throws in a frame callback, a camera inside the ground, a material with no light on it and
|
|
7
|
+
* an empty canvas all pass both. A person catches those by looking. This is the looking, done by
|
|
8
|
+
* the instrument this repository already trusts for its own captures — `browser.mjs`, which refuses
|
|
9
|
+
* a software rasteriser, and `cdp.mjs`, which reads the console from before the page loads — and
|
|
10
|
+
* reduced to the four questions an agent needs answered before it says a change works:
|
|
11
|
+
*
|
|
12
|
+
* 1. Did the page throw or print an error or a warning?
|
|
13
|
+
* 2. Did anything draw, or is the canvas one flat colour?
|
|
14
|
+
* 3. Is it moving, or did the loop stop?
|
|
15
|
+
* 4. Which backend drew it, and on which GPU?
|
|
16
|
+
*
|
|
17
|
+
* Each backend is photographed on its own, through `?backend=`, because the engine's own rule is
|
|
18
|
+
* that a picture looked at on one backend has not been looked at: a uniform one backend never
|
|
19
|
+
* uploads draws a plausible frame on that backend and a different one on the other.
|
|
20
|
+
*
|
|
21
|
+
* **What it gives up.** It sees one frame of a game after it boots, and nothing a player would
|
|
22
|
+
* have to click or press to reach; a page that waits for input photographs its waiting screen. It
|
|
23
|
+
* does not compare against a reference, because a game under construction has none — `frames.mjs`
|
|
24
|
+
* is the instrument for a before-and-after. And it refuses to look at all without a hardware GPU,
|
|
25
|
+
* which on a machine with none is a refusal rather than a picture: a frame from a software
|
|
26
|
+
* rasteriser reads exactly like evidence and is not.
|
|
27
|
+
*
|
|
28
|
+
* **What would make it wrong**: a game whose first frame is meant to be one colour, a title card
|
|
29
|
+
* on black. Then `nothing drew` is a false alarm, and the photograph says so at a glance.
|
|
30
|
+
*
|
|
31
|
+
* import { look, formatLook } from '@driftengine/core/scripts/look.mjs';
|
|
32
|
+
*
|
|
33
|
+
* const report = await look({ url: 'http://localhost:5173/' });
|
|
34
|
+
* console.log(formatLook(report));
|
|
35
|
+
* process.exitCode = report.ok ? 0 : 1;
|
|
36
|
+
*/
|
|
37
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
|
|
40
|
+
import { launch, requireHardwareGpu } from './browser.mjs';
|
|
41
|
+
import { connect } from './cdp.mjs';
|
|
42
|
+
import { luminance } from './frames.mjs';
|
|
43
|
+
import { decodePng, rgbaOf } from './png.mjs';
|
|
44
|
+
|
|
45
|
+
/** The two backends, in the order a consumer runs them: WebGPU where it can, WebGL2 beneath. */
|
|
46
|
+
export const LOOK_BACKENDS = Object.freeze(['webgpu', 'webgl2']);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* At or above this share of one colour, the canvas is called empty.
|
|
50
|
+
*
|
|
51
|
+
* Measured over the canvas alone, with the page's own interface hidden, so a readout over a blank
|
|
52
|
+
* canvas cannot carry it over the line. 99.5% leaves room for a few hundred pixels of a cursor or
|
|
53
|
+
* a dithered edge on a 1280 by 720 frame and for nothing a scene would draw on purpose.
|
|
54
|
+
*/
|
|
55
|
+
export const EMPTY_SHARE = 0.995;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The flags that reach the real GPU from a headless browser, by platform.
|
|
59
|
+
*
|
|
60
|
+
* Linux is the one measured: Vulkan through ANGLE, which is what `browser.mjs` defaults to and why,
|
|
61
|
+
* since plain headless there selects a software rasteriser and says nothing. macOS takes Metal and
|
|
62
|
+
* Windows takes Direct3D 11, each ANGLE's native path on that system, and neither has been run by
|
|
63
|
+
* this module yet; `requireHardwareGpu` is what says so if one is wrong, and `headless: false` is the
|
|
64
|
+
* answer it suggests. Any other platform gets `null`, and `look` refuses it by name.
|
|
65
|
+
*/
|
|
66
|
+
export function gpuFlagsFor(platform = process.platform) {
|
|
67
|
+
if (platform === 'linux') return ['--use-angle=vulkan', '--enable-features=Vulkan'];
|
|
68
|
+
if (platform === 'darwin') return ['--use-angle=metal'];
|
|
69
|
+
if (platform === 'win32') return ['--use-angle=d3d11'];
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The page's address with the backend forced and the boot badge declined.
|
|
75
|
+
*
|
|
76
|
+
* `set` rather than `append`: `URLSearchParams.get` answers with the *first* value, so a second
|
|
77
|
+
* `backend` behind one the caller already wrote would be ignored by the page, and the capture
|
|
78
|
+
* labelled WebGL2 would be whatever the caller asked for. The badge is declined because it holds
|
|
79
|
+
* the loop for three seconds after the first frame and covers the canvas while it does.
|
|
80
|
+
*/
|
|
81
|
+
export function lookUrl(url, backend) {
|
|
82
|
+
const next = new URL(url);
|
|
83
|
+
next.searchParams.set('backend', backend);
|
|
84
|
+
next.searchParams.set('splash', '0');
|
|
85
|
+
return next.toString();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What a frame is made of: its most common colour, how much of the frame that colour covers, and
|
|
90
|
+
* its mean luminance.
|
|
91
|
+
*
|
|
92
|
+
* Exact colours rather than buckets, because an empty canvas is exactly one colour — the clear
|
|
93
|
+
* colour — and anything a scene draws, lit and fogged, is a spread of them.
|
|
94
|
+
*/
|
|
95
|
+
export function frameStats(image) {
|
|
96
|
+
const { width, height, rgba } = image;
|
|
97
|
+
const counts = new Map();
|
|
98
|
+
let top = 0;
|
|
99
|
+
let topKey = 0;
|
|
100
|
+
let luma = 0;
|
|
101
|
+
for (let at = 0; at < rgba.length; at += 4) {
|
|
102
|
+
const key = (rgba[at] << 16) | (rgba[at + 1] << 8) | rgba[at + 2];
|
|
103
|
+
const count = (counts.get(key) ?? 0) + 1;
|
|
104
|
+
counts.set(key, count);
|
|
105
|
+
if (count > top) {
|
|
106
|
+
top = count;
|
|
107
|
+
topKey = key;
|
|
108
|
+
}
|
|
109
|
+
luma += luminance(rgba, at);
|
|
110
|
+
}
|
|
111
|
+
const pixels = width * height;
|
|
112
|
+
return {
|
|
113
|
+
width,
|
|
114
|
+
height,
|
|
115
|
+
dominant: `#${topKey.toString(16).padStart(6, '0')}`,
|
|
116
|
+
dominantShare: pixels === 0 ? 1 : top / pixels,
|
|
117
|
+
meanLuminance: pixels === 0 ? 0 : luma / pixels,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The share of pixels that differ between two frames of one size by more than `tolerance`. */
|
|
122
|
+
export function changedShare(a, b, tolerance = 2) {
|
|
123
|
+
if (a.width !== b.width || a.height !== b.height) return 1;
|
|
124
|
+
let changed = 0;
|
|
125
|
+
for (let at = 0; at < a.rgba.length; at += 4) {
|
|
126
|
+
if (
|
|
127
|
+
Math.abs(a.rgba[at] - b.rgba[at]) > tolerance ||
|
|
128
|
+
Math.abs(a.rgba[at + 1] - b.rgba[at + 1]) > tolerance ||
|
|
129
|
+
Math.abs(a.rgba[at + 2] - b.rgba[at + 2]) > tolerance
|
|
130
|
+
) {
|
|
131
|
+
changed += 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return changed / (a.width * a.height);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The console, sorted into what fails a look and what does not.
|
|
139
|
+
*
|
|
140
|
+
* By the level the browser reported rather than by matching words in the text, because a game is
|
|
141
|
+
* entitled to log "0 errors" and `cdp.mjs`'s own `complaints()` would count it. A warning fails as
|
|
142
|
+
* an error does: no console warnings at runtime is part of the engine's definition of done, and an
|
|
143
|
+
* agent told a warning is harmless learns to stop reading them.
|
|
144
|
+
*/
|
|
145
|
+
export function sortConsole(lines) {
|
|
146
|
+
const errors = [];
|
|
147
|
+
const warnings = [];
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
if (/^(error|exception|assert):/.test(line)) errors.push(line);
|
|
150
|
+
else if (/^(warning|warn):/.test(line)) warnings.push(line);
|
|
151
|
+
}
|
|
152
|
+
return { errors, warnings };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Whether the browser offers a WebGPU adapter to this page, asked only when a capture that asked
|
|
157
|
+
* for WebGPU drew with something else, so the report can say why.
|
|
158
|
+
*/
|
|
159
|
+
const ADAPTER = `(async () => {
|
|
160
|
+
if (!('gpu' in navigator)) return 'no navigator.gpu';
|
|
161
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
162
|
+
return adapter === null ? 'no adapter' : 'an adapter';
|
|
163
|
+
})()`;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Installed before the page's own scripts: notes every WebGPU and WebGL2 context a canvas asks for.
|
|
167
|
+
*
|
|
168
|
+
* **This is what says the game booted, and the obvious test does not.** A `<canvas>` has a drawing
|
|
169
|
+
* buffer of 300 by 150 from the moment it is parsed, so "a canvas with a size" is true before any
|
|
170
|
+
* renderer exists — the first version of this module asked exactly that, photographed a page still
|
|
171
|
+
* reading "starting…", and called it a pass. A context on a canvas in the document is the renderer
|
|
172
|
+
* itself, and its type is the backend that actually drew rather than the one the address asked
|
|
173
|
+
* for: `createRenderer` falls back to WebGL2 quietly by design, and hands the reason to the game.
|
|
174
|
+
*
|
|
175
|
+
* A context on a canvas that is not in the document at the moment of asking is left out, because
|
|
176
|
+
* that is a probe, not the game; one appended later is counted then.
|
|
177
|
+
*/
|
|
178
|
+
const WATCH = `(() => {
|
|
179
|
+
const seen = (window.__driftengineLook = { contexts: [] });
|
|
180
|
+
const getContext = HTMLCanvasElement.prototype.getContext;
|
|
181
|
+
HTMLCanvasElement.prototype.getContext = function (type, ...rest) {
|
|
182
|
+
const context = getContext.call(this, type, ...rest);
|
|
183
|
+
if (context !== null && (type === 'webgpu' || type === 'webgl2')) seen.contexts.push({ type, canvas: this });
|
|
184
|
+
return context;
|
|
185
|
+
};
|
|
186
|
+
})();`;
|
|
187
|
+
|
|
188
|
+
/** The backend of the last context asked for by a canvas that is in the document now, or null. */
|
|
189
|
+
const DRAWN_WITH = `(() => {
|
|
190
|
+
const found = (window.__driftengineLook?.contexts ?? []).filter((entry) => entry.canvas.isConnected).at(-1);
|
|
191
|
+
return found === undefined ? null : found.type;
|
|
192
|
+
})()`;
|
|
193
|
+
|
|
194
|
+
/** Hides everything on the page except its canvases, so a frame's statistics are the canvas's. */
|
|
195
|
+
const CANVAS_ONLY = `(() => {
|
|
196
|
+
const style = document.createElement('style');
|
|
197
|
+
style.id = 'driftengine-look';
|
|
198
|
+
style.textContent = '* { visibility: hidden !important; } canvas { visibility: visible !important; }';
|
|
199
|
+
document.head.append(style);
|
|
200
|
+
return true;
|
|
201
|
+
})()`;
|
|
202
|
+
const RESTORE = `(() => { document.getElementById('driftengine-look')?.remove(); return true; })()`;
|
|
203
|
+
|
|
204
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
205
|
+
|
|
206
|
+
async function photograph(page, file) {
|
|
207
|
+
await page.frames(2);
|
|
208
|
+
await page.screenshot(file);
|
|
209
|
+
return rgbaOf(decodePng(await readFile(file)));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Open the game once per backend, wait for it to boot, and photograph it.
|
|
214
|
+
*
|
|
215
|
+
* `headless: false` opens a real window instead, for a machine whose headless browser cannot reach
|
|
216
|
+
* its GPU; the photographs are the same, and the window closes when it is done.
|
|
217
|
+
*
|
|
218
|
+
* Writes into `out` a PNG of what a player sees per backend (`webgpu.png`, `webgl2.png`), the canvas
|
|
219
|
+
* alone beside each (`webgpu.canvas.png`), and `look.json`, the report this returns. Throws only
|
|
220
|
+
* when it cannot look at all — no browser, no hardware GPU, an unsupported platform — and the
|
|
221
|
+
* message says which. Everything it saw, however bad, comes back in the report instead, with `ok`
|
|
222
|
+
* false.
|
|
223
|
+
*
|
|
224
|
+
* `bootMs` bounds the wait for the renderer's context; `settleMs` is a pause after it for the first
|
|
225
|
+
* frames; `drawMs` bounds the wait after that for the canvas to show more than one colour, since a
|
|
226
|
+
* game that streams its world draws empty frames until it arrives; `moveMs` is the gap between the
|
|
227
|
+
* two frames compared to tell whether it moves.
|
|
228
|
+
*/
|
|
229
|
+
export async function look({
|
|
230
|
+
url,
|
|
231
|
+
backends = LOOK_BACKENDS,
|
|
232
|
+
out = '.driftengine/look',
|
|
233
|
+
width = 1280,
|
|
234
|
+
height = 720,
|
|
235
|
+
bootMs = 30_000,
|
|
236
|
+
settleMs = 1000,
|
|
237
|
+
drawMs = 10_000,
|
|
238
|
+
moveMs = 500,
|
|
239
|
+
flags = gpuFlagsFor(),
|
|
240
|
+
headless = true,
|
|
241
|
+
} = {}) {
|
|
242
|
+
if (typeof url !== 'string') throw new Error('look: pass the address of the running game as url');
|
|
243
|
+
if (flags === null) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`look: ${process.platform} is not supported. It runs on Windows, macOS and Linux; pass ` +
|
|
246
|
+
'`flags` to choose the browser flags for another system.',
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
await mkdir(out, { recursive: true });
|
|
250
|
+
|
|
251
|
+
const browser = await launch({ flags, headless });
|
|
252
|
+
const report = { url, gpu: '', captures: [], problems: [], ok: false };
|
|
253
|
+
try {
|
|
254
|
+
const client = await connect(browser.port);
|
|
255
|
+
try {
|
|
256
|
+
report.gpu = await requireHardwareGpu(client);
|
|
257
|
+
const timing = { bootMs, settleMs, drawMs, moveMs };
|
|
258
|
+
for (const backend of backends) {
|
|
259
|
+
report.captures.push(await lookAt(client, { url, backend, out, width, height, ...timing }));
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
client.close();
|
|
263
|
+
}
|
|
264
|
+
} finally {
|
|
265
|
+
await browser.close();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const capture of report.captures) {
|
|
269
|
+
for (const problem of capture.problems) report.problems.push(`${capture.backend}: ${problem}`);
|
|
270
|
+
}
|
|
271
|
+
report.ok = report.problems.length === 0;
|
|
272
|
+
await writeFile(path.join(out, 'look.json'), `${JSON.stringify(report, null, 2)}\n`);
|
|
273
|
+
return report;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function lookAt(client, options) {
|
|
277
|
+
const { url, backend, out, width, height, bootMs, settleMs, drawMs, moveMs } = options;
|
|
278
|
+
const address = lookUrl(url, backend);
|
|
279
|
+
const file = path.join(out, `${backend}.png`);
|
|
280
|
+
const canvasFile = path.join(out, `${backend}.canvas.png`);
|
|
281
|
+
const capture = {
|
|
282
|
+
backend,
|
|
283
|
+
url: address,
|
|
284
|
+
file,
|
|
285
|
+
drawnWith: null,
|
|
286
|
+
stats: null,
|
|
287
|
+
moved: null,
|
|
288
|
+
errors: [],
|
|
289
|
+
warnings: [],
|
|
290
|
+
logs: [],
|
|
291
|
+
problems: [],
|
|
292
|
+
};
|
|
293
|
+
const page = await client.page(address, width, height, { beforeLoad: WATCH });
|
|
294
|
+
try {
|
|
295
|
+
try {
|
|
296
|
+
await page.settled(`${DRAWN_WITH} !== null`, { timeoutMs: bootMs, settleMs });
|
|
297
|
+
capture.drawnWith = await page.eval(DRAWN_WITH);
|
|
298
|
+
} catch {
|
|
299
|
+
capture.problems.push(
|
|
300
|
+
`no canvas in the page asked for a WebGPU or WebGL2 context within ${bootMs} ms: the renderer never started, or the page threw before it did`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (capture.drawnWith !== null) {
|
|
304
|
+
if (capture.drawnWith !== backend) {
|
|
305
|
+
const adapter = backend === 'webgpu' ? await page.eval(ADAPTER) : null;
|
|
306
|
+
const why =
|
|
307
|
+
adapter === null || adapter === 'an adapter'
|
|
308
|
+
? 'the page chose its backend without reading ?backend='
|
|
309
|
+
: `the browser offered ${adapter}`;
|
|
310
|
+
capture.problems.push(
|
|
311
|
+
`asked for ${backend} and drew with ${capture.drawnWith}, so ${backend} was not looked at: ${why}`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
await page.eval(CANVAS_ONLY);
|
|
315
|
+
let first = await photograph(page, canvasFile);
|
|
316
|
+
const deadline = Date.now() + drawMs;
|
|
317
|
+
while (frameStats(first).dominantShare >= EMPTY_SHARE && Date.now() < deadline) {
|
|
318
|
+
await sleep(250);
|
|
319
|
+
first = await photograph(page, canvasFile);
|
|
320
|
+
}
|
|
321
|
+
await sleep(moveMs);
|
|
322
|
+
const second = await photograph(page, canvasFile);
|
|
323
|
+
await page.eval(RESTORE);
|
|
324
|
+
await photograph(page, file);
|
|
325
|
+
capture.stats = frameStats(second);
|
|
326
|
+
capture.moved = changedShare(first, second);
|
|
327
|
+
if (capture.stats.dominantShare >= EMPTY_SHARE) {
|
|
328
|
+
capture.problems.push(
|
|
329
|
+
`nothing drew: ${percent(capture.stats.dominantShare)} of the canvas is ${capture.stats.dominant}`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
capture.logs = [...page.logs];
|
|
335
|
+
await page.close();
|
|
336
|
+
}
|
|
337
|
+
const { errors, warnings } = sortConsole(capture.logs);
|
|
338
|
+
capture.errors = errors;
|
|
339
|
+
capture.warnings = warnings;
|
|
340
|
+
if (errors.length > 0) capture.problems.push(count(errors.length, 'console error'));
|
|
341
|
+
if (warnings.length > 0) capture.problems.push(count(warnings.length, 'console warning'));
|
|
342
|
+
return capture;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const percent = (share) => `${(share * 100).toFixed(1)}%`;
|
|
346
|
+
const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
|
|
347
|
+
|
|
348
|
+
/** The report as lines a person or an agent reads top to bottom: the verdict is the last line. */
|
|
349
|
+
export function formatLook(report) {
|
|
350
|
+
const lines = [`look: ${report.url}`, `gpu: ${report.gpu}`];
|
|
351
|
+
for (const capture of report.captures) {
|
|
352
|
+
lines.push(
|
|
353
|
+
'',
|
|
354
|
+
`${capture.backend}: ${capture.stats === null ? 'not photographed' : capture.file}`,
|
|
355
|
+
);
|
|
356
|
+
if (capture.drawnWith !== null) lines.push(` drew with: ${capture.drawnWith}`);
|
|
357
|
+
if (capture.stats !== null) {
|
|
358
|
+
lines.push(
|
|
359
|
+
` canvas: ${percent(capture.stats.dominantShare)} is its most common colour ${capture.stats.dominant}, mean luminance ${capture.stats.meanLuminance.toFixed(0)} of 255`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
if (capture.moved !== null) {
|
|
363
|
+
lines.push(
|
|
364
|
+
capture.moved > 0
|
|
365
|
+
? ` moving: ${percent(capture.moved)} of the canvas changed between two frames`
|
|
366
|
+
: ' still: no pixel changed between two frames, so nothing animates or the loop stopped',
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
for (const line of capture.errors) lines.push(` ${line}`);
|
|
370
|
+
for (const line of capture.warnings) lines.push(` ${line}`);
|
|
371
|
+
for (const problem of capture.problems) lines.push(` problem: ${problem}`);
|
|
372
|
+
}
|
|
373
|
+
lines.push(
|
|
374
|
+
'',
|
|
375
|
+
report.ok
|
|
376
|
+
? 'ok: open the PNGs above and check they show what the game should'
|
|
377
|
+
: `failed: ${count(report.problems.length, 'problem')}`,
|
|
378
|
+
);
|
|
379
|
+
return lines.join('\n');
|
|
380
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -666,6 +666,8 @@ export { MAX_CLUSTERED_LIGHTS } from './render/clusteredLights.ts';
|
|
|
666
666
|
export { SurfaceTexture } from './render/surfaceTexture.ts';
|
|
667
667
|
export type { SurfaceMaterial, SurfaceTextureOptions } from './render/surfaceTexture.ts';
|
|
668
668
|
export type { SurfaceProjection } from './render/surfaceProjection.ts';
|
|
669
|
+
export type { SurfaceLayers } from './render/surfaceLayers.ts';
|
|
670
|
+
export type { SurfaceLayerLook } from './render/surfaceLayerLooks.ts';
|
|
669
671
|
/*
|
|
670
672
|
* Compressed textures — BC, ETC2 and EAC, ASTC: the source `createSurfaceTexture` takes as blocks,
|
|
671
673
|
* the one question to ask before handing one over — whether this device takes its format
|
|
@@ -117,6 +117,7 @@ import {
|
|
|
117
117
|
packSurfaceLayers,
|
|
118
118
|
} from '../../surfaceLayers.ts';
|
|
119
119
|
import type { SurfaceLayers } from '../../surfaceLayers.ts';
|
|
120
|
+
import { LOOK_FLOATS, asksLayerLooks, packLayerLooks } from '../../surfaceLayerLooks.ts';
|
|
120
121
|
import type { SurfaceProjection } from '../../surfaceProjection.ts';
|
|
121
122
|
import { FILM_FRAG, FILM_VERT } from '../../shaders/film.ts';
|
|
122
123
|
import { SceneTarget } from '../../sceneTarget.ts';
|
|
@@ -1783,9 +1784,14 @@ export class WebGL2Renderer implements RendererApi {
|
|
|
1783
1784
|
private readonly transmission = new Float32Array([-1, -1, -1, 0]);
|
|
1784
1785
|
/** `uLayers`, the current material's layers packed. See `surfaceLayers.ts`. */
|
|
1785
1786
|
private readonly layerFloats = new Float32Array(LAYER_FLOATS);
|
|
1787
|
+
/** `uLayerLooks`, the current material's layer picks and looks packed. See `surfaceLayerLooks.ts`. */
|
|
1788
|
+
private readonly lookFloats = new Float32Array(LOOK_FLOATS);
|
|
1786
1789
|
/** Whether the lit stage has room for layers' two vectors, and which refusals were said. */
|
|
1787
1790
|
private layersRoom: boolean | null = null;
|
|
1788
1791
|
private warnedLayersRoom = false;
|
|
1792
|
+
/** Whether it has room for the layers' looks beside them, and whether the refusal was said. */
|
|
1793
|
+
private looksRoom: boolean | null = null;
|
|
1794
|
+
private warnedLooksRoom = false;
|
|
1789
1795
|
private warnedLayersModelMap = false;
|
|
1790
1796
|
private warnedLayersTriplanar = false;
|
|
1791
1797
|
private warnedOverlayRoom = false;
|
|
@@ -4398,6 +4404,9 @@ export class WebGL2Renderer implements RendererApi {
|
|
|
4398
4404
|
packSurfaceLayers(layers, this.layerFloats);
|
|
4399
4405
|
if (layers !== null) this.askLit('LAYERED');
|
|
4400
4406
|
gl.uniform4fv(u['uLayers'] ?? null, this.layerFloats);
|
|
4407
|
+
packLayerLooks(layers, this.lookFloats);
|
|
4408
|
+
if (this.looksFit(layers)) this.askLit('LAYER_LOOKS');
|
|
4409
|
+
gl.uniform4fv(u['uLayerLooks'] ?? null, this.lookFloats);
|
|
4401
4410
|
gl.uniform1i(u['uModelMap'] ?? null, MODEL_MAP_TEXTURE_UNIT);
|
|
4402
4411
|
packModel(
|
|
4403
4412
|
material?.model ?? null,
|
|
@@ -4691,6 +4700,28 @@ export class WebGL2Renderer implements RendererApi {
|
|
|
4691
4700
|
return null;
|
|
4692
4701
|
}
|
|
4693
4702
|
|
|
4703
|
+
/**
|
|
4704
|
+
* Whether a layered material's picks and looks are drawn: asked for, and room for their fifteen
|
|
4705
|
+
* vectors beside the layers' four. Refused, the layers are drawn reading layer `i` of their arrays
|
|
4706
|
+
* as they are, said once — which on a material sharing its arrays reads the wrong textures, so
|
|
4707
|
+
* the sentence names the way out.
|
|
4708
|
+
*/
|
|
4709
|
+
private looksFit(layers: SurfaceLayers<SurfaceTexture> | null): boolean {
|
|
4710
|
+
if (!asksLayerLooks(layers)) return false;
|
|
4711
|
+
this.looksRoom ??= this.switchFits('LAYER_LOOKS', 'LAYERED');
|
|
4712
|
+
if (this.looksRoom) return true;
|
|
4713
|
+
if (!this.warnedLooksRoom) {
|
|
4714
|
+
this.warnedLooksRoom = true;
|
|
4715
|
+
console.warn(
|
|
4716
|
+
"Renderer: a layered material's picks and looks are refused on this device. They take " +
|
|
4717
|
+
'fifteen fragment uniform vectors the lit shader does not have, so its layers read layer ' +
|
|
4718
|
+
'i of its arrays, untinted, at one repeat each. A lower `maxLights` makes the room, or ' +
|
|
4719
|
+
'WebGPU has it.',
|
|
4720
|
+
);
|
|
4721
|
+
}
|
|
4722
|
+
return false;
|
|
4723
|
+
}
|
|
4724
|
+
|
|
4694
4725
|
/** Says once that a layered material's triplanar projection is laid on the horizontal plane. */
|
|
4695
4726
|
private warnLayersTriplanar(): void {
|
|
4696
4727
|
if (this.warnedLayersTriplanar) return;
|
|
@@ -4701,9 +4732,13 @@ export class WebGL2Renderer implements RendererApi {
|
|
|
4701
4732
|
);
|
|
4702
4733
|
}
|
|
4703
4734
|
|
|
4704
|
-
/**
|
|
4705
|
-
|
|
4735
|
+
/**
|
|
4736
|
+
* Whether the lit shader with `feature` compiled in, and `beside` with it where a switch means
|
|
4737
|
+
* nothing alone, fits this GPU's fragment uniform vectors.
|
|
4738
|
+
*/
|
|
4739
|
+
private switchFits(feature: LitSwitch, beside?: LitSwitch): boolean {
|
|
4706
4740
|
const asIf: Partial<Record<LitSwitch, boolean>> = { [feature]: true };
|
|
4741
|
+
if (beside !== undefined) asIf[beside] = true;
|
|
4707
4742
|
return (
|
|
4708
4743
|
countUniformVectors(this.flatFragAt(this.lightBudget, asIf)) <= this.fragmentVectorLimit()
|
|
4709
4744
|
);
|
|
@@ -6960,6 +6995,7 @@ export class WebGL2Renderer implements RendererApi {
|
|
|
6960
6995
|
worldUvs: asIf.WORLD_UVS ?? this.litOn.WORLD_UVS,
|
|
6961
6996
|
movingSun: asIf.MOVING_SUN ?? this.litOn.MOVING_SUN,
|
|
6962
6997
|
layered: asIf.LAYERED ?? this.litOn.LAYERED,
|
|
6998
|
+
layerLooks: asIf.LAYER_LOOKS ?? this.litOn.LAYER_LOOKS,
|
|
6963
6999
|
maxLights: budget.maxLights,
|
|
6964
7000
|
maxAreaLights: budget.maxAreaLights,
|
|
6965
7001
|
});
|
|
@@ -9116,6 +9152,7 @@ export class WebGL2Renderer implements RendererApi {
|
|
|
9116
9152
|
resolveTransmission(null, this.transmission);
|
|
9117
9153
|
/* Nor layers: one layer until a material asks for more. */
|
|
9118
9154
|
this.layerFloats.fill(0);
|
|
9155
|
+
this.lookFloats.fill(0);
|
|
9119
9156
|
this.overlayMaps = null;
|
|
9120
9157
|
this.overlayTexture = null;
|
|
9121
9158
|
/* Nor an ambient of its own: the frame's, until a draw asks. See `setAmbientSH`. */
|
|
@@ -9290,6 +9327,7 @@ export class WebGL2Renderer implements RendererApi {
|
|
|
9290
9327
|
gl.uniform4fv(u['uOverlay'] ?? null, this.overlayFloats);
|
|
9291
9328
|
gl.uniform4fv(u['uWorldUv'] ?? null, this.projection);
|
|
9292
9329
|
gl.uniform4fv(u['uLayers'] ?? null, this.layerFloats);
|
|
9330
|
+
gl.uniform4fv(u['uLayerLooks'] ?? null, this.lookFloats);
|
|
9293
9331
|
/* Neither refracting nor glass, which is every draw that does not say otherwise. See
|
|
9294
9332
|
`bindSeeThrough` for the four numbers in the one vector. */
|
|
9295
9333
|
gl.uniform4f(u['uSeeThrough'] ?? null, 0, 0, 0, 0);
|
|
@@ -208,6 +208,7 @@ import {
|
|
|
208
208
|
packSurfaceLayers,
|
|
209
209
|
} from '../../surfaceLayers.ts';
|
|
210
210
|
import type { SurfaceLayers } from '../../surfaceLayers.ts';
|
|
211
|
+
import { LOOK_FLOATS, asksLayerLooks, packLayerLooks } from '../../surfaceLayerLooks.ts';
|
|
211
212
|
import {
|
|
212
213
|
frameReflectionsRefused,
|
|
213
214
|
resolveFrameReflections,
|
|
@@ -3015,6 +3016,8 @@ export class WebGPURenderer implements RendererApi {
|
|
|
3015
3016
|
private readonly projection = new Float32Array(PROJECTION_FLOATS);
|
|
3016
3017
|
/** `uLayers`, the material's layers packed by `packSurfaceLayers`. */
|
|
3017
3018
|
private readonly layerFloats = new Float32Array(LAYER_FLOATS);
|
|
3019
|
+
/** `uLayerLooks`, the material's layer picks and looks packed by `packLayerLooks`. */
|
|
3020
|
+
private readonly lookFloats = new Float32Array(LOOK_FLOATS);
|
|
3018
3021
|
/**
|
|
3019
3022
|
* `uGlassTint` as the material leaves it, its light from behind and that light's colour, which a
|
|
3020
3023
|
* pane overwrites for its own draw and `releaseSeeThrough` puts back. See `transmission.ts`.
|
|
@@ -3588,6 +3591,10 @@ export class WebGPURenderer implements RendererApi {
|
|
|
3588
3591
|
packSurfaceLayers(layers, this.layerFloats);
|
|
3589
3592
|
f.set(this.layerFloats, this.materialField('uLayers'));
|
|
3590
3593
|
if (layers !== null) this.enableLit('LAYERED');
|
|
3594
|
+
/* And which layer of shared arrays each reads, with its look: layerLooks.ts. */
|
|
3595
|
+
packLayerLooks(layers, this.lookFloats);
|
|
3596
|
+
f.set(this.lookFloats, this.materialField('uLayerLooks'));
|
|
3597
|
+
if (asksLayerLooks(layers)) this.enableLit('LAYER_LOOKS');
|
|
3591
3598
|
f.set(this.modelParams, this.materialField('uModelParams'));
|
|
3592
3599
|
});
|
|
3593
3600
|
/*
|
|
@@ -8976,6 +8983,7 @@ export class WebGPURenderer implements RendererApi {
|
|
|
8976
8983
|
f.set(this.transmission, at('uGlassTint'));
|
|
8977
8984
|
/* Nor layers: one layer until a material asks for more. */
|
|
8978
8985
|
f.fill(0, at('uLayers'), at('uLayers') + LAYER_FLOATS);
|
|
8986
|
+
f.fill(0, at('uLayerLooks'), at('uLayerLooks') + LOOK_FLOATS);
|
|
8979
8987
|
f[at('uCutout')] = 0;
|
|
8980
8988
|
f[at('uCutout') + 1] = 0;
|
|
8981
8989
|
this.cutoutResolveStaged = 'hard';
|
|
@@ -123,7 +123,7 @@ void areaLightAdd(
|
|
|
123
123
|
lampOpen += areaDiffuse;
|
|
124
124
|
lampShadowed += areaDiffuse * occl * glass;
|
|
125
125
|
|
|
126
|
-
if (
|
|
126
|
+
if (surfaceSpecular > 0.0 || metal > 0.0) {
|
|
127
127
|
vec3 c0 = centre - right * halfSize.x + up * halfSize.y;
|
|
128
128
|
vec3 c1 = centre + right * halfSize.x + up * halfSize.y;
|
|
129
129
|
vec3 c2 = centre + right * halfSize.x - up * halfSize.y;
|
|
@@ -39,7 +39,8 @@ export type LitSwitch =
|
|
|
39
39
|
| 'REFLECTION_MAPS'
|
|
40
40
|
| 'WORLD_UVS'
|
|
41
41
|
| 'MOVING_SUN'
|
|
42
|
-
| 'LAYERED'
|
|
42
|
+
| 'LAYERED'
|
|
43
|
+
| 'LAYER_LOOKS';
|
|
43
44
|
|
|
44
45
|
/** Every switch off: what a renderer starts from before its profile and its content say otherwise. */
|
|
45
46
|
export function noLitSwitches(): Record<LitSwitch, boolean> {
|
|
@@ -55,6 +56,7 @@ export function noLitSwitches(): Record<LitSwitch, boolean> {
|
|
|
55
56
|
WORLD_UVS: false,
|
|
56
57
|
MOVING_SUN: false,
|
|
57
58
|
LAYERED: false,
|
|
59
|
+
LAYER_LOOKS: false,
|
|
58
60
|
};
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -534,6 +534,11 @@ export interface FlatShaderOptions {
|
|
|
534
534
|
* True when absent. See \`layeredGlsl\`.
|
|
535
535
|
*/
|
|
536
536
|
readonly layered?: boolean;
|
|
537
|
+
/**
|
|
538
|
+
* Whether a layered material's layers are picked from shared arrays and given looks of their own,
|
|
539
|
+
* \`uLayerLooks\` and all. True when absent; nothing without \`layered\`. See \`layerLooksGlsl\`.
|
|
540
|
+
*/
|
|
541
|
+
readonly layerLooks?: boolean;
|
|
537
542
|
/**
|
|
538
543
|
* How many point lights this build declares room for. `MAX_POINT_LIGHTS` when absent.
|
|
539
544
|
*
|
|
@@ -686,7 +691,7 @@ export function flatFrag(options: FlatShaderOptions): string {
|
|
|
686
691
|
/* After the reflection maps, so its switch takes the next id; main calls it. worldUv.ts. */
|
|
687
692
|
worldUvGlsl(options.worldUvs ?? true),
|
|
688
693
|
/* After the projection, so its switch takes the next id; the normal map below calls it. */
|
|
689
|
-
layeredGlsl(options.layered ?? true),
|
|
694
|
+
layeredGlsl(options.layered ?? true, options.layerLooks ?? true),
|
|
690
695
|
/* Where each map is read, after both of the ways it can be read otherwise. See mapReads.ts. */
|
|
691
696
|
MAP_READS_GLSL,
|
|
692
697
|
/* After the frame and the projection it calls, unconditional for the same reason as the
|