Add per-leaf LOD approximation errors in lod-meta.json - #300
Merged
Conversation
Contributor
There was a problem hiding this comment.
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
errorstables (shape and numeric constraints) when reading viacollectFilesByLod, 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
marked this pull request as ready for review
August 10, 2026 10:22
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.
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.jsonnow carries anerrorstable, 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: truedeclares 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.chunkGaussiansandasset.chunkExtentrecord the partition parameters actually used, which the manifest previously did not preserve anywhere.No
versionbump: adding optional fields is backward compatible, and bumping would make every already-released splat-transform reject the file (read-lodrejects any version but 1) while buying nothing, since the engine never reads the manifest'sversion. 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
splatErrorreturn 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
filterNaNRowsexactly, including its two deliberate exceptions —scale_*may be-Infinityandopacitymay be+Infinity, both harmless here — so anything--filter-nankeeps 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.
collectFilesByLodvalidates the table when present: length equal tolodLevels, 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-nandeliberately keeps are accepted. A real three-level 32-leaf manifest at 3 SH bands was additionally loaded through the engine's ownGSplatOctreeand driven through the budget balancer at several budgets, confirming the error path is taken and the levels chosen track the budget.