@woosh/meep-engine 3.31.0 → 3.33.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 (117) hide show
  1. package/build/bundle-worker-terrain.js +1 -1
  2. package/package.json +1 -1
  3. package/src/core/collection/table/RowFirstTable.d.ts +8 -0
  4. package/src/core/collection/table/RowFirstTable.d.ts.map +1 -1
  5. package/src/core/collection/table/RowFirstTable.js +40 -11
  6. package/src/engine/graphics/ecs/sprite/Sprite.d.ts +3 -0
  7. package/src/engine/graphics/ecs/sprite/Sprite.d.ts.map +1 -1
  8. package/src/engine/graphics/ecs/sprite/Sprite.js +3 -0
  9. package/src/engine/graphics/ecs/sprite/SpriteSystemPE.d.ts +3 -0
  10. package/src/engine/graphics/ecs/sprite/SpriteSystemPE.d.ts.map +1 -1
  11. package/src/engine/graphics/ecs/sprite/SpriteSystemPE.js +3 -0
  12. package/src/engine/graphics3/DebugDrawSystem.d.ts.map +1 -1
  13. package/src/engine/graphics3/DebugDrawSystem.js +6 -2
  14. package/src/engine/graphics3/PathDisplaySystem.d.ts.map +1 -1
  15. package/src/engine/graphics3/PathDisplaySystem.js +311 -307
  16. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  17. package/src/shade/RENDERER_CONTRACT.md +15 -2
  18. package/src/shade/device/IMMEDIATE_DATA_PLAN_2026_08_28.md +5 -0
  19. package/src/shade/device/ShadeGPUCommandContext.d.ts +15 -2
  20. package/src/shade/device/ShadeGPUCommandContext.d.ts.map +1 -1
  21. package/src/shade/device/ShadeGPUCommandContext.js +154 -25
  22. package/src/shade/device/mock/SoftwareGPURenderPassEncoder.d.ts +20 -3
  23. package/src/shade/device/mock/SoftwareGPURenderPassEncoder.d.ts.map +1 -1
  24. package/src/shade/device/mock/SoftwareGPURenderPassEncoder.js +21 -3
  25. package/src/shade/renderer/GraphicsContext.d.ts +20 -1
  26. package/src/shade/renderer/GraphicsContext.d.ts.map +1 -1
  27. package/src/shade/renderer/GraphicsContext.js +105 -52
  28. package/src/shade/renderer/Renderer.d.ts +19 -4
  29. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  30. package/src/shade/renderer/Renderer.js +113 -2
  31. package/src/shade/renderer/animation/GPUAnimationManager.d.ts +10 -7
  32. package/src/shade/renderer/animation/GPUAnimationManager.d.ts.map +1 -1
  33. package/src/shade/renderer/animation/GPUAnimationManager.js +147 -45
  34. package/src/shade/renderer/buffer/GPUBufferReadbackAllocator.d.ts +6 -5
  35. package/src/shade/renderer/buffer/GPUBufferReadbackAllocator.d.ts.map +1 -1
  36. package/src/shade/renderer/buffer/GPUBufferReadbackAllocator.js +241 -240
  37. package/src/shade/renderer/buffer/GPUBufferStagingAllocator.d.ts +62 -2
  38. package/src/shade/renderer/buffer/GPUBufferStagingAllocator.d.ts.map +1 -1
  39. package/src/shade/renderer/buffer/GPUBufferStagingAllocator.js +306 -38
  40. package/src/shade/renderer/buffer/GPUBufferUploadAllocator.d.ts +60 -0
  41. package/src/shade/renderer/buffer/GPUBufferUploadAllocator.d.ts.map +1 -0
  42. package/src/shade/renderer/buffer/GPUBufferUploadAllocator.js +202 -0
  43. package/src/shade/renderer/buffer/GPUUploadBlock.d.ts +68 -0
  44. package/src/shade/renderer/buffer/GPUUploadBlock.d.ts.map +1 -0
  45. package/src/shade/renderer/buffer/GPUUploadBlock.js +97 -0
  46. package/src/shade/renderer/dynamic/DynamicMeshAVBOITChannel.d.ts +2 -1
  47. package/src/shade/renderer/dynamic/DynamicMeshAVBOITChannel.d.ts.map +1 -1
  48. package/src/shade/renderer/dynamic/DynamicMeshAVBOITChannel.js +19 -17
  49. package/src/shade/renderer/dynamic/GPUDynamicMeshDrawList.d.ts +70 -0
  50. package/src/shade/renderer/dynamic/GPUDynamicMeshDrawList.d.ts.map +1 -0
  51. package/src/shade/renderer/dynamic/GPUDynamicMeshDrawList.js +257 -0
  52. package/src/shade/renderer/dynamic/GPUDynamicMeshRenderer.d.ts +20 -7
  53. package/src/shade/renderer/dynamic/GPUDynamicMeshRenderer.d.ts.map +1 -1
  54. package/src/shade/renderer/dynamic/GPUDynamicMeshRenderer.js +39 -41
  55. package/src/shade/renderer/dynamic/GPUDynamicMeshResidency.d.ts +6 -20
  56. package/src/shade/renderer/dynamic/GPUDynamicMeshResidency.d.ts.map +1 -1
  57. package/src/shade/renderer/dynamic/GPUDynamicMeshResidency.js +42 -36
  58. package/src/shade/renderer/dynamic/chunk_dynamic_mesh_avboit_vertex.d.ts +2 -1
  59. package/src/shade/renderer/dynamic/chunk_dynamic_mesh_avboit_vertex.d.ts.map +1 -1
  60. package/src/shade/renderer/dynamic/chunk_dynamic_mesh_avboit_vertex.js +4 -3
  61. package/src/shade/renderer/dynamic/chunk_dynamic_mesh_vertex.d.ts +4 -3
  62. package/src/shade/renderer/dynamic/chunk_dynamic_mesh_vertex.d.ts.map +1 -1
  63. package/src/shade/renderer/dynamic/chunk_dynamic_mesh_vertex.js +7 -4
  64. package/src/shade/renderer/dynamic/graph_dynamic_mesh_avboit.d.ts +14 -15
  65. package/src/shade/renderer/dynamic/graph_dynamic_mesh_avboit.d.ts.map +1 -1
  66. package/src/shade/renderer/dynamic/graph_dynamic_mesh_avboit.js +49 -83
  67. package/src/shade/renderer/dynamic/shader_dynamic_mesh.d.ts +19 -3
  68. package/src/shade/renderer/dynamic/shader_dynamic_mesh.d.ts.map +1 -1
  69. package/src/shade/renderer/dynamic/shader_dynamic_mesh.js +26 -6
  70. package/src/shade/renderer/dynamic/shader_dynamic_mesh_avboit_draw.d.ts +2 -1
  71. package/src/shade/renderer/dynamic/shader_dynamic_mesh_avboit_draw.d.ts.map +1 -1
  72. package/src/shade/renderer/dynamic/shader_dynamic_mesh_avboit_draw.js +12 -6
  73. package/src/shade/renderer/dynamic/shader_dynamic_mesh_avboit_splat.d.ts +3 -2
  74. package/src/shade/renderer/dynamic/shader_dynamic_mesh_avboit_splat.d.ts.map +1 -1
  75. package/src/shade/renderer/dynamic/shader_dynamic_mesh_avboit_splat.js +21 -13
  76. package/src/shade/renderer/geometry/meshlet/build/meshoptimizer/geometry_to_meshlets_meshoptimizer.d.ts +5 -0
  77. package/src/shade/renderer/geometry/meshlet/build/meshoptimizer/geometry_to_meshlets_meshoptimizer.d.ts.map +1 -1
  78. package/src/shade/renderer/geometry/meshlet/build/meshoptimizer/geometry_to_meshlets_meshoptimizer.js +19 -0
  79. package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts +1 -1
  80. package/src/shade/renderer/postprocess/nss/NSS.d.ts +1 -1
  81. package/src/shade/renderer/rasterize/bucket/rasterization_has_opaque_bucket.d.ts +3 -1
  82. package/src/shade/renderer/rasterize/bucket/rasterization_has_opaque_bucket.d.ts.map +1 -1
  83. package/src/shade/renderer/rasterize/bucket/rasterization_has_opaque_bucket.js +3 -1
  84. package/src/shade/renderer/rasterize/expand/bucket/mesh/shader_instances_rasterization_bucket_sort.d.ts.map +1 -1
  85. package/src/shade/renderer/rasterize/expand/bucket/mesh/shader_instances_rasterization_bucket_sort.js +134 -88
  86. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_rasterization_bucket_sort.d.ts.map +1 -1
  87. package/src/shade/renderer/rasterize/expand/bucket/meshlet/shader_meshlets_rasterization_bucket_sort.js +57 -26
  88. package/src/shade/renderer/rasterize/fast/graph_rasterize_scene_fast.d.ts.map +1 -1
  89. package/src/shade/renderer/rasterize/fast/graph_rasterize_scene_fast.js +28 -20
  90. package/src/shade/renderer/rasterize/graph_clear_raster_targets.d.ts +38 -0
  91. package/src/shade/renderer/rasterize/graph_clear_raster_targets.d.ts.map +1 -0
  92. package/src/shade/renderer/rasterize/graph_clear_raster_targets.js +105 -0
  93. package/src/shade/renderer/rasterize/native/avboit/AVBOIT_LEDGER.md +20 -0
  94. package/src/shade/renderer/rasterize/standard/graph_rasterize_partial_opaque.d.ts +1 -1
  95. package/src/shade/renderer/rasterize/standard/graph_rasterize_partial_opaque.d.ts.map +1 -1
  96. package/src/shade/renderer/rasterize/standard/graph_rasterize_partial_opaque.js +21 -1
  97. package/src/shade/renderer/rasterize/standard/graph_rasterize_scene.d.ts.map +1 -1
  98. package/src/shade/renderer/rasterize/standard/graph_rasterize_scene.js +12 -20
  99. package/src/shade/renderer/shader/graph/graph_import_typed_buffer.d.ts +2 -1
  100. package/src/shade/renderer/shader/graph/graph_import_typed_buffer.d.ts.map +1 -1
  101. package/src/shade/renderer/shader/graph/graph_import_typed_buffer.js +15 -28
  102. package/src/shade/renderer/shader/type/serde/write_gpu_typed_buffer.d.ts.map +1 -1
  103. package/src/shade/renderer/shader/type/serde/write_gpu_typed_buffer.js +8 -1
  104. package/src/core/geom/3d/atlas/atlas_bench_lib.d.ts +0 -73
  105. package/src/core/geom/3d/atlas/atlas_bench_lib.d.ts.map +0 -1
  106. package/src/engine/graphics3/terrain/GPUTerrainSplatRenderer.d.ts +0 -59
  107. package/src/engine/graphics3/terrain/GPUTerrainSplatRenderer.d.ts.map +0 -1
  108. package/src/engine/graphics3/terrain/pack_terrain_row_table.d.ts +0 -22
  109. package/src/engine/graphics3/terrain/pack_terrain_row_table.d.ts.map +0 -1
  110. package/src/engine/graphics3/terrain/shader_terrain_splat.d.ts +0 -26
  111. package/src/engine/graphics3/terrain/shader_terrain_splat.d.ts.map +0 -1
  112. package/src/shade/renderer/animation/ANIMATION_BOUND_TRACK_GROUP_STRUCT.d.ts +0 -25
  113. package/src/shade/renderer/animation/ANIMATION_BOUND_TRACK_GROUP_STRUCT.d.ts.map +0 -1
  114. package/src/shade/renderer/animation/ANIMATION_KEYFRAME_BLOCK_STRUCT.d.ts +0 -26
  115. package/src/shade/renderer/animation/ANIMATION_KEYFRAME_BLOCK_STRUCT.d.ts.map +0 -1
  116. package/src/shade/renderer/animation/ANIMATION_SKIN_JOINT_BLOCK_STRUCT.d.ts +0 -29
  117. package/src/shade/renderer/animation/ANIMATION_SKIN_JOINT_BLOCK_STRUCT.d.ts.map +0 -1
