Touch and Pointer Events

Pointer Events give mouse, pen and touch one model, so the code above works with a finger. But the browser claims touch drags for scrolling (ending yours with pointercancel) unless the canvas has touch-action: none, several fingers each bring a pointerId, and nothing hovers. A pinch scales the zoom by two pointers' spread:

Pinch-to-zoom from two synthetic touch pointersJavaScript
const el = document.body, down = new Map();              // pointerId -> x
el.style.touchAction = 'none';
let zoom = 1, start = null;
const spread = () => Math.abs(Math.max(...down.values()) - Math.min(...down.values()));
el.addEventListener('pointerdown', (e) => {
  down.set(e.pointerId, e.clientX);
  if (down.size === 2) start = { spread: spread(), zoom };
});
el.addEventListener('pointermove', (e) => {
  if (!down.has(e.pointerId)) return;
  down.set(e.pointerId, e.clientX);
  if (down.size === 2) zoom = start.zoom * spread() / start.spread;   // for camera.zoom
});
el.addEventListener('pointerup', (e) => down.delete(e.pointerId));   // and on pointercancel
const touch = (type, pointerId, clientX) =>
  el.dispatchEvent(new PointerEvent(type, { pointerId, pointerType: 'touch', clientX }));
touch('pointerdown', 11, 300); touch('pointerdown', 12, 340);   // 40 px apart
touch('pointermove', 11, 280); touch('pointermove', 12, 360);   // 80 px apart
console.log('zoom', zoom);
Output
zoom 2

Real code measures the 2D distance, Math.hypot(dx, dy), and calls camera.updateProjectionMatrix() after setting camera.zoom. OrbitControls (Adding OrbitControls) already does one-finger orbit and two-finger dolly.

A two-finger pinch sets camera.zoom, tracked by pointerId with touch-action: noneHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; }
  #stage { position: relative; width: 600px; max-width: 100%; }
  canvas { display: block; max-width: 100%; touch-action: none; }  /* else the browser scrolls instead */
  .finger { position: absolute; width: 26px; height: 26px; margin: -13px; border-radius: 50%; background: rgb(224 154 16 / 0.6); pointer-events: none; }
  p { margin: 4px 8px; }
</style>
<script type="importmap">
{ "imports": {
  "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
  "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<div id="stage"></div>
<p id="out">Pinch with two fingers (or use the mouse wheel).</p>
<script type="module">
import * as THREE from 'three';

const stage = document.getElementById('stage');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 260);
stage.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(35, 600 / 260, 0.1, 50);
camera.position.set(0, 0.3, 3.4);
camera.lookAt(0, -0.05, 0);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1.2));
const sun = new THREE.DirectionalLight('#ffffff', 2.5);
sun.position.set(2, 3, 4);
scene.add(sun);
const plank = new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.06, 0.4), new THREE.MeshStandardMaterial({ color: '#8d6e63' }));
plank.position.y = -0.52;
scene.add(plank);
['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'].forEach((color, i) => {
  const book = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.98, 0.12), new THREE.MeshStandardMaterial({ color }));
  book.position.x = (i - 2.5) * 0.5;
  scene.add(book);
});

const el = renderer.domElement, down = new Map();       // pointerId -> {x, y, dot}
let start = null;
const spread = () => {
  const [a, b] = [...down.values()];
  return Math.hypot(a.x - b.x, a.y - b.y);
};
function setZoom(zoom) {
  camera.zoom = THREE.MathUtils.clamp(zoom, 0.5, 4);
  camera.updateProjectionMatrix();                      // needed after changing zoom
  document.getElementById('out').textContent = `${down.size} pointer(s) down · zoom ${camera.zoom.toFixed(2)}`;
  renderer.render(scene, camera);
}
el.addEventListener('pointerdown', (e) => {
  const dot = stage.appendChild(Object.assign(document.createElement('div'), { className: 'finger' }));
  down.set(e.pointerId, { x: e.offsetX, y: e.offsetY, dot });
  if (down.size === 2) start = { spread: spread(), zoom: camera.zoom };
  moveDot(e);
});
el.addEventListener('pointermove', (e) => {
  if (!down.has(e.pointerId)) return;
  Object.assign(down.get(e.pointerId), { x: e.offsetX, y: e.offsetY });
  moveDot(e);
  if (down.size === 2) setZoom(start.zoom * spread() / start.spread);
});
const lift = (e) => { down.get(e.pointerId)?.dot.remove(); down.delete(e.pointerId); start = null; };
el.addEventListener('pointerup', lift);
el.addEventListener('pointercancel', lift);             // the browser took the gesture over
el.addEventListener('wheel', (e) => { e.preventDefault(); setZoom(camera.zoom * (e.deltaY < 0 ? 1.1 : 0.9)); }, { passive: false });
function moveDot(e) {
  const p = down.get(e.pointerId);
  p.dot.style.left = `${p.x}px`;  p.dot.style.top = `${p.y}px`;
}

// Simulate a pinch: two touches 60 px apart spread to 180 px, leaving them down for the picture
const touch = (type, pointerId, x) => el.dispatchEvent(new PointerEvent(type,
  { pointerId, pointerType: 'touch', clientX: el.getBoundingClientRect().left + x, clientY: el.getBoundingClientRect().top + 130 }));
setZoom(1);
touch('pointerdown', 11, 270);  touch('pointerdown', 12, 330);
for (let d = 0; d <= 60; d += 10) { touch('pointermove', 11, 270 - d);  touch('pointermove', 12, 330 + d); }
</script>