main.ts

examples/spark/splat-portal/main.ts · raw · open the example · all examples · site home

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
// Spark's splat-portal: two separate worlds, each with a circular portal
// showing the other; walk through to switch worlds. Each world is its own
// scene with its own SplatRenderer; each frame renders the other world from
// the linked camera into a render target, which the portal's TSL material
// samples. Spark's world B is Sutro, a SOGS zip Radix cannot read: here it
// is the fireplace scan.
import * as THREE from "three";
import { MeshBasicNodeMaterial } from "three/webgpu";
import { CameraControls, SplatCollection, SplatRenderer, TSL } from "@sparkjsdev/radix";
import { asset } from "../js/assets.js";
import { examplePage, windowRenderer } from "../js/page.js";
import { fileObject, loadSplatFile } from "../js/files.js";

const { Discard, Fn, clamp, float, length, max, normalize, positionLocal, positionWorld, sin, smoothstep, texture, uniform, vec2, vec4 } = TSL;

const page = examplePage("splat-portal");
page.legend("WASD + mouse to move, go through the portal to switch worlds");

const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.01, 2000);
camera.position.set(0, 1, 3);

// createRenderer, sized to the window and added to the page; it follows
// window resizes, the camera's aspect too (js/page.ts)
const renderer = await windowRenderer(camera, { antialias: true, pixelRatio: devicePixelRatio });
const controls = new CameraControls({ canvas: renderer.domElement, object: camera, storageKey: "spark-splat-portal" });
controls.lookAt(new THREE.Vector3(0, 1, 0));

// A world: a scene with its own splat renderer drawing one scan, placed by
// the Object3D `holder`
async function world(name: string, holder: THREE.Object3D) {
  const file = await loadSplatFile(renderer, asset(name), { onProgress: page.progress(name) });
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x000000);
  const splats = new SplatRenderer({ renderer });
  splats.collection = new SplatCollection([fileObject(file, { matrixWorld: holder.matrixWorld })]);
  scene.add(splats, holder);
  return scene;
}

const valley = new THREE.Object3D();
valley.rotateX(Math.PI);
const sceneA = await world("valley.spz", valley);

const fireplace = new THREE.Object3D();
fireplace.rotateX(Math.PI);
fireplace.position.set(0, -0.5, -11);
const sceneB = await world("fireplace.spz", fireplace);

const RADIUS = 0.6;
const time = uniform(0);
const size = (): [number, number] => [Math.max(1, Math.floor(innerWidth * renderer.getPixelRatio())), Math.max(1, Math.floor(innerHeight * renderer.getPixelRatio()))];

// A portal in `world` showing its target: the other world seen by its
// camera. `toOther` maps this world's points through the portal into the
// other's; `clip` is the camera's world to clip transform.
function portal(world: THREE.Scene, position: THREE.Vector3, turn: number) {
  const target = new THREE.RenderTarget(...size(), { depthBuffer: true });
  const toOther = uniform(new THREE.Matrix4());
  const clip = uniform(new THREE.Matrix4());

  const material = new MeshBasicNodeMaterial({ side: THREE.DoubleSide });
  material.colorNode = Fn(() => {
    const local = positionLocal.xy;
    const r = length(local).toVar();
    Discard(r.greaterThan(RADIUS));
    const offset = normalize(local).mul(sin(r.mul(50).sub(time.mul(10))).mul(0.001)).toVar();
    const projected = clip.mul(toOther.mul(vec4(positionWorld, 1))).toVar();
    const ndc = projected.xy.div(max(projected.w, 1e-6)).toVar();
    // TSL samples render targets with v down on both backends (Spark's GLSL: up)
    const uv = clamp(vec2(ndc.x, ndc.y.negate()).mul(0.5).add(0.5).add(offset), 0, 1);
    const color = texture(target.texture, uv).add(offset.x.mul(50)).toVar();
    const edgeFade = float(1).sub(smoothstep(RADIUS * 0.8, RADIUS, r));
    return vec4(color.xyz.mul(edgeFade.add(0.1)), 1);
  })();

  const mesh = new THREE.Mesh(new THREE.CircleGeometry(RADIUS, 64), material);
  mesh.renderOrder = 1000;
  mesh.position.copy(position);
  mesh.rotation.y = turn;
  mesh.updateMatrixWorld(true);
  world.add(mesh);
  return { mesh, world, target, toOther: toOther.value as THREE.Matrix4, clip: clip.value as THREE.Matrix4, camera: new THREE.PerspectiveCamera() };
}

