@driftengine/core 4.8.2 → 4.8.4

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 (259) hide show
  1. package/CHANGELOG.json +127 -0
  2. package/README.md +1 -1
  3. package/dist/geometry/meshConcat.js +2 -0
  4. package/dist/index.d.ts +11 -4
  5. package/dist/index.js +22 -1
  6. package/dist/math/scalar.js +1 -1
  7. package/dist/render/anisotropicLobe.d.ts +7 -0
  8. package/dist/render/anisotropicLobe.js +33 -0
  9. package/dist/render/backend/api.d.ts +41 -4
  10. package/dist/render/backend/materialChanges.d.ts +7 -2
  11. package/dist/render/backend/materialChanges.js +8 -3
  12. package/dist/render/backend/select.js +8 -0
  13. package/dist/render/backend/webgl2/clothTextures.d.ts +27 -0
  14. package/dist/render/backend/webgl2/clothTextures.js +93 -0
  15. package/dist/render/backend/webgl2/renderer.d.ts +159 -3
  16. package/dist/render/backend/webgl2/renderer.js +662 -80
  17. package/dist/render/backend/webgl2/skinScatter.d.ts +50 -0
  18. package/dist/render/backend/webgl2/skinScatter.js +256 -0
  19. package/dist/render/backend/webgpu/buffers.d.ts +25 -11
  20. package/dist/render/backend/webgpu/buffers.js +98 -49
  21. package/dist/render/backend/webgpu/causticsPass.js +2 -1
  22. package/dist/render/backend/webgpu/clothSolvePack.d.ts +27 -0
  23. package/dist/render/backend/webgpu/clothSolvePack.js +129 -0
  24. package/dist/render/backend/webgpu/clothTextures.d.ts +68 -0
  25. package/dist/render/backend/webgpu/clothTextures.js +145 -0
  26. package/dist/render/backend/webgpu/compressedUpload.d.ts +18 -0
  27. package/dist/render/backend/webgpu/compressedUpload.js +38 -0
  28. package/dist/render/backend/webgpu/computeHost.d.ts +21 -0
  29. package/dist/render/backend/webgpu/computeHost.js +5 -0
  30. package/dist/render/backend/webgpu/depthPass.d.ts +29 -9
  31. package/dist/render/backend/webgpu/depthPass.js +43 -12
  32. package/dist/render/backend/webgpu/drawCommand.js +6 -2
  33. package/dist/render/backend/webgpu/filmPass.js +2 -1
  34. package/dist/render/backend/webgpu/flatPass.d.ts +43 -4
  35. package/dist/render/backend/webgpu/flatPass.js +111 -13
  36. package/dist/render/backend/webgpu/gpuSkinnedCloth.d.ts +74 -0
  37. package/dist/render/backend/webgpu/gpuSkinnedCloth.js +452 -0
  38. package/dist/render/backend/webgpu/linePass.js +2 -1
  39. package/dist/render/backend/webgpu/motionPass.d.ts +20 -0
  40. package/dist/render/backend/webgpu/motionPass.js +106 -9
  41. package/dist/render/backend/webgpu/panelPass.js +2 -1
  42. package/dist/render/backend/webgpu/particlePass.js +2 -1
  43. package/dist/render/backend/webgpu/plumePass.js +2 -1
  44. package/dist/render/backend/webgpu/postPass.d.ts +15 -3
  45. package/dist/render/backend/webgpu/postPass.js +25 -8
  46. package/dist/render/backend/webgpu/renderer.d.ts +123 -6
  47. package/dist/render/backend/webgpu/renderer.js +534 -58
  48. package/dist/render/backend/webgpu/sdfTextPass.js +2 -1
  49. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.d.ts +33 -0
  50. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.js +469 -0
  51. package/dist/render/backend/webgpu/skinScatterPass.d.ts +51 -0
  52. package/dist/render/backend/webgpu/skinScatterPass.js +253 -0
  53. package/dist/render/backend/webgpu/surfaceTexturePass.d.ts +4 -1
  54. package/dist/render/backend/webgpu/surfaceTexturePass.js +37 -12
  55. package/dist/render/backend/webgpu/textPass.js +2 -1
  56. package/dist/render/backend/webgpu/waterPass.js +2 -1
  57. package/dist/render/backend/webgpu/windStreakPass.js +2 -1
  58. package/dist/render/boltBatch.js +2 -1
  59. package/dist/render/causticsRenderer.js +2 -1
  60. package/dist/render/clothBindingData.d.ts +41 -0
  61. package/dist/render/clothBindingData.js +72 -0
  62. package/dist/render/clothPlacement.d.ts +25 -0
  63. package/dist/render/clothPlacement.js +85 -0
  64. package/dist/render/compressedSource.d.ts +66 -0
  65. package/dist/render/compressedSource.js +138 -0
  66. package/dist/render/cutoutCaster.d.ts +5 -1
  67. package/dist/render/cutoutCaster.js +6 -0
  68. package/dist/render/cutoutDither.d.ts +51 -0
  69. package/dist/render/cutoutDither.js +90 -0
  70. package/dist/render/decalPass.js +2 -1
  71. package/dist/render/eyeRefraction.d.ts +35 -0
  72. package/dist/render/eyeRefraction.js +40 -0
  73. package/dist/render/glCompressed.d.ts +27 -0
  74. package/dist/render/glCompressed.js +53 -0
  75. package/dist/render/globalMediumPass.js +3 -2
  76. package/dist/render/hairLobes.d.ts +70 -0
  77. package/dist/render/hairLobes.js +120 -0
  78. package/dist/render/lightBudget.d.ts +22 -0
  79. package/dist/render/lightBudget.js +22 -0
  80. package/dist/render/lineBatch.js +5 -1
  81. package/dist/render/mesh.d.ts +7 -0
  82. package/dist/render/mesh.js +24 -0
  83. package/dist/render/oitPass.js +2 -1
  84. package/dist/render/particleBatch.js +3 -2
  85. package/dist/render/particlePool.d.ts +6 -2
  86. package/dist/render/plumeRenderer.js +3 -2
  87. package/dist/render/recon/motionVectors.d.ts +4 -2
  88. package/dist/render/recon/motionVectors.js +6 -3
  89. package/dist/render/renderQuality.d.ts +29 -2
  90. package/dist/render/renderQuality.js +5 -0
  91. package/dist/render/rendererHarness.js +4 -0
  92. package/dist/render/sceneCoverage.d.ts +54 -0
  93. package/dist/render/sceneCoverage.js +72 -0
  94. package/dist/render/sceneTarget.d.ts +12 -0
  95. package/dist/render/sceneTarget.js +14 -0
  96. package/dist/render/sdfTextRenderer.js +2 -1
  97. package/dist/render/shaders/clothBinding.d.ts +22 -0
  98. package/dist/render/shaders/clothBinding.js +80 -0
  99. package/dist/render/shaders/depth.js +45 -8
  100. package/dist/render/shaders/flat/anisotropic.d.ts +15 -0
  101. package/dist/render/shaders/flat/anisotropic.js +68 -0
  102. package/dist/render/shaders/flat/eye.d.ts +1 -0
  103. package/dist/render/shaders/flat/eye.js +81 -0
  104. package/dist/render/shaders/flat/hair.d.ts +1 -0
  105. package/dist/render/shaders/flat/hair.js +101 -0
  106. package/dist/render/shaders/flat/index.js +42 -3
  107. package/dist/render/shaders/flat/main.d.ts +1 -1
  108. package/dist/render/shaders/flat/main.js +113 -17
  109. package/dist/render/shaders/flat/models.d.ts +14 -0
  110. package/dist/render/shaders/flat/models.js +273 -0
  111. package/dist/render/shaders/flat/preamble.js +26 -24
  112. package/dist/render/shaders/flat/skin.d.ts +1 -0
  113. package/dist/render/shaders/flat/skin.js +114 -0
  114. package/dist/render/shaders/generated/depth.wgsl.d.ts +66 -24
  115. package/dist/render/shaders/generated/depth.wgsl.js +66 -24
  116. package/dist/render/shaders/generated/flat.wgsl.d.ts +1815 -1704
  117. package/dist/render/shaders/generated/flat.wgsl.js +1853 -1742
  118. package/dist/render/shaders/generated/line.wgsl.d.ts +1 -1
  119. package/dist/render/shaders/generated/line.wgsl.js +1 -1
  120. package/dist/render/shaders/generated/particle.wgsl.d.ts +3 -3
  121. package/dist/render/shaders/generated/particle.wgsl.js +3 -3
  122. package/dist/render/shaders/generated/rush.wgsl.d.ts +1 -1
  123. package/dist/render/shaders/generated/rush.wgsl.js +1 -1
  124. package/dist/render/shaders/generated/sdfText.wgsl.d.ts +1 -1
  125. package/dist/render/shaders/generated/sdfText.wgsl.js +1 -1
  126. package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +48 -0
  127. package/dist/render/shaders/generated/skinBlur.wgsl.js +54 -0
  128. package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +1 -1
  129. package/dist/render/shaders/generated/temporalResolve.wgsl.js +1 -1
  130. package/dist/render/shaders/line.d.ts +1 -1
  131. package/dist/render/shaders/outputTransform.d.ts +14 -1
  132. package/dist/render/shaders/outputTransform.js +23 -1
  133. package/dist/render/shaders/particle.d.ts +3 -3
  134. package/dist/render/shaders/recon/motion.wgsl.d.ts +1 -1
  135. package/dist/render/shaders/recon/motion.wgsl.js +102 -0
  136. package/dist/render/shaders/recon/resolve.wgsl.js +15 -1
  137. package/dist/render/shaders/rush.js +13 -2
  138. package/dist/render/shaders/sdfText.d.ts +1 -1
  139. package/dist/render/shaders/skinBlur.d.ts +1 -0
  140. package/dist/render/shaders/skinBlur.js +103 -0
  141. package/dist/render/shaders/skinning.d.ts +3 -1
  142. package/dist/render/shaders/skinning.js +31 -4
  143. package/dist/render/shaders/temporalResolve.js +12 -5
  144. package/dist/render/shadowCasters.d.ts +13 -1
  145. package/dist/render/skinBlur.d.ts +41 -0
  146. package/dist/render/skinBlur.js +49 -0
  147. package/dist/render/skinScatter.d.ts +67 -0
  148. package/dist/render/skinScatter.js +80 -0
  149. package/dist/render/skinnedClothRun.d.ts +26 -0
  150. package/dist/render/skinnedClothRun.js +84 -0
  151. package/dist/render/ssrPass.js +2 -1
  152. package/dist/render/surfaceModel.d.ts +129 -0
  153. package/dist/render/surfaceModel.js +152 -0
  154. package/dist/render/surfaceTexture.d.ts +55 -4
  155. package/dist/render/surfaceTexture.js +60 -46
  156. package/dist/render/textRenderer.js +2 -1
  157. package/dist/render/vertexDefaults.js +8 -0
  158. package/dist/render/waterRenderer.js +2 -1
  159. package/dist/render/windStreakRenderer.js +2 -1
  160. package/package.json +6 -6
  161. package/src/geometry/meshConcat.ts +2 -0
  162. package/src/index.ts +53 -1
  163. package/src/math/scalar.ts +1 -1
  164. package/src/render/anisotropicLobe.ts +41 -0
  165. package/src/render/backend/api.ts +52 -3
  166. package/src/render/backend/materialChanges.ts +8 -3
  167. package/src/render/backend/select.ts +8 -0
  168. package/src/render/backend/webgl2/clothTextures.ts +120 -0
  169. package/src/render/backend/webgl2/renderer.ts +750 -80
  170. package/src/render/backend/webgl2/skinScatter.ts +309 -0
  171. package/src/render/backend/webgpu/buffers.ts +137 -63
  172. package/src/render/backend/webgpu/causticsPass.ts +2 -1
  173. package/src/render/backend/webgpu/clothSolvePack.ts +161 -0
  174. package/src/render/backend/webgpu/clothTextures.ts +194 -0
  175. package/src/render/backend/webgpu/compressedUpload.ts +65 -0
  176. package/src/render/backend/webgpu/computeHost.ts +25 -0
  177. package/src/render/backend/webgpu/depthPass.ts +43 -10
  178. package/src/render/backend/webgpu/drawCommand.ts +6 -2
  179. package/src/render/backend/webgpu/filmPass.ts +2 -1
  180. package/src/render/backend/webgpu/flatPass.ts +147 -8
  181. package/src/render/backend/webgpu/gpuSkinnedCloth.ts +502 -0
  182. package/src/render/backend/webgpu/linePass.ts +2 -1
  183. package/src/render/backend/webgpu/motionPass.ts +128 -9
  184. package/src/render/backend/webgpu/panelPass.ts +2 -1
  185. package/src/render/backend/webgpu/particlePass.ts +2 -1
  186. package/src/render/backend/webgpu/plumePass.ts +2 -1
  187. package/src/render/backend/webgpu/postPass.ts +25 -6
  188. package/src/render/backend/webgpu/renderer.ts +701 -52
  189. package/src/render/backend/webgpu/sdfTextPass.ts +2 -1
  190. package/src/render/backend/webgpu/shaders/clothSolve.wgsl.ts +470 -0
  191. package/src/render/backend/webgpu/skinScatterPass.ts +309 -0
  192. package/src/render/backend/webgpu/surfaceTexturePass.ts +42 -12
  193. package/src/render/backend/webgpu/textPass.ts +2 -1
  194. package/src/render/backend/webgpu/waterPass.ts +2 -1
  195. package/src/render/backend/webgpu/windStreakPass.ts +2 -1
  196. package/src/render/boltBatch.ts +2 -1
  197. package/src/render/causticsRenderer.ts +2 -1
  198. package/src/render/clothBindingData.ts +89 -0
  199. package/src/render/clothPlacement.ts +114 -0
  200. package/src/render/compressedSource.ts +198 -0
  201. package/src/render/cutoutCaster.ts +20 -2
  202. package/src/render/cutoutDither.ts +99 -0
  203. package/src/render/decalPass.ts +2 -1
  204. package/src/render/eyeRefraction.ts +72 -0
  205. package/src/render/glCompressed.ts +86 -0
  206. package/src/render/globalMediumPass.ts +3 -2
  207. package/src/render/hairLobes.ts +188 -0
  208. package/src/render/lightBudget.ts +25 -0
  209. package/src/render/lineBatch.ts +3 -1
  210. package/src/render/mesh.ts +23 -0
  211. package/src/render/oitPass.ts +2 -1
  212. package/src/render/particleBatch.ts +3 -2
  213. package/src/render/particlePool.ts +6 -2
  214. package/src/render/plumeRenderer.ts +3 -2
  215. package/src/render/recon/motionVectors.ts +6 -3
  216. package/src/render/renderQuality.ts +37 -2
  217. package/src/render/rendererHarness.ts +4 -0
  218. package/src/render/sceneCoverage.ts +78 -0
  219. package/src/render/sceneTarget.ts +21 -0
  220. package/src/render/sdfTextRenderer.ts +2 -1
  221. package/src/render/shaders/clothBinding.ts +81 -0
  222. package/src/render/shaders/depth.ts +45 -8
  223. package/src/render/shaders/flat/anisotropic.ts +68 -0
  224. package/src/render/shaders/flat/eye.ts +82 -0
  225. package/src/render/shaders/flat/hair.ts +102 -0
  226. package/src/render/shaders/flat/index.ts +42 -3
  227. package/src/render/shaders/flat/main.ts +113 -17
  228. package/src/render/shaders/flat/models.ts +286 -0
  229. package/src/render/shaders/flat/preamble.ts +26 -24
  230. package/src/render/shaders/flat/skin.ts +116 -0
  231. package/src/render/shaders/generated/depth.wgsl.ts +66 -24
  232. package/src/render/shaders/generated/flat.wgsl.ts +1853 -1742
  233. package/src/render/shaders/generated/line.wgsl.ts +1 -1
  234. package/src/render/shaders/generated/particle.wgsl.ts +3 -3
  235. package/src/render/shaders/generated/rush.wgsl.ts +1 -1
  236. package/src/render/shaders/generated/sdfText.wgsl.ts +1 -1
  237. package/src/render/shaders/generated/skinBlur.wgsl.ts +56 -0
  238. package/src/render/shaders/generated/temporalResolve.wgsl.ts +1 -1
  239. package/src/render/shaders/outputTransform.ts +24 -1
  240. package/src/render/shaders/recon/motion.wgsl.ts +102 -0
  241. package/src/render/shaders/recon/resolve.wgsl.ts +15 -1
  242. package/src/render/shaders/rush.ts +13 -2
  243. package/src/render/shaders/skinBlur.ts +105 -0
  244. package/src/render/shaders/skinning.ts +31 -4
  245. package/src/render/shaders/temporalResolve.ts +12 -5
  246. package/src/render/shadowCasters.ts +13 -0
  247. package/src/render/skinBlur.ts +53 -0
  248. package/src/render/skinScatter.ts +92 -0
  249. package/src/render/skinnedClothRun.ts +128 -0
  250. package/src/render/ssrPass.ts +2 -1
  251. package/src/render/surfaceModel.ts +274 -0
  252. package/src/render/surfaceTexture.ts +134 -63
  253. package/src/render/textRenderer.ts +2 -1
  254. package/src/render/vertexDefaults.ts +8 -0
  255. package/src/render/waterRenderer.ts +2 -1
  256. package/src/render/windStreakRenderer.ts +2 -1
  257. package/dist/math/exact.d.ts +0 -76
  258. package/dist/math/exact.js +0 -327
  259. package/src/math/exact.ts +0 -340
