两个着色器做水:水洼、溪流与瀑布

岛上没有水面模拟,也没有反射。两个 ShaderMaterial 分别负责平躺的水与竖直落下的水,共用一组 time 与 night 变量。

水是最容易做过头的东西。岛上的水只有两个材质,没有反射、没有折射、没有流体解算。

平躺的水:一个材质吃三处

水洼与溪流用的是同一个材质。它们的几何完全不同 —— 一个是圆形,一个是贝塞尔曲线围出的河道 —— 但片元着色器只关心位置与时间:

float w = sin(vPosition.x * 9. + vPosition.y * 12. + time * 1.6)
        * sin(vPosition.y * 7. - time);
float ring = pow(max(0., sin(length(vPosition.xy) * 19. - time * 1.6)), 22.);
vec3 c = mix(vec3(.10, .43, .43), vec3(.29, .72, .67), w * .25 + .5);
c += ring * .1;

两条正弦相乘给出不规则的波纹,一个高频幂次给出偶尔闪一下的环形高光。因为用的是位置而不是 UV,同一个材质铺在任何形状上都成立 —— 河道不用单独写 UV。

竖直的水:顶点位移加条纹

瀑布是一块 0.94 × 4.9 的平面,细分 10 × 32。它的顶点着色器让面片自己起伏:

p.z += sin(uv.y * 12. + time * 2. + uv.x * 5.) * .055;

片元则是一列高频条纹加一个缓慢脉冲,两侧用 smoothstep 渐隐:

float stripe = pow(.5 + .5 * sin(vUv.x * 70. + sin(vUv.y * 8. - time * 3.)), 7.);
float edge = smoothstep(0., .1, vUv.x) * (1. - smoothstep(.83, 1., vUv.x));
gl_FragColor = vec4(col, edge * fade * .85);

材质开 transparent 与 depthWrite: false,否则瀑布会挡住它后面的雾。

一组 uniform 供两个材质

time 与 night 是同一个对象:

const waterUniforms = { time: { value: 0 }, night: { value: 0 } };

动画循环里只写一次,两个材质同时更新。省下的不只是几行代码 —— 它保证了「切到月夜」时两处水是同一瞬间变暗的,不会一先一后。

两个必须加的 include

自定义片元着色器默认跳过色调映射与色彩空间转换,结果就是水会比周围亮一档,看着像发光的塑料:

#include <tonemapping_fragment>
#include <colorspace_fragment>

这两行是 three 的着色器片段,写在自己算完颜色之后。忘了它们,你就会开始怀疑是不是环境光配错了。

收尾

水的说服力来自运动与高光,不来自物理正确。两条正弦、一个幂次、一组共享 uniform,就足够让一个静态的岛看起来是活的。

← 返回文章列表

评论

…