← Build a viewer

public/build/lab.mjs

Repository source snapshot · Plain text · GitHub

1import init, { WorkshopRenderer } from '/pkg/pajama_gaussian_lab.js?v=gpu-1';23const byId = id => document.getElementById(id);4const canvas = byId('gpu-view');5const stage = byId('stage');6const slider = byId('time');7let renderer, playing = false, loaded = false, busy = false, failed = false;8let clock = 0.5, last = 0, yaw = 0, pitch = 0.15, distance = 5, frames = 0;9const language = () => document.documentElement.lang.startsWith('zh') ? 'zh' : 'en';10const say = (en, zh) => language() === 'zh' ? zh : en;11const stages = JSON.parse(byId('milestone-data').textContent);12const initial = Number(new URL(location.href).searchParams.get('stage') ?? 0);13stage.value = String(Number.isInteger(initial) && initial >= 0 && initial <= 7 ? initial : 0);1415function describe() {16  const n = Number(stage.value), data = stages[n];17  canvas.style.touchAction = n >= 5 ? 'none' : 'pan-y';18  canvas.setAttribute('aria-label', say('Interactive Gaussian viewer. Stage 5 onward: drag or use arrow keys to orbit; Home resets.', '可交互 Gaussian Viewer。阶段 5 起可拖动或使用方向键绕转,Home 重置。'));19  for (const [i, option] of [...stage.options].entries()) option.textContent = `${i} · ${stages[i].title[language()]}`;20  byId('stage-title').textContent = `${n} / 7 · ${data.title[language()]}`;21  byId('expected').textContent = data.expected[language()];22  byId('stage-blog').href = `/build/${data.post}/?lang=${language()}`;23  byId('source-stage').textContent = data.source;24  byId('source-stage').href = `/build/code/${data.source.replaceAll('/', '-')}.html`;25  byId('camera-controls').hidden = n < 5;26  byId('time-controls').hidden = n < 6;27  byId('reverse-label').hidden = n !== 4;28  byId('load-model').hidden = n !== 7 || loaded;29  byId('load-model').disabled = busy;30  byId('load-model').textContent = busy ? say('Loading model…', '正在加载模型…') : say('Load research model · 10.27 MiB', '加载研究模型 · 10.27 MiB');31  byId('play').textContent = playing ? say('Pause', '暂停') : say('Play · 10 s loop', '播放 · 10 秒循环');32  byId('previous-stage').disabled = n === 0;33  byId('next-stage').disabled = n === 7;34  if (!renderer && !failed) byId('gpu-status').textContent = say('Starting WebGPU…', '正在启动 WebGPU…');35  if (renderer && !failed) byId('gpu-status').textContent = n === 7 && !loaded ? say('Load the model to draw stage 7.', '加载模型后可绘制第 7 阶段。') : `${say('Ready', '已就绪')} · ${renderer.adapter()}`;36}3738function changeStage() {39  playing = false; byId('reverse').checked = false;40  const url = new URL(location.href); url.searchParams.set('stage', stage.value); history.replaceState(null, '', url);41  describe();42}43stage.addEventListener('change', changeStage);44byId('previous-stage').onclick = () => { stage.value = String(Math.max(0, Number(stage.value) - 1)); changeStage(); };45byId('next-stage').onclick = () => { stage.value = String(Math.min(7, Number(stage.value) + 1)); changeStage(); };46slider.oninput = () => { clock = Number(slider.value); playing = false; describe(); };47byId('play').onclick = () => { playing = !playing; describe(); };48byId('reset-camera').onclick = () => { yaw = 0; pitch = 0.15; distance = 5; updateCameraControls(); };49for (const key of ['yaw', 'pitch', 'distance']) byId(key).oninput = () => {50  yaw = Number(byId('yaw').value); pitch = Number(byId('pitch').value); distance = Number(byId('distance').value);51};52function updateCameraControls() { byId('yaw').value = yaw; byId('pitch').value = pitch; byId('distance').value = distance; }53let drag;54canvas.onpointerdown = e => { if (Number(stage.value) < 5) return; drag = { x: e.clientX, y: e.clientY }; canvas.setPointerCapture(e.pointerId); };55canvas.onpointermove = e => {56  if (!drag) return;57  yaw = Math.max(-3.14, Math.min(3.14, yaw + (e.clientX - drag.x) * 0.008));58  pitch = Math.max(-1.4, Math.min(1.4, pitch + (e.clientY - drag.y) * 0.008));59  drag = { x: e.clientX, y: e.clientY }; updateCameraControls();60};61canvas.onpointerup = canvas.onpointercancel = canvas.onlostpointercapture = () => { drag = null; };62canvas.onkeydown = e => {63  if (Number(stage.value) < 5) return;64  const actions = { ArrowLeft: () => yaw -= 0.1, ArrowRight: () => yaw += 0.1, ArrowUp: () => pitch += 0.1, ArrowDown: () => pitch -= 0.1, Home: () => { yaw = 0; pitch = 0.15; distance = 5; } };65  if (!actions[e.key]) return;66  e.preventDefault(); actions[e.key](); yaw = Math.max(-3.14, Math.min(3.14, yaw)); pitch = Math.max(-1.4, Math.min(1.4, pitch)); updateCameraControls();67};6869function fatal(error) {70  failed = true; playing = false;71  byId('gpu-status').textContent = `${say('WebGPU stopped', 'WebGPU 已停止')}: ${error.message || error}`;72  byId('recovery').hidden = false;73  byId('play').disabled = true;74}75byId('reload-gpu').onclick = () => location.reload();76byId('load-model').onclick = async () => {77  if (!renderer || busy || loaded || failed) return;78  byId('model-error').textContent = '';79  busy = true; describe();80  try {81    const response = await fetch('/data/n3d-sear-steak-stg-lite.ply.gz');82    if (!response.ok) throw new Error(`HTTP ${response.status}`);83    if (!response.body || !globalThis.DecompressionStream) throw new Error(say('This browser cannot decompress gzip streams.', '此浏览器无法解压 gzip 数据流。'));84    const bytes = new Uint8Array(await new Response(response.body.pipeThrough(new DecompressionStream('gzip'))).arrayBuffer());85    renderer.loadModel(bytes); loaded = true;86  } catch (error) { byId('model-error').textContent = String(error.message || error); }87  finally { busy = false; describe(); }88};8990// ANCHOR: browser-frame91function animate(now) {92  if (failed) return;93  const dt = last ? Math.min((now - last) / 1000, 0.1) : 0; last = now;94  if (!document.hidden && renderer) {95    // Physical pixels, capped for this lesson. CSS dimensions stay in CSS pixels.96    const bounds = canvas.getBoundingClientRect(), dpr = Math.min(devicePixelRatio || 1, 1.5);97    const width = Math.max(1, Math.min(2048, Math.round(bounds.width * dpr)));98    const height = Math.max(1, Math.min(2048, Math.round(bounds.height * dpr)));99    if (canvas.width !== width || canvas.height !== height) {100      canvas.width = width; canvas.height = height; renderer.resize(width, height);101    }102    if (playing) clock = (clock + dt / 10) % 1;103    slider.value = clock;104    const n = Number(stage.value);105    if (n !== 7 || loaded) {106      try {107        const count = renderer.render(n, clock, yaw, pitch, distance, byId('reverse').checked);108        if (count >= 0) { frames++; byId('draw-count').textContent = count.toLocaleString(); }109      } catch (error) { fatal(error); return; }110    } else {111      renderer.render(0, clock, yaw, pitch, distance, false);112      byId('draw-count').textContent = '—';113    }114    byId('time-value').textContent = clock.toFixed(3);115    byId('resolution').textContent = `${width} × ${height}`;116    byId('presented').textContent = frames.toLocaleString();117    byId('camera-value').textContent = `${yaw.toFixed(2)} / ${pitch.toFixed(2)} / ${distance.toFixed(1)}`;118  }119  requestAnimationFrame(animate);120}121// END: browser-frame122document.addEventListener('visibilitychange', () => { last = 0; });123document.addEventListener('build-language', describe);124describe();125try {126  if (!navigator.gpu) throw new Error(say('WebGPU is unavailable. Open this page in a WebGPU-capable browser or run the native example.', 'WebGPU 不可用。请使用支持 WebGPU 的浏览器,或运行桌面示例。'));127  await init({ module_or_path: '/pkg/pajama_gaussian_lab_bg.wasm?v=gpu-1' });128  renderer = await WorkshopRenderer.create(canvas);129  describe(); requestAnimationFrame(animate);130} catch (error) { fatal(error); }131window.addEventListener('pagehide', () => { playing = false; });132