Di 13 Lights objek kita udah kena cahaya, tapi masih kerasa melayang aneh. Yang kurang itu bayangan.

Sebenarnya objek kita udah punya bayangan kok, yaitu bagian belakang objek yang gelap karena gak kena cahaya. Itu namanya core shadow, dan itu otomatis dari lampu.

Yang belum ada itu drop shadow, yaitu bayangan yang jatuh dari satu objek ke objek lain. Bola yang jatuhin bayangan ke lantai, itu drop shadow.

Bayangan itu selalu jadi tantangan berat di render 3D real-time, dan developer harus cari akal biar bayangannya kelihatan realistis tapi frame rate-nya gak anjlok. Cara implementasinya banyak, dan ThreeJS punya solusi bawaan. Tapi hati-hati: solusi bawaan ini praktis, tapi jauh dari sempurna.

Gimana Cara Kerjanya

Kita gak bakal bedah sampai dalem, tapi paham dasarnya itu penting banget biar gak bingung nanti.

Tiap kali kita nge-render satu frame, ThreeJS diam-diam ngerender sekali lagi buat tiap lampu yang boleh bikin bayangan. Render tambahan itu mensimulasikan apa yang dilihat lampu, seolah-olah lampu itu adalah kamera.

Selama render lampu itu, semua material di scene diganti sementara sama MeshDepthMaterial. Ingat kan, material itu mewarnai objek putih kalau dekat dan hitam kalau jauh? Nah, itu persis yang dibutuhin: peta jarak.

Hasilnya disimpan sebagai texture yang namanya shadow map.

Kita gak bisa lihat shadow map itu secara langsung, tapi dia dipakai di tiap material yang boleh nerima bayangan, terus diproyeksikan ke permukaan geometry-nya.

Sederhananya: lampu ngintip dulu dari posisinya, nyatet apa aja yang kehalang, terus hasil catetannya ditempel ke lantai sebagai bayangan. Yang gak kelihatan sama lampu, berarti kena bayangan.

Contoh bagusnya bisa dilihat di halaman contoh shadow map viewer, di situ kelihatan apa yang “dilihat” sama lampu directional dan spotlight.

Cara Nyalain Bayangan

Ada tiga tempat yang harus dinyalain, dan kalau satu aja kelewat, bayangannya gak bakal muncul dan kamu bakal bingung nyari salahnya.

1. Nyalain di Renderer

const renderer = new THREE.WebGLRenderer({
    canvas: canvas
})
renderer.shadowMap.enabled = true

2. Nyalain di Tiap Objek

Tiap objek harus kita tentuin: dia boleh bikin bayangan (castShadow) atau boleh nerima bayangan (receiveShadow), atau dua-duanya.

Bola kita jatuhin bayangan, lantai kita yang nerima:

sphere.castShadow = true
 
// ...
plane.receiveShadow = true

Nyalain di sesedikit mungkin objek, karena tiap objek yang ikut main itu nambah beban.

3. Nyalain di Lampunya

directionalLight.castShadow = true

Dan ini penting: cuma tiga jenis lampu yang dukung bayangan, dari enam jenis yang kita pelajari di 13 Lights:

  1. PointLight
  2. DirectionalLight
  3. SpotLight

Jadi AmbientLight, HemisphereLight, dan RectAreaLight gak bisa bikin bayangan sama sekali, mau diapain juga.

❌ percuma, lampu jenis ini gak dukung bayangan

const ambientLight = new THREE.AmbientLight(0xffffff, 1)
ambientLight.castShadow = true

✅ benar, pakai lampu yang emang dukung

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5)
directionalLight.castShadow = true

Sekarang bayangan bolanya muncul di lantai. Tapi jelek banget kan? Kotak-kotak dan pinggirannya patah-patah. Ayo kita perbaiki.

Optimasi Shadow Map

1. Ukuran Render

Ingat, shadow map itu sebenarnya hasil render juga. Dan namanya render, ya ada resolusinya. Kita bisa akses lewat properti shadow:

directionalLight.castShadow = true
console.log(directionalLight.shadow)

