simvx.graphics.materials.custom_shader

Custom shader material system: user-facing API for custom GLSL shaders.

Provides ShaderMaterial for per-object custom shaders, UniformBuffer for GPU-side uniform data, and ShaderMaterialManager for pipeline caching and hot-reload.

Module Contents

Classes

ShaderMaterial

User-facing custom shader material.

UniformBuffer

The material descriptor group: uniforms at binding 0, textures above it.

ShaderMaterialManager

Caches custom-shader pipelines and drives the desktop custom-shader pass.

Data

API

simvx.graphics.materials.custom_shader.__all__

[‘ShaderMaterial’, ‘UniformBuffer’, ‘ShaderMaterialManager’]

simvx.graphics.materials.custom_shader.log

‘getLogger(…)’

simvx.graphics.materials.custom_shader.CUSTOM_FRONT_FACE

None

class simvx.graphics.materials.custom_shader.ShaderMaterial(vertex_path: str | pathlib.Path | None = None, fragment_path: str | pathlib.Path | None = None, *, vertex_source: str | None = None, fragment_source: str | None = None, language: str = 'glsl', wgsl_vertex: str | None = None, wgsl_fragment: str | None = None, wgsl_vertex_path: str | pathlib.Path | None = None, wgsl_fragment_path: str | pathlib.Path | None = None, transparent: bool = False)

User-facing custom shader material.

Allows using custom GLSL vertex/fragment shaders with user-defined uniforms. Works alongside the engine’s existing uber-shader pipeline by creating its own separate Vulkan pipeline.

Example::

mat = ShaderMaterial(
    vertex_path="shaders/wave.vert",
    fragment_path="shaders/gradient.frag",
)
mat.set_uniform("time", 0.0)
mat.set_uniform("colour", (1.0, 0.5, 0.2, 1.0))

Or with inline source::

mat = ShaderMaterial(
    vertex_source="""
        #version 450
        layout(location=0) in vec3 pos;
        void main() { gl_Position = vec4(pos, 1.0); }
    """,
    fragment_source="""
        #version 450
        layout(location=0) out vec4 out_color;
        void main() { out_color = vec4(1.0, 0.0, 0.0, 1.0); }
    """,
)

A material samples textures it declares in its own descriptor group, and composites rather than replaces when it is transparent::

mat = ShaderMaterial(..., transparent=True)
mat.set_texture("albedo", "assets/crate.png")

Initialization

property is_compiled: bool

Whether shaders have been compiled to SPIR-V and loaded.

property uniforms: dict[str, Any]

All current uniform values.

set_uniform(name: str, value: Any) None

Set a shader uniform by name.

Supported types: float, int, vec2, vec3, vec4, mat4, and numpy arrays. Type is inferred automatically from the value.

set_uniform_typed(name: str, value: Any, utype: str) None

Set a uniform with an explicit GLSL type string.

get_uniform(name: str) Any

Get the current value of a uniform. Raises KeyError if not set.

property textures: dict[str, Any]

The texture source bound to each name the shader declares.

set_texture(name: str, source: Any, *, colour_space: str = 'srgb') None

Bind a texture to the texture2D the shader declares as name.

source is anything the engine loads a texture from: a path, raw encoded bytes, an RGBA array, or a :class:~simvx.core.graphics.Texture resource. name must match the declaration in the shader, because the binding number comes from the shader source, not from the order textures are set here::

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

material.set_texture("albedo", "assets/crate.png")

colour_space is "srgb" for images that are colour (the default: the GPU decodes them to linear on sample) and "linear" for data maps – normals, masks, heightfields – whose bytes must reach the shader untouched.

Raises :class:~simvx.core.graphics.shader_bindings.ShaderBindingError if the shader declares no such texture, so a typo is a loud failure rather than a silently white surface.

get_texture(name: str) Any

The texture source bound to name. Raises KeyError if none is.

texture_colour_space(name: str) str

The colour space name was bound with ("srgb" unless stated).

