引言
2D 游戏加上光照后观感会有质的变化:火把摇曳、爆炸闪光、洞穴里手电筒扫过墙面——这些效果在 Defold 里完全可行,但需要理解渲染管线:法线贴图提供表面朝向、光照缓冲累积光源贡献、最后在合成阶段与场景贴图相乘。本文从原理讲起,覆盖法线贴图、自定义材质与 shader、光源类型、render target 流程、后处理叠加、阴影遮挡,最后给出移动端优化清单。
前置阅读:/defold-render-script/(渲染脚本与渲染管线)、/defold-shader-postprocessing/(着色器与后处理基础)。
目录
- 1. 2D 光照的基本原理
- 2. 法线贴图的生成与使用
- 3. 自定义材质与顶点数据
- 4. 光源类型与光照累积
- 5. 渲染流程与 render target
- 6. 后处理叠加与色调映射
- 7. 阴影与遮挡处理
- 8. 移动端性能优化
- 9. 速查表
- 相关阅读
- 延伸阅读
1. 2D 光照的基本原理
1. 从一张贴图说起
普通 2D 精灵只有颜色信息,没有任何「朝向」概念,所以打光时整张图会被均匀照亮,看起来像贴纸。要做出立体感,需要额外一张法线贴图,用 RGB 通道编码每个像素的表面朝向。
颜色贴图 (albedo) → 这个像素是什么颜色
法线贴图 (normal) → 这个像素朝向哪里
2. 光照累积公式
2D 光照的经典做法是光照缓冲(light buffer):
光照缓冲 = Σ 每个光源的贡献
最终颜色 = 颜色贴图 × 光照缓冲
每个光源的贡献:
diffuse = max(dot(normal, light_dir), 0)
falloff = 1 - saturate(distance / radius)
contribution = light_color × intensity × diffuse × falloff
记忆:2D 光照 = 把「光源贡献」画到一张离屏贴图上,再用乘法把它叠回场景。核心不是复杂的光照模型,而是缓冲的组织方式。实际工程中通常维护三张缓冲:场景缓冲(全分辨率)、光照缓冲(可半分辨率)、法线缓冲(可选)。
2. 法线贴图的生成与使用
1. 法线贴图的格式
法线贴图是 RGB 图,把单位法线从 [-1, 1] 映射到 [0, 1]:
R = (nx + 1) / 2
G = (ny + 1) / 2
B = (nz + 1) / 2
平面朝前的法线 (0, 0, 1) → RGB(128, 128, 255) # 淡紫色
识别技巧:一张正确的法线贴图整体呈淡紫蓝色,因为大部分像素的 Z 分量接近 1。
2. 生成方式
| 工具 | 说明 |
|---|---|
| Laigter | 开源,可从灰度高度图生成法线 |
| SpriteIlluminator | 商业,2D 光照专用 |
| 手绘 | 在 Photoshop 里用 128,128,255 为基色绘制 |
| 程序生成 | 从灰度图用 Sobel 算子计算梯度 |
3. 与图集一起打包
Defold 的图集(atlas)可以同时包含颜色图和法线图,但必须保证两者的 UV 布局完全一致,否则光照会错位。
sprites.atlas
├── tiles.png (颜色)
└── tiles_normal.png (法线)
踩坑:图集打包时如果颜色图和法线图的打包顺序不同,同一精灵在两套图集里的 UV 坐标会不一致。务必用同一份
.atlas配置生成两张图,或用 TexturePacker 锁定布局。另外,不同工具生成的 Y 轴方向可能相反(OpenGL 与 DirectX 约定不同),如果光照看起来「上下颠倒」,在 shader 里翻转法线贴图的 G 通道即可。
3. 自定义材质与顶点数据
1. 顶点格式
Defold 的 sprite 组件默认顶点格式是 position + texcoord0。要加法线,需要在 sprite 属性里选择带 normal 的顶点格式:
Vertex format: position, texcoord0, normal
2. 顶点着色器
// sprite_lit.vp
#version 140
in vec4 position;
in vec2 texcoord0;
in vec4 normal;
uniform mat4 mtx_worldview;
uniform mat4 mtx_proj;
uniform mat4 mtx_normal;
out vec2 var_texcoord0;
out vec3 var_normal;
out vec4 var_position;
void main()
{
vec4 world_pos = mtx_worldview * vec4(position.xyz, 1.0);
gl_Position = mtx_proj * world_pos;
var_texcoord0 = texcoord0;
var_normal = normalize((mtx_normal * vec4(normal.xyz, 0.0)).xyz);
var_position = world_pos;
}
内置 uniform 说明:
| uniform | 含义 |
|---|---|
| mtx_worldview | 世界矩阵 × 视图矩阵 |
| mtx_proj | 投影矩阵 |
| mtx_normal | 法线矩阵(世界空间) |
| mtx_light | 光源空间矩阵(做投影贴图用) |
3. 片元着色器
// sprite_lit.fp
#version 140
in vec2 var_texcoord0;
in vec3 var_normal;
in vec4 var_position;
uniform sampler2D tex0; // 颜色图集
uniform sampler2D light_map; // 光照缓冲
uniform fs_uniforms
{
vec4 tint;
vec4 light_params; // x=环境光强度, y=法线强度, zw=光照图宽高
};
out vec4 color_out;
void main()
{
vec4 albedo = texture(tex0, var_texcoord0);
if (albedo.a < 0.01) {
discard;
}
// 1. 解码法线
vec3 n = normalize(var_normal * 2.0 - 1.0);
// 2. 计算光照缓冲 UV(屏幕空间)
vec2 light_uv = gl_FragCoord.xy / vec2(light_params.z, light_params.w);
// 3. 采样光照缓冲
vec3 light = texture(light_map, light_uv).rgb;
// 4. 正向漫反射
float ndl = max(dot(n, vec3(0.0, 0.0, 1.0)), 0.0);
ndl = mix(1.0, ndl, light_params.y);
// 5. 环境光兜底,避免背光全黑
vec3 lit = albedo.rgb * (light * ndl + light_params.x);
color_out = vec4(lit, albedo.a) * tint;
}
材质文件配置:在 .material 里指定 Vertex Program / Fragment Program,声明 Samplers(tex0 指向图集、light_map 指向光照 RT)与 Fragment Constants(tint、light_params)。
4. 光源类型与光照累积
1. 点光
最基本的类型,圆形衰减:
-- light.lua
local M = {}
function M.new_point(pos, radius, color, intensity)
return {
kind = "point",
position = pos,
radius = radius,
color = color or vmath.vector4(1, 1, 1, 1),
intensity = intensity or 1.0,
}
end
-- 计算某点受到的光照强度
function M.sample(light, world_pos)
local d = vmath.length(world_pos - light.position)
if d > light.radius then return 0 end
local t = 1.0 - d / light.radius -- 平滑衰减:边缘更柔和
return t * t * light.intensity
end
return M
2. 聚光与手电筒
在点光基础上加方向约束:
function M.new_spot(pos, dir, radius, angle_deg, color)
return {
kind = "spot",
position = pos,
direction = vmath.normalize(dir),
radius = radius,
cos_inner = math.cos(math.rad(angle_deg * 0.6)),
cos_outer = math.cos(math.rad(angle_deg)),
color = color or vmath.vector4(1, 1, 1, 1),
intensity = 1.0,
}
end
function M.sample_spot(light, world_pos)
local to_pixel = world_pos - light.position
local d = vmath.length(to_pixel)
if d > light.radius then return 0 end
local dir = to_pixel / math.max(d, 0.0001)
local cos_theta = vmath.dot(dir, light.direction)
if cos_theta < light.cos_outer then return 0 end
-- 内外锥之间平滑过渡
local t = (cos_theta - light.cos_outer) / math.max(light.cos_inner - light.cos_outer, 0.0001)
t = math.min(1.0, math.max(0.0, t))
local falloff = 1.0 - d / light.radius
return t * t * falloff * falloff * light.intensity
end
3. 环境光与累积方案
环境光不是真的光源,而是给整个场景一个基础亮度,避免背光面全黑,通常取 0.1 ~ 0.25。
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 屏幕空间缓冲 | 每个光源画一个 quad 到光照 RT,加法混合 | 支持任意多光源 | 需要 render target |
| 逐精灵计算 | 在片元着色器里遍历光源 | 无额外缓冲 | 光源数受限,性能差 |
推荐:屏幕空间缓冲。把每个光源当成一个「精灵」画到光照 RT 上,用
BLEND_ADD累积,最后一次性合成。这样光源数量只影响填充率,不影响精灵的 shader 复杂度。
5. 渲染流程与 render target
1. 创建 render target
-- render/light.render_script
function init(self)
-- 场景缓冲(全分辨率)
self.scene_rt = render.render_target("scene_rt", {
[render.BUFFER_COLOR_BIT] = {
format = render.FORMAT_RGBA,
width = render.get_window_width(),
height = render.get_window_height(),
},
})
-- 光照缓冲(半分辨率,省一半填充率)
self.light_rt = render.render_target("light_rt", {
[render.BUFFER_COLOR_BIT] = {
format = render.FORMAT_RGBA,
width = render.get_window_width() / 2,
height = render.get_window_height() / 2,
min_filter = render.FILTER_LINEAR,
mag_filter = render.FILTER_LINEAR,
},
})
end
2. 渲染顺序
Pass 1 清空光照 RT 为环境光颜色
Pass 2 把每个光源画到光照 RT(加法混合)
Pass 3 清空场景 RT,用 lit 材质绘制精灵(采样光照 RT)
Pass 4 后处理(bloom、色调映射)
Pass 5 绘制到屏幕
function update(self)
-- ---------- Pass 1: 清空光照缓冲 ----------
render.set_render_target(self.light_rt)
render.clear({ [render.BUFFER_COLOR_BIT] = { 0.12, 0.12, 0.18, 1 } })
-- ---------- Pass 2: 累积光源 ----------
render.set_view(self.light_view)
render.set_projection(self.light_projection)
render.enable_state(render.STATE_BLEND_ADD)
render.draw(self.light_predicate)
-- ---------- Pass 3: 绘制场景 ----------
render.set_render_target(self.scene_rt)
render.clear({ [render.BUFFER_COLOR_BIT] = { 0, 0, 0, 1 } })
render.enable_texture("light_map", self.light_rt)
render.set_view(self.scene_view)
render.set_projection(self.scene_projection)
render.draw(self.scene_predicate)
-- ---------- Pass 4/5: 后处理并输出 ----------
render.set_render_target(render.RENDER_TARGET_DEFAULT)
render.enable_texture("scene_map", self.scene_rt)
render.draw(self.post_predicate)
-- 清理纹理绑定,避免下一帧冲突
render.disable_texture("light_map")
render.disable_texture("scene_map")
end
3. Predicate 与坐标转换
-- 光源对象统一打上 "light" 标签
self.light_predicate = render.predicate({ hash("light") })
self.scene_predicate = render.predicate({ hash("scene") })
-- 把 RT 尺寸作为常量传给材质
render.enable_material("sprite_lit")
render.set_constant("sprite_lit", "light_rt_size",
vmath.vector4(render.get_window_width() / 2, render.get_window_height() / 2, 0, 0))
// 片元着色器里把屏幕坐标转成光照 UV
vec2 light_uv = gl_FragCoord.xy / light_rt_size;
踩坑:
render.set_render_target之后必须重新设置 view 和 projection,因为 Defold 不会自动按新 RT 的尺寸重算。如果光照错位或只渲染在左下角,先检查这里。
6. 后处理叠加与色调映射
1. Bloom 泛光
亮部溢出,让火光、魔法有光晕:
1. 亮度阈值提取 → 只保留亮度 > threshold 的像素
2. 高斯模糊 → 水平 + 垂直两次模糊
3. 叠加回原图 → 加法混合
// bloom_extract.fp
#version 140
in vec2 var_texcoord0;
uniform sampler2D tex0;
uniform fs_uniforms { vec4 params; }; // x = 阈值
out vec4 color_out;
void main()
{
vec4 c = texture(tex0, var_texcoord0);
float luma = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722));
float factor = max(luma - params.x, 0.0) / max(luma, 0.0001);
color_out = vec4(c.rgb * factor, c.a);
}
2. 高斯模糊
// blur.fp — 通过 params.xy 控制方向
#version 140
in vec2 var_texcoord0;
uniform sampler2D tex0;
uniform fs_uniforms { vec4 params; }; // xy = 方向步长
out vec4 color_out;
const float WEIGHTS[5] = float[](0.227027, 0.194594, 0.121621, 0.054054, 0.016216);
void main()
{
vec3 result = texture(tex0, var_texcoord0).rgb * WEIGHTS[0];
for (int i = 1; i < 5; ++i) {
vec2 offset = params.xy * float(i);
result += texture(tex0, var_texcoord0 + offset).rgb * WEIGHTS[i];
result += texture(tex0, var_texcoord0 - offset).rgb * WEIGHTS[i];
}
color_out = vec4(result, 1.0);
}
色调映射:简化版用 Reinhard 的 c / (c + 1),高质量用 ACES 近似
场景 → 光照相乘 → bloom 提取 → 模糊 → 加回场景 → 色调映射 → 暗角/噪点 → 输出
记忆:后处理每一级都是「读上一级 RT、写新 RT」。RT 越多,带宽开销越大,移动端要谨慎控制级数(3~4 级封顶)。
7. 阴影与遮挡处理
1. 遮挡体投影
2D 阴影通常不做真实光线追踪,而是用遮挡体投影:
-- 每个遮挡体是一个凸多边形
local function build_shadow_volume(occluder, light_pos)
local verts = {}
for _, p in ipairs(occluder.points) do
local dir = vmath.normalize(p - light_pos)
table.insert(verts, p)
table.insert(verts, p + dir * 1000) -- 沿光线方向延伸
end
return verts
end
2. 光线步进与投影贴图
// 简化的光线步进遮挡
float shadow_factor(sampler2D occluders, vec2 from, vec2 to, int steps)
{
float shadow = 1.0;
for (int i = 1; i <= steps; ++i) {
float t = float(i) / float(steps);
vec2 p = mix(from, to, t);
float occ = texture(occluders, p).r;
shadow = min(shadow, 1.0 - occ * (1.0 - t));
}
return shadow;
}
如果场景以静态几何为主,可以预烘焙一张阴影贴图(与地图同尺寸,逐像素记录是否被遮挡),运行时只做采样,几乎零开销。
软阴影:硬边阴影看起来生硬,对阴影因子做 3x3 抖动采样取平均,或直接对阴影贴图做一次模糊,就能得到柔和的边缘过渡。
8. 移动端性能优化
1. 填充率是第一瓶颈
2D 光照的瓶颈几乎总是填充率(像素吞吐),不是顶点数。光源半径越大、重叠越多,开销越高。
| 优化项 | 做法 | 收益 |
|---|---|---|
| 光照缓冲降分辨率 | 半分辨率或四分之一 | 2~4 倍 |
| 限制同屏光源数 | 只保留最亮的 N 个 | 线性 |
| 光源裁剪 | 屏幕外光源不渲染 | 视场景 |
| 减少 RT 数量 | bloom 用半分辨率链 | 带宽减半 |
| 关闭不必要后处理 | 低端机降级 | 明显 |
2. 分级画质
local QUALITY = {
high = { light_scale = 1.0, bloom = true, shadows = true, max_lights = 16 },
medium = { light_scale = 0.5, bloom = true, shadows = false, max_lights = 8 },
low = { light_scale = 0.5, bloom = false, shadows = false, max_lights = 4 },
}
function pick_quality()
local info = sys.get_sys_info()
if info.system_name == "Android" or info.system_name == "iPhone OS" then
return QUALITY.medium
end
return QUALITY.high
end
3. 光源排序与 RT 复用
-- 每帧按「强度 / 距离」排序,只保留前 N 个
local function select_lights(lights, camera_pos, max_count)
table.sort(lights, function(a, b)
local da = vmath.length(a.position - camera_pos)
local db = vmath.length(b.position - camera_pos)
return (a.intensity / (da + 1)) > (b.intensity / (db + 1))
end)
local out = {}
for i = 1, math.min(max_count, #lights) do
out[i] = lights[i]
end
return out
end
function on_message(self, message_id, message, sender)
if message_id == hash("window_resized") then
rebuild_render_targets(self) -- 只在窗口尺寸变化时重建 RT
end
end
踩坑:
render.render_target每次调用都会分配 GPU 内存。在 update 里调用会迅速耗尽显存并导致崩溃或严重卡顿。务必只在 init 和window_resized时创建。半分辨率光照缓冲记得设置双线性过滤,否则边缘会有明显锯齿。
9. 速查表
| 需求 | API 或做法 | 备注 |
|---|---|---|
| 创建离屏缓冲 | render.render_target(name, spec) | 只在 init 调用 |
| 切换渲染目标 | render.set_render_target(rt) | 之后要重设 view/projection |
| 清空缓冲 | render.clear({ [BUFFER_COLOR_BIT] = rgba }) | 传 table |
| 绑定纹理 | render.enable_texture("light_map", rt) | 用完 disable |
| 设置常量 | render.set_constant(material, name, value) | 传 vector4 |
| 加法混合 | render.enable_state(render.STATE_BLEND_ADD) | 累积光源 |
| 绘制筛选 | render.draw(predicate) | predicate 按标签筛 |
| 屏幕坐标 | gl_FragCoord.xy / rt_size | 采样光照缓冲 |
| 法线解码 | normalize(tex * 2.0 - 1.0) | 修正 Y 轴视工具而定 |
| 窗口变化 | window_resized 消息 | 重建 RT 的唯一时机 |
| 画质分级 | 按 sys.get_sys_info 选参数 | 移动端降级 |
一句话记忆:2D 光照 = 法线贴图提供朝向 + 光照缓冲累积光源 + 合成阶段相乘;光源当精灵画进 RT 用 STATE_BLEND_ADD 累积;render.set_render_target 后必须重设 view/projection;RT 只在 init 和 window_resized 时创建;移动端瓶颈在填充率,优先降光照缓冲分辨率并限制同屏光源数。
相关阅读
- /defold-render-script/ — 渲染脚本、渲染管线与 Pass 组织
- /defold-shader-postprocessing/ — 着色器语法与后处理链
- /defold-sprite-animation/ — 图集、贴图与帧动画
- /defold-camera-viewport/ — 摄像机、视口与投影矩阵
延伸阅读
- /defold-particle-system/ — 粒子特效与光照的配合
- /defold-performance-optimization/ — 渲染性能与 Draw Call 优化
- /defold-profiling-debugging/ — 用 Profiler 定位渲染瓶颈
- /defold-editor-asset-pipeline/ — 法线贴图与图集的导入配置
- 游戏开发专题 — 2D 渲染与光照系统设计
-- ======================================
-- 完整示例:light_manager.lua 光源管理
-- 放在 /scripts/light_manager.lua
-- ======================================
local M = {
lights = {},
max_lights = 8,
}
function M.add(light)
light.intensity = light.intensity or 1.0
table.insert(M.lights, light)
return light
end
-- 每帧按「强度 / 距离」排序,只保留前 N 个
function M.select(camera_pos)
table.sort(M.lights, function(a, b)
local da = vmath.length(a.position - camera_pos)
local db = vmath.length(b.position - camera_pos)
return (a.intensity / (da + 1.0)) > (b.intensity / (db + 1.0))
end)
local out = {}
for i = 1, math.min(M.max_lights, #M.lights) do
out[i] = M.lights[i]
end
return out
end
return M
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。