8.2 Materials & Texturing (PBR, Substance)

Phase 8 · Technical Art · Study time: 25–45 h

The PBR material workflow, texture maps (albedo, normal, roughness, metallic) and tools like Substance Painter and Designer.

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.

1. What Is a Material? Material vs Shader vs Texture

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.

+--------------------+ | SHADER | a GPU program: "how do I turn light + textures | ("Lit" / "PBR") | into a pixel color?" -- has empty slots +----------+---------+ | fill the slots with real data v +--------------------+ | MATERIAL | | _BaseMap: rust.png | | _Metallic: 1.0 | | _Smoothness: 0.2 | +----------+---------+ | applied to a mesh's Renderer v [ rendered pixels on screen ]
Tip One shader, many materials, is the whole point of separating them. A single "Lit" shader in a real game might be reused by hundreds of different materials — armor, skin, cloth, rock — each one just a different set of textures and numbers plugged into the same GPU code.

2. UV Unwrapping — Flattening a 3D Model to 2D

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.

A cube's mesh, cut along seams and unfolded flat (a "cross" layout): +--------+ | TOP | +--------+ +-------+--------+-------+-------+ | LEFT | FRONT | RIGHT | BACK | +-------+--------+-------+-------+ +--------+ | BOTTOM | +--------+ Each edge between two boxes above is a real 3D edge that got cut open (a seam) so the cube's surface could lie flat in 2D UV space.

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."

Common mistake Placing seams in obvious, highly visible spots (straight down the middle of a character's face, across the front of a chest) because it was the easiest place to cut. Seams should go where topology naturally hides them, not wherever is fastest to unwrap.

3. Texel Density — Keeping Detail Consistent

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.

Wall A: texture stretched thin over a big wall -- LOW texel density (64 px/m) +----------------------------------+ |## ## ## ## ## ## | big, blurry blocks of detail +----------------------------------+ Wall B: same texture, smaller wall -- HIGH texel density (256 px/m) +------------+ |# # # # # # | small, crisp detail +------------+
Tip Real productions keep a texel density budget: a short chart like "hero characters: 1024 px/m, mid-ground props: 512 px/m, distant background: 128 px/m." Every artist checks new UVs against that chart instead of guessing by eye.

4. The PBR Texture Map Set — Overview

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:

+----------+ +----------+ +----------+ +----------+ |BaseColor | | Normal | |Roughness | | Metallic | |(a photo- | |(purple- | |(gray = | |(black = | | like flat| | blue, | | shiny, | | not | | color) | | bumpy) | | white = | | metal, | | | | | | rough) | | white = | | | | | | | | metal) | +----------+ +----------+ +----------+ +----------+ +----------+ +----------+ +----------+ | AO | | Height | | Emission | |(dark in | |(gray = | |(black + | | cracks, | | up/down | | a glowing| | white in | | bumps) | | shape) | | the open)| | | | | +----------+ +----------+ +----------+

5. Base Color / Albedo Map

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.

Common mistake Leaving "sRGB" checked on a normal map, roughness map, or metallic map. These are not colors — they are raw numbers hiding inside an image file. Checking sRGB on a data map silently corrupts every value the shader reads from it.

6. Normal Map — Faking Surface Detail

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.

Texture RGB channel : what it stores in tangent space R (red) stores X (tangent direction, left / right) G (green) stores Y (bitangent direction, up / down) B (blue) stores Z (normal direction, straight out) A perfectly flat pixel: R=128 G=128 B=255 == direction (0, 0, 1) == "point straight out, no bump here"
// 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.

Real bumpy geometry (expensive: needs a huge number of triangles) ^^ ^^ ^^ ^^ ^^ ------------------------------ (the true surface, lots of triangles, each little bump is real geometry) Flat polygon + a normal map (cheap: same lighting result, ONE flat triangle) ------------------------------ (the true surface: flat, few triangles) ^ ^ ^ ^ ^ ^ (fake per-pixel normals read from the texture, tilted to imitate the bumps above -- lighting can't tell the difference)
Common mistake A model that looks "inside out" — bumps read as dents and dents read as bumps — even though the geometry is correct. This is almost always a green channel mismatch: some tools bake normal maps using an OpenGL-style convention for the green channel, others use a DirectX-style convention, and the two are flipped versions of each other. Flipping the green channel back (covered in this chapter's exercises) usually fixes it immediately.

7. Roughness Map

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.

Roughness 0.0 (smooth) 1.0 (rough) |------------------------------------------| highlight: tiny, bright, sharp -> huge, dim, blurry smear

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.

Tip This channel-packing trick is exactly why Substance Painter and Designer default to exporting a combined "ORM" (Occlusion/Roughness/Metallic) or engine-specific mask map instead of three or four separate PNGs — fewer texture files means fewer texture reads on the GPU per pixel, which is faster.

8. Metallic Map

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).

Common mistake Painting mid-gray values (around 0.5) into a metallic map "to be safe" or "to blend it." Physically, almost nothing in the real world is half-metal. A mid-gray metallic value produces a muddy, physically-impossible look that PBR math was never designed to render well. Metallic values should sit close to 0 or close to 1, with only a thin transition edge where one material meets another.

9. Ambient Occlusion, Height/Displacement, and Emission Maps

Ambient Occlusion (AO)

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.

Height / Displacement

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).

Emission

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.

+----------+ +----------+ +----------+ | AO | | Height | | Emission | | dark = | | light = | | black = | | blocked, | | raised, | | no glow, | | white = | | dark = | | bright | | open | | lowered | | = glows | +----------+ +----------+ +----------+

10. Two Workflows: Metalness/Roughness vs Specular/Glossiness

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).

