Skip to content

Commit 36c3c61

Browse files
authored
Implementation PC SOGS file format loading via meta.json root file. Added webp dependency for accurate decoding. Updated viewer and editor to accept new file type. (#73)
1 parent eb1b502 commit 36c3c61

File tree

9 files changed

+389
-41
lines changed

9 files changed

+389
-41
lines changed

examples/editor/index.html

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
import * as THREE from "three";
6969
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
7070
import { GUI } from "lil-gui";
71-
import { constructGrid, SparkControls, SparkRenderer, SplatMesh, textSplats, dyno, transcodeSpz } from "@sparkjsdev/spark";
71+
import { constructGrid, SparkControls, SparkRenderer, SplatMesh, textSplats, dyno, transcodeSpz, isPcSogs } from "@sparkjsdev/spark";
7272
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
7373

7474
const scene = new THREE.Scene();
@@ -154,7 +154,7 @@
154154
loadFiles(urls);
155155
guiOptions.loadFromText = ""; // Clear after loading
156156
} else {
157-
alert("No valid URLs found in text. URLs must start with http:// or https:// and end with .ply, .spz, .splat, or .ksplat");
157+
alert("No valid URLs found in text. URLs must start with http:// or https:// and end with .ply, .spz, .splat, .ksplat, or .json");
158158
}
159159
}
160160
},
@@ -357,6 +357,7 @@
357357
try {
358358
let fileBytes;
359359
let fileName;
360+
let url = null;
360361

361362
// Check if splatFile is a URL string or a File object
362363
if (typeof splatFile === "string") {
@@ -365,6 +366,10 @@
365366
fileBytes = new Uint8Array(await fetchWithProgress(splatFile));
366367
// Extract filename from URL
367368
fileName = splatFile.split("/").pop().split("?")[0] || "downloaded-file";
369+
370+
if (isPcSogs(fileBytes)) {
371+
url = splatFile;
372+
}
368373
} else {
369374
// It's a File object
370375
fileBytes = new Uint8Array(await splatFile.arrayBuffer());
@@ -375,14 +380,18 @@
375380
writeOptions.filename = fileName.split(".")[0];
376381
}
377382

378-
const splatMesh = new SplatMesh({ fileBytes: fileBytes.slice(), fileName });
383+
const init = url ? { url } : { fileBytes: fileBytes.slice(), fileName };
384+
const splatMesh = new SplatMesh(init);
379385
const translate = guiOptions.loadOffset * index
380386
splatMesh.position.set(translate, 0.5 * translate, 0.1 * translate);
381387
splatMesh.enableWorldToView = true;
382388
splatMesh.worldModifier = makeWorldModifier(splatMesh);
383389
await splatMesh.initialized;
384390

385-
inputs.push({ fileBytes, pathOrUrl: fileName, object: splatMesh });
391+
if (!url) {
392+
// PC SOGS transcode not supported yet
393+
inputs.push({ fileBytes, pathOrUrl: fileName, object: splatMesh });
394+
}
386395
frame.add(splatMesh);
387396
console.log(`Loaded ${fileName} with ${splatMesh.numSplats} splats`);
388397

@@ -659,7 +668,7 @@
659668

660669
// Parse URLs from text (handles single URLs, multiple lines, mixed content)
661670
function parseURLsFromText(text) {
662-
const supportedExtensions = [".ply", ".spz", ".splat", ".ksplat"];
671+
const supportedExtensions = [".ply", ".spz", ".splat", ".ksplat", ".json"];
663672
const urls = [];
664673

665674
// Split by lines, commas, and semicolons

examples/viewer/index.html

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -263,15 +263,17 @@
263263
loadSplatFile(event.target.files[0]);
264264
};
265265

266-
var loadedSplat;
267266
async function loadSplatFile(splatFile) {
268-
const reader = new FileReader();
269267
fileBytes = new Uint8Array(await splatFile.arrayBuffer());
270268
fileName = splatFile.name;
269+
setSplatFile({ fileBytes: fileBytes.slice(), fileName });
270+
}
271271

272+
var loadedSplat;
273+
function setSplatFile(init) {
272274
if (loadedSplat) { scene.remove(loadedSplat); }
273275

274-
loadedSplat = new SplatMesh({ fileBytes: fileBytes.slice(), fileName });
276+
loadedSplat = new SplatMesh(init);
275277
loadedSplat.quaternion.set(1, 0, 0, 0);
276278
scene.add(loadedSplat);
277279

@@ -289,12 +291,7 @@
289291
fileName = splatURL.split("/").pop().split("?")[0];
290292
document.querySelector('.container').classList.add('hidden');
291293
document.querySelector('.canvas-container').classList.remove('invisible');
292-
fetch(splatURL)
293-
.then(res => res.blob())
294-
.then(blob => {
295-
loadSplatFile(blob);
296-
})
297-
.catch(err => console.error('Download failed:', err));
294+
setSplatFile({ url: splatURL });
298295
}
299296

300297
const urlFormEl = document.querySelector('.url-form');

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"spark-internal-rs": "file:rust/spark-internal-rs/pkg"
5959
},
6060
"dependencies": {
61+
"@jsquash/webp": "^1.5.0",
6162
"fflate": "^0.8.2"
6263
},
6364
"keywords": ["3d", "three.js", "gsplats", "3dgs", "gaussian", "splats"]

src/PackedSplats.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as THREE from "three";
22

33
import type { GsplatGenerator } from "./SplatGenerator";
4-
import { type SplatFileType, unpackSplats } from "./SplatLoader";
4+
import { type SplatFileType, SplatLoader, unpackSplats } from "./SplatLoader";
55
import { SPLAT_TEX_HEIGHT, SPLAT_TEX_WIDTH } from "./defines";
66
import {
77
DynoProgram,
@@ -120,27 +120,20 @@ export class PackedSplats {
120120
}
121121

122122
async asyncInitialize(options: PackedSplatsOptions) {
123-
let { url, fileBytes, construct } = options;
123+
const { url, fileBytes, construct } = options;
124124
if (url) {
125-
fileBytes = await fetch(url).then(async (response) => {
126-
if (!response.ok) {
127-
throw new Error(
128-
`${response.status} "${response.statusText}" fetching URL: ${url}`,
129-
);
130-
}
131-
const arrayBuffer = await response.arrayBuffer();
132-
return arrayBuffer;
133-
});
134-
}
135-
136-
if (fileBytes) {
125+
const loader = new SplatLoader();
126+
loader.packedSplats = this;
127+
await loader.loadAsync(url);
128+
} else if (fileBytes) {
137129
const unpacked = await unpackSplats({
138130
input: fileBytes,
139131
fileType: options.fileType,
140132
pathOrUrl: options.fileName ?? url,
141133
});
142134
this.initialize(unpacked);
143135
}
136+
144137
if (construct) {
145138
const maybePromise = construct(this);
146139
// If construct returns a promise, wait for it to complete

src/SplatLoader.ts

Lines changed: 167 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { decompressPartialGzip, getTextureSize } from "./utils";
1414
export class SplatLoader extends Loader {
1515
fileLoader: FileLoader;
1616
fileType?: SplatFileType;
17+
packedSplats?: PackedSplats;
1718

1819
constructor(manager?: LoadingManager) {
1920
super(manager);
@@ -37,12 +38,61 @@ export class SplatLoader extends Loader {
3738
async (response) => {
3839
if (onLoad) {
3940
const input = response as ArrayBuffer;
40-
const decoded = await unpackSplats({
41-
input,
42-
fileType: this.fileType,
43-
pathOrUrl: url,
44-
});
45-
onLoad(new PackedSplats(decoded));
41+
const extraFiles: Record<string, ArrayBuffer> = {};
42+
const promises = [];
43+
let fileType = this.fileType;
44+
45+
try {
46+
const pcSogsJson = tryPcSogs(input);
47+
if (this.fileType === SplatFileType.PCSOGS) {
48+
if (pcSogsJson === undefined) {
49+
throw new Error("Invalid PC SOGS file");
50+
}
51+
}
52+
53+
if (pcSogsJson !== undefined) {
54+
fileType = SplatFileType.PCSOGS;
55+
for (const key of ["means", "scales", "quats", "sh0", "shN"]) {
56+
const prop = pcSogsJson[key as keyof PcSogsJson];
57+
if (prop) {
58+
const files = prop.files;
59+
for (const file of files) {
60+
const fileUrl = new URL(file, url).toString();
61+
this.manager.itemStart(fileUrl);
62+
const promise = this.loadExtra(fileUrl)
63+
.then((data) => {
64+
extraFiles[file] = data;
65+
})
66+
.catch((error) => {
67+
this.manager.itemError(fileUrl);
68+
throw error;
69+
})
70+
.finally(() => {
71+
this.manager.itemEnd(fileUrl);
72+
});
73+
promises.push(promise);
74+
}
75+
}
76+
}
77+
}
78+
79+
await Promise.all(promises);
80+
const decoded = await unpackSplats({
81+
input,
82+
extraFiles,
83+
fileType,
84+
pathOrUrl: url,
85+
});
86+
87+
if (this.packedSplats) {
88+
this.packedSplats.initialize(decoded);
89+
onLoad(this.packedSplats);
90+
} else {
91+
onLoad(new PackedSplats(decoded));
92+
}
93+
} catch (error) {
94+
onError?.(error);
95+
}
4696
}
4797
},
4898
onProgress,
@@ -66,6 +116,17 @@ export class SplatLoader extends Loader {
66116
});
67117
}
68118

119+
async loadExtra(url: string): Promise<ArrayBuffer> {
120+
return new Promise((resolve, reject) => {
121+
this.fileLoader.load(
122+
url,
123+
(response) => resolve(response as ArrayBuffer),
124+
undefined,
125+
(error) => reject(error),
126+
);
127+
});
128+
}
129+
69130
parse(packedSplats: PackedSplats): SplatMesh {
70131
return new SplatMesh({ packedSplats });
71132
}
@@ -76,6 +137,7 @@ export enum SplatFileType {
76137
SPZ = "spz",
77138
SPLAT = "splat",
78139
KSPLAT = "ksplat",
140+
PCSOGS = "pcsogs",
79141
}
80142

81143
export function getSplatFileType(
@@ -133,12 +195,96 @@ export function getSplatFileTypeFromPath(
133195
return undefined;
134196
}
135197

198+
export type PcSogsJson = {
199+
means: {
200+
shape: number[];
201+
dtype: string;
202+
mins: number[];
203+
maxs: number[];
204+
files: string[];
205+
};
206+
scales: {
207+
shape: number[];
208+
dtype: string;
209+
mins: number[];
210+
maxs: number[];
211+
files: string[];
212+
};
213+
quats: { shape: number[]; dtype: string; encoding?: string; files: string[] };
214+
sh0: {
215+
shape: number[];
216+
dtype: string;
217+
mins: number[];
218+
maxs: number[];
219+
files: string[];
220+
};
221+
shN: {
222+
shape: number[];
223+
dtype: string;
224+
mins: number;
225+
maxs: number;
226+
quantization: number;
227+
files: string[];
228+
};
229+
};
230+
231+
export function isPcSogs(input: ArrayBuffer | Uint8Array | string): boolean {
232+
// Returns true if the input seems to be a valid PC SOGS file
233+
return tryPcSogs(input) !== undefined;
234+
}
235+
236+
export function tryPcSogs(
237+
input: ArrayBuffer | Uint8Array | string,
238+
): PcSogsJson | undefined {
239+
// Try to parse input as SOGS JSON and see if it's valid
240+
try {
241+
let text: string;
242+
if (typeof input === "string") {
243+
text = input;
244+
} else {
245+
const fileBytes =
246+
input instanceof ArrayBuffer ? new Uint8Array(input) : input;
247+
if (fileBytes.length > 65536) {
248+
// Should be only a few KB, definitely not a SOGS JSON file
249+
return undefined;
250+
}
251+
text = new TextDecoder().decode(fileBytes);
252+
}
253+
254+
const json = JSON.parse(text);
255+
if (!json || typeof json !== "object" || Array.isArray(json)) {
256+
return undefined;
257+
}
258+
for (const key of ["means", "scales", "quats", "sh0"]) {
259+
if (
260+
!json[key] ||
261+
typeof json[key] !== "object" ||
262+
Array.isArray(json[key])
263+
) {
264+
return undefined;
265+
}
266+
if (!json[key].shape || !json[key].files) {
267+
return undefined;
268+
}
269+
if (key !== "quats" && (!json[key].mins || !json[key].maxs)) {
270+
return undefined;
271+
}
272+
}
273+
// This is probably a PC SOGS file
274+
return json as PcSogsJson;
275+
} catch {
276+
return undefined;
277+
}
278+
}
279+
136280
export async function unpackSplats({
137281
input,
282+
extraFiles,
138283
fileType,
139284
pathOrUrl,
140285
}: {
141286
input: Uint8Array | ArrayBuffer;
287+
extraFiles?: Record<string, ArrayBuffer>;
142288
fileType?: SplatFileType;
143289
pathOrUrl?: string;
144290
}): Promise<{
@@ -201,7 +347,7 @@ export async function unpackSplats({
201347
return { packedArray, numSplats };
202348
});
203349
}
204-
case SplatFileType.KSPLAT:
350+
case SplatFileType.KSPLAT: {
205351
return await withWorker(async (worker) => {
206352
const { packedArray, numSplats, extra } = (await worker.call(
207353
"decodeKsplat",
@@ -213,6 +359,20 @@ export async function unpackSplats({
213359
};
214360
return { packedArray, numSplats, extra };
215361
});
362+
}
363+
case SplatFileType.PCSOGS: {
364+
return await withWorker(async (worker) => {
365+
const { packedArray, numSplats, extra } = (await worker.call(
366+
"decodePcSogs",
367+
{ fileBytes, extraFiles },
368+
)) as {
369+
packedArray: Uint32Array;
370+
numSplats: number;
371+
extra: Record<string, unknown>;
372+
};
373+
return { packedArray, numSplats, extra };
374+
});
375+
}
216376
default: {
217377
throw new Error(`Unknown splat file type: ${splatFileType}`);
218378
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export {
88
unpackSplats,
99
SplatFileType,
1010
getSplatFileType,
11+
isPcSogs,
1112
} from "./SplatLoader";
1213
export { PlyReader } from "./ply";
1314
export { SpzReader, SpzWriter, transcodeSpz } from "./spz";

0 commit comments

Comments
 (0)