Ilmu dasar kita udah cukup buat bikin sesuatu yang beneran. Ini project pertama kita: teks 3D gede di tengah scene dengan objek-objek melayang di sekitarnya.

Kita bakal niru apa yang dibikin ilithya di portfolio-nya. Portfolio itu contoh bagus banget soal apa yang bisa kamu bikin padahal baru belajar ThreeJS sebentar. Sederhana, efektif, dan kelihatan keren.

ThreeJS udah dukung teks 3D lewat class TextGeometry. Masalahnya, kita wajib nyediain font-nya sendiri, dan font itu harus dalam format json khusus yang namanya typeface. Ini yang udah disinggung sekilas waktu kita bahas daftar geometry di 07 Geometry > Geometry Bawaan ThreeJS.

Soal lisensi gak kita bahas panjang, tapi pastiin kamu punya hak buat make font-nya kalau bukan buat keperluan pribadi.

Dapetin Font Typeface

Ada beberapa cara buat dapet font dalam format ini.

Cara pertama, konversi font yang kamu punya pakai converter kaya facetype.js. Tinggal masukin file font-nya, pencet tombol convert, selesai.

Cara kedua, ambil dari contoh bawaan ThreeJS yang ada di folder /node_modules/three/examples/fonts/. Font-font di situ bisa dipindah ke folder /static/, atau malah bisa langsung di-import di file JavaScript, karena isinya json dan file .json diperlakukan sama kaya .js di Vite:

import typefaceFont from 'three/examples/fonts/helvetiker_regular.typeface.json'

Kita bakal campur dua cara itu: buka folder /node_modules/three/examples/fonts/, ambil file helvetiker_regular.typeface.json beserta file LICENSE-nya, terus taruh dua-duanya di folder /static/fonts/ (foldernya bikin sendiri, belum ada).

Kenapa file LICENSE-nya ikut dibawa? Karena itu bukti hak pakai font-nya. Sekali file itu ketinggalan, kamu gak punya pegangan apa-apa waktu ditanya soal lisensi.

Sekarang font-nya bisa diakses cukup dengan nulis /fonts/helvetiker_regular.typeface.json, tanpa /static di depannya, persis kaya aturan folder statis yang kita bahas di 09 Textures > 1. Dapetin URL Gambarnya.

Load Font-nya

Buat load font, kita butuh class loader baru namanya FontLoader.

Class ini gak ada di dalam variabel THREE, jadi harus di-import sendiri, sama kaya waktu kita ngambil OrbitControls dulu:

import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js'

Cara kerjanya mirip TextureLoader. Tulis kode ini setelah bagian textureLoader (kalau kamu pakai font lain, jangan lupa ganti path-nya):

/**
 * Fonts
 */
const fontLoader = new FontLoader()
 
fontLoader.load(
    '/fonts/helvetiker_regular.typeface.json',
    (font) =>
    {
        console.log('loaded')
    }
)

Di Console harusnya muncul 'loaded'. Kalau nggak, balik lagi ke langkah sebelumnya dan cek error di Console, biasanya salah path atau salah huruf besar-kecil.

Nah ini beda pentingnya sama TextureLoader: di TextureLoader kita bisa langsung pakai hasilnya di luar, karena texture-nya bakal ngisi sendiri belakangan. Di FontLoader, font-nya cuma hidup di dalam fungsi callback itu, jadi semua kode teks kita harus ditulis di dalam situ.

❌ salah, font gak dikenal di luar fungsi callback-nya

fontLoader.load(
    '/fonts/helvetiker_regular.typeface.json',
    (font) =>
    {
        console.log('loaded')
    }
)
 
const textGeometry = new TextGeometry('Hello Three.js', { font: font })

✅ benar, semuanya ditulis di dalam callback

fontLoader.load(
    '/fonts/helvetiker_regular.typeface.json',
    (font) =>
    {
        const textGeometry = new TextGeometry('Hello Three.js', { font: font })
    }
)

Ini masalah scope yang sama kaya waktu kita bahas load gambar pakai JavaScript biasa.

Bikin Geometry-nya

Seperti yang tadi disebut, kita pakai TextGeometry. Sama kaya FontLoader, dia juga harus di-import sendiri:

import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js'

Hati-hati sama contoh kode di halaman dokumentasinya, angka-angka di situ jauh lebih gede dari ukuran scene kita, jadi nanti teksnya bakal segede gaban dan gak kelihatan apa-apa.

Pastiin kodenya ditulis di dalam fungsi callback:

