Unity 渲染教程(十):更多复杂的应用场景

本篇文章是Unity C#渲染系列教程的第十部分,关于更多复杂的应用场景。上一次我们介绍了如何使用多张纹理来创建一个复杂材质。在这次的教程中我们将添加更多复杂的特性,同时还要解决多材质编辑的支持问题。

本教程使用Unity 5.4.3f1制作实现。

复杂的材质往往容易显得杂乱无章

1遮挡区域

虽然我们可以创建看起来复杂的材质,但这仅仅是错觉。因为渲染的三角形依然是平的。法线贴图能为我们产生深度的视觉效果,但它仅仅在使用平行光照时才有效。它们没有自阴影(self-shadowing)。有些部分可能比较高,会将阴影投射到相对较低的部分。但是这种情况并不会发生。最明显的情况是在法线贴图显示小孔,凹痕或者裂纹的时候。

例如,当某个人向电路板射击时。子弹并没有击穿电路板,只是在电路板表面留下了弹痕。下图就是发生这种情况的法线贴图。

弹痕电路板法线贴图

当使用法线贴图时,电路板上确实显示出了子弹射击后的凹痕。但是弹痕最深的部分和其他平面一样明亮,它并没有受到弹痕自身更高部分阴影的遮挡。这导致的结果就是看起来弹痕并没有实际上的那么深。

弹痕电路板

1.1遮挡贴图

为了添加自阴影(self-shadowing)这里我们使用了遮挡贴图。你可以把它理解为材质中固定的投影贴图。下图所示的就是我们弹痕的遮挡贴图,它是一张灰度图。

遮挡贴图

使用贴图,我们需要在shader中添加该材质的纹理属性。同时添加一个控制遮挡强度的滑动条,这样能更好的对其进行调整。

[NoScaleOffset] _OcclusionMap ("Occlusion", 2D) ="white"{}

_OcclusionStrength("Occlusion Strength", Range(0, 1)) = 1

与之前对金属贴图的处理一样,我们增加一个shader feature来只对遮罩贴图进行采样,将feature增加到base pass中,现在先不考虑额外的光照问题。

#pragma shader_feature _ _SMOOTHNESS_ALBEDO _SMOOTHNESS_METALLIC

#pragma shader_feature _OCCLUSION_MAP

#pragma shader_feature _EMISSION_MAP

1.2遮挡UI

因为我们有一个自定义的面板GUI,所以需要手动添加一个新的属性到我们shader的UI中。在MyLightingShaderGUI.DoMain中添加oOcclsion。

voidDoMain () {

DoNormals();

DoOcclusion();

DoEmission();

editor.TextureScaleOffsetProperty(mainTex);

}

新的方法几乎和DoMetallic一模一样,同样也包含了一张贴图,一个滑动条和一个关键字。所以我们将方法复制过来并做一些相应的调整。在DoMetallic方法中,如果当没有贴图的时候显示滑动条,这里的方法则相反,是在有贴图的情况下显示滑动条。同样,在Unity的标准shader中使用遮挡贴图的G Color channel(绿色通道),这里我们也使用绿色通道。别忘了在提示信息中给用户提示。

voidDoOcclusion () {

MaterialProperty map = FindProperty("_OcclusionMap");

EditorGUI.BeginChangeCheck();

editor.TexturePropertySingleLine(

MakeLabel(map,"Occlusion (G)"), map,

map.textureValue ? FindProperty("_OcclusionStrength") :null

);

if(EditorGUI.EndChangeCheck()) {

SetKeyword("_OCCLUSION_MAP", map.textureValue);

}

}

检视面板中没有和有遮挡贴图时的情况

1.3添加阴影

在我们的include文件中访问贴图,需要添加一个采样器和一个浮点变量。

sampler2D _OcclusionMap;

float_OcclusionStrength;

创建一个方法来在遮罩贴图存在的时候处理贴图的采样。当不存在的时候,光照应该不受影响,返回的采样值为1。

