Files
spark/src/ksplat.ts
T

577 lines
19 KiB
TypeScript

import {
computeMaxSplats,
encodeSh1Rgb,
encodeSh2Rgb,
encodeSh3Rgb,
fromHalf,
setPackedSplat,
} from "./utils";
type KsplatCompression = {
bytesPerCenter: number;
bytesPerScale: number;
bytesPerRotation: number;
bytesPerColor: number;
bytesPerSphericalHarmonicsComponent: number;
scaleOffsetBytes: number;
rotationOffsetBytes: number;
colorOffsetBytes: number;
sphericalHarmonicsOffsetBytes: number;
scaleRange: number;
};
const KSPLAT_COMPRESSION: Record<number, KsplatCompression> = {
0: {
bytesPerCenter: 12,
bytesPerScale: 12,
bytesPerRotation: 16,
bytesPerColor: 4,
bytesPerSphericalHarmonicsComponent: 4,
scaleOffsetBytes: 12,
rotationOffsetBytes: 24,
colorOffsetBytes: 40,
sphericalHarmonicsOffsetBytes: 44,
scaleRange: 1,
},
1: {
bytesPerCenter: 6,
bytesPerScale: 6,
bytesPerRotation: 8,
bytesPerColor: 4,
bytesPerSphericalHarmonicsComponent: 2,
scaleOffsetBytes: 6,
rotationOffsetBytes: 12,
colorOffsetBytes: 20,
sphericalHarmonicsOffsetBytes: 24,
scaleRange: 32767,
},
2: {
bytesPerCenter: 6,
bytesPerScale: 6,
bytesPerRotation: 8,
bytesPerColor: 4,
bytesPerSphericalHarmonicsComponent: 1,
scaleOffsetBytes: 6,
rotationOffsetBytes: 12,
colorOffsetBytes: 20,
sphericalHarmonicsOffsetBytes: 24,
scaleRange: 32767,
},
};
const KSPLAT_SH_DEGREE_TO_COMPONENTS: Record<number, number> = {
0: 0,
1: 9,
2: 24,
3: 45,
};
export function decodeKsplat(
fileBytes: Uint8Array,
initNumSplats: (numSplats: number) => void,
splatCallback: (
index: number,
x: number,
y: number,
z: number,
scaleX: number,
scaleY: number,
scaleZ: number,
quatX: number,
quatY: number,
quatZ: number,
quatW: number,
opacity: number,
r: number,
g: number,
b: number,
) => void,
shCallback?: (
index: number,
sh1: Float32Array,
sh2?: Float32Array,
sh3?: Float32Array,
) => void,
) {
const HEADER_BYTES = 4096;
const SECTION_BYTES = 1024;
let headerOffset = 0;
const header = new DataView(fileBytes.buffer, headerOffset, HEADER_BYTES);
headerOffset += HEADER_BYTES;
const versionMajor = header.getUint8(0);
const versionMinor = header.getUint8(1);
if (versionMajor !== 0 || versionMinor < 1) {
throw new Error(
`Unsupported .ksplat version: ${versionMajor}.${versionMinor}`,
);
}
const maxSectionCount = header.getUint32(4, true);
// const sectionCount = header.getUint32(8, true);
// const maxSplatCount = header.getUint32(12, true);
const splatCount = header.getUint32(16, true);
const compressionLevel = header.getUint16(20, true);
if (compressionLevel < 0 || compressionLevel > 2) {
throw new Error(`Invalid .ksplat compression level: ${compressionLevel}`);
}
// const sceneCenterX = header.getFloat32(24, true);
// const sceneCenterY = header.getFloat32(28, true);
// const sceneCenterZ = header.getFloat32(32, true);
const minSphericalHarmonicsCoeff = header.getFloat32(36, true) || -1.5;
const maxSphericalHarmonicsCoeff = header.getFloat32(40, true) || 1.5;
const numSplats = splatCount;
initNumSplats(numSplats);
const maxSplats = computeMaxSplats(numSplats);
const packedArray = new Uint32Array(maxSplats * 4);
const extra: Record<string, unknown> = {};
let sectionBase = HEADER_BYTES + maxSectionCount * SECTION_BYTES;
for (let section = 0; section < maxSectionCount; ++section) {
const section = new DataView(fileBytes.buffer, headerOffset, SECTION_BYTES);
headerOffset += SECTION_BYTES;
const sectionSplatCount = section.getUint32(0, true);
const sectionMaxSplatCount = section.getUint32(4, true);
const bucketSize = section.getUint32(8, true);
const bucketCount = section.getUint32(12, true);
const bucketBlockSize = section.getFloat32(16, true);
const bucketStorageSizeBytes = section.getUint16(20, true);
const compressionScaleRange =
(section.getUint32(24, true) ||
KSPLAT_COMPRESSION[compressionLevel]?.scaleRange) ??
1;
// const fullBucketCount = section.getUint32(32, true);
const partiallyFilledBucketCount = section.getUint32(36, true);
const bucketsMetaDataSizeBytes = partiallyFilledBucketCount * 4;
const bucketsStorageSizeBytes =
bucketStorageSizeBytes * bucketCount + bucketsMetaDataSizeBytes;
const sphericalHarmonicsDegree = section.getUint16(40, true);
const shComponents =
KSPLAT_SH_DEGREE_TO_COMPONENTS[sphericalHarmonicsDegree];
const {
bytesPerCenter,
bytesPerScale,
bytesPerRotation,
bytesPerColor,
bytesPerSphericalHarmonicsComponent,
scaleOffsetBytes,
rotationOffsetBytes,
colorOffsetBytes,
sphericalHarmonicsOffsetBytes,
} = KSPLAT_COMPRESSION[compressionLevel];
const bytesPerSplat =
bytesPerCenter +
bytesPerScale +
bytesPerRotation +
bytesPerColor +
shComponents * bytesPerSphericalHarmonicsComponent;
const splatDataStorageSizeBytes = bytesPerSplat * sectionMaxSplatCount;
const storageSizeBytes =
splatDataStorageSizeBytes + bucketsStorageSizeBytes;
const sh1Index = [0, 3, 6, 1, 4, 7, 2, 5, 8];
const sh2Index = [
9, 14, 19, 10, 15, 20, 11, 16, 21, 12, 17, 22, 13, 18, 23,
];
const sh3Index = [
24, 31, 38, 25, 32, 39, 26, 33, 40, 27, 34, 41, 28, 35, 42, 29, 36, 43,
30, 37, 44,
];
const sh1 =
sphericalHarmonicsDegree >= 1 ? new Float32Array(3 * 3) : undefined;
const sh2 =
sphericalHarmonicsDegree >= 2 ? new Float32Array(5 * 3) : undefined;
const sh3 =
sphericalHarmonicsDegree >= 3 ? new Float32Array(7 * 3) : undefined;
const compressionScaleFactor = bucketBlockSize / 2 / compressionScaleRange;
const bucketsBase = sectionBase + bucketsMetaDataSizeBytes;
const dataBase = sectionBase + bucketsStorageSizeBytes;
const data = new DataView(
fileBytes.buffer,
dataBase,
splatDataStorageSizeBytes,
);
const bucketArray = new Float32Array(
fileBytes.buffer,
bucketsBase,
bucketCount * 3,
);
function getSh(splatOffset: number, component: number) {
if (compressionLevel === 0) {
return data.getFloat32(
splatOffset + sphericalHarmonicsOffsetBytes + component * 4,
true,
);
}
if (compressionLevel === 1) {
return fromHalf(
data.getUint16(
splatOffset + sphericalHarmonicsOffsetBytes + component * 2,
true,
),
);
}
const t =
data.getUint8(splatOffset + sphericalHarmonicsOffsetBytes + component) /
255;
return (
minSphericalHarmonicsCoeff +
t * (maxSphericalHarmonicsCoeff - minSphericalHarmonicsCoeff)
);
}
for (let i = 0; i < sectionSplatCount; ++i) {
const splatOffset = i * bytesPerSplat;
const bucketIndex = Math.floor(i / bucketSize);
const x =
compressionLevel === 0
? data.getFloat32(splatOffset + 0, true)
: (data.getUint16(splatOffset + 0, true) - compressionScaleRange) *
compressionScaleFactor +
bucketArray[3 * bucketIndex + 0];
const y =
compressionLevel === 0
? data.getFloat32(splatOffset + 4, true)
: (data.getUint16(splatOffset + 2, true) - compressionScaleRange) *
compressionScaleFactor +
bucketArray[3 * bucketIndex + 1];
const z =
compressionLevel === 0
? data.getFloat32(splatOffset + 8, true)
: (data.getUint16(splatOffset + 4, true) - compressionScaleRange) *
compressionScaleFactor +
bucketArray[3 * bucketIndex + 2];
const scaleX =
compressionLevel === 0
? data.getFloat32(splatOffset + scaleOffsetBytes + 0, true)
: fromHalf(data.getUint16(splatOffset + scaleOffsetBytes + 0, true));
const scaleY =
compressionLevel === 0
? data.getFloat32(splatOffset + scaleOffsetBytes + 4, true)
: fromHalf(data.getUint16(splatOffset + scaleOffsetBytes + 2, true));
const scaleZ =
compressionLevel === 0
? data.getFloat32(splatOffset + scaleOffsetBytes + 8, true)
: fromHalf(data.getUint16(splatOffset + scaleOffsetBytes + 4, true));
const quatW =
compressionLevel === 0
? data.getFloat32(splatOffset + rotationOffsetBytes + 0, true)
: fromHalf(
data.getUint16(splatOffset + rotationOffsetBytes + 0, true),
);
const quatX =
compressionLevel === 0
? data.getFloat32(splatOffset + rotationOffsetBytes + 4, true)
: fromHalf(
data.getUint16(splatOffset + rotationOffsetBytes + 2, true),
);
const quatY =
compressionLevel === 0
? data.getFloat32(splatOffset + rotationOffsetBytes + 8, true)
: fromHalf(
data.getUint16(splatOffset + rotationOffsetBytes + 4, true),
);
const quatZ =
compressionLevel === 0
? data.getFloat32(splatOffset + rotationOffsetBytes + 12, true)
: fromHalf(
data.getUint16(splatOffset + rotationOffsetBytes + 6, true),
);
const r = data.getUint8(splatOffset + colorOffsetBytes + 0) / 255;
const g = data.getUint8(splatOffset + colorOffsetBytes + 1) / 255;
const b = data.getUint8(splatOffset + colorOffsetBytes + 2) / 255;
const opacity = data.getUint8(splatOffset + colorOffsetBytes + 3) / 255;
splatCallback(
i,
x,
y,
z,
scaleX,
scaleY,
scaleZ,
quatX,
quatY,
quatZ,
quatW,
opacity,
r,
g,
b,
);
if (sphericalHarmonicsDegree >= 1 && sh1) {
shCallback?.(i, sh1, sh2, sh3);
}
}
sectionBase += storageSizeBytes;
}
}
export function unpackKsplat(fileBytes: Uint8Array): {
packedArray: Uint32Array;
numSplats: number;
extra: Record<string, unknown>;
} {
const HEADER_BYTES = 4096;
const SECTION_BYTES = 1024;
let headerOffset = 0;
const header = new DataView(fileBytes.buffer, headerOffset, HEADER_BYTES);
headerOffset += HEADER_BYTES;
const versionMajor = header.getUint8(0);
const versionMinor = header.getUint8(1);
if (versionMajor !== 0 || versionMinor < 1) {
throw new Error(
`Unsupported .ksplat version: ${versionMajor}.${versionMinor}`,
);
}
const maxSectionCount = header.getUint32(4, true);
// const sectionCount = header.getUint32(8, true);
// const maxSplatCount = header.getUint32(12, true);
const splatCount = header.getUint32(16, true);
const compressionLevel = header.getUint16(20, true);
if (compressionLevel < 0 || compressionLevel > 2) {
throw new Error(`Invalid .ksplat compression level: ${compressionLevel}`);
}
// const sceneCenterX = header.getFloat32(24, true);
// const sceneCenterY = header.getFloat32(28, true);
// const sceneCenterZ = header.getFloat32(32, true);
const minSphericalHarmonicsCoeff = header.getFloat32(36, true) || -1.5;
const maxSphericalHarmonicsCoeff = header.getFloat32(40, true) || 1.5;
const numSplats = splatCount;
const maxSplats = computeMaxSplats(numSplats);
const packedArray = new Uint32Array(maxSplats * 4);
const extra: Record<string, unknown> = {};
let sectionBase = HEADER_BYTES + maxSectionCount * SECTION_BYTES;
for (let section = 0; section < maxSectionCount; ++section) {
const section = new DataView(fileBytes.buffer, headerOffset, SECTION_BYTES);
headerOffset += SECTION_BYTES;
const sectionSplatCount = section.getUint32(0, true);
const sectionMaxSplatCount = section.getUint32(4, true);
const bucketSize = section.getUint32(8, true);
const bucketCount = section.getUint32(12, true);
const bucketBlockSize = section.getFloat32(16, true);
const bucketStorageSizeBytes = section.getUint16(20, true);
const compressionScaleRange =
(section.getUint32(24, true) ||
KSPLAT_COMPRESSION[compressionLevel]?.scaleRange) ??
1;
// const fullBucketCount = section.getUint32(32, true);
const partiallyFilledBucketCount = section.getUint32(36, true);
const bucketsMetaDataSizeBytes = partiallyFilledBucketCount * 4;
const bucketsStorageSizeBytes =
bucketStorageSizeBytes * bucketCount + bucketsMetaDataSizeBytes;
const sphericalHarmonicsDegree = section.getUint16(40, true);
const shComponents =
KSPLAT_SH_DEGREE_TO_COMPONENTS[sphericalHarmonicsDegree];
const {
bytesPerCenter,
bytesPerScale,
bytesPerRotation,
bytesPerColor,
bytesPerSphericalHarmonicsComponent,
scaleOffsetBytes,
rotationOffsetBytes,
colorOffsetBytes,
sphericalHarmonicsOffsetBytes,
} = KSPLAT_COMPRESSION[compressionLevel];
const bytesPerSplat =
bytesPerCenter +
bytesPerScale +
bytesPerRotation +
bytesPerColor +
shComponents * bytesPerSphericalHarmonicsComponent;
const splatDataStorageSizeBytes = bytesPerSplat * sectionMaxSplatCount;
const storageSizeBytes =
splatDataStorageSizeBytes + bucketsStorageSizeBytes;
const sh1Index = [0, 3, 6, 1, 4, 7, 2, 5, 8];
const sh2Index = [
9, 14, 19, 10, 15, 20, 11, 16, 21, 12, 17, 22, 13, 18, 23,
];
const sh3Index = [
24, 31, 38, 25, 32, 39, 26, 33, 40, 27, 34, 41, 28, 35, 42, 29, 36, 43,
30, 37, 44,
];
const sh1 =
sphericalHarmonicsDegree >= 1 ? new Float32Array(3 * 3) : undefined;
const sh2 =
sphericalHarmonicsDegree >= 2 ? new Float32Array(5 * 3) : undefined;
const sh3 =
sphericalHarmonicsDegree >= 3 ? new Float32Array(7 * 3) : undefined;
const compressionScaleFactor = bucketBlockSize / 2 / compressionScaleRange;
const bucketsBase = sectionBase + bucketsMetaDataSizeBytes;
const dataBase = sectionBase + bucketsStorageSizeBytes;
const data = new DataView(
fileBytes.buffer,
dataBase,
splatDataStorageSizeBytes,
);
const bucketArray = new Float32Array(
fileBytes.buffer,
bucketsBase,
bucketCount * 3,
);
function getSh(splatOffset: number, component: number) {
if (compressionLevel === 0) {
return data.getFloat32(
splatOffset + sphericalHarmonicsOffsetBytes + component * 4,
true,
);
}
if (compressionLevel === 1) {
return fromHalf(
data.getUint16(
splatOffset + sphericalHarmonicsOffsetBytes + component * 2,
true,
),
);
}
const t =
data.getUint8(splatOffset + sphericalHarmonicsOffsetBytes + component) /
255;
return (
minSphericalHarmonicsCoeff +
t * (maxSphericalHarmonicsCoeff - minSphericalHarmonicsCoeff)
);
}
for (let i = 0; i < sectionSplatCount; ++i) {
const splatOffset = i * bytesPerSplat;
const bucketIndex = Math.floor(i / bucketSize);
const x =
compressionLevel === 0
? data.getFloat32(splatOffset + 0, true)
: (data.getUint16(splatOffset + 0, true) - compressionScaleRange) *
compressionScaleFactor +
bucketArray[3 * bucketIndex + 0];
const y =
compressionLevel === 0
? data.getFloat32(splatOffset + 4, true)
: (data.getUint16(splatOffset + 2, true) - compressionScaleRange) *
compressionScaleFactor +
bucketArray[3 * bucketIndex + 1];
const z =
compressionLevel === 0
? data.getFloat32(splatOffset + 8, true)
: (data.getUint16(splatOffset + 4, true) - compressionScaleRange) *
compressionScaleFactor +
bucketArray[3 * bucketIndex + 2];
const scaleX =
compressionLevel === 0
? data.getFloat32(splatOffset + scaleOffsetBytes + 0, true)
: fromHalf(data.getUint16(splatOffset + scaleOffsetBytes + 0, true));
const scaleY =
compressionLevel === 0
? data.getFloat32(splatOffset + scaleOffsetBytes + 4, true)
: fromHalf(data.getUint16(splatOffset + scaleOffsetBytes + 2, true));
const scaleZ =
compressionLevel === 0
? data.getFloat32(splatOffset + scaleOffsetBytes + 8, true)
: fromHalf(data.getUint16(splatOffset + scaleOffsetBytes + 4, true));
const quatW =
compressionLevel === 0
? data.getFloat32(splatOffset + rotationOffsetBytes + 0, true)
: fromHalf(
data.getUint16(splatOffset + rotationOffsetBytes + 0, true),
);
const quatX =
compressionLevel === 0
? data.getFloat32(splatOffset + rotationOffsetBytes + 4, true)
: fromHalf(
data.getUint16(splatOffset + rotationOffsetBytes + 2, true),
);
const quatY =
compressionLevel === 0
? data.getFloat32(splatOffset + rotationOffsetBytes + 8, true)
: fromHalf(
data.getUint16(splatOffset + rotationOffsetBytes + 4, true),
);
const quatZ =
compressionLevel === 0
? data.getFloat32(splatOffset + rotationOffsetBytes + 12, true)
: fromHalf(
data.getUint16(splatOffset + rotationOffsetBytes + 6, true),
);
const r = data.getUint8(splatOffset + colorOffsetBytes + 0) / 255;
const g = data.getUint8(splatOffset + colorOffsetBytes + 1) / 255;
const b = data.getUint8(splatOffset + colorOffsetBytes + 2) / 255;
const opacity = data.getUint8(splatOffset + colorOffsetBytes + 3) / 255;
setPackedSplat(
packedArray,
i,
x,
y,
z,
scaleX,
scaleY,
scaleZ,
quatX,
quatY,
quatZ,
quatW,
opacity,
r,
g,
b,
);
if (sphericalHarmonicsDegree >= 1) {
if (sh1) {
if (!extra.sh1) {
extra.sh1 = new Uint32Array(numSplats * 2);
}
for (const [i, key] of sh1Index.entries()) {
sh1[i] = getSh(splatOffset, key);
}
encodeSh1Rgb(extra.sh1 as Uint32Array, i, sh1);
}
if (sh2) {
if (!extra.sh2) {
extra.sh2 = new Uint32Array(numSplats * 4);
}
for (const [i, key] of sh2Index.entries()) {
sh2[i] = getSh(splatOffset, key);
}
encodeSh2Rgb(extra.sh2 as Uint32Array, i, sh2);
}
if (sh3) {
if (!extra.sh3) {
extra.sh3 = new Uint32Array(numSplats * 4);
}
for (const [i, key] of sh3Index.entries()) {
sh3[i] = getSh(splatOffset, key);
}
encodeSh3Rgb(extra.sh3 as Uint32Array, i, sh3);
}
}
}
sectionBase += storageSizeBytes;
}
return { packedArray, numSplats, extra };
}