Skip to content
All devlog entries

A Material Is Just a File

renderingmaterialsarchitecture

Material Editor

A material in Eresh is a text file. You edit it in the editor’s Material panel, or by hand, and the renderer turns it into a GPU pipeline plus a set of bound textures. This is what’s actually in the file, and the non-obvious parts underneath.

The file on disk

Here’s what a .mat.ron looks like:

(
    shader: "pbr",
    features: ["ALBEDO_MAP", "NORMAL_MAP"],
    params: {
        "base_color": Vec4((1.0, 1.0, 1.0, 1.0)),
        "roughness": Float(0.5),
        "metallic": Float(0.0),
    },
    blend: Opaque,
    raster: (cull: Back, front: Ccw, polygon: Fill),
    depth_write: true,
    receive_shadows: true,
)

That’s the whole authoring surface. A shader name, a list of features to turn on, a bag of params, and a few raster and blend knobs. params is a HashMap<String, MaterialParameterValue>, where a value can be a float, a vec, a bool, or a texture path. The schema isn’t fixed by the engine; it comes from the shader.

The shader declares the schema

The PBR shader starts with directives the engine parses at load time:

#pragma feature ALBEDO_MAP
#pragma feature NORMAL_MAP
#pragma feature ORM_MAP
#pragma feature EMISSIVE_MAP
#pragma feature SKINNED

#pragma group "Textures"
#pragma param base_color_map texture
#pragma param normal_map texture
#pragma param orm_map texture
#pragma param emissive_map texture

#pragma group "Base"
#pragma param base_color vec4 1.0 1.0 1.0 1.0
#pragma param metallic float 0.0 0.0 1.0
#pragma param roughness float 1.0 0.0 1.0

The #pragma param lines are the schema. They tell the editor what fields to show, what type they are, and their defaults. The Material panel in the editor doesn’t hand-write a form for each shader; it reads these declarations and builds the widgets from them. Add a #pragma param to your shader and the editor shows a new row. No engine changes, no per-material UI code.

#pragma feature lines are the compile-time switches. Each one auto-assigns a bit in declaration order: ALBEDO_MAP is bit 0, NORMAL_MAP is bit 1, and so on. A material’s features list turns into a bitmask, and that bitmask picks which #ifdef branches survive preprocessing.

One pipeline per combination

The bitmask plus the raster state (cull mode, front face, polygon fill, blend mode, depth write, depth compare) forms a key. Two materials with the same key share one GPU pipeline; two materials with different keys get different ones. The cache lives in PsoCache, keyed by PsoKey.

There’s a deliberate ordering trap here. SKINNED is declared last in the shader. If someone added a new feature above it, every existing material’s bitmask would shift, and every cached pipeline would be mis-keyed. The comment in the shader says so out loud, and there’s a test that pins it. Preprocessor bit assignment is declaration order, and that order is load-bearing.

The bind group contract

Eresh’s renderer uses four bind groups, and the material owns one of them:

  • Set 0: frame data (camera, lights, cluster index)
  • Set 1: per-draw uniforms (model matrix, material scalars)
  • Set 2: material textures and samplers
  • Set 3: shadow resources

Set 2 is what the material builds. The PBR path binds four texture/sampler pairs: base color, normal, ORM, and emissive. ORM follows the glTF convention: R is occlusion, G is roughness, B is metallic, packed into one map so you ship one texture instead of three.

A material that doesn’t define a map gets a default texture in that slot: white for base color and ORM, a flat normal ([128,128,255,255]), black for emissive. So a material with no textures at all still renders, just untextured.

The bug that made hot-reload silently stop

Here’s a real one. You’d edit a texture on disk, the asset watcher saw the change, the texture re-uploaded, and the material kept sampling the old one. No error, just stale pixels.

The cause: the material cache decides whether to rebuild its bind group by comparing a version number. That number used to fold in the material’s own version and the shader’s version, but not the textures’ versions. The bind group holds concrete wgpu::TextureViews. If a texture re-uploads and the material’s version doesn’t move, the cache early-returns and the old views stay bound forever.

The fix folds all four texture versions into the material’s cache key. Now a texture reload moves the version, the bind group rebuilds, and the new pixels show up. There’s a test named texture_version_participates_in_material_version that exists purely to keep this from coming back.

Skinning isn’t a material feature

This trips people up. SKINNED looks like a material feature, and it is a #pragma feature, but it’s an entity property, not a material one. A material never turns it on. An entity with a skeleton does.

So the skinned pipeline variant is built lazily. The first time a draw command from a skinned entity uses a material, the material creates its skinned pipeline on the spot and caches it. A material that’s only ever used by static meshes never pays for the skinned variant. Two skinned materials sharing a shader share one skinned pipeline, because the cache keys on shader and state, not on the material instance.

Where things stand today

The material system works today, in the editor and in the browser. You author a .mat.ron, the editor shows it as a panel generated from the shader’s #pragma param declarations, you drag textures into the slots, and the renderer builds and caches the pipelines. Changing anisotropy in the editor rebuilds the sampler and bind groups but doesn’t re-upload a single texture. Hot reload of textures, shaders, and the material file itself all reach the running renderer.

Eresh is pre-alpha. What’s here is the PBR path with four texture slots, feature-variant pipelines, and the editor panel. What’s not here yet: material graphs or node-based authoring, custom shaders beyond WGSL, and material instancing beyond sharing a .mat.ron. Those stay on the roadmap until they’re built.

What’s next

The next devlog will go deeper into another subsystem as it lands. If you want to see the material editor in action, the editor runs in the browser with no install required.