@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
package/CHANGELOG.json CHANGED
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "4.12.0",
4
+ "date": "2026-10-10",
5
+ "entries": [
6
+ {
7
+ "kind": "added",
8
+ "short": "`npm create @driftengine` starts a game that runs: a Vite and TypeScript project with the instructions a coding agent reads, the engine's skill, and `npm run check`.",
9
+ "text": "`@driftengine/create` is the new package `npm create @driftengine@latest my-game` runs. It starts a Vite and TypeScript project from the `starter` example, a lit cube turning at a rate a DriftScript rule sets, or with `-- --template first-game` from the complete 3D game, and both are generated from the typechecked examples, so a new project starts as code the compiler and the examples page have already run. It asks nothing and installs nothing, because the caller is as likely to be a coding agent as a person and an agent cannot answer a prompt. A project carries an `AGENTS.md` naming its commands, the rules the compiler cannot check and what done means there; the `driftengine` skill in `.agents/skills/` and `.claude/skills/`, the folders Codex, Cursor and Claude Code read skills from; and `npm run check`, which typechecks, runs the tests in Node and looks at the game. `-- --skill` adds the skill to a project that already exists. The engine is pinned at the command's own version and DriftScript exactly at the engine's pin. Vitest is 5 rather than the 4.1 this repository runs: Vitest 4.1 cannot be installed into a project with no lockfile by npm 10.9, the npm Node 22 ships, whose resolver stops on its peer list. `npm run cleanroom` now starts both templates from the packed tarballs and typechecks, tests and builds them, and with `--look` photographs them; both pass."
10
+ },
11
+ {
12
+ "kind": "added",
13
+ "short": "`npm run look` photographs a game on WebGPU and on WebGL2 on the machine's own GPU, and fails on a console error or warning, an empty canvas, or a backend that fell back.",
14
+ "text": "`@driftengine/core/scripts/look.mjs` is the engine's capture instruments as one verdict, for whoever cannot see the screen. `look({ url })` opens the page once with `?backend=webgpu` and once with `?backend=webgl2`, declines the boot badge, and writes what a player sees on each, the canvas alone beside it, and `look.json` with every console line. It says which backend actually drew, read from the context the page's canvas asked for rather than from the address, and how much of the canvas changed between two frames. It fails on a console error or a warning, on a canvas that is 99.5% one colour, measured with the page's own interface hidden so a readout over an empty canvas does not pass, and on a backend asked for and not used. With no hardware GPU it refuses rather than trust a software rasteriser. Against the starter it reported both backends drawing; against a page clearing its canvas under a text readout it reported nothing drew on either, and against a page with no canvas that no renderer started. A started project runs it as `npm run look`, and `-- --headed` opens a real window where a headless browser cannot reach the GPU."
15
+ },
16
+ {
17
+ "kind": "added",
18
+ "short": "The `driftengine` skill teaches a coding agent the engine at the version a project installed: `npx skills add drftrun/driftengine`, or `--skill` in a project.",
19
+ "text": "`skills/driftengine/` is an agent skill in the Agent Skills format: when the engine fits and when it does not, how to start and check a project, the shape of a program, the rules the compiler cannot check, the traps that look like engine faults, and where to read further. Its code is regions of the typechecked examples, rewritten from them by `npm run create:templates`, and its three references, every manual page, every example and every package, are generated from the repository with addresses at the installed version's tag, so a page added to the manual reaches the skill on the next run. A test fails when any of it is stale, when its frontmatter breaks the format, and when it names a type, function or option the engine's source does not declare. The manual gains a chapter, Working with a coding agent, and the installation page starts from `npm create @driftengine`."
20
+ },
21
+ {
22
+ "kind": "changed",
23
+ "short": "The capture tools look for Chrome or Edge on Windows and macOS as well as Linux, and stop the browser they start on Windows too.",
24
+ "text": "`browser.mjs` knew only Chrome's Linux and macOS paths and stopped a browser by signalling its process group, which Windows does not have, so neither the capture tools nor `look` could run there. `browserCandidates(platform, env)` lists Chrome, Chromium and Edge where each system installs them: under both Program Files and the per-user folder on Windows, where Edge is always present, and under `/Applications` and `~/Applications` on macOS. On Windows the browser's whole tree is stopped with `taskkill /T`. `look` uses each system's own graphics path through ANGLE: Vulkan on Linux, Metal on macOS, Direct3D 11 on Windows. Run on Linux only so far; on Windows and macOS the first run is a measurement still owed, and `look` prints the GPU it found on its second line."
25
+ },
26
+ {
27
+ "kind": "added",
28
+ "short": "`SurfaceMaterial.layers` picks each layer from arrays materials share, and gives each layer its own tint, ranges, normal strength, specular and repeats down.",
29
+ "text": "A layered material read layer i from arrays of its own, so a texture worn by many materials in many tints was baked once per material with the tint in its pixels: in the case reported, about 1 GB of arrays for 140 MB of textures. `arrayLayers` picks which layer of the albedo, normal and ORM arrays each layer reads, so materials share one array of each kind; `extrasAt` says where a material's own maps beyond its layers start, a mask and a mesh normal among them, which just past the layers no longer is once arrays are shared. `looks` gives each layer a `tint`, linear and allowed above 1, the `roughness` and `metalness` ranges its ORM's channels are spread over, a `normalStrength` its normal map's slopes are scaled by, and a `specular`, blended as the colours are, that every highlight, reflection and area light reads in place of the vertices'; and a layer's repeat may be a pair, across and down apart. They are a lit switch of their own, `LAYER_LOOKS`, fifteen fragment uniform vectors on WebGL2, off until a material names one, and a layered material that names none draws as before. On `demo/dev/layerLooks.html` layers picked from shared arrays draw the same picture as the same layers in arrays of their own, white tinted draws the same as the coloured texture, a strength of 0 draws the same as a flat map while 3 bands the surface harder than 1, roughness spread over 0 to 0.1 against 0.9 to 1 moved 13.9% of the frame, a layer's specular of 0 took the highlight off, and a repeat of `[4, 4]` drew the same as `4` while `[4, 1]` stretched the layer, identically on both backends. `SurfaceLayers` and `SurfaceLayerLook` are exported for consumers that build them."
30
+ },
31
+ {
32
+ "kind": "added",
33
+ "short": "`SurfaceLayers.meshOcclusion` darkens a layered surface's colour or its ambient light by an occlusion read at the mesh's own coordinates, over a range.",
34
+ "text": "A cliff blended from layers repeating dozens of times across it lost the shading of its own large shape, which only a map at the mesh's coordinates holds. `meshOcclusion` reads the red of the ORM array's layer after the material's mask and added mask, at the mesh's coordinates as `meshNormal` reads its normal, spreads it over a `range`, and darkens the surface by `mix(1, occlusion, strength)`. A number is the strength and darkens the blended colour, and so every light on it; `{ into: 'ambient' }` darkens the ambient light alone, sky, ground and probe, as an ambient occlusion does, leaving the sun and the lamps as they were. Absent or 0, nothing is read. On `demo/dev/layerLooks.html` a wall under an occlusion ramp darkened from black at its left edge to untouched at its right, 35.9% of the frame against the same wall without it; lit only by its ambient, the two targets drew the same picture, and with a sun on it the ambient target kept the sun's share; a range of 0.5 to 1 left the edge half dark. The same on both backends."
35
+ }
36
+ ]
37
+ },
2
38
  {
3
39
  "version": "4.11.1",
4
40
  "date": "2026-10-10",
package/README.md CHANGED
@@ -6,7 +6,7 @@ WebGL2 everywhere else, behind one API, and carries the fixed-step loop, the ren
6
6
  input, the scene graph, geometry, text and the whole of `@driftengine/physics`, which it re-exports.
7
7
  Everything else is a separate package a game adds when it reaches for it.
8
8
 
9
- **Cost: 904.6 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`,
9
+ **Cost: 910.1 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`,
10
10
  which fails the build if the figure drifts by more than 3%.
11
11
 
12
12
  The manual is at **[driftengine.dev/docs](https://driftengine.dev/docs)**, with an example running
package/dist/index.d.ts CHANGED
@@ -229,6 +229,8 @@ export { MAX_CLUSTERED_LIGHTS } from './render/clusteredLights.ts';
229
229
  export { SurfaceTexture } from './render/surfaceTexture.ts';
230
230
  export type { SurfaceMaterial, SurfaceTextureOptions } from './render/surfaceTexture.ts';
231
231
  export type { SurfaceProjection } from './render/surfaceProjection.ts';
232
+ export type { SurfaceLayers } from './render/surfaceLayers.ts';
233
+ export type { SurfaceLayerLook } from './render/surfaceLayerLooks.ts';
232
234
  export { compressedFormatName, isCompressedSource, levelBytes, uploadsCompressed, } from './render/compressedSource.ts';
233
235
  export type { BlockFormat, CompressedTextureFormat, CompressedTextureSource, SurfaceSource, } from './render/compressedSource.ts';
234
236
  export { DEFAULT_GOVERNOR_LIMITS, ResolutionGovernor } from './render/resolutionGovernor.ts';
@@ -1290,9 +1290,14 @@ export declare class WebGL2Renderer implements RendererApi {
1290
1290
  private readonly transmission;
1291
1291
  /** `uLayers`, the current material's layers packed. See `surfaceLayers.ts`. */
1292
1292
  private readonly layerFloats;
1293
+ /** `uLayerLooks`, the current material's layer picks and looks packed. See `surfaceLayerLooks.ts`. */
1294
+ private readonly lookFloats;
1293
1295
  /** Whether the lit stage has room for layers' two vectors, and which refusals were said. */
1294
1296
  private layersRoom;
1295
1297
  private warnedLayersRoom;
1298
+ /** Whether it has room for the layers' looks beside them, and whether the refusal was said. */
1299
+ private looksRoom;
1300
+ private warnedLooksRoom;
1296
1301
  private warnedLayersModelMap;
1297
1302
  private warnedLayersTriplanar;
1298
1303
  private warnedOverlayRoom;
@@ -2165,9 +2170,19 @@ export declare class WebGL2Renderer implements RendererApi {
2165
2170
  * no room for their two vectors each leave one layer, the last two said once each.
2166
2171
  */
2167
2172
  private layersOf;
2173
+ /**
2174
+ * Whether a layered material's picks and looks are drawn: asked for, and room for their fifteen
2175
+ * vectors beside the layers' four. Refused, the layers are drawn reading layer `i` of their arrays
2176
+ * as they are, said once — which on a material sharing its arrays reads the wrong textures, so
2177
+ * the sentence names the way out.
2178
+ */
2179
+ private looksFit;
2168
2180
  /** Says once that a layered material's triplanar projection is laid on the horizontal plane. */
2169
2181
  private warnLayersTriplanar;
2170
- /** Whether the lit shader with `feature` compiled in fits this GPU's fragment uniform vectors. */
2182
+ /**
2183
+ * Whether the lit shader with `feature` compiled in, and `beside` with it where a switch means
2184
+ * nothing alone, fits this GPU's fragment uniform vectors.
2185
+ */
2171
2186
  private switchFits;
2172
2187
  /**
2173
2188
  * How bright the environment the surfaces drawn next reflect is. 1 is the default and identity.
@@ -41,6 +41,7 @@ import { resolveTransmission } from '../../transmission.js';
41
41
  import { resolveOcclusionFade } from '../../occlusionFade.js';
42
42
  import { orthographicDepthSpan } from '../../lightMatrix.js';
43
43
  import { LAYER_FLOATS, layerMaskMap, layeredProjection, layersFitBeside, packSurfaceLayers, } from '../../surfaceLayers.js';
44
+ import { LOOK_FLOATS, asksLayerLooks, packLayerLooks } from '../../surfaceLayerLooks.js';
44
45
  import { FILM_FRAG, FILM_VERT } from '../../shaders/film.js';
45
46
  import { SceneTarget } from '../../sceneTarget.js';
46
47
  import { FILM_LOOK_WITHOUT_COMPOSITE } from '../../shaders/filmLook.js';
@@ -963,9 +964,14 @@ export class WebGL2Renderer {
963
964
  transmission = new Float32Array([-1, -1, -1, 0]);
964
965
  /** `uLayers`, the current material's layers packed. See `surfaceLayers.ts`. */
965
966
  layerFloats = new Float32Array(LAYER_FLOATS);
967
+ /** `uLayerLooks`, the current material's layer picks and looks packed. See `surfaceLayerLooks.ts`. */
968
+ lookFloats = new Float32Array(LOOK_FLOATS);
966
969
  /** Whether the lit stage has room for layers' two vectors, and which refusals were said. */
967
970
  layersRoom = null;
968
971
  warnedLayersRoom = false;
972
+ /** Whether it has room for the layers' looks beside them, and whether the refusal was said. */
973
+ looksRoom = null;
974
+ warnedLooksRoom = false;
969
975
  warnedLayersModelMap = false;
970
976
  warnedLayersTriplanar = false;
971
977
  warnedOverlayRoom = false;
@@ -3267,6 +3273,10 @@ export class WebGL2Renderer {
3267
3273
  if (layers !== null)
3268
3274
  this.askLit('LAYERED');
3269
3275
  gl.uniform4fv(u['uLayers'] ?? null, this.layerFloats);
3276
+ packLayerLooks(layers, this.lookFloats);
3277
+ if (this.looksFit(layers))
3278
+ this.askLit('LAYER_LOOKS');
3279
+ gl.uniform4fv(u['uLayerLooks'] ?? null, this.lookFloats);
3270
3280
  gl.uniform1i(u['uModelMap'] ?? null, MODEL_MAP_TEXTURE_UNIT);
3271
3281
  packModel(material?.model ?? null, modelMap !== null, this.modelParams, material?.physicalSpecular === true);
3272
3282
  /* The first physical highlight asked for is when the lit programs start carrying one. */
@@ -3539,6 +3549,27 @@ export class WebGL2Renderer {
3539
3549
  }
3540
3550
  return null;
3541
3551
  }
3552
+ /**
3553
+ * Whether a layered material's picks and looks are drawn: asked for, and room for their fifteen
3554
+ * vectors beside the layers' four. Refused, the layers are drawn reading layer `i` of their arrays
3555
+ * as they are, said once — which on a material sharing its arrays reads the wrong textures, so
3556
+ * the sentence names the way out.
3557
+ */
3558
+ looksFit(layers) {
3559
+ if (!asksLayerLooks(layers))
3560
+ return false;
3561
+ this.looksRoom ??= this.switchFits('LAYER_LOOKS', 'LAYERED');
3562
+ if (this.looksRoom)
3563
+ return true;
3564
+ if (!this.warnedLooksRoom) {
3565
+ this.warnedLooksRoom = true;
3566
+ console.warn("Renderer: a layered material's picks and looks are refused on this device. They take " +
3567
+ 'fifteen fragment uniform vectors the lit shader does not have, so its layers read layer ' +
3568
+ 'i of its arrays, untinted, at one repeat each. A lower `maxLights` makes the room, or ' +
3569
+ 'WebGPU has it.');
3570
+ }
3571
+ return false;
3572
+ }
3542
3573
  /** Says once that a layered material's triplanar projection is laid on the horizontal plane. */
3543
3574
  warnLayersTriplanar() {
3544
3575
  if (this.warnedLayersTriplanar)
@@ -3547,9 +3578,14 @@ export class WebGL2Renderer {
3547
3578
  console.warn("Renderer: a layered material's triplanar projection is laid on the horizontal plane: every " +
3548
3579
  "layer's maps on three planes would be three times the reads. See `SurfaceMaterial.layers`.");
3549
3580
  }
3550
- /** Whether the lit shader with `feature` compiled in fits this GPU's fragment uniform vectors. */
3551
- switchFits(feature) {
3581
+ /**
3582
+ * Whether the lit shader with `feature` compiled in, and `beside` with it where a switch means
3583
+ * nothing alone, fits this GPU's fragment uniform vectors.
3584
+ */
3585
+ switchFits(feature, beside) {
3552
3586
  const asIf = { [feature]: true };
3587
+ if (beside !== undefined)
3588
+ asIf[beside] = true;
3553
3589
  return (countUniformVectors(this.flatFragAt(this.lightBudget, asIf)) <= this.fragmentVectorLimit());
3554
3590
  }
3555
3591
  /**
@@ -5495,6 +5531,7 @@ export class WebGL2Renderer {
5495
5531
  worldUvs: asIf.WORLD_UVS ?? this.litOn.WORLD_UVS,
5496
5532
  movingSun: asIf.MOVING_SUN ?? this.litOn.MOVING_SUN,
5497
5533
  layered: asIf.LAYERED ?? this.litOn.LAYERED,
5534
+ layerLooks: asIf.LAYER_LOOKS ?? this.litOn.LAYER_LOOKS,
5498
5535
  maxLights: budget.maxLights,
5499
5536
  maxAreaLights: budget.maxAreaLights,
5500
5537
  });
@@ -7351,6 +7388,7 @@ export class WebGL2Renderer {
7351
7388
  resolveTransmission(null, this.transmission);
7352
7389
  /* Nor layers: one layer until a material asks for more. */
7353
7390
  this.layerFloats.fill(0);
7391
+ this.lookFloats.fill(0);
7354
7392
  this.overlayMaps = null;
7355
7393
  this.overlayTexture = null;
7356
7394
  /* Nor an ambient of its own: the frame's, until a draw asks. See `setAmbientSH`. */
@@ -7494,6 +7532,7 @@ export class WebGL2Renderer {
7494
7532
  gl.uniform4fv(u['uOverlay'] ?? null, this.overlayFloats);
7495
7533
  gl.uniform4fv(u['uWorldUv'] ?? null, this.projection);
7496
7534
  gl.uniform4fv(u['uLayers'] ?? null, this.layerFloats);
7535
+ gl.uniform4fv(u['uLayerLooks'] ?? null, this.lookFloats);
7497
7536
  /* Neither refracting nor glass, which is every draw that does not say otherwise. See
7498
7537
  `bindSeeThrough` for the four numbers in the one vector. */
7499
7538
  gl.uniform4f(u['uSeeThrough'] ?? null, 0, 0, 0, 0);
@@ -1189,6 +1189,8 @@ export declare class WebGPURenderer implements RendererApi {
1189
1189
  private readonly projection;
1190
1190
  /** `uLayers`, the material's layers packed by `packSurfaceLayers`. */
1191
1191
  private readonly layerFloats;
1192
+ /** `uLayerLooks`, the material's layer picks and looks packed by `packLayerLooks`. */
1193
+ private readonly lookFloats;
1192
1194
  /**
1193
1195
  * `uGlassTint` as the material leaves it, its light from behind and that light's colour, which a
1194
1196
  * pane overwrites for its own draw and `releaseSeeThrough` puts back. See `transmission.ts`.
@@ -67,6 +67,7 @@ import { resolveTransmission } from '../../transmission.js';
67
67
  import { resolveOcclusionFade } from '../../occlusionFade.js';
68
68
  import { orthographicDepthSpan } from '../../lightMatrix.js';
69
69
  import { LAYER_FLOATS, layerMaskMap, layeredProjection, layersFitBeside, packSurfaceLayers, } from '../../surfaceLayers.js';
70
+ import { LOOK_FLOATS, asksLayerLooks, packLayerLooks } from '../../surfaceLayerLooks.js';
70
71
  import { frameReflectionsRefused, resolveFrameReflections, } from '../../frameReflections.js';
71
72
  import { REFLECTION_SURFACE_FORMAT, ReflectionSurfacePass } from './reflectionSurfacePass.js';
72
73
  import { validateClothBinding } from '../../clothBindingData.js';
@@ -2256,6 +2257,8 @@ export class WebGPURenderer {
2256
2257
  projection = new Float32Array(PROJECTION_FLOATS);
2257
2258
  /** `uLayers`, the material's layers packed by `packSurfaceLayers`. */
2258
2259
  layerFloats = new Float32Array(LAYER_FLOATS);
2260
+ /** `uLayerLooks`, the material's layer picks and looks packed by `packLayerLooks`. */
2261
+ lookFloats = new Float32Array(LOOK_FLOATS);
2259
2262
  /**
2260
2263
  * `uGlassTint` as the material leaves it, its light from behind and that light's colour, which a
2261
2264
  * pane overwrites for its own draw and `releaseSeeThrough` puts back. See `transmission.ts`.
@@ -2745,6 +2748,11 @@ export class WebGPURenderer {
2745
2748
  f.set(this.layerFloats, this.materialField('uLayers'));
2746
2749
  if (layers !== null)
2747
2750
  this.enableLit('LAYERED');
2751
+ /* And which layer of shared arrays each reads, with its look: layerLooks.ts. */
2752
+ packLayerLooks(layers, this.lookFloats);
2753
+ f.set(this.lookFloats, this.materialField('uLayerLooks'));
2754
+ if (asksLayerLooks(layers))
2755
+ this.enableLit('LAYER_LOOKS');
2748
2756
  f.set(this.modelParams, this.materialField('uModelParams'));
2749
2757
  });
2750
2758
  /*
@@ -7186,6 +7194,7 @@ export class WebGPURenderer {
7186
7194
  f.set(this.transmission, at('uGlassTint'));
7187
7195
  /* Nor layers: one layer until a material asks for more. */
7188
7196
  f.fill(0, at('uLayers'), at('uLayers') + LAYER_FLOATS);
7197
+ f.fill(0, at('uLayerLooks'), at('uLayerLooks') + LOOK_FLOATS);
7189
7198
  f[at('uCutout')] = 0;
7190
7199
  f[at('uCutout') + 1] = 0;
7191
7200
  this.cutoutResolveStaged = 'hard';
@@ -7,4 +7,4 @@
7
7
  *
8
8
  * Depth zero and after the models, whose `modelLight` it calls; see `flatFrag`.
9
9
  */
10
- export declare const AREA_LIGHT_GLSL = "\n/**\n * The rectangle's signed form factor at this fragment: its corners **wound so a surface on the\n * emitting side sees them counter-clockwise**, which is what makes it positive there. The natural\n * order \u2014 minus, plus, plus, minus \u2014 winds the other way, and every one-sided light then clamped to\n * zero and the frame was black. The sign is the sidedness: a one-sided rectangle takes the positive\n * part and a two-sided one the magnitude.\n */\nfloat areaSignedForm(vec3 n, vec3 centre, vec3 right, vec3 up, vec2 halfSize) {\n vec3 c0 = centre - right * halfSize.x + up * halfSize.y;\n vec3 c1 = centre + right * halfSize.x + up * halfSize.y;\n vec3 c2 = centre + right * halfSize.x - up * halfSize.y;\n vec3 c3 = centre - right * halfSize.x - up * halfSize.y;\n return quadFormFactor(n, vWorldPos, c0, c1, c2, c3);\n}\n\n/**\n * How far along one of a rectangle's axes a fragment still sees it past the barn door on that axis's\n * positive edge: `p` the fragment's place along the axis, `height` its height above the\n * rectangle's plane, `halfSize` the rectangle's half extent, and the door's tip `tipOut` beyond\n * the edge and `tipUp` above the plane. Points of the rectangle past the answer are hidden.\n *\n * Above the tip, the tip's shadow on the plane as seen from the fragment is the limit. Below it, a\n * fragment beside the door and outside its line is hidden from all of it, and one inside the door's\n * reach is hidden from none of it.\n */\nfloat barnDoorLimit(float p, float height, float halfSize, float tipOut, float tipUp) {\n float tipAt = halfSize + tipOut;\n if (height > tipUp) return (height * tipAt - tipUp * p) / (height - tipUp);\n if (p > halfSize && height * tipOut < tipUp * (p - halfSize)) return -1e9;\n return 1e9;\n}\n\n/**\n * The part of a rectangle a fragment sees past its barn doors, written into `centre` and\n * `halfSize` as the smaller rectangle that is left, or false where the doors hide all of it.\n * `doors` is the cosine of their angle from the normal and their length; a length of 0 is no doors.\n * Each axis is clipped by its own two doors, which is what makes this four projections and a min.\n */\nbool areaBarnDoors(inout vec3 centre, vec3 right, vec3 up, inout vec2 halfSize, vec2 doors) {\n if (doors.y <= 0.0) return true;\n vec3 toFragment = vWorldPos - centre;\n float height = dot(toFragment, cross(right, up));\n /* Behind the emitting side: nothing to clip, and the form factor already answers it. */\n if (height <= 0.0) return true;\n float tipOut = doors.y * sqrt(max(1.0 - doors.x * doors.x, 0.0));\n float tipUp = doors.y * doors.x;\n vec2 p = vec2(dot(toFragment, right), dot(toFragment, up));\n vec2 hi = vec2(\n min(halfSize.x, barnDoorLimit(p.x, height, halfSize.x, tipOut, tipUp)),\n min(halfSize.y, barnDoorLimit(p.y, height, halfSize.y, tipOut, tipUp))\n );\n vec2 lo = vec2(\n max(-halfSize.x, -barnDoorLimit(-p.x, height, halfSize.x, tipOut, tipUp)),\n max(-halfSize.y, -barnDoorLimit(-p.y, height, halfSize.y, tipOut, tipUp))\n );\n if (hi.x <= lo.x || hi.y <= lo.y) return false;\n centre += right * (0.5 * (lo.x + hi.x)) + up * (0.5 * (lo.y + hi.y));\n halfSize = 0.5 * (hi - lo);\n return true;\n}\n\n/**\n * What one rectangle of colour `color` and form factor `form` adds to the lamps' two sums, open\n * and shadowed by `occl` and `glass`.\n *\n * **A model answers a rectangle as it answers a lamp at its centre**, carried at the rectangle's\n * own energy: the diffuse by the exact form factor over the centre's cosine, the specular by the\n * form factor. An approximation, stated: a model's lobe is not integrated over the rectangle as the\n * standard lobe is, so a glossy model under a large softbox shows a lobe from its centre rather than\n * its shape.\n *\n * **The standard lobe is integrated over the rectangle**, rather than sampled at one point on it.\n * What stood here once handed the closest point on the rectangle to `sphereLobe` and scaled it by\n * `form`, the *diffuse* form factor. Measured against a brute-force integral by\n * `scripts/areaSpecular.mjs`, that lost the reflection rather than blurring it: on polished metal\n * facing a softbox it returned 0.000233 where the integral is 0.9207, and head-on on a rough surface\n * it was 3.7 times too bright. Worst 287% either way; this is 40.1% where the light is what the\n * surface reflects and 79.0% for one overhead. `quadCoverage` says what fraction of the specular\n * lobe the rectangle covers and the environment BRDF says how much the surface returns, the split\n * the probe path uses. **`dfg.x + dfg.y` and not `envSpecularEnergy`**, deliberately: the\n * compensation returns the multiply-scattered share, and every direct lobe in this shader is\n * single-scattering, so adding it here returns energy the light never had. Measured at 211%\n * against 40.1%.\n */\nvoid areaLightAdd(\n vec3 centre,\n vec3 right,\n vec3 up,\n vec2 halfSize,\n vec3 color,\n float form,\n float occl,\n vec3 glass,\n vec3 n,\n vec3 albedo,\n float metal,\n vec3 specColor,\n float roughness,\n bool modelled,\n inout vec3 lampOpen,\n inout vec3 lampShadowed\n) {\n if (modelled) {\n vec3 areaL = normalize(centre - vWorldPos);\n modelLight(areaL, 0.0, 0.0);\n vec3 areaModel = (mDiffuse * (form / max(dot(n, areaL), 1e-3)) + mSpecular * form) * color;\n lampOpen += areaModel;\n lampShadowed += areaModel * occl * glass;\n return;\n }\n /* Held back with the point lamps', and shadowed by the same term it always was. */\n vec3 areaDiffuse = albedo * color * form * (1.0 - metal);\n lampOpen += areaDiffuse;\n lampShadowed += areaDiffuse * occl * glass;\n\n if (vSpecular > 0.0 || metal > 0.0) {\n vec3 c0 = centre - right * halfSize.x + up * halfSize.y;\n vec3 c1 = centre + right * halfSize.x + up * halfSize.y;\n vec3 c2 = centre + right * halfSize.x - up * halfSize.y;\n vec3 c3 = centre - right * halfSize.x - up * halfSize.y;\n vec3 toEyeArea = normalize(uCameraPos - vWorldPos);\n float coverage = quadCoverage(n, vWorldPos, toEyeArea, roughness, c0, c1, c2, c3);\n if (coverage > 0.0) {\n vec2 areaDfg = envBrdfApprox(max(dot(n, toEyeArea), 0.0), roughness);\n vec3 areaSpec = specColor * areaDfg.x + vec3(areaDfg.y);\n vec3 areaHighlight = areaSpec * color * coverage;\n lampOpen += areaHighlight;\n lampShadowed += areaHighlight * occl * glass;\n }\n }\n}\n";
10
+ export declare const AREA_LIGHT_GLSL = "\n/**\n * The rectangle's signed form factor at this fragment: its corners **wound so a surface on the\n * emitting side sees them counter-clockwise**, which is what makes it positive there. The natural\n * order \u2014 minus, plus, plus, minus \u2014 winds the other way, and every one-sided light then clamped to\n * zero and the frame was black. The sign is the sidedness: a one-sided rectangle takes the positive\n * part and a two-sided one the magnitude.\n */\nfloat areaSignedForm(vec3 n, vec3 centre, vec3 right, vec3 up, vec2 halfSize) {\n vec3 c0 = centre - right * halfSize.x + up * halfSize.y;\n vec3 c1 = centre + right * halfSize.x + up * halfSize.y;\n vec3 c2 = centre + right * halfSize.x - up * halfSize.y;\n vec3 c3 = centre - right * halfSize.x - up * halfSize.y;\n return quadFormFactor(n, vWorldPos, c0, c1, c2, c3);\n}\n\n/**\n * How far along one of a rectangle's axes a fragment still sees it past the barn door on that axis's\n * positive edge: `p` the fragment's place along the axis, `height` its height above the\n * rectangle's plane, `halfSize` the rectangle's half extent, and the door's tip `tipOut` beyond\n * the edge and `tipUp` above the plane. Points of the rectangle past the answer are hidden.\n *\n * Above the tip, the tip's shadow on the plane as seen from the fragment is the limit. Below it, a\n * fragment beside the door and outside its line is hidden from all of it, and one inside the door's\n * reach is hidden from none of it.\n */\nfloat barnDoorLimit(float p, float height, float halfSize, float tipOut, float tipUp) {\n float tipAt = halfSize + tipOut;\n if (height > tipUp) return (height * tipAt - tipUp * p) / (height - tipUp);\n if (p > halfSize && height * tipOut < tipUp * (p - halfSize)) return -1e9;\n return 1e9;\n}\n\n/**\n * The part of a rectangle a fragment sees past its barn doors, written into `centre` and\n * `halfSize` as the smaller rectangle that is left, or false where the doors hide all of it.\n * `doors` is the cosine of their angle from the normal and their length; a length of 0 is no doors.\n * Each axis is clipped by its own two doors, which is what makes this four projections and a min.\n */\nbool areaBarnDoors(inout vec3 centre, vec3 right, vec3 up, inout vec2 halfSize, vec2 doors) {\n if (doors.y <= 0.0) return true;\n vec3 toFragment = vWorldPos - centre;\n float height = dot(toFragment, cross(right, up));\n /* Behind the emitting side: nothing to clip, and the form factor already answers it. */\n if (height <= 0.0) return true;\n float tipOut = doors.y * sqrt(max(1.0 - doors.x * doors.x, 0.0));\n float tipUp = doors.y * doors.x;\n vec2 p = vec2(dot(toFragment, right), dot(toFragment, up));\n vec2 hi = vec2(\n min(halfSize.x, barnDoorLimit(p.x, height, halfSize.x, tipOut, tipUp)),\n min(halfSize.y, barnDoorLimit(p.y, height, halfSize.y, tipOut, tipUp))\n );\n vec2 lo = vec2(\n max(-halfSize.x, -barnDoorLimit(-p.x, height, halfSize.x, tipOut, tipUp)),\n max(-halfSize.y, -barnDoorLimit(-p.y, height, halfSize.y, tipOut, tipUp))\n );\n if (hi.x <= lo.x || hi.y <= lo.y) return false;\n centre += right * (0.5 * (lo.x + hi.x)) + up * (0.5 * (lo.y + hi.y));\n halfSize = 0.5 * (hi - lo);\n return true;\n}\n\n/**\n * What one rectangle of colour `color` and form factor `form` adds to the lamps' two sums, open\n * and shadowed by `occl` and `glass`.\n *\n * **A model answers a rectangle as it answers a lamp at its centre**, carried at the rectangle's\n * own energy: the diffuse by the exact form factor over the centre's cosine, the specular by the\n * form factor. An approximation, stated: a model's lobe is not integrated over the rectangle as the\n * standard lobe is, so a glossy model under a large softbox shows a lobe from its centre rather than\n * its shape.\n *\n * **The standard lobe is integrated over the rectangle**, rather than sampled at one point on it.\n * What stood here once handed the closest point on the rectangle to `sphereLobe` and scaled it by\n * `form`, the *diffuse* form factor. Measured against a brute-force integral by\n * `scripts/areaSpecular.mjs`, that lost the reflection rather than blurring it: on polished metal\n * facing a softbox it returned 0.000233 where the integral is 0.9207, and head-on on a rough surface\n * it was 3.7 times too bright. Worst 287% either way; this is 40.1% where the light is what the\n * surface reflects and 79.0% for one overhead. `quadCoverage` says what fraction of the specular\n * lobe the rectangle covers and the environment BRDF says how much the surface returns, the split\n * the probe path uses. **`dfg.x + dfg.y` and not `envSpecularEnergy`**, deliberately: the\n * compensation returns the multiply-scattered share, and every direct lobe in this shader is\n * single-scattering, so adding it here returns energy the light never had. Measured at 211%\n * against 40.1%.\n */\nvoid areaLightAdd(\n vec3 centre,\n vec3 right,\n vec3 up,\n vec2 halfSize,\n vec3 color,\n float form,\n float occl,\n vec3 glass,\n vec3 n,\n vec3 albedo,\n float metal,\n vec3 specColor,\n float roughness,\n bool modelled,\n inout vec3 lampOpen,\n inout vec3 lampShadowed\n) {\n if (modelled) {\n vec3 areaL = normalize(centre - vWorldPos);\n modelLight(areaL, 0.0, 0.0);\n vec3 areaModel = (mDiffuse * (form / max(dot(n, areaL), 1e-3)) + mSpecular * form) * color;\n lampOpen += areaModel;\n lampShadowed += areaModel * occl * glass;\n return;\n }\n /* Held back with the point lamps', and shadowed by the same term it always was. */\n vec3 areaDiffuse = albedo * color * form * (1.0 - metal);\n lampOpen += areaDiffuse;\n lampShadowed += areaDiffuse * occl * glass;\n\n if (surfaceSpecular > 0.0 || metal > 0.0) {\n vec3 c0 = centre - right * halfSize.x + up * halfSize.y;\n vec3 c1 = centre + right * halfSize.x + up * halfSize.y;\n vec3 c2 = centre + right * halfSize.x - up * halfSize.y;\n vec3 c3 = centre - right * halfSize.x - up * halfSize.y;\n vec3 toEyeArea = normalize(uCameraPos - vWorldPos);\n float coverage = quadCoverage(n, vWorldPos, toEyeArea, roughness, c0, c1, c2, c3);\n if (coverage > 0.0) {\n vec2 areaDfg = envBrdfApprox(max(dot(n, toEyeArea), 0.0), roughness);\n vec3 areaSpec = specColor * areaDfg.x + vec3(areaDfg.y);\n vec3 areaHighlight = areaSpec * color * coverage;\n lampOpen += areaHighlight;\n lampShadowed += areaHighlight * occl * glass;\n }\n }\n}\n";
@@ -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;
@@ -8,7 +8,7 @@ export declare function glassTintGlsl(glassShadows: boolean): string;
8
8
  * The lit stage's switches, by the name of the constant each is: the decision both backends make
9
9
  * about which optional code a lit program carries. See `litSwitchesGlsl`.
10
10
  */
11
- export type LitSwitch = 'GLASS_SHADOWS' | 'CLUSTERED_LIGHTS' | 'LIGHT_FIXTURES' | 'SURFACE_EFFECTS' | 'DRIFT_LIGHT' | 'PHYSICAL_SPECULAR' | 'SURFACE_OVERLAY' | 'REFLECTION_MAPS' | 'WORLD_UVS' | 'MOVING_SUN' | 'LAYERED';
11
+ export type LitSwitch = 'GLASS_SHADOWS' | 'CLUSTERED_LIGHTS' | 'LIGHT_FIXTURES' | 'SURFACE_EFFECTS' | 'DRIFT_LIGHT' | 'PHYSICAL_SPECULAR' | 'SURFACE_OVERLAY' | 'REFLECTION_MAPS' | 'WORLD_UVS' | 'MOVING_SUN' | 'LAYERED' | 'LAYER_LOOKS';
12
12
  /** Every switch off: what a renderer starts from before its profile and its content say otherwise. */
13
13
  export declare function noLitSwitches(): Record<LitSwitch, boolean>;
14
14
  /** Which of the lit stage's optional features a build reads. See `litSwitchesGlsl`. */
@@ -37,6 +37,7 @@ export function noLitSwitches() {
37
37
  WORLD_UVS: false,
38
38
  MOVING_SUN: false,
39
39
  LAYERED: false,
40
+ LAYER_LOOKS: false,
40
41
  };
41
42
  }
42
43
  /**
@@ -140,6 +140,11 @@ export interface FlatShaderOptions {
140
140
  * True when absent. See \`layeredGlsl\`.
141
141
  */
142
142
  readonly layered?: boolean;
143
+ /**
144
+ * Whether a layered material's layers are picked from shared arrays and given looks of their own,
145
+ * \`uLayerLooks\` and all. True when absent; nothing without \`layered\`. See \`layerLooksGlsl\`.
146
+ */
147
+ readonly layerLooks?: boolean;
143
148
  /**
144
149
  * How many point lights this build declares room for. `MAX_POINT_LIGHTS` when absent.
145
150
  *
@@ -513,7 +513,7 @@ export function flatFrag(options) {
513
513
  /* After the reflection maps, so its switch takes the next id; main calls it. worldUv.ts. */
514
514
  worldUvGlsl(options.worldUvs ?? true),
515
515
  /* After the projection, so its switch takes the next id; the normal map below calls it. */
516
- layeredGlsl(options.layered ?? true),
516
+ layeredGlsl(options.layered ?? true, options.layerLooks ?? true),
517
517
  /* Where each map is read, after both of the ways it can be read otherwise. See mapReads.ts. */
518
518
  MAP_READS_GLSL,
519
519
  /* After the frame and the projection it calls, unconditional for the same reason as the
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Which layer of the shared arrays each of a layered material's layers reads, and how each looks:
3
+ * `uLayerLooks`, packed by `surfaceLayerLooks.ts`, read by `layered.ts`.
4
+ *
5
+ * **Every helper is the identity where the switch is off**, written as a branch on `LAYER_LOOKS`
6
+ * itself because the generated WGSL carries one string for both values of an override: layer `i`
7
+ * reads layer `i`, the maps beyond the layers start at the layer count, a tint is white, a range is
8
+ * 0 to 1, a normal is as stored, a specular is the surface's own, a repeat down is the repeat
9
+ * across, and nothing is occluded — exactly what `layered.ts` did before this existed, which is what
10
+ * a layered material that asks for none of it has to keep drawing.
11
+ *
12
+ * **A lit switch, `LAYER_LOOKS`**, off until a material first asks and declared after `LAYERED`, so
13
+ * every switch before it keeps its id. It is only ever compiled beside `LAYERED`, whose `uLayers`
14
+ * it reads.
15
+ */
16
+ export declare function layerLooksGlsl(on: boolean): string;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Which layer of the shared arrays each of a layered material's layers reads, and how each looks:
3
+ * `uLayerLooks`, packed by `surfaceLayerLooks.ts`, read by `layered.ts`.
4
+ *
5
+ * **Every helper is the identity where the switch is off**, written as a branch on `LAYER_LOOKS`
6
+ * itself because the generated WGSL carries one string for both values of an override: layer `i`
7
+ * reads layer `i`, the maps beyond the layers start at the layer count, a tint is white, a range is
8
+ * 0 to 1, a normal is as stored, a specular is the surface's own, a repeat down is the repeat
9
+ * across, and nothing is occluded — exactly what `layered.ts` did before this existed, which is what
10
+ * a layered material that asks for none of it has to keep drawing.
11
+ *
12
+ * **A lit switch, `LAYER_LOOKS`**, off until a material first asks and declared after `LAYERED`, so
13
+ * every switch before it keeps its id. It is only ever compiled beside `LAYERED`, whose `uLayers`
14
+ * it reads.
15
+ */
16
+ export function layerLooksGlsl(on) {
17
+ if (!on) {
18
+ return /* glsl */ `
19
+ const bool LAYER_LOOKS = false; // wgsl:override
20
+ float layerArray(int i) { return float(i); }
21
+ float layerExtras() { return uLayers[1].y; }
22
+ vec3 layerTint(int i) { return vec3(1.0); }
23
+ vec2 layerRanges(int i, vec2 roughMetal) { return roughMetal; }
24
+ vec3 layerStrengthen(int i, vec3 texel) { return texel; }
25
+ float layerSpecular(int i, float own) { return own; }
26
+ float layerRepeatDown(int i, float across) { return across; }
27
+ float layerOcclusionStrength() { return 0.0; }
28
+ bool layerOcclusionAmbient() { return false; }
29
+ vec2 layerOcclusionRange() { return vec2(0.0, 1.0); }
30
+ `;
31
+ }
32
+ return /* glsl */ `
33
+ /* Whether layers are picked from shared arrays and given looks of their own: layerLooks.ts. */
34
+ const bool LAYER_LOOKS = true; // wgsl:override
35
+ /*
36
+ * Fifteen vectors, as surfaceLayerLooks.ts packs them: the array layer each of the five reads, base
37
+ * first (floats 0 to 4); where the maps beyond the layers start (5), the mesh occlusion's strength
38
+ * (6) and whether it darkens the ambient light rather than the colour (7); then two vectors a
39
+ * layer, base first: its tint (x y z) and normal strength (w), then its roughness range (x to y)
40
+ * and metalness range (z to w); then each layer's specular, -1 for the surface's own (48 to 52), its
41
+ * repeat down (53 to 57), and the occlusion's range (58 to 59).
42
+ */
43
+ uniform vec4 uLayerLooks[15]; // wgsl:material
44
+
45
+ /* Float k of the fifteen vectors. */
46
+ float layerLook(int k) {
47
+ return uLayerLooks[k / 4][k % 4];
48
+ }
49
+
50
+ /* The layer of the albedo, normal and ORM arrays a material's layer i reads. */
51
+ float layerArray(int i) {
52
+ if (LAYER_LOOKS) return layerLook(i);
53
+ return float(i);
54
+ }
55
+
56
+ /* Where the maps beyond the layers start in each array. */
57
+ float layerExtras() {
58
+ if (LAYER_LOOKS) return uLayerLooks[1].y;
59
+ return uLayers[1].y;
60
+ }
61
+
62
+ vec3 layerTint(int i) {
63
+ if (LAYER_LOOKS) return uLayerLooks[2 + i * 2].xyz;
64
+ return vec3(1.0);
65
+ }
66
+
67
+ /* The ORM's roughness and metalness, each spread over the layer's own range. */
68
+ vec2 layerRanges(int i, vec2 roughMetal) {
69
+ if (LAYER_LOOKS) {
70
+ vec4 range = uLayerLooks[3 + i * 2];
71
+ return vec2(mix(range.x, range.y, roughMetal.x), mix(range.z, range.w, roughMetal.y));
72
+ }
73
+ return roughMetal;
74
+ }
75
+
76
+ /*
77
+ * A tangent-space texel bent further or less by the layer's strength: its x and y scaled, its z
78
+ * kept, so 0 lies flat and a strength above 1 steepens every slope the map holds. z is held above
79
+ * zero so a texel lying exactly flat on its surface cannot normalise to nothing at strength 0.
80
+ */
81
+ vec3 layerStrengthen(int i, vec3 texel) {
82
+ if (LAYER_LOOKS) return normalize(vec3(texel.xy * uLayerLooks[2 + i * 2].w, max(texel.z, 1e-4)));
83
+ return texel;
84
+ }
85
+
86
+ /* The layer's specular, or the surface's own where it names none. */
87
+ float layerSpecular(int i, float own) {
88
+ if (LAYER_LOOKS) {
89
+ float named = layerLook(48 + i);
90
+ return named < 0.0 ? own : named;
91
+ }
92
+ return own;
93
+ }
94
+
95
+ /* The layer's repeat down, which is its repeat across unless it gave two. */
96
+ float layerRepeatDown(int i, float across) {
97
+ if (LAYER_LOOKS) return layerLook(53 + i);
98
+ return across;
99
+ }
100
+
101
+ float layerOcclusionStrength() {
102
+ if (LAYER_LOOKS) return uLayerLooks[1].z;
103
+ return 0.0;
104
+ }
105
+
106
+ bool layerOcclusionAmbient() {
107
+ if (LAYER_LOOKS) return uLayerLooks[1].w > 0.5;
108
+ return false;
109
+ }
110
+
111
+ vec2 layerOcclusionRange() {
112
+ if (LAYER_LOOKS) return uLayerLooks[14].zw;
113
+ return vec2(0.0, 1.0);
114
+ }
115
+ `;
116
+ }
@@ -2,7 +2,8 @@
2
2
  * A material blended from layers by a mask: `SurfaceMaterial.layers`, packed into `uLayers` by
3
3
  * `surfaceLayers.ts`.
4
4
  *
5
- * **Layer `i` is layer `i` of the material's arrays**, each read at its own repeat of where the
5
+ * **Layer `i` is layer `i` of the material's arrays** — or the layer `layerLooks.ts` picks, with the
6
+ * tint, ranges and normal strength it gives each — each read at its own repeat of where the
6
7
  * layers are laid: the mesh's coordinates, or the world's horizontal plane where the material has a
7
8
  * projection (`worldUv.ts`). The mask's channels weigh layers 1 to 4 — red, green, blue, alpha — and
8
9
  * come from a map read where a shading model's would be, from the ORM array's layer past the layers,
@@ -21,7 +22,8 @@
21
22
  * in uniform control flow (AGENTS.md 2026-08-07), so the loop is bounded by the layer count, a
22
23
  * uniform, and never skips on a share, which is the pixel's own.
23
24
  *
24
- * **A lit switch, `LAYERED`**, off until a material first asks, declared last so every switch before
25
- * it keeps its id. Compiled out, its four vectors are not declared and its calls are constants.
25
+ * **A lit switch, `LAYERED`**, off until a material first asks, declared after every switch before
26
+ * it so each keeps its id. Compiled out, its four vectors are not declared and its calls are
27
+ * constants. `looks` compiles `LAYER_LOOKS` in beside it, and means nothing without it.
26
28
  */
27
- export declare function layeredGlsl(on: boolean): string;
29
+ export declare function layeredGlsl(on: boolean, looks?: boolean): string;