Bawaannya cuma 512x512 demi performa, makanya pecah. Kita naikin, tapi ingat aturan pangkat 2 yang kita pelajari di 09 Textures > 2. Ukuran (*Size*), karena ini juga butuh mipmapping:

const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5)
directionalLight.castShadow = true
directionalLight.shadow.mapSize.width = 1024
directionalLight.shadow.mapSize.height = 1024

Bayangannya langsung jauh lebih rapi.

2. Near dan Far

Karena ThreeJS pakai kamera buat ngerender shadow map, berarti kamera itu punya properti yang sama kaya kamera biasa, termasuk near dan far yang kita bahas di 05 Camera > 3. Jarak Terdekat dan Terjauh.

Ngatur ini gak bikin bayangannya lebih tajam, tapi memperbaiki bug di mana bayangannya gak muncul sama sekali atau tiba-tiba kepotong.

Biar kita bisa lihat kameranya, kita pakai CameraHelper, konsepnya sama kaya helper-helper di 13 Lights > Helpers:

const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(directionalLightCameraHelper)

Sekarang near dan far-nya kelihatan sebagai garis. Tapi hati-hati sama urutannya: helper itu motret nilai kamera saat dia dibuat, jadi kalau nilainya diubah setelah helper dibikin, helper-nya nunjukkin nilai yang lama dan kamu bakal salah baca:

❌ salah, helper-nya nampilin nilai lama, bikin bingung

const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(directionalLightCameraHelper)
 
directionalLight.shadow.camera.near = 1
directionalLight.shadow.camera.far = 6

✅ benar, atur dulu nilainya, baru bikin helper-nya

directionalLight.shadow.camera.near = 1
directionalLight.shadow.camera.far = 6
 
const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(directionalLightCameraHelper)

3. Amplitudo

Lewat helper tadi kelihatan kalau jangkauan kameranya kelebaran, jauh lebih luas dari yang kita butuhin.

Karena ini DirectionalLight, ThreeJS pakai OrthographicCamera di baliknya. Nah, ingat kamera ortografis? Dia gak punya field of view, gantinya kita yang nentuin batas atas, kanan, bawah, dan kiri. Persis itu yang kita kecilin:

directionalLight.shadow.camera.top = 2
directionalLight.shadow.camera.right = 2
directionalLight.shadow.camera.bottom = - 2
directionalLight.shadow.camera.left = - 2

Logikanya gini: resolusi shadow map-nya tetap 1024x1024, tapi kalau area yang dipotret kelewat luas, resolusi itu kepakai buat motret ruang kosong. Kalau areanya dipersempit pas ke objek kita, resolusi yang sama jadi jauh lebih padat, alias bayangannya lebih tajam.

Makin kecil nilainya, makin presisi bayangannya. Tapi kalau kekecilan, bayangannya kepotong.

Kalau udah pas, helper-nya bisa disembunyiin:

directionalLightCameraHelper.visible = false

4. Blur

Ketajaman pinggiran bayangan diatur pakai properti radius:

directionalLight.shadow.radius = 10

Tapi perlu dicatat: teknik ini gak mikirin jarak objek ke lantai sama sekali. Di dunia nyata, bayangan itu makin kabur kalau objeknya makin jauh dari lantai. Yang ini nggak, dia cuma ngeblur rata semuanya dengan cara yang murah.

5. Algoritma Shadow Map

Ada beberapa algoritma yang bisa dipakai buat ngolah shadow map:

  1. THREE.BasicShadowMap, paling ngebut tapi kualitasnya jelek banget.
  2. THREE.PCFShadowMap, lebih berat tapi pinggirannya lebih halus. Ini yang jadi bawaannya.
  3. THREE.PCFSoftShadowMap, lebih berat lagi tapi pinggirannya paling lembut.
  4. THREE.VSMShadowMap, berat, banyak batasannya, dan hasilnya kadang aneh gak ketebak.

Gantinya lewat properti renderer.shadowMap.type:

const renderer = new THREE.WebGLRenderer({
    canvas: canvas
})
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap

Tapi awas jebakan: properti radius tadi gak jalan sama sekali kalau kamu pakai THREE.PCFSoftShadowMap. Jadi kamu harus milih salah satu, gak bisa dua-duanya.

