Skip to content

Add per-leaf LOD approximation errors in lod-meta.json - #300

Merged
slimbuck merged 5 commits into
playcanvas:mainfrom
slimbuck:dec-error
Aug 10, 2026
Merged

Add per-leaf LOD approximation errors in lod-meta.json#300
slimbuck merged 5 commits into
playcanvas:mainfrom
slimbuck:dec-error

Conversation

@slimbuck

@slimbuck slimbuck commented Aug 10, 2026

Copy link
Copy Markdown
Member

Streamed SOG LOD selection is distance-based: a node picks its level from camera distance bands, with no measure of how much quality that level actually gives up. This is the producer half of an error-driven alternative — each leaf of lod-meta.json now carries an errors table, indexed by absolute LOD level and measured against the finest level present in that leaf.

The consumer half is playcanvas/engine#9157, which spends a scene-wide splat budget where it buys the most error reduction per splat. The two halves land together and neither does anything alone: the engine falls back to distance bands for any manifest without error tables, and the tables are inert until an engine reads them.

Behaviour change: the LOD writer now rejects input it previously accepted. Non-finite geometry or colour fails the write with a message naming --filter-nan, so any pipeline feeding dirty PLYs into a streamed SOG build will start erroring. That is deliberate rather than incidental — see the validation section below — but it will look like a regression the first time it fires, so it is worth knowing before rather than after.

The metric. A symmetric Chamfer-style match between the leaf's finest level and each coarser one, where a pair's cost is the closed-form relative field-L2 between the densities the two splats paint, plus the L2 over stored SH, plus a coverage term. Writing f(x) = alpha * exp(-0.5 (x-mu)^T sigma^-1 (x-mu)), every inner product is closed form, so ||f_i - f_j||^2 / (||f_i||^2 + ||f_j||^2) is exact: zero only for identical splats, monotone in their separation, bounded by 1 once they stop overlapping.

The decimator's edge cost is not reusable here — as a one-sample MC estimate of a KL it carries ~1.2 nats of noise, far more than the error a well-decimated leaf actually has, and it is blind to opacity. The coverage term exists because nearest-neighbour matching cannot see thinning: drop every second splat of an overlapping group and each survivor still has a near-identical partner while the alpha the group accumulates halves. Sky gaps are exactly that shape. The metric compares arbitrary input LODs, so it does not assume the levels came from our decimator.

Header additions. lodErrors: true declares that the error tables are present, so a consumer can pick its allocation strategy up front instead of searching the tree for the field. asset.chunkGaussians and asset.chunkExtent record the partition parameters actually used, which the manifest previously did not preserve anywhere.

No version bump: adding optional fields is backward compatible, and bumping would make every already-released splat-transform reject the file (read-lod rejects any version but 1) while buying nothing, since the engine never reads the manifest's version. A per-file boolean also expresses something a version cannot — that a manifest legitimately has no error tables.

Why invalid input is rejected rather than tolerated. Every non-finite value has a route to a silently wrong error rather than a visibly broken one, which is the worst possible failure for a table the engine trusts to rank quality. A NaN scale or opacity poisons that splat's footprint mass, which reaches the coverage term. A NaN colour makes splatError return NaN for every pair the splat takes part in, and the nearest-match search discards NaN candidates — so the more of a level is broken, the less error it reports. Two genuinely displaced levels with NaN colours report an error of exactly 0, and the engine then drops the finer level from its frontier permanently, at any budget.

So the writer validates once, at the boundary, and everything downstream assumes finite input rather than each stage carrying its own opinion about invalid data. Geometry is checked in the bounds pass and colour/SH in the per-leaf gather, both of which already read that data for every gaussian, so neither adds I/O. The rules mirror filterNaNRows exactly, including its two deliberate exceptions — scale_* may be -Infinity and opacity may be +Infinity, both harmless here — so anything --filter-nan keeps is accepted, and the fix for a rejected file is always that flag.

Cost. The error pass adds roughly 25% to a streamed SOG write. Sharing one KNN traversal across every target level is what keeps it affordable: each splat of the reference level is matched against every coarser level, so searching the levels separately would repeat the same tree descent once per level.

Reading. collectFilesByLod validates the table when present: length equal to lodLevels, every entry a finite non-negative number.

Testing. Unit tests cover the metric's behaviour (an exact match reports zero, a half-sigma displacement lands on the analytic 0.0606, an opacity drop at identical geometry is heavily penalised, thinning two coincident splats to one reports 0.5, stored SH contributes, the table stays monotone), the header contract, and the KNN pruning path when a level holds fewer splats than k. Six further tests pin the input contract: NaN in scale, opacity, position, rotation or colour and a zero-norm rotation are each rejected with a message naming the flag, while the non-finite values --filter-nan deliberately keeps are accepted. A real three-level 32-leaf manifest at 3 SH bands was additionally loaded through the engine's own GSplatOctree and driven through the budget balancer at several budgets, confirming the error path is taken and the levels chosen track the budget.

@slimbuck
slimbuck requested a lite review from Copilot August 10, 2026 09:31
@slimbuck slimbuck self-assigned this Aug 10, 2026
@slimbuck slimbuck added the enhancement New feature or request label Aug 10, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR extends streamed SOG LOD manifests (lod-meta.json) with per-leaf, per-absolute-LOD approximation error tables, enabling an error-driven LOD budget allocator on the engine side while remaining backward compatible for consumers that ignore the new fields.

Changes:

  • Add an errors: number[] table to each leaf node in the LOD metadata tree, plus header fields (lodErrors, asset.chunkGaussians, asset.chunkExtent) to declare/describe the presence and partition parameters.
  • Implement an analytic, symmetric Chamfer-style error metric (field-L2 + stored SH L2 + coverage term) and compute it during writeLodSource.
  • Validate errors tables (shape and numeric constraints) when reading via collectFilesByLod, and add unit tests covering metric behavior and KNN traversal pruning.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
test/write-lod.test.mjs Adds unit tests for manifest header additions, error-table behavior, NaN handling, monotonicity, SH contribution, and KNN pruning.
src/lib/writers/write-lod.ts Computes per-leaf LOD error tables, emits new manifest header fields, and adds shared-traversal KNN matching + analytic splat error metric.
src/lib/readers/read-lod.ts Validates node.errors tables when present (length = lodLevels, finite, non-negative).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@slimbuck
slimbuck marked this pull request as ready for review August 10, 2026 10:22
@slimbuck
slimbuck merged commit c073c6f into playcanvas:main Aug 10, 2026
3 checks passed
@slimbuck
slimbuck deleted the dec-error branch August 10, 2026 14:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants