@driftengine/core 4.11.1 → 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 (231) hide show
  1. package/CHANGELOG.json +102 -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 +5 -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 +33 -5
  10. package/dist/render/backend/webgl2/renderer.js +123 -49
  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 +21 -8
  23. package/dist/render/backend/webgpu/renderer.js +103 -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/areaLight.d.ts +1 -1
  64. package/dist/render/shaders/flat/areaLight.js +1 -1
  65. package/dist/render/shaders/flat/glassTint.d.ts +1 -1
  66. package/dist/render/shaders/flat/glassTint.js +1 -0
  67. package/dist/render/shaders/flat/index.d.ts +5 -0
  68. package/dist/render/shaders/flat/index.js +1 -1
  69. package/dist/render/shaders/flat/layerLooks.d.ts +16 -0
  70. package/dist/render/shaders/flat/layerLooks.js +108 -0
  71. package/dist/render/shaders/flat/layered.d.ts +6 -4
  72. package/dist/render/shaders/flat/layered.js +77 -13
  73. package/dist/render/shaders/flat/main.d.ts +1 -1
  74. package/dist/render/shaders/flat/main.js +7 -2
  75. package/dist/render/shaders/flat/models.js +1 -1
  76. package/dist/render/shaders/flat/preamble.js +6 -0
  77. package/dist/render/shaders/flock.d.ts +1 -1
  78. package/dist/render/shaders/flock.js +4 -1
  79. package/dist/render/shaders/generated/arcane.wgsl.d.ts +11 -1
  80. package/dist/render/shaders/generated/arcane.wgsl.js +11 -1
  81. package/dist/render/shaders/generated/bolt.wgsl.d.ts +12 -2
  82. package/dist/render/shaders/generated/bolt.wgsl.js +12 -2
  83. package/dist/render/shaders/generated/caustics.wgsl.d.ts +12 -2
  84. package/dist/render/shaders/generated/caustics.wgsl.js +12 -2
  85. package/dist/render/shaders/generated/decalProject.wgsl.d.ts +6 -1
  86. package/dist/render/shaders/generated/decalProject.wgsl.js +6 -1
  87. package/dist/render/shaders/generated/film.wgsl.d.ts +12 -2
  88. package/dist/render/shaders/generated/film.wgsl.js +12 -2
  89. package/dist/render/shaders/generated/fire.wgsl.d.ts +11 -1
  90. package/dist/render/shaders/generated/fire.wgsl.js +11 -1
  91. package/dist/render/shaders/generated/flat.wgsl.d.ts +144 -16
  92. package/dist/render/shaders/generated/flat.wgsl.js +183 -55
  93. package/dist/render/shaders/generated/flock.wgsl.d.ts +12 -2
  94. package/dist/render/shaders/generated/flock.wgsl.js +12 -2
  95. package/dist/render/shaders/generated/globalMedium.wgsl.d.ts +12 -2
  96. package/dist/render/shaders/generated/globalMedium.wgsl.js +12 -2
  97. package/dist/render/shaders/generated/lightVolume.wgsl.d.ts +36 -16
  98. package/dist/render/shaders/generated/lightVolume.wgsl.js +39 -19
  99. package/dist/render/shaders/generated/panel.wgsl.d.ts +12 -2
  100. package/dist/render/shaders/generated/panel.wgsl.js +12 -2
  101. package/dist/render/shaders/generated/rush.wgsl.d.ts +31 -26
  102. package/dist/render/shaders/generated/rush.wgsl.js +31 -26
  103. package/dist/render/shaders/generated/scatter.wgsl.d.ts +12 -2
  104. package/dist/render/shaders/generated/scatter.wgsl.js +12 -2
  105. package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +11 -1
  106. package/dist/render/shaders/generated/skinBlur.wgsl.js +11 -1
  107. package/dist/render/shaders/generated/sky.wgsl.d.ts +12 -2
  108. package/dist/render/shaders/generated/sky.wgsl.js +12 -2
  109. package/dist/render/shaders/generated/smoke.wgsl.d.ts +11 -1
  110. package/dist/render/shaders/generated/smoke.wgsl.js +11 -1
  111. package/dist/render/shaders/generated/text.wgsl.d.ts +11 -1
  112. package/dist/render/shaders/generated/text.wgsl.js +11 -1
  113. package/dist/render/shaders/generated/water.wgsl.d.ts +12 -2
  114. package/dist/render/shaders/generated/water.wgsl.js +12 -2
  115. package/dist/render/shaders/generated/windStreaks.wgsl.d.ts +12 -2
  116. package/dist/render/shaders/generated/windStreaks.wgsl.js +12 -2
  117. package/dist/render/shaders/globalMedium.js +14 -1
  118. package/dist/render/shaders/gpudriven/present.wgsl.d.ts +15 -6
  119. package/dist/render/shaders/gpudriven/present.wgsl.js +71 -8
  120. package/dist/render/shaders/lightVolume.js +4 -1
  121. package/dist/render/shaders/line.d.ts +1 -1
  122. package/dist/render/shaders/outputTransform.d.ts +8 -2
  123. package/dist/render/shaders/outputTransform.js +18 -11
  124. package/dist/render/shaders/panel.d.ts +1 -1
  125. package/dist/render/shaders/panel.js +5 -1
  126. package/dist/render/shaders/particle.d.ts +3 -3
  127. package/dist/render/shaders/rush.js +8 -0
  128. package/dist/render/shaders/scatter.d.ts +1 -1
  129. package/dist/render/shaders/scatter.js +4 -1
  130. package/dist/render/shaders/sdfText.d.ts +1 -1
  131. package/dist/render/shaders/skinBlur.js +5 -1
  132. package/dist/render/shaders/sky.d.ts +1 -1
  133. package/dist/render/shaders/sky.js +5 -1
  134. package/dist/render/shaders/smoke.d.ts +1 -1
  135. package/dist/render/shaders/smoke.js +4 -1
  136. package/dist/render/shaders/text.d.ts +1 -1
  137. package/dist/render/shaders/text.js +4 -1
  138. package/dist/render/shaders/water.d.ts +1 -1
  139. package/dist/render/shaders/water.js +12 -2
  140. package/dist/render/shaders/windStreaks.d.ts +1 -1
  141. package/dist/render/shaders/windStreaks.js +4 -1
  142. package/dist/render/surfaceLayerLooks.d.ts +92 -0
  143. package/dist/render/surfaceLayerLooks.js +102 -0
  144. package/dist/render/surfaceLayers.d.ts +47 -6
  145. package/dist/render/surfaceLayers.js +5 -1
  146. package/dist/render/textRenderer.d.ts +3 -1
  147. package/dist/render/textRenderer.js +7 -1
  148. package/dist/render/vertexDefaults.d.ts +1 -1
  149. package/dist/render/vertexDefaults.js +1 -1
  150. package/dist/render/waterRenderer.d.ts +1 -1
  151. package/dist/render/waterRenderer.js +4 -1
  152. package/dist/render/windStreakRenderer.d.ts +3 -1
  153. package/dist/render/windStreakRenderer.js +5 -1
  154. package/package.json +6 -6
  155. package/scripts/browser.d.mts +5 -0
  156. package/scripts/browser.mjs +68 -14
  157. package/scripts/look.d.mts +81 -0
  158. package/scripts/look.mjs +380 -0
  159. package/src/geometry/meshBuilder.ts +21 -2
  160. package/src/index.ts +13 -2
  161. package/src/math/color.ts +39 -0
  162. package/src/render/backend/webgl2/renderer.ts +174 -46
  163. package/src/render/backend/webgl2/skinScatter.ts +5 -0
  164. package/src/render/backend/webgpu/giFieldPass.ts +3 -1
  165. package/src/render/backend/webgpu/gpuDrivenPass.ts +29 -13
  166. package/src/render/backend/webgpu/renderer.ts +112 -37
  167. package/src/render/backend/webgpu/skinScatterPass.ts +17 -1
  168. package/src/render/causticsRenderer.ts +5 -0
  169. package/src/render/decalPass.ts +3 -0
  170. package/src/render/flockRenderer.ts +5 -0
  171. package/src/render/globalMediumPass.ts +10 -1
  172. package/src/render/gradeColor.ts +90 -0
  173. package/src/render/lightMatrix.ts +32 -0
  174. package/src/render/passGrade.ts +58 -0
  175. package/src/render/plumeRenderer.ts +5 -0
  176. package/src/render/renderQuality.ts +60 -21
  177. package/src/render/sceneTarget.ts +3 -0
  178. package/src/render/sdfTextRenderer.ts +1 -1
  179. package/src/render/shaders/arcane.ts +5 -1
  180. package/src/render/shaders/bolt.ts +4 -1
  181. package/src/render/shaders/caustics.ts +4 -1
  182. package/src/render/shaders/decalProject.ts +12 -2
  183. package/src/render/shaders/film.ts +22 -9
  184. package/src/render/shaders/fire.ts +4 -1
  185. package/src/render/shaders/flat/areaLight.ts +1 -1
  186. package/src/render/shaders/flat/glassTint.ts +3 -1
  187. package/src/render/shaders/flat/index.ts +6 -1
  188. package/src/render/shaders/flat/layerLooks.ts +108 -0
  189. package/src/render/shaders/flat/layered.ts +78 -13
  190. package/src/render/shaders/flat/main.ts +7 -2
  191. package/src/render/shaders/flat/models.ts +1 -1
  192. package/src/render/shaders/flat/preamble.ts +6 -0
  193. package/src/render/shaders/flock.ts +4 -1
  194. package/src/render/shaders/generated/arcane.wgsl.ts +11 -1
  195. package/src/render/shaders/generated/bolt.wgsl.ts +12 -2
  196. package/src/render/shaders/generated/caustics.wgsl.ts +12 -2
  197. package/src/render/shaders/generated/decalProject.wgsl.ts +6 -1
  198. package/src/render/shaders/generated/film.wgsl.ts +12 -2
  199. package/src/render/shaders/generated/fire.wgsl.ts +11 -1
  200. package/src/render/shaders/generated/flat.wgsl.ts +183 -55
  201. package/src/render/shaders/generated/flock.wgsl.ts +12 -2
  202. package/src/render/shaders/generated/globalMedium.wgsl.ts +12 -2
  203. package/src/render/shaders/generated/lightVolume.wgsl.ts +39 -19
  204. package/src/render/shaders/generated/panel.wgsl.ts +12 -2
  205. package/src/render/shaders/generated/rush.wgsl.ts +31 -26
  206. package/src/render/shaders/generated/scatter.wgsl.ts +12 -2
  207. package/src/render/shaders/generated/skinBlur.wgsl.ts +11 -1
  208. package/src/render/shaders/generated/sky.wgsl.ts +12 -2
  209. package/src/render/shaders/generated/smoke.wgsl.ts +11 -1
  210. package/src/render/shaders/generated/text.wgsl.ts +11 -1
  211. package/src/render/shaders/generated/water.wgsl.ts +12 -2
  212. package/src/render/shaders/generated/windStreaks.wgsl.ts +12 -2
  213. package/src/render/shaders/globalMedium.ts +14 -1
  214. package/src/render/shaders/gpudriven/present.wgsl.ts +72 -8
  215. package/src/render/shaders/lightVolume.ts +4 -1
  216. package/src/render/shaders/outputTransform.ts +19 -11
  217. package/src/render/shaders/panel.ts +5 -1
  218. package/src/render/shaders/rush.ts +8 -0
  219. package/src/render/shaders/scatter.ts +4 -1
  220. package/src/render/shaders/skinBlur.ts +5 -1
  221. package/src/render/shaders/sky.ts +5 -1
  222. package/src/render/shaders/smoke.ts +4 -1
  223. package/src/render/shaders/text.ts +4 -1
  224. package/src/render/shaders/water.ts +12 -2
  225. package/src/render/shaders/windStreaks.ts +4 -1
  226. package/src/render/surfaceLayerLooks.ts +186 -0
  227. package/src/render/surfaceLayers.ts +52 -7
  228. package/src/render/textRenderer.ts +6 -0
  229. package/src/render/vertexDefaults.ts +1 -1
  230. package/src/render/waterRenderer.ts +5 -0
  231. package/src/render/windStreakRenderer.ts +5 -0
@@ -208,6 +208,7 @@ import {
208
208
  packSurfaceLayers,
209
209
  } from '../../surfaceLayers.ts';
210
210
  import type { SurfaceLayers } from '../../surfaceLayers.ts';
211
+ import { LOOK_FLOATS, asksLayerLooks, packLayerLooks } from '../../surfaceLayerLooks.ts';
211
212
  import {
212
213
  frameReflectionsRefused,
213
214
  resolveFrameReflections,
@@ -252,8 +253,9 @@ import {
252
253
  OUTPUT_TRANSFORM_CODE,
253
254
  RUSH_REACH_UV,
254
255
  clampMotionShare,
255
- forwardTransformCode,
256
256
  } from '../../vertexDefaults.ts';
257
+ import { gradeColorInto } from '../../gradeColor.ts';
258
+ import { interfaceGrade, passGradeCode, passGradeExposure } from '../../passGrade.ts';
257
259
  import { FRINGE_FLOATS, resolveFringe } from '../../shaders/fringe.ts';
258
260
  import { displayHeadroom, type DisplayRange } from '../../displayRange.ts';
259
261
  import {
@@ -1511,7 +1513,7 @@ export class WebGPURenderer implements RendererApi {
1511
1513
  * **Resolved by the caller and held, rather than defaulted per use.** Every shadow term
1512
1514
  * below reads it, and the constants they replaced were `DEFAULT_RENDER_QUALITY` copies —
1513
1515
  * which measured wrong the moment a scene overrode one. `demo/dayClock` resolves
1514
- * `directionalShadowMaxSlope` to 9 where the default is 3.
1516
+ * `directionalShadowMaxSlope` to 9 where the default was 3 (60 since 4.13.0).
1515
1517
  */
1516
1518
  readonly quality: Readonly<RenderQuality>;
1517
1519
 
@@ -2225,6 +2227,7 @@ export class WebGPURenderer implements RendererApi {
2225
2227
  private readonly mediumUpsampleUniforms: GPUBuffer;
2226
2228
  private readonly mediumUpsampleStaging = new ArrayBuffer(MEDIUM_UPSAMPLE_SIZE);
2227
2229
  private readonly mediumUpsampleFloats = new Float32Array(this.mediumUpsampleStaging);
2230
+ private readonly mediumUpsampleInts = new Int32Array(this.mediumUpsampleStaging);
2228
2231
  private mediumUpsampleGroup: GPUBindGroup | null = null;
2229
2232
  /** The inverse projection the composite linearises a depth with, built once per frame. */
2230
2233
  private readonly mediumInvProjection = mat4.create();
@@ -2475,6 +2478,12 @@ export class WebGPURenderer implements RendererApi {
2475
2478
  */
2476
2479
  private readonly skyUniforms: UniformRing;
2477
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]);
2478
2487
  private readonly skyBindGroup: GPUBindGroup;
2479
2488
  /** The camera's inverse with the clip correction undone, rebuilt once a frame. */
2480
2489
  private readonly skyInvViewProj = new Float32Array(16);
@@ -2559,6 +2568,8 @@ export class WebGPURenderer implements RendererApi {
2559
2568
  private decalUniforms!: GPUBuffer;
2560
2569
  private readonly decalStaging = new ArrayBuffer(DECAL_SLOT * MAX_DRAWN_DECALS);
2561
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);
2562
2573
  private decalBindGroup: GPUBindGroup | null = null;
2563
2574
  /** Said once where a mark cannot be drawn at all, rather than dropped in silence. */
2564
2575
  private decalsRefused = false;
@@ -2742,9 +2753,10 @@ export class WebGPURenderer implements RendererApi {
2742
2753
  * still owed.
2743
2754
  */
2744
2755
  this.pass = null;
2745
- this.mirrorClearColor[0] = clearColor[0];
2746
- this.mirrorClearColor[1] = clearColor[1];
2747
- 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];
2748
2760
  this.pendingClearMask |= MIRROR_TARGET;
2749
2761
  this.reflectionPassActive = true;
2750
2762
  /* Closed in `endPlanarReflection`, matching `renderer.ts:2054`. */
@@ -2915,6 +2927,8 @@ export class WebGPURenderer implements RendererApi {
2915
2927
  i[atF('uClipEnabled')] = this.reflectionPassActive ? 1 : 0;
2916
2928
  f.set(this.reflection?.clipPlane ?? NO_CLIP_PLANE, atF('uClipPlane'));
2917
2929
  f[atF('uSheen')] = sheen;
2930
+ i[atF('uOutputTransform')] = this.passGrade();
2931
+ f[atF('uOutputExposure')] = this.passGradeExposure();
2918
2932
  f[atF('uFilmRoughness')] = Math.min(1, Math.max(0, options.roughness ?? 0));
2919
2933
  f[atF('uFilmRoughnessCycles')] = Math.max(0.01, options.roughnessCyclesPerMetre ?? 60);
2920
2934
 
@@ -3015,6 +3029,8 @@ export class WebGPURenderer implements RendererApi {
3015
3029
  private readonly projection = new Float32Array(PROJECTION_FLOATS);
3016
3030
  /** `uLayers`, the material's layers packed by `packSurfaceLayers`. */
3017
3031
  private readonly layerFloats = new Float32Array(LAYER_FLOATS);
3032
+ /** `uLayerLooks`, the material's layer picks and looks packed by `packLayerLooks`. */
3033
+ private readonly lookFloats = new Float32Array(LOOK_FLOATS);
3018
3034
  /**
3019
3035
  * `uGlassTint` as the material leaves it, its light from behind and that light's colour, which a
3020
3036
  * pane overwrites for its own draw and `releaseSeeThrough` puts back. See `transmission.ts`.
@@ -3588,6 +3604,10 @@ export class WebGPURenderer implements RendererApi {
3588
3604
  packSurfaceLayers(layers, this.layerFloats);
3589
3605
  f.set(this.layerFloats, this.materialField('uLayers'));
3590
3606
  if (layers !== null) this.enableLit('LAYERED');
3607
+ /* And which layer of shared arrays each reads, with its look: layerLooks.ts. */
3608
+ packLayerLooks(layers, this.lookFloats);
3609
+ f.set(this.lookFloats, this.materialField('uLayerLooks'));
3610
+ if (asksLayerLooks(layers)) this.enableLit('LAYER_LOOKS');
3591
3611
  f.set(this.modelParams, this.materialField('uModelParams'));
3592
3612
  });
3593
3613
  /*
@@ -3881,6 +3901,13 @@ export class WebGPURenderer implements RendererApi {
3881
3901
  const fragAt = (name: string): number => PANEL_FRAG_FIELDS[name]?.offset ?? 0;
3882
3902
  this.panelFrags.writeFloats(fragmentSlot, fragAt('uColor'), color);
3883
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);
3884
3911
 
3885
3912
  const panelPipe = panelPipeline(this.targetPipelines(), this.surface.device, this.panelLayout);
3886
3913
  /* `allocate` already returns a byte offset, not an index. Multiplying by the stride
@@ -4014,8 +4041,8 @@ export class WebGPURenderer implements RendererApi {
4014
4041
  font.atlasWidth,
4015
4042
  font.atlasHeight,
4016
4043
  ]);
4017
- this.sdfTextFrags.writeInt(fragmentSlot, fragAt('uOutputTransform'), this.gradeCode());
4018
- 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());
4019
4046
 
4020
4047
  const sdfPipe = sdfTextPipeline(
4021
4048
  this.blendedPipelines(),
@@ -4136,6 +4163,13 @@ export class WebGPURenderer implements RendererApi {
4136
4163
  this.textFrags.writeFloats(fragmentSlot, fragAt('uColor'), style.color);
4137
4164
  this.textFrags.writeFloat(fragmentSlot, fragAt('uGlow'), style.glow);
4138
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);
4139
4173
 
4140
4174
  const pass = this.openTextPass();
4141
4175
  if (pass === null) return;
@@ -4498,20 +4532,29 @@ export class WebGPURenderer implements RendererApi {
4498
4532
  * array allocated with the batch, so the frame loop still allocates nothing.
4499
4533
  */
4500
4534
  /**
4501
- * The output transform a forward pass should apply, and the exposure it should apply it at.
4502
- *
4503
- * The WebGPU half of `Renderer.gradeCode`, and it has to agree with it draw for draw: this is
4504
- * one verb with two implementations, and a frame that grades on one backend and not the other
4505
- * 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.
4506
4538
  */
4507
- private gradeCode(): number {
4508
- return !this.hasComposite || !this.quality.hdrScene
4509
- ? forwardTransformCode(this.quality.outputTransform)
4510
- : 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;
4511
4553
  }
4512
4554
 
4513
- private gradeExposure(): number {
4514
- 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);
4515
4558
  }
4516
4559
 
4517
4560
  drawParticles(
@@ -4634,8 +4677,8 @@ export class WebGPURenderer implements RendererApi {
4634
4677
  if (has('uFogEnabled')) i[atF('uFogEnabled')] = batch.fog ? 1 : 0;
4635
4678
  /* Every material's fragment stage declares these, so they are unconditional like
4636
4679
  `uCameraPos` rather than guarded like the material-specific fields above. */
4637
- i[atF('uOutputTransform')] = this.gradeCode();
4638
- f[atF('uOutputExposure')] = this.gradeExposure();
4680
+ i[atF('uOutputTransform')] = this.passGrade();
4681
+ f[atF('uOutputExposure')] = this.passGradeExposure();
4639
4682
 
4640
4683
  const spriteGroup = batch.material === 'sprite' ? this.spriteFades(batch, camera, f) : null;
4641
4684
 
@@ -4889,6 +4932,8 @@ export class WebGPURenderer implements RendererApi {
4889
4932
  frags.writeInt(fragmentSlot, atF('uFogMode'), medium.fogMode);
4890
4933
  frags.writeFloat(fragmentSlot, atF('uFogNear'), medium.fogNear);
4891
4934
  frags.writeFloat(fragmentSlot, atF('uFogFar'), medium.fogFar);
4935
+ frags.writeInt(fragmentSlot, atF('uOutputTransform'), this.passGrade());
4936
+ frags.writeFloat(fragmentSlot, atF('uOutputExposure'), this.passGradeExposure());
4892
4937
  /*
4893
4938
  * **Written for every material, not only the one that reads it.** `arcane` is the only
4894
4939
  * plume whose block declares `uTint`, and the slot it lands in is past the end of fire's
@@ -5089,6 +5134,8 @@ export class WebGPURenderer implements RendererApi {
5089
5134
  /* No reflection pass yet: the shader's own "off" path, not an improvised one. */
5090
5135
  i[atF('uReflectionEnabled')] = mirror === null ? 0 : 1;
5091
5136
  i[atF('uReflectionFilterTaps')] = water.reflectionFilterTaps;
5137
+ i[atF('uOutputTransform')] = this.passGrade();
5138
+ f[atF('uOutputExposure')] = this.passGradeExposure();
5092
5139
  f[atF('uReflectionTexelSize')] = mirror === null ? 1 : 1 / mirror.width;
5093
5140
  f[atF('uReflectionTexelSize') + 1] = mirror === null ? 1 : 1 / mirror.height;
5094
5141
 
@@ -5241,6 +5288,8 @@ export class WebGPURenderer implements RendererApi {
5241
5288
  ring.writeFloats(fragmentSlot, at('uDustOffset'), options.driftM ?? NO_DRIFT);
5242
5289
  ring.writeFloat(fragmentSlot, at('uNear'), options.nearM ?? 0);
5243
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());
5244
5293
 
5245
5294
  /*
5246
5295
  * The clamp that makes a beam end on the floor, and the snapshot it reads.
@@ -5464,6 +5513,10 @@ export class WebGPURenderer implements RendererApi {
5464
5513
  v[at('uTime')] = timeSeconds;
5465
5514
 
5466
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();
5467
5520
 
5468
5521
  this.windStreakVerts.writeBlock(vertexSlot, this.windStreakVertBlock);
5469
5522
  this.windStreakFrags.writeBlock(fragmentSlot, this.windStreakFragBlock);
@@ -5569,6 +5622,8 @@ export class WebGPURenderer implements RendererApi {
5569
5622
  v[at('uWind') + 1] = windZ;
5570
5623
 
5571
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();
5572
5627
 
5573
5628
  this.flockVerts.writeBlock(vertexSlot, this.flockVertBlock);
5574
5629
  this.flockFrags.writeBlock(fragmentSlot, this.flockFragBlock);
@@ -5680,6 +5735,8 @@ export class WebGPURenderer implements RendererApi {
5680
5735
  f.set(core, atF('uCoreColor'));
5681
5736
  f.set(edge, atF('uEdgeColor'));
5682
5737
  f[atF('uCoreGain')] = coreGain;
5738
+ i[atF('uOutputTransform')] = this.passGrade();
5739
+ f[atF('uOutputExposure')] = this.passGradeExposure();
5683
5740
  f.set(camera.position, atF('uCameraPos'));
5684
5741
  /* The medium, through the same resolver every other pass here binds from. */
5685
5742
  const medium = resolveAtmosphere(
@@ -5816,8 +5873,8 @@ export class WebGPURenderer implements RendererApi {
5816
5873
  this.lineFrags.writeFloat(fragmentSlot, atF('uOpacity'), opacity);
5817
5874
  this.lineFrags.writeFloats(fragmentSlot, atF('uCameraPos'), camera.position);
5818
5875
  this.lineFrags.writeFloat(fragmentSlot, atF('uSoftness'), softness);
5819
- this.lineFrags.writeInt(fragmentSlot, atF('uOutputTransform'), this.gradeCode());
5820
- 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());
5821
5878
  /* The medium, through the same resolver every other pass here binds from. */
5822
5879
  const medium = resolveAtmosphere(
5823
5880
  env,
@@ -8475,9 +8532,10 @@ export class WebGPURenderer implements RendererApi {
8475
8532
  * at 92 MB a frame at 824x1830. `ensurePass` opens it on the first draw that wants it, which
8476
8533
  * for such a scene is after the last mirror, so it is opened once and cleared once.
8477
8534
  */
8478
- this.pendingClearColor[0] = clearColor[0];
8479
- this.pendingClearColor[1] = clearColor[1];
8480
- 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];
8481
8539
  this.pendingClearMask = SCENE_TARGET;
8482
8540
  if (this.quality.deferFramePass) {
8483
8541
  this.pass = null;
@@ -8976,6 +9034,7 @@ export class WebGPURenderer implements RendererApi {
8976
9034
  f.set(this.transmission, at('uGlassTint'));
8977
9035
  /* Nor layers: one layer until a material asks for more. */
8978
9036
  f.fill(0, at('uLayers'), at('uLayers') + LAYER_FLOATS);
9037
+ f.fill(0, at('uLayerLooks'), at('uLayerLooks') + LOOK_FLOATS);
8979
9038
  f[at('uCutout')] = 0;
8980
9039
  f[at('uCutout') + 1] = 0;
8981
9040
  this.cutoutResolveStaged = 'hard';
@@ -9233,8 +9292,8 @@ export class WebGPURenderer implements RendererApi {
9233
9292
  * `screenEffects` off, or on without `hdrScene`, the mesh pass writes what reaches the
9234
9293
  * canvas and grades as it always did.
9235
9294
  */
9236
- i[at('uOutputTransform')] = this.gradeCode();
9237
- f[at('uOutputExposure')] = this.gradeExposure();
9295
+ i[at('uOutputTransform')] = this.passGrade();
9296
+ f[at('uOutputExposure')] = this.passGradeExposure();
9238
9297
  /* The projection occlusion needs, settled by the pass that has a camera. */
9239
9298
  this.frameProjection = camera.projection as Float32Array;
9240
9299
 
@@ -10088,7 +10147,7 @@ export class WebGPURenderer implements RendererApi {
10088
10147
  /* `1` is sRGB alone: the conversion without the curve. `Math.min` rather than a literal, so
10089
10148
  a renderer asked for `none` stays at none. See `TranslucentMeshOptions.toneMapped`. */
10090
10149
  if (!toneMapped) {
10091
- this.perFrameInts[this.materialField('uOutputTransform')] = Math.min(this.gradeCode(), 1);
10150
+ this.perFrameInts[this.materialField('uOutputTransform')] = Math.min(this.passGrade(), 1);
10092
10151
  }
10093
10152
  /*
10094
10153
  * **Refraction, and the snapshot it reads is taken here or the draw does not refract.**
@@ -10260,7 +10319,7 @@ export class WebGPURenderer implements RendererApi {
10260
10319
  shared, so a strength left set is worn by everything drawn after it. */
10261
10320
  if (refracting) this.releaseSeeThrough();
10262
10321
  if (!toneMapped) {
10263
- this.perFrameInts[this.materialField('uOutputTransform')] = this.gradeCode();
10322
+ this.perFrameInts[this.materialField('uOutputTransform')] = this.passGrade();
10264
10323
  }
10265
10324
  if (ditheredBlend) {
10266
10325
  this.perFrameFloats[this.materialField('uCutout') + 1] =
@@ -10728,7 +10787,7 @@ export class WebGPURenderer implements RendererApi {
10728
10787
  this.perFrameInts[this.materialField('uFogEnabled')] = fog;
10729
10788
  }
10730
10789
  if (!toneMapped) {
10731
- this.perFrameInts[this.materialField('uOutputTransform')] = Math.min(this.gradeCode(), 1);
10790
+ this.perFrameInts[this.materialField('uOutputTransform')] = Math.min(this.passGrade(), 1);
10732
10791
  }
10733
10792
 
10734
10793
  const depthWrite = options.depthWrite ?? true;
@@ -10838,7 +10897,7 @@ export class WebGPURenderer implements RendererApi {
10838
10897
  this.perFrameInts[this.materialField('uFogEnabled')] = this.surfaceFog;
10839
10898
  }
10840
10899
  if (!toneMapped) {
10841
- this.perFrameInts[this.materialField('uOutputTransform')] = this.gradeCode();
10900
+ this.perFrameInts[this.materialField('uOutputTransform')] = this.passGrade();
10842
10901
  }
10843
10902
  if (refracting) this.releaseSeeThrough();
10844
10903
  if (ditheredBlend) {
@@ -10955,6 +11014,8 @@ export class WebGPURenderer implements RendererApi {
10955
11014
  i[atF('uFogMode')] = medium.fogMode;
10956
11015
  f[atF('uFogNear')] = medium.fogNear;
10957
11016
  f[atF('uFogFar')] = medium.fogFar;
11017
+ i[atF('uOutputTransform')] = this.passGrade();
11018
+ f[atF('uOutputExposure')] = this.passGradeExposure();
10958
11019
 
10959
11020
  const fragmentSlot = this.scatterFrags.allocate();
10960
11021
  if (fragmentSlot === null) return;
@@ -12575,6 +12636,8 @@ export class WebGPURenderer implements RendererApi {
12575
12636
  );
12576
12637
  f.set(medium.underwaterColor, at('uUnderwaterColor'));
12577
12638
  f[at('uUnderwaterFactor')] = medium.underwaterFactor;
12639
+ this.skyInts[at('uOutputTransform')] = this.passGrade();
12640
+ f[at('uOutputExposure')] = this.passGradeExposure();
12578
12641
 
12579
12642
  const slot = this.skyUniforms.allocate();
12580
12643
  if (slot === null) {
@@ -12739,6 +12802,8 @@ export class WebGPURenderer implements RendererApi {
12739
12802
  ? (OUTPUT_TRANSFORM_CODE[this.quality.outputTransform] ?? 0)
12740
12803
  : 0;
12741
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;
12742
12807
  f.set(this.filmA, at('uFilmA'));
12743
12808
  f.set(this.filmB, at('uFilmB'));
12744
12809
  f[at('uDisplayHeadroom')] = this.headroom;
@@ -13368,8 +13433,8 @@ export class WebGPURenderer implements RendererApi {
13368
13433
  });
13369
13434
  const context = this.passContext;
13370
13435
  context.pass = pass;
13371
- context.outputTransform = this.gradeCode();
13372
- context.outputExposure = this.gradeExposure();
13436
+ context.outputTransform = this.passGrade();
13437
+ context.outputExposure = this.passGradeExposure();
13373
13438
  context.jitter = this.latePassJitter;
13374
13439
  context.format = RECON_HISTORY_FORMAT;
13375
13440
  context.depthFormat = DEPTH_FORMAT;
@@ -14018,6 +14083,7 @@ export class WebGPURenderer implements RendererApi {
14018
14083
  f[base + at('uDecalOpacity')] = decal.opacity;
14019
14084
  f[base + at('uDecalFacingCos')] = decal.facingCos;
14020
14085
  f[base + at('uDecalSoftness')] = decal.softness;
14086
+ this.decalInts[base + at('uOutputTransform')] = this.passGrade();
14021
14087
  slot++;
14022
14088
  });
14023
14089
  device.queue.writeBuffer(this.decalUniforms, 0, this.decalStaging, 0, slot * DECAL_SLOT);
@@ -14264,6 +14330,8 @@ export class WebGPURenderer implements RendererApi {
14264
14330
  g[upsampleAt('uDepthToViewZ') + 1] = inv[14] ?? 0;
14265
14331
  g[upsampleAt('uDepthToViewZ') + 2] = inv[11] ?? 0;
14266
14332
  g[upsampleAt('uDepthToViewZ') + 3] = inv[15] ?? 1;
14333
+ this.mediumUpsampleInts[upsampleAt('uOutputTransform')] = this.passGrade();
14334
+ g[upsampleAt('uOutputExposure')] = this.passGradeExposure();
14267
14335
  device.queue.writeBuffer(this.mediumUpsampleUniforms, 0, this.mediumUpsampleStaging);
14268
14336
 
14269
14337
  const march = encoder.beginRenderPass({
@@ -14989,6 +15057,8 @@ export class WebGPURenderer implements RendererApi {
14989
15057
  projection,
14990
15058
  this.skinInverseProjection,
14991
15059
  () => this.gpuTimer.writesFor(),
15060
+ this.passGrade(),
15061
+ this.passGradeExposure(),
14992
15062
  );
14993
15063
  this.pass = encoder.beginRenderPass({
14994
15064
  label: 'skin.scattered',
@@ -16199,8 +16269,8 @@ export class WebGPURenderer implements RendererApi {
16199
16269
  const context = this.passContext;
16200
16270
  context.pass = pass;
16201
16271
  /* The frame's grade, so a contributed pass can obey the 2026-08-17 rule. See `PassContext`. */
16202
- context.outputTransform = this.gradeCode();
16203
- context.outputExposure = this.gradeExposure();
16272
+ context.outputTransform = this.passGrade();
16273
+ context.outputExposure = this.passGradeExposure();
16204
16274
  context.format = target.format;
16205
16275
  context.samples = target.sampleCount;
16206
16276
  return context as PassContext;
@@ -16697,8 +16767,11 @@ export class WebGPURenderer implements RendererApi {
16697
16767
  const slot = this.insetUniforms.allocate();
16698
16768
  if (slot !== null) {
16699
16769
  /* Only the colour: the quad covers the viewport set above, so it needs no geometry. */
16700
- const color = clearColor ?? BLACK_CLEAR;
16701
- 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);
16702
16775
  const insetPipe = insetPipeline(
16703
16776
  this.targetPipelines(),
16704
16777
  this.surface.device,
@@ -17360,6 +17433,8 @@ export class WebGPURenderer implements RendererApi {
17360
17433
  f[atF('uTime')] = timeSeconds;
17361
17434
  f[atF('uMaxDrop')] = CAUSTICS_MAX_DROP_M;
17362
17435
  f[atF('uStrength')] = strength;
17436
+ i[atF('uOutputTransform')] = this.passGrade();
17437
+ f[atF('uOutputExposure')] = this.passGradeExposure();
17363
17438
  f.set(env.directionalDir, atF('uLightDir'));
17364
17439
 
17365
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
+ }