跳到主要内容
Graphics

面试题作业记录:用 Unity URP 让一张湖景图动起来

Unity URP
Water Shader
SDF
Editor Tools

一次水体 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,裁切图保留原始像素,不做超分辨率放大。

已有编辑器演示,约30秒,无声;不是独立程序。
同一段历史录像的岸边裁切,11秒,无声;未放大补画。

1. 先判断题目需要什么,而不是先决定用多少技术

题目给出的关键词是“2.5D 俯视角”“AI 生成环境”“不同形状的水体”,以及波动、边缘水波纹和反射。这些描述足以确定视觉目标,却不足以确定整个产品的场景结构。

我最初考虑过两种输入。如果输入是三维场景,可以用水面与地形的关系处理岸线,再考虑场景深度、折射和局部倒影。如果输入是二维图片,问题就变成了:怎样保留图像已经提供的外观,只给其中的水增加动态。

“2.5D”本身并不能证明一定是后者。因此,我没有把推断写成需求事实,而是为这次 Demo 确立一个实现前提:以已经裁切、带 Alpha 的湖景图片作为输入,用 Unity 面片承载动态效果。 俯视是目标使用场景,当前演示也保留透视角度,便于观察反射随视角的变化。

另一个需要收敛的是交互。我一开始设想过鼠标激起波纹、波纹遇岸反弹、碰撞以后能量逐级衰减。但那是在回答“怎样模拟水波传播”,不是题目最直接提出的“怎样让静态水体持续呈现水的感觉”。本次保留稳定的周期波动和沿岸变化,不引入波传播状态、碰撞反弹或鼠标交互。

这个取舍决定了后面的结构:不模拟整片水的历史状态,而是在每个时刻计算一个可控的高度场。

2. 从输入开始:一张图片,不是两张蒙版

实际操作很短:导入湖景图片,启用 Read/Write Enabled,在 Project 中选中它,执行 Tools/Water/SdflizeSelectedMask。编辑器从图片 Alpha 提取范围,再生成 SDF。材质随后绑定原图和生成结果。

生成前只有一张图片;生成后才是“原图+SDF”两张贴图。已经完成的裁切属于输入准备,本工具不负责识别图片语义,也不要求额外提交一张独立蒙版。

五组原始湖景、Alpha 阈值结果与已有 SDF 对照

图 2:直接从工程素材生成的数据对照,未重新绘制湖景。左列为原图,中央为 Alpha≥128 的二值结果,右列为现有 SDF。原图和 SDF 的文件尺寸可能不同,因为生成器读取的是 Unity 导入后的 Texture2D。

实践中有一处特别重要的边界:Alpha 知道的是图片哪里不透明,不知道哪里在语义上属于水。 对于带沙地的一圈湖景,Alpha 也包含沙地。生成器忠实地对这个轮廓计算距离,之后由材质的岸线偏移把动态水域向内收缩。

这没有改变单图输入的流程,但让我更清楚地定义了工具的能力:它自动处理已有轮廓,不自动找出沙地内侧真正的水陆交界。对于宽窄变化较大的沙滩,一个等距离偏移只能近似控制水效果范围。

3. 第一阶段:让圆形测试变成任意轮廓

最早的测试只有 UV 渐变和一个圆。圆的内部距离可以直接写成 radius - length(uv - 0.5),很适合验证裁剪、相位和边缘衰减,却不能表达任意湖泊形状。

早期圆形岸边亮度波纹测试

图 3:开发过程中保留的早期截图。此时波纹主要表现为颜色明暗,还没有形成最终的反射法线;它的作用是验证“距离驱动波纹”是否成立。

要把圆替换成图片,我需要的不只是一个 0/1 蒙版,而是“每个像素距离边界多远”。因此加入编辑器距离场生成器。

我分别初始化两组距离:到水内的距离、到水外的距离。目标区域为 0,其他位置为无穷大,然后通过递增与递减扫描传播最近距离。水平和垂直步长为 1,斜向步长为 √2。

signedDistances[i] = distanceToOutside[i] - distanceToInside[i];

结果是内部为正、外部为负。当前算法属于八邻域近似距离,并非精确欧氏 SDF;边界附近使用像素中心距离,没有额外做半像素修正。

