../

Geometry & materials

What a mesh is made of in Three.js r18x: BufferGeometry and its attributes, the built-in shapes, custom and merged geometry, instancing with InstancedMesh and BatchedMesh, the material catalog and its shared properties, PBR maps, textures and color spaces, points, lines and sprites. Setup is in Fundamentals; lighting in Lights & shadows; custom shaders in Shaders & post-processing.

Mesh anatomy

 Mesh (Object3D: transform, layers, castShadow…)
 ├── geometry: BufferGeometry
 │     attributes: position (vec3), normal (vec3), uv (vec2),
 │                 color, tangent, uv1…uv3, skinIndex/Weight
 │     index?:     Uint16/Uint32 BufferAttribute
 │     groups:     [{ start, count, materialIndex }]
 │     boundingBox / boundingSphere (culling, raycasting)
 └── material: Material | Material[]  (one per group)
       → compiled to a shader program, cached by its settings

One Mesh = at least one draw call. Share geometries and materials between meshes freely; the renderer uploads each once.

BufferGeometry & attributes

AttributeitemSizeUsed for
position3required; local-space vertex positions
normal3lighting; missing = black lit materials
uv2texture coordinates (channel 0)
uv1, uv2, uv32extra UV sets; pick with texture.channel
color3 or 4per-vertex color; needs vertexColors: true
tangent4normal maps without derivatives; computeTangents()
skinIndex, skinWeight4skinning (from glTF)
CallNotes
setAttribute(name, attr) / getAttribute(name)typed as BufferAttribute | InterleavedBufferAttribute
setIndex(array | attr)share vertices between triangles
computeVertexNormals()averages face normals (smooth); non-indexed = flat
computeBoundingBox() / computeBoundingSphere()after moving vertices on the CPU
computeTangents()needs index, position, normal, uv
center(), translate(), rotateX(), scale()bake a transform into the vertices
applyMatrix4(m)bake any matrix (e.g. mesh.matrixWorld before merging)
setFromPoints(points)quick lines / point clouds from Vector3[]
toNonIndexed()unshare vertices (for flat normals, per-face data)
addGroup(start, count, matIndex)multi-material ranges
setDrawRange(start, count)draw only part (grow a line over time)
dispose()free GPU buffers
import {
  BufferAttribute, BufferGeometry, DynamicDrawUsage,
} from "three";
 
const geo = new BufferGeometry();
const pos = new BufferAttribute(
  new Float32Array(1000 * 3), 3,
);
pos.setUsage(DynamicDrawUsage); // updated often
geo.setAttribute("position", pos);
 
// later: change a vertex, then flag the upload
pos.setXYZ(42, 1, 2, 3);
pos.addUpdateRange(42 * 3, 3); // upload only this slice
pos.needsUpdate = true;
geo.computeBoundingSphere();   // keep culling correct

Float32BufferAttribute, Uint16BufferAttribute etc. are shorthands that accept plain arrays. Attribute arrays cannot grow: allocate the maximum, then use setDrawRange.

Built-in geometries

ClassConstructor (defaults)
BoxGeometry(w=1, h=1, d=1, wSeg=1, hSeg=1, dSeg=1)
PlaneGeometry(w=1, h=1, wSeg=1, hSeg=1): in XY, faces +Z
SphereGeometry(r=1, wSeg=32, hSeg=16, phiStart, phiLen, thetaStart, thetaLen)
CylinderGeometry(rTop=1, rBottom=1, h=1, radialSeg=32, hSeg=1, openEnded=false, …)
ConeGeometry(r=1, h=1, radialSeg=32, hSeg=1, openEnded=false, …)
CapsuleGeometry(r=1, height=1, capSeg=4, radialSeg=8, hSeg=1): height = middle section
CircleGeometry(r=1, segments=32, thetaStart, thetaLen)
RingGeometry(inner=0.5, outer=1, thetaSeg=32, phiSeg=1, …)
TorusGeometry(r=1, tube=0.4, radialSeg=12, tubularSeg=48, arc=2π, …)
TorusKnotGeometry(r=1, tube=0.4, tubularSeg=64, radialSeg=8, p=2, q=3)
IcosahedronGeometry, OctahedronGeometry, DodecahedronGeometry, TetrahedronGeometry(r=1, detail=0); raise detail for a geosphere
PolyhedronGeometry(vertices, indices, r, detail)
LatheGeometry(points: Vector2[], segments=12, …): revolve a profile
ShapeGeometry(shapes, curveSegments=12): flat 2D shape
ExtrudeGeometry(shapes, { depth, bevelEnabled, steps, extrudePath })
TubeGeometry(path: Curve, tubularSeg=64, r=1, radialSeg=8, closed=false)
EdgesGeometry(geometry, thresholdAngle=1): hard edges for LineSegments
WireframeGeometry(geometry): every edge

