@driftengine/core 4.2.1 → 4.4.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 +272 -0
- package/README.md +1 -1
- package/dist/cinematic/cameraPath.d.ts +53 -0
- package/dist/cinematic/cameraPath.js +118 -0
- package/dist/environment/daylight.d.ts +77 -0
- package/dist/environment/daylight.js +77 -0
- package/dist/geometry/meshConcat.d.ts +9 -0
- package/dist/geometry/meshConcat.js +101 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +14 -1
- package/dist/render/backend/createRenderer.d.ts +13 -1
- package/dist/render/backend/createRenderer.js +6 -5
- package/dist/render/backend/webgl2/renderer.d.ts +166 -1
- package/dist/render/backend/webgl2/renderer.js +424 -23
- package/dist/render/backend/webgpu/buffers.js +46 -8
- package/dist/render/backend/webgpu/depthPass.d.ts +81 -3
- package/dist/render/backend/webgpu/depthPass.js +75 -14
- package/dist/render/backend/webgpu/exposurePass.d.ts +39 -0
- package/dist/render/backend/webgpu/exposurePass.js +190 -0
- package/dist/render/backend/webgpu/fieldCompose.d.ts +11 -0
- package/dist/render/backend/webgpu/fieldCompose.js +13 -0
- package/dist/render/backend/webgpu/flatPass.d.ts +3 -1
- package/dist/render/backend/webgpu/flatPass.js +12 -5
- package/dist/render/backend/webgpu/particlePass.d.ts +8 -0
- package/dist/render/backend/webgpu/particlePass.js +1 -0
- package/dist/render/backend/webgpu/postPass.d.ts +12 -2
- package/dist/render/backend/webgpu/probeBake.d.ts +59 -3
- package/dist/render/backend/webgpu/probeBake.js +97 -7
- package/dist/render/backend/webgpu/probeTargets.d.ts +65 -0
- package/dist/render/backend/webgpu/probeTargets.js +180 -0
- package/dist/render/backend/webgpu/renderer.d.ts +176 -11
- package/dist/render/backend/webgpu/renderer.js +680 -250
- package/dist/render/backend/webgpu/scatterPass.js +7 -7
- package/dist/render/backend/webgpu/shaders/clusterBin.wgsl.d.ts +1 -1
- package/dist/render/backend/webgpu/skinPaletteRing.d.ts +4 -0
- package/dist/render/backend/webgpu/skinPaletteRing.js +9 -0
- package/dist/render/backend/webgpu/skyPass.js +5 -1
- package/dist/render/backend/webgpu/uniformRing.d.ts +31 -1
- package/dist/render/backend/webgpu/uniformRing.js +53 -3
- package/dist/render/bloomChain.d.ts +6 -0
- package/dist/render/bloomChain.js +8 -0
- package/dist/render/clusteredLights.d.ts +19 -11
- package/dist/render/clusteredLights.js +96 -45
- package/dist/render/cutoutCaster.d.ts +23 -0
- package/dist/render/cutoutCaster.js +20 -0
- package/dist/render/driftLight/bake.d.ts +34 -0
- package/dist/render/driftLight/bake.js +95 -0
- package/dist/render/driftLight/falloff.d.ts +16 -0
- package/dist/render/driftLight/falloff.js +23 -0
- package/dist/render/driftLight/layout.d.ts +59 -0
- package/dist/render/driftLight/layout.js +138 -0
- package/dist/render/driftLight/lightField.d.ts +108 -0
- package/dist/render/driftLight/lightField.js +179 -0
- package/dist/render/driftLight/uniforms.d.ts +28 -0
- package/dist/render/driftLight/uniforms.js +41 -0
- package/dist/render/driftLight/visibility.d.ts +18 -0
- package/dist/render/driftLight/visibility.js +47 -0
- package/dist/render/emptyTexture.d.ts +6 -0
- package/dist/render/emptyTexture.js +19 -0
- package/dist/render/exposurePass.d.ts +48 -0
- package/dist/render/exposurePass.js +172 -0
- package/dist/render/flameLight.d.ts +71 -0
- package/dist/render/flameLight.js +63 -0
- package/dist/render/gi/bounceLights.d.ts +34 -0
- package/dist/render/gi/bounceLights.js +66 -0
- package/dist/render/gi/fieldReach.d.ts +12 -0
- package/dist/render/gi/fieldReach.js +62 -0
- package/dist/render/gi/globalField.d.ts +8 -0
- package/dist/render/gi/globalField.js +31 -0
- package/dist/render/gi/probeTrace.d.ts +3 -1
- package/dist/render/gi/probeTrace.js +3 -1
- package/dist/render/gpuCapability.js +19 -4
- package/dist/render/lightBudget.d.ts +11 -0
- package/dist/render/lightBudget.js +11 -0
- package/dist/render/pointLightSelection.d.ts +16 -0
- package/dist/render/pointLightSelection.js +178 -83
- package/dist/render/probeGrid.d.ts +11 -1
- package/dist/render/probeGrid.js +5 -2
- package/dist/render/probeSweeps.d.ts +45 -0
- package/dist/render/probeSweeps.js +77 -0
- package/dist/render/reflectionProbe.d.ts +43 -1
- package/dist/render/reflectionProbe.js +17 -3
- package/dist/render/renderQuality.d.ts +3 -1
- package/dist/render/rendererHarness.d.ts +9 -0
- package/dist/render/rendererHarness.js +18 -3
- package/dist/render/sceneTarget.d.ts +20 -0
- package/dist/render/sceneTarget.js +62 -1
- package/dist/render/shaders/agx.d.ts +31 -0
- package/dist/render/shaders/agx.js +81 -0
- package/dist/render/shaders/ambientOcclusion.d.ts +24 -5
- package/dist/render/shaders/ambientOcclusion.js +120 -63
- package/dist/render/shaders/cutoutCoverage.d.ts +31 -0
- package/dist/render/shaders/cutoutCoverage.js +40 -0
- package/dist/render/shaders/depth.d.ts +25 -0
- package/dist/render/shaders/depth.js +69 -3
- package/dist/render/shaders/exposure.d.ts +52 -0
- package/dist/render/shaders/exposure.js +124 -0
- package/dist/render/shaders/filmLook.d.ts +47 -0
- package/dist/render/shaders/filmLook.js +98 -0
- package/dist/render/shaders/flat/directionalShadow.js +35 -4
- package/dist/render/shaders/flat/driftLight.d.ts +29 -0
- package/dist/render/shaders/flat/driftLight.js +75 -0
- package/dist/render/shaders/flat/index.js +5 -0
- package/dist/render/shaders/flat/lobes.js +13 -0
- package/dist/render/shaders/flat/main.d.ts +1 -1
- package/dist/render/shaders/flat/main.js +68 -9
- package/dist/render/shaders/flat/preamble.js +23 -5
- package/dist/render/shaders/flat/probeGrid.js +46 -3
- package/dist/render/shaders/generated/ambientOcclusion.wgsl.d.ts +1 -1
- package/dist/render/shaders/generated/ambientOcclusion.wgsl.js +1 -1
- package/dist/render/shaders/generated/depth.wgsl.d.ts +100 -0
- package/dist/render/shaders/generated/depth.wgsl.js +100 -0
- package/dist/render/shaders/generated/exposure.wgsl.d.ts +42 -0
- package/dist/render/shaders/generated/exposure.wgsl.js +48 -0
- package/dist/render/shaders/generated/flat.wgsl.d.ts +1800 -1344
- package/dist/render/shaders/generated/flat.wgsl.js +1816 -1360
- package/dist/render/shaders/generated/localExposure.wgsl.d.ts +18 -0
- package/dist/render/shaders/generated/localExposure.wgsl.js +24 -0
- package/dist/render/shaders/generated/rush.wgsl.d.ts +40 -10
- package/dist/render/shaders/generated/rush.wgsl.js +40 -10
- package/dist/render/shaders/generated/temporalResolve.wgsl.d.ts +1 -1
- package/dist/render/shaders/generated/temporalResolve.wgsl.js +1 -1
- package/dist/render/shaders/gi/driftLight.wgsl.d.ts +16 -0
- package/dist/render/shaders/gi/driftLight.wgsl.js +49 -0
- package/dist/render/shaders/gi/probeBake.wgsl.d.ts +29 -4
- package/dist/render/shaders/gi/probeBake.wgsl.js +190 -20
- package/dist/render/shaders/globalMedium.js +4 -0
- package/dist/render/shaders/localExposure.d.ts +35 -0
- package/dist/render/shaders/localExposure.js +138 -0
- package/dist/render/shaders/prefilter.d.ts +1 -1
- package/dist/render/shaders/prefilter.js +6 -0
- package/dist/render/shaders/rush.js +33 -5
- package/dist/render/shaders/tangentFrame.d.ts +1 -1
- package/dist/render/shaders/tangentFrame.js +15 -3
- package/dist/render/shaders/temporalResolve.js +39 -1
- package/dist/render/shadowCasters.d.ts +5 -3
- package/dist/render/shadowMap.js +13 -0
- package/dist/render/surfaceTexture.d.ts +5 -0
- package/package.json +6 -6
- package/scripts/gpuCompute.mjs +24 -3
- package/scripts/png.mjs +23 -12
- package/src/cinematic/cameraPath.ts +178 -0
- package/src/environment/daylight.ts +152 -0
- package/src/geometry/meshConcat.ts +100 -0
- package/src/index.ts +32 -0
- package/src/render/backend/createRenderer.ts +22 -6
- package/src/render/backend/webgl2/renderer.ts +462 -20
- package/src/render/backend/webgpu/buffers.ts +51 -8
- package/src/render/backend/webgpu/depthPass.ts +78 -10
- package/src/render/backend/webgpu/exposurePass.ts +234 -0
- package/src/render/backend/webgpu/fieldCompose.ts +15 -0
- package/src/render/backend/webgpu/flatPass.ts +10 -2
- package/src/render/backend/webgpu/particlePass.ts +9 -0
- package/src/render/backend/webgpu/probeBake.ts +143 -9
- package/src/render/backend/webgpu/probeTargets.ts +211 -0
- package/src/render/backend/webgpu/renderer.ts +807 -271
- package/src/render/backend/webgpu/scatterPass.ts +7 -7
- package/src/render/backend/webgpu/skinPaletteRing.ts +10 -0
- package/src/render/backend/webgpu/skyPass.ts +5 -1
- package/src/render/backend/webgpu/uniformRing.ts +49 -3
- package/src/render/bloomChain.ts +9 -0
- package/src/render/clusteredLights.ts +108 -63
- package/src/render/cutoutCaster.ts +44 -0
- package/src/render/driftLight/bake.ts +125 -0
- package/src/render/driftLight/falloff.ts +26 -0
- package/src/render/driftLight/layout.ts +177 -0
- package/src/render/driftLight/lightField.ts +227 -0
- package/src/render/driftLight/uniforms.ts +61 -0
- package/src/render/driftLight/visibility.ts +58 -0
- package/src/render/emptyTexture.ts +41 -0
- package/src/render/exposurePass.ts +186 -0
- package/src/render/flameLight.ts +135 -0
- package/src/render/gi/bounceLights.ts +68 -0
- package/src/render/gi/fieldReach.ts +74 -0
- package/src/render/gi/globalField.ts +39 -0
- package/src/render/gi/probeTrace.ts +3 -1
- package/src/render/gpuCapability.ts +18 -5
- package/src/render/lightBudget.ts +12 -0
- package/src/render/pointLightSelection.ts +191 -79
- package/src/render/probeGrid.ts +13 -2
- package/src/render/probeSweeps.ts +80 -0
- package/src/render/reflectionProbe.ts +54 -2
- package/src/render/renderQuality.ts +3 -1
- package/src/render/rendererHarness.ts +25 -3
- package/src/render/sceneTarget.ts +75 -1
- package/src/render/shaders/ambientOcclusion.ts +125 -63
- package/src/render/shaders/cutoutCoverage.ts +43 -0
- package/src/render/shaders/depth.ts +80 -3
- package/src/render/shaders/exposure.ts +128 -0
- package/src/render/shaders/filmLook.ts +111 -0
- package/src/render/shaders/flat/directionalShadow.ts +35 -4
- package/src/render/shaders/flat/driftLight.ts +75 -0
- package/src/render/shaders/flat/index.ts +5 -0
- package/src/render/shaders/flat/lobes.ts +13 -0
- package/src/render/shaders/flat/main.ts +68 -9
- package/src/render/shaders/flat/preamble.ts +23 -5
- package/src/render/shaders/flat/probeGrid.ts +46 -3
- package/src/render/shaders/generated/ambientOcclusion.wgsl.ts +1 -1
- package/src/render/shaders/generated/depth.wgsl.ts +103 -0
- package/src/render/shaders/generated/exposure.wgsl.ts +51 -0
- package/src/render/shaders/generated/flat.wgsl.ts +1816 -1360
- package/src/render/shaders/generated/localExposure.wgsl.ts +26 -0
- package/src/render/shaders/generated/rush.wgsl.ts +40 -10
- package/src/render/shaders/generated/temporalResolve.wgsl.ts +1 -1
- package/src/render/shaders/gi/driftLight.wgsl.ts +49 -0
- package/src/render/shaders/gi/probeBake.wgsl.ts +191 -20
- package/src/render/shaders/globalMedium.ts +4 -0
- package/src/render/shaders/localExposure.ts +154 -0
- package/src/render/shaders/prefilter.ts +6 -0
- package/src/render/shaders/rush.ts +33 -5
- package/src/render/shaders/tangentFrame.ts +15 -3
- package/src/render/shaders/temporalResolve.ts +39 -1
- package/src/render/shadowCasters.ts +5 -3
- package/src/render/shadowMap.ts +14 -0
- package/src/render/surfaceTexture.ts +5 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DriftLight's lookup in hand-written WGSL, for the traced probe bake: the summed light arriving on
|
|
3
|
+
* a surface a probe's ray struck, which is how candles past the frame's choice reach the bounce.
|
|
4
|
+
*
|
|
5
|
+
* **A transcription of `flat/driftLight.ts`, line for line**, because a compute shader cannot
|
|
6
|
+
* include the generated WGSL of a fragment stage. Two spellings of one lookup is the hazard the
|
|
7
|
+
* 2026-08-13 rule is about, and `driftLight.test.ts` beside this holds them together by reading
|
|
8
|
+
* both: change one and it names the line of the other.
|
|
9
|
+
*
|
|
10
|
+
* **Integer arithmetic for the brick's corner**, by the 2026-09-20 rule: a device divides a float by
|
|
11
|
+
* multiplying by its reciprocal, and a brick number off by one is a brick from somewhere else.
|
|
12
|
+
*
|
|
13
|
+
* The including module declares `driftIndex: texture_3d<u32>`, `driftAtlas: texture_3d<f32>` and a
|
|
14
|
+
* filtering `driftSampler`; `origin` is the field's first sample and, in `w`, the spacing.
|
|
15
|
+
*/
|
|
16
|
+
export const DRIFT_LIGHT_WGSL = /* wgsl */ `
|
|
17
|
+
fn driftLightIrradiance(world: vec3<f32>, n: vec3<f32>, origin: vec4<f32>) -> vec3<f32> {
|
|
18
|
+
let spacing = origin.w;
|
|
19
|
+
let at = (world + n * (0.5 * spacing) - origin.xyz) / (3.0 * spacing);
|
|
20
|
+
let cell = vec3<i32>(floor(at));
|
|
21
|
+
if (any(cell < vec3<i32>(0)) || any(cell >= vec3<i32>(textureDimensions(driftIndex, 0)))) {
|
|
22
|
+
return vec3<f32>(0.0);
|
|
23
|
+
}
|
|
24
|
+
let id = textureLoad(driftIndex, cell, 0).r;
|
|
25
|
+
if (id == 0u) {
|
|
26
|
+
return vec3<f32>(0.0);
|
|
27
|
+
}
|
|
28
|
+
let size = vec3<i32>(textureDimensions(driftAtlas, 0));
|
|
29
|
+
let brick = i32(id) - 1;
|
|
30
|
+
let across = size.x / 8;
|
|
31
|
+
let down = size.y / 4;
|
|
32
|
+
let corner = vec3<f32>(
|
|
33
|
+
f32((brick % across) * 8),
|
|
34
|
+
f32(((brick / across) % down) * 4),
|
|
35
|
+
f32((brick / (across * down)) * 4)
|
|
36
|
+
);
|
|
37
|
+
let texel = 1.0 / vec3<f32>(size);
|
|
38
|
+
let inside = (at - vec3<f32>(cell)) * 3.0 + 0.5;
|
|
39
|
+
let light = textureSampleLevel(driftAtlas, driftSampler, (corner + inside) * texel, 0.0);
|
|
40
|
+
if (light.a < 1e-3) {
|
|
41
|
+
return vec3<f32>(0.0);
|
|
42
|
+
}
|
|
43
|
+
let toward = textureSampleLevel(driftAtlas, driftSampler, (corner + vec3<f32>(4.0, 0.0, 0.0) + inside) * texel, 0.0);
|
|
44
|
+
let arriving = light.rgb / light.a;
|
|
45
|
+
let towardLight = toward.xyz / light.a;
|
|
46
|
+
let facing = max(dot(n, towardLight), 0.0) + (1.0 - min(length(towardLight), 1.0)) * 0.25;
|
|
47
|
+
return arriving * facing;
|
|
48
|
+
}
|
|
49
|
+
`;
|
|
@@ -41,20 +41,45 @@ export declare const BAKE_COUNTS = 8;
|
|
|
41
41
|
export declare const BAKE_EDGE = 11;
|
|
42
42
|
export declare const BAKE_FRAME = 12;
|
|
43
43
|
export declare const BAKE_SCHEDULED = 13;
|
|
44
|
+
/** How much of what a layer held a refresh keeps. See `PROBE_HISTORY` and `convolveMain`. */
|
|
45
|
+
export declare const BAKE_HISTORY = 14;
|
|
44
46
|
/** The sun, which the trace evaluates itself so a light that moves changes the bounce. */
|
|
45
47
|
export declare const BAKE_SUN_DIR = 16;
|
|
46
48
|
export declare const BAKE_SUN_COLOUR = 20;
|
|
47
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* What a ray that left the world finds: the sky's horizon colour where the frame drew a sky, and the
|
|
51
|
+
* whole of the sky, flat, where it drew none. The flag says which, in the fourth float of the row,
|
|
52
|
+
* which is where an f32 declared after a vec3 lands. The rest of the sky the frame drew follows.
|
|
53
|
+
*/
|
|
48
54
|
export declare const BAKE_SKY_COLOUR = 24;
|
|
49
|
-
|
|
50
|
-
export declare const
|
|
55
|
+
export declare const BAKE_SKY_DRAWN = 27;
|
|
56
|
+
export declare const BAKE_SKY_TOP = 28;
|
|
57
|
+
export declare const BAKE_SKY_DEEP = 32;
|
|
58
|
+
/** The sky's own sun, which at night is not the key light: the moon is. The sunset reads this. */
|
|
59
|
+
export declare const BAKE_SKY_SUN = 36;
|
|
60
|
+
/**
|
|
61
|
+
* DriftLight's field, as `driftLight/uniforms.ts` resolves it for a probe: how much is in and its
|
|
62
|
+
* scale in `x` and `w`, and where its grid starts with the spacing in `w`. Zero switches it off.
|
|
63
|
+
*/
|
|
64
|
+
export declare const BAKE_DRIFT_LIGHT = 40;
|
|
65
|
+
export declare const BAKE_DRIFT_ORIGIN = 44;
|
|
66
|
+
/**
|
|
67
|
+
* How many of the frame's exact lights `bounceLights` holds, and how the renderer shades a point
|
|
68
|
+
* light: 1 inverse-square, 0 the shaped falloff. See `gi/bounceLights.ts`.
|
|
69
|
+
*/
|
|
70
|
+
export declare const BAKE_LIGHT_COUNT = 48;
|
|
71
|
+
export declare const BAKE_FALLOFF = 49;
|
|
72
|
+
/** Rounded up to the struct's own sixteen-byte alignment: the lattice, five vec3 rows, three vec4. */
|
|
73
|
+
export declare const PROBE_BAKE_FLOATS = 52;
|
|
74
|
+
/** Rows of `bounceLights`: three a light. */
|
|
75
|
+
export declare const BOUNCE_LIGHT_ROWS: number;
|
|
51
76
|
/** Invocations a workgroup, for both entry points. 64 is this backend's default everywhere. */
|
|
52
77
|
export declare const PROBE_BAKE_WORKGROUP = 64;
|
|
53
78
|
/** Floats a ray's radiance takes in the intermediate buffer. */
|
|
54
79
|
export declare const PROBE_RAY_FLOATS = 3;
|
|
55
80
|
/** Floats a baked texel takes. Three and not four: the alpha of an irradiance map means nothing. */
|
|
56
81
|
export declare const PROBE_TEXEL_FLOATS = 3;
|
|
57
|
-
export declare const PROBE_BAKE_STRUCT_WGSL = "\nstruct ProbeBake {\n /* Where the grid's first probe stands, and how far apart they are. */\n origin: vec3<f32>,\n spacing: vec3<f32>,\n /* How many probes along each axis. Whole numbers carried as floats, as every block here does. */\n counts: vec3<f32>,\n /* Texels across one probe's map, gutter included. */\n edge: f32,\n /*
|
|
82
|
+
export declare const PROBE_BAKE_STRUCT_WGSL = "\nstruct ProbeBake {\n /* Where the grid's first probe stands, and how far apart they are. */\n origin: vec3<f32>,\n spacing: vec3<f32>,\n /* How many probes along each axis. Whole numbers carried as floats, as every block here does. */\n counts: vec3<f32>,\n /* Texels across one probe's map, gutter included. */\n edge: f32,\n /* How far the direction set is turned. The bake holds it at 0; the parity check turns it. */\n frame: f32,\n /* How many probes this dispatch bakes. The schedule says which. */\n scheduled: f32,\n /* How much of what a layer held each refresh keeps, 0 for a grid not yet traced whole. */\n history: f32,\n /* Towards the sun, and what colour it is. The trace shades what it hits rather than reading a\n capture, which is what lets a bounce follow a light that moved. */\n sunDir: vec3<f32>,\n sunColour: vec3<f32>,\n /* And what is outside, for a ray that left. See probeDistantRadiance and probeSkyAlong. */\n skyColour: vec3<f32>,\n skyDrawn: f32,\n skyTop: vec3<f32>,\n skyDeep: vec3<f32>,\n skySun: vec3<f32>,\n /* The summed light of a scene's many fixed lights. See driftLight.wgsl.ts. */\n driftLight: vec4<f32>,\n driftOrigin: vec4<f32>,\n /* How many of the frame's exact lights bounceLights holds, and 1 for inverse-square falloff. */\n lightCount: f32,\n falloff: f32,\n}\n";
|
|
58
83
|
/**
|
|
59
84
|
* The octahedral mapping and the probe lattice, as text.
|
|
60
85
|
*
|
|
@@ -27,6 +27,8 @@ import { FRAME_TURN, GOLDEN_ANGLE, PROBE_DIRECTIONS } from '../../gi/probeTrace.
|
|
|
27
27
|
import { PROBE_MARCH_WGSL, PROBE_TRACE_CORE_WGSL } from './probeTrace.wgsl.js';
|
|
28
28
|
import { PROBE_VOLUME_CORE_WGSL, PROBE_VOLUME_STRUCT_WGSL } from './probeVolume.wgsl.js';
|
|
29
29
|
import { SAMPLE_FIELD_WGSL } from './sampleField.wgsl.js';
|
|
30
|
+
import { DRIFT_LIGHT_WGSL } from './driftLight.wgsl.js';
|
|
31
|
+
import { BOUNCE_LIGHT_FLOATS, MAX_BOUNCE_LIGHTS } from '../../gi/bounceLights.js';
|
|
30
32
|
/**
|
|
31
33
|
* Floats in the bake block, and where each field of it starts.
|
|
32
34
|
*
|
|
@@ -45,13 +47,38 @@ export const BAKE_COUNTS = 8;
|
|
|
45
47
|
export const BAKE_EDGE = 11;
|
|
46
48
|
export const BAKE_FRAME = 12;
|
|
47
49
|
export const BAKE_SCHEDULED = 13;
|
|
50
|
+
/** How much of what a layer held a refresh keeps. See `PROBE_HISTORY` and `convolveMain`. */
|
|
51
|
+
export const BAKE_HISTORY = 14;
|
|
48
52
|
/** The sun, which the trace evaluates itself so a light that moves changes the bounce. */
|
|
49
53
|
export const BAKE_SUN_DIR = 16;
|
|
50
54
|
export const BAKE_SUN_COLOUR = 20;
|
|
51
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* What a ray that left the world finds: the sky's horizon colour where the frame drew a sky, and the
|
|
57
|
+
* whole of the sky, flat, where it drew none. The flag says which, in the fourth float of the row,
|
|
58
|
+
* which is where an f32 declared after a vec3 lands. The rest of the sky the frame drew follows.
|
|
59
|
+
*/
|
|
52
60
|
export const BAKE_SKY_COLOUR = 24;
|
|
53
|
-
|
|
54
|
-
export const
|
|
61
|
+
export const BAKE_SKY_DRAWN = 27;
|
|
62
|
+
export const BAKE_SKY_TOP = 28;
|
|
63
|
+
export const BAKE_SKY_DEEP = 32;
|
|
64
|
+
/** The sky's own sun, which at night is not the key light: the moon is. The sunset reads this. */
|
|
65
|
+
export const BAKE_SKY_SUN = 36;
|
|
66
|
+
/**
|
|
67
|
+
* DriftLight's field, as `driftLight/uniforms.ts` resolves it for a probe: how much is in and its
|
|
68
|
+
* scale in `x` and `w`, and where its grid starts with the spacing in `w`. Zero switches it off.
|
|
69
|
+
*/
|
|
70
|
+
export const BAKE_DRIFT_LIGHT = 40;
|
|
71
|
+
export const BAKE_DRIFT_ORIGIN = 44;
|
|
72
|
+
/**
|
|
73
|
+
* How many of the frame's exact lights `bounceLights` holds, and how the renderer shades a point
|
|
74
|
+
* light: 1 inverse-square, 0 the shaped falloff. See `gi/bounceLights.ts`.
|
|
75
|
+
*/
|
|
76
|
+
export const BAKE_LIGHT_COUNT = 48;
|
|
77
|
+
export const BAKE_FALLOFF = 49;
|
|
78
|
+
/** Rounded up to the struct's own sixteen-byte alignment: the lattice, five vec3 rows, three vec4. */
|
|
79
|
+
export const PROBE_BAKE_FLOATS = 52;
|
|
80
|
+
/** Rows of `bounceLights`: three a light. */
|
|
81
|
+
export const BOUNCE_LIGHT_ROWS = (MAX_BOUNCE_LIGHTS * BOUNCE_LIGHT_FLOATS) / 4;
|
|
55
82
|
/** Invocations a workgroup, for both entry points. 64 is this backend's default everywhere. */
|
|
56
83
|
export const PROBE_BAKE_WORKGROUP = 64;
|
|
57
84
|
/** Floats a ray's radiance takes in the intermediate buffer. */
|
|
@@ -67,17 +94,28 @@ struct ProbeBake {
|
|
|
67
94
|
counts: vec3<f32>,
|
|
68
95
|
/* Texels across one probe's map, gutter included. */
|
|
69
96
|
edge: f32,
|
|
70
|
-
/*
|
|
97
|
+
/* How far the direction set is turned. The bake holds it at 0; the parity check turns it. */
|
|
71
98
|
frame: f32,
|
|
72
99
|
/* How many probes this dispatch bakes. The schedule says which. */
|
|
73
100
|
scheduled: f32,
|
|
74
|
-
|
|
101
|
+
/* How much of what a layer held each refresh keeps, 0 for a grid not yet traced whole. */
|
|
102
|
+
history: f32,
|
|
75
103
|
/* Towards the sun, and what colour it is. The trace shades what it hits rather than reading a
|
|
76
104
|
capture, which is what lets a bounce follow a light that moved. */
|
|
77
105
|
sunDir: vec3<f32>,
|
|
78
106
|
sunColour: vec3<f32>,
|
|
79
|
-
/* And what is outside, for a ray that left. See probeDistantRadiance. */
|
|
107
|
+
/* And what is outside, for a ray that left. See probeDistantRadiance and probeSkyAlong. */
|
|
80
108
|
skyColour: vec3<f32>,
|
|
109
|
+
skyDrawn: f32,
|
|
110
|
+
skyTop: vec3<f32>,
|
|
111
|
+
skyDeep: vec3<f32>,
|
|
112
|
+
skySun: vec3<f32>,
|
|
113
|
+
/* The summed light of a scene's many fixed lights. See driftLight.wgsl.ts. */
|
|
114
|
+
driftLight: vec4<f32>,
|
|
115
|
+
driftOrigin: vec4<f32>,
|
|
116
|
+
/* How many of the frame's exact lights bounceLights holds, and 1 for inverse-square falloff. */
|
|
117
|
+
lightCount: f32,
|
|
118
|
+
falloff: f32,
|
|
81
119
|
}
|
|
82
120
|
`;
|
|
83
121
|
/**
|
|
@@ -228,11 +266,18 @@ fn convolveMain(@builtin(global_invocation_id) id: vec3<u32>) {
|
|
|
228
266
|
zero rather than dividing is what keeps it from being a hole if it ever does. */
|
|
229
267
|
let scale = select(0.0, 1.0 / weights, weights > 0.0);
|
|
230
268
|
let value = max(vec3<f32>(0.0), sum * scale);
|
|
269
|
+
/*
|
|
270
|
+
* **Blended into what the layer held, because one refresh is one estimate.** 256 rays over a set
|
|
271
|
+
* that turns every frame put two refreshes of a probe a few per cent apart, and written as they
|
|
272
|
+
* landed they were a shimmer about nine times a second. "bake.history" is 0 until the grid has
|
|
273
|
+
* been traced whole, so a first fill lands at once.
|
|
274
|
+
*/
|
|
275
|
+
let kept = mix(value, probeHistory(schedule[slot], u, v), bake.history);
|
|
231
276
|
|
|
232
277
|
let out = id.x * ${String(PROBE_TEXEL_FLOATS)}u;
|
|
233
|
-
irradiance[out] =
|
|
234
|
-
irradiance[out + 1u] =
|
|
235
|
-
irradiance[out + 2u] =
|
|
278
|
+
irradiance[out] = kept.x;
|
|
279
|
+
irradiance[out + 1u] = kept.y;
|
|
280
|
+
irradiance[out + 2u] = kept.z;
|
|
236
281
|
}
|
|
237
282
|
`;
|
|
238
283
|
/**
|
|
@@ -281,6 +326,11 @@ fn probeLayerSample(layer: u32, uv: vec2<f32>) -> vec3<f32> {
|
|
|
281
326
|
return vec3<f32>(probeValues[at], probeValues[at + 1u], probeValues[at + 2u]);
|
|
282
327
|
}
|
|
283
328
|
|
|
329
|
+
/* No history: this module's refreshes are checked as single estimates, and "bake.history" is 0. */
|
|
330
|
+
fn probeHistory(layer: u32, u: u32, v: u32) -> vec3<f32> {
|
|
331
|
+
return vec3<f32>(0.0);
|
|
332
|
+
}
|
|
333
|
+
|
|
284
334
|
/* The same flat value: this module checks the blend, and what a production texture holds at its
|
|
285
335
|
two levels is what makes them different. */
|
|
286
336
|
fn probeLayerIrradiance(layer: u32, uv: vec2<f32>) -> vec3<f32> {
|
|
@@ -364,6 +414,10 @@ fn echoMain(@builtin(global_invocation_id) id: vec3<u32>) {
|
|
|
364
414
|
irradiance[9] = bake.edge;
|
|
365
415
|
irradiance[10] = bake.frame;
|
|
366
416
|
irradiance[11] = bake.scheduled;
|
|
417
|
+
irradiance[12] = bake.skyDrawn;
|
|
418
|
+
irradiance[13] = bake.skyTop.x;
|
|
419
|
+
irradiance[14] = bake.skyDeep.x;
|
|
420
|
+
irradiance[15] = bake.skySun.x;
|
|
367
421
|
}
|
|
368
422
|
|
|
369
423
|
/* The encode, which is a different function from the fold "texelMain" checks and needs its own. */
|
|
@@ -440,7 +494,19 @@ ${PROBE_VOLUME_STRUCT_WGSL}
|
|
|
440
494
|
@group(0) @binding(9) var probeSampler: sampler;
|
|
441
495
|
/* The colour of whatever won the union at each sample, three floats, same layout as the field. */
|
|
442
496
|
@group(0) @binding(10) var<storage, read> albedoField: array<f32>;
|
|
497
|
+
/* DriftLight's brick index and atlas, or one-texel stand-ins while driftLight.x is zero. */
|
|
498
|
+
@group(0) @binding(11) var driftIndex: texture_3d<u32>;
|
|
499
|
+
@group(0) @binding(12) var driftAtlas: texture_3d<f32>;
|
|
500
|
+
@group(0) @binding(13) var driftSampler: sampler;
|
|
501
|
+
/* The frame's exact lights, three rows each: see gi/bounceLights.ts for what each row holds. */
|
|
502
|
+
@group(0) @binding(14) var<uniform> bounceLights: array<vec4<f32>, ${String(BOUNCE_LIGHT_ROWS)}>;
|
|
443
503
|
${SAMPLE_FIELD_WGSL}
|
|
504
|
+
${DRIFT_LIGHT_WGSL}
|
|
505
|
+
|
|
506
|
+
/* What a layer's irradiance level holds at a texel, which the convolution blends into. */
|
|
507
|
+
fn probeHistory(layer: u32, u: u32, v: u32) -> vec3<f32> {
|
|
508
|
+
return textureLoad(probeArray, vec2<i32>(i32(u), i32(v)), i32(layer), i32(volume.level)).rgb;
|
|
509
|
+
}
|
|
444
510
|
|
|
445
511
|
/*
|
|
446
512
|
* The albedo at a world point, from the finest cascade that holds it, by nearest sample.
|
|
@@ -553,13 +619,111 @@ fn probeSurfaceRadiance(p: vec3<f32>, towards: vec3<f32>) -> vec3<f32> {
|
|
|
553
619
|
/*
|
|
554
620
|
* And what has already bounced, which is the level this bake writes — so the loop closes.
|
|
555
621
|
*
|
|
556
|
-
* **
|
|
557
|
-
* actually used, so
|
|
558
|
-
*
|
|
559
|
-
*
|
|
622
|
+
* **Neither term is divided by pi, because the frame divides neither.** "convolveProbe"
|
|
623
|
+
* normalises by the cosine weights it actually used, so a probe stores the cosine-weighted
|
|
624
|
+
* *mean radiance*, which a Lambertian's outgoing radiance multiplies directly by its albedo. And
|
|
625
|
+
* the flat shader lights a surface with "albedo * (ambient + directionalColor * cosine)", so
|
|
626
|
+
* "directionalColor" is the radiance a white surface facing the sun sends back rather than an
|
|
627
|
+
* irradiance. This divided the sun by pi until 2026-09-27, and every sunlit wall a probe struck
|
|
628
|
+
* came back a third as bright as the frame drew it: a room lit through its one open side read
|
|
629
|
+
* 66 of 255 on its far wall against a path-traced 200 ("scripts/bounce-check.mjs"). What would
|
|
630
|
+
* make it wrong is a flat shader that divides its own sun by pi; "probeBake.test.ts" reads both.
|
|
560
631
|
*/
|
|
561
632
|
let bounced = probeVolumeIrradianceAt(p, n);
|
|
562
|
-
|
|
633
|
+
/*
|
|
634
|
+
* And the summed light of the scene's many fixed lights, which is how a candle nobody is near
|
|
635
|
+
* reaches the bounce: the trace shades a hit with the sun alone otherwise. At the presence and
|
|
636
|
+
* scale the frame reads it with, and with no share, because a probe sees every fixed light through
|
|
637
|
+
* the volume (driftLight/uniforms.ts).
|
|
638
|
+
*/
|
|
639
|
+
let summed = driftLightIrradiance(p, n, bake.driftOrigin) * (bake.driftLight.x * bake.driftLight.w);
|
|
640
|
+
return albedo * (direct + bounced + summed + probeLampsAt(p, n));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/*
|
|
644
|
+
* Whether a lamp reaches a point, by marching the field towards it and stopping short.
|
|
645
|
+
*
|
|
646
|
+
* **Short of the lamp by its fixture.** A flame stands inside a lantern and a bulb inside a shade,
|
|
647
|
+
* and a field a quarter of a metre fine draws those as solid: a march that ran all the way would
|
|
648
|
+
* find the lantern round every lantern and shade every one of them out. So it stops a fixture's
|
|
649
|
+
* width away, the larger of 30 cm and two of the finest voxels, and what it gives up is an occluder
|
|
650
|
+
* that close to a lamp.
|
|
651
|
+
*/
|
|
652
|
+
fn probeLampVisible(p: vec3<f32>, n: vec3<f32>, toward: vec3<f32>, span: f32) -> f32 {
|
|
653
|
+
let lifted = p + n * (PROBE_FIELD_START_VOXELS * march.finestStep);
|
|
654
|
+
var travelled = 0.0;
|
|
655
|
+
for (var step = 0u; step < march.steps; step = step + 1u) {
|
|
656
|
+
if (travelled >= span) { return 1.0; }
|
|
657
|
+
let at = lifted + toward * travelled;
|
|
658
|
+
if (probeFieldInside(at) <= 0.0) { return 1.0; }
|
|
659
|
+
let distance = probeFieldAt(at);
|
|
660
|
+
if (distance <= march.hitEpsilonM) { return 0.0; }
|
|
661
|
+
travelled = travelled + max(distance, march.finestStep * 0.25);
|
|
662
|
+
}
|
|
663
|
+
return 1.0;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/*
|
|
667
|
+
* The frame's exact lights on a surface a ray struck: the lit shader's lamp term, line for line
|
|
668
|
+
* (probeBake.test.ts holds the two together), with the field's own shadow in place of a lamp's
|
|
669
|
+
* shadow map. Not a photometric profile or a cookie, which the bounce reads as a plain cone.
|
|
670
|
+
*/
|
|
671
|
+
fn probeLampsAt(p: vec3<f32>, n: vec3<f32>) -> vec3<f32> {
|
|
672
|
+
var sum = vec3<f32>(0.0);
|
|
673
|
+
let count = u32(bake.lightCount);
|
|
674
|
+
for (var i = 0u; i < count; i = i + 1u) {
|
|
675
|
+
let at = bounceLights[i * 3u];
|
|
676
|
+
let colour = bounceLights[i * 3u + 1u];
|
|
677
|
+
let aim = bounceLights[i * 3u + 2u];
|
|
678
|
+
let lightRadius = at.w;
|
|
679
|
+
let toLight = at.xyz - p;
|
|
680
|
+
let dist = length(toLight);
|
|
681
|
+
let coneFalloff = smoothstep(aim.w, colour.w, dot(-toLight / max(dist, 1e-4), aim.xyz));
|
|
682
|
+
var falloff: f32;
|
|
683
|
+
if (bake.falloff == 1.0) {
|
|
684
|
+
let window = clamp(1.0 - pow(dist / max(lightRadius, 1e-4), 4.0), 0.0, 1.0);
|
|
685
|
+
falloff = window * window / max(dist * dist, 0.01);
|
|
686
|
+
} else {
|
|
687
|
+
falloff = clamp(1.0 - dist / lightRadius, 0.0, 1.0);
|
|
688
|
+
}
|
|
689
|
+
if (falloff <= 0.0) { continue; }
|
|
690
|
+
let ndl = max(dot(n, toLight / max(dist, 1e-4)), 0.0);
|
|
691
|
+
if (ndl <= 0.0) { continue; }
|
|
692
|
+
let shape = select(falloff * falloff, falloff, bake.falloff == 1.0) * coneFalloff;
|
|
693
|
+
if (shape <= 0.0) { continue; }
|
|
694
|
+
let fixture = max(0.3, 2.0 * march.finestStep);
|
|
695
|
+
let seen = probeLampVisible(p, n, toLight / max(dist, 1e-4), dist - fixture);
|
|
696
|
+
sum = sum + colour.rgb * ndl * shape * seen;
|
|
697
|
+
}
|
|
698
|
+
return sum;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/*
|
|
702
|
+
* The sky the frame drew, along a direction: the sky shader's gradient and its two sunset bands,
|
|
703
|
+
* transcribed line for line (probeBake.test.ts holds the two together). Not its sun, moon, stars or
|
|
704
|
+
* clouds: the sun reaches a probe as direct light at what the ray strikes, and the rest is a few
|
|
705
|
+
* per cent of a sky's light. Where the frame drew no sky, the environment's ambient, flat.
|
|
706
|
+
*/
|
|
707
|
+
fn probeSkyAlong(d: vec3<f32>) -> vec3<f32> {
|
|
708
|
+
if (bake.skyDrawn < 0.5) { return bake.skyColour; }
|
|
709
|
+
let t = d.y;
|
|
710
|
+
var col: vec3<f32>;
|
|
711
|
+
if (t >= 0.0) {
|
|
712
|
+
col = mix(bake.skyColour, bake.skyTop, pow(min(t, 1.0), 0.55));
|
|
713
|
+
} else {
|
|
714
|
+
col = mix(bake.skyColour, bake.skyDeep, min(-t * 1.8, 1.0));
|
|
715
|
+
}
|
|
716
|
+
let sunset = (1.0 - smoothstep(-0.03, 0.30, bake.skySun.y)) * smoothstep(-0.35, -0.05, bake.skySun.y);
|
|
717
|
+
let ember = vec3<f32>(1.0, 0.36, 0.13);
|
|
718
|
+
let sunBearing = normalize(bake.skySun.xz + vec2<f32>(1e-5));
|
|
719
|
+
let viewBearing = normalize(d.xz + vec2<f32>(1e-5));
|
|
720
|
+
let toward = max(dot(viewBearing, sunBearing), 0.0);
|
|
721
|
+
let fromHorizon = select(-t * 3.0, t, t >= 0.0);
|
|
722
|
+
let band = pow(1.0 - min(fromHorizon * 2.2, 1.0), 2.6);
|
|
723
|
+
col += ember * sunset * band * (0.18 + 0.75 * pow(toward, 2.2));
|
|
724
|
+
let away = max(-dot(viewBearing, sunBearing), 0.0);
|
|
725
|
+
col += vec3<f32>(0.42, 0.28, 0.45) * sunset * band * away * 0.22;
|
|
726
|
+
return col;
|
|
563
727
|
}
|
|
564
728
|
|
|
565
729
|
/*
|
|
@@ -567,15 +731,21 @@ fn probeSurfaceRadiance(p: vec3<f32>, towards: vec3<f32>) -> vec3<f32> {
|
|
|
567
731
|
*
|
|
568
732
|
* **Not the probe array's own level 0, and that was measured rather than reasoned.** Level 0 is a
|
|
569
733
|
* *rasterised* capture taken once, so a ray reading it gathers the light the room had when the
|
|
570
|
-
* capture was made
|
|
571
|
-
* most of its rays escaped through the opening and read the capture.
|
|
572
|
-
* **nothing in the trace reads anything rasterised
|
|
573
|
-
*
|
|
734
|
+
* capture was made, and a room whose red wall was repainted blue went on bouncing red, because
|
|
735
|
+
* most of its rays escaped through the opening and read the capture. The sky is the frame's own
|
|
736
|
+
* numbers, so **nothing in the trace reads anything rasterised** and the solution follows a scene
|
|
737
|
+
* that changes.
|
|
738
|
+
*
|
|
739
|
+
* **The sky drawn, and not the environment's ambient, since 2026-09-27.** The ambient is a grade's
|
|
740
|
+
* fill for surfaces the probes do not reach, and a palette sets it apart from the sky: at ten
|
|
741
|
+
* degrees of sun a courtyard's was five times dimmer than its horizon, and blue where the horizon
|
|
742
|
+
* was amber, so traced shade at dusk came out cool against the rasterised grid that saw the sky.
|
|
574
743
|
*
|
|
575
|
-
* No albedo and no sun: nothing was struck, so there is no surface to shade.
|
|
744
|
+
* No albedo and no sun: nothing was struck, so there is no surface to shade. "towards" points back
|
|
745
|
+
* down the ray, so the sky is read along its opposite.
|
|
576
746
|
*/
|
|
577
747
|
fn probeDistantRadiance(p: vec3<f32>, towards: vec3<f32>) -> vec3<f32> {
|
|
578
|
-
return
|
|
748
|
+
return probeSkyAlong(-towards);
|
|
579
749
|
}
|
|
580
750
|
${PROBE_TRACE_CORE_WGSL}
|
|
581
751
|
${PROBE_BAKE_CORE_WGSL}
|
|
@@ -290,6 +290,10 @@ void main() {
|
|
|
290
290
|
*/
|
|
291
291
|
export const MEDIUM_UPSAMPLE_FRAG = `#version 300 es
|
|
292
292
|
precision highp float;
|
|
293
|
+
/* highp int, by the rule every fragment stage doing bit arithmetic keeps: the footprint below is
|
|
294
|
+
picked with a mask and a shift. At four values it would survive sixteen bits, and a gate that
|
|
295
|
+
has to judge which masks are small is a gate nobody can hold. */
|
|
296
|
+
precision highp int;
|
|
293
297
|
|
|
294
298
|
in vec2 vUv;
|
|
295
299
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Bands of log luminance, and how many stops each is wide. */
|
|
2
|
+
export declare const LOCAL_BINS = 10;
|
|
3
|
+
export declare const LOCAL_BIN_STOPS: number;
|
|
4
|
+
/** The grid's width in texels: a block of tiles per band, and one for the tiles' means. */
|
|
5
|
+
export declare const LOCAL_GRID_WIDTH: number;
|
|
6
|
+
/** How far a pixel's band level is blended toward its tile's mean. */
|
|
7
|
+
export declare const LOCAL_BLURRED_BLEND = 0.4;
|
|
8
|
+
/** The furthest a region is moved either way, in stops. */
|
|
9
|
+
export declare const LOCAL_RANGE_STOPS = 3;
|
|
10
|
+
/**
|
|
11
|
+
* The grid: one texel a tile a band. Each reads its tile's 8 × 8 taps, as the meter does, and keeps
|
|
12
|
+
* the ones in its band; the last block keeps them all. Sum and count are both divided by the taps,
|
|
13
|
+
* so the filtered pair in the composite is still a weighted mean.
|
|
14
|
+
*/
|
|
15
|
+
export declare const EXPOSURE_LOCAL_FRAG: string;
|
|
16
|
+
/**
|
|
17
|
+
* The composite's half. Needs `AUTO_EXPOSURE_GLSL` before it, for the held brightness.
|
|
18
|
+
*
|
|
19
|
+
* A block is read clamped half a texel inside its own tiles, so the filter never reaches into the
|
|
20
|
+
* neighbouring band's block. Every fetch is at level zero: the grid has one level, and the branch
|
|
21
|
+
* on the strength above them is uniform but the pixel's band is not.
|
|
22
|
+
*/
|
|
23
|
+
export declare const LOCAL_EXPOSURE_GLSL: string;
|
|
24
|
+
/** The two bands a pixel at `pixelLog2` reads, and how much of the upper, as the shader finds them. */
|
|
25
|
+
export declare function localBin(pixelLog2: number): {
|
|
26
|
+
lower: number;
|
|
27
|
+
upper: number;
|
|
28
|
+
weight: number;
|
|
29
|
+
};
|
|
30
|
+
/** A pixel's local level from its two bands' sums and counts and its tile's mean, as the shader. */
|
|
31
|
+
export declare function sliceLocal(sumLower: number, countLower: number, sumUpper: number, countUpper: number, weight: number, tileMean: number): number;
|
|
32
|
+
/** A region's gain for a local level and the frame's held one at `strength`, as the composite. */
|
|
33
|
+
export declare function localExposureGain(localLog2: number, heldLog2: number, strength: number): number;
|
|
34
|
+
/** A local exposure strength as the shader takes it: 0 to 1, anything else off. */
|
|
35
|
+
export declare function clampLocalExposure(strength: number): number;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local exposure: each region of the frame brought part of the way toward the frame's own
|
|
3
|
+
* brightness, so shade and sun can both be read in one picture.
|
|
4
|
+
*
|
|
5
|
+
* **A tone curve maps one range, and a sunlit courtyard is two.** Measured in a bought courtyard at
|
|
6
|
+
* noon, the shaded arcades hold a fiftieth of the light the sunlit paving does, which is what the
|
|
7
|
+
* place is: a canyon lit mostly by its own walls. Exposed for the paving the arcades go to black,
|
|
8
|
+
* exposed for the arcades the paving goes to white, and no curve fixes both, because a curve is a
|
|
9
|
+
* function of a pixel's brightness and the same brightness has to mean shade in one place and sun in
|
|
10
|
+
* another. A photographer brackets; an eye adapts by region. This is the second.
|
|
11
|
+
*
|
|
12
|
+
* **A bilateral grid over the exposure meter's tiles.** The grid pass writes, for each of the
|
|
13
|
+
* meter's 32 × 32 tiles and each of ten two-stop bands of log luminance, the sum and the share of
|
|
14
|
+
* the tile's taps that fell in that band, and in an eleventh block the tile's mean. The composite
|
|
15
|
+
* reads the grid at the pixel's place and in the pixel's own band, filtered in both, so a shaded
|
|
16
|
+
* pixel beside sunlit ones takes the shade's level rather than the tile's: that is what keeps the
|
|
17
|
+
* lift from haloing across the edge of a shadow. The band's level is blended 0.4 toward the tile's
|
|
18
|
+
* mean, which is what stops a texture's own light and dark being flattened into one another.
|
|
19
|
+
*
|
|
20
|
+
* **The gain is `strength · (held − local)` stops**, clamped to three: at 0.5 a region five stops
|
|
21
|
+
* under the frame is lifted two and a half, and one two over is brought down one. The held value is
|
|
22
|
+
* eye adaptation's, so the frame as a whole still adapts and regions are moved relative to it.
|
|
23
|
+
*
|
|
24
|
+
* What it gives up is some of the contrast between regions, which is the point, and a halo a tile
|
|
25
|
+
* wide where two regions share a band and differ in brightness. What would make it wrong is a scene
|
|
26
|
+
* whose drama is the contrast itself: a single shaft into a dark room wants the room dark, and the
|
|
27
|
+
* strength is the caller's for that reason.
|
|
28
|
+
*/
|
|
29
|
+
import { METER_GRID, METER_MAX_LOG2, METER_MIN_LOG2, METER_TAPS } from './exposure.js';
|
|
30
|
+
/** Bands of log luminance, and how many stops each is wide. */
|
|
31
|
+
export const LOCAL_BINS = 10;
|
|
32
|
+
export const LOCAL_BIN_STOPS = (METER_MAX_LOG2 - METER_MIN_LOG2) / LOCAL_BINS;
|
|
33
|
+
/** The grid's width in texels: a block of tiles per band, and one for the tiles' means. */
|
|
34
|
+
export const LOCAL_GRID_WIDTH = METER_GRID * (LOCAL_BINS + 1);
|
|
35
|
+
/** How far a pixel's band level is blended toward its tile's mean. */
|
|
36
|
+
export const LOCAL_BLURRED_BLEND = 0.4;
|
|
37
|
+
/** The furthest a region is moved either way, in stops. */
|
|
38
|
+
export const LOCAL_RANGE_STOPS = 3;
|
|
39
|
+
const BLOCKS = (LOCAL_BINS + 1).toFixed(1);
|
|
40
|
+
const GRID = METER_GRID.toFixed(1);
|
|
41
|
+
const MIN = METER_MIN_LOG2.toFixed(1);
|
|
42
|
+
const MAX = METER_MAX_LOG2.toFixed(1);
|
|
43
|
+
const STOPS = LOCAL_BIN_STOPS.toFixed(1);
|
|
44
|
+
const LAST = (LOCAL_BINS - 1).toFixed(1);
|
|
45
|
+
/**
|
|
46
|
+
* The grid: one texel a tile a band. Each reads its tile's 8 × 8 taps, as the meter does, and keeps
|
|
47
|
+
* the ones in its band; the last block keeps them all. Sum and count are both divided by the taps,
|
|
48
|
+
* so the filtered pair in the composite is still a weighted mean.
|
|
49
|
+
*/
|
|
50
|
+
export const EXPOSURE_LOCAL_FRAG = `#version 300 es
|
|
51
|
+
precision highp float;
|
|
52
|
+
|
|
53
|
+
in vec2 vUv;
|
|
54
|
+
|
|
55
|
+
/** The finished scene, in scene light, before any exposure or curve. */
|
|
56
|
+
uniform highp sampler2D uScene;
|
|
57
|
+
|
|
58
|
+
out vec2 fragColor;
|
|
59
|
+
|
|
60
|
+
void main() {
|
|
61
|
+
float uvBlock = vUv.x * ${BLOCKS};
|
|
62
|
+
float band = floor(uvBlock);
|
|
63
|
+
vec2 corner = vec2(fract(uvBlock), vUv.y) - vec2(0.5 / ${GRID});
|
|
64
|
+
float sum = 0.0;
|
|
65
|
+
float count = 0.0;
|
|
66
|
+
for (int y = 0; y < ${METER_TAPS}; y++) {
|
|
67
|
+
for (int x = 0; x < ${METER_TAPS}; x++) {
|
|
68
|
+
vec2 uv = corner + (vec2(float(x), float(y)) + 0.5) / ${(METER_GRID * METER_TAPS).toFixed(1)};
|
|
69
|
+
vec3 c = textureLod(uScene, uv, 0.0).rgb;
|
|
70
|
+
float level = clamp(log2(max(dot(c, vec3(0.2126, 0.7152, 0.0722)), 1e-8)), ${MIN}, ${MAX});
|
|
71
|
+
float tapBand = floor(clamp((level - ${MIN}) / ${STOPS}, 0.0, ${LAST}));
|
|
72
|
+
float kept = band > ${LAST} || tapBand == band ? 1.0 : 0.0;
|
|
73
|
+
sum += level * kept;
|
|
74
|
+
count += kept;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
fragColor = vec2(sum, count) / ${(METER_TAPS * METER_TAPS).toFixed(1)};
|
|
78
|
+
}
|
|
79
|
+
`;
|
|
80
|
+
/**
|
|
81
|
+
* The composite's half. Needs `AUTO_EXPOSURE_GLSL` before it, for the held brightness.
|
|
82
|
+
*
|
|
83
|
+
* A block is read clamped half a texel inside its own tiles, so the filter never reaches into the
|
|
84
|
+
* neighbouring band's block. Every fetch is at level zero: the grid has one level, and the branch
|
|
85
|
+
* on the strength above them is uniform but the pixel's band is not.
|
|
86
|
+
*/
|
|
87
|
+
export const LOCAL_EXPOSURE_GLSL = `
|
|
88
|
+
/** How far each region is brought toward the frame, 0 to 1. 0 is off and the whole of the off path. */
|
|
89
|
+
uniform float uLocalExposure;
|
|
90
|
+
/** The bilateral grid: a block of tiles per band of brightness, and one of the tiles' means. */
|
|
91
|
+
uniform highp sampler2D uExposureLocal;
|
|
92
|
+
|
|
93
|
+
vec2 localBlock(vec2 uv, float block) {
|
|
94
|
+
vec2 inside = clamp(uv, vec2(0.5 / ${GRID}), vec2(1.0 - 0.5 / ${GRID}));
|
|
95
|
+
return textureLod(uExposureLocal, vec2((block + inside.x) / ${BLOCKS}, inside.y), 0.0).rg;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
float localExposureGain(vec2 uv, vec3 light) {
|
|
99
|
+
if (uLocalExposure <= 0.0) return 1.0;
|
|
100
|
+
float held = texelFetch(uExposureHeld, ivec2(0, 0), 0).r;
|
|
101
|
+
float pixel = clamp(log2(max(dot(light, vec3(0.2126, 0.7152, 0.0722)), 1e-8)), ${MIN}, ${MAX});
|
|
102
|
+
float b = (pixel - ${MIN}) / ${STOPS} - 0.5;
|
|
103
|
+
float lower = clamp(floor(b), 0.0, ${LAST});
|
|
104
|
+
float upper = min(lower + 1.0, ${LAST});
|
|
105
|
+
vec2 banded = mix(localBlock(uv, lower), localBlock(uv, upper), clamp(b - lower, 0.0, 1.0));
|
|
106
|
+
float tileMean = localBlock(uv, ${LOCAL_BINS.toFixed(1)}).r;
|
|
107
|
+
float bilateral = banded.y > 1e-3 ? banded.x / banded.y : tileMean;
|
|
108
|
+
float local = mix(bilateral, tileMean, ${LOCAL_BLURRED_BLEND.toFixed(1)});
|
|
109
|
+
float stops = uLocalExposure * (held - local);
|
|
110
|
+
return exp2(clamp(stops, -${LOCAL_RANGE_STOPS.toFixed(1)}, ${LOCAL_RANGE_STOPS.toFixed(1)}));
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
113
|
+
const clamp = (value, low, high) => Math.min(Math.max(value, low), high);
|
|
114
|
+
/** The two bands a pixel at `pixelLog2` reads, and how much of the upper, as the shader finds them. */
|
|
115
|
+
export function localBin(pixelLog2) {
|
|
116
|
+
const pixel = clamp(pixelLog2, METER_MIN_LOG2, METER_MAX_LOG2);
|
|
117
|
+
const b = (pixel - METER_MIN_LOG2) / LOCAL_BIN_STOPS - 0.5;
|
|
118
|
+
const lower = clamp(Math.floor(b), 0, LOCAL_BINS - 1);
|
|
119
|
+
return { lower, upper: Math.min(lower + 1, LOCAL_BINS - 1), weight: clamp(b - lower, 0, 1) };
|
|
120
|
+
}
|
|
121
|
+
/** A pixel's local level from its two bands' sums and counts and its tile's mean, as the shader. */
|
|
122
|
+
export function sliceLocal(sumLower, countLower, sumUpper, countUpper, weight, tileMean) {
|
|
123
|
+
const sum = sumLower + (sumUpper - sumLower) * weight;
|
|
124
|
+
const count = countLower + (countUpper - countLower) * weight;
|
|
125
|
+
const bilateral = count > 1e-3 ? sum / count : tileMean;
|
|
126
|
+
return bilateral + (tileMean - bilateral) * LOCAL_BLURRED_BLEND;
|
|
127
|
+
}
|
|
128
|
+
/** A region's gain for a local level and the frame's held one at `strength`, as the composite. */
|
|
129
|
+
export function localExposureGain(localLog2, heldLog2, strength) {
|
|
130
|
+
if (!(strength > 0))
|
|
131
|
+
return 1;
|
|
132
|
+
const stops = strength * (heldLog2 - localLog2);
|
|
133
|
+
return 2 ** clamp(stops, -LOCAL_RANGE_STOPS, LOCAL_RANGE_STOPS);
|
|
134
|
+
}
|
|
135
|
+
/** A local exposure strength as the shader takes it: 0 to 1, anything else off. */
|
|
136
|
+
export function clampLocalExposure(strength) {
|
|
137
|
+
return Number.isFinite(strength) ? clamp(strength, 0, 1) : 0;
|
|
138
|
+
}
|
|
@@ -34,4 +34,4 @@
|
|
|
34
34
|
* difference between one cube and a cube per camera. **What would make it wrong** is a scene
|
|
35
35
|
* where grazing metal is the subject rather than incidental.
|
|
36
36
|
*/
|
|
37
|
-
export declare const PREFILTER_FRAG = "#version 300 es\nprecision highp float;\n\nvec2 octEncode(vec3 d) {\n vec3 n = d / (abs(d.x) + abs(d.y) + abs(d.z));\n vec2 p = n.z >= 0.0\n ? n.xy\n : (1.0 - abs(vec2(n.y, n.x))) * vec2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0);\n return p * 0.5 + 0.5;\n}\n\nvec3 octDecode(vec2 uv) {\n vec2 f = uv * 2.0 - 1.0;\n vec3 n = vec3(f.x, f.y, 1.0 - abs(f.x) - abs(f.y));\n float t = max(-n.z, 0.0);\n n.x += n.x >= 0.0 ? -t : t;\n n.y += n.y >= 0.0 ? -t : t;\n return normalize(n);\n}\n\nvec2 octWrapUv(vec2 uv) {\n vec2 f = uv * 2.0 - 1.0;\n if (f.x < -1.0 || f.x > 1.0) {\n f.x = (f.x > 0.0 ? 2.0 : -2.0) - f.x;\n f.y = -f.y;\n }\n if (f.y < -1.0 || f.y > 1.0) {\n f.y = (f.y > 0.0 ? 2.0 : -2.0) - f.y;\n f.x = -f.x;\n }\n return f * 0.5 + 0.5;\n}\n\nvec2 octInsetUv(vec3 d, float edge) {\n return (octEncode(d) * (edge - 2.0) + 1.0) / edge;\n}\n\nvec3 octInsetDir(vec2 uv, float edge) {\n return octDecode(octWrapUv((uv * edge - 1.0) / (edge - 2.0)));\n}\n\nin vec2 vUv;\n\n/** The captured probe, box-filtered, which this convolves. Never the cube being written. */\nuniform highp samplerCube uPrefilterSource;\n\n/**\n * The edge of the level being written, in texels, which is what the inset is derived from.\n *\n * The level's own size rather than the array's, because a gutter is one texel at every level and\n * its share of the map therefore doubles as the chain coarsens. A base edge passed here instead\n * would inset every level by the base's fraction, which is a fifth of a texel at the top of the\n * chain and reads as the reflection sliding as roughness rises.\n */\nuniform float uPrefilterEdge;\n\n/** 1 on the level holding the cosine convolution, 0 on every level of the reflection chain. */\nuniform float uPrefilterIrradiance;\n\n/**\n * 1 when the reflection chain is a box filter rather than a GGX convolution.\n *\n * RenderQuality.environmentPrefilter is off by default, and that default is a correction rather\n * than a preference: the prefilter shipped wired straight into the lit pass and changed the\n * picture of every consumer that had a probe, which was reported as metals reading opaque. A\n * profile that has not asked for the lobe chain must get the same reflection it had before probes\n * moved into an array, so the box arm exists and is what that profile bakes.\n *\n * The array edge is twice the source cube's face, so one array level matches one cube level in\n * angular density and the box arm is a single fetch at the same index. There is no second\n * texture: the flag decides what is convolved into the layer rather than which of two chains is\n * bound, so it is read at bake time and a profile that changes it has to bake again.\n */\nuniform float uPrefilterBox;\n\n/** The level being written, for the box arm, which reads the source chain at the same index. */\nuniform float uPrefilterLevel;\n\n/** The roughness this level stands for, from roughnessForLevel. 0 is a mirror. */\nuniform float uPrefilterRoughness;\n/** Samples per texel, from PREFILTER_SAMPLE_COUNTS. A power of two; see the count's own note. */\nuniform float uPrefilterSamples;\n/** The source cube's face width in texels, for the solid-angle comparison below. */\nuniform float uPrefilterSourceTexels;\n/** The source chain's top level, so a wide sample cannot ask for a level that is not there. */\nuniform float uPrefilterSourceMaxLod;\n\nout vec4 fragColor;\n\nconst float PI = 3.14159265359;\n\n/**\n * The radical inverse of a 32-bit integer, base two: the second dimension of a Hammersley set.\n *\n * A bit reversal, written as the standard five-shift sequence rather than a loop, because a loop\n * over 32 bits per sample per texel is the one place in a bake where the arithmetic is the cost.\n * **This is why every sample count is a power of two** \u2014 the sequence's stratification is only\n * even when the count is, and an uneven one shows as a faint directional bias in a rough level\n * rather than as an error anybody would notice.\n */\nfloat radicalInverse(uint bits) {\n bits = (bits << 16u) | (bits >> 16u);\n bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);\n bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);\n bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);\n bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);\n return float(bits) * 2.3283064365386963e-10;\n}\n\n/**\n * A half vector drawn from the GGX distribution about the normal, for one Hammersley pair.\n *\n * Importance sampling rather than a uniform hemisphere: at low roughness the lobe occupies a\n * vanishing fraction of the hemisphere, so uniform samples would put almost every one of them\n * where the weight is zero and the few that land in the lobe would carry all the variance.\n */\nvec3 importanceSampleGgx(vec2 xi, vec3 n, float roughness) {\n float a = roughness * roughness;\n\n float phi = 2.0 * PI * xi.x;\n float cosTheta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y));\n float sinTheta = sqrt(1.0 - cosTheta * cosTheta);\n\n vec3 h = vec3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);\n\n /*\n * Any tangent will do as long as it is not parallel to the normal, and the choice of which\n * axis to cross against has to depend on the normal or it degenerates at the poles \u2014 which is\n * one face of six coming out as noise, on the two faces the axis points at.\n */\n vec3 up = abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);\n vec3 tangentX = normalize(cross(up, n));\n vec3 tangentY = cross(n, tangentX);\n\n return normalize(tangentX * h.x + tangentY * h.y + n * h.z);\n}\n\n/**\n * A direction drawn from the cosine lobe about the normal, for the diffuse level.\n *\n * **Cosine-weighted, which is what makes the estimator a plain mean.** Irradiance is\n * integral of L cos(theta), the cosine pdf is cos(theta) / pi, so the estimate is\n * pi / N * sum(L) \u2014 and what the lit pass wants is radiance rather than irradiance, so the pi\n * divides straight back out. irradianceSh.ts folds the same division into its band factors and\n * says so, which is what makes a grid of one reproduce what the projection produced.\n */\nvec3 importanceSampleCosine(vec2 xi, vec3 n) {\n float phi = 2.0 * PI * xi.x;\n float cosTheta = sqrt(max(1.0 - xi.y, 0.0));\n float sinTheta = sqrt(xi.y);\n\n vec3 h = vec3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);\n\n vec3 up = abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);\n vec3 tangentX = normalize(cross(up, n));\n vec3 tangentY = cross(n, tangentX);\n\n return normalize(tangentX * h.x + tangentY * h.y + n * h.z);\n}\n\n/** Trowbridge-Reitz, in its own normalisation, because this is a probability rather than a look. */\nfloat ggxDistribution(float ndh, float roughness) {\n float a = roughness * roughness;\n float a2 = a * a;\n float d = ndh * ndh * (a2 - 1.0) + 1.0;\n return a2 / max(PI * d * d, 1e-8);\n}\n\nvoid main() {\n vec3 n = octInsetDir(vUv, uPrefilterEdge);\n\n float texelSolidAngle = 4.0 * PI / (6.0 * uPrefilterSourceTexels * uPrefilterSourceTexels);\n\n /*\n * The box chain, which is one fetch: the source's own level at the same index, resampled through\n * the octahedral mapping. It is what a profile that has not asked for the lobe chain gets, and\n * it is the picture every consumer with a probe already had.\n */\n if (uPrefilterBox > 0.5 && uPrefilterIrradiance <= 0.5) {\n fragColor = vec4(\n textureLod(uPrefilterSource, n, min(uPrefilterLevel, uPrefilterSourceMaxLod)).rgb,\n 1.0\n );\n return;\n }\n\n /*\n * **The diffuse level, and it is a mean rather than a weighted sum.** Every cosine sample is\n * drawn with the weight already in its density, so there is nothing to accumulate a weight for\n * and nothing to divide by but the count. A sample below the horizon cannot occur, which is the\n * other half of why this arm has no continue in it.\n */\n if (uPrefilterIrradiance > 0.5) {\n vec3 diffuse = vec3(0.0);\n int cosineSamples = int(uPrefilterSamples);\n for (int i = 0; i < cosineSamples; i++) {\n vec2 xi = vec2(float(i) / uPrefilterSamples, radicalInverse(uint(i)));\n vec3 l = importanceSampleCosine(xi, n);\n /*\n * The same filtered importance sampling the reflection uses, against the cosine density.\n * A diffuse sample is wide \u2014 a sixth of the sphere at the median \u2014 so reading the base level\n * would carry a bright window through as a handful of fireflies in the ambient, which is the\n * one place they cannot be blurred out later.\n */\n float pdf = max(dot(n, l) / PI, 1e-4);\n float sampleSolidAngle = 1.0 / (uPrefilterSamples * pdf);\n float lod = clamp(\n 0.5 * log2(sampleSolidAngle / texelSolidAngle),\n 0.0,\n uPrefilterSourceMaxLod\n );\n diffuse += textureLod(uPrefilterSource, l, lod).rgb;\n }\n fragColor = vec4(diffuse / max(uPrefilterSamples, 1.0), 1.0);\n return;\n }\n\n /*\n * A mirror is a fetch rather than an integral, and it is a separate arm on purpose: at\n * roughness 0 the distribution is a delta, every importance sample collapses onto the same\n * direction, and the sum is one sample's worth of noise where the answer is exactly the texel.\n */\n if (uPrefilterRoughness <= 0.0) {\n fragColor = vec4(textureLod(uPrefilterSource, n, 0.0).rgb, 1.0);\n return;\n }\n\n vec3 sum = vec3(0.0);\n float weight = 0.0;\n\n int samples = int(uPrefilterSamples);\n for (int i = 0; i < samples; i++) {\n vec2 xi = vec2(float(i) / uPrefilterSamples, radicalInverse(uint(i)));\n vec3 h = importanceSampleGgx(xi, n, uPrefilterRoughness);\n vec3 l = normalize(2.0 * dot(n, h) * h - n);\n\n float ndl = dot(n, l);\n if (ndl <= 0.0) continue;\n\n /*\n * Filtered importance sampling. A sample covers 1 / (count * pdf) of the sphere; when that\n * is much wider than a texel, reading the base level lets a few very bright texels land in\n * some samples and not others, and the variance survives the average as fireflies. The half\n * in front of the logarithm is a length ratio against an area one \u2014 a level step doubles a\n * texel's width and quadruples its solid angle. sourceLevelForSample holds the same\n * arithmetic on the CPU and its test carries the hand-derived numbers.\n */\n float ndh = max(dot(n, h), 0.0);\n float pdf = max(ggxDistribution(ndh, uPrefilterRoughness) * 0.25, 1e-8);\n float sampleSolidAngle = 1.0 / (uPrefilterSamples * pdf);\n float lod = clamp(\n 0.5 * log2(sampleSolidAngle / texelSolidAngle),\n 0.0,\n uPrefilterSourceMaxLod\n );\n\n /*\n * textureLod rather than texture, and the 2026-08-07 rule is why: this fetch sits inside a\n * loop with a continue in it, which no compiler can prove uniform, and an implicit derivative\n * in non-uniform control flow is what lets a driver flatten the loop and cost every arm.\n * The level is wanted here anyway, so the rule costs nothing at all.\n */\n sum += textureLod(uPrefilterSource, l, lod).rgb * ndl;\n weight += ndl;\n }\n\n /*\n * Normalised by the accumulated weight rather than by the sample count, because samples with\n * a light below the horizon were skipped and dividing by the count would darken every rough\n * level by the fraction that missed \u2014 which reads as roughness losing energy and is exactly\n * the artefact uEnvironmentGain has been compensating for.\n */\n fragColor = vec4(sum / max(weight, 1e-4), 1.0);\n}\n";
|
|
37
|
+
export declare const PREFILTER_FRAG = "#version 300 es\nprecision highp float;\n/*\n * highp int as well, because the radical inverse below is a 32-bit bit reversal and the fragment\n * default for int and uint is mediump. A driver that computes mediump in sixteen bits returns 0\n * for every sample, and every cosine sample of the diffuse level lands on its own normal.\n */\nprecision highp int;\n\nvec2 octEncode(vec3 d) {\n vec3 n = d / (abs(d.x) + abs(d.y) + abs(d.z));\n vec2 p = n.z >= 0.0\n ? n.xy\n : (1.0 - abs(vec2(n.y, n.x))) * vec2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0);\n return p * 0.5 + 0.5;\n}\n\nvec3 octDecode(vec2 uv) {\n vec2 f = uv * 2.0 - 1.0;\n vec3 n = vec3(f.x, f.y, 1.0 - abs(f.x) - abs(f.y));\n float t = max(-n.z, 0.0);\n n.x += n.x >= 0.0 ? -t : t;\n n.y += n.y >= 0.0 ? -t : t;\n return normalize(n);\n}\n\nvec2 octWrapUv(vec2 uv) {\n vec2 f = uv * 2.0 - 1.0;\n if (f.x < -1.0 || f.x > 1.0) {\n f.x = (f.x > 0.0 ? 2.0 : -2.0) - f.x;\n f.y = -f.y;\n }\n if (f.y < -1.0 || f.y > 1.0) {\n f.y = (f.y > 0.0 ? 2.0 : -2.0) - f.y;\n f.x = -f.x;\n }\n return f * 0.5 + 0.5;\n}\n\nvec2 octInsetUv(vec3 d, float edge) {\n return (octEncode(d) * (edge - 2.0) + 1.0) / edge;\n}\n\nvec3 octInsetDir(vec2 uv, float edge) {\n return octDecode(octWrapUv((uv * edge - 1.0) / (edge - 2.0)));\n}\n\nin vec2 vUv;\n\n/** The captured probe, box-filtered, which this convolves. Never the cube being written. */\nuniform highp samplerCube uPrefilterSource;\n\n/**\n * The edge of the level being written, in texels, which is what the inset is derived from.\n *\n * The level's own size rather than the array's, because a gutter is one texel at every level and\n * its share of the map therefore doubles as the chain coarsens. A base edge passed here instead\n * would inset every level by the base's fraction, which is a fifth of a texel at the top of the\n * chain and reads as the reflection sliding as roughness rises.\n */\nuniform float uPrefilterEdge;\n\n/** 1 on the level holding the cosine convolution, 0 on every level of the reflection chain. */\nuniform float uPrefilterIrradiance;\n\n/**\n * 1 when the reflection chain is a box filter rather than a GGX convolution.\n *\n * RenderQuality.environmentPrefilter is off by default, and that default is a correction rather\n * than a preference: the prefilter shipped wired straight into the lit pass and changed the\n * picture of every consumer that had a probe, which was reported as metals reading opaque. A\n * profile that has not asked for the lobe chain must get the same reflection it had before probes\n * moved into an array, so the box arm exists and is what that profile bakes.\n *\n * The array edge is twice the source cube's face, so one array level matches one cube level in\n * angular density and the box arm is a single fetch at the same index. There is no second\n * texture: the flag decides what is convolved into the layer rather than which of two chains is\n * bound, so it is read at bake time and a profile that changes it has to bake again.\n */\nuniform float uPrefilterBox;\n\n/** The level being written, for the box arm, which reads the source chain at the same index. */\nuniform float uPrefilterLevel;\n\n/** The roughness this level stands for, from roughnessForLevel. 0 is a mirror. */\nuniform float uPrefilterRoughness;\n/** Samples per texel, from PREFILTER_SAMPLE_COUNTS. A power of two; see the count's own note. */\nuniform float uPrefilterSamples;\n/** The source cube's face width in texels, for the solid-angle comparison below. */\nuniform float uPrefilterSourceTexels;\n/** The source chain's top level, so a wide sample cannot ask for a level that is not there. */\nuniform float uPrefilterSourceMaxLod;\n\nout vec4 fragColor;\n\nconst float PI = 3.14159265359;\n\n/**\n * The radical inverse of a 32-bit integer, base two: the second dimension of a Hammersley set.\n *\n * A bit reversal, written as the standard five-shift sequence rather than a loop, because a loop\n * over 32 bits per sample per texel is the one place in a bake where the arithmetic is the cost.\n * **This is why every sample count is a power of two** \u2014 the sequence's stratification is only\n * even when the count is, and an uneven one shows as a faint directional bias in a rough level\n * rather than as an error anybody would notice.\n */\nfloat radicalInverse(uint bits) {\n bits = (bits << 16u) | (bits >> 16u);\n bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);\n bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);\n bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);\n bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);\n return float(bits) * 2.3283064365386963e-10;\n}\n\n/**\n * A half vector drawn from the GGX distribution about the normal, for one Hammersley pair.\n *\n * Importance sampling rather than a uniform hemisphere: at low roughness the lobe occupies a\n * vanishing fraction of the hemisphere, so uniform samples would put almost every one of them\n * where the weight is zero and the few that land in the lobe would carry all the variance.\n */\nvec3 importanceSampleGgx(vec2 xi, vec3 n, float roughness) {\n float a = roughness * roughness;\n\n float phi = 2.0 * PI * xi.x;\n float cosTheta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y));\n float sinTheta = sqrt(1.0 - cosTheta * cosTheta);\n\n vec3 h = vec3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);\n\n /*\n * Any tangent will do as long as it is not parallel to the normal, and the choice of which\n * axis to cross against has to depend on the normal or it degenerates at the poles \u2014 which is\n * one face of six coming out as noise, on the two faces the axis points at.\n */\n vec3 up = abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);\n vec3 tangentX = normalize(cross(up, n));\n vec3 tangentY = cross(n, tangentX);\n\n return normalize(tangentX * h.x + tangentY * h.y + n * h.z);\n}\n\n/**\n * A direction drawn from the cosine lobe about the normal, for the diffuse level.\n *\n * **Cosine-weighted, which is what makes the estimator a plain mean.** Irradiance is\n * integral of L cos(theta), the cosine pdf is cos(theta) / pi, so the estimate is\n * pi / N * sum(L) \u2014 and what the lit pass wants is radiance rather than irradiance, so the pi\n * divides straight back out. irradianceSh.ts folds the same division into its band factors and\n * says so, which is what makes a grid of one reproduce what the projection produced.\n */\nvec3 importanceSampleCosine(vec2 xi, vec3 n) {\n float phi = 2.0 * PI * xi.x;\n float cosTheta = sqrt(max(1.0 - xi.y, 0.0));\n float sinTheta = sqrt(xi.y);\n\n vec3 h = vec3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);\n\n vec3 up = abs(n.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);\n vec3 tangentX = normalize(cross(up, n));\n vec3 tangentY = cross(n, tangentX);\n\n return normalize(tangentX * h.x + tangentY * h.y + n * h.z);\n}\n\n/** Trowbridge-Reitz, in its own normalisation, because this is a probability rather than a look. */\nfloat ggxDistribution(float ndh, float roughness) {\n float a = roughness * roughness;\n float a2 = a * a;\n float d = ndh * ndh * (a2 - 1.0) + 1.0;\n return a2 / max(PI * d * d, 1e-8);\n}\n\nvoid main() {\n vec3 n = octInsetDir(vUv, uPrefilterEdge);\n\n float texelSolidAngle = 4.0 * PI / (6.0 * uPrefilterSourceTexels * uPrefilterSourceTexels);\n\n /*\n * The box chain, which is one fetch: the source's own level at the same index, resampled through\n * the octahedral mapping. It is what a profile that has not asked for the lobe chain gets, and\n * it is the picture every consumer with a probe already had.\n */\n if (uPrefilterBox > 0.5 && uPrefilterIrradiance <= 0.5) {\n fragColor = vec4(\n textureLod(uPrefilterSource, n, min(uPrefilterLevel, uPrefilterSourceMaxLod)).rgb,\n 1.0\n );\n return;\n }\n\n /*\n * **The diffuse level, and it is a mean rather than a weighted sum.** Every cosine sample is\n * drawn with the weight already in its density, so there is nothing to accumulate a weight for\n * and nothing to divide by but the count. A sample below the horizon cannot occur, which is the\n * other half of why this arm has no continue in it.\n */\n if (uPrefilterIrradiance > 0.5) {\n vec3 diffuse = vec3(0.0);\n int cosineSamples = int(uPrefilterSamples);\n for (int i = 0; i < cosineSamples; i++) {\n vec2 xi = vec2(float(i) / uPrefilterSamples, radicalInverse(uint(i)));\n vec3 l = importanceSampleCosine(xi, n);\n /*\n * The same filtered importance sampling the reflection uses, against the cosine density.\n * A diffuse sample is wide \u2014 a sixth of the sphere at the median \u2014 so reading the base level\n * would carry a bright window through as a handful of fireflies in the ambient, which is the\n * one place they cannot be blurred out later.\n */\n float pdf = max(dot(n, l) / PI, 1e-4);\n float sampleSolidAngle = 1.0 / (uPrefilterSamples * pdf);\n float lod = clamp(\n 0.5 * log2(sampleSolidAngle / texelSolidAngle),\n 0.0,\n uPrefilterSourceMaxLod\n );\n diffuse += textureLod(uPrefilterSource, l, lod).rgb;\n }\n fragColor = vec4(diffuse / max(uPrefilterSamples, 1.0), 1.0);\n return;\n }\n\n /*\n * A mirror is a fetch rather than an integral, and it is a separate arm on purpose: at\n * roughness 0 the distribution is a delta, every importance sample collapses onto the same\n * direction, and the sum is one sample's worth of noise where the answer is exactly the texel.\n */\n if (uPrefilterRoughness <= 0.0) {\n fragColor = vec4(textureLod(uPrefilterSource, n, 0.0).rgb, 1.0);\n return;\n }\n\n vec3 sum = vec3(0.0);\n float weight = 0.0;\n\n int samples = int(uPrefilterSamples);\n for (int i = 0; i < samples; i++) {\n vec2 xi = vec2(float(i) / uPrefilterSamples, radicalInverse(uint(i)));\n vec3 h = importanceSampleGgx(xi, n, uPrefilterRoughness);\n vec3 l = normalize(2.0 * dot(n, h) * h - n);\n\n float ndl = dot(n, l);\n if (ndl <= 0.0) continue;\n\n /*\n * Filtered importance sampling. A sample covers 1 / (count * pdf) of the sphere; when that\n * is much wider than a texel, reading the base level lets a few very bright texels land in\n * some samples and not others, and the variance survives the average as fireflies. The half\n * in front of the logarithm is a length ratio against an area one \u2014 a level step doubles a\n * texel's width and quadruples its solid angle. sourceLevelForSample holds the same\n * arithmetic on the CPU and its test carries the hand-derived numbers.\n */\n float ndh = max(dot(n, h), 0.0);\n float pdf = max(ggxDistribution(ndh, uPrefilterRoughness) * 0.25, 1e-8);\n float sampleSolidAngle = 1.0 / (uPrefilterSamples * pdf);\n float lod = clamp(\n 0.5 * log2(sampleSolidAngle / texelSolidAngle),\n 0.0,\n uPrefilterSourceMaxLod\n );\n\n /*\n * textureLod rather than texture, and the 2026-08-07 rule is why: this fetch sits inside a\n * loop with a continue in it, which no compiler can prove uniform, and an implicit derivative\n * in non-uniform control flow is what lets a driver flatten the loop and cost every arm.\n * The level is wanted here anyway, so the rule costs nothing at all.\n */\n sum += textureLod(uPrefilterSource, l, lod).rgb * ndl;\n weight += ndl;\n }\n\n /*\n * Normalised by the accumulated weight rather than by the sample count, because samples with\n * a light below the horizon were skipped and dividing by the count would darken every rough\n * level by the fraction that missed \u2014 which reads as roughness losing energy and is exactly\n * the artefact uEnvironmentGain has been compensating for.\n */\n fragColor = vec4(sum / max(weight, 1e-4), 1.0);\n}\n";
|