@driftengine/core 4.11.1 → 4.13.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 (231) hide show
  1. package/CHANGELOG.json +102 -0
  2. package/README.md +18 -9
  3. package/dist/geometry/meshBuilder.d.ts +10 -0
  4. package/dist/geometry/meshBuilder.js +11 -2
  5. package/dist/index.d.ts +5 -2
  6. package/dist/index.js +3 -2
  7. package/dist/math/color.d.ts +25 -0
  8. package/dist/math/color.js +37 -0
  9. package/dist/render/backend/webgl2/renderer.d.ts +33 -5
  10. package/dist/render/backend/webgl2/renderer.js +123 -49
  11. package/dist/render/backend/webgl2/skinScatter.d.ts +3 -1
  12. package/dist/render/backend/webgl2/skinScatter.js +5 -1
  13. package/dist/render/backend/webgpu/boltPass.d.ts +1 -1
  14. package/dist/render/backend/webgpu/causticsPass.d.ts +1 -1
  15. package/dist/render/backend/webgpu/filmPass.d.ts +1 -1
  16. package/dist/render/backend/webgpu/flockPass.d.ts +1 -1
  17. package/dist/render/backend/webgpu/giFieldPass.d.ts +3 -1
  18. package/dist/render/backend/webgpu/gpuDrivenPass.d.ts +10 -1
  19. package/dist/render/backend/webgpu/gpuDrivenPass.js +28 -11
  20. package/dist/render/backend/webgpu/panelPass.d.ts +1 -1
  21. package/dist/render/backend/webgpu/postPass.d.ts +2 -2
  22. package/dist/render/backend/webgpu/renderer.d.ts +21 -8
  23. package/dist/render/backend/webgpu/renderer.js +103 -38
  24. package/dist/render/backend/webgpu/scatterPass.d.ts +1 -1
  25. package/dist/render/backend/webgpu/skinScatterPass.d.ts +5 -1
  26. package/dist/render/backend/webgpu/skinScatterPass.js +9 -3
  27. package/dist/render/backend/webgpu/skyPass.d.ts +11 -1
  28. package/dist/render/backend/webgpu/waterPass.d.ts +1 -1
  29. package/dist/render/backend/webgpu/windStreakPass.d.ts +1 -1
  30. package/dist/render/causticsRenderer.d.ts +3 -1
  31. package/dist/render/causticsRenderer.js +5 -1
  32. package/dist/render/decalPass.d.ts +3 -1
  33. package/dist/render/decalPass.js +4 -1
  34. package/dist/render/flockRenderer.d.ts +1 -1
  35. package/dist/render/flockRenderer.js +4 -1
  36. package/dist/render/globalMediumPass.d.ts +3 -1
  37. package/dist/render/globalMediumPass.js +5 -1
  38. package/dist/render/gradeColor.d.ts +21 -0
  39. package/dist/render/gradeColor.js +64 -0
  40. package/dist/render/lightMatrix.d.ts +13 -0
  41. package/dist/render/lightMatrix.js +17 -0
  42. package/dist/render/passGrade.d.ts +13 -0
  43. package/dist/render/passGrade.js +47 -0
  44. package/dist/render/plumeRenderer.d.ts +1 -1
  45. package/dist/render/plumeRenderer.js +4 -1
  46. package/dist/render/renderQuality.d.ts +57 -18
  47. package/dist/render/renderQuality.js +3 -3
  48. package/dist/render/sceneTarget.d.ts +2 -0
  49. package/dist/render/sceneTarget.js +1 -0
  50. package/dist/render/sdfTextRenderer.d.ts +1 -1
  51. package/dist/render/sdfTextRenderer.js +1 -1
  52. package/dist/render/shaders/arcane.d.ts +1 -1
  53. package/dist/render/shaders/arcane.js +5 -1
  54. package/dist/render/shaders/bolt.d.ts +1 -1
  55. package/dist/render/shaders/bolt.js +4 -1
  56. package/dist/render/shaders/caustics.d.ts +1 -1
  57. package/dist/render/shaders/caustics.js +4 -1
  58. package/dist/render/shaders/decalProject.js +12 -2
  59. package/dist/render/shaders/film.d.ts +1 -1
  60. package/dist/render/shaders/film.js +22 -9
  61. package/dist/render/shaders/fire.d.ts +1 -1
  62. package/dist/render/shaders/fire.js +4 -1
  63. package/dist/render/shaders/flat/areaLight.d.ts +1 -1
  64. package/dist/render/shaders/flat/areaLight.js +1 -1
  65. package/dist/render/shaders/flat/glassTint.d.ts +1 -1
  66. package/dist/render/shaders/flat/glassTint.js +1 -0
  67. package/dist/render/shaders/flat/index.d.ts +5 -0
  68. package/dist/render/shaders/flat/index.js +1 -1
  69. package/dist/render/shaders/flat/layerLooks.d.ts +16 -0
  70. package/dist/render/shaders/flat/layerLooks.js +108 -0
  71. package/dist/render/shaders/flat/layered.d.ts +6 -4
  72. package/dist/render/shaders/flat/layered.js +77 -13
  73. package/dist/render/shaders/flat/main.d.ts +1 -1
  74. package/dist/render/shaders/flat/main.js +7 -2
  75. package/dist/render/shaders/flat/models.js +1 -1
  76. package/dist/render/shaders/flat/preamble.js +6 -0
  77. package/dist/render/shaders/flock.d.ts +1 -1
  78. package/dist/render/shaders/flock.js +4 -1
  79. package/dist/render/shaders/generated/arcane.wgsl.d.ts +11 -1
  80. package/dist/render/shaders/generated/arcane.wgsl.js +11 -1
  81. package/dist/render/shaders/generated/bolt.wgsl.d.ts +12 -2
  82. package/dist/render/shaders/generated/bolt.wgsl.js +12 -2
  83. package/dist/render/shaders/generated/caustics.wgsl.d.ts +12 -2
  84. package/dist/render/shaders/generated/caustics.wgsl.js +12 -2
  85. package/dist/render/shaders/generated/decalProject.wgsl.d.ts +6 -1
  86. package/dist/render/shaders/generated/decalProject.wgsl.js +6 -1
  87. package/dist/render/shaders/generated/film.wgsl.d.ts +12 -2
  88. package/dist/render/shaders/generated/film.wgsl.js +12 -2
  89. package/dist/render/shaders/generated/fire.wgsl.d.ts +11 -1
  90. package/dist/render/shaders/generated/fire.wgsl.js +11 -1
  91. package/dist/render/shaders/generated/flat.wgsl.d.ts +144 -16
  92. package/dist/render/shaders/generated/flat.wgsl.js +183 -55
  93. package/dist/render/shaders/generated/flock.wgsl.d.ts +12 -2
  94. package/dist/render/shaders/generated/flock.wgsl.js +12 -2
  95. package/dist/render/shaders/generated/globalMedium.wgsl.d.ts +12 -2
  96. package/dist/render/shaders/generated/globalMedium.wgsl.js +12 -2
  97. package/dist/render/shaders/generated/lightVolume.wgsl.d.ts +36 -16
  98. package/dist/render/shaders/generated/lightVolume.wgsl.js +39 -19
  99. package/dist/render/shaders/generated/panel.wgsl.d.ts +12 -2
  100. package/dist/render/shaders/generated/panel.wgsl.js +12 -2
  101. package/dist/render/shaders/generated/rush.wgsl.d.ts +31 -26
  102. package/dist/render/shaders/generated/rush.wgsl.js +31 -26
  103. package/dist/render/shaders/generated/scatter.wgsl.d.ts +12 -2
  104. package/dist/render/shaders/generated/scatter.wgsl.js +12 -2
  105. package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +11 -1
  106. package/dist/render/shaders/generated/skinBlur.wgsl.js +11 -1
  107. package/dist/render/shaders/generated/sky.wgsl.d.ts +12 -2
  108. package/dist/render/shaders/generated/sky.wgsl.js +12 -2
  109. package/dist/render/shaders/generated/smoke.wgsl.d.ts +11 -1
  110. package/dist/render/shaders/generated/smoke.wgsl.js +11 -1
  111. package/dist/render/shaders/generated/text.wgsl.d.ts +11 -1
  112. package/dist/render/shaders/generated/text.wgsl.js +11 -1
  113. package/dist/render/shaders/generated/water.wgsl.d.ts +12 -2
  114. package/dist/render/shaders/generated/water.wgsl.js +12 -2
  115. package/dist/render/shaders/generated/windStreaks.wgsl.d.ts +12 -2
  116. package/dist/render/shaders/generated/windStreaks.wgsl.js +12 -2
  117. package/dist/render/shaders/globalMedium.js +14 -1
  118. package/dist/render/shaders/gpudriven/present.wgsl.d.ts +15 -6
  119. package/dist/render/shaders/gpudriven/present.wgsl.js +71 -8
  120. package/dist/render/shaders/lightVolume.js +4 -1
  121. package/dist/render/shaders/line.d.ts +1 -1
  122. package/dist/render/shaders/outputTransform.d.ts +8 -2
  123. package/dist/render/shaders/outputTransform.js +18 -11
  124. package/dist/render/shaders/panel.d.ts +1 -1
  125. package/dist/render/shaders/panel.js +5 -1
  126. package/dist/render/shaders/particle.d.ts +3 -3
  127. package/dist/render/shaders/rush.js +8 -0
  128. package/dist/render/shaders/scatter.d.ts +1 -1
  129. package/dist/render/shaders/scatter.js +4 -1
  130. package/dist/render/shaders/sdfText.d.ts +1 -1
  131. package/dist/render/shaders/skinBlur.js +5 -1
  132. package/dist/render/shaders/sky.d.ts +1 -1
  133. package/dist/render/shaders/sky.js +5 -1
  134. package/dist/render/shaders/smoke.d.ts +1 -1
  135. package/dist/render/shaders/smoke.js +4 -1
  136. package/dist/render/shaders/text.d.ts +1 -1
  137. package/dist/render/shaders/text.js +4 -1
  138. package/dist/render/shaders/water.d.ts +1 -1
  139. package/dist/render/shaders/water.js +12 -2
  140. package/dist/render/shaders/windStreaks.d.ts +1 -1
  141. package/dist/render/shaders/windStreaks.js +4 -1
  142. package/dist/render/surfaceLayerLooks.d.ts +92 -0
  143. package/dist/render/surfaceLayerLooks.js +102 -0
  144. package/dist/render/surfaceLayers.d.ts +47 -6
  145. package/dist/render/surfaceLayers.js +5 -1
  146. package/dist/render/textRenderer.d.ts +3 -1
  147. package/dist/render/textRenderer.js +7 -1
  148. package/dist/render/vertexDefaults.d.ts +1 -1
  149. package/dist/render/vertexDefaults.js +1 -1
  150. package/dist/render/waterRenderer.d.ts +1 -1
  151. package/dist/render/waterRenderer.js +4 -1
  152. package/dist/render/windStreakRenderer.d.ts +3 -1
  153. package/dist/render/windStreakRenderer.js +5 -1
  154. package/package.json +6 -6
  155. package/scripts/browser.d.mts +5 -0
  156. package/scripts/browser.mjs +68 -14
  157. package/scripts/look.d.mts +81 -0
  158. package/scripts/look.mjs +380 -0
  159. package/src/geometry/meshBuilder.ts +21 -2
  160. package/src/index.ts +13 -2
  161. package/src/math/color.ts +39 -0
  162. package/src/render/backend/webgl2/renderer.ts +174 -46
  163. package/src/render/backend/webgl2/skinScatter.ts +5 -0
  164. package/src/render/backend/webgpu/giFieldPass.ts +3 -1
  165. package/src/render/backend/webgpu/gpuDrivenPass.ts +29 -13
  166. package/src/render/backend/webgpu/renderer.ts +112 -37
  167. package/src/render/backend/webgpu/skinScatterPass.ts +17 -1
  168. package/src/render/causticsRenderer.ts +5 -0
  169. package/src/render/decalPass.ts +3 -0
  170. package/src/render/flockRenderer.ts +5 -0
  171. package/src/render/globalMediumPass.ts +10 -1
  172. package/src/render/gradeColor.ts +90 -0
  173. package/src/render/lightMatrix.ts +32 -0
  174. package/src/render/passGrade.ts +58 -0
  175. package/src/render/plumeRenderer.ts +5 -0
  176. package/src/render/renderQuality.ts +60 -21
  177. package/src/render/sceneTarget.ts +3 -0
  178. package/src/render/sdfTextRenderer.ts +1 -1
  179. package/src/render/shaders/arcane.ts +5 -1
  180. package/src/render/shaders/bolt.ts +4 -1
  181. package/src/render/shaders/caustics.ts +4 -1
  182. package/src/render/shaders/decalProject.ts +12 -2
  183. package/src/render/shaders/film.ts +22 -9
  184. package/src/render/shaders/fire.ts +4 -1
  185. package/src/render/shaders/flat/areaLight.ts +1 -1
  186. package/src/render/shaders/flat/glassTint.ts +3 -1
  187. package/src/render/shaders/flat/index.ts +6 -1
  188. package/src/render/shaders/flat/layerLooks.ts +108 -0
  189. package/src/render/shaders/flat/layered.ts +78 -13
  190. package/src/render/shaders/flat/main.ts +7 -2
  191. package/src/render/shaders/flat/models.ts +1 -1
  192. package/src/render/shaders/flat/preamble.ts +6 -0
  193. package/src/render/shaders/flock.ts +4 -1
  194. package/src/render/shaders/generated/arcane.wgsl.ts +11 -1
  195. package/src/render/shaders/generated/bolt.wgsl.ts +12 -2
  196. package/src/render/shaders/generated/caustics.wgsl.ts +12 -2
  197. package/src/render/shaders/generated/decalProject.wgsl.ts +6 -1
  198. package/src/render/shaders/generated/film.wgsl.ts +12 -2
  199. package/src/render/shaders/generated/fire.wgsl.ts +11 -1
  200. package/src/render/shaders/generated/flat.wgsl.ts +183 -55
  201. package/src/render/shaders/generated/flock.wgsl.ts +12 -2
  202. package/src/render/shaders/generated/globalMedium.wgsl.ts +12 -2
  203. package/src/render/shaders/generated/lightVolume.wgsl.ts +39 -19
  204. package/src/render/shaders/generated/panel.wgsl.ts +12 -2
  205. package/src/render/shaders/generated/rush.wgsl.ts +31 -26
  206. package/src/render/shaders/generated/scatter.wgsl.ts +12 -2
  207. package/src/render/shaders/generated/skinBlur.wgsl.ts +11 -1
  208. package/src/render/shaders/generated/sky.wgsl.ts +12 -2
  209. package/src/render/shaders/generated/smoke.wgsl.ts +11 -1
  210. package/src/render/shaders/generated/text.wgsl.ts +11 -1
  211. package/src/render/shaders/generated/water.wgsl.ts +12 -2
  212. package/src/render/shaders/generated/windStreaks.wgsl.ts +12 -2
  213. package/src/render/shaders/globalMedium.ts +14 -1
  214. package/src/render/shaders/gpudriven/present.wgsl.ts +72 -8
  215. package/src/render/shaders/lightVolume.ts +4 -1
  216. package/src/render/shaders/outputTransform.ts +19 -11
  217. package/src/render/shaders/panel.ts +5 -1
  218. package/src/render/shaders/rush.ts +8 -0
  219. package/src/render/shaders/scatter.ts +4 -1
  220. package/src/render/shaders/skinBlur.ts +5 -1
  221. package/src/render/shaders/sky.ts +5 -1
  222. package/src/render/shaders/smoke.ts +4 -1
  223. package/src/render/shaders/text.ts +4 -1
  224. package/src/render/shaders/water.ts +12 -2
  225. package/src/render/shaders/windStreaks.ts +4 -1
  226. package/src/render/surfaceLayerLooks.ts +186 -0
  227. package/src/render/surfaceLayers.ts +52 -7
  228. package/src/render/textRenderer.ts +6 -0
  229. package/src/render/vertexDefaults.ts +1 -1
  230. package/src/render/waterRenderer.ts +5 -0
  231. package/src/render/windStreakRenderer.ts +5 -0
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { GrowableF32, GrowableU32 } from './growable.ts';
2
2
  import type { MeshData } from '../render/mesh.ts';
