Custom Shaders

ShaderMaterial lets you write custom GLSL vertex and fragment shaders for any MeshInstance3D.

Basic Usage

from simvx.graphics.materials.custom_shader import ShaderMaterial
from simvx.core import MeshInstance3D, Mesh

mat = ShaderMaterial(
    vertex_source="""
    #version 450
    layout(location = 0) in vec3 position;
    layout(push_constant) uniform PC { mat4 mvp; };
    void main() {
        gl_Position = mvp * vec4(position, 1.0);
    }
    """,
    fragment_source="""
    #version 450
    layout(location = 0) out vec4 frag_colour;
    layout(set = 2, binding = 0) uniform UBO { float time; vec3 tint; };
    void main() {
        frag_colour = vec4(tint * (0.5 + 0.5 * sin(time)), 1.0);
    }
    """,
)
mat.set_uniform("time", 0.0)
mat.set_uniform("tint", (1.0, 0.3, 0.1))

cube = MeshInstance3D(mesh=Mesh.cube(), material=mat)
self.add_child(cube)

Update uniforms each frame in on_update():

def on_update(self, dt):
    mat.set_uniform("time", self.elapsed_time)

Loading from Files

mat = ShaderMaterial(
    vertex_path="shaders/wave.vert",
    fragment_path="shaders/wave.frag",
)

GLSL files are compiled to SPIR-V automatically via glslc. Hot-reload is supported: modified shader files are detected and recompiled at runtime.

Uniforms

Set uniforms by name. Types are inferred from the Python value:

mat.set_uniform("speed", 2.5)                    # float
mat.set_uniform("offset", (1.0, 0.0))            # vec2
mat.set_uniform("colour", (1.0, 0.5, 0.0))       # vec3
mat.set_uniform("tint", (1.0, 0.5, 0.0, 1.0))   # vec4
mat.set_uniform("count", 10)                      # int

For explicit type control:

mat.set_uniform_typed("grid_size", (8, 8), "ivec2")

Supported types: float, int, uint, vec2, vec3, vec4, ivec2, ivec3, ivec4, mat4.

Textures

Declare a separated texture and sampler in the material’s own group and bind an image to the name the shader declares. There is no combined sampler2D uniform: WebGPU has no equivalent, so the engine does not accept one on either backend.

layout(set = 2, binding = 1) uniform texture2D albedo;
layout(set = 2, binding = 2) uniform sampler albedoSampler;

void main() {
    outColour = texture(sampler2D(albedo, albedoSampler), fragUV);
}
mat.set_texture("albedo", "assets/crate.png")            # colour map (sRGB)
mat.set_texture("height", heightfield, colour_space="linear")   # data map

The source is anything the engine loads a texture from: a path, encoded bytes, an RGBA array, or a Texture resource. Binding a name the shader never declared raises rather than leaving a silently white surface, and a texture the material never bound reads 1×1 white.

Calling set_texture again at any point repoints the binding, on both backends: the frames drawn after it sample the new texture.

Every declared sampler gets the same one: linear filtering, repeat addressing, and the full mip chain of whatever texture it reads (a colour map is mipped, a colour_space="linear" data map is not). A per-sampler filter mode is not configurable yet, and the limit is identical on both backends.

The declarations are read out of the shader source by a lexical reader. Commenting one out really does remove it, but the preprocessor is not evaluated: a declaration inside #if 0, one produced by a macro, and one living in an #included chunk are invisible to it. Declare a material’s textures and samplers in the stage source itself.

Transparency

mat = ShaderMaterial(..., transparent=True)

The material is drawn source-over (straight alpha) after the opaque ones, depth tested but not depth writing, and double-sided – the same treatment the standard transparent pass gives a blended Material. The fragment shader’s alpha is what composites.

Web export

ShaderMaterials are transpiled GLSL→WGSL at export time (build-time naga) and render through a per-material pipeline on WebGPU, textures and transparency included. To be web-portable a shader must follow the unified binding ABI:

  • camera UBO at set = 0, binding = 0 (mat4 view; mat4 proj;), not a push_constant (push constants don’t translate to WGSL)

  • transforms SSBO at set = 1, binding = 0 (mat4[], indexed by gl_InstanceIndex)

  • per-material UBO at set = 2, binding = 0, then separated textures/samplers at binding = 1+

The exporter reads the material group out of the emitted WGSL and publishes it, so the browser builds the same bind-group layout the Vulkan renderer does. A declaration neither backend can bind – a combined sampler, a storage image, a cube or array texture, a second uniform buffer in set = 2 – is named at export time, quoting the shader line that declared it, rather than failing pipeline creation in the browser.

Such a shader is treated exactly like one that fails to transpile at all: the export warns and the object falls back to its underlying Material. Export with simvx export web --strict to fail the build on any fallback instead of degrading silently.

Example

See examples/features/3d/custom_shader.py for custom ShaderMaterial with animated uniforms.

API Reference

See simvx.graphics.materials.custom_shader for the complete shader API.