@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
@@ -15,4 +15,4 @@
15
15
  * draws.
16
16
  */
17
17
  export declare const LINE_VERT = "#version 300 es\nlayout(location = 0) in vec3 aFrom;\nlayout(location = 1) in vec3 aTo;\n/** Along the segment (0 or 1), and which side of it (-1 or +1). */\nlayout(location = 2) in vec2 aCorner;\n\nuniform mat4 uViewProj;\nuniform mat4 uModel;\nuniform vec3 uCameraPos;\n/** Half-width in metres, and how much of a pixel a far line must still cover. */\nuniform float uWidth;\nuniform float uMinWidthPerMetre;\n\nout float vSide;\nout vec3 vWorldPos;\n\nvoid main() {\n /*\n * Through the model matrix before anything else, unlike an arc, whose endpoints are\n * world-space by nature. A line is usually attached to something that moves, and the\n * alternative is making every caller transform its own points on the CPU each frame.\n *\n * The width is deliberately *not* scaled by the model. A stroke's weight is a property\n * of how it is drawn rather than of the geometry it follows, so a card that shrinks\n * keeps the same line weight, which is what a reader expects and what a designer means.\n */\n vec3 from = (uModel * vec4(aFrom, 1.0)).xyz;\n vec3 to = (uModel * vec4(aTo, 1.0)).xyz;\n\n vec3 mid = mix(from, to, aCorner.x);\n vec3 dir = to - from;\n float len = length(dir);\n dir = len > 1e-6 ? dir / len : vec3(0.0, 1.0, 0.0);\n\n vec3 view = mid - uCameraPos;\n float dist = length(view);\n view = dist > 1e-6 ? view / dist : vec3(0.0, 0.0, 1.0);\n\n /*\n * Across the segment and across the line of sight. Degenerate exactly when the segment\n * points at the viewer, where any perpendicular is as good as another, and where the\n * segment covers almost no screen anyway.\n */\n vec3 across = cross(dir, view);\n float span = length(across);\n across = span > 1e-4 ? across / span : normalize(cross(dir, vec3(0.0, 1.0, 0.0)) + vec3(1e-3));\n\n /*\n * A floor on the world-space width that grows with distance, so a stroke never thins\n * below a pixel. Under a pixel a thin line does not get fainter, it *flickers*, because\n * the rasteriser catches it on some frames and not others.\n */\n float halfWidth = max(uWidth, dist * uMinWidthPerMetre);\n vec3 world = mid + across * aCorner.y * halfWidth;\n\n vSide = aCorner.y;\n vWorldPos = world;\n gl_Position = uViewProj * vec4(world, 1.0);\n}\n";
18
- export declare const LINE_FRAG = "#version 300 es\nprecision highp float;\n\nin float vSide;\nin vec3 vWorldPos;\n\nuniform vec3 uColor;\nuniform float uOpacity;\nuniform vec3 uCameraPos;\n/**\n * How far in from the edge the stroke fades, as a fraction of its half-width. Zero is a\n * clean edge, one is a stroke that is soft all the way to its middle.\n */\nuniform float uSoftness;\n\n\nuniform vec3 uFogColor;\n/** Extinction per metre **at the camera's own height**, not at the base. */\nuniform float uFogDensity;\n/** Reciprocal scale height of the haze, per metre. 0 is a uniform medium. */\nuniform float uFogHeightFalloff;\n/** The camera's height, so a fragment's own height is one subtraction away. */\nuniform float uFogEyeY;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFogDensity;\nuniform float uUnderwaterFactor;\n/** 0 integrates a medium (Beer-Lambert); 1 ramps linearly between near and far. */\nuniform int uFogMode;\n/** Linear mode only: where haze begins, and where it is total. */\nuniform float uFogNear;\nuniform float uFogFar;\n\n/** What distance converges toward: the day's haze, or the water column. */\nvec3 mediumColor() {\n return mix(uFogColor, uUnderwaterColor, uUnderwaterFactor);\n}\n\n/**\n * How much of a surface standing at height pointY, dist metres away, is lost to\n * the medium in between. 0 is perfectly clear, 1 is the medium's own colour.\n */\nfloat mediumFog(float dist, float pointY) {\n /*\n * A depth ramp rather than a medium, for worlds authored against one.\n *\n * Beer-Lambert is the honest model and stays the default: it integrates something\n * physically there, so extinction begins at the eye and never stops. A linear near/far\n * fog is not that \u2014 it is a *look*, with a hard start distance before which the air is\n * perfectly clear, and it is what the fixed-function pipeline offered and what several\n * renderers still expose. Approximating one with the other is not a matter of tuning:\n * matched at half occlusion, the exponential curve still lays haze over everything\n * inside near, where the ramp lays none, and near-field colour desaturates toward the\n * fog colour with nothing to show for it.\n *\n * Height falloff has no meaning here and is ignored: a ramp is defined on distance\n * alone.\n */\n if (uFogMode == 1) {\n float span = max(uFogFar - uFogNear, 1e-4);\n float ramp = clamp((dist - uFogNear) / span, 0.0, 1.0);\n if (uUnderwaterFactor <= 0.0) return ramp;\n float wetLinear = uUnderwaterFogDensity * dist;\n return mix(ramp, 1.0 - exp2(-wetLinear * wetLinear * 1.442695), uUnderwaterFactor);\n }\n\n // Where the medium begins along the ray: uFogNear, the linear mode's slot, which this mode\n // never read before Atmosphere.fogStart. The ray is integrated from there on, at the density\n // of that point; at a start of 0 every term is the one it always was. See render/fog.ts.\n float start = min(uFogNear, dist);\n float before = dist > 0.0 ? start / dist : 0.0;\n float t = (pointY - uFogEyeY) * uFogHeightFalloff;\n float rest = t * (1.0 - before);\n // (1 - e^-t)/t, which tends to 1 as the ray levels out. Guarded rather than\n // branched: at t = 0 the quotient is 0/0, and 1e-4 of a scale height is 8 mm.\n float denom = abs(rest) < 1e-4 ? 1e-4 : rest;\n float air =\n 1.0 - exp(-uFogDensity * exp(-t * before) * (dist - start) * (1.0 - exp(-denom)) / denom);\n if (uUnderwaterFactor <= 0.0) return air;\n // Water is its own medium and keeps its own curve, so crossing the surface\n // changes nothing about how the water column reads.\n float wet = uUnderwaterFogDensity * dist;\n return mix(air, 1.0 - exp2(-wet * wet * 1.442695), uUnderwaterFactor);\n}\n\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\nvoid main() {\n /*\n * Coverage across the ribbon: one down the middle, falling to zero at both edges.\n *\n * The band is at least one pixel wide *in screen space*, which is what lets one shader\n * draw a stroke of any width at any distance without either aliasing into a staircase or\n * blurring into a smear. A constant band is tuned for one width and is wrong at the rest,\n * which is the same argument `SDF_TEXT_FRAG` makes for the same reason.\n */\n float edge = 1.0 - abs(vSide);\n float band = max(fwidth(vSide), uSoftness);\n float coverage = smoothstep(0.0, band, edge);\n\n /*\n * Fogged like geometry, which is where this parts company with `bolt.ts`. That shader\n * includes the same helpers and never calls them, because an arc is light arriving\n * rather than a surface losing some of itself to the air on the way over. A drawn line\n * is a thing in the world and recedes like one.\n */\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n vec3 color = mix(uColor, mediumColor(), fog);\n outColor = vec4(applyOutputTransform(color), coverage * uOpacity);\n}\n";
18
+ export declare const LINE_FRAG = "#version 300 es\nprecision highp float;\n\nin float vSide;\nin vec3 vWorldPos;\n\nuniform vec3 uColor;\nuniform float uOpacity;\nuniform vec3 uCameraPos;\n/**\n * How far in from the edge the stroke fades, as a fraction of its half-width. Zero is a\n * clean edge, one is a stroke that is soft all the way to its middle.\n */\nuniform float uSoftness;\n\n\nuniform vec3 uFogColor;\n/** Extinction per metre **at the camera's own height**, not at the base. */\nuniform float uFogDensity;\n/** Reciprocal scale height of the haze, per metre. 0 is a uniform medium. */\nuniform float uFogHeightFalloff;\n/** The camera's height, so a fragment's own height is one subtraction away. */\nuniform float uFogEyeY;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFogDensity;\nuniform float uUnderwaterFactor;\n/** 0 integrates a medium (Beer-Lambert); 1 ramps linearly between near and far. */\nuniform int uFogMode;\n/** Linear mode only: where haze begins, and where it is total. */\nuniform float uFogNear;\nuniform float uFogFar;\n\n/** What distance converges toward: the day's haze, or the water column. */\nvec3 mediumColor() {\n return mix(uFogColor, uUnderwaterColor, uUnderwaterFactor);\n}\n\n/**\n * How much of a surface standing at height pointY, dist metres away, is lost to\n * the medium in between. 0 is perfectly clear, 1 is the medium's own colour.\n */\nfloat mediumFog(float dist, float pointY) {\n /*\n * A depth ramp rather than a medium, for worlds authored against one.\n *\n * Beer-Lambert is the honest model and stays the default: it integrates something\n * physically there, so extinction begins at the eye and never stops. A linear near/far\n * fog is not that \u2014 it is a *look*, with a hard start distance before which the air is\n * perfectly clear, and it is what the fixed-function pipeline offered and what several\n * renderers still expose. Approximating one with the other is not a matter of tuning:\n * matched at half occlusion, the exponential curve still lays haze over everything\n * inside near, where the ramp lays none, and near-field colour desaturates toward the\n * fog colour with nothing to show for it.\n *\n * Height falloff has no meaning here and is ignored: a ramp is defined on distance\n * alone.\n */\n if (uFogMode == 1) {\n float span = max(uFogFar - uFogNear, 1e-4);\n float ramp = clamp((dist - uFogNear) / span, 0.0, 1.0);\n if (uUnderwaterFactor <= 0.0) return ramp;\n float wetLinear = uUnderwaterFogDensity * dist;\n return mix(ramp, 1.0 - exp2(-wetLinear * wetLinear * 1.442695), uUnderwaterFactor);\n }\n\n // Where the medium begins along the ray: uFogNear, the linear mode's slot, which this mode\n // never read before Atmosphere.fogStart. The ray is integrated from there on, at the density\n // of that point; at a start of 0 every term is the one it always was. See render/fog.ts.\n float start = min(uFogNear, dist);\n float before = dist > 0.0 ? start / dist : 0.0;\n float t = (pointY - uFogEyeY) * uFogHeightFalloff;\n float rest = t * (1.0 - before);\n // (1 - e^-t)/t, which tends to 1 as the ray levels out. Guarded rather than\n // branched: at t = 0 the quotient is 0/0, and 1e-4 of a scale height is 8 mm.\n float denom = abs(rest) < 1e-4 ? 1e-4 : rest;\n float air =\n 1.0 - exp(-uFogDensity * exp(-t * before) * (dist - start) * (1.0 - exp(-denom)) / denom);\n if (uUnderwaterFactor <= 0.0) return air;\n // Water is its own medium and keeps its own curve, so crossing the surface\n // changes nothing about how the water column reads.\n float wet = uUnderwaterFogDensity * dist;\n return mix(air, 1.0 - exp2(-wet * wet * 1.442695), uUnderwaterFactor);\n}\n\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\nvoid main() {\n /*\n * Coverage across the ribbon: one down the middle, falling to zero at both edges.\n *\n * The band is at least one pixel wide *in screen space*, which is what lets one shader\n * draw a stroke of any width at any distance without either aliasing into a staircase or\n * blurring into a smear. A constant band is tuned for one width and is wrong at the rest,\n * which is the same argument `SDF_TEXT_FRAG` makes for the same reason.\n */\n float edge = 1.0 - abs(vSide);\n float band = max(fwidth(vSide), uSoftness);\n float coverage = smoothstep(0.0, band, edge);\n\n /*\n * Fogged like geometry, which is where this parts company with `bolt.ts`. That shader\n * includes the same helpers and never calls them, because an arc is light arriving\n * rather than a surface losing some of itself to the air on the way over. A drawn line\n * is a thing in the world and recedes like one.\n */\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n vec3 color = mix(uColor, mediumColor(), fog);\n outColor = vec4(applyOutputTransform(color), coverage * uOpacity);\n}\n";
@@ -39,6 +39,12 @@
39
39
  * Shared by the forward passes and the resolve, so the two cannot disagree about it.
40
40
  */
41
41
  export declare const HIGHLIGHT_SHOULDER_GLSL = "\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n";
