@driftengine/core 4.12.0 → 4.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/CHANGELOG.json +66 -0
  2. package/README.md +18 -9
  3. package/dist/geometry/meshBuilder.d.ts +10 -0
  4. package/dist/geometry/meshBuilder.js +11 -2
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.js +3 -2
  7. package/dist/math/color.d.ts +25 -0
  8. package/dist/math/color.js +37 -0
  9. package/dist/render/backend/webgl2/renderer.d.ts +17 -4
  10. package/dist/render/backend/webgl2/renderer.js +82 -47
  11. package/dist/render/backend/webgl2/skinScatter.d.ts +3 -1
  12. package/dist/render/backend/webgl2/skinScatter.js +5 -1
  13. package/dist/render/backend/webgpu/boltPass.d.ts +1 -1
  14. package/dist/render/backend/webgpu/causticsPass.d.ts +1 -1
  15. package/dist/render/backend/webgpu/filmPass.d.ts +1 -1
  16. package/dist/render/backend/webgpu/flockPass.d.ts +1 -1
  17. package/dist/render/backend/webgpu/giFieldPass.d.ts +3 -1
  18. package/dist/render/backend/webgpu/gpuDrivenPass.d.ts +10 -1
  19. package/dist/render/backend/webgpu/gpuDrivenPass.js +28 -11
  20. package/dist/render/backend/webgpu/panelPass.d.ts +1 -1
  21. package/dist/render/backend/webgpu/postPass.d.ts +2 -2
  22. package/dist/render/backend/webgpu/renderer.d.ts +19 -8
  23. package/dist/render/backend/webgpu/renderer.js +94 -38
  24. package/dist/render/backend/webgpu/scatterPass.d.ts +1 -1
  25. package/dist/render/backend/webgpu/skinScatterPass.d.ts +5 -1
  26. package/dist/render/backend/webgpu/skinScatterPass.js +9 -3
  27. package/dist/render/backend/webgpu/skyPass.d.ts +11 -1
  28. package/dist/render/backend/webgpu/waterPass.d.ts +1 -1
  29. package/dist/render/backend/webgpu/windStreakPass.d.ts +1 -1
  30. package/dist/render/causticsRenderer.d.ts +3 -1
  31. package/dist/render/causticsRenderer.js +5 -1
  32. package/dist/render/decalPass.d.ts +3 -1
  33. package/dist/render/decalPass.js +4 -1
  34. package/dist/render/flockRenderer.d.ts +1 -1
  35. package/dist/render/flockRenderer.js +4 -1
  36. package/dist/render/globalMediumPass.d.ts +3 -1
  37. package/dist/render/globalMediumPass.js +5 -1
  38. package/dist/render/gradeColor.d.ts +21 -0
  39. package/dist/render/gradeColor.js +64 -0
  40. package/dist/render/lightMatrix.d.ts +13 -0
  41. package/dist/render/lightMatrix.js +17 -0
  42. package/dist/render/passGrade.d.ts +13 -0
  43. package/dist/render/passGrade.js +47 -0
  44. package/dist/render/plumeRenderer.d.ts +1 -1
  45. package/dist/render/plumeRenderer.js +4 -1
  46. package/dist/render/renderQuality.d.ts +57 -18
  47. package/dist/render/renderQuality.js +3 -3
  48. package/dist/render/sceneTarget.d.ts +2 -0
  49. package/dist/render/sceneTarget.js +1 -0
  50. package/dist/render/sdfTextRenderer.d.ts +1 -1
  51. package/dist/render/sdfTextRenderer.js +1 -1
  52. package/dist/render/shaders/arcane.d.ts +1 -1
  53. package/dist/render/shaders/arcane.js +5 -1
  54. package/dist/render/shaders/bolt.d.ts +1 -1
  55. package/dist/render/shaders/bolt.js +4 -1
  56. package/dist/render/shaders/caustics.d.ts +1 -1
  57. package/dist/render/shaders/caustics.js +4 -1
  58. package/dist/render/shaders/decalProject.js +12 -2
  59. package/dist/render/shaders/film.d.ts +1 -1
  60. package/dist/render/shaders/film.js +22 -9
  61. package/dist/render/shaders/fire.d.ts +1 -1
  62. package/dist/render/shaders/fire.js +4 -1
  63. package/dist/render/shaders/flat/layerLooks.js +11 -19
  64. package/dist/render/shaders/flat/layered.js +33 -18
  65. package/dist/render/shaders/flock.d.ts +1 -1
  66. package/dist/render/shaders/flock.js +4 -1
  67. package/dist/render/shaders/generated/arcane.wgsl.d.ts +11 -1
  68. package/dist/render/shaders/generated/arcane.wgsl.js +11 -1
  69. package/dist/render/shaders/generated/bolt.wgsl.d.ts +12 -2
  70. package/dist/render/shaders/generated/bolt.wgsl.js +12 -2
  71. package/dist/render/shaders/generated/caustics.wgsl.d.ts +12 -2
  72. package/dist/render/shaders/generated/caustics.wgsl.js +12 -2
  73. package/dist/render/shaders/generated/decalProject.wgsl.d.ts +6 -1
  74. package/dist/render/shaders/generated/decalProject.wgsl.js +6 -1
  75. package/dist/render/shaders/generated/film.wgsl.d.ts +12 -2
  76. package/dist/render/shaders/generated/film.wgsl.js +12 -2
  77. package/dist/render/shaders/generated/fire.wgsl.d.ts +11 -1
  78. package/dist/render/shaders/generated/fire.wgsl.js +11 -1
  79. package/dist/render/shaders/generated/flat.wgsl.js +23 -23
  80. package/dist/render/shaders/generated/flock.wgsl.d.ts +12 -2
  81. package/dist/render/shaders/generated/flock.wgsl.js +12 -2
  82. package/dist/render/shaders/generated/globalMedium.wgsl.d.ts +12 -2
  83. package/dist/render/shaders/generated/globalMedium.wgsl.js +12 -2
  84. package/dist/render/shaders/generated/lightVolume.wgsl.d.ts +36 -16
  85. package/dist/render/shaders/generated/lightVolume.wgsl.js +39 -19
  86. package/dist/render/shaders/generated/panel.wgsl.d.ts +12 -2
  87. package/dist/render/shaders/generated/panel.wgsl.js +12 -2
  88. package/dist/render/shaders/generated/rush.wgsl.d.ts +31 -26
  89. package/dist/render/shaders/generated/rush.wgsl.js +31 -26
  90. package/dist/render/shaders/generated/scatter.wgsl.d.ts +12 -2
  91. package/dist/render/shaders/generated/scatter.wgsl.js +12 -2
  92. package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +11 -1
  93. package/dist/render/shaders/generated/skinBlur.wgsl.js +11 -1
  94. package/dist/render/shaders/generated/sky.wgsl.d.ts +12 -2
  95. package/dist/render/shaders/generated/sky.wgsl.js +12 -2
  96. package/dist/render/shaders/generated/smoke.wgsl.d.ts +11 -1
  97. package/dist/render/shaders/generated/smoke.wgsl.js +11 -1
  98. package/dist/render/shaders/generated/text.wgsl.d.ts +11 -1
  99. package/dist/render/shaders/generated/text.wgsl.js +11 -1
  100. package/dist/render/shaders/generated/water.wgsl.d.ts +12 -2
  101. package/dist/render/shaders/generated/water.wgsl.js +12 -2
  102. package/dist/render/shaders/generated/windStreaks.wgsl.d.ts +12 -2
  103. package/dist/render/shaders/generated/windStreaks.wgsl.js +12 -2
  104. package/dist/render/shaders/globalMedium.js +14 -1
  105. package/dist/render/shaders/gpudriven/present.wgsl.d.ts +15 -6
  106. package/dist/render/shaders/gpudriven/present.wgsl.js +71 -8
  107. package/dist/render/shaders/lightVolume.js +4 -1
  108. package/dist/render/shaders/line.d.ts +1 -1
  109. package/dist/render/shaders/outputTransform.d.ts +8 -2
  110. package/dist/render/shaders/outputTransform.js +18 -11
  111. package/dist/render/shaders/panel.d.ts +1 -1
  112. package/dist/render/shaders/panel.js +5 -1
  113. package/dist/render/shaders/particle.d.ts +3 -3
  114. package/dist/render/shaders/rush.js +8 -0
  115. package/dist/render/shaders/scatter.d.ts +1 -1
  116. package/dist/render/shaders/scatter.js +4 -1
  117. package/dist/render/shaders/sdfText.d.ts +1 -1
  118. package/dist/render/shaders/skinBlur.js +5 -1
  119. package/dist/render/shaders/sky.d.ts +1 -1
  120. package/dist/render/shaders/sky.js +5 -1
  121. package/dist/render/shaders/smoke.d.ts +1 -1
  122. package/dist/render/shaders/smoke.js +4 -1
  123. package/dist/render/shaders/text.d.ts +1 -1
  124. package/dist/render/shaders/text.js +4 -1
  125. package/dist/render/shaders/water.d.ts +1 -1
  126. package/dist/render/shaders/water.js +12 -2
  127. package/dist/render/shaders/windStreaks.d.ts +1 -1
  128. package/dist/render/shaders/windStreaks.js +4 -1
  129. package/dist/render/surfaceLayerLooks.d.ts +16 -4
  130. package/dist/render/surfaceLayerLooks.js +33 -14
  131. package/dist/render/surfaceLayers.d.ts +2 -2
  132. package/dist/render/textRenderer.d.ts +3 -1
  133. package/dist/render/textRenderer.js +7 -1
  134. package/dist/render/vertexDefaults.d.ts +1 -1
  135. package/dist/render/vertexDefaults.js +1 -1
  136. package/dist/render/waterRenderer.d.ts +1 -1
  137. package/dist/render/waterRenderer.js +4 -1
  138. package/dist/render/windStreakRenderer.d.ts +3 -1
  139. package/dist/render/windStreakRenderer.js +5 -1
  140. package/package.json +6 -6
  141. package/src/geometry/meshBuilder.ts +21 -2
  142. package/src/index.ts +11 -2
  143. package/src/math/color.ts +39 -0
  144. package/src/render/backend/webgl2/renderer.ts +134 -44
  145. package/src/render/backend/webgl2/skinScatter.ts +5 -0
  146. package/src/render/backend/webgpu/giFieldPass.ts +3 -1
  147. package/src/render/backend/webgpu/gpuDrivenPass.ts +29 -13
  148. package/src/render/backend/webgpu/renderer.ts +104 -37
  149. package/src/render/backend/webgpu/skinScatterPass.ts +17 -1
  150. package/src/render/causticsRenderer.ts +5 -0
  151. package/src/render/decalPass.ts +3 -0
  152. package/src/render/flockRenderer.ts +5 -0
  153. package/src/render/globalMediumPass.ts +10 -1
  154. package/src/render/gradeColor.ts +90 -0
  155. package/src/render/lightMatrix.ts +32 -0
  156. package/src/render/passGrade.ts +58 -0
  157. package/src/render/plumeRenderer.ts +5 -0
  158. package/src/render/renderQuality.ts +60 -21
  159. package/src/render/sceneTarget.ts +3 -0
  160. package/src/render/sdfTextRenderer.ts +1 -1
  161. package/src/render/shaders/arcane.ts +5 -1
  162. package/src/render/shaders/bolt.ts +4 -1
  163. package/src/render/shaders/caustics.ts +4 -1
  164. package/src/render/shaders/decalProject.ts +12 -2
  165. package/src/render/shaders/film.ts +22 -9
  166. package/src/render/shaders/fire.ts +4 -1
  167. package/src/render/shaders/flat/layerLooks.ts +11 -19
  168. package/src/render/shaders/flat/layered.ts +33 -18
  169. package/src/render/shaders/flock.ts +4 -1
  170. package/src/render/shaders/generated/arcane.wgsl.ts +11 -1
  171. package/src/render/shaders/generated/bolt.wgsl.ts +12 -2
  172. package/src/render/shaders/generated/caustics.wgsl.ts +12 -2
  173. package/src/render/shaders/generated/decalProject.wgsl.ts +6 -1
  174. package/src/render/shaders/generated/film.wgsl.ts +12 -2
  175. package/src/render/shaders/generated/fire.wgsl.ts +11 -1
  176. package/src/render/shaders/generated/flat.wgsl.ts +23 -23
  177. package/src/render/shaders/generated/flock.wgsl.ts +12 -2
  178. package/src/render/shaders/generated/globalMedium.wgsl.ts +12 -2
  179. package/src/render/shaders/generated/lightVolume.wgsl.ts +39 -19
  180. package/src/render/shaders/generated/panel.wgsl.ts +12 -2
  181. package/src/render/shaders/generated/rush.wgsl.ts +31 -26
  182. package/src/render/shaders/generated/scatter.wgsl.ts +12 -2
  183. package/src/render/shaders/generated/skinBlur.wgsl.ts +11 -1
  184. package/src/render/shaders/generated/sky.wgsl.ts +12 -2
  185. package/src/render/shaders/generated/smoke.wgsl.ts +11 -1
  186. package/src/render/shaders/generated/text.wgsl.ts +11 -1
  187. package/src/render/shaders/generated/water.wgsl.ts +12 -2
  188. package/src/render/shaders/generated/windStreaks.wgsl.ts +12 -2
  189. package/src/render/shaders/globalMedium.ts +14 -1
  190. package/src/render/shaders/gpudriven/present.wgsl.ts +72 -8
  191. package/src/render/shaders/lightVolume.ts +4 -1
  192. package/src/render/shaders/outputTransform.ts +19 -11
  193. package/src/render/shaders/panel.ts +5 -1
  194. package/src/render/shaders/rush.ts +8 -0
  195. package/src/render/shaders/scatter.ts +4 -1
  196. package/src/render/shaders/skinBlur.ts +5 -1
  197. package/src/render/shaders/sky.ts +5 -1
  198. package/src/render/shaders/smoke.ts +4 -1
  199. package/src/render/shaders/text.ts +4 -1
  200. package/src/render/shaders/water.ts +12 -2
  201. package/src/render/shaders/windStreaks.ts +4 -1
  202. package/src/render/surfaceLayerLooks.ts +49 -18
  203. package/src/render/surfaceLayers.ts +2 -2
  204. package/src/render/textRenderer.ts +6 -0
  205. package/src/render/vertexDefaults.ts +1 -1
  206. package/src/render/waterRenderer.ts +5 -0
  207. package/src/render/windStreakRenderer.ts +5 -0
