rdx.md

docs/rdx.md · raw · all examples · site home

Headings: RDX filesThe layoutSavingLoadingStreaming into the GPUGotchas

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# RDX files

RDX is Radix's own file format. It stores the bundles of an `ArrayData`, compressed, in chunks of rows. Any table can be saved, including tables that hold no splats. A file can be read one chunk at a time over HTTP range requests, so a large scene can start drawing before it has fully loaded.

Code: `src/core/rdx.ts` (`RdxFile`) and `compress.ts` (`Compression`), in `@sparkjsdev/radix/core`.

```ts
// One file of every serialized bundle, and the same table back
const file = await RdxFile.encode(data);
const loaded = await RdxFile.decode(file);

// Over HTTP range requests, into an empty table of the right shape
const rdx = await RdxFile.load("scene.rdx");
const target = RdxFile.makeData(rdx.header);
for (let c = 0; c < rdx.header.chunks.length; c++) {
  await rdx.readChunk(c, target);
}
```

## The layout

```
whole file:  [ header ][ chunk 0 ][ chunk 1 ] ...
chunked:     scene.rdx (the header), scene-0.rdx, scene-1.rdx, ...

a container (the header and every chunk):
  u32 magic | u32 meta bytes | u32 payload count | u32 0
    (magic: "RDXH" for the header, "RDXC" for a chunk)
  meta: JSON, padded to 16 bytes
  u32 size per payload, padded to 16 bytes
  payloads, each padded to 16 bytes
```

- **The header** holds the format version, the row count, the chunk size, each bundle's definition and meta, the table's meta, and each chunk's size in bytes (`RdxHeader`). Only bundles defined with `serialize: true` are saved.
- **A chunk** holds its rows of every channel, one payload per channel; its meta is `{ start, count }`. Each channel's bytes are transposed (byte 0 of every element, then byte 1, and so on) and gzipped (`Compression.compressTransposed`). Transposing puts the bytes of float exponents and of each packed field next to each other, so they compress much better.
- The chunk size is `RdxFile.CHUNK_SIZE` (65536) by default and can be any multiple of 4096, so every chunk covers whole texture rows and can be passed to `GpuArrays.upload` unchanged.

## Saving

| Member | What it does |
|---|---|
| `RdxFile.encode(data, { chunkSize?, progress? })` | one file: the header, then every chunk |
| `RdxFile.encodeChunks(data, options?)` | `{ header, chunks }`: the pieces as separate byte arrays |
| `RdxFile.encodeFiles(data, "scene.rdx", options?)` | a chunked set by file name: `{ "scene.rdx": header, "scene-0.rdx": chunk 0, ... }` |
| `RdxFile.chunkName("scene.rdx", 3)` | `"scene-3.rdx"`; a query or hash after `.rdx` is kept |

`writeSplats(data, "rdx")` writes a whole file on a worker ([files.md](files.md)). SH bundles (`extSh`, `packedSh`, a palette's `shLabel`) are saved like any other bundle, in their codec, and load in that codec unless `sh` asks for another. An SH palette (`ShPalette.data`) is a separate table: save it as its own RDX file, or pass it to `writeSplats` as `shPalette` to write the SH per splat ([sh.md](sh.md#palettes)).

## Loading

| Member | What it does |
|---|---|
| `RdxFile.decode(bytes, { progress? })` | a whole file to a table |
| `RdxFile.load(url, { chunked?, init? })` | an `RdxReader` over HTTP: one request for the header and one per chunk. `chunked: true` reads a chunked set, one file per chunk. `init` is passed to `fetch` |
| `RdxFile.open(readRange)` | an `RdxReader` over any `(start, end) => Promise<bytes>` |
| `RdxFile.makeData(header)` | an empty table of a header's bundles, count, and meta |
| `RdxFile.decodeHeader(bytes)` | `{ header, byteLength }`: the header at the start of `bytes`; chunk 0 of a whole file starts at `byteLength` |
| `RdxFile.decodeChunk(header, bytes, into)` | writes one chunk's rows into `into` |

An `RdxReader` has `header` and `readChunk(chunk, into)`. Chunks can be read in any order.

- The header lists every chunk's size, so after the first request a reader can compute each chunk's offset.
- When the server ignores range requests, the whole file is downloaded once and later reads use that copy.
- `loadSplats` and `SplatFiles.decode` recognize RDX by its first bytes and convert its splats to the requested layout. A splat file's `lodTree` is saved like any other bundle.

## Streaming into the GPU

```ts
// A file of packed splats
const rdx = await RdxFile.load(url);
const { count, chunkSize, chunks } = rdx.header;
const data = RdxFile.makeData(rdx.header);

const splats = new GpuArrays({
  renderer,
  bundles: { packedSplats: Splats.PACKED },
  capacity: count,
});

for (let c = 0; c < chunks.length; c++) {
  await rdx.readChunk(c, data);
  const start = c * chunkSize;
  splats.upload(data, {
    bundles: ["packedSplats"],
    srcStart: start,
    dstStart: start,
    count: Math.min(chunkSize, count - start),
  });

  // Draw the splats loaded so far
  const object = SplatCollection.arrays(splats);
  splatRenderer.collection = new SplatCollection([object]);
}
```

The arrays' `count` grows with each upload, and each new collection draws the splats uploaded so far. A packed bundle's encoding is in its meta, and `upload` copies it.

## Gotchas

- A chunked set is several files. Serve them from the same directory, under the names that `RdxFile.chunkName` returns.
- Large PLY files: `npm run convert:ply -- in.ply out.rdx` streams a PLY past Node's 2 GiB read limit into one packed RDX file (`scripts/convert-large-ply.ts`).