minecraft-renderer 0.1.64 → 0.1.66
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.
- package/dist/minecraft-renderer.js +60 -60
- package/dist/minecraft-renderer.js.meta.json +1 -1
- package/dist/threeWorker.js +411 -411
- package/package.json +1 -1
- package/src/three/entities.ts +39 -160
- package/src/three/entity/animations.js +92 -185
- package/src/three/globalBlockBuffer.ts +180 -15
- package/src/three/worldRendererThree.ts +1 -0
- package/src/wasm-mesher/tests/shaderCubeInstances.test.ts +319 -0
|
@@ -3,9 +3,16 @@ import * as THREE from 'three'
|
|
|
3
3
|
import { VERTICES_PER_FACE } from './shaders/cubeBlockShader'
|
|
4
4
|
import { packWord2Empty } from '../wasm-mesher/bridge/shaderCubeBridge'
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// Linear growth (NOT doubling) to keep iOS allocation spikes bounded to one increment.
|
|
7
|
+
// Reference: prismarine-web-client PR #90 (webgl) and #120 (webgpu) both grow by +1M faces.
|
|
8
|
+
const INITIAL_CAPACITY_FACES = 512_000 // ~8 MB up front (4 words × 4 B), well under 1M
|
|
9
|
+
const GROWTH_INCREMENT_FACES = 1_000_000 // +16 MB per growth step instead of doubling
|
|
10
|
+
const MAX_UPLOAD_FACES_PER_FRAME = 15_000 // face-indexed budget (chunksStorage uses 10k blocks)
|
|
11
|
+
const FRAGMENTATION_THRESHOLD = 0.25
|
|
7
12
|
const EMPTY_W2 = packWord2Empty()
|
|
8
13
|
|
|
14
|
+
type PendingMove = { key: string, oldStart: number, newStart: number, count: number }
|
|
15
|
+
|
|
9
16
|
export type GlobalBlockBufferShaderData = {
|
|
10
17
|
words: Uint32Array
|
|
11
18
|
count: number
|
|
@@ -26,8 +33,8 @@ export class GlobalBlockBuffer {
|
|
|
26
33
|
private readonly sectionSlots = new Map<string, { start: number, count: number }>()
|
|
27
34
|
private freeList: Array<{ start: number, count: number }> = []
|
|
28
35
|
private highWatermark = 0
|
|
29
|
-
private
|
|
30
|
-
private
|
|
36
|
+
private pendingRanges: Array<{ start: number, end: number }> = []
|
|
37
|
+
private pendingMove: PendingMove | null = null
|
|
31
38
|
|
|
32
39
|
constructor (
|
|
33
40
|
material: THREE.ShaderMaterial,
|
|
@@ -38,6 +45,7 @@ export class GlobalBlockBuffer {
|
|
|
38
45
|
this.w1 = new Uint32Array(this.capacityFaces)
|
|
39
46
|
this.w2 = new Uint32Array(this.capacityFaces)
|
|
40
47
|
this.w3 = new Uint32Array(this.capacityFaces)
|
|
48
|
+
this.w2.fill(EMPTY_W2)
|
|
41
49
|
|
|
42
50
|
const geometry = new THREE.InstancedBufferGeometry()
|
|
43
51
|
const positions = new Float32Array(VERTICES_PER_FACE * 3)
|
|
@@ -138,6 +146,19 @@ export class GlobalBlockBuffer {
|
|
|
138
146
|
const slot = this.sectionSlots.get(sectionKey)
|
|
139
147
|
if (!slot) return
|
|
140
148
|
|
|
149
|
+
if (this.pendingMove?.key === sectionKey) {
|
|
150
|
+
const { oldStart, count } = this.pendingMove
|
|
151
|
+
for (let i = oldStart; i < oldStart + count; i++) {
|
|
152
|
+
this.w0[i] = 0
|
|
153
|
+
this.w1[i] = 0
|
|
154
|
+
this.w2[i] = EMPTY_W2
|
|
155
|
+
this.w3[i] = 0
|
|
156
|
+
}
|
|
157
|
+
this.markDirty(oldStart, oldStart + count - 1)
|
|
158
|
+
this.insertFreeSlot({ start: oldStart, count })
|
|
159
|
+
this.pendingMove = null
|
|
160
|
+
}
|
|
161
|
+
|
|
141
162
|
for (let i = slot.start; i < slot.start + slot.count; i++) {
|
|
142
163
|
this.w0[i] = 0
|
|
143
164
|
this.w1[i] = 0
|
|
@@ -152,11 +173,42 @@ export class GlobalBlockBuffer {
|
|
|
152
173
|
this.mesh.geometry.instanceCount = this.highWatermark
|
|
153
174
|
}
|
|
154
175
|
|
|
176
|
+
/** One interior-hole move per frame when fragmentation exceeds threshold; deferred shrink. */
|
|
177
|
+
compactStep (): void {
|
|
178
|
+
if (this.pendingMove) {
|
|
179
|
+
const { newStart, count } = this.pendingMove
|
|
180
|
+
if (this.rangeFullyUploaded(newStart, newStart + count - 1)) {
|
|
181
|
+
this.finalizePendingMove()
|
|
182
|
+
}
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (this.highWatermark === 0) return
|
|
187
|
+
const interiorFree = this.interiorFreeFaces()
|
|
188
|
+
if (interiorFree / this.highWatermark <= FRAGMENTATION_THRESHOLD) return
|
|
189
|
+
|
|
190
|
+
const section = this.findMovableSection(MAX_UPLOAD_FACES_PER_FRAME)
|
|
191
|
+
if (!section) return
|
|
192
|
+
|
|
193
|
+
const hole = this.findLowestInteriorHole(section.start, section.count)
|
|
194
|
+
if (!hole) return
|
|
195
|
+
|
|
196
|
+
const reserved = this.reserveFreeSlotAt(hole.index, section.count)
|
|
197
|
+
const oldStart = section.start
|
|
198
|
+
const newStart = reserved.start
|
|
199
|
+
|
|
200
|
+
this.copySectionRange(oldStart, newStart, section.count)
|
|
201
|
+
this.sectionSlots.set(section.key, { start: newStart, count: section.count })
|
|
202
|
+
this.markDirty(newStart, newStart + section.count - 1)
|
|
203
|
+
this.pendingMove = { key: section.key, oldStart, newStart, count: section.count }
|
|
204
|
+
}
|
|
205
|
+
|
|
155
206
|
uploadDirtyRange (): void {
|
|
156
|
-
|
|
207
|
+
const r = this.pendingRanges[0]
|
|
208
|
+
if (!r) return
|
|
157
209
|
|
|
158
|
-
const offset =
|
|
159
|
-
const count =
|
|
210
|
+
const offset = r.start
|
|
211
|
+
const count = Math.min(r.end - r.start + 1, MAX_UPLOAD_FACES_PER_FRAME)
|
|
160
212
|
const geometry = this.mesh.geometry
|
|
161
213
|
|
|
162
214
|
for (const name of ['a_w0', 'a_w1', 'a_w2', 'a_w3'] as const) {
|
|
@@ -166,8 +218,8 @@ export class GlobalBlockBuffer {
|
|
|
166
218
|
attr.needsUpdate = true
|
|
167
219
|
}
|
|
168
220
|
|
|
169
|
-
this.
|
|
170
|
-
|
|
221
|
+
if (offset + count > r.end) this.pendingRanges.shift()
|
|
222
|
+
else r.start = offset + count
|
|
171
223
|
}
|
|
172
224
|
|
|
173
225
|
setCameraOrigin (x: number, y: number, z: number): void {
|
|
@@ -189,8 +241,8 @@ export class GlobalBlockBuffer {
|
|
|
189
241
|
this.sectionSlots.clear()
|
|
190
242
|
this.freeList.length = 0
|
|
191
243
|
this.highWatermark = 0
|
|
192
|
-
this.
|
|
193
|
-
this.
|
|
244
|
+
this.pendingRanges.length = 0
|
|
245
|
+
this.pendingMove = null
|
|
194
246
|
this.w0.fill(0)
|
|
195
247
|
this.w1.fill(0)
|
|
196
248
|
this.w2.fill(EMPTY_W2)
|
|
@@ -205,8 +257,26 @@ export class GlobalBlockBuffer {
|
|
|
205
257
|
}
|
|
206
258
|
|
|
207
259
|
private markDirty (start: number, end: number): void {
|
|
208
|
-
|
|
209
|
-
|
|
260
|
+
this.pendingRanges.push({ start, end })
|
|
261
|
+
this.pendingRanges.sort((a, b) => a.start - b.start)
|
|
262
|
+
this.mergePendingRanges()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private mergePendingRanges (): void {
|
|
266
|
+
if (this.pendingRanges.length < 2) return
|
|
267
|
+
const merged: Array<{ start: number, end: number }> = []
|
|
268
|
+
let cur = this.pendingRanges[0]!
|
|
269
|
+
for (let i = 1; i < this.pendingRanges.length; i++) {
|
|
270
|
+
const next = this.pendingRanges[i]!
|
|
271
|
+
if (next.start <= cur.end + 1) {
|
|
272
|
+
cur = { start: cur.start, end: Math.max(cur.end, next.end) }
|
|
273
|
+
} else {
|
|
274
|
+
merged.push(cur)
|
|
275
|
+
cur = next
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
merged.push(cur)
|
|
279
|
+
this.pendingRanges = merged
|
|
210
280
|
}
|
|
211
281
|
|
|
212
282
|
private takeFreeSlot (count: number): { start: number, count: number } | undefined {
|
|
@@ -246,6 +316,97 @@ export class GlobalBlockBuffer {
|
|
|
246
316
|
this.freeList = merged
|
|
247
317
|
}
|
|
248
318
|
|
|
319
|
+
private interiorFreeFaces (): number {
|
|
320
|
+
let total = 0
|
|
321
|
+
for (const slot of this.freeList) {
|
|
322
|
+
if (slot.start < this.highWatermark) total += slot.count
|
|
323
|
+
}
|
|
324
|
+
return total
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private findMovableSection (maxCount: number): { key: string, start: number, count: number } | undefined {
|
|
328
|
+
const sections: Array<{ key: string, start: number, count: number }> = []
|
|
329
|
+
for (const [key, slot] of this.sectionSlots) {
|
|
330
|
+
sections.push({ key, start: slot.start, count: slot.count })
|
|
331
|
+
}
|
|
332
|
+
if (sections.length === 0) return undefined
|
|
333
|
+
|
|
334
|
+
sections.sort((a, b) => {
|
|
335
|
+
if (b.start !== a.start) return b.start - a.start
|
|
336
|
+
return (b.start + b.count) - (a.start + a.count)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
const tailmost = sections[0]!
|
|
340
|
+
if (tailmost.count <= maxCount && this.findLowestInteriorHole(tailmost.start, tailmost.count)) {
|
|
341
|
+
return tailmost
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const candidates = sections
|
|
345
|
+
.filter(s => s.count <= maxCount)
|
|
346
|
+
.sort((a, b) => b.count - a.count)
|
|
347
|
+
|
|
348
|
+
for (const s of candidates) {
|
|
349
|
+
if (this.findLowestInteriorHole(s.start, s.count)) return s
|
|
350
|
+
}
|
|
351
|
+
return undefined
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private findLowestInteriorHole (
|
|
355
|
+
sectionStart: number,
|
|
356
|
+
count: number,
|
|
357
|
+
): { start: number, count: number, index: number } | undefined {
|
|
358
|
+
for (let i = 0; i < this.freeList.length; i++) {
|
|
359
|
+
const slot = this.freeList[i]!
|
|
360
|
+
if (slot.start < sectionStart && slot.count >= count) {
|
|
361
|
+
return { start: slot.start, count: slot.count, index: i }
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return undefined
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private reserveFreeSlotAt (index: number, count: number): { start: number, count: number } {
|
|
368
|
+
const slot = this.freeList[index]!
|
|
369
|
+
this.freeList.splice(index, 1)
|
|
370
|
+
if (slot.count === count) return { start: slot.start, count }
|
|
371
|
+
const used = { start: slot.start, count }
|
|
372
|
+
this.insertFreeSlot({ start: slot.start + count, count: slot.count - count })
|
|
373
|
+
return used
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private copySectionRange (oldStart: number, newStart: number, count: number): void {
|
|
377
|
+
this.w0.copyWithin(newStart, oldStart, oldStart + count)
|
|
378
|
+
this.w1.copyWithin(newStart, oldStart, oldStart + count)
|
|
379
|
+
this.w2.copyWithin(newStart, oldStart, oldStart + count)
|
|
380
|
+
this.w3.copyWithin(newStart, oldStart, oldStart + count)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private rangeFullyUploaded (start: number, end: number): boolean {
|
|
384
|
+
for (const r of this.pendingRanges) {
|
|
385
|
+
if (r.start <= end && r.end >= start) return false
|
|
386
|
+
}
|
|
387
|
+
return true
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private finalizePendingMove (): void {
|
|
391
|
+
const move = this.pendingMove
|
|
392
|
+
if (!move) return
|
|
393
|
+
|
|
394
|
+
const { oldStart, count } = move
|
|
395
|
+
for (let i = oldStart; i < oldStart + count; i++) {
|
|
396
|
+
this.w0[i] = 0
|
|
397
|
+
this.w1[i] = 0
|
|
398
|
+
this.w2[i] = EMPTY_W2
|
|
399
|
+
this.w3[i] = 0
|
|
400
|
+
}
|
|
401
|
+
this.insertFreeSlot({ start: oldStart, count })
|
|
402
|
+
this.shrinkHighWatermark()
|
|
403
|
+
if (oldStart < this.highWatermark) {
|
|
404
|
+
this.markDirty(oldStart, oldStart + count - 1)
|
|
405
|
+
}
|
|
406
|
+
this.mesh.geometry.instanceCount = this.highWatermark
|
|
407
|
+
this.pendingMove = null
|
|
408
|
+
}
|
|
409
|
+
|
|
249
410
|
private shrinkHighWatermark (): void {
|
|
250
411
|
while (this.highWatermark > 0) {
|
|
251
412
|
const tail = this.highWatermark - 1
|
|
@@ -258,8 +419,13 @@ export class GlobalBlockBuffer {
|
|
|
258
419
|
}
|
|
259
420
|
|
|
260
421
|
private growCapacity (minFaces: number): void {
|
|
422
|
+
// Moved CPU data at newStart survives nw*.set(); pendingRanges cleared below anyway.
|
|
423
|
+
if (this.pendingMove) this.finalizePendingMove()
|
|
424
|
+
|
|
425
|
+
console.warn('[globalBlockBuffer] growing faces', this.capacityFaces, '->', '(need', minFaces, ')')
|
|
261
426
|
let newCap = this.capacityFaces
|
|
262
|
-
while (newCap < minFaces) newCap
|
|
427
|
+
while (newCap < minFaces) newCap += GROWTH_INCREMENT_FACES
|
|
428
|
+
console.warn('[globalBlockBuffer] growing faces', this.capacityFaces, '->', newCap)
|
|
263
429
|
|
|
264
430
|
const nw0 = new Uint32Array(newCap)
|
|
265
431
|
const nw1 = new Uint32Array(newCap)
|
|
@@ -295,7 +461,6 @@ export class GlobalBlockBuffer {
|
|
|
295
461
|
mkAttr(this.w2, 'a_w2')
|
|
296
462
|
mkAttr(this.w3, 'a_w3')
|
|
297
463
|
|
|
298
|
-
this.
|
|
299
|
-
this.dirtyMax = this.highWatermark - 1
|
|
464
|
+
this.pendingRanges.length = 0
|
|
300
465
|
}
|
|
301
466
|
}
|
|
@@ -1284,6 +1284,7 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|
|
1284
1284
|
const globalBuffer = this.chunkMeshManager.globalBlockBuffer
|
|
1285
1285
|
if (globalBuffer) {
|
|
1286
1286
|
globalBuffer.setCameraOrigin(this.cameraWorldPos.x, this.cameraWorldPos.y, this.cameraWorldPos.z)
|
|
1287
|
+
globalBuffer.compactStep()
|
|
1287
1288
|
globalBuffer.uploadDirtyRange()
|
|
1288
1289
|
}
|
|
1289
1290
|
this.renderer.render(this.scene, cam)
|
|
@@ -359,6 +359,325 @@ test('GlobalBlockBuffer: free-list reuses slot with EMPTY sentinel', () => {
|
|
|
359
359
|
mat.dispose()
|
|
360
360
|
})
|
|
361
361
|
|
|
362
|
+
type BufferInternals = {
|
|
363
|
+
pendingRanges: Array<{ start: number, end: number }>
|
|
364
|
+
pendingMove: { key: string, oldStart: number, newStart: number, count: number } | null
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** True if some queued (not-yet-uploaded) dirty range covers [start, end] — i.e. it WILL hit the GPU. */
|
|
368
|
+
function rangeQueuedForUpload (buffer: GlobalBlockBuffer, start: number, end: number): boolean {
|
|
369
|
+
return getBufferInternals(buffer).pendingRanges.some(r => r.start <= end && r.end >= start)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function getBufferInternals (buffer: GlobalBlockBuffer): BufferInternals {
|
|
373
|
+
return buffer as unknown as BufferInternals
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function drainAllUploads (buffer: GlobalBlockBuffer): void {
|
|
377
|
+
while (getBufferInternals(buffer).pendingRanges.length) buffer.uploadDirtyRange()
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function makeSectionWords (faceW0: number[]): Uint32Array {
|
|
381
|
+
const words = new Uint32Array(faceW0.length * 4)
|
|
382
|
+
for (let i = 0; i < faceW0.length; i++) {
|
|
383
|
+
words[i * 4] = faceW0[i]!
|
|
384
|
+
words[i * 4 + 1] = 0
|
|
385
|
+
words[i * 4 + 2] = 0
|
|
386
|
+
words[i * 4 + 3] = packWord3(0, 0)
|
|
387
|
+
}
|
|
388
|
+
return words
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function readSectionFaceWords (buffer: GlobalBlockBuffer, key: string): number[] {
|
|
392
|
+
const slot = buffer.getSectionSlot(key)
|
|
393
|
+
if (!slot) throw new Error(`missing section ${key}`)
|
|
394
|
+
const geo = buffer.mesh.geometry
|
|
395
|
+
const w0 = (geo.getAttribute('a_w0') as THREE.InstancedBufferAttribute).array as Uint32Array
|
|
396
|
+
const w1 = (geo.getAttribute('a_w1') as THREE.InstancedBufferAttribute).array as Uint32Array
|
|
397
|
+
const w2 = (geo.getAttribute('a_w2') as THREE.InstancedBufferAttribute).array as Uint32Array
|
|
398
|
+
const w3 = (geo.getAttribute('a_w3') as THREE.InstancedBufferAttribute).array as Uint32Array
|
|
399
|
+
const out: number[] = []
|
|
400
|
+
for (let i = 0; i < slot.count; i++) {
|
|
401
|
+
const idx = slot.start + i
|
|
402
|
+
out.push(w0[idx]!, w1[idx]!, w2[idx]!, w3[idx]!)
|
|
403
|
+
}
|
|
404
|
+
return out
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function finishCurrentMove (buffer: GlobalBlockBuffer): void {
|
|
408
|
+
drainAllUploads(buffer)
|
|
409
|
+
buffer.compactStep()
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function isEmptyFace (buffer: GlobalBlockBuffer, index: number): boolean {
|
|
413
|
+
const w2 = (buffer.mesh.geometry.getAttribute('a_w2') as THREE.InstancedBufferAttribute).array as Uint32Array
|
|
414
|
+
return (w2[index]! & (1 << WORD2.EMPTY_SHIFT)) !== 0
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
test('GlobalBlockBuffer: compaction lowers watermark after interior-hole churn', () => {
|
|
418
|
+
const scene = new THREE.Scene()
|
|
419
|
+
const mat = createCubeBlockMaterial()
|
|
420
|
+
const buffer = new GlobalBlockBuffer(mat, scene)
|
|
421
|
+
|
|
422
|
+
buffer.addSection('a', makeSectionWords([10]), 1)
|
|
423
|
+
buffer.addSection('b', makeSectionWords([20]), 1)
|
|
424
|
+
buffer.addSection('c', makeSectionWords([30]), 1)
|
|
425
|
+
expect(buffer.mesh.geometry.instanceCount).toBe(3)
|
|
426
|
+
|
|
427
|
+
buffer.removeSection('b')
|
|
428
|
+
drainAllUploads(buffer)
|
|
429
|
+
expect(buffer.mesh.geometry.instanceCount).toBe(3)
|
|
430
|
+
|
|
431
|
+
buffer.compactStep()
|
|
432
|
+
finishCurrentMove(buffer)
|
|
433
|
+
|
|
434
|
+
expect(buffer.mesh.geometry.instanceCount).toBe(2)
|
|
435
|
+
expect(buffer.getSectionSlot('c')).toEqual({ start: 1, count: 1 })
|
|
436
|
+
expect(readSectionFaceWords(buffer, 'c')[0]).toBe(30)
|
|
437
|
+
expect(readSectionFaceWords(buffer, 'a')[0]).toBe(10)
|
|
438
|
+
|
|
439
|
+
buffer.dispose()
|
|
440
|
+
mat.dispose()
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
test('GlobalBlockBuffer: compaction preserves surviving section data', () => {
|
|
444
|
+
const scene = new THREE.Scene()
|
|
445
|
+
const mat = createCubeBlockMaterial()
|
|
446
|
+
const buffer = new GlobalBlockBuffer(mat, scene)
|
|
447
|
+
|
|
448
|
+
buffer.addSection('a', makeSectionWords([11, 12]), 2)
|
|
449
|
+
buffer.addSection('b', makeSectionWords([21]), 1)
|
|
450
|
+
buffer.addSection('c', makeSectionWords([31, 32, 33]), 3)
|
|
451
|
+
const expectedA = readSectionFaceWords(buffer, 'a')
|
|
452
|
+
const expectedC = readSectionFaceWords(buffer, 'c')
|
|
453
|
+
|
|
454
|
+
buffer.removeSection('b')
|
|
455
|
+
drainAllUploads(buffer)
|
|
456
|
+
buffer.compactStep()
|
|
457
|
+
finishCurrentMove(buffer)
|
|
458
|
+
|
|
459
|
+
expect(readSectionFaceWords(buffer, 'a')).toEqual(expectedA)
|
|
460
|
+
expect(readSectionFaceWords(buffer, 'c')).toEqual(expectedC)
|
|
461
|
+
|
|
462
|
+
buffer.dispose()
|
|
463
|
+
mat.dispose()
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
test('GlobalBlockBuffer: compaction defers instanceCount shrink until upload completes', () => {
|
|
467
|
+
const scene = new THREE.Scene()
|
|
468
|
+
const mat = createCubeBlockMaterial()
|
|
469
|
+
const buffer = new GlobalBlockBuffer(mat, scene)
|
|
470
|
+
|
|
471
|
+
buffer.addSection('a', makeSectionWords([10]), 1)
|
|
472
|
+
buffer.addSection('b', makeSectionWords([20]), 1)
|
|
473
|
+
buffer.addSection('c', makeSectionWords([30]), 1)
|
|
474
|
+
buffer.removeSection('b')
|
|
475
|
+
drainAllUploads(buffer)
|
|
476
|
+
|
|
477
|
+
buffer.compactStep()
|
|
478
|
+
expect(getBufferInternals(buffer).pendingMove).not.toBeNull()
|
|
479
|
+
expect(buffer.mesh.geometry.instanceCount).toBe(3)
|
|
480
|
+
|
|
481
|
+
finishCurrentMove(buffer)
|
|
482
|
+
expect(getBufferInternals(buffer).pendingMove).toBeNull()
|
|
483
|
+
expect(buffer.mesh.geometry.instanceCount).toBe(2)
|
|
484
|
+
|
|
485
|
+
buffer.dispose()
|
|
486
|
+
mat.dispose()
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
test('GlobalBlockBuffer: compaction runs one move at a time', () => {
|
|
490
|
+
const scene = new THREE.Scene()
|
|
491
|
+
const mat = createCubeBlockMaterial()
|
|
492
|
+
const buffer = new GlobalBlockBuffer(mat, scene)
|
|
493
|
+
|
|
494
|
+
buffer.addSection('a', makeSectionWords([10]), 1)
|
|
495
|
+
buffer.addSection('b', makeSectionWords([20]), 1)
|
|
496
|
+
buffer.addSection('c', makeSectionWords([30]), 1)
|
|
497
|
+
buffer.addSection('d', makeSectionWords([40]), 1)
|
|
498
|
+
buffer.addSection('e', makeSectionWords([50]), 1)
|
|
499
|
+
buffer.removeSection('b')
|
|
500
|
+
buffer.removeSection('d')
|
|
501
|
+
drainAllUploads(buffer)
|
|
502
|
+
|
|
503
|
+
buffer.compactStep()
|
|
504
|
+
const moveAfterFirst = getBufferInternals(buffer).pendingMove
|
|
505
|
+
expect(moveAfterFirst).not.toBeNull()
|
|
506
|
+
|
|
507
|
+
buffer.compactStep()
|
|
508
|
+
expect(getBufferInternals(buffer).pendingMove).toEqual(moveAfterFirst)
|
|
509
|
+
|
|
510
|
+
buffer.dispose()
|
|
511
|
+
mat.dispose()
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
test('GlobalBlockBuffer: compaction skips when fragmentation is below threshold', () => {
|
|
515
|
+
const scene = new THREE.Scene()
|
|
516
|
+
const mat = createCubeBlockMaterial()
|
|
517
|
+
const buffer = new GlobalBlockBuffer(mat, scene)
|
|
518
|
+
|
|
519
|
+
buffer.addSection('a', makeSectionWords([10]), 1)
|
|
520
|
+
buffer.addSection('b', makeSectionWords([20]), 1)
|
|
521
|
+
buffer.addSection('c', makeSectionWords([30]), 1)
|
|
522
|
+
buffer.addSection('d', makeSectionWords([40]), 1)
|
|
523
|
+
buffer.removeSection('b')
|
|
524
|
+
drainAllUploads(buffer)
|
|
525
|
+
expect(buffer.mesh.geometry.instanceCount).toBe(4)
|
|
526
|
+
|
|
527
|
+
buffer.compactStep()
|
|
528
|
+
expect(getBufferInternals(buffer).pendingMove).toBeNull()
|
|
529
|
+
expect(buffer.mesh.geometry.instanceCount).toBe(4)
|
|
530
|
+
|
|
531
|
+
buffer.dispose()
|
|
532
|
+
mat.dispose()
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test('GlobalBlockBuffer: interior-fallback move uploads EMPTY over old slot still in draw range', () => {
|
|
536
|
+
const scene = new THREE.Scene()
|
|
537
|
+
const mat = createCubeBlockMaterial()
|
|
538
|
+
const buffer = new GlobalBlockBuffer(mat, scene)
|
|
539
|
+
|
|
540
|
+
// A[0,2) B[2,2) C[4,3) — remove A; C (count 3) cannot fit hole [0,2), so B moves down.
|
|
541
|
+
buffer.addSection('a', makeSectionWords([10, 11]), 2)
|
|
542
|
+
buffer.addSection('b', makeSectionWords([20, 21]), 2)
|
|
543
|
+
buffer.addSection('c', makeSectionWords([30, 31, 32]), 3)
|
|
544
|
+
expect(buffer.mesh.geometry.instanceCount).toBe(7)
|
|
545
|
+
|
|
546
|
+
buffer.removeSection('a')
|
|
547
|
+
drainAllUploads(buffer)
|
|
548
|
+
|
|
549
|
+
buffer.compactStep()
|
|
550
|
+
expect(getBufferInternals(buffer).pendingMove?.key).toBe('b')
|
|
551
|
+
expect(getBufferInternals(buffer).pendingMove?.oldStart).toBe(2)
|
|
552
|
+
finishCurrentMove(buffer)
|
|
553
|
+
|
|
554
|
+
// Guards the High fix: the vacated old slot [2,3] is still inside the draw range, so it MUST be
|
|
555
|
+
// queued for GPU upload (markDirty). Checking isEmptyFace alone is insufficient — the CPU-backed
|
|
556
|
+
// attribute array is cleared regardless; only the pending upload range proves the GPU is told.
|
|
557
|
+
expect(rangeQueuedForUpload(buffer, 2, 3)).toBe(true)
|
|
558
|
+
|
|
559
|
+
expect(buffer.mesh.geometry.instanceCount).toBe(7)
|
|
560
|
+
expect(buffer.getSectionSlot('b')).toEqual({ start: 0, count: 2 })
|
|
561
|
+
expect(readSectionFaceWords(buffer, 'b')[0]).toBe(20)
|
|
562
|
+
expect(isEmptyFace(buffer, 2)).toBe(true)
|
|
563
|
+
expect(isEmptyFace(buffer, 3)).toBe(true)
|
|
564
|
+
|
|
565
|
+
buffer.dispose()
|
|
566
|
+
mat.dispose()
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
test('GlobalBlockBuffer: removeSection during pending move clears old GPU copy', () => {
|
|
570
|
+
const scene = new THREE.Scene()
|
|
571
|
+
const mat = createCubeBlockMaterial()
|
|
572
|
+
const buffer = new GlobalBlockBuffer(mat, scene)
|
|
573
|
+
|
|
574
|
+
buffer.addSection('a', makeSectionWords([10]), 1)
|
|
575
|
+
buffer.addSection('b', makeSectionWords([20]), 1)
|
|
576
|
+
buffer.addSection('c', makeSectionWords([30]), 1)
|
|
577
|
+
buffer.removeSection('b')
|
|
578
|
+
drainAllUploads(buffer)
|
|
579
|
+
|
|
580
|
+
buffer.compactStep()
|
|
581
|
+
expect(getBufferInternals(buffer).pendingMove?.key).toBe('c')
|
|
582
|
+
|
|
583
|
+
buffer.removeSection('c')
|
|
584
|
+
expect(getBufferInternals(buffer).pendingMove).toBeNull()
|
|
585
|
+
// The in-flight old copy at index 2 must be queued for upload, not just cleared in CPU memory.
|
|
586
|
+
expect(rangeQueuedForUpload(buffer, 2, 2)).toBe(true)
|
|
587
|
+
drainAllUploads(buffer)
|
|
588
|
+
|
|
589
|
+
expect(buffer.hasSection('c')).toBe(false)
|
|
590
|
+
expect(isEmptyFace(buffer, 2)).toBe(true)
|
|
591
|
+
|
|
592
|
+
buffer.dispose()
|
|
593
|
+
mat.dispose()
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
test('GlobalBlockBuffer: takeSectionData during pending move clears old GPU copy', () => {
|
|
597
|
+
const scene = new THREE.Scene()
|
|
598
|
+
const mat = createCubeBlockMaterial()
|
|
599
|
+
const buffer = new GlobalBlockBuffer(mat, scene)
|
|
600
|
+
|
|
601
|
+
buffer.addSection('a', makeSectionWords([10, 11]), 2)
|
|
602
|
+
buffer.addSection('b', makeSectionWords([20, 21]), 2)
|
|
603
|
+
buffer.addSection('c', makeSectionWords([30, 31, 32]), 3)
|
|
604
|
+
buffer.removeSection('a')
|
|
605
|
+
drainAllUploads(buffer)
|
|
606
|
+
|
|
607
|
+
buffer.compactStep()
|
|
608
|
+
expect(getBufferInternals(buffer).pendingMove?.key).toBe('b')
|
|
609
|
+
|
|
610
|
+
const taken = buffer.takeSectionData('b')
|
|
611
|
+
expect(taken?.words[0]).toBe(20)
|
|
612
|
+
expect(taken?.words[4]).toBe(21)
|
|
613
|
+
expect(getBufferInternals(buffer).pendingMove).toBeNull()
|
|
614
|
+
// The in-flight old copy at [2,3] must be queued for upload, not just cleared in CPU memory.
|
|
615
|
+
expect(rangeQueuedForUpload(buffer, 2, 3)).toBe(true)
|
|
616
|
+
drainAllUploads(buffer)
|
|
617
|
+
|
|
618
|
+
expect(buffer.hasSection('b')).toBe(false)
|
|
619
|
+
expect(isEmptyFace(buffer, 2)).toBe(true)
|
|
620
|
+
expect(isEmptyFace(buffer, 3)).toBe(true)
|
|
621
|
+
|
|
622
|
+
buffer.dispose()
|
|
623
|
+
mat.dispose()
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
test('GlobalBlockBuffer: takeSectionData reads relocated section slot', () => {
|
|
627
|
+
const scene = new THREE.Scene()
|
|
628
|
+
const mat = createCubeBlockMaterial()
|
|
629
|
+
const buffer = new GlobalBlockBuffer(mat, scene)
|
|
630
|
+
|
|
631
|
+
const cWords = makeSectionWords([30, 31])
|
|
632
|
+
buffer.addSection('a', makeSectionWords([10]), 1)
|
|
633
|
+
buffer.addSection('b', makeSectionWords([20]), 1)
|
|
634
|
+
buffer.addSection('c', cWords, 2)
|
|
635
|
+
buffer.removeSection('b')
|
|
636
|
+
drainAllUploads(buffer)
|
|
637
|
+
buffer.compactStep()
|
|
638
|
+
finishCurrentMove(buffer)
|
|
639
|
+
|
|
640
|
+
const taken = buffer.takeSectionData('c')
|
|
641
|
+
expect(taken?.count).toBe(2)
|
|
642
|
+
expect(taken?.words[0]).toBe(30)
|
|
643
|
+
expect(taken?.words[4]).toBe(31)
|
|
644
|
+
|
|
645
|
+
buffer.dispose()
|
|
646
|
+
mat.dispose()
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
test('GlobalBlockBuffer: uploadDirtyRange budgets large dirty span across frames', () => {
|
|
650
|
+
const scene = new THREE.Scene()
|
|
651
|
+
const mat = createCubeBlockMaterial()
|
|
652
|
+
const buffer = new GlobalBlockBuffer(mat, scene)
|
|
653
|
+
|
|
654
|
+
const faceCount = 20_000
|
|
655
|
+
const words = new Uint32Array(faceCount * 4)
|
|
656
|
+
for (let i = 0; i < faceCount; i++) {
|
|
657
|
+
const src = i * 4
|
|
658
|
+
words[src] = i + 1
|
|
659
|
+
words[src + 1] = 0
|
|
660
|
+
words[src + 2] = 0
|
|
661
|
+
words[src + 3] = packWord3(0, 0)
|
|
662
|
+
}
|
|
663
|
+
buffer.addSection('big', words, faceCount)
|
|
664
|
+
|
|
665
|
+
const w0Attr = buffer.mesh.geometry.getAttribute('a_w0') as THREE.InstancedBufferAttribute
|
|
666
|
+
buffer.uploadDirtyRange()
|
|
667
|
+
expect(w0Attr.updateRange.offset).toBe(0)
|
|
668
|
+
expect(w0Attr.updateRange.count).toBe(15_000)
|
|
669
|
+
|
|
670
|
+
buffer.uploadDirtyRange()
|
|
671
|
+
expect(w0Attr.updateRange.offset).toBe(15_000)
|
|
672
|
+
expect(w0Attr.updateRange.count).toBe(5_000)
|
|
673
|
+
|
|
674
|
+
buffer.uploadDirtyRange()
|
|
675
|
+
expect((buffer as unknown as { pendingRanges: unknown[] }).pendingRanges).toHaveLength(0)
|
|
676
|
+
|
|
677
|
+
buffer.dispose()
|
|
678
|
+
mat.dispose()
|
|
679
|
+
})
|
|
680
|
+
|
|
362
681
|
test('getShaderCubeResources: returns null without loadedData.tints (no throw)', () => {
|
|
363
682
|
const prev = (globalThis as any).loadedData
|
|
364
683
|
;(globalThis as any).loadedData = {}
|