Replace a surface's shaders with your own, expose float4 parameters and animate them per frame or from Lua.
You will create a ShaderMaterial: a material that replaces a surface's default Slang shaders with your own, together with a set of float4 parameters you drive from code or a script. Unlike a post-processing pass that acts on the whole image, a shader material is rendered per object, at the rendering phase you choose.
Every shader material is bound to a RenderingPhase. The default is AFTER_COLOR_PASS (after opaques, before transparency). Pick a later phase so an effect draws on top of the rest of the scene.
The phase determines which data is available and the resulting visual effect:
| Phase | When to use it |
|---|---|
AFTER_LIGHTING_PASS | Deferred only: lighting is resolved but the image is not yet tonemapped or anti-aliased with TAA. Best for effects that blend with the lit HDR scene (halos, volumetric contributions). |
AFTER_COLOR_PASS (default) | All opaques are drawn and TAA has been applied, but transparency is not yet composited. Suited to opaque shader-driven objects that must stay below transparent elements. |
AFTER_TRANSPARENCY_PASS | The full scene (opaque + transparent) is composited. Use it for overlays that must sit after all scene content but before bloom. |
AFTER_BLOOM_PASS | Bloom has been applied, if enabled. Use it for overlays that must not receive an automatic bloom effect. |
AFTER_POSTPROCESSING_PASS | The image is finalized without AA (tonemap done). Use it for overlays free of post-processing, or transparent objects that must show overlays. |
AFTER_AA_PASS | The image is fully finalized (tonemap, AA). Suited to UI or debug elements that must not undergo image processing and stay sharp. |
The MaterialManager offers several create() overloads. Specify the fragment shader, optionally the vertex shader, the number of exposed float4 parameters, and the phase.
auto& shaderMat = materialManager.create(
"uv_gradient.frag", // fragment shader (without extension)
"scale.vert"); // vertex shader (optional)
// float4 parameters read directly in the shader
shaderMat.setParameter(0, lysa::float4{gradient}); // fragment param
shaderMat.setParameter(1, lysa::float4{gradient}); // vertex param
The number of parameters is fixed at creation. setParameter(index, value) writes into an existing slot and getParameterCount() returns the capacity. Internally, parameters are stored in a global SSBO and re-uploaded on demand.
You can attach the material to the mesh — all instances inherit it — or override it on a specific instance without touching the mesh.
// On the mesh: all instances inherit the material
mesh.setSurfaceMaterial(0, shaderMat.id);
// Or as an override on a specific instance
instance.meshInstance.setSurfaceOverrideMaterial(0, shaderMat.id);
instance.meshInstance.removeSurfaceOverrideMaterial(0); // restore
On the Slang side, parameters are read from the global shaderMaterialParameters array at the material's offset. Here is a glowing ring whose index 0 holds the color and index 1 packs radius, width, glow and gamma — four scalars in a single float4.
// selection_ring.frag.slang
// parameters[0] : RGBA color
// parameters[1] : .x radius .y width .z glow .w gamma
float4 color = shaderMaterialParameters[mat.parametersIndex + 0];
float ring_rad = shaderMaterialParameters[mat.parametersIndex + 1].x;
float ring_w = shaderMaterialParameters[mat.parametersIndex + 1].y;
float glow_rad = shaderMaterialParameters[mat.parametersIndex + 1].z;
float gamma = shaderMaterialParameters[mat.parametersIndex + 1].w;
float2 p = input.uv - float2(0.5, 0.5);
float dist = length(p);
float ring = smoothstep(ring_w, 0.0, abs(dist - ring_rad));
float glow = exp(-dist * dist / (glow_rad * glow_rad)) * color.a;
A float4 can pack four heterogeneous scalars. Document the layout at the top of the shader to avoid any mismatch between the calling code and the Slang.
Since parameters are just CPU state synchronized to the GPU, animate them in the scene's processing loop. Here, a gradient that oscillates between 0 and 1:
void MyScene::onPhysicsProcess(const double delta) {
gradient = std::clamp(
gradient + gradientSpeed * static_cast<float>(delta), 0.0f, 1.0f);
if (gradient == 1.0f || gradient == 0.0f) {
gradientSpeed = -gradientSpeed;
}
shaderMat.setParameter(0, lysa::float4{gradient});
}
If the scripting layer is enabled, the same API is exposed. This selection ring is created at the AFTER_TRANSPARENCY_PASS phase so it shows above the scenery:
local mat = ctx.res.material_manager:create_shader(
"selection_ring.frag", "",
2, -- number of float4 parameters
lysa.RenderingPhase.AFTER_TRANSPARENCY_PASS)
mat:set_parameter(0, consts.UNIT_SELECTION_RING_COLOR) -- RGBA color
mat:set_parameter(1, lysa.float4(
consts.UNIT_SELECTION_RING_RADIUS, -- .x radius
consts.UNIT_SELECTION_RING_WIDTH, -- .y width
consts.UNIT_SELECTION_RING_GLOW_RADIUS, -- .z glow
consts.UI_GAMMA)) -- .w gamma
Every shader material exposes a pipeline_id derived from its shader pair. When the set of materials changes, Renderer::updatePipelines() rebuilds the pipelines of the relevant ShaderMaterialPass.