Tutorial · Shader material

Write a shader material

Replace a surface's shaders with your own, expose float4 parameters and animate them per frame or from Lua.

DIFFICULTY Intermediate
DURATION ~20 min
API ShaderMaterial

1. What you will build

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.

Rendering phase

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.

2. Choosing a phase

The phase determines which data is available and the resulting visual effect:

PhaseWhen to use it
AFTER_LIGHTING_PASSDeferred 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_PASSThe full scene (opaque + transparent) is composited. Use it for overlays that must sit after all scene content but before bloom.
AFTER_BLOOM_PASSBloom has been applied, if enabled. Use it for overlays that must not receive an automatic bloom effect.
AFTER_POSTPROCESSING_PASSThe image is finalized without AA (tonemap done). Use it for overlays free of post-processing, or transparent objects that must show overlays.
AFTER_AA_PASSThe image is fully finalized (tonemap, AA). Suited to UI or debug elements that must not undergo image processing and stay sharp.

3. Create the material

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.

4. Assign the material

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

5. Read the parameters in the shader

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;
Convention

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.

6. Animate the parameters

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});
}

7. From Lua

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
Pipeline

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.

← Post-process Custom pass →