Addons add RoundedBoxGeometry, TextGeometry (with FontLoader), ConvexGeometry, DecalGeometry, ParametricGeometry under three/addons/geometries/.

import { Shape, ExtrudeGeometry } from "three";
 
const s = new Shape();
s.moveTo(0, 0).lineTo(2, 0).lineTo(2, 1)
  .quadraticCurveTo(1, 2, 0, 1).closePath();
const hole = new Shape()
  .absarc(1, 0.8, 0.25, 0, Math.PI * 2);
s.holes.push(hole);
const geo = new ExtrudeGeometry(s, {
  depth: 0.3, bevelEnabled: true, bevelSize: 0.03,
});
geo.center();

Custom geometry

// A quad from 4 shared vertices and 2 indexed triangles
const geo = new BufferGeometry();
geo.setAttribute("position", new Float32BufferAttribute([
  -1, -1, 0,   1, -1, 0,   1, 1, 0,   -1, 1, 0,
], 3));
geo.setAttribute("uv", new Float32BufferAttribute([
  0, 0,   1, 0,   1, 1,   0, 1,
], 2));
geo.setIndex([0, 1, 2,   0, 2, 3]); // CCW = front
geo.computeVertexNormals();          // +Z here
NormalsHow
Smoothshared (indexed) vertices, then computeVertexNormals()
Flat, per facetoNonIndexed() then computeVertexNormals(), or material.flatShading = true
Crease angletoCreasedNormals(geo, angle) from BufferGeometryUtils
Welding duplicatesmergeVertices(geo, tolerance) then recompute normals

Winding is counter-clockwise for the front face. Getting it backwards makes the triangle invisible under default side: FrontSide.

Merging geometry

Many static meshes with the same material → merge into one geometry, one draw call.

import { mergeGeometries } from
  "three/addons/utils/BufferGeometryUtils.js";
 
const parts: BufferGeometry[] = [];
for (const m of rocks) {
  m.updateWorldMatrix(true, false);
  parts.push(m.geometry.clone().applyMatrix4(m.matrixWorld));
}
const merged = mergeGeometries(parts, false);
// null if attribute sets or index usage differ
BufferGeometryUtilsDoes
mergeGeometries(geos, useGroups)concatenate; useGroups: true keeps a group per input for multi-material
mergeVertices(geo, tol)weld identical vertices, add an index
mergeAttributes(attrs)concatenate attributes
toCreasedNormals(geo, angle)hard edges above an angle
computeMikkTSpaceTangentsglTF-exact tangents (needs the mikktspace wasm)
estimateBytesUsed(geo)memory estimate
interleaveAttributes, deinterleaveGeometrylayout changes

Merged objects cannot be moved, hidden or picked individually. When they must be, instance instead.

InstancedMesh

One geometry + one material drawn count times with per-instance matrices (and optional colors): one draw call for thousands of objects.

import {
  Color, DynamicDrawUsage, InstancedMesh, Matrix4,
  Object3D,
} from "three";
 
const COUNT = 5_000;
const trees = new InstancedMesh(geo, mat, COUNT);
trees.instanceMatrix.setUsage(DynamicDrawUsage);
 
