本章為大家?guī)韮?nèi)發(fā)光特效。

一、內(nèi)發(fā)光原理
學(xué)習(xí) Shader 過程中,偶然在網(wǎng)上看到一句的內(nèi)發(fā)光原理,十分精辟受用:
采樣周邊像素alpha取平均值,疊加發(fā)光效果
事實(shí)上,根據(jù)這句精辟的原理,就可以實(shí)現(xiàn)內(nèi)發(fā)光了,你也試試吧?
以下為我的實(shí)現(xiàn)過程。
二、采樣周邊像素Alpha取平均值
怎么采集某個(gè)點(diǎn)的周邊像素呢?這里我們可以用 「按圓采樣」 算法
2.1 采樣圓邊上某點(diǎn)的 Alpha 值
如果我們已知圓的半徑 radius ,已經(jīng)某個(gè)角度 angle ,那么這個(gè)點(diǎn)的坐標(biāo)就很好計(jì)算了,其上的 Alpha 值就不再話下 :

x = radius * cos(angle);
y = radius * sin(angle);
在 Cocos Creator 的 Shader 中,代碼如下:
/**
* 獲取指定角度方向,距離為xxx的像素的透明度
*
* @param angle 角度 [0.0, 360.0]
* @param dist 距離 [0.0, 1.0]
*
* @return alpha [0.0, 1.0]
*/
float getColorAlpha(float angle, float dist) {
// 角度轉(zhuǎn)弧度,公式為:弧度 = 角度 * (pi / 180)
// float radian = angle * 0.01745329252; // 這個(gè)浮點(diǎn)數(shù)是 pi / 180
float radian = radians(angle);
vec4 color = getTextureColor(texture, v_uv0 + vec2(dist * cos(radian), dist * sin(radian)));
return color.a;
}
PS:
這里我們用到了 sin 和 cos 函數(shù),函數(shù)接受的參數(shù)是弧度制,因此我們要實(shí)現(xiàn)角度轉(zhuǎn)弧度
// 角度轉(zhuǎn)弧度,公式為:弧度 = 角度 * (pi / 180)
float radian = angle * 0.01745329252; // 這個(gè)浮點(diǎn)數(shù)是 pi / 180
但實(shí)際上,GLSL ES 語言已然存在內(nèi)置函數(shù) radians(float degree):將角度值轉(zhuǎn)化為弧度值,因此我們就用內(nèi)置函數(shù)即可。
2.2 采樣圓邊上所有點(diǎn)的 Alpah 平均值
上面我們已經(jīng)實(shí)現(xiàn)了獲取圓上某點(diǎn)的顏色 Alpha 值。那么,我們只需要來一個(gè) for 循環(huán),遍歷 0 到 360 度,那這個(gè)圓上所有點(diǎn)的顏色 Alpha 平均值就很容易算出來了。
但是,這樣子可能會(huì)有兩個(gè)問題:
- 計(jì)算量可能會(huì)太多,導(dǎo)致我們的性能低下
- 半徑很少的時(shí)候,相鄰的兩個(gè)或多個(gè)角度的點(diǎn)可能很近,或者甚至重合,此時(shí)這兩個(gè)點(diǎn)的 Alpha 值有可能相差不大,那么此時(shí)分別計(jì)算這些角度的 Alpha 值,就可能顯得有點(diǎn)冗余了,取其一即可
基于以上考慮,最終我采用的是圓采樣方式為:
以某個(gè)角度作為間隔,遍歷由此產(chǎn)生的各個(gè)方向的Alpha值,將這些值的和的平均值近似看作這個(gè)圓的Alpha值
比如:
假設(shè)以 45° 角間隔,那么我只需要計(jì)算下圖的 [10, 17] 共計(jì) 8 個(gè)點(diǎn)的 Alpha 平均值,那么這個(gè)值我就可以近似看作這個(gè)圓上所有點(diǎn)的 Alpha 平均值了