3
3
  import type { Vec3 } from '../math/color.ts';
4
+ import { decodeSrgbInPlace } from '../math/color.ts';
4
5
 
5
6
  /**
6
7
  * The emissive colour of a vertex that has not been given one: negative, which no real
@@ -23,6 +24,16 @@ export interface MeshBuildOptions {
23
24
  * sample them. On, for a mesh that will be drawn with a `SurfaceTexture`.
24
25
  */
25
26
  planarUvs?: boolean;
27
+ /**
28
+ * The space the builder's colours were given in. `'linear'`, the default, is the renderer's own:
29
+ * light as a surface reflects it. `'srgb'` is a colour picked by eye, on a screen or in a paint
30
+ * program, and decodes every vertex's colour and emissive colour to linear here, once — as a
31
+ * texture of colours uploaded as sRGB is decoded — so a world built from hand-picked colours draws
32
+ * them as picked under the default `outputTransform`, where the same numbers taken as linear come
33
+ * out pale. What it gives up: nothing at the colour picked; a colour meant as light rather than as
34
+ * a look wants a linear number of its own.
35
+ */
36
+ colorSpace?: 'linear' | 'srgb';
26
37
  }
27
38
 
28
39
  /**
@@ -1083,14 +1094,22 @@ export class MeshBuilder {
1083
1094
  /* Same promise as `specular`: a mesh nothing bound uploads no skinning buffers, and
1084
1095
  `MeshData` requires both arrays or neither. */
