本文属于系列「游戏中的 Shader:十个常用效果的实现与边界」第 8 / 8 篇

水面扭曲为何露出重复接缝:双层 UV 扰动与采样边界

水面已经能随时间晃动,静止截图却很快暴露出两类接缝:相同形状以固定间距斜向重复,画面边缘还会卷入另一侧的颜色。继续提高扰动强度只会把接缝拉得更碎,并不会改变它们的来源。前者是单层噪声的空间周期,后者是底图采样越过 [0,1] 后仍按 Repeat 回卷。

双层扰动、单层重复、边缘回卷与强度钳制的 WebGL2 实际结果

第七篇把透明护盾的颜色表示、混合、深度和顺序收敛为一次宿主提交。本篇沿用同一边界:Shader 只计算当前全屏面的 UV,JavaScript 持有效果时钟、扰动强度和底图边界策略。第二篇建立的可暂停秒制时钟与 [0,1] UV 也继续有效;水面新增的是两份采样轨迹,而不是另一套时间语义。

一层会流动的噪声仍然保留固定周期

若只计算 texture(noise, uv * scale + direction * time),图案确实在移动,但相邻片元始终共享同一个缩放、方向和周期。观察者会看到整块扰动纹理平移,尤其在大面积平面和低频噪声上更明显。

本章让两层噪声使用不同尺度与非平行速度,并将两个 [-1,1] 向量求平均。相反的 X 方向打散整体漂移,不同的 Y 方向与缩放让两层较少在同一位置重复。完整片元入口如下:

// .tmp/game-shader-webgl-lab/chapters/08-water-distortion/water.frag
#version 300 es
precision highp float;

in vec2 vUv;
uniform sampler2D uScene;
uniform sampler2D uNoise;
uniform float uTimeSeconds;
uniform float uStrength;
uniform bool uDualLayer;
uniform bool uClampSceneUv;
out vec4 outColor;

void main() {
  vec2 flowA = vec2(0.071, 0.043) * uTimeSeconds;
  vec2 flowB = vec2(-0.052, 0.067) * uTimeSeconds;
  vec2 noiseA = texture(uNoise, fract(vUv * 2.7 + flowA)).rg * 2.0 - 1.0;
  vec2 noiseB = texture(uNoise, fract(vUv * 4.1 + flowB)).rg * 2.0 - 1.0;
  vec2 disturbance = uDualLayer ? (noiseA + noiseB) * 0.5 : noiseA;
  vec2 sampleUv = vUv + disturbance * uStrength;
  if (uClampSceneUv) {
    sampleUv = clamp(sampleUv, vec2(0.012), vec2(0.988));
  }
  vec3 sceneColor = texture(uScene, sampleUv).rgb;
  float highlight = max(0.0, disturbance.y) * 0.075;
  outColor = vec4(sceneColor + vec3(0.02, 0.06, 0.08) + highlight, 1.0);
}

fract 只用于可平铺的扰动纹理,使流动相位保持在有限区间。底图是已经渲染好的场景颜色,不具备平铺语义,因此不能照搬同一处理。固定画面用相邻像素 RGB 差的累计值作为行为指纹:双层为 1,284,281,单层为 1,386,833。这个统计量不能衡量“自然程度”,只能证明两条采样路径确实生成不同的固定输出;双层也不会从数学上消除所有周期,它只是把两个不同周期叠加,使当前视野内不再由一份纹理平移主导。

两层方案每个片元执行两次扰动采样和一次底图采样。若使用法线纹理,还应在合成后重新归一化方向;本章的 RG 通道表示二维偏移,不把它伪装成已经验证的水面光照法线。真实项目是否值得增加一次采样,要用目标设备、覆盖面积和效果数量测量,本次没有 GPU 计时,不能声称性能收益。

两层的权重也不是越复杂越好。直接相加会把理论偏移范围从 [-1,1] 扩到 [-2,2],同一个强度参数随之失去原来的量纲;求平均后,合成向量仍保留在单层可表达的范围内,宿主的 0.08 上限才同时约束单层对照与双层正式路径。若后续改成三层或给某层更高权重,应先让权重和归一,再重新计算最大偏移与边缘保护区,不能只看一张效果图调整常量。

