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.box(), 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.

Compiling and shipping

A ShaderMaterial compiles its GLSL to SPIR-V the first time it is drawn, so writing one needs glslc on your machine: the LunarG Vulkan SDK on Windows and macOS, sudo apt install glslc on Debian and Ubuntu, sudo dnf install glslc on Fedora, sudo pacman -S shaderc on Arch. The result is cached per user, keyed by a hash of the source, so an edited shader recompiles and an unchanged one does not. The cache is $SIMVX_SHADER_CACHE_DIR, else [rendering] shader_cache_dir in simvx.toml, else $XDG_CACHE_HOME/simvx/shaders (the platform cache directory elsewhere).

Your players need no compiler. Exporting the game compiles every ShaderMaterial and PostProcessEffect its first frame builds into precompiled_shaders/ beside the entry script and ships it; the renderer reads that bundle before it would reach for glslc, and the export fails, naming the install line above, when glslc is missing. A shader first built later than the first frame is compiled where the game runs instead. The engine’s own shaders and the shipped examples need no compiler at all.

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 refuses, quoting the declaration, rather than drawing the object’s underlying Material in its place.

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.