floatGetOcclusion (Interpolators i) {

#if defined(_OCCLUSION_MAP)

returntex2D(_OcclusionMap, i.uv.xy).g;

#else

return1;

#endif

}

当遮挡强度为0的时候,贴图不应该影响光照。所以方法的返回值为1。当遮挡强度最大时应该恰好等于贴图中的值。我们可以通过调节滑动条获得插值,插值的范围在1和贴图自身数值之间。

returnlerp(1, tex2D(_OcclusionMap, i.uv.xy).g, _OcclusionStrength);

将阴影应用于光照,我们需要在CreateLight方法中用遮挡因子添加光照衰减:

UnityLight CreateLight (Interpolators i) {

UNITY_LIGHT_ATTENUATION(attenuation, i, i.worldPos);

attenuation *= GetOcclusion(i);

light.color = _LightColor0.rgb * attenuation;

light.ndotl = DotClamped(i.normal, light.dir);

returnlight;

}

无遮挡贴图和最大强度遮挡贴图效果对比

1.4间接光照阴影

弹痕已经变暗了,然而还不够。因为在当前场景中大部分的光照信息是间接光照。我们的遮挡贴图并没有针对具体光照情况进行处理,我们需要将它应用到间接光照上。这里需要同时调节漫反射和镜面反射间接光。

UnityIndirect CreateIndirectLight (Interpolators i, float3 viewDir) {

#if defined(FORWARD_BASE_PASS)

floatocclusion = GetOcclusion(i);

indirectLight.diffuse *= occlusion;

indirectLight.specular *= occlusion;

#endif

returnindirectLight;

}

无遮挡贴图和完全遮挡贴图对比

这样处理之后阴影强度大大增加。实际上,他们有些太过明显了。因为遮挡贴图是由表面形状决定的而不是特定光线决定,遮挡贴图应该只在间距关照下起作用。弹痕越深的地方,各个角度的光照应该会变的更暗。当然,阳光直射的情况下弹痕应该是全亮的。所以我们需要把遮挡从直接光照中移除。

UnityLight CreateLight (Interpolators i) {

UNITY_LIGHT_ATTENUATION(attenuation, i, i.worldPos);

//  attenuation *= GetOcclusion(i);

light.color = _LightColor0.rgb * attenuation;

light.ndotl = DotClamped(i.normal, light.dir);

returnlight;

}

是否只受到间接光照影响效果对比

至于遮挡贴图,这是它能达到的最真实效果。需要说明的是我们经常发现许多游戏也将遮挡贴图应用在直线光照中。Unity在旧版本的Shaders也是这么处理的。虽然这么做并不真实,但是也给了艺术家们对光效的更多控制。

什么是屏幕空间的环境遮挡?

SSAO是一个后期处理图像效果,它用深度缓存对每帧的内容创建遮挡贴图。用于提升场景中的景深感。因为它是一个后期处理效果,在图像经过所有灯光渲染后才应用到场景中。这也意味着如果这么处理将导致环境遮挡效果被同时应用在间接光照和直接光照上。导致最终显示效果的不真实。

1.5合并贴图

我们只使用了遮挡贴图中的一个绿色通道(G Channel)。金属效果贴图中的电路存储在红色通道(R Channel),平滑度存储在透明通道(alpha Channel)。这就意味着我们可以将所有的贴图合并到同一张贴图里。下面就是合并后的贴图。

合并金属,遮挡和平滑度贴图到同一张贴图

shader并不知道我们重用了纹理,所有它会对遮挡贴图多做一次采样。但是只使用一张纹理能够节省内存和外存。如果使用DXT5压缩格式,我们同样情况下三张512*512的贴图只需要341KB。这意味着金属材质贴图和遮挡贴图被整合到一个梯度,这可能会降低贴图质量。幸运的是这些贴图通常情况下并不需要十分精细也无需十分准确。所以从日常使用的结果上考虑是可以接受的。

我们可以将它们整合到一个纹理采样吗?