1085
1096
  const skinned = this.anyJoint;
1097
+ /* Colours picked by eye, decoded once here: see `MeshBuildOptions.colorSpace`. */
1098
+ const picked = options.colorSpace === 'srgb';
1086
1099
  return {
1087
1100
  positions: this.positions.toTyped(),
1088
1101
  normals: this.normals.toTyped(),
1089
- colors: this.colors.toTyped(),
1102
+ colors: picked ? decodeSrgbInPlace(this.colors.toTyped()) : this.colors.toTyped(),
1090
1103
  emissive: this.emissive.toTyped(),
1091
1104
  ...(shines ? { specular: this.specular.toTyped() } : {}),
1092
1105
  ...(textured ? { relief: this.relief.toTyped() } : {}),
1093
- ...(tinted ? { emissiveColor: this.emissiveColor.toTyped() } : {}),
1106
+ ...(tinted
1107
+ ? {
1108
+ emissiveColor: picked
1109
+ ? decodeSrgbInPlace(this.emissiveColor.toTyped())
1110
+ : this.emissiveColor.toTyped(),
1111
+ }
1112
+ : {}),
1094
1113
  ...(rough ? { roughness: this.roughness.toTyped() } : {}),
1095
1114
  ...(mineral ? { grain: this.grain.toTyped() } : {}),
1096
1115
  ...(skinned ? { joints: this.expandedJoints(), weights: this.expandedWeights() } : {}),
package/src/index.ts CHANGED
@@ -96,7 +96,15 @@ export {
96
96
  wrapAngle,
97
97
  lerpAngle,
98
98
  } from './math/scalar.ts';
99
- export { hslToRgb, mixColor, mixColorInto, scaleColor } from './math/color.ts';
99
+ export {
100
+ decodeSrgbInPlace,
101
+ hslToRgb,
102
+ mixColor,
103
+ mixColorInto,
104
+ scaleColor,
105
+ srgbColor,
106
+ srgbToLinear,
107
+ } from './math/color.ts';
100
108
  export type { Vec3 } from './math/color.ts';
101
109
 
102
110
  export { Camera } from './render/camera.ts';
@@ -666,6 +674,8 @@ export { MAX_CLUSTERED_LIGHTS } from './render/clusteredLights.ts';
666
674
  export { SurfaceTexture } from './render/surfaceTexture.ts';
667
675
  export type { SurfaceMaterial, SurfaceTextureOptions } from './render/surfaceTexture.ts';
668
676
  export type { SurfaceProjection } from './render/surfaceProjection.ts';
677
+ export type { SurfaceLayers } from './render/surfaceLayers.ts';
678
+ export type { SurfaceLayerLook } from './render/surfaceLayerLooks.ts';
669
679
  /*
670
680
  * Compressed textures — BC, ETC2 and EAC, ASTC: the source `createSurfaceTexture` takes as blocks,
671
681
  * the one question to ask before handing one over — whether this device takes its format
@@ -705,7 +715,7 @@ export type { LightGrid } from './render/lightGrid.ts';
705
715
  export type { PointLightBuffer, PointLightSource } from './render/pointLightSelection.ts';
706
716
  export { createFlame, flameFrequencyHz, updateFlame } from './render/flameLight.ts';
707
717
  export type { Flame, FlameOptions } from './render/flameLight.ts';
708
- export { computeLightMatrix } from './render/lightMatrix.ts';
718
+ export { computeLightMatrix, computeLightMatrixForBounds } from './render/lightMatrix.ts';
709
719
  export { PlumeRenderer } from './render/plumeRenderer.ts';
710
720
  export { createEnvironment } from './render/backend/webgl2/renderer.ts';
711
721
  export type { InsetRect } from './render/backend/webgl2/renderer.ts';
@@ -748,6 +758,7 @@ export type { ShellStation, ShellFillOptions } from './geometry/shellFill.ts';
748
758
  * this line the only way for it to comply was to copy, which is what the rule forbids.
749
759
  */
750
760
  export { OUTPUT_TRANSFORM_GLSL } from './render/shaders/outputTransform.ts';
761
+ export { interfaceGrade } from './render/passGrade.ts';
751
762
  export { PLUME_VERT, FIRE_FRAG } from './render/shaders/fire.ts';
752
763
  export { ARCANE_FRAG } from './render/shaders/arcane.ts';
753
764
  export { SMOKE_FRAG } from './render/shaders/smoke.ts';
package/src/math/color.ts CHANGED
@@ -35,3 +35,42 @@ export function mixColorInto(out: Vec3, a: Vec3, b: Vec3, t: number): void {
35
35
  export function scaleColor(color: Vec3, factor: number): Vec3 {
36
36
  return [color[0] * factor, color[1] * factor, color[2] * factor];
37
37
  }
38
+
39
+ /**
40
+ * A colour picked by eye — on a screen, in a paint program, as a CSS colour — as the linear value
41
+ * the renderer lights, blends, fogs and grades: each channel through the sRGB curve, which the
42
+ * default `outputTransform`, `'srgb'`, applies the other way at the end, so a colour drawn as it is,
43
+ * a sky, a clear or a surface lit at exactly 1, comes out as the colour picked.
44
+ *
45
+ * **Why a colour needs it**: everything the renderer draws is linear and encoded at the end, so a
46
+ * colour typed as it looks comes out too light — a brown picked as `(0.55, 0.3, 0.15)` draws as
47
+ * tan. **What it gives up**: nothing at the colour picked. **What would make it wrong** is a number
48
+ * that is an amount rather than a look: a light's intensity, a multiplier, a strength, which are
49
+ * linear already. Allocates, so it is for building a world rather than for a frame.
50
+ */
51
+ export function srgbColor(r: number, g: number, b: number): Vec3 {
52
+ return [srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)];
53
+ }
54
+
55
+ /**
56
+ * Every value of `values` decoded from sRGB in place, but a negative one, which is a sentinel and
57
+ * not a colour (`MeshBuilder`'s emissive colour left to the albedo). For whole arrays of colours
58
+ * picked by eye; see `MeshBuildOptions.colorSpace`.
59
+ */
60
+ export function decodeSrgbInPlace(values: Float32Array): Float32Array {
61
+ for (let i = 0; i < values.length; i++) {
62
+ const value = values[i] as number;
63
+ if (value >= 0) values[i] = srgbToLinear(value);
64
+ }
65
+ return values;
66
+ }
67
+
68
+ /**
69
+ * One channel, 0 to 1, through the sRGB decode: the straight segment below 0.04045. For a colour
70
+ * that arrives as numbers per channel rather than as a triple, a packed hex token being the usual
71
+ * one; allocates nothing.
72
+ */
73
+ export function srgbToLinear(value: number): number {
74
+ // determinism: build-time — a colour for drawing, which no tick reads
75
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
76
+ }