@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
@@ -46,9 +46,16 @@ export interface MeshInstances {
46
46
  * read by a draw whose material is a `lightmapModel` — instances of one mesh in one material sit
47
47
  * in different regions of one page, each applied before the material's own region. **They ride
48
48
  * the tint and the opacity**, which a lightmapped instance gives up (see `lightmap.ts`): where
49
- * these are given, those are not read. **A lightmapped batch needs them**, since a bake is
49
+ * these are given, the tints are not read. **A lightmapped batch needs them**, since a bake is
50
50
  * different light at each instance; one drawn without them reads its tints as its regions, and
51
51
  * either mismatch is said once on the console.
52
+ *
53
+ * **The opacity survives, in sixty-fourths**, so a lightmapped instance fades by
54
+ * `setDitherOpacity` as any other does: its transparency travels as whole steps of 2 added to the
55
+ * region's U offset, which is a place in a page and so under 1, and the vertex stage takes them
56
+ * back off (`LIGHTMAP_OPACITY_STEPS`). Sixty-four is the screen door's own resolution, an 8x8
57
+ * pattern. **What it gives up**: a fading instance's offset keeps 16 bits of fraction rather than
58
+ * 23, a sixteenth of a texel on a 4,096 page; an opaque one is written to the bit as before.
52
59
  */
53
60
  readonly lightmapRegions?: Float32Array;
54
61
  /**
@@ -109,6 +116,12 @@ export function createMeshInstances(capacity: number): MeshInstances {
109
116
  */
110
117
  export const INSTANCE_FLOATS = 20;
111
118
 
119
+ /**
120
+ * How finely a lightmapped instance's opacity travels in its region: transparency in steps of
121
+ * 1/64, each 2 on the U offset. See `MeshInstances.lightmapRegions`; the vertex stage reads it back.
122
+ */
123
+ export const LIGHTMAP_OPACITY_STEPS = 64;
124
+
112
125
  /** Bytes one instance occupies. See `INSTANCE_FLOATS`. */
113
126
  export const INSTANCE_STRIDE = INSTANCE_FLOATS * 4;
114
127
 
@@ -138,6 +151,10 @@ export function packInstances(instances: MeshInstances, out: Float32Array): void
138
151
  if (lightmapRegions !== undefined) {
139
152
  /* The region in the tint's and the opacity's place. See `lightmapRegions`. */
140
153
  for (let c = 0; c < 4; c += 1) out[at + 16 + c] = lightmapRegions[i * 4 + c] as number;
154
+ /* And the opacity's transparency in whole steps of 2 on the U offset, none where opaque. */
155
+ const alpha = Math.min(Math.max(alphas?.[i] ?? 1, 0), 1);
156
+ const steps = Math.round((1 - alpha) * LIGHTMAP_OPACITY_STEPS);
157
+ if (steps > 0) out[at + 18] = (out[at + 18] as number) + 2 * steps;
141
158
  continue;
142
159
  }
143
160
  const t = i * 3;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Where ambient occlusion fades out with distance: `setAmbientOcclusionFade`'s two numbers as the
3
+ * estimate reads them, `uFade` in `shaders/ambientOcclusion.ts`.
4
+ *
5
+ * **Occlusion is whole up to `distance` metres from the eye and gone `radius` metres past it.** A
6
+ * screen-space estimate reaches as far as the depth does, and far off the depth buffer's steps are
7
+ * metres apart: a sky dome or a mountain range kilometres away is shaded in rings that follow
8
+ * those steps, and nothing there is in contact with anything the estimate could be right about.
9
+ * A pixel past the fade skips the estimate's walk, as the sky does, so it costs less as well.
10
+ *
11
+ * **What it gives up**: distant contact shade that is real — a doorway a hundred metres off — fades
12
+ * with the rest, and the distance is the eye's and not the subject's, so a long lens fades what it
13
+ * frames close up. What would make it wrong is a scene whose far depth is precise enough to shade,
14
+ * which is a reason to fade further, not to leave the fade off.
15
+ */
16
+
17
+ /** `uFade` for no fade: a distance below zero, which the estimate reads as "never". */
18
+ const NO_FADE = -1;
19
+
20
+ /**
21
+ * `distance` and `radius` into `out`, `[distance, radius]`. A distance that is not a finite number
22
+ * at or above zero is no fade, `[−1, 0]`, so `Infinity` reads as "never fades". A radius below zero
23
+ * or not a number is 0, which is a cut at `distance` rather than a ramp.
24
+ */
25
+ export function resolveOcclusionFade(distance: number, radius: number, out: Float32Array): void {
26
+ const fades = Number.isFinite(distance) && distance >= 0;
27
+ out[0] = fades ? distance : NO_FADE;
28
+ out[1] = fades && Number.isFinite(radius) ? Math.max(radius, 0) : 0;
29
+ }
@@ -800,6 +800,8 @@ export class SceneTarget {
800
800
  /** The projection's x and y scales, which turn a world radius into a screen one. */
801
801
  readonly projScale: Float32Array;
802
802
  readonly invProjection: Float32Array;
803
+ /** Where it fades out with distance, `uFade`: see `occlusionFade.ts`. */
804
+ readonly fade: Float32Array;
803
805
  /** Where the frame's jitter put the occlusion, in uv: see the shader's `uAoOffset`. */
804
806
  readonly offset?: Float32Array;
805
807
  },
@@ -959,6 +961,7 @@ export class SceneTarget {
959
961
  ao.radius,
960
962
  ao.projScale,
961
963
  ao.invProjection,
964
+ ao.fade,
962
965
  );
963
966
  }
