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¶
User-facing custom shader material. |
|
The material descriptor group: uniforms at binding 0, textures above it. |
|
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
texture2Dthe shader declares asname.sourceis anything the engine loads a texture from: a path, raw encoded bytes, an RGBA array, or a :class:~simvx.core.graphics.Textureresource.namemust 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_spaceis"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.ShaderBindingErrorif 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
namewas 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_fragmenttake precedence over their*_pathcounterparts. 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.
transparentis 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.
bindingsis 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_texturemid-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.
submissionsis 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 viagl_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.