ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

用Shader在图片中实时渲染3D圆环:SDF与Ray Marching实战

用Shader在图片中实时渲染3D圆环:SDF与Ray Marching实战 从B站刷到那段Shader圆环特效的那一刻我整个人是愣住的——画面里一个金属质感的3D圆环就这么在静态照片上转起来镜头还能绕过去看侧面完全不像是“图片里能跑出来的东西”。“还有高手”是当时弹幕的真实反应也是我想说的。这件事真正有意思的点在于你不需要建模软件也不需要视频剪辑纯粹用Shader在现代GPU上实时算出一个带纹理的3D圆环在原本的二维图片里自转、公转。整个过程的底层思维其实是一套完整的“从数学到像素”的渲染管线。今天这篇就拿GLSL/WebGL做载体把从零到跑起来的完整方案拆开讲覆盖原理、代码、调参和踩坑记录。这篇文章适合两类人看一类是Shader入门之后想往上走一步的想知道“如何不用模型文件直接渲染三维物体”另一类是手头有图片素材、想在网页或互动作品里加一点“立体活物”效果的前端/互动开发者。读完你能复现一个可交互的旋转圆环并且能理解它背后的SDF、光线步进、UV贴图三板斧是怎么回事。1. 整体设计与思路拆解把“2D图片里的3D圆环”这个问题拆成三块1.1 先搞清楚你看到的效果到底是怎么算出来的我一开始也以为这个效果需要什么预烘焙的模型序列或者复杂的关键帧动画。真正动手之后才明白它本质上是在片元着色器里完成了一次“虚拟场景的实时求交”GPU对屏幕上每一个像素发出一条光线把这条光线丢进一个只存在于函数里的3D场景一步步探测它是否碰到圆环表面碰到了就根据表面信息算颜色没碰到就显示原始图片内容。这里的核心关键词是“函数即场景”。渲染一个圆环并不需要告诉GPU“圆环的顶点在哪”而是告诉它“空间中任意一个点离圆环表面有多远”。这个距离函数叫SDFSigned Distance Field有向距离场。GPU在每一帧对千万级别的射线做几十次采样最终在2D屏幕上还原出3D圆环。这种做法的妙处在于物体不是一堆离散三角形而是一个连续的函数旋转、缩放、扭曲都变成了对函数的坐标变换——这让“让圆环转起来”成了加一行旋转矩阵的事。为了让照片场景和圆环融为一体我采用了两层合成的思路先在光线步进阶段渲染出圆环的颜色和透明度再用透明度混合叠加到原图片上。这样原图不用做任何预处理Shader跑起来就能看到一个圆环“长”在图片表面并转动。1.2 路径选型为什么最终选择Ray Marching SDF而不是传统Mesh渲染在方案设计阶段我考虑过三条路方案优点缺点适用场景传统Mesh渲染.obj模型贴图美术流程成熟、贴图直观需要模型文件和解析器动辄几百KB和图片融合生硬游戏引擎、正式3D场景顶点Shader做顶点动画性能极好只能整体晃动无法呈现圆环厚度和内部遮挡简单玩具效果Ray Marching SDF纯代码建模、零外部资源、天然带遮挡/景深像素级计算着色器稍重参数需要调图片合成、交互特效、金色大厅式演示我选了第三条。对“在图片里放一个立体圆环并旋转起来”这种场景Ray Marching是干净利落的方案不需要文件资源、不依赖外部加载器甚至可以在ShaderToy里直接预览。而且在2D图片里要做出“真3D感”关键是物体边缘要有正确的前后遮挡和自遮挡而这正是Ray Marching的强项——每一条光线都经历了真实的距离场求交看到的就是圆环在当前角度下的真实轮廓。1.3 坐标系和相机一张图看懂圆环、屏幕与时间的关系在Shader里构建3D场景第一步是定义坐标系。我用的是右手坐标系屏幕中心为原点X轴向右Y轴向上Z轴指向屏幕外。相机放在z轴负方向某个位置朝原点方向看。要让圆环转动我选择让“场景”自己转也就是对采样点坐标做时间相关的旋转而不是移动相机——这样固定相机不会破坏原图的构图圆环又能在照片上稳定地自转。圆环本体由两个半径控制主半径R控制“环有多大”副半径r控制“管子多粗”。这个参数化方式跟甜甜圈的造型逻辑一致。旋转速度我设了slowRotate、tiltAngle、rollAngle三个自由度分别对应绕Y轴自转、绕X轴俯仰和绕Z轴翻滚。通常让绕Y轴自转为主其他两个设为固定微调角度就能摆出很有节奏感的姿态。整个过程可以用一段GLSL伪代码表示vec3 p worldPos - torusCenter; p.xz rotate2D(p.xz, u_time * spinSpeed); p rotateX(p, tiltAngle); p rotateZ(p, rollAngle); float d sdTorus(p, vec2(R, r));2. 核心实现细节与原理从数学公式到像素颜色2.1 圆环的距离函数sdTorus的推导与理解圆环距离函数看起来只有三行但它背后是几何的理解。先看代码float sdTorus(vec3 p, vec2 radii) { vec2 q vec2(length(p.xz) - radii.x, p.y); return length(q) - radii.y; }这个函数的思路是把三维点p先投影到XZ平面上算出它到圆心在XZ平面上的投影距离减去主半径R后得到了“在圆环管的中心线上”的最近距离。这个结果再作为x坐标把原始的y坐标作为第二个分量组成一个二维向量q。此时问题就变为这个二维点离原点也就是圆环截面的中心有多远再减去副半径r就得到了三维点到圆环表面的真实距离。如果返回的值大于0点在圆环外小于0点在圆环内部等于0刚好在表面。实际使用中我建议把sdTorus封装为一个通用函数保留radii参数因为换不同大小的环只需要改外部传入的vec2不需要改函数内部。这个函数的性能很好只用了两个length和几次加减在光追循环里调用128次也毫无压力。2.2 光线步进主循环射线怎么找到圆环表面有了距离函数接下来就是经典的Ray Marching循环。对屏幕上每个像素先根据像素坐标构造一条射线起点是相机位置ro方向rd是把屏幕坐标经过相机透视变换后得到的单位向量。然后从t0开始不断计算当前点p到圆环的距离d如果d小于某个阈值说明碰到了表面就跳出循环否则让t前进d这么远。为什么前进距离恰好是d而不是固定步长因为SDF保证当前点和场景表面之间至少隔着d距离直接跳到最近表面的位置是最快的路径这个特性让Ray Marching在数学上不会“穿透”物体。以下是完整的着色器主干段const int MAX_STEPS 128; const float MAX_DIST 12.0; const float EPS 0.0008; vec2 rayMarch(vec3 ro, vec3 rd) { float t 0.0; for (int i 0; i MAX_STEPS; i) { vec3 p ro rd * t; float d sdTorus(p, vec2(0.65, 0.28)); if (d EPS) { return vec2(t, 1.0); // 命中返回距离和标记 } t d; if (t MAX_DIST) break; // 超出最远距离直接放弃 } return vec2(MAX_DIST, 0.0); // 未命中 }这个循环看似简单但隐藏着两个重要细节。第一EPS判定阈值不能太大也不能太小太大时表面看起来会有一层“绒边”太小则在低精度设备上容易漏掉薄面。我习惯的动态方案参考稳定在0.0008左右对圆环这个尺寸来说足够。第二如果场景里以后会加多个物体比如再加一个小球循环里需要分别对每个物体求距离取其中最小值作为这一步行进距离。这部分代码后续扩展时可以预留一个getSceneDist函数把所有物体的距离函数统一丢进去。2.3 表面法线与光照怎么让圆环看起来“立体”Ray Marching得到命中点之后下一步是计算这个点的表面法线。传统三角网格的法线是用叉积算的但我们的SDF场景没有三角形所以我用数值微分法估算法线vec3 calcNormal(vec3 p) { vec2 e vec2(EPS, 0.0); return normalize(vec3( sdTorus(p e.xyy, params) - sdTorus(p - e.xyy, params), sdTorus(p e.yxy, params) - sdTorus(p - e.yxy, params), sdTorus(p e.yyx, params) - sdTorus(p - e.yyx, params) )); }思路很简单分别在x、y、z方向上各采样两次距离函数用差值近似梯度这个梯度就是法线方向。为此我需要把sdTorus的半径参数提为全局变量或通过宏传入保证calcNormal和rayMarch用的是同一组参数。光照我选了经典的漫反射加一点高光和环境光float diffuse clamp(dot(normal, lightDir), 0.0, 1.0); float spec pow(clamp(reflect(-lightDir, normal).z, 0.0, 1.0), 32.0); vec3 col baseColor * (ambient diffuse * lightColor) spec * vec3(1.0);这套光照加上法线计算就能让圆环在不同角度呈现出不同的亮暗——这是“立体感”的视觉根本。我的经验是环境光不要给太高0.15左右就够太高的环境光会让圆环显得发闷失去金属质感的对比度。光照方向我固定为左上角来跟常见UI图标的投影习惯保持一致也能利用好原图中已有光照方向的对比。2.4 把2D图片内容变成圆环纹理UV映射的正确姿势“图片中的3D圆环”最诱人的一点是圆环表面可以带原图纹理而不是单纯的一个颜色球。要让纹理正确贴到环面上需要在命中点反算出它对应的圆柱坐标角度。我用的公式是vec2 torusUV(vec3 p) { float u atan(p.x, p.z) / 6.28318530 0.5; float v atan(p.y, length(p.xz) - R) / 6.28318530 0.5; return vec2(u, v); }其中u是绕Y轴旋转一周的角度映射到[0,1]v是绕管截面一周的角度映射到[0,1]。这样圆环表面上每个点都和图片的二维坐标建立了双射图片就能平铺在环面上面。采样时用texture2D(uTexture, uv)就能看到纹理随着圆环转动而变化。这里有一个典型的坑直接用atan得到的角度在边界处会从1跳到0造成一条明显的接缝。要解决它纹理采样时用fract使坐标连续或者做两层镜像采样来混合边界。我实测fract在大多数情况下够用纹理如果是无缝贴图则完全看不出来如果是有明显边界的照片可以把UV映射稍微偏移让接缝藏在圆环背面的暗部区域。3. 实操过程与核心代码实现从零搭一个可复现的Shader圆环3.1 开发环境准备ShaderToy作为快速原型工具我推荐用ShaderToy做开发不需要配置任何构建工具浏览器打开就能跑。它的片段着色器默认会提供iTime、iResolution、iChannel0、iMouse等uniform变量。把下面这段完整代码丢进去再在Channel 0里加载一张你喜欢的图片就能第一时间看到效果。这里要注意ShaderToy使用的GLSL ES语法在精度声明上有特定要求和标准桌面GLSL不完全一样但本文代码可以无缝兼容。precision highp float; uniform sampler2D uTexture; uniform vec2 uResolution; uniform float uTime; uniform vec2 uMouse; const float R 0.65; const float r 0.28; float sdTorus(vec3 p) { vec2 q vec2(length(p.xz) - R, p.y); return length(q) - r; } vec3 calcNormal(vec3 p) { vec2 e vec2(0.0008, 0.0); return normalize(vec3( sdTorus(p e.xyy) - sdTorus(p - e.xyy), sdTorus(p e.yxy) - sdTorus(p - e.yxy), sdTorus(p e.yyx) - sdTorus(p - e.yyx) )); } vec2 rayMarch(vec3 ro, vec3 rd) { float t 0.0; for (int i 0; i 96; i) { vec3 p ro rd * t; float d sdTorus(p); if (d 0.0008) return vec2(t, 1.0); t d; if (t 10.0) break; } return vec2(10.0, 0.0); } vec2 torusUV(vec3 p) { float u atan(p.x, p.z) / 6.28318530 0.5; float v atan(p.y, length(p.xz) - R) / 6.28318530 0.5; return vec2(u, v); } void main() { vec2 uv (gl_FragCoord.xy * 2.0 - uResolution) / min(uResolution.x, uResolution.y); vec3 ro vec3(0.0, 0.15, -2.6); vec3 rd normalize(vec3(uv, 1.8)); float rotY uTime * 0.6; float tiltX 0.35; float rollZ uTime * 0.12; // 旋转射线起点和方向让整个相机围绕圆环旋转 // 这里采用翻转坐标的方式效果等于场景反向旋转 rd.xy mat2(cos(rollZ), -sin(rollZ), sin(rollZ), cos(rollZ)) * rd.xy; rd.xz mat2(cos(rotY), -sin(rotY), sin(rotY), cos(rotY)) * rd.xz; ro.xy mat2(cos(rollZ), -sin(rollZ), sin(rollZ), cos(rollZ)) * ro.xy; ro.xz mat2(cos(rotY), -sin(rotY), sin(rotY), cos(rotY)) * ro.xz; vec2 hit rayMarch(ro, rd); vec3 color texture2D(uTexture, uv * 0.5 0.5).rgb; if (hit.y 0.5) { vec3 p ro rd * hit.x; vec3 n calcNormal(p); vec3 lightDir normalize(vec3(-0.4, 0.8, 0.6)); float diff clamp(dot(n, lightDir), 0.0, 1.0); vec3 viewDir normalize(ro - p); vec3 halfDir normalize(lightDir viewDir); float spec pow(clamp(dot(n, halfDir), 0.0, 1.0), 48.0); vec3 texColor texture2D(uTexture, torusUV(p)).rgb; vec3 base mix(texColor, vec3(0.75, 0.85, 1.0), 0.35); vec3 shaded base * (0.12 0.85 * diff) vec3(1.0) * spec * 0.6; color mix(color, shaded, 0.92); } gl_FragColor vec4(color, 1.0); }这段代码是完整可跑的。我把图片既用作背景也用作纹理效果上圆环表面会反射出图片的内容视觉上非常统一。缩放/移动圆环只需要调整ro.z的距离和R的值即可。3.2 参数调节对照表与效果说明自己上手跑的时候最容易问的问题是“为什么我的圆环看起来不对”。参数的影响往往是一环扣一环我整理了一张对照表参数作用范围推荐初始值调大后的表现调小后的表现R主半径圆环整体大小0.6~0.8圆环撑满画面变成小装饰环r副半径管的粗细0.25~0.35像游泳圈像细铁丝rotY速度旋转快慢0.5~0.8动态感强但容易晕沉稳适合展示EPS表面命中判定0.0008表面模糊、重影可能出现薄面穿透lightDir光照方向(-0.4,0.8,0.6)阴影位置变化立体感变化我在这上面花了不少时间找到一个规律R和r的比例最好保持在2.5到4之间太胖或太瘦都会让圆环看起来像塑料面包或线圈不那么“高级”。旋转速度跟圆环大小强相关圆环越大旋转速度要适当降低否则视觉上会觉得“甩得太快”。3.3 纹理合成的优先级先颜色、再光照、再混合在实际合成中我调整过的顺序很有讲究。最开始时我先做背景再算圆环颜色然后直接替换背景色。结果发现圆环边缘抗锯齿很差而且背景和圆环叠加的部分会产生明显的硬边界。后来改为先算出完整的圆环着色结果再用透明度混合到背景上。透明度统一给0.92左右保留一点点背景透出这样圆环有一种“和照片共生”的微妙质感而不是一个硬贴上去的贴纸。另外一个细节是在命中圆环的像素上最终颜色应该以圆环为主但仍然要掺一点点背景色让边缘有反光透进来的错觉。这个混合比例我用0.08~0.15不同光照条件下可以微调。这个思路是从电影合成学里偷师的——真实物体永远不可能完全隔绝环境光。4. 常见问题与调试实录我踩过的那些坑4.1 圆环为什么“糊”了或者根本看不见我最初遇到的现象是圆环边缘全是噪点像蒙了一层雾。排查半天发现是EPS设到了0.01判定距离太大导致射线在距离圆环表面还很远时就提前结束采样点落在表面附近的震荡区域法线方向乱跳。把EPS降到0.0008之后立刻恢复正常。如果你把代码从桌面GLSL移植到WebGL还要注意float精度问题建议在精度声明里标明highp否则移动设备的mediump会导致距离函数在大坐标时精度不足出现严重的块状噪点。如果圆环整个看不见优先检查相机方向和射线方向构造。我把rd写成normalize(vec3(uv, 1.8))这个1.8是等效焦距常数相当于一个焦距偏广角的镜头。如果你把它改得太小比如0.5视野会变得非常窄圆环可能跑出屏幕太大则圆环变小到看不清。建议固定在1.5~2.0之间。4.2 纹理滚动的边界接缝问题纹理接缝是最影响观感的问题之一。我之前在一个深色背景下用带渐变的纹理结果圆环侧面有一条肉眼可见的竖线。原因正如前面提到的atan在从正π到负π跳变时纹理坐标瞬间从1回到0采样的颜色发生了断裂。我的解决思路是给uv做平滑过渡判断uv.x如果大于0.8就额外混合一次uv.x-1.0的采样结果形成一个小的左右渐变带。这个技巧加了之后虽然接缝处没有完全消失但肉眼几乎看不出断裂了。更专业的做法是把纹理做成四方连续图案让接缝天然隐藏。4.3 性能分析和优化老核显也能稳稳60帧ShaderToy上跑这个效果时默认情况下90多次光线步进在高端显卡上毫无压力但集成显卡上可能会掉到30帧以下。我做了一系列优化最终让一台2018年的办公本也跑到60帧把MAX_STEPS从128降到80缝隙视觉上没有明显变化但循环计算量下降37.5%提前退出命中后立刻返回不进入后续步进。背景大片区域在第一二次距离测试时就因为距离过大而提前break利用这个特性平均每像素只跑十几次采样关闭高光pow计算当diff等于0时如果法线完全背向光源直接跳过Specular计算把动态分支尽量写成静态判断不要在每个像素里对uniform做复杂的分支例如把纹理混合比例作为常量写死这些优化做完后profiling数据很直观圆环区域的采样数量是背景区域的5~8倍但由于背景占画面大部分平均压力大幅降低。在这方面我的心得是“不要在还没画出来的地方浪费算力”。4.4 其它常见问题速查表现象可能原因解决方案圆环位置偏左/偏右射线方向与屏幕宽高比适配不对调整uv横轴除以aspectRatio或者用uResolution.min纹理拉伸严重torusUV角度映射范围不对确认atan结果加0.5后落在[0,1]区间圆环闪烁/抖动EPS过小或超出MAX_DIST边界增大EPS到0.001或增加最大距离圆环和背景不融合混合透明度设得太高或太低调整mix系数到0.85~0.95区间只有背景看不见圆环旋转矩阵用错或ro方向反了打印ro和rd检查旋转轴是否与期望一致5. 进阶实操与个人体会5.1 从单个圆环扩展到组合场景圆环做完之后我很快就想给它加邻居同场景里放几个不同大小、不同旋转方向的圆环形成一种星球环绕的效果。这里最重要的一点是把sdTorus抽成带半径参数的版本然后在getSceneDist里对每个圆环做一次独立的坐标偏移和旋转返回所有圆环距离的最小值。光线步进循环本身不用改一个圆形阵列就能形成丰富的构图。试过之后你会体会到SDF的组合逻辑非常有魅力把场景想成一组函数每个物体都往最近的表面贡献距离碰撞自然被处理。如果加上iMouse交互让圆环跟随鼠标旋转则会大幅提升互动感。基本思路是用鼠标坐标构造一组目标旋转角再用lerp平滑过渡避免突变的跳跃。我个人觉得这个交互很适合放到个人主页或者作品集里做一个全屏的“3D圆环控制台”。5.2 我为什么建议你在图片上做伪3D而不是直接上WebGL模型最后分享一个自己的体会很多人第一次看到“图片里的3D圆环转起来”时第一反应是“这不是应该用Three.js加载一个模型吗”但实际做下来我反而觉得用SDF加纹理贴图的方式更适合这类“单图片增强”的场景。原因是2D图片天然没有深度信息模型再怎么真实也是“插入”图片而不是“长在”图片里。而SDF方式可以通过光照方向、模糊阴影和半透明混合让圆环和图片之间生成视觉上的“共同环境”融合度反而更高。如果你想真正做图片和3D物体融合的互动作品这个思路比传统模型管线更具性价比。我在实际使用中也养成了一个习惯每次调整完参数都会刻意截一张圆环在静态状态下的图单独检查边缘、接缝、光照是否自然。静态观察到了一定细腻程度动起来的效果才有品质感。这算是踩了无数坑之后沉淀出来的笨办法但确实是有效的。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表