@driftengine/core 4.13.3 → 4.13.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 (153) hide show
  1. package/CHANGELOG.json +52 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +3 -2
  4. package/dist/render/anisotropicLobe.d.ts +5 -0
  5. package/dist/render/anisotropicLobe.js +13 -0
  6. package/dist/render/atmosphere.d.ts +79 -1
  7. package/dist/render/atmosphere.js +66 -19
  8. package/dist/render/backend/webgl2/renderer.d.ts +34 -2
  9. package/dist/render/backend/webgl2/renderer.js +44 -12
  10. package/dist/render/backend/webgpu/boltPass.d.ts +1 -1
  11. package/dist/render/backend/webgpu/causticsPass.d.ts +1 -1
  12. package/dist/render/backend/webgpu/clothSetPack.js +1 -0
  13. package/dist/render/backend/webgpu/clothSolvePack.d.ts +5 -2
  14. package/dist/render/backend/webgpu/clothSolvePack.js +2 -1
  15. package/dist/render/backend/webgpu/filmPass.d.ts +1 -1
  16. package/dist/render/backend/webgpu/gpuClothSet.js +1 -1
  17. package/dist/render/backend/webgpu/gpuDrivenPass.js +20 -3
  18. package/dist/render/backend/webgpu/linePass.d.ts +1 -1
  19. package/dist/render/backend/webgpu/mediumFields.d.ts +17 -0
  20. package/dist/render/backend/webgpu/mediumFields.js +74 -0
  21. package/dist/render/backend/webgpu/plumePass.d.ts +3 -3
  22. package/dist/render/backend/webgpu/postPass.d.ts +7 -2
  23. package/dist/render/backend/webgpu/renderer.d.ts +13 -3
  24. package/dist/render/backend/webgpu/renderer.js +86 -150
  25. package/dist/render/backend/webgpu/scatterPass.d.ts +1 -1
  26. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.d.ts +1 -1
  27. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.js +7 -5
  28. package/dist/render/backend/webgpu/waterPass.d.ts +1 -1
  29. package/dist/render/environmentMixing.d.ts +18 -0
  30. package/dist/render/environmentMixing.js +40 -0
  31. package/dist/render/fog.d.ts +43 -1
  32. package/dist/render/fog.js +87 -22
  33. package/dist/render/globalMedium.d.ts +112 -10
  34. package/dist/render/globalMedium.js +96 -1
  35. package/dist/render/globalMediumPass.d.ts +7 -3
  36. package/dist/render/globalMediumPass.js +22 -1
  37. package/dist/render/hairLobes.d.ts +27 -2
  38. package/dist/render/hairLobes.js +57 -4
  39. package/dist/render/lightBudget.d.ts +12 -0
  40. package/dist/render/lightBudget.js +24 -0
  41. package/dist/render/sceneTarget.d.ts +5 -3
  42. package/dist/render/sceneTarget.js +14 -8
  43. package/dist/render/shaders/arcane.d.ts +1 -1
  44. package/dist/render/shaders/bolt.d.ts +1 -1
  45. package/dist/render/shaders/caustics.d.ts +1 -1
  46. package/dist/render/shaders/film.d.ts +1 -1
  47. package/dist/render/shaders/film.js +4 -1
  48. package/dist/render/shaders/fire.d.ts +1 -1
  49. package/dist/render/shaders/flat/anisotropic.d.ts +8 -1
  50. package/dist/render/shaders/flat/anisotropic.js +25 -3
  51. package/dist/render/shaders/flat/hair.js +77 -27
  52. package/dist/render/shaders/flat/main.d.ts +1 -1
  53. package/dist/render/shaders/flat/main.js +23 -3
  54. package/dist/render/shaders/flat/models.js +12 -5
  55. package/dist/render/shaders/flat/preamble.js +7 -0
  56. package/dist/render/shaders/fog.d.ts +4 -4
  57. package/dist/render/shaders/fog.js +81 -32
  58. package/dist/render/shaders/generated/arcane.wgsl.d.ts +29 -39
  59. package/dist/render/shaders/generated/arcane.wgsl.js +29 -39
  60. package/dist/render/shaders/generated/bolt.wgsl.d.ts +26 -36
  61. package/dist/render/shaders/generated/bolt.wgsl.js +26 -36
  62. package/dist/render/shaders/generated/caustics.wgsl.d.ts +25 -35
  63. package/dist/render/shaders/generated/caustics.wgsl.js +25 -35
  64. package/dist/render/shaders/generated/film.wgsl.d.ts +28 -38
  65. package/dist/render/shaders/generated/film.wgsl.js +28 -38
  66. package/dist/render/shaders/generated/fire.wgsl.d.ts +28 -38
  67. package/dist/render/shaders/generated/fire.wgsl.js +28 -38
  68. package/dist/render/shaders/generated/flat.wgsl.d.ts +1466 -1546
  69. package/dist/render/shaders/generated/flat.wgsl.js +1489 -1569
  70. package/dist/render/shaders/generated/globalMedium.wgsl.d.ts +98 -12
  71. package/dist/render/shaders/generated/globalMedium.wgsl.js +98 -12
  72. package/dist/render/shaders/generated/line.wgsl.d.ts +25 -35
  73. package/dist/render/shaders/generated/line.wgsl.js +25 -35
  74. package/dist/render/shaders/generated/particle.wgsl.d.ts +100 -140
  75. package/dist/render/shaders/generated/particle.wgsl.js +100 -140
  76. package/dist/render/shaders/generated/rush.wgsl.d.ts +20 -10
  77. package/dist/render/shaders/generated/rush.wgsl.js +20 -10
  78. package/dist/render/shaders/generated/scatter.wgsl.d.ts +25 -35
  79. package/dist/render/shaders/generated/scatter.wgsl.js +25 -35
  80. package/dist/render/shaders/generated/smoke.wgsl.d.ts +28 -38
  81. package/dist/render/shaders/generated/smoke.wgsl.js +28 -38
  82. package/dist/render/shaders/generated/water.wgsl.d.ts +29 -39
  83. package/dist/render/shaders/generated/water.wgsl.js +29 -39
  84. package/dist/render/shaders/globalMedium.js +114 -10
  85. package/dist/render/shaders/gpudriven/blendRaster.wgsl.js +2 -1
  86. package/dist/render/shaders/gpudriven/fog.wgsl.d.ts +10 -4
  87. package/dist/render/shaders/gpudriven/fog.wgsl.js +77 -19
  88. package/dist/render/shaders/gpudriven/frameBlock.wgsl.d.ts +1 -1
  89. package/dist/render/shaders/gpudriven/frameBlock.wgsl.js +12 -1
  90. package/dist/render/shaders/gpudriven/shade.wgsl.js +3 -1
  91. package/dist/render/shaders/line.d.ts +1 -1
  92. package/dist/render/shaders/line.js +4 -2
  93. package/dist/render/shaders/localExposure.d.ts +42 -1
  94. package/dist/render/shaders/localExposure.js +73 -6
  95. package/dist/render/shaders/particle.d.ts +3 -3
  96. package/dist/render/shaders/particle.js +10 -6
  97. package/dist/render/shaders/scatter.d.ts +1 -1
  98. package/dist/render/shaders/scatter.js +4 -2
  99. package/dist/render/shaders/smoke.d.ts +1 -1
  100. package/dist/render/shaders/water.d.ts +1 -1
  101. package/dist/render/shaders/water.js +2 -1
  102. package/dist/render/surfaceModel.d.ts +23 -4
  103. package/dist/render/surfaceModel.js +12 -2
  104. package/package.json +6 -6
  105. package/src/index.ts +12 -2
  106. package/src/render/anisotropicLobe.ts +20 -0
  107. package/src/render/atmosphere.ts +153 -20
  108. package/src/render/backend/webgl2/renderer.ts +63 -13
  109. package/src/render/backend/webgpu/clothSetPack.ts +1 -0
  110. package/src/render/backend/webgpu/clothSolvePack.ts +6 -2
  111. package/src/render/backend/webgpu/gpuClothSet.ts +1 -1
  112. package/src/render/backend/webgpu/gpuDrivenPass.ts +20 -3
  113. package/src/render/backend/webgpu/mediumFields.ts +87 -0
  114. package/src/render/backend/webgpu/renderer.ts +109 -157
  115. package/src/render/backend/webgpu/shaders/clothSolve.wgsl.ts +7 -5
  116. package/src/render/environmentMixing.ts +48 -0
  117. package/src/render/fog.ts +125 -25
  118. package/src/render/globalMedium.ts +214 -11
  119. package/src/render/globalMediumPass.ts +29 -4
  120. package/src/render/hairLobes.ts +109 -3
  121. package/src/render/lightBudget.ts +32 -0
  122. package/src/render/sceneTarget.ts +15 -11
  123. package/src/render/shaders/film.ts +4 -1
  124. package/src/render/shaders/flat/anisotropic.ts +25 -3
  125. package/src/render/shaders/flat/hair.ts +77 -27
  126. package/src/render/shaders/flat/main.ts +23 -3
  127. package/src/render/shaders/flat/models.ts +12 -5
  128. package/src/render/shaders/flat/preamble.ts +7 -0
  129. package/src/render/shaders/fog.ts +81 -32
  130. package/src/render/shaders/generated/arcane.wgsl.ts +29 -39
  131. package/src/render/shaders/generated/bolt.wgsl.ts +26 -36
  132. package/src/render/shaders/generated/caustics.wgsl.ts +25 -35
  133. package/src/render/shaders/generated/film.wgsl.ts +28 -38
  134. package/src/render/shaders/generated/fire.wgsl.ts +28 -38
  135. package/src/render/shaders/generated/flat.wgsl.ts +1489 -1569
  136. package/src/render/shaders/generated/globalMedium.wgsl.ts +98 -12
  137. package/src/render/shaders/generated/line.wgsl.ts +25 -35
  138. package/src/render/shaders/generated/particle.wgsl.ts +100 -140
  139. package/src/render/shaders/generated/rush.wgsl.ts +20 -10
  140. package/src/render/shaders/generated/scatter.wgsl.ts +25 -35
  141. package/src/render/shaders/generated/smoke.wgsl.ts +28 -38
  142. package/src/render/shaders/generated/water.wgsl.ts +29 -39
  143. package/src/render/shaders/globalMedium.ts +114 -10
  144. package/src/render/shaders/gpudriven/blendRaster.wgsl.ts +2 -1
  145. package/src/render/shaders/gpudriven/fog.wgsl.ts +78 -19
  146. package/src/render/shaders/gpudriven/frameBlock.wgsl.ts +12 -1
  147. package/src/render/shaders/gpudriven/shade.wgsl.ts +3 -1
  148. package/src/render/shaders/line.ts +4 -2
  149. package/src/render/shaders/localExposure.ts +115 -6
  150. package/src/render/shaders/particle.ts +10 -6
  151. package/src/render/shaders/scatter.ts +4 -2
  152. package/src/render/shaders/water.ts +2 -1
  153. package/src/render/surfaceModel.ts +41 -6
