三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity卡通渲染动态积雪Shader实现:风格化材质与程序化遮罩融合

Unity卡通渲染动态积雪Shader实现:风格化材质与程序化遮罩融合

1. 项目概述:当卡通渲染遇上动态积雪

最近在做一个风格化项目,客户想要一个既带有日式卡通质感,又能随着时间“长”出积雪的模型。这听起来像是个缝合需求,但仔细一想,其实挺有意思。卡通渲染(Cel Shading)追求的是色块分明、轮廓清晰的非真实感,而动态积雪则是模拟物理世界的渐进覆盖过程。把这两者结合,本质上是在风格化的视觉框架下,实现一套有逻辑的动态材质变化。

这个项目的核心,就是解决“风格”与“动态”的兼容问题。我们不是要做一个物理精确的积雪模拟(那太耗性能),而是要设计一套视觉上合理、控制上便捷的“卡通感”积雪生长系统。最终效果是,模型表面会像被一层卡通风格的“雪漆”逐渐覆盖,从无到有,并且能通过C#脚本实时控制积雪的厚度、范围和生长速度。这不仅能用于雪景游戏角色、场景装饰,对于需要表现“腐蚀”、“覆盖”、“能量充盈”等状态变化的风格化项目,也提供了可复用的思路。

2. 核心思路拆解:卡通渲染与动态遮罩的化学反应

要实现“长雪”,关键在于理解卡通渲染的底层逻辑,并找到与之适配的动态控制方法。传统的卡通渲染Shader,其光照模型通常是阶梯化的(如兰伯特光照结果经过ceilstep函数处理),并辅以色块贴图(Ramp Texture)和轮廓描边。而动态积雪,本质上是一个随时间变化、受法线方向影响的遮罩(Mask)。

2.1 卡通渲染基础框架选择

在Unity中,尤其是URP(通用渲染管线)或内置管线,实现卡通渲染有几种常见路径:

  1. 表面着色器改造:对于内置管线,修改Surface Shader的光照函数是最直接的方式。
  2. 顶点/片元着色器重写:更灵活,适用于URP或需要深度定制的场景。
  3. Shader Graph可视化编程:对于不熟悉HLSL/GLSL的开发者,Amplify Shader Editor或Unity官方的Shader Graph是很好的选择,它们能直观地构建节点网络。

考虑到我们需要深度集成动态积雪逻辑,并且要提供完整的C#控制接口,直接编写一个自定义的顶点/片元着色器是可控性最强的方案。这样我们可以完全掌控从顶点数据到最终颜色的每一个环节,方便插入我们的积雪计算逻辑。

2.2 动态积雪的核心原理:世界空间法线与遮罩

积雪效果模拟的核心思想很简单:表面法线方向越朝上(与世界空间上方向量夹角越小),越容易积雪。我们通过计算模型顶点或片元的法线在世界空间中的Y分量(即朝上的程度),来生成一个初始的积雪权重。

但这个权重是静态的。要让雪“长”起来,我们需要引入动态因素:

  • 积雪阈值:一个可动态调整的浮点数。只有当某点的“积雪倾向”(如法线Y分量)大于这个阈值时,该点才被视为被雪覆盖。
  • 时间或进度参数:通过C#脚本控制这个阈值从1(完全无雪,因为法线Y分量最大为1)向0(完全积雪)变化,就能模拟出积雪从山顶(法线最朝上的地方)逐渐向山下蔓延的动态过程。

我们将这个动态计算出的遮罩,称为“积雪遮罩”(Snow Mask)。这个遮罩的值在0到1之间,0代表无雪(显示原材质),1代表完全被雪覆盖。

2.3 风格化融合策略

有了积雪遮罩,下一步是如何让它与卡通渲染风格融合。简单粗暴地用遮罩在“原材质颜色”和“雪白色”之间做线性插值(lerp),会显得很假,丢失卡通感。我们的策略是:

  1. 分别渲染:分别计算模型原本的卡通着色结果和“雪材质”的卡通着色结果。“雪材质”可以有自己的基础色、色块贴图,甚至略微不同的光照响应,以模拟雪的不同质感。
  2. 遮罩混合:使用计算好的动态积雪遮罩,对这两个着色结果进行混合。这样,被雪覆盖的部分,其光照、阴影也遵循卡通渲染的规则,整体风格才能统一。

