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);
});