Log inGet started

Per-entity shader data

Updated October 15, 2026

Per-entity shader data — a per-instance property override, without a material each

A material per entity is what a dissolve, a hit flash or an effect at its own age would otherwise cost: an authored material each or a runtime renderer.material.create clone each, a separate pipeline binding and a separate row in the material table for every one of them, and a setProperty call per entity per frame to drive it. The per-instance channel is the way out: every drawn object carries a block of four vec4 lanes of its own, a surface shader reads lane i as input.shader_data[i], and the engine attaches no meaning to what is in them.

local DISSOLVE_LANE = 0
renderer.instanceData.laneCount()                    --> 4   lanes per entity
renderer.instanceData.set(subject, DISSOLVE_LANE, progress)
renderer.instanceData.clear(subject)                 -- back to zero lanes
fn fragment(input: FragmentData) -> vec4<f32> {
    let progress = input.shader_data[0].x;
    if progress > fract(sin(dot(input.uv, vec2<f32>(12.9898, 78.233))) * 43758.5) {
        discard;
    }
    return material.base_color;
}

Twenty subjects sharing that one material each dissolve at their own rate, and a write reaches the frame after it. Reach for a material per entity when what differs is the LOOK — a different shader, texture or blend — and for a lane when what differs is a number the shader reads. A feature picks the lane indices it owns and names them where it writes them, so its writer and its shader read the block the same way — the baked-lightmap section of topics/rendering-lighting is that pattern in the engine's own code, holding lane 0 for the atlas rect and lane 1 for the layer and enable flag.

A lane an entity was never given reads zero, so a shader tells "not set" from "set to zero" by keeping a flag in one of the components rather than by testing the value it wants.

Which material an instance is drawing with is a number, because a shader cannot read a name. Every renderable's per-instance record carries a material index, and renderer.materialIdentity() is the table those indices are drawn from — assigned the first time the renderer draws with that material and stable while the renderer holds it.

local id = renderer.materialIdentity()
id.slots["@builtin::materials.plastic_red"]  --> 4     the index that material took
id.renderables[1]                            --> { entity = "ent_…", slot = 4, index = 4, material = "…" }
id.populations[1]                            --> { slot = 96, count = 4096, index = 5, material = "…" }
renderer.materialIndex("@builtin::materials.plastic_red")  --> 4

A surface shader reads its own instance's index as input.material_index, so a shader drawn by several materials can branch on which one it is under, and a compute pass that traces the scene resolves the same index through zeroMaterial(). Two renderables that differ only in their material read different indices; one renderable reads the same index frame after frame, and a material swap moves it on the next frame. A GPU-driven population is one row in populations rather than one per member: every slot of its reserved run carries the single material renderer.mesh.drawInstanced was given, so a member reads that index wherever the population moves.

A renderable draws with the material its entity references, so a renderable whose entity names none reads index 0. An index is an identity for as long as the renderer holds its material: once the material is destroyed and nothing draws with its name, the name leaves slots and the next material takes its index. A renderable still naming a destroyed material keeps a row for that name until it stops drawing with it. So the table holds a row per name the renderer holds a material under or something draws with, and a session that makes and destroys materials under fresh names keeps it the same size.

A population's members carry their own shader data. One material is what keeps a population one draw, so what a member differs in has to arrive per-instance. instanceDataBuffer is that channel: a GPU buffer holding one block of four vec4 lanes per instance, which the renderer copies into those instances' lanes every frame, and which a surface shader reads as input.shader_data[lane] — the same lanes an entity gets from renderer.instanceData.set, on slots no entity owns.

local lanes = substrate.createBuffer({ name = "crowd.lanes", type = "vec4", len = COUNT * 4, kind = "gpu" })
renderer.mesh.drawInstanced(mesh, {
    transformBuffer = "crowd.xf",
    instanceDataBuffer = lanes.name,   -- the NAME, not the buffer
    instanceCount = COUNT,
    material = M,
})

The copy re-runs every frame, so a compute pass (or a :write) that rewrites the buffer changes the next frame with no second registration. The buffer must back the whole reservation — a short one is refused at the call rather than letting the instances past its end read the next population's lanes. Omit it and the lanes read zero, whatever the slots held before the registration took them.

  • documentation
  • guide
  • rendering