@@ -27,6 +27,27 @@
27
27
  * gates on the same `gradeHere` the mesh pass uses.
28
28
  */
29
29
 
30
+ /**
31
+ * A highlight shoulder that keeps a colour's hue: past 0.8 the brightest channel eases toward 1 —
32
+ * `0.8 + 0.2·e / (e + 0.2)` of an excess `e` — and the other two are scaled by the same factor, so
33
+ * an overbright red stays red rather than clipping channel by channel toward white.
34
+ *
35
+ * **Per channel is what sRGB alone does, and it is a hue shift**: `(1.6, 0.4, 0.2)` clips to
36
+ * `(1, 0.4, 0.2)`, a pinker colour and a wider core, where this gives `(0.96, 0.24, 0.12)`. The
37
+ * curve is the one a ported game's own resolve applies, read out of its build, so a port matching
38
+ * it reads the same. **What it gives up** is ACES's filmic toe and saturation roll-off: it touches
39
+ * nothing below 0.8 and never desaturates, so a scene that wants a film's look still wants ACES.
40
+ * Shared by the forward passes and the resolve, so the two cannot disagree about it.
41
+ */
42
+ export const HIGHLIGHT_SHOULDER_GLSL = `
43
+ vec3 highlightShoulder(vec3 c) {
44
+ float m = max(c.r, max(c.g, c.b));
45
+ if (m <= 0.8) return c;
46
+ float e = m - 0.8;
47
+ return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);
48
+ }
49
+ `;
50
+
30
51
  /**
31
52
  * `uOutputTransform` and `uOutputExposure`, the fit, and `applyOutputTransform`.
32
53
  *
@@ -36,7 +57,7 @@
36
57
  * a branch that costs one compare.
37
58
  */
