feat(worldlabs-arcade): 3D Gaussian Splat arcade walkthrough demo

World Labs SPZ environment with animated Soldier character (Three.js + SparkJS).
Fixes Y-flip, panorama doubling, character scaling, third-person camera, and
ground raycasting for navigable photorealistic arcade scene.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
rshtirmer
2026-03-09 17:31:10 -04:00
parent 7f2ea28eab
commit 90269327a5
19 changed files with 1862 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:,">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>World Labs Arcade Demo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a0a2e; overflow: hidden; font-family: system-ui, -apple-system, sans-serif; }
canvas { display: block; }
#loading {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: #1a0a2e;
z-index: 100;
color: #bb88ff;
font-size: 20px;
gap: 16px;
}
#loading.hidden { display: none; }
#loading .spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(187, 136, 255, 0.2);
border-top-color: #bb88ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#controls-hint {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
color: rgba(187, 136, 255, 0.7);
font-size: 14px;
z-index: 10;
text-align: center;
pointer-events: none;
transition: opacity 0.5s;
}
#controls-hint.faded { opacity: 0; }
</style>
</head>
<body>
<div id="loading">
<div class="spinner"></div>
<span>Loading arcade world...</span>
</div>
<div id="controls-hint">Click to look &bull; WASD to move &bull; Shift to run</div>
<script type="module" src="/src/main.js"></script>
<script>
// Fade controls hint after 5 seconds
setTimeout(() => {
const hint = document.getElementById('controls-hint');
if (hint) hint.classList.add('faded');
}, 5000);
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "worldlabs-arcade-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"three": "^0.183.0",
"@sparkjsdev/spark": "latest"
},
"devDependencies": {
"vite": "^7.3.1"
}
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dfb230fc1f942f259dd00281a1186953ad602fc5d69067ce63e24b2aa439736b
size 2160468
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4b1b7471e55d88a4aaa6235dc8f404acc2fdd83f1927e3b41ca96a6517744f47
size 4784380
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

