import * as THREE from 'three/webgpu'; import { BVHHelper } from 'three-mesh-bvh'; import { firstHitOnly, indexForRaycasts } from '../../src/bvh'; import { Panel, Readouts, canvasSphere, createStage, debugArrow, fatal, orientY, studioLights } from './kit'; /** * Demo — "one pointer event becomes a surface sample". * * Everything the painter hands to a mode comes out of this single raycast: the hit point, * the interpolated face normal, and the tangent frame we build from it. The demo draws that * frame live, and lets you switch the BVH off to watch the pick cost jump. */ const stage = await createStage({ cameraPos: [0.2, 0.9, 3.6], environment: true, orbit: true, }).catch((err) => { fatal(err); return null; }); if (stage) { const { scene, camera, renderer } = stage; studioLights(scene); // Deliberately dense: ~34k triangles, so "walk the BVH" vs "test every triangle" is a // difference you can read off the panel instead of taking on faith. const sphere = canvasSphere(1, 160); scene.add(sphere); indexForRaycasts(sphere); const boundsTree = (sphere.geometry as unknown as { boundsTree: unknown }).boundsTree; const triangles = (sphere.geometry.getIndex()?.count ?? sphere.geometry.getAttribute('position').count) / 3; const bvhHelper = new BVHHelper(sphere, 10); bvhHelper.visible = false; scene.add(bvhHelper); // ---------- the frame gizmo ---------- const gizmo = new THREE.Group(); gizmo.visible = false; scene.add(gizmo); const normalArrow = debugArrow(0xc9a4ff, 0.55); // n — the surface normal const t1Arrow = debugArrow(0x5ad6ff, 0.4); // t1 — first tangent const t2Arrow = debugArrow(0xffc46a, 0.4); // t2 — n × t1 const ring = new THREE.Mesh( new THREE.RingGeometry(0.16, 0.185, 48), new THREE.MeshBasicMaterial({ color: 0xc9a4ff, side: THREE.DoubleSide, depthTest: false, toneMapped: false }), ); ring.renderOrder = 20; const plane = new THREE.Mesh( new THREE.CircleGeometry(0.17, 48), new THREE.MeshBasicMaterial({ color: 0x8a5cff, side: THREE.DoubleSide, transparent: true, opacity: 0.18, depthTest: false, toneMapped: false, }), ); plane.renderOrder = 19; gizmo.add(normalArrow, t1Arrow, t2Arrow, ring, plane); // ---------- picking ---------- const raycaster = firstHitOnly(new THREE.Raycaster()); const pointer = new THREE.Vector2(); const t1 = new THREE.Vector3(); const t2 = new THREE.Vector3(); let autoTour = true; let useBvh = true; let costEma = 0; renderer.domElement.addEventListener('pointermove', (e) => { const rect = renderer.domElement.getBoundingClientRect(); pointer.set( ((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1, ); if (autoTour) setAuto(false); }); function pick(): THREE.Intersection | null { raycaster.setFromCamera(pointer, camera); // One raycast is ~microseconds; average ten so the readout doesn't flicker. const t0 = performance.now(); let hits: THREE.Intersection[] = []; for (let i = 0; i < 10; i++) hits = raycaster.intersectObject(sphere, false); costEma = costEma * 0.9 + ((performance.now() - t0) / 10) * 0.1; return hits.find((h) => h.face) ?? null; } // ---------- panel ---------- const ui = new Panel('Picking'); const setAuto = ui.check({ label: 'Auto tour', value: true, onChange: (v) => { autoTour = v; }, }); ui.check({ label: 'Use the BVH', value: true, onChange: (v) => { useBvh = v; // three-mesh-bvh's patched raycast falls back to the stock one when no tree is present. (sphere.geometry as unknown as { boundsTree: unknown }).boundsTree = v ? boundsTree : null; if (!v) bvhHelper.visible = false; readBvh(v ? 'walking the tree' : 'brute force', v ? 'good' : 'bad'); }, }); ui.check({ label: 'Show BVH boxes', onChange: (v) => { bvhHelper.visible = v && useBvh; }, }); ui.slider({ label: 'BVH depth', value: 10, min: 1, max: 20, step: 1, format: (v) => String(v), onChange: (v) => { bvhHelper.depth = v; bvhHelper.update(); }, }); ui.note( 'The frame is n (violet), t1 (blue) and t2 = n × t1 (amber). ' + 'Every mode plants its geometry in that frame.', ); const out = new Readouts(); const readTris = out.add('Sphere triangles', triangles.toLocaleString()); const readBvh = out.add('Raycast strategy', 'walking the tree', 'good'); const readCost = out.add('Cost per pick', '—', 'hi'); const readHit = out.add('Hit point', '—'); const readNormal = out.add('Normal', '—'); readTris(triangles.toLocaleString()); // ---------- frame ---------- let frame = 0; stage.onFrame((_dt, time) => { if (autoTour) { // Keep the synthetic pointer inside the sphere's silhouette whatever the aspect is. const dist = camera.position.length(); const maxY = (1 / dist) / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * 0.62; pointer.set( (maxY / camera.aspect) * Math.sin(time * 0.63), maxY * Math.sin(time * 0.41 + 1.1), ); } const hit = pick(); if (!hit?.face) { gizmo.visible = false; return; } gizmo.visible = true; const n = hit.face.normal.clone().transformDirection(sphere.matrixWorld); // The same two lines every mode runs: pick any axis that isn't parallel to n, then // cross twice to get an orthonormal basis lying in the tangent plane. t1.set(1, 0, 0); if (Math.abs(n.x) > 0.9) t1.set(0, 1, 0); t1.cross(n).normalize(); t2.crossVectors(n, t1); gizmo.position.copy(hit.point); orientY(normalArrow, n); orientY(t1Arrow, t1); orientY(t2Arrow, t2); ring.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), n); plane.quaternion.copy(ring.quaternion); ring.position.copy(n).multiplyScalar(0.002); plane.position.copy(ring.position); if (++frame % 3 === 0) { const v = (a: THREE.Vector3): string => `${a.x.toFixed(2)}, ${a.y.toFixed(2)}, ${a.z.toFixed(2)}`; readCost(`${(costEma * 1000).toFixed(1)} µs`); readHit(v(hit.point)); readNormal(v(n)); } }); }