Cocos Creator Shader Effect 系列 - 6 - 內(nèi)發(fā)光特效

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

2d-sprite-glow-inner.gif

一、內(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 值就不再話下 :

Step 1
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:

這里我們用到了 sincos 函數(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è)問題:

  1. 計(jì)算量可能會(huì)太多,導(dǎo)致我們的性能低下
  2. 半徑很少的時(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 平均值了

Step2

在 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取平均值 。

Step3

那么,那么我們要采樣多少個(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);

Test 1

可以看到右邊的調(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;
Test 2

現(xiàn)在是反轉(zhuǎn)了,但是圖像外邊的其他位置卻上色了,而在反轉(zhuǎn)之前,圖像外邊的其他位置是透明的,為了應(yīng)用這部分過來,在反轉(zhuǎn)之前,我們判斷一下,透明度大于某個(gè) 閾值 ,我們才反轉(zhuǎn) alpha 值。

那么這里的 閾值 要怎么定義呢?

為了更加深入理解這個(gè)問題,我們先來放大一下 Cocos 的 Logo 上方的那個(gè)角,先看清楚一個(gè)問題:

glowThreshold

可以看到圖像的邊緣黑色并不是立即切換到完全透明的,而是一個(gè)過渡效果,從黑色開始慢慢變透明直到完全透明,透明從1 -> 0 慢慢過渡。事實(shí)上大部分的圖像邊緣都差不多類似這樣子,甚至部分圖片的設(shè)計(jì),本身就是有一個(gè)很長的漸變過渡帶。

那么問題來了,針對(duì)這種有漸變過渡帶的紋理,在我們實(shí)現(xiàn)的內(nèi)發(fā)光特效中,我們的發(fā)光邊緣要怎么定義呢?

  1. 從圖像邊緣最外邊的透明度為0.0開始發(fā)光?
  2. 從圖像邊緣往內(nèi),不透明(即透明度為1.0)的地方開始發(fā)光?
  3. 從圖像 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í),效果如下:

Test 3

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

Test 4

???

好像還并不是內(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ā)光了!

Test 5

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

flavour

對(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)出來了:

Glow Inner

三、混合顏色

在上面動(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ā)光特效了。

Test 5

那么這一步,我們要怎么實(shí)現(xiàn)一步到位,直接就將內(nèi)發(fā)光疊加在原圖上,形成最終效果。

實(shí)際上,這也叫 混合模式 ,混合模式主要解決的是兩種顏色之間,該如何混合,比如疊加、覆蓋等等。

混合模式在我們平時(shí)開發(fā)中也是經(jīng)常在使用著的,比如,Sprite 組件:

Blend

理解不同的組合,對(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;

混合后的最終效果:

Glow Inner

四、編輯器 texture 函數(shù)問題

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

texture function problem

這是因?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í)際上,這里有很多種采樣方式,比如: 矩形偏移采樣

矩形偏移采樣:

  1. 取右、右上、上、左上、左、左下、下、右下共計(jì)8個(gè)方向的點(diǎn)作為周邊
  2. 按照上一步的定義去擴(kuò)大「周邊」,從而實(shí)現(xiàn)收集

大概步驟如下圖:

Total

不過,你也可以看到,這種方案的收集方式存在一個(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 倉庫 中可以找到。

下一篇:

上一篇:

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容