Core Philosophy
- A texture is an image mapped onto a geometry's surface. Color is only one use: textures also drive transparency, relief, fake surface detail, shadowing, and reflectivity
- The PBR (Physically Based Rendering) texture set is the standard vocabulary. Learn the map types once and they apply across Three.js, Blender, Unity, and every modern engine
- Three costs govern every texture decision: download weight, GPU memory footprint, and data fidelity. Optimize all three, not just file size
The PBR texture types
Each map is a grayscale or color image whose pixels mean something specific:
- Color (albedo): raw pixel colors applied to the surface. The only map besides matcap that must be flagged as sRGB
- Alpha: grayscale, white is visible, black is invisible. Needs
transparent: trueon the material - Height (displacement): grayscale, moves actual vertices to create relief. Useless without enough subdivisions, since only vertices can move
- Normal: adds fake surface detail by lying to the lighting about face orientation. Vertices never move, so it is the cheap way to add detail without subdividing. The purple-blue image
- Ambient occlusion: grayscale, fakes shadow in crevices. Not physically accurate, but adds contrast and depth
- Metalness: grayscale, white is metal, black is not. Drives reflection
- Roughness: grayscale, white is rough (diffuses light, like carpet), black is smooth (mirror-like reflection, like still water)
Pitfall: height moves geometry and needs vertices, normal fakes it and needs none. Prefer normal maps for detail, reserve displacement for silhouette-changing relief.
Loading with TextureLoader
One loader instance handles any number of textures:
const textureLoader = new THREE.TextureLoader()
const colorTexture = textureLoader.load('/textures/door/color.jpg')
colorTexture.colorSpace = THREE.SRGBColorSpace
const material = new THREE.MeshBasicMaterial({ map: colorTexture })
- The texture is usable immediately and renders transparent until the image arrives, then updates itself
.load()accepts three optional callbacks after the path: load, progress, error. Add them when a texture silently fails to appear, the error callback is the fastest way to spot a bad path
textureLoader.load(
'/textures/door/color.jpg',
() => { console.log('loaded') },
() => { console.log('progress') },
() => { console.log('error') }
)
- With a Vite-style setup, files in
/static/are referenced by path without the/staticprefix. Files in/src/are imported like JS modules. The static-folder behavior is bundler configuration, not a Three.js feature, so verify it when changing build tools
Manual loading (the underlying mechanism)
TextureLoader wraps this pattern. Knowing it explains why textures update asynchronously:
const image = new Image()
const texture = new THREE.Texture(image)
image.addEventListener('load', () => { texture.needsUpdate = true })
image.src = '/textures/door/color.jpg'
- Create the
Textureup front and flipneedsUpdatewhen the image lands. This avoids the scope trap of creating the texture inside the load callback where nothing else can reach it - A raw image cannot be used directly: WebGL needs a GPU-friendly format and mipmap generation, which is what the
Texturewrapper provides
LoadingManager: one set of events for all assets
When loading many textures (or mixing loaders), pass a shared LoadingManager so you get global lifecycle events instead of per-texture callbacks:
const loadingManager = new THREE.LoadingManager()
loadingManager.onStart = () => { console.log('started') }
loadingManager.onLoad = () => { console.log('all loaded') }
loadingManager.onProgress = () => { console.log('progressing') }
loadingManager.onError = () => { console.log('error') }
const textureLoader = new THREE.TextureLoader(loadingManager)
- This is the mechanism behind loading screens: show the loader on
onStart, hide it ononLoad - The same manager works with other loader types (models, cube textures), so one manager can gate the entire asset load
Color space: the greyish texture fix
- Symptom: a color texture renders washed out and greyish. Cause: the image is sRGB-encoded but Three.js treats it as linear
- Fix: flag it explicitly
colorTexture.colorSpace = THREE.SRGBColorSpace
- The rule: only textures used on
mapandmatcapare sRGB and need this flag. Data textures (alpha, height, normal, ao, metalness, roughness) encode values, not colors, and must stay linear. Setting sRGB on a data map corrupts its values
UV coordinates
- Wrapping a 2D image around a 3D shape requires UV unwrapping: every vertex gets a 2D coordinate on a flat plane, like unfolding an origami. The texture stretches or squeezes accordingly
- Inspect them at
geometry.attributes.uv - Three.js primitives (Box, Sphere, Cone, Torus) ship with generated UVs. Custom
BufferGeometryneeds UVs authored by hand, and geometry from a 3D package needs unwrapping there (auto-unwrap usually suffices) - UV space runs 0 to 1, with
(0, 0)at the bottom left of each face
Transforming: repeat, offset, rotation
repeat and offset are Vector2s, rotation is radians:
colorTexture.repeat.x = 2
colorTexture.repeat.y = 3
colorTexture.wrapS = THREE.RepeatWrapping // x axis
colorTexture.wrapT = THREE.RepeatWrapping // y axis
- Gotcha: setting
repeatalone does not tile. The default wrap mode clamps, so the texture shrinks and its last pixel smears across the rest. Repeating requireswrapS/wrapTset toTHREE.RepeatWrapping THREE.MirroredRepeatWrappingflips the texture on each repeat, which hides seams on non-tileable images
colorTexture.offset.x = 0.5 // shifts UVs
colorTexture.rotation = Math.PI * 0.25
- Rotation pivots around UV
(0, 0), the bottom-left corner, by default. To rotate around the middle, move the pivot:
colorTexture.center.x = 0.5
colorTexture.center.y = 0.5
Mipmaps and filtering
- Mipmapping generates successively half-sized copies of the texture down to 1x1. The GPU picks the closest size for the surface's on-screen footprint. Three.js does this automatically
- Two filters control sampling, set per texture:
minFilter (texture larger than its on-screen area)
Six values: NearestFilter, LinearFilter, NearestMipmapNearestFilter, NearestMipmapLinearFilter, LinearMipmapNearestFilter, and the default LinearMipmapLinearFilter.
- The default is the safe blurry-but-smooth choice.
NearestFiltergives sharper distant detail but produces moire artifacts on high-frequency patterns like checkerboards. Test with a checkerboard when diagnosing shimmer
magFilter (texture smaller than its on-screen area)
Only NearestFilter and LinearFilter (default).
LinearFilterblurs a small texture stretched over a big surface, which is usually fine and unnoticedNearestFilterpreserves hard pixel edges: the Minecraft look. This is the correct setting for pixel art and for tiny data textures (like toon gradient maps) where blending between pixels destroys the intent
colorTexture.magFilter = THREE.NearestFilter
Skip mipmaps when Nearest is the minFilter
NearestFilter on minFilter never reads mipmaps, so stop generating them and reclaim GPU memory:
colorTexture.generateMipmaps = false
colorTexture.minFilter = THREE.NearestFilter
NearestFilteris also the cheapest filter overall, a small free performance win when the look allows it
Format, size, and optimization
Three axes to weigh when preparing textures:
Weight (download)
.jpgis lossy and light,.pngis lossless and heavy. Compress aggressively with tools like TinyPNG, users download every byte
Size (GPU memory)
- Every pixel lives in GPU memory regardless of file compression, and mipmaps add roughly a third more on top. Use the smallest resolution that still looks right
- Width and height must each be a power of 2 (
512x512,1024x1024,512x2048all work) so mipmaps can halve cleanly to 1x1. A non-power-of-2 texture gets stretched to the nearest power of 2, looks worse, and logs a console warning
Data (what the pixels must preserve)
- Transparency needs an alpha channel: use
.png, or keep a.jpgcolor map and a separate grayscale alpha map - Normal maps must be lossless. JPEG artifacts in the red/green/blue channels become visible lighting glitches, so normal maps are
.png(or another lossless format), always
Sourcing textures
- Common sources: poliigon.com, 3dtextures.me, arroway-textures.ch. Verify the license before commercial use
- Or author your own: photo-based in a 2D editor, or procedural with Substance Designer
Common mistakes
- Color texture looks grey or washed out: missing
colorSpace = THREE.SRGBColorSpaceon themaptexture - Setting sRGB color space on data maps (normal, roughness, etc.), which corrupts their values
- Setting
repeatwithoutRepeatWrappingonwrapS/wrapT, so the texture clamps and smears instead of tiling - Rotating a texture and wondering why it pivots off-corner:
centerdefaults to(0, 0), set it to(0.5, 0.5) - Using a displacement map on a low-subdivision geometry and seeing nothing (or garbage): only vertices can move
- Shipping non-power-of-2 textures: silent stretch, quality loss, console warning
- Keeping mipmap generation on when
minFilterisNearestFilter: wasted GPU memory - Saving normal maps as JPEG: compression artifacts become lighting glitches
- Applying a texture to a hand-built
BufferGeometrywith no UV attribute and getting nothing - Debugging a texture that never appears without wiring the error callback or LoadingManager
onError, which would have named the bad path immediately