在 Cocos Creator 的 Shader 中,代碼如下:
/**
* 獲取指定距離的周邊像素的透明度平均值
*
* @param dist 距離 [0.0, 1.0]
*
* @return average alpha [0.0, 1.0]
*/
float getAverageAlpha(float dist) {
float totalAlpha = 0.0;
// 以30度為一個(gè)單位,那么「周邊一圈」就由0到360度中共計(jì)12個(gè)點(diǎn)的組成
totalAlpha += getColorAlpha(0.0, dist);
totalAlpha += getColorAlpha(30.0, dist);
totalAlpha += getColorAlpha(60.0, dist);
totalAlpha += getColorAlpha(90.0, dist);
totalAlpha += getColorAlpha(120.0, dist);
totalAlpha += getColorAlpha(150.0, dist);
totalAlpha += getColorAlpha(180.0, dist);
totalAlpha += getColorAlpha(210.0, dist);
totalAlpha += getColorAlpha(240.0, dist);
totalAlpha += getColorAlpha(270.0, dist);
totalAlpha += getColorAlpha(300.0, dist);
totalAlpha += getColorAlpha(330.0, dist);
return totalAlpha * 0.0833; // 1 / 12 = 0.08333
}
2.3 采樣點(diǎn)周邊像素 Alpha 平均值
上面兩個(gè)步驟,我們已經(jīng)實(shí)現(xiàn)了 近似采樣一個(gè)圓上所有點(diǎn)的 Alpha 平均值 。
而如果我們把「周邊」這個(gè)詞語理解為由很多個(gè)半徑不同的圓組合起來,那么現(xiàn)在我們只需要采樣多幾個(gè)圓,那么就可以實(shí)現(xiàn)我們的最終需求了—— 采樣周邊像素Alpha取平均值 。

那么,那么我們要采樣多少個(gè)圓呢?采集少了,效果可能粗糙,采集多了,可能計(jì)算量過多導(dǎo)致性能降低
一般而言,這種可變的屬性,我們應(yīng)該交給上層去傳入,但是如果上層要用內(nèi)發(fā)光特效,你暴露的一個(gè)參數(shù)名字叫 采樣多少個(gè)圓 ,那使用者一般會(huì)很茫然。
事實(shí)上,更加貼合上層使用者理解的屬性名應(yīng)該為 發(fā)光寬度 glowColorSize。
那我們又如何在程序上,在這個(gè)發(fā)光寬度上,控制采樣多少個(gè)圓呢?
劃分方案有很多種,這里我們采用按照發(fā)光寬度,等比劃分10個(gè)圓,只采樣這10個(gè)圓。(當(dāng)然你可以改動(dòng)這里的劃分方案)
在 Cocos Creator 的 Shader 中,代碼如下:
/**
* 獲取發(fā)光的透明度
*/
float getGlowAlpha() {
// 如果發(fā)光寬度為0,直接返回0.0透明度,減少計(jì)算量
if (glowColorSize == 0.0) {
return 0.0;
}
// 將傳入的指定距離,平均分成10圈,求出每一圈的平均透明度,
// 然后求和取平均值,那么就可以得到該點(diǎn)的平均透明度
float totalAlpha = 0.0;
totalAlpha += getAverageAlpha(glowColorSize * 0.1);
totalAlpha += getAverageAlpha(glowColorSize * 0.2);
totalAlpha += getAverageAlpha(glowColorSize * 0.3);
totalAlpha += getAverageAlpha(glowColorSize * 0.4);
totalAlpha += getAverageAlpha(glowColorSize * 0.5);
totalAlpha += getAverageAlpha(glowColorSize * 0.6);
totalAlpha += getAverageAlpha(glowColorSize * 0.7);
totalAlpha += getAverageAlpha(glowColorSize * 0.8);
totalAlpha += getAverageAlpha(glowColorSize * 0.9);
totalAlpha += getAverageAlpha(glowColorSize * 1.0);
return totalAlpha * 0.1;
}
2.4 調(diào)試發(fā)光
Ok,有了上面的采樣手段,現(xiàn)在我們可以來調(diào)試了。
首先,那么發(fā)光顏色選什么好呢?
交給上層控制吧,我們只需要定義一個(gè) 發(fā)光顏色 glowColor 即可。
float alpha = getGlowAlpha();
gl_FragColor = glowColor * alpha;
先來個(gè)內(nèi)發(fā)紅光看下: glowColor = vec4(1.0, 0.0, 0.0, 1.0);

可以看到右邊的調(diào)試結(jié)果還是挺符合我們的輸出預(yù)期,周邊點(diǎn)明顯是有一個(gè)漸變透明過程
但是,此時(shí)我們得到的是內(nèi)部透明度為1,靠近邊緣的為接近0的透明度,其他位置為0的透明度。而內(nèi)發(fā)光效果的話,恰恰相反,我們需要的是一個(gè)內(nèi)部透明度為0,靠近內(nèi)邊緣透明度為1的效果。
那么我們嘗試反轉(zhuǎn)一下
float alpha = getGlowAlpha();
// 內(nèi)發(fā)光是從邊緣發(fā)光的,是需要內(nèi)部透明度為0,靠近邊緣的接近1的透明度
// 因此我們需要反轉(zhuǎn)一下透明度
alpha = 1.0 - alpha;
gl_FragColor = glowColor * alpha;

