Zum Inhalt

viewer/js/viewer.js

import {
  Application, Asset, AssetListLoader, Entity, Vec3,
  FILLMODE_FILL_WINDOW, RESOLUTION_AUTO
} from 'playcanvas';

const ORBIT_SPEED = 8; // degrees per second

// Pose for the church demo splat — tuned in-browser in Task 4.
const DEMO_POSE = {
  level: { x: -19, y: 10, z: 180 },
  offset: { x: 2.4, y: 2.4, z: -1.2 },
  cameraPos: { x: 1.878, y: 1.115, z: -5.222 },
  cameraFocus: { x: 0.656, y: -0.107, z: 0.212 }
};
// Pose for user-supplied splats — z:180 is the common Brush/COLMAP flip;
// the user re-frames with orbit/pan/zoom.
const DEFAULT_POSE = {
  level: { x: 0, y: 0, z: 180 },
  offset: { x: 0, y: 0, z: 0 },
  cameraPos: { x: 0, y: 0, z: 4 },
  cameraFocus: { x: 0, y: 0, z: 0 }
};

export function createViewer(hostElement) {
  if (!document.createElement('canvas').getContext('webgl2')) {
    hostElement.innerHTML =
      '<img src="assets/og-image.jpg" alt="3D Gaussian Splat" ' +
      'style="width:100%;height:100%;object-fit:cover">' +
      '<p style="position:absolute;left:0;right:0;bottom:0;margin:0;' +
      'padding:0.8rem;text-align:center;background:rgba(14,15,19,0.85)">' +
      'Your browser does not support WebGL2 — showing a still image.</p>';
    return {
      loadSplat: () => {}, setAutoOrbit: () => {},
      isAutoOrbit: () => false, onLoad: () => {},
      enterWalking: async () => false, exitWalking: () => {},
      isWalking: () => false,
      onWalkingEnter: () => {}, onWalkingExit: () => {},
      onWalkingModeChange: () => {}, onWalkingEyeChange: () => {},
      enterCollisionEditor: async () => false,
      exitCollisionEditor: () => {},
      isCollisionEditor: () => false,
      getCollisionMode: () => null,
      onCollisionEnter: () => {},
      onCollisionExit: () => {},
      onCollisionMeshBuilt: () => {},
      setUseMeshCollider: () => {},
      isUsingMeshCollider: () => false,
      unsupported: true
    };
  }

  const canvas = document.createElement('canvas');
  hostElement.appendChild(canvas);

  const app = new Application(canvas, {
    graphicsDeviceOptions: { antialias: false }
  });
  app.setCanvasFillMode(FILLMODE_FILL_WINDOW);
  app.setCanvasResolution(RESOLUTION_AUTO);
  app.start();
  window.addEventListener('resize', () => app.resizeCanvas());

  const camera = new Entity('camera');
  camera.addComponent('camera', { clearColor: [0.055, 0.059, 0.075, 1] });
  camera.setPosition(DEMO_POSE.cameraPos.x, DEMO_POSE.cameraPos.y, DEMO_POSE.cameraPos.z);
  app.root.addChild(camera);

  let cc = null;
  const cameraReady = (async () => {
    // camera-controls is a PlayCanvas ESM script; loading it through the asset
    // registry lets the engine attach it. Its `import 'playcanvas'` resolves to
    // the vendored engine via the importmap in viewer.html.
    const ccAsset = new Asset('camera-controls', 'script', { url: 'vendor/camera-controls.mjs' });
    await new Promise(res => new AssetListLoader([ccAsset], app.assets).load(res));
    camera.addComponent('script');
    cc = camera.script.create('cameraControls');
  })();

  // splatPivot rotates for the auto-orbit; splatEntity holds the static leveling
  let splatPivot = null;
  let splatEntity = null;
  let autoOrbit = true;
  const loadListeners = [];

  app.on('update', (dt) => {
    if (autoOrbit && splatPivot) splatPivot.rotate(0, ORBIT_SPEED * dt, 0);
  });

  for (const ev of ['pointerdown', 'wheel']) {
    canvas.addEventListener(ev, () => { autoOrbit = false; });
  }

  async function loadSplat(source, filename, opts = {}) {
    await cameraReady;
    const isFile = source instanceof File;
    // Delivery links (?src=) force the generic pose + auto-frame, so a
    // client's splat is not framed with the church-tuned demo camera.
    const pose = (opts.forceDefaultPose || isFile) ? DEFAULT_POSE : DEMO_POSE;

    let url = source;
    let name = filename;
    let revoke = null;
    if (isFile) {
      url = URL.createObjectURL(source);
      name = source.name;
      revoke = url;
    }

    // loadFromUrlAndFilename: the filename drives format detection, so
    // blob: URLs (which carry no extension) load correctly.
    let asset;
    try {
      asset = await new Promise((resolve, reject) => {
        app.assets.loadFromUrlAndFilename(url, name, 'gsplat', (err, a) => {
          if (err) reject(err); else resolve(a);
        });
      });
    } catch {
      if (revoke) URL.revokeObjectURL(revoke);
      throw new Error('splat-load-failed');
    }
    if (revoke) URL.revokeObjectURL(revoke);

    if (splatPivot) splatPivot.destroy();
    splatPivot = new Entity('splat-pivot');
    splatEntity = new Entity('splat');
    splatEntity.addComponent('gsplat', { asset });
    splatEntity.setLocalEulerAngles(pose.level.x, pose.level.y, pose.level.z);
    splatEntity.setLocalPosition(pose.offset.x, pose.offset.y, pose.offset.z);
    splatPivot.addChild(splatEntity);
    app.root.addChild(splatPivot);

    if (cc) cc.reset(
      new Vec3(pose.cameraFocus.x, pose.cameraFocus.y, pose.cameraFocus.z),
      new Vec3(pose.cameraPos.x, pose.cameraPos.y, pose.cameraPos.z));

    autoOrbit = true; // a freshly loaded splat always starts orbiting

    if (opts.autoFrame) await autoFrameCamera();

    for (const fn of loadListeners) {
      try { fn({ splatEntity, splatPivot }); } catch (e) { console.error(e); }
    }
  }

  // Robust auto-frame for arbitrary (client-delivered) splats: frame the
  // camera from the splat CENTER positions (PC 2.x: gsplat._instance.resource
  // .centers) using per-axis percentiles, so floater outliers don't blow up
  // the framing. Falls back silently to the pose camera if positions are
  // unavailable.
  async function autoFrameCamera() {
    const getCenters = () => {
      const g = splatEntity && splatEntity.gsplat;
      return (g && ((g._instance && g._instance.resource && g._instance.resource.centers) ||
                    (g.instance && g.instance.resource && g.instance.resource.centers))) || null;
    };
    let centers = null;
    for (let i = 0; i < 60 && !centers; i++) {
      centers = getCenters();
      if (!centers) await new Promise(r => requestAnimationFrame(r));
    }
    if (!centers) return;

    const n = centers.length / 3;
    const stepN = Math.max(1, Math.floor(n / 60000));
    const xs = [], ys = [], zs = [];
    for (let i = 0; i < n; i += stepN) {
      xs.push(centers[i * 3]); ys.push(centers[i * 3 + 1]); zs.push(centers[i * 3 + 2]);
    }
    const pct = 0.025;
    const q = (arr, t) => {
      arr.sort((a, b) => a - b);
      return arr[Math.min(arr.length - 1, Math.max(0, Math.floor(t * (arr.length - 1))))];
    };
    const lo = new Vec3(q(xs, pct), q(ys, pct), q(zs, pct));
    const hi = new Vec3(q(xs, 1 - pct), q(ys, 1 - pct), q(zs, 1 - pct));

    let center = new Vec3((lo.x + hi.x) / 2, (lo.y + hi.y) / 2, (lo.z + hi.z) / 2);
    center = splatEntity.getWorldTransform().transformPoint(center.clone());
    const r = Math.max((hi.x - lo.x) / 2, (hi.y - lo.y) / 2, (hi.z - lo.z) / 2, 0.5);
    const dist = r * 2.4;
    const dir = new Vec3(0.8, 0.5, 0.8).normalize();
    const camPos = new Vec3(center.x + dir.x * dist, center.y + dir.y * dist, center.z + dir.z * dist);
    if (cc) {
      cc.reset(center, camPos);
    } else {
      camera.setPosition(camPos);
      camera.lookAt(center.x, center.y, center.z);
    }
  }

  // ---------- Walking-mode glue ----------

  let walkingMode = null;
  let pivotRotationOnEnter = null;
  let lockChangeHandler = null;
  let useMeshCollider = false;  // sticky preference, applied on every walking-enter
  const walkEnterListeners = [];
  const walkExitListeners = [];
  const walkModeListeners = [];
  const walkEyeListeners = [];

  async function enterWalking(input) {
    if (walkingMode) return false;
    if (!splatEntity || !splatPivot) throw new Error('no-splat-loaded');
    autoOrbit = false;
    pivotRotationOnEnter = splatPivot.getRotation().clone();
    splatPivot.setEulerAngles(0, 0, 0);

    const { heightmapFromSplat, WalkingMode } = await import('./walking.js');
    const hm = heightmapFromSplat(splatEntity, splatPivot);
    if (!hm) {
      // No usable bounds — abort cleanly and restore the orbit pose.
      splatPivot.setRotation(pivotRotationOnEnter);
      pivotRotationOnEnter = null;
      autoOrbit = true;
      throw new Error('heightmap-build-failed');
    }
    if (hm.source === 'aabb') {
      console.warn('[walking] splat positions unavailable — using flat-floor fallback');
    }

    if (cc) cc.enabled = false;
    try { await canvas.requestPointerLock?.(); } catch { /* desktop without lock — fine */ }
    // input device wiring is owned by the caller (app.js) — viewer just consumes.

    lockChangeHandler = () => {
      if (document.pointerLockElement !== canvas && walkingMode) {
        // Don't auto-exit walking if the collision editor deliberately
        // released the lock for brush work.
        if (collisionMode && collisionMode._lockReleasedForBrush) return;
        exitWalking(input);
      }
    };
    document.addEventListener('pointerlockchange', lockChangeHandler);

    walkingMode = new WalkingMode({
      app, camera, splatBounds: hm.bounds, heightmap: hm.heightmap,
      input, onExit: () => exitWalking(input),
      onModeChange: (m) => {
        for (const fn of walkModeListeners) {
          try { fn(m); } catch (e) { console.error(e); }
        }
      },
      onEyeChange: (v) => {
        for (const fn of walkEyeListeners) {
          try { fn(v); } catch (e) { console.error(e); }
        }
      },
    });
    walkingMode.enter();
    applyWalkingColliderPreference();
    for (const fn of walkEnterListeners) {
      try { fn(); } catch (e) { console.error(e); }
    }
    return true;
  }

  function applyWalkingColliderPreference() {
    if (!walkingMode) return;
    if (useMeshCollider && collisionMode) {
      const strat = collisionMode.getCollider();
      if (strat) { walkingMode.setCollider(strat); return; }
    }
    walkingMode.setCollider(null); // back to heightmap default
  }

  function setUseMeshCollider(on) {
    useMeshCollider = !!on;
    applyWalkingColliderPreference();
  }

  function exitWalking(input) {
    if (!walkingMode) return;
    walkingMode.exit();
    walkingMode = null;
    if (lockChangeHandler) {
      document.removeEventListener('pointerlockchange', lockChangeHandler);
      lockChangeHandler = null;
    }
    if (document.exitPointerLock && document.pointerLockElement === canvas) {
      document.exitPointerLock();
    }
    if (cc) cc.enabled = true;
    if (pivotRotationOnEnter && splatPivot) {
      splatPivot.setRotation(pivotRotationOnEnter);
      pivotRotationOnEnter = null;
    }
    for (const fn of walkExitListeners) {
      try { fn(); } catch (e) { console.error(e); }
    }
  }

  // ---------- Collision-editor glue ----------

  let collisionMode = null;
  const collisionEnterListeners = [];
  const collisionExitListeners = [];
  const collisionBuiltListeners = [];

  async function enterCollisionEditor() {
    if (collisionMode) return false;
    if (!splatEntity) throw new Error('no-splat-loaded');
    const [{ CollisionMode }, { splatWorldGeometry }] = await Promise.all([
      import('./collision/collision-mode.js'),
      import('./walking.js'),
    ]);
    collisionMode = new CollisionMode({
      app, camera, splatEntity, splatPivot,
      getSplatPositions: () => splatWorldGeometry(splatEntity, splatPivot),
    });
    collisionMode.onBuilt((info) => {
      // If walking-mode is up and prefers mesh, the BVH just got rebuilt —
      // hand the fresh one over.
      applyWalkingColliderPreference();
      for (const fn of collisionBuiltListeners) {
        try { fn(info); } catch (e) { console.error(e); }
      }
    });
    for (const fn of collisionEnterListeners) {
      try { fn({ mode: collisionMode }); } catch (e) { console.error(e); }
    }
    return true;
  }

  function exitCollisionEditor() {
    if (!collisionMode) return;
    collisionMode.destroy();
    collisionMode = null;
    for (const fn of collisionExitListeners) {
      try { fn(); } catch (e) { console.error(e); }
    }
  }

  return {
    loadSplat,
    setAutoOrbit(on) { autoOrbit = on; },
    isAutoOrbit() { return autoOrbit; },
    onLoad(fn) { loadListeners.push(fn); },
    enterWalking,
    exitWalking,
    isWalking() { return walkingMode != null; },
    onWalkingEnter(fn) { walkEnterListeners.push(fn); },
    onWalkingExit(fn) { walkExitListeners.push(fn); },
    onWalkingModeChange(fn) { walkModeListeners.push(fn); },
    onWalkingEyeChange(fn) { walkEyeListeners.push(fn); },
    enterCollisionEditor,
    exitCollisionEditor,
    isCollisionEditor() { return collisionMode != null; },
    getCollisionMode() { return collisionMode; },
    onCollisionEnter(fn) { collisionEnterListeners.push(fn); },
    onCollisionExit(fn) { collisionExitListeners.push(fn); },
    onCollisionMeshBuilt(fn) { collisionBuiltListeners.push(fn); },
    setUseMeshCollider,
    isUsingMeshCollider() { return useMeshCollider; },
    unsupported: false
  };
}