PBR Textures in Three.js: Normal Maps, Seamless Tiles, and the Settings That Bite
Color maps are sRGB, data maps are linear — the one rule behind most 'PBR looks wrong' bugs. MeshStandardMaterial texture slots explained, plus a browser-only workflow for generating normal maps and seamless tiling textures from any photo.
Nothing exposes a texture pipeline like physically-based rendering. In a sprite game, a slightly-off texture still reads fine; under Three.js's MeshStandardMaterial, the same sloppiness produces washed-out albedo, plastic-looking surfaces, and normal maps that dent inward instead of bulging out. The good news is that nearly every "PBR looks wrong in Three.js" bug traces to a handful of texture settings — and all of them are covered here, along with a browser-only workflow for generating the normal maps and seamless tiles themselves.
The Slots, and the One Rule
MeshStandardMaterial takes several textures: map (color), normalMap (surface direction), roughnessMap and metalnessMap (reflectivity data), and aoMap (baked shadowing). The rule that governs all of them: color maps are sRGB, data maps are linear. In code, that means exactly one texture gets a color space assignment:
const color = loader.load("bricks_color.jpg");
color.colorSpace = THREE.SRGBColorSpace; // color data: tag it
const normal = loader.load("bricks_normal.png"); // data: leave it
const rough = loader.load("bricks_rough.png"); // data: leave it
const mat = new THREE.MeshStandardMaterial({
map: color, normalMap: normal, roughnessMap: rough
});
Skip the sRGB tag on the color map and the whole material looks pale and washed out. Apply it to the normal map and your lighting subtly breaks — the decoded vectors are wrong, so surfaces shade as if lit from odd angles. If a PBR material ever looks inexplicably flat or off-color, audit color spaces before touching anything else.
Getting a Normal Map When You Only Have a Photo
Downloaded and scanned textures usually arrive as a lone color image. You can still get convincing surface relief: the Normal Map Generator derives a normal map from the image's luminance, entirely in the browser, with a strength slider and live preview. It works best on materials where brightness tracks depth — brick, stone, bark, fabric weave, brushed metal. Two tuning notes from experience: generate at the texture's full resolution and let the engine mip it down, and be conservative with strength — you can amplify later on the material with normalScale, but relief baked in too hot flattens badly under grazing light. Judge the result under a moving light source; a static light hides inverted or exaggerated relief.
Tiling Surfaces Without Visible Seams
Grounds, walls, and terrain repeat one texture many times, which requires two things. First, wrapping:
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(8, 8); // tile 8×8 across the surface
Second — and this is the part no setting fixes — the texture itself must tile seamlessly. A non-seamless texture repeated 64 times turns its edge mismatch into a grid of visible lines across your entire floor. The Seamless Texture tool converts any image into a tileable version with a live tiled preview, so you can verify the repeat looks organic before it's in the scene. Watch for obvious repeating features too: a distinctive stain that recurs every tile reads as wallpaper. Apply the same repeat values to the color, normal, and roughness maps together, or the layers slide out of alignment.
The Settings That Bite
- Power-of-two sizes. WebGL2 relaxed the hard requirement, but 512/1024/2048 remains the safe default — mipmaps behave predictably and compressed GPU formats expect it.
- Anisotropy for floors. Ground planes viewed at grazing angles go blurry with default sampling. Set
tex.anisotropy = renderer.capabilities.getMaxAnisotropy()and the floor sharpens dramatically for near-zero cost on modern hardware. - aoMap needs a second UV set. Ambient occlusion maps sample a separate UV channel; on simple geometry you can copy
uvto it, and recent Three.js releases exposetexture.channelto select the set explicitly. - normalScale flips. If bumps look carved into the surface, your normal map's green channel uses the other convention — negate
normalScale.yrather than regenerating. - File size is a texture setting too. Four 2048² maps per material adds up fast on the wire. Run color maps through the Image Compressor (color textures tolerate lossy compression well; normal maps prefer lossless), and for texture-heavy production scenes, look at GPU-compressed KTX2/Basis via
KTX2Loader— smaller downloads and smaller VRAM.
A Complete Browser-Only Material Workflow
Putting it together: start from any source photo, make it tileable in the Seamless Texture tool, feed that result to the Normal Map Generator (the seamless version, so the normal map tiles too), compress the color map, and load the set with the color-space rule from the top of this post. Every step runs client-side — no uploads, no accounts — and the output drops straight into MeshStandardMaterial. The Three.js textures manual is the reference worth bookmarking for everything else the loader can do.