Skip to content

Add a LOD mode and a per-splat detail falloff for streamed GSplats - #9267

Merged
mvaligursky merged 3 commits into
mainfrom
mv-gsplat-lod-modes
Aug 31, 2026
Merged

Add a LOD mode and a per-splat detail falloff for streamed GSplats#9267
mvaligursky merged 3 commits into
mainfrom
mv-gsplat-lod-modes

Conversation

@mvaligursky

Copy link
Copy Markdown
Contributor

Follow-up to #9233, adding user control over how the splat budget is spent.

scene.gsplat.lodMode — global selection mode

  • GSPLAT_LODMODE_ERROR ('error', default): unchanged behaviour — the budget goes where it removes the most approximation error per splat, using the manifest's error tables when present and derived errors otherwise.
  • GSPLAT_LODMODE_DISTANCE ('distance'): ignores error metadata and guarantees a coarser-with-distance progression — detail steps down in clean concentric bands around the camera, with band edges adapting to the budget. Useful when a capture's quality makes its error tables unreliable (e.g. sky-heavy nodes that pin themselves to LOD 0). Internally this synthesizes per-level errors whose per-step ratio is a per-level constant, so node content cancels out of the ranking and the same single allocator path serves both modes.
  • In distance mode, coverage is pure inverse-square world distance under both projections: node size is factored out, so equal-distance nodes always pick equal levels regardless of the leaf sizes the octree cut produced, and orthographic cameras gain a distance ordering (their footprint carries no depth term to rank by).
  • The mode is part of the cached LOD table identity, and is also settable via scene settings (render.gsplatLodMode).

GSplatComponent#lodFalloff — per-splat near/far tilt

  • Range [0, 8], default 1. Higher values concentrate detail near the camera at the cost of the far field; 0 spreads the budget evenly with no view preference. In distance mode this controls the band spacing.
  • The tilt pivots around a mid-field reference, so it redistributes the splat's detail rather than changing its overall share of the budget — useful for balancing foreground splats against large environments.
  • Implemented as a multiply in the balancer's key space: the default path is bit-identical to before, and a non-default falloff costs ~0.03 ms at 39k nodes (vs ~0.9 ms for the naive Math.pow per node), so it is safe on mobile.

Example

The lod-streaming example gains a LOD Mode dropdown and a LOD Falloff slider.

Tests: distance-mode table pricing and cache identity, falloff behaviour against an exact greedy oracle (including bit-identical default), size-independence and ortho/scale behaviour of distance-mode coverage, and component default/init/clamp/clone.

- scene.gsplat.lodMode: 'error' (default) spends the splat budget by measured
  approximation error; 'distance' ignores error metadata and guarantees a
  coarser-with-distance progression in clean concentric bands, for captures
  whose error tables are unreliable. Also settable via scene settings
  (render.gsplatLodMode).
- GSplatComponent#lodFalloff (0..8, default 1): tilts this splat's share of the
  budget between the near and far field; 0 ignores the view entirely. Applied
  in key space so the default path is unchanged and non-default falloff adds
  no measurable cost.
- Distance mode ranks nodes by pure inverse-square world distance under both
  projections, so equal-distance nodes always pick equal levels whatever leaf
  sizes the octree cut produced, and orthographic cameras gain a distance
  ordering.
- lod-streaming example gains a mode dropdown and a falloff slider.
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

Build size report

This PR changes the size of the minified bundles.

Bundle Minified Gzip Brotli
playcanvas.min.js 2391.4 KB (+1.4 KB, +0.06%) 615.0 KB (+0.4 KB, +0.07%) 477.3 KB (+0.3 KB, +0.05%)
playcanvas.min.mjs 2388.8 KB (+1.4 KB, +0.06%) 613.7 KB (+0.4 KB, +0.06%) 476.7 KB (+0.3 KB, +0.07%)

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

Public API report

This PR changes the public API surface (+8 / −2), per the docs' rules (@ignore / @Private / undocumented are excluded).

