@driftengine/core 4.11.0 → 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 (77) hide show
  1. package/CHANGELOG.json +72 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.js +1 -1
  5. package/dist/render/ambientOcclusionPass.d.ts +3 -2
  6. package/dist/render/ambientOcclusionPass.js +4 -2
  7. package/dist/render/backend/api.d.ts +21 -1
  8. package/dist/render/backend/webgl2/renderer.d.ts +61 -5
  9. package/dist/render/backend/webgl2/renderer.js +153 -21
  10. package/dist/render/backend/webgpu/renderer.d.ts +54 -1
  11. package/dist/render/backend/webgpu/renderer.js +246 -89
  12. package/dist/render/instances.d.ts +13 -1
  13. package/dist/render/instances.js +10 -0
  14. package/dist/render/occlusionFade.d.ts +21 -0
  15. package/dist/render/occlusionFade.js +27 -0
  16. package/dist/render/sceneTarget.d.ts +2 -0
  17. package/dist/render/sceneTarget.js +1 -1
  18. package/dist/render/shaders/ambientOcclusion.js +12 -1
  19. package/dist/render/shaders/flat/areaLight.d.ts +1 -1
  20. package/dist/render/shaders/flat/areaLight.js +1 -1
  21. package/dist/render/shaders/flat/glassTint.d.ts +1 -1
  22. package/dist/render/shaders/flat/glassTint.js +1 -0
  23. package/dist/render/shaders/flat/index.d.ts +5 -0
  24. package/dist/render/shaders/flat/index.js +10 -3
  25. package/dist/render/shaders/flat/layerLooks.d.ts +16 -0
  26. package/dist/render/shaders/flat/layerLooks.js +116 -0
  27. package/dist/render/shaders/flat/layered.d.ts +19 -9
  28. package/dist/render/shaders/flat/layered.js +169 -37
  29. package/dist/render/shaders/flat/main.d.ts +1 -1
  30. package/dist/render/shaders/flat/main.js +17 -5
  31. package/dist/render/shaders/flat/models.js +1 -1
  32. package/dist/render/shaders/flat/normalMap.d.ts +1 -1
  33. package/dist/render/shaders/flat/normalMap.js +11 -2
  34. package/dist/render/shaders/flat/preamble.js +6 -0
  35. package/dist/render/shaders/generated/ambientOcclusion.wgsl.d.ts +6 -1
  36. package/dist/render/shaders/generated/ambientOcclusion.wgsl.js +6 -1
  37. package/dist/render/shaders/generated/flat.wgsl.d.ts +176 -48
  38. package/dist/render/shaders/generated/flat.wgsl.js +215 -87
  39. package/dist/render/surfaceLayerLooks.d.ts +80 -0
  40. package/dist/render/surfaceLayerLooks.js +83 -0
  41. package/dist/render/surfaceLayers.d.ts +128 -19
  42. package/dist/render/surfaceLayers.js +73 -13
  43. package/dist/render/surfaceTexture.d.ts +13 -4
  44. package/dist/render/transmission.d.ts +21 -0
  45. package/dist/render/transmission.js +21 -0
  46. package/dist/ui/splash.d.ts +42 -2
  47. package/dist/ui/splash.js +78 -13
  48. package/package.json +6 -6
  49. package/scripts/browser.d.mts +5 -0
  50. package/scripts/browser.mjs +68 -14
  51. package/scripts/look.d.mts +81 -0
  52. package/scripts/look.mjs +380 -0
  53. package/src/index.ts +4 -0
  54. package/src/render/ambientOcclusionPass.ts +4 -1
  55. package/src/render/backend/api.ts +31 -0
  56. package/src/render/backend/webgl2/renderer.ts +170 -22
  57. package/src/render/backend/webgpu/renderer.ts +379 -139
  58. package/src/render/instances.ts +18 -1
  59. package/src/render/occlusionFade.ts +29 -0
  60. package/src/render/sceneTarget.ts +3 -0
  61. package/src/render/shaders/ambientOcclusion.ts +12 -1
  62. package/src/render/shaders/flat/areaLight.ts +1 -1
  63. package/src/render/shaders/flat/glassTint.ts +3 -1
  64. package/src/render/shaders/flat/index.ts +15 -3
  65. package/src/render/shaders/flat/layerLooks.ts +116 -0
  66. package/src/render/shaders/flat/layered.ts +170 -37
  67. package/src/render/shaders/flat/main.ts +17 -5
  68. package/src/render/shaders/flat/models.ts +1 -1
  69. package/src/render/shaders/flat/normalMap.ts +11 -2
  70. package/src/render/shaders/flat/preamble.ts +6 -0
  71. package/src/render/shaders/generated/ambientOcclusion.wgsl.ts +6 -1
  72. package/src/render/shaders/generated/flat.wgsl.ts +215 -87
  73. package/src/render/surfaceLayerLooks.ts +155 -0
  74. package/src/render/surfaceLayers.ts +187 -23
  75. package/src/render/surfaceTexture.ts +13 -4
  76. package/src/render/transmission.ts +27 -0
  77. package/src/ui/splash.ts +83 -14
