Three.js Interaktif — Creative Web

Three.js Interaktif Tidak seperti HTML yang punya event handler bawaan, di 3D kamu harus mendeteksi interaksi sendiri menggunakan Raycaster — "sinar" virtual…

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);
});

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;
}

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
}

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

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

Yang akan kamu pelajari