const dummy = new Object3D(); // reusable PRS helper
const c = new Color();
for (let i = 0; i < COUNT; i++) {
  dummy.position.set(rand(-50, 50), 0, rand(-50, 50));
  dummy.rotation.y = Math.random() * Math.PI * 2;
  dummy.scale.setScalar(rand(0.7, 1.3));
  dummy.updateMatrix();
  trees.setMatrixAt(i, dummy.matrix);
  trees.setColorAt(i, c.setHSL(0.3, 0.5, rand(.3, .6)));
}
trees.instanceMatrix.needsUpdate = true;
trees.instanceColor!.needsUpdate = true;
trees.computeBoundingSphere(); // culling + raycasting
scene.add(trees);
MemberNotes
setMatrixAt(i, m) / getMatrixAt(i, out)then instanceMatrix.needsUpdate = true
setColorAt(i, c) / getColorAt(i, out)creates instanceColor on first call; call it before the first render
countdraw only the first count (≤ the constructor max)
computeBoundingSphere()the default sphere covers the geometry at the origin; instances outside get culled
frustumCulledwhole-mesh culling; set false if bounds are unknown
setMorphAt(i, mesh)per-instance morph weights
raycast hit instanceIdwhich instance was picked
dispose()frees instance buffers

Hiding one instance: scale it to zero, or swap it with the last and decrement count.

BatchedMesh

Many different geometries sharing one material, drawn with multi-draw (WEBGL_multi_draw) where available. Per-instance matrices, colors and visibility; per-instance frustum culling and sorting.

import { BatchedMesh, Matrix4 } from "three";
 
// (maxInstances, maxVertices, maxIndices, material)
const batch = new BatchedMesh(1000, 50_000, 100_000, mat);
const boxId = batch.addGeometry(boxGeo);
const ballId = batch.addGeometry(sphereGeo);
 
const m = new Matrix4();
for (let i = 0; i < 500; i++) {
  const inst = batch.addInstance(i % 2 ? boxId : ballId);
  m.makeTranslation(i % 25, 0, Math.floor(i / 25));
  batch.setMatrixAt(inst, m);
}
scene.add(batch);
BatchedMeshNotes
addGeometry(geo, reservedVerts?, reservedIdx?)returns a geometry id; all geometries need the same attributes
addInstance(geometryId)returns an instance id
setMatrixAt, setColorAt, setVisibleAtper instance, no needsUpdate needed
setGeometryAt(id, geo), deleteGeometry, deleteInstanceedit the batch
optimize()compact after deletions
perObjectFrustumCulled, sortObjectsboth true by default
raycast hit batchIdthe instance id

Pick: InstancedMesh for many copies of one mesh, BatchedMesh for many distinct meshes with one material, merging for static geometry that never changes.

Materials

MaterialLighting modelRelative costUse for
MeshBasicMaterialnone (unlit)lowestUI, baked lighting, flat color, video
MeshLambertMaterialdiffuse only (per-pixel in current releases)lowmatte things, mobile
MeshPhongMaterialBlinn-Phong, shininess, specularlow–midglossy look without PBR
MeshToonMaterialstepped diffuse via gradientMaplow–midcel shading
MeshStandardMaterialPBR metal/roughnessmiddefault choice; what glTF uses
MeshPhysicalMaterialPBR + clearcoat, transmission, sheen, iridescence, anisotropy, dispersionhighcar paint, glass, fabric
MeshMatcapMaterialcolor looked up from a matcap by normallowsculpt previews; ignores lights
MeshNormalMaterialview-space normal → RGBlowdebugging
MeshDepthMaterialdepthlowshadow and depth passes
MeshDistanceMaterialdistance to a pointlowpoint-light shadow internals
ShadowMaterialtransparent except where shadowedlowshadow catcher on a ground plane
PointsMaterialunlitlowPoints clouds
LineBasicMaterial, LineDashedMaterialunlitlowLine, LineSegments
SpriteMaterialunlitlowSprite billboards
ShaderMaterial, RawShaderMaterialyoursyourscustom GLSL

MeshPhysicalMaterial with transmission > 0 renders the opaque scene an extra time into a texture; one glass object costs a whole extra pass. With WebGPURenderer use the *NodeMaterial twins (MeshStandardNodeMaterial etc.).

Common material properties

