下载 App
10/16案例分享
【案例分享】不写 shader 也能发光:NanoVG 径向渐变做 Bloom 的 4 个参数、4 个坑
一句话结论
在星火编辑器里做发光 / Bloom,不需要后处理 shader:nvgRadialGradient 画一层径向渐变就够,一个发光圆只花 2 次 draw call。但要守住三条规则 —— 颜色在函数外乘好、只在 brightness > 1.0 时画光晕、渐变只画一层。
先划边界,别硬凑
后处理 Bloom 要引擎级的 blur shader,Lua 层拿不到。可游戏里大部分「发光」需求,本质就是「一个圆,中心亮,往外淡出」。径向渐变恰好是这个东西。
能干的:圆形粒子、UI 按钮高亮、能量球 / 光球、发光指示器,圆角矩形换成 nvgBoxGradient 也行。
干不了的:任意形状发光、文字发光、需要高斯衰减的精确 Bloom。这三样老老实实等引擎 shader。
形状对应用哪个函数:
圆形用 nvgRadialGradient
圆角矩形用 nvgBoxGradient
任意形状不支持,要引擎级 Blur Shader
四个参数,改这四个就够
BLOOM_INNER_ALPHA:推荐 0.45,可调 0.3 ~ 0.6,光晕中心的 alpha,越大中心越实
BLOOM_MID_ALPHA:推荐 0.6,可调 0 ~ 1,实心核心占比。0 = 从圆心就开始渐变,1 = 一大块实心核
BLOOM_OUTER_ALPHA:推荐 0.1,可调 0 ~ 1,光晕范围倍数,越大晕得越开
BLOOM_SIZE:推荐 2.0,可调 0.5 ~ 5,基础半径倍数
光晕半径不是 radius,是两个公式算出来的:
local maxRadius = radius * BLOOM_SIZE * (1.0 + BLOOM_OUTER_ALPHA * 3.0)
local innerR = radius * BLOOM_MID_ALPHA * 0.5
按推荐值代入,radius = 10 的子弹粒子:
maxRadius = 10 × 2.0 × 1.3 = 26 —— 光晕是核心半径的 2.6 倍
innerR = 10 × 0.6 × 0.5 = 3 —— 半径 3 以内是实心,3 到 26 之间渐变到全透明
BLOOM_OUTER_ALPHA 在公式里是乘 3 的,所以你把它从 0.1 调到 0.3,maxRadius 就从 26 涨到 38,不是涨一点点。
完整代码
直接抄。DrawGlowingCircle 是对外唯一入口,另外两个是它调的下手。
-- ===== Bloom 全局常量(与示例 59_NanoVG_Bloom.lua 一致)=====
local BLOOM_INNER_ALPHA = 0.45 -- 光晕中心 alpha
local BLOOM_MID_ALPHA = 0.6 -- 实心核心占比
local BLOOM_OUTER_ALPHA = 0.1 -- 光晕范围倍数
local BLOOM_SIZE = 2.0 -- 基础大小倍数
--- 画一层圆形光晕。不判断亮度、不乘系数,只管画。
function DrawCircleBloom(ctx, x, y, radius, r, g, b)
local maxRadius = radius * BLOOM_SIZE * (1.0 + BLOOM_OUTER_ALPHA * 3.0)
local innerR = radius * BLOOM_MID_ALPHA * 0.5
local alpha = BLOOM_INNER_ALPHA
nvgBeginPath(ctx)
nvgCircle(ctx, x, y, maxRadius)
local grad = nvgRadialGradient(ctx, x, y, innerR, maxRadius,
nvgRGBAf(r, g, b, alpha), -- 内侧:亮
nvgRGBAf(r, g, b, 0)) -- 外侧:全透明
nvgFillPaint(ctx, grad)
nvgFill(ctx)
end
--- 画一个会发光的圆(外部唯一入口)
--- brightness 亮度倍数。> 1.0 才触发 bloom,推荐 1.1 ~ 1.5
function DrawGlowingCircle(ctx, x, y, radius, r, g, b, brightness)
-- 关键 1:HDR 颜色在这里算好,下面调用的函数一律不再乘任何系数
local hdrR, hdrG, hdrB = r * brightness, g * brightness, b * brightness
-- 关键 2:只有越过阈值才画光晕,否则它就是个普通圆
if brightness > 1.0 then
DrawCircleBloom(ctx, x, y, radius, hdrR, hdrG, hdrB)
end
-- 核心圆:tonemapping 压回 0~1,防止中心糊成一块死白
nvgBeginPath(ctx)
nvgCircle(ctx, x, y, radius)
nvgFillColor(ctx, nvgRGBAf(math.min(1, hdrR), math.min(1, hdrG), math.min(1, hdrB), 1.0))
nvgFill(ctx)
end
--- ACES tonemapping:把 HDR 分量压回 0~1,保住高光层次
function ACESTonemap(x)
local a, b, c, d, e = 2.51, 0.03, 2.43, 0.59, 0.14
return math.max(0, math.min(1, (x * (a * x + b)) / (x * (c * x + d) + e)))
end
核心圆那行如果你不想写 math.min,换成 ACESTonemap(hdrR) 效果更好 —— math.min 是硬截断,超过 1.0 的部分直接丢掉;ACES 是曲线压缩,brightness 从 1.3 拉到 2.0 时中心还能看出层次变化。
圆角矩形版本,按钮高亮直接用它:
--- 带光晕的圆角矩形
function DrawGlowingRect(ctx, x, y, w, h, cornerRadius, r, g, b, brightness)
local hdrR, hdrG, hdrB = r * brightness, g * brightness, b * brightness
local feather = math.max(w, h) * 0.3 * BLOOM_SIZE -- 羽化距离
if brightness > 1.0 then
nvgBeginPath(ctx)
nvgRect(ctx, x - feather, y - feather, w + feather * 2, h + feather * 2)
local grad = nvgBoxGradient(ctx, x, y, w, h, cornerRadius, feather,
nvgRGBAf(hdrR, hdrG, hdrB, BLOOM_INNER_ALPHA),
nvgRGBAf(hdrR, hdrG, hdrB, 0))
nvgFillPaint(ctx, grad)
nvgFill(ctx)
end
nvgBeginPath(ctx)
nvgRoundedRect(ctx, x, y, w, h, cornerRadius)
nvgFillColor(ctx, nvgRGBAf(math.min(1, hdrR), math.min(1, hdrG), math.min(1, hdrB), 1))
nvgFill(ctx)
end
brightness 给多少
普通元素:1.0,无光晕,正常渲染
轻微发光:1.1 ~ 1.2,边缘一层很淡的雾
标准发光:1.3 ~ 1.5,适中,推荐
强烈发光:1.5 ~ 2.0,明显,用多了会糊
动态脉动:1.5 + math.sin(t) * 0.5,在 1.0 ~ 2.0 之间呼吸
脉动这条很好用。能量球的 brightness 挂上正弦,t 用累计时间,不用改任何别的代码,球就活了。
我踩过的四个坑
坑 1:在 DrawBloom 内部又乘了一次 intensity
-- 错:颜色进来时已经乘过 brightness,函数里再乘一遍
function DrawBloom(x, y, radius, r, g, b, intensity)
local hdrR = r * intensity -- 双重放大
...
end
drawBloom(x, y, r, g, b, 1.5) -- r,g,b 是归一化颜色,又乘了 1.5
-- 对:HDR 颜色在调用前算好,绘制函数原样使用
local hdrR, hdrG, hdrB = r * brightness, g * brightness, b * brightness
DrawCircleBloom(ctx, x, y, radius, hdrR, hdrG, hdrB)
双重放大不会报错,只会让画面亮得莫名其妙。我第一版调了半小时 BLOOM_INNER_ALPHA,其实问题根本不在那儿。
一条系数只能乘一次,而且必须在唯一的那个地方乘。我现在的做法是:DrawCircleBloom 的形参名就叫 hdrR,看到这个名字就知道不能动它。
坑 2:无条件绘制 bloom
-- 错:所有元素都画光晕
drawCircleBloom(ctx, x, y, radius, r, g, b)
-- 对:只在越过阈值时画
if brightness > 1.0 then
drawCircleBloom(ctx, x, y, radius, hdrR, hdrG, hdrB)
end
阈值 1.0 是有意义的:brightness <= 1.0 时颜色分量本来就 ≤ 1.0,属于 SDR 范围,画光晕纯属浪费 draw call。只有超出去的部分才需要「溢出成光」。
坑 3:brightness 给太高
-- 错:光晕糊成一坨,粒子挤在一起分不清
local brightness = 2.5
-- 对
local brightness = 1.3
brightness = 2.5 时颜色分量到 2.5,核心圆被 math.min 截成纯白,光晕半径按公式也跟着胀。屏幕上超过 2.0 基本就没有「形状」了,只剩一坨亮斑。1.1 ~ 1.5 是甜区。
坑 4:给所有元素都加 bloom
飞船、敌人、背景星星不需要光晕。只给子弹、粒子、经验碎片这类小型发光元素加。
判断标准很简单:这个元素是不是「能量」?子弹是能量,加。飞船是实体,不加。背景星星只是装饰,也不加。
性能
一个发光圆 = 2 次绘制(光晕 + 核心圆)。100 个子弹粒子就是 200 次。这个量级 NanoVG 扛得住。
但你要是给 500 颗背景星星全加上,凭空多出 500 次 draw call —— 而且星星本来就不该发光。
三条省法:
1. 单层渐变最高效。别为了「更柔和」叠两三层,叠加会在每层 maxRadius 处留下可见硬边界,还成倍加 draw call。
2. 按需绘制。就是坑 2 那个 if。省的是真金白银。
3. 数量自己数一遍。每个 bloom 是一次 draw call,先数清楚屏上有几个发光体,再决定要不要给第 501 个也加上。
已知限制
径向渐变是线性衰减,边缘不如高斯曲线自然。想要那种柔到没边的光,这招给不了。
只支持圆和圆角矩形。三角形、多边形、不规则碎片的光晕做不了。
多层叠加时,每层渐变的外边界会露出来,形成一圈硬边。
可迁移的经验
把「要不要特效」折算成一个标量,特效的开关就变成了比大小。
brightness > 1.0 这个判断值钱的地方不在于 1.0,而在于它把一个美术判断变成了一个数值条件。数值条件能调、能曲线化、能写测试、能交给 AI 改。
这招能搬到别处:速度 > 300 才拖尾、血量 < 20% 才红屏、连击 > 10 才闪光。凡是「什么时候该有特效」的问题,先问一句「能不能折算成一个数」。折得出来,这事就归代码管;折不出来,就会一直卡在「看情况加」上。