Show API diff
-AppBase.applySceneSettings(settings: { physics: { gravity: number[] }; render: { ambientBake: boolean; ambientBakeNumSamples: number; ambientBakeOcclusionBrightness: number; ambientBakeOcclusionContrast: number; ambientBakeSpherePart: number; ambientLuminance: number; clusteredLightingEnabled: boolean; exposure: number; fog: string; fog_color: number[]; fog_density: number; fog_end: number; fog_start: number; gamma_correction: number; global_ambient: number[]; gsplatAlphaClip: number; gsplatAlphaClipForward: number; gsplatAntiAlias: boolean; gsplatColorUpdateAngle: number; gsplatCooldownTicks: number; gsplatDataFormat: string; gsplatEnableIds: boolean; gsplatFoveationCenter: number; gsplatFoveationStrength: number; gsplatLodBehindPenalty: number; gsplatLodUnderfillLimit: number; gsplatLodUpdateAngle: number; gsplatLodUpdateDistance: number; gsplatMinContribution: number; gsplatMinPixelSize: number; gsplatRadialSorting: boolean; gsplatSplatBudget: number; gsplatUseFog: boolean; gsplatUseTonemap: boolean; lightingAreaLightsEnabled: boolean; lightingCells: number[]; lightingCookieAtlasResolution: number; lightingCookiesEnabled: boolean; lightingMaxLights: number; lightingMaxLightsPerCell: number; lightingShadowAtlasResolution: number; lightingShadowsEnabled: boolean; lightingShadowType: number; lightmapFilterEnabled: boolean; lightmapFilterRange: number; lightmapFilterSmoothness: number; lightmapMaxResolution: number; lightmapMode: number; lightmapSizeMultiplier: number; skybox: number | null; skyboxIntensity: number; skyboxLuminance: number; skyboxMip: number; skyboxRotation: number[]; skyCenter: number[]; skyMeshPosition: number[]; skyMeshRotation: number[]; skyMeshScale: number[]; skyType: string; tonemapping: number } }): void
+AppBase.applySceneSettings(settings: { physics: { gravity: number[] }; render: { ambientBake: boolean; ambientBakeNumSamples: number; ambientBakeOcclusionBrightness: number; ambientBakeOcclusionContrast: number; ambientBakeSpherePart: number; ambientLuminance: number; clusteredLightingEnabled: boolean; exposure: number; fog: string; fog_color: number[]; fog_density: number; fog_end: number; fog_start: number; gamma_correction: number; global_ambient: number[]; gsplatAlphaClip: number; gsplatAlphaClipForward: number; gsplatAntiAlias: boolean; gsplatColorUpdateAngle: number; gsplatCooldownTicks: number; gsplatDataFormat: string; gsplatEnableIds: boolean; gsplatFoveationCenter: number; gsplatFoveationStrength: number; gsplatLodBehindPenalty: number; gsplatLodMode: string; gsplatLodUnderfillLimit: number; gsplatLodUpdateAngle: number; gsplatLodUpdateDistance: number; gsplatMinContribution: number; gsplatMinPixelSize: number; gsplatRadialSorting: boolean; gsplatSplatBudget: number; gsplatUseFog: boolean; gsplatUseTonemap: boolean; lightingAreaLightsEnabled: boolean; lightingCells: number[]; lightingCookieAtlasResolution: number; lightingCookiesEnabled: boolean; lightingMaxLights: number; lightingMaxLightsPerCell: number; lightingShadowAtlasResolution: number; lightingShadowsEnabled: boolean; lightingShadowType: number; lightmapFilterEnabled: boolean; lightmapFilterRange: number; lightmapFilterSmoothness: number; lightmapMaxResolution: number; lightmapMode: number; lightmapSizeMultiplier: number; skybox: number | null; skyboxIntensity: number; skyboxLuminance: number; skyboxMip: number; skyboxRotation: number[]; skyCenter: number[]; skyMeshPosition: number[]; skyMeshRotation: number[]; skyMeshScale: number[]; skyType: string; tonemapping: number } }): void
-Application.applySceneSettings(settings: { physics: { gravity: number[] }; render: { ambientBake: boolean; ambientBakeNumSamples: number; ambientBakeOcclusionBrightness: number; ambientBakeOcclusionContrast: number; ambientBakeSpherePart: number; ambientLuminance: number; clusteredLightingEnabled: boolean; exposure: number; fog: string; fog_color: number[]; fog_density: number; fog_end: number; fog_start: number; gamma_correction: number; global_ambient: number[]; gsplatAlphaClip: number; gsplatAlphaClipForward: number; gsplatAntiAlias: boolean; gsplatColorUpdateAngle: number; gsplatCooldownTicks: number; gsplatDataFormat: string; gsplatEnableIds: boolean; gsplatFoveationCenter: number; gsplatFoveationStrength: number; gsplatLodBehindPenalty: number; gsplatLodUnderfillLimit: number; gsplatLodUpdateAngle: number; gsplatLodUpdateDistance: number; gsplatMinContribution: number; gsplatMinPixelSize: number; gsplatRadialSorting: boolean; gsplatSplatBudget: number; gsplatUseFog: boolean; gsplatUseTonemap: boolean; lightingAreaLightsEnabled: boolean; lightingCells: number[]; lightingCookieAtlasResolution: number; lightingCookiesEnabled: boolean; lightingMaxLights: number; lightingMaxLightsPerCell: number; lightingShadowAtlasResolution: number; lightingShadowsEnabled: boolean; lightingShadowType: number; lightmapFilterEnabled: boolean; lightmapFilterRange: number; lightmapFilterSmoothness: number; lightmapMaxResolution: number; lightmapMode: number; lightmapSizeMultiplier: number; skybox: number | null; skyboxIntensity: number; skyboxLuminance: number; skyboxMip: number; skyboxRotation: number[]; skyCenter: number[]; skyMeshPosition: number[]; skyMeshRotation: number[]; skyMeshScale: number[]; skyType: string; tonemapping: number } }): void
+Application.applySceneSettings(settings: { physics: { gravity: number[] }; render: { ambientBake: boolean; ambientBakeNumSamples: number; ambientBakeOcclusionBrightness: number; ambientBakeOcclusionContrast: number; ambientBakeSpherePart: number; ambientLuminance: number; clusteredLightingEnabled: boolean; exposure: number; fog: string; fog_color: number[]; fog_density: number; fog_end: number; fog_start: number; gamma_correction: number; global_ambient: number[]; gsplatAlphaClip: number; gsplatAlphaClipForward: number; gsplatAntiAlias: boolean; gsplatColorUpdateAngle: number; gsplatCooldownTicks: number; gsplatDataFormat: string; gsplatEnableIds: boolean; gsplatFoveationCenter: number; gsplatFoveationStrength: number; gsplatLodBehindPenalty: number; gsplatLodMode: string; gsplatLodUnderfillLimit: number; gsplatLodUpdateAngle: number; gsplatLodUpdateDistance: number; gsplatMinContribution: number; gsplatMinPixelSize: number; gsplatRadialSorting: boolean; gsplatSplatBudget: number; gsplatUseFog: boolean; gsplatUseTonemap: boolean; lightingAreaLightsEnabled: boolean; lightingCells: number[]; lightingCookieAtlasResolution: number; lightingCookiesEnabled: boolean; lightingMaxLights: number; lightingMaxLightsPerCell: number; lightingShadowAtlasResolution: number; lightingShadowsEnabled: boolean; lightingShadowType: number; lightmapFilterEnabled: boolean; lightmapFilterRange: number; lightmapFilterSmoothness: number; lightmapMaxResolution: number; lightmapMode: number; lightmapSizeMultiplier: number; skybox: number | null; skyboxIntensity: number; skyboxLuminance: number; skyboxMip: number; skyboxRotation: number[]; skyCenter: number[]; skyMeshPosition: number[]; skyMeshRotation: number[]; skyMeshScale: number[]; skyType: string; tonemapping: number } }): void
+GSplatComponent.get lodFalloff(): number
+GSplatComponent.set lodFalloff(value: number)
+GSplatParams.get lodMode(): string
+GSplatParams.set lodMode(value: string)
+const GSPLAT_LODMODE_DISTANCE: "distance"
+const GSPLAT_LODMODE_ERROR: "error"

Informational only — this never fails the build.

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automated PR review by Codex (GPT-5).

I found three actionable issues, reported inline: one public TypeScript API regression and two mismatches between the documented LOD guarantees and the allocator behavior.

Validation performed:

  • npm run lint
  • focused GSplat component/allocator/table/coverage tests: 63 passing
  • full unit suite: 2,528 passing, 2 pending
  • direct allocator reproductions for equal-distance ties and cross-placement falloff allocation
  • all current PR checks are green

Comment thread src/scene/constants.js
Comment thread src/scene/gsplat-unified/gsplat-lod-table.js
Comment thread src/framework/components/gsplat/component.js Outdated
@mvaligursky
mvaligursky merged commit a939ca9 into main Aug 31, 2026
10 checks passed
@mvaligursky
mvaligursky deleted the mv-gsplat-lod-modes branch August 31, 2026 16:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant