Add a LOD mode and a per-splat detail falloff for streamed GSplats - #9267
Merged
Conversation
- 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.
Build size reportThis PR changes the size of the minified bundles.
|
Public API reportThis 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
commented
Aug 31, 2026
mvaligursky
left a comment
Contributor
Author
There was a problem hiding this comment.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #9233, adding user control over how the splat budget is spent.
scene.gsplat.lodMode— global selection modeGSPLAT_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.render.gsplatLodMode).GSplatComponent#lodFalloff— per-splat near/far tiltMath.powper node), so it is safe on mobile.Example
The
lod-streamingexample 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.