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…

Three.js Interaktif

Tidak seperti HTML yang punya event handler bawaan, di 3D kamu harus mendeteksi interaksi sendiri menggunakan Raycaster — "sinar" virtual yang ditembakkan dari kamera menembus layar.

Raycaster — Menembak Sinar ke Scene

Raycaster mendeteksi objek 3D mana yang "tertembak" oleh sinar dari posisi mouse:

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

window.addEventListener("mousemove", (e) => {
  // Konversi posisi mouse ke koordinat normalized (-1 sampai 1)
  mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
});

Membongkar rumus (e.clientX / window.innerWidth) * 2 - 1:

  1. e.clientX / window.innerWidth — posisi mouse dalam rentang 0..1 (0 = pojok kiri, 1 = pojok kanan).
  2. * 2 — skala jadi rentang 0..2.
  3. - 1 — geser jadi rentang -1..1 (yang disebut Normalized Device Coordinates / NDC).

Kenapa harus NDC? Karena WebGL (dan Three.js di atasnya) bekerja di ruang terstandarisasi di mana layar selalu dipetakan ke rentang -1..1 — independen dari resolusi. Raycaster butuh NDC supaya tahu arah sinar yang benar dari kamera.

Sumbu Y di-flip (-(e.clientY / window.innerHeight) * 2 + 1) karena di browser Y=0 di atas, sedangkan di WebGL Y=0 di tengah dan nilai positif ke atas.

Jadi: pojok kiri-bawah = (-1, -1), tengah = (0, 0), pojok kanan-atas = (1, 1).

Hati-hati canvas yang tidak full-screen: kalau canvas kamu berukuran kecil (bukan window.innerWidth), gunakan canvas.getBoundingClientRect() untuk dapat ukuran & offset yang benar, jangan pakai window.innerWidth.

Deteksi Hover pada Objek 3D

const interactiveObjects = [cube, sphere, torus];
let hoveredObject = null;

function checkIntersection() {
  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(interactiveObjects);

  // Reset semua objek
  interactiveObjects.forEach(obj => {
    obj.material.emissive.setHex(0x000000);
  });

  if (intersects.length > 0) {
    hoveredObject = intersects[0].object;
    hoveredObject.material.emissive.setHex(0x333333); // highlight
    document.body.style.cursor = "pointer";
  } else {
    hoveredObject = null;
    document.body.style.cursor = "default";
  }
}

function animate() {
  requestAnimationFrame(animate);
  checkIntersection();
  renderer.render(scene, camera);
}
animate();

intersectObjects() mengembalikan array objek yang ditembak sinar, diurutkan dari yang terdekat. intersects[0] adalah objek pertama yang terkena.

Klik pada Objek 3D

window.addEventListener("click", () => {
  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(interactiveObjects);

  if (intersects.length > 0) {
    const clicked = intersects[0].object;
    console.log("Objek diklik:", clicked.name);

    // Contoh: ubah warna random saat diklik
    clicked.material.color.setHex(Math.random() * 0xffffff);

    // Contoh: animasi dengan GSAP
    gsap.to(clicked.scale, {
      x: 1.5, y: 1.5, z: 1.5,
      duration: 0.2,
      yoyo: true,
      repeat: 1
    });
  }
});

Informasi dari Intersection

Object intersection memberikan banyak data berguna:

const intersects = raycaster.intersectObjects(interactiveObjects);
if (intersects.length > 0) {
  const hit = intersects[0];

  console.log(hit.object);     // objek yang terkena
  console.log(hit.point);      // titik 3D di mana sinar mengenai objek
  console.log(hit.distance);   // jarak dari kamera
  console.log(hit.face);       // face (segitiga) yang terkena
  console.log(hit.faceIndex);  // index face
  console.log(hit.uv);         // koordinat UV di titik hit
}

Object Selection — Pilih dan Sorot Objek

let selectedObject = null;
const outlineMaterial = new THREE.MeshBasicMaterial({
  color: 0xffff00,
  side: THREE.BackSide
});

window.addEventListener("click", () => {
  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(interactiveObjects);

  // Hapus outline dari selection sebelumnya
  if (selectedObject && selectedObject.userData.outline) {
    selectedObject.remove(selectedObject.userData.outline);
    selectedObject.userData.outline = null;
  }

  if (intersects.length > 0) {
    selectedObject = intersects[0].object;

    // Buat outline
    const outline = new THREE.Mesh(
      selectedObject.geometry.clone(),
      outlineMaterial
    );
    outline.scale.setScalar(1.05);
    selectedObject.add(outline);
    selectedObject.userData.outline = outline;
  } else {
    selectedObject = null;
  }
});

Drag & Drop 3D

let isDragging = false;
let dragObject = null;
const dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // bidang horizontal
const intersection = new THREE.Vector3();

window.addEventListener("mousedown", (e) => {
  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(interactiveObjects);

  if (intersects.length > 0) {
    isDragging = true;
    dragObject = intersects[0].object;
    controls.enabled = false; // nonaktifkan orbit controls saat drag
  }
});

window.addEventListener("mousemove", () => {
  if (!isDragging || !dragObject) return;

  raycaster.setFromCamera(mouse, camera);
  if (raycaster.ray.intersectPlane(dragPlane, intersection)) {
    dragObject.position.x = intersection.x;
    dragObject.position.z = intersection.z;
  }
});

window.addEventListener("mouseup", () => {
  isDragging = false;
  dragObject = null;
  controls.enabled = true;
});

Hover Effect dengan Animasi

function checkIntersection() {
  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(interactiveObjects);

  interactiveObjects.forEach(obj => {
    if (!obj.userData.originalScale) {
      obj.userData.originalScale = obj.scale.clone();
    }
  });

  if (intersects.length > 0) {
    const hovered = intersects[0].object;
    gsap.to(hovered.scale, {
      x: 1.2, y: 1.2, z: 1.2,
      duration: 0.3
    });
    document.body.style.cursor = "pointer";
  }

  interactiveObjects.forEach(obj => {
    if (!intersects.find(i => i.object === obj)) {
      gsap.to(obj.scale, {
        x: 1, y: 1, z: 1,
        duration: 0.3
      });
    }
  });
}

Interaktivitas 3D membuka pintu untuk pengalaman web yang imersif — product configurator, virtual showroom, atau portfolio interaktif!