资讯动态

Three.js着色器实战:一张图片变身旋转3D圆环

发布时间:2026/9/4 14:45:08 来源:尧图企业网站定制
这篇不是标题党是真的能让一张平平无奇的图片在浏览器里变成一个立体感十足的旋转3D圆环。而且不依赖任何复杂的视频处理工具核心就是Shader——更准确地说是在Three.js里写自定义着色器把一张2D图片用极坐标变换映射到圆环几何体上再用时间uniform驱动旋转。先说说这个项目适合谁。如果你是前端工程师想学Shader但被各种数学公式劝退或者你是Three.js新手想从ShaderMaterial入手做点有视觉冲击力的东西又或者你只是好奇“一张图片怎么能变成立体旋转效果”都能从这篇文章里找到答案。我会把整个实现从原理讲到代码再到踩坑排查全部拆开揉碎讲清楚。1. 从一张图片到旋转圆环核心思路拆解Shader这玩意儿说穿了就是让GPU帮你算像素颜色。图片本身是静态的但如果我们把“显示图片”这个动作变成一个实时计算的过程就能在每一帧给像素位置和颜色加上动态变化。旋转的错觉就是这么来的。1.1 为什么选Shader而不直接用CSS或Canvas有人可能会说我用CSStransform: rotate()转一个PNG图片不就行了行但那只是二维平面旋转像一张纸片在转没有立体感。我们要的是“3D圆环”——图片的前后两层都要可见边缘有弧度中间有厚度甚至光线扫过时还能有明暗变化。用Three.js建模一个圆环体TorusGeometry再把图片作为贴图贴上去当然也可以实现。但问题在于普通贴图是UV平铺的图片会被拉伸成环绕的形状看起来像一张打印了图案的轮胎缺乏“照片悬浮在环上”的质感。而Shader可以自定义UV采样和变换逻辑让我们完全控制每个像素怎么映射、怎么旋转、怎么加高光。一句话总结思路用Shader接管贴图的采样和颜色计算而不是把图片直接扔给材质系统处理。1.2 这个项目到底做了什么最终效果是这样的一张图片比如一个圆形Logo、一张人像、甚至一张风景照被映射到一个圆环体上点击页面或启动动画后圆环会以自身中心轴为轴持续旋转。图片正面朝外时清晰可见转到背面时会根据深度产生模糊和透明效果同时有一个沿圆环扫过的高光像灯光打在金属环上。这一整套效果如果用Canvas 2D去手动实现CPU会直接爆炸用普通贴图材质又做不出深度感和高光。只有Shader能做到实时、流畅、可交互。2. 搭建基础场景Three.js ShaderMaterial要写Shader得先有一个能跑Shader的环境。我个人直接用的是Three.js的ShaderMaterial它不像RawShaderMaterial那样要手动处理attribute和uniform的声明对新手友好得多。2.1 场景初始化先搭一个完整的Three.js场景包含相机、渲染器和圆环几何体import * as THREE from three; import vertexShader from ./shaders/vertex.glsl; import fragmentShader from ./shaders/fragment.glsl; const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(0, 0, 6); const renderer new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); document.body.appendChild(renderer.domElement); const texture new THREE.TextureLoader().load(./texture.jpg); const geometry new THREE.TorusGeometry(1.5, 0.65, 64, 96); const material new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms: { uTexture: { value: texture }, uTime: { value: 0 }, uSpeed: { value: 0.5 }, uGlowColor: { value: new THREE.Color(0x00ccff) } }, side: THREE.DoubleSide, transparent: true }); const mesh new THREE.Mesh(geometry, material); scene.add(mesh);这里有几个参数值得停下来解释一下。TorusGeometry(1.5, 0.65, 64, 96)中1.5是环的主半径0.65是管道半径64和96分别是分段数。分段数直接影响渲染质量和性能64和96对于大多数显卡来说毫无压力但如果你是第一次跑建议分别降到32和48先确认效果再拉高。side: THREE.DoubleSide很关键。默认情况下Three.js材质只渲染正面但圆环旋转时背面肯定会露出来所以必须双面渲染。代价是GPU计算量翻倍但对于一个圆环来说完全可以忽略。transparent: true是为了让圆环的边缘有一个平滑的淡出效果这个我们后面在Shader里会配合透明度通道实现。2.2 顶点着色器的职责顶点着色器在Shader里是管“位置”的。它接收每个顶点在模型空间中的坐标经过MVP矩阵变换后输出到屏幕空间。对于这个圆环我们实际上不需要改变顶点的位置因为旋转是通过UV采样方式实现的——你在逐像素层面把图片“包”到圆环上而不是物理旋转模型。// vertex.glsl varying vec2 vUv; varying vec3 vNormal; varying vec3 vPosition; void main() { vUv uv; vNormal normalize(normalMatrix * normal); vPosition (modelViewMatrix * vec4(position, 1.0)).xyz; gl_Position projectionMatrix * modelViewMatrix * vec4(position, 1.0); }这段代码里vUv是每个顶点在纹理上的坐标vNormal是法线方向vPosition是视空间下的位置。这些varying变量会被插值后传给片元着色器让每个像素都能拿到它对应的位置和朝向来计算颜色。注意如果你用的是RawShaderMaterial上面这些变量uv、normalMatrix、modelViewMatrix都需要手动声明。用ShaderMaterial可以让Three.js自动处理这些内置uniform省掉一大段样板代码。2.3 片元着色器的核心逻辑片元着色器才是真正施展魔法的地方。这里的设计要解决三个问题如何采样图片、如何产生旋转、如何计算光照高光。先写一个基础版本不做旋转仅验证图片能正确显示// fragment.glsl precision highp float; varying vec2 vUv; varying vec3 vNormal; varying vec3 vPosition; uniform sampler2D uTexture; void main() { vec4 texColor texture2D(uTexture, vUv); float alpha 1.0 - pow(abs(vUv.x - 0.5) * 2.0, 1.5); gl_FragColor vec4(texColor.rgb, alpha); }跑起来后你会看见一个圆环图片沿着环体铺展开来边缘的透明度逐渐降为零像一个渐变玻璃环。但注意这里的UV采样是Three.js自动生成的图片的四个边会分别对应圆环的四段看起来是“扭曲”的。这很正常因为我们还没做特殊的UV映射。接下来我们做两件事一是把图片转成极坐标形式映射到环上让图片中心对准环的中心二是加入时间uniform让它旋转。3. 让圆环转起来极坐标变换与动画控制3.1 极坐标映射原理一个圆环的UV坐标可以理解成两个方向一个是沿着管道环绕的方向另一个是管道截面的方向。默认情况下图片会沿着这两个方向平铺所以看起来图片被“拉伸”成了环形。如果不想要拉伸感需要把图片当成一个整体放在圆环正前方像贴纸一样覆盖上去。也就是说圆环上每个像素显示的应该是图片上对应极坐标位置的颜色。具体做法把UV坐标从[0,1]换算到以图片中心为原点的笛卡尔坐标[x, y]范围内范围是[-1, 1]。然后根据当前像素在圆环上的位置反算出它对应的图片极坐标下的采样点vec3 pos normalize(vPosition); float theta atan(pos.x, pos.z); // 绕Y轴的角度 float phi atan(length(pos.xz), pos.y); // 绕管道截面的角度这里theta决定了图片的横向采样位置phi决定了纵向采样位置。再将theta加上一个随时间变化的偏移量就实现了图片绕Y轴的旋转。这个思路在数学上非常优雅你并没有旋转物体你只是旋转了“取色”的角度。3.2 采样坐标与旋转的结合在片元着色器中写一个旋转采样函数vec2 rotateUv(vec2 uv, float angle) { float s sin(angle); float c cos(angle); return vec2(uv.x * c - uv.y * s, uv.x * s uv.y * c); } void main() { vec2 uv vUv * 2.0 - 1.0; // 映射到[-1,1] uv rotateUv(uv, uTime * uSpeed); // 旋转采样坐标 vec4 texColor texture2D(uTexture, uv * 0.5 0.5); // 转回[0,1] // 丢弃超出图片范围的部分 if (uv.x * uv.x uv.y * uv.y 1.0) { discard; } gl_FragColor texColor; }这里用uv * 0.5 0.5把旋转后的坐标转回纹理坐标系然后用discard丢弃圆环上超出图片圆形范围的部分。这样图片就以圆形裁切的方式显示在环上边缘干净利落。uTime是一个uniform在每一帧更新。function animate() { requestAnimationFrame(animate); material.uniforms.uTime.value performance.now() / 1000; renderer.render(scene, camera); } animate();很多人第一次跑的时候发现图片“转起来”但不是预期的3D旋转而是像一张纸片绕Y轴转。这是因为我们只是旋转了采样坐标并没有让像素的亮度随朝向变化。真正要做出“立体感”还得靠法线和高光。3.3 高光从“贴纸”到“金属环”一个在3D空间中旋转的环面朝光线时应该更亮侧向光线时应该更暗。这个计算靠法线。完整片元着色器代码如下precision highp float; varying vec2 vUv; varying vec3 vNormal; varying vec3 vPosition; uniform sampler2D uTexture; uniform float uTime; uniform float uSpeed; uniform vec3 uGlowColor; float specular(vec3 normal, vec3 lightDir, vec3 viewDir, float shininess) { vec3 reflectDir reflect(-lightDir, normal); float dotProduct max(dot(viewDir, reflectDir), 0.0); return pow(dotProduct, shininess); } void main() { vec2 uv vUv * 2.0 - 1.0; uv rotateUv(uv, uTime * uSpeed); vec3 lightDir normalize(vec3(1.0, 1.0, 1.0)); vec3 viewDir normalize(-vPosition); vec3 normal normalize(vNormal); vec4 texColor texture2D(uTexture, uv * 0.5 0.5); if (uv.x * uv.x uv.y * uv.y 1.0) { discard; } float diff max(dot(normal, lightDir), 0.0); float spec specular(normal, lightDir, viewDir, 32.0); vec3 finalColor texColor.rgb * (diff * 0.7 0.3) uGlowColor * spec * 0.8; float alpha texColor.a; gl_FragColor vec4(finalColor, alpha); }这样跑起来圆环就有了明暗转向外面的部分更亮转到里面的部分更暗同时有一条高光带贴着环的表面扫过。光和影的配合让“图片”真正融入了三维空间。4. 优化细节与性能陷阱排查4.1 Shader性能踩坑实录Shader写起来快调起来才是真考验。我在这个项目里碰到的第一个坑是texture2D在高DPI屏幕上的锯齿。后来发现问题出在renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))——这行代码没错但如果你在片元着色器里直接用vUv采样纹理而没有开启纹理的mipmap在旋转过程中远处边缘会出现闪烁噪点。解决办法是更新纹理的参数texture.generateMipmaps true; texture.minFilter THREE.LinearMipmapLinearFilter; texture.magFilter THREE.LinearFilter; texture.wrapS THREE.ClampToEdgeWrapping; texture.wrapT THREE.ClampToEdgeWrapping; texture.needsUpdate true;LinearMipmapLinearFilter能让旋转缩放时的画面平滑过渡配合clamp保证边缘不重复铺展。这三行配置几乎每个Shader贴图项目都用得上建议直接记住。另一个坑是透明度问题。当圆环旋转到侧面时图片正面和背面重叠透明度叠加后会产生刺眼的“白色光晕”。这是因为两个面都渲染了半透明像素GPU按照“先远后近”的顺序合成颜色但ShaderMaterial在Three.js默认是“先近后远”的排序导致半透明区域出现脏感。解决办法有两个。轻量方案在片元着色器里检查法线与视角夹角如果法线方向远离相机就降低透明度float viewFactor dot(normal, normalize(viewDir)); float alpha texColor.a * (0.3 0.7 * abs(viewFactor));这个技巧在大多数场景下效果足够而且几乎不增加开销。重量方案关闭transparent改为depthWrite: true然后用特殊的混合模式。这个对新手不友好我就不展开推荐了。4.2 GPU与缓存的冷知识这个项目跑起来后我用RenderDoc分析过帧耗时。全分辨率下每个像素都要跑一遍旋转矩阵乘法、法线点积、幂运算对GPU压力不大但也不是零成本。特别是通过Shader控制大量旋转圆环的场景如果同时有几个透明物体互相遮挡片元着色器的调用次数会急剧上升。另外如果你和我一样在Linux下开发会经常遇到Mesa驱动的shader cache失效问题。症状是代码改了但画面不更新。排查手段是设置MESA_SHADER_CACHE_DISABLEtrue跑一次如果画面正常了说明是缓存回退的bug。同理mesa_glthreadtrue能让多线程优化生效配合Three.js的WebGL渲染能明显压低CPU占用率。这些都是在调试运行环境时很常用的小工具。4.3 从圆环扩展到更多形态基础版本跑通之后玩法很多。举个最简单的把TorusGeometry换成SphereGeometry就能把图片变成地球仪旋转效果换成一个圆环但增加管道的半径变化可以做出“呼吸”效果。还有一个我的个人偏好把鼠标位置映射为uTime的偏移量实现“鼠标拖拽旋转”。做法是在mousemove事件里更新一个新的uniformlet targetRotation 0; document.addEventListener(mousemove, (e) { targetRotation (e.clientX / window.innerWidth - 0.5) * 3.0; }); function animate() { materials.forEach((mat) { mat.uniforms.uDrag.value (targetRotation - mat.uniforms.uDrag.value) * 0.05; // 用lerp插值手感很顺滑 }); }这样旋转就不是匀速的了而是带“惯性”跟手滑动观感直接上一个档次。5. 完整材质代码与运行建议把上面所有片段整合成一个可运行的完整示例。以下是最核心的Shander部分。顶点着色器沿用2.2的版本片元着色器升级为带高光、旋转、拖拽控制的完整版precision highp float; varying vec2 vUv; varying vec3 vNormal; varying vec3 vPosition; uniform sampler2D uTexture; uniform float uTime; uniform float uSpeed; uniform float uDrag; uniform vec3 uGlowColor; uniform float uGlowIntensity; vec2 rotateUv(vec2 uv, float angle) { float s sin(angle); float c cos(angle); return vec2(uv.x * c - uv.y * s, uv.x * s uv.y * c); } void main() { float rotationAngle uTime * uSpeed uDrag; vec2 uv rotateUv(vUv * 2.0 - 1.0, rotationAngle); vec3 normal normalize(vNormal); vec3 lightDir normalize(vec3(0.8, 1.0, 0.6)); vec3 viewDir normalize(-vPosition); float diff max(dot(normal, lightDir), 0.0); vec3 reflectDir reflect(-lightDir, normal); float spec pow(max(dot(viewDir, reflectDir), 0.0), 48.0); vec4 texColor texture2D(uTexture, uv * 0.5 0.5); if (uv.x * uv.x uv.y * uv.y 1.0) { discard; } vec3 baseColor texColor.rgb * (diff * 0.8 0.2); vec3 glow uGlowColor * spec * uGlowIntensity; float alpha texColor.a * (0.3 0.7 * abs(dot(normal, viewDir))); gl_FragColor vec4(baseColor glow, alpha); }对应的JS配置const material new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms: { uTexture: { value: texture }, uTime: { value: 0 }, uSpeed: { value: 0.4 }, uDrag: { value: 0 }, uGlowColor: { value: new THREE.Color(0x66aaff) }, uGlowIntensity: { value: 0.7 } }, side: THREE.DoubleSide, transparent: true });这里uSpeed是自转速度建议区间在0.2到0.8之间。太慢没动感太快会让人看不清楚图片内容。uGlowIntensity控制高光强度0.5到1.0之间比较自然太高会把图片本身颜色盖住。为了实用性我建议在材质上绑定一个交互设置面板const controls { speed: 0.4, glowIntensity: 0.7, glowColor: #66aaff };然后通过dat.GUI或自定义UI实时改变uniforms值在调参过程中就能感觉出每个参数的作用。参数调得好效果从“还行”直接跳到“卧槽”。6. 网上少见但很实用的排查技巧问题1图片转到一半突然不见了原因discard把超过圆形范围的部分都丢掉了但圆环在旋转时图片边缘的像素投影后可能会超出圆形范围一点点。解决在discard之前加一个抗锯齿过渡带float feather smoothstep(1.0, 0.98, length(uv));问题2高光闪烁特别厉害原因法线插值带来的精度问题尤其是当管道分段数低时。解决把geometry分段数提高new THREE.TorusGeometry(1.5, 0.65, 96, 128)。高光越强对几何平滑度要求越高。问题3图片颜色的亮度不一致原因很多图片不是sRGB色域在Shader里采样后颜色整体偏灰。解决在加载纹理时设置texture.colorSpace THREE.SRGBColorSpace;Three.js会自动做色域转换。问题4圆环上有明显的“接缝”原因图片在0度和360度处的采样缝隙。解决图片本身做成无缝贴图或者在Shader里对theta方向做模运算时把偏移量限制在[-PI, PI]区间避免跨度过大产生数值误差。更简单的方案换一张对称性强的图片接缝肉眼几乎看不清。这些东西教科书里基本不会写都是实打实跑项目跑出来的经验。我刚开始调这个效果时一个高光闪烁的问题排查了两个小时最后发现是法线没有重新归一化。7. 代码跑通之后还可以往哪走Shader的魅力在于它不只是一个“效果”而是一套可以反复组合的数学工具。拿这个圆环来说换几个数学表达式就能变出完全不同的视觉把rotateUv换成uv.x 0.1 * sin(uv.y * 10.0 uTime)图片就会像水面波纹一样扭曲把指数从pow(..., 48.0)改成pow(..., 4.0)高光就会变得模糊柔和像塑料把圆环的main radius用另一个sin(uTime)去驱动圆环就会一呼一吸地膨胀收缩。这些扩展都不需要改架构只动片元着色器里的几行数学表达式。如果你学Shader是为了做Web特效、独立游戏、数据可视化大屏这种“换公式出效果”的练习方式比死磕光照模型理论更能建立手感。我个人最喜欢的变种是把这张图片替换成一张风景照片然后极坐标映射到一个旋转的球体上配合一点雾效看起来就像一个缓慢自转的小行星挂在天花板的投影里。代码加起来的量不多但展现出来的氛围感非常强。实践是最好的学习方式。拿着上面的代码跑起来然后把参数从0到1逐档调一遍感受每个变量对画面到底干了什么比看十篇Shader教程都管用。说回开头那句“还有高手”——Shader这条路上永远有高手但很多时候你跟高手之间的差距就只差一个敢动手试着改公式的自己。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价