PropertyDefaultNotes
colorwhiteColorRepresentation: 0xff0000, "#f00", "red" (sRGB)
sideFrontSideBackSide, DoubleSide (planes, leaves, open meshes)
transparentfalseenables blending and back-to-front sorting per object
opacity1only has effect with transparent: true
alphaTest0discard below threshold: cheap cut-outs, no sorting
alphaHashfalsedithered transparency; order independent, noisy
alphaToCoveragefalseMSAA-smoothed cut-outs (needs antialias)
depthWritetruefalse for particles and glows so they don't occlude each other
depthTesttruefalse to draw on top (gizmos, with renderOrder)
blendingNormalBlendingAdditiveBlending for glow, fire, sparks
wireframefalsedebug; 1px lines
flatShadingfalsefaceted look from derivatives
vertexColorsfalsemultiply by the color attribute
fogtruerespond to scene.fog
toneMappedtruefalse for UI or colors that must match exactly
polygonOffset + polygonOffsetFactor/Unitsoffdecals, coplanar overlays
visibletruehides every mesh using this material
clippingPlanesnullneeds renderer.localClippingEnabled = true
needsUpdateset after changing something that alters the program (adding a map, flatShading, vertexColors)

Transparency rules of thumb: sorting is per object, not per triangle, so intersecting transparent meshes will glitch. Prefer alphaTest for foliage and fences, depthWrite: false + additive blending for particles, and renderOrder when you know the order.

PBR maps

MapColor spaceChannel readNotes
mapSRGBColorSpaceRGB(A)base color × color
emissiveMapSRGBColorSpaceRGB× emissive × emissiveIntensity; set emissive non-black
normalMapdata (none)RGBtangent space; normalScale (Vector2; flip Y with -1 for DirectX maps)
roughnessMapdataG× roughness
metalnessMapdataB× metalness
aoMapdataR× aoMapIntensity; indirect light only
bumpMapdataRcheap height-based normals; bumpScale
displacementMapdataRmoves vertices; needs dense geometry
alphaMapdataGgrayscale opacity; set transparent or alphaTest
lightMapdata (linear)RGBbaked light; lightMapIntensity
envMapper textureoverrides scene.environment for this material

Roughness, metalness and AO fit in one "ORM" texture (R=AO, G=roughness, B=metalness): assign the same texture to aoMap, roughnessMap and metalnessMap. Since r151 aoMap and lightMap read uv by default; the old "needs uv2" advice is obsolete (use texture.channel = 1 to pick uv1).

const tl = new TextureLoader();
const [albedo, orm, normal] = await Promise.all([
  tl.loadAsync("/crate/albedo.jpg"),
  tl.loadAsync("/crate/orm.jpg"),
  tl.loadAsync("/crate/normal.png"),
]);
albedo.colorSpace = SRGBColorSpace; // only color maps
const mat = new MeshStandardMaterial({
  map: albedo,
  normalMap: normal,
  aoMap: orm, roughnessMap: orm, metalnessMap: orm,
  roughness: 1, metalness: 1, // multipliers: keep 1
});

Textures

const tex = await new TextureLoader().loadAsync(url);
tex.colorSpace = SRGBColorSpace;
tex.wrapS = tex.wrapT = RepeatWrapping;
tex.repeat.set(4, 4);          // tile 4×4
tex.anisotropy =               // sharp at grazing angles
  renderer.capabilities.getMaxAnisotropy();
PropertyDefaultNotes
colorSpaceNoColorSpaceSRGBColorSpace for color, leave for data
wrapS / wrapTClampToEdgeWrappingRepeatWrapping, MirroredRepeatWrapping
repeat, offset, rotation, center1, 0, 0, 0UV transform; repeat > 1 needs RepeatWrapping
magFilterLinearFilterNearestFilter for pixel art
minFilterLinearMipmapLinearFilterNearestFilter or LinearFilter disable mipmaps
generateMipmapstruefalse for render targets and data you sample exactly
anisotropy1up to getMaxAnisotropy() (often 16)
flipYtrueTextureLoader images flipped for GL; glTF textures use false
channel0which UV set: 0 = uv, 1 = uv1 …
needsUpdateset after changing image or data
SourceClassNotes
Image URLTextureLoaderloadAsync(url); decodes on the main thread
Off-thread decodeImageBitmapLoaderreturns ImageBitmap; wrap in Texture; flipY is ignored
<canvas>CanvasTextureset needsUpdate after redrawing
<video>VideoTextureupdates each frame; colorSpace = SRGBColorSpace
Typed arrayDataTexture(data, w, h, format, type)noise, LUTs, heightmaps; needsUpdate = true
3D / arrayData3DTexture, DataArrayTexturevolumes, texture arrays
Six imagesCubeTextureLoaderskybox; order px, nx, py, ny, pz, nz
GPU compressedKTX2Loader (addon)Basis Universal → BCn / ETC / ASTC at runtime
HDRHDRLoader, EXRLoader, UltraHDRLoadersee environment maps

