@driftengine/core 4.13.4 → 4.13.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.json +36 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +3 -2
  4. package/dist/render/anisotropicLobe.d.ts +5 -0
  5. package/dist/render/anisotropicLobe.js +13 -0
  6. package/dist/render/atmosphere.d.ts +79 -1
  7. package/dist/render/atmosphere.js +66 -19
  8. package/dist/render/backend/webgl2/renderer.d.ts +34 -2
  9. package/dist/render/backend/webgl2/renderer.js +44 -12
  10. package/dist/render/backend/webgpu/boltPass.d.ts +1 -1
  11. package/dist/render/backend/webgpu/causticsPass.d.ts +1 -1
  12. package/dist/render/backend/webgpu/filmPass.d.ts +1 -1
  13. package/dist/render/backend/webgpu/gpuDrivenPass.js +20 -3
  14. package/dist/render/backend/webgpu/linePass.d.ts +1 -1
  15. package/dist/render/backend/webgpu/mediumFields.d.ts +17 -0
  16. package/dist/render/backend/webgpu/mediumFields.js +74 -0
  17. package/dist/render/backend/webgpu/plumePass.d.ts +3 -3
  18. package/dist/render/backend/webgpu/postPass.d.ts +7 -2
  19. package/dist/render/backend/webgpu/renderer.d.ts +13 -3
  20. package/dist/render/backend/webgpu/renderer.js +86 -150
  21. package/dist/render/backend/webgpu/scatterPass.d.ts +1 -1
  22. package/dist/render/backend/webgpu/waterPass.d.ts +1 -1
  23. package/dist/render/environmentMixing.d.ts +18 -0
  24. package/dist/render/environmentMixing.js +40 -0
  25. package/dist/render/fog.d.ts +43 -1
  26. package/dist/render/fog.js +87 -22
  27. package/dist/render/globalMedium.d.ts +112 -10
  28. package/dist/render/globalMedium.js +96 -1
  29. package/dist/render/globalMediumPass.d.ts +7 -3
  30. package/dist/render/globalMediumPass.js +22 -1
  31. package/dist/render/hairLobes.d.ts +5 -3
  32. package/dist/render/hairLobes.js +11 -7
  33. package/dist/render/lightBudget.d.ts +12 -0
  34. package/dist/render/lightBudget.js +24 -0
  35. package/dist/render/sceneTarget.d.ts +5 -3
  36. package/dist/render/sceneTarget.js +14 -8
  37. package/dist/render/shaders/arcane.d.ts +1 -1
  38. package/dist/render/shaders/bolt.d.ts +1 -1
  39. package/dist/render/shaders/caustics.d.ts +1 -1
  40. package/dist/render/shaders/film.d.ts +1 -1
  41. package/dist/render/shaders/film.js +4 -1
  42. package/dist/render/shaders/fire.d.ts +1 -1
  43. package/dist/render/shaders/flat/anisotropic.d.ts +8 -1
  44. package/dist/render/shaders/flat/anisotropic.js +25 -3
  45. package/dist/render/shaders/flat/hair.js +31 -7
  46. package/dist/render/shaders/flat/main.d.ts +1 -1
  47. package/dist/render/shaders/flat/main.js +20 -1
  48. package/dist/render/shaders/flat/models.js +1 -1
  49. package/dist/render/shaders/flat/preamble.js +7 -0
  50. package/dist/render/shaders/fog.d.ts +4 -4
  51. package/dist/render/shaders/fog.js +81 -32
  52. package/dist/render/shaders/generated/arcane.wgsl.d.ts +29 -39
  53. package/dist/render/shaders/generated/arcane.wgsl.js +29 -39
  54. package/dist/render/shaders/generated/bolt.wgsl.d.ts +26 -36
  55. package/dist/render/shaders/generated/bolt.wgsl.js +26 -36
  56. package/dist/render/shaders/generated/caustics.wgsl.d.ts +25 -35
  57. package/dist/render/shaders/generated/caustics.wgsl.js +25 -35
  58. package/dist/render/shaders/generated/film.wgsl.d.ts +28 -38
  59. package/dist/render/shaders/generated/film.wgsl.js +28 -38
  60. package/dist/render/shaders/generated/fire.wgsl.d.ts +28 -38
  61. package/dist/render/shaders/generated/fire.wgsl.js +28 -38
  62. package/dist/render/shaders/generated/flat.wgsl.d.ts +1466 -1546
  63. package/dist/render/shaders/generated/flat.wgsl.js +1489 -1569
  64. package/dist/render/shaders/generated/globalMedium.wgsl.d.ts +98 -12
  65. package/dist/render/shaders/generated/globalMedium.wgsl.js +98 -12
  66. package/dist/render/shaders/generated/line.wgsl.d.ts +25 -35
  67. package/dist/render/shaders/generated/line.wgsl.js +25 -35
  68. package/dist/render/shaders/generated/particle.wgsl.d.ts +100 -140
  69. package/dist/render/shaders/generated/particle.wgsl.js +100 -140
  70. package/dist/render/shaders/generated/rush.wgsl.d.ts +20 -10
  71. package/dist/render/shaders/generated/rush.wgsl.js +20 -10
  72. package/dist/render/shaders/generated/scatter.wgsl.d.ts +25 -35
  73. package/dist/render/shaders/generated/scatter.wgsl.js +25 -35
  74. package/dist/render/shaders/generated/smoke.wgsl.d.ts +28 -38
  75. package/dist/render/shaders/generated/smoke.wgsl.js +28 -38
  76. package/dist/render/shaders/generated/water.wgsl.d.ts +29 -39
  77. package/dist/render/shaders/generated/water.wgsl.js +29 -39
  78. package/dist/render/shaders/globalMedium.js +114 -10
  79. package/dist/render/shaders/gpudriven/blendRaster.wgsl.js +2 -1
  80. package/dist/render/shaders/gpudriven/fog.wgsl.d.ts +10 -4
  81. package/dist/render/shaders/gpudriven/fog.wgsl.js +77 -19
  82. package/dist/render/shaders/gpudriven/frameBlock.wgsl.d.ts +1 -1
  83. package/dist/render/shaders/gpudriven/frameBlock.wgsl.js +12 -1
  84. package/dist/render/shaders/gpudriven/shade.wgsl.js +3 -1
  85. package/dist/render/shaders/line.d.ts +1 -1
  86. package/dist/render/shaders/line.js +4 -2
  87. package/dist/render/shaders/localExposure.d.ts +42 -1
  88. package/dist/render/shaders/localExposure.js +73 -6
  89. package/dist/render/shaders/particle.d.ts +3 -3
  90. package/dist/render/shaders/particle.js +10 -6
  91. package/dist/render/shaders/scatter.d.ts +1 -1
  92. package/dist/render/shaders/scatter.js +4 -2
  93. package/dist/render/shaders/smoke.d.ts +1 -1
  94. package/dist/render/shaders/water.d.ts +1 -1
  95. package/dist/render/shaders/water.js +2 -1
  96. package/dist/render/surfaceModel.d.ts +23 -4
  97. package/dist/render/surfaceModel.js +12 -2
  98. package/package.json +6 -6
  99. package/src/index.ts +12 -2
  100. package/src/render/anisotropicLobe.ts +20 -0
  101. package/src/render/atmosphere.ts +153 -20
  102. package/src/render/backend/webgl2/renderer.ts +63 -13
  103. package/src/render/backend/webgpu/gpuDrivenPass.ts +20 -3
  104. package/src/render/backend/webgpu/mediumFields.ts +87 -0
  105. package/src/render/backend/webgpu/renderer.ts +109 -157
  106. package/src/render/environmentMixing.ts +48 -0
  107. package/src/render/fog.ts +125 -25
  108. package/src/render/globalMedium.ts +214 -11
  109. package/src/render/globalMediumPass.ts +29 -4
  110. package/src/render/hairLobes.ts +35 -4
  111. package/src/render/lightBudget.ts +32 -0
  112. package/src/render/sceneTarget.ts +15 -11
  113. package/src/render/shaders/film.ts +4 -1
  114. package/src/render/shaders/flat/anisotropic.ts +25 -3
  115. package/src/render/shaders/flat/hair.ts +31 -7
  116. package/src/render/shaders/flat/main.ts +20 -1
  117. package/src/render/shaders/flat/models.ts +1 -1
  118. package/src/render/shaders/flat/preamble.ts +7 -0
  119. package/src/render/shaders/fog.ts +81 -32
  120. package/src/render/shaders/generated/arcane.wgsl.ts +29 -39
  121. package/src/render/shaders/generated/bolt.wgsl.ts +26 -36
  122. package/src/render/shaders/generated/caustics.wgsl.ts +25 -35
  123. package/src/render/shaders/generated/film.wgsl.ts +28 -38
  124. package/src/render/shaders/generated/fire.wgsl.ts +28 -38
  125. package/src/render/shaders/generated/flat.wgsl.ts +1489 -1569
  126. package/src/render/shaders/generated/globalMedium.wgsl.ts +98 -12
  127. package/src/render/shaders/generated/line.wgsl.ts +25 -35
  128. package/src/render/shaders/generated/particle.wgsl.ts +100 -140
  129. package/src/render/shaders/generated/rush.wgsl.ts +20 -10
  130. package/src/render/shaders/generated/scatter.wgsl.ts +25 -35
  131. package/src/render/shaders/generated/smoke.wgsl.ts +28 -38
  132. package/src/render/shaders/generated/water.wgsl.ts +29 -39
  133. package/src/render/shaders/globalMedium.ts +114 -10
  134. package/src/render/shaders/gpudriven/blendRaster.wgsl.ts +2 -1
  135. package/src/render/shaders/gpudriven/fog.wgsl.ts +78 -19
  136. package/src/render/shaders/gpudriven/frameBlock.wgsl.ts +12 -1
  137. package/src/render/shaders/gpudriven/shade.wgsl.ts +3 -1
  138. package/src/render/shaders/line.ts +4 -2
  139. package/src/render/shaders/localExposure.ts +115 -6
  140. package/src/render/shaders/particle.ts +10 -6
  141. package/src/render/shaders/scatter.ts +4 -2
  142. package/src/render/shaders/water.ts +2 -1
  143. package/src/render/surfaceModel.ts +41 -6