現(xiàn)在是反轉(zhuǎn)了,但是圖像外邊的其他位置卻上色了,而在反轉(zhuǎn)之前,圖像外邊的其他位置是透明的,為了應(yīng)用這部分過來,在反轉(zhuǎn)之前,我們判斷一下,透明度大于某個(gè) 閾值 ,我們才反轉(zhuǎn) alpha 值。
那么這里的 閾值 要怎么定義呢?
為了更加深入理解這個(gè)問題,我們先來放大一下 Cocos 的 Logo 上方的那個(gè)角,先看清楚一個(gè)問題:

可以看到圖像的邊緣黑色并不是立即切換到完全透明的,而是一個(gè)過渡效果,從黑色開始慢慢變透明直到完全透明,透明從1 -> 0 慢慢過渡。事實(shí)上大部分的圖像邊緣都差不多類似這樣子,甚至部分圖片的設(shè)計(jì),本身就是有一個(gè)很長的漸變過渡帶。
那么問題來了,針對(duì)這種有漸變過渡帶的紋理,在我們實(shí)現(xiàn)的內(nèi)發(fā)光特效中,我們的發(fā)光邊緣要怎么定義呢?
- 從圖像邊緣最外邊的透明度為0.0開始發(fā)光?
- 從圖像邊緣往內(nèi),不透明(即透明度為1.0)的地方開始發(fā)光?
- 從圖像 0.0 到 1.0 之間的某個(gè)值開始發(fā)光?
不好取舍,不同圖片可能是需要不同處理,效果才好。
既然如此,我們就可以將這幾種定義抽象一下,比如叫 發(fā)光閾值 glowThreshold,范圍[0.0, 1.0]。我們暴露給上層使用者,交由上層使用者自行根據(jù)紋理去控制此值的大小即可。
現(xiàn)在我們的代碼就可以修改為這樣子了:
float alpha = getGlowAlpha();
if (alpha > glowThreshold) {
// 內(nèi)發(fā)光是從邊緣發(fā)光的,是需要內(nèi)部透明度為0,靠近邊緣的接近1的透明度
// 因此我們需要反轉(zhuǎn)一下透明度
alpha = 1.0 - alpha;
}
gl_FragColor = glowColor * alpha;
在 glowThreshold 為 0.2 時(shí),效果如下:

OK,看上去差不多的樣子了,現(xiàn)在我們?cè)囍唵问謩?dòng)混合一下,看起來內(nèi)發(fā)光效果就有了

???
好像還并不是內(nèi)發(fā)光的效果,看上去上方尖角的光源有點(diǎn)擴(kuò)邊了?這是那里出問題了呢?
因?yàn)槲覀兪且鰞?nèi)發(fā)光,所以如果點(diǎn)本來是透明的或者小于我們?cè)O(shè)立的閾值,那么其實(shí)這個(gè)點(diǎn)是沒必要進(jìn)行采樣周邊Alpha平均值的,否則就會(huì)有上面這種 擴(kuò)邊 的問題,那么我們?cè)谌“l(fā)光透明度的時(shí)候,在判斷一下即可
/**
* 獲取發(fā)光的透明度
*/
float getGlowAlpha() {
// 如果發(fā)光寬度為0,直接返回0.0透明度,減少計(jì)算量
if (glowColorSize == 0.0) {
return 0.0;
}
// 因?yàn)槲覀兪且鰞?nèi)發(fā)光,所以如果點(diǎn)本來是透明的或者接近透明的
// 那么就意味著這個(gè)點(diǎn)是圖像外的透明點(diǎn)或者圖像內(nèi)透明點(diǎn)(如空洞)之類的
// 內(nèi)發(fā)光的話,這些透明點(diǎn)我們不用處理,讓它保持原樣,否則就是會(huì)有內(nèi)描邊或者一點(diǎn)擴(kuò)邊的效果
// 同時(shí)也是提前直接結(jié)束,減少計(jì)算量
vec4 srcColor = getTextureColor(texture, v_uv0);
if (srcColor.a <= glowThreshold) {
return srcColor.a;
}
// 將傳入的指定距離,平均分成10圈,求出每一圈的平均透明度,
// 然后求和取平均值,那么就可以得到該點(diǎn)的平均透明度
float totalAlpha = 0.0;
totalAlpha += getAverageAlpha(glowColorSize * 0.1);
totalAlpha += getAverageAlpha(glowColorSize * 0.2);
totalAlpha += getAverageAlpha(glowColorSize * 0.3);
totalAlpha += getAverageAlpha(glowColorSize * 0.4);
totalAlpha += getAverageAlpha(glowColorSize * 0.5);
totalAlpha += getAverageAlpha(glowColorSize * 0.6);
totalAlpha += getAverageAlpha(glowColorSize * 0.7);
totalAlpha += getAverageAlpha(glowColorSize * 0.8);
totalAlpha += getAverageAlpha(glowColorSize * 0.9);
totalAlpha += getAverageAlpha(glowColorSize * 1.0);
return totalAlpha * 0.1;
}
現(xiàn)在看下來效果差不多了,是內(nèi)發(fā)光了!

