Visitar URL original
GTAONode: Add bent normals and directional specular occlusion aka GTSO by bhouston · Pull Request #35052 · mrdoob/three.js · GitHub
Skip to content

GTAONode: Add bent normals and directional specular occlusion aka GTSO - #35052

Draft
bhouston wants to merge 9 commits into
mrdoob:devfrom
bhouston:gtao-bent-normal-and-specular-occlusion
Draft

bhouston wants to merge 9 commits into
mrdoob:devfrom
bhouston:gtao-bent-normal-and-specular-occlusion

Conversation

@bhouston

@bhouston bhouston commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Adds bent normals to GTAONode and uses them for directional specular occlusion (GTSO) of environment lighting. Today GTAO only outputs a scalar visibility, and materials turn it into specular occlusion with a heuristic that does not know which directions are blocked. Metal therefore keeps reflecting the sky inside folds and cavities. With a bent normal, a reflection that points into nearby geometry is occluded, and one that points out of the opening is not.

import { ao } from 'three/addons/tsl/display/GTAONode.js';

const aoPass = ao( prePassDepth, prePassNormal, camera );

// before: scenePass.contextNode = builtinAOContext( aoPass.getTextureNode().sample( screenUV ).r );
scenePass.contextNode = aoPass.getLightingContext();

Live Demo Link:
https://raw.githack.com/bhouston/three.js/5b3167255be351a5f59b99b2e7f092c69c24ea84/examples/webgpu_postprocessing_ao_source.html

GTAO + GTSO vs GTAO

webgpu_postprocessing_ao gains a GTAO + GTSO AO type next to GTAO and SSAO, and its bust is now polished bronze so the specular occlusion is visible. Switch between GTAO + GTSO and GTAO and look at the bust's collar, beard and coat folds: with GTSO they stop reflecting environment they cannot see, which gives the bust noticeably more depth. The live demo loads a copy of the example that imports src/ directly, since the committed build/ does not include the core change yet.

Implements the specular occlusion half of the GTAO paper we already use, Jimenez et al., Practical Realtime Strategies for Accurate Indirect Occlusion (SIGGRAPH 2016 slides):

  • Bent normal: the cosine-weighted average of the unoccluded directions, computed in closed form from the two horizon angles GTAO already finds in each slice. This is the paper's "Algorithm 2", the same formula as Intel's XeGTAO. No extra depth samples.
  • Specular occlusion: the paper's cone-to-cone model (Section 6, Appendix B). Visibility is a cone around the bent normal with cos(αv) = sqrt(1 - AO), and the specular lobe is a cone around the reflection vector with cos(αs) = 2^(-3.32193 α²) (α is GGX alpha). Their overlap is the analytic spherical-cap intersection from Oat and Sander, Ambient Aperture Lighting (I3D 2007).
  • One deviation from the paper: the overlap is divided by the part of the specular cone above the surface instead of the whole cone. The paper's ratio darkens grazing reflections on unoccluded surfaces, which shows up as a dark outline at silhouettes (the authors mention this overdarkening on slide 142). With this change, open surfaces get exactly 1.

API changes:

  • The GTAO render target is RGBA half-float: visibility in R, encoded bent-normal offset in GBA. Decoding against the receiving surface normal preserves fine normal detail at reduced AO resolution. The node itself still evaluates to the scalar AO (.r), so existing code keeps working.
  • getBentNormalNode( uv, normal ) and getLightingContext( uv ) on GTAONode, and an exported specularOcclusion() TSL function.
  • PhysicalLightingModel gains an optional getSO( { node, normal, roughness }, builder ) context hook, next to the existing getAO( inputNode, builder ) hook. It is used for the base layer and for clearcoat.

Important points

  • The new getSO context hook is a small core change in PhysicalLightingModel. @sunag, is this the right shape for the hook, or would you prefer a different extension point?
  • The RGBA16F AO target uses 8 bytes per pixel. Half float represents the neutral bent-normal offset exactly. GTSO is opt-in through getLightingContext().
  • Occluded reflections go dark rather than showing the occluder, since there are no inter-reflections. This is screen-space, so off-screen and hidden occluders are still missed.

Validation: changed-file lint, the addon unit suite, hardware WebGPU/WebGL bent-normal regression, and AO example mode/resize checks pass on Apple M3.

…dd GTSO example.

The specular cone now uses GGX alpha as in the paper's Eq. 26, and the cone overlap is normalized by the part of the specular cone above the surface so unoccluded grazing reflections are no longer darkened. The getSpecularOcclusion() context hook now takes ( inputNode, normal, roughness, builder ) like getAO(). Adds webgpu_postprocessing_gtso with mode, normal and bent normal views, and restores webgpu_postprocessing_ao to dev.
…pu_postprocessing_gtso.

The bust is now polished bronze with fine procedural bumps and stands on the central pedestal, so the specular occlusion is visible.
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

📦 Bundle size

Full ESM build including core, minified and gzipped.

Before After Diff
Core 394.36
103.19
394.36
103.19
+0 B
+0 B
WebGL 780.75
194.74
780.75
194.74
+0 B
+0 B
WebGPU 1114.74
299.78
1114.89
299.83
+152 B
+53 B
WebGPU Nodes 1112.68
299.42
1112.84
299.47
+152 B
+53 B

🌳 Bundle size after tree-shaking

Minimal build including a renderer, camera, empty scene, and dependencies.

Before After Diff
WebGL 519.09
124.69
519.09
124.69
+0 B
+0 B
WebGPU 790.17
211.49
790.32
211.54
+152 B
+51 B
WebGPU Nodes 736.26
197.93
736.29
197.94
+32 B
+6 B

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

🖼️ E2E screenshot tests

✅ All examples render correctly again (run).

Comment thread examples/jsm/tsl/display/GTAONode.js Outdated
return inputNode !== null ? inputNode.mul( visibility ) : visibility;

},
getSpecularOcclusion: ( inputNode, normal, roughness, { material, context } ) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a chance we might need to add more parameters in the future? If so, I'd prefer to group them into an object and always keep the builder as the second parameter, so the function maintains a consistent signature.

getSpecularOcclusion( { node, normal, roughness }, builder )

What do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sunag I've changed the interface as you've requested. I've also merged in the latest dev branch so it is ready for merge/review.

@ektogamat

This comment was marked as spam.

@Mugen87
Mugen87 marked this pull request as draft October 26, 2026 14:17
@mrdoob mrdoob added this to the r188 milestone Oct 27, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants