mirror of
https://github.com/storytold/spark.git
synced 2026-10-09 00:09:53 +00:00
1395 lines
43 KiB
TypeScript
1395 lines
43 KiB
TypeScript
import { Gunzip } from "fflate";
|
||
import * as THREE from "three";
|
||
|
||
// Miscellaneous utility functions for Spark
|
||
|
||
import {
|
||
LN_SCALE_MAX,
|
||
LN_SCALE_MIN,
|
||
SCALE_ZERO,
|
||
SPLAT_TEX_HEIGHT,
|
||
SPLAT_TEX_MIN_HEIGHT,
|
||
SPLAT_TEX_WIDTH,
|
||
} from "./defines.js";
|
||
import { unindent } from "./dyno/base.js";
|
||
|
||
const f32buffer = new Float32Array(1);
|
||
const u32buffer = new Uint32Array(f32buffer.buffer);
|
||
const supportsFloat16Array = "Float16Array" in globalThis;
|
||
const f16buffer = supportsFloat16Array
|
||
? new globalThis["Float16Array" as keyof typeof globalThis](1)
|
||
: null;
|
||
const u16buffer = new Uint16Array(f16buffer?.buffer);
|
||
|
||
// Returns a normalized array of numbers
|
||
export function normalize(vec: number[]) {
|
||
const norm = Math.sqrt(vec.reduce((acc, v) => acc + v * v, 0));
|
||
return vec.map((v) => v / norm);
|
||
}
|
||
|
||
// Reinterpret the bits of a float32 as a uint32
|
||
export function floatBitsToUint(f: number): number {
|
||
f32buffer[0] = f;
|
||
return u32buffer[0];
|
||
}
|
||
|
||
// Reinterpret the bits of a uint32 as a float32
|
||
export function uintBitsToFloat(u: number): number {
|
||
u32buffer[0] = u;
|
||
return f32buffer[0];
|
||
}
|
||
|
||
export const toHalf = supportsFloat16Array ? toHalfNative : toHalfJS;
|
||
export const fromHalf = supportsFloat16Array ? fromHalfNative : fromHalfJS;
|
||
|
||
// Encode a number as a float16, stored as a uint16 number.
|
||
function toHalfNative(f: number): number {
|
||
f16buffer[0] = f;
|
||
return u16buffer[0];
|
||
}
|
||
|
||
// Encode a number as a float16, stored as a uint16 number.
|
||
function toHalfJS(f: number): number {
|
||
// Store the value into the shared Float32 array.
|
||
f32buffer[0] = f;
|
||
const bits = u32buffer[0];
|
||
|
||
// Extract sign (1 bit), exponent (8 bits), and fraction (23 bits)
|
||
const sign = (bits >> 31) & 0x1;
|
||
const exp = (bits >> 23) & 0xff;
|
||
const frac = bits & 0x7fffff;
|
||
const halfSign = sign << 15;
|
||
|
||
// Handle special cases: NaN and Infinity
|
||
if (exp === 0xff) {
|
||
// NaN: set all exponent bits to 1 and some nonzero fraction bits.
|
||
if (frac !== 0) {
|
||
return halfSign | 0x7fff;
|
||
}
|
||
// Infinity
|
||
return halfSign | 0x7c00;
|
||
}
|
||
|
||
// Adjust the exponent from float32 bias (127) to float16 bias (15)
|
||
const newExp = exp - 127 + 15;
|
||
|
||
// Handle overflow: too large to represent in half precision.
|
||
if (newExp >= 0x1f) {
|
||
return halfSign | 0x7c00; // Infinity
|
||
}
|
||
if (newExp <= 0) {
|
||
// Handle subnormals and underflow.
|
||
if (newExp < -10) {
|
||
// Too small: underflows to zero.
|
||
return halfSign;
|
||
}
|
||
// Convert to subnormal: add the implicit leading 1 to the fraction,
|
||
// then shift to align with the half-precision's 10 fraction bits.
|
||
const subFrac = (frac | 0x800000) >> (1 - newExp + 13);
|
||
return halfSign | subFrac;
|
||
}
|
||
|
||
// Normalized half-precision number: shift fraction to fit into 10 bits.
|
||
const halfFrac = frac >> 13;
|
||
return halfSign | (newExp << 10) | halfFrac;
|
||
}
|
||
|
||
// Convert a float16 stored as a uint16 number back to a float32.
|
||
function fromHalfNative(u: number): number {
|
||
u16buffer[0] = u;
|
||
return f16buffer[0];
|
||
}
|
||
|
||
// Convert a float16 stored as a uint16 number back to a float32.
|
||
function fromHalfJS(h: number): number {
|
||
// Extract the sign (1 bit), exponent (5 bits), and fraction (10 bits)
|
||
const sign = (h >> 15) & 0x1;
|
||
const exp = (h >> 10) & 0x1f;
|
||
const frac = h & 0x3ff;
|
||
|
||
let f32bits: number;
|
||
|
||
if (exp === 0) {
|
||
if (frac === 0) {
|
||
// Zero (positive or negative)
|
||
f32bits = sign << 31;
|
||
} else {
|
||
// Subnormal half-precision number.
|
||
// Normalize the subnormal number:
|
||
let mant = frac;
|
||
let e = -14; // For half, the exponent for subnormals is fixed at -14.
|
||
// Shift left until the implicit leading 1 is in place.
|
||
while ((mant & 0x400) === 0) {
|
||
// 0x400 === 1 << 10
|
||
mant <<= 1;
|
||
e--;
|
||
}
|
||
// Remove the leading 1 (which is now implicit)
|
||
mant &= 0x3ff;
|
||
// Convert the half exponent (e) to the 32-bit float exponent:
|
||
const newExp = e + 127; // 32-bit float bias is 127.
|
||
const newFrac = mant << 13; // Align to 23-bit fraction (23 - 10 = 13)
|
||
f32bits = (sign << 31) | (newExp << 23) | newFrac;
|
||
}
|
||
} else if (exp === 0x1f) {
|
||
// Handle special cases for Infinity and NaN.
|
||
if (frac === 0) {
|
||
// Infinity
|
||
f32bits = (sign << 31) | 0x7f800000;
|
||
} else {
|
||
// NaN (we choose a quiet NaN)
|
||
f32bits = (sign << 31) | 0x7fc00000;
|
||
}
|
||
} else {
|
||
// Normalized half-precision number.
|
||
// Adjust exponent from half (bias 15) to float32 (bias 127)
|
||
const newExp = exp - 15 + 127;
|
||
const newFrac = frac << 13;
|
||
f32bits = (sign << 31) | (newExp << 23) | newFrac;
|
||
}
|
||
|
||
// Write the 32-bit bit pattern to the shared buffer,
|
||
// then read it as a float32 to return a JavaScript number.
|
||
u32buffer[0] = f32bits;
|
||
return f32buffer[0];
|
||
}
|
||
|
||
// Convert a number 0..1 to a 0..255 uint
|
||
export function floatToUint8(v: number): number {
|
||
// Converts from 0..1 float to 0..255 uint8
|
||
return Math.max(0, Math.min(255, Math.round(v * 255)));
|
||
}
|
||
|
||
// Convert a number -1..1 to a -127..127 int
|
||
export function floatToSint8(v: number): number {
|
||
// Converts from -1..1 float to -127..127 int8
|
||
return Math.max(-127, Math.min(127, Math.round(v * 127)));
|
||
}
|
||
|
||
// Convert a 0..255 uint to a 0..1 float
|
||
export function Uint8ToFloat(v: number): number {
|
||
// Converts from 0..255 uint8 to 0..1 float
|
||
return v / 255;
|
||
}
|
||
|
||
// Convert a -127..127 int to a -1..1 float
|
||
export function Sint8ToFloat(v: number): number {
|
||
// Converts from -127..127 int8 to -1..1 float
|
||
return v / 127;
|
||
}
|
||
|
||
// A simple utility class for caching a fixed number of items
|
||
export class DataCache {
|
||
// Maximum number of items to cache
|
||
maxItems: number;
|
||
|
||
// Function to fetch data for a key
|
||
asyncFetch: (key: string) => Promise<unknown>;
|
||
|
||
// Array of cached items
|
||
items: { key: string; data: unknown }[];
|
||
|
||
// Create a DataCache with a given function that fetches data not in the cache.
|
||
constructor({
|
||
asyncFetch,
|
||
maxItems = 5,
|
||
}: { asyncFetch: (key: string) => Promise<unknown>; maxItems?: number }) {
|
||
this.asyncFetch = asyncFetch;
|
||
this.maxItems = maxItems;
|
||
this.items = [];
|
||
}
|
||
|
||
// Fetch data for the key, returning cached data if available.
|
||
async getFetch(key: string): Promise<unknown> {
|
||
// Fetches data for a key and caches it, returns cached data if available.
|
||
const index = this.items.findIndex((item) => item.key === key);
|
||
if (index >= 0) {
|
||
// Data exists in our cache, move it to the end of the array
|
||
const item = this.items.splice(index, 1)[0];
|
||
this.items.push(item);
|
||
// Return the cached data
|
||
return item.data;
|
||
}
|
||
|
||
// Fetch the data from the asyncFetch function
|
||
const data = await this.asyncFetch(key);
|
||
// Add the data to the cache
|
||
this.items.push({ key, data });
|
||
// If the cache is too large, remove the oldest accessed item
|
||
while (this.items.length > this.maxItems) {
|
||
this.items.shift();
|
||
}
|
||
// Return the fetched data
|
||
return data;
|
||
}
|
||
}
|
||
|
||
// Like Array.map but for objects
|
||
export function mapObject(
|
||
obj: Record<string, unknown>,
|
||
fn: (value: unknown, key: string) => unknown,
|
||
): Record<string, unknown> {
|
||
// Maps over an object, applying a function to each value and key
|
||
const entries = Object.entries(obj).map(([key, value]) => [
|
||
key,
|
||
fn(value, key),
|
||
]);
|
||
// Returns a new object with the mapped values
|
||
return Object.fromEntries(entries);
|
||
}
|
||
|
||
// Like Array.map().filter() but for objects.
|
||
// The callback fn() should return undefined to filter out the key.
|
||
export function mapFilterObject(
|
||
obj: Record<string, unknown>,
|
||
fn: (value: unknown, key: string) => unknown,
|
||
): Record<string, unknown> {
|
||
// Maps over an object, applying a function to each value and key
|
||
// If no return (or return undefined), the key is not included in the result
|
||
const entries = Object.entries(obj)
|
||
.map(([key, value]) => [key, fn(value, key)])
|
||
.filter(([_, value]) => value !== undefined);
|
||
// Returns a new object with the filtered values
|
||
return Object.fromEntries(entries);
|
||
}
|
||
|
||
// Recursively finds all ArrayBuffers in an object and returns them as an array
|
||
// to use as transferable objects to send between workers.
|
||
export function getArrayBuffers(ctx: unknown): Transferable[] {
|
||
const buffers: ArrayBuffer[] = [];
|
||
const seen = new Set();
|
||
|
||
function traverse(obj: unknown) {
|
||
if (obj && typeof obj === "object" && !seen.has(obj)) {
|
||
seen.add(obj);
|
||
|
||
if (obj instanceof ArrayBuffer) {
|
||
buffers.push(obj);
|
||
} else if (ArrayBuffer.isView(obj)) {
|
||
// Handles TypedArrays and DataView
|
||
buffers.push(obj.buffer);
|
||
} else if (Array.isArray(obj)) {
|
||
obj.forEach(traverse);
|
||
} else {
|
||
Object.values(obj).forEach(traverse);
|
||
}
|
||
}
|
||
}
|
||
|
||
traverse(ctx);
|
||
return buffers;
|
||
}
|
||
|
||
// Create an array of the given size and initialize element with initFunction()
|
||
export function newArray<T>(
|
||
n: number,
|
||
initFunction: (index: number) => T,
|
||
): T[] {
|
||
// Creates a new array and calls a constructor function for each element with index
|
||
return new Array(n).fill(null).map((_, i) => initFunction(i));
|
||
}
|
||
|
||
// A free list that has a pool of items of type T, with callbacks
|
||
// for constructing, disposing, and checking if an item is valid for the given args.
|
||
export class FreeList<T, Args> {
|
||
items: T[];
|
||
allocate: (args: Args) => T;
|
||
dispose?: (item: T) => void;
|
||
valid: (item: T, args: Args) => boolean;
|
||
|
||
constructor({
|
||
// Allocate a new item with the given args
|
||
allocate,
|
||
// Dispose of an item (optional, if GC is enough)
|
||
dispose,
|
||
// Check if an existing item in the list is valid for the given args,
|
||
// allowing you to store heterogeneous items in the list.
|
||
valid,
|
||
}: {
|
||
allocate: (args: Args) => T;
|
||
dispose?: (item: T) => void;
|
||
valid: (item: T, args: Args) => boolean;
|
||
}) {
|
||
this.items = [];
|
||
this.allocate = allocate;
|
||
this.dispose = dispose;
|
||
this.valid = valid;
|
||
}
|
||
|
||
// Allocate a new item from the free list, first checking if a existing item
|
||
// on the freelist is valid for the given args.
|
||
alloc(args: Args): T {
|
||
while (true) {
|
||
const item = this.items.pop();
|
||
if (!item) {
|
||
// No items in the free list, allocate a new one
|
||
break;
|
||
}
|
||
if (this.valid(item, args)) {
|
||
// Found a valid item, return it
|
||
// console.log(`FreeList.alloc(${JSON.stringify(args)}): found valid item. Reusing...`);
|
||
return item;
|
||
}
|
||
// Item isn't valid for our args, dispose of it and try again
|
||
if (this.dispose) {
|
||
// console.log(`FreeList.alloc(${JSON.stringify(args)}): disposing invalid item.`);
|
||
this.dispose(item);
|
||
}
|
||
}
|
||
// console.log(`FreeList.alloc(${JSON.stringify(args)}): allocating new item`);
|
||
return this.allocate(args);
|
||
}
|
||
|
||
free(item: T) {
|
||
// Return item to the free list
|
||
this.items.push(item);
|
||
}
|
||
|
||
disposeAll() {
|
||
// Disposes of all items in the free list
|
||
let item: T | undefined;
|
||
item = this.items.pop();
|
||
while (item) {
|
||
if (this.dispose) {
|
||
this.dispose(item);
|
||
}
|
||
item = this.items.pop();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Encode a PackedSplat as 4 consecutive Uint32 elements in the packedSplats array.
|
||
// The center coordinates x,y,z are encoded as float16, the scales x,y,z as a
|
||
// logarithmic uint8, rotation as three uint8s representing rotation axis and angle,
|
||
// and RGBA as 4xuint8.
|
||
export function setPackedSplat(
|
||
packedSplats: Uint32Array,
|
||
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,
|
||
encoding?: {
|
||
rgbMin?: number;
|
||
rgbMax?: number;
|
||
lnScaleMin?: number;
|
||
lnScaleMax?: number;
|
||
},
|
||
) {
|
||
const rgbMin = encoding?.rgbMin ?? 0.0;
|
||
const rgbMax = encoding?.rgbMax ?? 1.0;
|
||
const rgbRange = rgbMax - rgbMin;
|
||
const uR = floatToUint8((r - rgbMin) / rgbRange);
|
||
const uG = floatToUint8((g - rgbMin) / rgbRange);
|
||
const uB = floatToUint8((b - rgbMin) / rgbRange);
|
||
const uA = floatToUint8(opacity);
|
||
|
||
// Alternate internal encodings commented out below.
|
||
const uQuat = encodeQuatOctXy88R8(
|
||
tempQuaternion.set(quatX, quatY, quatZ, quatW),
|
||
);
|
||
// const uQuat = encodeQuatXyz888(new THREE.Quaternion(quatX, quatY, quatZ, quatW));
|
||
// const uQuat = encodeQuatEulerXyz888(new THREE.Quaternion(quatX, quatY, quatZ, quatW));
|
||
const uQuatX = uQuat & 0xff;
|
||
const uQuatY = (uQuat >>> 8) & 0xff;
|
||
const uQuatZ = (uQuat >>> 16) & 0xff;
|
||
|
||
// Allow scales below LN_SCALE_MIN to be encoded as 0, which signifies a 2DGS
|
||
const lnScaleMin = encoding?.lnScaleMin ?? LN_SCALE_MIN;
|
||
const lnScaleMax = encoding?.lnScaleMax ?? LN_SCALE_MAX;
|
||
const lnScaleScale = 254.0 / (lnScaleMax - lnScaleMin);
|
||
const uScaleX =
|
||
scaleX < SCALE_ZERO
|
||
? 0
|
||
: Math.min(
|
||
255,
|
||
Math.max(
|
||
1,
|
||
Math.round((Math.log(scaleX) - lnScaleMin) * lnScaleScale) + 1,
|
||
),
|
||
);
|
||
const uScaleY =
|
||
scaleY < SCALE_ZERO
|
||
? 0
|
||
: Math.min(
|
||
255,
|
||
Math.max(
|
||
1,
|
||
Math.round((Math.log(scaleY) - lnScaleMin) * lnScaleScale) + 1,
|
||
),
|
||
);
|
||
const uScaleZ =
|
||
scaleZ < SCALE_ZERO
|
||
? 0
|
||
: Math.min(
|
||
255,
|
||
Math.max(
|
||
1,
|
||
Math.round((Math.log(scaleZ) - lnScaleMin) * lnScaleScale) + 1,
|
||
),
|
||
);
|
||
|
||
const uCenterX = toHalf(x);
|
||
const uCenterY = toHalf(y);
|
||
const uCenterZ = toHalf(z);
|
||
|
||
// Encode the splat as 4 consecutive Uint32 elements
|
||
const i4 = index * 4;
|
||
packedSplats[i4] = uR | (uG << 8) | (uB << 16) | (uA << 24);
|
||
packedSplats[i4 + 1] = uCenterX | (uCenterY << 16);
|
||
packedSplats[i4 + 2] = uCenterZ | (uQuatX << 16) | (uQuatY << 24);
|
||
packedSplats[i4 + 3] =
|
||
uScaleX | (uScaleY << 8) | (uScaleZ << 16) | (uQuatZ << 24);
|
||
}
|
||
|
||
// Encode the center coordinates x,y,z in the packedSplats Uint32Array,
|
||
// leaving all other fields as is.
|
||
export function setPackedSplatCenter(
|
||
packedSplats: Uint32Array,
|
||
index: number,
|
||
x: number,
|
||
y: number,
|
||
z: number,
|
||
) {
|
||
const uCenterX = toHalf(x);
|
||
const uCenterY = toHalf(y);
|
||
const uCenterZ = toHalf(z);
|
||
|
||
const i4 = index * 4;
|
||
packedSplats[i4 + 1] = uCenterX | (uCenterY << 16);
|
||
packedSplats[i4 + 2] = uCenterZ | (packedSplats[i4 + 2] & 0xffff0000);
|
||
}
|
||
|
||
// Encode the scales x,y,z in the packedSplats Uint32Array, leaving all other fields as is.
|
||
export function setPackedSplatScales(
|
||
packedSplats: Uint32Array,
|
||
index: number,
|
||
scaleX: number,
|
||
scaleY: number,
|
||
scaleZ: number,
|
||
encoding?: {
|
||
lnScaleMin?: number;
|
||
lnScaleMax?: number;
|
||
},
|
||
) {
|
||
// Allow scales below LN_SCALE_MIN to be encoded as 0, which signifies a 2DGS
|
||
const lnScaleMin = encoding?.lnScaleMin ?? LN_SCALE_MIN;
|
||
const lnScaleMax = encoding?.lnScaleMax ?? LN_SCALE_MAX;
|
||
const lnScaleScale = 254.0 / (lnScaleMax - lnScaleMin);
|
||
const uScaleX =
|
||
scaleX < SCALE_ZERO
|
||
? 0
|
||
: Math.min(
|
||
255,
|
||
Math.max(
|
||
1,
|
||
Math.round((Math.log(scaleX) - lnScaleMin) * lnScaleScale) + 1,
|
||
),
|
||
);
|
||
const uScaleY =
|
||
scaleY < SCALE_ZERO
|
||
? 0
|
||
: Math.min(
|
||
255,
|
||
Math.max(
|
||
1,
|
||
Math.round((Math.log(scaleY) - lnScaleMin) * lnScaleScale) + 1,
|
||
),
|
||
);
|
||
const uScaleZ =
|
||
scaleZ < SCALE_ZERO
|
||
? 0
|
||
: Math.min(
|
||
255,
|
||
Math.max(
|
||
1,
|
||
Math.round((Math.log(scaleZ) - lnScaleMin) * lnScaleScale) + 1,
|
||
),
|
||
);
|
||
|
||
const i4 = index * 4;
|
||
packedSplats[i4 + 3] =
|
||
uScaleX |
|
||
(uScaleY << 8) |
|
||
(uScaleZ << 16) |
|
||
(packedSplats[i4 + 3] & 0xff000000);
|
||
}
|
||
|
||
// Temporary storage used in `encodeQuatOCtXy88R8` and `decodeQuatOctXy88R8` to
|
||
// avoid allocation new Quaternions and Vector3 instances.
|
||
const tempQuaternion = new THREE.Quaternion();
|
||
|
||
// Encode the rotation quatX, quatY, quatZ, quatW in the packedSplats Uint32Array,
|
||
// leaving all other fields as is.
|
||
export function setPackedSplatQuat(
|
||
packedSplats: Uint32Array,
|
||
index: number,
|
||
quatX: number,
|
||
quatY: number,
|
||
quatZ: number,
|
||
quatW: number,
|
||
) {
|
||
const uQuat = encodeQuatOctXy88R8(
|
||
tempQuaternion.set(quatX, quatY, quatZ, quatW),
|
||
);
|
||
// const uQuat = encodeQuatXyz888(new THREE.Quaternion(quatX, quatY, quatZ, quatW));
|
||
// const uQuat = encodeQuatEulerXyz888(new THREE.Quaternion(quatX, quatY, quatZ, quatW));
|
||
const uQuatX = uQuat & 0xff;
|
||
const uQuatY = (uQuat >>> 8) & 0xff;
|
||
const uQuatZ = (uQuat >>> 16) & 0xff;
|
||
|
||
const i4 = index * 4;
|
||
packedSplats[i4 + 2] =
|
||
(packedSplats[i4 + 2] & 0x0000ffff) | (uQuatX << 16) | (uQuatY << 24);
|
||
packedSplats[i4 + 3] = (packedSplats[i4 + 3] & 0x00ffffff) | (uQuatZ << 24);
|
||
}
|
||
|
||
// Encode the RGBA color in the packedSplats Uint32Array, leaving other fields alone.
|
||
export function setPackedSplatRgba(
|
||
packedSplats: Uint32Array,
|
||
index: number,
|
||
r: number,
|
||
g: number,
|
||
b: number,
|
||
a: number,
|
||
encoding?: {
|
||
rgbMin?: number;
|
||
rgbMax?: number;
|
||
},
|
||
) {
|
||
const rgbMin = encoding?.rgbMin ?? 0.0;
|
||
const rgbMax = encoding?.rgbMax ?? 1.0;
|
||
const rgbRange = rgbMax - rgbMin;
|
||
const uR = floatToUint8((r - rgbMin) / rgbRange);
|
||
const uG = floatToUint8((g - rgbMin) / rgbRange);
|
||
const uB = floatToUint8((b - rgbMin) / rgbRange);
|
||
const uA = floatToUint8(a);
|
||
const i4 = index * 4;
|
||
packedSplats[i4] = uR | (uG << 8) | (uB << 16) | (uA << 24);
|
||
}
|
||
|
||
// Encode the RGB color in the packedSplats Uint32Array, leaving other fields alone.
|
||
export function setPackedSplatRgb(
|
||
packedSplats: Uint32Array,
|
||
index: number,
|
||
r: number,
|
||
g: number,
|
||
b: number,
|
||
encoding?: {
|
||
rgbMin?: number;
|
||
rgbMax?: number;
|
||
},
|
||
) {
|
||
const rgbMin = encoding?.rgbMin ?? 0.0;
|
||
const rgbMax = encoding?.rgbMax ?? 1.0;
|
||
const rgbRange = rgbMax - rgbMin;
|
||
const uR = floatToUint8((r - rgbMin) / rgbRange);
|
||
const uG = floatToUint8((g - rgbMin) / rgbRange);
|
||
const uB = floatToUint8((b - rgbMin) / rgbRange);
|
||
|
||
const i4 = index * 4;
|
||
packedSplats[i4] =
|
||
uR | (uG << 8) | (uB << 16) | (packedSplats[i4] & 0xff000000);
|
||
}
|
||
|
||
// Encode the opacity in the packedSplats Uint32Array, leaving other fields alone.
|
||
export function setPackedSplatOpacity(
|
||
packedSplats: Uint32Array,
|
||
index: number,
|
||
opacity: number,
|
||
) {
|
||
const uA = floatToUint8(opacity);
|
||
|
||
const i4 = index * 4;
|
||
packedSplats[i4] = (packedSplats[i4] & 0x00ffffff) | (uA << 24);
|
||
}
|
||
|
||
const packedCenter = new THREE.Vector3();
|
||
const packedScales = new THREE.Vector3();
|
||
const packedQuaternion = new THREE.Quaternion();
|
||
const packedColor = new THREE.Color();
|
||
const packedFields = {
|
||
center: packedCenter,
|
||
scales: packedScales,
|
||
quaternion: packedQuaternion,
|
||
color: packedColor,
|
||
opacity: 0.0,
|
||
};
|
||
|
||
// Unpack all components of a PackedSplat from the packedSplats Uint32Array into
|
||
// THREE.js vector objects. The returned objects will be reused each call.
|
||
export function unpackSplat(
|
||
packedSplats: Uint32Array,
|
||
index: number,
|
||
encoding?: {
|
||
rgbMin?: number;
|
||
rgbMax?: number;
|
||
lnScaleMin?: number;
|
||
lnScaleMax?: number;
|
||
},
|
||
): {
|
||
center: THREE.Vector3;
|
||
scales: THREE.Vector3;
|
||
quaternion: THREE.Quaternion;
|
||
color: THREE.Color;
|
||
opacity: number;
|
||
} {
|
||
// Returns a static object which is reused each time
|
||
const result = packedFields;
|
||
|
||
const i4 = index * 4;
|
||
const word0 = packedSplats[i4];
|
||
const word1 = packedSplats[i4 + 1];
|
||
const word2 = packedSplats[i4 + 2];
|
||
const word3 = packedSplats[i4 + 3];
|
||
|
||
const rgbMin = encoding?.rgbMin ?? 0.0;
|
||
const rgbMax = encoding?.rgbMax ?? 1.0;
|
||
const rgbRange = rgbMax - rgbMin;
|
||
result.color.set(
|
||
rgbMin + ((word0 & 0xff) / 255) * rgbRange,
|
||
rgbMin + (((word0 >>> 8) & 0xff) / 255) * rgbRange,
|
||
rgbMin + (((word0 >>> 16) & 0xff) / 255) * rgbRange,
|
||
);
|
||
result.opacity = ((word0 >>> 24) & 0xff) / 255;
|
||
result.center.set(
|
||
fromHalf(word1 & 0xffff),
|
||
fromHalf((word1 >>> 16) & 0xffff),
|
||
fromHalf(word2 & 0xffff),
|
||
);
|
||
|
||
const lnScaleMin = encoding?.lnScaleMin ?? LN_SCALE_MIN;
|
||
const lnScaleMax = encoding?.lnScaleMax ?? LN_SCALE_MAX;
|
||
const lnScaleScale = (lnScaleMax - lnScaleMin) / 254.0;
|
||
const uScalesX = word3 & 0xff;
|
||
result.scales.x =
|
||
uScalesX === 0 ? 0.0 : Math.exp(lnScaleMin + (uScalesX - 1) * lnScaleScale);
|
||
const uScalesY = (word3 >>> 8) & 0xff;
|
||
result.scales.y =
|
||
uScalesY === 0 ? 0.0 : Math.exp(lnScaleMin + (uScalesY - 1) * lnScaleScale);
|
||
const uScalesZ = (word3 >>> 16) & 0xff;
|
||
result.scales.z =
|
||
uScalesZ === 0 ? 0.0 : Math.exp(lnScaleMin + (uScalesZ - 1) * lnScaleScale);
|
||
|
||
const uQuat = ((word2 >>> 16) & 0xffff) | ((word3 >>> 8) & 0xff0000);
|
||
decodeQuatOctXy88R8(uQuat, result.quaternion);
|
||
// decodeQuatXyz888(uQuat, result.quaternion);
|
||
// decodeQuatEulerXyz888(uQuat, result.quaternion);
|
||
|
||
return result;
|
||
}
|
||
|
||
// Compute a texture array size that is large enough to fit numSplats. The most
|
||
// common 2D texture size in WebGL2 is 4096x4096 which only allows for 16M splats,
|
||
// so Spark stores Gsplat data in a 2D texture array, which most platforms support
|
||
// up to 2048x2048x2048 = 8G splats. Allocations that fit within a single 2D texture
|
||
// array layer will be rounded up to fill an entire texture row. Once a texture
|
||
// array layer is filled, the allocation will be rounded up to fill an entire layer.
|
||
// This is done so the entire set of splats can be covered by min/max coords across
|
||
// each dimension.
|
||
export function getTextureSize(numSplats: number): {
|
||
width: number;
|
||
height: number;
|
||
depth: number;
|
||
maxSplats: number;
|
||
} {
|
||
// Compute a texture array size that is large enough to fit numSplats.
|
||
// The width is always 2048, the height sized to fit the splats but no larger than 2048.
|
||
// The depth is the number of layers needed to fit the splats.
|
||
// maxSplats is computed as the new total available splats that can be stored.
|
||
const width = SPLAT_TEX_WIDTH;
|
||
const height = Math.max(
|
||
SPLAT_TEX_MIN_HEIGHT,
|
||
Math.min(SPLAT_TEX_HEIGHT, Math.ceil(numSplats / width)),
|
||
);
|
||
const depth = Math.ceil(numSplats / (width * height));
|
||
const maxSplats = width * height * depth;
|
||
return { width, height, depth, maxSplats };
|
||
}
|
||
|
||
export function computeMaxSplats(numSplats: number): number {
|
||
// Compute the size of a Gsplat array texture (2048x2048xD) that can fit
|
||
// numSplats splats, and return the total number of splats that can be stored
|
||
// in such a texture.
|
||
const width = SPLAT_TEX_WIDTH;
|
||
const height = Math.max(
|
||
SPLAT_TEX_MIN_HEIGHT,
|
||
Math.min(SPLAT_TEX_HEIGHT, Math.ceil(numSplats / width)),
|
||
);
|
||
const depth = Math.ceil(numSplats / (width * height));
|
||
return width * height * depth;
|
||
}
|
||
|
||
// Heuristic function to determine if we are running on a mobile device.
|
||
export function isMobile(): boolean {
|
||
if (navigator.maxTouchPoints > 0) {
|
||
// Touch-enabled device, assume it's mobile
|
||
return true;
|
||
}
|
||
return /Mobi|Android|iPhone|iPad|iPod|Opera Mini|IEMobile/.test(
|
||
navigator.userAgent,
|
||
);
|
||
}
|
||
|
||
// Heuristic function to determine if we are running on an Android device.
|
||
// (does not include Oculus Quest)
|
||
export function isAndroid(): boolean {
|
||
return /Android/.test(navigator.userAgent);
|
||
}
|
||
|
||
// Heuristic function to determine if we are running on an Oculus Quest device.
|
||
export function isOculus(): boolean {
|
||
return /Oculus/.test(navigator.userAgent);
|
||
}
|
||
|
||
// Take an array of RGBA8 encoded pixels and flip them vertically in-place.
|
||
// This is useful for converting between top-left and bottom-left coordinate systems
|
||
// in standard 2D images vs WebGL2.
|
||
export function flipPixels(
|
||
pixels: Uint8Array,
|
||
width: number,
|
||
height: number,
|
||
): Uint8Array {
|
||
// Flips pixels vertically in-place, returns original array.
|
||
const tempLine = new Uint8Array(width * 4);
|
||
|
||
// Only need to process half the height since we're swapping
|
||
for (let y = 0; y < height / 2; y++) {
|
||
const topOffset = y * width * 4;
|
||
const bottomOffset = (height - 1 - y) * width * 4;
|
||
|
||
// Save top line to temp buffer
|
||
tempLine.set(pixels.subarray(topOffset, topOffset + width * 4));
|
||
// Move bottom line to top
|
||
pixels.set(
|
||
pixels.subarray(bottomOffset, bottomOffset + width * 4),
|
||
topOffset,
|
||
);
|
||
// Move saved top line to bottom
|
||
pixels.set(tempLine, bottomOffset);
|
||
}
|
||
return pixels;
|
||
}
|
||
|
||
// Utility to take an array of RGBA8 encoded pixels and convert them to a
|
||
// PNG-encoded image data URL that can be downloaded to the client.
|
||
export function pixelsToPngUrl(
|
||
pixels: Uint8Array,
|
||
width: number,
|
||
height: number,
|
||
): string {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) {
|
||
throw new Error("Can't get 2d context");
|
||
}
|
||
const imageData = ctx.createImageData(width, height);
|
||
imageData.data.set(pixels);
|
||
ctx.putImageData(imageData, 0, 0);
|
||
return canvas.toDataURL("image/png");
|
||
}
|
||
|
||
// Manually clone a THREE.Clock object.
|
||
export function cloneClock(clock: THREE.Clock): THREE.Clock {
|
||
const newClock = new THREE.Clock(clock.autoStart);
|
||
newClock.startTime = clock.startTime;
|
||
newClock.oldTime = clock.oldTime;
|
||
newClock.elapsedTime = clock.elapsedTime;
|
||
newClock.running = clock.running;
|
||
return newClock;
|
||
}
|
||
|
||
// Utility to filter out an undefined values from an object.
|
||
export function omitUndefined<T extends object>(obj: T): Partial<T> {
|
||
return Object.fromEntries(
|
||
Object.entries(obj).filter(([_, value]) => value !== undefined),
|
||
) as Partial<T>;
|
||
}
|
||
|
||
// "Identity" vertex shader that just passes through the position.
|
||
export const IDENT_VERTEX_SHADER = unindent(`
|
||
precision highp float;
|
||
|
||
in vec3 position;
|
||
|
||
void main() {
|
||
gl_Position = vec4(position.xy, 0.0, 1.0);
|
||
}
|
||
`);
|
||
|
||
// Returns the average position of an array of THREE.Vector3.
|
||
export function averagePositions(positions: THREE.Vector3[]): THREE.Vector3 {
|
||
const sum = new THREE.Vector3();
|
||
for (const position of positions) {
|
||
sum.add(position);
|
||
}
|
||
return sum.divideScalar(positions.length);
|
||
}
|
||
|
||
// Returns an "average" of an array of THREE.Quaternion objects.
|
||
// Note that this is not a spherical lerp between quaternions but
|
||
// rather an arithmetic mean that is normalized to unit length.
|
||
export function averageQuaternions(
|
||
quaternions: THREE.Quaternion[],
|
||
): THREE.Quaternion {
|
||
if (quaternions.length === 0) {
|
||
return new THREE.Quaternion();
|
||
}
|
||
const sum = quaternions[0].clone();
|
||
for (let i = 1; i < quaternions.length; i++) {
|
||
if (quaternions[i].dot(quaternions[0]) < 0.0) {
|
||
sum.x -= quaternions[i].x;
|
||
sum.y -= quaternions[i].y;
|
||
sum.z -= quaternions[i].z;
|
||
sum.w -= quaternions[i].w;
|
||
} else {
|
||
sum.x += quaternions[i].x;
|
||
sum.y += quaternions[i].y;
|
||
sum.z += quaternions[i].z;
|
||
sum.w += quaternions[i].w;
|
||
}
|
||
}
|
||
return sum.normalize();
|
||
}
|
||
|
||
// Compare two coordinates given by matrix1 and matrix2, returning the distance
|
||
// between their origins and the "coincidence" of their orientations, defined
|
||
// as the dot product of their "-z" axes.
|
||
export function coinciDist(matrix1: THREE.Matrix4, matrix2: THREE.Matrix4) {
|
||
const origin1 = new THREE.Vector3(0, 0, 0).applyMatrix4(matrix1);
|
||
const origin2 = new THREE.Vector3(0, 0, 0).applyMatrix4(matrix2);
|
||
const direction1 = new THREE.Vector3(0, 0, -1)
|
||
.applyMatrix4(matrix1)
|
||
.sub(origin1)
|
||
.normalize();
|
||
const direction2 = new THREE.Vector3(0, 0, -1)
|
||
.applyMatrix4(matrix2)
|
||
.sub(origin2)
|
||
.normalize();
|
||
|
||
const distance = origin1.distanceTo(origin2);
|
||
const coincidence = direction1.dot(direction2);
|
||
return { distance, coincidence };
|
||
}
|
||
|
||
// Utility function that returns whether two coordinate system origins
|
||
// given by matrix1 and matrix2 are within a certain maxDistance of each other.
|
||
export function withinDist({
|
||
matrix1,
|
||
matrix2,
|
||
maxDistance,
|
||
}: {
|
||
matrix1: THREE.Matrix4;
|
||
matrix2: THREE.Matrix4;
|
||
maxDistance: number;
|
||
}): boolean {
|
||
const origin1 = new THREE.Vector3(0, 0, 0).applyMatrix4(matrix1);
|
||
const origin2 = new THREE.Vector3(0, 0, 0).applyMatrix4(matrix2);
|
||
return origin1.distanceTo(origin2) <= maxDistance;
|
||
}
|
||
|
||
// Utility function that returns whether two coordinate systems are "close"
|
||
// to each other, defined by a maxDistance and a minCoincidence.
|
||
export function withinCoinciDist({
|
||
matrix1,
|
||
matrix2,
|
||
maxDistance,
|
||
minCoincidence,
|
||
}: {
|
||
matrix1: THREE.Matrix4;
|
||
matrix2: THREE.Matrix4;
|
||
maxDistance: number;
|
||
minCoincidence?: number;
|
||
}): boolean {
|
||
const { distance, coincidence } = coinciDist(matrix1, matrix2);
|
||
return (
|
||
distance <= maxDistance &&
|
||
(minCoincidence == null || coincidence >= minCoincidence)
|
||
);
|
||
}
|
||
|
||
// Compare two coordinate systems given by matrix1 and matrix2, returning the
|
||
// distance between their origins and the "coorientation" of their orientations,
|
||
// define as the dot product of their quaternion transforms (flipping their
|
||
// orientation to be on the same hemisphere if necessary).
|
||
export function coorientDist(matrix1: THREE.Matrix4, matrix2: THREE.Matrix4) {
|
||
const [origin1, rotate1] = [new THREE.Vector3(), new THREE.Quaternion()];
|
||
const [origin2, rotate2] = [new THREE.Vector3(), new THREE.Quaternion()];
|
||
matrix1.decompose(origin1, rotate1, new THREE.Vector3());
|
||
matrix2.decompose(origin2, rotate2, new THREE.Vector3());
|
||
|
||
const distance = origin1.distanceTo(origin2);
|
||
const coorient = Math.abs(rotate1.dot(rotate2));
|
||
return { distance, coorient };
|
||
}
|
||
|
||
// Utility function that returns whether two coordinate systems are "close"
|
||
// to each other, defined a maxDistance and a minCoorient.
|
||
export function withinCoorientDist({
|
||
matrix1,
|
||
matrix2,
|
||
maxDistance,
|
||
minCoorient,
|
||
}: {
|
||
matrix1: THREE.Matrix4;
|
||
matrix2: THREE.Matrix4;
|
||
maxDistance: number;
|
||
minCoorient?: number;
|
||
}): boolean {
|
||
const { distance, coorient } = coorientDist(matrix1, matrix2);
|
||
return (
|
||
distance <= maxDistance && (minCoorient == null || coorient >= minCoorient)
|
||
);
|
||
}
|
||
|
||
// Like Math.sign but with a custom epsilon value.
|
||
export function epsilonSign(value: number, epsilon = 0.001): number {
|
||
if (Math.abs(value) < epsilon) {
|
||
return 0;
|
||
}
|
||
return Math.sign(value);
|
||
}
|
||
|
||
// Encode a THREE.Quaternion into a 24-bit integer, converting the xyz coordinates
|
||
// to signed 8-bit integers (w can be derived from xyz), and flipping the sign
|
||
// of the quaternion if necessary to make this possible (q == -q for quaternions).
|
||
export function encodeQuatXyz888(q: THREE.Quaternion): number {
|
||
const negQuat = q.w < 0.0;
|
||
const iQuatX = floatToSint8(negQuat ? -q.x : q.x);
|
||
const iQuatY = floatToSint8(negQuat ? -q.y : q.y);
|
||
const iQuatZ = floatToSint8(negQuat ? -q.z : q.z);
|
||
const uQuatX = iQuatX & 0xff;
|
||
const uQuatY = iQuatY & 0xff;
|
||
const uQuatZ = iQuatZ & 0xff;
|
||
return uQuatX | (uQuatY << 8) | (uQuatZ << 16);
|
||
}
|
||
|
||
// Decode a 24-bit integer of the quaternion's xyz coordinates into a THREE.Quaternion.
|
||
export function decodeQuatXyz888(
|
||
encoded: number,
|
||
out: THREE.Quaternion,
|
||
): THREE.Quaternion {
|
||
const iQuatX = (encoded << 24) >> 24;
|
||
const iQuatY = (encoded << 16) >> 24;
|
||
const iQuatZ = (encoded << 8) >> 24;
|
||
out.set(iQuatX / 127.0, iQuatY / 127.0, iQuatZ / 127.0, 0.0);
|
||
const dotSelf = out.x * out.x + out.y * out.y + out.z * out.z;
|
||
out.w = Math.sqrt(Math.max(0.0, 1.0 - dotSelf));
|
||
return out;
|
||
}
|
||
|
||
// Temporary storage used in `encodeQuatOCtXy88R8` and `decodeQuatOctXy88R8` to
|
||
// avoid allocation new Quaternions and Vector3 instances.
|
||
const tempNormalizedQuaternion = new THREE.Quaternion();
|
||
const tempAxis = new THREE.Vector3();
|
||
|
||
/**
|
||
* Encodes a THREE.Quaternion into a 24‐bit integer.
|
||
*
|
||
* Bit layout (LSB → MSB):
|
||
* - Bits 0–7: quantized U (8 bits)
|
||
* - Bits 8–15: quantized V (8 bits)
|
||
* - Bits 16–23: quantized angle θ (8 bits) from [0,π]
|
||
*
|
||
* This version uses folded octahedral mapping (all inline).
|
||
*/
|
||
export function encodeQuatOctXy88R8(q: THREE.Quaternion): number {
|
||
// Force the minimal representation (q.w >= 0)
|
||
const qnorm = tempNormalizedQuaternion.copy(q).normalize();
|
||
if (qnorm.w < 0) {
|
||
qnorm.set(-qnorm.x, -qnorm.y, -qnorm.z, -qnorm.w);
|
||
}
|
||
// Compute the rotation angle θ in [0, π]
|
||
const theta = 2 * Math.acos(qnorm.w);
|
||
// Recover the rotation axis (default to (1,0,0) for near-zero rotation)
|
||
const xyz_norm = Math.sqrt(
|
||
qnorm.x * qnorm.x + qnorm.y * qnorm.y + qnorm.z * qnorm.z,
|
||
);
|
||
const axis =
|
||
xyz_norm < 1e-6
|
||
? tempAxis.set(1, 0, 0)
|
||
: tempAxis.set(qnorm.x, qnorm.y, qnorm.z).divideScalar(xyz_norm);
|
||
// const foldAxis = (axis.z < 0);
|
||
|
||
// --- Folded Octahedral Mapping (inline) ---
|
||
// Compute p = (axis.x, axis.y) / (|axis.x|+|axis.y|+|axis.z|)
|
||
const sum = Math.abs(axis.x) + Math.abs(axis.y) + Math.abs(axis.z);
|
||
let p_x = axis.x / sum;
|
||
let p_y = axis.y / sum;
|
||
// Fold the lower hemisphere.
|
||
if (axis.z < 0) {
|
||
const tmp = p_x;
|
||
p_x = (1 - Math.abs(p_y)) * (p_x >= 0 ? 1 : -1);
|
||
p_y = (1 - Math.abs(tmp)) * (p_y >= 0 ? 1 : -1);
|
||
}
|
||
// Remap from [-1,1] to [0,1]
|
||
const u_f = p_x * 0.5 + 0.5;
|
||
const v_f = p_y * 0.5 + 0.5;
|
||
// Quantize to 7 bits (0..127)
|
||
const quantU = Math.round(u_f * 255);
|
||
const quantV = Math.round(v_f * 255);
|
||
// --- Angle Quantization: Quantize θ ∈ [0,π] to 10 bits (0..1023) ---
|
||
const angleInt = Math.round(theta * (255 / Math.PI));
|
||
|
||
// Pack into 24 bits: bits [0–7]: quantU, [8–15]: quantV, [16–23]: angleInt.
|
||
return (angleInt << 16) | (quantV << 8) | quantU;
|
||
}
|
||
|
||
/**
|
||
* Decodes a 24‐bit encoded quaternion (packed in a number) back to a THREE.Quaternion.
|
||
*
|
||
* Assumes the same bit layout as in encodeQuatOctXy88R8.
|
||
*/
|
||
export function decodeQuatOctXy88R8(
|
||
encoded: number,
|
||
out: THREE.Quaternion,
|
||
): THREE.Quaternion {
|
||
// Extract 8‐bit quantU and quantV, and 8‐bit angleInt.
|
||
const quantU = encoded & 0xff; // bits 0–7
|
||
const quantV = (encoded >>> 8) & 0xff; // bits 8–15
|
||
const angleInt = (encoded >>> 16) & 0xff; // bits 16–23
|
||
|
||
// Recover u and v in [0,1] then map to [-1,1]
|
||
const u_f = quantU / 255;
|
||
const v_f = quantV / 255;
|
||
let f_x = (u_f - 0.5) * 2;
|
||
let f_y = (v_f - 0.5) * 2;
|
||
// Inverse folded mapping: recover z from the constraint |p_x|+|p_y|+z = 1.
|
||
const f_z = 1 - (Math.abs(f_x) + Math.abs(f_y));
|
||
const t = Math.max(-f_z, 0);
|
||
f_x += f_x >= 0 ? -t : t;
|
||
f_y += f_y >= 0 ? -t : t;
|
||
const axis = tempAxis.set(f_x, f_y, f_z).normalize();
|
||
|
||
// Decode the angle: θ ∈ [0,π]
|
||
const theta = (angleInt / 255) * Math.PI;
|
||
const halfTheta = theta * 0.5;
|
||
const s = Math.sin(halfTheta);
|
||
const w = Math.cos(halfTheta);
|
||
// Reconstruct the quaternion from axis-angle: (axis * sin(θ/2), cos(θ/2))
|
||
out.set(axis.x * s, axis.y * s, axis.z * s, w);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Encodes a THREE.Quaternion into a 24‑bit unsigned integer
|
||
* by converting it to Euler angles (roll, pitch, yaw).
|
||
* The Euler angles are assumed to be in radians in the range [-π, π].
|
||
* Each angle is normalized to [0,1] and quantized to 8 bits.
|
||
* Bit layout (LSB→MSB):
|
||
* - Bits 0–7: roll (quantized)
|
||
* - Bits 8–15: pitch (quantized)
|
||
* - Bits 16–23: yaw (quantized)
|
||
*/
|
||
export function encodeQuatEulerXyz888(q: THREE.Quaternion): number {
|
||
// Normalize quaternion to ensure a proper rotation.
|
||
const qNorm = q.clone().normalize();
|
||
|
||
// Tait–Bryan angles (roll, pitch, yaw)
|
||
const sinr_cosp = 2.0 * (qNorm.w * qNorm.x + qNorm.y * qNorm.z);
|
||
const cosr_cosp = 1.0 - 2.0 * (qNorm.x * qNorm.x + qNorm.y * qNorm.y);
|
||
const roll = Math.atan2(sinr_cosp, cosr_cosp);
|
||
|
||
const sinp = 2.0 * (qNorm.w * qNorm.y - qNorm.z * qNorm.x);
|
||
const pitch =
|
||
Math.abs(sinp) >= 1.0 ? Math.sign(sinp) * (Math.PI / 2) : Math.asin(sinp);
|
||
|
||
const siny_cosp = 2.0 * (qNorm.w * qNorm.z + qNorm.x * qNorm.y);
|
||
const cosy_cosp = 1.0 - 2.0 * (qNorm.y * qNorm.y + qNorm.z * qNorm.z);
|
||
const yaw = Math.atan2(siny_cosp, cosy_cosp);
|
||
|
||
// Map each angle from [-π, π] to [0, 1]
|
||
const normRoll = (roll + Math.PI) / (2 * Math.PI);
|
||
const normPitch = (pitch + Math.PI) / (2 * Math.PI);
|
||
const normYaw = (yaw + Math.PI) / (2 * Math.PI);
|
||
|
||
// Quantize to 8 bits (0 to 255)
|
||
const rollQ = Math.round(normRoll * 255);
|
||
const pitchQ = Math.round(normPitch * 255);
|
||
const yawQ = Math.round(normYaw * 255);
|
||
|
||
// Pack into a 24-bit unsigned integer:
|
||
// Bits 0–7: rollQ, Bits 8–15: pitchQ, Bits 16–23: yawQ.
|
||
return (yawQ << 16) | (pitchQ << 8) | rollQ;
|
||
}
|
||
|
||
/**
|
||
* Decodes a 24‑bit unsigned integer into a THREE.Quaternion
|
||
* by unpacking three 8‑bit values (roll, pitch, yaw) in the range [0,255]
|
||
* and then converting them back to Euler angles in [-π, π] and to a quaternion.
|
||
*/
|
||
export function decodeQuatEulerXyz888(
|
||
encoded: number,
|
||
out: THREE.Quaternion,
|
||
): THREE.Quaternion {
|
||
// Unpack 8‑bit values.
|
||
const rollQ = encoded & 0xff;
|
||
const pitchQ = (encoded >>> 8) & 0xff;
|
||
const yawQ = (encoded >>> 16) & 0xff;
|
||
|
||
// Convert quantized values back to normalized [0,1] values.
|
||
const normRoll = rollQ / 255;
|
||
const normPitch = pitchQ / 255;
|
||
const normYaw = yawQ / 255;
|
||
|
||
// Map from [0,1] to [-π, π]
|
||
const roll = normRoll * (2 * Math.PI) - Math.PI;
|
||
const pitch = normPitch * (2 * Math.PI) - Math.PI;
|
||
const yaw = normYaw * (2 * Math.PI) - Math.PI;
|
||
|
||
// Convert Euler angles to quaternion (Tait–Bryan: roll, pitch, yaw).
|
||
const cr = Math.cos(roll * 0.5);
|
||
const sr = Math.sin(roll * 0.5);
|
||
const cp = Math.cos(pitch * 0.5);
|
||
const sp = Math.sin(pitch * 0.5);
|
||
const cy = Math.cos(yaw * 0.5);
|
||
const sy = Math.sin(yaw * 0.5);
|
||
|
||
out.w = cr * cp * cy + sr * sp * sy;
|
||
out.x = sr * cp * cy - cr * sp * sy;
|
||
out.y = cr * sp * cy + sr * cp * sy;
|
||
out.z = cr * cp * sy - sr * sp * cy;
|
||
out.normalize();
|
||
return out;
|
||
}
|
||
|
||
// Pack four signed 8-bit values into a single uint32.
|
||
function packSint8Bytes(
|
||
b0: number,
|
||
b1: number,
|
||
b2: number,
|
||
b3: number,
|
||
): number {
|
||
const clampedB0 = Math.max(-127, Math.min(127, b0 * 127));
|
||
const clampedB1 = Math.max(-127, Math.min(127, b1 * 127));
|
||
const clampedB2 = Math.max(-127, Math.min(127, b2 * 127));
|
||
const clampedB3 = Math.max(-127, Math.min(127, b3 * 127));
|
||
return (
|
||
(clampedB0 & 0xff) |
|
||
((clampedB1 & 0xff) << 8) |
|
||
((clampedB2 & 0xff) << 16) |
|
||
((clampedB3 & 0xff) << 24)
|
||
);
|
||
}
|
||
|
||
// Encode an array of 9 signed RGB SH1 coefficients (clamped to [-1,1]) into
|
||
// a pair of uint32 values, where each coefficient is stored as a sint7
|
||
export function encodeSh1Rgb(
|
||
sh1Array: Uint32Array,
|
||
index: number,
|
||
sh1Rgb: Float32Array,
|
||
encoding?: {
|
||
sh1Min?: number;
|
||
sh1Max?: number;
|
||
},
|
||
) {
|
||
const sh1Min = encoding?.sh1Min ?? -1;
|
||
const sh1Max = encoding?.sh1Max ?? 1;
|
||
const sh1Mid = 0.5 * (sh1Min + sh1Max);
|
||
const sh1Scale = 126 / (sh1Max - sh1Min);
|
||
|
||
// Pack sint7 values into 2 x uint32
|
||
const base = index * 2;
|
||
for (let i = 0; i < 9; ++i) {
|
||
const s = (sh1Rgb[i] - sh1Mid) * sh1Scale;
|
||
const value = Math.round(Math.max(-63, Math.min(63, s))) & 0x7f;
|
||
const bitStart = i * 7;
|
||
const bitEnd = bitStart + 7;
|
||
|
||
const wordStart = Math.floor(bitStart / 32);
|
||
const bitOffset = bitStart - wordStart * 32;
|
||
const firstWord = (value << bitOffset) & 0xffffffff;
|
||
sh1Array[base + wordStart] |= firstWord;
|
||
|
||
if (bitEnd > wordStart * 32 + 32) {
|
||
const secondWord = (value >>> (32 - bitOffset)) & 0xffffffff;
|
||
sh1Array[base + wordStart + 1] |= secondWord;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Encode an array of 15 signed RGB SH2 coefficients (clamped to [-1,1]) into
|
||
// an array of 4 uint32 values, where each coefficient is stored as a sint8.
|
||
export function encodeSh2Rgb(
|
||
sh2Array: Uint32Array,
|
||
index: number,
|
||
sh2Rgb: Float32Array,
|
||
encoding?: {
|
||
sh2Min?: number;
|
||
sh2Max?: number;
|
||
},
|
||
) {
|
||
const sh2Min = encoding?.sh2Min ?? -1;
|
||
const sh2Max = encoding?.sh2Max ?? 1;
|
||
const sh2Mid = 0.5 * (sh2Min + sh2Max);
|
||
const sh2Scale = 2 / (sh2Max - sh2Min);
|
||
|
||
// Pack sint8 values into 4 x uint32
|
||
sh2Array[index * 4 + 0] = packSint8Bytes(
|
||
(sh2Rgb[0] - sh2Mid) * sh2Scale,
|
||
(sh2Rgb[1] - sh2Mid) * sh2Scale,
|
||
(sh2Rgb[2] - sh2Mid) * sh2Scale,
|
||
(sh2Rgb[3] - sh2Mid) * sh2Scale,
|
||
);
|
||
sh2Array[index * 4 + 1] = packSint8Bytes(
|
||
(sh2Rgb[4] - sh2Mid) * sh2Scale,
|
||
(sh2Rgb[5] - sh2Mid) * sh2Scale,
|
||
(sh2Rgb[6] - sh2Mid) * sh2Scale,
|
||
(sh2Rgb[7] - sh2Mid) * sh2Scale,
|
||
);
|
||
sh2Array[index * 4 + 2] = packSint8Bytes(
|
||
(sh2Rgb[8] - sh2Mid) * sh2Scale,
|
||
(sh2Rgb[9] - sh2Mid) * sh2Scale,
|
||
(sh2Rgb[10] - sh2Mid) * sh2Scale,
|
||
(sh2Rgb[11] - sh2Mid) * sh2Scale,
|
||
);
|
||
sh2Array[index * 4 + 3] = packSint8Bytes(
|
||
(sh2Rgb[12] - sh2Mid) * sh2Scale,
|
||
(sh2Rgb[13] - sh2Mid) * sh2Scale,
|
||
(sh2Rgb[14] - sh2Mid) * sh2Scale,
|
||
0,
|
||
);
|
||
}
|
||
|
||
// Encode an array of 21 signed RGB SH3 coefficients (clamped to [-1,1]) into
|
||
// an array of 4 uint32 values, where each coefficient is stored as a sint6.
|
||
export function encodeSh3Rgb(
|
||
sh3Array: Uint32Array,
|
||
index: number,
|
||
sh3Rgb: Float32Array,
|
||
encoding?: {
|
||
sh3Min?: number;
|
||
sh3Max?: number;
|
||
},
|
||
) {
|
||
const sh3Min = encoding?.sh3Min ?? -1;
|
||
const sh3Max = encoding?.sh3Max ?? 1;
|
||
const sh3Mid = 0.5 * (sh3Min + sh3Max);
|
||
const sh3Scale = 62 / (sh3Max - sh3Min);
|
||
|
||
// Pack sint6 values into 4 x uint32
|
||
const base = index * 4;
|
||
for (let i = 0; i < 21; ++i) {
|
||
const s = (sh3Rgb[i] - sh3Mid) * sh3Scale;
|
||
const value = Math.round(Math.max(-31, Math.min(31, s))) & 0x3f;
|
||
const bitStart = i * 6;
|
||
const bitEnd = bitStart + 6;
|
||
|
||
const wordStart = Math.floor(bitStart / 32);
|
||
const bitOffset = bitStart - wordStart * 32;
|
||
const firstWord = (value << bitOffset) & 0xffffffff;
|
||
sh3Array[base + wordStart] |= firstWord;
|
||
|
||
if (bitEnd > wordStart * 32 + 32) {
|
||
const secondWord = (value >>> (32 - bitOffset)) & 0xffffffff;
|
||
sh3Array[base + wordStart + 1] |= secondWord;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Partially decompress a gzip-encoded Uint8Array, returning a Uint8Array of
|
||
// the specified numBytes from the start of the file.
|
||
export function decompressPartialGzip(
|
||
fileBytes: Uint8Array,
|
||
numBytes: number,
|
||
): Uint8Array {
|
||
const chunks: Uint8Array[] = [];
|
||
let totalBytes = 0;
|
||
let result: Uint8Array | null = null;
|
||
|
||
const gunzip = new Gunzip((data, final) => {
|
||
chunks.push(data);
|
||
totalBytes += data.length;
|
||
if (final || totalBytes >= numBytes) {
|
||
const allBytes = new Uint8Array(totalBytes);
|
||
let offset = 0;
|
||
for (const chunk of chunks) {
|
||
allBytes.set(chunk, offset);
|
||
offset += chunk.length;
|
||
}
|
||
result = allBytes.slice(0, numBytes);
|
||
}
|
||
});
|
||
|
||
const CHUNK_SIZE = 1024;
|
||
let offset = 0;
|
||
while (result == null && offset < fileBytes.length) {
|
||
const chunk = fileBytes.slice(offset, offset + CHUNK_SIZE);
|
||
gunzip.push(chunk, false);
|
||
offset += CHUNK_SIZE;
|
||
}
|
||
|
||
if (result == null) {
|
||
gunzip.push(new Uint8Array(), true);
|
||
if (result == null) {
|
||
throw new Error("Failed to decompress partial gzip");
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
export class GunzipReader {
|
||
fileBytes: Uint8Array;
|
||
chunkBytes: number;
|
||
|
||
chunks: Uint8Array[];
|
||
totalBytes: number;
|
||
reader: ReadableStreamDefaultReader;
|
||
|
||
constructor({
|
||
fileBytes,
|
||
chunkBytes = 64 * 1024,
|
||
}: { fileBytes: Uint8Array; chunkBytes?: number }) {
|
||
this.fileBytes = fileBytes;
|
||
this.chunkBytes = chunkBytes;
|
||
this.chunks = [];
|
||
this.totalBytes = 0;
|
||
|
||
const ds = new DecompressionStream("gzip");
|
||
const decompressionStream = new Blob([fileBytes]).stream().pipeThrough(ds);
|
||
this.reader = decompressionStream.getReader();
|
||
}
|
||
|
||
async read(numBytes: number): Promise<Uint8Array> {
|
||
while (this.totalBytes < numBytes) {
|
||
const { value: chunk, done: readerDone } = await this.reader.read();
|
||
if (readerDone) {
|
||
break;
|
||
}
|
||
|
||
this.chunks.push(chunk);
|
||
this.totalBytes += chunk.length;
|
||
}
|
||
|
||
if (this.totalBytes < numBytes) {
|
||
throw new Error(
|
||
`Unexpected EOF: needed ${numBytes}, got ${this.totalBytes}`,
|
||
);
|
||
}
|
||
|
||
const allBytes = new Uint8Array(this.totalBytes);
|
||
let outOffset = 0;
|
||
for (const chunk of this.chunks) {
|
||
allBytes.set(chunk, outOffset);
|
||
outOffset += chunk.length;
|
||
}
|
||
|
||
const result = allBytes.subarray(0, numBytes);
|
||
this.chunks = [allBytes.subarray(numBytes)];
|
||
this.totalBytes -= numBytes;
|
||
return result;
|
||
}
|
||
}
|