scenes.ts
examples/scenes/scenes.ts · raw · open the example · all examples · site home
Exports: SceneContextDemoSCENES
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 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426
// The scenes of /build/examples/scenes/index.html, each a short use of collections, renderers,
// views and modifiers (docs/rendering.md, views.md and
// generators.md describe the parts; recipes.md has these as recipes):
// multi several objects with their own transforms (one scaled by a
// gizmo, non-uniformly too) and a TSL generator, in one sort
// views one SplatRenderer drawn from two cameras side by side; the
// right camera's view sets its own collection and a modifier (and,
// on the CPU path, each camera's view holds its own order)
// modifiers view-space depth and normals, world height, a wave in a
// gizmo's frame, switched live; RGB and depth thumbnails
// rendered every frame through views of the app's own keys
// without recompiling
// subset a fromSplats generator remapped by an index list recomputed
// from the camera (the nearest splats), or by a TSL function;
// each object's release returns its index list to a pool once
// nothing draws it (the splats stay)
// offline a still at a fixed size from its own renderer, sorted exactly
// for its camera, read back and shown over the page until
// clicked away
import * as THREE from "three";
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
import type { WebGPURenderer } from "three/webgpu";
import {
GpuSplats, SplatGenerator, SplatCollection, SplatRenderer, Splats, TSL,
type AnySplatObject, type ArrayData, type CameraControls, type GpuArrays, type SplatNode, type SplatRendererOptions,
} from "@sparkjsdev/radix";
const { abs, clamp, cos, exp, float, length, mix, sin, uint, uniform, vec3, vec4 } = TSL;
export interface SceneContext {
renderer: WebGPURenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
controls: CameraControls;
// The loaded file, and the matrix that centers it at the origin, about a
// unit in radius, upright
data: ArrayData;
splats: GpuArrays;
fit: THREE.Matrix4;
webgpu: boolean;
// Seconds since the page started (main.ts sets it every frame)
time: ReturnType<typeof uniform>;
// A splat renderer in the page's sort mode (the sort button switches
// them all)
splatRenderer(options: Omit<SplatRendererOptions, "renderer">): SplatRenderer;
// A button in the bar; `active` lights it while it returns true
button(label: string, onClick: () => void, active?: () => boolean): void;
}
export interface Demo {
// Every frame, before the render (seconds)
frame?(time: number): void;
// Instead of rendering the scene from the camera
render?(): void;
// A line for the status
status?(): string;
}
// An Object3D at `position` whose child holds the file fitted to it: the
// splat object's world transform is the child's matrixWorld, the Object3D
// takes a gizmo
function place(ctx: SceneContext, position: [number, number, number]) {
const object = new THREE.Object3D();
object.position.set(...position);
const fitted = new THREE.Object3D();
fitted.matrixAutoUpdate = false;
fitted.matrix.copy(ctx.fit);
object.add(fitted);
ctx.scene.add(object);
return { object, matrixWorld: fitted.matrixWorld };
}
// A TSL generator as a splat object: 4000 small splats on a turning,
// rippling ring of radius 1, colored around it, placed by `matrixWorld`
function ring(ctx: SceneContext, matrixWorld: AnySplatObject["matrixWorld"] = new THREE.Matrix4().makeScale(1.4, 1.4, 1.4)): AnySplatObject {
const count = 4000;
const time = ctx.time;
const generator: SplatGenerator = {
generateSplat: (index) => {
const t = float(index).div(count);
const angle = t.mul(Math.PI * 2).add(time.mul(0.3));
const ripple = sin(t.mul(Math.PI * 24).add(time.mul(2))).mul(0.06);
const center = vec3(cos(angle), ripple, sin(angle));
const rgb = vec3(t, sin(t.mul(Math.PI)), float(1).sub(t));
return GpuSplats.Splat(center, vec3(0.008, 0.008, 0.004), vec4(0, 0, 0, 1), rgb, float(0.9), uint(index));
},
};
return { generator, count, matrixWorld };
}
// A gizmo that holds the camera controls while it drags
function gizmoOf(ctx: SceneContext) {
const gizmo = new TransformControls(ctx.camera, ctx.renderer.domElement);
gizmo.addEventListener("dragging-changed", (event) => (ctx.controls.enabled = !event.value));
ctx.scene.add(gizmo.getHelper());
return gizmo;
}
// A modifier setting the splat's rgb (made once: a program per modifier)
const tint = (rgb: (s: SplatNode) => unknown) => (s: SplatNode): SplatNode => {
const out = s.toVar();
out.get("rgb").assign(rgb(s) as never);
return out;
};
function multi(ctx: SceneContext): Demo {
const left = place(ctx, [-0.8, 0, 0]);
const right = place(ctx, [0.8, 0, 0]);
// The ring's matrixWorld is a function, read every render: a bob and a tilt
const ringMatrix = new THREE.Matrix4();
const tilt = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), 0.15);
const bob = new THREE.Vector3();
const scale = new THREE.Vector3(1.6, 1.6, 1.6);
const ringAt = () => ringMatrix.compose(bob.set(0, Math.sin(ctx.time.value as number) * 0.15, 0), tilt, scale);
// One collection: two objects of the same arrays (one program) and a
// generator, each placed by its own matrixWorld
const splatRenderer = ctx.splatRenderer({
collection: new SplatCollection([
SplatCollection.arrays(ctx.splats, { matrixWorld: left.matrixWorld }),
SplatCollection.arrays(ctx.splats, { matrixWorld: right.matrixWorld }),
ring(ctx, ringAt),
]),
});
ctx.scene.add(splatRenderer);
// A gizmo on the right object: scaling one axis takes the covariance path
const gizmo = gizmoOf(ctx);
gizmo.attach(right.object);
gizmo.setMode("scale");
for (const mode of ["translate", "rotate", "scale"] as const) {
ctx.button(mode, () => gizmo.setMode(mode), () => gizmo.mode === mode);
}
ctx.button("squash", () => right.object.scale.set(1, 0.35, 1.6));
return {
frame(seconds) {
left.object.rotation.y = seconds * 0.4;
},
status: () => `${splatRenderer.collection.objects.length} objects, ${splatRenderer.collection.count.toLocaleString()} splats in one sort`,
};
}
function views(ctx: SceneContext): Demo {
const leftObject = SplatCollection.arrays(ctx.splats, { matrixWorld: place(ctx, [-0.6, 0, 0]).matrixWorld });
const rightObject = SplatCollection.arrays(ctx.splats, { matrixWorld: place(ctx, [0.6, 0, 0.3]).matrixWorld });
const ringObject = ring(ctx, new THREE.Matrix4().makeScale(1.5, 1.5, 1.5));
// One renderer, rendered from two cameras: each render draws the view of
// its camera, or the renderer's own settings when it has none. The
// orbiting camera's view sets its own collection (the same splat objects,
// without the left one) and a modifier; the method is the renderer's.
const splatRenderer = ctx.splatRenderer({ collection: new SplatCollection([leftObject, rightObject, ringObject]) });
ctx.scene.add(splatRenderer);
const other = new THREE.PerspectiveCamera(60, 1, 0.01, 1000);
const otherView = splatRenderer.viewFor(other, {
collection: new SplatCollection([rightObject, ringObject]),
modifier: tint((s) => mix(s.get("rgb"), vec3(1, 0.55, 0.15), 0.5)),
});
for (const method of ["ewa", "3dgut", "htgs"] as const) {
ctx.button(method, () => (splatRenderer.method = method), () => splatRenderer.method === method);
}
const size = new THREE.Vector2();
const half = (x: number, camera: THREE.PerspectiveCamera) => {
camera.aspect = size.x / 2 / size.y;
camera.updateProjectionMatrix();
ctx.renderer.setViewport(x * size.x / 2, 0, size.x / 2, size.y);
ctx.renderer.setScissor(x * size.x / 2, 0, size.x / 2, size.y);
ctx.renderer.render(ctx.scene, camera);
};
const orderOf = (camera: THREE.Camera) => (splatRenderer.sortMode === "cpu" ? `, order of ${splatRenderer.views.get(camera)?.order?.visible ?? 0}` : "");
return {
frame(seconds) {
other.position.set(Math.sin(seconds * 0.3) * 3, 1.2, Math.cos(seconds * 0.3) * 3);
other.lookAt(0, 0, 0);
other.updateMatrixWorld();
},
render() {
// One clear, then both halves: a background would clear the whole
// canvas in each render on WebGPU, scissor or not
const renderer = ctx.renderer;
const background = ctx.scene.background;
renderer.getSize(size);
ctx.scene.background = null;
renderer.setClearColor(background as THREE.Color);
renderer.autoClear = false;
renderer.clear();
renderer.setScissorTest(true);
half(0, ctx.camera);
half(1, other);
renderer.setScissorTest(false);
renderer.setViewport(0, 0, size.x, size.y);
renderer.autoClear = true;
ctx.scene.background = background;
},
status: () => `${splatRenderer.method} | left: your camera, the renderer's collection${orderOf(ctx.camera)} | right: orbiting, its view's collection of ${otherView.collection?.objects.length} objects, tinted${orderOf(other)}`,
};
}
function modifiers(ctx: SceneContext): Demo {
const time = ctx.time;
const depthRange = uniform(new THREE.Vector2(1, 4));
const splatRenderer = ctx.splatRenderer({
collection: new SplatCollection([SplatCollection.arrays(ctx.splats, { matrixWorld: place(ctx, [0, 0, 0]).matrixWorld }), ring(ctx)]),
});
ctx.scene.add(splatRenderer);
// The frame the wave runs in, moved by a gizmo
const frame = new THREE.Object3D();
frame.position.set(0, -0.2, 0);
ctx.scene.add(frame);
const gizmo = gizmoOf(ctx);
// Made once each: switching among them compiles nothing after the first use
const depth = tint((s) => vec3(float(1).sub(clamp(s.get("center").z.negate().sub(depthRange.x).div(depthRange.y.sub(depthRange.x)), 0, 1))));
const choices: [string, SplatRenderer["modifier"], SplatRenderer["modifierSpace"]][] = [
["rgb", undefined, "view"],
["depth (view)", depth, "view"],
["normal (view)", tint((s) => abs(GpuSplats.normalOf(s))), "view"],
["height (world)", tint((s) => mix(vec3(0.1, 0.3, 1), vec3(1, 0.3, 0.1), clamp(s.get("center").y.mul(0.8).add(0.5), 0, 1))), "world"],
["wave (gizmo frame)", (s) => {
const out = s.toVar();
const c = s.get("center");
const d = length(vec3(c.x, 0, c.z));
const h = sin(d.mul(14).sub(time.mul(3))).mul(0.05).mul(exp(d.negate()));
out.get("center").assign(c.add(vec3(0, h, 0)));
out.get("rgb").assign(mix(s.get("rgb"), vec3(0.2, 0.7, 1), clamp(h.mul(12).add(0.3), 0, 1)));
return out;
}, frame.matrixWorld],
];
let chosen = 0;
const waving = () => choices[chosen][2] === frame.matrixWorld;
choices.forEach(([label, modifier, space], i) => ctx.button(label, () => {
chosen = i;
splatRenderer.modifier = modifier;
splatRenderer.modifierSpace = space;
if (waving()) {
gizmo.attach(frame);
} else {
gizmo.detach();
}
}, () => chosen === i));
// Two thumbnails, rendered every frame from the same camera through views
// of the app's own keys: plain (no modifier, whatever the renderer's) and
// depth. `splatRenderer.view` picks the view for the renders that follow.
const thumbs = [
splatRenderer.viewFor("thumb rgb", { modifier: null }),
splatRenderer.viewFor("thumb depth", { modifier: depth, modifierSpace: "view" }),
];
const targets = thumbs.map(() => new THREE.RenderTarget(384, 216, { type: THREE.HalfFloatType }));
const hud = new THREE.Scene();
const hudCamera = new THREE.OrthographicCamera(0, 1, 1, 0, -1, 1);
const quads = targets.map((target) => {
const quad = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: target.texture }));
hud.add(quad);
return quad;
});
const thumbCamera = new THREE.PerspectiveCamera();
return {
frame() {
const distance = ctx.camera.position.length();
(depthRange.value as THREE.Vector2).set(Math.max(distance - 1.2, 0.01), distance + 1.2);
},
render() {
const renderer = ctx.renderer;
gizmo.getHelper().visible = false;
thumbCamera.copy(ctx.camera);
thumbCamera.aspect = 16 / 9;
thumbCamera.updateProjectionMatrix();
thumbs.forEach((view, i) => {
splatRenderer.view = view;
renderer.setRenderTarget(targets[i]);
renderer.render(ctx.scene, thumbCamera);
});
splatRenderer.view = undefined;
renderer.setRenderTarget(null);
gizmo.getHelper().visible = waving();
renderer.render(ctx.scene, ctx.camera);
// 16:9 thumbnails a fifth of the width, stacked at the bottom right
const [w, h] = [0.2, 0.2 * ctx.camera.aspect * (9 / 16)];
quads.forEach((quad, i) => {
quad.scale.set(w, h, 1);
quad.position.set(1 - w / 2 - 0.01, 0.07 + h / 2 + i * (h + 0.01), 0);
});
renderer.autoClear = false;
renderer.render(hud, hudCamera);
renderer.autoClear = true;
},
status: () => `modifier: ${choices[chosen][0]}; thumbnails: rgb (bottom), depth (top), from one camera through two views`,
};
}
function subset(ctx: SceneContext): Demo {
const object = place(ctx, [0, 0, 0]);
const { data, splats, renderer } = ctx;
const n = Math.max(1, Math.floor(splats.count * 0.3));
// The centers, for the CPU's choice of the nearest
const centers = new Float32Array(data.count * 3);
const { bundle } = Splats.get(data);
const c = { x: 0, y: 0, z: 0 };
for (let i = 0; i < data.count; i += 1) {
Splats.loadCenter(bundle, i, c);
centers.set([c.x, c.y, c.z], i * 3);
}
// One generator for every index list: splat i is the arrays' splat list[i]
// (data: { splats, indices }); and a procedural one: every 4th splat
const listed = SplatGenerator.fromSplats({ remap: "indices" });
const everyFourth = SplatGenerator.fromSplats({ remap: (i) => i.mul(uint(4)) });
const splatRenderer = ctx.splatRenderer({});
ctx.scene.add(splatRenderer);
// Index lists come from a pool, and each splat object's release puts its
// index list back once nothing draws or sorts that object; the splats stay
const pool: GpuArrays[] = [];
let made = 0;
const inverse = new THREE.Matrix4();
const eye = new THREE.Vector3();
const chosenAt = new THREE.Vector3(Infinity, 0, 0);
const nearest = new Uint32Array(n);
let procedural = false;
function choose() {
inverse.copy(object.matrixWorld).invert();
eye.copy(ctx.camera.position).applyMatrix4(inverse);
const d2 = (i: number) => (centers[i * 3] - eye.x) ** 2 + (centers[i * 3 + 1] - eye.y) ** 2 + (centers[i * 3 + 2] - eye.z) ** 2;
// The n-th nearest distance from a sample, then everything closer
const sample = Float32Array.from({ length: 4096 }, () => d2(Math.floor(Math.random() * data.count))).sort();
const threshold = sample[Math.min(4095, Math.floor((n / data.count) * 4096))];
let count = 0;
for (let i = 0; i < data.count && count < n; i += 1) {
if (d2(i) <= threshold) {
nearest[count++] = i;
}
}
made += pool.length === 0 ? 1 : 0;
const indices = SplatGenerator.indices(renderer, nearest.subarray(0, count), pool.pop());
// object() checks the data against the generator's (StoredSplats)
splatRenderer.collection = new SplatCollection([SplatCollection.object({ generator: listed, data: { splats, indices }, count, matrixWorld: object.matrixWorld, release: () => pool.push(indices) })]);
}
ctx.button("nearest 30%", () => {
procedural = false;
chosenAt.set(Infinity, 0, 0);
}, () => !procedural);
ctx.button("every 4th", () => {
procedural = true;
splatRenderer.collection = new SplatCollection([{ generator: everyFourth, data: { splats }, count: Math.floor(splats.count / 4), matrixWorld: object.matrixWorld }]);
}, () => procedural);
// Re-chosen at most 4 times a second, after the camera moved 5 cm
let chosenTime = -Infinity;
return {
frame(seconds) {
object.object.updateMatrixWorld();
if (!procedural && ctx.camera.position.distanceTo(chosenAt) > 0.05 && seconds - chosenTime > 0.25) {
chosenAt.copy(ctx.camera.position);
chosenTime = seconds;
choose();
}
},
status: () => procedural ? `every 4th splat: ${splatRenderer.collection.count.toLocaleString()}` : `the nearest ${splatRenderer.collection.count.toLocaleString()} splats; ${made} index lists made, ${pool.length} back in the pool`,
};
}
function offline(ctx: SceneContext): Demo {
const collection = new SplatCollection([SplatCollection.arrays(ctx.splats, { matrixWorld: place(ctx, [0, 0, 0]).matrixWorld }), ring(ctx)]);
ctx.scene.add(ctx.splatRenderer({ collection }));
const preview = document.getElementById("preview") as HTMLImageElement;
preview.onclick = () => (preview.hidden = true);
// The still: its own renderer of the same collection in its own scene, with an
// exact CPU order for its camera (on either backend), at a fixed size
async function still(width = 1920, height = 1080) {
const renderer = ctx.renderer;
const camera = ctx.camera.clone();
camera.aspect = width / height;
camera.updateProjectionMatrix();
const stillSplats = new SplatRenderer({ renderer, collection, sortMode: "cpu", autoSort: false, method: "htgs" });
const stillScene = new THREE.Scene().add(stillSplats);
stillScene.background = ctx.scene.background;
const target = new THREE.RenderTarget(width, height, { type: THREE.UnsignedByteType });
try {
const view = stillSplats.viewFor(camera);
view.apply(await view.sort(camera));
renderer.setRenderTarget(target);
renderer.render(stillScene, camera);
renderer.setRenderTarget(null);
const pixels = await renderer.readRenderTargetPixelsAsync(target, 0, 0, width, height) as Uint8Array;
const image = new ImageData(width, height);
for (let y = 0; y < height; y += 1) {
// WebGL2 reads rows bottom up
const row = ctx.webgpu ? y : height - 1 - y;
image.data.set(pixels.subarray(row * width * 4, (row + 1) * width * 4), y * width * 4);
}
const canvas = Object.assign(document.createElement("canvas"), { width, height });
canvas.getContext("2d")!.putImageData(image, 0, 0);
preview.src = canvas.toDataURL("image/png");
preview.hidden = false;
} finally {
target.dispose();
stillSplats.dispose();
}
}
let rendering = false;
ctx.button("render a 1920x1080 still", () => {
if (!rendering) {
rendering = true;
void still().finally(() => (rendering = false));
}
});
return {
status: () => (rendering ? "rendering the still..." : "the still is sorted for its own camera, then read back"),
};
}
export const SCENES: Record<string, (ctx: SceneContext) => Demo> = { multi, views, modifiers, subset, offline };