@@ -9,4 +9,4 @@
9
9
  */
10
10
  /** Shared billboard vertex stage: used by fire, smoke and any future plume. */
11
11
  export declare const PLUME_VERT = "#version 300 es\nlayout(location = 0) in vec3 aCenter;\nlayout(location = 1) in vec2 aCorner;\nlayout(location = 2) in vec2 aSize;\nlayout(location = 3) in float aSeed;\nlayout(location = 4) in float aBlade;\n\nuniform mat4 uViewProj;\nuniform vec3 uCameraPos;\nuniform float uTime;\n/** Amplitude of the size pulse; 0 keeps the quad a fixed size. */\nuniform float uSizePulse;\n/**\n * Horizontal wind, m/s. Plumes lean with it, increasingly toward their top \u2014\n * the base is anchored to whatever is burning, the crown is free. Sharing one\n * wind with the rest of the world is the point: smoke drifting one way while\n * foliage leans another is the clearest tell that a scene is a pile of effects\n * rather than a place.\n */\nuniform vec2 uWind;\n/**\n * Whole-batch offset. Plume centres live in a static buffer because braziers\n * never move; a batch that *does* move \u2014 a tornado \u2014 shifts by a uniform rather\n * than rewriting geometry every frame.\n */\nuniform vec3 uOrigin;\n/** How strongly this plume type answers the wind; smoke far more than flame. */\nuniform float uWindResponse;\n\nout vec2 vUv;\nout float vSeed;\nout float vDistance;\nout vec3 vWorldPos;\n\n/**\n * Plumes are not a constant size: a fire flares and dies back, a smoke column\n * swells and thins. Two out-of-phase sines, offset per plume, so the whole\n * effect breathes rather than only its interior pattern animating.\n */\nfloat sizePulse(float seed) {\n return 1.0 + uSizePulse * (\n sin(uTime * 1.7 + seed * 9.1) * 0.62 +\n sin(uTime * 3.3 + seed * 4.7) * 0.38\n );\n}\n\nvoid main() {\n // Height swells more than width, the way a real flame stretches upward.\n vec3 anchor = aCenter + uOrigin;\n\n /*\n * No billboarding at all: the blade's heading is fixed in the world.\n *\n * Each plume is drawn as a CROSS of two blades 90 degrees apart (see BLADES in\n * plumeRenderer), and aSeed advances by the golden ratio per plume, so the crosses\n * themselves are scattered by the golden angle rather than aligned into a grid.\n *\n * Nothing here reads the camera, and that is the fix. Anything that does - a full\n * billboard, or merely leaning edge-on cards toward the viewer - rotates geometry\n * as the camera moves, which is exactly what reads as flat: orbiting a fire showed\n * the same silhouette from every angle. A cross cannot track the viewer, so turning\n * around a fire genuinely brings one blade broadside while the other goes edge-on.\n * That is the parallax a volume has, and it is also why no blade needs to be turned\n * to stay visible - its partner is square-on whenever it is not.\n *\n * World up stays the vertical axis: a flame that tilts with the camera reads as a\n * decal.\n */\n vec3 up = vec3(0.0, 1.0, 0.0);\n float yaw = aSeed * 6.28318530718 + aBlade * 1.57079632679;\n vec3 right = vec3(cos(yaw), 0.0, sin(yaw));\n float pulse = sizePulse(aSeed);\n vec2 size = vec2(aSize.x * mix(1.0, pulse, 0.55), aSize.y * pulse);\n\n float rise = aCorner.y * 0.5 + 0.5;\n vec3 world = anchor\n + right * (aCorner.x * size.x)\n + up * (rise * size.y);\n\n // Quadratic in height: a column bends away from its anchor rather than\n // shearing uniformly, which would slide its base off the fire.\n world.xz += uWind * (uWindResponse * rise * rise * size.y);\n\n vUv = vec2(aCorner.x * 0.5 + 0.5, aCorner.y * 0.5 + 0.5);\n vSeed = aSeed;\n vDistance = distance(world, uCameraPos);\n vWorldPos = world;\n gl_Position = uViewProj * vec4(world, 1.0);\n}\n";
