playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
351 lines (350 loc) • 11.4 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 { GSplatOctreeNode } from "./gsplat-octree-node.js";
import { path } from "../../core/path.js";
import { Debug } from "../../core/debug.js";
import { Tracing } from "../../core/tracing.js";
import { TRACEID_OCTREE_RESOURCES } from "../../core/constants.js";
const _toDelete = [];
class GSplatOctree {
/**
* @param {string} assetFileUrl - The file URL of the container asset.
* @param {Object} data - The parsed JSON data containing info, filenames and tree.
*/
constructor(assetFileUrl, data) {
/**
* @type {GSplatOctreeNode[]}
*/
__publicField(this, "nodes");
/**
* Packed per-node axis-aligned bounds in octree local space for CPU hot paths (e.g. LOD).
* Length is {@link GSplatOctree.nodes}.length * 6. For node index `i`, base `b = i * 6`:
* `[minX, minY, minZ, maxX, maxY, maxZ]` matching {@link GSplatOctreeNode.bounds}.
*
* @type {Float32Array}
*/
__publicField(this, "nodeBoundsMinMax");
/**
* @type {{ url: string, lodLevel: number }[]}
*/
__publicField(this, "files");
/**
* @type {number}
*/
__publicField(this, "lodLevels");
/**
* The file URL of the container asset, used as the base for resolving relative URLs.
*
* @type {string}
*/
__publicField(this, "assetFileUrl");
/**
* Resources of individual files, identified by their file index.
*
* @type {Map<number, GSplatResource>}
*/
__publicField(this, "fileResources", /* @__PURE__ */ new Map());
/**
* Reference counts for each file by file index. Index is fileIndex, value is reference count.
* When a file reaches zero references, it is scheduled for cooldown and unload.
*
* @type {Int32Array}
*/
__publicField(this, "fileRefCounts");
/**
* Cooldown timers for files that reached zero references. Key is fileIndex, value is ticks
* remaining.
*
* @type {Map<number, number>}
*/
__publicField(this, "cooldowns", /* @__PURE__ */ new Map());
/**
* Optional environment asset URL.
*
* @type {string|null}
*/
__publicField(this, "environmentUrl", null);
/**
* Loaded environment resource.
*
* @type {GSplatResource|null}
*/
__publicField(this, "environmentResource", null);
/**
* Reference count for environment usage.
*/
__publicField(this, "environmentRefCount", 0);
/**
* Asset loader used for loading/unloading resources.
*
* @type {GSplatAssetLoaderBase|null}
*/
__publicField(this, "assetLoader", null);
/**
* Whether this octree has been destroyed.
*/
__publicField(this, "destroyed", false);
/**
* Number of update ticks before unloading unused file resources. Set from GSplatParams.
*
* @private
*/
__publicField(this, "cooldownTicks", 100);
this.lodLevels = data.lodLevels;
this.assetFileUrl = assetFileUrl;
const baseDir = path.getDirectory(assetFileUrl);
this.files = data.filenames.map((url) => ({
url: path.isRelativePath(url) ? path.join(baseDir, url) : url,
lodLevel: -1
}));
this.fileRefCounts = new Int32Array(this.files.length);
if (data.environment) {
this.environmentUrl = path.isRelativePath(data.environment) ? path.join(baseDir, data.environment) : data.environment;
}
const leafNodes = [];
this._extractLeafNodes(data.tree, leafNodes);
this.nodes = leafNodes.map((nodeData) => {
const lods = [];
for (let i = 0; i < this.lodLevels; i++) {
const lodData = nodeData.lods[i.toString()];
if (lodData) {
lods.push({
file: this.files[lodData.file].url || "",
fileIndex: lodData.file,
offset: lodData.offset || 0,
count: lodData.count || 0
});
this.files[lodData.file].lodLevel = i;
} else {
lods.push({
file: "",
fileIndex: -1,
offset: 0,
count: 0
});
}
}
return new GSplatOctreeNode(lods, nodeData.bound);
});
const nodeCount = this.nodes.length;
const boundsFlat = new Float32Array(nodeCount * 6);
for (let i = 0; i < nodeCount; i++) {
const bounds = this.nodes[i].bounds;
const mn = bounds.getMin();
const mx = bounds.getMax();
const b = i * 6;
boundsFlat[b + 0] = mn.x;
boundsFlat[b + 1] = mn.y;
boundsFlat[b + 2] = mn.z;
boundsFlat[b + 3] = mx.x;
boundsFlat[b + 4] = mx.y;
boundsFlat[b + 5] = mx.z;
}
this.nodeBoundsMinMax = boundsFlat;
}
/**
* Destroys the octree and clears internal state. Does not force-unload resources as they may
* still be referenced by managers. Resources will be cleaned up when their reference counts
* reach zero through the normal cleanup mechanisms.
*/
destroy() {
this.destroyed = true;
this.fileResources.clear();
this.cooldowns.clear();
this.assetLoader?.destroy();
this.assetLoader = null;
this.environmentResource = null;
}
/**
* Trace out per-LOD counts of currently loaded file resources.
*
* @private
*/
_traceLodCounts() {
Debug.call(() => {
if (!Tracing.get(TRACEID_OCTREE_RESOURCES)) return;
const loadedCounts = /* @__PURE__ */ new Map();
for (const fileIndex of this.fileResources.keys()) {
const lod = this.files[fileIndex].lodLevel;
loadedCounts.set(lod, (loadedCounts.get(lod) || 0) + 1);
}
const maxLod = Math.max(0, this.lodLevels - 1);
const loadedSummary = Array.from({ length: maxLod + 1 }, (_, i) => loadedCounts.get(i) || 0).join(" / ");
Debug.trace(TRACEID_OCTREE_RESOURCES, `${this.assetFileUrl}: LOD resources in memory: ${loadedSummary}`);
});
}
/**
* Recursively extracts leaf nodes (nodes with 'lods' property) from the hierarchical tree.
*
* @param {Object} node - The current tree node to process.
* @param {Array} leafNodes - Array to collect leaf nodes.
* @private
*/
_extractLeafNodes(node, leafNodes) {
if (node.lods) {
leafNodes.push({
lods: node.lods,
bound: node.bound
});
} else if (node.children) {
for (const child of node.children) {
this._extractLeafNodes(child, leafNodes);
}
}
}
getFileResource(fileIndex) {
return this.fileResources.get(fileIndex);
}
/**
* Increments reference count for a file by index and cancels any pending cooldown.
*
* @param {number} fileIndex - Index of the file in `files` array.
*/
incRefCount(fileIndex) {
Debug.assert(fileIndex >= 0 && fileIndex < this.files.length);
const count = this.fileRefCounts[fileIndex] + 1;
this.fileRefCounts[fileIndex] = count;
this.cooldowns.delete(fileIndex);
}
/**
* Decrements reference count for a file by index. When it reaches zero, either unload
* immediately (if cooldownTicks is 0) or schedule for cooldown.
*
* @param {number} fileIndex - Index of the file in `files` array.
* @param {number} cooldownTicks - Number of update ticks before unloading when unused. If 0,
* unload immediately.
*/
decRefCount(fileIndex, cooldownTicks) {
Debug.assert(fileIndex >= 0 && fileIndex < this.files.length);
const count = this.fileRefCounts[fileIndex] - 1;
this.fileRefCounts[fileIndex] = count;
Debug.assert(count >= 0);
if (count === 0) {
if (cooldownTicks === 0) {
this.unloadResource(fileIndex);
} else {
this.cooldowns.set(fileIndex, cooldownTicks);
}
}
}
/**
* Unloads a resource for a file index if currently loaded.
*
* @param {number} fileIndex - Index of the file in `files` array.
*/
unloadResource(fileIndex) {
Debug.assert(fileIndex >= 0 && fileIndex < this.files.length);
if (!this.assetLoader) {
return;
}
const fullUrl = this.files[fileIndex].url;
this.assetLoader.unload(fullUrl);
if (this.fileResources.has(fileIndex)) {
this.fileResources.delete(fileIndex);
this._traceLodCounts();
}
}
/**
* Advances cooldowns for zero-ref files and unloads those whose timers expired.
*
* @param {number} cooldownTicks - Number of ticks for new cooldowns, synced from GSplatParams.
*/
updateCooldownTick(cooldownTicks) {
this.cooldownTicks = cooldownTicks;
if (this.cooldowns.size > 0) {
this.cooldowns.forEach((remaining, fileIndex) => {
if (remaining <= 1) {
if (this.fileRefCounts[fileIndex] === 0) {
this.unloadResource(fileIndex);
}
_toDelete.push(fileIndex);
} else {
this.cooldowns.set(fileIndex, remaining - 1);
}
});
_toDelete.forEach((idx) => this.cooldowns.delete(idx));
_toDelete.length = 0;
}
}
/**
* Ensures a file resource is loaded and available. This function:
* - Starts loading if not already started
* - Checks if loading completed and stores the resource if available
*
* @param {number} fileIndex - The index of the file in the `files` array.
*/
ensureFileResource(fileIndex) {
Debug.assert(fileIndex >= 0 && fileIndex < this.files.length);
Debug.assert(this.assetLoader);
if (this.fileResources.has(fileIndex)) {
return;
}
const fullUrl = this.files[fileIndex].url;
const res = this.assetLoader?.getResource(fullUrl);
if (res) {
this.fileResources.set(fileIndex, res);
res.releaseTextureSources?.();
if (this.fileRefCounts[fileIndex] === 0) {
this.cooldowns.set(fileIndex, this.cooldownTicks);
}
this._traceLodCounts();
return;
}
this.assetLoader?.load(fullUrl);
}
/**
* Increments reference count for environment.
*/
incEnvironmentRefCount() {
this.environmentRefCount++;
}
/**
* Decrements reference count for environment. When it reaches zero, immediately unload.
*/
decEnvironmentRefCount() {
this.environmentRefCount--;
Debug.assert(this.environmentRefCount >= 0);
if (this.environmentRefCount === 0) {
this.unloadEnvironmentResource();
}
}
/**
* Ensures environment resource is loaded and available.
*/
ensureEnvironmentResource() {
if (!this.assetLoader) {
return;
}
if (!this.environmentUrl) {
return;
}
if (this.environmentResource) {
return;
}
const res = this.assetLoader.getResource(this.environmentUrl);
if (res) {
this.environmentResource = res;
if (this.environmentRefCount === 0) {
this.unloadEnvironmentResource();
}
return;
}
this.assetLoader.load(this.environmentUrl);
}
/**
* Unloads environment resource if currently loaded.
*/
unloadEnvironmentResource() {
if (!this.assetLoader) {
return;
}
if (this.environmentResource && this.environmentUrl) {
this.assetLoader.unload(this.environmentUrl);
this.environmentResource = null;
}
}
}
export {
GSplatOctree
};