ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Three.js ShaderMaterial实战:实现动态水面与柳树摇曳特效

Three.js ShaderMaterial实战:实现动态水面与柳树摇曳特效 在Three.js里折腾了一段时间后我越来越觉得ShaderMaterial是个绕不过去的坎。你用内置的MeshStandardMaterial做普通场景很顺手可一旦想要水面波纹、柳条摇曳、旗帜飘动这类动态着色器特效内置材质往往要么做不到要么性能代价高得离谱。这篇文章不是教科书式的API罗列而是我实际做动态着色器特效时的一些思考和踩坑记录。我会从为什么必须用ShaderMaterial讲起再一步步搭出可以运行的GLSL着色器最终实现能随风摆动的柳树和水面浮动的波纹效果最后会聊几个我常遇到的坑和排查思路。适合已经会用Three.js搭静态场景、想进一步掌握自定义材质做动态效果的开发者。1. 为什么选择ShaderMaterial需求分析与方案选型1.1 内置材质解决不了的问题Three.js的内置材质体系从MeshBasicMaterial到MeshStandardMaterial本质上都是在帮你封装一套完整的渲染方案。MeshStandardMaterial背后是PBR基于物理的渲染流程有光照、阴影、环境贴图、粗糙度和金属度这些概念绝大部分静态物体用它是非常合适的。但内置材质有一个很明显的边界你只能在它暴露的接口里做调整。比如想实现水面波纹通常的思路是给贴图的offset做滚动或者用多张法线贴图叠加再不断偏移。这种办法在远处看还能凑合一旦摄像机离水面近了你就会发现波纹是平移的没有上下起伏的立体感因为没有真正的顶点位移也没有逐像素的颜色计算。更麻烦的是柳条随风摆动这种顶点层面的形变。你不可能用任何内置材质实现因为内置材质的顶点处理逻辑是写死在Shader里的你拿不到position的控制权。ShaderMaterial的定位就是把这个控制权交还给你顶点怎么变、像素怎么着色你说了算。1.2 ShaderMaterial vs 内置材质什么时候该动手写我自己的判断标准很简单主要看三个信号需要逐顶点位移模型自身要弯曲、摆动、波动、扭曲比如柳条、旗帜、水面、布匹。需要逐像素的特殊计算不是基于贴图采样而是基于算法生成颜色比如流光、扫描、能量罩、熔岩沸腾。需要接入大量实时数据时间、鼠标位置、音频频谱、物理模拟结果这些数据要在每一帧传给GPU参与计算。只要你命中其中一个内置材质就很尴尬。有同学会说那我用多个内置材质叠加再用心跳函数控制参数行不行对于一些极简单的效果可以但一旦涉及到顶点位置和像素颜色层面的联动写ShaderMaterial是唯一体面的解法。1.3 这次项目到底要做什么为了把ShaderMaterial的核心用法讲透我做了一个仿真自然场景的demo里面包含两片水面和几棵柳树。水面用片元着色器做波纹颜色混合的动态效果柳树用顶点着色器做随风摆动的形变效果。选择这个组合的原因有两个。第一它覆盖了ShaderMaterial两大阶段的核心写法顶点阶段和片元阶段。第二效果足够直观你看到柳枝在摆、水面在流大脑能立刻把代码和画面建立联系理解起来比抽象的变色方块容易得多。2. 环境准备与最小工程搭建2.1 获取Three.js的正确姿势先说下载引用的方式。如果你只是快速试验直接在HTML里用importmap引入CDN是最省事的办法script typeimportmap { imports: { three: https://unpkg.com/three0.160.0/build/three.module.js } } /script如果你打算正经做一个长期项目建议用npm安装配合Vite或Webpack来做构建。npm安装的好处是版本锁定清晰后续升级不会莫名其妙地破坏你的代码npm install three0.160.0然后在你自己的模块里导入import * as THREE from three;这里有一个细节容易被新人忽略ShaderMaterial本身也在three的入口导出里不用单独从other目录引。而一些辅助模块比如OrbitControls在0.160版本之后放在three/addons/controls/OrbitControls.js这个需要单独引。很多报错找不到OrbitControls都是因为把这个忘了。2.2 搭建最小渲染场景不管做什么特效场景的基本结构是固定的场景、相机、渲染器、物体、灯光如果shader里不用灯光可以不加。我先放一个最精简的模板后续所有shader代码都在这个基础上改import * as THREE from three; import { OrbitControls } from three/addons/controls/OrbitControls.js; const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(3, 2, 5); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); document.body.appendChild(renderer.domElement); const controls new OrbitControls(camera, renderer.domElement); // 这里放你的物体和材质 function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } animate();注意renderer.setPixelRatio那里我习惯限制到2。移动端很多设备devicePixelRatio是3甚至更高如果直接设为原值像素量会爆炸很多项目卡顿不是因为shader复杂而是像素比没限制。2.3 调试工具与观察窗口写shader和写普通JavaScript不同你不能console.log一个像素值出来看。我的调试工具组合是OrbitControls自由旋转视角从不同角度看效果是否合理。dat.GUI或lil-gui实时调整uniform参数比如摆动幅度、波动速度不用改代码刷新页面。Stats.js看渲染帧率和draw call数量用于性能验证。尤其推荐lil-gui它对uniform的调整非常方便。比如我要调水面颜色直接在gui里挂一个color控件改颜色时实时写进uniform比每次改代码刷新快太多。3. GLSL基础ShaderMaterial的运行机制3.1 两个着色器各管什么ShaderMaterial由两个着色器组成顶点着色器Vertex Shader和片元着色器Fragment Shader。你可以把渲染过程想象成一条工厂流水线。顶点着色器负责处理几何形状物体在三维空间里每个顶点应该在哪、怎么移动、怎么被透视投影到屏幕上。片元着色器负责处理像素颜色屏幕上的每个像素最终显示什么颜色。一个生活化的类比是捏橡皮泥。顶点着色器决定橡皮泥捏成什么形状捏好后摆到展示台上片元着色器决定橡皮泥表面涂什么颜色、画什么花纹。你要是想改形状就得动顶点着色器要是想换颜色就得动片元着色器。3.2 attribute、uniform、varying的区别与生命周期这三类变量是GLSL的核心搞懂它们的区别shader就入门了一半。attribute顶点属性每个顶点都有一份自己的值。比如position顶点位置、uv纹理坐标、normal法线。它们是逐顶点的只能在顶点着色器里访问。uniform全局常量对当前绘制批次里的所有顶点和像素都是一样的值。比如时间uTime、鼠标位置uMouse、颜色uColor。它们在CPU端由JavaScript传入每帧都可以更新。varying顶点着色器和片元着色器之间的传递变量。顶点着色器给它赋值片元着色器读取它时拿到的是插值后的值。比如把每个顶点的uv传给片元着色器片元拿到的是平滑过渡的uv坐标。Three.js的ShaderMaterial还会自动帮你声明一些内置变量不需要你手动写。比如顶点着色器里可以直接用projectionMatrix、modelViewMatrix以及position、normal、uv这些attribute。3.3 第一个最小着色器渐变平面先不整花活写一个能出画面、能看到渐变效果的shader把你CPU和GPU之间的数据通路跑通。顶点着色器非常简单把顶点位置变换到裁剪空间varying vec2 vUv; void main() { vUv uv; gl_Position projectionMatrix * modelViewMatrix * vec4(position, 1.0); }片元着色器根据UV做一个从蓝色到白色的渐变varying vec2 vUv; void main() { vec3 color mix(vec3(0.1, 0.3, 0.8), vec3(1.0), vUv.x); gl_FragColor vec4(color, 1.0); }在JavaScript里创建材质和平面const planeMaterial new THREE.ShaderMaterial({ vertexShader: vertexShader, fragmentShader: fragmentShader }); const plane new THREE.Mesh(new THREE.PlaneGeometry(3, 2), planeMaterial); scene.add(plane);这里的uv和vUv要重点看。内置attributeuv在顶点着色器里能直接用我把它赋给varying变量vUv这样片元着色器就能拿到每个像素的纹理坐标。mix函数是GLSL里的线性插值第三参数是0到1的混合系数。vUv.x从左到右从0变到1所以颜色从蓝色平滑过渡到白色。这个例子看起来简单但它验证了最重要的事着色器编译通过、uniform和varying的数据链路是通的。后面所有复杂特效都是在这个基础上加计算逻辑而已。4. 动态特效实战柳树摇曳与水面波纹4.1 让柳枝动起来顶点着色器里的正弦波动杨柳树的飘逸感核心在于风吹过时枝条从树干到末梢摆动越来越大。翻译成图形学语言就是顶点位移的幅度随着高度变化递增。每个顶点的摆动是一个随时间变化的周期函数最简单的就是正弦函数。假设我们用一张竖长的PlanGeometry贴上一棵柳树的纹理再把几何体分成足够多的顶点段。着色器这样写uniform float uTime; varying vec2 vUv; void main() { vUv uv; vec3 pos position; float swing sin(uTime * 2.0 uv.y * 6.0) * 0.05 * uv.y; pos.x swing; gl_Position projectionMatrix * modelViewMatrix * vec4(pos, 1.0); }这里有几个关键点要拆开讲。uv.y的范围是从0到10是底部1是顶部。uv.y * 6.0给摆动加了一个随高度变化的相位偏置。这个偏置非常关键——如果所有顶点都用同一个相位整棵树会像钟摆一样左右晃而加了相位偏置后不同高度的点的位移错开了看起来更像流动的波浪。0.05 * uv.y是幅度控制。越靠近树梢uv.y越接近1摆动幅度越大靠近树根幅度趋近于0。这正是自然风的效果。0.05这个数值我是反复调出来的太小了看不出来动太大了整个树像抽搐。在JavaScript端材质初始化时声明uniform然后在渲染循环里更新const treeMaterial new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 } }, vertexShader: treeVertexShader, fragmentShader: treeFragmentShader, side: THREE.DoubleSide, transparent: true }); function animate() { requestAnimationFrame(animate); treeMaterial.uniforms.uTime.value performance.now() * 0.001; renderer.render(scene, camera); }performance.now() * 0.001是把毫秒换算成秒这样shader里的时间单位是秒好控制速度。uniforms对象的写法是Three.js的标准格式每个uniform都要用{ value: xxx }包一层这个结构不要记错。4.2 水面波纹片元着色器的噪波与颜色混合水面比柳条更适合做片元着色器练手。我的目标是做一个既不像镜子也不像玻璃的水面有深浅渐变的流动感看起来像那么回事。思路是用两列不同频率、不同方向的波形叠加让像素的颜色在深水和浅水之间平滑过渡uniform float uTime; varying vec2 vUv; void main() { vec2 p vUv * 8.0; float wave sin(p.x * 1.2 uTime) * 0.5 sin(p.y * 0.9 uTime * 1.3) * 0.5; float depth wave * 0.5 0.5; vec3 deepColor vec3(0.02, 0.15, 0.30); vec3 shallowColor vec3(0.25, 0.55, 0.75); vec3 color mix(deepColor, shallowColor, depth); gl_FragColor vec4(color, 0.9); }把vUv乘以8相当于把水面纹理坐标平铺成8x8的网格让波纹更细密。当然这里的波纹不是真正的法线扰动而是颜色深浅的变化视觉上已经能给出水波荡漾的感觉。为什么要叠加两个正弦波而不是一个因为单个纯净正弦波看起来太规律了像是重复的条纹。两个不同频率、不同传播方向的波叠加后会形成复杂的干涉图案虽然还不是真正的噪声但已经打破了周期性视觉上自然得多。wave * 0.5 0.5是把取值范围从[-1, 1]映射到[0, 1]这样depth才能作为mix的系数。很多新手在这里直接拿wave当系数结果颜色一大片是黑的这就是没做归一化导致的。4.3 让特效响应交互鼠标影响波动方向动态特效只跟着时间走看久了还是会腻。真实的渲染项目里用户交互几乎是个必选项。我在水面shader里加了鼠标影响鼠标在画面上的位置会决定波纹的偏移方向。在片元着色器里加一个uniformuniform vec2 uMouse; // 在main里把原来的波浪公式改为 float wave sin((p.x - uMouse.x) * 1.2 uTime) * 0.5 sin((p.y - uMouse.y) * 0.9 uTime * 1.3) * 0.5;JavaScript端监听鼠标位置换算成归一化坐标const uniforms { uTime: { value: 0 }, uMouse: { value: new THREE.Vector2(0, 0) } }; window.addEventListener(mousemove, (e) { uniforms.uMouse.value.x (e.clientX / window.innerWidth) * 2 - 1; uniforms.uMouse.value.y -(e.clientY / window.innerHeight) * 2 1; });这里把鼠标坐标从像素空间映射到[-1, 1]的归一化空间然后取反Y轴让鼠标向上移动时对应的坐标也向上符合直觉。实际运行的时候你会发现鼠标移动时水面的波纹会跟着搅动交互感一下就出来了。5. 性能优化与调度注意事项5.1 减少uniform更新频率与不必要的重绘ShaderMaterial最容易被忽略的性能问题不在GPU而在CPU端的uniform更新。很多人在渲染循环里每帧更新所有uniform有些其实根本不会变。比如你的场景里有十棵柳树每棵都用自己的ShaderMaterial每帧更新十份uTime这个开销还算可控。但如果场景里有五十个动态物体每个更新三四个uniform每帧就有一百多次JS到WebGL的状态更新这些调用累计起来很可观。我的建议是分类处理每帧必须更新的比如时间、鼠标放到循环里恒定不变的比如颜色、强度只在初始化时设一次。还有一个小细节如果你的渲染循环不需要每帧跑满60fps可以做个简单的节流比如把动画帧率限制到30或45。前提是视觉效果能接受我实测过很多水面效果在30fps下依然顺畅。5.2 精度限定符的选择要谨慎GLSL里有三种精度highp、mediump、lowp。在桌面端GPU上highp一般是显卡浮点运算的完整精度移动端的highp有时会带来显著的性能损耗。Three.js的ShaderMaterial默认会在shader头部自动添加一段精度声明通常桌面端是highp移动端是mediump。但当你写复杂数学运算时显式声明一下更稳妥。比如在片元着色器开头写precision highp float;这个声明必须在所有全局变量和函数定义之前。如果你用的是WebGL 2.0Three.js默认方程式的精度可能更复杂一些但顶点着色器保持highp几乎是必须的因为位置变换的精度直接决定模型是否会出现细碎的抖动。片元着色器里则要根据色彩精度来取舍如果需要平滑的渐变别用lowp颜色会断层。5.3 区分GPU瓶颈和CPU瓶颈当你的场景卡了先别急着优化shader搞清楚瓶颈在哪。用Chrome DevTools的Performance面板录制一段运行过程如果CPU占用拉满那问题可能在draw call过多、物理模拟、或JS逻辑上如果CPU占用不算高但GPU占用高那大概率是overdraw过多或者shader计算量过大。针对shader本身的优化我总结了几条实战经验避免片元着色器里的for循环尤其是循环次数和uniform值相关时。GPU的并行架构不适合动态分支和循环一旦遇到就会严重拖慢执行效率我见过有人写了个一万次的循环让帧率从60掉到个位数。避免多次pow、sqrt这类昂贵函数。能用乘法近似就不开方能用mix的就别用三角函数。尽早return复杂度高的分支。虽然GLSL不太鼓励分支但合理的提前退出比无谓的计算要快。如果你只是做颜色偏移、渐变能用内置的varying插值就别额外开一个uniform去逐像素计算插值是GPU的硬件功能几乎是零成本。6. 常见问题排查实录与进阶方向6.1 黑屏先读GPU给你的报错写了shader之后黑屏是所有人都会遇到的问题。不用慌第一步先看控制台。Three.js会把编译错误输出到浏览器的console里里面会详细说明是哪一行、什么错误。最常见的错误有这么几类语法错误分号、大括号、变量名拼写。GLSL对拼写非常敏感一个字母写错整个shader编译失败。类型不匹配把float赋值给了vec3或者vec3和float做运算没写对。比如vec3 float就不行必须vec3 vec3(float)。精度声明问题忘了声明精度或者声明位置不对。版本问题如果你的代码是用GLSL ES 3.0写的用out关键字但又没在Three.js里手动指定glslVersion: THREE.GLSL3就会报错。反过来用默认的GLSL1.0写法则用gl_FragColor这两种不要混。我在项目里最常犯的错误是GLSL变量名和JavaScript的uniform名对不上。shader里写uTimeJS里写utime编译不报错但画面完全没变化排查了好久。6.2 uniform传值不生效的几种原因这个问题出现的频率非常高我归纳为三类结构写错Three.js要求uniforms对象里每个字段都要是{ value: ... }的结构写成{ uTime: performance.now() }这种裸值是不会生效的。被编译器优化掉如果shader里声明了一个uniform但没有任何计算用到它编译器会把它优化掉。最典型的是你调试时临时加了个uniform之后忘了在shader里引用JS端还在每帧更新它白白浪费性能自然也没效果。类型不匹配JS端用普通数字传float没问题传vec2要用THREE.Vector2传vec3要用THREE.Vector3或THREE.Color。我传颜色的时候经常用THREE.Color这样在shader里直接当vec3用非常方便。6.3 从单体特效到整个场景数字孪生可视化里的着色器应用掌握了ShaderMaterial之后你会发现它的应用场景远不止做几个动态几何体。我接触过不少数字孪生可视化项目业务方需要给三维场景里的管线、设备、园区做高亮、呼吸、扫描、脉冲扩散这类动态效果。看起来需求五花八门底层几乎全靠shader来实现。比如设备状态高亮可以在片元着色器里根据时间做一个正弦波强度变化让设备边缘的发光颜色像呼吸一样起伏。也就是把uTime和边缘检测基于法线和视角方向的dot结合起来。再比如管线流动指示在顶点着色器里根据管线的长度UV做动态偏移让颜色的亮带沿着管线方向流动。这类需求放在Three.js里如果用内置材质硬做要么做不到要么实现得又慢又绕。但用ShaderMaterial就只是把uniform、varying、sin、mix这些基本元素按照业务规则组装起来而已。你花几个小时把基础概念搞透后面再遇到XX动态特效的需求思路都会清晰很多。6.4 一个小技巧用onBeforeCompile劫持内置材质有时候你不想抛弃内置材质的光照和阴影只想在它上面加一点动态效果。Three.js提供了一个隐藏的入口onBeforeCompile可以在内置材质的shader被编译之前注入代码。这个技巧我经常用来给MeshStandardMaterial加一个简单的顶点波动同时保留PBR光照。写出来的代码类似const material new THREE.MeshStandardMaterial({ color: 0x88cc88 }); material.onBeforeCompile (shader) { shader.uniforms.uTime { value: 0 }; shader.vertexShader shader.vertexShader .replace( #include begin_vertex, #include begin_vertex transformed.x sin(uTime * 2.0 position.y * 5.0) * 0.01; ); // 保存引用以便每帧更新 material.userData._shader shader; };每次要更新uniform时从material.userData._shader.uniforms.uTime取值再赋值就行。这个技巧会让你在不想写完整shader时快速验证一个想法但要注意#include的替换位置容易因为Three.js版本升级而改变升级依赖时要专门回归测试。对我个人来说ShaderMaterial就像是Three.js里的越狱口。你花在理解GLSL和渲染流程上的功夫不只是多会几个API而是真正明白了画面里的每个像素是怎么来的。这种掌控感是用内置材质怎么都体会不到的。做动态效果时当你不再需要围绕材质的既定参数打转而是直接决定波怎么摆、光怎么流动那种自由度和成就感值得每个做可视化的人去体验一次。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进