Skip to content

Conversation

@Mugen87
Copy link
Collaborator

@Mugen87 Mugen87 commented Sep 7, 2025

Fixed #29668.

Description

The PR adds a new SSGI post processing node for WebGPURenderer. The implementation is based on https://github.com/cdrinmatane/SSRT3 which is a SSGI component for Unity.

SSGINode is a complete port of the Unity code and supports all features that were missing in the experimental versions in #29668. E.g. SSGINode supports temporal filtering, view-space sampling or the original noise data from the Unity source. The discussions and code from #29668 where a real help and time-saver so many thanks to all contributors!

The purpose of SSGI is to add indirect diffuse light and AO to a scene. Similar to other modern SSGI implementations, this one works best with temporal filtering which means it is used together with TAA. In that case, noise that is normally quite visible can be effectively reduced without a dedicated denoise() pass. Depending on whether temporal filtering is used or not, the effect should be configured a bit differently. I've added some comments for this in the JSDoc of the class.

The default settings of the node are relatively low so the effect runs with 60 FPS on a Pixel 8a and a macMini with a high resolution Studio Display screen. You will notice some temporal instabilities at certain surfaces with a slice count of one which can be mitigate by increasing it to two (like in the demo). The slice count has a huge performance impact though so you will see performance drops when ramping up the quality. It heavily depends on the scene what kind of settings are required so the result looks good. Another thing that you might notice is ghosting when moving the camera which is typical for temporal techniques. We might be able to reduce this by improving the TAA over time though. Keep in mind that SSGI is in general an expensive FX effect so it should be used in a sensible way.

Live demo: https://rawcdn.githack.com/mrdoob/three.js/dev/examples/webgpu_postprocessing_ssgi.html

@zalo
Copy link
Contributor

zalo commented Sep 7, 2025

Awesome! Looking good!

Since it’s using temporal accumulation, perhaps the noise pattern should be randomized every frame to even out the dithering pattern 😄

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 7, 2025

Interesting! Do you have a reference for how this is done?

We want to include temporal accumulation in other effects like SSR as well so information about this topic are highly welcome.

@Mugen87 Mugen87 added this to the r181 milestone Sep 7, 2025
@zalo
Copy link
Contributor

zalo commented Sep 7, 2025

Everywhere “frameNumber” is used here is for hacking in temporal jitter:
https://github.com/zalo/three.js/blob/2b2c012ed88d402e53c53c14823aff1295440367/examples/jsm/shaders/SSILVBShader.js#L90

The temporal jitter checkbox should enable it here:
https://raw.githack.com/zalo/three.js/feat-ssilvb-gtvbao/examples/webgl_postprocessing_ssilvb.html

I’m not 1,000,000% sure it will converge, but it might help 😅

I’d love to see a dropdown that switches between the no-SSGI color, the pure SSGI channel, and the composite!

@Maksims
Copy link

Maksims commented Sep 7, 2025

This looks cool.
But the demo is scene used is missleading, as it has lightmaps with baked GI in them.
To present the technique in its actual quality, it is best to use scene with dynamic lights and shadow maps, but without lightmaps.

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 7, 2025

100% agreed!

I've just struggled to find a free, good looking glTF asset with an appropriate CC license and material/texture setup. Many assets at Sketchfab bake light and shadows directly into the diffuse textures so there is not even an option to remove the baked lighting which would be possible with separate light maps. I also wanted to avoid to reuse an existing asset in the repository so we can provide fresh visuals. Help in this context would be highly welcome. Maybe someone can recommend or even share an appropriate asset?

@zalo Thanks! Let's give this approach a go when the PR got merged.

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 7, 2025

I’d love to see a dropdown that switches between the no-SSGI color, the pure SSGI channel, and the composite!

The last entry in the GUI named output should hopefully do what you are looking for. Beauty is the unprocessed beauty pass (so no-SSGI color), GI is the pure SSGI channel and Default is the composite. Or are you referring to something else?