可以,你需要将shader中所有获取采样数据的地方指向同一张贴图。如果你正在对其进行优化,可以去除代码中多余的纹理属性。

例子下载:unitypackage

2遮罩细节

我们的金属电路板缺乏细节。让我们来解决下这个问题。这里有一张反照率贴图和法线贴图。

反照率和法线贴图的细节

导入材质并设置淡出多级渐远纹理(fade out mipmap)。分配材质并将纹理强度设置为1。细节纹理不能太小,设置成3*3的tiling效果适中。

电路板细节

2.1细节遮罩

细节纹理盖了整个球体表面,看起来最终的表现并不是十分理想。金属部分最好不要应用细节纹理。我们需要使用遮罩去控制哪里需要哪里不需要细节纹理。它的工作原理和泼墨图类似,就像我们在教程第三部分,合并材质里使用的一样。不同的是这里的0代表了没有纹理,而1代表了完整的纹理。

使用细节遮罩可以防止金属部分应用细节纹理。另外,这么做也可以在电路板的低凹处减少和消除细节纹理。这样在电路板的弹痕处也就不应用细节纹理了。

遮罩细节

Unity的实例shader使用透明通道(alpha channel)存储细节遮罩信息,所以这里我们可以使用相同的方法。这张图片上的四个通道都存储了相同的值。为我们的shader添加一个该贴图的属性。

[NoScaleOffset] _DetailMask ("Detail Mask", 2D) ="white"{}

许多材质没有细节遮罩,所以需要一个新的shader feature,在base pass和additive pass中使用。

#pragma shader_feature _DETAIL_MASK

添加必要的变量和一个方法为我们获取遮罩数据。

sampler2D _MainTex, _DetailTex, _DetailMask;

floatGetDetailMask (Interpolators i) {

#if defined (_DETAIL_MASK)

returntex2D(_DetailMask, i.uv.xy).a;

#else

return1;

#endif

}

将这张贴图添加到我们UI的emission map和color的下方。在这种情况下,需要一个纹理属性和shader keyword。

voidDoMain () {

DoEmission();

DoDetailMask();

editor.TextureScaleOffsetProperty(mainTex);

}

voidDoDetailMask () {

MaterialProperty mask = FindProperty("_DetailMask");

EditorGUI.BeginChangeCheck();

editor.TexturePropertySingleLine(

MakeLabel(map,"Detail Mask (A)"), mask

);

if(EditorGUI.EndChangeCheck()) {

SetKeyword("_DETAIL_MASK", mask.textureValue);

}

}

使用细节遮罩

2.2反照率细节

使用细节遮罩,我们需要对Include文件进行一些调整。相较于直接使用将反照率乘以细节的方法,我们最终使用的是基于遮罩介于修改和未修改的反照率插值进行替换的方法。就像其他方法一样,让我们把反照率的索引放回我们的函数中。

float3 GetAlbedo (Interpolators i) {

float3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Tint.rgb;

float3 details = tex2D(_DetailTex, i.uv.zw) * unity_ColorSpaceDouble;

albedo = lerp(albedo, albedo * details, GetDetailMask(i));

returnalbedo;

}

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {

//  float3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Tint.rgb;

//  albedo *= tex2D(_DetailTex, i.uv.zw) * unity_ColorSpaceDouble;

float3 specularTint;

floatoneMinusReflectivity;

float3 albedo = DiffuseAndSpecularFromMetallic(

GetAlbedo(i), GetMetallic(i), specularTint, oneMinusReflectivity

);

}

2.3法线细节

我们需要对法线向量进行相同的修改。在这个例子中,在未修改的面向上的切线空间法线向量,是没有对应的细节向量的。所以我们再次基于细节遮罩,用细节向量的原始值与细节遮罩进行差值,来作为新的细节遮罩的值.

voidInitializeFragmentNormal(inout Interpolators i) {

float3 mainNormal =

UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);

float3 detailNormal =

UnpackScaleNormal(tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale);

