引言
Defold 的渲染是「数据驱动」的:材质(Material)说「用哪个着色器、暴露哪些常量、开哪些采样器」,渲染脚本(Render Script)说「什么时候用这个材质、画什么」。 想给角色加描边、让场景溶解消失、把死亡瞬间染成灰度——这些都不是改引擎,而是写一个几十行的着色器再挂上材质。
本文把材质与着色器讲成一条完整链路:先理清 .material 与 .vp/.fp 的关系,再讲顶点与片元着色器的数据流与内建属性,接着是 uniform 常量与采样器的声明与传值方式,然后是材质标签(Tags)如何参与渲染排序,再给溶解、描边、灰度、水面四个可直接抄的效果实现,最后讲着色器的性能纪律与调试手段。
前置:渲染脚本与渲染管线 、后处理与全屏效果 。着色器变体管理的通用问题见 着色器变体管理 、WebGL 管线与后处理 。
1. 材质与着色器的关系
一个 .material 文件描述「怎么用一组着色器」:
sprite.material
├── vertex_program → sprite.vp (顶点着色器)
├── fragment_program → sprite.fp (片元着色器)
├── vertex_constants → view_proj 等(顶点阶段 uniform)
├── fragment_constants → tint 等 (片元阶段 uniform)
├── samplers → texture_sampler(纹理采样器)
└── tags → "sprite" (渲染标签)
# sprite.material 的结构(示意)
name: "sprite"
tags: "sprite"
vertex_program: "/builtins/materials/sprite.vp"
fragment_program: "/builtins/materials/sprite.fp"
vertex_constants {
name: "view_proj"
type: CONSTANT_TYPE_VIEWPROJ
}
fragment_constants {
name: "tint"
type: CONSTANT_TYPE_USER
value { x: 1.0 y: 1.0 z: 1.0 w: 1.0 }
}
samplers {
name: "texture_sampler"
wrap_u: WRAP_MODE_CLAMP_TO_EDGE
wrap_v: WRAP_MODE_CLAMP_TO_EDGE
}
职责划分:
| 文件 | 回答的问题 | 谁写 |
|---|---|---|
.vp / .fp | 顶点怎么变换、像素怎么上色 | 图形程序员 |
.material | 暴露哪些 uniform、开哪些采样器 | 引擎/TA |
.render_script | 何时用该材质、绘制哪些对象 | 渲染工程师 |
.go 组件 | 用哪个材质 | 游戏程序员 |
替换一个组件的材质:
-- 运行时给 sprite 换材质(如命中闪白用的变体)
go.set("#sprite", "material", hash("/materials/flash.material"))
心智:材质是「着色器的使用说明书」,渲染脚本是「调用者」——写效果先定 uniform 契约,再写着色器,最后在渲染脚本里传值绘制。
2. 顶点与片元着色器的数据流
渲染管线里两个可编程阶段:
顶点数据(position/texcoord0/color…)
↓ 顶点着色器(.vp):变换坐标、传出 varying
光栅化
↓ 片元着色器(.fp):逐像素算颜色
帧缓冲
顶点着色器(Defold 内置 sprite.vp 的简化形态):
uniform mediump mat4 view_proj; // 视图×投影矩阵(引擎自动填充)
attribute mediump vec4 position; // 顶点位置
attribute mediump vec2 texcoord0; // 纹理坐标
varying mediump vec2 var_texcoord0; // 传给片元
void main() {
gl_Position = view_proj * vec4(position.xyz, 1.0);
var_texcoord0 = texcoord0;
}
片元着色器:
varying mediump vec2 var_texcoord0;
uniform lowp sampler2D texture_sampler; // 图集纹理
uniform lowp vec4 tint; // 染色(含 alpha)
void main() {
vec4 c = texture2D(texture_sampler, var_texcoord0);
gl_FragColor = c * tint; // 采样 × 染色
}
数据传递规则:
attribute 顶点属性(position/texcoord0/color/normal),只读,来自网格
varying 顶点着色器 → 片元着色器,逐顶点插值到逐像素
uniform 外部传入(渲染脚本或组件属性),整批绘制期间不变
| 变量类型 | 声明位置 | 谁赋值 | 变化频率 |
|---|---|---|---|
| attribute | 顶点着色器 | 网格数据 | 每顶点 |
| varying | 两个着色器 | 顶点着色器 | 每顶点插值 |
| uniform | 两个着色器 | 渲染脚本/引擎 | 每 draw call |
精度限定符(WebGL 必写,桌面可省):
highp 高精度(位置变换常用)
mediump 中精度(texcoord 常用)
lowp 低精度(颜色常用,性能最好)
心智:attribute 进、varying 传、uniform 调——顶点着色器管「在哪」,片元着色器管「什么颜色」;精度限定符在 WebGL 上是必须的,颜色用 lowp 最省。
3. uniform 与采样器:声明与传值
uniform 声明在着色器里,值在渲染脚本里给:
-- 渲染脚本中给 uniform 传值
render.set_constant("#sprite", "tint", vmath.vector4(1, 0.5, 0.5, 1))
render.enable_material("flash") -- 启用材质(标签名)
render.draw(self.predicates) -- 按谓词绘制
四种常量类型(.material 里的 type):
| 类型 | 谁填 | 用途 |
|---|---|---|
CONSTANT_TYPE_VIEW | 引擎 | 视图矩阵 |
CONSTANT_TYPE_PROJECTION | 引擎 | 投影矩阵 |
CONSTANT_TYPE_VIEWPROJ | 引擎 | 视图×投影 |
CONSTANT_TYPE_USER | 你 | 自定义(时间、强度、颜色) |
自定义 uniform 的完整链路:
# my.material 里声明一个自定义 uniform
fragment_constants {
name: "dissolve_amount"
type: CONSTANT_TYPE_USER
value { x: 0.0 y: 0.0 z: 0.0 w: 0.0 }
}
-- 渲染脚本里每帧更新(溶解进度)
local t = os.clock()
render.set_constant("#dissolve", "dissolve_amount", vmath.vector4(t % 1.0, 0, 0, 0))
采样器(Sampler):
samplers {
name: "texture_sampler" # 名字要和着色器里的 uniform 一致
wrap_u: WRAP_MODE_CLAMP_TO_EDGE
wrap_v: WRAP_MODE_CLAMP_TO_EDGE
filter_min: FILTER_MODE_MIN_LINEAR
filter_mag: FILTER_MODE_MAG_LINEAR
}
采样器三种 wrap 模式:
CLAMP_TO_EDGE 超界取边缘(图集必备,防相邻帧串色)
REPEAT 平铺重复(地面、水面)
MIRRORED_REPEAT 镜像重复(对称纹理)
多个采样器(如光照需要 albedo + normal):
uniform lowp sampler2D texture_sampler; // 主图集
uniform lowp sampler2D normal_sampler; // 法线图
samplers { name: "texture_sampler" wrap_u: WRAP_MODE_CLAMP_TO_EDGE wrap_v: WRAP_MODE_CLAMP_TO_EDGE }
samplers { name: "normal_sampler" wrap_u: WRAP_MODE_REPEAT wrap_v: WRAP_MODE_REPEAT }
心智:uniform 在着色器里声明、在渲染脚本里 set_constant 传值;引擎能自动填 view/proj,自定义量用 CONSTANT_TYPE_USER;采样器名字必须与着色器 uniform 同名,图集一律 CLAMP_TO_EDGE。
4. 材质标签与渲染顺序
标签(Tags)是材质与渲染脚本之间的「契约」:材质打标签,渲染脚本按标签建谓词(Predicate)筛选要画的对象。
# sprite.material
tags: "sprite"
-- 渲染脚本:建谓词、按标签取对象
function init(self)
self.sprite_pred = render.predicate({ hash("sprite") })
self.outline_pred = render.predicate({ hash("outline") })
end
function update(self)
render.set_viewport(0, 0, render.get_window_width(), render.get_window_height())
render.set_view(self.view)
render.set_projection(self.projection)
render.enable_material("sprite") -- 按材质标签启用
render.draw(self.sprite_pred)
end
渲染顺序控制:
决定先后顺序的四个因素:
1. 渲染脚本里 render.draw 的调用顺序(最直接)
2. 标签分组:同一谓词里的对象按「提交顺序 + 深度」绘制
3. 材质的 tags 决定它属于哪个谓词
4. 透明混合对象的排序(需手动按深度排)
-- 先画不透明,再画透明(顺序错了透明会盖住后面)
render.draw(self.opaque_pred) -- 不透明:地板、墙
render.draw(self.transparent_pred) -- 透明:粒子、光晕
描边效果的标准做法(两遍绘制):
1. 第一遍:用「放大 + 纯色」的材质画轮廓
2. 第二遍:正常材质画本体,盖在轮廓上
3. 结果:本体外露出一圈颜色 = 描边
function update(self)
-- 第一遍:描边材质,稍微放大
render.enable_material("outline")
render.set_constant("#outline", "outline_color", vmath.vector4(1, 0, 0, 1))
render.draw(self.sprite_pred)
-- 第二遍:正常材质
render.enable_material("sprite")
render.draw(self.sprite_pred)
end
心智:材质打标签、渲染脚本建谓词、draw 顺序定前后——描边这类效果就是「换个材质画两遍」,顺序与标签是唯一需要设计的东西。
5. 常见效果的完整实现
5.1 溶解(Dissolve)
用噪声纹理的阈值做「逐像素消失」:
varying mediump vec2 var_texcoord0;
uniform lowp sampler2D texture_sampler;
uniform lowp sampler2D noise_sampler;
uniform lowp vec4 tint;
uniform lowp vec4 dissolve_amount; // .x = 0~1 进度
uniform lowp vec4 edge_color; // 边缘发光色
void main() {
vec4 c = texture2D(texture_sampler, var_texcoord0) * tint;
float n = texture2D(noise_sampler, var_texcoord0).r;
float a = dissolve_amount.x;
// 噪声低于阈值 → 丢弃像素
if (n < a) discard;
// 阈值附近的像素染成边缘色(燃烧边缘)
float edge = smoothstep(a, a + 0.08, n);
gl_FragColor = mix(edge_color, c, edge);
}
-- 渲染脚本里推进溶解进度
render.set_constant("#dissolve", "dissolve_amount", vmath.vector4(self.t, 0, 0, 0))
5.2 灰度 / 死亡滤镜
用点积把 RGB 投影到亮度轴:
void main() {
vec4 c = texture2D(texture_sampler, var_texcoord0) * tint;
float gray = dot(c.rgb, vec3(0.299, 0.587, 0.114)); // 感知亮度权重
gl_FragColor = vec4(mix(c.rgb, vec3(gray), 1.0), c.a);
}
为什么不是 (r+g+b)/3:
人眼对绿色最敏感、蓝色最不敏感
0.299/0.587/0.114 是 Rec.601 亮度权重,观感更自然
5.3 受击闪白
最简单也最常用——用 tint 的开关控制:
-- 命中瞬间把 tint 拉白,再补间回正常色
go.set("#sprite", "tint", vmath.vector4(4, 4, 4, 1)) -- 过曝即白
go.animate("#sprite", "tint", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
vmath.vector4(1, 1, 1, 1), go.Easing.OUT_QUAD, 0.15)
原理:着色器里 gl_FragColor = c * tint,tint 大于 1 时颜色过曝变白
—— 不需要额外着色器,用内置 sprite 材质就能做
5.4 水面波纹
用 UV 偏移 + 正弦扰动:
varying mediump vec2 var_texcoord0;
uniform lowp sampler2D texture_sampler;
uniform lowp vec4 time;
void main() {
// 两路不同频率的正弦,错开相位制造「流动感」
float wave_x = sin(var_texcoord0.y * 12.0 + time.x * 2.0) * 0.01;
float wave_y = cos(var_texcoord0.x * 9.0 + time.x * 1.5) * 0.01;
vec2 uv = var_texcoord0 + vec2(wave_x, wave_y);
gl_FragColor = texture2D(texture_sampler, uv);
}
-- 每帧推进 time
render.set_constant("#water", "time", vmath.vector4(os.clock(), 0, 0, 0))
心智:溶解靠噪声阈值 discard、灰度靠点积亮度、闪白靠 tint 过曝、水面靠 UV 正弦扰动——四个效果都是十几行,关键在「想清楚用哪个数据当输入」。
6. 性能与调试
片元着色器是性能热点(每个像素都跑一遍):
开销排序(从高到低):
discard / 分支 → 破坏早期深度测试,最贵
texture2D 采样 → 每多一个采样器就多一次显存读
sin/cos/pow 等超越函数 → 比加减乘贵一个量级
mix/smoothstep → 便宜
优化纪律:
1. 能用顶点着色器算的,别放片元(每顶点 vs 每像素差几十倍)
2. discard 尽量配合「不透明 + 深度预写」使用,别在全屏透明层上用
3. 减少采样次数:能合并到一张图集的通道就别开第二个采样器
4. 常量用 vec4 打包(一次传 4 个 float,比 4 次传 1 个快)
5. 精度降到够用即可:颜色 lowp、UV mediump
调试手段:
// 直接输出中间量看对不对(最快的调试法)
gl_FragColor = vec4(var_texcoord0, 0.0, 1.0); // 看 UV 是否正确
gl_FragColor = vec4(vec3(n), 1.0); // 看噪声图
gl_FragColor = vec4(c.rgb, 1.0); // 看采样结果
调试顺序:
1. 先确认「数据对不对」——把 uniform/varying 直接输出成颜色
2. 再确认「算法对不对」——分步 mix 出中间结果
3. 最后查「材质接线」——采样器名字、uniform 名字是否与 .material 一致
常见报错与原因:
| 现象 | 常见原因 |
|---|---|
| 全黑 | uniform 名字拼错 / 没 set_constant |
| 全白 | tint 未初始化(默认 0)或过曝 |
| 纹理串色 | 采样器 wrap 没设 CLAMP_TO_EDGE |
| 移动端正常、桌面异常 | 精度限定符缺失(桌面容忍,WebGL 不容忍) |
| 编译失败无提示 | 检查 varying 在两阶段声明是否完全一致 |
心智:片元着色器贵在「逐像素」——分支与 discard 最贵、采样次之、超越函数再次;调试一律先输出数据再看算法,最后查材质接线。
速查表
| 需求 | 做法 |
|---|---|
| 新建材质 | .material 指向 .vp/.fp |
| 传自定义值 | CONSTANT_TYPE_USER + render.set_constant |
| 引擎自动量 | CONSTANT_TYPE_VIEWPROJ |
| 纹理采样 | samplers { name: "texture_sampler" } |
| 图集防串色 | WRAP_MODE_CLAMP_TO_EDGE |
| 打标签 | tags: "sprite" |
| 筛选绘制 | render.predicate({ hash("sprite") }) |
| 启用材质 | render.enable_material("sprite") |
| 闪白 | go.set(..., "tint", vmath.vector4(4,4,4,1)) |
| 灰度 | dot(c.rgb, vec3(0.299,0.587,0.114)) |
| 溶解 | 噪声阈值 + discard |
| 调试 | 直接 gl_FragColor = vec4(中间量, 1.0) |
一句话记忆:Defold 渲染 = 材质(声明 uniform/采样器/标签)+ 着色器(attribute 进、varying 传、uniform 调)+ 渲染脚本(谓词筛选、set_constant 传值、draw 定序)——效果实现就是把「数据 → 算法 → 输出」三段写清;性能上片元最贵(少分支、少采样、降精度),调试上一律先输出数据再看算法、最后查材质接线。
小结
材质与着色器是 Defold 里「改一行、变一片」的地方,也是新手最容易卡住的地方——卡点几乎都在接线而非算法:uniform 名字对不上、采样器 wrap 模式错、varying 两阶段声明不一致。所以实践顺序应该是:先把材质接线(uniform 名、采样器名、标签)搭通,用「输出中间量」的方式确认数据流,再写算法逻辑。性能上记住片元着色器逐像素执行的三档开销(分支/discard > 采样 > 超越函数),能用顶点阶段算的绝不下放到片元。把这两条做扎实,溶解、描边、灰度、水面这类效果都能在半小时内落地。
延伸阅读
- 渲染脚本与渲染管线
- 后处理与全屏效果
- 着色器变体管理 — 多平台多材质的变体膨胀问题
- WebGL 管线与后处理 — 后处理链路的另一种实现
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。