FoliMesh
Blog

three.js'te Ağaçlar ve Ormanlar: LOD, Instancing, KTX2 ve Rüzgâr — Pratik Bir Rehber

Kare hızını ve indirme boyutunu öldürmeden bir three.js web oyununa gerçek bir orman nasıl eklenir: oyuna hazır GLB, sıkıştırma, THREE.LOD, InstancedMesh ve BatchedMesh, cutout yapraklar, gölgeler ve ucuz bir rüzgâr shader'ı.

·8 dk okuma#threejs#webgl#optimization#lod#tutorial

Giderek daha fazla oyun tarayıcı için yapılıyor — ve kodlarının giderek daha büyük bir kısmı, varsayılan olarak three.js'e yönelen yapay zekâ asistanlarıyla birlikte yazılıyor. Örneğin Vibe Jam 2026, indirme gerektirmeden açılan tarayıcı oyunları istiyor ve three.js'i öneriyordu. Er ya da geç bu oyunların her biri bir orman ister. Orman da genellikle 60 FPS'i 15'e, 5 MB'lık bir sayfayı 150 MB'a çeviren ilk şeydir.

Web, tüm platformların en katı olanıdır: zayıf dizüstü bilgisayarlar ve telefonlar, tarayıcıyla paylaşılan bir GPU ve oyuncu bir şey görmeden önce indirilmesi gereken her megabayt. Bu rehber, bu sınırlara dayanabilen bir bitki örtüsünün nasıl kurulacağını gösteriyor — adım adım, güncel three.js için kodlarla.

Web bütçesi

Kod yazmadan önce sınırları belirleyin. Bunlar orta segment bir dizüstü veya telefon için başlangıç noktalarıdır, kanun değil — kendi sahnenizi ölçün:

Ne Rahat hedef
Kare başına draw call en fazla birkaç yüz
Kare başına üçgen 0,5–2 milyon
Tek ağaç, yakın seviye 5.000–15.000 üçgen
Tek ağaç, uzak seviye 1.500 üçgenin altı; impostor — 2
Tüm ormanın indirme boyutu birkaç MB, onlarca değil

Temel fikir her motorda aynıdır: ormanın büyük kısmı uzaktadır, dolayısıyla ormanın büyük kısmı ucuz olmalıdır. Her detay seviyesi bir öncekinden kabaca 3–4 kat daha hafif olmalıdır.

Aynı meşe LOD0, LOD1, LOD2 ve LOD3'te Tek ağaç, dört detay seviyesi: siluet korunuyor, maliyet düşüyor.

Adım 1. Oyuna hazır bir GLB ile başlayın

Oyun için bir ağaç sadece güzel bir mesh değildir. Tek bir satır render kodu yazmadan önce dosyada şunların olduğunu kontrol edin:

  • aynı silueti koruyan bir LOD zinciri (ve ideal olarak en uzak seviye için bir impostor);
  • binlerce ayrı yaprak ya da katı bir "brokoli" kütlesi değil, kümeler halinde kartlar olarak yapraklar;
  • gövdenin dibinde bir pivot ve metre cinsinden gerçek ölçek;
  • basit çarpışma — taç için değil, gövde için bir kapsül;
  • vertex başına saklanan rüzgâr ağırlıkları.

Bunların hepsini tarayıcıda ücretsiz araçlarla kontrol edebilirsiniz: poligon sayacı, LOD görüntüleyici ve glTF doğrulayıcı.

Adım 2. Dosyayı küçültün: meshopt ve KTX2

İki sıkıştırma, tipik bir ağacı birkaç kat küçültür:

  • Geometri — meshopt (veya Draco) sıkıştırması.
  • Dokular — KTX2 (Basis Universal). PNG veya JPEG'in aksine KTX2 video belleğinde de sıkıştırılmış kalır; böylece hem indirmeden hem de VRAM'den tasarruf sağlar.