detailNormal = lerp(float3(0, 0, 1), detailNormal, GetDetailMask(i));

float3 tangentSpaceNormal = BlendNormals(mainNormal, detailNormal);

}

遮罩细节

例子下载:unitypackage

3更多关键字

我们一直使用着色器功能(shader feature)来切换是否开启着功能相关代码,这些代码可以将各种贴图和采样包含到我们的照明方程式中。Unity的标准着色器使用的也是这种方法。这是单一大型着色器(uber shader)的思想。它可以做很多事情,包含很多可以使用的变体。

在标准着色器中同样有切换使用法线贴图和细节贴图的shader功能。当主贴图或细节法线贴图被分配时法线贴图可以被使用。当设置了反照率贴图或线贴图细节时,细节功能被开启。

接下来添加Feature到我们的shader中。与此同时,别忘了也要保证简单切换的前提下做好每个贴图的独立性。首先,让我们基于细节反射率贴图存在这种情况来设置关键字。

voidDoSecondary () {

GUILayout.Label("Secondary Maps", EditorStyles.boldLabel);

MaterialProperty detailTex = FindProperty("_DetailTex");

EditorGUI.BeginChangeCheck();

editor.TexturePropertySingleLine(

MakeLabel(detailTex,"Albedo (RGB) multiplied by 2"), detailTex

);

if(EditorGUI.EndChangeCheck()) {

SetKeyword("_DETAIL_ALBEDO_MAP", detailTex.textureValue);

}

DoSecondaryNormals();

editor.TextureScaleOffsetProperty(detailTex);

}

接下来设置基于主法线贴图存在情况下的关键字。

voidDoNormals () {

MaterialProperty map = FindProperty("_NormalMap");

EditorGUI.BeginChangeCheck();

editor.TexturePropertySingleLine(

MakeLabel(map), map,

map.textureValue ? FindProperty("_BumpScale") :null

);

if(EditorGUI.EndChangeCheck()) {

SetKeyword("_NORMAL_MAP", map.textureValue);

}

}

同样的,设置细节法线贴图存在情况下的关键字。

voidDoSecondaryNormals () {

MaterialProperty map = FindProperty("_DetailNormalMap");

EditorGUI.BeginChangeCheck();

editor.TexturePropertySingleLine(

MakeLabel(map), map,

map.textureValue ? FindProperty("_DetailBumpScale") :null

);

if(EditorGUI.EndChangeCheck()) {

SetKeyword("_DETAIL_NORMAL_MAP", map.textureValue);

}

}

3.1更多的shader变体(shader variants)

要让这些功能生效,我们需要在shader pass中为每一个关键字添加新的shader feature.

首先是基础通道(base pass).

#pragma shader_feature _METALLIC_MAP

#pragma shader_feature _ _SMOOTHNESS_ALBEDO _SMOOTHNESS_METALLIC

#pragma shader_feature _NORMAL_MAP

#pragma shader_feature _OCCLUSION_MAP

#pragma shader_feature _EMISSION_MAP

#pragma shader_feature _DETAIL_MASK

#pragma shader_feature _DETAIL_ALBEDO_MAP

#pragma shader_feature _DETAIL_NORMAL_MAP

接着是附加通道(additive pass)。

#pragma shader_feature _METALLIC_MAP

#pragma shader_feature _ _SMOOTHNESS_ALBEDO _SMOOTHNESS_METALLIC

#pragma shader_feature _NORMAL_MAP

#pragma shader_feature _DETAIL_MASK

#pragma shader_feature _DETAIL_ALBEDO_MAP

#pragma shader_feature _DETAIL_NORMAL_MAP

着色器变体的数量现在增加了很多。但是,要在材质中激活关键字,你必须通过检视面板更改所有相关贴图。否则,着色器的GUI面板将无法正确设置关键字。在创建新的材质贴图时,这不是一个问题,但是在更改后,现有的材质贴图需要刷新后才能实现修改。

3.2使用关键字