42
+ /**
43
+ * Linear to sRGB alone, for a stage that needs the encode and nothing else: one that multiplies an
44
+ * encoded pixel and wants the factor in the same terms (`decalProject.ts`). The body below includes
45
+ * it, so there is one definition. Branch-free, so it may sit where derivatives are taken.
46
+ */
47
+ export declare const LINEAR_TO_SRGB_GLSL = "\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n";
42
48
  /**
43
49
  * `uOutputTransform` and `uOutputExposure`, the fit, and `applyOutputTransform`.
44
50
  *
@@ -47,11 +53,11 @@ export declare const HIGHLIGHT_SHOULDER_GLSL = "\nvec3 highlightShoulder(vec3 c)
47
53
  * caller's, per draw, and a permutation per output transform would double a pipeline count for
48
54
  * a branch that costs one compare.
49
55
  */
50
- export declare const OUTPUT_TRANSFORM_GLSL = "\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n";
56
+ export declare const OUTPUT_TRANSFORM_GLSL = "\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n";
51
57
  /**
52
58
  * The same, for the lit stage, whose two uniforms live in its material block: a draw that opts out
53
59
  * of the grade sets them, so they change with the material and not with the pass. See
54
60
  * `hoistUniformBlock` in `scripts/wgsl/transform.mjs`. Text otherwise identical, so WebGL2 runs
55
61
  * the same program it always has.
56
62
  */
57
- export declare const LIT_OUTPUT_TRANSFORM_GLSL = "\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform; // wgsl:material\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure; // wgsl:material\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n";
63
+ export declare const LIT_OUTPUT_TRANSFORM_GLSL = "\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform; // wgsl:material\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure; // wgsl:material\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n";
@@ -46,6 +46,23 @@ vec3 highlightShoulder(vec3 c) {
46
46
  return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);
47
47
  }
48
48
  `;
49
+ /**
50
+ * Linear to sRGB alone, for a stage that needs the encode and nothing else: one that multiplies an
51
+ * encoded pixel and wants the factor in the same terms (`decalProject.ts`). The body below includes
52
+ * it, so there is one definition. Branch-free, so it may sit where derivatives are taken.
53
+ */
54
+ export const LINEAR_TO_SRGB_GLSL = `
55
+ /*
56
+ * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black
57
+ * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the
58
+ * wrong place.
59
+ */
60
+ vec3 linearToSrgb(vec3 c) {
61
+ vec3 low = c * 12.92;
62
+ vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;
63
+ return mix(high, low, step(c, vec3(0.0031308)));
64
+ }
65
+ `;
49
66
  /** The fit and `applyOutputTransform`, below whichever declaration of the two uniforms. */
50
67
  const OUTPUT_TRANSFORM_BODY_GLSL = `
51
68
  /*
@@ -95,17 +112,7 @@ vec3 acesFilmic(vec3 x) {
95
112
  return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);
96
113
  }
97
114
 
98
- /*
99
- * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black
100
- * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the
101
- * wrong place.
102
- */
103
- vec3 linearToSrgb(vec3 c) {
104
- vec3 low = c * 12.92;
105
- vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;
106
- return mix(high, low, step(c, vec3(0.0031308)));
107
- }
108
-
115
+ ${LINEAR_TO_SRGB_GLSL}
109
116
  ${HIGHLIGHT_SHOULDER_GLSL}
110
117
  vec3 applyOutputTransform(vec3 c) {
111
118
  if (uOutputTransform == 0) return c;
@@ -14,4 +14,4 @@
14
14
  * `InsetRect` uses and the same one a caller laying out an overlay already has.
15
15
  */
16
16
  export declare const PANEL_VERT = "#version 300 es\nlayout(location = 0) in vec2 aCorner;\n\n/** left, top, width, height, in CSS pixels from the top-left of the viewport. */\nuniform vec4 uRect;\nuniform vec2 uViewport;\n/**\n * Clip space, as the backend drawing this defines it. Identity on WebGL2.\n *\n * Like the text pass, this builds its own clip position and never multiplies by a camera, so the\n * correction every other draw carries in its view-projection cannot reach it. Without it the\n * generated vertex shader's Y negation stands uncancelled and the panel lands mirrored about\n * the middle of the frame \u2014 a bar drawn at 82% of the height appearing at 18% of it.\n */\nuniform mat4 uClipCorrection;\n\nvoid main() {\n vec2 px = uRect.xy + aCorner * uRect.zw;\n // Into clip space, flipping y: the caller counts down from the top and GL counts up.\n vec2 ndc = vec2(px.x / uViewport.x * 2.0 - 1.0, 1.0 - px.y / uViewport.y * 2.0);\n gl_Position = uClipCorrection * vec4(ndc, 0.0, 1.0);\n}\n";
17
- export declare const PANEL_FRAG = "#version 300 es\nprecision highp float;\n\nuniform vec3 uColor;\nuniform float uAlpha;\n\nout vec4 outColor;\n\nvoid main() {\n outColor = vec4(uColor, uAlpha);\n}\n";
17
+ export declare const PANEL_FRAG = "#version 300 es\nprecision highp float;\n\nuniform vec3 uColor;\nuniform float uAlpha;\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\nvoid main() {\n /* Graded where nothing after this will grade it, as every pass is: a panel's colour is the\n same linear colour as the text drawn over it. The alpha is coverage. */\n outColor = vec4(applyOutputTransform(uColor), uAlpha);\n}\n";
@@ -1,3 +1,4 @@
1
+ import { OUTPUT_TRANSFORM_GLSL } from './outputTransform.js';
1
2
  /**
2
3
  * A flat, blended rectangle in screen space.
3
4
  *
@@ -43,8 +44,11 @@ uniform vec3 uColor;
43
44
  uniform float uAlpha;
44
45
 
45
46
  out vec4 outColor;
47
+ ${OUTPUT_TRANSFORM_GLSL}
46
48
 
47
49
  void main() {
48
- outColor = vec4(uColor, uAlpha);
50
+ /* Graded where nothing after this will grade it, as every pass is: a panel's colour is the
51
+ same linear colour as the text drawn over it. The alpha is coverage. */
52
+ outColor = vec4(applyOutputTransform(uColor), uAlpha);
49
53
  }
