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:
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.
<!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>