为了使用关键字我们需要修改包含文件。首先,GetAlbedo方法可以从细节贴图部分移除。

float3 GetAlbedo (Interpolators i) {

float3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Tint.rgb;

#if defined (_DETAIL_ALBEDO_MAP)

float3 details = tex2D(_DetailTex, i.uv.zw) * unity_ColorSpaceDouble;

albedo = lerp(albedo, albedo * details, GetDetailMask(i));

#endif

returnalbedo;

}

你如何测试代码是否真的起作用了?

当你没有使用细节反照率贴图的时候,理所应当的你无法得到反照率的细节。但是这是因为代码真的忽略了它没有执行,还是因为shader从默认纹理进行了采样呢?

有两种方法可以验证关键字是否如我们所希望的那样运行。第一,临时将默认的纹理替换为其他比较明显的纹理,比如纯白的细节反照率贴图。如果当移除贴图时材质变得太亮,那么意味着代码还包含在里面。或者添加一个临时的#else代码块来做点更明显可以看出来的修改。

接着,我们来处理法线贴图。在这个例子中,我们有四种可能的配置。不存在法线贴图,只有主贴图,只有细节贴图,或者所有的贴图都有。让我们将对这些贴图的采样功能独立出来,移到一个新的方法中。

float3 GetTangentSpaceNormal (Interpolators i) {

float3 mainNormal =

UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);

float3 detailNormal =

UnpackScaleNormal(tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale);

detailNormal = lerp(float3(0, 0, 1), detailNormal, GetDetailMask(i));

returnBlendNormals(mainNormal, detailNormal);

}

voidInitializeFragmentNormal(inout Interpolators i) {

//  float3 mainNormal =

//      UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);

//  float3 detailNormal =

//      UnpackScaleNormal(tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale);

//  detailNormal = lerp(float3(0, 0, 1), detailNormal, GetDetailMask(i));

//  float3 tangentSpaceNormal = BlendNormals(mainNormal, detailNormal);

float3 tangentSpaceNormal = GetTangentSpaceNormal(i);

}

现在重写GetTangentSpaceNormal方法来保证我们更好的应对这四种情况。

要感谢shader编译器的优化,我们可以用两个define来实现条件检测。

float3 GetTangentSpaceNormal (Interpolators i) {

float3 normal = float3(0, 0, 1);

#if defined(_NORMAL_MAP)

normal = UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);

#endif

#if defined(_DETAIL_NORMAL_MAP)

float3 detailNormal =

UnpackScaleNormal(

tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale

);

detailNormal = lerp(float3(0, 0, 1), detailNormal, GetDetailMask(i));

normal = BlendNormals(normal, detailNormal);

#endif

returnnormal;

}

那反照率贴图和颜色如何处理?

Unity的标准着色器假定总是存在一个反照率贴图,所以不为它保留关键字。由于绝大多数材质使用反照率贴图,所以这是一个合理的假设。所以我没有添加albedo关键字。当然你可以自己添加它。

标准着色器也总是使用反照率色调。这个假设有些奇怪,因为许多材质不使用色调,默认为白色。您可以为色调添加关键字,只有在色调设置为白色以外的其他颜色时才启用该关键字。我不喜欢这样做,因为颜色的选择不是使用或不使用纹理的问题。它容易发生意想不到的问题,如动画颜色没有被使用,因为它们最初是白色的。

标准着色器根据自发光颜色设置其自发光的关键字。当颜色设置为黑色时消失。事实上,这是相当多的人难以对自发光颜色进行动画处理的原因,所以我不这样实现。

Uber shaders很方便。但是在特定的项目中,你有机会用尽量少的keywords写出只包含你需要且仅需要的特性的shader。当你考虑优化shader的时候,记得这一点。

例子下载:unitypackage

4编辑多张材质

到目前为止,我们只考虑到了在同一时间编辑一张材质的情况。但是Unity允许用户同时选中多张材质。如果我们在材质中使用了自己编写的shader,那么shader的GUI就应该能在同一时间对其进行修改刷新。你能在预览面板里看到所有选中的材质。