50
54
  `;
@@ -47,7 +47,7 @@ export declare const PARTICLE_VERT = "#version 300 es\n/** Per-vertex: the quad
47
47
  * and unlit smoke in a world lit by fires is the tell that gives an effect away
48
48
  * fastest. A puff catching a flame's colour is most of what makes it belong.
49
49
  */
50
- export declare const PARTICLE_SMOKE_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\nin vec3 vColor;\nin float vAlpha;\nin float vAge;\nin float vSeed;\nin vec3 vWorldPos;\nin vec3 vNormal;\n\nuniform float uTime;\nuniform vec3 uDirectionalDir;\nuniform vec3 uDirectionalColor;\nuniform vec3 uAmbient;\nuniform vec3 uCameraPos;\nuniform int uNoiseOctaves;\n/** How strongly the noise eats into the puff. 0 is a plain soft ball. */\nuniform float uErosion;\n\n#define MAX_LIGHTS 16\nuniform int uLightCount;\nuniform vec3 uLightPos[MAX_LIGHTS];\nuniform vec3 uLightColor[MAX_LIGHTS];\nuniform float uLightRadius[MAX_LIGHTS];\n// How present each light is, 0 to 1. See pointLightSelection.ts.\nuniform float uLightWeight[MAX_LIGHTS];\n\n\nuniform vec3 uFogColor;\n/** Extinction per metre **at the camera's own height**, not at the base. */\nuniform float uFogDensity;\n/** Reciprocal scale height of the haze, per metre. 0 is a uniform medium. */\nuniform float uFogHeightFalloff;\n/** The camera's height, so a fragment's own height is one subtraction away. */\nuniform float uFogEyeY;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFogDensity;\nuniform float uUnderwaterFactor;\n/** 0 integrates a medium (Beer-Lambert); 1 ramps linearly between near and far. */\nuniform int uFogMode;\n/** Linear mode only: where haze begins, and where it is total. */\nuniform float uFogNear;\nuniform float uFogFar;\n\n/** What distance converges toward: the day's haze, or the water column. */\nvec3 mediumColor() {\n return mix(uFogColor, uUnderwaterColor, uUnderwaterFactor);\n}\n\n/**\n * How much of a surface standing at height pointY, dist metres away, is lost to\n * the medium in between. 0 is perfectly clear, 1 is the medium's own colour.\n */\nfloat mediumFog(float dist, float pointY) {\n /*\n * A depth ramp rather than a medium, for worlds authored against one.\n *\n * Beer-Lambert is the honest model and stays the default: it integrates something\n * physically there, so extinction begins at the eye and never stops. A linear near/far\n * fog is not that \u2014 it is a *look*, with a hard start distance before which the air is\n * perfectly clear, and it is what the fixed-function pipeline offered and what several\n * renderers still expose. Approximating one with the other is not a matter of tuning:\n * matched at half occlusion, the exponential curve still lays haze over everything\n * inside near, where the ramp lays none, and near-field colour desaturates toward the\n * fog colour with nothing to show for it.\n *\n * Height falloff has no meaning here and is ignored: a ramp is defined on distance\n * alone.\n */\n if (uFogMode == 1) {\n float span = max(uFogFar - uFogNear, 1e-4);\n float ramp = clamp((dist - uFogNear) / span, 0.0, 1.0);\n if (uUnderwaterFactor <= 0.0) return ramp;\n float wetLinear = uUnderwaterFogDensity * dist;\n return mix(ramp, 1.0 - exp2(-wetLinear * wetLinear * 1.442695), uUnderwaterFactor);\n }\n\n // Where the medium begins along the ray: uFogNear, the linear mode's slot, which this mode\n // never read before Atmosphere.fogStart. The ray is integrated from there on, at the density\n // of that point; at a start of 0 every term is the one it always was. See render/fog.ts.\n float start = min(uFogNear, dist);\n float before = dist > 0.0 ? start / dist : 0.0;\n float t = (pointY - uFogEyeY) * uFogHeightFalloff;\n float rest = t * (1.0 - before);\n // (1 - e^-t)/t, which tends to 1 as the ray levels out. Guarded rather than\n // branched: at t = 0 the quotient is 0/0, and 1e-4 of a scale height is 8 mm.\n float denom = abs(rest) < 1e-4 ? 1e-4 : rest;\n float air =\n 1.0 - exp(-uFogDensity * exp(-t * before) * (dist - start) * (1.0 - exp(-denom)) / denom);\n if (uUnderwaterFactor <= 0.0) return air;\n // Water is its own medium and keeps its own curve, so crossing the surface\n // changes nothing about how the water column reads.\n float wet = uUnderwaterFogDensity * dist;\n return mix(air, 1.0 - exp2(-wet * wet * 1.442695), uUnderwaterFactor);\n}\n\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat valueNoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(hash21(i), hash21(i + vec2(1.0, 0.0)), u.x),\n mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0, 1.0)), u.x),\n u.y\n );\n}\n\nfloat billow(vec2 p, float t, int octaves) {\n float v = valueNoise(p * 2.4 + vec2(0.0, -t * 0.5)) * 0.62;\n if (octaves == 1) return v / 0.62;\n v += valueNoise(p * 5.1 + vec2(t * 0.15, -t * 0.8)) * 0.38;\n return v / 1.0;\n}\n\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\nvoid main() {\n /*\n * A round, soft body first. 'smoothstep' rather than a hard disc: the edge is\n * the entire difference between a puff and a sprite, and a cut-off circle reads\n * as a decal however good the noise inside it is.\n */\n float r = length(vUv);\n float body = 1.0 - smoothstep(0.35, 1.0, r);\n if (body <= 0.0) discard;\n\n /*\n * Then eaten into by noise that drifts and coarsens as the puff ages, so it\n * dissipates by *breaking up* rather than by uniformly thinning. Uniform\n * thinning is what a fading sprite does, and it is what the old cube did.\n */\n float t = uTime * 0.55 + vSeed * 7.31;\n float n = billow(vUv * (0.9 + vAge * 1.4) + vec2(vSeed * 3.7, 0.0), t, uNoiseOctaves);\n float erode = mix(1.0, n, uErosion * (0.35 + vAge * 0.65));\n float mask = clamp(body * erode, 0.0, 1.0);\n // Feather the last of it out rather than letting a noise threshold flicker.\n mask *= smoothstep(0.0, 0.25, mask);\n if (mask <= 0.002) discard;\n\n vec3 n3 = normalize(vNormal);\n /*\n * Wrapped diffuse. Smoke transmits as well as reflects, so the terminator on a\n * puff sits well past 90\u00B0 \u2014 a hard 'max(dot, 0)' gives a lump with a black side,\n * which is exactly the \"coloured box\" failure the fidelity rules name.\n */\n float ndl = dot(n3, uDirectionalDir) * 0.5 + 0.5;\n vec3 lit = uAmbient + uDirectionalColor * ndl;\n\n for (int i = 0; i < MAX_LIGHTS; i++) {\n if (i >= uLightCount) break;\n vec3 toLight = uLightPos[i] - vWorldPos;\n float dist = length(toLight);\n float radius = uLightRadius[i];\n if (radius <= 0.0 || dist >= radius) continue;\n float falloff = 1.0 - dist / radius;\n float wrapped = dot(n3, toLight / max(dist, 1e-4)) * 0.5 + 0.5;\n lit += uLightColor[i] * falloff * falloff * wrapped * uLightWeight[i];\n }\n\n vec3 shaded = vColor * lit;\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n outColor = vec4(applyOutputTransform(mix(shaded, mediumColor(), fog)), mask * vAlpha);\n}\n";
50
+ export declare const PARTICLE_SMOKE_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\nin vec3 vColor;\nin float vAlpha;\nin float vAge;\nin float vSeed;\nin vec3 vWorldPos;\nin vec3 vNormal;\n\nuniform float uTime;\nuniform vec3 uDirectionalDir;\nuniform vec3 uDirectionalColor;\nuniform vec3 uAmbient;\nuniform vec3 uCameraPos;\nuniform int uNoiseOctaves;\n/** How strongly the noise eats into the puff. 0 is a plain soft ball. */\nuniform float uErosion;\n\n#define MAX_LIGHTS 16\nuniform int uLightCount;\nuniform vec3 uLightPos[MAX_LIGHTS];\nuniform vec3 uLightColor[MAX_LIGHTS];\nuniform float uLightRadius[MAX_LIGHTS];\n// How present each light is, 0 to 1. See pointLightSelection.ts.\nuniform float uLightWeight[MAX_LIGHTS];\n\n\nuniform vec3 uFogColor;\n/** Extinction per metre **at the camera's own height**, not at the base. */\nuniform float uFogDensity;\n/** Reciprocal scale height of the haze, per metre. 0 is a uniform medium. */\nuniform float uFogHeightFalloff;\n/** The camera's height, so a fragment's own height is one subtraction away. */\nuniform float uFogEyeY;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFogDensity;\nuniform float uUnderwaterFactor;\n/** 0 integrates a medium (Beer-Lambert); 1 ramps linearly between near and far. */\nuniform int uFogMode;\n/** Linear mode only: where haze begins, and where it is total. */\nuniform float uFogNear;\nuniform float uFogFar;\n\n/** What distance converges toward: the day's haze, or the water column. */\nvec3 mediumColor() {\n return mix(uFogColor, uUnderwaterColor, uUnderwaterFactor);\n}\n\n/**\n * How much of a surface standing at height pointY, dist metres away, is lost to\n * the medium in between. 0 is perfectly clear, 1 is the medium's own colour.\n */\nfloat mediumFog(float dist, float pointY) {\n /*\n * A depth ramp rather than a medium, for worlds authored against one.\n *\n * Beer-Lambert is the honest model and stays the default: it integrates something\n * physically there, so extinction begins at the eye and never stops. A linear near/far\n * fog is not that \u2014 it is a *look*, with a hard start distance before which the air is\n * perfectly clear, and it is what the fixed-function pipeline offered and what several\n * renderers still expose. Approximating one with the other is not a matter of tuning:\n * matched at half occlusion, the exponential curve still lays haze over everything\n * inside near, where the ramp lays none, and near-field colour desaturates toward the\n * fog colour with nothing to show for it.\n *\n * Height falloff has no meaning here and is ignored: a ramp is defined on distance\n * alone.\n */\n if (uFogMode == 1) {\n float span = max(uFogFar - uFogNear, 1e-4);\n float ramp = clamp((dist - uFogNear) / span, 0.0, 1.0);\n if (uUnderwaterFactor <= 0.0) return ramp;\n float wetLinear = uUnderwaterFogDensity * dist;\n return mix(ramp, 1.0 - exp2(-wetLinear * wetLinear * 1.442695), uUnderwaterFactor);\n }\n\n // Where the medium begins along the ray: uFogNear, the linear mode's slot, which this mode\n // never read before Atmosphere.fogStart. The ray is integrated from there on, at the density\n // of that point; at a start of 0 every term is the one it always was. See render/fog.ts.\n float start = min(uFogNear, dist);\n float before = dist > 0.0 ? start / dist : 0.0;\n float t = (pointY - uFogEyeY) * uFogHeightFalloff;\n float rest = t * (1.0 - before);\n // (1 - e^-t)/t, which tends to 1 as the ray levels out. Guarded rather than\n // branched: at t = 0 the quotient is 0/0, and 1e-4 of a scale height is 8 mm.\n float denom = abs(rest) < 1e-4 ? 1e-4 : rest;\n float air =\n 1.0 - exp(-uFogDensity * exp(-t * before) * (dist - start) * (1.0 - exp(-denom)) / denom);\n if (uUnderwaterFactor <= 0.0) return air;\n // Water is its own medium and keeps its own curve, so crossing the surface\n // changes nothing about how the water column reads.\n float wet = uUnderwaterFogDensity * dist;\n return mix(air, 1.0 - exp2(-wet * wet * 1.442695), uUnderwaterFactor);\n}\n\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat valueNoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(hash21(i), hash21(i + vec2(1.0, 0.0)), u.x),\n mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0, 1.0)), u.x),\n u.y\n );\n}\n\nfloat billow(vec2 p, float t, int octaves) {\n float v = valueNoise(p * 2.4 + vec2(0.0, -t * 0.5)) * 0.62;\n if (octaves == 1) return v / 0.62;\n v += valueNoise(p * 5.1 + vec2(t * 0.15, -t * 0.8)) * 0.38;\n return v / 1.0;\n}\n\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\nvoid main() {\n /*\n * A round, soft body first. 'smoothstep' rather than a hard disc: the edge is\n * the entire difference between a puff and a sprite, and a cut-off circle reads\n * as a decal however good the noise inside it is.\n */\n float r = length(vUv);\n float body = 1.0 - smoothstep(0.35, 1.0, r);\n if (body <= 0.0) discard;\n\n /*\n * Then eaten into by noise that drifts and coarsens as the puff ages, so it\n * dissipates by *breaking up* rather than by uniformly thinning. Uniform\n * thinning is what a fading sprite does, and it is what the old cube did.\n */\n float t = uTime * 0.55 + vSeed * 7.31;\n float n = billow(vUv * (0.9 + vAge * 1.4) + vec2(vSeed * 3.7, 0.0), t, uNoiseOctaves);\n float erode = mix(1.0, n, uErosion * (0.35 + vAge * 0.65));\n float mask = clamp(body * erode, 0.0, 1.0);\n // Feather the last of it out rather than letting a noise threshold flicker.\n mask *= smoothstep(0.0, 0.25, mask);\n if (mask <= 0.002) discard;\n\n vec3 n3 = normalize(vNormal);\n /*\n * Wrapped diffuse. Smoke transmits as well as reflects, so the terminator on a\n * puff sits well past 90\u00B0 \u2014 a hard 'max(dot, 0)' gives a lump with a black side,\n * which is exactly the \"coloured box\" failure the fidelity rules name.\n */\n float ndl = dot(n3, uDirectionalDir) * 0.5 + 0.5;\n vec3 lit = uAmbient + uDirectionalColor * ndl;\n\n for (int i = 0; i < MAX_LIGHTS; i++) {\n if (i >= uLightCount) break;\n vec3 toLight = uLightPos[i] - vWorldPos;\n float dist = length(toLight);\n float radius = uLightRadius[i];\n if (radius <= 0.0 || dist >= radius) continue;\n float falloff = 1.0 - dist / radius;\n float wrapped = dot(n3, toLight / max(dist, 1e-4)) * 0.5 + 0.5;\n lit += uLightColor[i] * falloff * falloff * wrapped * uLightWeight[i];\n }\n\n vec3 shaded = vColor * lit;\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n outColor = vec4(applyOutputTransform(mix(shaded, mediumColor(), fog)), mask * vAlpha);\n}\n";
51
51
  /**
52
52
  * Sparks and embers: additive, hot in the middle, and stretched along travel.
53
53
  *
@@ -55,7 +55,7 @@ export declare const PARTICLE_SMOKE_FRAG = "#version 300 es\nprecision highp flo
55
55
  * is what turned the first version into a grey cube at night. The core is allowed
56
56
  * past white so it reads as incandescent rather than as a pale dot.
57
57
  */
58
- export declare const PARTICLE_SPARK_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\nin vec3 vColor;\nin float vAlpha;\nin float vAge;\nin float vSeed;\nin vec3 vWorldPos;\nin vec3 vNormal;\n\nuniform float uTime;\nuniform vec3 uCameraPos;\n/** How far past white the centre may go. */\nuniform float uCoreGain;\n\nuniform vec3 uFogColor;\n/** Extinction per metre **at the camera's own height**, not at the base. */\nuniform float uFogDensity;\n/** Reciprocal scale height of the haze, per metre. 0 is a uniform medium. */\nuniform float uFogHeightFalloff;\n/** The camera's height, so a fragment's own height is one subtraction away. */\nuniform float uFogEyeY;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFogDensity;\nuniform float uUnderwaterFactor;\n/** 0 integrates a medium (Beer-Lambert); 1 ramps linearly between near and far. */\nuniform int uFogMode;\n/** Linear mode only: where haze begins, and where it is total. */\nuniform float uFogNear;\nuniform float uFogFar;\n\n/** What distance converges toward: the day's haze, or the water column. */\nvec3 mediumColor() {\n return mix(uFogColor, uUnderwaterColor, uUnderwaterFactor);\n}\n\n/**\n * How much of a surface standing at height pointY, dist metres away, is lost to\n * the medium in between. 0 is perfectly clear, 1 is the medium's own colour.\n */\nfloat mediumFog(float dist, float pointY) {\n /*\n * A depth ramp rather than a medium, for worlds authored against one.\n *\n * Beer-Lambert is the honest model and stays the default: it integrates something\n * physically there, so extinction begins at the eye and never stops. A linear near/far\n * fog is not that \u2014 it is a *look*, with a hard start distance before which the air is\n * perfectly clear, and it is what the fixed-function pipeline offered and what several\n * renderers still expose. Approximating one with the other is not a matter of tuning:\n * matched at half occlusion, the exponential curve still lays haze over everything\n * inside near, where the ramp lays none, and near-field colour desaturates toward the\n * fog colour with nothing to show for it.\n *\n * Height falloff has no meaning here and is ignored: a ramp is defined on distance\n * alone.\n */\n if (uFogMode == 1) {\n float span = max(uFogFar - uFogNear, 1e-4);\n float ramp = clamp((dist - uFogNear) / span, 0.0, 1.0);\n if (uUnderwaterFactor <= 0.0) return ramp;\n float wetLinear = uUnderwaterFogDensity * dist;\n return mix(ramp, 1.0 - exp2(-wetLinear * wetLinear * 1.442695), uUnderwaterFactor);\n }\n\n // Where the medium begins along the ray: uFogNear, the linear mode's slot, which this mode\n // never read before Atmosphere.fogStart. The ray is integrated from there on, at the density\n // of that point; at a start of 0 every term is the one it always was. See render/fog.ts.\n float start = min(uFogNear, dist);\n float before = dist > 0.0 ? start / dist : 0.0;\n float t = (pointY - uFogEyeY) * uFogHeightFalloff;\n float rest = t * (1.0 - before);\n // (1 - e^-t)/t, which tends to 1 as the ray levels out. Guarded rather than\n // branched: at t = 0 the quotient is 0/0, and 1e-4 of a scale height is 8 mm.\n float denom = abs(rest) < 1e-4 ? 1e-4 : rest;\n float air =\n 1.0 - exp(-uFogDensity * exp(-t * before) * (dist - start) * (1.0 - exp(-denom)) / denom);\n if (uUnderwaterFactor <= 0.0) return air;\n // Water is its own medium and keeps its own curve, so crossing the surface\n // changes nothing about how the water column reads.\n float wet = uUnderwaterFogDensity * dist;\n return mix(air, 1.0 - exp2(-wet * wet * 1.442695), uUnderwaterFactor);\n}\n\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat valueNoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(hash21(i), hash21(i + vec2(1.0, 0.0)), u.x),\n mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0, 1.0)), u.x),\n u.y\n );\n}\n\nfloat billow(vec2 p, float t, int octaves) {\n float v = valueNoise(p * 2.4 + vec2(0.0, -t * 0.5)) * 0.62;\n if (octaves == 1) return v / 0.62;\n v += valueNoise(p * 5.1 + vec2(t * 0.15, -t * 0.8)) * 0.38;\n return v / 1.0;\n}\n\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\nvoid main() {\n float r = length(vUv);\n if (r > 1.0) discard;\n\n /*\n * Two falloffs again \u2014 a tight core and a broad halo \u2014 because one gives either\n * a hard dot or a soft smudge. The halo is what makes a shower of embers read\n * as glowing air rather than as a scatter of points.\n */\n float core = pow(1.0 - clamp(r, 0.0, 1.0), 6.0);\n float halo = pow(1.0 - clamp(r, 0.0, 1.0), 1.6);\n\n /*\n * Embers flicker as they tumble and cool. Per-particle phase from the seed, so\n * a shower crackles instead of pulsing as one body.\n */\n float flick = 0.7 + 0.3 * valueNoise(vec2(uTime * 9.0 + vSeed * 13.0, vSeed));\n\n // Cooling: the last of a spark's life is dimmer as well as smaller.\n float cool = 1.0 - vAge * vAge;\n float energy = (halo * 0.55 + core * uCoreGain) * flick * cool * vAlpha;\n\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n energy *= 1.0 - fog;\n /*\n * Alpha 1, energy in the colour \u2014 see the note in 'bolt.ts'. Additive blending is\n * 'rgb * a', so carrying the energy in both channels squares it and quietly halves\n * every emissive effect in the game.\n */\n outColor = vec4(applyOutputTransform(vColor * energy), 1.0);\n}\n";
58
+ export declare const PARTICLE_SPARK_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\nin vec3 vColor;\nin float vAlpha;\nin float vAge;\nin float vSeed;\nin vec3 vWorldPos;\nin vec3 vNormal;\n\nuniform float uTime;\nuniform vec3 uCameraPos;\n/** How far past white the centre may go. */\nuniform float uCoreGain;\n\nuniform vec3 uFogColor;\n/** Extinction per metre **at the camera's own height**, not at the base. */\nuniform float uFogDensity;\n/** Reciprocal scale height of the haze, per metre. 0 is a uniform medium. */\nuniform float uFogHeightFalloff;\n/** The camera's height, so a fragment's own height is one subtraction away. */\nuniform float uFogEyeY;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFogDensity;\nuniform float uUnderwaterFactor;\n/** 0 integrates a medium (Beer-Lambert); 1 ramps linearly between near and far. */\nuniform int uFogMode;\n/** Linear mode only: where haze begins, and where it is total. */\nuniform float uFogNear;\nuniform float uFogFar;\n\n/** What distance converges toward: the day's haze, or the water column. */\nvec3 mediumColor() {\n return mix(uFogColor, uUnderwaterColor, uUnderwaterFactor);\n}\n\n/**\n * How much of a surface standing at height pointY, dist metres away, is lost to\n * the medium in between. 0 is perfectly clear, 1 is the medium's own colour.\n */\nfloat mediumFog(float dist, float pointY) {\n /*\n * A depth ramp rather than a medium, for worlds authored against one.\n *\n * Beer-Lambert is the honest model and stays the default: it integrates something\n * physically there, so extinction begins at the eye and never stops. A linear near/far\n * fog is not that \u2014 it is a *look*, with a hard start distance before which the air is\n * perfectly clear, and it is what the fixed-function pipeline offered and what several\n * renderers still expose. Approximating one with the other is not a matter of tuning:\n * matched at half occlusion, the exponential curve still lays haze over everything\n * inside near, where the ramp lays none, and near-field colour desaturates toward the\n * fog colour with nothing to show for it.\n *\n * Height falloff has no meaning here and is ignored: a ramp is defined on distance\n * alone.\n */\n if (uFogMode == 1) {\n float span = max(uFogFar - uFogNear, 1e-4);\n float ramp = clamp((dist - uFogNear) / span, 0.0, 1.0);\n if (uUnderwaterFactor <= 0.0) return ramp;\n float wetLinear = uUnderwaterFogDensity * dist;\n return mix(ramp, 1.0 - exp2(-wetLinear * wetLinear * 1.442695), uUnderwaterFactor);\n }\n\n // Where the medium begins along the ray: uFogNear, the linear mode's slot, which this mode\n // never read before Atmosphere.fogStart. The ray is integrated from there on, at the density\n // of that point; at a start of 0 every term is the one it always was. See render/fog.ts.\n float start = min(uFogNear, dist);\n float before = dist > 0.0 ? start / dist : 0.0;\n float t = (pointY - uFogEyeY) * uFogHeightFalloff;\n float rest = t * (1.0 - before);\n // (1 - e^-t)/t, which tends to 1 as the ray levels out. Guarded rather than\n // branched: at t = 0 the quotient is 0/0, and 1e-4 of a scale height is 8 mm.\n float denom = abs(rest) < 1e-4 ? 1e-4 : rest;\n float air =\n 1.0 - exp(-uFogDensity * exp(-t * before) * (dist - start) * (1.0 - exp(-denom)) / denom);\n if (uUnderwaterFactor <= 0.0) return air;\n // Water is its own medium and keeps its own curve, so crossing the surface\n // changes nothing about how the water column reads.\n float wet = uUnderwaterFogDensity * dist;\n return mix(air, 1.0 - exp2(-wet * wet * 1.442695), uUnderwaterFactor);\n}\n\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat valueNoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(hash21(i), hash21(i + vec2(1.0, 0.0)), u.x),\n mix(hash21(i + vec2(0.0, 1.0)), hash21(i + vec2(1.0, 1.0)), u.x),\n u.y\n );\n}\n\nfloat billow(vec2 p, float t, int octaves) {\n float v = valueNoise(p * 2.4 + vec2(0.0, -t * 0.5)) * 0.62;\n if (octaves == 1) return v / 0.62;\n v += valueNoise(p * 5.1 + vec2(t * 0.15, -t * 0.8)) * 0.38;\n return v / 1.0;\n}\n\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\nvoid main() {\n float r = length(vUv);\n if (r > 1.0) discard;\n\n /*\n * Two falloffs again \u2014 a tight core and a broad halo \u2014 because one gives either\n * a hard dot or a soft smudge. The halo is what makes a shower of embers read\n * as glowing air rather than as a scatter of points.\n */\n float core = pow(1.0 - clamp(r, 0.0, 1.0), 6.0);\n float halo = pow(1.0 - clamp(r, 0.0, 1.0), 1.6);\n\n /*\n * Embers flicker as they tumble and cool. Per-particle phase from the seed, so\n * a shower crackles instead of pulsing as one body.\n */\n float flick = 0.7 + 0.3 * valueNoise(vec2(uTime * 9.0 + vSeed * 13.0, vSeed));\n\n // Cooling: the last of a spark's life is dimmer as well as smaller.\n float cool = 1.0 - vAge * vAge;\n float energy = (halo * 0.55 + core * uCoreGain) * flick * cool * vAlpha;\n\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n energy *= 1.0 - fog;\n /*\n * Alpha 1, energy in the colour \u2014 see the note in 'bolt.ts'. Additive blending is\n * 'rgb * a', so carrying the energy in both channels squares it and quietly halves\n * every emissive effect in the game.\n */\n outColor = vec4(applyOutputTransform(vColor * energy), 1.0);\n}\n";
59
59
  /**
60
60
  * Dust motes and drifting glints: unlit, alpha blended, and fogged only when the caller asks.
61
61
  *
@@ -79,7 +79,7 @@ export declare const PARTICLE_SPARK_FRAG = "#version 300 es\nprecision highp flo
79
79
  * option and stay unconditionally fogged, matching every particle drawn before this material
80
80
  * existed; see `ParticleBatchOptions.fog`.
81
81
  */
82
- export declare const PARTICLE_MOTE_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\nin vec3 vColor;\nin float vAlpha;\nin float vAge;\nin float vSeed;\nin vec3 vWorldPos;\nin vec3 vNormal;\n\nuniform vec3 uCameraPos;\n/**\n * Whether the fragment stage mixes toward the medium's own colour at all. 0 \u2014 what a batch\n * gets by leaving ParticleBatchOptions.fog unset \u2014 leaves vColor untouched by distance, which\n * is what a caller reaching for this material over 'spark'/'smoke' is usually asking for: an\n * unlit point drawn exactly as its own colour says. 1 mixes toward mediumColor() by\n * mediumFog(), the same medium every other pass in the frame recedes into.\n */\nuniform int uFogEnabled;\n\nuniform vec3 uFogColor;\n/** Extinction per metre **at the camera's own height**, not at the base. */\nuniform float uFogDensity;\n/** Reciprocal scale height of the haze, per metre. 0 is a uniform medium. */\nuniform float uFogHeightFalloff;\n/** The camera's height, so a fragment's own height is one subtraction away. */\nuniform float uFogEyeY;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFogDensity;\nuniform float uUnderwaterFactor;\n/** 0 integrates a medium (Beer-Lambert); 1 ramps linearly between near and far. */\nuniform int uFogMode;\n/** Linear mode only: where haze begins, and where it is total. */\nuniform float uFogNear;\nuniform float uFogFar;\n\n/** What distance converges toward: the day's haze, or the water column. */\nvec3 mediumColor() {\n return mix(uFogColor, uUnderwaterColor, uUnderwaterFactor);\n}\n\n/**\n * How much of a surface standing at height pointY, dist metres away, is lost to\n * the medium in between. 0 is perfectly clear, 1 is the medium's own colour.\n */\nfloat mediumFog(float dist, float pointY) {\n /*\n * A depth ramp rather than a medium, for worlds authored against one.\n *\n * Beer-Lambert is the honest model and stays the default: it integrates something\n * physically there, so extinction begins at the eye and never stops. A linear near/far\n * fog is not that \u2014 it is a *look*, with a hard start distance before which the air is\n * perfectly clear, and it is what the fixed-function pipeline offered and what several\n * renderers still expose. Approximating one with the other is not a matter of tuning:\n * matched at half occlusion, the exponential curve still lays haze over everything\n * inside near, where the ramp lays none, and near-field colour desaturates toward the\n * fog colour with nothing to show for it.\n *\n * Height falloff has no meaning here and is ignored: a ramp is defined on distance\n * alone.\n */\n if (uFogMode == 1) {\n float span = max(uFogFar - uFogNear, 1e-4);\n float ramp = clamp((dist - uFogNear) / span, 0.0, 1.0);\n if (uUnderwaterFactor <= 0.0) return ramp;\n float wetLinear = uUnderwaterFogDensity * dist;\n return mix(ramp, 1.0 - exp2(-wetLinear * wetLinear * 1.442695), uUnderwaterFactor);\n }\n\n // Where the medium begins along the ray: uFogNear, the linear mode's slot, which this mode\n // never read before Atmosphere.fogStart. The ray is integrated from there on, at the density\n // of that point; at a start of 0 every term is the one it always was. See render/fog.ts.\n float start = min(uFogNear, dist);\n float before = dist > 0.0 ? start / dist : 0.0;\n float t = (pointY - uFogEyeY) * uFogHeightFalloff;\n float rest = t * (1.0 - before);\n // (1 - e^-t)/t, which tends to 1 as the ray levels out. Guarded rather than\n // branched: at t = 0 the quotient is 0/0, and 1e-4 of a scale height is 8 mm.\n float denom = abs(rest) < 1e-4 ? 1e-4 : rest;\n float air =\n 1.0 - exp(-uFogDensity * exp(-t * before) * (dist - start) * (1.0 - exp(-denom)) / denom);\n if (uUnderwaterFactor <= 0.0) return air;\n // Water is its own medium and keeps its own curve, so crossing the surface\n // changes nothing about how the water column reads.\n float wet = uUnderwaterFogDensity * dist;\n return mix(air, 1.0 - exp2(-wet * wet * 1.442695), uUnderwaterFactor);\n}\n\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\nvoid main() {\n /*\n * One soft, round falloff \u2014 no erosion, no core past white, because this material draws\n * neither a body being eaten by noise nor a light source. Squared rather than linear so the\n * edge feathers instead of ramping, which is what keeps a field of these reading as dust\n * rather than as a scatter of hard-edged discs.\n */\n float r = length(vUv);\n float body = 1.0 - smoothstep(0.4, 1.0, r);\n if (body <= 0.0) discard;\n float alpha = body * body * vAlpha;\n if (alpha <= 0.002) discard;\n\n vec3 color = vColor;\n if (uFogEnabled != 0) {\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n color = mix(color, mediumColor(), fog);\n }\n /*\n * A real alpha blend (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) rather than spark's energy-in-colour\n * trick: this point genuinely occludes what little is behind it instead of adding light to it.\n */\n outColor = vec4(applyOutputTransform(color), alpha);\n}\n";
82
+ export declare const PARTICLE_MOTE_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\nin vec3 vColor;\nin float vAlpha;\nin float vAge;\nin float vSeed;\nin vec3 vWorldPos;\nin vec3 vNormal;\n\nuniform vec3 uCameraPos;\n/**\n * Whether the fragment stage mixes toward the medium's own colour at all. 0 \u2014 what a batch\n * gets by leaving ParticleBatchOptions.fog unset \u2014 leaves vColor untouched by distance, which\n * is what a caller reaching for this material over 'spark'/'smoke' is usually asking for: an\n * unlit point drawn exactly as its own colour says. 1 mixes toward mediumColor() by\n * mediumFog(), the same medium every other pass in the frame recedes into.\n */\nuniform int uFogEnabled;\n\nuniform vec3 uFogColor;\n/** Extinction per metre **at the camera's own height**, not at the base. */\nuniform float uFogDensity;\n/** Reciprocal scale height of the haze, per metre. 0 is a uniform medium. */\nuniform float uFogHeightFalloff;\n/** The camera's height, so a fragment's own height is one subtraction away. */\nuniform float uFogEyeY;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFogDensity;\nuniform float uUnderwaterFactor;\n/** 0 integrates a medium (Beer-Lambert); 1 ramps linearly between near and far. */\nuniform int uFogMode;\n/** Linear mode only: where haze begins, and where it is total. */\nuniform float uFogNear;\nuniform float uFogFar;\n\n/** What distance converges toward: the day's haze, or the water column. */\nvec3 mediumColor() {\n return mix(uFogColor, uUnderwaterColor, uUnderwaterFactor);\n}\n\n/**\n * How much of a surface standing at height pointY, dist metres away, is lost to\n * the medium in between. 0 is perfectly clear, 1 is the medium's own colour.\n */\nfloat mediumFog(float dist, float pointY) {\n /*\n * A depth ramp rather than a medium, for worlds authored against one.\n *\n * Beer-Lambert is the honest model and stays the default: it integrates something\n * physically there, so extinction begins at the eye and never stops. A linear near/far\n * fog is not that \u2014 it is a *look*, with a hard start distance before which the air is\n * perfectly clear, and it is what the fixed-function pipeline offered and what several\n * renderers still expose. Approximating one with the other is not a matter of tuning:\n * matched at half occlusion, the exponential curve still lays haze over everything\n * inside near, where the ramp lays none, and near-field colour desaturates toward the\n * fog colour with nothing to show for it.\n *\n * Height falloff has no meaning here and is ignored: a ramp is defined on distance\n * alone.\n */\n if (uFogMode == 1) {\n float span = max(uFogFar - uFogNear, 1e-4);\n float ramp = clamp((dist - uFogNear) / span, 0.0, 1.0);\n if (uUnderwaterFactor <= 0.0) return ramp;\n float wetLinear = uUnderwaterFogDensity * dist;\n return mix(ramp, 1.0 - exp2(-wetLinear * wetLinear * 1.442695), uUnderwaterFactor);\n }\n\n // Where the medium begins along the ray: uFogNear, the linear mode's slot, which this mode\n // never read before Atmosphere.fogStart. The ray is integrated from there on, at the density\n // of that point; at a start of 0 every term is the one it always was. See render/fog.ts.\n float start = min(uFogNear, dist);\n float before = dist > 0.0 ? start / dist : 0.0;\n float t = (pointY - uFogEyeY) * uFogHeightFalloff;\n float rest = t * (1.0 - before);\n // (1 - e^-t)/t, which tends to 1 as the ray levels out. Guarded rather than\n // branched: at t = 0 the quotient is 0/0, and 1e-4 of a scale height is 8 mm.\n float denom = abs(rest) < 1e-4 ? 1e-4 : rest;\n float air =\n 1.0 - exp(-uFogDensity * exp(-t * before) * (dist - start) * (1.0 - exp(-denom)) / denom);\n if (uUnderwaterFactor <= 0.0) return air;\n // Water is its own medium and keeps its own curve, so crossing the surface\n // changes nothing about how the water column reads.\n float wet = uUnderwaterFogDensity * dist;\n return mix(air, 1.0 - exp2(-wet * wet * 1.442695), uUnderwaterFactor);\n}\n\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\nvoid main() {\n /*\n * One soft, round falloff \u2014 no erosion, no core past white, because this material draws\n * neither a body being eaten by noise nor a light source. Squared rather than linear so the\n * edge feathers instead of ramping, which is what keeps a field of these reading as dust\n * rather than as a scatter of hard-edged discs.\n */\n float r = length(vUv);\n float body = 1.0 - smoothstep(0.4, 1.0, r);\n if (body <= 0.0) discard;\n float alpha = body * body * vAlpha;\n if (alpha <= 0.002) discard;\n\n vec3 color = vColor;\n if (uFogEnabled != 0) {\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n color = mix(color, mediumColor(), fog);\n }\n /*\n * A real alpha blend (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) rather than spark's energy-in-colour\n * trick: this point genuinely occludes what little is behind it instead of adding light to it.\n */\n outColor = vec4(applyOutputTransform(color), alpha);\n}\n";
83
83
  /**
84
84
  * A textured sprite: an image a consumer supplies, a cell of it at a time, at the colour and opacity
85
85
  * each particle carries — what another engine's sprite renderer draws, and the material the three
@@ -64,6 +64,12 @@ uniform float uReach;
64
64
  */
65
65
  uniform int uOutputTransform;
66
66
  uniform float uOutputExposure;
67
+ /*
68
+ * Whether the scene arrives graded: 1 where the forward passes graded what they drew because this
69
+ * resolve does not (a frame without \`hdrScene\`), so a factor multiplied into it — the occlusion —
70
+ * is multiplied as an encoded pixel takes it. 0 where the scene is linear light and this grades it.
71
+ */
72
+ uniform int uSceneGraded;
67
73
  /**
68
74
  * The \`filmic\` curve's eight constants, solved once a curve by \`resolveFilmicCurve\`: (slope, black
69
75
  * clip, white clip, toe scale) and (shoulder scale, toe match, straight match, shoulder match).
@@ -404,6 +410,8 @@ void main() {
404
410
  */
405
411
  float share = clamp(sampled.a, 0.0, 1.0);
406
412
  float ao = mix(1.0, textureLod(uAo, vUv + uAoOffset, 0.0).r, uAoStrength * share);
413
+ /* Darkening a graded pixel's light by a factor is darkening the pixel by that factor encoded. */
414
+ if (uSceneGraded != 0) ao = linearToSrgb(vec3(ao)).x;
407
415
 
408
416
  /*
409
417
  * Distance from the centre, corrected so the falloff is a circle on screen rather than
@@ -11,4 +11,4 @@ export declare const SCATTER_VERT = "#version 300 es\nlayout(location = 0) in ve
11
11
  * program is not obliged to read every attribute a VAO offers.
12
12
  */
13
13
  export declare const SCATTER_DEPTH_VERT = "#version 300 es\nlayout(location = 0) in vec3 aPosition;\nlayout(location = 3) in vec3 aInstancePos;\nlayout(location = 4) in vec2 aScaleYaw;\nlayout(location = 6) in vec3 aWindResponse;\n\nuniform mat4 uLightViewProj;\n\nuniform vec2 uWindDirection;\nuniform float uWindSpeed;\nuniform float uWindGust;\nuniform float uWindTime;\n/* Spatial frequency of the travelling gust, per metre. */\nuniform vec2 uWindSpatialPhase;\n/*\n * Where something heavy has just been: xyz world position, w how pressed, 0 unused.\n *\n * Uniforms rather than a texture or a re-upload, because this is the hottest shader\n * in the game and the effect is a metre wide. Eight of them is about two seconds of\n * trail at running pace. See TrampleField.\n */\nuniform vec4 uTrample[8];\n/* How far a press reaches, metres, and how much of a plant it takes away. */\nuniform float uTrampleRadius;\nuniform float uTrampleDepth;\n\n/* Yaw as (sin, cos). Computed once per vertex and shared by position and normal. */\nvec2 scatterYaw(float yaw) {\n return vec2(sin(yaw), cos(yaw));\n}\n\n/* The instance's local offset from its root, after scale, yaw, wind and trampling. */\nvec3 scatterOffset(\n vec3 position,\n vec3 instancePos,\n vec2 scaleYaw,\n vec3 windResponse,\n vec2 yawTrig\n) {\n float s = yawTrig.x;\n float c = yawTrig.y;\n\n vec3 local = position * scaleYaw.x;\n vec3 rotated = vec3(local.x * c + local.z * s, local.y, -local.x * s + local.z * c);\n\n /*\n * Wind phase comes from world position, so neighbouring plants are never in\n * lockstep and a gust visibly travels across a field instead of the whole\n * field pulsing at once. Direction and strength come from the shared wind \u2014\n * this adds spatial variation, never a second wind.\n *\n * Amplitude rises with the square of height, so the base stays planted and\n * only the tip travels. A linear falloff slides the whole plant sideways,\n * which reads as the ground moving.\n */\n float phase = dot(instancePos.xz, uWindSpatialPhase) + uWindTime + windResponse.z;\n float rooted = clamp(rotated.y * windResponse.x, 0.0, 1.0);\n float bend = (uWindSpeed + uWindGust * windResponse.y) * sin(phase) * rooted * rooted;\n rotated.xz += uWindDirection * bend;\n\n /*\n * Trampling: pressed toward the ground and pushed away from whatever pressed it.\n *\n * The strongest press wins rather than the sum. Two overlapping presses adding up\n * would drive a blade through the deck, and a character crossing their own trail is\n * exactly when that would happen.\n *\n * Direction comes from the *press*, so a plant leans away from where the foot was\n * instead of all of them lying the same way, which is what makes it read as\n * something having gone through rather than as a second wind.\n */\n float pressed = 0.0;\n vec2 away = vec2(0.0);\n for (int i = 0; i < 8; i++) {\n vec4 p = uTrample[i];\n if (p.w <= 0.0) continue;\n vec3 offset = instancePos - p.xyz;\n float dist = length(offset);\n if (dist >= uTrampleRadius) continue;\n float fade = 1.0 - dist / uTrampleRadius;\n float strength = p.w * fade * fade;\n if (strength <= pressed) continue;\n pressed = strength;\n // Degenerate at the exact centre, where any direction is as good as another.\n away = dist > 1e-4 ? offset.xz / dist : vec2(0.0);\n }\n if (pressed > 0.0) {\n float take = pressed * uTrampleDepth;\n // Height goes first \u2014 a flattened plant is shorter, and the fold outward is\n // what makes the loss of height read as bending rather than as sinking.\n rotated.y *= 1.0 - take;\n rotated.xz += away * take * rotated.y * 1.4;\n }\n\n return rotated;\n}\n\nout vec4 vLightPosition;\n\nvoid main() {\n vec3 rotated = scatterOffset(\n aPosition,\n aInstancePos,\n aScaleYaw,\n aWindResponse,\n scatterYaw(aScaleYaw.y)\n );\n vLightPosition = uLightViewProj * vec4(aInstancePos + rotated, 1.0);\n gl_Position = vLightPosition;\n}\n";
14
- export declare const SCATTER_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vColor;\nin vec3 vNormal;\nin vec3 vWorldPos;\n\nuniform vec3 uDirectionalDir;\nuniform vec3 uDirectionalColor;\nuniform vec3 uAmbient;\nuniform vec3 uCameraPos;\n\nuniform vec3 uFogColor;\n/** Extinction per metre **at the camera's own height**, not at the base. */\nuniform float uFogDensity;\n/** Reciprocal scale height of the haze, per metre. 0 is a uniform medium. */\nuniform float uFogHeightFalloff;\n/** The camera's height, so a fragment's own height is one subtraction away. */\nuniform float uFogEyeY;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFogDensity;\nuniform float uUnderwaterFactor;\n/** 0 integrates a medium (Beer-Lambert); 1 ramps linearly between near and far. */\nuniform int uFogMode;\n/** Linear mode only: where haze begins, and where it is total. */\nuniform float uFogNear;\nuniform float uFogFar;\n\n/** What distance converges toward: the day's haze, or the water column. */\nvec3 mediumColor() {\n return mix(uFogColor, uUnderwaterColor, uUnderwaterFactor);\n}\n\n/**\n * How much of a surface standing at height pointY, dist metres away, is lost to\n * the medium in between. 0 is perfectly clear, 1 is the medium's own colour.\n */\nfloat mediumFog(float dist, float pointY) {\n /*\n * A depth ramp rather than a medium, for worlds authored against one.\n *\n * Beer-Lambert is the honest model and stays the default: it integrates something\n * physically there, so extinction begins at the eye and never stops. A linear near/far\n * fog is not that \u2014 it is a *look*, with a hard start distance before which the air is\n * perfectly clear, and it is what the fixed-function pipeline offered and what several\n * renderers still expose. Approximating one with the other is not a matter of tuning:\n * matched at half occlusion, the exponential curve still lays haze over everything\n * inside near, where the ramp lays none, and near-field colour desaturates toward the\n * fog colour with nothing to show for it.\n *\n * Height falloff has no meaning here and is ignored: a ramp is defined on distance\n * alone.\n */\n if (uFogMode == 1) {\n float span = max(uFogFar - uFogNear, 1e-4);\n float ramp = clamp((dist - uFogNear) / span, 0.0, 1.0);\n if (uUnderwaterFactor <= 0.0) return ramp;\n float wetLinear = uUnderwaterFogDensity * dist;\n return mix(ramp, 1.0 - exp2(-wetLinear * wetLinear * 1.442695), uUnderwaterFactor);\n }\n\n // Where the medium begins along the ray: uFogNear, the linear mode's slot, which this mode\n // never read before Atmosphere.fogStart. The ray is integrated from there on, at the density\n // of that point; at a start of 0 every term is the one it always was. See render/fog.ts.\n float start = min(uFogNear, dist);\n float before = dist > 0.0 ? start / dist : 0.0;\n float t = (pointY - uFogEyeY) * uFogHeightFalloff;\n float rest = t * (1.0 - before);\n // (1 - e^-t)/t, which tends to 1 as the ray levels out. Guarded rather than\n // branched: at t = 0 the quotient is 0/0, and 1e-4 of a scale height is 8 mm.\n float denom = abs(rest) < 1e-4 ? 1e-4 : rest;\n float air =\n 1.0 - exp(-uFogDensity * exp(-t * before) * (dist - start) * (1.0 - exp(-denom)) / denom);\n if (uUnderwaterFactor <= 0.0) return air;\n // Water is its own medium and keeps its own curve, so crossing the surface\n // changes nothing about how the water column reads.\n float wet = uUnderwaterFogDensity * dist;\n return mix(air, 1.0 - exp2(-wet * wet * 1.442695), uUnderwaterFactor);\n}\n\n\nout vec4 outColor;\n\nvoid main() {\n vec3 n = normalize(vNormal);\n /*\n * Two-sided. A blade of grass is a flat sliver seen from both faces, and\n * lighting its back face as though it faced away leaves half of every field\n * black \u2014 the cheapest and most obvious foliage tell there is.\n */\n float ndl = abs(dot(n, uDirectionalDir));\n vec3 lit = vColor * (uAmbient + uDirectionalColor * ndl);\n\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n\n outColor = vec4(mix(lit, mediumColor(), fog), 1.0);\n}\n";
14
+ export declare const SCATTER_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vColor;\nin vec3 vNormal;\nin vec3 vWorldPos;\n\nuniform vec3 uDirectionalDir;\nuniform vec3 uDirectionalColor;\nuniform vec3 uAmbient;\nuniform vec3 uCameraPos;\n\nuniform vec3 uFogColor;\n/** Extinction per metre **at the camera's own height**, not at the base. */\nuniform float uFogDensity;\n/** Reciprocal scale height of the haze, per metre. 0 is a uniform medium. */\nuniform float uFogHeightFalloff;\n/** The camera's height, so a fragment's own height is one subtraction away. */\nuniform float uFogEyeY;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFogDensity;\nuniform float uUnderwaterFactor;\n/** 0 integrates a medium (Beer-Lambert); 1 ramps linearly between near and far. */\nuniform int uFogMode;\n/** Linear mode only: where haze begins, and where it is total. */\nuniform float uFogNear;\nuniform float uFogFar;\n\n/** What distance converges toward: the day's haze, or the water column. */\nvec3 mediumColor() {\n return mix(uFogColor, uUnderwaterColor, uUnderwaterFactor);\n}\n\n/**\n * How much of a surface standing at height pointY, dist metres away, is lost to\n * the medium in between. 0 is perfectly clear, 1 is the medium's own colour.\n */\nfloat mediumFog(float dist, float pointY) {\n /*\n * A depth ramp rather than a medium, for worlds authored against one.\n *\n * Beer-Lambert is the honest model and stays the default: it integrates something\n * physically there, so extinction begins at the eye and never stops. A linear near/far\n * fog is not that \u2014 it is a *look*, with a hard start distance before which the air is\n * perfectly clear, and it is what the fixed-function pipeline offered and what several\n * renderers still expose. Approximating one with the other is not a matter of tuning:\n * matched at half occlusion, the exponential curve still lays haze over everything\n * inside near, where the ramp lays none, and near-field colour desaturates toward the\n * fog colour with nothing to show for it.\n *\n * Height falloff has no meaning here and is ignored: a ramp is defined on distance\n * alone.\n */\n if (uFogMode == 1) {\n float span = max(uFogFar - uFogNear, 1e-4);\n float ramp = clamp((dist - uFogNear) / span, 0.0, 1.0);\n if (uUnderwaterFactor <= 0.0) return ramp;\n float wetLinear = uUnderwaterFogDensity * dist;\n return mix(ramp, 1.0 - exp2(-wetLinear * wetLinear * 1.442695), uUnderwaterFactor);\n }\n\n // Where the medium begins along the ray: uFogNear, the linear mode's slot, which this mode\n // never read before Atmosphere.fogStart. The ray is integrated from there on, at the density\n // of that point; at a start of 0 every term is the one it always was. See render/fog.ts.\n float start = min(uFogNear, dist);\n float before = dist > 0.0 ? start / dist : 0.0;\n float t = (pointY - uFogEyeY) * uFogHeightFalloff;\n float rest = t * (1.0 - before);\n // (1 - e^-t)/t, which tends to 1 as the ray levels out. Guarded rather than\n // branched: at t = 0 the quotient is 0/0, and 1e-4 of a scale height is 8 mm.\n float denom = abs(rest) < 1e-4 ? 1e-4 : rest;\n float air =\n 1.0 - exp(-uFogDensity * exp(-t * before) * (dist - start) * (1.0 - exp(-denom)) / denom);\n if (uUnderwaterFactor <= 0.0) return air;\n // Water is its own medium and keeps its own curve, so crossing the surface\n // changes nothing about how the water column reads.\n float wet = uUnderwaterFogDensity * dist;\n return mix(air, 1.0 - exp2(-wet * wet * 1.442695), uUnderwaterFactor);\n}\n\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\nvoid main() {\n vec3 n = normalize(vNormal);\n /*\n * Two-sided. A blade of grass is a flat sliver seen from both faces, and\n * lighting its back face as though it faced away leaves half of every field\n * black \u2014 the cheapest and most obvious foliage tell there is.\n */\n float ndl = abs(dot(n, uDirectionalDir));\n vec3 lit = vColor * (uAmbient + uDirectionalColor * ndl);\n\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n\n /* Graded where nothing after this will grade it, as every pass is: the same light as the ground. */\n outColor = vec4(applyOutputTransform(mix(lit, mediumColor(), fog)), 1.0);\n}\n";
@@ -1,4 +1,5 @@
1
1
  import { FOG_GLSL } from './fog.js';
2
+ import { OUTPUT_TRANSFORM_GLSL } from './outputTransform.js';
2
3
  /**
3
4
  * Instanced scatter: grass, flowers, foliage — anything there are thousands of.
4
5
  *
@@ -188,6 +189,7 @@ uniform vec3 uCameraPos;
188
189
  ${FOG_GLSL}
189
190
 
190
191
  out vec4 outColor;
192
+ ${OUTPUT_TRANSFORM_GLSL}
191
193
 
192
194
  void main() {
193
195
  vec3 n = normalize(vNormal);
@@ -201,6 +203,7 @@ void main() {
201
203
 
202
204
  float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);
203
205
 
204
- outColor = vec4(mix(lit, mediumColor(), fog), 1.0);
206
+ /* Graded where nothing after this will grade it, as every pass is: the same light as the ground. */
207
+ outColor = vec4(applyOutputTransform(mix(lit, mediumColor(), fog)), 1.0);
205
208
  }
206
209
  `;
