Skip to content

build-lod panics on SOG inputs when --max-sh exceeds bundled meta.shn.bands #352

@joaomelorodrigues

Description

@joaomelorodrigues

Summary

build-lod --max-sh=N panics with index out of bounds when N is greater than the SH bands the input file actually contains. The CLI flag behaves as both a lower and upper bound; based on its name it should be a cap only.

This consistently reproduces on .sog inputs that bundle no spherical harmonics (meta.shn absent or meta.shn.bands = 0), which is common for generator-produced SOGs (e.g. World Labs spz_urls.full_res re-encoded to SOG via @playcanvas/splat-transform).

Repro

```
build-lod --quality --rad-chunked --cluster-sh=5 --max-sh=3 input.sog
```

with a SOG whose meta.json has no shn section.

Observed

```
Detected file type: SOGS
Read: num_splats: 29427170 with sh_degree: 0
bhatt_lod::compute_lod_tree: initial_len=29427170
Sorted and prepared splats
Level: -8, step: 0.00390625, frontier: 1155 / 29427170, # active: 1155, # splats: 29427170
thread 'main' panicked at spark-lib/src/gsplat.rs:414:36:
index out of bounds: the len is 0 but the index is 1
```

Root cause

In rust/spark-lib/src/sogs.rs decode_v2, max_sh_degree is derived from meta.shn.bands; when shn is missing it defaults to 0, so sh1/sh2/sh3 are initialized as empty Vecs (rust/spark-lib/src/sogs.rs#L189-L212).

The CLI then calls splats.set_max_sh_degree(max_sh) in rust/build-lod/src/main.rs#L161-L163 with the requested CLI value (3). TsplatArray::set_max_sh_degree (rust/spark-lib/src/gsplat.rs#L256-L269) only truncates sh1/sh2/sh3 when the new value is lower than the current one. It never grows them, but it does raise self.max_sh_degree.

Subsequently, bhatt_lod walks the tree and unconditionally accesses &self.sh1[index] whenever self.max_sh_degree >= 1 (rust/spark-lib/src/gsplat.rs#L410-L420). With sh1.len() == 0 and max_sh_degree == 3, the first non-leaf merge panics.

Suggested fix

Clamp --max-sh to the source's actual SH degree before applying it. Minimal change in rust/build-lod/src/main.rs around L161:

```rust
if let Some(max_sh) = options.max_sh {
let input_sh = TsplatArray::max_sh_degree(&splats);
let clamped = max_sh.min(input_sh);
splats.set_max_sh_degree(clamped);
description.insert("max_sh_degree".to_string(), serde_json::Value::Number(clamped.into()));
}
```

Alternatively, make TsplatArray::set_max_sh_degree clamp internally so all callers (including the WASM/in-browser path) are protected:

```rust
fn set_max_sh_degree(&mut self, max_sh_degree: usize) {
assert!(max_sh_degree <= 3, "SH degrees must be between 0 and 3");
let current_capacity = if !self.sh3.is_empty() { 3 }
else if !self.sh2.is_empty() { 2 }
else if !self.sh1.is_empty() { 1 }
else { 0 };
self.max_sh_degree = max_sh_degree.min(current_capacity);
// truncation logic stays as-is
}
```

Workaround

Pass --max-sh=0 on SOG inputs known to lack SH, or probe meta.shn.bands out of the SOG zip and clamp --max-sh to it before invoking the binary.

Environment

  • build-lod from the latest sparkjsdev/spark main
  • Input: 29.4M-splat .sog produced by @playcanvas/splat-transform from a World Labs .spz (SH stripped during SOG conversion)
  • Host: Ubuntu 22.04 on AWS g4ad.xlarge (AMD V520, Vulkan/RADV)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions