September 27, 2026
Three.js Instancing: Merender 100.000 Objek.
Membedah kenapa 100.000 Mesh membuat browser megap-megap, lalu bagaimana InstancedMesh menyelesaikannya: instanceMatrix, instanceColor, frustum culling, raycasting per instance, menghapus instance, perbandingan dengan geometry merging dan BatchedMesh, sampai tips performa desktop dan mobile.
Bayangkan tumpukan jerami berisi 100.000 helai, dan setiap helai bisa diklik, diwarnai, dijatuhkan, lalu dicabut satu per satu. Kalau setiap helai dibuat sebagai THREE.Mesh biasa, tab browser akan terasa seperti slideshow bahkan di laptop yang kencang. Bukan karena GPU tidak kuat menggambar dua juta segitiga, melainkan karena CPU kewalahan menyuruh GPU menggambar.
Catatan ini membedah teknik yang membuat Haystack Hunt tetap bisa dimainkan di level Hard (100.000 helai jerami): instancing. Game kecil ini dibuat dalam sehari sebagai bagian dari seri One Day One Code , satu file HTML dengan Three.js r160 dari CDN, tanpa build step. Contoh kode di bawah diambil dari sana, lengkap dengan bagian yang kurang optimal dan cara memperbaikinya.
Masalah Sebenarnya: Draw Call
Setiap kali Three.js menggambar satu Mesh, renderer melakukan serangkaian pekerjaan di CPU:
- Menghitung ulang
matrixWorldobjek (updateMatrixWorld). - Mengecek apakah bounding sphere objek masuk view frustum kamera.
- Memasukkan objek ke render list dan mengurutkannya (opaque depan-ke-belakang, transparan belakang-ke-depan).
- Mengaktifkan program shader, mengikat buffer geometri, dan mengunggah uniform (
modelMatrix,normalMatrix, warna material). - Memanggil
gl.drawElementsataugl.drawArrays: inilah satu draw call.
Langkah 5 sendiri murah, tetapi di browser setiap panggilan WebGL melewati validasi dan, di banyak platform, diterjemahkan lagi oleh ANGLE ke Direct3D/Metal. Overhead per objek biasanya dalam orde mikrodetik. Dikalikan 100.000 objek, anggaran 16,6 ms per frame (60 FPS) sudah habis sebelum GPU mulai bekerja.
Sebaliknya, GPU sangat senang menerima satu perintah besar: “gambar geometri ini 100.000 kali”. Itulah instancing.
Aturan praktis: target di bawah beberapa ratus draw call per frame untuk desktop, dan lebih sedikit lagi untuk mobile. Cara mengukurnya akan dibahas di bagian performa.
InstancedMesh: Satu Geometri, Banyak Transformasi
InstancedMesh memakai ekstensi instanced drawing WebGL (drawElementsInstanced, bawaan WebGL2). Geometri dan material cukup satu; yang berbeda per instance hanyalah data kecil seperti matriks transformasi dan warna.
const strawGeo = new THREE.CylinderGeometry(0.035, 0.02, 1, 5, 1);
const mat = new THREE.MeshStandardMaterial({ roughness: 0.9, metalness: 0 });
const strawMesh = new THREE.InstancedMesh(strawGeo, mat, STRAW_COUNT);
Argumen ketiga adalah kapasitas maksimum. Ukuran buffer dialokasikan sekali di konstruktor; strawMesh.count boleh diturunkan belakangan (hanya sejumlah itu yang digambar), tetapi tidak boleh melebihi kapasitas awal. Untuk menambah kapasitas, buat InstancedMesh baru.
Helai jerami di sini sengaja low-poly: silinder dengan 5 segmen radial dan 1 segmen tinggi. Kalau dihitung dari cara CylinderGeometry membangun vertex, hasilnya 34 vertex dan 20 segitiga. Untuk 100.000 helai, GPU memproses sekitar 2 juta segitiga. Angka itu enteng untuk GPU modern, asal dikirim dalam satu draw call.
Di dalam shader
Ketika Three.js mendeteksi objek adalah InstancedMesh, shader dikompilasi dengan define USE_INSTANCING. Di vertex shader, setiap vertex dikalikan dengan matriks milik instance-nya sebelum modelMatrix:
// disederhanakan dari chunk bawaan Three.js
attribute mat4 instanceMatrix; // per instance, bukan per vertex
vec4 mvPosition = vec4(transformed, 1.0);
#ifdef USE_INSTANCING
mvPosition = instanceMatrix * mvPosition;
#endif
mvPosition = modelViewMatrix * mvPosition;
gl_Position = projectionMatrix * mvPosition;
instanceMatrix adalah atribut dengan divisor 1: nilainya maju satu langkah per instance, bukan per vertex. Karena mat4 menempati empat slot atribut, Three.js mengikatnya sebagai empat vec4.
instanceMatrix dan setMatrixAt
instanceMatrix adalah InstancedBufferAttribute berisi Float32Array sepanjang count * 16. Menulisnya dilakukan lewat setMatrixAt(index, matrix). Cara paling mudah menyusun matriks adalah memakai satu Object3D “dummy” yang dipakai ulang:
const tmp = new THREE.Object3D();
for (let i = 0; i < STRAW_COUNT; i++) {
const a = rng() * Math.PI * 2;
const r = Math.sqrt(rng()) * PILE_RADIUS; // sqrt: sebaran merata di lingkaran
const maxH = PILE_HEIGHT * Math.cos((r / PILE_RADIUS) * Math.PI * 0.5); // bentuk kubah
const h = rng() * Math.max(0.3, maxH);
const len = 0.8 + rng() * 1.5;
tmp.position.set(Math.cos(a) * r, h + 0.1, Math.sin(a) * r);
tmp.rotation.set((rng() - 0.5) * Math.PI, rng() * Math.PI * 2, (rng() - 0.5) * Math.PI);
tmp.scale.set(1, len, 1);
tmp.updateMatrix(); // position + quaternion + scale -> tmp.matrix
strawMesh.setMatrixAt(i, tmp.matrix);
}
strawMesh.instanceMatrix.needsUpdate = true;
Dua hal yang perlu diperhatikan:
- Jangan membuat
Object3DatauMatrix4baru di dalam loop. 100.000 alokasi objek memicu garbage collector dan membuat frame tersendat. Satu objek sementara di luar loop sudah cukup. needsUpdate = truedipasang sekali setelah loop, bukan di setiap iterasi. Flag ini hanya menandai bahwa buffer perlu diunggah ulang ke GPU pada render berikutnya.
Kenapa Math.sqrt(rng()) untuk radius? Kalau radius diambil seragam (rng() * R), titik akan menumpuk di tengah, karena luas cincin bertambah seiring radius. Akar kuadrat mengoreksi distribusinya supaya kepadatan merata.
Seeded RNG: menyimpan 100.000 objek dalam beberapa byte
rng di atas bukan Math.random, melainkan mulberry32 dengan seed:
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
Karena urutan angkanya deterministik, fitur “save progress” cukup menyimpan { seed, count, removed: [indeks...] } ke localStorage. Saat dilanjutkan, tumpukan dibangun ulang dengan seed yang sama dan helai yang tercatat langsung disembunyikan. Tidak perlu menyimpan 1,6 juta float. (Operasi >>> 0, | 0, dan Math.imul di sana adalah trik aritmetika 32-bit; latar belakangnya dibahas di Bit, Byte, dan Basis Angka di JavaScript
.)
Syaratnya satu: urutan pemanggilan rng() tidak boleh berubah antara versi game. Menambah satu rng() di tengah loop akan menggeser semua angka setelahnya, dan save lama akan menghasilkan tumpukan yang berbeda.
instanceColor dan Pitfall Kompilasi Shader
Warna per instance disimpan di instanceColor, InstancedBufferAttribute dengan 3 komponen (RGB) per instance:
strawMesh.instanceColor = new THREE.InstancedBufferAttribute(
new Float32Array(STRAW_COUNT * 3), 3
);
const col = new THREE.Color();
for (let i = 0; i < STRAW_COUNT; i++) {
col.setHex(strawColors[(rng() * strawColors.length) | 0]);
strawMesh.setColorAt(i, col);
}
strawMesh.instanceColor.needsUpdate = true;
setColorAt sebenarnya akan membuat instanceColor sendiri jika masih null, tetapi membuatnya eksplisit di awal punya alasan penting: shader dikompilasi berdasarkan fitur yang ada saat render pertama. Kalau instanceColor baru muncul setelah objek pernah dirender, program shader yang sudah ter-cache tidak punya define USE_INSTANCING_COLOR, sehingga warna per instance diabaikan sampai material dikompilasi ulang (material.needsUpdate = true). Aturan amannya: siapkan semua atribut instance sebelum renderer.render pertama.
Warna instance dikalikan dengan material.color. Biarkan material.color putih (default) supaya warna instance tampil apa adanya. Dengan Color Three.js, nilai hex dianggap sRGB dan dikonversi ke ruang warna linear saat disimpan, jadi warna yang terlihat tetap sesuai hex yang ditulis.
Highlight saat hover
Efek hover di Haystack Hunt memanfaatkan getColorAt/setColorAt untuk menyimpan warna asli lalu menggantinya dengan warna terang:
if (newHover !== hoverId) {
clearHover(); // kembalikan warna helai sebelumnya
if (newHover >= 0) {
strawMesh.getColorAt(newHover, hoverOrigColor);
strawMesh.setColorAt(newHover, HILITE);
strawMesh.instanceColor.needsUpdate = true;
hoverId = newHover;
}
}
Hanya satu helai yang berubah, tetapi needsUpdate = true membuat seluruh buffer warna (100.000 × 3 × 4 byte = 1,2 MB) diunggah ulang. Solusinya ada di bagian update parsial di bawah.
Frustum Culling pada InstancedMesh
Untuk Mesh biasa, Three.js membuang objek yang berada di luar pandangan kamera dengan mengecek bounding sphere-nya. Untuk InstancedMesh, pengecekan itu dilakukan sekali untuk seluruh kelompok: boundingSphere milik InstancedMesh mencakup semua instance, dihitung otomatis saat pertama kali dibutuhkan (frustum culling atau raycasting), lalu di-cache.
Konsekuensinya:
- Tidak ada culling per instance. Kalau satu helai saja terlihat, 100.000 helai tetap dikirim ke GPU. Vertex shader tetap berjalan untuk semuanya; yang di luar layar hanya dibuang saat clipping.
- Bounding sphere bisa basi. Jika instance dipindahkan jauh setelah sphere dihitung (misalnya partikel yang menyebar), sphere lama tidak lagi mencakup semuanya. Gejalanya klasik: seluruh objek tiba-tiba hilang saat kamera diputar ke sudut tertentu, dan raycast gagal mengenai instance yang jelas terlihat. Solusinya panggil
strawMesh.computeBoundingSphere()setelah perubahan besar, atau matikan culling denganstrawMesh.frustumCulled = falsejika objek memang hampir selalu terlihat.
Di Haystack Hunt, tumpukan jerami selalu berada di tengah layar dan helai hanya bergeser sedikit ke bawah saat “runtuh”, jadi sphere awal tetap valid.
Kalau scene jauh lebih besar dari layar (hutan, kota, medan), ada dua strategi:
- Chunking: bagi dunia menjadi grid, misalnya 16 × 16 petak, dengan satu
InstancedMeshper petak. Culling bekerja per petak; draw call naik dari 1 ke paling banyak 256, dan itu masih sangat wajar. BatchedMesh: kelas yang lebih baru di Three.js dengan propertiperObjectFrustumCulled(defaulttrue) dansortObjects. Culling dilakukan per instance di CPU, lalu hanya instance yang terlihat yang digambar.
Raycasting dan instanceId
Raycaster mendukung InstancedMesh secara langsung. Hasil intersection memiliki properti instanceId:
raycaster.setFromCamera(pointer, camera);
// jarum dicek duluan: menang tidak pernah terkena cooldown
if (raycaster.intersectObject(needle, true).length) { winGame(); return; }
const hit = raycaster.intersectObject(strawMesh)[0]; // hasil sudah terurut dari yang terdekat
if (hit && hit.instanceId != null && !removed[hit.instanceId]) {
removeStraw(hit.instanceId);
}
Perhatikan pengecekan hit.instanceId != null, bukan if (hit.instanceId): instance nomor 0 itu valid dan bernilai falsy.
Biaya raycast yang tersembunyi
Di balik layar, InstancedMesh.raycast bekerja kira-kira seperti ini:
- Cek sinar terhadap
boundingSphereseluruh kelompok. Kalau meleset, selesai. - Untuk setiap instance dari
0sampaicount - 1: ambil matriksnya, gabungkan denganmatrixWorld, lalu jalankan raycast Mesh biasa (cek bounding sphere geometri yang ditransformasi, lalu uji segitiga demi segitiga jika lolos).
Langkah 2 adalah loop linear di CPU. Untuk satu klik, 100.000 iterasi masih bisa diterima (beberapa milidetik). Tetapi untuk hover yang terpicu di setiap pointermove (bisa 60 sampai 120 kali per detik), biayanya menumpuk. Itulah alasan Haystack Hunt punya batas:
const HOVER_LIMIT = 20000; // di atas ini, raycast per gerakan mouse dilewati
renderer.domElement.addEventListener('pointermove', e => {
if (e.pointerType === 'touch') return; // touch tidak punya hover
if (STRAW_COUNT > HOVER_LIMIT) return; // tumpukan besar: hover terlalu mahal
// ... raycast + highlight
});
Opsi yang lebih baik kalau hover di 100.000 instance benar-benar dibutuhkan:
- Throttle raycast ke
requestAnimationFrame(maksimal sekali per frame), bukan per event. - Spatial index: simpan posisi instance di grid 3D atau octree, lalu hanya uji instance di sel yang dilalui sinar. Library
three-mesh-bvhmenyediakan BVH yang siap pakai. - GPU picking: render scene ke render target kecil dengan warna yang mengodekan ID instance, baca satu piksel di bawah kursor dengan
renderer.readRenderTargetPixels. Biayanya satu render tambahan, tetapi konstan terhadap jumlah objek.
Menghapus Instance
Tidak ada removeAt di InstancedMesh. Ada dua pola yang umum.
Pola 1: sembunyikan dengan matriks nol
Ini yang dipakai Haystack Hunt:
const ZERO = new THREE.Matrix4().makeScale(0, 0, 0);
function removeStraw(id) {
if (removed[id]) return;
removed[id] = 1; // Uint8Array, 1 byte per helai
strawMesh.setMatrixAt(id, ZERO); // semua vertex runtuh ke satu titik
strawMesh.instanceMatrix.needsUpdate = true;
}
Matriks skala nol membuat semua vertex instance itu jatuh ke titik yang sama, sehingga segitiganya berluas nol dan dibuang GPU saat rasterisasi. Kelebihannya: indeks tetap stabil. Instance nomor 4.217 akan selalu helai yang sama, dan itu yang membuat format save removed: [indeks...] bekerja.
Kekurangannya: vertex shader tetap berjalan untuk instance yang disembunyikan, dan raycast tetap mengiterasinya (meski langsung gagal di cek bounding sphere yang radiusnya nol). Untuk game di mana pemain mencabut paling banyak beberapa ratus helai, ini tidak masalah.
Pola 2: swap-with-last + count
Kalau instance sering dihapus dan jumlahnya menyusut drastis (peluru, partikel, musuh), cara yang lebih efisien adalah memindahkan instance terakhir ke slot yang dikosongkan, lalu mengurangi count:
const _m = new THREE.Matrix4();
const _c = new THREE.Color();
// slotOf[id] = posisi slot saat ini; idAt[slot] = id yang menempati slot itu
function removeInstance(mesh, id, slotOf, idAt) {
const slot = slotOf[id];
const last = mesh.count - 1;
if (slot !== last) {
mesh.getMatrixAt(last, _m);
mesh.setMatrixAt(slot, _m);
if (mesh.instanceColor) {
mesh.getColorAt(last, _c);
mesh.setColorAt(slot, _c);
}
const movedId = idAt[last];
idAt[slot] = movedId;
slotOf[movedId] = slot;
}
slotOf[id] = -1;
mesh.count = last; // instance terakhir tidak lagi digambar
mesh.instanceMatrix.needsUpdate = true;
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
}
Operasinya O(1), GPU benar-benar menggambar lebih sedikit, dan raycast mengiterasi lebih sedikit. Harganya: instanceId dari raycast sekarang adalah slot, bukan ID logis, sehingga harus diterjemahkan lewat idAt[hit.instanceId]. Lupa menerjemahkan adalah sumber bug yang sangat membingungkan: yang terhapus justru objek lain.
Update Parsial: Jangan Unggah 6,4 MB per Frame
Fitur “runtuh” di Haystack Hunt menjatuhkan helai yang berada di atas celah. Setiap frame, helai yang sedang jatuh ditulis ulang:
function updateSettle(dt) {
if (!settle.length) return;
let changed = false;
for (let i = settle.length - 1; i >= 0; i--) {
const s = settle[i];
// ... hitung langkah jatuh
_m.compose(sPos[s.id], sQuat[s.id], sScale[s.id]);
strawMesh.setMatrixAt(s.id, _m);
changed = true;
}
if (changed) strawMesh.instanceMatrix.needsUpdate = true;
}
Masalahnya: mungkin hanya 10 helai yang jatuh, tetapi needsUpdate mengunggah seluruh instanceMatrix, yaitu 100.000 × 16 × 4 byte = 6,4 MB setiap frame selama animasi berlangsung. Di GPU desktop ini masih lolos, di ponsel kelas menengah ini terasa sebagai patah-patah setiap kali jerami dicabut.
BufferAttribute punya addUpdateRange(start, count) untuk mengunggah sebagian saja (satuannya jumlah komponen float, bukan jumlah instance):
const attr = strawMesh.instanceMatrix;
attr.setUsage(THREE.DynamicDrawUsage); // harus sebelum render pertama
function markInstanceDirty(attr, index, itemSize) {
attr.addUpdateRange(index * itemSize, itemSize); // 16 float untuk matriks, 3 untuk warna
attr.needsUpdate = true;
}
// di updateSettle:
strawMesh.setMatrixAt(s.id, _m);
markInstanceDirty(attr, s.id, 16);
Renderer mengosongkan daftar range setelah upload. Setiap range bisa menjadi satu panggilan bufferSubData, jadi kalau yang berubah ribuan instance yang tersebar acak, satu upload penuh justru lebih murah. Aturan praktisnya: range untuk perubahan kecil dan jarang, upload penuh untuk perubahan besar.
DynamicDrawUsage memberi petunjuk ke driver bahwa buffer ini sering ditulis ulang. Usage tidak bisa diubah setelah buffer dipakai, jadi setel sejak awal.
Geometry Merging vs Instancing
Cara lain mencapai satu draw call adalah menggabungkan semua geometri menjadi satu BufferGeometry besar dengan BufferGeometryUtils.mergeGeometries (dulu bernama mergeBufferGeometries). Kapan memilih yang mana?
| Aspek | mergeGeometries | InstancedMesh | BatchedMesh |
|---|---|---|---|
| Draw call | 1 | 1 | 1 (multi-draw) |
| Geometri berbeda-beda | Ya | Tidak, harus sama | Ya |
| Memori untuk 100.000 helai | ± 100 MB (vertex digandakan) | ± 8 MB (matriks + warna) | Di antaranya |
| Memindahkan satu objek | Tulis ulang semua vertex-nya | Satu setMatrixAt | Satu setMatrixAt |
| Menghapus objek | Sulit (rebuild atau degenerate) | Matriks nol / swap + count | deleteInstance |
| Culling per objek | Tidak | Tidak | Ya (perObjectFrustumCulled) |
| Raycast mengembalikan | faceIndex (perlu dipetakan) | instanceId | batchId |
Hitungan memorinya: satu helai punya 34 vertex, masing-masing dengan posisi (12 byte), normal (12 byte), dan UV (8 byte), ditambah indeks. Digabung 100.000 kali, hasilnya sekitar 110 MB lebih sebelum indeks. Dengan instancing, geometrinya tetap satu (±1 KB) ditambah 64 byte matriks + 12 byte warna per instance, sekitar 7,6 MB total.
Merging cocok untuk objek statis dan beragam: dekorasi level, bangunan yang tidak pernah bergerak, teks yang sudah dijadikan mesh. Instancing cocok untuk objek seragam dan dinamis: rumput, pohon, partikel, jerami. BatchedMesh mengisi celah di tengah: beberapa bentuk berbeda yang tetap ingin dipindah dan di-cull satu per satu.
Tips Performa
Ukur dulu dengan renderer.info
console.log(renderer.info.render.calls); // draw call frame terakhir
console.log(renderer.info.render.triangles); // segitiga frame terakhir
console.log(renderer.info.memory); // { geometries, textures } di GPU
Dengan satu InstancedMesh dan shadow aktif, calls untuk jerami seharusnya 2 (satu untuk shadow map, satu untuk render utama), bukan 100.000. Kalau angkanya jauh lebih besar, ada objek yang tidak ter-instance. renderer.info.memory.geometries yang terus naik adalah tanda kebocoran: geometri atau material dibuat berulang tanpa dispose().
Pixel ratio
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
Ponsel modern punya devicePixelRatio 3. Pada rasio 3, fragment shader berjalan 9 kali lebih banyak dibanding rasio 1, dan 2,25 kali dibanding rasio 2. Perbedaan visual antara 2 dan 3 nyaris tak terlihat, perbedaan FPS sangat terasa. Untuk scene sangat berat, bahkan 1.5 layak dipertimbangkan.
Shadow: biaya yang sering tidak disadari
Shadow map berarti scene dirender dua kali: sekali dari sudut pandang cahaya ke tekstur kedalaman (di sini 2048 × 2048), sekali lagi dari kamera. Semua 2 juta segitiga jerami ikut di kedua pass. Ditambah PCFSoftShadowMap yang mengambil banyak sampel per piksel, bayangan bisa menghabiskan separuh frame. Haystack Hunt mematikannya untuk tumpukan besar:
const SHADOW_LIMIT = 20000;
const heavy = STRAW_COUNT > SHADOW_LIMIT;
strawMesh.castShadow = !heavy;
strawMesh.receiveShadow = !heavy;
Pilihan lain yang lebih halus: turunkan shadow.mapSize, persempit shadow.camera supaya hanya mencakup area tumpukan, pakai PCFShadowMap biasa, atau “panggang” bayangan statis ke tekstur tanah.
Hal kecil lain yang menumpuk
- Material:
MeshStandardMaterial(PBR) lebih mahal per piksel dibandingMeshLambertMaterial. Untuk ribuan objek kecil yang kasar seperti jerami, perbedaan visualnya tipis. - Segmen geometri: 5 segmen radial terlihat bulat dari kejauhan. Setiap segmen tambahan dikalikan 100.000.
- Loop O(n) per aksi: fungsi
collapseArounddi Haystack Hunt mengiterasi seluruh helai setiap kali satu helai dicabut. Pada 100.000 helai ini masih di bawah satu milidetik, tetapi spatial hash (grid XZ) akan menurunkannya ke puluhan helai saja. - Alokasi saat animasi: animasi helai terbang membuat
Meshbaru dan meng-clone material setiap pencabutan. Dengan cooldown 250 ms ini aman, tetapi untuk efek yang lebih sering, pakai object pool atau jadikan partikelnyaInstancedMeshkedua. - Render on demand: jika scene diam, tidak perlu render 60 kali per detik. Render hanya saat kamera bergerak (event
changedariOrbitControls) atau ada animasi aktif. Baterai laptop dan ponsel akan berterima kasih.
Mobile
Semua masalah di atas terasa dua kali lipat di ponsel: GPU lebih lemah, bandwidth memori lebih kecil, dan perangkat cepat panas lalu menurunkan clock (thermal throttling). Beberapa penyesuaian di Haystack Hunt:
- Tanpa hover di touch:
pointermovedenganpointerType === 'touch'diabaikan, sekaligus menghemat raycast. - Tap vs drag: sentuhan dianggap tap hanya jika jari bergeser kurang dari 14 piksel (mouse: 6 piksel), karena jari lebih goyah. Tanpa ini, memutar kamera dengan satu jari akan ikut mencabut jerami.
- Cooldown 250 ms antar pencabutan, mencegah spam tap yang memicu banyak raycast dan animasi sekaligus.
- Level adaptif: shadow dan hover otomatis dimatikan di atas 20.000 helai.
Yang layak ditambahkan untuk proyek yang lebih serius:
- Deteksi kemampuan GPU sederhana, misalnya mengukur waktu frame di beberapa detik pertama, lalu menurunkan pixel ratio atau jumlah instance secara otomatis.
- Menangani
webglcontextlostdi canvas. Browser mobile bisa membuang konteks WebGL saat tab di-background atau memori menipis, dan tanpa handler, layar tinggal hitam. new THREE.WebGLRenderer({ antialias: false })di perangkat dengan pixel ratio tinggi. Pada rasio 2 ke atas, tepi bergerigi sudah jauh berkurang, sementara MSAA menambah beban bandwidth.
Checklist
- Objek seragam dalam jumlah besar memakai
InstancedMesh, bukan ribuanMesh. -
Object3D/Matrix4/Colorsementara dibuat sekali di luar loop. -
instanceColordisiapkan sebelum render pertama. -
needsUpdatedipasang sekali setelah batch, danaddUpdateRangedipakai untuk perubahan kecil. -
computeBoundingSphere()dipanggil setelah instance berpindah jauh, ataufrustumCulled = falsedengan sadar. - Cek
instanceId != null, bukan truthy; terjemahkan slot ke ID jika memakai swap-with-last. - Raycast hover di-throttle atau dibatasi untuk jumlah instance besar.
-
renderer.info.render.callsdicek; pixel ratio dibatasi; shadow dievaluasi. - Mobile: tap slop, tanpa hover, dan penanganan context loss.
Kesimpulan
Instancing mengubah pertanyaan dari “berapa banyak objek yang sanggup digambar?” menjadi “berapa banyak data per objek yang perlu dikirim?”. Satu draw call untuk 100.000 helai jerami bukan trik sulap, melainkan cara GPU memang ingin diajak bekerja. Sisa pekerjaannya adalah menjaga CPU tetap ringan: tidak mengalokasikan di dalam loop, tidak mengunggah buffer penuh untuk perubahan kecil, dan tidak menjalankan raycast linear di setiap gerakan mouse.
Haystack Hunt masih punya ruang perbaikan (update parsial, spatial hash, pool untuk animasi), dan itu justru bagian yang menyenangkan dari proyek satu hari: mengirim yang cukup baik, lalu tahu persis apa yang akan dioptimasi berikutnya. Cerita di balik proyek iseng seperti ini ada di Proyek-Proyek Kegabutan .

Hey! I’m Fanny, the software engineer tending to this digital garden. You can read more about me, or subscribe by email.