TripoSplat-WebGPU / src /models /triposplat /TripoSplatWebGPUModel.ts
Yosun's picture
Deploy TripoSplat WebGPU demo
31c7d49 verified
Raw
History Blame Contribute Delete
18.5 kB
import { buildGaussianPly } from '../../lib/gaussianPly'
import { float16ToFloat32, float32ToFloat16 } from '../../runtime/float16'
import type {
OrtLoadSessionResult,
OrtRunTimings,
OrtWorkerClientOptions,
OrtWorkerStatus,
} from '../../runtime/OrtWorkerClient'
import { OrtWorkerClient } from '../../runtime/OrtWorkerClient'
import { createTensorPayload, type TensorPayload } from '../../runtime/tensors'
import type {
GaussianScene,
GenerationOptions,
ImageToGaussianModel,
} from '../ImageToGaussianModel'
import { throwIfAborted } from '../ImageToGaussianModel'
import {
elementCount,
TRIPOSPLAT_FEATURE1_SHAPE,
TRIPOSPLAT_FEATURE2_SHAPE,
TRIPOSPLAT_IMAGE_SHAPE,
TRIPOSPLAT_LATENT_SHAPE,
TRIPOSPLAT_CAMERA_SHAPE,
TRIPOSPLAT_MAX_DECODER_POINTS,
TRIPOSPLAT_MAX_GAUSSIANS,
TRIPOSPLAT_MIN_GAUSSIANS,
TRIPOSPLAT_VAE_NOISE_SHAPE,
} from './contracts'
import {
decodeTripoSplatGaussianFeatures,
TRIPOSPLAT_GAUSSIANS_PER_POINT,
TRIPOSPLAT_GS_FEATURE_WIDTH,
} from './gaussianDecoder'
import {
sampleFlowEulerCfg,
type FlowModelInvocation,
type FlowTensorState,
} from './flowSampler'
import type {
TripoSplatGraphDescriptor,
TripoSplatGraphName,
TripoSplatModelGraphs,
} from './manifests'
import { createTripoSplatModelManifest } from './manifests'
import {
buildTripoSplatEncoderTensors,
preprocessTripoSplatImage,
type RgbImage,
type TripoSplatBackgroundRemover,
} from './preprocess'
import { fillNormal, Mulberry32 } from './random'
import {
sampleOctree,
type OctreeOccupancyInvocation,
type OctreeSampleResult,
} from './octree'
const SESSION_IDS: Record<TripoSplatGraphName, string> = {
dinov3: 'triposplat/dinov3',
vaeEncoder: 'triposplat/vae-encoder',
dit: 'triposplat/dit',
octree: 'triposplat/octree',
gaussianDecoder: 'triposplat/gaussian-decoder',
}
export interface TripoSplatWebGPUModelOptions {
graphs?: TripoSplatModelGraphs
removeBackground?: TripoSplatBackgroundRemover
allowWasmFallback?: boolean
onRuntimeStatus?: (status: OrtWorkerStatus) => void
worker?: Omit<OrtWorkerClientOptions, 'onStatus'>
}
export interface TripoSplatEncoderResult {
preparedImage: RgbImage
feature1?: Float32Array
feature2?: Float32Array
timings: Partial<Record<'dinov3' | 'vaeEncoder', OrtRunTimings>>
}
interface TripoSplatCondition {
feature1: Float32Array
feature2: Float32Array
}
function payloadToFloat32(label: string, payload: TensorPayload): Float32Array {
if (payload.type === 'float32') return payload.data
if (payload.type === 'float16') return float16ToFloat32(payload.data)
throw new Error(`${label} must be float32 or float16, got ${payload.type}.`)
}
function inputPayload(
descriptor: TripoSplatGraphDescriptor,
data: Float32Array,
dims: readonly number[],
): TensorPayload {
return descriptor.precision === 'float16'
? createTensorPayload('float16', float32ToFloat16(data), dims)
: createTensorPayload('float32', data, dims)
}
/** Browser TripoSplat adapter. Encoder-only manifests are supported for parity bring-up. */
export class TripoSplatWebGPUModel implements ImageToGaussianModel {
readonly graphs: TripoSplatModelGraphs
private readonly client: OrtWorkerClient
private readonly removeBackground?: TripoSplatBackgroundRemover
private readonly allowWasmFallback: boolean
private readonly sessions = new Map<TripoSplatGraphName, OrtLoadSessionResult>()
private disposed = false
constructor(options: TripoSplatWebGPUModelOptions = {}) {
this.graphs = options.graphs ?? createTripoSplatModelManifest()
this.removeBackground = options.removeBackground
this.allowWasmFallback = options.allowWasmFallback ?? false
this.client = new OrtWorkerClient({ ...options.worker, onStatus: options.onRuntimeStatus })
}
async load(): Promise<void> {
this.assertUsable()
// Full-pipeline graphs are deliberately staged to fit the 16 GB target.
// `load()` warms only the encoder slice; generate() releases it before DiT.
for (const name of ['dinov3', 'vaeEncoder'] as const) {
if (this.graphs[name]) await this.loadGraph(name)
}
}
async encode(
image: ImageBitmap,
options: GenerationOptions = {},
): Promise<TripoSplatEncoderResult> {
this.assertUsable()
throwIfAborted(options.signal)
const hasDino = this.graphs.dinov3 !== undefined
const hasVae = this.graphs.vaeEncoder !== undefined
if (!hasDino && !hasVae) throw new Error('TripoSplat encoder manifest contains neither DINOv3 nor Flux VAE.')
options.onProgress?.({ stage: 'preprocessing', message: 'Preparing the TripoSplat 1024px RGB composite…' })
const prepared = await preprocessTripoSplatImage(image, {
erodeRadius: options.erodeRadius,
removeBackground: this.removeBackground,
opaqueImageIsAlreadyPrepared: options.inputIsPrepared,
})
const tensors = buildTripoSplatEncoderTensors(prepared.image)
throwIfAborted(options.signal)
const result: TripoSplatEncoderResult = { preparedImage: prepared.image, timings: {} }
if (hasDino) {
options.onProgress?.({ stage: 'encoding-dinov3', message: 'Running DINOv3 on WebGPU…' })
const descriptor = this.requireGraph('dinov3')
await this.loadGraph('dinov3')
const response = await this.client.runSession({
sessionId: SESSION_IDS.dinov3,
inputs: {
pixel_values: inputPayload(descriptor, tensors.dinov3.data, TRIPOSPLAT_IMAGE_SHAPE),
},
outputs: ['feature1'],
})
const feature1 = response.outputs.feature1
if (!feature1) throw new Error('DINOv3 graph did not return feature1.')
result.feature1 = payloadToFloat32('feature1', feature1)
if (result.feature1.length !== elementCount(TRIPOSPLAT_FEATURE1_SHAPE)) {
throw new Error(`DINOv3 returned ${result.feature1.length} values; expected ${elementCount(TRIPOSPLAT_FEATURE1_SHAPE)}.`)
}
result.timings.dinov3 = response.timings
}
if (hasVae) {
options.onProgress?.({ stage: 'encoding-vae', message: 'Running the Flux VAE encoder on WebGPU…' })
const descriptor = this.requireGraph('vaeEncoder')
await this.loadGraph('vaeEncoder')
const epsilon = options.vaeNoise
? new Float32Array(options.vaeNoise)
: fillNormal(
new Float32Array(elementCount(TRIPOSPLAT_VAE_NOISE_SHAPE)),
new Mulberry32(options.seed ?? 42),
)
if (epsilon.length !== elementCount(TRIPOSPLAT_VAE_NOISE_SHAPE)) {
throw new Error(`VAE epsilon contains ${epsilon.length} values; expected ${elementCount(TRIPOSPLAT_VAE_NOISE_SHAPE)}.`)
}
const response = await this.client.runSession({
sessionId: SESSION_IDS.vaeEncoder,
inputs: {
image_rgb: inputPayload(descriptor, tensors.rgb.data, TRIPOSPLAT_IMAGE_SHAPE),
epsilon: inputPayload(descriptor, epsilon, TRIPOSPLAT_VAE_NOISE_SHAPE),
},
outputs: ['feature2'],
})
const feature2Payload = response.outputs.feature2
if (!feature2Payload) throw new Error('Flux VAE graph did not return feature2.')
result.feature2 = payloadToFloat32('feature2', feature2Payload)
if (result.feature2.length !== elementCount(TRIPOSPLAT_FEATURE2_SHAPE)) {
throw new Error(`Flux VAE returned ${result.feature2.length} values; expected ${elementCount(TRIPOSPLAT_FEATURE2_SHAPE)}.`)
}
result.timings.vaeEncoder = response.timings
}
throwIfAborted(options.signal)
return result
}
async generate(image: ImageBitmap, options: GenerationOptions = {}): Promise<GaussianScene> {
for (const graph of ['dinov3', 'vaeEncoder', 'dit', 'octree', 'gaussianDecoder'] as const) {
this.requireGraph(graph)
}
const random = new Mulberry32(options.seed ?? 42)
const vaeNoise = options.vaeNoise
? new Float32Array(options.vaeNoise)
: fillNormal(new Float32Array(elementCount(TRIPOSPLAT_VAE_NOISE_SHAPE)), random)
let encoded: TripoSplatEncoderResult
try {
encoded = await this.encode(image, { ...options, vaeNoise })
} finally {
await Promise.all([this.disposeGraph('dinov3'), this.disposeGraph('vaeEncoder')])
}
if (!encoded.feature1 || !encoded.feature2) {
throw new Error('TripoSplat encoding did not produce both conditioning tensors.')
}
throwIfAborted(options.signal)
const condition: TripoSplatCondition = {
feature1: encoded.feature1,
feature2: encoded.feature2,
}
const negativeCondition: TripoSplatCondition = {
feature1: new Float32Array(encoded.feature1.length),
feature2: new Float32Array(encoded.feature2.length),
}
const latent = options.latentNoise
? new Float32Array(options.latentNoise)
: fillNormal(new Float32Array(elementCount(TRIPOSPLAT_LATENT_SHAPE)), random)
const camera = options.cameraNoise
? new Float32Array(options.cameraNoise)
: fillNormal(new Float32Array(elementCount(TRIPOSPLAT_CAMERA_SHAPE)), random)
this.assertLength('latent noise', latent, elementCount(TRIPOSPLAT_LATENT_SHAPE))
this.assertLength('camera noise', camera, elementCount(TRIPOSPLAT_CAMERA_SHAPE))
const ditDescriptor = this.requireGraph('dit')
await this.loadGraph('dit')
let ditInferenceMs = 0
let ditReadbackMs = 0
const steps = options.steps ?? 20
let flowState: FlowTensorState
try {
flowState = await sampleFlowEulerCfg(
async (invocation: FlowModelInvocation<TripoSplatCondition>) => {
const response = await this.client.runSession({
sessionId: SESSION_IDS.dit,
inputs: {
latent: inputPayload(ditDescriptor, invocation.sample.latent, TRIPOSPLAT_LATENT_SHAPE),
camera: inputPayload(ditDescriptor, invocation.sample.camera, TRIPOSPLAT_CAMERA_SHAPE),
t: inputPayload(
ditDescriptor,
new Float32Array(invocation.timestepTensor),
[1],
),
feature1: inputPayload(
ditDescriptor,
new Float32Array(invocation.condition.feature1),
TRIPOSPLAT_FEATURE1_SHAPE,
),
feature2: inputPayload(
ditDescriptor,
new Float32Array(invocation.condition.feature2),
TRIPOSPLAT_FEATURE2_SHAPE,
),
},
outputs: ['pred_latent', 'pred_camera'],
tag: `flow-${invocation.pass}-${invocation.step}-of-${invocation.totalSteps}`,
})
ditInferenceMs += response.timings.inferenceMs
ditReadbackMs += response.timings.readbackMs
const predictedLatent = response.outputs.pred_latent
const predictedCamera = response.outputs.pred_camera
if (!predictedLatent || !predictedCamera) {
throw new Error('DiT graph must return pred_latent and pred_camera.')
}
return {
latent: payloadToFloat32('pred_latent', predictedLatent),
camera: payloadToFloat32('pred_camera', predictedCamera),
}
},
{ latent, camera },
{
condition,
negativeCondition,
steps,
guidanceScale: options.guidanceScale ?? 3,
shift: options.shift ?? 3,
predictionArithmetic: ditDescriptor.internalPrecision === 'float32'
? 'float32'
: 'float16',
signal: options.signal,
onStep: ({ step, totalSteps }) => {
options.onProgress?.({
stage: 'sampling',
message: `TripoSplat flow step ${step}/${totalSteps}…`,
progress: step / totalSteps,
step,
totalSteps,
})
},
},
)
} finally {
await this.disposeGraph('dit')
}
const numGaussians = this.normalizeGaussianCount(options.numGaussians ?? TRIPOSPLAT_MAX_GAUSSIANS)
const numPoints = numGaussians / TRIPOSPLAT_GAUSSIANS_PER_POINT
const octreeDescriptor = this.requireGraph('octree')
await this.loadGraph('octree')
let octreeInferenceMs = 0
options.onProgress?.({ stage: 'decoding-octree', message: 'Sampling the dynamic occupancy octree…' })
let points: OctreeSampleResult
try {
points = await sampleOctree(
async (invocation: OctreeOccupancyInvocation<Float32Array>) => {
const paddedCenters = new Float32Array(TRIPOSPLAT_MAX_DECODER_POINTS * 3)
paddedCenters.set(invocation.parentCenters)
const response = await this.client.runSession({
sessionId: SESSION_IDS.octree,
inputs: {
x: inputPayload(octreeDescriptor, paddedCenters, [1, TRIPOSPLAT_MAX_DECODER_POINTS, 3]),
l: inputPayload(octreeDescriptor, Float32Array.of(invocation.resolution), [1]),
cond: inputPayload(
octreeDescriptor,
new Float32Array(invocation.condition),
TRIPOSPLAT_LATENT_SHAPE,
),
},
outputs: ['logits'],
tag: `octree-level-${invocation.level}`,
})
octreeInferenceMs += response.timings.inferenceMs
const logitsPayload = response.outputs.logits
if (!logitsPayload) throw new Error('Octree graph did not return logits.')
const paddedLogits = payloadToFloat32('logits', logitsPayload)
const required = invocation.parentCount * 8
if (paddedLogits.length < required) {
throw new Error(`Octree returned ${paddedLogits.length} logits; active frontier needs ${required}.`)
}
return { logits: paddedLogits.slice(0, required) }
},
{
condition: flowState.latent,
numPoints,
rng: () => random.next(),
signal: options.signal,
onLevel: ({ level, totalLevels, occupiedVoxels }) => {
options.onProgress?.({
stage: 'decoding-octree',
message: `Octree level ${level}/${totalLevels}: ${occupiedVoxels.toLocaleString()} occupied voxels…`,
progress: level / totalLevels,
})
},
},
)
} finally {
await this.disposeGraph('octree')
}
const gaussianDescriptor = this.requireGraph('gaussianDecoder')
await this.loadGraph('gaussianDecoder')
options.onProgress?.({ stage: 'decoding-gaussians', message: 'Decoding Gaussian attributes…' })
let features: Float32Array
let gaussianInferenceMs = 0
try {
const gaussianResponse = await this.client.runSession({
sessionId: SESSION_IDS.gaussianDecoder,
inputs: {
points: inputPayload(gaussianDescriptor, new Float32Array(points.points), [1, numPoints, 3]),
cond: inputPayload(
gaussianDescriptor,
new Float32Array(flowState.latent),
TRIPOSPLAT_LATENT_SHAPE,
),
},
outputs: ['features'],
})
gaussianInferenceMs = gaussianResponse.timings.inferenceMs
const featurePayload = gaussianResponse.outputs.features
if (!featurePayload) throw new Error('Gaussian decoder graph did not return features.')
features = payloadToFloat32('features', featurePayload)
} finally {
await this.disposeGraph('gaussianDecoder')
}
this.assertLength('Gaussian decoder features', features, numPoints * TRIPOSPLAT_GS_FEATURE_WIDTH)
const gaussians = decodeTripoSplatGaussianFeatures(points.points, features)
options.onProgress?.({ stage: 'building-ply', message: 'Building browser Gaussian PLY…' })
const ply = buildGaussianPly(gaussians)
return {
model: 'triposplat',
count: numGaussians,
totalCount: numGaussians,
ply,
gaussians,
coordinateSystem: 'triposplat-object',
colorSpace: 'sh0',
metadata: {
steps,
guidanceScale: options.guidanceScale ?? 3,
shift: options.shift ?? 3,
seed: options.seed ?? 42,
ditInferenceMs,
ditReadbackMs,
octreeInferenceMs,
gaussianInferenceMs,
},
}
}
async dispose(): Promise<void> {
if (this.disposed) return
this.disposed = true
this.sessions.clear()
await this.client.dispose()
}
private async loadGraph(name: TripoSplatGraphName): Promise<OrtLoadSessionResult> {
const existing = this.sessions.get(name)
if (existing) return existing
const descriptor = this.requireGraph(name)
const loaded = await this.client.loadSession({
sessionId: SESSION_IDS[name],
manifest: descriptor.manifest,
options: {
allowWasmFallback: this.allowWasmFallback,
// Match the validated browser labs for every published graph. DiT also
// relies on exported Add(0) dense-layout barriers that ORT must retain.
graphOptimizationLevel: 'disabled',
},
})
this.sessions.set(name, loaded)
return loaded
}
private async disposeGraph(name: TripoSplatGraphName): Promise<void> {
if (!this.sessions.delete(name)) return
await this.client.disposeSession(SESSION_IDS[name])
}
private requireGraph(name: TripoSplatGraphName): TripoSplatGraphDescriptor {
const graph = this.graphs[name]
if (!graph) throw new Error(`TripoSplat graph '${name}' is not configured.`)
return graph
}
private assertLength(label: string, value: Float32Array, expected: number): void {
if (value.length !== expected) throw new Error(`${label} contains ${value.length} values; expected ${expected}.`)
}
private normalizeGaussianCount(requested: number): number {
if (!Number.isFinite(requested) || requested < TRIPOSPLAT_MIN_GAUSSIANS || requested > TRIPOSPLAT_MAX_GAUSSIANS) {
throw new Error(
`numGaussians must be in [${TRIPOSPLAT_MIN_GAUSSIANS}, ${TRIPOSPLAT_MAX_GAUSSIANS}].`,
)
}
const rounded = Math.round(requested / TRIPOSPLAT_GAUSSIANS_PER_POINT) * TRIPOSPLAT_GAUSSIANS_PER_POINT
if (rounded !== TRIPOSPLAT_MAX_GAUSSIANS) {
throw new Error(
`The checked-in Gaussian decoder contract is fixed at ${TRIPOSPLAT_MAX_GAUSSIANS} ` +
`Gaussians (8192 decoder points); requested ${rounded}. Export a separate fixed-shape ` +
`decoder to support another count without changing full self-attention semantics.`,
)
}
return rounded
}
private assertUsable(): void {
if (this.disposed) throw new Error('TripoSplatWebGPUModel has been disposed.')
}
}