Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
binding: new BindingTwoWay(),
link: { observer, path: 'splatBudget' },
min: 0,
max: 20,
max: 40,
precision: 1,
step: 0.1
})
Expand Down
232 changes: 84 additions & 148 deletions src/scene/gsplat-unified/gsplat-frustum-culler.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
import { Frustum } from '../../core/shape/frustum.js';
import { Mat4 } from '../../core/math/mat4.js';
import { Vec2 } from '../../core/math/vec2.js';
import { PIXELFORMAT_R32U, PIXELFORMAT_RGBA32F } from '../../platform/graphics/constants.js';
import { RenderTarget } from '../../platform/graphics/render-target.js';
import { Texture } from '../../platform/graphics/texture.js';
import { TextureUtils } from '../../platform/graphics/texture-utils.js';
import { GSplatNodeCullRenderPass } from './gsplat-node-cull-render-pass.js';
import { BUFFERUSAGE_COPY_DST } from '../../platform/graphics/constants.js';
import { StorageBuffer } from '../../platform/graphics/storage-buffer.js';

/**
* @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js'
* @import { GSplatInfo } from "./gsplat-info.js"
*/

const tmpSize = new Vec2();
const _viewProjMat = new Mat4();
const _frustum = new Frustum();
const _frustumPlanes = new Float32Array(24);

// 8 u32/f32 elements per BoundsEntry (matches WGSL struct layout):
// [centerX, centerY, centerZ, radius, transformIndex, pad, pad, pad]
const BOUNDS_ENTRY_FLOATS = 8;

