Custom Shader Textures

sampling a texture and blending from a ShaderMaterial.

▶ Run in browser

Tags: 3d

A ShaderMaterial runs your own GLSL in place of the engine’s standard shader. Its per-material resources live in descriptor set 2: the uniform block at binding 0, then the textures it samples. Declare each texture as a separate texture2D and sampler pair rather than a combined sampler2D, because the separated form is what both the Vulkan and the WebGPU backends can bind.

material.set_texture("albedo", source) binds an image to the texture2D the shader declares under that name; the source can be a file path, encoded bytes, an RGBA array or a Texture, and a name the shader does not declare raises. set_uniform(name, value) fills the uniform block by name. Attach the material to a mesh with mesh_instance.shader_material = material.

The scene uses one shader three ways:

  • an opaque cube,

  • a pane in front of it built with transparent=True, so the alpha the fragment shader writes composites over the cube instead of replacing it,

  • a floor that tiles the texture 24 times each way (the uv_scale uniform). Its far half is minified well below one texel per pixel, so it reads from the texture’s mip chain.

Pressing T (or the on-screen button) calls set_texture again on all three materials while they are on screen. The texture sits in a descriptor set that may be rewritten after binding, so the swap needs no pipeline rebuild: frames already in flight may show either image and every frame after samples the new one. Drag or use the mouse wheel to orbit and zoom the OrbitCamera3D; A and D orbit from the keyboard.

Source

  1"""Custom Shader Textures: sampling a texture and blending from a ShaderMaterial.
  2
  3A `ShaderMaterial` runs your own GLSL in place of the engine's standard shader.
  4Its per-material resources live in descriptor set 2: the uniform block at
  5binding 0, then the textures it samples. Declare each texture as a separate
  6`texture2D` and `sampler` pair rather than a combined `sampler2D`, because the
  7separated form is what both the Vulkan and the WebGPU backends can bind.
  8
  9`material.set_texture("albedo", source)` binds an image to the `texture2D` the
 10shader declares under that name; the source can be a file path, encoded bytes,
 11an RGBA array or a `Texture`, and a name the shader does not declare raises.
 12`set_uniform(name, value)` fills the uniform block by name. Attach the material
 13to a mesh with `mesh_instance.shader_material = material`.
 14
 15The scene uses one shader three ways:
 16
 17- an opaque cube,
 18- a pane in front of it built with `transparent=True`, so the alpha the fragment
 19  shader writes composites over the cube instead of replacing it,
 20- a floor that tiles the texture 24 times each way (the `uv_scale` uniform).
 21  Its far half is minified well below one texel per pixel, so it reads from the
 22  texture's mip chain.
 23
 24Pressing T (or the on-screen button) calls `set_texture` again on all three
 25materials while they are on screen. The texture sits in a descriptor set that
 26may be rewritten after binding, so the swap needs no pipeline rebuild: frames
 27already in flight may show either image and every frame after samples the new
 28one. Drag or use the mouse wheel to orbit and zoom the `OrbitCamera3D`; A and D
 29orbit from the keyboard.
 30"""
 31
 32import math
 33
 34import numpy as np
 35
 36from simvx.core import (
 37    Anchor,
 38    Axis1D,
 39    Button,
 40    CanvasLayer,
 41    DirectionalLight3D,
 42    Input,
 43    Key,
 44    Label,
 45    Mesh,
 46    MeshInstance3D,
 47    Node,
 48    OrbitCamera3D,
 49    on_input,
 50)
 51from simvx.graphics import App, ShaderMaterial
 52
 53VERTEX = """\
 54#version 450
 55layout(location = 0) in vec3 inPosition;
 56layout(location = 1) in vec3 inNormal;
 57layout(location = 2) in vec2 inUV;
 58
 59layout(set = 0, binding = 0) uniform Camera {
 60    mat4 view;
 61    mat4 proj;
 62};
 63layout(std430, set = 1, binding = 0) readonly buffer Transforms {
 64    mat4 models[];
 65};
 66
 67layout(location = 0) out vec2 fragUV;
 68
 69void main() {
 70    fragUV = inUV;
 71    gl_Position = proj * view * models[gl_InstanceIndex] * vec4(inPosition, 1.0);
 72}
 73"""
 74
 75FRAGMENT = """\
 76#version 450
 77layout(location = 0) in vec2 fragUV;
 78layout(location = 0) out vec4 outColour;
 79
 80layout(set = 2, binding = 0) uniform Params {
 81    float tint;
 82    float alpha;
 83    float uv_scale;
 84};
 85layout(set = 2, binding = 1) uniform texture2D albedo;
 86layout(set = 2, binding = 2) uniform sampler albedoSampler;
 87
 88void main() {
 89    vec4 texel = texture(sampler2D(albedo, albedoSampler), fragUV * uv_scale);
 90    outColour = vec4(texel.rgb * tint, texel.a * alpha);
 91}
 92"""
 93
 94
 95def two_tone(mask: np.ndarray, on: tuple, off: tuple) -> np.ndarray:
 96    """An RGBA image painted `on` where the mask holds and `off` elsewhere."""
 97    pixels = np.empty((*mask.shape, 4), dtype=np.uint8)
 98    pixels[mask], pixels[~mask] = on, off
 99    return pixels