@@ -1,5 +1,6 @@
1
1
  import { assert } from "../../../core/assert.js";
2
- import { binarySearchHighIndex } from "../../../core/collection/array/binarySearchHighIndex.js";
2
+ import { round_up } from "../../../core/math/round_up.js";
3
+ import { MEGABYTE } from "../../../core/science/units/memory/MEGABYTE.js";
3
4
 
4
5
  /**
5
6
  *
@@ -8,11 +9,140 @@ import { binarySearchHighIndex } from "../../../core/collection/array/binarySear
8
9
  let id_count = 0;
9
10
 
10
11
  /**
11
- * Provides staging buffers for pushing data from CPU to GPU.
12
+ * A pooled buffer is handed out only for a request it is at most this many times the size of.
12
13
  *
14
+ * A buffer is mapped whole each time it comes back, and a mapping is paid for by the size of the
15
+ * buffer, not by what was written into it. With no bound, eight idle 16 MiB buffers left by a burst
16
+ * took the 80 KiB and 40 KiB draw-constant writes of a 1,500-DynamicMesh frame from then on: 32 MiB
17
+ * mapped a render for 120 KiB written, and 178–202 renders/s against 244–256 with no burst. The same
18
+ * 128 MiB mapped and idle outside the pool ran 246–259, so the cost was the handing out, not the
19
+ * memory (RTX 4090, Chrome 154, paired in one page, 2026-09-27).
20
+ *
21
+ * @type {number}
22
+ */
23
+ const REUSE_LIMIT_SCALE = 2;
24
+
25
+ /**
26
+ * Sizes a new staging buffer can be made at within each doubling, evenly spaced: with 4, a buffer
27
+ * made for a request of more than 16 bytes is never more than a quarter larger than it.
28
+ *
29
+ * @type {number}
30
+ */
31
+ const SIZE_CLASSES_PER_DOUBLING = 4;
32
+
33
+ /**
34
+ * The size a new staging buffer for `bytes` is made at: the next of the
35
+ * {@link SIZE_CLASSES_PER_DOUBLING} sizes in the doubling `bytes` falls in.
36
+ *
37
+ * Made at the exact size, a write that grew a little every frame asked for a size no pooled buffer
38
+ * had, every frame, and got a new buffer every frame. Now one buffer serves it until it grows past a
39
+ * class.
40
+ *
41
+ * @param {number} bytes
42
+ * @returns {number} a multiple of 4, as a buffer mapped at creation must be
43
+ */
44
+ function size_class(bytes) {
45
+ if (bytes <= 16) {
46
+ return round_up(bytes, 4);
47
+ }
48
+
49
+ // the largest power of two not above `bytes`
50
+ let doubling = 16;
51
+
52
+ while (doubling * 2 <= bytes) {
53
+ doubling *= 2;
54
+ }
55
+
56
+ return round_up(bytes, doubling / SIZE_CLASSES_PER_DOUBLING);
57
+ }
58
+
59
+ /**
60
+ * Where the first buffer of at least `size` bytes is, or would go, in `buffers` sorted by size.
61
+ *
62
+ * Both ends of the pool use it: a request takes the buffer there, the smallest that holds it, and a
63
+ * buffer given back goes in there, ahead of its equals — so of buffers of one size, the one given
64
+ * back last goes out first. A search that landed anywhere among equals moved a steady writer to
65
+ * another buffer whenever their count changed, and every buffer it had touched was kept for a
66
+ * backstop more: a burst of 40 left 3 alive past the backstop where the writer needed 1.
67
+ *
68
+ * @param {GPUBuffer[]} buffers
69
+ * @param {number} size
70
+ * @returns {number}
71
+ */
72
+ function first_at_least(buffers, size) {
73
+ let low = 0;
74
+ let high = buffers.length;
75
+
76
+ while (low < high) {
77
+ const middle = (low + high) >>> 1;
78
+
79
+ if (buffers[middle].size < size) {
80
+ low = middle + 1;
81
+ } else {
82
+ high = middle;
83
+ }
84
+ }
85
+
86
+ return low;
87
+ }
88
+
89
+ /**
90
+ * Mapped `MAP_WRITE | COPY_SRC` buffers for pushing data from the CPU to the GPU, one per upload.
91
+ *
92
+ * A buffer comes out mapped; its holder fills it, unmaps it, copies out of it and gives it back, and
93
+ * the pool maps it again — a round trip through the GPU — before it can come out again. For bytes
94
+ * that is the cheaper road; for thousands of small writes a frame the round trips are the cost, which
95
+ * is why a {@link ShadeGPUCommandContext} brings only its large writes here (see
96
+ * `MAPPED_UPLOAD_THRESHOLD`).
97
+ *
98
+ * A buffer is made up to a quarter larger than asked, so a size that drifts keeps its buffer, and is
99
+ * handed out again only to requests at least half its size ({@link REUSE_LIMIT_SCALE}), so a burst's
100
+ * large buffers do not end up mapped whole for every smaller write after it. Of buffers of one size
101
+ * the one given back last goes out first, so a steady writer keeps one and the rest go idle. What
102
+ * sits idle is destroyed by {@link update}, as {@link TransientResourcePool#update} does for the
103
+ * frame's transients: without that, a burst's buffers stayed until the device went.
13
104
  */
