From 51149c88040e4acdb0e9f0db38d30867e9178233 Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Thu, 27 Aug 2026 13:24:56 +0100 Subject: [PATCH 1/8] Error-driven GSplat LOD selection with a derived fallback LOD selection for streamed SOG was geometric: each node picked a level from camera-distance bands, and the budget balancer redistributed by distance bucket. Distance says nothing about how much a node actually loses at a given level - a node of near-duplicate splats and one carrying fine detail at the same distance were treated alike. Spend the budget by measured quality loss instead. Every node starts at the cheapest level it can render, and each single-level upgrade in the scene is ranked by coverage * error removed / splats added, then bought best-first until one does not fit. Errors come from the manifest when splat-transform wrote them, and are derived from splat counts otherwise, so there is a single code path rather than a fallback to distance. Measured against authored errors on three captures, the derived proxy lands within 2-17% of them, where the distance-based system it replaces was 20-790% worse. Budget is now always enforced, which makes lodBaseDistance and lodMultiplier inert - they are removed, and a non-positive budget warns and uses the default rather than pinning every node to its coarsest level. --- .../gaussian-splatting/benchmark.example.mjs | 2 +- .../gaussian-splatting/billions.example.mjs | 33 +- .../gaussian-splatting/clipping.example.mjs | 2 - .../gaussian-splatting/downtown.example.mjs | 19 +- .../lod-instances.example.mjs | 1 - .../lod-streaming-sh.controls.jsx | 18 - .../lod-streaming-sh.example.mjs | 28 +- .../lod-streaming.controls.jsx | 18 - .../lod-streaming.example.mjs | 35 +- .../gaussian-splatting/relighting.example.mjs | 37 +- .../splat-portal.example.mjs | 10 +- .../gaussian-splatting/vr-lod.example.mjs | 28 +- .../gaussian-splatting/weather.example.mjs | 5 +- .../gaussian-splatting/wind.example.mjs | 4 +- .../gaussian-splatting/world.controls.jsx | 18 - .../gaussian-splatting/world.example.mjs | 25 +- src/framework/app-base.js | 2 +- src/framework/components/gsplat/component.js | 69 +--- src/framework/components/gsplat/system.js | 2 - src/scene/gsplat-unified/constants.js | 15 +- .../gsplat-unified/gsplat-budget-balancer.js | 334 ++++++++++++------ src/scene/gsplat-unified/gsplat-lod-table.js | 284 +++++++++++++++ .../gsplat-unified/gsplat-octree-instance.js | 292 +++++---------- .../gsplat-unified/gsplat-octree-node.js | 3 + src/scene/gsplat-unified/gsplat-octree.js | 155 +++++++- src/scene/gsplat-unified/gsplat-params.js | 13 +- src/scene/gsplat-unified/gsplat-placement.js | 53 --- src/scene/gsplat-unified/gsplat-world.js | 84 +---- .../components/gsplat/component.test.mjs | 28 -- .../gsplat-budget-balancer.test.mjs | 305 ++++++++++++++++ .../gsplat-unified/gsplat-lod-table.test.mjs | 229 ++++++++++++ 31 files changed, 1403 insertions(+), 748 deletions(-) create mode 100644 src/scene/gsplat-unified/gsplat-lod-table.js create mode 100644 test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs create mode 100644 test/scene/gsplat-unified/gsplat-lod-table.test.mjs diff --git a/examples/src/examples/gaussian-splatting/benchmark.example.mjs b/examples/src/examples/gaussian-splatting/benchmark.example.mjs index 120ab22c178..b73144eb440 100644 --- a/examples/src/examples/gaussian-splatting/benchmark.example.mjs +++ b/examples/src/examples/gaussian-splatting/benchmark.example.mjs @@ -790,7 +790,7 @@ async function runBenchmark(config, colIndex, budgetIndices) { url: './assets/splats/playcanvas-logo/meta.json' }); const churchAsset = new Asset('church', 'gsplat', { - url: 'https://code.playcanvas.com/examples_data/example_roman_parish_02/lod-meta.json' + url: 'https://code.playcanvas.com/examples_data/example_roman_parish_03/lod-meta.json' }); setStatus(`${config.label} Loading assets...`); diff --git a/examples/src/examples/gaussian-splatting/billions.example.mjs b/examples/src/examples/gaussian-splatting/billions.example.mjs index 16208275bf6..38ac142641a 100644 --- a/examples/src/examples/gaussian-splatting/billions.example.mjs +++ b/examples/src/examples/gaussian-splatting/billions.example.mjs @@ -153,16 +153,6 @@ const LAYOUT = { const DEFAULT_INSTANCES_DESKTOP = 50; const DEFAULT_INSTANCES_MOBILE = 2; -// Default per-instance LOD ramp tuned for this instanced-world scale. The multiplier sets how -// fast detail falls off with distance (LOD = 1 + log(d/base)/log(mult)): a smaller multiplier -// drops distant tiles to coarse LODs sooner, which flattens how the active-splat count grows -// with instance count. The base distance sets the LOD0 radius and barely affects the far-tile -// growth, so it stays as-is. -// Mobile uses the aggressive 1.6 falloff (~3M active at 20 instances) to keep the load light; -// desktop uses a gentler 1.8 so distant tiles stay more detailed (~6M active at 20 instances). -const DEFAULT_LOD_BASE_DISTANCE = 40; -const DEFAULT_LOD_MULTIPLIER = platform.mobile ? 1.6 : 1.8; - const assets = { scene: new Asset('gsplat', 'gsplat', { url: config.url }), // equirectangular (360) LDR backdrop image @@ -241,18 +231,6 @@ const toM = (v) => `${(v / 1e6).toFixed(1)}M`; const toB = (v) => `${(v / 1e9).toFixed(1)}B`; // --- LOD tuning (temporary): seed defaults and live-apply on change --- -data.set('lodBaseDistance', DEFAULT_LOD_BASE_DISTANCE); -data.set('lodMultiplier', DEFAULT_LOD_MULTIPLIER); -const applyLod = () => { - const base = data.get('lodBaseDistance'); - const mult = data.get('lodMultiplier'); - for (let i = 0; i < gsInstances.length; i++) { - gsInstances[i].lodBaseDistance = base; - gsInstances[i].lodMultiplier = mult; - } -}; -data.on('lodBaseDistance:set', applyLod); -data.on('lodMultiplier:set', applyLod); // Each instance's grid slot is a fixed function of its index — independent of the current // instance count — so changing the count never moves (and never re-streams) the tiles we @@ -335,8 +313,6 @@ const rebuildInstances = () => { app.root.addChild(entity); instanceEntities.push(entity); const gs = /** @type {any} */ (entity.gsplat); - gs.lodBaseDistance = data.get('lodBaseDistance'); - gs.lodMultiplier = data.get('lodMultiplier'); gs.lodRangeMin = lodRange.min; gs.lodRangeMax = lodRange.max; gsInstances.push(gs); @@ -423,10 +399,11 @@ if (USE_CYLINDER_CONTROLLER) { } // --- Splat budget --- -// Hardcoded to 0 (no cap): the LOD ramp on each instance is what gates splat count, the -// budget is left disabled. Kept as an observer so the value can still be overridden via -// share-URL state if needed. -data.set('splatBudget', 0); +// The budget is what gates splat count across every tile: LOD levels are chosen globally to fit it, +// so nearby tiles get the fine levels and distant ones stay coarse. One asset at its finest level is +// ~106M splats, so with tens of tiles on screen the budget is doing all the work here. Kept as an +// observer so the value can still be overridden via share-URL state. +data.set('splatBudget', platform.mobile ? 4 : 8); const applySplatBudget = () => { const millions = data.get('splatBudget'); app.scene.gsplat.splatBudget = Math.round(millions * 1000000); diff --git a/examples/src/examples/gaussian-splatting/clipping.example.mjs b/examples/src/examples/gaussian-splatting/clipping.example.mjs index 9f62b7f3a8f..4826c812c31 100644 --- a/examples/src/examples/gaussian-splatting/clipping.example.mjs +++ b/examples/src/examples/gaussian-splatting/clipping.example.mjs @@ -170,8 +170,6 @@ for (let z = 0; z < GRID_SIZE; z++) { entity.setLocalPosition(px, 0, pz); entity.setLocalEulerAngles(180, 0, 0); app.root.addChild(entity); - const gs = /** @type {any} */ (entity.gsplat); - gs.lodBaseDistance = 1.2; } } diff --git a/examples/src/examples/gaussian-splatting/downtown.example.mjs b/examples/src/examples/gaussian-splatting/downtown.example.mjs index 71aa158c176..561960a561a 100644 --- a/examples/src/examples/gaussian-splatting/downtown.example.mjs +++ b/examples/src/examples/gaussian-splatting/downtown.example.mjs @@ -52,7 +52,6 @@ import { TouchDevice, Vec3, createGraphicsDevice, - math, platform } from 'playcanvas'; import { CameraControls } from 'playcanvas/scripts/esm/camera-controls.mjs'; @@ -131,7 +130,6 @@ const config = { lodUnderfillLimit: 5, // Distance-based LOD ramp base distance (LOD = 1 + log(d / base) / log(mult)); the multiplier // is derived from the splat budget — see the budget section below - lodBaseDistance: 20, // Fly speeds moveSpeed: 13, moveFastSpeed: 100, @@ -306,21 +304,12 @@ Object.assign(cc, { focusPoint: focusPoint }); -// --- Splat budget (millions; 0 = no cap), driving both the cap and the LOD multiplier. -// Base distance is fixed (config.lodBaseDistance); the multiplier interpolates 1.5 (at 2M) to -// 2.5 (at the Extreme budget), clamped — coarser falloff as the budget grows. Default to the -// Medium quality preset (desktop 8M / mobile 2M), so a quality button is lit at launch. --- -const extremeBudget = platform.mobile ? 8 : 25; +// --- Splat budget (millions). LOD levels are chosen to fit it, spending splats where they remove +// the most approximation error. Default to the Medium quality preset (desktop 8M / mobile 4M), so +// a quality button is lit at launch. --- data.set('splatBudget', platform.mobile ? 4 : 8); const applySplatBudget = () => { - const budget = data.get('splatBudget'); - app.scene.gsplat.splatBudget = Math.round(budget * 1000000); - const t = math.clamp((budget - 2) / (extremeBudget - 2), 0, 1); - const mult = 1.5 + t * (2.5 - 1.5); - for (let i = 0; i < gsInstances.length; i++) { - gsInstances[i].lodBaseDistance = config.lodBaseDistance; - gsInstances[i].lodMultiplier = mult; - } + app.scene.gsplat.splatBudget = Math.round(data.get('splatBudget') * 1000000); }; applySplatBudget(); data.on('splatBudget:set', applySplatBudget); diff --git a/examples/src/examples/gaussian-splatting/lod-instances.example.mjs b/examples/src/examples/gaussian-splatting/lod-instances.example.mjs index 8d3112a5fc4..8c9003d34cf 100644 --- a/examples/src/examples/gaussian-splatting/lod-instances.example.mjs +++ b/examples/src/examples/gaussian-splatting/lod-instances.example.mjs @@ -279,7 +279,6 @@ for (let z = 0; z < GRID_SIZE; z++) { entity.setLocalEulerAngles(180, 0, 0); app.root.addChild(entity); const gs = /** @type {any} */ (entity.gsplat); - gs.lodBaseDistance = 1.2; gs.setParameter('uComponentId', componentIndex); gs.setWorkBufferModifier(workBufferModifier); componentIndex++; diff --git a/examples/src/examples/gaussian-splatting/lod-streaming-sh.controls.jsx b/examples/src/examples/gaussian-splatting/lod-streaming-sh.controls.jsx index 9df7dc95f9e..77518ade1b5 100644 --- a/examples/src/examples/gaussian-splatting/lod-streaming-sh.controls.jsx +++ b/examples/src/examples/gaussian-splatting/lod-streaming-sh.controls.jsx @@ -62,24 +62,6 @@ export function Controls({ observer }) { ]} /> - - - - - - { // Skatepark configuration const config = { name: 'Skatepark', - url: 'https://code.playcanvas.com/examples_data/example_skatepark_02/lod-meta.json', + url: 'https://code.playcanvas.com/examples_data/example_skatepark_03/lod-meta.json', lodUpdateDistance: 1, lodUnderfillLimit: 10, cameraPosition: [32, 2, 2], @@ -105,23 +105,19 @@ const config = { }; // LOD preset definitions with customizable distances -/** @type {Record} */ +/** @type {Record} */ const LOD_PRESETS = { 'desktop-max': { - range: [0, 5], - lodBaseDistance: 15 + range: [0, 5] }, desktop: { - range: [0, 2], - lodBaseDistance: 15 + range: [0, 2] }, 'mobile-max': { - range: [1, 2], - lodBaseDistance: 15 + range: [1, 2] }, mobile: { - range: [2, 5], - lodBaseDistance: 15 + range: [2, 5] } }; @@ -190,23 +186,11 @@ const applyPreset = () => { const presetData = LOD_PRESETS[preset] || LOD_PRESETS.desktop; gs.lodRangeMin = presetData.range[0]; gs.lodRangeMax = presetData.range[1]; - gs.lodBaseDistance = presetData.lodBaseDistance; - data.set('lodBaseDistance', presetData.lodBaseDistance); }; applyPreset(); data.on('lodPreset:set', applyPreset); -data.set('lodMultiplier', 4); -gs.lodMultiplier = 4; - -data.on('lodBaseDistance:set', () => { - gs.lodBaseDistance = data.get('lodBaseDistance'); -}); -data.on('lodMultiplier:set', () => { - gs.lodMultiplier = data.get('lodMultiplier'); -}); - const applySplatBudget = () => { const millions = data.get('splatBudget'); app.scene.gsplat.splatBudget = Math.round(millions * 1000000); diff --git a/examples/src/examples/gaussian-splatting/lod-streaming.controls.jsx b/examples/src/examples/gaussian-splatting/lod-streaming.controls.jsx index 4cb223f23b8..2b2a37ab22c 100644 --- a/examples/src/examples/gaussian-splatting/lod-streaming.controls.jsx +++ b/examples/src/examples/gaussian-splatting/lod-streaming.controls.jsx @@ -219,24 +219,6 @@ export function Controls({ observer }) { ]} /> - - - - - - { // Original dataset: https://www.youtube.com/watch?v=3RtY_cLK13k const config = { name: 'Roman-Parish', - url: 'https://code.playcanvas.com/examples_data/example_roman_parish_02/lod-meta.json', + url: 'https://code.playcanvas.com/examples_data/example_roman_parish_03/lod-meta.json', lodUpdateDistance: 0.5, lodUnderfillLimit: 5, cameraPosition: [10.3, 2, -10], @@ -175,27 +175,19 @@ const ENV_PRESETS = { }; // LOD preset definitions -/** @type {Record} */ +/** @type {Record} */ const LOD_PRESETS = { 'desktop-max': { - range: [0, 5], - lodBaseDistance: 7, - lodMultiplier: 3 + range: [0, 5] }, desktop: { - range: [1, 5], - lodBaseDistance: 5, - lodMultiplier: 4 + range: [1, 5] }, 'mobile-max': { - range: [2, 5], - lodBaseDistance: 5, - lodMultiplier: 2 + range: [2, 5] }, mobile: { - range: [3, 5], - lodBaseDistance: 2, - lodMultiplier: 2 + range: [3, 5] } }; @@ -455,11 +447,7 @@ const applyPreset = () => { if (gsplatGs) { gsplatGs.lodRangeMin = presetData.range[0]; gsplatGs.lodRangeMax = presetData.range[1]; - gsplatGs.lodBaseDistance = presetData.lodBaseDistance; - gsplatGs.lodMultiplier = presetData.lodMultiplier; } - data.set('lodBaseDistance', presetData.lodBaseDistance); - data.set('lodMultiplier', presetData.lodMultiplier); }; const loadGSplat = async (/** @type {string|null} */ url) => { @@ -503,10 +491,6 @@ const loadGSplat = async (/** @type {string|null} */ url) => { app.root.addChild(gsplatEntity); gsplatGs = /** @type {any} */ (gsplatEntity.gsplat); - const presetData = LOD_PRESETS[data.get('lodPreset')] || LOD_PRESETS.desktop; - gsplatGs.lodBaseDistance = presetData.lodBaseDistance; - gsplatGs.lodMultiplier = presetData.lodMultiplier; - // Start with lowest LOD for fast initial display, then stream up const lodLevels = gsplatGs.resource?.octree?.lodLevels; if (lodLevels) { @@ -547,13 +531,6 @@ await loadGSplat(data.get('url') || null); data.on('lodPreset:set', applyPreset); -data.on('lodBaseDistance:set', () => { - if (gsplatGs) gsplatGs.lodBaseDistance = data.get('lodBaseDistance'); -}); -data.on('lodMultiplier:set', () => { - if (gsplatGs) gsplatGs.lodMultiplier = data.get('lodMultiplier'); -}); - const applySplatBudget = () => { const millions = data.get('splatBudget'); app.scene.gsplat.splatBudget = Math.round(millions * 1000000); diff --git a/examples/src/examples/gaussian-splatting/relighting.example.mjs b/examples/src/examples/gaussian-splatting/relighting.example.mjs index d6591a51eb2..70ae427ad41 100644 --- a/examples/src/examples/gaussian-splatting/relighting.example.mjs +++ b/examples/src/examples/gaussian-splatting/relighting.example.mjs @@ -153,7 +153,7 @@ app.on('destroy', () => { // Original dataset: https://www.youtube.com/watch?v=3RtY_cLK13k const config = { name: 'Roman-Parish', - url: 'https://code.playcanvas.com/examples_data/example_roman_parish_02/lod-meta.json', + url: 'https://code.playcanvas.com/examples_data/example_roman_parish_03/lod-meta.json', lodUpdateDistance: 0.5, lodUnderfillLimit: 5, cameraPosition: [10.3, 2, -10], @@ -180,27 +180,19 @@ const ENV_PRESETS = { }; // LOD preset definitions -/** @type {Record} */ +/** @type {Record} */ const LOD_PRESETS = { 'desktop-max': { - range: [0, 5], - lodBaseDistance: 7, - lodMultiplier: 3 + range: [0, 5] }, desktop: { - range: [1, 5], - lodBaseDistance: 5, - lodMultiplier: 4 + range: [1, 5] }, 'mobile-max': { - range: [2, 5], - lodBaseDistance: 5, - lodMultiplier: 2 + range: [2, 5] }, mobile: { - range: [3, 5], - lodBaseDistance: 2, - lodMultiplier: 2 + range: [3, 5] } }; @@ -209,7 +201,7 @@ const assets = { // Draco compressed mesh matching the splat scene, with positions and normals mesh: new Asset('mesh', 'container', { - url: 'https://code.playcanvas.com/examples_data/example_roman_parish_02/roman-parish-mesh.glb' + url: 'https://code.playcanvas.com/examples_data/example_roman_parish_03/roman-parish-mesh.glb' }), envatlas: new Asset( @@ -701,11 +693,7 @@ const applyPreset = () => { if (gsplatGs) { gsplatGs.lodRangeMin = presetData.range[0]; gsplatGs.lodRangeMax = presetData.range[1]; - gsplatGs.lodBaseDistance = presetData.lodBaseDistance; - gsplatGs.lodMultiplier = presetData.lodMultiplier; } - data.set('lodBaseDistance', presetData.lodBaseDistance); - data.set('lodMultiplier', presetData.lodMultiplier); }; const loadGSplat = async (/** @type {string|null} */ url) => { @@ -749,10 +737,6 @@ const loadGSplat = async (/** @type {string|null} */ url) => { app.root.addChild(gsplatEntity); gsplatGs = /** @type {any} */ (gsplatEntity.gsplat); - const presetData = LOD_PRESETS[data.get('lodPreset')] || LOD_PRESETS.desktop; - gsplatGs.lodBaseDistance = presetData.lodBaseDistance; - gsplatGs.lodMultiplier = presetData.lodMultiplier; - // Start with lowest LOD for fast initial display, then stream up const lodLevels = gsplatGs.resource?.octree?.lodLevels; if (lodLevels) { @@ -781,13 +765,6 @@ await loadGSplat(data.get('url') || null); data.on('lodPreset:set', applyPreset); -data.on('lodBaseDistance:set', () => { - if (gsplatGs) gsplatGs.lodBaseDistance = data.get('lodBaseDistance'); -}); -data.on('lodMultiplier:set', () => { - if (gsplatGs) gsplatGs.lodMultiplier = data.get('lodMultiplier'); -}); - const applySplatBudget = () => { const millions = data.get('splatBudget'); app.scene.gsplat.splatBudget = Math.round(millions * 1000000); diff --git a/examples/src/examples/gaussian-splatting/splat-portal.example.mjs b/examples/src/examples/gaussian-splatting/splat-portal.example.mjs index f315779ad75..8480a640f35 100644 --- a/examples/src/examples/gaussian-splatting/splat-portal.example.mjs +++ b/examples/src/examples/gaussian-splatting/splat-portal.example.mjs @@ -112,8 +112,8 @@ app.on('destroy', () => { }); // Outside scene (Roman Parish) and inside-portal scene (Skatepark) configuration -const OUTSIDE_URL = 'https://code.playcanvas.com/examples_data/example_roman_parish_02/lod-meta.json'; -const INSIDE_URL = 'https://code.playcanvas.com/examples_data/example_skatepark_02/lod-meta.json'; +const OUTSIDE_URL = 'https://code.playcanvas.com/examples_data/example_roman_parish_03/lod-meta.json'; +const INSIDE_URL = 'https://code.playcanvas.com/examples_data/example_skatepark_03/lod-meta.json'; const assets = { outside: new Asset('outside', 'gsplat', { url: OUTSIDE_URL }), @@ -318,8 +318,6 @@ const sceneConfigs = [ name: 'Parish', asset: assets.outside, layer: outsideLayer, - lodBaseDistance: 7, - lodMultiplier: 3, intrinsicEuler: new Vec3(-90, 0, 0), portalLocalPos: new Vec3(2, 2.4, 5), portalLocalYaw: 130, @@ -329,8 +327,6 @@ const sceneConfigs = [ name: 'Skatepark', asset: assets.inside, layer: insideLayer, - lodBaseDistance: 15, - lodMultiplier: 4, intrinsicEuler: new Vec3(-90, 0, 0), portalLocalPos: new Vec3(14, 1, 13.5), portalLocalYaw: 50, @@ -381,8 +377,6 @@ sceneConfigs.forEach((config) => { entity.addComponent('gsplat', { asset: config.asset, layers: [config.layer.id], - lodBaseDistance: config.lodBaseDistance, - lodMultiplier: config.lodMultiplier, // Start with the lowest LOD until the first frame settles, then unlock the full range lodRangeMin: 4, lodRangeMax: 5 diff --git a/examples/src/examples/gaussian-splatting/vr-lod.example.mjs b/examples/src/examples/gaussian-splatting/vr-lod.example.mjs index a0c8c59eee8..95a503377e7 100644 --- a/examples/src/examples/gaussian-splatting/vr-lod.example.mjs +++ b/examples/src/examples/gaussian-splatting/vr-lod.example.mjs @@ -133,7 +133,7 @@ app.on('destroy', () => { const config = { name: 'Roman-Parish', - url: 'https://code.playcanvas.com/examples_data/example_roman_parish_02/lod-meta.json', + url: 'https://code.playcanvas.com/examples_data/example_roman_parish_03/lod-meta.json', lodUpdateDistance: 0.5, lodUnderfillLimit: 5, cameraPosition: [10.3, 2, -10], @@ -144,27 +144,19 @@ const config = { focusPoint: [12, 3, 0] }; -/** @type {Record} */ +/** @type {Record} */ const LOD_PRESETS = { 'desktop-max': { - range: [0, 5], - lodBaseDistance: 7, - lodMultiplier: 3 + range: [0, 5] }, desktop: { - range: [1, 5], - lodBaseDistance: 5, - lodMultiplier: 4 + range: [1, 5] }, 'mobile-max': { - range: [2, 5], - lodBaseDistance: 5, - lodMultiplier: 2 + range: [2, 5] }, mobile: { - range: [3, 5], - lodBaseDistance: 2, - lodMultiplier: 2 + range: [3, 5] } }; @@ -469,8 +461,6 @@ const applyPreset = () => { if (gsplatGs) { gsplatGs.lodRangeMin = presetData.range[0]; gsplatGs.lodRangeMax = presetData.range[1]; - gsplatGs.lodBaseDistance = presetData.lodBaseDistance; - gsplatGs.lodMultiplier = presetData.lodMultiplier; } }; @@ -494,10 +484,6 @@ const loadGSplat = (scene) => { app.root.addChild(gsplatEntity); gsplatGs = /** @type {any} */ (gsplatEntity.gsplat); - const presetData = LOD_PRESETS[lodPresetKey] || LOD_PRESETS.desktop; - gsplatGs.lodBaseDistance = presetData.lodBaseDistance; - gsplatGs.lodMultiplier = presetData.lodMultiplier; - const lodLevels = gsplatGs.resource?.octree?.lodLevels; if (lodLevels) { const worstLod = lodLevels - 1; @@ -539,7 +525,7 @@ const caveAsset = new Asset('gsplat-cave', 'gsplat', { app.assets.add(caveAsset); const skateparkAsset = new Asset('gsplat-skatepark', 'gsplat', { - url: 'https://code.playcanvas.com/examples_data/example_skatepark_02/lod-meta.json' + url: 'https://code.playcanvas.com/examples_data/example_skatepark_03/lod-meta.json' }); app.assets.add(skateparkAsset); diff --git a/examples/src/examples/gaussian-splatting/weather.example.mjs b/examples/src/examples/gaussian-splatting/weather.example.mjs index ed408eee6e4..4abaa9fbc8d 100644 --- a/examples/src/examples/gaussian-splatting/weather.example.mjs +++ b/examples/src/examples/gaussian-splatting/weather.example.mjs @@ -83,7 +83,7 @@ app.on('destroy', () => { const assets = { scene: new Asset('gsplat', 'gsplat', { - url: 'https://code.playcanvas.com/examples_data/example_roman_parish_02/lod-meta.json' + url: 'https://code.playcanvas.com/examples_data/example_roman_parish_03/lod-meta.json' }) }; @@ -132,9 +132,6 @@ gsplatEntity.addComponent('gsplat', { gsplatEntity.setLocalEulerAngles(270, 0, 0); app.root.addChild(gsplatEntity); -gsplatEntity.gsplat.lodBaseDistance = 5; -gsplatEntity.gsplat.lodMultiplier = 4; - // Procedural weather const weatherEntity = new Entity('Weather'); weatherEntity.addComponent('script'); diff --git a/examples/src/examples/gaussian-splatting/wind.example.mjs b/examples/src/examples/gaussian-splatting/wind.example.mjs index 8b22aa8cbd0..411ee6d4c6f 100644 --- a/examples/src/examples/gaussian-splatting/wind.example.mjs +++ b/examples/src/examples/gaussian-splatting/wind.example.mjs @@ -83,7 +83,7 @@ app.on('destroy', () => { const assets = { skatepark: new Asset('gsplat', 'gsplat', { - url: 'https://code.playcanvas.com/examples_data/example_skatepark_02/lod-meta.json' + url: 'https://code.playcanvas.com/examples_data/example_skatepark_03/lod-meta.json' }), envAtlas: new Asset( 'env-atlas', @@ -146,8 +146,6 @@ skatepark.addComponent('gsplat', { asset: assets.skatepark }); skatepark.setLocalEulerAngles(-90, 0, 0); -skatepark.gsplat.lodBaseDistance = 15; -skatepark.gsplat.lodMultiplier = 4; app.root.addChild(skatepark); // the reusable wind script diff --git a/examples/src/examples/gaussian-splatting/world.controls.jsx b/examples/src/examples/gaussian-splatting/world.controls.jsx index b9d0dcb819a..74353cda9dc 100644 --- a/examples/src/examples/gaussian-splatting/world.controls.jsx +++ b/examples/src/examples/gaussian-splatting/world.controls.jsx @@ -71,24 +71,6 @@ export function Controls({ observer }) { step={0.1} /> - - - - - - diff --git a/examples/src/examples/gaussian-splatting/world.example.mjs b/examples/src/examples/gaussian-splatting/world.example.mjs index 2be34f27a0b..30447397ad7 100644 --- a/examples/src/examples/gaussian-splatting/world.example.mjs +++ b/examples/src/examples/gaussian-splatting/world.example.mjs @@ -81,7 +81,7 @@ app.on('destroy', () => { // Skatepark configuration const config = { - url: 'https://code.playcanvas.com/examples_data/example_skatepark_02/lod-meta.json', + url: 'https://code.playcanvas.com/examples_data/example_skatepark_03/lod-meta.json', lodUpdateDistance: 1, lodUnderfillLimit: 10, cameraPosition: [32, 2, 2], @@ -94,15 +94,13 @@ const config = { }; // LOD preset definitions -/** @type {Record} */ +/** @type {Record} */ const LOD_PRESETS = { desktop: { - range: [0, 2], - lodBaseDistance: 15 + range: [0, 2] }, mobile: { - range: [1, 5], - lodBaseDistance: 15 + range: [1, 5] } }; @@ -182,21 +180,6 @@ skatepark.setLocalEulerAngles(rotX, rotY, rotZ); skatepark.setLocalScale(1, 1, 1); app.root.addChild(skatepark); -// Apply LOD distances to skatepark -const gs = /** @type {any} */ (skatepark.gsplat); -gs.lodBaseDistance = presetData.lodBaseDistance; -gs.lodMultiplier = 4; - -data.set('lodBaseDistance', presetData.lodBaseDistance); -data.set('lodMultiplier', 4); - -data.on('lodBaseDistance:set', () => { - gs.lodBaseDistance = data.get('lodBaseDistance'); -}); -data.on('lodMultiplier:set', () => { - gs.lodMultiplier = data.get('lodMultiplier'); -}); - // World center coordinates const worldCenter = { x: 18, y: -1.3, z: 13.5 }; diff --git a/src/framework/app-base.js b/src/framework/app-base.js index 4573ee2d454..3a5a3a84ca9 100644 --- a/src/framework/app-base.js +++ b/src/framework/app-base.js @@ -1439,7 +1439,7 @@ class AppBase extends EventHandler { * @param {number} [settings.render.gsplatLodUpdateAngle] - Angle threshold in degrees to trigger gsplat LOD updates based on camera rotation. Defaults to 0. * @param {number} [settings.render.gsplatLodBehindPenalty] - Multiplier applied to effective distance for gsplat nodes behind the camera. Defaults to 1. * @param {number} [settings.render.gsplatLodUnderfillLimit] - Maximum number of gsplat LOD levels allowed below the optimal level when optimal data is not resident. Defaults to 0. - * @param {number} [settings.render.gsplatSplatBudget] - Target number of splats across all GSplats in the scene. 0 disables budget enforcement. Defaults to 0. + * @param {number} [settings.render.gsplatSplatBudget] - Target number of splats across all GSplats in the scene. LOD levels are chosen globally to stay within it; a non-positive value is not a way to disable this and the default is used instead. Defaults to 1000000. * @param {number} [settings.render.gsplatAlphaClip] - Alpha threshold for gsplat shadow, pick, and prepass rendering. Defaults to 0.3. * @param {number} [settings.render.gsplatAlphaClipForward] - Alpha threshold for the forward gsplat rendering pass. Defaults to 1 / 255. * @param {number} [settings.render.gsplatMinPixelSize] - Minimum screen-space pixel size below which splats are discarded. Defaults to 2. diff --git a/src/framework/components/gsplat/component.js b/src/framework/components/gsplat/component.js index 7fb4b8f72d4..6f075323dbd 100644 --- a/src/framework/components/gsplat/component.js +++ b/src/framework/components/gsplat/component.js @@ -97,20 +97,6 @@ class GSplatComponent extends Component { */ _materialTmp = null; - /** - * Base distance for the first LOD transition (LOD 0 to LOD 1). - * - * @private - */ - _lodBaseDistance = 5; - - /** - * Geometric multiplier between successive LOD distance thresholds. - * - * @private - */ - _lodMultiplier = 3; - /** * Minimum allowed LOD index (inclusive). * @@ -385,54 +371,41 @@ class GSplatComponent extends Component { } /** - * Sets the base distance for the first LOD transition (LOD 0 to LOD 1). Objects closer - * than this distance use the highest quality LOD. Each subsequent LOD level transitions - * at a progressively larger distance, controlled by {@link lodMultiplier}. Clamped to a - * minimum of 0.1. Defaults to 5. - * * @type {number} + * @deprecated LOD level selection is driven by `app.scene.gsplat.splatBudget`. + * @ignore */ set lodBaseDistance(value) { - this._lodBaseDistance = Math.max(0.1, value); - if (this._placement) { - this._placement.lodBaseDistance = this._lodBaseDistance; - } + Debug.removed('GSplatComponent#lodBaseDistance is removed. LOD levels are chosen to fit app.scene.gsplat.splatBudget; use that to control quality.'); } /** - * Gets the base distance for the first LOD transition. - * * @type {number} + * @deprecated LOD level selection is driven by `app.scene.gsplat.splatBudget`. + * @ignore */ get lodBaseDistance() { - return this._lodBaseDistance; + Debug.removed('GSplatComponent#lodBaseDistance is removed. LOD levels are chosen to fit app.scene.gsplat.splatBudget; use that to control quality.'); + return 0; } /** - * Sets the multiplier between successive LOD distance thresholds. Each LOD level - * transitions at this factor times the previous level's distance, creating a geometric - * progression. Lower values keep higher quality at distance; higher values switch to - * coarser LODs sooner. Clamped to a minimum of 1.2 to avoid degenerate logarithmic LOD - * computation. LOD distances are automatically compensated for the camera's field of - * view — a wider FOV makes objects appear smaller on screen, so LOD switches to coarser - * levels sooner to match the reduced screen-space detail. Defaults to 3. - * * @type {number} + * @deprecated LOD level selection is driven by `app.scene.gsplat.splatBudget`. + * @ignore */ set lodMultiplier(value) { - this._lodMultiplier = Math.max(1.2, value); - if (this._placement) { - this._placement.lodMultiplier = this._lodMultiplier; - } + Debug.removed('GSplatComponent#lodMultiplier is removed. LOD levels are chosen to fit app.scene.gsplat.splatBudget; use that to control quality.'); } /** - * Gets the geometric multiplier between successive LOD distance thresholds. - * * @type {number} + * @deprecated LOD level selection is driven by `app.scene.gsplat.splatBudget`. + * @ignore */ get lodMultiplier() { - return this._lodMultiplier; + Debug.removed('GSplatComponent#lodMultiplier is removed. LOD levels are chosen to fit app.scene.gsplat.splatBudget; use that to control quality.'); + return 0; } /** @@ -485,24 +458,20 @@ class GSplatComponent extends Component { /** * @type {number[]|null} - * @deprecated Use {@link lodBaseDistance} and {@link lodMultiplier} instead. + * @deprecated LOD level selection is driven by `app.scene.gsplat.splatBudget`. * @ignore */ set lodDistances(value) { - Debug.removed('GSplatComponent#lodDistances is removed. Use lodBaseDistance and lodMultiplier instead.'); - if (Array.isArray(value) && value.length > 0) { - this.lodBaseDistance = value[0]; - this.lodMultiplier = 3; - } + Debug.removed('GSplatComponent#lodDistances is removed. LOD levels are chosen to fit app.scene.gsplat.splatBudget; use that to control quality.'); } /** * @type {number[]} - * @deprecated Use {@link lodBaseDistance} and {@link lodMultiplier} instead. + * @deprecated LOD level selection is driven by `app.scene.gsplat.splatBudget`. * @ignore */ get lodDistances() { - Debug.removed('GSplatComponent#lodDistances is removed. Use lodBaseDistance and lodMultiplier instead.'); + Debug.removed('GSplatComponent#lodDistances is removed. LOD levels are chosen to fit app.scene.gsplat.splatBudget; use that to control quality.'); return []; } @@ -1008,8 +977,6 @@ class GSplatComponent extends Component { this._placement = null; this._placement = new GSplatPlacement(resource, this.entity, 0, this._parameters, null, this._id); - this._placement.lodBaseDistance = this._lodBaseDistance; - this._placement.lodMultiplier = this._lodMultiplier; this._placement.lodRangeMin = this._lodRangeMin; this._placement.lodRangeMax = this._lodRangeMax; this._placement.workBufferUpdate = this._workBufferUpdate; diff --git a/src/framework/components/gsplat/system.js b/src/framework/components/gsplat/system.js index ed826f47ad4..be549483110 100644 --- a/src/framework/components/gsplat/system.js +++ b/src/framework/components/gsplat/system.js @@ -26,8 +26,6 @@ Debug.call(() => { // order matters here const _properties = [ 'unified', - 'lodBaseDistance', - 'lodMultiplier', 'lodRangeMin', 'lodRangeMax', 'castShadows', diff --git a/src/scene/gsplat-unified/constants.js b/src/scene/gsplat-unified/constants.js index 5958bdba6ad..b792c570f8f 100644 --- a/src/scene/gsplat-unified/constants.js +++ b/src/scene/gsplat-unified/constants.js @@ -9,8 +9,17 @@ export const ALPHA_VISIBILITY_THRESHOLD = 1.0 / 255.0; export const CACHE_STRIDE = 8; /** - * Number of distance buckets for global splat budget balancing. - * More buckets = finer granularity for budget prioritization (sqrt-based distance mapping). + * Default target number of splats across all GSplats in the scene, used by + * {@link GSplatParams#splatBudget} and substituted when a non-positive budget is configured. * @type {number} */ -export const NUM_BUCKETS = 64; +export const SPLAT_BUDGET_DEFAULT = 1000000; + +/** + * Number of value buckets for global splat budget balancing. Upgrades are bucketed by + * coverage-weighted error reduction per splat on a fixed log scale, so this sets how finely the + * greedy order is resolved. 256 already measured indistinguishable from an exact sort at no more + * cost than 64; 512 keeps that resolution across the wider value window the balancer uses. + * @type {number} + */ +export const NUM_VALUE_BUCKETS = 512; diff --git a/src/scene/gsplat-unified/gsplat-budget-balancer.js b/src/scene/gsplat-unified/gsplat-budget-balancer.js index ab4ba361b5e..6e903a2fade 100644 --- a/src/scene/gsplat-unified/gsplat-budget-balancer.js +++ b/src/scene/gsplat-unified/gsplat-budget-balancer.js @@ -1,144 +1,280 @@ /** * @import { GSplatOctreeInstance } from './gsplat-octree-instance.js' * @import { GSplatPlacement } from './gsplat-placement.js' + * @import { GSplatLodTable } from './gsplat-lod-table.js' */ -import { NUM_BUCKETS } from './constants.js'; +import { NUM_VALUE_BUCKETS } from './constants.js'; + +// Monotonic float -> integer key. The bit pattern of a positive float is order preserving, and +// linear in log2 of the value, so bucketing on it is a log-spaced bucketing without a Math.log. +const _f32 = new Float32Array(1); +const _u32 = new Uint32Array(_f32.buffer); +const keyOf = (value) => { + _f32[0] = value; + return _u32[0]; +}; + +// Fixed bucket scale rather than one derived from the values seen this update. A derived range +// shifts every update, which moves a node between buckets when nothing about that node changed - +// and that is a flicker source in its own right. +// +// The window has to cover every `coverage * error-per-splat` a scene can produce. Coverage is +// structurally bounded to [1e-12, 1] by NodeInfo#lodCoverage, so scene extent does not enter into +// it. The ratio does: with derived errors it is `ln(a/b) / (a - b)` over adjacent frontier counts, +// which peaks at ln 2 for counts 1 -> 2 and falls as ~ln2/count for large nodes. So the low end +// tracks splats *per node* rather than scene size - 1e-24 leaves room for a node of ~1e12 splats. +// The high end allows for authored errors far larger than any measured (~3), since the colour term +// in splat-transform's metric is unnormalised and has no upper bound. +// +// Anything outside the window still resolves, it just shares the first or last bucket and loses +// ordering against its neighbours there. +const KEY_LO = keyOf(1e-24); +const KEY_HI = keyOf(1e3); +const KEY_SCALE = (NUM_VALUE_BUCKETS - 1) / (KEY_HI - KEY_LO); /** - * Balances splat budget across multiple octree instances by adjusting LOD levels. - * Uses sqrt-based bucket distribution to give more precision to nearby geometry. - * Bucket 0 = nearest to camera (highest priority), bucket N-1 = farthest (lowest priority). + * Distributes a splat budget across octree instances by choosing a LOD level per node. + * + * Every node starts at the cheapest level it can render, which is the coarsest the scene can be and + * therefore always within budget. Each single-level upgrade available anywhere in the scene is then + * ranked by `coverage * error removed / splats added` - value for money, weighted by how much + * screen the node covers - and they are bought best first until one does not fit. + * + * Stopping at the first upgrade that does not fit, rather than skipping it and continuing, is + * deliberate. Continuing would make a node's outcome depend on whether some unrelated cheaper + * upgrade happened to be considered first, so small camera movements would flip levels on and off. + * The cost is leaving some budget unspent. + * + * Only a node's next unbought upgrade is ever in the queue; buying it enqueues its successor. Since + * a successor's value is never higher than its predecessor's, it lands in the current bucket or a + * lower one, so a single sweep from the top bucket down suffices. It also means at most one entry + * per node is live, which is what lets the buckets be intrusive lists over preallocated typed + * arrays with no per-entry storage at all. * * @ignore */ class GSplatBudgetBalancer { + /** @type {Int32Array} */ + _bucketHead = new Int32Array(NUM_VALUE_BUCKETS); + + /** @type {Int32Array} */ + _bucketTail = new Int32Array(NUM_VALUE_BUCKETS); + + /** + * Next node in the same bucket, indexed by global node index. -1 terminates the list. + * + * @type {Int32Array} + * @private + */ + _next = new Int32Array(0); + /** - * Buckets storing NodeInfo references. - * @type {Array|null} + * Index of a node's next unbought upgrade, indexed by global node index. + * + * @type {Int32Array} * @private */ - _buckets = null; + _pending = new Int32Array(0); /** - * Initialize bucket infrastructure on first use. + * Node coverage, indexed by global node index. Copied out of NodeInfo during the seed pass so + * the drain, which visits nodes in value order rather than index order, reads a flat array. + * + * @type {Float32Array} * @private */ - _initBuckets() { - if (!this._buckets) { - // Pre-allocate bucket arrays (will hold NodeInfo references) - this._buckets = new Array(NUM_BUCKETS); - for (let i = 0; i < NUM_BUCKETS; i++) { - this._buckets[i] = []; - } + _coverage = new Float32Array(0); + + /** + * Which instance owns each global node index. + * + * @type {Uint16Array} + * @private + */ + _instanceOf = new Uint16Array(0); + + /** + * Global node index of each instance's first node. + * + * @type {number[]} + * @private + */ + _instanceBase = []; + + /** @type {GSplatOctreeInstance[]} */ + _instances = []; + + /** @type {GSplatLodTable[]} */ + _tables = []; + + /** + * @param {number} capacity - Required global node capacity. + * @private + */ + _ensureCapacity(capacity) { + if (this._next.length >= capacity) return; + const size = Math.max(capacity, this._next.length * 2, 1024); + this._next = new Int32Array(size); + this._pending = new Int32Array(size); + this._coverage = new Float32Array(size); + this._instanceOf = new Uint16Array(size); + } + + /** + * Maps an upgrade value to a bucket. Monotonic, so a node's successor upgrade never lands in a + * bucket above the one it was bought from. + * + * @param {number} value - Coverage-weighted error reduction per splat. + * @returns {number} Bucket index. + * @private + */ + _bucketOf(value) { + const bucket = ((keyOf(value) - KEY_LO) * KEY_SCALE) | 0; + return bucket < 0 ? 0 : (bucket >= NUM_VALUE_BUCKETS ? NUM_VALUE_BUCKETS - 1 : bucket); + } + + /** + * @param {number} bucket - Bucket to append to. + * @param {number} node - Global node index. + * @private + */ + _push(bucket, node) { + this._next[node] = -1; + if (this._bucketHead[bucket] < 0) { + this._bucketHead[bucket] = node; + } else { + this._next[this._bucketTail[bucket]] = node; } + this._bucketTail[bucket] = node; } /** - * Balances splat budget across all octree instances by adjusting LOD levels. - * Uses sqrt-based bucket distribution to give more precision to nearby geometry. - * Makes multiple passes, adjusting by one LOD level per pass, until budget is reached - * or all nodes hit their respective limits (per-instance rangeMin or rangeMax). + * Assigns a LOD level to every node of every instance, keeping the total splat count within + * budget. Reads NodeInfo#lodCoverage, writes NodeInfo#optimalLod. * * @param {Map} octreeInstances - Map of * GSplatOctreeInstance objects. * @param {number} budget - Target splat budget for octrees. */ balance(octreeInstances, budget) { - // Initialize buckets on first use - this._initBuckets(); + const instances = this._instances; + const tables = this._tables; + const bases = this._instanceBase; + instances.length = 0; + tables.length = 0; + bases.length = 0; - // Clear buckets - for (let i = 0; i < NUM_BUCKETS; i++) { - this._buckets[i].length = 0; + let nodeTotal = 0; + let totalStartCount = 0; + let totalFinestCount = 0; + for (const [, inst] of octreeInstances) { + const table = inst.octree.getLodTable(inst.rangeMin, inst.rangeMax); + bases.push(nodeTotal); + instances.push(inst); + tables.push(table); + nodeTotal += inst.octree.nodes.length; + totalStartCount += table.totalStartCount; + totalFinestCount += table.totalFinestCount; } + if (instances.length === 0) return; - // Collect all nodes into buckets (indices precomputed in evaluateNodeLods when enforcing budget). - let totalOptimalSplats = 0; - for (const [, inst] of octreeInstances) { - const nodes = inst.octree.nodes; - const nodeInfos = inst.nodeInfos; + // Everything fits, or nothing does - either way there is nothing to trade off. + if (totalFinestCount <= budget) { + this._assignChainEnd(true); + return; + } + if (totalStartCount >= budget) { + this._assignChainEnd(false); + return; + } + + this._ensureCapacity(nodeTotal); + this._bucketHead.fill(-1); - for (let nodeIndex = 0, len = nodes.length; nodeIndex < len; nodeIndex++) { - const nodeInfo = nodeInfos[nodeIndex]; - const optimalLod = nodeInfo.optimalLod; - if (optimalLod < 0) continue; + const next = this._next; + const pending = this._pending; + const coverage = this._coverage; + const instanceOf = this._instanceOf; + + // Seed pass: floor every node and queue its first upgrade. + for (let i = 0; i < instances.length; i++) { + const inst = instances[i]; + const table = tables[i]; + const nodeInfos = inst.nodeInfos; + const base = bases[i]; + const { startLod, firstUpgrade, upgradeRatio } = table; - // Cache lods array on nodeInfo for fast access in budget adjustment loops - const lods = nodes[nodeIndex].lods; - nodeInfo.lods = lods; + for (let n = 0, len = nodeInfos.length; n < len; n++) { + const nodeInfo = nodeInfos[n]; + const lod = startLod[n]; + nodeInfo.optimalLod = lod; + if (lod < 0) continue; - this._buckets[nodeInfo.budgetBucket].push(nodeInfo); + const first = firstUpgrade[n]; + if (first >= firstUpgrade[n + 1]) continue; - totalOptimalSplats += lods[optimalLod].count; + const g = base + n; + const cov = nodeInfo.lodCoverage; + coverage[g] = cov; + instanceOf[g] = i; + pending[g] = first; + this._push(this._bucketOf(cov * upgradeRatio[first]), g); } } - // Skip if already at budget - let currentSplats = totalOptimalSplats; - if (currentSplats === budget) { - return; - } + // Drain: best deals first, stopping at the first upgrade that does not fit. + // + // Each bucket is consumed as a queue: pop the head, then push the node's successor, which + // may land back in this same bucket and must be appended behind whatever is still queued. + // Popping first is what makes that safe - reading the popped node's link afterwards would + // miss a same-bucket re-push whenever it was the tail. + let spent = totalStartCount; + for (let bucket = NUM_VALUE_BUCKETS - 1; bucket >= 0; bucket--) { + let g = this._bucketHead[bucket]; + while (g >= 0) { + this._bucketHead[bucket] = next[g]; - // Determine direction - const isOverBudget = currentSplats > budget; - - // Multiple passes: adjust by one LOD level per pass until budget is reached - let done = false; - while (!done && (isOverBudget ? currentSplats > budget : currentSplats < budget)) { - let modified = false; - - if (isOverBudget) { - // Degrade: process from FARTHEST (bucket NUM_BUCKETS-1) to NEAREST (bucket 0) - // This preserves quality for nearby geometry - for (let b = NUM_BUCKETS - 1; b >= 0 && !done; b--) { - const bucket = this._buckets[b]; - for (let i = 0, len = bucket.length; i < len; i++) { - const nodeInfo = bucket[i]; - if (nodeInfo.optimalLod < nodeInfo.inst.rangeMax) { - const lods = nodeInfo.lods; - const optimalLod = nodeInfo.optimalLod; - currentSplats -= lods[optimalLod].count - lods[optimalLod + 1].count; - nodeInfo.optimalLod = optimalLod + 1; - modified = true; - if (currentSplats <= budget) { - done = true; - break; - } - } - } - } - } else { - // Upgrade: process from NEAREST (bucket 0) to FARTHEST (bucket NUM_BUCKETS-1) - // This improves quality for nearby geometry first - for (let b = 0; b < NUM_BUCKETS && !done; b++) { - const bucket = this._buckets[b]; - for (let i = 0, len = bucket.length; i < len; i++) { - const nodeInfo = bucket[i]; - if (nodeInfo.optimalLod > nodeInfo.inst.rangeMin) { - const lods = nodeInfo.lods; - const optimalLod = nodeInfo.optimalLod; - const splatsAdded = lods[optimalLod - 1].count - lods[optimalLod].count; - if (currentSplats + splatsAdded <= budget) { - nodeInfo.optimalLod = optimalLod - 1; - currentSplats += splatsAdded; - modified = true; - if (currentSplats >= budget) { - done = true; - break; - } - } else { - done = true; - break; - } - } - } + const i = instanceOf[g]; + const table = tables[i]; + const k = pending[g]; + const cost = table.upgradeCost[k]; + if (spent + cost > budget) return; + + spent += cost; + const n = g - bases[i]; + instances[i].nodeInfos[n].optimalLod = table.upgradeToLod[k]; + + const k2 = k + 1; + if (k2 < table.firstUpgrade[n + 1]) { + pending[g] = k2; + this._push(this._bucketOf(coverage[g] * table.upgradeRatio[k2]), g); } + g = this._bucketHead[bucket]; } + } + } - // If no nodes were modified, we can't adjust further (all at limits) - if (!modified) { - break; + /** + * Puts every node at one end of its LOD chain, for the cases where the budget makes the ranking + * irrelevant - either the whole scene fits at its finest, or not even the cheapest scene does. + * + * @param {boolean} finest - True for the finest level in range, false for the cheapest. + * @private + */ + _assignChainEnd(finest) { + for (let i = 0; i < this._instances.length; i++) { + const table = this._tables[i]; + const nodeInfos = this._instances[i].nodeInfos; + const { startLod, firstUpgrade, upgradeToLod } = table; + for (let n = 0, len = nodeInfos.length; n < len; n++) { + const lod = startLod[n]; + if (lod < 0 || !finest) { + nodeInfos[n].optimalLod = lod; + continue; + } + const end = firstUpgrade[n + 1]; + nodeInfos[n].optimalLod = end > firstUpgrade[n] ? upgradeToLod[end - 1] : lod; } } } diff --git a/src/scene/gsplat-unified/gsplat-lod-table.js b/src/scene/gsplat-unified/gsplat-lod-table.js new file mode 100644 index 00000000000..5fa51e3d18b --- /dev/null +++ b/src/scene/gsplat-unified/gsplat-lod-table.js @@ -0,0 +1,284 @@ +/** + * @import { GSplatOctree } from './gsplat-octree.js' + */ + +/** + * Everything the budget allocator can know about an octree before it sees a camera, precomputed + * once per LOD range. + * + * Each node is reduced to a chain of single-level *upgrades*, ordered cheapest level first. The + * chain is the Pareto frontier over (splat count, error): a level is kept only when it strictly + * improves on the cheapest error seen so far, which discards levels that cost more and look worse + * than something else the node already offers. Requiring a *strict* improvement also collapses + * levels identical in both, so consecutive entries always differ in both and every upgrade's cost + * stays above zero. + * + * Per update the allocator needs one number per node - its projected screen coverage - and the + * value of an upgrade is `coverage * error removed / splats added`. The second factor is fixed, and + * coverage is a single non-negative scalar multiplying every upgrade of that node equally, so the + * running minimum that keeps a node's returns non-increasing can be taken here rather than each + * update: + * + * min over j<=k of (coverage * r_j) === coverage * min over j<=k of r_j + * + * That is what {@link GSplatLodTable#upgradeRatio} stores, leaving one multiply per upgrade at + * selection time. + * + * @ignore + */ +class GSplatLodTable { + /** + * Finest allowed LOD index. + * + * @type {number} + */ + rangeMin; + + /** + * Coarsest allowed LOD index. + * + * @type {number} + */ + rangeMax; + + /** + * Per node, the cheapest renderable level in range - where the allocator starts before it + * spends anything. -1 when the node has no renderable level in range at all. + * + * @type {Int16Array} + */ + startLod; + + /** + * Per node, the splat count at {@link GSplatLodTable#startLod}. + * + * @type {Int32Array} + */ + startCount; + + /** + * Per node, where its upgrade slice begins. Node `n` owns + * `[firstUpgrade[n], firstUpgrade[n + 1])`, so the array holds one extra entry and the slice + * length needs no second array. + * + * @type {Int32Array} + */ + firstUpgrade; + + /** + * Per upgrade, the LOD index it moves the node to. + * + * @type {Int16Array} + */ + upgradeToLod; + + /** + * Per upgrade, the additional splats it costs. Always above zero. + * + * @type {Int32Array} + */ + upgradeCost; + + /** + * Per upgrade, error removed per additional splat, clamped to the running minimum along the + * node's chain so returns never increase as a node gets finer. Multiplying by the node's + * coverage yields the upgrade's value. + * + * @type {Float32Array} + */ + upgradeRatio; + + /** + * Sum of {@link GSplatLodTable#startCount} over all nodes - the splat cost of the whole octree + * before any upgrade is bought. + * + * @type {number} + */ + totalStartCount = 0; + + /** + * Sum of the finest renderable level in range over all nodes - the splat cost with every + * upgrade bought. + * + * @type {number} + */ + totalFinestCount = 0; + + /** + * @param {GSplatOctree} octree - The octree to build the table for. + * @param {number} rangeMin - Finest allowed LOD index. + * @param {number} rangeMax - Coarsest allowed LOD index. + */ + constructor(octree, rangeMin, rangeMax) { + this.rangeMin = rangeMin; + this.rangeMax = rangeMax; + + const nodes = octree.nodes; + const nodeCount = nodes.length; + const spanLength = rangeMax - rangeMin + 1; + + this.startLod = new Int16Array(nodeCount); + this.startCount = new Int32Array(nodeCount); + this.firstUpgrade = new Int32Array(nodeCount + 1); + + // A node contributes at most one upgrade per level boundary in range. + const maxUpgrades = nodeCount * Math.max(0, spanLength - 1); + const upgradeToLod = new Int16Array(maxUpgrades); + const upgradeCost = new Int32Array(maxUpgrades); + const upgradeRatio = new Float32Array(maxUpgrades); + + // Reused per node: the candidate levels, then the frontier compacted in place over them. + const scratch = new Int16Array(spanLength); + + let upgradeCount = 0; + let totalStartCount = 0; + let totalFinestCount = 0; + + for (let n = 0; n < nodeCount; n++) { + const lods = nodes[n].lods; + this.firstUpgrade[n] = upgradeCount; + + // Collect renderable levels in range, ordered by ascending cost and then ascending + // error. Insertion sort: the list is at most spanLength long and, since coarser levels + // normally hold fewer splats, usually already in order. + let candidateCount = 0; + for (let lod = rangeMax; lod >= rangeMin; lod--) { + if (lods[lod].count <= 0) continue; + let j = candidateCount++; + while (j > 0) { + const prev = scratch[j - 1]; + if (lods[prev].count < lods[lod].count || + (lods[prev].count === lods[lod].count && lods[prev].error <= lods[lod].error)) { + break; + } + scratch[j] = prev; + j--; + } + scratch[j] = lod; + } + + // Pareto frontier in one sweep of that order. Compacts in place, since the write index + // never runs ahead of the read index. + let frontierCount = 0; + let bestError = Infinity; + for (let i = 0; i < candidateCount; i++) { + const lod = scratch[i]; + if (lods[lod].error < bestError) { + bestError = lods[lod].error; + scratch[frontierCount++] = lod; + } + } + + if (frontierCount === 0) { + this.startLod[n] = -1; + this.startCount[n] = 0; + continue; + } + + const startLod = scratch[0]; + this.startLod[n] = startLod; + this.startCount[n] = lods[startLod].count; + totalStartCount += lods[startLod].count; + totalFinestCount += lods[scratch[frontierCount - 1]].count; + + let previousRatio = Infinity; + for (let i = 1; i < frontierCount; i++) { + const coarseLod = scratch[i - 1]; + const fineLod = scratch[i]; + const cost = lods[fineLod].count - lods[coarseLod].count; + const benefit = lods[coarseLod].error - lods[fineLod].error; + previousRatio = Math.min(previousRatio, benefit / cost); + upgradeToLod[upgradeCount] = fineLod; + upgradeCost[upgradeCount] = cost; + upgradeRatio[upgradeCount] = previousRatio; + upgradeCount++; + } + } + + this.firstUpgrade[nodeCount] = upgradeCount; + this.totalStartCount = totalStartCount; + this.totalFinestCount = totalFinestCount; + + // Trim to what was actually used - dominated levels mean this is often well short of the + // upper bound, and the arrays live as long as the octree. + this.upgradeToLod = upgradeToLod.subarray(0, upgradeCount).slice(); + this.upgradeCost = upgradeCost.subarray(0, upgradeCount).slice(); + this.upgradeRatio = upgradeRatio.subarray(0, upgradeCount).slice(); + } + + /** + * Walks a node's chain to the coarsest level that is no finer than `lod` and no coarser than + * `limit` levels above it, preferring the finest such level that satisfies `accept`. + * + * Streaming fallbacks use this instead of walking raw LOD indices, so they can only ever pick + * a level the allocator itself would consider. That keeps the splat count monotone as a node + * climbs towards its target: chain entries are ordered by ascending cost, whereas raw level + * indices are not - nothing guarantees a coarser level holds fewer splats. + * + * @param {number} nodeIndex - The node. + * @param {number} lod - The target LOD index, expected to be on the node's chain. + * @param {number} limit - How many chain steps coarser than the target are acceptable. + * @param {(lod: number) => boolean} accept - Predicate a level must satisfy. + * @returns {number} The chosen LOD index, or -1 when nothing in the window qualifies. + */ + findCoarserAccepted(nodeIndex, lod, limit, accept) { + const start = this.firstUpgrade[nodeIndex]; + const end = this.firstUpgrade[nodeIndex + 1]; + + // Position of `lod` in the chain. Entry -1 is startLod, entry k is upgradeToLod[start + k]. + let position = -1; + for (let k = start; k < end; k++) { + if (this.upgradeToLod[k] === lod) { + position = k - start; + break; + } + } + if (position < 0 && this.startLod[nodeIndex] !== lod) return -1; + + // Finest first: the target itself, then progressively coarser chain entries. + const lowest = Math.max(-1, position - limit); + for (let p = position; p >= lowest; p--) { + const candidate = p < 0 ? this.startLod[nodeIndex] : this.upgradeToLod[start + p]; + if (accept(candidate)) return candidate; + } + return -1; + } + + /** + * Returns the next coarser level on a node's chain, or -1 when `lod` is already its cheapest. + * + * @param {number} nodeIndex - The node. + * @param {number} lod - A LOD index on the node's chain. + * @returns {number} The next coarser chain entry, or -1. + */ + coarserOnChain(nodeIndex, lod) { + const start = this.firstUpgrade[nodeIndex]; + const end = this.firstUpgrade[nodeIndex + 1]; + for (let k = start; k < end; k++) { + if (this.upgradeToLod[k] === lod) { + return k === start ? this.startLod[nodeIndex] : this.upgradeToLod[k - 1]; + } + } + return -1; + } + + /** + * Returns the next finer level on a node's chain, or -1 when `lod` is already its finest. + * + * @param {number} nodeIndex - The node. + * @param {number} lod - A LOD index on the node's chain. + * @returns {number} The next finer chain entry, or -1. + */ + finerOnChain(nodeIndex, lod) { + const start = this.firstUpgrade[nodeIndex]; + const end = this.firstUpgrade[nodeIndex + 1]; + if (start === end) return -1; + if (this.startLod[nodeIndex] === lod) return this.upgradeToLod[start]; + for (let k = start; k < end - 1; k++) { + if (this.upgradeToLod[k] === lod) return this.upgradeToLod[k + 1]; + } + return -1; + } +} + +export { GSplatLodTable }; diff --git a/src/scene/gsplat-unified/gsplat-octree-instance.js b/src/scene/gsplat-unified/gsplat-octree-instance.js index 55938312bf5..b93feffdeeb 100644 --- a/src/scene/gsplat-unified/gsplat-octree-instance.js +++ b/src/scene/gsplat-unified/gsplat-octree-instance.js @@ -8,7 +8,6 @@ import { Color } from '../../core/math/color.js'; import { GSplatPlacement } from './gsplat-placement.js'; import { GsplatAllocId } from './gsplat-alloc-id.js'; import { GSPLAT_DEBUG_NODE_AABBS } from '../constants.js'; -import { NUM_BUCKETS } from './constants.js'; /** * @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js' @@ -48,7 +47,8 @@ class NodeInfo { currentLod = -1; /** - * Optimal LOD index based on distance/visibility (before underfill). + * LOD index the budget allocator chose for this node, before underfill. -1 when the node has + * nothing renderable in its LOD range. */ optimalLod = -1; @@ -58,6 +58,14 @@ class NodeInfo { */ worldDistance = 0; + /** + * Approximate projected screen coverage: the square of the node's projected radius, including + * the FOV scale and the behind-camera penalty already folded into the distance. This is the + * view-dependent half of an upgrade's value in the budget allocator, and the only way distance + * influences LOD selection. + */ + lodCoverage = 0; + /** * Accumulated camera translation for SH color update threshold tracking. */ @@ -70,22 +78,6 @@ class NodeInfo { */ inst = null; - /** - * Cached reference to this node's LOD array for fast budget balancing. - * - * @type {Array|null} - */ - lods = null; - - /** - * Distance bucket index [0, NUM_BUCKETS - 1] for global budget balancing (sqrt mapping). - * Written during {@link GSplatOctreeInstance.evaluateNodeLods} when a global max distance - * is supplied (budget enforcement path only). - * - * @type {number} - */ - budgetBucket = 0; - /** * Unique allocation identifier for persistent work buffer allocation tracking. * @@ -172,6 +164,15 @@ class GSplatOctreeInstance { */ rangeMax = 0; + /** + * Selection table for the current LOD range, refreshed by + * {@link GSplatOctreeInstance#resolveLodRange}. + * + * @type {import('./gsplat-lod-table.js').GSplatLodTable|null} + * @private + */ + _lodTable = null; + /** * Previous node position at which LOD was last updated. This is used to determine if LOD needs * to be updated as the octree splat moves. @@ -230,13 +231,6 @@ class GSplatOctreeInstance { */ _deviceLostEvent = null; - /** - * Reusable scratch for LOD distance thresholds. - * - * @type {Float32Array|null} - * @private - */ - _lodMinDistThresholds = null; /** * @param {GraphicsDevice} device - The graphics device. @@ -380,35 +374,41 @@ class GSplatOctreeInstance { } /** - * Selects desired LOD index for a node using the underfill strategy. When underfill is enabled, - * it prefers already-loaded LODs within [optimalLodIndex .. optimalLodIndex + lodUnderfillLimit]. - * If none are loaded, it selects the coarsest available LOD within the range. + * Selects the LOD index to display for a node, applying the underfill strategy. When underfill + * is enabled it prefers the finest already-loaded level within `lodUnderfillLimit` steps + * coarser than the target, so a node shows something rather than nothing while its target + * streams in. If none are loaded it takes the coarsest level in that window. + * + * Steps are taken along the node's LOD chain rather than over raw LOD indices. Chain entries + * are ordered by ascending splat count, whereas raw indices are not - nothing guarantees a + * coarser level holds fewer splats, and real captures do contain inversions. Walking the chain + * is what keeps a node's splat count from exceeding what the allocator budgeted for it. * - * @param {import('./gsplat-octree-node.js').GSplatOctreeNode} node - The octree node. - * @param {number} optimalLodIndex - Optimal LOD index based on camera/distance. - * @param {number} maxLod - Maximum LOD index. - * @param {number} lodUnderfillLimit - Allowed coarse range above optimal. - * @returns {number} Desired LOD index to display. + * @param {number} nodeIndex - The octree node index. + * @param {number} optimalLodIndex - LOD index the allocator chose. + * @param {number} lodUnderfillLimit - Allowed number of coarser chain steps. + * @returns {number} LOD index to display. */ - selectDesiredLodIndex(node, optimalLodIndex, maxLod, lodUnderfillLimit) { - if (lodUnderfillLimit > 0) { - const allowedMaxCoarseLod = Math.min(maxLod, optimalLodIndex + lodUnderfillLimit); - - // prefer highest quality already-loaded within the allowed range - for (let lod = optimalLodIndex; lod <= allowedMaxCoarseLod; lod++) { - const fi = node.lods[lod].fileIndex; - if (fi !== -1 && this.octree.getFileResource(fi)) { - return lod; - } - } + selectDesiredLodIndex(nodeIndex, optimalLodIndex, lodUnderfillLimit) { + if (lodUnderfillLimit > 0 && optimalLodIndex >= 0) { + const table = this._lodTable; + const node = this.octree.nodes[nodeIndex]; - // fallback: choose the coarsest available within the range - for (let lod = allowedMaxCoarseLod; lod >= optimalLodIndex; lod--) { + // prefer the finest already-loaded level within the allowed window + const loaded = table.findCoarserAccepted(nodeIndex, optimalLodIndex, lodUnderfillLimit, (lod) => { const fi = node.lods[lod].fileIndex; - if (fi !== -1) { - return lod; - } + return fi !== -1 && !!this.octree.getFileResource(fi); + }); + if (loaded >= 0) return loaded; + + // fall back to the coarsest level in the window that has a file at all + let coarsest = -1; + let lod = optimalLodIndex; + for (let step = 0; step <= lodUnderfillLimit && lod >= 0; step++) { + if (node.lods[lod].fileIndex !== -1) coarsest = lod; + lod = table.coarserOnChain(nodeIndex, lod); } + if (coarsest >= 0) return coarsest; } return optimalLodIndex; @@ -416,15 +416,19 @@ class GSplatOctreeInstance { /** * Prefetch only the next-better LOD toward optimal. This stages loading in steps across all - * nodes, avoiding intermixing requests before coarse is present. + * nodes, avoiding intermixing requests before coarse is present. Steps follow the node's LOD + * chain, so each step is a strict increase in splat count and can never overshoot the level + * the allocator budgeted for. * - * @param {import('./gsplat-octree-node.js').GSplatOctreeNode} node - The octree node. + * @param {number} nodeIndex - The octree node index. * @param {number} desiredLodIndex - Currently selected LOD for display (may be coarser than optimal). * @param {number} optimalLodIndex - Target optimal LOD. */ - prefetchNextLod(node, desiredLodIndex, optimalLodIndex) { + prefetchNextLod(nodeIndex, desiredLodIndex, optimalLodIndex) { if (desiredLodIndex === -1 || optimalLodIndex === -1) return; + const node = this.octree.nodes[nodeIndex]; + // If we're already at optimal but it's not loaded yet, request it if (desiredLodIndex === optimalLodIndex) { const fi = node.lods[optimalLodIndex].fileIndex; @@ -437,94 +441,51 @@ class GSplatOctreeInstance { return; } - // Step one level finer toward optimal - const targetLod = Math.max(optimalLodIndex, desiredLodIndex - 1); - // Find first valid fileIndex between targetLod..optimalLodIndex - for (let lod = targetLod; lod >= optimalLodIndex; lod--) { - const fi = node.lods[lod].fileIndex; - if (fi !== -1) { - this.octree.ensureFileResource(fi); - if (!this.octree.getFileResource(fi)) { - this.prefetchPending.add(fi); - } - break; + // Step one chain entry finer toward optimal + const targetLod = this._lodTable.finerOnChain(nodeIndex, desiredLodIndex); + if (targetLod < 0) return; + const fi = node.lods[targetLod].fileIndex; + if (fi !== -1) { + this.octree.ensureFileResource(fi); + if (!this.octree.getFileResource(fi)) { + this.prefetchPending.add(fi); } } } /** - * Updates the octree instance when LOD needs to be updated. - * - * @param {GraphNode} cameraNode - The camera node. - * @param {import('./gsplat-params.js').GSplatParams} params - Global gsplat parameters. + * Resolves the configured LOD range against the octree and caches the selection table for it. + * Called before {@link GSplatOctreeInstance#evaluateNodeCoverage} so both that and the budget + * allocator see the same range. */ - updateLod(cameraNode, params) { - + resolveLodRange() { const maxLod = this.octree.lodLevels - 1; - const { lodBaseDistance, lodMultiplier } = this.placement; - - // Clamp configured LOD range to valid bounds [0, maxLod] and ensure min <= max const { lodRangeMin, lodRangeMax } = this.placement; const rangeMin = Math.max(0, Math.min(lodRangeMin ?? 0, maxLod)); const rangeMax = Math.max(rangeMin, Math.min(lodRangeMax ?? maxLod, maxLod)); - - // Pass 1: Evaluate optimal LOD for each node (distance-based) - const uniformScale = this.placement.node.getWorldTransform().getScale().x; - this.evaluateNodeLods(cameraNode, maxLod, lodBaseDistance, lodMultiplier, rangeMin, rangeMax, params, uniformScale, false); - - // Pass 2: Calculate desired LOD (underfill) and apply changes - this.applyLodChanges(maxLod, params); - } - - /** - * Ensures the reusable threshold buffer can store indices 1 through maxLod and fills - * buf[k] = d0 * m^(k-1) for k from 1 to maxLod (same distance bands as truncating 1 + log(d/d0) / log(m)). - * - * @param {number} maxLod - Maximum LOD index (>= 1). - * @param {number} d0 - lodBaseDistance in FOV-adjusted distance space. - * @param {number} m - lodMultiplier. - * @returns {Float32Array} Buffer; index 0 unused; entries 1..maxLod set. - * @private - */ - _ensureLodMinDistThresholds(maxLod, d0, m) { - const needLen = maxLod + 1; - let buf = this._lodMinDistThresholds; - if (!buf || buf.length < needLen) { - buf = new Float32Array(needLen); - this._lodMinDistThresholds = buf; - } - let t = d0; - buf[1] = t; - for (let k = 2; k <= maxLod; k++) { - t *= m; - buf[k] = t; - } - return buf; + this.rangeMin = rangeMin; + this.rangeMax = rangeMax; + this._lodTable = this.octree.getLodTable(rangeMin, rangeMax); } /** - * Evaluates optimal LOD indices for all nodes based on camera position and parameters. - * This is Pass 1 of the LOD update process. Results are stored in nodeInfos array. + * Evaluates per-node projected screen coverage and world distance from the camera. This is + * Pass 1 of the LOD update process; results are stored in the nodeInfos array and consumed by + * the budget allocator, which is what actually picks a LOD level. * - * Uses geometric LOD distances (lodBaseDistance * lodMultiplier^i) with FOV compensation - * so that LOD transitions are perceptually uniform under perspective projection. + * Coverage is the square of the node's projected radius, with FOV compensation so it is + * comparable across cameras, and with the behind-camera penalty folded into the distance. It is + * the only route by which camera position influences LOD. * * @param {GraphNode} cameraNode - The camera node. - * @param {number} maxLod - Maximum LOD index (lodLevels - 1). - * @param {number} lodBaseDistance - Base distance for first LOD transition. - * @param {number} lodMultiplier - Geometric ratio between successive LOD thresholds. - * @param {number} rangeMin - Minimum allowed LOD index. - * @param {number} rangeMax - Maximum allowed LOD index. * @param {import('./gsplat-params.js').GSplatParams} params - Global gsplat parameters. - * @param {number} uniformScale - Uniform scale of the octree transform for world-space conversion. - * @param {boolean} [accumulateSplats] - When true (default), sum splat counts for the chosen LOD per node and return the total (budget path). When false, skip counting (faster; return value unused). - * @param {number} [globalMaxDistanceForBuckets] - When > 0, writes {@link NodeInfo.budgetBucket} using the same sqrt mapping as the budget balancer. Omit or pass 0 when not enforcing global budget. - * @returns {number} Total number of splats that would be used by optimal LODs when accumulateSplats is true; otherwise 0. - * @private */ - evaluateNodeLods(cameraNode, maxLod, lodBaseDistance, lodMultiplier, rangeMin, rangeMax, params, uniformScale, accumulateSplats = true, globalMaxDistanceForBuckets = 0) { + evaluateNodeCoverage(cameraNode, params) { const { lodBehindPenalty } = params; + // Uniform scale of the octree transform, for world-space distance conversion. + const uniformScale = this.placement.node.getWorldTransform().getScale().x; + // Compute FOV compensation: use min(tanHalfV, tanHalfH) to handle ultra-wide and portrait const camera = cameraNode.camera; let tanHalfVFov = Math.tan(camera.fov * 0.5 * math.DEG_TO_RAD); @@ -555,15 +516,6 @@ class GSplatOctreeInstance { const fwx = localCameraForward.x; const fwy = localCameraForward.y; const fwz = localCameraForward.z; - let totalSplats = 0; - - /** @type {Float32Array|null} */ - let minDistBuf = null; - if (maxLod >= 1) { - minDistBuf = this._ensureLodMinDistThresholds(maxLod, lodBaseDistance, lodMultiplier); - } - - const bucketScale = globalMaxDistanceForBuckets > 0 ? NUM_BUCKETS / Math.sqrt(globalMaxDistanceForBuckets) : 0; for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex++) { const nodeInfo = nodeInfos[nodeIndex]; @@ -609,84 +561,26 @@ class GSplatOctreeInstance { } } - // LOD index from geometric distance bands (equivalent to 1 + log(d/d0)/log(m) truncated; coarse-first scan). const fovAdjustedDistance = penalizedDistance * fovScale; - let optimalLodIndex; - if (maxLod === 0 || fovAdjustedDistance < lodBaseDistance) { - optimalLodIndex = 0; - } else { - optimalLodIndex = maxLod; - while (optimalLodIndex > 1 && fovAdjustedDistance < minDistBuf[optimalLodIndex]) { - optimalLodIndex--; - } - } - - // Clamp to configured range - if (optimalLodIndex < rangeMin) optimalLodIndex = rangeMin; - if (optimalLodIndex > rangeMax) optimalLodIndex = rangeMax; - - nodeInfo.optimalLod = optimalLodIndex; nodeInfo.worldDistance = fovAdjustedDistance * uniformScale; - // Budget balancer bucket (sqrt mapping; must match GSplatBudgetBalancer). Fused here when enforcing budget. - if (bucketScale > 0 && optimalLodIndex >= 0) { - const bucket = (Math.sqrt(nodeInfo.worldDistance) * bucketScale) >>> 0; - nodeInfo.budgetBucket = bucket < NUM_BUCKETS ? bucket : NUM_BUCKETS - 1; - } - - if (accumulateSplats) { - // Count splats for this optimal LOD - const lod = nodes[nodeIndex].lods[optimalLodIndex]; - if (lod && lod.count) { - totalSplats += lod.count; - } - } + // Squared projected radius. Floored just above zero so a degenerate node still has a + // well-defined, lowest-possible priority rather than a value the allocator has to + // special-case. + const radius = nodes[nodeIndex].boundingSphere.w; + const projectedRadius = radius / Math.max(radius + fovAdjustedDistance, 1e-12); + nodeInfo.lodCoverage = Math.max(projectedRadius * projectedRadius, 1e-12); } - - return totalSplats; - } - - /** - * Evaluates optimal LOD for all nodes without applying changes. - * Called by GSplatManager during phased global budget enforcement. - * - * @param {GraphNode} cameraNode - The camera node. - * @param {import('./gsplat-params.js').GSplatParams} params - Global gsplat parameters. - * @param {number} [budgetScale] - Dynamic scale applied to LOD parameters to shift - * boundaries closer to the budget target. Applied to lodBaseDistance directly, and - * gently to lodMultiplier via pow(budgetScale, -0.2). Defaults to 1. - * @param {number} [globalMaxDistanceForBuckets] - When > 0, {@link NodeInfo.budgetBucket} is populated during LOD evaluation for budget balancing. - * @returns {number} Total optimal splat count. - */ - evaluateOptimalLods(cameraNode, params, budgetScale = 1, globalMaxDistanceForBuckets = 0) { - const maxLod = this.octree.lodLevels - 1; - const { lodBaseDistance, lodMultiplier } = this.placement; - const { lodRangeMin, lodRangeMax } = this.placement; - const rangeMin = Math.max(0, Math.min(lodRangeMin ?? 0, maxLod)); - const rangeMax = Math.max(rangeMin, Math.min(lodRangeMax ?? maxLod, maxLod)); - - // Store clamped range for budget balancer to use - this.rangeMin = rangeMin; - this.rangeMax = rangeMax; - - // Get uniform scale for world-space conversion - const uniformScale = this.placement.node.getWorldTransform().getScale().x; - - const effectiveBase = lodBaseDistance * budgetScale; - const effectiveMult = Math.max(1.2, lodMultiplier * Math.pow(budgetScale, -0.2)); - - return this.evaluateNodeLods(cameraNode, maxLod, effectiveBase, effectiveMult, - rangeMin, rangeMax, params, uniformScale, true, globalMaxDistanceForBuckets); } /** * Applies calculated LOD changes and manages file placements. - * This is Pass 2 of the LOD update process. Reads from nodeInfos array populated by evaluateNodeLods(). + * This is Pass 2 of the LOD update process. Reads the levels the budget allocator wrote into + * the nodeInfos array. * - * @param {number} maxLod - Maximum LOD index (lodLevels - 1). * @param {import('./gsplat-params.js').GSplatParams} params - Global gsplat parameters. */ - applyLodChanges(maxLod, params) { + applyLodChanges(params) { const nodes = this.octree.nodes; const { lodUnderfillLimit = 0 } = params; @@ -698,7 +592,7 @@ class GSplatOctreeInstance { const currentLodIndex = nodeInfo.currentLod; // Apply underfill strategy to determine desired LOD for streaming - const desiredLodIndex = this.selectDesiredLodIndex(node, optimalLodIndex, maxLod, lodUnderfillLimit); + const desiredLodIndex = this.selectDesiredLodIndex(nodeIndex, optimalLodIndex, lodUnderfillLimit); // if desired LOD differs from currently displayed LOD if (desiredLodIndex !== currentLodIndex) { @@ -794,7 +688,7 @@ class GSplatOctreeInstance { } // Prefetch loading: request only the next-better LOD toward optimal - this.prefetchNextLod(node, desiredLodIndex, optimalLodIndex); + this.prefetchNextLod(nodeIndex, desiredLodIndex, optimalLodIndex); } } @@ -948,7 +842,7 @@ class GSplatOctreeInstance { */ update() { - // Re-evaluate LODs when lodBaseDistance or lodMultiplier changed on the component + // Re-evaluate LODs when the LOD range changed on the component if (this.placement.lodDirty) { this.placement.lodDirty = false; this.needsLodUpdate = true; diff --git a/src/scene/gsplat-unified/gsplat-octree-node.js b/src/scene/gsplat-unified/gsplat-octree-node.js index 6f9342cdf1d..aaae8141935 100644 --- a/src/scene/gsplat-unified/gsplat-octree-node.js +++ b/src/scene/gsplat-unified/gsplat-octree-node.js @@ -8,6 +8,9 @@ import { Vec4 } from '../../core/math/vec4.js'; * @property {number} fileIndex - The file index in the octree files array * @property {number} offset - The offset in the file * @property {number} count - The count of items + * @property {number} error - Approximation error relative to the finest LOD present in this node. + * Zero at that finest level and non-decreasing as levels get coarser. Read from the manifest when + * it supplies one, otherwise derived from splat counts - see {@link GSplatOctree#lodErrorSource}. */ const tmpMin = new Vec3(); diff --git a/src/scene/gsplat-unified/gsplat-octree.js b/src/scene/gsplat-unified/gsplat-octree.js index 609e1e5959f..56a2af6b8b2 100644 --- a/src/scene/gsplat-unified/gsplat-octree.js +++ b/src/scene/gsplat-unified/gsplat-octree.js @@ -1,4 +1,5 @@ import { GSplatOctreeNode } from './gsplat-octree-node.js'; +import { GSplatLodTable } from './gsplat-lod-table.js'; import { path } from '../../core/path.js'; import { Debug } from '../../core/debug.js'; import { Tracing } from '../../core/tracing.js'; @@ -6,6 +7,7 @@ import { TRACEID_OCTREE_RESOURCES } from '../../core/constants.js'; // Temporary array reused to avoid allocations during cooldown ticking const _toDelete = []; + /** * @import { GSplatResource } from '../gsplat/gsplat-resource.js' * @import { GSplatOctreeNodeLod } from './gsplat-octree-node.js' @@ -37,6 +39,35 @@ class GSplatOctree { */ lodLevels; + /** + * Where the per-level approximation errors in {@link GSplatOctreeNode#lods} came from. + * `'file'` when the manifest declared `lodErrors` and every renderable level supplied a usable + * value, `'derived'` when they were computed from splat counts instead. Errors always exist + * either way - this is for diagnostics only, there is no separate code path. + * + * @type {'file'|'derived'} + */ + lodErrorSource = 'derived'; + + /** + * Precomputed LOD selection table for the LOD range currently in use, shared by every instance + * of this octree. Only one is kept: changing the range is rare, and retaining tables for ranges + * no longer in use costs memory for nothing. + * + * @type {GSplatLodTable|null} + * @private + */ + _lodTable = null; + + /** + * How many times {@link GSplatOctree#getLodTable} has had to rebuild. Debug-only, to catch + * instances of one octree asking for different ranges - which would rebuild on every request. + * + * @type {number} + * @private + */ + _lodTableRebuilds = 0; + /** * The file URL of the container asset, used as the base for resolving relative URLs. * @@ -135,6 +166,11 @@ class GSplatOctree { const leafNodes = []; this._extractLeafNodes(data.tree, leafNodes); + // The manifest declares whether it carries error tables; the values themselves are + // confirmed while the nodes are built, so one bad entry anywhere falls the whole asset + // back to derived errors rather than mixing the two. + let fileErrors = data.lodErrors === true; + // Create nodes from the extracted leaf nodes this.nodes = leafNodes.map((nodeData) => { /** @type {GSplatOctreeNodeLod[]} */ @@ -143,12 +179,14 @@ class GSplatOctree { // Ensure we have exactly lodLevels entries for (let i = 0; i < this.lodLevels; i++) { const lodData = nodeData.lods[i.toString()]; + const error = nodeData.errors?.[i]; if (lodData) { lods.push({ file: this.files[lodData.file].url || '', fileIndex: lodData.file, offset: lodData.offset || 0, - count: lodData.count || 0 + count: lodData.count || 0, + error: 0 }); // record LOD level for the file index @@ -159,14 +197,35 @@ class GSplatOctree { file: '', fileIndex: -1, offset: 0, - count: 0 + count: 0, + error: 0 }); } + + // A level that can be rendered must supply an error that is finite and + // non-negative. Errors are magnitudes relative to the finest level, so a negative + // one is meaningless - and more dangerous than a non-finite one, since it would + // pass a finiteness check and then dominate every finer level on the frontier. + if (fileErrors) { + if (lods[i].count > 0 && !(Number.isFinite(error) && error >= 0)) { + fileErrors = false; + } else { + lods[i].error = error ?? 0; + } + } } return new GSplatOctreeNode(lods, nodeData.bound); }); + this.lodErrorSource = fileErrors ? 'file' : 'derived'; + if (data.lodErrors === true && !fileErrors) { + Debug.warn(`GSplatOctree: ${assetFileUrl} declares lodErrors but does not supply a finite, non-negative error for every renderable LOD level, deriving errors from splat counts instead.`); + } + if (!fileErrors) { + this._deriveLodErrors(); + } + // precompute node bounds for CPU hot paths const nodeCount = this.nodes.length; const boundsFlat = new Float32Array(nodeCount * 6); @@ -195,6 +254,7 @@ class GSplatOctree { this.destroyed = true; // Clear internal state + this._lodTable = null; this.fileResources.clear(); this.cooldowns.clear(); @@ -226,6 +286,94 @@ class GSplatOctree { }); } + /** + * Derives per-level approximation errors from splat counts, used when the manifest supplies + * none. The measure is the log of the level's decimation factor against the node's finest + * renderable level. + * + * The allocator only ever consumes the *difference* between adjacent levels, and decimation is + * geometric - each level holds roughly half the splats of the one below it. A log therefore + * gives equal error steps for equal count ratios, which matches how the levels were actually + * produced, and it beat a cube-root spacing proxy on every capture measured - by 2 percentage + * points on a finely partitioned one and by over 20 on a coarse one. + * + * Deliberately scale-free. Reweighting a node by its physical size, as `ln(ref/c) * V^p` over + * AABB volume `V`, was swept for `p` in 1/12 .. 1/3 against real splat-transform errors on + * three captures: it never helped, and cost up to +120% on the finely partitioned one. Two + * reasons it should not help - {@link NodeInfo#lodCoverage} already accounts for apparent size, + * so a size term double-counts it, and splat-transform's own error is a mass-weighted *mean*, + * itself scale-free, so a scale-free proxy matches it in kind. + * + * How close it gets depends mostly on how finely the asset is partitioned, since a count-only + * proxy has less to work with when a node covers more varied content. Against authored errors: + * ~2-6% on captures with thousands of nodes, ~13-17% on one with only ~500. + * + * The result is clamped monotone non-decreasing, because nothing upstream guarantees that a + * coarser level holds fewer splats and a coarser level must never advertise less error than + * the finer one it stands in for. + * + * @private + */ + _deriveLodErrors() { + const levels = this.lodLevels; + const nodes = this.nodes; + for (let n = 0; n < nodes.length; n++) { + const lods = nodes[n].lods; + + // finest renderable level is the reference, and carries no error + let refCount = 0; + for (let i = 0; i < levels; i++) { + if (lods[i].count > 0) { + refCount = lods[i].count; + break; + } + } + if (refCount === 0) continue; + + let previous = 0; + for (let i = 0; i < levels; i++) { + const count = lods[i].count; + const error = count > 0 ? Math.log(refCount / count) : 0; + previous = Math.max(previous, error); + lods[i].error = previous; + } + } + } + + /** + * Returns the LOD selection table for a LOD range, building it on first use and whenever the + * range changes. The previous table is dropped rather than kept: the range comes from placement + * properties and changes rarely, so caching one per range seen would hold memory for ranges no + * longer in use. + * + * The table has to be per range rather than derived from a single full-range one, because a + * sub-range's Pareto frontier is not the full frontier filtered down to it - when `rangeMax` + * lands inside a run of levels with equal error, a level that the full range discards becomes + * the sub-range's cheapest entry. + * + * @param {number} rangeMin - Finest allowed LOD index. + * @param {number} rangeMax - Coarsest allowed LOD index. + * @returns {GSplatLodTable} The selection table. + */ + getLodTable(rangeMin, rangeMax) { + let table = this._lodTable; + if (!table || table.rangeMin !== rangeMin || table.rangeMax !== rangeMax) { + table = new GSplatLodTable(this, rangeMin, rangeMax); + this._lodTable = table; + + Debug.call(() => { + // One slot assumes every instance of this octree uses the same range, which is the + // case when the range comes from a shared preset. Instances asking for different + // ranges would rebuild on every request instead, so say so rather than quietly + // spending the build cost each update. + if (++this._lodTableRebuilds === 64) { + Debug.warnOnce(`GSplatOctree: ${this.assetFileUrl} has rebuilt its LOD selection table ${this._lodTableRebuilds} times. Instances of one octree using different LOD ranges rebuild it on every request - give them a shared range if that is not intended.`); + } + }); + } + return table; + } + /** * Recursively extracts leaf nodes (nodes with 'lods' property) from the hierarchical tree. * @@ -238,7 +386,8 @@ class GSplatOctree { // This is a leaf node with LOD data leafNodes.push({ lods: node.lods, - bound: node.bound + bound: node.bound, + errors: node.errors }); } else if (node.children) { // This is a branch node, recurse into children diff --git a/src/scene/gsplat-unified/gsplat-params.js b/src/scene/gsplat-unified/gsplat-params.js index 16d7ee52443..b2327a91a8c 100644 --- a/src/scene/gsplat-unified/gsplat-params.js +++ b/src/scene/gsplat-unified/gsplat-params.js @@ -22,6 +22,7 @@ import wgslCompactRead from '../shader-lib/wgsl/chunks/gsplat/vert/formats/conta import wgslCompactWrite from '../shader-lib/wgsl/chunks/gsplat/frag/formats/containerCompactWrite.js'; import wgslPackedRead from '../shader-lib/wgsl/chunks/gsplat/vert/formats/containerPackedRead.js'; import wgslPackedWrite from '../shader-lib/wgsl/chunks/gsplat/frag/formats/containerPackedWrite.js'; +import { SPLAT_BUDGET_DEFAULT } from './constants.js'; /** * @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js' @@ -467,12 +468,16 @@ class GSplatParams { } /** @private */ - _splatBudget = 0; + _splatBudget = SPLAT_BUDGET_DEFAULT; /** - * Target number of splats across all GSplats in the scene. When set > 0, - * the system adjusts LOD levels globally to stay within this budget. - * Set to 0 to disable budget enforcement and use LOD distances only (default). + * Target number of splats across all GSplats in the scene. LOD levels are chosen globally to + * stay within this budget, spending it where it removes the most approximation error per splat. + * A budget larger than the scene resolves to every node at its finest level. Defaults to + * 1000000. + * + * There is no way to disable budgeted LOD selection: a non-positive value would pin every node + * to its coarsest level rather than lift the cap, so it warns and the default is used instead. * * @type {number} */ diff --git a/src/scene/gsplat-unified/gsplat-placement.js b/src/scene/gsplat-unified/gsplat-placement.js index 226d9017abb..4a7c718306e 100644 --- a/src/scene/gsplat-unified/gsplat-placement.js +++ b/src/scene/gsplat-unified/gsplat-placement.js @@ -61,49 +61,6 @@ class GSplatPlacement { */ lodIndex = 0; - /** - * Base distance for the first LOD transition (LOD 0 to LOD 1). - * - * @private - */ - _lodBaseDistance = 5; - - /** - * Geometric multiplier between successive LOD distance thresholds. - * Distance for LOD level i is: lodBaseDistance * lodMultiplier^i. - * - * @private - */ - _lodMultiplier = 3; - - /** - * @type {number} - */ - set lodBaseDistance(value) { - if (this._lodBaseDistance !== value) { - this._lodBaseDistance = value; - this.lodDirty = true; - } - } - - get lodBaseDistance() { - return this._lodBaseDistance; - } - - /** - * @type {number} - */ - set lodMultiplier(value) { - if (this._lodMultiplier !== value) { - this._lodMultiplier = value; - this.lodDirty = true; - } - } - - get lodMultiplier() { - return this._lodMultiplier; - } - /** * Minimum allowed LOD index (inclusive). Clamped to the asset's valid range at use. * @@ -314,16 +271,6 @@ class GSplatPlacement { return /** @type {BoundingBox} */ (aabb); } - /** - * Computes the LOD distance threshold for a given level using the geometric progression. - * - * @param {number} level - The LOD level index. - * @returns {number} The distance threshold for the given LOD level. - */ - getLodDistance(level) { - return this.lodBaseDistance * Math.pow(this.lodMultiplier, level); - } - /** * Gets an instance-level texture by name. Creates the streams container on first access * if the format has instance streams defined. diff --git a/src/scene/gsplat-unified/gsplat-world.js b/src/scene/gsplat-unified/gsplat-world.js index 07bfcd2a34f..09806e6eb36 100644 --- a/src/scene/gsplat-unified/gsplat-world.js +++ b/src/scene/gsplat-unified/gsplat-world.js @@ -12,6 +12,7 @@ import { GSplatWorldState } from './gsplat-world-state.js'; import { GSplatPlacementStateTracker } from './gsplat-placement-state-tracker.js'; import { GSplatBudgetBalancer } from './gsplat-budget-balancer.js'; import { GSPLAT_DEBUG_LOD, GSPLAT_DEBUG_SH_UPDATE } from '../constants.js'; +import { SPLAT_BUDGET_DEFAULT } from './constants.js'; /** * @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js' @@ -23,8 +24,6 @@ import { GSPLAT_DEBUG_LOD, GSPLAT_DEBUG_SH_UPDATE } from '../constants.js'; */ // Module-scope scratch (stateless) -const cameraPosition = new Vec3(); -const _tempVec3 = new Vec3(); const invModelMat = new Mat4(); const _localCamPos = new Vec3(); const _closestPt = new Vec3(); @@ -135,9 +134,6 @@ class GSplatWorld { /** @type {GSplatBudgetBalancer} */ _budgetBalancer = new GSplatBudgetBalancer(); - /** @type {number} */ - _budgetScale = 1.0; - /** @type {BlockAllocator} */ _allocator; @@ -556,18 +552,16 @@ class GSplatWorld { this._lastLodCameraFwd.copy(camera.forward); this._lastLodCameraFov = camera.camera.fov; - const budget = this._scene.gsplat.splatBudget; - - if (budget > 0) { - // Global budget enforcement - this._enforceBudget(budget, camera); - } else { - // Budget disabled - use LOD distances only, no budget adjustments - this._budgetScale = 1.0; - for (const [, inst] of this._octreeInstances) { - inst.updateLod(camera, this._scene.gsplat); - } + // LOD selection is always budget driven. A budget generous enough for the whole scene + // resolves to every node at its finest level, so there is no separate unbudgeted path - + // which also means a non-positive budget is not a way to disable LOD selection, it would + // simply pin every node to its coarsest level. Substitute the default and say so. + let budget = this._scene.gsplat.splatBudget; + if (budget <= 0) { + Debug.warnOnce(`GSplatParams#splatBudget is ${budget}, which is not a way to disable LOD selection - LOD levels are always chosen to fit the budget, so a non-positive one would render everything at its coarsest level. Using the default of ${SPLAT_BUDGET_DEFAULT} instead; set a budget that suits the scene.`); + budget = SPLAT_BUDGET_DEFAULT; } + this._enforceBudget(budget, camera); } // create new world state if needed @@ -1113,33 +1107,7 @@ class GSplatWorld { } /** - * Computes max world-space distance across all octree instances. Used for sqrt-based bucket - * distribution in budget balancing. - * - * @param {GraphNode} camera - The primary camera. - * @returns {number} Maximum world-space distance, minimum 1 to avoid division by zero. - * @private - */ - computeGlobalMaxDistance(camera) { - let maxDist = 0; - cameraPosition.copy(camera.getPosition()); - - for (const [, inst] of this._octreeInstances) { - const worldTransform = inst.placement.node.getWorldTransform(); - const aabb = inst.placement.aabb; - - // Transform center to world space and add bounding sphere radius - worldTransform.transformPoint(aabb.center, _tempVec3); - const scale = worldTransform.getScale().x; - const dist = _tempVec3.distance(cameraPosition) + aabb.halfExtents.length() * scale; - if (dist > maxDist) maxDist = dist; - } - - return Math.max(maxDist, 1); - } - - /** - * Enforces global splat budget across all octree instances using a phased approach. + * Enforces the global splat budget across all octree instances. * * @param {number} budget - Target splat budget from GSplatParams.splatBudget. * @param {GraphNode} camera - The primary camera. @@ -1164,13 +1132,11 @@ class GSplatWorld { // Remaining budget for octrees after accounting for fixed splats. const octreeBudget = Math.max(1, budget - fixedSplats); - // Compute global max distance for distance bucket calculation - const globalMaxDistance = this.computeGlobalMaxDistance(camera); - - // Phase 2: Evaluate optimal LODs for all octrees and calculate padding for active placements - let totalOptimalSplats = 0; + // Phase 1: resolve each instance's LOD range and evaluate per-node coverage, and collect + // padding for active placements for (const [, inst] of this._octreeInstances) { - totalOptimalSplats += inst.evaluateOptimalLods(camera, this._scene.gsplat, this._budgetScale, globalMaxDistance); + inst.resolveLodRange(); + inst.evaluateNodeCoverage(camera, this._scene.gsplat); for (const placement of inst.activePlacements) { const resource = /** @type {GSplatResourceBase} */ (placement.resource); const numSplats = resource?.numSplats ?? 0; @@ -1181,26 +1147,12 @@ class GSplatWorld { // Adjust budget for estimated padding overhead const adjustedBudget = Math.max(1, octreeBudget - paddingEstimate); - // Adapt _budgetScale to bring LOD estimates closer to budget by uniformly shifting LOD - // boundaries. - if (totalOptimalSplats > 0) { - const ratio = totalOptimalSplats / adjustedBudget; - const budgetScaleDeadZone = 0.4; - const budgetScaleBlendRate = 0.3; - if (ratio > 1 + budgetScaleDeadZone || ratio < 1 - budgetScaleDeadZone) { - const invCorrection = 1 / Math.sqrt(ratio); - this._budgetScale *= 1 + (invCorrection - 1) * budgetScaleBlendRate; - this._budgetScale = Math.max(0.01, Math.min(this._budgetScale, 100.0)); - } - } - - // Budget balancing across all octrees + // Phase 2: choose a LOD level per node within that budget this._budgetBalancer.balance(this._octreeInstances, adjustedBudget); - // Apply LOD changes + // Phase 3: apply LOD changes for (const [, inst] of this._octreeInstances) { - const maxLod = inst.octree.lodLevels - 1; - inst.applyLodChanges(maxLod, this._scene.gsplat); + inst.applyLodChanges(this._scene.gsplat); } } diff --git a/test/framework/components/gsplat/component.test.mjs b/test/framework/components/gsplat/component.test.mjs index 4b12f3bdcbc..fac8c3bbd91 100644 --- a/test/framework/components/gsplat/component.test.mjs +++ b/test/framework/components/gsplat/component.test.mjs @@ -28,8 +28,6 @@ describe('GSplatComponent', function () { expect(e.gsplat).to.exist; expect(e.gsplat.enabled).to.equal(true); expect(e.gsplat.castShadows).to.equal(false); - expect(e.gsplat.lodBaseDistance).to.equal(5); - expect(e.gsplat.lodMultiplier).to.equal(3); expect(e.gsplat.lodRangeMin).to.equal(0); expect(e.gsplat.lodRangeMax).to.equal(99); }); @@ -38,39 +36,17 @@ describe('GSplatComponent', function () { const e = new Entity(); e.addComponent('gsplat', { castShadows: true, - lodBaseDistance: 8, - lodMultiplier: 2, lodRangeMin: 2, lodRangeMax: 7 }); expect(e.gsplat.castShadows).to.equal(true); - expect(e.gsplat.lodBaseDistance).to.equal(8); - expect(e.gsplat.lodMultiplier).to.equal(2); expect(e.gsplat.lodRangeMin).to.equal(2); expect(e.gsplat.lodRangeMax).to.equal(7); }); }); - describe('#properties', function () { - - it('clamps lodBaseDistance to a minimum of 0.1', function () { - const e = new Entity(); - e.addComponent('gsplat'); - e.gsplat.lodBaseDistance = 0; - expect(e.gsplat.lodBaseDistance).to.equal(0.1); - }); - - it('clamps lodMultiplier to a minimum of 1.2', function () { - const e = new Entity(); - e.addComponent('gsplat'); - e.gsplat.lodMultiplier = 1; - expect(e.gsplat.lodMultiplier).to.equal(1.2); - }); - - }); - describe('#parameters', function () { it('marks the placement dirty on setParameter and deleteParameter', function () { @@ -99,8 +75,6 @@ describe('GSplatComponent', function () { const e = new Entity(); e.addComponent('gsplat', { castShadows: true, - lodBaseDistance: 8, - lodMultiplier: 2, lodRangeMin: 3, lodRangeMax: 6 }); @@ -108,8 +82,6 @@ describe('GSplatComponent', function () { const clone = e.clone(); expect(clone.gsplat.castShadows).to.equal(true); - expect(clone.gsplat.lodBaseDistance).to.equal(8); - expect(clone.gsplat.lodMultiplier).to.equal(2); expect(clone.gsplat.lodRangeMin).to.equal(3); expect(clone.gsplat.lodRangeMax).to.equal(6); }); diff --git a/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs new file mode 100644 index 00000000000..3ee1d92b0a4 --- /dev/null +++ b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs @@ -0,0 +1,305 @@ +import { expect } from 'chai'; + +import { GSplatBudgetBalancer } from '../../../src/scene/gsplat-unified/gsplat-budget-balancer.js'; +import { GSplatLodTable } from '../../../src/scene/gsplat-unified/gsplat-lod-table.js'; + +// Minimal stand-ins for the pieces the balancer touches: an octree exposing nodes and a table +// cache, and an instance exposing nodeInfos plus its resolved LOD range. +const makeInstance = (nodes, coverage, rangeMin = 0, rangeMax = nodes[0].lods.length - 1) => { + const tables = new Map(); + const octree = { + nodes: nodes.map(node => ({ lods: node.lods })), + getLodTable(min, max) { + const key = min * 256 + max; + if (!tables.has(key)) tables.set(key, new GSplatLodTable(this, min, max)); + return tables.get(key); + } + }; + return { + octree, + nodeInfos: nodes.map((_, i) => ({ optimalLod: -1, lodCoverage: coverage?.[i] ?? 1 })), + rangeMin, + rangeMax + }; +}; + +const single = (nodes, coverage, rangeMin, rangeMax) => { + const inst = makeInstance(nodes, coverage, rangeMin, rangeMax); + return { inst, instances: new Map([[{}, inst]]) }; +}; + +const lodsOf = inst => inst.nodeInfos.map(info => info.optimalLod); + +const splatsOf = (inst) => { + let total = 0; + for (let i = 0; i < inst.nodeInfos.length; i++) { + const lod = inst.nodeInfos[i].optimalLod; + if (lod >= 0) total += inst.octree.nodes[i].lods[lod].count; + } + return total; +}; + +// Exact greedy over the same chains, used as an oracle: a max-heap keyed on the true +// coverage-weighted ratio rather than a bucketed approximation of it. Same early exit. +const exactGreedy = (inst, budget) => { + const table = inst.octree.getLodTable(inst.rangeMin, inst.rangeMax); + const chosen = []; + const heap = []; + let spent = 0; + + for (let n = 0; n < inst.nodeInfos.length; n++) { + chosen.push(table.startLod[n]); + if (table.startLod[n] < 0) continue; + spent += table.startCount[n]; + const k = table.firstUpgrade[n]; + if (k < table.firstUpgrade[n + 1]) { + heap.push({ n, k, value: inst.nodeInfos[n].lodCoverage * table.upgradeRatio[k] }); + } + } + + for (;;) { + if (heap.length === 0) break; + heap.sort((a, b) => b.value - a.value); + const top = heap.shift(); + const cost = table.upgradeCost[top.k]; + if (spent + cost > budget) break; + spent += cost; + chosen[top.n] = table.upgradeToLod[top.k]; + const k2 = top.k + 1; + if (k2 < table.firstUpgrade[top.n + 1]) { + heap.push({ n: top.n, k: k2, value: inst.nodeInfos[top.n].lodCoverage * table.upgradeRatio[k2] }); + } + } + return { lods: chosen, spent }; +}; + +const residual = (inst, lods) => { + let total = 0; + for (let i = 0; i < lods.length; i++) { + if (lods[i] >= 0) total += inst.octree.nodes[i].lods[lods[i]].error; + } + return total; +}; + +// Deterministic pseudo-random scene, shaped like a real capture: counts roughly halve per level, +// errors grow unevenly, coverage spans orders of magnitude. +const makeScene = (nodeCount, levels, seed = 1) => { + let s = seed >>> 0; + const rnd = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296); + const nodes = []; + const coverage = []; + for (let n = 0; n < nodeCount; n++) { + const lods = []; + let count = 40 + Math.floor(rnd() * 200); + let error = 0; + lods.push({ count, error }); + for (let l = 1; l < levels; l++) { + count = Math.max(1, Math.floor(count * (0.42 + rnd() * 0.12))); + error += 0.3 + rnd() * 2.2; + lods.push({ count, error }); + } + nodes.push({ lods }); + const d = 2 + 5000 * Math.cbrt(rnd()); + const r = 3 + rnd() * 12; + const pr = r / (r + d); + coverage.push(pr * pr); + } + return { nodes, coverage }; +}; + +describe('GSplatBudgetBalancer', function () { + + it('puts every node at its finest level when the whole scene fits', function () { + const { inst, instances } = single([ + { lods: [{ count: 10, error: 0 }, { count: 5, error: 1 }] }, + { lods: [{ count: 10, error: 0 }, { count: 5, error: 1 }] } + ]); + + new GSplatBudgetBalancer().balance(instances, 100); + + expect(lodsOf(inst)).to.deep.equal([0, 0]); + }); + + it('floors every node when even the cheapest scene is over budget', function () { + const { inst, instances } = single([ + { lods: [{ count: 10, error: 0 }, { count: 5, error: 1 }] }, + { lods: [{ count: 10, error: 0 }, { count: 5, error: 1 }] } + ]); + + new GSplatBudgetBalancer().balance(instances, 4); + + expect(lodsOf(inst)).to.deep.equal([1, 1]); + }); + + it('spends on the node whose error falls fastest per splat', function () { + // identical costs, so the only difference is how much error each upgrade removes + const { inst, instances } = single([ + { lods: [{ count: 10, error: 0 }, { count: 5, error: 100 }] }, + { lods: [{ count: 10, error: 0 }, { count: 5, error: 1 }] } + ]); + + // floor is 5 + 5, and 15 affords exactly one cost-5 upgrade + new GSplatBudgetBalancer().balance(instances, 15); + + expect(lodsOf(inst)).to.deep.equal([0, 1]); + }); + + it('weights that by how much screen the node covers', function () { + // node 1 removes 10x the error, but node 0 covers 100x the screen + const { inst, instances } = single([ + { lods: [{ count: 10, error: 0 }, { count: 5, error: 10 }] }, + { lods: [{ count: 10, error: 0 }, { count: 5, error: 100 }] } + ], [1, 0.01]); + + new GSplatBudgetBalancer().balance(instances, 15); + + expect(lodsOf(inst)).to.deep.equal([0, 1]); + }); + + it('lets one node take several upgrades in a single pass', function () { + const { inst, instances } = single([ + { lods: [{ count: 30, error: 0 }, { count: 20, error: 50 }, { count: 10, error: 100 }] }, + { lods: [{ count: 30, error: 0 }, { count: 20, error: 1 }, { count: 10, error: 2 }] } + ]); + + // floor is 10 + 10; 40 affords node 0's two 10-splat upgrades and nothing else + new GSplatBudgetBalancer().balance(instances, 40); + + expect(lodsOf(inst)).to.deep.equal([0, 2]); + }); + + it('stops at the first upgrade that does not fit', function () { + // the best deal is node 0's, but it costs 40 and only 10 is spare. Node 1's cheap upgrade + // would fit - stopping anyway is what keeps the result stable as the camera moves. + const { inst, instances } = single([ + { lods: [{ count: 50, error: 0 }, { count: 10, error: 1000 }] }, + { lods: [{ count: 15, error: 0 }, { count: 10, error: 1 }] } + ]); + + new GSplatBudgetBalancer().balance(instances, 30); + + expect(lodsOf(inst)).to.deep.equal([1, 1]); + }); + + it('never exceeds the budget', function () { + const { nodes, coverage } = makeScene(400, 5); + const { inst, instances } = single(nodes, coverage); + const balancer = new GSplatBudgetBalancer(); + + for (const budget of [5000, 20000, 50000, 200000]) { + balancer.balance(instances, budget); + expect(splatsOf(inst)).to.be.at.most(budget); + } + }); + + it('leaves a node with nothing renderable unassigned', function () { + const { inst, instances } = single([ + { lods: [{ count: 0, error: 0 }, { count: 0, error: 0 }] }, + { lods: [{ count: 10, error: 0 }, { count: 5, error: 1 }] } + ]); + + new GSplatBudgetBalancer().balance(instances, 12); + + expect(lodsOf(inst)).to.deep.equal([-1, 0]); + }); + + it('is deterministic across repeated runs', function () { + const { nodes, coverage } = makeScene(300, 5, 7); + const { inst, instances } = single(nodes, coverage); + const balancer = new GSplatBudgetBalancer(); + + balancer.balance(instances, 30000); + const first = lodsOf(inst); + balancer.balance(instances, 30000); + expect(lodsOf(inst)).to.deep.equal(first); + + // and independent of the balancer instance, so scratch state cannot leak between runs + const fresh = new GSplatBudgetBalancer(); + fresh.balance(instances, 30000); + expect(lodsOf(inst)).to.deep.equal(first); + }); + + it('shares one budget across several instances', function () { + const a = makeInstance([{ lods: [{ count: 10, error: 0 }, { count: 5, error: 100 }] }], [1]); + const b = makeInstance([{ lods: [{ count: 10, error: 0 }, { count: 5, error: 1 }] }], [1]); + const instances = new Map([[{}, a], [{}, b]]); + + // floor is 5 + 5, so 15 affords one upgrade and it should go to the instance that gains more + new GSplatBudgetBalancer().balance(instances, 15); + + expect(lodsOf(a)).to.deep.equal([0]); + expect(lodsOf(b)).to.deep.equal([1]); + }); + + it('honours each instance\'s own LOD range', function () { + const a = makeInstance([{ lods: [{ count: 100, error: 0 }, { count: 50, error: 1 }, { count: 10, error: 4 }] }], [1], 0, 2); + const b = makeInstance([{ lods: [{ count: 100, error: 0 }, { count: 50, error: 1 }, { count: 10, error: 4 }] }], [1], 2, 2); + const instances = new Map([[{}, a], [{}, b]]); + + new GSplatBudgetBalancer().balance(instances, 1000); + + expect(lodsOf(a)).to.deep.equal([0]); + expect(lodsOf(b)).to.deep.equal([2]); + }); + + it('lands within a few percent of an exact greedy allocation', function () { + // Buckets resolve the greedy order approximately. This pins how much that costs, using an + // exact max-heap over the same chains as the reference. + // + // Budgets are sampled part-way between the floored and the fully upgraded scene. Very high + // fractions are deliberately not asserted on: almost everything gets bought, so the + // residual error is near zero and the *ratio* against it turns noisy while the absolute + // difference stays negligible. + const { nodes, coverage } = makeScene(2000, 5, 11); + const { inst, instances } = single(nodes, coverage); + const table = inst.octree.getLodTable(inst.rangeMin, inst.rangeMax); + const balancer = new GSplatBudgetBalancer(); + + for (const fraction of [0.2, 0.5]) { + const budget = Math.round(table.totalStartCount + + (table.totalFinestCount - table.totalStartCount) * fraction); + balancer.balance(instances, budget); + const bucketed = residual(inst, lodsOf(inst)); + const exact = residual(inst, exactGreedy(inst, budget).lods); + expect(bucketed).to.be.at.most(exact * 1.03); + } + }); + + it('spends as much of the budget as an exact greedy allocation', function () { + // The early exit means both stop at their first misfit. If bucketing shifted where that + // lands, the two would diverge in how much they manage to spend. + const { nodes, coverage } = makeScene(2000, 5, 29); + const { inst, instances } = single(nodes, coverage); + const table = inst.octree.getLodTable(inst.rangeMin, inst.rangeMax); + const balancer = new GSplatBudgetBalancer(); + + for (const fraction of [0.2, 0.5, 0.8]) { + const budget = Math.round(table.totalStartCount + + (table.totalFinestCount - table.totalStartCount) * fraction); + balancer.balance(instances, budget); + const exact = exactGreedy(inst, budget).spent; + expect(splatsOf(inst)).to.be.at.least(exact * 0.99); + } + }); + + it('moves few nodes when the camera moves slightly', function () { + // Temporal stability is the point of the fixed bucket scale and the early exit, so a small + // change in coverage must not reshuffle the scene. + const { nodes, coverage } = makeScene(500, 5, 3); + const { inst, instances } = single(nodes, coverage); + const balancer = new GSplatBudgetBalancer(); + + balancer.balance(instances, 40000); + const before = lodsOf(inst); + + // 1% closer on every node, as a small forward step would give + for (let i = 0; i < inst.nodeInfos.length; i++) { + inst.nodeInfos[i].lodCoverage *= 1.01; + } + balancer.balance(instances, 40000); + const after = lodsOf(inst); + + const changed = before.reduce((n, lod, i) => n + (lod === after[i] ? 0 : 1), 0); + expect(changed).to.be.below(before.length * 0.1); + }); +}); diff --git a/test/scene/gsplat-unified/gsplat-lod-table.test.mjs b/test/scene/gsplat-unified/gsplat-lod-table.test.mjs new file mode 100644 index 00000000000..2288b21b3c6 --- /dev/null +++ b/test/scene/gsplat-unified/gsplat-lod-table.test.mjs @@ -0,0 +1,229 @@ +import { expect } from 'chai'; + +import { GSplatLodTable } from '../../../src/scene/gsplat-unified/gsplat-lod-table.js'; +import { GSplatOctree } from '../../../src/scene/gsplat-unified/gsplat-octree.js'; + +// A single-leaf streamed SOG manifest with `levels` LOD levels, so a test only has to state the +// per-level counts and, optionally, the error table and its header flag. +const makeOctree = (counts, errors, lodErrors) => new GSplatOctree('/scene/lod-meta.json', { + lodLevels: counts.length, + lodErrors, + filenames: counts.map((_, i) => `${i}/meta.json`), + tree: { + bound: { min: [0, 0, 0], max: [1, 1, 1] }, + errors, + lods: Object.fromEntries(counts.map((count, i) => [i, { file: i, offset: 0, count }])) + } +}); + +// The chain a node offers, coarsest first: [startLod, ...upgradeToLod]. +const chainOf = (table, node = 0) => { + const chain = [table.startLod[node]]; + for (let k = table.firstUpgrade[node]; k < table.firstUpgrade[node + 1]; k++) { + chain.push(table.upgradeToLod[k]); + } + return chain; +}; + +const upgradesOf = (table, node = 0) => { + const out = []; + for (let k = table.firstUpgrade[node]; k < table.firstUpgrade[node + 1]; k++) { + out.push({ toLod: table.upgradeToLod[k], cost: table.upgradeCost[k], ratio: table.upgradeRatio[k] }); + } + return out; +}; + +describe('GSplatOctree LOD errors', function () { + + it('reads per-level errors from the manifest when it declares them', function () { + const octree = makeOctree([10, 5], [0, 12.5], true); + expect(octree.lodErrorSource).to.equal('file'); + expect(octree.nodes[0].lods.map(lod => lod.error)).to.deep.equal([0, 12.5]); + }); + + it('derives errors when the manifest does not declare them', function () { + // pre-3.3 manifests carry no lodErrors header, so any values present are not trusted + const octree = makeOctree([8, 1], [0, 12.5], undefined); + expect(octree.lodErrorSource).to.equal('derived'); + // the derived measure is the log of the decimation factor, so 8 splats down to 1 is ln(8) + expect(octree.nodes[0].lods[0].error).to.equal(0); + expect(octree.nodes[0].lods[1].error).to.be.closeTo(Math.log(8), 1e-6); + }); + + it('derives equal error steps for equal decimation ratios', function () { + // each level halves, so a log measure gives a constant step per level - which is what makes + // it track how the levels were actually produced + const octree = makeOctree([80, 40, 20, 10], undefined, undefined); + const errors = octree.nodes[0].lods.map(lod => lod.error); + for (let i = 1; i < errors.length; i++) { + expect(errors[i] - errors[i - 1]).to.be.closeTo(Math.log(2), 1e-6); + } + }); + + it('derives errors when a declared error is not finite', function () { + expect(makeOctree([10, 5], [0, null], true).lodErrorSource).to.equal('derived'); + expect(makeOctree([10, 5], [0], true).lodErrorSource).to.equal('derived'); + }); + + it('derives errors when a declared error is negative', function () { + // errors are magnitudes relative to the finest level; a negative one would let a coarse + // level dominate every finer level on the frontier and pin the node there at any budget + expect(makeOctree([10, 5], [0, -3], true).lodErrorSource).to.equal('derived'); + }); + + it('ignores declared errors on levels that hold no splats', function () { + expect(makeOctree([10, 0], [0, null], true).lodErrorSource).to.equal('file'); + }); + + it('clamps derived errors monotone across levels', function () { + // level 3 holds fewer splats than level 4, so the raw ratio would rank the coarser level + // as the better one + const octree = makeOctree([78, 38, 19, 6, 7], undefined, undefined); + const errors = octree.nodes[0].lods.map(lod => lod.error); + for (let i = 1; i < errors.length; i++) { + expect(errors[i]).to.be.at.least(errors[i - 1]); + } + }); + + it('reuses a table for the same range and rebuilds for a different one', function () { + const octree = makeOctree([10, 5, 2], [0, 1, 2], true); + const a = octree.getLodTable(0, 2); + expect(octree.getLodTable(0, 2)).to.equal(a); + + const b = octree.getLodTable(1, 2); + expect(b).to.not.equal(a); + expect(b.rangeMin).to.equal(1); + + // only the current range is kept, so going back rebuilds rather than returning the old one + const c = octree.getLodTable(0, 2); + expect(c).to.not.equal(a); + expect(c.rangeMin).to.equal(0); + }); +}); + +describe('GSplatLodTable', function () { + + it('orders a node chain cheapest level first', function () { + const octree = makeOctree([100, 50, 20], [0, 1, 3], true); + const table = new GSplatLodTable(octree, 0, 2); + + expect(chainOf(table)).to.deep.equal([2, 1, 0]); + expect(table.startCount[0]).to.equal(20); + expect(table.totalStartCount).to.equal(20); + expect(table.totalFinestCount).to.equal(100); + expect(upgradesOf(table).map(u => u.cost)).to.deep.equal([30, 50]); + }); + + it('drops levels dominated in both count and error', function () { + // level 1 costs more than level 2 and looks worse - nothing would ever pick it + const octree = makeOctree([100, 50, 20], [0, 4, 3], true); + const table = new GSplatLodTable(octree, 0, 2); + + expect(chainOf(table)).to.deep.equal([2, 0]); + }); + + it('drops a level duplicated in both count and error, so no upgrade is free', function () { + // a zero-cost upgrade would carry a 0/0 ratio, and because ratios accumulate through + // Math.min that NaN would demote every later upgrade on the node + const octree = makeOctree([100, 50, 50], [0, 5, 5], true); + const table = new GSplatLodTable(octree, 0, 2); + + expect(chainOf(table)).to.deep.equal([2, 0]); + for (const upgrade of upgradesOf(table)) { + expect(upgrade.cost).to.be.above(0); + expect(Number.isFinite(upgrade.ratio)).to.equal(true); + } + }); + + it('keeps upgrade ratios non-increasing along a chain', function () { + // raw ratios here rise towards the finest level; the running minimum has to flatten them, + // otherwise a later upgrade could outrank an earlier one on the same node + const octree = makeOctree([100, 90, 20], [0, 8, 10], true); + const table = new GSplatLodTable(octree, 0, 2); + + const ratios = upgradesOf(table).map(u => u.ratio); + expect(ratios.length).to.be.above(1); + for (let i = 1; i < ratios.length; i++) { + expect(ratios[i]).to.be.at.most(ratios[i - 1]); + } + }); + + it('skips levels with no splats', function () { + const octree = makeOctree([100, 0, 20], [0, 0, 3], true); + const table = new GSplatLodTable(octree, 0, 2); + + expect(chainOf(table)).to.deep.equal([2, 0]); + }); + + it('marks a node with nothing renderable as having no start level', function () { + const octree = makeOctree([0, 0], [0, 0], true); + const table = new GSplatLodTable(octree, 0, 1); + + expect(table.startLod[0]).to.equal(-1); + expect(table.firstUpgrade[1]).to.equal(table.firstUpgrade[0]); + expect(table.totalStartCount).to.equal(0); + }); + + it('honours the LOD range', function () { + const octree = makeOctree([100, 50, 20, 8], [0, 1, 2, 3], true); + const table = new GSplatLodTable(octree, 1, 2); + + expect(chainOf(table)).to.deep.equal([2, 1]); + expect(table.totalStartCount).to.equal(20); + expect(table.totalFinestCount).to.equal(50); + }); + + it('builds a sub-range frontier from that range alone, not the full one', function () { + // Level 2 is dominated across the full range (level 3 is cheaper at equal error), but with + // rangeMax 2 it is the cheapest level the node has - so filtering the full frontier down to + // the sub-range would leave this node with nothing to start from. + const octree = makeOctree([100, 50, 20, 20], [0, 1, 5, 5], true); + + expect(chainOf(new GSplatLodTable(octree, 0, 3))).to.deep.equal([3, 1, 0]); + expect(chainOf(new GSplatLodTable(octree, 0, 2))).to.deep.equal([2, 1, 0]); + }); + + describe('chain navigation', function () { + + it('steps coarser and finer along the chain, not over raw LOD indices', function () { + // level 1 is dominated, so the chain is 2 -> 0 and stepping must skip level 1 + const octree = makeOctree([100, 50, 20], [0, 4, 3], true); + const table = new GSplatLodTable(octree, 0, 2); + + expect(table.coarserOnChain(0, 0)).to.equal(2); + expect(table.coarserOnChain(0, 2)).to.equal(-1); + expect(table.finerOnChain(0, 2)).to.equal(0); + expect(table.finerOnChain(0, 0)).to.equal(-1); + }); + + it('finds the finest accepted level within a window of coarser chain steps', function () { + const octree = makeOctree([100, 50, 20], [0, 1, 3], true); + const table = new GSplatLodTable(octree, 0, 2); + + // nothing accepted + expect(table.findCoarserAccepted(0, 0, 2, () => false)).to.equal(-1); + // the target itself wins when it qualifies + expect(table.findCoarserAccepted(0, 0, 2, () => true)).to.equal(0); + // otherwise the finest qualifying level within the window + expect(table.findCoarserAccepted(0, 0, 2, lod => lod >= 1)).to.equal(1); + // and the window bounds how far coarser it may look + expect(table.findCoarserAccepted(0, 0, 1, lod => lod === 2)).to.equal(-1); + expect(table.findCoarserAccepted(0, 0, 2, lod => lod === 2)).to.equal(2); + }); + + it('never steps coarser to a level holding more splats', function () { + // level 3 holds fewer splats than level 4, an inversion real captures do contain + const octree = makeOctree([78, 38, 19, 6, 7], undefined, undefined); + const table = new GSplatLodTable(octree, 0, 4); + const lods = octree.nodes[0].lods; + + let lod = table.startLod[0]; + let previous = 0; + while (lod >= 0) { + expect(lods[lod].count).to.be.above(previous); + previous = lods[lod].count; + lod = table.finerOnChain(0, lod); + } + }); + }); +}); From 498d44bbc5cbbfc1ad4c73da3383b85450904867 Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Thu, 27 Aug 2026 13:40:22 +0100 Subject: [PATCH 2/8] Keep GSplatOctreeNodeLod out of the public API docs The typedef is the only symbol in gsplat-unified/ that carried documentation without an @ignore, so excludeNotDocumented let it through and the plugin pulled it in via GSplatComponent#resource -> octree -> nodes -> lods. Its four existing properties were already public; adding a fifth made that visible. Also drops the {@link GSplatOctree#lodErrorSource} reference, which could not resolve because GSplatOctree itself is undocumented. --- src/scene/gsplat-unified/gsplat-octree-node.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/scene/gsplat-unified/gsplat-octree-node.js b/src/scene/gsplat-unified/gsplat-octree-node.js index aaae8141935..dd1b567995e 100644 --- a/src/scene/gsplat-unified/gsplat-octree-node.js +++ b/src/scene/gsplat-unified/gsplat-octree-node.js @@ -10,7 +10,8 @@ import { Vec4 } from '../../core/math/vec4.js'; * @property {number} count - The count of items * @property {number} error - Approximation error relative to the finest LOD present in this node. * Zero at that finest level and non-decreasing as levels get coarser. Read from the manifest when - * it supplies one, otherwise derived from splat counts - see {@link GSplatOctree#lodErrorSource}. + * it supplies one, otherwise derived from splat counts - see `GSplatOctree#lodErrorSource`. + * @ignore */ const tmpMin = new Vec3(); From 8690339030d2edbe99763f7cea6f94918b07528a Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Thu, 27 Aug 2026 13:55:09 +0100 Subject: [PATCH 3/8] Address review: concave frontier, and cache tables per LOD range Two review findings on the LOD selection table. The Pareto frontier does not guarantee non-increasing marginal returns - the slopes between consecutive levels can rise towards the finer end - and clamping each ratio to the running minimum understated the compound step that was actually on offer. For levels (20,10) -> (90,8) -> (100,0) both ratios clamped to 2/70, hiding a 10-error reduction available for 80 splats at 0.125, so a cheaper but less valuable upgrade elsewhere could win the budget and strand the node at its coarsest level. Reduce each node to the upper concave hull over (count, -error) instead, pooling levels below a chord into one compound upgrade. Slopes are then non-increasing by construction and the clamp is gone. This binds on 9-27% of upgrades on captures with authored errors, and never on derived ones, which are concave already. Residual error improves by 1-14% against authored errors as ground truth. Separately, lodRangeMin/lodRangeMax are per placement, so instances of one octree can differ. Holding a single table made each request rebuild the other's, four full builds per update for two instances. Keep a bounded table per range again, and have the balancer read the table resolveLodRange() already resolved rather than resolving it a second time. --- .../gsplat-unified/gsplat-budget-balancer.js | 4 +- src/scene/gsplat-unified/gsplat-lod-table.js | 57 ++++++++++++++++++- .../gsplat-unified/gsplat-octree-instance.js | 14 ++--- src/scene/gsplat-unified/gsplat-octree.js | 52 ++++++++++------- .../gsplat-budget-balancer.test.mjs | 23 +++++++- .../gsplat-unified/gsplat-lod-table.test.mjs | 47 +++++++++++---- 6 files changed, 154 insertions(+), 43 deletions(-) diff --git a/src/scene/gsplat-unified/gsplat-budget-balancer.js b/src/scene/gsplat-unified/gsplat-budget-balancer.js index 6e903a2fade..3643f491082 100644 --- a/src/scene/gsplat-unified/gsplat-budget-balancer.js +++ b/src/scene/gsplat-unified/gsplat-budget-balancer.js @@ -169,7 +169,9 @@ class GSplatBudgetBalancer { let totalStartCount = 0; let totalFinestCount = 0; for (const [, inst] of octreeInstances) { - const table = inst.octree.getLodTable(inst.rangeMin, inst.rangeMax); + // resolveLodRange() already built this for the instance's range; resolving it again + // here would rebuild whenever two instances of one octree differ in range + const table = inst.lodTable; bases.push(nodeTotal); instances.push(inst); tables.push(table); diff --git a/src/scene/gsplat-unified/gsplat-lod-table.js b/src/scene/gsplat-unified/gsplat-lod-table.js index 5fa51e3d18b..6802adda4de 100644 --- a/src/scene/gsplat-unified/gsplat-lod-table.js +++ b/src/scene/gsplat-unified/gsplat-lod-table.js @@ -1,3 +1,5 @@ +import { Debug } from '../../core/debug.js'; + /** * @import { GSplatOctree } from './gsplat-octree.js' */ @@ -175,24 +177,58 @@ class GSplatLodTable { continue; } + // Reduce the frontier to its upper concave hull over (count, -error). The frontier only + // guarantees that error falls as cost rises - the slopes between consecutive levels can + // still *rise* towards the finer end, and a level sitting below the chord between its + // neighbours is never worth stopping at. Dropping it pools the levels either side into + // one compound upgrade priced at the chord, which is the value actually on offer. + // + // Without this the allocator would have to clamp each marginal ratio to the running + // minimum to keep its greedy order valid, and that understates the compound step: for + // levels (20,10) -> (90,8) -> (100,0) both ratios clamp to 2/70, hiding the 10-error + // reduction available for 80 splats at 0.125. Real authored error curves are not + // concave - this binds on 9-27% of upgrades across measured captures - so the clamp + // could leave a node stranded at its coarsest level while cheaper, less valuable + // upgrades elsewhere won the budget. Compacts in place, as above. + let hullCount = 0; + for (let i = 0; i < frontierCount; i++) { + const lod = scratch[i]; + while (hullCount >= 2) { + const a = scratch[hullCount - 2]; + const b = scratch[hullCount - 1]; + // sign of (b - a) x (lod - a) with y = -error; >= 0 means b is not above the + // chord a -> lod, so it cannot be an optimal stopping point + const cross = (lods[b].count - lods[a].count) * (lods[a].error - lods[lod].error) - + (lods[a].error - lods[b].error) * (lods[lod].count - lods[a].count); + if (cross >= 0) hullCount--; else break; + } + scratch[hullCount++] = lod; + } + frontierCount = hullCount; + const startLod = scratch[0]; this.startLod[n] = startLod; this.startCount[n] = lods[startLod].count; totalStartCount += lods[startLod].count; totalFinestCount += lods[scratch[frontierCount - 1]].count; - let previousRatio = Infinity; for (let i = 1; i < frontierCount; i++) { const coarseLod = scratch[i - 1]; const fineLod = scratch[i]; const cost = lods[fineLod].count - lods[coarseLod].count; const benefit = lods[coarseLod].error - lods[fineLod].error; - previousRatio = Math.min(previousRatio, benefit / cost); upgradeToLod[upgradeCount] = fineLod; upgradeCost[upgradeCount] = cost; - upgradeRatio[upgradeCount] = previousRatio; + upgradeRatio[upgradeCount] = benefit / cost; upgradeCount++; } + + // The hull makes this hold by construction, and both the allocator's single sweep and + // its lazy re-insertion depend on it. + const emitted = upgradeCount; + Debug.call(() => { + this._assertNonIncreasing(n, upgradeRatio, emitted); + }); } this.firstUpgrade[nodeCount] = upgradeCount; @@ -206,6 +242,21 @@ class GSplatLodTable { this.upgradeRatio = upgradeRatio.subarray(0, upgradeCount).slice(); } + /** + * Asserts that a node's emitted marginal ratios never rise towards the finer end. + * + * @param {number} nodeIndex - The node whose chain was just emitted. + * @param {Float32Array} ratios - The ratio array being filled. + * @param {number} end - One past the node's last emitted upgrade. + * @private + */ + _assertNonIncreasing(nodeIndex, ratios, end) { + for (let k = this.firstUpgrade[nodeIndex] + 1; k < end; k++) { + Debug.assert(ratios[k] <= ratios[k - 1], + `GSplatLodTable: node ${nodeIndex} has rising marginal returns (${ratios[k - 1]} -> ${ratios[k]}); the concave hull should have pooled these levels.`); + } + } + /** * Walks a node's chain to the coarsest level that is no finer than `lod` and no coarser than * `limit` levels above it, preferring the finest such level that satisfies `accept`. diff --git a/src/scene/gsplat-unified/gsplat-octree-instance.js b/src/scene/gsplat-unified/gsplat-octree-instance.js index b93feffdeeb..fc0bcd5a2db 100644 --- a/src/scene/gsplat-unified/gsplat-octree-instance.js +++ b/src/scene/gsplat-unified/gsplat-octree-instance.js @@ -165,13 +165,13 @@ class GSplatOctreeInstance { rangeMax = 0; /** - * Selection table for the current LOD range, refreshed by - * {@link GSplatOctreeInstance#resolveLodRange}. + * Selection table for this instance's current LOD range, refreshed by + * {@link GSplatOctreeInstance#resolveLodRange}. Read by the budget balancer rather than having + * it resolve the range a second time. * * @type {import('./gsplat-lod-table.js').GSplatLodTable|null} - * @private */ - _lodTable = null; + lodTable = null; /** * Previous node position at which LOD was last updated. This is used to determine if LOD needs @@ -391,7 +391,7 @@ class GSplatOctreeInstance { */ selectDesiredLodIndex(nodeIndex, optimalLodIndex, lodUnderfillLimit) { if (lodUnderfillLimit > 0 && optimalLodIndex >= 0) { - const table = this._lodTable; + const table = this.lodTable; const node = this.octree.nodes[nodeIndex]; // prefer the finest already-loaded level within the allowed window @@ -442,7 +442,7 @@ class GSplatOctreeInstance { } // Step one chain entry finer toward optimal - const targetLod = this._lodTable.finerOnChain(nodeIndex, desiredLodIndex); + const targetLod = this.lodTable.finerOnChain(nodeIndex, desiredLodIndex); if (targetLod < 0) return; const fi = node.lods[targetLod].fileIndex; if (fi !== -1) { @@ -465,7 +465,7 @@ class GSplatOctreeInstance { const rangeMax = Math.max(rangeMin, Math.min(lodRangeMax ?? maxLod, maxLod)); this.rangeMin = rangeMin; this.rangeMax = rangeMax; - this._lodTable = this.octree.getLodTable(rangeMin, rangeMax); + this.lodTable = this.octree.getLodTable(rangeMin, rangeMax); } /** diff --git a/src/scene/gsplat-unified/gsplat-octree.js b/src/scene/gsplat-unified/gsplat-octree.js index 56a2af6b8b2..e8a9c9ebfc5 100644 --- a/src/scene/gsplat-unified/gsplat-octree.js +++ b/src/scene/gsplat-unified/gsplat-octree.js @@ -7,6 +7,10 @@ import { TRACEID_OCTREE_RESOURCES } from '../../core/constants.js'; // Temporary array reused to avoid allocations during cooldown ticking const _toDelete = []; +// How many LOD selection tables one octree keeps. Sized for the number of distinct LOD ranges that +// can be live at once - a few quality presets - not for every range ever requested. +const MAX_LOD_TABLES = 4; + /** * @import { GSplatResource } from '../gsplat/gsplat-resource.js' @@ -50,18 +54,22 @@ class GSplatOctree { lodErrorSource = 'derived'; /** - * Precomputed LOD selection table for the LOD range currently in use, shared by every instance - * of this octree. Only one is kept: changing the range is rare, and retaining tables for ranges - * no longer in use costs memory for nothing. + * Precomputed LOD selection tables, keyed by the LOD range they were built for, and shared by + * every instance of this octree using that range. + * + * More than one is kept because `lodRangeMin`/`lodRangeMax` are per placement, so instances of + * one octree may legitimately differ - holding only the last would rebuild on every request + * once two ranges are live. The map is bounded, and the oldest entry is evicted rather than + * retained indefinitely, so a range that falls out of use does not hold its table forever. * - * @type {GSplatLodTable|null} + * @type {Map} * @private */ - _lodTable = null; + _lodTables = new Map(); /** - * How many times {@link GSplatOctree#getLodTable} has had to rebuild. Debug-only, to catch - * instances of one octree asking for different ranges - which would rebuild on every request. + * How many times {@link GSplatOctree#getLodTable} has had to rebuild. Debug-only, to catch more + * concurrently-live LOD ranges than the map holds, which would rebuild on every request. * * @type {number} * @private @@ -254,7 +262,7 @@ class GSplatOctree { this.destroyed = true; // Clear internal state - this._lodTable = null; + this._lodTables.clear(); this.fileResources.clear(); this.cooldowns.clear(); @@ -341,10 +349,9 @@ class GSplatOctree { } /** - * Returns the LOD selection table for a LOD range, building it on first use and whenever the - * range changes. The previous table is dropped rather than kept: the range comes from placement - * properties and changes rarely, so caching one per range seen would hold memory for ranges no - * longer in use. + * Returns the LOD selection table for a LOD range, building it on first use. Tables for ranges + * still in use are kept, so instances of this octree that differ in range do not rebuild each + * other's table on every request. * * The table has to be per range rather than derived from a single full-range one, because a * sub-range's Pareto frontier is not the full frontier filtered down to it - when `rangeMax` @@ -356,18 +363,23 @@ class GSplatOctree { * @returns {GSplatLodTable} The selection table. */ getLodTable(rangeMin, rangeMax) { - let table = this._lodTable; - if (!table || table.rangeMin !== rangeMin || table.rangeMax !== rangeMax) { + const key = rangeMin * 256 + rangeMax; + let table = this._lodTables.get(key); + if (!table) { + // Evict the oldest rather than growing without bound - Map iterates in insertion order. + // The cap only needs to cover the ranges live at one time, which is a handful of quality + // presets in practice. + if (this._lodTables.size >= MAX_LOD_TABLES) { + this._lodTables.delete(this._lodTables.keys().next().value); + } table = new GSplatLodTable(this, rangeMin, rangeMax); - this._lodTable = table; + this._lodTables.set(key, table); Debug.call(() => { - // One slot assumes every instance of this octree uses the same range, which is the - // case when the range comes from a shared preset. Instances asking for different - // ranges would rebuild on every request instead, so say so rather than quietly - // spending the build cost each update. + // Rebuilding a handful of times as ranges settle is expected; rebuilding constantly + // means more ranges are live than the map holds, and every request pays a build. if (++this._lodTableRebuilds === 64) { - Debug.warnOnce(`GSplatOctree: ${this.assetFileUrl} has rebuilt its LOD selection table ${this._lodTableRebuilds} times. Instances of one octree using different LOD ranges rebuild it on every request - give them a shared range if that is not intended.`); + Debug.warnOnce(`GSplatOctree: ${this.assetFileUrl} has rebuilt its LOD selection table ${this._lodTableRebuilds} times, so more than ${MAX_LOD_TABLES} LOD ranges are in use at once and each request is rebuilding. Share ranges between instances, or raise MAX_LOD_TABLES.`); } }); } diff --git a/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs index 3ee1d92b0a4..f1b064dbc5e 100644 --- a/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs +++ b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs @@ -19,7 +19,10 @@ const makeInstance = (nodes, coverage, rangeMin = 0, rangeMax = nodes[0].lods.le octree, nodeInfos: nodes.map((_, i) => ({ optimalLod: -1, lodCoverage: coverage?.[i] ?? 1 })), rangeMin, - rangeMax + rangeMax, + // resolveLodRange() supplies this in the engine; the balancer reads it rather than + // resolving the range itself + lodTable: octree.getLodTable(rangeMin, rangeMax) }; }; @@ -181,6 +184,24 @@ describe('GSplatBudgetBalancer', function () { expect(lodsOf(inst)).to.deep.equal([1, 1]); }); + it('buys a compound upgrade that beats a cheaper rival outright', function () { + // Node 0's middle level sits below the chord, so its levels pool into one 80-splat step + // worth 10 error (0.125/splat). Node 1 offers 4 error for 40 splats (0.1/splat). Treating + // node 0 as two steps would price both at 2/70, letting node 1 win and then leaving too + // little budget for node 0's 70-splat first step - residual 10 instead of 4. + const { inst, instances } = single([ + { lods: [{ count: 100, error: 0 }, { count: 90, error: 8 }, { count: 20, error: 10 }] }, + { lods: [{ count: 60, error: 0 }, { count: 60, error: 0 }, { count: 20, error: 4 }] } + ]); + + // floors are 20 + 20, so 120 affords exactly node 0's compound step + new GSplatBudgetBalancer().balance(instances, 120); + + expect(inst.nodeInfos[0].optimalLod).to.equal(0); + expect(inst.nodeInfos[1].optimalLod).to.equal(2); + expect(splatsOf(inst)).to.equal(120); + }); + it('never exceeds the budget', function () { const { nodes, coverage } = makeScene(400, 5); const { inst, instances } = single(nodes, coverage); diff --git a/test/scene/gsplat-unified/gsplat-lod-table.test.mjs b/test/scene/gsplat-unified/gsplat-lod-table.test.mjs index 2288b21b3c6..d625f14fffe 100644 --- a/test/scene/gsplat-unified/gsplat-lod-table.test.mjs +++ b/test/scene/gsplat-unified/gsplat-lod-table.test.mjs @@ -85,19 +85,30 @@ describe('GSplatOctree LOD errors', function () { } }); - it('reuses a table for the same range and rebuilds for a different one', function () { + it('keeps a table per range so differing instances do not rebuild each other', function () { + // lodRangeMin/Max are per placement, so two instances of one octree can differ. Holding + // only the last range would make each request rebuild the other's table. const octree = makeOctree([10, 5, 2], [0, 1, 2], true); const a = octree.getLodTable(0, 2); - expect(octree.getLodTable(0, 2)).to.equal(a); - const b = octree.getLodTable(1, 2); + expect(b).to.not.equal(a); + expect(a.rangeMin).to.equal(0); expect(b.rangeMin).to.equal(1); - // only the current range is kept, so going back rebuilds rather than returning the old one - const c = octree.getLodTable(0, 2); - expect(c).to.not.equal(a); - expect(c.rangeMin).to.equal(0); + // alternating between them returns the same objects, no rebuild + expect(octree.getLodTable(0, 2)).to.equal(a); + expect(octree.getLodTable(1, 2)).to.equal(b); + expect(octree.getLodTable(0, 2)).to.equal(a); + }); + + it('evicts the oldest table rather than growing without bound', function () { + const octree = makeOctree([100, 50, 20, 10, 5], [0, 1, 2, 3, 4], true); + const first = octree.getLodTable(0, 4); + // fill past the cap with distinct ranges; the first one should be gone by then + for (const [lo, hi] of [[1, 4], [2, 4], [3, 4], [0, 3]]) octree.getLodTable(lo, hi); + + expect(octree.getLodTable(0, 4)).to.not.equal(first); }); }); @@ -135,14 +146,28 @@ describe('GSplatLodTable', function () { } }); - it('keeps upgrade ratios non-increasing along a chain', function () { - // raw ratios here rise towards the finest level; the running minimum has to flatten them, - // otherwise a later upgrade could outrank an earlier one on the same node + it('pools levels below the chord into one compound upgrade', function () { + // The frontier keeps all three, but 90 sits below the chord from 20 to 100, so stopping + // there is never optimal. Pooling prices the real offer - 10 error for 80 splats - where + // treating them as two steps would advertise only 2/70 for the first. const octree = makeOctree([100, 90, 20], [0, 8, 10], true); const table = new GSplatLodTable(octree, 0, 2); + expect(chainOf(table)).to.deep.equal([2, 0]); + const upgrades = upgradesOf(table); + expect(upgrades.length).to.equal(1); + expect(upgrades[0].cost).to.equal(80); + expect(upgrades[0].ratio).to.be.closeTo(0.125, 1e-6); + }); + + it('keeps every level whose returns already fall towards the finest', function () { + // 50 is above the chord from 20 to 100, so it is a genuine stopping point and survives + const octree = makeOctree([100, 50, 20], [0, 1, 5], true); + const table = new GSplatLodTable(octree, 0, 2); + + expect(chainOf(table)).to.deep.equal([2, 1, 0]); const ratios = upgradesOf(table).map(u => u.ratio); - expect(ratios.length).to.be.above(1); + expect(ratios.length).to.equal(2); for (let i = 1; i < ratios.length; i++) { expect(ratios[i]).to.be.at.most(ratios[i - 1]); } From 6e5470979e3c6dd83d3ca414ad778c1550f12c06 Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Thu, 27 Aug 2026 15:00:43 +0100 Subject: [PATCH 4/8] Reference count LOD selection tables instead of capping them A fixed cap can evict a table an instance is still using: with more live ranges than the cap holds, every LOD update misses on all of them and rebuilds. The cap existed to stop tables being retained for ranges no longer in use, which reference counting answers directly. acquireLodTable/releaseLodTable replace getLodTable. An instance takes a reference when its range changes and releases the previous one, and releases on destroy, so a table lives exactly as long as some instance is on its range - no retention for dead ranges, and nothing live can be evicted however many ranges are in play. --- src/scene/gsplat-unified/gsplat-lod-table.js | 10 +++ .../gsplat-unified/gsplat-octree-instance.js | 16 ++++- src/scene/gsplat-unified/gsplat-octree.js | 63 ++++++++----------- .../gsplat-budget-balancer.test.mjs | 14 +++-- .../gsplat-unified/gsplat-lod-table.test.mjs | 53 ++++++++++++---- 5 files changed, 99 insertions(+), 57 deletions(-) diff --git a/src/scene/gsplat-unified/gsplat-lod-table.js b/src/scene/gsplat-unified/gsplat-lod-table.js index 6802adda4de..25e8f05b098 100644 --- a/src/scene/gsplat-unified/gsplat-lod-table.js +++ b/src/scene/gsplat-unified/gsplat-lod-table.js @@ -43,6 +43,16 @@ class GSplatLodTable { */ rangeMax; + /** + * How many octree instances currently hold this table. Managed by the owning + * {@link GSplatOctree}, which drops the table when this reaches zero - so a table is retained + * exactly while some instance is using its range, rather than on a fixed cap that could evict + * one still in use. + * + * @type {number} + */ + refCount = 0; + /** * Per node, the cheapest renderable level in range - where the allocator starts before it * spends anything. -1 when the node has no renderable level in range at all. diff --git a/src/scene/gsplat-unified/gsplat-octree-instance.js b/src/scene/gsplat-unified/gsplat-octree-instance.js index fc0bcd5a2db..7131a309c02 100644 --- a/src/scene/gsplat-unified/gsplat-octree-instance.js +++ b/src/scene/gsplat-unified/gsplat-octree-instance.js @@ -273,6 +273,13 @@ class GSplatOctreeInstance { * (e.g. during world state updates where decrements must be deferred). */ destroy(skipRefCounting = false) { + // The LOD table is this instance's own cache reference, not one of the octree's file + // reference counts, so it is released regardless of skipRefCounting. + if (this.octree && !this.octree.destroyed) { + this.octree.releaseLodTable(this.lodTable); + } + this.lodTable = null; + if (!skipRefCounting && this.octree && !this.octree.destroyed) { // Decrement ref counts for all files currently in use (loaded files) const filesToDecRef = this.getFileDecrements(); @@ -465,7 +472,14 @@ class GSplatOctreeInstance { const rangeMax = Math.max(rangeMin, Math.min(lodRangeMax ?? maxLod, maxLod)); this.rangeMin = rangeMin; this.rangeMax = rangeMax; - this.lodTable = this.octree.getLodTable(rangeMin, rangeMax); + + // Hold a reference only while this instance is on that range, so a table is built once per + // live range and dropped when the last instance moves off it. + const table = this.lodTable; + if (!table || table.rangeMin !== rangeMin || table.rangeMax !== rangeMax) { + this.lodTable = this.octree.acquireLodTable(rangeMin, rangeMax); + this.octree.releaseLodTable(table); + } } /** diff --git a/src/scene/gsplat-unified/gsplat-octree.js b/src/scene/gsplat-unified/gsplat-octree.js index e8a9c9ebfc5..7c9f9294aaa 100644 --- a/src/scene/gsplat-unified/gsplat-octree.js +++ b/src/scene/gsplat-unified/gsplat-octree.js @@ -7,10 +7,6 @@ import { TRACEID_OCTREE_RESOURCES } from '../../core/constants.js'; // Temporary array reused to avoid allocations during cooldown ticking const _toDelete = []; -// How many LOD selection tables one octree keeps. Sized for the number of distinct LOD ranges that -// can be live at once - a few quality presets - not for every range ever requested. -const MAX_LOD_TABLES = 4; - /** * @import { GSplatResource } from '../gsplat/gsplat-resource.js' @@ -54,28 +50,20 @@ class GSplatOctree { lodErrorSource = 'derived'; /** - * Precomputed LOD selection tables, keyed by the LOD range they were built for, and shared by + * Precomputed LOD selection tables, keyed by the LOD range they were built for and shared by * every instance of this octree using that range. * - * More than one is kept because `lodRangeMin`/`lodRangeMax` are per placement, so instances of - * one octree may legitimately differ - holding only the last would rebuild on every request - * once two ranges are live. The map is bounded, and the oldest entry is evicted rather than - * retained indefinitely, so a range that falls out of use does not hold its table forever. + * `lodRangeMin`/`lodRangeMax` are per placement, so instances of one octree may legitimately + * differ and each live range needs its own table - holding only the most recent would rebuild + * on every request. Entries are reference counted by the instances holding them and dropped at + * zero, so nothing is retained for a range that has fallen out of use, and nothing still in use + * can be evicted. * * @type {Map} * @private */ _lodTables = new Map(); - /** - * How many times {@link GSplatOctree#getLodTable} has had to rebuild. Debug-only, to catch more - * concurrently-live LOD ranges than the map holds, which would rebuild on every request. - * - * @type {number} - * @private - */ - _lodTableRebuilds = 0; - /** * The file URL of the container asset, used as the base for resolving relative URLs. * @@ -349,9 +337,8 @@ class GSplatOctree { } /** - * Returns the LOD selection table for a LOD range, building it on first use. Tables for ranges - * still in use are kept, so instances of this octree that differ in range do not rebuild each - * other's table on every request. + * Takes a reference to the LOD selection table for a LOD range, building it on first use. The + * caller must pass it back to {@link GSplatOctree#releaseLodTable} when it stops using it. * * The table has to be per range rather than derived from a single full-range one, because a * sub-range's Pareto frontier is not the full frontier filtered down to it - when `rangeMax` @@ -360,32 +347,34 @@ class GSplatOctree { * * @param {number} rangeMin - Finest allowed LOD index. * @param {number} rangeMax - Coarsest allowed LOD index. - * @returns {GSplatLodTable} The selection table. + * @returns {GSplatLodTable} The selection table, with its reference count incremented. */ - getLodTable(rangeMin, rangeMax) { + acquireLodTable(rangeMin, rangeMax) { const key = rangeMin * 256 + rangeMax; let table = this._lodTables.get(key); if (!table) { - // Evict the oldest rather than growing without bound - Map iterates in insertion order. - // The cap only needs to cover the ranges live at one time, which is a handful of quality - // presets in practice. - if (this._lodTables.size >= MAX_LOD_TABLES) { - this._lodTables.delete(this._lodTables.keys().next().value); - } table = new GSplatLodTable(this, rangeMin, rangeMax); this._lodTables.set(key, table); - - Debug.call(() => { - // Rebuilding a handful of times as ranges settle is expected; rebuilding constantly - // means more ranges are live than the map holds, and every request pays a build. - if (++this._lodTableRebuilds === 64) { - Debug.warnOnce(`GSplatOctree: ${this.assetFileUrl} has rebuilt its LOD selection table ${this._lodTableRebuilds} times, so more than ${MAX_LOD_TABLES} LOD ranges are in use at once and each request is rebuilding. Share ranges between instances, or raise MAX_LOD_TABLES.`); - } - }); } + table.refCount++; return table; } + /** + * Releases a reference taken by {@link GSplatOctree#acquireLodTable}, dropping the table once + * no instance holds it. + * + * @param {GSplatLodTable|null} table - The table to release. Null is ignored, so callers can + * release unconditionally. + */ + releaseLodTable(table) { + if (!table) return; + Debug.assert(table.refCount > 0, `GSplatOctree: releasing a LOD table for range [${table.rangeMin}, ${table.rangeMax}] that holds no references.`); + if (--table.refCount <= 0) { + this._lodTables.delete(table.rangeMin * 256 + table.rangeMax); + } + } + /** * Recursively extracts leaf nodes (nodes with 'lods' property) from the hierarchical tree. * diff --git a/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs index f1b064dbc5e..ad70a4837ed 100644 --- a/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs +++ b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs @@ -9,10 +9,12 @@ const makeInstance = (nodes, coverage, rangeMin = 0, rangeMax = nodes[0].lods.le const tables = new Map(); const octree = { nodes: nodes.map(node => ({ lods: node.lods })), - getLodTable(min, max) { + acquireLodTable(min, max) { const key = min * 256 + max; if (!tables.has(key)) tables.set(key, new GSplatLodTable(this, min, max)); - return tables.get(key); + const table = tables.get(key); + table.refCount++; + return table; } }; return { @@ -22,7 +24,7 @@ const makeInstance = (nodes, coverage, rangeMin = 0, rangeMax = nodes[0].lods.le rangeMax, // resolveLodRange() supplies this in the engine; the balancer reads it rather than // resolving the range itself - lodTable: octree.getLodTable(rangeMin, rangeMax) + lodTable: octree.acquireLodTable(rangeMin, rangeMax) }; }; @@ -45,7 +47,7 @@ const splatsOf = (inst) => { // Exact greedy over the same chains, used as an oracle: a max-heap keyed on the true // coverage-weighted ratio rather than a bucketed approximation of it. Same early exit. const exactGreedy = (inst, budget) => { - const table = inst.octree.getLodTable(inst.rangeMin, inst.rangeMax); + const table = inst.lodTable; const chosen = []; const heap = []; let spent = 0; @@ -273,7 +275,7 @@ describe('GSplatBudgetBalancer', function () { // difference stays negligible. const { nodes, coverage } = makeScene(2000, 5, 11); const { inst, instances } = single(nodes, coverage); - const table = inst.octree.getLodTable(inst.rangeMin, inst.rangeMax); + const table = inst.lodTable; const balancer = new GSplatBudgetBalancer(); for (const fraction of [0.2, 0.5]) { @@ -291,7 +293,7 @@ describe('GSplatBudgetBalancer', function () { // lands, the two would diverge in how much they manage to spend. const { nodes, coverage } = makeScene(2000, 5, 29); const { inst, instances } = single(nodes, coverage); - const table = inst.octree.getLodTable(inst.rangeMin, inst.rangeMax); + const table = inst.lodTable; const balancer = new GSplatBudgetBalancer(); for (const fraction of [0.2, 0.5, 0.8]) { diff --git a/test/scene/gsplat-unified/gsplat-lod-table.test.mjs b/test/scene/gsplat-unified/gsplat-lod-table.test.mjs index d625f14fffe..267dd220c01 100644 --- a/test/scene/gsplat-unified/gsplat-lod-table.test.mjs +++ b/test/scene/gsplat-unified/gsplat-lod-table.test.mjs @@ -85,30 +85,57 @@ describe('GSplatOctree LOD errors', function () { } }); - it('keeps a table per range so differing instances do not rebuild each other', function () { + it('keeps a table per live range so differing instances do not rebuild each other', function () { // lodRangeMin/Max are per placement, so two instances of one octree can differ. Holding - // only the last range would make each request rebuild the other's table. + // only the most recent range would make each request rebuild the other's table. const octree = makeOctree([10, 5, 2], [0, 1, 2], true); - const a = octree.getLodTable(0, 2); - const b = octree.getLodTable(1, 2); + const a = octree.acquireLodTable(0, 2); + const b = octree.acquireLodTable(1, 2); expect(b).to.not.equal(a); expect(a.rangeMin).to.equal(0); expect(b.rangeMin).to.equal(1); - // alternating between them returns the same objects, no rebuild - expect(octree.getLodTable(0, 2)).to.equal(a); - expect(octree.getLodTable(1, 2)).to.equal(b); - expect(octree.getLodTable(0, 2)).to.equal(a); + // alternating between them returns the same objects, no rebuild, however many are live + expect(octree.acquireLodTable(0, 2)).to.equal(a); + expect(octree.acquireLodTable(1, 2)).to.equal(b); + expect(a.refCount).to.equal(2); }); - it('evicts the oldest table rather than growing without bound', function () { + it('keeps a table alive while any reference is held, then drops it', function () { + const octree = makeOctree([10, 5, 2], [0, 1, 2], true); + const a = octree.acquireLodTable(0, 2); + const alsoA = octree.acquireLodTable(0, 2); + expect(alsoA).to.equal(a); + expect(a.refCount).to.equal(2); + + // one holder leaving must not drop a table the other is still using + octree.releaseLodTable(a); + expect(a.refCount).to.equal(1); + expect(octree.acquireLodTable(0, 2)).to.equal(a); + + octree.releaseLodTable(a); + octree.releaseLodTable(a); + expect(a.refCount).to.equal(0); + + // with no holders left the next request rebuilds rather than returning the dropped table + expect(octree.acquireLodTable(0, 2)).to.not.equal(a); + }); + + it('retains every live range however many there are', function () { + // a fixed cap would evict a range still in use here, rebuilding all of them every pass const octree = makeOctree([100, 50, 20, 10, 5], [0, 1, 2, 3, 4], true); - const first = octree.getLodTable(0, 4); - // fill past the cap with distinct ranges; the first one should be gone by then - for (const [lo, hi] of [[1, 4], [2, 4], [3, 4], [0, 3]]) octree.getLodTable(lo, hi); + const ranges = [[0, 4], [1, 4], [2, 4], [3, 4], [0, 3], [1, 3]]; + const held = ranges.map(([lo, hi]) => octree.acquireLodTable(lo, hi)); + + ranges.forEach(([lo, hi], i) => { + expect(octree.acquireLodTable(lo, hi)).to.equal(held[i]); + }); + }); - expect(octree.getLodTable(0, 4)).to.not.equal(first); + it('tolerates releasing null', function () { + const octree = makeOctree([10, 5], [0, 1], true); + expect(() => octree.releaseLodTable(null)).to.not.throw(); }); }); From 5bb9fd54f68bd8cd1082897e246eb9d5960eb278 Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Thu, 27 Aug 2026 15:42:46 +0100 Subject: [PATCH 5/8] Keep every LOD level, and price a step by the run it opens Reducing each node to the concave hull priced compound steps correctly but deleted the levels in between, and those levels are not redundant: 20-25% of Pareto levels went, affecting 70-86% of nodes, and every one of them strictly reduces error - median 15% against the level below it. They are also the states streaming and underfill step through, so a node could no longer show an already-loaded intermediate, and prefetch's one-level climb became a multi-level jump - 12 to 229 splats in one step on a parish_03 node. Keep the full Pareto chain and move one level at a time, dropping only levels dominated in both count and error, which is 4%. Value a step by the best deal reachable by carrying on from where it starts rather than by its own slope, so a step that is poor alone but opens an excellent run competes on what it is worth. That recovers the mispricing the hull was introduced for - measured end to end through the real balancer, residual error is 1-17% below the previous running-minimum pricing - without removing anything. Values are no longer monotone along a chain, so requeueing a successor is capped at the bucket being drained. Every update re-floors from the cheapest level, so a node pushed above the sweep would be dropped on every update rather than merely delayed, and could never finish the run it started. --- .../gsplat-unified/gsplat-budget-balancer.js | 26 +++-- src/scene/gsplat-unified/gsplat-lod-table.js | 109 ++++++------------ .../gsplat-unified/gsplat-lod-table.test.mjs | 28 +++-- 3 files changed, 72 insertions(+), 91 deletions(-) diff --git a/src/scene/gsplat-unified/gsplat-budget-balancer.js b/src/scene/gsplat-unified/gsplat-budget-balancer.js index 3643f491082..bc1f2b9aff3 100644 --- a/src/scene/gsplat-unified/gsplat-budget-balancer.js +++ b/src/scene/gsplat-unified/gsplat-budget-balancer.js @@ -46,11 +46,13 @@ const KEY_SCALE = (NUM_VALUE_BUCKETS - 1) / (KEY_HI - KEY_LO); * upgrade happened to be considered first, so small camera movements would flip levels on and off. * The cost is leaving some budget unspent. * - * Only a node's next unbought upgrade is ever in the queue; buying it enqueues its successor. Since - * a successor's value is never higher than its predecessor's, it lands in the current bucket or a - * lower one, so a single sweep from the top bucket down suffices. It also means at most one entry - * per node is live, which is what lets the buckets be intrusive lists over preallocated typed - * arrays with no per-entry storage at all. + * Only a node's next unbought upgrade is ever in the queue; buying it enqueues its successor. That + * keeps at most one entry per node live, which is what lets the buckets be intrusive lists over + * preallocated typed arrays with no per-entry storage at all. + * + * A successor can be worth more than what was just bought, since values are the best deal reachable + * from a level rather than that level's own slope. Requeueing is therefore capped at the bucket + * being drained, so a run always completes within the sweep that started it - see the drain. * * @ignore */ @@ -122,8 +124,8 @@ class GSplatBudgetBalancer { } /** - * Maps an upgrade value to a bucket. Monotonic, so a node's successor upgrade never lands in a - * bucket above the one it was bought from. + * Maps an upgrade value to a bucket. Monotonic in the value, so ordering between different + * upgrades is preserved; the drain caps where a successor may be requeued. * * @param {number} value - Coverage-weighted error reduction per splat. * @returns {number} Bucket index. @@ -250,7 +252,15 @@ class GSplatBudgetBalancer { const k2 = k + 1; if (k2 < table.firstUpgrade[n + 1]) { pending[g] = k2; - this._push(this._bucketOf(coverage[g] * table.upgradeRatio[k2]), g); + // Never above the bucket being drained. A successor can be worth more than what + // was just bought - values are the best deal reachable from a level, so a poorly + // valued step opens a better run - and this sweep has already passed the higher + // buckets. Since every update re-floors from the cheapest level, a node pushed + // above the sweep would be dropped on every update, not merely delayed, and + // could never finish the run it started. Requeueing it here instead completes + // the run in this sweep, at the priority of the step that opened it. + const target = this._bucketOf(coverage[g] * table.upgradeRatio[k2]); + this._push(target > bucket ? bucket : target, g); } g = this._bucketHead[bucket]; } diff --git a/src/scene/gsplat-unified/gsplat-lod-table.js b/src/scene/gsplat-unified/gsplat-lod-table.js index 25e8f05b098..e4f53dee19c 100644 --- a/src/scene/gsplat-unified/gsplat-lod-table.js +++ b/src/scene/gsplat-unified/gsplat-lod-table.js @@ -1,5 +1,3 @@ -import { Debug } from '../../core/debug.js'; - /** * @import { GSplatOctree } from './gsplat-octree.js' */ @@ -11,20 +9,21 @@ import { Debug } from '../../core/debug.js'; * Each node is reduced to a chain of single-level *upgrades*, ordered cheapest level first. The * chain is the Pareto frontier over (splat count, error): a level is kept only when it strictly * improves on the cheapest error seen so far, which discards levels that cost more and look worse - * than something else the node already offers. Requiring a *strict* improvement also collapses - * levels identical in both, so consecutive entries always differ in both and every upgrade's cost - * stays above zero. - * - * Per update the allocator needs one number per node - its projected screen coverage - and the - * value of an upgrade is `coverage * error removed / splats added`. The second factor is fixed, and - * coverage is a single non-negative scalar multiplying every upgrade of that node equally, so the - * running minimum that keeps a node's returns non-increasing can be taken here rather than each - * update: + * than something else the node already offers - a small fraction in practice, and redundant by + * definition. Requiring a *strict* improvement also collapses levels identical in both, so + * consecutive entries always differ in both and every upgrade's cost stays above zero. * - * min over j<=k of (coverage * r_j) === coverage * min over j<=k of r_j + * Every level that survives is a level the node can render, and the allocator moves one level at a + * time. Nothing else is dropped: levels that are poor value for their splats are still real + * improvements, and the chain doubles as the set of states streaming and underfill may pass + * through, so removing them would deny a loaded level to underfill and turn prefetch's one-level + * climb into a multi-level jump. * - * That is what {@link GSplatLodTable#upgradeRatio} stores, leaving one multiply per upgrade at - * selection time. + * Per update the allocator needs one number per node - its projected screen coverage - and the + * value of an upgrade is `coverage * error removed / splats added`. The second factor is fixed and + * coverage is a single non-negative scalar multiplying every upgrade of that node equally, so + * {@link GSplatLodTable#upgradeRatio} holds the fixed part and selection costs one multiply per + * upgrade. * * @ignore */ @@ -92,9 +91,13 @@ class GSplatLodTable { upgradeCost; /** - * Per upgrade, error removed per additional splat, clamped to the running minimum along the - * node's chain so returns never increase as a node gets finer. Multiplying by the node's - * coverage yields the upgrade's value. + * Per upgrade, error removed per additional splat over the best run this upgrade opens up - + * `max` over the levels reachable from where it starts, rather than its own slope. Multiplying + * by the node's coverage yields the upgrade's value. + * + * Note this is not monotone along a chain: once a poorly-valued step has been taken, what + * remains can be worth more than what was just bought. The allocator tolerates that, see + * GSplatBudgetBalancer. * * @type {Float32Array} */ @@ -187,35 +190,6 @@ class GSplatLodTable { continue; } - // Reduce the frontier to its upper concave hull over (count, -error). The frontier only - // guarantees that error falls as cost rises - the slopes between consecutive levels can - // still *rise* towards the finer end, and a level sitting below the chord between its - // neighbours is never worth stopping at. Dropping it pools the levels either side into - // one compound upgrade priced at the chord, which is the value actually on offer. - // - // Without this the allocator would have to clamp each marginal ratio to the running - // minimum to keep its greedy order valid, and that understates the compound step: for - // levels (20,10) -> (90,8) -> (100,0) both ratios clamp to 2/70, hiding the 10-error - // reduction available for 80 splats at 0.125. Real authored error curves are not - // concave - this binds on 9-27% of upgrades across measured captures - so the clamp - // could leave a node stranded at its coarsest level while cheaper, less valuable - // upgrades elsewhere won the budget. Compacts in place, as above. - let hullCount = 0; - for (let i = 0; i < frontierCount; i++) { - const lod = scratch[i]; - while (hullCount >= 2) { - const a = scratch[hullCount - 2]; - const b = scratch[hullCount - 1]; - // sign of (b - a) x (lod - a) with y = -error; >= 0 means b is not above the - // chord a -> lod, so it cannot be an optimal stopping point - const cross = (lods[b].count - lods[a].count) * (lods[a].error - lods[lod].error) - - (lods[a].error - lods[b].error) * (lods[lod].count - lods[a].count); - if (cross >= 0) hullCount--; else break; - } - scratch[hullCount++] = lod; - } - frontierCount = hullCount; - const startLod = scratch[0]; this.startLod[n] = startLod; this.startCount[n] = lods[startLod].count; @@ -225,20 +199,28 @@ class GSplatLodTable { for (let i = 1; i < frontierCount; i++) { const coarseLod = scratch[i - 1]; const fineLod = scratch[i]; - const cost = lods[fineLod].count - lods[coarseLod].count; - const benefit = lods[coarseLod].error - lods[fineLod].error; + + // Value this step by the best deal reachable by carrying on from where it starts, + // not by its own slope. A step can be poor on its own while the run it opens is + // excellent - for levels (20,10) -> (90,8) -> (100,0) the first step removes 2 + // error for 70 splats, but reaching 100 removes 10 for 80. Priced locally the node + // looks worthless and loses the budget to genuinely inferior upgrades elsewhere; + // priced by its reach it competes on what it is actually worth, then climbs one + // level at a time. O(levels) per step over a handful of levels. + let ratio = 0; + for (let j = i; j < frontierCount; j++) { + const reach = scratch[j]; + const r = (lods[coarseLod].error - lods[reach].error) / + (lods[reach].count - lods[coarseLod].count); + if (r > ratio) ratio = r; + } + upgradeToLod[upgradeCount] = fineLod; - upgradeCost[upgradeCount] = cost; - upgradeRatio[upgradeCount] = benefit / cost; + upgradeCost[upgradeCount] = lods[fineLod].count - lods[coarseLod].count; + upgradeRatio[upgradeCount] = ratio; upgradeCount++; } - // The hull makes this hold by construction, and both the allocator's single sweep and - // its lazy re-insertion depend on it. - const emitted = upgradeCount; - Debug.call(() => { - this._assertNonIncreasing(n, upgradeRatio, emitted); - }); } this.firstUpgrade[nodeCount] = upgradeCount; @@ -252,21 +234,6 @@ class GSplatLodTable { this.upgradeRatio = upgradeRatio.subarray(0, upgradeCount).slice(); } - /** - * Asserts that a node's emitted marginal ratios never rise towards the finer end. - * - * @param {number} nodeIndex - The node whose chain was just emitted. - * @param {Float32Array} ratios - The ratio array being filled. - * @param {number} end - One past the node's last emitted upgrade. - * @private - */ - _assertNonIncreasing(nodeIndex, ratios, end) { - for (let k = this.firstUpgrade[nodeIndex] + 1; k < end; k++) { - Debug.assert(ratios[k] <= ratios[k - 1], - `GSplatLodTable: node ${nodeIndex} has rising marginal returns (${ratios[k - 1]} -> ${ratios[k]}); the concave hull should have pooled these levels.`); - } - } - /** * Walks a node's chain to the coarsest level that is no finer than `lod` and no coarser than * `limit` levels above it, preferring the finest such level that satisfies `accept`. diff --git a/test/scene/gsplat-unified/gsplat-lod-table.test.mjs b/test/scene/gsplat-unified/gsplat-lod-table.test.mjs index 267dd220c01..552bfe43f1f 100644 --- a/test/scene/gsplat-unified/gsplat-lod-table.test.mjs +++ b/test/scene/gsplat-unified/gsplat-lod-table.test.mjs @@ -173,31 +173,35 @@ describe('GSplatLodTable', function () { } }); - it('pools levels below the chord into one compound upgrade', function () { - // The frontier keeps all three, but 90 sits below the chord from 20 to 100, so stopping - // there is never optimal. Pooling prices the real offer - 10 error for 80 splats - where - // treating them as two steps would advertise only 2/70 for the first. + it('prices a step by the best run it opens, keeping every level', function () { + // 20 -> 90 is a poor step on its own - 2 error for 70 splats - but it opens the way to 100, + // which removes 10 for 80 (0.125). Pricing it locally would make the node look worthless. + // The middle level still has to survive: it is a real improvement, and both streaming and + // underfill step through it. const octree = makeOctree([100, 90, 20], [0, 8, 10], true); const table = new GSplatLodTable(octree, 0, 2); - expect(chainOf(table)).to.deep.equal([2, 0]); + expect(chainOf(table)).to.deep.equal([2, 1, 0]); const upgrades = upgradesOf(table); - expect(upgrades.length).to.equal(1); - expect(upgrades[0].cost).to.equal(80); + expect(upgrades.length).to.equal(2); + + expect(upgrades[0].cost).to.equal(70); expect(upgrades[0].ratio).to.be.closeTo(0.125, 1e-6); + + expect(upgrades[1].cost).to.equal(10); + expect(upgrades[1].ratio).to.be.closeTo(0.8, 1e-6); }); - it('keeps every level whose returns already fall towards the finest', function () { - // 50 is above the chord from 20 to 100, so it is a genuine stopping point and survives + it('prices a step by its own slope when nothing further beats it', function () { + // returns already fall towards the finest here, so each step is its own best deal const octree = makeOctree([100, 50, 20], [0, 1, 5], true); const table = new GSplatLodTable(octree, 0, 2); expect(chainOf(table)).to.deep.equal([2, 1, 0]); const ratios = upgradesOf(table).map(u => u.ratio); expect(ratios.length).to.equal(2); - for (let i = 1; i < ratios.length; i++) { - expect(ratios[i]).to.be.at.most(ratios[i - 1]); - } + expect(ratios[0]).to.be.closeTo(4 / 30, 1e-6); + expect(ratios[1]).to.be.closeTo(1 / 50, 1e-6); }); it('skips levels with no splats', function () { From da3f2a0ad1ff6820a0663f6e415bd050ab9c948b Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Thu, 27 Aug 2026 15:56:34 +0100 Subject: [PATCH 6/8] Give the lod-streaming example a far clip that suits arbitrary captures The example loads any capture through the url hash parameter and some span kilometres, where the default far clip of 1000 cuts the distant content. Because the far plane cuts on view-space depth, a distant node vanishes when looked at head-on and returns when it moves off to the side, so on a 26km capture the sky reads as patches popping around the horizon rather than as a clipped horizon. --- .../examples/gaussian-splatting/lod-streaming.example.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs b/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs index 28cf86dbeea..57491f222ab 100644 --- a/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs +++ b/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs @@ -274,6 +274,11 @@ const camera = new Entity('camera'); camera.addComponent('camera', { clearColor: new Color(1, 1, 1), fov: 75, + // Generous, because this example loads arbitrary captures via the `url` hash parameter and some + // span kilometres. The far plane cuts on view-space depth, so at the default 1000 a distant node + // vanishes when looked at head-on and returns when it moves off to the side - which reads as + // patches popping around the horizon rather than as a clipped horizon. + farClip: 100000, toneMapping: TONEMAP_LINEAR }); From 603598debc811498e4a21336f8b45638974d899f Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Fri, 28 Aug 2026 10:54:58 +0100 Subject: [PATCH 7/8] Address review: orthographic coverage, collision-free table keys Coverage used the perspective projected-radius formula under every projection, so an orthographic camera gave equal-size nodes less LOD budget the deeper they sat, and moving along the view axis reshuffled quality while no footprint changed. Branch on the camera's projection: orthographic coverage is the radius against the ortho window, depth-independent and mirroring Camera#getScreenSize, with the behind-camera penalty still applied so invisible content cannot win budget. FOV compensation is perspective-only. The LOD table key packed the range as rangeMin * 256 + rangeMax, which aliases pairs once rangeMax passes 256 - nothing bounds lodLevels or the configured range - silently handing an instance a table for the wrong range. Key by a string in both acquire and release. Also pin the allocator's boundary behaviour where a step's rank is justified by a run the remaining budget cannot finish. An affordability-aware drain that resolves those boundaries optimally was built and measured: ~0.2% aggregate residual improvement across four captures, for a per-pop chain walk and a relaxed early exit whose pass-over semantics measured equal-or-worse temporal churn. Traded away for the simple drain and the hard early exit; the accepted outcomes are pinned in tests with the rationale. --- .../gsplat-unified/gsplat-octree-instance.js | 55 +++++++---- src/scene/gsplat-unified/gsplat-octree.js | 9 +- .../gsplat-budget-balancer.test.mjs | 34 +++++++ .../gsplat-unified/gsplat-lod-table.test.mjs | 18 ++++ .../gsplat-octree-instance.test.mjs | 98 +++++++++++++++++++ 5 files changed, 193 insertions(+), 21 deletions(-) create mode 100644 test/scene/gsplat-unified/gsplat-octree-instance.test.mjs diff --git a/src/scene/gsplat-unified/gsplat-octree-instance.js b/src/scene/gsplat-unified/gsplat-octree-instance.js index 7131a309c02..3890f28457b 100644 --- a/src/scene/gsplat-unified/gsplat-octree-instance.js +++ b/src/scene/gsplat-unified/gsplat-octree-instance.js @@ -7,7 +7,7 @@ import { BoundingBox } from '../../core/shape/bounding-box.js'; import { Color } from '../../core/math/color.js'; import { GSplatPlacement } from './gsplat-placement.js'; import { GsplatAllocId } from './gsplat-alloc-id.js'; -import { GSPLAT_DEBUG_NODE_AABBS } from '../constants.js'; +import { GSPLAT_DEBUG_NODE_AABBS, PROJECTION_ORTHOGRAPHIC } from '../constants.js'; /** * @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js' @@ -487,9 +487,11 @@ class GSplatOctreeInstance { * Pass 1 of the LOD update process; results are stored in the nodeInfos array and consumed by * the budget allocator, which is what actually picks a LOD level. * - * Coverage is the square of the node's projected radius, with FOV compensation so it is - * comparable across cameras, and with the behind-camera penalty folded into the distance. It is - * the only route by which camera position influences LOD. + * Coverage is the square of the node's projected radius. Under a perspective camera that + * attenuates with distance, with FOV compensation so it is comparable across cameras; under an + * orthographic camera a node's footprint does not depend on depth, so coverage is the radius + * against the ortho window, mirroring Camera#getScreenSize. The behind-camera penalty applies + * in both. Coverage is the only route by which camera position influences LOD. * * @param {GraphNode} cameraNode - The camera node. * @param {import('./gsplat-params.js').GSplatParams} params - Global gsplat parameters. @@ -500,14 +502,21 @@ class GSplatOctreeInstance { // Uniform scale of the octree transform, for world-space distance conversion. const uniformScale = this.placement.node.getWorldTransform().getScale().x; - // Compute FOV compensation: use min(tanHalfV, tanHalfH) to handle ultra-wide and portrait const camera = cameraNode.camera; - let tanHalfVFov = Math.tan(camera.fov * 0.5 * math.DEG_TO_RAD); - if (camera.horizontalFov) { - tanHalfVFov /= camera.aspectRatio; + const ortho = camera.projection === PROJECTION_ORTHOGRAPHIC; + + // FOV compensation, perspective only: use min(tanHalfV, tanHalfH) to handle ultra-wide and + // portrait. An orthographic footprint depends on neither FOV nor distance. + let fovScale = 1; + if (!ortho) { + let tanHalfVFov = Math.tan(camera.fov * 0.5 * math.DEG_TO_RAD); + if (camera.horizontalFov) { + tanHalfVFov /= camera.aspectRatio; + } + const tanHalfHFov = tanHalfVFov * camera.aspectRatio; + fovScale = Math.min(tanHalfVFov, tanHalfHFov) / REF_TAN_HALF_FOV; } - const tanHalfHFov = tanHalfVFov * camera.aspectRatio; - const fovScale = Math.min(tanHalfVFov, tanHalfHFov) / REF_TAN_HALF_FOV; + const invOrthoHeight = ortho ? 1 / Math.max(camera.orthoHeight, 1e-12) : 0; // transform camera position to octree local space const worldCameraPosition = cameraNode.getPosition(); @@ -560,9 +569,9 @@ class GSplatOctreeInstance { const dz = qz - pz; const actualDistance = Math.sqrt(dx * dx + dy * dy + dz * dz); - // Apply angular-based multiplier for nodes behind the camera when enabled - let penalizedDistance = actualDistance; - + // Angular multiplier for nodes behind the camera when enabled - kept as a factor so the + // orthographic path, whose coverage does not go through distance, can still apply it. + let penaltyFactor = 1; if (lodBehindPenalty > 1 && actualDistance > 0.01) { // forward · (dx,dy,dz) / |d| — same as Vec3.dot(dir, forward) / distance without temporaries const dotOverDistance = (fwx * dx + fwy * dy + fwz * dz) / actualDistance; @@ -570,20 +579,30 @@ class GSplatOctreeInstance { // Only apply penalty when behind the camera (dot < 0) if (dotOverDistance < 0) { const t = -dotOverDistance; // 0 .. 1 for front -> directly behind - const factor = 1 + t * (lodBehindPenalty - 1); - penalizedDistance = actualDistance * factor; + penaltyFactor = 1 + t * (lodBehindPenalty - 1); } } - const fovAdjustedDistance = penalizedDistance * fovScale; + const fovAdjustedDistance = actualDistance * penaltyFactor * fovScale; nodeInfo.worldDistance = fovAdjustedDistance * uniformScale; // Squared projected radius. Floored just above zero so a degenerate node still has a // well-defined, lowest-possible priority rather than a value the allocator has to // special-case. const radius = nodes[nodeIndex].boundingSphere.w; - const projectedRadius = radius / Math.max(radius + fovAdjustedDistance, 1e-12); - nodeInfo.lodCoverage = Math.max(projectedRadius * projectedRadius, 1e-12); + let coverage; + if (ortho) { + // No distance attenuation: the footprint is the radius against the ortho window, + // clamped to a full-window 1 as the perspective ratio is bounded by 1. The behind + // penalty divides squared, matching how a penalized distance scales the far-field + // perspective coverage. + const projectedRadius = Math.min(radius * invOrthoHeight, 1); + coverage = (projectedRadius * projectedRadius) / (penaltyFactor * penaltyFactor); + } else { + const projectedRadius = radius / Math.max(radius + fovAdjustedDistance, 1e-12); + coverage = projectedRadius * projectedRadius; + } + nodeInfo.lodCoverage = Math.max(coverage, 1e-12); } } diff --git a/src/scene/gsplat-unified/gsplat-octree.js b/src/scene/gsplat-unified/gsplat-octree.js index 7c9f9294aaa..dfd382d2a18 100644 --- a/src/scene/gsplat-unified/gsplat-octree.js +++ b/src/scene/gsplat-unified/gsplat-octree.js @@ -59,7 +59,7 @@ class GSplatOctree { * zero, so nothing is retained for a range that has fallen out of use, and nothing still in use * can be evicted. * - * @type {Map} + * @type {Map} * @private */ _lodTables = new Map(); @@ -350,7 +350,10 @@ class GSplatOctree { * @returns {GSplatLodTable} The selection table, with its reference count incremented. */ acquireLodTable(rangeMin, rangeMax) { - const key = rangeMin * 256 + rangeMax; + // A string key rather than packed arithmetic: nothing bounds lodLevels or the configured + // range, and a packed key would alias pairs once rangeMax passes the pack base, silently + // handing an instance a table for the wrong range. + const key = `${rangeMin},${rangeMax}`; let table = this._lodTables.get(key); if (!table) { table = new GSplatLodTable(this, rangeMin, rangeMax); @@ -371,7 +374,7 @@ class GSplatOctree { if (!table) return; Debug.assert(table.refCount > 0, `GSplatOctree: releasing a LOD table for range [${table.rangeMin}, ${table.rangeMax}] that holds no references.`); if (--table.refCount <= 0) { - this._lodTables.delete(table.rangeMin * 256 + table.rangeMax); + this._lodTables.delete(`${table.rangeMin},${table.rangeMax}`); } } diff --git a/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs index ad70a4837ed..18780aa5a31 100644 --- a/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs +++ b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs @@ -186,6 +186,40 @@ describe('GSplatBudgetBalancer', function () { expect(lodsOf(inst)).to.deep.equal([1, 1]); }); + it('pins the boundary behaviour when a ranked run only partly fits', function () { + // Deliberate trade-off, not a target. Node 0's rank comes from the run to lod0 - 10 error + // for 80 splats - but at this budget only its 70-splat first step fits, removing 2 error + // where node 1's 40-splat step would have removed 4. An affordability-aware drain that + // resolves this boundary (and the one below) optimally was built and measured: ~0.2% + // better in aggregate across four captures, for a per-pop walk and a relaxed early exit - + // traded away for a simpler drain and the hard early exit that keeps selection stable as + // the camera moves. + const { inst, instances } = single([ + { lods: [{ count: 100, error: 0 }, { count: 90, error: 8 }, { count: 20, error: 10 }] }, + { lods: [{ count: 60, error: 0 }, { count: 60, error: 0 }, { count: 20, error: 4 }] } + ]); + + new GSplatBudgetBalancer().balance(instances, 110); + + expect(lodsOf(inst)).to.deep.equal([1, 2]); + expect(splatsOf(inst)).to.equal(110); + }); + + it('pins the hard stop when the top-ranked step does not fit at all', function () { + // Same nodes at budget 100: node 0 ranks first but its 70-splat first step exceeds the 60 + // spare, so the sweep stops - node 1's affordable step is deliberately left unbought rather + // than letting cheaper upgrades reshuffle the outcome as the camera moves. + const { inst, instances } = single([ + { lods: [{ count: 100, error: 0 }, { count: 90, error: 8 }, { count: 20, error: 10 }] }, + { lods: [{ count: 60, error: 0 }, { count: 60, error: 0 }, { count: 20, error: 4 }] } + ]); + + new GSplatBudgetBalancer().balance(instances, 100); + + expect(lodsOf(inst)).to.deep.equal([2, 2]); + expect(splatsOf(inst)).to.equal(40); + }); + it('buys a compound upgrade that beats a cheaper rival outright', function () { // Node 0's middle level sits below the chord, so its levels pool into one 80-splat step // worth 10 error (0.125/splat). Node 1 offers 4 error for 40 splats (0.1/splat). Treating diff --git a/test/scene/gsplat-unified/gsplat-lod-table.test.mjs b/test/scene/gsplat-unified/gsplat-lod-table.test.mjs index 552bfe43f1f..e7947cf8b30 100644 --- a/test/scene/gsplat-unified/gsplat-lod-table.test.mjs +++ b/test/scene/gsplat-unified/gsplat-lod-table.test.mjs @@ -133,6 +133,24 @@ describe('GSplatOctree LOD errors', function () { }); }); + it('keeps ranges distinct beyond any packing base', function () { + // nothing bounds lodLevels or the configured range, and a packed numeric key would alias + // pairs like [0, 300] and [1, 44] - handing an instance a table for the wrong range + const octree = makeOctree(Array.from({ length: 301 }, (_, i) => 301 - i), undefined, undefined); + const a = octree.acquireLodTable(0, 300); + const b = octree.acquireLodTable(1, 44); + + expect(b).to.not.equal(a); + expect(a.rangeMin).to.equal(0); + expect(a.rangeMax).to.equal(300); + expect(b.rangeMin).to.equal(1); + expect(b.rangeMax).to.equal(44); + + // and releasing one leaves the other untouched + octree.releaseLodTable(b); + expect(octree.acquireLodTable(0, 300)).to.equal(a); + }); + it('tolerates releasing null', function () { const octree = makeOctree([10, 5], [0, 1], true); expect(() => octree.releaseLodTable(null)).to.not.throw(); diff --git a/test/scene/gsplat-unified/gsplat-octree-instance.test.mjs b/test/scene/gsplat-unified/gsplat-octree-instance.test.mjs new file mode 100644 index 00000000000..c47ddd3089b --- /dev/null +++ b/test/scene/gsplat-unified/gsplat-octree-instance.test.mjs @@ -0,0 +1,98 @@ +import { expect } from 'chai'; + +import { PROJECTION_ORTHOGRAPHIC, PROJECTION_PERSPECTIVE } from '../../../src/scene/constants.js'; +import { GraphNode } from '../../../src/scene/graph-node.js'; +import { GSplatOctreeInstance } from '../../../src/scene/gsplat-unified/gsplat-octree-instance.js'; +import { GSplatOctree } from '../../../src/scene/gsplat-unified/gsplat-octree.js'; + +// An octree of unit leaves centred at the given positions, all the same size, so coverage +// differences can only come from the camera model. +const makeOctree = centers => new GSplatOctree('/scene/lod-meta.json', { + lodLevels: 1, + filenames: ['0/meta.json'], + tree: { + children: centers.map(([x, y, z]) => ({ + bound: { min: [x - 1, y - 1, z - 1], max: [x + 1, y + 1, z + 1] }, + lods: { 0: { file: 0, offset: 0, count: 10 } } + })) + } +}); + +// evaluateNodeCoverage reads only the octree, the placement's node transform and the nodeInfos +// array, so a focused test can supply exactly those rather than a fully constructed instance. +const makeInstance = (octree) => { + const instance = Object.create(GSplatOctreeInstance.prototype); + instance.octree = octree; + instance.placement = { node: new GraphNode() }; + instance.nodeInfos = octree.nodes.map(() => ({ lodCoverage: 0, worldDistance: 0 })); + return instance; +}; + +// A camera node at the origin looking down -z. Only the properties the coverage pass reads. +const makeCamera = (projection, orthoHeight = 5) => { + const node = new GraphNode(); + node.camera = { projection, fov: 45, horizontalFov: false, aspectRatio: 1, orthoHeight }; + return node; +}; + +describe('GSplatOctreeInstance#evaluateNodeCoverage', function () { + + it('attenuates perspective coverage with distance', function () { + const instance = makeInstance(makeOctree([[0, 0, -10], [0, 0, -1000]])); + + instance.evaluateNodeCoverage(makeCamera(PROJECTION_PERSPECTIVE), { lodBehindPenalty: 1 }); + + const [near, far] = instance.nodeInfos; + expect(near.lodCoverage).to.be.above(far.lodCoverage * 100); + }); + + it('gives equal-size nodes equal orthographic coverage regardless of depth', function () { + // An orthographic footprint does not depend on depth - two equal nodes fill the same screen + // area wherever they sit along the view axis, and must receive the same LOD budget. + const instance = makeInstance(makeOctree([[0, 0, -10], [0, 0, -1000]])); + + instance.evaluateNodeCoverage(makeCamera(PROJECTION_ORTHOGRAPHIC), { lodBehindPenalty: 1 }); + + const [near, far] = instance.nodeInfos; + expect(near.lodCoverage).to.be.above(1e-12); + expect(far.lodCoverage).to.be.closeTo(near.lodCoverage, near.lodCoverage * 1e-6); + }); + + it('keeps orthographic coverage constant as the camera moves along its view axis', function () { + const octree = makeOctree([[0, 0, -100]]); + const camera = makeCamera(PROJECTION_ORTHOGRAPHIC); + + const instance = makeInstance(octree); + instance.evaluateNodeCoverage(camera, { lodBehindPenalty: 1 }); + const before = instance.nodeInfos[0].lodCoverage; + + camera.setPosition(0, 0, 50); + instance.evaluateNodeCoverage(camera, { lodBehindPenalty: 1 }); + + expect(instance.nodeInfos[0].lodCoverage).to.be.closeTo(before, before * 1e-6); + }); + + it('sizes orthographic coverage by the ortho window', function () { + const octree = makeOctree([[0, 0, -100]]); + const instance = makeInstance(octree); + + instance.evaluateNodeCoverage(makeCamera(PROJECTION_ORTHOGRAPHIC, 5), { lodBehindPenalty: 1 }); + const zoomedOut = instance.nodeInfos[0].lodCoverage; + + instance.evaluateNodeCoverage(makeCamera(PROJECTION_ORTHOGRAPHIC, 50), { lodBehindPenalty: 1 }); + const zoomedFurtherOut = instance.nodeInfos[0].lodCoverage; + + expect(zoomedOut).to.be.above(zoomedFurtherOut * 50); + }); + + it('still penalises nodes behind an orthographic camera', function () { + // Behind-camera content is invisible under any projection, so it must not win budget just + // because orthographic coverage carries no distance term. + const instance = makeInstance(makeOctree([[0, 0, -10], [0, 0, 10]])); + + instance.evaluateNodeCoverage(makeCamera(PROJECTION_ORTHOGRAPHIC), { lodBehindPenalty: 3 }); + + const [front, behind] = instance.nodeInfos; + expect(behind.lodCoverage).to.be.below(front.lodCoverage); + }); +}); From 7ab4243d4c3754b55b118948bb402b61e72a2450 Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Fri, 28 Aug 2026 11:14:55 +0100 Subject: [PATCH 8/8] Convert the local node radius to world units for orthographic coverage Node radii are octree-local while orthoHeight is a world-space window, so the orthographic coverage ratio dropped the placement's scale - the same octree at different scales ranked identically under the shared budget. The perspective path is unaffected: its radius and distance are both local, so the scale cancels in the ratio. Fold the placement's uniform scale into the precomputed inverse window height, outside the per-node loop, and pin it with a non-identity-scale regression test. --- .../gsplat-unified/gsplat-octree-instance.js | 5 ++++- .../gsplat-octree-instance.test.mjs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/scene/gsplat-unified/gsplat-octree-instance.js b/src/scene/gsplat-unified/gsplat-octree-instance.js index 3890f28457b..cd0c530512d 100644 --- a/src/scene/gsplat-unified/gsplat-octree-instance.js +++ b/src/scene/gsplat-unified/gsplat-octree-instance.js @@ -516,7 +516,10 @@ class GSplatOctreeInstance { const tanHalfHFov = tanHalfVFov * camera.aspectRatio; fovScale = Math.min(tanHalfVFov, tanHalfHFov) / REF_TAN_HALF_FOV; } - const invOrthoHeight = ortho ? 1 / Math.max(camera.orthoHeight, 1e-12) : 0; + // Node radii are octree-local while orthoHeight is a world-space window, so the placement's + // uniform scale is folded in here. The perspective path needs no such conversion - its + // radius and distance are both local, so the scale cancels in the ratio. + const invOrthoHeight = ortho ? uniformScale / Math.max(camera.orthoHeight, 1e-12) : 0; // transform camera position to octree local space const worldCameraPosition = cameraNode.getPosition(); diff --git a/test/scene/gsplat-unified/gsplat-octree-instance.test.mjs b/test/scene/gsplat-unified/gsplat-octree-instance.test.mjs index c47ddd3089b..3faf5c641e5 100644 --- a/test/scene/gsplat-unified/gsplat-octree-instance.test.mjs +++ b/test/scene/gsplat-unified/gsplat-octree-instance.test.mjs @@ -85,6 +85,23 @@ describe('GSplatOctreeInstance#evaluateNodeCoverage', function () { expect(zoomedOut).to.be.above(zoomedFurtherOut * 50); }); + it('scales orthographic coverage with the placement transform', function () { + // Node radii are octree-local, orthoHeight is world-space: a scaled placement doubles the + // projected radius, so coverage must quadruple - the same octree at different scales must + // not rank identically under the shared budget. + const octree = makeOctree([[0, 0, -100]]); + const camera = makeCamera(PROJECTION_ORTHOGRAPHIC, 1000); + + const instance = makeInstance(octree); + instance.evaluateNodeCoverage(camera, { lodBehindPenalty: 1 }); + const atUnitScale = instance.nodeInfos[0].lodCoverage; + + instance.placement.node.setLocalScale(2, 2, 2); + instance.evaluateNodeCoverage(camera, { lodBehindPenalty: 1 }); + + expect(instance.nodeInfos[0].lodCoverage).to.be.closeTo(atUnitScale * 4, atUnitScale * 1e-5); + }); + it('still penalises nodes behind an orthographic camera', function () { // Behind-camera content is invisible under any projection, so it must not win budget just // because orthographic coverage carries no distance term.