12
- export declare const FIRE_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\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat valueNoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n 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\n/** Rising turbulence: octaves scrolled upward at different rates. */\nfloat turbulence(vec2 p, float t) {\n float v = valueNoise(p * 3.0 + vec2(0.0, -t * 1.9)) * 0.55;\n if (uNoiseOctaves == 1) return v / 0.55;\n v += valueNoise(p * 6.5 + vec2(t * 0.3, -t * 3.1)) * 0.28;\n if (uNoiseOctaves == 2) return v / 0.83;\n return v + valueNoise(p * 13.0 + vec2(-t * 0.2, -t * 4.6)) * 0.17;\n}\n\nvoid main() {\n if (uClipEnabled != 0 && dot(vec4(vWorldPos, 1.0), uClipPlane) < 0.0) discard;\n\n vec2 uv = vUv;\n float t = uTime + vSeed * 37.0;\n\n // Taper toward the tip and pinch the sides, so the quad never shows.\n float sides = 1.0 - abs(uv.x - 0.5) * 2.0;\n float taper = sides * (1.0 - uv.y * 0.72);\n if (taper <= 0.0) discard;\n\n // Sway: the column leans and wanders instead of standing to attention.\n float sway = (valueNoise(vec2(t * 0.55, vSeed * 11.0)) - 0.5) * 0.34 * uv.y;\n vec2 p = vec2(uv.x + sway, uv.y);\n\n float n = turbulence(p, t);\n // Flame body: noise must beat a threshold that rises with height, which is\n // what makes the tip break into separate licks.\n float body = n * taper * 1.9 - uv.y * 0.55;\n if (body <= 0.02) discard;\n\n // Colour ramp: white-hot core through yellow and orange to a dark red edge.\n float heat = clamp(body * 1.5, 0.0, 1.0);\n vec3 col = mix(vec3(0.62, 0.06, 0.02), vec3(1.0, 0.45, 0.06), smoothstep(0.0, 0.45, heat));\n col = mix(col, vec3(1.0, 0.85, 0.35), smoothstep(0.45, 0.75, heat));\n col = mix(col, vec3(1.0, 0.98, 0.88), smoothstep(0.78, 1.0, heat));\n\n float alpha = clamp(body * 2.2, 0.0, 1.0) * (1.0 - smoothstep(0.72, 1.0, uv.y));\n // Additive light must lose alpha as it scatters, or mixing its colour alone\n // still makes a distant flame glow through fog and through water.\n float fog = mediumFog(vDistance, vWorldPos.y);\n col = mix(col, mediumColor(), fog);\n alpha *= 1.0 - fog;\n\n /* Graded where nothing after this will grade it, as every pass is. The alpha is coverage. */\n outColor = vec4(applyOutputTransform(col), alpha);\n}\n";
12
+ export declare const FIRE_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/**\n * The haze: its extinction per metre **at the camera's own height**, not at the base; its\n * reciprocal scale height per metre, 0 for a uniform medium; the camera's height, so a fragment's\n * own height is one subtraction away; and the water column's own density.\n *\n * **Four numbers in one vector rather than four uniforms**, and the reason is a phone: a scalar\n * costs a whole row of the uniform grid, and the lit stage at its eight-light rung must fit the 256\n * rows an Adreno 740 offers (uniformVectorBudget.ts). Packed, the haze's eight scalars cost two\n * rows where they cost eight, which is what paid for the second layer and the sun's glow below.\n */\nuniform vec4 uFogMedium;\n/** Where haze begins, where a linear ramp is total, and the mode: 0 a medium, 1 a ramp. */\nuniform vec4 uFogRange;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFactor;\n/**\n * The second layer's extinction per metre at the camera's height and its falloff; then the least of\n * a surface the haze must leave, Atmosphere.fogMaxOpacity's complement, and the distance past which\n * it hides nothing (0 is none). **Every lane is none at zero**, and so are uFogGlow's: a program a\n * binder never wrote these into draws the haze it drew before they existed, rather than none.\n */\nuniform vec4 uFogLayer2;\n/** The sun's glow in the haze: its colour, black for none, and the exponent of its lobe. */\nuniform vec4 uFogGlow;\n/** Surface \u2192 the glow's light, and how far past the haze's start the glow begins. */\nuniform vec4 uFogGlowDir;\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 * The haze's optical depth from where it begins along the ray to a point at height pointY, dist\n * metres away: each layer's density at the eye carried over what is left of the ray, with the\n * second layer only where there is one. The fog and the sun's glow both integrate it.\n */\nfloat hazeDepth(float dist, float pointY) {\n // Where the medium begins along the ray: uFogRange.x, 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(uFogRange.x, dist);\n float before = dist > 0.0 ? start / dist : 0.0;\n float t = (pointY - uFogMedium.z) * uFogMedium.y;\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 depth = uFogMedium.x * exp(-t * before) * (dist - start) * (1.0 - exp(-denom)) / denom;\n if (uFogLayer2.x > 0.0) {\n float t2 = (pointY - uFogMedium.z) * uFogLayer2.y;\n float rest2 = t2 * (1.0 - before);\n float denom2 = abs(rest2) < 1e-4 ? 1e-4 : rest2;\n depth += uFogLayer2.x * exp(-t2 * before) * (dist - start) * (1.0 - exp(-denom2)) / denom2;\n }\n return depth;\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 /* Past the cutoff the air hides nothing, in either mode; the water column keeps its own law. */\n bool cut = uFogLayer2.w > 0.0 && dist > uFogLayer2.w;\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 (uFogRange.z == 1.0) {\n float span = max(uFogRange.y - uFogRange.x, 1e-4);\n float ramp = cut ? 0.0 : min(clamp((dist - uFogRange.x) / span, 0.0, 1.0), 1.0 - uFogLayer2.z);\n if (uUnderwaterFactor <= 0.0) return ramp;\n float wetLinear = uFogMedium.w * dist;\n return mix(ramp, 1.0 - exp2(-wetLinear * wetLinear * 1.442695), uUnderwaterFactor);\n }\n\n // Beer-Lambert through both layers, leaving at least uFogLayer2.z of the surface: a floor on\n // what survives rather than a ceiling on the depth, so the curve keeps its shape above it.\n float air = cut ? 0.0 : 1.0 - max(exp(-hazeDepth(dist, pointY)), uFogLayer2.z);\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 = uFogMedium.w * dist;\n return mix(air, 1.0 - exp2(-wet * wet * 1.442695), uUnderwaterFactor);\n}\n\n/**\n * The sun's light the haze scatters toward the eye along a ray to a point at height pointY, dist\n * metres away in the direction toPoint: added to a surface after its fog, never mixed. A lobe of\n * cos^exponent about the light, by as much of the ray past the glow's own start as the haze hides \u2014\n * the haze's depth per metre carried over that stretch. None in a linear ramp, past the cutoff, or\n * under water, which keeps its own medium.\n */\nvec3 mediumGlow(float dist, float pointY, vec3 toPoint) {\n if (uFogRange.z == 1.0 || max(max(uFogGlow.r, uFogGlow.g), uFogGlow.b) <= 0.0) return vec3(0.0);\n if (uFogLayer2.w > 0.0 && dist > uFogLayer2.w) return vec3(0.0);\n float span = dist - min(uFogRange.x, dist);\n if (span <= 0.0) return vec3(0.0);\n float perMetre = hazeDepth(dist, pointY) / span;\n float hidden = 1.0 - exp(-perMetre * max(span - uFogGlowDir.w, 0.0));\n float lobe = pow(clamp(dot(toPoint, uFogGlowDir.xyz), 0.0, 1.0), uFogGlow.a);\n return uFogGlow.rgb * (lobe * hidden * (1.0 - uUnderwaterFactor));\n}\n\nuniform int uNoiseOctaves;\nuniform vec4 uClipPlane;\nuniform int uClipEnabled;\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat valueNoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n 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\n/** Rising turbulence: octaves scrolled upward at different rates. */\nfloat turbulence(vec2 p, float t) {\n float v = valueNoise(p * 3.0 + vec2(0.0, -t * 1.9)) * 0.55;\n if (uNoiseOctaves == 1) return v / 0.55;\n v += valueNoise(p * 6.5 + vec2(t * 0.3, -t * 3.1)) * 0.28;\n if (uNoiseOctaves == 2) return v / 0.83;\n return v + valueNoise(p * 13.0 + vec2(-t * 0.2, -t * 4.6)) * 0.17;\n}\n\nvoid main() {\n if (uClipEnabled != 0 && dot(vec4(vWorldPos, 1.0), uClipPlane) < 0.0) discard;\n\n vec2 uv = vUv;\n float t = uTime + vSeed * 37.0;\n\n // Taper toward the tip and pinch the sides, so the quad never shows.\n float sides = 1.0 - abs(uv.x - 0.5) * 2.0;\n float taper = sides * (1.0 - uv.y * 0.72);\n if (taper <= 0.0) discard;\n\n // Sway: the column leans and wanders instead of standing to attention.\n float sway = (valueNoise(vec2(t * 0.55, vSeed * 11.0)) - 0.5) * 0.34 * uv.y;\n vec2 p = vec2(uv.x + sway, uv.y);\n\n float n = turbulence(p, t);\n // Flame body: noise must beat a threshold that rises with height, which is\n // what makes the tip break into separate licks.\n float body = n * taper * 1.9 - uv.y * 0.55;\n if (body <= 0.02) discard;\n\n // Colour ramp: white-hot core through yellow and orange to a dark red edge.\n float heat = clamp(body * 1.5, 0.0, 1.0);\n vec3 col = mix(vec3(0.62, 0.06, 0.02), vec3(1.0, 0.45, 0.06), smoothstep(0.0, 0.45, heat));\n col = mix(col, vec3(1.0, 0.85, 0.35), smoothstep(0.45, 0.75, heat));\n col = mix(col, vec3(1.0, 0.98, 0.88), smoothstep(0.78, 1.0, heat));\n\n float alpha = clamp(body * 2.2, 0.0, 1.0) * (1.0 - smoothstep(0.72, 1.0, uv.y));\n // Additive light must lose alpha as it scatters, or mixing its colour alone\n // still makes a distant flame glow through fog and through water.\n float fog = mediumFog(vDistance, vWorldPos.y);\n col = mix(col, mediumColor(), fog);\n alpha *= 1.0 - fog;\n\n /* Graded where nothing after this will grade it, as every pass is. The alpha is coverage. */\n outColor = vec4(applyOutputTransform(col), alpha);\n}\n";
@@ -11,5 +11,12 @@
11
11
  *
12
12
  * Lamps widen both of the lobe's widths by the source's angle, as `sphereLobe` widens the standard
13
13
  * lobe, with the energy kept; at strength 0 that is `sphereLobe` exactly.
14
+ *
15
+ * **A lift**, where the model asks for one: the distribution raised where it is bright, `D +
16
+ * min(3, D^1.5) · k`, which is how a brushed finish that reads as polished in its streak is
17
+ * authored. `k` is the model's `lift`, times the model map's alpha where one is bound, so it can
18
+ * vary texel by texel. Applied as a factor of the distribution at the half vector, so the
19
+ * peak-normalised lobe and the physical one lift alike. **What it gives up** is energy: a lifted
20
+ * lobe returns more than arrives, which is the look it is for.
14
21
  */
15
- export declare const ANISOTROPIC_GLSL = "\n/* The stretch's frame and strength, gathered once with the surface. */\nvec3 aAlong;\nvec3 aAcross;\nfloat aStrength;\n\nvoid anisotropicSurface() {\n vec2 direction = vec2(1.0, 0.0);\n float strength = uModelParams[0].x;\n if (uModelParams[1].w > 0.0) {\n vec2 mapped = mMap.rg * 2.0 - 1.0;\n direction = dot(mapped, mapped) > 1e-8 ? normalize(mapped) : direction;\n strength *= mMap.b;\n }\n vec2 turned = vec2(\n direction.x * uModelParams[0].y - direction.y * uModelParams[0].z,\n direction.x * uModelParams[0].z + direction.y * uModelParams[0].y\n );\n vec3 along = mTangent * turned.x + mBitangent * turned.y;\n /*\n * A mesh with neither tangents nor texture coordinates has no frame to take a direction from,\n * and normalising the zero it gives was a NaN that drew the whole surface black. Any direction in\n * the surface stands in, the one across the world's up, so the highlight is stretched somewhere.\n */\n if (dot(along, along) < 1e-12) {\n along = cross(abs(mNormal.y) < 0.999 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0), mNormal);\n }\n aAlong = normalize(along);\n aAcross = cross(mNormal, aAlong);\n aStrength = strength;\n}\n\n/** The stretched lobe at a half vector, 1 at the mirror direction: anisotropicLobe.ts. */\nfloat anisotropicLobe(vec3 h, float sourceRadius, float dist) {\n float alpha = max(mRoughness * mRoughness, MIN_LOBE_ALPHA);\n float alongWidth = alpha + (1.0 - alpha) * aStrength * aStrength;\n float grow = sourceRadius / max(2.0 * dist, 1e-3);\n float alongWide = clamp(alongWidth + grow, alongWidth, 1.0);\n float acrossWide = clamp(alpha + grow, alpha, 1.0);\n float energy = (alongWidth / alongWide) * (alpha / acrossWide);\n float x = dot(aAlong, h) / alongWide;\n float y = dot(aAcross, h) / acrossWide;\n float z = dot(mNormal, h);\n float d = x * x + y * y + z * z;\n return energy / max(d * d, 1e-8);\n}\n\n/**\n * The same stretch as GGX's own BRDF, `\u03C0 \u00B7 D \u00B7 Vis \u00B7 N\u00B7L`, for a surface whose highlight is\n * physical: the distribution normalised, and Smith's height-correlated masking stretched with it\n * (Heitz 2014, as Filament writes it). `anisotropicPhysicalLobe` in anisotropicLobe.ts.\n */\nfloat anisotropicPhysical(vec3 l, vec3 h, float ndl, float sourceRadius, float dist) {\n float alpha = max(mRoughness * mRoughness, MIN_LOBE_ALPHA);\n float alongWidth = alpha + (1.0 - alpha) * aStrength * aStrength;\n float grow = sourceRadius / max(2.0 * dist, 1e-3);\n float along = clamp(alongWidth + grow, alongWidth, 1.0);\n float across = clamp(alpha + grow, alpha, 1.0);\n float x = dot(aAlong, h) / along;\n float y = dot(aAcross, h) / across;\n float z = dot(mNormal, h);\n float d = x * x + y * y + z * z;\n float distribution = 1.0 / max(3.14159265 * along * across * d * d, 1e-8);\n float ndv = max(dot(mNormal, mToEye), 1e-4);\n float viewed = ndl * length(vec3(along * dot(aAlong, mToEye), across * dot(aAcross, mToEye), ndv));\n float lit = ndv * length(vec3(along * dot(aAlong, l), across * dot(aAcross, l), ndl));\n return 3.14159265 * distribution * (0.5 / max(viewed + lit, 1e-5)) * ndl;\n}\n\nvoid anisotropicLight(vec3 l, float sourceRadius, float dist) {\n float ndl = max(dot(mNormal, l), 0.0);\n vec3 h = normalize(l + mToEye);\n vec3 tint = highlightTint(mSpecColor, max(dot(mToEye, h), 0.0), mMetal);\n mDiffuse = mAlbedo * ndl * (1.0 - mMetal);\n float lobe = physicalSpecular()\n ? anisotropicPhysical(l, h, ndl, sourceRadius, dist)\n : anisotropicLobe(h, sourceRadius, dist);\n mSpecular = ndl > 0.0 ? tint * lobe : vec3(0.0);\n}\n\n/* The environment's normal, bent toward the stretch by its strength and the surface's roughness. */\nvec3 anisotropicReflectNormal(vec3 n) {\n vec3 across = cross(aAcross, mToEye);\n vec3 bent = cross(across, aAcross);\n float bend = aStrength * clamp(5.0 * mRoughness, 0.0, 1.0);\n return normalize(mix(n, bent, bend));\n}\n";
22
+ export declare const ANISOTROPIC_GLSL = "\n/* The stretch's frame, strength and lift, gathered once with the surface. */\nvec3 aAlong;\nvec3 aAcross;\nfloat aStrength;\nfloat aLift;\n\nvoid anisotropicSurface() {\n vec2 direction = vec2(1.0, 0.0);\n float strength = uModelParams[0].x;\n aLift = uModelParams[0].w;\n if (uModelParams[1].w > 0.0) {\n vec2 mapped = mMap.rg * 2.0 - 1.0;\n direction = dot(mapped, mapped) > 1e-8 ? normalize(mapped) : direction;\n strength *= mMap.b;\n aLift *= mMap.a;\n }\n vec2 turned = vec2(\n direction.x * uModelParams[0].y - direction.y * uModelParams[0].z,\n direction.x * uModelParams[0].z + direction.y * uModelParams[0].y\n );\n vec3 along = mTangent * turned.x + mBitangent * turned.y;\n /*\n * A mesh with neither tangents nor texture coordinates has no frame to take a direction from,\n * and normalising the zero it gives was a NaN that drew the whole surface black. Any direction in\n * the surface stands in, the one across the world's up, so the highlight is stretched somewhere.\n */\n if (dot(along, along) < 1e-12) {\n along = cross(abs(mNormal.y) < 0.999 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0), mNormal);\n }\n aAlong = normalize(along);\n aAcross = cross(mNormal, aAlong);\n aStrength = strength;\n}\n\n/**\n * How much the lift brightens the lobe at a half vector, from the stretched distribution there, D,\n * at widths `along` and `across` and `d` its denominator's root: `(D + min(3, D^1.5) k) / D`.\n * anisotropicLiftFactor in anisotropicLobe.ts.\n */\nfloat anisotropicLift(float along, float across, float d) {\n if (aLift <= 0.0) return 1.0;\n float distribution = 1.0 / max(3.14159265 * along * across * d * d, 1e-8);\n return 1.0 + aLift * min(3.0 / distribution, sqrt(distribution));\n}\n\n/** The stretched lobe at a half vector, 1 at the mirror direction: anisotropicLobe.ts. */\nfloat anisotropicLobe(vec3 h, float sourceRadius, float dist) {\n float alpha = max(mRoughness * mRoughness, MIN_LOBE_ALPHA);\n float alongWidth = alpha + (1.0 - alpha) * aStrength * aStrength;\n float grow = sourceRadius / max(2.0 * dist, 1e-3);\n float alongWide = clamp(alongWidth + grow, alongWidth, 1.0);\n float acrossWide = clamp(alpha + grow, alpha, 1.0);\n float energy = (alongWidth / alongWide) * (alpha / acrossWide);\n float x = dot(aAlong, h) / alongWide;\n float y = dot(aAcross, h) / acrossWide;\n float z = dot(mNormal, h);\n float d = x * x + y * y + z * z;\n return energy / max(d * d, 1e-8) * anisotropicLift(alongWide, acrossWide, d);\n}\n\n/**\n * The same stretch as GGX's own BRDF, `\u03C0 \u00B7 D \u00B7 Vis \u00B7 N\u00B7L`, for a surface whose highlight is\n * physical: the distribution normalised, and Smith's height-correlated masking stretched with it\n * (Heitz 2014, as Filament writes it). `anisotropicPhysicalLobe` in anisotropicLobe.ts.\n */\nfloat anisotropicPhysical(vec3 l, vec3 h, float ndl, float sourceRadius, float dist) {\n float alpha = max(mRoughness * mRoughness, MIN_LOBE_ALPHA);\n float alongWidth = alpha + (1.0 - alpha) * aStrength * aStrength;\n float grow = sourceRadius / max(2.0 * dist, 1e-3);\n float along = clamp(alongWidth + grow, alongWidth, 1.0);\n float across = clamp(alpha + grow, alpha, 1.0);\n float x = dot(aAlong, h) / along;\n float y = dot(aAcross, h) / across;\n float z = dot(mNormal, h);\n float d = x * x + y * y + z * z;\n float distribution =\n 1.0 / max(3.14159265 * along * across * d * d, 1e-8) * anisotropicLift(along, across, d);\n float ndv = max(dot(mNormal, mToEye), 1e-4);\n float viewed = ndl * length(vec3(along * dot(aAlong, mToEye), across * dot(aAcross, mToEye), ndv));\n float lit = ndv * length(vec3(along * dot(aAlong, l), across * dot(aAcross, l), ndl));\n return 3.14159265 * distribution * (0.5 / max(viewed + lit, 1e-5)) * ndl;\n}\n\nvoid anisotropicLight(vec3 l, float sourceRadius, float dist) {\n float ndl = max(dot(mNormal, l), 0.0);\n vec3 h = normalize(l + mToEye);\n vec3 tint = highlightTint(mSpecColor, max(dot(mToEye, h), 0.0), mMetal);\n mDiffuse = mAlbedo * ndl * (1.0 - mMetal);\n float lobe = physicalSpecular()\n ? anisotropicPhysical(l, h, ndl, sourceRadius, dist)\n : anisotropicLobe(h, sourceRadius, dist);\n mSpecular = ndl > 0.0 ? tint * lobe : vec3(0.0);\n}\n\n/* The environment's normal, bent toward the stretch by its strength and the surface's roughness. */\nvec3 anisotropicReflectNormal(vec3 n) {\n vec3 across = cross(aAcross, mToEye);\n vec3 bent = cross(across, aAcross);\n float bend = aStrength * clamp(5.0 * mRoughness, 0.0, 1.0);\n return normalize(mix(n, bent, bend));\n}\n";
@@ -11,20 +11,30 @@
11
11
  *
12
12
  * Lamps widen both of the lobe's widths by the source's angle, as `sphereLobe` widens the standard
13
13
  * lobe, with the energy kept; at strength 0 that is `sphereLobe` exactly.
14
+ *
15
+ * **A lift**, where the model asks for one: the distribution raised where it is bright, `D +
16
+ * min(3, D^1.5) · k`, which is how a brushed finish that reads as polished in its streak is
17
+ * authored. `k` is the model's `lift`, times the model map's alpha where one is bound, so it can
18
+ * vary texel by texel. Applied as a factor of the distribution at the half vector, so the
19
+ * peak-normalised lobe and the physical one lift alike. **What it gives up** is energy: a lifted
20
+ * lobe returns more than arrives, which is the look it is for.
14
21
  */
15
22
  export const ANISOTROPIC_GLSL = /* glsl */ `
16
- /* The stretch's frame and strength, gathered once with the surface. */
23
+ /* The stretch's frame, strength and lift, gathered once with the surface. */
17
24
  vec3 aAlong;
18
25
  vec3 aAcross;
19
26
  float aStrength;
27
+ float aLift;
20
28
 
21
29
  void anisotropicSurface() {
22
30
  vec2 direction = vec2(1.0, 0.0);
23
31
  float strength = uModelParams[0].x;
32
+ aLift = uModelParams[0].w;
24
33
  if (uModelParams[1].w > 0.0) {
25
34
  vec2 mapped = mMap.rg * 2.0 - 1.0;
26
35
  direction = dot(mapped, mapped) > 1e-8 ? normalize(mapped) : direction;
27
36
  strength *= mMap.b;
37
+ aLift *= mMap.a;
28
38
  }
29
39
  vec2 turned = vec2(
30
40
  direction.x * uModelParams[0].y - direction.y * uModelParams[0].z,
@@ -44,6 +54,17 @@ void anisotropicSurface() {
44
54
  aStrength = strength;
45
55
  }
46
56
 
57
+ /**
58
+ * How much the lift brightens the lobe at a half vector, from the stretched distribution there, D,
59
+ * at widths \`along\` and \`across\` and \`d\` its denominator's root: \`(D + min(3, D^1.5) k) / D\`.
60
+ * anisotropicLiftFactor in anisotropicLobe.ts.
61
+ */
62
+ float anisotropicLift(float along, float across, float d) {
63
+ if (aLift <= 0.0) return 1.0;
64
+ float distribution = 1.0 / max(3.14159265 * along * across * d * d, 1e-8);
65
+ return 1.0 + aLift * min(3.0 / distribution, sqrt(distribution));
66
+ }
67
+
47
68
  /** The stretched lobe at a half vector, 1 at the mirror direction: anisotropicLobe.ts. */
48
69
  float anisotropicLobe(vec3 h, float sourceRadius, float dist) {
49
70
  float alpha = max(mRoughness * mRoughness, MIN_LOBE_ALPHA);
@@ -56,7 +77,7 @@ float anisotropicLobe(vec3 h, float sourceRadius, float dist) {
56
77
  float y = dot(aAcross, h) / acrossWide;
57
78
  float z = dot(mNormal, h);
58
79
  float d = x * x + y * y + z * z;
59
- return energy / max(d * d, 1e-8);
80
+ return energy / max(d * d, 1e-8) * anisotropicLift(alongWide, acrossWide, d);
60
81
  }
61
82
 
62
83
  /**
@@ -74,7 +95,8 @@ float anisotropicPhysical(vec3 l, vec3 h, float ndl, float sourceRadius, float d
74
95
  float y = dot(aAcross, h) / across;
75
96
  float z = dot(mNormal, h);
76
97
  float d = x * x + y * y + z * z;
77
- float distribution = 1.0 / max(3.14159265 * along * across * d * d, 1e-8);
98
+ float distribution =
99
+ 1.0 / max(3.14159265 * along * across * d * d, 1e-8) * anisotropicLift(along, across, d);
78
100
  float ndv = max(dot(mNormal, mToEye), 1e-4);
79
101
  float viewed = ndl * length(vec3(along * dot(aAlong, mToEye), across * dot(aAcross, mToEye), ndv));
80
102
  float lit = ndv * length(vec3(along * dot(aAlong, l), across * dot(aAcross, l), ndl));
@@ -3,10 +3,19 @@
3
3
  * their tilted peaks, the transmitted lobe to the backlight, the sum to what a white strand
4
4
  * receives, and every term to a finite answer where the geometry degenerates.
5
5
  *
6
- * **The strand is the tangent, root to tip.** The model map's green turns each strand's tilt by up
7
- * to half of it either way (0.5 is none) and its blue is occlusion, which light from every side
8
- * takes and the lamps do not — their shadows are theirs to cast. The red is not read: a colour from
9
- * root to tip is the albedo map's to carry.
6
+ * **The strand is the tangent, root to tip**, unless the model map says otherwise. Its red is the
7
+ * strand's specular, which scales the reflected lobe by twice it, so 0.5 is the lobe as drawn and 0
8
+ * a root that reflects nothing; its green turns each strand's tilt by up to half of it either way
9
+ * (0.5 is none); and its blue is occlusion, which light from every side takes and the lamps do not
10
+ * — their shadows are theirs to cast. A colour from root to tip is the albedo map's to carry.
11
+ *
12
+ * **A strand's direction, texel by texel**, where the model has a \`directionOffset\`: that many
13
+ * layers past the one the other channels are read at, a direction in the surface's tangent frame —
14
+ * red along the tangent, green along the bitangent and blue out of the surface along its normal,
15
+ * each −1 to 1 as a normal map packs them — which the strand runs along instead of the tangent. So
16
+ * a card's strands can lean out of its plane, each its own way, as the strands it stands for do.
17
+ * **What it gives up** is the frame's own precision: eight bits a component turns a strand in
18
+ * steps of about half a degree, which the lobes, a few degrees wide, do not resolve.
10
19
  *
11
20
  * **Light from every side** — the sky, the probes, DriftLight — goes through the lobes rather than
12
21
  * past them: they are evaluated once, for a light along the eye flattened across the strand, every
@@ -29,8 +38,10 @@ const float HAIR_F0 = ${HAIR_F0.toFixed(9)};
29
38
  const float HAIR_MIN_BETA = ${HAIR_MIN_BETA.toFixed(3)};
30
39
  const float HAIR_AREA = ${HAIR_AREA.toFixed(3)};
31
40
 
32
- /* This texel's tilt, and the share of light from every side it returns: gathered with the surface. */
41
+ /* This texel's tilt, its specular, and the share of light from every side it returns: gathered
42
+ with the surface. */
33
43
  float hShift;
44
+ float hSpecular;
34
45
  vec3 hAround;
35
46
 
36
47
  /** A longitudinal lobe: a normalised Gaussian of width \`beta\` at \`x\` from its centre. */
@@ -68,7 +79,7 @@ void hairLobes(vec3 l, float grow, float backlit) {
68
79
  float x = sinI + sinO;
69
80
 
70
81
  float r = hairLongitudinal(beta + grow, x + 2.0 * hShift) * 0.25 * cosHalfPhi
71
- * hairFresnel(sqrt(clamp(0.5 + 0.5 * dot(l, mToEye), 0.0, 1.0)));
82
+ * hairFresnel(sqrt(clamp(0.5 + 0.5 * dot(l, mToEye), 0.0, 1.0))) * hSpecular;
72
83
 
73
84
  float a = 1.0 / (1.19 / cosD + 0.36 * cosD);
74
85
  float h = cosHalfPhi * (1.0 + a * (0.6 - 0.8 * cosPhi));
@@ -115,8 +126,21 @@ vec3 hairIndirect() {
115
126
  return min(vec3(1.0), 2.0 * (mSpecular + mDiffuse));
116
127
  }
117
128
 
118
- void hairSurface() {
129
+ /**
130
+ * The strand at this texel: its tilt and specular from the model map, its direction from the layer
131
+ * \`directionOffset\` names where there is one, and then its share of light from every side, which
132
+ * reads all three. On a branch of uniforms alone, so the read's derivatives are in uniform flow; a
133
+ * direction that decodes to nothing keeps the tangent.
134
+ */
135
+ void hairSurface(vec3 at) {
119
136
  hShift = uModelParams[0].x * (0.5 + mMap.g);
137
+ hSpecular = 2.0 * mMap.r;
138
+ if (uModelParams[0].w > 0.0 && uModelParams[1].w > 0.0) {
139
+ vec3 d = texture(uModelMap, vec3(at.xy, at.z + uModelParams[0].w)).rgb * 2.0 - 1.0;
140
+ vec3 strand = mTangent * d.x + mBitangent * d.y + mNormal * d.z;
141
+ float length2 = dot(strand, strand);
142
+ if (length2 > 1e-4) mTangent = strand * inversesqrt(length2);
143
+ }
120
144
  hAround = hairIndirect();
121
145
  }
122
146