但是好像發(fā)光強(qiáng)度不夠得樣子?沒關(guān)系,我們給它加點(diǎn)料,來個(gè)一元四次方程加強(qiáng),讓靠近邊緣的地方更加亮

對(duì)應(yīng)代碼如下:
float alpha = getGlowAlpha();
if (alpha > glowThreshold) {
// 內(nèi)發(fā)光是從邊緣發(fā)光的,是需要內(nèi)部透明度為0,靠近邊緣的接近1的透明度
// 因此我們需要反轉(zhuǎn)一下透明度
alpha = 1.0 - alpha;
// 給點(diǎn)調(diào)料,讓靠近邊緣的更加亮
alpha = -1.0 * (alpha - 1.0) * (alpha - 1.0) * (alpha - 1.0) * (alpha - 1.0) + 1.0;
}
gl_FragColor = glowColor * alpha;
現(xiàn)在大概效果已經(jīng)出來了:

三、混合顏色
在上面動(dòng)圖中,實(shí)際上為了演示,我是有兩個(gè) Sprite, 一個(gè)用內(nèi)置材質(zhì),一個(gè)用在調(diào)試中的內(nèi)發(fā)光材質(zhì),通過手動(dòng)移動(dòng)的方式,我們已經(jīng)大概看到將內(nèi)發(fā)光疊加到原圖上方,看起來就是內(nèi)發(fā)光特效了。

那么這一步,我們要怎么實(shí)現(xiàn)一步到位,直接就將內(nèi)發(fā)光疊加在原圖上,形成最終效果。
實(shí)際上,這也叫 混合模式 ,混合模式主要解決的是兩種顏色之間,該如何混合,比如疊加、覆蓋等等。
混合模式在我們平時(shí)開發(fā)中也是經(jīng)常在使用著的,比如,Sprite 組件:

理解不同的組合,對(duì)于我們實(shí)現(xiàn)不同混合效果,是基礎(chǔ)中的基礎(chǔ)。
關(guān)于這部分,官方在 UI渲染批次合并指南的 Blend 模式章節(jié) 一文中有說到,覺得純文字比較難以理解的,可以參考網(wǎng)上 2dx 關(guān)于混合模式的相關(guān)文章。
回歸我們的主題,要實(shí)現(xiàn)在原圖上疊加我們的內(nèi)發(fā)光特效,那么
// 源顏色就是內(nèi)發(fā)光顏色
vec4 color_dest = o;
// 目標(biāo)顏色就是圖案顏色色
vec4 color_src = glowColor * alpha;
// 按照官方的混合顏色介紹和規(guī)則
//
// 要在圖案上方,疊加一個(gè)內(nèi)發(fā)光,將兩者顏色混合起來,那么最終選擇的混合模式如下:
//
// (內(nèi)發(fā)光)color_src: GL_SRC_ALPHA
// (原圖像)color_dest: GL_ONE
//
// 即最終顏色如下:
// color_src * GL_SRC_ALPHA + color_dest * GL_ONE
gl_FragColor = color_src * color_src.a + color_dest;
混合后的最終效果:

四、編輯器 texture 函數(shù)問題
在對(duì)比 瀏覽器 和 Cocos Creator 編輯器 的預(yù)覽結(jié)果的后,你可能會(huì)發(fā)現(xiàn)編輯器的發(fā)光效果,相比起瀏覽器的沒有那么好,比如編輯器左右兩邊的發(fā)光很窄。

