Creating an interactive, real-time 3D experience in the browser has never been easier thanks to Three.js. In this article, we’ll dive into building a complete third-person bow & arrow aiming system — including smooth aiming, bone attachments, character animation, and projectile physics — all running fluidly on the web.
Overview
This project demonstrates:
- A third-person camera that follows the player
- Dynamic aiming using raycasting and plane intersection
- Character animation blending for idle, draw, and shoot
- Arrow projectile simulation (with optional gravity)
- Performance optimization through vector reuse
Attaching the Bow to the Character
- Our character (a Mixamo rigged GLB) already includes hand bones.
- To attach the bow properly, we find the left-hand bone and compensate for its scale.
const leftHand = character.getObjectByName("mixamorigLeftHand");
bow.position.set(0, 0, 0);
bow.rotation.set(0, Math.PI / 2, 0);
bow.scale.setScalar(1 / leftHand.getWorldScale(tmp).x);
leftHand.add(bow);
Smooth Aiming with Raycasting
To make aiming feel natural, we project a ray from the camera through the mouse cursor and find where it intersects a plane in front of the character.
That intersection point becomes our target aim point.
const raycaster = new THREE.Raycaster();
const plane = new THREE.Plane();
const aimPoint = new THREE.Vector3();
const tmpVec = new THREE.Vector3();
function updateAim(mouseNDC, camera, anchor) {
  const camDir = camera.getWorldDirection(tmpVec);
  const planePoint = anchor.getWorldPosition(new THREE.Vector3()).addScaledVector(camDir, 10);
  plane.setFromNormalAndCoplanarPoint(camDir, planePoint);
  raycaster.setFromCamera(mouseNDC, camera);
  const hit = new THREE.Vector3();
  if (raycaster.ray.intersectPlane(plane, hit)) {
    aimPoint.lerp(hit, 0.1); // smooth motion
  }
}
This gives you smooth, camera-relative aiming that feels consistent at any distance.
Shooting Arrows
When the player clicks, we spawn a new arrow mesh at the bow’s position and give it a velocity toward the aim point.
const arrows = [];
const gravity = -9.8;
function shootArrow(origin, target) {
  const dir = target.clone().sub(origin).normalize();
  const arrow = new THREE.Mesh(
    new THREE.CylinderGeometry(0.02, 0.02, 1),
    new THREE.MeshStandardMaterial({ color: 0x663300 })
  );
  arrow.position.copy(origin);
  arrow.rotation.x = Math.PI / 2;
  arrow.vel = dir.multiplyScalar(40);
  arrow.life = 3;
  arrows.push(arrow);
  scene.add(arrow);
}
Each frame, we update arrow positions and optionally apply gravity:
for (const a of arrows) {
  a.vel.y += gravity * delta;
  a.position.addScaledVector(a.vel, delta);
  a.lookAt(a.position.clone().add(a.vel));
  a.life -= delta;
  if (a.life <= 0) {
    scene.remove(a);
  }
}
Animation Blending
Use a THREE.AnimationMixer to blend between idle, draw, and shoot animations.
function playAnimation(name, fade = 0.25) {
  const newAction = mixer.clipAction(animations[name]);
  newAction.reset().fadeIn(fade).play();
  if (activeAction) activeAction.fadeOut(fade);
  activeAction = newAction;
}
When the player clicks:
- mousedown→ play draw animation
- mouseup→ play shoot animation + spawn arrow
window.addEventListener('mousedown', () => playAnimation('Draw'));
window.addEventListener('mouseup', () => {
  playAnimation('Shoot');
  shootArrow(bow.getWorldPosition(new THREE.Vector3()), aimPoint);
});
Result
After putting everything together, you get a responsive, cinematic aiming experience:
- Move the character around
- Hold mouse to draw
- Release to shoot
- Arrows fly realistically and disappear after a few seconds
All of this runs fully in the browser with no external physics engine required.