diff --git a/examples/src/examples/gaussian-splatting/lod-instances.controls.mjs b/examples/src/examples/gaussian-splatting/lod-instances.controls.mjs index 30ddff1b86d..183b0d8ba2b 100644 --- a/examples/src/examples/gaussian-splatting/lod-instances.controls.mjs +++ b/examples/src/examples/gaussian-splatting/lod-instances.controls.mjs @@ -3,20 +3,32 @@ * @returns {JSX.Element} The returned JSX Element. */ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => { - const { BindingTwoWay, LabelGroup, BooleanInput, Panel, Label } = ReactPCUI; + const { BindingTwoWay, LabelGroup, BooleanInput, Panel, SliderInput, Label } = ReactPCUI; return fragment( jsx( Panel, { headerText: 'Settings' }, jsx( LabelGroup, - { text: 'Colorize' }, + { text: 'Hue Animation' }, jsx(BooleanInput, { type: 'toggle', binding: new BindingTwoWay(), link: { observer, path: 'colorize' }, value: observer.get('colorize') }) + ), + jsx( + LabelGroup, + { text: 'Splat Budget' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'splatBudget' }, + min: 0, + max: 10, + precision: 1, + step: 0.1 + }) ) ), jsx( diff --git a/examples/src/examples/gaussian-splatting/lod-instances.example.mjs b/examples/src/examples/gaussian-splatting/lod-instances.example.mjs index 080e6a1c875..8444ebf3af0 100644 --- a/examples/src/examples/gaussian-splatting/lod-instances.example.mjs +++ b/examples/src/examples/gaussian-splatting/lod-instances.example.mjs @@ -210,19 +210,10 @@ assetListLoader.load(() => { // allow rendering with lower LOD quality when optimal is not yet loaded app.scene.gsplat.lodUnderfillLimit = 10; - // internal LOD preset based on platform (7 LOD levels: 0-6) - const isMobile = pc.platform.mobile; - app.scene.gsplat.splatBudget = isMobile ? 1000000 : 3000000; + data.set('splatBudget', pc.platform.mobile ? 1 : 3); // create grid of instances centered around origin on XZ plane const half = (GRID_SIZE - 1) * 0.5; - /** - * Compute per-LOD distances from a base value. - * @param {number} base - The base distance in world units. - * @returns {number[]} The array of distances for LODs 0..6. - */ - const lodBase = 1.2; - const lodDistances = [lodBase, lodBase * 2, lodBase * 3, lodBase * 4, lodBase * 5, lodBase * 6, lodBase * 7]; // Create a grid of playbot instances using unified gsplat component let componentIndex = 0; @@ -239,13 +230,21 @@ assetListLoader.load(() => { entity.setLocalEulerAngles(180, 0, 0); app.root.addChild(entity); const gs = /** @type {any} */ (entity.gsplat); - gs.lodDistances = lodDistances; + gs.lodBaseDistance = 1.2; gs.setParameter('uComponentId', componentIndex); gs.setWorkBufferModifier(workBufferModifier); componentIndex++; } } + const applySplatBudget = () => { + const millions = data.get('splatBudget'); + app.scene.gsplat.splatBudget = Math.round(millions * 1000000); + }; + + applySplatBudget(); + data.on('splatBudget:set', applySplatBudget); + // Create a camera with fly controls const camera = new pc.Entity('camera'); camera.addComponent('camera', { diff --git a/examples/src/examples/gaussian-splatting/lod-streaming-sh.controls.mjs b/examples/src/examples/gaussian-splatting/lod-streaming-sh.controls.mjs index 264f95fc521..8332fa76dd6 100644 --- a/examples/src/examples/gaussian-splatting/lod-streaming-sh.controls.mjs +++ b/examples/src/examples/gaussian-splatting/lod-streaming-sh.controls.mjs @@ -3,7 +3,7 @@ * @returns {JSX.Element} The returned JSX Element. */ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => { - const { BindingTwoWay, LabelGroup, BooleanInput, Panel, SelectInput, Label } = ReactPCUI; + const { BindingTwoWay, LabelGroup, BooleanInput, Panel, SelectInput, SliderInput, Label } = ReactPCUI; return fragment( jsx( Panel, @@ -43,6 +43,40 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => { { v: 'mobile', t: 'Mobile (2-5)' } ] }) + ), + jsx( + LabelGroup, + { text: 'LOD Base Dist' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'lodBaseDistance' }, + min: 1, + max: 50, + precision: 1 + }) + ), + jsx( + LabelGroup, + { text: 'LOD Multiplier' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'lodMultiplier' }, + min: 1.2, + max: 10, + precision: 1 + }) + ), + jsx( + LabelGroup, + { text: 'Splat Budget' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'splatBudget' }, + min: 0, + max: 10, + precision: 1, + step: 0.1 + }) ) ), jsx( diff --git a/examples/src/examples/gaussian-splatting/lod-streaming-sh.example.mjs b/examples/src/examples/gaussian-splatting/lod-streaming-sh.example.mjs index d3ea5bf8463..9f57acf607d 100644 --- a/examples/src/examples/gaussian-splatting/lod-streaming-sh.example.mjs +++ b/examples/src/examples/gaussian-splatting/lod-streaming-sh.example.mjs @@ -63,23 +63,23 @@ const config = { }; // LOD preset definitions with customizable distances -/** @type {Record} */ +/** @type {Record} */ const LOD_PRESETS = { 'desktop-max': { range: [0, 5], - lodDistances: [15, 30, 80, 250, 300] + lodBaseDistance: 15 }, 'desktop': { range: [0, 2], - lodDistances: [15, 30, 80, 250, 300] + lodBaseDistance: 15 }, 'mobile-max': { range: [1, 2], - lodDistances: [15, 30, 80, 250, 300] + lodBaseDistance: 15 }, 'mobile': { range: [2, 5], - lodDistances: [15, 30, 80, 250, 300] + lodBaseDistance: 15 } }; @@ -119,6 +119,7 @@ assetListLoader.load(() => { data.set('debugLod', false); data.set('colorizeSH', false); data.set('lodPreset', pc.platform.mobile ? 'mobile' : 'desktop'); + data.set('splatBudget', pc.platform.mobile ? 1 : 3); app.scene.gsplat.colorizeLod = !!data.get('debugLod'); app.scene.gsplat.colorizeColorUpdate = !!data.get('colorizeSH'); @@ -148,12 +149,31 @@ assetListLoader.load(() => { const presetData = LOD_PRESETS[preset] || LOD_PRESETS.desktop; app.scene.gsplat.lodRangeMin = presetData.range[0]; app.scene.gsplat.lodRangeMax = presetData.range[1]; - gs.lodDistances = presetData.lodDistances; + 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); + }; + + applySplatBudget(); + data.on('splatBudget:set', applySplatBudget); + // Create a camera with fly controls const camera = new pc.Entity('camera'); camera.addComponent('camera', { diff --git a/examples/src/examples/gaussian-splatting/lod-streaming.controls.mjs b/examples/src/examples/gaussian-splatting/lod-streaming.controls.mjs index 2b37df42087..f2d10ebd0d9 100644 --- a/examples/src/examples/gaussian-splatting/lod-streaming.controls.mjs +++ b/examples/src/examples/gaussian-splatting/lod-streaming.controls.mjs @@ -3,9 +3,34 @@ * @returns {JSX.Element} The returned JSX Element. */ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => { - const { BindingTwoWay, LabelGroup, BooleanInput, Panel, SelectInput, Label } = ReactPCUI; + const { BindingTwoWay, LabelGroup, BooleanInput, Panel, SelectInput, SliderInput, Label } = ReactPCUI; const isWebGPU = observer.get('isWebGPU'); return fragment( + jsx( + Panel, + { headerText: 'Camera' }, + jsx( + LabelGroup, + { text: 'FOV' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'cameraFov' }, + min: 10, + max: 120, + precision: 0 + }) + ), + jsx( + LabelGroup, + { text: 'High Res' }, + jsx(BooleanInput, { + type: 'toggle', + binding: new BindingTwoWay(), + link: { observer, path: 'highRes' }, + value: observer.get('highRes') || false + }) + ) + ), jsx( Panel, { headerText: 'Settings' }, @@ -29,16 +54,6 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => { value: observer.get('culling') || false }) ), - jsx( - LabelGroup, - { text: 'High Res' }, - jsx(BooleanInput, { - type: 'toggle', - binding: new BindingTwoWay(), - link: { observer, path: 'highRes' }, - value: observer.get('highRes') || false - }) - ), jsx( LabelGroup, { text: 'Compact' }, @@ -75,27 +90,38 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => { ] }) ), + jsx( + LabelGroup, + { text: 'LOD Base Dist' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'lodBaseDistance' }, + min: 1, + max: 50, + precision: 1 + }) + ), + jsx( + LabelGroup, + { text: 'LOD Multiplier' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'lodMultiplier' }, + min: 1.2, + max: 5, + precision: 1 + }) + ), jsx( LabelGroup, { text: 'Splat Budget' }, - jsx(SelectInput, { - type: 'string', + jsx(SliderInput, { binding: new BindingTwoWay(), link: { observer, path: 'splatBudget' }, - value: observer.get('splatBudget') || '4M', - options: [ - { v: 'none', t: 'No limit' }, - { v: '1M', t: '1M' }, - { v: '2M', t: '2M' }, - { v: '3M', t: '3M' }, - { v: '4M', t: '4M' }, - { v: '5M', t: '5M' }, - { v: '6M', t: '6M' }, - { v: '7M', t: '7M' }, - { v: '8M', t: '8M' }, - { v: '9M', t: '9M' }, - { v: '10M', t: '10M' } - ] + min: 0, + max: 10, + precision: 1, + step: 0.1 }) ) ), diff --git a/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs b/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs index 6d1203872e1..f6e5956b1fa 100644 --- a/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs +++ b/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs @@ -76,24 +76,28 @@ const config = { focusPoint: [12, 3, 0] }; -// LOD preset definitions with customizable distances -/** @type {Record} */ +// LOD preset definitions +/** @type {Record} */ const LOD_PRESETS = { 'desktop-max': { range: [0, 5], - lodDistances: [7, 20, 40, 80, 120, 150, 200] + lodBaseDistance: 7, + lodMultiplier: 3 }, 'desktop': { range: [1, 5], - lodDistances: [5, 10, 25, 50, 65, 90, 150] + lodBaseDistance: 5, + lodMultiplier: 4 }, 'mobile-max': { range: [2, 5], - lodDistances: [5, 7, 12, 25, 75, 120, 200] + lodBaseDistance: 5, + lodMultiplier: 2 }, 'mobile': { range: [3, 5], - lodDistances: [2, 4, 6, 10, 75, 120, 200] + lodBaseDistance: 2, + lodMultiplier: 2 } }; @@ -150,7 +154,7 @@ assetListLoader.load(() => { data.set('compact', true); data.set('debugLod', false); data.set('lodPreset', pc.platform.mobile ? 'mobile' : 'desktop'); - data.set('splatBudget', pc.platform.mobile ? '1M' : '4M'); + data.set('splatBudget', pc.platform.mobile ? 1 : 4); const entity = new pc.Entity(config.name || 'gsplat'); entity.addComponent('gsplat', { @@ -169,7 +173,10 @@ assetListLoader.load(() => { const presetData = LOD_PRESETS[preset] || LOD_PRESETS.desktop; app.scene.gsplat.lodRangeMin = presetData.range[0]; app.scene.gsplat.lodRangeMax = presetData.range[1]; - gs.lodDistances = presetData.lodDistances; + gs.lodBaseDistance = presetData.lodBaseDistance; + gs.lodMultiplier = presetData.lodMultiplier; + data.set('lodBaseDistance', presetData.lodBaseDistance); + data.set('lodMultiplier', presetData.lodMultiplier); }; applyPreset(); @@ -194,23 +201,16 @@ assetListLoader.load(() => { data.on('lodPreset:set', applyPreset); + data.on('lodBaseDistance:set', () => { + gs.lodBaseDistance = data.get('lodBaseDistance'); + }); + data.on('lodMultiplier:set', () => { + gs.lodMultiplier = data.get('lodMultiplier'); + }); + const applySplatBudget = () => { - const preset = data.get('splatBudget'); - const budgetMap = { - 'none': 0, - '1M': 1000000, - '2M': 2000000, - '3M': 3000000, - '4M': 4000000, - '5M': 5000000, - '6M': 6000000, - '7M': 7000000, - '8M': 8000000, - '9M': 9000000, - '10M': 10000000 - }; - // Global splat budget applies to all GSplats in the scene - app.scene.gsplat.splatBudget = budgetMap[preset] || 0; + const millions = data.get('splatBudget'); + app.scene.gsplat.splatBudget = Math.round(millions * 1000000); }; applySplatBudget(); @@ -233,6 +233,10 @@ assetListLoader.load(() => { app.root.addChild(camera); + data.on('cameraFov:set', () => { + camera.camera.fov = data.get('cameraFov'); + }); + // Add the GsplatRevealRadial script to the gsplat entity entity.addComponent('script'); const revealScript = entity.script?.create(GsplatRevealRadial); @@ -251,8 +255,8 @@ assetListLoader.load(() => { sceneSize: 500, moveSpeed: /** @type {number} */ (config.moveSpeed), moveFastSpeed: /** @type {number} */ (config.moveFastSpeed), - enableOrbit: config.enableOrbit ?? false, - enablePan: config.enablePan ?? false, + enableOrbit: false, + enablePan: false, focusPoint: focusPoint }); diff --git a/examples/src/examples/gaussian-splatting/viewer.example.mjs b/examples/src/examples/gaussian-splatting/viewer.example.mjs index 05ae2741dd5..893a9d1589d 100644 --- a/examples/src/examples/gaussian-splatting/viewer.example.mjs +++ b/examples/src/examples/gaussian-splatting/viewer.example.mjs @@ -89,6 +89,8 @@ assetListLoader.load(() => { let splatEntity = null; + app.scene.gsplat.lodBehindPenalty = 3; + /** * Calculate the bounding box of an entity. * diff --git a/examples/src/examples/gaussian-splatting/world.controls.mjs b/examples/src/examples/gaussian-splatting/world.controls.mjs index 485aa2148a6..4d2755c180a 100644 --- a/examples/src/examples/gaussian-splatting/world.controls.mjs +++ b/examples/src/examples/gaussian-splatting/world.controls.mjs @@ -3,8 +3,22 @@ * @returns {JSX.Element} The returned JSX Element. */ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => { - const { BindingTwoWay, LabelGroup, BooleanInput, Panel, SelectInput, Label } = ReactPCUI; + const { BindingTwoWay, LabelGroup, BooleanInput, Panel, SliderInput, Label } = ReactPCUI; return fragment( + jsx( + Panel, + { headerText: 'Camera' }, + jsx( + LabelGroup, + { text: 'Orbit' }, + jsx(BooleanInput, { + type: 'toggle', + binding: new BindingTwoWay(), + link: { observer, path: 'orbitCamera' }, + value: observer.get('orbitCamera') || false + }) + ) + ), jsx( Panel, { headerText: 'Settings' }, @@ -21,19 +35,35 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => { jsx( LabelGroup, { text: 'Splat Budget' }, - jsx(SelectInput, { - type: 'string', + jsx(SliderInput, { binding: new BindingTwoWay(), link: { observer, path: 'splatBudget' }, - value: observer.get('splatBudget') || '4M', - options: [ - { v: 'none', t: 'No limit' }, - { v: '1M', t: '1M' }, - { v: '2M', t: '2M' }, - { v: '3M', t: '3M' }, - { v: '4M', t: '4M' }, - { v: '6M', t: '6M' } - ] + min: 0, + max: 10, + precision: 1, + step: 0.1 + }) + ), + jsx( + LabelGroup, + { text: 'LOD Base Dist' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'lodBaseDistance' }, + min: 1, + max: 50, + precision: 1 + }) + ), + jsx( + LabelGroup, + { text: 'LOD Multiplier' }, + jsx(SliderInput, { + binding: new BindingTwoWay(), + link: { observer, path: 'lodMultiplier' }, + min: 1.2, + max: 10, + precision: 1 }) ) ), diff --git a/examples/src/examples/gaussian-splatting/world.example.mjs b/examples/src/examples/gaussian-splatting/world.example.mjs index cfae436b176..a1f100c984f 100644 --- a/examples/src/examples/gaussian-splatting/world.example.mjs +++ b/examples/src/examples/gaussian-splatting/world.example.mjs @@ -60,15 +60,15 @@ const config = { }; // LOD preset definitions -/** @type {Record} */ +/** @type {Record} */ const LOD_PRESETS = { 'desktop': { range: [0, 2], - lodDistances: [15, 30, 80, 250, 300] + lodBaseDistance: 15 }, 'mobile': { - range: [2, 5], - lodDistances: [15, 30, 80, 250, 300] + range: [1, 5], + lodBaseDistance: 15 } }; @@ -115,7 +115,7 @@ assetListLoader.load(() => { // initialize UI settings data.set('debugLod', false); - data.set('splatBudget', pc.platform.mobile ? '1M' : '4M'); + data.set('splatBudget', pc.platform.mobile ? 1 : 4); app.scene.gsplat.colorizeLod = !!data.get('debugLod'); @@ -124,17 +124,8 @@ assetListLoader.load(() => { }); const applySplatBudget = () => { - const preset = data.get('splatBudget'); - const budgetMap = { - 'none': 0, - '1M': 1000000, - '2M': 2000000, - '3M': 3000000, - '4M': 4000000, - '6M': 6000000 - }; - // Global splat budget applies to all GSplats in the scene - app.scene.gsplat.splatBudget = budgetMap[preset] || 0; + const millions = data.get('splatBudget'); + app.scene.gsplat.splatBudget = Math.round(millions * 1000000); }; applySplatBudget(); @@ -161,7 +152,18 @@ assetListLoader.load(() => { // Apply LOD distances to skatepark const gs = /** @type {any} */ (skatepark.gsplat); - gs.lodDistances = presetData.lodDistances; + 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 }; @@ -218,11 +220,22 @@ assetListLoader.load(() => { sceneSize: 500, moveSpeed: config.moveSpeed, moveFastSpeed: config.moveFastSpeed, - enableOrbit: config.enableOrbit, - enablePan: config.enablePan, + enableOrbit: false, + enablePan: false, focusPoint: focusPoint }); + data.set('orbitCamera', false); + data.on('orbitCamera:set', () => { + const orbit = !!data.get('orbitCamera'); + cc.enableOrbit = orbit; + cc.enablePan = orbit; + cc.enableFly = !orbit; + if (orbit) { + cc.focusPoint = new pc.Vec3(worldCenter.x, worldCenter.y, worldCenter.z); + } + }); + // Orbit parameters const logo1Radius = 3; const logo1Speed = 0.6; diff --git a/scripts/esm/gsplat/streamed-gsplat.mjs b/scripts/esm/gsplat/streamed-gsplat.mjs index bdad4698094..95e1a6576e6 100644 --- a/scripts/esm/gsplat/streamed-gsplat.mjs +++ b/scripts/esm/gsplat/streamed-gsplat.mjs @@ -17,27 +17,51 @@ class StreamedGsplat extends Script { /** * @attribute - * @type {number[]} + * @type {number} */ - ultraLodDistances = [5, 20, 35, 50, 65, 90, 150]; + ultraLodBaseDistance = 7; /** * @attribute - * @type {number[]} + * @type {number} */ - highLodDistances = [5, 20, 35, 50, 65, 90, 150]; + ultraLodMultiplier = 3; /** * @attribute - * @type {number[]} + * @type {number} */ - mediumLodDistances = [5, 7, 12, 25, 75, 120, 200]; + highLodBaseDistance = 5; /** * @attribute - * @type {number[]} + * @type {number} + */ + highLodMultiplier = 3; + + /** + * @attribute + * @type {number} + */ + mediumLodBaseDistance = 5; + + /** + * @attribute + * @type {number} + */ + mediumLodMultiplier = 2; + + /** + * @attribute + * @type {number} + */ + lowLodBaseDistance = 5; + + /** + * @attribute + * @type {number} */ - lowLodDistances = [5, 7, 12, 25, 75, 120, 200]; + lowLodMultiplier = 2; /** * @attribute @@ -112,7 +136,8 @@ class StreamedGsplat extends Script { // Add component directly to this entity this.entity.addComponent('gsplat', { unified: true, - lodDistances: this._getCurrentLodDistances(), + lodBaseDistance: this._getCurrentLodBaseDistance(), + lodMultiplier: this._getCurrentLodMultiplier(), asset: a }); @@ -145,7 +170,8 @@ class StreamedGsplat extends Script { // Add the component while entity is disabled child.addComponent('gsplat', { unified: true, - lodDistances: this._getCurrentLodDistances(), + lodBaseDistance: this._getCurrentLodBaseDistance(), + lodMultiplier: this._getCurrentLodMultiplier(), asset: a }); @@ -159,25 +185,34 @@ class StreamedGsplat extends Script { }); } - _getCurrentLodDistances() { - let distances; + _getCurrentLodBaseDistance() { switch (this._currentPreset) { case 'ultra': - distances = this.ultraLodDistances; - break; + return this.ultraLodBaseDistance; case 'high': - distances = this.highLodDistances; - break; + return this.highLodBaseDistance; case 'medium': - distances = this.mediumLodDistances; - break; + return this.mediumLodBaseDistance; case 'low': - distances = this.lowLodDistances; - break; + return this.lowLodBaseDistance; default: - distances = [5, 20, 35, 50, 65, 90, 150]; + return 5; + } + } + + _getCurrentLodMultiplier() { + switch (this._currentPreset) { + case 'ultra': + return this.ultraLodMultiplier; + case 'high': + return this.highLodMultiplier; + case 'medium': + return this.mediumLodMultiplier; + case 'low': + return this.lowLodMultiplier; + default: + return 3; } - return distances && distances.length > 0 ? distances : [5, 20, 35, 50, 65, 90, 150]; } _getCurrentLodRange() { @@ -209,11 +244,10 @@ class StreamedGsplat extends Script { app.scene.gsplat.lodRangeMin = range[0]; app.scene.gsplat.lodRangeMax = range[1]; - const lodDistances = this._getCurrentLodDistances(); - // Apply to main streaming asset only (environment doesn't support these settings) if (this.entity.gsplat) { - this.entity.gsplat.lodDistances = lodDistances; + this.entity.gsplat.lodBaseDistance = this._getCurrentLodBaseDistance(); + this.entity.gsplat.lodMultiplier = this._getCurrentLodMultiplier(); } } diff --git a/src/framework/components/gsplat/component.js b/src/framework/components/gsplat/component.js index 5be4f7ae8f6..6c3888eae83 100644 --- a/src/framework/components/gsplat/component.js +++ b/src/framework/components/gsplat/component.js @@ -112,12 +112,20 @@ class GSplatComponent extends Component { _highQualitySH = true; /** - * LOD distance thresholds, stored as a copy. + * Base distance for the first LOD transition (LOD 0 to LOD 1). * - * @type {number[]|null} + * @type {number} + * @private + */ + _lodBaseDistance = 5; + + /** + * Geometric multiplier between successive LOD distance thresholds. + * + * @type {number} * @private */ - _lodDistances = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60]; + _lodMultiplier = 3; /** * @type {BoundingBox|null} @@ -424,25 +432,75 @@ class GSplatComponent extends Component { } /** - * Sets LOD distance thresholds used by octree-based gsplat rendering. The provided array - * is copied. + * 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[]|null} + * @type {number} */ - set lodDistances(value) { - this._lodDistances = Array.isArray(value) ? value.slice() : null; + set lodBaseDistance(value) { + this._lodBaseDistance = Math.max(0.1, value); if (this._placement) { - this._placement.lodDistances = this._lodDistances; + this._placement.lodBaseDistance = this._lodBaseDistance; } } /** - * Gets a copy of LOD distance thresholds previously set, or null when not set. + * Gets the base distance for the first LOD transition. * + * @type {number} + */ + get lodBaseDistance() { + return this._lodBaseDistance; + } + + /** + * 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} + */ + set lodMultiplier(value) { + this._lodMultiplier = Math.max(1.2, value); + if (this._placement) { + this._placement.lodMultiplier = this._lodMultiplier; + } + } + + /** + * Gets the geometric multiplier between successive LOD distance thresholds. + * + * @type {number} + */ + get lodMultiplier() { + return this._lodMultiplier; + } + + /** + * @deprecated Use {@link lodBaseDistance} and {@link lodMultiplier} instead. * @type {number[]|null} */ + 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; + } + } + + /** + * @deprecated Use {@link lodBaseDistance} and {@link lodMultiplier} instead. + * @type {number[]} + */ get lodDistances() { - return this._lodDistances ? this._lodDistances.slice() : null; + Debug.removed('GSplatComponent#lodDistances is removed. Use lodBaseDistance and lodMultiplier instead.'); + return []; } /** @@ -935,7 +993,8 @@ class GSplatComponent extends Component { this._placement = null; this._placement = new GSplatPlacement(resource, this.entity, 0, this._parameters, null, this._id); - this._placement.lodDistances = this._lodDistances; + this._placement.lodBaseDistance = this._lodBaseDistance; + this._placement.lodMultiplier = this._lodMultiplier; this._placement.workBufferUpdate = this._workBufferUpdate; this._placement.workBufferModifier = this._workBufferModifier; diff --git a/src/framework/components/gsplat/system.js b/src/framework/components/gsplat/system.js index 4e3937934d6..307e7ad7401 100644 --- a/src/framework/components/gsplat/system.js +++ b/src/framework/components/gsplat/system.js @@ -32,7 +32,8 @@ const _schema = [ // order matters here const _properties = [ 'unified', - 'lodDistances', + 'lodBaseDistance', + 'lodMultiplier', 'castShadows', 'material', 'highQualitySH', diff --git a/src/scene/gsplat-unified/gsplat-budget-balancer.js b/src/scene/gsplat-unified/gsplat-budget-balancer.js index bfc1ca927a8..a2d96d3680b 100644 --- a/src/scene/gsplat-unified/gsplat-budget-balancer.js +++ b/src/scene/gsplat-unified/gsplat-budget-balancer.js @@ -102,13 +102,14 @@ class GSplatBudgetBalancer { const isOverBudget = currentSplats > budget; // Multiple passes: adjust by one LOD level per pass until budget is reached - while (isOverBudget ? currentSplats > budget : currentSplats < budget) { + 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 && currentSplats > budget; b--) { + 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]; @@ -118,14 +119,17 @@ class GSplatBudgetBalancer { currentSplats -= lods[optimalLod].count - lods[optimalLod + 1].count; nodeInfo.optimalLod = optimalLod + 1; modified = true; - if (currentSplats <= budget) break; + 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 && currentSplats < budget; b++) { + 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]; @@ -137,7 +141,13 @@ class GSplatBudgetBalancer { nodeInfo.optimalLod = optimalLod - 1; currentSplats += splatsAdded; modified = true; - if (currentSplats >= budget) break; + if (currentSplats >= budget) { + done = true; + break; + } + } else { + done = true; + break; } } } diff --git a/src/scene/gsplat-unified/gsplat-manager.js b/src/scene/gsplat-unified/gsplat-manager.js index 938a15bc306..ec067735bd9 100644 --- a/src/scene/gsplat-unified/gsplat-manager.js +++ b/src/scene/gsplat-unified/gsplat-manager.js @@ -271,6 +271,9 @@ class GSplatManager { /** @type {Vec3} */ lastLodCameraFwd = new Vec3(Infinity, Infinity, Infinity); + /** @type {number} */ + lastLodCameraFov = -1; + /** @type {Vec3} */ lastSortCameraPos = new Vec3(Infinity, Infinity, Infinity); @@ -294,6 +297,18 @@ class GSplatManager { */ _budgetBalancer = new GSplatBudgetBalancer(); + /** + * Dynamic scale factor applied to LOD parameters during budget enforcement. Shifts all + * LOD boundaries uniformly to bring the initial estimate closer to the budget target, + * reducing balancer work. Applied directly to lodBaseDistance and gently to lodMultiplier. + * Values > 1 push boundaries outward (more splats), values < 1 pull them inward + * (fewer splats). + * + * @type {number} + * @private + */ + _budgetScale = 1.0; + /** * Persistent block allocator for work buffer pixel allocations. Grows on demand. * @@ -1025,7 +1040,12 @@ class GSplatManager { } } - return cameraMoved || cameraRotated; + // FOV change check (trigger when FOV differs by more than ~2%) + const currentFov = this.cameraNode.camera.fov; + const fovChanged = this.lastLodCameraFov < 0 || + Math.abs(currentFov - this.lastLodCameraFov) > this.lastLodCameraFov * 0.02; + + return cameraMoved || cameraRotated || fovChanged; } /** @@ -1175,7 +1195,7 @@ class GSplatManager { if (dist > maxDist) maxDist = dist; } - return Math.max(maxDist, 1); // Avoid division by zero + return Math.max(maxDist, 1); } /** @@ -1211,8 +1231,9 @@ class GSplatManager { const globalMaxDistance = this.computeGlobalMaxDistance(); // Phase 2: Evaluate optimal LODs for all octrees and calculate padding for active placements + let totalOptimalSplats = 0; for (const [, inst] of this.octreeInstances) { - inst.evaluateOptimalLods(this.cameraNode, this.scene.gsplat); + totalOptimalSplats += inst.evaluateOptimalLods(this.cameraNode, this.scene.gsplat, this._budgetScale); for (const placement of inst.activePlacements) { const resource = /** @type {GSplatResourceBase} */ (placement.resource); const numSplats = resource?.numSplats ?? 0; @@ -1225,6 +1246,22 @@ class GSplatManager { // content may change after LOD evaluation applies changes const adjustedBudget = Math.max(1, octreeBudget - paddingEstimate); + // Adapt _budgetScale to bring LOD estimates closer to budget by uniformly shifting + // all LOD boundaries. Larger base distance → more nodes at LOD 0 → more splats, so: + // under budget (ratio < 1) → increase scale, over budget (ratio > 1) → decrease scale. + // The scale intentionally targets ~60-140% of budget (wide dead zone), leaving the + // balancer to handle the remaining gap with per-node adjustments. + 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 this._budgetBalancer.balance(this.octreeInstances, adjustedBudget, globalMaxDistance); @@ -1384,8 +1421,10 @@ class GSplatManager { } // update last camera data when LOD was evaluated - this.lastLodCameraPos.copy(this.cameraNode.getPosition()); - this.lastLodCameraFwd.copy(this.cameraNode.forward); + const cameraNode = this.cameraNode; + this.lastLodCameraPos.copy(cameraNode.getPosition()); + this.lastLodCameraFwd.copy(cameraNode.forward); + this.lastLodCameraFov = cameraNode.camera.fov; const budget = this.scene.gsplat.splatBudget; @@ -1394,6 +1433,7 @@ class GSplatManager { this._enforceBudget(budget); } else { // Budget disabled - use LOD distances only, no budget adjustments + this._budgetScale = 1.0; for (const [, inst] of this.octreeInstances) { inst.updateLod(this.cameraNode, this.scene.gsplat); } diff --git a/src/scene/gsplat-unified/gsplat-octree-instance.js b/src/scene/gsplat-unified/gsplat-octree-instance.js index 57a6c175ebe..7219ea2ddbd 100644 --- a/src/scene/gsplat-unified/gsplat-octree-instance.js +++ b/src/scene/gsplat-unified/gsplat-octree-instance.js @@ -1,4 +1,5 @@ import { Debug } from '../../core/debug.js'; +import { math } from '../../core/math/math.js'; import { Mat4 } from '../../core/math/mat4.js'; import { Vec2 } from '../../core/math/vec2.js'; import { Vec3 } from '../../core/math/vec3.js'; @@ -22,6 +23,9 @@ const _dirToNode = new Vec3(); const _tempCompletedUrls = []; const _tempDebugAabb = new BoundingBox(); +// tan(22.5deg) for the engine's default 45-degree vertical FOV, used as the FOV compensation reference +const REF_TAN_HALF_FOV = Math.tan(22.5 * math.DEG_TO_RAD); + // Color instances used by debug wireframe rendering for LOD visualization const _lodColors = [ new Color(1, 0, 0), @@ -62,12 +66,6 @@ class NodeInfo { */ inst = null; - /** - * Index in octree.nodes array. - * @type {number} - */ - nodeIndex = 0; - /** * Cached reference to this node's LOD array for fast budget balancing. * @type {Array|null} @@ -237,7 +235,7 @@ class GSplatOctreeInstance { for (let i = 0; i < octree.nodes.length; i++) { const nodeInfo = new NodeInfo(); nodeInfo.inst = this; - nodeInfo.nodeIndex = i; + this.nodeInfos[i] = nodeInfo; } @@ -361,53 +359,6 @@ class GSplatOctreeInstance { return toRelease; } - /** - * Calculate LOD index for a specific node using pre-calculated local camera position. - * @param {Vec3} localCameraPosition - The camera position in local space. - * @param {Vec3} localCameraForward - The camera forward direction in local space (normalized). - * @param {number} nodeIndex - The node index. - * @param {number} maxLod - The maximum LOD index (lodLevels - 1). - * @param {number[]} lodDistances - Array of distance thresholds per LOD. - * @param {number} lodBehindPenalty - Multiplier for behind-camera distance. 1 disables penalty. - * @returns {number} The LOD index for this node, or -1 if node should not be rendered. - */ - calculateNodeLod(localCameraPosition, localCameraForward, nodeIndex, maxLod, lodDistances, lodBehindPenalty) { - const node = this.octree.nodes[nodeIndex]; - - // Calculate the nearest point on the bounding box to the camera for accurate distance - node.bounds.closestPoint(localCameraPosition, _dirToNode); - - // Calculate direction from camera to nearest point on box - _dirToNode.sub(localCameraPosition); - let distance = _dirToNode.length(); - - // Apply angular-based multiplier for nodes behind the camera when enabled - if (lodBehindPenalty > 1 && distance > 0.01) { - - // dot using unnormalized direction to avoid extra normalize; divide by distance - const dotOverDistance = localCameraForward.dot(_dirToNode) / distance; - - // 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); - distance *= factor; - } - } - - // Find appropriate LOD based on distance and available LOD levels - for (let lod = 0; lod < maxLod; lod++) { - if (distance < lodDistances[lod]) { - return lod; - } - } - - // If distance is greater than all thresholds, use the highest available LOD - return maxLod; - - // return -1 for past far plane - } - /** * Selects desired LOD index for a node using the underfill strategy. When underfill is enabled, * it prefers already-loaded LODs within [optimalLodIndex .. optimalLodIndex + lodUnderfillLimit]. @@ -490,7 +441,7 @@ class GSplatOctreeInstance { updateLod(cameraNode, params) { const maxLod = this.octree.lodLevels - 1; - const lodDistances = this.placement.lodDistances || [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60]; + const { lodBaseDistance, lodMultiplier } = this.placement; // Clamp configured LOD range to valid bounds [0, maxLod] and ensure min <= max const { lodRangeMin, lodRangeMax } = params; @@ -499,7 +450,7 @@ class GSplatOctreeInstance { // Pass 1: Evaluate optimal LOD for each node (distance-based) const uniformScale = this.placement.node.getWorldTransform().getScale().x; - this.evaluateNodeLods(cameraNode, maxLod, lodDistances, rangeMin, rangeMax, params, uniformScale); + this.evaluateNodeLods(cameraNode, maxLod, lodBaseDistance, lodMultiplier, rangeMin, rangeMax, params, uniformScale); // Pass 2: Calculate desired LOD (underfill) and apply changes this.applyLodChanges(maxLod, params); @@ -509,9 +460,13 @@ class GSplatOctreeInstance { * 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. * + * Uses geometric LOD distances (lodBaseDistance * lodMultiplier^i) with FOV compensation + * so that LOD transitions are perceptually uniform under perspective projection. + * * @param {GraphNode} cameraNode - The camera node. * @param {number} maxLod - Maximum LOD index (lodLevels - 1). - * @param {number[]} lodDistances - Array of distance thresholds per LOD. + * @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. @@ -519,9 +474,21 @@ class GSplatOctreeInstance { * @returns {number} Total number of splats that would be used by optimal LODs. * @private */ - evaluateNodeLods(cameraNode, maxLod, lodDistances, rangeMin, rangeMax, params, uniformScale) { + evaluateNodeLods(cameraNode, maxLod, lodBaseDistance, lodMultiplier, rangeMin, rangeMax, params, uniformScale) { const { lodBehindPenalty } = params; + // 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 tanHalfHFov = tanHalfVFov * camera.aspectRatio; + const fovScale = Math.min(tanHalfVFov, tanHalfHFov) / REF_TAN_HALF_FOV; + + // Precompute inverse log of multiplier for O(1) LOD index computation + const invLogMult = 1.0 / Math.log(lodMultiplier); + // transform camera position to octree local space const worldCameraPosition = cameraNode.getPosition(); const octreeWorldTransform = this.placement.node.getWorldTransform(); @@ -560,25 +527,22 @@ class GSplatOctreeInstance { } } - // Find appropriate LOD based on penalized distance - let optimalLodIndex = maxLod; - for (let lod = 0; lod < maxLod; lod++) { - if (penalizedDistance < lodDistances[lod]) { - optimalLodIndex = lod; - break; - } + // Compute LOD index via logarithm with FOV compensation + const fovAdjustedDistance = penalizedDistance * fovScale; + let optimalLodIndex; + if (fovAdjustedDistance < lodBaseDistance) { + optimalLodIndex = 0; + } else { + const rawLod = 1 + Math.log(fovAdjustedDistance / lodBaseDistance) * invLogMult; + optimalLodIndex = Math.min(maxLod, rawLod | 0); } // Clamp to configured range if (optimalLodIndex < rangeMin) optimalLodIndex = rangeMin; if (optimalLodIndex > rangeMax) optimalLodIndex = rangeMax; - // Calculate world-space distance for budget enforcement bucketing - const worldDistance = actualDistance * uniformScale; - - // Store optimal LOD and world distance nodeInfo.optimalLod = optimalLodIndex; - nodeInfo.worldDistance = worldDistance; + nodeInfo.worldDistance = fovAdjustedDistance * uniformScale; // Count splats for this optimal LOD const lod = nodes[nodeIndex].lods[optimalLodIndex]; @@ -596,11 +560,14 @@ class GSplatOctreeInstance { * * @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. * @returns {number} Total optimal splat count. */ - evaluateOptimalLods(cameraNode, params) { + evaluateOptimalLods(cameraNode, params, budgetScale = 1) { const maxLod = this.octree.lodLevels - 1; - const lodDistances = this.placement.lodDistances || [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60]; + const { lodBaseDistance, lodMultiplier } = this.placement; const { lodRangeMin, lodRangeMax } = params; const rangeMin = Math.max(0, Math.min(lodRangeMin ?? 0, maxLod)); const rangeMax = Math.max(rangeMin, Math.min(lodRangeMax ?? maxLod, maxLod)); @@ -612,7 +579,10 @@ class GSplatOctreeInstance { // Get uniform scale for world-space conversion const uniformScale = this.placement.node.getWorldTransform().getScale().x; - return this.evaluateNodeLods(cameraNode, maxLod, lodDistances, + 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); } @@ -885,6 +855,12 @@ class GSplatOctreeInstance { */ update() { + // Re-evaluate LODs when lodBaseDistance or lodMultiplier changed on the component + if (this.placement.lodDirty) { + this.placement.lodDirty = false; + this.needsLodUpdate = true; + } + // handle pending loads if (this.pending.size) { for (const fileIndex of this.pending) { diff --git a/src/scene/gsplat-unified/gsplat-placement.js b/src/scene/gsplat-unified/gsplat-placement.js index 355019d48b5..97cd269d716 100644 --- a/src/scene/gsplat-unified/gsplat-placement.js +++ b/src/scene/gsplat-unified/gsplat-placement.js @@ -66,12 +66,49 @@ class GSplatPlacement { lodIndex = 0; /** - * LOD distance thresholds for octree-based gsplat. Only used when the - * resource is an octree resource; otherwise ignored and kept null. + * Base distance for the first LOD transition (LOD 0 to LOD 1). * - * @type {number[]|null} + * @type {number} + * @private + */ + _lodBaseDistance = 5; + + /** + * Geometric multiplier between successive LOD distance thresholds. + * Distance for LOD level i is: lodBaseDistance * lodMultiplier^i. + * + * @type {number} + * @private + */ + _lodMultiplier = 3; + + /** + * @type {number} */ - _lodDistances = null; + 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; + } /** * The axis-aligned bounding box for this placement, in local space. @@ -96,6 +133,13 @@ class GSplatPlacement { */ _streams = null; + /** + * Flag indicating LOD parameters have changed and LOD needs re-evaluation. + * + * @type {boolean} + */ + lodDirty = false; + /** * Flag indicating the splat needs to be re-rendered to work buffer. * @@ -234,35 +278,13 @@ class GSplatPlacement { } /** - * Sets LOD distance thresholds. Only applicable for octree resources. The provided array is - * copied. If the resource has an octree with N LOD levels, the array should contain N-1 - * elements. For non-octree resources, the value is ignored and kept null. - * - * @type {number[]|null} - */ - set lodDistances(distances) { - const isOctree = !!(this.resource && /** @type {any} */ (this.resource).octree); - if (isOctree) { - if (distances) { - const lodLevels = /** @type {any} */ (this.resource).octree?.lodLevels ?? 1; - Debug.assert(Array.isArray(distances), 'lodDistances must be an array'); - Debug.assert(distances.length >= lodLevels, 'lodDistances must have at least octree LOD levels - 1 entries, privided:', - distances.length, 'expected:', lodLevels); - - this._lodDistances = distances.slice(); - } else { - this._lodDistances = null; - } - } - } - - /** - * Gets a copy of LOD distance thresholds, or null when not set. + * Computes the LOD distance threshold for a given level using the geometric progression. * - * @type {number[]|null} + * @param {number} level - The LOD level index. + * @returns {number} The distance threshold for the given LOD level. */ - get lodDistances() { - return this._lodDistances ? this._lodDistances.slice() : null; + getLodDistance(level) { + return this.lodBaseDistance * Math.pow(this.lodMultiplier, level); } /**