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:

  1. Memperbaiki bug lama Clock di mana nilainya jadi kacau kalau getElapsedTime() dipanggil lebih dari sekali dalam satu frame.
  2. Harus di-update manual pakai timer.update().
  3. Dia ngecek kalau tab-nya lagi gak aktif, jadi gak muncul lonjakan waktu aneh pas kita balik ke tab-nya.
  4. Harus di-import sendiri, kaya OrbitControls dan FontLoader dulu.
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:

  1. 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 lewat floor.geometry dan floor.material.
  2. 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.
  3. Pakai MeshStandardMaterial karena 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.01

Habis 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) * radius

Tapi sekarang lingkarannya terlalu rapi, kaya barisan upacara. Kita acak radiusnya biar ada yang dekat dan ada yang jauh:

const radius = 3 + Math.random() * 4

Artinya: 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.4

Perhatiin bedanya position.y sama rotation:

  1. position.y pakai Math.random() * 0.4 doang, karena kita cuma mau kuburannya naik, gak mau ada yang tenggelam ke bawah tanah.
  2. rotation pakai (Math.random() - 0.5), karena miringnya harus bisa ke kiri dan ke kanan. Trik - 0.5 yang 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:

  1. Resolusi, ganti dari 4K jadi 1K. Kita gak butuh resolusi gila-gilaan.
  2. Format paket, pilih ZIP.
  3. Isi ZIP-nya, ini yang paling penting.

Nama-nama pilihan di isi ZIP itu harusnya udah familiar dari 09 Textures > Apa itu Texture?:

  1. AO (ambient occlusion), mencegah cahaya ambient masuk ke celah-celah.
  2. Diffuse, warna aslinya.
  3. Displacement, menggeser vertices buat bikin permukaan timbul.
  4. Normal, memalsukan arah permukaan buat bikin detail. Ada versi DX dan GL, kita butuh yang GL.
  5. Rough, seberapa kasar atau halus permukaannya.
  6. Bump, mirip normal tapi hitam-putih. Gak kita butuhin.
  7. 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:

  1. EXR, ukuran file gede banget dengan data maksimal.
  2. JPG, ukuran kecil tapi ada potensi cacat kompresi.
  3. 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.RepeatWrapping

Terus warnanya keabu-abuan, dan kita tau obatnya:

floorColorTexture.colorSpace = THREE.SRGBColorSpace

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

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

Perhatiin 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:

  1. Bikin model-nya di software 3D. Bikin limas di Blender itu gampang banget, dan ngatur UV-nya juga simpel.
  2. 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 di BufferGeometry.

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

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

Kedua, 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.SRGBColorSpace

Pasang 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 = true

Lampu 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 = 10

Kok 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:

  1. Udah didukung semua browser besar, cuma Safari versi lama yang belum.
  2. Dukung transparansi.
  3. Kompresinya lossy, artinya gambarnya sedikit rusak. Tujuannya nyari titik pas antara ukuran dan kualitas, biasanya sekitar 80%.
  4. 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:

  1. Squoosh, satu file sekali jalan, opsinya banyak, ada preview langsung.
  2. CloudConvert, banyak file sekaligus, 25 gambar per hari kalau punya akun.
  3. 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:

  1. door/: ukuran 1024
  2. floor/: ukuran 512 (termasuk alpha.jpg-nya)
  3. grave/: ukuran 512
  4. roof/: ukuran 512
  5. wall/: ukuran 1024

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:

  1. Tambahin lebih banyak tweak buat lampu, warna, pengulangan texture, kecepatan hantu, dan lain-lain.
  2. Tambahin tweak buat Sky-nya.
  3. Keluar dari bentuk rumah kotak, tambahin garasi, gudang, atau WC di belakang.
  4. Tambahin objek lain: pagar, jalan setapak, batu, papan kayu, jendela.
  5. Balik lagi ke bab ini setelah kamu belajar impor model.
  6. Tambahin suara.
  7. 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

  1. Timer itu pengganti Clock yang lebih aman, tapi wajib di-update manual pakai timer.update() tiap frame dan harus di-import sendiri.
  2. Tentuin patokan satuan dari awal. Di project ini 1 unit = 1 meter, dan itu yang bikin kita tau pintu harus sekitar 2, bukan 20.
  3. Origin geometry itu ada di tengahnya, makanya dinding setinggi 2.5 harus dinaikin 1.25 biar gak setengah kekubur.
  4. Gak ada geometry “pyramid” di ThreeJS, tapi ConeGeometry dengan radialSegments 4 itu ya limas segi empat.
  5. Tulis angka sebagai penjumlahan (2.5 + 0.75) kalau itu ngebantu, biar asal-usulnya kelihatan waktu dibaca lagi nanti.
  6. Kasih warna sementara ke objek yang lagi dibikin. Kalau pintunya gak diwarnain merah dulu, kita gak bakal nyadar ada z-fighting.
  7. Bungkus objek yang berkaitan ke dalam Group. Bukan cuma biar rapi, tapi biar bisa diakses balik lewat .children dari luar perulangan.
  8. Kirim sudut yang sama ke Math.sin() dan Math.cos() buat dapet koordinat lingkaran. Kaliin radiusnya buat ngatur lebarnya.
  9. Math.random() doang buat nilai yang cuma boleh positif (ketinggian), (Math.random() - 0.5) buat nilai yang boleh dua arah (rotasi).
  10. Pilih format ARM di Poly Haven kalau ada, karena tiga map digabung jadi satu file dan itu bagus buat performa.
  11. Satu texture ARM dipasang ke tiga properti sekaligus: aoMap, roughnessMap, dan metalnessMap.
  12. Normal map ambil yang versi GL, bukan DX.
  13. alphaMap gak jalan tanpa transparent: true, dan itu yang bikin pinggiran lantai bisa memudar mulus.
  14. wrap cuma perlu diatur kalau repeat-nya lebih dari 1. Kalau di bawah 1, gak usah.
  15. displacementMap butuh subdivision banyak, tapi jangan kebanyakan. Redam pakai displacementScale, dan geser hasilnya pakai displacementBias.
  16. ConeGeometry dan SphereGeometry punya masalah UV bawaan yang gak ada obat gampangnya. Akalin dengan muter objeknya biar bagian jeleknya kesembunyi.
  17. color di material itu mewarnai texture-nya, cocok buat bikin semak lebih ijo tanpa ganti texture.
  18. Warna lampu yang gak putih itu jauh lebih realistis. Lampu pintu yang hangat kontras sama lingkungan yang dingin, dan itu bikin ceritanya kerasa.
  19. Kaliin beberapa sinus dengan frekuensi ganjil kalau mau gerakan yang gak ketebak. Satu sinus doang polanya terlalu mesin.
  20. Tanda minus di depan elapsedTime bikin arah putarannya kebalik.
  21. Shadow map resolusi rendah justru lebih bagus di scene yang penuh detail, karena bayangannya jadi lembut dan menyebar tanpa cacat, sekaligus lebih enteng.
  22. Kumpulin semua kode bayangan di satu tempat biar gampang diatur, dan telusuri isi Group pakai for(... of ...) buat objek yang lahir di dalam perulangan.
  23. Sky itu turunan Mesh, jadi scale-nya wajib dibesarin, kalau nggak dia cuma kubus kecil ngambang.
  24. Parameter shader diatur lewat material.uniforms['nama'].value, dan .value-nya jangan sampai lupa.
  25. Fog punya near dan far, FogExp2 cuma punya kepekatan dan lebih realistis. Warnanya harus nyatu sama warna latar belakang.
  26. 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.