Repository navigation
Conversation
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>
|
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? |
🖼️ 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>
|
@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 ); |
There was a problem hiding this comment.
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: 8gives 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.
There was a problem hiding this comment.
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.
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
targetEdgePixels) and, optionally, only split where they visibly deviate from the displaced surface (displacementError,maxErrorPixels). Frustum and backface culling.shadowDetail), wireframe,clone(),dispose(), orthographic and array cameras, motion vectors for TRAA and motion blur, andcompileAsync(). 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:
AdaptiveSubdivisionOcclusionskips 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.I can also make the example more complicated / interesting to show how it scales to large worlds (which I've also tested).