@yali-engine/render 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/dist/bvh.d.ts +66 -0
  2. package/dist/bvh.js +493 -0
  3. package/dist/bvh.js.map +1 -0
  4. package/dist/camera.d.ts +34 -0
  5. package/dist/camera.js +66 -0
  6. package/dist/camera.js.map +1 -0
  7. package/dist/cluster-build.d.ts +46 -0
  8. package/dist/cluster-build.js +179 -0
  9. package/dist/cluster-build.js.map +1 -0
  10. package/dist/cluster-pages.d.ts +70 -0
  11. package/dist/cluster-pages.js +203 -0
  12. package/dist/cluster-pages.js.map +1 -0
  13. package/dist/cluster-stream.d.ts +83 -0
  14. package/dist/cluster-stream.js +226 -0
  15. package/dist/cluster-stream.js.map +1 -0
  16. package/dist/culled-draws.d.ts +30 -0
  17. package/dist/culled-draws.js +103 -0
  18. package/dist/culled-draws.js.map +1 -0
  19. package/dist/debug-lines.d.ts +51 -0
  20. package/dist/debug-lines.js +158 -0
  21. package/dist/debug-lines.js.map +1 -0
  22. package/dist/device-profile.d.ts +100 -0
  23. package/dist/device-profile.js +131 -0
  24. package/dist/device-profile.js.map +1 -0
  25. package/dist/device.d.ts +39 -0
  26. package/dist/device.js +79 -0
  27. package/dist/device.js.map +1 -0
  28. package/dist/environment.d.ts +60 -0
  29. package/dist/environment.js +204 -0
  30. package/dist/environment.js.map +1 -0
  31. package/dist/extract.d.ts +74 -0
  32. package/dist/extract.js +194 -0
  33. package/dist/extract.js.map +1 -0
  34. package/dist/frame-graph.d.ts +112 -0
  35. package/dist/frame-graph.js +226 -0
  36. package/dist/frame-graph.js.map +1 -0
  37. package/dist/gpu-health.d.ts +74 -0
  38. package/dist/gpu-health.js +106 -0
  39. package/dist/gpu-health.js.map +1 -0
  40. package/dist/gpu-timer.d.ts +26 -0
  41. package/dist/gpu-timer.js +93 -0
  42. package/dist/gpu-timer.js.map +1 -0
  43. package/dist/headless.d.ts +28 -0
  44. package/dist/headless.js +91 -0
  45. package/dist/headless.js.map +1 -0
  46. package/dist/index.d.ts +34 -0
  47. package/dist/index.js +36 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/lights.d.ts +81 -0
  50. package/dist/lights.js +247 -0
  51. package/dist/lights.js.map +1 -0
  52. package/dist/matrix.d.ts +6 -0
  53. package/dist/matrix.js +59 -0
  54. package/dist/matrix.js.map +1 -0
  55. package/dist/meshes.d.ts +411 -0
  56. package/dist/meshes.js +545 -0
  57. package/dist/meshes.js.map +1 -0
  58. package/dist/meshlets-decoder.d.ts +20 -0
  59. package/dist/meshlets-decoder.js +47 -0
  60. package/dist/meshlets-decoder.js.map +1 -0
  61. package/dist/meshlets.d.ts +186 -0
  62. package/dist/meshlets.js +1040 -0
  63. package/dist/meshlets.js.map +1 -0
  64. package/dist/occlusion.d.ts +51 -0
  65. package/dist/occlusion.js +188 -0
  66. package/dist/occlusion.js.map +1 -0
  67. package/dist/pick-decoder.d.ts +19 -0
  68. package/dist/pick-decoder.js +45 -0
  69. package/dist/pick-decoder.js.map +1 -0
  70. package/dist/pick.d.ts +86 -0
  71. package/dist/pick.js +452 -0
  72. package/dist/pick.js.map +1 -0
  73. package/dist/pipeline-cache.d.ts +53 -0
  74. package/dist/pipeline-cache.js +96 -0
  75. package/dist/pipeline-cache.js.map +1 -0
  76. package/dist/post-options.d.ts +109 -0
  77. package/dist/post-options.js +23 -0
  78. package/dist/post-options.js.map +1 -0
  79. package/dist/post-shaders.gen.d.ts +40 -0
  80. package/dist/post-shaders.gen.js +43 -0
  81. package/dist/post-shaders.gen.js.map +1 -0
  82. package/dist/post.d.ts +101 -0
  83. package/dist/post.js +555 -0
  84. package/dist/post.js.map +1 -0
  85. package/dist/render-plugin.d.ts +239 -0
  86. package/dist/render-plugin.js +633 -0
  87. package/dist/render-plugin.js.map +1 -0
  88. package/dist/scene.d.ts +406 -0
  89. package/dist/scene.js +2005 -0
  90. package/dist/scene.js.map +1 -0
  91. package/dist/shaders.gen.d.ts +40 -0
  92. package/dist/shaders.gen.js +43 -0
  93. package/dist/shaders.gen.js.map +1 -0
  94. package/dist/shadows.d.ts +104 -0
  95. package/dist/shadows.js +227 -0
  96. package/dist/shadows.js.map +1 -0
  97. package/dist/staging-ring.d.ts +41 -0
  98. package/dist/staging-ring.js +125 -0
  99. package/dist/staging-ring.js.map +1 -0
  100. package/dist/textures.d.ts +165 -0
  101. package/dist/textures.js +361 -0
  102. package/dist/textures.js.map +1 -0
  103. package/dist/tonemap.d.ts +45 -0
  104. package/dist/tonemap.js +141 -0
  105. package/dist/tonemap.js.map +1 -0
  106. package/dist/viewport.d.ts +78 -0
  107. package/dist/viewport.js +130 -0
  108. package/dist/viewport.js.map +1 -0
  109. package/dist/webgl2/plugin.d.ts +40 -0
  110. package/dist/webgl2/plugin.js +190 -0
  111. package/dist/webgl2/plugin.js.map +1 -0
  112. package/dist/webgl2/program.d.ts +27 -0
  113. package/dist/webgl2/program.js +74 -0
  114. package/dist/webgl2/program.js.map +1 -0
  115. package/dist/webgl2/renderer.d.ts +128 -0
  116. package/dist/webgl2/renderer.js +976 -0
  117. package/dist/webgl2/renderer.js.map +1 -0
  118. package/dist/webgl2/shaders.glsl.gen.d.ts +27 -0
  119. package/dist/webgl2/shaders.glsl.gen.js +20 -0
  120. package/dist/webgl2/shaders.glsl.gen.js.map +1 -0
  121. package/dist/webgl2/textures.d.ts +11 -0
  122. package/dist/webgl2/textures.js +82 -0
  123. package/dist/webgl2/textures.js.map +1 -0
  124. package/dist/webgl2/webgl2.d.ts +1 -0
  125. package/dist/webgl2/webgl2.js +4 -0
  126. package/dist/webgl2/webgl2.js.map +1 -0
  127. package/dist/worker-mode.d.ts +85 -0
  128. package/dist/worker-mode.js +204 -0
  129. package/dist/worker-mode.js.map +1 -0
  130. package/package.json +47 -3
  131. package/README.md +0 -3
