<script>
let flatscreenDemo = false;
const ballCount = 50;
const ballColors = ['#ffe700', '#00bf11', '#00c2f6', '#f04da6'];
const damping = 0.99;
const baseGravity = new THREE.Vector3(0, -0.45, 0);
const gravity = new THREE.Vector3();

// Room collision boundary, must be kept in sync with <a-plane> positions for walls/floor/ceiling
const room = { x1: -4, x2: 4, z1: -4, z2: 4, y1: 0, y2: 5 };

// Physics helpers
function collideRoom(point, radius) {
  let hasCollision = false;
  if (point.x < room.x1 + radius) {
    point.x = room.x1 + radius;
    hasCollision = true;
  } else if (point.x > room.x2 - radius) {
    point.x = room.x2 - radius;
    hasCollision = true;
  }
  if (point.y < room.y1 + radius) {
    point.y = room.y1 + radius;
    hasCollision = true;
  } else if (point.y > room.y2 - radius) {
    point.y = room.y2 - radius;
    hasCollision = true;
  }
  if (point.z < room.z1 + radius) {
    point.z = room.z1 + radius;
    hasCollision = true;
  } else if (point.z > room.z2 - radius) {
    point.z = room.z2 - radius;
    hasCollision = true;
  }
  return hasCollision;
}
  
function constrainDistance(point, anchor, distance) {
  return point
    .sub(anchor)
    .normalize()
    .multiplyScalar(distance)
    .add(anchor);
}


AFRAME.registerComponent('camera-reverse', {
  schema: {},
  init() {
  },
  tick(time, timeDelta) {
    if (window.vrCamera) {
      this.el.object3D.position.x = -window.vrCamera.object3D.position.x;
      this.el.object3D.position.y = -window.vrCamera.object3D.position.y;
      this.el.object3D.position.z = -window.vrCamera.object3D.position.z;
    }
  }
});

// This is hacky, but the camera rig component updates the entire scene, including itself.
AFRAME.registerComponent('camera-rig', {
  schema: {},
  init() {
    this.radius = 1.6; // collision radius, meters
    this.originalPosition = new THREE.Vector3(0, this.radius, 0);
    this.simPosition = this.originalPosition.clone();
    this.prevSimPosition = this.originalPosition.clone();
    this.inertia = new THREE.Vector3();
    this.playerHasContact = false;
    this.tempVec3 = new THREE.Vector3();
    this.demoRotation = 0;
    
    this.cameraExistingRotMat4 = new THREE.Matrix4();
    this.cameraDeltaRotMat4 = new THREE.Matrix4();
    this.cameraDeltaEuler = new THREE.Euler();
  },
  tick(time, timeDelta) {
    // not sure if this check is needed, just here for safety
    if (window.simReady) {
      // When the controller trigger is squeezed, apply relative controller rotation to the camera rig.
      if (window.isRotating) {
        this.cameraDeltaEuler.x = window.vrController.object3D.rotation.x - window.controllerRotationStart.x;
        this.cameraDeltaEuler.y = window.vrController.object3D.rotation.y - window.controllerRotationStart.y;
        this.cameraDeltaEuler.z = window.vrController.object3D.rotation.z - window.controllerRotationStart.z;
        this.cameraExistingRotMat4.makeRotationFromEuler(window.rotationStart);
        this.cameraDeltaRotMat4.makeRotationFromEuler(this.cameraDeltaEuler);
        this.cameraExistingRotMat4.multiply(this.cameraDeltaRotMat4);
        this.el.object3D.rotation.setFromRotationMatrix(this.cameraExistingRotMat4);
      }
      // handle demo mode, and automatically reset orientation when disabled by entering VR mode
      if (flatscreenDemo) {
        this.demoRotation += 0.001 * timeDelta;
        this.el.object3D.rotation.z = this.demoRotation;
      }
      else if (this.demoRotation > 0) {
        this.demoRotation = 0;
        this.el.object3D.rotation.z = 0;
      }

      // update gravity to point down relative to the camera rig
      gravity.copy(baseGravity).multiplyScalar(timeDelta*0.001);
      gravity.applyEuler(this.el.object3D.rotation);
      
      // Simulate player body
      this.inertia.subVectors(this.simPosition, this.prevSimPosition);
      // apply friction with floor, walls, etc.
      if (this.playerHasContact) {
        this.inertia.multiplyScalar(0.8);
      }
      this.prevSimPosition.copy(this.simPosition);
      this.simPosition.add(this.inertia).add(gravity);
      this.playerHasContact = collideRoom(this.simPosition, this.radius);
      this.el.object3D.position.copy(this.simPosition).add(window.vrCamera.object3D.position).sub(this.originalPosition);
      
      // Simulate ball collisions using Verlet integration.
      // This is a simple model, but it works fine for this demo.
      const physicsSteps = 10;
      const balls = window.balls;
      const ballDiameter = window.balls[0].radius * 2;
      
      // apply inertia and gravity to balls each frame
      for (const ball of balls) {
        // `inertia` is a reference to `ball.temp1`.
        const inertia = ball.temp1.subVectors(ball.position, ball.prevPosition);
        ball.prevPosition.copy(ball.position);
        inertia.multiplyScalar(damping);
        ball.position.add(inertia).add(gravity);
      }
      
      // iterative collision solver
      for (let i=0; i<physicsSteps; i++) {
        for (const ball of balls) {
          // room is a hard boundary
          collideRoom(ball.position, ball.radius);

          let hitCount = 0;
          for (const targetBall of balls) {
            if (ball === targetBall) continue;
            // `toPoint` is a reference to `ball.temp1`.
            const toPoint = ball.temp1.copy(ball.position).sub(targetBall.position);
            if (toPoint.length() < ballDiameter) {
              hitCount++;
              const midpoint = toPoint.multiplyScalar(0.5).add(targetBall.position);
              const tempPosition = ball.temp2.copy(ball.position);
              // accumulate collision related movement in `ball.delta` without applying it immediately
              ball.delta.add(constrainDistance(tempPosition, midpoint, ball.radius).sub(ball.position));
            }
          }
          // flag collision and average movement delta from all sources
          if (hitCount > 0) {
            ball.hasHits = true;
            ball.delta.divideScalar(hitCount);
          }
        }
        // apply the accumulated collision related movement to balls, and reset for next frame
        for (const ball of balls) {
          if (!ball.hasHits) continue;
          ball.hasHits = false;
          ball.position.add(ball.delta);
          ball.delta.x = 0;
          ball.delta.y = 0;
          ball.delta.z = 0;
        }
      }
    }
  }
});
</script>