❌ sia-sia, radius-nya diabaikan total sama algoritma ini

renderer.shadowMap.type = THREE.PCFSoftShadowMap
directionalLight.shadow.radius = 10

✅ pilih salah satu: blur manual…

renderer.shadowMap.type = THREE.PCFShadowMap
directionalLight.shadow.radius = 10

✅ …atau pinggiran lembut bawaan algoritmanya

renderer.shadowMap.type = THREE.PCFSoftShadowMap

SpotLight

Sekarang kita coba pasang SpotLight kaya di 13 Lights > SpotLight dan nyalain castShadow-nya. Jangan lupa target-nya dimasukin ke scene, jebakan yang udah kita bahas dulu:

// Spot light
const spotLight = new THREE.SpotLight(0xffffff, 3.6, 10, Math.PI * 0.3)
spotLight.castShadow = true
spotLight.position.set(0, 2, 2)
scene.add(spotLight)
scene.add(spotLight.target)
 
const spotLightCameraHelper = new THREE.CameraHelper(spotLight.shadow.camera)
scene.add(spotLightCameraHelper)

Kalau scene-nya jadi kesilauan, kecilin lampu yang lain:

const ambientLight = new THREE.AmbientLight(0xffffff, 0.8)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.3)

Perhatiin, bayangan dari dua lampu itu gak nyatu dengan mulus. Masing-masing diurus sendiri-sendiri, dan sayangnya gak banyak yang bisa kita lakuin soal ini. Ini salah satu bukti kalau sistem bayangan bawaan ThreeJS itu jauh dari sempurna.

Kualitasnya juga bisa dinaikin sama kaya tadi:

spotLight.shadow.mapSize.width = 1024
spotLight.shadow.mapSize.height = 1024

Bedanya: karena ini SpotLight, ThreeJS pakai PerspectiveCamera di baliknya, bukan ortografis. Jadi gak ada top, right, bottom, left di sini. fov-nya juga gak bisa kita ubah, karena bakal ditimpa sama nilai angle milik spotlight-nya sendiri.

Yang bisa diatur cuma near dan far:

spotLight.shadow.camera.near = 1
spotLight.shadow.camera.far = 6

Kalau udah pas, sembunyiin helper-nya:

spotLightCameraHelper.visible = false

PointLight

Terakhir, lampu ketiga yang dukung bayangan:

// Point light
const pointLight = new THREE.PointLight(0xffffff, 2.7)
pointLight.castShadow = true
pointLight.position.set(- 1, 1, 0)
scene.add(pointLight)
 
const pointLightCameraHelper = new THREE.CameraHelper(pointLight.shadow.camera)
scene.add(pointLightCameraHelper)

Kecilin lagi lampu yang lain kalau kesilauan:

const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.1)
const spotLight = new THREE.SpotLight(0xffffff, 2.4, 10, Math.PI * 0.3)

Lho, kok helper-nya PerspectiveCamera tapi ngadep ke bawah? Padahal lampunya nyinarin ke segala arah.

Jawabannya bikin merinding kalau mikirin performanya: karena PointLight nyinarin ke segala arah, ThreeJS harus ngerender keenam arahnya (depan, belakang, kiri, kanan, atas, bawah) buat bikin shadow map berbentuk kubus. Konsepnya sama kaya Cube Camera yang kita singgung di 05 Camera dulu. Helper yang kelihatan itu cuma posisi kamera di render terakhir dari enam render itu, yang kebetulan menghadap ke bawah.

Artinya: satu PointLight yang bikin bayangan itu biayanya enam kali render. Jadi jangan kebanyakan pasang PointLight dengan bayangan nyala, nanti frame rate-nya ambruk.

Yang bisa diatur cuma mapSize, near, dan far:

pointLight.shadow.mapSize.width = 1024
pointLight.shadow.mapSize.height = 1024
pointLight.shadow.camera.near = 0.1
pointLight.shadow.camera.far = 5

Sembunyiin helper-nya kalau udah:

pointLightCameraHelper.visible = false

Baking Shadow