fontLoader.load(
    '/fonts/helvetiker_regular.typeface.json',
    (font) =>
    {
        const textGeometry = new TextGeometry(
            'Hello Three.js',
            {
                font: font,
                size: 0.5,
                depth: 0.2,
                curveSegments: 12,
                bevelEnabled: true,
                bevelThickness: 0.03,
                bevelSize: 0.02,
                bevelOffset: 0,
                bevelSegments: 5
            }
        )
        const textMaterial = new THREE.MeshBasicMaterial()
        const text = new THREE.Mesh(textGeometry, textMaterial)
        scene.add(text)
    }
)

Parameternya sekilas:

  1. font, font hasil muatan tadi.
  2. size, ukuran hurufnya.
  3. depth, ketebalan teks ke arah sumbu z, ini yang bikin dia jadi 3D beneran.
  4. curveSegments, jumlah segitiga buat bagian yang melengkung. Konsepnya sama persis kaya subdivisions.
  5. bevelEnabled, nyalain lengkungan halus di pinggiran teks.
  6. bevelThickness, ketebalan lengkungan pinggir itu.
  7. bevelSize, seberapa jauh lengkungan itu keluar dari bentuk aslinya.
  8. bevelOffset, geseran lengkungannya.
  9. bevelSegments, jumlah segitiga buat lengkungan pinggir itu.

Sekarang harusnya muncul teks 3D putih yang masih perlu dipoles. Kubus lama yang cuma buat tes bisa dihapus.

Kalau mau lihat sesuatu yang seru, kasih wireframe: true kaya di 07 Geometry > 2. Wireframe:

const textMaterial = new THREE.MeshBasicMaterial({ wireframe: true })

Nah, kelihatan kan segitiganya banyak banget? Ini penting: bikin geometry teks itu kerjaan yang lama dan berat buat komputer. Jadi jangan bikin berkali-kali, dan tekan jumlah segitiganya serendah mungkin dengan ngecilin curveSegments dan bevelSegments. Dua itu tersangka utamanya.

Matiin lagi wireframe-nya kalau udah puas sama tingkat detailnya.

Nengahin Teksnya

Teksnya sekarang gak di tengah, melenceng ke kanan. Cara nengahinnya ada beberapa, dan kita bakal pakai cara yang paling ribet dulu biar dapet ilmunya, baru nanti kita kasih tau jalan pintasnya.

1. Kenalan sama Bounding

Bounding itu informasi yang nempel di geometry tentang seberapa besar ruang yang dia makan. Bentuknya bisa kotak, bisa bola. Bayangin kaya kardus imajiner yang pas banget ngebungkus objek kita.

Bounding ini gak kelihatan, tapi dia ngebantu ThreeJS ngitung dengan cepat apakah objeknya masih ada di dalam layar atau nggak. Kalau ternyata udah di luar layar, objeknya gak usah di-render sama sekali. Teknik ini namanya frustum culling, dan itu bahasan bab lain.

Bawaannya ThreeJS pakai bounding bola. Yang kita butuhin sekarang bounding kotak, karena lebih presisi buat teks yang bentuknya memanjang. Kita minta ThreeJS ngitungin pakai method computeBoundingBox():

textGeometry.computeBoundingBox()

Hasilnya bisa kita intip di properti boundingBox:

console.log(textGeometry.boundingBox)

Yang keluar itu objek bernama Box3, isinya properti min dan max. Perhatiin, nilai min-nya bukan 0 kaya yang mungkin kita kira. Itu gara-gara bevelThickness dan bevelSize tadi yang bikin geometry-nya nyembul dikit ke arah minus. Untuk sekarang abaikan aja.

2. Geser Geometry-nya, Bukan Mesh-nya

Sekarang ukurannya udah ketauan, tinggal digeser. Tapi yang kita geser itu geometry-nya, bukan mesh-nya.

Kenapa? Karena kalau yang digeser mesh-nya, posisi mesh itu jadi gak di tengah scene lagi, dan nanti pas kita mau muter atau nge-scale teksnya, titik putarnya jadi ngawur. Kalau geometry-nya yang digeser, mesh-nya tetap duduk manis di tengah scene, dan teksnya juga pas di tengah mesh itu. Dua-duanya beres.

Caranya pakai method translate(...) persis setelah computeBoundingBox():

textGeometry.computeBoundingBox()
textGeometry.translate(
    - textGeometry.boundingBox.max.x * 0.5,
    - textGeometry.boundingBox.max.y * 0.5,
    - textGeometry.boundingBox.max.z * 0.5
)