964
967
 
@@ -76,6 +76,11 @@ uniform vec2 uProjScale;
76
76
  uniform mat4 uInvProjection;
77
77
  /** How far the sampling reaches, in metres of the world. */
78
78
  uniform float uRadius;
79
+ /**
80
+ * Where occlusion fades out: whole up to x metres from the eye, gone y metres past it, and no fade
81
+ * where x is below zero. See occlusionFade.ts.
82
+ */
83
+ uniform vec2 uFade;
79
84
 
80
85
  out float fragColor;
81
86
 
@@ -168,6 +173,12 @@ void main() {
168
173
  fragColor = 1.0;
169
174
  return;
170
175
  }
176
+ /* How far past the caller's fade this pixel is, and open with no walk where it is all the way. */
177
+ float faded = uFade.x < 0.0 ? 0.0 : clamp((length(p) - uFade.x) / max(uFade.y, 1e-4), 0.0, 1.0);
178
+ if (faded >= 1.0) {
179
+ fragColor = AO_STORE;
180
+ return;
181
+ }
171
182
 
172
183
  /*
173
184
  * At a silhouette, the quad derivative above is replaced by the one-sided difference that
@@ -308,7 +319,7 @@ void main() {
308
319
  visible += projectedLength * gtaoArc(h0, h1, normalAngle);
309
320
  }
310
321
 
311
- fragColor = clamp(visible / float(AO_SLICES) * AO_STORE, 0.0, 1.0);
322
+ fragColor = clamp(mix(visible / float(AO_SLICES), 1.0, faded) * AO_STORE, 0.0, 1.0);
312
323
  }
313
324
  `;
314
325
 
@@ -123,7 +123,7 @@ void areaLightAdd(
123
123
  lampOpen += areaDiffuse;
124
124
  lampShadowed += areaDiffuse * occl * glass;
125
125
 
126
- if (vSpecular > 0.0 || metal > 0.0) {
126
+ if (surfaceSpecular > 0.0 || metal > 0.0) {
127
127
  vec3 c0 = centre - right * halfSize.x + up * halfSize.y;
128
128
  vec3 c1 = centre + right * halfSize.x + up * halfSize.y;
129
129
  vec3 c2 = centre + right * halfSize.x - up * halfSize.y;
@@ -39,7 +39,8 @@ export type LitSwitch =
39
39
  | 'REFLECTION_MAPS'
40
40
  | 'WORLD_UVS'
41
41
  | 'MOVING_SUN'
42
- | 'LAYERED';
42
+ | 'LAYERED'
43
+ | 'LAYER_LOOKS';
43
44
 
44
45
  /** Every switch off: what a renderer starts from before its profile and its content say otherwise. */
