Custom Shader

ShaderMaterial with inline GLSL and animated uniforms.

▶ Run in browser

Tags: 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())