three-realtime-rt 0.3.0 → 0.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.
@@ -1,228 +1,290 @@
1
- import * as THREE from "three";
2
-
3
- const gbufferVert = /* glsl */ `
4
- out vec3 vWorldPos;
5
- out vec3 vWorldNormal;
6
- out vec2 vUvCoord;
7
-
8
- uniform mat3 uNormalMatrixWorld;
9
-
10
- void main() {
11
- vec4 wp = modelMatrix * vec4(position, 1.0);
12
- vWorldPos = wp.xyz;
13
- vWorldNormal = normalize(uNormalMatrixWorld * normal);
14
- vUvCoord = uv;
15
- gl_Position = projectionMatrix * viewMatrix * wp;
16
- }
17
- `;
18
-
19
- const gbufferFrag = /* glsl */ `
20
- precision highp float;
21
-
22
- layout(location = 0) out vec4 gAlbedoRough;
23
- layout(location = 1) out vec4 gNormalMetal;
24
- layout(location = 2) out vec4 gWorldPos;
25
- layout(location = 3) out vec4 gEmissive;
26
-
27
- in vec3 vWorldPos;
28
- in vec3 vWorldNormal;
29
- in vec2 vUvCoord;
30
-
31
- uniform vec3 uColor;
32
- uniform float uRoughness;
33
- uniform float uMetalness;
34
- uniform float uTransmission;
35
- uniform vec3 uEmissive;
36
- uniform sampler2D uMap;
37
- uniform bool uHasMap;
38
- uniform sampler2D uEmissiveMap;
39
- uniform bool uHasEmissiveMap;
40
-
41
- void main() {
42
- vec3 albedo = uColor;
43
- if (uHasMap) {
44
- albedo *= texture(uMap, vUvCoord).rgb;
45
- }
46
- vec3 emissive = uEmissive;
47
- if (uHasEmissiveMap) {
48
- emissive *= texture(uEmissiveMap, vUvCoord).rgb;
49
- }
50
- vec3 n = normalize(vWorldNormal) * (gl_FrontFacing ? 1.0 : -1.0);
51
- gAlbedoRough = vec4(albedo, uRoughness);
52
- // .w is a packed material word: >= 2 means transmissive glass (w - 2 =
53
- // transmission amount), else it is plain metalness. Lets the lighting pass
54
- // read specular/glass properties without an extra G-buffer sampler (it
55
- // already sits at the WebGL2 16-sampler minimum).
56
- gNormalMetal = vec4(n, uTransmission > 0.0 ? 2.0 + uTransmission : uMetalness);
57
- // .w packs the valid flag AND roughness: 0 = background, 1 + roughness
58
- // otherwise. Every consumer only tests w < 0.5, so this stays compatible.
59
- gWorldPos = vec4(vWorldPos, 1.0 + uRoughness);
60
- gEmissive = vec4(emissive, 1.0);
61
- }
62
- `;
63
-
64
- /**
65
- * Rasterizes the scene into a 4-target G-buffer (all RGBA32F):
66
- * [0] albedo.rgb + roughness [1] worldNormal.xyz + metalness
67
- * [2] worldPos.xyz + validFlag [3] emissive.rgb
68
- *
69
- * Uses a per-mesh material swap (cached) so each mesh's Standard/Basic/Lambert/Phong
70
- * material properties flow into the buffer without touching user materials.
71
- */
72
- export class GBufferPass {
73
- constructor(width, height, { mixedPrecision = true } = {}) {
74
- // Mixed fp16/fp32 attachments are legal WebGL2 but some implementations
75
- // (notably Apple's Metal backend) may reject the framebuffer — the caller
76
- // probes support and passes the verdict here.
77
- this._mixedPrecision = mixedPrecision;
78
- // Two G-buffers, ping-ponged each frame: the previous frame's worldPos +
79
- // normals are needed to validate reprojected history (stage 2).
80
- this._targets = [
81
- this._makeTarget(width, height),
82
- this._makeTarget(width, height),
83
- ];
84
- this._current = 0;
85
-
86
- this._materialCache = new WeakMap(); // mesh -> gbuffer ShaderMaterial
87
- this._swapped = []; // [mesh, originalMaterial] pairs during render
88
- this._normalMat3 = new THREE.Matrix3();
89
- }
90
-
91
- _makeTarget(width, height) {
92
- const t = new THREE.WebGLMultipleRenderTargets(width, height, 4, {
93
- minFilter: THREE.NearestFilter,
94
- magFilter: THREE.NearestFilter,
95
- type: THREE.FloatType,
96
- depthBuffer: true,
97
- });
98
- for (const tex of t.texture) tex.generateMipmaps = false;
99
- // Mixed precision: only world position (reprojection + plane-distance
100
- // tests) needs fp32. Albedo/normal/emissive are fine in fp16, which halves
101
- // G-buffer bandwidth on 3 of the 4 targets a large win on mobile GPUs.
102
- if (this._mixedPrecision) {
103
- t.texture[0].type = THREE.HalfFloatType; // albedo + roughness
104
- t.texture[1].type = THREE.HalfFloatType; // normal + packed material word
105
- t.texture[3].type = THREE.HalfFloatType; // emissive
106
- }
107
- return t;
108
- }
109
-
110
- get target() {
111
- return this._targets[this._current];
112
- }
113
- get _prev() {
114
- return this._targets[1 - this._current];
115
- }
116
-
117
- get albedoRough() {
118
- return this.target.texture[0];
119
- }
120
- get normalMetal() {
121
- return this.target.texture[1];
122
- }
123
- get worldPos() {
124
- return this.target.texture[2];
125
- }
126
- get emissive() {
127
- return this.target.texture[3];
128
- }
129
- get prevNormalMetal() {
130
- return this._prev.texture[1];
131
- }
132
- get prevWorldPos() {
133
- return this._prev.texture[2];
134
- }
135
-
136
- setSize(width, height) {
137
- for (const t of this._targets) t.setSize(width, height);
138
- }
139
-
140
- _gbufferMaterialFor(mesh) {
141
- let material = this._materialCache.get(mesh);
142
- if (!material) {
143
- material = new THREE.ShaderMaterial({
144
- glslVersion: THREE.GLSL3,
145
- vertexShader: gbufferVert,
146
- fragmentShader: gbufferFrag,
147
- uniforms: {
148
- uNormalMatrixWorld: { value: new THREE.Matrix3() },
149
- uColor: { value: new THREE.Color(1, 1, 1) },
150
- uRoughness: { value: 1.0 },
151
- uMetalness: { value: 0.0 },
152
- uTransmission: { value: 0.0 },
153
- uEmissive: { value: new THREE.Color(0, 0, 0) },
154
- uMap: { value: null },
155
- uHasMap: { value: false },
156
- uEmissiveMap: { value: null },
157
- uHasEmissiveMap: { value: false },
158
- },
159
- side: THREE.FrontSide,
160
- });
161
- this._materialCache.set(mesh, material);
162
- }
163
-
164
- // Sync properties from the user's material every frame (cheap).
165
- const src = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
166
- const u = material.uniforms;
167
- if (src.color) u.uColor.value.copy(src.color);
168
- u.uRoughness.value = src.roughness ?? 1.0;
169
- u.uMetalness.value = src.metalness ?? 0.0;
170
- u.uTransmission.value = src.transmission ?? 0.0; // MeshPhysicalMaterial
171
-
172
- if (src.emissive) {
173
- u.uEmissive.value
174
- .copy(src.emissive)
175
- .multiplyScalar(src.emissiveIntensity ?? 1);
176
- }
177
- u.uMap.value = src.map ?? null;
178
- u.uHasMap.value = !!src.map;
179
- u.uEmissiveMap.value = src.emissiveMap ?? null;
180
- u.uHasEmissiveMap.value = !!src.emissiveMap;
181
- u.uNormalMatrixWorld.value.getNormalMatrix(mesh.matrixWorld);
182
- material.side = src.side ?? THREE.FrontSide;
183
- return material;
184
- }
185
-
186
- render(renderer, scene, camera) {
187
- // Ping-pong: what was "current" becomes "previous".
188
- this._current = 1 - this._current;
189
-
190
- // Swap in G-buffer materials.
191
- this._swapped.length = 0;
192
- scene.traverse((obj) => {
193
- if (obj.isMesh && obj.geometry && obj.visible) {
194
- // The G-buffer has no alpha blending — a nearly invisible surface
195
- // (glass display case, ghost meshes) would write itself opaque over
196
- // everything behind it. Hide it from the raster pass instead.
197
- const m = Array.isArray(obj.material) ? obj.material[0] : obj.material;
198
- if (m.transparent && (m.opacity ?? 1) < 0.5) {
199
- obj.visible = false;
200
- this._swapped.push([obj, null]); // restore visibility after render
201
- return;
202
- }
203
- this._swapped.push([obj, obj.material]);
204
- obj.material = this._gbufferMaterialFor(obj);
205
- }
206
- });
207
-
208
- const prevBackground = scene.background;
209
- scene.background = null; // background writes nothing; worldPos.w stays 0
210
-
211
- renderer.setRenderTarget(this.target);
212
- renderer.setClearColor(0x000000, 0);
213
- renderer.clear(true, true, false);
214
- renderer.render(scene, camera);
215
- renderer.setRenderTarget(null);
216
-
217
- scene.background = prevBackground;
218
- for (const [mesh, mat] of this._swapped) {
219
- if (mat === null) mesh.visible = true; // was hidden, not material-swapped
220
- else mesh.material = mat;
221
- }
222
- this._swapped.length = 0;
223
- }
224
-
225
- dispose() {
226
- for (const t of this._targets) t.dispose();
227
- }
228
- }
1
+ import * as THREE from "three";
2
+
3
+ const gbufferVert = /* glsl */ `
4
+ out vec3 vWorldPos;
5
+ out vec3 vWorldNormal;
6
+ out vec2 vUvCoord;
7
+
8
+ uniform mat3 uNormalMatrixWorld;
9
+
10
+ void main() {
11
+ vec4 wp = modelMatrix * vec4(position, 1.0);
12
+ vWorldPos = wp.xyz;
13
+ vWorldNormal = normalize(uNormalMatrixWorld * normal);
14
+ vUvCoord = uv;
15
+ gl_Position = projectionMatrix * viewMatrix * wp;
16
+ }
17
+ `;
18
+
19
+ const gbufferFrag = /* glsl */ `
20
+ precision highp float;
21
+
22
+ layout(location = 0) out vec4 gAlbedoRough;
23
+ layout(location = 1) out vec4 gNormalMetal;
24
+ layout(location = 2) out vec4 gWorldPos;
25
+ layout(location = 3) out vec4 gEmissive;
26
+
27
+ in vec3 vWorldPos;
28
+ in vec3 vWorldNormal;
29
+ in vec2 vUvCoord;
30
+
31
+ uniform vec3 uColor;
32
+ uniform float uRoughness;
33
+ uniform float uMetalness;
34
+ uniform float uTransmission;
35
+ uniform vec3 uEmissive;
36
+ uniform sampler2D uMap;
37
+ uniform bool uHasMap;
38
+ uniform sampler2D uEmissiveMap;
39
+ uniform bool uHasEmissiveMap;
40
+ // PBR texture maps (raster pass has ample sampler headroom, unlike the lighting
41
+ // pass). All guarded by a uHas* flag so a material without a given map writes
42
+ // exactly the same bytes it did before these were added.
43
+ uniform sampler2D uNormalMap;
44
+ uniform bool uHasNormalMap;
45
+ uniform vec2 uNormalScale;
46
+ uniform sampler2D uRoughnessMap;
47
+ uniform bool uHasRoughnessMap;
48
+ uniform sampler2D uMetalnessMap;
49
+ uniform bool uHasMetalnessMap;
50
+ uniform bool uBlend;
51
+ uniform float uOpacity;
52
+
53
+ // Screen-space cotangent frame (Mikkelsen 2010): reconstruct a tangent basis
54
+ // from derivatives of world position and uv, so tangent-space normal maps work
55
+ // without a per-vertex tangent attribute (none is uploaded to the BVH/G-buffer).
56
+ vec3 perturbNormal(vec3 N, vec3 P, vec2 uv, vec3 mapN) {
57
+ vec3 dpdx = dFdx(P);
58
+ vec3 dpdy = dFdy(P);
59
+ vec2 duvdx = dFdx(uv);
60
+ vec2 duvdy = dFdy(uv);
61
+ vec3 t = normalize(dpdx * duvdy.y - dpdy * duvdx.y);
62
+ vec3 b = normalize(cross(N, t));
63
+ mat3 tbn = mat3(t, b, N);
64
+ return normalize(tbn * mapN);
65
+ }
66
+
67
+ void main() {
68
+ vec3 albedo = uColor;
69
+ if (uHasMap) {
70
+ albedo *= texture(uMap, vUvCoord).rgb;
71
+ }
72
+ vec3 emissive = uEmissive;
73
+ if (uHasEmissiveMap) {
74
+ emissive *= texture(uEmissiveMap, vUvCoord).rgb;
75
+ }
76
+ vec3 n = normalize(vWorldNormal) * (gl_FrontFacing ? 1.0 : -1.0);
77
+ if (uHasNormalMap) {
78
+ // Tangent-space normal in [-1,1], scaled by material.normalScale (x,y).
79
+ vec3 mapN = texture(uNormalMap, vUvCoord).xyz * 2.0 - 1.0;
80
+ mapN.xy *= uNormalScale;
81
+ n = perturbNormal(n, vWorldPos, vUvCoord, mapN);
82
+ }
83
+ // three.js convention: green channel of roughnessMap x scalar roughness,
84
+ // blue channel of metalnessMap x scalar metalness (an ORM texture packs both).
85
+ float roughness = uRoughness;
86
+ if (uHasRoughnessMap) roughness *= texture(uRoughnessMap, vUvCoord).g;
87
+ float metalness = uMetalness;
88
+ if (uHasMetalnessMap) metalness *= texture(uMetalnessMap, vUvCoord).b;
89
+ gAlbedoRough = vec4(albedo, roughness);
90
+ // .w is a packed material word in three disjoint ranges, so the lighting pass
91
+ // reads specular/glass/blend properties without an extra G-buffer sampler (it
92
+ // already sits at the WebGL2 16-sampler minimum):
93
+ // [0,1] plain metalness [2,3] transmissive glass (w - 2 = transmission)
94
+ // [4,5] alpha blend (w - 4 = opacity). Blend wins: a transparent surface is
95
+ // kept out of the BVH and composited by the lighting pass, so it must never be
96
+ // read as glass even if the material also carries a transmission value.
97
+ float matWord = uBlend
98
+ ? 4.0 + uOpacity
99
+ : (uTransmission > 0.0 ? 2.0 + uTransmission : metalness);
100
+ gNormalMetal = vec4(n, matWord);
101
+ // .w packs the valid flag AND roughness: 0 = background, 1 + roughness
102
+ // otherwise. Every consumer only tests w < 0.5, so this stays compatible.
103
+ gWorldPos = vec4(vWorldPos, 1.0 + roughness);
104
+ // .a is normally the constant 1.0 (CompositePass reads only .rgb). A blend
105
+ // surface carries its opacity here; the packed word above also encodes it, so
106
+ // the sampler-bound lighting pass reads opacity without a gEmissive fetch.
107
+ gEmissive = vec4(emissive, uBlend ? uOpacity : 1.0);
108
+ }
109
+ `;
110
+
111
+ /**
112
+ * Rasterizes the scene into a 4-target G-buffer (all RGBA32F):
113
+ * [0] albedo.rgb + roughness [1] worldNormal.xyz + metalness
114
+ * [2] worldPos.xyz + validFlag [3] emissive.rgb
115
+ *
116
+ * Uses a per-mesh material swap (cached) so each mesh's Standard/Basic/Lambert/Phong
117
+ * material properties flow into the buffer without touching user materials.
118
+ */
119
+ export class GBufferPass {
120
+ constructor(width, height, { mixedPrecision = true } = {}) {
121
+ // Mixed fp16/fp32 attachments are legal WebGL2 but some implementations
122
+ // (notably Apple's Metal backend) may reject the framebuffer — the caller
123
+ // probes support and passes the verdict here.
124
+ this._mixedPrecision = mixedPrecision;
125
+ // Two G-buffers, ping-ponged each frame: the previous frame's worldPos +
126
+ // normals are needed to validate reprojected history (stage 2).
127
+ this._targets = [
128
+ this._makeTarget(width, height),
129
+ this._makeTarget(width, height),
130
+ ];
131
+ this._current = 0;
132
+
133
+ this._materialCache = new WeakMap(); // mesh -> gbuffer ShaderMaterial
134
+ this._swapped = []; // [mesh, originalMaterial] pairs during render
135
+ this._normalMat3 = new THREE.Matrix3();
136
+ }
137
+
138
+ _makeTarget(width, height) {
139
+ const t = new THREE.WebGLMultipleRenderTargets(width, height, 4, {
140
+ minFilter: THREE.NearestFilter,
141
+ magFilter: THREE.NearestFilter,
142
+ type: THREE.FloatType,
143
+ depthBuffer: true,
144
+ });
145
+ for (const tex of t.texture) tex.generateMipmaps = false;
146
+ // Mixed precision: only world position (reprojection + plane-distance
147
+ // tests) needs fp32. Albedo/normal/emissive are fine in fp16, which halves
148
+ // G-buffer bandwidth on 3 of the 4 targets — a large win on mobile GPUs.
149
+ if (this._mixedPrecision) {
150
+ t.texture[0].type = THREE.HalfFloatType; // albedo + roughness
151
+ t.texture[1].type = THREE.HalfFloatType; // normal + packed material word
152
+ t.texture[3].type = THREE.HalfFloatType; // emissive
153
+ }
154
+ return t;
155
+ }
156
+
157
+ get target() {
158
+ return this._targets[this._current];
159
+ }
160
+ get _prev() {
161
+ return this._targets[1 - this._current];
162
+ }
163
+
164
+ get albedoRough() {
165
+ return this.target.texture[0];
166
+ }
167
+ get normalMetal() {
168
+ return this.target.texture[1];
169
+ }
170
+ get worldPos() {
171
+ return this.target.texture[2];
172
+ }
173
+ get emissive() {
174
+ return this.target.texture[3];
175
+ }
176
+ get prevNormalMetal() {
177
+ return this._prev.texture[1];
178
+ }
179
+ get prevWorldPos() {
180
+ return this._prev.texture[2];
181
+ }
182
+
183
+ setSize(width, height) {
184
+ for (const t of this._targets) t.setSize(width, height);
185
+ }
186
+
187
+ _gbufferMaterialFor(mesh) {
188
+ let material = this._materialCache.get(mesh);
189
+ if (!material) {
190
+ material = new THREE.ShaderMaterial({
191
+ glslVersion: THREE.GLSL3,
192
+ vertexShader: gbufferVert,
193
+ fragmentShader: gbufferFrag,
194
+ uniforms: {
195
+ uNormalMatrixWorld: { value: new THREE.Matrix3() },
196
+ uColor: { value: new THREE.Color(1, 1, 1) },
197
+ uRoughness: { value: 1.0 },
198
+ uMetalness: { value: 0.0 },
199
+ uTransmission: { value: 0.0 },
200
+ uEmissive: { value: new THREE.Color(0, 0, 0) },
201
+ uMap: { value: null },
202
+ uHasMap: { value: false },
203
+ uEmissiveMap: { value: null },
204
+ uHasEmissiveMap: { value: false },
205
+ uNormalMap: { value: null },
206
+ uHasNormalMap: { value: false },
207
+ uNormalScale: { value: new THREE.Vector2(1, 1) },
208
+ uRoughnessMap: { value: null },
209
+ uHasRoughnessMap: { value: false },
210
+ uMetalnessMap: { value: null },
211
+ uHasMetalnessMap: { value: false },
212
+ uBlend: { value: false },
213
+ uOpacity: { value: 1.0 },
214
+ },
215
+ side: THREE.FrontSide,
216
+ });
217
+ this._materialCache.set(mesh, material);
218
+ }
219
+
220
+ // Sync properties from the user's material every frame (cheap).
221
+ const src = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
222
+ const u = material.uniforms;
223
+ if (src.color) u.uColor.value.copy(src.color);
224
+ u.uRoughness.value = src.roughness ?? 1.0;
225
+ u.uMetalness.value = src.metalness ?? 0.0;
226
+ u.uTransmission.value = src.transmission ?? 0.0; // MeshPhysicalMaterial
227
+
228
+ if (src.emissive) {
229
+ u.uEmissive.value
230
+ .copy(src.emissive)
231
+ .multiplyScalar(src.emissiveIntensity ?? 1);
232
+ }
233
+ u.uMap.value = src.map ?? null;
234
+ u.uHasMap.value = !!src.map;
235
+ u.uEmissiveMap.value = src.emissiveMap ?? null;
236
+ u.uHasEmissiveMap.value = !!src.emissiveMap;
237
+ u.uNormalMap.value = src.normalMap ?? null;
238
+ u.uHasNormalMap.value = !!src.normalMap;
239
+ if (src.normalScale) u.uNormalScale.value.copy(src.normalScale);
240
+ else u.uNormalScale.value.set(1, 1);
241
+ u.uRoughnessMap.value = src.roughnessMap ?? null;
242
+ u.uHasRoughnessMap.value = !!src.roughnessMap;
243
+ u.uMetalnessMap.value = src.metalnessMap ?? null;
244
+ u.uHasMetalnessMap.value = !!src.metalnessMap;
245
+ // Alpha-blended transparency: primary-visible here (opacity packed into the
246
+ // material word + gEmissive.a), composited against the geometry behind by the
247
+ // lighting pass. opacity 1 renders opaque, matching the old force-opaque path.
248
+ u.uBlend.value = !!src.transparent;
249
+ u.uOpacity.value = src.opacity ?? 1.0;
250
+ u.uNormalMatrixWorld.value.getNormalMatrix(mesh.matrixWorld);
251
+ material.side = src.side ?? THREE.FrontSide;
252
+ return material;
253
+ }
254
+
255
+ render(renderer, scene, camera) {
256
+ // Ping-pong: what was "current" becomes "previous".
257
+ this._current = 1 - this._current;
258
+
259
+ // Swap in G-buffer materials. Transparent meshes are written too — as the
260
+ // nearest single layer, depth-tested/written like any surface (overlapping
261
+ // transparent surfaces do not inter-sort). Their opacity rides in the packed
262
+ // material word + gEmissive.a, and the lighting pass blends them against the
263
+ // geometry behind. opacity 1 writes fully opaque, so alpha-textured cases
264
+ // (LittlestTokyo's glass) look exactly as before.
265
+ this._swapped.length = 0;
266
+ scene.traverse((obj) => {
267
+ if (obj.isMesh && obj.geometry && obj.visible) {
268
+ this._swapped.push([obj, obj.material]);
269
+ obj.material = this._gbufferMaterialFor(obj);
270
+ }
271
+ });
272
+
273
+ const prevBackground = scene.background;
274
+ scene.background = null; // background writes nothing; worldPos.w stays 0
275
+
276
+ renderer.setRenderTarget(this.target);
277
+ renderer.setClearColor(0x000000, 0);
278
+ renderer.clear(true, true, false);
279
+ renderer.render(scene, camera);
280
+ renderer.setRenderTarget(null);
281
+
282
+ scene.background = prevBackground;
283
+ for (const [mesh, mat] of this._swapped) mesh.material = mat;
284
+ this._swapped.length = 0;
285
+ }
286
+
287
+ dispose() {
288
+ for (const t of this._targets) t.dispose();
289
+ }
290
+ }