Memory. An uncompressed RGBA texture costs w × h × 4 bytes, plus a third for mipmaps: a 4096² texture is ~85 MB of VRAM regardless of the JPEG's file size. KTX2 (UASTC or ETC1S) stays compressed on the GPU (4–8× smaller). Use 2048² or less unless the texture fills the screen; WebGL2 handles non-power-of-two sizes including mipmaps.

import { KTX2Loader } from
  "three/addons/loaders/KTX2Loader.js";
 
const ktx2 = new KTX2Loader()
  // serve a copy of three/examples/jsm/libs/basis/
  .setTranscoderPath("/basis/")
  .detectSupport(renderer);
const tex = await ktx2.loadAsync("/textures/wood.ktx2");
tex.colorSpace = SRGBColorSpace;

Encode with the KTX-Software toktx / ktx create CLIs or gltf-transform (see Models).

Points

const N = 20_000;
const pos = new Float32Array(N * 3);
for (let i = 0; i < pos.length; i++) {
  pos[i] = (Math.random() - 0.5) * 20;
}
const geo = new BufferGeometry();
geo.setAttribute("position", new BufferAttribute(pos, 3));
 
const stars = new Points(geo, new PointsMaterial({
  size: 0.05,              // world units when attenuated
  sizeAttenuation: true,   // smaller with distance
  color: "#cfe3ff",
  map: dotTexture,         // round sprite, not squares
  alphaTest: 0.5, // or transparent + depthWrite: false
}));
scene.add(stars);

Points are square screen-aligned quads; give them a circular map or a shader. Max point size is driver-limited (often 64–1024 px). Raycasting uses raycaster.params.Points.threshold.

Lines

ObjectDraws
Linea connected strip through the vertices
LineLoopstrip + closing segment
LineSegmentspairs of vertices = separate segments (EdgesGeometry, grids)
Line2, LineSegments2 (addons)screen-space thick lines via instanced quads
const pts = curve.getPoints(100); // e.g. CatmullRomCurve3
const line = new Line(
  new BufferGeometry().setFromPoints(pts),
  new LineDashedMaterial({ dashSize: 0.2, gapSize: 0.1 }),
);
line.computeLineDistances(); // required for dashes

linewidth on LineBasicMaterial is ignored by practically every WebGL implementation (always 1px). For real widths use the addon:

import { Line2 } from "three/addons/lines/Line2.js";
import { LineGeometry } from
  "three/addons/lines/LineGeometry.js";
import { LineMaterial } from
  "three/addons/lines/LineMaterial.js";
 
const g = new LineGeometry().setFromPoints(pts);
const fat = new Line2(g, new LineMaterial({
  color: 0xffaa00,
  linewidth: 4,        // pixels (worldUnits: false)
}));
fat.computeLineDistances();

Sprites

A Sprite is a quad that always faces the camera: labels, markers, particles at small counts.

const label = new Sprite(new SpriteMaterial({
  map: labelTexture,        // e.g. a CanvasTexture
  sizeAttenuation: true,    // false: constant screen size
  depthTest: false,         // stay on top
}));
label.scale.set(2, 0.5, 1); // world size; match aspect
label.center.set(0.5, 0);   // anchor: bottom middle
label.position.set(0, 2, 0);
label.renderOrder = 10;

For thousands of billboards use Points or an InstancedMesh of planes with a shader; for crisp text use troika-three-text or HTML overlays (CSS2DRenderer addon).

Recipes

Instanced forest with colors

Use for any crowd of identical meshes (trees, rocks, bullets): one draw call, pickable per instance.

import {
  Color, ConeGeometry, InstancedMesh,
  MeshStandardMaterial, Object3D,
} from "three";
 