METALNESS / ROUGHNESS WORKFLOW SPECULAR / GLOSSINESS WORKFLOW +----------------+ +----------------+ | Base Color | | Diffuse Color | +----------------+ +----------------+ | Metallic | | Specular Color| +----------------+ | (RGB, hand- | | Roughness | | painted) | +----------------+ +----------------+ | Glossiness | +----------------+ Both feed the exact same lighting math in the shader -- just different inputs describing the same physical surface.

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.

11. Texturing Tools: Substance Painter, Substance Designer, and Smart Materials

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
Substance Designer Substance Painter (procedural graph, builds a --> (paint the built material onto a reusable "smart material") specific UV-unwrapped 3D model, using layers + smart masks) | v export the PBR map set | v import into Unity, assign textures to a Material, Material drives the Renderer
Tip Smart materials save huge amounts of time on the first 80% of a texture, but they rarely nail the readable, hand-placed details that make a hero prop feel intentional — a scratch that lines up with where a character actually grips a weapon, a scorch mark exactly where an explosion should have hit. Always leave time for manual hand-painted touch-ups on top of a smart material base.

12. Trim Sheets and Texture Atlases

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.

Texture atlas (4x4 grid of tiles, UV space 0..1 across the whole sheet) +------+------+------+------+ | 0,0 | 1,0 | 2,0 | 3,0 | +------+------+------+------+ | 0,1 | 1,1 | 2,1* | 3,1 | +------+------+------+------+ | 0,2 | 1,2 | 2,2 | 3,2 | +------+------+------+------+ * = tile (2,1), the exact tile picked by the code above Trim sheet (one texture, many reusable edge/molding strips) +--------------------------------------------------+ | molding A | pipe B | bolt row C | wood trim D | +--------------------------------------------------+ Different meshes place their UVs over just the strip they need, instead of each needing its own full unique texture.
Tip Leave a small padding gap of unused pixels between neighboring atlas tiles. Without it, a distant mip level (covered next) can blend colors from one tile into its neighbor's edge, causing a visible seam that was never there up close.

13. Mip-Maps

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.

Mip 0: 1024 x 1024 [ full detail -- used up close ] Mip 1: 512 x 512 Mip 2: 256 x 256 Mip 3: 128 x 128 Mip 4: 64 x 64 ... Mip 10: 1 x 1 [ one averaged color -- used very far away ]

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.

14. Glossary

15. Exercises

Exercise 1 A background prop's UV island uses 20% of a 1024x1024 shared texture sheet (along the relevant axis), and that island wraps 4 meters of the prop's surface in-game. Your project's texel density target for background props is 256 px/m.
(a) Using the ComputeTexelDensity function from section 3, compute this prop's actual texel density.
(b) Is it above or below target, and by roughly what factor?
(c) Name two different fixes, and say which one is usually the better choice, and why.
Show answer

(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.

Exercise 2 Write the Unity C# needed to set up two simple, untextured materials using only uniform (flat, non-textured) Metallic and Smoothness values: a chrome trophy and a rubber tire. Explain the values you picked for each, using what this chapter covered about the metallic and roughness/smoothness maps.
Show answer
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.

Exercise 3 A normal map was baked in a DCC (Digital Content Creation) tool using an OpenGL-style green-channel convention, then imported into an engine that expects the DirectX-style convention instead. On screen, every bump on the model reads as a dent, and every dent reads as a bump — the lighting looks inverted, even though the geometry and the rest of the texture import settings are correct.
(a) Which single RGB channel is responsible for this, and why is it that one specifically and not the other two?
(b) Write a short C# fix that corrects an already-imported Texture2D in place.
Show answer

(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.

← Back to all chapters