playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
558 lines (472 loc) • 19.8 kB
JavaScript
import { GSplatFormat, GSplatResourceBase, Http, PIXELFORMAT_RGBA8, PIXELFORMAT_RGBA32U, Vec3, WasmModule } from 'playcanvas';
/**
* @import { AppBase, BoundingBox, GraphicsDevice } from 'playcanvas'
*/
/**
* A parser for the SPZ gaussian splat format (https://github.com/nianticlabs/spz), version 4.
*
* The splat data stays in its quantized form on the GPU (roughly 20 bytes per splat plus
* spherical harmonics), and is dequantized in the shader, similar to how the compressed PLY and
* SOG formats are handled by the engine.
*
* SPZ v4 attribute streams are ZSTD compressed. The parser uses a ZSTD wasm module which the
* application needs to register before loading spz assets, in the same way the Draco module is
* registered:
*
* ```javascript
* import { WasmModule } from 'playcanvas';
*
* WasmModule.setConfig('ZstdDecoderModule', {
* glueUrl: 'zstd.wasm.js',
* wasmUrl: 'zstd.wasm.wasm'
* });
* ```
*
* To use, register the parser with the gsplat resource handler:
*
* ```javascript
* app.loader.getHandler('gsplat').addParser(new SpzParser(app));
* ```
*
* and then load spz files as gsplat assets:
*
* ```javascript
* import { Asset } from 'playcanvas';
*
* const asset = new Asset('gsplat', 'gsplat', { url: 'scene.spz' });
* app.assets.add(asset);
* app.assets.load(asset);
* ```
*/
// SPZ v4 file header constants
const NGSP_MAGIC = 0x5053474e;
const FLAG_ANTIALIASED = 0x1;
const FLAG_HAS_EXTENSIONS = 0x2;
// number of SH coefficients per channel for each SH degree
const SH_DIM_FOR_DEGREE = [0, 3, 8, 15, 24];
// GLSL shader code for reading the quantized splat data. Texture declarations and load
// functions are auto-generated from the GSplatFormat streams.
const readGLSL = /* glsl */`
uniform float spzPositionScale;
// work values
uvec4 packedData; // xyz: 24-bit position + 8-bit scale per component, w: rotation bits
vec3 getCenter() {
packedData = loadPackedTexture();
// sign-extend the 24-bit fixed point position
ivec3 p = ivec3(packedData.xyz << 8u) >> 8;
return vec3(p) * spzPositionScale;
}
vec4 getColor() {
vec4 c = loadColorTexture();
// rgb stores SH DC coefficients as c * 0.15 + 0.5, convert to color as 0.5 + SH_C0 * dc,
// alpha is stored with sigmoid already applied
const float scale = 0.28209479177387814 / 0.15;
return vec4((c.rgb - 0.5) * scale + 0.5, c.a);
}
vec4 getRotation() {
// smallest-three encoding: bits 30-31 store the index of the largest component, the
// remaining three components are stored as 9-bit magnitude + sign bit, 10 bits each,
// starting from the lowest bits for the highest component index
uint comp = packedData.w;
uint largest = comp >> 30u;
float q[4];
float sumSquares = 0.0;
for (int i = 3; i >= 0; i--) {
if (uint(i) != largest) {
float v = float(comp & 0x1ffu) * (0.7071067811865476 / 511.0);
if ((comp & 0x200u) != 0u) v = -v;
comp = comp >> 10u;
q[i] = v;
sumSquares += v * v;
}
}
q[largest] = sqrt(max(0.0, 1.0 - sumSquares));
// spz stores the quaternion as (x, y, z, w), the engine expects (w, x, y, z)
return vec4(q[3], q[0], q[1], q[2]);
}
vec3 getScale() {
// 8-bit log encoded scale in the top byte of each position word
vec3 s = vec3(packedData.xyz >> 24u);
return exp(s / 16.0 - 10.0);
}
#if SH_BANDS > 0
// quantized SH bytes of the current splat, packed 4 to a word
uint shWords[12];
float unpackSH(int i) {
uint b = (shWords[i >> 2] >> uint(8 * (i & 3))) & 0xffu;
return (float(b) - 128.0) / 128.0;
}
void readSHData(out vec3 sh[SH_COEFFS], out float scale) {
uvec4 d0 = loadShTexture0();
shWords[0] = d0.x; shWords[1] = d0.y; shWords[2] = d0.z; shWords[3] = d0.w;
#if SH_BANDS > 1
uvec4 d1 = loadShTexture1();
shWords[4] = d1.x; shWords[5] = d1.y; shWords[6] = d1.z; shWords[7] = d1.w;
#endif
#if SH_BANDS > 2
uvec4 d2 = loadShTexture2();
shWords[8] = d2.x; shWords[9] = d2.y; shWords[10] = d2.z; shWords[11] = d2.w;
#endif
for (int i = 0; i < SH_COEFFS; i++) {
sh[i] = vec3(unpackSH(i * 3), unpackSH(i * 3 + 1), unpackSH(i * 3 + 2));
}
scale = 1.0;
}
#endif
`;
// WGSL variant of the read code
const readWGSL = /* wgsl */`
uniform spzPositionScale: f32;
// work values
var<private> packedData: vec4u; // xyz: 24-bit position + 8-bit scale per component, w: rotation bits
fn getCenter() -> vec3f {
packedData = loadPackedTexture();
// sign-extend the 24-bit fixed point position
let p = bitcast<vec3i>(packedData.xyz << vec3u(8u)) >> vec3u(8u);
return vec3f(p) * uniform.spzPositionScale;
}
fn getColor() -> vec4f {
let c = loadColorTexture();
// rgb stores SH DC coefficients as c * 0.15 + 0.5, convert to color as 0.5 + SH_C0 * dc,
// alpha is stored with sigmoid already applied
const scale: f32 = 0.28209479177387814 / 0.15;
return vec4f((c.rgb - 0.5) * scale + 0.5, c.a);
}
fn getRotation() -> vec4f {
// smallest-three encoding: bits 30-31 store the index of the largest component, the
// remaining three components are stored as 9-bit magnitude + sign bit, 10 bits each,
// starting from the lowest bits for the highest component index
var comp: u32 = packedData.w;
let largest: u32 = comp >> 30u;
var q = array<f32, 4>(0.0, 0.0, 0.0, 0.0);
var sumSquares: f32 = 0.0;
for (var i: i32 = 3; i >= 0; i--) {
if (u32(i) != largest) {
var v: f32 = f32(comp & 0x1ffu) * (0.7071067811865476 / 511.0);
if ((comp & 0x200u) != 0u) { v = -v; }
comp = comp >> 10u;
q[i] = v;
sumSquares += v * v;
}
}
q[largest] = sqrt(max(0.0, 1.0 - sumSquares));
// spz stores the quaternion as (x, y, z, w), the engine expects (w, x, y, z)
return vec4f(q[3], q[0], q[1], q[2]);
}
fn getScale() -> vec3f {
// 8-bit log encoded scale in the top byte of each position word
let s = vec3f(packedData.xyz >> vec3u(24u));
return exp(s / 16.0 - 10.0);
}
#if SH_BANDS > 0
// quantized SH bytes of the current splat, packed 4 to a word
var<private> shWords: array<u32, 12>;
fn unpackSH(i: i32) -> f32 {
let b = (shWords[i >> 2] >> u32(8 * (i & 3))) & 0xffu;
return (f32(b) - 128.0) / 128.0;
}
fn readSHData(sh: ptr<function, array<half3, SH_COEFFS>>, scale: ptr<function, f32>) {
let d0 = loadShTexture0();
shWords[0] = d0.x; shWords[1] = d0.y; shWords[2] = d0.z; shWords[3] = d0.w;
#if SH_BANDS > 1
let d1 = loadShTexture1();
shWords[4] = d1.x; shWords[5] = d1.y; shWords[6] = d1.z; shWords[7] = d1.w;
#endif
#if SH_BANDS > 2
let d2 = loadShTexture2();
shWords[8] = d2.x; shWords[9] = d2.y; shWords[10] = d2.z; shWords[11] = d2.w;
#endif
for (var i: i32 = 0; i < SH_COEFFS; i++) {
sh[i] = half3(vec3f(unpackSH(i * 3), unpackSH(i * 3 + 1), unpackSH(i * 3 + 2)));
}
*scale = 1.0;
}
#endif
`;
/**
* Parsed SPZ file data - the quantized attribute streams as stored in the file, together with
* the header information needed to interpret them.
*/
class SpzGSplatData {
/** @type {number} */
numSplats;
/** @type {number} */
fractionalBits;
/** @type {0|1|2|3} */
shBands;
/** @type {number} */
shDim;
/** @type {boolean} */
antialiased;
/** @type {Uint8Array} positions, 3x 24-bit little-endian fixed point per splat */
positions;
/** @type {Uint8Array} alphas, 1 byte per splat, sigmoid applied */
alphas;
/** @type {Uint8Array} colors, 3 bytes per splat */
colors;
/** @type {Uint8Array} scales, 3 bytes per splat, log encoded */
scales;
/** @type {Uint8Array} rotations, 4 bytes per splat, smallest-three encoded */
rotations;
/** @type {Uint8Array|null} spherical harmonics, shDim * 3 bytes per splat */
sh;
constructor(header, streams) {
this.numSplats = header.numPoints;
this.fractionalBits = header.fractionalBits;
this.antialiased = header.antialiased;
this.shDim = SH_DIM_FOR_DEGREE[header.shDegree];
// the engine supports at most 3 SH bands, additional bands are ignored
this.shBands = /** @type {0|1|2|3} */ (Math.min(header.shDegree, 3));
this.positions = streams.positions;
this.alphas = streams.alphas;
this.colors = streams.colors;
this.scales = streams.scales;
this.rotations = streams.rotations;
this.sh = streams.sh ?? null;
}
/**
* Decodes the fixed point positions to a float array, used by the sorter.
*
* @returns {Float32Array} The centers, 3 floats per splat.
*/
getCenters() {
const { numSplats, positions } = this;
const scale = 1 / (1 << this.fractionalBits);
const centers = new Float32Array(numSplats * 3);
for (let i = 0; i < numSplats * 3; i++) {
const o = i * 3;
let v = positions[o] | (positions[o + 1] << 8) | (positions[o + 2] << 16);
v = (v << 8) >> 8; // sign-extend 24-bit
centers[i] = v * scale;
}
return centers;
}
/**
* @param {BoundingBox} aabb - The bounding box to update.
*/
calcAabb(aabb) {
const { numSplats, positions } = this;
const scale = 1 / (1 << this.fractionalBits);
const min = new Vec3(Infinity, Infinity, Infinity);
const max = new Vec3(-Infinity, -Infinity, -Infinity);
for (let i = 0; i < numSplats; i++) {
for (let c = 0; c < 3; c++) {
const o = (i * 3 + c) * 3;
let v = positions[o] | (positions[o + 1] << 8) | (positions[o + 2] << 16);
v = (v << 8) >> 8;
const p = v * scale;
const mc = c === 0 ? 'x' : (c === 1 ? 'y' : 'z');
min[mc] = Math.min(min[mc], p);
max[mc] = Math.max(max[mc], p);
}
}
aabb.setMinMax(min, max);
}
}
/**
* A gsplat resource which keeps the SPZ data in its quantized form in GPU textures, and
* dequantizes it in the shader.
*/
class GSplatSpzResource extends GSplatResourceBase {
/**
* @param {GraphicsDevice} device - The graphics device.
* @param {SpzGSplatData} gsplatData - The parsed spz data.
* @param {object} [options] - Passed to {@link GSplatResourceBase} constructor.
*/
constructor(device, gsplatData, options = {}) {
super(device, gsplatData, options);
const { numSplats, shBands } = gsplatData;
// splat data streams:
// - packedTexture: xyz words store 24-bit fixed point position + 8-bit log scale,
// w word stores the smallest-three rotation
// - colorTexture: rgb color + alpha bytes
const formatStreams = [
{ name: 'packedTexture', format: PIXELFORMAT_RGBA32U },
{ name: 'colorTexture', format: PIXELFORMAT_RGBA8 }
];
// quantized SH coefficient bytes, packed 16 to a texture
const numShTextures = shBands > 0 ? Math.ceil((SH_DIM_FOR_DEGREE[shBands] * 3) / 16) : 0;
for (let i = 0; i < numShTextures; i++) {
formatStreams.push({ name: `shTexture${i}`, format: PIXELFORMAT_RGBA32U });
}
this._format = new GSplatFormat(device, formatStreams, {
readGLSL: readGLSL,
readWGSL: readWGSL
});
// let streams create textures from format
this.streams.init(this.format, numSplats);
// dequantization uniform
this.parameters.set('spzPositionScale', 1 / (1 << gsplatData.fractionalBits));
// pack position + scale + rotation
const { positions, scales, rotations, colors, alphas } = gsplatData;
const packedTexture = this.streams.getTexture('packedTexture');
const packedData = packedTexture.lock();
for (let i = 0; i < numSplats; i++) {
const p = i * 9;
const s = i * 3;
const r = i * 4;
packedData[i * 4 + 0] = positions[p + 0] | (positions[p + 1] << 8) | (positions[p + 2] << 16) | (scales[s + 0] << 24);
packedData[i * 4 + 1] = positions[p + 3] | (positions[p + 4] << 8) | (positions[p + 5] << 16) | (scales[s + 1] << 24);
packedData[i * 4 + 2] = positions[p + 6] | (positions[p + 7] << 8) | (positions[p + 8] << 16) | (scales[s + 2] << 24);
packedData[i * 4 + 3] = rotations[r + 0] | (rotations[r + 1] << 8) | (rotations[r + 2] << 16) | (rotations[r + 3] << 24);
}
packedTexture.unlock();
// color + alpha
const colorTexture = this.streams.getTexture('colorTexture');
const colorData = colorTexture.lock();
for (let i = 0; i < numSplats; i++) {
colorData[i * 4 + 0] = colors[i * 3 + 0];
colorData[i * 4 + 1] = colors[i * 3 + 1];
colorData[i * 4 + 2] = colors[i * 3 + 2];
colorData[i * 4 + 3] = alphas[i];
}
colorTexture.unlock();
// spherical harmonics - pack the quantized bytes 4 to a word, 16 to a texture. Note that
// the source data can store more coefficients than the engine supports (SH degree 4), in
// which case only the first 15 coefficients are used.
if (shBands > 0) {
const sh = gsplatData.sh;
const srcStride = gsplatData.shDim * 3;
const usedBytes = SH_DIM_FOR_DEGREE[shBands] * 3;
for (let t = 0; t < numShTextures; t++) {
const shTexture = this.streams.getTexture(`shTexture${t}`);
const shData = shTexture.lock();
for (let i = 0; i < numSplats; i++) {
const src = i * srcStride + t * 16;
for (let w = 0; w < 4; w++) {
let word = 0;
for (let k = 0; k < 4; k++) {
const b = t * 16 + w * 4 + k;
word |= (b < usedBytes ? sh[src + w * 4 + k] : 128) << (k * 8);
}
shData[i * 4 + w] = word;
}
}
shTexture.unlock();
}
}
}
configureMaterialDefines(defines) {
defines.set('SH_BANDS', this.gsplatData.shBands);
}
}
/**
* Parses the SPZ v4 (NGSP) container and decompresses the attribute streams.
*
* @param {ArrayBuffer} arrayBuffer - The file data.
* @param {{ decompress: (src: Uint8Array, size: number) => Uint8Array }} decoder - The ZSTD decoder.
* @returns {SpzGSplatData} The parsed data.
*/
const parseSpz = (arrayBuffer, decoder) => {
const dv = new DataView(arrayBuffer);
if (dv.byteLength < 32) {
throw new Error('Invalid spz file: too small');
}
const magic = dv.getUint32(0, true);
const version = dv.getUint32(4, true);
if (magic !== NGSP_MAGIC) {
// legacy versions (1-3) store the gzipped header + streams, so the file starts with the
// gzip magic instead
if (dv.getUint8(0) === 0x1f && dv.getUint8(1) === 0x8b) {
throw new Error('Unsupported spz file: legacy gzip based version, only version 4 is supported');
}
throw new Error(`Invalid spz file: unexpected magic number 0x${magic.toString(16)}`);
}
if (version !== 4) {
throw new Error(`Unsupported spz version ${version}, only version 4 is supported`);
}
const header = {
numPoints: dv.getUint32(8, true),
shDegree: dv.getUint8(12),
fractionalBits: dv.getUint8(13),
flags: dv.getUint8(14),
numStreams: dv.getUint8(15),
tocByteOffset: dv.getUint32(16, true),
antialiased: false
};
header.antialiased = (header.flags & FLAG_ANTIALIASED) !== 0;
if (header.shDegree >= SH_DIM_FOR_DEGREE.length) {
throw new Error(`Invalid spz file: unsupported SH degree ${header.shDegree}`);
}
if ((header.flags & FLAG_HAS_EXTENSIONS) !== 0) {
// extensions are stored between the header and the TOC and are skipped here
console.warn('SpzParser: file contains extensions which are not supported and will be ignored');
}
// expected uncompressed stream sizes, in file order, zero-size streams are not stored
const numPoints = header.numPoints;
const shDim = SH_DIM_FOR_DEGREE[header.shDegree];
const streamNames = ['positions', 'alphas', 'colors', 'scales', 'rotations', 'sh'];
const streamSizes = [numPoints * 9, numPoints, numPoints * 3, numPoints * 3, numPoints * 4, numPoints * shDim * 3]
.filter(size => size > 0);
if (header.numStreams !== streamSizes.length) {
throw new Error(`Invalid spz file: expected ${streamSizes.length} streams, found ${header.numStreams}`);
}
// read the TOC and decompress the streams
const tocSize = header.numStreams * 16;
if (header.tocByteOffset < 32 || header.tocByteOffset + tocSize > dv.byteLength) {
throw new Error('Invalid spz file: table of contents out of bounds');
}
const streams = {};
let dataOffset = header.tocByteOffset + tocSize;
for (let i = 0; i < header.numStreams; i++) {
const compressedSize = Number(dv.getBigUint64(header.tocByteOffset + i * 16, true));
const uncompressedSize = Number(dv.getBigUint64(header.tocByteOffset + i * 16 + 8, true));
if (uncompressedSize !== streamSizes[i]) {
throw new Error(`Invalid spz file: unexpected size of the ${streamNames[i]} stream`);
}
if (dataOffset + compressedSize > dv.byteLength) {
throw new Error('Invalid spz file: stream extends past the end of the file');
}
const compressed = new Uint8Array(arrayBuffer, dataOffset, compressedSize);
streams[streamNames[i]] = decoder.decompress(compressed, uncompressedSize);
dataOffset += compressedSize;
}
return new SpzGSplatData(header, streams);
};
class SpzParser {
/** @type {AppBase} */
app;
/**
* @param {AppBase} app - The app instance.
*/
constructor(app) {
this.app = app;
}
canParse(context) {
return context.ext === 'spz';
}
/**
* @param {object} url - The URL of the resource to load.
* @param {string} url.load - The URL to use for loading the resource.
* @param {string} url.original - The original URL useful for identifying the resource type.
* @param {Function} callback - The callback used when the resource is loaded or an error
* occurs.
* @param {import('playcanvas').Asset} asset - Container asset.
*/
load(url, callback, asset) {
if (!WasmModule.getConfig('ZstdDecoderModule')) {
callback('SpzParser: ZSTD decoder module is not registered. Use WasmModule.setConfig(\'ZstdDecoderModule\', { glueUrl, wasmUrl }) before loading spz files.');
return;
}
this.handler.fetch(url, Http.ResponseType.ARRAY_BUFFER, (err, data) => {
if (err) {
callback(err);
return;
}
WasmModule.getInstance('ZstdDecoderModule', (decoder) => {
try {
const gsplatData = parseSpz(data, decoder);
const prepareCenters = this.app.scene?.gsplatCentersEnabled !== false;
const resource = new GSplatSpzResource(this.app.graphicsDevice, gsplatData, { prepareCenters });
callback(null, resource);
} catch (e) {
callback(e);
}
});
}, asset);
}
open(url, data) {
return data;
}
}
export { SpzParser, GSplatSpzResource };