
去年我在一个技术社群里看到有人发了一张截图一栋建筑照片里的圆环装饰竟然像真实物体一样缓缓转动透视、阴影、遮挡关系全都对得上。评论区吵成一片有人说是预渲染视频有人说是3D建模软件做的。结果作者只回了一句一个Shader文件而已。我当时的反应和大多数人一样——不可能吧。但自己动手复现了一遍之后我只想说确实还有高手。而且这套思路远不止是炫技它涉及的核心技术——用UV坐标在片元着色器里做3D数学运算几乎能迁移到所有实时渲染项目里。无论你在玩Unity Shader、Three.js还是裸写WebGL这套逻辑都能直接用。这篇文章我会从数学原理讲到完整代码实现再到参数调节和避坑经验。不依赖外部贴图不依赖3D模型文件一张普通图片加上一段片元着色器代码就能让画面里的圆环“立起来”并旋转。就算你没有图形学基础跟着思路走一遍也能理解每一行代码在干什么。1. 渲染的本质为什么一张图片能产生3D错觉先聊一个最基础但最关键的问题我们在屏幕上看到的所谓“3D效果”本质到底是什么答案其实是四个字——深度暗示。人的视觉系统判断物体远近靠的是遮挡关系、光影明暗、透视变形、运动视差这几种线索。当一幅静态画面同时具备这些线索时大脑就会自动把它解读为“立体的”哪怕它只是屏幕上的二维像素阵列。Shadertoy、Unity Shader、Three.js里的Shader之所以能轻松骗过眼睛就是因为它们可以在片元着色器阶段对每个像素完成一套完整的“3D场景求交”运算。具体来说每个像素的uv坐标会被当成一道射线的屏幕投影位置然后我们用数学方式描述一个圆环表面去计算这道射线是否穿过圆环、命中的是哪一点、那一点的法线朝向如何。有了命中点和法线光照计算就顺理成章了。听上去很绕但你可以这样理解**Shader就是个每像素执行一次的微型相机uv坐标是它的取景网格模型不是网格而是数学公式。**我们不需要真正构建几十万个三角形来表示圆环因为圆环本身就是一个可以用方程精确描述的曲面对象。对它做个光线求交就能在像素级还原出完整轮廓。这套方案最大的优势在于它跟分辨率无关。无论窗口缩到多小或放到多大圆环边缘始终锐利因为它是用解析几何算法逐像素算出来的而不是贴图放大后的插值模糊。这也是为什么很多知名Shader作品放大看依然质感惊人。2. 坐标系变换从图片平面到3D世界的视角魔法要让圆环在图片里“立起来”并旋转第一步不是写光照而是先解决坐标系的转换问题。我们拿到手的每一张贴图uv坐标范围都是0到1通常左下角是(0,0)右上角是(1,1)。这是一个二维平面坐标。但要在屏幕空间里表现3D物体我们需要一个“虚拟相机”和一个“世界坐标系”。我用的是很经典的做法**把uv坐标从正中心向外拉伸让x轴和y轴的范围都落在-1到1之间然后根据宽高比修正坐标变形。**只有先做这一步圆环投影到屏幕上才不会变成椭圆。具体代码如下vec2 uv fragCoord.xy / iResolution.xy; // 归一化到0~1 uv uv * 2.0 - 1.0; // 移到-1~1 uv.x * iResolution.x / iResolution.y; // 修正宽高比为什么宽高比修正这么重要因为屏幕的物理尺寸往往是横向宽、纵向短。如果不做修正同样一段垂直于x轴的长度和平行于y轴的长度在屏幕上占据的像素数不一致画面里的圆形就会横向拉伸成椭圆。做一次乘以宽高比的操作就能让x轴的单位长度和y轴单位长度在屏幕上保持一致。修正之后一个3D坐标系的雏形就出来了。我们可以把x轴看成水平方向y轴看成垂直方向然后额外引入一个z轴表示深度。这时候实际渲染流程就变成了先把uv坐标当成屏幕平面的x和y帧缓冲区的每个像素负责发出射线并寻找与圆环的交点。如果命中就计算颜色没命中就保留原图颜色。这一步是整个实现的地基。地基打不稳后面所有光照都是歪的。很多新手第一次做类似效果圆环看起来总是一副“贴纸感”十有八九就是漏了宽高比修正。3. 圆环表面重建SDF距离场与法线计算有了坐标空间下一步就是怎么描述圆环这个几何体。这里我推荐用SDF带符号的距离场方案因为它的代码简洁、理解直观而且法线计算可以直接用数值微分来近似省去一堆手推偏导的麻烦。圆环的SDF描述不复杂。一个大圆在xz平面上半径为R管道的横截面半径为r。任意一点p到圆环表面的大致距离可以这样算float sdTorus(vec3 p, float R, float r) { vec2 q vec2(length(p.xz) - R, p.y); return length(q) - r; }这里有两点需要解释清楚。第一为什么要用length(p.xz) - R因为圆环的中心线在xz平面上是一个圆点p在这个平面上的投影点到圆心的距离减去大圆半径R得到的是它相对于“中心圈”的径向偏移。第二为什么要把这个径向偏移和y分量放在一起再求一次长度、减去半径r因为圆环可以看成一根绕着大圆弯成圈的管道管道截面是半径为r的小圆。上面两步合起来正好是在管道的极坐标系里测量点到截面中心的距离。如果你还没太跟上可以从一个更生活化的角度想**想象你拿一根软管绕着一个水桶弯了一圈。管子的中心线是大圆管子的粗细是r。SDF函数要算的就是“空间中某个点离这根软管表面有多远”。**上面那段代码就是把这句话翻译成了数学语言。有了SDF距离值每个像素就可以判断自己是否落在圆环表面——距离值的绝对值接近零说明射线刚好擦过管壁。但要渲染出立体感光知道“在不在表面”还远远不够我们还需要知道表面是朝哪个方向倾斜的也就是法线。法线计算我直接用了相邻采样差分法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) ); }这段代码看起来有点“魔法”但它背后的思想很朴素——在点p周围取几个微小的偏移位置分别计算它们的SDF值然后用差分近似出每个方向的导数向量最后归一化就是法线。h取0.001左右比较合适太大法线会变平滑太小则容易出现数值抖动。如果追求极致性能或者你在移动端运行也可以用解析法直接推导圆环法线公式省去4次SDF采样。但实际工程里数值差分法的开销极低而且可读性更高我的建议是先用它把效果跑通后续再考虑优化。4. 让圆环动起来旋转矩阵与交互动画设计几何体构造完成之后真正“转起来”的部分来了。圆环要动本质上就是我们每一帧重新计算射线与圆环的交点时用的坐标系在不断变化。最简单也最稳妥的方案是在做SDF求交之前先把射线的起点和方向做反向旋转。意思是圆环保持不动但是我们让相机绕场景旋转效果看起来就是圆环在相对旋转。这在渲染领域是一个很常见的技巧可以避免频繁修改SDF函数里的几何参数。为什么我推荐旋转相机而不是直接旋转模型原因有二。第一代码改动小——只需要把rayOrigin和rayDir乘一个旋转矩阵后面所有求交和法线计算完全不用动。第二旋转相机可以同时带动高光和阴影变化视觉上更自然因为光照方向其实没变变的是观察角度。旋转矩阵怎么构造用旋转角度angle动态生成mat2 rot2D(float a) { float c cos(a); float s sin(a); return mat2(c, -s, s, c); } // 在更新rayDir时调用 rayDir.xz rot2D(iTime * 0.5) * rayDir.xz;这里iTime * 0.5是每秒钟旋转约0.5弧度的缓慢旋转数值越大转得越快实际调试时可以按喜好调整。同时我还喜欢在场景里叠加一圈微弱的上下浮动让圆环看起来更“悬浮”逻辑是在z轴上再加一个正弦扰动vec3 torusCenter vec3(0.0, sin(iTime * 0.8) * 0.15, 0.0); p - torusCenter;这种做法比旋转坐标系的视觉效果更丰富但要注意SDF求交时的输入点p必须跟着平移否则圆环内部会错位画面出现割裂感。视线与圆环求交的过程我采用了固定步长的光线步进ray marching因为SDF给出了射线到圆环表面的估计距离允许我们安全地推进射线。每次步进距离取该点的SDF绝对值如果步进超过一定次数或距离小于某个阈值就认为命中表面或者脱靶。固定步数设为64比较平衡——太少容易出现断断续续的虚影太多则低端手机上会有明显压力。5. 光影与材质把圆环从“线框”变成“实体”几何命中之后圆环还只是一个带轮廓的形状。真正让它和背景图片水乳交融、产生“它就在照片里”的错觉靠的是光照模型。我用的是最经典也最稳妥的方案——兰伯特漫反射加Blinn-Phong高光再配合一点环境光。为什么不用PBR那套复杂的金属流程因为在这个场景里圆环是叠加在真实照片上的装饰物体而不是独立的全3D场景。如果材质写得太复杂反而会和背景图的质感打架看起来像两块东西硬拼在一起。漫反射部分的公式本质上就是在做法线点乘光照方向的操作。光照方向我固定为normalize(vec3(0.8, 1.0, 0.6))这是一个从斜上方打下来的光能同时照亮圆环的上部和侧面产生比较自然的体积感。高光部分用的Blinn-Phong半程向量法在性能和效果之间取了一个很好的平衡点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;这里有两个值得说明的细节。第一个是pow(..., 64.0)这个指数控制高光的锐度。64是一个比较高的值适合金属或塑料质感如果你想要哑光材质改成8到16即可。第二个是环境光常数ambientColor——它模拟的是场景中无处不在的反射光防止背光面死黑。我取vec3(0.15, 0.15, 0.2)带一点冷色让圆环的颜色更“坐得住”。材质颜色我用了暖橙色因为它在大多数街景、建筑照片里都比较跳但又不突兀。你可以把baseColor换成任意颜色甚至用时间函数做成呼吸变色vec3 baseColor 0.5 0.5 * cos(iTime vec3(0.0, 2.0, 4.0));另外千万别忘了给圆环加一条细细的阴影带不然它看起来会像浮在图片上的贴纸。阴影可以通过二次步进来算——从命中点沿光源方向再做一次SDF步进如果碰到了别的东西就说明当前点处于阴影中。因为是单物体场景这个阴影主要是圆环自遮挡代码并不复杂。6. 融合与合成背景保留和边缘抗锯齿处理圆环渲染完毕最后一步是把圆环合回原图。这里的关键技术点有两个一个是合成方式另一个是抗锯齿。合成方式上我直接用mix函数按透明度叠加。只有命中圆环的像素才会被替换成圆环颜色没命中的完全保留原图vec3 finalColor mix(background, shadedColor, hitMask);但是问题来了圆环边缘的像素往往会因为步进次数不够或阈值判断过于机械出现明显的锯齿和抖动。一帧一帧看起来还行但一旦旋转起来那些锯齿的边缘就会开始闪烁。解决这个问题的办法是抗锯齿。最划算的方案是平滑边缘——把命中判定的二元逻辑改成基于距离值的平滑过渡。说白了就是让距离绝对值的倒数去控制透明度。smoothstep天然适合干这个活float hitMask 1.0 - smoothstep(0.0, 0.003, abs(dist));这段代码的意思是如果距离值小于0.003认为命中的透明度接近1如果距离在0.003到0之间变化透明度也随之渐变。这样圆环边缘就不再是“在/不在”的硬切换而是像被砂纸打磨过一样过渡非常柔和。如果再进一步还可以开MSAA或者超采样。但这些方案在Shader里实现复杂、开销大我的建议是先用smoothstep方案跑通然后放大检查边缘如果还有锯齿再考虑用FXAA这一类后处理方案。实际项目中这个smoothstep方案就能干掉九成以上的边缘瑕疵。7. 参数调节与常见坑我在复现过程中踩过的雷最后这部分想分享一些非常实战的内容——参数怎么调哪些地方容易踩坑以及我在调试过程中总结的几条经验。7.1 圆环大小与图片内容的比例很多人拿到代码第一反应就是把圆环半径调大结果一跑发现圆环占满了整个屏幕反而没有美感。我的经验是圆环的视觉效果和最外层半径R、管道半径r的比例关系极大。R和r的比值在2到4之间比较美观太接近1的话圆环看起来会像一个敦实的甜甜圈拉大到5以上则细得像一根金属丝。先固定R再单独调r观察圆环截面的变化比同时调两个参数更容易找到手感。7.2 镜头距离与透视感想突出“3D感”镜头不能离圆环太远。太远了旋转的透视变化会非常微弱看起来像平面图形在转。我在很多项目里验证过的经验是相机位置z值在3.5到4.5之间透视感最理想。小于3圆环会冲出屏幕边缘大于5旋转带来的形变几乎看不出来了。7.3 步进次数与性能光线步进的次数直接决定性能和画质的平衡。国内很多低端手机的GPU对循环次数很敏感。64次是一个安全基准但如果你用的图片分辨率特别大比如4K建议把步进次数降到48同时把命中阈值适当放宽画质差距肉眼几乎看不出但帧率会明显改善。7.4 高频闪烁问题如果圆环在旋转过程中边缘出现高频闪烁或碎块通常是因为法线计算采用的h值太小导致差分结果被浮点数精度影响。先把h从0.001改成0.01试试大多数情况能解决。如果还有问题检查一下是否需要把uv坐标乘以一个较大的比例因子让SDF运算发生在数值范围比较友好的区间。7.5 背景图片明暗对圆环观感的影响背景图片本身如果整体偏暗圆环的暖橙色会格外凸显像贴纸一样悬浮。解决思路是给圆环加一点基于背景亮度的环境光反馈。一个很取巧的做法是先对背景图做一次模糊降采样采到圆环所在区域的近似亮度值然后把它乘进环境光里。这样图片暗的地方圆环也会自然变暗融合度立刻提升。8. 应用场景延伸这套Shader技术能用到哪整个实现从头到尾跑通之后你其实已经掌握了一套通用的、可迁移的实时渲染能力。很多人问这东西除了炫技还能干嘛我的答案是有不少实际用途。如果你在做游戏UI圆环进度条、技能冷却转盘、加载动画这套思路可以让你绕过序列帧和模型烘焙直接用一张贴图和一个Shader做出来资源和性能双重节省。如果你在玩Three.js完全可以在ShaderMaterial里套用同样的算法叠加到3D场景中的任何平面上做动态传送门、魔法阵、科幻标记。甚至你可以在数据可视化的大屏项目里给某个指标数字加一个旋转的装饰圆环视觉上立刻有了一层高级感。我之前还尝试过一个骚操作把SDF圆环和噪声函数结合让圆环的半径随时间产生波浪状变形效果就像一个缓缓膨胀收缩的能量环。实现方式不外乎是在SDF距离值上叠加一个sin扰动但视觉冲击力和纯静态圆环完全不是一个量级。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; }这行代码虽然简单但它体现了SDF方案的扩展性有多强——你只需要改一个距离计算的细节就能改变整个几何体的形态。回到开头那句话让图片里的3D圆环转起来确实还有高手但高手不是靠什么黑魔法而是靠对坐标空间、SDF距离场、光照模型这些底层层面的理解。这些知识一点就透但一旦掌握你就能在任何实时渲染环境里玩出花来。希望这篇拆解能帮你在自己的项目里也试一试这种“用代码凭空造物”的感觉。