About Store Forum Documentation Contact



Post Reply 
Nanite-like Meshlets
Author Message
Fex Offline
Gold Supporter

Post: #1
Nanite-like Meshlets
Videos:
Using:
https://www.fab.com/listings/454001c6-07...c098596e2e
https://streamable.com/6djncn

Using:
https://www.fab.com/listings/af3e64be-29...24987162e8
https://streamable.com/1xsc3m

Using an engine generated million tri mesh (so megascans don't have to be included in engine repo):
https://streamable.com/xnr8kv


Screenshots:
[Image: Screenshot-From-2026-07-13-22-17-29.png]

[Image: Screenshot-From-2026-07-13-22-17-39.png]







AI written summary:

Film-quality source meshes — photogrammetry scans, ZBrush sculpts, Megascans "Raw" assets — can't be drawn directly. A million-triangle rock is mostly sub-pixel triangles, where the hardware rasterizer wastes most of its work (2x2 quad granularity + triangle setup), and where hand-authored LODs pop.

This branch adds virtualized geometry: a mesh is baked into clusters (<=64 vertices / <=124 triangles, each with a bounding sphere + normal cone), the GPU picks a per-view cut through a continuous-LOD DAG every frame, and the number of triangles you draw tracks the screen area the object covers — never how many triangles the artist shipped.

Vulkan only (needs VK_EXT_mesh_shader), and opt-in: with it off, nothing in the classic path changes.



How it works

  1. BakeMeshRender::buildMeshletDag(). Cluster the mesh, then repeatedly group adjacent clusters, simplify each group with its outer boundary locked (so neighbours stay crack-free), and re-clusterize. It's a DAG, not a chain. 1M triangles -> ~22k clusters, 14-19 levels, about 1.8 seconds. Bake once, save the mesh, done.
  2. Task shader — one thread per (instance, cluster): frustum cull + normal-cone backface cull + error-based LOD cut + two-phase Hi-Z occlusion. One dispatch covers every instance of a mesh, so 400 rocks is one dispatch, not 400.
  3. Mesh shader — emits the surviving cluster's triangles, and can skin its own <=64 vertices (no pre-skin pass, no per-instance deformed vertex buffer — a crowd is still one dispatch).
  4. Visibility buffer — rasterize (cluster | triangle) into one 32-bit target, then resolve materials in a single full-screen pass: exactly one shade per visible pixel.
  5. Compute rasterizer — clusters whose screen footprint is tiny skip the hardware raster entirely; a 64-bit atomic is the depth test. At scan density that's most of the geometry.
  6. Hierarchical pre-pass — cull the DAG's node hierarchy first so the task stage only ever sees survivors.

Why the LOD cut is crack-free: a cluster is used while its own error projects below the pixel budget and its parent group's error does not. Because the group's boundary was locked during simplification, two neighbouring clusters sitting at different LOD levels still share an identical boundary. No stitching, no skirts, no popping.



Turning it on (and off)

Everything lives on the global GpuMeshlet object. Defaults are the ship defaults — the whole system is inert until you set enabled:

Code:
GpuMeshlet.enabled = true;   // master switch          DEFAULT: false (fully inert)

Culling
Code:
GpuMeshlet.cull      = true;   // per-cluster frustum cull            DEFAULT: true
GpuMeshlet.cone_cull = true;   // per-cluster normal-cone backface    DEFAULT: true
GpuMeshlet.hiz       = true;   // two-phase Hi-Z occlusion            DEFAULT: true
GpuMeshlet.precull   = false;  // hierarchical node pre-pass          DEFAULT: false
GpuMeshlet.tri_cull  = false;  // per-triangle cull                   DEFAULT: false (measured SLOWER; the
                               //   compute rasterizer already owns that case)
precull is off by default because it carries a fixed per-mesh dispatch cost that doesn't pay for itself on light scenes. On heavy content it is the single biggest win available — see the numbers below. If your scene is scan-density, turn it on.

LOD
Code:
GpuMeshlet.lod              = true;   // the continuous-LOD DAG       DEFAULT: true
GpuMeshlet.lod_error        = 1.0f;   // screen-space error budget, in PIXELS
GpuMeshlet.shadow_lod_error = 4.0f;   // the budget used for SHADOW views
shadow_lod_error matters more than it looks. A shadow map cannot resolve sub-pixel triangles, so rasterizing them into one is pure waste. Shadow views take a coarser cut — but the cut is still evaluated from the main camera, so shadow geometry can never disagree with what you can see. Set it to 0 to use the main view's budget (the old behaviour).

Rasterization
Code:
GpuMeshlet.visbuffer = false;  // visibility buffer + deferred material resolve
GpuMeshlet.swraster  = false;  // compute rasterizer for tiny clusters (needs visbuffer)
GpuMeshlet.sw_thresh = 16.0f;  // cluster screen size (px) below which compute raster takes over

Shadows / secondary views
Code:
GpuMeshlet.shadow    = true;   // meshlets cast shadows               DEFAULT: true
GpuMeshlet.secondary = true;   // meshlets appear in mirrors / water / GI captures

Streaming (cluster residency — for assets too big to keep resident)
Code:
GpuMeshlet.stream      = false;  // residency management               DEFAULT: false (bit-exact when off)
GpuMeshlet.stream_disk = false;  // spill to disk
GpuMeshlet.stream_pak  = false;  // stream straight out of the .pak
GpuMeshlet.stream_pool = 1024;   // resident cluster budget

World integration
Code:
GpuMeshlet.world = false;   // Game::Static objects auto-migrate to the meshlet path
Or drive it yourself: GpuMeshlet.add(render, matrix, material) per instance, or worldAdd(mesh, matrix) which returns false if the per-frame unique-mesh budget (128 meshes) is exhausted — in which case you draw it classically. Overflow degrades, it never silently drops geometry.

Debug palette (visibility-buffer path only — it recolors the real cluster/triangle IDs, not a proxy mesh)
Code:
GpuMeshlet.debug      = 0;  // 0 off, 1 triangle, 2 cluster, 3 triangle screen SIZE, 4 instance
GpuMeshlet.debug_wipe = 0;  // split-screen X in pixels (0 = whole screen)

Stats: GpuMeshlet.drawnClusters(), GpuMeshlet.precullClusters().



Virtual Shadow Maps (the companion feature)

Cascaded shadow maps re-render every caster every frame at a resolution that's wrong almost everywhere. VSM treats the sun's shadow as a virtual texture: a 128x128 grid of pages per clipmap level over a 4096^2 physical atlas, where only the pages the screen actually asks for are rendered — and they are cached across frames.

Code:
VsmShadows(true);              // enable (or EE_VSM=1)
VsmInvalidate(Ball);           // tell the cache a world volume changed
VsmStats(resident, rendered);  // rendered == 0 means fully cached
VsmPendingPages();             // >0 == still refilling
  • It's delivered as a full-screen sun-visibility mask, into the same socket the ray-traced sun shadows use — so it needed zero shader-pak changes.
  • Clipmap level is chosen by texel density (~1 atlas texel per screen pixel), not distance — which is also what bounds the page count.
  • Pages are rendered by the engine's own traversal, so a page gets every caster: terrain, classic objects, and meshlets alike.
  • The cascades only render while the cache is refilling. In the steady state VSM carries the sun shadow alone and the entire cascade render + resolve is skipped.
  • Movement is tracked automatically for both meshlet and classic casters (skinned ones are bounded by their bones — a character's matrix can sit still while the pose moves). Spawn / despawn / streaming are handled too. VsmInvalidate() is only for geometry that changes without moving (a terrain edit, a swapped mesh).

Env knobs: EE_VSM=1, EE_VSM_LEVELS (4), EE_VSM_CSM (auto / 0=never / 1=always), EE_VSM_PAGE_BUDGET (24), EE_VSM_COLD_BUDGET (128), EE_VSM_CLASSIC=0 (disable the classic-caster scan), EE_VSM_DEBUG (1-9), EE_VSM_LOG=1.

Measured (400 instances, GPU_TIMER_LIGHT): cascades 3.01 ms -> VSM 2.71 ms, sharper and cached. A moving caster re-renders 12 pages, not 567.



Tutorials

Five, all under Tutorials/Source/14 - Game Basics/. They run on engine/procedural assets — no downloads needed.
  • Tutorial_14_Meshlets — the teaching one. A classic-drawn sphere next to a meshlet-drawn sphere as an A/B control, with every toggle live: [C] frustum, [N] cone, [H] Hi-Z, [W] an occluding wall, [S] shadows, [L] the LOD DAG, [-/=] the error budget, [G] instance count, [T] false-color clusters. Watch the drawn-cluster count collapse as you put the wall up.
  • Tutorial_14_MeshletWorld — the Game::Static world path (GpuMeshlet.world) plus VSM. This is what a real game scene looks like.
  • Tutorial_14_SkinnedMeshlets — mesh-shader skinning. A crowd of animated characters, still one dispatch, crack-free under animation.
  • Tutorial_14_HeroMeshlets — hero density: a procedurally displaced 1M-triangle rock, instanced into the distance so one frame spans the whole LOD range.
  • Tutorial_14_MeshletShowcase — the presentation demo. Split-screen photoreal | false-color (the canonical shot), a fly-in, a 200-instance field, and the debug palette. Defaults to its own procedural 1M-triangle rock, and will import a real scan if you point it at one:

Code:
# one scan (OBJ or FBX; 8K PBR maps are found automatically next to the mesh)
EE_SHOWCASE_MESH=/path/Rock_Raw.fbx  EE_SHOWCASE_YAW=180  ./Tutorial_14_MeshletShowcase

# a whole Megascans kit: every sub-folder's scan becomes its OWN asset
EE_SHOWCASE_DIR=/path/quarry  EE_SHOWCASE_COUNT=45  EE_MESHLET_PRECULL=1  ./Tutorial_14_MeshletShowcase

Each tutorial exposes its knobs as env vars for A/B'ing without a rebuild — EE_MESHLET_VIS, EE_MESHLET_SW, EE_MESHLET_LOD_ERR, EE_MESHLET_PRECULL, EE_MESHLET_SHADOW_ERR, EE_MESHLET_TRI_CULL, EE_MESHLET_STREAM, and so on. (These are demo-side conveniences; the engine API is the GpuMeshlet flags above.)



Numbers

Measured on an AMD RX 6650 XT at 3072x1296, on real Megascans scans:
  • Stone wall scan — 1,000,000 tris (28k clusters): draws 71k (5.7%), 2.7 ms opaque.
  • Mine tunnel scan — 7,996,040 tris (194k clusters, 23 DAG levels): draws 144k (1.8%), 2.3 ms opaque.
  • Slate quarry45 distinct million-triangle scans = 48,977,263 triangles of unique geometry, 200 instances = 217,936,240 scene triangles: draws 363k (0.167%), 12.9 ms opaque.

And what precull does to that quarry scene (16 unique scans, 200 instances, ~212M scene triangles):

Code:
gpuOpaque    gpuLight    fps
  precull OFF      34.9 ms     115.0 ms    6.6
  precull ON        9.6 ms      25.5 ms   28.1
Pixel-identical output. If you're running scan-density content, turn precull on.



Caveats — read these
  • Vulkan only. Needs VK_EXT_mesh_shader. There is no DX12 or GL path yet; the classic renderer is untouched and still handles everything else.
  • Opt-in. Default-off, top to bottom. Nothing changes until GpuMeshlet.enabled = true.
  • 128 unique meshes per frame. Past that, worldAdd() returns false and the mesh keeps its classic draw — it degrades, it doesn't drop.
  • precull is default-off and tri_cull is default-off (the latter measured slower — the compute rasterizer already covers its best case).
  • Materials work on every path, including normal mapping with zero stored tangents (the resolve derives the tangent frame analytically) and alpha-tested cutouts. Non-opaque techniques fall back to the classic path.

Full write-up: docs/meshlets-design.md and docs/vsm-design.md. The README has expandable sections for both.


In this fork:
https://github.com/DrewGilpin/EsenthelEngine
07-14-2026 03:38 PM
Find all posts by this user Quote this message in a reply
Post Reply