Bunu tarayıcıda glTF sıkıştırıcımız ve KTX2 dönüştürücümüzle ya da komut satırında glTF Transform ile yapabilirsiniz:

npx @gltf-transform/cli optimize tree.glb tree.min.glb --compress meshopt --texture-compress ktx2

(CLI'da KTX2 sıkıştırması için KTX-Software'in kurulu olması gerekir; --texture-compress webp onsuz da çalışır.)

Sıkıştırılmış bir dosyayı three.js'te yüklemek:

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';

const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
const loader = new GLTFLoader().setKTX2Loader(ktx2).setMeshoptDecoder(MeshoptDecoder);

const gltf = await loader.loadAsync('/models/oak.min.glb');

Adım 3. Birkaç ağaç: THREE.LOD

Oyuncunun yakınındaki bir avuç ağaç için — bir köy meydanı, bir vitrin ağacı — yerleşik THREE.LOD yeterlidir. Kameraya olan mesafeye göre alt nesnelerden birini gösterir; üçüncü argüman, sınırda titremeyi önleyen bir histerezistir:

const lod = new THREE.LOD();
lod.addLevel(lod0, 0);
lod.addLevel(lod1, 30, 0.1);
lod.addLevel(lod2, 70, 0.1);
lod.addLevel(impostor, 150, 0.1);
lod.position.set(x, 0, z);
scene.add(lod);

THREE.LOD, renderer sahneyi çizdiğinde otomatik olarak güncellenir. Sınırı şu: her ağaç yine ayrı bir nesnedir — gövde kabuğu ve yapraklar için ayrı draw call'lar. Birkaç düzine ağacı geçince instancing gerekir.

Adım 4. Çok sayıda ağaç: chunk'lar halinde instancing

InstancedMesh, bir mesh'in binlerce kopyasını tek bir draw call'da çizer. Ancak three.js onu tek bir nesne olarak ele alır: bütün olarak elenir ve LOD'u yoktur — 500 m uzaktakiler veya kameranın arkasındakiler dahil tüm instance'lar aynı detayda çizilir.

Çözüm Godot ve Unity'dekiyle aynıdır: dünyayı chunk'lara bölün ve her chunk, her LOD seviyesi ve her materyal için bir InstancedMesh oluşturun. Böylece her chunk kendi seviyesini mesafeye göre seçer:

const CHUNK = 32; // metre — LOD0 mesafesine yakın
const STARTS = [0, 35, 80, 160]; // her LOD'un başladığı yer
const CULL = 400;

function buildChunk(trees, lodMeshes) {
  // lodMeshes[i] = GLB'den alınan [barkMesh, leavesMesh]
  const levels = lodMeshes.map((parts) => parts.map((src) => {
    const im = new THREE.InstancedMesh(src.geometry, src.material, trees.length);
    trees.forEach((m, k) => im.setMatrixAt(k, m));
    im.computeBoundingSphere();
    return im;
  }));
  const group = new THREE.Group();
  levels.flat().forEach((im) => group.add(im));
  group.userData.levels = levels;
  group.userData.center = centerOf(trees); // bir THREE.Vector3
  return group;
}

function updateChunk(group, camera) {
  const d = camera.position.distanceTo(group.userData.center);
  group.userData.levels.forEach((parts, i) => {
    const visible = d >= STARTS[i] && d < (STARTS[i + 1] ?? CULL);
    parts.forEach((im) => { im.visible = visible; });
  });
}

updateChunk'ı her karede değil, her chunk için saniyede birkaç kez çağırın — mesafeler yavaş değişir. Chunk boyutu önemlidir: mesafe chunk'ın merkezine kadar ölçülür; bu yüzden LOD0 bandından çok daha büyük chunk'lar, oyuncunun hemen yanında kaba ağaçlar gösterir.

