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.
- package/README.md +559 -394
- package/package.json +52 -52
- package/src/CompositePass.js +261 -214
- package/src/CopyPass.js +75 -0
- package/src/DenoisePass.js +221 -202
- package/src/GBufferPass.js +290 -228
- package/src/RTLightingPass.js +1124 -680
- package/src/RealtimeRaytracer.js +1106 -730
- package/src/RestirPass.js +31 -4
- package/src/SceneCompiler.js +542 -424
- package/src/TAAPass.js +271 -230
- package/src/VolumetricPass.js +398 -329
- package/src/blueNoise.js +17 -17
- package/src/index.d.ts +107 -6
package/src/RealtimeRaytracer.js
CHANGED
|
@@ -1,730 +1,1106 @@
|
|
|
1
|
-
import * as THREE from "three";
|
|
2
|
-
import { compileScene, syncLights } from "./SceneCompiler.js";
|
|
3
|
-
import { GBufferPass } from "./GBufferPass.js";
|
|
4
|
-
import { RTLightingPass } from "./RTLightingPass.js";
|
|
5
|
-
import { DenoisePass } from "./DenoisePass.js";
|
|
6
|
-
import { CompositePass } from "./CompositePass.js";
|
|
7
|
-
import { TAAPass } from "./TAAPass.js";
|
|
8
|
-
import { VolumetricPass } from "./VolumetricPass.js";
|
|
9
|
-
import { RestirPass } from "./RestirPass.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
let
|
|
16
|
-
let
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* rt
|
|
30
|
-
* rt.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (!gl
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (tier === "
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
this.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
this.
|
|
318
|
-
this.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
this.
|
|
322
|
-
this.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
this.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
this.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
this.
|
|
456
|
-
this.
|
|
457
|
-
|
|
458
|
-
this.
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
)
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
this.
|
|
528
|
-
this.
|
|
529
|
-
this.
|
|
530
|
-
this.
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
if (
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
this.
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
//
|
|
621
|
-
this.
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
this.
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
this.
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
this.
|
|
723
|
-
this.
|
|
724
|
-
this.
|
|
725
|
-
this.
|
|
726
|
-
this.
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { compileScene, syncLights } from "./SceneCompiler.js";
|
|
3
|
+
import { GBufferPass } from "./GBufferPass.js";
|
|
4
|
+
import { RTLightingPass } from "./RTLightingPass.js";
|
|
5
|
+
import { DenoisePass } from "./DenoisePass.js";
|
|
6
|
+
import { CompositePass } from "./CompositePass.js";
|
|
7
|
+
import { TAAPass } from "./TAAPass.js";
|
|
8
|
+
import { VolumetricPass } from "./VolumetricPass.js";
|
|
9
|
+
import { RestirPass } from "./RestirPass.js";
|
|
10
|
+
import { CopyPass } from "./CopyPass.js";
|
|
11
|
+
|
|
12
|
+
// Van der Corput / Halton radical inverse — deterministic low-discrepancy
|
|
13
|
+
// sub-pixel offsets for temporal jitter.
|
|
14
|
+
function halton(index, base) {
|
|
15
|
+
let f = 1;
|
|
16
|
+
let r = 0;
|
|
17
|
+
let i = index;
|
|
18
|
+
while (i > 0) {
|
|
19
|
+
f /= base;
|
|
20
|
+
r += f * (i % base);
|
|
21
|
+
i = Math.floor(i / base);
|
|
22
|
+
}
|
|
23
|
+
return r;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Drop-in ray traced renderer for three.js scenes.
|
|
28
|
+
*
|
|
29
|
+
* const rt = new RealtimeRaytracer(renderer);
|
|
30
|
+
* rt.compileScene(scene); // once (static scenes, stage 1)
|
|
31
|
+
* rt.render(scene, camera); // per frame, instead of renderer.render
|
|
32
|
+
*
|
|
33
|
+
* Hybrid deferred: rasterized G-buffer for primary visibility, BVH-traced
|
|
34
|
+
* shadow rays + 1-bounce GI for lighting, progressive temporal accumulation.
|
|
35
|
+
*/
|
|
36
|
+
export class RealtimeRaytracer {
|
|
37
|
+
/**
|
|
38
|
+
* Can this renderer run the ray tracing pipeline at all? Requires WebGL2
|
|
39
|
+
* with float render targets on a hardware GPU (software rasterizers like
|
|
40
|
+
* SwiftShader technically work but are unusably slow — treated as no).
|
|
41
|
+
*/
|
|
42
|
+
static isSupported(renderer) {
|
|
43
|
+
try {
|
|
44
|
+
const gl = renderer.getContext();
|
|
45
|
+
if (typeof WebGL2RenderingContext === "undefined" || !(gl instanceof WebGL2RenderingContext)) return false;
|
|
46
|
+
if (!gl.getExtension("EXT_color_buffer_float")) return false;
|
|
47
|
+
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
|
|
48
|
+
if (dbg) {
|
|
49
|
+
const r = String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) || "");
|
|
50
|
+
if (/swiftshader|llvmpipe|software/i.test(r)) return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Rough capability tier for choosing defaults: "none" (can't trace — see
|
|
60
|
+
* isSupported), "mid" (phones/tablets), "high" (desktop-class). WebGPU
|
|
61
|
+
* presence is not used as a backend (this library is WebGL2), only as a
|
|
62
|
+
* modern-browser signal; a WGSL compute backend is roadmap.
|
|
63
|
+
*/
|
|
64
|
+
static detectTier(renderer) {
|
|
65
|
+
if (renderer && !RealtimeRaytracer.isSupported(renderer)) return "none";
|
|
66
|
+
const nav = typeof navigator !== "undefined" ? navigator : {};
|
|
67
|
+
const mobile =
|
|
68
|
+
(nav.maxTouchPoints ?? 0) > 1 || /Android|iPhone|iPad|Mobile/i.test(nav.userAgent || "");
|
|
69
|
+
return mobile ? "mid" : "high";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Sensible constructor options for a tier (spread them, then override). */
|
|
73
|
+
static recommendedOptions(tier) {
|
|
74
|
+
if (tier === "none") return {};
|
|
75
|
+
if (tier === "mid") {
|
|
76
|
+
return {
|
|
77
|
+
renderScale: 0.375,
|
|
78
|
+
...RealtimeRaytracer._qualityFor(0.375),
|
|
79
|
+
adaptiveQuality: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// High: full per-light direct shadows (stochasticLights pinned false).
|
|
83
|
+
// Pinned explicitly because the constructor default is now `true` (the
|
|
84
|
+
// conservative-default change) — high must keep its original behaviour.
|
|
85
|
+
return { renderScale: 0.5, denoiseIterations: 3, stochasticLights: false, adaptiveQuality: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* GPU tier probe. An OPTIONAL, async companion to {@link detectTier}: when the
|
|
90
|
+
* browser exposes WebGPU it inspects the real adapter limits, otherwise it
|
|
91
|
+
* falls back to the WebGL heuristic. Returns
|
|
92
|
+
* `{ tier: "none"|"mid"|"high", source: "webgpu"|"webgl"|"fallback", details }`.
|
|
93
|
+
*
|
|
94
|
+
* const probe = await RealtimeRaytracer.probeGPUTier();
|
|
95
|
+
* const rt = new RealtimeRaytracer(renderer, RealtimeRaytracer.recommendedOptions(probe.tier));
|
|
96
|
+
*
|
|
97
|
+
* HONEST-HEURISTIC CAVEAT: WebGPU does NOT expose VRAM. `adapter.limits`
|
|
98
|
+
* advertises binding/allocation ceilings (`maxBufferSize` etc.), which are a
|
|
99
|
+
* driver-reported proxy for "how beefy is this GPU", not a memory size — a
|
|
100
|
+
* card with 8GB and a card with 24GB can report the identical 2GB
|
|
101
|
+
* `maxBufferSize`. `adapter.info` (vendor/architecture/description) is masked
|
|
102
|
+
* on many browsers for fingerprinting reasons, so it is treated as a hint
|
|
103
|
+
* only. The classification is therefore deliberately coarse.
|
|
104
|
+
*
|
|
105
|
+
* Classification (WebGPU path), all thresholds echoed back in `details`:
|
|
106
|
+
* - Software signature in adapter.info (swiftshader / llvmpipe / "basic
|
|
107
|
+
* render" / paravirtual) → `"none"`.
|
|
108
|
+
* - "strong" = `maxBufferSize >= 2GiB` AND `maxTextureDimension2D >= 16384`
|
|
109
|
+
* (integrated GPUs typically report an 8192 texture limit and a smaller
|
|
110
|
+
* buffer ceiling).
|
|
111
|
+
* - Screen-demand factor: `screenPixels = screen.width * screen.height *
|
|
112
|
+
* min(devicePixelRatio, 2)` (DPR clamped so a 3× phone doesn't read as a
|
|
113
|
+
* workstation). A 4K-class panel (`screenPixels >= 6e6`) has to fill ~4×
|
|
114
|
+
* the pixels of 1080p for the same lighting resolution, so a "strong" GPU
|
|
115
|
+
* is only called `"high"` on such a screen when it ALSO clears a 4GiB
|
|
116
|
+
* `maxBufferSize` bar; otherwise it is demoted to `"mid"`.
|
|
117
|
+
* - strong (clearing the screen bar) → `"high"`, else → `"mid"`.
|
|
118
|
+
*
|
|
119
|
+
* No WebGPU (or requestAdapter fails) → the existing {@link detectTier} WebGL
|
|
120
|
+
* heuristic, `source: "webgl"` when a renderer was supplied to probe, or
|
|
121
|
+
* `source: "fallback"` when the tier is a pure user-agent guess (no context).
|
|
122
|
+
*/
|
|
123
|
+
static async probeGPUTier(renderer) {
|
|
124
|
+
const GiB = 1024 * 1024 * 1024;
|
|
125
|
+
const details = {};
|
|
126
|
+
|
|
127
|
+
// Screen-demand factor (used by every path). DPR clamped to 2 so a phone's
|
|
128
|
+
// 3×/4× ratio doesn't inflate demand past what its tiny CSS area needs.
|
|
129
|
+
const dpr = (typeof window !== "undefined" && window.devicePixelRatio) || 1;
|
|
130
|
+
const scr =
|
|
131
|
+
typeof window !== "undefined" && window.screen
|
|
132
|
+
? window.screen
|
|
133
|
+
: { width: 1920, height: 1080 };
|
|
134
|
+
const screenPixels = Math.round(scr.width * scr.height * Math.min(dpr, 2));
|
|
135
|
+
const demanding = screenPixels >= 6e6; // 4K-class panel
|
|
136
|
+
details.screenPixels = screenPixels;
|
|
137
|
+
details.demanding = demanding;
|
|
138
|
+
|
|
139
|
+
if (typeof navigator !== "undefined" && navigator.gpu) {
|
|
140
|
+
try {
|
|
141
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
142
|
+
if (adapter) {
|
|
143
|
+
const L = adapter.limits || {};
|
|
144
|
+
const maxBufferSize = Number(L.maxBufferSize || 0);
|
|
145
|
+
const maxStorageBufferBindingSize = Number(L.maxStorageBufferBindingSize || 0);
|
|
146
|
+
const maxTextureDimension2D = Number(L.maxTextureDimension2D || 0);
|
|
147
|
+
const maxComputeWorkgroupStorageSize = Number(L.maxComputeWorkgroupStorageSize || 0);
|
|
148
|
+
Object.assign(details, {
|
|
149
|
+
maxBufferSize,
|
|
150
|
+
maxStorageBufferBindingSize,
|
|
151
|
+
maxTextureDimension2D,
|
|
152
|
+
maxComputeWorkgroupStorageSize,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// adapter.info is a plain property in newer specs; older Chromium
|
|
156
|
+
// exposed it via the async requestAdapterInfo(). Both are masked on
|
|
157
|
+
// some browsers — treat any of it as an optional hint.
|
|
158
|
+
let info = {};
|
|
159
|
+
try {
|
|
160
|
+
info =
|
|
161
|
+
adapter.info ||
|
|
162
|
+
(adapter.requestAdapterInfo ? await adapter.requestAdapterInfo() : {}) ||
|
|
163
|
+
{};
|
|
164
|
+
} catch {
|
|
165
|
+
info = {};
|
|
166
|
+
}
|
|
167
|
+
details.vendor = info.vendor || null;
|
|
168
|
+
details.architecture = info.architecture || null;
|
|
169
|
+
details.description = info.description || null;
|
|
170
|
+
const infoStr = `${info.vendor || ""} ${info.architecture || ""} ${
|
|
171
|
+
info.description || ""
|
|
172
|
+
} ${info.device || ""}`.toLowerCase();
|
|
173
|
+
|
|
174
|
+
if (/swiftshader|llvmpipe|software|basic render|microsoft basic|paravirtual/.test(infoStr)) {
|
|
175
|
+
details.reason = "software renderer signature in adapter.info";
|
|
176
|
+
return { tier: "none", source: "webgpu", details };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const strong = maxBufferSize >= 2 * GiB && maxTextureDimension2D >= 16384;
|
|
180
|
+
const hugeBuffer = maxBufferSize >= 4 * GiB;
|
|
181
|
+
let tier;
|
|
182
|
+
if (strong && (!demanding || hugeBuffer)) {
|
|
183
|
+
tier = "high";
|
|
184
|
+
details.reason =
|
|
185
|
+
demanding && hugeBuffer
|
|
186
|
+
? "strong limits + >=4GiB buffer clears 4K-class screen demand -> high"
|
|
187
|
+
: "large buffer + textures -> high";
|
|
188
|
+
} else if (strong && demanding) {
|
|
189
|
+
tier = "mid";
|
|
190
|
+
details.reason =
|
|
191
|
+
"strong limits but 4K-class screen without a >=4GiB buffer budget -> mid";
|
|
192
|
+
} else {
|
|
193
|
+
tier = "mid";
|
|
194
|
+
details.reason = "modest adapter limits -> mid";
|
|
195
|
+
}
|
|
196
|
+
return { tier, source: "webgpu", details };
|
|
197
|
+
}
|
|
198
|
+
details.reason = "navigator.gpu present but requestAdapter returned no adapter";
|
|
199
|
+
} catch (err) {
|
|
200
|
+
details.error = String((err && err.message) || err);
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
details.reason = "no navigator.gpu (WebGPU unavailable)";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// WebGL heuristic fallback. With a renderer we actually probe the GL
|
|
207
|
+
// context (source "webgl"); without one the tier is a pure user-agent guess
|
|
208
|
+
// (source "fallback").
|
|
209
|
+
const tier = RealtimeRaytracer.detectTier(renderer);
|
|
210
|
+
return { tier, source: renderer ? "webgl" : "fallback", details };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Probe whether this context accepts a framebuffer with mixed fp16/fp32
|
|
215
|
+
* color attachments (legal WebGL2; some drivers reject it anyway). Runs raw
|
|
216
|
+
* GL before three renders anything, and restores null bindings after.
|
|
217
|
+
*/
|
|
218
|
+
static _mixedMrtSupported(gl) {
|
|
219
|
+
try {
|
|
220
|
+
const fb = gl.createFramebuffer();
|
|
221
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
|
|
222
|
+
const mk = (ifmt) => {
|
|
223
|
+
const t = gl.createTexture();
|
|
224
|
+
gl.bindTexture(gl.TEXTURE_2D, t);
|
|
225
|
+
gl.texStorage2D(gl.TEXTURE_2D, 1, ifmt, 4, 4);
|
|
226
|
+
return t;
|
|
227
|
+
};
|
|
228
|
+
const t0 = mk(gl.RGBA16F);
|
|
229
|
+
const t1 = mk(gl.RGBA32F);
|
|
230
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t0, 0);
|
|
231
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT1, gl.TEXTURE_2D, t1, 0);
|
|
232
|
+
gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]);
|
|
233
|
+
const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
|
|
234
|
+
gl.deleteFramebuffer(fb);
|
|
235
|
+
gl.deleteTexture(t0);
|
|
236
|
+
gl.deleteTexture(t1);
|
|
237
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
238
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
239
|
+
return ok;
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Companion settings for a given lighting resolution. LOW resolution wants
|
|
247
|
+
* MORE denoise passes, not fewer — the filter runs at lighting res so extra
|
|
248
|
+
* iterations are nearly free there, and they're what makes 25% lighting look
|
|
249
|
+
* good. Stochastic lights kick in once the budget is clearly constrained.
|
|
250
|
+
*/
|
|
251
|
+
static _qualityFor(scale) {
|
|
252
|
+
return {
|
|
253
|
+
denoiseIterations: scale <= 0.3 ? 5 : scale <= 0.45 ? 4 : 3,
|
|
254
|
+
stochasticLights: scale <= 0.55,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Canvas-scale ladder for the governor's deepest lever. Canvas scale shrinks
|
|
259
|
+
// the drawing buffer, so EVERY pass (raster G-buffer, lighting, denoise, TAA,
|
|
260
|
+
// resolve) gets quadratically cheaper — unlike renderScale, which only touches
|
|
261
|
+
// the lighting buffer. It's app-owned (the demo/gallery own the canvas + CSS
|
|
262
|
+
// stretch), so the governor drives it through canvasScaleHook rather than
|
|
263
|
+
// touching the renderer directly.
|
|
264
|
+
static CANVAS_LEVELS = [1, 0.85, 0.75, 0.62, 0.5];
|
|
265
|
+
|
|
266
|
+
// Sample count the carried-over irradiance history is clamped to when the
|
|
267
|
+
// targets are reallocated (renderScale step / canvas resize). Small enough
|
|
268
|
+
// that the EMA visibly reconverges onto the new-resolution samples, large
|
|
269
|
+
// enough that the image doesn't flash back to raw 1-spp noise — "keep ~8
|
|
270
|
+
// frames of confidence". See RTLightingPass.resizeCarry.
|
|
271
|
+
static HISTORY_CARRY_FRAMES = 8;
|
|
272
|
+
|
|
273
|
+
constructor(renderer, options = {}) {
|
|
274
|
+
this.renderer = renderer;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* False when the platform can't run the tracer — render() then simply
|
|
278
|
+
* forwards to renderer.render (plain rasterized three.js), so apps work
|
|
279
|
+
* everywhere without their own capability checks.
|
|
280
|
+
*/
|
|
281
|
+
this.supported = RealtimeRaytracer.isSupported(renderer);
|
|
282
|
+
if (!this.supported) {
|
|
283
|
+
console.warn(
|
|
284
|
+
"three-realtime-rt: ray tracing unavailable on this system " +
|
|
285
|
+
"(needs WebGL2 + EXT_color_buffer_float on a hardware GPU). " +
|
|
286
|
+
"Falling back to plain three.js rendering."
|
|
287
|
+
);
|
|
288
|
+
this.compiled = null;
|
|
289
|
+
this.frame = 0;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const size = renderer.getSize(new THREE.Vector2());
|
|
294
|
+
const pr = renderer.getPixelRatio();
|
|
295
|
+
// Canvas (on-screen) drawing-buffer size. Everything internal renders at the
|
|
296
|
+
// OVERSCAN-padded size derived from this; the final on-screen draw crops the
|
|
297
|
+
// centre back to it.
|
|
298
|
+
this._canvasW = Math.floor(size.x * pr);
|
|
299
|
+
this._canvasH = Math.floor(size.y * pr);
|
|
300
|
+
/**
|
|
301
|
+
* Overscan: render internally at a padded resolution with a proportionally
|
|
302
|
+
* widened field of view, then crop the centre to the canvas on the final
|
|
303
|
+
* draw. Disocclusion pixels at the leading screen edge during camera motion
|
|
304
|
+
* are then born OFF-screen, hiding their several-frame temporal-convergence
|
|
305
|
+
* noise. Fraction of padding PER EDGE (clamped 0–0.25); 0.1 on a 1000×600
|
|
306
|
+
* canvas renders 1200×720 internally (1.44× the pixels). 0 disables it.
|
|
307
|
+
*/
|
|
308
|
+
this._overscan = Math.min(0.25, Math.max(0, options.overscan ?? 0));
|
|
309
|
+
/**
|
|
310
|
+
* Resolution scale for the ray traced lighting (G-buffer and final image
|
|
311
|
+
* stay full res). 0.5 traces 4x fewer rays; the bilateral upsample +
|
|
312
|
+
* denoiser reconstruct the difference. Set 1.0 for maximum quality.
|
|
313
|
+
*/
|
|
314
|
+
this._renderScale = options.renderScale ?? 0.5;
|
|
315
|
+
// Padded internal render size (canvas × the overscan factor). All passes
|
|
316
|
+
// work here; _crop maps it back to the canvas on the final draw.
|
|
317
|
+
this._width = Math.round(this._canvasW * this._padFactor);
|
|
318
|
+
this._height = Math.round(this._canvasH * this._padFactor);
|
|
319
|
+
// Central-crop UV transform (scale.xy, offset.zw): padded → canvas. Identity
|
|
320
|
+
// when overscan is 0. Recomputed by _updateCrop() on every size/overscan change.
|
|
321
|
+
this._crop = new THREE.Vector4(1, 1, 0, 0);
|
|
322
|
+
this._updateCrop();
|
|
323
|
+
|
|
324
|
+
const mixedPrecision = RealtimeRaytracer._mixedMrtSupported(renderer.getContext());
|
|
325
|
+
if (!mixedPrecision) {
|
|
326
|
+
console.info("three-realtime-rt: mixed fp16/fp32 G-buffer not supported here — using fp32 for all targets.");
|
|
327
|
+
}
|
|
328
|
+
this.gbuffer = new GBufferPass(this._width, this._height, { mixedPrecision });
|
|
329
|
+
this.rtPass = new RTLightingPass(this._scaledW, this._scaledH);
|
|
330
|
+
this.denoisePass = new DenoisePass(this._scaledW, this._scaledH);
|
|
331
|
+
// Separate à-trous instance for the specular buffer (its own ping-pong
|
|
332
|
+
// targets, so the specular denoise cannot clobber the irradiance result).
|
|
333
|
+
this.specDenoisePass = new DenoisePass(this._scaledW, this._scaledH, {
|
|
334
|
+
blendIsSpec: true, // blend pixels here hold the behind-the-pane image
|
|
335
|
+
});
|
|
336
|
+
this.composite = new CompositePass();
|
|
337
|
+
this.taaPass = new TAAPass(this._width, this._height);
|
|
338
|
+
this._sceneColor = this._makeColorTarget(this._width, this._height);
|
|
339
|
+
// Fullscreen blit used to carry history buffers across target reallocation
|
|
340
|
+
// (renderScale steps / canvas resizes) instead of hard-clearing them.
|
|
341
|
+
this._copyPass = new CopyPass();
|
|
342
|
+
|
|
343
|
+
this.compiled = null;
|
|
344
|
+
this.frame = 0;
|
|
345
|
+
|
|
346
|
+
/** Debug view: 0 composite, 1 albedo, 2 normal, 3 irradiance, 4 worldPos, 5 emissive */
|
|
347
|
+
this.outputMode = 0;
|
|
348
|
+
/** Environment (sky) color used for GI rays that miss + composite background. */
|
|
349
|
+
this.envColor = options.envColor ?? new THREE.Color(0.03, 0.04, 0.06);
|
|
350
|
+
this.envIntensity = options.envIntensity ?? 1.0;
|
|
351
|
+
/**
|
|
352
|
+
* Ray offset epsilon. When not set explicitly it is auto-scaled from the
|
|
353
|
+
* scene's size at compile time (dense scenes need a larger offset or
|
|
354
|
+
* shadow rays self-intersect). Set it manually if you see acne (raise) or
|
|
355
|
+
* light leaking through thin walls (lower).
|
|
356
|
+
*/
|
|
357
|
+
this.eps = options.eps ?? 1e-3;
|
|
358
|
+
this._autoEps = options.eps == null;
|
|
359
|
+
/** Reproject accumulated lighting through camera motion (stage 2). */
|
|
360
|
+
this.temporalReprojection = options.temporalReprojection ?? true;
|
|
361
|
+
/** History length cap: higher = smoother but slower to react. */
|
|
362
|
+
this.maxHistory = options.maxHistory ?? 128;
|
|
363
|
+
/** Clamp on indirect luminance to suppress fireflies. 0 disables. */
|
|
364
|
+
this.fireflyClamp = options.fireflyClamp ?? 4.0;
|
|
365
|
+
/** 1-bounce global illumination (traced indirect). Toggle for a direct-only look. */
|
|
366
|
+
this.gi = options.gi ?? true;
|
|
367
|
+
/**
|
|
368
|
+
* Half-rate GI: trace the bounce on alternating checkerboard parity each
|
|
369
|
+
* frame (doubled — unbiased, temporal accumulation converges to the same
|
|
370
|
+
* brightness). Halves GI's ray cost for a small convergence-speed hit;
|
|
371
|
+
* the cheapest way to keep GI "worth turning on" on weaker GPUs.
|
|
372
|
+
*/
|
|
373
|
+
this.giHalfRate = options.giHalfRate ?? false;
|
|
374
|
+
/**
|
|
375
|
+
* Sample static emissive meshes as area lights (next-event estimation).
|
|
376
|
+
* Dramatically less noise than waiting for GI rays to hit the emitter, and
|
|
377
|
+
* emitters gain direct lighting + shadows. Off = legacy hit-only behaviour.
|
|
378
|
+
*/
|
|
379
|
+
this.emissiveNEE = options.emissiveNEE ?? true;
|
|
380
|
+
/**
|
|
381
|
+
* Importance-sample WHICH emissive triangle NEE shoots at, proportional to
|
|
382
|
+
* area x emitted luminance (a compile-time power CDF), instead of a uniform
|
|
383
|
+
* 1-of-N pick. Same mean, far less sparkle in scenes whose emitters differ
|
|
384
|
+
* in size/brightness. Off = legacy uniform pick (A/B comparison hook).
|
|
385
|
+
*/
|
|
386
|
+
this.emissiveImportance = options.emissiveImportance ?? true;
|
|
387
|
+
/**
|
|
388
|
+
* PBR direct specular: Cook-Torrance GGX highlights for all surfaces, in a
|
|
389
|
+
* separate specular buffer added without the albedo multiply (dielectric
|
|
390
|
+
* highlights are white, F0 ~= 0.04). Off = the old Lambert-only look.
|
|
391
|
+
*/
|
|
392
|
+
this.specular = options.specular ?? true;
|
|
393
|
+
/** Traced mirror/glossy reflections on metallic surfaces. */
|
|
394
|
+
this.reflections = options.reflections ?? true;
|
|
395
|
+
/** Traced refraction for transmissive (MeshPhysicalMaterial.transmission) surfaces. */
|
|
396
|
+
this.refraction = options.refraction ?? true;
|
|
397
|
+
/**
|
|
398
|
+
* Alpha-blended transparency: a `transparent: true` mesh is primary-visible
|
|
399
|
+
* but kept out of the BVH, and the lighting pass traces a straight-through
|
|
400
|
+
* ray to composite the geometry behind it (weighted by `opacity`, tinted by
|
|
401
|
+
* the pane's albedo). Default ON — it fixes silently-wrong behaviour and
|
|
402
|
+
* costs only on blend pixels. Off = blend surfaces render fully opaque.
|
|
403
|
+
*/
|
|
404
|
+
this.transparency = options.transparency ?? true;
|
|
405
|
+
/** Index of refraction used for transmissive surfaces. */
|
|
406
|
+
this.ior = options.ior ?? 1.5;
|
|
407
|
+
/**
|
|
408
|
+
* One stochastic direct shadow ray per pixel per frame (source picked at
|
|
409
|
+
* random) instead of one per light — the biggest ray-count lever for
|
|
410
|
+
* many-light scenes and mobile GPUs. Slightly noisier moving shadows;
|
|
411
|
+
* temporal accumulation + the denoiser absorb it. Defaults ON so the
|
|
412
|
+
* zero-config renderer stays cheap on weak GPUs; the governor turns it off
|
|
413
|
+
* again once it has scaled resolution up on a strong machine.
|
|
414
|
+
*/
|
|
415
|
+
this.stochasticLights = options.stochasticLights ?? true;
|
|
416
|
+
/**
|
|
417
|
+
* Adaptive quality governor: watches the app's real frame time and walks
|
|
418
|
+
* QUALITY_LADDER — degrading when frames run long, cautiously probing a
|
|
419
|
+
* better level when there is headroom (reverting if the probe fails). The
|
|
420
|
+
* portable way to "work well" on unknown hardware. Drives renderScale,
|
|
421
|
+
* denoiseIterations and stochasticLights; setting those manually while
|
|
422
|
+
* enabled will be overridden — turn this off for manual control. Defaults
|
|
423
|
+
* ON so the conservative default resolution scales UP toward targetFps on
|
|
424
|
+
* capable hardware instead of being stuck low.
|
|
425
|
+
*/
|
|
426
|
+
this.adaptiveQuality = options.adaptiveQuality ?? true;
|
|
427
|
+
/** Frame-rate target for the adaptive governor. */
|
|
428
|
+
this.targetFps = options.targetFps ?? 55;
|
|
429
|
+
/**
|
|
430
|
+
* Emergency overload brake — independent of adaptiveQuality and ON by
|
|
431
|
+
* default. Two protections: an oversized first buffer gets its lighting
|
|
432
|
+
* scale clamped (with a loud warning), and consecutive catastrophic
|
|
433
|
+
* frames (>400ms) force quality down before the GPU driver gives up.
|
|
434
|
+
* Weak GPUs fed high settings can otherwise hang the whole machine
|
|
435
|
+
* (observed: full system crash on an Intel-Mac in Chrome). Set
|
|
436
|
+
* overloadProtection: false to opt out.
|
|
437
|
+
*/
|
|
438
|
+
this.overloadProtection = options.overloadProtection ?? true;
|
|
439
|
+
this._overloadStrikes = 0;
|
|
440
|
+
this._obLastT = null;
|
|
441
|
+
this._qEma = null;
|
|
442
|
+
this._qLastT = null;
|
|
443
|
+
this._qLastChange = 0;
|
|
444
|
+
// Direction of the last committed quality change (+1 up / -1 down) and an
|
|
445
|
+
// oscillation flag: when two consecutive steps reverse direction the
|
|
446
|
+
// governor is hunting around the frame-time boundary, so it widens its
|
|
447
|
+
// deadband and lengthens its cooldown to settle down (see _adaptQuality).
|
|
448
|
+
this._qLastDir = 0;
|
|
449
|
+
this._qOscillating = false;
|
|
450
|
+
/**
|
|
451
|
+
* App-owned canvas-scale setter, driven by the governor as its deepest
|
|
452
|
+
* lever once renderScale bottoms out. The app owns the canvas + CSS stretch,
|
|
453
|
+
* so it must apply the buffer resize itself; null disables this level.
|
|
454
|
+
*/
|
|
455
|
+
this.canvasScaleHook = options.canvasScaleHook ?? null;
|
|
456
|
+
this._canvasLevelIdx = 0;
|
|
457
|
+
/** Edge-aware à-trous denoise on the irradiance buffer. */
|
|
458
|
+
this.denoise = options.denoise ?? true;
|
|
459
|
+
/**
|
|
460
|
+
* À-trous iterations (steps 1, 2, 4, ...). Defaults to a lean 2; the
|
|
461
|
+
* adaptive governor raises it (via _qualityFor) as it lowers resolution,
|
|
462
|
+
* where the extra passes run at lighting res and are nearly free.
|
|
463
|
+
*/
|
|
464
|
+
this.denoiseIterations = options.denoiseIterations ?? 2;
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Temporal anti-aliasing: sub-pixel projection jitter + a full-res history
|
|
468
|
+
* resolve with neighbourhood clamp. Supersamples silhouettes over time and
|
|
469
|
+
* clears the bright disocclusion speckles at edges. Analytic (FSR2 / TAAU
|
|
470
|
+
* family), not a learned upscaler.
|
|
471
|
+
*/
|
|
472
|
+
this.taa = options.taa ?? true;
|
|
473
|
+
/** Fresh-sample weight in the TAA blend (lower = smoother/more AA, more lag). */
|
|
474
|
+
this.taaBlend = options.taaBlend ?? 0.1;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Volumetric lighting — real "god rays": single-scatter fog integrated
|
|
478
|
+
* along each primary ray with one jittered, BVH-shadowed light sample per
|
|
479
|
+
* pixel per frame, temporally accumulated like the surface lighting.
|
|
480
|
+
* Shafts are carved by actual occluders and work for off-screen sources.
|
|
481
|
+
* Off by default; costs roughly one extra shadow ray per lighting pixel.
|
|
482
|
+
*/
|
|
483
|
+
this.volumetric = {
|
|
484
|
+
enabled: options.volumetric?.enabled ?? false,
|
|
485
|
+
density: options.volumetric?.density ?? 0.015,
|
|
486
|
+
maxDist: options.volumetric?.maxDist ?? 40,
|
|
487
|
+
// Localized fog: up to 8 AABBs whose densities ADD to the global term at
|
|
488
|
+
// any point they contain. Empty = global-only (original behavior).
|
|
489
|
+
zones: options.volumetric?.zones ?? [],
|
|
490
|
+
};
|
|
491
|
+
// Quarter CANVAS resolution, independent of renderScale: fog is
|
|
492
|
+
// low-frequency, so resolution buys nothing — the budget goes into
|
|
493
|
+
// multiple march steps per ray instead (see VolumetricPass).
|
|
494
|
+
this.volumetricPass = new VolumetricPass(this._volW, this._volH);
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* ReSTIR direct lighting: per-pixel reservoirs converge onto the light
|
|
498
|
+
* that matters most to each pixel (temporal reuse, one visibility ray at
|
|
499
|
+
* shading). Cost is flat in light count; also greatly reduces emissive
|
|
500
|
+
* area-light noise. On by default — turn off to compare estimators.
|
|
501
|
+
*/
|
|
502
|
+
this.restir = options.restir ?? true;
|
|
503
|
+
this.restirPass = new RestirPass(this._scaledW, this._scaledH);
|
|
504
|
+
|
|
505
|
+
/** Distance fog (composited in linear space before tonemap). */
|
|
506
|
+
this.fog = {
|
|
507
|
+
enabled: options.fog?.enabled ?? false,
|
|
508
|
+
color: options.fog?.color ?? new THREE.Color(0.5, 0.6, 0.7),
|
|
509
|
+
density: options.fog?.density ?? 0.05,
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Procedural sky. When enabled it is BOTH the background and the ambient
|
|
514
|
+
* light for GI rays that escape the scene — the core of natural outdoor
|
|
515
|
+
* lighting. `sunDir` points toward the sun (keep it in sync with your
|
|
516
|
+
* DirectionalLight for matching direct shadows).
|
|
517
|
+
*/
|
|
518
|
+
this.sky = {
|
|
519
|
+
enabled: options.sky?.enabled ?? false,
|
|
520
|
+
sunDir: options.sky?.sunDir ?? new THREE.Vector3(0.4, 0.8, 0.45).normalize(),
|
|
521
|
+
sunColor: options.sky?.sunColor ?? new THREE.Color(1.0, 0.9, 0.75),
|
|
522
|
+
zenith: options.sky?.zenith ?? new THREE.Color(0.18, 0.34, 0.62),
|
|
523
|
+
horizon: options.sky?.horizon ?? new THREE.Color(0.7, 0.8, 0.9),
|
|
524
|
+
intensity: options.sky?.intensity ?? 1.0,
|
|
525
|
+
};
|
|
526
|
+
this._invViewProj = new THREE.Matrix4();
|
|
527
|
+
this._jitterIndex = 0;
|
|
528
|
+
this._jitteredViewProj = new THREE.Matrix4();
|
|
529
|
+
this._jitterUv = new THREE.Vector2(); // this frame's jitter in UV space
|
|
530
|
+
this._prevJitterUv = new THREE.Vector2();
|
|
531
|
+
|
|
532
|
+
this._prevViewProj = new THREE.Matrix4();
|
|
533
|
+
this._camWorldPos = new THREE.Vector3();
|
|
534
|
+
this._needsClear = true;
|
|
535
|
+
|
|
536
|
+
// First-frame guard: a 4K/5K drawing buffer at high lighting scale can
|
|
537
|
+
// hang a weak GPU on the very first frame — before any frame-time
|
|
538
|
+
// measurement can react. Start those safe; adaptiveQuality (or the app)
|
|
539
|
+
// can raise quality once frames are proven survivable.
|
|
540
|
+
if (this.overloadProtection && this._width * this._height > 3.2e6 && this._renderScale > 0.375) {
|
|
541
|
+
console.warn(
|
|
542
|
+
`three-realtime-rt: ${(this._width * this._height / 1e6).toFixed(1)}M-pixel drawing buffer — ` +
|
|
543
|
+
`clamping lighting renderScale to 0.375 (overloadProtection). Raise renderScale manually, ` +
|
|
544
|
+
`enable adaptiveQuality, or pass overloadProtection: false to opt out.`
|
|
545
|
+
);
|
|
546
|
+
this._renderScale = 0.375;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Consecutive catastrophic frames mean the GPU is drowning — cut quality
|
|
551
|
+
// hard and loudly before the driver resets (or takes the machine with it).
|
|
552
|
+
// Hidden tabs are exempt (browser throttling looks like huge frame times).
|
|
553
|
+
_overloadBrake() {
|
|
554
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
|
555
|
+
this._obLastT = null;
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const now = performance.now();
|
|
559
|
+
const dt = this._obLastT == null ? null : now - this._obLastT;
|
|
560
|
+
this._obLastT = now;
|
|
561
|
+
if (dt == null) return;
|
|
562
|
+
if (dt > 400 && dt < 10000) this._overloadStrikes++;
|
|
563
|
+
else if (dt < 200) this._overloadStrikes = 0;
|
|
564
|
+
if (this._overloadStrikes < 3) return;
|
|
565
|
+
this._overloadStrikes = 0;
|
|
566
|
+
|
|
567
|
+
if (this._renderScale > 0.2) {
|
|
568
|
+
this.denoiseIterations = Math.min(this.denoiseIterations, 3);
|
|
569
|
+
this.stochasticLights = true;
|
|
570
|
+
this.renderScale = Math.max(0.2, Math.round(this._renderScale * 0.5 * 20) / 20);
|
|
571
|
+
console.warn(
|
|
572
|
+
`three-realtime-rt: frames exceeding 400ms — overload brake cut lighting to ` +
|
|
573
|
+
`${Math.round(this._renderScale * 100)}%. Lower your canvas resolution or enable adaptiveQuality.`
|
|
574
|
+
);
|
|
575
|
+
} else if (this.volumetric.enabled || this.reflections || this.refraction) {
|
|
576
|
+
this.volumetric.enabled = false;
|
|
577
|
+
this.reflections = false;
|
|
578
|
+
this.refraction = false;
|
|
579
|
+
console.warn(
|
|
580
|
+
"three-realtime-rt: still overloaded at minimum lighting scale — " +
|
|
581
|
+
"disabling volumetric/reflections/refraction."
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
_makeColorTarget(width, height) {
|
|
587
|
+
const t = new THREE.WebGLRenderTarget(width, height, {
|
|
588
|
+
minFilter: THREE.LinearFilter,
|
|
589
|
+
magFilter: THREE.LinearFilter,
|
|
590
|
+
format: THREE.RGBAFormat,
|
|
591
|
+
type: THREE.HalfFloatType,
|
|
592
|
+
depthBuffer: false,
|
|
593
|
+
stencilBuffer: false,
|
|
594
|
+
});
|
|
595
|
+
t.texture.generateMipmaps = false;
|
|
596
|
+
return t;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Build/rebuild BVH + material and light tables from the scene. Call after
|
|
601
|
+
* structural scene changes. Pass `{ dynamicMeshes: [...] }` to mark meshes
|
|
602
|
+
* whose transforms will change every frame (drive them with updateDynamic()).
|
|
603
|
+
*/
|
|
604
|
+
compileScene(scene, options) {
|
|
605
|
+
if (!this.supported) return null;
|
|
606
|
+
if (this.compiled) this.compiled.dispose();
|
|
607
|
+
this.compiled = compileScene(scene, options);
|
|
608
|
+
// Emissive area lights are the noisiest direct-light path: one triangle
|
|
609
|
+
// sample per pixel per frame, and the 1/dist^2 term spikes near a small
|
|
610
|
+
// emitter (fireflies). ReSTIR's reservoirs are what tame this — warn when
|
|
611
|
+
// a scene relies on emissive NEE without them. (fireflyClamp and the
|
|
612
|
+
// denoiser absorb the rest; see the README's emissive caveats.)
|
|
613
|
+
if (this.compiled.emissiveTriCount > 0 && this.emissiveNEE && !this.restir) {
|
|
614
|
+
console.info(
|
|
615
|
+
"[three-realtime-rt] this scene has emissive area lights but restir is off — " +
|
|
616
|
+
"emissive NEE alone is the noisiest sampling path; enable restir for a large noise win."
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
if (this._autoEps) {
|
|
620
|
+
// ~1/1000 of the scene diagonal, floored at the classic 1e-3.
|
|
621
|
+
this.eps = Math.min(Math.max(1e-3, this.compiled.sceneDiagonal * 1.2e-3), 0.05);
|
|
622
|
+
}
|
|
623
|
+
this.rtPass.setCompiledScene(this.compiled);
|
|
624
|
+
this.volumetricPass.setCompiledScene(this.compiled);
|
|
625
|
+
this.restirPass.setCompiledScene(this.compiled);
|
|
626
|
+
this.resetAccumulation();
|
|
627
|
+
return this.compiled;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Re-bake moving (dynamic) meshes into the dynamic BVH level. Call each frame
|
|
632
|
+
* after moving them. Only the dynamic level is touched — the static BVH was
|
|
633
|
+
* uploaded once at compile time — so cost scales with the moving triangle
|
|
634
|
+
* count, not the scene size.
|
|
635
|
+
*/
|
|
636
|
+
updateDynamic() {
|
|
637
|
+
if (this.compiled) this.compiled.updateDynamic();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Refresh light positions/colors from the scene without a full recompile —
|
|
642
|
+
* lets the demo toggle, move, and recolor lights live. Lights with intensity
|
|
643
|
+
* 0 (or invisible) are dropped so they can be switched off.
|
|
644
|
+
*/
|
|
645
|
+
updateLights(scene) {
|
|
646
|
+
if (!this.supported || !this.compiled) return;
|
|
647
|
+
syncLights(scene, this.compiled);
|
|
648
|
+
this.rtPass.setCompiledScene(this.compiled);
|
|
649
|
+
this.volumetricPass.setCompiledScene(this.compiled);
|
|
650
|
+
this.restirPass.setCompiledScene(this.compiled);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
resetAccumulation() {
|
|
654
|
+
if (!this.supported) return;
|
|
655
|
+
this._needsClear = true;
|
|
656
|
+
if (this.taaPass) this.taaPass.reset();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Padded/canvas size ratio per axis: 1 + 2×overscan (padding is per edge).
|
|
660
|
+
get _padFactor() {
|
|
661
|
+
return 1 + 2 * this._overscan;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Recompute the central-crop transform that maps the padded internal image
|
|
665
|
+
// back to the canvas on the final draw. UV: canvas_uv × scale + offset →
|
|
666
|
+
// padded_uv, sampling the central canvas-sized region. Identity at overscan 0.
|
|
667
|
+
_updateCrop() {
|
|
668
|
+
this._crop.set(
|
|
669
|
+
this._canvasW / this._width,
|
|
670
|
+
this._canvasH / this._height,
|
|
671
|
+
(this._width - this._canvasW) * 0.5 / this._width,
|
|
672
|
+
(this._height - this._canvasH) * 0.5 / this._height
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
get _scaledW() {
|
|
677
|
+
return Math.max(1, Math.floor(this._width * this._renderScale));
|
|
678
|
+
}
|
|
679
|
+
get _scaledH() {
|
|
680
|
+
return Math.max(1, Math.floor(this._height * this._renderScale));
|
|
681
|
+
}
|
|
682
|
+
get _volW() {
|
|
683
|
+
return Math.max(1, this._width >> 2);
|
|
684
|
+
}
|
|
685
|
+
get _volH() {
|
|
686
|
+
return Math.max(1, this._height >> 2);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
get renderScale() {
|
|
690
|
+
return this._renderScale;
|
|
691
|
+
}
|
|
692
|
+
set renderScale(v) {
|
|
693
|
+
this._renderScale = v;
|
|
694
|
+
this.setSize(this._canvasW, this._canvasH);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
get overscan() {
|
|
698
|
+
return this._overscan;
|
|
699
|
+
}
|
|
700
|
+
// Live-assignable like renderScale. Changing the padded size reallocates every
|
|
701
|
+
// pass, so this hard-resets accumulation (a settings-time knob, not per-frame).
|
|
702
|
+
set overscan(v) {
|
|
703
|
+
const c = Math.min(0.25, Math.max(0, v || 0));
|
|
704
|
+
if (c === this._overscan) return;
|
|
705
|
+
this._overscan = c;
|
|
706
|
+
this.setSize(this._canvasW, this._canvasH);
|
|
707
|
+
this.resetAccumulation();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Resize (or re-scale) the pipeline WITHOUT dumping temporal history. A
|
|
711
|
+
// renderScale step only resizes the lighting-resolution targets; a genuine
|
|
712
|
+
// canvas resize also resizes the full-res ones. Each history-bearing buffer
|
|
713
|
+
// is carried over (resampled) rather than cleared — a hard reset here is what
|
|
714
|
+
// strobed the image on every governor tick. Compares desired vs currently
|
|
715
|
+
// allocated size per pass, so it is correct no matter when _renderScale was
|
|
716
|
+
// updated (the renderScale setter changes it before calling us).
|
|
717
|
+
setSize(width, height) {
|
|
718
|
+
if (!this.supported) return;
|
|
719
|
+
// Arguments are the CANVAS (on-screen) size; the internal targets are the
|
|
720
|
+
// overscan-padded size derived from it. The only place the two diverge is
|
|
721
|
+
// the final on-screen crop (see _crop / _updateCrop).
|
|
722
|
+
this._canvasW = Math.floor(width);
|
|
723
|
+
this._canvasH = Math.floor(height);
|
|
724
|
+
this._width = Math.round(this._canvasW * this._padFactor);
|
|
725
|
+
this._height = Math.round(this._canvasH * this._padFactor);
|
|
726
|
+
this._updateCrop();
|
|
727
|
+
|
|
728
|
+
const sw = this._scaledW;
|
|
729
|
+
const sh = this._scaledH;
|
|
730
|
+
const scaledChanged =
|
|
731
|
+
this.rtPass.targetA.width !== sw || this.rtPass.targetA.height !== sh;
|
|
732
|
+
const canvasChanged =
|
|
733
|
+
this.taaPass.targetA.width !== this._width ||
|
|
734
|
+
this.taaPass.targetA.height !== this._height;
|
|
735
|
+
|
|
736
|
+
// Lighting-resolution targets (change on both a renderScale step and a
|
|
737
|
+
// canvas resize). Carry the irradiance history — the buffer whose reset
|
|
738
|
+
// causes the flash — and reallocate the rest.
|
|
739
|
+
if (scaledChanged) {
|
|
740
|
+
this.rtPass.resizeCarry(
|
|
741
|
+
this.renderer,
|
|
742
|
+
this._copyPass,
|
|
743
|
+
sw,
|
|
744
|
+
sh,
|
|
745
|
+
RealtimeRaytracer.HISTORY_CARRY_FRAMES
|
|
746
|
+
);
|
|
747
|
+
this.denoisePass.setSize(sw, sh); // display-only, no temporal state
|
|
748
|
+
this.specDenoisePass.setSize(sw, sh); // ditto; spec history lives in rtPass
|
|
749
|
+
// ReSTIR reservoirs store packed id·64+M encodings — invalid to linearly
|
|
750
|
+
// resample — but they reconverge in a few frames, so just reallocate and
|
|
751
|
+
// clear them.
|
|
752
|
+
this.restirPass.setSize(sw, sh);
|
|
753
|
+
this.restirPass.clearHistory(this.renderer);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Full-res / canvas-res targets: only touched on a real canvas resize (a
|
|
757
|
+
// renderScale step leaves them alone, so TAA keeps its resolved history).
|
|
758
|
+
if (canvasChanged) {
|
|
759
|
+
this.gbuffer.setSize(this._width, this._height);
|
|
760
|
+
// Quarter-canvas fog: low-frequency and reconverges instantly, so a plain
|
|
761
|
+
// reallocation + clear is fine.
|
|
762
|
+
this.volumetricPass.setSize(this._volW, this._volH);
|
|
763
|
+
this.volumetricPass.clearHistory(this.renderer);
|
|
764
|
+
// TAA history is full-canvas-res: carry it across the resize (linear
|
|
765
|
+
// resample) so the ladder step doesn't reset AA.
|
|
766
|
+
this.taaPass.resizeCarry(this.renderer, this._copyPass, this._width, this._height);
|
|
767
|
+
this._sceneColor.setSize(this._width, this._height);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ---- adaptive quality governor: continuous dynamic resolution scaling ----
|
|
772
|
+
// Measures real call-to-call frame time (EMA) and steers renderScale
|
|
773
|
+
// proportionally toward targetFps, in 0.05 steps with a cooldown so target
|
|
774
|
+
// reallocation and accumulation resets stay rare. Lighting cost ≈ scale², so
|
|
775
|
+
// the correction uses a damped power of the error. Limitation: under a vsync
|
|
776
|
+
// cap the frame time can't reveal headroom, so upscaling only happens when
|
|
777
|
+
// frames are measurably faster than the target — it never thrashes.
|
|
778
|
+
_adaptQuality() {
|
|
779
|
+
const now = performance.now();
|
|
780
|
+
const dt = this._qLastT == null ? null : now - this._qLastT;
|
|
781
|
+
this._qLastT = now;
|
|
782
|
+
if (dt == null || dt > 100) return; // first frame or hidden-tab stall
|
|
783
|
+
this._qEma = this._qEma == null ? dt : this._qEma * 0.9 + dt * 0.1;
|
|
784
|
+
// Calmness: normally 2s between changes. When the last two steps reversed
|
|
785
|
+
// direction the governor is hunting the boundary, so hold for 5s AND widen
|
|
786
|
+
// the "comfortable" deadband — both push it to commit to a level instead of
|
|
787
|
+
// ping-ponging (each ping-pong is a target reallocation).
|
|
788
|
+
const cooldown = this._qOscillating ? 5000 : 2000;
|
|
789
|
+
if (now - this._qLastChange < cooldown) return;
|
|
790
|
+
|
|
791
|
+
const ratio = this._qEma / (1000 / this.targetFps);
|
|
792
|
+
const dbLo = this._qOscillating ? 0.6 : 0.8;
|
|
793
|
+
const dbHi = this._qOscillating ? 1.24 : 1.12;
|
|
794
|
+
if (ratio < dbHi && ratio > dbLo) return; // comfortable — leave it alone
|
|
795
|
+
|
|
796
|
+
let s = this._renderScale * Math.pow(1 / ratio, 0.35);
|
|
797
|
+
s = Math.round(Math.min(1, Math.max(0.2, s)) * 20) / 20; // 0.05 steps
|
|
798
|
+
|
|
799
|
+
// When we're fast, give back the deepest lever FIRST: restore canvas scale
|
|
800
|
+
// one step before touching renderScale, since canvas is the coarsest/most
|
|
801
|
+
// valuable resolution to recover and it's quadratic on every pass.
|
|
802
|
+
if (ratio < dbLo && this.canvasScaleHook && this._canvasLevelIdx > 0) {
|
|
803
|
+
this._canvasLevelIdx--;
|
|
804
|
+
this.canvasScaleHook(RealtimeRaytracer.CANVAS_LEVELS[this._canvasLevelIdx]);
|
|
805
|
+
this._recordChange(1, now); // restoring resolution = quality up
|
|
806
|
+
this._qEma = null; // cost profile changed — measure fresh
|
|
807
|
+
console.info(
|
|
808
|
+
`three-realtime-rt: adaptive quality → ${Math.round(
|
|
809
|
+
RealtimeRaytracer.CANVAS_LEVELS[this._canvasLevelIdx] * 100
|
|
810
|
+
)}% canvas`
|
|
811
|
+
);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// When we're slow and renderScale has already bottomed out (clamped to its
|
|
816
|
+
// 0.2 floor while we're already near it), step DOWN the canvas ladder — the
|
|
817
|
+
// deepest, quadratic-on-every-pass lever — instead of a no-op renderScale.
|
|
818
|
+
if (
|
|
819
|
+
ratio > dbHi &&
|
|
820
|
+
s <= 0.2 &&
|
|
821
|
+
this._renderScale <= 0.25 &&
|
|
822
|
+
this.canvasScaleHook &&
|
|
823
|
+
this._canvasLevelIdx < RealtimeRaytracer.CANVAS_LEVELS.length - 1
|
|
824
|
+
) {
|
|
825
|
+
this._canvasLevelIdx++;
|
|
826
|
+
this.canvasScaleHook(RealtimeRaytracer.CANVAS_LEVELS[this._canvasLevelIdx]);
|
|
827
|
+
this._recordChange(-1, now); // deeper downscale = quality down
|
|
828
|
+
this._qEma = null; // cost profile changed — measure fresh
|
|
829
|
+
console.info(
|
|
830
|
+
`three-realtime-rt: adaptive quality → ${Math.round(
|
|
831
|
+
RealtimeRaytracer.CANVAS_LEVELS[this._canvasLevelIdx] * 100
|
|
832
|
+
)}% canvas`
|
|
833
|
+
);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (Math.abs(s - this._renderScale) < 0.045) return;
|
|
838
|
+
|
|
839
|
+
const dir = Math.sign(s - this._renderScale);
|
|
840
|
+
const q = RealtimeRaytracer._qualityFor(s);
|
|
841
|
+
this.denoiseIterations = q.denoiseIterations;
|
|
842
|
+
this.stochasticLights = q.stochasticLights;
|
|
843
|
+
this.renderScale = s; // reallocates targets, carrying history over (no reset)
|
|
844
|
+
this._recordChange(dir, now);
|
|
845
|
+
this._qEma = null; // cost profile changed — measure fresh
|
|
846
|
+
console.info(
|
|
847
|
+
`three-realtime-rt: adaptive quality → ${Math.round(s * 100)}% lighting, ` +
|
|
848
|
+
`${q.denoiseIterations} denoise passes, ` +
|
|
849
|
+
`${q.stochasticLights ? "stochastic" : "full"} direct light`
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Record a committed quality step and update oscillation state: two
|
|
854
|
+
// consecutive steps in OPPOSITE directions mean the governor is hunting the
|
|
855
|
+
// frame-time boundary (drives the wider deadband + longer cooldown above).
|
|
856
|
+
_recordChange(dir, now) {
|
|
857
|
+
this._qOscillating = dir !== 0 && this._qLastDir !== 0 && dir !== this._qLastDir;
|
|
858
|
+
if (dir !== 0) this._qLastDir = dir;
|
|
859
|
+
this._qLastChange = now;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
render(scene, camera) {
|
|
863
|
+
if (!this.supported) {
|
|
864
|
+
this.renderer.render(scene, camera);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (this.adaptiveQuality) this._adaptQuality();
|
|
868
|
+
if (this.overloadProtection) this._overloadBrake();
|
|
869
|
+
if (!this.compiled) this.compileScene(scene);
|
|
870
|
+
|
|
871
|
+
this.frame += 1;
|
|
872
|
+
camera.updateMatrixWorld();
|
|
873
|
+
|
|
874
|
+
// --- sub-pixel jitter (TAA): offset the projection a fraction of a pixel
|
|
875
|
+
// each frame so the whole pipeline (raster G-buffer + traced lighting)
|
|
876
|
+
// samples slightly different positions; the TAA resolve averages them into
|
|
877
|
+
// supersampled edges. Restored after the frame so callers see a clean matrix.
|
|
878
|
+
const proj = camera.projectionMatrix;
|
|
879
|
+
const savedProj0 = proj.elements[0];
|
|
880
|
+
const savedProj5 = proj.elements[5];
|
|
881
|
+
const savedProj8 = proj.elements[8];
|
|
882
|
+
const savedProj9 = proj.elements[9];
|
|
883
|
+
// Overscan: widen the frustum so the padded image covers proportionally
|
|
884
|
+
// more FOV. Scaling elements[0]/[5] (the x/y projection scale) by 1/padFactor
|
|
885
|
+
// makes each axis see (1 + 2·overscan)× as much — the extra content lands in
|
|
886
|
+
// the padding that the final crop discards. Applied like the TAA jitter
|
|
887
|
+
// (temporary; the caller's matrix is restored at frame end), and BEFORE the
|
|
888
|
+
// jitter so the two compose. Previous-frame matrices captured below are the
|
|
889
|
+
// widened ones, keeping temporal reprojection consistent in padded space.
|
|
890
|
+
if (this._overscan > 0) {
|
|
891
|
+
const inv = 1 / this._padFactor;
|
|
892
|
+
proj.elements[0] *= inv;
|
|
893
|
+
proj.elements[5] *= inv;
|
|
894
|
+
}
|
|
895
|
+
// Debug views (outputMode != 0) bypass the TAA resolve, so skip the jitter
|
|
896
|
+
// too — otherwise the raw buffers visibly shake.
|
|
897
|
+
if (this.taa && this.outputMode === 0) {
|
|
898
|
+
this._jitterIndex = (this._jitterIndex + 1) % 16;
|
|
899
|
+
const jx = (halton(this._jitterIndex + 1, 2) - 0.5) * 2 / this._width;
|
|
900
|
+
const jy = (halton(this._jitterIndex + 1, 3) - 0.5) * 2 / this._height;
|
|
901
|
+
proj.elements[8] += jx;
|
|
902
|
+
proj.elements[9] += jy;
|
|
903
|
+
// Where this jitter moves the image, in UV space: elements[8/9] multiply
|
|
904
|
+
// view-space z (= -w), so NDC shifts by -j → UV by -j/2. The TAA resolve
|
|
905
|
+
// uses this to unjitter its input back onto a stable grid.
|
|
906
|
+
this._jitterUv.set(-jx * 0.5, -jy * 0.5);
|
|
907
|
+
} else {
|
|
908
|
+
this._jitterUv.set(0, 0);
|
|
909
|
+
}
|
|
910
|
+
// View-projection actually used to render this frame (jittered if TAA on).
|
|
911
|
+
this._jitteredViewProj
|
|
912
|
+
.copy(proj)
|
|
913
|
+
.multiply(camera.matrixWorldInverse);
|
|
914
|
+
|
|
915
|
+
const prevAutoClear = this.renderer.autoClear;
|
|
916
|
+
this.renderer.autoClear = false;
|
|
917
|
+
|
|
918
|
+
if (this._needsClear) {
|
|
919
|
+
this.rtPass.clearHistory(this.renderer);
|
|
920
|
+
this.volumetricPass.clearHistory(this.renderer);
|
|
921
|
+
this.restirPass.clearHistory(this.renderer);
|
|
922
|
+
this._needsClear = false;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// 1. rasterize G-buffer (ping-pongs internally; previous frame kept)
|
|
926
|
+
this.gbuffer.render(this.renderer, scene, camera);
|
|
927
|
+
|
|
928
|
+
// 2. ray traced lighting with temporal reprojection
|
|
929
|
+
const rtU = this.rtPass.material.uniforms;
|
|
930
|
+
rtU.uEnvColor.value.copy(this.envColor);
|
|
931
|
+
rtU.uEnvIntensity.value = this.envIntensity;
|
|
932
|
+
rtU.uEps.value = this.eps;
|
|
933
|
+
rtU.uTemporalReprojection.value = this.temporalReprojection;
|
|
934
|
+
rtU.uMaxHistory.value = this.maxHistory;
|
|
935
|
+
rtU.uFireflyClamp.value = this.fireflyClamp > 0 ? this.fireflyClamp : 1e6;
|
|
936
|
+
rtU.uGIEnabled.value = this.gi;
|
|
937
|
+
rtU.uGIHalfRate.value = this.giHalfRate;
|
|
938
|
+
rtU.uEmissiveCount.value = this.emissiveNEE ? this.compiled.emissiveTriCount : 0;
|
|
939
|
+
rtU.uEmissiveCDF.value = this.emissiveImportance;
|
|
940
|
+
rtU.uReflEnabled.value = this.reflections;
|
|
941
|
+
rtU.uRefrEnabled.value = this.refraction;
|
|
942
|
+
rtU.uBlendEnabled.value = this.transparency;
|
|
943
|
+
rtU.uIor.value = this.ior;
|
|
944
|
+
rtU.uLightStochastic.value = this.stochasticLights;
|
|
945
|
+
rtU.uSkyEnabled.value = this.sky.enabled;
|
|
946
|
+
rtU.uSunDir.value.copy(this.sky.sunDir);
|
|
947
|
+
rtU.uSunColor.value.copy(this.sky.sunColor);
|
|
948
|
+
rtU.uSkyZenith.value.copy(this.sky.zenith);
|
|
949
|
+
rtU.uSkyHorizon.value.copy(this.sky.horizon);
|
|
950
|
+
rtU.uSkyIntensity.value = this.sky.intensity;
|
|
951
|
+
rtU.uPrevViewProj.value.copy(this._prevViewProj);
|
|
952
|
+
rtU.uViewProj.value.copy(this._jitteredViewProj);
|
|
953
|
+
rtU.uCameraPos.value.copy(camera.getWorldPosition(this._camWorldPos));
|
|
954
|
+
|
|
955
|
+
// 2a. ReSTIR reservoirs (ALU-only, no rays) — the lighting pass shades
|
|
956
|
+
// each pixel's winner with a single visibility ray.
|
|
957
|
+
let reservoirTex = null;
|
|
958
|
+
if (this.restir) {
|
|
959
|
+
// Emissive candidates follow the emissiveNEE toggle — without this the
|
|
960
|
+
// reservoir keeps proposing panel samples the user has switched off.
|
|
961
|
+
this.restirPass.setEmissiveCount(this.emissiveNEE ? this.compiled.emissiveTriCount : 0);
|
|
962
|
+
reservoirTex = this.restirPass.render(
|
|
963
|
+
this.renderer,
|
|
964
|
+
this.gbuffer,
|
|
965
|
+
this._prevViewProj,
|
|
966
|
+
this._camWorldPos,
|
|
967
|
+
this.frame,
|
|
968
|
+
this.eps
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
let { irradiance, specular } = this.rtPass.render(this.renderer, this.gbuffer, this.frame, reservoirTex);
|
|
972
|
+
|
|
973
|
+
// 3. denoise (display-only: history keeps accumulating raw samples)
|
|
974
|
+
if (this.denoise && this.denoiseIterations > 0) {
|
|
975
|
+
irradiance = this.denoisePass.render(
|
|
976
|
+
this.renderer,
|
|
977
|
+
irradiance,
|
|
978
|
+
this.gbuffer,
|
|
979
|
+
this._camWorldPos,
|
|
980
|
+
this.eps,
|
|
981
|
+
this.denoiseIterations
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// 3a. light denoise on the specular buffer. specKeep (DenoisePass) already
|
|
986
|
+
// spares near-mirror pixels, so reflections stay crisp; capped at 2 passes
|
|
987
|
+
// to avoid washing out sharp dielectric highlights.
|
|
988
|
+
let specularTex = this.specular ? specular : null;
|
|
989
|
+
if (specularTex && this.denoise && this.denoiseIterations > 0) {
|
|
990
|
+
specularTex = this.specDenoisePass.render(
|
|
991
|
+
this.renderer,
|
|
992
|
+
specularTex,
|
|
993
|
+
this.gbuffer,
|
|
994
|
+
this._camWorldPos,
|
|
995
|
+
this.eps,
|
|
996
|
+
Math.min(this.denoiseIterations, 2)
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// 3b. volumetric single-scatter (optional): one BVH-shadowed light sample
|
|
1001
|
+
// per lighting pixel along the camera ray, accumulated temporally. The
|
|
1002
|
+
// composite adds the result before fog and tonemap.
|
|
1003
|
+
let volumetricTex = null;
|
|
1004
|
+
// Runs when a global density is set OR when localized zones are present
|
|
1005
|
+
// (a zone can add fog even where the global term is 0).
|
|
1006
|
+
const hasZones = this.volumetric.zones && this.volumetric.zones.length > 0;
|
|
1007
|
+
if (
|
|
1008
|
+
this.volumetric.enabled &&
|
|
1009
|
+
this.outputMode === 0 &&
|
|
1010
|
+
(this.volumetric.density > 0 || hasZones)
|
|
1011
|
+
) {
|
|
1012
|
+
volumetricTex = this.volumetricPass.render(
|
|
1013
|
+
this.renderer,
|
|
1014
|
+
this.gbuffer,
|
|
1015
|
+
this._prevViewProj,
|
|
1016
|
+
this._camWorldPos,
|
|
1017
|
+
this.frame,
|
|
1018
|
+
this.eps,
|
|
1019
|
+
this.volumetric.density,
|
|
1020
|
+
this.volumetric.maxDist,
|
|
1021
|
+
this.volumetric.zones
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// 4. composite (bilateral upsample if lighting is sub-res). With TAA on,
|
|
1026
|
+
// render to an offscreen colour target so the resolve can accumulate it;
|
|
1027
|
+
// otherwise straight to screen. Debug views bypass TAA (raw buffers).
|
|
1028
|
+
const useTaa = this.taa && this.outputMode === 0;
|
|
1029
|
+
const cU = this.composite.material.uniforms;
|
|
1030
|
+
cU.uOutputMode.value = this.outputMode;
|
|
1031
|
+
cU.uUpsample.value = this._renderScale < 1;
|
|
1032
|
+
cU.uIrrTexelSize.value.set(1 / this._scaledW, 1 / this._scaledH);
|
|
1033
|
+
cU.uCameraPos.value.copy(this._camWorldPos);
|
|
1034
|
+
cU.uFogEnabled.value = this.fog.enabled;
|
|
1035
|
+
cU.uFogColor.value.copy(this.fog.color);
|
|
1036
|
+
cU.uFogDensity.value = this.fog.density;
|
|
1037
|
+
cU.uSkyEnabled.value = this.sky.enabled;
|
|
1038
|
+
cU.uInvViewProj.value.copy(this._invViewProj.copy(this._jitteredViewProj).invert());
|
|
1039
|
+
cU.uSunDir.value.copy(this.sky.sunDir);
|
|
1040
|
+
cU.uSunColor.value.copy(this.sky.sunColor);
|
|
1041
|
+
cU.uSkyZenith.value.copy(this.sky.zenith);
|
|
1042
|
+
cU.uSkyHorizon.value.copy(this.sky.horizon);
|
|
1043
|
+
cU.uSkyIntensity.value = this.sky.intensity;
|
|
1044
|
+
cU.uVolumetric.value = volumetricTex;
|
|
1045
|
+
cU.uVolEnabled.value = volumetricTex !== null;
|
|
1046
|
+
cU.uVolTexelSize.value.set(1 / this._volW, 1 / this._volH);
|
|
1047
|
+
// With TAA on we composite into the padded offscreen target (no crop — the
|
|
1048
|
+
// TAA copy crops on its way to screen). Without TAA the composite IS the
|
|
1049
|
+
// on-screen draw, so it applies the central crop itself.
|
|
1050
|
+
this.composite.render(
|
|
1051
|
+
this.renderer,
|
|
1052
|
+
irradiance,
|
|
1053
|
+
this.gbuffer,
|
|
1054
|
+
scene.background,
|
|
1055
|
+
useTaa ? this._sceneColor : null,
|
|
1056
|
+
specularTex,
|
|
1057
|
+
useTaa ? null : this._crop
|
|
1058
|
+
);
|
|
1059
|
+
|
|
1060
|
+
// 5. temporal anti-aliasing resolve (jitter + neighbourhood-clamped history).
|
|
1061
|
+
if (useTaa) {
|
|
1062
|
+
this.taaPass.render(
|
|
1063
|
+
this.renderer,
|
|
1064
|
+
this._sceneColor.texture,
|
|
1065
|
+
this.gbuffer,
|
|
1066
|
+
this._prevViewProj, // last frame's jittered VP
|
|
1067
|
+
this._jitterUv,
|
|
1068
|
+
this._prevJitterUv,
|
|
1069
|
+
this.taaBlend,
|
|
1070
|
+
null, // outputTarget: null = screen (the final on-screen draw)
|
|
1071
|
+
this._crop // central-crop the padded resolve onto the canvas
|
|
1072
|
+
);
|
|
1073
|
+
} else if (this.taa) {
|
|
1074
|
+
// In a debug view: keep history fresh so switching back doesn't ghost.
|
|
1075
|
+
this.taaPass.reset();
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
this.renderer.autoClear = prevAutoClear;
|
|
1079
|
+
|
|
1080
|
+
// Restore the caller's projection matrix (remove this frame's jitter + the
|
|
1081
|
+
// overscan widening) so callers always see their own clean matrix.
|
|
1082
|
+
proj.elements[0] = savedProj0;
|
|
1083
|
+
proj.elements[5] = savedProj5;
|
|
1084
|
+
proj.elements[8] = savedProj8;
|
|
1085
|
+
proj.elements[9] = savedProj9;
|
|
1086
|
+
|
|
1087
|
+
// Record this frame's (jittered) view-projection + jitter for next frame.
|
|
1088
|
+
this._prevViewProj.copy(this._jitteredViewProj);
|
|
1089
|
+
this._prevJitterUv.copy(this._jitterUv);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
dispose() {
|
|
1093
|
+
if (!this.supported) return;
|
|
1094
|
+
this.gbuffer.dispose();
|
|
1095
|
+
this.rtPass.dispose();
|
|
1096
|
+
this.denoisePass.dispose();
|
|
1097
|
+
this.specDenoisePass.dispose();
|
|
1098
|
+
this.composite.dispose();
|
|
1099
|
+
this.taaPass.dispose();
|
|
1100
|
+
this.volumetricPass.dispose();
|
|
1101
|
+
this.restirPass.dispose();
|
|
1102
|
+
this._sceneColor.dispose();
|
|
1103
|
+
this._copyPass.dispose();
|
|
1104
|
+
if (this.compiled) this.compiled.dispose();
|
|
1105
|
+
}
|
|
1106
|
+
}
|