Material (asset type)
Entities sharing one material can still differ in a value: every drawn
object carries four vec4 lanes of its own that a surface shader reads
as input.shader_data[lane] and renderer.instanceData.set writes. Use
that for a dissolve at its own progress per subject, a hit flash at its
own age, a fill level per instance — a material per entity is for when
the LOOK differs, not when a number does.
New to how a material relates to its shader, its textures, and the live GPU material behind it? Read
core/resource-modelfirst. It also covers the difference between an authored.materialasset (this type) and a runtime GPU material made withrenderer.material.create.
When to use one
- You want a named surface configuration an entity wears by referencing it
from its
Modelcomponent (material = "@.../<Name>"). - You want runtime property tweaks (
asset.resolve(ref):setProperty(...)) to affect every entity using the material at once. - You want to publish a curated look (
Gold,RedGlow,BrickWall) other authors can reuse without re-typing shader bindings.
If you need a brand-new lighting model or a custom visual effect, you
need a .shader first; the material wraps it.
Where it lives
- Source:
/zero/source/.../<Name>.material/ - Identity:
<Name>(the.materialsuffix strips). - Folder shape:
mat.yaml— canonical material spec (shader + property values). Required.preview.png— the material's rendered shader-ball still, its persisted visual description. Required. Rendered when the material is staged (zm add) with its preview missing or stale, and bygeneratePreviews; it syncs, publishes, feeds image-based search, and gives browsers a thumbnail.README.md/.metadata— optional prose + tags, indexed when an author writes real ones.textures/— optional folder for embedded textures. Validator allows the folder; its interior is unrestricted.shaders/— optional folder for a co-located custom shader. Same rule.
How to create one
asset.create("material", "<Name>")
-- Creates: /zero/source/<Name>.material/
-- mat.yaml (shader binding + property scaffold)
-- preview.png (rendered when the material is staged with `zm add`; the
-- picture lands as a change the next `zm add` stages)
-- `folder` places it in a subfolder of /source instead of the root:
asset.create("material", "<Name>", { folder = "materials" })
Pass the shader and initial values through asset.create, then tune with
setProperty and keep what you tuned with saveDefinition:
local mat = asset.create("material", "<Name>", { shader = "@builtin::shaders.pbr" })
mat:setProperty("base_color", { 0.8, 0.6, 0.1, 1 })
mat:setProperty("roughness", 0.3)
mat:saveDefinition() -- writes the tuned values into mat.yaml
This mints the .material asset and returns a handle you tune with
setProperty. A set is a runtime change: it reaches the GPU material and every
entity wearing it, and saveDefinition is the call that writes it into
mat.yaml, so a property driven every frame costs no file write and leaves the
authored file as its author wrote it. A purely runtime asset.create lives only
for the session.
For an ephemeral, runtime-only GPU material that is not an asset at all, use
renderer.material.create instead (see core/resource-model).
How it operates
-
Property reflection. The bound shader's uniform struct fields become the material's configurable properties — no manual property declarations needed.
nagaintrospects the shader at load. -
Property values.
mat.yamlgroups values by type, each block keyed by the shader's field names:properties:holds scalars and colors/vectors,ints:holds integer-typed fields,bools:holds boolean-typed fields, andtextures:holds texture slots. Omitted fields take the shader's default. A scalar is a bare number (roughness: 0.5); a color or vector is an array (base_color: [1, 1, 1, 1]).shader:is the shader's identity string. This is the shape every material the engine writes uses. AsaveDefinitionwrites the changed values into the file where they already stand and leaves the rest of it — its comments, the order of its keys and blocks, the spelling of its literals — untouched. The olderfloats:/colors:blocks (with{ r, g, b, a }map colors) still load for materials hand-authored that way. -
Render state.
render:holds how the mesh is drawn rather than what the shader reads:blend(the equation the fragment composites with, also spelledtype),cull,depth_write,depth_compare,topologyandqueue(the draw order, a whole number:2000opaque,3000transparent,4000overlay). The.shadertype README's "The render block" section lists every value each key takes, andasset.create'srenderargument takes the same keys and values. -
Application. An entity wears a material by referencing it from its
Modelcomponent (material = "@.../<Name>"). Materials affect every entity that references them; runtimeasset.resolve(ref):setProperty(...)changes propagate to all of them immediately, and last until the next mode change unlesssaveDefinitionwrites them intomat.yaml. -
Texture binding. Texture slots accept
color:r,g,b,afor solid colors,default:white/default:black/default:normalfor the engine's own fallback texels,@builtin::textures.<name>for builtins,path/to/image.pngfor file textures, or a render-target handle for camera renders. The GPU texture cache reads thecolor:anddefault:forms from the string itself, so a slot written in one names no asset and carries nothing with the material when it travels; every other form names a texture asset the slot depends on.A texture in
base_color_textureis multiplied bybase_color: every built-in surface shader declaring both draws the texture's colour timesbase_color, channel by channel. Under the default whitebase_colorthe texture shows as itself, and a colouredbase_colortints it. The two read a colour differently:base_coloris linear, and the slot reads its texels as sRGB-encoded, acolor:value's included, the way it reads an image. A colour stated in both places is therefore multiplied by a darker copy of itself:color:0.62,0.14,0.05,1in the slot decodes to0.34, 0.017, 0.0039, so besidebase_color0.62, 0.14, 0.05the surface draws0.21, 0.0024, 0.0002, far darker than either. State a solid colour once, asbase_color, and leave the slot at its default white. -
Hot reload. Editing
mat.yamlrepacks the GPU uniform buffer on the next frame; visible immediately. -
Inspector.
type.yamlnamesinspector: { type: "inspector.luau" }, andinspector.luaudeclares the material's sections beside the generic ones:shader(the bound shader, swappable),properties(each shader field as its typed control) andtextures(each texture slot as an asset chip). They read the material throughmat:getShaderRef()(the bound shader's ref,~expanded) andmat:getTextures()(slot name →{ ref, name }: the bound texture's guid and the id the material names it by). An edit there is an authoring write:mat:writeProperty(name, value)andmat:writeTexture(slot, texture)write the value intomat.yaml(and drop the session's own value for it, so the material reads what the file holds), andsetShaderrewrites the file; inside an undo operation each file write is recorded, so undo putsmat.yamlback.setProperty/setTexturestay runtime writes that last until the next mode change.
Discovery
asset.list("material")— every registered material.asset.inspect("<name>")— shader binding, property values, source path, this type README.ls /zero/source/<Name>.materiallists the files the folder holds.asset.resolve("<name>"):getProperties()— the property fields exposed by the bound shader, each at the value it currently holds: whatmat.yamlauthored until something writes to the material, and the written value from then on, whichever call wrote it. That is the value the surface is drawn with. Read them back rather than guessing names; which fields exist comes from the shader.asset.resolve("<name>"):getDefinition()— the authoredmat.yamlbytes, the value the material rests at.asset.resolve("<name>"):getShader()— the shader the material binds.
Authoring conventions
- Use lowercase, descriptive names (
brushed_steel,red_glow,tarmac_wet). Avoid shader-name suffixes in the material name — the binding lives inmat.yaml::shader. - Drive every property the shader exposes; defaults are a fallback, not a contract for which fields exist.
- Embed textures alongside the material (
textures/subfolder) when they're used only by this material; reference shared textures from@builtin::textures.*or a library.
Common pitfalls
- Property names must match the shader's uniform struct exactly (case-sensitive). Misnamed properties silently get the shader's default.
base_color_texturemultipliesbase_color. Acolor:texture holding the same value asbase_colormultiplies the colour by its own sRGB-decoded value, so the surface renders far darker than the value written. Write a solid colour asbase_coloralone.- Broken shaders break the material. A material whose shader has a parse error renders as the magenta checkerboard. Inspect the shader first if the material looks wrong.
setPropertytargets the material, not the entity. Changes affect every entity that references the material.setPropertyis a runtime change. It reaches the GPU, not the file: callsaveDefinitionto write the current values intomat.yaml. For a per-entity override that no other entity sees, give the entity its own copy withModel:applySessionMaterial(renderer.material.create(...)).- Renaming the folder changes the identity. Update every
Modelcomponent andasset.createcallsite that references it.
Related types
.shader— defines what properties exist; required before a material can bind to it..preset— captures a component configuration; conceptually similar to a material, but for components instead of shaders.