100
101
102ys, xs = np.mgrid[0:64, 0:64]
103CHECKERBOARD = two_tone((xs // 8 + ys // 8) % 2 == 0, (230, 120, 40, 255), (40, 90, 220, 255))
104DIAGONALS = two_tone((xs + ys) // 8 % 2 == 0, (60, 200, 110, 255), (150, 60, 210, 255))
105
106
107def textured(tint: float, alpha: float = 1.0, uv_scale: float = 1.0, transparent: bool = False) -> ShaderMaterial:
108    material = ShaderMaterial(vertex_source=VERTEX, fragment_source=FRAGMENT, transparent=transparent)
109    material.set_uniform("tint", tint)
110    material.set_uniform("alpha", alpha)
111    material.set_uniform("uv_scale", uv_scale)
112    material.set_texture("albedo", CHECKERBOARD)
113    return material
114
115
116class TextureShaderScene(Node):
117    """A textured cube behind a transparent textured pane, over a tiled floor."""
118
119    input_context = {"orbit": [Axis1D(low=Key.A, high=Key.D)], "swap_texture": [Key.T]}
120
121    def on_ready(self):
122        self.camera = self.add_child(
123            OrbitCamera3D(
124                distance=7.0,
125                pitch=math.radians(-10),
126                max_pitch=math.radians(-3),
127                min_distance=4.0,
128                max_distance=30.0,
129            )
130        )
131        self.add_child(DirectionalLight3D())
132        self.materials = [textured(1.0), textured(0.4, alpha=0.45, transparent=True), textured(0.85, uv_scale=24.0)]
133        surfaces = [
134            MeshInstance3D(name="TexturedCube", mesh=Mesh.box(2.6)),
135            MeshInstance3D(name="GlassPane", mesh=Mesh.box((3.4, 3.4, 0.068)), position=(0.0, 0.0, 2.4)),
136            MeshInstance3D(name="TiledFloor", mesh=Mesh.plane(60.0), position=(0.0, -1.6, 0.0)),
137        ]
138        for surface, material in zip(surfaces, self.materials, strict=True):
139            surface.shader_material = material
140            self.add_child(surface)
141
142        # Example help overlay: not part of the technique shown.
143        hud = self.add_child(CanvasLayer())
144        hud.add_child(Label("Drag or A / D: orbit, wheel: zoom", anchor=Anchor.TOP_LEFT, inset=16))
145        hud.add_child(Button(action="swap_texture", text="Swap texture", anchor=Anchor.BOTTOM_LEFT, inset=16))
146
147    @on_input("swap_texture")
148    def swap_texture(self, _event):
149        image = DIAGONALS if self.materials[0].get_texture("albedo") is CHECKERBOARD else CHECKERBOARD
150        for material in self.materials:
151            material.set_texture("albedo", image)
152
153    def on_update(self, dt):
154        self.camera.orbit(Input.value("orbit") * dt, 0.0)
155
156
157if __name__ == "__main__":
158    App(width=1024, height=640, title="Custom Shader Textures").run(TextureShaderScene())