@zephyr3d/scene 0.9.21 → 0.9.22

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,644 @@
1
+ import { AbstractPostEffect, PostEffectLayer } from './posteffect.js';
2
+ import { linearToGamma } from '../shaders/misc.js';
3
+ import { screenSpaceRayTracing_HiZ, screenSpaceRayTracing_Linear2D } from '../shaders/ssr.js';
4
+ import { SSGI_rand2, SSGI_cosineSampleHemisphere } from '../shaders/ssgi.js';
5
+ import { Vector2, Matrix4x4, Vector4 } from '@zephyr3d/base';
6
+ import { copyTexture, fetchSampler } from '../utility/misc.js';
7
+ import { BilateralBlurBlitter } from '../blitter/bilateralblur.js';
8
+ import { ShaderHelper } from '../material/shader/helper.js';
9
+ import '../material/lambert.js';
10
+ import '../material/blinn.js';
11
+ import '../material/unlit.js';
12
+ import '../material/particle.js';
13
+ import '../material/subsurfaceprofile.js';
14
+ import '../material/skin.js';
15
+ import '@zephyr3d/device';
16
+ import '../values.js';
17
+ import '../material/meshmaterial.js';
18
+ import '../material/grassmaterial.js';
19
+ import '../material/terrain-cm.js';
20
+ import '../material/pbrmr.js';
21
+ import '../material/pbrsg.js';
22
+ import '../material/mtoon.js';
23
+ import '../material/pbrblueprint.js';
24
+ import '../material/sprite.js';
25
+ import '../material/sprite_std.js';
26
+ import '../utility/blueprint/material/texture.js';
27
+ import { RGHistoryResources } from '../render/rendergraph/history_resources.js';
28
+ import { FrameResources } from '../render/rendergraph/blackboard.js';
29
+
30
+ /**
31
+ * SSGI post effect
32
+ *
33
+ * @remarks
34
+ * Screen space global illumination: gathers one-bounce indirect diffuse
35
+ * lighting by cosine-weighted hemisphere ray marching against the scene
36
+ * depth buffer (Hi-Z accelerated when available), then denoises the result
37
+ * with a bilateral blur and optional temporal accumulation before adding
38
+ * it to the scene color. Internal used in light pass.
39
+ *
40
+ * @internal
41
+ */ class SSGI extends AbstractPostEffect {
42
+ static _tracePrograms = {};
43
+ static _combineProgram;
44
+ static _temporalProgram;
45
+ static _blurBlitterH = null;
46
+ static _blurBlitterV = null;
47
+ _traceBindGroups;
48
+ _combineBindGroup;
49
+ _temporalBindGroup;
50
+ /**
51
+ * Creates an instance of SSGI post effect
52
+ */ constructor(){
53
+ super();
54
+ this._layer = PostEffectLayer.opaque;
55
+ this._traceBindGroups = {};
56
+ this._combineBindGroup = null;
57
+ this._temporalBindGroup = null;
58
+ }
59
+ /** {@inheritDoc AbstractPostEffect.requireLinearDepthTexture} */ requireLinearDepthTexture() {
60
+ return true;
61
+ }
62
+ /** {@inheritDoc AbstractPostEffect.requireDepthAttachment} */ requireDepthAttachment() {
63
+ return true;
64
+ }
65
+ /** {@inheritDoc AbstractPostEffect.requireMotionVectorTexture} */ requireMotionVectorTexture(ctx) {
66
+ return !!ctx.camera.ssgiTemporal;
67
+ }
68
+ /**
69
+ * Declares SSGI's internal steps (trace, optional bilateral blur, optional
70
+ * temporal resolve, combine) as individual render graph passes.
71
+ */ setup(s) {
72
+ const { graph, ctx, history } = s;
73
+ // SSGI needs the surface normal MRT and dynamic loops; neither is
74
+ // available on WebGL1. Pass the input through untouched in that case.
75
+ if (ctx.device.type === 'webgl' || !s.blackboard.get(FrameResources.SSRNormal)) {
76
+ return s.input;
77
+ }
78
+ const camera = ctx.camera;
79
+ const halfRes = !!camera.ssgiHalfResolution;
80
+ const traceWidth = Math.max(1, halfRes ? s.width >> 1 : s.width);
81
+ const traceHeight = Math.max(1, halfRes ? s.height >> 1 : s.height);
82
+ const linearDepthHandle = s.blackboard.get(FrameResources.LinearDepth);
83
+ const texDesc = {
84
+ format: 'rgba16f',
85
+ sizeMode: 'absolute',
86
+ width: traceWidth,
87
+ height: traceHeight
88
+ };
89
+ const readCommon = (builder)=>{
90
+ if (linearDepthHandle) {
91
+ builder.read(linearDepthHandle);
92
+ }
93
+ // Normal MRT, HiZ and the scene color are reached through DrawContext
94
+ // fields; the dependency list carries their handles.
95
+ for (const dep of s.dependencies){
96
+ builder.read(dep);
97
+ }
98
+ };
99
+ const getLinearDepth = (rg)=>linearDepthHandle ? rg.getTexture(linearDepthHandle) : ctx.linearDepthTexture;
100
+ // 1. Trace cosine-weighted hemisphere rays against the depth buffer and
101
+ // gather the radiance at the hit points.
102
+ const traceHandle = graph.addPass('SSGI:Trace', (builder)=>{
103
+ builder.read(s.input);
104
+ readCommon(builder);
105
+ const out = builder.createTexture({
106
+ ...texDesc,
107
+ label: 'SSGI:trace'
108
+ });
109
+ const fb = builder.createFramebuffer({
110
+ label: 'SSGI:traceFB',
111
+ width: traceWidth,
112
+ height: traceHeight,
113
+ colorAttachments: out,
114
+ depthAttachment: null,
115
+ ignoreDepthStencil: true
116
+ });
117
+ builder.setExecute((rg)=>{
118
+ const device = ctx.device;
119
+ device.pushDeviceStates();
120
+ try {
121
+ device.setFramebuffer(rg.getFramebuffer(fb));
122
+ this.trace(ctx, rg.getTexture(s.input), getLinearDepth(rg));
123
+ } finally{
124
+ device.popDeviceStates();
125
+ }
126
+ });
127
+ return out;
128
+ });
129
+ // 2. Temporal accumulation BEFORE the spatial blur: accumulating the raw
130
+ // per-pixel estimate raises the effective sample count over time, and the
131
+ // blur then only has to clean the residual noise. (Blurring first would
132
+ // turn per-frame noise into large low-frequency blobs that flicker.)
133
+ // The history stores the pre-blur accumulated signal.
134
+ const wantTemporal = !!camera.ssgiTemporal;
135
+ const motionVectorHandle = wantTemporal ? s.blackboard.get(FrameResources.MotionVector) : null;
136
+ const historySize = {
137
+ width: traceWidth,
138
+ height: traceHeight
139
+ };
140
+ const prevRadianceHandle = wantTemporal && history && motionVectorHandle ? history.importPreviousIfCompatible(graph, RGHistoryResources.SSGI_RADIANCE, texDesc, historySize) : null;
141
+ const prevMotionVectorHandle = wantTemporal && history && motionVectorHandle ? history.importPreviousIfCompatible(graph, RGHistoryResources.SSGI_MOTION_VECTOR, {
142
+ format: 'rgba16f',
143
+ sizeMode: 'absolute',
144
+ width: s.width,
145
+ height: s.height
146
+ }, {
147
+ width: s.width,
148
+ height: s.height
149
+ }) : null;
150
+ let radianceHandle = traceHandle;
151
+ const canTemporal = !!(motionVectorHandle && prevRadianceHandle && prevMotionVectorHandle);
152
+ if (canTemporal) {
153
+ radianceHandle = graph.addPass('SSGI:Temporal', (builder)=>{
154
+ builder.read(traceHandle);
155
+ builder.read(prevRadianceHandle);
156
+ builder.read(prevMotionVectorHandle);
157
+ builder.read(motionVectorHandle);
158
+ readCommon(builder);
159
+ const out = builder.createTexture({
160
+ ...texDesc,
161
+ label: 'SSGI:temporal'
162
+ });
163
+ const fb = builder.createFramebuffer({
164
+ label: 'SSGI:temporalFB',
165
+ width: traceWidth,
166
+ height: traceHeight,
167
+ colorAttachments: out,
168
+ depthAttachment: null,
169
+ ignoreDepthStencil: true
170
+ });
171
+ builder.setExecute((rg)=>{
172
+ const device = ctx.device;
173
+ device.pushDeviceStates();
174
+ try {
175
+ this.temporal(ctx, rg.getTexture(traceHandle), rg.getTexture(prevRadianceHandle), rg.getTexture(prevMotionVectorHandle), rg.getFramebuffer(fb));
176
+ } finally{
177
+ device.popDeviceStates();
178
+ }
179
+ });
180
+ return out;
181
+ });
182
+ }
183
+ // The accumulated (pre-blur) signal is what feeds next frame's history.
184
+ const accumulatedHandle = radianceHandle;
185
+ // 3. Bilateral blur (horizontal + vertical) guided by scene depth cleans
186
+ // the residual noise left after accumulation.
187
+ if (camera.ssgiBlurKernelSize > 0) {
188
+ const blurInputHandle = radianceHandle;
189
+ radianceHandle = graph.addPass('SSGI:Blur', (builder)=>{
190
+ builder.read(blurInputHandle);
191
+ readCommon(builder);
192
+ const middle = builder.createTexture({
193
+ ...texDesc,
194
+ label: 'SSGI:blurH'
195
+ });
196
+ const out = builder.createTexture({
197
+ ...texDesc,
198
+ label: 'SSGI:blur'
199
+ });
200
+ const middleFB = builder.createFramebuffer({
201
+ label: 'SSGI:blurHFB',
202
+ width: traceWidth,
203
+ height: traceHeight,
204
+ colorAttachments: middle,
205
+ depthAttachment: null,
206
+ ignoreDepthStencil: true
207
+ });
208
+ const outFB = builder.createFramebuffer({
209
+ label: 'SSGI:blurFB',
210
+ width: traceWidth,
211
+ height: traceHeight,
212
+ colorAttachments: out,
213
+ depthAttachment: null,
214
+ ignoreDepthStencil: true
215
+ });
216
+ builder.setExecute((rg)=>{
217
+ const device = ctx.device;
218
+ device.pushDeviceStates();
219
+ try {
220
+ const depthTex = getLinearDepth(rg);
221
+ const blitterH = SSGI._blurBlitterH = SSGI._blurBlitterH ?? new BilateralBlurBlitter(false);
222
+ this.blurPass(ctx, blitterH, depthTex, rg.getTexture(blurInputHandle), rg.getFramebuffer(middleFB));
223
+ const blitterV = SSGI._blurBlitterV = SSGI._blurBlitterV ?? new BilateralBlurBlitter(true);
224
+ this.blurPass(ctx, blitterV, depthTex, rg.getTexture(middle), rg.getFramebuffer(outFB));
225
+ } finally{
226
+ device.popDeviceStates();
227
+ }
228
+ });
229
+ return out;
230
+ });
231
+ }
232
+ // 4. Upsample, modulate by the albedo approximation and add to the scene
233
+ // color; queue history commits.
234
+ const finalRadianceHandle = radianceHandle;
235
+ return graph.addPass('PostEffect:SSGI', (builder)=>{
236
+ builder.read(s.input);
237
+ builder.read(finalRadianceHandle);
238
+ if (accumulatedHandle !== finalRadianceHandle) {
239
+ builder.read(accumulatedHandle);
240
+ }
241
+ if (motionVectorHandle) {
242
+ builder.read(motionVectorHandle);
243
+ }
244
+ readCommon(builder);
245
+ const output = s.createOutput(builder, {
246
+ needDepthAttachment: true
247
+ });
248
+ builder.setExecute((rg)=>{
249
+ const device = ctx.device;
250
+ device.pushDeviceStates();
251
+ try {
252
+ device.setFramebuffer(output.framebuffer ? rg.getFramebuffer(output.framebuffer) : null);
253
+ const inputTex = rg.getTexture(s.input);
254
+ const radianceTex = rg.getTexture(finalRadianceHandle);
255
+ copyTexture(inputTex, device.getFramebuffer(), fetchSampler('clamp_nearest_nomip'), AbstractPostEffect.getDefaultRenderState(ctx, 'eq'));
256
+ this.combine(ctx, inputTex, radianceTex, output.srgbOutput);
257
+ // Commit history whenever temporal is wanted - even on frames where
258
+ // the temporal pass could not run yet (no compatible history). This
259
+ // seeds the very first frame; gating the commit on canTemporal
260
+ // would deadlock: no history -> no temporal -> no commit -> no
261
+ // history, forever.
262
+ if (wantTemporal && history && motionVectorHandle) {
263
+ // Commit the PRE-blur accumulated signal: feeding the blurred
264
+ // result back would compound the blur every frame.
265
+ const accumulatedTex = rg.getTexture(accumulatedHandle);
266
+ history.queueRetainedCommit(RGHistoryResources.SSGI_RADIANCE, {
267
+ format: accumulatedTex.format,
268
+ sizeMode: 'absolute',
269
+ width: accumulatedTex.width,
270
+ height: accumulatedTex.height
271
+ }, {
272
+ width: accumulatedTex.width,
273
+ height: accumulatedTex.height
274
+ }, accumulatedTex);
275
+ if (ctx.motionVectorTexture) {
276
+ history.queueRetainedCommit(RGHistoryResources.SSGI_MOTION_VECTOR, {
277
+ format: ctx.motionVectorTexture.format,
278
+ sizeMode: 'absolute',
279
+ width: ctx.motionVectorTexture.width,
280
+ height: ctx.motionVectorTexture.height
281
+ }, {
282
+ width: ctx.motionVectorTexture.width,
283
+ height: ctx.motionVectorTexture.height
284
+ }, ctx.motionVectorTexture);
285
+ }
286
+ }
287
+ } finally{
288
+ device.popDeviceStates();
289
+ }
290
+ });
291
+ return output.color;
292
+ });
293
+ }
294
+ /** @internal */ blurPass(ctx, blitter, depthTex, srcTex, fbTo) {
295
+ const camera = ctx.camera;
296
+ blitter.kernelRadius = Math.max(1, camera.ssgiBlurKernelSize >> 0) - 1 >> 1;
297
+ blitter.stdDev = camera.ssgiBlurStdDev;
298
+ blitter.size = new Vector2(srcTex.width, srcTex.height);
299
+ blitter.depthTex = depthTex;
300
+ blitter.depthCutoff = camera.ssgiBlurDepthCutoff;
301
+ blitter.blurSizeTex = null;
302
+ blitter.sampler = fetchSampler('clamp_nearest_nomip');
303
+ blitter.cameraNearFar.setXY(camera.getNearPlane(), camera.getFarPlane());
304
+ blitter.srgbOut = false;
305
+ blitter.blit(srcTex, fbTo, fetchSampler('clamp_linear_nomip'));
306
+ }
307
+ /** @internal */ trace(ctx, inputColorTexture, sceneDepthTexture) {
308
+ const device = ctx.device;
309
+ const hash = `${!!ctx.HiZTexture}:${!!ctx.SSRCalcThickness}`;
310
+ let program = SSGI._tracePrograms[hash];
311
+ if (program === undefined) {
312
+ const created = this._createTraceProgram(ctx);
313
+ if (!created) {
314
+ return;
315
+ }
316
+ program = created;
317
+ SSGI._tracePrograms[hash] = program;
318
+ }
319
+ let bindGroup = this._traceBindGroups[hash];
320
+ if (!bindGroup) {
321
+ bindGroup = device.createBindGroup(program.bindGroupLayouts[0]);
322
+ this._traceBindGroups[hash] = bindGroup;
323
+ }
324
+ const camera = ctx.camera;
325
+ const nearestSampler = fetchSampler('clamp_nearest');
326
+ const linearSampler = fetchSampler('clamp_linear');
327
+ const fb = device.getFramebuffer();
328
+ const traceWidth = fb.getWidth();
329
+ const traceHeight = fb.getHeight();
330
+ bindGroup.setTexture('colorTex', inputColorTexture, linearSampler);
331
+ bindGroup.setTexture('normalTex', ctx.SSRNormalTexture, nearestSampler);
332
+ bindGroup.setTexture('depthTex', sceneDepthTexture, nearestSampler);
333
+ bindGroup.setValue('cameraNearFar', new Vector2(camera.getNearPlane(), camera.getFarPlane()));
334
+ bindGroup.setValue('projMatrix', camera.getProjectionMatrix());
335
+ bindGroup.setValue('invProjMatrix', Matrix4x4.invert(camera.getProjectionMatrix()));
336
+ bindGroup.setValue('viewMatrix', camera.viewMatrix);
337
+ bindGroup.setValue('ssgiParams', new Vector4(camera.ssgiRadius, camera.ssgiMaxSteps, camera.ssgiThickness, camera.ssgiSamples));
338
+ // Advance the noise sequence only when the temporal resolve can converge
339
+ // it; a static pattern is less objectionable than unaccumulated flicker.
340
+ bindGroup.setValue('frameIndex', camera.ssgiTemporal ? device.frameInfo.frameCounter % 1024 : 0);
341
+ if (ctx.HiZTexture) {
342
+ bindGroup.setTexture('hizTex', ctx.HiZTexture, nearestSampler);
343
+ bindGroup.setValue('depthMipLevels', ctx.HiZTexture.mipLevelCount);
344
+ bindGroup.setValue('targetSize', new Vector4(traceWidth, traceHeight, ctx.HiZTexture.width, ctx.HiZTexture.height));
345
+ } else {
346
+ bindGroup.setValue('ssgiStride', camera.ssgiStride);
347
+ bindGroup.setValue('targetSize', new Vector4(traceWidth, traceHeight, sceneDepthTexture.width, sceneDepthTexture.height));
348
+ }
349
+ bindGroup.setValue('flip', this.needFlip(device) ? 1 : 0);
350
+ device.setProgram(program);
351
+ device.setBindGroup(0, bindGroup);
352
+ this.drawFullscreenQuad(AbstractPostEffect.getDefaultRenderState(ctx, 'always'));
353
+ }
354
+ /** @internal */ temporal(ctx, currentRadianceTex, prevRadianceTex, prevMotionVectorTex, outFramebuffer) {
355
+ const device = ctx.device;
356
+ let program = SSGI._temporalProgram;
357
+ if (!program) {
358
+ program = this._createTemporalProgram(ctx);
359
+ SSGI._temporalProgram = program;
360
+ }
361
+ if (!this._temporalBindGroup) {
362
+ this._temporalBindGroup = device.createBindGroup(program.bindGroupLayouts[0]);
363
+ }
364
+ this._temporalBindGroup.setTexture('historyColorTex', prevRadianceTex, fetchSampler('clamp_linear_nomip'));
365
+ this._temporalBindGroup.setTexture('currentColorTex', currentRadianceTex, fetchSampler('clamp_nearest_nomip'));
366
+ this._temporalBindGroup.setTexture('motionVector', ctx.motionVectorTexture, fetchSampler('clamp_linear_nomip'));
367
+ this._temporalBindGroup.setTexture('prevMotionVector', prevMotionVectorTex, fetchSampler('clamp_linear_nomip'));
368
+ this._temporalBindGroup.setValue('flip', this.needFlip(device) ? 1 : 0);
369
+ this._temporalBindGroup.setValue('texSize', new Vector2(currentRadianceTex.width, currentRadianceTex.height));
370
+ this._temporalBindGroup.setValue('temporalWeight', ctx.camera.ssgiTemporalWeight);
371
+ device.setFramebuffer(outFramebuffer);
372
+ device.setProgram(program);
373
+ device.setBindGroup(0, this._temporalBindGroup);
374
+ this.drawFullscreenQuad(AbstractPostEffect.getDefaultRenderState(ctx, 'always'));
375
+ }
376
+ /** @internal */ combine(ctx, inputColorTexture, radianceTex, srgbOut) {
377
+ const device = ctx.device;
378
+ let program = SSGI._combineProgram;
379
+ if (program === undefined) {
380
+ program = this._createCombineProgram(ctx);
381
+ SSGI._combineProgram = program;
382
+ }
383
+ if (!this._combineBindGroup) {
384
+ this._combineBindGroup = device.createBindGroup(program.bindGroupLayouts[0]);
385
+ }
386
+ this._combineBindGroup.setTexture('colorTex', inputColorTexture, fetchSampler('clamp_nearest'));
387
+ this._combineBindGroup.setTexture('radianceTex', radianceTex, fetchSampler('clamp_linear'));
388
+ this._combineBindGroup.setValue('ssgiIntensity', ctx.camera.ssgiIntensity);
389
+ this._combineBindGroup.setValue('targetSize', new Vector2(inputColorTexture.width, inputColorTexture.height));
390
+ this._combineBindGroup.setValue('flip', this.needFlip(device) ? 1 : 0);
391
+ this._combineBindGroup.setValue('srgbOut', srgbOut ? 1 : 0);
392
+ device.setProgram(program);
393
+ device.setBindGroup(0, this._combineBindGroup);
394
+ this.drawFullscreenQuad(AbstractPostEffect.getDefaultRenderState(ctx, 'gt'));
395
+ }
396
+ /** @internal */ _createTraceProgram(ctx) {
397
+ const program = ctx.device.buildRenderProgram({
398
+ vertex (pb) {
399
+ this.flip = pb.int().uniform(0);
400
+ this.$inputs.pos = pb.vec2().attrib('position');
401
+ this.$outputs.uv = pb.vec2();
402
+ pb.main(function() {
403
+ this.$builtins.position = pb.vec4(this.$inputs.pos, 1, 1);
404
+ this.$outputs.uv = pb.add(pb.mul(this.$inputs.pos.xy, 0.5), pb.vec2(0.5));
405
+ this.$if(pb.notEqual(this.flip, 0), function() {
406
+ this.$builtins.position.y = pb.neg(this.$builtins.position.y);
407
+ });
408
+ });
409
+ },
410
+ fragment (pb) {
411
+ this.colorTex = pb.tex2D().uniform(0);
412
+ this.normalTex = pb.tex2D().uniform(0);
413
+ this.depthTex = pb.tex2D().uniform(0);
414
+ this.cameraNearFar = pb.vec2().uniform(0);
415
+ this.projMatrix = pb.mat4().uniform(0);
416
+ this.invProjMatrix = pb.mat4().uniform(0);
417
+ this.viewMatrix = pb.mat4().uniform(0);
418
+ // (radius, maxIterations, thickness, numRays)
419
+ this.ssgiParams = pb.vec4().uniform(0);
420
+ this.frameIndex = pb.float().uniform(0);
421
+ this.targetSize = pb.vec4().uniform(0);
422
+ if (ctx.HiZTexture) {
423
+ this.hizTex = pb.tex2D().uniform(0);
424
+ this.depthMipLevels = pb.int().uniform(0);
425
+ } else {
426
+ this.ssgiStride = pb.float().uniform(0);
427
+ }
428
+ this.$outputs.outColor = pb.vec4();
429
+ pb.func('getPosition', [
430
+ pb.vec2('uv'),
431
+ pb.mat4('mat')
432
+ ], function() {
433
+ this.$l.linearDepth = ShaderHelper.sampleLinearDepth(this, this.depthTex, this.uv, 0);
434
+ this.$l.nonLinearDepth = pb.div(pb.sub(pb.div(this.cameraNearFar.x, this.linearDepth), this.cameraNearFar.y), pb.sub(this.cameraNearFar.x, this.cameraNearFar.y));
435
+ this.$l.clipSpacePos = pb.vec4(pb.sub(pb.mul(this.uv, 2), pb.vec2(1)), pb.sub(pb.mul(pb.clamp(this.nonLinearDepth, 0, 1), 2), 1), 1);
436
+ this.$l.wPos = pb.mul(this.mat, this.clipSpacePos);
437
+ this.$return(pb.vec4(pb.div(this.wPos.xyz, this.wPos.w), this.linearDepth));
438
+ });
439
+ pb.main(function() {
440
+ this.$l.screenUV = pb.div(pb.vec2(this.$builtins.fragCoord.xy), this.targetSize.xy);
441
+ this.$l.color = pb.vec3(0);
442
+ this.$l.pos = this.getPosition(this.screenUV, this.invProjMatrix);
443
+ this.$if(pb.lessThan(this.pos.w, 1), function() {
444
+ this.$l.viewPos = this.pos.xyz;
445
+ this.$l.worldNormal = pb.sub(pb.mul(pb.textureSampleLevel(this.normalTex, this.screenUV, 0).rgb, 2), pb.vec3(1));
446
+ this.$l.viewNormal = pb.normalize(pb.mul(this.viewMatrix, pb.vec4(this.worldNormal, 0)).xyz);
447
+ this.$l.numRays = pb.max(this.ssgiParams.w, 1);
448
+ this.$l.sum = pb.vec3(0);
449
+ this.$for(pb.float('ray'), 0, this.numRays, function() {
450
+ this.$l.rand = SSGI_rand2(this, pb.vec2(this.$builtins.fragCoord.xy), pb.add(pb.mul(this.frameIndex, this.numRays), this.ray));
451
+ this.$l.rayDir = SSGI_cosineSampleHemisphere(this, this.viewNormal, this.rand);
452
+ this.$l.hitInfo = pb.vec4(0);
453
+ if (ctx.HiZTexture) {
454
+ this.hitInfo = screenSpaceRayTracing_HiZ(this, this.viewPos, this.rayDir, this.viewMatrix, this.projMatrix, this.invProjMatrix, this.cameraNearFar, this.depthMipLevels, this.ssgiParams.y, this.ssgiParams.x, this.ssgiParams.z, this.targetSize, this.hizTex, this.normalTex);
455
+ } else {
456
+ this.hitInfo = screenSpaceRayTracing_Linear2D(this, this.viewPos, this.rayDir, this.viewMatrix, this.projMatrix, this.invProjMatrix, this.cameraNearFar, this.ssgiParams.x, this.ssgiParams.y, this.ssgiParams.z, this.ssgiStride, this.targetSize, this.depthTex, this.normalTex, // Backface depth is only present when the depth prepass was
457
+ // built with SSR thickness computation enabled.
458
+ !!ctx.SSRCalcThickness);
459
+ }
460
+ this.$l.hitAlpha = pb.clamp(this.hitInfo.w, 0, 1);
461
+ // Hemisphere rays frequently graze the screen plane; the
462
+ // projective ray setup can then produce Inf/NaN which would be
463
+ // smeared into large blocks by the bilateral blur. NaN fails
464
+ // any comparison, so this test also rejects it.
465
+ this.$l.hitValid = pb.and(pb.all(pb.lessThan(pb.abs(this.hitInfo), pb.vec4(1e30))), pb.greaterThan(this.hitAlpha, 0));
466
+ this.$if(this.hitValid, function() {
467
+ this.$l.hitUV = pb.clamp(this.hitInfo.xy, pb.vec2(0), pb.vec2(1));
468
+ // Firefly clamp: an HDR sun/sky sample can be orders of
469
+ // magnitude brighter than the surroundings; unclamped it
470
+ // dominates the low-spp estimate as isolated bright dots no
471
+ // spatial filter can remove.
472
+ this.$l.radiance = pb.min(pb.textureSampleLevel(this.colorTex, this.hitUV, 0).rgb, pb.vec3(4));
473
+ this.sum = pb.add(this.sum, pb.mul(this.radiance, this.hitAlpha));
474
+ });
475
+ });
476
+ // Cosine-weighted sampling: the irradiance estimate over the
477
+ // hemisphere reduces to the plain average of the hit radiances.
478
+ this.color = pb.div(this.sum, this.numRays);
479
+ });
480
+ // Reinhard-compress into [0, 1) (mirrors the SSR intersect pass).
481
+ // The bilateral blur weights neighbors by color distance; in linear
482
+ // HDR a firefly is "far" from every neighbor and survives the blur
483
+ // untouched. Compressed, bright outliers stay within blurring range
484
+ // and the denoiser can actually converge them. combine() inverts
485
+ // this mapping.
486
+ this.color = pb.div(this.color, pb.add(this.color, pb.vec3(1)));
487
+ this.$outputs.outColor = pb.vec4(this.color, 1);
488
+ });
489
+ }
490
+ });
491
+ if (!program) {
492
+ return null;
493
+ }
494
+ program.name = '@SSGI_Trace';
495
+ return program;
496
+ }
497
+ /** @internal */ _createTemporalProgram(ctx) {
498
+ const program = ctx.device.buildRenderProgram({
499
+ vertex (pb) {
500
+ this.flip = pb.int().uniform(0);
501
+ this.$inputs.pos = pb.vec2().attrib('position');
502
+ this.$outputs.uv = pb.vec2();
503
+ pb.main(function() {
504
+ this.$builtins.position = pb.vec4(this.$inputs.pos, 1, 1);
505
+ this.$outputs.uv = pb.add(pb.mul(this.$inputs.pos.xy, 0.5), pb.vec2(0.5));
506
+ this.$if(pb.notEqual(this.flip, 0), function() {
507
+ this.$builtins.position.y = pb.neg(this.$builtins.position.y);
508
+ });
509
+ });
510
+ },
511
+ fragment (pb) {
512
+ this.historyColorTex = pb.tex2D().uniform(0);
513
+ this.currentColorTex = pb.tex2D().uniform(0);
514
+ this.motionVector = pb.tex2D().uniform(0);
515
+ this.prevMotionVector = pb.tex2D().uniform(0);
516
+ this.texSize = pb.vec2().uniform(0);
517
+ this.temporalWeight = pb.float().uniform(0);
518
+ this.$outputs.outColor = pb.vec4();
519
+ pb.main(function() {
520
+ // Sample-count adaptive accumulation with fixed-gamma variance
521
+ // clipping. The accumulated frame count N rides in the alpha
522
+ // channel of the radiance history: early frames blend with
523
+ // 1/(N+1) (fast convergence), converged pixels blend at the
524
+ // temporalWeight cap. A FIXED-weight EMA never settles - its
525
+ // stationary state is a random walk inside the clip box, visible
526
+ // as slowly rippling low-frequency noise after the blur. With
527
+ // count-based weighting the estimator is a true running average,
528
+ // so the converged value stops moving.
529
+ // TAA-style box collapsing with velocity is deliberately avoided:
530
+ // camera jitter alone would collapse the box and reject all
531
+ // history, preventing convergence of the stochastic signal.
532
+ this.$l.screenUV = pb.div(pb.vec2(this.$builtins.fragCoord.xy), this.texSize);
533
+ this.$l.currentColor = pb.textureSampleLevel(this.currentColorTex, this.screenUV, 0).rgb;
534
+ this.$l.velocitySample = pb.textureSampleLevel(this.motionVector, this.screenUV, 0);
535
+ this.$l.velocity = this.velocitySample.xy;
536
+ // Sky/unwritten motion vector sentinel: no history
537
+ this.$if(pb.and(pb.greaterThanEqual(this.velocity.x, 5e4), pb.greaterThanEqual(this.velocity.y, 5e4)), function() {
538
+ this.$outputs.outColor = pb.vec4(this.currentColor, 1);
539
+ }).$else(function() {
540
+ this.$l.reprojectedUV = pb.sub(this.screenUV, this.velocity);
541
+ this.$l.offscreen = pb.or(pb.any(pb.lessThan(this.reprojectedUV, pb.vec2(0))), pb.any(pb.greaterThan(this.reprojectedUV, pb.vec2(1))));
542
+ this.$if(this.offscreen, function() {
543
+ this.$outputs.outColor = pb.vec4(this.currentColor, 1);
544
+ }).$else(function() {
545
+ this.$l.historySample = pb.textureSampleLevel(this.historyColorTex, this.reprojectedUV, 0);
546
+ this.$l.historyColor = this.historySample.rgb;
547
+ this.$l.historyCount = pb.max(this.historySample.a, 1);
548
+ // 3x3 neighborhood statistics of the current stochastic estimate
549
+ this.$l.m1 = pb.vec3(0);
550
+ this.$l.m2 = pb.vec3(0);
551
+ for(let i = -1; i <= 1; i++){
552
+ for(let j = -1; j <= 1; j++){
553
+ const c = `c${(i + 1) * 3 + j + 1}`;
554
+ this.$l[c] = pb.textureSampleLevel(this.currentColorTex, pb.add(this.screenUV, pb.div(pb.vec2(i, j), this.texSize)), 0).rgb;
555
+ this.m1 = pb.add(this.m1, this[c]);
556
+ this.m2 = pb.add(this.m2, pb.mul(this[c], this[c]));
557
+ }
558
+ }
559
+ this.m1 = pb.div(this.m1, 9);
560
+ this.m2 = pb.div(this.m2, 9);
561
+ this.$l.sigma = pb.sqrt(pb.max(pb.sub(this.m2, pb.mul(this.m1, this.m1)), pb.vec3(0)));
562
+ // gamma = 2 with a small absolute slack: tolerant to the noise a
563
+ // converged history legitimately sits in (compressed-space values).
564
+ this.$l.halfExtent = pb.add(pb.mul(this.sigma, 2), pb.vec3(0.03));
565
+ this.$l.clampedHistory = pb.clamp(this.historyColor, pb.sub(this.m1, this.halfExtent), pb.add(this.m1, this.halfExtent));
566
+ // How much the clip moved the history signals stale data;
567
+ // knock the effective count down so recovery is fast.
568
+ this.$l.clipAmount = pb.length(pb.sub(this.clampedHistory, this.historyColor));
569
+ this.$l.clipReset = pb.clamp(pb.mul(this.clipAmount, 8), 0, 1);
570
+ // Disocclusion: previous frame's motion at the reprojected point
571
+ // should agree with the current motion; a large mismatch means the
572
+ // surface was not visible last frame.
573
+ this.$l.prevVelocity = pb.textureSampleLevel(this.prevMotionVector, this.reprojectedUV, 0).xy;
574
+ this.$l.velocityDiff = pb.length(pb.mul(pb.sub(this.velocity, this.prevVelocity), this.texSize));
575
+ this.$l.disocclusion = pb.clamp(pb.mul(pb.sub(this.velocityDiff, 2.5), 0.1), 0, 1);
576
+ this.$l.confidence = pb.mul(pb.sub(1, this.disocclusion), pb.sub(1, this.clipReset));
577
+ this.$l.effectiveCount = pb.max(pb.mul(this.historyCount, this.confidence), 0);
578
+ // Count-based blend weight, capped by temporalWeight:
579
+ // maxCount = w/(1-w) so the cap and the weight limit agree.
580
+ this.$l.wMax = pb.clamp(this.temporalWeight, 0, 0.99);
581
+ this.$l.maxCount = pb.div(this.wMax, pb.sub(1, this.wMax));
582
+ this.$l.w = pb.min(this.wMax, pb.div(this.effectiveCount, pb.add(this.effectiveCount, 1)));
583
+ this.$l.newCount = pb.min(pb.add(this.effectiveCount, 1), this.maxCount);
584
+ this.$outputs.outColor = pb.vec4(pb.mix(this.currentColor, this.clampedHistory, this.w), this.newCount);
585
+ });
586
+ });
587
+ });
588
+ }
589
+ });
590
+ program.name = '@SSGI_Temporal';
591
+ return program;
592
+ }
593
+ /** @internal */ _createCombineProgram(ctx) {
594
+ const program = ctx.device.buildRenderProgram({
595
+ vertex (pb) {
596
+ this.flip = pb.int().uniform(0);
597
+ this.$inputs.pos = pb.vec2().attrib('position');
598
+ this.$outputs.uv = pb.vec2();
599
+ pb.main(function() {
600
+ this.$builtins.position = pb.vec4(this.$inputs.pos, 1, 1);
601
+ this.$outputs.uv = pb.add(pb.mul(this.$inputs.pos.xy, 0.5), pb.vec2(0.5));
602
+ this.$if(pb.notEqual(this.flip, 0), function() {
603
+ this.$builtins.position.y = pb.neg(this.$builtins.position.y);
604
+ });
605
+ });
606
+ },
607
+ fragment (pb) {
608
+ this.colorTex = pb.tex2D().uniform(0);
609
+ this.radianceTex = pb.tex2D().uniform(0);
610
+ this.targetSize = pb.vec2().uniform(0);
611
+ this.ssgiIntensity = pb.float().uniform(0);
612
+ this.srgbOut = pb.int().uniform(0);
613
+ this.$outputs.outColor = pb.vec4();
614
+ pb.main(function() {
615
+ this.$l.screenUV = pb.div(pb.vec2(this.$builtins.fragCoord.xy), this.targetSize);
616
+ this.$l.sceneColor = pb.textureSampleLevel(this.colorTex, this.screenUV, 0).rgb;
617
+ // Invert the Reinhard compression applied by the trace pass
618
+ // (x/(1+x) -> y/(1-y)); the clamp bounds the expansion so a fully
619
+ // saturated texel cannot blow up to infinity.
620
+ this.$l.compressed = pb.clamp(pb.textureSampleLevel(this.radianceTex, this.screenUV, 0).rgb, pb.vec3(0), pb.vec3(0.98));
621
+ this.$l.radiance = pb.div(this.compressed, pb.sub(pb.vec3(1), this.compressed));
622
+ // The forward pipeline keeps no albedo GBuffer; approximate the
623
+ // surface reflectance by the chromaticity of the lit scene color
624
+ // (hue preserved, luminance normalized). Unlike scaling by the
625
+ // scene color itself this keeps indirect light visible in shadowed
626
+ // areas - exactly where GI matters.
627
+ this.$l.luma = pb.dot(this.sceneColor, pb.vec3(0.2126, 0.7152, 0.0722));
628
+ this.$l.albedoApprox = pb.clamp(pb.div(this.sceneColor, pb.vec3(pb.add(this.luma, 1e-3))), pb.vec3(0), pb.vec3(1));
629
+ this.$l.combined = pb.add(this.sceneColor, pb.mul(this.radiance, this.albedoApprox, this.ssgiIntensity));
630
+ this.$if(pb.equal(this.srgbOut, 0), function() {
631
+ this.$outputs.outColor = pb.vec4(this.combined, 1);
632
+ }).$else(function() {
633
+ this.$outputs.outColor = pb.vec4(linearToGamma(this, this.combined), 1);
634
+ });
635
+ });
636
+ }
637
+ });
638
+ program.name = '@SSGI_Combine';
639
+ return program;
640
+ }
641
+ }
642
+
643
+ export { SSGI };
644
+ //# sourceMappingURL=ssgi.js.map