export function forest(count: number, size = 80) {
  const geo = new ConeGeometry(0.6, 2, 8).translate(0, 1, 0);
  const mat = new MeshStandardMaterial({ roughness: 0.9 });
  const mesh = new InstancedMesh(geo, mat, count);
  const d = new Object3D();
  const c = new Color();
  for (let i = 0; i < count; i++) {
    d.position.set(
      (Math.random() - 0.5) * size, 0,
      (Math.random() - 0.5) * size,
    );
    d.scale.setScalar(0.6 + Math.random() * 0.8);
    d.updateMatrix();
    mesh.setMatrixAt(i, d.matrix);
    mesh.setColorAt(i, c.setHSL(0.3, 0.45,
      0.25 + Math.random() * 0.2));
  }
  mesh.computeBoundingSphere();
  mesh.castShadow = true;
  return mesh;
}

Merge static scenery

Use for level geometry that never moves: hundreds of meshes become one per material.

import { Mesh } from "three";
import type { BufferGeometry, Material, Object3D }
  from "three";
import { mergeGeometries } from
  "three/addons/utils/BufferGeometryUtils.js";
 
export function mergeByMaterial(root: Object3D): Mesh[] {
  const buckets = new Map<Material, BufferGeometry[]>();
  root.updateMatrixWorld(true);
  root.traverse((o) => {
    const m = o as Mesh;
    if (!m.isMesh || Array.isArray(m.material)) return;
    const g = m.geometry.clone().applyMatrix4(m.matrixWorld);
    const list = buckets.get(m.material) ?? [];
    list.push(g);
    buckets.set(m.material, list);
  });
  return [...buckets].flatMap(([mat, geos]) => {
    const merged = mergeGeometries(geos);
    geos.forEach((g) => g.dispose());
    return merged ? [new Mesh(merged, mat)] : [];
  });
}

Typed PBR texture set

Use to load a material from a folder of maps with the right color spaces and sampling in one call.

import {
  MeshStandardMaterial, RepeatWrapping, SRGBColorSpace,
  TextureLoader,
} from "three";
import type { Texture, WebGLRenderer } from "three";
 
export async function pbr(
  dir: string, renderer: WebGLRenderer, repeat = 1,
): Promise<MeshStandardMaterial> {
  const tl = new TextureLoader();
  const load = (f: string) => tl.loadAsync(`${dir}/${f}`);
  const [map, normalMap, orm] = await Promise.all([
    load("albedo.jpg"), load("normal.png"), load("orm.jpg"),
  ]);
  map.colorSpace = SRGBColorSpace;
  const aniso = renderer.capabilities.getMaxAnisotropy();
  for (const t of [map, normalMap, orm] as Texture[]) {
    t.wrapS = t.wrapT = RepeatWrapping;
    t.repeat.set(repeat, repeat);
    t.anisotropy = aniso;
  }
  return new MeshStandardMaterial({
    map, normalMap,
    aoMap: orm, roughnessMap: orm, metalnessMap: orm,
  });
}

Text label sprite

Use for name tags and markers that should face the camera; draws text with Canvas 2D.

import {
  CanvasTexture, SRGBColorSpace, Sprite, SpriteMaterial,
} from "three";
 
export function textSprite(text: string, px = 64): Sprite {
  const c = document.createElement("canvas");
  const ctx = c.getContext("2d")!;
  ctx.font = `600 ${px}px system-ui`;
  c.width = Math.ceil(ctx.measureText(text).width) + 16;
  c.height = px * 1.4;
  ctx.font = `600 ${px}px system-ui`; // reset by resize
  ctx.fillStyle = "white";
  ctx.textBaseline = "middle";
  ctx.fillText(text, 8, c.height / 2);
  const tex = new CanvasTexture(c);
  tex.colorSpace = SRGBColorSpace;
  const s = new Sprite(new SpriteMaterial({
    map: tex, transparent: true, toneMapped: false,
  }));
  s.scale.set(c.width / c.height, 1, 1).multiplyScalar(0.3);
  return s;
}

Animate vertices on the CPU

Use for small procedural surfaces (flags, water tiles); for large ones move the work to a shader.

const geo = new PlaneGeometry(4, 4, 64, 64);
const pos = geo.attributes.position as BufferAttribute;
const base = Float32Array.from(pos.array);
 
function wave(t: number): void {
  for (let i = 0; i < pos.count; i++) {
    const x = base[i * 3];
    const y = base[i * 3 + 1];
    pos.setZ(i, Math.sin(x * 2 + t) * Math.cos(y * 2 + t)
      * 0.15);
  }
  pos.needsUpdate = true;
  geo.computeVertexNormals(); // lighting follows shape
}

References