Skip to content

Examples: Add webgpu_tsl_adaptive_subdivision. - #35348

Open
zalo wants to merge 2 commits into
mrdoob:devfrom
zalo:tsl-adaptive-subdivision
Open

zalo wants to merge 2 commits into
mrdoob:devfrom
zalo:tsl-adaptive-subdivision

Conversation

@zalo

@zalo zalo commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Adaptive Subdivision Mesh

Adds AdaptiveSubdivisionMesh, which subdivides a mesh on the GPU every frame, so displacement maps add real geometry to coarse meshes with no per-frame CPU work.

Live Demo: https://raw.githack.com/zalo/three.js/tsl-adaptive-subdivision/examples/webgpu_tsl_adaptive_subdivision.html

adaptive_subdivision_displacement
const mesh = new AdaptiveSubdivisionMesh( geometry, new THREE.MeshStandardNodeMaterial(), {
	displacement: ( { position, normal, footprint } ) => triplanarHeight( position, normal, footprint ).mul( 0.1 ),
	maxDisplacement: 0.1
} );

// animation loop
mesh.update( renderer, camera );
renderer.render( scene, camera );
  • Longest edge bisection on a concurrent binary tree (Dupuy, HPG 2020; Benyoub and Dupuy, HPG 2024), starting from the halfedges of any triangle mesh. The leaves live in a GPU memory pool (~92 bytes each) and split or merge incrementally from frame to frame.
  • Crack-free and pop-free: vertices shared by neighboring triangles are bit-identical, and new vertices morph in (geomorphing).
  • Level of detail: triangles target a size on screen (targetEdgePixels) and, optionally, only split where they visibly deviate from the displaced surface (displacementError, maxErrorPixels). Frustum and backface culling.
  • Integration: shadow maps (with their own subdivision, shadowDetail), wireframe, clone(), dispose(), orthographic and array cameras, motion vectors for TRAA and motion blur, and compileAsync(). WebGPU backend only.
  • webgpu_tsl_adaptive_subdivision: a cobblestone ground, a stone wall and a few rocks with textures from Poly Haven (6 × 1k JPGs, 1.6 MB), and a checkbox to compare against the same meshes normal mapped.

This PR (~4,500 lines: the mesh 3,420, the CBT 348, the example 755) is expected to be followed by:

  • Occlusion culling: AdaptiveSubdivisionOcclusion skips leaves hidden behind a depth pyramid of the source meshes (~580 lines). It helps where occluders fill the view and costs a little in open views.
  • Raycasting the displaced surface with a CPU displacement callback (~220 lines).

