@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
package/dist/scene.js ADDED
@@ -0,0 +1,2005 @@
1
+ import { YaliError } from '@yali-engine/core';
2
+ import { isBlendClass, isSkinnedClass, MAX_LODS, SKIN_STREAM_BYTES } from "./meshes.js";
3
+ import { sceneCull, sceneDraw, sceneShadow } from "./shaders.gen.js";
4
+ import { CASCADE_UNIFORM_BYTES, cascadeCullPlanes, fitCascades, MAX_CASCADES, SHADOW_FLOATS, SunShadows } from "./shadows.js";
5
+ import { CULL_CASCADES, CulledDraws } from "./culled-draws.js";
6
+ import { StagingRing } from "./staging-ring.js";
7
+ import { CURVES, toneMapColor } from "./tonemap.js";
8
+ /**
9
+ * A draw class's bind group entries: the skinned classes read the palette (binding 17, ADR-0088);
10
+ * a rigid pipeline's auto layout has no binding 17, so its group leaves it out.
11
+ */
12
+ function entriesFor(entries, drawClass) {
13
+ return isSkinnedClass(drawClass) ? entries : entries.filter((e) => e.binding !== 17);
14
+ }
15
+ /** Texels per palette texture row (`shaders/scene/skin.wesl` `PALETTE_WIDTH`). */
16
+ export const PALETTE_WIDTH = 1024;
17
+ /**
18
+ * The order draw classes draw in (ADR-0081, ADR-0088): everything opaque and masked, rigid then
19
+ * skinned, before anything blended.
20
+ */
21
+ export const CLASS_ORDER = [0, 1, 2, 3, 6, 7, 8, 9, 4, 5, 10, 11];
22
+ /** Each shading level's fragment entry (ADR-0072), cheapest last. */
23
+ const FRAGMENT_ENTRIES = ['fs', 'fs_lambert', 'fs_flat'];
24
+ /** Bytes per instance: a 32-byte transform plus a 16-byte static record (ADR-0055). */
25
+ export const INSTANCE_BYTES = 48;
26
+ /** The `Camera` uniform (shaders/core/camera.wesl): the camera's 176 bytes, then the instance origin (ADR-0075). */
27
+ export const CAMERA_BYTES = 192;
28
+ /** Bytes per CPU-culled record (ADR-0059): position, scale, rotation, colour, material (no mesh: the draw knows it). */
29
+ export const CULLED_BYTES = 40;
30
+ /** Bytes of the per-frame part of an instance (the transform). */
31
+ const TRANSFORM_BYTES = 32;
32
+ /** Bytes of the part uploaded only when it changes (colour, mesh, material). */
33
+ const STATIC_BYTES = 16;
34
+ const VERTEX_BYTES = 32;
35
+ /** The skin stream's vertex layout (ADR-0088): 4 × u16 joints at location 6, 4 × unorm16 weights at 7. */
36
+ /** A pool vertex's position only (the motion pass, ADR-0098). */
37
+ export const POSITION_LAYOUT = { arrayStride: VERTEX_BYTES, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] };
38
+ export const SKIN_LAYOUT = {
39
+ arrayStride: SKIN_STREAM_BYTES,
40
+ attributes: [
41
+ { shaderLocation: 6, offset: 0, format: 'uint16x4' },
42
+ { shaderLocation: 7, offset: 8, format: 'unorm16x4' },
43
+ ],
44
+ };
45
+ const ARGS_WORDS = 5;
46
+ const ARGS_BYTES = ARGS_WORDS * 4;
47
+ const MESH_INFO_WORDS = 16;
48
+ /** The scene pass's clear colour (linear). */
49
+ const CLEAR = [0.035, 0.035, 0.045];
50
+ /**
51
+ * The GPU-driven many-draw renderer (DESIGN.md §11, M2 increment 2). The core's extract
52
+ * writes every `render.Mesh` instance into linear memory, grouped by mesh, with the count
53
+ * per mesh; `upload` copies them to the GPU straight from that memory. A compute pass
54
+ * culls each instance against its mesh's bounding sphere, picks its LOD from the projected
55
+ * error, and appends it to that (mesh, LOD) slot; the draw is one `drawIndexedIndirect`
56
+ * per slot over one geometry pool.
57
+ *
58
+ * A slot's instances live at a start the CPU computes from the counts (the layout), so a
59
+ * draw finds them through `firstInstance` (the storage path) or through a vertex-buffer
60
+ * offset with `firstInstance` 0 (the vertex path, for compatibility mode; ADR-0045). The
61
+ * layout, the indirect-argument template and the recorded render bundle change only when
62
+ * the counts do.
63
+ */
64
+ export class SceneRenderer {
65
+ device;
66
+ /** Instance-buffer capacity (grows on demand). */
67
+ capacity;
68
+ /** Instances uploaded this frame. */
69
+ count = 0;
70
+ /** Instances drawn, read back a few frames late (NaN until the first readback). */
71
+ visible = Number.NaN;
72
+ /** Replay a recorded render bundle instead of encoding the draws each frame. Set before `init`. */
73
+ useBundles = false;
74
+ /** Upload instances through a mapped staging ring (default true; see `StagingRing`). Set before `init`. */
75
+ useStaging = true;
76
+ /** How the vertex stage gets instance data (ADR-0045; see the class comment). Set before `init`. */
77
+ path = 'storage';
78
+ /**
79
+ * Who culls (ADR-0057): `gpu`, the compute pass feeding indirect draws; `cpu`, the extract
80
+ * (`InstanceExtractor.runCulled`) with ordinary draws from its per-slot counts, for
81
+ * devices where indirect draws cost more than they save (compatibility mode). `cpu` uses
82
+ * the vertex path. Set before `init`.
83
+ */
84
+ cull = 'gpu';
85
+ /** The largest projected LOD error allowed, in pixels, before `lodBias`. */
86
+ errorPx = 1;
87
+ /**
88
+ * The governor's knob (`render.lodBias`, ADR-0050): each unit doubles the allowed LOD
89
+ * error, and from 1 up also drops instances under `0.25 × 2^lodBias` pixels of radius
90
+ * (0.5, 1, 2 px), so a step saves work even in a scene without LODs.
91
+ */
92
+ lodBias = 0;
93
+ /** Instances whose projected radius is under this many pixels are not drawn (0 keeps them all; `lodBias` may raise it). */
94
+ minRadiusPx = 0;
95
+ // The CPU-cull path: interleaved records in a vertex buffer, and per draw: index count,
96
+ // first index, base vertex, first instance, instance count.
97
+ #cpuBuf = null;
98
+ #cpuDraws = new Uint32Array(0);
99
+ #cpuDrawCount = 0;
100
+ #cpuByLod = new Uint32Array(8);
101
+ /** The CPU cull's draws, per slot and per cascade run (ADR-0073). */
102
+ #culledDraws = new CulledDraws();
103
+ /** The cascade fit the CPU cull's caster planes come from (ADR-0073). */
104
+ #cullFit = new Float32Array(SHADOW_FLOATS);
105
+ /** Instances each shadow cascade drew last frame on the CPU cull (ADR-0073; a diagnostic). */
106
+ get shadowCasters() {
107
+ return this.#culledDraws.casters.slice(0, this.#cpuCascades);
108
+ }
109
+ /** Cascades the last CPU cull partitioned by (0: every cascade draws whole slots). */
110
+ #cpuCascades = 0;
111
+ /** What the last layout and draw looked like. */
112
+ stats = { slots: 0, draws: 0, layouts: 0, meshes: 0, shaderBuilds: 0 };
113
+ #format;
114
+ #store;
115
+ #materials;
116
+ #textures;
117
+ #texturesArrays = -1;
118
+ #environment = null;
119
+ #lights = null;
120
+ #shadows = null;
121
+ #shadowPipeline = null;
122
+ #shadowGroups = [];
123
+ /** The cascade pipeline without back-face culling, for double-sided casters (built when one exists). */
124
+ #shadowPipelineDouble = null;
125
+ #shadowGroupsDouble = [];
126
+ /** The camera uniform as uploaded: with shadows, its planes swept towards the light (the cull's casters). */
127
+ #cameraData = new Float32Array(CAMERA_BYTES / 4);
128
+ #envVersion = -1;
129
+ #lightingBuf;
130
+ #lightingData = new Float32Array(48);
131
+ /** The light; changes apply the next frame. */
132
+ lighting = { sunColor: [1, 1, 1], sunIntensity: 3, ambient: 1 };
133
+ /**
134
+ * When set (T0, ADR-0071), the draw tone-maps inline with these knobs and writes the display
135
+ * format directly: no HDR target, no tone-mapping pass. Set before `init`.
136
+ */
137
+ inlineTone = null;
138
+ /** The pass's clear colour: linear, or tone-mapped when `inlineTone` is set. */
139
+ #clear = { r: CLEAR[0], g: CLEAR[1], b: CLEAR[2], a: 1 };
140
+ #lightingKey = '';
141
+ #cullPipeline;
142
+ #drawPipeline;
143
+ /** One draw pipeline per shading level, and its bind group (the layouts match: `bindings_anchor`). */
144
+ #levelPipelines = [];
145
+ #levelGroups = [];
146
+ #activeLevel = 0;
147
+ /**
148
+ * Draw classes 1–5 (ADR-0081: mask, blend, double-sided): per class, one pipeline per shading
149
+ * level, built the first time a mesh of that class exists (a scene without them compiles
150
+ * nothing more), and their bind groups. Class 0 is `#levelPipelines` / `#levelGroups`.
151
+ */
152
+ #classPipelines = new Array(CLASS_ORDER.length).fill(null);
153
+ #classGroups = Array.from({ length: CLASS_ORDER.length }, () => []);
154
+ /** The skinned pool (ADR-0088): vertices in the rigid layout, and the skin stream beside them. */
155
+ #skinnedVertexBuf = null;
156
+ #skinBuf = null;
157
+ #skinnedBlocks = 0;
158
+ #skinBlocks = 0;
159
+ /** The palette: `PALETTE_WIDTH` × rows of rgba32float, three texels per joint (`skin.wesl`). */
160
+ #palette = null;
161
+ /** Skinned casters' shadow pipelines (single-, double-sided) and their groups (they bind the palette). */
162
+ #shadowSkinned = null;
163
+ #shadowSkinnedDouble = null;
164
+ #shadowSkinnedGroups = [];
165
+ #shadowSkinnedDoubleGroups = [];
166
+ /** Where the skinned draws read the palette each frame (`@yali-engine/animation` sets it). */
167
+ paletteSource = null;
168
+ #classBuilding = new Map();
169
+ /** The last draw bind-group entries, for the groups of class pipelines built later. */
170
+ #groupEntries = null;
171
+ /** Each slot's draw class (GPU cull path). */
172
+ #slotClass = new Uint8Array(0);
173
+ /** Shading levels compiled from the draw shader itself (`shaderVariant`'s levels follow). */
174
+ #baseLevels = 1;
175
+ /**
176
+ * Benchmarks only: a transform of the linked draw shader, compiled beside it as extra
177
+ * shading levels (`#buildDraws`), so a page can alternate the two and compare them under
178
+ * the same conditions (a phone's GPU clocks move between separate runs). Set before `init`.
179
+ * A variant must keep every binding statically used (or keep the level's `bindings_anchor`
180
+ * call): its pipelines share the bind-group entries, and an unused binding drops out of
181
+ * the auto layout, so the group fails validation (`RENDER_SETUP`).
182
+ */
183
+ shaderVariant = null;
184
+ /** Shading levels to compile (1, or 3 for the governor's knob). Set before `init`. */
185
+ shadingLevels = 1;
186
+ shadingLevel = 0;
187
+ #camera;
188
+ #params;
189
+ #paramsData = new ArrayBuffer(16);
190
+ #paramsU32 = new Uint32Array(this.#paramsData);
191
+ #paramsF32 = new Float32Array(this.#paramsData);
192
+ #lodScale = 1;
193
+ #materialBuf;
194
+ /** The materials' glTF extensions (`material_ext`, binding 18, ADR-0093). */
195
+ #materialExtBuf;
196
+ #materialVersion = -1;
197
+ #vertexBuf = null;
198
+ #indexBuf = null;
199
+ #vertexBlocks = 0;
200
+ #indexBlocks = 0;
201
+ #geometryVersion = -1;
202
+ #instances = null;
203
+ #staging = null;
204
+ // What the GPU's static region holds: records [0, #staticCount) of extract buffer
205
+ // generation #staticEpoch; -1 after the GPU buffer is (re)created.
206
+ #staticEpoch = -1;
207
+ #staticCount = 0;
208
+ /** Static-region uploads so far (they should be rare: ADR-0055). */
209
+ staticUploads = 0;
210
+ // What the GPU's transform region holds: records [0, #transformCount) of extract buffer
211
+ // generation #transformEpoch, current as of the last frame; -1 after the GPU buffer is
212
+ // (re)created (ADR-0075).
213
+ #transformEpoch = -1;
214
+ #transformCount = 0;
215
+ /** Transform bytes uploaded last frame (all of them, or only the dirty ranges: ADR-0075). */
216
+ transformBytes = 0;
217
+ // The layout: rebuilt when the per-mesh counts (or the meshes' LODs) change.
218
+ #counts = new Uint32Array(0);
219
+ #layoutMeshes = -1;
220
+ #layoutVersion = -1;
221
+ #meshInfo = null;
222
+ #argsTemplate = null;
223
+ #args = null;
224
+ #ranges = null;
225
+ #readback = null;
226
+ #readbackBusy = false;
227
+ #frame = 0;
228
+ /** Per draw: slot, first instance of its range, instance capacity of the range. */
229
+ #drawList = new Uint32Array(0);
230
+ /** The LOD each slot draws. */
231
+ #slotLod = new Uint8Array(0);
232
+ /** Instances drawn per slot, from the last readback. */
233
+ #drawnBySlot = new Uint32Array(0);
234
+ #cullGroup = null;
235
+ #drawGroup = null;
236
+ /** Recorded draws per slot set (0: the plain cull's or the shadows', 1 and 2: occlusion's passes). */
237
+ #bundles = null;
238
+ /**
239
+ * Two-pass occlusion culling (ADR-0095), `occlusion()` from `@yali-engine/render/occlusion`: set before
240
+ * `init`; it runs on the GPU cull's storage path only (`occlusionActive`).
241
+ */
242
+ occlusion = null;
243
+ #occl = null;
244
+ #recheckGroup = null;
245
+ #occlVersion = -1;
246
+ /** Draw slots and visible entries per set, under occlusion. */
247
+ #slots = 0;
248
+ #region = 0;
249
+ #dirtyGroups = true;
250
+ #destroyed = false;
251
+ constructor(device, format, store, materials, textures, capacity) {
252
+ this.device = device;
253
+ this.#format = format;
254
+ this.#store = store;
255
+ this.#materials = materials;
256
+ this.#textures = textures;
257
+ this.#lightingBuf = device.createBuffer({ label: 'yali:lighting', size: 192, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
258
+ this.#noSceneColor = device.createTexture({ label: 'yali:scene-color:none', size: [1, 1], format: 'rgba16float', usage: GPUTextureUsage.TEXTURE_BINDING });
259
+ this.capacity = capacity;
260
+ this.#camera = device.createBuffer({ label: 'yali:camera', size: CAMERA_BYTES, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
261
+ this.#params = device.createBuffer({ label: 'yali:cull:params', size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
262
+ this.#materialBuf = device.createBuffer({ label: 'yali:materials', size: materials.data.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
263
+ this.#materialExtBuf = device.createBuffer({ label: 'yali:materials:ext', size: materials.ext.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
264
+ }
265
+ /** Compiles both pipelines asynchronously through `cache` (DESIGN.md §13: never on the first frame). */
266
+ async init(cache) {
267
+ if (this.cull === 'cpu')
268
+ this.path = 'vertex';
269
+ const vertex = this.path === 'vertex';
270
+ this.#cache = cache;
271
+ this.#palette = this.#paletteTexture(1);
272
+ [this.#cullPipeline, this.#levelPipelines, this.#shadowPipeline] = await Promise.all([
273
+ cache.compute({ label: 'yali:scene:cull', compute: { shader: sceneCull, entryPoint: vertex ? 'cull_compact' : 'cull' } }),
274
+ this.#buildDraws(),
275
+ this.#shadows?.enabled ? this.#buildShadow() : Promise.resolve(null),
276
+ ]);
277
+ this.#drawPipeline = this.#levelPipelines[0];
278
+ if (this.occlusion && !vertex && this.cull === 'gpu') {
279
+ const occl = this.occlusion(this.device, this.capacity);
280
+ await occl.init(cache);
281
+ this.#occl = occl;
282
+ }
283
+ this.#allocateInstances(this.capacity);
284
+ if (this.cull === 'cpu')
285
+ this.#cpuDrawGroup();
286
+ }
287
+ /**
288
+ * The draw shader with the app's node materials in its material block (ADR-0064): the
289
+ * linked `sceneDraw` as is when there are none.
290
+ */
291
+ #drawShader() {
292
+ this.#shadersVersion = this.#materials.shadersVersion;
293
+ const block = this.#materials.shaderBlock();
294
+ if (!block)
295
+ return sceneDraw;
296
+ const begin = sceneDraw.wgsl.indexOf('// @yali:materials begin');
297
+ const end = sceneDraw.wgsl.indexOf('// @yali:materials end');
298
+ if (begin < 0 || end < begin)
299
+ throw new YaliError('SHADER_MARKERS', 'The draw shader lost its @yali:materials markers.', 'Restore them in shaders/scene/draw.wesl.');
300
+ const wgsl = `${sceneDraw.wgsl.slice(0, begin)}${block}\n${sceneDraw.wgsl.slice(end)}`;
301
+ return { hash: `${sceneDraw.hash}-m${fnv1a(block)}`, wgsl };
302
+ }
303
+ /**
304
+ * Every shading level's draw pipeline (all compiled up front: never on a frame). With
305
+ * `shaderVariant`, the same levels again from the transformed shader, after them: level
306
+ * `shadingLevels + k` is level k's variant, for A/B measurements in one page.
307
+ */
308
+ #buildDraws() {
309
+ const shader = this.#drawShader();
310
+ const entries = FRAGMENT_ENTRIES.slice(0, Math.max(1, Math.min(FRAGMENT_ENTRIES.length, this.shadingLevels)));
311
+ this.#baseLevels = entries.length;
312
+ const builds = entries.map((fs) => this.#buildDraw(shader, fs));
313
+ if (this.shaderVariant) {
314
+ const wgsl = this.shaderVariant(shader.wgsl);
315
+ const variant = { hash: `${shader.hash}-v${fnv1a(wgsl)}`, wgsl };
316
+ builds.push(...entries.map((fs) => this.#buildDraw(variant, fs)));
317
+ }
318
+ return Promise.all(builds);
319
+ }
320
+ #buildDraw(shader, fsEntry, drawClass = 0) {
321
+ const vertex = this.path === 'vertex';
322
+ const blend = isBlendClass(drawClass);
323
+ const skinned = isSkinnedClass(drawClass);
324
+ const alpha = (drawClass % 6) >> 1;
325
+ const entry = this.cull === 'cpu' ? 'vs_culled' : vertex ? 'vs_attr' : 'vs';
326
+ return this.#cache.render({
327
+ label: skinned ? 'yali:scene:draw:skinned' : 'yali:scene:draw',
328
+ vertex: {
329
+ shader,
330
+ entryPoint: skinned ? `${entry}_skinned` : entry,
331
+ buffers: [
332
+ {
333
+ arrayStride: VERTEX_BYTES,
334
+ attributes: [
335
+ { shaderLocation: 0, offset: 0, format: 'float32x3' },
336
+ { shaderLocation: 1, offset: 12, format: 'float32x3' },
337
+ { shaderLocation: 5, offset: 24, format: 'float32x2' },
338
+ ],
339
+ },
340
+ ...(vertex
341
+ ? [
342
+ {
343
+ arrayStride: this.cull === 'cpu' ? CULLED_BYTES : INSTANCE_BYTES,
344
+ stepMode: 'instance',
345
+ attributes: [
346
+ { shaderLocation: 2, offset: 0, format: 'float32x4' },
347
+ { shaderLocation: 3, offset: 16, format: 'float32x4' },
348
+ { shaderLocation: 4, offset: 32, format: this.cull === 'cpu' ? 'uint32x2' : 'uint32x4' },
349
+ ],
350
+ },
351
+ ]
352
+ : []),
353
+ // The skin stream (ADR-0088), after the instance slot: 4 × u16 joints, 4 × unorm16 weights.
354
+ ...(skinned ? [SKIN_LAYOUT] : []),
355
+ ],
356
+ },
357
+ fragment: {
358
+ shader,
359
+ entryPoint: alpha === 0 ? fsEntry : `${fsEntry}_${alpha === 1 ? 'mask' : 'blend'}`,
360
+ targets: [blend ? { format: this.#format, blend: { color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha' }, alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' } } } : { format: this.#format }],
361
+ },
362
+ primitive: { topology: 'triangle-list', cullMode: drawClass & 1 ? 'none' : 'back', frontFace: 'ccw' },
363
+ // Blended surfaces test depth but do not write it (drawn after everything opaque).
364
+ depthStencil: { format: 'depth32float', depthWriteEnabled: !blend, depthCompare: 'greater' },
365
+ });
366
+ }
367
+ /** Starts building the pipelines of every draw class in `classes` not built yet (never on a frame). */
368
+ #ensureClasses(classes) {
369
+ if (!this.#cache)
370
+ return;
371
+ for (const c of classes) {
372
+ if (c === 0 || this.#classPipelines[c] || this.#classBuilding.has(c))
373
+ continue;
374
+ const shader = this.#drawShader();
375
+ const entries = FRAGMENT_ENTRIES.slice(0, this.#baseLevels);
376
+ const build = Promise.all(entries.map((fs) => this.#buildDraw(shader, fs, c))).then((ps) => {
377
+ this.#classBuilding.delete(c);
378
+ if (this.#destroyed)
379
+ return;
380
+ this.#classPipelines[c] = ps;
381
+ if (this.#groupEntries)
382
+ this.#classGroups[c] = ps.map((p, i) => this.device.createBindGroup({ label: `yali:scene:draw:c${c}:${i}`, layout: p.getBindGroupLayout(0), entries: entriesFor(this.#groupEntries, c) }));
383
+ this.#bundles = null;
384
+ }, (err) => {
385
+ this.#classBuilding.delete(c);
386
+ this.shaderError = err instanceof Error ? err.message : String(err);
387
+ console.error(`[yali render] draw class ${c} failed to compile:`, err);
388
+ });
389
+ this.#classBuilding.set(c, build);
390
+ }
391
+ if (this.#shadows?.enabled && !this.#shadowPipelineDouble && [...classes].some((c) => c & 1 && !isBlendClass(c) && !isSkinnedClass(c))) {
392
+ this.#buildShadow(true).then((p) => {
393
+ if (this.#destroyed)
394
+ return;
395
+ this.#shadowPipelineDouble = p;
396
+ this.#buildShadowGroups();
397
+ });
398
+ }
399
+ // Skinned casters (ADR-0088): their own pipelines, single- and double-sided, when one exists.
400
+ for (const two of [false, true]) {
401
+ const have = two ? this.#shadowSkinnedDouble : this.#shadowSkinned;
402
+ if (!this.#shadows?.enabled || have || this.#shadowBuilding.has(two) || ![...classes].some((c) => isSkinnedClass(c) && !isBlendClass(c) && (c & 1) === (two ? 1 : 0)))
403
+ continue;
404
+ this.#shadowBuilding.add(two);
405
+ this.#buildShadow(two, true).then((p) => {
406
+ this.#shadowBuilding.delete(two);
407
+ if (this.#destroyed)
408
+ return;
409
+ if (two)
410
+ this.#shadowSkinnedDouble = p;
411
+ else
412
+ this.#shadowSkinned = p;
413
+ this.#buildShadowGroups();
414
+ });
415
+ }
416
+ }
417
+ #shadowBuilding = new Set();
418
+ /**
419
+ * Resolves when every draw class the stored meshes need is compiled (they build in the
420
+ * background, and a class draws from the frame after its pipelines exist). Await it for a
421
+ * first frame that must show every mesh (goldens, captures).
422
+ */
423
+ async drawClassesReady() {
424
+ this.#ensureClasses(new Set(this.#store.entries.map((e) => e.drawClass)));
425
+ if (this.#maskMotionFormat)
426
+ this.prepareMaskMotion(this.#maskMotionFormat);
427
+ await Promise.all([...this.#classBuilding.values(), ...this.#maskMotionBuilding.values()]);
428
+ await this.clusters?.ready();
429
+ }
430
+ /** Class `c`'s pipeline and bind group at the active shading level (null until built). */
431
+ #classDraw(c) {
432
+ if (c === 0)
433
+ return this.#drawPipeline && this.#drawGroup ? [this.#drawPipeline, this.#drawGroup] : null;
434
+ const ps = this.#classPipelines[c];
435
+ const gs = this.#classGroups[c];
436
+ if (!ps || gs.length !== ps.length)
437
+ return null;
438
+ const level = this.#activeLevel % this.#baseLevels;
439
+ return [ps[level], gs[level]];
440
+ }
441
+ /** The depth-only cascade pipeline (ADR-0068): the draw's vertex buffers, transform only (and, skinned, the skin stream). */
442
+ #buildShadow(doubleSided = false, skinned = false) {
443
+ const vertex = this.path === 'vertex';
444
+ const cpu = this.cull === 'cpu';
445
+ const entry = skinned ? (cpu ? 'vs_shadow_culled_skinned' : vertex ? 'vs_shadow_attr_skinned' : 'vs_shadow_skinned') : vertex ? 'vs_shadow_attr' : 'vs_shadow';
446
+ return this.#cache.render({
447
+ label: `yali:scene:shadow${skinned ? ':skinned' : ''}${doubleSided ? ':double' : ''}`,
448
+ vertex: {
449
+ shader: sceneShadow,
450
+ entryPoint: entry,
451
+ buffers: [
452
+ { arrayStride: VERTEX_BYTES, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] },
453
+ ...(vertex
454
+ ? [
455
+ {
456
+ arrayStride: cpu ? CULLED_BYTES : INSTANCE_BYTES,
457
+ stepMode: 'instance',
458
+ attributes: [
459
+ { shaderLocation: 2, offset: 0, format: 'float32x4' },
460
+ { shaderLocation: 3, offset: 16, format: 'float32x4' },
461
+ // The skinned casters read the record's ids for their skin row.
462
+ ...(skinned ? [{ shaderLocation: 4, offset: 32, format: (cpu ? 'uint32x2' : 'uint32x4') }] : []),
463
+ ],
464
+ },
465
+ ]
466
+ : []),
467
+ ...(skinned ? [SKIN_LAYOUT] : []),
468
+ ],
469
+ },
470
+ primitive: { topology: 'triangle-list', cullMode: doubleSided ? 'none' : 'back', frontFace: 'ccw' },
471
+ depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'greater' },
472
+ });
473
+ }
474
+ /** One bind group per cascade: its matrix at its offset, and (storage path) the instances. */
475
+ #buildShadowGroups() {
476
+ const sh = this.#shadows;
477
+ if (!sh?.enabled || !this.#shadowPipeline)
478
+ return;
479
+ const storage = this.path === 'storage';
480
+ if (storage && (!this.#instances || !this.#ranges))
481
+ return;
482
+ const groups = (pipeline, label) => Array.from({ length: sh.cascades }, (_, c) => this.device.createBindGroup({
483
+ label: `${label}:${c}`,
484
+ layout: pipeline.getBindGroupLayout(0),
485
+ entries: [
486
+ { binding: 0, resource: { buffer: sh.cascadeBuffer, offset: SunShadows.offset(c), size: CASCADE_UNIFORM_BYTES } },
487
+ ...(storage ? [{ binding: 1, resource: { buffer: this.#instances } }, { binding: 2, resource: { buffer: this.#ranges } }] : []),
488
+ ],
489
+ }));
490
+ this.#shadowGroups = groups(this.#shadowPipeline, 'yali:scene:shadow');
491
+ this.#shadowGroupsDouble = this.#shadowPipelineDouble ? groups(this.#shadowPipelineDouble, 'yali:scene:shadow:double') : [];
492
+ // The skinned casters' groups add the palette (binding 3).
493
+ const skinned = (pipeline, label) => Array.from({ length: sh.cascades }, (_, c) => this.device.createBindGroup({
494
+ label: `${label}:${c}`,
495
+ layout: pipeline.getBindGroupLayout(0),
496
+ entries: [
497
+ { binding: 0, resource: { buffer: sh.cascadeBuffer, offset: SunShadows.offset(c), size: CASCADE_UNIFORM_BYTES } },
498
+ ...(storage ? [{ binding: 1, resource: { buffer: this.#instances } }, { binding: 2, resource: { buffer: this.#ranges } }] : []),
499
+ { binding: 3, resource: this.#palette.createView() },
500
+ ],
501
+ }));
502
+ this.#shadowSkinnedGroups = this.#shadowSkinned && this.#palette ? skinned(this.#shadowSkinned, 'yali:scene:shadow:skinned') : [];
503
+ this.#shadowSkinnedDoubleGroups = this.#shadowSkinnedDouble && this.#palette ? skinned(this.#shadowSkinnedDouble, 'yali:scene:shadow:skinned:double') : [];
504
+ }
505
+ /** A palette texture `rows` texture rows tall (ADR-0088). */
506
+ #paletteTexture(rows) {
507
+ return this.device.createTexture({ label: 'yali:palette', size: [PALETTE_WIDTH, rows], format: 'rgba32float', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC });
508
+ }
509
+ /**
510
+ * Uploads this frame's palette (ADR-0088): full texture rows, then the partial last one. A
511
+ * palette taller than the texture replaces it (doubling), and the bind groups are rebuilt.
512
+ */
513
+ #syncPalette() {
514
+ const view = this.paletteSource?.();
515
+ if (!view || view.rows === 0 || !this.#palette)
516
+ return;
517
+ const texels = view.rows * 3;
518
+ const rows = Math.ceil(texels / PALETTE_WIDTH);
519
+ if (rows > this.#palette.height) {
520
+ this.#palette.destroy();
521
+ this.#palette = this.#paletteTexture(nextPow2(rows));
522
+ if (this.cull === 'cpu')
523
+ this.#cpuDrawGroup();
524
+ else
525
+ this.#dirtyGroups = true;
526
+ }
527
+ const q = this.device.queue;
528
+ const src = view.f32;
529
+ const full = Math.floor(texels / PALETTE_WIDTH);
530
+ const byteAt = src.byteOffset + view.at * 4;
531
+ if (full > 0)
532
+ q.writeTexture({ texture: this.#palette }, src.buffer, { offset: byteAt, bytesPerRow: PALETTE_WIDTH * 16, rowsPerImage: full }, [PALETTE_WIDTH, full]);
533
+ const rest = texels - full * PALETTE_WIDTH;
534
+ if (rest > 0)
535
+ q.writeTexture({ texture: this.#palette, origin: [0, full] }, src.buffer, { offset: byteAt + full * PALETTE_WIDTH * 16, bytesPerRow: PALETTE_WIDTH * 16 }, [rest, 1]);
536
+ }
537
+ /**
538
+ * When node-material shaders were registered, compiles the new draw pipeline in the
539
+ * background and swaps it in when ready (DESIGN.md §13: never a compile on the frame);
540
+ * until then the old one draws (new shader ids fall back to the standard material).
541
+ */
542
+ #syncShaders() {
543
+ if (this.#rebuilding || this.#shadersVersion === this.#materials.shadersVersion || !this.#cache)
544
+ return;
545
+ this.#rebuilding = this.#buildDraws().then((ps) => {
546
+ this.#rebuilding = null;
547
+ if (this.#destroyed)
548
+ return;
549
+ this.#levelPipelines = ps;
550
+ this.#drawPipeline = ps[Math.min(this.#activeLevel, ps.length - 1)];
551
+ this.stats.shaderBuilds++;
552
+ if (this.cull === 'cpu')
553
+ this.#cpuDrawGroup();
554
+ else
555
+ this.#dirtyGroups = true;
556
+ }, (err) => {
557
+ this.#rebuilding = null;
558
+ this.shaderError = err instanceof Error ? err.message : String(err);
559
+ console.error('[yali render] the draw shader with the node materials failed to compile:', err);
560
+ });
561
+ }
562
+ /**
563
+ * Resolves when the draw pipeline includes every node-material shader registered so far
564
+ * (it compiles in the background). Await it after registering shaders to keep the first
565
+ * frames from drawing those materials as standard ones; rejects when the build failed.
566
+ */
567
+ async shadersReady() {
568
+ for (;;) {
569
+ this.#syncShaders();
570
+ if (this.#rebuilding)
571
+ await this.#rebuilding;
572
+ if (this.shaderError)
573
+ throw new YaliError('SHADER_BUILD', `The draw shader with the node materials failed to compile: ${this.shaderError}`, 'See the WGSL error; recompile the materials (the materials build step).');
574
+ if (this.#shadersVersion === this.#materials.shadersVersion)
575
+ return;
576
+ }
577
+ }
578
+ #cache = null;
579
+ #shadersVersion = -1;
580
+ #rebuilding = null;
581
+ /** The last failed node-material shader build's message ('' when none). */
582
+ shaderError = '';
583
+ /** The sun's shadows (set before `init`; the render plugin does). */
584
+ set shadows(s) {
585
+ this.#shadows = s;
586
+ }
587
+ /** The clustered point and spot lights (set before `init`; the render plugin does). */
588
+ set lights(l) {
589
+ this.#lights = l;
590
+ }
591
+ /** The environment the draw lights with (set before `init`; the render plugin does). */
592
+ set environment(env) {
593
+ this.#environment = env;
594
+ this.#envVersion = -1;
595
+ }
596
+ /** The texture and lighting bindings every draw path shares (ADR-0060, M2 increment 5). */
597
+ #textureEntries() {
598
+ this.#texturesArrays = this.#textures.arraysVersion;
599
+ if (!this.#environment)
600
+ throw new YaliError('NO_ENVIRONMENT', 'The scene renderer has no environment.', 'Set scene.environment before init (the render plugin does).');
601
+ if (!this.#lights)
602
+ throw new YaliError('NO_LIGHTS', 'The scene renderer has no clustered lights.', 'Set scene.lights before init (the render plugin does).');
603
+ if (!this.#shadows)
604
+ throw new YaliError('NO_SHADOWS', 'The scene renderer has no shadow maps.', 'Set scene.shadows before init (the render plugin does).');
605
+ return [
606
+ { binding: 4, resource: this.#textures.texture('srgb').createView({ dimension: '2d-array' }) },
607
+ { binding: 5, resource: this.#textures.sampler },
608
+ { binding: 6, resource: this.#textures.texture('linear').createView({ dimension: '2d-array' }) },
609
+ { binding: 7, resource: { buffer: this.#lightingBuf } },
610
+ { binding: 8, resource: this.#environment.prefiltered.createView({ dimension: 'cube' }) },
611
+ { binding: 9, resource: this.#textures.rgba8Texture('srgb').createView({ dimension: '2d-array' }) },
612
+ { binding: 10, resource: this.#textures.rgba8Texture('linear').createView({ dimension: '2d-array' }) },
613
+ { binding: 11, resource: { buffer: this.#lights.lightsBuffer } },
614
+ { binding: 12, resource: { buffer: this.#lights.paramsBuffer } },
615
+ { binding: 13, resource: this.#lights.bins.createView() },
616
+ { binding: 14, resource: { buffer: this.#shadows.paramsBuffer } },
617
+ { binding: 15, resource: this.#shadows.view },
618
+ { binding: 16, resource: this.#shadows.sampler },
619
+ { binding: 17, resource: (this.#palette ??= this.#paletteTexture(1)).createView() },
620
+ ];
621
+ }
622
+ /** Uploads the lighting uniform when the light or the environment changed. */
623
+ #syncLighting() {
624
+ const env = this.#environment;
625
+ if (!env)
626
+ return;
627
+ const l = this.lighting;
628
+ const c = l.sunColor;
629
+ const t = this.inlineTone;
630
+ const exposure = t ? t.exposure : 0;
631
+ const curve = t ? CURVES[t.curve] : -1;
632
+ if (c[0] === this.#lit[0] && c[1] === this.#lit[1] && c[2] === this.#lit[2] && l.sunIntensity === this.#lit[3] && l.ambient === this.#lit[4] && exposure === this.#lit[5] && curve === this.#lit[6] && env.version === this.#envVersion)
633
+ return;
634
+ this.#lit.set([c[0], c[1], c[2], l.sunIntensity, l.ambient, exposure, curve]);
635
+ if (t) {
636
+ const [r, g, b] = toneMapColor(CLEAR, t.exposure, t.curve);
637
+ Object.assign(this.#clear, { r, g, b });
638
+ }
639
+ this.#envVersion = env.version;
640
+ const d = this.#lightingData;
641
+ d[0] = c[0] * l.sunIntensity;
642
+ d[1] = c[1] * l.sunIntensity;
643
+ d[2] = c[2] * l.sunIntensity;
644
+ d[3] = l.ambient;
645
+ d[4] = env.mips;
646
+ d[5] = t ? 1 : 0;
647
+ d[6] = exposure;
648
+ d[7] = Math.max(0, curve);
649
+ d.set(env.sh9, 8);
650
+ const screen = this.#splitBlend() && this.#sceneColor;
651
+ d[44] = screen ? 1 : 0;
652
+ d[45] = screen ? 1 / this.#targetW : 0;
653
+ d[46] = screen ? 1 / this.#targetH : 0;
654
+ // The copy's mip chain (ADR-0119): its levels once the downsample is built, else just the copy.
655
+ d[47] = screen && this.#mips ? this.#sceneColor.mipLevelCount : 1;
656
+ this.device.queue.writeBuffer(this.#lightingBuf, 0, d);
657
+ // The second transparent layer's (ADR-0125): `screen.x` 2 says which pass draws, the layer copy's levels.
658
+ if (screen && this.#lighting2 && this.#layerColor) {
659
+ d[44] = 2;
660
+ d[47] = this.#mips ? this.#layerColor.mipLevelCount : 1;
661
+ this.device.queue.writeBuffer(this.#lighting2, 0, d);
662
+ d[44] = 1;
663
+ d[47] = this.#mips ? this.#sceneColor.mipLevelCount : 1;
664
+ }
665
+ }
666
+ #lit = new Float64Array(7).fill(Number.NaN);
667
+ #cpuDrawGroup() {
668
+ this.#drawGroups([{ binding: 0, resource: { buffer: this.#camera } }, { binding: 3, resource: { buffer: this.#materialBuf } }, { binding: 18, resource: { buffer: this.#materialExtBuf } }, { binding: 19, resource: (this.#sceneColor ?? this.#noSceneColor).createView() }, ...this.#textureEntries()]);
669
+ this.#buildShadowGroups();
670
+ }
671
+ /** The draw's bind group for every shading level's pipeline (the same entries). */
672
+ #drawGroups(entries) {
673
+ this.#groupEntries = entries;
674
+ this.#groupsEpoch++;
675
+ this.#levelGroups = this.#levelPipelines.map((p, i) => this.device.createBindGroup({ label: `yali:scene:draw:${i}`, layout: p.getBindGroupLayout(0), entries: entriesFor(entries, 0) }));
676
+ for (let c = 1; c < CLASS_ORDER.length; c++) {
677
+ const ps = this.#classPipelines[c];
678
+ this.#classGroups[c] = ps ? ps.map((p, i) => this.device.createBindGroup({ label: `yali:scene:draw:c${c}:${i}`, layout: p.getBindGroupLayout(0), entries: entriesFor(entries, c) })) : [];
679
+ }
680
+ this.#drawGroup = this.#levelGroups[Math.min(this.#activeLevel, this.#levelGroups.length - 1)];
681
+ }
682
+ /** Swaps in the shading level's pipeline and bind group when the knob moved. */
683
+ #syncLevel() {
684
+ const level = Math.max(0, Math.min(this.#levelPipelines.length - 1, Math.round(this.shadingLevel)));
685
+ if (level === this.#activeLevel)
686
+ return;
687
+ this.#activeLevel = level;
688
+ this.#drawPipeline = this.#levelPipelines[level];
689
+ if (this.#levelGroups[level])
690
+ this.#drawGroup = this.#levelGroups[level];
691
+ this.#bundles = null;
692
+ }
693
+ /** Whether the sun casts this frame (shadows on, and not shading level 2). */
694
+ get #casting() {
695
+ return !!this.#shadows?.enabled && this.#activeLevel % this.#baseLevels < 2;
696
+ }
697
+ /**
698
+ * Writes the CPU cull's parameters (ADR-0057) into linear memory at word `at` of `f32`:
699
+ * the camera's six frustum planes (camera-relative), the LOD scale for a target `heightPx`
700
+ * tall, and the error and small-instance thresholds after `lodBias`: what the GPU cull
701
+ * reads from its uniforms.
702
+ */
703
+ writeCullParams(f32, at, camera, heightPx, widthPx = heightPx) {
704
+ this.#syncLevel();
705
+ if (this.#casting)
706
+ SunShadows.sweptPlanes(camera, f32, at);
707
+ else
708
+ for (let k = 0; k < 24; k++)
709
+ f32[at + k] = camera.uniform[16 + k];
710
+ // Casters per cascade (ADR-0073): fit now (the Submit stage's fit is the same), then the boxes.
711
+ const sh = this.#shadows;
712
+ const partition = this.#casting && sh && sh.cascades <= CULL_CASCADES ? sh.cascades : 0;
713
+ f32[at + 27] = partition;
714
+ if (partition > 0) {
715
+ // The fit only: the Submit stage's update fits again and uploads (no GPU write here).
716
+ fitCascades(sh, camera, widthPx / Math.max(1, heightPx), this.#cullFit);
717
+ cascadeCullPlanes(this.#cullFit, partition, f32, at + 28);
718
+ }
719
+ const scale = 2 ** this.lodBias;
720
+ f32[at + 24] = heightPx / (2 * Math.tan(camera.fovY / 2));
721
+ f32[at + 25] = this.errorPx * scale;
722
+ f32[at + 26] = Math.max(this.minRadiusPx, this.lodBias > 0 ? 0.25 * scale : 0);
723
+ }
724
+ #allocateInstances(capacity) {
725
+ this.#instances?.destroy();
726
+ this.#staging?.destroy();
727
+ this.capacity = capacity;
728
+ // Exactly capacity * 48 bytes: the shaders find the static region from its length.
729
+ this.#instances = this.device.createBuffer({ label: 'yali:instances', size: capacity * INSTANCE_BYTES, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC });
730
+ this.#staging = this.useStaging ? new StagingRing(this.device, capacity * TRANSFORM_BYTES) : null;
731
+ this.#staticEpoch = -1;
732
+ this.#transformEpoch = -1;
733
+ this.#dirtyGroups = true;
734
+ }
735
+ /** Grows the instance buffers to hold `count` instances (rare; reallocates). */
736
+ ensureCapacity(count) {
737
+ if (count <= this.capacity)
738
+ return;
739
+ this.#allocateInstances(Math.max(count, Math.ceil(this.capacity * 1.5)));
740
+ this.#occl?.ensureCapacity(this.capacity);
741
+ }
742
+ /** Whether two-pass occlusion culling runs (asked for, on the GPU cull's storage path). */
743
+ get occlusionActive() {
744
+ return !!this.#occl;
745
+ }
746
+ /** The staging ring's counters (null when staging is off). */
747
+ get staging() {
748
+ return this.#staging;
749
+ }
750
+ /** The camera uniform buffer. */
751
+ get cameraBuffer() {
752
+ return this.#camera;
753
+ }
754
+ /**
755
+ * Uploads the camera uniform; `heightPx` (the render target's height) sets the LOD scale.
756
+ * `origin` is the camera's offset from the anchor the instance records are relative to
757
+ * (ADR-0075; omit it for camera-relative records).
758
+ */
759
+ setCamera(camera, heightPx, origin) {
760
+ // The draw reads only viewProj and the light; the GPU cull reads the planes, which with
761
+ // shadows are swept towards the light so off-screen casters survive (ADR-0068).
762
+ this.#syncLevel();
763
+ const data = this.#cameraData;
764
+ data.set(camera.uniform);
765
+ // TAA's sub-pixel jitter (ADR-0092): a clip-space offset, so x += jx * w (and y).
766
+ const j = this.jitter;
767
+ if (j) {
768
+ for (let c = 0; c < 4; c++) {
769
+ data[c * 4] += j[0] * data[c * 4 + 3];
770
+ data[c * 4 + 1] += j[1] * data[c * 4 + 3];
771
+ }
772
+ }
773
+ for (let i = 0; i < 3; i++)
774
+ data[44 + i] = origin ? origin[i] : 0;
775
+ if (this.#casting)
776
+ SunShadows.sweptPlanes(camera, data, 16);
777
+ this.device.queue.writeBuffer(this.#camera, 0, data);
778
+ this.#occl?.update(camera, data, this.#casting, this.#slots, this.#region);
779
+ this.#lodScale = heightPx / (2 * Math.tan(camera.fovY / 2));
780
+ }
781
+ /**
782
+ * Uploads this frame's extracted instances straight from linear memory (`heap` is the
783
+ * whole-memory byte view, `u32` the same memory as words): the transforms every frame,
784
+ * the static records only when they changed or the GPU has not got them (ADR-0055). Lays
785
+ * out the draw slots from the per-mesh counts. Call `recordUpload` on the frame's encoder
786
+ * before the passes, and `afterSubmit` after `queue.submit`.
787
+ */
788
+ upload(heap, u32, x) {
789
+ this.#syncGeometry();
790
+ this.#syncPalette();
791
+ this.#syncMaterials();
792
+ this.#syncShaders();
793
+ this.#syncLighting();
794
+ if (this.#texturesArrays !== this.#textures.arraysVersion) {
795
+ if (this.cull === 'cpu')
796
+ this.#cpuDrawGroup();
797
+ else
798
+ this.#dirtyGroups = true;
799
+ }
800
+ if (this.cull === 'cpu')
801
+ return this.#uploadCulled(heap, u32, x);
802
+ this.count = Math.min(x.count, this.capacity);
803
+ const countsAt = x.countsAt >>> 2;
804
+ if (this.#layoutChanged(u32, countsAt, x.meshes))
805
+ this.#layout(u32, countsAt, x.meshes);
806
+ if (this.#dirtyGroups)
807
+ this.#bindGroups();
808
+ const q = this.device.queue;
809
+ const bytes = this.count * TRANSFORM_BYTES;
810
+ // The heap may be a SharedArrayBuffer view (threaded core): WebGPU accepts shared
811
+ // sources (GPUAllowSharedBufferSource); @webgpu/types' generics don't say so yet.
812
+ const src = heap;
813
+ if (x.dirty < 0 || x.epoch !== this.#transformEpoch || this.count > this.#transformCount) {
814
+ // Everything: the extract rewrote it all, or the GPU buffer does not hold the rest.
815
+ if (bytes > 0 && !this.#staging?.write(heap, x.address, bytes))
816
+ q.writeBuffer(this.#instances, 0, src, x.address, bytes);
817
+ this.#transformEpoch = x.epoch;
818
+ this.#transformCount = this.count;
819
+ this.transformBytes = bytes;
820
+ }
821
+ else {
822
+ // Only the ranges the extract rewrote (ADR-0075); the GPU holds the rest from earlier frames.
823
+ let sent = 0;
824
+ const at = x.dirtyAt >>> 2;
825
+ for (let i = 0; i < x.dirty; i++) {
826
+ const first = u32[at + 2 * i];
827
+ const end = Math.min(u32[at + 2 * i + 1], this.count);
828
+ if (end <= first)
829
+ continue;
830
+ q.writeBuffer(this.#instances, first * TRANSFORM_BYTES, src, x.address + first * TRANSFORM_BYTES, (end - first) * TRANSFORM_BYTES);
831
+ sent += (end - first) * TRANSFORM_BYTES;
832
+ }
833
+ this.transformBytes = sent;
834
+ }
835
+ if (this.count > 0 && (x.staticChanged || x.epoch !== this.#staticEpoch || this.count > this.#staticCount)) {
836
+ q.writeBuffer(this.#instances, this.capacity * TRANSFORM_BYTES, heap, x.address + x.cap * TRANSFORM_BYTES, this.count * STATIC_BYTES);
837
+ this.#staticEpoch = x.epoch;
838
+ this.#staticCount = this.count;
839
+ this.staticUploads++;
840
+ }
841
+ const scale = 2 ** this.lodBias;
842
+ this.#paramsU32[0] = this.count;
843
+ this.#paramsF32[1] = this.#lodScale;
844
+ this.#paramsF32[2] = this.errorPx * scale;
845
+ this.#paramsF32[3] = Math.max(this.minRadiusPx, this.lodBias > 0 ? 0.25 * scale : 0);
846
+ q.writeBuffer(this.#params, 0, this.#paramsData);
847
+ }
848
+ /**
849
+ * The CPU-cull upload (ADR-0057, ADR-0059): the visible instances' 40-byte records, already
850
+ * grouped by draw slot, into one vertex buffer; one draw per non-empty slot from the
851
+ * per-slot counts. No compute pass, no indirect draw, no readback.
852
+ */
853
+ #uploadCulled(heap, u32, x) {
854
+ const q = this.device.queue;
855
+ const bytes = x.count * CULLED_BYTES;
856
+ if (!this.#cpuBuf || this.#cpuBuf.size < bytes) {
857
+ this.#cpuBuf?.destroy();
858
+ this.#cpuBuf = this.device.createBuffer({ label: 'yali:scene:culled', size: Math.max(1 << 16, nextPow2(bytes)), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
859
+ }
860
+ if (bytes > 0)
861
+ q.writeBuffer(this.#cpuBuf, 0, heap, x.address, bytes);
862
+ const draws = this.#culledDraws;
863
+ draws.build(u32, x.countsAt >>> 2, x.meshes, this.#store.entries, x.cascades);
864
+ this.#cpuDraws = draws.main;
865
+ this.#cpuByLod.set(draws.byLod);
866
+ this.#cpuCascades = x.cascades;
867
+ this.#cpuDrawCount = draws.mainCount;
868
+ this.count = x.count;
869
+ this.visible = x.count;
870
+ }
871
+ /** The last `MeshStore.updateVertices` serial uploaded. */
872
+ #writesSeen = 0;
873
+ /** Uploads meshes added since the last call (grows the pool, re-uploading, when full). */
874
+ #syncGeometry() {
875
+ const s = this.#store;
876
+ if (this.#geometryVersion === s.version) {
877
+ this.#syncWrites();
878
+ return;
879
+ }
880
+ const vBytes = s.vertexCount * VERTEX_BYTES;
881
+ const iBytes = s.indexCount * 4;
882
+ const q = this.device.queue;
883
+ if (!this.#vertexBuf || this.#vertexBuf.size < vBytes) {
884
+ this.#vertexBuf?.destroy();
885
+ // STORAGE too: the clustered draw pulls vertices (ADR-0100).
886
+ this.#vertexBuf = this.device.createBuffer({ label: 'yali:geometry:vertices', size: Math.max(1 << 16, nextPow2(vBytes)), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
887
+ this.#poolEpoch++;
888
+ this.#vertexBlocks = 0;
889
+ this.#dirtyGroups = true;
890
+ }
891
+ if (!this.#indexBuf || this.#indexBuf.size < iBytes) {
892
+ this.#indexBuf?.destroy();
893
+ this.#indexBuf = this.device.createBuffer({ label: 'yali:geometry:indices', size: Math.max(1 << 16, nextPow2(iBytes)), usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST });
894
+ this.#indexBlocks = 0;
895
+ this.#dirtyGroups = true;
896
+ }
897
+ for (; this.#vertexBlocks < s.vertexBlocks.length; this.#vertexBlocks++) {
898
+ const b = s.vertexBlocks[this.#vertexBlocks];
899
+ q.writeBuffer(this.#vertexBuf, b.at * VERTEX_BYTES, b.data);
900
+ }
901
+ for (; this.#indexBlocks < s.indexBlocks.length; this.#indexBlocks++) {
902
+ const b = s.indexBlocks[this.#indexBlocks];
903
+ q.writeBuffer(this.#indexBuf, b.at * 4, b.data);
904
+ }
905
+ // The skinned pool (ADR-0088), only once a skinned mesh exists.
906
+ if (s.skinnedVertexCount > 0) {
907
+ const sv = s.skinnedVertexCount * VERTEX_BYTES;
908
+ if (!this.#skinnedVertexBuf || this.#skinnedVertexBuf.size < sv) {
909
+ this.#skinnedVertexBuf?.destroy();
910
+ this.#skinBuf?.destroy();
911
+ const n = Math.max(1 << 12, nextPow2(s.skinnedVertexCount));
912
+ this.#skinnedVertexBuf = this.device.createBuffer({ label: 'yali:geometry:skinned', size: n * VERTEX_BYTES, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
913
+ this.#skinBuf = this.device.createBuffer({ label: 'yali:geometry:skin', size: n * SKIN_STREAM_BYTES, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
914
+ this.#skinnedBlocks = 0;
915
+ this.#skinBlocks = 0;
916
+ this.#bundles = null;
917
+ }
918
+ for (; this.#skinnedBlocks < s.skinnedVertexBlocks.length; this.#skinnedBlocks++) {
919
+ const b = s.skinnedVertexBlocks[this.#skinnedBlocks];
920
+ q.writeBuffer(this.#skinnedVertexBuf, b.at * VERTEX_BYTES, b.data);
921
+ }
922
+ for (; this.#skinBlocks < s.skinBlocks.length; this.#skinBlocks++) {
923
+ const b = s.skinBlocks[this.#skinBlocks];
924
+ q.writeBuffer(this.#skinBuf, b.at * SKIN_STREAM_BYTES, b.data);
925
+ }
926
+ }
927
+ this.#geometryVersion = s.version;
928
+ this.#syncWrites();
929
+ this.#ensureClasses(new Set(s.entries.map((e) => e.drawClass)));
930
+ }
931
+ /** Rewritten vertices (`MeshStore.updateVertices`, morphs) and indices (`updateIndices`, ADR-0117) newer than the last upload. */
932
+ #syncWrites() {
933
+ const s = this.#store;
934
+ if (s.writeSerial === this.#writesSeen)
935
+ return;
936
+ const q = this.device.queue;
937
+ for (const w of s.vertexWrites.values()) {
938
+ if (w.serial <= this.#writesSeen)
939
+ continue;
940
+ if (w.skinned)
941
+ this.#skinnedDynamic = true;
942
+ else
943
+ this.#poolDynamic = true;
944
+ if (this.#motionWanted)
945
+ this.#prevPools();
946
+ const buf = w.skinned ? this.#skinnedVertexBuf : this.#vertexBuf;
947
+ if (buf)
948
+ q.writeBuffer(buf, w.at * VERTEX_BYTES, w.data);
949
+ }
950
+ for (const w of s.indexWrites.values()) {
951
+ if (w.serial > this.#writesSeen && this.#indexBuf)
952
+ q.writeBuffer(this.#indexBuf, w.at * 4, w.data);
953
+ }
954
+ // Streamed cluster pages' vertices (ADR-0134): static, so the pool stays as it was for motion.
955
+ for (const w of s.rangeWrites) {
956
+ if (w.serial > this.#writesSeen && this.#vertexBuf)
957
+ q.writeBuffer(this.#vertexBuf, w.at * VERTEX_BYTES, w.data);
958
+ }
959
+ s.rangesSeen = s.writeSerial;
960
+ this.#writesSeen = s.writeSerial;
961
+ }
962
+ #syncMaterials() {
963
+ const m = this.#materials;
964
+ m.sync(this.#textures.version);
965
+ if (this.#materialVersion === m.version)
966
+ return;
967
+ this.device.queue.writeBuffer(this.#materialBuf, 0, m.data);
968
+ this.device.queue.writeBuffer(this.#materialExtBuf, 0, m.ext);
969
+ this.#materialVersion = m.version;
970
+ }
971
+ #layoutChanged(counts, at, meshes) {
972
+ if (meshes !== this.#layoutMeshes || this.#layoutVersion !== this.#store.version)
973
+ return true;
974
+ const own = this.#counts;
975
+ for (let m = 0; m < meshes; m++)
976
+ if (counts[at + m] !== own[m])
977
+ return true;
978
+ return false;
979
+ }
980
+ /**
981
+ * Lays out the draw slots: each mesh with instances gets one slot per LOD, and each
982
+ * slot a range as long as the mesh's instance count (any LOD may take them all). Writes
983
+ * the mesh table, the indirect-argument template and the draw list; reallocates what
984
+ * no longer fits. Runs when the counts change, never per frame.
985
+ */
986
+ #layout(counts, at, meshes) {
987
+ const d = this.device;
988
+ const entries = this.#store.entries;
989
+ if (this.#counts.length < meshes)
990
+ this.#counts = new Uint32Array(nextPow2(meshes));
991
+ this.#counts.set(counts.subarray(at, at + meshes));
992
+ this.#layoutMeshes = meshes;
993
+ this.#layoutVersion = this.#store.version;
994
+ const info = new ArrayBuffer(Math.max(1, meshes) * MESH_INFO_WORDS * 4);
995
+ const infoU = new Uint32Array(info);
996
+ const infoF = new Float32Array(info);
997
+ let slots = 0;
998
+ let range = 0;
999
+ for (let m = 0; m < meshes; m++) {
1000
+ const n = this.#counts[m];
1001
+ const e = entries[m];
1002
+ const o = m * MESH_INFO_WORDS;
1003
+ infoF[o] = e.center[0];
1004
+ infoF[o + 1] = e.center[1];
1005
+ infoF[o + 2] = e.center[2];
1006
+ infoF[o + 3] = e.radius;
1007
+ infoU[o + 4] = e.lods.length;
1008
+ infoU[o + 5] = slots;
1009
+ infoU[o + 6] = range;
1010
+ infoU[o + 7] = n;
1011
+ for (let l = 0; l < MAX_LODS; l++)
1012
+ infoF[o + 8 + l] = e.lods[l]?.error ?? 0;
1013
+ if (n > 0) {
1014
+ slots += e.lods.length;
1015
+ range += e.lods.length * n;
1016
+ }
1017
+ }
1018
+ // Occlusion (ADR-0095) keeps three sets of slots and ranges: shadows, pass 1, pass 2.
1019
+ const sets = this.#occl ? 3 : 1;
1020
+ const args = new Uint32Array(Math.max(1, slots) * ARGS_WORDS * sets);
1021
+ const draws = new Uint32Array(slots * 3);
1022
+ const slotLod = new Uint8Array(slots);
1023
+ const slotClass = new Uint8Array(slots);
1024
+ const slotMesh = new Uint32Array(slots);
1025
+ const storage = this.path === 'storage';
1026
+ for (let m = 0, s = 0; m < meshes; m++) {
1027
+ const n = this.#counts[m];
1028
+ if (n === 0)
1029
+ continue;
1030
+ const e = entries[m];
1031
+ const base = infoU[m * MESH_INFO_WORDS + 6];
1032
+ for (let l = 0; l < e.lods.length; l++, s++) {
1033
+ const lod = e.lods[l];
1034
+ const start = base + l * n;
1035
+ for (let k = 0; k < sets; k++)
1036
+ args.set([lod.count, 0, lod.firstIndex, e.baseVertex, storage ? start + k * range : 0], (s + k * slots) * ARGS_WORDS);
1037
+ draws.set([s, start, n], s * 3);
1038
+ slotLod[s] = l;
1039
+ slotMesh[s] = m;
1040
+ slotClass[s] = e.drawClass;
1041
+ }
1042
+ }
1043
+ this.#drawList = draws;
1044
+ this.#slots = slots;
1045
+ this.#region = range;
1046
+ this.#slotLod = slotLod;
1047
+ this.#slotMesh = slotMesh;
1048
+ this.#slotClass = slotClass;
1049
+ this.#drawnBySlot = new Uint32Array(slots);
1050
+ this.stats.slots = slots;
1051
+ this.stats.meshes = meshes;
1052
+ this.stats.layouts++;
1053
+ const grow = (buf, size, label, usage) => {
1054
+ if (buf && buf.size >= size)
1055
+ return buf;
1056
+ buf?.destroy();
1057
+ this.#dirtyGroups = true;
1058
+ return d.createBuffer({ label, size: nextPow2(Math.max(size, 256)), usage });
1059
+ };
1060
+ this.#meshInfo = grow(this.#meshInfo, info.byteLength, 'yali:scene:meshes', GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
1061
+ this.#argsTemplate = grow(this.#argsTemplate, args.byteLength, 'yali:scene:args-template', GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST);
1062
+ this.#args = grow(this.#args, args.byteLength, 'yali:scene:args', GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC);
1063
+ if (!this.#readbackBusy) {
1064
+ const rb = grow(this.#readback, args.byteLength, 'yali:scene:args-readback', GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST);
1065
+ this.#readback = rb;
1066
+ }
1067
+ this.#ranges = storage
1068
+ ? grow(this.#ranges, Math.max(1, range) * 4 * sets, 'yali:scene:visible', GPUBufferUsage.STORAGE)
1069
+ : grow(this.#ranges, Math.max(1, range) * INSTANCE_BYTES, 'yali:scene:compact', GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX);
1070
+ d.queue.writeBuffer(this.#meshInfo, 0, info);
1071
+ d.queue.writeBuffer(this.#argsTemplate, 0, args);
1072
+ this.#argsBytes = args.byteLength;
1073
+ this.#bundles = null; // re-recorded with the new draw list
1074
+ }
1075
+ #argsBytes = 0;
1076
+ #bindGroups() {
1077
+ if (!this.#instances || !this.#meshInfo || !this.#args || !this.#ranges)
1078
+ return;
1079
+ const d = this.device;
1080
+ const vertex = this.path === 'vertex';
1081
+ const cullEntries = [
1082
+ { binding: 0, resource: { buffer: this.#camera } },
1083
+ { binding: 1, resource: { buffer: this.#instances } },
1084
+ { binding: 2, resource: { buffer: this.#meshInfo } },
1085
+ { binding: 3, resource: { buffer: this.#args } },
1086
+ { binding: 4, resource: { buffer: this.#params } },
1087
+ { binding: vertex ? 6 : 5, resource: { buffer: this.#ranges } },
1088
+ ];
1089
+ const occl = this.#occl;
1090
+ if (occl) {
1091
+ cullEntries.push({ binding: 7, resource: { buffer: occl.params } }, { binding: 8, resource: occl.view }, { binding: 9, resource: { buffer: occl.recheck } });
1092
+ this.#recheckGroup = d.createBindGroup({ label: 'yali:scene:recheck', layout: occl.recheckPipeline.getBindGroupLayout(0), entries: cullEntries });
1093
+ this.#occlVersion = occl.version;
1094
+ }
1095
+ this.#cullGroup = d.createBindGroup({ label: 'yali:scene:cull', layout: (occl?.cullPipeline ?? this.#cullPipeline).getBindGroupLayout(0), entries: cullEntries });
1096
+ // The vertex path's draw reads only the camera and materials (instances come as vertex buffers).
1097
+ this.#drawGroups(vertex
1098
+ ? [{ binding: 0, resource: { buffer: this.#camera } }, { binding: 3, resource: { buffer: this.#materialBuf } }, { binding: 18, resource: { buffer: this.#materialExtBuf } }, { binding: 19, resource: (this.#sceneColor ?? this.#noSceneColor).createView() }, ...this.#textureEntries()]
1099
+ : [
1100
+ { binding: 0, resource: { buffer: this.#camera } },
1101
+ { binding: 1, resource: { buffer: this.#instances } },
1102
+ { binding: 2, resource: { buffer: this.#ranges } },
1103
+ { binding: 3, resource: { buffer: this.#materialBuf } },
1104
+ { binding: 18, resource: { buffer: this.#materialExtBuf } },
1105
+ { binding: 19, resource: (this.#sceneColor ?? this.#noSceneColor).createView() },
1106
+ ...this.#textureEntries(),
1107
+ ]);
1108
+ this.#buildShadowGroups();
1109
+ this.#dirtyGroups = false;
1110
+ this.#bundles = null;
1111
+ }
1112
+ /** Every cascade, each into its tile of the atlas, from the same instances and slots as the draw. */
1113
+ #encodeShadows(pass) {
1114
+ const sh = this.#shadows;
1115
+ pass.setVertexBuffer(0, this.#vertexBuf);
1116
+ const vertex = this.path === 'vertex';
1117
+ // Single-sided casters cull back faces; double-sided ones (ADR-0081) draw both, through the
1118
+ // no-cull pipeline once it is built (until then the single-sided one). Blend casts nothing.
1119
+ const double = this.#shadowPipelineDouble && this.#shadowGroupsDouble.length === sh.cascades;
1120
+ // Skinned casters (ADR-0088) draw after the rigid ones, from the skinned pool, through their
1121
+ // own pipelines (until those are built, they cast nothing).
1122
+ const passes = [];
1123
+ for (const two of double ? [false, true] : [false])
1124
+ passes.push([false, two]);
1125
+ if (this.#skinBuf) {
1126
+ if (this.#shadowSkinned && this.#shadowSkinnedGroups.length === sh.cascades)
1127
+ passes.push([true, false]);
1128
+ if (this.#shadowSkinnedDouble && this.#shadowSkinnedDoubleGroups.length === sh.cascades)
1129
+ passes.push([true, true]);
1130
+ }
1131
+ for (let c = 0; c < sh.cascades; c++) {
1132
+ pass.setViewport(c * sh.size, 0, sh.size, sh.size, 0, 1);
1133
+ // Per cascade: the last cascade's clustered casters drew from their own index buffer (ADR-0122).
1134
+ pass.setIndexBuffer(this.#indexBuf, 'uint32');
1135
+ for (const [skinned, twoSided] of passes) {
1136
+ pass.setPipeline(skinned ? (twoSided ? this.#shadowSkinnedDouble : this.#shadowSkinned) : twoSided ? this.#shadowPipelineDouble : this.#shadowPipeline);
1137
+ pass.setBindGroup(0, skinned ? (twoSided ? this.#shadowSkinnedDoubleGroups[c] : this.#shadowSkinnedGroups[c]) : twoSided ? this.#shadowGroupsDouble[c] : this.#shadowGroups[c]);
1138
+ pass.setVertexBuffer(0, skinned ? this.#skinnedVertexBuf : this.#vertexBuf);
1139
+ if (skinned)
1140
+ pass.setVertexBuffer(vertex ? 2 : 1, this.#skinBuf);
1141
+ const wants = (cls) => !isBlendClass(cls) && isSkinnedClass(cls) === skinned && (skinned ? (cls & 1) === (twoSided ? 1 : 0) : !double || ((cls & 1) === 1) === twoSided);
1142
+ if (this.cull === 'cpu') {
1143
+ // Only this cascade's casters when the cull partitioned them, else whole slots.
1144
+ const part = c < this.#cpuCascades;
1145
+ const d = part ? this.#culledDraws.cascade[c] : this.#cpuDraws;
1146
+ const cls = part ? this.#culledDraws.cascadeClass[c] : this.#culledDraws.mainClass;
1147
+ const count = part ? this.#culledDraws.cascadeCount[c] : this.#cpuDrawCount;
1148
+ for (let i = 0; i < count; i++) {
1149
+ if (!wants(cls[i]))
1150
+ continue;
1151
+ const o = i * 5;
1152
+ pass.setVertexBuffer(1, this.#cpuBuf, d[o + 3] * CULLED_BYTES, d[o + 4] * CULLED_BYTES);
1153
+ pass.drawIndexed(d[o], d[o + 4], d[o + 1], d[o + 2], 0);
1154
+ }
1155
+ continue;
1156
+ }
1157
+ const list = this.#drawList;
1158
+ const cl = skinned ? null : this.clusters;
1159
+ for (let i = 0; i < list.length; i += 3) {
1160
+ if (!wants(this.#slotClass[list[i]]))
1161
+ continue;
1162
+ // A clustered mesh's finest-LOD casters are drawn as clusters below (ADR-0112).
1163
+ if (cl && this.#slotLod[list[i]] === 0 && cl.shadowCovers(this.#slotMesh[list[i]]))
1164
+ continue;
1165
+ if (vertex)
1166
+ pass.setVertexBuffer(1, this.#ranges, list[i + 1] * INSTANCE_BYTES, list[i + 2] * INSTANCE_BYTES);
1167
+ pass.drawIndexedIndirect(this.#args, list[i] * ARGS_BYTES);
1168
+ }
1169
+ }
1170
+ if (this.cull !== 'cpu')
1171
+ this.clusters?.encodeShadow(pass, c);
1172
+ }
1173
+ }
1174
+ /** Encodes the draws (one indirect draw per slot of slot set `set`) on a pass or a bundle encoder. */
1175
+ #encodeDraws(enc, set = 0, part = 'all', layer = 0) {
1176
+ const at = set * this.#slots;
1177
+ const cl = this.clusters;
1178
+ enc.setIndexBuffer(this.#indexBuf, 'uint32');
1179
+ const list = this.#drawList;
1180
+ const vertex = this.path === 'vertex';
1181
+ // Class by class (ADR-0081, ADR-0088): opaque and mask (rigid, then skinned), then blend. A
1182
+ // class whose pipeline is still compiling is skipped for those frames.
1183
+ for (const c of CLASS_ORDER) {
1184
+ if (part !== 'all' && isBlendClass(c) !== (part === 'blend'))
1185
+ continue;
1186
+ let bound = false;
1187
+ for (let i = 0; i < list.length; i += 3) {
1188
+ if (this.#slotClass[list[i]] !== c)
1189
+ continue;
1190
+ if (cl && this.#slotLod[list[i]] === 0 && cl.covers(this.#slotMesh[list[i]]))
1191
+ continue;
1192
+ if (!bound) {
1193
+ const draw = layer ? this.#layerDraw(c) : this.#classDraw(c);
1194
+ if (!draw || !this.#bindPool(enc, c, vertex ? 2 : 1))
1195
+ break;
1196
+ enc.setPipeline(draw[0]);
1197
+ enc.setBindGroup(0, draw[1]);
1198
+ bound = true;
1199
+ }
1200
+ if (vertex)
1201
+ enc.setVertexBuffer(1, this.#ranges, list[i + 1] * INSTANCE_BYTES, list[i + 2] * INSTANCE_BYTES);
1202
+ enc.drawIndexedIndirect(this.#args, (list[i] + at) * ARGS_BYTES);
1203
+ }
1204
+ }
1205
+ }
1206
+ /**
1207
+ * Binds the vertex pool class `c` draws from (ADR-0088): the rigid pool, or the skinned pool and
1208
+ * its skin stream at slot `skinSlot`. False when the skinned pool is not there yet.
1209
+ */
1210
+ #bindPool(enc, c, skinSlot) {
1211
+ if (!isSkinnedClass(c)) {
1212
+ enc.setVertexBuffer(0, this.#vertexBuf);
1213
+ return true;
1214
+ }
1215
+ if (!this.#skinnedVertexBuf || !this.#skinBuf)
1216
+ return false;
1217
+ enc.setVertexBuffer(0, this.#skinnedVertexBuf);
1218
+ enc.setVertexBuffer(skinSlot, this.#skinBuf);
1219
+ return true;
1220
+ }
1221
+ /** The CPU-cull draws: one ordinary indexed draw per non-empty slot, at its range's vertex-buffer offset. */
1222
+ #encodeCulled(pass) {
1223
+ pass.setIndexBuffer(this.#indexBuf, 'uint32');
1224
+ const d = this.#cpuDraws;
1225
+ const cls = this.#culledDraws.mainClass;
1226
+ let draws = 0;
1227
+ for (const c of CLASS_ORDER) {
1228
+ let bound = false;
1229
+ for (let i = 0; i < this.#cpuDrawCount; i++) {
1230
+ if (cls[i] !== c)
1231
+ continue;
1232
+ if (!bound) {
1233
+ const draw = this.#classDraw(c);
1234
+ if (!draw || !this.#bindPool(pass, c, 2))
1235
+ break;
1236
+ pass.setPipeline(draw[0]);
1237
+ pass.setBindGroup(0, draw[1]);
1238
+ bound = true;
1239
+ }
1240
+ const o = i * 5;
1241
+ pass.setVertexBuffer(1, this.#cpuBuf, d[o + 3] * CULLED_BYTES, d[o + 4] * CULLED_BYTES);
1242
+ pass.drawIndexed(d[o], d[o + 4], d[o + 1], d[o + 2], 0);
1243
+ draws++;
1244
+ }
1245
+ }
1246
+ this.stats.draws = draws;
1247
+ }
1248
+ #ready() {
1249
+ if (this.cull === 'cpu')
1250
+ return this.#cpuDrawCount > 0 && !!this.#cpuBuf && !!this.#drawGroup && !!this.#vertexBuf && !!this.#indexBuf;
1251
+ return this.count > 0 && this.#drawList.length > 0 && !!this.#cullGroup && !!this.#drawGroup && !!this.#vertexBuf && !!this.#indexBuf;
1252
+ }
1253
+ // ── Transmission through the opaque scene (ADR-0099) ─────────────────────────────────────
1254
+ /** Set before `addPasses` when the colour target is the HDR one: blended classes then draw after a copy of the opaque scene. */
1255
+ screenTransmission = false;
1256
+ #sceneColor = null;
1257
+ #noSceneColor;
1258
+ #targetW = 0;
1259
+ #targetH = 0;
1260
+ /** The colour target's size (call before `upload` each frame): the opaque copy follows it. */
1261
+ setTargetSize(width, height) {
1262
+ if (!this.#splitBlend() || (width === this.#targetW && height === this.#targetH))
1263
+ return;
1264
+ this.#targetW = width;
1265
+ this.#targetH = height;
1266
+ this.#sceneColor?.destroy();
1267
+ // A mip chain for the rough-surface blur (ADR-0119): down to 1/64, where roughness 1 is drawn from.
1268
+ const levels = Math.min(7, Math.floor(Math.log2(Math.max(width, height))) + 1);
1269
+ this.#sceneColor = this.device.createTexture({
1270
+ label: 'yali:scene-color',
1271
+ size: [width, height],
1272
+ mipLevelCount: levels,
1273
+ format: 'rgba16float',
1274
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
1275
+ });
1276
+ this.#mipGroups = [];
1277
+ this.#mipViews = [];
1278
+ this.#buildMips();
1279
+ this.#lit[0] = Number.NaN; // the lighting uniform carries the size
1280
+ this.#dirtyGroups = true;
1281
+ }
1282
+ // The opaque copy's mip chain (ADR-0119): a 2 × 2 box downsample per level, by loads.
1283
+ #mips = null;
1284
+ #mipsBuilding = false;
1285
+ #mipViews = [];
1286
+ #mipGroups = [];
1287
+ #buildMips() {
1288
+ if (this.#mips || this.#mipsBuilding || !this.#cache)
1289
+ return;
1290
+ this.#mipsBuilding = true;
1291
+ const wgsl = `@group(0) @binding(0) var src: texture_2d<f32>;
1292
+ @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
1293
+ let p = vec2f(f32((i << 1u) & 2u), f32(i & 2u));
1294
+ return vec4f(p * 2.0 - 1.0, 0.0, 1.0);
1295
+ }
1296
+ @fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
1297
+ let hi = vec2i(textureDimensions(src)) - 1;
1298
+ let p = vec2i(pos.xy) * 2;
1299
+ let s = textureLoad(src, min(p, hi), 0) + textureLoad(src, min(p + vec2i(1, 0), hi), 0) + textureLoad(src, min(p + vec2i(0, 1), hi), 0) + textureLoad(src, min(p + vec2i(1, 1), hi), 0);
1300
+ return s * 0.25;
1301
+ }`;
1302
+ const shader = { hash: 'yali-scene-color-mips-1', wgsl };
1303
+ void this.#cache.render({ label: 'yali:scene-color:mips', vertex: { shader, entryPoint: 'vs' }, fragment: { shader, entryPoint: 'fs', targets: [{ format: 'rgba16float' }] }, primitive: { topology: 'triangle-list' } }).then((p) => {
1304
+ this.#mips = p;
1305
+ this.#lit[0] = Number.NaN; // the lighting uniform says how many levels there are
1306
+ });
1307
+ }
1308
+ /** Fills the opaque copy's levels below the first (the copy's encoder, after the copy). */
1309
+ #encodeMips(encoder) {
1310
+ const t = this.#sceneColor;
1311
+ if (!t || !this.#mips || t.mipLevelCount < 2)
1312
+ return;
1313
+ if (this.#mipViews.length !== t.mipLevelCount) {
1314
+ this.#mipViews = Array.from({ length: t.mipLevelCount }, (_, l) => t.createView({ baseMipLevel: l, mipLevelCount: 1 }));
1315
+ this.#mipGroups = this.#mipViews.slice(0, -1).map((view, l) => this.device.createBindGroup({ label: `yali:scene-color:mip${l}`, layout: this.#mips.getBindGroupLayout(0), entries: [{ binding: 0, resource: view }] }));
1316
+ }
1317
+ for (let l = 1; l < t.mipLevelCount; l++) {
1318
+ const pass = encoder.beginRenderPass({ label: 'scene.copy.mips', colorAttachments: [{ view: this.#mipViews[l], loadOp: 'clear', storeOp: 'store', clearValue: { r: 0, g: 0, b: 0, a: 0 } }] });
1319
+ pass.setPipeline(this.#mips);
1320
+ pass.setBindGroup(0, this.#mipGroups[l - 1]);
1321
+ pass.draw(3);
1322
+ pass.end();
1323
+ }
1324
+ }
1325
+ /** Whether the blended classes draw in their own pass after the opaque copy (the GPU cull with an HDR target). */
1326
+ #splitBlend() {
1327
+ return this.screenTransmission && this.cull === 'gpu';
1328
+ }
1329
+ /** TAA's jitter in clip units, applied to the uploaded view-projection (ADR-0092); null for none. */
1330
+ jitter = null;
1331
+ // ── Cluster culling (ADR-0100) ─────────────────────────────────────────────────────────
1332
+ /** Set by the render plugin from `renderPlugin({ meshlets })`, on the GPU cull's storage path. */
1333
+ clusters = null;
1334
+ #clusterVersion = -1;
1335
+ #slotMesh = new Uint32Array(0);
1336
+ #poolEpoch = 0;
1337
+ /** The finest-LOD slots of this layout (and where their instances are), for the cluster cull. */
1338
+ clusterLayout() {
1339
+ if (this.cull === 'cpu' || this.path !== 'storage' || this.#drawList.length === 0)
1340
+ return null;
1341
+ const list = this.#drawList;
1342
+ const slots = [];
1343
+ for (let i = 0; i < list.length; i += 3)
1344
+ if (this.#slotLod[list[i]] === 0)
1345
+ slots.push({ slot: list[i], mesh: this.#slotMesh[list[i]], start: list[i + 1], n: list[i + 2] });
1346
+ return { key: `${this.stats.layouts}`, slots, sets: this.#occl ? [1, 2] : [0], slots_: this.#slots, region: this.#region };
1347
+ }
1348
+ /** What the cluster passes bind (null until the buffers exist). */
1349
+ clusterSources() {
1350
+ if (!this.#instances || !this.#ranges || !this.#args || !this.#vertexBuf || !this.#groupEntries)
1351
+ return null;
1352
+ const o = this.#occl;
1353
+ return { key: `${this.stats.layouts}:${this.capacity}:${this.#poolEpoch}:${this.#groupsEpoch}`, camera: this.#camera, instances: this.#instances, visible: this.#ranges, args: this.#args, cullParams: this.#params, pool: this.#vertexBuf, drawEntries: entriesFor(this.#groupEntries, 0), occlusion: o ? { params: o.params, view: o.view, version: o.version } : null };
1354
+ }
1355
+ /** The full-shading draw shader, with the app's node materials (the clustered draw extends it). */
1356
+ clusterShader() {
1357
+ return this.#drawShader();
1358
+ }
1359
+ #clusterShadow = null;
1360
+ /** What the clustered shadow casters read this frame (ADR-0112; the planes refitted in place); null when the sun casts nothing. */
1361
+ clusterShadow() {
1362
+ const sh = this.#shadows;
1363
+ if (!sh?.enabled || !this.#casting || this.cull === 'cpu' || this.path !== 'storage')
1364
+ return null;
1365
+ this.#clusterShadow ??= {
1366
+ cascades: sh.cascades,
1367
+ planes: new Float32Array(MAX_CASCADES * 20),
1368
+ light: this.#cameraData.subarray(40, 43),
1369
+ cascadeBuffer: sh.cascadeBuffer,
1370
+ cascadeOffset: SunShadows.offset,
1371
+ cascadeBytes: CASCADE_UNIFORM_BYTES,
1372
+ shader: sceneShadow,
1373
+ };
1374
+ cascadeCullPlanes(sh.params, sh.cascades, this.#clusterShadow.planes, 0);
1375
+ return this.#clusterShadow;
1376
+ }
1377
+ #groupsEpoch = 0;
1378
+ // ── Per-object motion (ADR-0098) ──────────────────────────────────────────────────────
1379
+ #prevInstances = null;
1380
+ #prevPalette = null;
1381
+ #motionWanted = false;
1382
+ /** Bumped when a motion resource is recreated: bind groups that hold one are rebuilt. */
1383
+ motionVersion = 0;
1384
+ /** Whether motion vectors can be drawn here (the GPU cull's storage path). */
1385
+ get motionSupported() {
1386
+ return this.cull === 'gpu' && this.path === 'storage';
1387
+ }
1388
+ /** What the motion pass binds (null until the instances exist, or off this path); keeps last frame's copies from now on. */
1389
+ motionResources() {
1390
+ if (!this.motionSupported || !this.#instances || !this.#ranges || !this.#palette || !this.#vertexBuf)
1391
+ return null;
1392
+ this.#motionWanted = true;
1393
+ this.#prevPools();
1394
+ const size = this.capacity * TRANSFORM_BYTES;
1395
+ if (!this.#prevInstances || this.#prevInstances.size < size) {
1396
+ this.#prevInstances?.destroy();
1397
+ this.#prevInstances = this.device.createBuffer({ label: 'yali:instances:prev', size, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
1398
+ this.motionVersion++;
1399
+ }
1400
+ const p = this.#palette;
1401
+ if (!this.#prevPalette || this.#prevPalette.height !== p.height) {
1402
+ this.#prevPalette?.destroy();
1403
+ this.#prevPalette = this.device.createTexture({ label: 'yali:palette:prev', size: [p.width, p.height], format: 'rgba32float', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
1404
+ this.motionVersion++;
1405
+ }
1406
+ // A pool nothing rewrites is its own last frame (no copy, no memory).
1407
+ const prevPool = this.#poolDynamic && this.#prevPool ? this.#prevPool : this.#vertexBuf;
1408
+ const prevSkinnedPool = this.#skinnedDynamic && this.#prevSkinned ? this.#prevSkinned : (this.#skinnedVertexBuf ?? this.#noPool());
1409
+ return { camera: this.#camera, instances: this.#instances, visible: this.#ranges, prevInstances: this.#prevInstances, palette: p, prevPalette: this.#prevPalette, prevPool, prevSkinnedPool };
1410
+ }
1411
+ /**
1412
+ * Last frame's copy of each pool something rewrites (ADR-0124): made (from the pool as it is now, so
1413
+ * before a pending write lands) when missing or when the pool was recreated; kept by `recordMotionCopies`.
1414
+ */
1415
+ #prevPools() {
1416
+ const copy = (pool, prev, label) => {
1417
+ if (!pool)
1418
+ return prev;
1419
+ if (prev && prev.size === pool.size && this.#prevOf.get(prev) === pool)
1420
+ return prev;
1421
+ prev?.destroy();
1422
+ // VERTEX too: the masked motion stage reads last frame's position as a vertex buffer (ADR-0133).
1423
+ const made = this.device.createBuffer({ label, size: pool.size, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.VERTEX });
1424
+ this.#prevOf.set(made, pool);
1425
+ const e = this.device.createCommandEncoder();
1426
+ e.copyBufferToBuffer(pool, 0, made, 0, pool.size);
1427
+ this.device.queue.submit([e.finish()]);
1428
+ this.motionVersion++;
1429
+ return made;
1430
+ };
1431
+ if (this.#poolDynamic)
1432
+ this.#prevPool = copy(this.#vertexBuf, this.#prevPool, 'yali:geometry:vertices:prev');
1433
+ if (this.#skinnedDynamic)
1434
+ this.#prevSkinned = copy(this.#skinnedVertexBuf, this.#prevSkinned, 'yali:geometry:skinned:prev');
1435
+ }
1436
+ /** A stand-in for a skinned pool that does not exist (the skinned motion pipeline still binds one). */
1437
+ #noPool() {
1438
+ this.#emptyPool ??= this.device.createBuffer({ label: 'yali:geometry:none', size: 32, usage: GPUBufferUsage.STORAGE });
1439
+ return this.#emptyPool;
1440
+ }
1441
+ #poolDynamic = false;
1442
+ #skinnedDynamic = false;
1443
+ #prevPool = null;
1444
+ #prevSkinned = null;
1445
+ #emptyPool = null;
1446
+ /** Which pool each copy was made from (a recreated pool needs a new copy). */
1447
+ #prevOf = new WeakMap();
1448
+ /** The bind-group generation of the instances, the visible ranges and the palette (motion rebuilds its groups on a change). */
1449
+ get motionKey() {
1450
+ return `${this.motionVersion}:${this.stats.layouts}:${this.capacity}:${this.#palette?.height ?? 0}:${this.#poolEpoch}:${this.#skinnedVertexBuf?.size ?? 0}`;
1451
+ }
1452
+ /**
1453
+ * Draws every opaque instance of the view's slot sets (both passes under occlusion) with the
1454
+ * motion pipelines: `rigid`, and `skinned` for the skinned classes (pipeline, bind group). With
1455
+ * `clustered` (the motion pass's shader and rigid entries), meshes the view drew as clusters draw
1456
+ * those same clusters once their pipeline is built (ADR-0112), whole until then.
1457
+ */
1458
+ encodeMotion(pass, rigid, skinned, clustered, motionParams) {
1459
+ if (this.cull === 'cpu' || !this.#ready())
1460
+ return;
1461
+ // Meshes added since: their masked classes' pipelines too.
1462
+ if (this.#maskMotionFormat && (this.#maskMotionVersion !== this.#store.version || this.#maskMotionMaterials !== this.#materials.blendMotion)) {
1463
+ this.#maskMotionVersion = this.#store.version;
1464
+ this.#maskMotionMaterials = this.#materials.blendMotion;
1465
+ this.prepareMaskMotion(this.#maskMotionFormat);
1466
+ }
1467
+ const cl = clustered && this.clusters?.motionCovers(clustered) ? this.clusters : null;
1468
+ pass.setIndexBuffer(this.#indexBuf, 'uint32');
1469
+ const list = this.#drawList;
1470
+ const sets = this.#occl ? [1, 2] : [0];
1471
+ let bound = null;
1472
+ let pool = -1;
1473
+ for (let i = 0; i < list.length; i += 3) {
1474
+ const c = this.#slotClass[list[i]];
1475
+ // Blended surfaces draw motion only through their own stage, while a material asks (ADR-0133).
1476
+ const blend = isBlendClass(c);
1477
+ if (blend && !(motionParams && this.#materials.blendMotion > 0))
1478
+ continue;
1479
+ if (cl && this.#slotLod[list[i]] === 0 && cl.covers(this.#slotMesh[list[i]]))
1480
+ continue;
1481
+ // Masked classes through their alpha test once built (ADR-0124), else as solid as the rest.
1482
+ const mask = ((c % 6) >> 1 === 1 || blend) && motionParams ? this.#maskMotionFor(c, motionParams) : null;
1483
+ if (blend && !mask)
1484
+ continue;
1485
+ const pp = mask ?? (isSkinnedClass(c) ? skinned : rigid);
1486
+ if (!pp)
1487
+ continue;
1488
+ const skin = isSkinnedClass(c) ? 1 : 0;
1489
+ if (pool !== skin) {
1490
+ if (!this.#bindPool(pass, c, 1))
1491
+ continue;
1492
+ pool = skin;
1493
+ }
1494
+ // The masked stage's last-frame positions (ADR-0133): after the pool (and the skin buffer).
1495
+ if (mask)
1496
+ pass.setVertexBuffer(skin ? 2 : 1, skin ? (this.#skinnedDynamic && this.#prevSkinned ? this.#prevSkinned : this.#skinnedVertexBuf) : this.#poolDynamic && this.#prevPool ? this.#prevPool : this.#vertexBuf);
1497
+ if (bound !== pp[0]) {
1498
+ pass.setPipeline(pp[0]);
1499
+ pass.setBindGroup(0, pp[1]);
1500
+ bound = pp[0];
1501
+ }
1502
+ for (const s of sets)
1503
+ pass.drawIndexedIndirect(this.#args, (list[i] + s * this.#slots) * ARGS_BYTES);
1504
+ }
1505
+ if (cl)
1506
+ cl.encodeMotion(pass, clustered);
1507
+ }
1508
+ // ── Motion for alpha-masked cut-outs (ADR-0124) ─────────────────────────────────────────────
1509
+ /**
1510
+ * Starts building the masked classes' motion pipelines over the draw shader (`vs_motion_mask`,
1511
+ * `fs_motion_mask`: the material's alpha test), for a motion target of `format`: the motion pass calls
1512
+ * it when it builds its graph, so `drawClassesReady()` awaits them (trap 134). Classes present only.
1513
+ */
1514
+ prepareMaskMotion(format) {
1515
+ this.#maskMotionFormat = format;
1516
+ if (!this.#cache || !this.motionSupported)
1517
+ return;
1518
+ for (const e of this.#store.entries) {
1519
+ const c = e.drawClass;
1520
+ // Masked classes, and blended ones while a material asks for their motion (ADR-0133).
1521
+ const blend = isBlendClass(c);
1522
+ if (((c % 6) >> 1 !== 1 && !(blend && this.#materials.blendMotion > 0)) || this.#maskMotion[c] || this.#maskMotionBuilding.has(c))
1523
+ continue;
1524
+ const skinned = isSkinnedClass(c);
1525
+ const build = this.#cache
1526
+ .render({
1527
+ label: `yali:motion:mask:c${c}`,
1528
+ vertex: {
1529
+ shader: this.#drawShader(),
1530
+ entryPoint: skinned ? 'vs_motion_mask_skinned' : 'vs_motion_mask',
1531
+ buffers: [
1532
+ {
1533
+ arrayStride: VERTEX_BYTES,
1534
+ attributes: [
1535
+ { shaderLocation: 0, offset: 0, format: 'float32x3' },
1536
+ { shaderLocation: 1, offset: 12, format: 'float32x3' },
1537
+ { shaderLocation: 5, offset: 24, format: 'float32x2' },
1538
+ ],
1539
+ },
1540
+ ...(skinned ? [SKIN_LAYOUT] : []),
1541
+ // Last frame's position (ADR-0133), from last frame's copy of the pool (or the pool itself).
1542
+ { arrayStride: VERTEX_BYTES, attributes: [{ shaderLocation: 8, offset: 0, format: 'float32x3' }] },
1543
+ ],
1544
+ },
1545
+ fragment: { shader: this.#drawShader(), entryPoint: blend ? 'fs_motion_blend' : 'fs_motion_mask', targets: [{ format }] },
1546
+ primitive: { topology: 'triangle-list', cullMode: c & 1 ? 'none' : 'back', frontFace: 'ccw' },
1547
+ depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'greater' },
1548
+ })
1549
+ .then((p) => {
1550
+ this.#maskMotionBuilding.delete(c);
1551
+ this.#maskMotion[c] = p;
1552
+ this.#maskMotionGroups[c] = null;
1553
+ }, (err) => {
1554
+ this.#maskMotionBuilding.delete(c);
1555
+ console.warn(`[yali] motion: ${blend ? `the blended class ${c} draws no motion` : `the masked class ${c} draws its motion without its alpha test`} (${err instanceof Error ? err.message : String(err)}).`);
1556
+ });
1557
+ this.#maskMotionBuilding.set(c, build);
1558
+ }
1559
+ }
1560
+ #maskMotionFormat = null;
1561
+ #maskMotion = [];
1562
+ #maskMotionGroups = [];
1563
+ #maskMotionBuilding = new Map();
1564
+ #maskMotionKey = '';
1565
+ #maskMotionVersion = -1;
1566
+ #maskMotionMaterials = 0;
1567
+ /** Class `c`'s masked motion pipeline and group (the draw's entries, the motion uniform, last frame's transforms and palette), when built. */
1568
+ #maskMotionFor(c, params) {
1569
+ const p = this.#maskMotion[c];
1570
+ if (!p || !this.#groupEntries || !this.#prevInstances || !this.#prevPalette)
1571
+ return null;
1572
+ const key = `${this.motionKey}:${this.#groupsEpoch}:${this.#prevInstances.size}`;
1573
+ if (key !== this.#maskMotionKey) {
1574
+ this.#maskMotionKey = key;
1575
+ this.#maskMotionGroups.fill(null);
1576
+ }
1577
+ let g = this.#maskMotionGroups[c];
1578
+ if (!g) {
1579
+ g = this.device.createBindGroup({
1580
+ label: `yali:motion:mask:c${c}`,
1581
+ layout: p.getBindGroupLayout(0),
1582
+ entries: [
1583
+ ...entriesFor(this.#groupEntries, c),
1584
+ { binding: 20, resource: { buffer: params } },
1585
+ { binding: 21, resource: { buffer: this.#prevInstances } },
1586
+ ...(isSkinnedClass(c) ? [{ binding: 22, resource: this.#prevPalette.createView() }] : []),
1587
+ ],
1588
+ });
1589
+ this.#maskMotionGroups[c] = g;
1590
+ }
1591
+ return [p, g];
1592
+ }
1593
+ /** What the motion pass reads besides the visible instances: the cluster lists it redraws (ADR-0112). */
1594
+ get motionReads() {
1595
+ if (!this.clusters)
1596
+ return [];
1597
+ return this.#occl ? ['scene.clusterList1', 'scene.clusterList2'] : ['scene.clusterList'];
1598
+ }
1599
+ /** After the frame's passes: this frame's transforms and palette become last frame's (motion only). */
1600
+ recordMotionCopies(encoder) {
1601
+ if (!this.#motionWanted || !this.#instances || !this.#prevInstances)
1602
+ return;
1603
+ encoder.copyBufferToBuffer(this.#instances, 0, this.#prevInstances, 0, Math.min(this.capacity * TRANSFORM_BYTES, this.#prevInstances.size));
1604
+ const p = this.#palette;
1605
+ if (p && this.#prevPalette && this.#prevPalette.height === p.height)
1606
+ encoder.copyTextureToTexture({ texture: p }, { texture: this.#prevPalette }, [p.width, p.height]);
1607
+ // The pools something rewrites (ADR-0124): this frame's vertices become last frame's.
1608
+ for (const [pool, prev, on] of [[this.#vertexBuf, this.#prevPool, this.#poolDynamic], [this.#skinnedVertexBuf, this.#prevSkinned, this.#skinnedDynamic]]) {
1609
+ if (on && pool && prev && prev.size === pool.size && this.#prevOf.get(prev) === pool)
1610
+ encoder.copyBufferToBuffer(pool, 0, prev, 0, pool.size);
1611
+ }
1612
+ }
1613
+ /**
1614
+ * Adds `scene.cull`, `scene.shadow` (with shadows) and `scene.draw` to the graph. `keepDepth`
1615
+ * stores the depth after the draw, for a later pass that samples it (TAA).
1616
+ */
1617
+ addPasses(graph, color, depth, keepDepth = false) {
1618
+ const sh = this.#shadows;
1619
+ graph.addPass({
1620
+ name: 'scene.cull',
1621
+ kind: 'compute',
1622
+ writes: ['scene.visible'],
1623
+ execute: (pass) => {
1624
+ if (this.cull === 'cpu' || !this.#ready())
1625
+ return;
1626
+ if (this.#occl && this.#occl.version !== this.#occlVersion)
1627
+ this.#bindGroups();
1628
+ pass.setPipeline(this.#occl?.cullPipeline ?? this.#cullPipeline);
1629
+ pass.setBindGroup(0, this.#cullGroup);
1630
+ pass.dispatchWorkgroups(Math.ceil(this.count / 64));
1631
+ },
1632
+ });
1633
+ // The clusters are culled now and drawn in scene.draw: all of them without occlusion (ADR-0100),
1634
+ // under it those of the instances visible last frame, against its pyramid (ADR-0109).
1635
+ this.clusters?.addCull(graph, 'scene.visible', this.#occl ? 1 : 0);
1636
+ if (sh?.enabled) {
1637
+ // Clustered casters (ADR-0112): slot set 0's clustered meshes culled per cascade, at the view's cut.
1638
+ this.clusters?.addShadowCull(graph, 'scene.visible');
1639
+ graph.importTexture('shadow.atlas', () => sh.view);
1640
+ graph.addPass({
1641
+ name: 'scene.shadow',
1642
+ kind: 'render',
1643
+ reads: this.clusters ? ['scene.visible', 'scene.clusterShadow'] : ['scene.visible'],
1644
+ writes: ['scene.shadow'],
1645
+ depth: { target: 'shadow.atlas', clear: 0, store: true },
1646
+ execute: (pass) => {
1647
+ if (!this.#ready() || this.#shadowGroups.length !== sh.cascades || !this.#casting)
1648
+ return;
1649
+ this.#encodeShadows(pass);
1650
+ },
1651
+ });
1652
+ }
1653
+ graph.addPass({
1654
+ name: 'scene.draw',
1655
+ kind: 'render',
1656
+ reads: [...(sh?.enabled ? ['scene.visible', 'scene.lights', 'scene.shadow'] : ['scene.visible', 'scene.lights']), ...(this.clusters ? [this.#occl ? 'scene.clusterList1' : 'scene.clusterList'] : [])],
1657
+ color: { target: color, clear: this.#clear },
1658
+ // Occlusion's pyramid and second pass read and continue this depth.
1659
+ depth: { target: depth, clear: 0, store: keepDepth || !!this.#occl || this.#splitBlend() },
1660
+ execute: (pass) => {
1661
+ this.#encodeScene(pass, this.#occl ? 1 : 0, this.#splitBlend() ? 'opaque' : 'all');
1662
+ this.clusters?.encodeDraw(pass, this.#occl ? 1 : 0);
1663
+ if (!this.#occl && !this.#splitBlend())
1664
+ for (let i = 0; i < this.overlays.length; i++)
1665
+ this.overlays[i](pass);
1666
+ },
1667
+ });
1668
+ const occl = this.#occl;
1669
+ if (occl)
1670
+ this.#addOcclusionPasses(graph, color, depth, keepDepth, occl);
1671
+ if (!this.#splitBlend())
1672
+ return;
1673
+ // The blended classes after a copy of the opaque scene (ADR-0099): transmission samples it.
1674
+ graph.addPass({
1675
+ name: 'scene.copy',
1676
+ kind: 'encoder',
1677
+ reads: occl ? ['scene.opaque2'] : [],
1678
+ writes: ['scene.opaque'],
1679
+ execute: (encoder, ctx) => {
1680
+ if (!this.#sceneColor || this.#sceneColor.width !== ctx.width || this.#sceneColor.height !== ctx.height)
1681
+ return;
1682
+ encoder.copyTextureToTexture({ texture: ctx.texture(color) }, { texture: this.#sceneColor }, [ctx.width, ctx.height]);
1683
+ this.#encodeMips(encoder);
1684
+ },
1685
+ });
1686
+ graph.addPass({
1687
+ name: 'scene.transparent',
1688
+ kind: 'render',
1689
+ reads: ['scene.opaque'],
1690
+ color: { target: color, clear: this.#clear, load: true },
1691
+ // Stored for the second layer's pass (ADR-0125), which tests against it.
1692
+ depth: { target: depth, clear: 0, store: true, load: true },
1693
+ execute: (pass) => {
1694
+ if (occl) {
1695
+ this.#encodeScene(pass, 1, 'blend');
1696
+ this.#encodeScene(pass, 2, 'blend');
1697
+ }
1698
+ else
1699
+ this.#encodeScene(pass, 0, 'blend');
1700
+ for (let i = 0; i < this.overlays.length; i++)
1701
+ this.overlays[i](pass);
1702
+ },
1703
+ });
1704
+ // The second transparent layer (ADR-0125): the scene with layer 0's surfaces copied again, then the
1705
+ // layer-1 surfaces refracting that copy. Both do nothing while no material is in layer 1.
1706
+ graph.addPass({
1707
+ name: 'scene.copyLayer',
1708
+ kind: 'encoder',
1709
+ writes: ['scene.layerCopy'],
1710
+ execute: (encoder, ctx) => {
1711
+ if (!this.#layered() || !this.#ensureLayerCopy(ctx.width, ctx.height))
1712
+ return;
1713
+ encoder.copyTextureToTexture({ texture: ctx.texture(color) }, { texture: this.#layerColor }, [ctx.width, ctx.height]);
1714
+ this.#encodeLayerMips(encoder);
1715
+ },
1716
+ });
1717
+ graph.addPass({
1718
+ name: 'scene.transparent2',
1719
+ kind: 'render',
1720
+ reads: ['scene.layerCopy'],
1721
+ color: { target: color, clear: this.#clear, load: true },
1722
+ depth: { target: depth, clear: 0, store: keepDepth, load: true },
1723
+ execute: (pass) => {
1724
+ if (!this.#layered() || !this.#layerColor)
1725
+ return;
1726
+ if (occl) {
1727
+ this.#encodeScene(pass, 1, 'blend', 1);
1728
+ this.#encodeScene(pass, 2, 'blend', 1);
1729
+ }
1730
+ else
1731
+ this.#encodeScene(pass, 0, 'blend', 1);
1732
+ },
1733
+ });
1734
+ }
1735
+ // ── The second transparent layer (ADR-0125) ───────────────────────────────────────────────
1736
+ /** Whether layer 1 draws: a material is in it, and the blended classes draw after the opaque copy. */
1737
+ #layered() {
1738
+ return this.#materials.layered > 0 && this.#splitBlend() && !!this.#sceneColor;
1739
+ }
1740
+ /** The layer copy (the opaque copy's size and levels) and its lighting uniform; false until they exist. */
1741
+ #ensureLayerCopy(width, height) {
1742
+ const src = this.#sceneColor;
1743
+ if (!src || src.width !== width || src.height !== height)
1744
+ return false;
1745
+ if (!this.#layerColor || this.#layerColor.width !== width || this.#layerColor.height !== height) {
1746
+ this.#layerColor?.destroy();
1747
+ this.#layerColor = this.device.createTexture({ label: 'yali:scene-color:layer', size: [width, height], mipLevelCount: src.mipLevelCount, format: 'rgba16float', usage: src.usage });
1748
+ this.#layerMipViews = [];
1749
+ this.#layerMipGroups = [];
1750
+ this.#layerGroups = [];
1751
+ this.#lighting2 ??= this.device.createBuffer({ label: 'yali:lighting:layer', size: 192, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
1752
+ this.#lit[0] = Number.NaN; // the second lighting uniform is written with the first
1753
+ this.#bundles = null;
1754
+ }
1755
+ return true;
1756
+ }
1757
+ /** The layer copy's levels below the first (as `#encodeMips` the opaque copy's). */
1758
+ #encodeLayerMips(encoder) {
1759
+ const t = this.#layerColor;
1760
+ if (!t || !this.#mips || t.mipLevelCount < 2)
1761
+ return;
1762
+ if (this.#layerMipViews.length !== t.mipLevelCount) {
1763
+ this.#layerMipViews = Array.from({ length: t.mipLevelCount }, (_, l) => t.createView({ baseMipLevel: l, mipLevelCount: 1 }));
1764
+ this.#layerMipGroups = this.#layerMipViews.slice(0, -1).map((view, l) => this.device.createBindGroup({ label: `yali:scene-color:layer:mip${l}`, layout: this.#mips.getBindGroupLayout(0), entries: [{ binding: 0, resource: view }] }));
1765
+ }
1766
+ for (let l = 1; l < t.mipLevelCount; l++) {
1767
+ const pass = encoder.beginRenderPass({ label: 'scene.copyLayer.mips', colorAttachments: [{ view: this.#layerMipViews[l], loadOp: 'clear', storeOp: 'store', clearValue: { r: 0, g: 0, b: 0, a: 0 } }] });
1768
+ pass.setPipeline(this.#mips);
1769
+ pass.setBindGroup(0, this.#layerMipGroups[l - 1]);
1770
+ pass.draw(3);
1771
+ pass.end();
1772
+ }
1773
+ }
1774
+ /** Class `c`'s draw in layer 1's pass: its pipeline with the layer uniform and the layer copy bound. */
1775
+ #layerDraw(c) {
1776
+ const ps = this.#classPipelines[c];
1777
+ if (!ps || !this.#groupEntries || !this.#lighting2 || !this.#layerColor)
1778
+ return null;
1779
+ if (this.#layerGroupsEpoch !== this.#groupsEpoch) {
1780
+ this.#layerGroupsEpoch = this.#groupsEpoch;
1781
+ this.#layerGroups = [];
1782
+ }
1783
+ const level = this.#activeLevel % this.#baseLevels;
1784
+ let gs = this.#layerGroups[c];
1785
+ if (!gs || gs.length !== ps.length) {
1786
+ const view = this.#layerColor.createView();
1787
+ const entries = entriesFor(this.#groupEntries, c).map((e) => (e.binding === 7 ? { binding: 7, resource: { buffer: this.#lighting2 } } : e.binding === 19 ? { binding: 19, resource: view } : e));
1788
+ gs = ps.map((p, i) => this.device.createBindGroup({ label: `yali:scene:draw:layer:c${c}:${i}`, layout: p.getBindGroupLayout(0), entries }));
1789
+ this.#layerGroups[c] = gs;
1790
+ }
1791
+ return [ps[level], gs[level]];
1792
+ }
1793
+ #layerColor = null;
1794
+ #lighting2 = null;
1795
+ #layerMipViews = [];
1796
+ #layerMipGroups = [];
1797
+ #layerGroups = [];
1798
+ #layerGroupsEpoch = -1;
1799
+ #addOcclusionPasses(graph, color, depth, keepDepth, occl) {
1800
+ // Occlusion's second pass (ADR-0095): the pyramid from pass 1's depth, the held-back
1801
+ // instances against it, and what turned out visible drawn over pass 1.
1802
+ occl.addPass(graph, depth);
1803
+ graph.addPass({
1804
+ name: 'scene.recheck',
1805
+ kind: 'compute',
1806
+ reads: ['scene.hzb'],
1807
+ writes: ['scene.visible2'],
1808
+ execute: (pass) => {
1809
+ if (!this.#ready() || !this.#recheckGroup)
1810
+ return;
1811
+ if (occl.version !== this.#occlVersion)
1812
+ this.#bindGroups();
1813
+ pass.setPipeline(occl.recheckPipeline);
1814
+ pass.setBindGroup(0, this.#recheckGroup);
1815
+ pass.dispatchWorkgroups(Math.ceil(this.count / 64));
1816
+ },
1817
+ });
1818
+ const split = this.#splitBlend();
1819
+ // Under occlusion pass 2's clusters and those pass 1 held back, against this pyramid (ADR-0109).
1820
+ this.clusters?.addCull(graph, 'scene.visible2', 2);
1821
+ graph.addPass({
1822
+ name: 'scene.draw2',
1823
+ kind: 'render',
1824
+ reads: this.clusters ? ['scene.visible2', 'scene.clusterList2'] : ['scene.visible2'],
1825
+ ...(split ? { writes: ['scene.opaque2'] } : {}),
1826
+ color: { target: color, clear: this.#clear, load: true },
1827
+ depth: { target: depth, clear: 0, store: keepDepth || split, load: true },
1828
+ execute: (pass) => {
1829
+ this.#encodeScene(pass, 2, split ? 'opaque' : 'all');
1830
+ this.clusters?.encodeDraw(pass, 2);
1831
+ if (!split)
1832
+ for (let i = 0; i < this.overlays.length; i++)
1833
+ this.overlays[i](pass);
1834
+ },
1835
+ });
1836
+ }
1837
+ /**
1838
+ * Drawn at the end of `scene.draw`, after every draw class, into the same colour and depth
1839
+ * targets (debug lines, ADR-0087). Each gets the pass encoder.
1840
+ */
1841
+ overlays = [];
1842
+ #encodeScene(pass, set = 0, part = 'all', layer = 0) {
1843
+ if (set < 2 && part !== 'blend')
1844
+ this.stats.draws = 0;
1845
+ if (!this.#ready())
1846
+ return;
1847
+ if (this.cull === 'cpu')
1848
+ return this.#encodeCulled(pass);
1849
+ if (part !== 'blend')
1850
+ this.stats.draws += this.#drawList.length / 3;
1851
+ if (!this.useBundles)
1852
+ return this.#encodeDraws(pass, set, part, layer);
1853
+ // The clusters covering a new mesh change what the recorded draws skip.
1854
+ if (this.clusters && this.clusters.version !== this.#clusterVersion) {
1855
+ this.#clusterVersion = this.clusters.version;
1856
+ this.#bundles = null;
1857
+ }
1858
+ if (!this.#bundles)
1859
+ this.#bundles = new Map();
1860
+ const key = `${set}:${part}:${layer}`;
1861
+ let b = this.#bundles.get(key);
1862
+ if (!b) {
1863
+ const enc = this.device.createRenderBundleEncoder({ label: `yali:scene:bundle:${key}`, colorFormats: [this.#format], depthStencilFormat: 'depth32float' });
1864
+ this.#encodeDraws(enc, set, part, layer);
1865
+ b = [enc.finish()];
1866
+ this.#bundles.set(key, b);
1867
+ }
1868
+ pass.executeBundles(b);
1869
+ }
1870
+ /** The colour format the scene draws into (an overlay's pipeline must match it). */
1871
+ get format() {
1872
+ return this.#format;
1873
+ }
1874
+ /** The rigid vertex pool (`MESH_VERTEX_FLOATS` f32 per vertex; STORAGE): GPU deformables write their meshes' vertices into it (ADR-0101). */
1875
+ get vertexPool() {
1876
+ // Whoever takes the pool writes it on the GPU (the cloth, ADR-0101): motion keeps last frame's copy.
1877
+ this.#poolDynamic = true;
1878
+ return this.#vertexBuf;
1879
+ }
1880
+ /** The index pool (u32; COPY_DST): a torn cloth copies its triangles over its mesh's (ADR-0104). */
1881
+ get indexPool() {
1882
+ return this.#indexBuf;
1883
+ }
1884
+ /** Records this frame's staged instance copy and the indirect-argument reset (call first on the frame's encoder). */
1885
+ recordUpload(encoder) {
1886
+ if (this.#instances)
1887
+ this.#staging?.record(encoder, this.#instances);
1888
+ this.#occl?.record(encoder);
1889
+ if (this.#argsBytes > 0 && this.#argsTemplate && this.#args)
1890
+ encoder.copyBufferToBuffer(this.#argsTemplate, 0, this.#args, 0, this.#argsBytes);
1891
+ }
1892
+ /** Call after `queue.submit`: recycles the staging buffers. */
1893
+ afterSubmit() {
1894
+ this.#staging?.recycle();
1895
+ this.#occl?.afterSubmit();
1896
+ }
1897
+ /** Call after recording: every 30 frames, copies the draw args out to count the drawn instances. */
1898
+ afterPasses(encoder) {
1899
+ this.#frame++;
1900
+ if (this.cull === 'cpu')
1901
+ return false;
1902
+ if (this.#readbackBusy || this.#frame % 30 !== 0 || !this.#readback || !this.#args || this.#argsBytes === 0)
1903
+ return false;
1904
+ if (this.#readback.size < this.#argsBytes)
1905
+ return false;
1906
+ encoder.copyBufferToBuffer(this.#args, 0, this.#readback, 0, this.#argsBytes);
1907
+ this.#readbackBusy = true;
1908
+ this.#readbackLayout = this.stats.layouts;
1909
+ return true;
1910
+ }
1911
+ /** The layout the pending readback was copied under (a later layout's slots differ). */
1912
+ #readbackLayout = 0;
1913
+ /** Call after submit when `afterPasses` returned true. */
1914
+ collect() {
1915
+ const rb = this.#readback;
1916
+ const bytes = this.#argsBytes;
1917
+ const layout = this.#readbackLayout;
1918
+ void rb.mapAsync(GPUMapMode.READ, 0, bytes).then(() => {
1919
+ const a = new Uint32Array(rb.getMappedRange(0, bytes));
1920
+ let n = 0;
1921
+ // Counts from an older layout are kept out of the per-slot table: its slots meant other LODs.
1922
+ const bySlot = layout === this.stats.layouts ? this.#drawnBySlot : null;
1923
+ // Under occlusion, the view's two sets (set 0 holds the shadow casters).
1924
+ const slots = this.#occl ? this.#slots : 0;
1925
+ if (bySlot)
1926
+ bySlot.fill(0);
1927
+ for (let s = 0, i = 1; i < a.length; s++, i += ARGS_WORDS) {
1928
+ if (slots && s < slots)
1929
+ continue;
1930
+ n += a[i];
1931
+ const slot = slots ? s % slots : s;
1932
+ if (bySlot && slot < bySlot.length)
1933
+ bySlot[slot] += a[i];
1934
+ }
1935
+ this.visible = n;
1936
+ rb.unmap();
1937
+ this.#readbackBusy = false;
1938
+ }, () => {
1939
+ this.#readbackBusy = false;
1940
+ });
1941
+ }
1942
+ /** Instances drawn at each LOD (index 0 = full detail), from the last readback. A diagnostic: it allocates. */
1943
+ drawnByLod() {
1944
+ if (this.cull === 'cpu') {
1945
+ let last = 0;
1946
+ for (let l = 0; l < 8; l++)
1947
+ if (this.#cpuByLod[l])
1948
+ last = l + 1;
1949
+ return Array.from(this.#cpuByLod.subarray(0, Math.max(1, last)));
1950
+ }
1951
+ const out = [];
1952
+ const bySlot = this.#drawnBySlot;
1953
+ for (let s = 0; s < bySlot.length && s < this.#slotLod.length; s++)
1954
+ out[this.#slotLod[s]] = (out[this.#slotLod[s]] ?? 0) + bySlot[s];
1955
+ return Array.from(out, (v) => v ?? 0);
1956
+ }
1957
+ /** Bytes of the per-instance and layout GPU buffers, for the memory counter. */
1958
+ get gpuBytes() {
1959
+ if (this.#destroyed)
1960
+ return 0;
1961
+ let n = 0;
1962
+ for (const b of this.#owned())
1963
+ n += b?.size ?? 0;
1964
+ return n + (this.#staging?.bytes ?? 0);
1965
+ }
1966
+ #owned() {
1967
+ return [this.#camera, this.#params, this.#materialBuf, this.#materialExtBuf, this.#lightingBuf, this.#vertexBuf, this.#indexBuf, this.#instances, this.#meshInfo, this.#argsTemplate, this.#args, this.#ranges, this.#readback, this.#cpuBuf, this.#skinnedVertexBuf, this.#skinBuf];
1968
+ }
1969
+ /** GPU buffers this renderer currently owns (for the dispose leak check). */
1970
+ get liveBuffers() {
1971
+ return this.#destroyed ? 0 : this.#owned().filter(Boolean).length + (this.#staging?.liveBuffers ?? 0);
1972
+ }
1973
+ /** Destroys every GPU buffer this renderer owns. */
1974
+ destroy() {
1975
+ if (this.#destroyed)
1976
+ return;
1977
+ this.#destroyed = true;
1978
+ for (const b of this.#owned())
1979
+ b?.destroy();
1980
+ this.#occl?.destroy();
1981
+ this.#sceneColor?.destroy();
1982
+ this.#noSceneColor.destroy();
1983
+ this.#prevInstances?.destroy();
1984
+ this.#prevPalette?.destroy();
1985
+ this.#layerColor?.destroy();
1986
+ this.#lighting2?.destroy();
1987
+ this.#prevPool?.destroy();
1988
+ this.#prevSkinned?.destroy();
1989
+ this.#emptyPool?.destroy();
1990
+ this.#palette?.destroy();
1991
+ this.#staging?.destroy();
1992
+ }
1993
+ }
1994
+ function fnv1a(s) {
1995
+ let h = 0x811c9dc5;
1996
+ for (let i = 0; i < s.length; i++) {
1997
+ h ^= s.charCodeAt(i);
1998
+ h = Math.imul(h, 0x01000193);
1999
+ }
2000
+ return (h >>> 0).toString(16).padStart(8, '0');
2001
+ }
2002
+ function nextPow2(n) {
2003
+ return n <= 1 ? 1 : 2 ** Math.ceil(Math.log2(n));
2004
+ }
2005
+ //# sourceMappingURL=scene.js.map