Once you've got a rotating cube on screen, the next itch is making it look like an actual object instead of a flat-colored box. That's texturing, and it trips people up not because the API is hard but because a texture that looks wrong is almost always a lighting or color-space problem, not a loading problem.
Building the Base Mesh
A BoxGeometry already ships with UV coordinates baked in, so it's the easiest starting point for texturing before you move on to GLTFLoader for real assets exported from Blender. UVs are just a second coordinate space — each vertex gets a (u, v) pair between 0 and 1 that maps it to a point on the 2D texture image. If you ever build a custom BufferGeometry by hand, you're responsible for supplying that uv attribute yourself, or every texture you apply will map incorrectly or not at all.
Loading and Applying a Texture
TextureLoader.load() takes a URL and returns a Texture immediately, filling it in asynchronously — you don't need to wait for a promise to assign it to a material. Pass that texture as the map property on a MeshStandardMaterial, and add normalMap or roughnessMap for extra surface detail if your asset has them. The one property people forget: texture.colorSpace = THREE.SRGBColorSpace on the base color map, so three.js decodes the image's color data correctly instead of rendering it washed out or oddly dark.
import * as THREE from 'three';
const textureLoader = new THREE.TextureLoader();
const colorMap = textureLoader.load('/textures/crate_diffuse.jpg');
colorMap.colorSpace = THREE.SRGBColorSpace;
const normalMap = textureLoader.load('/textures/crate_normal.jpg');
const roughnessMap = textureLoader.load('/textures/crate_roughness.jpg');
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({
map: colorMap,
normalMap: normalMap,
roughnessMap: roughnessMap,
roughness: 0.8,
});
const crate = new THREE.Mesh(geometry, material);
scene.add(crate);
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
directionalLight.position.set(3, 4, 5);
scene.add(directionalLight);
Lighting So the Texture Actually Reads
MeshStandardMaterial is physically based, which means it needs light to show anything at all, and the quality of that light determines whether your texture reads as a real surface or a flat sticker. A single AmbientLight fills in shadows evenly but flattens detail; pair it with at least one DirectionalLight positioned off to one side so the normal map actually has something to react against. If a textured model still looks flat after adding lights, the normal map is usually either missing or applied with the wrong intensity — try material.normalScale.set(1, 1) and adjust from there.
- map — the base color/albedo texture
- normalMap — fakes surface bumps without adding geometry
- roughnessMap — controls how sharp or diffuse reflections look per-pixel
- metalnessMap — marks which areas read as metal vs dielectric
- aoMap — baked ambient occlusion; requires a second UV channel (uv2) to work
Forgetting colorSpace on the base color map is the single most common "why does my texture look wrong" bug. Data maps like normalMap and roughnessMap should NOT get SRGBColorSpace — they are not color data, and tagging them as sRGB will subtly corrupt the lighting math.
Wrapping Up
Texturing is where a three.js scene stops looking like a tech demo. Get the color space right, give the material real light to work with, and most of the "my model looks wrong" problems disappear before you ever need to touch a shader.