Custom Shader Textures¶
sampling a texture and blending from a ShaderMaterial.
▶ Run in browserTags: 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()