BatchedMesh modern alternatiftir. Tek bir materyalle çok sayıda farklı geometri çizer, her nesneyi ayrı ayrı eler (perObjectFrustumCulled varsayılan olarak açıktır) ve setGeometryIdAt ile herhangi bir instance'ın geometrisini değiştirmenize izin verir — ki LOD geçişi tam olarak budur:

const bark = new THREE.BatchedMesh(maxTrees, maxVerts, maxIndices, barkMaterial);
const lodIds = barkLods.map((g) => bark.addGeometry(g));

for (const t of trees) {
  t.id = bark.addInstance(lodIds[0]);
  bark.setMatrixAt(t.id, t.matrix);
}

// saniyede birkaç kez:
for (const t of trees) {
  const d = camera.position.distanceTo(t.position);
  const lod = STARTS.findLastIndex((s) => d >= s);
  bark.setGeometryIdAt(t.id, lodIds[lod]);
}

Materyal başına (gövde kabuğu, yapraklar) bir BatchedMesh kullanın. Hazır bir çözüm tercih ederseniz topluluk kütüphaneleri @three.ez/instanced-mesh (instancing için instance başına culling ve LOD) ve @three.ez/batched-mesh-extensions (BatchedMesh için LOD) bu işi sizin yerinize yapar.

Bir kısayol daha: dosyanız EXT_mesh_gpu_instancing glTF uzantısını kullanıyorsa GLTFLoader, InstancedMesh nesnelerini sizin için oluşturur — tek bir dosyada koca bir orman.

FoliMesh editöründe bir orman Instance'lanmış ağaçlardan bir orman: ağaç sayısı arttıkça draw call sayısı neredeyse hiç artmıyor.

Adım 5. Yapraklar: saydamlık değil, cutout

Web'deki yavaş ormanların çoğu yaprakları yüzünden yavaştır.

  • transparent: true değil, alphaTest kullanın. Saydam materyaller sıralanır ve harmanlanır (blend), derinlik yazmaz ve overdraw'u katlar. Cutout çok daha ucuzdur:
leaves.material.transparent = false;
leaves.material.alphaTest = 0.5;
leaves.material.side = THREE.DoubleSide;
leaves.material.alphaToCoverage = true; // renderer'da antialias: true olduğunda yumuşak kenarlar
  • Kartların şekline dikkat edin. Yuvarlak bir yaprak kümesinin etrafındaki kare bir kart çok sayıda görünmez pikseli gölgelendirir. Kümenin silüetine göre kesilmiş kartlar ciddi miktarda fill rate kazandırır — ve telefonlarda asıl sınır çoğu zaman fill rate'tir.
  • Zayıf cihazlarda çözünürlüğü düşürün. renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5)), yüksek DPI'lı telefonlarda yaprak örtüsü için çoğu zaman tek başına en büyük kazançtır.

Adım 6. Gölgeler ve rüzgâr

Gölgeler ormanı ikinci kez çizer. Gölge kamerasını oyuncunun etrafında dar tutun, makul bir shadow.mapSize kullanın ve yalnızca yakın seviyelerin gölge düşürmesine izin verin:

lod2Leaves.castShadow = false;
impostor.castShadow = false;

Rüzgârın yeri vertex shader'dır. Model rüzgâr ağırlıklarını vertex başına saklıyorsa — örneğin ikinci UV kanalının x'inde gövde bükülmesi, y'sinde dal salınımı — standart bir materyale onBeforeCompile ile hareket ekleyebilirsiniz:

const time = { value: 0 };
geometry.setAttribute('wind', geometry.getAttribute('uv1')); // GLB'den gelen TEXCOORD_1

material.onBeforeCompile = (shader) => {
  shader.uniforms.uTime = time;
  shader.vertexShader = shader.vertexShader
    .replace('#include <common>', '#include <common>\nattribute vec2 wind;\nuniform float uTime;')
    .replace('#include <begin_vertex>', `#include <begin_vertex>
      float sway = sin(uTime * 1.3 + position.y * 0.2);
      transformed.x += (sway * wind.x * 0.25 + sin(uTime * 2.7 + position.x) * wind.y * 0.1);
      transformed.z += sway * wind.x * 0.1;`);
};