@@ -26,7 +26,7 @@
26
26
  * whose drama is the contrast itself: a single shaft into a dark room wants the room dark, and the
27
27
  * strength is the caller's for that reason.
28
28
  */
29
- import { METER_GRID, METER_MAX_LOG2, METER_MIN_LOG2, METER_TAPS } from './exposure.ts';
29
+ import { ADAPTED_KEY, METER_GRID, METER_MAX_LOG2, METER_MIN_LOG2, METER_TAPS } from './exposure.ts';
30
30
 
31
31
  /** Bands of log luminance, and how many stops each is wide. */
32
32
  export const LOCAL_BINS = 10;
@@ -82,15 +82,27 @@ void main() {
82
82
  `;
83
83
 
84
84
  /**
85
- * The composite's half. Needs `AUTO_EXPOSURE_GLSL` before it, for the held brightness.
85
+ * The composite's half. Needs `AUTO_EXPOSURE_GLSL` and `uOutputExposure` before it, for the held
86
+ * brightness and the frame's exposure.
86
87
  *
87
88
  * A block is read clamped half a texel inside its own tiles, so the filter never reaches into the
88
89
  * neighbouring band's block. Every fetch is at level zero: the grid has one level, and the branch
89
90
  * on the strength above them is uniform but the pixel's band is not.
90
91
  */
91
92
  export const LOCAL_EXPOSURE_GLSL = `
92
- /** How far each region is brought toward the frame, 0 to 1. 0 is off and the whole of the off path. */
93
+ /** Above 0 when regions move at all: 0 is off and the whole of the off path. */
93
94
  uniform float uLocalExposure;
95
+ /**
96
+ * How regions move, as \`resolveLocalExposure\` packs it: the share of a region's stops from the
97
+ * pivot it keeps above the pivot and below it, each less one; the share of a pixel's own stops
98
+ * from its region it keeps, less one; and how far a region's level leans toward its tile's mean.
99
+ */
100
+ uniform vec4 uLocalShape;
101
+ /**
102
+ * What they move about: 1 for the frame's held brightness, 0 for middle grey at the frame's
103
+ * exposure; stops added to it; and the furthest a region moves either way.
104
+ */
105
+ uniform vec4 uLocalPivot;
94
106
  /** The bilateral grid: a block of tiles per band of brightness, and one of the tiles' means. */
95
107
  uniform highp sampler2D uExposureLocal;
96
108
 
@@ -109,9 +121,15 @@ float localExposureGain(vec2 uv, vec3 light) {
109
121
  vec2 banded = mix(localBlock(uv, lower), localBlock(uv, upper), clamp(b - lower, 0.0, 1.0));
110
122
  float tileMean = localBlock(uv, ${LOCAL_BINS.toFixed(1)}).r;
111
123
  float bilateral = banded.y > 1e-3 ? banded.x / banded.y : tileMean;
112
- float local = mix(bilateral, tileMean, ${LOCAL_BLURRED_BLEND.toFixed(1)});
113
- float stops = uLocalExposure * (held - local);
114
- return exp2(clamp(stops, -${LOCAL_RANGE_STOPS.toFixed(1)}, ${LOCAL_RANGE_STOPS.toFixed(1)}));
124
+ float local = mix(bilateral, tileMean, uLocalShape.w);
125
+ /* Middle grey at the frame's exposure, in the scene light the grid measured. */
126
+ float pivot = uLocalPivot.x > 0.5
127
+ ? held
128
+ : ${Math.log2(ADAPTED_KEY).toFixed(6)} - log2(max(autoExposureGain() * uOutputExposure, 1e-8));
129
+ pivot += uLocalPivot.y;
130
+ float keep = local > pivot ? uLocalShape.x : uLocalShape.y;
131
+ float stops = (local - pivot) * keep + (pixel - local) * uLocalShape.z;
132
+ return exp2(clamp(stops, -uLocalPivot.z, uLocalPivot.z));
115
133
  }
116
134
  `;
117
135
 
@@ -148,6 +166,97 @@ export function localExposureGain(localLog2: number, heldLog2: number, strength:
148
166
  return 2 ** clamp(stops, -LOCAL_RANGE_STOPS, LOCAL_RANGE_STOPS);
149
167
  }
150
168
 
169
+ /**
170
+ * Local exposure as a shape rather than one strength: each region turned about middle grey at the
171
+ * frame's exposure, the real-time form most engines expose.
172
+ *
173
+ * A region's level is its stops from that grey: it keeps `highlightContrast` of them where it is
174
+ * brighter and `shadowContrast` where it is darker, so 1 leaves it where it is and 0 brings it all
175
+ * the way to grey. A pixel keeps `detail` of its own stops from its region, so above 1 sharpens the
176
+ * texture inside a region a curve would have flattened. **What it gives up** is the held level the
177
+ * one-number form turns about: a frame metered far from grey, a night, is pulled toward grey rather
178
+ * than toward itself, which is what the form is for and what a caller holding a night dark leaves
179
+ * off.
180
+ */
181
+ export interface LocalExposureOptions {
182
+ /** The share of a region's stops above middle grey it keeps. 1 is none moved. */
183
+ readonly highlightContrast: number;
184
+ /** The share it keeps below. */
185
+ readonly shadowContrast: number;
186
+ /** The share of a pixel's own stops from its region it keeps. Default 1. */
187
+ readonly detail?: number;
188
+ /** How far a region's level leans from its bilateral band toward its tile's mean. Default 0.4. */
189
+ readonly blurredBlend?: number;
190
+ /** Stops added to middle grey, the level regions turn about. Default 0. */
191
+ readonly middleGreyBias?: number;
192
+ /** The furthest a region moves either way, in stops. Default 8. */
193
+ readonly range?: number;
194
+ }
195
+
196
+ /** Floats `resolveLocalExposure` fills: `uLocalShape`, then `uLocalPivot`. */
197
+ export const LOCAL_EXPOSURE_FLOATS = 8;
198
+
199
+ /**
200
+ * Either form of `setLocalExposure` as the composite reads it, into `out`, and whether regions move
201
+ * at all. The one number is the shape it always was, both shares `1 − strength` about the held
202
+ * level, at most three stops: so a strength draws what it drew, to the bit.
203
+ */
204
+ export function resolveLocalExposure(
205
+ setting: number | LocalExposureOptions,
206
+ out: Float32Array,
207
+ ): boolean {
208
+ if (typeof setting === 'number') {
209
+ const strength = clampLocalExposure(setting);
210
+ out[0] = -strength;
211
+ out[1] = -strength;
212
+ out[2] = 0;
213
+ out[3] = LOCAL_BLURRED_BLEND;
214
+ out[4] = 1;
215
+ out[5] = 0;
216
+ out[6] = LOCAL_RANGE_STOPS;
217
+ out[7] = 0;
218
+ return strength > 0;
219
+ }
220
+ const share = (value: number | undefined, fallback: number): number =>
221
+ value !== undefined && Number.isFinite(value) ? Math.max(0, value) : fallback;
222
+ const highlight = share(setting.highlightContrast, 1);
223
+ const shadow = share(setting.shadowContrast, 1);
224
+ const detail = share(setting.detail, 1);
225
+ out[0] = highlight - 1;
226
+ out[1] = shadow - 1;
227
+ out[2] = detail - 1;
228
+ out[3] = clamp(share(setting.blurredBlend, LOCAL_BLURRED_BLEND), 0, 1);
229
+ out[4] = 0;
230
+ const bias = setting.middleGreyBias;
231
+ out[5] = bias !== undefined && Number.isFinite(bias) ? bias : 0;
232
+ out[6] = share(setting.range, 8);
233
+ out[7] = 0;
234
+ return highlight !== 1 || shadow !== 1 || detail !== 1;
235
+ }
236
+
237
+ /**
238
+ * A pixel's gain from either form, as the composite computes it: `pixelLog2` its own level,
239
+ * `localLog2` its region's before the blend, `tileMean` its tile's, `heldLog2` the frame's held
240
+ * brightness and `exposure` the frame's exposure, gain and all; `shape` is `resolveLocalExposure`'s.
241
+ */
242
+ export function localExposureShapedGain(
243
+ pixelLog2: number,
244
+ localLog2: number,
245
+ tileMean: number,
246
+ heldLog2: number,
247
+ exposure: number,
248
+ shape: ArrayLike<number>,
249
+ ): number {
250
+ const local = localLog2 + (tileMean - localLog2) * (shape[3] as number);
251
+ const pivot =
252
+ ((shape[4] as number) > 0.5 ? heldLog2 : Math.log2(ADAPTED_KEY) - Math.log2(exposure)) +
253
+ (shape[5] as number);
254
+ const keep = local > pivot ? (shape[0] as number) : (shape[1] as number);
255
+ const stops = (local - pivot) * keep + (pixelLog2 - local) * (shape[2] as number);
256
+ const range = shape[6] as number;
257
+ return 2 ** clamp(stops, -range, range);
258
+ }
259
+
151
260
  /** A local exposure strength as the shader takes it: 0 to 1, anything else off. */
152
261
  export function clampLocalExposure(strength: number): number {
153
262
  return Number.isFinite(strength) ? clamp(strength, 0, 1) : 0;
@@ -334,8 +334,10 @@ void main() {
334
334
  }
335
335
 
336
336
  vec3 shaded = vColor * lit;
337
- float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);
338
- outColor = vec4(applyOutputTransform(mix(shaded, mediumColor(), fog)), mask * vAlpha);
337
+ float dist = distance(vWorldPos, uCameraPos);
338
+ float fog = mediumFog(dist, vWorldPos.y);
339
+ vec3 glow = mediumGlow(dist, vWorldPos.y, normalize(vWorldPos - uCameraPos));
340
+ outColor = vec4(applyOutputTransform(mix(shaded, mediumColor(), fog) + glow), mask * vAlpha);
339
341
  }
340
342
  `;
341
343
 
@@ -463,8 +465,9 @@ void main() {
463
465
 
464
466
  vec3 color = vColor;
465
467
  if (uFogEnabled != 0) {
466
- float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);
467
- color = mix(color, mediumColor(), fog);
468
+ float dist = distance(vWorldPos, uCameraPos);
469
+ float fog = mediumFog(dist, vWorldPos.y);
470
+ color = mix(color, mediumColor(), fog) + mediumGlow(dist, vWorldPos.y, normalize(vWorldPos - uCameraPos));
468
471
  }
469
472
  /*
470
473
  * A real alpha blend (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) rather than spark's energy-in-colour
@@ -563,8 +566,9 @@ void main() {
563
566
  if (alpha <= 0.001) discard;
564
567
  vec3 color = vColor * image.rgb;
565
568
  if (uFogEnabled != 0) {
566
- float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);
567
- color = mix(color, mediumColor(), fog);
569
+ float dist = distance(vWorldPos, uCameraPos);
570
+ float fog = mediumFog(dist, vWorldPos.y);
571
+ color = mix(color, mediumColor(), fog) + mediumGlow(dist, vWorldPos.y, normalize(vWorldPos - uCameraPos));
568
572
  }
569
573
  outColor = vec4(applyOutputTransform(color), alpha);
570
574
  }
@@ -206,9 +206,11 @@ void main() {
206
206
  float ndl = abs(dot(n, uDirectionalDir));
207
207
  vec3 lit = vColor * (uAmbient + uDirectionalColor * ndl);
208
208
 
209
- float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);
209
+ float dist = distance(vWorldPos, uCameraPos);
210
+ float fog = mediumFog(dist, vWorldPos.y);
211
+ vec3 glow = mediumGlow(dist, vWorldPos.y, normalize(vWorldPos - uCameraPos));
210
212
 
211
213
  /* Graded where nothing after this will grade it, as every pass is: the same light as the ground. */
212
- outColor = vec4(applyOutputTransform(mix(lit, mediumColor(), fog)), 1.0);
214
+ outColor = vec4(applyOutputTransform(mix(lit, mediumColor(), fog) + glow), 1.0);
213
215
  }
214
216
  `;
@@ -277,7 +277,8 @@ void main() {
277
277
 
278
278
  float dist = distance(vWorldPos, uCameraPos);
279
279
  float fog = mediumFog(dist, vWorldPos.y);
280
- col = mix(col, atmosphereColor, fog);
280
+ vec3 glow = mediumGlow(dist, vWorldPos.y, normalize(vWorldPos - uCameraPos));
281
+ col = mix(col, atmosphereColor, fog) + glow;
281
282
  /*
282
283
  * **Graded here, before the mirror is mixed in**, as every pass grades what it draws when
283
284
  * nothing after it will: the mirror's own passes graded what it holds, so mixing first would put
@@ -28,12 +28,20 @@ export interface AnisotropicModel {
28
28
  readonly strength: number;
29
29
  /** Radians, turning the stretch's direction from the tangent toward the bitangent. */
30
30
  readonly rotation: number;
31
+ /**
32
+ * How far the highlight is lifted where it is bright: the distribution `D` becomes
33
+ * `D + min(3, D^1.5) · lift`, which is how a brushed finish that reads as polished along its
34
+ * streak is authored. Where a model map is bound its alpha scales it texel by texel, so a map
35
+ * storing a tenth of a region's lift takes `lift: 10`. 0, the default, is none.
36
+ */
37
+ readonly lift: number;
31
38
  }
32
39
 
33
40
  /**
34
41
  * Hair: three lobes along the strand, which is the mesh's tangent, running from root to tip
35
- * (`hairLobes.ts`). A model map's green turns each strand's tilt by up to half of it either way and
36
- * its blue is occlusion.
42
+ * (`hairLobes.ts`). A model map's red is the strand's specular, twice it scaling the reflected lobe
43
+ * (0.5 is the lobe as drawn), its green turns each strand's tilt by up to half of it either way and
44
+ * its blue is occlusion; `directionOffset` names a layer of it holding each strand's direction.
37
45
  */
38
46
  export interface HairModel {
39
47
  readonly kind: 'hair';
@@ -47,6 +55,15 @@ export interface HairModel {
47
55
  readonly scatter: number;
48
56
  /** How strongly light from behind glows through: the transmitted lobe. */
49
57
  readonly backlit: number;
58
+ /**
59
+ * Where the model map holds each strand's direction: this many layers past the layer the texel's
60
+ * other channels are read at, so a model map of one layer with its directions in the next takes
61
+ * 1. The direction is in the surface's tangent frame, red along the tangent, green along the
62
+ * bitangent and blue out of the surface along its normal, each −1 to 1 packed as a normal map
63
+ * packs them, and the strand runs along it rather than along the tangent: a card's strands can
64
+ * lean out of its plane. 0, the default, is none.
65
+ */
66
+ readonly directionOffset: number;
50
67
  }
51
68
 
52
69
  /** Skin: light scattered beneath the surface, reddening its terminator and its thin parts. */
@@ -137,10 +154,11 @@ function unit(model: string, name: string, v: Vec3): Vec3 {
137
154
 
138
155
  /** Brushed metal and the like, after glTF's `KHR_materials_anisotropy`: a strength and a turn. */
139
156
  export function anisotropicModel(
140
- options: { strength?: number; rotation?: number } = {},
157
+ options: { strength?: number; rotation?: number; lift?: number } = {},
141
158
  ): AnisotropicModel {
142
159
  return Object.freeze({
143
160
  kind: 'anisotropic',
161
+ lift: check('anisotropicModel', 'lift', options.lift ?? 0, 0, 100),
144
162
  strength: check('anisotropicModel', 'strength', options.strength ?? 1, 0, 1),
145
163
  rotation: check(
146
164
  'anisotropicModel',
@@ -154,13 +172,26 @@ export function anisotropicModel(
154
172
 
155
173
  /** Hair, along the mesh's tangent from root to tip. A tilt of 3°, within the 2.5° to 5° measured. */
156
174
  export function hairModel(
157
- options: { shift?: number; scatter?: number; backlit?: number } = {},
175
+ options: { shift?: number; scatter?: number; backlit?: number; directionOffset?: number } = {},
158
176
  ): HairModel {
177
+ const directionOffset = check(
178
+ 'hairModel',
179
+ 'directionOffset',
180
+ options.directionOffset ?? 0,
181
+ 0,
182
+ 255,
183
+ );
184
+ if (!Number.isInteger(directionOffset)) {
185
+ throw new Error(
186
+ `hairModel: directionOffset must be a whole number of layers, not ${directionOffset}`,
187
+ );
188
+ }
159
189
  return Object.freeze({
160
190
  kind: 'hair',
161
191
  shift: check('hairModel', 'shift', options.shift ?? (3 * Math.PI) / 180, -0.5, 0.5),
162
192
  scatter: check('hairModel', 'scatter', options.scatter ?? 0.7, 0, 1),
163
193
  backlit: check('hairModel', 'backlit', options.backlit ?? 1, 0, 4),
194
+ directionOffset,
164
195
  });
165
196
  }
166
197
 
@@ -246,8 +277,8 @@ export function lightmapModel(options: { region?: LightmapRegion } = {}): Lightm
246
277
  * models that read it leave free; skin, hair and the eye shade their own highlights and never do.
247
278
  *
248
279
  * - standard (null): − · −, −, physical, −
249
- * - anisotropic: strength, cos and sin of the rotation · −, −, physical, map
250
- * - hair: shift, scatter, backlit, − · −, −, −, map
280
+ * - anisotropic: strength, cos and sin of the rotation, lift · −, −, physical, map
281
+ * - hair: shift, scatter, backlit, direction offset · −, −, −, map
251
282
  * - skin: the scatter colour, radius / `SKIN_MEAN_EXIT` · transmission, profile, −, map
252
283
  * - eye: iris radius, iris depth, ior, cornea roughness · the axis, map
253
284
  * - lightmap: the region's scale and bias · −, −, physical, map
@@ -270,12 +301,16 @@ export function packModel(
270
301
  out[0] = model.strength;
271
302
  out[1] = Math.cos(model.rotation);
272
303
  out[2] = Math.sin(model.rotation);
304
+ /* The lift, which the map's alpha scales texel by texel; 0, and a missing field, is none. */
305
+ out[3] = model.lift ?? 0;
273
306
  out[6] = highlight;
274
307
  return;
275
308
  case 'hair':
276
309
  out[0] = model.shift;
277
310
  out[1] = model.scatter;
278
311
  out[2] = model.backlit;
312
+ /* A layer offset the shader adds to the texel's own; 0 is none, and so is a missing field. */
313
+ out[3] = model.directionOffset ?? 0;
279
314
  return;
280
315
  case 'skin':
281
316
  out[0] = model.scatterColor[0];