Gallery
No video available.
一次水体 Shader 面试题作业。记录我写了什么、怎样把之前学过的内容用进去,以及还有哪些地方没处理好。
这是我在一次面试中做的水体 Shader 作业,要求是让 AI 生成的湖景呈现水面波动、岸边波纹和反射。我把完成过程留在这里,记录自己写过这个东西,也方便以后回来复习。
它不是上线项目,也不是一套成熟的水体产品。下面按当时的制作过程写:做了什么、为什么这样做,以及还有哪些地方没处理好。
作业内容:水体 Shader 与 SDF 编辑器工具
环境:Unity 6000.6.0f1 · URP 17.6.0 · HLSL · C#
形式:标准 Quad 上的面片水体;Unity 工程交付,未构建独立程序
记录日期:2026-09-11
素材生成与学习过程使用了 AI 辅助;实现沿着个人课程逐步手写、测试和调整。下文区分课程改编、项目自己的方案,以及尚未验证的部分。

图 1:已有展示录像第 6 秒的 Scene 视口裁切。这里展示的是当前 Demo 的真实画面,不是 AI 生成的效果预想图。原录像为 1280×720,裁切图保留原始像素,不做超分辨率放大。
题目给出的关键词是“2.5D 俯视角”“AI 生成环境”“不同形状的水体”,以及波动、边缘水波纹和反射。这些描述足以确定视觉目标,却不足以确定整个产品的场景结构。
我最初考虑过两种输入。如果输入是三维场景,可以用水面与地形的关系处理岸线,再考虑场景深度、折射和局部倒影。如果输入是二维图片,问题就变成了:怎样保留图像已经提供的外观,只给其中的水增加动态。
“2.5D”本身并不能证明一定是后者。因此,我没有把推断写成需求事实,而是为这次 Demo 确立一个实现前提:以已经裁切、带 Alpha 的湖景图片作为输入,用 Unity 面片承载动态效果。 俯视是目标使用场景,当前演示也保留透视角度,便于观察反射随视角的变化。
另一个需要收敛的是交互。我一开始设想过鼠标激起波纹、波纹遇岸反弹、碰撞以后能量逐级衰减。但那是在回答“怎样模拟水波传播”,不是题目最直接提出的“怎样让静态水体持续呈现水的感觉”。本次保留稳定的周期波动和沿岸变化,不引入波传播状态、碰撞反弹或鼠标交互。
这个取舍决定了后面的结构:不模拟整片水的历史状态,而是在每个时刻计算一个可控的高度场。
实际操作很短:导入湖景图片,启用 Read/Write Enabled,在 Project 中选中它,执行 Tools/Water/SdflizeSelectedMask。编辑器从图片 Alpha 提取范围,再生成 SDF。材质随后绑定原图和生成结果。
生成前只有一张图片;生成后才是“原图+SDF”两张贴图。已经完成的裁切属于输入准备,本工具不负责识别图片语义,也不要求额外提交一张独立蒙版。

图 2:直接从工程素材生成的数据对照,未重新绘制湖景。左列为原图,中央为 Alpha≥128 的二值结果,右列为现有 SDF。原图和 SDF 的文件尺寸可能不同,因为生成器读取的是 Unity 导入后的 Texture2D。
实践中有一处特别重要的边界:Alpha 知道的是图片哪里不透明,不知道哪里在语义上属于水。 对于带沙地的一圈湖景,Alpha 也包含沙地。生成器忠实地对这个轮廓计算距离,之后由材质的岸线偏移把动态水域向内收缩。
这没有改变单图输入的流程,但让我更清楚地定义了工具的能力:它自动处理已有轮廓,不自动找出沙地内侧真正的水陆交界。对于宽窄变化较大的沙滩,一个等距离偏移只能近似控制水效果范围。
最早的测试只有 UV 渐变和一个圆。圆的内部距离可以直接写成 radius - length(uv - 0.5),很适合验证裁剪、相位和边缘衰减,却不能表达任意湖泊形状。

