Rendering in Retrospect · #4 of 12
Materials Are Data, Not Formulas
Retrospective — written in 2026, looking back.
The Shader-Per-Material Trap
Early in my personal rendering experiments, every distinct surface got its own shader. A rough metal needed a highlight tweak, so I copied the base shader and edited it. A surface needed a tint, so I copied it again and hard-coded the tint. Within weeks I had a folder of shaders that were ninety percent identical, and a growing dread of changing any of them: a fix to the shared lighting logic had to be repeated in every copy, and I always missed one.
That taught me the lesson the first post in this series touched on from the lighting side: the formula is not the material. The formula is an interpreter, shared and stable. A material is the data that interpreter reads — parameters, textures, flags — and two surfaces differ as materials precisely to the extent their data differs, not their code. Reorganizing around that idea changed my workflow more than my code: making a new surface went from a programming task to a content task.
One Shader, Many Parameter Blocks
Write one, or a small number of, general-purpose lit shaders that accept a defined parameter set: base color, roughness, metallic value, normal scale, emissive contribution. The exact set depends on your lighting model; the principle does not. Each material is then a block of values for those parameters, which maps naturally onto a uniform or constant buffer:
// GLSL-style excerpt — illustrative material parameter block
layout(std140, binding = 1) uniform MaterialParams {
vec4 uBaseColor; // rgb: tint
float uRoughness; // 0 = smooth, 1 = rough
float uMetallic; // 0 = dielectric, 1 = metal
float uNormalScale; // strength of the normal map
float uEmissiveStrength;
vec3 uEmissiveColor;
};
// The shared fragment path interprets whichever block is bound.
On the CPU side, a material becomes a small, boring struct:
// Illustrative pseudocode for a personal renderer
struct Material {
Vec4 baseColor; float roughness; float metallic;
float normalScale; float emissiveStrength; Vec3 emissiveColor;
TextureHandle albedoMap; // optional; white texture if absent
TextureHandle normalMap; // optional
TextureHandle packedMap; // see channel packing, below
};
Boring is the compliment. A struct like this can be loaded from a file, edited in a small tool, duplicated, and reloaded at runtime. The rough metal and the tinted surface from my old folder are now two rows of data interpreted by one shader. Improving the lighting model means changing one shader and re-interpreting all existing content — the data survives. Under shader-per-material, every improvement invalidated every copy.
There is a performance angle too: shader programs are expensive to compile and switch, so a renderer with a few programs and many parameter blocks batches and sorts draws far more easily than one juggling dozens of near-duplicates. In my personal projects, though, the workflow gain arrived first and mattered more.
Texture Channels Are Data Layout Too
Once materials are data, textures stop being pictures and become storage. A texture holds four channels per texel, and choosing what goes in them is a layout decision with real costs: each separate texture is another shader sample, another binding, and another chunk of bandwidth per fragment.
Channel packing is the standard answer I adopted in my experiments: combine single-purpose grayscale maps that share the same UVs and resolution into one texture's channels. A common illustrative arrangement is:
Illustrative packed texture layout ("ORM"-style):
R : ambient occlusion G : roughness
B : metallic A : spare / emissive mask / height
One sample then yields three parameters that would otherwise cost three:
// GLSL-style excerpt — unpacking packed material data
vec4 orm = texture(uPackedMap, vUV);
float roughness = orm.g * uRoughness; // map modulated by scalar
float metallic = orm.b * uMetallic;
float ao = orm.r;
Two details carry the broader lesson. First, the packed map is modulated by the scalar parameters, not a replacement for them. That gives both levels of control: a texture for variation across a surface, a scalar for adjusting the whole material without re-exporting a texture. Second, packing only works when the maps genuinely share UVs and resolution needs. Packing maps with different tiling or resolution is false economy — you pay the largest resolution in every channel and complicate authoring for nothing. Data layout, like shadow allocation in the previous post, is a budget decision, not a dogma.
The same question applies to a base color texture, whose alpha channel is often spare capacity and a natural home for an opacity or emissive mask. Ask of every texture not "what does this picture show?" but "what four values per texel does my shader need, and how do they group?"
Instances Versus Unique Shaders
With materials as data, a useful distinction becomes available, at personal-project scale as much as in larger engines:
- A base material is the interpreter plus defaults: the shader, the packed-texture layout, the feature switches.
- A material instance is just a new parameter block — and optionally different textures in the same layout — referring to that base. Creating one is cheap, instant, and needs no compilation.
In my current experiments, almost everything is an instance. Ten variants of a stone-like surface — different tints, roughness ranges, normal strengths — are ten small data records and one shader. I can iterate on all ten in the time the old workflow took to recompile one.
A genuinely unique shader is justified only when the interpretation changes, not the values. A translucent surface that wraps light around, or a stylized surface that quantizes diffuse into bands, cannot be expressed as parameters of the standard interpreter without contorting it. That is a new base material. My test before writing one: can I describe this surface as values my parameter block lacks, or does it need a new equation? If one more scalar or texture channel in the shared block expresses it, it stays an instance — the shared shader gains a parameter, not a sibling.
The opposite failure mode is real too: a shared shader growing endless flags and per-fragment branches for every special case becomes the monolith from my first renderer, wearing data-driven clothes. Branching on flags constant for an entire draw is wasteful, and flag tangles are as hard to reason about as folders of copies. My compromise is a small number of base materials — standard lit, unlit, one or two specialists — with a rich parameter block, splitting a base only when a feature is both expensive and rare.
Constant Buffers Teach You to Think in Frequencies
Parameter blocks also force a useful habit: grouping data by how often it changes. Per-frame data — camera position, light buffers, shadow matrices — changes every frame and lives in its own buffer, bound once. Per-material data changes a modest number of times per frame in a sorted renderer. Per-object data — mainly a model transform — changes per draw.
That separation is not bureaucracy. Uploading a large buffer when only a transform changed wastes bandwidth; interleaving stable material data with volatile per-frame data forces re-uploading both at the higher frequency. Layout by update frequency is the CPU-side mirror of the GPU lesson from my first post: the shape of data is a performance decision, made before any shader runs.
It also localizes bugs. One object with wrong roughness: inspect one material record. Everything wrong: inspect the shared shader or the per-frame buffer. That is the same benefit I praised for lighting, now as a direct consequence of structured data instead of scattered uniforms set from a dozen places in code.
Materials as a Content Workflow
The deepest change is not technical. When materials are data, they can live in data files, and a scene starts to be authored rather than programmed. In my personal engine, materials live in simple text definitions I can edit and hot-reload: name, base material, parameter values, texture paths. Tweaking becomes a loop of seconds — change a roughness scalar, reload, look — instead of a compile loop. That speed is how visual judgment gets trained: you learn what roughness 0.3 versus 0.6 looks like by sweeping the value live, not by recompiling six times and forgetting the first result.
That is where this series has been heading. The first renderer taught me a renderer is a budget system. Lighting taught me formulas are compressed geometry. Shadows taught me quality is allocation. Materials complete the pattern: code is the smallest, most stable part of the system. The data — its layout, packing, frequency, and files — is where a renderer is actually built, rebuilt, and understood. I still write shaders, of course. I just write far fewer, expect each to serve a long time, and expect the materials to change every day — which now, finally, they can.