playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
368 lines (365 loc) • 17 kB
JavaScript
import { Debug } from '../../core/debug.js';
import { Mat4 } from '../../core/math/mat4.js';
import { Vec2 } from '../../core/math/vec2.js';
import { BoundingBox } from '../../core/shape/bounding-box.js';
import { PIXELFORMAT_RGBA32U } from '../../platform/graphics/constants.js';
import { Texture } from '../../platform/graphics/texture.js';
import { TextureUtils } from '../../platform/graphics/texture-utils.js';
/**
* @import { GraphicsDevice } from "../../platform/graphics/graphics-device.js";
* @import { GSplatResourceBase } from "../gsplat/gsplat-resource-base.js"
* @import { GSplatPlacement } from "./gsplat-placement.js"
* @import { GSplatStreams } from "../gsplat/gsplat-streams.js"
* @import { GraphNode } from '../graph-node.js';
* @import { GSplatOctreeNode } from './gsplat-octree-node.js';
* @import { NodeInfo } from './gsplat-octree-instance.js';
* @import { ScopeId } from '../../platform/graphics/scope-id.js';
*/ const tmpSize = new Vec2();
// Reusable buffer for sub-draw data (only grows, never shrinks)
let subDrawDataArray = new Uint32Array(0);
// Temporary full-range interval used by updateSubDraws when this.intervals is empty
const _fullRangeInterval = [
0,
0
];
/**
* Represents a snapshot of gsplat state for rendering. This class captures all necessary data
* at a point in time and should not hold references back to the source placement. All required
* data should be copied or referenced, allowing placement to be modified without affecting the info.
*
* @ignore
*/ class GSplatInfo {
destroy() {
this.intervals.length = 0;
this.intervalOffsets.length = 0;
this.intervalAllocIds.length = 0;
this.intervalNodeIndices.length = 0;
this.subDrawTexture?.destroy();
this.subDrawTexture = null;
this.subDrawCount = 0;
}
/**
* Sets per-interval pixel offsets for this splat. Sub-draw computation and GPU texture
* creation are deferred to {@link ensureSubDrawTexture} to avoid work for splats that
* may never be rendered (e.g. intermediate world states or unchanged splats).
*
* @param {number[]} intervalOffsets - Per-interval pixel offsets in the work buffer.
*/ setLayout(intervalOffsets) {
this.intervalOffsets = intervalOffsets;
this.subDrawTexture?.destroy();
this.subDrawTexture = null;
this.subDrawCount = 0;
}
/**
* Ensures the sub-draw texture exists, computing sub-draw data and creating the GPU texture
* on first call. Must be called outside a render pass (e.g. in the render pass update method)
* since WebGPU does not allow texture creation inside a render pass.
*
* @param {number} textureWidth - The work buffer texture width.
*/ ensureSubDrawTexture(textureWidth) {
if (!this.subDrawTexture && textureWidth > 0) {
this.updateSubDraws(textureWidth);
}
}
/**
* Updates the flattened intervals array from placement intervals. Intervals are sorted and
* stored as half-open pairs [start, end). Called once from the constructor; sub-draw data
* is built later in setLayout when the work buffer texture width is known.
*
* @param {Map<number, Vec2>} intervals - Map of node index to inclusive [x, y] intervals.
*/ updateIntervals(intervals) {
const resource = this.resource;
this.intervals.length = 0;
this.intervalAllocIds.length = 0;
this.intervalNodeIndices.length = 0;
this.activeSplats = resource.numSplats;
// If placement has intervals defined
if (intervals.size > 0) {
// Write half-open intervals, count total splats, and build per-interval allocIds/nodeIndices
let totalCount = 0;
let k = 0;
this.intervals.length = intervals.size * 2;
for (const [nodeIndex, interval] of intervals){
this.intervals[k++] = interval.x;
this.intervals[k++] = interval.y + 1;
totalCount += interval.y - interval.x + 1;
if (this.nodeInfos) {
this.intervalAllocIds.push(this.nodeInfos[nodeIndex].allocId);
this.intervalNodeIndices.push(nodeIndex);
}
}
if (this.octreeNodes) {
// Octree: always keep intervals (even when fully loaded) so each node
// maintains its own non-contiguous offset in the work buffer.
// numBoundsEntries covers ALL nodes for stable boundsBaseIndex across LOD changes.
this.activeSplats = totalCount;
this.numBoundsEntries = this.octreeNodes.length;
} else if (totalCount === this.numSplats) {
// Non-octree: clear intervals when they cover the full range
this.intervals.length = 0;
} else {
this.activeSplats = totalCount;
}
} else {
// Non-octree: single bounds entry, single allocation
this.numBoundsEntries = 1;
this.intervalAllocIds.push(this.allocId);
// check if we need to limit to active splats (instead of rendering all splats)
const totalCapacity = resource.maxSplats;
if (totalCapacity && this.activeSplats < totalCapacity) {
// Provide interval [0, numSplats) to limit sorting to active splats only
this.intervals[0] = 0;
this.intervals[1] = this.activeSplats;
}
}
}
/**
* Splits an interval at row boundaries into sub-draws (partial first row, full middle rows,
* partial last row) and appends them to the sub-draw data array.
*
* @param {Uint32Array} subDrawData - The output array to append sub-draw entries to.
* @param {number} subDrawCount - Current number of sub-draws already in the array.
* @param {number} sourceBase - Source splat index for this interval.
* @param {number} size - Number of splats in this interval.
* @param {number} targetOffset - Pixel offset in the work buffer texture.
* @param {number} textureWidth - Width of the work buffer texture.
* @returns {number} Updated sub-draw count.
*/ appendSubDraws(subDrawData, subDrawCount, sourceBase, size, targetOffset, textureWidth) {
let remaining = size;
let row = targetOffset / textureWidth | 0;
const col = targetOffset % textureWidth;
if (col > 0) {
const count = Math.min(remaining, textureWidth - col);
const idx = subDrawCount * 4;
subDrawData[idx] = row | 1 << 16;
subDrawData[idx + 1] = col;
subDrawData[idx + 2] = col + count;
subDrawData[idx + 3] = sourceBase;
subDrawCount++;
sourceBase += count;
remaining -= count;
row++;
}
const fullRows = remaining / textureWidth | 0;
if (fullRows > 0) {
const idx = subDrawCount * 4;
subDrawData[idx] = row | fullRows << 16;
subDrawData[idx + 1] = 0;
subDrawData[idx + 2] = textureWidth;
subDrawData[idx + 3] = sourceBase;
subDrawCount++;
sourceBase += fullRows * textureWidth;
remaining -= fullRows * textureWidth;
row += fullRows;
}
if (remaining > 0) {
const idx = subDrawCount * 4;
subDrawData[idx] = row | 1 << 16;
subDrawData[idx + 1] = 0;
subDrawData[idx + 2] = remaining;
subDrawData[idx + 3] = sourceBase;
subDrawCount++;
}
return subDrawCount;
}
/**
* Builds the sub-draw data texture from the current intervals (or a synthetic full-range
* interval when none exist). Each interval is split at row boundaries of the work buffer
* texture to produce axis-aligned rectangles stored as a small RGBA32U texture.
*
* @param {number} textureWidth - The work buffer texture width.
*/ updateSubDraws(textureWidth) {
// Use a local full-range interval when none exist, so the instanced draw path
// always has sub-draws. This must NOT mutate this.intervals because the GPU
// interval compaction reads this.intervals separately for per-node culling.
let intervals = this.intervals;
let numIntervals = intervals.length / 2;
if (numIntervals === 0) {
_fullRangeInterval[0] = 0;
_fullRangeInterval[1] = this.activeSplats;
intervals = _fullRangeInterval;
numIntervals = 1;
}
// Split intervals at row boundaries. Each interval produces at most 3 sub-draws:
// partial first row, full middle rows, partial last row.
// Reuse module-scope buffer, growing if needed (4 uints per sub-draw, 3 sub-draws per interval max).
const maxSubDraws = numIntervals * 3;
const requiredSize = maxSubDraws * 4;
if (subDrawDataArray.length < requiredSize) {
subDrawDataArray = new Uint32Array(requiredSize);
}
const subDrawData = subDrawDataArray;
let subDrawCount = 0;
for(let i = 0; i < numIntervals; i++){
subDrawCount = this.appendSubDraws(subDrawData, subDrawCount, intervals[i * 2], intervals[i * 2 + 1] - intervals[i * 2], this.intervalOffsets[i], textureWidth);
}
this.subDrawCount = subDrawCount;
// Calculate 2D texture dimensions to stay within device limits
const { x: texWidth, y: texHeight } = TextureUtils.calcTextureSize(subDrawCount, tmpSize);
// Create the sub-draw data texture
this.subDrawTexture = Texture.createDataTexture2D(this.device, 'subDrawData', texWidth, texHeight, PIXELFORMAT_RGBA32U);
// Upload sub-draw data
const texData = this.subDrawTexture.lock();
texData.set(subDrawData.subarray(0, subDrawCount * 4));
this.subDrawTexture.unlock();
}
update() {
const worldMatrix = this.node.getWorldTransform();
const worldMatrixChanged = !this.previousWorldTransform.equals(worldMatrix);
if (worldMatrixChanged) {
this.previousWorldTransform.copy(worldMatrix);
}
const renderDirty = this._consumeRenderDirty ? this._consumeRenderDirty() : false;
return worldMatrixChanged || renderDirty;
}
/**
* Writes bounding sphere data for this GSplatInfo into a shared Float32Array.
* For octree resources, writes spheres for ALL nodes (indexed by nodeIndex) to keep
* boundsBaseIndex stable across LOD changes.
* For non-octree resources, computes a single sphere from the resource AABB.
*
* @param {Float32Array} data - The shared bounds sphere data array.
* @param {number} offset - The float offset to start writing at.
*/ writeBoundsSpheres(data, offset) {
if (this.octreeNodes) {
for(let i = 0; i < this.octreeNodes.length; i++){
const s = this.octreeNodes[i].boundingSphere;
data[offset++] = s.x;
data[offset++] = s.y;
data[offset++] = s.z;
data[offset++] = s.w;
}
} else {
// Non-octree: single sphere from resource AABB
const aabb = this.resource.aabb;
const he = aabb.halfExtents;
const r = Math.sqrt(he.x * he.x + he.y * he.y + he.z * he.z);
data[offset++] = aabb.center.x;
data[offset++] = aabb.center.y;
data[offset++] = aabb.center.z;
data[offset++] = r;
}
}
get hasSphericalHarmonics() {
return this.resource.gsplatData?.shBands > 0;
}
/**
* Create a new GSplatInfo.
*
* @param {GraphicsDevice} device - The graphics device.
* @param {GSplatResourceBase} resource - The splat resource.
* @param {GSplatPlacement} placement - The placement of the splat.
* @param {Function|null} [consumeRenderDirty] - Callback to consume render dirty flag.
* @param {GSplatOctreeNode[]|null} [octreeNodes] - Octree nodes for bounds lookup.
* @param {NodeInfo[]|null} [nodeInfos] - Per-node info array from octree instance.
*/ constructor(device, resource, placement, consumeRenderDirty = null, octreeNodes = null, nodeInfos = null){
/** @type {number} */ this.activeSplats = 0;
/**
* Array of intervals for remapping of indices, each two consecutive numbers represent
* start and end of a range of splats.
*
* @type {number[]}
*/ this.intervals = [];
/**
* Per-interval pixel offsets in the work buffer. For non-octree splats this has one entry.
* For octree splats each entry corresponds to one interval in this.intervals.
*
* @type {number[]}
*/ this.intervalOffsets = [];
/**
* Per-interval allocation IDs for persistent tracking. Parallel to intervals: for octree
* splats each entry is the NodeInfo.allocId for that interval's node; for non-octree
* splats this has one entry equal to this.allocId.
*
* @type {number[]}
*/ this.intervalAllocIds = [];
/**
* Per-interval octree node indices. Parallel to intervals: for octree splats each entry
* is the nodeIndex for that interval. Empty for non-octree splats.
*
* @type {number[]}
*/ this.intervalNodeIndices = [];
/** @type {Mat4} */ this.previousWorldTransform = new Mat4();
/** @type {BoundingBox} */ this.aabb = new BoundingBox();
/**
* Small RGBA32U texture storing per-sub-draw data for instanced interval rendering.
* Each texel: R = rowStart | (numRows << 16), G = colStart, B = colEnd, A = sourceBase.
* Created lazily by {@link ensureSubDrawTexture} when needed for rendering.
*
* @type {Texture|null}
*/ this.subDrawTexture = null;
/**
* Number of sub-draw instances for instanced interval rendering.
*
* @type {number}
*/ this.subDrawCount = 0;
/**
* Number of bounding sphere entries this GSplatInfo contributes to the shared bounds texture.
*
* @type {number}
*/ this.numBoundsEntries = 0;
/**
* Base index into the shared bounds sphere texture for this GSplatInfo's entries.
*
* @type {number}
*/ this.boundsBaseIndex = 0;
/**
* Octree nodes array reference for writing bounding sphere data. Set when the GSplatInfo
* is created from an octree placement.
*
* @type {GSplatOctreeNode[]|null}
*/ this.octreeNodes = null;
/**
* Per-node info array from the octree instance, providing allocId for each node.
* Indexed by nodeIndex. Null for non-octree splats.
*
* @type {NodeInfo[]|null}
*/ this.nodeInfos = null;
/** @type {number} */ this.colorAccumulatedTranslation = 0;
/**
* Per-instance shader parameters. Reference to the component's parameters Map.
*
* @type {Map<string, {scopeId: ScopeId, data: *}>|null}
*/ this.parameters = null;
/**
* Function to get current work buffer modifier from source placement.
* Retrieved live (not snapshotted) to ensure shader configuration stays current.
*
* @type {(() => ({ code: string, hash: number }|null))|null}
*/ this.getWorkBufferModifier = null;
/**
* Function to get current instance streams from source placement.
* Retrieved live (not snapshotted) to ensure streams are available after lazy creation.
*
* @type {(() => GSplatStreams|null)|null}
*/ this.getInstanceStreams = null;
/**
* Callback to consume render dirty flag from the source placement.
*
* @type {Function|null}
* @private
*/ this._consumeRenderDirty = null;
Debug.assert(resource);
Debug.assert(placement);
this.device = device;
this.resource = resource;
this.node = placement.node;
this.lodIndex = placement.lodIndex;
this.placementId = placement.id;
this.allocId = placement.allocId;
// Only octree file splats (with octreeNodes) share the parent's bounds group.
// Other child placements (e.g. environment) have independent bounds and must
// use their own allocId.
this.parentPlacementId = octreeNodes && placement.parentPlacement ? placement.parentPlacement.allocId : placement.allocId;
this.numSplats = resource.numSplats;
this.aabb.copy(placement.aabb);
this.parameters = placement.parameters;
this.getWorkBufferModifier = ()=>placement.workBufferModifier;
this.getInstanceStreams = ()=>placement.streams;
this._consumeRenderDirty = consumeRenderDirty;
this.octreeNodes = octreeNodes;
this.nodeInfos = nodeInfos;
this.updateIntervals(placement.intervals);
}
}
export { GSplatInfo };