Logikanya: ambil ukuran totalnya, bagi dua, terus geser ke arah sebaliknya (makanya dikasih minus). Sama kaya kita mau naruh penggaris tepat di tengah meja, ya digeser setengah panjangnya.

Teksnya udah ketengah. Tapi kalau mau presisi banget, kita juga harus ngurangin nilai bevel-nya, karena itu tadi yang bikin min-nya gak nol:

textGeometry.translate(
    - (textGeometry.boundingBox.max.x - 0.02) * 0.5, // Dikurangi bevelSize
    - (textGeometry.boundingBox.max.y - 0.02) * 0.5, // Dikurangi bevelSize
    - (textGeometry.boundingBox.max.z - 0.03) * 0.5  // Dikurangi bevelThickness
)

3. Jalan Pintasnya

Semua yang barusan kita kerjain itu… sebenarnya bisa diganti satu baris pakai method center():

textGeometry.center()

Gampang banget kan? Terus ngapain tadi capek-capek? Biar kita ngerti soal bounding dan frustum culling. Kalau langsung dikasih center() dari awal, kita gak bakal pernah tau ada kardus imajiner yang nentuin objek kita dirender atau nggak.

❌ ribet, padahal ada jalan pintasnya

textGeometry.computeBoundingBox()
textGeometry.translate(
    - (textGeometry.boundingBox.max.x - 0.02) * 0.5,
    - (textGeometry.boundingBox.max.y - 0.02) * 0.5,
    - (textGeometry.boundingBox.max.z - 0.03) * 0.5
)

✅ simpel, hasilnya sama

textGeometry.center()

Kasih Material Matcap

Saatnya bikin teksnya keren. Kita pakai MeshMatcapMaterial karena tampilannya bagus tapi performanya enteng banget, cocok banget buat scene yang isinya bakal banyak objek.

Pilih dulu texture matcap-nya dari folder /static/textures/matcaps/, atau ambil dari kumpulan matcap ini. Jangan kelamaan milih! Dan kalau bukan buat keperluan pribadi, pastiin kamu berhak makainya. Gak perlu resolusi tinggi, 256x256 itu udah lebih dari cukup.

Load pakai textureLoader yang udah ada, dan jangan lupa colorSpace-nya karena matcap itu termasuk texture warna, sesuai aturan di 09 Textures > 2. Load Pakai JavaScript Biasa:

const matcapTexture = textureLoader.load('/textures/matcaps/1.png')
matcapTexture.colorSpace = THREE.SRGBColorSpace

Sekarang ganti MeshBasicMaterial yang jelek itu jadi MeshMatcapMaterial:

const textMaterial = new THREE.MeshMatcapMaterial({ matcap: matcapTexture })

Jadi deh, teks 3D dengan material yang kelihatan kena cahaya padahal di scene kita gak ada lampu satupun.

Nambah Objek Melayang

Sekarang kita tambahin objek-objek yang melayang di sekelilingnya. Kita bikin satu donat, tapi di dalam perulangan.

Tulis perulangannya di dalam fungsi callback, tepat setelah bagian teks:

for(let i = 0; i < 100; i++)
{
 
}

Sebenarnya ini bisa aja ditulis di luar callback, tapi sengaja kita taruh di dalam, karena nanti kita mau teks dan donatnya dibikin bareng-bareng pakai bahan yang sama. Alasannya baru kelihatan di bagian optimasi di bawah.

Di dalam perulangan, kita bikin TorusGeometry (nama teknisnya donat, kenalan di 07 Geometry > Geometry Bawaan ThreeJS), materialnya, dan mesh-nya:

for(let i = 0; i < 100; i++)
{
    const donutGeometry = new THREE.TorusGeometry(0.3, 0.2, 20, 45)
    const donutMaterial = new THREE.MeshMatcapMaterial({ matcap: matcapTexture })
    const donut = new THREE.Mesh(donutGeometry, donutMaterial)
    scene.add(donut)
}

Hasilnya: 100 donat numpuk semua di titik yang sama persis. Wajar, kita gak nyuruh mereka pindah ke mana-mana.

1. Posisi Acak

Kita sebar pakai trik yang persis sama kaya waktu kita nyebar segitiga acak dulu:

donut.position.x = (Math.random() - 0.5) * 10
donut.position.y = (Math.random() - 0.5) * 10
donut.position.z = (Math.random() - 0.5) * 10

