Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- // M3a-e brought up a single-instance GPU accept/expand traversal, matching ClusterLODManager's
- // CPU SelectClusters exactly, but driven by one full CPU-orchestrated dispatch sequence PER
- // ClusterLODObject (a loop on the CPU side calling this shader once per instance, every frame).
- // That never matched the actual goal - a GPU-driven renderer needs to process every instance in
- // one shared batch, not re-run the whole traversal machinery per object.
- //
- // M3f (this step) batches the traversal: every ClusterLODObject that shares the same
- // ClusterLODData (and so the same DAG/buffers) is folded into ONE dispatch sequence for that
- // asset. Concretely:
- // - _InstanceData holds one (worldPosition.xyz, errorThreshold.w) entry per instance in the
- // batch, uploaded fresh every frame by ClusterLODManager.RenderGpuBatch (positions/instance
- // membership can change frame to frame).
- // - Every per-traversal buffer (_Counters, frontier, selected, claimed-groups) is laid out as
- // N fixed-size regions, one per instance, inside otherwise-ordinary shared buffers - instance
- // i's region always starts at i * (region size). This is a deliberate simplification: rather
- // than a single global atomic-append pool shared across every instance (which would need
- // per-instance bounds tracked some other way), each instance gets its own fixed capacity
- // (_CapacityPerInstance, sized exactly like the single-instance version's frontierCapacity -
- // see ClusterLODManager.GetOrBuildBuffers), and only ever touches its own region.
- // - Because each instance's capacity is now a fixed, CPU-known constant (not data/camera
- // dependent), the worst-case total thread count for any pass (instanceCount *
- // _CapacityPerInstance) is also a fixed, CPU-known constant - so TraverseLevel/FinalizeArgs
- // can always be dispatched at that fixed size with plain ComputeShader.Dispatch, no
- // DispatchIndirect/_DispatchArgs machinery needed anymore (that existed in M3d/e specifically
- // to avoid a CPU readback between passes when the real per-pass count wasn't knowable ahead of
- // time - with a fixed worst-case bound available, dispatching that bound directly and letting
- // idle threads self-cull via their own instance's currentCount check is simpler and just as
- // readback-free). The tradeoff: later passes, where most instances have far fewer live nodes
- // than the worst case, still dispatch the full fixed thread count with most threads idling -
- // a known, accepted cost of this first batched cut, and a candidate for a later optimization
- // pass (e.g. a global compaction step) if profiling ever shows it matters.
- //
- // Rendering itself is NOT batched yet in this step: ClusterLODManager still issues one
- // Graphics.RenderPrimitivesIndirect call per instance (via a startCommand offset into the shared
- // _Args buffer, which FinalizeArgs lays out in the same fixed-per-instance-region style), each
- // with that instance's own transform passed the same way it always was. That's what lets this
- // step land without touching ClusterDebug.shader at all - per-instance transform lookup inside a
- // single unified draw call is a separate, later step (M3g).
- //
- // M3h added a coarse, whole-object frustum cull on the CPU side (ClusterLODManager.Update) that
- // keeps a fully-invisible ClusterLODObject out of its batch entirely, before any of this even
- // dispatches for it. M3h-2 (this step) adds the finer-grained version Nanite-style renderers
- // actually rely on: a per-node bounding-sphere-vs-frustum test inside ProcessNode itself, right
- // alongside the existing per-node LOD-error test. A node whose own bounding sphere is fully
- // outside the frustum is dropped outright - not selected, not expanded into children - which is
- // sound under the same containment assumption the whole-object version already leans on (a
- // group's children are assumed to sit within their parent's bound), so culling higher in the DAG
- // safely prunes everything beneath it too, not just that one node. This needed a new per-meshlet
- // radius buffer (_MeshletRadius) because the existing _MeshletBoundingSphereError only ever
- // carried center+error, never the meshlet's actual bounding-sphere radius (Meshlet.boundingSphere.w
- // in ClusterLODManager.cs/Meshlet.cs), plus a per-frame _FrustumPlanes uniform (6 planes, shared by
- // every instance in the batch since only the camera moves) and a new per-instance culledCount
- // counter for observability parity with the rest of the health-check/dump tooling.
- //
- // Fix (found via a scaled ClusterLODObject culling incorrectly): every meshlet's object-space
- // bounding sphere/error was being used directly in world-space math as if every instance had
- // scale 1 - added _InstanceScale (one conservative uniform-scale factor per instance, see
- // ClusterLODManager.ComputeConservativeScale) and applied it to sphere center offset, sphere
- // radius, and the LOD-error metric everywhere ProcessNode uses them.
- //
- // Fix (found via a rotated ClusterLODObject whose whole cluster-LOD mesh vanished at certain
- // camera angles, while a reference standard-mesh renderer of the same object stayed visible):
- // rotation was being ignored entirely the same way scale used to be - a meshlet's object-space
- // bounding-sphere offset was only ever translated, never rotated, by the instance's actual
- // transform.rotation. Added _InstanceRotation (one quaternion per instance) and
- // RotateVectorByQuaternion, applied to the sphere center offset in ProcessNode. The coarse
- // whole-object cull in ClusterLODManager.Update was unaffected (its test sphere is centered
- // exactly on the instance's own origin, so rotating a zero offset is a no-op) - only this
- // per-node math needed the fix.
- //
- // M3i adds scaffolding for the real centerpiece feature this whole M3 series has been building
- // toward - a streaming/paging system where clusters aren't all resident on the GPU all the time.
- // It did NOT implement streaming itself; it only wired up the mechanism a page loader would need:
- // - _Residency is one uint per meshlet (asset-scoped, shared by every instance - residency is a
- // property of the bake's data, not of any one instance), built once by
- // ClusterLODManager.GetOrBuildBuffers.
- // - ProcessNode checks it before doing anything else with a node; if a node is non-resident, its
- // meshlet index gets appended (InterlockedAdd-counted, capped, with an overflow counter - same
- // pattern as every other append buffer in this file) to _StreamingRequests, for a page loader
- // to read back and act on (M4a's async readback consumes this).
- //
- // M4d/M4e (this step) make residency real and give the non-resident path an actual fallback:
- // - Residency is no longer all-1s. ClusterLODManager.GetOrBuildBuffers now marks only DAG roots
- // (Meshlet.groupIndex < 0) resident - the coarse, always-available baseline - and everything
- // else not resident. This is a pure function of the existing bake data (no new mutable runtime
- // state yet); real incremental promotion/eviction as pages actually load is M4f+.
- // - When ProcessNode finds a non-resident node, it no longer pretends the node is resident (M3i's
- // placeholder). It looks up meshletIndex -> _MeshletGroupIndex (the group this meshlet is a
- // child of, i.e. a COARSER representation) -> that group's ENTIRE parent meshlet range (see
- // ResolveAndSelectFallbacks) and selects every meshlet in it. A group's parent range can (and
- // often does) hold more than one meshlet - AppendMeshlets splits a large simplified result into
- // several output meshlets from a single call - so a fallback can't just follow one
- // representative (an earlier version of this that only followed parentRange.x collapsed every
- // sibling in a multi-parent group onto the same single meshlet; see git history/PR notes for
- // the bug report that caught it). Every entry in a parent range is always already resident
- // under today's rule (residency == DAG root), so this only ever needs one hop - the per-
- // candidate residency check inside ResolveAndSelectFallbacks is a guard against that changing,
- // not something exercised today.
- // - Multiple sibling (or cousin) non-resident nodes commonly resolve to the same fallback
- // candidate(s) (today, every non-root in a branch falls back to that branch's root set).
- // Selecting a candidate more than once per such node would draw the same cluster multiple times
- // - the exact class of bug the M3d/M3e group-claim dedup already exists to prevent, just one
- // level removed. _ClaimedFallbacks applies the identical InterlockedCompareExchange claim-once
- // pattern, keyed by fallback meshlet index instead of group index, reset to all-zero once per
- // frame by ClusterLODManager.RenderGpuBatch exactly like _ClaimedGroups.
- // - Bugfix (post-M4f): expanding into a group used to happen unconditionally once a node was
- // found insufficient, regardless of whether the group's children were actually resident yet.
- // That let a child that happened to already be loaded get selected on its own merits while its
- // still-non-resident siblings independently fell back to the SAME shared parent - both
- // rendering at once, visibly overlapping. ProcessNode now checks whether an entire group is
- // resident before expanding into it; if not, the coarser node selects itself instead (a DAG cut
- // must be all-or-nothing per group) and the specific missing children get requested directly.
- // - M4d/M4e deliberately left GPU memory usage untouched (every meshlet's geometry stayed fully
- // uploaded regardless of residency). M4f (this step) closes that gap: ClusterLODManager now
- // maintains _Positions/_Triangles (still those exact property names - ClusterDebug.shader is
- // completely unchanged) as a growable, page-local POOL instead of a fixed, fully-resident
- // array. Only meshlets that have actually been loaded (via ClusterLODPageLoader, reading
- // ClusterLODPagePacker's on-disk pages) occupy space in it, starting with just the resident
- // root set and growing as ClusterLODManager's streaming loop consumes _StreamingRequests
- // (via M4a's async readback) and promotes newly-loaded meshlets to resident. FinalizeArgs'
- // startVertex now comes from _MeshletPoolIndexOffset (a per-meshlet lookup into the pool) since
- // a meshlet's location depends on load order, not a formula derivable from its index alone -
- // see ClusterLODManager.AppendMeshletPageToPool for how a page's local triangle indices get
- // pre-offset by its vertex-pool base at append time, so no extra indirection is needed here or
- // in the shader.
- //
- // M6/M6b: FinalizeArgs (removed in M7 below) laid _Args out as one command slot per potential
- // selected meshlet per instance - a fixed cap (ClusterLODManager.MaxSelectedPerInstance, later made
- // adaptive/M6b) capped how many of those slots actually got draw commands, replacing the original
- // "one slot per meshlet in the whole bake" scheme that caused the M7 bottleneck described below.
- //
- // M7 replaces FinalizeArgs' one-draw-command-per-selected-cluster scheme entirely. M6/M6b had
- // already cut per-instance draw count down from "every meshlet in the bake" to "an adaptively-sized
- // cap on selected clusters", but profiling a 144-instance stress test (Stanford bunny, worst case
- // ~10M triangles) still showed ~90,000 indirect draw commands per frame and ~30fps - each draw
- // averaging under 126 triangles (ClusterMaxPrimitives), squarely in the regime where per-draw GPU
- // submission/processing overhead dominates over actual shading work. No amount of tuning the cap
- // fixes that; the fundamental issue is issuing one hardware draw call per cluster at all.
- //
- // The fix: stop treating each cluster as a separate draw and instead treat it as a GPU INSTANCE
- // within a single draw call. CompactSelectedForRun flattens every selected cluster across a batch
- // run's instances into one tightly-packed list (no more dense, mostly-empty per-instance-slot
- // regions), and ClusterLODManager issues exactly ONE indirect draw per material run, with
- // instanceCount == the compacted cluster count (written by WriteInstancedArgs from a GPU-computed
- // total - no CPU readback, no cap, no overflow counter needed, since the compacted buffer is sized
- // to _CapacityPerInstance * instanceCount, the same bound already proven safe for _Selected
- // itself). Each cluster becomes SV_InstanceID in ClusterDebug.shader, which looks up which
- // ClusterLODObject and which meshlet that instance is via _CompactedInstanceIndex/
- // _CompactedMeshletIndex.
- //
- // The catch with hardware instancing: every instance in one draw call must share the same
- // vertexCountPerInstance - but clusters have different real triangle counts (up to
- // ClusterMaxPrimitives, rarely exactly that many). Fixed by padding: the draw always requests
- // ClusterMaxPrimitives*3 vertices per instance (the worst case), and ClusterDebug.shader collapses
- // any vertex beyond a cluster's own real index count (_MeshletIndexCounts) to a single fixed point,
- // making that padding triangle zero-area and contributing nothing. This wastes some vertex shader
- // invocations on padding (worst case ~2x for a cluster at half its max primitive count) in exchange
- // for eliminating tens of thousands of draw-command submissions - a clear win in the regime this was
- // profiled in, though worth remembering if a bake's clusters end up far under ClusterMaxPrimitives
- // on average.
- //
- // Future-proofing note: this whole instancing-plus-degenerate-padding scheme is a software
- // emulation of what a hardware mesh shader pipeline does natively and more efficiently. In a real
- // mesh-shader renderer, a task/amplification shader would run per meshlet-GROUP, deciding
- // culling/LOD expansion (replacing TraverseRoots/TraverseLevel/ProcessNode here) and amplifying into
- // exactly one mesh shader invocation per surviving meshlet, which then emits that meshlet's actual
- // (variable, non-padded) vertex/primitive count directly - no CPU-orchestrated multi-pass traversal,
- // no compacted args buffer, no fixed-size padding waste, no separate compute dispatch sequence at
- // all. As of when this was written, Unity has no public, portable scripting API for mesh/task
- // shaders (the feature is listed as "under consideration"; the only way to touch them today is a
- // native render plugin, which isn't practical for a portable, shippable package). If/when Unity
- // exposes this, TraverseRoots/TraverseLevel/Advance/ProcessNode's traversal logic is the part that
- // would move into a task shader almost unchanged (it already thinks in terms of "does this node
- // pass, does it expand into children" - a task shader's amplification decision is exactly that),
- // while CompactSelectedForRun/WriteInstancedArgs/the padding trick in ClusterDebug.shader would
- // simply disappear, replaced by the mesh shader's native per-meshlet output.
- #pragma kernel TraverseRoots
- #pragma kernel TraverseLevel
- #pragma kernel Advance
- #pragma kernel CompactSelectedForRun
- #pragma kernel WriteInstancedArgs
- // Do NOT declare _Args as RWStructuredBuffer<IndirectDrawArgs> (a custom 4-uint struct). This is a
- // documented Unity/driver bug: on some AMD GPUs under DirectX, a RWStructuredBuffer<T> for a
- // custom struct silently uses a stride of 4 bytes instead of the struct's real size, so writes
- // for element N land only 4 bytes after element N-1 instead of sizeof(T) bytes after - every
- // element after the first overlaps/smears into its neighbors. The documented fix: only ever
- // declare indirect-args buffers as (RW)StructuredBuffer<uint> (or (RW)Buffer<uint>) in a compute
- // shader, and index by hand in units of uint. See
- // https://discussions.unity.com/t/do-not-use-unitys-indirectdrawindexedargs-in-a-structuredbuffer/1623092
- static const uint IndirectDrawArgsUintCount = 4; // vertexCountPerInstance, instanceCount, startVertex, startInstance
- // Counter layout: one CountersPerInstance-uint block per batch instance, at
- // [instanceIndex * CountersPerInstance + k]:
- // [0] currentFrontierCount - how many entries in this instance's frontier region are valid.
- // [1] nextFrontierCount - accumulated via InterlockedAdd as this instance's failed nodes expand.
- // [2] selectedCount - cumulative across every pass so far (never reset mid-traversal).
- // [3] overflowCount - how many appends were dropped because this instance's region was full.
- // [4] corruptionCount - how many nodes were dropped for an out-of-range meshlet/group index.
- // [5] culledCount - how many nodes were dropped for being fully outside the frustum (M3h-2).
- //
- // M6 added a 7th slot (argsOverflowCount) for FinalizeArgs' fixed-cap draw-command scheme; M7
- // removed both that slot and FinalizeArgs itself - CompactSelectedForRun's compacted buffer is
- // sized to the same proven-safe bound as _Selected (_CapacityPerInstance * instanceCount), so it
- // can never overflow by construction and needs no counter for it.
- static const uint CountersPerInstance = 6u;
- RWStructuredBuffer<uint> _Counters;
- // This pass's input (read-only) and output (accumulated via InterlockedAdd, within each
- // instance's own fixed-size region - see file header) node-index lists. Pass 0 (TraverseRoots)
- // doesn't read _CurrentFrontier at all - it reads _Roots directly, the same for every instance.
- StructuredBuffer<uint> _CurrentFrontier;
- RWStructuredBuffer<uint> _NextFrontier;
- // Every node that passes its test lands here, in its instance's own fixed-size region (cumulative
- // across all passes), consumed by FinalizeArgs at the end of the traversal.
- RWStructuredBuffer<uint> _Selected;
- // Per-group claim flags, one _GroupsCount-sized region per instance (offset instanceIndex *
- // _GroupsCount) - see the dedup explanation in the M3d/M3e history (ClusterLODManager.cs). Reset
- // to all-zero once per frame by ClusterLODManager.RenderGpuBatch before pass 0.
- RWStructuredBuffer<uint> _ClaimedGroups;
- // One (worldPosition.xyz, errorThreshold.w) entry per instance in this batch.
- StructuredBuffer<float4> _InstanceData;
- int _InstanceCount;
- // Fix: a conservative uniform-scale approximation of each instance's transform.lossyScale (see
- // ClusterLODManager.ComputeConservativeScale - the largest axis, since an exact per-axis scale
- // would turn a sphere into an ellipsoid). Every meshlet's object-space bounding-sphere
- // center/radius and error were previously being used directly in world-space math as if every
- // instance had scale 1 - wrong for a scaled object in both directions (wrong LOD cut, wrong
- // culling). Kept as a separate per-instance buffer rather than a fifth _InstanceData component
- // since float4 is already full (xyz + errorThreshold).
- StructuredBuffer<float> _InstanceScale;
- // Bugfix (found via a rotated object whose whole cluster-LOD mesh vanished at certain camera
- // angles, while a reference standard-mesh renderer stayed visible): every meshlet's object-space
- // bounding-sphere offset was only ever translated by the instance's world position, never rotated
- // by its actual transform.rotation - wrong for a rotated object with an off-center root/meshlet,
- // since the coarse whole-object cull (rotation-invariant - it's centered exactly on the instance's
- // own origin) would still let traversal start, but this per-node math would compute a drifted
- // world position that could fall outside the frustum at some angles even though the real,
- // correctly-rotated geometry is still inside it. One quaternion (x,y,z,w, matching Unity's
- // Quaternion layout exactly) per instance, applied via RotateVectorByQuaternion below.
- StructuredBuffer<float4> _InstanceRotation;
- // Standard optimized quaternion-vector rotation (q * v * q^-1 without forming q^-1 explicitly) -
- // no assumptions beyond q being a unit quaternion, which Unity's Quaternion (the CPU-side source
- // of _InstanceRotation) always is.
- float3 RotateVectorByQuaternion(float4 q, float3 v)
- {
- float3 t = 2.0 * cross(q.xyz, v);
- return v + q.w * t + cross(q.xyz, t);
- }
- // Per meshlet (indexed by any meshlet index, root or child, shared by every instance since they
- // all use the same ClusterLODData): xyz = object-space bounding sphere center, w = error. Per
- // meshlet: the group (if any) whose children refine it further, or -1 for a true leaf.
- StructuredBuffer<float4> _MeshletBoundingSphereError;
- StructuredBuffer<int> _MeshletParentGroup;
- int _MeshletsCount;
- // M3h-2: the meshlet's own bounding-sphere radius (Meshlet.boundingSphere.w in
- // ClusterLODManager.cs/Meshlet.cs) - kept as a separate buffer rather than folded into
- // _MeshletBoundingSphereError because that buffer's w component is already the LOD-error metric,
- // a different value entirely (see ClusterLODManager.GetOrBuildBuffers).
- StructuredBuffer<float> _MeshletRadius;
- // Six frustum planes (xyz = normal, w = distance, matching Unity's Plane struct / Plane.GetDistanceToPoint
- // convention: dot(normal, point) + distance), uploaded once per frame by ClusterLODManager - shared
- // by every instance in the batch since only the camera's planes change, not any instance's own data.
- float4 _FrustumPlanes[6];
- // M5: screen-space LOD error metric - mirrors ClusterLODManager.projectionScale/cameraOrthographic
- // (see that field's comment for the exact derivation), uploaded once per frame alongside
- // _FrustumPlanes since both only depend on the active camera, not any instance's own data.
- // _ProjectionScale converts a world-space error radius into screen pixels: for a perspective
- // camera, pixels = worldError * _ProjectionScale / distance; for an orthographic one
- // (_Orthographic != 0) there's no perspective divide, so pixels = worldError * _ProjectionScale
- // directly. Replaces the old distance*errorThreshold heuristic, whose "acceptable error" depended
- // on camera FOV/resolution in a way that made the same threshold look different across scenes.
- float _ProjectionScale;
- int _Orthographic;
- // M3i: 1 = resident, 0 = not - one entry per meshlet, asset-scoped (shared by every instance,
- // built once by ClusterLODManager.GetOrBuildBuffers and currently always all-1s - see file header
- // for why). _StreamingRequests is an append-only list of meshlet indices whose residency bit was
- // found to be 0, up to _StreamingRequestCapacity entries; _StreamingRequestCounter is a 2-uint
- // [requestCount, overflowCount] pair, reset to zero once per frame by
- // ClusterLODManager.RenderGpuBatch, the same append-with-overflow-counter pattern used everywhere
- // else in this file (see e.g. _Counters' overflowCount). None of this is per-instance because
- // residency/streaming is a property of the shared asset, not of any one instance's traversal.
- StructuredBuffer<uint> _Residency;
- RWStructuredBuffer<uint> _StreamingRequests;
- RWStructuredBuffer<uint> _StreamingRequestCounter;
- int _StreamingRequestCapacity;
- // M4d/M4e: per meshlet, the group this meshlet is a CHILD of (i.e. a coarser representation of it)
- // - -1 for a DAG root. This is the reverse direction of _MeshletParentGroup (which points to the
- // group whose children are a FINER version of this meshlet, used for expansion) - needed here to
- // walk UP toward a resident ancestor instead of down toward finer detail. Per group, the meshlet
- // range its simplification produced (the coarser output) - same (offset, count) encoding as
- // _GroupChildOffsetCount, just pointing the other way in the DAG.
- StructuredBuffer<int> _MeshletGroupIndex;
- StructuredBuffer<uint2> _GroupParentMeshletOffsetCount;
- // M4d/M4e: per-instance claim flags for fallback-ancestor selection, one _MeshletsCount-sized
- // region per instance (offset instanceIndex * _MeshletsCount) - see file header for why this dedup
- // is load-bearing, not an optimization. Reset to all-zero once per frame by
- // ClusterLODManager.RenderGpuBatch, same as _ClaimedGroups.
- RWStructuredBuffer<uint> _ClaimedFallbacks;
- // Per group: (childMeshletOffset, childMeshletCount) into _GroupChildMeshletIndices.
- StructuredBuffer<uint2> _GroupChildOffsetCount;
- StructuredBuffer<uint> _GroupChildMeshletIndices;
- int _GroupsCount;
- // Root meshlet indices - the same list for every instance of this asset.
- StructuredBuffer<uint> _Roots;
- int _RootCount;
- // Fixed per-instance region size for _NextFrontier/_Selected/_CurrentFrontier (frontier regions)
- // and the corresponding bound used when writing into them.
- int _CapacityPerInstance;
- // Per-frame view state, shared by every instance in the batch (only the camera moves - each
- // instance's own world position comes from _InstanceData above).
- float3 _CameraWorldPosition;
- // M3h-2: fully outside test only (never claims partial visibility) - a node whose sphere merely
- // straddles a plane is treated as visible, same conservative direction as ClusterLODManager's
- // CPU-side IsSphereVisible.
- bool IsSphereVisible(float3 center, float radius)
- {
- [unroll]
- for (int i = 0; i < 6; i++)
- {
- float4 plane = _FrustumPlanes[i];
- if (dot(plane.xyz, center) + plane.w < -radius)
- {
- return false;
- }
- }
- return true;
- }
- // Claims (dedup, per-instance) and appends one fallback candidate to _Selected - shared by every
- // call site in ResolveAndSelectFallbacks below so the claim-then-append pattern is written once.
- void ClaimAndSelectFallback(uint instanceIndex, uint counterBase, uint regionBase, uint candidate)
- {
- uint claimBase = instanceIndex * (uint)_MeshletsCount;
- uint previousFallbackClaim;
- InterlockedCompareExchange(_ClaimedFallbacks[claimBase + candidate], 0u, 1u, previousFallbackClaim);
- if (previousFallbackClaim != 0u)
- {
- return;
- }
- uint outIndex;
- InterlockedAdd(_Counters[counterBase + 2], 1u, outIndex);
- if (outIndex < (uint)_CapacityPerInstance)
- {
- _Selected[regionBase + outIndex] = candidate;
- }
- else
- {
- uint dummy;
- InterlockedAdd(_Counters[counterBase + 3], 1u, dummy);
- }
- }
- // Bugfix (found via a bake whose top level has 4 root meshlets - see history below): a
- // ClusterGroup's simplification can produce MORE THAN ONE parent meshlet (AppendMeshlets splits a
- // large simplified result into several output meshlets from one call - ClusterGroup.
- // parentMeshletCount reflects this). The original version of this function only ever followed
- // parentRange.x (the FIRST parent meshlet), so every non-resident child - regardless of which of
- // the N siblings it geometrically belongs to - collapsed onto that same single meshlet. Fix:
- // select EVERY meshlet in the parent range, each deduped independently via _ClaimedFallbacks (so N
- // different children of the same group all requesting a fallback still only add each of the
- // group's parent meshlets once). Under today's residency rule ("resident" == DAG root), every
- // entry in a parent range is always already resident by construction, so this never needs to
- // recurse further than one hop - the per-candidate residency check below only guards against that
- // assumption ever changing (e.g. once M4f introduces partial, non-root residency and a parent
- // range's own members might themselves need a further fallback).
- void ResolveAndSelectFallbacks(uint instanceIndex, uint meshletIndex)
- {
- uint counterBase = instanceIndex * CountersPerInstance;
- uint regionBase = instanceIndex * (uint)_CapacityPerInstance;
- int groupIdx = _MeshletGroupIndex[meshletIndex];
- if (groupIdx < 0)
- {
- // A root (groupIndex < 0) is always resident by construction, so reaching this function
- // for one (the caller only calls it for non-resident nodes) would itself mean
- // stale/corrupted data - select itself rather than drop it silently.
- ClaimAndSelectFallback(instanceIndex, counterBase, regionBase, meshletIndex);
- return;
- }
- if (groupIdx >= (int)_GroupsCount)
- {
- uint corruptionDummy;
- InterlockedAdd(_Counters[counterBase + 4], 1u, corruptionDummy);
- return;
- }
- uint2 parentRange = _GroupParentMeshletOffsetCount[(uint)groupIdx];
- if (parentRange.y == 0u)
- {
- uint corruptionDummy;
- InterlockedAdd(_Counters[counterBase + 4], 1u, corruptionDummy);
- return;
- }
- for (uint k = 0; k < parentRange.y; k++)
- {
- uint candidate = parentRange.x + k;
- if (candidate >= (uint)_MeshletsCount)
- {
- uint corruptionDummy;
- InterlockedAdd(_Counters[counterBase + 4], 1u, corruptionDummy);
- continue;
- }
- if (_Residency[candidate] == 0u)
- {
- // Not reachable under today's residency rule (see comment above) - guarded rather than
- // assumed, so a future partial-residency scheme (M4f+) doesn't silently render
- // not-actually-resident geometry if this ever fires. The streaming request for the
- // original meshletIndex (recorded by the caller) is the correct signal in that case,
- // not a recursive walk from here.
- continue;
- }
- ClaimAndSelectFallback(instanceIndex, counterBase, regionBase, candidate);
- }
- }
- // Tests one node (already known to belong to instanceIndex) and either selects it or expands it
- // into its instance's next-frontier region - shared by TraverseRoots and TraverseLevel so the
- // accept/expand logic only needs to be written (and read) once.
- void ProcessNode(uint instanceIndex, uint meshletIndex)
- {
- uint counterBase = instanceIndex * CountersPerInstance;
- uint regionBase = instanceIndex * (uint)_CapacityPerInstance;
- // Mirrors SelectClusters' out-of-range meshlet index check (see ClusterLODManager.cs M3e
- // history) - a corrupted/stale bake could otherwise send this thread reading past the end of
- // every per-meshlet buffer below. Drop just this one node and count it.
- if (meshletIndex >= (uint)_MeshletsCount)
- {
- uint corruptionDummy;
- InterlockedAdd(_Counters[counterBase + 4], 1u, corruptionDummy);
- return;
- }
- float4 instanceData = _InstanceData[instanceIndex];
- float3 objectWorldPosition = instanceData.xyz;
- // M5/M6c: target screen-space error in pixels (ClusterLODManager.targetScreenSpaceError - one
- // scene-wide value, uploaded identically for every instance, not per-object) - renamed from the
- // old errorThreshold to match, since its unit/meaning changed entirely back in M5.
- float targetScreenSpaceError = instanceData.w;
- float objectScale = _InstanceScale[instanceIndex];
- float4 objectRotation = _InstanceRotation[instanceIndex];
- float4 sphereError = _MeshletBoundingSphereError[meshletIndex];
- int parentGroupIndex = _MeshletParentGroup[meshletIndex];
- float radius = _MeshletRadius[meshletIndex] * objectScale;
- // Bugfix: rotate the local offset by the instance's actual rotation before translating - see
- // _InstanceRotation's declaration above for the full story on why omitting this could make a
- // rotated instance's whole cluster-LOD mesh disappear at certain camera angles.
- float3 worldCenter = objectWorldPosition + RotateVectorByQuaternion(objectRotation, sphereError.xyz * objectScale);
- // M3h-2: a node fully outside the frustum is dropped outright - not selected, not expanded -
- // regardless of whether its own LOD error would otherwise have been sufficient. Safe to prune
- // its whole subtree here under the same containment assumption the coarse whole-object cull in
- // ClusterLODManager.Update already relies on (a group's children sit within their parent's
- // bound).
- if (!IsSphereVisible(worldCenter, radius))
- {
- uint culledDummy;
- InterlockedAdd(_Counters[counterBase + 5], 1u, culledDummy);
- return;
- }
- // M4d/M4e: a non-resident node can't be trusted to decide anything about itself (we don't have
- // its geometry) - resolve and select its nearest resident ancestor instead, with per-instance
- // dedup so siblings that share a fallback don't select it more than once. Checked after the
- // frustum test (no point requesting/falling back for a node that's invisible anyway) but before
- // the LOD-sufficiency test (which would otherwise wrongly use this node's own error/geometry).
- if (_Residency[meshletIndex] == 0u)
- {
- uint requestIndex;
- InterlockedAdd(_StreamingRequestCounter[0], 1u, requestIndex);
- if (requestIndex < (uint)_StreamingRequestCapacity)
- {
- _StreamingRequests[requestIndex] = meshletIndex;
- }
- else
- {
- uint requestOverflowDummy;
- InterlockedAdd(_StreamingRequestCounter[1], 1u, requestOverflowDummy);
- }
- ResolveAndSelectFallbacks(instanceIndex, meshletIndex);
- return;
- }
- // M5: projected screen-space error, replacing the old distance*errorThreshold heuristic - see
- // _ProjectionScale's declaration above and ClusterLODManager.IsDetailSufficient (the CPU
- // mirror of this exact formula) for the full derivation. sphereError.w (Meshlet.error) is an
- // object-space error bound - scale it by the same factor as the geometry (objectScale) to get
- // a world-space error, then project that into actual screen pixels at this node's distance.
- float d = max(0.0, distance(_CameraWorldPosition, worldCenter));
- float distanceFactor = _Orthographic != 0 ? 1.0 : max(d, 0.0001);
- float worldError = sphereError.w * objectScale;
- float screenError = worldError * _ProjectionScale / distanceFactor;
- bool sufficient = (parentGroupIndex < 0) || (screenError <= targetScreenSpaceError);
- // Bugfix (found via a child cluster loading independently of its siblings and rendering ON TOP
- // OF its still-selected parent - overlapping, doubled-up geometry): a DAG cut has to be
- // all-or-nothing per group - a group's children collectively tile exactly their parent's
- // footprint, so showing some of them while the parent is ALSO shown (because its other,
- // still-non-resident children fell back to it) double-covers that area. So: before ever
- // expanding into a group, check whether every one of its children is resident; if not, force
- // this node to select itself (as if sufficient) instead of expanding, and directly request
- // whichever specific children are still missing - they won't be visited via the frontier while
- // expansion stays blocked, so nothing else would ever request them. Not deduped across the
- // (typically few) sibling threads that might all reach this same group - same append-with-
- // overflow-counter tolerance every other streaming request in this file already has.
- if (!sufficient && parentGroupIndex >= 0 && parentGroupIndex < (int)_GroupsCount)
- {
- uint2 childRangeForResidency = _GroupChildOffsetCount[(uint)parentGroupIndex];
- bool allChildrenResident = true;
- for (uint rc = 0; rc < childRangeForResidency.y; rc++)
- {
- uint residencyCheckChild = _GroupChildMeshletIndices[childRangeForResidency.x + rc];
- if (residencyCheckChild >= (uint)_MeshletsCount)
- {
- continue; // the existing corruption check below still catches this properly
- }
- if (_Residency[residencyCheckChild] == 0u)
- {
- allChildrenResident = false;
- uint requestIndex;
- InterlockedAdd(_StreamingRequestCounter[0], 1u, requestIndex);
- if (requestIndex < (uint)_StreamingRequestCapacity)
- {
- _StreamingRequests[requestIndex] = residencyCheckChild;
- }
- else
- {
- uint requestOverflowDummy;
- InterlockedAdd(_StreamingRequestCounter[1], 1u, requestOverflowDummy);
- }
- }
- }
- if (!allChildrenResident)
- {
- sufficient = true;
- }
- }
- if (sufficient)
- {
- uint outIndex;
- InterlockedAdd(_Counters[counterBase + 2], 1u, outIndex);
- if (outIndex < (uint)_CapacityPerInstance)
- {
- _Selected[regionBase + outIndex] = meshletIndex;
- }
- else
- {
- uint dummy;
- InterlockedAdd(_Counters[counterBase + 3], 1u, dummy);
- }
- }
- else if (parentGroupIndex >= (int)_GroupsCount)
- {
- // Mirrors SelectClusters' out-of-range group index check - same reasoning as above.
- uint corruptionDummy;
- InterlockedAdd(_Counters[counterBase + 4], 1u, corruptionDummy);
- }
- else
- {
- // Claim this group (within this instance's own claim region) before expanding it - only
- // the thread that actually flips the flag from 0 to 1 proceeds. See the M3d/M3e history in
- // ClusterLODManager.cs for why this dedup is load-bearing, not an optimization.
- uint claimBase = instanceIndex * (uint)_GroupsCount;
- uint previousClaim;
- InterlockedCompareExchange(_ClaimedGroups[claimBase + (uint)parentGroupIndex], 0u, 1u, previousClaim);
- if (previousClaim == 0u)
- {
- uint2 range = _GroupChildOffsetCount[(uint)parentGroupIndex];
- uint childOffset = range.x;
- uint childCount = range.y;
- for (uint c = 0; c < childCount; c++)
- {
- uint childIndex = _GroupChildMeshletIndices[childOffset + c];
- uint outIndex;
- InterlockedAdd(_Counters[counterBase + 1], 1u, outIndex);
- if (outIndex < (uint)_CapacityPerInstance)
- {
- _NextFrontier[regionBase + outIndex] = childIndex;
- }
- else
- {
- uint dummy;
- InterlockedAdd(_Counters[counterBase + 3], 1u, dummy);
- }
- }
- }
- }
- }
- // Pass 0: every instance's roots are identical (data.RootMeshletIndices), so this is dispatched at
- // a tight, CPU-known size (instanceCount * rootCount) rather than the fixed worst-case bound
- // TraverseLevel uses - no per-instance "current count" bookkeeping needed yet, since every
- // instance starts with exactly _RootCount live nodes.
- [numthreads(64, 1, 1)]
- void TraverseRoots(uint3 id : SV_DispatchThreadID)
- {
- uint instanceIndex = id.x / (uint)_RootCount;
- if (instanceIndex >= (uint)_InstanceCount)
- {
- return;
- }
- uint rootLocal = id.x % (uint)_RootCount;
- uint meshletIndex = _Roots[rootLocal];
- ProcessNode(instanceIndex, meshletIndex);
- }
- // Passes 1+: dispatched at the fixed worst-case size (instanceCount * _CapacityPerInstance) every
- // time - most threads beyond a given instance's actual currentFrontierCount just return
- // immediately (see file header for why this is simpler than per-instance indirect dispatch args).
- [numthreads(64, 1, 1)]
- void TraverseLevel(uint3 id : SV_DispatchThreadID)
- {
- uint instanceIndex = id.x / (uint)_CapacityPerInstance;
- if (instanceIndex >= (uint)_InstanceCount)
- {
- return;
- }
- uint localSlot = id.x % (uint)_CapacityPerInstance;
- uint counterBase = instanceIndex * CountersPerInstance;
- uint currentCount = _Counters[counterBase + 0];
- if (localSlot >= currentCount)
- {
- return;
- }
- uint regionBase = instanceIndex * (uint)_CapacityPerInstance;
- uint meshletIndex = _CurrentFrontier[regionBase + localSlot];
- ProcessNode(instanceIndex, meshletIndex);
- }
- // Runs once per instance between passes: promotes next->current and resets next to zero. Dispatched
- // at a fixed, CPU-known size (instanceCount) every pass - no dispatch-args buffer needed now that
- // TraverseLevel's own dispatch size is also a fixed worst-case bound rather than exactly-sized.
- [numthreads(64, 1, 1)]
- void Advance(uint3 id : SV_DispatchThreadID)
- {
- if (id.x >= (uint)_InstanceCount)
- {
- return;
- }
- uint counterBase = id.x * CountersPerInstance;
- uint nextCount = _Counters[counterBase + 1];
- _Counters[counterBase + 0] = nextCount;
- _Counters[counterBase + 1] = 0u;
- }
- // M7: single combined indirect-args buffer for the batch's CURRENT material run only (see
- // ClusterLODManager.RenderGpuBatch's render loop) - always exactly IndirectDrawArgsUintCount (4)
- // uints, reused/overwritten for each run in turn (runs are processed one at a time on the CPU, so
- // there's no concurrent-run risk). Same "never declare as RWStructuredBuffer<IndirectDrawArgs>"
- // AMD stride workaround as always - see IndirectDrawArgsUintCount's own comment.
- RWStructuredBuffer<uint> _Args;
- // M7: how many vertices a padded cluster instance's draw range covers - always
- // ClusterMaxPrimitives*3 (the worst case), regardless of any individual cluster's real index count.
- // ClusterDebug.shader is what actually collapses the unused tail into degenerate triangles; this
- // compute shader only needs the value to write it into _Args.
- int _PaddedVertexCountPerCluster;
- // M7: this run's compacted cluster list - one entry per selected cluster across every instance in
- // the run, written by CompactSelectedForRun below and consumed directly by ClusterDebug.shader
- // (indexed by SV_InstanceID) instead of FinalizeArgs' old per-instance-slot _Args/_CmdMeshletIndex
- // scheme. Sized generously (instanceCount * _CapacityPerInstance, the same bound already proven
- // safe for _Selected itself - see ClusterLODManager.RenderGpuBatch) so it can never overflow;
- // _CompactedCounter is reset to zero by the CPU before each run's compaction dispatch and read back
- // into _Args by WriteInstancedArgs immediately after, with no CPU sync point either way.
- RWStructuredBuffer<uint> _CompactedInstanceIndex;
- RWStructuredBuffer<uint> _CompactedMeshletIndex;
- RWStructuredBuffer<uint> _CompactedCounter;
- // M7: which instances (a contiguous range within the batch, same instances a material run
- // already covers - see ClusterLODManager.RenderGpuBatch's material-sorted run loop) this
- // compaction dispatch should flatten.
- int _RunStartInstance;
- int _RunInstanceCount;
- // Runs once per material run, at a fixed CPU-known dispatch size (runInstanceCount *
- // _CapacityPerInstance) - one thread per potential _Selected slot for this run's instances (mirrors
- // TraverseLevel's own "most threads idle past the real count" approach). Every valid entry
- // (localSlot < that instance's selectedCount) gets flattened into _CompactedInstanceIndex/
- // _CompactedMeshletIndex at a globally-unique, tightly-packed index - no gaps, unlike the
- // per-instance-region layout _Selected itself uses (which is only gap-free WITHIN one instance,
- // not across instances, since each instance's own region is _CapacityPerInstance-sized regardless
- // of its real selectedCount).
- [numthreads(64, 1, 1)]
- void CompactSelectedForRun(uint3 id : SV_DispatchThreadID)
- {
- uint localInstance = id.x / (uint)_CapacityPerInstance;
- if (localInstance >= (uint)_RunInstanceCount)
- {
- return;
- }
- uint instanceIndex = (uint)_RunStartInstance + localInstance;
- uint localSlot = id.x % (uint)_CapacityPerInstance;
- uint counterBase = instanceIndex * CountersPerInstance;
- uint selectedCount = _Counters[counterBase + 2];
- if (localSlot >= selectedCount)
- {
- return;
- }
- uint regionBase = instanceIndex * (uint)_CapacityPerInstance;
- uint meshletIndex = _Selected[regionBase + localSlot];
- uint globalSlot;
- InterlockedAdd(_CompactedCounter[0], 1u, globalSlot);
- _CompactedInstanceIndex[globalSlot] = instanceIndex;
- _CompactedMeshletIndex[globalSlot] = meshletIndex;
- }
- // Runs once per material run, at a fixed 1-thread dispatch, immediately after
- // CompactSelectedForRun - writes this run's actual compacted count (no CPU readback) into _Args as
- // a standard hardware-instanced draw: vertexCountPerInstance = _PaddedVertexCountPerCluster
- // (fixed/padded, same for every instance), instanceCount = the real compacted total, startVertex/
- // startInstance = 0 (there's only ever this one run's own data in _Args at draw time).
- [numthreads(1, 1, 1)]
- void WriteInstancedArgs(uint3 id : SV_DispatchThreadID)
- {
- uint count = _CompactedCounter[0];
- _Args[0] = (uint)_PaddedVertexCountPerCluster;
- _Args[1] = count;
- _Args[2] = 0u;
- _Args[3] = 0u;
- }
Add Comment
Please, Sign In to add comment