边缘策略取决于被扰动的纹理是否允许平铺

底图 UV 加上扰动后会越出 [0,1]。若采样对象本来就是无限平铺的水面纹理,Repeat 可以是正确选择;若对象是屏幕颜色、反射 RT 或传送门画面,回卷意味着左边缘会读到右侧内容。固定底图故意让左上边界为红色、右下边界为绿色,未钳制样本的相邻差累计升到 1,628,751,边缘错色因此能被回归检查捕获。

零强度、正常强度、非法输入与长时间归一化的边界样本

这里把场景 UV 钳到 [0.012,0.988],留出线性过滤所需的安全内缩。该常量来自当前固定纹理和画面合同,并不是所有 Render Target 的通用答案。更严格的实现应由宿主按源纹理尺寸提交半个或一个 texel 的内缩量;若边缘需要继续扭曲而不能拉伸最后一行像素,则应扩大离屏颜色的保护区,或在越界区域渐弱扰动。用 Repeat 隐藏越界只是换成另一种可见错误。

钳制本身也会在大位移时形成一条被横向拉长的边缘色带,因此它是明确、可预测的退化,不是无损修复。屏幕热浪通常更适合在距边缘若干 texel 内把强度平滑压到零;传送门若有遮罩,则可以在遮罩外直接放弃采样;可平铺水面贴图才适合让底图 Repeat。三种策略的选择取决于被采样资源的所有权与有效域,不能由通用水面材质自行猜测。

强度也直接决定需要多大的保护区。宿主在 Draw 前把它限制到 [0,0.08],输入 0.24 实际提交 0.08,非有限值和负数退化为零。时间继续使用秒,但在 4096 秒处取模,避免持续增长的浮点数让小相位增量逐渐失去有效位;暂停时仍由效果时钟保持当前值。

// .tmp/game-shader-webgl-lab/chapters/08-water-distortion/app.js
export function normalizeWaterState(input) {
  const strength = Number.isFinite(input.strength)
    ? Math.min(Math.max(input.strength, 0), 0.08)
    : 0;
  const timeSeconds = Number.isFinite(input.timeSeconds) && input.timeSeconds >= 0
    ? input.timeSeconds % 4096
    : 0;
  return { strength, timeSeconds };
}

function drawCase(item) {
  const state = normalizeWaterState({
    strength: item.strength ?? 0.035,
    timeSeconds: item.timeSeconds ?? 37.25,
  });
  gl.uniform1f(locations.time, state.timeSeconds);
  gl.uniform1f(locations.strength, state.strength);
  gl.uniform1i(locations.dual, item.mode === 'single' ? 0 : 1);
  gl.uniform1i(locations.clamp, item.mode === 'edgeRepeat' ? 0 : 1);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
  return state;
}

宿主归一化、两层扰动与底图采样的固定数据关系

真实 Chrome 编译并链接 GLSL ES 3.00,分别绘制结果与边界两组画面,再用 gl.readPixels 统计固定输出:

$ python3 $WORK_DIR/.tmp/daily-labs/2026-08-11-2000/run_lab.py
exit_code=0
pass=10 fail=0 skip=0 duration=17.674s
dual_adjacent_difference=1284281
single_adjacent_difference=1386833
unclamped_edge_adjacent_difference=1628751
clamped_strength=0.08 wrapped_time_seconds=904

水面扭曲在这个切片中的边界已经明确:可平铺扰动纹理使用两组不同尺度和方向的有限相位,宿主限制位移上限,底图按自身语义选择钳制、保护区或渐弱,而不是默认跟随噪声 Repeat。它适用于水面、热浪或传送门的二维采样扰动,不覆盖折射深度、法线光照、泡沫、反射或多分辨率 Render Target。下一篇距离与高度雾会继续复用宿主持有有限参数的合同,但输入会从 UV 和时间转向世界位置、相机距离与高度。