@driftengine/core 4.8.7 → 4.9.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 (163) hide show
  1. package/CHANGELOG.json +61 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +5 -0
  4. package/dist/index.js +7 -0
  5. package/dist/render/atmosphere.d.ts +13 -0
  6. package/dist/render/atmosphere.js +9 -1
  7. package/dist/render/backend/api.d.ts +9 -1
  8. package/dist/render/backend/createRenderer.d.ts +6 -0
  9. package/dist/render/backend/createRenderer.js +4 -1
  10. package/dist/render/backend/webgl2/renderer.d.ts +90 -2
  11. package/dist/render/backend/webgl2/renderer.js +297 -16
  12. package/dist/render/backend/webgpu/device.d.ts +7 -1
  13. package/dist/render/backend/webgpu/device.js +31 -3
  14. package/dist/render/backend/webgpu/flatPass.d.ts +16 -1
  15. package/dist/render/backend/webgpu/flatPass.js +58 -26
  16. package/dist/render/backend/webgpu/postPass.d.ts +51 -9
  17. package/dist/render/backend/webgpu/postPass.js +16 -2
  18. package/dist/render/backend/webgpu/reflectionSurfacePass.d.ts +30 -0
  19. package/dist/render/backend/webgpu/reflectionSurfacePass.js +100 -0
  20. package/dist/render/backend/webgpu/renderer.d.ts +131 -2
  21. package/dist/render/backend/webgpu/renderer.js +586 -70
  22. package/dist/render/displayRange.d.ts +31 -0
  23. package/dist/render/displayRange.js +47 -0
  24. package/dist/render/filmicCurve.d.ts +74 -0
  25. package/dist/render/filmicCurve.js +203 -0
  26. package/dist/render/fog.d.ts +4 -1
  27. package/dist/render/fog.js +12 -2
  28. package/dist/render/frame/resources.d.ts +2 -2
  29. package/dist/render/frame/resources.js +6 -0
  30. package/dist/render/frameReflections.d.ts +40 -0
  31. package/dist/render/frameReflections.js +48 -0
  32. package/dist/render/pass.d.ts +49 -0
  33. package/dist/render/reflectionSurfaceTargets.d.ts +28 -0
  34. package/dist/render/reflectionSurfaceTargets.js +99 -0
  35. package/dist/render/renderQuality.d.ts +39 -8
  36. package/dist/render/renderQuality.js +4 -0
  37. package/dist/render/sceneTarget.d.ts +5 -0
  38. package/dist/render/sceneTarget.js +24 -1
  39. package/dist/render/screenSpaceReflection.d.ts +5 -0
  40. package/dist/render/screenSpaceReflection.js +5 -0
  41. package/dist/render/shaders/arcane.d.ts +1 -1
  42. package/dist/render/shaders/bolt.d.ts +1 -1
  43. package/dist/render/shaders/caustics.d.ts +1 -1
  44. package/dist/render/shaders/film.d.ts +1 -1
  45. package/dist/render/shaders/filmLook.js +2 -1
  46. package/dist/render/shaders/fire.d.ts +1 -1
  47. package/dist/render/shaders/flat/glassTint.d.ts +1 -1
  48. package/dist/render/shaders/flat/glassTint.js +2 -0
  49. package/dist/render/shaders/flat/index.d.ts +10 -0
  50. package/dist/render/shaders/flat/index.js +6 -0
  51. package/dist/render/shaders/flat/main.d.ts +1 -1
  52. package/dist/render/shaders/flat/main.js +28 -0
  53. package/dist/render/shaders/flat/overlay.d.ts +16 -0
  54. package/dist/render/shaders/flat/overlay.js +117 -0
  55. package/dist/render/shaders/flat/pointShadow.js +1 -1
  56. package/dist/render/shaders/flat/reflectionSurface.d.ts +21 -0
  57. package/dist/render/shaders/flat/reflectionSurface.js +61 -0
  58. package/dist/render/shaders/fog.d.ts +1 -1
  59. package/dist/render/shaders/fog.js +9 -2
  60. package/dist/render/shaders/fringe.d.ts +29 -0
  61. package/dist/render/shaders/fringe.js +55 -0
  62. package/dist/render/shaders/generated/arcane.wgsl.d.ts +1 -1
  63. package/dist/render/shaders/generated/arcane.wgsl.js +1 -1
  64. package/dist/render/shaders/generated/bolt.wgsl.d.ts +1 -1
  65. package/dist/render/shaders/generated/bolt.wgsl.js +1 -1
  66. package/dist/render/shaders/generated/caustics.wgsl.d.ts +1 -1
  67. package/dist/render/shaders/generated/caustics.wgsl.js +1 -1
  68. package/dist/render/shaders/generated/film.wgsl.d.ts +1 -1
  69. package/dist/render/shaders/generated/film.wgsl.js +1 -1
  70. package/dist/render/shaders/generated/fire.wgsl.d.ts +1 -1
  71. package/dist/render/shaders/generated/fire.wgsl.js +1 -1
  72. package/dist/render/shaders/generated/flat.wgsl.d.ts +176 -16
  73. package/dist/render/shaders/generated/flat.wgsl.js +214 -54
  74. package/dist/render/shaders/generated/line.wgsl.d.ts +1 -1
  75. package/dist/render/shaders/generated/line.wgsl.js +1 -1
  76. package/dist/render/shaders/generated/particle.wgsl.d.ts +4 -4
  77. package/dist/render/shaders/generated/particle.wgsl.js +4 -4
  78. package/dist/render/shaders/generated/rush.wgsl.d.ts +56 -26
  79. package/dist/render/shaders/generated/rush.wgsl.js +56 -26
  80. package/dist/render/shaders/generated/scatter.wgsl.d.ts +1 -1
  81. package/dist/render/shaders/generated/scatter.wgsl.js +1 -1
  82. package/dist/render/shaders/generated/smoke.wgsl.d.ts +1 -1
  83. package/dist/render/shaders/generated/smoke.wgsl.js +1 -1
  84. package/dist/render/shaders/generated/ssrResolve.wgsl.d.ts +29 -0
  85. package/dist/render/shaders/generated/ssrResolve.wgsl.js +29 -0
  86. package/dist/render/shaders/generated/ssrTrace.wgsl.d.ts +22 -2
  87. package/dist/render/shaders/generated/ssrTrace.wgsl.js +22 -2
  88. package/dist/render/shaders/generated/water.wgsl.d.ts +1 -1
  89. package/dist/render/shaders/generated/water.wgsl.js +1 -1
  90. package/dist/render/shaders/gpudriven/fog.wgsl.d.ts +2 -2
  91. package/dist/render/shaders/gpudriven/fog.wgsl.js +11 -3
  92. package/dist/render/shaders/line.d.ts +1 -1
  93. package/dist/render/shaders/particle.d.ts +3 -3
  94. package/dist/render/shaders/rush.js +54 -11
  95. package/dist/render/shaders/scatter.d.ts +1 -1
  96. package/dist/render/shaders/smoke.d.ts +1 -1
  97. package/dist/render/shaders/ssrResolve.d.ts +17 -0
  98. package/dist/render/shaders/ssrResolve.js +51 -0
  99. package/dist/render/shaders/ssrTrace.js +26 -0
  100. package/dist/render/shaders/water.d.ts +1 -1
  101. package/dist/render/ssrPass.d.ts +16 -0
  102. package/dist/render/ssrPass.js +106 -1
  103. package/dist/render/surfaceOverlay.d.ts +132 -0
  104. package/dist/render/surfaceOverlay.js +85 -0
  105. package/dist/render/surfaceTexture.d.ts +19 -0
  106. package/dist/render/surfaceTexture.js +69 -0
  107. package/dist/render/temporalAa.d.ts +14 -0
  108. package/dist/render/temporalAa.js +20 -0
  109. package/dist/render/vertexDefaults.d.ts +17 -0
  110. package/dist/render/vertexDefaults.js +24 -0
  111. package/package.json +6 -6
  112. package/src/index.ts +16 -0
  113. package/src/render/atmosphere.ts +22 -1
  114. package/src/render/backend/api.ts +9 -0
  115. package/src/render/backend/createRenderer.ts +18 -1
  116. package/src/render/backend/webgl2/renderer.ts +357 -13
  117. package/src/render/backend/webgpu/device.ts +42 -3
  118. package/src/render/backend/webgpu/flatPass.ts +63 -26
  119. package/src/render/backend/webgpu/postPass.ts +18 -0
  120. package/src/render/backend/webgpu/reflectionSurfacePass.ts +114 -0
  121. package/src/render/backend/webgpu/renderer.ts +735 -83
  122. package/src/render/displayRange.ts +61 -0
  123. package/src/render/filmicCurve.ts +229 -0
  124. package/src/render/fog.ts +19 -3
  125. package/src/render/frame/resources.ts +6 -0
  126. package/src/render/frameReflections.ts +72 -0
  127. package/src/render/pass.ts +53 -1
  128. package/src/render/reflectionSurfaceTargets.ts +105 -0
  129. package/src/render/renderQuality.ts +44 -8
  130. package/src/render/sceneTarget.ts +30 -2
  131. package/src/render/screenSpaceReflection.ts +5 -0
  132. package/src/render/shaders/filmLook.ts +2 -1
  133. package/src/render/shaders/flat/glassTint.ts +5 -1
  134. package/src/render/shaders/flat/index.ts +16 -0
  135. package/src/render/shaders/flat/main.ts +28 -0
  136. package/src/render/shaders/flat/overlay.ts +117 -0
  137. package/src/render/shaders/flat/pointShadow.ts +1 -1
  138. package/src/render/shaders/flat/reflectionSurface.ts +61 -0
  139. package/src/render/shaders/fog.ts +9 -2
  140. package/src/render/shaders/fringe.ts +59 -0
  141. package/src/render/shaders/generated/arcane.wgsl.ts +1 -1
  142. package/src/render/shaders/generated/bolt.wgsl.ts +1 -1
  143. package/src/render/shaders/generated/caustics.wgsl.ts +1 -1
  144. package/src/render/shaders/generated/film.wgsl.ts +1 -1
  145. package/src/render/shaders/generated/fire.wgsl.ts +1 -1
  146. package/src/render/shaders/generated/flat.wgsl.ts +214 -54
  147. package/src/render/shaders/generated/line.wgsl.ts +1 -1
  148. package/src/render/shaders/generated/particle.wgsl.ts +4 -4
  149. package/src/render/shaders/generated/rush.wgsl.ts +56 -26
  150. package/src/render/shaders/generated/scatter.wgsl.ts +1 -1
  151. package/src/render/shaders/generated/smoke.wgsl.ts +1 -1
  152. package/src/render/shaders/generated/ssrResolve.wgsl.ts +30 -0
  153. package/src/render/shaders/generated/ssrTrace.wgsl.ts +22 -2
  154. package/src/render/shaders/generated/water.wgsl.ts +1 -1
  155. package/src/render/shaders/gpudriven/fog.wgsl.ts +11 -3
  156. package/src/render/shaders/rush.ts +54 -11
  157. package/src/render/shaders/ssrResolve.ts +52 -0
  158. package/src/render/shaders/ssrTrace.ts +26 -0
  159. package/src/render/ssrPass.ts +138 -1
  160. package/src/render/surfaceOverlay.ts +195 -0
  161. package/src/render/surfaceTexture.ts +67 -0
  162. package/src/render/temporalAa.ts +20 -0
  163. package/src/render/vertexDefaults.ts +28 -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 float t = (pointY - uFogEyeY) * uFogHeightFalloff;\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(t) < 1e-4 ? 1e-4 : t;\n float air = 1.0 - exp(-uFogDensity * dist * (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 * 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";
@@ -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 float t = (pointY - uFogEyeY) * uFogHeightFalloff;\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(t) < 1e-4 ? 1e-4 : t;\n float air = 1.0 - exp(-uFogDensity * dist * (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 * 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 float t = (pointY - uFogEyeY) * uFogHeightFalloff;\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(t) < 1e-4 ? 1e-4 : t;\n float air = 1.0 - exp(-uFogDensity * dist * (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 * 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 float t = (pointY - uFogEyeY) * uFogHeightFalloff;\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(t) < 1e-4 ? 1e-4 : t;\n float air = 1.0 - exp(-uFogDensity * dist * (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 * 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
@@ -25,6 +25,8 @@ import { AUTO_EXPOSURE_GLSL } from './exposure.js';
25
25
  import { LOCAL_EXPOSURE_GLSL } from './localExposure.js';
26
26
  import { FILM_LOOK_GLSL } from './filmLook.js';
27
27
  import { HIGHLIGHT_SHOULDER_GLSL } from './outputTransform.js';
28
+ import { FILMIC_GLSL } from '../filmicCurve.js';
29
+ import { FRINGE_GLSL } from './fringe.js';
28
30
  export const RUSH_FRAG = `#version 300 es
29
31
  precision highp float;
30
32
  /*
@@ -62,6 +64,18 @@ uniform float uReach;
62
64
  */
63
65
  uniform int uOutputTransform;
64
66
  uniform float uOutputExposure;
67
+ /**
68
+ * The \`filmic\` curve's eight constants, solved once a curve by \`resolveFilmicCurve\`: (slope, black
69
+ * clip, white clip, toe scale) and (shoulder scale, toe match, straight match, shoulder match).
70
+ * Read only where \`uOutputTransform\` is 4.
71
+ */
72
+ uniform vec4 uFilmA;
73
+ uniform vec4 uFilmB;
74
+ /**
75
+ * The display's peak over paper white on a high dynamic range canvas, and 1 everywhere else: a
76
+ * value of 1 is the display's paper white, and only above 1 does anything here let light past it.
77
+ */
78
+ uniform float uDisplayHeadroom;
65
79
 
66
80
  /**
67
81
  * The frame's depth, and the matrix that carries a pixel back to where it was last frame.
@@ -89,6 +103,14 @@ uniform mat4 uReprojection;
89
103
  uniform float uMotionStrength;
90
104
  /** A ceiling on the smear, in UV, so a fast turn softens the frame rather than erasing it. */
91
105
  uniform float uMotionMax;
106
+ /**
107
+ * Each drawn object's own motion, where the frame keeps it: the reconstruction's motion target,
108
+ * whose texel is where a mover's surface was minus where it is, in this pass's uv, and a flag in the
109
+ * fourth channel above one half where a draw wrote it. \`uObjectMotion\` is 1 where it is bound and
110
+ * drawn this frame and 0 otherwise, when a placeholder is bound and the camera's motion alone blurs.
111
+ */
112
+ uniform sampler2D uMotion;
113
+ uniform float uObjectMotion;
92
114
 
93
115
  /**
94
116
  * Ambient occlusion, already estimated and blurred by its own pass. 1 is open, 0 is enclosed.
@@ -242,10 +264,12 @@ vec3 acesFilmic(vec3 x) {
242
264
  }
243
265
 
244
266
  ${HIGHLIGHT_SHOULDER_GLSL}
267
+ ${FILMIC_GLSL}
245
268
  vec3 grade(vec3 c) {
246
269
  if (uOutputTransform == 0) return c;
247
270
  if (uOutputTransform == 2) c = acesFilmic(c);
248
271
  if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);
272
+ if (uOutputTransform == 4) c = filmicCurve(c * uOutputExposure);
249
273
  return linearToSrgb(c);
250
274
  }
251
275
 
@@ -258,9 +282,16 @@ vec3 withBloom(vec3 c) {
258
282
  /** The grade, sampled after the curve and before the veil. See uGradeLut's own comment. */
259
283
  vec3 applyColourGrade(vec3 c) {
260
284
  if (uGradeStrength <= 0.0) return c;
261
- vec3 clamped = clamp(c, 0.0, 1.0);
285
+ /*
286
+ * **A table graded for paper white grades the colour and keeps the light above it** on a high
287
+ * range display: the pixel is brought under white by its brightest channel, graded, and taken
288
+ * back up by the same factor, so a lamp keeps its brightness and takes the look's colour. At a
289
+ * headroom of 1 the factor is exactly 1 and this is the grade it always was.
290
+ */
291
+ float over = uDisplayHeadroom > 1.0 ? max(max(c.r, max(c.g, c.b)), 1.0) : 1.0;
292
+ vec3 clamped = clamp(c / over, 0.0, 1.0);
262
293
  vec3 uvw = (clamped * (uGradeSize - 1.0) + 0.5) / uGradeSize;
263
- return mix(c, textureLod(uGradeLut, uvw, 0.0).rgb, uGradeStrength);
294
+ return mix(c, textureLod(uGradeLut, uvw, 0.0).rgb * over, uGradeStrength);
264
295
  }
265
296
 
266
297
  /** The veil, mixed in last of everything this pass does. See uVeilAlpha's own comment. */
@@ -290,14 +321,24 @@ vec3 withVeil(vec3 c) {
290
321
  * correctly, and a camera turning past a horizon *should* smear it.
291
322
  */
292
323
  vec3 cameraBlur(vec3 scene) {
293
- float depth = textureLod(uDepth, vUv, 0.0).r;
294
- vec4 clip = vec4(vUv * 2.0 - 1.0, ${glslSceneDepthToNdc('depth')}, 1.0);
295
- vec4 previous = uReprojection * clip;
296
- /* Behind the previous eye: there is no last-frame position to smear toward. */
297
- if (previous.w <= 0.0) return scene;
298
-
299
- vec2 wasUv = (previous.xy / previous.w) * 0.5 + 0.5;
300
- vec2 velocity = (vUv - wasUv) * uMotionStrength;
324
+ /*
325
+ * **A drawn object's own motion where it has one**: a kick's leg or a thrown body smears along
326
+ * its path while the stage standing still behind it does not, which the camera's motion alone
327
+ * cannot tell apart. Read unconditionally, the placeholder where there is no target.
328
+ */
329
+ vec4 moved = textureLod(uMotion, vUv, 0.0);
330
+ vec2 velocity;
331
+ if (uObjectMotion > 0.5 && moved.a > 0.5) {
332
+ velocity = -moved.xy * uMotionStrength;
333
+ } else {
334
+ float depth = textureLod(uDepth, vUv, 0.0).r;
335
+ vec4 clip = vec4(vUv * 2.0 - 1.0, ${glslSceneDepthToNdc('depth')}, 1.0);
336
+ vec4 previous = uReprojection * clip;
337
+ /* Behind the previous eye: there is no last-frame position to smear toward. */
338
+ if (previous.w <= 0.0) return scene;
339
+ vec2 wasUv = (previous.xy / previous.w) * 0.5 + 0.5;
340
+ velocity = (vUv - wasUv) * uMotionStrength;
341
+ }
301
342
 
302
343
  float distance = length(velocity);
303
344
  if (distance < 1e-4) return scene;
@@ -335,9 +376,11 @@ vec3 finish(vec3 light) {
335
376
  return withGrain(withVeil(applyColourGrade(grade(lensed))), gl_FragCoord.xy);
336
377
  }
337
378
 
379
+ ${FRINGE_GLSL}
338
380
  void main() {
339
381
  vec4 sampled = textureLod(uScene, vUv, 0.0);
340
- vec3 scene = sampled.rgb;
382
+ /* The lens's colour fringe first, on the scene as it arrived. See shaders/fringe.ts. */
383
+ vec3 scene = withFringe(sampled.rgb);
341
384
  if (uMotionStrength > 0.0) scene = cameraBlur(scene);
342
385
  /*
343
386
  * Defocus after the camera smear and before everything else, because a lens is the last thing
@@ -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 float t = (pointY - uFogEyeY) * uFogHeightFalloff;\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(t) < 1e-4 ? 1e-4 : t;\n float air = 1.0 - exp(-uFogDensity * dist * (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\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";
@@ -5,4 +5,4 @@
5
5
  * Alpha-blended rather than additive: smoke occludes what is behind it, and
6
6
  * adding it would make it glow instead of darken.
7
7
  */
8
- export declare const SMOKE_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\nin float vSeed;\nin float vDistance;\nin vec3 vWorldPos;\n\nuniform float uTime;\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 float t = (pointY - uFogEyeY) * uFogHeightFalloff;\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(t) < 1e-4 ? 1e-4 : t;\n float air = 1.0 - exp(-uFogDensity * dist * (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\nuniform int uNoiseOctaves;\nuniform vec4 uClipPlane;\nuniform int uClipEnabled;\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 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) {\n float v = valueNoise(p * 2.2 + vec2(0.0, -t * 0.55)) * 0.55;\n if (uNoiseOctaves == 1) return v / 0.55;\n v += valueNoise(p * 4.7 + vec2(t * 0.12, -t * 0.9)) * 0.29;\n if (uNoiseOctaves == 2) return v / 0.84;\n return v + valueNoise(p * 9.3 + vec2(-t * 0.09, -t * 1.4)) * 0.16;\n}\n\nvoid main() {\n if (uClipEnabled != 0 && dot(vec4(vWorldPos, 1.0), uClipPlane) < 0.0) discard;\n\n float t = uTime + vSeed * 53.0;\n float height = vUv.y;\n\n // Widens as it climbs: sample a compressed space low down, a stretched one up\n // top. Narrow at the bottom is what lets it sit inside the flame's tip.\n float spread = mix(0.5, 1.0, height);\n float centred = (vUv.x - 0.5) / spread + 0.5;\n\n // Soft horizontal falloff rather than a hard cut \u2014 a clipped edge here shows\n // the quad and is the single most obvious way smoke reads as fake.\n float sides = smoothstep(0.0, 0.55, 1.0 - clamp(abs(centred - 0.5) * 2.0, 0.0, 1.0));\n if (sides <= 0.001) discard;\n\n // Leans away over its height instead of rising ruler-straight.\n float drift = (valueNoise(vec2(t * 0.21, vSeed * 7.0)) - 0.5) * 0.6 * height * height;\n float n = billow(vec2(centred + drift, height), t);\n\n float density = n * sides * 1.35 - height * 0.88;\n if (density <= 0.02) discard;\n\n // Colour tells the story of the plume: ember-lit where it leaves the flame,\n // cooling through soot, then dispersing into the fog. Starting at flat grey\n // is what made it look like a separate object parked above the fire.\n vec3 col = mix(vec3(0.90, 0.42, 0.16), vec3(0.11, 0.10, 0.10), smoothstep(0.0, 0.34, height));\n vec3 atmosphereColor = mediumColor();\n col = mix(col, atmosphereColor, smoothstep(0.30, 0.95, height));\n\n // Fade in at the base so it emerges out of the flame, and out at the top so\n // it dissipates rather than ending.\n float emerge = smoothstep(0.0, 0.30, height);\n float dissipate = 1.0 - smoothstep(0.22, 0.75, height);\n\n float fog = mediumFog(vDistance, vWorldPos.y);\n col = mix(col, atmosphereColor, fog);\n float alpha = clamp(density * 0.95, 0.0, 1.0) * emerge * dissipate * 0.13;\n outColor = vec4(col, alpha * (1.0 - fog));\n}\n";
8
+ export declare const SMOKE_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\nin float vSeed;\nin float vDistance;\nin vec3 vWorldPos;\n\nuniform float uTime;\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\nuniform int uNoiseOctaves;\nuniform vec4 uClipPlane;\nuniform int uClipEnabled;\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 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) {\n float v = valueNoise(p * 2.2 + vec2(0.0, -t * 0.55)) * 0.55;\n if (uNoiseOctaves == 1) return v / 0.55;\n v += valueNoise(p * 4.7 + vec2(t * 0.12, -t * 0.9)) * 0.29;\n if (uNoiseOctaves == 2) return v / 0.84;\n return v + valueNoise(p * 9.3 + vec2(-t * 0.09, -t * 1.4)) * 0.16;\n}\n\nvoid main() {\n if (uClipEnabled != 0 && dot(vec4(vWorldPos, 1.0), uClipPlane) < 0.0) discard;\n\n float t = uTime + vSeed * 53.0;\n float height = vUv.y;\n\n // Widens as it climbs: sample a compressed space low down, a stretched one up\n // top. Narrow at the bottom is what lets it sit inside the flame's tip.\n float spread = mix(0.5, 1.0, height);\n float centred = (vUv.x - 0.5) / spread + 0.5;\n\n // Soft horizontal falloff rather than a hard cut \u2014 a clipped edge here shows\n // the quad and is the single most obvious way smoke reads as fake.\n float sides = smoothstep(0.0, 0.55, 1.0 - clamp(abs(centred - 0.5) * 2.0, 0.0, 1.0));\n if (sides <= 0.001) discard;\n\n // Leans away over its height instead of rising ruler-straight.\n float drift = (valueNoise(vec2(t * 0.21, vSeed * 7.0)) - 0.5) * 0.6 * height * height;\n float n = billow(vec2(centred + drift, height), t);\n\n float density = n * sides * 1.35 - height * 0.88;\n if (density <= 0.02) discard;\n\n // Colour tells the story of the plume: ember-lit where it leaves the flame,\n // cooling through soot, then dispersing into the fog. Starting at flat grey\n // is what made it look like a separate object parked above the fire.\n vec3 col = mix(vec3(0.90, 0.42, 0.16), vec3(0.11, 0.10, 0.10), smoothstep(0.0, 0.34, height));\n vec3 atmosphereColor = mediumColor();\n col = mix(col, atmosphereColor, smoothstep(0.30, 0.95, height));\n\n // Fade in at the base so it emerges out of the flame, and out at the top so\n // it dissipates rather than ending.\n float emerge = smoothstep(0.0, 0.30, height);\n float dissipate = 1.0 - smoothstep(0.22, 0.75, height);\n\n float fog = mediumFog(vDistance, vWorldPos.y);\n col = mix(col, atmosphereColor, fog);\n float alpha = clamp(density * 0.95, 0.0, 1.0) * emerge * dissipate * 0.13;\n outColor = vec4(col, alpha * (1.0 - fog));\n}\n";
@@ -13,3 +13,20 @@
13
13
  * The vertex stage is `FULLSCREEN_VERT`, shared with the occlusion pass, its blur and the rush.
14
14
  */
15
15
  export declare const SSR_RESOLVE_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\n\n/** Premultiplied reflection in rgb, coverage in a. Cleared to zero: nothing found is nothing. */\nuniform sampler2D uSsrReflection;\n\nout vec4 fragColor;\n\nvoid main() {\n fragColor = textureLod(uSsrReflection, vUv, 0.0);\n}\n";
16
+ /**
17
+ * The resolve for a trace of the frame's materials: **the probe's share of each pixel swapped for
18
+ * what the ray found**, by the weight the lit stage reflected with.
19
+ *
20
+ * The frame holds `environment · tint · weight` of the probe (`flat/reflectionSurface.ts`), and a
21
+ * reflection found in the frame belongs there with the same tint and weight. So the frame gains the
22
+ * found colour times the tint, less the probe's share, both scaled by how far the hit is believed —
23
+ * nothing where nothing was found, and the probe kept wherever the ray left the frame. Added, against
24
+ * a blend of `(one, one)` on the colour and the scene's alpha kept, onto a half-float scene, where a
25
+ * negative addend is the subtraction it reads as.
26
+ *
27
+ * **Blurred by the surface's roughness**, twelve taps on two rings whose radius is the roughness
28
+ * times `uSsrBlur` texels: the trace reads the scene at one point, which is a mirror, and a satin
29
+ * panel reflects a smear. What it gives up is a lobe's true shape and its stretch toward a grazing
30
+ * view; what it keeps is that a rougher panel reflects a softer picture, and a mirror a sharp one.
31
+ */
32
+ export declare const SSR_MATERIAL_RESOLVE_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vUv;\n\n/** The found colour times how far it is believed in rgb, the belief in a. */\nuniform sampler2D uSsrReflection;\n/** The environment as the frame shows it, the roughness in a. */\nuniform sampler2D uSsrProbeMap;\n/** What a found reflection is multiplied by to land where the environment did. */\nuniform sampler2D uSsrTintMap;\n/** x, y: one texel of the trace; z: the blur's radius at roughness 1, in texels. */\nuniform vec3 uSsrBlur;\n\nout vec4 fragColor;\n\nvoid main() {\n vec4 probe = textureLod(uSsrProbeMap, vUv, 0.0);\n vec3 tint = textureLod(uSsrTintMap, vUv, 0.0).rgb;\n vec4 traced = textureLod(uSsrReflection, vUv, 0.0);\n float radius = probe.a * uSsrBlur.z;\n if (radius >= 1.0) {\n vec4 sum = traced;\n for (int i = 0; i < 12; i++) {\n /* Six taps a ring, sixty degrees apart, the inner ring turned half a step. */\n float angle = float(i % 6) * 1.0471976 + (i < 6 ? 0.0 : 0.5235988);\n float reach = i < 6 ? radius : radius * 0.5;\n vec2 offset = vec2(cos(angle), sin(angle)) * reach * uSsrBlur.xy;\n sum += textureLod(uSsrReflection, vUv + offset, 0.0);\n }\n traced = sum / 13.0;\n }\n fragColor = vec4(traced.rgb * tint - traced.a * probe.rgb, 0.0);\n}\n";
@@ -26,3 +26,54 @@ void main() {
26
26
  fragColor = textureLod(uSsrReflection, vUv, 0.0);
27
27
  }
28
28
  `;
29
+ /**
30
+ * The resolve for a trace of the frame's materials: **the probe's share of each pixel swapped for
31
+ * what the ray found**, by the weight the lit stage reflected with.
32
+ *
33
+ * The frame holds `environment · tint · weight` of the probe (`flat/reflectionSurface.ts`), and a
34
+ * reflection found in the frame belongs there with the same tint and weight. So the frame gains the
35
+ * found colour times the tint, less the probe's share, both scaled by how far the hit is believed —
36
+ * nothing where nothing was found, and the probe kept wherever the ray left the frame. Added, against
37
+ * a blend of `(one, one)` on the colour and the scene's alpha kept, onto a half-float scene, where a
38
+ * negative addend is the subtraction it reads as.
39
+ *
40
+ * **Blurred by the surface's roughness**, twelve taps on two rings whose radius is the roughness
41
+ * times `uSsrBlur` texels: the trace reads the scene at one point, which is a mirror, and a satin
42
+ * panel reflects a smear. What it gives up is a lobe's true shape and its stretch toward a grazing
43
+ * view; what it keeps is that a rougher panel reflects a softer picture, and a mirror a sharp one.
44
+ */
45
+ export const SSR_MATERIAL_RESOLVE_FRAG = `#version 300 es
46
+ precision highp float;
47
+
48
+ in vec2 vUv;
49
+
50
+ /** The found colour times how far it is believed in rgb, the belief in a. */
51
+ uniform sampler2D uSsrReflection;
52
+ /** The environment as the frame shows it, the roughness in a. */
53
+ uniform sampler2D uSsrProbeMap;
54
+ /** What a found reflection is multiplied by to land where the environment did. */
55
+ uniform sampler2D uSsrTintMap;
56
+ /** x, y: one texel of the trace; z: the blur's radius at roughness 1, in texels. */
57
+ uniform vec3 uSsrBlur;
58
+
59
+ out vec4 fragColor;
60
+
61
+ void main() {
62
+ vec4 probe = textureLod(uSsrProbeMap, vUv, 0.0);
63
+ vec3 tint = textureLod(uSsrTintMap, vUv, 0.0).rgb;
64
+ vec4 traced = textureLod(uSsrReflection, vUv, 0.0);
65
+ float radius = probe.a * uSsrBlur.z;
66
+ if (radius >= 1.0) {
67
+ vec4 sum = traced;
68
+ for (int i = 0; i < 12; i++) {
69
+ /* Six taps a ring, sixty degrees apart, the inner ring turned half a step. */
70
+ float angle = float(i % 6) * 1.0471976 + (i < 6 ? 0.0 : 0.5235988);
71
+ float reach = i < 6 ? radius : radius * 0.5;
72
+ vec2 offset = vec2(cos(angle), sin(angle)) * reach * uSsrBlur.xy;
73
+ sum += textureLod(uSsrReflection, vUv + offset, 0.0);
74
+ }
75
+ traced = sum / 13.0;
76
+ }
77
+ fragColor = vec4(traced.rgb * tint - traced.a * probe.rgb, 0.0);
78
+ }
79
+ `;
@@ -73,6 +73,18 @@ uniform float uSsrThickness;
73
73
  uniform float uSsrSteps;
74
74
  /** How much of the frame each side is fade, as a fraction. */
75
75
  uniform float uSsrEdgeFade;
76
+ /**
77
+ * **1 where the trace is the frame's materials rather than a box's**: every pixel the lit stage
78
+ * left a reflection for reflects, by its own material, and the box, its axis and its strength are
79
+ * not read. See \`flat/reflectionSurface.ts\` for the two maps it is handed.
80
+ */
81
+ uniform float uSsrMaterial;
82
+ /** The roughness past which a pixel's reflection is the probes' alone, faded over the last quarter. */
83
+ uniform float uSsrMaxRoughness;
84
+ /** The environment as the frame shows it, and the surface's roughness in its alpha. */
85
+ uniform sampler2D uSsrProbeMap;
86
+ /** What a found reflection is multiplied by to land where the environment did. */
87
+ uniform sampler2D uSsrTintMap;
76
88
 
77
89
  out vec4 fragColor;
78
90
 
@@ -150,6 +162,20 @@ void main() {
150
162
  returned = clamp(uSsrStrength * dfg.x + dfg.y, 0.0, 1.0);
151
163
  }
152
164
  mask *= returned;
165
+ /*
166
+ * **Or the pixel's own material**, where the trace is the frame's: reflective wherever the lit
167
+ * stage left a reflection to swap (any tint above zero), and the probes' alone past the
168
+ * roughness ceiling. How much it returns is the tint, which the resolve multiplies by, so the
169
+ * mask here is only whether to march and how much to trust a hit.
170
+ */
171
+ if (uSsrMaterial > 0.5) {
172
+ vec3 tint = textureLod(uSsrTintMap, vUv, 0.0).rgb;
173
+ float roughness = textureLod(uSsrProbeMap, vUv, 0.0).a;
174
+ float reflective = max(max(tint.r, tint.g), tint.b) > 0.0 ? 1.0 : 0.0;
175
+ float ceiling = max(uSsrMaxRoughness, 1.0e-4);
176
+ mask = reflective * (1.0 - smoothstep(ceiling * 0.75, ceiling, roughness));
177
+ mask *= ${glslIsFarDepth('stored')} ? 0.0 : 1.0;
178
+ }
153
179
 
154
180
  vec3 found = vec3(0.0);
155
181
  float weight = 0.0;
@@ -15,4 +15,4 @@
15
15
  * ocean from a fixed vertex budget without the surface sliding underfoot.
16
16
  */
17
17
  export declare const WATER_VERT = "#version 300 es\nlayout(location = 0) in vec2 aGrid;\n\nuniform mat4 uViewProj;\nuniform mat4 uReflectionViewProj;\nuniform vec3 uCameraPos;\nuniform float uTime;\n/**\n * Where the sheet is laid, in world XZ.\n *\n * The ocean passes its own camera position snapped to whole cells, which is what\n * gives an endless sea from a fixed vertex budget without the surface sliding\n * underfoot. A *bounded* sheet \u2014 a pool in a courtyard \u2014 passes the middle of the\n * thing it is filling instead, and is world-fixed by construction.\n *\n * Snapped on the CPU rather than here so that one number is the only difference\n * between an ocean and a basin, and the shader has no idea which it is drawing.\n */\nuniform vec2 uGridOrigin;\n/**\n * Metres per unit of the sheet's own grid, along each of the body's own axes.\n *\n * One and one for the ocean, whose grid is authored in metres and is two meshes wide.\n * A bounded body \u2014 a pool, a tank, a puddle, a channel \u2014 is drawn from a unit sheet\n * and scaled by its own half-extents, which is what lets one mesh and one material\n * serve every body of water in the world.\n *\n * **Two numbers and not one since 2026-08-28.** A single span makes every bounded\n * body square, and a square that contains a channel floods the ground either side\n * of it. The ocean passes the same number twice and is bit-identical to what it was.\n */\nuniform vec2 uGridSpan;\nuniform vec2 uGridHalf;\nuniform vec2 uNearHalf;\n/**\n * The body's own +z, in world XZ, unit length. (0, 1) leaves the sheet unturned.\n *\n * A direction rather than an angle, so nothing here needs a trig call and a consumer\n * bounding a channel can hand over the direction its run already takes. The across\n * axis is world up crossed with this, `(fz, -fx)`, which is the basis\n * `MeshBuilder.addOrientedBox` builds from a forward vector \u2014 one convention for an\n * oriented thing, so a reader who has met one has met both.\n */\nuniform vec2 uGridForward;\n/** Prevailing wind bearing, normalised. Waves are steered toward it. */\nuniform vec2 uWindDir;\n/** Multiplier on authored steepness \u2014 how built-up the sea is. */\nuniform float uWaveGain;\nuniform float uWaterLevel;\n\nout vec3 vWorldPos;\nout vec3 vNormal;\nout float vCrest;\nout vec4 vReflectionClip;\n/** Grid-local offset, so the sheet can fade out its own boundary. */\nout vec2 vGrid;\n\n\nconst int GERSTNER_COUNT = 4;\n/*\n * direction.xy, steepness, wavelength. Amplitude is steepness/k, so these sum to\n * roughly a 1m swell \u2014 enough to read as a living surface, low enough that crests\n * never wash over an island whose deck sits ~1.8m above sea level.\n */\nconst vec4 GERSTNER_WAVES[4] = vec4[4](\n vec4( 1.0, 0.0, 0.115, 34.0),\n vec4( 0.6, 0.8, 0.100, 18.0),\n vec4(-0.7, 0.7, 0.080, 9.0),\n vec4( 0.2, -0.98, 0.060, 5.0)\n);\n\n/*\n * Steer each wave toward the wind, but only partway. A real sea is a dominant\n * swell plus cross-chop; rotating all four onto one bearing gives corduroy,\n * which reads as a material rather than as water.\n */\nvec2 gerstnerDirection(int i, vec2 windDir) {\n return normalize(mix(normalize(GERSTNER_WAVES[i].xy), windDir, 0.62));\n}\n\nfloat gerstnerWavenumber(int i) {\n return 6.28318530718 / GERSTNER_WAVES[i].w;\n}\n\n/** Deep-water phase in radians at a point on the resting plane. */\nfloat gerstnerPhase(float k, vec2 dir, vec2 p, float time) {\n float c = sqrt(9.81 / k); // deep-water phase speed\n return k * (dot(dir, p) - c * time);\n}\n\nstruct GerstnerSurface {\n /** Displacement from the resting plane \u2014 horizontal as well as vertical. */\n vec3 offset;\n vec3 normal;\n /** Height in units of steepness; positive near a crest. */\n float crest;\n};\n\nGerstnerSurface gerstnerSurface(vec2 p, float time, vec2 windDir, float gain) {\n GerstnerSurface s;\n s.offset = vec3(0.0);\n s.normal = vec3(0.0, 1.0, 0.0);\n s.crest = 0.0;\n for (int i = 0; i < GERSTNER_COUNT; i++) {\n vec2 dir = gerstnerDirection(i, windDir);\n float steepness = GERSTNER_WAVES[i].z * gain;\n float k = gerstnerWavenumber(i);\n float f = gerstnerPhase(k, dir, p, time);\n float a = steepness / k; // amplitude from steepness\n\n s.offset.x += dir.x * a * cos(f);\n s.offset.y += a * sin(f);\n s.offset.z += dir.y * a * cos(f);\n\n // Analytic normal: the partial derivatives of the displacement above.\n s.normal.x -= dir.x * steepness * cos(f);\n s.normal.z -= dir.y * steepness * cos(f);\n s.normal.y -= steepness * sin(f);\n\n s.crest += sin(f) * steepness;\n }\n return s;\n}\n\n/**\n * Second derivatives of the height field: (d2h/dx2, d2h/dz2, d2h/dxdz).\n *\n * How the surface *bends*, which is what decides where reflected light\n * converges. Analytic from the same sum, so it cannot disagree with the shape\n * the vertex stage displaced.\n */\nvec3 gerstnerCurvature(vec2 p, float time, vec2 windDir, float gain) {\n vec3 h = vec3(0.0);\n for (int i = 0; i < GERSTNER_COUNT; i++) {\n vec2 dir = gerstnerDirection(i, windDir);\n float steepness = GERSTNER_WAVES[i].z * gain;\n float k = gerstnerWavenumber(i);\n float f = gerstnerPhase(k, dir, p, time);\n // Second derivative of (steepness/k)*sin(f) along the wave's own bearing.\n float second = -steepness * k * sin(f);\n h.x += second * dir.x * dir.x;\n h.y += second * dir.y * dir.y;\n h.z += second * dir.x * dir.y;\n }\n return h;\n}\n\n\nvoid main() {\n /*\n * Waves must reach nothing by the near sheet's edge, because beyond it lies\n * the flat skirt. A displaced rim meeting an undisplaced skirt would tear the\n * surface open along a visible seam; fading first makes the two meshes agree\n * exactly where they meet. Chebyshev distance, so the fade band follows the\n * rectangular boundary rather than cutting an ellipse inside it.\n *\n * **Measured as a fraction of each axis' own extent**, which is what lets a\n * channel three metres across and twenty-one long fade evenly at both its ends\n * and both its sides. Against one shared extent the narrow axis would be inside\n * the fade band along its whole length and the water would never reach its own\n * bank. For the ocean, whose two extents are equal, this is the same arithmetic\n * with the division moved one step earlier.\n *\n * It is also the honest thing to draw. At a few hundred metres a 1 m swell is\n * well under a pixel, and out on the skirt a float32 phase argument has lost\n * most of its meaningful bits \u2014 a calm horizon is what an ocean actually looks\n * like from that far away.\n */\n vec2 grid = aGrid * uGridSpan;\n /* The same offset the fragment stage fades the rim on, so the two cannot disagree. */\n vGrid = grid;\n vec2 rimFraction = abs(grid) / max(uNearHalf, vec2(1e-4));\n float rimDistance = max(rimFraction.x, rimFraction.y);\n float waveFade = 1.0 - smoothstep(0.35, 0.97, rimDistance);\n\n /*\n * Into the world: across the body, then along it. `right = up x forward` is\n * `(fz, -fx)`, and getting that pair the wrong way round is a sheet mirrored\n * about its own axis, which on a square is invisible and on a channel is a\n * channel across the road instead of along it.\n */\n vec2 laid = vec2(uGridForward.y, -uGridForward.x) * grid.x + uGridForward * grid.y;\n\n vec3 base = vec3(uGridOrigin.x + laid.x, uWaterLevel, uGridOrigin.y + laid.y);\n\n GerstnerSurface sea = gerstnerSurface(base.xz, uTime, uWindDir, waveFade * uWaveGain);\n vec3 pos = base + sea.offset;\n\n vWorldPos = pos;\n vNormal = normalize(sea.normal);\n vCrest = sea.crest;\n vReflectionClip = uReflectionViewProj * vec4(pos, 1.0);\n gl_Position = uViewProj * vec4(pos, 1.0);\n}\n";
18
- export declare const WATER_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vWorldPos;\nin vec3 vNormal;\nin float vCrest;\nin vec4 vReflectionClip;\nin vec2 vGrid;\n\nuniform vec2 uGridHalf;\nuniform float uFoamGain;\n/**\n * How much of what is beneath it a body of water hides, looked straight down.\n *\n * The *density* of the water rather than a rendering trick: a sea hides its own\n * floor, a fountain shows the tiles at the bottom of it, and a puddle is nearly\n * clear. Only the nadir end is configurable \u2014 at a grazing angle every water\n * surface goes reflective, which is Fresnel and not a property of the liquid.\n */\nuniform float uNadirOpacity;\n/** The whole surface's visibility, 0 to 1. Zero draws nothing at all. */\nuniform float uVisibility;\n/**\n * How much this body mirrors regardless of the angle it is seen at, 0 to 1.\n *\n * Zero is physics: reflection is Fresnel alone, which is right for an ocean seen\n * across its own surface and which means *almost nothing* when you stand over a\n * basin and look down into it \u2014 the angle is steep, the Fresnel term is a few\n * percent, and a reflection nobody can see is the same as no reflection: the\n * character's reflection is technically present and far too faint to perceive.\n *\n * So a body may declare itself more mirror than water. It is a lie about optics\n * and an honest one about what a small pool is *for*: you look into it to see\n * something. The sea keeps zero and behaves exactly as it always has.\n */\nuniform float uMirror;\nuniform vec3 uDeepColor;\nuniform vec3 uShallowColor;\nuniform vec3 uDirectionalDir;\nuniform vec3 uDirectionalColor;\nuniform vec3 uAmbient;\n/**\n * The scene's point lights, which water is entitled to as much as stone is.\n *\n * The surface used to answer to the sun alone, on the reasonable assumption that water\n * is a thing that sits under the sky. It is not: a flooded corridor, a cistern, an\n * indoor pool and a harbour at night are all water lit entirely by lamps, and under\n * those this shaded to flat ambient \u2014 a coloured plane, with the specular glitter that\n * is the single strongest cue that a surface *is* water switched off because no\n * directional source existed to make it. A lantern on a jetty threw nothing.\n *\n * Same slots, same weights and the surface shader's falloffs, a light's own exponent among\n * them, so a lamp reaches as far over water as over the ground beside it. **What it gives up**:\n * a spot's cone, which water does not read, so a spot lights the water around it as a lamp\n * would; and the smooth falloff's square, which the surface applies and this has never, so under\n * that falloff a lamp's pool on water is the wider of the two.\n */\n#define MAX_LIGHTS 16\nuniform int uLightCount;\nuniform vec3 uLightPos[MAX_LIGHTS];\nuniform vec3 uLightColor[MAX_LIGHTS];\nuniform float uLightRadius[MAX_LIGHTS];\nuniform float uLightWeight[MAX_LIGHTS];\nuniform int uLightFalloff;\n/* The surface shader's cone record, of which water reads z alone: a light's own falloff exponent. */\nuniform vec4 uLightCone[MAX_LIGHTS];\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 float t = (pointY - uFogEyeY) * uFogHeightFalloff;\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(t) < 1e-4 ? 1e-4 : t;\n float air = 1.0 - exp(-uFogDensity * dist * (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\nuniform vec3 uCameraPos;\nuniform sampler2D uReflectionMap;\nuniform int uReflectionEnabled;\nuniform vec2 uReflectionTexelSize;\nuniform int uReflectionFilterTaps;\n\nconst vec2 REFLECTION_FILTER[9] = vec2[9](\n vec2( 0.00, 0.00), vec2(-0.62, -0.31), vec2( 0.54, 0.43),\n vec2(-0.34, 0.76), vec2( 0.78, -0.57), vec2(-0.88, 0.28),\n vec2( 0.22, -0.91), vec2( 0.91, 0.08), vec2(-0.18, 0.94)\n);\n// Preserve small moving silhouettes such as a character while still spreading\n// the surrounding taps enough to make the target read as rough water.\nconst float REFLECTION_FILTER_WEIGHT[9] = float[9](\n 0.28, 0.09, 0.09, 0.09, 0.09, 0.09, 0.09, 0.09, 0.09\n);\n\nout vec4 outColor;\n\nvoid main() {\n vec3 n = normalize(vNormal);\n vec3 view = normalize(uCameraPos - vWorldPos);\n\n // Fresnel: water is nearly a mirror at grazing angles and clear straight down.\n // abs keeps the underside physically continuous with the top instead of\n // turning the entire surface into an opaque mirror below the waterline.\n float facing = clamp(abs(dot(n, view)), 0.0, 1.0);\n float fresnel = 0.02 + 0.98 * pow(1.0 - facing, 5.0);\n\n vec3 atmosphereColor = mediumColor();\n\n vec3 col = mix(uDeepColor, uShallowColor, clamp(vCrest * 1.4 + 0.4, 0.0, 1.0));\n vec3 incident = uAmbient +\n uDirectionalColor * max(dot(n, uDirectionalDir), 0.0) * 0.6;\n\n // Specular glitter along the crests \u2014 the strongest single cue that a surface\n // is water rather than a coloured plane.\n vec3 halfVec = normalize(uDirectionalDir + view);\n float spec = pow(max(dot(n, halfVec), 0.0), 220.0);\n vec3 highlight = uDirectionalColor * spec * 1.6;\n\n /*\n * And the same from every lamp in range: a light over water lights it, and glitters\n * on it. Indoors this is the whole of the surface's lighting.\n *\n * The exponent is the sun's, so a crest picks a lamp out as tightly as it picks out\n * the sky \u2014 which is what makes a strip light read as a *streak* across a pool rather\n * than a broad sheen. Weighted by presence like every other consumer of these slots,\n * so a light entering the set fades in instead of appearing.\n */\n for (int i = 0; i < MAX_LIGHTS; i++) {\n if (i >= uLightCount) break;\n vec3 toLight = uLightPos[i] - vWorldPos;\n float lightDist = length(toLight);\n float falloff;\n float exponent = uLightCone[i].z;\n if (exponent > 0.0) {\n float reach = lightDist / max(uLightRadius[i], 1e-4);\n falloff = pow(clamp(1.0 - reach * reach, 0.0, 1.0), exponent);\n } else if (uLightFalloff == 1) {\n float window = clamp(1.0 - pow(lightDist / max(uLightRadius[i], 1e-4), 4.0), 0.0, 1.0);\n falloff = window * window / max(lightDist * lightDist, 0.01);\n } else {\n falloff = clamp(1.0 - lightDist / max(uLightRadius[i], 1e-4), 0.0, 1.0);\n }\n if (falloff <= 0.0) continue;\n vec3 lightDir = toLight / max(lightDist, 1e-4);\n float shaped = falloff * uLightWeight[i];\n incident += uLightColor[i] * max(dot(n, lightDir), 0.0) * shaped * 0.6;\n vec3 lampHalf = normalize(lightDir + view);\n highlight += uLightColor[i] * pow(max(dot(n, lampHalf), 0.0), 220.0) * shaped * 1.6;\n }\n\n col *= incident;\n col += highlight;\n\n // Foam where crests peak \u2014 and only when the wind is actually breaking them.\n float foam = smoothstep(0.55, 0.85, vCrest) * uFoamGain;\n col = mix(col, vec3(0.92, 0.96, 1.0), foam * 0.5);\n\n float dist = distance(vWorldPos, uCameraPos);\n float fog = mediumFog(dist, vWorldPos.y);\n col = mix(col, atmosphereColor, fog);\n\n // Transparency is angle-dependent, like real water: looking down you see\n // straight through to the seabed, at grazing angles it turns reflective.\n // This is computed before reflection because straight-alpha compositing must\n // not attenuate reflected radiance a second time below.\n float airOpacity = mix(uNadirOpacity, 0.96, fresnel);\n float underwaterOpacity = mix(uNadirOpacity * 0.55, 0.88, fresnel);\n float opacity = mix(airOpacity, underwaterOpacity, uUnderwaterFactor);\n opacity = mix(opacity, 0.96, foam * 0.7);\n\n // Project the displaced surface into the mirrored camera. The Gerstner\n // displacement supplies broad distortion; the analytic normal adds the\n // smaller ripples that make reflected silhouettes move with each crest.\n vec3 reflected = atmosphereColor;\n float reflectionValid = 0.0;\n if (uReflectionEnabled != 0 && vReflectionClip.w > 0.0) {\n vec2 reflectionUv = vReflectionClip.xy / vReflectionClip.w * 0.5 + 0.5;\n reflectionUv += n.xz * mix(0.008, 0.022, fresnel);\n vec2 border = min(reflectionUv, 1.0 - reflectionUv);\n reflectionValid = smoothstep(0.0, 0.025, min(border.x, border.y));\n // A planar target is optically sharp, which is correct for a mirror but\n // wrong for a moving water surface. Broaden it over a Poisson footprint\n // that grows gently with viewing distance; normal displacement above\n // still makes every crest steer that softened image independently.\n float blurPixels = 1.35 + min(dist * 0.022, 3.5);\n vec3 sceneReflection = vec3(0.0);\n float filterWeight = 0.0;\n for (int i = 0; i < 9; i++) {\n if (i >= uReflectionFilterTaps) break;\n vec2 sampleUv = reflectionUv +\n REFLECTION_FILTER[i] * uReflectionTexelSize * blurPixels;\n float weight = REFLECTION_FILTER_WEIGHT[i];\n /*\n * `textureLod` rather than `texture`, by the rule `AGENTS.md` records on 2026-08-07:\n * this loop is reached through a branch on `vReflectionClip`, which is not uniform, and\n * an implicit derivative there is undefined. **WGSL does not merely leave it undefined,\n * it refuses to compile the module**: *\"'textureSample' must only be called from uniform\n * control flow\"*, with the note that reading `vReflectionClip` may be non-uniform.\n *\n * Bit-identical on WebGL2: `planarReflection.ts` allocates one level and filters\n * `LINEAR`, so there is no mip to select and level zero is the only thing `texture`\n * could have read.\n */\n sceneReflection +=\n textureLod(uReflectionMap, clamp(sampleUv, 0.0, 1.0), 0.0).rgb * weight;\n filterWeight += weight;\n }\n sceneReflection /= max(filterWeight, 0.001);\n reflected = mix(atmosphereColor, sceneReflection, reflectionValid);\n }\n // The atmosphere-colour fallback preserves the old sky reflection when a\n // quality profile disables the extra scene pass. Foam scatters rather than\n // mirroring, so it masks the reflection at breaking crests.\n float sceneWeight = float(uReflectionEnabled) * reflectionValid;\n float reflectionGain = mix(0.75, 0.94, sceneWeight);\n // Angle-honest by default, and a mirror where a body says so. See uMirror above.\n float reflectivity = mix(fresnel, 1.0, uMirror);\n float reflectedRadiance = min(\n reflectivity * reflectionGain * (1.0 - foam * 0.8),\n opacity\n );\n // With SRC_ALPHA blending, mixing by Fresnel directly would multiply the\n // reflection by opacity again. Divide it out here so the framebuffer gets\n // the intended reflected fraction while the remainder stays transmissive.\n float reflectionWeight = reflectedRadiance / max(opacity, 0.001);\n col = mix(col, reflected, reflectionWeight);\n\n /*\n * Dissolve the sheet at its own boundary.\n *\n * Fading on fog alone made the horizon a function of the daily palette: a\n * clear day never reached the threshold, so the grid's rim \u2014 corner and all \u2014\n * became the skyline. Fading on the grid coordinate cannot fail that way,\n * because alpha reaches zero at the last row of vertices by construction.\n * Chebyshev distance because the sheet is a rectangle, so the fade band\n * follows its four edges instead of cutting an ellipse out of it \u2014 and it is\n * measured per axis, or a channel's narrow sides would be one continuous fade\n * and it would have no visible edge at all.\n */\n vec2 rimFraction = abs(vGrid) / max(uGridHalf, vec2(0.001));\n float rimT = max(rimFraction.x, rimFraction.y);\n float rim = 1.0 - smoothstep(0.9, 1.0, rimT);\n // Fog still helps where it is thick, it just is no longer load-bearing.\n float edge = 1.0 - smoothstep(0.82, 1.0, fog);\n outColor = vec4(col, opacity * edge * rim * uVisibility);\n}\n";
18
+ export declare const WATER_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vWorldPos;\nin vec3 vNormal;\nin float vCrest;\nin vec4 vReflectionClip;\nin vec2 vGrid;\n\nuniform vec2 uGridHalf;\nuniform float uFoamGain;\n/**\n * How much of what is beneath it a body of water hides, looked straight down.\n *\n * The *density* of the water rather than a rendering trick: a sea hides its own\n * floor, a fountain shows the tiles at the bottom of it, and a puddle is nearly\n * clear. Only the nadir end is configurable \u2014 at a grazing angle every water\n * surface goes reflective, which is Fresnel and not a property of the liquid.\n */\nuniform float uNadirOpacity;\n/** The whole surface's visibility, 0 to 1. Zero draws nothing at all. */\nuniform float uVisibility;\n/**\n * How much this body mirrors regardless of the angle it is seen at, 0 to 1.\n *\n * Zero is physics: reflection is Fresnel alone, which is right for an ocean seen\n * across its own surface and which means *almost nothing* when you stand over a\n * basin and look down into it \u2014 the angle is steep, the Fresnel term is a few\n * percent, and a reflection nobody can see is the same as no reflection: the\n * character's reflection is technically present and far too faint to perceive.\n *\n * So a body may declare itself more mirror than water. It is a lie about optics\n * and an honest one about what a small pool is *for*: you look into it to see\n * something. The sea keeps zero and behaves exactly as it always has.\n */\nuniform float uMirror;\nuniform vec3 uDeepColor;\nuniform vec3 uShallowColor;\nuniform vec3 uDirectionalDir;\nuniform vec3 uDirectionalColor;\nuniform vec3 uAmbient;\n/**\n * The scene's point lights, which water is entitled to as much as stone is.\n *\n * The surface used to answer to the sun alone, on the reasonable assumption that water\n * is a thing that sits under the sky. It is not: a flooded corridor, a cistern, an\n * indoor pool and a harbour at night are all water lit entirely by lamps, and under\n * those this shaded to flat ambient \u2014 a coloured plane, with the specular glitter that\n * is the single strongest cue that a surface *is* water switched off because no\n * directional source existed to make it. A lantern on a jetty threw nothing.\n *\n * Same slots, same weights and the surface shader's falloffs, a light's own exponent among\n * them, so a lamp reaches as far over water as over the ground beside it. **What it gives up**:\n * a spot's cone, which water does not read, so a spot lights the water around it as a lamp\n * would; and the smooth falloff's square, which the surface applies and this has never, so under\n * that falloff a lamp's pool on water is the wider of the two.\n */\n#define MAX_LIGHTS 16\nuniform int uLightCount;\nuniform vec3 uLightPos[MAX_LIGHTS];\nuniform vec3 uLightColor[MAX_LIGHTS];\nuniform float uLightRadius[MAX_LIGHTS];\nuniform float uLightWeight[MAX_LIGHTS];\nuniform int uLightFalloff;\n/* The surface shader's cone record, of which water reads z alone: a light's own falloff exponent. */\nuniform vec4 uLightCone[MAX_LIGHTS];\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\nuniform vec3 uCameraPos;\nuniform sampler2D uReflectionMap;\nuniform int uReflectionEnabled;\nuniform vec2 uReflectionTexelSize;\nuniform int uReflectionFilterTaps;\n\nconst vec2 REFLECTION_FILTER[9] = vec2[9](\n vec2( 0.00, 0.00), vec2(-0.62, -0.31), vec2( 0.54, 0.43),\n vec2(-0.34, 0.76), vec2( 0.78, -0.57), vec2(-0.88, 0.28),\n vec2( 0.22, -0.91), vec2( 0.91, 0.08), vec2(-0.18, 0.94)\n);\n// Preserve small moving silhouettes such as a character while still spreading\n// the surrounding taps enough to make the target read as rough water.\nconst float REFLECTION_FILTER_WEIGHT[9] = float[9](\n 0.28, 0.09, 0.09, 0.09, 0.09, 0.09, 0.09, 0.09, 0.09\n);\n\nout vec4 outColor;\n\nvoid main() {\n vec3 n = normalize(vNormal);\n vec3 view = normalize(uCameraPos - vWorldPos);\n\n // Fresnel: water is nearly a mirror at grazing angles and clear straight down.\n // abs keeps the underside physically continuous with the top instead of\n // turning the entire surface into an opaque mirror below the waterline.\n float facing = clamp(abs(dot(n, view)), 0.0, 1.0);\n float fresnel = 0.02 + 0.98 * pow(1.0 - facing, 5.0);\n\n vec3 atmosphereColor = mediumColor();\n\n vec3 col = mix(uDeepColor, uShallowColor, clamp(vCrest * 1.4 + 0.4, 0.0, 1.0));\n vec3 incident = uAmbient +\n uDirectionalColor * max(dot(n, uDirectionalDir), 0.0) * 0.6;\n\n // Specular glitter along the crests \u2014 the strongest single cue that a surface\n // is water rather than a coloured plane.\n vec3 halfVec = normalize(uDirectionalDir + view);\n float spec = pow(max(dot(n, halfVec), 0.0), 220.0);\n vec3 highlight = uDirectionalColor * spec * 1.6;\n\n /*\n * And the same from every lamp in range: a light over water lights it, and glitters\n * on it. Indoors this is the whole of the surface's lighting.\n *\n * The exponent is the sun's, so a crest picks a lamp out as tightly as it picks out\n * the sky \u2014 which is what makes a strip light read as a *streak* across a pool rather\n * than a broad sheen. Weighted by presence like every other consumer of these slots,\n * so a light entering the set fades in instead of appearing.\n */\n for (int i = 0; i < MAX_LIGHTS; i++) {\n if (i >= uLightCount) break;\n vec3 toLight = uLightPos[i] - vWorldPos;\n float lightDist = length(toLight);\n float falloff;\n float exponent = uLightCone[i].z;\n if (exponent > 0.0) {\n float reach = lightDist / max(uLightRadius[i], 1e-4);\n falloff = pow(clamp(1.0 - reach * reach, 0.0, 1.0), exponent);\n } else if (uLightFalloff == 1) {\n float window = clamp(1.0 - pow(lightDist / max(uLightRadius[i], 1e-4), 4.0), 0.0, 1.0);\n falloff = window * window / max(lightDist * lightDist, 0.01);\n } else {\n falloff = clamp(1.0 - lightDist / max(uLightRadius[i], 1e-4), 0.0, 1.0);\n }\n if (falloff <= 0.0) continue;\n vec3 lightDir = toLight / max(lightDist, 1e-4);\n float shaped = falloff * uLightWeight[i];\n incident += uLightColor[i] * max(dot(n, lightDir), 0.0) * shaped * 0.6;\n vec3 lampHalf = normalize(lightDir + view);\n highlight += uLightColor[i] * pow(max(dot(n, lampHalf), 0.0), 220.0) * shaped * 1.6;\n }\n\n col *= incident;\n col += highlight;\n\n // Foam where crests peak \u2014 and only when the wind is actually breaking them.\n float foam = smoothstep(0.55, 0.85, vCrest) * uFoamGain;\n col = mix(col, vec3(0.92, 0.96, 1.0), foam * 0.5);\n\n float dist = distance(vWorldPos, uCameraPos);\n float fog = mediumFog(dist, vWorldPos.y);\n col = mix(col, atmosphereColor, fog);\n\n // Transparency is angle-dependent, like real water: looking down you see\n // straight through to the seabed, at grazing angles it turns reflective.\n // This is computed before reflection because straight-alpha compositing must\n // not attenuate reflected radiance a second time below.\n float airOpacity = mix(uNadirOpacity, 0.96, fresnel);\n float underwaterOpacity = mix(uNadirOpacity * 0.55, 0.88, fresnel);\n float opacity = mix(airOpacity, underwaterOpacity, uUnderwaterFactor);\n opacity = mix(opacity, 0.96, foam * 0.7);\n\n // Project the displaced surface into the mirrored camera. The Gerstner\n // displacement supplies broad distortion; the analytic normal adds the\n // smaller ripples that make reflected silhouettes move with each crest.\n vec3 reflected = atmosphereColor;\n float reflectionValid = 0.0;\n if (uReflectionEnabled != 0 && vReflectionClip.w > 0.0) {\n vec2 reflectionUv = vReflectionClip.xy / vReflectionClip.w * 0.5 + 0.5;\n reflectionUv += n.xz * mix(0.008, 0.022, fresnel);\n vec2 border = min(reflectionUv, 1.0 - reflectionUv);\n reflectionValid = smoothstep(0.0, 0.025, min(border.x, border.y));\n // A planar target is optically sharp, which is correct for a mirror but\n // wrong for a moving water surface. Broaden it over a Poisson footprint\n // that grows gently with viewing distance; normal displacement above\n // still makes every crest steer that softened image independently.\n float blurPixels = 1.35 + min(dist * 0.022, 3.5);\n vec3 sceneReflection = vec3(0.0);\n float filterWeight = 0.0;\n for (int i = 0; i < 9; i++) {\n if (i >= uReflectionFilterTaps) break;\n vec2 sampleUv = reflectionUv +\n REFLECTION_FILTER[i] * uReflectionTexelSize * blurPixels;\n float weight = REFLECTION_FILTER_WEIGHT[i];\n /*\n * `textureLod` rather than `texture`, by the rule `AGENTS.md` records on 2026-08-07:\n * this loop is reached through a branch on `vReflectionClip`, which is not uniform, and\n * an implicit derivative there is undefined. **WGSL does not merely leave it undefined,\n * it refuses to compile the module**: *\"'textureSample' must only be called from uniform\n * control flow\"*, with the note that reading `vReflectionClip` may be non-uniform.\n *\n * Bit-identical on WebGL2: `planarReflection.ts` allocates one level and filters\n * `LINEAR`, so there is no mip to select and level zero is the only thing `texture`\n * could have read.\n */\n sceneReflection +=\n textureLod(uReflectionMap, clamp(sampleUv, 0.0, 1.0), 0.0).rgb * weight;\n filterWeight += weight;\n }\n sceneReflection /= max(filterWeight, 0.001);\n reflected = mix(atmosphereColor, sceneReflection, reflectionValid);\n }\n // The atmosphere-colour fallback preserves the old sky reflection when a\n // quality profile disables the extra scene pass. Foam scatters rather than\n // mirroring, so it masks the reflection at breaking crests.\n float sceneWeight = float(uReflectionEnabled) * reflectionValid;\n float reflectionGain = mix(0.75, 0.94, sceneWeight);\n // Angle-honest by default, and a mirror where a body says so. See uMirror above.\n float reflectivity = mix(fresnel, 1.0, uMirror);\n float reflectedRadiance = min(\n reflectivity * reflectionGain * (1.0 - foam * 0.8),\n opacity\n );\n // With SRC_ALPHA blending, mixing by Fresnel directly would multiply the\n // reflection by opacity again. Divide it out here so the framebuffer gets\n // the intended reflected fraction while the remainder stays transmissive.\n float reflectionWeight = reflectedRadiance / max(opacity, 0.001);\n col = mix(col, reflected, reflectionWeight);\n\n /*\n * Dissolve the sheet at its own boundary.\n *\n * Fading on fog alone made the horizon a function of the daily palette: a\n * clear day never reached the threshold, so the grid's rim \u2014 corner and all \u2014\n * became the skyline. Fading on the grid coordinate cannot fail that way,\n * because alpha reaches zero at the last row of vertices by construction.\n * Chebyshev distance because the sheet is a rectangle, so the fade band\n * follows its four edges instead of cutting an ellipse out of it \u2014 and it is\n * measured per axis, or a channel's narrow sides would be one continuous fade\n * and it would have no visible edge at all.\n */\n vec2 rimFraction = abs(vGrid) / max(uGridHalf, vec2(0.001));\n float rimT = max(rimFraction.x, rimFraction.y);\n float rim = 1.0 - smoothstep(0.9, 1.0, rimT);\n // Fog still helps where it is thick, it just is no longer load-bearing.\n float edge = 1.0 - smoothstep(0.82, 1.0, fog);\n outColor = vec4(col, opacity * edge * rim * uVisibility);\n}\n";