Every 3D model you have seen so far in this course has been plain gray. This chapter fixes that. You will learn how a game engine decides what a surface actually looks like: the raw color, whether it looks like metal or plastic, whether it looks smooth or scratched up, and how tiny bumps are faked without adding a single extra triangle. This is the job of a technical artist almost every day — not painting pretty pictures for their own sake, but understanding exactly which texture drives which part of the look, so a scene can be both correct and fast.
We will build this up piece by piece: what a material actually is, how a texture gets wrapped onto a lumpy 3D shape, and then the full standard set of PBR (Physically Based Rendering) texture maps used by tools like Substance Painter and Substance Designer. By the end you should be able to look at a game character or prop and say, out loud, "that shine is coming from the roughness map, that edge wear is the metallic map, and those tiny cracks are the normal map" — not as a guess, but because you know exactly what each map does.
Three words get mixed up constantly by beginners: texture, shader, and material. They are three different things, and understanding the difference makes everything else in this chapter click into place.
A texture is just an image — a 2D grid of pixels, each one a color, stored in memory. A photo of a brick wall saved as a .png file is a texture. Nothing more mysterious than that.
A shader is a small program that runs on the GPU (Graphics Processing Unit — the chip built specifically to draw pixels fast). The shader's job is to decide, for every pixel on screen, what color that pixel should be, based on inputs like the direction of the light, the angle of the camera, and whatever textures it has been given. A shader by itself does not know which texture to use — it just has a slot ("plug in a texture here") waiting to be filled.
A material is a shader with the slots actually filled in: this specific texture goes in this slot, this specific number goes in that slot. The same shader can produce a rusty pipe or a shiny plastic toy — same code, different material data plugged into it.
A useful analogy: the shader is a recipe (a function with named ingredients). The material is that recipe filled out with real ingredients (this brand of flour, this many grams of sugar). The texture is a single ingredient (one bag of flour).
using UnityEngine;
public class MakeRustyMetal : MonoBehaviour
{
public Texture2D rustTexture;
void Start()
{
// Find a shader: a GPU program, not yet filled in with any data.
Shader litShader = Shader.Find("Universal Render Pipeline/Lit");
// A Material = a shader + the actual textures and numbers it uses.
Material rustyMetal = new Material(litShader);
rustyMetal.SetTexture("_BaseMap", rustTexture);
rustyMetal.SetFloat("_Metallic", 1.0f); // fully metal
rustyMetal.SetFloat("_Smoothness", 0.2f); // rough, not shiny
GetComponent<Renderer>().material = rustyMetal;
}
}
This script does not print anything to the console. What happens instead: every time the GPU draws a pixel that belongs to this object, it runs the Lit shader's code, and that code reads _BaseMap, _Metallic, and _Smoothness from this particular material. The result is a dull, gray-brown, non-shiny surface — instead of the shader's plain gray default — even though not a single line of the shader's own code changed.
A texture is a flat 2D image. A game model is a lumpy 3D shape. So how does the engine know which pixel of that flat image belongs on, say, the model's left elbow? It needs a mapping — for every point on the 3D surface, "which pixel of the 2D texture goes here?"
That mapping is stored as two extra numbers attached to every vertex (corner point) of the mesh, called U and V (the letters are used instead of X/Y so they are never confused with the model's actual 3D position). U goes across the texture image, left to right, from 0 to 1. V goes up the image, bottom to top, from 0 to 1. Together, a (U, V) pair is a single coordinate inside the flat texture image — this is called UV space.
UV unwrapping is the process — done by hand in a 3D tool like Blender or Maya, or automatically by an algorithm — of cutting a 3D mesh along certain edges and laying it flat, the same way you would cut open a cardboard box so it lies flat on a table. Every triangle of the mesh ends up placed somewhere inside that flat 0..1 by 0..1 square, ready for a texture to be painted onto it.
The edges you cut along to flatten the model are called seams. Wherever a seam exists, the texture can visibly fail to line up across it, the same way a wrapped gift shows a visible strip of tape. A good UV layout hides its seams somewhere hard to notice — inside an armpit, along a hemline, at the back of the head.
You can inspect a mesh's UVs directly in code — they are just numbers, one pair per vertex:
using UnityEngine;
public class InspectUVs : MonoBehaviour
{
void Start()
{
Mesh mesh = GetComponent<MeshFilter>().sharedMesh;
Vector2[] uvs = mesh.uv; // one UV coordinate per vertex
Debug.Log("Vertex count: " + mesh.vertexCount);
Debug.Log("First vertex UV: " + uvs[0]);
}
}
Vertex count: 24
First vertex UV: (0.00, 0.67)
A default Unity cube has 24 vertices, not 8 — each of the 6 faces needs its own 4 corner vertices, because each face needs its own independent UV coordinates (a shared corner vertex could not point at six different spots in the texture at once). The first vertex sits at U=0, V=0.67: "when shading this corner, sample the texture at 0% across and 67% up."
A single pixel of a texture, as it appears mapped onto a 3D surface, is called a texel (short for "texture element," the texture-space equivalent of a screen pixel). Texel density is how many texels cover a fixed real-world distance on the model — usually written as pixels per meter, e.g. 256 px/m.
Texel density matters because it controls sharpness. If two neighboring props in a scene have very different texel density, the low-density one will look noticeably blurrier than everything around it — or, the other way around, a high-density prop wastes texture memory on detail nobody will ever get close enough to see. Keeping texel density consistent across a scene means nothing looks oddly soft or oddly over-sharp compared to its neighbors.
float ComputeTexelDensity(int textureResolutionPx, float uvIslandFraction, float worldSizeMeters)
{
float texturePixelsUsed = textureResolutionPx * uvIslandFraction;
return texturePixelsUsed / worldSizeMeters; // pixels per meter
}
// A 1024px texture, where this object's UV island uses half of it,
// covering 2 meters of the object in the game world:
float density = ComputeTexelDensity(1024, 0.5f, 2f);
Debug.Log(density);
256
That worked example gives 256 pixels per meter: 1024 * 0.5 = 512 texture pixels are spread across 2 meters of surface, which is 512 / 2 = 256 px/m. If a neighboring wall panel used the exact same 1024px texture but only had a UV island covering 10% of it stretched over 4 meters, its density would be 1024 * 0.1 / 4 = 25.6 px/m — noticeably blurrier when the two panels sit side by side.
PBR stands for Physically Based Rendering: a way of writing lighting shader math so that surfaces respond to light using formulas grounded in the real physics of how light actually scatters off rough surfaces and reflects off metal — instead of older tricks that just looked "good enough" under one specific lighting setup and broke under any other.
Because the shader math is now standardized (roughly the same formulas across Unity, Unreal, and tools like Substance), artists paint a standard, well-known set of texture maps to feed that math, and the material looks physically correct under any lighting condition it is dropped into — sunlight, a dark cave, a colored studio light. That standard set is:
The next six sections cover each of these in depth. Here is what the same object looks like broken into each individual map — this is exactly the view Substance Painter shows you while painting, one channel at a time:
The base color map (also called albedo, from the Latin word for "whiteness," meaning how much light a surface reflects) stores the raw color of a material with all lighting removed: no baked-in shadows, no baked-in highlights, no baked-in dirt darkening from ambient occlusion. Just "what color is this material, seen in flat, perfectly even light."
That last part trips up a lot of beginners moving from traditional painting. A base color map is not a finished-looking painting — it should look almost flat and a little boring on its own, because the shader is going to add all the lighting, shadow, and highlight on top of it in real time. If you paint a shadow directly into the base color map, and the real-time shader also casts a shadow there, you get a double-dark patch that looks wrong the moment the light angle changes.
#if UNITY_EDITOR
using UnityEditor;
void FixImportSettings(string path, bool isColorData)
{
TextureImporter importer = (TextureImporter)AssetImporter.GetAtPath(path);
// true for BaseColor/Emission (real color), false for Normal/Roughness/
// Metallic/AO/Height (raw data, not meant to look like a color at all).
importer.sRGBTexture = isColorData;
importer.SaveAndReimport();
}
#endif
That last comment matters more than it looks. Human eyes do not perceive brightness in a straight line — a texture meant to be seen as color (base color, emission) is stored using an sRGB curve (a standard brightness curve that matches how eyes perceive light, so colors look right on screen) and must be imported with the "sRGB" flag on. A texture that stores raw numeric data instead of a real color — a normal map, a roughness map — must be imported with sRGB turned off, or the GPU "un-warps" numbers that were never meant to be warped in the first place, and the data comes out wrong.
In 3D graphics, a normal is a direction that points straight out and away from a surface. Lighting math needs to know which way a surface faces in order to compute how bright it should look — a surface facing the light is bright, a surface facing away is dark. A single flat triangle has exactly one normal direction for its entire face.
Real surfaces are not flat. Brick, skin, and wood grain are covered in millions of microscopic bumps, each one tilted at a slightly different angle. Modeling every one of those bumps as actual triangles would need an absurd amount of geometry for something the camera will only ever see as a subtle texture. A normal map solves this without adding a single triangle: it is a texture that does not store color at all — it stores a direction, one fake normal per pixel, hidden inside the image's Red, Green, and Blue channels.
Normal maps are almost always stored in tangent space: the R, G, B channels encode a direction relative to the surface's own local left/right axis (called the tangent), its own local up/down axis (called the bitangent), and its own local straight-out axis (the real geometric normal) — not the world's fixed X/Y/Z axes. This is what lets one normal map texture be reused anywhere: the same brick bump pattern can tile around a curved wall, and at every point the shader converts that tangent-space direction into the correct world direction using the surface's own local axes at that exact pixel.
// Fragment shader excerpt (HLSL, Unity URP style)
half3 SampleBump(float2 uv, half3 tangentWS, half3 bitangentWS,
half3 normalWS, sampler2D bumpMap)
{
half4 packed = tex2D(bumpMap, uv);
half3 tangentNormal = UnpackNormal(packed); // RGB (0..1) -> direction (-1..1)
// Rotate the tangent-space bump direction into world space using
// this pixel's own local tangent/bitangent/normal axes.
half3x3 tbn = half3x3(tangentWS, bitangentWS, normalWS);
half3 worldNormal = normalize(mul(tangentNormal, tbn));
return worldNormal;
}
Trace it by hand for two texels. A flat texel with color (128, 128, 255) normalizes to about (0.50, 0.50, 1.00), and UnpackNormal converts that to roughly (0, 0, 1) — "straight out," so worldNormal ends up equal to the real geometric normal: no visible bump. A slightly reddish texel with color (200, 128, 255) unpacks to roughly (0.50, 0.00, 0.87) — tilted toward the surface's local +X (tangent) direction. That tiny tilt is enough to make the lighting math compute a different brightness for that one pixel than its flat neighbors, which is what the eye reads as a bump, even though the actual mesh underneath is dead flat.
Roughness is a number from 0 to 1 describing how microscopically bumpy a surface is, at a scale far too small to see individual bumps — only their statistical effect on how light scatters. A roughness of 0 means a mirror-smooth surface: light bounces off in one tight, sharp direction, producing a small, bright, crisp highlight (the visible bright spot where a light reflects — called a specular highlight). A roughness of 1 means a very rough surface: light scatters in many directions at once, spreading that same highlight into a large, dim, blurry smear.
Some engines flip this and call it smoothness instead — Unity's older Standard shader and URP's Lit shader both expose "Smoothness," where smoothness = 1 - roughness. Same physical idea, just the number runs backward. Always check which one a shader's slider actually controls before typing in a value.
Because roughness is stored as a grayscale texture, one pixel can differ from its neighbor — a single sword can have a polished, low-roughness blade next to a worn, high-roughness leather grip, using one texture with two different gray values painted on it.
To save texture memory, engines commonly pack several grayscale maps into the different color channels of one texture instead of using four separate full files. Unity's URP Lit shader, for example, reads a single "Mask Map" where R = Metallic, G = Ambient Occlusion, B = a detail mask, and A = Smoothness — four maps' worth of data riding inside one image file, because each channel is really just its own independent grayscale image.
The metallic value is, physically, almost always either 0 or 1. A value of 0 means a dielectric (a non-conducting material — wood, plastic, skin, stone, rust). A value of 1 means a pure metal (steel, gold, aluminum). PBR shader math uses this single number to decide, physically, how a surface splits incoming light between two very different behaviors: metals reflect light with their own base color tinting the reflection, and give back essentially none of that light as soft, scattered "diffuse" light; dielectrics do the opposite — their reflections stay a neutral white or gray no matter what color they are, and most of the color you actually see comes from scattered diffuse light.
A metallic map is a grayscale texture that lets one object be part bare metal and part something else — a rusty pipe, for example, has bare steel patches (metallic close to 1) next to rust patches (metallic close to 0, because rust, iron oxide, is a dielectric even though it started as metal).
Ambient occlusion is a grayscale map, precomputed from the 3D model's own geometry, storing how exposed each point on the surface is to soft, indirect ambient light — light bouncing around a scene generally, not light coming directly from one visible source. A value of 0 (black) means a point is tucked deep into a crevice where almost no ambient light can reach it; a value of 1 (white) means a point is wide open with nothing nearby blocking incoming light.
Baking this in ahead of time adds soft, subtle contact shadows in seams, screw holes, and tight corners — detail that real-time lighting either cannot compute cheaply enough, or would need extremely fine shadow resolution to catch. AO should only ever darken indirect/ambient light, never block direct light or cast a hard shadow — that job belongs to the engine's real-time shadow system.
A height map is a grayscale texture storing how far "up" or "down" from the base surface each point should appear to sit, with mid-gray meaning "no change." It has two different jobs depending on how it is used. As a parallax map, it is a cheap shader trick — Parallax Occlusion Mapping shifts which texel gets sampled based on the camera's viewing angle and this height value, faking real depth on a surface that is still, underneath, completely flat geometry; a cobblestone floor using this trick looks flat when viewed straight down, but shows convincing depth and hidden crevices at a grazing angle. As true displacement, the same map is instead used to physically move a mesh's vertices up and down — which only works if the mesh already has enough triangles nearby to move (a technique called tessellation is often used to add that extra detail automatically, only where the camera is close enough to need it).
An emission map is a color texture (not grayscale — it stores an actual RGB glow color) marking which parts of a surface should appear to emit their own light, independent of anything else in the scene: a neon sign, a character's glowing eyes, a lit-up screen.
Renderer screenRenderer = GetComponent<Renderer>();
Material screenMat = screenRenderer.material;
screenMat.EnableKeyword("_EMISSION");
screenMat.SetTexture("_EmissionMap", screenGlowTexture);
screenMat.SetColor("_EmissionColor", Color.cyan * 3f); // brighter than pure white
This makes the screen prop appear lit even in a completely dark room, because emission is not affected by any external light source at all. Multiplying the color by 3f pushes its brightness above 1.0, which matters if the project uses a post-processing bloom effect (a soft glow the camera adds around very bright pixels) — only pixels above roughly 1.0 brightness will actually bloom outward.
There are two different, historically common ways to describe the exact same underlying physical surface response to light. Which one a pipeline uses depends on the tools and engine version involved.
The metalness/roughness workflow — the modern default in Unity, Unreal, and Substance — describes a surface with Base Color, Metallic, and Roughness, exactly as covered in the sections above. It is compact: metallic and roughness can be packed into just two channels of one shared texture, as shown in section 7.
The specular/glossiness workflow — older, still found in some engines and mobile-oriented pipelines — instead describes a surface with Diffuse Color, a full RGB Specular Color (the reflection tint, hand-painted directly by the artist instead of derived automatically from metallic), and Glossiness (the direct inverse of roughness: 1 means shiny, 0 means rough).
The practical difference: the metalness workflow derives the reflection's tint color automatically from Base Color whenever Metallic is 1, so an artist paints less data and it stays physically consistent almost by accident. The specular workflow hands the artist direct, manual control over the reflection color — more flexible in unusual cases, but much easier to accidentally paint a physically-impossible material (a gray "metal" with a bright red specular tint, for example, which does not correspond to any real substance). Substance Painter and Designer can bake or convert between the two workflows automatically, since underneath they describe the same physical values, just organized differently.
Substance Designer is a node-based, procedural tool — "procedural" meaning the result is generated by a graph of connected math, noise, and pattern nodes, rather than hand-painted pixel by pixel. Because the whole material is really just a graph of parameters, it can be tweaked non-destructively (drag a single "rust amount" slider on one node, and the entire material updates everywhere) and it can be regenerated at any resolution on demand, since nothing is a fixed-size baked image until export time. Designer is typically used to build reusable base materials from scratch — "worn metal," "mossy stone," "cracked concrete" — which then get exported either as a normal set of PBR texture files, or as a live, still-adjustable package (an .sbsar file) that other tools can plug their own parameter values into.
Substance Painter is a 3D painting tool. You import a model that already has its UVs unwrapped (section 2), and paint directly onto the 3D surface in the viewport — not onto the flat 2D UV layout, though you can switch to that 2D view too — using layers, masks, and generators much like Photoshop, except a single brush stroke can update several PBR channels at once (base color, roughness, metallic, height) in one motion. Painter also understands the model's own geometry: a "dirt in crevices" generator, for instance, automatically uses the model's curvature and ambient occlusion to decide where grime should naturally collect, without an artist hand-painting every crevice by eye.
A smart material is a bundled preset combining several of those painted or procedural layers into one reusable package — for example, a "rusted steel" smart material might combine a base metal layer, an edge-wear layer, a rust-in-crevices layer, and a dirt layer, each with a smart mask that reacts automatically to the specific model's own curvature, exposed edges, and ambient occlusion. Drag that same smart material onto a completely different model — a sword instead of a pipe — and it adapts its wear pattern to that new model's shape automatically, instead of an artist re-painting every scratch and rust patch by hand each time.
The typical export from Painter follows a predictable naming pattern that maps straight onto the material slots covered earlier in this chapter:
T_Sword_BaseColor.png -> Material._BaseMap
T_Sword_Normal.png -> Material._BumpMap
T_Sword_MaskMap.png -> Material._MetallicGlossMap (R=Metal, G=AO, A=Smoothness)
T_Sword_Emissive.png -> Material._EmissionMap
A trim sheet is a single texture containing many small, reusable strips of detail — moldings, pipes, cable runs, bolt rows, wood trim — laid out in a row or grid. Many different UV islands, across many different meshes in a level, can each reference just the one strip they need, instead of every wall, doorframe, and pipe needing its own unique full-resolution texture. This is standard practice in environment art, because it keeps both texture memory and the total number of unique textures low while a level can still look highly detailed.
A texture atlas is the same underlying idea applied to distinct small props or icons rather than repeatable strips: many different small textures packed together into one shared sheet, so multiple objects can share a single material and be drawn together more efficiently. Sharing one texture and material across many objects can reduce the number of separate draw calls (a draw call is one instruction sent to the GPU telling it to render a batch of geometry; each one carries CPU overhead, so fewer, larger draw calls are generally faster than many tiny ones).
// Pick tile (2, 1) out of a 4x4 grid atlas (each tile is 0.25 x 0.25 in UV space)
Vector2 tileSize = new Vector2(0.25f, 0.25f);
Vector2Int tileIndex = new Vector2Int(2, 1);
Material atlasMat = GetComponent<Renderer>().material;
atlasMat.mainTextureScale = tileSize;
atlasMat.mainTextureOffset = new Vector2(
tileIndex.x * tileSize.x,
tileIndex.y * tileSize.y
);
This shrinks and shifts the window the shader samples the atlas texture through, so the renderer only ever shows the small 0.25 by 0.25 slice of the sheet sitting at grid position (2, 1), instead of the whole atlas stretched over the object.
A mip-map is a precomputed stack of the same texture stored at progressively smaller resolutions — 1024, then 512, then 256, and so on, all the way down to a single 1x1 pixel — generated automatically when the texture is imported or built.
The reason this matters: when a textured surface sits far from the camera and covers only a handful of screen pixels, sampling the full-resolution texture causes aliasing (flickering, shimmering noise), because many original texture pixels get crammed unevenly into just a few screen pixels, and which ones "win" can change slightly from frame to frame as the camera moves. Instead, the GPU picks (or smoothly blends between) an appropriately small mip level for that distance — smaller textures are both faster to sample and visually stable, with none of that shimmer.
Texture2D tex = myMaterial.mainTexture as Texture2D;
Debug.Log(tex.mipmapCount);
11
A 1024x1024 texture produces 11 mip levels: 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1 — each one exactly half the resolution of the one before it, until there is nothing left to shrink.
This connects directly back to the atlas padding tip from the last section: at a small mip level, the GPU has already blended neighboring texture pixels together to build that smaller image, so two atlas tiles sitting hard against each other with zero padding can visibly bleed into one another once the object is far enough away to use one of those small mip levels — even though the same object looked perfectly clean up close.
ComputeTexelDensity function from section 3, compute this prop's actual texel density.
(a) texturePixelsUsed = 1024 * 0.2 = 204.8. density = 204.8 / 4 = 51.2 px/m.
(b) The target is 256 px/m, and this prop measures about 51.2 px/m — roughly 5 times below target. It will look noticeably blurrier than other background props at the correct density.
(c) Fix 1: increase the overall texture sheet's resolution (for example from 1024 up to a size large enough to hit 256 px/m at this same 20% UV fraction, which works out to roughly 5120px, rounded up to the next power of two, 8192). Fix 2: give this prop's UV island a bigger fraction of the existing shared sheet — shrink or reflow the other islands packed onto the same 1024 texture, or move this prop onto its own texture set entirely. Fix 2 is usually the better choice: bumping the whole sheet's resolution to fix one under-sized island wastes memory on every other object sharing that same sheet, even though only this one prop actually needed more detail. Reallocating UV space (or giving the prop its own texture) targets the actual problem without inflating memory for everything else.
Material chrome = new Material(Shader.Find("Universal Render Pipeline/Lit"));
chrome.SetFloat("_Metallic", 1f); // pure metal
chrome.SetFloat("_Smoothness", 0.9f); // very smooth -> roughness ~0.1
Material tire = new Material(Shader.Find("Universal Render Pipeline/Lit"));
tire.SetFloat("_Metallic", 0f); // a dielectric, not metal
tire.SetFloat("_Smoothness", 0.1f); // very rough -> roughness ~0.9
Chrome is a bare, polished metal, so Metallic should sit at (or extremely close to) 1 — anything mid-gray here would look physically wrong, per the common mistake noted in section 8. Because it is polished, its microscopic surface is very smooth, so Smoothness is high (close to 1), giving a small, tight, bright highlight. Rubber is a dielectric — it does not tint its reflections with its own base color the way a metal would — so Metallic sits at 0. Rubber's surface is also microscopically rough (matte, not shiny), so Smoothness is low, giving a wide, dim, blurry highlight instead of a sharp glossy one.
Texture2D in place.
(a) The Green channel. As covered in section 6, R stores the X (tangent) direction, G stores the Y (bitangent) direction, and B stores the Z (normal, straight-out) direction. OpenGL-style and DirectX-style normal maps are defined with the bitangent (up/down) axis pointing in opposite directions from each other — R and B stay consistent between the two conventions, only G flips. That single flipped channel is enough to invert every bump into a dent and back.
Color[] pixels = normalMap.GetPixels();
for (int i = 0; i < pixels.Length; i++)
{
Color c = pixels[i];
c.g = 1f - c.g; // flip the Y / bitangent axis convention
pixels[i] = c;
}
normalMap.SetPixels(pixels);
normalMap.Apply();
(b) The loop reads every pixel, replaces its green value with 1 - g (a full inversion, since normal map channels run 0 to 1), and writes the pixels back. This mirrors the bitangent axis, converting the map from one convention to the other. In a real production, the more common fix is to re-export the map from the source DCC tool with the correct convention selected, or use the "flip green channel" option some engines and image tools provide directly — but the underlying operation is exactly this: invert G, leave R and B untouched.