zhanghandong/makepad-2.0-shaders
| makepad shader, Sdf2d, pixel shader, draw_bg, draw_text, draw_quad, makepad gpu, shader function, pixel fn, vertex fn, instance, uniform, shader variable, sdf, premultiply, Pal.premul, GaussShadow, makepad graphics, custom draw, DrawQuad, DrawVector, 着色器, 像素, 渲染, 自定义绘制, 距离场
npx skills add https://github.com/ZhangHanDong/makepad-skills --skill makepad-2.0-shaders
> Version: makepad-widgets (dev branch) | Last Updated: 2026-03-03
Makepad uses a custom GPU shader system integrated into the widget property tree. Shaders are defined inline using pixel: fn() { ... } and vertex: fn() { ... } blocks within draw_bg, draw_text, or custom draw objects.
Refer to the local files for detailed documentation:
./references/shader-reference.md - Shader syntax, variables, built-ins, custom functions./references/sdf2d-reference.md - SDF2D primitives, combinators, drawing operationsdraw_bg +: {
// Declare variables
instance hover: 0.0 // Animatable per-instance
uniform accent: #4488ff // Shared across all instances
pixel: fn() {
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
// ... SDF operations ...
return sdf.result
}
}
| Type | Declaration | Animatable | Scope |
|------|-------------|-----------|-------|
| instance | instance hover: 0.0 | Yes (via Animator) | Per-widget instance |
| uniform | uniform color: #fff | No | Shared across instances |
| texture_2d | texture_2d tex: none | No | Texture sampler |
| varying | varying uv: vec2 | No | Vertex → fragment |
| Variable | Type | Description |
|----------|------|-------------|
| self.pos | vec2 | Normalized position (0.0 to 1.0) |
| self.rect_size | vec2 | Widget size in pixels |
| self.dpi_factor | float | Screen DPI factor |
| self.draw_pass.time | float | Time in seconds |
Every pixel shader MUST return premultiplied alpha color!
// WRONG - non-premultiplied
pixel: fn() {
return vec4(1.0, 0.0, 0.0, 0.5)
}
// CORRECT - use Pal.premul()
pixel: fn() {
return Pal.premul(vec4(1.0, 0.0, 0.0, 0.5))
}
// ALSO CORRECT - sdf.result is already premultiplied
pixel: fn() {
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
sdf.circle(cx, cy, r)
sdf.fill(#f00)
return sdf.result
}
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
sdf.circle(cx, cy, radius)
sdf.rect(x, y, w, h)
sdf.box(x, y, w, h, border_radius)
sdf.hexagon(cx, cy, radius)
sdf.arc(cx, cy, radius, start_angle, end_angle, thickness)
sdf.move_to(x, y)
sdf.line_to(x, y)
sdf.close_path()
sdf.fill(color) // Filled shape
sdf.stroke(color, width) // Outlined shape
sdf.glow(color, amount) // Glow effect
sdf.clear(color) // Clear with color
sdf.union() // Add shapes together
sdf.intersect() // Keep overlap only
sdf.subtract() // Remove second from first
sdf.gloop(radius) // Smooth union
sdf.blend(amount) // Linear blend
sdf.translate(x, y)
sdf.rotate(angle, cx, cy)
sdf.scale(factor, cx, cy)
// Mix two colors
mix(#f00, #00f, 0.5) // 50% blend
// Premultiply alpha
Pal.premul(vec4(r, g, b, a))
// HSV conversions
Pal.hsv2rgb(vec4(h, s, v, 1.0))
Pal.rgb2hsv(color)
// Random
Math.random_2d(vec2(x, y))
draw_bg +: {
pixel: fn() {
let grad = mix(#1a1a2e, #16213e, self.pos.y)
return Pal.premul(vec4(grad.xyz, 1.0))
}
}
draw_bg +: {
instance hover: 0.0
color: #333
pixel: fn() {
return Pal.premul(mix(self.color, self.color * 1.3, self.hover))
}
}
draw_bg +: {
pixel: fn() {
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
// Shadow
sdf.box(2.0, 2.0, self.rect_size.x - 4.0, self.rect_size.y - 4.0, 8.0)
sdf.fill(GaussShadow.box_shadow(sdf, 4.0, #0005))
// Card
sdf.box(0.0, 0.0, self.rect_size.x - 2.0, self.rect_size.y - 2.0, 8.0)
sdf.fill(#2a2a3d)
return sdf.result
}
}
draw_bg +: {
instance hover: 0.0
instance down: 0.0
uniform color_bg: #4488ff
uniform color_hover: #5599ff
uniform color_down: #3377ee
pixel: fn() {
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
sdf.box(0.0, 0.0, self.rect_size.x, self.rect_size.y, 6.0)
let color = mix(self.color_bg, self.color_hover, self.hover)
let color = mix(color, self.color_down, self.down)
sdf.fill(color)
return sdf.result
}
}
draw_bg +: {
fn wave(pos: vec2, time: float) -> float {
return sin(pos.x * 10.0 + time * 3.0) * 0.1
}
pixel: fn() {
let w = self.wave(self.pos, self.draw_pass.time)
let color = mix(#1a1a2e, #4488ff, self.pos.y + w)
return Pal.premul(vec4(color.xyz, 1.0))
}
}
Splash CAN:
pixel: fn(), vertex: fn(), get_color: fn() on existing draw types via +:+: blocksSplash CANNOT:
Rule: Rust defines the draw type struct + registers it; Splash overrides how it draws.
See ./references/shader-reference.md "Splash Shader Capabilities & Boundaries" for the full pattern.
For standalone shader-driven widgets (e.g. particle fields, visualizers), follow the examples/shader pattern:
#[derive(Script, ScriptHook)]
#[repr(C)] // CRITICAL: must be repr(C) for GPU layout
pub struct DrawMyShader {
#[deref] draw_super: DrawQuad, // inherits from DrawQuad
#[live] my_param: f32, // maps to shader variable
}
set_type_default() do #(DrawMyShader::script_shader(vm)){
..mod.draw.DrawQuad // inherit DrawQuad defaults
my_param: 0.5 // default value
// Custom functions: property-style syntax, NOT fn name(self, ...)
my_helper: fn(a: float, b: float) -> vec2 {
return vec2(a * 2.0, b * 0.5)
}
pixel: fn() {
let result = self.my_helper(self.pos.x, self.pos.y)
return Pal.premul(vec4(result.x, result.y, 0.0, 1.0))
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, _: &mut Scope, walk: Walk) -> DrawStep {
cx.begin_turtle(walk, self.layout);
let rect = cx.turtle().rect();
self.draw_bg.draw_abs(cx, rect); // single fullscreen quad
cx.end_turtle_with_area(&mut self.area);
DrawStep::done()
}
// Direct field access (when draw type has #[live] fields):
self.draw_bg.my_param = 0.75;
self.area.redraw(cx);
// Via NextFrame for animation:
if let Event::NextFrame(ne) = event {
if ne.set.contains(&self.next_frame) {
self.draw_bg.my_param += 0.01;
self.area.redraw(cx);
self.next_frame = cx.new_next_frame();
}
}
For drawing thousands of independent particles (dots, stars, etc.):
#[derive(Script, ScriptHook)]
#[repr(C)]
pub struct DrawDot {
#[deref] draw_super: DrawQuad,
#[live] dot_color: Vec3, // per-instance color
}
// Shader: each instance is a small circle
pixel: fn() {
let d = length(self.pos - vec2(0.5, 0.5))
let alpha = 1.0 - smoothstep(0.35, 0.5, d)
return Pal.premul(vec4(self.dot_color * alpha, alpha))
}
self.draw_dot.begin_many_instances(cx); // start batch
for i in 0..particles.len() {
let (x, y) = particles[i];
self.draw_dot.dot_color = vec3(r, g, b); // set per-instance data
self.draw_dot.draw_abs(cx, Rect {
pos: dvec2(x - radius, y - radius),
size: dvec2(radius * 2.0, radius * 2.0),
});
}
self.draw_dot.end_many_instances(cx); // submit batch as one draw call
// Per particle: store persistent displacement
displacements: Vec<(f64, f64)>,
// Each frame:
for i in 0..dots.len() {
let (mut dx, mut dy) = displacements[i];
// 1. Decay (spring back, 0.94 = ~2-3 sec return)
dx *= 0.94;
dy *= 0.94;
// 2. Apply forces (cursor push, ripples, etc.)
let dist = distance(dot_pos, mouse_pos);
let t = (1.0 - dist / radius).max(0.0);
let push = t * t * t * strength; // cubic falloff
dx += direction.x * push;
dy += direction.y * push;
displacements[i] = (dx, dy);
// Draw at original_pos + displacement
}
begin_many_instances / end_many_instances batches into single GPU draw call| Pitfall | Error | Fix |
|---------|-------|-----|
| let x = 1.0; x = 2.0 | cannot assign to let binding | Use different names: let x2 = ... |
| fn push(self, ...) -> vec2 | method not found on self | Use property syntax: push: fn(...) -> vec2 { } |
| return vec4(r, g, b, a) without premul | Incorrect alpha blending | return Pal.premul(vec4(r, g, b, a)) |
| Custom shader in Splash eval | Silent blank render | Must use compiled script_mod! path |
| Missing #[repr(C)] on draw struct | GPU layout mismatch | Always add #[repr(C)] |
| fn calc(self, x: float) syntax | cannot push to frozen vec | Use calc: fn(x: float) -> float { } |
Pal.premul() or return sdf.resultinstance for animation - Only instance variables work with Animatoruniform for shared values - Colors, sizes shared across instances+: merge operator - Extend default shaders: draw_bg +: { ... }new_batch: true - Required when mixing shaders with textname: fn(args) -> type { }, call via self.name(args)let cannot be reassigned; use unique names per step10. #[repr(C)] on draw structs - Required for GPU memory layout alignment
sdf.box() with large border_radius breaks when radius approaches half the dimension — the formula size.xy - vec2(2*r, 2*r) goes negative, producing diamond/spiky shapes. Use this standard capsule SDF instead:
draw_bg +: {
pixel: fn() {
let w = self.rect_size.x
let h = self.rect_size.y
let r = h * 0.5
let px = self.pos.x * w
let py = self.pos.y * h
// Standard capsule: clamp x to center segment, then circle distance
let cx = clamp(px, r, max(r, w - r))
let cy = h * 0.5
let d = length(vec2(px - cx, py - cy)) - r
let alpha = 1.0 - smoothstep(-1.0, 1.0, d)
return Pal.premul(vec4(0.1, 0.1, 0.18, alpha * 0.82))
}
}
Key points:
clamp(px, r, w-r) constrains x to the center line segment between the two end circlesmax(r, w-r) prevents clamp range inversion when widget is very narrow(-1.0, 1.0, d) provides 2px anti-aliasingwidth: Fit content changesEmbed animation directly in the background shader to avoid z-order issues with child widgets (LoadingSpinner/other widgets can cause bleed-through at capsule edges):
draw_bg +: {
pixel: fn() {
let w = self.rect_size.x
let h = self.rect_size.y
let r = h * 0.5
let px = self.pos.x * w
let py = self.pos.y * h
// Capsule background
let cx_bg = clamp(px, r, max(r, w - r))
let cy = h * 0.5
let d_bg = length(vec2(px - cx_bg, py - cy)) - r
let bg_alpha = 1.0 - smoothstep(-1.0, 1.0, d_bg)
let bg = vec4(0.1, 0.1, 0.18, bg_alpha * 0.82)
// Pulsing dot (driven by draw_pass.time)
let t = self.draw_pass.time
let pulse = 0.5 + 0.5 * sin(t * 4.0)
let dot_r = 4.0 + pulse * 3.0
let dot_cx = r + 2.0
let d_dot = length(vec2(px - dot_cx, py - cy)) - dot_r
let dot_alpha = (1.0 - smoothstep(-1.0, 1.0, d_dot)) * bg_alpha
let dot_color = mix(vec3(0.3, 0.6, 1.0), vec3(0.2, 0.9, 0.5), pulse)
// Composite
let final_rgb = mix(bg.xyz, dot_color, dot_alpha * 0.8)
let final_a = bg.w + dot_alpha * 0.6 * (1.0 - bg.w)
return Pal.premul(vec4(final_rgb, final_a))
}
}
IMPORTANT: Must call self.ui.widget(cx, ids!(my_window)).redraw(cx) from handle_next_frame to keep draw_pass.time advancing. Without continuous redraw, time-based animation freezes.
Take zhanghandong/makepad-2.0-shaders from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.