<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[FoliMesh Blog]]></title><description><![CDATA[Practical guides on 3D trees and vegetation for games and the web: LOD chains, impostors, instancing and forest performance in Godot, three.js, Unity and Unreal]]></description><link>https://folimesh.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 10:23:14 GMT</lastBuildDate><atom:link href="https://folimesh.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Trees and Forests in three.js: LOD, Instancing, KTX2 and Wind]]></title><description><![CDATA[A fast forest in three.js: triangle budgets, meshopt and KTX2, THREE.LOD, chunked InstancedMesh and BatchedMesh, cutout leaves, shadows and wind.]]></description><link>https://folimesh.hashnode.dev/trees-and-forests-in-three-js-lod-instancing-ktx2-and-wind</link><guid isPermaLink="true">https://folimesh.hashnode.dev/trees-and-forests-in-three-js-lod-instancing-ktx2-and-wind</guid><category><![CDATA[ThreeJS]]></category><category><![CDATA[WebGL]]></category><category><![CDATA[GameDev]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[FoliMesh]]></dc:creator><pubDate>Fri, 30 Oct 2026 17:28:43 GMT</pubDate><content:encoded><![CDATA[<p><em>I'm on the team behind FoliMesh, a browser-based tree generator. Everything below works with any trees and plain three.js.</em></p>
<p>A forest is usually the first thing that turns a browser game's 60 FPS into 15 and a 5 MB page into 150 MB. The web is the strictest platform: weak laptops and phones, a GPU shared with the browser, and every megabyte downloaded before the player sees anything. Here's how to build vegetation that survives those limits.</p>
<h2>The web budget</h2>
<p>Starting points for a mid-range laptop or phone — measure your own scene:</p>
<table>
<thead>
<tr>
<th>What</th>
<th>Comfortable target</th>
</tr>
</thead>
<tbody><tr>
<td>Draw calls per frame</td>
<td>a few hundred at most</td>
</tr>
<tr>
<td>Triangles per frame</td>
<td>0.5–2 million</td>
</tr>
<tr>
<td>One tree, near level</td>
<td>5,000–15,000 triangles</td>
</tr>
<tr>
<td>One tree, far level</td>
<td>under 1,500; impostor — 2</td>
</tr>
<tr>
<td>Download for the whole forest</td>
<td>a few MB, not tens</td>
</tr>
</tbody></table>
<p>Most of the forest is far away, so most of it must be cheap. Each LOD should be ~3–4× lighter than the previous one.</p>
<h2>Step 1. Start with a game-ready GLB</h2>
<p>Before writing rendering code, check the file has:</p>
<ul>
<li>an LOD chain with the same silhouette (ideally plus an impostor);</li>
<li>leaves as clustered cards — not thousands of single leaves, not a solid "broccoli";</li>
<li>pivot at the trunk base, real-world scale in metres;</li>
<li>simple collision — a trunk capsule, not the crown;</li>
<li>per-vertex wind weights.</li>
</ul>
<h2>Step 2. Make the file small: meshopt + KTX2</h2>
<ul>
<li><strong>Geometry</strong> — meshopt (or Draco).</li>
<li><strong>Textures</strong> — KTX2 (Basis Universal). Unlike PNG/JPEG it stays compressed in VRAM, saving both download and memory.</li>
</ul>
<pre><code class="language-bash">npx @gltf-transform/cli optimize tree.glb tree.min.glb --compress meshopt --texture-compress ktx2
</code></pre>
<p>(KTX2 in the CLI needs KTX-Software installed; <code>--texture-compress webp</code> works without it.)</p>
<pre><code class="language-js">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');
</code></pre>
<h2>Step 3. A few trees: THREE.LOD</h2>
<p>For a handful of trees near the player, built-in <code>THREE.LOD</code> is enough. The third argument is hysteresis against flicker:</p>
<pre><code class="language-js">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);
</code></pre>
<p>Its limit: every tree is still separate draw calls for bark and leaves. Past a few dozen trees you need instancing.</p>
<h2>Step 4. Many trees: instancing, in chunks</h2>
<p><code>InstancedMesh</code> draws thousands of copies in one draw call — but it's culled as a whole and has no LOD. Instances 500 m away or behind the camera are drawn at full detail.</p>
<p>Fix: split the world into chunks, one <code>InstancedMesh</code> per chunk × LOD level × material, and pick the level by distance:</p>
<pre><code class="language-js">const CHUNK = 32;              // metres — close to the LOD0 distance
const STARTS = [0, 35, 80, 160];
const CULL = 400;

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

