@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
package/dist/ui/splash.js CHANGED
@@ -80,8 +80,9 @@ export const SPLASH_FADE_MS = 420;
80
80
  /**
81
81
  * When the badge gives the screen to the game. Two conditions and a cap, each for a named failure.
82
82
  *
83
- * - **The game has painted.** Swapping before that shows the empty canvas the badge exists to
84
- * cover.
83
+ * - **The game has painted**, and every promise it asked the badge to wait for has settled
84
+ * (`holdSplash`). Swapping before that shows the empty canvas, or the half-loaded first screen,
85
+ * the badge exists to cover.
85
86
  * - **The minimum has passed.** A badge that vanishes the instant a fast machine paints is a
86
87
  * flicker, and the machines that boot fastest are the ones a developer tests on.
87
88
  * - **The cap overrides both**, and is checked first so that a minimum longer than the cap
@@ -99,6 +100,58 @@ export function splashDecision(elapsedMs, minMs, contentReady, capMs = SPLASH_HA
99
100
  return 'hold';
100
101
  return elapsedMs >= minMs ? 'swap' : 'hold';
101
102
  }
103
+ /**
104
+ * What a badge waits for besides the clock: the game's first frame, and every promise the game asked
105
+ * it to wait for (`holdSplash`), under the cap the game named, the longest of several, or the engine's.
106
+ *
107
+ * **A hold settles whichever way its promise does.** A load that failed must not pin the logo: the
108
+ * game is shown, broken and visible, which is the cap's own argument. Pure apart from the promises,
109
+ * so the rule is testable in Node.
110
+ */
111
+ export class SplashState {
112
+ /** Whether a frame of the game has reached the screen. */
113
+ painted = false;
114
+ pending = 0;
115
+ /** The longest cap a hold has named, or 0 for none. */
116
+ asked = 0;
117
+ /** The longest the badge may stay: the cap a hold named, the longest of several, or the engine's. */
118
+ get capMs() {
119
+ return this.asked > 0 ? this.asked : SPLASH_HARD_CAP_MS;
120
+ }
121
+ /** Wait for `until` too, under `capMs` where it names one; `settled` once it has. */
122
+ hold(until, capMs, settled) {
123
+ this.pending += 1;
124
+ if (capMs !== undefined && Number.isFinite(capMs) && capMs > this.asked)
125
+ this.asked = capMs;
126
+ const done = () => {
127
+ this.pending -= 1;
128
+ settled();
129
+ };
130
+ Promise.resolve(until).then(done, done);
131
+ }
132
+ /** Painted, with every hold settled: what `splashDecision` calls the content being ready. */
133
+ get ready() {
134
+ return this.painted && this.pending === 0;
135
+ }
136
+ }
137
+ /** The badge on the page, which `holdSplash` holds. One screen, so one badge: see `bootGate.ts`. */
138
+ let mounted = null;
139
+ /**
140
+ * Keep the engine's badge up until `until` settles, as well as until the game's first frame: a game
141
+ * whose first screen loads for longer than that frame — a stage, figures and their pictures on a
142
+ * first visit — is seen whole rather than loading. **`capMs` is the game's own ceiling** on the
143
+ * badge in place of the engine's twenty seconds, longer for a slow first visit or shorter, so a load
144
+ * that never settles is still shown in the end; the longest of several holds' wins, and the minimum
145
+ * hold is unchanged.
146
+ *
147
+ * **Frames run while the game loads behind the plate**, since a load may need them, and are held
148
+ * once it has settled, for whatever is left of the minimum, as they are after a first frame with no
149
+ * hold. Returns whether there was a badge to hold: none in a packaged build, whose shell shows its
150
+ * own until the first frame, nor where `splash: false` or `?splash=0` turned it off.
151
+ */
152
+ export function holdSplash(until, options = {}) {
153
+ return mounted?.holdUntil(until, options.capMs) ?? false;
154
+ }
102
155
  /**
103
156
  * The badge markup. One constant string, interpolated into nothing.
104
157
  *
@@ -228,17 +281,18 @@ export function mountSplash(options = {}) {
228
281
  the document and already styled; nothing here needs to be undone. */
