Skip to content

Commit 0bfb6c8

Browse files
authored
Implement float32 sorting option (#129)
* Add commented benchmarkSort to compare JS vs Wasm sorting. * Add 32-bit float sort via SparkViewpooint.sort32, using 2-pass radix-65536 sort. Turn on sort32 by default for examples/editor. Implemented Rust sort and JS sort, updated benchmarking code to include float16 and float32 sort. * Add sort32 to docs/spark-viewpoint.md.
1 parent 2ab9ee1 commit 0bfb6c8

6 files changed

Lines changed: 433 additions & 53 deletions

File tree

docs/docs/spark-viewpoint.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const viewpoint = spark.newViewpoint({
2323
sortCoorient?: boolean;
2424
depthBias?: number;
2525
sort360?: boolean;
26+
sort32?: boolean;
2627
});
2728
```
2829

@@ -44,6 +45,7 @@ const viewpoint = spark.newViewpoint({
4445
| **sortCoorient** | View direction dot product threshold for re-sorting splats. For `sortRadial: true` it defaults to 0.99 while `sortRadial: false` uses 0.999 because it is more sensitive to view direction. (default: `0.99` if `sortRadial` else `0.999`)
4546
| **depthBias** | Constant added to Z-depth to bias values into the positive range for `sortRadial: false`, but also used for culling splats "well behind" the viewpoint origin (default: `1.0`)
4647
| **sort360** | Set this to true if rendering a 360 to disable "behind the viewpoint" culling during sorting. This is set automatically when rendering 360 envMaps using the `SparkRenderer.renderEnvMap()` utility function. (default: `false`)
48+
| **sort32** | Set this to true to sort with float32 precision with two-pass sort. (default: `false`)
4749

4850
## `dispose()`
4951

examples/editor/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,8 @@
480480
stats.dom.style.display = value ? "block" : "none";
481481
});
482482
gui.add(spark.defaultView, "sortRadial").name("Radial sort").listen();
483+
spark.defaultView.sort32 = true;
484+
gui.add(spark.defaultView, "sort32").name("Float32 sort").listen();
483485
gui.add(grid, "opacity", 0, 1, 0.01).name("Grid opacity").listen();
484486
gui.add({
485487
logFocalDistance: 0.0,

rust/spark-internal-rs/src/lib.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use js_sys::{Float32Array, Uint16Array, Uint32Array};
44
use wasm_bindgen::prelude::*;
55

66
mod sort;
7-
use sort::{sort_internal, SortBuffers};
7+
use sort::{sort_internal, SortBuffers, sort32_internal, Sort32Buffers};
88

99
mod raycast;
1010
use raycast::{raycast_ellipsoids, raycast_spheres};
@@ -13,6 +13,7 @@ const RAYCAST_BUFFER_COUNT: u32 = 65536;
1313

1414
thread_local! {
1515
static SORT_BUFFERS: RefCell<SortBuffers> = RefCell::new(SortBuffers::default());
16+
static SORT32_BUFFERS: RefCell<Sort32Buffers> = RefCell::new(Sort32Buffers::default());
1617
static RAYCAST_BUFFER: RefCell<Vec<u32>> = RefCell::new(vec![0; RAYCAST_BUFFER_COUNT as usize * 4]);
1718
}
1819

@@ -45,6 +46,35 @@ pub fn sort_splats(
4546
active_splats
4647
}
4748

49+
#[wasm_bindgen]
50+
pub fn sort32_splats(
51+
num_splats: u32, readback: Uint32Array, ordering: Uint32Array,
52+
) -> u32 {
53+
let max_splats = readback.length() as usize;
54+
55+
let active_splats = SORT32_BUFFERS.with_borrow_mut(|buffers| {
56+
buffers.ensure_size(max_splats);
57+
let sub_readback = readback.subarray(0, num_splats);
58+
sub_readback.copy_to(&mut buffers.readback[..num_splats as usize]);
59+
60+
let active_splats = match sort32_internal(buffers, max_splats, num_splats as usize) {
61+
Ok(active_splats) => active_splats,
62+
Err(err) => {
63+
wasm_bindgen::throw_str(&format!("{}", err));
64+
}
65+
};
66+
67+
if active_splats > 0 {
68+
// Copy out ordering result
69+
let subarray = &buffers.ordering[..active_splats as usize];
70+
ordering.subarray(0, active_splats).copy_from(&subarray);
71+
}
72+
active_splats
73+
});
74+
75+
active_splats
76+
}
77+
4878
#[wasm_bindgen]
4979
pub fn raycast_splats(
5080
origin_x: f32, origin_y: f32, origin_z: f32,

rust/spark-internal-rs/src/sort.rs

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use anyhow::anyhow;
22

3-
const DEPTH_INFINITY: u32 = 0x7c00;
4-
const DEPTH_SIZE: usize = DEPTH_INFINITY as usize + 1;
3+
const DEPTH_INFINITY_F16: u32 = 0x7c00;
4+
const DEPTH_SIZE_F16: usize = DEPTH_INFINITY_F16 as usize + 1;
55

66
#[derive(Default)]
77
pub struct SortBuffers {
@@ -18,8 +18,8 @@ impl SortBuffers {
1818
if self.ordering.len() < max_splats {
1919
self.ordering.resize(max_splats, 0);
2020
}
21-
if self.buckets.len() < DEPTH_SIZE {
22-
self.buckets.resize(DEPTH_SIZE, 0);
21+
if self.buckets.len() < DEPTH_SIZE_F16 {
22+
self.buckets.resize(DEPTH_SIZE_F16, 0);
2323
}
2424
}
2525
}
@@ -30,11 +30,11 @@ pub fn sort_internal(buffers: &mut SortBuffers, num_splats: usize) -> anyhow::Re
3030

3131
// Set the bucket counts to zero
3232
buckets.clear();
33-
buckets.resize(DEPTH_SIZE, 0);
33+
buckets.resize(DEPTH_SIZE_F16, 0);
3434

3535
// Count the number of splats in each bucket
3636
for &metric in readback.iter() {
37-
if (metric as u32) < DEPTH_INFINITY {
37+
if (metric as u32) < DEPTH_INFINITY_F16 {
3838
buckets[metric as usize] += 1;
3939
}
4040
}
@@ -49,7 +49,7 @@ pub fn sort_internal(buffers: &mut SortBuffers, num_splats: usize) -> anyhow::Re
4949

5050
// Write out splat indices at the right location using bucket offsets
5151
for (index, &metric) in readback.iter().enumerate() {
52-
if (metric as u32) < DEPTH_INFINITY {
52+
if (metric as u32) < DEPTH_INFINITY_F16 {
5353
ordering[buckets[metric as usize] as usize] = index as u32;
5454
buckets[metric as usize] += 1;
5555
}
@@ -65,3 +65,111 @@ pub fn sort_internal(buffers: &mut SortBuffers, num_splats: usize) -> anyhow::Re
6565
}
6666
Ok(active_splats)
6767
}
68+
69+
const DEPTH_INFINITY_F32: u32 = 0x7f800000;
70+
const RADIX_BASE: usize = 1 << 16; // 65536
71+
72+
#[derive(Default)]
73+
pub struct Sort32Buffers {
74+
/// raw f32 bit‑patterns (one per splat)
75+
pub readback: Vec<u32>,
76+
/// output indices
77+
pub ordering: Vec<u32>,
78+
/// bucket counts / offsets (length == RADIX_BASE)
79+
pub buckets16: Vec<u32>,
80+
/// scratch space for indices
81+
pub scratch: Vec<u32>,
82+
}
83+
84+
impl Sort32Buffers {
85+
/// ensure all internal buffers are large enough for up to `max_splats`
86+
pub fn ensure_size(&mut self, max_splats: usize) {
87+
if self.readback.len() < max_splats {
88+
self.readback.resize(max_splats, 0);
89+
}
90+
if self.ordering.len() < max_splats {
91+
self.ordering.resize(max_splats, 0);
92+
}
93+
if self.scratch.len() < max_splats {
94+
self.scratch.resize(max_splats, 0);
95+
}
96+
if self.buckets16.len() < RADIX_BASE {
97+
self.buckets16.resize(RADIX_BASE, 0);
98+
}
99+
}
100+
}
101+
102+
/// Two‑pass radix sort (base 2¹⁶) of 32‑bit float bit‑patterns,
103+
/// descending order (largest keys first). Mirrors the JS `sort32Splats`.
104+
pub fn sort32_internal(
105+
buffers: &mut Sort32Buffers,
106+
max_splats: usize,
107+
num_splats: usize,
108+
) -> anyhow::Result<u32> {
109+
// make sure our buffers can hold `max_splats`
110+
buffers.ensure_size(max_splats);
111+
112+
let Sort32Buffers { readback, ordering, buckets16, scratch } = buffers;
113+
let keys = &readback[..num_splats];
114+
115+
// ——— Pass #1: bucket by inv(low 16 bits) ———
116+
buckets16.fill(0);
117+
for &key in keys.iter() {
118+
if key < DEPTH_INFINITY_F32 {
119+
let inv = !key;
120+
buckets16[(inv & 0xFFFF) as usize] += 1;
121+
}
122+
}
123+
// exclusive prefix‑sum → starting offsets
124+
let mut total: u32 = 0;
125+
for slot in buckets16.iter_mut() {
126+
let cnt = *slot;
127+
*slot = total;
128+
total = total.wrapping_add(cnt);
129+
}
130+
let active_splats = total;
131+
132+
// scatter into scratch by low bits of inv
133+
for (i, &key) in keys.iter().enumerate() {
134+
if key < DEPTH_INFINITY_F32 {
135+
let inv = !key;
136+
let lo = (inv & 0xFFFF) as usize;
137+
scratch[buckets16[lo] as usize] = i as u32;
138+
buckets16[lo] += 1;
139+
}
140+
}
141+
142+
// ——— Pass #2: bucket by inv(high 16 bits) ———
143+
buckets16.fill(0);
144+
for &idx in scratch.iter().take(active_splats as usize) {
145+
let key = keys[idx as usize];
146+
let inv = !key;
147+
buckets16[(inv >> 16) as usize] += 1;
148+
}
149+
// exclusive prefix‑sum again
150+
let mut sum: u32 = 0;
151+
for slot in buckets16.iter_mut() {
152+
let cnt = *slot;
153+
*slot = sum;
154+
sum = sum.wrapping_add(cnt);
155+
}
156+
// scatter into final ordering by high bits of inv
157+
for &idx in scratch.iter().take(active_splats as usize) {
158+
let key = keys[idx as usize];
159+
let inv = !key;
160+
let hi = (inv >> 16) as usize;
161+
ordering[buckets16[hi] as usize] = idx;
162+
buckets16[hi] += 1;
163+
}
164+
165+
// sanity‑check: last bucket should have consumed all entries
166+
if buckets16[RADIX_BASE - 1] != active_splats {
167+
return Err(anyhow!(
168+
"Expected {} active splats but got {}",
169+
active_splats,
170+
buckets16[RADIX_BASE - 1]
171+
));
172+
}
173+
174+
Ok(active_splats)
175+
}

src/SparkViewpoint.ts

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
dyno,
1919
dynoBlock,
2020
dynoConst,
21+
floatBitsToUint,
2122
mul,
2223
packHalf2x16,
2324
readPackedSplat,
@@ -117,6 +118,11 @@ export type SparkViewpointOptions = {
117118
* @default false
118119
*/
119120
sort360?: boolean;
121+
/*
122+
* Set this to true to sort with float32 precision with two-pass sort.
123+
* @default true
124+
*/
125+
sort32?: boolean;
120126
};
121127

122128
// A SparkViewpoint is created from and tied to a SparkRenderer, and represents
@@ -149,6 +155,7 @@ export class SparkViewpoint {
149155
sortCoorient?: boolean;
150156
depthBias?: number;
151157
sort360?: boolean;
158+
sort32?: boolean;
152159

153160
display: {
154161
accumulator: SplatAccumulator;
@@ -164,7 +171,8 @@ export class SparkViewpoint {
164171
} | null = null;
165172
private sortingCheck = false;
166173

167-
private readback: Uint16Array = new Uint16Array(0);
174+
private readback16: Uint16Array = new Uint16Array(0);
175+
private readback32: Uint32Array = new Uint32Array(0);
168176
private orderingFreelist: FreeList<Uint32Array, number>;
169177

170178
constructor(options: SparkViewpointOptions & { spark: SparkRenderer }) {
@@ -209,6 +217,7 @@ export class SparkViewpoint {
209217
this.sortCoorient = options.sortCoorient;
210218
this.depthBias = options.depthBias;
211219
this.sort360 = options.sort360;
220+
this.sort32 = options.sort32;
212221

213222
this.orderingFreelist = new FreeList({
214223
allocate: (maxSplats) => new Uint32Array(maxSplats),
@@ -557,15 +566,24 @@ export class SparkViewpoint {
557566
const {
558567
reader,
559568
doubleSortReader,
569+
sort32Reader,
560570
dynoSortRadial,
561571
dynoOrigin,
562572
dynoDirection,
563573
dynoDepthBias,
564574
dynoSort360,
565575
dynoSplats,
566576
} = SparkViewpoint.makeSorter();
567-
const halfMaxSplats = Math.ceil(maxSplats / 2);
568-
this.readback = reader.ensureBuffer(halfMaxSplats, this.readback);
577+
const sort32 = this.sort32 ?? false;
578+
let readback: Uint16Array | Uint32Array;
579+
if (sort32) {
580+
this.readback32 = reader.ensureBuffer(maxSplats, this.readback32);
581+
readback = this.readback32;
582+
} else {
583+
const halfMaxSplats = Math.ceil(maxSplats / 2);
584+
this.readback16 = reader.ensureBuffer(halfMaxSplats, this.readback16);
585+
readback = this.readback16;
586+
}
569587

570588
const worldToOrigin = accumulator.toWorld.clone().invert();
571589
const viewToOrigin = viewToWorld.clone().premultiply(worldToOrigin);
@@ -581,25 +599,33 @@ export class SparkViewpoint {
581599
dynoSort360.value = this.sort360 ?? false;
582600
dynoSplats.packedSplats = accumulator.splats;
583601

602+
const sortReader = sort32 ? sort32Reader : doubleSortReader;
603+
const count = sort32 ? numSplats : Math.ceil(numSplats / 2);
584604
await reader.renderReadback({
585605
renderer: this.spark.renderer,
586-
reader: doubleSortReader,
587-
count: Math.ceil(numSplats / 2),
588-
readback: this.readback,
606+
reader: sortReader,
607+
count,
608+
readback,
589609
});
590610

591611
const result = (await withWorker(async (worker) => {
592-
return worker.call("sortDoubleSplats", {
612+
const rpcName = sort32 ? "sort32Splats" : "sortDoubleSplats";
613+
return worker.call(rpcName, {
614+
maxSplats,
593615
numSplats,
594-
readback: this.readback,
616+
readback,
595617
ordering,
596618
});
597619
})) as {
598-
readback: Uint16Array;
620+
readback: Uint16Array | Uint32Array;
599621
ordering: Uint32Array;
600622
activeSplats: number;
601623
};
602-
this.readback = result.readback;
624+
if (sort32) {
625+
this.readback32 = result.readback as Uint32Array;
626+
} else {
627+
this.readback16 = result.readback as Uint16Array;
628+
}
603629
ordering = result.ordering;
604630
activeSplats = result.activeSplats;
605631
}
@@ -669,6 +695,7 @@ export class SparkViewpoint {
669695
dynoSplats: DynoPackedSplats;
670696
reader: Readback;
671697
doubleSortReader: DynoBlock<{ index: "int" }, { rgba8: "vec4" }>;
698+
sort32Reader: DynoBlock<{ index: "int" }, { rgba8: "vec4" }>;
672699
} | null = null;
673700

674701
private static makeSorter() {
@@ -716,6 +743,28 @@ export class SparkViewpoint {
716743
},
717744
);
718745

746+
const sort32Reader = dynoBlock(
747+
{ index: "int" },
748+
{ rgba8: "vec4" },
749+
({ index }) => {
750+
if (!index) {
751+
throw new Error("No index");
752+
}
753+
const sortParams = {
754+
sortRadial: dynoSortRadial,
755+
sortOrigin: dynoOrigin,
756+
sortDirection: dynoDirection,
757+
sortDepthBias: dynoDepthBias,
758+
sort360: dynoSort360,
759+
};
760+
761+
const gsplat = readPackedSplat(dynoSplats, index);
762+
const metric = computeSortMetric({ gsplat, ...sortParams });
763+
const rgba8 = uintToRgba8(floatBitsToUint(metric));
764+
return { rgba8 };
765+
},
766+
);
767+
719768
SparkViewpoint.dynos = {
720769
dynoSortRadial,
721770
dynoOrigin,
@@ -725,6 +774,7 @@ export class SparkViewpoint {
725774
dynoSplats,
726775
reader,
727776
doubleSortReader,
777+
sort32Reader,
728778
};
729779
}
730780
return SparkViewpoint.dynos;

0 commit comments

Comments
 (0)