@@ -253,8 +253,9 @@ import {
253
253
  OUTPUT_TRANSFORM_CODE,
254
254
  RUSH_REACH_UV,
255
255
  clampMotionShare,
256
- forwardTransformCode,
257
256
  } from '../../vertexDefaults.ts';
257
+ import { gradeColorInto } from '../../gradeColor.ts';
258
+ import { interfaceGrade, passGradeCode, passGradeExposure } from '../../passGrade.ts';
258
259
  import { FRINGE_FLOATS, resolveFringe } from '../../shaders/fringe.ts';
259
260
  import { displayHeadroom, type DisplayRange } from '../../displayRange.ts';
260
261
  import {
@@ -1512,7 +1513,7 @@ export class WebGPURenderer implements RendererApi {
1512
1513
  * **Resolved by the caller and held, rather than defaulted per use.** Every shadow term
1513
1514
  * below reads it, and the constants they replaced were `DEFAULT_RENDER_QUALITY` copies —
1514
1515
  * which measured wrong the moment a scene overrode one. `demo/dayClock` resolves
1515
- * `directionalShadowMaxSlope` to 9 where the default is 3.
1516
+ * `directionalShadowMaxSlope` to 9 where the default was 3 (60 since 4.13.0).
1516
1517
  */
1517
1518
  readonly quality: Readonly<RenderQuality>;
1518
1519
 
@@ -2226,6 +2227,7 @@ export class WebGPURenderer implements RendererApi {
2226
2227
  private readonly mediumUpsampleUniforms: GPUBuffer;
2227
2228
  private readonly mediumUpsampleStaging = new ArrayBuffer(MEDIUM_UPSAMPLE_SIZE);
2228
2229
  private readonly mediumUpsampleFloats = new Float32Array(this.mediumUpsampleStaging);
2230
+ private readonly mediumUpsampleInts = new Int32Array(this.mediumUpsampleStaging);
2229
2231
  private mediumUpsampleGroup: GPUBindGroup | null = null;
2230
2232
  /** The inverse projection the composite linearises a depth with, built once per frame. */
2231
2233
  private readonly mediumInvProjection = mat4.create();
@@ -2476,6 +2478,12 @@ export class WebGPURenderer implements RendererApi {
2476
2478
  */
2477
2479
  private readonly skyUniforms: UniformRing;
2478
2480
  private readonly skyFloats = new Float32Array(SKY_UNIFORM_SIZE / 4);
2481
+ /** The same block's ints: the grade the sky draws with. */
2482
+ private readonly skyInts = new Int32Array(this.skyFloats.buffer);
2483
+ /** `clearOf`'s scratch: a clear colour as the open target takes it. */
2484
+ private readonly gradedClear: Vec3 = [0, 0, 0];
2485
+ /** An inset's clear as its quad's uniform, rewritten in place: the colour and an alpha of 1. */
2486
+ private readonly insetClear = new Float32Array([0, 0, 0, 1]);
2479
2487
  private readonly skyBindGroup: GPUBindGroup;
2480
2488
  /** The camera's inverse with the clip correction undone, rebuilt once a frame. */
2481
2489
  private readonly skyInvViewProj = new Float32Array(16);
@@ -2560,6 +2568,8 @@ export class WebGPURenderer implements RendererApi {
2560
2568
  private decalUniforms!: GPUBuffer;
2561
2569
  private readonly decalStaging = new ArrayBuffer(DECAL_SLOT * MAX_DRAWN_DECALS);
2562
2570
  private readonly decalFloats = new Float32Array(this.decalStaging);
2571
+ /** The same slots' ints: the grade the frame under the marks was drawn with. */
2572
+ private readonly decalInts = new Int32Array(this.decalStaging);
2563
2573
  private decalBindGroup: GPUBindGroup | null = null;
2564
2574
  /** Said once where a mark cannot be drawn at all, rather than dropped in silence. */
2565
2575
  private decalsRefused = false;
@@ -2743,9 +2753,10 @@ export class WebGPURenderer implements RendererApi {
2743
2753
  * still owed.
2744
2754
  */
2745
2755
  this.pass = null;
2746
- this.mirrorClearColor[0] = clearColor[0];
2747
- this.mirrorClearColor[1] = clearColor[1];
2748
- this.mirrorClearColor[2] = clearColor[2];
2756
+ const mirrorClear = this.clearOf(clearColor);
2757
+ this.mirrorClearColor[0] = mirrorClear[0];
2758
+ this.mirrorClearColor[1] = mirrorClear[1];
2759
+ this.mirrorClearColor[2] = mirrorClear[2];
2749
2760
  this.pendingClearMask |= MIRROR_TARGET;
2750
2761
  this.reflectionPassActive = true;
2751
2762
  /* Closed in `endPlanarReflection`, matching `renderer.ts:2054`. */
@@ -2916,6 +2927,8 @@ export class WebGPURenderer implements RendererApi {
2916
2927
  i[atF('uClipEnabled')] = this.reflectionPassActive ? 1 : 0;
2917
2928
  f.set(this.reflection?.clipPlane ?? NO_CLIP_PLANE, atF('uClipPlane'));
2918
2929
  f[atF('uSheen')] = sheen;
2930
+ i[atF('uOutputTransform')] = this.passGrade();
2931
+ f[atF('uOutputExposure')] = this.passGradeExposure();
2919
2932
  f[atF('uFilmRoughness')] = Math.min(1, Math.max(0, options.roughness ?? 0));
2920
2933
  f[atF('uFilmRoughnessCycles')] = Math.max(0.01, options.roughnessCyclesPerMetre ?? 60);
2921
2934
 
@@ -3888,6 +3901,13 @@ export class WebGPURenderer implements RendererApi {
3888
3901
  const fragAt = (name: string): number => PANEL_FRAG_FIELDS[name]?.offset ?? 0;
3889
3902
  this.panelFrags.writeFloats(fragmentSlot, fragAt('uColor'), color);
3890
3903
  this.panelFrags.writeFloat(fragmentSlot, fragAt('uAlpha'), alpha);
3904
+ /* An interface: the screen encode, never the curve. See `interfaceGrade`. */
3905
+ this.panelFrags.writeInt(
3906
+ fragmentSlot,
3907
+ fragAt('uOutputTransform'),
3908
+ interfaceGrade(this.passGrade()),
3909
+ );
3910
+ this.panelFrags.writeFloat(fragmentSlot, fragAt('uOutputExposure'), 1);
3891
3911
 
3892
3912
  const panelPipe = panelPipeline(this.targetPipelines(), this.surface.device, this.panelLayout);
3893
3913
  /* `allocate` already returns a byte offset, not an index. Multiplying by the stride
@@ -4021,8 +4041,8 @@ export class WebGPURenderer implements RendererApi {
4021
4041
  font.atlasWidth,
4022
4042
  font.atlasHeight,
4023
4043
  ]);
4024
- this.sdfTextFrags.writeInt(fragmentSlot, fragAt('uOutputTransform'), this.gradeCode());
4025
- this.sdfTextFrags.writeFloat(fragmentSlot, fragAt('uOutputExposure'), this.gradeExposure());
4044
+ this.sdfTextFrags.writeInt(fragmentSlot, fragAt('uOutputTransform'), this.passGrade());
4045
+ this.sdfTextFrags.writeFloat(fragmentSlot, fragAt('uOutputExposure'), this.passGradeExposure());
4026
4046
 
4027
4047
  const sdfPipe = sdfTextPipeline(
4028
4048
  this.blendedPipelines(),
@@ -4143,6 +4163,13 @@ export class WebGPURenderer implements RendererApi {
4143
4163
  this.textFrags.writeFloats(fragmentSlot, fragAt('uColor'), style.color);
4144
4164
  this.textFrags.writeFloat(fragmentSlot, fragAt('uGlow'), style.glow);
4145
4165
  this.textFrags.writeFloat(fragmentSlot, fragAt('uAlpha'), style.alpha);
4166
+ /* An interface: the screen encode, never the curve. See `interfaceGrade`. */
4167
+ this.textFrags.writeInt(
4168
+ fragmentSlot,
4169
+ fragAt('uOutputTransform'),
4170
+ interfaceGrade(this.passGrade()),
4171
+ );
4172
+ this.textFrags.writeFloat(fragmentSlot, fragAt('uOutputExposure'), 1);
4146
4173
 
4147
4174
  const pass = this.openTextPass();
4148
4175
  if (pass === null) return;
@@ -4505,20 +4532,29 @@ export class WebGPURenderer implements RendererApi {
4505
4532
  * array allocated with the batch, so the frame loop still allocates nothing.
4506
4533
  */
4507
4534
  /**
4508
- * The output transform a forward pass should apply, and the exposure it should apply it at.
4509
- *
4510
- * The WebGPU half of `Renderer.gradeCode`, and it has to agree with it draw for draw: this is
4511
- * one verb with two implementations, and a frame that grades on one backend and not the other
4512
- * is the silent kind of disagreement. See that method for what the gate is protecting.
4535
+ * The grade a pass applies to what it draws into the target open now, and the exposure it
4536
+ * applies it at: the WebGPU half of `Renderer.passGrade`, and it has to agree with it draw for
4537
+ * draw. The decision is `passGrade.ts`'s, so both halves only gather the state it is taken from.
4513
4538
  */
4514
- private gradeCode(): number {
4515
- return !this.hasComposite || !this.quality.hdrScene
4516
- ? forwardTransformCode(this.quality.outputTransform)
4517
- : 0;
4539
+ private passGrade(): number {
4540
+ const storing = this.probePassActive || this.capturePassActive;
4541
+ const transform = this.quality.outputTransform;
4542
+ return passGradeCode(storing, this.framePresented, this.resolveGrades(), transform);
4543
+ }
4544
+
4545
+ private passGradeExposure(): number {
4546
+ const storing = this.probePassActive || this.capturePassActive;
4547
+ return passGradeExposure(storing, this.framePresented, this.resolveGrades(), this.exposure);
4548
+ }
4549
+
4550
+ /** Whether the frame's resolve applies the transform, so the passes before it draw linear. */
4551
+ private resolveGrades(): boolean {
4552
+ return this.hasComposite && this.quality.hdrScene;
4518
4553
  }
4519
4554
 
4520
- private gradeExposure(): number {
4521
- return !this.hasComposite || !this.quality.hdrScene ? this.exposure : 1;
4555
+ /** A clear colour as the open target takes it, graded as a pass would. See `gradeColor.ts`. */
4556
+ private clearOf(color: Readonly<Vec3>): Vec3 {
4557
+ return gradeColorInto(this.passGrade(), this.passGradeExposure(), color, this.gradedClear);
4522
4558
  }
4523
4559
 
4524
4560
  drawParticles(
@@ -4641,8 +4677,8 @@ export class WebGPURenderer implements RendererApi {
4641
4677
  if (has('uFogEnabled')) i[atF('uFogEnabled')] = batch.fog ? 1 : 0;
4642
4678
  /* Every material's fragment stage declares these, so they are unconditional like
4643
4679
  `uCameraPos` rather than guarded like the material-specific fields above. */
4644
- i[atF('uOutputTransform')] = this.gradeCode();
4645
- f[atF('uOutputExposure')] = this.gradeExposure();
4680
+ i[atF('uOutputTransform')] = this.passGrade();
4681
+ f[atF('uOutputExposure')] = this.passGradeExposure();
4646
4682
 
4647
4683
  const spriteGroup = batch.material === 'sprite' ? this.spriteFades(batch, camera, f) : null;
4648
4684
 
@@ -4896,6 +4932,8 @@ export class WebGPURenderer implements RendererApi {
4896
4932
  frags.writeInt(fragmentSlot, atF('uFogMode'), medium.fogMode);
4897
4933
  frags.writeFloat(fragmentSlot, atF('uFogNear'), medium.fogNear);
4898
4934
  frags.writeFloat(fragmentSlot, atF('uFogFar'), medium.fogFar);
4935
+ frags.writeInt(fragmentSlot, atF('uOutputTransform'), this.passGrade());
4936
+ frags.writeFloat(fragmentSlot, atF('uOutputExposure'), this.passGradeExposure());
4899
4937
  /*
4900
4938
  * **Written for every material, not only the one that reads it.** `arcane` is the only
4901
4939
  * plume whose block declares `uTint`, and the slot it lands in is past the end of fire's
@@ -5096,6 +5134,8 @@ export class WebGPURenderer implements RendererApi {
5096
5134
  /* No reflection pass yet: the shader's own "off" path, not an improvised one. */
5097
5135
  i[atF('uReflectionEnabled')] = mirror === null ? 0 : 1;
5098
5136
  i[atF('uReflectionFilterTaps')] = water.reflectionFilterTaps;
5137
+ i[atF('uOutputTransform')] = this.passGrade();
5138
+ f[atF('uOutputExposure')] = this.passGradeExposure();
5099
5139
  f[atF('uReflectionTexelSize')] = mirror === null ? 1 : 1 / mirror.width;
5100
5140
  f[atF('uReflectionTexelSize') + 1] = mirror === null ? 1 : 1 / mirror.height;
5101
5141
 
@@ -5248,6 +5288,8 @@ export class WebGPURenderer implements RendererApi {
5248
5288
  ring.writeFloats(fragmentSlot, at('uDustOffset'), options.driftM ?? NO_DRIFT);
5249
5289
  ring.writeFloat(fragmentSlot, at('uNear'), options.nearM ?? 0);
5250
5290
  ring.writeInt(fragmentSlot, at('uSamples'), this.quality.lightVolumeSamples);
5291
+ ring.writeInt(fragmentSlot, at('uOutputTransform'), this.passGrade());
5292
+ ring.writeFloat(fragmentSlot, at('uOutputExposure'), this.passGradeExposure());
5251
5293
 
5252
5294
  /*
5253
5295
  * The clamp that makes a beam end on the floor, and the snapshot it reads.
@@ -5471,6 +5513,10 @@ export class WebGPURenderer implements RendererApi {
5471
5513
  v[at('uTime')] = timeSeconds;
5472
5514
 
5473
5515
  this.windStreakFragFloats.set(tint, (WIND_STREAK_FRAG_FIELDS.uTint.offset ?? 0) / 4);
5516
+ this.windStreakFragBlock[WIND_STREAK_FRAG_FIELDS.uOutputTransform.offset / 4] =
5517
+ this.passGrade();
5518
+ this.windStreakFragFloats[WIND_STREAK_FRAG_FIELDS.uOutputExposure.offset / 4] =
5519
+ this.passGradeExposure();
5474
5520
 
5475
5521
  this.windStreakVerts.writeBlock(vertexSlot, this.windStreakVertBlock);
5476
5522
  this.windStreakFrags.writeBlock(fragmentSlot, this.windStreakFragBlock);
@@ -5576,6 +5622,8 @@ export class WebGPURenderer implements RendererApi {
5576
5622
  v[at('uWind') + 1] = windZ;
5577
5623
 
5578
5624
  this.flockFragFloats.set(tint, (FLOCK_FRAG_FIELDS.uTint.offset ?? 0) / 4);
5625
+ this.flockFragBlock[FLOCK_FRAG_FIELDS.uOutputTransform.offset / 4] = this.passGrade();
5626
+ this.flockFragFloats[FLOCK_FRAG_FIELDS.uOutputExposure.offset / 4] = this.passGradeExposure();
5579
5627
 
5580
5628
  this.flockVerts.writeBlock(vertexSlot, this.flockVertBlock);
5581
5629
  this.flockFrags.writeBlock(fragmentSlot, this.flockFragBlock);
@@ -5687,6 +5735,8 @@ export class WebGPURenderer implements RendererApi {
5687
5735
  f.set(core, atF('uCoreColor'));
5688
5736
  f.set(edge, atF('uEdgeColor'));
5689
5737
  f[atF('uCoreGain')] = coreGain;
5738
+ i[atF('uOutputTransform')] = this.passGrade();
5739
+ f[atF('uOutputExposure')] = this.passGradeExposure();
5690
5740
  f.set(camera.position, atF('uCameraPos'));
5691
5741
  /* The medium, through the same resolver every other pass here binds from. */
5692
5742
  const medium = resolveAtmosphere(
@@ -5823,8 +5873,8 @@ export class WebGPURenderer implements RendererApi {
5823
5873
  this.lineFrags.writeFloat(fragmentSlot, atF('uOpacity'), opacity);
5824
5874
  this.lineFrags.writeFloats(fragmentSlot, atF('uCameraPos'), camera.position);
5825
5875
  this.lineFrags.writeFloat(fragmentSlot, atF('uSoftness'), softness);
5826
- this.lineFrags.writeInt(fragmentSlot, atF('uOutputTransform'), this.gradeCode());
5827
- this.lineFrags.writeFloat(fragmentSlot, atF('uOutputExposure'), this.gradeExposure());
5876
+ this.lineFrags.writeInt(fragmentSlot, atF('uOutputTransform'), this.passGrade());
5877
+ this.lineFrags.writeFloat(fragmentSlot, atF('uOutputExposure'), this.passGradeExposure());
5828
5878
  /* The medium, through the same resolver every other pass here binds from. */
5829
5879
  const medium = resolveAtmosphere(
5830
5880
  env,
@@ -8482,9 +8532,10 @@ export class WebGPURenderer implements RendererApi {
8482
8532
  * at 92 MB a frame at 824x1830. `ensurePass` opens it on the first draw that wants it, which
8483
8533
  * for such a scene is after the last mirror, so it is opened once and cleared once.
8484
8534
  */
8485
- this.pendingClearColor[0] = clearColor[0];
8486
- this.pendingClearColor[1] = clearColor[1];
8487
- this.pendingClearColor[2] = clearColor[2];
8535
+ const frameClear = this.clearOf(clearColor);
8536
+ this.pendingClearColor[0] = frameClear[0];
8537
+ this.pendingClearColor[1] = frameClear[1];
8538
+ this.pendingClearColor[2] = frameClear[2];
8488
8539
  this.pendingClearMask = SCENE_TARGET;
8489
8540
  if (this.quality.deferFramePass) {
8490
8541
  this.pass = null;
@@ -9241,8 +9292,8 @@ export class WebGPURenderer implements RendererApi {
9241
9292
  * `screenEffects` off, or on without `hdrScene`, the mesh pass writes what reaches the
9242
9293
  * canvas and grades as it always did.
9243
9294
  */
9244
- i[at('uOutputTransform')] = this.gradeCode();
9245
- f[at('uOutputExposure')] = this.gradeExposure();
9295
+ i[at('uOutputTransform')] = this.passGrade();
9296
+ f[at('uOutputExposure')] = this.passGradeExposure();
9246
9297
  /* The projection occlusion needs, settled by the pass that has a camera. */
9247
9298
  this.frameProjection = camera.projection as Float32Array;
9248
9299
 
@@ -10096,7 +10147,7 @@ export class WebGPURenderer implements RendererApi {
10096
10147
  /* `1` is sRGB alone: the conversion without the curve. `Math.min` rather than a literal, so
10097
10148
  a renderer asked for `none` stays at none. See `TranslucentMeshOptions.toneMapped`. */
10098
10149
  if (!toneMapped) {
10099
- this.perFrameInts[this.materialField('uOutputTransform')] = Math.min(this.gradeCode(), 1);
10150
+ this.perFrameInts[this.materialField('uOutputTransform')] = Math.min(this.passGrade(), 1);
10100
10151
  }
10101
10152
  /*
10102
10153
  * **Refraction, and the snapshot it reads is taken here or the draw does not refract.**
@@ -10268,7 +10319,7 @@ export class WebGPURenderer implements RendererApi {
10268
10319
  shared, so a strength left set is worn by everything drawn after it. */
10269
10320
  if (refracting) this.releaseSeeThrough();
10270
10321
  if (!toneMapped) {
10271
- this.perFrameInts[this.materialField('uOutputTransform')] = this.gradeCode();
10322
+ this.perFrameInts[this.materialField('uOutputTransform')] = this.passGrade();
10272
10323
  }
10273
10324
  if (ditheredBlend) {
10274
10325
  this.perFrameFloats[this.materialField('uCutout') + 1] =
@@ -10736,7 +10787,7 @@ export class WebGPURenderer implements RendererApi {
10736
10787
  this.perFrameInts[this.materialField('uFogEnabled')] = fog;
10737
10788
  }
10738
10789
  if (!toneMapped) {
10739
- this.perFrameInts[this.materialField('uOutputTransform')] = Math.min(this.gradeCode(), 1);
10790
+ this.perFrameInts[this.materialField('uOutputTransform')] = Math.min(this.passGrade(), 1);
10740
10791
  }
10741
10792
 
10742
10793
  const depthWrite = options.depthWrite ?? true;
@@ -10846,7 +10897,7 @@ export class WebGPURenderer implements RendererApi {
10846
10897
  this.perFrameInts[this.materialField('uFogEnabled')] = this.surfaceFog;
10847
10898
  }
10848
10899
  if (!toneMapped) {
10849
- this.perFrameInts[this.materialField('uOutputTransform')] = this.gradeCode();
10900
+ this.perFrameInts[this.materialField('uOutputTransform')] = this.passGrade();
10850
10901
  }
10851
10902
  if (refracting) this.releaseSeeThrough();
10852
10903
  if (ditheredBlend) {
@@ -10963,6 +11014,8 @@ export class WebGPURenderer implements RendererApi {
10963
11014
  i[atF('uFogMode')] = medium.fogMode;
10964
11015
  f[atF('uFogNear')] = medium.fogNear;
10965
11016
  f[atF('uFogFar')] = medium.fogFar;
11017
+ i[atF('uOutputTransform')] = this.passGrade();
11018
+ f[atF('uOutputExposure')] = this.passGradeExposure();
10966
11019
 
10967
11020
  const fragmentSlot = this.scatterFrags.allocate();
10968
11021
  if (fragmentSlot === null) return;
@@ -12583,6 +12636,8 @@ export class WebGPURenderer implements RendererApi {
12583
12636
  );
12584
12637
  f.set(medium.underwaterColor, at('uUnderwaterColor'));
12585
12638
  f[at('uUnderwaterFactor')] = medium.underwaterFactor;
12639
+ this.skyInts[at('uOutputTransform')] = this.passGrade();
12640
+ f[at('uOutputExposure')] = this.passGradeExposure();
12586
12641
 
12587
12642
  const slot = this.skyUniforms.allocate();
12588
12643
  if (slot === null) {
@@ -12747,6 +12802,8 @@ export class WebGPURenderer implements RendererApi {
12747
12802
  ? (OUTPUT_TRANSFORM_CODE[this.quality.outputTransform] ?? 0)
12748
12803
  : 0;
12749
12804
  f[at('uOutputExposure')] = graded ? this.exposure : 1;
12805
+ /* The occlusion multiplied in as an encoded pixel takes it, where the passes graded the scene. */
12806
+ i[at('uSceneGraded')] = this.passGrade() !== 0 ? 1 : 0;
12750
12807
  f.set(this.filmA, at('uFilmA'));
12751
12808
  f.set(this.filmB, at('uFilmB'));
12752
12809
  f[at('uDisplayHeadroom')] = this.headroom;
@@ -13376,8 +13433,8 @@ export class WebGPURenderer implements RendererApi {
13376
13433
  });
13377
13434
  const context = this.passContext;
13378
13435
  context.pass = pass;
13379
- context.outputTransform = this.gradeCode();
13380
- context.outputExposure = this.gradeExposure();
13436
+ context.outputTransform = this.passGrade();
13437
+ context.outputExposure = this.passGradeExposure();
13381
13438
  context.jitter = this.latePassJitter;
13382
13439
  context.format = RECON_HISTORY_FORMAT;
13383
13440
  context.depthFormat = DEPTH_FORMAT;
@@ -14026,6 +14083,7 @@ export class WebGPURenderer implements RendererApi {
14026
14083
  f[base + at('uDecalOpacity')] = decal.opacity;
14027
14084
  f[base + at('uDecalFacingCos')] = decal.facingCos;
14028
14085
  f[base + at('uDecalSoftness')] = decal.softness;
14086
+ this.decalInts[base + at('uOutputTransform')] = this.passGrade();
14029
14087
  slot++;
14030
14088
  });
14031
14089
  device.queue.writeBuffer(this.decalUniforms, 0, this.decalStaging, 0, slot * DECAL_SLOT);
@@ -14272,6 +14330,8 @@ export class WebGPURenderer implements RendererApi {
14272
14330
  g[upsampleAt('uDepthToViewZ') + 1] = inv[14] ?? 0;
14273
14331
  g[upsampleAt('uDepthToViewZ') + 2] = inv[11] ?? 0;
14274
14332
  g[upsampleAt('uDepthToViewZ') + 3] = inv[15] ?? 1;
14333
+ this.mediumUpsampleInts[upsampleAt('uOutputTransform')] = this.passGrade();
14334
+ g[upsampleAt('uOutputExposure')] = this.passGradeExposure();
14275
14335
  device.queue.writeBuffer(this.mediumUpsampleUniforms, 0, this.mediumUpsampleStaging);
14276
14336
 
14277
14337
  const march = encoder.beginRenderPass({
@@ -14997,6 +15057,8 @@ export class WebGPURenderer implements RendererApi {
14997
15057
  projection,
14998
15058
  this.skinInverseProjection,
14999
15059
  () => this.gpuTimer.writesFor(),
15060
+ this.passGrade(),
15061
+ this.passGradeExposure(),
15000
15062
  );
15001
15063
  this.pass = encoder.beginRenderPass({
15002
15064
  label: 'skin.scattered',
@@ -16207,8 +16269,8 @@ export class WebGPURenderer implements RendererApi {
16207
16269
  const context = this.passContext;
16208
16270
  context.pass = pass;
16209
16271
  /* The frame's grade, so a contributed pass can obey the 2026-08-17 rule. See `PassContext`. */
16210
- context.outputTransform = this.gradeCode();
16211
- context.outputExposure = this.gradeExposure();
16272
+ context.outputTransform = this.passGrade();
16273
+ context.outputExposure = this.passGradeExposure();
16212
16274
  context.format = target.format;
16213
16275
  context.samples = target.sampleCount;
16214
16276
  return context as PassContext;
@@ -16705,8 +16767,11 @@ export class WebGPURenderer implements RendererApi {
16705
16767
  const slot = this.insetUniforms.allocate();
16706
16768
  if (slot !== null) {
16707
16769
  /* Only the colour: the quad covers the viewport set above, so it needs no geometry. */
16708
- const color = clearColor ?? BLACK_CLEAR;
16709
- this.insetUniforms.writeFloats(slot, 0, [color[0], color[1], color[2], 1]);
16770
+ const color = this.clearOf(clearColor ?? BLACK_CLEAR);
16771
+ this.insetClear[0] = color[0];
16772
+ this.insetClear[1] = color[1];
16773
+ this.insetClear[2] = color[2];
16774
+ this.insetUniforms.writeFloats(slot, 0, this.insetClear);
16710
16775
  const insetPipe = insetPipeline(
16711
16776
  this.targetPipelines(),
16712
16777
  this.surface.device,
@@ -17368,6 +17433,8 @@ export class WebGPURenderer implements RendererApi {
17368
17433
  f[atF('uTime')] = timeSeconds;
17369
17434
  f[atF('uMaxDrop')] = CAUSTICS_MAX_DROP_M;
17370
17435
  f[atF('uStrength')] = strength;
17436
+ i[atF('uOutputTransform')] = this.passGrade();
17437
+ f[atF('uOutputExposure')] = this.passGradeExposure();
17371
17438
  f.set(env.directionalDir, atF('uLightDir'));
17372
17439
 
17373
17440
  /*
@@ -41,6 +41,8 @@ export class SkinScatterPass {
41
41
  private readonly layout: GPUBindGroupLayout;
42
42
  private readonly uniforms: GPUBuffer;
43
43
  private readonly staging = new Float32Array((SLOT * 2) / 4);
44
+ /** The same blocks' ints: the frame's grade, which the second axis applies. */
45
+ private readonly stagingInts = new Int32Array(this.staging.buffer);
44
46
  private readonly linear: GPUSampler;
45
47
  private readonly nearest: GPUSampler;
46
48
  private readonly commands: CommandPool = createCommandPool(16);
@@ -212,6 +214,9 @@ export class SkinScatterPass {
212
214
  projection: ArrayLike<number>,
213
215
  inverseProjection: ArrayLike<number>,
214
216
  timestampWrites: () => GPURenderPassTimestampWrites | undefined,
217
+ /** The frame's grade, which the second axis applies to the light it adds. */
218
+ outputTransform = 0,
219
+ outputExposure = 1,
215
220
  ): void {
216
221
  const diffuse = this.diffuse;
217
222
  if (diffuse === null || this.diffuseView === null || this.acrossView === null) return;
@@ -221,7 +226,14 @@ export class SkinScatterPass {
221
226
  this.groupDown = this.group('skin.blurDown', this.acrossView, depth);
222
227
  this.groupsDepth = depth;
223
228
  }
224
- this.writeUniforms(diffuse.width, diffuse.height, projection, inverseProjection);
229
+ this.writeUniforms(
230
+ diffuse.width,
231
+ diffuse.height,
232
+ projection,
233
+ inverseProjection,
234
+ outputTransform,
235
+ outputExposure,
236
+ );
225
237
 
226
238
  const across = postPipeline(
227
239
  pipelines,
@@ -316,6 +328,8 @@ export class SkinScatterPass {
316
328
  height: number,
317
329
  projection: ArrayLike<number>,
318
330
  inverse: ArrayLike<number>,
331
+ outputTransform: number,
332
+ outputExposure: number,
319
333
  ): void {
320
334
  const f = this.staging;
321
335
  const fields = BINDINGS.fields;
@@ -335,6 +349,8 @@ export class SkinScatterPass {
335
349
  f.set(this.profiles, base + fields.uProfiles.offset / 4);
336
350
  /* The colour is applied once, on the axis that adds into the frame. */
337
351
  f[base + fields.uApplyAlbedo.offset / 4] = axis;
352
+ this.stagingInts[base + fields.uOutputTransform.offset / 4] = outputTransform;
353
+ f[base + fields.uOutputExposure.offset / 4] = outputExposure;
338
354
  }
339
355
  this.device.queue.writeBuffer(this.uniforms, 0, f);
340
356
  }
@@ -116,10 +116,15 @@ export class CausticsRenderer {
116
116
  windX = 0,
117
117
  windZ = 0,
118
118
  strength = 1,
119
+ /** The renderer's `passGrade`: the frame's, but none in a probe's face or a capture. */
120
+ outputTransform = 0,
121
+ outputExposure = 1,
119
122
  ): boolean {
120
123
  if (this.vertexCount === 0) return false;
121
124
  const u = this.uniforms;
122
125
  gl.useProgram(this.program);
126
+ gl.uniform1i(u['uOutputTransform'] ?? null, outputTransform);
127
+ gl.uniform1f(u['uOutputExposure'] ?? null, outputExposure);
123
128
  gl.uniformMatrix4fv(u['uViewProj'] ?? null, false, camera.viewProjection);
124
129
  gl.uniform3fv(u['uCameraPos'] ?? null, camera.position);
125
130
  gl.uniform1f(u['uTime'] ?? null, timeSeconds);
@@ -53,6 +53,8 @@ export class DecalPass {
53
53
  eye: ArrayLike<number>,
54
54
  width: number,
55
55
  height: number,
56
+ /** The renderer's `passGrade`: how the frame under the marks was graded, 0 for not yet. */
57
+ outputTransform = 0,
56
58
  ): void {
57
59
  if (queue.length === 0) return;
58
60
  const { gl } = this;
@@ -62,6 +64,7 @@ export class DecalPass {
62
64
  mat4.multiply(this.depthToWorld, this.depthToWorld, DEPTH_01_TO_CLIP);
63
65
 
64
66
  gl.useProgram(this.program);
67
+ gl.uniform1i(u['uOutputTransform'] ?? null, outputTransform);
65
68
  gl.bindVertexArray(this.vao);
66
69
  gl.disable(gl.DEPTH_TEST);
67
70
  gl.depthMask(false);
@@ -74,9 +74,14 @@ export class FlockRenderer {
74
74
  tint: Vec3,
75
75
  windX = 0,
76
76
  windZ = 0,
77
+ outputTransform = 0,
78
+ outputExposure = 1,
77
79
  ): void {
78
80
  const u = this.uniforms;
79
81
  gl.useProgram(this.program);
82
+ /* The renderer's `passGrade`: the frame's, but none in a probe's face or a capture. */
83
+ gl.uniform1i(u['uOutputTransform'] ?? null, outputTransform);
84
+ gl.uniform1f(u['uOutputExposure'] ?? null, outputExposure);
80
85
  gl.uniformMatrix4fv(u['uViewProj'] ?? null, false, camera.viewProjection);
81
86
  /*
82
87
  * No `uCameraPos` here, and the shader no longer declares one. It fed the banking that
@@ -268,7 +268,14 @@ export class GlobalMediumPass {
268
268
  * `(ONE, SRC_ALPHA)` on colour is `dst * transmittance + inscatter`; alpha is left alone with
269
269
  * `(ZERO, ONE)`, because the frame's own alpha is not the medium's to spend.
270
270
  */
271
- compositeMedium(depth: WebGLTexture, frameWidth: number, frameHeight: number): void {
271
+ compositeMedium(
272
+ depth: WebGLTexture,
273
+ frameWidth: number,
274
+ frameHeight: number,
275
+ /** The renderer's `passGrade`: how the frame under the medium was graded. */
276
+ outputTransform = 0,
277
+ outputExposure = 1,
278
+ ): void {
272
279
  if (this.target === null || this.unavailable) return;
273
280
  const { gl } = this;
274
281
  const u = this.upsampleUniforms;
@@ -292,6 +299,8 @@ export class GlobalMediumPass {
292
299
  this.mediumTexel[1] = 1 / Math.max(1, this.height);
293
300
  gl.uniform2fv(u['uMediumTexel'] ?? null, this.mediumTexel);
294
301
  gl.uniform4fv(u['uDepthToViewZ'] ?? null, this.depthToViewZ);
302
+ gl.uniform1i(u['uOutputTransform'] ?? null, outputTransform);
303
+ gl.uniform1f(u['uOutputExposure'] ?? null, outputExposure);
295
304
 
296
305
  gl.drawArrays(gl.TRIANGLES, 0, 3);
297
306
 
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The forward grade on the CPU: what `applyOutputTransform` in `shaders/outputTransform.ts` does to
3
+ * a colour, for a colour no shader draws — a clear colour, written to a target before any pass runs.
4
+ *
5
+ * **Why a clear colour is graded at all.** Every pass grades what it draws when nothing after it
6
+ * will, so a colour handed to the renderer means one thing wherever it lands: linear light. A clear
7
+ * left as it was given is the one colour in the frame read as a display value, so a scene whose fog
8
+ * fades to its background colour fades to a different colour than the background it meets; with
9
+ * `hdrScene` the resolve grades the clear with everything else, and a frame without it has to agree.
10
+ *
11
+ * **A second copy of the curve, and what holds the two together** is the hand-derived values in
12
+ * `gradeColor.test.ts`, the same ones the shader's own constants give: the sRGB encode's two
13
+ * segments, the ACES fit through its two matrices, and the highlight shoulder. Code 4, the filmic
14
+ * curve, is the composite's alone, so a forward code never carries it (`forwardTransformCode`).
15
+ */
16
+ import type { Vec3 } from '../math/color.ts';
17
+
18
+ /* Columns as the shader's `mat3` reads them, written here as rows of the product. */
19
+ const IN_R = [0.59719, 0.35458, 0.04823];
20
+ const IN_G = [0.076, 0.90834, 0.01566];
21
+ const IN_B = [0.0284, 0.13383, 0.83777];
22
+ const OUT_R = [1.60475, -0.53108, -0.07367];
23
+ const OUT_G = [-0.10208, 1.10813, -0.00605];
24
+ const OUT_B = [-0.00327, -0.07276, 1.07602];
25
+
26
+ /**
27
+ * `color` graded by forward code `code` at `exposure` into `out`, which may be `color`: 0 leaves it
28
+ * as it is, 1 encodes it as sRGB, 2 runs ACES first and 3 the highlight shoulder. Allocates nothing.
29
+ */
30
+ export function gradeColorInto(
31
+ code: number,
32
+ exposure: number,
33
+ color: Readonly<Vec3>,
34
+ out: Vec3,
35
+ ): Vec3 {
36
+ let r = color[0];
37
+ let g = color[1];
38
+ let b = color[2];
39
+ if (code === 0) {
40
+ out[0] = r;
41
+ out[1] = g;
42
+ out[2] = b;
43
+ return out;
44
+ }
45
+ if (code === 2) {
46
+ const x = r * exposure;
47
+ const y = g * exposure;
48
+ const z = b * exposure;
49
+ const ir = fit(dot(IN_R, x, y, z));
50
+ const ig = fit(dot(IN_G, x, y, z));
51
+ const ib = fit(dot(IN_B, x, y, z));
52
+ r = clamp01(dot(OUT_R, ir, ig, ib));
53
+ g = clamp01(dot(OUT_G, ir, ig, ib));
54
+ b = clamp01(dot(OUT_B, ir, ig, ib));
55
+ } else if (code === 3) {
56
+ r *= exposure;
57
+ g *= exposure;
58
+ b *= exposure;
59
+ const m = Math.max(r, g, b);
60
+ if (m > 0.8) {
61
+ const e = m - 0.8;
62
+ const k = (0.8 + (0.2 * e) / (e + 0.2)) / m;
63
+ r *= k;
64
+ g *= k;
65
+ b *= k;
66
+ }
67
+ }
68
+ out[0] = encode(r);
69
+ out[1] = encode(g);
70
+ out[2] = encode(b);
71
+ return out;
72
+ }
73
+
74
+ function dot(row: readonly number[], x: number, y: number, z: number): number {
75
+ return (row[0] as number) * x + (row[1] as number) * y + (row[2] as number) * z;
76
+ }
77
+
78
+ /** The RRT and ODT fit, `rrtAndOdtFit` in the shader. */
79
+ function fit(v: number): number {
80
+ return (v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081);
81
+ }
82
+
83
+ /** Linear to sRGB, the piecewise form the shader uses. */
84
+ function encode(c: number): number {
85
+ return c <= 0.0031308 ? c * 12.92 : 1.055 * Math.max(c, 0) ** (1 / 2.4) - 0.055;
86
+ }
87
+
88
+ function clamp01(v: number): number {
89
+ return v < 0 ? 0 : v > 1 ? 1 : v;
90
+ }
@@ -68,6 +68,38 @@ export function computeLightMatrix(
68
68
  return radius * 6;
69
69
  }
70
70
 
71
+ /**
72
+ * A directional light's matrix covering a box of known size whole: a model, a level, a room. The
73
+ * sphere around the box, at its centre, through `computeLightMatrix`, widened by the half texel the
74
+ * centre's snap may move it so a corner on the sphere stays inside. Returns the depth span, as that
75
+ * does.
76
+ *
77
+ * **For a scene whose extent is known rather than one that follows a viewer.** A radius chosen by
78
+ * eye around a guessed centre leaves part of the model outside the map, where it casts nothing; the
79
+ * bounds are the answer, and a loader has them. **What it gives up** is resolution where the box is
80
+ * large: the map's texels are spread over all of it, so a building's shadows are coarser than a
81
+ * focus on the viewer would draw them, which is what `computeLightMatrix` around the viewer is for.
82
+ */
83
+ export function computeLightMatrixForBounds(
84
+ lightDir: Vec3,
85
+ min: Readonly<Vec3>,
86
+ max: Readonly<Vec3>,
87
+ shadowMapSize: number,
88
+ out: mat4,
89
+ ): number {
90
+ const half = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2;
91
+ const radius = Math.max(half, 1e-3) * (1 + 2 / Math.max(shadowMapSize, 1));
92
+ return computeLightMatrix(
93
+ lightDir,
94
+ (min[0] + max[0]) / 2,
95
+ (min[1] + max[1]) / 2,
96
+ (min[2] + max[2]) / 2,
97
+ radius,
98
+ shadowMapSize,
99
+ out,
100
+ );
101
+ }
102
+
71
103
  /**
72
104
  * The depth an orthographic light matrix spans, in metres: what `computeLightMatrix` returns, read
73
105
  * back off any such matrix. Its depth row is the light's axis scaled by two over the span, so the