playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
666 lines (665 loc) • 20.2 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { Debug } from "./debug.js";
class MemBlock {
constructor() {
/**
* Position in the address space.
*
* @private
*/
__publicField(this, "_offset", 0);
/**
* Size of this block.
*
* @private
*/
__publicField(this, "_size", 0);
/**
* True if this is a free region, false if allocated.
*
* @private
*/
__publicField(this, "_free", true);
/**
* Previous node in the main (all-nodes) list.
*
* @type {MemBlock|null}
* @private
*/
__publicField(this, "_prev", null);
/**
* Next node in the main (all-nodes) list.
*
* @type {MemBlock|null}
* @private
*/
__publicField(this, "_next", null);
/**
* Previous node in the bucket free-list.
*
* @type {MemBlock|null}
* @private
*/
__publicField(this, "_prevFree", null);
/**
* Next node in the bucket free-list.
*
* @type {MemBlock|null}
* @private
*/
__publicField(this, "_nextFree", null);
/**
* Index of the size bucket this free block belongs to, or -1 if not in any bucket.
*
* @private
*/
__publicField(this, "_bucket", -1);
}
/**
* The offset of this block in the address space.
*
* @type {number}
*/
get offset() {
return this._offset;
}
/**
* The size of this block.
*
* @type {number}
*/
get size() {
return this._size;
}
}
class BlockAllocator {
/**
* Create a new BlockAllocator.
*
* @param {number} [capacity] - Initial address space capacity. Defaults to 0.
* @param {number} [growMultiplier] - Multiplicative growth factor for auto-grow in
* {@link updateAllocation}. Defaults to 1.1 (10% extra).
*/
constructor(capacity = 0, growMultiplier = 1.1) {
/**
* Head of the main list (all blocks, offset-ordered).
*
* @type {MemBlock|null}
* @private
*/
__publicField(this, "_headAll", null);
/**
* Tail of the main list.
*
* @type {MemBlock|null}
* @private
*/
__publicField(this, "_tailAll", null);
/**
* Segregated free-list bucket heads. Each entry is the head of a doubly-linked list of free
* blocks whose size falls in that power-of-2 range. Bucket i covers sizes [2^i, 2^(i+1)).
* The array grows dynamically as larger free blocks appear.
*
* @type {Array<MemBlock|null>}
* @private
*/
__publicField(this, "_freeBucketHeads", []);
/**
* Pool of recycled MemBlock objects.
*
* @type {MemBlock[]}
* @private
*/
__publicField(this, "_pool", []);
/**
* Total address space.
*
* @private
*/
__publicField(this, "_capacity", 0);
/**
* Sum of all allocated block sizes.
*
* @private
*/
__publicField(this, "_usedSize", 0);
/**
* Sum of all free region sizes.
*
* @private
*/
__publicField(this, "_freeSize", 0);
/**
* Number of free regions. Maintained O(1) for the fragmentation metric.
*
* @private
*/
__publicField(this, "_freeRegionCount", 0);
/**
* Multiplicative growth factor used by {@link updateAllocation}. When growing, the new
* capacity is at least `capacity * growMultiplier`.
*
* @type {number}
* @private
*/
__publicField(this, "_growMultiplier");
this._growMultiplier = growMultiplier;
if (capacity > 0) {
this._capacity = capacity;
this._freeSize = capacity;
const block = this._obtain(0, capacity, true);
this._headAll = block;
this._tailAll = block;
this._addToBucket(block);
}
}
/**
* Total address space capacity.
*
* @type {number}
*/
get capacity() {
return this._capacity;
}
/**
* Total size of all allocated blocks.
*
* @type {number}
*/
get usedSize() {
return this._usedSize;
}
/**
* Total size of all free regions.
*
* @type {number}
*/
get freeSize() {
return this._freeSize;
}
/**
* Fragmentation ratio in the range [0, 1]. Returns 0 when all free space is one contiguous
* block (ideal), and approaches 1 when free space is split into many pieces. Computed O(1)
* from the internally maintained free region count.
*
* @type {number}
*/
get fragmentation() {
return this._freeSize > 0 ? 1 - 1 / this._freeRegionCount : 0;
}
/**
* Compute the bucket index for a given block size. Uses floor(log2(size)) via the CLZ
* intrinsic for integer math.
*
* @param {number} size - Block size (must be > 0).
* @returns {number} Bucket index.
* @private
*/
_bucketFor(size) {
return 31 - Math.clz32(size);
}
/**
* Add a free block to the appropriate size bucket. Prepends to the bucket list for O(1)
* insertion. Grows the bucket array if needed.
*
* @param {MemBlock} block - The free block to add.
* @private
*/
_addToBucket(block) {
const b = this._bucketFor(block._size);
block._bucket = b;
while (b >= this._freeBucketHeads.length) {
this._freeBucketHeads.push(null);
}
block._prevFree = null;
block._nextFree = this._freeBucketHeads[b];
if (this._freeBucketHeads[b]) this._freeBucketHeads[b]._prevFree = block;
this._freeBucketHeads[b] = block;
this._freeRegionCount++;
}
/**
* Remove a free block from its current size bucket.
*
* @param {MemBlock} block - The free block to remove.
* @private
*/
_removeFromBucket(block) {
const b = block._bucket;
if (block._prevFree) block._prevFree._nextFree = block._nextFree;
else this._freeBucketHeads[b] = block._nextFree;
if (block._nextFree) block._nextFree._prevFree = block._prevFree;
block._prevFree = null;
block._nextFree = null;
block._bucket = -1;
this._freeRegionCount--;
}
/**
* Move a free block to the correct bucket after its size changed (e.g. due to merging or
* splitting). Only performs the remove+add if the bucket actually changed.
*
* @param {MemBlock} block - The free block whose size has changed.
* @private
*/
_rebucket(block) {
const newBucket = this._bucketFor(block._size);
if (newBucket !== block._bucket) {
this._removeFromBucket(block);
this._addToBucket(block);
}
}
/**
* Obtain a MemBlock from the pool or create a new one.
*
* @param {number} offset - The offset.
* @param {number} size - The size.
* @param {boolean} free - Whether the block is free.
* @returns {MemBlock} The block.
* @private
*/
_obtain(offset, size, free) {
let block;
if (this._pool.length > 0) {
block = /** @type {MemBlock} */
this._pool.pop();
} else {
block = new MemBlock();
}
block._offset = offset;
block._size = size;
block._free = free;
block._prev = null;
block._next = null;
block._prevFree = null;
block._nextFree = null;
block._bucket = -1;
return block;
}
/**
* Return a MemBlock to the pool.
*
* @param {MemBlock} block - The block to release.
* @private
*/
_release(block) {
block._prev = null;
block._next = null;
block._prevFree = null;
block._nextFree = null;
block._bucket = -1;
this._pool.push(block);
}
/**
* Insert a block into the main list after a given node.
*
* @param {MemBlock} block - The block to insert.
* @param {MemBlock|null} after - Insert after this node (null = insert at head).
* @private
*/
_insertAfterInMainList(block, after) {
if (after === null) {
block._prev = null;
block._next = this._headAll;
if (this._headAll) this._headAll._prev = block;
this._headAll = block;
if (!this._tailAll) this._tailAll = block;
} else {
block._prev = after;
block._next = after._next;
if (after._next) after._next._prev = block;
after._next = block;
if (this._tailAll === after) this._tailAll = block;
}
}
/**
* Remove a block from the main list.
*
* @param {MemBlock} block - The block to remove.
* @private
*/
_removeFromMainList(block) {
if (block._prev) block._prev._next = block._next;
else this._headAll = block._next;
if (block._next) block._next._prev = block._prev;
else this._tailAll = block._prev;
block._prev = null;
block._next = null;
}
/**
* Find the best-fit free block for the requested size using segregated buckets. Scans the
* target bucket for the smallest block >= size (best-fit), then falls through to higher
* buckets where any block is guaranteed large enough (first-fit).
*
* @param {number} size - Minimum size needed.
* @returns {MemBlock|null} The best fitting free block, or null.
* @private
*/
_findFreeBlock(size) {
const startBucket = this._bucketFor(size);
const len = this._freeBucketHeads.length;
if (startBucket < len) {
let best = null;
let node = this._freeBucketHeads[startBucket];
while (node) {
if (node._size >= size) {
if (!best || node._size < best._size) {
best = node;
if (node._size === size) break;
}
}
node = node._nextFree;
}
if (best) return best;
}
for (let b = startBucket + 1; b < len; b++) {
if (this._freeBucketHeads[b]) {
return this._freeBucketHeads[b];
}
}
return null;
}
/**
* Allocate a contiguous block of the given size.
*
* @param {number} size - The number of units to allocate. Must be > 0.
* @returns {MemBlock|null} A MemBlock handle, or null if no space is available.
*/
allocate(size) {
Debug.assert(size > 0, "BlockAllocator.allocate: size must be > 0");
const gap = this._findFreeBlock(size);
if (!gap) return null;
this._usedSize += size;
this._freeSize -= size;
if (gap._size === size) {
gap._free = false;
this._removeFromBucket(gap);
return gap;
}
const alloc = this._obtain(gap._offset, size, false);
gap._offset += size;
gap._size -= size;
this._rebucket(gap);
this._insertAfterInMainList(alloc, gap._prev);
return alloc;
}
/**
* Free a previously allocated block. Adjacent free regions are merged automatically.
*
* @param {MemBlock} block - The block to free (must have been returned by {@link allocate}).
*/
free(block) {
Debug.assert(block && !block._free, "BlockAllocator.free: block is null or already free");
block._free = true;
this._usedSize -= block._size;
this._freeSize += block._size;
const prev = block._prev;
const next = block._next;
const prevFree = prev && prev._free;
const nextFree = next && next._free;
if (prevFree && nextFree) {
prev._size += block._size + next._size;
this._removeFromMainList(block);
this._removeFromMainList(next);
this._removeFromBucket(next);
this._release(block);
this._release(next);
this._rebucket(prev);
} else if (prevFree) {
prev._size += block._size;
this._removeFromMainList(block);
this._release(block);
this._rebucket(prev);
} else if (nextFree) {
block._size += next._size;
this._removeFromMainList(next);
this._removeFromBucket(next);
this._release(next);
this._addToBucket(block);
} else {
this._addToBucket(block);
}
}
/**
* Grow the address space. Only increases capacity, never decreases.
*
* @param {number} newCapacity - The new capacity. Must be > current capacity.
*/
grow(newCapacity) {
if (newCapacity <= this._capacity) return;
const added = newCapacity - this._capacity;
this._capacity = newCapacity;
this._freeSize += added;
if (this._tailAll && this._tailAll._free) {
this._tailAll._size += added;
this._rebucket(this._tailAll);
} else {
const block = this._obtain(this._capacity - added, added, true);
this._insertAfterInMainList(block, this._tailAll);
this._addToBucket(block);
}
}
/**
* Defragment the allocator by moving allocated blocks to reduce fragmentation.
*
* When maxMoves is 0, performs a full compaction in a single O(n) pass: all allocated blocks
* are packed contiguously from offset 0 and a single free block is placed at the end.
*
* When maxMoves > 0, performs incremental defragmentation in two phases:
* - Phase 1 (up to maxMoves/2): relocates the last allocated block to the first fitting free
* gap (maximizes tail free space).
* - Phase 2 (up to maxMoves/2): slides allocated blocks left into adjacent free gaps
* (cleans up interior fragmentation).
*
* @param {number} [maxMoves] - Maximum number of block moves. 0 = full compaction. Defaults
* to 0.
* @param {Set<MemBlock>} [result] - Optional Set to receive moved blocks. Defaults to a new
* Set.
* @returns {Set<MemBlock>} The set of MemBlocks that were moved.
*/
defrag(maxMoves = 0, result = /* @__PURE__ */ new Set()) {
result.clear();
if (this._freeRegionCount === 0) return result;
if (maxMoves === 0) {
this._defragFull(result);
} else {
this._defragIncremental(maxMoves, result);
}
return result;
}
/**
* Full compaction: single-pass, pack all allocated blocks from offset 0.
*
* @param {Set<MemBlock>} result - Set to receive moved blocks.
* @private
*/
_defragFull(result) {
for (let b = 0; b < this._freeBucketHeads.length; b++) {
let node = this._freeBucketHeads[b];
while (node) {
const nextFree = node._nextFree;
this._removeFromMainList(node);
node._prevFree = null;
node._nextFree = null;
node._bucket = -1;
this._pool.push(node);
node = nextFree;
}
this._freeBucketHeads[b] = null;
}
this._freeRegionCount = 0;
let offset = 0;
let block = this._headAll;
while (block) {
if (block._offset !== offset) {
block._offset = offset;
result.add(block);
}
offset += block._size;
block = block._next;
}
const remaining = this._capacity - offset;
if (remaining > 0) {
const freeBlock = this._obtain(offset, remaining, true);
this._insertAfterInMainList(freeBlock, this._tailAll);
this._addToBucket(freeBlock);
}
}
/**
* Incremental defragmentation with two phases.
*
* @param {number} maxMoves - Maximum total moves.
* @param {Set<MemBlock>} result - Set to receive moved blocks.
* @private
*/
_defragIncremental(maxMoves, result) {
const phase1Moves = Math.ceil(maxMoves / 2);
const phase2Moves = maxMoves - phase1Moves;
for (let i = 0; i < phase1Moves; i++) {
let lastAlloc = this._tailAll;
while (lastAlloc && lastAlloc._free) lastAlloc = lastAlloc._prev;
if (!lastAlloc) break;
const gap = this._findFreeBlock(lastAlloc._size);
if (!gap || gap._offset >= lastAlloc._offset) break;
this._moveBlock(lastAlloc, gap);
result.add(lastAlloc);
}
let block = this._headAll;
for (let i = 0; i < phase2Moves && block; ) {
const next = block._next;
if (block._free && next && !next._free) {
const allocBlock = next;
const freeBlock = block;
allocBlock._offset = freeBlock._offset;
freeBlock._offset = allocBlock._offset + allocBlock._size;
const a = freeBlock._prev;
const b = allocBlock._next;
allocBlock._prev = a;
allocBlock._next = freeBlock;
freeBlock._prev = allocBlock;
freeBlock._next = b;
if (a) a._next = allocBlock;
else this._headAll = allocBlock;
if (b) b._prev = freeBlock;
else this._tailAll = freeBlock;
if (freeBlock._next && freeBlock._next._free) {
const right = freeBlock._next;
freeBlock._size += right._size;
this._removeFromMainList(right);
this._removeFromBucket(right);
this._release(right);
this._rebucket(freeBlock);
}
result.add(allocBlock);
i++;
block = freeBlock._next;
} else {
block = next;
}
}
}
/**
* Move an allocated block to a free gap. The block's offset is updated in-place so caller
* handles stay valid.
*
* @param {MemBlock} block - The allocated block to move.
* @param {MemBlock} gap - The free gap to move into (must be >= block size).
* @private
*/
_moveBlock(block, gap) {
Debug.assert(!block._free, "_moveBlock: block must be allocated");
Debug.assert(gap._free, "_moveBlock: gap must be free");
Debug.assert(gap._size >= block._size, "_moveBlock: gap too small");
const blockSize = block._size;
const newOffset = gap._offset;
const prev = block._prev;
this._removeFromMainList(block);
const freed = this._obtain(block._offset, blockSize, true);
this._insertAfterInMainList(freed, prev);
this._addToBucket(freed);
if (freed._next && freed._next._free) {
const right = freed._next;
freed._size += right._size;
this._removeFromMainList(right);
this._removeFromBucket(right);
this._release(right);
this._rebucket(freed);
}
if (freed._prev && freed._prev._free) {
const left = freed._prev;
left._size += freed._size;
this._removeFromMainList(freed);
this._removeFromBucket(freed);
this._release(freed);
this._rebucket(left);
}
block._offset = newOffset;
if (gap._size === blockSize) {
const gapPrev = gap._prev;
this._removeFromMainList(gap);
this._removeFromBucket(gap);
this._release(gap);
this._insertAfterInMainList(block, gapPrev);
} else {
gap._offset += blockSize;
gap._size -= blockSize;
this._rebucket(gap);
this._insertAfterInMainList(block, gap._prev);
}
}
/**
* Batch update: free a set of blocks and allocate new ones. Handles growth and compaction
* internally when allocations cannot be satisfied.
*
* The `toAllocate` array is modified in-place: each numeric size entry is replaced with the
* allocated {@link MemBlock}.
*
* @param {MemBlock[]} toFree - Blocks to release.
* @param {Array<number|MemBlock>} toAllocate - Sizes to allocate. Modified in-place: numbers
* are replaced with MemBlock instances.
* @returns {boolean} True if a full defrag was performed (all existing blocks have new
* offsets and must be re-rendered), false if only incremental allocations were made.
*/
updateAllocation(toFree, toAllocate) {
for (let i = 0; i < toFree.length; i++) {
this.free(toFree[i]);
}
for (let i = 0; i < toAllocate.length; i++) {
const size = (
/** @type {number} */
toAllocate[i]
);
const block = this.allocate(size);
if (block) {
toAllocate[i] = block;
} else {
let totalRemaining = size;
for (let j = i + 1; j < toAllocate.length; j++) {
totalRemaining += /** @type {number} */
toAllocate[j];
}
const neededCapacity = this._usedSize + totalRemaining;
const headroomCapacity = Math.ceil(neededCapacity * this._growMultiplier);
if (headroomCapacity > this._capacity) {
this.grow(headroomCapacity);
}
this.defrag(0);
for (let j = i; j < toAllocate.length; j++) {
const s = (
/** @type {number} */
toAllocate[j]
);
const b = this.allocate(s);
Debug.assert(b, "BlockAllocator.updateAllocation: allocation failed after defrag");
toAllocate[j] = b;
}
return true;
}
}
return false;
}
}
export {
BlockAllocator,
MemBlock
};