Bayangan bawaan ThreeJS itu berguna banget kalau scene-nya sederhana. Kalau udah rame, hasilnya berantakan dan berat.

Alternatifnya: baked shadow. Ini konsep yang persis sama kaya baking cahaya yang kita bahas kemarin, cuma sekarang yang dipanggang ke dalam texture itu bayangannya.

Daripada ngomentarin semua baris kode bayangan satu-satu, kita matiin aja dari renderer-nya:

renderer.shadowMap.enabled = false

Terus kita load texture bayangannya pakai TextureLoader biasa. Karena ini dipakai sebagai map, colorSpace-nya di-set sRGB:

/**
 * Textures
 */
const textureLoader = new THREE.TextureLoader()
const bakedShadow = textureLoader.load('/textures/bakedShadow.jpg')
bakedShadow.colorSpace = THREE.SRGBColorSpace

Terakhir, lantainya gak usah pakai MeshStandardMaterial lagi, cukup MeshBasicMaterial dengan gambar bayangan itu sebagai map. Wong bayangannya udah nempel di gambarnya, ngapain lagi diitung sama cahaya:

const plane = new THREE.Mesh(
    new THREE.PlaneGeometry(5, 5),
    new THREE.MeshBasicMaterial({
        map: bakedShadow
    })
)

Hasilnya bayangan kabur yang realistis dan enteng banget. Masalahnya cuma satu tapi besar: bayangannya mati. Kalau bolanya gerak atau lampunya digeser, bayangannya diam aja di situ, karena dia cuma gambar yang ditempel.

Alternatif: Bayangan Palsu yang Bisa Gerak

Ada jalan tengah yang kurang realistis tapi bisa gerak: kita taruh bayangan sederhana di bawah bola, sedikit di atas lantai, terus bayangan itu kita gerakin ngikutin bolanya.

Texture-nya cuma lingkaran gradasi putih di tengah dan hitam di pinggir. Bagian putih bakal kelihatan, bagian hitam bakal hilang, sesuai cara kerja alphaMap.

Pertama, balikin lantainya ke material biasa:

const plane = new THREE.Mesh(
    new THREE.PlaneGeometry(5, 5),
    material
)

Terus load texture bayangan sederhananya:

const simpleShadow = textureLoader.load('/textures/simpleShadow.jpg')

Sekarang kita bikin bayangannya: satu bidang datar yang diputar rebah, materialnya hitam, dengan texture tadi sebagai alphaMap. Jangan lupa transparent: true, jebakan lama dari 10 Materials > 4. Opacity:

const sphereShadow = new THREE.Mesh(
    new THREE.PlaneGeometry(1.5, 1.5),
    new THREE.MeshBasicMaterial({
        color: 0x000000,
        transparent: true,
        alphaMap: simpleShadow
    })
)
sphereShadow.rotation.x = - Math.PI * 0.5
sphereShadow.position.y = plane.position.y + 0.01
 
scene.add(sphere, sphereShadow, plane)

Perhatiin + 0.01 itu, jangan dianggap remeh. Kalau bayangannya ditaruh di ketinggian yang sama persis sama lantainya, GPU bakal bingung mana yang di depan mana yang di belakang, terus muncul kedip-kedip aneh. Itu z-fighting yang udah kita bahas di 05 Camera > 3. Jarak Terdekat dan Terjauh dulu.

❌ salah, ketinggiannya sama persis, muncul z-fighting

sphereShadow.position.y = plane.position.y

✅ benar, dinaikin sedikit biar GPU-nya gak bingung

sphereShadow.position.y = plane.position.y + 0.01

Nah, sekarang bagian serunya. Kalau bolanya kita animasiin, bayangannya tinggal diikutin, dan opacity-nya diubah sesuai ketinggian bola. Makin tinggi bolanya, makin pudar bayangannya, persis kaya di dunia nyata:

const clock = new THREE.Clock()
 
