main.ts

examples/spark/interactivity/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
156
157
158
159
160
161
// Spark's interactivity ("Tipatat's Splat Restaurant"): food scans fading in
// and out over a GLB table lit by a shadow-casting spotlight. The splats are
// depth-tested against the table and floor (they write no depth), and a
// hidden cylinder casts the food's shadow. The fades set each model's opacity
// (js/objects.js). The lit meshes need three's sRGB output pass
// (directCanvas: false).
import * as THREE from "three";
import { CameraControls } from "@sparkjsdev/radix";
import { asset, loadGlb } from "../js/assets.js";
import { SplatSet, type SplatModel } from "../js/objects.js";
import { examplePage, windowRenderer } from "../js/page.js";

const FOODS = [
  { name: "Branzino Amarin", file: "branzino-amarin.spz", scale: 0.3, shadowSize: 1 },
  { name: "Iberico Sandwich", file: "iberico-sandwich-by-reserve.spz", scale: 0.6, shadowSize: 0, offsetY: -0.05 },
  { name: "Coral Caviar", file: "coral-caviar.spz", scale: 0.4, shadowSize: 0.7 },
  { name: "Pad Thai", file: "pad-thai.spz", scale: 0.2, shadowSize: 1 },
  { name: "Prime Rib", file: "primerib-tamos.spz", scale: 0.3, shadowSize: 1.1 },
  { name: "Double-Double", file: "double-double-from-InNOut.spz", scale: 0.5, shadowSize: 0.6, offsetY: 0.03 },
  { name: "Gyro Kebab", file: "gyro.spz", scale: 0.13, shadowSize: 0, offsetY: -0.13 },
  { name: "Mels Steak Sandwich", file: "steaksandwich-mels.spz", scale: 0.3, shadowSize: 0, offsetY: 0.08 },
  { name: "Clams and Caviar", file: "clams-and-caviar-by-ikoyi.spz", scale: 0.4, shadowSize: 0.72 },
  { name: "Tomahawk Niku", file: "tomahawk-niku.spz", scale: 0.3, shadowSize: 1 },
  { name: "Dh Burger", file: "burger-from-amboy.spz", scale: 0.4, shadowSize: 0.6, offsetY: 0.04 },
];

const page = examplePage("interactivity");

const IS_MOBILE = "ontouchstart" in window || navigator.maxTouchPoints > 0;
const menu = document.getElementById("menu")!;
const menuList = document.getElementById("menu_list")!;
const mobileButton = document.getElementById("mobile_button")!;
mobileButton.addEventListener("click", () => menu.classList.toggle("visible"));

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(65, innerWidth / innerHeight, 0.1, 1000);
camera.position.set(0, 0.9, -1.2);

// 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, { directCanvas: false });
renderer.shadowMap.enabled = true;

const controls = new CameraControls({
  canvas: renderer.domElement, object: camera, storageKey: "spark-interactivity",
  settings: { mode: "orbit", minDistance: 0.8, maxDistance: 2.3 },
});
controls.lookAt(new THREE.Vector3(0.2, 0, 0));

const table = await loadGlb("table.glb");
table.children.find((item) => item.name === "cover")!.receiveShadow = true;
scene.add(table);

// Every scan up front (Spark's preloadSplats), out of the scene until shown
const splats = new SplatSet({ renderer, scene });
let loaded = 0;
const foods = await Promise.all(FOODS.map(async (food) => {
  const model = await splats.load(asset(food.file), { onProgress: page.progress(food.file), opacity: 0 });
  scene.remove(model);
  model.rotation.x = Math.PI;
  model.position.y = food.offsetY ?? 0;
  model.scale.setScalar(food.scale);
  page.status(`Loaded ${++loaded} of ${FOODS.length} food scans`);
  return { ...food, model };
}));

const spotLight = new THREE.SpotLight(0xffcc88, 3, 0, 0.9, 1);
spotLight.position.set(0, 1, 0);
spotLight.castShadow = true;
spotLight.shadow.mapSize.set(1024, 1024);
spotLight.shadow.camera.near = 0.1;
spotLight.shadow.camera.far = 5;
scene.add(spotLight);

const fillLight = new THREE.PointLight(0xffcc88, 0.2);
fillLight.position.set(0, 0, -3);
scene.add(fillLight);

// Splats cast no shadows: an invisible cylinder fakes one
const shadow = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.45, 0.04, 40, 1), new THREE.MeshPhongMaterial({ colorWrite: false, depthWrite: false }));
shadow.visible = false;
shadow.castShadow = true;
shadow.position.set(0, 0.1, 0);
scene.add(shadow);

const floor = new THREE.Mesh(new THREE.PlaneGeometry(10, 10), new THREE.MeshPhongMaterial({ color: 0x777777 }));
floor.rotation.x = -Math.PI / 2;
floor.position.set(0, -1.397, 0);
scene.add(floor);

// A switch fades the shown food out over FADE frames, then the next one in
const FADE = IS_MOBILE ? 30 : 60;
const easeInOutSine = (x: number) => -(Math.cos(Math.PI * x) - 1) / 2;
let shown: SplatModel | undefined;
let next: (typeof foods)[number] | undefined;
let frame = 0;

function switchTo(index: number) {
  if (next) {
    return;
  }
  next = foods[index];
  next.model.opacity = 0;
  scene.add(next.model);
  shadow.visible = false;
  frame = 0;

  [...menuList.children].forEach((link, i) => link.classList.toggle("active", i === index));
  if (IS_MOBILE) {
    menu.classList.remove("visible");
  }
}

foods.forEach(({ name }, i) => {
  const link = Object.assign(document.createElement("a"), { textContent: name, href: "javascript:;" });
  link.addEventListener("click", () => switchTo(i));
  menuList.append(link);
});
menu.classList.add("visible");
mobileButton.classList.toggle("visible", IS_MOBILE);
switchTo(0);
page.ready(renderer);

function fade() {
  if (!next) {
    return;
  }
  frame += 1;
  if (frame <= FADE) {
    if (shown) {
      shown.opacity = 1 - easeInOutSine(frame / FADE);
    }
    if (frame === FADE) {
      shadow.scale.setScalar(next.shadowSize);
      shadow.visible = true;
      // Spark keeps faded food in the scene at opacity 0; dropping it draws less
      if (shown && shown !== next.model) {
        scene.remove(shown);
      }
    }
  } else {
    next.model.opacity = easeInOutSine(Math.min((frame - FADE) / FADE, 1));
    if (frame === 2 * FADE) {
      shown = next.model;
      next = undefined;
    }
  }
}

renderer.setAnimationLoop((time) => {
  controls.update();
  const rotation = time / 10000;
  table.rotation.y = rotation;
  for (const model of [shown, next?.model]) {
    if (model) {
      model.rotation.y = -rotation;
    }
  }
  fade();
  renderer.render(scene, camera);
});