Ini saatnya semua ilmu dari bab-bab sebelumnya dipakai bareng-bareng dalam satu project beneran: rumah berhantu.
Aturan mainnya: kita gak boleh pakai model 3D jadi sama sekali, semuanya harus dari bentuk-bentuk dasar yang udah kita kenal di 07 Geometry > Geometry Bawaan ThreeJS. Ini emang ada batasannya, tapi menguasai bentuk-bentuk dasar sampai mentok itu penting banget, karena nanti pas kamu udah bisa impor model pun, bentuk dasar tetap kepakai terus.
Texture-nya kita ambil dari Poly Haven, dan kamu yang milih sendiri semuanya. Di jalan nanti kita bakal ketemu trik-trik biar scene-nya makin hidup: kabut, langit realistis, bayangan, displacement map, dan lain-lain.
Setup
Starter-nya udah berisi bola placeholder dengan MeshStandardMaterial, satu DirectionalLight, satu AmbientLight, OrbitControls, dan waktu yang berjalan di fungsi tick pakai class Timer.
Timer, Pengganti Clock
Nah ini yang baru. Selama ini kita pakai Clock, sekarang kita kenalan sama Timer. Mirip banget, tapi ada 4 bedanya:
- Memperbaiki bug lama
Clockdi mana nilainya jadi kacau kalaugetElapsedTime()dipanggil lebih dari sekali dalam satu frame. - Harus di-update manual pakai
timer.update(). - Dia ngecek kalau tab-nya lagi gak aktif, jadi gak muncul lonjakan waktu aneh pas kita balik ke tab-nya.
- Harus di-import sendiri, kaya
OrbitControlsdanFontLoaderdulu.
import { Timer } from 'three/addons/misc/Timer.js'Cara pakainya di dalam tick:
const timer = new Timer()
const tick = () =>
{
// Timer
timer.update()
const elapsedTime = timer.getElapsed()
// ...
}❌ salah, Timer gak jalan sendiri kaya Clock
const elapsedTime = timer.getElapsed()✅ benar, di-update dulu tiap frame
timer.update()
const elapsedTime = timer.getElapsed()Tips Soal Satuan Ukuran
Ini kesalahan pemula yang hampir semua orang lakuin: naruh angka asal-asalan tanpa patokan.
Satu unit di ThreeJS itu artinya terserah kamu. Kalau kamu lagi bikin lanskap luas buat diterbangin, mungkin 1 unit = 1 kilometer. Kalau bikin rumah, 1 unit = 1 meter. Kalau bikin game kelereng, 1 unit = 1 sentimeter.
Punya patokan itu ngebantu banget waktu bikin geometry. Misalnya kita mau bikin pintu. Kita tau pintu itu sedikit lebih tinggi dari kita, jadi sekitar 2 meter. Nah, dengan patokan 1 unit = 1 meter, kita langsung tau angkanya harus sekitar 2, bukan 20 atau 0.2.
Di project ini, 1 unit = 1 meter.
Lantai
Sebelum bola placeholder-nya kita hapus, kita bikin lantainya dulu.
Lantainya bidang datar persegi yang posisinya pas di tengah scene. Dengan begitu, 0 di sumbu y bisa kita anggap sebagai permukaan tanah, dan semua komponen rumah tinggal ditaruh relatif ke situ:
// Floor
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.MeshStandardMaterial()
)
floor.rotation.x = - Math.PI * 0.5
scene.add(floor)Ada tiga hal yang perlu dibahas dari kode sesingkat itu:
- Geometry dan material-nya ditulis langsung di dalam
Mesh, gak dipisah jadi variabel sendiri. Kodenya jadi lebih pendek, dan kalau nanti butuh, tetap bisa diakses lewatfloor.geometrydanfloor.material. - Ukurannya
20 x 20, cukup buat nampung rumah dan kuburan. Tapi kotak segede ini bakal kelihatan jelek kalau pinggirannya keliatan. Tenang, nanti kita akalin biar pinggirannya memudar. - Pakai
MeshStandardMaterialkarena kita ngejar tampilan realistis. Texture yang bakal kita pakai itu texture PBR, dan itu jodohnya sama material ini.
Soal rotasinya, - Math.PI * 0.5 itu seperempat putaran berlawanan arah jarum jam, biar bidangnya rebah jadi lantai. Sekarang bola placeholder-nya boleh dihapus.
Rumah
1. Group
Daripada semua bagian rumah dimasukin langsung ke scene, kita bikin Group dulu, konsepnya sama kaya waktu kita bikin grup. Gunanya biar kalau nanti mau geser atau perbesar seluruh rumah, tinggal geser grup-nya sekali:
// House container
const house = new THREE.Group()
scene.add(house)Mulai sekarang, semua bagian rumah dimasukin ke house, bukan ke scene. Ini jebakan yang bakal sering bikin kamu bengong kenapa objeknya gak muncul.
2. Dinding
Rumahnya kecil aja. Karena 1 unit = 1 meter, ukurannya 4 x 2.5 x 4:
// Walls
const walls = new THREE.Mesh(
new THREE.BoxGeometry(4, 2.5, 4),
new THREE.MeshStandardMaterial()
)
walls.position.y += 1.25
house.add(walls)Kenapa harus dinaikin 1.25? Karena titik asal (origin) sebuah geometry itu ada di tengahnya, bukan di bawahnya. Jadi kalau ditaruh di y = 0, setengah rumahnya kekubur di dalam tanah. 1.25 itu setengah dari tinggi 2.5.
Sebenarnya praktik yang lebih bagus itu naruh angka-angka ini di variabel terpisah biar gampang diubah nanti. Tapi buat latihan ini kita tulis langsung aja biar gak kepanjangan.
3. Atap
Buat atap, kita mau bentuk limas segi empat. Coba cari di dokumentasi ThreeJS, ada gak geometry namanya “pyramid”?
Gak ada. Tapi ada akalnya: pakai ConeGeometry tapi jumlah sisinya dibikin cuma 4. Kerucut dengan 4 sisi itu ya limas segi empat.
Tiga parameter pertama ConeGeometry itu radius, tinggi, dan radialSegments (jumlah sisi sampingnya):
// Roof
const roof = new THREE.Mesh(
new THREE.ConeGeometry(3.5, 1.5, 4),
new THREE.MeshStandardMaterial()
)
roof.position.y = 2.5 + 0.75
roof.rotation.y = Math.PI * 0.25
house.add(roof)Kenapa 2.5 + 0.75? 2.5 itu tinggi dindingnya, dan 0.75 itu setengah dari tinggi atapnya, lagi-lagi karena origin kerucutnya ada di tengah. Sengaja ditulis penjumlahan biar kelihatan asal-usul angkanya, bukan langsung ditulis 3.25 yang bikin bingung kalau dibaca lagi bulan depan.
Rotasinya Math.PI * 0.25 alias 1/8 lingkaran, biar sudut limasnya lurus sama sudut dindingnya.
4. Pintu
Kedengeran aneh, tapi pintunya cukup pakai bidang datar persegi. Alasannya: kita bakal pakai texture pintu cantik yang udah kita kenal di 09 Textures:
// Door
const door = new THREE.Mesh(
new THREE.PlaneGeometry(2.2, 2.2),
new THREE.MeshStandardMaterial()
)
door.position.y = 1
door.position.z = 2
house.add(door)Masalahnya, warna pintunya sama persis sama warna dinding, jadi kita gak bisa lihat apa-apa. Kamu mungkin mikir “ah nanti juga kelihatan pas dikasih texture”. Justru itu bahayanya, karena ada bug yang gak bakal kita sadari.
Jadi kita warnain merah dulu sementara:
const door = new THREE.Mesh(
new THREE.PlaneGeometry(2.2, 2.2),
new THREE.MeshStandardMaterial({ color: 'red' })
)Nah kan, muncul kedip-kedip aneh. Itu z-fighting yang udah kita kenal dari 05 Camera > 3. Jarak Terdekat dan Terjauh dan 14 Shadows > Alternatif: Bayangan Palsu yang Bisa Gerak: dua permukaan ada di posisi yang sama persis secara matematis, dan GPU bingung mana yang di depan.
Solusinya konyol tapi manjur: geser dikit.
❌ salah, pintunya nempel persis di dinding, muncul z-fighting
door.position.z = 2✅ benar, digeser 1 sentimeter ke depan
door.position.z = 2 + 0.01Habis itu warna merahnya dihapus lagi.
5. Semak
Kita taruh dua semak di kanan-kiri pintu pakai bola sederhana. Ini kesempatan bagus buat nerapin optimasi yang udah kita pelajari di 11 3D Text > Optimasi: 4 semak, 4 mesh, tapi cuma 1 geometry dan 1 material:
// Bushes
const bushGeometry = new THREE.SphereGeometry(1, 16, 16)
const bushMaterial = new THREE.MeshStandardMaterial()
const bush1 = new THREE.Mesh(bushGeometry, bushMaterial)
bush1.scale.set(0.5, 0.5, 0.5)
bush1.position.set(0.8, 0.2, 2.2)
const bush2 = new THREE.Mesh(bushGeometry, bushMaterial)
bush2.scale.set(0.25, 0.25, 0.25)
bush2.position.set(1.4, 0.1, 2.1)
const bush3 = new THREE.Mesh(bushGeometry, bushMaterial)
bush3.scale.set(0.4, 0.4, 0.4)
bush3.position.set(- 0.8, 0.1, 2.2)
const bush4 = new THREE.Mesh(bushGeometry, bushMaterial)
bush4.scale.set(0.15, 0.15, 0.15)
bush4.position.set(- 1, 0.05, 2.6)
house.add(bush1, bush2, bush3, bush4)Perhatiin house.add(bush1, bush2, bush3, bush4), satu panggilan buat empat objek sekaligus.
Kuburan
Kita mau naruh banyak kuburan melingkari rumah, tapi jangan sampai nembus dindingnya.
Sama kaya semak, satu geometry dan satu material buat semua. Dan kita bungkus dalam Group juga, biar rapi:
// Graves
const graveGeometry = new THREE.BoxGeometry(0.6, 0.8, 0.2)
const graveMaterial = new THREE.MeshStandardMaterial()
const graves = new THREE.Group()
scene.add(graves)Perhatiin graves dimasukin ke scene, bukan ke house, karena kuburan bukan bagian dari rumah.
Sekarang perulangan buat 30 kuburan:
for(let i = 0; i < 30; i++)
{
const grave = new THREE.Mesh(graveGeometry, graveMaterial)
graves.add(grave)
}Ke-30 kuburannya numpuk semua di dalam rumah. Sekarang kita sebar melingkar, dan di sinilah trigonometri jadi berguna banget.
1. Trigonometri buat Bikin Lingkaran
Prinsipnya cuma satu kalimat: kalau kamu ngirim sudut yang sama ke sinus dan kosinus, hasilnya adalah koordinat x dan y dari sebuah titik di lingkaran.
Sudutnya pakai radian. Math.PI itu setengah lingkaran, jadi buat satu lingkaran penuh kita kali 2:
for(let i = 0; i < 30; i++)
{
const angle = Math.random() * Math.PI * 2
const x = Math.sin(angle)
const z = Math.cos(angle)
const grave = new THREE.Mesh(graveGeometry, graveMaterial)
grave.position.x = x
grave.position.z = z
graves.add(grave)
}Kenapa yang kedua dinamain z bukan y? Karena di matematika 2D namanya emang x dan y, tapi di scene kita yang jadi bidang datarnya itu sumbu x dan z, sedangkan y itu ke atas.
2. Radius dan Keacakan
Lingkarannya masih kekecilan karena sinus dan kosinus itu selalu menghasilkan lingkaran berjari-jari 1. Tinggal dikali:
const radius = 4
const x = Math.sin(angle) * radius
const z = Math.cos(angle) * radiusTapi sekarang lingkarannya terlalu rapi, kaya barisan upacara. Kita acak radiusnya biar ada yang dekat dan ada yang jauh:
const radius = 3 + Math.random() * 4Artinya: minimal 3 (biar gak nabrak rumah) ditambah acak antara 0 sampai 4, jadi rentangnya 3 sampai 7. Angka 3 itu yang jadi penjaga jarak dari rumah.
3. Ketinggian dan Rotasi Acak
Kuburannya masih setengah kekubur dan arahnya lurus semua kaya belum pernah kena gempa. Kita acak:
grave.position.y = Math.random() * 0.4
grave.rotation.x = (Math.random() - 0.5) * 0.4
grave.rotation.y = (Math.random() - 0.5) * 0.4
grave.rotation.z = (Math.random() - 0.5) * 0.4Perhatiin bedanya position.y sama rotation:
position.ypakaiMath.random() * 0.4doang, karena kita cuma mau kuburannya naik, gak mau ada yang tenggelam ke bawah tanah.rotationpakai(Math.random() - 0.5), karena miringnya harus bisa ke kiri dan ke kanan. Trik- 0.5yang sama persis kaya di 07 Geometry > Bikin Segitiga Acak Sebanyak-banyaknya.
Dan * 0.4 di belakang rotasi itu buat ngeredam efeknya, karena tanpa itu kuburannya miring kelewat ekstrem sampai kaya abis kena badai.
Texturing
Mesh-nya udah siap. Sekarang bagian yang paling lama: texture.
Nyiapin texture itu proses panjang. Kamu harus nyari tempat yang bagus, mastiin kamu berhak makainya, download dengan format yang benar, terapin dengan cara yang beda-beda tergantung jenis map-nya, terus dioptimasi. Sabar aja, pasti bisa.
Kita pakai Poly Haven karena realistis, gampang di-download, gratis, dan lisensinya CC0 yang artinya kamu bebas ngapain aja. Kalau kamu suka kerjaan mereka, bisa dukung lewat Patreon mereka.
Pengaturan Download di Poly Haven
Sebelum pencet Download, ada beberapa hal yang harus diatur:
- Resolusi, ganti dari
4Kjadi1K. Kita gak butuh resolusi gila-gilaan. - Format paket, pilih
ZIP. - Isi ZIP-nya, ini yang paling penting.
Nama-nama pilihan di isi ZIP itu harusnya udah familiar dari 09 Textures > Apa itu Texture?:
- AO (ambient occlusion), mencegah cahaya ambient masuk ke celah-celah.
- Diffuse, warna aslinya.
- Displacement, menggeser vertices buat bikin permukaan timbul.
- Normal, memalsukan arah permukaan buat bikin detail. Ada versi DX dan GL, kita butuh yang GL.
- Rough, seberapa kasar atau halus permukaannya.
- Bump, mirip normal tapi hitam-putih. Gak kita butuhin.
- Metal, bagian mana yang logam. Ambil kalau ada.
Dan ada satu pilihan sakti: AO/Rough/Metal, alias ARM. Ini menggabungkan tiga map jadi satu file dengan cara nyimpen masing-masing di channel warna yang berbeda (merah, hijau, biru). Ini pilihan terbaik buat performa, karena makin sedikit file texture makin bagus.
Terus formatnya ada tiga pilihan:
- EXR, ukuran file gede banget dengan data maksimal.
- JPG, ukuran kecil tapi ada potensi cacat kompresi.
- PNG, ukuran sedang tanpa cacat kompresi.
Normalnya kita pakai JPG buat kebanyakan texture dan PNG buat normal map (biar nilai datanya gak geser, kaya yang dibahas di 09 Textures > 3. Data). Tapi karena scene kita ini kotor dan berlumut semua, cacat kompresinya gak bakal kelihatan, jadi JPG buat semuanya.
1. Lantai
Lantai ini justru yang paling ribet. Kalau lantai udah beres, sisanya gampang.
Alpha Map dulu
Ingat masalah “pinggiran lantai yang kelihatan” tadi? Sekarang kita beresin.
Di folder static/floor/ ada file alpha.jpg, isinya gradasi lingkaran putih di tengah dan hitam di pinggir. Sesuai cara kerja alphaMap, bagian putih bakal kelihatan dan bagian hitam bakal hilang. Jadi pinggiran lantainya bakal memudar mulus tanpa batas tegas:
/**
* Textures
*/
const textureLoader = new THREE.TextureLoader()
// Floor
const floorAlphaTexture = textureLoader.load('./floor/alpha.jpg')Ingat, satu TextureLoader cukup buat semua texture, dan jangan tulis static/ di path-nya.
Mulai sekarang semua variabel texture kita akhiri dengan ...Texture. Ini gak wajib dan bikin nama variabelnya panjang, tapi mencegah bentrok sama variabel lain di kode kita.
Pasang ke materialnya:
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.MeshStandardMaterial({
alphaMap: floorAlphaTexture,
transparent: true
})
)Bisa nebak kenapa transparent: true wajib ada? Karena kaya di 10 Materials > 4. Opacity, ThreeJS harus dikasih tau dulu kalau material ini main transparansi. Tanpa itu, alphaMap-nya diem aja gak ada efeknya.
Pasang Texture Lantainya
Pilih texture lantai di Poly Haven. Di contoh ini pakai Coast Sand Rock 02. Download, unzip, ganti nama foldernya sesuai nama file zip-nya, terus taruh di static/floor/:
const floorAlphaTexture = textureLoader.load('./floor/alpha.jpg')
const floorColorTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_diff_1k.jpg')
const floorARMTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_arm_1k.jpg')
const floorNormalTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_nor_gl_1k.jpg')
const floorDisplacementTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_disp_1k.jpg')Di sinilah kamu paling sering salah, apalagi karena Console gak selalu ngasih error kalau path-nya salah. Cek tab Console dan Network di DevTools buat mastiin semuanya kebaca.
Repeat dan Wrap
Pasang map-nya dulu, dan langsung ketauan texture-nya kegedean. Kita ulang-ulang pakai repeat yang udah kita pelajari di 09 Textures > 1. Repeat:
floorColorTexture.repeat.set(8, 8)
floorARMTexture.repeat.set(8, 8)
floorNormalTexture.repeat.set(8, 8)
floorDisplacementTexture.repeat.set(8, 8)Tapi malah muncul garis-garis aneh di pinggir, karena texture-nya belum dikasih izin buat mengulang, jadi piksel terakhirnya ketarik memanjang:
floorColorTexture.wrapS = THREE.RepeatWrapping
floorARMTexture.wrapS = THREE.RepeatWrapping
floorNormalTexture.wrapS = THREE.RepeatWrapping
floorDisplacementTexture.wrapS = THREE.RepeatWrapping
floorColorTexture.wrapT = THREE.RepeatWrapping
floorARMTexture.wrapT = THREE.RepeatWrapping
floorNormalTexture.wrapT = THREE.RepeatWrapping
floorDisplacementTexture.wrapT = THREE.RepeatWrappingTerus warnanya keabu-abuan, dan kita tau obatnya:
floorColorTexture.colorSpace = THREE.SRGBColorSpaceCuma texture warna yang dikasih sRGB. ARM, normal, dan displacement itu texture data, bukan warna. Kalau ikut di-sRGB-kan, nilainya geser dan hasilnya salah.
Pasang Semua Map-nya
Nah ini bagian keren dari file ARM: satu texture dipakai buat tiga properti sekaligus:
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20, 100, 100),
new THREE.MeshStandardMaterial({
alphaMap: floorAlphaTexture,
transparent: true,
map: floorColorTexture,
aoMap: floorARMTexture,
roughnessMap: floorARMTexture,
metalnessMap: floorARMTexture,
normalMap: floorNormalTexture,
displacementMap: floorDisplacementTexture,
displacementScale: 0.3,
displacementBias: - 0.2
})
)Perhatiin PlaneGeometry(20, 20, 100, 100), subdivisionnya dinaikin ke 100. Ini wajib, karena displacementMap menggeser vertices beneran, dan kalau vertices-nya cuma 4 ya gak ada yang bisa digeser. Aturan lama dari 10 Materials > 3. Properti Lainnya.
Tapi jangan tergoda naikin subdivisionnya lebih tinggi lagi. Terlalu banyak vertices itu jelek buat performa, apalagi dengan MeshStandardMaterial. Gantinya kita redam efeknya pakai displacementScale.
Terus kenapa ada displacementBias: - 0.2? Karena gambar displacement-nya gak cukup gelap, jadi semua vertices-nya naik, minimal sedikit, dan lantainya jadi kelihatan ngambang. displacementBias itu menggeser seluruh hasil displacement ke bawah.
Angka 0.3 dan - 0.2 itu gak jatuh dari langit, itu hasil coba-coba pakai Debug UI:
gui.add(floor.material, 'displacementScale').min(0).max(1).step(0.001).name('floorDisplacementScale')
gui.add(floor.material, 'displacementBias').min(-1).max(1).step(0.001).name('floorDisplacementBias')Ini contoh sempurna kenapa Debug UI itu penting: nyari dua angka ini pakai cara ngedit-simpan-refresh bisa makan waktu setengah jam, pakai slider cuma setengah menit.
2. Dinding
Kabar baik: dinding jauh lebih gampang. Pilih texture bata di Poly Haven (di sini pakai Castle Brick Broken 06), pengaturan download-nya sama, cuma Displacement-nya gak usah dicentang:
// Wall
const wallColorTexture = textureLoader.load('./wall/castle_brick_broken_06_1k/castle_brick_broken_06_diff_1k.jpg')
const wallARMTexture = textureLoader.load('./wall/castle_brick_broken_06_1k/castle_brick_broken_06_arm_1k.jpg')
const wallNormalTexture = textureLoader.load('./wall/castle_brick_broken_06_1k/castle_brick_broken_06_nor_gl_1k.jpg')
wallColorTexture.colorSpace = THREE.SRGBColorSpacePasang ke dindingnya:
const walls = new THREE.Mesh(
new THREE.BoxGeometry(4, 2.5, 4),
new THREE.MeshStandardMaterial({
map: wallColorTexture,
aoMap: wallARMTexture,
roughnessMap: wallARMTexture,
metalnessMap: wallARMTexture,
normalMap: wallNormalTexture
})
)Lihat betapa bagusnya hasilnya tanpa perlu ngatur apa-apa lagi. Kalau kamu pakai texture lain, mungkin perlu main repeat, wrapS, dan wrapT kaya di lantai tadi.
3. Atap
Prosesnya mirip dinding. Di sini pakai Roof Slates 02:
// Roof
const roofColorTexture = textureLoader.load('./roof/roof_slates_02_1k/roof_slates_02_diff_1k.jpg')
const roofARMTexture = textureLoader.load('./roof/roof_slates_02_1k/roof_slates_02_arm_1k.jpg')
const roofNormalTexture = textureLoader.load('./roof/roof_slates_02_1k/roof_slates_02_nor_gl_1k.jpg')
roofColorTexture.colorSpace = THREE.SRGBColorSpace
roofColorTexture.repeat.set(3, 1)
roofARMTexture.repeat.set(3, 1)
roofNormalTexture.repeat.set(3, 1)
roofColorTexture.wrapS = THREE.RepeatWrapping
roofARMTexture.wrapS = THREE.RepeatWrapping
roofNormalTexture.wrapS = THREE.RepeatWrappingPerhatiin cuma wrapS yang di-set, wrapT nggak. Kenapa? Karena repeat.y-nya 1, gak lebih. wrap cuma perlu diatur kalau nilai repeat-nya lebih dari 1.
Masalah yang Gak Ada Obatnya
Kalau kamu perhatiin, ada dua masalah di atap: texture-nya miring gak karuan, dan cahayanya kelakuan aneh, ada pantulan di tempat yang seharusnya gak ada.
Penyebabnya: cara ConeGeometry bikin attribute normals dan uv-nya emang gitu, dan gak ada perbaikan yang gampang. Jadi untuk sekarang ya kita biarin.
Tapi ada dua jalan keluar kalau kamu penasaran:
- Bikin model-nya di software 3D. Bikin limas di Blender itu gampang banget, dan ngatur UV-nya juga simpel.
- Bikin attribute geometry-nya sendiri, kaya yang kita latihan di 07 Geometry > Bikin Geometry Sendiri (BufferGeometry). Bikin satu segitiga tiap sisi dan dua segitiga buat alasnya, terus tentuin koordinat UV-nya. Buat normal-nya, kabar baik, ThreeJS bisa ngitungin sendiri pakai method
computeVertexNormals()yang ada diBufferGeometry.
4. Semak
Di sini pakai Leaves Forest Ground:
// Bush
const bushColorTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_diff_1k.jpg')
const bushARMTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_arm_1k.jpg')
const bushNormalTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_nor_gl_1k.jpg')
bushColorTexture.colorSpace = THREE.SRGBColorSpace
bushColorTexture.repeat.set(2, 1)
bushARMTexture.repeat.set(2, 1)
bushNormalTexture.repeat.set(2, 1)
bushColorTexture.wrapS = THREE.RepeatWrapping
bushARMTexture.wrapS = THREE.RepeatWrapping
bushNormalTexture.wrapS = THREE.RepeatWrappingPasang ke bushMaterial:
const bushMaterial = new THREE.MeshStandardMaterial({
color: '#ccffcc',
map: bushColorTexture,
aoMap: bushARMTexture,
roughnessMap: bushARMTexture,
metalnessMap: bushARMTexture,
normalMap: bushNormalTexture
})Ada dua trik di sini.
Pertama, kalau kamu lihat bagian atas semaknya, ada texture yang ketarik aneh di kutubnya, khas bola. Sama kaya atap, solusi bersihnya itu bikin geometry sendiri. Tapi ada solusi cepat: puter semaknya biar bagian jeleknya nyempil ke dinding:
bush1.rotation.x = - 0.75
bush2.rotation.x = - 0.75
bush3.rotation.x = - 0.75
bush4.rotation.x = - 0.75Kedua, properti color: '#ccffcc' itu hijau muda yang mewarnai texture-nya, biar semaknya kelihatan lebih ijo dan segar. Trik ini bisa dipakai di material manapun, prinsipnya sama kaya yang kita bahas di 10 Materials > 2. Color.
5. Kuburan
Sekarang kamu udah hapal polanya. Di sini pakai Plastered Stone Wall:
// Grave
const graveColorTexture = textureLoader.load('./grave/plastered_stone_wall_1k/plastered_stone_wall_diff_1k.jpg')
const graveARMTexture = textureLoader.load('./grave/plastered_stone_wall_1k/plastered_stone_wall_arm_1k.jpg')
const graveNormalTexture = textureLoader.load('./grave/plastered_stone_wall_1k/plastered_stone_wall_nor_gl_1k.jpg')
graveColorTexture.colorSpace = THREE.SRGBColorSpace
graveColorTexture.repeat.set(0.3, 0.4)
graveARMTexture.repeat.set(0.3, 0.4)
graveNormalTexture.repeat.set(0.3, 0.4)repeat-nya di bawah 1, artinya texture-nya malah dizoom, bukan diulang. Dan karena di bawah 1, wrapS dan wrapT gak perlu diatur sama sekali.
const graveMaterial = new THREE.MeshStandardMaterial({
map: graveColorTexture,
aoMap: graveARMTexture,
roughnessMap: graveARMTexture,
metalnessMap: graveARMTexture,
normalMap: graveNormalTexture
})6. Pintu
Terakhir pintu. Texture-nya udah tersedia di static/door/, bikinan Katsukagi dengan lisensi CC0. Yang ini map-nya kepisah semua, gak digabung jadi ARM:
// Door
const doorColorTexture = textureLoader.load('./door/color.jpg')
const doorAlphaTexture = textureLoader.load('./door/alpha.jpg')
const doorAmbientOcclusionTexture = textureLoader.load('./door/ambientOcclusion.jpg')
const doorHeightTexture = textureLoader.load('./door/height.jpg')
const doorNormalTexture = textureLoader.load('./door/normal.jpg')
const doorMetalnessTexture = textureLoader.load('./door/metalness.jpg')
const doorRoughnessTexture = textureLoader.load('./door/roughness.jpg')
doorColorTexture.colorSpace = THREE.SRGBColorSpacePasang semuanya, jangan lupa alphaMap dan transparent: true, dan subdivisionnya dinaikin ke 100 biar displacementMap-nya jalan:
const door = new THREE.Mesh(
new THREE.PlaneGeometry(2.2, 2.2, 100, 100),
new THREE.MeshStandardMaterial({
map: doorColorTexture,
transparent: true,
alphaMap: doorAlphaTexture,
aoMap: doorAmbientOcclusionTexture,
displacementMap: doorHeightTexture,
displacementScale: 0.15,
displacementBias: - 0.04,
normalMap: doorNormalTexture,
metalnessMap: doorMetalnessTexture,
roughnessMap: doorRoughnessTexture
})
)Pakai displacement buat pintu itu sebenarnya berlebihan, tapi pintunya bagus dan ini kesempatan bagus buat main-main.
Cahaya
Berkat texture-texture tadi, scene kita jadi detail dan realistis. Sekarang kita atur pencahayaannya biar makin nyeremin.
1. Ambient dan Directional
Sekarang dua lampu itu masih putih polos, dan itu gak realistis kecuali kita lagi di laboratorium. Kita ganti jadi biru kelabu ala malam hari:
const ambientLight = new THREE.AmbientLight('#86cdff', 0.275)
const directionalLight = new THREE.DirectionalLight('#86cdff', 1)Scene-nya jadi gelap banget, tapi tenang, sebentar lagi dibenerin.
AmbientLight yang diredupin itu sengaja, biar permukaan yang kena bayangan tetap bisa dinikmati, sekaligus mura-mura jadi pantulan cahaya dari DirectionalLight, persis alasan yang kita bahas di 13 Lights > AmbientLight.
2. Lampu Pintu
DirectionalLight-nya ada di belakang rumah, jadi bagian depan rumah (sisi paling penting) malah kegelapan. Kita pasang lampu di atas pintu.
Di dunia nyata lampu pintu itu bohlam, jadi kita pakai PointLight:
// Door light
const doorLight = new THREE.PointLight('#ff7d46', 5)
doorLight.position.set(0, 2.2, 2.5)
house.add(doorLight)Sekarang bagian depan rumah jadi pusat perhatian. Warna hangat lampunya bikin rumahnya kelihatan nyaman, dan itu kontras banget sama dinginnya lingkungan sekitar. Ini trik penceritaan yang halus tapi ngefek.
Hantu
Mungkin kamu ngebayangin hantu berupa seprei yang melambai-lambai kaya di kartun. Sayangnya di tahap ini kita belum bisa impor model, jadi kita pakai pendekatan minimalis: hantunya diwakili sama PointLight.
Tiga lampu dengan warna beda, yang bakal muterin rumah sambil naik-turun nembus lantai dengan irama yang beda-beda:
/**
* Ghosts
*/
const ghost1 = new THREE.PointLight('#8800ff', 6)
const ghost2 = new THREE.PointLight('#ff0088', 6)
const ghost3 = new THREE.PointLight('#ff0000', 6)
scene.add(ghost1, ghost2, ghost3)1. Gerakan Melingkar
Prinsipnya sama persis kaya waktu kita nyebar kuburan tadi, bedanya sekarang sudutnya berubah tiap frame, jadi ditulis di dalam tick:
const tick = () =>
{
timer.update()
const elapsedTime = timer.getElapsed()
// Ghosts
const ghost1Angle = elapsedTime * 0.5
ghost1.position.x = Math.cos(ghost1Angle) * 4
ghost1.position.z = Math.sin(ghost1Angle) * 4
// ...
}elapsedTime itu jumlah detik sejak scene mulai jalan, dikali 0.5 biar geraknya lebih pelan. Dikali 4 di belakang sin/cos itu radiusnya, karena tanpa itu lingkarannya cuma berjari-jari 1.
2. Gerakan Naik-Turun yang Gak Ketebak
Buat naik-turun, sinus itu pas banget. Tapi satu sinus doang polanya terlalu rapi, kaya mesin, gak kaya hantu.
Solusi gampangnya: kaliin beberapa sinus dengan frekuensi yang beda-beda:
ghost1.position.y = Math.sin(ghost1Angle) * Math.sin(ghost1Angle * 2.34) * Math.sin(ghost1Angle * 3.45)Kenapa jadi gak ketebak? Karena tiga gelombang dengan irama yang gak kelipatan satu sama lain itu butuh waktu lama banget buat balik ke pola yang sama. Angka 2.34 dan 3.45 itu sengaja dipilih angka ganjil yang gak bulat.
Kalau mau eksperimen nyari kombinasi frekuensi sendiri, pakai alat visualisasi rumus kaya Desmos Calculator, jauh lebih cepat daripada nebak-nebak di kode.
3. Dua Hantu Lainnya
Hantu kedua dan ketiga tinggal salin, terus dibedain tiga hal: radius, kecepatan, dan arahnya:
const ghost2Angle = - elapsedTime * 0.38
ghost2.position.x = Math.cos(ghost2Angle) * 5
ghost2.position.z = Math.sin(ghost2Angle) * 5
ghost2.position.y = Math.sin(ghost2Angle) * Math.sin(ghost2Angle * 2.34) * Math.sin(ghost2Angle * 3.45)
const ghost3Angle = elapsedTime * 0.23
ghost3.position.x = Math.cos(ghost3Angle) * 6
ghost3.position.z = Math.sin(ghost3Angle) * 6
ghost3.position.y = Math.sin(ghost3Angle) * Math.sin(ghost3Angle * 2.34) * Math.sin(ghost3Angle * 3.45)Tanda minus di depan elapsedTime milik hantu kedua itu yang bikin dia muter ke arah sebaliknya. Sederhana banget tapi ngefek, karena kalau ketiganya muter searah dengan kecepatan mirip, kelihatannya kaya komidi putar, bukan hantu.
Bayangan
Sekarang kita nyalain bayangan biar makin realistis.
Di 14 Shadows kita belajar kalau banyak lampu yang bikin bayangan itu sering hasilnya jelek. Tapi karena scene ini rumit dan detail, bayangan dari DirectionalLight dan ketiga hantu justru bikin hasilnya bagus.
Kali ini semua kode bayangan kita kumpulin di satu tempat, jangan disebar ke mana-mana:
/**
* Shadows
*/
// Renderer
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
// Cast and receive
directionalLight.castShadow = true
ghost1.castShadow = true
ghost2.castShadow = true
ghost3.castShadow = true
walls.castShadow = true
walls.receiveShadow = true
roof.castShadow = true
floor.receiveShadow = trueLampu pintunya sengaja gak dikasih bayangan, karena efeknya gak seberapa tapi biayanya mahal.
Masalah Kuburan
Kuburan itu agak tricky, karena dibikin di dalam perulangan, jadi variabelnya gak bisa diakses dari luar situ.
Salah satu solusinya ya nulis castShadow langsung di dalam perulangan, dan itu jalan kok. Tapi kita mau semua kode bayangan ngumpul di satu tempat.
Untungnya kita udah masukin semua kuburan ke dalam Group bernama graves, dan grup itu bisa diakses dari luar perulangan. Jadi kita telusuri isinya pakai for(... of ...):
for(const grave of graves.children)
{
grave.castShadow = true
grave.receiveShadow = true
}Nah, ini alasan sesungguhnya kenapa tadi kuburannya dibungkus Group. Bukan cuma biar rapi, tapi biar bisa diakses balik kaya gini.
Pengaturan Shadow Map
Idealnya kita bikin CameraHelper di tiap lampu terus atur satu-satu sampai pas, kaya di 14 Shadows > Optimasi Shadow Map. Tapi biar hemat waktu, ini nilai yang udah pas:
// Mappings
directionalLight.shadow.mapSize.width = 256
directionalLight.shadow.mapSize.height = 256
directionalLight.shadow.camera.top = 8
directionalLight.shadow.camera.right = 8
directionalLight.shadow.camera.bottom = - 8
directionalLight.shadow.camera.left = - 8
directionalLight.shadow.camera.near = 1
directionalLight.shadow.camera.far = 20
ghost1.shadow.mapSize.width = 256
ghost1.shadow.mapSize.height = 256
ghost1.shadow.camera.far = 10
ghost2.shadow.mapSize.width = 256
ghost2.shadow.mapSize.height = 256
ghost2.shadow.camera.far = 10
ghost3.shadow.mapSize.width = 256
ghost3.shadow.mapSize.height = 256
ghost3.shadow.camera.far = 10Kok resolusinya malah diturunin ke 256, bukan dinaikin? Ini pelajaran penting: kalau scene-nya udah penuh texture dan detail, shadow map resolusi rendah itu justru bikin bayangannya kelihatan lembut dan menyebar tanpa cacat yang keliatan. Jadi dapat dua keuntungan sekaligus: lebih enteng dan lebih bagus.
Langit
Scene-nya udah keren, tapi latar hitam polosnya norak. Kita pasang class Sky yang menghasilkan langit realistis:
import { Sky } from 'three/addons/objects/Sky.js'/**
* Sky
*/
const sky = new Sky()
sky.scale.set(100, 100, 100)
scene.add(sky)
sky.material.uniforms['turbidity'].value = 10
sky.material.uniforms['rayleigh'].value = 3
sky.material.uniforms['mieCoefficient'].value = 0.1
sky.material.uniforms['mieDirectionalG'].value = 0.95
sky.material.uniforms['sunPosition'].value.set(0.3, -0.038, -0.95)Dua hal yang bikin bingung di sini.
Pertama, sky.scale.set(100, 100, 100) itu wajib. Tanpa itu, langitnya ada kok, cuma bentuknya kubus kecil yang ngambang di scene. Sky itu turunan dari Mesh, jadi scale-nya bisa kita ubah kaya objek biasa.
Kedua, cara ngatur parameternya aneh banget, pakai sky.material.uniforms['nama'].value, dan jangan lupa .value-nya. Itu cara ngatur parameter shader custom di ThreeJS, dan bakal dijelasin lengkap di bab Shaders nanti. Untuk sekarang terima aja dulu.
Kabut
Di film horor, kabut selalu dipakai biar suasananya menekan dan korbannya gak bisa lihat jalan keluar terlalu cepat. ThreeJS dukung fitur ini lewat Fog dan FogExp2.
1. Fog
scene.fog = new THREE.Fog('#ff0000', 1, 13)Parameternya: warna, near (jarak dari kamera di mana kabutnya mulai), dan far (jarak di mana kabutnya jadi pekat total).
Warnanya sengaja merah menyala dulu biar kita bisa lihat jelas di mana kabutnya berada. Nanti diganti.
Fog cocok kalau kamu mau kontrol penuh di mana kabutnya mulai dan berakhir.
2. FogExp2
scene.fog = new THREE.FogExp2('#ff0000', 0.1)Yang ini lebih sederhana: makin jauh dari kamera, makin pekat kabutnya, gak ada batas mulai dan selesai. Parameternya cuma warna dan kepekatan. Ini pendekatan yang lebih realistis, jadi ini yang kita pakai.
3. Warna Kabutnya
Nah ini triknya: pilih warna yang nyatu sama latar belakangnya, dalam hal ini langit kita. Ambil warna bagian bawah langitnya pakai color picker, terus tempel:
scene.fog = new THREE.FogExp2('#04343f', 0.1)❌ jelek, kabutnya kelihatan sebagai lapisan terpisah dari langit
scene.fog = new THREE.FogExp2('#ffffff', 0.1)✅ bagus, kabut dan langitnya menyatu jadi satu kesatuan
scene.fog = new THREE.FogExp2('#04343f', 0.1)Optimasi Texture
Selesai… eh tunggu, ada yang kelupaan dan ini penting banget.
Kita udah download dan pakai texture realistis, tapi udah dicek belum beratnya berapa?
Sekitar 13MB. Itu kelewat berat banget buat scene sesederhana ini.
Texture yang kegedean itu bukan cuma masalah waktu loading. Itu juga masalah buat GPU: makin gede texture-nya, makin banyak memori yang dimakan. Jelek buat performa, dan bikin macet sepersekian detik waktu texture-nya lagi dikirim ke GPU.
Jadi kita bikin texture-nya sekecil mungkin dan dikompres.
WEBP
Sekarang file kita JPG semua, dan itu udah lumayan. Tapi ada format yang lebih bagus: WEBP.
Yang perlu diketahui soal WEBP:
- Udah didukung semua browser besar, cuma Safari versi lama yang belum.
- Dukung transparansi.
- Kompresinya lossy, artinya gambarnya sedikit rusak. Tujuannya nyari titik pas antara ukuran dan kualitas, biasanya sekitar 80%.
- Ada versi lossless-nya juga, walaupun gak kita pakai.
Ingat aturan lama: texture yang isinya data (kaya normal map) sebaiknya gak dikasih kompresi lossy. Tapi buat scene kotor berlumut kaya punya kita, aman-aman aja, bahkan buat normal map-nya. Kalau scene-nya bersih dan halus, mendingan tetap PNG atau WEBP lossless.
Alatnya
Pilihan online yang populer:
- Squoosh, satu file sekali jalan, opsinya banyak, ada preview langsung.
- CloudConvert, banyak file sekaligus, 25 gambar per hari kalau punya akun.
- TinyPNG, banyak file sekaligus, terbatas tapi bisa ditunggu, tanpa opsi.
Kalau pakai layanan online, inget kamu lagi ngirim gambar ke server orang, jadi jangan kirim data sensitif ke situ.
Pengaturan yang dipakai di sini: kualitas 80 buat semuanya, dan ukurannya beda-beda:
door/: ukuran1024floor/: ukuran512(termasukalpha.jpg-nya)grave/: ukuran512roof/: ukuran512wall/: ukuran1024
Setelah dikonversi, taruh file WEBP-nya di folder yang sama, terus ganti .jpg jadi .webp di kode:
const bushColorTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_diff_1k.webp')
const bushARMTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_arm_1k.webp')
const bushNormalTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_nor_gl_1k.webp')Simpan file aslinya sampai kamu yakin semuanya beres, jangan langsung dihapus.
Hasilnya: dari 13MB jadi sekitar 1.6MB, alias 8 kali lebih kecil. Texture-nya emang jadi kurang tajam, tapi di scene sekotor ini gak ada yang nyadar. User dan GPU mereka bakal berterima kasih.
Idealnya, kita download dari Poly Haven dalam format PNG dulu (biar dapat aslinya tanpa kompresi lossy), baru dikonversi ke WEBP. Tapi JPG dari Poly Haven itu kualitasnya udah tinggi kok, jadi gak masalah.
Lanjut Sendiri
Kita udah selesai. Kelihatan kan, banyak banget yang bisa dibikin cuma pakai bawaan ThreeJS. Texture yang bagus emang ngebantu banyak. Tapi kalau kamu masih pengen lanjut:
- Tambahin lebih banyak tweak buat lampu, warna, pengulangan texture, kecepatan hantu, dan lain-lain.
- Tambahin tweak buat
Sky-nya. - Keluar dari bentuk rumah kotak, tambahin garasi, gudang, atau WC di belakang.
- Tambahin objek lain: pagar, jalan setapak, batu, papan kayu, jendela.
- Balik lagi ke bab ini setelah kamu belajar impor model.
- Tambahin suara.
- Bikin lampu pintunya berkedip-kedip.
Code Lengkap
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { Timer } from 'three/addons/misc/Timer.js'
import { Sky } from 'three/addons/objects/Sky.js'
import GUI from 'lil-gui'
/**
* Base
*/
// Debug
const gui = new GUI()
// 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()
// Floor
const floorAlphaTexture = textureLoader.load('./floor/alpha.webp')
const floorColorTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_diff_1k.webp')
const floorARMTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_arm_1k.webp')
const floorNormalTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_nor_gl_1k.webp')
const floorDisplacementTexture = textureLoader.load('./floor/coast_sand_rocks_02_1k/coast_sand_rocks_02_disp_1k.webp')
floorColorTexture.colorSpace = THREE.SRGBColorSpace
floorColorTexture.repeat.set(8, 8)
floorARMTexture.repeat.set(8, 8)
floorNormalTexture.repeat.set(8, 8)
floorDisplacementTexture.repeat.set(8, 8)
floorColorTexture.wrapS = THREE.RepeatWrapping
floorARMTexture.wrapS = THREE.RepeatWrapping
floorNormalTexture.wrapS = THREE.RepeatWrapping
floorDisplacementTexture.wrapS = THREE.RepeatWrapping
floorColorTexture.wrapT = THREE.RepeatWrapping
floorARMTexture.wrapT = THREE.RepeatWrapping
floorNormalTexture.wrapT = THREE.RepeatWrapping
floorDisplacementTexture.wrapT = THREE.RepeatWrapping
// Wall
const wallColorTexture = textureLoader.load('./wall/castle_brick_broken_06_1k/castle_brick_broken_06_diff_1k.webp')
const wallARMTexture = textureLoader.load('./wall/castle_brick_broken_06_1k/castle_brick_broken_06_arm_1k.webp')
const wallNormalTexture = textureLoader.load('./wall/castle_brick_broken_06_1k/castle_brick_broken_06_nor_gl_1k.webp')
wallColorTexture.colorSpace = THREE.SRGBColorSpace
// Roof
const roofColorTexture = textureLoader.load('./roof/roof_slates_02_1k/roof_slates_02_diff_1k.webp')
const roofARMTexture = textureLoader.load('./roof/roof_slates_02_1k/roof_slates_02_arm_1k.webp')
const roofNormalTexture = textureLoader.load('./roof/roof_slates_02_1k/roof_slates_02_nor_gl_1k.webp')
roofColorTexture.colorSpace = THREE.SRGBColorSpace
roofColorTexture.repeat.set(3, 1)
roofARMTexture.repeat.set(3, 1)
roofNormalTexture.repeat.set(3, 1)
roofColorTexture.wrapS = THREE.RepeatWrapping
roofARMTexture.wrapS = THREE.RepeatWrapping
roofNormalTexture.wrapS = THREE.RepeatWrapping
// Bush
const bushColorTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_diff_1k.webp')
const bushARMTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_arm_1k.webp')
const bushNormalTexture = textureLoader.load('./bush/leaves_forest_ground_1k/leaves_forest_ground_nor_gl_1k.webp')
bushColorTexture.colorSpace = THREE.SRGBColorSpace
bushColorTexture.repeat.set(2, 1)
bushARMTexture.repeat.set(2, 1)
bushNormalTexture.repeat.set(2, 1)
bushColorTexture.wrapS = THREE.RepeatWrapping
bushARMTexture.wrapS = THREE.RepeatWrapping
bushNormalTexture.wrapS = THREE.RepeatWrapping
// Grave
const graveColorTexture = textureLoader.load('./grave/plastered_stone_wall_1k/plastered_stone_wall_diff_1k.webp')
const graveARMTexture = textureLoader.load('./grave/plastered_stone_wall_1k/plastered_stone_wall_arm_1k.webp')
const graveNormalTexture = textureLoader.load('./grave/plastered_stone_wall_1k/plastered_stone_wall_nor_gl_1k.webp')
graveColorTexture.colorSpace = THREE.SRGBColorSpace
graveColorTexture.repeat.set(0.3, 0.4)
graveARMTexture.repeat.set(0.3, 0.4)
graveNormalTexture.repeat.set(0.3, 0.4)
// Door
const doorColorTexture = textureLoader.load('./door/color.webp')
const doorAlphaTexture = textureLoader.load('./door/alpha.webp')
const doorAmbientOcclusionTexture = textureLoader.load('./door/ambientOcclusion.webp')
const doorHeightTexture = textureLoader.load('./door/height.webp')
const doorNormalTexture = textureLoader.load('./door/normal.webp')
const doorMetalnessTexture = textureLoader.load('./door/metalness.webp')
const doorRoughnessTexture = textureLoader.load('./door/roughness.webp')
doorColorTexture.colorSpace = THREE.SRGBColorSpace
/**
* House
*/
// Floor
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20, 100, 100),
new THREE.MeshStandardMaterial({
alphaMap: floorAlphaTexture,
transparent: true,
map: floorColorTexture,
aoMap: floorARMTexture,
roughnessMap: floorARMTexture,
metalnessMap: floorARMTexture,
normalMap: floorNormalTexture,
displacementMap: floorDisplacementTexture,
displacementScale: 0.3,
displacementBias: - 0.2
})
)
floor.rotation.x = - Math.PI * 0.5
scene.add(floor)
gui.add(floor.material, 'displacementScale').min(0).max(1).step(0.001).name('floorDisplacementScale')
gui.add(floor.material, 'displacementBias').min(-1).max(1).step(0.001).name('floorDisplacementBias')
// House container
const house = new THREE.Group()
scene.add(house)
// Walls
const walls = new THREE.Mesh(
new THREE.BoxGeometry(4, 2.5, 4),
new THREE.MeshStandardMaterial({
map: wallColorTexture,
aoMap: wallARMTexture,
roughnessMap: wallARMTexture,
metalnessMap: wallARMTexture,
normalMap: wallNormalTexture
})
)
walls.position.y += 1.25
house.add(walls)
// Roof
const roof = new THREE.Mesh(
new THREE.ConeGeometry(3.5, 1.5, 4),
new THREE.MeshStandardMaterial({
map: roofColorTexture,
aoMap: roofARMTexture,
roughnessMap: roofARMTexture,
metalnessMap: roofARMTexture,
normalMap: roofNormalTexture
})
)
roof.position.y = 2.5 + 0.75
roof.rotation.y = Math.PI * 0.25
house.add(roof)
// Door
const door = new THREE.Mesh(
new THREE.PlaneGeometry(2.2, 2.2, 100, 100),
new THREE.MeshStandardMaterial({
map: doorColorTexture,
transparent: true,
alphaMap: doorAlphaTexture,
aoMap: doorAmbientOcclusionTexture,
displacementMap: doorHeightTexture,
displacementScale: 0.15,
displacementBias: - 0.04,
normalMap: doorNormalTexture,
metalnessMap: doorMetalnessTexture,
roughnessMap: doorRoughnessTexture
})
)
door.position.y = 1
door.position.z = 2 + 0.01
house.add(door)
// Bushes
const bushGeometry = new THREE.SphereGeometry(1, 16, 16)
const bushMaterial = new THREE.MeshStandardMaterial({
color: '#ccffcc',
map: bushColorTexture,
aoMap: bushARMTexture,
roughnessMap: bushARMTexture,
metalnessMap: bushARMTexture,
normalMap: bushNormalTexture
})
const bush1 = new THREE.Mesh(bushGeometry, bushMaterial)
bush1.scale.set(0.5, 0.5, 0.5)
bush1.position.set(0.8, 0.2, 2.2)
bush1.rotation.x = - 0.75
const bush2 = new THREE.Mesh(bushGeometry, bushMaterial)
bush2.scale.set(0.25, 0.25, 0.25)
bush2.position.set(1.4, 0.1, 2.1)
bush2.rotation.x = - 0.75
const bush3 = new THREE.Mesh(bushGeometry, bushMaterial)
bush3.scale.set(0.4, 0.4, 0.4)
bush3.position.set(- 0.8, 0.1, 2.2)
bush3.rotation.x = - 0.75
const bush4 = new THREE.Mesh(bushGeometry, bushMaterial)
bush4.scale.set(0.15, 0.15, 0.15)
bush4.position.set(- 1, 0.05, 2.6)
bush4.rotation.x = - 0.75
house.add(bush1, bush2, bush3, bush4)
// Graves
const graveGeometry = new THREE.BoxGeometry(0.6, 0.8, 0.2)
const graveMaterial = new THREE.MeshStandardMaterial({
map: graveColorTexture,
aoMap: graveARMTexture,
roughnessMap: graveARMTexture,
metalnessMap: graveARMTexture,
normalMap: graveNormalTexture
})
const graves = new THREE.Group()
scene.add(graves)
for(let i = 0; i < 30; i++)
{
// Coordinates
const angle = Math.random() * Math.PI * 2
const radius = 3 + Math.random() * 4
const x = Math.sin(angle) * radius
const z = Math.cos(angle) * radius
// Mesh
const grave = new THREE.Mesh(graveGeometry, graveMaterial)
grave.position.x = x
grave.position.y = Math.random() * 0.4
grave.position.z = z
grave.rotation.x = (Math.random() - 0.5) * 0.4
grave.rotation.y = (Math.random() - 0.5) * 0.4
grave.rotation.z = (Math.random() - 0.5) * 0.4
// Add to the graves group
graves.add(grave)
}
/**
* Lights
*/
// Ambient light
const ambientLight = new THREE.AmbientLight('#86cdff', 0.275)
scene.add(ambientLight)
// Directional light
const directionalLight = new THREE.DirectionalLight('#86cdff', 1)
directionalLight.position.set(3, 2, - 8)
scene.add(directionalLight)
// Door light
const doorLight = new THREE.PointLight('#ff7d46', 5)
doorLight.position.set(0, 2.2, 2.5)
house.add(doorLight)
/**
* Ghosts
*/
const ghost1 = new THREE.PointLight('#8800ff', 6)
const ghost2 = new THREE.PointLight('#ff0088', 6)
const ghost3 = new THREE.PointLight('#ff0000', 6)
scene.add(ghost1, ghost2, ghost3)
/**
* Camera
*/
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100)
camera.position.x = 4
camera.position.y = 2
camera.position.z = 5
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)
/**
* Shadows
*/
// Renderer
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
// Cast and receive
directionalLight.castShadow = true
ghost1.castShadow = true
ghost2.castShadow = true
ghost3.castShadow = true
walls.castShadow = true
walls.receiveShadow = true
roof.castShadow = true
floor.receiveShadow = true
for(const grave of graves.children)
{
grave.castShadow = true
grave.receiveShadow = true
}
// Mappings
directionalLight.shadow.mapSize.width = 256
directionalLight.shadow.mapSize.height = 256
directionalLight.shadow.camera.top = 8
directionalLight.shadow.camera.right = 8
directionalLight.shadow.camera.bottom = - 8
directionalLight.shadow.camera.left = - 8
directionalLight.shadow.camera.near = 1
directionalLight.shadow.camera.far = 20
ghost1.shadow.mapSize.width = 256
ghost1.shadow.mapSize.height = 256
ghost1.shadow.camera.far = 10
ghost2.shadow.mapSize.width = 256
ghost2.shadow.mapSize.height = 256
ghost2.shadow.camera.far = 10
ghost3.shadow.mapSize.width = 256
ghost3.shadow.mapSize.height = 256
ghost3.shadow.camera.far = 10
/**
* Sky
*/
const sky = new Sky()
sky.scale.set(100, 100, 100)
scene.add(sky)
sky.material.uniforms['turbidity'].value = 10
sky.material.uniforms['rayleigh'].value = 3
sky.material.uniforms['mieCoefficient'].value = 0.1
sky.material.uniforms['mieDirectionalG'].value = 0.95
sky.material.uniforms['sunPosition'].value.set(0.3, - 0.038, - 0.95)
/**
* Fog
*/
scene.fog = new THREE.FogExp2('#04343f', 0.1)
/**
* Animate
*/
const timer = new Timer()
const tick = () =>
{
// Timer
timer.update()
const elapsedTime = timer.getElapsed()
// Ghosts
const ghost1Angle = elapsedTime * 0.5
ghost1.position.x = Math.cos(ghost1Angle) * 4
ghost1.position.z = Math.sin(ghost1Angle) * 4
ghost1.position.y = Math.sin(ghost1Angle) * Math.sin(ghost1Angle * 2.34) * Math.sin(ghost1Angle * 3.45)
const ghost2Angle = - elapsedTime * 0.38
ghost2.position.x = Math.cos(ghost2Angle) * 5
ghost2.position.z = Math.sin(ghost2Angle) * 5
ghost2.position.y = Math.sin(ghost2Angle) * Math.sin(ghost2Angle * 2.34) * Math.sin(ghost2Angle * 3.45)
const ghost3Angle = elapsedTime * 0.23
ghost3.position.x = Math.cos(ghost3Angle) * 6
ghost3.position.z = Math.sin(ghost3Angle) * 6
ghost3.position.y = Math.sin(ghost3Angle) * Math.sin(ghost3Angle * 2.34) * Math.sin(ghost3Angle * 3.45)
// Update controls
controls.update()
// Render
renderer.render(scene, camera)
// Call tick again on the next frame
window.requestAnimationFrame(tick)
}
tick()Ringkasan
Timeritu penggantiClockyang lebih aman, tapi wajib di-update manual pakaitimer.update()tiap frame dan harus di-import sendiri.- Tentuin patokan satuan dari awal. Di project ini 1 unit = 1 meter, dan itu yang bikin kita tau pintu harus sekitar
2, bukan20. - Origin geometry itu ada di tengahnya, makanya dinding setinggi
2.5harus dinaikin1.25biar gak setengah kekubur. - Gak ada geometry “pyramid” di ThreeJS, tapi
ConeGeometrydenganradialSegments4itu ya limas segi empat. - Tulis angka sebagai penjumlahan (
2.5 + 0.75) kalau itu ngebantu, biar asal-usulnya kelihatan waktu dibaca lagi nanti. - Kasih warna sementara ke objek yang lagi dibikin. Kalau pintunya gak diwarnain merah dulu, kita gak bakal nyadar ada z-fighting.
- Bungkus objek yang berkaitan ke dalam
Group. Bukan cuma biar rapi, tapi biar bisa diakses balik lewat.childrendari luar perulangan. - Kirim sudut yang sama ke
Math.sin()danMath.cos()buat dapet koordinat lingkaran. Kaliin radiusnya buat ngatur lebarnya. Math.random()doang buat nilai yang cuma boleh positif (ketinggian),(Math.random() - 0.5)buat nilai yang boleh dua arah (rotasi).- Pilih format ARM di Poly Haven kalau ada, karena tiga map digabung jadi satu file dan itu bagus buat performa.
- Satu texture ARM dipasang ke tiga properti sekaligus:
aoMap,roughnessMap, danmetalnessMap. - Normal map ambil yang versi GL, bukan DX.
alphaMapgak jalan tanpatransparent: true, dan itu yang bikin pinggiran lantai bisa memudar mulus.wrapcuma perlu diatur kalaurepeat-nya lebih dari 1. Kalau di bawah 1, gak usah.displacementMapbutuh subdivision banyak, tapi jangan kebanyakan. Redam pakaidisplacementScale, dan geser hasilnya pakaidisplacementBias.ConeGeometrydanSphereGeometrypunya masalah UV bawaan yang gak ada obat gampangnya. Akalin dengan muter objeknya biar bagian jeleknya kesembunyi.colordi material itu mewarnai texture-nya, cocok buat bikin semak lebih ijo tanpa ganti texture.- Warna lampu yang gak putih itu jauh lebih realistis. Lampu pintu yang hangat kontras sama lingkungan yang dingin, dan itu bikin ceritanya kerasa.
- Kaliin beberapa sinus dengan frekuensi ganjil kalau mau gerakan yang gak ketebak. Satu sinus doang polanya terlalu mesin.
- Tanda minus di depan
elapsedTimebikin arah putarannya kebalik. - Shadow map resolusi rendah justru lebih bagus di scene yang penuh detail, karena bayangannya jadi lembut dan menyebar tanpa cacat, sekaligus lebih enteng.
- Kumpulin semua kode bayangan di satu tempat biar gampang diatur, dan telusuri isi
Grouppakaifor(... of ...)buat objek yang lahir di dalam perulangan. Skyitu turunanMesh, jadiscale-nya wajib dibesarin, kalau nggak dia cuma kubus kecil ngambang.- Parameter shader diatur lewat
material.uniforms['nama'].value, dan.value-nya jangan sampai lupa. Fogpunyaneardanfar,FogExp2cuma punya kepekatan dan lebih realistis. Warnanya harus nyatu sama warna latar belakang.- Cek berat total texture-mu. 13MB itu kelewat berat, dan konversi ke WEBP dengan kualitas 80 bisa mengecilkannya sampai 8 kali lipat.
Bab selanjutnya kita belajar Particles, buat bikin ribuan titik kecil yang melayang kaya debu, salju, atau bintang, tanpa bikin performanya ambruk.