Di HTML, tiap tombol punya event-nya sendiri. Di 3D gak begitu: canvas cuma satu gambar besar, dan browser cuma tahu "ada klik di titik (x, y) canvas". Produk mana yang ada di titik itu harus kamu cari sendiri. Bayangin pembeli menunjuk kaca etalase toko Raka: tarik garis lurus dari mata pembeli lewat ujung jarinya di kaca, dan produk pertama yang tertembus garis itu adalah yang dia maksud. Garis itu disebut ray (sinar), dan alat three.js untuk menembakkannya adalah Raycaster.
Di lesson ini etalase 3D Raka berisi Mug Sablon Kopi dan kotak Kaos Sablon Kopi: kamera bisa diputar, produk menyala saat ditunjuk, dan klik mengganti warna sablonnya.
🧪 Tebakan cepat: canvas 320 × 240 Raka ada di tengah halaman. Kodenya menghitung
pointer.x = (e.clientX / window.innerWidth) * 2 - 1. Pointer tepat di atas mug. Mugnya menyala? Tebak dulu, jawabannya ada di bagian "Pointer jadi NDC".
OrbitControls: memutar kamera
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; // r186
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // meluncur halus setelah dilepas
controls.enableZoom = false; // roda mouse tetap men-scroll halaman
renderer.setAnimationLoop(() => {
controls.update(); // wajib kalau enableDamping = true
renderer.render(scene, camera);
});
- Geser dengan tombol kiri: kamera mengelilingi
controls.target(bawaannya (0, 0, 0)). Tombol kanan atau Ctrl/Shift + geser: menggeser (pan). Roda mouse: maju-mundur (zoom). - Besar putarannya 2π × jarak geser / tinggi canvas: di canvas setinggi 240 px, geser 240 px = satu putaran penuh.
- Dengan
enableDamping, tiapupdate()cuma menjalankan 5% (dampingFactor0,05) sisa putaran, sisanya menyusul di frame berikutnya. Tanpacontrols.update()di loop, sisa itu menggantung: kamera tertinggal dari jari dan berhenti mendadak. enableZoom = falseuntuk produk yang menempel di artikel: OrbitControls menangkap roda mouse, jadi pembaca yang mau scroll malah men-zoom.- OrbitControls memasang
touch-action: nonedi canvas. Di HP, geseran yang mulai di canvas gak pernah men-scroll halaman. Canvas setinggi layar bisa menjebak jempol: buat lebih pendek, atau pakaitouch-action: pan-ysupaya geser naik-turun tetap men-scroll (geser kiri-kanan tetap memutar).
Pointer jadi NDC
Raycaster butuh posisi pointer dalam NDC (Normalized Device Coordinates): -1 di kiri sampai 1 di kanan, -1 di bawah sampai 1 di atas, (0, 0) di tengah canvas, berapa pun ukurannya.
const pointer = new THREE.Vector2();
const r = renderer.domElement.getBoundingClientRect(); // posisi dan ukuran canvas di halaman
pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1;
Contoh: canvas di left 40, top 100, ukuran 320 × 240; pointer di (120, 160). x: (120 - 40) / 320 = 0,25, kali 2 kurang 1 = -0,5. y: (160 - 100) / 240 = 0,25, kali 2 = 0,5, dibalik lalu ditambah 1 = 0,5. Y dibalik karena di halaman y bertambah ke bawah, di NDC ke atas.
Rumus dengan window.innerWidth cuma benar untuk canvas selebar jendela. Jendela Raka 1280 × 800, canvas-nya di left 480, top 280. Pointer di tengah mug (clientX 592, clientY 400): rumus yang benar memberi x = -0,3 (mug), rumus jendela memberi x = 592 / 1280 × 2 - 1 = -0,075, celah kosong di antara mug dan kaos. Gak ada yang menyala. Itu jawaban tebakannya.
Raycaster: sinar dari kamera
const raycaster = new THREE.Raycaster();
const produk = [mug, kaos];
function produkDi(e) {
// ...pointer.x dan pointer.y seperti di atas
raycaster.setFromCamera(pointer, camera);
const kena = raycaster.intersectObjects(produk)[0]; // yang terdekat
let benda = kena ? kena.object : null;
while (benda && !produk.includes(benda)) benda = benda.parent; // gagang naik ke mug
return benda;
}
setFromCameramembuat sinar dari posisi kamera lewat titik pointer.intersectObjectsmengujinya ke setiap segitiga dan mengembalikan array, terdekat dulu:distance,point(titik 3D yang kena),object,uv(posisi di tekstur),faceIndex. Kosong[]kalau gak kena apa-apa.- Bawaannya rekursif, jadi anak seperti gagang ikut dicek, dan
objectadalah mesh yang benar-benar kena. Di atas gagang,intersects[0].objectadalahgagang, bukanmug: naik lewatparent.
Hasil dari etalase (kamera di (0, 1,2, 5,2)):
| NDC | object | distance | uv |
|---|---|---|---|
| (-0,30, 0,00) | badan mug | 4,64 | (0,62, 0,60) |
| (0,50, 0,00) | kaos | 5,25 | (0,57, 0,55) |
| (0,00, -0,25) | gagang | 5,05 | (0,20, 0,17) |
| (-0,05, 0,05) | [] |
Hover dan klik
let disorot = null;
function sorot(benda) {
if (benda === disorot) return;
if (disorot) for (const m of disorot.userData.bahan) m.emissive.set('#000000');
if (benda) for (const m of benda.userData.bahan) m.emissive.set('#333333');
renderer.domElement.style.cursor = benda ? 'pointer' : 'grab';
disorot = benda;
}
renderer.domElement.addEventListener('pointermove', (e) => sorot(produkDi(e)));
let tekan = null;
renderer.domElement.addEventListener('pointerdown', (e) => { tekan = [e.clientX, e.clientY]; });
renderer.domElement.addEventListener('click', (e) => {
if (!tekan || Math.hypot(e.clientX - tekan[0], e.clientY - tekan[1]) > 4) return; // itu geser
const benda = produkDi(e);
if (benda) gantiWarna(benda);
});
function gantiWarna(benda) {
const s = benda.userData.sablon; // { kanvas, tekstur, ke }
s.ke = (s.ke + 1) % WARNA.length; // cokelat #c47a3d, hijau #7fb069
gambarSablon(s.kanvas, WARNA[s.ke]);
s.tekstur.needsUpdate = true; // kirim ulang canvas ke GPU
}
emissiveadalah cahaya yang dipancarkan material sendiri;#333333membuatnya sedikit lebih terang.userData.bahanberisi material milik produk itu saja: material yang dipakai bersama dua produk akan ikut menyala di keduanya.clicktetap terpanggil setelah orang memutar kamera, jadi cek jarak daripointerdown: lebih dari 4 px berarti geser.- Sablon digambar ulang di canvas-nya, lalu
needsUpdate = true. Tanpa itu GPU masih memakai gambar lama.
Alternatif keyboard
Semua yang bisa dilakukan pointer harus bisa dengan keyboard. controls.listenToKeyEvents(window) cuma memberi panah untuk pan, dan Shift + panah memutar 2π / 240 ≈ 1,5° per tekan: terlalu kecil. Lebih jelas: tombol biasa (otomatis bisa di-Tab dan Enter).
const SUMBU_Y = new THREE.Vector3(0, 1, 0);
function putarKamera(sudut) {
camera.position.sub(controls.target).applyAxisAngle(SUMBU_Y, sudut).add(controls.target);
controls.update();
}
tombolKiri.addEventListener('click', () => putarKamera(-Math.PI / 12)); // 15°
tombolWarnaMug.addEventListener('click', () => gantiWarna(mug));
Di React Three Fiber (lesson berikutnya), cukup <mesh onClick={...} onPointerOver={...}>: R3F yang menjalankan raycasting untukmu, tetap dengan Raycaster yang sama di baliknya.
⚠️ Jebakan yang sering ketemu
- NDC dari
window.innerWidthuntuk canvas kecil: hover dan klik meleset. PakaigetBoundingClientRect(). kena.object === mug: gagang (anak) gak pernah cocok. Naik lewatparent.enableDampingtanpacontrols.update()di loop: kamera tertinggal dan berhenti mendadak.- Klik setelah memutar kamera: warna ikut berganti. Bedakan klik dan geser.
- Lupa
tekstur.needsUpdate = true: canvas sudah berubah, mug tetap warna lama.
TL;DR: Canvas gak tahu benda apa yang diklik; Raycaster yang mencarinya. Ubah pointer ke NDC relatif ke canvas (getBoundingClientRect, y dibalik), raycaster.setFromCamera(pointer, camera), lalu intersectObjects(produk): array terdekat dulu (object, distance, point, uv), anak ikut dicek, naik lewat parent. Hover: emissive + kursor; klik (bukan geser): gambar ulang sablon lalu needsUpdate. OrbitControls dari 'three/addons/controls/OrbitControls.js': damping butuh controls.update() tiap frame, enableZoom = false di artikel, dan ia memasang touch-action: none. Sediakan tombol keyboard untuk semua aksi pointer. Di R3F, onClick dan onPointerOver langsung di <mesh>.