
1. 从一张静态壁纸到一缸会呼吸的鱼这个屏保到底难在哪很多人第一次听到3D热带鱼屏保这个词脑子里浮现的画面大概是Windows XP时代那种几条贴图鱼在蓝色背景上循环平移的动画。说实话我一开始也是这么想的直到真正动手去做才发现要让一缸鱼看起来活着背后要解决的问题远比想象中复杂。这个项目的核心目标很明确用实时渲染的方式在普通消费级设备上呈现一个持续运行、不消耗过多资源、同时视觉上足够绚丽的热带鱼水下场景。它适合对图形渲染感兴趣的开发者、想入门实时3D的爱好者以及需要为桌面端产品设计动态视觉方案的工程师。为什么说它难因为屏保这个场景有三个非常苛刻的约束条件。第一它必须长时间稳定运行不能跑几个小时就内存泄漏或者帧率崩塌第二它不能占满GPU用户可能一边开着屏保一边还有别的后台任务第三它得好看而且不是静态截图那种好看是鱼要游动、水要波动、光线要变化的那种动态好看。这三个条件叠在一起就把很多能跑就行的方案直接淘汰了。我见过不少人做这类项目的思路是找一个现成的3D模型贴个纹理加个正弦波位移就完事了。结果做出来鱼像纸片一样僵硬水体像一块塑料布光照更是完全没有水下那种朦胧的散射感。问题的根源在于热带鱼屏保的本质不是一个模型展示问题而是一个生态系统模拟问题。你需要同时处理鱼的群体行为、水体介质的光学特性、焦散与折射的视觉表现以及所有这些在性能预算内的平衡。这篇文章我会把整个设计和实现过程拆开来讲包括技术选型的取舍逻辑、鱼群行为的实现方式、水下渲染的关键技巧以及我在实际调试中踩过的那些坑。不管你是用Three.js、原生WebGL还是其他渲染框架底层的思路是通用的。2. 技术选型为什么我最终没有用现成的游戏引擎2.1 屏保场景对技术栈的特殊要求做3D项目第一反应通常是Unity或者Unreal。这两个引擎确实强大模型导入、光照系统、粒子效果都是现成的。但屏保这个场景有个很尴尬的地方它是一个寄生在用户桌面上的小程序用户不会为了一个屏保去安装几百兆的运行时。Unity打包出来的桌面应用哪怕是最小化配置体积也很难压到50MB以下而且启动时间通常在3到5秒。对于屏保来说用户期望的是屏幕一黑鱼就出来了这个体验差距是致命的。所以我的选型逻辑很直接优先考虑Web技术栈用轻量级运行时承载3D渲染。具体来说Three.js加上WebGL2作为渲染后端打包成一个独立的桌面应用或者浏览器全屏页面。这样做的好处是整个包体可以控制在5MB以内启动几乎是瞬时的而且跨平台适配的成本极低。但Web技术栈也有它的短板。最大的问题是性能上限比原生引擎低尤其是在大量绘制调用和复杂着色器的情况下。这就意味着我不能像在Unity里那样随便往场景里丢几十条高面数的鱼模型必须从一开始就把性能预算算清楚。2.2 渲染管线的关键决策在渲染管线的设计上我做了几个关键决策每一个都直接影响最终的视觉表现和运行效率。第一个决策是前向渲染还是延迟渲染。延迟渲染在处理大量动态光源时有优势但它需要G-Buffer显存开销大而且对透明物体的支持很麻烦。水下场景里水的透明和折射是核心视觉效果用延迟渲染会让我在后期处理阶段非常痛苦。所以我选了前向渲染配合少量的实时光源和大量的烘焙环境光。第二个决策是鱼的渲染方式。高面数的鱼模型虽然好看但每条鱼几千个三角形20条鱼就是几万个三角形再加上骨骼动画的计算开销在集成显卡上直接跪。我的方案是用中等面数的模型每条鱼控制在800到1500个三角形配合顶点着色器做鱼鳍和尾巴的摆动而不是用完整的骨骼系统。这样既保证了游动的自然感又把CPU端的计算压力降到了最低。第三个决策是水体的实现方式。这里我纠结了很久最终选择了一个混合方案水面用屏幕空间的折射和反射来模拟水体内部用体积雾加焦散投影来表现光线的散射效果。这个方案的好处是不需要真实的光线追踪用几个巧妙的着色器技巧就能骗过眼睛。技术选项方案A游戏引擎方案BThree.js WebGL2最终选择包体大小50MB以上5MB以内方案B启动时间3-5秒接近瞬时方案B性能上限高中等方案A的优势场景不适用透明物体支持好需要手动处理方案B可接受跨平台成本高低方案B2.3 为什么放弃物理渲染转向风格化还有一个容易被忽略的决策渲染风格。PBR基于物理的渲染是现在的标配金属度、粗糙度、环境光遮蔽一套下来效果确实真实。但水下场景有个特殊性水本身就是一个强散射介质光在水里的传播和空气中完全不同。如果你用标准的PBR管线去渲染水下的鱼会发现颜色发灰、对比度低看起来像蒙了一层灰。我的做法是放弃严格的PBR转向一种风格化的渲染方式。具体来说鱼的材质用自定义的着色器手动控制高光的位置和强度让鱼鳞在光线扫过时有那种闪烁的金属感。水体的颜色不是靠物理计算出来的而是用深度和视角角度做插值从近处的青绿色渐变到远处的深蓝色。这种看起来对比算出来对更适合屏保这种追求视觉冲击力的场景。提示风格化渲染不是偷懒而是把计算资源花在刀刃上。屏保的观看距离通常在一米以上用户不会凑近去数鱼鳞的细节所以把精力放在整体氛围的营造上收益远高于追求物理精确。3. 鱼群行为让每条鱼都像有自己的想法3.1 从Boids算法到热带鱼的群体逻辑鱼群游动是让场景活起来的关键。如果每条鱼都沿着固定路径循环看两分钟就会觉得假。我用的基础是Boids算法这个算法由三个基本规则组成分离避免和邻居碰撞、对齐和邻居保持大致方向、聚合向邻居的中心靠拢。这三个规则叠加起来就能产生非常自然的群体运动。但直接用标准Boids算法做出来的鱼群有个问题太整齐了。真实的鱼群虽然整体方向一致但每条鱼都有自己的小动作有的会突然加速有的会稍微偏离队伍再追回来。所以我在Boids的基础上加了两层扰动。第一层是个体差异。每条鱼在初始化时会被分配一组随机参数最大速度、转向灵敏度、对邻居的感知半径。这些参数决定了它的性格——有的鱼比较活跃总是冲在前面有的鱼比较懒散喜欢跟在后面。这个改动很小但效果非常明显鱼群立刻从一群无人机变成了一群鱼。第二层是环境响应。鱼会对场景中的虚拟障碍物比如水草、石头产生避让行为也会对光线变化做出反应。比如当一束光从水面射下来时附近的鱼会稍微向光的方向偏转模拟趋光性。这些细节单独看可能注意不到但叠加在一起就让整个场景有了生命力。3.2 性能优化用空间分区把计算量降下来Boids算法有个致命的问题计算复杂度是O(n²)。每条鱼都要和其他所有鱼计算距离20条鱼就是400次距离计算每帧都要算。如果鱼的数量增加到50条就是2500次CPU直接吃不消。解决方案是空间分区。我把整个水体空间划分成一个个立方体格子每条鱼只需要和它所在格子以及相邻格子里的鱼做计算。这样复杂度就降到了O(n·k)k是每个格子里的平均鱼数。在实际场景中20条鱼分布在8个格子里每条鱼平均只需要和3到4条鱼做计算计算量直接降了一个数量级。具体实现上我用了一个简单的哈希表来管理格子。每帧开始时先清空格子然后遍历所有鱼根据位置把它们放进对应的格子。然后每条鱼只需要查询自己所在格子和相邻的26个格子3x3x3的邻域就能找到所有需要交互的邻居。这个优化让鱼的数量可以轻松扩展到50条以上而CPU占用几乎没有明显增加。// 空间分区哈希表的简化实现 class SpatialHash { constructor(cellSize) { this.cellSize cellSize; this.grid new Map(); } clear() { this.grid.clear(); } insert(fish) { const key this.getKey(fish.position); if (!this.grid.has(key)) { this.grid.set(key, []); } this.grid.get(key).push(fish); } getKey(pos) { const x Math.floor(pos.x / this.cellSize); const y Math.floor(pos.y / this.cellSize); const z Math.floor(pos.z / this.cellSize); return ${x},${y},${z}; } getNeighbors(fish) { const neighbors []; const baseX Math.floor(fish.position.x / this.cellSize); const baseY Math.floor(fish.position.y / this.cellSize); const baseZ Math.floor(fish.position.z / this.cellSize); for (let dx -1; dx 1; dx) { for (let dy -1; dy 1; dy) { for (let dz -1; dz 1; dz) { const key ${baseX dx},${baseY dy},${baseZ dz}; const cell this.grid.get(key); if (cell) { neighbors.push(...cell); } } } } return neighbors; } }3.3 鱼鳍和尾巴的顶点动画鱼的游动姿态是另一个影响真实感的关键。如果鱼的身体是刚性的只有位置在变看起来就像在滑行而不是在游。我用顶点着色器实现了鱼鳍和尾巴的摆动核心思路是根据顶点距离鱼身体根部的距离施加一个随时间变化的正弦波位移。这个位移的幅度和频率不是固定的而是和鱼的游动速度挂钩。鱼游得快的时候尾巴摆动幅度大、频率高鱼慢下来的时候尾巴轻轻晃动。这个联动关系让鱼的姿态和运动状态保持一致视觉上就非常自然。具体实现时我在鱼的模型上标记了哪些顶点属于尾巴、哪些属于背鳍、哪些属于胸鳍。然后在顶点着色器里根据这些标记和当前时间计算每个顶点的偏移量。这个计算完全在GPU上完成CPU只需要传递一个时间参数和速度参数开销几乎为零。注意顶点动画的幅度要控制好。我一开始把尾巴摆动的幅度设得太大结果鱼游起来像在抽搐。后来把幅度降到原来的三分之一反而看起来更自然。这个参数需要反复调试没有标准值。4. 水下渲染把水的感觉做出来4.1 水体颜色的深度渐变与视角依赖水下的视觉核心是什么是那种光线被水吸收、散射后产生的朦胧感和色彩偏移。在空气中远处的物体只是变小颜色基本不变。但在水下远处的物体会逐渐被水的颜色吞没从清晰的轮廓变成一团模糊的色块。我用水体颜色的深度渐变来模拟这个效果。具体来说在片元着色器里根据当前像素到摄像机的距离在物体本身的颜色和水的颜色之间做插值。距离越远水的颜色占比越高。这个插值不是线性的而是用指数函数因为光在水中的衰减遵循比尔-朗伯定律近似指数衰减。但只用深度还不够。水下的颜色还和视角有关。当你垂直向下看时看到的是水本身的颜色当你水平看时看到的是更远的水体颜色更深。所以我加了一个基于视角方向的修正项让水体颜色随着视线角度变化。这个修正让场景在不同视角下都有正确的色彩表现。// 水体颜色计算的片元着色器片段 vec3 computeWaterColor(vec3 objectColor, float depth, vec3 viewDir) { // 水的基色近处偏青绿远处偏深蓝 vec3 shallowColor vec3(0.2, 0.6, 0.7); vec3 deepColor vec3(0.02, 0.1, 0.3); // 根据深度插值水色 float depthFactor 1.0 - exp(-depth * 0.15); vec3 waterColor mix(shallowColor, deepColor, depthFactor); // 根据视角方向调整水的浓度 float viewFactor abs(dot(viewDir, vec3(0.0, 1.0, 0.0))); float waterDensity mix(0.3, 0.8, viewFactor); // 混合物体颜色和水色 return mix(objectColor, waterColor, waterDensity * depthFactor); }4.2 焦散效果水下光斑的廉价实现焦散是水下场景最有辨识度的视觉元素——那些在海底和鱼身上流动的明亮光斑。真实的焦散是光线经过水面折射后汇聚形成的物理模拟非常昂贵。但在屏保场景里我不需要物理精确只需要看起来像。我的做法是用一张预生成的焦散纹理通过两层不同速度和方向的UV动画叠加产生流动的光斑效果。然后把这张纹理投影到场景中的物体表面用叠加混合模式让光斑亮起来。这个方案的计算成本极低就是两次纹理采样和一次混合但视觉效果非常接近真实焦散。关键在于焦散纹理的制作。我用了一个简单的算法生成一张噪声图然后对它做几次不同尺度的模糊和阈值处理最后得到一张有明暗交替光斑的纹理。这张纹理是预先算好的运行时只需要采样不需要任何实时计算。焦散的投影方式也有讲究。如果直接把纹理贴在物体表面光斑不会随着物体移动而变化看起来很假。我的做法是用世界坐标做投影这样当鱼游过时光斑会自然地扫过鱼的身体产生正确的遮挡关系。4.3 水面折射与屏幕空间反射水面是连接水下世界和外部世界的界面它的渲染质量直接影响整个场景的可信度。我用了一个屏幕空间的方案先渲染水下的场景到一张纹理然后在水面渲染时根据水面法线对这张纹理做偏移采样模拟折射效果。这个方案的关键是水面法线的计算。我用两层不同频率和方向的正弦波叠加生成水面的高度场然后通过有限差分计算法线。这样得到的水面既有大尺度的波浪又有小尺度的涟漪看起来非常自然。反射部分我用了一个简化的方案不渲染真实的反射场景而是用一个渐变的环境色加上高光来模拟。因为屏保场景中水面之上通常没有太多值得反射的内容用环境色反而更干净。高光部分用Blinn-Phong模型计算让阳光在水面上形成闪烁的光点。效果实现方式性能开销视觉收益水体颜色渐变深度插值 视角修正极低高焦散光斑预生成纹理 UV动画低高水面折射屏幕空间偏移采样中等高水面反射环境色 高光极低中等体积雾深度雾 高度雾低中等4.4 粒子系统气泡与悬浮微粒水下场景如果只有鱼和水还是会觉得空。真实的鱼缸里有气泡、有悬浮的微粒、有从水面飘落的碎屑。这些细节虽然小但它们是让场景可信的关键。我用了一个轻量级的粒子系统来模拟这些效果。气泡从水底的几个固定位置生成向上漂浮到达水面后消失。悬浮微粒则均匀分布在整个水体中缓慢地随机运动。这些粒子的渲染用最简单的点精灵Point Sprite每个粒子就是一个带纹理的四边形面向摄像机。粒子的数量控制在200到300个之间这个量级在集成显卡上也能轻松跑满60帧。每个粒子的生命周期、速度、大小都有随机变化避免出现整齐划一的不自然感。提示粒子的透明度要用软边缘不要用硬切。我一开始用了一个圆形纹理边缘是硬切的结果粒子看起来像一个个小圆点。后来换成高斯模糊的圆形纹理粒子立刻变得柔和自然。5. 性能调优让屏保在低端设备上也能跑5.1 帧率预算与自适应降级屏保的运行环境千差万别有的用户是独立显卡有的用户是集成显卡还有的可能在虚拟机里跑。如果只针对高端设备优化低端设备上就会卡成幻灯片。我的策略是设定一个帧率目标60帧然后根据实际帧率动态调整渲染质量。具体来说我设置了几个质量等级高、中、低。高质量下所有效果全开粒子数量300焦散纹理分辨率1024中等质量下关闭体积雾粒子数量降到150焦散纹理降到512低质量下只保留基本的鱼和水面渲染关闭焦散和粒子水面折射用简单的颜色混合代替。质量等级的切换不是瞬间完成的而是有一个平滑的过渡。当检测到连续30帧的平均帧率低于50帧时降一级当连续300帧的平均帧率高于58帧时升一级。这个滞后机制避免了在临界点反复切换导致的闪烁。5.2 绘制调用合并与实例化渲染WebGL的绘制调用开销很大每次调用都有固定的CPU开销。如果每条鱼、每个粒子都单独调用一次绘制CPU很快就会成为瓶颈。解决方案是实例化渲染Instanced Rendering把相同几何体的多个实例合并到一次绘制调用中。对于鱼来说虽然每条鱼的位置、朝向、游动状态不同但它们的几何体是相同的。我把鱼的位置、旋转、速度等参数打包成实例属性在顶点着色器里根据实例ID读取对应的参数然后应用到顶点位置上。这样20条鱼只需要一次绘制调用CPU开销直接降到原来的二十分之一。粒子系统更是实例化的典型场景。所有粒子共享同一个四边形几何体每个粒子的位置、大小、透明度作为实例属性传入。300个粒子一次绘制调用搞定。// 使用Three.js的InstancedMesh实现鱼群渲染 const fishGeometry new THREE.BufferGeometry(); // ... 设置鱼的顶点数据 const fishMaterial new THREE.ShaderMaterial({ uniforms: { time: { value: 0 }, // ... 其他uniform }, vertexShader: attribute vec3 instancePosition; attribute vec3 instanceRotation; attribute float instanceSpeed; // ... 其他实例属性 varying vec3 vColor; void main() { // 根据实例属性计算顶点位置 vec3 pos position; // 应用鱼鳍摆动 pos computeFinAnimation(position, time, instanceSpeed); // 应用实例变换 vec4 worldPos modelMatrix * vec4(pos, 1.0); worldPos.xyz instancePosition; gl_Position projectionMatrix * viewMatrix * worldPos; } , fragmentShader: ... }); const fishMesh new THREE.InstancedMesh(fishGeometry, fishMaterial, 20);5.3 内存管理与长时间运行的稳定性屏保可能连续运行几个小时甚至几天内存泄漏是必须杜绝的问题。我在开发过程中养成了一个习惯每帧结束后检查是否有不再使用的对象及时释放。特别是纹理、缓冲区这些GPU资源如果不手动释放显存会持续增长直到崩溃。另一个容易被忽略的问题是浮点数精度。鱼的位置如果一直累加时间长了浮点数会变得很大精度下降导致鱼的运动出现抖动。我的解决方案是定期把鱼的位置重置到一个合理的范围内同时保持相对位置不变。这个操作对用户是不可见的但能保证长时间运行的稳定性。还有一个细节是时间参数的溢出。如果用一个不断累加的浮点数来表示时间跑几个小时后这个数会变得很大在着色器里做正弦计算时会出现精度问题。我的做法是让时间参数在一个周期内循环比如每1000秒归零一次。因为所有的动画都是周期性的归零不会造成视觉上的跳变。6. 那些调试中踩过的坑和最终的效果取舍6.1 鱼穿模与碰撞检测的简化鱼群游动时最尴尬的bug就是两条鱼穿模——一条鱼直接从另一条鱼的身体里穿过去。标准的Boids算法虽然有分离规则但那只是一种软避让在鱼密度高的时候仍然会穿模。我试过做精确的碰撞检测用包围盒或者包围球来判断鱼是否相交。但问题是鱼的形状是不规则的包围盒太粗糙包围球更粗糙。如果用精确的网格碰撞计算量又太大。最后我找到了一个折中方案在分离规则里加一个紧急避让机制。当两条鱼的距离小于一个阈值时施加一个远大于正常分离力的排斥力强制把它们推开。这个力只在极近距离时生效所以不会影响正常的群体行为但能有效防止穿模。6.2 水面反射的视觉欺骗前面提到水面反射我用的是环境色加高光没有做真实的反射。这个方案在大多数情况下效果不错但有一个问题当摄像机从水下往上看时水面应该像一面镜子反射出水下的场景。用环境色就完全不对了。我的解决方案是根据摄像机的位置动态切换水面的渲染方式。当摄像机在水下时水面用屏幕空间的反射采样已经渲染好的水下场景纹理当摄像机在水上时水面用环境色加高光。这个切换是平滑的通过一个基于摄像机高度的混合因子来控制不会出现突变。6.3 色彩校准在不同显示器上的一致性屏保的视觉效果高度依赖色彩但不同显示器的色域、伽马值差异很大。我在自己的显示器上调好的颜色到别人的屏幕上可能完全变样。为了解决这个问题我做了一次色彩校准。具体来说我把场景中所有颜色的亮度控制在一个合理的范围内避免过亮或过暗。同时我用了一个简单的色调映射Tone Mapping来压缩高动态范围让亮部不会过曝暗部不会死黑。这个色调映射用的是ACES的近似曲线计算量很小但效果很好。另外我避免使用纯饱和的颜色。纯红色、纯蓝色在广色域显示器上会非常刺眼在窄色域显示器上又会显得暗淡。我用的都是稍微降低饱和度的颜色这样在不同显示器上的表现更一致。6.4 最终的效果取舍什么该留什么该砍做屏保和做游戏不一样游戏可以为了效果牺牲性能屏保必须在性能和效果之间找到平衡。我在开发过程中砍掉了很多看起来很酷但代价太大的效果。比如我一开始想做真实的光线追踪焦散用光线从水面折射后汇聚到海底。效果确实惊艳但在集成显卡上帧率直接掉到20帧。后来换成了预生成纹理的方案效果打了七折但性能提升了十倍。还有鱼的骨骼动画我一开始用完整的骨骼系统每条鱼有十几根骨头游动姿态确实更自然。但CPU开销太大20条鱼就把一个核心跑满了。后来改成顶点着色器动画姿态自然度打了八折但CPU开销几乎为零。这些取舍没有绝对的对错关键是想清楚什么对屏保场景最重要。我的判断标准是如果用户不仔细看就注意不到的效果优先砍掉如果砍掉后场景明显变假的咬牙也要保留。效果保留/砍掉理由真实光线追踪焦散砍掉性能代价太大预生成纹理可替代完整骨骼动画砍掉CPU开销高顶点动画效果接近体积雾保留对水下氛围贡献大开销可控粒子系统保留提升场景可信度实例化后开销低水面真实反射部分保留仅在水下视角启用水上用环境色高精度鱼模型砍掉面数减半后视觉差异极小7. 后续可以继续折腾的方向这个屏保做完之后我还在想几个可以继续深挖的点。一个是鱼的种类多样化现在只有一种热带鱼如果加入不同体型、不同游动方式的鱼场景会更丰富。另一个是交互性比如鼠标移动时鱼会躲避或者点击时鱼会聚集过来。这些交互不需要复杂的逻辑但能让屏保从观看变成互动。还有一个方向是环境的变化。现在的场景是静态的如果加入昼夜循环光线从明亮到昏暗再到明亮水体的颜色和焦散的效果随之变化长时间运行也不会觉得单调。这个改动的核心是让光照参数随时间变化技术上不难但需要仔细调参才能让过渡自然。最后如果你也在做类似的项目我的建议是先把核心的鱼群行为和水体渲染跑通确保性能达标然后再往上叠加效果。不要一开始就追求完美先把框架搭起来后面调优的空间很大。我在这个项目上最大的体会就是屏保的视觉效果不是靠某一个惊艳的技术实现的而是靠一堆小细节的叠加每个细节单独看可能不起眼但合在一起就是那种说不出来哪里好但就是好看的感觉。