@@ -0,0 +1,58 @@
export const GAME = {
FOV: 65,
NEAR: 0.01,
FAR: 1000,
MAX_DELTA: 0.05,
MAX_DPR: 2,
};
export const IS_MOBILE = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints > 1);
// Collider bounds: ~11.4 x 1.7 x 6.2 units, centered near (-1.4, 0, 2.0)
// Tighter play area to stay in the good-looking splat region
export const PLAYER = {
SIZE: 1,
SPEED: 1.0,
START_X: -1.4,
START_Y: -0.86,
START_Z: 2.0,
COLOR: 0x44aaff,
TURN_SPEED: 8, // radians/s for model rotation
};
export const CAMERA = {
EYE_HEIGHT: 0.9,
MOUSE_SENSITIVITY: 0.002,
FOLLOW_DISTANCE: 1.5, // distance behind player
FOLLOW_HEIGHT: 0.8, // height above player feet
LOOK_HEIGHT: 0.45, // look-at height on player (chest level)
LERP_SPEED: 10, // camera smoothing
};
export const BOUNDS = {
MIN_X: -5.5,
MAX_X: 3.0,
MIN_Z: 0.0,
MAX_Z: 4.2,
};
export const COLORS = {
SKY: 0x1a0a2e,
};
export const WORLD = {
splatPath: 'assets/worlds/arcade.spz', // full-res (30MB) for sharp close-up
colliderPath: 'assets/worlds/arcade-collider.glb',
panoPath: 'assets/worlds/arcade-pano.png',
scale: 1,
position: { x: 0, y: 0, z: 0 },
};
export const CHARACTER = {
path: 'assets/models/Soldier.glb',
scale: 0.5, // GLB has internal 0.01 scale (cm export) → 0.5 × 0.01 = ~0.9m tall
offsetY: 0,
facingOffset: Math.PI,
clipMap: { idle: 'Idle', walk: 'Walk', run: 'Run' },
};
@@ -0,0 +1,13 @@
class EventBusImpl {
constructor() { this._listeners = {}; }
on(event, fn) { (this._listeners[event] ||= []).push(fn); }
off(event, fn) { const arr = this._listeners[event]; if (arr) this._listeners[event] = arr.filter(f => f !== fn); }
emit(event, data) { (this._listeners[event] || []).forEach(fn => fn(data)); }
}
export const eventBus = new EventBusImpl();
export const Events = {
GAME_RESTART: 'game:restart',
GAME_OVER: 'game:over',
};
+125
View File
@@ -0,0 +1,125 @@
import * as THREE from 'three';
import { GAME, CAMERA, COLORS, PLAYER, BOUNDS } from './Constants.js';
import { eventBus, Events } from './EventBus.js';
import { gameState } from './GameState.js';
import { InputSystem } from '../systems/InputSystem.js';
import { loadWorld, getGroundHeight } from '../level/WorldLoader.js';
import { Player } from '../gameplay/Player.js';
export class Game {
constructor() {
this.clock = new THREE.Clock();
this.yaw = -Math.PI * 0.5; // face along +X into the arcade
this.pitch = 0.0;
this.player = null;
// Renderer — antialias OFF is critical for Gaussian Splats
this.renderer = new THREE.WebGLRenderer({ antialias: false });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, GAME.MAX_DPR));
this.renderer.setClearColor(COLORS.SKY);
document.body.prepend(this.renderer.domElement);
// Scene
this.scene = new THREE.Scene();
// Camera
this.camera = new THREE.PerspectiveCamera(
GAME.FOV, window.innerWidth / window.innerHeight, GAME.NEAR, GAME.FAR
);
// Systems
this.input = new InputSystem();
// Lighting for mesh objects (character model)
this.scene.add(new THREE.AmbientLight(0xffffff, 1.0));
const dir = new THREE.DirectionalLight(0xffffee, 1.5);
dir.position.set(2, 5, 3);
this.scene.add(dir);
// Pointer lock for mouse look (orbits around player)
this.renderer.domElement.addEventListener('click', () => {
this.renderer.domElement.requestPointerLock();
});
document.addEventListener('mousemove', (e) => {
if (document.pointerLockElement !== this.renderer.domElement) return;
this.yaw -= e.movementX * CAMERA.MOUSE_SENSITIVITY;
this.pitch -= e.movementY * CAMERA.MOUSE_SENSITIVITY;
this.pitch = Math.max(-0.5, Math.min(1.2, this.pitch));
});
eventBus.on(Events.GAME_RESTART, () => this.restart());
window.addEventListener('resize', () => this.onResize());
this.loadingEl = document.getElementById('loading');
this.init();
}
async init() {
try {
if (this.loadingEl) this.loadingEl.textContent = 'Loading world...';
await loadWorld(this.scene, this.renderer, this.camera);
// Create the player character
this.player = new Player(this.scene);
if (this.loadingEl) this.loadingEl.classList.add('hidden');
} catch (err) {
console.error('[Game] World load error:', err);
if (this.loadingEl) this.loadingEl.textContent = 'World load failed';
}
this.startGame();
this.renderer.setAnimationLoop(() => this.animate());
}
startGame() {
gameState.reset();
gameState.started = true;
if (this.player) this.player.reset();
this.yaw = Math.PI * 0.5;
this.pitch = 0.05;
}
restart() { this.startGame(); }
animate() {
const delta = Math.min(this.clock.getDelta(), GAME.MAX_DELTA);
this.input.update();
if (gameState.started && !gameState.gameOver && this.player) {
// Update player movement + animation (pass camera yaw as azimuth)
this.player.update(delta, this.input, this.yaw);
// Pin player to starting Y (collider is unreliable after Y-flip)
const pp = this.player.mesh.position;
pp.y = PLAYER.START_Y;
// Clamp player to world bounds
pp.x = Math.max(BOUNDS.MIN_X, Math.min(BOUNDS.MAX_X, pp.x));
pp.z = Math.max(BOUNDS.MIN_Z, Math.min(BOUNDS.MAX_Z, pp.z));
// Third-person camera: orbit behind player
const idealX = pp.x + Math.sin(this.yaw) * CAMERA.FOLLOW_DISTANCE;
const idealZ = pp.z + Math.cos(this.yaw) * CAMERA.FOLLOW_DISTANCE;
const idealY = pp.y + CAMERA.FOLLOW_HEIGHT + Math.sin(this.pitch) * CAMERA.FOLLOW_DISTANCE * 0.5;
// Smooth follow
const t = 1 - Math.exp(-CAMERA.LERP_SPEED * delta);
this.camera.position.x += (idealX - this.camera.position.x) * t;
this.camera.position.y += (idealY - this.camera.position.y) * t;
this.camera.position.z += (idealZ - this.camera.position.z) * t;
// Look at player
this.camera.lookAt(pp.x, pp.y + CAMERA.LOOK_HEIGHT, pp.z);
}
this.renderer.render(this.scene, this.camera);
}
onResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
}
@@ -0,0 +1,11 @@
class GameStateImpl {
constructor() { this.reset(); }
reset() {
this.started = false;
this.gameOver = false;
this.score = 0;
this.bestScore = 0;
}
}
export const gameState = new GameStateImpl();
@@ -0,0 +1,129 @@
import * as THREE from 'three';
import { PLAYER, CHARACTER } from '../core/Constants.js';
import { loadAnimatedModel } from '../level/AssetLoader.js';
const _v = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _up = new THREE.Vector3(0, 1, 0);
export class Player {
constructor(scene) {
this.scene = scene;
this.mixer = null;
this.actions = {};
this.activeAction = null;
this.model = null;
this.ready = false;
// Group is the position anchor — camera follows this
this.mesh = new THREE.Group();
this.mesh.position.set(PLAYER.START_X, PLAYER.START_Y, PLAYER.START_Z);
this.scene.add(this.mesh);
this._loadModel();
}
async _loadModel() {
try {
const { model, clips } = await loadAnimatedModel(CHARACTER.path);
model.scale.setScalar(CHARACTER.scale);
model.position.y = CHARACTER.offsetY;
this.model = model;
this.mesh.add(model);
// Set up mixer
this.mixer = new THREE.AnimationMixer(model);
for (const clip of clips) {
this.actions[clip.name] = this.mixer.clipAction(clip);
}
// Start idle
const idleClip = CHARACTER.clipMap.idle;
if (this.actions[idleClip]) {
this.actions[idleClip].play();
this.activeAction = this.actions[idleClip];
}
this.ready = true;
console.log('[Player] Loaded. Animations:', Object.keys(this.actions).join(', '));
} catch (err) {
console.warn('[Player] Model failed, using fallback:', err.message);
// Fallback: colored box
const geo = new THREE.BoxGeometry(0.3, 0.9, 0.3);
const mat = new THREE.MeshLambertMaterial({ color: PLAYER.COLOR });
const box = new THREE.Mesh(geo, mat);
box.position.y = 0.45;
this.mesh.add(box);
this.ready = true;
}
}
fadeToAction(key, duration = 0.3) {
const clipName = CHARACTER.clipMap[key];
const next = this.actions[clipName];
if (!next || next === this.activeAction) return;
if (this.activeAction) this.activeAction.fadeOut(duration);
next.reset().setEffectiveTimeScale(1).setEffectiveWeight(1).fadeIn(duration).play();
this.activeAction = next;
}
/**
* @param {number} delta
* @param {InputSystem} input
* @param {number} cameraYaw — yaw angle from Game (pointer lock)
*/
update(delta, input, cameraYaw) {
if (this.mixer) this.mixer.update(delta);
if (!this.ready) return;
let ix = 0, iz = 0;
if (input.forward) iz -= 1;
if (input.backward) iz += 1;
if (input.left) ix -= 1;
if (input.right) ix += 1;
const isMoving = ix !== 0 || iz !== 0;
if (isMoving) {
// Camera-relative movement
_v.set(ix, 0, iz).normalize();
_v.applyAxisAngle(_up, cameraYaw);
const speed = input.shift ? PLAYER.SPEED * 2.5 : PLAYER.SPEED;
this.mesh.position.addScaledVector(_v, speed * delta);
// Rotate model to face movement direction
if (this.model) {
const angle = Math.atan2(_v.x, _v.z) + (CHARACTER.facingOffset || 0);
_q.setFromAxisAngle(_up, angle);
this.model.quaternion.rotateTowards(_q, PLAYER.TURN_SPEED * delta);
}
this.fadeToAction(input.shift ? 'run' : 'walk');
} else {
this.fadeToAction('idle');
}
}
reset() {
this.mesh.position.set(PLAYER.START_X, PLAYER.START_Y, PLAYER.START_Z);
if (this.model) {
this.model.quaternion.identity();
}
this.fadeToAction('idle');
}
destroy() {
if (this.mixer) this.mixer.stopAllAction();
this.mesh.traverse((c) => {
if (c.isMesh) {
c.geometry.dispose();
if (Array.isArray(c.material)) c.material.forEach(m => m.dispose());
else c.material.dispose();
}
});
this.scene.remove(this.mesh);
}
}
@@ -0,0 +1,94 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
const loader = new GLTFLoader();
loader.setMeshoptDecoder(MeshoptDecoder);
const cache = new Map();
/**
* Load a GLTF/GLB model (static, no skeleton).
*/
export async function loadModel(path) {
const gltf = await _load(path);
const clone = gltf.scene.clone(true);
clone.traverse((child) => {
if (child.isMesh) {
child.material = child.material.clone();
child.castShadow = true;
child.receiveShadow = true;
}
});
return clone;
}
/**
* Load a GLTF/GLB model with skeleton + animations.
* Uses SkeletonUtils.clone() so bone bindings survive cloning.
*/
export async function loadAnimatedModel(path) {
const gltf = await _load(path);
// SkeletonUtils.clone properly re-binds SkinnedMesh to cloned Skeleton
const model = SkeletonUtils.clone(gltf.scene);
model.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
return { model, clips: gltf.animations };
}
/**
* Preload multiple paths in parallel. Returns when all are cached.
* @param {string[]} paths
* @param {(loaded: number, total: number) => void} [onProgress]
*/
export async function preloadAll(paths, onProgress) {
let loaded = 0;
const total = paths.length;
await Promise.all(paths.map((path) =>
_load(path).then(() => {
loaded++;
if (onProgress) onProgress(loaded, total);
})
));
}
/**
* Dispose all cached models.
*/
export function disposeAll() {
cache.forEach((promise) => {
promise.then((gltf) => {
gltf.scene.traverse((child) => {
if (child.isMesh) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((m) => m.dispose());
} else {
child.material.dispose();
}
}
});
});
});
cache.clear();
}
function _load(path) {
if (!cache.has(path)) {
cache.set(path, new Promise((resolve, reject) => {
loader.load(path, resolve, undefined,
(err) => reject(new Error(`Failed to load: ${path}${err.message || err}`))
);
}));
}
return cache.get(path);
}
@@ -0,0 +1,105 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
import { WORLD } from '../core/Constants.js';
let _colliderMesh = null;
let _splatMesh = null;
/**
* Load the World Labs environment.
*/
export async function loadWorld(scene, renderer, camera) {
// SparkRenderer — quality tuning for splat rendering
const spark = new SparkRenderer({
renderer,
preBlurAmount: 0.1, // Minimal blur — full-res splat is sharp enough
maxPixelRadius: 512, // Allow larger splats for close-up detail
});
scene.add(spark);
const promises = [];
if (WORLD.splatPath) {
promises.push(loadSplat(scene).catch(err => {
console.warn('[WorldLoader] Splat failed:', err.message);
}));
}
if (WORLD.colliderPath) {
promises.push(loadCollider(scene).catch(err => {
console.warn('[WorldLoader] Collider failed:', err.message);
}));
}
// Panorama disabled — same scene as splat causes "world inside world" doubling
// if (WORLD.panoPath) { ... }
await Promise.all(promises);
console.log('[WorldLoader] Environment loaded',
_splatMesh ? '(splat OK)' : '(no splat)',
_colliderMesh ? '(collider OK)' : '(no collider)'
);
return { splat: _splatMesh, collider: _colliderMesh };
}
async function loadSplat(scene) {
const splat = new SplatMesh({ url: WORLD.splatPath });
splat.scale.setScalar(WORLD.scale);
// World Labs SPZ is Y-flipped. rotation.x=PI flips Y and Z.
// Compensate Z shift: world Z range [-1.08, 5.16] → offset = sum = 4.08
splat.rotation.x = Math.PI;
splat.position.set(WORLD.position.x, WORLD.position.y, WORLD.position.z + 4.08);
scene.add(splat);
if (splat.initialized) await splat.initialized;
_splatMesh = splat;
}
async function loadCollider(scene) {
const loader = new GLTFLoader();
const gltf = await loader.loadAsync(WORLD.colliderPath);
_colliderMesh = gltf.scene;
_colliderMesh.visible = false;
_colliderMesh.scale.setScalar(WORLD.scale);
// Match splat Y-flip: rotation.x=PI + Z compensation
_colliderMesh.rotation.x = Math.PI;
_colliderMesh.position.set(WORLD.position.x, WORLD.position.y, WORLD.position.z + 4.08);
_colliderMesh.traverse(child => {
if (child.isMesh) child.material.side = THREE.DoubleSide;
});
// Force matrix update so raycasts work before first render
_colliderMesh.updateMatrixWorld(true);
scene.add(_colliderMesh);
}
async function loadPanorama(scene) {
const texLoader = new THREE.TextureLoader();
const panoTex = await texLoader.loadAsync(WORLD.panoPath);
panoTex.mapping = THREE.EquirectangularReflectionMapping;
panoTex.colorSpace = THREE.SRGBColorSpace;
scene.background = panoTex;
scene.environment = panoTex;
}
const _raycaster = new THREE.Raycaster();
const _upDir = new THREE.Vector3(0, 1, 0);
const _rayOrigin = new THREE.Vector3();
let _lastGroundY = 0;
export function getGroundHeight(x, z, fallback = 0) {
if (!_colliderMesh) return fallback;
// Raycast UPWARD from below — after Y-flip, the visual floor is the
// lowest surface. Shooting up from below hits the floor first.
_rayOrigin.set(x, -50, z);
_raycaster.set(_rayOrigin, _upDir);
const hits = _raycaster.intersectObject(_colliderMesh, true);
if (hits.length > 0) {
_lastGroundY = hits[0].point.y;
return _lastGroundY;
}
// No hit — keep last known ground height to prevent floating
return _lastGroundY;
}
+40
View File
@@ -0,0 +1,40 @@
import { Game } from './core/Game.js';
import { gameState } from './core/GameState.js';
const game = new Game();
// Expose for testing
window.__GAME__ = game;
window.__GAME_STATE__ = gameState;
window.render_game_to_text = () => {
if (!game || !gameState) return JSON.stringify({ error: 'not_ready' });
const payload = {
coords: 'origin:center x:right y:up z:toward-camera',
mode: gameState.gameOver ? 'game_over' : gameState.started ? 'playing' : 'loading',
score: gameState.score,
};
if (gameState.started && game.player) {
const pos = game.player.mesh.position;
payload.player = {
x: Math.round(pos.x * 100) / 100,
y: Math.round(pos.y * 100) / 100,
z: Math.round(pos.z * 100) / 100,
};
}
return JSON.stringify(payload);
};
window.advanceTime = (ms) => {
return new Promise((resolve) => {
const start = performance.now();
function step() {
if (performance.now() - start >= ms) return resolve();
requestAnimationFrame(step);
}
requestAnimationFrame(step);
});
};
@@ -0,0 +1,31 @@
// =============================================================================
// InputSystem.js — Keyboard state tracker for third-person controller
//
// WASD / Arrow keys for movement. Provides both boolean (forward/left/etc)
// and analog (moveX/moveZ) accessors for backward compatibility.
// =============================================================================
export class InputSystem {
constructor() {
this.keys = {};
window.addEventListener('keydown', (e) => {
this.keys[e.code] = true;
if (e.code.startsWith('Arrow')) e.preventDefault();
});
window.addEventListener('keyup', (e) => { this.keys[e.code] = false; });
}
isDown(code) { return !!this.keys[code]; }
setGameActive() {}
update() {}
get forward() { return this.isDown('KeyW') || this.isDown('ArrowUp'); }
get backward() { return this.isDown('KeyS') || this.isDown('ArrowDown'); }
get left() { return this.isDown('KeyA') || this.isDown('ArrowLeft'); }
get right() { return this.isDown('KeyD') || this.isDown('ArrowRight'); }
get shift() { return this.isDown('ShiftLeft') || this.isDown('ShiftRight'); }
get jump() { return this.isDown('Space'); }
get moveX() { return (this.right ? 1 : 0) - (this.left ? 1 : 0); }
get moveZ() { return (this.backward ? 1 : 0) - (this.forward ? 1 : 0); }
}
+24
View File
@@ -0,0 +1,24 @@
import { eventBus, Events } from '../core/EventBus.js';
import { gameState } from '../core/GameState.js';
export class Menu {
constructor() {
this.gameoverOverlay = document.getElementById('gameover-overlay');
this.restartBtn = document.getElementById('restart-btn');
this.finalScoreEl = document.getElementById('final-score');
this.bestScoreEl = document.getElementById('best-score');
this.restartBtn.addEventListener('click', () => {
this.gameoverOverlay.classList.add('hidden');
eventBus.emit(Events.GAME_RESTART);
});
eventBus.on(Events.GAME_OVER, ({ score }) => this.showGameOver(score));
}
showGameOver(score) {
this.finalScoreEl.textContent = `Score: ${score}`;
this.bestScoreEl.textContent = `Best: ${gameState.bestScore}`;
this.gameoverOverlay.classList.remove('hidden');
}
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 3000,
},
build: {
target: 'esnext',
},
});