現(xiàn)詳解)
簡(jiǎn)介smaaDemo是一份面向圖形渲染開(kāi)發(fā)者與圖形學(xué)學(xué)習(xí)者的OpenGL、Vulkan雙API抗鋸齒演示工程聚焦亞像素形態(tài)抗鋸齒SMAA這一后期處理技術(shù)幫助讀者理解邊緣檢測(cè)、鄰域搜索與色彩混合三階段算法并對(duì)比MSAA、SSAA、FXAA等方案在畫(huà)質(zhì)與性能上的取舍。壓縮包為zip格式整體約22.84MB內(nèi)含C源代碼、GLSL或SPIR-V著色器、紋理與測(cè)試場(chǎng)景資源以及Makefile或CMakeLists.txt構(gòu)建腳本和SMAA質(zhì)量等級(jí)等配置文件便于直接編譯運(yùn)行與調(diào)參實(shí)驗(yàn)。目前已有238人學(xué)習(xí)關(guān)注。通過(guò)研讀該項(xiàng)目讀者可掌握在OpenGL與Vulkan中搭建后期處理管線、集成SMAA并優(yōu)化渲染性能的完整思路同時(shí)加深對(duì)紋理采樣與著色器編程等高級(jí)特性的理解適合具備一定圖形編程基礎(chǔ)、希望提升畫(huà)面質(zhì)量與渲染效率的開(kāi)發(fā)者參考實(shí)踐。1. 從鋸齒邊緣說(shuō)起smaaDemo 到底演示了什么如果你寫(xiě)過(guò) OpenGL 或 Vulkan 的渲染管線大概率被鋸齒邊緣折磨過(guò)。MSAA 靠硬件多重采樣開(kāi)銷隨采樣數(shù)線性上漲FXAA 是后處理快但糊而 SMAASubpixel Morphological Antialiasing亞像素形態(tài)抗鋸齒走的是另一條路——它先用邊緣檢測(cè)找出需要處理的像素再根據(jù)局部形態(tài)模式做混合權(quán)重計(jì)算最后在亞像素級(jí)別修正顏色。smaaDemo 就是把這套流程用 OpenGL 和 Vulkan 兩條后端各實(shí)現(xiàn)了一遍的演示工程代碼結(jié)構(gòu)清晰適合拿來(lái)對(duì)照學(xué)習(xí)或直接摳出核心 pass 塞進(jìn)自己的渲染器。它解決的是「想要接近 MSAA 的畫(huà)質(zhì)、又不想付 MSAA 的帶寬和顯存代價(jià)」這個(gè)矛盾適合已經(jīng)能跑通基礎(chǔ)渲染管線、想深入理解后處理抗鋸齒的圖形開(kāi)發(fā)者。2. SMAA 的三段式管線邊緣檢測(cè)、形態(tài)計(jì)算、鄰域混合2.1 為什么不是一步到位SMAA 的核心思路是把抗鋸齒拆成三個(gè)獨(dú)立的 pass每個(gè) pass 只做一件事。這種拆分不是為了好看而是因?yàn)檫吘墮z測(cè)和混合權(quán)重計(jì)算對(duì)紋理采樣的需求完全不同——前者需要精確的亮度對(duì)比后者需要大范圍的鄰域信息。如果硬塞進(jìn)一個(gè) shader要么采樣次數(shù)爆炸要么精度不夠。第一個(gè) pass 是邊緣檢測(cè)。它把場(chǎng)景顏色紋理轉(zhuǎn)成亮度然后對(duì)每個(gè)像素檢查上下左右四個(gè)方向的亮度差超過(guò)閾值就標(biāo)記為邊緣。輸出是一張邊緣紋理每個(gè)通道存不同方向的邊緣信息。第二個(gè) pass 是形態(tài)計(jì)算也叫 pattern 計(jì)算。它讀取邊緣紋理在 3x3 鄰域內(nèi)統(tǒng)計(jì)邊緣的分布模式查一張預(yù)計(jì)算的模式表得出每個(gè)像素的混合權(quán)重。第三個(gè) pass 是鄰域混合用權(quán)重去采樣原始顏色紋理的周圍像素做加權(quán)平均。這三個(gè) pass 的輸入輸出關(guān)系很清晰顏色紋理 → 邊緣紋理 → 權(quán)重紋理 → 最終顏色。smaaDemo 里 OpenGL 和 Vulkan 的實(shí)現(xiàn)都遵循這個(gè)結(jié)構(gòu)區(qū)別只在于資源綁定和管線狀態(tài)的管理方式。2.2 邊緣檢測(cè)的閾值怎么定邊緣檢測(cè)的閾值直接決定哪些像素會(huì)被當(dāng)成邊緣。閾值太低平坦區(qū)域也會(huì)被誤判白白增加后續(xù)計(jì)算量閾值太高細(xì)邊緣被漏掉抗鋸齒效果打折。常見(jiàn)做法是用亮度差和局部對(duì)比度兩個(gè)條件聯(lián)合判斷。// 邊緣檢測(cè)片段著色器核心邏輯簡(jiǎn)化版 vec3 luma vec3(0.299, 0.587, 0.114); float lumaM dot(texture(texColor, uv).rgb, luma); float lumaL dot(texture(texColor, uv - vec2(texelSize.x, 0.0)).rgb, luma); float lumaR dot(texture(texColor, uv vec2(texelSize.x, 0.0)).rgb, luma); float lumaT dot(texture(texColor, uv - vec2(0.0, texelSize.y)).rgb, luma); float lumaB dot(texture(texColor, uv vec2(0.0, texelSize.y)).rgb, luma); float edgeH abs(lumaL lumaR - 2.0 * lumaM); // 水平方向二階差分 float edgeV abs(lumaT lumaB - 2.0 * lumaM); // 垂直方向二階差分 float edge max(edgeH, edgeV); // 閾值判斷低于閾值的直接歸零 float threshold 0.1; edge step(threshold, edge);這段代碼里texelSize是紋理尺寸的倒數(shù)用來(lái)把 UV 偏移換算成一個(gè)像素的距離。luma權(quán)重是標(biāo)準(zhǔn)的 Rec.709 亮度系數(shù)。閾值 0.1 是個(gè)經(jīng)驗(yàn)值實(shí)際項(xiàng)目里可以根據(jù)場(chǎng)景對(duì)比度調(diào)整——高動(dòng)態(tài)范圍場(chǎng)景可能需要 0.05 到 0.15 之間試。注意這里用的是二階差分而不是簡(jiǎn)單的一階差分因?yàn)槎A差分對(duì)細(xì)邊緣更敏感同時(shí)能抑制漸變區(qū)域的誤判。2.3 形態(tài)計(jì)算與模式表形態(tài)計(jì)算 pass 是 SMAA 最「玄學(xué)」的部分。它讀取邊緣紋理在 3x3 鄰域內(nèi)把每個(gè)像素的邊緣標(biāo)記拼成一個(gè) 8 位模式碼然后查一張預(yù)計(jì)算的查找表得到混合權(quán)重。這張表是離線生成的smaaDemo 里以紋理形式加載。// 形態(tài)計(jì)算片段著色器簡(jiǎn)化 // 從邊緣紋理讀取 3x3 鄰域 float e[9]; e[0] texture(texEdge, uv vec2(-texelSize.x, -texelSize.y)).r; e[1] texture(texEdge, uv vec2(0.0, -texelSize.y)).r; // ... 省略中間幾個(gè) e[8] texture(texEdge, uv vec2(texelSize.x, texelSize.y)).r; // 拼模式碼每個(gè)方向一個(gè) bit int pattern 0; pattern | (e[1] 0.5) ? 1 : 0; pattern | (e[3] 0.5) ? 2 : 0; pattern | (e[5] 0.5) ? 4 : 0; pattern | (e[7] 0.5) ? 8 : 0; // 查表得到混合權(quán)重 vec2 weights texture(texArea, vec2(float(pattern) / 16.0, 0.5)).rg;模式碼只用了上下左右四個(gè)方向的邊緣標(biāo)記共 16 種組合。查找表texArea存的是每種組合對(duì)應(yīng)的混合權(quán)重。這里有個(gè)容易翻車的點(diǎn)模式碼的 bit 順序必須和查找表生成時(shí)的順序完全一致否則權(quán)重全亂。smaaDemo 里 OpenGL 和 Vulkan 的紋理坐標(biāo)原點(diǎn)不同如果直接照搬查找表Vulkan 下可能需要翻轉(zhuǎn) Y 軸。2.4 鄰域混合的采樣策略最后一個(gè) pass 用權(quán)重去采樣原始顏色。權(quán)重是個(gè)二維向量分別控制水平和垂直方向的混合偏移量。采樣時(shí)不是簡(jiǎn)單取周圍四個(gè)像素而是根據(jù)權(quán)重做雙線性插值這樣能用更少的采樣點(diǎn)得到平滑結(jié)果。// 鄰域混合片段著色器簡(jiǎn)化 vec2 weights texture(texWeight, uv).rg; vec2 offset weights * texelSize; // 對(duì)角線方向采樣 vec3 color texture(texColor, uv).rgb * 0.5; color texture(texColor, uv vec2(-offset.x, -offset.y)).rgb * 0.125; color texture(texColor, uv vec2(offset.x, -offset.y)).rgb * 0.125; color texture(texColor, uv vec2(-offset.x, offset.y)).rgb * 0.125; color texture(texColor, uv vec2(offset.x, offset.y)).rgb * 0.125;權(quán)重越大采樣點(diǎn)離中心越遠(yuǎn)混合范圍越廣。這種對(duì)角線采樣模式是 SMAA 的經(jīng)典做法五個(gè)采樣點(diǎn)就能覆蓋大部分邊緣形態(tài)。如果場(chǎng)景里斜邊緣特別多可以改成八方向采樣但開(kāi)銷會(huì)上去。smaaDemo 默認(rèn)用的是五采樣版本在畫(huà)質(zhì)和性能之間取了個(gè)平衡。3. OpenGL 與 Vulkan 雙后端資源綁定和管線差異3.1 紋理和幀緩沖的創(chuàng)建OpenGL 和 Vulkan 在資源管理上的差異在 SMAA 這種多 pass 后處理里體現(xiàn)得特別明顯。OpenGL 用glGenTextures和glGenFramebuffers就能搞定Vulkan 則要手動(dòng)管理內(nèi)存分配、圖像布局轉(zhuǎn)換和管線屏障。// OpenGL 創(chuàng)建中間紋理邊緣紋理、權(quán)重紋理 GLuint CreateTexture(int w, int h, GLenum internalFormat) { GLuint tex; glGenTextures(1, tex); glBindTexture(GL_TEXTURE_2D, tex); glTexImage2D(GL_TEXTURE_2D, 0, internalFormat, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); return tex; }OpenGL 這邊沒(méi)什么好說(shuō)的注意GL_CLAMP_TO_EDGE必須設(shè)否則邊緣像素采樣會(huì)出問(wèn)題。Vulkan 那邊就麻煩多了每個(gè)中間紋理都要配一個(gè)VkImage、VkDeviceMemory、VkImageView還要在渲染前把圖像布局從VK_IMAGE_LAYOUT_UNDEFINED轉(zhuǎn)到VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL渲染完再轉(zhuǎn)到VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL。smaaDemo 里把這些封裝成了輔助函數(shù)但第一次看 Vulkan 代碼的人很容易被這一堆布局轉(zhuǎn)換繞暈。3.2 管線狀態(tài)對(duì)象的配置SMAA 的三個(gè) pass 各自需要不同的著色器和渲染狀態(tài)。OpenGL 用glUseProgram切換著色器用glEnable/glDisable控制混合和深度測(cè)試。Vulkan 則要預(yù)先創(chuàng)建三個(gè)VkPipeline每個(gè)管線綁定對(duì)應(yīng)的著色器模塊和管線狀態(tài)。// Vulkan 管線創(chuàng)建的關(guān)鍵配置以邊緣檢測(cè) pass 為例 VkPipelineColorBlendAttachmentState blendAttachment{}; blendAttachment.blendEnable VK_FALSE; // 后處理不需要混合 blendAttachment.colorWriteMask VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; VkPipelineDepthStencilStateCreateInfo depthStencil{}; depthStencil.depthTestEnable VK_FALSE; // 后處理關(guān)閉深度測(cè)試 depthStencil.depthWriteEnable VK_FALSE; depthStencil.stencilTestEnable VK_FALSE;后處理 pass 必須關(guān)掉深度測(cè)試和混合否則中間紋理會(huì)被污染。Vulkan 里這些狀態(tài)是管線創(chuàng)建時(shí)固定的不能像 OpenGL 那樣運(yùn)行時(shí)改。所以三個(gè) pass 要?jiǎng)?chuàng)建三個(gè)獨(dú)立的管線對(duì)象雖然代碼量大但運(yùn)行時(shí)切換開(kāi)銷比 OpenGL 小。3.3 全屏三角形的繪制SMAA 的每個(gè) pass 都是全屏操作不需要頂點(diǎn)緩沖直接用gl_VertexID或gl_VertexIndex生成全屏三角形。// 頂點(diǎn)著色器無(wú)頂點(diǎn)緩沖用頂點(diǎn)索引生成全屏三角形 #version 450 void main() { // 三個(gè)頂點(diǎn)覆蓋整個(gè)裁剪空間 vec2 pos vec2((gl_VertexIndex 1) 2, gl_VertexIndex 2); gl_Position vec4(pos * 2.0 - 1.0, 0.0, 1.0); }這個(gè)技巧在 OpenGL 和 Vulkan 里都通用gl_VertexIndex在 Vulkan 里對(duì)應(yīng)gl_VertexIndexOpenGL 里是gl_VertexID。三個(gè)頂點(diǎn)就能覆蓋整個(gè)屏幕比畫(huà)兩個(gè)三角形的四邊形省一個(gè)頂點(diǎn)。注意 Vulkan 的裁剪空間 Y 軸方向和 OpenGL 相反如果發(fā)現(xiàn)畫(huà)面上下顛倒在頂點(diǎn)著色器里把 Y 分量取反就行。4. 避坑與排查SMAA 集成時(shí)最容易翻車的五個(gè)點(diǎn)4.1 邊緣檢測(cè)閾值過(guò)高導(dǎo)致細(xì)邊緣丟失現(xiàn)象場(chǎng)景里細(xì)小的幾何邊緣比如欄桿、電線完全沒(méi)有抗鋸齒效果粗邊緣正常。原因邊緣檢測(cè)的閾值設(shè)得太高細(xì)邊緣的亮度差沒(méi)超過(guò)閾值被當(dāng)成平坦區(qū)域跳過(guò)了。解決把閾值從 0.1 降到 0.05 左右試同時(shí)觀察平坦區(qū)域有沒(méi)有出現(xiàn)噪點(diǎn)。如果噪點(diǎn)太多說(shuō)明閾值降過(guò)頭了需要在邊緣檢測(cè)里加一個(gè)局部對(duì)比度判斷只有亮度差和局部對(duì)比度同時(shí)超過(guò)閾值才標(biāo)記為邊緣。4.2 Vulkan 下畫(huà)面上下顛倒現(xiàn)象OpenGL 后端正常Vulkan 后端畫(huà)面上下翻轉(zhuǎn)。原因Vulkan 的裁剪空間 Y 軸指向和 OpenGL 相反全屏三角形的頂點(diǎn)位置沒(méi)做適配。解決在 Vulkan 的頂點(diǎn)著色器里把gl_Position.y取反或者在創(chuàng)建交換鏈時(shí)設(shè)置VkSurfaceCapabilitiesKHR的currentTransform為VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR并手動(dòng)處理。smaaDemo 里用的是頂點(diǎn)著色器取反的方案改動(dòng)最小。4.3 中間紋理格式選錯(cuò)導(dǎo)致精度不足現(xiàn)象抗鋸齒后邊緣出現(xiàn)明顯的色帶或階梯畫(huà)質(zhì)比預(yù)期差很多。原因邊緣紋理和權(quán)重紋理用了GL_RGB或VK_FORMAT_R8G8B8_UNORM精度不夠權(quán)重計(jì)算時(shí)量化誤差被放大。解決邊緣紋理用GL_RG8或VK_FORMAT_R8G8_UNORM就夠權(quán)重紋理建議用GL_RGBA8或VK_FORMAT_R8G8B8A8_UNORM。如果場(chǎng)景動(dòng)態(tài)范圍很大可以考慮用GL_RGBA16F但帶寬開(kāi)銷會(huì)上去。smaaDemo 默認(rèn)用的是 8 位格式大部分場(chǎng)景夠用。4.4 查找表紋理的采樣坐標(biāo)算錯(cuò)現(xiàn)象抗鋸齒效果完全不對(duì)邊緣出現(xiàn)奇怪的色塊或條紋。原因模式碼到查找表 UV 的映射算錯(cuò)了比如把 16 種模式映射到了 0 到 1 的 UV 范圍但沒(méi)考慮紋理過(guò)濾。解決查找表紋理的過(guò)濾方式必須設(shè)為GL_NEAREST或VK_FILTER_NEAREST因?yàn)槟J酱a是離散的線性過(guò)濾會(huì)混入相鄰模式的權(quán)重。UV 計(jì)算時(shí)用(pattern 0.5) / 16.0而不是pattern / 16.0確保采樣點(diǎn)落在紋素中心。4.5 多 pass 之間的同步問(wèn)題現(xiàn)象Vulkan 后端偶爾出現(xiàn)畫(huà)面撕裂或中間紋理內(nèi)容錯(cuò)亂OpenGL 后端正常。原因Vulkan 的渲染 pass 之間沒(méi)有正確設(shè)置管線屏障或圖像布局轉(zhuǎn)換GPU 在寫(xiě)邊緣紋理的同時(shí)下一個(gè) pass 已經(jīng)在讀它了。解決每個(gè) pass 結(jié)束后加vkCmdPipelineBarrier把圖像布局從COLOR_ATTACHMENT_OPTIMAL轉(zhuǎn)到SHADER_READ_ONLY_OPTIMAL并設(shè)置srcStageMask為COLOR_ATTACHMENT_OUTPUT、dstStageMask為FRAGMENT_SHADER。OpenGL 驅(qū)動(dòng)會(huì)自動(dòng)處理這些同步所以 OpenGL 后端不容易出這個(gè)問(wèn)題但 Vulkan 必須手動(dòng)管理。5. 進(jìn)階技巧把 SMAA 塞進(jìn)延遲渲染管線5.1 延遲渲染下的邊緣檢測(cè)調(diào)整延遲渲染管線里顏色緩沖存的是 G-Buffer 的 albedo不是最終光照結(jié)果。如果直接對(duì) albedo 做邊緣檢測(cè)光照變化劇烈的區(qū)域會(huì)被誤判為邊緣。常見(jiàn)做法是用深度和法線做邊緣檢測(cè)或者等光照 pass 完成后再對(duì)最終顏色做 SMAA。smaaDemo 默認(rèn)是對(duì)最終顏色做后處理這個(gè)順序在延遲渲染里也適用。但如果你的管線有多個(gè)光照 passSMAA 應(yīng)該放在所有光照合成之后、色調(diào)映射之前。色調(diào)映射會(huì)壓縮動(dòng)態(tài)范圍改變亮度差如果在色調(diào)映射之后做邊緣檢測(cè)閾值需要重新調(diào)。5.2 時(shí)間性抗鋸齒與 SMAA 的配合如果項(xiàng)目里已經(jīng)用了 TAA時(shí)間性抗鋸齒SMAA 可以作為補(bǔ)充處理 TAA 搞不定的高頻邊緣。做法是把 SMAA 放在 TAA 之后用 TAA 的歷史累積結(jié)果作為 SMAA 的輸入。這樣 TAA 負(fù)責(zé)靜態(tài)場(chǎng)景的收斂SMAA 負(fù)責(zé)動(dòng)態(tài)邊緣的即時(shí)修正。// 渲染順序幾何 → 光照 → TAA → SMAA → 色調(diào)映射 → UI // 注意 SMAA 的輸入紋理必須是 TAA 的輸出不能是原始顏色 RenderPass geometryPass(); RenderPass lightingPass(); RenderPass taaPass(); // 時(shí)間性抗鋸齒 RenderPass smaaEdgePass(); // SMAA 邊緣檢測(cè) RenderPass smaaWeightPass(); // SMAA 形態(tài)計(jì)算 RenderPass smaaBlendPass(); // SMAA 鄰域混合 RenderPass tonemapPass(); RenderPass uiPass();這個(gè)順序下SMAA 的閾值可以比單獨(dú)使用時(shí)稍微高一點(diǎn)因?yàn)?TAA 已經(jīng)處理掉了一部分低頻鋸齒。我一般會(huì)把閾值從 0.1 提到 0.12 到 0.15 之間具體看場(chǎng)景。5.3 性能驗(yàn)證與參數(shù)調(diào)優(yōu)SMAA 的開(kāi)銷主要在三個(gè) pass 的紋理采樣上。邊緣檢測(cè)每個(gè)像素采樣 5 次形態(tài)計(jì)算采樣 9 次鄰域混合采樣 5 次總共 19 次紋理采樣。在 1080p 分辨率下這個(gè)開(kāi)銷對(duì)現(xiàn)代 GPU 來(lái)說(shuō)不算大但在移動(dòng)端或者集成顯卡上需要關(guān)注。驗(yàn)證方法很簡(jiǎn)單用 GPU 計(jì)時(shí)器分別測(cè)三個(gè) pass 的耗時(shí)然后逐個(gè)關(guān)掉看幀率變化。如果邊緣檢測(cè)耗時(shí)占比過(guò)高說(shuō)明閾值太低導(dǎo)致邊緣像素太多如果鄰域混合耗時(shí)高說(shuō)明采樣模式可以簡(jiǎn)化。參數(shù)調(diào)優(yōu)的優(yōu)先級(jí)先調(diào)邊緣檢測(cè)閾值再調(diào)查找表精度最后調(diào)采樣模式。閾值影響最大查找表精度影響畫(huà)質(zhì)采樣模式影響性能。我一般會(huì)準(zhǔn)備三套參數(shù)高質(zhì)量閾值 0.05、八方向采樣、平衡閾值 0.1、五方向采樣、性能閾值 0.15、四方向采樣根據(jù)目標(biāo)平臺(tái)切換。從那以后我每次集成 SMAA 都強(qiáng)制走一遍「先關(guān)掉所有 pass 看基準(zhǔn)幀率再逐個(gè)打開(kāi)看增量」的流程避免把性能問(wèn)題歸咎于錯(cuò)誤的 pass。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取