function updateChunk(group, camera) {
  const d = camera.position.distanceTo(group.userData.center);
  group.userData.levels.forEach((parts, i) =&gt; {
    const visible = d &gt;= STARTS[i] &amp;&amp; d &lt; (STARTS[i + 1] ?? CULL);
    parts.forEach((im) =&gt; { im.visible = visible; });
  });
}
</code></pre>
<p>Run <code>updateChunk</code> a few times per second, not every frame. Keep chunks close to the LOD0 band — distance is measured to the chunk centre.</p>
<h3>BatchedMesh: the modern alternative</h3>
<p><code>BatchedMesh</code> draws many geometries with one material, culls each object separately, and <code>setGeometryIdAt</code> is exactly an LOD switch:</p>
<pre><code class="language-js">const bark = new THREE.BatchedMesh(maxTrees, maxVerts, maxIndices, barkMaterial);
const lodIds = barkLods.map((g) =&gt; bark.addGeometry(g));

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

// a few times per second:
for (const t of trees) {
  const d = camera.position.distanceTo(t.position);
  bark.setGeometryIdAt(t.id, lodIds[STARTS.findLastIndex((s) =&gt; d &gt;= s)]);
}
</code></pre>
<p>One BatchedMesh per material. Ready-made options: <code>@three.ez/instanced-mesh</code> and <code>@three.ez/batched-mesh-extensions</code>. And if a GLB uses <code>EXT_mesh_gpu_instancing</code>, GLTFLoader creates the InstancedMeshes for you.</p>
<h2>Step 5. Leaves: cutout, not transparency</h2>
<p>Most slow web forests are slow because of leaves.</p>
<pre><code class="language-js">leaves.material.transparent = false;
leaves.material.alphaTest = 0.5;
leaves.material.side = THREE.DoubleSide;
leaves.material.alphaToCoverage = true; // soft edges with antialias: true
</code></pre>
<ul>
<li>Cut cards to the cluster silhouette — on phones fill rate is often the real limit.</li>
<li><code>renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5))</code> is often the single biggest win on high-DPI phones.</li>
</ul>
<h2>Step 6. Shadows and wind</h2>
<p>Shadows draw the forest twice. Keep the shadow camera tight, use a moderate <code>shadow.mapSize</code>, and only near levels cast:</p>
<pre><code class="language-js">lod2Leaves.castShadow = false;
impostor.castShadow = false;
</code></pre>
<p>Wind belongs in the vertex shader. With per-vertex weights in the second UV channel (trunk bend in x, branch sway in y):</p>
<pre><code class="language-js">const time = { value: 0 };
geometry.setAttribute('wind', geometry.getAttribute('uv1'));

material.onBeforeCompile = (shader) =&gt; {
  shader.uniforms.uTime = time;
  shader.vertexShader = shader.vertexShader
    .replace('#include &lt;common&gt;', '#include &lt;common&gt;\nattribute vec2 wind;\nuniform float uTime;')
    .replace('#include &lt;begin_vertex&gt;', `#include &lt;begin_vertex&gt;
      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 loop:
time.value = clock.getElapsedTime();
</code></pre>
<p>Copying <code>uv1</code> under its own name avoids a clash with three.js's own <code>uv1</code> declaration.</p>
<h2>Step 7. Measure</h2>
<ul>
<li><code>renderer.info.render.calls</code> and <code>.triangles</code>;</li>
<li>the browser Performance panel — CPU or GPU bound?;</li>
<li>a real mid-range phone, not just your dev machine.</li>
</ul>
<p>Often one fix (cutout leaves, pixel ratio, shadow distance) beats all the others combined.</p>
<h2>Checklist</h2>
<ul>
<li><input disabled type="checkbox" /> GLB with LOD chain, clustered cards, root pivot, collision, wind weights</li>
<li><input disabled type="checkbox" /> meshopt geometry, KTX2 textures</li>
<li><input disabled type="checkbox" /> THREE.LOD for a few trees; chunked InstancedMesh or BatchedMesh for forests</li>
<li><input disabled type="checkbox" /> <code>alphaTest</code> + <code>alphaToCoverage</code>, not <code>transparent</code></li>
<li><input disabled type="checkbox" /> Shadows only on near levels, tight shadow camera</li>
<li><input disabled type="checkbox" /> Pixel ratio capped on phones</li>
<li><input disabled type="checkbox" /> Draw calls, triangles and frame time measured on a real device</li>
</ul>
<hr />
<p>If you'd rather not build all of this by hand: the <a href="https://folimesh.com/tree-generator?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=seeding">FoliMesh tree generator</a> runs in the browser and exports a GLB with LOD0–LOD3 plus a billboard, cluster-shaped leaf cards, wind weights in <code>uv1</code> and collision capsules in <code>userData</code> — at the triangle budgets you set.</p>
<p>What's the worst bottleneck you've hit with vegetation in three.js?</p>
]]></content:encoded></item></channel></rss>