选择编辑器预计算,是因为输入轮廓在展示过程中不变化。运行时只需读取已经保存的距离,不必每帧重新处理整张蒙版。需要变化的是相位和表面扰动,不是轮廓本身。

一个实际踩过的坑:64 和 128 不是“采样质量”

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 是两件完全不同的事。

4. 第二阶段:把波浪坐标换成我真正需要的坐标

主体波沿指定方向变化。个人 W01 定向波练习 使用 uv.x 作为坐标;本次把它推广为 UV 在指定方向上的投影。

岸边波纹则沿距离场变化。相比 W02 圆形波练习 中固定中心的径向距离,这里使用的是到输入轮廓的距离。

float phase = signedDistance * 6.2831853 * _RippleFrequency + _RippleSpeed * timeSeconds;

相同距离具有相同相位,因此波纹能够沿等距离轮廓展开。这里并没有模拟波纹撞击岸边;岸边本身就是生成波纹的坐标依据。

为了让主体波和岸边波不互相混淆,我将控制分成三个层次:

控制回答的问题不代表什么
Water Edge Offset水效果从哪里开始?不识别真实沙滩宽度
Shore Fade Width主体波从零到完整振幅,过渡多宽?不改变传播速度
Edge Fade In / Out岸边波纹在哪一段渐强、消失?不是随时间退潮

最后叠加主体高度和岸边高度。所谓“靠岸减弱”,在这里指振幅包络,不是能量守恒模拟,也不是波速自动降低。

当前保存的参数中,部分淡出距离超过可解码的有效范围。这说明参数面板虽然可调,仍需要结合 SDF 的截断范围理解;不能只看滑条数值就认定波纹一定完成了整段淡出。

5. 第三阶段:从颜色波纹走向表面法线

只有亮暗条纹时,效果更像在平面上画了一圈线。要让环境反射随水波改变,我需要表面方向的变化。

本次没有增加网格细分或顶点位移,而是分别计算当前位置、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 上。

6. 第四阶段:保留图片外观,添加流动与反射

流动扰动,不是重新生成湖景

正弦波容易形成过于整齐的条纹。我使用 DuDv 贴图的 RG 通道表达二维偏移,通过时间滚动扰动主体波的采样坐标。当前代码保留了二次采样的 Domain Warp;它改变波纹分布,不改变用于裁剪的原始 UV。

这一部分来自 W11 DuDv 和 W12 Domain Warping 的思路。但练习中偏移可以直接构建法线,本次先扰动高度场,再统一计算法线。

我也尝试考虑更细颗粒的额外扰动层,最后没有继续增加。对这个 Demo 来说,新增采样和新增参数不应成为目标本身;如果画面收益不明显,就保留已有层级。

环境反射,不等于整套 PBR

反射方向由视线和动态法线决定:

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 和镜像重复算法。

7. 最后一次收敛:不要让 Shader 重做 AI 已经画好的东西

当带沙地、水底与深浅细节的湖景加入后,我原本考虑的程序化深浅色已经不再必要。继续叠加颜色逻辑,反而容易覆盖素材本身有效的美术信息。

开发阶段加入带沙地的 AI 湖景

图 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 控制水效果范围。外围显示原图,内部使用动态结果。当前使用硬分界,不包含冲滩、湿润残留或潮汐动画。

为了调节方便,面板采用英文分组、中英文参数对照,将频率、速度和淡入淡出距离改成滑条。高度预览也从早期的单一主体波归一化,调整为主体波与岸边波共同的高度范围,并在水效果外保留原图。

8. 结果、交付与还没有解决的问题

当前工程包含五张湖景输入、五张对应命名的 SDF、一个水面 Shader,以及一个基础材质和四个材质变体。不同材质通过覆盖图片、SDF 或参数复用同一套计算,这里的 Material Variant 不是编译意义上的 Shader Variant。

当前编辑器场景与素材列表

图 7:原录像第 6 秒完整画面。场景中可见五个水面对象,Project 中保留原图与 SDF;这是编辑器演示证据,不是独立程序或性能测试。

本次交付选择 Unity 工程与说明,不包含独立可执行程序。流程中已经自动化的是蒙版提取、距离计算、SDF 保存和导入设置;选图、绑定材质与场景布置仍由使用者完成,不能称为完整的一键 AI 管线。

