compressedPly.ts
src/core/compressedPly.ts · raw · all examples · site home
Exports: CompressedPlyFile
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 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209
// SuperSplat / PlayCanvas compressed PLY: a chunk element of quantization
// ranges per 256 splats (positions, log scales, colors), then four packed
// uint32 words per splat. Not standard 3DGS PLY; PlyFile.read reads both.
// Writers sort splats in Morton order of their centers so that each chunk's
// ranges are small.
import type { ArrayData } from "./ArrayData";
import { SplatMath } from "./math";
import { COMPRESSED_CHUNK_PROPERTIES, COMPRESSED_CHUNK_SIZE, COMPRESSED_VERTEX_PROPERTIES, PlyFile, encodeCompressedSh } from "./ply";
import { counter, type Progress } from "./progress";
import { Sh } from "./sh";
import { SortKeys } from "./sort";
import { Splats } from "./splats";
// Per splat: center, ln scales, quaternion w x y z, rgb, opacity
const VALUES = 14;
// splat-transform's clamp of ln scales
const LN_SCALE_LIMIT = 20;
// The splat bundle of `data` (any layout), in Morton order unless `sort` is
// false, and with `sh` (default true) its SH bundle as an sh element of
// uchar f_rest_* in splat-transform's mapping (clamped to [-4, 4] by the
// format; degree 3 at most). A LoD tree is not written.
function writeCompressedPly(data: ArrayData, { sort = true, sh = true, progress }: {
sort?: boolean;
sh?: boolean;
progress?: Progress;
} = {}): Uint8Array<ArrayBuffer> {
const { count } = data;
const decode = Splats.decoder(Splats.get(data).bundle);
const found = sh ? Sh.find(data.bundles) : undefined;
const decodeSh = found && Sh.decoder(data, found);
// The format stops at degree 3: higher degrees write their first 3 bands
const perChannel = found ? Sh.COEFFICIENTS[Math.min(found.degree, 3)] : 0;
const shValues = perChannel * 3;
const tick = counter(progress, 2 * count, "encoding");
const values = new Float32Array(count * VALUES);
const s = Splats.makeExtSplat();
const q = { x: 0, y: 0, z: 0, w: 1 };
for (let i = 0; i < count; i += 1) {
tick(i);
decode(i, s);
SplatMath.normalizeQuat(s.quaternion, q);
const v = i * VALUES;
values[v] = s.center.x;
values[v + 1] = s.center.y;
values[v + 2] = s.center.z;
values[v + 3] = lnScale(s.scales.x);
values[v + 4] = lnScale(s.scales.y);
values[v + 5] = lnScale(s.scales.z);
values[v + 6] = q.w;
values[v + 7] = q.x;
values[v + 8] = q.y;
values[v + 9] = q.z;
values[v + 10] = s.rgb.r;
values[v + 11] = s.rgb.g;
values[v + 12] = s.rgb.b;
values[v + 13] = s.opacity;
}
const order = sort ? mortonOrder(values, count) : undefined;
const chunks = Math.ceil(count / COMPRESSED_CHUNK_SIZE);
const header = new TextEncoder().encode([
"ply", "format binary_little_endian 1.0", "comment Generated by Radix",
`element chunk ${chunks}`, ...COMPRESSED_CHUNK_PROPERTIES.map((name) => `property float ${name}`),
`element vertex ${count}`, ...COMPRESSED_VERTEX_PROPERTIES.map((name) => `property uint ${name}`),
...(shValues > 0 ? [`element sh ${count}`, ...Array.from({ length: shValues }, (_, k) => `property uchar f_rest_${k}`)] : []),
"end_header", "",
].join("\n"));
const stride = COMPRESSED_CHUNK_PROPERTIES.length;
const vertexStart = header.byteLength + chunks * stride * 4;
const shStart = vertexStart + count * COMPRESSED_VERTEX_PROPERTIES.length * 4;
const bytes = new Uint8Array(shStart + count * shValues);
bytes.set(header);
const view = new DataView(bytes.buffer);
// Mins then maxes of the center, ln scales and rgb, as the chunk properties
const range = new Float32Array(stride);
const extend = (at: number, value: number) => {
range[at] = Math.min(range[at], value);
range[at + 3] = Math.max(range[at + 3], value);
};
for (let c = 0; c < chunks; c += 1) {
const start = c * COMPRESSED_CHUNK_SIZE;
const end = Math.min(count, start + COMPRESSED_CHUNK_SIZE);
for (let g = 0; g < stride; g += 6) {
range.fill(Infinity, g, g + 3);
range.fill(-Infinity, g + 3, g + 6);
}
for (let j = start; j < end; j += 1) {
const v = (order ? order[j] : j) * VALUES;
for (let k = 0; k < 3; k += 1) {
extend(k, values[v + k]);
extend(6 + k, values[v + 3 + k]);
extend(12 + k, values[v + 10 + k]);
}
}
for (let k = 0; k < stride; k += 1) {
view.setFloat32(header.byteLength + (c * stride + k) * 4, range[k], true);
}
for (let j = start; j < end; j += 1) {
tick(count + j);
const v = (order ? order[j] : j) * VALUES;
const at = vertexStart + j * 16;
view.setUint32(at, pack111011(values, v, range, 0), true);
view.setUint32(at + 4, packRotation(values, v + 6), true);
view.setUint32(at + 8, pack111011(values, v + 3, range, 6), true);
view.setUint32(at + 12, packColor(values, v + 10, range), true);
}
}
// f_rest_{c * N + k} (channel-major) of each splat in the written order
if (decodeSh) {
const coeffs = new Float32Array(Sh.VALUES);
for (let j = 0; j < count; j += 1) {
decodeSh(order ? order[j] : j, coeffs);
const at = shStart + j * shValues;
for (let c = 0; c < 3; c += 1) {
for (let k = 0; k < perChannel; k += 1) {
bytes[at + c * perChannel + k] = encodeCompressedSh(coeffs[k * 3 + c]);
}
}
}
}
return bytes;
}
// SuperSplat / PlayCanvas compressed PLY files of splats
export class CompressedPlyFile {
// Either kind of PLY (the same as PlyFile.read)
static readonly read = PlyFile.read;
// The splat bundle of `data` (any layout)
static readonly write = writeCompressedPly;
}
function lnScale(scale: number) {
return Math.max(-LN_SCALE_LIMIT, Math.min(LN_SCALE_LIMIT, Math.log(scale)));
}
// `value` in [min, max] as a `bits`-bit code (0 for an empty range)
function unorm(value: number, min: number, max: number, bits: number) {
const top = 2 ** bits - 1;
const t = max > min ? (value - min) / (max - min) : 0;
return Math.max(0, Math.min(top, Math.floor(t * top + 0.5)));
}
// Three values at `v` in the ranges at `at` (mins) and `at + 3` (maxes)
function pack111011(values: Float32Array, v: number, range: Float32Array, at: number) {
return unorm(values[v], range[at], range[at + 3], 11) * 2 ** 21
+ unorm(values[v + 1], range[at + 1], range[at + 4], 10) * 2 ** 11
+ unorm(values[v + 2], range[at + 2], range[at + 5], 11);
}
// rgb in the chunk's color ranges, then linear opacity
function packColor(values: Float32Array, v: number, range: Float32Array) {
return unorm(values[v], range[12], range[15], 8) * 2 ** 24
+ unorm(values[v + 1], range[13], range[16], 8) * 2 ** 16
+ unorm(values[v + 2], range[14], range[17], 8) * 2 ** 8
+ unorm(values[v + 3], 0, 1, 8);
}
// The index of the largest of w, x, y, z in 2 bits, then the other three
// (made positive by flipping the sign, each within +-1 / sqrt(2)) in 10
function packRotation(values: Float32Array, v: number) {
let largest = 0;
for (let k = 1; k < 4; k += 1) {
if (Math.abs(values[v + k]) > Math.abs(values[v + largest])) {
largest = k;
}
}
const sign = values[v + largest] < 0 ? -1 : 1;
let word = largest;
for (let k = 0; k < 4; k += 1) {
if (k !== largest) {
word = word * 1024 + unorm(sign * values[v + k] * Math.SQRT1_2 + 0.5, 0, 1, 10);
}
}
return word;
}
// Splat indices in Morton order of their centers on a 1024^3 grid over
// their bounds
function mortonOrder(values: Float32Array, count: number) {
const lo = [Infinity, Infinity, Infinity];
const hi = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < count; i += 1) {
for (let k = 0; k < 3; k += 1) {
lo[k] = Math.min(lo[k], values[i * VALUES + k]);
hi[k] = Math.max(hi[k], values[i * VALUES + k]);
}
}
const cell = (value: number, k: number) => (hi[k] > lo[k] ? Math.min(1023, Math.floor((1024 * (value - lo[k])) / (hi[k] - lo[k]))) : 0);
const keys = new Uint32Array(count);
for (let i = 0; i < count; i += 1) {
const v = i * VALUES;
keys[i] = ((spread3(cell(values[v + 2], 2)) << 2) | (spread3(cell(values[v + 1], 1)) << 1) | spread3(cell(values[v], 0))) >>> 0;
}
return SortKeys.sort({ keys, count, bits: 32 }).ordering;
}
// The 10 bits of x at every third bit
function spread3(x: number) {
let s = (x ^ (x << 16)) & 0xff0000ff;
s = (s ^ (s << 8)) & 0x0300f00f;
s = (s ^ (s << 4)) & 0x030c30c3;
return (s ^ (s << 2)) & 0x09249249;
}