38
59
  export const OUTPUT_TRANSFORM_GLSL = `
39
- /** 0 none, 1 sRGB, 2 ACES then sRGB. See renderQuality.ts. */
60
+ /** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */
40
61
  uniform int uOutputTransform;
41
62
  /** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */
42
63
  uniform float uOutputExposure;
@@ -99,9 +120,11 @@ vec3 linearToSrgb(vec3 c) {
99
120
  return mix(high, low, step(c, vec3(0.0031308)));
100
121
  }
101
122
 
123
+ ${HIGHLIGHT_SHOULDER_GLSL}
102
124
  vec3 applyOutputTransform(vec3 c) {
103
125
  if (uOutputTransform == 0) return c;
104
126
  if (uOutputTransform == 2) c = acesFilmic(c);
127
+ if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);
105
128
  return linearToSrgb(c);
106
129
  }
107
130
  `;
@@ -128,6 +128,108 @@ fn motionSkinnedVert(
128
128
  return out;
129
129
  }
130
130
 
131
+ /*
132
+ * The same for a rig with eight influences a vertex, the second four at the scene's own locations
133
+ * 14 and 15 and summed after the first, as \`skinMatrix()\` sums them with \`SKIN_EIGHT\` on — or the
134
+ * motion of a face or a shoulder would be the four heaviest joints' alone.
135
+ */
136
+ @vertex
137
+ fn motionSkinnedEightVert(
138
+ @location(0) position: vec3<f32>,
139
+ @location(11) joints: vec4<f32>,
140
+ @location(12) weights: vec4<f32>,
141
+ @location(14) joints2: vec4<f32>,
142
+ @location(15) weights2: vec4<f32>,
143
+ ) -> Varyings {
144
+ let skin = skinOf(palette, joints, weights) + skinOf(palette, joints2, weights2);
145
+ let wasSkin =
146
+ skinOf(previousPalette, joints, weights) + skinOf(previousPalette, joints2, weights2);
147
+ let world = draw.model * (skin * vec4<f32>(position, 1.0));
148
+ let wasWorld = draw.previousModel * (wasSkin * vec4<f32>(position, 1.0));
149
+ var out: Varyings;
150
+ out.clip = frame.raster * world;
151
+ out.now = frame.viewProj * world;
152
+ out.was = frame.previousViewProj * wasWorld;
153
+ return out;
154
+ }
155
+
156
+ /*
157
+ * The cloth-bound stage: a skinned vertex moved toward its simulation triangle by its painted
158
+ * weight, as shaders/clothBinding.ts moves it in the scene, once with this frame's particles and
159
+ * once with last frame's. **The same arithmetic in the same order**, or the depth this stage writes
160
+ * is not the scene's and the equal test admits none of the garment. The binding's turn is not
161
+ * needed: a motion is where a point went, not which way it faces.
162
+ */
163
+ @group(2) @binding(0) var clothBinding: texture_2d<f32>;
164
+ @group(2) @binding(1) var clothParticles: texture_2d<f32>;
165
+ @group(2) @binding(2) var previousClothParticles: texture_2d<f32>;
166
+
167
+ fn clothTexel(i: i32, width: i32) -> vec2<i32> {
168
+ return vec2<i32>(i % width, i / width);
169
+ }
170
+
171
+ fn clothPoint(particles: texture_2d<f32>, indices: vec4<f32>, at: vec4<f32>) -> vec3<f32> {
172
+ let width = i32(textureDimensions(particles, 0).x);
173
+ let a = i32(indices.x);
174
+ let b = i32(indices.y);
175
+ let c = i32(indices.z);
176
+ let pa = textureLoad(particles, clothTexel(a, width), 0).xyz;
177
+ if (a == b && b == c) {
178
+ return pa;
179
+ }
180
+ let pb = textureLoad(particles, clothTexel(b, width), 0).xyz;
181
+ let pc = textureLoad(particles, clothTexel(c, width), 0).xyz;
182
+ let n = normalize(cross(pb - pa, pc - pa));
183
+ return pa * (1.0 - at.x - at.y) + pb * at.x + pc * at.y + n * at.z;
184
+ }
185
+
186
+ /* A skinned point now and last frame, carried to the cloth by the vertex's own weight. */
187
+ fn clothVaryings(vertex: u32, world: vec4<f32>, wasWorld: vec4<f32>) -> Varyings {
188
+ let width = i32(textureDimensions(clothBinding, 0).x);
189
+ let indices = textureLoad(clothBinding, clothTexel(i32(vertex) * 2, width), 0);
190
+ let at = textureLoad(clothBinding, clothTexel(i32(vertex) * 2 + 1, width), 0);
191
+ let follow = indices.w;
192
+ let placed = vec4<f32>(mix(world.xyz, clothPoint(clothParticles, indices, at), follow), world.w);
193
+ let wasPlaced = vec4<f32>(
194
+ mix(wasWorld.xyz, clothPoint(previousClothParticles, indices, at), follow),
195
+ wasWorld.w);
196
+ var out: Varyings;
197
+ out.clip = frame.raster * placed;
198
+ out.now = frame.viewProj * placed;
199
+ out.was = frame.previousViewProj * wasPlaced;
200
+ return out;
201
+ }
202
+
203
+ @vertex
204
+ fn motionClothVert(
205
+ @builtin(vertex_index) vertex: u32,
206
+ @location(0) position: vec3<f32>,
207
+ @location(11) joints: vec4<f32>,
208
+ @location(12) weights: vec4<f32>,
209
+ ) -> Varyings {
210
+ let world = draw.model * (skinOf(palette, joints, weights) * vec4<f32>(position, 1.0));
211
+ let wasWorld =
212
+ draw.previousModel * (skinOf(previousPalette, joints, weights) * vec4<f32>(position, 1.0));
213
+ return clothVaryings(vertex, world, wasWorld);
214
+ }
215
+
216
+ @vertex
217
+ fn motionClothEightVert(
218
+ @builtin(vertex_index) vertex: u32,
219
+ @location(0) position: vec3<f32>,
220
+ @location(11) joints: vec4<f32>,
221
+ @location(12) weights: vec4<f32>,
222
+ @location(14) joints2: vec4<f32>,
223
+ @location(15) weights2: vec4<f32>,
224
+ ) -> Varyings {
225
+ let skin = skinOf(palette, joints, weights) + skinOf(palette, joints2, weights2);
226
+ let wasSkin =
227
+ skinOf(previousPalette, joints, weights) + skinOf(previousPalette, joints2, weights2);
228
+ let world = draw.model * (skin * vec4<f32>(position, 1.0));
229
+ let wasWorld = draw.previousModel * (wasSkin * vec4<f32>(position, 1.0));
230
+ return clothVaryings(vertex, world, wasWorld);
231
+ }
232
+
131
233
  /*
132
234
  * The rewritten stage: a mesh whose vertices were rewritten this frame reads last frame's rows from
133
235
  * a second buffer of the same stride, so each vertex carries both of its positions.
@@ -951,6 +951,19 @@ fn reconSceneBilinear(p: vec2<f32>) -> vec3<f32> {
951
951
  return textureSampleLevel(sceneTexture, linearSampler, uv, 0.0).xyz;
952
952
  }
953
953
 
954
+ /*
955
+ * The surface's share of an output pixel, read from the jittered scene it was rebuilt from
956
+ * (sceneCoverage.ts): what the blended draws that landed before the reconstruction left of it.
957
+ * The late ones then cover the picture this is stored in, so the composite's occlusion reads both.
958
+ * Bilinear and at the unjittered position, which is where the colour beside it stands.
959
+ */
960
+ fn reconSceneShare(ox: i32, oy: i32) -> f32 {
961
+ let output = vec2<f32>(f32(recon.outputSize.x), f32(recon.outputSize.y));
962
+ let render = vec2<f32>(f32(recon.renderSize.x), f32(recon.renderSize.y));
963
+ let at = (vec2<f32>(f32(ox), f32(oy)) + 0.5) / output + recon.jitter / render;
964
+ return textureSampleLevel(sceneTexture, linearSampler, vec2<f32>(at.x, 1.0 - at.y), 0.0).w;
965
+ }
966
+
954
967
  fn reconHistoryBilinear(p: vec2<f32>) -> vec4<f32> {
955
968
  let size = vec2<f32>(f32(recon.outputSize.x), f32(recon.outputSize.y));
956
969
  let uv = vec2<f32>(p.x + 0.5, size.y - 0.5 - p.y) / size;
@@ -973,7 +986,8 @@ fn sharpenMain(@builtin(global_invocation_id) id: vec3<u32>) {
973
986
  if (id.x >= recon.outputSize.x || id.y >= recon.outputSize.y) { return; }
974
987
  let y = i32(recon.outputSize.y) - 1 - i32(id.y);
975
988
  let value = reconSharpenAt(i32(id.x), y);
976
- textureStore(resolved, vec2<i32>(i32(id.x), i32(id.y)), vec4<f32>(value, 1.0));
989
+ let share = reconSceneShare(i32(id.x), y);
990
+ textureStore(resolved, vec2<i32>(i32(id.x), i32(id.y)), vec4<f32>(value, share));
977
991
  }
978
992
  `;
979
993
  }
@@ -25,6 +25,7 @@ import { DEPTH_OF_FIELD_GLSL } from './depthOfField.ts';
25
25
  import { AUTO_EXPOSURE_GLSL } from './exposure.ts';
26
26
  import { LOCAL_EXPOSURE_GLSL } from './localExposure.ts';
27
27
  import { FILM_LOOK_GLSL } from './filmLook.ts';
28
+ import { HIGHLIGHT_SHOULDER_GLSL } from './outputTransform.ts';
28
29
 
29
30
  export const RUSH_FRAG = `#version 300 es
30
31
  precision highp float;
@@ -242,9 +243,11 @@ vec3 acesFilmic(vec3 x) {
242
243
  return clamp(ACES_OUTPUT * (a / b), 0.0, 1.0);
243
244
  }
244
245
 
246
+ ${HIGHLIGHT_SHOULDER_GLSL}
245
247
  vec3 grade(vec3 c) {
246
248
  if (uOutputTransform == 0) return c;
247
249
  if (uOutputTransform == 2) c = acesFilmic(c);
250
+ if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);
248
251
  return linearToSrgb(c);
249
252
  }
