戰(zhàn):?jiǎn)螐垐D片渲染出3D旋轉(zhuǎn)圓環(huán)的完整指南)
去年我在一個(gè)技術(shù)社群里看到有人發(fā)了一張截圖一棟建筑照片里的圓環(huán)裝飾竟然像真實(shí)物體一樣緩緩轉(zhuǎn)動(dòng)透視、陰影、遮擋關(guān)系全都對(duì)得上。評(píng)論區(qū)吵成一片有人說是預(yù)渲染視頻有人說是3D建模軟件做的。結(jié)果作者只回了一句一個(gè)Shader文件而已。我當(dāng)時(shí)的反應(yīng)和大多數(shù)人一樣——不可能吧。但自己動(dòng)手復(fù)現(xiàn)了一遍之后我只想說確實(shí)還有高手。而且這套思路遠(yuǎn)不止是炫技它涉及的核心技術(shù)——用UV坐標(biāo)在片元著色器里做3D數(shù)學(xué)運(yùn)算幾乎能遷移到所有實(shí)時(shí)渲染項(xiàng)目里。無論你在玩Unity Shader、Three.js還是裸寫WebGL這套邏輯都能直接用。這篇文章我會(huì)從數(shù)學(xué)原理講到完整代碼實(shí)現(xiàn)再到參數(shù)調(diào)節(jié)和避坑經(jīng)驗(yàn)。不依賴外部貼圖不依賴3D模型文件一張普通圖片加上一段片元著色器代碼就能讓畫面里的圓環(huán)“立起來”并旋轉(zhuǎn)。就算你沒有圖形學(xué)基礎(chǔ)跟著思路走一遍也能理解每一行代碼在干什么。1. 渲染的本質(zhì)為什么一張圖片能產(chǎn)生3D錯(cuò)覺先聊一個(gè)最基礎(chǔ)但最關(guān)鍵的問題我們?cè)谄聊簧峡吹降乃^“3D效果”本質(zhì)到底是什么答案其實(shí)是四個(gè)字——深度暗示。人的視覺系統(tǒng)判斷物體遠(yuǎn)近靠的是遮擋關(guān)系、光影明暗、透視變形、運(yùn)動(dòng)視差這幾種線索。當(dāng)一幅靜態(tài)畫面同時(shí)具備這些線索時(shí)大腦就會(huì)自動(dòng)把它解讀為“立體的”哪怕它只是屏幕上的二維像素陣列。Shadertoy、Unity Shader、Three.js里的Shader之所以能輕松騙過眼睛就是因?yàn)樗鼈兛梢栽谄麟A段對(duì)每個(gè)像素完成一套完整的“3D場(chǎng)景求交”運(yùn)算。具體來說每個(gè)像素的uv坐標(biāo)會(huì)被當(dāng)成一道射線的屏幕投影位置然后我們用數(shù)學(xué)方式描述一個(gè)圓環(huán)表面去計(jì)算這道射線是否穿過圓環(huán)、命中的是哪一點(diǎn)、那一點(diǎn)的法線朝向如何。有了命中點(diǎn)和法線光照計(jì)算就順理成章了。聽上去很繞但你可以這樣理解**Shader就是個(gè)每像素執(zhí)行一次的微型相機(jī)uv坐標(biāo)是它的取景網(wǎng)格模型不是網(wǎng)格而是數(shù)學(xué)公式。**我們不需要真正構(gòu)建幾十萬個(gè)三角形來表示圓環(huán)因?yàn)閳A環(huán)本身就是一個(gè)可以用方程精確描述的曲面對(duì)象。對(duì)它做個(gè)光線求交就能在像素級(jí)還原出完整輪廓。這套方案最大的優(yōu)勢(shì)在于它跟分辨率無關(guān)。無論窗口縮到多小或放到多大圓環(huán)邊緣始終銳利因?yàn)樗怯媒馕鰩缀嗡惴ㄖ鹣袼厮愠鰜淼亩皇琴N圖放大后的插值模糊。這也是為什么很多知名Shader作品放大看依然質(zhì)感驚人。2. 坐標(biāo)系變換從圖片平面到3D世界的視角魔法要讓圓環(huán)在圖片里“立起來”并旋轉(zhuǎn)第一步不是寫光照而是先解決坐標(biāo)系的轉(zhuǎn)換問題。我們拿到手的每一張貼圖uv坐標(biāo)范圍都是0到1通常左下角是(0,0)右上角是(1,1)。這是一個(gè)二維平面坐標(biāo)。但要在屏幕空間里表現(xiàn)3D物體我們需要一個(gè)“虛擬相機(jī)”和一個(gè)“世界坐標(biāo)系”。我用的是很經(jīng)典的做法**把uv坐標(biāo)從正中心向外拉伸讓x軸和y軸的范圍都落在-1到1之間然后根據(jù)寬高比修正坐標(biāo)變形。**只有先做這一步圓環(huán)投影到屏幕上才不會(huì)變成橢圓。具體代碼如下vec2 uv fragCoord.xy / iResolution.xy; // 歸一化到0~1 uv uv * 2.0 - 1.0; // 移到-1~1 uv.x * iResolution.x / iResolution.y; // 修正寬高比為什么寬高比修正這么重要因?yàn)槠聊坏奈锢沓叽缤菣M向?qū)?、縱向短。如果不做修正同樣一段垂直于x軸的長(zhǎng)度和平行于y軸的長(zhǎng)度在屏幕上占據(jù)的像素?cái)?shù)不一致畫面里的圓形就會(huì)橫向拉伸成橢圓。做一次乘以寬高比的操作就能讓x軸的單位長(zhǎng)度和y軸單位長(zhǎng)度在屏幕上保持一致。修正之后一個(gè)3D坐標(biāo)系的雛形就出來了。我們可以把x軸看成水平方向y軸看成垂直方向然后額外引入一個(gè)z軸表示深度。這時(shí)候?qū)嶋H渲染流程就變成了先把uv坐標(biāo)當(dāng)成屏幕平面的x和y幀緩沖區(qū)的每個(gè)像素負(fù)責(zé)發(fā)出射線并尋找與圓環(huán)的交點(diǎn)。如果命中就計(jì)算顏色沒命中就保留原圖顏色。這一步是整個(gè)實(shí)現(xiàn)的地基。地基打不穩(wěn)后面所有光照都是歪的。很多新手第一次做類似效果圓環(huán)看起來總是一副“貼紙感”十有八九就是漏了寬高比修正。3. 圓環(huán)表面重建SDF距離場(chǎng)與法線計(jì)算有了坐標(biāo)空間下一步就是怎么描述圓環(huán)這個(gè)幾何體。這里我推薦用SDF帶符號(hào)的距離場(chǎng)方案因?yàn)樗拇a簡(jiǎn)潔、理解直觀而且法線計(jì)算可以直接用數(shù)值微分來近似省去一堆手推偏導(dǎo)的麻煩。圓環(huán)的SDF描述不復(fù)雜。一個(gè)大圓在xz平面上半徑為R管道的橫截面半徑為r。任意一點(diǎn)p到圓環(huán)表面的大致距離可以這樣算float sdTorus(vec3 p, float R, float r) { vec2 q vec2(length(p.xz) - R, p.y); return length(q) - r; }這里有兩點(diǎn)需要解釋清楚。第一為什么要用length(p.xz) - R因?yàn)閳A環(huán)的中心線在xz平面上是一個(gè)圓點(diǎn)p在這個(gè)平面上的投影點(diǎn)到圓心的距離減去大圓半徑R得到的是它相對(duì)于“中心圈”的徑向偏移。第二為什么要把這個(gè)徑向偏移和y分量放在一起再求一次長(zhǎng)度、減去半徑r因?yàn)閳A環(huán)可以看成一根繞著大圓彎成圈的管道管道截面是半徑為r的小圓。上面兩步合起來正好是在管道的極坐標(biāo)系里測(cè)量點(diǎn)到截面中心的距離。如果你還沒太跟上可以從一個(gè)更生活化的角度想**想象你拿一根軟管繞著一個(gè)水桶彎了一圈。管子的中心線是大圓管子的粗細(xì)是r。SDF函數(shù)要算的就是“空間中某個(gè)點(diǎn)離這根軟管表面有多遠(yuǎn)”。**上面那段代碼就是把這句話翻譯成了數(shù)學(xué)語言。有了SDF距離值每個(gè)像素就可以判斷自己是否落在圓環(huán)表面——距離值的絕對(duì)值接近零說明射線剛好擦過管壁。但要渲染出立體感光知道“在不在表面”還遠(yuǎn)遠(yuǎn)不夠我們還需要知道表面是朝哪個(gè)方向傾斜的也就是法線。法線計(jì)算我直接用了相鄰采樣差分法vec3 computeNormal(vec3 p) { float h 0.001; vec2 k vec2(1.0, -1.0); return normalize( k.xyy * sdTorus(p k.xyy * h, R, r) k.yyx * sdTorus(p k.yyx * h, R, r) k.yxy * sdTorus(p k.yxy * h, R, r) k.xxx * sdTorus(p k.xxx * h, R, r) ); }這段代碼看起來有點(diǎn)“魔法”但它背后的思想很樸素——在點(diǎn)p周圍取幾個(gè)微小的偏移位置分別計(jì)算它們的SDF值然后用差分近似出每個(gè)方向的導(dǎo)數(shù)向量最后歸一化就是法線。h取0.001左右比較合適太大法線會(huì)變平滑太小則容易出現(xiàn)數(shù)值抖動(dòng)。如果追求極致性能或者你在移動(dòng)端運(yùn)行也可以用解析法直接推導(dǎo)圓環(huán)法線公式省去4次SDF采樣。但實(shí)際工程里數(shù)值差分法的開銷極低而且可讀性更高我的建議是先用它把效果跑通后續(xù)再考慮優(yōu)化。4. 讓圓環(huán)動(dòng)起來旋轉(zhuǎn)矩陣與交互動(dòng)畫設(shè)計(jì)幾何體構(gòu)造完成之后真正“轉(zhuǎn)起來”的部分來了。圓環(huán)要?jiǎng)颖举|(zhì)上就是我們每一幀重新計(jì)算射線與圓環(huán)的交點(diǎn)時(shí)用的坐標(biāo)系在不斷變化。最簡(jiǎn)單也最穩(wěn)妥的方案是在做SDF求交之前先把射線的起點(diǎn)和方向做反向旋轉(zhuǎn)。意思是圓環(huán)保持不動(dòng)但是我們讓相機(jī)繞場(chǎng)景旋轉(zhuǎn)效果看起來就是圓環(huán)在相對(duì)旋轉(zhuǎn)。這在渲染領(lǐng)域是一個(gè)很常見的技巧可以避免頻繁修改SDF函數(shù)里的幾何參數(shù)。為什么我推薦旋轉(zhuǎn)相機(jī)而不是直接旋轉(zhuǎn)模型原因有二。第一代碼改動(dòng)小——只需要把rayOrigin和rayDir乘一個(gè)旋轉(zhuǎn)矩陣后面所有求交和法線計(jì)算完全不用動(dòng)。第二旋轉(zhuǎn)相機(jī)可以同時(shí)帶動(dòng)高光和陰影變化視覺上更自然因?yàn)楣庹辗较蚱鋵?shí)沒變變的是觀察角度。旋轉(zhuǎn)矩陣怎么構(gòu)造用旋轉(zhuǎn)角度angle動(dòng)態(tài)生成mat2 rot2D(float a) { float c cos(a); float s sin(a); return mat2(c, -s, s, c); } // 在更新rayDir時(shí)調(diào)用 rayDir.xz rot2D(iTime * 0.5) * rayDir.xz;這里iTime * 0.5是每秒鐘旋轉(zhuǎn)約0.5弧度的緩慢旋轉(zhuǎn)數(shù)值越大轉(zhuǎn)得越快實(shí)際調(diào)試時(shí)可以按喜好調(diào)整。同時(shí)我還喜歡在場(chǎng)景里疊加一圈微弱的上下浮動(dòng)讓圓環(huán)看起來更“懸浮”邏輯是在z軸上再加一個(gè)正弦擾動(dòng)vec3 torusCenter vec3(0.0, sin(iTime * 0.8) * 0.15, 0.0); p - torusCenter;這種做法比旋轉(zhuǎn)坐標(biāo)系的視覺效果更豐富但要注意SDF求交時(shí)的輸入點(diǎn)p必須跟著平移否則圓環(huán)內(nèi)部會(huì)錯(cuò)位畫面出現(xiàn)割裂感。視線與圓環(huán)求交的過程我采用了固定步長(zhǎng)的光線步進(jìn)ray marching因?yàn)镾DF給出了射線到圓環(huán)表面的估計(jì)距離允許我們安全地推進(jìn)射線。每次步進(jìn)距離取該點(diǎn)的SDF絕對(duì)值如果步進(jìn)超過一定次數(shù)或距離小于某個(gè)閾值就認(rèn)為命中表面或者脫靶。固定步數(shù)設(shè)為64比較平衡——太少容易出現(xiàn)斷斷續(xù)續(xù)的虛影太多則低端手機(jī)上會(huì)有明顯壓力。5. 光影與材質(zhì)把圓環(huán)從“線框”變成“實(shí)體”幾何命中之后圓環(huán)還只是一個(gè)帶輪廓的形狀。真正讓它和背景圖片水乳交融、產(chǎn)生“它就在照片里”的錯(cuò)覺靠的是光照模型。我用的是最經(jīng)典也最穩(wěn)妥的方案——蘭伯特漫反射加Blinn-Phong高光再配合一點(diǎn)環(huán)境光。為什么不用PBR那套復(fù)雜的金屬流程因?yàn)樵谶@個(gè)場(chǎng)景里圓環(huán)是疊加在真實(shí)照片上的裝飾物體而不是獨(dú)立的全3D場(chǎng)景。如果材質(zhì)寫得太復(fù)雜反而會(huì)和背景圖的質(zhì)感打架看起來像兩塊東西硬拼在一起。漫反射部分的公式本質(zhì)上就是在做法線點(diǎn)乘光照方向的操作。光照方向我固定為normalize(vec3(0.8, 1.0, 0.6))這是一個(gè)從斜上方打下來的光能同時(shí)照亮圓環(huán)的上部和側(cè)面產(chǎn)生比較自然的體積感。高光部分用的Blinn-Phong半程向量法在性能和效果之間取了一個(gè)很好的平衡點(diǎn)vec3 lightDir normalize(vec3(0.8, 1.0, 0.6)); float diff max(dot(normal, lightDir), 0.0); vec3 viewDir normalize(cameraPos - hitPoint); vec3 halfDir normalize(lightDir viewDir); float spec pow(max(dot(normal, halfDir), 0.0), 64.0); vec3 baseColor vec3(0.9, 0.6, 0.2); vec3 color baseColor * diff * 0.8 vec3(1.0) * spec * 0.6 ambientColor;這里有兩個(gè)值得說明的細(xì)節(jié)。第一個(gè)是pow(..., 64.0)這個(gè)指數(shù)控制高光的銳度。64是一個(gè)比較高的值適合金屬或塑料質(zhì)感如果你想要啞光材質(zhì)改成8到16即可。第二個(gè)是環(huán)境光常數(shù)ambientColor——它模擬的是場(chǎng)景中無處不在的反射光防止背光面死黑。我取vec3(0.15, 0.15, 0.2)帶一點(diǎn)冷色讓圓環(huán)的顏色更“坐得住”。材質(zhì)顏色我用了暖橙色因?yàn)樗诖蠖鄶?shù)街景、建筑照片里都比較跳但又不突兀。你可以把baseColor換成任意顏色甚至用時(shí)間函數(shù)做成呼吸變色vec3 baseColor 0.5 0.5 * cos(iTime vec3(0.0, 2.0, 4.0));另外千萬別忘了給圓環(huán)加一條細(xì)細(xì)的陰影帶不然它看起來會(huì)像浮在圖片上的貼紙。陰影可以通過二次步進(jìn)來算——從命中點(diǎn)沿光源方向再做一次SDF步進(jìn)如果碰到了別的東西就說明當(dāng)前點(diǎn)處于陰影中。因?yàn)槭菃挝矬w場(chǎng)景這個(gè)陰影主要是圓環(huán)自遮擋代碼并不復(fù)雜。6. 融合與合成背景保留和邊緣抗鋸齒處理圓環(huán)渲染完畢最后一步是把圓環(huán)合回原圖。這里的關(guān)鍵技術(shù)點(diǎn)有兩個(gè)一個(gè)是合成方式另一個(gè)是抗鋸齒。合成方式上我直接用mix函數(shù)按透明度疊加。只有命中圓環(huán)的像素才會(huì)被替換成圓環(huán)顏色沒命中的完全保留原圖vec3 finalColor mix(background, shadedColor, hitMask);但是問題來了圓環(huán)邊緣的像素往往會(huì)因?yàn)椴竭M(jìn)次數(shù)不夠或閾值判斷過于機(jī)械出現(xiàn)明顯的鋸齒和抖動(dòng)。一幀一幀看起來還行但一旦旋轉(zhuǎn)起來那些鋸齒的邊緣就會(huì)開始閃爍。解決這個(gè)問題的辦法是抗鋸齒。最劃算的方案是平滑邊緣——把命中判定的二元邏輯改成基于距離值的平滑過渡。說白了就是讓距離絕對(duì)值的倒數(shù)去控制透明度。smoothstep天然適合干這個(gè)活float hitMask 1.0 - smoothstep(0.0, 0.003, abs(dist));這段代碼的意思是如果距離值小于0.003認(rèn)為命中的透明度接近1如果距離在0.003到0之間變化透明度也隨之漸變。這樣圓環(huán)邊緣就不再是“在/不在”的硬切換而是像被砂紙打磨過一樣過渡非常柔和。如果再進(jìn)一步還可以開MSAA或者超采樣。但這些方案在Shader里實(shí)現(xiàn)復(fù)雜、開銷大我的建議是先用smoothstep方案跑通然后放大檢查邊緣如果還有鋸齒再考慮用FXAA這一類后處理方案。實(shí)際項(xiàng)目中這個(gè)smoothstep方案就能干掉九成以上的邊緣瑕疵。7. 參數(shù)調(diào)節(jié)與常見坑我在復(fù)現(xiàn)過程中踩過的雷最后這部分想分享一些非常實(shí)戰(zhàn)的內(nèi)容——參數(shù)怎么調(diào)哪些地方容易踩坑以及我在調(diào)試過程中總結(jié)的幾條經(jīng)驗(yàn)。7.1 圓環(huán)大小與圖片內(nèi)容的比例很多人拿到代碼第一反應(yīng)就是把圓環(huán)半徑調(diào)大結(jié)果一跑發(fā)現(xiàn)圓環(huán)占滿了整個(gè)屏幕反而沒有美感。我的經(jīng)驗(yàn)是圓環(huán)的視覺效果和最外層半徑R、管道半徑r的比例關(guān)系極大。R和r的比值在2到4之間比較美觀太接近1的話圓環(huán)看起來會(huì)像一個(gè)敦實(shí)的甜甜圈拉大到5以上則細(xì)得像一根金屬絲。先固定R再單獨(dú)調(diào)r觀察圓環(huán)截面的變化比同時(shí)調(diào)兩個(gè)參數(shù)更容易找到手感。7.2 鏡頭距離與透視感想突出“3D感”鏡頭不能離圓環(huán)太遠(yuǎn)。太遠(yuǎn)了旋轉(zhuǎn)的透視變化會(huì)非常微弱看起來像平面圖形在轉(zhuǎn)。我在很多項(xiàng)目里驗(yàn)證過的經(jīng)驗(yàn)是相機(jī)位置z值在3.5到4.5之間透視感最理想。小于3圓環(huán)會(huì)沖出屏幕邊緣大于5旋轉(zhuǎn)帶來的形變幾乎看不出來了。7.3 步進(jìn)次數(shù)與性能光線步進(jìn)的次數(shù)直接決定性能和畫質(zhì)的平衡。國(guó)內(nèi)很多低端手機(jī)的GPU對(duì)循環(huán)次數(shù)很敏感。64次是一個(gè)安全基準(zhǔn)但如果你用的圖片分辨率特別大比如4K建議把步進(jìn)次數(shù)降到48同時(shí)把命中閾值適當(dāng)放寬畫質(zhì)差距肉眼幾乎看不出但幀率會(huì)明顯改善。7.4 高頻閃爍問題如果圓環(huán)在旋轉(zhuǎn)過程中邊緣出現(xiàn)高頻閃爍或碎塊通常是因?yàn)榉ň€計(jì)算采用的h值太小導(dǎo)致差分結(jié)果被浮點(diǎn)數(shù)精度影響。先把h從0.001改成0.01試試大多數(shù)情況能解決。如果還有問題檢查一下是否需要把uv坐標(biāo)乘以一個(gè)較大的比例因子讓SDF運(yùn)算發(fā)生在數(shù)值范圍比較友好的區(qū)間。7.5 背景圖片明暗對(duì)圓環(huán)觀感的影響背景圖片本身如果整體偏暗圓環(huán)的暖橙色會(huì)格外凸顯像貼紙一樣懸浮。解決思路是給圓環(huán)加一點(diǎn)基于背景亮度的環(huán)境光反饋。一個(gè)很取巧的做法是先對(duì)背景圖做一次模糊降采樣采到圓環(huán)所在區(qū)域的近似亮度值然后把它乘進(jìn)環(huán)境光里。這樣圖片暗的地方圓環(huán)也會(huì)自然變暗融合度立刻提升。8. 應(yīng)用場(chǎng)景延伸這套Shader技術(shù)能用到哪整個(gè)實(shí)現(xiàn)從頭到尾跑通之后你其實(shí)已經(jīng)掌握了一套通用的、可遷移的實(shí)時(shí)渲染能力。很多人問這東西除了炫技還能干嘛我的答案是有不少實(shí)際用途。如果你在做游戲UI圓環(huán)進(jìn)度條、技能冷卻轉(zhuǎn)盤、加載動(dòng)畫這套思路可以讓你繞過序列幀和模型烘焙直接用一張貼圖和一個(gè)Shader做出來資源和性能雙重節(jié)省。如果你在玩Three.js完全可以在ShaderMaterial里套用同樣的算法疊加到3D場(chǎng)景中的任何平面上做動(dòng)態(tài)傳送門、魔法陣、科幻標(biāo)記。甚至你可以在數(shù)據(jù)可視化的大屏項(xiàng)目里給某個(gè)指標(biāo)數(shù)字加一個(gè)旋轉(zhuǎn)的裝飾圓環(huán)視覺上立刻有了一層高級(jí)感。我之前還嘗試過一個(gè)騷操作把SDF圓環(huán)和噪聲函數(shù)結(jié)合讓圓環(huán)的半徑隨時(shí)間產(chǎn)生波浪狀變形效果就像一個(gè)緩緩膨脹收縮的能量環(huán)。實(shí)現(xiàn)方式不外乎是在SDF距離值上疊加一個(gè)sin擾動(dòng)但視覺沖擊力和純靜態(tài)圓環(huán)完全不是一個(gè)量級(jí)。float sdTorus(vec3 p, float R, float r) { float ripple sin(p.x * 3.0 iTime) * 0.03; vec2 q vec2(length(p.xz) - R ripple, p.y); return length(q) - r * 0.5; }這行代碼雖然簡(jiǎn)單但它體現(xiàn)了SDF方案的擴(kuò)展性有多強(qiáng)——你只需要改一個(gè)距離計(jì)算的細(xì)節(jié)就能改變整個(gè)幾何體的形態(tài)。回到開頭那句話讓圖片里的3D圓環(huán)轉(zhuǎn)起來確實(shí)還有高手但高手不是靠什么黑魔法而是靠對(duì)坐標(biāo)空間、SDF距離場(chǎng)、光照模型這些底層層面的理解。這些知識(shí)一點(diǎn)就透但一旦掌握你就能在任何實(shí)時(shí)渲染環(huán)境里玩出花來。希望這篇拆解能幫你在自己的項(xiàng)目里也試一試這種“用代碼憑空造物”的感覺。