Custom Shader Textures

sampling a texture and blending from a ShaderMaterial.

▶ Run in browser

Tags: 3d

Demonstrates:

  • ShaderMaterial.set_texture() bound to the texture2D the shader declares

  • Separated texture + sampler declarations in the material’s own group

  • transparent=True: an alpha-blended custom material over opaque geometry

  • A tiled floor receding to the horizon: the minified half of the mip chain

  • Rebinding a texture on a LIVE material: T swaps all three surfaces to a second image mid-frame, and the next frame samples it

  • The same scene rendering identically on the Vulkan and web backends

Controls: A / D - Orbit camera T - Swap the texture every surface samples Escape - Quit

Usage: uv run python examples/features/3d/custom_shader_texture.py uv run python examples/features/3d/custom_shader_texture.py –test

Source

  1"""Custom Shader Textures: sampling a texture and blending from a ShaderMaterial.
  2
  3Demonstrates:
  4  - ShaderMaterial.set_texture() bound to the texture2D the shader declares
  5  - Separated texture + sampler declarations in the material's own group
  6  - transparent=True: an alpha-blended custom material over opaque geometry
  7  - A tiled floor receding to the horizon: the minified half of the mip chain
  8  - Rebinding a texture on a LIVE material: T swaps all three surfaces to a
  9    second image mid-frame, and the next frame samples it
 10  - The same scene rendering identically on the Vulkan and web backends
 11
 12Controls:
 13    A / D       - Orbit camera
 14    T           - Swap the texture every surface samples
 15    Escape      - Quit
 16
 17Usage:
 18    uv run python examples/features/3d/custom_shader_texture.py
 19    uv run python examples/features/3d/custom_shader_texture.py --test
 20"""
 21
 22import math
 23import sys
 24
 25import numpy as np
 26
 27from simvx.core import Camera3D, DirectionalLight3D, Input, Key, Material, Mesh, MeshInstance3D, Node, on_input
 28from simvx.graphics import App, ShaderMaterial
 29
 30# A material's own resources live in set 2: the uniform block at binding 0, then
 31# a SEPARATED texture and sampler, which is what both backends can bind (a
 32# combined `sampler2D` uniform has no WebGPU equivalent).
 33TEXTURED_VERT = """\
 34#version 450
 35layout(location = 0) in vec3 inPosition;
 36layout(location = 1) in vec3 inNormal;
 37layout(location = 2) in vec2 inUV;
 38
 39layout(set = 0, binding = 0) uniform Camera {
 40    mat4 view;
 41    mat4 proj;
 42};
 43layout(std430, set = 1, binding = 0) readonly buffer Transforms {
 44    mat4 models[];
 45};
 46
 47layout(location = 0) out vec2 fragUV;
 48
 49void main() {
 50    mat4 model = models[gl_InstanceIndex];
 51    fragUV = inUV;
 52    gl_Position = proj * view * model * vec4(inPosition, 1.0);
 53}
 54"""
 55
 56TEXTURED_FRAG = """\
 57#version 450
 58layout(location = 0) in vec2 fragUV;
 59layout(location = 0) out vec4 outColour;
 60
 61layout(set = 2, binding = 0) uniform Params {
 62    float tint;
 63    float alpha;
 64    float uv_scale;
 65};
 66layout(set = 2, binding = 1) uniform texture2D albedo;
 67layout(set = 2, binding = 2) uniform sampler albedoSampler;
 68
 69void main() {
 70    // uv_scale > 1 tiles the texture: the floor uses it, so the far half of the
 71    // surface is minified and reads from the texture's mip chain.
 72    vec4 texel = texture(sampler2D(albedo, albedoSampler), fragUV * uv_scale);
 73    outColour = vec4(texel.rgb * tint, texel.a * alpha);
 74}
 75"""
 76
 77
 78def checkerboard(size: int = 64, cells: int = 8) -> np.ndarray:
 79    """An RGBA checkerboard: orange and blue squares, no asset file needed."""
 80    pixels = np.zeros((size, size, 4), dtype=np.uint8)
 81    step = size // cells
 82    ys, xs = np.mgrid[0:size, 0:size]
 83    dark = ((xs // step) + (ys // step)) % 2 == 0
 84    pixels[dark] = (230, 120, 40, 255)
 85    pixels[~dark] = (40, 90, 220, 255)
 86    return pixels
 87
 88
 89def diagonals(size: int = 64, cells: int = 8) -> np.ndarray:
 90    """The swap target: green and violet diagonal bands, same size, no file."""
 91    pixels = np.zeros((size, size, 4), dtype=np.uint8)
 92    step = size // cells
 93    ys, xs = np.mgrid[0:size, 0:size]
 94    band = ((xs + ys) // step) % 2 == 0
 95    pixels[band] = (60, 200, 110, 255)
 96    pixels[~band] = (150, 60, 210, 255)
 97    return pixels
 98
 99
100class TextureShaderScene(Node):
101    """A textured cube behind a transparent textured pane, over a tiled floor."""
102
103    input_actions = {
104        "orbit_left": [Key.A],
105        "orbit_right": [Key.D],
106        "swap_texture": [Key.T],
107        "quit": [Key.ESCAPE],
108    }
109
110    def on_ready(self):
111        self.angle = 0.0
112        # The two images T alternates between, and the materials it rebinds.
113        self._images = (checkerboard(), diagonals())
114        self._image = 0
115        self._materials: list[ShaderMaterial] = []
116        self.camera = Camera3D(name="Camera", fov=60, near=0.1, far=100.0, position=(0.0, 1.2, 7.0))
117        self.camera.look_at((0.0, 0.0, 0.0))
118        self.add_child(self.camera)
119        self.add_child(DirectionalLight3D(name="Sun", intensity=1.0))
120
121        pixels = checkerboard()
122
123        opaque = ShaderMaterial(vertex_source=TEXTURED_VERT, fragment_source=TEXTURED_FRAG)
124        opaque.set_uniform("tint", 1.0)
125        opaque.set_uniform("alpha", 1.0)
126        opaque.set_uniform("uv_scale", 1.0)
127        opaque.set_texture("albedo", pixels)
128        self._materials.append(opaque)
129        cube = MeshInstance3D(
130            name="TexturedCube",
131            mesh=Mesh.cube(size=2.6),
132            material=Material(colour=(1, 1, 1)),
133            position=(0.0, 0.0, 0.0),
134        )
135        cube.shader_material = opaque
136        self.add_child(cube)
137
138        # The same shader, transparent: alpha comes out of the fragment shader
139        # and the pipeline composites it over the cube behind.
140        glass = ShaderMaterial(vertex_source=TEXTURED_VERT, fragment_source=TEXTURED_FRAG, transparent=True)
141        glass.set_uniform("tint", 0.4)
142        glass.set_uniform("alpha", 0.45)
143        glass.set_uniform("uv_scale", 1.0)
144        glass.set_texture("albedo", pixels)
145        self._materials.append(glass)
146        pane = MeshInstance3D(
147            name="GlassPane",
148            mesh=Mesh.cube(size=3.4),
149            material=Material(colour=(1, 1, 1)),
150            position=(0.0, 0.0, 2.4),
151            scale=(1.0, 1.0, 0.02),
152        )
153        pane.shader_material = glass
154        self.add_child(pane)
155
156        # A floor tiling the same texture 24 times each way. Its far half is
157        # minified far below one texel per pixel, which is the only part of a
158        # scene that can tell whether a sampler reads the mip chain or clings to
159        # level 0 -- so it is what keeps the two backends honest about sampling.
160        floor_material = ShaderMaterial(vertex_source=TEXTURED_VERT, fragment_source=TEXTURED_FRAG)
161        floor_material.set_uniform("tint", 0.85)
162        floor_material.set_uniform("alpha", 1.0)
163        floor_material.set_uniform("uv_scale", 24.0)
164        floor_material.set_texture("albedo", pixels)
165        self._materials.append(floor_material)
166        floor = MeshInstance3D(
167            name="TiledFloor",
168            mesh=Mesh.cube(size=1.0),
169            material=Material(colour=(1, 1, 1)),
170            position=(0.0, -1.6, 0.0),
171            scale=(60.0, 0.04, 60.0),
172        )
173        floor.shader_material = floor_material
174        self.add_child(floor)
175
176    @on_input(action="swap_texture")
177    def _swap_texture(self, _event):
178        """Rebind every surface's albedo to the other image, while it renders.
179
180        set_texture() on a material already on screen is all a live swap takes.
181        The texture lives in the material's own descriptor set -- the group the
182        shader declares ``albedo`` in -- and that set is built to be updated after
183        binding, so its texture descriptor may be rewritten while submitted frames
184        are still reading it. Those frames may show either image; the ones after
185        them sample the new one, with no pipeline rebuild and no reload.
186        """
187        self._image = 1 - self._image
188        for material in self._materials:
189            material.set_texture("albedo", self._images[self._image])
190        return True
191
192    def on_update(self, dt):
193        if Input.is_action_pressed("quit"):
194            self.app.quit()
195        if Input.is_action_pressed("orbit_left"):
196            self.angle -= dt
197        if Input.is_action_pressed("orbit_right"):
198            self.angle += dt
199        radius = 7.0
200        self.camera.position = (math.sin(self.angle) * radius, 1.2, math.cos(self.angle) * radius)
201        self.camera.look_at((0.0, 0.0, 0.0))
202
203
204def _selftest() -> bool:
205    """Render headlessly and check the texture, the blend, and the live swap.
206
207    The swap is pressed through the same action a player presses, and it is
208    judged on captured pixels rather than on the material's own state: a rebind
209    that never reached the descriptor set would leave the frame unchanged.
210    """
211    from simvx.core.testing import InputSimulator
212
213    SWAP = 6
214    FRAMES = 14
215
216    app = App(width=320, height=240, title="Custom Shader Textures")
217    scene = TextureShaderScene()
218    sim = InputSimulator()
219
220    def on_frame(idx: int, _t: float) -> bool:
221        if idx == SWAP:
222            sim.press_key(Key.T)
223        elif idx == SWAP + 1:
224            sim.release_key(Key.T)
225        return True
226
227    # The camera is still, so any difference between these two frames is the
228    # texture and nothing else.
229    before, after = app.run_headless(scene, frames=FRAMES, on_frame=on_frame, capture_frames=[SWAP - 1, FRAMES - 1])
230
231    ok = True
232
233    def check(label: str, passed: bool, detail: str) -> None:
234        nonlocal ok
235        ok = ok and passed
236        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
237
238    lit = before[..., :3].astype(int)
239    coloured = lit[lit.sum(axis=2) > 60]
240    check(
241        "the shader samples its texture and blends the pane over the cube",
242        coloured.shape[0] > 500 and int(coloured.std(axis=0).max()) > 12,
243        f"{coloured.shape[0]} lit pixels, colour spread {coloured.std(axis=0).max():.1f}",
244    )
245
246    # The checkerboard is orange/blue and the swap target is green/violet, so
247    # the lit part of the frame moves toward green when the rebind lands.
248    def greenness(frame: np.ndarray) -> float:
249        rgb = frame[..., :3].astype(int)
250        surface = rgb[rgb.sum(axis=2) > 60]
251        return float(surface[:, 1].mean() - surface[:, 0].mean())
252
253    moved = float(np.abs(after[..., :3].astype(int) - before[..., :3].astype(int)).mean())
254    check(
255        "T rebinds the live material and the next frames sample the new image",
256        moved > 10.0 and greenness(after) > greenness(before) + 20.0,
257        f"the frame moved {moved:.1f} per channel, green-over-red {greenness(before):.1f} -> {greenness(after):.1f}",
258    )
259    check(
260        "and it is one image for all three surfaces",
261        all(m.get_texture("albedo") is scene._images[1] for m in scene._materials),
262        f"{len(scene._materials)} materials rebound to the same array",
263    )
264
265    print("SELFTEST:", "PASS" if ok else "FAIL")
266    return ok
267
268
269def main():
270    if "--test" in sys.argv:
271        sys.exit(0 if _selftest() else 1)
272    App(width=1024, height=640, title="Custom Shader Textures").run(TextureShaderScene())
273
274
275if __name__ == "__main__":
276    main()