How to Implement Custom Rendering Using Shaders and the ShaderWrapper in Manim
Use Manim's ShaderWrapper class to encapsulate custom GLSL shaders, vertex buffers, and uniforms, allowing high-performance custom rendering without managing raw OpenGL boilerplate.
Manim’s rendering engine leverages ModernGL to communicate with the GPU, and the ShaderWrapper abstraction in the 3b1b/manim repository provides a Pythonic interface for injecting custom shader code. By extending either the base ShaderWrapper for general-purpose rendering or VShaderWrapper for vectorized graphics, you can implement particle systems, custom lighting, or procedural geometry while Manim handles context management, buffer allocation, and the render loop automatically.
Understanding the ShaderWrapper Architecture
The shader system centers on two primary wrapper classes and a suite of utility functions that manage GLSL compilation, vertex attribute binding, and efficient uniform updates.
ShaderWrapper Base Class
The ShaderWrapper class in manimlib/shader_wrapper.py provides the foundation for custom GPU rendering. It creates a ModernGL Program from vertex, geometry, and fragment shader files, constructs a VBO/VAO pair for the supplied vertex data, and manages texture binding and uniform updates. The class orchestrates the full rendering lifecycle: init_program_code() loads GLSL sources, init_program() compiles the ModernGL Program, and render() executes the draw call.
VShaderWrapper for VMobject Rendering
For complex vector graphics requiring fill and stroke rendering, VShaderWrapper (also in manimlib/shader_wrapper.py) extends the base class with specialized functionality. It compiles four separate shader programs—stroke, fill, border, and depth—and manages a shared off-screen framebuffer for the winding-number fill technique that Manim uses to render complex shapes with holes and self-intersections correctly.
Utility Functions for Shader Management
Located in manimlib/utils/shaders.py, these helpers streamline shader development:
get_shader_code_from_file– Locates shader files in the Manim shader directory and processes#INSERTdirectives for reusable code snippets.image_path_to_texture– Converts image files into ModernGL textures for fragment shader sampling.set_program_uniform– Updates uniform values while caching previous states inPROGRAM_UNIFORM_MIRRORSto avoid redundant GPU writes.
Step-by-Step Implementation Guide
Implementing custom shaders requires defining structured vertex data, initializing the wrapper with shader paths, and managing uniforms through the scene loop.
Creating a Custom ShaderWrapper
Instantiate ShaderWrapper with the ModernGL context from the camera, the path to a folder containing vert.glsl and frag.glsl files, and any initial uniforms:
from manimlib.shader_wrapper import ShaderWrapper
self.shader = ShaderWrapper(
ctx=self.camera.context,
vert_data=self.vert_data,
shader_folder="shaders/custom_effect",
mobject_uniforms={"time": 0.0, "amplitude": 1.0},
depth_test=False,
)
The wrapper automatically calls init_program_code() to load GLSL sources and init_program() to compile the ModernGL Program.
Defining Vertex Data and Attributes
Manim uses structured NumPy arrays to define vertex attributes. The moderngl.detect_format function automatically translates NumPy dtypes into OpenGL attribute format strings:
import numpy as np
dtype = [
("position", "3f"),
("color", "4f"),
("uv", "2f")
]
verts = np.zeros(100, dtype=dtype)
verts["position"] = np.random.rand(100, 3)
verts["color"] = np.random.rand(100, 4)
The read_in() method concatenates vertex data, while generate_vaos() creates VertexArray objects that bind the VBO to the shader program attributes. Ensure your GLSL attribute names match the NumPy field names exactly.
Managing Uniforms and Textures
Update uniforms dynamically through the scene loop using the wrapper's mobject_uniforms dictionary:
def update_uniforms(mobject, dt):
mobject.shader.mobject_uniforms["time"] += dt
mobject.shader.mobject_uniforms["resolution"] = [
mobject.camera.frame_width,
mobject.camera.frame_height
]
self.add_updater(update_uniforms)
For textures, provide a mapping of sampler names to image paths:
self.shader = ShaderWrapper(
# ... other arguments ...
texture_paths={"noise_texture": "assets/noise.png"}
)
The init_textures() method loads these via image_path_to_texture and binds them to texture units before rendering. The update_program_uniforms() method efficiently updates GPU state while avoiding redundant writes through the PROGRAM_UNIFORM_MIRRORS cache.
Complete Code Examples
Example 1: Point Cloud with Custom Vertex Shader
This example creates a PointCloudMobject using the base ShaderWrapper to render 500 animated points with per-vertex colors:
from manimlib import *
import numpy as np
from manimlib.shader_wrapper import ShaderWrapper
class PointCloudMobject(VMobject):
def __init__(self, points, **kwargs):
super().__init__(**kwargs)
# Structured array: position (3f) and color (4f)
dtype = [("position", "3f"), ("color", "4f")]
self.vert_data = np.array(list(points), dtype=dtype)
self.shader = ShaderWrapper(
ctx=self.camera.context,
vert_data=self.vert_data,
shader_folder="shaders/simple_vert",
mobject_uniforms={"time": 0.0},
depth_test=False,
)
# Update time uniform each frame
self.add_updater(lambda m, dt: m.shader.mobject_uniforms.update(time=m.time))
def draw(self):
self.shader.pre_render()
self.shader.render()
class CustomShaderScene(Scene):
def construct(self):
# Generate 500 random points with random colors
pts = [(np.random.rand(3), np.random.rand(4)) for _ in range(500)]
point_cloud = PointCloudMobject(pts).scale(3)
self.add(point_cloud)
self.wait(5)
The ShaderWrapper automatically detects the vertex format from the NumPy dtype, creates the VBO and VAO, and handles the render loop integration through the draw() method.
Example 2: Custom Fillable VMobject with VShaderWrapper
For objects requiring complex fills and strokes, use VShaderWrapper to leverage Manim’s winding-number fill technique:
from manimlib import *
import numpy as np
from manimlib.shader_wrapper import VShaderWrapper
class WavyRect(VMobject):
def __init__(self, width=4, height=2, **kwargs):
super().__init__(**kwargs)
# Define vertex structure for VMobject rendering
verts = np.zeros(6, dtype=[
("point", "3f"),
("stroke_rgba", "4f"),
("stroke_width", "f4"),
("joint_angle", "f4"),
("fill_rgba", "4f"),
("base_normal", "3f"),
])
# Populate rectangular mesh data
verts["point"] = np.array([
[-width/2, -height/2, 0],
[width/2, -height/2, 0],
[width/2, height/2, 0],
[-width/2, height/2, 0],
[-width/2, -height/2, 0],
[-width/2, height/2, 0],
])
verts["stroke_rgba"] = [1, 1, 1, 1]
verts["fill_rgba"] = [0.3, 0.5, 0.8, 1.0]
self.vert_data = verts
self.shader = VShaderWrapper(
ctx=self.camera.context,
vert_data=self.vert_data,
shader_folder="shaders/quadratic_bezier",
mobject_uniforms={"wave_amp": 0.1},
depth_test=True,
)
# Animate the wave amplitude uniform
self.add_updater(lambda m, dt: m.shader.mobject_uniforms.update(
wave_amp=0.1 * np.sin(m.time)
))
def draw(self):
self.shader.pre_render()
self.shader.render()
class WaveFillScene(Scene):
def construct(self):
rect = WavyRect().shift(LEFT)
self.add(rect)
self.wait(6)
VShaderWrapper automatically creates the off-screen fill canvas, manages four separate shader programs, and composites the final image using the winding-number technique, enabling complex fillable shapes without manual framebuffer management.
Key Source Files and References
| File | Role | Key Components |
|---|---|---|
manimlib/shader_wrapper.py |
Core wrapper implementations | ShaderWrapper class, VShaderWrapper class, init_program(), generate_vaos(), render() |
manimlib/utils/shaders.py |
Shader loading utilities | get_shader_code_from_file(), image_path_to_texture(), set_program_uniform(), PROGRAM_UNIFORM_MIRRORS |
manimlib/shaders/simple_vert.glsl |
Minimal vertex shader example | Basic vertex/fragment pair for point rendering |
manimlib/shaders/quadratic_bezier/ |
VMobject shader suite | stroke/vert.glsl, fill/vert.glsl, border/frag.glsl |
manimlib/utils/directories.py |
Path resolution | get_shader_dir() for locating shader files |
The ShaderWrapper system uses ModernGL to abstract OpenGL context management. When read_in() processes vertex data, it checks if the existing VBO can accommodate the new data or if it must allocate a new GPU buffer. The generate_vaos() method then binds these buffers to shader attributes using the format string detected from the NumPy dtype.
Summary
ShaderWrapperinmanimlib/shader_wrapper.pyencapsulates ModernGL programs, VBO/VAO management, and uniform updates, enabling custom GLSL rendering without low-level OpenGL code.VShaderWrapperextends this for vector graphics, automatically handling four shader passes (stroke, fill, border, depth) and off-screen framebuffers for the winding-number fill technique.- Vertex data must be provided as structured NumPy arrays;
moderngl.detect_formatautomatically maps these to GLSL attributes based on field names and dtypes. - Uniforms are managed through the
mobject_uniformsdictionary and efficiently updated viaset_program_uniform, which caches values inPROGRAM_UNIFORM_MIRRORSto avoid redundant GPU writes. - Textures are loaded via
image_path_to_textureand bound to specific texture units, accessible in GLSL by the keys provided in thetexture_pathsdictionary.
Frequently Asked Questions
How do I update uniforms dynamically in a custom ShaderWrapper?
Access the mobject_uniforms dictionary on your wrapper instance and update values within a Manim updater. The wrapper automatically propagates these to the GPU during pre_render() using the cached uniform setter to avoid redundant writes. For time-based animations, add an updater like self.add_updater(lambda m, dt: m.shader.mobject_uniforms.update(time=m.time)).
What is the difference between ShaderWrapper and VShaderWrapper?
ShaderWrapper is the general-purpose base class suitable for point clouds, particle systems, or any custom geometry requiring a single shader program. VShaderWrapper is a specialized subclass designed specifically for VMobject rendering, automatically managing four separate shader programs (stroke, fill, border, depth) and an off-screen framebuffer for the winding-number fill technique required for complex vector shapes.
How does Manim handle vertex attribute formats automatically?
Manim uses moderngl.detect_format to inspect the NumPy structured array dtype provided to the wrapper. It translates field definitions like ("position", "3f") or ("color", "4f") into OpenGL attribute format strings (e.g., '3f 4f'). The generate_vaos() method then binds these attributes to the shader program, requiring that your GLSL attribute names match the NumPy field names exactly.
Where should I place custom GLSL shader files?
Place your shader files in a folder containing vert.glsl and frag.glsl (and optionally geom.glsl), then pass the folder path to the shader_folder parameter. The utility get_shader_code_from_file in manimlib/utils/shaders.py resolves these paths using get_shader_dir() from manimlib/utils/directories.py, and supports #INSERT directives to include reusable code snippets from other files.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →