第七章 移动设备着色器适配

在接下来的两章,我们将着手于让我们写的着色器对不同的平台都有较好的性能表现。我们不会讨论任何一个特殊的平台,我们将会分解着色器内的元素,这样的话我们就可以对它们进行调整,从而让它们对于移动平台有更好的优化并且通常来说对其他任何平台来说也更高效。这些技术涵盖了从 了解Unity提供的一些可以减少着色器内存溢出方面的内建变量学习可以让我们的着色器代码更加高效的方法 。这一章将会包含下面的这些知识点:

  • 什么是低成本着色器
  • 着色器的性能分析
  • 针对移动设备修改着色器

介绍

学习如何优化着色器的艺术将会出现在你参与的任何游戏项目中。在任何产品中总有需要优化着色器的时候,或者需要用更少的纹理来产生相同的效果。作为一个技术美术或者着色器编程人员,你必须要理解这些核心的基本原理来优化你的着色器代码从而让你的游戏在提升性能表现的同时又能达到相同的视觉表现。有了这些知识也可以为你自己开始写着色器代码进行铺垫。比如,你知道使用你着色器的游戏将会运行在移动设备中,我们可以自动的设置所有的光照函数使用 half vector 作为视野方向,或者把所有的 浮点型变量类型(float variable types) 都设置成 fixed 类型 或 half 类型。前面提到的这些技术或者很多的其他技术,都可以让你的着色器在目标硬件上更加高效的运行。开始我们的着色器优化学习之旅吧。


什么是低成本着色器

我们首先问一个问题,什么是低成本的着色器,它回答起来可能有点困难因为有太多的元素可以可以让一个着色器变得更加高效了。它可以是你的变量使用的内存的大小。可以是你的着色器使用的纹理的大小。也可是一个工作良好的着色器,但是我们却只使用了相较之前一半的代码或者数据就获得了相同的视觉效果。我们将会在这个知识点中探索一些这样的技术并且会展示如何将这些技术结合起来从而让你的着色器更快更高效,并且不管是在移动设备还是在PC上都生成当今游戏中每个人都期望的高质量的视觉效果。


  • 始前准备
    在开始这个知识点之前,我们需要准备一些资源并且把它们放一块。所以让我们按照下面的几个任务来:

    • 1.创建一个新的场景,并且在场景中添加一个球体和一个方向光。
    • 2.创建一个新的着色器和材质球,并且把着色器应用到材质上。
    • 3.然后把材质球应用到我们刚刚创建的球体。
    • 4.最后,我们修改我们之前创建的着色器让它能使用漫反射纹理和法线贴图,并且创建一个自定义的光线函数。下面的代码展示的是修改后的着色器代码:
    Shader "Custom/MSA"
    {
        Properties
        {
            _MainTex ("Albedo (RGB)", 2D) = "white" {}
            _NormalMap ("Normal Map", 2D) = "bump" {}
        }
        SubShader
        {
            Tags { "RenderType"="Opaque" }
            LOD 200
    
            CGPROGRAM
    
            sampler2D _MainTex;
            sampler2D _NormalMap;
            #pragma surface surf SimpleLambert
    
            struct Input
            {
                float2 uv_MainTex;
                float2 uv_NormalMap;
            };
    
            inline float4 LightingSimpleLambert(SurfaceOutput s, float3 lightDir, float atten)
            {
                float diff = max(0, dot(s.Normal, lightDir));
                float4 c;
                c.rgb = s.Albedo * _LightColor0.rgb * (diff * atten * 2);
                c.a = s.Alpha;
                return c;
            }
    
            void surf (Input IN, inout SurfaceOutput o)
            {
                // Albedo comes from a texture tinted by color
                fixed4 c = tex2D (_MainTex, IN.uv_MainTex);
                o.Albedo = c.rgb;
                o.Alpha = c.a;
                o.Normal = UnpackNormal(tex2D(_NormalMap, IN.uv_NormalMap));
            }
            ENDCG
        }
        FallBack "Diffuse"
    }
    

    现在你应该有如下图所示的一个设置。下面的这个设置将让我们初步了解一些在Unity中使用表面着色器进行优化的基本概念:
    diagram


  • 操作步骤
    我们将构建一个简单的漫反射着色器用来了解几种常用的优化着色器的方法。
    首先,我们将会优化变量类型从而可以让它们在处理数据的时候使用更少的内存:
    1. 让我们从着色器的 输入结构体(struct Input) 着手。当前我们的UV数据是保存在一个 float2 类型的变量中的。我们需要将它改成 half2
    struct Input
    {
      half2 uv_MainTex;
      half2 uv_NormalMap;
    };
    
    1. 接下来我们去修改光照函数,通过如下的变量类型的修改从而减少了变量的内存占用:
    inline fixed4 LightingSimpleLambert(SurfaceOutput s, fixed3 lightDir, fixed atten)
    {
        fixed diff = max(0, dot(s.Normal, lightDir));
        fixed4 c;
        c.rgb = s.Albedo * _LightColor0.rgb * (diff * atten * 2);
        c.a = s.Alpha;
        return c;
    }
    
    1. 最后,我们来修改 surf() 函数中的变量类型,这样就完成了这次的优化。代码如下所示:
    void surf (Input IN, inout SurfaceOutput o)
    {
        // Albedo comes from a texture tinted by color
        fixed4 c = tex2D (_MainTex, IN.uv_MainTex);
        o.Albedo = c.rgb;
        o.Alpha = c.a;
        o.Normal = UnpackNormal(tex2D(_NormalMap, IN.uv_NormalMap));
    }
    
    现在我们优化了我们的变量,接下来我们将利用Unity内建的 光线函数变量(lighting function variable),这样我们就可以控制光线该如何被着色器处理。通过这样做,我们极大的减少着色器需要处理的光线数量。按照下面的代码修改着色器中的 #pragma 声明:
    #pragma surface surf SimpleLambert noforwardadd
    我们之后还会通过让法线贴图和漫反射纹理共享UV来继续优化着色器。为了这个优化,我们简单的通过在 UnpackNormal() 函数中用 _MainTex 的UV替换掉 _NormalMap 的UV,从而修改着色器的 UV查找(UV lookup)
    void surf (Input IN, inout SurfaceOutput o)
    {
        // Albedo comes from a texture tinted by color
        fixed4 c = tex2D (_MainTex, IN.uv_MainTex);
        o.Albedo = c.rgb;
        o.Alpha = c.a;
        o.Normal = UnpackNormal(tex2D(_NormalMap, IN.uv_MainTex));
    }
    
    1. 由于我们已经不再需要法线贴图的UV了,所以我们需要确保在 输入结构体(Input struct) 中移除掉跟法线贴图UV相关的代码:
    struct Input
    {
        half2 uv_MainTex;
    };
    
    1. 最后,我们通过告诉着色器只需要工作在一些特定的渲染器中从而进一步优化我们的着色器:
      #pragma surface surf SimpleLambert exclude_path:prepass noforwardadd

    优化的最终结果显示我们几乎注意不到视效质量上的不同,但我们却减少了这个着色器在屏幕上绘制的次数。我们在下一个知识点将会学习如何确定着色器的绘制次数,这里我们关心的是我们通过更少的数据消耗而取得了相同的效果。所以当你在创建自己的着色器的时候也要留意这一点。下图向我们展示了我们的着色器的最终效果:
    diagram

  • 原理介绍
    现在我们了解了一些能优化我们着色器的方法,现在我们更进一步的探索,去了解这些技术为什么会起作用,并且看一看一些其他的你自己可以尝试的技术。


    我们留意一下每一个变量,当我们定义它们的时候,保存在它们每一个中的数据的大小。如果你熟悉编程,你应该理解你可以定义不同类型大小的值和变量。也就是说一个浮点类型事实上占用了最大的内存消耗。下面的描述向我们更多的陈述了这些变量的细节:

    • Float:一个浮点类型是一个32位精度的值并且是我们看到的三种数据类型中最慢的。它还有与之对应的其他浮点类型值 float2float3float4
    • Half:half 变量类型是一个缩减了位数的16位浮点值,它适合用来保存UV和颜色值,相比于float类型它要快很多。跟上面的浮点类型类似,half类型也有与之对应的其他类型,它们是 half2half3 ,和 half4
    • Fixed :fixed 类型是三种类型中大小最小的,但它可以用于光线计算和颜色计算,它也有与之对应其他fixed类型:fixed2fixed3fixed4

    在优化我们的简单着色器的第二个阶段中,我们在 #pragma 声明中定义了一个 noforwardadd。它是一个基础开关,它会自动告诉Unity任何使用这个特定着色器的游戏对象,只会接收单一方向上的逐像素的光。任何其他被这个着色器计算的光线,都会被强制作为逐顶点光线,被Unity自身生成的球谐值处理。当我们在在场景中放置一个其他类型的光线来照射我们的球体的时候,这一点尤其明显,因为我们的着色器是使用法线贴图做的逐像素操作。


    这很棒,但是如果你想要在场景中添加很多的方向光,并且想控制使用它们中的哪一个作为主要的逐像素光会怎样呢?很好,如果你又留意的话,每一个光都有一个 Render Mode 下拉框。如果你点击这个下拉框,你会看到你又好几个选项可以设置。它们分别是 AutoImportantNot Important。你可以选中一个光线,通过设置渲染模式为 Important 来告诉Unity,比起逐顶点光,这个光线更应该考虑作为一个逐像素光,反之亦然。如果光线的渲染模式保持 Auto ,那就是让Unity来决定最佳的处理方案。


    在你的场景中另外再放置一个光源,并且移除掉着色器当前的主纹理。你将会发现第二个点光源【这里不知道作者的这个点光源什么时候加的,操作步骤里面又没有】对法线贴图没有反应,仅有我们最开始创建的方向光有反应。这里的概念是,通过将其他额外的光作为顶点光来计算从而节省了逐像素操作,通过将主要的方向光作为逐像素光来计算从而节省了性能。下图演示了这个概念,可以看到点光源对法线贴图没有反应:
    diagram


    最后,我们做了一些清理并且简单的让法线贴图纹理使用主纹理的UV数据,我们特地删除了为法线贴图拉取数据部分的代码。这总是让你的代码保持简洁和清理不想要的数据的好方法。


    我们还在 #pragma 声明处定义了 exclude_pass: prepass 这样的话这个着色器就不会再接受来自后渲染器的任何自定义光了。这意味着,如果在已经设置好的主摄像机中,我们能在前渲染器中高效的使用这个着色器。


    只要花一点时间,你就会被着色器尽然可以如此多的优化而震惊。你已经了解到我们是如何将灰度纹理打包到一个单一的RGB纹理中的以及如何使用查找纹理来伪造照明效果。着色器的优化又很多方法,这就是为什么这是一个一开始问起来就模糊不清的问题,但是如果知道这些不同的优化技术,你就可以根据你的游戏和目标平台来定制你的着色器,最终获得一个简洁流畅的着色器和稳定的游戏帧率。