const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()
 
    // Update the sphere
    sphere.position.x = Math.cos(elapsedTime) * 1.5
    sphere.position.z = Math.sin(elapsedTime) * 1.5
    sphere.position.y = Math.abs(Math.sin(elapsedTime * 3))
 
    // Update the shadow
    sphereShadow.position.x = sphere.position.x
    sphereShadow.position.z = sphere.position.z
    sphereShadow.material.opacity = (1 - sphere.position.y) * 0.3
 
    // ...
}
 
tick()

Bedah sedikit rumusnya:

  1. Math.cos dan Math.sin yang dipasangkan bikin gerakan melingkar, karena satu naik-turun duluan dan satunya belakangan. Dikali 1.5 biar radiusnya lebar.
  2. Math.abs(Math.sin(...)) bikin gerakan memantul. Math.sin itu naik-turun dari -1 ke 1, terus Math.abs bikin semua nilai minus jadi plus, jadi kaya bola yang mantul di lantai dan gak pernah nembus ke bawah.
  3. (1 - sphere.position.y) * 0.3 bikin bayangannya pudar waktu bolanya tinggi. Waktu bolanya nempel lantai (y = 0), opacity-nya 0.3 alias paling pekat. Waktu bolanya di puncak (y = 1), opacity-nya 0 alias hilang.

Gak realistis, tapi performanya enteng banget dan buat banyak project itu udah lebih dari cukup.

Teknik Mana yang Dipakai?

Gak ada jawaban tunggal. Tergantung project-nya, tergantung performa yang kamu kejar, dan tergantung teknik apa yang kamu kuasai.

Dan yang penting: ketiganya bisa dicampur. Misalnya bayangan lantai yang gak gerak dipanggang aja jadi texture, terus objek yang gerak dikasih bayangan palsu yang ngikutin. Hasilnya bagus dan tetap enteng.

Code Lengkap

import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import GUI from 'lil-gui'
 
/**
 * Debug
 */
const gui = new GUI()
 
/**
 * Base
 */
// Canvas
const canvas = document.querySelector('canvas.webgl')
 
// Scene
const scene = new THREE.Scene()
 
// Sizes
const sizes = {
    width: window.innerWidth,
    height: window.innerHeight
}
 
/**
 * Textures
 */
const textureLoader = new THREE.TextureLoader()
const simpleShadow = textureLoader.load('/textures/simpleShadow.jpg')
 
/**
 * Lights
 */
// Ambient light
const ambientLight = new THREE.AmbientLight(0xffffff, 1)
scene.add(ambientLight)
 
// Directional light
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5)
directionalLight.position.set(2, 2, - 1)
directionalLight.castShadow = true
directionalLight.shadow.mapSize.width = 1024
directionalLight.shadow.mapSize.height = 1024
directionalLight.shadow.camera.near = 1
directionalLight.shadow.camera.far = 6
directionalLight.shadow.camera.top = 2
directionalLight.shadow.camera.right = 2
directionalLight.shadow.camera.bottom = - 2
directionalLight.shadow.camera.left = - 2
scene.add(directionalLight)
 
const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
directionalLightCameraHelper.visible = false
scene.add(directionalLightCameraHelper)
 
/**
 * Materials
 */
const material = new THREE.MeshStandardMaterial()
material.roughness = 0.7
 
/**
 * Objects
 */
const sphere = new THREE.Mesh(
    new THREE.SphereGeometry(0.5, 32, 32),
    material
)
sphere.castShadow = true
 
const plane = new THREE.Mesh(
    new THREE.PlaneGeometry(5, 5),
    material
)
plane.rotation.x = - Math.PI * 0.5
plane.position.y = - 0.5
plane.receiveShadow = true
 
const sphereShadow = new THREE.Mesh(
    new THREE.PlaneGeometry(1.5, 1.5),
    new THREE.MeshBasicMaterial({
        color: 0x000000,
        transparent: true,
        alphaMap: simpleShadow
    })
)
sphereShadow.rotation.x = - Math.PI * 0.5
sphereShadow.position.y = plane.position.y + 0.01
 
scene.add(sphere, sphereShadow, plane)
 
/**
 * Camera
 */
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100)
camera.position.x = 1
camera.position.y = 1
camera.position.z = 2
scene.add(camera)
 
// Controls
const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true
 
/**
 * Renderer
 */