material_bindings() simvx.core.graphics.shader_bindings.MaterialBindings

The textures and samplers the shader declares in its material group.

Read from the shader source (the WGSL escape hatch when there is one, else the GLSL) and cached: the source is what states which name sits at which binding, and both backends read the same model out of it.

compile(device: Any, shader_dir: pathlib.Path | None = None) None

Compile shaders to SPIR-V and create Vulkan shader modules.

Uses file paths if provided, otherwise writes inline source to temp files for compilation via glslc.

Args: device: Vulkan logical device handle. shader_dir: Base directory for resolving relative shader paths and includes.

resolve_wgsl(base_dir: pathlib.Path | None = None) tuple[str | None, str | None]

Return (vertex_wgsl, fragment_wgsl) for the web escape hatch, or (None, None).

Inline wgsl_vertex/wgsl_fragment take precedence over their *_path counterparts. Used by the web exporter to skip naga transpilation when hand-written WGSL is supplied. A partial escape hatch (only one stage) is an error: both stages must be provided together.

property has_wgsl_escape_hatch: bool

Whether hand-written WGSL is supplied for both stages (web only).

get_pipeline_key() tuple

Return a hashable key unique to this shader combination.

Used for pipeline caching in ShaderMaterialManager. transparent is part of the key because it selects a different pipeline (blended, no depth write), not merely a different uniform value.

has_source_changed() bool

Check if shader source files have been modified since last compile.

cleanup(device: Any) None

Destroy Vulkan shader modules.

class simvx.graphics.materials.custom_shader.UniformBuffer(max_size: int = 1024)

The material descriptor group: uniforms at binding 0, textures above it.

Manages a host-visible Vulkan buffer and the descriptor set a custom-shader pipeline binds as group 2. The buffer is laid out according to std140 rules so it can be directly consumed by a GLSL uniform block; the textures and samplers the shader declares get one descriptor each, at the binding the shader stated, which is the same layout the browser builds for the same shader.

Initialization

property is_created: bool
create(device: Any, physical_device: Any, bindings: simvx.core.graphics.shader_bindings.MaterialBindings | None = None) None

Create the GPU buffer and descriptor resources.

bindings is the material group model read from the shader source: each texture it declares gets a sampled-image descriptor and each sampler a sampler descriptor, at the binding the shader stated, both UPDATE_AFTER_BIND so a texture can be reassigned while the set is bound. Passing none keeps the group to its uniform block alone.

bind_texture(device: Any, binding: int, image_view: Any) None

Point one texture binding at a resolved image view.

A no-op when that view is already there, so the per-frame call costs a dict lookup and nothing else while a material’s texture stays put. When it does move – set_texture mid-game – the descriptor is rewritten under submitted frames that are still reading this set, which is legal only because the binding is UPDATE_AFTER_BIND (see :meth:create). Those frames may show either texture; the frames after them show the new one.

bind_sampler(device: Any, binding: int, sampler: Any) None

Point one sampler binding at a sampler. Idempotent, like bind_texture.

update(device: Any, uniform_data: dict[str, Any], uniform_types: dict[str, str]) None

Upload uniform values to the GPU buffer using std140 layout.

Args: device: Vulkan logical device. uniform_data: Name-to-value mapping of uniforms. uniform_types: Name-to-GLSL-type mapping (e.g. {“time”: “float”}).

get_descriptor_set() Any

Return the Vulkan descriptor set for binding to a pipeline.

get_descriptor_layout() Any

Return the descriptor set layout for pipeline creation.

cleanup(device: Any) None

Destroy GPU resources.

class simvx.graphics.materials.custom_shader.ShaderMaterialManager

Caches custom-shader pipelines and drives the desktop custom-shader pass.

Tracks all registered ShaderMaterial instances and their compiled pipelines. Pipelines are cached by the shader source/path combination so that multiple objects sharing the same shaders reuse one pipeline.

