@driftengine/core 4.8.3 → 4.8.5

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 (180) hide show
  1. package/CHANGELOG.json +82 -0
  2. package/README.md +1 -1
  3. package/dist/build/shaderComments.js +0 -0
  4. package/dist/geometry/meshConcat.js +2 -0
  5. package/dist/index.d.ts +10 -4
  6. package/dist/index.js +16 -1
  7. package/dist/math/scalar.js +1 -1
  8. package/dist/render/anisotropicLobe.d.ts +7 -0
  9. package/dist/render/anisotropicLobe.js +33 -0
  10. package/dist/render/backend/api.d.ts +41 -4
  11. package/dist/render/backend/select.js +8 -0
  12. package/dist/render/backend/webgl2/clothTextures.d.ts +27 -0
  13. package/dist/render/backend/webgl2/clothTextures.js +93 -0
  14. package/dist/render/backend/webgl2/renderer.d.ts +136 -3
  15. package/dist/render/backend/webgl2/renderer.js +604 -63
  16. package/dist/render/backend/webgl2/skinScatter.d.ts +50 -0
  17. package/dist/render/backend/webgl2/skinScatter.js +256 -0
  18. package/dist/render/backend/webgpu/buffers.d.ts +25 -11
  19. package/dist/render/backend/webgpu/buffers.js +98 -49
  20. package/dist/render/backend/webgpu/clothSolvePack.d.ts +27 -0
  21. package/dist/render/backend/webgpu/clothSolvePack.js +129 -0
  22. package/dist/render/backend/webgpu/clothTextures.d.ts +68 -0
  23. package/dist/render/backend/webgpu/clothTextures.js +145 -0
  24. package/dist/render/backend/webgpu/compressedUpload.d.ts +18 -0
  25. package/dist/render/backend/webgpu/compressedUpload.js +38 -0
  26. package/dist/render/backend/webgpu/computeHost.d.ts +21 -0
  27. package/dist/render/backend/webgpu/computeHost.js +5 -0
  28. package/dist/render/backend/webgpu/depthPass.d.ts +16 -6
  29. package/dist/render/backend/webgpu/depthPass.js +39 -9
  30. package/dist/render/backend/webgpu/drawCommand.js +6 -2
  31. package/dist/render/backend/webgpu/flatPass.d.ts +38 -4
  32. package/dist/render/backend/webgpu/flatPass.js +109 -12
  33. package/dist/render/backend/webgpu/gpuSkinnedCloth.d.ts +74 -0
  34. package/dist/render/backend/webgpu/gpuSkinnedCloth.js +452 -0
  35. package/dist/render/backend/webgpu/motionPass.d.ts +20 -0
  36. package/dist/render/backend/webgpu/motionPass.js +106 -9
  37. package/dist/render/backend/webgpu/postPass.d.ts +3 -1
  38. package/dist/render/backend/webgpu/postPass.js +6 -3
  39. package/dist/render/backend/webgpu/renderer.d.ts +113 -5
  40. package/dist/render/backend/webgpu/renderer.js +498 -48
  41. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.d.ts +33 -0
  42. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.js +469 -0
  43. package/dist/render/backend/webgpu/skinScatterPass.d.ts +51 -0
  44. package/dist/render/backend/webgpu/skinScatterPass.js +253 -0
  45. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +4 -1
  46. package/dist/render/backend/webgpu/surfaceTexturePass.js +37 -12
  47. package/dist/render/clothBindingData.d.ts +41 -0
  48. package/dist/render/clothBindingData.js +72 -0
  49. package/dist/render/clothPlacement.d.ts +25 -0
  50. package/dist/render/clothPlacement.js +85 -0
  51. package/dist/render/compressedSource.d.ts +66 -0
  52. package/dist/render/compressedSource.js +138 -0
  53. package/dist/render/cutoutCaster.d.ts +2 -0
  54. package/dist/render/cutoutCaster.js +2 -0
  55. package/dist/render/cutoutDither.d.ts +51 -0
  56. package/dist/render/cutoutDither.js +90 -0
  57. package/dist/render/eyeRefraction.d.ts +35 -0
  58. package/dist/render/eyeRefraction.js +40 -0
  59. package/dist/render/glCompressed.d.ts +27 -0
  60. package/dist/render/glCompressed.js +53 -0
  61. package/dist/render/hairLobes.d.ts +70 -0
  62. package/dist/render/hairLobes.js +120 -0
  63. package/dist/render/lightBudget.d.ts +22 -0
  64. package/dist/render/lightBudget.js +22 -0
  65. package/dist/render/mesh.d.ts +7 -0
  66. package/dist/render/mesh.js +24 -0
  67. package/dist/render/recon/motionVectors.d.ts +4 -2
  68. package/dist/render/recon/motionVectors.js +6 -3
  69. package/dist/render/renderQuality.d.ts +20 -0
  70. package/dist/render/renderQuality.js +5 -0
  71. package/dist/render/rendererHarness.js +4 -0
  72. package/dist/render/sceneTarget.d.ts +12 -0
  73. package/dist/render/sceneTarget.js +14 -0
  74. package/dist/render/shaders/clothBinding.d.ts +22 -0
  75. package/dist/render/shaders/clothBinding.js +80 -0
  76. package/dist/render/shaders/depth.js +42 -6
  77. package/dist/render/shaders/flat/anisotropic.d.ts +15 -0
  78. package/dist/render/shaders/flat/anisotropic.js +68 -0
  79. package/dist/render/shaders/flat/eye.d.ts +1 -0
  80. package/dist/render/shaders/flat/eye.js +81 -0
  81. package/dist/render/shaders/flat/hair.d.ts +1 -0
  82. package/dist/render/shaders/flat/hair.js +101 -0
  83. package/dist/render/shaders/flat/index.js +36 -2
  84. package/dist/render/shaders/flat/main.d.ts +1 -1
  85. package/dist/render/shaders/flat/main.js +113 -17
  86. package/dist/render/shaders/flat/models.d.ts +14 -0
  87. package/dist/render/shaders/flat/models.js +273 -0
  88. package/dist/render/shaders/flat/preamble.js +26 -24
  89. package/dist/render/shaders/flat/skin.d.ts +1 -0
  90. package/dist/render/shaders/flat/skin.js +114 -0
  91. package/dist/render/shaders/generated/depth.wgsl.d.ts +38 -16
  92. package/dist/render/shaders/generated/depth.wgsl.js +38 -16
  93. package/dist/render/shaders/generated/flat.wgsl.d.ts +1790 -1704
  94. package/dist/render/shaders/generated/flat.wgsl.js +1828 -1742
  95. package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +48 -0
  96. package/dist/render/shaders/generated/skinBlur.wgsl.js +54 -0
  97. package/dist/render/shaders/recon/motion.wgsl.d.ts +1 -1
  98. package/dist/render/shaders/recon/motion.wgsl.js +102 -0
  99. package/dist/render/shaders/skinBlur.d.ts +1 -0
  100. package/dist/render/shaders/skinBlur.js +103 -0
  101. package/dist/render/shaders/skinning.d.ts +3 -1
  102. package/dist/render/shaders/skinning.js +31 -4
  103. package/dist/render/shadowCasters.d.ts +13 -1
  104. package/dist/render/skinBlur.d.ts +41 -0
  105. package/dist/render/skinBlur.js +49 -0
  106. package/dist/render/skinScatter.d.ts +67 -0
  107. package/dist/render/skinScatter.js +80 -0
  108. package/dist/render/skinnedClothRun.d.ts +26 -0
  109. package/dist/render/skinnedClothRun.js +84 -0
  110. package/dist/render/surfaceModel.d.ts +129 -0
  111. package/dist/render/surfaceModel.js +152 -0
  112. package/dist/render/surfaceTexture.d.ts +44 -4
  113. package/dist/render/surfaceTexture.js +60 -46
  114. package/dist/render/vertexDefaults.js +7 -0
  115. package/package.json +6 -6
  116. package/src/build/shaderComments.ts +0 -0
  117. package/src/geometry/meshConcat.ts +2 -0
  118. package/src/index.ts +40 -1
  119. package/src/math/scalar.ts +1 -1
  120. package/src/render/anisotropicLobe.ts +41 -0
  121. package/src/render/backend/api.ts +52 -3
  122. package/src/render/backend/select.ts +8 -0
  123. package/src/render/backend/webgl2/clothTextures.ts +120 -0
  124. package/src/render/backend/webgl2/renderer.ts +693 -64
  125. package/src/render/backend/webgl2/skinScatter.ts +309 -0
  126. package/src/render/backend/webgpu/buffers.ts +137 -63
  127. package/src/render/backend/webgpu/clothSolvePack.ts +161 -0
  128. package/src/render/backend/webgpu/clothTextures.ts +194 -0
  129. package/src/render/backend/webgpu/compressedUpload.ts +65 -0
  130. package/src/render/backend/webgpu/computeHost.ts +25 -0
  131. package/src/render/backend/webgpu/depthPass.ts +39 -7
  132. package/src/render/backend/webgpu/drawCommand.ts +6 -2
  133. package/src/render/backend/webgpu/flatPass.ts +145 -7
  134. package/src/render/backend/webgpu/gpuSkinnedCloth.ts +502 -0
  135. package/src/render/backend/webgpu/motionPass.ts +128 -9
  136. package/src/render/backend/webgpu/postPass.ts +5 -1
  137. package/src/render/backend/webgpu/renderer.ts +663 -43
  138. package/src/render/backend/webgpu/shaders/clothSolve.wgsl.ts +470 -0
  139. package/src/render/backend/webgpu/skinScatterPass.ts +309 -0
  140. package/src/render/backend/webgpu/surfaceTexturePass.ts +42 -12
  141. package/src/render/clothBindingData.ts +89 -0
  142. package/src/render/clothPlacement.ts +114 -0
  143. package/src/render/compressedSource.ts +198 -0
  144. package/src/render/cutoutCaster.ts +5 -0
  145. package/src/render/cutoutDither.ts +99 -0
  146. package/src/render/eyeRefraction.ts +72 -0
  147. package/src/render/glCompressed.ts +86 -0
  148. package/src/render/hairLobes.ts +188 -0
  149. package/src/render/lightBudget.ts +25 -0
  150. package/src/render/mesh.ts +23 -0
  151. package/src/render/recon/motionVectors.ts +6 -3
  152. package/src/render/renderQuality.ts +28 -0
  153. package/src/render/rendererHarness.ts +4 -0
  154. package/src/render/sceneTarget.ts +21 -0
  155. package/src/render/shaders/clothBinding.ts +81 -0
  156. package/src/render/shaders/depth.ts +42 -6
  157. package/src/render/shaders/flat/anisotropic.ts +68 -0
  158. package/src/render/shaders/flat/eye.ts +82 -0
  159. package/src/render/shaders/flat/hair.ts +102 -0
  160. package/src/render/shaders/flat/index.ts +36 -2
  161. package/src/render/shaders/flat/main.ts +113 -17
  162. package/src/render/shaders/flat/models.ts +286 -0
  163. package/src/render/shaders/flat/preamble.ts +26 -24
  164. package/src/render/shaders/flat/skin.ts +116 -0
  165. package/src/render/shaders/generated/depth.wgsl.ts +38 -16
  166. package/src/render/shaders/generated/flat.wgsl.ts +1828 -1742
  167. package/src/render/shaders/generated/skinBlur.wgsl.ts +56 -0
  168. package/src/render/shaders/recon/motion.wgsl.ts +102 -0
  169. package/src/render/shaders/skinBlur.ts +105 -0
  170. package/src/render/shaders/skinning.ts +31 -4
  171. package/src/render/shadowCasters.ts +13 -0
  172. package/src/render/skinBlur.ts +53 -0
  173. package/src/render/skinScatter.ts +92 -0
  174. package/src/render/skinnedClothRun.ts +128 -0
  175. package/src/render/surfaceModel.ts +274 -0
  176. package/src/render/surfaceTexture.ts +123 -63
  177. package/src/render/vertexDefaults.ts +7 -0
  178. package/dist/math/exact.d.ts +0 -76
  179. package/dist/math/exact.js +0 -327
  180. package/src/math/exact.ts +0 -340
