Per-entity shader data
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.