2D自然光照效果开发指南:划破黑暗,洞见光明
精华03/3084 浏览开发心得 包含 AI 合成内容
做《觅光》的时候,我一直想要那种"黑暗洞穴里只有火把周围一圈亮光"的感觉——玩家点亮一支火把,光圈慢慢亮起来,多支火把的光自然地连成一片。听起来简单对吧?在其他游戏引擎里面,简单利用shader和URP就能实现。但在taptap制造中做起来,我踩了不少坑,试了三个方案才搞定。这篇就把我的摸索过程和最终方案分享出来,希望对想做类似效果的朋友有帮助。
先趁机介绍一下我的游戏!《觅光》是一款 2D 平台解谜游戏,核心玩法就是在黑暗洞穴里靠火把生存。整个画面默认是全黑的,只有光源附近才能看到场景——火把、玩家、钥匙、甚至敌人都会发出各自颜色的微光。欢迎来玩!
基于这个设计,最终实现了这些效果:
完全黑暗的洞穴,只有光源附近可见
多个火把的光圈自然重叠,交界处不会出现黑缝
火把有暖色光晕和跳动闪烁的动画
玩家自身带微弱冷色光(亮度随生命值变化)
钥匙发金色光、敌人发紫色幽光、萤火虫随机闪烁
地面边缘有隐约的轮廓光,让玩家不至于完全瞎走