@@ -3,15 +3,18 @@ import { OCTAHEDRAL_GLSL } from '../octahedral.ts';
3
3
  import { PROBEGRID_GLSL } from './probeGrid.ts';
4
4
  import { TANGENT_FRAME_GLSL } from '../tangentFrame.ts';
5
5
  import { CUTOUT_COVERAGE_GLSL } from '../cutoutCoverage.ts';
6
+ import { CUTOUT_DITHER_GLSL } from '../../cutoutDither.ts';
6
7
  import { preambleGlsl } from './preamble.ts';
7
8
  import { LOBES_GLSL } from './lobes.ts';
8
9
  import { POINTSHADOW_GLSL } from './pointShadow.ts';
9
10
  import { DIRECTIONALSHADOW_GLSL } from './directionalShadow.ts';
10
11
  import { glassTintGlsl, litSwitchesGlsl } from './glassTint.ts';
11
12
  import { MORPH_GLSL } from '../morph.ts';
13
+ import { CLOTH_BINDING_GLSL } from '../clothBinding.ts';
12
14
  import { SKINNING_GLSL } from '../skinning.ts';
13
15
  import { SURFACE_GLSL } from './surface.ts';
14
16
  import { MAIN_GLSL } from './main.ts';
17
+ import { MODELS_GLSL } from './models.ts';
15
18
  import { DRIFT_LIGHT_GLSL } from './driftLight.ts';
