Builtin Library
- ..
- README.md2.6 KB
- preview.png28.5 KB
- properties.yaml5.1 KB
- shader.wgsl6.5 KB
README.md
pbr_layered
A surface composed from two complete PBR layers: a base, and a top layer laid over it wherever coverage says so.
Rust over painted metal, snow over rock, mud over a vehicle, wear over a finish. Pre-blending those into one texture set bakes the coverage in — it can no longer change at runtime, and every entity wearing the material wears it identically. Here both layers keep their own shading inputs and coverage is evaluated per fragment, so the same material can be pristine on one entity and ruined on the next.
Coverage
Coverage is the product of three terms, each neutral by default:
| term | property | what it is for |
|---|---|---|
| mask | top_mask_texture, top_mask_channel, top_mask_contrast | where the top layer gathers — creases, edges, upward faces |
| material | top_coverage | how far the look has progressed, for every entity sharing the material |
| instance | top_instance_lane, top_instance_component | how far for this entity |
top_coverage defaults to 0, so a material that sets nothing renders as its base
layer alone.
top_mask_contrast shapes the mask: 1 is a linear ramp, higher values pull the
top layer back into the mask's brightest areas, which is what makes rust and snow
gather in creases instead of washing evenly over everything.
Driving it per instance
Point the material at an instanceData lane, then write that lane per entity:
-- material: top_instance_lane = 0, top_instance_component = 0
local clean, rusted = entity.find("barrel_a"), entity.find("barrel_b")
if clean and rusted then
renderer.instanceData.set(clean, 0, 0.1) -- nearly clean
renderer.instanceData.set(rusted, 0, 0.9) -- heavily rusted
end
Both entities share one material and one draw path; only the lane differs.
Texture slot budget
WebGPU guarantees 16 sampled textures per fragment stage and the engine's group(0) already holds several. This shader declares 9, which leaves room for those plus a margin. Two full five-map layers would not fit, so the top layer carries the three maps that make it read as a different material — albedo, roughness/metallic, normal — and takes occlusion and emissive from the base.
Layer count is two. The cost is fixed and predictable: both layers are always sampled, and coverage selects between their inputs rather than branching.
Why the inputs blend rather than the results
Lighting each layer separately and adding puts more energy back out than arrived, so a half-covered surface would read brighter than either layer alone. Interpolating the shading inputs and lighting once cannot do that.