45
46
  export function noLitSwitches(): Record<LitSwitch, boolean> {
@@ -55,6 +56,7 @@ export function noLitSwitches(): Record<LitSwitch, boolean> {
55
56
  WORLD_UVS: false,
56
57
  MOVING_SUN: false,
57
58
  LAYERED: false,
59
+ LAYER_LOOKS: false,
58
60
  };
59
61
  }
60
62
 
@@ -18,6 +18,7 @@ import { modelsGlsl } from './models.ts';
18
18
  import { AREA_LIGHT_GLSL } from './areaLight.ts';
19
19
  import { NORMAL_MAP_GLSL } from './normalMap.ts';
20
20
  import { DRIFT_LIGHT_GLSL } from './driftLight.ts';
21
+ import { LIGHTMAP_OPACITY_STEPS } from '../../instances.ts';
21
22
  import { SURFACE_EFFECTS_GLSL } from './surfaceEffects.ts';
22
23
  import { overlayGlsl } from './overlay.ts';
23
24
  import { reflectionSurfaceGlsl } from './reflectionSurface.ts';
@@ -342,10 +343,16 @@ void main() {
342
343
  vAlpha = aInstanceTint.w;
343
344
  vThickness = 1.0;
344
345
  if (LIGHTMAP_REGIONS) {
346
+ /*
347
+ * The opacity's transparency rides the U offset as whole steps of 2, the offset itself under 1
348
+ * (\`MeshInstances.lightmapRegions\`): taken off, and none where the instance is opaque.
349
+ */
350
+ float steps = floor(aInstanceTint.z * 0.5);
351
+ float offsetU = aInstanceTint.z - 2.0 * steps;
345
352
  /* Out of the lane's \`-1 - uv\`, through the region, and back into it. See lightmap.ts. */
346
- vGrain = -1.0 - ((-1.0 - aGrain) * aInstanceTint.x + aInstanceTint.z);
353
+ vGrain = -1.0 - ((-1.0 - aGrain) * aInstanceTint.x + offsetU);
347
354
  vRelief = -1.0 - ((-1.0 - aRelief) * aInstanceTint.y + aInstanceTint.w);
348
- vAlpha = 1.0;
355
+ vAlpha = 1.0 - steps / ${LIGHTMAP_OPACITY_STEPS.toFixed(1)};
349
356
  }
350
357
  #else
351
358
  vSkyDirect = aChannel.y;
@@ -527,6 +534,11 @@ export interface FlatShaderOptions {
527
534
  * True when absent. See \`layeredGlsl\`.
528
535
  */
529
536
  readonly layered?: boolean;
537
+ /**
538
+ * Whether a layered material's layers are picked from shared arrays and given looks of their own,
539
+ * \`uLayerLooks\` and all. True when absent; nothing without \`layered\`. See \`layerLooksGlsl\`.
540
+ */
541
+ readonly layerLooks?: boolean;
530
542
  /**
531
543
  * How many point lights this build declares room for. `MAX_POINT_LIGHTS` when absent.
532
544
  *
@@ -679,7 +691,7 @@ export function flatFrag(options: FlatShaderOptions): string {
679
691
  /* After the reflection maps, so its switch takes the next id; main calls it. worldUv.ts. */
680
692
  worldUvGlsl(options.worldUvs ?? true),
681
693
  /* After the projection, so its switch takes the next id; the normal map below calls it. */
682
- layeredGlsl(options.layered ?? true),
694
+ layeredGlsl(options.layered ?? true, options.layerLooks ?? true),
683
695
  /* Where each map is read, after both of the ways it can be read otherwise. See mapReads.ts. */
684
696
  MAP_READS_GLSL,
685
697
  /* After the frame and the projection it calls, unconditional for the same reason as the
@@ -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: boolean): string {
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
+ }
@@ -1,106 +1,239 @@
1
+ import { layerLooksGlsl } from './layerLooks.ts';
2
+
1
3
  /**
2
4
  * A material blended from layers by a mask: `SurfaceMaterial.layers`, packed into `uLayers` by
3
5
  * `surfaceLayers.ts`.
4
6
  *
5
- * **Layer `i` is layer `i` of the material's arrays**, each read at the mesh's coordinates times its
6
- * own repeat; the mask is layer 0 of the map a shading model would read, at the mesh's coordinates.
7
- * Each channel lays a layer over everything before it — red layer 1, green 2, blue 3, alpha 4 — so a
8
- * layer's share is its channel times one less each later channel, and the base keeps what none of
9
- * them took. Colour, roughness and occlusion are blended by those shares, and the normal map's
10
- * tangent-space texels too, before the frame turns them into the world.
7
+ * **Layer `i` is layer `i` of the material's arrays** — or the layer `layerLooks.ts` picks, with the
8
+ * tint, ranges and normal strength it gives each — each read at its own repeat of where the
9
+ * layers are laid: the mesh's coordinates, or the world's horizontal plane where the material has a
10
+ * projection (`worldUv.ts`). The mask's channels weigh layers 1 to 4 — red, green, blue, alpha — and
11
+ * come from a map read where a shading model's would be, from the ORM array's layer past the layers,
12
+ * or from the vertex colour; an added mask placed as the layers are raises one layer's weight, and
13
+ * one layer may be weighed by how much the surface faces up instead of by its channel.
14
+ *
15
+ * **Two ways to combine them.** Laid over, each layer covers everything before it, so a layer's
16
+ * share is its weight times one less each later weight, and the base keeps what none took. Summed,
17
+ * the base is mixed toward each layer times its weight by the weights' sum held to 1, so each
18
+ * layer's share is its weight times that and the base keeps the rest. The shares are worked out once
19
+ * a pixel, in `layeredBegin`; colour, roughness and occlusion are blended by them, and the normal
20
+ * map's tangent-space texels too, before the frame turns them into the world — around a normal read
21
+ * at the mesh's own coordinates, where the material lays one under the layers.
11
22
  *
12
23
  * **Every layer present is read, whatever its share.** A read whose level is implicit is legal only
13
24
  * in uniform control flow (AGENTS.md 2026-08-07), so the loop is bounded by the layer count, a
14
25
  * uniform, and never skips on a share, which is the pixel's own.
15
26
  *
16
- * **A lit switch, `LAYERED`**, off until a material first asks, declared last so every switch before
17
- * it keeps its id. Compiled out, its two vectors are not declared and its calls are constants.
27
+ * **A lit switch, `LAYERED`**, off until a material first asks, declared after every switch before
28
+ * it so each keeps its id. Compiled out, its four vectors are not declared and its calls are
29
+ * constants. `looks` compiles `LAYER_LOOKS` in beside it, and means nothing without it.
18
30
  */
19
- export function layeredGlsl(on: boolean): string {
31
+ export function layeredGlsl(on: boolean, looks = false): string {
20
32
  if (!on) {
21
33
  return /* glsl */ `
22
34
  const bool LAYERED = false; // wgsl:override
23
35
  bool layered() { return false; }
24
36
  void layeredBegin() {}
37
+ vec3 layeredColor(vec3 color) { return color; }
25
38
  vec4 layeredAlbedo() { return vec4(1.0); }
26
39
  vec3 layeredOrm() { return vec3(1.0); }
27
40
  vec3 layeredTexel() { return vec3(0.0, 0.0, 1.0); }
41
+ bool layeredMeshNormal() { return false; }
42
+ vec3 layeredMeshTexel() { return vec3(0.0, 0.0, 1.0); }
28
43
  bool layeredGlows() { return false; }
29
44
  vec3 layeredEmissive() { return vec3(0.0); }
45
+ float layeredAmbient() { return 1.0; }
46
+ float layeredSpecular(float own) { return own; }
30
47
  `;
31
48
  }
32
49
  return /* glsl */ `
33
50
  /* Whether this program blends a material's layers. Off but where one has asked: layered.ts. */
34
51
  const bool LAYERED = true; // wgsl:override
35
- /* The five repeats, base first (x y z w, then x), the count (y) and the glowing layer, or -1 (z). */
36
- uniform vec4 uLayers[2]; // wgsl:material
37
-
38
- /* The mask's channels that lay a layer, zero past the count: read once, in layeredBegin. */
39
- vec4 lMask;
52
+ /*
53
+ * Four vectors, as surfaceLayers.ts packs them: the five repeats, base first (x y z w, then x); the
54
+ * count (y), the glowing layer or -1 (z) and where the mask is, 0 a map, 1 the ORM array, 2 the
55
+ * vertex colour (w); the blend, 0 over and 1 summed (x), whether a mesh normal is laid under the
56
+ * layers (y), the added mask's layer or -1 (z) and its repeat (w); its intensity (x), the facing
57
+ * layer or -1 (y), its bias (z) and its sharpness (w).
58
+ */
59
+ uniform vec4 uLayers[4]; // wgsl:material
60
+ ${layerLooksGlsl(looks)}
61
+ /* Each layer's share of this point, the base's and layers 1 to 4's: worked out once, in layeredBegin. */
62
+ float lShareBase;
63
+ vec4 lShare;
64
+ /* What an occlusion read at the mesh's coordinates leaves of this point, 1 where none: layeredBegin. */
65
+ float lMeshOcclusion;
40
66
 
41
67
  bool layered() {
42
68
  return uLayers[1].y > 0.5;
43
69
  }
44
70
 
45
- /* The mask, at the mesh's coordinates, where a model's map is. Under a branch on uniforms. */
46
- void layeredBegin() {
47
- vec4 m = texture(uModelMap, vec3(vUv.xy, 0.0));
48
- float n = uLayers[1].y;
49
- lMask = vec4(n > 1.5 ? m.r : 0.0, n > 2.5 ? m.g : 0.0, n > 3.5 ? m.b : 0.0, n > 4.5 ? m.a : 0.0);
71
+ float layerRepeat(int i) {
72
+ return i < 4 ? uLayers[0][i] : uLayers[1].x;
50
73
  }
51
74
 
52
- /* Layer i's share of this point: its channel, less every later layer's. */
53
- float layerShare(int i) {
54
- float keep = 1.0;
55
- for (int j = 3; j >= 0; j--) {
56
- if (j + 1 == i) return lMask[j] * keep;
57
- keep *= 1.0 - lMask[j];
75
+ /* Where the layers are laid: the world's horizontal plane under a projection, the mesh's otherwise. */
76
+ vec2 layerPlace() {
77
+ if (WORLD_UVS && projected()) return planarAt();
78
+ return vUv.xy;
79
+ }
80
+
81
+ vec3 layerAt(int i) {
82
+ float across = layerRepeat(i);
83
+ return vec3(layerPlace() * vec2(across, layerRepeatDown(i, across)), layerArray(i));
84
+ }
85
+
86
+ /* The weights as the material keeps them: the mask's channels, at the mesh's own coordinates. */
87
+ vec4 layerWeights() {
88
+ float source = uLayers[1].w;
89
+ if (source > 1.5) return vec4(vColor, 0.0);
90
+ if (source > 0.5) return texture(uOrmMap, vec3(vUv.xy, layerExtras()));
91
+ return texture(uModelMap, vec3(vUv.xy, 0.0));
92
+ }
93
+
94
+ /* Which of layers 1 to 4 a packed layer number names, as a vector of one 1: none for -1. */
95
+ vec4 layerPick(float layer) {
96
+ return vec4(equal(vec4(layer), vec4(1.0, 2.0, 3.0, 4.0)));
97
+ }
98
+
99
+ /*
100
+ * The weights, changed where the material asks and zero past the count, and every share from them.
101
+ * Under a branch on uniforms, as its reads are.
102
+ */
103
+ void layeredBegin() {
104
+ /*
105
+ * The occlusion at the mesh's coordinates, once: the ORM array's layer past the layers and past
106
+ * any mask and added mask it carries, spread over its range and taken at its strength.
107
+ */
108
+ lMeshOcclusion = 1.0;
109
+ float strength = layerOcclusionStrength();
110
+ if (strength > 0.0) {
111
+ float at = layerExtras() + (abs(uLayers[1].w - 1.0) < 0.5 ? 1.0 : 0.0) + (uLayers[2].z > 0.5 ? 1.0 : 0.0);
112
+ vec2 range = layerOcclusionRange();
113
+ float occlusion = mix(range.x, range.y, texture(uOrmMap, vec3(vUv.xy, at)).r);
114
+ lMeshOcclusion = mix(1.0, occlusion, strength);
115
+ }
116
+ vec4 m = layerWeights();
117
+ if (uLayers[2].z > 0.5) {
118
+ /* The ORM array's layer past the layers, and past the mask where the array carries that too. */
119
+ float at = layerExtras() + (abs(uLayers[1].w - 1.0) < 0.5 ? 1.0 : 0.0);
120
+ float added = texture(uOrmMap, vec3(layerPlace() * uLayers[2].w, at)).r * uLayers[3].x;
121
+ m = mix(m, clamp(m + added, 0.0, 1.0), layerPick(uLayers[2].z));
122
+ }
123
+ if (uLayers[3].y > 0.5) {
124
+ float up = normalize(vNormal).y * 0.5 + 0.5;
125
+ float facing = clamp(uLayers[3].z + uLayers[3].w * up, 0.0, 1.0);
126
+ m = mix(m, vec4(facing), layerPick(uLayers[3].y));
58
127
  }
59
- return keep;
128
+ float n = uLayers[1].y;
129
+ m *= vec4(n > 1.5 ? 1.0 : 0.0, n > 2.5 ? 1.0 : 0.0, n > 3.5 ? 1.0 : 0.0, n > 4.5 ? 1.0 : 0.0);
130
+ if (uLayers[2].x > 0.5) {
131
+ float taken = min(m.x + m.y + m.z + m.w, 1.0);
132
+ lShare = m * taken;
133
+ lShareBase = 1.0 - taken;
134
+ return;
135
+ }
136
+ /* Each over everything before it: a layer keeps its weight of what every later one leaves. */
137
+ float keep = 1.0;
138
+ lShare.w = m.w * keep;
139
+ keep *= 1.0 - m.w;
140
+ lShare.z = m.z * keep;
141
+ keep *= 1.0 - m.z;
142
+ lShare.y = m.y * keep;
143
+ keep *= 1.0 - m.y;
144
+ lShare.x = m.x * keep;
145
+ keep *= 1.0 - m.x;
146
+ lShareBase = keep;
60
147
  }
61
148
 
62
- float layerRepeat(int i) {
63
- return i < 4 ? uLayers[0][i] : uLayers[1].x;
149
+ float layerShare(int i) {
150
+ if (i == 0) return lShareBase;
151
+ return lShare[i - 1];
64
152
  }
65
153
 
66
- vec3 layerAt(int i) {
67
- return vec3(vUv.xy * layerRepeat(i), float(i));
154
+ /* The vertex colour's part in the albedo: none where it is the mask instead. */
155
+ vec3 layeredColor(vec3 color) {
156
+ return uLayers[1].w > 1.5 ? vec3(1.0) : color;
68
157
  }
69
158
 
70
- /* Colour by the shares, and the base layer's coverage, which a cutout reads. */
159
+ /*
160
+ * Colour by the shares, each layer's tinted, and the base layer's coverage, which a cutout reads;
161
+ * then darkened by the occlusion at the mesh's coordinates, where it is the colour that takes it.
162
+ */
71
163
  vec4 layeredAlbedo() {
72
164
  vec4 sum = vec4(0.0);
73
165
  for (int i = 0; i < 5; i++) {
74
166
  if (float(i) >= uLayers[1].y) break;
75
167
  vec4 t = texture(uAlbedo, layerAt(i));
76
- sum.rgb += t.rgb * layerShare(i);
168
+ sum.rgb += t.rgb * layerTint(i) * layerShare(i);
77
169
  if (i == 0) sum.a = t.a;
78
170
  }
171
+ if (!layerOcclusionAmbient()) sum.rgb *= lMeshOcclusion;
79
172
  return sum;
80
173
  }
81
174
 
175
+ /* What the ambient light keeps: the occlusion at the mesh's coordinates, where it takes it. */
176
+ float layeredAmbient() {
177
+ return layerOcclusionAmbient() ? lMeshOcclusion : 1.0;
178
+ }
179
+
180
+ /*
181
+ * The specular by the shares, each layer's own or the surface's where it names none. The surface's
182
+ * own, untouched, where no material has asked for looks: shares summing to one in floating point
183
+ * are not quite one, and a layered material that names nothing must draw exactly as before.
184
+ */
185
+ float layeredSpecular(float own) {
186
+ if (LAYER_LOOKS) {
187
+ float sum = 0.0;
188
+ for (int i = 0; i < 5; i++) {
189
+ if (float(i) >= uLayers[1].y) break;
190
+ sum += layerSpecular(i, own) * layerShare(i);
191
+ }
192
+ return sum;
193
+ }
194
+ return own;
195
+ }
196
+
197
+ /* Occlusion, roughness and metalness by the shares, each layer's spread over its own ranges. */
82
198
  vec3 layeredOrm() {
83
199
  vec3 sum = vec3(0.0);
84
200
  for (int i = 0; i < 5; i++) {
85
201
  if (float(i) >= uLayers[1].y) break;
86
- sum += texture(uOrmMap, layerAt(i)).rgb * layerShare(i);
202
+ vec3 t = texture(uOrmMap, layerAt(i)).rgb;
203
+ t.yz = layerRanges(i, t.yz);
204
+ sum += t * layerShare(i);
87
205
  }
88
206
  return sum;
89
207
  }
90
208
 
91
- /* The tangent-space texels blended, each with its z rebuilt where a two-channel map stored none. */
209
+ /* A tangent-space texel off the normal array, its z rebuilt where a two-channel map stored none. */
210
+ vec3 layerNormalTexel(vec3 at) {
211
+ vec3 t = texture(uNormalMap, at).xyz * 2.0 - 1.0;
212
+ t.z = t.z > 0.0 ? t.z : sqrt(max(0.0, 1.0 - dot(t.xy, t.xy)));
213
+ return t;
214
+ }
215
+
216
+ /* The tangent-space texels blended by the shares. */
92
217
  vec3 layeredTexel() {
93
218
  vec3 sum = vec3(0.0);
94
219
  for (int i = 0; i < 5; i++) {
95
220
  if (float(i) >= uLayers[1].y) break;
96
- vec3 t = texture(uNormalMap, layerAt(i)).xyz * 2.0 - 1.0;
97
- t.z = t.z > 0.0 ? t.z : sqrt(max(0.0, 1.0 - dot(t.xy, t.xy)));
98
- sum += t * layerShare(i);
221
+ sum += layerStrengthen(i, layerNormalTexel(layerAt(i))) * layerShare(i);
99
222
  }
100
223
  float len = length(sum);
101
224
  return len > 1e-5 ? sum / len : vec3(0.0, 0.0, 1.0);
102
225
  }
103
226
 
227
+ /* Whether a normal read at the mesh's own coordinates lies under the layers. */
228
+ bool layeredMeshNormal() {
229
+ return uLayers[2].y > 0.5;
230
+ }
231
+
232
+ /* That normal's texel: the normal array's layer past the layers, at the mesh's coordinates. */
233
+ vec3 layeredMeshTexel() {
234
+ return layerNormalTexel(vec3(vUv.xy, layerExtras()));
235
+ }
236
+
104
237
  /* Whether the emissive map belongs to one layer rather than to the whole surface. */
105
238
  bool layeredGlows() {
106
239
  return uLayers[1].z > -0.5;
@@ -18,6 +18,9 @@ export const MAIN_GLSL = `void main() {
18
18
  projectSurface(surfaceAt, frameAt, frameTangents);
19
19
  /* A layered material's mask, read once for every layer's share below: layered.ts. */
20
20
  if (LAYERED && layered()) layeredBegin();
21
+ /* And the specular every highlight below reads: its layers' own, where a material gives them. */
22
+ surfaceSpecular = vSpecular;
23
+ if (LAYERED && layered()) surfaceSpecular = layeredSpecular(vSpecular);
21
24
  if (dot(vec4(vWorldPos, 1.0), uClipPlane) < 0.0) discard;
22
25
  /*
23
26
  * The layer's surface effects, zeros where the albedo array carries no table — see
@@ -102,6 +105,8 @@ export const MAIN_GLSL = `void main() {
102
105
  }
103
106
  }
104
107
  }
108
+ /* A layered material whose mask is the vertex colour takes no tint from it: layered.ts. */
109
+ if (LAYERED && layered()) albedo = layeredColor(albedo);
105
110
  albedo *= texel.rgb;
106
111
  coverage = kept;
107
112
  }
@@ -172,7 +177,8 @@ export const MAIN_GLSL = `void main() {
172
177
  /*
173
178
  * **A thin surface lets light from behind through, coloured by itself**: a banner, a leaf, a
174
179
  * lampshade, an opaque material's \`diffuseTransmission\`, in the glass tint's spare lane. It
175
- * gathers the light behind it in glassGlow, as a pane does, and adds it through its own colour.
180
+ * gathers the light behind it in glassGlow, as a pane does, and adds it through its own colour,
181
+ * or through the \`transmissionColor\` it names in the tint's colour lanes (transmission.ts).
176
182
  * None on glass, which has its own way through.
177
183
  */
178
184
  float thin = glassTransmission > 0.0 ? 0.0 : uGlassTint.w;
@@ -508,6 +514,8 @@ export const MAIN_GLSL = `void main() {
508
514
  #endif
509
515
  /* A draw's own ambient, where one is set, in place of the frame's: \`setAmbientSH\`. */
510
516
  if (uAmbientSH[6].w > 0.5) ambient = ambientHarmonics(n);
517
+ /* Less of it where a layered material's mesh occlusion darkens the ambient light: layered.ts. */
518
+ if (LAYERED && layered()) ambient *= layeredAmbient();
511
519
  /*
512
520
  * A metal keeps its ambient and loses its direct diffuse.
513
521
  *
@@ -561,7 +569,7 @@ export const MAIN_GLSL = `void main() {
561
569
  * mix(x, y, 0) is exactly x, so at metal 0 this is vec3(vSpecular) and the line below is the
562
570
  * one it replaces with the scalar broadened to three components.
563
571
  */
564
- vec3 specColor = mix(vec3(vSpecular), albedo, metal);
572
+ vec3 specColor = mix(vec3(surfaceSpecular), albedo, metal);
565
573
  /*
566
574
  * **A metal's highlight goes white at a grazing angle, and that is most of what reads as
567
575
  * polished.**
@@ -1654,7 +1662,7 @@ export const MAIN_GLSL = `void main() {
1654
1662
  * any lamp in the scene. The condition is the old one exactly when metal is 0, so nothing
1655
1663
  * that binds no map takes a different branch than it took before.
1656
1664
  */
1657
- if (vSpecular > 0.0 || metal > 0.0) {
1665
+ if (surfaceSpecular > 0.0 || metal > 0.0) {
1658
1666
  vec3 toEyeLamp = normalize(uCameraPos - vWorldPos);
1659
1667
  vec3 lampHalfway = normalize(toLight / max(dist, 1e-4) + toEyeLamp);
1660
1668
  /* The same grazing whitening the sun's highlight takes, and zero at metal 0 for the look. */
@@ -1957,8 +1965,12 @@ export const MAIN_GLSL = `void main() {
1957
1965
  * underneath: a ceiling emitting a dull warm haze over a pale panel cannot be expressed
1958
1966
  * by scaling the panel, and scaling it gets the brightness right and the hue wrong.
1959
1967
  */
1960
- /* What a thin surface lets through from behind, through its own colour; a metal lets none. */
1961
- if (thin > 0.0) lit += albedo * glassGlow * (thin * (1.0 - metal));
1968
+ /* What a thin surface lets through from behind, in its own colour or the one it names (below
1969
+ zero: none named, transmission.ts); a metal lets none. */
1970
+ if (thin > 0.0) {
1971
+ vec3 through = uGlassTint.r < 0.0 ? albedo : uGlassTint.rgb;
1972
+ lit += through * glassGlow * (thin * (1.0 - metal));
1973
+ }
1962
1974
  diffuseAlone = lit;
1963
1975
  vec3 emissiveTint = vEmissiveColor.r < 0.0 ? albedo : vEmissiveColor;
1964
1976
  /*