229
282
  }
230
283
  }
231
- let painted = false;
284
+ const state = new SplashState();
232
285
  let leaving = false;
233
286
  let timer = null;
234
287
  /*
235
- * **Freeze the game the moment it has proved it can draw.**
288
+ * **Freeze the game the moment it has proved it can draw and has loaded.**
236
289
  *
237
- * `painted` is the first `endFrame`, and everything after it until the plate leaves is time the
238
- * game would otherwise spend running behind an opaque screen. `bootGate` is read by `startLoop`
290
+ * Ready is the first `endFrame` with every hold settled (`SplashState`), and everything after it
291
+ * until the plate leaves is time the game would otherwise spend running behind an opaque screen.
292
+ * Before it, frames run: a load the game is holding the badge for may need them. `bootGate` is read by `startLoop`
239
293
  * and named by neither of them; see its own comment for why the state is shared that way.
240
294
  */
241
- const openGate = holdFrames(() => painted && !leaving);
295
+ const openGate = holdFrames(() => state.ready && !leaving);
242
296
  const remove = () => {
243
297
  /* Leaving the top layer is what `hidePopover` is for. `remove()` alone takes the element out
244
298
  of the document, and an element removed while shown is not a state worth relying on. */
@@ -256,6 +310,8 @@ export function mountSplash(options = {}) {
256
310
  if (leaving)
257
311
  return;
258
312
  leaving = true;
313
+ if (mounted === badge)
314
+ mounted = null;
259
315
  /* Before the fade, not after it: the game resumes as the plate becomes transparent, so what
260
316
  shows through is a running frame rather than the still one it was frozen on. */
261
317
  openGate();
@@ -278,23 +334,32 @@ export function mountSplash(options = {}) {
278
334
  if (leaving)
279
335
  return;
280
336
  const elapsed = performance.now() - started;
281
- if (splashDecision(elapsed, minMs, painted) === 'swap') {
337
+ if (splashDecision(elapsed, minMs, state.ready, state.capMs) === 'swap') {
282
338
  release();
283
339
  return;
284
340
  }
285
341
  if (timer !== null)
286
342
  clearTimeout(timer);
287
- const next = painted ? minMs - elapsed : SPLASH_HARD_CAP_MS - elapsed;
343
+ const next = state.ready ? minMs - elapsed : state.capMs - elapsed;
288
344
  timer = setTimeout(settle, Math.max(0, next));
289
345
  };
290
- settle();
291
- return {
346
+ const badge = {
292
347
  present() {
293
- if (painted || leaving)
348
+ if (state.painted || leaving)
294
349
  return;
295
- painted = true;
350
+ state.painted = true;
296
351
  settle();
297
352
  },
298
353
  release,
354
+ holdUntil(until, capMs) {
355
+ if (leaving)
356
+ return false;
357
+ state.hold(until, capMs, settle);
358
+ settle();
359
+ return true;
360
+ },
299
361
  };
362
+ mounted = badge;
363
+ settle();
364
+ return badge;
300
365
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/core",
3
- "version": "4.11.0",
3
+ "version": "4.12.0",
4
4
  "description": "The runtime of DriftEngine, a WebGPU game engine with a WebGL2 fallback: renderer, loop, input, cameras, scene graph and physics",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,8 +29,8 @@
29
29
  ],
30
30
  "sideEffects": false,
31
31
  "dependencies": {
32
- "@driftengine/drft": "4.11.0",
33
- "@driftengine/physics": "4.11.0",
32
+ "@driftengine/drft": "4.12.0",
33
+ "@driftengine/physics": "4.12.0",
34
34
  "gl-matrix": "^3.4.3"
35
35
  },
36
36
  "author": "Drift Technologies",
@@ -67,8 +67,8 @@
67
67
  "prepack": "node ../../scripts/build.mjs @driftengine/core"
68
68
  },
69
69
  "devDependencies": {
70
- "@driftengine/nav": "4.11.0",
71
- "@driftengine/network": "4.11.0",
72
- "@driftengine/texture": "4.11.0"
70
+ "@driftengine/nav": "4.12.0",
71
+ "@driftengine/network": "4.12.0",
72
+ "@driftengine/texture": "4.12.0"
73
73
  }
74
74
  }
@@ -7,6 +7,11 @@ export interface LaunchedBrowser {
7
7
  close(): Promise<void>;
8
8
  }
9
9
 
10
+ /** Where a Chromium-based browser lives on a platform, in the order `chromePath` tries them. */
11
+ export function browserCandidates(
12
+ platform?: string,
13
+ env?: Readonly<Record<string, string | undefined>>,
14
+ ): string[];
10
15
  export function chromePath(): string;
11
16
  export function launch(options?: {
12
17
  flags?: readonly string[];
@@ -27,22 +27,64 @@
27
27
  * it, and it is what turns "leaks accumulate forever" into "at most one leak per still-running
28
28
  * process."
29
29
  */
30
- import { spawn } from 'node:child_process';
30
+ import { spawn, spawnSync } from 'node:child_process';
31
31
  import { once } from 'node:events';
32
32
  import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
33
33
  import { tmpdir } from 'node:os';
34
34
  import path from 'node:path';
35
35
  import { sleep } from './cdp.mjs';
36
36
 
37
- /** Where a Chrome or Chromium lives, in the order worth trying. `CHROME_PATH` wins. */
38
- const CANDIDATES = [
39
- '/opt/google/chrome/chrome',
40
- '/usr/bin/google-chrome',
41
- '/usr/bin/google-chrome-stable',
42
- '/usr/bin/chromium',
43
- '/usr/bin/chromium-browser',
44
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
45
- ];
37
+ /**
38
+ * Where a Chromium-based browser lives on each platform, in the order worth trying.
39
+ *
40
+ * **Edge is last on every list and the only thing on Windows that is always there.** It is
41
+ * Chromium, it speaks the same DevTools protocol, and it ships with Windows 10 and 11, so a person
42
+ * who never installed Chrome still has a browser this module can drive. Chrome comes first where it
43
+ * exists because it is what the engine's captures are measured on.
44
+ *
45
+ * Windows paths are built from the environment rather than written out, because `Program Files`
46
+ * is wherever that machine put it and a per-user install sits under `LOCALAPPDATA`; a Mac's
47
+ * per-user install sits under `~/Applications`. Nothing here has been run on Windows or macOS by
48
+ * this repository yet; the lists are the browsers' documented install locations.
49
+ */
50
+ export function browserCandidates(platform = process.platform, env = process.env) {
51
+ if (platform === 'win32') {
52
+ const roots = [env['PROGRAMFILES'], env['PROGRAMFILES(X86)'], env['LOCALAPPDATA']].filter(
53
+ (root) => typeof root === 'string' && root !== '',
54
+ );
55
+ return [
56
+ ...roots.map((root) =>
57
+ path.win32.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),
58
+ ),
59
+ ...roots.map((root) =>
60
+ path.win32.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
61
+ ),
62
+ ];
63
+ }
64
+ if (platform === 'darwin') {
65
+ /* `/Applications` for an install by an administrator, `~/Applications` for one without. */
66
+ const roots = [
67
+ '/Applications',
68
+ typeof env['HOME'] === 'string' && env['HOME'] !== ''
69
+ ? path.posix.join(env['HOME'], 'Applications')
70
+ : null,
71
+ ].filter((root) => root !== null);
72
+ const apps = [
73
+ 'Google Chrome.app/Contents/MacOS/Google Chrome',
74
+ 'Chromium.app/Contents/MacOS/Chromium',
75
+ 'Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
76
+ ];
77
+ return apps.flatMap((app) => roots.map((root) => path.posix.join(root, app)));
78
+ }
79
+ return [
80
+ '/opt/google/chrome/chrome',
81
+ '/usr/bin/google-chrome',
82
+ '/usr/bin/google-chrome-stable',
83
+ '/usr/bin/chromium',
84
+ '/usr/bin/chromium-browser',
85
+ '/usr/bin/microsoft-edge',
86
+ ];
87
+ }
46
88
 
47
89
  /**
48
90
  * What it takes to reach the real card from a headless process.
@@ -80,7 +122,7 @@ const PROFILE_PREFIX = 'driftengine-shots-';
80
122
  const OWNER_FILE = 'driftengine-owner.json';
81
123
 
82
124
  /**
83
- * Signal an entire process group, escalating if it does not go.
125
+ * Stop a browser and every process it started, escalating if it does not go.
84
126
  *
85
127
  * **Chrome is a tree, not a process.** It forks a zygote, a GPU process and one renderer per
86
128
  * page, and signalling the parent alone leaves the rest running with the port closed and the
@@ -89,9 +131,20 @@ const OWNER_FILE = 'driftengine-owner.json';
89
131
  *
90
132
  * SIGTERM first because a browser asked politely flushes and exits; SIGKILL after a grace period
91
133
  * because one that is wedged never will, and a wedged browser is exactly the case this exists for.
134
+ *
135
+ * **Windows has no process groups to signal**, so there `taskkill /T` walks the tree from the
136
+ * browser's own PID and `/F` ends it at once. Synchronous, because this also runs from an `exit`
137
+ * handler, where nothing asynchronous gets to finish.
92
138
  */
93
139
  function killTree(pid, graceMs = 2000) {
94
140
  if (!Number.isInteger(pid) || pid <= 0) return;
141
+ if (process.platform === 'win32') {
142
+ spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], {
143
+ stdio: 'ignore',
144
+ windowsHide: true,
145
+ });
146
+ return;
147
+ }
95
148
  try {
96
149
  process.kill(-pid, 'SIGTERM');
97
150
  } catch {
@@ -194,10 +247,11 @@ function defaultIsAlive(pid) {
194
247
  export function chromePath() {
195
248
  const asked = process.env['CHROME_PATH'];
196
249
  if (asked !== undefined && asked !== '') return asked;
197
- const found = CANDIDATES.find((candidate) => existsSync(candidate));
250
+ const candidates = browserCandidates();
251
+ const found = candidates.find((candidate) => existsSync(candidate));
198
252
  if (found === undefined) {
199
253
  throw new Error(
200
- `no Chrome or Chromium found. Tried:\n ${CANDIDATES.join('\n ')}\nSet CHROME_PATH to one.`,
254
+ `no Chrome, Chromium or Edge found. Tried:\n ${candidates.join('\n ')}\nSet CHROME_PATH to one.`,
201
255
  );
202
256
  }
203
257
  return found;
@@ -227,7 +281,7 @@ export async function launch({ flags = GPU_FLAGS, headless = true, timeoutMs = 2
227
281
  process and every renderer with one signal to the negative PID. `unref()` is deliberately
228
282
  not called: the parent must stay attached, or its own exit and signal handlers below never
229
283
  fire, and those are half of what closes the other three holes. */
230
- const child = spawn(binary, args, { stdio: 'ignore', detached: true });
284
+ const child = spawn(binary, args, { stdio: 'ignore', detached: true, windowsHide: true });
231
285
  writeFileSync(
232
286
  path.join(profile, OWNER_FILE),
233
287
  JSON.stringify({ ownerPid: process.pid, chromePid: child.pid }),
@@ -0,0 +1,81 @@
1
+ import type { RgbaImage } from './png.d.mts';
2
+
3
+ /** The backends `look` photographs by default, in the order a consumer runs them. */
4
+ export const LOOK_BACKENDS: readonly ['webgpu', 'webgl2'];
5
+
6
+ /** At or above this share of one colour, a canvas is called empty. */
7
+ export const EMPTY_SHARE: number;
8
+
9
+ /** What a frame is made of. */
10
+ export interface FrameStats {
11
+ readonly width: number;
12
+ readonly height: number;
13
+ /** The most common colour, as `#rrggbb`. */
14
+ readonly dominant: string;
15
+ /** The share of the frame that colour covers, 0 to 1. */
16
+ readonly dominantShare: number;
17
+ /** Rec. 709 luminance averaged over the frame, 0 to 255. */
18
+ readonly meanLuminance: number;
19
+ }
20
+
21
+ /** One backend's photograph and everything the page said while it was taken. */
22
+ export interface LookCapture {
23
+ readonly backend: string;
24
+ readonly url: string;
25
+ /** What a player sees, interface included. */
26
+ readonly file: string;
27
+ /**
28
+ * The backend that actually drew, read from the context the page's canvas asked for: `'webgpu'`,
29
+ * `'webgl2'`, or `null` when no canvas in the page asked for either.
30
+ */
31
+ readonly drawnWith: 'webgpu' | 'webgl2' | null;
32
+ /** Measured over the canvas alone; `null` when the page never booted. */
33
+ readonly stats: FrameStats | null;
34
+ /** The share of the canvas that changed between two frames; `null` when the page never booted. */
35
+ readonly moved: number | null;
36
+ readonly errors: readonly string[];
37
+ readonly warnings: readonly string[];
38
+ /** Every console line, `level: text`. */
39
+ readonly logs: readonly string[];
40
+ readonly problems: readonly string[];
41
+ }
42
+
43
+ export interface LookReport {
44
+ readonly url: string;
45
+ /** The renderer string of the GPU that drew, read from a WebGL2 context. */
46
+ readonly gpu: string;
47
+ readonly captures: readonly LookCapture[];
48
+ readonly problems: readonly string[];
49
+ /** True when no capture found a problem. */
50
+ readonly ok: boolean;
51
+ }
52
+
53
+ export interface LookOptions {
54
+ /** The address of the running game. */
55
+ readonly url: string;
56
+ readonly backends?: readonly string[];
57
+ /** Where the PNGs and `look.json` go. Defaults to `.driftengine/look`. */
58
+ readonly out?: string;
59
+ readonly width?: number;
60
+ readonly height?: number;
61
+ /** How long to wait for a canvas in the page to ask for a WebGPU or WebGL2 context. */
62
+ readonly bootMs?: number;
63
+ /** A pause after that for the first frames. */
64
+ readonly settleMs?: number;
65
+ /** How long to wait after that for the canvas to show more than one colour. */
66
+ readonly drawMs?: number;
67
+ /** The gap between the two frames compared to tell whether the game moves. */
68
+ readonly moveMs?: number;
69
+ /** Browser flags; defaults to `gpuFlagsFor()`. */
70
+ readonly flags?: readonly string[] | null;
71
+ /** `false` opens a real window, for a machine whose headless browser cannot reach its GPU. */
72
+ readonly headless?: boolean;
73
+ }
74
+
75
+ export function gpuFlagsFor(platform?: string): string[] | null;
76
+ export function lookUrl(url: string, backend: string): string;
77
+ export function frameStats(image: RgbaImage): FrameStats;
78
+ export function changedShare(a: RgbaImage, b: RgbaImage, tolerance?: number): number;
79
+ export function sortConsole(lines: readonly string[]): { errors: string[]; warnings: string[] };
80
+ export function look(options: LookOptions): Promise<LookReport>;
81
+ export function formatLook(report: LookReport): string;