图 3:开发过程中保留的早期截图。此时波纹主要表现为颜色明暗,还没有形成最终的反射法线;它的作用是验证“距离驱动波纹”是否成立。
要把圆替换成图片,我需要的不只是一个 0/1 蒙版,而是“每个像素距离边界多远”。因此加入编辑器距离场生成器。
我分别初始化两组距离:到水内的距离、到水外的距离。目标区域为 0,其他位置为无穷大,然后通过递增与递减扫描传播最近距离。水平和垂直步长为 1,斜向步长为 √2。
signedDistances[i] = distanceToOutside[i] - distanceToInside[i];
结果是内部为正、外部为负。当前算法属于八邻域近似距离,并非精确欧氏 SDF;边界附近使用像素中心距离,没有额外做半像素修正。
选择编辑器预计算,是因为输入轮廓在展示过程中不变化。运行时只需读取已经保存的距离,不必每帧重新处理整张蒙版。需要变化的是相位和表面扰动,不是轮廓本身。
PNG 的灰度不能直接保存负距离,所以需要编码:
encoded = Mathf.Clamp01(0.5f + distance / (2f * range));
Shader 再使用同样的范围解码:
float signedDistance = (encoded * 2.0 - 1.0) * _SdfRangePixels - _WaterEdgeOffsetPx;
生成器当前的范围为 64,材质也必须为 64。材质改成 128 不会增加采样质量,只会让解码距离翻倍,连带改变波纹间距和衰减区间。
范围越大也不总是越好:8 位灰度只有 256 级,±64 的量化间隔约为 0.50 像素,±128 约为 1.00 像素。扩大可表示距离,需要接受更粗的距离精度。它与 Alpha 分割阈值 128 是两件完全不同的事。
主体波沿指定方向变化。个人 W01 定向波练习 使用 uv.x 作为坐标;本次把它推广为 UV 在指定方向上的投影。
岸边波纹则沿距离场变化。相比 W02 圆形波练习 中固定中心的径向距离,这里使用的是到输入轮廓的距离。
float phase = signedDistance * 6.2831853 * _RippleFrequency + _RippleSpeed * timeSeconds;
相同距离具有相同相位,因此波纹能够沿等距离轮廓展开。这里并没有模拟波纹撞击岸边;岸边本身就是生成波纹的坐标依据。
为了让主体波和岸边波不互相混淆,我将控制分成三个层次:
| 控制 | 回答的问题 | 不代表什么 |
|---|---|---|
| Water Edge Offset | 水效果从哪里开始? | 不识别真实沙滩宽度 |
| Shore Fade Width | 主体波从零到完整振幅,过渡多宽? | 不改变传播速度 |
| Edge Fade In / Out | 岸边波纹在哪一段渐强、消失? | 不是随时间退潮 |
最后叠加主体高度和岸边高度。所谓“靠岸减弱”,在这里指振幅包络,不是能量守恒模拟,也不是波速自动降低。
当前保存的参数中,部分淡出距离超过可解码的有效范围。这说明参数面板虽然可调,仍需要结合 SDF 的截断范围理解;不能只看滑条数值就认定波纹一定完成了整段淡出。
只有亮暗条纹时,效果更像在平面上画了一圈线。要让环境反射随水波改变,我需要表面方向的变化。
本次没有增加网格细分或顶点位移,而是分别计算当前位置、U 邻点和 V 邻点的高度。高度差与面片的世界空间轴共同构成两条切向量,再叉乘得到法线。
float3 tangentUWS = input.axisUWS * stepUV + baseNormalWS * (heightX - height);
float3 tangentVWS = input.axisVWS * stepUV + baseNormalWS * (heightY - height);
float3 normalWS = normalize(cross(tangentUWS, tangentVWS));

图 4:历史法线调试画面,不是最终水色。它帮助我检查高度变化是否真的进入法线,而不是仅改变了颜色。
这里最容易混淆的是空间。UV 的 V 方向不等于世界空间 Y。标准 Quad 的局部 X、Y 对应 U、V,旋转到水平以后,再转换成世界空间轴。因此我保留轴向量的缩放,用基础法线承载高度方向,并检查叉乘方向是否与基础法线一致。
这也是本次与 W05 法线练习 的区别:练习使用解析形式,本次用完整高度函数的有限差分,将岸边衰减和扰动一起包含进去。当前实现仍限定于标准 Quad,不能把同一套轴假设直接套到 Unity Plane 上。
正弦波容易形成过于整齐的条纹。我使用 DuDv 贴图的 RG 通道表达二维偏移,通过时间滚动扰动主体波的采样坐标。当前代码保留了二次采样的 Domain Warp;它改变波纹分布,不改变用于裁剪的原始 UV。
这一部分来自 W11 DuDv 和 W12 Domain Warping 的思路。但练习中偏移可以直接构建法线,本次先扰动高度场,再统一计算法线。
我也尝试考虑更细颗粒的额外扰动层,最后没有继续增加。对这个 Demo 来说,新增采样和新增参数不应成为目标本身;如果画面收益不明显,就保留已有层级。
反射方向由视线和动态法线决定:
float3 reflectionDirectionWS = reflect(-viewDirectionWS, normalWS);
float3 reflectionColor = GlossyEnvironmentReflection(reflectionDirectionWS, _Roughness, 1.0);
我沿用了 W09 环境反射 与 W10 粗糙度 的概念,但将练习中的经纬度环境图读取改为 URP 环境反射接口。
菲涅尔使用五次幂近似,空气到水的 F0 取约 0.02。F0 是正视反射率,观察角度变化时最终反射比例仍会变化。额外的 Reflection Strength 是美术控制,不是另一种物理介质参数。

图 5:早期反射测试截图。较强的法线倾斜会改变环境采样方向,所以不应把任何偏黄、偏灰区域都直接判断成光照错误;需要先检查环境图内容和观察方向。
环境反射能读取天空盒记录的景物,但不能自动读取湖景 RGB 中某棵树的位置,也不能保证局部树木的精确倒影。本次未加入实时平面反射、屏幕空间反射、局部探针烘焙或完整 PBR 直接光照。
既然没有真实水下场景,我没有要求 Shader 去折射一个不存在的三维水底,而是将法线沿 U、V 方向的倾斜投影为底图偏移。
这能让原图中的水下细节晃动,但并不求解折射射线与水底的交点。因此我将它称为“模拟折射”,更准确地说是法线驱动的底图 UV 扰动。它借鉴了 W06 与 W07 的表现目标,没有照搬其中的 refract 和镜像重复算法。
当带沙地、水底与深浅细节的湖景加入后,我原本考虑的程序化深浅色已经不再必要。继续叠加颜色逻辑,反而容易覆盖素材本身有效的美术信息。

