Defold 2D 光照与特效

系统讲解 Defold 中的 2D 光照实现:从法线贴图与光照累积原理讲起,覆盖自定义材质与顶点数据、点光聚光环境光的类型差异、render target 渲染流程、后处理叠加与色调映射、阴影遮挡处理,以及移动端性能优化策略。

引言

2D 游戏加上光照后观感会有质的变化:火把摇曳、爆炸闪光、洞穴里手电筒扫过墙面——这些效果在 Defold 里完全可行,但需要理解渲染管线:法线贴图提供表面朝向、光照缓冲累积光源贡献、最后在合成阶段与场景贴图相乘。本文从原理讲起,覆盖法线贴图、自定义材质与 shader、光源类型、render target 流程、后处理叠加、阴影遮挡,最后给出移动端优化清单。

前置阅读:渲染脚本与渲染管线、着色器与后处理基础。


目录


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 时创建;移动端瓶颈在填充率,优先降光照缓冲分辨率并限制同屏光源数。


相关阅读

延伸阅读

-- ======================================
-- 完整示例: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

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「defold」更多文章

  1. Defold 测试与持续集成
  2. Defold 本地化与多语言
  3. Defold 团队协作与版本控制