mirror of
https://github.com/storytold/spark.git
synced 2026-10-09 00:09:53 +00:00
246 lines
7.8 KiB
HTML
246 lines
7.8 KiB
HTML
<!DOCTYPE html>
|
|
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Spark • Multiple Splats</title>
|
|
<link rel="stylesheet" href="style.css">
|
|
</head>
|
|
|
|
<body>
|
|
<a href="#" id="mobile_button">Menu</a>
|
|
<div id="menu">
|
|
<div class="border">
|
|
<div class="border">
|
|
<h3>Tipatat's Splat Restaurant</h3>
|
|
<h1> Menu </h1>
|
|
<h2>CHEF SPECIALS</h2>
|
|
<div id="menu_list"></div>
|
|
<h2>Food scans by <a href="https://x.com/tipatat" target="_blank">Tipatat</a></h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "/examples/js/vendor/three/build/three.module.js",
|
|
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
|
|
"@sparkjs-dev/spark": "/dist/spark.module.js"
|
|
}
|
|
}
|
|
</script>
|
|
<script type="module">
|
|
import * as THREE from "three";
|
|
import { SplatMesh } from "@sparkjs-dev/spark";
|
|
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
import { EXRLoader } from "three/addons/loaders/EXRLoader.js";
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
import { FOOD_ASSETS, FOOD_URL } from './food.js';
|
|
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
|
import { preloadSplats } from "/examples/js/preloader.js";
|
|
|
|
// Add food items to the menu
|
|
FOOD_ASSETS.forEach((food, i) => {
|
|
const el = document.createElement("a");
|
|
el.textContent = food.name;
|
|
el.href = 'javascript:;';
|
|
el.addEventListener('click', async function () {
|
|
switchToFood(i);
|
|
});
|
|
document.getElementById('menu_list').appendChild(el);
|
|
});
|
|
|
|
// Setup mobile menu button
|
|
let IS_MOBILE = 'ontouchstart' in window || navigator.msMaxTouchPoints;
|
|
document.getElementById('mobile_button').addEventListener('click', () => {
|
|
document.getElementById('menu').classList.toggle('visible');
|
|
});
|
|
|
|
const scene = new THREE.Scene();
|
|
const renderer = new THREE.WebGLRenderer();
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
document.body.appendChild(renderer.domElement)
|
|
|
|
// Setup camera
|
|
const camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
camera.position.set(0, 0.9, -1.2);
|
|
|
|
// handle windows resize
|
|
window.addEventListener('resize', onWindowResize, false);
|
|
function onWindowResize() {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
}
|
|
|
|
// Setup mouse controls to orbit the camera around
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.target.set(0.2, 0, 0);
|
|
controls.minDistance = 0.8;
|
|
controls.maxDistance = 2.3;
|
|
controls.enablePan = false;
|
|
controls.update();
|
|
|
|
|
|
// Current and next food splats
|
|
let food, nextFood;
|
|
// Other items
|
|
let table, shadow;
|
|
|
|
// Add table
|
|
const gltfLoader = new GLTFLoader();
|
|
const modelURL = await getAssetFileURL("table.glb");
|
|
const gltfTable = await gltfLoader.loadAsync(modelURL);
|
|
table = gltfTable.scene;
|
|
|
|
// Transition length in frames.
|
|
// Two transitions: one for fading out the old food, another for fading in the next one
|
|
const TRANSITION_LENGTH = IS_MOBILE ? 30 : 60;
|
|
// Transition timers. `null` if transition is not active
|
|
let fadeOutTime = null;
|
|
let fadeInTime = null;
|
|
|
|
// Preload all splat files
|
|
let splats;
|
|
preloadSplats(FOOD_ASSETS.map(t => t.file)).then(loaded_splats => {
|
|
splats = loaded_splats;
|
|
init();
|
|
});
|
|
|
|
async function init() {
|
|
// Setup lighting
|
|
const spotLight = new THREE.SpotLight(0xffcc88);
|
|
spotLight.position.set(0, 1, 0);
|
|
spotLight.castShadow = true;
|
|
spotLight.shadow.mapSize.width = 1024;
|
|
spotLight.shadow.mapSize.height = 1024;
|
|
spotLight.shadow.camera.near = 0.1;
|
|
spotLight.shadow.camera.far = 5;
|
|
spotLight.angle = 0.9;
|
|
spotLight.penumbra = 1;
|
|
spotLight.intensity = 3;
|
|
scene.add(spotLight);
|
|
|
|
const fillLight = new THREE.PointLight(0xffcc88, 0.2);
|
|
fillLight.position.set(0, 0, -3);
|
|
scene.add(fillLight);
|
|
|
|
// Splats don't project shadows, so we add a cylinder below the spotlight to fake one ;)
|
|
const geometry = new THREE.CylinderGeometry(0.45, 0.45, 0.04, 40, 1);
|
|
const material = new THREE.MeshPhongMaterial({ colorWrite: false, depthWrite: false });
|
|
shadow = new THREE.Mesh(geometry, material);
|
|
shadow.visible = false;
|
|
shadow.castShadow = true;
|
|
shadow.position.set(0, 0.1, 0);
|
|
scene.add(shadow);
|
|
|
|
// Set the table cloth to receive shadows
|
|
const tableCloth = table.children.find(item => item.name == 'cover');
|
|
tableCloth.receiveShadow = true;
|
|
scene.add(table);
|
|
|
|
// Add floor
|
|
const plane = new THREE.PlaneGeometry(10, 10);
|
|
const floormat = new THREE.MeshPhongMaterial({ color: 0x777777 });
|
|
const floor = new THREE.Mesh(plane, floormat);
|
|
floor.rotation.x = -Math.PI / 2;
|
|
floor.position.set(0, -1.397, 0);
|
|
scene.add(floor);
|
|
|
|
// Show menu
|
|
document.getElementById('menu').classList.add('visible');
|
|
if (IS_MOBILE) document.getElementById('mobile_button').classList.add('visible');
|
|
|
|
// Load first food by default
|
|
switchToFood(0);
|
|
|
|
// Start render loop
|
|
renderer.setAnimationLoop(animate);
|
|
}
|
|
|
|
function animate(time) {
|
|
controls.update();
|
|
renderer.render(scene, camera);
|
|
const rotation = time / 10000;
|
|
table.rotation.y = rotation;
|
|
if (food) food.rotation.y = -rotation;
|
|
if (nextFood) nextFood.rotation.y = -rotation;
|
|
|
|
|
|
// fade out
|
|
if (fadeOutTime !== null) {
|
|
fadeOutTime++;
|
|
if (fadeOutTime < TRANSITION_LENGTH) {
|
|
if (food) food.opacity = 1 - easeInOutSine(fadeOutTime / TRANSITION_LENGTH);
|
|
} else {
|
|
// Fade out finished
|
|
if (food) food.dispose();
|
|
fadeOutTime = null;
|
|
// Fade in next food
|
|
fadeInTime = 0;
|
|
shadow.visible = true;
|
|
shadow.scale.setScalar(shadow.nextScale);
|
|
}
|
|
}
|
|
|
|
// fade in
|
|
if (fadeInTime != null && nextFood.isInitialized) {
|
|
fadeInTime++;
|
|
if (fadeInTime < TRANSITION_LENGTH) {
|
|
nextFood.opacity = easeInOutSine(fadeInTime / TRANSITION_LENGTH);
|
|
} else {
|
|
|
|
// Fade in finished
|
|
food = nextFood;
|
|
fadeInTime = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Change food from menu link
|
|
function switchToFood(foodIndex) {
|
|
|
|
// already transitioning
|
|
if (fadeOutTime !== null || fadeInTime !== null) return;
|
|
|
|
const foodItem = FOOD_ASSETS[foodIndex];
|
|
|
|
nextFood = splats[foodItem.file];
|
|
nextFood.quaternion.set(1, 0, 0, 0);
|
|
|
|
// Customize splat depending on the settings set for this food
|
|
if (foodItem['offsetY']) nextFood.position.set(0, foodItem.offsetY, 0);
|
|
nextFood.scale.setScalar(foodItem.scale);
|
|
shadow.nextScale = foodItem.shadowSize;
|
|
|
|
// Set opacity to 0 to prepare the fade in transition
|
|
nextFood.opacity = 0;
|
|
|
|
// Setup shadow to the required size, and make it visible only when the splat is initialized
|
|
nextFood.initialized.then(() => {
|
|
shadow.visible = false;
|
|
fadeOutTime = 0; // Fade in next food
|
|
});
|
|
|
|
scene.add(nextFood);
|
|
|
|
// Hide menu if on mobile
|
|
if (IS_MOBILE) document.getElementById('menu').classList.remove('visible');
|
|
|
|
// toggle menu item
|
|
const menu_items = document.getElementById('menu_list').children;
|
|
for (let i = 0; i < menu_items.length; i++) {
|
|
menu_items[i].classList.toggle('active', foodIndex == i);
|
|
}
|
|
}
|
|
|
|
function easeInOutSine(x) {
|
|
return -(Math.cos(Math.PI * x) - 1) / 2;
|
|
}
|
|
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|