天天看點

CG: 用Rust實作一個3D渲染器 - 淺談MSAA抗鋸齒

摘要

寫作動機:畢業設計是從頭寫一個3D渲染器,程式設計語言為Rust。鑒于有幸聽過GAMES101相關課程,遂想為畢設加入4xMSAA抗鋸齒算法。但踩了幾個大坑,差點自閉,解決後趁思路還算清晰,分享一下遇到的難點和對應方案。

這三篇博文給了我較大啟發:

  1. 博文一
  2. 博文二
  3. 博文三

另外,本文并不會教你MSAA的原理,讨論僅從我在實作時應用的方案來切入。

問題的産生

接觸過GAMES101 CG課作業二的夥伴應該熟悉,課程定義的MSAA流程以及我們在網上看到的實作如下:

// 't'為目前三角形面片
seg_pos = {{0.25, 0.25}, {0.75, 0.25},{0.25, 0.75}, {0.75, 0.75}};
for(int x=x_l;x<=x_r;x++)
    for(int y=y_b;y<=y_t;y++) 
        int count = 0
        float min_depth = FLT_MAX;
        for(auto & e : seg_pos) 
            if(insideTriangle((float)x + e[0], (float)y + e[1], t.v)) 
                count++
                ...用重心坐标插值出目前采樣點深度
                min_depth = std::min(min_depth, 目前采樣點深度)
        if(count != 0 && depth_buf[get_index(x, y)] > min_depth) 
            像素顔色 = t.getColor() * (count / 4)
            設定顔色(像素顔色)
           
CG: 用Rust實作一個3D渲染器 - 淺談MSAA抗鋸齒

這是一種簡化版MSAA:四個子采樣點設定在像素中心的右上角,判斷每個采樣點是否在目前考慮的三角形面片内,若是則命中次數加1,并維護一個變量min_depth,記錄四個采樣點中最小的深度值。如果命中次數為0,或者未通過深度測試,則直接discard目前像素;否則目前像素設定顔色為

三角形顔色 * 命中率

,說白了就是簡單的算術平均。

于是乎,我為畢設實作MSAA時便屁颠屁颠地直接将上述流程照搬,結果出了大問題 -- 每個三角形面片之間出現了黑色裂縫:

CG: 用Rust實作一個3D渲染器 - 淺談MSAA抗鋸齒

一開始我以為又是什麼舍入帶來的精度損失,查閱資料後,好像在實作GAMES作業2的時候也有類似問題。

于是我重新回去運作作業2,确實在三角形交界處會出現黑線:

CG: 用Rust實作一個3D渲染器 - 淺談MSAA抗鋸齒

直接原因便是,這種算法存在一個缺陷,具體請看圖:

CG: 用Rust實作一個3D渲染器 - 淺談MSAA抗鋸齒

(注意,作業2比較特殊,整個場景就定義了兩個三角形面片,也就是藍色和黃綠色三角形)

圖中,我們正在藍色三角形内進行光栅化。考慮一種最壞的情況,即邊緣像素僅有左下角的采樣點命中了藍色三角形内部。

按照上面僞代碼的思路,根本不考慮另外三個處于黃綠色三角形内的采樣點的貢獻,目前像素顔色簡單粗暴設定為

t.getColor() * (1/4)

,這裡的 \(t\) 即為藍色三角形,它的顔色為 \(RGB(194,217,233)\),則目前像素會被設定為 \(RGB(48,54,58)\),呈現出一種類似于黑色的顔色,是以也不難想象當采樣點命中為2、3個的時候,顔色也隻是會稍微淺一點罷了,這與右邊的黃綠色産生了一種較強的割裂感,進而産生裂縫的錯覺。

嘗試解決

意識到問題後,我便查閱資料,并确定要實作一種開銷較大的MSAA:

每個像素有4bit的coverage mask,以及4個深度值(每個sample各一個),另外還有其他attribute(最簡單的就是color),并且是可以拿一份color copy給所有sample(準确地說是隻copy給那些mask非0的sample),而這份color可以來自像素中心或者某個sample。

政策

我選用的政策是,對于一個像素,算出其每個被命中sample(采樣點)的顔色、深度,最後再對相關值進行關于命中次數的算術平均:

(這個例子和上述作業2不太一樣,這裡假設背景的藍色和黃綠色僅恰巧為某些材質的接縫處,并不是簡單的兩個三角形交界;圖中的三角形是很多三角面片的其中一個)

CG: 用Rust實作一個3D渲染器 - 淺談MSAA抗鋸齒

如圖,按照我選擇的政策,該像素最右的sample在該三角形之外,被discard(丢棄)。那麼該像素的顔色即為剩下三個sample顔色的算術平均,最終顔色在邊界處産生一種較為平滑的過渡。這其實接近于SSAA了,因為會對多個采樣點運作fragment shader。

實作

你可能會注意到我在講述政策時用了一種不太一樣的sample模式,也即采樣點并非在像素右上角,而是環繞像素中心并略微旋轉:

CG: 用Rust實作一個3D渲染器 - 淺談MSAA抗鋸齒

因為在應對三角形邊界情形時,順時針旋轉26.6度的算子被證明是一種比較有效的sample模式。下面來看如何通過代碼一步步實作:

為了得到目标模式,我們先确立一個正正方方的采樣模式,其各sample都是一個Vector2類型,為這些sample均乘以一個旋轉矩陣即可:

