Di 13 Lights objek kita udah kena cahaya, tapi masih kerasa melayang aneh. Yang kurang itu bayangan.
Sebenarnya objek kita udah punya bayangan kok, yaitu bagian belakang objek yang gelap karena gak kena cahaya. Itu namanya core shadow, dan itu otomatis dari lampu.
Yang belum ada itu drop shadow, yaitu bayangan yang jatuh dari satu objek ke objek lain. Bola yang jatuhin bayangan ke lantai, itu drop shadow.
Bayangan itu selalu jadi tantangan berat di render 3D real-time, dan developer harus cari akal biar bayangannya kelihatan realistis tapi frame rate-nya gak anjlok. Cara implementasinya banyak, dan ThreeJS punya solusi bawaan. Tapi hati-hati: solusi bawaan ini praktis, tapi jauh dari sempurna.
Gimana Cara Kerjanya
Kita gak bakal bedah sampai dalem, tapi paham dasarnya itu penting banget biar gak bingung nanti.
Tiap kali kita nge-render satu frame, ThreeJS diam-diam ngerender sekali lagi buat tiap lampu yang boleh bikin bayangan. Render tambahan itu mensimulasikan apa yang dilihat lampu, seolah-olah lampu itu adalah kamera.
Selama render lampu itu, semua material di scene diganti sementara sama MeshDepthMaterial. Ingat kan, material itu mewarnai objek putih kalau dekat dan hitam kalau jauh? Nah, itu persis yang dibutuhin: peta jarak.
Hasilnya disimpan sebagai texture yang namanya shadow map.
Kita gak bisa lihat shadow map itu secara langsung, tapi dia dipakai di tiap material yang boleh nerima bayangan, terus diproyeksikan ke permukaan geometry-nya.
Sederhananya: lampu ngintip dulu dari posisinya, nyatet apa aja yang kehalang, terus hasil catetannya ditempel ke lantai sebagai bayangan. Yang gak kelihatan sama lampu, berarti kena bayangan.
Contoh bagusnya bisa dilihat di halaman contoh shadow map viewer, di situ kelihatan apa yang “dilihat” sama lampu directional dan spotlight.
Cara Nyalain Bayangan
Ada tiga tempat yang harus dinyalain, dan kalau satu aja kelewat, bayangannya gak bakal muncul dan kamu bakal bingung nyari salahnya.
1. Nyalain di Renderer
const renderer = new THREE.WebGLRenderer({
canvas: canvas
})
renderer.shadowMap.enabled = true2. Nyalain di Tiap Objek
Tiap objek harus kita tentuin: dia boleh bikin bayangan (castShadow) atau boleh nerima bayangan (receiveShadow), atau dua-duanya.
Bola kita jatuhin bayangan, lantai kita yang nerima:
sphere.castShadow = true
// ...
plane.receiveShadow = trueNyalain di sesedikit mungkin objek, karena tiap objek yang ikut main itu nambah beban.
3. Nyalain di Lampunya
directionalLight.castShadow = trueDan ini penting: cuma tiga jenis lampu yang dukung bayangan, dari enam jenis yang kita pelajari di 13 Lights:
- PointLight
- DirectionalLight
- SpotLight
Jadi AmbientLight, HemisphereLight, dan RectAreaLight gak bisa bikin bayangan sama sekali, mau diapain juga.
❌ percuma, lampu jenis ini gak dukung bayangan
const ambientLight = new THREE.AmbientLight(0xffffff, 1)
ambientLight.castShadow = true✅ benar, pakai lampu yang emang dukung
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5)
directionalLight.castShadow = trueSekarang bayangan bolanya muncul di lantai. Tapi jelek banget kan? Kotak-kotak dan pinggirannya patah-patah. Ayo kita perbaiki.
Optimasi Shadow Map
1. Ukuran Render
Ingat, shadow map itu sebenarnya hasil render juga. Dan namanya render, ya ada resolusinya. Kita bisa akses lewat properti shadow:
directionalLight.castShadow = true
console.log(directionalLight.shadow)Bawaannya cuma 512x512 demi performa, makanya pecah. Kita naikin, tapi ingat aturan pangkat 2 yang kita pelajari di 09 Textures > 2. Ukuran (*Size*), karena ini juga butuh mipmapping:
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5)
directionalLight.castShadow = true
directionalLight.shadow.mapSize.width = 1024
directionalLight.shadow.mapSize.height = 1024Bayangannya langsung jauh lebih rapi.
2. Near dan Far
Karena ThreeJS pakai kamera buat ngerender shadow map, berarti kamera itu punya properti yang sama kaya kamera biasa, termasuk near dan far yang kita bahas di 05 Camera > 3. Jarak Terdekat dan Terjauh.
Ngatur ini gak bikin bayangannya lebih tajam, tapi memperbaiki bug di mana bayangannya gak muncul sama sekali atau tiba-tiba kepotong.
Biar kita bisa lihat kameranya, kita pakai CameraHelper, konsepnya sama kaya helper-helper di 13 Lights > Helpers:
const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(directionalLightCameraHelper)Sekarang near dan far-nya kelihatan sebagai garis. Tapi hati-hati sama urutannya: helper itu motret nilai kamera saat dia dibuat, jadi kalau nilainya diubah setelah helper dibikin, helper-nya nunjukkin nilai yang lama dan kamu bakal salah baca:
❌ salah, helper-nya nampilin nilai lama, bikin bingung
const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(directionalLightCameraHelper)
directionalLight.shadow.camera.near = 1
directionalLight.shadow.camera.far = 6✅ benar, atur dulu nilainya, baru bikin helper-nya
directionalLight.shadow.camera.near = 1
directionalLight.shadow.camera.far = 6
const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(directionalLightCameraHelper)3. Amplitudo
Lewat helper tadi kelihatan kalau jangkauan kameranya kelebaran, jauh lebih luas dari yang kita butuhin.
Karena ini DirectionalLight, ThreeJS pakai OrthographicCamera di baliknya. Nah, ingat kamera ortografis? Dia gak punya field of view, gantinya kita yang nentuin batas atas, kanan, bawah, dan kiri. Persis itu yang kita kecilin:
directionalLight.shadow.camera.top = 2
directionalLight.shadow.camera.right = 2
directionalLight.shadow.camera.bottom = - 2
directionalLight.shadow.camera.left = - 2Logikanya gini: resolusi shadow map-nya tetap 1024x1024, tapi kalau area yang dipotret kelewat luas, resolusi itu kepakai buat motret ruang kosong. Kalau areanya dipersempit pas ke objek kita, resolusi yang sama jadi jauh lebih padat, alias bayangannya lebih tajam.
Makin kecil nilainya, makin presisi bayangannya. Tapi kalau kekecilan, bayangannya kepotong.
Kalau udah pas, helper-nya bisa disembunyiin:
directionalLightCameraHelper.visible = false4. Blur
Ketajaman pinggiran bayangan diatur pakai properti radius:
directionalLight.shadow.radius = 10Tapi perlu dicatat: teknik ini gak mikirin jarak objek ke lantai sama sekali. Di dunia nyata, bayangan itu makin kabur kalau objeknya makin jauh dari lantai. Yang ini nggak, dia cuma ngeblur rata semuanya dengan cara yang murah.
5. Algoritma Shadow Map
Ada beberapa algoritma yang bisa dipakai buat ngolah shadow map:
THREE.BasicShadowMap, paling ngebut tapi kualitasnya jelek banget.THREE.PCFShadowMap, lebih berat tapi pinggirannya lebih halus. Ini yang jadi bawaannya.THREE.PCFSoftShadowMap, lebih berat lagi tapi pinggirannya paling lembut.THREE.VSMShadowMap, berat, banyak batasannya, dan hasilnya kadang aneh gak ketebak.
Gantinya lewat properti renderer.shadowMap.type:
const renderer = new THREE.WebGLRenderer({
canvas: canvas
})
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMapTapi awas jebakan: properti radius tadi gak jalan sama sekali kalau kamu pakai THREE.PCFSoftShadowMap. Jadi kamu harus milih salah satu, gak bisa dua-duanya.
❌ sia-sia, radius-nya diabaikan total sama algoritma ini
renderer.shadowMap.type = THREE.PCFSoftShadowMap
directionalLight.shadow.radius = 10✅ pilih salah satu: blur manual…
renderer.shadowMap.type = THREE.PCFShadowMap
directionalLight.shadow.radius = 10✅ …atau pinggiran lembut bawaan algoritmanya
renderer.shadowMap.type = THREE.PCFSoftShadowMapSpotLight
Sekarang kita coba pasang SpotLight kaya di 13 Lights > SpotLight dan nyalain castShadow-nya. Jangan lupa target-nya dimasukin ke scene, jebakan yang udah kita bahas dulu:
// Spot light
const spotLight = new THREE.SpotLight(0xffffff, 3.6, 10, Math.PI * 0.3)
spotLight.castShadow = true
spotLight.position.set(0, 2, 2)
scene.add(spotLight)
scene.add(spotLight.target)
const spotLightCameraHelper = new THREE.CameraHelper(spotLight.shadow.camera)
scene.add(spotLightCameraHelper)Kalau scene-nya jadi kesilauan, kecilin lampu yang lain:
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.3)Perhatiin, bayangan dari dua lampu itu gak nyatu dengan mulus. Masing-masing diurus sendiri-sendiri, dan sayangnya gak banyak yang bisa kita lakuin soal ini. Ini salah satu bukti kalau sistem bayangan bawaan ThreeJS itu jauh dari sempurna.
Kualitasnya juga bisa dinaikin sama kaya tadi:
spotLight.shadow.mapSize.width = 1024
spotLight.shadow.mapSize.height = 1024Bedanya: karena ini SpotLight, ThreeJS pakai PerspectiveCamera di baliknya, bukan ortografis. Jadi gak ada top, right, bottom, left di sini. fov-nya juga gak bisa kita ubah, karena bakal ditimpa sama nilai angle milik spotlight-nya sendiri.
Yang bisa diatur cuma near dan far:
spotLight.shadow.camera.near = 1
spotLight.shadow.camera.far = 6Kalau udah pas, sembunyiin helper-nya:
spotLightCameraHelper.visible = falsePointLight
Terakhir, lampu ketiga yang dukung bayangan:
// Point light
const pointLight = new THREE.PointLight(0xffffff, 2.7)
pointLight.castShadow = true
pointLight.position.set(- 1, 1, 0)
scene.add(pointLight)
const pointLightCameraHelper = new THREE.CameraHelper(pointLight.shadow.camera)
scene.add(pointLightCameraHelper)Kecilin lagi lampu yang lain kalau kesilauan:
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.1)
const spotLight = new THREE.SpotLight(0xffffff, 2.4, 10, Math.PI * 0.3)Lho, kok helper-nya PerspectiveCamera tapi ngadep ke bawah? Padahal lampunya nyinarin ke segala arah.
Jawabannya bikin merinding kalau mikirin performanya: karena PointLight nyinarin ke segala arah, ThreeJS harus ngerender keenam arahnya (depan, belakang, kiri, kanan, atas, bawah) buat bikin shadow map berbentuk kubus. Konsepnya sama kaya Cube Camera yang kita singgung di 05 Camera dulu. Helper yang kelihatan itu cuma posisi kamera di render terakhir dari enam render itu, yang kebetulan menghadap ke bawah.
Artinya: satu PointLight yang bikin bayangan itu biayanya enam kali render. Jadi jangan kebanyakan pasang PointLight dengan bayangan nyala, nanti frame rate-nya ambruk.
Yang bisa diatur cuma mapSize, near, dan far:
pointLight.shadow.mapSize.width = 1024
pointLight.shadow.mapSize.height = 1024
pointLight.shadow.camera.near = 0.1
pointLight.shadow.camera.far = 5Sembunyiin helper-nya kalau udah:
pointLightCameraHelper.visible = falseBaking Shadow
Bayangan bawaan ThreeJS itu berguna banget kalau scene-nya sederhana. Kalau udah rame, hasilnya berantakan dan berat.
Alternatifnya: baked shadow. Ini konsep yang persis sama kaya baking cahaya yang kita bahas kemarin, cuma sekarang yang dipanggang ke dalam texture itu bayangannya.
Daripada ngomentarin semua baris kode bayangan satu-satu, kita matiin aja dari renderer-nya:
renderer.shadowMap.enabled = falseTerus kita load texture bayangannya pakai TextureLoader biasa. Karena ini dipakai sebagai map, colorSpace-nya di-set sRGB:
/**
* Textures
*/
const textureLoader = new THREE.TextureLoader()
const bakedShadow = textureLoader.load('/textures/bakedShadow.jpg')
bakedShadow.colorSpace = THREE.SRGBColorSpaceTerakhir, lantainya gak usah pakai MeshStandardMaterial lagi, cukup MeshBasicMaterial dengan gambar bayangan itu sebagai map. Wong bayangannya udah nempel di gambarnya, ngapain lagi diitung sama cahaya:
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(5, 5),
new THREE.MeshBasicMaterial({
map: bakedShadow
})
)Hasilnya bayangan kabur yang realistis dan enteng banget. Masalahnya cuma satu tapi besar: bayangannya mati. Kalau bolanya gerak atau lampunya digeser, bayangannya diam aja di situ, karena dia cuma gambar yang ditempel.
Alternatif: Bayangan Palsu yang Bisa Gerak
Ada jalan tengah yang kurang realistis tapi bisa gerak: kita taruh bayangan sederhana di bawah bola, sedikit di atas lantai, terus bayangan itu kita gerakin ngikutin bolanya.
Texture-nya cuma lingkaran gradasi putih di tengah dan hitam di pinggir. Bagian putih bakal kelihatan, bagian hitam bakal hilang, sesuai cara kerja alphaMap.
Pertama, balikin lantainya ke material biasa:
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(5, 5),
material
)Terus load texture bayangan sederhananya:
const simpleShadow = textureLoader.load('/textures/simpleShadow.jpg')Sekarang kita bikin bayangannya: satu bidang datar yang diputar rebah, materialnya hitam, dengan texture tadi sebagai alphaMap. Jangan lupa transparent: true, jebakan lama dari 10 Materials > 4. Opacity:
const sphereShadow = new THREE.Mesh(
new THREE.PlaneGeometry(1.5, 1.5),
new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
alphaMap: simpleShadow
})
)
sphereShadow.rotation.x = - Math.PI * 0.5
sphereShadow.position.y = plane.position.y + 0.01
scene.add(sphere, sphereShadow, plane)Perhatiin + 0.01 itu, jangan dianggap remeh. Kalau bayangannya ditaruh di ketinggian yang sama persis sama lantainya, GPU bakal bingung mana yang di depan mana yang di belakang, terus muncul kedip-kedip aneh. Itu z-fighting yang udah kita bahas di 05 Camera > 3. Jarak Terdekat dan Terjauh dulu.
❌ salah, ketinggiannya sama persis, muncul z-fighting
sphereShadow.position.y = plane.position.y✅ benar, dinaikin sedikit biar GPU-nya gak bingung
sphereShadow.position.y = plane.position.y + 0.01Nah, sekarang bagian serunya. Kalau bolanya kita animasiin, bayangannya tinggal diikutin, dan opacity-nya diubah sesuai ketinggian bola. Makin tinggi bolanya, makin pudar bayangannya, persis kaya di dunia nyata:
const clock = new THREE.Clock()
const tick = () =>
{
const elapsedTime = clock.getElapsedTime()
// Update the sphere
sphere.position.x = Math.cos(elapsedTime) * 1.5
sphere.position.z = Math.sin(elapsedTime) * 1.5
sphere.position.y = Math.abs(Math.sin(elapsedTime * 3))
// Update the shadow
sphereShadow.position.x = sphere.position.x
sphereShadow.position.z = sphere.position.z
sphereShadow.material.opacity = (1 - sphere.position.y) * 0.3
// ...
}
tick()Bedah sedikit rumusnya:
Math.cosdanMath.sinyang dipasangkan bikin gerakan melingkar, karena satu naik-turun duluan dan satunya belakangan. Dikali1.5biar radiusnya lebar.Math.abs(Math.sin(...))bikin gerakan memantul.Math.sinitu naik-turun dari-1ke1, terusMath.absbikin semua nilai minus jadi plus, jadi kaya bola yang mantul di lantai dan gak pernah nembus ke bawah.(1 - sphere.position.y) * 0.3bikin bayangannya pudar waktu bolanya tinggi. Waktu bolanya nempel lantai (y=0), opacity-nya0.3alias paling pekat. Waktu bolanya di puncak (y=1), opacity-nya0alias hilang.
Gak realistis, tapi performanya enteng banget dan buat banyak project itu udah lebih dari cukup.
Teknik Mana yang Dipakai?
Gak ada jawaban tunggal. Tergantung project-nya, tergantung performa yang kamu kejar, dan tergantung teknik apa yang kamu kuasai.
Dan yang penting: ketiganya bisa dicampur. Misalnya bayangan lantai yang gak gerak dipanggang aja jadi texture, terus objek yang gerak dikasih bayangan palsu yang ngikutin. Hasilnya bagus dan tetap enteng.
Code Lengkap
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import GUI from 'lil-gui'
/**
* Debug
*/
const gui = new GUI()
/**
* Base
*/
// Canvas
const canvas = document.querySelector('canvas.webgl')
// Scene
const scene = new THREE.Scene()
// Sizes
const sizes = {
width: window.innerWidth,
height: window.innerHeight
}
/**
* Textures
*/
const textureLoader = new THREE.TextureLoader()
const simpleShadow = textureLoader.load('/textures/simpleShadow.jpg')
/**
* Lights
*/
// Ambient light
const ambientLight = new THREE.AmbientLight(0xffffff, 1)
scene.add(ambientLight)
// Directional light
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5)
directionalLight.position.set(2, 2, - 1)
directionalLight.castShadow = true
directionalLight.shadow.mapSize.width = 1024
directionalLight.shadow.mapSize.height = 1024
directionalLight.shadow.camera.near = 1
directionalLight.shadow.camera.far = 6
directionalLight.shadow.camera.top = 2
directionalLight.shadow.camera.right = 2
directionalLight.shadow.camera.bottom = - 2
directionalLight.shadow.camera.left = - 2
scene.add(directionalLight)
const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
directionalLightCameraHelper.visible = false
scene.add(directionalLightCameraHelper)
/**
* Materials
*/
const material = new THREE.MeshStandardMaterial()
material.roughness = 0.7
/**
* Objects
*/
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(0.5, 32, 32),
material
)
sphere.castShadow = true
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(5, 5),
material
)
plane.rotation.x = - Math.PI * 0.5
plane.position.y = - 0.5
plane.receiveShadow = true
const sphereShadow = new THREE.Mesh(
new THREE.PlaneGeometry(1.5, 1.5),
new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
alphaMap: simpleShadow
})
)
sphereShadow.rotation.x = - Math.PI * 0.5
sphereShadow.position.y = plane.position.y + 0.01
scene.add(sphere, sphereShadow, plane)
/**
* Camera
*/
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100)
camera.position.x = 1
camera.position.y = 1
camera.position.z = 2
scene.add(camera)
// Controls
const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true
/**
* Renderer
*/
const renderer = new THREE.WebGLRenderer({
canvas: canvas
})
renderer.setSize(sizes.width, sizes.height)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
/**
* Animate
*/
const clock = new THREE.Clock()
const tick = () =>
{
const elapsedTime = clock.getElapsedTime()
// Update the sphere
sphere.position.x = Math.cos(elapsedTime) * 1.5
sphere.position.z = Math.sin(elapsedTime) * 1.5
sphere.position.y = Math.abs(Math.sin(elapsedTime * 3))
// Update the shadow
sphereShadow.position.x = sphere.position.x
sphereShadow.position.z = sphere.position.z
sphereShadow.material.opacity = (1 - sphere.position.y) * 0.3
// Update controls
controls.update()
// Render
renderer.render(scene, camera)
// Call tick again on the next frame
window.requestAnimationFrame(tick)
}
tick()Ringkasan
- Bagian gelap di belakang objek itu core shadow dan itu otomatis dari lampu. Yang kita kejar di bab ini drop shadow, yaitu bayangan yang jatuh ke objek lain.
- ThreeJS bikin bayangan dengan ngerender ulang scene dari sudut pandang tiap lampu, semua materialnya diganti sementara jadi
MeshDepthMaterial, hasilnya disimpan sebagai texture bernama shadow map. - Ada 3 tempat yang harus dinyalain:
renderer.shadowMap.enabled,castShadow/receiveShadowdi objek, dancastShadowdi lampu. Kelewat satu aja, bayangannya gak muncul. - Cuma
PointLight,DirectionalLight, danSpotLightyang dukung bayangan.AmbientLight,HemisphereLight, danRectAreaLightgak bisa sama sekali. - Resolusi bawaan shadow map cuma
512x512, makanya pecah. Naikin lewatshadow.mapSize, dan wajib angka pangkat 2. shadow.cameraitu kamera beneran, punyaneardanfar. Ngaturnya gak bikin lebih tajam, tapi memperbaiki bayangan yang hilang atau kepotong.CameraHelpermotret nilai kamera saat dia dibuat, jadi atur dulunear/far-nya, baru bikin helper-nya.DirectionalLightpakaiOrthographicCameradi baliknya, jadi amplitudonya diatur pakaitop,right,bottom,left. Makin sempit areanya, makin tajam bayangannya, tapi kalau kesempitan bayangannya kepotong.shadow.radiusitu blur murah yang gak mikirin jarak objek ke lantai, beda sama bayangan di dunia nyata.- Ada 4 algoritma shadow map, bawaannya
THREE.PCFShadowMap.radiusgak jalan diTHREE.PCFSoftShadowMap, jadi harus pilih salah satu. SpotLightpakaiPerspectiveCameradi baliknya, danfov-nya gak bisa diubah karena ditimpaanglemilik spotlight-nya.PointLightitu paling mahal, karena harus ngerender 6 arah buat bikin shadow map berbentuk kubus. Jangan kebanyakan.- Bayangan dari lampu yang berbeda gak nyatu dengan mulus, diurus sendiri-sendiri, dan gak banyak yang bisa kita lakuin soal itu.
- Baked shadow itu bayangan yang dipanggang ke dalam texture. Enteng dan realistis, tapi mati total, gak ikut gerak.
- Bayangan palsu pakai
alphaMapdi bidang datar itu jalan tengahnya: kurang realistis tapi bisa ngikutin objeknya dan opacity-nya bisa berubah sesuai ketinggian. - Naikin bayangan palsu sedikit di atas lantai (
+ 0.01) biar gak kena z-fighting. Math.cos+Math.sinbikin gerakan melingkar, danMath.abs(Math.sin(...))bikin gerakan memantul.- Gak ada teknik yang paling benar, dan ketiganya boleh dicampur sesuai kebutuhan project.
Bab selanjutnya kita gabungin semua ilmu dari bab-bab sebelumnya jadi satu project utuh: rumah berhantu lengkap dengan kuburan, kabut, dan hantu yang melayang-layang.