All files / src/components/VirtualWalkthrough camera-adoption.ts

100% Statements 12/12
100% Branches 8/8
100% Functions 2/2
100% Lines 12/12

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80                                                                                                2x 8x 1x     7x     7x 2x     5x               5x   5x     3x 3x 3x        
import { CameraComponent, Entity, Quat, Vec3 } from 'playcanvas';
 
/**
 * The pose an adopted camera is put back to when the walkthrough releases it.
 */
interface AdoptedPose {
  parent: Entity;
  position: Vec3;
  rotation: Quat;
}
 
/**
 * A camera the walkthrough took over, and the call that hands it back.
 */
export interface AdoptedCamera {
  /** The entity now carrying the live camera component, which the rig poses and projects from. */
  camera: Entity;
  release: () => void;
}
 
/**
 * Hands the scene's existing camera to a walkthrough rig that hasn't been given one.
 *
 * A caller mounting the viewer into a scene it doesn't own passes `camera={null}`, because an XR
 * session is welded to the camera component it was started on — `XrManager.camera` is read-only and
 * `start` refuses while a session is running — so a walkthrough that brought a camera of its own
 * could only become the session's camera by ending it, which drops the user out of the headset.
 *
 * That leaves the rig with no camera under it, and everything that poses or solves against the head
 * resolves it with `findComponent('camera')` on the rig: `WalkthroughCamera`'s pointer and keyboard
 * walk, `XrStartPose`, `XrNavigation`'s thumbstick movement and turning, and the bounds clamp all go
 * inert, so the head is never placed at the walkthrough's start point and can never be moved from
 * wherever the host scene left it. Splat LOD is chosen from
 * the camera's distance to each octree node, so the far side of the model stays coarse for the whole
 * session no matter which way the headset turns.
 *
 * Reparenting carries the live camera component with it, so the session, its views, and the host's
 * own clear color and clip planes are all untouched — only which entity the camera hangs off
 * changes. The camera becomes a direct child of the rig, which is exactly where a camera the viewer
 * owns sits, so the rig means the same thing either way.
 *
 * @param root - The scene root to look for a camera in, expected to hold exactly one camera.
 * @param rig - The entity the walkthrough's XR scripts move, `camera-root`.
 * @param ownCamera - The entity the viewer mounts its own camera on. Nothing is adopted when it
 * already carries one.
 * @returns The adopted camera and the call that puts it back, or null when there was nothing to
 * adopt.
 */
export const adoptSceneCamera = (root: Entity, rig: Entity, ownCamera: Entity): AdoptedCamera | null => {
  if (ownCamera.camera) {
    return null;
  }
 
  const camera = (root.findComponent('camera') as CameraComponent | null)?.entity;
  // isDescendantOf covers a host that mounted the walkthrough inside its own camera, where
  // reparenting would make a cycle.
  if (!camera || camera === rig || !camera.parent || rig.isDescendantOf(camera)) {
    return null;
  }
 
  const previous: AdoptedPose = {
    parent: camera.parent as Entity,
    position: camera.getLocalPosition().clone(),
    rotation: camera.getLocalRotation().clone(),
  };
 
  // addChild keeps the local transform, so the host's authored camera pose survives the move and
  // only has to be restored because a session overwrites it with the head pose every frame.
  rig.addChild(camera);
 
  return {
    camera,
    release: () => {
      previous.parent.addChild(camera);
      camera.setLocalPosition(previous.position);
      camera.setLocalRotation(previous.rotation);
    },
  };
};