這是因?yàn)?/p>
在 Cocos Creator 2.2.1 的編輯器中,超出邊界的uv并不是返回 vec4(0.0, 0.0, 0.0, 0.0),實(shí)際返回為
- 超出左邊界的uv,返回 v_uv0.x = 0 的顏色
- 超出右邊界的uv,返回 v_uv0.x = 1 的顏色
- 超出上邊界的uv,返回 v_uv0.y = 1 的顏色
- 超出下邊界的uv,返回 v_uv0.y = 0 的顏色
而這樣子的處理,會(huì)導(dǎo)致我們獲取圖像邊緣位置的周邊像素的的 alpha 值有可能偏低。
比如:在我們這個(gè)例子上,以圖像中間左邊緣為例,采樣周邊平均 Alpha 的時(shí)候,因?yàn)槌鰣D像邊界的都是 1.0 ,因此這個(gè)圖像左邊緣的 平均 Alpha 就是1.0,相當(dāng)于沒有內(nèi)發(fā)光了,光不起來,同理圖像其他邊緣也是。
要修復(fù)這個(gè)問題其實(shí)也很簡單,我們只需要封裝一層獲取 uv 像素的函數(shù)
vec4 getTextureColor(sampler2D texture, vec2 v_uv0) {
if (v_uv0.x > 1.0 || v_uv0.x < 0.0 || v_uv0.y > 1.0 || v_uv0.y < 0.0) {
return vec4(0.0, 0.0, 0.0, 0.0);
}
return texture(texture, v_uv0);
}
然后將原來所有的 texture() 函數(shù)的地方直接替換為 getTextureColor() 即可
PS:上面用到的靜圖、動(dòng)圖都是修復(fù)后的效果圖
五、總結(jié)
5.1 采樣算法
在實(shí)現(xiàn) 采樣周邊像素Alpha取平均值 的時(shí)候,我們采用了 「按圓采樣」 算法去進(jìn)行采樣,實(shí)際上,這里有很多種采樣方式,比如: 矩形偏移采樣
矩形偏移采樣:
- 取右、右上、上、左上、左、左下、下、右下共計(jì)8個(gè)方向的點(diǎn)作為周邊
- 按照上一步的定義去擴(kuò)大「周邊」,從而實(shí)現(xiàn)收集
大概步驟如下圖:

不過,你也可以看到,這種方案的收集方式存在一個(gè)問題:
隨著收集距離的擴(kuò)大,會(huì)出現(xiàn)越來越多的點(diǎn)不會(huì)收集到,因?yàn)槭占较蚓椭挥?個(gè),方向夾角之間的點(diǎn)是收集不了的(比如 23 -> 24, 33 -> 34 之間的點(diǎn))
那是不是這個(gè)方案就不好呢,其實(shí)也不是,這個(gè)方案的最大優(yōu)點(diǎn)是減少了很多 sin , cos 的計(jì)算,因?yàn)榫褪占?個(gè)方向,而這8個(gè)方向恰好只需要加法和減法就可以的出來了,因此性能上會(huì)更好,對(duì)于部分圖片,如果發(fā)光寬度很短,那么此采集方案可能更優(yōu)。
那么,簡單總結(jié)下現(xiàn)在討論的兩種「周邊采樣算法」的優(yōu)劣:
| 采樣算法 | 優(yōu)點(diǎn) | 缺點(diǎn) | 適用場合 |
|---|---|---|---|
| 按圓采樣 | 覆蓋面相對(duì)較全,效果相對(duì)細(xì)膩 | 計(jì)算量相對(duì)偏多 | 絕大部分場合 |
| 矩形偏移采樣 | 覆蓋面相對(duì)少,效果相對(duì)粗糙,且由于方向固定,可能存在特殊情況下,效果不理想 | 計(jì)算量相對(duì)較少 | 發(fā)光寬度較少,比較少大轉(zhuǎn)折彎的紋理 |
當(dāng)然,還有其他很多采樣算法,如果你有想法,不妨自己動(dòng)手試下吧,試完之后記得分析下優(yōu)劣和使用場合,這會(huì)讓你有更多收獲。
5.2 關(guān)于發(fā)光強(qiáng)度
為了實(shí)現(xiàn)邊緣更加光亮,我直接寫死了一個(gè) 一元四次方程,實(shí)際上這可能不好控制。另外一些好的公式可以使用 二次貝塞爾 或者 三次貝塞爾 可以很方便操作控制點(diǎn),從而實(shí)現(xiàn)不同曲度。
5.3 其他
當(dāng)然,在操作一遍下來后,說不準(zhǔn)你也覺得這種實(shí)現(xiàn)不好,xxx地方有哪些地方可以優(yōu)化,如果有更好的方案,我們不妨留言交流一下吧。
OK,本章完,完整代碼在我的 Github 倉庫 或 Gitee 倉庫 中可以找到。
下一篇:
上一篇: