Custom Shader¶
ShaderMaterial API preview with animated uniforms.
▶ Run in browserTags: 3d
Demonstrates:
ShaderMaterial with inline GLSL vertex and fragment source
Per-frame uniform animation (time, amplitude, colour)
Multiple objects with different shader parameters
Standard Material objects alongside custom shaders
Controls: A / D - Orbit camera W / S - Zoom in / out 1 / 2 - Adjust wave amplitude Escape - Quit
Usage: uv run python examples/features/3d/custom_shader.py
Source¶
1"""Custom Shader: ShaderMaterial API preview with animated uniforms.
2
3Demonstrates:
4 - ShaderMaterial with inline GLSL vertex and fragment source
5 - Per-frame uniform animation (time, amplitude, colour)
6 - Multiple objects with different shader parameters
7 - Standard Material objects alongside custom shaders
8
9Controls:
10 A / D - Orbit camera
11 W / S - Zoom in / out
12 1 / 2 - Adjust wave amplitude
13 Escape - Quit
14
15Usage:
16 uv run python examples/features/3d/custom_shader.py
17"""
18
19
20import math
21import sys
22
23from simvx.core import (
24 Camera3D,
25 DirectionalLight3D,
26 Input,
27 InputMap,
28 Key,
29 Material,
30 Mesh,
31 MeshInstance3D,
32 Node,
33 Text2D,
34)
35from simvx.graphics import App, ShaderMaterial
36
37# -- Inline GLSL shaders --
38# Wave vertex shader: sine displacement along Y based on time + position
39WAVE_VERT = """\
40#version 450
41layout(location = 0) in vec3 inPosition;
42layout(location = 1) in vec3 inNormal;
43layout(location = 2) in vec2 inUV;
44
45layout(set = 0, binding = 0) uniform Camera {
46 mat4 view;
47 mat4 proj;
48};
49
50layout(std430, set = 1, binding = 0) readonly buffer Transforms {
51 mat4 models[];
52};
53
54layout(location = 0) out vec3 fragWorldPos;
55layout(location = 1) out vec3 fragNormal;
56layout(location = 2) out vec2 fragUV;
57
58layout(set = 2, binding = 0) uniform Params {
59 float time;
60 float amplitude;
61};
62
63void main() {
64 mat4 model = models[gl_InstanceIndex];
65 vec3 pos = inPosition;
66 // Sine-wave vertex displacement
67 pos.y += sin(pos.x * 3.0 + time * 2.0) * amplitude * 0.3;
68 pos.y += cos(pos.z * 2.5 + time * 1.5) * amplitude * 0.2;
69
70 vec4 worldPos = model * vec4(pos, 1.0);
71 fragWorldPos = worldPos.xyz;
72 fragNormal = mat3(model) * inNormal;
73 fragUV = inUV;
74 gl_Position = proj * view * worldPos;
75}
76"""
77
78# Animated gradient fragment shader: colour shifts with time
79WAVE_FRAG = """\
80#version 450
81layout(location = 0) in vec3 fragWorldPos;
82layout(location = 1) in vec3 fragNormal;
83layout(location = 2) in vec2 fragUV;
84
85layout(location = 0) out vec4 outColour;
86
87layout(set = 2, binding = 0) uniform Params {
88 float time;
89 float amplitude;
90};
91
92void main() {
93 // Animated gradient based on world position and time
94 float r = sin(fragWorldPos.x * 0.5 + time) * 0.5 + 0.5;
95 float g = sin(fragWorldPos.z * 0.5 + time * 0.7 + 2.094) * 0.5 + 0.5;
96 float b = sin(time * 0.5 + 4.189) * 0.5 + 0.5;
97 // Simple diffuse lighting
98 vec3 N = normalize(fragNormal);
99 vec3 L = normalize(vec3(1.0, 2.0, 1.0));
100 float diff = max(dot(N, L), 0.15);
101 outColour = vec4(vec3(r, g, b) * diff, 1.0);
102}
103"""
104
105# Colour-pulse fragment shader: single pulsing colour
106PULSE_FRAG = """\
107#version 450
108layout(location = 0) in vec3 fragWorldPos;
109layout(location = 1) in vec3 fragNormal;
110layout(location = 2) in vec2 fragUV;
111
112layout(location = 0) out vec4 outColour;
113
114layout(set = 2, binding = 0) uniform Params {
115 float time;
116 float amplitude;
117};
118
119void main() {
120 // Pulsing warm colour
121 float pulse = sin(time * 3.0) * 0.5 + 0.5;
122 vec3 colour = mix(vec3(0.9, 0.2, 0.1), vec3(1.0, 0.8, 0.2), pulse);
123 // Simple diffuse
124 vec3 N = normalize(fragNormal);
125 vec3 L = normalize(vec3(-1.0, 2.0, 0.5));
126 float diff = max(dot(N, L), 0.15);
127 outColour = vec4(colour * diff * (0.8 + amplitude * 0.1), 1.0);
128}
129"""
130
131
132class CustomShaderDemo(Node):
133 def on_ready(self):
134 InputMap.add_action("orbit_left", [Key.A])
135 InputMap.add_action("orbit_right", [Key.D])
136 InputMap.add_action("zoom_in", [Key.W])
137 InputMap.add_action("zoom_out", [Key.S])
138 InputMap.add_action("amp_up", [Key.KEY_1])
139 InputMap.add_action("amp_down", [Key.KEY_2])
140 InputMap.add_action("quit", [Key.ESCAPE])
141
142 # Camera orbit state
143 self._yaw = 30.0
144 self._distance = 14.0
145 self._pitch = 25.0
146
147 self._cam = Camera3D(name="Camera", fov=60, near=0.1, far=100.0)
148 self.add_child(self._cam)
149
150 # Directional light
151 sun = DirectionalLight3D(name="Sun", intensity=1.0)
152 sun.look_at((-1.0, -2.0, -1.0))
153 self.add_child(sun)
154
155 # -- Wave shader material (gradient + vertex displacement) --
156 self._wave_shader = ShaderMaterial(vertex_source=WAVE_VERT, fragment_source=WAVE_FRAG)
157 self._wave_shader.set_uniform("time", 0.0)
158 self._wave_shader.set_uniform("amplitude", 1.0)
159
160 # -- Pulse shader material (colour pulse, shared vertex shader) --
161 self._pulse_shader = ShaderMaterial(vertex_source=WAVE_VERT, fragment_source=PULSE_FRAG)
162 self._pulse_shader.set_uniform("time", 0.0)
163 self._pulse_shader.set_uniform("amplitude", 1.0)
164
165 # Sphere with wave shader (left)
166 wave_sphere = MeshInstance3D(
167 name="WaveSphere",
168 mesh=Mesh.sphere(radius=1.5, rings=32, segments=32),
169 material=Material(colour=(1.0, 1.0, 1.0)),
170 position=(-3.5, 1.5, 0.0),
171 )
172 wave_sphere.shader_material = self._wave_shader
173 self.add_child(wave_sphere)
174
175 # Cube with pulse shader (right)
176 pulse_cube = MeshInstance3D(
177 name="PulseCube",
178 mesh=Mesh.cube(size=2.0),
179 material=Material(colour=(1.0, 1.0, 1.0)),
180 position=(3.5, 1.5, 0.0),
181 )
182 pulse_cube.shader_material = self._pulse_shader
183 self.add_child(pulse_cube)
184
185 # Ground plane (standard material, no custom shader)
186 ground = MeshInstance3D(
187 name="Ground",
188 mesh=Mesh.cube(),
189 material=Material(colour=(0.35, 0.4, 0.35), roughness=0.9),
190 position=(0.0, -0.05, 0.0),
191 scale=(20.0, 0.1, 20.0),
192 )
193 self.add_child(ground)
194
195 # Reference cubes (standard materials) for comparison
196 for i, colour in enumerate([(0.8, 0.2, 0.3), (0.2, 0.3, 0.8), (0.2, 0.8, 0.3)]):
197 ref = MeshInstance3D(
198 name=f"Ref{i}",
199 mesh=Mesh.cube(),
200 material=Material(colour=colour, roughness=0.4, metallic=0.2),
201 position=(-3.0 + i * 3.0, 0.5, -4.0),
202 )
203 self.add_child(ref)
204
205 # HUD
206 self._hud = Text2D(name="HUD", text="", font_scale=1.2, position=(10.0, 10.0))
207 self.add_child(self._hud)
208
209 self._time = 0.0
210 self._amplitude = 1.0
211 self._update_camera()
212
213 def on_update(self, dt):
214 self._time += dt
215
216 # Camera orbit
217 if Input.is_action_pressed("orbit_left"):
218 self._yaw += 60.0 * dt
219 if Input.is_action_pressed("orbit_right"):
220 self._yaw -= 60.0 * dt
221 if Input.is_action_pressed("zoom_in"):
222 self._distance = max(5.0, self._distance - 8.0 * dt)
223 if Input.is_action_pressed("zoom_out"):
224 self._distance = min(30.0, self._distance + 8.0 * dt)
225
226 # Amplitude adjustment
227 if Input.is_action_pressed("amp_up"):
228 self._amplitude = min(3.0, self._amplitude + 1.5 * dt)
229 if Input.is_action_pressed("amp_down"):
230 self._amplitude = max(0.1, self._amplitude - 1.5 * dt)
231
232 if Input.is_action_just_pressed("quit"):
233 self.app.quit()
234 return
235
236 # Update shader uniforms each frame
237 self._wave_shader.set_uniform("time", self._time)
238 self._wave_shader.set_uniform("amplitude", self._amplitude)
239 self._pulse_shader.set_uniform("time", self._time)
240 self._pulse_shader.set_uniform("amplitude", self._amplitude)
241
242 # Slowly rotate the shader objects
243 for child in self.children:
244 if child.name in ("WaveSphere", "PulseCube"):
245 child.rotate_y(math.radians(30.0) * dt)
246
247 self._update_camera()
248 self._update_hud()
249
250 def _update_camera(self):
251 yaw_rad = math.radians(self._yaw)
252 pitch_rad = math.radians(self._pitch)
253 cp = math.cos(pitch_rad)
254 x = self._distance * cp * math.sin(yaw_rad)
255 y = self._distance * math.sin(pitch_rad)
256 z = self._distance * cp * math.cos(yaw_rad)
257 self._cam.position = (x, y, z)
258 self._cam.look_at((0.0, 1.0, 0.0))
259
260 def _update_hud(self):
261 wave_info = f"Uniforms: time={self._time:.1f} amplitude={self._amplitude:.2f}"
262 if sys.platform == "emscripten":
263 # Web runtime: shaders are transpiled to WGSL at export time, so the
264 # desktop Vulkan compile flag never flips. Report the web reality.
265 compiled = "pre-compiled (WGSL)"
266 else:
267 compiled = "yes" if self._wave_shader.is_compiled else "no"
268 lines = [
269 "Custom Shader Demo",
270 f" Wave shader compiled: {compiled}",
271 f" Pulse shader uniforms: {len(self._pulse_shader.uniforms)}",
272 f" {wave_info}",
273 "[1/2] Amplitude [A/D] Orbit [W/S] Zoom [Esc] Quit",
274 ]
275 self._hud.text = "\n".join(lines)
276
277
278if __name__ == "__main__":
279 app = App(title="Custom Shader Demo", width=1280, height=720)
280 app.run(CustomShaderDemo())