Custom Shader

ShaderMaterial with inline GLSL and animated uniforms.

▶ Run in browser

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