Zgragselus

Claude comments

Jul 6th, 2026
73
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
C++ 44.21 KB | None | 0 0
  1. // M3a-e brought up a single-instance GPU accept/expand traversal, matching ClusterLODManager's
  2. // CPU SelectClusters exactly, but driven by one full CPU-orchestrated dispatch sequence PER
  3. // ClusterLODObject (a loop on the CPU side calling this shader once per instance, every frame).
  4. // That never matched the actual goal - a GPU-driven renderer needs to process every instance in
  5. // one shared batch, not re-run the whole traversal machinery per object.
  6. //
  7. // M3f (this step) batches the traversal: every ClusterLODObject that shares the same
  8. // ClusterLODData (and so the same DAG/buffers) is folded into ONE dispatch sequence for that
  9. // asset. Concretely:
  10. //   - _InstanceData holds one (worldPosition.xyz, errorThreshold.w) entry per instance in the
  11. //     batch, uploaded fresh every frame by ClusterLODManager.RenderGpuBatch (positions/instance
  12. //     membership can change frame to frame).
  13. //   - Every per-traversal buffer (_Counters, frontier, selected, claimed-groups) is laid out as
  14. //     N fixed-size regions, one per instance, inside otherwise-ordinary shared buffers - instance
  15. //     i's region always starts at i * (region size). This is a deliberate simplification: rather
  16. //     than a single global atomic-append pool shared across every instance (which would need
  17. //     per-instance bounds tracked some other way), each instance gets its own fixed capacity
  18. //     (_CapacityPerInstance, sized exactly like the single-instance version's frontierCapacity -
  19. //     see ClusterLODManager.GetOrBuildBuffers), and only ever touches its own region.
  20. //   - Because each instance's capacity is now a fixed, CPU-known constant (not data/camera
  21. //     dependent), the worst-case total thread count for any pass (instanceCount *
  22. //     _CapacityPerInstance) is also a fixed, CPU-known constant - so TraverseLevel/FinalizeArgs
  23. //     can always be dispatched at that fixed size with plain ComputeShader.Dispatch, no
  24. //     DispatchIndirect/_DispatchArgs machinery needed anymore (that existed in M3d/e specifically
  25. //     to avoid a CPU readback between passes when the real per-pass count wasn't knowable ahead of
  26. //     time - with a fixed worst-case bound available, dispatching that bound directly and letting
  27. //     idle threads self-cull via their own instance's currentCount check is simpler and just as
  28. //     readback-free). The tradeoff: later passes, where most instances have far fewer live nodes
  29. //     than the worst case, still dispatch the full fixed thread count with most threads idling -
  30. //     a known, accepted cost of this first batched cut, and a candidate for a later optimization
  31. //     pass (e.g. a global compaction step) if profiling ever shows it matters.
  32. //
  33. // Rendering itself is NOT batched yet in this step: ClusterLODManager still issues one
  34. // Graphics.RenderPrimitivesIndirect call per instance (via a startCommand offset into the shared
  35. // _Args buffer, which FinalizeArgs lays out in the same fixed-per-instance-region style), each
  36. // with that instance's own transform passed the same way it always was. That's what lets this
  37. // step land without touching ClusterDebug.shader at all - per-instance transform lookup inside a
  38. // single unified draw call is a separate, later step (M3g).
  39. //
  40. // M3h added a coarse, whole-object frustum cull on the CPU side (ClusterLODManager.Update) that
  41. // keeps a fully-invisible ClusterLODObject out of its batch entirely, before any of this even
  42. // dispatches for it. M3h-2 (this step) adds the finer-grained version Nanite-style renderers
  43. // actually rely on: a per-node bounding-sphere-vs-frustum test inside ProcessNode itself, right
  44. // alongside the existing per-node LOD-error test. A node whose own bounding sphere is fully
  45. // outside the frustum is dropped outright - not selected, not expanded into children - which is
  46. // sound under the same containment assumption the whole-object version already leans on (a
  47. // group's children are assumed to sit within their parent's bound), so culling higher in the DAG
  48. // safely prunes everything beneath it too, not just that one node. This needed a new per-meshlet
  49. // radius buffer (_MeshletRadius) because the existing _MeshletBoundingSphereError only ever
  50. // carried center+error, never the meshlet's actual bounding-sphere radius (Meshlet.boundingSphere.w
  51. // in ClusterLODManager.cs/Meshlet.cs), plus a per-frame _FrustumPlanes uniform (6 planes, shared by
  52. // every instance in the batch since only the camera moves) and a new per-instance culledCount
  53. // counter for observability parity with the rest of the health-check/dump tooling.
  54. //
  55. // Fix (found via a scaled ClusterLODObject culling incorrectly): every meshlet's object-space
  56. // bounding sphere/error was being used directly in world-space math as if every instance had
  57. // scale 1 - added _InstanceScale (one conservative uniform-scale factor per instance, see
  58. // ClusterLODManager.ComputeConservativeScale) and applied it to sphere center offset, sphere
  59. // radius, and the LOD-error metric everywhere ProcessNode uses them.
  60. //
  61. // Fix (found via a rotated ClusterLODObject whose whole cluster-LOD mesh vanished at certain
  62. // camera angles, while a reference standard-mesh renderer of the same object stayed visible):
  63. // rotation was being ignored entirely the same way scale used to be - a meshlet's object-space
  64. // bounding-sphere offset was only ever translated, never rotated, by the instance's actual
  65. // transform.rotation. Added _InstanceRotation (one quaternion per instance) and
  66. // RotateVectorByQuaternion, applied to the sphere center offset in ProcessNode. The coarse
  67. // whole-object cull in ClusterLODManager.Update was unaffected (its test sphere is centered
  68. // exactly on the instance's own origin, so rotating a zero offset is a no-op) - only this
  69. // per-node math needed the fix.
  70. //
  71. // M3i adds scaffolding for the real centerpiece feature this whole M3 series has been building
  72. // toward - a streaming/paging system where clusters aren't all resident on the GPU all the time.
  73. // It did NOT implement streaming itself; it only wired up the mechanism a page loader would need:
  74. //   - _Residency is one uint per meshlet (asset-scoped, shared by every instance - residency is a
  75. //     property of the bake's data, not of any one instance), built once by
  76. //     ClusterLODManager.GetOrBuildBuffers.
  77. //   - ProcessNode checks it before doing anything else with a node; if a node is non-resident, its
  78. //     meshlet index gets appended (InterlockedAdd-counted, capped, with an overflow counter - same
  79. //     pattern as every other append buffer in this file) to _StreamingRequests, for a page loader
  80. //     to read back and act on (M4a's async readback consumes this).
  81. //
  82. // M4d/M4e (this step) make residency real and give the non-resident path an actual fallback:
  83. //   - Residency is no longer all-1s. ClusterLODManager.GetOrBuildBuffers now marks only DAG roots
  84. //     (Meshlet.groupIndex < 0) resident - the coarse, always-available baseline - and everything
  85. //     else not resident. This is a pure function of the existing bake data (no new mutable runtime
  86. //     state yet); real incremental promotion/eviction as pages actually load is M4f+.
  87. //   - When ProcessNode finds a non-resident node, it no longer pretends the node is resident (M3i's
  88. //     placeholder). It looks up meshletIndex -> _MeshletGroupIndex (the group this meshlet is a
  89. //     child of, i.e. a COARSER representation) -> that group's ENTIRE parent meshlet range (see
  90. //     ResolveAndSelectFallbacks) and selects every meshlet in it. A group's parent range can (and
  91. //     often does) hold more than one meshlet - AppendMeshlets splits a large simplified result into
  92. //     several output meshlets from a single call - so a fallback can't just follow one
  93. //     representative (an earlier version of this that only followed parentRange.x collapsed every
  94. //     sibling in a multi-parent group onto the same single meshlet; see git history/PR notes for
  95. //     the bug report that caught it). Every entry in a parent range is always already resident
  96. //     under today's rule (residency == DAG root), so this only ever needs one hop - the per-
  97. //     candidate residency check inside ResolveAndSelectFallbacks is a guard against that changing,
  98. //     not something exercised today.
  99. //   - Multiple sibling (or cousin) non-resident nodes commonly resolve to the same fallback
  100. //     candidate(s) (today, every non-root in a branch falls back to that branch's root set).
  101. //     Selecting a candidate more than once per such node would draw the same cluster multiple times
  102. //     - the exact class of bug the M3d/M3e group-claim dedup already exists to prevent, just one
  103. //     level removed. _ClaimedFallbacks applies the identical InterlockedCompareExchange claim-once
  104. //     pattern, keyed by fallback meshlet index instead of group index, reset to all-zero once per
  105. //     frame by ClusterLODManager.RenderGpuBatch exactly like _ClaimedGroups.
  106. //   - Bugfix (post-M4f): expanding into a group used to happen unconditionally once a node was
  107. //     found insufficient, regardless of whether the group's children were actually resident yet.
  108. //     That let a child that happened to already be loaded get selected on its own merits while its
  109. //     still-non-resident siblings independently fell back to the SAME shared parent - both
  110. //     rendering at once, visibly overlapping. ProcessNode now checks whether an entire group is
  111. //     resident before expanding into it; if not, the coarser node selects itself instead (a DAG cut
  112. //     must be all-or-nothing per group) and the specific missing children get requested directly.
  113. //   - M4d/M4e deliberately left GPU memory usage untouched (every meshlet's geometry stayed fully
  114. //     uploaded regardless of residency). M4f (this step) closes that gap: ClusterLODManager now
  115. //     maintains _Positions/_Triangles (still those exact property names - ClusterDebug.shader is
  116. //     completely unchanged) as a growable, page-local POOL instead of a fixed, fully-resident
  117. //     array. Only meshlets that have actually been loaded (via ClusterLODPageLoader, reading
  118. //     ClusterLODPagePacker's on-disk pages) occupy space in it, starting with just the resident
  119. //     root set and growing as ClusterLODManager's streaming loop consumes _StreamingRequests
  120. //     (via M4a's async readback) and promotes newly-loaded meshlets to resident. FinalizeArgs'
  121. //     startVertex now comes from _MeshletPoolIndexOffset (a per-meshlet lookup into the pool) since
  122. //     a meshlet's location depends on load order, not a formula derivable from its index alone -
  123. //     see ClusterLODManager.AppendMeshletPageToPool for how a page's local triangle indices get
  124. //     pre-offset by its vertex-pool base at append time, so no extra indirection is needed here or
  125. //     in the shader.
  126. //
  127. // M6/M6b: FinalizeArgs (removed in M7 below) laid _Args out as one command slot per potential
  128. // selected meshlet per instance - a fixed cap (ClusterLODManager.MaxSelectedPerInstance, later made
  129. // adaptive/M6b) capped how many of those slots actually got draw commands, replacing the original
  130. // "one slot per meshlet in the whole bake" scheme that caused the M7 bottleneck described below.
  131. //
  132. // M7 replaces FinalizeArgs' one-draw-command-per-selected-cluster scheme entirely. M6/M6b had
  133. // already cut per-instance draw count down from "every meshlet in the bake" to "an adaptively-sized
  134. // cap on selected clusters", but profiling a 144-instance stress test (Stanford bunny, worst case
  135. // ~10M triangles) still showed ~90,000 indirect draw commands per frame and ~30fps - each draw
  136. // averaging under 126 triangles (ClusterMaxPrimitives), squarely in the regime where per-draw GPU
  137. // submission/processing overhead dominates over actual shading work. No amount of tuning the cap
  138. // fixes that; the fundamental issue is issuing one hardware draw call per cluster at all.
  139. //
  140. // The fix: stop treating each cluster as a separate draw and instead treat it as a GPU INSTANCE
  141. // within a single draw call. CompactSelectedForRun flattens every selected cluster across a batch
  142. // run's instances into one tightly-packed list (no more dense, mostly-empty per-instance-slot
  143. // regions), and ClusterLODManager issues exactly ONE indirect draw per material run, with
  144. // instanceCount == the compacted cluster count (written by WriteInstancedArgs from a GPU-computed
  145. // total - no CPU readback, no cap, no overflow counter needed, since the compacted buffer is sized
  146. // to _CapacityPerInstance * instanceCount, the same bound already proven safe for _Selected
  147. // itself). Each cluster becomes SV_InstanceID in ClusterDebug.shader, which looks up which
  148. // ClusterLODObject and which meshlet that instance is via _CompactedInstanceIndex/
  149. // _CompactedMeshletIndex.
  150. //
  151. // The catch with hardware instancing: every instance in one draw call must share the same
  152. // vertexCountPerInstance - but clusters have different real triangle counts (up to
  153. // ClusterMaxPrimitives, rarely exactly that many). Fixed by padding: the draw always requests
  154. // ClusterMaxPrimitives*3 vertices per instance (the worst case), and ClusterDebug.shader collapses
  155. // any vertex beyond a cluster's own real index count (_MeshletIndexCounts) to a single fixed point,
  156. // making that padding triangle zero-area and contributing nothing. This wastes some vertex shader
  157. // invocations on padding (worst case ~2x for a cluster at half its max primitive count) in exchange
  158. // for eliminating tens of thousands of draw-command submissions - a clear win in the regime this was
  159. // profiled in, though worth remembering if a bake's clusters end up far under ClusterMaxPrimitives
  160. // on average.
  161. //
  162. // Future-proofing note: this whole instancing-plus-degenerate-padding scheme is a software
  163. // emulation of what a hardware mesh shader pipeline does natively and more efficiently. In a real
  164. // mesh-shader renderer, a task/amplification shader would run per meshlet-GROUP, deciding
  165. // culling/LOD expansion (replacing TraverseRoots/TraverseLevel/ProcessNode here) and amplifying into
  166. // exactly one mesh shader invocation per surviving meshlet, which then emits that meshlet's actual
  167. // (variable, non-padded) vertex/primitive count directly - no CPU-orchestrated multi-pass traversal,
  168. // no compacted args buffer, no fixed-size padding waste, no separate compute dispatch sequence at
  169. // all. As of when this was written, Unity has no public, portable scripting API for mesh/task
  170. // shaders (the feature is listed as "under consideration"; the only way to touch them today is a
  171. // native render plugin, which isn't practical for a portable, shippable package). If/when Unity
  172. // exposes this, TraverseRoots/TraverseLevel/Advance/ProcessNode's traversal logic is the part that
  173. // would move into a task shader almost unchanged (it already thinks in terms of "does this node
  174. // pass, does it expand into children" - a task shader's amplification decision is exactly that),
  175. // while CompactSelectedForRun/WriteInstancedArgs/the padding trick in ClusterDebug.shader would
  176. // simply disappear, replaced by the mesh shader's native per-meshlet output.
  177.  
  178. #pragma kernel TraverseRoots
  179. #pragma kernel TraverseLevel
  180. #pragma kernel Advance
  181. #pragma kernel CompactSelectedForRun
  182. #pragma kernel WriteInstancedArgs
  183.  
  184. // Do NOT declare _Args as RWStructuredBuffer<IndirectDrawArgs> (a custom 4-uint struct). This is a
  185. // documented Unity/driver bug: on some AMD GPUs under DirectX, a RWStructuredBuffer<T> for a
  186. // custom struct silently uses a stride of 4 bytes instead of the struct's real size, so writes
  187. // for element N land only 4 bytes after element N-1 instead of sizeof(T) bytes after - every
  188. // element after the first overlaps/smears into its neighbors. The documented fix: only ever
  189. // declare indirect-args buffers as (RW)StructuredBuffer<uint> (or (RW)Buffer<uint>) in a compute
  190. // shader, and index by hand in units of uint. See
  191. // https://discussions.unity.com/t/do-not-use-unitys-indirectdrawindexedargs-in-a-structuredbuffer/1623092
  192. static const uint IndirectDrawArgsUintCount = 4; // vertexCountPerInstance, instanceCount, startVertex, startInstance
  193.  
  194. // Counter layout: one CountersPerInstance-uint block per batch instance, at
  195. // [instanceIndex * CountersPerInstance + k]:
  196. //   [0] currentFrontierCount - how many entries in this instance's frontier region are valid.
  197. //   [1] nextFrontierCount    - accumulated via InterlockedAdd as this instance's failed nodes expand.
  198. //   [2] selectedCount        - cumulative across every pass so far (never reset mid-traversal).
  199. //   [3] overflowCount        - how many appends were dropped because this instance's region was full.
  200. //   [4] corruptionCount      - how many nodes were dropped for an out-of-range meshlet/group index.
  201. //   [5] culledCount          - how many nodes were dropped for being fully outside the frustum (M3h-2).
  202. //
  203. // M6 added a 7th slot (argsOverflowCount) for FinalizeArgs' fixed-cap draw-command scheme; M7
  204. // removed both that slot and FinalizeArgs itself - CompactSelectedForRun's compacted buffer is
  205. // sized to the same proven-safe bound as _Selected (_CapacityPerInstance * instanceCount), so it
  206. // can never overflow by construction and needs no counter for it.
  207. static const uint CountersPerInstance = 6u;
  208.  
  209. RWStructuredBuffer<uint> _Counters;
  210.  
  211. // This pass's input (read-only) and output (accumulated via InterlockedAdd, within each
  212. // instance's own fixed-size region - see file header) node-index lists. Pass 0 (TraverseRoots)
  213. // doesn't read _CurrentFrontier at all - it reads _Roots directly, the same for every instance.
  214. StructuredBuffer<uint> _CurrentFrontier;
  215. RWStructuredBuffer<uint> _NextFrontier;
  216.  
  217. // Every node that passes its test lands here, in its instance's own fixed-size region (cumulative
  218. // across all passes), consumed by FinalizeArgs at the end of the traversal.
  219. RWStructuredBuffer<uint> _Selected;
  220.  
  221. // Per-group claim flags, one _GroupsCount-sized region per instance (offset instanceIndex *
  222. // _GroupsCount) - see the dedup explanation in the M3d/M3e history (ClusterLODManager.cs). Reset
  223. // to all-zero once per frame by ClusterLODManager.RenderGpuBatch before pass 0.
  224. RWStructuredBuffer<uint> _ClaimedGroups;
  225.  
  226. // One (worldPosition.xyz, errorThreshold.w) entry per instance in this batch.
  227. StructuredBuffer<float4> _InstanceData;
  228. int _InstanceCount;
  229.  
  230. // Fix: a conservative uniform-scale approximation of each instance's transform.lossyScale (see
  231. // ClusterLODManager.ComputeConservativeScale - the largest axis, since an exact per-axis scale
  232. // would turn a sphere into an ellipsoid). Every meshlet's object-space bounding-sphere
  233. // center/radius and error were previously being used directly in world-space math as if every
  234. // instance had scale 1 - wrong for a scaled object in both directions (wrong LOD cut, wrong
  235. // culling). Kept as a separate per-instance buffer rather than a fifth _InstanceData component
  236. // since float4 is already full (xyz + errorThreshold).
  237. StructuredBuffer<float> _InstanceScale;
  238.  
  239. // Bugfix (found via a rotated object whose whole cluster-LOD mesh vanished at certain camera
  240. // angles, while a reference standard-mesh renderer stayed visible): every meshlet's object-space
  241. // bounding-sphere offset was only ever translated by the instance's world position, never rotated
  242. // by its actual transform.rotation - wrong for a rotated object with an off-center root/meshlet,
  243. // since the coarse whole-object cull (rotation-invariant - it's centered exactly on the instance's
  244. // own origin) would still let traversal start, but this per-node math would compute a drifted
  245. // world position that could fall outside the frustum at some angles even though the real,
  246. // correctly-rotated geometry is still inside it. One quaternion (x,y,z,w, matching Unity's
  247. // Quaternion layout exactly) per instance, applied via RotateVectorByQuaternion below.
  248. StructuredBuffer<float4> _InstanceRotation;
  249.  
  250. // Standard optimized quaternion-vector rotation (q * v * q^-1 without forming q^-1 explicitly) -
  251. // no assumptions beyond q being a unit quaternion, which Unity's Quaternion (the CPU-side source
  252. // of _InstanceRotation) always is.
  253. float3 RotateVectorByQuaternion(float4 q, float3 v)
  254. {
  255.     float3 t = 2.0 * cross(q.xyz, v);
  256.     return v + q.w * t + cross(q.xyz, t);
  257. }
  258.  
  259. // Per meshlet (indexed by any meshlet index, root or child, shared by every instance since they
  260. // all use the same ClusterLODData): xyz = object-space bounding sphere center, w = error. Per
  261. // meshlet: the group (if any) whose children refine it further, or -1 for a true leaf.
  262. StructuredBuffer<float4> _MeshletBoundingSphereError;
  263. StructuredBuffer<int> _MeshletParentGroup;
  264. int _MeshletsCount;
  265.  
  266. // M3h-2: the meshlet's own bounding-sphere radius (Meshlet.boundingSphere.w in
  267. // ClusterLODManager.cs/Meshlet.cs) - kept as a separate buffer rather than folded into
  268. // _MeshletBoundingSphereError because that buffer's w component is already the LOD-error metric,
  269. // a different value entirely (see ClusterLODManager.GetOrBuildBuffers).
  270. StructuredBuffer<float> _MeshletRadius;
  271.  
  272. // Six frustum planes (xyz = normal, w = distance, matching Unity's Plane struct / Plane.GetDistanceToPoint
  273. // convention: dot(normal, point) + distance), uploaded once per frame by ClusterLODManager - shared
  274. // by every instance in the batch since only the camera's planes change, not any instance's own data.
  275. float4 _FrustumPlanes[6];
  276.  
  277. // M5: screen-space LOD error metric - mirrors ClusterLODManager.projectionScale/cameraOrthographic
  278. // (see that field's comment for the exact derivation), uploaded once per frame alongside
  279. // _FrustumPlanes since both only depend on the active camera, not any instance's own data.
  280. // _ProjectionScale converts a world-space error radius into screen pixels: for a perspective
  281. // camera, pixels = worldError * _ProjectionScale / distance; for an orthographic one
  282. // (_Orthographic != 0) there's no perspective divide, so pixels = worldError * _ProjectionScale
  283. // directly. Replaces the old distance*errorThreshold heuristic, whose "acceptable error" depended
  284. // on camera FOV/resolution in a way that made the same threshold look different across scenes.
  285. float _ProjectionScale;
  286. int _Orthographic;
  287.  
  288. // M3i: 1 = resident, 0 = not - one entry per meshlet, asset-scoped (shared by every instance,
  289. // built once by ClusterLODManager.GetOrBuildBuffers and currently always all-1s - see file header
  290. // for why). _StreamingRequests is an append-only list of meshlet indices whose residency bit was
  291. // found to be 0, up to _StreamingRequestCapacity entries; _StreamingRequestCounter is a 2-uint
  292. // [requestCount, overflowCount] pair, reset to zero once per frame by
  293. // ClusterLODManager.RenderGpuBatch, the same append-with-overflow-counter pattern used everywhere
  294. // else in this file (see e.g. _Counters' overflowCount). None of this is per-instance because
  295. // residency/streaming is a property of the shared asset, not of any one instance's traversal.
  296. StructuredBuffer<uint> _Residency;
  297. RWStructuredBuffer<uint> _StreamingRequests;
  298. RWStructuredBuffer<uint> _StreamingRequestCounter;
  299. int _StreamingRequestCapacity;
  300.  
  301. // M4d/M4e: per meshlet, the group this meshlet is a CHILD of (i.e. a coarser representation of it)
  302. // - -1 for a DAG root. This is the reverse direction of _MeshletParentGroup (which points to the
  303. // group whose children are a FINER version of this meshlet, used for expansion) - needed here to
  304. // walk UP toward a resident ancestor instead of down toward finer detail. Per group, the meshlet
  305. // range its simplification produced (the coarser output) - same (offset, count) encoding as
  306. // _GroupChildOffsetCount, just pointing the other way in the DAG.
  307. StructuredBuffer<int> _MeshletGroupIndex;
  308. StructuredBuffer<uint2> _GroupParentMeshletOffsetCount;
  309.  
  310. // M4d/M4e: per-instance claim flags for fallback-ancestor selection, one _MeshletsCount-sized
  311. // region per instance (offset instanceIndex * _MeshletsCount) - see file header for why this dedup
  312. // is load-bearing, not an optimization. Reset to all-zero once per frame by
  313. // ClusterLODManager.RenderGpuBatch, same as _ClaimedGroups.
  314. RWStructuredBuffer<uint> _ClaimedFallbacks;
  315.  
  316. // Per group: (childMeshletOffset, childMeshletCount) into _GroupChildMeshletIndices.
  317. StructuredBuffer<uint2> _GroupChildOffsetCount;
  318. StructuredBuffer<uint> _GroupChildMeshletIndices;
  319. int _GroupsCount;
  320.  
  321. // Root meshlet indices - the same list for every instance of this asset.
  322. StructuredBuffer<uint> _Roots;
  323. int _RootCount;
  324.  
  325. // Fixed per-instance region size for _NextFrontier/_Selected/_CurrentFrontier (frontier regions)
  326. // and the corresponding bound used when writing into them.
  327. int _CapacityPerInstance;
  328.  
  329. // Per-frame view state, shared by every instance in the batch (only the camera moves - each
  330. // instance's own world position comes from _InstanceData above).
  331. float3 _CameraWorldPosition;
  332.  
  333. // M3h-2: fully outside test only (never claims partial visibility) - a node whose sphere merely
  334. // straddles a plane is treated as visible, same conservative direction as ClusterLODManager's
  335. // CPU-side IsSphereVisible.
  336. bool IsSphereVisible(float3 center, float radius)
  337. {
  338.     [unroll]
  339.     for (int i = 0; i < 6; i++)
  340.     {
  341.         float4 plane = _FrustumPlanes[i];
  342.         if (dot(plane.xyz, center) + plane.w < -radius)
  343.         {
  344.             return false;
  345.         }
  346.     }
  347.  
  348.     return true;
  349. }
  350.  
  351. // Claims (dedup, per-instance) and appends one fallback candidate to _Selected - shared by every
  352. // call site in ResolveAndSelectFallbacks below so the claim-then-append pattern is written once.
  353. void ClaimAndSelectFallback(uint instanceIndex, uint counterBase, uint regionBase, uint candidate)
  354. {
  355.     uint claimBase = instanceIndex * (uint)_MeshletsCount;
  356.     uint previousFallbackClaim;
  357.     InterlockedCompareExchange(_ClaimedFallbacks[claimBase + candidate], 0u, 1u, previousFallbackClaim);
  358.  
  359.     if (previousFallbackClaim != 0u)
  360.     {
  361.         return;
  362.     }
  363.  
  364.     uint outIndex;
  365.     InterlockedAdd(_Counters[counterBase + 2], 1u, outIndex);
  366.  
  367.     if (outIndex < (uint)_CapacityPerInstance)
  368.     {
  369.         _Selected[regionBase + outIndex] = candidate;
  370.     }
  371.     else
  372.     {
  373.         uint dummy;
  374.         InterlockedAdd(_Counters[counterBase + 3], 1u, dummy);
  375.     }
  376. }
  377.  
  378. // Bugfix (found via a bake whose top level has 4 root meshlets - see history below): a
  379. // ClusterGroup's simplification can produce MORE THAN ONE parent meshlet (AppendMeshlets splits a
  380. // large simplified result into several output meshlets from one call - ClusterGroup.
  381. // parentMeshletCount reflects this). The original version of this function only ever followed
  382. // parentRange.x (the FIRST parent meshlet), so every non-resident child - regardless of which of
  383. // the N siblings it geometrically belongs to - collapsed onto that same single meshlet. Fix:
  384. // select EVERY meshlet in the parent range, each deduped independently via _ClaimedFallbacks (so N
  385. // different children of the same group all requesting a fallback still only add each of the
  386. // group's parent meshlets once). Under today's residency rule ("resident" == DAG root), every
  387. // entry in a parent range is always already resident by construction, so this never needs to
  388. // recurse further than one hop - the per-candidate residency check below only guards against that
  389. // assumption ever changing (e.g. once M4f introduces partial, non-root residency and a parent
  390. // range's own members might themselves need a further fallback).
  391. void ResolveAndSelectFallbacks(uint instanceIndex, uint meshletIndex)
  392. {
  393.     uint counterBase = instanceIndex * CountersPerInstance;
  394.     uint regionBase = instanceIndex * (uint)_CapacityPerInstance;
  395.  
  396.     int groupIdx = _MeshletGroupIndex[meshletIndex];
  397.     if (groupIdx < 0)
  398.     {
  399.         // A root (groupIndex < 0) is always resident by construction, so reaching this function
  400.         // for one (the caller only calls it for non-resident nodes) would itself mean
  401.         // stale/corrupted data - select itself rather than drop it silently.
  402.         ClaimAndSelectFallback(instanceIndex, counterBase, regionBase, meshletIndex);
  403.         return;
  404.     }
  405.  
  406.     if (groupIdx >= (int)_GroupsCount)
  407.     {
  408.         uint corruptionDummy;
  409.         InterlockedAdd(_Counters[counterBase + 4], 1u, corruptionDummy);
  410.         return;
  411.     }
  412.  
  413.     uint2 parentRange = _GroupParentMeshletOffsetCount[(uint)groupIdx];
  414.     if (parentRange.y == 0u)
  415.     {
  416.         uint corruptionDummy;
  417.         InterlockedAdd(_Counters[counterBase + 4], 1u, corruptionDummy);
  418.         return;
  419.     }
  420.  
  421.     for (uint k = 0; k < parentRange.y; k++)
  422.     {
  423.         uint candidate = parentRange.x + k;
  424.         if (candidate >= (uint)_MeshletsCount)
  425.         {
  426.             uint corruptionDummy;
  427.             InterlockedAdd(_Counters[counterBase + 4], 1u, corruptionDummy);
  428.             continue;
  429.         }
  430.  
  431.         if (_Residency[candidate] == 0u)
  432.         {
  433.             // Not reachable under today's residency rule (see comment above) - guarded rather than
  434.             // assumed, so a future partial-residency scheme (M4f+) doesn't silently render
  435.             // not-actually-resident geometry if this ever fires. The streaming request for the
  436.             // original meshletIndex (recorded by the caller) is the correct signal in that case,
  437.             // not a recursive walk from here.
  438.             continue;
  439.         }
  440.  
  441.         ClaimAndSelectFallback(instanceIndex, counterBase, regionBase, candidate);
  442.     }
  443. }
  444.  
  445. // Tests one node (already known to belong to instanceIndex) and either selects it or expands it
  446. // into its instance's next-frontier region - shared by TraverseRoots and TraverseLevel so the
  447. // accept/expand logic only needs to be written (and read) once.
  448. void ProcessNode(uint instanceIndex, uint meshletIndex)
  449. {
  450.     uint counterBase = instanceIndex * CountersPerInstance;
  451.     uint regionBase = instanceIndex * (uint)_CapacityPerInstance;
  452.  
  453.     // Mirrors SelectClusters' out-of-range meshlet index check (see ClusterLODManager.cs M3e
  454.     // history) - a corrupted/stale bake could otherwise send this thread reading past the end of
  455.     // every per-meshlet buffer below. Drop just this one node and count it.
  456.     if (meshletIndex >= (uint)_MeshletsCount)
  457.     {
  458.         uint corruptionDummy;
  459.         InterlockedAdd(_Counters[counterBase + 4], 1u, corruptionDummy);
  460.         return;
  461.     }
  462.  
  463.     float4 instanceData = _InstanceData[instanceIndex];
  464.     float3 objectWorldPosition = instanceData.xyz;
  465.     // M5/M6c: target screen-space error in pixels (ClusterLODManager.targetScreenSpaceError - one
  466.     // scene-wide value, uploaded identically for every instance, not per-object) - renamed from the
  467.     // old errorThreshold to match, since its unit/meaning changed entirely back in M5.
  468.     float targetScreenSpaceError = instanceData.w;
  469.     float objectScale = _InstanceScale[instanceIndex];
  470.     float4 objectRotation = _InstanceRotation[instanceIndex];
  471.  
  472.     float4 sphereError = _MeshletBoundingSphereError[meshletIndex];
  473.     int parentGroupIndex = _MeshletParentGroup[meshletIndex];
  474.     float radius = _MeshletRadius[meshletIndex] * objectScale;
  475.  
  476.     // Bugfix: rotate the local offset by the instance's actual rotation before translating - see
  477.     // _InstanceRotation's declaration above for the full story on why omitting this could make a
  478.     // rotated instance's whole cluster-LOD mesh disappear at certain camera angles.
  479.     float3 worldCenter = objectWorldPosition + RotateVectorByQuaternion(objectRotation, sphereError.xyz * objectScale);
  480.  
  481.     // M3h-2: a node fully outside the frustum is dropped outright - not selected, not expanded -
  482.     // regardless of whether its own LOD error would otherwise have been sufficient. Safe to prune
  483.     // its whole subtree here under the same containment assumption the coarse whole-object cull in
  484.     // ClusterLODManager.Update already relies on (a group's children sit within their parent's
  485.     // bound).
  486.     if (!IsSphereVisible(worldCenter, radius))
  487.     {
  488.         uint culledDummy;
  489.         InterlockedAdd(_Counters[counterBase + 5], 1u, culledDummy);
  490.         return;
  491.     }
  492.  
  493.     // M4d/M4e: a non-resident node can't be trusted to decide anything about itself (we don't have
  494.     // its geometry) - resolve and select its nearest resident ancestor instead, with per-instance
  495.     // dedup so siblings that share a fallback don't select it more than once. Checked after the
  496.     // frustum test (no point requesting/falling back for a node that's invisible anyway) but before
  497.     // the LOD-sufficiency test (which would otherwise wrongly use this node's own error/geometry).
  498.     if (_Residency[meshletIndex] == 0u)
  499.     {
  500.         uint requestIndex;
  501.         InterlockedAdd(_StreamingRequestCounter[0], 1u, requestIndex);
  502.  
  503.         if (requestIndex < (uint)_StreamingRequestCapacity)
  504.         {
  505.             _StreamingRequests[requestIndex] = meshletIndex;
  506.         }
  507.         else
  508.         {
  509.             uint requestOverflowDummy;
  510.             InterlockedAdd(_StreamingRequestCounter[1], 1u, requestOverflowDummy);
  511.         }
  512.  
  513.         ResolveAndSelectFallbacks(instanceIndex, meshletIndex);
  514.         return;
  515.     }
  516.  
  517.     // M5: projected screen-space error, replacing the old distance*errorThreshold heuristic - see
  518.     // _ProjectionScale's declaration above and ClusterLODManager.IsDetailSufficient (the CPU
  519.     // mirror of this exact formula) for the full derivation. sphereError.w (Meshlet.error) is an
  520.     // object-space error bound - scale it by the same factor as the geometry (objectScale) to get
  521.     // a world-space error, then project that into actual screen pixels at this node's distance.
  522.     float d = max(0.0, distance(_CameraWorldPosition, worldCenter));
  523.     float distanceFactor = _Orthographic != 0 ? 1.0 : max(d, 0.0001);
  524.     float worldError = sphereError.w * objectScale;
  525.     float screenError = worldError * _ProjectionScale / distanceFactor;
  526.     bool sufficient = (parentGroupIndex < 0) || (screenError <= targetScreenSpaceError);
  527.  
  528.     // Bugfix (found via a child cluster loading independently of its siblings and rendering ON TOP
  529.     // OF its still-selected parent - overlapping, doubled-up geometry): a DAG cut has to be
  530.     // all-or-nothing per group - a group's children collectively tile exactly their parent's
  531.     // footprint, so showing some of them while the parent is ALSO shown (because its other,
  532.     // still-non-resident children fell back to it) double-covers that area. So: before ever
  533.     // expanding into a group, check whether every one of its children is resident; if not, force
  534.     // this node to select itself (as if sufficient) instead of expanding, and directly request
  535.     // whichever specific children are still missing - they won't be visited via the frontier while
  536.     // expansion stays blocked, so nothing else would ever request them. Not deduped across the
  537.     // (typically few) sibling threads that might all reach this same group - same append-with-
  538.     // overflow-counter tolerance every other streaming request in this file already has.
  539.     if (!sufficient && parentGroupIndex >= 0 && parentGroupIndex < (int)_GroupsCount)
  540.     {
  541.         uint2 childRangeForResidency = _GroupChildOffsetCount[(uint)parentGroupIndex];
  542.         bool allChildrenResident = true;
  543.  
  544.         for (uint rc = 0; rc < childRangeForResidency.y; rc++)
  545.         {
  546.             uint residencyCheckChild = _GroupChildMeshletIndices[childRangeForResidency.x + rc];
  547.             if (residencyCheckChild >= (uint)_MeshletsCount)
  548.             {
  549.                 continue; // the existing corruption check below still catches this properly
  550.             }
  551.  
  552.             if (_Residency[residencyCheckChild] == 0u)
  553.             {
  554.                 allChildrenResident = false;
  555.  
  556.                 uint requestIndex;
  557.                 InterlockedAdd(_StreamingRequestCounter[0], 1u, requestIndex);
  558.                 if (requestIndex < (uint)_StreamingRequestCapacity)
  559.                 {
  560.                     _StreamingRequests[requestIndex] = residencyCheckChild;
  561.                 }
  562.                 else
  563.                 {
  564.                     uint requestOverflowDummy;
  565.                     InterlockedAdd(_StreamingRequestCounter[1], 1u, requestOverflowDummy);
  566.                 }
  567.             }
  568.         }
  569.  
  570.         if (!allChildrenResident)
  571.         {
  572.             sufficient = true;
  573.         }
  574.     }
  575.  
  576.     if (sufficient)
  577.     {
  578.         uint outIndex;
  579.         InterlockedAdd(_Counters[counterBase + 2], 1u, outIndex);
  580.  
  581.         if (outIndex < (uint)_CapacityPerInstance)
  582.         {
  583.             _Selected[regionBase + outIndex] = meshletIndex;
  584.         }
  585.         else
  586.         {
  587.             uint dummy;
  588.             InterlockedAdd(_Counters[counterBase + 3], 1u, dummy);
  589.         }
  590.     }
  591.     else if (parentGroupIndex >= (int)_GroupsCount)
  592.     {
  593.         // Mirrors SelectClusters' out-of-range group index check - same reasoning as above.
  594.         uint corruptionDummy;
  595.         InterlockedAdd(_Counters[counterBase + 4], 1u, corruptionDummy);
  596.     }
  597.     else
  598.     {
  599.         // Claim this group (within this instance's own claim region) before expanding it - only
  600.         // the thread that actually flips the flag from 0 to 1 proceeds. See the M3d/M3e history in
  601.         // ClusterLODManager.cs for why this dedup is load-bearing, not an optimization.
  602.         uint claimBase = instanceIndex * (uint)_GroupsCount;
  603.         uint previousClaim;
  604.         InterlockedCompareExchange(_ClaimedGroups[claimBase + (uint)parentGroupIndex], 0u, 1u, previousClaim);
  605.  
  606.         if (previousClaim == 0u)
  607.         {
  608.             uint2 range = _GroupChildOffsetCount[(uint)parentGroupIndex];
  609.             uint childOffset = range.x;
  610.             uint childCount = range.y;
  611.  
  612.             for (uint c = 0; c < childCount; c++)
  613.             {
  614.                 uint childIndex = _GroupChildMeshletIndices[childOffset + c];
  615.  
  616.                 uint outIndex;
  617.                 InterlockedAdd(_Counters[counterBase + 1], 1u, outIndex);
  618.  
  619.                 if (outIndex < (uint)_CapacityPerInstance)
  620.                 {
  621.                     _NextFrontier[regionBase + outIndex] = childIndex;
  622.                 }
  623.                 else
  624.                 {
  625.                     uint dummy;
  626.                     InterlockedAdd(_Counters[counterBase + 3], 1u, dummy);
  627.                 }
  628.             }
  629.         }
  630.     }
  631. }
  632.  
  633. // Pass 0: every instance's roots are identical (data.RootMeshletIndices), so this is dispatched at
  634. // a tight, CPU-known size (instanceCount * rootCount) rather than the fixed worst-case bound
  635. // TraverseLevel uses - no per-instance "current count" bookkeeping needed yet, since every
  636. // instance starts with exactly _RootCount live nodes.
  637. [numthreads(64, 1, 1)]
  638. void TraverseRoots(uint3 id : SV_DispatchThreadID)
  639. {
  640.     uint instanceIndex = id.x / (uint)_RootCount;
  641.     if (instanceIndex >= (uint)_InstanceCount)
  642.     {
  643.         return;
  644.     }
  645.  
  646.     uint rootLocal = id.x % (uint)_RootCount;
  647.     uint meshletIndex = _Roots[rootLocal];
  648.  
  649.     ProcessNode(instanceIndex, meshletIndex);
  650. }
  651.  
  652. // Passes 1+: dispatched at the fixed worst-case size (instanceCount * _CapacityPerInstance) every
  653. // time - most threads beyond a given instance's actual currentFrontierCount just return
  654. // immediately (see file header for why this is simpler than per-instance indirect dispatch args).
  655. [numthreads(64, 1, 1)]
  656. void TraverseLevel(uint3 id : SV_DispatchThreadID)
  657. {
  658.     uint instanceIndex = id.x / (uint)_CapacityPerInstance;
  659.     if (instanceIndex >= (uint)_InstanceCount)
  660.     {
  661.         return;
  662.     }
  663.  
  664.     uint localSlot = id.x % (uint)_CapacityPerInstance;
  665.     uint counterBase = instanceIndex * CountersPerInstance;
  666.     uint currentCount = _Counters[counterBase + 0];
  667.  
  668.     if (localSlot >= currentCount)
  669.     {
  670.         return;
  671.     }
  672.  
  673.     uint regionBase = instanceIndex * (uint)_CapacityPerInstance;
  674.     uint meshletIndex = _CurrentFrontier[regionBase + localSlot];
  675.  
  676.     ProcessNode(instanceIndex, meshletIndex);
  677. }
  678.  
  679. // Runs once per instance between passes: promotes next->current and resets next to zero. Dispatched
  680. // at a fixed, CPU-known size (instanceCount) every pass - no dispatch-args buffer needed now that
  681. // TraverseLevel's own dispatch size is also a fixed worst-case bound rather than exactly-sized.
  682. [numthreads(64, 1, 1)]
  683. void Advance(uint3 id : SV_DispatchThreadID)
  684. {
  685.     if (id.x >= (uint)_InstanceCount)
  686.     {
  687.         return;
  688.     }
  689.  
  690.     uint counterBase = id.x * CountersPerInstance;
  691.     uint nextCount = _Counters[counterBase + 1];
  692.     _Counters[counterBase + 0] = nextCount;
  693.     _Counters[counterBase + 1] = 0u;
  694. }
  695.  
  696. // M7: single combined indirect-args buffer for the batch's CURRENT material run only (see
  697. // ClusterLODManager.RenderGpuBatch's render loop) - always exactly IndirectDrawArgsUintCount (4)
  698. // uints, reused/overwritten for each run in turn (runs are processed one at a time on the CPU, so
  699. // there's no concurrent-run risk). Same "never declare as RWStructuredBuffer<IndirectDrawArgs>"
  700. // AMD stride workaround as always - see IndirectDrawArgsUintCount's own comment.
  701. RWStructuredBuffer<uint> _Args;
  702.  
  703. // M7: how many vertices a padded cluster instance's draw range covers - always
  704. // ClusterMaxPrimitives*3 (the worst case), regardless of any individual cluster's real index count.
  705. // ClusterDebug.shader is what actually collapses the unused tail into degenerate triangles; this
  706. // compute shader only needs the value to write it into _Args.
  707. int _PaddedVertexCountPerCluster;
  708.  
  709. // M7: this run's compacted cluster list - one entry per selected cluster across every instance in
  710. // the run, written by CompactSelectedForRun below and consumed directly by ClusterDebug.shader
  711. // (indexed by SV_InstanceID) instead of FinalizeArgs' old per-instance-slot _Args/_CmdMeshletIndex
  712. // scheme. Sized generously (instanceCount * _CapacityPerInstance, the same bound already proven
  713. // safe for _Selected itself - see ClusterLODManager.RenderGpuBatch) so it can never overflow;
  714. // _CompactedCounter is reset to zero by the CPU before each run's compaction dispatch and read back
  715. // into _Args by WriteInstancedArgs immediately after, with no CPU sync point either way.
  716. RWStructuredBuffer<uint> _CompactedInstanceIndex;
  717. RWStructuredBuffer<uint> _CompactedMeshletIndex;
  718. RWStructuredBuffer<uint> _CompactedCounter;
  719.  
  720. // M7: which instances (a contiguous range within the batch, same instances a material run
  721. // already covers - see ClusterLODManager.RenderGpuBatch's material-sorted run loop) this
  722. // compaction dispatch should flatten.
  723. int _RunStartInstance;
  724. int _RunInstanceCount;
  725.  
  726. // Runs once per material run, at a fixed CPU-known dispatch size (runInstanceCount *
  727. // _CapacityPerInstance) - one thread per potential _Selected slot for this run's instances (mirrors
  728. // TraverseLevel's own "most threads idle past the real count" approach). Every valid entry
  729. // (localSlot < that instance's selectedCount) gets flattened into _CompactedInstanceIndex/
  730. // _CompactedMeshletIndex at a globally-unique, tightly-packed index - no gaps, unlike the
  731. // per-instance-region layout _Selected itself uses (which is only gap-free WITHIN one instance,
  732. // not across instances, since each instance's own region is _CapacityPerInstance-sized regardless
  733. // of its real selectedCount).
  734. [numthreads(64, 1, 1)]
  735. void CompactSelectedForRun(uint3 id : SV_DispatchThreadID)
  736. {
  737.     uint localInstance = id.x / (uint)_CapacityPerInstance;
  738.     if (localInstance >= (uint)_RunInstanceCount)
  739.     {
  740.         return;
  741.     }
  742.     uint instanceIndex = (uint)_RunStartInstance + localInstance;
  743.  
  744.     uint localSlot = id.x % (uint)_CapacityPerInstance;
  745.     uint counterBase = instanceIndex * CountersPerInstance;
  746.     uint selectedCount = _Counters[counterBase + 2];
  747.     if (localSlot >= selectedCount)
  748.     {
  749.         return;
  750.     }
  751.  
  752.     uint regionBase = instanceIndex * (uint)_CapacityPerInstance;
  753.     uint meshletIndex = _Selected[regionBase + localSlot];
  754.  
  755.     uint globalSlot;
  756.     InterlockedAdd(_CompactedCounter[0], 1u, globalSlot);
  757.     _CompactedInstanceIndex[globalSlot] = instanceIndex;
  758.     _CompactedMeshletIndex[globalSlot] = meshletIndex;
  759. }
  760.  
  761. // Runs once per material run, at a fixed 1-thread dispatch, immediately after
  762. // CompactSelectedForRun - writes this run's actual compacted count (no CPU readback) into _Args as
  763. // a standard hardware-instanced draw: vertexCountPerInstance = _PaddedVertexCountPerCluster
  764. // (fixed/padded, same for every instance), instanceCount = the real compacted total, startVertex/
  765. // startInstance = 0 (there's only ever this one run's own data in _Args at draw time).
  766. [numthreads(1, 1, 1)]
  767. void WriteInstancedArgs(uint3 id : SV_DispatchThreadID)
  768. {
  769.     uint count = _CompactedCounter[0];
  770.     _Args[0] = (uint)_PaddedVertexCountPerCluster;
  771.     _Args[1] = count;
  772.     _Args[2] = 0u;
  773.     _Args[3] = 0u;
  774. }
  775.  
Add Comment
Please, Sign In to add comment