lod.ts
examples/sogs/lod.ts · raw · open the example · all examples · site home
Exports: SogLodTreeBudgetModeLodSettingsdefaultLodSettingsfovScaleisLodMetaparseLodavailableLevelVec3LikeLevelChainslevelChainsVec3LikeselectLevels
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 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374
// SOG LoD scenes (PlayCanvas's lod-meta.json, written by splat-transform):
// a tree of bounds whose leaves each name, per level (0 finest), a range
// { file, offset, count } of one of the scene's SOG files (unbundled
// meta.json folders, named <level>_<unit>, many leaves per file), plus an
// optional environment SOG drawn whole. parseLod flattens the leaves into
// typed arrays; selectLevels picks a level per leaf from the camera, after
// PlayCanvas's GSplatOctreeInstance and GSplatBudgetBalancer (engine
// 2.23): the distance to the leaf's box (longer behind the camera, scaled
// by the field of view) against thresholds s * baseDistance * multiplier^i,
// clamped to a range, with one scale s for the whole scene fitted to the
// splat budget: in "target" mode the largest s that fits (detail rises to
// fill the budget), in "limit" mode the same but at most 1 (the distances
// are the most detail allowed).
interface RawNode {
bound: { min: number[]; max: number[] };
children?: RawNode[];
lods?: Record<string, { file: number; offset: number; count: number }>;
}
interface RawLodMeta {
lodLevels: number;
filenames: string[];
environment?: string | null;
tree: RawNode;
counts?: number[];
}
export interface SogLodTree {
url: string;
levels: number;
// Absolute meta.json URLs, and the level each one holds
files: string[];
fileLevel: Int8Array;
environment?: string;
leaves: number;
// Per leaf: min xyz, max xyz
bounds: Float32Array;
// Per leaf and level (leaf * levels + level): the range, file -1 for none
file: Int32Array;
offset: Uint32Array;
count: Uint32Array;
min: [number, number, number];
max: [number, number, number];
// Splats of all leaves per level, and what the file says they are
levelTotals: number[];
counts?: number[];
}
// "target": detail rises until the budget is used; "limit": the distances
// decide and the budget only lowers detail
export type BudgetMode = "target" | "limit";
export interface LodSettings {
baseDistance: number;
multiplier: number;
rangeMin: number;
rangeMax: number;
behindPenalty: number;
// Splats selected (0: none; target: the finest everywhere, limit: the
// distances alone)
splatBudget: number;
budgetMode: BudgetMode;
// Levels coarser than the selected one a leaf may show while the
// selected one loads (the finest resident in that window; 0: the
// selected one only, else what it showed before)
underfillLimit: number;
// Most splats of loaded files kept on the GPU
residentBudget: number;
// Re-select after the camera moves this far (object units) or turns this
// many degrees (turning only matters through behindPenalty)
updateDistance: number;
updateAngle: number;
freeze: boolean;
colorLevels: boolean;
}
// After PlayCanvas's streaming examples, but the budget caps rather than
// fills: a filled budget is often more than the GPU (or the CPU sort's
// sort and upload) keeps at frame rate while flying, where the distances'
// own selection is often well under it
export function defaultLodSettings(cpuSort: boolean): LodSettings {
return {
baseDistance: 5, multiplier: 3, rangeMin: 0, rangeMax: 7, behindPenalty: 2,
splatBudget: cpuSort ? 2_000_000 : 4_000_000, budgetMode: "limit", underfillLimit: 5, residentBudget: 16_000_000,
updateDistance: 0.5, updateAngle: 90, freeze: false, colorLevels: false,
};
}
// The distance scale of a perspective camera's field of view against
// PlayCanvas's reference 45 degrees (vertical), by the narrower of the two
// half angles: wider views see leaves smaller, so farther
export function fovScale(fov: number, aspect: number): number {
const tanHalfV = Math.tan((fov * Math.PI) / 360);
const tanHalfH = tanHalfV * (aspect > 0 && Number.isFinite(aspect) ? aspect : 1);
return Math.min(tanHalfV, tanHalfH) / Math.tan(Math.PI / 8);
}
export function isLodMeta(json: unknown): boolean {
const meta = json as RawLodMeta;
return typeof meta === "object" && meta !== null && typeof meta.lodLevels === "number" && Array.isArray(meta.filenames) && typeof meta.tree === "object";
}
export function parseLod(json: unknown, url: string): SogLodTree {
if (!isLodMeta(json)) {
throw new Error("Not a SOG lod-meta.json");
}
const meta = json as RawLodMeta;
const levels = meta.lodLevels;
const raw: RawNode[] = [];
const stack = [meta.tree];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.lods) {
raw.push(node);
}
stack.push(...(node.children ?? []));
}
const leaves = raw.length;
const bounds = new Float32Array(leaves * 6);
const file = new Int32Array(leaves * levels).fill(-1);
const offset = new Uint32Array(leaves * levels);
const count = new Uint32Array(leaves * levels);
const fileLevel = new Int8Array(meta.filenames.length).fill(-1);
const levelTotals = new Array<number>(levels).fill(0);
raw.forEach((node, leaf) => {
bounds.set(node.bound.min, leaf * 6);
bounds.set(node.bound.max, leaf * 6 + 3);
for (const [key, range] of Object.entries(node.lods!)) {
const level = Number(key);
if (level < 0 || level >= levels || range.count === 0) {
continue;
}
const at = leaf * levels + level;
file[at] = range.file;
offset[at] = range.offset;
count[at] = range.count;
fileLevel[range.file] = level;
levelTotals[level] += range.count;
}
});
return {
url,
levels,
files: meta.filenames.map((name) => new URL(name, url).href),
fileLevel,
environment: meta.environment ? new URL(meta.environment, url).href : undefined,
leaves,
bounds,
file,
offset,
count,
min: meta.tree.bound.min as [number, number, number],
max: meta.tree.bound.max as [number, number, number],
levelTotals,
counts: meta.counts,
};
}
// The level of `leaf` nearest `level` that it has, coarser first; -1 if none
export function availableLevel(tree: SogLodTree, leaf: number, level: number): number {
const base = leaf * tree.levels;
for (let l = level; l < tree.levels; l += 1) {
if (tree.file[base + l] >= 0) {
return l;
}
}
for (let l = level - 1; l >= 0; l -= 1) {
if (tree.file[base + l] >= 0) {
return l;
}
}
return -1;
}
export interface Vec3Like {
x: number;
y: number;
z: number;
}
// Every leaf's levels over a level range: band b (0 finest) is level
// rangeMin + b, snapped to a level the leaf has (availableLevel), with its
// splats; a leaf missing a level repeats a neighbor, so that band step
// changes nothing
export interface LevelChains {
rangeMin: number;
span: number;
// Per leaf and band (leaf * span + b); level -1: the leaf has none
level: Int8Array;
count: Float64Array;
// Splats of all leaves at the finest and the coarsest band
finest: number;
coarsest: number;
}
const chainCache = new WeakMap<SogLodTree, LevelChains>();
export function levelChains(tree: SogLodTree, rangeMin: number, rangeMax: number): LevelChains {
const cached = chainCache.get(tree);
if (cached && cached.rangeMin === rangeMin && cached.span === rangeMax - rangeMin + 1) {
return cached;
}
const span = rangeMax - rangeMin + 1;
const level = new Int8Array(tree.leaves * span);
const count = new Float64Array(tree.leaves * span);
let finest = 0;
let coarsest = 0;
for (let leaf = 0; leaf < tree.leaves; leaf += 1) {
for (let b = 0; b < span; b += 1) {
const l = availableLevel(tree, leaf, rangeMin + b);
level[leaf * span + b] = l;
count[leaf * span + b] = l >= 0 ? tree.count[leaf * tree.levels + l] : 0;
}
finest += count[leaf * span];
coarsest += count[leaf * span + span - 1];
}
const chains = { rangeMin, span, level, count, finest, coarsest };
chainCache.set(tree, chains);
return chains;
}
export interface Vec3Like {
x: number;
y: number;
z: number;
}
// The budget fit's axis: ln s over [-48, 48] in BINS bins (any scene's
// switch points fall inside; ones outside share an end bin), the bin that
// runs out of budget split again into BINS sub-bins. s = 1 falls on the
// edge of bin BINS / 2, so limit mode keeps the configured distances
// exactly.
const BINS = 1024;
const LOG_MIN = -48;
const BIN_SCALE = BINS / 96;
const UNIT_BIN = BINS / 2;
const histogram = new Float64Array(BINS);
let logDistances = new Float64Array(0);
const binOf = (x: number) => Math.min(BINS - 1, Math.max(0, Math.floor(x)));
// The level of every leaf into `out` for a camera at `eye` looking along
// unit `forward` (both in the tree's space) with field-of-view scale
// `fov` (fovScale), and its distance (longer behind the camera, scaled by
// the field of view) into `d`; returns the splats selected.
//
// A leaf at distance d is at band 1 + floor(log_m(d / (s * base))),
// clamped to the range: finer than band L + 1 exactly when
// ln s > ln(d / base) - L ln m (with L counted from rangeMin). All those
// switch points lie on one ln s axis, so a histogram of the splats each
// one adds, summed from the coarsest end, finds the largest s within the
// budget. Stopping at the first sub-bin that does not fit (not skipping
// it) keeps a leaf's level from depending on leaves farther along the
// axis, so small camera moves do not flip levels on and off.
export function selectLevels(tree: SogLodTree, eye: Vec3Like, forward: Vec3Like, s: LodSettings, out: Int8Array, d: Float32Array, fov = 1): number {
const { leaves, bounds } = tree;
const rangeMax = Math.max(0, Math.min(s.rangeMax, tree.levels - 1));
const rangeMin = Math.max(0, Math.min(s.rangeMin, rangeMax));
const chains = levelChains(tree, rangeMin, rangeMax);
const { span } = chains;
const penalty = Math.max(0, s.behindPenalty - 1);
const logBase = Math.log(Math.max(1e-6, s.baseDistance));
const logMultiplier = Math.log(Math.max(1.2, s.multiplier));
// Bins per band step
const step = logMultiplier * BIN_SCALE;
if (logDistances.length < leaves) {
logDistances = new Float64Array(leaves);
}
for (let leaf = 0; leaf < leaves; leaf += 1) {
const b = leaf * 6;
const dx = Math.max(bounds[b], Math.min(eye.x, bounds[b + 3])) - eye.x;
const dy = Math.max(bounds[b + 1], Math.min(eye.y, bounds[b + 4])) - eye.y;
const dz = Math.max(bounds[b + 2], Math.min(eye.z, bounds[b + 5])) - eye.z;
let distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
// Toward the box's nearest point: 0 side-on, 1 straight behind
if (penalty > 0 && distance > 0.01) {
const facing = (dx * forward.x + dy * forward.y + dz * forward.z) / distance;
if (facing < 0) {
distance *= 1 - facing * penalty;
}
}
distance *= fov;
d[leaf] = distance;
// In bins: the leaf's switch point leaving band b for b - 1 is at
// position - (rangeMin + b - 1) * step
logDistances[leaf] = (Math.log(Math.max(distance, 1e-6)) - logBase - LOG_MIN) * BIN_SCALE;
}
const switchAt = (leaf: number, b: number) => logDistances[leaf] - (rangeMin + b - 1) * step;
// The cut: switch points in bins below cutBin are taken, and in cutBin
// those in sub-bins below cutSub
const budget = s.splatBudget > 0 ? s.splatBudget : Infinity;
const limit = s.budgetMode === "limit";
let cutBin = UNIT_BIN;
let cutSub = 0;
if (chains.coarsest >= budget) {
cutBin = -1;
} else if (!limit && chains.finest <= budget) {
cutBin = BINS;
} else {
// Limit mode within budget at the configured distances: the cut at s = 1
let unitTotal = 0;
if (limit) {
for (let leaf = 0; leaf < leaves; leaf += 1) {
let b = span - 1;
while (b > 0 && binOf(switchAt(leaf, b)) < UNIT_BIN) {
b -= 1;
}
unitTotal += chains.count[leaf * span + b];
}
}
if (!limit || unitTotal > budget) {
const stop = limit ? UNIT_BIN : BINS;
let total = chains.coarsest;
const accumulate = (bin: number) => {
histogram.fill(0);
for (let leaf = 0; leaf < leaves; leaf += 1) {
for (let b = 1; b < span; b += 1) {
const delta = chains.count[leaf * span + b - 1] - chains.count[leaf * span + b];
if (delta === 0) {
continue;
}
const x = switchAt(leaf, b);
const at = binOf(x);
if (bin < 0) {
histogram[at] += delta;
} else if (at === bin) {
histogram[binOf((x - bin) * BINS)] += delta;
}
}
}
};
accumulate(-1);
cutBin = 0;
while (cutBin < stop && total + histogram[cutBin] <= budget) {
total += histogram[cutBin];
cutBin += 1;
}
if (cutBin < stop) {
accumulate(cutBin);
while (cutSub < BINS && total + histogram[cutSub] <= budget) {
total += histogram[cutSub];
cutSub += 1;
}
}
}
}
// Each leaf steps finer while its next switch point is taken; switch
// points rise as bands get finer, so the first one not taken ends it
let total = 0;
for (let leaf = 0; leaf < leaves; leaf += 1) {
let b = span - 1;
if (cutBin >= BINS) {
b = 0;
} else if (cutBin >= 0) {
while (b > 0) {
const x = switchAt(leaf, b);
const bin = binOf(x);
if (bin > cutBin || (bin === cutBin && binOf((x - cutBin) * BINS) >= cutSub)) {
break;
}
b -= 1;
}
}
out[leaf] = chains.level[leaf * span + b];
total += chains.count[leaf * span + b];
}
return total;
}