@sunag
Copy link
Collaborator

sunag commented Sep 7, 2025

Congratulations @Mugen87 , quite amazing.

@hybridherbst
Copy link
Contributor

Looks very nice!

Would it be possible to also get a denoise pass in? Maybe the denoise pass from N8AO by @N8python would be suitable here? (https://github.com/N8python/n8ao/blob/master/src/N8AOPass.js#L433)

@zalo
Copy link
Contributor

zalo commented Sep 8, 2025

Or are you referring to something else?

Ah, nope! 😅 It was hidden on mobile until I scrolled down. 🫠

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 8, 2025

Would it be possible to also get a denoise pass in?

A denoise pass is already available as DenoiseNode.

/**
* Post processing node for denoising data like raw screen-space ambient occlusion output.
* Denoise can noticeably improve the quality of ambient occlusion but also add quite some
* overhead to the post processing setup. It's best to make its usage optional (e.g. via
* graphic settings).
*
* Reference: {@link https://openaccess.thecvf.com/content/WACV2021/papers/Khademi_Self-Supervised_Poisson-Gaussian_Denoising_WACV_2021_paper.pdf}.
*
* @augments TempNode
* @three_import import { denoise } from 'three/addons/tsl/display/DenoiseNode.js';
*/
class DenoiseNode extends TempNode {

The respective TSL function denoise() is documented here:

/**
* TSL function for creating a denoise effect.
*
* @tsl
* @function
* @param {Node} node - The node that represents the input of the effect (e.g. AO).
* @param {Node<float>} depthNode - A node that represents the scene's depth.
* @param {?Node<vec3>} normalNode - A node that represents the scene's normals.
* @param {Camera} camera - The camera the scene is rendered with.
* @returns {DenoiseNode}
*/
export const denoise = ( node, depthNode, normalNode, camera ) => nodeObject( new DenoiseNode( convertToTexture( node ), nodeObject( depthNode ), nodeObject( normalNode ), camera ) );

You would use denoise() right after the GI pass and then use the result for the composite:

const denoisePass = denoise( giPass, scenePassDepth, scenePassNormal, camera );

@Mugen87 Mugen87 merged commit 22e1b94 into mrdoob:dev Sep 8, 2025
8 checks passed
@zalo
Copy link
Contributor

zalo commented Sep 9, 2025

Hmm, @Mugen87 I think it might be too early to close #29668... I just found the time to sit down and test everything...

I think there may be some significant bugs in the SSGI implementation...

Here's the #29668 's (three.js GT-VBAO) implementation with 2 slices and 8 steps:
Image

And here's the WebGPU TSL SSGI Implementation with 2 slices and 8 steps:
Image

GT-VBAO 32 steps:
image

SSGI 32 steps:
image

The SSGI one seems to get exponentially worse as more steps are added... likewise, I don't think the temporal jitter is working as expected 💀
Perhaps it is worth adding some more intermediate debug views between the existing implementations and tracking down where they diverge 😅

I wish I could work with TSL as well as GLSL 🫠

@zalo
Copy link
Contributor

zalo commented Sep 9, 2025

Hm, the SSRT3 "three.js" implementation is here; which wasn't quite right to the Unity version either 😅
https://raw.githack.com/zalo/three.js/feat-ssilvb/examples/webgl_postprocessing_ssilvb.html
https://github.com/zalo/three.js/blob/01789a62f2abac47159c81ff9aa482441b519dfc/examples/jsm/shaders/SSILVBShader.js

Which implementation should I step through line by line to look for differences?

@hybridherbst
Copy link
Contributor

The implementation at https://raw.githack.com/zalo/three.js/feat-ssilvb/examples/webgl_postprocessing_ssilvb.html goes darker with higher AOSamples count, that is probably not intended, right?

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 9, 2025

@zalo I have used the code from https://github.com/cdrinmatane/SSRT3/tree/main/HDRP as a reference. When visually comparing SSGINode with your port, I have temporarily exchanged below code to match yours so the ray starts of the same (I've seen you have used the results of randf() in these lines):

const noiseOffset = spatialOffsets( screenCoordinate );
const noiseDirection = gradientNoise( screenCoordinate );
const initialRayStep = fract( noiseOffset.add( this._temporalOffset ) ).add( rand( uvNode ).mul( 2 ).sub( 1 ) );

But I did revert to the original version. The way you have computed the horizon did not seem plausible to me so I sticked to the original version as well.

Instead of reopening #29668, I would prefer to open a new issue focusing on SSGINode.

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 9, 2025

@cdrintherrieno Hi there 👋 ! Glady to report that SSRT3 founds its way in the main repo but it seems there is an issue in our AO implementation that makes the AO too dark the more samples are used (see #31839 (comment)). I could not reproduce this issue own my side so maybe it you could have a look at our demos?

You can use below URLs for testing:

https://rawcdn.githack.com/Mugen87/three.js/88e013b60298ed85d6557f4f954a57692122ca4a/examples/webgpu_postprocessing_ssgi.html
https://rawcdn.githack.com/mrdoob/three.js/8683d9657d3d9b83e9055025c95edcff71281be9/examples/webgpu_postprocessing_ssgi.html

Does the AO/GI look plausible to you? You can visually debug the results of the AO/GI by changing the output in the GUI. When doing so, it's best to disable temporal filtering so you get stable frames.

BTW: The code of the implementation is here:

https://github.com/mrdoob/three.js/blob/dev/examples/jsm/tsl/display/SSGINode.js

The actual shader code is implemented in the setup() method.

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 9, 2025

@zalo I have tested the other scene with SSGINode and I get a completely different result than in your post. It looks as expected to me:

image

Here is a link for testing using the latest code from dev, just with another scene setup in webgpu_postprocessing_ssgi:

https://rawcdn.githack.com/Mugen87/three.js/88e013b60298ed85d6557f4f954a57692122ca4a/examples/webgpu_postprocessing_ssgi.html

I also can't observe the mentioned darkening with higher step counts. Some areas are getting a more prominent AO, yes, but it does not look like in your second screenshot. It looks as expected to me.

@zalo
Copy link
Contributor

zalo commented Sep 9, 2025

Woah yeah that is completely different on my phone! I can see the OP’s example work correctly on my phone now… I’ll check to see what’s different about my code or how I called it in the morning in a few hours …. Perhaps 16-bit textures were getting used and messing up the bit counting?

In the new mostly correct version in the OP, I am noticing a leftward bias to the darkening (that goes away when you look at the scene from the top-down).

ScreenRecording_09-09-2025.06-22-09_1.mp4

This behavior does not appear in the new Little Tokyo demo you linked! 🧐

@cdrintherrieno
Copy link

cdrintherrieno commented Sep 9, 2025

@Mugen87 Very nice to see it implemented in ThreeJS! Indeed the AO seems wrong, also it flickers which indicates that the slice parametrization is not symmetric. If I had to guess based on a quick glance at the code, it's probably around these parts:

let frontBackHorizon = vec2( dot( pixelToSample, viewDir ), dot( pixelToSampleBackface, viewDir ) );
frontBackHorizon = GTAOFastAcos( clamp( frontBackHorizon, - 1, 1 ) );
frontBackHorizon = clamp( div( mul( samplingDirection, vec2( frontBackHorizon.x.negate(), frontBackHorizon.y ) ).sub( n.sub( HALF_PI ) ), PI ) ); // Port note: This line also required an update because of different uv conventions
frontBackHorizon = directionIsRight.equal( true ).select( frontBackHorizon.yx, frontBackHorizon.xy ); // Front/Back get inverted depending on angle

You can also compare with other implementations (although I'm not sure if they use the same coordinate system as WebGPU):
https://www.shadertoy.com/view/dsGBzW
https://www.shadertoy.com/view/XXGSDd
https://github.com/martymcmodding/iMMERSE/blob/main/Shaders/MartysMods_MXAO.fx#L592

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 9, 2025

I think there are two major differences in coordinate space conventions that a port must honor. In WebGPU, the uv (0,0) represents the top-left corner whereas in Unity it should be bottom-left. For matching uv offsets, three's implementation must compute the uv and sampling directions differently.

const uvDirection = directionIsRight.equal( true ).select( vec2( 1, - 1 ), vec2( - 1, 1 ) ); // Port note: Because of different uv conventions, uv-y has a different sign
const samplingDirection = directionIsRight.equal( true ).select( 1, - 1 );

I believe this bit is correct. What I'm unsure about is the second difference in view space handedness. In WebGPU and WebGL, the camera looks down the negative Z-axis by default so (0,0,-1). In Unity, it should be (0,0,1) (is that right?). So our viewZ values are also negative. That's why I had to add a negate() here to fix view space sampling.

stepRadius.assign( max( RADIUS.mul( this._halfProjScale ).div( viewPosition.z.negate() ), float( STEP_COUNT ) ) ); // Port note: viewZ is negative so a negate is requried

This difference in view space handedness could also affect the slice parametrization. Ideally, we don't get it right by trial and error but can understand the math differences in the shader. Can you think of a part of the frontBackHorizon computation that needs an update if the view space handedness is inverted?

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 9, 2025

Found one bug which is fixed via #31863. Keep working on potential horizon issue...

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 9, 2025

Okay, I'll check out GT-VBAO next when I'm happy with the SSRT3 code. In the meanwhile, I would appreciate any help in evaluating our port of SSRT3 so we know the coordinate space sensitive operations are correct. This will also help us when writing the TSL for GT-VBGI. BTW: I would prefer to do this manually and not with an AI so we can evaluate, understand and document (if necessary) each line.

@zalo Thank you for your help here ❤️ ! I'm convinced any three.js SSGI and AO implementation will benefit if we get our SSRT3 right. We can document all relevant port changes so it's clear for developers what parts of the reference code have been updated in what way.

@zalo
Copy link
Contributor

zalo commented Sep 9, 2025

Ahh, so the source of the major discrepancy was that it was falling back to WebGL when I did local development without https (and I guess in other weird contexts); it seems like there's something in here that breaks when emitting GLSL. When forced to https, it uses WebGPU and everything looks as expected.

Secondly, I think I found the AO bug! When I change the PI on this line to PI2, everything looks beautiful and non-flickery:

const rotationAngle = mul( float( i ).add( noiseDirection ).add( this._temporalDirection ), PI.div( float( ROTATION_COUNT ) ) ).toConst();

I'm not sure why, since that correlates with this line which still has it as PI:
https://github.com/cdrinmatane/SSRT3/blob/main/HDRP/Shaders/Resources/SSRTCS.compute#L348

Sorry for the compression making it rough...

Recording.2025-09-09.123545.mp4

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 9, 2025

Secondly, I think I found the AO bug! When I change the PI on this line to PI2, everything looks beautiful and non-flickery:

Indeed, that's it! Awesome! Would you like to make a PR with the fix?

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 9, 2025

Ahh, so the source of the major discrepancy was that it was falling back to WebGL when I did local development without https

Makes sense! Indeed, there is an issue on the GLSL side. In some areas, the AO looks darker than in the WGSL version. I'll have a look at this tomorrow unless you find the root cause ahead of me^^.

@zalo
Copy link
Contributor

zalo commented Sep 9, 2025

Comparing the "fixed" SSGI (SSRT3) shader with the three.js GT-VBAO:

Recording.2025-09-09.162623.mp4

While the SSRT3 SSGI is certainly an improvement on GTAO, I think GT-VBAO/GI (maybe with distant light sampling?) will be a significant quality improvement even on top of that, if we can work it.

Also, this is all raising the necessity for marking pixels "dirty" in the TRAA pass, to prevent ghosting...

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 10, 2025

#31873 fixed a bug in SSGINode that substantially compromised the bitfield. This bug was the actual root cause for the AO flickering and was also responsible for the mentioned quality issues.

The new version produces a noticeably better AO. I guess we need a new comparison video for #31839 (comment) ^^.

@zalo
Copy link
Contributor

zalo commented Sep 10, 2025

Here's the latest comparison between the two implementations, both set to 4 slices and 32 samples (other settings adjusted to be similar style):
SSGIvsSSILVB

The smoother/nicer one is the fixed SSGI 😄 I no longer think that the incremental improvement from GT-VBAO will make a huge difference...

Now there are just a couple smearing issues with the TRAA to solve... and I might commit some extra changes to the scene. The "Neutral Tonemapping" and Exposure set to 1.5 is crushing out a lot of the detail, and the "pre-baked" lighting room is hiding a lot of the benefit 😄

@zalo
Copy link
Contributor

zalo commented Sep 10, 2025

As far as the demo scene goes... the current room is a crime compared to some of these other models

Spaceship Hallway shows off both the AO and the GI really well, since it has no baked in lighting (this is JUST the SSGI postprocess):
SSGIComparison

EDIT: This scene does not look nearly as good when the GI compositing is fixed. Guess it was just lucky.

I'll look into making a custom demo scene to show it off in a few hours...

@zalo
Copy link
Contributor

zalo commented Sep 11, 2025

Hmm, there's another issue... it looks like the GI doesn't act like normal light, it just adds itself on top of the scene.
SponzaLightBug

SponzaLightBug2

Since the Albedo/Normals/Roughness/etc. GBuffers are present, would it make sense to interpret the GI as PBR light when compositing?

At the very least, it should account for the underlying color/textures somehow...

@zalo
Copy link
Contributor

zalo commented Sep 11, 2025

Aha, I've got a fix

Just need to swap out:

const compositePass = vec4( add( scenePassColor.rgb, gi ).mul( ao ), scenePassColor.a );

for:

const compositePass = vec4( scenePassColor.rgb.mul( ao ).add( scenePassDiffuse.rgb.mul(gi)), scenePassColor.a );

SponzaLightBugFix

I'll make a PR for it.

@zalo
Copy link
Contributor

zalo commented Sep 11, 2025

Considering a Cornell Box since it very succinctly shows the benefits of AO+GI and it looks nice with the settings dialed in:
CornellBoxComparison2

(Here's the lit frame at full color depth)
image

Cornell Box Scene Code
// Walls
let geo = new THREE.PlaneGeometry(1, 1);
let mat = new THREE.MeshPhysicalMaterial({ color: "#ff0000" });
let mesh = new THREE.Mesh(geo, mat);
mesh.scale.set( 20, 15, 1 );
mesh.rotation.y = Math.PI * 0.5;
mesh.position.set(-10, 7.5, 0);
mesh.receiveShadow = true;
scene.add(mesh);

mat = new THREE.MeshPhysicalMaterial({ color: "#00ff00" });
mesh = new THREE.Mesh(geo, mat);
mesh.scale.set( 20, 15, 1 );
mesh.rotation.y = Math.PI * -0.5;
mesh.position.set(10, 7.5, 0);
mesh.receiveShadow = true;
scene.add(mesh);

mat = new THREE.MeshPhysicalMaterial({ color: "#fff" });
mesh = new THREE.Mesh(geo, mat);
mesh.scale.set( 20, 20, 1 );
mesh.rotation.x = Math.PI * -.5;
mesh.receiveShadow = true;
scene.add(mesh);

mesh = new THREE.Mesh(geo, mat);
mesh.scale.set( 15, 20, 1 );
mesh.rotation.z = Math.PI * -0.5;
mesh.position.set(0, 7.5, -10);
mesh.receiveShadow = true;
scene.add(mesh);

mesh = new THREE.Mesh(geo, mat);
mesh.scale.set( 20, 20, 1 );
mesh.rotation.x = Math.PI * 0.5;
mesh.position.set(0, 15, 0);
mesh.receiveShadow = true;
scene.add(mesh);

// Tall Box
geo = new THREE.BoxGeometry(5, 7, 5);
mesh = new THREE.Mesh(geo, mat);
mesh.rotation.y = Math.PI * 0.25;
mesh.position.set(-3, 3.5, -2);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);

// Short Box
geo = new THREE.BoxGeometry(4, 4, 4);
mesh = new THREE.Mesh(geo, mat);
mesh.rotation.y = Math.PI * -0.1;
mesh.position.set(4, 2, 4);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);

// Lampshade
mesh = new THREE.Mesh(new THREE.CylinderGeometry(2.5, 2.5, 1, 64), 
					  new THREE.MeshBasicMaterial());
mesh.position.y = 15;
scene.add(mesh);

// Main Light
let light = new THREE.PointLight("#ffffff", 100);
light.position.set(0, 13, 0);
light.distance = 100;
light.castShadow = true;
light.shadow.mapSize.width = 1024;
light.shadow.mapSize.height = 1024;
light.shadow.bias = -0.002;
scene.add(light);

// Ambient Light
light = new THREE.AmbientLight("#0c0c0c");
scene.add(light);

Also, it seems like there's another bug where the screen space number of steps decreases the effective radius because the step radius is getting divided by the number of steps twice; I put a fix for that into my PR as well.

@zalo
Copy link
Contributor

zalo commented Sep 12, 2025

I've got an improvement to the temporal noise coming down the pipe in #31890

And I took a stab at trying to fix the ghosting in the TRAA implementation, but persisting the previous frame's depth to the next frame was too much for me, so I made an issue at #31892

Lastly, I have this Cornell Box SSGI Example Scene, does this seem good? Should I make a PR for this?

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 12, 2025

Lastly, I have this Cornell Box SSGI Example Scene, does this seem good? Should I make a PR for this?

That would be great!

@zalo
Copy link
Contributor

zalo commented Sep 13, 2025

Unless we want to do a two part TRAA or have the SSGI take in Roughness/Specular to subsume SSR, then I think I'm done for this release. 😄

@samevision
Copy link

Is it possible to just use this AO and disable for performance reasons? This AO seems much smoother than the original ao node.

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 27, 2025

Is it possible to just use this AO and disable for performance reasons?

You mean disabling the GI? That is of course doable e.g. with a new flag. That would mean the rgb components of the SSGI texture would just hold the black clear color.

That said, GTAONode does currently not support temporal filtering. Adding it should definitely improve the quality similar to SSGINode. It requires the usage of TAA though. So that would be an option as well.

@samevision
Copy link

I already tried exclude the GI stuff but without success. Would you mind to adapt it accordingly? It would make sense to disable the whole GI computation if the intensity is set to 0.

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 27, 2025

If we want to do this right, we need larger changes to the component since "AO only" means you can use a smaller render target, less uniforms and less TSL.

TBH, improving the standalone AO node seems easier to maintain to me right now.

@samevision
Copy link

Is it possible to achieve similar results? The AO of the SSGI node is much better than of GTOA node.

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 27, 2025

SSGINode is based on GTAO so the underlying algorithms are similar. We could mirror some changes of SSGINode back to GTAONode to make the effect more similar but that must be done carefully. The code in GTAONode is more simple but thus more performant.

I would recommend to start with adding temporal filtering to GTAONode which will denoise/smooth the AO similar to SSGINode. We can then see what else is missing. In the meanwhile, try to use a manual denoise() with DenoiseNode to smooth the AO.

@Mugen87
Copy link
Collaborator Author

Mugen87 commented Sep 27, 2025

Let's discuss the addition of temporal filtering to GTAONode at a new issue though.

Repository owner locked as resolved and limited conversation to collaborators Sep 27, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Postprocessing: Add SSILVB GI and AO

8 participants