@@ -0,0 +1,1040 @@
1
+ // Cluster (meshlet) culling, `@yali-engine/render/meshlets` (ADR-0100): an entry of its own, so a page
2
+ // that never asks for it carries none of its code, its shaders or meshoptimizer's clusterizer.
3
+ import { MeshoptClusterizer } from 'meshoptimizer/clusterizer';
4
+ import { MeshoptSimplifier } from 'meshoptimizer/simplifier';
5
+ import { YaliError } from '@yali-engine/core';
6
+ import { CLUSTER_BYTES, CLUSTER_WORDS, packClusters } from "./cluster-build.js";
7
+ import { decodeClusterPage, PAGE_WORDS } from "./cluster-pages.js";
8
+ import { ClusterResidency } from "./cluster-stream.js";
9
+ import { clusterRequest } from "./meshlets-decoder.js";
10
+ /** The hierarchy builder (ADR-0111), now in `cluster-build.ts` (ADR-0113). */
11
+ export { buildClusterLod, packClusters } from "./cluster-build.js";
12
+ import { sceneClusters } from "./post-shaders.gen.js";
13
+ /** Whole files fetched when a server ignored `Range` (served once, sliced after). */
14
+ const wholeFiles = new Map();
15
+ /** The default page fetch: one `Range` request; a server that answers 200 sends the file once, kept and sliced. */
16
+ async function rangeFetch(url, offset, length) {
17
+ const whole = wholeFiles.get(url);
18
+ if (whole)
19
+ return (await whole).subarray(offset, offset + length);
20
+ const r = await fetch(url, { headers: { Range: `bytes=${offset}-${offset + length - 1}` } });
21
+ if (r.status === 206)
22
+ return new Uint8Array(await r.arrayBuffer());
23
+ if (r.ok) {
24
+ console.warn(`[yali] meshlets: ${url} ignored the Range header; its whole file is kept in memory. Serve it from a server that answers Range (206).`);
25
+ const all = r.arrayBuffer().then((b) => new Uint8Array(b));
26
+ wholeFiles.set(url, all);
27
+ return (await all).subarray(offset, offset + length);
28
+ }
29
+ throw new YaliError('PAGE_FETCH', `Fetching cluster pages from ${url} answered ${r.status}.`, 'Serve the <name>.pages.bin that yali convert wrote next to the GLB.');
30
+ }
31
+ /** The cluster cull without the page bits (devices with 8 storage buffers a stage): `note_pages` emptied, so the binding is unused. */
32
+ export function withoutPageBits(s) {
33
+ const re = /fn note_pages\(index: u32, cl: Cluster\) \{[\s\S]*?\n\}\nfn emit\(/;
34
+ if (!re.test(s.wgsl))
35
+ throw new YaliError('SHADER_SHAPE', 'The cluster cull has no note_pages to remove.', 'Keep note_pages directly before emit in shaders/scene/clusters.wesl.');
36
+ return { hash: `${s.hash}-nobits`, wgsl: s.wgsl.replace(re, 'fn note_pages(index: u32, cl: Cluster) {\n}\nfn emit(') };
37
+ }
38
+ /**
39
+ * Cluster culling for big meshes (ADR-0100): pass it as `renderPlugin({ meshlets: meshlets() })`.
40
+ *
41
+ * @example
42
+ * import { meshlets } from '@yali-engine/render/meshlets';
43
+ * renderPlugin({ canvas, meshlets: meshlets({ minTriangles: 8192 }) });
44
+ */
45
+ export function meshlets(options = {}) {
46
+ return {
47
+ options,
48
+ async create(device, scene, store) {
49
+ await MeshoptClusterizer.ready;
50
+ if (options.lod)
51
+ await MeshoptSimplifier.ready;
52
+ return new ClusterRenderer(device, scene, store, options);
53
+ },
54
+ };
55
+ }
56
+ /** Index stride per visible-list slot (`SLOT` in clusters.wesl). */
57
+ const SLOT = 128;
58
+ /** The cluster record and the pool, clusters and local vertices, as the appended vertex stages read them. */
59
+ const CLUSTER_SOURCES = `
60
+ struct ClusterRec { center: vec3f, radius: f32, apex: vec3f, cutoff: f32, axis: vec3f, base_vertex: u32, vert_offset: u32, tri_offset: u32, tri_count: u32, flags: u32, self_sphere: vec4f, parent_sphere: vec4f, self_error: f32, parent_error: f32, pad0: vec2f, pad1: vec4f }
61
+ @group(0) @binding(20) var<storage, read> cl_pool: array<f32>;
62
+ @group(0) @binding(21) var<storage, read> cl_clusters: array<ClusterRec>;
63
+ @group(0) @binding(22) var<storage, read> cl_vertices: array<u32>;
64
+ `;
65
+ /** Vertex pulling for the clustered draw: appended to the scene's draw shader. */
66
+ const CLUSTER_DRAW = `${CLUSTER_SOURCES}
67
+ @group(0) @binding(24) var<storage, read> cl_visible: array<vec4u>;
68
+ @vertex fn vs_cluster(@builtin(vertex_index) vi: u32, @builtin(instance_index) base: u32) -> VertexOut {
69
+ let rec = cl_visible[base + vi / ${SLOT}u];
70
+ let cl = cl_clusters[rec.y];
71
+ let v = (cl_vertices[cl.vert_offset + vi % ${SLOT}u] + cl.base_vertex) * 8u;
72
+ return shade(load_instance(rec.x), vec3f(cl_pool[v], cl_pool[v + 1u], cl_pool[v + 2u]), vec3f(cl_pool[v + 3u], cl_pool[v + 4u], cl_pool[v + 5u]), vec2f(cl_pool[v + 6u], cl_pool[v + 7u]));
73
+ }
74
+ `;
75
+ /**
76
+ * The motion pass over the clusters the view drew (ADR-0112): the same visible lists and compacted
77
+ * indices, so motion covers exactly the drawn surface. Appended to the motion shader (`place`,
78
+ * `pose_now`, `pose_then` there).
79
+ */
80
+ const CLUSTER_MOTION = `${CLUSTER_SOURCES}
81
+ @group(0) @binding(24) var<storage, read> cl_visible: array<vec4u>;
82
+ @vertex fn vs_cluster_motion(@builtin(vertex_index) vi: u32, @builtin(instance_index) base: u32) -> Out {
83
+ let rec = cl_visible[base + vi / ${SLOT}u];
84
+ let cl = cl_clusters[rec.y];
85
+ let v = (cl_vertices[cl.vert_offset + vi % ${SLOT}u] + cl.base_vertex) * 8u;
86
+ let p = vec3f(cl_pool[v], cl_pool[v + 1u], cl_pool[v + 2u]);
87
+ return place(pose_now(rec.x), pose_then(rec.x), p, vec3f(prev_pool[v], prev_pool[v + 1u], prev_pool[v + 2u]));
88
+ }
89
+ `;
90
+ /** Vertices per clustered caster instance: the most triangles a cluster has, three corners each. */
91
+ const SHADOW_VERTICES = 124 * 3;
92
+ /**
93
+ * Clustered shadow casters (ADR-0112): one instance per surviving cluster, pulling each corner through
94
+ * its triangle bytes; corners past them collapse to a point. Appended to the scene's shadow shader
95
+ * (`cascade`, `instances`, `shadow_clip` there). (ADR-0122's indexed casters were removed, ADR-0139.)
96
+ */
97
+ const CLUSTER_SHADOW = `${CLUSTER_SOURCES}
98
+ @group(0) @binding(23) var<storage, read> cl_triangles: array<u32>;
99
+ @group(0) @binding(24) var<storage, read> cl_casters: array<vec4u>;
100
+ @vertex fn vs_shadow_cluster(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> @builtin(position) vec4f {
101
+ let rec = cl_casters[ii];
102
+ let cl = cl_clusters[rec.y];
103
+ if (vi / 3u >= cl.tri_count) {
104
+ return vec4f(0.0);
105
+ }
106
+ let b = cl.tri_offset + vi;
107
+ let local = (cl_triangles[b / 4u] >> ((b % 4u) * 8u)) & 0xffu;
108
+ let v = (cl_vertices[cl.vert_offset + local] + cl.base_vertex) * 8u;
109
+ let a = bitcast<vec4f>(instances[2u * rec.x]);
110
+ let q = bitcast<vec4f>(instances[2u * rec.x + 1u]);
111
+ return shadow_clip(a.xyz - cascade.origin.xyz, a.w, q, vec3f(cl_pool[v], cl_pool[v + 1u], cl_pool[v + 2u]));
112
+ }
113
+ `;
114
+ /** Grows a typed array to hold `n` more. */
115
+ function grow(a, used, more, make) {
116
+ if (used + more <= a.length)
117
+ return a;
118
+ const b = make(Math.max(a.length * 2, used + more));
119
+ b.set(a.subarray(0, used));
120
+ return b;
121
+ }
122
+ /**
123
+ * Clusters on the CPU, in the layout the shaders read (ADR-0100, ADR-0111): 32 words per cluster
124
+ * (bounding sphere, normal cone, base vertex, where its vertices and triangle bytes start, its
125
+ * triangle count, flags, then its own and its parent's error spheres and errors, both 0 without a
126
+ * hierarchy), the clusters' local vertex indices, and their triangle bytes (each cluster's start
127
+ * rounded to 4). Pure: tested in Node.
128
+ */
129
+ export class ClusterData {
130
+ words = new Uint32Array(1024);
131
+ f32 = new Float32Array(this.words.buffer);
132
+ count = 0;
133
+ vertices = new Uint32Array(4096);
134
+ vertexCount = 0;
135
+ triangles = new Uint8Array(4096);
136
+ triangleBytes = 0;
137
+ meshes = 0;
138
+ /**
139
+ * Clusters a mesh (interleaved vertices, 8 floats each; its finest LOD's indices) on this thread and
140
+ * appends them; with `lod`, its whole hierarchy (ADR-0111). Needs `MeshoptClusterizer.ready` (and
141
+ * the simplifier's for `lod`).
142
+ */
143
+ build(vertices, indices, baseVertex, doubleSided, lod = false) {
144
+ return this.append(packClusters(vertices, indices, lod), baseVertex, doubleSided);
145
+ }
146
+ /**
147
+ * Appends one mesh's packed clusters (`packClusters`, here or on a decode worker, ADR-0113): moves
148
+ * their vertex and triangle offsets to where they land and sets the mesh's base vertex and side.
149
+ */
150
+ append(p, baseVertex, doubleSided) {
151
+ const first = this.count;
152
+ const n = p.words.length / CLUSTER_WORDS;
153
+ this.words = grow(this.words, first * CLUSTER_WORDS, p.words.length, (k) => new Uint32Array(k));
154
+ this.f32 = new Float32Array(this.words.buffer);
155
+ const vBase = this.vertexCount;
156
+ this.vertices = grow(this.vertices, vBase, p.vertices.length, (k) => new Uint32Array(k));
157
+ this.vertices.set(p.vertices, vBase);
158
+ this.vertexCount += p.vertices.length;
159
+ // Each cluster's triangles start on 4 bytes, so the block keeps its alignment from a 4-byte start.
160
+ const tBase = this.triangleBytes;
161
+ this.triangles = grow(this.triangles, tBase, p.triangles.length + 4, (k) => new Uint8Array(k));
162
+ this.triangles.set(p.triangles, tBase);
163
+ this.triangleBytes = (tBase + p.triangles.length + 3) & ~3;
164
+ const w = this.words;
165
+ w.set(p.words, first * CLUSTER_WORDS);
166
+ for (let c = 0; c < n; c++) {
167
+ const o = (first + c) * CLUSTER_WORDS;
168
+ w[o + 11] = baseVertex;
169
+ w[o + 12] += vBase;
170
+ w[o + 13] += tBase;
171
+ w[o + 15] = doubleSided ? 1 : 0;
172
+ }
173
+ this.count += n;
174
+ this.meshes++;
175
+ return { first, count: n, doubleSided, indices: p.corners };
176
+ }
177
+ }
178
+ /** Floats of the shadow cull's uniform: 20 planes and the light. */
179
+ const SHADOW_CULL_FLOATS = 84;
180
+ /** Words of the casters' draw state (`sh_args`): 4 cascades × 2 sides × a draw record. */
181
+ const SHADOW_ARGS_WORDS = 32;
182
+ /** The clustered meshes' data on the GPU, the cull passes and the draw. */
183
+ export class ClusterRenderer {
184
+ device;
185
+ #scene;
186
+ #store;
187
+ #min;
188
+ #maxEntries;
189
+ /** Whether meshes get a cluster LOD hierarchy (ADR-0111). */
190
+ #lod;
191
+ version = 0;
192
+ /** Meshes looked at, by id: their clusters, or null (not clustered). */
193
+ #meshes = [];
194
+ #storeVersion = -1;
195
+ /** The clusters on the CPU (uploaded when they grow). */
196
+ data = new ClusterData();
197
+ #uploaded = -1;
198
+ #buffers = null;
199
+ #phases;
200
+ /** Pass 1's held-back (instance, cluster) pairs, read by pass 2 (ADR-0109). */
201
+ #recheck = null;
202
+ #recheckDispatch;
203
+ /** Occlusion for clusters (ADR-0109; live): off culls them once, after both instance passes, as ADR-0100 did. */
204
+ occlusion = true;
205
+ /**
206
+ * Clustered shadow casters (ADR-0112; live): with a hierarchy (`lod`), casters at the view's cut, culled
207
+ * per cascade and pulled; without one the cascades draw clustered meshes whole (pulled costs more there).
208
+ * Off, they draw whole always. (ADR-0122's indexed casters were removed: slower whole frames, ADR-0139.)
209
+ */
210
+ shadows = true;
211
+ /** Clustered motion (ADR-0112; live): off, the motion pass draws clustered meshes whole, as before. */
212
+ motion = true;
213
+ #shadow;
214
+ #shadowData = new Float32Array(SHADOW_CULL_FLOATS);
215
+ #shadowArgs = new Uint32Array(SHADOW_ARGS_WORDS);
216
+ #shadowCull;
217
+ #shadowDraw = null;
218
+ #shadowHash = '';
219
+ #motionDraw = null;
220
+ #motionHash = '';
221
+ #motionGroups = [0, 1, 2].map(() => ({ key: '', group: null }));
222
+ /** The phases that culled last frame (diagnostics). */
223
+ #ran = [false, false, false];
224
+ #cull;
225
+ #cullFirst;
226
+ #cullSecond;
227
+ #sizeRecheck;
228
+ #recheckPipe;
229
+ #size;
230
+ #expand;
231
+ #draw = null;
232
+ #drawHash = '';
233
+ #cache = null;
234
+ /** Streamed pages' residency (ADR-0134). */
235
+ residency = new ClusterResidency();
236
+ /** By residency mesh: the store's mesh id and its pages. */
237
+ #streamIds = [];
238
+ #streamSrc = [];
239
+ /** The cull's page bits (needed, then used) and their words per half. */
240
+ #pageBits = null;
241
+ #pageWords = 0;
242
+ #pageReading = false;
243
+ /** Whether the cull binds the page bits (a ninth storage buffer a stage). */
244
+ #streamBits;
245
+ #inFlight;
246
+ #fetch;
247
+ #decoder = null;
248
+ #fetchWarned = false;
249
+ /** Pages fetched, their bytes, fetches that failed (ADR-0134). */
250
+ streamStats = { fetched: 0, bytes: 0, failed: 0 };
251
+ /** Meshes clustered and their clusters (for the counters). */
252
+ get stats() {
253
+ return { meshes: this.data.meshes, clusters: this.data.count };
254
+ }
255
+ constructor(device, scene, store, o) {
256
+ this.device = device;
257
+ this.#scene = scene;
258
+ this.#store = store;
259
+ this.#min = o.minTriangles ?? 4096;
260
+ this.#maxEntries = o.maxEntries ?? 4 << 20;
261
+ this.#lod = o.lod ?? false;
262
+ this.#pool = o.pool ?? null;
263
+ this.#streamBits = device.limits.maxStorageBuffersPerShaderStage >= 9;
264
+ this.#inFlight = o.stream?.inFlight ?? 8;
265
+ this.#fetch = o.stream?.fetch ?? rangeFetch;
266
+ const phase = (k) => ({
267
+ jobsKey: '',
268
+ jobs: device.createBuffer({ label: `yali:clusters:jobs${k}`, size: 32 * 64, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }),
269
+ params: device.createBuffer({ label: `yali:clusters:params${k}`, size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }),
270
+ outArgs: device.createBuffer({ label: `yali:clusters:args${k}`, size: 48, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC }),
271
+ outVisible: null,
272
+ outIndices: null,
273
+ dispatchArgs: device.createBuffer({ label: `yali:clusters:expand-dispatch${k}`, size: 12, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT }),
274
+ dispatch: { x: 0, y: 0, half: 0, indexHalf: 0 },
275
+ groupKey: '',
276
+ cullGroup: null,
277
+ sizeGroup: null,
278
+ expandGroup: null,
279
+ drawGroups: null,
280
+ recheckGroup: null,
281
+ sizeRecheckGroup: null,
282
+ });
283
+ this.#phases = [phase(0), phase(1), phase(2)];
284
+ this.#recheckDispatch = device.createBuffer({ label: 'yali:clusters:recheck-dispatch', size: 12, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT });
285
+ this.#shadow = {
286
+ jobsKey: '',
287
+ jobs: device.createBuffer({ label: 'yali:clusters:shadow-jobs', size: 32 * 64, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }),
288
+ params: device.createBuffer({ label: 'yali:clusters:shadow-params', size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }),
289
+ args: device.createBuffer({ label: 'yali:clusters:shadow-args', size: SHADOW_ARGS_WORDS * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC }),
290
+ planes: device.createBuffer({ label: 'yali:clusters:shadow-planes', size: SHADOW_CULL_FLOATS * 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }),
291
+ visible: null,
292
+ dispatch: { x: 0, y: 0, half: 0, stride: 0, cascades: 0 },
293
+ groupKey: '',
294
+ cullGroup: null,
295
+ drawGroups: [],
296
+ live: false,
297
+ };
298
+ }
299
+ async init(cache) {
300
+ this.#cache = cache;
301
+ [this.#cull, this.#cullFirst, this.#cullSecond, this.#sizeRecheck, this.#recheckPipe, this.#size, this.#expand, this.#shadowCull] = await Promise.all(['cull_clusters', 'cull_clusters_first', 'cull_clusters_second', 'size_recheck', 'recheck_clusters', 'size_expand', 'expand_clusters', 'cull_clusters_shadow'].map((entryPoint) => cache.compute({ label: `yali:clusters:${entryPoint}`, compute: { shader: this.#streamBits ? sceneClusters : withoutPageBits(sceneClusters), entryPoint } })));
302
+ }
303
+ /** Whether mesh `mesh`'s finest LOD is drawn as clusters (the main view's slot then skips it). */
304
+ covers(mesh) {
305
+ this.#sync();
306
+ return !!this.#meshes[mesh];
307
+ }
308
+ /**
309
+ * Clusters the meshes added since the last look: on the decode pool when there is one (ADR-0113;
310
+ * the mesh draws whole until they arrive), else here on the main thread.
311
+ */
312
+ #sync() {
313
+ const store = this.#store;
314
+ if (store.version === this.#storeVersion)
315
+ return;
316
+ this.#storeVersion = store.version;
317
+ let changed = false;
318
+ for (let id = this.#meshes.length; id < store.entries.length; id++) {
319
+ const e = store.entries[id];
320
+ const g = store.geometry(id);
321
+ // Rigid, opaque (single- or double-sided) and big enough to gain from it.
322
+ const clusterable = g && !e.skinned && (e.drawClass === 0 || e.drawClass === 1) && g.indices.length / 3 >= this.#min;
323
+ this.#meshes[id] = null;
324
+ if (!clusterable)
325
+ continue;
326
+ // Streamed pages (`yali convert --clusters --stream`, ADR-0134): the roots now, the rest as asked for.
327
+ if (g.pages && this.#lod) {
328
+ this.#addStreamed(id, g.pages, e.baseVertex, e.drawClass === 1, g.vertices.length / 8);
329
+ continue;
330
+ }
331
+ // Clusters the file carried (`yali convert --clusters`, ADR-0121): no build at all.
332
+ if (g.clusters && g.clusters.lod === this.#lod) {
333
+ this.#meshes[id] = this.data.append(g.clusters, e.baseVertex, e.drawClass === 1);
334
+ this.buildStats.prebuilt++;
335
+ changed = true;
336
+ continue;
337
+ }
338
+ if (this.#pool) {
339
+ this.#offload(id, g.vertices, g.indices, e.baseVertex, e.drawClass === 1);
340
+ continue;
341
+ }
342
+ this.#meshes[id] = this.data.build(g.vertices, g.indices, e.baseVertex, e.drawClass === 1, this.#lod);
343
+ changed = true;
344
+ }
345
+ if (changed)
346
+ this.version++;
347
+ }
348
+ /** Builds a mesh's clusters on the pool; if the pool cannot (no `cluster-lod` decoder), here, once warned. */
349
+ #offload(id, vertices, indices, baseVertex, doubleSided) {
350
+ const t0 = performance.now();
351
+ const job = this.#pool.decode('cluster-lod', clusterRequest(vertices, indices, this.#lod))
352
+ .catch((err) => {
353
+ if (!this.#warned) {
354
+ this.#warned = true;
355
+ console.warn(`[yali] meshlets: building clusters on the main thread; the decode pool failed (${err instanceof Error ? err.message : String(err)}). Register decodeClusterLod from @yali-engine/render/meshlets-decoder as 'cluster-lod' in the decode worker.`);
356
+ }
357
+ return packClusters(vertices, indices, this.#lod);
358
+ })
359
+ .then((p) => {
360
+ this.#meshes[id] = this.data.append(p, baseVertex, doubleSided);
361
+ this.buildStats.meshes++;
362
+ this.buildStats.ms += performance.now() - t0;
363
+ this.version++;
364
+ });
365
+ this.#pending.add(job);
366
+ void job.finally(() => this.#pending.delete(job));
367
+ }
368
+ /** A streamed mesh: its records appended, its root pages decoded and applied (the mesh draws whole until then). */
369
+ #addStreamed(id, src, baseVertex, doubleSided, capacity) {
370
+ const job = (async () => {
371
+ const decode = await this.#vertexDecoder();
372
+ const roots = [];
373
+ let rootVertices = 0;
374
+ for (let p = 0; p < src.roots; p++) {
375
+ const at = src.pages[p * PAGE_WORDS + 3];
376
+ roots.push(decodeClusterPage(src.rootData.subarray(at, at + src.pages[p * PAGE_WORDS + 4]), decode));
377
+ rootVertices += src.pages[p * PAGE_WORDS + 2];
378
+ }
379
+ const c = this.data.append({ words: src.words.slice(), vertices: new Uint32Array(src.localIds), triangles: new Uint8Array(src.triangleBytes), corners: src.corners }, baseVertex, doubleSided);
380
+ const slots = src.slotVertices > 0 ? Math.max(0, Math.floor((capacity - rootVertices) / src.slotVertices)) : 0;
381
+ const m = this.residency.addMesh(this.data, { first: c.first, table: src.pages, roots: src.roots, slotVertices: src.slotVertices, baseVertex, rootVertices, slots }, roots);
382
+ this.#streamIds[m] = id;
383
+ this.#streamSrc[m] = src;
384
+ this.residency.takeDirty(); // the appended records upload whole; the roots' vertices are the mesh's own
385
+ this.#meshes[id] = c;
386
+ this.buildStats.prebuilt++;
387
+ this.version++;
388
+ })().catch((err) => console.warn(`[yali] meshlets: mesh ${id}'s streamed pages could not be read (${err instanceof Error ? err.message : String(err)}); it draws its root mesh whole.`));
389
+ this.#pending.add(job);
390
+ void job.finally(() => this.#pending.delete(job));
391
+ }
392
+ #vertexDecoder() {
393
+ this.#decoder ??= import('meshoptimizer/decoder').then(async ({ MeshoptDecoder }) => {
394
+ await MeshoptDecoder.ready;
395
+ return (t, c, s, src) => MeshoptDecoder.decodeVertexBuffer(t, c, s, src);
396
+ });
397
+ return this.#decoder;
398
+ }
399
+ /** The page bits buffer, sized for every page (two halves: needed, used). */
400
+ #ensurePageBits() {
401
+ const words = Math.max(1, Math.ceil(this.residency.pageCount / 32));
402
+ if (!this.#pageBits || this.#pageWords < words) {
403
+ this.#pageBits?.destroy();
404
+ this.#pageBits = this.device.createBuffer({ label: 'yali:clusters:page-bits', size: words * 8, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST });
405
+ this.#pageWords = words;
406
+ for (const p of this.#phases)
407
+ p.groupKey = '';
408
+ }
409
+ return this.#pageBits;
410
+ }
411
+ /**
412
+ * Once a frame (ADR-0134): what pages changed goes to the GPU (vertices straight into the scene's pool,
413
+ * ahead of the cull that may draw them), then the cull's page bits are read (a frame or so late) and
414
+ * the pages they ask for fetched. Without page bits every page is asked for, coarse first.
415
+ */
416
+ #stream() {
417
+ const r = this.residency;
418
+ if (r.pageCount === 0)
419
+ return;
420
+ const bits = this.#ensurePageBits();
421
+ const q = this.device.queue;
422
+ const dirty = r.takeDirty();
423
+ const pool = this.#scene.clusterSources()?.pool;
424
+ for (const w of dirty.vertices) {
425
+ const id = this.#streamIds[w.mesh];
426
+ this.#store.writeVertices(id, w.first, w.data);
427
+ if (pool)
428
+ q.writeBuffer(pool, (this.#store.entries[id].baseVertex + w.first) * 32, w.data);
429
+ }
430
+ if (this.#buffers && this.#uploaded === this.data.count) {
431
+ const b = this.#buffers;
432
+ const rec = dirty.records;
433
+ for (let i = 0; i < rec.length;) {
434
+ let j = i + 1;
435
+ while (j < rec.length && rec[j] === rec[j - 1] + 1)
436
+ j++;
437
+ q.writeBuffer(b.clusters, rec[i] * CLUSTER_BYTES, this.data.words.buffer, rec[i] * CLUSTER_BYTES, (j - i) * CLUSTER_BYTES);
438
+ i = j;
439
+ }
440
+ for (const [a, z] of dirty.ids)
441
+ if (z > a)
442
+ q.writeBuffer(b.vertices, a * 4, this.data.vertices.buffer, a * 4, (z - a) * 4);
443
+ for (const [a, z] of dirty.triangles)
444
+ if (z > a)
445
+ q.writeBuffer(b.triangles, a, this.data.triangles.buffer, a, (z - a + 3) & ~3);
446
+ }
447
+ const room = this.#inFlight - r.inFlight;
448
+ if (room <= 0)
449
+ return;
450
+ if (!this.#streamBits) {
451
+ const all = new Uint32Array(this.#pageWords).fill(0xffffffff);
452
+ for (const g of r.requests(this.data, all, new Uint32Array(this.#pageWords), room))
453
+ this.#fetchPage(g);
454
+ return;
455
+ }
456
+ if (this.#pageReading)
457
+ return;
458
+ this.#pageReading = true;
459
+ const size = this.#pageWords * 8;
460
+ const rb = this.device.createBuffer({ label: 'yali:clusters:page-bits-read', size, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
461
+ const e = this.device.createCommandEncoder();
462
+ e.copyBufferToBuffer(bits, 0, rb, 0, size);
463
+ e.clearBuffer(bits);
464
+ q.submit([e.finish()]);
465
+ void rb
466
+ .mapAsync(GPUMapMode.READ)
467
+ .then(() => {
468
+ const w = new Uint32Array(rb.getMappedRange().slice(0));
469
+ const half = w.length / 2;
470
+ for (const g of r.requests(this.data, w.subarray(0, half), w.subarray(half), this.#inFlight - r.inFlight))
471
+ this.#fetchPage(g);
472
+ })
473
+ .catch(() => { })
474
+ .finally(() => {
475
+ rb.destroy();
476
+ this.#pageReading = false;
477
+ });
478
+ }
479
+ #fetchPage(g) {
480
+ const r = this.residency;
481
+ const { mesh, offset, length } = r.locate(g);
482
+ const src = this.#streamSrc[mesh];
483
+ void this.#fetch(src.url, src.base + offset - src.rootData.length, length)
484
+ .then(async (bytes) => {
485
+ const page = decodeClusterPage(bytes, await this.#vertexDecoder());
486
+ this.streamStats.fetched++;
487
+ this.streamStats.bytes += length;
488
+ r.load(this.data, g, page);
489
+ })
490
+ .catch((err) => {
491
+ r.cancel(g);
492
+ this.streamStats.failed++;
493
+ if (!this.#fetchWarned) {
494
+ this.#fetchWarned = true;
495
+ console.warn(`[yali] meshlets: a cluster page could not be fetched (${err instanceof Error ? err.message : String(err)}); its parents stay leaves.`);
496
+ }
497
+ });
498
+ }
499
+ #pool;
500
+ #pending = new Set();
501
+ #warned = false;
502
+ /** Meshes clustered on the pool and the milliseconds from request to arrival, summed (ADR-0113); meshes whose file carried their clusters (ADR-0121). */
503
+ buildStats = { meshes: 0, ms: 0, prebuilt: 0 };
504
+ /** Cluster builds still on the pool. */
505
+ get pending() {
506
+ return this.#pending.size;
507
+ }
508
+ #upload() {
509
+ if (this.#uploaded === this.data.count && this.#buffers)
510
+ return;
511
+ const d = this.device;
512
+ this.#buffers?.clusters.destroy();
513
+ this.#buffers?.vertices.destroy();
514
+ this.#buffers?.triangles.destroy();
515
+ const make = (label, data, bytes) => {
516
+ const b = d.createBuffer({ label, size: Math.max(16, (bytes + 3) & ~3), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
517
+ d.queue.writeBuffer(b, 0, data.buffer, data.byteOffset, (bytes + 3) & ~3);
518
+ return b;
519
+ };
520
+ this.#buffers = {
521
+ clusters: make('yali:clusters', this.data.words, this.data.count * CLUSTER_BYTES),
522
+ vertices: make('yali:clusters:vertices', this.data.vertices, this.data.vertexCount * 4),
523
+ triangles: make('yali:clusters:triangles', this.data.triangles, this.data.triangleBytes),
524
+ };
525
+ this.#uploaded = this.data.count;
526
+ for (const p of this.#phases)
527
+ p.groupKey = '';
528
+ this.#shadow.groupKey = '';
529
+ }
530
+ /**
531
+ * The job records (8 words each) for the clustered finest-LOD slots of `sets`, and the room their
532
+ * survivors may need over `roomSets`: entries per side and indices per side.
533
+ */
534
+ #jobWords(lay, sets, roomSets) {
535
+ const words = [];
536
+ let prefix = 0;
537
+ let maxN = 0;
538
+ const halves = [0, 0];
539
+ const indexHalves = [0, 0];
540
+ for (const { slot, mesh, start, n } of lay.slots) {
541
+ const c = this.#meshes[mesh];
542
+ if (!c)
543
+ continue;
544
+ for (const set of sets) {
545
+ words.push(slot + set * lay.slots_, start + set * lay.region, c.first, c.count, prefix, c.doubleSided ? 1 : 0, 0, 0);
546
+ prefix += c.count;
547
+ maxN = Math.max(maxN, n);
548
+ }
549
+ for (const _ of roomSets) {
550
+ halves[c.doubleSided ? 1 : 0] += n * c.count;
551
+ indexHalves[c.doubleSided ? 1 : 0] += n * c.indices;
552
+ }
553
+ }
554
+ return { words, prefix, maxN, halves, indexHalves };
555
+ }
556
+ /**
557
+ * Phase `k`'s jobs for this layout: one per (clustered mesh's finest-LOD slot, slot set it culls).
558
+ * Pass 2 also has room for every cluster pass 1 may hold back.
559
+ */
560
+ #layoutJobs(k) {
561
+ const p = this.#phases[k];
562
+ const s = this.#scene;
563
+ const lay = s.clusterLayout();
564
+ if (!lay)
565
+ return false;
566
+ const key = `${lay.key}:${this.version}`;
567
+ if (key === p.jobsKey)
568
+ return p.dispatch.x > 0;
569
+ p.jobsKey = key;
570
+ const sets = k === 0 ? lay.sets : [k];
571
+ const { words, prefix, maxN, halves, indexHalves } = this.#jobWords(lay, sets, k === 2 ? [1, 2] : sets);
572
+ const jobs = words.length / 8;
573
+ if (jobs === 0 || halves[0] + halves[1] > this.#maxEntries || indexHalves[0] + indexHalves[1] > this.#maxEntries * 64) {
574
+ p.dispatch = { x: 0, y: 0, half: 0, indexHalf: 0 };
575
+ return false;
576
+ }
577
+ if (p.jobs.size < words.length * 4) {
578
+ p.jobs.destroy();
579
+ p.jobs = this.device.createBuffer({ label: `yali:clusters:jobs${k}`, size: words.length * 8, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
580
+ }
581
+ this.device.queue.writeBuffer(p.jobs, 0, new Uint32Array(words));
582
+ this.device.queue.writeBuffer(p.params, 0, new Uint32Array([jobs, prefix, halves[0], indexHalves[0]]));
583
+ const entries = Math.max(1, halves[0] + halves[1]);
584
+ if (!p.outVisible || p.outVisible.size < entries * 16) {
585
+ p.outVisible?.destroy();
586
+ p.outVisible = this.device.createBuffer({ label: `yali:clusters:visible${k}`, size: entries * 16, usage: GPUBufferUsage.STORAGE });
587
+ }
588
+ const indices = Math.max(4, indexHalves[0] + indexHalves[1]);
589
+ if (!p.outIndices || p.outIndices.size < indices * 4) {
590
+ p.outIndices?.destroy();
591
+ p.outIndices = this.device.createBuffer({ label: `yali:clusters:indices${k}`, size: indices * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDEX });
592
+ }
593
+ // Pass 1 holds back at most what it culls: (instance, cluster) pairs after a count.
594
+ if (k === 1 && (!this.#recheck || this.#recheck.size < (1 + entries * 2) * 4)) {
595
+ this.#recheck?.destroy();
596
+ this.#recheck = this.device.createBuffer({ label: 'yali:clusters:recheck', size: (1 + entries * 2) * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
597
+ for (const q of this.#phases)
598
+ q.groupKey = '';
599
+ }
600
+ p.dispatch = { x: Math.ceil(prefix / 64), y: Math.min(maxN, 65535), half: halves[0], indexHalf: indexHalves[0] };
601
+ p.groupKey = '';
602
+ return true;
603
+ }
604
+ #groups(k) {
605
+ const p = this.#phases[k];
606
+ const s = this.#scene;
607
+ const src = s.clusterSources();
608
+ const occl = k > 0 ? src?.occlusion : null;
609
+ if (!src || !this.#buffers || !p.outVisible || !this.#draw || (k > 0 && (!occl || !this.#recheck)))
610
+ return false;
611
+ const pageBits = this.#streamBits ? [{ binding: 21, resource: { buffer: this.#ensurePageBits() } }] : [];
612
+ const key = `${src.key}:${p.jobsKey}:${this.#uploaded}:${this.#drawHash}:${occl?.version ?? -1}:${this.#recheck?.size ?? 0}:${this.#pageWords}`;
613
+ if (key === p.groupKey)
614
+ return !!p.cullGroup;
615
+ p.groupKey = key;
616
+ const d = this.device;
617
+ const b = this.#buffers;
618
+ const buf = (binding, buffer) => ({ binding, resource: { buffer } });
619
+ const occlEntries = occl ? [buf(12, occl.params), { binding: 13, resource: occl.view }] : [];
620
+ const cull = k === 0 ? this.#cull : k === 1 ? this.#cullFirst : this.#cullSecond;
621
+ p.cullGroup = d.createBindGroup({
622
+ label: `yali:clusters:cull${k}`,
623
+ layout: cull.getBindGroupLayout(0),
624
+ entries: [buf(0, src.camera), buf(1, src.instances), buf(2, src.visible), buf(3, src.args), buf(4, b.clusters), buf(5, p.jobs), buf(6, p.params), buf(7, p.outArgs), buf(8, p.outVisible), buf(16, src.cullParams), ...occlEntries, ...(k === 1 ? [buf(14, this.#recheck)] : []), ...pageBits],
625
+ });
626
+ if (k === 2) {
627
+ p.sizeRecheckGroup = d.createBindGroup({ label: 'yali:clusters:size-recheck', layout: this.#sizeRecheck.getBindGroupLayout(0), entries: [buf(14, this.#recheck), buf(15, this.#recheckDispatch)] });
628
+ p.recheckGroup = d.createBindGroup({
629
+ label: 'yali:clusters:recheck',
630
+ layout: this.#recheckPipe.getBindGroupLayout(0),
631
+ entries: [buf(0, src.camera), buf(1, src.instances), buf(4, b.clusters), buf(6, p.params), buf(7, p.outArgs), buf(8, p.outVisible), buf(16, src.cullParams), ...occlEntries, buf(14, this.#recheck), ...pageBits],
632
+ });
633
+ }
634
+ p.sizeGroup = d.createBindGroup({ label: `yali:clusters:size${k}`, layout: this.#size.getBindGroupLayout(0), entries: [buf(7, p.outArgs), buf(11, p.dispatchArgs)] });
635
+ p.expandGroup = d.createBindGroup({
636
+ label: `yali:clusters:expand${k}`,
637
+ layout: this.#expand.getBindGroupLayout(0),
638
+ entries: [buf(4, b.clusters), buf(6, p.params), buf(7, p.outArgs), buf(8, p.outVisible), buf(9, p.outIndices), buf(10, b.triangles)],
639
+ });
640
+ const draw = [...src.drawEntries.filter((e) => e.binding !== 2 && e.binding !== 17), buf(20, src.pool), buf(21, b.clusters), buf(22, b.vertices), buf(24, p.outVisible)];
641
+ p.drawGroups = [0, 1].map((side) => d.createBindGroup({ label: `yali:clusters:draw${k}:${side}`, layout: this.#draw[side].getBindGroupLayout(0), entries: draw }));
642
+ return true;
643
+ }
644
+ /** The draw pipelines over the scene's current draw shader (rebuilt when its materials change). */
645
+ #pipelines() {
646
+ const src = this.#scene.clusterShader();
647
+ if (!src || src.hash === this.#drawHash || !this.#cache)
648
+ return;
649
+ const hash = src.hash;
650
+ const shader = { hash: `${hash}-clusters`, wgsl: src.wgsl + CLUSTER_DRAW };
651
+ const make = (cullMode) => this.#cache.render({
652
+ label: `yali:clusters:draw:${cullMode}`,
653
+ vertex: { shader, entryPoint: 'vs_cluster' },
654
+ fragment: { shader, entryPoint: 'fs', targets: [{ format: this.#scene.format }] },
655
+ primitive: { topology: 'triangle-list', cullMode },
656
+ depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'greater' },
657
+ });
658
+ this.#drawHash = hash;
659
+ this.#building = Promise.all([make('back'), make('none')]).then((p) => {
660
+ this.#draw = p;
661
+ for (const q of this.#phases)
662
+ q.groupKey = '';
663
+ });
664
+ }
665
+ #building = null;
666
+ /**
667
+ * Resolves when the clustered draw's pipelines for the current draw shader are built, and every
668
+ * mesh the store holds now has its clusters (the pool's builds arrived, ADR-0113).
669
+ */
670
+ async ready() {
671
+ this.#sync();
672
+ this.#pipelines();
673
+ const src = this.#castersOn() ? this.#scene.clusterShadow() : null;
674
+ if (src)
675
+ this.#shadowPipelines(src);
676
+ await Promise.all([this.#building, this.#shadowBuilding, this.#motionBuilding, ...this.#pending]);
677
+ }
678
+ /**
679
+ * Draws frames until streamed pages stop arriving (ADR-0134): calls `frame` (one frame drawn) until no
680
+ * page is in flight or being asked for and none arrived for 8 frames in a row, at most `most` frames.
681
+ * For golden images, whose frame must not depend on when pages came. Immediate with nothing streamed.
682
+ */
683
+ async settled(frame, most = 1200) {
684
+ if (this.residency.pageCount === 0)
685
+ return;
686
+ let quiet = 0;
687
+ for (let i = 0; i < most && quiet < 8; i++) {
688
+ const before = this.residency.stats.loaded + this.streamStats.failed;
689
+ frame();
690
+ await new Promise((r) => setTimeout(r, 8));
691
+ const busy = this.residency.inFlight > 0 || this.#pageReading || this.residency.stats.loaded + this.streamStats.failed !== before;
692
+ quiet = busy ? 0 : quiet + 1;
693
+ }
694
+ }
695
+ /**
696
+ * The phase a call means: without occlusion (or with `occlusion` off) only phase 0, culled once
697
+ * after both instance passes under occlusion, as ADR-0100 did.
698
+ */
699
+ #phaseFor(phase) {
700
+ if (phase === 0 || this.occlusion)
701
+ return phase;
702
+ return phase === 2 ? 0 : -1;
703
+ }
704
+ /**
705
+ * Adds a cluster cull, reading the instance cull's `after` (ADR-0100, ADR-0109): `phase` 0 without
706
+ * occlusion; under occlusion 1 (after the first instance cull, drawn in the view's first pass) and
707
+ * 2 (after the recheck, drawn in the second).
708
+ */
709
+ addCull(graph, after, phase = 0) {
710
+ graph.addPass({
711
+ name: `scene.clusters${phase || ''}`,
712
+ kind: 'compute',
713
+ reads: phase === 2 ? [after, 'scene.clusterList1'] : [after],
714
+ writes: [`scene.clusterList${phase || ''}`],
715
+ execute: (pass) => {
716
+ const k = this.#phaseFor(phase);
717
+ if (phase !== 2)
718
+ this.#ran.fill(false);
719
+ if (k < 0) {
720
+ // Occlusion off for clusters: pass 1 draws none, pass 2's call culls them once (phase 0).
721
+ this.#phases[1].dispatch.x = 0;
722
+ this.#phases[1].jobsKey = '';
723
+ return;
724
+ }
725
+ this.#sync();
726
+ this.#pipelines();
727
+ if (this.data.count === 0 || !this.#layoutJobs(k))
728
+ return;
729
+ this.#upload();
730
+ if (phase !== 2)
731
+ this.#stream();
732
+ if (!this.#groups(k))
733
+ return;
734
+ const p = this.#phases[k];
735
+ this.#ran[k] = true;
736
+ const { half, indexHalf } = p.dispatch;
737
+ // Two indexed draws: each side's indices from its half, its visible-list entries from its half (first instance).
738
+ this.device.queue.writeBuffer(p.outArgs, 0, new Uint32Array([0, 1, 0, 0, 0, 0, 1, indexHalf, 0, half, 0, 0]));
739
+ if (k === 1)
740
+ this.device.queue.writeBuffer(this.#recheck, 0, new Uint32Array([0]));
741
+ pass.setPipeline(k === 0 ? this.#cull : k === 1 ? this.#cullFirst : this.#cullSecond);
742
+ pass.setBindGroup(0, p.cullGroup);
743
+ pass.dispatchWorkgroups(p.dispatch.x, p.dispatch.y);
744
+ if (k === 2 && this.#phases[1].dispatch.x > 0) {
745
+ pass.setPipeline(this.#sizeRecheck);
746
+ pass.setBindGroup(0, p.sizeRecheckGroup);
747
+ pass.dispatchWorkgroups(1);
748
+ pass.setPipeline(this.#recheckPipe);
749
+ pass.setBindGroup(0, p.recheckGroup);
750
+ pass.dispatchWorkgroupsIndirect(this.#recheckDispatch, 0);
751
+ }
752
+ pass.setPipeline(this.#size);
753
+ pass.setBindGroup(0, p.sizeGroup);
754
+ pass.dispatchWorkgroups(1);
755
+ pass.setPipeline(this.#expand);
756
+ pass.setBindGroup(0, p.expandGroup);
757
+ pass.dispatchWorkgroupsIndirect(p.dispatchArgs, 0);
758
+ },
759
+ });
760
+ }
761
+ /** Draws phase `phase`'s visible clusters (in the scene's opaque pass it is called from). */
762
+ encodeDraw(pass, phase = 0) {
763
+ const k = this.#phaseFor(phase);
764
+ if (k < 0)
765
+ return;
766
+ const p = this.#phases[k];
767
+ if (p.dispatch.x === 0 || !this.#draw || !p.drawGroups)
768
+ return;
769
+ pass.setIndexBuffer(p.outIndices, 'uint32');
770
+ for (const side of [0, 1]) {
771
+ pass.setPipeline(this.#draw[side]);
772
+ pass.setBindGroup(0, p.drawGroups[side]);
773
+ pass.drawIndexedIndirect(p.outArgs, side * 20);
774
+ }
775
+ }
776
+ // ── Clustered shadow casters (ADR-0112) ────────────────────────────────────────────────────
777
+ /** The shadow cull's jobs: the caster set's (0) clustered slots, with room for every cascade. */
778
+ #layoutShadow(cascades) {
779
+ const sh = this.#shadow;
780
+ const lay = this.#scene.clusterLayout();
781
+ if (!lay)
782
+ return false;
783
+ const key = `${lay.key}:${this.version}:${cascades}`;
784
+ if (key === sh.jobsKey)
785
+ return sh.dispatch.x > 0;
786
+ sh.jobsKey = key;
787
+ sh.groupKey = '';
788
+ const { words, prefix, maxN, halves } = this.#jobWords(lay, [0], [0]);
789
+ const jobs = words.length / 8;
790
+ const stride = halves[0] + halves[1];
791
+ if (jobs === 0 || stride * cascades > this.#maxEntries) {
792
+ sh.dispatch = { x: 0, y: 0, half: 0, stride: 0, cascades: 0 };
793
+ return false;
794
+ }
795
+ if (sh.jobs.size < words.length * 4) {
796
+ sh.jobs.destroy();
797
+ sh.jobs = this.device.createBuffer({ label: 'yali:clusters:shadow-jobs', size: words.length * 8, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
798
+ }
799
+ this.device.queue.writeBuffer(sh.jobs, 0, new Uint32Array(words));
800
+ this.device.queue.writeBuffer(sh.params, 0, new Uint32Array([jobs, prefix, halves[0], stride]));
801
+ const bytes = Math.max(1, stride * cascades) * 16;
802
+ if (!sh.visible || sh.visible.size < bytes) {
803
+ sh.visible?.destroy();
804
+ sh.visible = this.device.createBuffer({ label: 'yali:clusters:shadow-visible', size: bytes, usage: GPUBufferUsage.STORAGE });
805
+ }
806
+ sh.dispatch = { x: Math.ceil(prefix / 64), y: Math.min(maxN, 65535), half: halves[0], stride, cascades };
807
+ return true;
808
+ }
809
+ /** The caster pipelines over the scene's shadow shader (single-sided culls back faces, as the scene's). */
810
+ #shadowPipelines(src) {
811
+ if (src.shader.hash === this.#shadowHash || !this.#cache)
812
+ return;
813
+ this.#shadowHash = src.shader.hash;
814
+ const shader = { hash: `${src.shader.hash}-clusters-shadow`, wgsl: src.shader.wgsl + CLUSTER_SHADOW };
815
+ const make = (cullMode) => this.#cache.render({
816
+ label: `yali:clusters:shadow:${cullMode}`,
817
+ vertex: { shader, entryPoint: 'vs_shadow_cluster' },
818
+ primitive: { topology: 'triangle-list', cullMode, frontFace: 'ccw' },
819
+ depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'greater' },
820
+ });
821
+ this.#shadowBuilding = Promise.all([make('back'), make('none')]).then((p) => {
822
+ this.#shadowDraw = [p[0], p[1]];
823
+ this.#shadow.groupKey = '';
824
+ });
825
+ }
826
+ #shadowGroups(src) {
827
+ const sh = this.#shadow;
828
+ const s = this.#scene.clusterSources();
829
+ if (!s || !this.#buffers || !sh.visible || !this.#shadowDraw)
830
+ return false;
831
+ const key = `${s.key}:${sh.jobsKey}:${this.#uploaded}:${this.#shadowHash}:${sh.visible.size}`;
832
+ if (key === sh.groupKey)
833
+ return !!sh.cullGroup;
834
+ sh.groupKey = key;
835
+ const d = this.device;
836
+ const b = this.#buffers;
837
+ const buf = (binding, buffer) => ({ binding, resource: { buffer } });
838
+ sh.cullGroup = d.createBindGroup({
839
+ label: 'yali:clusters:shadow-cull',
840
+ layout: this.#shadowCull.getBindGroupLayout(0),
841
+ entries: [buf(0, s.camera), buf(1, s.instances), buf(2, s.visible), buf(3, s.args), buf(4, b.clusters), buf(5, sh.jobs), buf(6, sh.params), buf(16, s.cullParams), buf(17, sh.planes), buf(18, sh.args), buf(19, sh.visible)],
842
+ });
843
+ sh.drawGroups = Array.from({ length: src.cascades }, (_, c) => [0, 1].map((side) => d.createBindGroup({
844
+ label: `yali:clusters:shadow:${c}:${side}`,
845
+ layout: this.#shadowDraw[side].getBindGroupLayout(0),
846
+ entries: [
847
+ { binding: 0, resource: { buffer: src.cascadeBuffer, offset: src.cascadeOffset(c), size: src.cascadeBytes } },
848
+ buf(1, s.instances),
849
+ buf(20, s.pool),
850
+ buf(21, b.clusters),
851
+ buf(22, b.vertices),
852
+ buf(23, b.triangles),
853
+ buf(24, sh.visible),
854
+ ],
855
+ })));
856
+ return true;
857
+ }
858
+ /**
859
+ * Adds the shadow casters' cluster cull after `after` (the instance cull, whose slot set 0 holds the
860
+ * casters): every cascade at once, read by `scene.shadow` (ADR-0112).
861
+ */
862
+ addShadowCull(graph, after) {
863
+ graph.addPass({
864
+ name: 'scene.clusterShadow',
865
+ kind: 'compute',
866
+ reads: [after],
867
+ writes: ['scene.clusterShadow'],
868
+ execute: (pass) => {
869
+ const sh = this.#shadow;
870
+ sh.live = false;
871
+ if (!this.#castersOn())
872
+ return;
873
+ const src = this.#scene.clusterShadow();
874
+ if (!src)
875
+ return;
876
+ this.#sync();
877
+ this.#shadowPipelines(src);
878
+ if (this.data.count === 0 || !this.#shadowDraw || !this.#layoutShadow(src.cascades))
879
+ return;
880
+ this.#upload();
881
+ if (!this.#shadowGroups(src))
882
+ return;
883
+ const u = this.#shadowData;
884
+ u.fill(0);
885
+ u.set(src.planes.subarray(0, src.cascades * 20), 0);
886
+ u[80] = src.light[0];
887
+ u[81] = src.light[1];
888
+ u[82] = src.light[2];
889
+ u[83] = src.cascades;
890
+ this.device.queue.writeBuffer(sh.planes, 0, u);
891
+ const args = this.#shadowArgs;
892
+ args.fill(0);
893
+ for (let c = 0; c < src.cascades; c++) {
894
+ const o = c * 8;
895
+ args[o] = args[o + 4] = SHADOW_VERTICES;
896
+ args[o + 3] = c * sh.dispatch.stride;
897
+ args[o + 7] = c * sh.dispatch.stride + sh.dispatch.half;
898
+ }
899
+ this.device.queue.writeBuffer(sh.args, 0, args);
900
+ pass.setPipeline(this.#shadowCull);
901
+ pass.setBindGroup(0, sh.cullGroup);
902
+ pass.dispatchWorkgroups(sh.dispatch.x, sh.dispatch.y, src.cascades);
903
+ sh.live = true;
904
+ },
905
+ });
906
+ }
907
+ /** Whether the casters are clustered: with a hierarchy (ADR-0112; without one they draw whole, cheaper). */
908
+ #castersOn() {
909
+ return this.shadows && this.#lod;
910
+ }
911
+ /** Whether this frame's casters of mesh `mesh` are drawn as clusters (the cascades' slots then skip it). */
912
+ shadowCovers(mesh) {
913
+ return this.#shadow.live && !!this.#meshes[mesh];
914
+ }
915
+ /** Draws cascade `cascade`'s clustered casters into the shadow pass (its viewport already set). */
916
+ encodeShadow(pass, cascade) {
917
+ const sh = this.#shadow;
918
+ if (!sh.live || !this.#shadowDraw || cascade >= sh.drawGroups.length)
919
+ return;
920
+ for (const side of [0, 1]) {
921
+ pass.setPipeline(this.#shadowDraw[side]);
922
+ pass.setBindGroup(0, sh.drawGroups[cascade][side]);
923
+ pass.drawIndirect(sh.args, (cascade * 2 + side) * 16);
924
+ }
925
+ }
926
+ // ── Clustered motion (ADR-0112) ────────────────────────────────────────────────────────────
927
+ /**
928
+ * Whether the motion pass may skip the clustered meshes' slots: the clustered motion pipeline over
929
+ * `m`'s shader is built (it starts building on the first call).
930
+ */
931
+ motionCovers(m) {
932
+ if (!this.motion)
933
+ return false;
934
+ this.prepareMotion(m.shader, m.format);
935
+ return !!this.#motionDraw;
936
+ }
937
+ /** Starts building the clustered motion pipeline over the motion pass's shader (`ready()` then awaits it). */
938
+ prepareMotion(motionShader, format) {
939
+ if (!this.#cache || motionShader.hash === this.#motionHash)
940
+ return;
941
+ this.#motionHash = motionShader.hash;
942
+ this.#motionDraw = null;
943
+ const shader = { hash: `${motionShader.hash}-clusters-motion`, wgsl: motionShader.wgsl + CLUSTER_MOTION };
944
+ this.#motionBuilding = this.#cache
945
+ .render({
946
+ label: 'yali:clusters:motion',
947
+ vertex: { shader, entryPoint: 'vs_cluster_motion' },
948
+ fragment: { shader, entryPoint: 'fs', targets: [{ format }] },
949
+ primitive: { topology: 'triangle-list', cullMode: 'none' },
950
+ depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'greater' },
951
+ })
952
+ .then((p) => {
953
+ this.#motionDraw = p;
954
+ for (const g of this.#motionGroups)
955
+ g.key = '';
956
+ });
957
+ }
958
+ #motionBuilding = null;
959
+ #shadowBuilding = null;
960
+ /** Draws the clusters this frame's view drew (every phase that ran) with the motion pipeline. */
961
+ encodeMotion(pass, m) {
962
+ const draw = this.#motionDraw;
963
+ const b = this.#buffers;
964
+ const s = this.#scene.clusterSources();
965
+ if (!this.motion || !draw || !b || !s)
966
+ return;
967
+ for (let k = 0; k < 3; k++) {
968
+ const p = this.#phases[k];
969
+ if (!this.#ran[k] || p.dispatch.x === 0 || !p.outVisible || !p.outIndices)
970
+ continue;
971
+ const g = this.#motionGroups[k];
972
+ const key = `${m.key}:${p.groupKey}:${s.key}`;
973
+ if (g.key !== key || !g.group) {
974
+ g.key = key;
975
+ const buf = (binding, buffer) => ({ binding, resource: { buffer } });
976
+ g.group = this.device.createBindGroup({
977
+ label: `yali:clusters:motion${k}`,
978
+ layout: draw.getBindGroupLayout(0),
979
+ // The motion shader's own bindings but the visible ranges (the clusters carry their instance).
980
+ entries: [...m.entries.filter((e) => e.binding !== 2), buf(20, s.pool), buf(21, b.clusters), buf(22, b.vertices), buf(24, p.outVisible)],
981
+ });
982
+ }
983
+ pass.setPipeline(draw);
984
+ pass.setBindGroup(0, g.group);
985
+ pass.setIndexBuffer(p.outIndices, 'uint32');
986
+ pass.drawIndexedIndirect(p.outArgs, 0);
987
+ pass.drawIndexedIndirect(p.outArgs, 20);
988
+ }
989
+ }
990
+ /** Clusters the last frame drew, over its phases: single- and double-sided (a diagnostic; reads back from the GPU). */
991
+ async readCounts() {
992
+ const d = this.device;
993
+ const rb = d.createBuffer({ size: 48 * 3 + SHADOW_ARGS_WORDS * 4, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
994
+ const e = d.createCommandEncoder();
995
+ this.#phases.forEach((p, k) => e.copyBufferToBuffer(p.outArgs, 0, rb, k * 48, 48));
996
+ e.copyBufferToBuffer(this.#shadow.args, 0, rb, 144, SHADOW_ARGS_WORDS * 4);
997
+ d.queue.submit([e.finish()]);
998
+ await rb.mapAsync(GPUMapMode.READ);
999
+ const a = new Uint32Array(rb.getMappedRange().slice(0));
1000
+ rb.destroy();
1001
+ // Clustered casters per cascade (ADR-0112), both sides; empty when they did not run.
1002
+ const sh = this.#shadow;
1003
+ const shadow = sh.live ? Array.from({ length: sh.dispatch.cascades }, (_, c) => a[36 + c * 8 + 1] + a[36 + c * 8 + 5]) : [];
1004
+ const live = this.#ran.map((r, k) => r && this.#phases[k].dispatch.x > 0);
1005
+ let single = 0;
1006
+ let double = 0;
1007
+ let triangles = 0;
1008
+ const perPhase = [0, 1, 2].map((k) => (live[k] ? a[k * 12 + 10] + a[k * 12 + 11] : 0));
1009
+ for (let k = 0; k < 3; k++) {
1010
+ if (!live[k])
1011
+ continue;
1012
+ single += a[k * 12 + 10];
1013
+ double += a[k * 12 + 11];
1014
+ triangles += (a[k * 12] + a[k * 12 + 5]) / 3;
1015
+ }
1016
+ return { single, double, triangles, drawReady: !!this.#draw, jobs: this.#phases.reduce((n, p) => n + p.dispatch.x, 0), perPhase, shadow, motionReady: !!this.#motionDraw };
1017
+ }
1018
+ destroy() {
1019
+ this.#buffers?.clusters.destroy();
1020
+ this.#buffers?.vertices.destroy();
1021
+ this.#buffers?.triangles.destroy();
1022
+ for (const p of this.#phases) {
1023
+ p.jobs.destroy();
1024
+ p.params.destroy();
1025
+ p.outArgs.destroy();
1026
+ p.outVisible?.destroy();
1027
+ p.outIndices?.destroy();
1028
+ p.dispatchArgs.destroy();
1029
+ }
1030
+ this.#recheck?.destroy();
1031
+ this.#recheckDispatch.destroy();
1032
+ const sh = this.#shadow;
1033
+ sh.jobs.destroy();
1034
+ sh.params.destroy();
1035
+ sh.args.destroy();
1036
+ sh.planes.destroy();
1037
+ sh.visible?.destroy();
1038
+ }
1039
+ }
1040
+ //# sourceMappingURL=meshlets.js.map