The manager also owns the shared per-frame camera UBO (group0) and a per-frame transforms SSBO (group1) used by every custom-shader draw. Per-material uniforms live in a group2 UBO created lazily per material.

The descriptor layout ABI is identical to the web WebGPU custom-shader ABI: group0 camera UBO, group1 transforms SSBO addressed by gl_InstanceIndex, group2 per-material UBO (+ separated textures/samplers at binding 1.., not yet wired for the canary).

Initialization

register_material(material: simvx.graphics.materials.custom_shader.ShaderMaterial) None

Track a ShaderMaterial for hot-reload monitoring.

get_or_create_pipeline(material: simvx.graphics.materials.custom_shader.ShaderMaterial, device: Any, physical_device: Any, render_pass: Any, extent: tuple[int, int], shader_dir: pathlib.Path | None = None) tuple[Any, Any]

Get a cached pipeline for this material, or compile and create one.

Uses the unified ABI layouts: group0 camera UBO, group1 transforms SSBO, group2 per-material UBO. Returns (VkPipeline, VkPipelineLayout).

The pipeline is shared between materials with the same key; the group2 resources are NOT, so they are minted per material before the cache is consulted. Two materials over one shader (the same tiling shader on a wall and on a floor, differing only in their uniforms) are the ordinary case, and one of them missing its own set would silently draw with the other’s uniforms and textures.

get_uniform_buffer(material: simvx.graphics.materials.custom_shader.ShaderMaterial) simvx.graphics.materials.custom_shader.UniformBuffer | None

Get the per-material (group2) UniformBuffer for a material, if any.

update_uniforms(material: simvx.graphics.materials.custom_shader.ShaderMaterial, device: Any) None

Upload current uniform values for a material to its group2 GPU buffer.

update_textures(material: simvx.graphics.materials.custom_shader.ShaderMaterial, device: Any, texture_slots: dict[str, int], engine: Any) None

Write this material’s resolved textures into its group2 descriptor set.

Every texture the shader declares gets a descriptor, whether or not the material bound one: an unbound texture reads the 1x1 white fallback, so a shader that samples it sees plain white rather than undefined memory – the same thing the browser runtime does for an unresolved slot. Samplers are bound once each, all to Engine.material_sampler() – the shared linear, repeating, mip-sampling sampler the browser binds too, NOT the maxLod=0 sampler a single-mip bindless texture carries, which would leave a minified surface aliasing on the desktop while the browser sampled the chain both backends generated.

draw(cmd: Any, submissions: list, device: Any, physical_device: Any, render_pass: Any, extent: tuple[int, int], view: numpy.ndarray, proj: numpy.ndarray, registry: Any, shader_dir: pathlib.Path | None = None, frame_globals_buf: Any = None, gbuffer: bool = False, engine: Any = None) None

Draw all ShaderMaterial-backed submissions inside the main render pass.

submissions is the renderer’s per-frame [(mesh_handle, transform, material_id, shader_material, texture_slots)] list. Each submission is drawn individually (one transforms-SSBO slot per draw, addressed via gl_InstanceIndex), binding the cached custom pipeline, the shared camera UBO (group0), the transforms SSBO (group1), and the per-material set (group2). Per-material pipeline switching is the cost of the custom-shader path.

Opaque materials are drawn before transparent ones, so a blended surface composites over what is behind it rather than over an empty frame.

check_hot_reload(device: Any, physical_device: Any, render_pass: Any, extent: tuple[int, int], shader_dir: pathlib.Path | None = None) list[simvx.graphics.materials.custom_shader.ShaderMaterial]

Check all registered materials for source file changes and recompile.

Returns a list of materials that were recompiled.

A reload destroys objects a submitted command buffer may still be reading – the pipeline, its layout, the shader modules and the material’s descriptor pool – so the device is drained first. Once, and only when a source has actually changed: a poll that finds nothing to do must not stall the frame.

cleanup(device: Any) None

Destroy all cached pipelines, uniform buffers, shared resources, and modules.