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(游戏引擎) | 方案B(Three.js + WebGL2) | 最终选择 |
|---|---|---|---|
| 包体大小 | 50MB以上 | 5MB以内 | 方案B |
| 启动时间 | 3-5秒 | 接近瞬时 | 方案B |
| 性能上限 | 高 | 中等 | 方案A的优势场景不适用 |
| 透明物体支持 | 好 | 需要手动处理 | 方案B可接受 |
| 跨平台成本 | 高 | 低 | 方案B |
2.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. 后续可以继续折腾的方向
这个屏保做完之后,我还在想几个可以继续深挖的点。一个是鱼的种类多样化,现在只有一种热带鱼,如果加入不同体型、不同游动方式的鱼,场景会更丰富。另一个是交互性,比如鼠标移动时鱼会躲避,或者点击时鱼会聚集过来。这些交互不需要复杂的逻辑,但能让屏保从"观看"变成"互动"。
还有一个方向是环境的变化。现在的场景是静态的,如果加入昼夜循环,光线从明亮到昏暗再到明亮,水体的颜色和焦散的效果随之变化,长时间运行也不会觉得单调。这个改动的核心是让光照参数随时间变化,技术上不难,但需要仔细调参才能让过渡自然。
最后,如果你也在做类似的项目,我的建议是先把核心的鱼群行为和水体渲染跑通,确保性能达标,然后再往上叠加效果。不要一开始就追求完美,先把框架搭起来,后面调优的空间很大。我在这个项目上最大的体会就是:屏保的视觉效果不是靠某一个惊艳的技术实现的,而是靠一堆小细节的叠加,每个细节单独看可能不起眼,但合在一起就是那种"说不出来哪里好,但就是好看"的感觉。