@@ -6,4 +6,4 @@
6
6
  * the alpha test is not uniform control flow.
7
7
  */
8
8
  export declare const SDF_TEXT_VERT = "#version 300 es\nprecision highp float;\n\nlayout(location = 0) in vec3 aPosition;\nlayout(location = 1) in vec2 aUv;\n\nuniform mat4 uViewProj;\nuniform mat4 uModel;\n\nout vec2 vUv;\n\nvoid main() {\n vUv = aUv;\n gl_Position = uViewProj * uModel * vec4(aPosition, 1.0);\n}\n";
9
- export declare const SDF_TEXT_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\n\n/* An array of one: every surface texture is an array, and an atlas is read at layer 0. */\nuniform mediump sampler2DArray uAtlas;\nuniform vec3 uColor;\nuniform float uOpacity;\n/* Texels the field spans, from the atlas metrics. Scales the antialiasing band. */\nuniform float uDistanceRange;\nuniform vec2 uAtlasSize;\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\n/*\n * The middle of the three channels.\n *\n * A single-channel field rounds every corner off at small sizes, because one distance\n * cannot describe two edges meeting. Three channels carry the corner and the median\n * recovers it.\n */\nfloat median(vec3 rgb) {\n return max(min(rgb.r, rgb.g), min(max(rgb.r, rgb.g), rgb.b));\n}\n\nvoid main() {\n vec3 sampled = textureLod(uAtlas, vec3(vUv, 0.0), 0.0).rgb;\n float distance = median(sampled) - 0.5;\n\n /*\n * The band is one pixel wide *in screen space*, which is what lets one atlas serve every\n * size. A constant band is tuned for one size and is wrong at all the others.\n */\n vec2 texelsPerPixel = fwidth(vUv) * uAtlasSize;\n float pixelRange = max(0.5 * (texelsPerPixel.x + texelsPerPixel.y), 0.0001);\n float coverage = clamp(distance * uDistanceRange / pixelRange + 0.5, 0.0, 1.0);\n\n outColor = vec4(applyOutputTransform(uColor), coverage * uOpacity);\n}\n";
9
+ export declare const SDF_TEXT_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\n\n/* An array of one: every surface texture is an array, and an atlas is read at layer 0. */\nuniform mediump sampler2DArray uAtlas;\nuniform vec3 uColor;\nuniform float uOpacity;\n/* Texels the field spans, from the atlas metrics. Scales the antialiasing band. */\nuniform float uDistanceRange;\nuniform vec2 uAtlasSize;\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\n/*\n * The middle of the three channels.\n *\n * A single-channel field rounds every corner off at small sizes, because one distance\n * cannot describe two edges meeting. Three channels carry the corner and the median\n * recovers it.\n */\nfloat median(vec3 rgb) {\n return max(min(rgb.r, rgb.g), min(max(rgb.r, rgb.g), rgb.b));\n}\n\nvoid main() {\n vec3 sampled = textureLod(uAtlas, vec3(vUv, 0.0), 0.0).rgb;\n float distance = median(sampled) - 0.5;\n\n /*\n * The band is one pixel wide *in screen space*, which is what lets one atlas serve every\n * size. A constant band is tuned for one size and is wrong at all the others.\n */\n vec2 texelsPerPixel = fwidth(vUv) * uAtlasSize;\n float pixelRange = max(0.5 * (texelsPerPixel.x + texelsPerPixel.y), 0.0001);\n float coverage = clamp(distance * uDistanceRange / pixelRange + 0.5, 0.0, 1.0);\n\n outColor = vec4(applyOutputTransform(uColor), coverage * uOpacity);\n}\n";
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { glslIsFarDepth, glslSceneDepthToNdc } from '../depthConvention.js';
21
21
  import { SKIN_BLUR_EDGES, SKIN_BLUR_TAPS, SKIN_PROFILES } from '../skinBlur.js';