图 6:开发过程中的湖景截图,说明为什么选择保留原图的静态细节。它不是与图 1 同时、同角度录制的开关对照,不应标成严格的 Before / After。
最终合成同时保留两种采样:原始 UV 读取静态图片,扰动 UV 读取动态水体。两者由经过偏移的 SDF 区分。
clip(baseSample.a - 0.5);
float waterMask = step(0.0, signedDistance);
float3 finalColor = lerp(baseSample.rgb, waterColor, waterMask);
这段代码的重要性不在于复杂,而在于职责明确:图片 Alpha 控制整张素材的保留范围,SDF 控制水效果范围。外围显示原图,内部使用动态结果。当前使用硬分界,不包含冲滩、湿润残留或潮汐动画。
为了调节方便,面板采用英文分组、中英文参数对照,将频率、速度和淡入淡出距离改成滑条。高度预览也从早期的单一主体波归一化,调整为主体波与岸边波共同的高度范围,并在水效果外保留原图。
当前工程包含五张湖景输入、五张对应命名的 SDF、一个水面 Shader,以及一个基础材质和四个材质变体。不同材质通过覆盖图片、SDF 或参数复用同一套计算,这里的 Material Variant 不是编译意义上的 Shader Variant。

图 7:原录像第 6 秒完整画面。场景中可见五个水面对象,Project 中保留原图与 SDF;这是编辑器演示证据,不是独立程序或性能测试。
本次交付选择 Unity 工程与说明,不包含独立可执行程序。流程中已经自动化的是蒙版提取、距离计算、SDF 保存和导入设置;选图、绑定材质与场景布置仍由使用者完成,不能称为完整的一键 AI 管线。
收尾检查也暴露了需要保留的限制:
回头看,这次真正完成的不是一份功能很多的水体清单,而是一条有边界的实现路径:让 AI 图片保留它擅长的静态外观,让距离场提供轮廓约束,让 Shader 负责连续的动态着色。
从原理练习到能放进工程的效果,最重要的变化,是我不再只问“这个公式能做什么”,而是开始问“它应该处理哪一种数据、在哪个空间计算、由哪个参数控制,以及哪些事情不应该交给它做”。
正文图片来源分为项目现有素材、历史开发截图和已有录像帧;未使用生成式图片冒充实现效果。
依据:对照个人网站 Water 001—012 页公开代码与本次工程的 WaterShape.shader。这里记录的是公式和概念关系,不声称逐行照搬。
| 课程 | 原练习重点 | 当前使用方式 |
|---|---|---|
| W01 | 沿 uv.x 的正弦偏移 | EvaluateBodyHeight:改为 UV 与方向的点乘;结果作为高度,不直接偏移颜色采样 |
| W02 | 固定中心的径向距离驱动波纹 | EvaluateEdgeHeight:保留距离进入正弦相位的结构,将径向距离替换为 SDF |
| W03 | 振幅乘线性距离衰减 | 借用振幅包络概念;工程改为基于岸边距离的 smoothstep,不是原线性衰减公式 |
| W04 | 两个圆形波源相加 | 仅迁移叠加思想:工程叠加主体高度与岸边高度;没有照搬双圆心,也没有碰撞模拟 |
| W05 | 解析坡度构造法线 | EvaluateWaterNormalWS:换成完整高度函数的有限差分、世界切向量和叉乘 |
| W06 | refract 折射方向与底图偏移 | 只保留视觉目标。工程投影法线倾斜来扰动底图,没有调用 refract,也没有折射率求交 |
| W07 | 镜像重复修正越界 UV | 工程没有复用 mirrorRepeat;目前使用 saturate 限制 UV,属于不同边界处理 |
| W08 | F0 与视角控制混合 | EvaluateFresnel 保留结构,采用五次幂;公开练习调用的幂参数为 1,不应称逐行照搬 |
| W09 | 反射向量映射到经纬环境图 | 工程用 reflect + GlossyEnvironmentReflection;不自己计算经纬环境 UV |
| W10 | textureLod 控制环境模糊 | 工程向 URP 传入粗糙度,不能直接把练习的 mipLevel=3 等同于材质 Roughness=3 |
| W11 | RG 解码为有符号偏移并滚动 | EvaluateDuDv 复用 RG×2−1 的结构;偏移进入高度场,不直接构造最终 normal |
| W12 | 第一次 DuDv 改变第二次采样位置 | 当前 EvaluateDomainWarp 仍有两次 EvaluateDuDv 调用;额外的独立细节层没有继续扩展 |
WaterSDFMath.cs。Editor/WaterSDFGenerator.cs。No video available.