Ingat lagi kenapa harus - 0.5: Math.random() cuma ngasih 0 sampai 1, semuanya positif, jadi kalau gak dikurangi setengah, semua donatnya numpuk di satu pojok dan gak ada yang nongol di belakang kita. Dikali 10 biar sebarannya lebar, dari -5 sampai 5.

2. Rotasi Acak

Rotasinya juga kita acak. Gak perlu ketiga sumbu, dua aja cukup. Dan karena donat itu bentuknya simetris, setengah putaran (Math.PI) udah cukup buat dapet semua variasi sudut yang mungkin. Radian ini yang udah kita kenal di 03 Transform Object > `rotate` Memutar Object:

donut.rotation.x = Math.random() * Math.PI
donut.rotation.y = Math.random() * Math.PI

3. Ukuran Acak

Terakhir ukurannya. Hati-hati di sini: kalau tiap sumbu dikasih nilai acak sendiri-sendiri, donatnya bakal jadi gepeng dan penyok gak karuan. Kita mau donatnya tetap berbentuk donat, cuma beda ukuran, jadi ketiga sumbunya harus pakai angka yang sama:

❌ salah, tiap sumbu beda nilai, donatnya jadi penyet

donut.scale.set(Math.random(), Math.random(), Math.random())

✅ benar, satu nilai dipakai buat ketiga sumbunya

const scale = Math.random()
donut.scale.set(scale, scale, scale)

Ini alasan kenapa scale yang kita pelajari di 03 Transform Object > `scale` Mengubah UkuranObject itu punya tiga sumbu terpisah, biar bisa dibikin penyet kalau emang mau, tapi kali ini kita gak mau.

Optimasi

Kode kita sekarang jalan, tapi boros banget.

Ingat pelajaran di 10 Materials > 1. Bikin 3 Objek dengan 1 Material: satu material bisa dipakai banyak mesh. Ternyata geometry juga sama, satu geometry bisa dipakai banyak mesh.

Sedangkan kode kita sekarang bikin geometry baru dan material baru 100 kali, padahal isinya sama persis semua. Itu 100 geometry dan 100 material nyampah di memori GPU tanpa guna, mirip kasus memory leak yang kita bahas dulu.

Solusinya: keluarin dari dalam perulangan.

❌ boros, tiap putaran bikin geometry dan material baru

for(let i = 0; i < 100; i++)
{
    const donutGeometry = new THREE.TorusGeometry(0.3, 0.2, 20, 45)
    const donutMaterial = new THREE.MeshMatcapMaterial({ matcap: matcapTexture })
    const donut = new THREE.Mesh(donutGeometry, donutMaterial)
    scene.add(donut)
}

✅ hemat, bikin sekali terus dipakai bareng-bareng

const donutGeometry = new THREE.TorusGeometry(0.3, 0.2, 20, 45)
const donutMaterial = new THREE.MeshMatcapMaterial({ matcap: matcapTexture })
 
for(let i = 0; i < 100; i++)
{
    const donut = new THREE.Mesh(donutGeometry, donutMaterial)
    scene.add(donut)
}

Tapi masih bisa lebih hemat lagi. Coba perhatiin, material teks dan material donatnya itu sama persis, sama-sama MeshMatcapMaterial dengan matcap yang sama.

Jadi kita hapus donutMaterial, ganti nama textMaterial jadi material aja, terus pakai buat dua-duanya:

const material = new THREE.MeshMatcapMaterial({ matcap: matcapTexture })
 
// ...
 
const text = new THREE.Mesh(textGeometry, material)
 
// ...
 
for(let i = 0; i < 100; i++)
{
    const donut = new THREE.Mesh(donutGeometry, material)
 
    // ...
}

Nah, ini alasan kenapa tadi perulangannya sengaja ditaruh di dalam fungsi callback: biar teks dan donatnya bisa pakai satu variabel material yang sama. Kalau donatnya dibikin di luar, dia gak bakal bisa nyentuh material yang lahir di dalam callback.

Masih bisa dioptimasi lebih jauh lagi sebenarnya, tapi itu ada babnya sendiri nanti.

Lanjut Sendiri

Kalau mau, kamu bisa nambah bentuk-bentuk lain selain donat, bikin mereka beranimasi pakai Clock, atau coba matcap yang beda-beda. Ini project kamu, mainin aja.

Code Lengkap

import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js'
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js'
 
/**
 * 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 matcapTexture = textureLoader.load('/textures/matcaps/1.png')
matcapTexture.colorSpace = THREE.SRGBColorSpace
 
/**
 * Fonts
 */
const fontLoader = new FontLoader()
 
