// LoFinity — entry point: renderer, camera, sky, intro choreography and the // backend bridge. The world itself (vending machine, house, fields…) is // built in world.js. import * as THREE from "three"; import { mergeGeometries } from "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/utils/BufferGeometryUtils.js"; import { buildWorld } from "/static/world.js"; import { initUI } from "/static/ui.js"; // --------------------------------------------------------------------------- // Renderer / scene / camera // --------------------------------------------------------------------------- const canvas = document.getElementById("scene"); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: "low-power", }); // 1.5x is visually identical to 2x at this flat-shaded art style, ~44% fewer pixels renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; // nothing that casts shadows ever moves: bake the shadow map once renderer.shadowMap.autoUpdate = false; renderer.shadowMap.needsUpdate = true; const scene = new THREE.Scene(); scene.fog = new THREE.Fog(0xbfe3ff, 90, 420); const camera = new THREE.PerspectiveCamera( 55, window.innerWidth / window.innerHeight, 0.1, 1000, ); // The camera starts high in the sky gaze and dollies down/back during the // descent for a touch of parallax. const CAM_START = new THREE.Vector3(0, 7.4, 14.5); const CAM_END = new THREE.Vector3(0, 4.8, 17); const LOOK_SKY = new THREE.Vector3(0, 90, -60); const LOOK_SCENE = new THREE.Vector3(-0.5, 2.8, -3); camera.position.copy(CAM_START); camera.lookAt(LOOK_SKY); camera.layers.enable(1); // cloud layer window.addEventListener("resize", () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); // --------------------------------------------------------------------------- // Sky: big inverted dome with a vertical anime-blue gradient // --------------------------------------------------------------------------- const skyMaterial = new THREE.ShaderMaterial({ side: THREE.BackSide, depthWrite: false, uniforms: { topColor: { value: new THREE.Color(0x1547a8) }, horizonColor: { value: new THREE.Color(0xbfe3ff) }, }, vertexShader: /* glsl */ ` varying vec3 vWorldPosition; void main() { vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: /* glsl */ ` uniform vec3 topColor; uniform vec3 horizonColor; varying vec3 vWorldPosition; void main() { float h = clamp(normalize(vWorldPosition).y, 0.0, 1.0); vec3 color = mix(horizonColor, topColor, pow(h, 0.55)); gl_FragColor = vec4(color, 1.0); } `, }); scene.add(new THREE.Mesh(new THREE.SphereGeometry(450, 32, 16), skyMaterial)); // --------------------------------------------------------------------------- // Lights // --------------------------------------------------------------------------- const hemi = new THREE.HemisphereLight(0xcfe8ff, 0xa8b48a, 0.85); scene.add(hemi); // Clouds (layer 1) get their own bounce light: white from above, cool // blue-gray from below — no green ground tint. const cloudLight = new THREE.HemisphereLight(0xf2f8ff, 0xc9d9ec, 0.9); cloudLight.layers.set(1); scene.add(cloudLight); const sun = new THREE.DirectionalLight(0xfff2dc, 1.6); sun.layers.enable(1); sun.position.set(30, 55, 20); sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048); sun.shadow.camera.left = -45; sun.shadow.camera.right = 45; sun.shadow.camera.top = 45; sun.shadow.camera.bottom = -45; sun.shadow.camera.near = 5; sun.shadow.camera.far = 150; sun.shadow.bias = -0.0005; scene.add(sun); // --------------------------------------------------------------------------- // World // --------------------------------------------------------------------------- // Collapse the static background into one mesh per material. The scene is // hundreds of tiny flat-shaded meshes (trees, fences, sunflowers, fields…) // that never move, so bake each into world space and merge by material — // cutting draw calls ~3-4x. Interactables (vending/bench/lamp + their hover // outlines) and the animated bird/clouds are excluded so picking and // animation keep working; textured/basic meshes (signs, poster, sky) are // skipped automatically since they aren't plain opaque Lambert. function mergeStaticWorld(scene, world) { scene.updateMatrixWorld(true); const excludedRoots = [world.vending, world.bench, world.lamp, world.gameboy, ...world.clouds]; const isExcluded = (obj) => { for (let o = obj; o; o = o.parent) if (excludedRoots.includes(o)) return true; return false; }; const buckets = new Map(); // material signature -> meshes sharing it scene.traverse((obj) => { if (!obj.isMesh || isExcluded(obj)) return; const m = obj.material; if (Array.isArray(m) || !m.isMeshLambertMaterial) return; if (m.map || m.transparent || m.opacity < 1) return; const key = [ m.color.getHexString(), m.emissive.getHexString(), m.flatShading ? 1 : 0, m.side, obj.castShadow ? 1 : 0, obj.receiveShadow ? 1 : 0, ].join(":"); let b = buckets.get(key); if (!b) buckets.set( key, (b = { material: m, cast: obj.castShadow, receive: obj.receiveShadow, meshes: [] }), ); b.meshes.push(obj); }); let saved = 0; for (const b of buckets.values()) { if (b.meshes.length < 2) continue; // a lone mesh has nothing to merge with const geoms = b.meshes.map((mesh) => { // bake the world transform in, then strip everything but position/normal // so every geometry in the bucket has identical attributes to merge const g = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry.clone(); g.applyMatrix4(mesh.matrixWorld); for (const name of Object.keys(g.attributes)) if (name !== "position" && name !== "normal") g.deleteAttribute(name); return g; }); const merged = mergeGeometries(geoms, false); geoms.forEach((g) => g.dispose()); if (!merged) continue; const mesh = new THREE.Mesh(merged, b.material); mesh.castShadow = b.cast; mesh.receiveShadow = b.receive; scene.add(mesh); for (const old of b.meshes) { old.geometry.dispose(); old.parent?.remove(old); } saved += b.meshes.length - 1; } return saved; } const world = buildWorld(scene); mergeStaticWorld(scene, world); // Lamp pool light: dark by day, ramps up in night mode. Sits at the bulb // (lamp group origin + the bulb's local offset). No shadows — the shadow map // is baked once, and this only lights up at night anyway. const lampLight = new THREE.PointLight(0xffd9a0, 0, 24, 2); lampLight.position.set( world.lamp.position.x - 1.85, world.lamp.position.y + 8.73, world.lamp.position.z, ); scene.add(lampLight); // Freeze transform recomputation for the (almost entirely) static scene; // only the clouds and the camera move. scene.traverse((obj) => { obj.updateMatrix(); obj.matrixAutoUpdate = false; }); for (const cloud of world.clouds) cloud.matrixAutoUpdate = true; world.bird.matrixAutoUpdate = true; world.bird.userData.tail.matrixAutoUpdate = true; // --------------------------------------------------------------------------- // Interactables: hover outline + floating label, click hook // --------------------------------------------------------------------------- const raycaster = new THREE.Raycaster(); const pointerNDC = new THREE.Vector2(-2, -2); // offscreen until first move let pointerActive = false; let hoveredItem = null; const labelAnchor = new THREE.Vector3(); // anchors are precomputed: nothing interactable ever moves const collectionAnchor = world.collection.getWorldPosition(new THREE.Vector3()); const interactables = [ { object: world.vending, outlines: [world.vending.userData.outline], label: document.getElementById("vending-label"), anchor: world.vending.position.clone(), labelOffsetY: 5.6, // above the machine top onClick: () => zoomToMachine(), }, { object: world.collection, outlines: world.collection.userData.outlines, label: document.getElementById("collection-label"), anchor: collectionAnchor, labelOffsetY: 1.15, // above the bench backrest onClick: () => zoomToBench(), }, { object: world.lamp, outlines: [world.lamp.userData.outline], label: document.getElementById("lamp-label"), anchor: world.lamp.position.clone(), labelOffsetY: 5.5, // pole mid-height; CSS sits the bubble to its left labelOffsetX: -0.3, // left of the bubble onClick: () => toggleNight(), }, { object: world.gameboy, outlines: [world.gameboy.userData.outline], label: document.getElementById("gameboy-label"), anchor: world.gameboy.position.clone(), labelOffsetY: 1.3, // floats just above the handheld on the ground onClick: () => zoomToGameboy(), }, ]; window.addEventListener("pointermove", (e) => { pointerNDC.set( (e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1, ); pointerActive = true; }); document.addEventListener("pointerleave", () => { pointerActive = false; }); function setHoveredItem(item) { if (item === hoveredItem) return; if (hoveredItem) { for (const outline of hoveredItem.outlines) outline.visible = false; hoveredItem.label.classList.remove("visible"); } hoveredItem = item; if (item) { for (const outline of item.outlines) outline.visible = true; item.label.classList.add("visible"); } canvas.style.cursor = item ? "pointer" : ""; } function updateHover() { if (intro.phase !== "idle" || view.mode !== "scene" || !pointerActive) { setHoveredItem(null); return; } raycaster.setFromCamera(pointerNDC, camera); setHoveredItem( interactables.find( (item) => raycaster.intersectObject(item.object, true).length > 0, ) ?? null, ); if (hoveredItem) { labelAnchor.copy(hoveredItem.anchor); labelAnchor.y += hoveredItem.labelOffsetY; labelAnchor.x += hoveredItem.labelOffsetX || 0; labelAnchor.project(camera); hoveredItem.label.style.left = `${((labelAnchor.x + 1) / 2) * window.innerWidth}px`; hoveredItem.label.style.top = `${((1 - labelAnchor.y) / 2) * window.innerHeight}px`; } } // --------------------------------------------------------------------------- // Zoom views: machine (modal while zoomed) and bench (a close look at the // tapes). Click zooms in, close/Esc/click zooms back out. // --------------------------------------------------------------------------- // Frames the machine on the left so the modal card sits beside it. const MACHINE_CAM = new THREE.Vector3(-2.3, 3.0, 10.8); const MACHINE_LOOK = new THREE.Vector3(-2.9, 2.6, 4.2); // Leans over the bench seat where the tapes and walkman rest. const BENCH_CAM = new THREE.Vector3(1.3, 3.3, 8.4); const BENCH_LOOK = new THREE.Vector3(1.3, 1.15, 4.4); // Crouches toward the game boy on the sidewalk; the modal sits over it. const GAMEBOY_CAM = new THREE.Vector3(5.6, 2.3, 9.0); const GAMEBOY_LOOK = new THREE.Vector3(6.5, 0.3, 5.6); const ZOOM_MS = 1500; const view = { mode: "scene", // scene | zoom-in | machine | bench | gameboy | zoom-out target: "machine", // what the current zoom-in lands on t0: 0, camFrom: new THREE.Vector3(), camTo: new THREE.Vector3(), lookFrom: new THREE.Vector3(), lookTo: new THREE.Vector3(), }; function startViewTransition(mode, camTo, lookTo) { view.mode = mode; view.t0 = performance.now(); view.camFrom.copy(camera.position); view.lookFrom.copy(lookTarget); view.camTo.copy(camTo); view.lookTo.copy(lookTo); } function zoomToMachine() { setHoveredItem(null); view.target = "machine"; startViewTransition("zoom-in", MACHINE_CAM, MACHINE_LOOK); } function zoomToBench() { setHoveredItem(null); view.target = "bench"; startViewTransition("zoom-in", BENCH_CAM, BENCH_LOOK); } function closeMachineView() { ui.closeModal(); startViewTransition("zoom-out", CAM_END, LOOK_SCENE); } function closeBenchView() { ui.closeCollection(); startViewTransition("zoom-out", CAM_END, LOOK_SCENE); } function zoomToGameboy() { setHoveredItem(null); view.target = "gameboy"; startViewTransition("zoom-in", GAMEBOY_CAM, GAMEBOY_LOOK); } function closeGameboyView() { ui.closeGameboy(); startViewTransition("zoom-out", CAM_END, LOOK_SCENE); } canvas.addEventListener("click", () => { if (hoveredItem && view.mode === "scene" && intro.phase === "idle") { hoveredItem.onClick(); } else if (view.mode === "bench") { closeBenchView(); } }); window.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; if (view.mode === "bench") closeBenchView(); else if (view.mode === "gameboy") closeGameboyView(); }); // Machine glow while generating let machineBusy = false; const SCREEN_BASE = new THREE.Color(0x1c2f28); const DISPENSER_BASE = new THREE.Color(0x10181d); function setMachineBusy(busy) { machineBusy = busy; if (!busy) { world.vending.userData.screenMaterial.color.copy(SCREEN_BASE); world.vending.userData.dispenserMaterial.color.copy(DISPENSER_BASE); } } // --------------------------------------------------------------------------- // Day / night: clicking the lamp post eases the sky shader, fog and lights to // a moonlit palette and lights the lamp. No camera move — the scene just dims. // --------------------------------------------------------------------------- const DAY = { skyTop: new THREE.Color(0x1547a8), skyHorizon: new THREE.Color(0xbfe3ff), fog: new THREE.Color(0xbfe3ff), hemiSky: new THREE.Color(0xcfe8ff), hemiGround: new THREE.Color(0xa8b48a), sun: new THREE.Color(0xfff2dc), cloudSky: new THREE.Color(0xf2f8ff), cloudGround: new THREE.Color(0xc9d9ec), }; const NIGHT = { skyTop: new THREE.Color(0x05060f), skyHorizon: new THREE.Color(0x232a4d), fog: new THREE.Color(0x141a30), hemiSky: new THREE.Color(0x2a3358), hemiGround: new THREE.Color(0x1c2422), sun: new THREE.Color(0x93a9e6), // a cool, dim "moon" cloudSky: new THREE.Color(0x9fb0d8), cloudGround: new THREE.Color(0x2b3450), }; const NIGHT_KEY = "lofinity:night"; // localStorage flag, persists the choice let isNight = false; let nightTarget = 0; // 0 = day, 1 = night let nightMix = 0; // eased toward nightTarget in the render loop function applyPalette(m) { const sky = skyMaterial.uniforms; sky.topColor.value.lerpColors(DAY.skyTop, NIGHT.skyTop, m); sky.horizonColor.value.lerpColors(DAY.skyHorizon, NIGHT.skyHorizon, m); scene.fog.color.lerpColors(DAY.fog, NIGHT.fog, m); hemi.color.lerpColors(DAY.hemiSky, NIGHT.hemiSky, m); hemi.groundColor.lerpColors(DAY.hemiGround, NIGHT.hemiGround, m); hemi.intensity = THREE.MathUtils.lerp(0.85, 0.32, m); sun.color.lerpColors(DAY.sun, NIGHT.sun, m); sun.intensity = THREE.MathUtils.lerp(1.6, 0.5, m); cloudLight.color.lerpColors(DAY.cloudSky, NIGHT.cloudSky, m); cloudLight.groundColor.lerpColors(DAY.cloudGround, NIGHT.cloudGround, m); cloudLight.intensity = THREE.MathUtils.lerp(0.9, 0.4, m); lampLight.intensity = THREE.MathUtils.lerp(0, 2.6, m); } function setNightLabel() { const span = document.querySelector("#lamp-label span"); if (span) span.textContent = isNight ? "☀ back to day" : "☾ turn on night"; } function toggleNight() { isNight = !isNight; nightTarget = isNight ? 1 : 0; setNightLabel(); try { localStorage.setItem(NIGHT_KEY, isNight ? "1" : "0"); } catch { /* storage unavailable (private mode) — keep the choice in-memory only */ } } // restore the persisted day/night choice on load, applied instantly (no fade) try { if (localStorage.getItem(NIGHT_KEY) === "1") { isNight = true; nightTarget = 1; nightMix = 1; applyPalette(1); setNightLabel(); } } catch { /* unreadable storage — start in day mode */ } // --------------------------------------------------------------------------- // Backend bridge + modal UI // --------------------------------------------------------------------------- let generateFn = null; const ui = initUI({ generate: (prompt, seconds) => { if (!generateFn) return Promise.reject(new Error("backend not connected")); return generateFn(prompt, seconds); }, // chunks-done/total for the brewing bar; a plain GET, independent of the // gradio client so it answers while a generation is in flight getProgress: async () => { try { const r = await fetch("/api/progress"); return r.ok ? await r.json() : null; } catch { return null; } }, onRequestClose: () => { if (view.mode === "machine") closeMachineView(); }, onRequestCloseCollection: () => { if (view.mode === "bench") closeBenchView(); }, onRequestCloseGameboy: () => { if (view.mode === "gameboy") closeGameboyView(); }, // "play while waiting": leave the beat brewing in the background and fly // from the machine over to the garden mini-game. onPlayWhileWaiting: () => { if (view.mode !== "machine") return; ui.closeModal(); zoomToGameboy(); }, onGeneratingChange: setMachineBusy, }); import("https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js") .then(async ({ Client }) => { const client = await Client.connect(window.location.origin); generateFn = async (prompt, seconds) => { const result = await client.predict("/generate_song", { prompt, seconds }); const data = result.data[0]; const dataUri = data.audio; if (!dataUri) throw new Error("no audio in response"); // The tape comes back as an inline base64 WAV; pull it into a Blob URL — // lighter for