250
253
 
@@ -335,7 +338,8 @@ vec3 finish(vec3 light) {
335
338
  }
336
339
 
337
340
  void main() {
338
- vec3 scene = textureLod(uScene, vUv, 0.0).rgb;
341
+ vec4 sampled = textureLod(uScene, vUv, 0.0);
342
+ vec3 scene = sampled.rgb;
339
343
  if (uMotionStrength > 0.0) scene = cameraBlur(scene);
340
344
  /*
341
345
  * Defocus after the camera smear and before everything else, because a lens is the last thing
@@ -350,8 +354,15 @@ void main() {
350
354
  * not then darkened again along its own trail. mix from 1 rather than a multiply by the
351
355
  * strength, so the parameter reads as "how much of this occlusion", and 0 is exactly the
352
356
  * frame that existed before the effect did.
357
+ *
358
+ * **Scaled by the surface's share of the pixel**, which the scene carries in its alpha
359
+ * (sceneCoverage.ts): the occlusion was measured from the depth the opaque world left, and a
360
+ * pane, a puff of smoke or a caption in front of a corner wrote no depth, so darkening all of the
361
+ * pixel darkened them by a corner they hide. Where nothing blended landed the share is 1 and
362
+ * this is the frame it was.
353
363
  */
354
- float ao = mix(1.0, textureLod(uAo, vUv + uAoOffset, 0.0).r, uAoStrength);
364
+ float share = clamp(sampled.a, 0.0, 1.0);
365
+ float ao = mix(1.0, textureLod(uAo, vUv + uAoOffset, 0.0).r, uAoStrength * share);
355
366
 
356
367
  /*
357
368
  * Distance from the centre, corrected so the falloff is a circle on screen rather than
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Skin's screen-space scattering: one axis of the blur that spreads the diffuse pass by Burley's
3
+ * profile, and on the second axis the sum the frame takes back. `skinBlur.ts` beside the render
4
+ * directory is the kernel's reference and says what it gives up.
5
+ *
6
+ * **One pass, run twice**: along x into a target of its own, keeping the profile in the alpha, then
7
+ * along y blended additively into the frame — its colour added, the frame's alpha kept.
8
+ *
9
+ * **Refuses what is not the same skin**: a neighbour that wrote no skin, or one further in depth
10
+ * than four of the profile's widest distances, takes no weight, and what is kept is divided by
11
+ * what was kept, so a cheek's light does not pull toward the black beside its silhouette. A tap
12
+ * part skin is counted for the part it is: the target is read as coverage-weighted, which is what
13
+ * a filtered tap across an edge already is.
14
+ */
15
+ import { glslIsFarDepth, glslSceneDepthToNdc } from '../depthConvention.ts';
16
+ import { SKIN_BLUR_EDGES, SKIN_BLUR_TAPS, SKIN_PROFILES } from '../skinBlur.ts';
17
+
18
+ const list = (values: readonly number[]): string => values.map((v) => v.toFixed(2)).join(', ');
19
+
20
+ export const SKIN_BLUR_FRAG = `#version 300 es
21
+ precision highp float;
22
+
23
+ in vec2 vUv;
24
+
25
+ /** The diffuse pass, or the first axis' result: diffuse in rgb, the profile as (index + 1) / 8. */
26
+ uniform sampler2D uSkin;
27
+ /** highp, for the reason the ambient occlusion blur gives: eight bits of depth find false edges. */
28
+ uniform highp sampler2D uDepth;
29
+ /** One texel along the axis being blurred, in UV. The other component is zero. */
30
+ uniform vec2 uStep;
31
+ /** A depth sample to view-space metres, as the ambient occlusion blur takes it. */
32
+ uniform vec4 uDepthToViewZ;
33
+ /** How many pixels a metre spans at a metre's distance. */
34
+ uniform float uFocal;
35
+ /** Each profile's scatter distance per channel, in metres. */
36
+ uniform vec4 uProfiles[${SKIN_PROFILES}];
37
+
38
+ out vec4 fragColor;
39
+
40
+ const float TAPS[${SKIN_BLUR_TAPS.length}] = float[${SKIN_BLUR_TAPS.length}](${list(SKIN_BLUR_TAPS)});
41
+ const float EDGES[${SKIN_BLUR_EDGES.length}] = float[${SKIN_BLUR_EDGES.length}](${list(SKIN_BLUR_EDGES)});
42
+
43
+ float viewZ(float depth) {
44
+ float ndc = ${glslSceneDepthToNdc('depth')};
45
+ return (uDepthToViewZ.x * ndc + uDepthToViewZ.y) / (uDepthToViewZ.z * ndc + uDepthToViewZ.w);
46
+ }
47
+
48
+ /** Burley's profile across a line, integrated from 0 to x: one half at infinity. */
49
+ vec3 burleyMass(float x, vec3 d) {
50
+ return 0.5 - (exp(-x / d) + 3.0 * exp(-x / (3.0 * d))) * 0.125;
51
+ }
52
+
53
+ void main() {
54
+ vec4 own = textureLod(uSkin, vUv, 0.0);
55
+ /*
56
+ * Not skin: nothing here to spread, and nothing to add. Any alpha is skin, because a pixel a
57
+ * silhouette crosses under multisampling resolves to its covered share of the profile's code, and
58
+ * its colour to that share of the diffuse, which is exactly what it owes the frame.
59
+ */
60
+ if (own.a < 1e-4) {
61
+ fragColor = vec4(0.0);
62
+ return;
63
+ }
64
+ float depth = textureLod(uDepth, vUv, 0.0).r;
65
+ if (${glslIsFarDepth('depth')}) {
66
+ fragColor = own;
67
+ return;
68
+ }
69
+ /* A part-covered pixel's code is its share of the whole: read it as the nearest, which is the
70
+ right profile at half coverage and up, and a neighbouring one only on a silhouette below it. */
71
+ int profile = clamp(int(own.a * 8.0 + 0.5) - 1, 0, ${SKIN_PROFILES - 1});
72
+ vec3 d = max(uProfiles[profile].xyz, vec3(1e-6));
73
+ float widest = max(d.r, max(d.g, d.b));
74
+ float z = abs(viewZ(depth));
75
+ /* The widest distance in pixels here: under half of one, there is nothing to spread across. */
76
+ float pixels = widest * uFocal / max(z, 1e-4);
77
+ if (pixels < 0.5) {
78
+ fragColor = own;
79
+ return;
80
+ }
81
+ vec3 centre = 2.0 * burleyMass(EDGES[0] * widest, d);
82
+ vec3 sum = own.rgb * centre;
83
+ vec3 weight = centre;
84
+ for (int i = 1; i < ${SKIN_BLUR_TAPS.length}; i++) {
85
+ vec3 outer = i < ${SKIN_BLUR_EDGES.length} ? burleyMass(EDGES[min(i, ${SKIN_BLUR_EDGES.length - 1})] * widest, d) : vec3(0.5);
86
+ vec3 w = outer - burleyMass(EDGES[i - 1] * widest, d);
87
+ for (int side = -1; side <= 1; side += 2) {
88
+ vec2 uv = vUv + uStep * (float(side) * TAPS[i] * pixels);
89
+ vec4 tap = textureLod(uSkin, uv, 0.0);
90
+ float tapZ = abs(viewZ(textureLod(uDepth, uv, 0.0).r));
91
+ float keep = clamp(1.0 - abs(tapZ - z) / (4.0 * widest), 0.0, 1.0);
92
+ /*
93
+ * How much of the tap is skin: a filtered tap astride a silhouette is part skin and part
94
+ * the black beside it, in colour and in alpha alike, so its alpha over this pixel's says the
95
+ * share, and the colour is already that share of the skin's — counted as such, it neither
96
+ * darkens the edge nor drops out.
97
+ */
98
+ float cover = clamp(tap.a / own.a, 0.0, 1.0);
99
+ sum += tap.rgb * w * keep;
100
+ weight += w * (cover * keep);
101
+ }
102
+ }
103
+ fragColor = vec4(sum / weight, own.a);
104
+ }
105
+ `;
@@ -31,6 +31,18 @@ uniform highp sampler2D uJointPalette;
31
31
 
32
32
  layout(location = 11) in vec4 aJoints;
33
33
  layout(location = 12) in vec4 aWeights;
34
+ /*
35
+ * **The fifth to eighth influences, read only by a pipeline built for a mesh that has them.**
36
+ *
37
+ * Declared in every skinned variant at the last two locations WebGL2 guarantees — 13 is the channel,
38
+ * and an instanced draw, which takes 11 to 15, cannot skin — and fed zeros by the absent-attribute
39
+ * constants for a mesh with four. \`SKIN_EIGHT\` is a pipeline constant rather than a permutation,
40
+ * so the generated vertex stages do not double: off, the device compiles the second sum away and a
41
+ * four-influence mesh pays nothing; on, it is four more palette reads a vertex.
42
+ */
43
+ const bool SKIN_EIGHT = false; // wgsl:override
44
+ layout(location = 14) in vec4 aJoints2;
45
+ layout(location = 15) in vec4 aWeights2;
34
46
 
35
47
  /** One joint's matrix, read as four consecutive texels of row 0. */
36
48
  mat4 jointMatrix(int index) {
@@ -51,9 +63,24 @@ mat4 jointMatrix(int index) {
51
63
  * by hand with unnormalised weights, which shades slightly dark or bright rather than breaking.
52
64
  */
53
65
  mat4 skinMatrix() {
54
- return jointMatrix(int(aJoints.x)) * aWeights.x
55
- + jointMatrix(int(aJoints.y)) * aWeights.y
56
- + jointMatrix(int(aJoints.z)) * aWeights.z
57
- + jointMatrix(int(aJoints.w)) * aWeights.w;
66
+ mat4 m = jointMatrix(int(aJoints.x)) * aWeights.x
67
+ + jointMatrix(int(aJoints.y)) * aWeights.y
68
+ + jointMatrix(int(aJoints.z)) * aWeights.z
69
+ + jointMatrix(int(aJoints.w)) * aWeights.w;
70
+ if (SKIN_EIGHT) {
71
+ m += jointMatrix(int(aJoints2.x)) * aWeights2.x
72
+ + jointMatrix(int(aJoints2.y)) * aWeights2.y
73
+ + jointMatrix(int(aJoints2.z)) * aWeights2.z
74
+ + jointMatrix(int(aJoints2.w)) * aWeights2.w;
75
+ }
76
+ return m;
58
77
  }
78
+
59
79
  `;
80
+
81
+ /** The source above with the eight-influence switch on, for a WebGL2 program built for such a mesh. */
82
+ export function skinEight(source: string): string {
83
+ const off = 'const bool SKIN_EIGHT = false;';
84
+ if (!source.includes(off)) throw new Error('skinEight: the source carries no SKIN_EIGHT switch');
85
+ return source.replace(off, 'const bool SKIN_EIGHT = true;');
86
+ }
@@ -211,11 +211,18 @@ vec3 historyCatmullRom(vec2 uv) {
211
211
  }
212
212
 
213
213
  void main() {
214
- vec3 current = textureLod(uScene, vUv, 0.0).rgb;
214
+ /*
215
+ * **The alpha is this frame's and takes no history.** It is how much of the pixel is still the
216
+ * opaque surface (sceneCoverage.ts), which the composite's occlusion reads after this has
217
+ * replaced the scene, so it passes through untouched. The history is read for its colour alone.
218
+ */
219
+ vec4 sampled = textureLod(uScene, vUv, 0.0);
220
+ vec3 current = sampled.rgb;
221
+ float share = sampled.a;
215
222
 
216
223
  /* Nothing to blend towards: the first frame, a resize, a cut, or the effect switched off. */
217
224
  if (uHistoryBlend <= 0.0) {
218
- fragColor = vec4(current, 1.0);
225
+ fragColor = vec4(current, share);
219
226
  flickerOut = freshRecord(current);
220
227
  flickerMotionOut = vec4(0.0);
221
228
  return;
@@ -248,7 +255,7 @@ void main() {
248
255
  vec4 previous = uReprojection * clip;
249
256
  /* Behind the previous eye: there is no last-frame position to sample. */
250
257
  if (previous.w <= 0.0) {
251
- fragColor = vec4(current, 1.0);
258
+ fragColor = vec4(current, share);
252
259
  flickerOut = freshRecord(current);
253
260
  flickerMotionOut = vec4(metres, 0.0, 0.0, 0.0);
254
261
  return;
@@ -261,7 +268,7 @@ void main() {
261
268
  * as a smear for as long as the camera kept turning.
262
269
  */
263
270
  if (wasUv.x < 0.0 || wasUv.x > 1.0 || wasUv.y < 0.0 || wasUv.y > 1.0) {
264
- fragColor = vec4(current, 1.0);
271
+ fragColor = vec4(current, share);
265
272
  flickerOut = freshRecord(current);
266
273
  flickerMotionOut = vec4(metres, 0.0, 0.0, 0.0);
267
274
  return;
@@ -320,6 +327,6 @@ void main() {
320
327
  repeated * still
321
328
  );
322
329
  flickerMotionOut = vec4(metres, swingOut, 0.0, 0.0);
323
- fragColor = vec4(mix(current, bounded, blend), 1.0);
330
+ fragColor = vec4(mix(current, bounded, blend), share);
324
331
  }
325
332
  `;
@@ -2,6 +2,7 @@ import { resolveGlass, type GlassOptions, type ResolvedGlass } from './glass.ts'
2
2
  import type { ReadonlyMat4 } from 'gl-matrix';
3
3
  import type { InstanceData } from './instancedMesh.ts';
4
4
  import type { InstancedHandle, MeshHandle, ScatterHandle } from './backend/api.ts';
5
+ import type { ClothBindingHandle, ClothParticlesHandle } from './backend/api.ts';
5
6
  import type { MeshInstances } from './instances.ts';
6
7
  import type { SurfaceMaterial } from './surfaceTexture.ts';
7
8
  import type { SurfaceTextureHandle } from './backend/api.ts';
@@ -70,6 +71,12 @@ export function glassOf(material: SceneCasterMaterial | undefined, out: Resolved
70
71
  return resolveGlass(material?.glass, out);
71
72
  }
72
73
 
74
+ /** A skinned caster's cloth: the mesh's binding and the character's particles. See `setCloth`. */
75
+ export interface ShadowCasterCloth {
76
+ readonly binding: ClothBindingHandle;
77
+ readonly particles: ClothParticlesHandle;
78
+ }
79
+
73
80
  export interface ShadowCasterSink {
74
81
  /** A rigid mesh at a model transform: the world, a prop, a static piece of scenery. */
75
82
  mesh(mesh: MeshHandle, model: ReadonlyMat4, material?: SceneCasterMaterial): void;
@@ -88,6 +95,12 @@ export interface ShadowCasterSink {
88
95
  model: ReadonlyMat4,
89
96
  palette: Float32Array,
90
97
  material?: SceneCasterMaterial,
98
+ /**
99
+ * The cloth the visible draw places this mesh by, if any — for the reason the palette is not
100
+ * optional in spirit: a garment cast from its skinning alone throws a shadow that does not
101
+ * move as the cloth does.
102
+ */
103
+ cloth?: ShadowCasterCloth,
91
104
  ): void;
92
105
  /**
93
106
  * An instanced batch of rigid meshes, placed by the same matrices the visible draw uses.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Skin's screen-space scattering, as `shaders/skinBlur.ts` spreads it: the reference its tests hold
3
+ * the kernel to, and nothing at run time calls it.
4
+ *
5
+ * **What it spreads.** With `skinScattering: 'screen-space'`, a skin draw writes its specular to the
6
+ * frame and its diffuse — the light that went beneath the surface — to a target of its own, with the
7
+ * profile it scatters by in the alpha. Before anything blended is drawn, that target is blurred
8
+ * along the screen's two axes by Burley's normalised diffusion, one width per channel, and added
9
+ * back: light that entered a cheek leaves it a centimetre away, reddest furthest, across the
10
+ * pixels that centimetre covers at the cheek's distance. The pre-integrated fit stays in the lit
11
+ * stage for what this cannot see — a terminator's curvature — and this carries what that cannot:
12
+ * light crossing a shadow's edge, or a nostril's rim, in the picture rather than in a fit.
13
+ *
14
+ * **Separable, which the profile is not**: two 1D passes with Burley's profile integrated across a
15
+ * line, as Jimenez's separable subsurface scattering does. What it gives up is the radial shape, a
16
+ * blur a little squarer than the profile; what it buys is thirteen taps an axis rather than a disc.
17
+ *
18
+ * **Each tap is the profile's mass over its cell, in closed form**, so a channel's weights sum to
19
+ * one before any are refused, and a channel that travels a quarter as far as the widest keeps most
20
+ * of its light at the centre. The taps are placed in units of the widest channel's distance and
21
+ * closer together near the centre, where the profile's sharp peak is.
22
+ */
23
+
24
+ /** The taps' distances from the centre, one side, in units of the widest channel's distance. */
25
+ export const SKIN_BLUR_TAPS = [0, 0.4, 1.1, 2.1, 3.5, 5.6, 9] as const;
26
+
27
+ /**
28
+ * Where each tap's cell ends, in the same units: halfway to the next tap, and the last cell runs
29
+ * to infinity, so the profile's whole mass is somewhere.
30
+ */
31
+ export const SKIN_BLUR_EDGES = [0.2, 0.75, 1.6, 2.8, 4.55, 7.3] as const;
32
+
33
+ /** How many profiles a frame's skin can name: `SkinModel.profile`. */
34
+ export const SKIN_PROFILES = 8;
35
+
36
+ /**
37
+ * Burley's profile across a line, `(e^(−x/d) + e^(−x/3d)) / 8d`, integrated from 0 to `x`: half of
38
+ * it lies on each side, so this reaches ½ at infinity.
39
+ */
40
+ export function burleyMass(x: number, d: number): number {
41
+ return 0.5 - (Math.exp(-x / d) + 3 * Math.exp(-x / (3 * d))) / 8;
42
+ }
43
+
44
+ /**
45
+ * Tap `tap`'s weight for one channel scattering `d`, the taps spaced by `widest`: the centre's cell
46
+ * is both sides of it, every other cell one side, and a tap on each side takes this weight.
47
+ */
48
+ export function skinBlurWeight(tap: number, d: number, widest: number): number {
49
+ if (tap === 0) return 2 * burleyMass((SKIN_BLUR_EDGES[0] as number) * widest, d);
50
+ const inner = (SKIN_BLUR_EDGES[tap - 1] as number) * widest;
51
+ const outer = tap < SKIN_BLUR_EDGES.length ? (SKIN_BLUR_EDGES[tap] as number) * widest : Infinity;
52
+ return (outer === Infinity ? 0.5 : burleyMass(outer, d)) - burleyMass(inner, d);
53
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The skin model's arithmetic, as `shaders/flat/skin.ts` computes it: the reference its tests hold
3
+ * the fit to, and nothing at run time calls it.
4
+ *
5
+ * **Pre-integrated, after Penner 2011, with the profile integrated rather than looked up.** Light
6
+ * entering skin leaves it a little way off, so on a curved surface a point is lit by its
7
+ * neighbours' `N·L` as well as its own: the clamped cosine averaged around a ring of the surface's
8
+ * curvature, weighted by the diffusion profile at each neighbour's distance. The profile is Burley's
9
+ * normalised diffusion, one per channel, at a scatter distance `d` — the model's `radius` times its
10
+ * scatter colour — projected onto the ring's plane, as a cylinder's cross-section sees it. The
11
+ * result depends on `N·L` and on one number per channel, `k = d × curvature`, and at `k = 0` it is
12
+ * Lambert's.
13
+ *
14
+ * **Fitted rather than tabulated**, so no seventeenth sampler and no image: the ring average is the
15
+ * clamped cosine convolved with an angular kernel, so it splits into the cosine's first harmonic —
16
+ * scaled by one number, `κ₁(k)` — and a smoothed `|cos θ|` carrying every even harmonic:
17
+ *
18
+ * D(c, k) = ½ (κ₁ c + A √(c² + ε²) + B)
19
+ *
20
+ * with `κ₁`, `A`, `ε` and `B` closed forms in `k` that are exactly `1, 1, 0, 0` at `k = 0`. The
21
+ * test measures it against the integral done numerically: **worst 0.0105 over `k` to 10**, beyond
22
+ * which the shader holds `k` — a scatter ten times the surface's own radius, where the answer is
23
+ * nearly flat already.
24
+ */
25
+
26
+ /** Schlick's base for skin, at index 1.4: `((1 − n) / (1 + n))²`. */
27
+ export const SKIN_F0 = ((1 - 1.4) / (1 + 1.4)) ** 2;
28
+
29
+ /** The largest `k` the fit is held to, and where the shader clamps it. */
30
+ export const SKIN_MAX_K = 10;
31
+
32
+ /**
33
+ * The fit's eight numbers, found by minimising its worst error against the numeric integral over
34
+ * `c` in [−1, 1] and `k` in (0, 10]:
35
+ *
36
+ * - `kappa`: `κ₁ = (1 + a k² + b k³)^(−1/6)` — the first harmonic's share, quadratic at zero and
37
+ * falling as `1/√k`, as the integral's does;
38
+ * - `spread`: `A = (1 + a k³)^(−1/6)`;
39
+ * - `soften`: `ε = a k / (1 + b k)`, how far `|c|` is rounded at the terminator;
40
+ * - `floor`: `B = (1 − A)(a + b (1 − A))`, the even part's floor as `A` gives up `|c|`.
41
+ */
42
+ export const SKIN_FIT = {
43
+ kappa: [22.6772, 101.349],
44
+ spread: 838.87,
45
+ soften: [1.3081, 3.65743],
46
+ floor: [0.508537, 0.129496],
47
+ } as const;
48
+
49
+ /** The ring-averaged `N·L` for one channel: `c` is `N·L`, `k` that channel's scatter × curvature. */
50
+ export function skinRing(c: number, k: number): number {
51
+ const q = Math.min(Math.max(k, 0), SKIN_MAX_K);
52
+ const k2 = q * q;
53
+ const kappa = (1 + SKIN_FIT.kappa[0] * k2 + SKIN_FIT.kappa[1] * k2 * q) ** (-1 / 6);
54
+ const spread = (1 + SKIN_FIT.spread * k2 * q) ** (-1 / 6);
55
+ const soften = (SKIN_FIT.soften[0] * q) / (1 + SKIN_FIT.soften[1] * q);
56
+ const given = 1 - spread;
57
+ const floor = given * (SKIN_FIT.floor[0] + SKIN_FIT.floor[1] * given);
58
+ return 0.5 * (kappa * c + spread * Math.sqrt(c * c + soften * soften) + floor);
59
+ }
60
+
61
+ /**
62
+ * How far into a penumbra skin's light reaches, as a width the shadow is assumed to have: the
63
+ * lamp loop reads a shadow where its derivative cannot be taken, so its width in the world is not
64
+ * known there. Two centimetres is a soft edge across a face.
65
+ */
66
+ export const SKIN_PENUMBRA_WIDTH = 0.02;
67
+
68
+ /**
69
+ * A shadow on skin, one channel at scatter distance `d`: the shadow's own value raised to
70
+ * `1 / (1 + d / width)`, so light reaches further into the penumbra the further that channel
71
+ * travels beneath the surface — red furthest, which is the red edge a shadow on skin has — and a
72
+ * shadow stays whole at 0 and absent at 1. **What it gives up** is the profile itself: Penner
73
+ * convolves a sharpened shadow with it, which needs the penumbra's width in the world and hardens
74
+ * the narrow channels' edge where nothing widened the map to make room.
75
+ */
76
+ export function skinPenumbra(shadow: number, d: number): number {
77
+ return Math.max(shadow, 0) ** (1 / (1 + d / SKIN_PENUMBRA_WIDTH));
78
+ }
79
+
80
+ /**
81
+ * Light through a thin part, one channel: `transmission × exp(−thickness / d)` of the light behind,
82
+ * by how squarely it is behind. A part as thick as its curvature's diameter — an ear's rim at a few
83
+ * millimetres, a cheek at ten centimetres — passes red through the one and nothing through the other.
84
+ */
85
+ export function skinTransmission(
86
+ backNdl: number,
87
+ thickness: number,
88
+ d: number,
89
+ transmission: number,
90
+ ): number {
91
+ return transmission * Math.exp(-thickness / Math.max(d, 1e-6)) * Math.max(backNdl, 0);
92
+ }