16
19
  import { SURFACE_EFFECTS_GLSL } from './surfaceEffects.ts';
17
20
  import { CHANNEL_ATTRIBUTE, CHANNEL_BEND } from '../vertexChannel.ts';
@@ -145,6 +148,10 @@ ${CHANNEL_BEND}
145
148
  #if MORPHED
146
149
  ${MORPH_GLSL}
147
150
  #endif
151
+ #if SKINNED
152
+ /* Samplers and a constant alone, so no field of the vertex block moves. See clothBinding.ts. */
153
+ ${CLOTH_BINDING_GLSL}
154
+ #endif
148
155
 
149
156
  out vec3 vNormal;
150
157
  out vec3 vColor;
@@ -218,9 +225,11 @@ void main() {
218
225
  mat4 skin = skinMatrix();
219
226
  vec4 local = skin * vec4(basePosition, 1.0);
220
227
  vec3 localNormal = mat3(skin) * aNormal;
228
+ vec3 localTangent = mat3(skin) * aTangent.xyz;
221
229
  #else
222
230
  vec4 local = vec4(basePosition, 1.0);
223
231
  vec3 localNormal = aNormal;
232
+ vec3 localTangent = aTangent.xyz;
224
233
  #endif
225
234
  #if INSTANCED
226
235
  mat4 model = mat4(aInstanceModel0, aInstanceModel1, aInstanceModel2, aInstanceModel3);
@@ -241,9 +250,31 @@ void main() {
241
250
  vec3 bent = channelBend(world.xyz, aChannel.x);
242
251
  #endif
243
252
  world = vec4(bent, world.w);
244
- vWorldPos = world.xyz;
245
253
  // Rotation/translation-only models (our case): mat3 is a valid normal matrix.
246
254
  vec3 worldNormal = mat3(model) * localNormal;
255
+ /*
256
+ * **The tangent turns as the normal does**, because the fragment stage crosses it with the
257
+ * world-space normal to build its frame. It went out in the mesh's own space until 4.8.4, which
258
+ * is world space only for a mesh that is moved and never turned: a turned or skinned mesh lit its
259
+ * normal map, and anything reading a direction along its surface, from the wrong side.
260
+ */
261
+ vec3 worldTangent = mat3(model) * localTangent;
262
+ #if SKINNED
263
+ /*
264
+ * **Toward the cloth, after the skeleton and the bend.** The simulation already swings with the
265
+ * wind it was given, so the bend moves only the skinned part; the weight painted per vertex says
266
+ * how much of the cloth's place and turn the vertex takes. See clothBinding.ts.
267
+ */
268
+ if (CLOTH_BOUND) {
269
+ vec3 clothPosition;
270
+ mat3 turn;
271
+ float follow = clothPlace(clothPosition, turn);
272
+ world = vec4(mix(world.xyz, clothPosition, follow), world.w);
273
+ worldNormal = normalize(mix(worldNormal, turn * aNormal, follow));
274
+ worldTangent = mix(worldTangent, turn * aTangent.xyz, follow);
275
+ }
276
+ #endif
277
+ vWorldPos = world.xyz;
247
278
  vNormal = worldNormal;
248
279
  vLightPos = uLightViewProj * world;
249
280
  vColor = aColor * tint;
@@ -254,7 +285,7 @@ void main() {
254
285
  vRoughness = aRoughness;
255
286
  vGrain = aGrain;
256
287
  vRelief = aRelief;
257
- vTangent = aTangent;
288
+ vTangent = vec4(worldTangent, aTangent.w);
258
289
  #if INSTANCED
259
290
  /* No channel on this path; the neutral values are what every mesh read before it existed. */
260
291
  vSkyDirect = 1.0;
@@ -534,8 +565,11 @@ export function flatFrag(options: FlatShaderOptions): string {
534
565
  TANGENT_FRAME_GLSL,
535
566
  /* Unconditional for the same reason, and called only where a cutoff was asked for. */
536
567
  CUTOUT_COVERAGE_GLSL,
568
+ CUTOUT_DITHER_GLSL,
537
569
  DRIFT_LIGHT_GLSL,
538
570
  SURFACE_EFFECTS_GLSL,
571
+ /* Depth zero and after the tangent frame and the lobes it calls; see models.ts. */
572
+ MODELS_GLSL,
539
573
  MAIN_GLSL,
540
574
  ].join('\n'),
541
575
  {
@@ -21,6 +21,8 @@ export const MAIN_GLSL = `void main() {
21
21
  fxLoad(int(surfaceAt.z));
22
22
  surfaceAt = fxAnimate(surfaceAt);
23
23
  }
24
+ /* An eye's iris, moved by its cornea before any map reads it: models.ts. */
25
+ surfaceAt = modelSurfaceAt(surfaceAt);
24
26
  vec2 fxDx = dFdx(surfaceAt.xy);
25
27
  vec2 fxDy = dFdy(surfaceAt.xy);
26
28
  if (uWriteMode.y != 0.0 && !ditherKeeps(uWriteMode.y)) discard;
@@ -56,18 +58,35 @@ export const MAIN_GLSL = `void main() {
56
58
  * rectangle of haze with a picture in it, rather than a picture.
57
59
  */
58
60
  float coverage = 1.0;
59
- if (uAlbedoEnabled != 0) {
61
+ if (uMaterialFlags.x != 0) {
60
62
  vec4 texel = texture(uAlbedo, surfaceAt);
61
63
  // Discarded before anything else is computed: a thrown-away fragment should not pay
62
64
  // for the lighting it will never contribute to. The test credits alpha for the mip level
63
65
  // it samples, so a needle averaged thin down the chain is not lost; see cutoutCoverage.ts.
64
66
  // Behind a branch on a uniform, so a material with no cutoff pays nothing for it.
65
- if (uAlbedoCutout > 0.0) {
67
+ float kept = texel.a;
68
+ if (uCutout.x > 0.0) {
66
69
  float tested = cutoutAlpha(texel.a, vUv.xy * vec2(textureSize(uAlbedo, 0).xy));
67
- if (tested < uAlbedoCutout) discard;
70
+ if (uCutout.y == 0.0) {
71
+ if (tested < uCutout.x) discard;
72
+ } else {
73
+ /*
74
+ * A dithered edge: the pixel is kept by the share of it the texture covers. Under a
75
+ * temporal resolve the test is against noise that moves every frame, and a kept pixel is
76
+ * whole; multisampled, the share is the alpha the GPU turns into coverage. See
77
+ * cutoutDither.ts.
78
+ */
79
+ float share = cutoutShare(tested, uCutout.x);
80
+ if (uCutout.y == 1.0) {
81
+ if (!cutoutKeeps(share, gl_FragCoord.xy, uCutout.z)) discard;
82
+ kept = 1.0;
83
+ } else {
84
+ kept = share;
85
+ }
86
+ }
68
87
  }
69
88
  albedo *= texel.rgb;
70
- coverage = texel.a;
89
+ coverage = kept;
71
90
  }
72
91
 
73
92
  /*
@@ -91,7 +110,7 @@ export const MAIN_GLSL = `void main() {
91
110
  float ormOcclusion = 1.0;
92
111
  float ormRoughness = vRoughness;
93
112
  float metal = 0.0;
94
- if (uOrmEnabled != 0) {
113
+ if (uMaterialFlags.y != 0) {
95
114
  vec3 t = texture(uOrmMap, surfaceAt).rgb;
96
115
  ormOcclusion = mix(1.0, t.r, uOrmScale.r);
97
116
  ormRoughness = clamp(t.g * uOrmScale.g, 0.0, 1.0);
@@ -121,6 +140,8 @@ export const MAIN_GLSL = `void main() {
121
140
  }
122
141
 
123
142
  vec3 lit = albedo;
143
+ /* Skin's diffuse, held before emission for the screen-space blur's own pass: models.ts. */
144
+ vec3 diffuseAlone = vec3(0.0);
124
145
  /* The light arriving at a glass pane from behind it, gathered in each light loop below and added
125
146
  over the see-through at the end. Stays zero for anything that is not glass. See glass.ts. */
126
147
  vec3 glassGlow = vec3(0.0);
@@ -143,7 +164,7 @@ export const MAIN_GLSL = `void main() {
143
164
  * Taken from the stored normal alone, a pane seen from behind glowed with the light on the
144
165
  * eye's side and not with the sun shining through it.
145
166
  */
146
- bool backFace = (uDoubleSided != 0 || glassTransmission > 0.0) && dot(n, uCameraPos - vWorldPos) < 0.0;
167
+ bool backFace = (uMaterialFlags.w != 0 || glassTransmission > 0.0) && dot(n, uCameraPos - vWorldPos) < 0.0;
147
168
  /*
148
169
  * The authored normal, before either relief perturbs it.
149
170
  *
@@ -162,6 +183,15 @@ export const MAIN_GLSL = `void main() {
162
183
  */
163
184
  if (uNormalStrength > 0.0) {
164
185
  vec3 mapped = texture(uNormalMap, surfaceAt).xyz * 2.0 - 1.0;
186
+ /*
187
+ * z rebuilt where none is stored. A tangent-space normal points out of its surface, so a
188
+ * stored z at or below zero is never a real one: it is a two-channel map, BC5, whose blue a
189
+ * device samples as zero and which arrives here as -1. Every map baked from BC5 to PNG before
190
+ * 4.8.4 carried the same zero. A select rather than a flag: no uniform to bind, no material
191
+ * that has to say what format it holds, and continuous at the boundary, since a unit texel
192
+ * lying flat has its z near zero either way. A texel with z above zero is left as it was.
193
+ */
194
+ mapped.z = mapped.z > 0.0 ? mapped.z : sqrt(max(0.0, 1.0 - dot(mapped.xy, mapped.xy)));
165
195
  mat3 tbn = tangentFrame(n, vWorldPos, vUv.xy, vTangent, vHasTangents);
166
196
  n = normalize(mix(n, normalize(tbn * mapped), uNormalStrength));
167
197
  }
@@ -268,7 +298,7 @@ export const MAIN_GLSL = `void main() {
268
298
  * and normalized: dividing the gradient by \`det\` instead would blow up on a fragment where
269
299
  * the surface is edge-on and the quad's footprint collapses.
270
300
  */
271
- if (uAlbedoEnabled != 0 && uTextureRelief != 0.0) {
301
+ if (uMaterialFlags.x != 0 && uTextureRelief != 0.0) {
272
302
  /*
273
303
  * **A centred difference, not three.js's forward one, and that is what makes the two
274
304
  * backends agree.** WebGL2's framebuffer counts y upward and WebGPU's counts it downward,
@@ -331,6 +361,14 @@ export const MAIN_GLSL = `void main() {
331
361
  * across the fold of every curtain.
332
362
  */
333
363
  if (backFace) n = -n;
364
+ /*
365
+ * A model's surface, gathered once, on a branch on the switches alone (models.ts) — and whether
366
+ * there is one, asked once: every site below is a branch on these two, which a pipeline with no
367
+ * model folds away, and each extra call was a copy of its plumbing in all sixteen permutations.
368
+ */
369
+ bool modelled = modelOn();
370
+ bool modelledBehind = modelSeesBehind();
371
+ if (modelled) modelSurface(n, albedo, surfaceRoughness, metal, surfaceAt);
334
372
 
335
373
  /*
336
374
  * One dot product, used twice: ndl is the lit side and its negative is the night side below.
@@ -444,7 +482,19 @@ export const MAIN_GLSL = `void main() {
444
482
  * smallest change available in a shader with a documented history of shared subexpressions
445
483
  * rounding differently when their neighbours move. See the night-emissive term.
446
484
  */
447
- lit = albedo * (ambient + sunColor * direct * (1.0 - metal));
485
+ /*
486
+ * A model answers the sun itself, and is shadowed as it says (models.ts). The standard line
487
+ * stands as it was, so a pipeline with no model compiles to exactly the arithmetic before.
488
+ */
489
+ vec3 sunModelSpecular = vec3(0.0);
490
+ if (modelled) {
491
+ modelLight(uDirectionalDir, 0.0, 0.0);
492
+ float sunModelFacing = modelledBehind ? 1.0 : sunFacing;
493
+ lit = modelAmbient(ambient) + sunColor * mDiffuse * modelShade(sunShade) * sunModelFacing;
494
+ sunModelSpecular = mSpecular * sunColor * sunShade * sunModelFacing;
495
+ } else {
496
+ lit = albedo * (ambient + sunColor * direct * (1.0 - metal));
497
+ }
448
498
 
449
499
  /*
450
500
  * A sun highlight, for the few surfaces that ask for one.
@@ -507,6 +557,7 @@ export const MAIN_GLSL = `void main() {
507
557
  */
508
558
  vec3 sunHighlight =
509
559
  sunColor * specularLobe(max(dot(n, halfway), 0.0), surfaceRoughness) * sunSpec * sunShade * sunFacing;
560
+ if (modelled) sunHighlight = sunModelSpecular;
510
561
  /*
511
562
  * The dielectric's share goes in here, where it has always gone, so the environment blend
512
563
  * below dims it by exactly the reflectance it always dimmed it by. The metal's share is added
@@ -578,18 +629,20 @@ export const MAIN_GLSL = `void main() {
578
629
  * whole block was skipped for a fully metallic surface and max(uReflectivity, metal) inside it
579
630
  * was never reached. A chromed subject came out near black with a baked probe sitting unread.
580
631
  *
581
- * **Gated on uOrmEnabled rather than on metal, and that is required rather than tidy.** The
632
+ * **Gated on the ORM flag rather than on metal, and that is required rather than tidy.** The
582
633
  * block takes fwidth of the mirror direction, and a derivative may only be taken under uniform
583
- * control flow. uOrmEnabled is a uniform; metal is a texture fetch, so branching on it would
634
+ * control flow. The flag is a uniform; metal is a texture fetch, so branching on it would
584
635
  * put that derivative in non-uniform control flow and WGSL refuses the module outright. See
585
636
  * AGENTS.md, 2026-08-07. The per-texel decision still happens, one line down, in the amount.
586
637
  *
587
- * At uOrmEnabled 0 this is uReflectivity > 0.0 exactly, so nothing that binds no map takes a
638
+ * With the flag at 0 this is uReflectivity > 0.0 exactly, so nothing that binds no map takes a
588
639
  * branch it did not take before.
589
640
  */
590
- if (uReflectivity > 0.0 || uOrmEnabled != 0) {
641
+ /* A model reflects its environment whatever the pass asked of the standard model: see
642
+ modelReflectance. On the switches alone, so the derivatives below stay in uniform flow. */
643
+ if (uReflectivity > 0.0 || uMaterialFlags.y != 0 || modelled) {
591
644
  vec3 toEye = normalize(uCameraPos - vWorldPos);
592
- vec3 mirrored = reflect(-toEye, n);
645
+ vec3 mirrored = reflect(-toEye, modelled ? modelReflectNormal(n) : n);
593
646
  vec3 environment = mix(uAmbientGround, uAmbient, mirrored.y * 0.5 + 0.5);
594
647
  #if ENVIRONMENT_PROBE
595
648
  /*
@@ -845,6 +898,8 @@ export const MAIN_GLSL = `void main() {
845
898
  #else
846
899
  float weight = clamp(fresnel * reflectAmount, 0.0, 1.0);
847
900
  #endif
901
+ if (modelled) weight = modelReflectance(weight);
902
+ if (modelled) environment = modelEnvironment(environment);
848
903
  lit = mix(
849
904
  lit,
850
905
  environment * mix(vec3(1.0), albedo, metal),
@@ -1151,7 +1206,8 @@ export const MAIN_GLSL = `void main() {
1151
1206
  */
1152
1207
  float backNdl =
1153
1208
  glassTransmission > 0.0 ? max(-dot(n, toLight / max(dist, 1e-4)), 0.0) : 0.0;
1154
- if (ndl <= 0.0 && backNdl <= 0.0) continue;
1209
+ /* A model that sees light from behind keeps the lamps behind the surface. See models.ts. */
1210
+ if (ndl <= 0.0 && backNdl <= 0.0 && !modelledBehind) continue;
1155
1211
 
1156
1212
  /*
1157
1213
  * Normal-offset bias.
@@ -1400,13 +1456,25 @@ export const MAIN_GLSL = `void main() {
1400
1456
 
1401
1457
  float shape = (uLightFalloff == 1 ? falloff : falloff * falloff) * coneFalloff * photometric;
1402
1458
  glassGlow += lightColor * backNdl * shape * lightWeight * shaded * lampGlass;
1403
- if (ndl <= 0.0) continue;
1459
+ if (ndl <= 0.0 && !modelledBehind) continue;
1404
1460
  /*
1405
1461
  * The cookie tints rather than scales, because a mask may be coloured — a stained window is
1406
1462
  * the case that makes the difference — and a greyscale one is exactly a scale. Applied to
1407
1463
  * the light's colour rather than to \`shape\`, which is a scalar and could not carry it.
1408
1464
  */
1409
1465
  lightColor *= cookieTint;
1466
+ /*
1467
+ * A model's answer to this lamp, held back and shadowed exactly as the standard terms below
1468
+ * are — the shadowed sum through \`modelShade\`, which is where skin's penumbra reddens.
1469
+ */
1470
+ if (modelled) {
1471
+ modelLight(toLight / max(dist, 1e-4), lightSourceRadius, dist);
1472
+ vec3 lampModelLight = lightColor * shape * lightWeight;
1473
+ lampOpen += (mDiffuse + mSpecular) * lampModelLight;
1474
+ lampShadowed += (mDiffuse * modelShade(shaded) + mSpecular * shaded) * lampModelLight * lampGlass;
1475
+ lightShade = min(lightShade, mix(1.0, shaded, lightWeight));
1476
+ continue;
1477
+ }
1410
1478
  /*
1411
1479
  * **A metal has no diffuse term, and this lamp kept giving it one.**
1412
1480
  *
@@ -1648,6 +1716,22 @@ export const MAIN_GLSL = `void main() {
1648
1716
 
1649
1717
  glassGlow += uAreaLightColor[a] * backForm * areaOccl * areaGlass;
1650
1718
  if (form <= 0.0) continue;
1719
+ /*
1720
+ * A model answers a rectangle as it answers a lamp at its centre, carried at the rectangle's
1721
+ * own energy: the diffuse by the exact form factor over the centre's cosine, the specular by
1722
+ * the form factor. **An approximation, stated**: a model's lobe is not integrated over the
1723
+ * rectangle as the standard lobe is, so a glossy model under a large softbox shows a lobe
1724
+ * from its centre rather than its shape.
1725
+ */
1726
+ if (modelled) {
1727
+ vec3 areaL = normalize(centre - vWorldPos);
1728
+ modelLight(areaL, 0.0, 0.0);
1729
+ vec3 areaModel = (mDiffuse * (form / max(dot(n, areaL), 1e-3)) + mSpecular * form)
1730
+ * uAreaLightColor[a];
1731
+ lampOpen += areaModel;
1732
+ lampShadowed += areaModel * areaOccl * areaGlass;
1733
+ continue;
1734
+ }
1651
1735
  /* Held back with the point lamps', and shadowed by the same term it always was. */
1652
1736
  vec3 areaDiffuse = albedo * uAreaLightColor[a] * form * (1.0 - metal);
1653
1737
  lampOpen += areaDiffuse;
@@ -1741,7 +1825,12 @@ export const MAIN_GLSL = `void main() {
1741
1825
  if (glassTransmission > 0.0) {
1742
1826
  glassGlow += driftLightIrradiance(vWorldPos, -n) * (driftShare * uDriftLight.w);
1743
1827
  }
1744
- lit += albedo * driftLightIrradiance(vWorldPos, n) * (driftShare * uDriftLight.w) * (1.0 - metal);
1828
+ /* The standard expression in its own order — albedo times the field, then the share — so a
1829
+ pipeline with no model rounds exactly as it did; a model is handed the light alone. */
1830
+ vec3 driftField = driftLightIrradiance(vWorldPos, n);
1831
+ lit += modelled
1832
+ ? modelIrradiance(driftField * (driftShare * uDriftLight.w))
1833
+ : albedo * driftField * (driftShare * uDriftLight.w) * (1.0 - metal);
1745
1834
  }
1746
1835
 
1747
1836
  /*
@@ -1793,6 +1882,7 @@ export const MAIN_GLSL = `void main() {
1793
1882
  * underneath: a ceiling emitting a dull warm haze over a pale panel cannot be expressed
1794
1883
  * by scaling the panel, and scaling it gets the brightness right and the hue wrong.
1795
1884
  */
1885
+ diffuseAlone = lit;
1796
1886
  vec3 emissiveTint = vEmissiveColor.r < 0.0 ? albedo : vEmissiveColor;
1797
1887
  /*
1798
1888
  * Where the surface glows, from an image, or everywhere if it binds none.
@@ -1811,7 +1901,7 @@ export const MAIN_GLSL = `void main() {
1811
1901
  * explicit level — the 2026-08-07 rule is satisfied by the gate rather than by a \`textureLod\`.
1812
1902
  */
1813
1903
  vec3 emissiveMapped = vec3(1.0);
1814
- if (uEmissiveMapEnabled != 0) {
1904
+ if (uMaterialFlags.z != 0) {
1815
1905
  emissiveMapped = texture(uEmissiveMap, surfaceAt).rgb * uEmissiveScale;
1816
1906
  }
1817
1907
  /* Pulse, flicker and fade from the layer's effects; exactly 1 where it names none. */
@@ -1890,8 +1980,14 @@ export const MAIN_GLSL = `void main() {
1890
1980
  // Water is a camera medium, not a property of individual objects.
1891
1981
  vec3 waterTransmission = vec3(0.42) + uUnderwaterColor * 2.0;
1892
1982
  lit = mix(lit, lit * waterTransmission, uUnderwaterFactor * 0.55);
1983
+ diffuseAlone = mix(diffuseAlone, diffuseAlone * waterTransmission, uUnderwaterFactor * 0.55);
1893
1984
  fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);
1894
1985
  }
1986
+ /* The diffuse pass leaves here, with skin's diffuse and the profile it is spread by. */
1987
+ if (modelDiffuseAlone()) {
1988
+ outColor = vec4(modelDiffuseOut(diffuseAlone * ormOcclusion, fog), skinProfileCode());
1989
+ return;
1990
+ }
1895
1991
 
1896
1992
  /*
1897
1993
  * A surface recedes into the medium; light added to the frame fades in it (\`uFogEnabled\` at 2,
@@ -0,0 +1,286 @@
1
+ /**
2
+ * The lit stage's shading models: how a material that is not the standard one answers light.
3
+ *
4
+ * **One switch a model, each a pipeline constant, and a branch only ever on the switch itself.**
5
+ * `MODEL_ANISOTROPIC`, `MODEL_HAIR`, `MODEL_SKIN` and `MODEL_EYE` are off in every pipeline but the
6
+ * ones a material with that model asks for, so the device compiles every model out of the standard
7
+ * pipelines and a scene that names none carries none — the arrangement `litSwitchesGlsl` has, for
8
+ * its reason. Not one constant numbering the models: a comparison against an override is a
9
+ * specialisation-constant operation, which the WGSL generator refuses (`overridableConstants`), so
10
+ * every way in is a function that branches on the switches one at a time.
11
+ *
12
+ * **Where the models plug in.** `main` gathers the surface once (`modelSurface`), then asks at each
13
+ * place light arrives: `modelLight` for the sun, each lamp and each rectangle, `modelAmbient` for
14
+ * the sky and the probe's irradiance, `modelIrradiance` for DriftLight, `modelReflectNormal` and
15
+ * `modelReflectance` for the environment's reflection. Each returns today's term for the standard
16
+ * model, and each model's file is where its own answers are.
17
+ *
18
+ * **What a model returns from a light** is the two halves the standard terms are: a diffuse already
19
+ * weighted by the angle of arrival, and a specular lobe in the engine's peak-normalised units (see
20
+ * `specularLobe`), both before any shadow — the caller shadows the specular, and the diffuse through
21
+ * `modelShade`, which is the identity but for skin, whose penumbra reddens.
22
+ */
23
+ import { SURFACE_MODEL_SWITCH } from '../../surfaceModel.ts';
24
+ import type { SurfaceModelKind } from '../../surfaceModel.ts';
25
+ import { ANISOTROPIC_GLSL } from './anisotropic.ts';
26
+ import { HAIR_GLSL } from './hair.ts';
27
+ import { SKIN_GLSL } from './skin.ts';
28
+ import { EYE_GLSL } from './eye.ts';
29
+
30
+ /** The switches, the numbers, the surface every model reads, and the standard answer it falls back on. */
31
+ const MODELS_HEAD = /* glsl */ `
32
+ /* Which model a pipeline shades by. Each off but where a material asks for it: see models.ts. */
33
+ const bool MODEL_ANISOTROPIC = false; // wgsl:override
34
+ const bool MODEL_HAIR = false; // wgsl:override
35
+ const bool MODEL_SKIN = false; // wgsl:override
36
+ const bool MODEL_EYE = false; // wgsl:override
37
+ /*
38
+ * Skin's screen-space scattering, as two halves of one surface: the scene pass without its diffuse,
39
+ * and a pass that is nothing but it, which the blur spreads before it is added back. Both off but
40
+ * where \`skinScattering\` is \`'screen-space'\`; see \`skinBlur.ts\`.
41
+ */
42
+ const bool SKIN_SCREEN = false; // wgsl:override
43
+ const bool SKIN_DIFFUSE = false; // wgsl:override
44
+
45
+ /*
46
+ * A model's numbers, two vectors a material — the lit stage's uniform budget has no more room
47
+ * (surfaceModel.ts) — and whether a model map is bound, in the last component.
48
+ */
49
+ uniform vec4 uModelParams[2];
50
+ /* The model's own channels, in the albedo's layout: what each means is each model's to say. */
51
+ uniform highp sampler2DArray uModelMap;
52
+
53
+ /** Whether this pipeline shades by any model but the standard one. */
54
+ bool modelOn() {
55
+ if (MODEL_ANISOTROPIC) return true;
56
+ if (MODEL_HAIR) return true;
57
+ if (MODEL_SKIN) return true;
58
+ if (MODEL_EYE) return true;
59
+ return false;
60
+ }
61
+
62
+ /** Whether this pipeline draws skin's diffuse alone, for the screen-space blur. */
63
+ bool modelDiffuseAlone() {
64
+ if (MODEL_SKIN) {
65
+ if (SKIN_DIFFUSE) return true;
66
+ }
67
+ return false;
68
+ }
69
+
70
+ /** Whether light from behind the surface reaches the eye through it: hair, and skin's thin parts. */
71
+ bool modelSeesBehind() {
72
+ if (MODEL_HAIR) return true;
73
+ if (MODEL_SKIN) return true;
74
+ return false;
75
+ }
76
+
77
+ /* The surface a model shades, gathered once in main before any light reaches it. */
78
+ vec3 mNormal;
79
+ vec3 mToEye;
80
+ vec3 mTangent;
81
+ vec3 mBitangent;
82
+ vec3 mAlbedo;
83
+ vec3 mSpecColor;
84
+ float mRoughness;
85
+ float mMetal;
86
+ vec4 mMap;
87
+ /*
88
+ * What \`modelLight\` leaves: the diffuse weighted by its angle and the lobe, neither shadowed.
89
+ * Globals rather than out parameters, because every parameter a call passes is a local, a store and
90
+ * a pointer at each call site in \`main\` — sixteen copies of it, one a permutation.
91
+ */
92
+ vec3 mDiffuse;
93
+ vec3 mSpecular;
94
+
95
+ /**
96
+ * Normalised GGX — the distribution itself, not the engine's peak-normalised look — for the models
97
+ * whose highlight is physical: skin's and the cornea's.
98
+ */
99
+ float ggxLobe(float ndh, float alpha) {
100
+ float a2 = alpha * alpha;
101
+ float d = ndh * ndh * (a2 - 1.0) + 1.0;
102
+ return a2 / (3.14159265 * d * d);
103
+ }
104
+
105
+ /** Smith's height-correlated masking with the BRDF's 1 / 4 N·L N·V folded in, for the same. */
106
+ float smithMasking(float ndl, float ndv, float alpha) {
107
+ float a2 = alpha * alpha;
108
+ return 0.5 / max(
109
+ ndl * sqrt(ndv * ndv * (1.0 - a2) + a2) + ndv * sqrt(ndl * ndl * (1.0 - a2) + a2),
110
+ 1e-5
111
+ );
112
+ }
113
+
114
+ /** The standard model's answer to one light, which a model falls back to for what it does not change. */
115
+ void standardLight(vec3 l, float sourceRadius, float dist) {
116
+ float ndl = max(dot(mNormal, l), 0.0);
117
+ vec3 h = normalize(l + mToEye);
118
+ float ndh = max(dot(mNormal, h), 0.0);
119
+ float lobe = sourceRadius > 0.0
120
+ ? sphereLobe(ndh, mRoughness, sourceRadius, dist)
121
+ : specularLobe(ndh, mRoughness);
122
+ vec3 tint = mix(mSpecColor, vec3(1.0), pow(1.0 - max(dot(mToEye, h), 0.0), 5.0) * mMetal);
123
+ mDiffuse = mAlbedo * ndl * (1.0 - mMetal);
124
+ mSpecular = ndl > 0.0 ? tint * lobe : vec3(0.0);
125
+ }
126
+
127
+ `;
128
+
129
+ /** Where `main` asks: each model's answer, and the standard's where a model keeps it. */
130
+ const MODELS_DISPATCH = /* glsl */ `
131
+ /**
132
+ * Where the material's maps are read: the surface's own coordinate, but for an eye, whose iris is
133
+ * seen through its cornea and moved by it (\`eyeRefraction.ts\`). Before any map is read, and on a
134
+ * branch on the switches alone, so its derivatives are in uniform control flow.
135
+ */
136
+ vec3 modelSurfaceAt(vec3 at) {
137
+ if (MODEL_EYE) return eyeRefract(at);
138
+ return at;
139
+ }
140
+
141
+ /**
142
+ * Gather the surface. The frame and the map read derivatives, so this is called under a branch on
143
+ * the switches alone, which is uniform control flow by construction.
144
+ */
145
+ void modelSurface(vec3 n, vec3 albedo, float roughness, float metal, vec3 at) {
146
+ mNormal = n;
147
+ mToEye = normalize(uCameraPos - vWorldPos);
148
+ mat3 frame = tangentFrame(n, vWorldPos, vUv.xy, vTangent, vHasTangents);
149
+ mTangent = frame[0];
150
+ mBitangent = frame[1];
151
+ mAlbedo = albedo;
152
+ mSpecColor = mix(vec3(vSpecular), albedo, metal);
153
+ mRoughness = roughness;
154
+ mMetal = metal;
155
+ mMap = vec4(0.5, 0.5, 1.0, 1.0);
156
+ if (uModelParams[1].w > 0.0) mMap = texture(uModelMap, at);
157
+ if (MODEL_ANISOTROPIC) anisotropicSurface();
158
+ if (MODEL_HAIR) hairSurface();
159
+ if (MODEL_SKIN) skinSurface();
160
+ if (MODEL_EYE) eyeSurface(at);
161
+ }
162
+
163
+ /**
164
+ * One light, arriving along \`l\` (unit, toward the light), from a source \`sourceRadius\` across at
165
+ * \`dist\` — 0 for the sun. Into \`mDiffuse\`, weighted by its angle, and \`mSpecular\`, in lobe
166
+ * units, neither shadowed.
167
+ */
168
+ void modelLight(vec3 l, float sourceRadius, float dist) {
169
+ if (MODEL_ANISOTROPIC) {
170
+ anisotropicLight(l, sourceRadius, dist);
171
+ return;
172
+ }
173
+ if (MODEL_HAIR) {
174
+ hairLight(l, sourceRadius, dist);
175
+ return;
176
+ }
177
+ if (MODEL_SKIN) {
178
+ skinLight(l, sourceRadius, dist);
179
+ return;
180
+ }
181
+ if (MODEL_EYE) {
182
+ eyeLight(l, sourceRadius, dist);
183
+ return;
184
+ }
185
+ standardLight(l, sourceRadius, dist);
186
+ }
187
+
188
+ /** How a shadow of \`shadow\` darkens this model's diffuse: the identity, but for hair and skin. */
189
+ vec3 modelShade(float shadow) {
190
+ if (MODEL_HAIR) return hairShade(shadow);
191
+ if (MODEL_SKIN) return skinShade(shadow);
192
+ return vec3(shadow);
193
+ }
194
+
195
+ /** The sky's or the probe's light on this surface, before any reflection. */
196
+ vec3 modelAmbient(vec3 ambient) {
197
+ if (MODEL_HAIR) return hairAround(ambient);
198
+ if (MODEL_SKIN) {
199
+ if (SKIN_SCREEN) return vec3(0.0);
200
+ }
201
+ return mAlbedo * ambient;
202
+ }
203
+
204
+ /** Light arriving from every side at once — DriftLight's — as this surface returns it. */
205
+ vec3 modelIrradiance(vec3 irradiance) {
206
+ if (MODEL_HAIR) return hairAround(irradiance);
207
+ if (MODEL_SKIN) {
208
+ if (SKIN_SCREEN) return vec3(0.0);
209
+ }
210
+ return mAlbedo * irradiance * (1.0 - mMetal);
211
+ }
212
+
213
+ /** The normal the environment is reflected about: the surface's own, but for a stretched lobe. */
214
+ vec3 modelReflectNormal(vec3 n) {
215
+ if (MODEL_ANISOTROPIC) return anisotropicReflectNormal(n);
216
+ if (MODEL_HAIR) return hairAcross();
217
+ return n;
218
+ }
219
+
220
+ /**
221
+ * The environment the reflection blends toward: black in skin's diffuse pass, so the blend dims
222
+ * the light before it by the reflection's share — as the whole surface's does, and only that light,
223
+ * the lamps arriving after it — and adds none of the reflection, which is the scene pass's half.
224
+ */
225
+ vec3 modelEnvironment(vec3 environment) {
226
+ if (MODEL_SKIN) {
227
+ if (SKIN_DIFFUSE) return vec3(0.0);
228
+ }
229
+ return environment;
230
+ }
231
+
232
+ /** Skin's diffuse as the diffuse pass writes it, from the lit total before emission, fogged. */
233
+ vec3 modelDiffuseOut(vec3 diffuse, float fog) {
234
+ return diffuse * (1.0 - fog);
235
+ }
236
+
237
+ /**
238
+ * How much of the environment the surface reflects — the blend's weight, Fresnel and all — from
239
+ * what the standard model would.
240
+ */
241
+ float modelReflectance(float standard) {
242
+ if (MODEL_HAIR) return hairReflectance();
243
+ if (MODEL_SKIN) return skinReflectance();
244
+ if (MODEL_EYE) return eyeReflectance();
245
+ return standard;
246
+ }
247
+ `;
248
+
249
+ /** The models' whole chunk, at depth zero before `main`: see `flatFrag`. */
250
+ export const MODELS_GLSL = [
251
+ MODELS_HEAD,
252
+ ANISOTROPIC_GLSL,
253
+ HAIR_GLSL,
254
+ SKIN_GLSL,
255
+ EYE_GLSL,
256
+ MODELS_DISPATCH,
257
+ ].join('\n');
258
+
259
+ /**
260
+ * Which half of a skin a lit pipeline draws under `skinScattering: 'screen-space'`: the whole
261
+ * surface (every other pipeline), the frame's half without its diffuse, or the diffuse alone into
262
+ * the blur's target — that last one depth-tested for equality against what the frame's half wrote
263
+ * and writing none, since it is the same surface drawn again. See `skinBlur.ts`.
264
+ */
265
+ export type SkinHalf = 'whole' | 'scene' | 'diffuse';
266
+
267
+ /** A skin lit stage with one half's switch on, WebGL2's way: see `SkinHalf`. */
268
+ export function skinHalfBound(source: string, half: SkinHalf): string {
269
+ if (half === 'whole') return source;
270
+ const name = half === 'scene' ? 'SKIN_SCREEN' : 'SKIN_DIFFUSE';
271
+ const off = `const bool ${name} = false;`;
272
+ if (!source.includes(off)) {
273
+ throw new Error(`skinHalfBound: this lit stage declares no ${name} constant`);
274
+ }
275
+ return source.replace(off, `const bool ${name} = true;`);
276
+ }
277
+
278
+ /** A lit stage with one model's switch on: the same source, the constant flipped — WebGL2's way. */
279
+ export function modelBound(source: string, kind: SurfaceModelKind): string {
280
+ const name = SURFACE_MODEL_SWITCH[kind];
281
+ const off = `const bool ${name} = false;`;
282
+ if (!source.includes(off)) {
283
+ throw new Error(`modelBound: this lit stage declares no ${name} constant`);
284
+ }
285
+ return source.replace(off, `const bool ${name} = true;`);
286
+ }