three-realtime-rt 0.3.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.
@@ -0,0 +1,424 @@
1
+ import * as THREE from "three";
2
+ import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
3
+ import {
4
+ MeshBVH,
5
+ MeshBVHUniformStruct,
6
+ FloatVertexAttributeTexture,
7
+ SAH,
8
+ CENTER,
9
+ } from "three-mesh-bvh";
10
+ import { decodeBlueNoise, BLUE_NOISE_SIZE } from "./blueNoise.js";
11
+
12
+ const MAX_LIGHTS = 16; // stage-1 cap; a data-texture light list is future work
13
+
14
+ // Emissive-mesh triangles sampled by next-event estimation. Beyond the cap the
15
+ // largest-area triangles win (they carry the most light); the rest are dropped
16
+ // from the light list with a warning.
17
+ const MAX_EMISSIVE_TRIS = 256;
18
+
19
+ /**
20
+ * A two-level BVH scene. Static geometry lives in one BVH uploaded to the GPU
21
+ * ONCE; dynamic (moving) meshes live in a second, small BVH that is re-baked and
22
+ * re-uploaded every frame. The lighting shader traces both and takes the nearest
23
+ * hit, so the per-frame cost scales with the *moving* triangle count only — not
24
+ * the size of the static world. (This is the TLAS/BLAS idea, minus per-instance
25
+ * transforms: one merged dynamic mesh is plenty for typical rigid-body scenes.)
26
+ */
27
+ export class CompiledScene {
28
+ constructor() {
29
+ // Static level (built once). Per level, one packed RGBA attribute texture
30
+ // (normal.xyz + materialIndex in .w): two BVH structs already cost 8 of the
31
+ // 16 guaranteed fragment samplers, so attributes must stay lean.
32
+ this.staticBvh = null;
33
+ this.staticBvhUniform = new MeshBVHUniformStruct();
34
+ this.staticAttrTex = new FloatVertexAttributeTexture();
35
+
36
+ // Dynamic level (re-baked/refit each frame).
37
+ this.dynamicBvh = null;
38
+ this.dynamicBvhUniform = new MeshBVHUniformStruct();
39
+ this.dynamicAttrTex = new FloatVertexAttributeTexture();
40
+ this.dynamicMerged = null;
41
+ this.dynamicPacked = null; // Float32Array + BufferAttribute for re-baking
42
+ this.dynamicPackedAttr = null;
43
+ this.dynamic = []; // [{ mesh, start, count, localPos, localNorm }]
44
+ this.hasDynamic = false;
45
+
46
+ this.materialsTex = null;
47
+ this.materials = [];
48
+ this.lightPosType = [];
49
+ this.lightColorRadius = [];
50
+ this.lightCount = 0;
51
+ this.emissiveTriCount = 0;
52
+ this.triangleCount = 0;
53
+
54
+ this._m3 = new THREE.Matrix3();
55
+ this._normalFrame = 0;
56
+ this._dynBuildVolume = null; // world-volume of the dynamic set at build time
57
+ }
58
+
59
+ /**
60
+ * Re-bake the dynamic meshes' current world transforms into the dynamic
61
+ * geometry, refit the dynamic BVH, and re-upload ONLY the (small) dynamic
62
+ * textures. The static BVH is never touched. Call once per frame after moving
63
+ * the meshes.
64
+ */
65
+ updateDynamic() {
66
+ if (!this.hasDynamic || this.dynamic.length === 0) return;
67
+ const posAttr = this.dynamicMerged.getAttribute("position");
68
+ const pos = posAttr.array;
69
+ const packed = this.dynamicPacked; // normal.xyz + matIndex.w per vertex
70
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
71
+ let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
72
+
73
+ for (const seg of this.dynamic) {
74
+ seg.mesh.updateWorldMatrix(true, false);
75
+ const m = seg.mesh.matrixWorld.elements;
76
+ const nm = this._m3.getNormalMatrix(seg.mesh.matrixWorld).elements;
77
+ const lp = seg.localPos;
78
+ const ln = seg.localNorm;
79
+ let o = seg.start * 3;
80
+ let p = seg.start * 4;
81
+ for (let i = 0; i < seg.count; i++) {
82
+ const x = lp[i * 3], y = lp[i * 3 + 1], z = lp[i * 3 + 2];
83
+ const wx = m[0] * x + m[4] * y + m[8] * z + m[12];
84
+ const wy = m[1] * x + m[5] * y + m[9] * z + m[13];
85
+ const wz = m[2] * x + m[6] * y + m[10] * z + m[14];
86
+ pos[o] = wx;
87
+ pos[o + 1] = wy;
88
+ pos[o + 2] = wz;
89
+ if (wx < minX) minX = wx; if (wx > maxX) maxX = wx;
90
+ if (wy < minY) minY = wy; if (wy > maxY) maxY = wy;
91
+ if (wz < minZ) minZ = wz; if (wz > maxZ) maxZ = wz;
92
+ const nx = ln[i * 3], ny = ln[i * 3 + 1], nz = ln[i * 3 + 2];
93
+ const tx = nm[0] * nx + nm[3] * ny + nm[6] * nz;
94
+ const ty = nm[1] * nx + nm[4] * ny + nm[7] * nz;
95
+ const tz = nm[2] * nx + nm[5] * ny + nm[8] * nz;
96
+ const il = 1.0 / (Math.hypot(tx, ty, tz) || 1);
97
+ packed[p] = tx * il;
98
+ packed[p + 1] = ty * il;
99
+ packed[p + 2] = tz * il;
100
+ // packed[p + 3] (matIndex) never changes
101
+ o += 3;
102
+ p += 4;
103
+ }
104
+ }
105
+ posAttr.needsUpdate = true;
106
+
107
+ // refit() keeps the tree TOPOLOGY from build time. While the props sit in
108
+ // a pile that's fine — but once they scatter (an explosion), triangles
109
+ // that are tree-neighbors end up across the room, refitted nodes balloon
110
+ // into huge overlapping boxes, and every ray wades through them (observed:
111
+ // 30 fps → 10 on mobile). When the set has spread well past its
112
+ // build-time volume, rebuild the tree from scratch instead — a few ms,
113
+ // paid only on large redistributions.
114
+ const vol =
115
+ Math.max(maxX - minX, 1e-6) *
116
+ Math.max(maxY - minY, 1e-6) *
117
+ Math.max(maxZ - minZ, 1e-6);
118
+ if (this._dynBuildVolume == null) this._dynBuildVolume = vol;
119
+ if (vol > this._dynBuildVolume * 3 || vol < this._dynBuildVolume / 3) {
120
+ this.dynamicBvh = new MeshBVH(this.dynamicMerged, { strategy: CENTER });
121
+ this._dynBuildVolume = vol;
122
+ } else {
123
+ this.dynamicBvh.refit();
124
+ }
125
+ this.dynamicBvhUniform.updateFrom(this.dynamicBvh);
126
+ // Normals only feed GI-bounce shading off movers — amortize their upload.
127
+ if (this._normalFrame++ % 8 === 0) {
128
+ this.dynamicAttrTex.updateFrom(this.dynamicPackedAttr);
129
+ }
130
+ }
131
+
132
+ dispose() {
133
+ this.staticBvhUniform.dispose();
134
+ this.staticAttrTex.dispose();
135
+ this.dynamicBvhUniform.dispose();
136
+ this.dynamicAttrTex.dispose();
137
+ if (this.materialsTex) this.materialsTex.dispose();
138
+ if (this.staticBvh) this.staticBvh.geometry.dispose();
139
+ if (this.dynamicMerged) this.dynamicMerged.dispose();
140
+ }
141
+ }
142
+
143
+ function extractMeshGeometry(mesh, materialIndex) {
144
+ const src = mesh.geometry.index
145
+ ? mesh.geometry.toNonIndexed()
146
+ : mesh.geometry.clone();
147
+
148
+ if (!src.getAttribute("normal")) src.computeVertexNormals();
149
+ const localPos = src.getAttribute("position").array.slice();
150
+ const localNorm = src.getAttribute("normal").array.slice();
151
+
152
+ const geo = new THREE.BufferGeometry();
153
+ geo.setAttribute("position", src.getAttribute("position").clone());
154
+ geo.setAttribute("normal", src.getAttribute("normal").clone());
155
+ geo.applyMatrix4(mesh.matrixWorld); // bake world transform
156
+
157
+ const count = geo.getAttribute("position").count;
158
+ const mi = new Float32Array(count).fill(materialIndex);
159
+ geo.setAttribute("materialIndex", new THREE.BufferAttribute(mi, 1));
160
+ return { geo, localPos, localNorm, count };
161
+ }
162
+
163
+ // Effective emissive colour (already scaled by intensity), or null if the
164
+ // material doesn't emit. Matches the shading table's emissiveMap exclusion.
165
+ function emissiveColor(mat) {
166
+ if (mat.emissiveMap != null || !mat.emissive) return null;
167
+ const i = mat.emissiveIntensity ?? 1;
168
+ if (i <= 0 || mat.emissive.r + mat.emissive.g + mat.emissive.b <= 0) return null;
169
+ return [mat.emissive.r * i, mat.emissive.g * i, mat.emissive.b * i];
170
+ }
171
+
172
+ // Row 0: materials, 2 texels each (albedo+rough, emissive+metal).
173
+ // Row 1: emissive triangles for NEE, 4 texels each:
174
+ // [v0.xyz | area] [e1.xyz | emit.r] [e2.xyz | emit.g] [n.xyz | emit.b]
175
+ // Rows 2..65: a 64x64 RGBA blue-noise tile for low-discrepancy sampling.
176
+ // All packed into ONE texture because the lighting pass already sits at the
177
+ // WebGL2-guaranteed 16-sampler limit — extra samplers are not available.
178
+ function buildSceneDataTexture(materials, emissiveTris) {
179
+ const bn = decodeBlueNoise();
180
+ const width = Math.max(materials.length * 2, emissiveTris.length * 4, BLUE_NOISE_SIZE);
181
+ const height = 2 + BLUE_NOISE_SIZE;
182
+ const data = new Float32Array(width * height * 4);
183
+ materials.forEach((mat, i) => {
184
+ const o = i * 8;
185
+ const color = mat.color ?? new THREE.Color(1, 1, 1);
186
+ const emissive = emissiveColor(mat) ?? [0, 0, 0];
187
+ data[o + 0] = color.r;
188
+ data[o + 1] = color.g;
189
+ data[o + 2] = color.b;
190
+ data[o + 3] = mat.roughness ?? 1;
191
+ data[o + 4] = emissive[0];
192
+ data[o + 5] = emissive[1];
193
+ data[o + 6] = emissive[2];
194
+ data[o + 7] = mat.metalness ?? 0;
195
+ });
196
+ const row = width * 4;
197
+ emissiveTris.forEach((t, i) => {
198
+ const o = row + i * 16;
199
+ data[o + 0] = t.v0[0]; data[o + 1] = t.v0[1]; data[o + 2] = t.v0[2]; data[o + 3] = t.area;
200
+ data[o + 4] = t.e1[0]; data[o + 5] = t.e1[1]; data[o + 6] = t.e1[2]; data[o + 7] = t.emit[0];
201
+ data[o + 8] = t.e2[0]; data[o + 9] = t.e2[1]; data[o + 10] = t.e2[2]; data[o + 11] = t.emit[1];
202
+ data[o + 12] = t.n[0]; data[o + 13] = t.n[1]; data[o + 14] = t.n[2]; data[o + 15] = t.emit[2];
203
+ });
204
+ for (let y = 0; y < BLUE_NOISE_SIZE; y++) {
205
+ const o = (2 + y) * row;
206
+ const src = y * BLUE_NOISE_SIZE * 4;
207
+ for (let i = 0; i < BLUE_NOISE_SIZE * 4; i++) {
208
+ data[o + i] = (bn[src + i] + 0.5) / 256.0;
209
+ }
210
+ }
211
+ const tex = new THREE.DataTexture(data, width, height, THREE.RGBAFormat, THREE.FloatType);
212
+ tex.minFilter = THREE.NearestFilter;
213
+ tex.magFilter = THREE.NearestFilter;
214
+ tex.needsUpdate = true;
215
+ return tex;
216
+ }
217
+
218
+ // Collect world-space triangles of an emissive mesh for the NEE light list.
219
+ // `geo` is already non-indexed and world-baked by extractMeshGeometry.
220
+ function collectEmissiveTriangles(geo, emit, out) {
221
+ const pos = geo.getAttribute("position").array;
222
+ for (let i = 0; i + 8 < pos.length; i += 9) {
223
+ const e1 = [pos[i + 3] - pos[i], pos[i + 4] - pos[i + 1], pos[i + 5] - pos[i + 2]];
224
+ const e2 = [pos[i + 6] - pos[i], pos[i + 7] - pos[i + 1], pos[i + 8] - pos[i + 2]];
225
+ const cx = e1[1] * e2[2] - e1[2] * e2[1];
226
+ const cy = e1[2] * e2[0] - e1[0] * e2[2];
227
+ const cz = e1[0] * e2[1] - e1[1] * e2[0];
228
+ const len = Math.hypot(cx, cy, cz);
229
+ if (len < 1e-10) continue; // degenerate
230
+ out.push({
231
+ v0: [pos[i], pos[i + 1], pos[i + 2]],
232
+ e1,
233
+ e2,
234
+ n: [cx / len, cy / len, cz / len],
235
+ area: len * 0.5,
236
+ emit,
237
+ });
238
+ }
239
+ }
240
+
241
+ // A single degenerate triangle so the dynamic BVH textures are always valid even
242
+ // when there are no dynamic meshes (tracing is gated by a uHasDynamic flag).
243
+ function degenerateGeometry() {
244
+ const geo = new THREE.BufferGeometry();
245
+ geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(9), 3));
246
+ geo.setAttribute("normal", new THREE.BufferAttribute(new Float32Array([0, 1, 0, 0, 1, 0, 0, 1, 0]), 3));
247
+ geo.setAttribute("materialIndex", new THREE.BufferAttribute(new Float32Array(3), 1));
248
+ return geo;
249
+ }
250
+
251
+ // Pack normal.xyz + materialIndex.w into one itemSize-4 attribute so each BVH
252
+ // level costs a single sampler for its per-vertex data.
253
+ function packAttributes(merged) {
254
+ const norm = merged.getAttribute("normal");
255
+ const matIdx = merged.getAttribute("materialIndex");
256
+ const count = norm.count;
257
+ const packed = new Float32Array(count * 4);
258
+ for (let i = 0; i < count; i++) {
259
+ packed[i * 4] = norm.getX(i);
260
+ packed[i * 4 + 1] = norm.getY(i);
261
+ packed[i * 4 + 2] = norm.getZ(i);
262
+ packed[i * 4 + 3] = matIdx.getX(i);
263
+ }
264
+ return { packed, attr: new THREE.BufferAttribute(packed, 4) };
265
+ }
266
+
267
+ // Build one BVH level (merge geometries, upload BVH + attribute textures).
268
+ function buildLevel(geometries, { dynamic }) {
269
+ const merged =
270
+ geometries.length > 0 ? mergeGeometries(geometries, false) : degenerateGeometry();
271
+ const bvh = new MeshBVH(merged, { strategy: dynamic ? CENTER : SAH });
272
+ return { merged, bvh, ...packAttributes(merged) };
273
+ }
274
+
275
+ export function compileScene(scene, options = {}) {
276
+ scene.updateMatrixWorld(true);
277
+ const dynamicSet = options.dynamicMeshes ? new Set(options.dynamicMeshes) : null;
278
+
279
+ const compiled = new CompiledScene();
280
+ const materials = compiled.materials;
281
+ const staticGeoms = [];
282
+ const dynamicGeoms = [];
283
+ const emissiveTris = [];
284
+ let dynVertexOffset = 0;
285
+ const tmpGeoms = []; // to dispose after merge
286
+
287
+ scene.traverse((obj) => {
288
+ if (!obj.isMesh || !obj.geometry || !obj.visible || obj.userData.rtExclude) return;
289
+ const mat = Array.isArray(obj.material) ? obj.material[0] : obj.material;
290
+ // Transparent surfaces must not act as opaque occluders — e.g.
291
+ // LittlestTokyo's glass display case (texture-alpha, opacity 1) would put
292
+ // the whole model in shadow. Alpha-textured glass can't be cheaply tested,
293
+ // so ANY transparent material is skipped like rtExclude (still
294
+ // rasterized). alphaTest cut-outs (transparent: false) stay occluders.
295
+ if (mat.transparent) return;
296
+ let mi = materials.indexOf(mat);
297
+ if (mi < 0) { mi = materials.length; materials.push(mat); }
298
+
299
+ const extracted = extractMeshGeometry(obj, mi);
300
+ tmpGeoms.push(extracted.geo);
301
+ // Static emissive meshes become NEE area lights (sampled directly with
302
+ // shadow rays instead of waiting for a GI ray to stumble into them).
303
+ // Dynamic emitters are left out — their world-space triangles would go
304
+ // stale — so they keep lighting the old way, via GI-ray hits.
305
+ const isDynamic = dynamicSet && dynamicSet.has(obj);
306
+ if (!isDynamic) {
307
+ const emit = emissiveColor(mat);
308
+ if (emit) collectEmissiveTriangles(extracted.geo, emit, emissiveTris);
309
+ }
310
+ if (isDynamic) {
311
+ dynamicGeoms.push(extracted.geo);
312
+ compiled.dynamic.push({
313
+ mesh: obj,
314
+ start: dynVertexOffset,
315
+ count: extracted.count,
316
+ localPos: extracted.localPos,
317
+ localNorm: extracted.localNorm,
318
+ });
319
+ dynVertexOffset += extracted.count;
320
+ } else {
321
+ staticGeoms.push(extracted.geo);
322
+ }
323
+ });
324
+
325
+ if (staticGeoms.length === 0 && dynamicGeoms.length === 0) {
326
+ throw new Error("three-realtime-rt: no meshes found in scene");
327
+ }
328
+
329
+ // Static level.
330
+ const s = buildLevel(staticGeoms, { dynamic: false });
331
+ compiled.staticBvh = s.bvh;
332
+ compiled.staticBvhUniform.updateFrom(s.bvh);
333
+ compiled.staticAttrTex.updateFrom(s.attr);
334
+
335
+ // Dynamic level.
336
+ compiled.hasDynamic = dynamicGeoms.length > 0;
337
+ const d = buildLevel(dynamicGeoms, { dynamic: true });
338
+ compiled.dynamicMerged = d.merged;
339
+ compiled.dynamicBvh = d.bvh;
340
+ compiled.dynamicBvhUniform.updateFrom(d.bvh);
341
+ compiled.dynamicPacked = d.packed;
342
+ compiled.dynamicPackedAttr = d.attr;
343
+ compiled.dynamicAttrTex.updateFrom(d.attr);
344
+
345
+ compiled.triangleCount =
346
+ (s.merged.getAttribute("position").count +
347
+ (compiled.hasDynamic ? d.merged.getAttribute("position").count : 0)) / 3;
348
+
349
+ // World-space extent of the static level. Used to auto-scale the ray offset
350
+ // epsilon: dense/large scenes (e.g. a detailed diorama normalized to a few
351
+ // units) need a bigger offset or every shadow ray self-intersects nearby
352
+ // micro-geometry and the scene renders black.
353
+ s.merged.computeBoundingBox();
354
+ const bb = s.merged.boundingBox;
355
+ compiled.sceneDiagonal = bb.isEmpty() ? 1 : bb.min.distanceTo(bb.max);
356
+
357
+ if (emissiveTris.length > MAX_EMISSIVE_TRIS) {
358
+ console.warn(
359
+ `three-realtime-rt: ${emissiveTris.length} emissive triangles exceed the ` +
360
+ `NEE cap of ${MAX_EMISSIVE_TRIS}; keeping the largest by area. Dropped ` +
361
+ `triangles no longer act as lights — prefer low-poly emitter meshes.`
362
+ );
363
+ emissiveTris.sort((a, b) => b.area - a.area);
364
+ emissiveTris.length = MAX_EMISSIVE_TRIS;
365
+ }
366
+ compiled.emissiveTriCount = emissiveTris.length;
367
+ compiled.materialsTex = buildSceneDataTexture(materials, emissiveTris);
368
+ syncLights(scene, compiled);
369
+
370
+ // Static merged geometry is owned by its BVH (disposed with it); dynamic
371
+ // merged geometry is kept live for re-baking. Dispose the per-mesh temporaries
372
+ // that aren't the merged buffers.
373
+ for (const g of tmpGeoms) {
374
+ if (g !== s.merged && g !== d.merged) g.dispose();
375
+ }
376
+ return compiled;
377
+ }
378
+
379
+ /** (Re)scan the scene's lights into the compiled light tables. Cheap; call anytime. */
380
+ export function syncLights(scene, compiled) {
381
+ const posType = compiled.lightPosType;
382
+ const colorRadius = compiled.lightColorRadius;
383
+ posType.length = 0;
384
+ colorRadius.length = 0;
385
+ let count = 0;
386
+ const tmpP = new THREE.Vector3();
387
+ const tmpT = new THREE.Vector3();
388
+
389
+ scene.traverse((obj) => {
390
+ if (!obj.isLight || !obj.visible || obj.intensity <= 0) return;
391
+ if (count >= MAX_LIGHTS) return;
392
+ if (obj.isPointLight) {
393
+ obj.getWorldPosition(tmpP);
394
+ posType.push(tmpP.x, tmpP.y, tmpP.z, 0);
395
+ colorRadius.push(
396
+ obj.color.r * obj.intensity,
397
+ obj.color.g * obj.intensity,
398
+ obj.color.b * obj.intensity,
399
+ obj.userData.rtRadius ?? 0.15
400
+ );
401
+ count++;
402
+ } else if (obj.isDirectionalLight) {
403
+ obj.getWorldPosition(tmpP);
404
+ obj.target.getWorldPosition(tmpT);
405
+ const dir = tmpT.sub(tmpP).normalize();
406
+ posType.push(dir.x, dir.y, dir.z, 1);
407
+ colorRadius.push(
408
+ obj.color.r * obj.intensity,
409
+ obj.color.g * obj.intensity,
410
+ obj.color.b * obj.intensity,
411
+ obj.userData.rtRadius ?? 0.02
412
+ );
413
+ count++;
414
+ }
415
+ });
416
+
417
+ compiled.lightCount = count;
418
+ while (posType.length < MAX_LIGHTS * 4) {
419
+ posType.push(0, 0, 0, 0);
420
+ colorRadius.push(0, 0, 0, 0);
421
+ }
422
+ }
423
+
424
+ export { MAX_LIGHTS };
package/src/TAAPass.js ADDED
@@ -0,0 +1,230 @@
1
+ import * as THREE from "three";
2
+
3
+ const fullscreenVert = /* glsl */ `
4
+ out vec2 vUv;
5
+ void main() {
6
+ vUv = uv;
7
+ gl_Position = vec4(position.xy, 0.0, 1.0);
8
+ }
9
+ `;
10
+
11
+ // Temporal anti-aliasing resolve. Runs at full resolution on the final tonemapped
12
+ // color. Reprojects the previous resolved frame through world position (same trick
13
+ // the lighting/denoise passes use — no separate motion-vector buffer needed),
14
+ // clamps that history to the current 3x3 neighborhood colour box (kills ghosting
15
+ // AND the bright disocclusion speckles), then blends. Combined with sub-pixel
16
+ // camera jitter in RealtimeRaytracer, silhouettes get supersampled over time —
17
+ // this is the analytic (FSR2 / TAAU) approach, not a learned upscaler.
18
+ const taaFrag = /* glsl */ `
19
+ precision highp float;
20
+
21
+ layout(location = 0) out vec4 outColor;
22
+
23
+ in vec2 vUv;
24
+
25
+ uniform sampler2D uCurrent; // this frame's composited LDR colour
26
+ uniform sampler2D uHistory; // previous resolved colour
27
+ uniform sampler2D uGWorldPos; // current full-res G-buffer
28
+ uniform mat4 uPrevViewProj;
29
+ uniform vec2 uTexelSize;
30
+ uniform vec2 uJitter; // this frame's projection jitter (UV space)
31
+ uniform vec2 uPrevJitter; // last frame's projection jitter (UV space)
32
+ uniform float uBlend; // fresh-sample weight when history is valid (~0.1)
33
+ uniform bool uReset; // first frame after a scene/size change
34
+
35
+ // The raster (and the whole lighting chain guided by it) wobbles with the
36
+ // sub-pixel jitter. Resolving on that wobbling grid drags history along with
37
+ // it — the image shimmers. So the resolve UNJITTERS its input: content that
38
+ // unjittered would sit at u is rasterized at u + jitter, hence sample there.
39
+ // Output then lives on a stable grid and jitter only contributes sub-pixel
40
+ // coverage information over time (which is what gives the anti-aliasing).
41
+ vec3 sampleCurrent(vec2 uv) {
42
+ return texture(uCurrent, uv + uJitter).rgb;
43
+ }
44
+
45
+ void main() {
46
+ vec3 current = sampleCurrent(vUv);
47
+ // World position of the (unjittered) content at this pixel — same offset.
48
+ vec4 wp = texture(uGWorldPos, vUv + uJitter);
49
+
50
+ // Background (no geometry): no useful reprojection, show current directly.
51
+ if (wp.w < 0.5 || uReset) {
52
+ outColor = vec4(current, 1.0);
53
+ return;
54
+ }
55
+
56
+ vec3 P = wp.xyz;
57
+
58
+ // Neighbourhood colour AABB (used to clamp history — the core anti-ghost /
59
+ // anti-speckle step). Also the min corner tells us the local floor, so a
60
+ // single bright fireflight can't survive the clamp.
61
+ vec3 nmin = current, nmax = current;
62
+ for (int dy = -1; dy <= 1; dy++) {
63
+ for (int dx = -1; dx <= 1; dx++) {
64
+ if (dx == 0 && dy == 0) continue;
65
+ vec3 c = sampleCurrent(vUv + vec2(float(dx), float(dy)) * uTexelSize);
66
+ nmin = min(nmin, c);
67
+ nmax = max(nmax, c);
68
+ }
69
+ }
70
+
71
+ // Reproject this pixel's world point into the previous frame. The history is
72
+ // a STABILIZED (unjittered-grid) image, so remove last frame's jitter from
73
+ // the projected position before sampling it.
74
+ vec4 clip = uPrevViewProj * vec4(P, 1.0);
75
+ if (clip.w <= 0.0) { outColor = vec4(current, 1.0); return; }
76
+ vec2 prevUv = (clip.xy / clip.w) * 0.5 + 0.5 - uPrevJitter;
77
+ if (prevUv.x < 0.0 || prevUv.x > 1.0 || prevUv.y < 0.0 || prevUv.y > 1.0) {
78
+ outColor = vec4(current, 1.0);
79
+ return;
80
+ }
81
+
82
+ // NOTE: no geometric (depth/normal) history validation here, on purpose.
83
+ // Under sub-pixel jitter such tests fail on every hard edge each frame,
84
+ // dropping those pixels to the raw jittered current — the whole image
85
+ // shimmers. The neighbourhood clamp below already bounds any stale history
86
+ // (disocclusions resolve within a frame or two), which is exactly how
87
+ // production TAA implementations handle rejection.
88
+ vec3 history = texture(uHistory, prevUv).rgb;
89
+ // Guard against a stray non-finite history value poisoning the buffer (it
90
+ // would otherwise re-blend with itself every frame and stick as black).
91
+ if (any(isnan(history)) || any(isinf(history))) {
92
+ outColor = vec4(current, 1.0);
93
+ return;
94
+ }
95
+ // Clamp history into the current neighbourhood box: removes ghosting on
96
+ // motion and rejects bright edge speckles that history would otherwise keep.
97
+ history = clamp(history, nmin, nmax);
98
+
99
+ vec3 resolved = mix(history, current, uBlend);
100
+ outColor = vec4(resolved, 1.0);
101
+ }
102
+ `;
103
+
104
+ const copyFrag = /* glsl */ `
105
+ precision highp float;
106
+ layout(location = 0) out vec4 outColor;
107
+ in vec2 vUv;
108
+ uniform sampler2D uTex;
109
+ void main() { outColor = vec4(texture(uTex, vUv).rgb, 1.0); }
110
+ `;
111
+
112
+ /**
113
+ * Full-res temporal anti-aliasing resolve with a ping-pong history buffer.
114
+ * `render()` resolves current+history into the write buffer, copies that to the
115
+ * screen, and swaps. Pair with sub-pixel projection jitter for supersampled AA.
116
+ */
117
+ export class TAAPass {
118
+ constructor(width, height) {
119
+ this._width = width;
120
+ this._height = height;
121
+ this.targetA = this._makeTarget(width, height);
122
+ this.targetB = this._makeTarget(width, height);
123
+ this._reset = true;
124
+
125
+ this.material = new THREE.ShaderMaterial({
126
+ glslVersion: THREE.GLSL3,
127
+ vertexShader: fullscreenVert,
128
+ fragmentShader: taaFrag,
129
+ uniforms: {
130
+ uCurrent: { value: null },
131
+ uHistory: { value: null },
132
+ uGWorldPos: { value: null },
133
+ uPrevViewProj: { value: new THREE.Matrix4() },
134
+ uTexelSize: { value: new THREE.Vector2(1 / width, 1 / height) },
135
+ uJitter: { value: new THREE.Vector2() },
136
+ uPrevJitter: { value: new THREE.Vector2() },
137
+ uBlend: { value: 0.1 },
138
+ uReset: { value: true },
139
+ },
140
+ depthTest: false,
141
+ depthWrite: false,
142
+ });
143
+
144
+ this.copyMaterial = new THREE.ShaderMaterial({
145
+ glslVersion: THREE.GLSL3,
146
+ vertexShader: fullscreenVert,
147
+ fragmentShader: copyFrag,
148
+ uniforms: { uTex: { value: null } },
149
+ depthTest: false,
150
+ depthWrite: false,
151
+ });
152
+
153
+ this.scene = new THREE.Scene();
154
+ this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
155
+ this.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.material);
156
+ this.quad.frustumCulled = false;
157
+ this.scene.add(this.quad);
158
+ }
159
+
160
+ _makeTarget(width, height) {
161
+ const t = new THREE.WebGLRenderTarget(width, height, {
162
+ minFilter: THREE.LinearFilter,
163
+ magFilter: THREE.LinearFilter,
164
+ format: THREE.RGBAFormat,
165
+ type: THREE.HalfFloatType,
166
+ depthBuffer: false,
167
+ stencilBuffer: false,
168
+ });
169
+ t.texture.generateMipmaps = false;
170
+ return t;
171
+ }
172
+
173
+ setSize(width, height) {
174
+ this._width = width;
175
+ this._height = height;
176
+ this.targetA.setSize(width, height);
177
+ this.targetB.setSize(width, height);
178
+ this.material.uniforms.uTexelSize.value.set(1 / width, 1 / height);
179
+ this._reset = true;
180
+ }
181
+
182
+ /** Force the next frame to show the current sample with no history (scene changed). */
183
+ reset() {
184
+ this._reset = true;
185
+ }
186
+
187
+ /**
188
+ * @param currentColor tonemapped LDR colour texture for this frame
189
+ * @param gbuffer current GBufferPass (provides the world-pos guide)
190
+ * @param prevViewProj the *jittered* view-projection used last frame
191
+ * @param jitterUv this frame's projection jitter in UV space
192
+ * @param prevJitterUv last frame's projection jitter in UV space
193
+ * @param outputTarget where the resolved frame goes (null = screen); lets a
194
+ * post pass (god rays) run after the resolve
195
+ */
196
+ render(renderer, currentColor, gbuffer, prevViewProj, jitterUv, prevJitterUv, blend, outputTarget = null) {
197
+ const u = this.material.uniforms;
198
+ u.uCurrent.value = currentColor;
199
+ u.uHistory.value = this.targetB.texture; // previous resolved
200
+ u.uGWorldPos.value = gbuffer.worldPos;
201
+ u.uPrevViewProj.value.copy(prevViewProj);
202
+ u.uJitter.value.copy(jitterUv);
203
+ u.uPrevJitter.value.copy(prevJitterUv);
204
+ u.uBlend.value = blend;
205
+ u.uReset.value = this._reset;
206
+
207
+ // Resolve into targetA.
208
+ this.quad.material = this.material;
209
+ renderer.setRenderTarget(this.targetA);
210
+ renderer.render(this.scene, this.camera);
211
+
212
+ // Copy the resolved frame out (screen, or a post-pass input target).
213
+ this.quad.material = this.copyMaterial;
214
+ this.copyMaterial.uniforms.uTex.value = this.targetA.texture;
215
+ renderer.setRenderTarget(outputTarget);
216
+ renderer.render(this.scene, this.camera);
217
+
218
+ // Swap: this frame's resolve becomes next frame's history.
219
+ [this.targetA, this.targetB] = [this.targetB, this.targetA];
220
+ this._reset = false;
221
+ }
222
+
223
+ dispose() {
224
+ this.targetA.dispose();
225
+ this.targetB.dispose();
226
+ this.material.dispose();
227
+ this.copyMaterial.dispose();
228
+ this.quad.geometry.dispose();
229
+ }
230
+ }