@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.
Files changed (46) hide show
  1. package/CHANGELOG.json +36 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +2 -0
  4. package/dist/render/backend/webgl2/renderer.d.ts +16 -1
  5. package/dist/render/backend/webgl2/renderer.js +41 -2
  6. package/dist/render/backend/webgpu/renderer.d.ts +2 -0
  7. package/dist/render/backend/webgpu/renderer.js +9 -0
  8. package/dist/render/shaders/flat/areaLight.d.ts +1 -1
  9. package/dist/render/shaders/flat/areaLight.js +1 -1
  10. package/dist/render/shaders/flat/glassTint.d.ts +1 -1
  11. package/dist/render/shaders/flat/glassTint.js +1 -0
  12. package/dist/render/shaders/flat/index.d.ts +5 -0
  13. package/dist/render/shaders/flat/index.js +1 -1
  14. package/dist/render/shaders/flat/layerLooks.d.ts +16 -0
  15. package/dist/render/shaders/flat/layerLooks.js +116 -0
  16. package/dist/render/shaders/flat/layered.d.ts +6 -4
  17. package/dist/render/shaders/flat/layered.js +62 -13
  18. package/dist/render/shaders/flat/main.d.ts +1 -1
  19. package/dist/render/shaders/flat/main.js +7 -2
  20. package/dist/render/shaders/flat/models.js +1 -1
  21. package/dist/render/shaders/flat/preamble.js +6 -0
  22. package/dist/render/shaders/generated/flat.wgsl.d.ts +144 -16
  23. package/dist/render/shaders/generated/flat.wgsl.js +183 -55
  24. package/dist/render/surfaceLayerLooks.d.ts +80 -0
  25. package/dist/render/surfaceLayerLooks.js +83 -0
  26. package/dist/render/surfaceLayers.d.ts +47 -6
  27. package/dist/render/surfaceLayers.js +5 -1
  28. package/package.json +6 -6
  29. package/scripts/browser.d.mts +5 -0
  30. package/scripts/browser.mjs +68 -14
  31. package/scripts/look.d.mts +81 -0
  32. package/scripts/look.mjs +380 -0
  33. package/src/index.ts +2 -0
  34. package/src/render/backend/webgl2/renderer.ts +40 -2
  35. package/src/render/backend/webgpu/renderer.ts +8 -0
  36. package/src/render/shaders/flat/areaLight.ts +1 -1
  37. package/src/render/shaders/flat/glassTint.ts +3 -1
  38. package/src/render/shaders/flat/index.ts +6 -1
  39. package/src/render/shaders/flat/layerLooks.ts +116 -0
  40. package/src/render/shaders/flat/layered.ts +63 -13
  41. package/src/render/shaders/flat/main.ts +7 -2
  42. package/src/render/shaders/flat/models.ts +1 -1
  43. package/src/render/shaders/flat/preamble.ts +6 -0
  44. package/src/render/shaders/generated/flat.wgsl.ts +183 -55
  45. package/src/render/surfaceLayerLooks.ts +155 -0
  46. package/src/render/surfaceLayers.ts +52 -7
@@ -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
- /** Whether the lit shader with `feature` compiled in fits this GPU's fragment uniform vectors. */
4705
- private switchFits(feature: LitSwitch): boolean {
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 (vSpecular > 0.0 || metal > 0.0) {
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