main.ts

examples/spark/webxr/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
// Spark's webxr: the valley in VR with tracked hands drawn as splats, each
// fingertip carrying a colored, wobbling sphere that lights the splats it
// touches. The hands are a TSL generator reading the joints from a uniform
// array (Spark's makeJointSplats), drawn by their own SplatRenderer so the
// edits skip them (Spark's editable = false); the fingertip spheres are an
// SdfEdit (js/sdf.js) in the valley renderer's world modifier. On the
// desktop it shows the valley; hands need a headset or an emulator.
import * as THREE from "three";
import { CameraControls, GpuSplats, SplatCollection, SplatRenderer, TSL, type SplatGenerator } from "@sparkjsdev/radix";
import { asset } from "../js/assets.js";
import { examplePage, windowRenderer } from "../js/page.js";
import { SdfBlend, SdfEdit, SdfSphere, sdfModifier } from "../js/sdf.js";
import { fileObject, loadSplatFile } from "../js/files.js";
import { setupXr, xrBackend } from "../js/xr.js";

const { float, select, uint, uniformArray, vec3, vec4 } = TSL;

const page = examplePage("webxr");

// The controls move the camera, and in XR its parent, the local frame
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 1000);
const localFrame = new THREE.Group();
localFrame.add(camera);
scene.add(localFrame);

// 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, { xr: true, backend: xrBackend() });
const controls = new CameraControls({ canvas: renderer.domElement, object: camera, storageKey: "spark-webxr" });

const edit = new SdfEdit({ rgbaBlendMode: SdfBlend.ADD_RGBA, sdfSmooth: 0.02, softEdge: 0.02, maxSdfs: 10 });
localFrame.add(edit);

// The valley with the edits, the hands in a renderer of their own; both
// move by uniforms the CPU sort cannot see
const splats = new SplatRenderer({ renderer, params: { maxStdDev: Math.sqrt(5) }, modifier: sdfModifier([edit]), modifierSpace: "world" });
const handSplats = new SplatRenderer({ renderer, params: { maxStdDev: Math.sqrt(5) } });
scene.add(splats, handSplats);
setupXr(renderer, [splats, handSplats], controls, { optionalFeatures: ["hand-tracking"] });

const valley = new THREE.Object3D();
valley.quaternion.set(1, 0, 0, 0);
valley.scale.setScalar(0.5);
scene.add(valley);
const file = await loadSplatFile(renderer, asset("valley.spz"), { onProgress: page.progress("valley") });
splats.collection = new SplatCollection([fileObject(file, { matrixWorld: valley.matrixWorld })]);

// Both hands' 25 joints: position and radius (0: not tracked), in the
// reference space the local frame places
const JOINTS = 25;
const joints = Array.from({ length: JOINTS * 2 }, () => new THREE.Vector4());
const uJoints = uniformArray(joints, "vec4");
const handGenerator: SplatGenerator = {
  generateSplat(index) {
    const joint = uJoints.element(index).toVar();
    const radius = joint.w.mul(0.6);
    return GpuSplats.Splat(joint.xyz, vec3(radius), vec4(0, 0, 0, 1), vec3(0.9, 0.85, 0.8), select(joint.w.greaterThan(0), float(0.9), float(0)), uint(index));
  },
};
const hands = new THREE.Object3D();
localFrame.add(hands);
handSplats.collection = new SplatCollection([{ generator: handGenerator, count: JOINTS * 2, matrixWorld: hands.matrixWorld }]);

// A sphere per fingertip, its color by finger as in Spark's example
const TIPS = ["thumb-tip", "index-finger-tip", "middle-finger-tip", "ring-finger-tip", "pinky-finger-tip"];
const tips = new Map<string, { sdf: SdfSphere; finger: number }>();
for (const hand of ["left", "right"]) {
  TIPS.forEach((tip, finger) => {
    const sdf = new SdfSphere({ radius: 0.03, color: new THREE.Color(finger < 3 ? 1 : 0, finger % 2, finger > 1 ? 1 : 0), opacity: 0 });
    sdf.visible = false;
    edit.add(sdf);
    tips.set(`${hand}-${tip}`, { sdf, finger });
  });
}
page.ready(renderer);

const lastCameraPos = new THREE.Vector3();
renderer.setAnimationLoop((time, xrFrame) => {
  controls.update();

  // A large jump of the head (a new session's origin) moves the frame back
  if (lastCameraPos.distanceTo(camera.position) > 0.5) {
    localFrame.position.copy(camera.position).multiplyScalar(-1);
  }
  lastCameraPos.copy(camera.position);

  joints.forEach((joint) => joint.set(0, 0, 0, 0));
  tips.forEach(({ sdf }) => (sdf.visible = false));
  const referenceSpace = renderer.xr.getReferenceSpace();
  if (xrFrame && referenceSpace) {
    for (const source of xrFrame.session.inputSources) {
      if (!source.hand || (source.handedness !== "left" && source.handedness !== "right")) {
        continue;
      }
      const base = source.handedness === "left" ? 0 : JOINTS;
      [...source.hand.entries()].forEach(([name, space], i) => {
        const pose = xrFrame.getJointPose!(space, referenceSpace);
        if (!pose || i >= JOINTS) {
          return;
        }
        const { x, y, z } = pose.transform.position;
        joints[base + i].set(x, y, z, pose.radius ?? 0.01);
        const tip = tips.get(`${source.handedness}-${name}`);
        if (tip) {
          const { sdf, finger } = tip;
          sdf.position.set(x, y, z);
          sdf.displace.set(0.01 * Math.sin(time * 0.007 + finger), 0.01 * Math.sin(time * 0.002 + finger * 2), 0.01 * Math.sin(time * 0.009 + finger * 3));
          sdf.visible = true;
        }
      });
    }
  }
  edit.update();

  for (const splatRenderer of [splats, handSplats]) {
    if (splatRenderer.sortMode === "cpu") {
      splatRenderer.requestSort();
    }
  }
  renderer.render(scene, camera);
});