CG: 用Rust實作一個3D渲染器 - 淺談MSAA抗鋸齒
pub static MSAA_LEVEL: usize = 4;
pub static MSAA_OFFSET: f32 = 0.25;
pub static MSAA_SAMPLE_POS: Matrix2<Vector2<f32>> = Matrix2::new(
    Vector2::new(-MSAA_OFFSET,-MSAA_OFFSET),Vector2::new(MSAA_OFFSET,MSAA_OFFSET),
    Vector2::new(-MSAA_OFFSET,MSAA_OFFSET),Vector2::new(MSAA_OFFSET,-MSAA_OFFSET),
);
           
  • MSAA_LEVEL表征一個像素的sample個數
  • MSAA_OFFSET表征每個sample與像素中心x、y的偏移絕對值
  • MSAA_SAMPLE_POS表征還未旋轉的sample模式

計算旋轉後的模式:

pub fn calc_conv() -> Matrix2<Vector2<f32>> {
    let mut conv_tmp: Matrix2<Vector2<f32>> = Default::default();
    let rotate: Matrix2<f32> = rotate_matrix2d(-26.6);
    for i in 0..4 {
        conv_tmp[i] = rotate*MSAA_SAMPLE_POS[i];
    }
    conv_tmp
}
           

為了友善輸出單個像素四個sample的狀态,我定義了一個結構叫做 MsaaTensor

pub struct MsaaTensor {
    mask:  Vector4<bool>,
    dept:  Vector4<f32>,
    colo:  Vector4<Vector3<f32>>,
}
           
  • mask記錄各sample是否被激活(命中 / hit)
  • dept記錄各sample的深度
  • colo記錄各sample的顔色

進入渲染例程,通過三個變量奠基:

for 像素(x,y) in 目前三角形包圍盒 {
    let 像素坐标 = x + y * self.height;
    let 張量 = msaa_tensors[像素坐标];
    let hit = 0.0;
    ......
}
           

為每個像素計算其MsaaTensor四個角的資訊:

for 像素(x,y) in 目前三角形包圍盒 {
    ......
    for idx in 0..MSAA_LEVEL {
        let 采樣點重心坐标 = barycentric(三角形三頂點,像素.x+采樣模式[idx].x,像素.y+采樣模式[idx].y);
        if 在三角形内(采樣點重心坐标) {
            hit += 1.0;
            let 深度 = 深度插值(采樣點重心坐标,三角形三頂點);
            tensor.設定标志(idx,true);
            tensor.設定深度(idx,深度);
            tensor.設定顔色(idx,着色器.顔色(采樣點重心坐标));
        }else {
            tensor.設定标志(idx,false);
        }
    }
    ......
}
           

如果沒有子sample被hit,那麼直接discard此像素:

for 像素(x,y) in 目前三角形包圍盒 {
    ......
    if hit == 0.0 {
        continue;
    }
    ......
}
           

隻要有一個sample被hit,計算blend資訊并着色:

for 像素(x,y) in 目前三角形包圍盒 {
    .......
    else {
        let 漫反射_blend = Default::default();
        let 深度_blend = 0.0;
        for idx in 0..MSAA_LEVEL {
            if msaa_tensors[ipixel].标志位(idx) == true {
                漫反射_blend += msaa_tensors[ipixel].顔色(idx);
                深度_blend   += msaa_tensors[ipixel].深度(idx);
            }
        }

        // 目前pixel的深度由被hit的子sample混合得到
        深度_blend /= hit;
        if 深度緩存(像素.x,像素.y)>深度_blend {
            continue;
        }

        深度緩存(像素x, 像素.y, depth_blend);
        幀緩存(像素.x, 像素.y, 漫反射_blend/hit);
    }
    ......
}
           

運作程式看看效果:

CG: 用Rust實作一個3D渲染器 - 淺談MSAA抗鋸齒

對比發現,好像除了一些地方變糊了,模型内部顔色交界處改善不太明顯。那麼試着把MSAA_OFFSET的步長改大一點:

pub static MSAA_OFFSET: f32 = 0.5;
           
CG: 用Rust實作一個3D渲染器 - 淺談MSAA抗鋸齒

效果相對明顯了,但我發現除了顔色變換劇烈的邊緣,一些較為平滑的局部也被MSAA處理得糊成一片。原因是我們的政策會對一切sample hit數不為0的像素進行算術平均處理,這與MSAA的理念相違背了。

改進

若像素的sample hit數為4,則表明該像素并不處于三角形面片邊緣,也就沒必要進行MSAA平均處理,直接用改像素點本身的顔色着色就行了。有了這個覺悟,為代碼加一段判定:

for 像素(x,y) in 目前三角形包圍盒 {
    .......
    else if hit == 4.0 {
        let 像素重心坐标 = barycentric(三角形三頂點,像素.x,像素.y);
        let 深度 = 深度插值(像素重心坐标,三角形三頂點);
        if 深度緩存(像素.x,像素.y)>深度 {
            continue;
        }
        深度緩存(像素x, 像素.y, 深度);
        幀緩存(像素.x, 像素.y, 着色器.顔色(像素重心坐标));
        continue;
    }
    ......
}
           

看看效果:

CG: 用Rust實作一個3D渲染器 - 淺談MSAA抗鋸齒

很明顯内部平滑部分未被MSAA影響,但似乎對比起來效果不顯著,這裡我還是感到比較困惑,歡迎夥伴一起讨論。