gpuDecode.ts
examples/sogs/gpuDecode.ts · raw · open the example · all examples · site home
Exports: PreparedSogprepareSogGpuclosePrepareduploadSogGpudecodeSogGpu
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257
// SOG decoding on the GPU, for files streamed while drawing (LoD chunks):
// each WebP becomes an ImageBitmap (decoded off the main thread) and a
// texture, and one GpuArrayWriter pass (a fragment pass, both backends)
// reads splat i's texel (i % width, i / width) of every image and stores
// it as an ext splat (GpuSplats.storeExt) with its palette label, as
// sog.ts decodes on the CPU. Per-byte values (scales, colors, alpha,
// quaternion components) come from a 256-row table built from the meta,
// so v1 ranges and v2 codebooks take one shader. One more writer pass turns
// the centroid image into an ext palette (ShPalette layout), each byte looked
// up in the same table. prepareSogGpu does the asynchronous half
// (downloads, image decodes), uploadSogGpu the synchronous one (textures,
// the passes), so a stream can land one file per frame.
import * as THREE from "three";
import type { WebGPURenderer } from "three/webgpu";
import {
ArrayData, GpuArrayReader, GpuArrayWriter, GpuArrays, GpuEncoding, GpuShPalette, GpuSplats, ShPalette, Splats, TSL, defineBundle,
type Schema, type ShDegree, type TslNode,
} from "@sparkjsdev/radix";
import type { SogArrays } from "./sh.js";
import { LABEL_BUNDLE, SH_COEFFICIENTS, alphaTable, colorTables, paletteShape, scaleTables, shTable, type Progress, type SogMeta, type SogSource } from "./sog.js";
import { decodeBitmap, type Pixels } from "./webp.js";
const { abs, exp, int, ivec2, max, mix, normalize, select, sign, sqrt, texture, uint, uniform, uvec4, vec3, vec4 } = TSL;
// Per byte b: scale (x, y, z: exp of the log scale; w: alpha), color (x, y, z:
// rgb; w: a quaternion component, (b / 255 - 0.5) sqrt 2), sh (an SH value)
const TABLES = defineBundle({ scale: "float32x4", color: "float32x4", sh: "float32" });
const IMAGES = ["meansL", "meansU", "quats", "scales", "sh0", "labels"] as const;
type ImageName = (typeof IMAGES)[number];
function imageTexture(image: ImageBitmap | Pixels) {
const texture = image instanceof ImageBitmap ? new THREE.Texture(image) : new THREE.DataTexture(image.rgba, image.width, image.height);
texture.flipY = false;
texture.premultiplyAlpha = false;
texture.colorSpace = THREE.NoColorSpace;
texture.minFilter = texture.magFilter = THREE.NearestFilter;
texture.generateMipmaps = false;
texture.needsUpdate = true;
return texture;
}
const placeholder = imageTexture({ rgba: new Uint8Array(4), width: 1, height: 1 });
interface Decoder {
writer: GpuArrayWriter;
images: Record<ImageName, TslNode>;
tables: GpuArrayReader<typeof TABLES>;
width: TslNode;
mins: TslNode;
maxs: TslNode;
}
function makeDecoder(labels: boolean): Decoder {
const images = Object.fromEntries(IMAGES.map((name) => [name, texture(placeholder)])) as Record<ImageName, TslNode>;
const tables = new GpuArrayReader(TABLES);
const width = uniform(1, "uint");
const mins = uniform(new THREE.Vector3());
const maxs = uniform(new THREE.Vector3());
const outputs: Schema = labels ? { extSplats: Splats.EXT, shLabel: LABEL_BUNDLE } : { extSplats: Splats.EXT };
const writer = new GpuArrayWriter({
outputs,
write: (index, out) => {
const coord = ivec2(int(index.mod(width)), int(index.div(width)));
const bytes = (name: ImageName) => uvec4(images[name].load(coord).mul(255).add(0.5)).toVar();
const lo = bytes("meansL");
const hi = bytes("meansU");
const v = mix(mins, maxs, vec3(lo.xyz).add(vec3(hi.xyz).mul(256)).div(65535)).toVar();
const center = sign(v).mul(exp(abs(v)).sub(1));
const q = bytes("quats");
const a = tables.load("color", q.x).w.toVar();
const b = tables.load("color", q.y).w.toVar();
const c = tables.load("color", q.z).w.toVar();
const r = sqrt(max(a.mul(a).add(b.mul(b)).add(c.mul(c)).oneMinus(), 0)).toVar();
const mode = q.w.sub(uint(252)).toVar();
const quaternion = normalize(vec4(
select(mode.equal(uint(0)), a, select(mode.equal(uint(1)), r, b)),
select(mode.lessThanEqual(uint(1)), b, select(mode.equal(uint(2)), r, c)),
select(mode.lessThanEqual(uint(2)), c, r),
select(mode.equal(uint(0)), r, a),
));
const s = bytes("scales");
const scales = vec3(tables.load("scale", s.x).x, tables.load("scale", s.y).y, tables.load("scale", s.z).z);
const k = bytes("sh0");
const rgb = vec3(tables.load("color", k.x).x, tables.load("color", k.y).y, tables.load("color", k.z).z);
const opacity = tables.load("scale", k.w).w;
const o = out as Record<string, Record<string, TslNode>>;
GpuSplats.storeExt(o.extSplats as { extA: TslNode; extB: TslNode }, GpuSplats.Splat(center, scales, quaternion, rgb, opacity));
if (labels) {
const l = bytes("labels");
o.shLabel.label.assign(l.x.bitOr(l.y.shiftLeft(uint(8))));
}
},
});
return { writer, images, tables, width, mins, maxs };
}
const decoders = new Map<boolean, Decoder>();
const decoderFor = (labels: boolean) => {
if (!decoders.has(labels)) {
decoders.set(labels, makeDecoder(labels));
}
return decoders.get(labels)!;
};
// The per-byte table of a file's meta, on the GPU
function tablesOf(renderer: WebGPURenderer, source: SogSource) {
const [sx, sy, sz] = scaleTables(source.meta);
const [cr, cg, cb] = colorTables(source.meta);
const alpha = alphaTable(source.meta);
const data = new ArrayData({ count: 256 }).addBundle("tables", TABLES);
const { scale, color, sh } = data.bundles.tables.channels;
for (let b = 0; b < 256; b += 1) {
scale.data.set([sx[b], sy[b], sz[b], alpha[b]], b * 4);
color.data.set([cr[b], cg[b], cb[b], (b / 255 - 0.5) * Math.SQRT2], b * 4);
}
if (source.meta.shN) {
sh.data.set(shTable(source.meta));
}
return GpuArrays.fromData(renderer, data);
}
// --- The SH palette ---
interface PaletteWriter {
writer: GpuArrayWriter;
centroids: TslNode;
tables: GpuArrayReader<typeof TABLES>;
}
// An ext palette's entries: element t is texel j = t % stride of entry
// n = t / stride, words 4 j .. 4 j + 3 of the entry, word k being
// coefficient k (zero past the degree's), the centroid texel at
// x = (n % 64) * coefficients + k, y = n / 64, its bytes looked up in the
// sh table
function makePaletteWriter(degree: ShDegree, stride: number): PaletteWriter {
const centroids = texture(placeholder);
const tables = new GpuArrayReader(TABLES);
const coefficients = SH_COEFFICIENTS[degree];
const writer = new GpuArrayWriter({
outputs: { shPalette: ShPalette.BUNDLE },
write: (index, out) => {
const n = index.div(uint(stride)).toVar();
const j = index.mod(uint(stride)).toVar();
const x = n.mod(uint(64)).mul(uint(coefficients)).toVar();
const y = int(n.div(uint(64))).toVar();
const word = (w: number) => {
const k = j.mul(uint(4)).add(uint(w)).toVar();
const texel = uvec4(centroids.load(ivec2(int(x.add(k)), y)).mul(255).add(0.5)).toVar();
const value = vec3(tables.load("sh", texel.x), tables.load("sh", texel.y), tables.load("sh", texel.z));
return select(k.lessThan(uint(coefficients)), GpuEncoding.encodeExtRgb(value), uint(0));
};
(out.shPalette as Record<string, TslNode>).shEntries.assign(uvec4(word(0), word(1), word(2), word(3)));
},
});
return { writer, centroids, tables };
}
const paletteWriters = new Map<string, PaletteWriter>();
function paletteWriterFor(degree: ShDegree, stride: number) {
const key = `${degree}|${stride}`;
if (!paletteWriters.has(key)) {
paletteWriters.set(key, makePaletteWriter(degree, stride));
}
return paletteWriters.get(key)!;
}
// The ext palette from the centroid image, on the GPU (`tables`: the
// file's per-byte tables)
function decodePaletteGpu(renderer: WebGPURenderer, meta: SogMeta, image: ImageBitmap, tables: GpuArrays): { palette: GpuArrays; degree: number } {
const { degree, size } = paletteShape(meta, image);
const palette = GpuShPalette.create(renderer, { codec: "ext", degree, size });
const stride = palette.bundle("shPalette").meta.stride as number;
const centroids = imageTexture(image);
const each = paletteWriterFor(degree, stride);
each.centroids.value = centroids;
each.tables.bind(tables, "tables");
palette.write(each.writer, { at: 0, count: size * stride });
each.centroids.value = placeholder;
each.tables.bind();
centroids.dispose();
return { palette, degree };
}
// A SOG's images as ImageBitmaps (decoded off the main thread), ready for
// uploadSogGpu
export interface PreparedSog {
source: SogSource;
images: [ImageName, ImageBitmap | Pixels][];
// The SH centroid image
centroids?: ImageBitmap;
}
export async function prepareSogGpu(source: SogSource): Promise<PreparedSog> {
const { meta } = source;
const names: Partial<Record<ImageName, string>> = {
meansL: meta.means.files[0], meansU: meta.means.files[1], quats: meta.quats.files[0], scales: meta.scales.files[0], sh0: meta.sh0.files[0],
labels: meta.shN?.files[1],
};
const entries = Object.entries(names).filter((entry): entry is [ImageName, string] => entry[1] !== undefined);
const [bitmaps, centroids] = await Promise.all([
Promise.all(entries.map(([, file]) => source.getFile(file).then(decodeBitmap))),
meta.shN ? source.getFile(meta.shN.files[0]).then(decodeBitmap) : undefined,
]);
return { source, images: entries.map(([name], i) => [name, bitmaps[i]]), centroids };
}
// Frees a prepared SOG that will not be uploaded
export function closePrepared({ images, centroids }: PreparedSog) {
images.forEach(([, image]) => (image instanceof ImageBitmap ? image.close() : undefined));
centroids?.close();
}
// The textures and the writer passes (main thread, synchronous); the
// bitmaps are closed
export function uploadSogGpu(renderer: WebGPURenderer, prepared: PreparedSog): SogArrays {
const { source, images, centroids } = prepared;
const { meta } = source;
const decoder = decoderFor(meta.shN !== undefined);
const textures = images.map(([, bitmap]) => imageTexture(bitmap));
images.forEach(([name], i) => (decoder.images[name].value = textures[i]));
decoder.width.value = images[0][1].width;
decoder.mins.value.fromArray(meta.means.mins);
decoder.maxs.value.fromArray(meta.means.maxs);
const tables = tablesOf(renderer, source);
decoder.tables.bind(tables, "tables");
const arrays = new GpuArrays({ renderer, bundles: decoder.writer.outputs, capacity: meta.count });
arrays.write(decoder.writer, { at: 0, count: meta.count });
for (const name of IMAGES) {
decoder.images[name].value = placeholder;
}
decoder.tables.bind();
textures.forEach((each) => each.dispose());
const palette = centroids && decodePaletteGpu(renderer, meta, centroids, tables);
tables.dispose();
closePrepared(prepared);
if (!palette) {
return { arrays, degree: 0, layout: "extSplats" };
}
return { arrays, palette: palette.palette, degree: palette.degree, layout: "extSplats" };
}
// One SOG decoded into GpuArrays of ext splats (with shLabel when it has
// SH) and its palette
export async function decodeSogGpu(renderer: WebGPURenderer, source: SogSource, { onProgress }: { onProgress?: Progress } = {}): Promise<SogArrays> {
const prepared = await prepareSogGpu(source);
onProgress?.(1, "decoding");
return uploadSogGpu(renderer, prepared);
}