游戏开发画面,后续还会优化
而且这一切没有用到任何着色器或帧缓冲,纯粹靠taptap制造内部的NanoVG引擎的合成操作实现。
我踩过的坑:为什么"画个圆"没那么简单
第一反应:在黑色遮罩上挖个洞
最开始我的思路很直觉——全屏铺一层半透明黑色,然后在火把位置"挖个洞"。
黑暗遮罩(alpha=245)
↓ 在火把位置挖洞
出现一个圆形亮区
结果:洞的边界处透明度从 0 突然跳到 245,过渡非常生硬,像是在黑纸上剪了个圆,完全没有光照自然衰减的感觉。
第二次尝试:挖大洞 + 渐变填充
我把洞挖到整个光照半径那么大,然后用径向渐变重新填充,实现从亮到暗的平滑过渡。
单个火把的效果确实好多了。但是——
当两支火把的光圈重叠时,交界处出现了一条明显的暗缝。因为两个渐变各自带有半透明黑色,叠在一起就变得更暗了。
这个问题困扰了我很久,因为在我的游戏里火把是核心玩法,玩家会频繁地在多支火把之间穿梭,光圈重叠是常态,不是特例。
这时候我发现,应该反过来想——前两个方案的根本问题在于,我是在"逐个光源叠加黑暗"。两个光源各画一份黑暗渐变,重叠区域黑暗就加倍了。
正确的思路应该是反过来:先铺满黑暗,然后逐个光源"擦除"黑暗。
两个光源各自擦掉一部分黑暗,重叠区域就被擦得更干净 = 更亮。这才是物理上正确的多光源行为。
最终方案:DESTINATION_OUT 四步法
这就是我最终使用的方案。核心思想是利用 Canvas/NanoVG 的合成模式来实现"擦除"效果。
原理一句话:先画好场景,再铺满黑暗,用光源把黑暗擦掉,最后把被误擦的场景补回来。
需要用到的合成操作
开始之前先了解三个关键的合成模式(这是 Canvas 2D / NanoVG 的标准 API,其他框架也有等价物):
SOURCE_OVER(默认模式)
计算公式:`dst = src + dst×(1-srcA)`
新内容直接画在旧内容上面,就像正常叠图层一样。
DESTINATION_OUT(擦除模式)
计算公式:`dst = dst×(1-srcA)`
新画的内容越不透明,旧内容被擦掉的就越多,常用来做橡皮擦或遮罩效果。
DESTINATION_OVER(后置模式)
计算公式:`dst = dst + src×(1-dstA)`
新内容画在旧内容后面,只在旧内容的透明区域可见,相当于"垫在底下"。
完整流程
Step 1 [SOURCE_OVER] 正常画场景(背景、地形、角色、敌人...)
↓
Step 2 [SOURCE_OVER] 全屏铺一层纯黑遮罩(完全盖住场景)
↓
Step 3 [DESTINATION_OUT] 逐个光源画径向渐变圆(擦掉对应区域的黑暗)
↓
Step 4 [DESTINATION_OVER] 反向重新画一遍场景(补回被误擦的画面)
↓
Step 5 [SOURCE_OVER] 叠加暖色光晕(可选,增加氛围感)
为什么需要 Step 4?
这是一个容易被忽略的关键点。Step 3 的 DESTINATIONOUT 不光擦掉了黑暗层,也把 Step 1 画好的场景一起擦了。所以需要用 DESTINATIONOVER(画在后面)把场景补回来——它只会填充透明区域(也就是被光照亮的地方),已有内容不受影响。
为什么 Step 4 要"反向"绘制?
因为 DESTINATIONOVER 把新内容画在旧内容后面。正常绘制顺序是"天空 → 地面 → 角色"(后画的盖住先画的),但在 DESTINATIONOVER 模式下后画的反而跑到更后面去了。所以要反过来,先画前景(角色、粒子),最后画背景(天空)。
代码实现
以下是核心渲染函数的完整实现,不理解也没关系!我的整个帖子都可以把关键部分复制到塔啦啦的项目文档里面,让塔啦啦来做阅读理解就好啦!希望对你有所帮助:
```lua
function DrawLighting(vg)
local lights = CollectAllLights()
-- Step 2: 全屏黑暗层
nvgBeginPath(vg)
nvgRect(vg, camX, camY, screenW, screenH) -- 覆盖整个可见区域
nvgFillColor(vg, nvgRGBA(0, 0, 0, 255)) -- 255 = 完全不可见
nvgFill(vg)
-- Step 3: DESTINATION_OUT —— 逐光源擦除黑暗
nvgGlobalCompositeOperation(vg, NVG_DESTINATION_OUT)
for _, l in ipairs(lights) do
nvgBeginPath(vg)
nvgCircle(vg, l.x, l.y, l.r)
local paint = nvgRadialGradient(vg, l.x, l.y,
l.r * 0.06, -- 亮核半径:极小,只有 6% 是完全透亮的
l.r * 0.92, -- 衰减终点:92% 处开始全黑
nvgRGBA(255, 255, 255, 255), -- 中心:完全擦除
nvgRGBA(255, 255, 255, 0)) -- 边缘:不擦除
nvgFillPaint(vg, paint)
nvgFill(vg)
end
nvgGlobalCompositeOperation(vg, NVG_SOURCE_OVER) -- 恢复默认
-- Step 4: DESTINATION_OVER —— 在光照区背后补回场景
nvgGlobalCompositeOperation(vg, NVG_DESTINATION_OVER)
DrawSceneReverse(vg) -- 注意:反向绘制!
nvgGlobalCompositeOperation(vg, NVG_SOURCE_OVER)
-- Step 5: 暖色光晕叠加(增加氛围感)
for _, l in ipairs(lights) do
-- 大范围暖色弥漫
nvgBeginPath(vg)
nvgCircle(vg, l.x, l.y, l.r * 0.55)
local outer = nvgRadialGradient(vg, l.x, l.y, 0, l.r * 0.55,
nvgRGBA(l.cr, l.cg, l.cb, 50),
nvgRGBA(l.cr, l.cg, l.cb, 0))
nvgFillPaint(vg, outer)
nvgFill(vg)
-- 核心亮点
nvgBeginPath(vg)
nvgCircle(vg, l.x, l.y, l.r * 0.2)
local core = nvgRadialGradient(vg, l.x, l.y, 0, l.r * 0.2,
nvgRGBA(l.cr, l.cg, l.cb, 80),
nvgRGBA(l.cr, l.cg, l.cb, 0))
nvgFillPaint(vg, core)
nvgFill(vg)
end
end
```
渲染主循环中的调用顺序:
```lua
function HandleNanoVGRender()
nvgBeginFrame(vg, width, height, dpr)
-- Step 1: 正常画一遍场景
DrawScene(vg)
-- Step 2-5: 光照处理(黑暗 + 擦除 + 恢复 + 光晕)
DrawLighting(vg)
-- HUD 在最上层,不受光照影响
DrawHUD(vg)
nvgEndFrame(vg)
end
```
让光源"活"起来:不止是火把
光照系统搭好之后,我发现可以给各种游戏元素都赋予微光,让整个场景变得很有层次感。
多类型光源收集
```lua
function CollectAllLights()
local all = {}
-- 火把:主光源,带闪烁动画
for _, t in ipairs(torches) do
if t.is_on then
-- 用 sin 函数让半径微微跳动,模拟火焰闪烁
local flicker = math.sin(totalTime * 3.0 + t.flickerPhase) * 8
table.insert(all, {
x = t.x, y = t.y - 8,
r = t.lightRadius + flicker, -- 大光圈 + 闪烁
cr = 255, cg = 170, cb = 70, -- 暖色
})
else
-- 未点亮的火把也发微弱光,让玩家能隐约发现它
table.insert(all, {
x = t.x, y = t.y - 8,
r = 18, -- 很小的光圈
cr = 255, cg = 170, cb = 70,
})
end
end
-- 玩家:微弱冷色光,亮度随 LP(生命值)变化
local lpRatio = player.lp / LP_MAX
table.insert(all, {
x = player.x, y = player.y,
r = 15 + 20 * lpRatio, -- LP 越高光越大
cr = 200, cg = 210, cb = 255, -- 冷色调,和火把形成对比
})
-- 钥匙:金色微光,吸引玩家注意
for _, k in ipairs(keys) do
if not k.collected then
table.insert(all, {
x = k.x, y = k.y,
r = 25,
cr = 255, cg = 220, cb = 50, -- 金色
})
end
end
-- 敌人:紫色幽光,营造危险感
for _, e in ipairs(enemies) do
if e.alive then
table.insert(all, {
x = e.x + e.w/2, y = e.y + e.h/2,
r = 22,
cr = 160, cg = 80, cb = 220, -- 紫色
})
end
end
-- 萤火虫:随机闪烁的小光点
for _, f in ipairs(fireflies) do
local glow = 0.5 + 0.5 * math.sin(f.brightness)
table.insert(all, {
x = f.x, y = f.y,
r = 12 * glow + 5,
cr = f.r, cg = f.g, cb = f.b,
})
end
-- 地面边缘微光:让地形轮廓隐约可见
for _, seg in ipairs(edgeSegments) do
if seg.dir == "top" then
local step = tileSize * 3
local count = math.max(1, math.floor(seg.len / step))
for i = 0, count do
table.insert(all, {
x = seg.x + (seg.len * i / count),
y = seg.y,
r = 14,
cr = 60, cg = 80, cb = 100, -- 冷蓝,非常微弱
})
end
end
end
return all
end
```
这么做之后,即使在远离火把的暗区,玩家也能通过微弱的轮廓光隐约感知地形,通过紫色幽光感知附近有敌人,通过金色微光发现钥匙——信息都通过光来传递,和游戏"觅光"的主题完美契合。
调参指南:怎么调出你想要的感觉
这套方案里有几个关键参数,不同的值会带来截然不同的视觉风格:
渐变参数
lua
nvgRadialGradient(vg, cx, cy,
innerRadius, -- 亮核半径(这个范围内完全透亮)
outerRadius, -- 衰减终点(从这里开始完全黑暗)
innerColor, -- 中心颜色 (alpha=255 完全擦除)
outerColor) -- 边缘颜色 (alpha=0 不擦除)
常用调参方向
- 想要更大的中心亮区:把 innerRadius 比例从 0.06 调大到 0.15~0.20
- 想要更柔和的明暗过渡:把 outerRadius 比例从 0.92 调小到 0.75~0.80
- 想要夜视效果(暗区微微透光):把黑暗层 alpha 从 255 降到 230~240
- 想要更浓烈的色彩感:把光晕层的 alpha 值调高(50→80, 80→120)
- 想要更明显的火焰跳动:把闪烁振幅从 ±8px 调大到 ±15px
- 想要手电筒/锥形光:把 nvgCircle 换成扇形 path
黑暗度 (DARKNESS) 的影响
255 = 完全漆黑,光源外什么都看不见(恐怖/探索风格)
245 = 微微透出场景轮廓(更友好,降低迷路感)
230 = 明显能看到暗处(夜晚氛围,非完全黑暗)
200 = 黄昏感(配合色调变化可以做昼夜循环)
性能注意事项
因为 Step 4 需要把整个场景重新画一遍,这套方案的绘制开销大约是普通渲染的 2 倍。一些优化建议:
- 控制光源数量:实测 10~15 个光源以内完全流畅,超过 20 个需注意性能
- 视口裁剪:只绘制摄像机可见范围内的内容,屏幕外的直接跳过
- 远处光源简化:离屏幕边缘很远的光源可以跳过光晕层
- 合成模式批量化:先画完所有 DEST_OUT 的,再统一切回来,减少状态切换
我实际游戏里同屏最多有十几个光源(火把 + 玩家 + 钥匙 + 敌人 + 萤火虫 + 边缘光),在手机上也跑得很流畅。
可以怎么扩展
这套方案的底子很灵活,我列几个可以进一步做的方向:
- 昼夜循环:让 DARKNESS 随时间在 255 到 0 之间变化
- 手电筒效果:把擦除的圆改成扇形路径
- 光照遮挡/阴影:从光源射线检测碰撞体,被挡的方向不擦除
- 动态光源:爆炸、技能释放时临时添加光源到列表
- 彩色场景染色:不同区域用不同颜色的光,营造不同氛围
其中光照的遮挡和阴影效果,是我在本次游戏制作中因为仓促尚未实现的,但是可以的话希望能帮助你实现!
总结
回顾整个过程,核心就是一个思维转换:
不要想"怎么添加光亮",要想"怎么擦除黑暗"。
"逐光源叠加黑暗"会导致重叠区变暗(暗缝问题),而"逐光源擦除黑暗"天然就是物理正确的——两个光源照同一个地方,当然应该更亮。
如果你也在做类似"黑暗中探索"主题的 2D 游戏,希望这篇能帮你少走一些弯路。也欢迎来试玩《觅光》!有问题欢迎交流!