收尾检查也暴露了需要保留的限制:

  • 八邻域近似与 8 位量化可能影响岸边波纹法线,硬阈值和高频反射也可能使边缘更显眼;没有完成逐项隔离测试,不把锯齿归结为单一原因。
  • SDF 的有效范围有限;偏移后剩余的正距离更短,过大的 Fade 参数未必能够完整走完。
  • 原图的非二次幂缩放影响生成时的像素单位,不能简单把磁盘原图像素等同于导入后 Texture2D 像素。
  • 当前没有 GPU 性能测量,不宣称固定帧率、耗时或具体性能提升。
  • 五组素材已经备齐,但 lake3 的材质实际绑定了 lake2_SDF,而不是已有的 lake3_SDF。此处尚未修正或重新验证,不能把素材数量等同于全部验证通过。
  • DuDv 数据贴图当前启用了 sRGB,可能影响 RG 通道解码出的偏移数值,仍需在线性读取条件下复核;没有将它认定为某一种画面问题的唯一原因。

回头看,这次真正完成的不是一份功能很多的水体清单,而是一条有边界的实现路径:让 AI 图片保留它擅长的静态外观,让距离场提供轮廓约束,让 Shader 负责连续的动态着色。

从原理练习到能放进工程的效果,最重要的变化,是我不再只问“这个公式能做什么”,而是开始问“它应该处理哪一种数据、在哪个空间计算、由哪个参数控制,以及哪些事情不应该交给它做”。

参考与复习入口

正文图片来源分为项目现有素材、历史开发截图和已有录像帧;未使用生成式图片冒充实现效果。

附录:课程到工程的逐项对应

依据:对照个人网站 Water 001—012 页公开代码与本次工程的 WaterShape.shader。这里记录的是公式和概念关系,不声称逐行照搬。

课程原练习重点当前使用方式
W01沿 uv.x 的正弦偏移EvaluateBodyHeight:改为 UV 与方向的点乘;结果作为高度,不直接偏移颜色采样
W02固定中心的径向距离驱动波纹EvaluateEdgeHeight:保留距离进入正弦相位的结构,将径向距离替换为 SDF
W03振幅乘线性距离衰减借用振幅包络概念;工程改为基于岸边距离的 smoothstep,不是原线性衰减公式
W04两个圆形波源相加仅迁移叠加思想:工程叠加主体高度与岸边高度;没有照搬双圆心,也没有碰撞模拟
W05解析坡度构造法线EvaluateWaterNormalWS:换成完整高度函数的有限差分、世界切向量和叉乘
W06refract 折射方向与底图偏移只保留视觉目标。工程投影法线倾斜来扰动底图,没有调用 refract,也没有折射率求交
W07镜像重复修正越界 UV工程没有复用 mirrorRepeat;目前使用 saturate 限制 UV,属于不同边界处理
W08F0 与视角控制混合EvaluateFresnel 保留结构,采用五次幂;公开练习调用的幂参数为 1,不应称逐行照搬
W09反射向量映射到经纬环境图工程用 reflect + GlossyEnvironmentReflection;不自己计算经纬环境 UV
W10textureLod 控制环境模糊工程向 URP 传入粗糙度,不能直接把练习的 mipLevel=3 等同于材质 Roughness=3
W11RG 解码为有符号偏移并滚动EvaluateDuDv 复用 RG×2−1 的结构;偏移进入高度场,不直接构造最终 normal
W12第一次 DuDv 改变第二次采样位置当前 EvaluateDomainWarp 仍有两次 EvaluateDuDv 调用;额外的独立细节层没有继续扩展

本次独立实现与 URP 适配

  • Alpha 提取、八邻域距离变换、正负距离合成、PNG 编解码:WaterSDFMath.cs。
  • 选中纹理生成 SDF、保存、设置数据贴图导入方式:Editor/WaterSDFGenerator.cs。
  • 静态轮廓与动态波形坐标分离、岸线偏移、沙地原图合成:当前项目方案。
  • 标准 Quad 的世界轴、法线方向校正:本次网格约束与空间适配。
  • 环境反射、物体到世界变换、观察方向:URP 提供接口。
  • 中英文材质标签、Range 滑条、高度预览:本次调试与交付可用性工作。