选中两个材质的情况预览

4.1设置太少的Keywords

在同一时间编辑多个材质的功能现在已经可以工作了。然而,还有一个问题。你会发现当使用我们的shader创建两个新的材质时。在同时选中它们后,将一个法线贴图分配给他们。即使他们当前都拥有法线贴图,在编辑后只有第一个才是会使用我们分配的贴图。

只有第一个材质应用了法线贴图

会出现这种现象是因为我们shader的GUI只设置了一个材质的关键字。第一个被选中的材质为编辑器默认的设置目标。

如何确定选中材质的顺序?

在所有具体的实际情况中,排序是任意但确定的。所有你无法依据某个指定的材质进行判断是否第一个被选中。

解决这个问题我们可以为所有选中的材质修改关键字。为了实现这种效果,需要修改shader GUI中的SetKeyword方法。我们要遍历Editor中的targets数组中的材质。这里我们使用foreach的遍历方式,Foreach比较简洁,我们不用当心它的性能问题。

voidSetKeyword (stringkeyword,boolstate) {

if(state) {

foreach(Material mineditor.targets) {

m.EnableKeyword(keyword);

}

}

else{

foreach(Material mineditor.targets) {

m.DisableKeyword(keyword);

}

}

}

foreach是如何工作的?

foreach是for循环的变体。与常规的for相比它有一些开销,因为它创建了临时的迭代器对象。所以我从来没有在app代码中使用,或者在编辑器代码中频繁调用。

如果你需要,可以使用for循环像这样代替它。

Object[] targets = editor.targets;

for(inti = 0; i < targets.Length; i++) {

Material m = targets[i]asMaterial;

}

需要注意的是上面的代码使用了一个临时变量来存储editer.targets属性。而foreach循环中不需要,因为数组对象在foreach中只被引用一次。editor.targets是一个object数组,我们需要显示转换每一个material元素,而foreach循环中直接隐式转换成了Material对象。

进行修改之后,所有的法线贴图就可以在修改贴图或者调整凹凸尺度(bump scale)后正常的显示在所有材质上。

材质都包含了法线贴图

4.2设置过多的关键字

不幸的是,由于我们的修改反而产生了一个新的问题。考虑到选择两种贴图的情况。第一张贴图使用法线贴图,第二种贴图不使用。在这种情况下,基于第一个贴图,bump scale属性在UI上面显示了出来。这样做没有问题,第二种材质会忽略凹凸尺度缩放。然而,当凹凸尺度缩放改变的时候,UI会同时更新两张贴图的关键字。这导致的结果就是所有材质的_NORMAL_MAP关键字都被设置了。所以第二张贴图最终_NORMAL_MAP关键字也会生效,然而它并没有法线贴图!

这种问题不会存在于当材质改变只更新关键字的情况。不幸的是TexturePropertySingleLine合并了两个属性,无法用BeginChangeCheck和EndChangeCheck两个方法来进行区分。这在之前是没问题的,但现在已经不是这样了。

要修复这个问题,我们必须跟踪贴图纹理的修改。我们只有在产生修改的时候更新keyword的值。

voidDoNormals () {

MaterialProperty map = FindProperty("_NormalMap");

Texture tex = map.textureValue;

EditorGUI.BeginChangeCheck();

editor.TexturePropertySingleLine(

MakeLabel(map), map,

tex ? FindProperty("_BumpScale") :null

);

if(EditorGUI.EndChangeCheck() && tex != map.textureValue) {

SetKeyword("_NORMAL_MAP", map.textureValue);

}

}

我们解决了DoNormals方法里的问题,同样的问题也发生在Dometallic,DoOcclusion,DoEmission和DoSecondaryNormals方法里。像DoNrmals方法一样修复所有方法中的问题,现在我们shader的GUI已经支持多材质编辑了!

推荐阅读更多精彩内容