three-realtime-rt 0.3.0 → 0.3.2

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.
@@ -1,329 +1,398 @@
1
- import * as THREE from "three";
2
- import { shaderStructs, shaderIntersectFunction } from "three-mesh-bvh";
3
- import { MAX_LIGHTS } from "./SceneCompiler.js";
4
- import { BVH_ANY_HIT_GLSL } from "./bvhAnyHit.glsl.js";
5
-
6
- const fullscreenVert = /* glsl */ `
7
- out vec2 vUv;
8
- void main() {
9
- vUv = uv;
10
- gl_Position = vec4(position.xy, 0.0, 1.0);
11
- }
12
- `;
13
-
14
- // Physically-based single-scatter volumetric light ("god rays"): for each
15
- // pixel, pick ONE jittered point along the camera ray, sample ONE light
16
- // source there, and cast a real BVH shadow ray — shafts are carved by actual
17
- // occluders, not screen-space tricks, and work for off-screen sources too.
18
- // One occlusion ray per pixel per frame; the Monte Carlo estimate converges
19
- // through the same EMA temporal accumulation the surface lighting uses.
20
- const volumetricFrag = /* glsl */ `
21
- precision highp float;
22
- precision highp isampler2D;
23
- precision highp usampler2D;
24
-
25
- ${shaderStructs}
26
- ${shaderIntersectFunction}
27
- ${BVH_ANY_HIT_GLSL}
28
-
29
- #define MAX_LIGHTS ${MAX_LIGHTS}
30
- #define PI 3.14159265358979
31
-
32
- layout(location = 0) out vec4 outScatter;
33
-
34
- in vec2 vUv;
35
-
36
- uniform BVH bvhStatic;
37
- uniform BVH bvhDynamic;
38
- uniform bool uHasDynamic;
39
- uniform sampler2D uMaterialsTex; // row 1: emissive NEE triangles
40
- uniform sampler2D uGWorldPos;
41
-
42
- // temporal accumulation (reprojected through the SURFACE point — an
43
- // approximation for a view-ray quantity, good enough for smooth fog)
44
- uniform sampler2D uPrevAccum;
45
- uniform mat4 uPrevViewProj;
46
- uniform float uMaxHistory;
47
-
48
- uniform vec4 uLightPosType[MAX_LIGHTS];
49
- uniform vec4 uLightColorRadius[MAX_LIGHTS];
50
- uniform int uLightCount;
51
- uniform int uEmissiveCount;
52
-
53
- uniform vec3 uCameraPos;
54
- uniform float uFrame;
55
- uniform float uEps;
56
- uniform float uDensity; // scatter coefficient
57
- uniform float uMaxDist; // cap for rays that hit nothing / far surfaces
58
-
59
- // ---------- RNG ----------
60
- // First four dims from the shared blue-noise tile (rows 2..65 of the
61
- // scene-data texture) with an R2 temporal shift; the rest is PCG. Same
62
- // scheme as RTLightingPass — see the comment there.
63
- uint gSeed;
64
- int gBnDim;
65
- vec4 gBlueNoise;
66
- uint pcgHash(uint s) {
67
- uint state = s * 747796405u + 2891336453u;
68
- uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
69
- return (word >> 22u) ^ word;
70
- }
71
- float rand() {
72
- if (gBnDim < 4) {
73
- float v = gBlueNoise[gBnDim];
74
- gBnDim++;
75
- return v;
76
- }
77
- gSeed = pcgHash(gSeed);
78
- return float(gSeed) * (1.0 / 4294967296.0);
79
- }
80
- vec2 rand2() { return vec2(rand(), rand()); }
81
-
82
- vec4 fetchBlueNoise() {
83
- ivec2 p = ivec2(gl_FragCoord.xy) & 63;
84
- vec4 bn = texelFetch(uMaterialsTex, ivec2(p.x, 2 + p.y), 0);
85
- vec4 shift = fract(float(uFrame) * vec4(0.6180340, 0.7548777, 0.5698403, 0.8191725));
86
- return fract(bn + shift);
87
- }
88
-
89
- vec3 randUnitVector() {
90
- vec2 u = rand2();
91
- float z = u.x * 2.0 - 1.0;
92
- float a = u.y * 2.0 * PI;
93
- float r = sqrt(max(0.0, 1.0 - z * z));
94
- return vec3(r * cos(a), r * sin(a), z);
95
- }
96
-
97
- // Any-hit: first blocker wins, no closest-hit sorting (see bvhAnyHit.glsl.js).
98
- bool occluded(vec3 ro, vec3 rd, float maxDist) {
99
- if (bvhIntersectAnyHit(bvhStatic, ro, rd, maxDist - 2.0 * uEps)) return true;
100
- if (uHasDynamic && bvhIntersectAnyHit(bvhDynamic, ro, rd, maxDist - 2.0 * uEps)) return true;
101
- return false;
102
- }
103
-
104
- // In-scattered radiance at a point in the volume from analytic light i.
105
- // Like the surface version but with no cosine term (isotropic phase, folded
106
- // into uDensity along with 1/4π).
107
- vec3 lightAt(int i, vec3 S) {
108
- vec4 posType = uLightPosType[i];
109
- vec4 colRad = uLightColorRadius[i];
110
- if (posType.w < 0.5) {
111
- vec3 lp = posType.xyz + randUnitVector() * colRad.w;
112
- vec3 d = lp - S;
113
- float dist = length(d);
114
- if (dist < 1e-4) return vec3(0.0);
115
- if (occluded(S, d / dist, dist)) return vec3(0.0);
116
- return colRad.rgb / (dist * dist);
117
- }
118
- vec3 L = normalize(-posType.xyz + randUnitVector() * colRad.w);
119
- if (occluded(S, L, 1e7)) return vec3(0.0);
120
- return colRad.rgb;
121
- }
122
-
123
- // In-scattered radiance from one sampled emissive triangle (row 1 of the
124
- // materials texture same layout the lighting pass uses).
125
- vec3 emissiveAt(vec3 S) {
126
- if (uEmissiveCount == 0) return vec3(0.0);
127
- int i = min(int(rand() * float(uEmissiveCount)), uEmissiveCount - 1) * 4;
128
- vec4 t0 = texelFetch(uMaterialsTex, ivec2(i, 1), 0);
129
- vec4 t1 = texelFetch(uMaterialsTex, ivec2(i + 1, 1), 0);
130
- vec4 t2 = texelFetch(uMaterialsTex, ivec2(i + 2, 1), 0);
131
- vec4 t3 = texelFetch(uMaterialsTex, ivec2(i + 3, 1), 0);
132
- vec2 u = rand2();
133
- if (u.x + u.y > 1.0) u = 1.0 - u;
134
- vec3 lp = t0.xyz + t1.xyz * u.x + t2.xyz * u.y;
135
- vec3 d = lp - S;
136
- float d2 = dot(d, d);
137
- float dist = sqrt(d2);
138
- if (dist < 1e-4) return vec3(0.0);
139
- vec3 wi = d / dist;
140
- float cosL = abs(dot(t3.xyz, wi));
141
- if (cosL < 1e-4) return vec3(0.0);
142
- if (occluded(S, wi, dist)) return vec3(0.0);
143
- vec3 e = vec3(t1.w, t2.w, t3.w) * (cosL * float(uEmissiveCount) * t0.w / max(d2, 1e-4));
144
- // same close-range variance clamp idea as the surface pass
145
- float l = dot(e, vec3(0.299, 0.587, 0.114));
146
- if (l > 20.0) e *= 20.0 / l;
147
- return e;
148
- }
149
-
150
- void main() {
151
- vec4 wp = texture(uGWorldPos, vUv);
152
-
153
- ivec2 px = ivec2(gl_FragCoord.xy);
154
- gSeed = uint(px.x) * 2153u + uint(px.y) * 9277u + uint(uFrame) * 26699u;
155
- gSeed = pcgHash(gSeed);
156
- gBlueNoise = fetchBlueNoise();
157
- gBnDim = 0;
158
-
159
- // Segment to integrate: camera → surface (or the fog cap on a miss).
160
- bool hit = wp.w > 0.5;
161
- vec3 P = wp.xyz;
162
- float segLen = hit ? min(distance(P, uCameraPos), uMaxDist) : uMaxDist;
163
- vec3 rd = hit
164
- ? normalize(P - uCameraPos)
165
- : vec3(0.0); // background without geometry: skip (no stable ray direction here)
166
-
167
- vec3 sample_ = vec3(0.0);
168
- if (hit && segLen > 1e-3) {
169
- // ONE jittered point on the segment (uniform pdf 1/segLen).
170
- float t = rand() * segLen;
171
- vec3 S = uCameraPos + rd * t;
172
- vec3 Lin = vec3(0.0);
173
- // Stochastically pick analytic lights or the emissive set, weighted 1/p.
174
- bool hasL = uLightCount > 0;
175
- bool hasE = uEmissiveCount > 0;
176
- if (hasL && hasE) {
177
- if (rand() < 0.5) {
178
- int i = min(int(rand() * float(uLightCount)), uLightCount - 1);
179
- Lin = lightAt(i, S) * float(uLightCount) * 2.0;
180
- } else {
181
- Lin = emissiveAt(S) * 2.0;
182
- }
183
- } else if (hasL) {
184
- int i = min(int(rand() * float(uLightCount)), uLightCount - 1);
185
- Lin = lightAt(i, S) * float(uLightCount);
186
- } else if (hasE) {
187
- Lin = emissiveAt(S);
188
- }
189
- // transmittance to the camera + estimator weight (segLen / pdf-normalized)
190
- sample_ = Lin * uDensity * segLen * exp(-uDensity * t);
191
- // Single-sample spike clamp same reasoning as the surface firefly clamp:
192
- // the EMA decays outliers only as 1/count, so they read as grain.
193
- float sl = dot(sample_, vec3(0.299, 0.587, 0.114));
194
- if (sl > 4.0) sample_ *= 4.0 / sl;
195
- }
196
-
197
- // --- temporal accumulation, reprojected through the surface point ---
198
- float count = 1.0;
199
- vec3 history = vec3(0.0);
200
- if (hit) {
201
- vec4 clip = uPrevViewProj * vec4(P, 1.0);
202
- if (clip.w > 0.0) {
203
- vec2 prevUv = (clip.xy / clip.w) * 0.5 + 0.5;
204
- if (prevUv.x >= 0.0 && prevUv.x <= 1.0 && prevUv.y >= 0.0 && prevUv.y <= 1.0) {
205
- vec4 h = texture(uPrevAccum, prevUv);
206
- count = clamp(h.a, 0.0, uMaxHistory) + 1.0;
207
- history = h.rgb;
208
- }
209
- }
210
- }
211
- vec3 blended = mix(history, sample_, 1.0 / count);
212
- if (any(isnan(blended)) || any(isinf(blended))) blended = vec3(0.0);
213
- outScatter = vec4(blended, count);
214
- }
215
- `;
216
-
217
- /**
218
- * Optional volumetric lighting pass at lighting resolution. Ping-pongs an
219
- * accumulation buffer like RTLightingPass; the composite adds the result
220
- * before fog/tonemap. Cost when enabled one extra shadow ray per lighting
221
- * pixel per frame.
222
- */
223
- export class VolumetricPass {
224
- constructor(width, height) {
225
- this.targetA = this._makeTarget(width, height);
226
- this.targetB = this._makeTarget(width, height);
227
-
228
- this.material = new THREE.ShaderMaterial({
229
- glslVersion: THREE.GLSL3,
230
- vertexShader: fullscreenVert,
231
- fragmentShader: volumetricFrag,
232
- uniforms: {
233
- bvhStatic: { value: null },
234
- bvhDynamic: { value: null },
235
- uHasDynamic: { value: false },
236
- uMaterialsTex: { value: null },
237
- uGWorldPos: { value: null },
238
- uPrevAccum: { value: null },
239
- uPrevViewProj: { value: new THREE.Matrix4() },
240
- uMaxHistory: { value: 48 },
241
- uLightPosType: { value: [] },
242
- uLightColorRadius: { value: [] },
243
- uLightCount: { value: 0 },
244
- uEmissiveCount: { value: 0 },
245
- uCameraPos: { value: new THREE.Vector3() },
246
- uFrame: { value: 0 },
247
- uEps: { value: 1e-3 },
248
- uDensity: { value: 0.03 },
249
- uMaxDist: { value: 40 },
250
- },
251
- depthTest: false,
252
- depthWrite: false,
253
- });
254
-
255
- this.scene = new THREE.Scene();
256
- this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
257
- this.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.material);
258
- this.quad.frustumCulled = false;
259
- this.scene.add(this.quad);
260
- }
261
-
262
- _makeTarget(width, height) {
263
- const t = new THREE.WebGLRenderTarget(width, height, {
264
- minFilter: THREE.LinearFilter,
265
- magFilter: THREE.LinearFilter,
266
- format: THREE.RGBAFormat,
267
- type: THREE.HalfFloatType,
268
- depthBuffer: false,
269
- stencilBuffer: false,
270
- });
271
- t.texture.generateMipmaps = false;
272
- return t;
273
- }
274
-
275
- setCompiledScene(compiled) {
276
- const u = this.material.uniforms;
277
- u.bvhStatic.value = compiled.staticBvhUniform;
278
- u.bvhDynamic.value = compiled.dynamicBvhUniform;
279
- u.uHasDynamic.value = compiled.hasDynamic;
280
- u.uMaterialsTex.value = compiled.materialsTex;
281
- u.uLightPosType.value = compiled.lightPosType;
282
- u.uLightColorRadius.value = compiled.lightColorRadius;
283
- u.uLightCount.value = compiled.lightCount;
284
- u.uEmissiveCount.value = compiled.emissiveTriCount;
285
- }
286
-
287
- clearHistory(renderer) {
288
- const prev = renderer.getRenderTarget();
289
- renderer.setClearColor(0x000000, 0);
290
- for (const t of [this.targetA, this.targetB]) {
291
- renderer.setRenderTarget(t);
292
- renderer.clear(true, false, false);
293
- }
294
- renderer.setRenderTarget(prev);
295
- }
296
-
297
- setSize(width, height) {
298
- this.targetA.setSize(width, height);
299
- this.targetB.setSize(width, height);
300
- }
301
-
302
- /** Renders into targetA (reading targetB as history), swaps, returns the texture. */
303
- render(renderer, gbuffer, prevViewProj, cameraPos, frame, eps, density, maxDist) {
304
- const u = this.material.uniforms;
305
- u.uGWorldPos.value = gbuffer.worldPos;
306
- u.uPrevAccum.value = this.targetB.texture;
307
- u.uPrevViewProj.value.copy(prevViewProj);
308
- u.uCameraPos.value.copy(cameraPos);
309
- u.uFrame.value = frame;
310
- u.uEps.value = eps;
311
- u.uDensity.value = density;
312
- u.uMaxDist.value = maxDist;
313
-
314
- renderer.setRenderTarget(this.targetA);
315
- renderer.render(this.scene, this.camera);
316
- renderer.setRenderTarget(null);
317
-
318
- const out = this.targetA;
319
- [this.targetA, this.targetB] = [this.targetB, this.targetA];
320
- return out.texture;
321
- }
322
-
323
- dispose() {
324
- this.targetA.dispose();
325
- this.targetB.dispose();
326
- this.material.dispose();
327
- this.quad.geometry.dispose();
328
- }
329
- }
1
+ import * as THREE from "three";
2
+ import { shaderStructs, shaderIntersectFunction } from "three-mesh-bvh";
3
+ import { MAX_LIGHTS } from "./SceneCompiler.js";
4
+ import { BVH_ANY_HIT_GLSL } from "./bvhAnyHit.glsl.js";
5
+
6
+ // Must match MAX_FOG_ZONES in the fragment shader.
7
+ const MAX_FOG_ZONES = 8;
8
+
9
+ const fullscreenVert = /* glsl */ `
10
+ out vec2 vUv;
11
+ void main() {
12
+ vUv = uv;
13
+ gl_Position = vec4(position.xy, 0.0, 1.0);
14
+ }
15
+ `;
16
+
17
+ // Physically-based single-scatter volumetric light ("god rays"): for each
18
+ // pixel, pick ONE jittered point along the camera ray, sample ONE light
19
+ // source there, and cast a real BVH shadow ray — shafts are carved by actual
20
+ // occluders, not screen-space tricks, and work for off-screen sources too.
21
+ // One occlusion ray per pixel per frame; the Monte Carlo estimate converges
22
+ // through the same EMA temporal accumulation the surface lighting uses.
23
+ const volumetricFrag = /* glsl */ `
24
+ precision highp float;
25
+ precision highp isampler2D;
26
+ precision highp usampler2D;
27
+
28
+ ${shaderStructs}
29
+ ${shaderIntersectFunction}
30
+ ${BVH_ANY_HIT_GLSL}
31
+
32
+ #define MAX_LIGHTS ${MAX_LIGHTS}
33
+ #define PI 3.14159265358979
34
+
35
+ layout(location = 0) out vec4 outScatter;
36
+
37
+ in vec2 vUv;
38
+
39
+ uniform BVH bvhStatic;
40
+ uniform BVH bvhDynamic;
41
+ uniform bool uHasDynamic;
42
+ uniform sampler2D uMaterialsTex; // row 1: emissive NEE triangles
43
+ uniform sampler2D uGWorldPos;
44
+
45
+ // temporal accumulation (reprojected through the SURFACE point — an
46
+ // approximation for a view-ray quantity, good enough for smooth fog)
47
+ uniform sampler2D uPrevAccum;
48
+ uniform mat4 uPrevViewProj;
49
+ uniform float uMaxHistory;
50
+
51
+ uniform vec4 uLightPosType[MAX_LIGHTS];
52
+ uniform vec4 uLightColorRadius[MAX_LIGHTS];
53
+ uniform vec4 uLightDirCone[MAX_LIGHTS]; // spot: direction.xyz + cos(outer)
54
+ uniform int uLightCount;
55
+ uniform int uEmissiveCount;
56
+
57
+ uniform vec3 uCameraPos;
58
+ uniform float uFrame;
59
+ uniform float uEps;
60
+ uniform float uDensity; // scatter coefficient (global term)
61
+ uniform float uMaxDist; // cap for rays that hit nothing / far surfaces
62
+
63
+ // Localized fog zones: up to 8 AABBs. Two vec4 per zone —
64
+ // [2*i] = (min.xyz, density), [2*i+1] = (max.xyz, unused).
65
+ // Density at a point = uDensity + Σ density of every zone containing it.
66
+ #define MAX_FOG_ZONES 8
67
+ uniform vec4 uFogZones[MAX_FOG_ZONES * 2];
68
+ uniform int uFogZoneCount;
69
+
70
+ float fogDensityAt(vec3 p) {
71
+ float d = uDensity;
72
+ for (int i = 0; i < MAX_FOG_ZONES; i++) {
73
+ if (i >= uFogZoneCount) break;
74
+ vec4 lo = uFogZones[i * 2];
75
+ vec3 mn = lo.xyz;
76
+ vec3 mx = uFogZones[i * 2 + 1].xyz;
77
+ if (all(greaterThanEqual(p, mn)) && all(lessThanEqual(p, mx))) {
78
+ d += lo.w;
79
+ }
80
+ }
81
+ return d;
82
+ }
83
+
84
+ // ---------- RNG ----------
85
+ // First four dims from the shared blue-noise tile (rows 2..65 of the
86
+ // scene-data texture) with an R2 temporal shift; the rest is PCG. Same
87
+ // scheme as RTLightingPass — see the comment there.
88
+ uint gSeed;
89
+ int gBnDim;
90
+ vec4 gBlueNoise;
91
+ uint pcgHash(uint s) {
92
+ uint state = s * 747796405u + 2891336453u;
93
+ uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
94
+ return (word >> 22u) ^ word;
95
+ }
96
+ float rand() {
97
+ if (gBnDim < 4) {
98
+ float v = gBlueNoise[gBnDim];
99
+ gBnDim++;
100
+ return v;
101
+ }
102
+ gSeed = pcgHash(gSeed);
103
+ return float(gSeed) * (1.0 / 4294967296.0);
104
+ }
105
+ vec2 rand2() { return vec2(rand(), rand()); }
106
+
107
+ vec4 fetchBlueNoise() {
108
+ ivec2 p = ivec2(gl_FragCoord.xy) & 63;
109
+ vec4 bn = texelFetch(uMaterialsTex, ivec2(p.x, 2 + p.y), 0);
110
+ vec4 shift = fract(float(uFrame) * vec4(0.6180340, 0.7548777, 0.5698403, 0.8191725));
111
+ return fract(bn + shift);
112
+ }
113
+
114
+ vec3 randUnitVector() {
115
+ vec2 u = rand2();
116
+ float z = u.x * 2.0 - 1.0;
117
+ float a = u.y * 2.0 * PI;
118
+ float r = sqrt(max(0.0, 1.0 - z * z));
119
+ return vec3(r * cos(a), r * sin(a), z);
120
+ }
121
+
122
+ // Any-hit: first blocker wins, no closest-hit sorting (see bvhAnyHit.glsl.js).
123
+ bool occluded(vec3 ro, vec3 rd, float maxDist) {
124
+ if (bvhIntersectAnyHit(bvhStatic, ro, rd, maxDist - 2.0 * uEps)) return true;
125
+ if (uHasDynamic && bvhIntersectAnyHit(bvhDynamic, ro, rd, maxDist - 2.0 * uEps)) return true;
126
+ return false;
127
+ }
128
+
129
+ // In-scattered radiance at a point in the volume from analytic light i.
130
+ // Like the surface version but with no cosine term (isotropic phase, folded
131
+ // into uDensity along with 1/4π).
132
+ vec3 lightAt(int i, vec3 S) {
133
+ vec4 posType = uLightPosType[i];
134
+ vec4 colRad = uLightColorRadius[i];
135
+ if (posType.w < 0.5 || posType.w >= 1.5) {
136
+ vec3 lp = posType.xyz + randUnitVector() * colRad.w;
137
+ vec3 d = lp - S;
138
+ float dist = length(d);
139
+ if (dist < 1e-4) return vec3(0.0);
140
+ float cone = 1.0;
141
+ if (posType.w >= 1.5) {
142
+ // spot: this is what draws visible light CONES in fog
143
+ vec4 dc = uLightDirCone[i];
144
+ cone = smoothstep(dc.w, posType.w - 2.0, dot(dc.xyz, -d / dist));
145
+ if (cone <= 0.0) return vec3(0.0);
146
+ }
147
+ if (occluded(S, d / dist, dist)) return vec3(0.0);
148
+ return colRad.rgb * (cone / (dist * dist));
149
+ }
150
+ vec3 L = normalize(-posType.xyz + randUnitVector() * colRad.w);
151
+ if (occluded(S, L, 1e7)) return vec3(0.0);
152
+ return colRad.rgb;
153
+ }
154
+
155
+ // In-scattered radiance from one sampled emissive triangle (row 1 of the
156
+ // materials texture — same layout the lighting pass uses).
157
+ vec3 emissiveAt(vec3 S) {
158
+ if (uEmissiveCount == 0) return vec3(0.0);
159
+ int i = min(int(rand() * float(uEmissiveCount)), uEmissiveCount - 1) * 4;
160
+ vec4 t0 = texelFetch(uMaterialsTex, ivec2(i, 1), 0);
161
+ vec4 t1 = texelFetch(uMaterialsTex, ivec2(i + 1, 1), 0);
162
+ vec4 t2 = texelFetch(uMaterialsTex, ivec2(i + 2, 1), 0);
163
+ vec4 t3 = texelFetch(uMaterialsTex, ivec2(i + 3, 1), 0);
164
+ vec2 u = rand2();
165
+ if (u.x + u.y > 1.0) u = 1.0 - u;
166
+ vec3 lp = t0.xyz + t1.xyz * u.x + t2.xyz * u.y;
167
+ vec3 d = lp - S;
168
+ float d2 = dot(d, d);
169
+ float dist = sqrt(d2);
170
+ if (dist < 1e-4) return vec3(0.0);
171
+ vec3 wi = d / dist;
172
+ float cosL = abs(dot(t3.xyz, wi));
173
+ if (cosL < 1e-4) return vec3(0.0);
174
+ if (occluded(S, wi, dist)) return vec3(0.0);
175
+ vec3 e = vec3(t1.w, t2.w, t3.w) * (cosL * float(uEmissiveCount) * t0.w / max(d2, 1e-4));
176
+ // same close-range variance clamp idea as the surface pass
177
+ float l = dot(e, vec3(0.299, 0.587, 0.114));
178
+ if (l > 20.0) e *= 20.0 / l;
179
+ return e;
180
+ }
181
+
182
+ void main() {
183
+ vec4 wp = texture(uGWorldPos, vUv);
184
+
185
+ ivec2 px = ivec2(gl_FragCoord.xy);
186
+ gSeed = uint(px.x) * 2153u + uint(px.y) * 9277u + uint(uFrame) * 26699u;
187
+ gSeed = pcgHash(gSeed);
188
+ gBlueNoise = fetchBlueNoise();
189
+ gBnDim = 0;
190
+
191
+ // Segment to integrate: camera surface (or the fog cap on a miss).
192
+ bool hit = wp.w > 0.5;
193
+ vec3 P = wp.xyz;
194
+ float segLen = hit ? min(distance(P, uCameraPos), uMaxDist) : uMaxDist;
195
+ vec3 rd = hit
196
+ ? normalize(P - uCameraPos)
197
+ : vec3(0.0); // background without geometry: skip (no stable ray direction here)
198
+
199
+ // STRATIFIED MARCH: several jittered steps per ray instead of one point.
200
+ // This pass runs at quarter canvas resolution (fog is low-frequency), so
201
+ // the extra steps cost less than the old single-sample full-lighting-res
202
+ // version and MOVING lights, whose in-scatter field changes every frame
203
+ // and can never converge temporally, get real per-frame averaging.
204
+ // Nothing to scatter: no global fog AND no localized zones output zeros fast.
205
+ if (uDensity <= 0.0 && uFogZoneCount == 0) {
206
+ outScatter = vec4(0.0, 0.0, 0.0, 1.0);
207
+ return;
208
+ }
209
+
210
+ #define VOL_STEPS 4
211
+ vec3 sample_ = vec3(0.0);
212
+ if (hit && segLen > 1e-3) {
213
+ bool hasL = uLightCount > 0;
214
+ bool hasE = uEmissiveCount > 0;
215
+ float segStep = segLen / float(VOL_STEPS);
216
+ // Piecewise integration: density can vary along the ray (zones), so the
217
+ // transmittance is built up step by step from the LOCAL density at each
218
+ // sample rather than a single closed-form exp(-uDensity * t).
219
+ float opticalDepth = 0.0;
220
+ for (int k = 0; k < VOL_STEPS; k++) {
221
+ float t = (float(k) + rand()) * segStep; // ascending strata
222
+ vec3 S = uCameraPos + rd * t;
223
+ float local = fogDensityAt(S);
224
+ opticalDepth += local * segStep;
225
+ vec3 Lin = vec3(0.0);
226
+ // Stochastically pick analytic lights or the emissive set, weighted 1/p.
227
+ if (hasL && hasE) {
228
+ if (rand() < 0.5) {
229
+ int i = min(int(rand() * float(uLightCount)), uLightCount - 1);
230
+ Lin = lightAt(i, S) * float(uLightCount) * 2.0;
231
+ } else {
232
+ Lin = emissiveAt(S) * 2.0;
233
+ }
234
+ } else if (hasL) {
235
+ int i = min(int(rand() * float(uLightCount)), uLightCount - 1);
236
+ Lin = lightAt(i, S) * float(uLightCount);
237
+ } else if (hasE) {
238
+ Lin = emissiveAt(S);
239
+ }
240
+ vec3 c = Lin * local * segStep * exp(-opticalDepth);
241
+ // per-step spike clamp — outliers decay only as 1/count in the EMA
242
+ float sl = dot(c, vec3(0.299, 0.587, 0.114));
243
+ if (sl > 2.0) c *= 2.0 / sl;
244
+ sample_ += c;
245
+ }
246
+ }
247
+
248
+ // --- temporal accumulation, reprojected through the surface point ---
249
+ float count = 1.0;
250
+ vec3 history = vec3(0.0);
251
+ if (hit) {
252
+ vec4 clip = uPrevViewProj * vec4(P, 1.0);
253
+ if (clip.w > 0.0) {
254
+ vec2 prevUv = (clip.xy / clip.w) * 0.5 + 0.5;
255
+ if (prevUv.x >= 0.0 && prevUv.x <= 1.0 && prevUv.y >= 0.0 && prevUv.y <= 1.0) {
256
+ vec4 h = texture(uPrevAccum, prevUv);
257
+ count = clamp(h.a, 0.0, uMaxHistory) + 1.0;
258
+ history = h.rgb;
259
+ }
260
+ }
261
+ }
262
+ vec3 blended = mix(history, sample_, 1.0 / count);
263
+ if (any(isnan(blended)) || any(isinf(blended))) blended = vec3(0.0);
264
+ outScatter = vec4(blended, count);
265
+ }
266
+ `;
267
+
268
+ /**
269
+ * Optional volumetric lighting pass at lighting resolution. Ping-pongs an
270
+ * accumulation buffer like RTLightingPass; the composite adds the result
271
+ * before fog/tonemap. Cost when enabled ≈ one extra shadow ray per lighting
272
+ * pixel per frame.
273
+ */
274
+ export class VolumetricPass {
275
+ constructor(width, height) {
276
+ this.targetA = this._makeTarget(width, height);
277
+ this.targetB = this._makeTarget(width, height);
278
+
279
+ this.material = new THREE.ShaderMaterial({
280
+ glslVersion: THREE.GLSL3,
281
+ vertexShader: fullscreenVert,
282
+ fragmentShader: volumetricFrag,
283
+ uniforms: {
284
+ bvhStatic: { value: null },
285
+ bvhDynamic: { value: null },
286
+ uHasDynamic: { value: false },
287
+ uMaterialsTex: { value: null },
288
+ uGWorldPos: { value: null },
289
+ uPrevAccum: { value: null },
290
+ uPrevViewProj: { value: new THREE.Matrix4() },
291
+ uMaxHistory: { value: 48 },
292
+ uLightPosType: { value: [] },
293
+ uLightColorRadius: { value: [] },
294
+ uLightDirCone: { value: [] },
295
+ uLightCount: { value: 0 },
296
+ uEmissiveCount: { value: 0 },
297
+ uCameraPos: { value: new THREE.Vector3() },
298
+ uFrame: { value: 0 },
299
+ uEps: { value: 1e-3 },
300
+ uDensity: { value: 0.03 },
301
+ uMaxDist: { value: 40 },
302
+ // Localized fog zones: flat vec4 array, two vec4 per zone (see shader).
303
+ uFogZones: { value: new Array(MAX_FOG_ZONES * 2).fill(0).map(() => new THREE.Vector4()) },
304
+ uFogZoneCount: { value: 0 },
305
+ },
306
+ depthTest: false,
307
+ depthWrite: false,
308
+ });
309
+
310
+ // Reused per-frame scratch so we don't allocate the zone vectors each call.
311
+ this._zoneVecs = this.material.uniforms.uFogZones.value;
312
+
313
+ this.scene = new THREE.Scene();
314
+ this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
315
+ this.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.material);
316
+ this.quad.frustumCulled = false;
317
+ this.scene.add(this.quad);
318
+ }
319
+
320
+ _makeTarget(width, height) {
321
+ const t = new THREE.WebGLRenderTarget(width, height, {
322
+ minFilter: THREE.LinearFilter,
323
+ magFilter: THREE.LinearFilter,
324
+ format: THREE.RGBAFormat,
325
+ type: THREE.HalfFloatType,
326
+ depthBuffer: false,
327
+ stencilBuffer: false,
328
+ });
329
+ t.texture.generateMipmaps = false;
330
+ return t;
331
+ }
332
+
333
+ setCompiledScene(compiled) {
334
+ const u = this.material.uniforms;
335
+ u.bvhStatic.value = compiled.staticBvhUniform;
336
+ u.bvhDynamic.value = compiled.dynamicBvhUniform;
337
+ u.uHasDynamic.value = compiled.hasDynamic;
338
+ u.uMaterialsTex.value = compiled.materialsTex;
339
+ u.uLightPosType.value = compiled.lightPosType;
340
+ u.uLightColorRadius.value = compiled.lightColorRadius;
341
+ u.uLightDirCone.value = compiled.lightDirCone;
342
+ u.uLightCount.value = compiled.lightCount;
343
+ u.uEmissiveCount.value = compiled.emissiveTriCount;
344
+ }
345
+
346
+ clearHistory(renderer) {
347
+ const prev = renderer.getRenderTarget();
348
+ renderer.setClearColor(0x000000, 0);
349
+ for (const t of [this.targetA, this.targetB]) {
350
+ renderer.setRenderTarget(t);
351
+ renderer.clear(true, false, false);
352
+ }
353
+ renderer.setRenderTarget(prev);
354
+ }
355
+
356
+ setSize(width, height) {
357
+ this.targetA.setSize(width, height);
358
+ this.targetB.setSize(width, height);
359
+ }
360
+
361
+ /** Renders into targetA (reading targetB as history), swaps, returns the texture. */
362
+ render(renderer, gbuffer, prevViewProj, cameraPos, frame, eps, density, maxDist, zones) {
363
+ const u = this.material.uniforms;
364
+ u.uGWorldPos.value = gbuffer.worldPos;
365
+ u.uPrevAccum.value = this.targetB.texture;
366
+ u.uPrevViewProj.value.copy(prevViewProj);
367
+ u.uCameraPos.value.copy(cameraPos);
368
+ u.uFrame.value = frame;
369
+ u.uEps.value = eps;
370
+ u.uDensity.value = density;
371
+ u.uMaxDist.value = maxDist;
372
+
373
+ // Pack up to MAX_FOG_ZONES AABBs into the flat vec4 array, two vec4 per
374
+ // zone: [min.xyz, density] then [max.xyz, 0]. Reuses cached Vector4s.
375
+ const zn = zones && zones.length ? Math.min(zones.length, MAX_FOG_ZONES) : 0;
376
+ for (let i = 0; i < zn; i++) {
377
+ const z = zones[i];
378
+ this._zoneVecs[i * 2].set(z.min[0], z.min[1], z.min[2], z.density);
379
+ this._zoneVecs[i * 2 + 1].set(z.max[0], z.max[1], z.max[2], 0);
380
+ }
381
+ u.uFogZoneCount.value = zn;
382
+
383
+ renderer.setRenderTarget(this.targetA);
384
+ renderer.render(this.scene, this.camera);
385
+ renderer.setRenderTarget(null);
386
+
387
+ const out = this.targetA;
388
+ [this.targetA, this.targetB] = [this.targetB, this.targetA];
389
+ return out.texture;
390
+ }
391
+
392
+ dispose() {
393
+ this.targetA.dispose();
394
+ this.targetB.dispose();
395
+ this.material.dispose();
396
+ this.quad.geometry.dispose();
397
+ }
398
+ }