diff --git a/src/Viewer.js b/src/Viewer.js index 79a1f462..a5c2c91c 100644 --- a/src/Viewer.js +++ b/src/Viewer.js @@ -4,6 +4,7 @@ import { PlyLoader } from './loaders/ply/PlyLoader.js'; import { SplatLoader } from './loaders/splat/SplatLoader.js'; import { KSplatLoader } from './loaders/ksplat/KSplatLoader.js'; import { SpzLoader } from './loaders/spz/SpzLoader.js'; +import { SogLoader } from './loaders/sog/SogLoader.js'; import { sceneFormatFromPath } from './loaders/Utils.js'; import { LoadingSpinner } from './ui/LoadingSpinner.js'; import { LoadingProgressBar } from './ui/LoadingProgressBar.js'; @@ -1075,6 +1076,22 @@ export class Viewer { } else if (format === SceneFormat.Spz) { return SpzLoader.loadFromURL(path, onProgress, splatAlphaRemovalThreshold, this.inMemoryCompressionLevel, this.optimizeSplatData, this.sphericalHarmonicsDegree, headers); + } else if (format === SceneFormat.Sog) { + const optimizeSplatData = this.optimizeSplatData; + if (path.endsWith('.sog')) { + return SogLoader.loadFromZipURL(path, onProgress, splatAlphaRemovalThreshold, this.inMemoryCompressionLevel, + optimizeSplatData, headers, this.sectionSize, this.sceneCenter, + this.bucketBlockSize, this.bucketSize); + } else { + let base = path; + if (base.endsWith('meta.json')) { + base = base.substring(0, base.lastIndexOf('/') + 1); + } + if (!base.endsWith('/')) base += '/'; + return SogLoader.loadFromDirectoryURL(base, onProgress, splatAlphaRemovalThreshold, this.inMemoryCompressionLevel, + optimizeSplatData, headers, this.sectionSize, this.sceneCenter, + this.bucketBlockSize, this.bucketSize); + } } } catch (e) { throw this.updateError(e, null); diff --git a/src/index.js b/src/index.js index c760c295..d36f4e7b 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import { PlyLoader } from './loaders/ply/PlyLoader.js'; import { SpzLoader } from './loaders/spz/SpzLoader.js'; import { SplatLoader } from './loaders/splat/SplatLoader.js'; import { KSplatLoader } from './loaders/ksplat/KSplatLoader.js'; +import { SogLoader } from './loaders/sog/SogLoader.js'; import * as LoaderUtils from './loaders/Utils.js'; import { SplatBuffer } from './loaders/SplatBuffer.js'; import { SplatParser } from './loaders/splat/SplatParser.js'; @@ -27,6 +28,7 @@ export { SpzLoader, SplatLoader, KSplatLoader, + SogLoader, LoaderUtils, SplatBuffer, SplatParser, diff --git a/src/loaders/SceneFormat.js b/src/loaders/SceneFormat.js index 880f1560..12bb07b9 100644 --- a/src/loaders/SceneFormat.js +++ b/src/loaders/SceneFormat.js @@ -2,5 +2,6 @@ export const SceneFormat = { 'Splat': 0, 'KSplat': 1, 'Ply': 2, - 'Spz': 3 + 'Spz': 3, + 'Sog': 4 }; diff --git a/src/loaders/UncompressedSplatArray.js b/src/loaders/UncompressedSplatArray.js index 013a8696..0c9c8c4e 100644 --- a/src/loaders/UncompressedSplatArray.js +++ b/src/loaders/UncompressedSplatArray.js @@ -76,10 +76,11 @@ export class UncompressedSplatArray { return newSplat; } - addSplatFromComonents(x, y, z, scale0, scale1, scale2, rot0, rot1, rot2, rot3, r, g, b, opacity, ...rest) { + addSplatFromComponents(x, y, z, scale0, scale1, scale2, rot0, rot1, rot2, rot3, r, g, b, opacity, ...rest) { const newSplat = [x, y, z, scale0, scale1, scale2, rot0, rot1, rot2, rot3, r, g, b, opacity, ...this.defaultSphericalHarmonics]; + const baseOffset = BASE_COMPONENT_COUNT; for (let i = 0; i < rest.length && i < this.sphericalHarmonicsCount; i++) { - newSplat[i] = rest[i]; + newSplat[baseOffset + i] = rest[i]; } this.addSplat(newSplat); return newSplat; diff --git a/src/loaders/Utils.js b/src/loaders/Utils.js index 4d446d7e..e7b5587b 100644 --- a/src/loaders/Utils.js +++ b/src/loaders/Utils.js @@ -5,5 +5,6 @@ export const sceneFormatFromPath = (path) => { else if (path.endsWith('.splat')) return SceneFormat.Splat; else if (path.endsWith('.ksplat')) return SceneFormat.KSplat; else if (path.endsWith('.spz')) return SceneFormat.Spz; + else if (path.endsWith('.sog')) return SceneFormat.Sog; return null; }; diff --git a/src/loaders/sog/SogLoader.js b/src/loaders/sog/SogLoader.js new file mode 100644 index 00000000..2e8ab2f6 --- /dev/null +++ b/src/loaders/sog/SogLoader.js @@ -0,0 +1,84 @@ +import * as THREE from 'three'; +import { SogParser } from './SogParser.js'; +import { SplatBuffer } from '../SplatBuffer.js'; +import { SplatBufferGenerator } from '../SplatBufferGenerator.js'; +// Note: progress utilities available, but not used here to keep loader minimal +import { unzipStoredEntries } from './ZipReaderBrowser.js'; +import { LoaderStatus } from '../LoaderStatus.js'; + +async function fetchJSON(url, headers) { + const resp = await fetch(url, { headers }); + if (!resp.ok) throw new Error(`Failed to fetch ${url}: ${resp.status} ${resp.statusText}`); + return resp.json(); +} + +function finalize(splatArray, optimizeSplatData, minimumAlpha, compressionLevel, sectionSize, sceneCenter, blockSize, bucketSize) { + if (optimizeSplatData) { + const gen = SplatBufferGenerator.getStandardGenerator( + minimumAlpha, compressionLevel, sectionSize, sceneCenter, blockSize, bucketSize + ); + return gen.generateFromUncompressedSplatArray(splatArray); + } else { + return SplatBuffer.generateFromUncompressedSplatArrays([splatArray], minimumAlpha, 0, new THREE.Vector3()); + } +} + +export class SogLoader { + // Multi-file SOG directory: baseURL ends with '/'; expects meta.json and the image files next to it + static async loadFromDirectoryURL(baseURL, onProgress, minimumAlpha, compressionLevel, + optimizeSplatData = true, headers, sectionSize, sceneCenter, blockSize, bucketSize) { + const isMeta = baseURL.toLowerCase().endsWith('meta.json'); + const dir = isMeta ? baseURL.slice(0, baseURL.lastIndexOf('/') + 1) : (baseURL.endsWith('/') ? baseURL : (baseURL + '/')); + const metaURL = isMeta ? baseURL : (dir + 'meta.json'); + if (onProgress) onProgress(0, '0%', LoaderStatus.Downloading); + const meta = await fetchJSON(metaURL, headers); + if (onProgress) onProgress(0, '0%', LoaderStatus.Processing); + const splatArray = await SogParser.parse(meta, (name) => dir + name); + const buffer = finalize( + splatArray, optimizeSplatData, minimumAlpha, compressionLevel, + sectionSize, sceneCenter, blockSize, bucketSize + ); + if (onProgress) onProgress(100, '100%', LoaderStatus.Done); + return buffer; + } + + // Bundled .sog: a ZIP whose root contains meta.json and files it references + static async loadFromZipURL(fileURL, onProgress, minimumAlpha, compressionLevel, + optimizeSplatData = true, headers, sectionSize, sceneCenter, blockSize, bucketSize) { + if (onProgress) onProgress(0, '0%', LoaderStatus.Downloading); + const resp = await fetch(fileURL, { headers }); + if (!resp.ok) throw new Error(`Failed to fetch ${fileURL}: ${resp.status} ${resp.statusText}`); + const arrayBuffer = await resp.arrayBuffer(); + if (onProgress) onProgress(0, '0%', LoaderStatus.Processing); + const entries = unzipStoredEntries(arrayBuffer); + const metaBytes = entries.get('meta.json'); + if (!metaBytes) throw new Error('SOG archive missing meta.json at root'); + const meta = JSON.parse(new TextDecoder().decode(metaBytes)); + + const resolver = (name) => { + const bytes = entries.get(name); + if (!bytes) throw new Error(`SOG archive missing file: ${name}`); + return new Blob([bytes], { type: 'image/webp' }); + }; + const splatArray = await SogParser.parse(meta, resolver); + const buffer = finalize( + splatArray, optimizeSplatData, minimumAlpha, compressionLevel, + sectionSize, sceneCenter, blockSize, bucketSize + ); + if (onProgress) onProgress(100, '100%', LoaderStatus.Done); + return buffer; + } + + static async loadFromFileHandles(metaJSON, fileResolver, onProgress, minimumAlpha, compressionLevel, + optimizeSplatData = true, sectionSize, sceneCenter, blockSize, bucketSize) { + // metaJSON is already-parsed meta; fileResolver(name) -> Blob or URL + if (onProgress) onProgress(0, '0%', LoaderStatus.Processing); + const splatArray = await SogParser.parse(metaJSON, fileResolver); + const buffer = finalize( + splatArray, optimizeSplatData, minimumAlpha, compressionLevel, + sectionSize, sceneCenter, blockSize, bucketSize + ); + if (onProgress) onProgress(100, '100%', LoaderStatus.Done); + return buffer; + } +} diff --git a/src/loaders/sog/SogParser.js b/src/loaders/sog/SogParser.js new file mode 100644 index 00000000..ea246e4b --- /dev/null +++ b/src/loaders/sog/SogParser.js @@ -0,0 +1,192 @@ +import * as THREE from 'three'; +import { UncompressedSplatArray } from '../UncompressedSplatArray.js'; + +async function loadImagePixels(src, typeHint) { + let blob; + if (src instanceof Blob) { + blob = src; + } else if (typeof src === 'string') { + const resp = await fetch(src); + blob = await resp.blob(); + } else { + throw new Error('Unsupported image source'); + } + + try { + if (typeof ImageDecoder !== 'undefined') { + const decoder = new ImageDecoder({ data: blob, type: blob.type || typeHint || 'image/webp' }); + const { image } = await decoder.decode(); + const width = image.displayWidth || image.codedWidth; + const height = image.displayHeight || image.codedHeight; + const data = new Uint8ClampedArray(width * height * 4); + await image.copyTo(data, { format: 'RGBA' }); + image.close(); + return { data, width, height }; + } + } catch (e) {} + + const bitmap = await createImageBitmap(blob); + const width = bitmap.width; + const height = bitmap.height; + let ctx; + let canvas; + if (typeof OffscreenCanvas !== 'undefined') { + canvas = new OffscreenCanvas(width, height); + ctx = canvas.getContext('2d'); + } else { + canvas = document.createElement('canvas'); + canvas.width = width; canvas.height = height; + ctx = canvas.getContext('2d'); + } + ctx.drawImage(bitmap, 0, 0); + const imageData = ctx.getImageData(0, 0, width, height); + return { data: imageData.data, width, height }; +} + +function lerp(a, b, t) { + return a + (b - a) * t; +} +function unlog(n) { + return Math.sign(n) * (Math.exp(Math.abs(n)) - 1); +} + +function reconstructQuaternion(r, g, b, a) { + const comp = (c) => (c / 255 - 0.5) * 2.0 / Math.SQRT2; + const A = comp(r); + const B = comp(g); + const C = comp(b); + const mode = a - 252; + const t = A*A + B*B + C*C; + const D = Math.sqrt(Math.max(0, 1 - t)); + let qx; + let qy; + let qz; + let qw; + switch (mode) { + case 0: + qx = D; qy = A; qz = B; qw = C; break; + case 1: + qx = A; qy = D; qz = B; qw = C; break; + case 2: + qx = A; qy = B; qz = D; qw = C; break; + case 3: + qx = A; qy = B; qz = C; qw = D; break; + default: throw new Error('Invalid quaternion mode'); + } + const q = new THREE.Quaternion(qx, qy, qz, qw); + if (q.w < 0) { + q.x = -q.x; + q.y = -q.y; + q.z = -q.z; + q.w = -q.w; + } + return q.normalize(); +} + +export class SogParser { + static async parse(meta, baseURLOrResolver) { + const resolve = async (name) => { + const url = typeof baseURLOrResolver === 'function' ? await baseURLOrResolver(name) : `${baseURLOrResolver}${name}`; + return loadImagePixels(url); + }; + + const [meansL, meansU, quats, scalesImg, sh0Img] = await Promise.all([ + resolve(meta.means.files[0]), + resolve(meta.means.files[1]), + resolve(meta.quats.files[0]), + resolve(meta.scales.files[0]), + resolve(meta.sh0.files[0]) + ]); + + const width = meansL.width; + const height = meansL.height; + const capacity = width * height; + const count = Math.min(meta.count, capacity); + + let degree = 0; + let shNCoeffsTotal = 0; + let shNCoeffsWanted = 0; + if (meta.shN && meta.shN.bands) { + const bands = meta.shN.bands; + degree = Math.min(bands, 2); + shNCoeffsTotal = [0, 3, 8, 15][bands]; + shNCoeffsWanted = [0, 3, 8][degree]; + } + const splats = new UncompressedSplatArray(degree); + + const mins = meta.means.mins; + const maxs = meta.means.maxs; + const sh0Codebook = meta.sh0.codebook; + const scaleCodebook = meta.scales.codebook; + + let labelsImg = null; + let centroidsImg = null; + let shNCodebook = null; + if (degree > 0) { + const f0 = meta.shN.files[0]; + const f1 = meta.shN.files[1]; + const firstIsLabels = /label/i.test(f0); + const [imgA, imgB] = await Promise.all([ + resolve(f0), + resolve(f1) + ]); + labelsImg = firstIsLabels ? imgA : imgB; + centroidsImg = firstIsLabels ? imgB : imgA; + shNCodebook = meta.shN.codebook; + } + + for (let i = 0; i < count; i++) { + const x = i % width; + const y = (i / width) | 0; + const idx = (x + y * width) * 4; + + const qx = (meansU.data[idx + 0] << 8) | meansL.data[idx + 0]; + const qy = (meansU.data[idx + 1] << 8) | meansL.data[idx + 1]; + const qz = (meansU.data[idx + 2] << 8) | meansL.data[idx + 2]; + const nx = lerp(mins[0], maxs[0], qx / 65535); + const ny = lerp(mins[1], maxs[1], qy / 65535); + const nz = lerp(mins[2], maxs[2], qz / 65535); + const px = unlog(nx); + const py = unlog(ny); + const pz = unlog(nz); + + const sx = Math.exp(scaleCodebook[scalesImg.data[idx + 0]]); + const sy = Math.exp(scaleCodebook[scalesImg.data[idx + 1]]); + const sz = Math.exp(scaleCodebook[scalesImg.data[idx + 2]]); + + const q = reconstructQuaternion(quats.data[idx + 0], quats.data[idx + 1], quats.data[idx + 2], quats.data[idx + 3]); + + const SH_C0 = 0.28209479177387814; + const r = 0.5 + sh0Codebook[sh0Img.data[idx + 0]] * SH_C0; + const g = 0.5 + sh0Codebook[sh0Img.data[idx + 1]] * SH_C0; + const b = 0.5 + sh0Codebook[sh0Img.data[idx + 2]] * SH_C0; + const aByte = sh0Img.data[idx + 3]; + + if (degree === 0) { + splats.addSplatFromComponents(px, py, pz, sx, sy, sz, q.x, q.y, q.z, q.w, r * 255, g * 255, b * 255, aByte); + } else { + const restCount = splats.sphericalHarmonicsCount; + const rest = new Array(restCount).fill(0); + const label = labelsImg.data[idx + 0] | (labelsImg.data[idx + 1] << 8); + if (label < (meta.shN.count || 0) && shNCodebook) { + for (let j = 0; j < shNCoeffsWanted; j++) { + const u = (label % 64) * shNCoeffsTotal + j; + const v = Math.floor(label / 64); + if (u < centroidsImg.width && v < centroidsImg.height) { + const cidx = (v * centroidsImg.width + u) * 4; + const rIdx = centroidsImg.data[cidx + 0]; + const gIdx = centroidsImg.data[cidx + 1]; + const bIdx = centroidsImg.data[cidx + 2]; + rest[j + 0 * shNCoeffsWanted] = shNCodebook[rIdx] ?? 0; + rest[j + 1 * shNCoeffsWanted] = shNCodebook[gIdx] ?? 0; + rest[j + 2 * shNCoeffsWanted] = shNCodebook[bIdx] ?? 0; + } + } + } + splats.addSplatFromComponents(px, py, pz, sx, sy, sz, q.x, q.y, q.z, q.w, r * 255, g * 255, b * 255, aByte, ...rest); + } + } + + return splats; + } +} diff --git a/src/loaders/sog/ZipReaderBrowser.js b/src/loaders/sog/ZipReaderBrowser.js new file mode 100644 index 00000000..8e610052 --- /dev/null +++ b/src/loaders/sog/ZipReaderBrowser.js @@ -0,0 +1,69 @@ +// Minimal ZIP reader for browser ArrayBuffer input supporting STORE (method 0), +// data descriptor (0x08074b50), and UTF-8 filenames, matching splat-transform writer. + +export function unzipStoredEntries(arrayBuffer) { + const u8 = new Uint8Array(arrayBuffer); + const dv = new DataView(arrayBuffer); + const entries = new Map(); + + let cursor = 0; + const size = u8.length; + + const getUint32 = (o) => dv.getUint32(o, true); + const getUint16 = (o) => dv.getUint16(o, true); + + const SIG_LOCAL = 0x04034b50; + const SIG_CENTRAL = 0x02014b50; + const SIG_EOCD = 0x06054b50; + const SIG_DD = 0x08074b50; + + while (cursor + 30 <= size) { + const sig = getUint32(cursor); + if (sig === SIG_CENTRAL || sig === SIG_EOCD) break; + if (sig !== SIG_LOCAL) break; + + const gpFlags = getUint16(cursor + 6); + const method = getUint16(cursor + 8); + const nameLen = getUint16(cursor + 26); + const extraLen = getUint16(cursor + 28); + + if (method !== 0) { + throw new Error(`Unsupported ZIP compression method: ${method} (only STORE=0)`); + } + + const nameBytes = u8.subarray(cursor + 30, cursor + 30 + nameLen); + const utf8 = (gpFlags & 0x800) !== 0; + const name = new TextDecoder(utf8 ? 'utf-8' : 'ascii').decode(nameBytes); + + const headerEnd = cursor + 30 + nameLen + extraLen; + const useDescriptor = (gpFlags & 0x8) !== 0; + + if (!useDescriptor) { + const sizeUncomp = getUint32(cursor + 22); + const dataStart = headerEnd; + const dataEnd = dataStart + sizeUncomp; + entries.set(name, u8.slice(dataStart, dataEnd)); + cursor = dataEnd; + } else { + let pos = headerEnd; + let found = false; + while (pos + 16 <= size) { + if (getUint32(pos) === SIG_DD) { + const crc = getUint32(pos + 4); // eslint-disable-line + const sizeUncomp = getUint32(pos + 8); + const sizeComp = getUint32(pos + 12); // eslint-disable-line + const dataStart = headerEnd; + const dataEnd = dataStart + sizeUncomp; + entries.set(name, u8.slice(dataStart, dataEnd)); + cursor = pos + 16; + found = true; + break; + } + pos++; + } + if (!found) throw new Error('ZIP data descriptor not found'); + } + } + + return entries; +} diff --git a/src/loaders/splat/SplatParser.js b/src/loaders/splat/SplatParser.js index 53c1c152..47adeebd 100644 --- a/src/loaders/splat/SplatParser.js +++ b/src/loaders/splat/SplatParser.js @@ -69,7 +69,7 @@ export class SplatParser { (inRotation[3] - 128) / 128, (inRotation[0] - 128) / 128); quat.normalize(); - splatArray.addSplatFromComonents(inCenter[0], inCenter[1], inCenter[2], inScale[0], inScale[1], inScale[2], + splatArray.addSplatFromComponents(inCenter[0], inCenter[1], inCenter[2], inScale[0], inScale[1], inScale[2], quat.w, quat.x, quat.y, quat.z, inColor[0], inColor[1], inColor[2], inColor[3]); } } @@ -97,7 +97,7 @@ export class SplatParser { (inRotation[3] - 128) / 128, (inRotation[0] - 128) / 128); quat.normalize(); - splatArray.addSplatFromComonents(inCenter[0], inCenter[1], inCenter[2], inScale[0], inScale[1], inScale[2], + splatArray.addSplatFromComponents(inCenter[0], inCenter[1], inCenter[2], inScale[0], inScale[1], inScale[2], quat.w, quat.x, quat.y, quat.z, inColor[0], inColor[1], inColor[2], inColor[3]); }