type Portal = ReturnType<typeof portal>;
const portalA = portal(sceneA, new THREE.Vector3(0, 1, -2), 0);
const portalB = portal(sceneB, new THREE.Vector3(-1, 1, -5), Math.PI);
const pairs = [[portalA, portalB], [portalB, portalA]] as const;
addEventListener("resize", () => [portalA, portalB].forEach(({ target }) => target.setSize(...size())));

// Through a portal: other * Ry(PI) * this^-1 (the portals do not move)
const rotY180 = new THREE.Matrix4().makeRotationY(Math.PI);
for (const [from, to] of pairs) {
  from.toOther.copy(from.mesh.matrixWorld).invert().premultiply(rotY180).premultiply(to.mesh.matrixWorld);
}

// The main camera carried through `from`, kept within 3 of the other portal
function linkCamera(from: Portal, to: Portal) {
  const linked = from.camera;
  linked.matrixWorld.copy(camera.matrixWorld).premultiply(from.toOther);
  linked.matrixWorld.decompose(linked.position, linked.quaternion, linked.scale);
  linked.position.sub(to.mesh.position).clampLength(0, 3).add(to.mesh.position);
  linked.projectionMatrix.copy(camera.projectionMatrix);
  linked.projectionMatrixInverse.copy(camera.projectionMatrixInverse);
  linked.updateMatrixWorld(true);
  from.clip.multiplyMatrices(linked.projectionMatrix, linked.matrixWorldInverse);
}

let here = portalA;
let lastSide = -1;
let cooldown = 0;
page.ready(renderer);

// Crossing this world's portal (from one side to the other, or into its
// middle) moves the camera into the other world
function crossPortal(delta: number) {
  cooldown -= delta;
  if (cooldown > 0) {
    return;
  }
  const local = here.mesh.worldToLocal(camera.position.clone());
  const side = Math.sign(local.z);
  const close = Math.abs(local.z) < 0.3 && Math.hypot(local.x, local.y) <= RADIUS * 1.2;
  if (close && ((lastSide !== 0 && side !== lastSide) || Math.abs(local.z) < 0.1)) {
    camera.matrixWorld.premultiply(here.toOther);
    camera.matrixWorld.decompose(camera.position, camera.quaternion, camera.scale);
    camera.position.add(new THREE.Vector3(0, 0, -0.2).applyQuaternion(camera.quaternion));
    camera.updateMatrixWorld(true);
    here = here === portalA ? portalB : portalA;
    cooldown = 0.5;
  }
  lastSide = side;
}

let last: number | undefined;
renderer.setAnimationLoop((now) => {
  const delta = last === undefined ? 0 : Math.min((now - last) / 1000, 0.1);
  last = now;
  controls.update();
  time.value = now / 1000;
  camera.updateMatrixWorld(true);

  // The other worlds into the targets, portals hidden
  portalA.mesh.visible = portalB.mesh.visible = false;
  for (const [from, to] of pairs) {
    linkCamera(from, to);
    renderer.setRenderTarget(from.target);
    renderer.render(to.world, from.camera);
  }
  renderer.setRenderTarget(null);
  portalA.mesh.visible = portalB.mesh.visible = true;

  crossPortal(delta);
  renderer.render(here.world, camera);
});