import * as THREE from 'three'; import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d'; const ASSET_ROOT = 'https://huggingface.co/datasets/mikafi/gaussiansplats3D/resolve/main/demo/assets'; const scenes = [ { id: 'garden', title: 'Garden Walkthrough', shortName: 'Garden', mood: 'Outdoor capture', description: 'Start in a dense outdoor capture, then walk through the scene instead of orbiting around it.', files: { standard: `${ASSET_ROOT}/data/garden/garden.ksplat`, ultra: `${ASSET_ROOT}/data/garden/garden_high.ksplat` }, sizes: { standard: '75 MB', ultra: '181 MB' }, thumb: `${ASSET_ROOT}/images/garden.png`, cameraUp: [0, -1, -0.54], start: [-3.15634, -0.16946, -0.51552], lookAt: [1.52976, 2.27776, 1.65898], walkSpeed: 1.45, sprintMultiplier: 2.4, liftSpeed: 0.85, tourRadius: 4.4, tourHeight: 0.35 }, { id: 'bonsai', title: 'Bonsai Atrium', shortName: 'Bonsai', mood: 'Indoor detail', description: 'A compact capture with fine structure and view-dependent highlights for close inspection.', files: { standard: `${ASSET_ROOT}/data/bonsai/bonsai.ksplat`, ultra: `${ASSET_ROOT}/data/bonsai/bonsai_high.ksplat` }, sizes: { standard: '21 MB', ultra: '46 MB' }, thumb: `${ASSET_ROOT}/images/bonsai.png`, cameraUp: [0.01933, -0.7583, -0.65161], start: [1.54163, 2.68515, -6.37228], lookAt: [0.45622, 1.95338, 1.51278], walkSpeed: 1.2, sprintMultiplier: 2.2, liftSpeed: 0.75, tourRadius: 3.2, tourHeight: 0.25 }, { id: 'truck', title: 'Truck Yard', shortName: 'Truck', mood: 'Large object scan', description: 'Circle a high-fidelity vehicle capture with a grounded first-person camera and cinematic tour mode.', files: { standard: `${ASSET_ROOT}/data/truck/truck.ksplat`, ultra: `${ASSET_ROOT}/data/truck/truck_high.ksplat` }, sizes: { standard: '29 MB', ultra: '75 MB' }, thumb: `${ASSET_ROOT}/images/truck.png`, cameraUp: [0, -1, -0.17], start: [-5, -1, -1], lookAt: [-1.72477, 0.05395, -0.00147], walkSpeed: 1.35, sprintMultiplier: 2.45, liftSpeed: 0.8, tourRadius: 4.1, tourHeight: 0.2 }, { id: 'stump', title: 'Forest Stump', shortName: 'Stump', mood: 'Organic texture', description: 'Explore bark, foliage, and soft photogrammetry detail from a close-range natural capture.', files: { standard: `${ASSET_ROOT}/data/stump/stump.ksplat`, ultra: `${ASSET_ROOT}/data/stump/stump_high.ksplat` }, sizes: { standard: '74 MB', ultra: '181 MB' }, thumb: `${ASSET_ROOT}/images/stump.png`, cameraUp: [0, -1, -1], start: [-3.3816, 1.96931, -1.7189], lookAt: [-0.04979, 1.37519, 1.13443], walkSpeed: 1.1, sprintMultiplier: 2.35, liftSpeed: 0.75, tourRadius: 3.7, tourHeight: 0.2 } ]; const dom = { app: document.querySelector('#app'), viewport: document.querySelector('#viewport'), sceneTitle: document.querySelector('#sceneTitle'), sceneDescription: document.querySelector('#sceneDescription'), sceneGrid: document.querySelector('#sceneGrid'), qualityLabel: document.querySelector('#qualityLabel'), loadStatus: document.querySelector('#loadStatus'), walkButton: document.querySelector('#walkButton'), tourButton: document.querySelector('#tourButton'), resetButton: document.querySelector('#resetButton'), loadingOverlay: document.querySelector('#loadingOverlay'), loaderText: document.querySelector('#loaderText'), toast: document.querySelector('#toast'), fps: document.querySelector('#fps'), speedLabel: document.querySelector('#speedLabel') }; const params = new URLSearchParams(window.location.search); let currentScene = scenes.find((scene) => scene.id === params.get('scene')) ?? scenes.find((scene) => scene.id === 'truck') ?? scenes[0]; let quality = params.get('quality') === 'ultra' ? 'ultra' : 'standard'; let renderer; let camera; let viewer; let rafId = 0; let lastTime = performance.now(); let frameBucketStart = lastTime; let frameCount = 0; let isWalking = false; let tourEnabled = true; let tourStartTime = 0; let yaw = 0; let pitch = 0; let basis; const keys = new Set(); const cameraState = { position: new THREE.Vector3(), direction: new THREE.Vector3(), height: 0 }; function vectorFromArray(value) { return new THREE.Vector3(value[0], value[1], value[2]); } function makeBasis(scene) { const up = vectorFromArray(scene.cameraUp).normalize(); const start = vectorFromArray(scene.start); const target = vectorFromArray(scene.lookAt); const initialForward = target.clone().sub(start).normalize(); let flatForward = initialForward.clone().sub(up.clone().multiplyScalar(initialForward.dot(up))); if (flatForward.lengthSq() < 0.0001) { flatForward = new THREE.Vector3(0, 0, -1).sub(up.clone().multiplyScalar(up.z)).normalize(); } else { flatForward.normalize(); } const right = new THREE.Vector3().crossVectors(flatForward, up).normalize(); const correctedForward = new THREE.Vector3().crossVectors(up, right).normalize(); const initialPitch = Math.asin(THREE.MathUtils.clamp(initialForward.dot(up), -0.92, 0.92)); return { up, right, forward: correctedForward, focus: target, start, initialPitch }; } function setCameraFromYawPitch() { const yawQuat = new THREE.Quaternion().setFromAxisAngle(basis.up, yaw); const yawedForward = basis.forward.clone().applyQuaternion(yawQuat).normalize(); const yawedRight = basis.right.clone().applyQuaternion(yawQuat).normalize(); const pitchQuat = new THREE.Quaternion().setFromAxisAngle(yawedRight, pitch); const direction = yawedForward.applyQuaternion(pitchQuat).normalize(); cameraState.direction.copy(direction); camera.up.copy(basis.up); camera.position.copy(cameraState.position); camera.lookAt(cameraState.position.clone().add(direction)); } function resetCamera() { yaw = 0; pitch = basis.initialPitch; cameraState.position.copy(basis.start); cameraState.height = cameraState.position.dot(basis.up); setCameraFromYawPitch(); } function stopTour() { tourEnabled = false; dom.tourButton.classList.remove('active'); dom.tourButton.innerHTML = ' Tour'; } function startTour() { tourEnabled = true; tourStartTime = performance.now(); dom.tourButton.classList.add('active'); dom.tourButton.innerHTML = ' Tour'; } function updateTour(time) { const t = Math.max(0, time - tourStartTime) * 0.00009; const radius = currentScene.tourRadius; const side = basis.right.clone().multiplyScalar(Math.cos(t) * radius); const forward = basis.forward.clone().multiplyScalar(Math.sin(t) * radius); const lift = basis.up.clone().multiplyScalar(currentScene.tourHeight + Math.sin(t * 1.7) * 0.24); const position = basis.focus.clone().add(side).add(forward).add(lift); cameraState.position.copy(position); cameraState.height = cameraState.position.dot(basis.up); camera.up.copy(basis.up); camera.position.copy(position); camera.lookAt(basis.focus.clone().add(basis.up.clone().multiplyScalar(0.12))); } function updateMovement(delta) { if (!isWalking) return; const sprint = keys.has('ShiftLeft') || keys.has('ShiftRight'); const speed = currentScene.walkSpeed * (sprint ? currentScene.sprintMultiplier : 1); const movement = new THREE.Vector3(); const flatForward = cameraState.direction .clone() .sub(basis.up.clone().multiplyScalar(cameraState.direction.dot(basis.up))); if (flatForward.lengthSq() < 0.0001) { flatForward.copy(basis.forward); } else { flatForward.normalize(); } const right = new THREE.Vector3().crossVectors(flatForward, basis.up).normalize(); if (keys.has('KeyW') || keys.has('ArrowUp')) movement.add(flatForward); if (keys.has('KeyS') || keys.has('ArrowDown')) movement.sub(flatForward); if (keys.has('KeyD') || keys.has('ArrowRight')) movement.add(right); if (keys.has('KeyA') || keys.has('ArrowLeft')) movement.sub(right); if (keys.has('Space')) movement.add(basis.up); if (keys.has('KeyC') || keys.has('ControlLeft') || keys.has('ControlRight')) movement.sub(basis.up); if (movement.lengthSq() > 0) { movement.normalize().multiplyScalar(speed * delta); cameraState.position.add(movement); } const vertical = cameraState.position.dot(basis.up); const minHeight = cameraState.height - 2.2; const maxHeight = cameraState.height + 2.2; if (vertical < minHeight || vertical > maxHeight) { cameraState.position.add( basis.up.clone().multiplyScalar(THREE.MathUtils.clamp(vertical, minHeight, maxHeight) - vertical) ); } dom.speedLabel.textContent = sprint ? 'Sprint' : 'Walk'; setCameraFromYawPitch(); } function animate(time) { rafId = requestAnimationFrame(animate); const delta = Math.min(0.05, (time - lastTime) / 1000); lastTime = time; if (tourEnabled && !isWalking) updateTour(time); updateMovement(delta); viewer?.update(); viewer?.render(); frameCount += 1; if (time - frameBucketStart >= 650) { const fps = Math.round((frameCount * 1000) / (time - frameBucketStart)); dom.fps.textContent = fps >= 5 ? `${fps} fps` : 'Live'; frameBucketStart = time; frameCount = 0; } } function showToast(message) { dom.toast.textContent = message; dom.toast.classList.add('visible'); window.clearTimeout(showToast.timeoutId); showToast.timeoutId = window.setTimeout(() => { dom.toast.classList.remove('visible'); }, 2600); } function setLoading(isLoading, message) { dom.loaderText.textContent = message; dom.loadStatus.textContent = isLoading ? 'Loading scene' : 'Ready'; dom.loadingOverlay.classList.toggle('hidden', !isLoading); } function updateUrl() { const next = new URL(window.location.href); next.searchParams.set('scene', currentScene.id); next.searchParams.set('quality', quality); window.history.replaceState({}, '', next); } function renderSceneButtons() { dom.sceneGrid.innerHTML = ''; for (const scene of scenes) { const button = document.createElement('button'); button.type = 'button'; button.className = `scene-option${scene.id === currentScene.id ? ' active' : ''}`; button.style.setProperty('--thumb', `url("${scene.thumb}")`); button.innerHTML = ` ${scene.shortName} ${scene.mood} `; button.addEventListener('click', () => { if (scene.id === currentScene.id) return; currentScene = scene; updateUrl(); loadScene(); }); dom.sceneGrid.appendChild(button); } } function updateText() { dom.sceneTitle.textContent = currentScene.title; dom.sceneDescription.textContent = currentScene.description; dom.qualityLabel.textContent = quality === 'ultra' ? `Ultra ${currentScene.sizes.ultra}` : `Standard ${currentScene.sizes.standard}`; for (const segment of document.querySelectorAll('.segment')) { segment.classList.toggle('active', segment.dataset.quality === quality); } } function disposeCurrentViewer() { if (rafId) cancelAnimationFrame(rafId); rafId = 0; try { viewer?.dispose?.(); } catch (error) { console.warn('Viewer disposal warning:', error); } viewer = undefined; renderer?.dispose?.(); dom.viewport.replaceChildren(); } async function loadScene() { setLoading(true, `Loading ${currentScene.shortName} in ${quality} quality (${currentScene.sizes[quality]}).`); disposeCurrentViewer(); renderSceneButtons(); updateText(); basis = makeBasis(currentScene); renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false, powerPreference: 'high-performance' }); renderer.setClearColor(0x020303, 1); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75)); renderer.setSize(window.innerWidth, window.innerHeight); renderer.outputColorSpace = THREE.SRGBColorSpace; dom.viewport.appendChild(renderer.domElement); camera = new THREE.PerspectiveCamera(64, window.innerWidth / window.innerHeight, 0.05, 500); camera.up.copy(basis.up); resetCamera(); const canUseSharedMemory = typeof SharedArrayBuffer !== 'undefined' && window.crossOriginIsolated; viewer = new GaussianSplats3D.Viewer({ selfDrivenMode: false, renderer, camera, useBuiltInControls: false, ignoreDevicePixelRatio: false, gpuAcceleratedSort: canUseSharedMemory, sharedMemoryForWorkers: canUseSharedMemory, integerBasedSort: true, halfPrecisionCovariancesOnGPU: true, dynamicScene: false, renderMode: GaussianSplats3D.RenderMode.Always, sceneRevealMode: GaussianSplats3D.SceneRevealMode.Gradual, sceneFadeInRateMultiplier: 1.7, antialiased: false, sphericalHarmonicsDegree: 2, inMemoryCompressionLevel: 2, freeIntermediateSplatData: true, logLevel: GaussianSplats3D.LogLevel.None }); try { await viewer.addSplatScene(currentScene.files[quality], { progressiveLoad: false, showLoadingUI: false, splatAlphaRemovalThreshold: 5 }); setLoading(false, 'Ready'); startTour(); lastTime = performance.now(); frameBucketStart = lastTime; frameCount = 0; rafId = requestAnimationFrame(animate); showToast('Click Walk, then use WASD and mouse to explore.'); } catch (error) { console.error(error); setLoading(false, 'Unable to load the splat.'); dom.loadStatus.textContent = 'Load failed'; showToast('The scene failed to load. Try Standard quality or refresh.'); } } function resize() { if (!renderer || !camera) return; camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75)); renderer.setSize(window.innerWidth, window.innerHeight); } function enterWalkMode() { if (!renderer) return; stopTour(); renderer.domElement.requestPointerLock?.(); } document.addEventListener('pointerlockchange', () => { isWalking = document.pointerLockElement === renderer?.domElement; dom.app.classList.toggle('is-walking', isWalking); dom.walkButton.innerHTML = isWalking ? '× Walking' : ' Walk'; if (!isWalking) keys.clear(); }); document.addEventListener('mousemove', (event) => { if (!isWalking) return; yaw -= event.movementX * 0.0021; pitch = THREE.MathUtils.clamp(pitch - event.movementY * 0.0021, -1.28, 1.28); }); document.addEventListener('keydown', (event) => { if ( [ 'KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'ShiftLeft', 'ShiftRight', 'KeyC', 'ControlLeft', 'ControlRight' ].includes(event.code) ) { keys.add(event.code); if (isWalking) event.preventDefault(); } if (event.code === 'Escape') keys.clear(); }); document.addEventListener('keyup', (event) => { keys.delete(event.code); }); dom.walkButton.addEventListener('click', () => { if (isWalking) { document.exitPointerLock?.(); } else { enterWalkMode(); } }); dom.tourButton.addEventListener('click', () => { if (isWalking) document.exitPointerLock?.(); if (tourEnabled) { stopTour(); } else { startTour(); } }); dom.resetButton.addEventListener('click', () => { if (isWalking) document.exitPointerLock?.(); resetCamera(); stopTour(); showToast('View reset.'); }); for (const segment of document.querySelectorAll('.segment')) { segment.addEventListener('click', () => { const nextQuality = segment.dataset.quality; if (nextQuality === quality) return; quality = nextQuality; updateUrl(); loadScene(); }); } window.addEventListener('resize', resize); window.addEventListener('blur', () => keys.clear()); renderSceneButtons(); updateText(); loadScene();