fontLoader.load(
    '/fonts/helvetiker_regular.typeface.json',
    (font) =>
    {
        // Material
        const material = new THREE.MeshMatcapMaterial({ matcap: matcapTexture })
 
        // Text
        const textGeometry = new TextGeometry(
            'Hello Three.js',
            {
                font: font,
                size: 0.5,
                depth: 0.2,
                curveSegments: 5,
                bevelEnabled: true,
                bevelThickness: 0.03,
                bevelSize: 0.02,
                bevelOffset: 0,
                bevelSegments: 4
            }
        )
        textGeometry.center()
 
        const text = new THREE.Mesh(textGeometry, material)
        scene.add(text)
 
        // Donuts
        const donutGeometry = new THREE.TorusGeometry(0.3, 0.2, 20, 45)
 
        for(let i = 0; i < 100; i++)
        {
            const donut = new THREE.Mesh(donutGeometry, material)
 
            donut.position.x = (Math.random() - 0.5) * 10
            donut.position.y = (Math.random() - 0.5) * 10
            donut.position.z = (Math.random() - 0.5) * 10
 
            donut.rotation.x = Math.random() * Math.PI
            donut.rotation.y = Math.random() * Math.PI
 
            const scale = Math.random()
            donut.scale.set(scale, scale, scale)
 
            scene.add(donut)
        }
    }
)
 
/**
 * Camera
 */
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100)
camera.position.z = 3
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)
 
/**
 * Animate
 */
const clock = new THREE.Clock()
 
const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()
 
    // Update controls
    controls.update()
 
    // Render
    renderer.render(scene, camera)
 
    // Call tick again on the next frame
    window.requestAnimationFrame(tick)
}
 
tick()

Ringkasan

  1. Teks 3D dibikin pakai TextGeometry, dan wajib dikasih font dalam format json typeface. Font biasa (.ttf) gak bisa langsung dipakai.
  2. Font typeface bisa didapat dari converter online atau dari /node_modules/three/examples/fonts/. Bawa serta file LICENSE-nya sebagai bukti hak pakai.
  3. FontLoader dan TextGeometry gak ada di variabel THREE, dua-duanya harus di-import sendiri dari three/examples/jsm/.
  4. Beda dari TextureLoader, semua kode yang butuh font harus ditulis di dalam fungsi callback-nya, karena variabel font cuma hidup di dalam situ.
  5. Angka contoh di dokumentasi TextGeometry jauh lebih gede dari skala scene kita, jadi jangan disalin mentah-mentah.
  6. Bikin geometry teks itu berat buat komputer. Tekan jumlah segitiganya lewat curveSegments dan bevelSegments, dan jangan bikin berulang-ulang.
  7. bounding itu kardus (atau bola) imajiner yang membungkus geometry, dipakai ThreeJS buat frustum culling, yaitu gak me-render objek yang udah di luar layar.
  8. Bawaannya bounding itu bola, jadi kita minta versi kotaknya pakai computeBoundingBox(), hasilnya objek Box3 dengan properti min dan max.
  9. min-nya gak nol gara-gara bevelThickness dan bevelSize bikin geometry-nya nyembul dikit.
  10. Geser geometry-nya, bukan mesh-nya. Dengan begitu mesh tetap di tengah scene dan teksnya tetap di tengah mesh.
  11. Semua kerumitan nengahin itu bisa diganti satu baris textGeometry.center(). Cara panjangnya cuma buat ngerti bounding-nya.
  12. Matcap itu pilihan bagus buat scene rame, karena kelihatan kena cahaya tanpa perlu lampu sama sekali. Jangan lupa colorSpace = THREE.SRGBColorSpace.
  13. (Math.random() - 0.5) * 10 buat nyebar posisi ke arah negatif juga, kalau gak dikurangi setengah semua objeknya numpuk di satu pojok.
  14. Donat itu simetris, jadi Math.random() * Math.PI (setengah putaran) udah cukup buat variasi rotasinya, dan cukup dua sumbu aja.
  15. Ukuran acak wajib pakai satu nilai buat ketiga sumbunya, kalau beda-beda objeknya jadi penyet.
  16. Satu geometry dan satu material bisa dipakai banyak mesh sekaligus. Bikin di dalam perulangan itu boros dan nyampah di memori GPU.
  17. Perulangan sengaja ditaruh di dalam callback font biar donat dan teks bisa berbagi variabel material yang sama.

Bab selanjutnya kita akan belajar cara nge-deploy karya kita ke internet, biar bisa dilihat orang lain, bukan cuma jalan di localhost kita sendiri.