Skip to content

Textures

A Three.js guide for coding agents. Also covers texture loading, TextureLoader, LoadingManager, PBR textures, albedo map, color map, and 24 more.

Show all 30 aliases

texture loading, TextureLoader, LoadingManager, PBR textures, albedo map, color map, alpha map, height map, displacement texture, normal map texture, ambient occlusion map, metalness map, roughness map, UV coordinates, texture repeat, texture offset, texture rotation, RepeatWrapping, mipmaps, minFilter magFilter, NearestFilter, moire pattern texture, texture looks grey, texture looks washed out, sRGBColorSpace, how do I load a texture, how do I repeat a texture, how do I make pixelated textures, power of 2 texture size, texture optimization

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: true on 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 /static prefix. 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 Texture up front and flip needsUpdate when 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 Texture wrapper 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 on onLoad
  • 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 map and matcap are 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 BufferGeometry needs 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 repeat alone does not tile. The default wrap mode clamps, so the texture shrinks and its last pixel smears across the rest. Repeating requires wrapS/wrapT set to THREE.RepeatWrapping
  • THREE.MirroredRepeatWrapping flips 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. NearestFilter gives 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).

  • LinearFilter blurs a small texture stretched over a big surface, which is usually fine and unnoticed
  • NearestFilter preserves 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
  • NearestFilter is 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)

  • .jpg is lossy and light, .png is 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, 512x2048 all 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 .jpg color 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.SRGBColorSpace on the map texture
  • Setting sRGB color space on data maps (normal, roughness, etc.), which corrupts their values
  • Setting repeat without RepeatWrapping on wrapS/wrapT, so the texture clamps and smears instead of tiling
  • Rotating a texture and wondering why it pivots off-corner: center defaults 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 minFilter is NearestFilter: wasted GPU memory
  • Saving normal maps as JPEG: compression artifacts become lighting glitches
  • Applying a texture to a hand-built BufferGeometry with 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

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "textures" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems