4000 行 shader,只为了让水面好看一点
我花了六周重写水面。这篇文章是给未来的自己看的:哪些尝试有用,哪些纯粹是在自我感动。
《HONEY GARDEN》里有一片湖。玩家会在湖边待很久——钓鱼、发呆、和祖母说话。
所以水面必须好看。这不是“想要”,这是“必须”。一个玩家盯着看二十分钟的平面,糊弄不过去。
我花了六周。4000 行 shader。这篇是账单。
v1:把教程拼起来
第一版是标准的“三件套”:
// 滚两层法线贴图 → 采样反射 → 加菲涅尔边缘
vec3 n = normalize(mix(
texture(uNormalA, uv * 3.0 + uTime * 0.03).rgb,
texture(uNormalB, uv * 2.0 - uTime * 0.021).rgb,
0.5
));
vec3 r = reflect(-V, n);
vec3 col = texture(uReflect, r.xy).rgb;
col = mix(col, uDeepColor, fresnel);
跑起来了。看着像水。看着像任何一款 2015 年之后的游戏里的水。
问题在这:它是对的,但它不是我的。我盯着看了三天,越看越觉得这片湖和我的游戏没有关系。
v2:关掉反射
我做了一个所有人都会劝我别做的决定:把实时反射关了。
理由是——我的游戏里没有东西值得反射。
一座花园,几棵树,一片天。反射出来的东西,玩家已经看到了。我用掉了整个帧预算的 40%,去重复显示他们已经知道的信息。
关掉之后,多出来的预算我拿去做了三件事:
一、真实的水深
我用了一张手绘的水深图,让颜色真正取决于“这里有多深”,而不是一个统一的渐变。
float depth = texture(uDepthMap, worldXZ * 0.02).r;
// 浅处偏黄绿(能看到泥),深处偏蓝黑(看不到底)
vec3 waterCol = mix(uShallowCol, uDeepCol, smoothstep(0.0, 0.6, depth));
// 岸边一圈透光,是"浅"最直观的信号
float shore = 1.0 - smoothstep(0.0, 0.12, depth);
waterCol += uSunCol * shore * 0.35;
这一条改动带来的提升,比我前面所有法线贴图的调试加起来都大。玩家能看见湖底,于是他们会相信这片湖有三米深,而不是一张贴图。
二、风的记忆
我的游戏里有风。风吹过花田,也吹过水面。
但我不是实时算的——我用一个全局的 windPhase,让浪的传播方向和花田倒伏的方向一致。
// 浪从风向的来向传播,和花田、草的 shader 共用同一个相位
float wave = sin(dot(worldXZ, uWindDir) * 0.4 - uWindPhase);
这样做的副作用是:起风的时候,整个画面会同时动起来。 花、草、树、水,全部朝一个方向倒。第一次跑起来的时候我在椅子上坐直了。
这一条只值 20 行代码。它是整个水面里我最满意的部分。
三、不完美
真实的水面不平。有涟漪,有被风吹皱的地方,有鱼跳起来留下的一圈。
我加了一张手绘的“扰动图”,美术同事用 Aseprite 画的,4 个通道分别控制:涟漪强度、波纹方向偏移、高光锐度、透明度。
vec4 dist = texture(uDisturb, worldXZ * 0.01 - uTime * 0.005);
n.xy += (dist.rg - 0.5) * dist.b * 0.6;
specularPower = mix(80.0, 400.0, dist.g);
这张图只有 512×512,手绘的,一点都不物理。但它是唯一让水面看起来像“这个地方的水”的东西。
性能账
| 版本 | 帧时间(湖面) | 观感 |
|---|---|---|
| v1 三件套 + 实时反射 | 6.8 ms | 像别人的游戏 |
| v2 关反射 + 水深 | 2.1 ms | 好一点,但平 |
| v3 + 风的记忆 | 2.3 ms | 整个场景活了 |
| v4 + 扰动图 | 2.4 ms | 终版 |
最后是 2.4 毫秒,比第一版快了将近三倍,而且好看得多。
关掉一个“正确”的技术,换回一个“属于这里”的技术。这是这六周里我学到的唯一一件事。
一些纯粹浪费掉的时间
诚实起见,也记一下没用的尝试:
- 用 FFT 做海洋频谱。 花了一周,效果很好,但我的湖只有 60 米宽,风平浪静。它一直在算一些玩家永远看不到的浪。
- 屏幕空间反射。 又是五天。最后还是关了,因为湖边的树反射在水里之后,画面变得非常乱。
- 焦散(caustics)。 好看。但我的湖底没有东西给焦散照。空转。
- 把水面细分成网格做真实位移。 做完发现,我的水面从来没有近距离特写。玩家永远在岸上。
这四项加起来大概三周。都是“因为能做到所以做了”,不是“因为需要所以做了”。
如果重来
我会先问一个问题:玩家会在这个画面上停留多久?
如果答案是“十分钟以上”,那就值得花六周。如果答案是“跑过去三秒”,那我应该去把岸边的石头画好——那个更划算。
我的湖值得。你的呢?
END OF CARTRIDGE