// render döngüsünde:
time.value = clock.getElapsedTime();

Attribute'u kendi adıyla kopyalamak, bir materyal ikinci bir UV seti kullandığında uv1'i kendisi tanımlayan three.js ile çakışmayı önler.

Adım 7. Ölçün

  • renderer.info.render.calls ve renderer.info.render.triangles — kare başına draw call ve üçgen sayısı;
  • tarayıcının Performance paneli — sınırın CPU mu yoksa GPU mu olduğu;
  • gerçek bir orta segment telefon — sadece geliştirme makineniz değil.

Her değişiklikten önce ve sonra ölçün. Çoğu zaman tek bir düzeltme (cutout yapraklar, pixel ratio, gölge mesafesi) diğerlerinin toplamından fazlasını kazandırır.

Kontrol listesi

  • LOD zinciri, kümeler halinde yaprak kartları, kökte pivot, çarpışma ve rüzgâr ağırlıkları olan bir GLB.
  • Geometri meshopt ile, dokular KTX2 ile sıkıştırılmış.
  • Yakındaki birkaç ağaç için THREE.LOD; ormanlar için chunk'lı InstancedMesh veya BatchedMesh.
  • Yapraklarda transparent değil, alphaTest ve alphaToCoverage.
  • Gölgeler yalnızca yakın seviyelerde; dar gölge kamerası.
  • Telefonlarda pixel ratio sınırlandırılmış.
  • Draw call'lar, üçgenler ve kare süresi gerçek bir cihazda ölçülmüş.

FoliMesh bunun neresinde

FoliMesh, tarayıcıda çalışan ve oyun bütçeleri etrafında tasarlanmış bir ağaç ve doğa üreticisidir. Her seviye için bir üçgen bütçesi belirlersiniz; o da LOD0–LOD3 ve en uzak mesafe için çapraz bir billboard içeren bir GLB, kümelerinin şekline göre kesilmiş yaprak kartları, kökte bir pivot, TEXCOORD_1'de rüzgâr ağırlıkları (three.js bunu uv1 olarak okur) ve node'un extras alanında LOD mesafeleri ile çarpışma kapsülleri (three.js'te object.userData) dışa aktarır. Koca ormanlar, GLTFLoader'ın instance'lı mesh'lere dönüştürdüğü EXT_mesh_gpu_instancing ile tek bir GLB olarak dışa aktarılabilir.

FoliMesh'in diğer araçlarla nasıl kıyaslandığını görmek için 3D ağaç üreticileri karşılaştırmamızı okuyun. Godot ile mi çalışıyorsunuz? Godot 4'te hızlı bir orman nasıl yapılır yazımıza göz atın.

SSS

three.js'te LOD var mı? Evet, THREE.LOD alt nesneler arasında mesafeye göre geçiş yapar. Bir InstancedMesh içinde instance başına çalışmaz — bunun için chunk'lar, setGeometryIdAt ile BatchedMesh ya da @three.ez/instanced-mesh gibi bir kütüphane kullanın.

InstancedMesh mi, BatchedMesh mi? Tek bir mesh'in çok sayıda kopyası için en basit ve en hızlı olanı InstancedMesh'tir. Nesne başına culling ve nesne başına LOD istediğinizde ya da tek materyalli çok sayıda farklı mesh olduğunda BatchedMesh daha iyidir.

Ağaçlarımın yaprakları neden siyah görünüyor veya yanlış sıralanıyor? Genellikle materyal transparent: true olduğu için. DoubleSide ile alphaTest'e geçin; sıralama sorunları kaybolur ve performans artar.

Ağaçlar için KTX2'ye değer mi? Evet. Gövde kabuğu ve yaprak dokuları çoğu zaman indirmenin ve video belleğinin en büyük kısmını oluşturur; KTX2 ikisini de azaltır.