22
+ import { OUTPUT_TRANSFORM_GLSL } from './outputTransform.js';
22
23
  const list = (values) => values.map((v) => v.toFixed(2)).join(', ');
23
24
  export const SKIN_BLUR_FRAG = `#version 300 es
24
25
  precision highp float;
@@ -45,6 +46,7 @@ uniform sampler2D uAlbedo;
45
46
  uniform float uApplyAlbedo;
46
47
 
47
48
  out vec4 fragColor;
49
+ ${OUTPUT_TRANSFORM_GLSL}
48
50
 
49
51
  const float TAPS[${SKIN_BLUR_TAPS.length}] = float[${SKIN_BLUR_TAPS.length}](${list(SKIN_BLUR_TAPS)});
50
52
  const float EDGES[${SKIN_BLUR_EDGES.length}] = float[${SKIN_BLUR_EDGES.length}](${list(SKIN_BLUR_EDGES)});
@@ -67,7 +69,9 @@ vec3 burleyMass(float x, vec3 d) {
67
69
  vec3 withAlbedo(vec3 light) {
68
70
  if (uApplyAlbedo < 0.5) return light;
69
71
  vec4 albedo = textureLod(uAlbedo, vUv, 0.0);
70
- return light * albedo.rgb / max(albedo.a, 1e-4);
72
+ /* Graded on the axis that adds into the frame, as every pass grades what it adds where nothing
73
+ after it will: 0 where the resolve grades the frame, and the first axis keeps linear light. */
74
+ return applyOutputTransform(light * albedo.rgb / max(albedo.a, 1e-4));
71
75
  }
72
76
 
73
77
  void main() {
@@ -11,4 +11,4 @@ export declare const SKY_VERT: string;
11
11
  * plane is reversed by construction.
12
12
  */
13
13
  export declare function skyVertFor(reversed: boolean): string;
14
- export declare const SKY_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vNdc;\n\nuniform mat4 uInvViewProj;\nuniform vec3 uCameraPos;\nuniform vec3 uTopColor;\nuniform vec3 uHorizonColor;\nuniform vec3 uDeepColor;\nuniform vec3 uSunDir;\nuniform vec3 uSunColor;\nuniform float uSunDiscExponent;\nuniform vec3 uMoonDir;\nuniform vec3 uMoonColor;\nuniform float uMoonAngularRadius;\nuniform float uMoonPhase;\nuniform float uNightFactor;\nuniform vec2 uCloudOffset;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFactor;\n\nout vec4 outColor;\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat valueNoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n float a = hash21(i);\n float b = hash21(i + vec2(1.0, 0.0));\n float c = hash21(i + vec2(0.0, 1.0));\n float d = hash21(i + vec2(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\n/** Three octaves is the ceiling \u2014 drop to two before cutting anything else. */\nfloat fbm(vec2 p) {\n float v = valueNoise(p) * 0.5;\n v += valueNoise(p * 2.03) * 0.3;\n v += valueNoise(p * 4.01) * 0.2;\n return v;\n}\n\nvoid main() {\n vec4 world = uInvViewProj * vec4(vNdc, 1.0, 1.0);\n vec3 dir = normalize(world.xyz / world.w - uCameraPos);\n\n float t = dir.y;\n vec3 col = t >= 0.0\n ? mix(uHorizonColor, uTopColor, pow(min(t, 1.0), 0.55))\n : mix(uHorizonColor, uDeepColor, min(-t * 1.8, 1.0));\n\n // Celestial bodies belong to the sky, not to the void underneath. Seeing the\n // sun below the horizon made the world read as unbounded in every direction;\n // fading them out at the horizon leaves the deep reading as depth instead.\n float aboveHorizon = smoothstep(-0.04, 0.06, dir.y);\n\n /*\n * Sunset.\n *\n * Low sunlight crosses far more atmosphere than high sunlight, and that path\n * scatters the blue out of it \u2014 which is why the sun reddens as it drops and\n * why the glow around it spreads into a band rather than staying a disc. Both\n * effects peak right at the horizon and are gone within a few degrees of it,\n * so they are driven by the sun's own elevation rather than by the clock.\n *\n * TWO EDGES, BECAUSE A SUNSET ENDS. This shipped with only the upper one, so every\n * sun below the horizon read as maximum sunset and stayed there all the way to\n * straight down \u2014 and a sun direction of (0, -1, 0) is what a night world writes to\n * mean \"there is no sun in this sky\". A consumer measured the result on a black\n * sun: 87% more red in the sky than the same frame with the sun pointing up, a warm\n * band over a third of the picture in an arctic snowfield, and the world's own\n * blue-grey horizon arriving with its channel order reversed. Four night worlds were\n * not started over it, and this repository's own storm sea shipped it. The lower\n * edge is where astronomical twilight ends, near -0.31, so the term now fades out\n * over the same range the sky it models does.\n *\n * What it gives up: the glow is a function of the sun's elevation and nothing else,\n * so a caller cannot place one where its own sun is not. That is deliberate \u2014 a knob\n * here would let a world disagree with itself \u2014 but it is also what makes this wrong\n * for a body that is not our sun in our atmosphere. A world lit by something that\n * scatters differently needs its own term, not a wider edge here.\n *\n * Written ascending-and-inverted rather than as a descending smoothstep, whose\n * result GLSL ES leaves undefined when edge0 >= edge1. Every implementation seen so\n * far computes the ramp anybody would expect, which is why the descending form\n * survived this long, but the spec does not owe us that.\n */\n float sunset = (1.0 - smoothstep(-0.03, 0.30, uSunDir.y)) *\n smoothstep(-0.35, -0.05, uSunDir.y);\n vec3 ember = vec3(1.0, 0.36, 0.13);\n\n float s = max(dot(dir, uSunDir), 0.0);\n // The disc itself reddens; the last sliver above the horizon is nearly blood\n // orange, which is the moment the whole effect is remembered for.\n vec3 discColor = mix(uSunColor, uSunColor * ember, sunset * 0.85);\n col += discColor * (\n pow(s, uSunDiscExponent) * 1.2 +\n pow(s, max(uSunDiscExponent / 75.0, 1.0)) * 0.12\n ) * aboveHorizon;\n\n /*\n * The band. Brightest toward the sun's bearing and hugging the horizon,\n * falling off both with angle around the compass and with height \u2014 a sunset\n * is a direction, not a uniform warm wash, and washing the whole dome is what\n * makes a fake one look like a colour filter.\n */\n vec2 sunBearing = normalize(uSunDir.xz + vec2(1e-5));\n vec2 viewBearing = normalize(dir.xz + vec2(1e-5));\n float toward = max(dot(viewBearing, sunBearing), 0.0);\n /*\n * Measured from the horizon, and falling three times faster below it than above.\n *\n * This was abs(dir.y), which is symmetric, so the band was mirrored twenty-seven\n * degrees down into the deep \u2014 visible in any world whose ground does not cover\n * the lower half of the frame, and captured in the -Y face of every environment\n * probe, where it tinted reflections warm. Nothing in the sky mirrors a sunset\n * below the horizon; what is down there is ground, and the ground is drawn by\n * somebody else.\n *\n * Not gated by aboveHorizon like the sun disc and the moon are: that factor is\n * only 0.4 at a dir.y of zero, which is exactly where this term is brightest, so\n * multiplying by it would cut the band's core to a third and change every sunset\n * to fix a night. Falling faster below instead leaves everything at or above the\n * horizon bit-for-bit as it was, and the horizon line itself unbroken. What it\n * gives up: a camera under the world, or above a planet seen from outside its\n * atmosphere, sees the glow end sooner than the geometry does.\n */\n float fromHorizon = dir.y >= 0.0 ? dir.y : -dir.y * 3.0;\n float band = pow(1.0 - min(fromHorizon * 2.2, 1.0), 2.6);\n col += ember * sunset * band * (0.18 + 0.75 * pow(toward, 2.2));\n\n /*\n * Belt of Venus: the sky opposite a setting sun takes a cool pink-violet from\n * light already reddened on its way past. Subtle, but its absence is why an\n * artificial sunset feels like it is only happening on one side.\n */\n float away = max(-dot(viewBearing, sunBearing), 0.0);\n col += vec3(0.42, 0.28, 0.45) * sunset * band * away * 0.22;\n\n /*\n * Moon: a shaded procedural sphere-disc, not one dot subtracted from another.\n * The local sphere normal gives a curved terminator and limb; lunar phase\n * supplies the light direction around that sphere. Low-frequency noise adds\n * maria without a texture file, while a faint earthshine keeps the dark limb\n * just legible instead of cutting a mathematically black hole in the sky.\n */\n vec3 moonReference = abs(uMoonDir.y) < 0.98\n ? vec3(0.0, 1.0, 0.0)\n : vec3(1.0, 0.0, 0.0);\n vec3 moonRight = normalize(cross(moonReference, uMoonDir));\n vec3 moonUp = cross(uMoonDir, moonRight);\n float moonRadius = clamp(uMoonAngularRadius, 0.001, 0.5);\n vec2 moonUv = vec2(dot(dir, moonRight), dot(dir, moonUp)) / moonRadius;\n float moonRadiusSq = dot(moonUv, moonUv);\n float moonFacing = step(0.0, dot(dir, uMoonDir));\n float moonDisc = (1.0 - smoothstep(0.90, 1.0, moonRadiusSq)) * moonFacing;\n if (moonDisc > 0.0) {\n float sphereZ = sqrt(max(1.0 - moonRadiusSq, 0.0));\n vec3 surfaceNormal = normalize(vec3(moonUv, sphereZ));\n float phaseAngle = uMoonPhase * 6.28318530718;\n vec3 phaseLight = vec3(sin(phaseAngle), 0.0, -cos(phaseAngle));\n float phaseShade = max(dot(surfaceNormal, phaseLight), 0.0);\n float maria = valueNoise(moonUv * 3.2 + vec2(4.7, 9.1)) * 0.68 +\n valueNoise(moonUv * 9.3 - vec2(7.4, 2.6)) * 0.32;\n float albedo = mix(0.56, 1.02, maria);\n float limb = mix(0.72, 1.0, sphereZ);\n float earthshine = 0.035;\n float lunarLight = earthshine + (1.0 - earthshine) * phaseShade;\n col += uMoonColor * albedo * limb * lunarLight * moonDisc *\n 1.35 * uNightFactor * aboveHorizon;\n }\n float phaseIllumination = 0.5 - cos(uMoonPhase * 6.28318530718) * 0.5;\n float moonHalo = (1.0 - smoothstep(1.0, 3.0, length(moonUv))) * moonFacing;\n col += uMoonColor * moonHalo * 0.08 * phaseIllumination *\n uNightFactor * aboveHorizon;\n\n if (dir.y > 0.0) {\n // Stars. Stereographic projection: unlike dir.xz/dir.y it stays finite and\n // near-uniform all the way to the horizon, so stars don't smear into grain.\n // Each cell holds one jittered *point* \u2014 lighting the whole cell would draw\n // square blocks, which is what \"random pixels\" looks like.\n if (uNightFactor > 0.0) {\n vec2 p = dir.xz / (1.0 + dir.y) * 26.0;\n vec2 cell = floor(p);\n float h = hash21(cell);\n if (h > 0.93) {\n vec2 jitter = vec2(hash21(cell + 17.3), hash21(cell + 41.7));\n float d = length(fract(p) - jitter);\n float star = smoothstep(0.10, 0.0, d) * (0.4 + h * 0.6);\n // Fade out near the horizon, where haze would swallow them anyway.\n col += vec3(0.86, 0.91, 1.0) * star * uNightFactor * smoothstep(0.03, 0.3, dir.y);\n }\n }\n\n // Clouds: banded value noise on a layer above the camera, carried by the\n // world's wind \u2014 the same signal the smoke and the grass answer to, so the\n // largest moving thing in frame agrees with the smallest.\n // The floor on dir.y bounds the perspective stretch near the horizon.\n if (dir.y > 0.02) {\n vec2 uv = dir.xz / max(dir.y, 0.16) * 0.09 + uCloudOffset * 0.012;\n float band = smoothstep(0.46, 0.60, fbm(uv));\n float fade = smoothstep(0.02, 0.30, dir.y);\n vec3 cloudCol = mix(uHorizonColor, vec3(1.0), 0.45) * mix(1.0, 0.32, uNightFactor);\n col = mix(col, cloudCol, band * fade * 0.8);\n }\n }\n\n // There is no outdoor sky inside a water column. Retain a little vertical\n // gradient so the surface remains legible above and depth reads darker\n // below, while celestial bodies and clouds disappear with the same smooth\n // surface transition used by the geometry passes.\n float waterLight = mix(0.62, 1.08, clamp(dir.y * 0.5 + 0.5, 0.0, 1.0));\n col = mix(col, uUnderwaterColor * waterLight, uUnderwaterFactor);\n\n outColor = vec4(col, 1.0);\n}\n";
14
+ export declare const SKY_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vNdc;\n\nuniform mat4 uInvViewProj;\nuniform vec3 uCameraPos;\nuniform vec3 uTopColor;\nuniform vec3 uHorizonColor;\nuniform vec3 uDeepColor;\nuniform vec3 uSunDir;\nuniform vec3 uSunColor;\nuniform float uSunDiscExponent;\nuniform vec3 uMoonDir;\nuniform vec3 uMoonColor;\nuniform float uMoonAngularRadius;\nuniform float uMoonPhase;\nuniform float uNightFactor;\nuniform vec2 uCloudOffset;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFactor;\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat valueNoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n float a = hash21(i);\n float b = hash21(i + vec2(1.0, 0.0));\n float c = hash21(i + vec2(0.0, 1.0));\n float d = hash21(i + vec2(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\n/** Three octaves is the ceiling \u2014 drop to two before cutting anything else. */\nfloat fbm(vec2 p) {\n float v = valueNoise(p) * 0.5;\n v += valueNoise(p * 2.03) * 0.3;\n v += valueNoise(p * 4.01) * 0.2;\n return v;\n}\n\nvoid main() {\n vec4 world = uInvViewProj * vec4(vNdc, 1.0, 1.0);\n vec3 dir = normalize(world.xyz / world.w - uCameraPos);\n\n float t = dir.y;\n vec3 col = t >= 0.0\n ? mix(uHorizonColor, uTopColor, pow(min(t, 1.0), 0.55))\n : mix(uHorizonColor, uDeepColor, min(-t * 1.8, 1.0));\n\n // Celestial bodies belong to the sky, not to the void underneath. Seeing the\n // sun below the horizon made the world read as unbounded in every direction;\n // fading them out at the horizon leaves the deep reading as depth instead.\n float aboveHorizon = smoothstep(-0.04, 0.06, dir.y);\n\n /*\n * Sunset.\n *\n * Low sunlight crosses far more atmosphere than high sunlight, and that path\n * scatters the blue out of it \u2014 which is why the sun reddens as it drops and\n * why the glow around it spreads into a band rather than staying a disc. Both\n * effects peak right at the horizon and are gone within a few degrees of it,\n * so they are driven by the sun's own elevation rather than by the clock.\n *\n * TWO EDGES, BECAUSE A SUNSET ENDS. This shipped with only the upper one, so every\n * sun below the horizon read as maximum sunset and stayed there all the way to\n * straight down \u2014 and a sun direction of (0, -1, 0) is what a night world writes to\n * mean \"there is no sun in this sky\". A consumer measured the result on a black\n * sun: 87% more red in the sky than the same frame with the sun pointing up, a warm\n * band over a third of the picture in an arctic snowfield, and the world's own\n * blue-grey horizon arriving with its channel order reversed. Four night worlds were\n * not started over it, and this repository's own storm sea shipped it. The lower\n * edge is where astronomical twilight ends, near -0.31, so the term now fades out\n * over the same range the sky it models does.\n *\n * What it gives up: the glow is a function of the sun's elevation and nothing else,\n * so a caller cannot place one where its own sun is not. That is deliberate \u2014 a knob\n * here would let a world disagree with itself \u2014 but it is also what makes this wrong\n * for a body that is not our sun in our atmosphere. A world lit by something that\n * scatters differently needs its own term, not a wider edge here.\n *\n * Written ascending-and-inverted rather than as a descending smoothstep, whose\n * result GLSL ES leaves undefined when edge0 >= edge1. Every implementation seen so\n * far computes the ramp anybody would expect, which is why the descending form\n * survived this long, but the spec does not owe us that.\n */\n float sunset = (1.0 - smoothstep(-0.03, 0.30, uSunDir.y)) *\n smoothstep(-0.35, -0.05, uSunDir.y);\n vec3 ember = vec3(1.0, 0.36, 0.13);\n\n float s = max(dot(dir, uSunDir), 0.0);\n // The disc itself reddens; the last sliver above the horizon is nearly blood\n // orange, which is the moment the whole effect is remembered for.\n vec3 discColor = mix(uSunColor, uSunColor * ember, sunset * 0.85);\n col += discColor * (\n pow(s, uSunDiscExponent) * 1.2 +\n pow(s, max(uSunDiscExponent / 75.0, 1.0)) * 0.12\n ) * aboveHorizon;\n\n /*\n * The band. Brightest toward the sun's bearing and hugging the horizon,\n * falling off both with angle around the compass and with height \u2014 a sunset\n * is a direction, not a uniform warm wash, and washing the whole dome is what\n * makes a fake one look like a colour filter.\n */\n vec2 sunBearing = normalize(uSunDir.xz + vec2(1e-5));\n vec2 viewBearing = normalize(dir.xz + vec2(1e-5));\n float toward = max(dot(viewBearing, sunBearing), 0.0);\n /*\n * Measured from the horizon, and falling three times faster below it than above.\n *\n * This was abs(dir.y), which is symmetric, so the band was mirrored twenty-seven\n * degrees down into the deep \u2014 visible in any world whose ground does not cover\n * the lower half of the frame, and captured in the -Y face of every environment\n * probe, where it tinted reflections warm. Nothing in the sky mirrors a sunset\n * below the horizon; what is down there is ground, and the ground is drawn by\n * somebody else.\n *\n * Not gated by aboveHorizon like the sun disc and the moon are: that factor is\n * only 0.4 at a dir.y of zero, which is exactly where this term is brightest, so\n * multiplying by it would cut the band's core to a third and change every sunset\n * to fix a night. Falling faster below instead leaves everything at or above the\n * horizon bit-for-bit as it was, and the horizon line itself unbroken. What it\n * gives up: a camera under the world, or above a planet seen from outside its\n * atmosphere, sees the glow end sooner than the geometry does.\n */\n float fromHorizon = dir.y >= 0.0 ? dir.y : -dir.y * 3.0;\n float band = pow(1.0 - min(fromHorizon * 2.2, 1.0), 2.6);\n col += ember * sunset * band * (0.18 + 0.75 * pow(toward, 2.2));\n\n /*\n * Belt of Venus: the sky opposite a setting sun takes a cool pink-violet from\n * light already reddened on its way past. Subtle, but its absence is why an\n * artificial sunset feels like it is only happening on one side.\n */\n float away = max(-dot(viewBearing, sunBearing), 0.0);\n col += vec3(0.42, 0.28, 0.45) * sunset * band * away * 0.22;\n\n /*\n * Moon: a shaded procedural sphere-disc, not one dot subtracted from another.\n * The local sphere normal gives a curved terminator and limb; lunar phase\n * supplies the light direction around that sphere. Low-frequency noise adds\n * maria without a texture file, while a faint earthshine keeps the dark limb\n * just legible instead of cutting a mathematically black hole in the sky.\n */\n vec3 moonReference = abs(uMoonDir.y) < 0.98\n ? vec3(0.0, 1.0, 0.0)\n : vec3(1.0, 0.0, 0.0);\n vec3 moonRight = normalize(cross(moonReference, uMoonDir));\n vec3 moonUp = cross(uMoonDir, moonRight);\n float moonRadius = clamp(uMoonAngularRadius, 0.001, 0.5);\n vec2 moonUv = vec2(dot(dir, moonRight), dot(dir, moonUp)) / moonRadius;\n float moonRadiusSq = dot(moonUv, moonUv);\n float moonFacing = step(0.0, dot(dir, uMoonDir));\n float moonDisc = (1.0 - smoothstep(0.90, 1.0, moonRadiusSq)) * moonFacing;\n if (moonDisc > 0.0) {\n float sphereZ = sqrt(max(1.0 - moonRadiusSq, 0.0));\n vec3 surfaceNormal = normalize(vec3(moonUv, sphereZ));\n float phaseAngle = uMoonPhase * 6.28318530718;\n vec3 phaseLight = vec3(sin(phaseAngle), 0.0, -cos(phaseAngle));\n float phaseShade = max(dot(surfaceNormal, phaseLight), 0.0);\n float maria = valueNoise(moonUv * 3.2 + vec2(4.7, 9.1)) * 0.68 +\n valueNoise(moonUv * 9.3 - vec2(7.4, 2.6)) * 0.32;\n float albedo = mix(0.56, 1.02, maria);\n float limb = mix(0.72, 1.0, sphereZ);\n float earthshine = 0.035;\n float lunarLight = earthshine + (1.0 - earthshine) * phaseShade;\n col += uMoonColor * albedo * limb * lunarLight * moonDisc *\n 1.35 * uNightFactor * aboveHorizon;\n }\n float phaseIllumination = 0.5 - cos(uMoonPhase * 6.28318530718) * 0.5;\n float moonHalo = (1.0 - smoothstep(1.0, 3.0, length(moonUv))) * moonFacing;\n col += uMoonColor * moonHalo * 0.08 * phaseIllumination *\n uNightFactor * aboveHorizon;\n\n if (dir.y > 0.0) {\n // Stars. Stereographic projection: unlike dir.xz/dir.y it stays finite and\n // near-uniform all the way to the horizon, so stars don't smear into grain.\n // Each cell holds one jittered *point* \u2014 lighting the whole cell would draw\n // square blocks, which is what \"random pixels\" looks like.\n if (uNightFactor > 0.0) {\n vec2 p = dir.xz / (1.0 + dir.y) * 26.0;\n vec2 cell = floor(p);\n float h = hash21(cell);\n if (h > 0.93) {\n vec2 jitter = vec2(hash21(cell + 17.3), hash21(cell + 41.7));\n float d = length(fract(p) - jitter);\n float star = smoothstep(0.10, 0.0, d) * (0.4 + h * 0.6);\n // Fade out near the horizon, where haze would swallow them anyway.\n col += vec3(0.86, 0.91, 1.0) * star * uNightFactor * smoothstep(0.03, 0.3, dir.y);\n }\n }\n\n // Clouds: banded value noise on a layer above the camera, carried by the\n // world's wind \u2014 the same signal the smoke and the grass answer to, so the\n // largest moving thing in frame agrees with the smallest.\n // The floor on dir.y bounds the perspective stretch near the horizon.\n if (dir.y > 0.02) {\n vec2 uv = dir.xz / max(dir.y, 0.16) * 0.09 + uCloudOffset * 0.012;\n float band = smoothstep(0.46, 0.60, fbm(uv));\n float fade = smoothstep(0.02, 0.30, dir.y);\n vec3 cloudCol = mix(uHorizonColor, vec3(1.0), 0.45) * mix(1.0, 0.32, uNightFactor);\n col = mix(col, cloudCol, band * fade * 0.8);\n }\n }\n\n // There is no outdoor sky inside a water column. Retain a little vertical\n // gradient so the surface remains legible above and depth reads darker\n // below, while celestial bodies and clouds disappear with the same smooth\n // surface transition used by the geometry passes.\n float waterLight = mix(0.62, 1.08, clamp(dir.y * 0.5 + 0.5, 0.0, 1.0));\n col = mix(col, uUnderwaterColor * waterLight, uUnderwaterFactor);\n\n /* Graded where nothing after this will grade it, as every pass is: its colours are the same\n light as the fog the world fades into. 0 in a probe's face, whose cube stores radiance. */\n outColor = vec4(applyOutputTransform(col), 1.0);\n}\n";
@@ -1,4 +1,5 @@
1
1
  import { glslFarDepth } from '../depthConvention.js';
2
+ import { OUTPUT_TRANSFORM_GLSL } from './outputTransform.js';
2
3
  /**
3
4
  * Procedural gradient sky drawn as a single attribute-less fullscreen triangle.
4
5
  * Ray direction is reconstructed per-fragment from the inverse view-projection,
@@ -74,6 +75,7 @@ uniform vec3 uUnderwaterColor;
74
75
  uniform float uUnderwaterFactor;
75
76
 
76
77
  out vec4 outColor;
78
+ ${OUTPUT_TRANSFORM_GLSL}
77
79
 
78
80
  float hash21(vec2 p) {
79
81
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
@@ -270,6 +272,8 @@ void main() {
270
272
  float waterLight = mix(0.62, 1.08, clamp(dir.y * 0.5 + 0.5, 0.0, 1.0));
271
273
  col = mix(col, uUnderwaterColor * waterLight, uUnderwaterFactor);
272
274
 
273
- outColor = vec4(col, 1.0);
275
+ /* Graded where nothing after this will grade it, as every pass is: its colours are the same
276
+ light as the fog the world fades into. 0 in a probe's face, whose cube stores radiance. */
277
+ outColor = vec4(applyOutputTransform(col), 1.0);
274
278
  }
275
279
  `;