const renderer = new THREE.WebGLRenderer({
    canvas: canvas
})
renderer.setSize(sizes.width, sizes.height)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
 
/**
 * Animate
 */
const clock = new THREE.Clock()
 
const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()
 
    // Update the sphere
    sphere.position.x = Math.cos(elapsedTime) * 1.5
    sphere.position.z = Math.sin(elapsedTime) * 1.5
    sphere.position.y = Math.abs(Math.sin(elapsedTime * 3))
 
    // Update the shadow
    sphereShadow.position.x = sphere.position.x
    sphereShadow.position.z = sphere.position.z
    sphereShadow.material.opacity = (1 - sphere.position.y) * 0.3
 
    // Update controls
    controls.update()
 
    // Render
    renderer.render(scene, camera)
 
    // Call tick again on the next frame
    window.requestAnimationFrame(tick)
}
 
tick()

Ringkasan

  1. Bagian gelap di belakang objek itu core shadow dan itu otomatis dari lampu. Yang kita kejar di bab ini drop shadow, yaitu bayangan yang jatuh ke objek lain.
  2. ThreeJS bikin bayangan dengan ngerender ulang scene dari sudut pandang tiap lampu, semua materialnya diganti sementara jadi MeshDepthMaterial, hasilnya disimpan sebagai texture bernama shadow map.
  3. Ada 3 tempat yang harus dinyalain: renderer.shadowMap.enabled, castShadow/receiveShadow di objek, dan castShadow di lampu. Kelewat satu aja, bayangannya gak muncul.
  4. Cuma PointLight, DirectionalLight, dan SpotLight yang dukung bayangan. AmbientLight, HemisphereLight, dan RectAreaLight gak bisa sama sekali.
  5. Resolusi bawaan shadow map cuma 512x512, makanya pecah. Naikin lewat shadow.mapSize, dan wajib angka pangkat 2.
  6. shadow.camera itu kamera beneran, punya near dan far. Ngaturnya gak bikin lebih tajam, tapi memperbaiki bayangan yang hilang atau kepotong.
  7. CameraHelper motret nilai kamera saat dia dibuat, jadi atur dulu near/far-nya, baru bikin helper-nya.
  8. DirectionalLight pakai OrthographicCamera di baliknya, jadi amplitudonya diatur pakai top, right, bottom, left. Makin sempit areanya, makin tajam bayangannya, tapi kalau kesempitan bayangannya kepotong.
  9. shadow.radius itu blur murah yang gak mikirin jarak objek ke lantai, beda sama bayangan di dunia nyata.
  10. Ada 4 algoritma shadow map, bawaannya THREE.PCFShadowMap. radius gak jalan di THREE.PCFSoftShadowMap, jadi harus pilih salah satu.
  11. SpotLight pakai PerspectiveCamera di baliknya, dan fov-nya gak bisa diubah karena ditimpa angle milik spotlight-nya.
  12. PointLight itu paling mahal, karena harus ngerender 6 arah buat bikin shadow map berbentuk kubus. Jangan kebanyakan.
  13. Bayangan dari lampu yang berbeda gak nyatu dengan mulus, diurus sendiri-sendiri, dan gak banyak yang bisa kita lakuin soal itu.
  14. Baked shadow itu bayangan yang dipanggang ke dalam texture. Enteng dan realistis, tapi mati total, gak ikut gerak.
  15. Bayangan palsu pakai alphaMap di bidang datar itu jalan tengahnya: kurang realistis tapi bisa ngikutin objeknya dan opacity-nya bisa berubah sesuai ketinggian.
  16. Naikin bayangan palsu sedikit di atas lantai (+ 0.01) biar gak kena z-fighting.
  17. Math.cos + Math.sin bikin gerakan melingkar, dan Math.abs(Math.sin(...)) bikin gerakan memantul.
  18. Gak ada teknik yang paling benar, dan ketiganya boleh dicampur sesuai kebutuhan project.

Bab selanjutnya kita gabungin semua ilmu dari bab-bab sebelumnya jadi satu project utuh: rumah berhantu lengkap dengan kuburan, kabut, dan hantu yang melayang-layang.