/**
* GPU frustum culling for GSplat octree nodes. Manages bounding-sphere and transform
* textures, runs a render-pass that tests each sphere against camera frustum planes,
* and produces a bit-packed visibility texture consumed by interval compaction.
* Frustum culling data for GSplat octree nodes. Manages bounding-sphere and
* transform storage buffers and computes frustum planes from camera matrices.
* The actual culling test is performed inline by the interval compaction compute shader.
*
* @ignore
*/
Expand All @@ -29,61 +27,53 @@ class GSplatFrustumCuller {
device;

/**
* RGBA32F texture storing local-space bounding spheres for all selected nodes
* across all GSplatInfos. Each texel is (center.x, center.y, center.z, radius).
* Created lazily on first use and resized as needed.
* Storage buffer holding interleaved BoundsEntry structs (center.xyz, radius,
* transformIndex, pad x3). 32 bytes per entry.
*
* @type {Texture|null}
* @type {StorageBuffer|null}
*/
boundsSphereTexture = null;
boundsBuffer = null;

/**
* R32U texture mapping each bounds entry to its GSplatInfo index (for transform lookup).
* Same dimensions as boundsSphereTexture. Created lazily on first use and resized as needed.
* Total number of bounds entries across all GSplatInfos.
*
* @type {Texture|null}
* @type {number}
*/
boundsTransformIndexTexture = null;
totalBoundsEntries = 0;

/**
* R32U texture storing per-node visibility as packed bitmasks.
* Each texel packs 32 visibility bits, so width is boundsSphereTexture.width / 32.
* Written by the culling render pass.
*
* @type {Texture|null}
*/
nodeVisibilityTexture = null;
/** @type {number} */
_allocatedBoundsEntries = 0;

/**
* Render target wrapping nodeVisibilityTexture for the culling pass.
*
* @type {RenderTarget|null}
*/
cullingRenderTarget = null;
/** @type {Float32Array|null} */
_boundsFloatView = null;

/**
* GPU frustum culling render pass. Created lazily on first use.
*
* @type {GSplatNodeCullRenderPass|null}
*/
cullingPass = null;
/** @type {Uint32Array|null} */
_boundsUintView = null;

/** @type {Float32Array|null} */
_tmpSpheres = null;

/**
* Total number of bounds entries across all GSplatInfos.
* Storage buffer holding world matrices as vec4f triplets (3 vec4f per matrix,
* rows of a 4x3 affine matrix). 48 bytes per matrix.
*
* @type {number}
* @type {StorageBuffer|null}
*/
totalBoundsEntries = 0;
transformsBuffer = null;

/** @type {number} */
_allocatedTransformCount = 0;

/** @type {Float32Array|null} */
_transformsData = null;

/**
* RGBA32F texture storing world matrices (3 texels per GSplatInfo, rows of a 4x3
* affine matrix) for transforming local bounding spheres to world space during
* GPU frustum culling.
* Created lazily on first use and resized as needed.
* Packed frustum planes (6 planes x 4 floats: nx, ny, nz, distance).
* Updated by {@link computeFrustumPlanes} and consumed by the interval cull shader.
*
* @type {Texture|null}
* @type {Float32Array}
*/
transformsTexture = null;
frustumPlanes = new Float32Array(24);

/**
* @param {GraphicsDevice} device - The graphics device.
Expand All @@ -93,22 +83,17 @@ class GSplatFrustumCuller {
}

destroy() {
this.boundsSphereTexture?.destroy();
this.boundsTransformIndexTexture?.destroy();
this.nodeVisibilityTexture?.destroy();
this.cullingRenderTarget?.destroy();
this.cullingPass?.destroy();
this.transformsTexture?.destroy();
this.boundsBuffer?.destroy();
this.transformsBuffer?.destroy();
}

/**
* Updates the bounds sphere texture with local-space bounding spheres from pre-built
* bounds groups. Each group contributes one set of sphere entries and maps to one
* transform index.
* Updates the bounds buffer with local-space bounding spheres and transform
* indices from pre-built bounds groups.
*
* @param {Array<{splat: GSplatInfo, boundsBaseIndex: number, numBoundsEntries: number}>} boundsGroups - Pre-built bounds groups.
*/
updateBoundsTexture(boundsGroups) {
updateBoundsData(boundsGroups) {
let totalEntries = 0;
for (let i = 0; i < boundsGroups.length; i++) {
totalEntries += boundsGroups[i].numBoundsEntries;
Expand All @@ -118,73 +103,69 @@ class GSplatFrustumCuller {

if (totalEntries === 0) return;

// Width is multiple of 32 so that 32 consecutive spheres always land on the same
// texture row, allowing the bit-packed culling shader to avoid per-iteration modulo/division.
const { x: width, y: height } = TextureUtils.calcTextureSize(totalEntries, tmpSize, 32);
if (totalEntries > this._allocatedBoundsEntries) {
this.boundsBuffer?.destroy();
this._allocatedBoundsEntries = totalEntries;
this.boundsBuffer = new StorageBuffer(this.device, totalEntries * BOUNDS_ENTRY_FLOATS * 4, BUFFERUSAGE_COPY_DST);

// Create/resize bounds sphere texture (RGBA32F: center.xyz, radius)
if (!this.boundsSphereTexture) {
this.boundsSphereTexture = Texture.createDataTexture2D(this.device, 'boundsSphereTexture', width, height, PIXELFORMAT_RGBA32F);
} else {
this.boundsSphereTexture.resize(width, height);
const ab = new ArrayBuffer(totalEntries * BOUNDS_ENTRY_FLOATS * 4);
this._boundsFloatView = new Float32Array(ab);
this._boundsUintView = new Uint32Array(ab);
this._tmpSpheres = new Float32Array(totalEntries * 4);
}

// Create/resize transform index texture (R32U: group index per bounds entry)
if (!this.boundsTransformIndexTexture) {
this.boundsTransformIndexTexture = Texture.createDataTexture2D(this.device, 'boundsTransformIndexTexture', width, height, PIXELFORMAT_R32U);
} else {
this.boundsTransformIndexTexture.resize(width, height);
}

const sphereData = this.boundsSphereTexture.lock();
const indexData = /** @type {Uint32Array} */ (this.boundsTransformIndexTexture.lock());
const floatView = this._boundsFloatView;
const uintView = this._boundsUintView;
const tmpSpheres = this._tmpSpheres;

for (let i = 0; i < boundsGroups.length; i++) {
const group = boundsGroups[i];
const base = group.boundsBaseIndex;
const count = group.numBoundsEntries;

group.splat.writeBoundsSpheres(sphereData, base * 4);
group.splat.writeBoundsSpheres(tmpSpheres, base * 4);

for (let j = 0; j < count; j++) {
indexData[base + j] = i;
const src = (base + j) * 4;
const dst = (base + j) * BOUNDS_ENTRY_FLOATS;
floatView[dst + 0] = tmpSpheres[src + 0];
floatView[dst + 1] = tmpSpheres[src + 1];
floatView[dst + 2] = tmpSpheres[src + 2];
floatView[dst + 3] = tmpSpheres[src + 3];
uintView[dst + 4] = i;
// [dst+5..dst+7] are zero-initialized by ArrayBuffer
}
}

this.boundsSphereTexture.unlock();
this.boundsTransformIndexTexture.unlock();
this.boundsBuffer.write(0, floatView);
}

/**
* Updates the transforms texture with one world matrix per bounds group.
* Each matrix uses 3 texels (RGBA32F per row) in the texture.
* Updates the transforms buffer with one world matrix per bounds group.
* Each matrix is stored as 3 vec4f (rows of a 4x3 affine matrix).
*
* @param {Array<{splat: GSplatInfo, boundsBaseIndex: number, numBoundsEntries: number}>} boundsGroups - Pre-built bounds groups.
*/
updateTransformsTexture(boundsGroups) {
updateTransformsData(boundsGroups) {
const numMatrices = boundsGroups.length;
if (numMatrices === 0) return;

// 3 texels per matrix (rows of a 4x3 affine matrix). Width is a multiple of 3 so all 3
// texels of a matrix always land on the same texture row.
const totalTexels = numMatrices * 3;
const { x: width, y: height } = TextureUtils.calcTextureSize(totalTexels, tmpSize, 3);

if (!this.transformsTexture) {
this.transformsTexture = Texture.createDataTexture2D(this.device, 'transformsTexture', width, height, PIXELFORMAT_RGBA32F);
} else {
this.transformsTexture.resize(width, height);
if (numMatrices > this._allocatedTransformCount) {
this.transformsBuffer?.destroy();
this._allocatedTransformCount = numMatrices;
// 3 vec4f per matrix = 12 floats = 48 bytes
this.transformsBuffer = new StorageBuffer(this.device, numMatrices * 12 * 4, BUFFERUSAGE_COPY_DST);
this._transformsData = new Float32Array(numMatrices * 12);
}

const data = this.transformsTexture.lock();
const data = this._transformsData;

Comment on lines +153 to 162
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateTransformsData allocates a new Float32Array every call. Since this runs whenever frustum planes are updated (and can be frequent), consider caching/reusing a typed array sized to _allocatedTransformCount * 12 to reduce GC pressure and improve frame stability.

Copilot uses AI. Check for mistakes.
// Write world matrices as 3 rows of a 4x3 matrix (row-major, 12 floats per matrix).
// Mat4.data is column-major: [col0(4), col1(4), col2(4), col3(4)].
// We store 3 rows, each as (Rx, Ry, Rz, T):
// row0 = data[0], data[4], data[8], data[12]
// row1 = data[1], data[5], data[9], data[13]
// row2 = data[2], data[6], data[10], data[14]
// The shader reconstructs the mat4 by transposing + appending (0,0,0,1).
let offset = 0;
for (let i = 0; i < boundsGroups.length; i++) {
const m = boundsGroups[i].splat.node.getWorldTransform().data;
Expand All @@ -196,72 +177,27 @@ class GSplatFrustumCuller {
data[offset++] = m[2]; data[offset++] = m[6]; data[offset++] = m[10]; data[offset++] = m[14];
}

this.transformsTexture.unlock();
this.transformsBuffer.write(0, data);
}

/**
* Runs the GPU frustum culling pass to generate the node visibility texture.
* Computes the view-projection matrix, extracts frustum planes, and tests each
* bounding sphere against them.
* Computes frustum planes from camera matrices and stores them in
* {@link frustumPlanes} for use by the interval cull compute shader.
*
* @param {Mat4} projectionMatrix - The camera projection matrix.
* @param {Mat4} viewMatrix - The camera view matrix.
*/
updateNodeVisibility(projectionMatrix, viewMatrix) {
if (this.totalBoundsEntries === 0 || !this.boundsSphereTexture || !this.boundsTransformIndexTexture || !this.transformsTexture) {
return;
}

// Compute view-projection matrix and extract frustum planes
computeFrustumPlanes(projectionMatrix, viewMatrix) {
_viewProjMat.mul2(projectionMatrix, viewMatrix);
_frustum.setFromMat4(_viewProjMat);
const planes = this.frustumPlanes;
for (let p = 0; p < 6; p++) {
const plane = _frustum.planes[p];
_frustumPlanes[p * 4 + 0] = plane.normal.x;
_frustumPlanes[p * 4 + 1] = plane.normal.y;
_frustumPlanes[p * 4 + 2] = plane.normal.z;
_frustumPlanes[p * 4 + 3] = plane.distance;
}

// Visibility texture is 32x smaller: each texel stores 32 sphere results as bits.
// Since boundsTextureWidth is a multiple of 32, the visibility texture is exactly
// (boundsWidth/32) x boundsHeight, keeping a 1:1 row correspondence and allowing
// the shader to derive visWidth = boundsTextureWidth / 32 without extra uniforms.
const width = this.boundsSphereTexture.width / 32;
const height = this.boundsSphereTexture.height;

// Create/resize visibility texture (R32U: bit-packed, 32 spheres per texel)
if (!this.nodeVisibilityTexture) {
this.nodeVisibilityTexture = Texture.createDataTexture2D(this.device, 'nodeVisibilityTexture', width, height, PIXELFORMAT_R32U);

this.cullingRenderTarget = new RenderTarget({
name: 'NodeCullingRT',
colorBuffer: this.nodeVisibilityTexture,
depth: false
});
} else if (this.nodeVisibilityTexture.width !== width || this.nodeVisibilityTexture.height !== height) {
this.nodeVisibilityTexture.resize(width, height);
/** @type {RenderTarget} */ (this.cullingRenderTarget).resize(width, height);
planes[p * 4 + 0] = plane.normal.x;
planes[p * 4 + 1] = plane.normal.y;
planes[p * 4 + 2] = plane.normal.z;
planes[p * 4 + 3] = plane.distance;
}

// Lazily create the culling render pass
if (!this.cullingPass) {
this.cullingPass = new GSplatNodeCullRenderPass(this.device);
this.cullingPass.init(this.cullingRenderTarget);
this.cullingPass.colorOps.clear = true;
this.cullingPass.colorOps.clearValue.set(0, 0, 0, 0);
}

// Set up uniforms and execute
this.cullingPass.setup(
this.boundsSphereTexture,
this.boundsTransformIndexTexture,
this.transformsTexture,
this.totalBoundsEntries,
_frustumPlanes
);

this.cullingPass.render();
}
}

Expand Down
Loading