Custom Shader¶
ShaderMaterial with inline GLSL and animated uniforms.
▶ Run in browserTags: 3d
A ShaderMaterial draws a mesh with your own GLSL vertex and fragment shaders
instead of the engine’s standard material. Build one from source strings (or
vertex_path= / fragment_path= files) and hang it on a MeshInstance3D:
shader = ShaderMaterial(vertex_source=WAVE_VERT, fragment_source=WAVE_FRAG)
sphere = MeshInstance3D(mesh=Mesh.sphere(radius=1.5))
sphere.shader_material = shader
A shader reads the engine’s bindings: the camera’s view and proj at
set 0, the per-instance model matrices at set 1 (indexed by
gl_InstanceIndex), and the vertex position, normal and UV at locations 0 to 2.
Your own uniforms live in one block at set 2, binding 0. Set them by name with
shader.set_uniform("time", t); the type is inferred from the Python value,
and setting them every frame animates the shader.
Both shaders here share one vertex shader that ripples the surface with a sine
wave scaled by amplitude. The sphere’s fragment shader paints a gradient that
drifts with time; the cube’s pulses between red and gold. The ground and the
three cubes behind use the standard Material, to show the two side by side.
Controls: Drag / right stick - Orbit the camera Scroll / triggers - Zoom A / D - Orbit the camera W / S - Zoom in / out 1 / 2 - Raise / lower the wave amplitude
Source¶
1"""Custom Shader: ShaderMaterial with inline GLSL and animated uniforms.
2
3A ``ShaderMaterial`` draws a mesh with your own GLSL vertex and fragment shaders
4instead of the engine's standard material. Build one from source strings (or
5``vertex_path=`` / ``fragment_path=`` files) and hang it on a ``MeshInstance3D``:
6
7 shader = ShaderMaterial(vertex_source=WAVE_VERT, fragment_source=WAVE_FRAG)
8 sphere = MeshInstance3D(mesh=Mesh.sphere(radius=1.5))
9 sphere.shader_material = shader
10
11A shader reads the engine's bindings: the camera's ``view`` and ``proj`` at
12set 0, the per-instance model matrices at set 1 (indexed by
13``gl_InstanceIndex``), and the vertex position, normal and UV at locations 0 to 2.
14Your own uniforms live in one block at set 2, binding 0. Set them by name with
15``shader.set_uniform("time", t)``; the type is inferred from the Python value,
16and setting them every frame animates the shader.
17
18Both shaders here share one vertex shader that ripples the surface with a sine
19wave scaled by ``amplitude``. The sphere's fragment shader paints a gradient that
20drifts with ``time``; the cube's pulses between red and gold. The ground and the
21three cubes behind use the standard ``Material``, to show the two side by side.
22
23Controls:
24 Drag / right stick - Orbit the camera
25 Scroll / triggers - Zoom
26 A / D - Orbit the camera
27 W / S - Zoom in / out
28 1 / 2 - Raise / lower the wave amplitude
29"""
30
31import math
32
33from simvx.core import (
34 Anchor,
35 Axis1D,
36 CanvasLayer,
37 DirectionalLight3D,
38 Input,
39 Key,
40 Label,
41 Material,
42 Mesh,
43 MeshInstance3D,
44 Node,
45 OrbitCamera3D,
46 Property,
47)
48from simvx.graphics import App, ShaderMaterial
49
50# The uniform block both stages declare; set_uniform fills it by member name.
51PARAMS = """\
52layout(set = 2, binding = 0) uniform Params {
53 float time;
54 float amplitude;
55};
56"""
57
58WAVE_VERT = (
59 """\
60#version 450
61layout(location = 0) in vec3 inPosition;
62layout(location = 1) in vec3 inNormal;
63layout(location = 2) in vec2 inUV;
64
65layout(set = 0, binding = 0) uniform Camera {
66 mat4 view;
67 mat4 proj;
68};
69layout(std430, set = 1, binding = 0) readonly buffer Transforms {
70 mat4 models[];
71};
72"""
73 + PARAMS
74 + """
75layout(location = 0) out vec3 fragWorldPos;
76layout(location = 1) out vec3 fragNormal;
77
78void main() {
79 mat4 model = models[gl_InstanceIndex];
80 vec3 pos = inPosition;
81 pos.y += sin(pos.x * 3.0 + time * 2.0) * amplitude * 0.3;
82 pos.y += cos(pos.z * 2.5 + time * 1.5) * amplitude * 0.2;
83 vec4 worldPos = model * vec4(pos, 1.0);
84 fragWorldPos = worldPos.xyz;
85 fragNormal = mat3(model) * inNormal;
86 gl_Position = proj * view * worldPos;
87}
88"""
89)
90
91WAVE_FRAG = (
92 """\
93#version 450
94layout(location = 0) in vec3 fragWorldPos;
95layout(location = 1) in vec3 fragNormal;
96layout(location = 0) out vec4 outColour;
97"""
98 + PARAMS
99 + """
100void main() {
101 vec3 colour = vec3(
102 sin(fragWorldPos.x * 0.5 + time) * 0.5 + 0.5,
103 sin(fragWorldPos.z * 0.5 + time * 0.7 + 2.094) * 0.5 + 0.5,
104 sin(time * 0.5 + 4.189) * 0.5 + 0.5);
105 float diffuse = max(dot(normalize(fragNormal), normalize(vec3(1.0, 2.0, 1.0))), 0.15);
106 outColour = vec4(colour * diffuse, 1.0);
107}
108"""
109)
110
111PULSE_FRAG = (
112 """\
113#version 450
114layout(location = 0) in vec3 fragWorldPos;
115layout(location = 1) in vec3 fragNormal;
116layout(location = 0) out vec4 outColour;
117"""
118 + PARAMS
119 + """
120void main() {
121 vec3 colour = mix(vec3(0.9, 0.2, 0.1), vec3(1.0, 0.8, 0.2), sin(time * 3.0) * 0.5 + 0.5);
122 float diffuse = max(dot(normalize(fragNormal), normalize(vec3(-1.0, 2.0, 0.5))), 0.15);
123 outColour = vec4(colour * diffuse * (0.8 + amplitude * 0.1), 1.0);
124}
125"""
126)
127
128
129class CustomShaderDemo(Node):
130 input_context = {
131 "orbit": [Axis1D(low=Key.D, high=Key.A)],
132 "zoom": [Axis1D(low=Key.S, high=Key.W)],
133 "amplitude": [Axis1D(low=Key.KEY_2, high=Key.KEY_1)],
134 }
135 amplitude = Property(1.0, range=(0.1, 3.0))
136
137 def on_ready(self):
138 self.time = 0.0
139 self.camera = self.add_child(
140 OrbitCamera3D(
141 pivot=(0.0, 1.0, 0.0),
142 distance=14.0,
143 yaw=math.radians(30),
144 pitch=math.radians(-25),
145 min_distance=5.0,
146 max_distance=30.0,
147 )
148 )
149 self.add_child(DirectionalLight3D(direction=(-1.0, -2.0, -1.0)))
150
151 self.wave = ShaderMaterial(vertex_source=WAVE_VERT, fragment_source=WAVE_FRAG)
152 self.pulse = ShaderMaterial(vertex_source=WAVE_VERT, fragment_source=PULSE_FRAG)
153 self.sphere = self.add_child(MeshInstance3D(mesh=Mesh.sphere(radius=1.5), position=(-3.5, 1.5, 0.0)))
154 self.sphere.shader_material = self.wave
155 self.cube = self.add_child(MeshInstance3D(mesh=Mesh.box(2.0), position=(3.5, 1.5, 0.0)))
156 self.cube.shader_material = self.pulse
157
158 ground = Material(colour=(0.35, 0.4, 0.35), roughness=0.9)
159 self.add_child(MeshInstance3D(mesh=Mesh.plane(20.0), material=ground))
160 for i, colour in enumerate([(0.8, 0.2, 0.3), (0.2, 0.3, 0.8), (0.2, 0.8, 0.3)]):
161 material = Material(colour=colour, roughness=0.4, metallic=0.2)
162 self.add_child(MeshInstance3D(mesh=Mesh.box(), material=material, position=(-3.0 + i * 3.0, 0.5, -4.0)))
163
164 hud = self.add_child(CanvasLayer())
165 self.readout = hud.add_child(Label(anchor=Anchor.TOP_LEFT, inset=10, font_size=16))
166
167 # Example help overlay: not part of the technique shown.
168 help_text = "[1/2] amplitude [A/D] or drag: orbit [W/S] or scroll: zoom"
169 hud.add_child(Label(help_text, anchor=Anchor.BOTTOM_LEFT, inset=10, font_size=14))
170
171 def on_update(self, dt):
172 self.time += dt
173 self.amplitude += Input.value("amplitude") * 1.5 * dt
174 self.camera.orbit(Input.value("orbit") * math.radians(60.0) * dt, 0.0)
175 self.camera.zoom(Input.value("zoom") * 8.0 * dt)
176
177 for shader in (self.wave, self.pulse):
178 shader.set_uniform("time", self.time)
179 shader.set_uniform("amplitude", self.amplitude)
180
181 spin = math.radians(30.0) * dt
182 self.sphere.rotate_y(spin)
183 self.cube.rotate_y(spin)
184
185 self.readout.text = f"Uniforms: time={self.time:.1f} amplitude={self.amplitude:.2f}"
186
187
188if __name__ == "__main__":
189 App(title="Custom Shader", width=1280, height=720).run(CustomShaderDemo())