14
105
  export class GPUBufferStagingAllocator {
15
106
 
107
+ // ----- reclaim tuning, as TransientResourcePool's -----
108
+
109
+ /**
110
+ * Idle bytes above which buffers idle for {@link param_over_budget_idle_frames} are destroyed.
111
+ * The same as the upload blocks' budget.
112
+ *
113
+ * @type {number}
114
+ */
115
+ param_soft_byte_budget = 16 * MEGABYTE;
116
+
117
+ /**
118
+ * Frames a buffer may sit idle while the pool is over its budget: long enough that a write made
119
+ * every few frames keeps its buffer, short enough that a burst's go within a second or so.
120
+ *
121
+ * @type {number}
122
+ */
123
+ param_over_budget_idle_frames = 60;
124
+
125
+ /**
126
+ * An idle buffer is destroyed after this many frames even with the pool under its budget.
127
+ *
128
+ * @type {number}
129
+ */
130
+ param_backstop_frames = 600;
131
+
132
+ /**
133
+ * Most buffers destroyed by one {@link update}.
134
+ *
135
+ * @type {number}
136
+ */
137
+ param_max_reclaim_per_frame = 8;
138
+
139
+ /**
140
+ * Most idle buffers one {@link update} looks at.
141
+ *
142
+ * @type {number}
143
+ */
144
+ param_scan_budget = 16;
145
+
16
146
  /**
17
147
  * @type {GPUDevice}
18
148
  */
@@ -24,6 +154,60 @@ export class GPUBufferStagingAllocator {
24
154
  */
25
155
  #ready = [];
26
156
 
157
+ /**
158
+ * Bytes in {@link #ready}.
159
+ *
160
+ * @type {number}
161
+ */
162
+ #ready_byte_size = 0;
163
+
164
+ /**
165
+ * Bytes in buffers handed out and not back in {@link #ready} yet: with their holder, or on their
166
+ * way back through a mapping. They are memory like any other, and a GPU running behind holds a
167
+ * frame's worth of them for every frame it is behind.
168
+ *
169
+ * @type {number}
170
+ */
171
+ #held_byte_size = 0;
172
+
173
+ /**
174
+ * Buffers handed out and not given back yet. Only these are taken back: a buffer given back
175
+ * twice, or one this pool never made, would otherwise go into {@link #ready} twice and come out
176
+ * to two holders at once.
177
+ *
178
+ * @type {Set<GPUBuffer>}
179
+ */
180
+ #held = new Set();
181
+
182
+ /**
183
+ * Set by {@link destroy}. A buffer whose mapping comes back after that is destroyed, not pooled —
184
+ * the pool it would go into is gone.
185
+ *
186
+ * @type {boolean}
187
+ */
188
+ #destroyed = false;
189
+
190
+ /**
191
+ * Frames counted by {@link increment_time}.
192
+ *
193
+ * @type {number}
194
+ */
195
+ #frame = 0;
196
+
197
+ /**
198
+ * The frame each buffer in {@link #ready} came back in: how long it has been idle.
199
+ *
200
+ * @type {WeakMap<GPUBuffer, number>}
201
+ */
202
+ #returned_at = new WeakMap();
203
+
204
+ /**
205
+ * Where in {@link #ready} the next {@link update} starts looking.
206
+ *
207
+ * @type {number}
208
+ */
209
+ #cursor = 0;
210
+
27
211
  /**
28
212
  *
29
213
  * @param {GPUDevice} device
@@ -35,17 +219,13 @@ export class GPUBufferStagingAllocator {
35
219
  }
36
220
 
37
221
  /**
222
+ * Bytes in every buffer this pool has made and not destroyed: free, handed out, or on the way
223
+ * back.
38
224
  *
39
225
  * @returns {number}
40
226
  */
41
227
  get gpu_memory_usage() {
42
- let r = 0;
43
-
44
- for (const b of this.#ready) {
45
- r += b.size;
46
- }
47
-
48
- return r;
228
+ return this.#ready_byte_size + this.#held_byte_size;
49
229
  }
50
230
 
51
231
 
@@ -57,48 +237,45 @@ export class GPUBufferStagingAllocator {
57
237
  get(min_size_bytes) {
58
238
  assert.isNonNegativeInteger(min_size_bytes, 'min_size_bytes');
59
239
 
60
- const index = this.#get_position(min_size_bytes);
61
-
62
240
  const buffers = this.#ready;
63
241
 
242
+ // the smallest ready buffer that holds the request, unless even that one is too large for it
243
+ const index = first_at_least(buffers, min_size_bytes);
244
+
64
245
  let result;
65
- if (index !== -1) {
246
+
247
+ if (index < buffers.length && buffers[index].size <= min_size_bytes * REUSE_LIMIT_SCALE) {
66
248
  result = buffers.splice(index, 1)[0];
67
- }
68
249
 
69
- if (result === undefined) {
250
+ this.#ready_byte_size -= result.size;
251
+ } else {
252
+ const device = this.#device;
253
+
254
+ // Never rounded past the device's limit for a request that is within it, which would
255
+ // refuse a write the exact size takes; and a whole number of words below the limit, which
256
+ // the spec does not require to be one, as a buffer mapped at creation must be.
257
+ const limit = device.limits.maxBufferSize;
258
+ const largest = limit - limit % 4;
70
259
 
71
- // TODO align buffer size?
72
260
  const id = id_count++;
73
261
 
74
- result = this.#device.createBuffer({
262
+ result = device.createBuffer({
75
263
  label: `staging ${id}`,
76
- size: min_size_bytes,
264
+ size: Math.max(min_size_bytes, Math.min(size_class(min_size_bytes), largest)),
77
265
  usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.MAP_WRITE,
78
266
  mappedAtCreation: true,
79
267
  });
80
-
81
268
  }
82
269
 
270
+ this.#held_byte_size += result.size;
271
+ this.#held.add(result);
272
+
83
273
  assert.greaterThanOrEqual(result.size, min_size_bytes, 'result.size < min_size_bytes');
84
274
  assert.equal(result.mapState, "mapped", 'result.mapState !== "mapped"');
85
275
 
86
276
  return result;
87
277
  }
88
278
 
89
- /**
90
- *
91
- * @param {number} size
92
- * @return {number}
93
- */
94
- #get_position(size) {
95
- function by_size(a, b) {
96
- return a.size - b.size;
97
- }
98
-
99
- return binarySearchHighIndex(this.#ready, { size }, by_size);
100
- }
101
-
102
279
  /**
103
280
  *
104
281
  * @param {GPUBuffer} buffer
@@ -109,42 +286,133 @@ export class GPUBufferStagingAllocator {
109
286
  assert.equal(buffer.mapState, "mapped", 'buffer.mapState !== "mapped"');
110
287
  assert.equal(buffer.usage, GPUBufferUsage.COPY_SRC | GPUBufferUsage.MAP_WRITE, 'buffer.usage !== GPUBufferUsage.COPY_SRC | GPUBufferUsage.MAP_WRITE');
111
288
 
112
- const index = this.#get_position(buffer.size);
289
+ // ahead of its equals: the next request for this size takes it
290
+ const index = first_at_least(this.#ready, buffer.size);
113
291
 
114
292
  this.#ready.splice(index, 0, buffer);
115
293
 
294
+ this.#ready_byte_size += buffer.size;
295
+
116
296
  return true;
117
297
  }
118
298
 
119
299
  /**
120
300
  *
121
301
  * @param {GPUBuffer} buffer
302
+ * @returns {boolean} whether it was taken back: false for a buffer given back already, or one
303
+ * this pool did not hand out
122
304
  */
123
305
  release(buffer) {
124
- const state = buffer.mapState;
306
+ if (!this.#held.delete(buffer)) {
307
+ return false;
308
+ }
125
309
 
126
- // TODO have a cleanup mechanism to prevent memory leak
310
+ const state = buffer.mapState;
127
311
 
128
312
  if (state === "mapped") {
129
313
  // release directly
130
- this.#add_to_ready(buffer);
314
+ this.#take_back(buffer);
131
315
  } else if (state === "unmapped") {
132
316
  buffer.mapAsync(GPUMapMode.WRITE)
133
317
  .then(
134
- () => this.#add_to_ready(buffer),
135
- console.error
318
+ () => this.#take_back(buffer),
319
+ () => this.#give_up(buffer)
136
320
  );
137
321
  } else {
138
322
  throw new Error(`Invalid map state: ${state}`);
139
323
  }
324
+
325
+ return true;
326
+ }
327
+
328
+ /**
329
+ * A buffer back and mapped, to be handed out again.
330
+ *
331
+ * @param {GPUBuffer} buffer
332
+ */
333
+ #take_back(buffer) {
334
+ this.#held_byte_size -= buffer.size;
335
+
336
+ if (this.#destroyed) {
337
+ buffer.destroy();
338
+
339
+ return;
340
+ }
341
+
342
+ this.#add_to_ready(buffer);
343
+
344
+ this.#returned_at.set(buffer, this.#frame);
345
+ }
346
+
347
+ /**
348
+ * A mapping that failed — the buffer or its device is gone — brings nothing back to reuse. It
349
+ * used to be logged and dropped: never destroyed, so whatever was left of it waited for the
350
+ * garbage collector.
351
+ *
352
+ * @param {GPUBuffer} buffer
353
+ */
354
+ #give_up(buffer) {
355
+ this.#held_byte_size -= buffer.size;
356
+
357
+ buffer.destroy();
358
+ }
359
+
360
+ /**
361
+ * Advance the pool's frame clock; once a frame, with the other pools'.
362
+ */
363
+ increment_time() {
364
+ this.#frame++;
365
+ }
366
+
367
+ /**
368
+ * Once-a-frame housekeeping: destroy the buffers nothing has asked for in a while — idle past
369
+ * {@link param_backstop_frames}, or, while more than {@link param_soft_byte_budget} bytes sit
370
+ * idle, past {@link param_over_budget_idle_frames}. A bounded sweep from where the last one
371
+ * stopped, at most {@link param_scan_budget} buffers looked at and
372
+ * {@link param_max_reclaim_per_frame} destroyed, as {@link TransientResourcePool#update}.
373
+ */
374
+ update() {
375
+ const ready = this.#ready;
376
+
377
+ let count = ready.length;
378
+
379
+ let over_budget = this.#ready_byte_size - this.param_soft_byte_budget;
380
+ let reclaim_budget = this.param_max_reclaim_per_frame;
381
+
382
+ const sweep = Math.min(count, this.param_scan_budget);
383
+
384
+ for (let i = 0; i < sweep && count > 0 && reclaim_budget > 0; i++) {
385
+ const cursor = this.#cursor % count;
386
+ const buffer = ready[cursor];
387
+
388
+ const idle = this.#frame - this.#returned_at.get(buffer);
389
+
390
+ if (idle > this.param_backstop_frames || (over_budget > 0 && idle > this.param_over_budget_idle_frames)) {
391
+ ready.splice(cursor, 1);
392
+ count--;
393
+
394
+ this.#ready_byte_size -= buffer.size;
395
+ over_budget -= buffer.size;
396
+ reclaim_budget--;
397
+
398
+ buffer.destroy();
399
+
400
+ // the slot now holds the next buffer; the cursor stays where it is
401
+ } else {
402
+ this.#cursor = cursor + 1;
403
+ }
404
+ }
140
405
  }
141
406
 
142
407
  destroy() {
408
+ this.#destroyed = true;
409
+
143
410
  for (const b of this.#ready) {
144
411
  b.destroy();
145
412
  }
146
413
 
147
414
  // drop data
148
415
  this.#ready.splice(0, this.#ready.length);
416
+ this.#ready_byte_size = 0;
149
417
  }
150
- }
418
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Uploads of this many bytes or more go through a mapped staging buffer of their own
3
+ * ({@link GraphicsContext#buffer_allocator_staging}); smaller ones through upload blocks.
4
+ *
5
+ * The two ways cost differently. A mapping is a round trip per upload, which thousands of small
6
+ * uniforms a frame cannot afford: 1 KiB uploads moved 8–10 MiB/s mapped against 430–470 MiB/s
7
+ * through blocks. Bytes, though, travel better mapped — filled in place and copied once, where a
8
+ * block's are copied into it, sent with a queue write and copied again on the GPU — and 1 MiB
9
+ * uploads moved 3,350–3,560 MiB/s mapped against 1,480–1,640 through blocks. The two crossed between
10
+ * 24 and 32 KiB (RTX 4090, Chrome 154, 4 MiB per submission in uploads of one size, 2026-09-26).
11
+ *
12
+ * @type {number}
13
+ */
14
+ export const MAPPED_UPLOAD_THRESHOLD: number;
15
+ /**
16
+ * Size of every upload block. Everything that comes here is smaller than
17
+ * {@link MAPPED_UPLOAD_THRESHOLD}, so any empty block holds any write, and a frame's uniforms — a
18
+ * few thousand at the heavy end, tens of bytes each — fill one or two.
19
+ *
20
+ * @type {number}
21
+ */
22
+ export const UPLOAD_BLOCK_SIZE: number;
23
+ /**
24
+ * Upload memory for {@link ShadeGPUCommandContext}'s small writes: pooled {@link GPUUploadBlock}s,
25
+ * all {@link UPLOAD_BLOCK_SIZE} bytes, each a `COPY_SRC | COPY_DST` buffer paired with CPU memory of
26
+ * the same size.
27
+ *
28
+ * A block is a transient in {@link TransientResourcePool}'s sense and is reused as soon as it is
29
+ * given back, for the reason `ShadeGPUCommandContext#send_uploads` sets out. It is a pool of its own,
30
+ * not {@link GraphicsContext#buffer_allocator_main}, because the frame graph aliases that pool's
31
+ * buffers within a submission, and a queue write — which lands ahead of the whole submission — into
32
+ * an aliased buffer would overwrite what an earlier pass in it still needs. Nothing aliases a block.
33
+ */
34
+ export class GPUBufferUploadAllocator extends TransientResourcePool {
35
+ /**
36
+ * @param {GPUDevice} device
37
+ */
38
+ constructor(device: GPUDevice);
39
+ /**
40
+ * An empty block. Give it back with {@link release}.
41
+ *
42
+ * @returns {GPUUploadBlock}
43
+ */
44
+ get(): GPUUploadBlock;
45
+ /**
46
+ * @param {GPUUploadBlock} block
47
+ * @returns {boolean}
48
+ */
49
+ release(block: GPUUploadBlock): boolean;
50
+ find(): number;
51
+ compare(a: any, b: any): number;
52
+ sizeOf(block: any): any;
53
+ make(): GPUUploadBlock;
54
+ destroyResource(block: any): void;
55
+ afterAcquire(block: any): void;
56
+ #private;
57
+ }
58
+ import { TransientResourcePool } from "../pool/TransientResourcePool.js";
59
+ import { GPUUploadBlock } from "./GPUUploadBlock.js";
60
+ //# sourceMappingURL=GPUBufferUploadAllocator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GPUBufferUploadAllocator.d.ts","sourceRoot":"","sources":["../../../../../src/shade/renderer/buffer/GPUBufferUploadAllocator.js"],"names":[],"mappings":"AAOA;;;;;;;;;;;;GAYG;AACH,sCAFU,MAAM,CAEqC;AAErD;;;;;;GAMG;AACH,gCAFU,MAAM,CAEgC;AAShD;;;;;;;;;;GAUG;AACH;IAeI;;OAEG;IACH,+BAMC;IAED;;;;OAIG;IACH,OAFa,cAAc,CAI1B;IAkBD;;;OAGG;IACH,eAHW,cAAc,GACZ,OAAO,CAMnB;IAED,eAYC;IAED,gCAIC;IAED,wBAEC;IAED,uBAkDC;IAED,kCAEC;IAED,+BAEC;;CASJ;sCArMqC,kCAAkC;+BACzC,qBAAqB"}
@@ -0,0 +1,202 @@
1
+ import { assert } from "../../../core/assert.js";
2
+ import { KILOBYTE } from "../../../core/science/units/memory/KILOBYTE.js";
3
+ import { MEGABYTE } from "../../../core/science/units/memory/MEGABYTE.js";
4
+ import { warn_limited } from "../../util/warn_limited.js";
5
+ import { TransientResourcePool } from "../pool/TransientResourcePool.js";
6
+ import { GPUUploadBlock } from "./GPUUploadBlock.js";
7
+
8
+ /**
9
+ * Uploads of this many bytes or more go through a mapped staging buffer of their own
10
+ * ({@link GraphicsContext#buffer_allocator_staging}); smaller ones through upload blocks.
11
+ *
12
+ * The two ways cost differently. A mapping is a round trip per upload, which thousands of small
13
+ * uniforms a frame cannot afford: 1 KiB uploads moved 8–10 MiB/s mapped against 430–470 MiB/s
14
+ * through blocks. Bytes, though, travel better mapped — filled in place and copied once, where a
15
+ * block's are copied into it, sent with a queue write and copied again on the GPU — and 1 MiB
16
+ * uploads moved 3,350–3,560 MiB/s mapped against 1,480–1,640 through blocks. The two crossed between
17
+ * 24 and 32 KiB (RTX 4090, Chrome 154, 4 MiB per submission in uploads of one size, 2026-09-26).
18
+ *
19
+ * @type {number}
20
+ */
21
+ export const MAPPED_UPLOAD_THRESHOLD = 32 * KILOBYTE;
22
+
23
+ /**
24
+ * Size of every upload block. Everything that comes here is smaller than
25
+ * {@link MAPPED_UPLOAD_THRESHOLD}, so any empty block holds any write, and a frame's uniforms — a
26
+ * few thousand at the heavy end, tens of bytes each — fill one or two.
27
+ *
28
+ * @type {number}
29
+ */
30
+ export const UPLOAD_BLOCK_SIZE = 256 * KILOBYTE;
31
+
32
+ assert.greaterThan(UPLOAD_BLOCK_SIZE, MAPPED_UPLOAD_THRESHOLD, 'a block must hold the largest write routed to it');
33
+
34
+ /**
35
+ * @type {number}
36
+ */
37
+ let id_counter = 0;
38
+
39
+ /**
40
+ * Upload memory for {@link ShadeGPUCommandContext}'s small writes: pooled {@link GPUUploadBlock}s,
41
+ * all {@link UPLOAD_BLOCK_SIZE} bytes, each a `COPY_SRC | COPY_DST` buffer paired with CPU memory of
42
+ * the same size.
43
+ *
44
+ * A block is a transient in {@link TransientResourcePool}'s sense and is reused as soon as it is
45
+ * given back, for the reason `ShadeGPUCommandContext#send_uploads` sets out. It is a pool of its own,
46
+ * not {@link GraphicsContext#buffer_allocator_main}, because the frame graph aliases that pool's
47
+ * buffers within a submission, and a queue write — which lands ahead of the whole submission — into
48
+ * an aliased buffer would overwrite what an earlier pass in it still needs. Nothing aliases a block.
49
+ */
50
+ export class GPUBufferUploadAllocator extends TransientResourcePool {
51
+ /**
52
+ * Pooled bytes above which idle blocks are reclaimed. Counted once, but a block holds its size
53
+ * twice — on the GPU, and on the CPU for as long as it lives — so this is kept lower than the
54
+ * general pool's.
55
+ *
56
+ * @type {number}
57
+ */
58
+ param_soft_byte_budget = 16 * MEGABYTE;
59
+
60
+ /**
61
+ * @type {GPUDevice|undefined}
62
+ */
63
+ #device;
64
+
65
+ /**
66
+ * @param {GPUDevice} device
67
+ */
68
+ constructor(device) {
69
+ super();
70
+
71
+ assert.defined(device, 'device');
72
+
73
+ this.#device = device;
74
+ }
75
+
76
+ /**
77
+ * An empty block. Give it back with {@link release}.
78
+ *
79
+ * @returns {GPUUploadBlock}
80
+ */
81
+ get() {
82
+ return super.get(UPLOAD_BLOCK_SIZE);
83
+ }
84
+
85
+ /**
86
+ * When each pooled block was given back, counting releases. The pool keeps its blocks in this
87
+ * order and hands out the latest, so a frame that needs one block gets the same one every time
88
+ * and the rest go idle long enough to be reclaimed. With no order at all, the pool's sorted
89
+ * insert put each block given back in the middle and the one handed out came from the end: after
90
+ * a burst, a one-block frame cycled through three blocks for good, none ever idle.
91
+ *
92
+ * @type {WeakMap<GPUUploadBlock, number>}
93
+ */
94
+ #given_back = new WeakMap();
95
+
96
+ /**
97
+ * @type {number}
98
+ */
99
+ #release_count = 0;
100
+
101
+ /**
102
+ * @param {GPUUploadBlock} block
103
+ * @returns {boolean}
104
+ */
105
+ release(block) {
106
+ this.#given_back.set(block, ++this.#release_count);
107
+
108
+ return super.release(block);
109
+ }
110
+
111
+ find() {
112
+ const records = this.records;
113
+
114
+ // the latest given back that the device did not refuse: a refused block is never handed out
115
+ // again, and sits idle until update() reclaims it like any other
116
+ for (let i = records.length - 1; i >= 0; i--) {
117
+ if (!records[i].resource.refused) {
118
+ return i;
119
+ }
120
+ }
121
+
122
+ return -1;
123
+ }
124
+
125
+ compare(a, b) {
126
+ const given_back = this.#given_back;
127
+
128
+ return given_back.get(a.resource) - given_back.get(b.resource);
129
+ }
130
+
131
+ sizeOf(block) {
132
+ return block.size;
133
+ }
134
+
135
+ make() {
136
+ // the CPU half first: it is the one that can throw, and a buffer made before it would leak
137
+ const data = new ArrayBuffer(UPLOAD_BLOCK_SIZE);
138
+
139
+ const device = this.#device;
140
+
141
+ /*
142
+ WebGPU does not throw for a buffer it cannot back. It reports the failure through the error
143
+ scopes, later, and hands back a buffer that fails everything it is used in — here, the whole
144
+ submission of every context that takes the block. The scopes are how the block finds out,
145
+ so the pool can stop handing it out; they also keep the error from reaching the page's
146
+ uncapturederror handler, so it is reported here instead — a few times, not once a frame
147
+ for as long as the device stays out of memory.
148
+ */
149
+
150
+ /**
151
+ * Assigned before either scope can answer: they answer on a later turn.
152
+ * @type {GPUUploadBlock|null}
153
+ */
154
+ let block = null;
155
+
156
+ const on_scope = error => {
157
+ if (error !== null && block !== null && !block.refused) {
158
+ block.refused = true;
159
+
160
+ warn_limited(`GPUBufferUploadAllocator: an upload block could not be created and will not be used again: ${error.message}`, 8);
161
+ }
162
+ };
163
+
164
+ device.pushErrorScope('out-of-memory');
165
+ device.pushErrorScope('validation');
166
+
167
+ let buffer;
168
+
169
+ try {
170
+ buffer = device.createBuffer({
171
+ label: `upload ${id_counter++}`,
172
+ size: UPLOAD_BLOCK_SIZE,
173
+ usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
174
+ });
175
+ } finally {
176
+ // popped whatever happens, innermost first: a scope left open would take every error
177
+ // the device raised from then on, for anyone
178
+ device.popErrorScope().then(on_scope, console.error);
179
+ device.popErrorScope().then(on_scope, console.error);
180
+ }
181
+
182
+ block = new GPUUploadBlock(buffer, data);
183
+
184
+ return block;
185
+ }
186
+
187
+ destroyResource(block) {
188
+ block.buffer.destroy();
189
+ }
190
+
191
+ afterAcquire(block) {
192
+ block.used = 0;
193
+ }
194
+
195
+ destroy() {
196
+ super.destroy();
197
+
198
+ // as GPUBufferAllocator does: a block asked for after teardown would otherwise be made, and
199
+ // nothing would ever destroy it
200
+ this.#device = undefined;
201
+ }
202
+ }