<a-scene>
  <a-assets>
    <img id="floor-tex" crossorigin="anonymous" src="https://assets.codepen.io/329180/wood_floor_basecolor.jpg">
    <img id="wall-tex" crossorigin="anonymous" src="https://assets.codepen.io/329180/wallpaper_artdeco_basecolor.jpg">
    <img id="ceiling-normal-tex" crossorigin="anonymous" src="https://assets.codepen.io/329180/ceiling_droptiles_normal_1.jpg">
  </a-assets>

  <a-sky color="#222"></a-sky>
  <a-entity id="camera-rig" camera-rig>
    <a-entity camera-reverse>
      <a-entity id="camera" camera orbit-controls="target: 0 2 0; initialPosition: 0 2.2 4; minDistance: 2;"></a-entity>
    </a-entity>
  </a-entity>
  <a-entity light="type: point; color: #FFE; intensity: 0.8; castShadow: true; shadowCameraFar: 10; shadowMapWidth: 1024; shadowMapHeight: 1024; shadowBias: -0.0001;" position="0 3.5 0"></a-entity>
  <a-entity light="type: hemisphere; color: #FFF; groundColor: #7B6250; intensity: 0.15"></a-entity>

  <a-plane position="0 0 0" width="8" height="8" rotation="-90 0 0" material="src: #floor-tex; repeat: 4 4" shadow="receive: true"></a-plane>
  <a-plane position="0 5 0" width="8" height="8" rotation="90 0 0" material="normalMap: #ceiling-normal-tex; normalTextureRepeat: 2.5 2.5" shadow="receive: true"></a-plane>
  <a-plane position="0 2.5 -4" width="8" height="5" rotation="0 0 0" material="src: #wall-tex; repeat: 4 2.5" shadow="receive: true"></a-plane>
  <a-plane position="0 2.5 4" width="8" height="5" rotation="0 180 0" material="src: #wall-tex; repeat: 4 2.5" shadow="receive: true"></a-plane>
  <a-plane position="-4 2.5 0" width="8" height="5" rotation="0 90 0" material="src: #wall-tex; repeat: 4 2.5" shadow="receive: true"></a-plane>
  <a-plane position="4 2.5 0" width="8" height="5" rotation="0 -90 0" material="src: #wall-tex; repeat: 4 2.5" shadow="receive: true"></a-plane>

  <a-entity id="controller" vive-controls="hand: right; model: false" oculus-touch-controls="hand: right; model: false"></a-entity>
</a-scene>



<script>
  const scene = document.querySelector('a-scene');
  window.vrCamera = document.getElementById('camera');
  window.vrCameraRig = document.getElementById('camera-rig');
  window.vrController = document.getElementById('controller');
  
  window.isRotating = false;
  window.rotationStart = new THREE.Euler();
  window.controllerRotationStart = new THREE.Euler();
  window.vrController.addEventListener('triggerdown', () => {
    window.isRotating = true;
    window.rotationStart.copy(window.vrCameraRig.object3D.rotation);
    window.controllerRotationStart.copy(window.vrController.object3D.rotation);
  });
  window.vrController.addEventListener('triggerup', () => {
    window.isRotating = false;
  });
  
  window.balls = [];
  for (let i=0; i<ballCount; i++) {
    const color = ballColors[i % ballColors.length];
    const el = document.createElement('a-icosahedron');
    const radius = 0.1;
    // const radius = 0.25;
    el.setAttribute('color', color);
    el.setAttribute('radius', String(radius));
    el.setAttribute('detail', '2');
    el.setAttribute('roughness', '0.38');
    el.setAttribute('shadow', 'cast: true; receive: true;');
    scene.appendChild(el);
    el.object3D.position.y = radius;
    el.object3D.position.x = Math.random() * 6 - 3;
    el.object3D.position.z = Math.random() * 6 - 3;
    window.balls.push({
      radius: radius,
      hasHits: false,
      delta: new THREE.Vector3(),
      temp1: new THREE.Vector3(),
      temp2: new THREE.Vector3(),
      position: el.object3D.position,
      prevPosition: el.object3D.position.clone()
    });
  }

  scene.addEventListener('enter-vr', () => {
    flatscreenDemo = false;
  });
  
  window.simReady = true;
</script>

<div class="tip">Change the direction of gravity by rotating your right controller while holding the trigger.</div>
.tip {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 1;
  pointer-events: none;
  padding: 12px;
  padding-bottom: 16px;
  font-family: system-ui, sans-serif;
  font-size: clamp(0.75rem, 3.5vw, 1.25rem); 
  text-align: center;
  color: #fff;
  text-shadow: 0 0 4px #000, 0 0 20px #000;
  background-color: rgb(50 0 20 / 0.3);
}

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.