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)
Summary
build-lod --max-sh=Npanics withindex out of boundswhenNis 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
.soginputs that bundle no spherical harmonics (meta.shnabsent ormeta.shn.bands = 0), which is common for generator-produced SOGs (e.g. World Labsspz_urls.full_resre-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.jsonhas noshnsection.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.rsdecode_v2,max_sh_degreeis derived frommeta.shn.bands; whenshnis missing it defaults to0, sosh1/sh2/sh3are initialized as emptyVecs (rust/spark-lib/src/sogs.rs#L189-L212).The CLI then calls
splats.set_max_sh_degree(max_sh)inrust/build-lod/src/main.rs#L161-L163with the requested CLI value (3).TsplatArray::set_max_sh_degree(rust/spark-lib/src/gsplat.rs#L256-L269) only truncatessh1/sh2/sh3when the new value is lower than the current one. It never grows them, but it does raiseself.max_sh_degree.Subsequently,
bhatt_lodwalks the tree and unconditionally accesses&self.sh1[index]wheneverself.max_sh_degree >= 1(rust/spark-lib/src/gsplat.rs#L410-L420). Withsh1.len() == 0andmax_sh_degree == 3, the first non-leaf merge panics.Suggested fix
Clamp
--max-shto the source's actual SH degree before applying it. Minimal change inrust/build-lod/src/main.rsaround 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_degreeclamp 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=0on SOG inputs known to lack SH, or probemeta.shn.bandsout of the SOG zip and clamp--max-shto it before invoking the binary.Environment
build-lodfrom the latestsparkjsdev/sparkmain.sogproduced by@playcanvas/splat-transformfrom a World Labs.spz(SH stripped during SOG conversion)g4ad.xlarge(AMD V520, Vulkan/RADV)