3. Shader实现详解:从顶点到片元的卡通雪世界

接下来,我们一步步构建这个Shader。这里以Unity URP下的HLSL着色器为例,因为URP是目前更主流的管线。核心代码会嵌入详细注释。

3.1 着色器属性与变量定义

首先,在Shader的Properties块和CBUFFER中定义我们需要用到的所有参数。

Shader "Custom/ToonSnow" { Properties { // 基础材质属性 _BaseColor ("Base Color", Color) = (1,1,1,1) _BaseMap ("Base Map", 2D) = "white" {} // 卡通渲染关键属性 _RampMap ("Ramp Texture", 2D) = "white" {} // 色块贴图 _ShadowThreshold ("Shadow Threshold", Range(0, 1)) = 0.5 // 明暗分界阈值 _ShadowSmoothness ("Shadow Smoothness", Range(0, 0.1)) = 0.02 // 过渡平滑度 _OutlineColor ("Outline Color", Color) = (0,0,0,1) _OutlineWidth ("Outline Width", Range(0, 0.1)) = 0.03 // 雪材质属性 _SnowColor ("Snow Color", Color) = (0.9,0.92,0.95,1) _SnowRampMap ("Snow Ramp Texture", 2D) = "white" {} // 积雪控制属性 _SnowDirection ("Snow Direction", Vector) = (0, 1, 0, 0) // 雪下落方向,通常为(0,1,0) _SnowAmount ("Snow Amount", Range(0, 1)) = 0.5 // 积雪总量,由脚本控制 _SnowCoverage ("Snow Coverage", Range(0, 1)) = 0.2 // 积雪覆盖度,影响“长”的范围 _SnowHardness ("Snow Hardness", Range(1, 10)) = 3 // 积雪边缘硬度 } SubShader { // 第一个Pass:渲染轮廓描边(使用背面膨胀法) Pass { Name "Outline" Tags { "LightMode" = "SRPDefaultUnlit" } Cull Front // 只渲染背面 HLSLPROGRAM // ... 轮廓Pass的具体代码,后面详述 ENDHLSL } // 第二个Pass:渲染主体(卡通光照+积雪) Pass { Name "ForwardLit" Tags { "LightMode" = "UniversalForward" } Cull Back HLSLPROGRAM // 关键:包含URP的核心光照库 #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" // 定义顶点着色器和片元着色器 #pragma vertex vert #pragma fragment frag // 声明属性对应的变量 TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); TEXTURE2D(_RampMap); SAMPLER(sampler_RampMap); TEXTURE2D(_SnowRampMap); SAMPLER(sampler_SnowRampMap); float4 _BaseColor; float4 _SnowColor; float _ShadowThreshold; float _ShadowSmoothness; float3 _SnowDirection; float _SnowAmount; float _SnowCoverage; float _SnowHardness; // 顶点着色器输入结构 struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; }; // 顶点着色器输出/片元着色器输入结构 struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWS : TEXCOORD1; float3 positionWS : TEXCOORD2; }; // 顶点着色器 Varyings vert(Attributes input) { Varyings output; // 将顶点位置和法线从物体空间变换到世界空间 VertexPositionInputs positionInputs = GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs normalInputs = GetVertexNormalInputs(input.normalOS); output.positionCS = positionInputs.positionCS; output.positionWS = positionInputs.positionWS; output.normalWS = normalInputs.normalWS; output.uv = input.uv; return output; } // **核心函数:计算积雪遮罩** float CalculateSnowMask(float3 worldNormal, float3 snowDir, float amount, float coverage, float hardness) { // 1. 计算法线与雪方向的点积,归一化到[0,1]区间。值越大表示越朝上。 float dotProduct = dot(worldNormal, normalize(snowDir)); float snowWeight = saturate(dotProduct * 0.5 + 0.5); // 从[-1,1]映射到[0,1] // 2. 应用积雪总量(_SnowAmount)和覆盖度(_SnowCoverage)。 // _SnowAmount控制积雪的“厚度”或“进度”,_SnowCoverage影响积雪的基准线。 // 当snowWeight大于 (coverage - amount) 时,开始出现积雪。 float threshold = coverage - amount; // 3. 使用smoothstep进行平滑过渡,_SnowHardness控制边缘锐利度。 // 公式:smoothstep(edge0, edge1, x) 当x在[edge0, edge1]之间时,返回0到1的平滑插值。 // 我们让edge0和edge1根据hardness和threshold动态变化,以控制过渡带宽度。 float edge0 = threshold - (1.0 / hardness) * 0.1; float edge1 = threshold + (1.0 / hardness) * 0.1; float mask = smoothstep(edge0, edge1, snowWeight); return mask; } // **核心函数:卡通化光照计算** float3 ToonShading(float3 albedo, float3 normalWS, float3 lightDir, float3 lightColor, Texture2D rampTex, SamplerState rampSampler) { // 经典兰伯特光照 float NdotL = dot(normalize(normalWS), normalize(lightDir)); // 应用阈值和平滑度,实现阶梯化阴影 float shadow = smoothstep(_ShadowThreshold - _ShadowSmoothness, _ShadowThreshold + _ShadowSmoothness, NdotL); // 通过色块贴图采样,将连续的shadow值映射到卡通色阶 float3 ramp = SAMPLE_TEXTURE2D(rampTex, rampSampler, float2(shadow, 0.5)).rgb; // 结合光照颜色和材质底色 return albedo * ramp * lightColor; } // 片元着色器 float4 frag(Varyings input) : SV_Target { // 获取基础纹理和颜色 float4 baseTex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); float3 baseAlbedo = baseTex.rgb * _BaseColor.rgb; // 获取主光源信息(URP函数) Light mainLight = GetMainLight(); float3 lightDir = mainLight.direction; float3 lightColor = mainLight.color; // 计算积雪遮罩 float snowMask = CalculateSnowMask(input.normalWS, _SnowDirection, _SnowAmount, _SnowCoverage, _SnowHardness); // 分别计算基础材质和雪材质的卡通着色结果 float3 baseToonColor = ToonShading(baseAlbedo, input.normalWS, lightDir, lightColor, _RampMap, sampler_RampMap); float3 snowToonColor = ToonShading(_SnowColor.rgb, input.normalWS, lightDir, lightColor, _SnowRampMap, sampler_SnowRampMap); // 使用积雪遮罩混合两种颜色 float3 finalColor = lerp(baseToonColor, snowToonColor, snowMask); // 可以额外添加一些高光或边缘光来增强卡通感和雪的质感(此处省略以保持核心清晰) return float4(finalColor, 1.0); } ENDHLSL } } }

注意:轮廓描边Pass的实现:上面代码省略了轮廓Pass。常用方法是“法线外扩”,即在顶点着色器中,沿顶点法线方向(在视图空间)将顶点位置向外挤出一定距离(_OutlineWidth)。这个Pass使用纯色(_OutlineColor)渲染,且只渲染背面(Cull Front)。实现时需注意在视图空间计算,以避免透视变形。网上有大量成熟方案,这里不展开占用篇幅。

3.2 关键参数解析与艺术指导

  • _SnowAmount:这是最核心的控制参数,C#脚本主要就是驱动它从0到1变化。值为0时,threshold = coverage - 0 = coverage,只有法线最朝上的部分(snowWeight > coverage)才有雪,雪量最少。值为1时,threshold = coverage - 1,这个值通常为负,导致几乎所有snowWeight > 负数的区域(即绝大部分区域)都被雪覆盖。
  • _SnowCoverage:可以理解为“雪线的海拔”。调高此值,雪会更集中在模型顶部;调低,雪更容易覆盖侧面。它与_SnowAmount配合,可以创造出“新雪轻轻覆盖山顶”或“暴风雪后连侧面都积雪”的不同效果。
  • _SnowHardness:控制积雪边缘的软硬程度。值越大,积雪与非积雪区域的边界越清晰锐利,卡通感更强;值越小,边界越模糊,过渡更自然,适合想要稍微柔和一些的风格。
  • _RampMap_SnowRampMap:这是塑造卡通质感的关键。通常是一张一维渐变纹理(水平方向从黑到白,或从暗色调到亮色调)。_SnowRampMap可以使用对比度更低、颜色更冷的渐变,来模拟雪地柔和的反光特性。

4. C#控制脚本:让雪“长”起来的魔法

Shader准备好了,我们需要一个脚本来驱动_SnowAmount等属性,实现动态变化。这个脚本将挂在需要“长雪”的模型上。

using UnityEngine; [RequireComponent(typeof(Renderer))] public class DynamicSnowController : MonoBehaviour { private Renderer _targetRenderer; private MaterialPropertyBlock _propBlock; // 公开可调参数,方便在Inspector中或由其他脚本控制 [Header("Snow Growth Settings")] [SerializeField, Range(0f, 1f)] private float _snowAmount = 0f; [SerializeField, Range(0f, 1f)] private float _targetSnowAmount = 1f; [SerializeField] private float _growthSpeed = 0.2f; // 生长速度(单位/秒) [Header("Advanced Control")] [SerializeField, Range(0f, 1f)] private float _snowCoverage = 0.2f; [SerializeField, Range(1f, 10f)] private float _snowHardness = 3f; [SerializeField] private bool _useWorldSpaceSnowDir = true; [SerializeField] private Vector3 _customSnowDirection = Vector3.up; // 属性访问器,方便外部脚本控制 public float SnowAmount { get => _snowAmount; set { _snowAmount = Mathf.Clamp01(value); UpdateMaterialProperties(); } } public float TargetSnowAmount { get => _targetSnowAmount; set => _targetSnowAmount = Mathf.Clamp01(value); } void Start() { _targetRenderer = GetComponent<Renderer>(); _propBlock = new MaterialPropertyBlock(); // 初始更新一次材质属性 UpdateMaterialProperties(); } void Update() { // 每帧向目标积雪量逼近,实现平滑生长/消融 if (!Mathf.Approximately(_snowAmount, _targetSnowAmount)) { _snowAmount = Mathf.MoveTowards(_snowAmount, _targetSnowAmount, _growthSpeed * Time.deltaTime); UpdateMaterialProperties(); } } /// <summary> /// 更新材质属性块,这是高效修改材质参数的方式,避免创建新的Material实例。 /// </summary> private void UpdateMaterialProperties() { if (_targetRenderer == null || _propBlock == null) return; _targetRenderer.GetPropertyBlock(_propBlock); // 设置Shader中的属性 _propBlock.SetFloat("_SnowAmount", _snowAmount); _propBlock.SetFloat("_SnowCoverage", _snowCoverage); _propBlock.SetFloat("_SnowHardness", _snowHardness); // 设置雪方向 Vector3 snowDir = _useWorldSpaceSnowDir ? Vector3.up : _customSnowDirection.normalized; _propBlock.SetVector("_SnowDirection", snowDir); _targetRenderer.SetPropertyBlock(_propBlock); } /// <summary> /// 立即设置积雪量(无过渡) /// </summary> public void SetSnowAmountImmediate(float amount) { _snowAmount = Mathf.Clamp01(amount); _targetSnowAmount = _snowAmount; // 同时更新目标值,防止Update函数再插值 UpdateMaterialProperties(); } /// <summary> /// 开始生长积雪(平滑过渡到目标值) /// </summary> public void StartSnowGrowth(float targetAmount) { TargetSnowAmount = targetAmount; } /// <summary> /// 重置积雪(平滑或立即) /// </summary> public void ResetSnow(bool immediate = false) { if (immediate) { SetSnowAmountImmediate(0f); } else { TargetSnowAmount = 0f; } } // 在Inspector中修改参数时,实时更新预览(仅在编辑器下生效) #if UNITY_EDITOR void OnValidate() { // 防止在播放模式外频繁调用 if (UnityEditor.EditorApplication.isPlaying) { UpdateMaterialProperties(); } } #endif }

脚本核心要点:

  1. 使用MaterialPropertyBlock:这是最佳实践。直接修改Material的属性会创建新的材质实例,导致内存浪费。MaterialPropertyBlock允许我们为每个渲染器实例设置独立的属性,高效且不会破坏材质球的共享。
  2. 平滑过渡Update函数中使用Mathf.MoveTowards实现积雪量的线性插值,使生长过程平滑自然。_growthSpeed控制了变化速率。
  3. 灵活的API:提供了属性访问器(SnowAmount)和方法(SetSnowAmountImmediate,StartSnowGrowth,ResetSnow),方便其他系统(如游戏逻辑、动画器、Timeline)以不同方式控制积雪效果。
  4. 编辑器友好OnValidate方法确保了在Unity编辑器内调整参数时,能实时看到模型上的反馈,极大提升了美术和设计人员的调试效率。

5. 实战调试与性能优化心得

在实际项目中应用这套方案,会遇到一些典型问题。这里分享我的调试经验和优化技巧。

5.1 常见视觉问题与修复

  • 问题:积雪边缘闪烁或锯齿严重。

    • 原因_SnowHardness值过大,且smoothstep的过渡区间在屏幕像素级别变化剧烈,导致在运动或特定视角下出现走样。
    • 解决
      1. 适当降低_SnowHardness,增加过渡带的宽度。
      2. 在片元着色器中,对计算积雪遮罩所用的法线进行简单的平滑处理。可以使用屏幕空间导数(fwidth)来估算变化率,进行边缘抗锯齿(AA)。例如:
        float snowMask = CalculateSnowMask(input.normalWS, ...); float maskDerivative = fwidth(snowMask); snowMask = smoothstep(0 - maskDerivative, 0 + maskDerivative, snowMask);
  • 问题:雪在模型接缝或低多边形区域呈现不自然的块状。

    • 原因:顶点法线在硬边处不连续,导致基于顶点或片元法线计算的snowWeight突变。
    • 解决
      1. 模型阶段:确保模型在需要平滑积雪的区域使用了软边(Smooth Normals)。
      2. Shader阶段:如果无法修改模型,可以尝试在着色器中对世界空间法线进行简单的邻域平滑。但这需要额外的纹理采样(如法线贴图)或屏幕空间后处理,性能开销较大。一个折中方案是使用一张额外的、预烘焙的“积雪流向图”(Snow Flow Map)来覆盖或引导遮罩,但这属于美术工作流范畴。
  • 问题:卡通阴影与积雪遮罩结合后,明暗交界处颜色怪异。

    • 原因:基础材质和雪材质使用了不同的_RampMap,导致在混合区域,光照的阶跃变化不一致。
    • 解决:确保_SnowRampMap的色阶过渡与_RampMap在视觉风格上协调。有时让它们使用同一张ramp图反而更安全。如果想做出雪更柔和的感觉,可以单独调整雪材质的_ShadowThreshold_ShadowSmoothness属性,这需要在Shader中为雪材质也暴露这些控制参数。

5.2 性能考量与优化

  • Draw Call:这套方案在一个Draw Call内完成,使用了2个Pass(轮廓+主体)。轮廓Pass会增加额外的渲染开销,如果场景中此类物体极多,可以考虑在移动平台或低端设备上关闭轮廓效果。
  • Shader复杂度:片元着色器中进行了两次完整的卡通光照计算(基础+雪),并有一次smoothsteplerp。对于性能敏感的平台:
    • 可以简化卡通光照模型,例如使用更简单的阈值阴影而非纹理采样。
    • 如果雪材质和基础材质的光照响应非常接近,可以只计算一次光照,然后只用积雪遮罩去混合albedo(底色),但这会牺牲雪的独立质感。
  • 使用GPU Instancing:如果大量物体使用同一个材质和Shader,且只有_SnowAmount等少数属性不同,可以开启GPU Instancing来大幅提升渲染效率。确保在Shader的CBUFFER中正确声明这些每实例属性(如UNITY_INSTANCING_BUFFER_START...UNITY_INSTANCING_BUFFER_END),并在C#脚本中使用MaterialPropertyBlock配合Graphics.DrawMeshInstanced或渲染器的EnableInstancing

5.3 扩展思路

这个框架的潜力不止于雪。你可以把“雪”理解为一种“覆盖物”。通过替换_SnowColor_SnowRampMap和调整遮罩计算逻辑,你可以轻松实现:

  • 泥土覆盖:将雪色换成土褐色,调整方向为从下往上(_SnowDirection = (0, -1, 0)),模拟角色在泥地行走后下半身沾满泥土的效果。
  • 能量充能/腐蚀:用发光的能量色或腐蚀的暗紫色作为覆盖色,将遮罩计算与时间、距离等参数绑定,实现武器充能、护盾衰减或环境腐蚀的效果。
  • 多图层混合:甚至可以定义多个遮罩和覆盖材质,实现更复杂的多层材质混合,比如“基础岩石 + 苔藓 + 积雪”的三层效果。

这套“卡通渲染 + 动态遮罩”的组合拳,其核心价值在于提供了一种在风格化视觉体系中,实现动态、可程序化控制的表面效果的方法论。它平衡了表现力、性能和可控性,希望能为你的下一个风格化项目带来一些灵感。

← 返回列表