I can also make the example more complicated / interesting to show how it scales to large worlds (which I've also tested).

Adds AdaptiveSubdivisionMesh, which refines a mesh on the GPU with longest edge
bisection from halfedge root bisectors, driven by the size of triangles on
screen, measured by area so the density is the same for all source meshes. The
leaves live in a memory pool of fixed-size blocks with explicit neighbor links,
managed by a concurrent binary tree as in "Concurrent Binary Trees for
Large-Scale Game Components" (Benyoub and Dupuy, HPG 2024), so memory scales
with the leaf budget (maxLeafCount) instead of the maximum depth: about 92 bytes
per leaf. Frames alternate between splits and merges, and compute passes are
dispatched directly over the pool with an early exit, as indirect dispatches
cost more than they save. Displacement is evaluated per vertex from a TSL
callback, here triplanar-mapped height maps, so walls and rocks get real
geometry with no per-frame CPU work. Includes crack-free evaluation, frustum and
backface culling, and a wireframe overlay. Each leaf draws a patch sized to its
size on screen, from the full patch down to one triangle, with one indirect draw
per patch size; edges between leaves of different patch sizes are drawn at the
finer size from both sides, so they stay crack-free.

An optional displacementError callback only lets leaves split while they deviate
visibly from the displaced surface (maxErrorPixels); the example bakes an error
pyramid for it. Splits, merges and changes of the patch size morph: the patch
drawn per leaf is itself bisected, so new vertices start on the surface of their
parent and move to their displaced positions as the camera approaches, taking at
least morphTime, so transitions do not pop at any camera speed.

The camera passed to update() only draws the leaves it can see. Shadow passes
draw a separate subdivision, created on first use, that also refines faces the
camera cannot see and scales its detail with shadowDetail, down to no
subdivision at 0, where its pool only holds the roots. Other cameras draw all
leaves. Also supports material.wireframe, clone(), dispose() of all GPU buffers,
orthographic and array cameras, motion vectors for TRAA and motion blur, and
warns on the WebGL backend.

Normals default to the normals of the displaced triangles plus a normal map of
the detail they are too coarse to show, so the normal map fades out as the
subdivision gets finer. An optional displacementGradient callback replaces
finite differences; the example bakes each height map once into a mipmapped
height and gradient texture.

raycast() intersects the source geometry, and getInfo() reports the leaf counts
and the size of the GPU buffers.

compileAsync() compiles the compute pipelines without blocking, so the browser
compiles them in parallel before the first frame instead of one after the other
when they are first used. The numbers that differ between meshes are uniforms,
so meshes of the same kind share their shaders and compile them once.

The example displaces a cobblestone ground, a stone wall and a few rocks, lit
by a low sun raking across the relief. An Adaptive Displacement checkbox
switches to the source meshes normal mapped with the same relief, for
comparison. Source vertices are welded with a tolerance, so the seams of
generated geometries are joined.

Textures are CC0 from Poly Haven.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zalo

zalo commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

I feel bad to keep making massive PRs... there is no pressure to merge this or any of mine; they're more proposals as I sort my thoughts on how best to elevate three.js..

I seems like there are about ~10 features of roughly this size that would make three.js straightforward to get comparable to unreal in terms of raw visual fidelity. I'm torn between PRing them piecemeal, and just making a combined kit of them that just jumps straight there. 🤔

At some point, the old examples will want to be deprecated for the new; would that be crazy?
Perhaps we could still demonstrate every example's features, but also do so at maximum quality in a smaller number of them...

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

🖼️ E2E screenshot tests

✅ All examples render correctly again (run).

… merges per subdivision.

- The example no longer renders before its animation loop is set: the relief maps are baked by the
  first frame. Rendering starts the animation loop of the renderer, and the E2E test, which runs a
  single frame, ran it before the shaders compiled on CI and captured a black frame.
- The inspector is attached after compiling, which reported the shadow nodes to it outside of a frame.
- Each subdivision counts its own updates and time, so the shadow subdivision, which is only updated
  after shadow passes, still alternates between splits and merges and morphs at the right rate when
  shadows are not rendered every frame. Before, it could only ever merge or only ever split.
- morphTime defaults to 0, so morphs follow the camera without delay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Mugen87

Mugen87 commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator

@zalo I think we should not creating a lot of PRs in a brute-force manner that are in fact in a pure Draft status. Generating the code with AI is easy but there is a lot of API considerations, testing, bug fixing and polishment to do. Also achieving a good visual fidelity is one thing, running with a good, usable performance another one. I see large gaps in certain PRs regarding this aspect.

this._cameraPosition.value.copy( _cameraPosition );
this._cameraDirection.value.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld ).transformDirection( _inverse );

renderer.getSize( _size );

@Mugen87 Mugen87 Nov 5, 2026 •

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.

I don't have the bandwidth to review the PR in detail yet. But a first look made clear this line needs a fix.

You retrieve the size from renderer.getSize() and that method ignores the pixel ratio. The example sets setPixelRatio( devicePixelRatio ) though. At a pixel ratio of 2 (like on my Display) the effects are:

  • Triangle size: targetEdgePixels: 8 gives edges of about 16 real pixels so about a quarter of the triangles the docs imply.
  • Debug view: Triangle Size measures real pixels so triangles at the target show as red instead of the documented green.
  • Normals: The fragment pixel footprint is twice as large so normals sample about one mip too coarse.

Using getDrawingBufferSize() should fix all these points.

@Mugen87 Mugen87 Nov 5, 2026 •

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.

get comparable to unreal in terms of raw visual fidelity

We should not think it that terms and it should not be a design goal for three.js.

Unreal is Game Engine meant for Consoles and Gaming PCs. three.js is an 3D engine for the web platform. We run on totally different devices than Unreal and have different performance constraints. Our technology stack including GI techniques and Addons must reflect that. A three.js feature that does not run properly on a Mobile device is a misconception.

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.

2 participants