@@ -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
@@ -481,6 +481,7 @@ export type {
481
481
  LineHandle,
482
482
  MeshHandle,
483
483
  MeshOptions,
484
+ PrepareOptions,
484
485
  RenderBackend,
485
486
  RendererApi,
486
487
  ParticleHandle,
@@ -665,6 +666,8 @@ export { MAX_CLUSTERED_LIGHTS } from './render/clusteredLights.ts';
665
666
  export { SurfaceTexture } from './render/surfaceTexture.ts';
666
667
  export type { SurfaceMaterial, SurfaceTextureOptions } from './render/surfaceTexture.ts';
667
668
  export type { SurfaceProjection } from './render/surfaceProjection.ts';
669
+ export type { SurfaceLayers } from './render/surfaceLayers.ts';
670
+ export type { SurfaceLayerLook } from './render/surfaceLayerLooks.ts';
668
671
  /*
669
672
  * Compressed textures — BC, ETC2 and EAC, ASTC: the source `createSurfaceTexture` takes as blocks,
670
673
  * the one question to ask before handing one over — whether this device takes its format
@@ -993,6 +996,7 @@ export {
993
996
  SPLASH_HARD_CAP_MS,
994
997
  SPLASH_MIN_MS,
995
998
  forcedSplash,
999
+ holdSplash,
996
1000
  mountSplash,
997
1001
  splashDecision,
998
1002
  splashWanted,
@@ -113,7 +113,8 @@ export class AmbientOcclusionPass {
113
113
  * by, or null if this pass could not run, which the caller must read as "no occlusion"
114
114
  * rather than as black.
115
115
  *
116
- * `invProjection` is column-major, as `gl-matrix` produces it.
116
+ * `invProjection` is column-major, as `gl-matrix` produces it. `fade` is `uFade`, where the
117
+ * occlusion fades out with distance (`occlusionFade.ts`).
117
118
  */
118
119
  run(
119
120
  depth: WebGLTexture,
@@ -122,6 +123,7 @@ export class AmbientOcclusionPass {
122
123
  radius: number,
123
124
  projScale: Float32Array,
124
125
  invProjection: Float32Array,
126
+ fade: Float32Array,
125
127
  ): WebGLTexture | null {
126
128
  if (!this.ensureSize(width, height)) return null;
127
129
  const { gl } = this;
@@ -142,6 +144,7 @@ export class AmbientOcclusionPass {
142
144
  gl.uniform1i(this.estimateUniforms['uDepth'] ?? null, 0);
143
145
  gl.uniform2fv(this.estimateUniforms['uProjScale'] ?? null, projScale);
144
146
  gl.uniform1f(this.estimateUniforms['uRadius'] ?? null, radius);
147
+ gl.uniform2fv(this.estimateUniforms['uFade'] ?? null, fade);
145
148
  gl.uniformMatrix4fv(this.estimateUniforms['uInvProjection'] ?? null, false, invProjection);
146
149
  gl.drawArrays(gl.TRIANGLES, 0, 3);
147
150
 
@@ -235,6 +235,15 @@ export interface SurfaceTextureHandle {
235
235
  * `WebGLBuffer` in a private field, so no WebGPU object can satisfy it however identical its
236
236
  * shape. It is not meant to be read; it is meant to be enforced.
237
237
  */
238
+ /** Which of a draw's pipelines `prepareMesh` and `prepareInstanced` compile beside the opaque ones. */
239
+ export interface PrepareOptions {
240
+ /**
241
+ * The blended ones too, for a draw made with `drawTranslucentMesh` or `drawTranslucentInstanced`.
242
+ * Off by default: an opaque surface would pay a compile it never draws with.
243
+ */
244
+ readonly translucent?: boolean;
245
+ }
246
+
238
247
  /** What a mesh needs to be told at creation that its geometry cannot say. */
239
248
  export interface MeshOptions {
240
249
  /**
@@ -312,6 +321,8 @@ export type RendererApi = Omit<
312
321
  | 'disposeSurfaceTexture'
313
322
  | 'setSurfaceTexture'
314
323
  | 'setMaterial'
324
+ | 'prepareMesh'
325
+ | 'prepareInstanced'
315
326
  | 'setSurfaceOverlay'
316
327
  | 'createClothBinding'
317
328
  | 'createClothParticles'
@@ -455,6 +466,26 @@ export type RendererApi = Omit<
455
466
  * setter each — and `setSurfaceTexture` is now a wrapper over it.
456
467
  */
457
468
  setMaterial(material: SurfaceMaterial<SurfaceTextureHandle> | null): void;
469
+ /**
470
+ * Compile, off the main thread, every pipeline a draw of `mesh` in `material` takes that making
471
+ * the mesh did not — a two-sided, cut-out or shaded-by-a-model variant, a material's lit switches,
472
+ * a skin's halves — so the first frame that draws it waits on none; `ready()` resolves once they
473
+ * have. For `pipelineCompile: 'skip'` above all, where a draw whose pipeline is new is left out
474
+ * until it lands: a figure brought on screen whole rather than without its face for a few frames.
475
+ * A rigged mesh is prepared as drawn with a palette, and one with targets both with weights and
476
+ * without. Call it between frames: it sets `material` to read it and leaves none set after.
477
+ */
478
+ prepareMesh(
479
+ mesh: MeshHandle,
480
+ material: SurfaceMaterial<SurfaceTextureHandle> | null,
481
+ options?: PrepareOptions,
482
+ ): void;
483
+ /** `prepareMesh` for an instanced batch drawn in `material`, its own pipelines being its own. */
484
+ prepareInstanced(
485
+ batch: InstancedHandle,
486
+ material: SurfaceMaterial<SurfaceTextureHandle> | null,
487
+ options?: PrepareOptions,
488
+ ): void;
458
489
 
459
490
  /**
460
491
  * A 3D text object: one string, drawn as instanced glyph cubes over the scene.