@driftengine/core 4.11.1 → 4.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.json +102 -0
- package/README.md +18 -9
- package/dist/geometry/meshBuilder.d.ts +10 -0
- package/dist/geometry/meshBuilder.js +11 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +3 -2
- package/dist/math/color.d.ts +25 -0
- package/dist/math/color.js +37 -0
- package/dist/render/backend/webgl2/renderer.d.ts +33 -5
- package/dist/render/backend/webgl2/renderer.js +123 -49
- package/dist/render/backend/webgl2/skinScatter.d.ts +3 -1
- package/dist/render/backend/webgl2/skinScatter.js +5 -1
- package/dist/render/backend/webgpu/boltPass.d.ts +1 -1
- package/dist/render/backend/webgpu/causticsPass.d.ts +1 -1
- package/dist/render/backend/webgpu/filmPass.d.ts +1 -1
- package/dist/render/backend/webgpu/flockPass.d.ts +1 -1
- package/dist/render/backend/webgpu/giFieldPass.d.ts +3 -1
- package/dist/render/backend/webgpu/gpuDrivenPass.d.ts +10 -1
- package/dist/render/backend/webgpu/gpuDrivenPass.js +28 -11
- package/dist/render/backend/webgpu/panelPass.d.ts +1 -1
- package/dist/render/backend/webgpu/postPass.d.ts +2 -2
- package/dist/render/backend/webgpu/renderer.d.ts +21 -8
- package/dist/render/backend/webgpu/renderer.js +103 -38
- package/dist/render/backend/webgpu/scatterPass.d.ts +1 -1
- package/dist/render/backend/webgpu/skinScatterPass.d.ts +5 -1
- package/dist/render/backend/webgpu/skinScatterPass.js +9 -3
- package/dist/render/backend/webgpu/skyPass.d.ts +11 -1
- package/dist/render/backend/webgpu/waterPass.d.ts +1 -1
- package/dist/render/backend/webgpu/windStreakPass.d.ts +1 -1
- package/dist/render/causticsRenderer.d.ts +3 -1
- package/dist/render/causticsRenderer.js +5 -1
- package/dist/render/decalPass.d.ts +3 -1
- package/dist/render/decalPass.js +4 -1
- package/dist/render/flockRenderer.d.ts +1 -1
- package/dist/render/flockRenderer.js +4 -1
- package/dist/render/globalMediumPass.d.ts +3 -1
- package/dist/render/globalMediumPass.js +5 -1
- package/dist/render/gradeColor.d.ts +21 -0
- package/dist/render/gradeColor.js +64 -0
- package/dist/render/lightMatrix.d.ts +13 -0
- package/dist/render/lightMatrix.js +17 -0
- package/dist/render/passGrade.d.ts +13 -0
- package/dist/render/passGrade.js +47 -0
- package/dist/render/plumeRenderer.d.ts +1 -1
- package/dist/render/plumeRenderer.js +4 -1
- package/dist/render/renderQuality.d.ts +57 -18
- package/dist/render/renderQuality.js +3 -3
- package/dist/render/sceneTarget.d.ts +2 -0
- package/dist/render/sceneTarget.js +1 -0
- package/dist/render/sdfTextRenderer.d.ts +1 -1
- package/dist/render/sdfTextRenderer.js +1 -1
- package/dist/render/shaders/arcane.d.ts +1 -1
- package/dist/render/shaders/arcane.js +5 -1
- package/dist/render/shaders/bolt.d.ts +1 -1
- package/dist/render/shaders/bolt.js +4 -1
- package/dist/render/shaders/caustics.d.ts +1 -1
- package/dist/render/shaders/caustics.js +4 -1
- package/dist/render/shaders/decalProject.js +12 -2
- package/dist/render/shaders/film.d.ts +1 -1
- package/dist/render/shaders/film.js +22 -9
- package/dist/render/shaders/fire.d.ts +1 -1
- package/dist/render/shaders/fire.js +4 -1
- package/dist/render/shaders/flat/areaLight.d.ts +1 -1
- package/dist/render/shaders/flat/areaLight.js +1 -1
- package/dist/render/shaders/flat/glassTint.d.ts +1 -1
- package/dist/render/shaders/flat/glassTint.js +1 -0
- package/dist/render/shaders/flat/index.d.ts +5 -0
- package/dist/render/shaders/flat/index.js +1 -1
- package/dist/render/shaders/flat/layerLooks.d.ts +16 -0
- package/dist/render/shaders/flat/layerLooks.js +108 -0
- package/dist/render/shaders/flat/layered.d.ts +6 -4
- package/dist/render/shaders/flat/layered.js +77 -13
- package/dist/render/shaders/flat/main.d.ts +1 -1
- package/dist/render/shaders/flat/main.js +7 -2
- package/dist/render/shaders/flat/models.js +1 -1
- package/dist/render/shaders/flat/preamble.js +6 -0
- package/dist/render/shaders/flock.d.ts +1 -1
- package/dist/render/shaders/flock.js +4 -1
- package/dist/render/shaders/generated/arcane.wgsl.d.ts +11 -1
- package/dist/render/shaders/generated/arcane.wgsl.js +11 -1
- package/dist/render/shaders/generated/bolt.wgsl.d.ts +12 -2
- package/dist/render/shaders/generated/bolt.wgsl.js +12 -2
- package/dist/render/shaders/generated/caustics.wgsl.d.ts +12 -2
- package/dist/render/shaders/generated/caustics.wgsl.js +12 -2
- package/dist/render/shaders/generated/decalProject.wgsl.d.ts +6 -1
- package/dist/render/shaders/generated/decalProject.wgsl.js +6 -1
- package/dist/render/shaders/generated/film.wgsl.d.ts +12 -2
- package/dist/render/shaders/generated/film.wgsl.js +12 -2
- package/dist/render/shaders/generated/fire.wgsl.d.ts +11 -1
- package/dist/render/shaders/generated/fire.wgsl.js +11 -1
- package/dist/render/shaders/generated/flat.wgsl.d.ts +144 -16
- package/dist/render/shaders/generated/flat.wgsl.js +183 -55
- package/dist/render/shaders/generated/flock.wgsl.d.ts +12 -2
- package/dist/render/shaders/generated/flock.wgsl.js +12 -2
- package/dist/render/shaders/generated/globalMedium.wgsl.d.ts +12 -2
- package/dist/render/shaders/generated/globalMedium.wgsl.js +12 -2
- package/dist/render/shaders/generated/lightVolume.wgsl.d.ts +36 -16
- package/dist/render/shaders/generated/lightVolume.wgsl.js +39 -19
- package/dist/render/shaders/generated/panel.wgsl.d.ts +12 -2
- package/dist/render/shaders/generated/panel.wgsl.js +12 -2
- package/dist/render/shaders/generated/rush.wgsl.d.ts +31 -26
- package/dist/render/shaders/generated/rush.wgsl.js +31 -26
- package/dist/render/shaders/generated/scatter.wgsl.d.ts +12 -2
- package/dist/render/shaders/generated/scatter.wgsl.js +12 -2
- package/dist/render/shaders/generated/skinBlur.wgsl.d.ts +11 -1
- package/dist/render/shaders/generated/skinBlur.wgsl.js +11 -1
- package/dist/render/shaders/generated/sky.wgsl.d.ts +12 -2
- package/dist/render/shaders/generated/sky.wgsl.js +12 -2
- package/dist/render/shaders/generated/smoke.wgsl.d.ts +11 -1
- package/dist/render/shaders/generated/smoke.wgsl.js +11 -1
- package/dist/render/shaders/generated/text.wgsl.d.ts +11 -1
- package/dist/render/shaders/generated/text.wgsl.js +11 -1
- package/dist/render/shaders/generated/water.wgsl.d.ts +12 -2
- package/dist/render/shaders/generated/water.wgsl.js +12 -2
- package/dist/render/shaders/generated/windStreaks.wgsl.d.ts +12 -2
- package/dist/render/shaders/generated/windStreaks.wgsl.js +12 -2
- package/dist/render/shaders/globalMedium.js +14 -1
- package/dist/render/shaders/gpudriven/present.wgsl.d.ts +15 -6
- package/dist/render/shaders/gpudriven/present.wgsl.js +71 -8
- package/dist/render/shaders/lightVolume.js +4 -1
- package/dist/render/shaders/line.d.ts +1 -1
- package/dist/render/shaders/outputTransform.d.ts +8 -2
- package/dist/render/shaders/outputTransform.js +18 -11
- package/dist/render/shaders/panel.d.ts +1 -1
- package/dist/render/shaders/panel.js +5 -1
- package/dist/render/shaders/particle.d.ts +3 -3
- package/dist/render/shaders/rush.js +8 -0
- package/dist/render/shaders/scatter.d.ts +1 -1
- package/dist/render/shaders/scatter.js +4 -1
- package/dist/render/shaders/sdfText.d.ts +1 -1
- package/dist/render/shaders/skinBlur.js +5 -1
- package/dist/render/shaders/sky.d.ts +1 -1
- package/dist/render/shaders/sky.js +5 -1
- package/dist/render/shaders/smoke.d.ts +1 -1
- package/dist/render/shaders/smoke.js +4 -1
- package/dist/render/shaders/text.d.ts +1 -1
- package/dist/render/shaders/text.js +4 -1
- package/dist/render/shaders/water.d.ts +1 -1
- package/dist/render/shaders/water.js +12 -2
- package/dist/render/shaders/windStreaks.d.ts +1 -1
- package/dist/render/shaders/windStreaks.js +4 -1
- package/dist/render/surfaceLayerLooks.d.ts +92 -0
- package/dist/render/surfaceLayerLooks.js +102 -0
- package/dist/render/surfaceLayers.d.ts +47 -6
- package/dist/render/surfaceLayers.js +5 -1
- package/dist/render/textRenderer.d.ts +3 -1
- package/dist/render/textRenderer.js +7 -1
- package/dist/render/vertexDefaults.d.ts +1 -1
- package/dist/render/vertexDefaults.js +1 -1
- package/dist/render/waterRenderer.d.ts +1 -1
- package/dist/render/waterRenderer.js +4 -1
- package/dist/render/windStreakRenderer.d.ts +3 -1
- package/dist/render/windStreakRenderer.js +5 -1
- package/package.json +6 -6
- package/scripts/browser.d.mts +5 -0
- package/scripts/browser.mjs +68 -14
- package/scripts/look.d.mts +81 -0
- package/scripts/look.mjs +380 -0
- package/src/geometry/meshBuilder.ts +21 -2
- package/src/index.ts +13 -2
- package/src/math/color.ts +39 -0
- package/src/render/backend/webgl2/renderer.ts +174 -46
- package/src/render/backend/webgl2/skinScatter.ts +5 -0
- package/src/render/backend/webgpu/giFieldPass.ts +3 -1
- package/src/render/backend/webgpu/gpuDrivenPass.ts +29 -13
- package/src/render/backend/webgpu/renderer.ts +112 -37
- package/src/render/backend/webgpu/skinScatterPass.ts +17 -1
- package/src/render/causticsRenderer.ts +5 -0
- package/src/render/decalPass.ts +3 -0
- package/src/render/flockRenderer.ts +5 -0
- package/src/render/globalMediumPass.ts +10 -1
- package/src/render/gradeColor.ts +90 -0
- package/src/render/lightMatrix.ts +32 -0
- package/src/render/passGrade.ts +58 -0
- package/src/render/plumeRenderer.ts +5 -0
- package/src/render/renderQuality.ts +60 -21
- package/src/render/sceneTarget.ts +3 -0
- package/src/render/sdfTextRenderer.ts +1 -1
- package/src/render/shaders/arcane.ts +5 -1
- package/src/render/shaders/bolt.ts +4 -1
- package/src/render/shaders/caustics.ts +4 -1
- package/src/render/shaders/decalProject.ts +12 -2
- package/src/render/shaders/film.ts +22 -9
- package/src/render/shaders/fire.ts +4 -1
- package/src/render/shaders/flat/areaLight.ts +1 -1
- package/src/render/shaders/flat/glassTint.ts +3 -1
- package/src/render/shaders/flat/index.ts +6 -1
- package/src/render/shaders/flat/layerLooks.ts +108 -0
- package/src/render/shaders/flat/layered.ts +78 -13
- package/src/render/shaders/flat/main.ts +7 -2
- package/src/render/shaders/flat/models.ts +1 -1
- package/src/render/shaders/flat/preamble.ts +6 -0
- package/src/render/shaders/flock.ts +4 -1
- package/src/render/shaders/generated/arcane.wgsl.ts +11 -1
- package/src/render/shaders/generated/bolt.wgsl.ts +12 -2
- package/src/render/shaders/generated/caustics.wgsl.ts +12 -2
- package/src/render/shaders/generated/decalProject.wgsl.ts +6 -1
- package/src/render/shaders/generated/film.wgsl.ts +12 -2
- package/src/render/shaders/generated/fire.wgsl.ts +11 -1
- package/src/render/shaders/generated/flat.wgsl.ts +183 -55
- package/src/render/shaders/generated/flock.wgsl.ts +12 -2
- package/src/render/shaders/generated/globalMedium.wgsl.ts +12 -2
- package/src/render/shaders/generated/lightVolume.wgsl.ts +39 -19
- package/src/render/shaders/generated/panel.wgsl.ts +12 -2
- package/src/render/shaders/generated/rush.wgsl.ts +31 -26
- package/src/render/shaders/generated/scatter.wgsl.ts +12 -2
- package/src/render/shaders/generated/skinBlur.wgsl.ts +11 -1
- package/src/render/shaders/generated/sky.wgsl.ts +12 -2
- package/src/render/shaders/generated/smoke.wgsl.ts +11 -1
- package/src/render/shaders/generated/text.wgsl.ts +11 -1
- package/src/render/shaders/generated/water.wgsl.ts +12 -2
- package/src/render/shaders/generated/windStreaks.wgsl.ts +12 -2
- package/src/render/shaders/globalMedium.ts +14 -1
- package/src/render/shaders/gpudriven/present.wgsl.ts +72 -8
- package/src/render/shaders/lightVolume.ts +4 -1
- package/src/render/shaders/outputTransform.ts +19 -11
- package/src/render/shaders/panel.ts +5 -1
- package/src/render/shaders/rush.ts +8 -0
- package/src/render/shaders/scatter.ts +4 -1
- package/src/render/shaders/skinBlur.ts +5 -1
- package/src/render/shaders/sky.ts +5 -1
- package/src/render/shaders/smoke.ts +4 -1
- package/src/render/shaders/text.ts +4 -1
- package/src/render/shaders/water.ts +12 -2
- package/src/render/shaders/windStreaks.ts +4 -1
- package/src/render/surfaceLayerLooks.ts +186 -0
- package/src/render/surfaceLayers.ts +52 -7
- package/src/render/textRenderer.ts +6 -0
- package/src/render/vertexDefaults.ts +1 -1
- package/src/render/waterRenderer.ts +5 -0
- package/src/render/windStreakRenderer.ts +5 -0
|
@@ -16,4 +16,4 @@
|
|
|
16
16
|
* gets motion without direction. That is what separates a spell from a lamp on a
|
|
17
17
|
* dimmer.
|
|
18
18
|
*/
|
|
19
|
-
export declare const ARCANE_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/** The caller's colour, so an aura shifts with the world's own palette. */\nuniform vec3 uTint;\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\nvoid main() {\n if (uClipEnabled != 0 && dot(vec4(vWorldPos, 1.0), uClipPlane) < 0.0) discard;\n\n // Polar about the quad centre. This is the line that makes it an orb rather\n // than a column; everything downstream reads radius and angle, never height.\n float radius = length(vUv - 0.5) * 2.0;\n if (radius >= 1.0) discard;\n\n vec2 d = vUv - 0.5;\n float angle = atan(d.y, d.x);\n float t = uTime + vSeed * 37.0;\n\n /*\n * Two bands, turning opposite ways at different rates. Sampled in (angle, radius)\n * space so the noise wraps around the disc instead of sliding across it \u2014 noise in\n * UV space would drift off one edge and read as a texture being scrolled.\n */\n float inner = valueNoise(vec2(angle * 1.9 + t * 0.55, radius * 4.2));\n float outer = valueNoise(vec2(angle * 3.1 - t * 0.34, radius * 2.6));\n float swirl = inner * 0.62 + outer * 0.38;\n if (uNoiseOctaves > 1) {\n swirl = mix(swirl, swirl * valueNoise(vec2(angle * 6.3 + t * 0.9, radius * 8.0)) * 2.0, 0.35);\n }\n\n /*\n * Core and corona as two separate falloffs rather than one curve. A single falloff\n * gives a blob; what makes this read as *contained energy* is a small bright centre\n * with a much wider, much fainter halo, so the object inside stays legible.\n */\n float core = 1.0 - smoothstep(0.0, 0.28, radius);\n float corona = (1.0 - smoothstep(0.18, 1.0, radius)) * (0.35 + swirl * 0.65);\n float body = core * 0.9 + corona;\n if (body <= 0.02) discard;\n\n // Hot centre through the tint to a dark rim, so the core reads as white-hot\n // energy and the edge as the colour it is made of.\n vec3 col = mix(uTint * 0.35, uTint, smoothstep(0.0, 0.55, body));\n col = mix(col, vec3(1.0), smoothstep(0.82, 1.4, body));\n col *= uTint;\n\n float alpha = clamp(body * 1.6, 0.0, 1.0);\n // Additive light must lose alpha as it scatters, or mixing its colour alone\n // still makes a distant aura 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 outColor = vec4(col, alpha);\n}\n";
|
|
19
|
+
export declare const ARCANE_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/* Before `uTint`, where fire and smoke declare it, so one field table serves all three. */\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/** The caller's colour, so an aura shifts with the world's own palette. */\nuniform vec3 uTint;\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\nvoid main() {\n if (uClipEnabled != 0 && dot(vec4(vWorldPos, 1.0), uClipPlane) < 0.0) discard;\n\n // Polar about the quad centre. This is the line that makes it an orb rather\n // than a column; everything downstream reads radius and angle, never height.\n float radius = length(vUv - 0.5) * 2.0;\n if (radius >= 1.0) discard;\n\n vec2 d = vUv - 0.5;\n float angle = atan(d.y, d.x);\n float t = uTime + vSeed * 37.0;\n\n /*\n * Two bands, turning opposite ways at different rates. Sampled in (angle, radius)\n * space so the noise wraps around the disc instead of sliding across it \u2014 noise in\n * UV space would drift off one edge and read as a texture being scrolled.\n */\n float inner = valueNoise(vec2(angle * 1.9 + t * 0.55, radius * 4.2));\n float outer = valueNoise(vec2(angle * 3.1 - t * 0.34, radius * 2.6));\n float swirl = inner * 0.62 + outer * 0.38;\n if (uNoiseOctaves > 1) {\n swirl = mix(swirl, swirl * valueNoise(vec2(angle * 6.3 + t * 0.9, radius * 8.0)) * 2.0, 0.35);\n }\n\n /*\n * Core and corona as two separate falloffs rather than one curve. A single falloff\n * gives a blob; what makes this read as *contained energy* is a small bright centre\n * with a much wider, much fainter halo, so the object inside stays legible.\n */\n float core = 1.0 - smoothstep(0.0, 0.28, radius);\n float corona = (1.0 - smoothstep(0.18, 1.0, radius)) * (0.35 + swirl * 0.65);\n float body = core * 0.9 + corona;\n if (body <= 0.02) discard;\n\n // Hot centre through the tint to a dark rim, so the core reads as white-hot\n // energy and the edge as the colour it is made of.\n vec3 col = mix(uTint * 0.35, uTint, smoothstep(0.0, 0.55, body));\n col = mix(col, vec3(1.0), smoothstep(0.82, 1.4, body));\n col *= uTint;\n\n float alpha = clamp(body * 1.6, 0.0, 1.0);\n // Additive light must lose alpha as it scatters, or mixing its colour alone\n // still makes a distant aura 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";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FOG_GLSL } from './fog.js';
|
|
2
|
+
import { OUTPUT_TRANSFORM_GLSL } from './outputTransform.js';
|
|
2
3
|
/**
|
|
3
4
|
* An arcane aura: the volume a floating object sits inside.
|
|
4
5
|
*
|
|
@@ -30,6 +31,8 @@ ${FOG_GLSL}
|
|
|
30
31
|
uniform int uNoiseOctaves;
|
|
31
32
|
uniform vec4 uClipPlane;
|
|
32
33
|
uniform int uClipEnabled;
|
|
34
|
+
/* Before \`uTint\`, where fire and smoke declare it, so one field table serves all three. */
|
|
35
|
+
${OUTPUT_TRANSFORM_GLSL}
|
|
33
36
|
/** The caller's colour, so an aura shifts with the world's own palette. */
|
|
34
37
|
uniform vec3 uTint;
|
|
35
38
|
|
|
@@ -97,6 +100,7 @@ void main() {
|
|
|
97
100
|
col = mix(col, mediumColor(), fog);
|
|
98
101
|
alpha *= 1.0 - fog;
|
|
99
102
|
|
|
100
|
-
|
|
103
|
+
/* Graded where nothing after this will grade it, as every pass is. The alpha is coverage. */
|
|
104
|
+
outColor = vec4(applyOutputTransform(col), alpha);
|
|
101
105
|
}
|
|
102
106
|
`;
|
|
@@ -14,4 +14,4 @@
|
|
|
14
14
|
* gives an arc its three-dimensionality is the path, and the path is world-space.
|
|
15
15
|
*/
|
|
16
16
|
export declare const BOLT_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/** Where this segment sits along its arc, the arc's envelope, its seed, its gain. */\nlayout(location = 3) in vec4 aArc;\n\nuniform mat4 uViewProj;\nuniform vec3 uCameraPos;\n/** Core half-width in metres, and how much of a pixel a far arc must still cover. */\nuniform float uWidth;\nuniform float uMinWidthPerMetre;\n\nout float vSide;\nout float vAlong;\nout float vFade;\nout float vSeed;\nout float vGain;\nout vec3 vWorldPos;\n\nvoid main() {\n vec3 mid = mix(aFrom, aTo, aCorner.x);\n vec3 dir = aTo - aFrom;\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\n * segment points at the viewer, where any perpendicular is as good as another \u2014\n * and where the 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 filament\n * never thins below a pixel. Under a pixel a bright thin line does not get\n * fainter, it *flickers* \u2014 the rasteriser catches it on some frames and not\n * others \u2014 and a strobing artefact reads as a bug, not as lightning.\n */\n float halfWidth = max(uWidth, dist * uMinWidthPerMetre);\n vec3 world = mid + across * aCorner.y * halfWidth;\n\n vSide = aCorner.y;\n vAlong = aArc.x;\n vFade = aArc.y;\n vSeed = aArc.z;\n vGain = aArc.w;\n vWorldPos = world;\n gl_Position = uViewProj * vec4(world, 1.0);\n}\n";
|
|
17
|
-
export declare const BOLT_FRAG = "#version 300 es\nprecision highp float;\n\nin float vSide;\nin float vAlong;\nin float vFade;\nin float vSeed;\nin float vGain;\nin vec3 vWorldPos;\n\nuniform float uTime;\n/** The saturating centre, and the glow it sits inside. */\nuniform vec3 uCoreColor;\nuniform vec3 uEdgeColor;\n/** How far past white the core is allowed to 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\nuniform vec3 uCameraPos;\n\nout vec4 outColor;\n\nfloat hash11(float p) {\n return fract(sin(p * 12.9898) * 43758.5453123);\n}\n\nvoid main() {\n /*\n * Across the filament: 1 at the centre line, 0 at the quad's edge. Two powers\n * of it are the entire look \u2014 a very tight one for the core and a loose one for\n * the glow. A single falloff gives either a hard line with no atmosphere or a\n * soft smudge with no filament.\n */\n /*\n * Clamped, because a multisampled target shades a pixel on the quad's edge at its centre, which\n * may lie outside the quad: there 'vSide' runs past one, 'across' goes negative, and 'pow' of a\n * negative number is NaN. One NaN pixel is a dark speck; bloom then spreads it into a white disc\n * the size of the blur. Found by a fight drawing short arcs at 4 samples.\n */\n float across = clamp(1.0 - abs(vSide), 0.0, 1.0);\n float core = pow(across, 10.0);\n /*\n * A *wide* halo, deliberately. This exponent is the single biggest control over\n * whether the effect reads as lightning or as a drawn line: a tight falloff gives a\n * crisp filament with nothing around it, which is a wire, while a broad one bleeds\n * light into the air the way an over-exposed discharge does on film. It is the\n * closest thing to bloom available without a second pass.\n */\n float glow = pow(across, 1.1);\n\n /*\n * Stutter. An arc is not a steady light: it is re-established many times a\n * second, and the eye reads a *constant* bright line as neon. Two frequencies\n * so the pattern does not read as a sine, and per-arc phase from the seed so\n * several arcs never blink together.\n */\n /*\n * Stutter, but shallow. An arc is re-established many times a second and a perfectly\n * steady bright line reads as neon \u2014 but too *much* flicker reads as a broken effect\n * rather than as a discharge, and at the brief lifetime these now have, deep flicker\n * mostly hides the bolt. The path itself is already being redrawn (see 'BoltPool'),\n * which carries most of the crackle; this only keeps the brightness alive.\n */\n float t = uTime * 47.0 + vSeed * 17.0;\n float stutter = 0.78 + 0.22 * (hash11(floor(t)) * 0.6 + hash11(floor(t * 2.37)) * 0.4);\n\n /*\n * The tip tapers. An arc that ends in a squared-off quad reads as a drawn\n * segment; thinning toward the far end is what makes it read as a discharge\n * dissipating into the air.\n */\n float taper = 1.0 - vAlong * 0.55;\n\n float energy = vFade * stutter * taper * vGain;\n vec3 lit = uEdgeColor * glow + uCoreColor * core * uCoreGain;\n\n /*\n * Distance dims it rather than tinting it toward the fog. A discharge emits;\n * mixing it into the medium's colour is what a *surface* does, and doing that\n * here turned a far bolt grey instead of faint.\n */\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n /*\n * Alpha is 1, and the energy is already in the colour.\n *\n * The additive blend is 'SRC_ALPHA, ONE', so what reaches the framebuffer is\n * 'rgb * a'. Putting the energy in both \u2014 which the first version did \u2014 squares it,\n * and squaring a number below one is how an effect authored as blinding arrives as\n * faint: at a typical energy of 0.5 it delivered a quarter of what it asked for.\n * Every emissive material here has to pick one channel and mean it.\n */\n outColor = vec4(lit * energy * (1.0 - fog), 1.0);\n}\n";
|
|
17
|
+
export declare const BOLT_FRAG = "#version 300 es\nprecision highp float;\n\nin float vSide;\nin float vAlong;\nin float vFade;\nin float vSeed;\nin float vGain;\nin vec3 vWorldPos;\n\nuniform float uTime;\n/** The saturating centre, and the glow it sits inside. */\nuniform vec3 uCoreColor;\nuniform vec3 uEdgeColor;\n/** How far past white the core is allowed to 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\nuniform vec3 uCameraPos;\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 hash11(float p) {\n return fract(sin(p * 12.9898) * 43758.5453123);\n}\n\nvoid main() {\n /*\n * Across the filament: 1 at the centre line, 0 at the quad's edge. Two powers\n * of it are the entire look \u2014 a very tight one for the core and a loose one for\n * the glow. A single falloff gives either a hard line with no atmosphere or a\n * soft smudge with no filament.\n */\n /*\n * Clamped, because a multisampled target shades a pixel on the quad's edge at its centre, which\n * may lie outside the quad: there 'vSide' runs past one, 'across' goes negative, and 'pow' of a\n * negative number is NaN. One NaN pixel is a dark speck; bloom then spreads it into a white disc\n * the size of the blur. Found by a fight drawing short arcs at 4 samples.\n */\n float across = clamp(1.0 - abs(vSide), 0.0, 1.0);\n float core = pow(across, 10.0);\n /*\n * A *wide* halo, deliberately. This exponent is the single biggest control over\n * whether the effect reads as lightning or as a drawn line: a tight falloff gives a\n * crisp filament with nothing around it, which is a wire, while a broad one bleeds\n * light into the air the way an over-exposed discharge does on film. It is the\n * closest thing to bloom available without a second pass.\n */\n float glow = pow(across, 1.1);\n\n /*\n * Stutter. An arc is not a steady light: it is re-established many times a\n * second, and the eye reads a *constant* bright line as neon. Two frequencies\n * so the pattern does not read as a sine, and per-arc phase from the seed so\n * several arcs never blink together.\n */\n /*\n * Stutter, but shallow. An arc is re-established many times a second and a perfectly\n * steady bright line reads as neon \u2014 but too *much* flicker reads as a broken effect\n * rather than as a discharge, and at the brief lifetime these now have, deep flicker\n * mostly hides the bolt. The path itself is already being redrawn (see 'BoltPool'),\n * which carries most of the crackle; this only keeps the brightness alive.\n */\n float t = uTime * 47.0 + vSeed * 17.0;\n float stutter = 0.78 + 0.22 * (hash11(floor(t)) * 0.6 + hash11(floor(t * 2.37)) * 0.4);\n\n /*\n * The tip tapers. An arc that ends in a squared-off quad reads as a drawn\n * segment; thinning toward the far end is what makes it read as a discharge\n * dissipating into the air.\n */\n float taper = 1.0 - vAlong * 0.55;\n\n float energy = vFade * stutter * taper * vGain;\n vec3 lit = uEdgeColor * glow + uCoreColor * core * uCoreGain;\n\n /*\n * Distance dims it rather than tinting it toward the fog. A discharge emits;\n * mixing it into the medium's colour is what a *surface* does, and doing that\n * here turned a far bolt grey instead of faint.\n */\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n /*\n * Alpha is 1, and the energy is already in the colour.\n *\n * The additive blend is 'SRC_ALPHA, ONE', so what reaches the framebuffer is\n * 'rgb * a'. Putting the energy in both \u2014 which the first version did \u2014 squares it,\n * and squaring a number below one is how an effect authored as blinding arrives as\n * faint: at a typical energy of 0.5 it delivered a quarter of what it asked for.\n * Every emissive material here has to pick one channel and mean it.\n */\n /* Graded where nothing after this will grade it, as every pass is, light added included. */\n outColor = vec4(applyOutputTransform(lit * energy * (1.0 - fog)), 1.0);\n}\n";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FOG_GLSL } from './fog.js';
|
|
2
|
+
import { OUTPUT_TRANSFORM_GLSL } from './outputTransform.js';
|
|
2
3
|
/**
|
|
3
4
|
* Electrical arcs: a filament with a saturating core and a wide soft glow.
|
|
4
5
|
*
|
|
@@ -92,6 +93,7 @@ ${FOG_GLSL}
|
|
|
92
93
|
uniform vec3 uCameraPos;
|
|
93
94
|
|
|
94
95
|
out vec4 outColor;
|
|
96
|
+
${OUTPUT_TRANSFORM_GLSL}
|
|
95
97
|
|
|
96
98
|
float hash11(float p) {
|
|
97
99
|
return fract(sin(p * 12.9898) * 43758.5453123);
|
|
@@ -162,6 +164,7 @@ void main() {
|
|
|
162
164
|
* faint: at a typical energy of 0.5 it delivered a quarter of what it asked for.
|
|
163
165
|
* Every emissive material here has to pick one channel and mean it.
|
|
164
166
|
*/
|
|
165
|
-
|
|
167
|
+
/* Graded where nothing after this will grade it, as every pass is, light added included. */
|
|
168
|
+
outColor = vec4(applyOutputTransform(lit * energy * (1.0 - fog)), 1.0);
|
|
166
169
|
}
|
|
167
170
|
`;
|
|
@@ -35,4 +35,4 @@
|
|
|
35
35
|
* ordinary one, and it wants the sheet laid just clear of the tiles.
|
|
36
36
|
*/
|
|
37
37
|
export declare const CAUSTICS_VERT = "#version 300 es\nlayout(location = 0) in vec3 aRest;\nlayout(location = 1) in vec2 aLocal;\n/** x: resting height of the water below. y: spare. */\nlayout(location = 2) in vec2 aParams;\n\nuniform mat4 uViewProj;\n\nout vec3 vWorldPos;\nout vec2 vLocal;\nout vec2 vParams;\n\nvoid main() {\n vWorldPos = aRest;\n vLocal = aLocal;\n vParams = aParams;\n gl_Position = uViewProj * vec4(aRest, 1.0);\n}\n";
|
|
38
|
-
export declare const CAUSTICS_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vWorldPos;\nin vec2 vLocal;\nin vec2 vParams;\n\nuniform vec3 uCameraPos;\nuniform vec2 uWindDir;\nuniform float uWaveGain;\nuniform float uTime;\n/** Toward the dominant source, as everywhere else in the engine. */\nuniform vec3 uLightDir;\n/** Colour of the light the water is reflecting. */\nuniform vec3 uTint;\nuniform float uStrength;\n/** How far from the water, on either side, the effect has died out entirely. */\nuniform float uMaxDrop;\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\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\n/**\n * Shallowest the source may be and still be treated as a source.\n *\n * The slant path goes as 1/sin(elevation), so a light on the horizon would send\n * the sampled water position kilometres away and the pattern would smear into\n * noise. Clamping keeps a sunset dramatic instead of broken.\n */\nconst float MIN_ELEVATION = 0.16;\n\nvoid main() {\n float waterY = vParams.x;\n float drop = vWorldPos.y - waterY;\n\n /*\n * Everything is masked rather than branched on, and fwidth() below is why:\n * screen-space derivatives are undefined in non-uniform control flow, so a\n * discard above them would make the antialiasing of the caustic lines a\n * driver-dependent guess. Nothing is saved by branching either \u2014 a masked\n * fragment adds zero under additive blending.\n *\n * Either side of the surface is lit, and the distance from it is what matters\n * rather than the direction. This began as a soffit-only effect \u2014 light thrown\n * *up* onto the underside of a bridge \u2014 on the reasoning that below the water\n * there is nothing to light. That is only true when the water is a ceiling to\n * you. A pool floor, a flooded corridor, a tank with a lamp over it: the water\n * focuses light onto the bottom exactly as it does onto a soffit, and it is by\n * far the more common of the two.\n *\n * Only the surface's own thin band is excluded, where the sheet and the water\n * are coplanar and the pattern would be meaningless.\n *\n * Too far from it and the net has spread into nothing. And the sheet dissolves\n * at its own boundary, so a lit surface does not end in a lit rectangle.\n */\n float distanceFromWater = abs(drop);\n float clear = step(0.02, distanceFromWater);\n float height = 1.0 - smoothstep(uMaxDrop * 0.45, uMaxDrop, distanceFromWater);\n float rim = min(min(vLocal.x, 1.0 - vLocal.x), min(vLocal.y, 1.0 - vLocal.y));\n float edge = smoothstep(0.0, 0.12, rim);\n float reach = clear * height * edge;\n\n /*\n * Which water lights *this* point. For a flat surface the reflected ray leaves\n * along the mirrored light direction, so walking back from the ceiling gives\n * the water position and the path length in one step. This is why the net\n * slides along the ground as the sun moves rather than sitting under itself.\n */\n float elevation = max(uLightDir.y, MIN_ELEVATION);\n float slant = 1.0 / elevation;\n float d = drop * slant;\n vec2 water = vWorldPos.xz + uLightDir.xz * d;\n\n vec3 h = gerstnerCurvature(water, uTime, uWindDir, uWaveGain);\n float a = 1.0 - 2.0 * d * h.x;\n float b = 1.0 - 2.0 * d * h.y;\n float c = 2.0 * d * h.z;\n float det = a * b - c * c;\n\n /*\n * The bright net is where the determinant crosses zero, so its width is a\n * gradient question, not a constant. Widening the core by the screen-space\n * derivative antialiases it for free \u2014 without that, a line thinner than a\n * pixel strobes as the camera moves, which is the usual reason an effect like\n * this ends up looking like noise.\n */\n float soft = max(0.05, fwidth(det) * 1.6);\n float lines = soft / max(abs(det), soft);\n /*\n * Between the lines, the faint brightening of merely convergent water \u2014 and it has to\n * stay faint. At 0.45 it lit the *whole* sheet, and since the sheet hangs a few\n * centimetres under the soffit, the result read as a pale plate floating below the\n * bridge rather than as light on its underside \u2014 reported as being able to see through\n * the bridge ceiling, which looked glassy and transparent. The plate was the sheet.\n *\n * A tighter threshold and a fifth of the weight: the net is bright, everything between\n * it is almost nothing, and a soffit with no caustics on it is simply a soffit.\n */\n float sheen = clamp(1.0 / max(abs(det), 0.18) - 0.85, 0.0, 1.0);\n float caustic = clamp(lines * 0.9 + sheen * 0.09, 0.0, 1.6);\n\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n\n // Additive: this is light arriving at a surface that is already shaded.\n outColor = vec4(uTint * (caustic * uStrength * reach * (1.0 - fog)), 1.0);\n}\n";
|
|
38
|
+
export declare const CAUSTICS_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vWorldPos;\nin vec2 vLocal;\nin vec2 vParams;\n\nuniform vec3 uCameraPos;\nuniform vec2 uWindDir;\nuniform float uWaveGain;\nuniform float uTime;\n/** Toward the dominant source, as everywhere else in the engine. */\nuniform vec3 uLightDir;\n/** Colour of the light the water is reflecting. */\nuniform vec3 uTint;\nuniform float uStrength;\n/** How far from the water, on either side, the effect has died out entirely. */\nuniform float uMaxDrop;\n\nuniform vec3 uFogColor;\n/** Extinction per metre **at the camera's own height**, not at the base. */\nuniform float uFogDensity;\n/** Reciprocal scale height of the haze, per metre. 0 is a uniform medium. */\nuniform float uFogHeightFalloff;\n/** The camera's height, so a fragment's own height is one subtraction away. */\nuniform float uFogEyeY;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFogDensity;\nuniform float uUnderwaterFactor;\n/** 0 integrates a medium (Beer-Lambert); 1 ramps linearly between near and far. */\nuniform int uFogMode;\n/** Linear mode only: where haze begins, and where it is total. */\nuniform float uFogNear;\nuniform float uFogFar;\n\n/** What distance converges toward: the day's haze, or the water column. */\nvec3 mediumColor() {\n return mix(uFogColor, uUnderwaterColor, uUnderwaterFactor);\n}\n\n/**\n * How much of a surface standing at height pointY, dist metres away, is lost to\n * the medium in between. 0 is perfectly clear, 1 is the medium's own colour.\n */\nfloat mediumFog(float dist, float pointY) {\n /*\n * A depth ramp rather than a medium, for worlds authored against one.\n *\n * Beer-Lambert is the honest model and stays the default: it integrates something\n * physically there, so extinction begins at the eye and never stops. A linear near/far\n * fog is not that \u2014 it is a *look*, with a hard start distance before which the air is\n * perfectly clear, and it is what the fixed-function pipeline offered and what several\n * renderers still expose. Approximating one with the other is not a matter of tuning:\n * matched at half occlusion, the exponential curve still lays haze over everything\n * inside near, where the ramp lays none, and near-field colour desaturates toward the\n * fog colour with nothing to show for it.\n *\n * Height falloff has no meaning here and is ignored: a ramp is defined on distance\n * alone.\n */\n if (uFogMode == 1) {\n float span = max(uFogFar - uFogNear, 1e-4);\n float ramp = clamp((dist - uFogNear) / span, 0.0, 1.0);\n if (uUnderwaterFactor <= 0.0) return ramp;\n float wetLinear = uUnderwaterFogDensity * dist;\n return mix(ramp, 1.0 - exp2(-wetLinear * wetLinear * 1.442695), uUnderwaterFactor);\n }\n\n // Where the medium begins along the ray: uFogNear, the linear mode's slot, which this mode\n // never read before Atmosphere.fogStart. The ray is integrated from there on, at the density\n // of that point; at a start of 0 every term is the one it always was. See render/fog.ts.\n float start = min(uFogNear, dist);\n float before = dist > 0.0 ? start / dist : 0.0;\n float t = (pointY - uFogEyeY) * uFogHeightFalloff;\n float rest = t * (1.0 - before);\n // (1 - e^-t)/t, which tends to 1 as the ray levels out. Guarded rather than\n // branched: at t = 0 the quotient is 0/0, and 1e-4 of a scale height is 8 mm.\n float denom = abs(rest) < 1e-4 ? 1e-4 : rest;\n float air =\n 1.0 - exp(-uFogDensity * exp(-t * before) * (dist - start) * (1.0 - exp(-denom)) / denom);\n if (uUnderwaterFactor <= 0.0) return air;\n // Water is its own medium and keeps its own curve, so crossing the surface\n // changes nothing about how the water column reads.\n float wet = uUnderwaterFogDensity * dist;\n return mix(air, 1.0 - exp2(-wet * wet * 1.442695), uUnderwaterFactor);\n}\n\n\nout vec4 outColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\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\n/**\n * Shallowest the source may be and still be treated as a source.\n *\n * The slant path goes as 1/sin(elevation), so a light on the horizon would send\n * the sampled water position kilometres away and the pattern would smear into\n * noise. Clamping keeps a sunset dramatic instead of broken.\n */\nconst float MIN_ELEVATION = 0.16;\n\nvoid main() {\n float waterY = vParams.x;\n float drop = vWorldPos.y - waterY;\n\n /*\n * Everything is masked rather than branched on, and fwidth() below is why:\n * screen-space derivatives are undefined in non-uniform control flow, so a\n * discard above them would make the antialiasing of the caustic lines a\n * driver-dependent guess. Nothing is saved by branching either \u2014 a masked\n * fragment adds zero under additive blending.\n *\n * Either side of the surface is lit, and the distance from it is what matters\n * rather than the direction. This began as a soffit-only effect \u2014 light thrown\n * *up* onto the underside of a bridge \u2014 on the reasoning that below the water\n * there is nothing to light. That is only true when the water is a ceiling to\n * you. A pool floor, a flooded corridor, a tank with a lamp over it: the water\n * focuses light onto the bottom exactly as it does onto a soffit, and it is by\n * far the more common of the two.\n *\n * Only the surface's own thin band is excluded, where the sheet and the water\n * are coplanar and the pattern would be meaningless.\n *\n * Too far from it and the net has spread into nothing. And the sheet dissolves\n * at its own boundary, so a lit surface does not end in a lit rectangle.\n */\n float distanceFromWater = abs(drop);\n float clear = step(0.02, distanceFromWater);\n float height = 1.0 - smoothstep(uMaxDrop * 0.45, uMaxDrop, distanceFromWater);\n float rim = min(min(vLocal.x, 1.0 - vLocal.x), min(vLocal.y, 1.0 - vLocal.y));\n float edge = smoothstep(0.0, 0.12, rim);\n float reach = clear * height * edge;\n\n /*\n * Which water lights *this* point. For a flat surface the reflected ray leaves\n * along the mirrored light direction, so walking back from the ceiling gives\n * the water position and the path length in one step. This is why the net\n * slides along the ground as the sun moves rather than sitting under itself.\n */\n float elevation = max(uLightDir.y, MIN_ELEVATION);\n float slant = 1.0 / elevation;\n float d = drop * slant;\n vec2 water = vWorldPos.xz + uLightDir.xz * d;\n\n vec3 h = gerstnerCurvature(water, uTime, uWindDir, uWaveGain);\n float a = 1.0 - 2.0 * d * h.x;\n float b = 1.0 - 2.0 * d * h.y;\n float c = 2.0 * d * h.z;\n float det = a * b - c * c;\n\n /*\n * The bright net is where the determinant crosses zero, so its width is a\n * gradient question, not a constant. Widening the core by the screen-space\n * derivative antialiases it for free \u2014 without that, a line thinner than a\n * pixel strobes as the camera moves, which is the usual reason an effect like\n * this ends up looking like noise.\n */\n float soft = max(0.05, fwidth(det) * 1.6);\n float lines = soft / max(abs(det), soft);\n /*\n * Between the lines, the faint brightening of merely convergent water \u2014 and it has to\n * stay faint. At 0.45 it lit the *whole* sheet, and since the sheet hangs a few\n * centimetres under the soffit, the result read as a pale plate floating below the\n * bridge rather than as light on its underside \u2014 reported as being able to see through\n * the bridge ceiling, which looked glassy and transparent. The plate was the sheet.\n *\n * A tighter threshold and a fifth of the weight: the net is bright, everything between\n * it is almost nothing, and a soffit with no caustics on it is simply a soffit.\n */\n float sheen = clamp(1.0 / max(abs(det), 0.18) - 0.85, 0.0, 1.0);\n float caustic = clamp(lines * 0.9 + sheen * 0.09, 0.0, 1.6);\n\n float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);\n\n // Additive: this is light arriving at a surface that is already shaded.\n /* Graded where nothing after this will grade it, as every pass is, light added included. */\n outColor = vec4(applyOutputTransform(uTint * (caustic * uStrength * reach * (1.0 - fog))), 1.0);\n}\n";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { GERSTNER_GLSL } from './gerstner.js';
|
|
2
2
|
import { FOG_GLSL } from './fog.js';
|
|
3
|
+
import { OUTPUT_TRANSFORM_GLSL } from './outputTransform.js';
|
|
3
4
|
/**
|
|
4
5
|
* Light off water, projected onto what covers it.
|
|
5
6
|
*
|
|
@@ -76,6 +77,7 @@ uniform float uMaxDrop;
|
|
|
76
77
|
${FOG_GLSL}
|
|
77
78
|
|
|
78
79
|
out vec4 outColor;
|
|
80
|
+
${OUTPUT_TRANSFORM_GLSL}
|
|
79
81
|
|
|
80
82
|
${GERSTNER_GLSL}
|
|
81
83
|
|
|
@@ -162,6 +164,7 @@ void main() {
|
|
|
162
164
|
float fog = mediumFog(distance(vWorldPos, uCameraPos), vWorldPos.y);
|
|
163
165
|
|
|
164
166
|
// Additive: this is light arriving at a surface that is already shaded.
|
|
165
|
-
|
|
167
|
+
/* Graded where nothing after this will grade it, as every pass is, light added included. */
|
|
168
|
+
outColor = vec4(applyOutputTransform(uTint * (caustic * uStrength * reach * (1.0 - fog))), 1.0);
|
|
166
169
|
}
|
|
167
170
|
`;
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* the rule `AGENTS.md` states for `texture()` in a branch, arriving through the other door.
|
|
24
24
|
*/
|
|
25
25
|
import { glslIsFarDepth } from '../depthConvention.js';
|
|
26
|
+
import { LINEAR_TO_SRGB_GLSL } from './outputTransform.js';
|
|
26
27
|
export const DECAL_PROJECT_FRAG = `#version 300 es
|
|
27
28
|
precision highp float;
|
|
28
29
|
|
|
@@ -61,7 +62,9 @@ uniform float uDecalOpacity;
|
|
|
61
62
|
uniform float uDecalFacingCos;
|
|
62
63
|
/** How much of the mark's radius is edge. */
|
|
63
64
|
uniform float uDecalSoftness;
|
|
64
|
-
|
|
65
|
+
/** How the frame under the marks was graded, 0 where the resolve grades it. See renderQuality.ts. */
|
|
66
|
+
uniform int uOutputTransform;
|
|
67
|
+
${LINEAR_TO_SRGB_GLSL}
|
|
65
68
|
out vec4 fragColor;
|
|
66
69
|
|
|
67
70
|
void main() {
|
|
@@ -120,6 +123,13 @@ void main() {
|
|
|
120
123
|
mark *= ${glslIsFarDepth('stored')} ? 0.0 : 1.0;
|
|
121
124
|
|
|
122
125
|
/* White is the identity of a multiply, so a pixel the projector missed changes nothing. */
|
|
123
|
-
|
|
126
|
+
vec3 multiply = mix(vec3(1.0), uDecalColor, mark * uDecalOpacity);
|
|
127
|
+
/*
|
|
128
|
+
* **The multiply as an encoded pixel takes it**, where the frame was graded before this: the
|
|
129
|
+
* receiver is a display value, and darkening its light by a factor is darkening the display value
|
|
130
|
+
* by that factor encoded — exact through the sRGB encode, whose power carries a product through,
|
|
131
|
+
* and close through a curve, which bends it. A frame graded at the resolve multiplies linear light.
|
|
132
|
+
*/
|
|
133
|
+
fragColor = vec4(mix(multiply, linearToSrgb(multiply), float(uOutputTransform != 0)), 1.0);
|
|
124
134
|
}
|
|
125
135
|
`;
|
|
@@ -12,4 +12,4 @@
|
|
|
12
12
|
* rainbow is generated rather than sampled.
|
|
13
13
|
*/
|
|
14
14
|
export declare const FILM_VERT = "#version 300 es\nprecision highp float;\n\nlayout(location = 0) in vec3 aPosition;\nlayout(location = 1) in vec3 aNormal;\nlayout(location = 2) in vec3 aColor;\n/*\n * Coverage, on the attribute the mesh format calls emissive.\n *\n * A film has no use for self-illumination, and it very much needs an edge that\n * dissolves: the first oil was flat squares, and a hard straight edge is what made\n * it read as a decal dropped on the world rather than a puddle lying in it. One\n * float per vertex, 1 in the middle of a slick and 0 at its rim.\n */\nlayout(location = 3) in float aCoverage;\n\nuniform mat4 uViewProj;\n/* The mirrored camera's projection, so a film can sample the planar target the way water does. */\nuniform mat4 uReflectionViewProj;\n\nout vec3 vWorld;\nout vec3 vNormal;\nout vec3 vTint;\nout vec4 vReflectionClip;\nout float vCoverage;\n\nvoid main() {\n vWorld = aPosition;\n vNormal = normalize(aNormal);\n vTint = aColor;\n vCoverage = aCoverage;\n vReflectionClip = uReflectionViewProj * vec4(aPosition, 1.0);\n gl_Position = uViewProj * vec4(aPosition, 1.0);\n}\n";
|
|
15
|
-
export declare const FILM_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vWorld;\nin vec3 vNormal;\nin vec3 vTint;\nin vec4 vReflectionClip;\n/*\n * The scene mirrored across the plane this film lies on, and how much of it to show.\n *\n * **Sheen is iridescence and this is the image**, which is the split a consumer asked for after\n * finding that raising sheen to make a road wetter only made it oilier. A wet street at night is\n * defined by everything above it appearing twice, and no amount of interference pattern is that.\n *\n * The projection is the water shader's, unchanged and deliberately so: reproducing it outside the\n * engine is the piece most likely to be subtly wrong and least likely to be noticed, since a\n * mirror off by a small skew looks like a wet road until somebody puts a straight line across it.\n */\nuniform sampler2D uReflectionMap;\nuniform int uReflectionEnabled;\nuniform float uReflectionStrength;\nin float vCoverage;\n\nuniform vec3 uCameraPos;\nuniform float uTime;\n/*\n * The mirrored pass clips at the water plane, and it does so **in the fragment\n * shader** rather than with gl_ClipDistance.\n *\n * That is not a style choice. Clip distances in WebGL2 need\n * EXT_clip_cull_distance both enabled on the context and declared with an\n * #extension line, and without the declaration the shader fails to *compile* \u2014\n * which, since programs are built when the renderer is constructed, takes the\n * whole boot with it and leaves a black screen. This shader had never been\n * compiled when it was written, so the mistake sat harmlessly in the file until\n * the day it was wired to something. Discarding is what flat.ts does, it needs\n * no extension, and a film is a thin translucent layer where the cost of a\n * discarded fragment is nothing.\n */\nuniform vec4 uClipPlane;\nuniform int uClipEnabled;\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/** How strong the sheen is. Zero leaves a plain dark film. */\nuniform float uSheen;\n\n/**\n * How rough the film's own surface is, 0 to 1, and how coarse that roughness is.\n *\n * **A film had exactly one finish before this: mirror.** That is right for standing water and\n * wrong for the thing this pass is most used for, because a wet road is not a mirror \u2014 it is\n * water lying *in* aggregate, and the stone underneath breaks the reflection into a smear. With\n * no way to say so, a patch of film over a textured road met it at a hard rim: mirror on one\n * side, stone on the other, with nothing able to cross it. Feathering the coverage cannot fix\n * that, and it was tried \u2014 the alpha was never what the eye was reading.\n *\n * So the reflection is displaced by a noise field rather than blurred. **Displaced, because a\n * blur is the wrong phenomenon**: a rough surface does not soften what it reflects, it scatters\n * it, and a lamp on wet tarmac smears into a streak rather than becoming a soft disc. It is also\n * what a blur cannot afford here \u2014 the mirror has no mip chain (see the `textureLod` note\n * below), so softening it honestly would cost several taps where this costs none.\n *\n * `uFilmRoughnessCycles` is bumps to the metre, and it is the same quantity\n * `Renderer.setSurfaceRelief` takes. **Matching it to the surface underneath is the point**:\n * given the same field at the same scale, the water is broken up by the aggregate that the dry\n * road beside it is showing, and the two stop being different materials.\n *\n * Zero is the mirror this pass has always been, exactly, so nothing that does not ask for\n * roughness renders differently.\n */\nuniform float uFilmRoughness;\nuniform float uFilmRoughnessCycles;\n\nout vec4 fragColor;\n\n/** A hue wheel, without a texture. Standard 6-segment ramp. */\nvec3 hue(float h) {\n h = fract(h) * 6.0;\n vec3 c = clamp(vec3(abs(h - 3.0) - 1.0, 2.0 - abs(h - 2.0), 2.0 - abs(h - 4.0)), 0.0, 1.0);\n return c;\n}\n\n/**\n * The same value noise `flat.ts` builds its relief from, and deliberately the same.\n *\n * Copied rather than shared because these two shaders have no common include beyond the fog, and\n * a film that used a *different* field would defeat the whole purpose of taking a cycle count:\n * the water would be broken up by one pattern and the road by another, which is two materials\n * again with extra steps.\n */\nfloat filmHash(vec3 p) {\n p = fract(p * 0.1031);\n p += dot(p, p.zyx + 31.32);\n return fract((p.x + p.y) * p.z);\n}\n\nfloat filmNoise(vec3 p) {\n vec3 i = floor(p);\n vec3 f = fract(p);\n vec3 u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(\n mix(filmHash(i + vec3(0.0, 0.0, 0.0)), filmHash(i + vec3(1.0, 0.0, 0.0)), u.x),\n mix(filmHash(i + vec3(0.0, 1.0, 0.0)), filmHash(i + vec3(1.0, 1.0, 0.0)), u.x),\n u.y\n ),\n mix(\n mix(filmHash(i + vec3(0.0, 0.0, 1.0)), filmHash(i + vec3(1.0, 0.0, 1.0)), u.x),\n mix(filmHash(i + vec3(0.0, 1.0, 1.0)), filmHash(i + vec3(1.0, 1.0, 1.0)), u.x),\n u.y\n ),\n u.z\n );\n}\n\nvoid main() {\n if (uClipEnabled != 0 && dot(vec4(vWorld, 1.0), uClipPlane) < 0.0) discard;\n\n vec3 view = normalize(uCameraPos - vWorld);\n float facing = max(dot(normalize(vNormal), view), 0.0);\n /*\n * Fresnel-ish: dark looking straight down at it, bright at a grazing angle.\n * This is the whole effect \u2014 it is what makes a wet patch look wet rather\n * than looking like a patch of dark paint.\n */\n float grazing = pow(1.0 - facing, 3.0);\n\n /*\n * A slow travelling interference, so the sheen crawls rather than sitting\n * still. Two frequencies that do not divide each other, or the pattern\n * repeats visibly across a long slick.\n */\n float ripple =\n sin(vWorld.x * 0.73 + vWorld.z * 0.41 + uTime * 0.55) *\n cos(vWorld.z * 0.91 - vWorld.x * 0.29 - uTime * 0.37);\n\n vec3 sheen = hue(grazing * 1.35 + ripple * 0.14 + 0.55);\n /*\n * Dark underneath, so the film always reads as *less* than the surface it lies on even where\n * the sheen is strongest \u2014 but not so dark that it disappears.\n *\n * A slick that has just launched somebody has to be findable afterwards. It was rounded,\n * feathered and iridescent \u2014 and at a fifth of an opaque tint on a pale deck at dusk,\n * invisible. A hazard you cannot find is not a hazard, it is a trap.\n *\n * So the base is darker and the sheen has a floor: even looking straight down, where the\n * Fresnel term is nearly nothing, a slick is a distinctly wet patch rather than a faint one.\n */\n vec3 base = vTint * 0.9;\n vec3 color = base + sheen * uSheen * (0.22 + grazing * 0.75);\n\n /*\n * The mirrored scene, under the sheen rather than instead of it.\n *\n * Screen-space from the mirrored clip position, exactly as the water pass does it, with the\n * same fade as the sample leaves the target: a reflection that stopped dead at the edge of its\n * own buffer would draw a line across the road where nothing is.\n *\n * Weighted by the grazing term as well as by strength, because that is what a reflection does:\n * a wet surface underfoot shows almost nothing and the same surface down the street shows the\n * whole scene. Added before the fog so the image recedes with everything else in the frame.\n */\n if (uReflectionEnabled != 0 && uReflectionStrength > 0.0 && vReflectionClip.w > 0.0) {\n vec2 reflectionUv = vReflectionClip.xy / vReflectionClip.w * 0.5 + 0.5;\n vec2 border = min(reflectionUv, 1.0 - reflectionUv);\n float valid = smoothstep(0.0, 0.025, min(border.x, border.y));\n /*\n * An explicit level, not an implicit one, and the branch above is the whole reason.\n *\n * An implicit derivative inside control flow that is not provably uniform is what lets a\n * compiler flatten the branch and pay for every arm \u2014 AGENTS.md, 2026-08-07 \u2014 and WGSL\n * does not merely warn: the module fails to compile with \"textureSample must only be\n * called from uniform control flow\", which invalidates the pipeline, which invalidates\n * the command buffer, and the frame still presents. The fifth time on this branch. The\n * mirror has no mip chain, so level 0 is the only level there is; the water shader\n * reaches the same conclusion for the same lookup.\n */\n /*\n * The aggregate under the water, as a displacement of what the water shows.\n *\n * The gradient of the same field the road's relief uses, taken across the surface and used\n * to push the lookup. **Stretched along the reflection rather than isotropic**, and that is\n * the half that makes it read as a road: a reflection on a rough horizontal surface smears\n * *towards the viewer*, because a bump tilts the ray in the plane it is already travelling\n * in. Displacing in a circle gives frosted glass instead, which is a different wet thing.\n *\n * Scaled by the grazing term for the same reason the mirror is: looking straight down at a\n * puddle the reflection is compressed into almost nothing and a displacement of it would be\n * enormous, so the scatter has to fall off exactly where the image does.\n */\n if (uFilmRoughness > 0.0) {\n vec3 at = vWorld * uFilmRoughnessCycles;\n float here = filmNoise(at);\n vec2 slope = vec2(\n filmNoise(at + vec3(0.5, 0.0, 0.0)) - here,\n filmNoise(at + vec3(0.0, 0.0, 0.5)) - here\n );\n /* Along the view, which on a horizontal film is where the image runs. */\n vec2 along = normalize(vec2(view.x, view.z) + vec2(1e-5, 0.0));\n vec2 smear = along * dot(slope, along) * 3.0 + slope * 0.35;\n reflectionUv += smear * uFilmRoughness * 0.016 * (0.2 + grazing * 0.8);\n /* Re-fade at the border: a displaced sample can leave the target the original did not. */\n vec2 movedBorder = min(reflectionUv, 1.0 - reflectionUv);\n valid = min(valid, smoothstep(0.0, 0.025, min(movedBorder.x, movedBorder.y)));\n }\n vec3 mirrored = textureLod(uReflectionMap, clamp(reflectionUv, 0.0, 1.0), 0.0).rgb;\n color = mix(color, mirrored, valid * uReflectionStrength * (0.25 + grazing * 0.75));\n }\n\n // The same fog every other pass applies \u2014 and now literally the same code, which\n // it was not: this ran its own curve while claiming otherwise, so a slick receded\n // at a different rate than the deck it was lying on.\n float fog = mediumFog(length(uCameraPos - vWorld), vWorld.y);\n color = mix(color, mediumColor(), fog);\n\n /*\n * Alpha from coverage, squared.\n *\n * Squared because a linear ramp across the rim still reads as a visible band \u2014\n * the eye finds the constant-gradient edge. Squaring pushes the falloff outward\n * so the slick has a soft shoulder and no perceptible boundary. The sheen fades\n * with it, which is right: the thinnest part of a film is the least iridescent.\n */\n /*\n * Alpha from coverage, and the rim is what is squared \u2014 not the whole patch.\n *\n * Squaring the coverage outright made the *middle* translucent too, which is most of why a\n * slick could not be found. The shoulder still needs to be soft, so the square is mixed in\n * near the edge only: solid through the body, dissolving at the rim.\n */\n float coverage = clamp(vCoverage, 0.0, 1.0);\n float alpha = mix(coverage * coverage, coverage, 0.65) * 0.92;\n fragColor = vec4(color, alpha);\n}\n";
|
|
15
|
+
export declare const FILM_FRAG = "#version 300 es\nprecision highp float;\n\nin vec3 vWorld;\nin vec3 vNormal;\nin vec3 vTint;\nin vec4 vReflectionClip;\n/*\n * The scene mirrored across the plane this film lies on, and how much of it to show.\n *\n * **Sheen is iridescence and this is the image**, which is the split a consumer asked for after\n * finding that raising sheen to make a road wetter only made it oilier. A wet street at night is\n * defined by everything above it appearing twice, and no amount of interference pattern is that.\n *\n * The projection is the water shader's, unchanged and deliberately so: reproducing it outside the\n * engine is the piece most likely to be subtly wrong and least likely to be noticed, since a\n * mirror off by a small skew looks like a wet road until somebody puts a straight line across it.\n */\nuniform sampler2D uReflectionMap;\nuniform int uReflectionEnabled;\nuniform float uReflectionStrength;\nin float vCoverage;\n\nuniform vec3 uCameraPos;\nuniform float uTime;\n/*\n * The mirrored pass clips at the water plane, and it does so **in the fragment\n * shader** rather than with gl_ClipDistance.\n *\n * That is not a style choice. Clip distances in WebGL2 need\n * EXT_clip_cull_distance both enabled on the context and declared with an\n * #extension line, and without the declaration the shader fails to *compile* \u2014\n * which, since programs are built when the renderer is constructed, takes the\n * whole boot with it and leaves a black screen. This shader had never been\n * compiled when it was written, so the mistake sat harmlessly in the file until\n * the day it was wired to something. Discarding is what flat.ts does, it needs\n * no extension, and a film is a thin translucent layer where the cost of a\n * discarded fragment is nothing.\n */\nuniform vec4 uClipPlane;\nuniform int uClipEnabled;\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/** How strong the sheen is. Zero leaves a plain dark film. */\nuniform float uSheen;\n\n/**\n * How rough the film's own surface is, 0 to 1, and how coarse that roughness is.\n *\n * **A film had exactly one finish before this: mirror.** That is right for standing water and\n * wrong for the thing this pass is most used for, because a wet road is not a mirror \u2014 it is\n * water lying *in* aggregate, and the stone underneath breaks the reflection into a smear. With\n * no way to say so, a patch of film over a textured road met it at a hard rim: mirror on one\n * side, stone on the other, with nothing able to cross it. Feathering the coverage cannot fix\n * that, and it was tried \u2014 the alpha was never what the eye was reading.\n *\n * So the reflection is displaced by a noise field rather than blurred. **Displaced, because a\n * blur is the wrong phenomenon**: a rough surface does not soften what it reflects, it scatters\n * it, and a lamp on wet tarmac smears into a streak rather than becoming a soft disc. It is also\n * what a blur cannot afford here \u2014 the mirror has no mip chain (see the `textureLod` note\n * below), so softening it honestly would cost several taps where this costs none.\n *\n * `uFilmRoughnessCycles` is bumps to the metre, and it is the same quantity\n * `Renderer.setSurfaceRelief` takes. **Matching it to the surface underneath is the point**:\n * given the same field at the same scale, the water is broken up by the aggregate that the dry\n * road beside it is showing, and the two stop being different materials.\n *\n * Zero is the mirror this pass has always been, exactly, so nothing that does not ask for\n * roughness renders differently.\n */\nuniform float uFilmRoughness;\nuniform float uFilmRoughnessCycles;\n\nout vec4 fragColor;\n\n/** 0 none, 1 sRGB, 2 ACES then sRGB, 3 the highlight shoulder then sRGB. See renderQuality.ts. */\nuniform int uOutputTransform;\n/** Scales the scene into the tone curve. 1 is the reference grade. See renderQuality.ts. */\nuniform float uOutputExposure;\n\n/*\n * ACES: the reference fit, not the cheap one.\n *\n * This was Narkowicz's single rational function, on the argument that the difference from\n * the reference curve is invisible next to the gain. That argument holds for a look and\n * fails for a *match*, which is what this option is for \u2014 aces exists so a world ported\n * from three.js reads the same, and three.js applies the full fit: into AP1 through\n * ACESInputMat, the RRT and ODT together, then back out through ACESOutputMat.\n *\n * The two curves are not a shade apart. Narkowicz is markedly brighter through the\n * midtones, which is exactly where an interior scene lives, and it is a large part of a\n * measured ~1.7x that survived correcting the colour space and the missing Lambert pi.\n * An approximation of a standard is the wrong thing to hold when the standard is the\n * specification.\n */\nvec3 rrtAndOdtFit(vec3 v) {\n vec3 a = v * (v + 0.0245786) - 0.000090537;\n vec3 b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return a / b;\n}\n\nvec3 acesFilmic(vec3 x) {\n /*\n * Exposure first, because a tone curve is only defined relative to one.\n *\n * The reference RRT and ODT are graded for a dark cinema. Every renderer that ships ACES\n * scales its input to suit whatever it is actually shown on, and the factor each one\n * picks is a *look*, not a standard - so this engine takes no position and reads it from\n * the caller. Neutral by default, which is the reference grade unmodified. three.js's own\n * pick is 1 / 0.6, for a consumer matching one.\n */\n x *= uOutputExposure;\n // Columns, as GLSL reads them: sRGB -> XYZ -> D65_2_D60 -> AP1 -> RRT_SAT.\n const mat3 ACES_INPUT = mat3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777\n );\n // ODT_SAT -> XYZ -> D60_2_D65 -> sRGB.\n const mat3 ACES_OUTPUT = mat3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602\n );\n return clamp(ACES_OUTPUT * rrtAndOdtFit(ACES_INPUT * x), 0.0, 1.0);\n}\n\n\n/*\n * Linear to sRGB. The piecewise form rather than a plain pow: the linear toe near black\n * is where banding lives in an 8-bit buffer, and the cheap approximation puts it in the\n * wrong place.\n */\nvec3 linearToSrgb(vec3 c) {\n vec3 low = c * 12.92;\n vec3 high = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;\n return mix(high, low, step(c, vec3(0.0031308)));\n}\n\n\nvec3 highlightShoulder(vec3 c) {\n float m = max(c.r, max(c.g, c.b));\n if (m <= 0.8) return c;\n float e = m - 0.8;\n return c * ((0.8 + 0.2 * e / (e + 0.2)) / m);\n}\n\nvec3 applyOutputTransform(vec3 c) {\n if (uOutputTransform == 0) return c;\n if (uOutputTransform == 2) c = acesFilmic(c);\n if (uOutputTransform == 3) c = highlightShoulder(c * uOutputExposure);\n return linearToSrgb(c);\n}\n\n\n/** A hue wheel, without a texture. Standard 6-segment ramp. */\nvec3 hue(float h) {\n h = fract(h) * 6.0;\n vec3 c = clamp(vec3(abs(h - 3.0) - 1.0, 2.0 - abs(h - 2.0), 2.0 - abs(h - 4.0)), 0.0, 1.0);\n return c;\n}\n\n/**\n * The same value noise `flat.ts` builds its relief from, and deliberately the same.\n *\n * Copied rather than shared because these two shaders have no common include beyond the fog, and\n * a film that used a *different* field would defeat the whole purpose of taking a cycle count:\n * the water would be broken up by one pattern and the road by another, which is two materials\n * again with extra steps.\n */\nfloat filmHash(vec3 p) {\n p = fract(p * 0.1031);\n p += dot(p, p.zyx + 31.32);\n return fract((p.x + p.y) * p.z);\n}\n\nfloat filmNoise(vec3 p) {\n vec3 i = floor(p);\n vec3 f = fract(p);\n vec3 u = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(\n mix(filmHash(i + vec3(0.0, 0.0, 0.0)), filmHash(i + vec3(1.0, 0.0, 0.0)), u.x),\n mix(filmHash(i + vec3(0.0, 1.0, 0.0)), filmHash(i + vec3(1.0, 1.0, 0.0)), u.x),\n u.y\n ),\n mix(\n mix(filmHash(i + vec3(0.0, 0.0, 1.0)), filmHash(i + vec3(1.0, 0.0, 1.0)), u.x),\n mix(filmHash(i + vec3(0.0, 1.0, 1.0)), filmHash(i + vec3(1.0, 1.0, 1.0)), u.x),\n u.y\n ),\n u.z\n );\n}\n\nvoid main() {\n if (uClipEnabled != 0 && dot(vec4(vWorld, 1.0), uClipPlane) < 0.0) discard;\n\n vec3 view = normalize(uCameraPos - vWorld);\n float facing = max(dot(normalize(vNormal), view), 0.0);\n /*\n * Fresnel-ish: dark looking straight down at it, bright at a grazing angle.\n * This is the whole effect \u2014 it is what makes a wet patch look wet rather\n * than looking like a patch of dark paint.\n */\n float grazing = pow(1.0 - facing, 3.0);\n\n /*\n * A slow travelling interference, so the sheen crawls rather than sitting\n * still. Two frequencies that do not divide each other, or the pattern\n * repeats visibly across a long slick.\n */\n float ripple =\n sin(vWorld.x * 0.73 + vWorld.z * 0.41 + uTime * 0.55) *\n cos(vWorld.z * 0.91 - vWorld.x * 0.29 - uTime * 0.37);\n\n vec3 sheen = hue(grazing * 1.35 + ripple * 0.14 + 0.55);\n /*\n * Dark underneath, so the film always reads as *less* than the surface it lies on even where\n * the sheen is strongest \u2014 but not so dark that it disappears.\n *\n * A slick that has just launched somebody has to be findable afterwards. It was rounded,\n * feathered and iridescent \u2014 and at a fifth of an opaque tint on a pale deck at dusk,\n * invisible. A hazard you cannot find is not a hazard, it is a trap.\n *\n * So the base is darker and the sheen has a floor: even looking straight down, where the\n * Fresnel term is nearly nothing, a slick is a distinctly wet patch rather than a faint one.\n */\n vec3 base = vTint * 0.9;\n vec3 color = base + sheen * uSheen * (0.22 + grazing * 0.75);\n\n /*\n * **Graded before the mirror is mixed in**, as every pass grades what it draws when nothing after\n * it will, because the mirror's own passes graded what it holds. The fog that used to follow the\n * mirror distributes over the mix, so the film's own colour is fogged and graded here, and what\n * the mirror shows is fogged toward the same air, graded, where it is mixed in below.\n */\n // The same fog every other pass applies \u2014 and now literally the same code, which\n // it was not: this ran its own curve while claiming otherwise, so a slick receded\n // at a different rate than the deck it was lying on.\n float fog = mediumFog(length(uCameraPos - vWorld), vWorld.y);\n vec3 gradedAir = applyOutputTransform(mediumColor());\n vec3 graded = applyOutputTransform(mix(color, mediumColor(), fog));\n\n /*\n * The mirrored scene, under the sheen rather than instead of it.\n *\n * Screen-space from the mirrored clip position, exactly as the water pass does it, with the\n * same fade as the sample leaves the target: a reflection that stopped dead at the edge of its\n * own buffer would draw a line across the road where nothing is.\n *\n * Weighted by the grazing term as well as by strength, because that is what a reflection does:\n * a wet surface underfoot shows almost nothing and the same surface down the street shows the\n * whole scene. Fogged as the film is, so the image recedes with everything else in the frame.\n */\n if (uReflectionEnabled != 0 && uReflectionStrength > 0.0 && vReflectionClip.w > 0.0) {\n vec2 reflectionUv = vReflectionClip.xy / vReflectionClip.w * 0.5 + 0.5;\n vec2 border = min(reflectionUv, 1.0 - reflectionUv);\n float valid = smoothstep(0.0, 0.025, min(border.x, border.y));\n /*\n * An explicit level, not an implicit one, and the branch above is the whole reason.\n *\n * An implicit derivative inside control flow that is not provably uniform is what lets a\n * compiler flatten the branch and pay for every arm \u2014 AGENTS.md, 2026-08-07 \u2014 and WGSL\n * does not merely warn: the module fails to compile with \"textureSample must only be\n * called from uniform control flow\", which invalidates the pipeline, which invalidates\n * the command buffer, and the frame still presents. The fifth time on this branch. The\n * mirror has no mip chain, so level 0 is the only level there is; the water shader\n * reaches the same conclusion for the same lookup.\n */\n /*\n * The aggregate under the water, as a displacement of what the water shows.\n *\n * The gradient of the same field the road's relief uses, taken across the surface and used\n * to push the lookup. **Stretched along the reflection rather than isotropic**, and that is\n * the half that makes it read as a road: a reflection on a rough horizontal surface smears\n * *towards the viewer*, because a bump tilts the ray in the plane it is already travelling\n * in. Displacing in a circle gives frosted glass instead, which is a different wet thing.\n *\n * Scaled by the grazing term for the same reason the mirror is: looking straight down at a\n * puddle the reflection is compressed into almost nothing and a displacement of it would be\n * enormous, so the scatter has to fall off exactly where the image does.\n */\n if (uFilmRoughness > 0.0) {\n vec3 at = vWorld * uFilmRoughnessCycles;\n float here = filmNoise(at);\n vec2 slope = vec2(\n filmNoise(at + vec3(0.5, 0.0, 0.0)) - here,\n filmNoise(at + vec3(0.0, 0.0, 0.5)) - here\n );\n /* Along the view, which on a horizontal film is where the image runs. */\n vec2 along = normalize(vec2(view.x, view.z) + vec2(1e-5, 0.0));\n vec2 smear = along * dot(slope, along) * 3.0 + slope * 0.35;\n reflectionUv += smear * uFilmRoughness * 0.016 * (0.2 + grazing * 0.8);\n /* Re-fade at the border: a displaced sample can leave the target the original did not. */\n vec2 movedBorder = min(reflectionUv, 1.0 - reflectionUv);\n valid = min(valid, smoothstep(0.0, 0.025, min(movedBorder.x, movedBorder.y)));\n }\n vec3 mirrored = textureLod(uReflectionMap, clamp(reflectionUv, 0.0, 1.0), 0.0).rgb;\n graded = mix(\n graded,\n mix(mirrored, gradedAir, fog),\n valid * uReflectionStrength * (0.25 + grazing * 0.75)\n );\n }\n\n /*\n * Alpha from coverage, squared.\n *\n * Squared because a linear ramp across the rim still reads as a visible band \u2014\n * the eye finds the constant-gradient edge. Squaring pushes the falloff outward\n * so the slick has a soft shoulder and no perceptible boundary. The sheen fades\n * with it, which is right: the thinnest part of a film is the least iridescent.\n */\n /*\n * Alpha from coverage, and the rim is what is squared \u2014 not the whole patch.\n *\n * Squaring the coverage outright made the *middle* translucent too, which is most of why a\n * slick could not be found. The shoulder still needs to be soft, so the square is mixed in\n * near the edge only: solid through the body, dissolving at the rim.\n */\n float coverage = clamp(vCoverage, 0.0, 1.0);\n float alpha = mix(coverage * coverage, coverage, 0.65) * 0.92;\n fragColor = vec4(graded, alpha);\n}\n";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FOG_GLSL } from './fog.js';
|
|
2
|
+
import { OUTPUT_TRANSFORM_GLSL } from './outputTransform.js';
|
|
2
3
|
/**
|
|
3
4
|
* A thin, wet, iridescent film lying on a surface.
|
|
4
5
|
*
|
|
@@ -120,6 +121,7 @@ uniform float uFilmRoughness;
|
|
|
120
121
|
uniform float uFilmRoughnessCycles;
|
|
121
122
|
|
|
122
123
|
out vec4 fragColor;
|
|
124
|
+
${OUTPUT_TRANSFORM_GLSL}
|
|
123
125
|
|
|
124
126
|
/** A hue wheel, without a texture. Standard 6-segment ramp. */
|
|
125
127
|
vec3 hue(float h) {
|
|
@@ -197,6 +199,19 @@ void main() {
|
|
|
197
199
|
vec3 base = vTint * 0.9;
|
|
198
200
|
vec3 color = base + sheen * uSheen * (0.22 + grazing * 0.75);
|
|
199
201
|
|
|
202
|
+
/*
|
|
203
|
+
* **Graded before the mirror is mixed in**, as every pass grades what it draws when nothing after
|
|
204
|
+
* it will, because the mirror's own passes graded what it holds. The fog that used to follow the
|
|
205
|
+
* mirror distributes over the mix, so the film's own colour is fogged and graded here, and what
|
|
206
|
+
* the mirror shows is fogged toward the same air, graded, where it is mixed in below.
|
|
207
|
+
*/
|
|
208
|
+
// The same fog every other pass applies — and now literally the same code, which
|
|
209
|
+
// it was not: this ran its own curve while claiming otherwise, so a slick receded
|
|
210
|
+
// at a different rate than the deck it was lying on.
|
|
211
|
+
float fog = mediumFog(length(uCameraPos - vWorld), vWorld.y);
|
|
212
|
+
vec3 gradedAir = applyOutputTransform(mediumColor());
|
|
213
|
+
vec3 graded = applyOutputTransform(mix(color, mediumColor(), fog));
|
|
214
|
+
|
|
200
215
|
/*
|
|
201
216
|
* The mirrored scene, under the sheen rather than instead of it.
|
|
202
217
|
*
|
|
@@ -206,7 +221,7 @@ void main() {
|
|
|
206
221
|
*
|
|
207
222
|
* Weighted by the grazing term as well as by strength, because that is what a reflection does:
|
|
208
223
|
* a wet surface underfoot shows almost nothing and the same surface down the street shows the
|
|
209
|
-
* whole scene.
|
|
224
|
+
* whole scene. Fogged as the film is, so the image recedes with everything else in the frame.
|
|
210
225
|
*/
|
|
211
226
|
if (uReflectionEnabled != 0 && uReflectionStrength > 0.0 && vReflectionClip.w > 0.0) {
|
|
212
227
|
vec2 reflectionUv = vReflectionClip.xy / vReflectionClip.w * 0.5 + 0.5;
|
|
@@ -252,15 +267,13 @@ void main() {
|
|
|
252
267
|
valid = min(valid, smoothstep(0.0, 0.025, min(movedBorder.x, movedBorder.y)));
|
|
253
268
|
}
|
|
254
269
|
vec3 mirrored = textureLod(uReflectionMap, clamp(reflectionUv, 0.0, 1.0), 0.0).rgb;
|
|
255
|
-
|
|
270
|
+
graded = mix(
|
|
271
|
+
graded,
|
|
272
|
+
mix(mirrored, gradedAir, fog),
|
|
273
|
+
valid * uReflectionStrength * (0.25 + grazing * 0.75)
|
|
274
|
+
);
|
|
256
275
|
}
|
|
257
276
|
|
|
258
|
-
// The same fog every other pass applies — and now literally the same code, which
|
|
259
|
-
// it was not: this ran its own curve while claiming otherwise, so a slick receded
|
|
260
|
-
// at a different rate than the deck it was lying on.
|
|
261
|
-
float fog = mediumFog(length(uCameraPos - vWorld), vWorld.y);
|
|
262
|
-
color = mix(color, mediumColor(), fog);
|
|
263
|
-
|
|
264
277
|
/*
|
|
265
278
|
* Alpha from coverage, squared.
|
|
266
279
|
*
|
|
@@ -278,6 +291,6 @@ void main() {
|
|
|
278
291
|
*/
|
|
279
292
|
float coverage = clamp(vCoverage, 0.0, 1.0);
|
|
280
293
|
float alpha = mix(coverage * coverage, coverage, 0.65) * 0.92;
|
|
281
|
-
fragColor = vec4(
|
|
294
|
+
fragColor = vec4(graded, alpha);
|
|
282
295
|
}
|
|
283
296
|
`;
|
|
@@ -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\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 outColor = vec4(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/** 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";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FOG_GLSL } from './fog.js';
|
|
2
|
+
import { OUTPUT_TRANSFORM_GLSL } from './outputTransform.js';
|
|
2
3
|
/**
|
|
3
4
|
* Procedural flames: camera-facing quads whose shape and colour come entirely
|
|
4
5
|
* from animated noise. No sprite sheets, no image assets — and no static
|
|
@@ -114,6 +115,7 @@ uniform vec4 uClipPlane;
|
|
|
114
115
|
uniform int uClipEnabled;
|
|
115
116
|
|
|
116
117
|
out vec4 outColor;
|
|
118
|
+
${OUTPUT_TRANSFORM_GLSL}
|
|
117
119
|
|
|
118
120
|
float hash21(vec2 p) {
|
|
119
121
|
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
|
|
@@ -173,6 +175,7 @@ void main() {
|
|
|
173
175
|
col = mix(col, mediumColor(), fog);
|
|
174
176
|
alpha *= 1.0 - fog;
|
|
175
177
|
|
|
176
|
-
|
|
178
|
+
/* Graded where nothing after this will grade it, as every pass is. The alpha is coverage. */
|
|
179
|
+
outColor = vec4(applyOutputTransform(col), alpha);
|
|
177
180
|
}
|
|
178
181
|
`;
|
|
@@ -7,4 +7,4 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Depth zero and after the models, whose `modelLight` it calls; see `flatFrag`.
|
|
9
9
|
*/
|
|
10
|
-
export declare const AREA_LIGHT_GLSL = "\n/**\n * The rectangle's signed form factor at this fragment: its corners **wound so a surface on the\n * emitting side sees them counter-clockwise**, which is what makes it positive there. The natural\n * order \u2014 minus, plus, plus, minus \u2014 winds the other way, and every one-sided light then clamped to\n * zero and the frame was black. The sign is the sidedness: a one-sided rectangle takes the positive\n * part and a two-sided one the magnitude.\n */\nfloat areaSignedForm(vec3 n, vec3 centre, vec3 right, vec3 up, vec2 halfSize) {\n vec3 c0 = centre - right * halfSize.x + up * halfSize.y;\n vec3 c1 = centre + right * halfSize.x + up * halfSize.y;\n vec3 c2 = centre + right * halfSize.x - up * halfSize.y;\n vec3 c3 = centre - right * halfSize.x - up * halfSize.y;\n return quadFormFactor(n, vWorldPos, c0, c1, c2, c3);\n}\n\n/**\n * How far along one of a rectangle's axes a fragment still sees it past the barn door on that axis's\n * positive edge: `p` the fragment's place along the axis, `height` its height above the\n * rectangle's plane, `halfSize` the rectangle's half extent, and the door's tip `tipOut` beyond\n * the edge and `tipUp` above the plane. Points of the rectangle past the answer are hidden.\n *\n * Above the tip, the tip's shadow on the plane as seen from the fragment is the limit. Below it, a\n * fragment beside the door and outside its line is hidden from all of it, and one inside the door's\n * reach is hidden from none of it.\n */\nfloat barnDoorLimit(float p, float height, float halfSize, float tipOut, float tipUp) {\n float tipAt = halfSize + tipOut;\n if (height > tipUp) return (height * tipAt - tipUp * p) / (height - tipUp);\n if (p > halfSize && height * tipOut < tipUp * (p - halfSize)) return -1e9;\n return 1e9;\n}\n\n/**\n * The part of a rectangle a fragment sees past its barn doors, written into `centre` and\n * `halfSize` as the smaller rectangle that is left, or false where the doors hide all of it.\n * `doors` is the cosine of their angle from the normal and their length; a length of 0 is no doors.\n * Each axis is clipped by its own two doors, which is what makes this four projections and a min.\n */\nbool areaBarnDoors(inout vec3 centre, vec3 right, vec3 up, inout vec2 halfSize, vec2 doors) {\n if (doors.y <= 0.0) return true;\n vec3 toFragment = vWorldPos - centre;\n float height = dot(toFragment, cross(right, up));\n /* Behind the emitting side: nothing to clip, and the form factor already answers it. */\n if (height <= 0.0) return true;\n float tipOut = doors.y * sqrt(max(1.0 - doors.x * doors.x, 0.0));\n float tipUp = doors.y * doors.x;\n vec2 p = vec2(dot(toFragment, right), dot(toFragment, up));\n vec2 hi = vec2(\n min(halfSize.x, barnDoorLimit(p.x, height, halfSize.x, tipOut, tipUp)),\n min(halfSize.y, barnDoorLimit(p.y, height, halfSize.y, tipOut, tipUp))\n );\n vec2 lo = vec2(\n max(-halfSize.x, -barnDoorLimit(-p.x, height, halfSize.x, tipOut, tipUp)),\n max(-halfSize.y, -barnDoorLimit(-p.y, height, halfSize.y, tipOut, tipUp))\n );\n if (hi.x <= lo.x || hi.y <= lo.y) return false;\n centre += right * (0.5 * (lo.x + hi.x)) + up * (0.5 * (lo.y + hi.y));\n halfSize = 0.5 * (hi - lo);\n return true;\n}\n\n/**\n * What one rectangle of colour `color` and form factor `form` adds to the lamps' two sums, open\n * and shadowed by `occl` and `glass`.\n *\n * **A model answers a rectangle as it answers a lamp at its centre**, carried at the rectangle's\n * own energy: the diffuse by the exact form factor over the centre's cosine, the specular by the\n * form factor. An approximation, stated: a model's lobe is not integrated over the rectangle as the\n * standard lobe is, so a glossy model under a large softbox shows a lobe from its centre rather than\n * its shape.\n *\n * **The standard lobe is integrated over the rectangle**, rather than sampled at one point on it.\n * What stood here once handed the closest point on the rectangle to `sphereLobe` and scaled it by\n * `form`, the *diffuse* form factor. Measured against a brute-force integral by\n * `scripts/areaSpecular.mjs`, that lost the reflection rather than blurring it: on polished metal\n * facing a softbox it returned 0.000233 where the integral is 0.9207, and head-on on a rough surface\n * it was 3.7 times too bright. Worst 287% either way; this is 40.1% where the light is what the\n * surface reflects and 79.0% for one overhead. `quadCoverage` says what fraction of the specular\n * lobe the rectangle covers and the environment BRDF says how much the surface returns, the split\n * the probe path uses. **`dfg.x + dfg.y` and not `envSpecularEnergy`**, deliberately: the\n * compensation returns the multiply-scattered share, and every direct lobe in this shader is\n * single-scattering, so adding it here returns energy the light never had. Measured at 211%\n * against 40.1%.\n */\nvoid areaLightAdd(\n vec3 centre,\n vec3 right,\n vec3 up,\n vec2 halfSize,\n vec3 color,\n float form,\n float occl,\n vec3 glass,\n vec3 n,\n vec3 albedo,\n float metal,\n vec3 specColor,\n float roughness,\n bool modelled,\n inout vec3 lampOpen,\n inout vec3 lampShadowed\n) {\n if (modelled) {\n vec3 areaL = normalize(centre - vWorldPos);\n modelLight(areaL, 0.0, 0.0);\n vec3 areaModel = (mDiffuse * (form / max(dot(n, areaL), 1e-3)) + mSpecular * form) * color;\n lampOpen += areaModel;\n lampShadowed += areaModel * occl * glass;\n return;\n }\n /* Held back with the point lamps', and shadowed by the same term it always was. */\n vec3 areaDiffuse = albedo * color * form * (1.0 - metal);\n lampOpen += areaDiffuse;\n lampShadowed += areaDiffuse * occl * glass;\n\n if (
|
|
10
|
+
export declare const AREA_LIGHT_GLSL = "\n/**\n * The rectangle's signed form factor at this fragment: its corners **wound so a surface on the\n * emitting side sees them counter-clockwise**, which is what makes it positive there. The natural\n * order \u2014 minus, plus, plus, minus \u2014 winds the other way, and every one-sided light then clamped to\n * zero and the frame was black. The sign is the sidedness: a one-sided rectangle takes the positive\n * part and a two-sided one the magnitude.\n */\nfloat areaSignedForm(vec3 n, vec3 centre, vec3 right, vec3 up, vec2 halfSize) {\n vec3 c0 = centre - right * halfSize.x + up * halfSize.y;\n vec3 c1 = centre + right * halfSize.x + up * halfSize.y;\n vec3 c2 = centre + right * halfSize.x - up * halfSize.y;\n vec3 c3 = centre - right * halfSize.x - up * halfSize.y;\n return quadFormFactor(n, vWorldPos, c0, c1, c2, c3);\n}\n\n/**\n * How far along one of a rectangle's axes a fragment still sees it past the barn door on that axis's\n * positive edge: `p` the fragment's place along the axis, `height` its height above the\n * rectangle's plane, `halfSize` the rectangle's half extent, and the door's tip `tipOut` beyond\n * the edge and `tipUp` above the plane. Points of the rectangle past the answer are hidden.\n *\n * Above the tip, the tip's shadow on the plane as seen from the fragment is the limit. Below it, a\n * fragment beside the door and outside its line is hidden from all of it, and one inside the door's\n * reach is hidden from none of it.\n */\nfloat barnDoorLimit(float p, float height, float halfSize, float tipOut, float tipUp) {\n float tipAt = halfSize + tipOut;\n if (height > tipUp) return (height * tipAt - tipUp * p) / (height - tipUp);\n if (p > halfSize && height * tipOut < tipUp * (p - halfSize)) return -1e9;\n return 1e9;\n}\n\n/**\n * The part of a rectangle a fragment sees past its barn doors, written into `centre` and\n * `halfSize` as the smaller rectangle that is left, or false where the doors hide all of it.\n * `doors` is the cosine of their angle from the normal and their length; a length of 0 is no doors.\n * Each axis is clipped by its own two doors, which is what makes this four projections and a min.\n */\nbool areaBarnDoors(inout vec3 centre, vec3 right, vec3 up, inout vec2 halfSize, vec2 doors) {\n if (doors.y <= 0.0) return true;\n vec3 toFragment = vWorldPos - centre;\n float height = dot(toFragment, cross(right, up));\n /* Behind the emitting side: nothing to clip, and the form factor already answers it. */\n if (height <= 0.0) return true;\n float tipOut = doors.y * sqrt(max(1.0 - doors.x * doors.x, 0.0));\n float tipUp = doors.y * doors.x;\n vec2 p = vec2(dot(toFragment, right), dot(toFragment, up));\n vec2 hi = vec2(\n min(halfSize.x, barnDoorLimit(p.x, height, halfSize.x, tipOut, tipUp)),\n min(halfSize.y, barnDoorLimit(p.y, height, halfSize.y, tipOut, tipUp))\n );\n vec2 lo = vec2(\n max(-halfSize.x, -barnDoorLimit(-p.x, height, halfSize.x, tipOut, tipUp)),\n max(-halfSize.y, -barnDoorLimit(-p.y, height, halfSize.y, tipOut, tipUp))\n );\n if (hi.x <= lo.x || hi.y <= lo.y) return false;\n centre += right * (0.5 * (lo.x + hi.x)) + up * (0.5 * (lo.y + hi.y));\n halfSize = 0.5 * (hi - lo);\n return true;\n}\n\n/**\n * What one rectangle of colour `color` and form factor `form` adds to the lamps' two sums, open\n * and shadowed by `occl` and `glass`.\n *\n * **A model answers a rectangle as it answers a lamp at its centre**, carried at the rectangle's\n * own energy: the diffuse by the exact form factor over the centre's cosine, the specular by the\n * form factor. An approximation, stated: a model's lobe is not integrated over the rectangle as the\n * standard lobe is, so a glossy model under a large softbox shows a lobe from its centre rather than\n * its shape.\n *\n * **The standard lobe is integrated over the rectangle**, rather than sampled at one point on it.\n * What stood here once handed the closest point on the rectangle to `sphereLobe` and scaled it by\n * `form`, the *diffuse* form factor. Measured against a brute-force integral by\n * `scripts/areaSpecular.mjs`, that lost the reflection rather than blurring it: on polished metal\n * facing a softbox it returned 0.000233 where the integral is 0.9207, and head-on on a rough surface\n * it was 3.7 times too bright. Worst 287% either way; this is 40.1% where the light is what the\n * surface reflects and 79.0% for one overhead. `quadCoverage` says what fraction of the specular\n * lobe the rectangle covers and the environment BRDF says how much the surface returns, the split\n * the probe path uses. **`dfg.x + dfg.y` and not `envSpecularEnergy`**, deliberately: the\n * compensation returns the multiply-scattered share, and every direct lobe in this shader is\n * single-scattering, so adding it here returns energy the light never had. Measured at 211%\n * against 40.1%.\n */\nvoid areaLightAdd(\n vec3 centre,\n vec3 right,\n vec3 up,\n vec2 halfSize,\n vec3 color,\n float form,\n float occl,\n vec3 glass,\n vec3 n,\n vec3 albedo,\n float metal,\n vec3 specColor,\n float roughness,\n bool modelled,\n inout vec3 lampOpen,\n inout vec3 lampShadowed\n) {\n if (modelled) {\n vec3 areaL = normalize(centre - vWorldPos);\n modelLight(areaL, 0.0, 0.0);\n vec3 areaModel = (mDiffuse * (form / max(dot(n, areaL), 1e-3)) + mSpecular * form) * color;\n lampOpen += areaModel;\n lampShadowed += areaModel * occl * glass;\n return;\n }\n /* Held back with the point lamps', and shadowed by the same term it always was. */\n vec3 areaDiffuse = albedo * color * form * (1.0 - metal);\n lampOpen += areaDiffuse;\n lampShadowed += areaDiffuse * occl * glass;\n\n if (surfaceSpecular > 0.0 || metal > 0.0) {\n vec3 c0 = centre - right * halfSize.x + up * halfSize.y;\n vec3 c1 = centre + right * halfSize.x + up * halfSize.y;\n vec3 c2 = centre + right * halfSize.x - up * halfSize.y;\n vec3 c3 = centre - right * halfSize.x - up * halfSize.y;\n vec3 toEyeArea = normalize(uCameraPos - vWorldPos);\n float coverage = quadCoverage(n, vWorldPos, toEyeArea, roughness, c0, c1, c2, c3);\n if (coverage > 0.0) {\n vec2 areaDfg = envBrdfApprox(max(dot(n, toEyeArea), 0.0), roughness);\n vec3 areaSpec = specColor * areaDfg.x + vec3(areaDfg.y);\n vec3 areaHighlight = areaSpec * color * coverage;\n lampOpen += areaHighlight;\n lampShadowed += areaHighlight * occl * glass;\n }\n }\n}\n";
|
|
@@ -123,7 +123,7 @@ void areaLightAdd(
|
|
|
123
123
|
lampOpen += areaDiffuse;
|
|
124
124
|
lampShadowed += areaDiffuse * occl * glass;
|
|
125
125
|
|
|
126
|
-
if (
|
|
126
|
+
if (surfaceSpecular > 0.0 || metal > 0.0) {
|
|
127
127
|
vec3 c0 = centre - right * halfSize.x + up * halfSize.y;
|
|
128
128
|
vec3 c1 = centre + right * halfSize.x + up * halfSize.y;
|
|
129
129
|
vec3 c2 = centre + right * halfSize.x - up * halfSize.y;
|
|
@@ -8,7 +8,7 @@ export declare function glassTintGlsl(glassShadows: boolean): string;
|
|
|
8
8
|
* The lit stage's switches, by the name of the constant each is: the decision both backends make
|
|
9
9
|
* about which optional code a lit program carries. See `litSwitchesGlsl`.
|
|
10
10
|
*/
|
|
11
|
-
export type LitSwitch = 'GLASS_SHADOWS' | 'CLUSTERED_LIGHTS' | 'LIGHT_FIXTURES' | 'SURFACE_EFFECTS' | 'DRIFT_LIGHT' | 'PHYSICAL_SPECULAR' | 'SURFACE_OVERLAY' | 'REFLECTION_MAPS' | 'WORLD_UVS' | 'MOVING_SUN' | 'LAYERED';
|
|
11
|
+
export type LitSwitch = 'GLASS_SHADOWS' | 'CLUSTERED_LIGHTS' | 'LIGHT_FIXTURES' | 'SURFACE_EFFECTS' | 'DRIFT_LIGHT' | 'PHYSICAL_SPECULAR' | 'SURFACE_OVERLAY' | 'REFLECTION_MAPS' | 'WORLD_UVS' | 'MOVING_SUN' | 'LAYERED' | 'LAYER_LOOKS';
|
|
12
12
|
/** Every switch off: what a renderer starts from before its profile and its content say otherwise. */
|
|
13
13
|
export declare function noLitSwitches(): Record<LitSwitch, boolean>;
|
|
14
14
|
/** Which of the lit stage's optional features a build reads. See `litSwitchesGlsl`. */
|
|
@@ -140,6 +140,11 @@ export interface FlatShaderOptions {
|
|
|
140
140
|
* True when absent. See \`layeredGlsl\`.
|
|
141
141
|
*/
|
|
142
142
|
readonly layered?: boolean;
|
|
143
|
+
/**
|
|
144
|
+
* Whether a layered material's layers are picked from shared arrays and given looks of their own,
|
|
145
|
+
* \`uLayerLooks\` and all. True when absent; nothing without \`layered\`. See \`layerLooksGlsl\`.
|
|
146
|
+
*/
|
|
147
|
+
readonly layerLooks?: boolean;
|
|
143
148
|
/**
|
|
144
149
|
* How many point lights this build declares room for. `MAX_POINT_LIGHTS` when absent.
|
|
145
150
|
*
|
|
@@ -513,7 +513,7 @@ export function flatFrag(options) {
|
|
|
513
513
|
/* After the reflection maps, so its switch takes the next id; main calls it. worldUv.ts. */
|
|
514
514
|
worldUvGlsl(options.worldUvs ?? true),
|
|
515
515
|
/* After the projection, so its switch takes the next id; the normal map below calls it. */
|
|
516
|
-
layeredGlsl(options.layered ?? true),
|
|
516
|
+
layeredGlsl(options.layered ?? true, options.layerLooks ?? true),
|
|
517
517
|
/* Where each map is read, after both of the ways it can be read otherwise. See mapReads.ts. */
|
|
518
518
|
MAP_READS_GLSL,
|
|
519
519
|
/* After the frame and the projection it calls, unconditional for the same reason as the
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which layer of the shared arrays each of a layered material's layers reads, and how each looks:
|
|
3
|
+
* `uLayerLooks`, packed by `surfaceLayerLooks.ts`, read by `layered.ts`.
|
|
4
|
+
*
|
|
5
|
+
* **Every helper is the identity where the switch is off**, written as a branch on `LAYER_LOOKS`
|
|
6
|
+
* itself because the generated WGSL carries one string for both values of an override: layer `i`
|
|
7
|
+
* reads layer `i`, the maps beyond the layers start at the layer count, a tint is white, a range is
|
|
8
|
+
* 0 to 1, a normal is as stored, a specular is the surface's own, a repeat down is the repeat
|
|
9
|
+
* across, and nothing is occluded — exactly what `layered.ts` did before this existed, which is what
|
|
10
|
+
* a layered material that asks for none of it has to keep drawing.
|
|
11
|
+
*
|
|
12
|
+
* **A lit switch, `LAYER_LOOKS`**, off until a material first asks and declared after `LAYERED`, so
|
|
13
|
+
* every switch before it keeps its id. It is only ever compiled beside `LAYERED`, whose `uLayers`
|
|
14
|
+
* it reads.
|
|
15
|
+
*/
|
|
16
|
+
export declare function layerLooksGlsl(on: boolean): string;
|