我是靠谱客的博主 生动大白,最近开发中收集的这篇文章主要介绍UnityShader 表面着色器简单例程集合0.前言1.表面着色器概述2.表面着色器:自定义光照函数BasicDiffuse3.表面着色器:Diffuse Shading—漫反射光照改善技巧4.让Texture动起来:UV动画与sprite sheet5.制作一个静态Cubemap,并在shader中使用它6. 法线贴图理论详解以及在shader中的使用7.菲涅尔效果及布料shader8. 使用Queue Tags 来控制渲染顺序9. 卡通风格的实现,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

0.前言

这些简单的shader程序都是写于2015年的暑假。当时实验室空调坏了,30多个人在实验室中挥汗如雨,闷热中学习shader的日子还历历在目。这些文章闲置在我个人博客中,一年将过,师弟也到了学shader的时候,这些例程虽然很简单,刚接触shader时却可以练练手,所以从个人博客中中搬了出来。而对于有一个月以上shaderLab编程经验的同学来说,这篇文章可以不用看了:-)

1.表面着色器概述

表面着色器只存在于Unity中,算是Unity微创新自创的一套着色器标准。它使得shader的书写门槛降低,使shader技术更容易使用。表面着色器的一些特性如下:

  • SurfaceShader可以看成是一个光照VS/FS的生成器,它减少了开发者重复编写代码的工作。

  • SurfacebShader的语句编写在CGPROGRAM...ENDCG块内,而且SurfacebShader不允许有pass,它自己会编译成多个Pass。

  • SurfacebShader使用一个编译指令来声明它是一个表面着色器。

表面着色器的三要素是:编译指令、输入结构、输出结构

① SrufaceShader的编译指令

#pragma surface surfaceFunction lightModel [optionalparams]

这个编译指令的参数可分为以下两类:

  • 必需的参数
    • surfaceFunction:表示Cg函数中有表面着色器(surface shader)代码。这个函数的格式应该是这样:void surf (Input IN,inout SurfaceOutput o), Input是你自己定义的结构。Input结构中应该包含所有纹理坐标(texture coordinates)和表面函数(surfaceFunction)所需要的额外的必需变量。

    • lightModel :光照模型,内置的光照模型有Lambert与BlinnPhong。我们也可以定义自己的光照模型在这里作为指令的参数(在后面进行解释)。关于Lambert与BlinnPhong光照模型可以参考这篇文章:常见光照模型解析

  • 可选参数[optionalparams]:这些参数的内容可以参考官方文档 : 官方文档

② SurfaceShader的输入结构

当SurfaceShader编译指令指定了表面函数surf与一个Lambert漫反射光照模型,这时编译指令是这样的:

#pragma surface surf Lambert

这个surf就是表面函数了,表面函数的声明如下所示:

void surf (Input IN, inout SurfaceOutput o)

可以看到,surf函数含有两个参数,第一个是Input类型的IN,Input是什么类型?实际上,Input是你自己写定义的输入结构,这个结构通常拥有着色器需要的所有纹理坐标信息,这个纹理坐标必须被命名为“uv”后接纹理名,或者是uv2开始,即使用第二纹理坐标集,除了纹理的UV信息,你也可以在结构中输入其他着色函数需要的数据,这些数据包括:

  • float3 viewDir - 视图方向( view direction)值。为了计算视差效果(Parallax effects),边缘光照(rim lighting)等,需要包含视图方向( view direction)值。
  • float4 with COLOR semantic -每个顶点(per-vertex)颜色的插值。
  • float4 screenPos - 屏幕空间中的位置。 为了反射效果,需要包含屏幕空间中的位置信息。比如在Dark Unity中所使用的 WetStreet着色器。
  • float3 worldPos - 世界空间中的位置。
  • float3 worldRefl - 世界空间中的反射向量。如果表面着色器(surface shader)不写入法线(o.Normal)参数,将包含这个参数。 请参考这个例子:Reflect-Diffuse 着色器。
  • float3 worldNormal - 世界空间中的法线向量(normal vector)。如果表面着色器(surface shader)不写入法线(o.Normal)参数,将包含这个参数。
  • float3 worldRefl; INTERNAL_DATA - 世界空间中的反射向量。如果表面着色器(surface shader)不写入法线(o.Normal)参数,将包含这个参数。为了获得基于每个顶点法线贴图( per-pixel normal map)的反射向量(reflection vector)需要使用世界反射向量(WorldReflectionVector (IN, o.Normal))。请参考这个例子: Reflect-Bumped着色器。
  • float3 worldNormal; INTERNAL_DATA -世界空间中的法线向量(normal vector)。如果表面着色器(surface shader)不写入法线(o.Normal)参数,将包含这个参数。为了获得基于每个顶点法线贴图( per-pixel normal map)的法线向量(normal vector)需要使用世界法线向量(WorldNormalVector (IN, o.Normal))。

例如下面是一个我们自己定义的结构:

    //输入结构    
   struct Input     
   {    
        float2 uv_MainTex;//纹理贴图    
        float2 uv_BumpMap;//法线贴图    
        float3 viewDir;//观察方向    
    }; 
       
    

③ 表面着色器的标准输出结构

要书写Surface Shader,了解表面着色器的标准输出结构必不可少,定义一个表面函数(上面的surf),需要用自定义的输入结构来输入相关的UV或数据信息,并在表面函数体内填充输出结构SrufaceOutput.surfOutput描述的是表面的特性:反射率、法向量、自发光、镜面反射度、光泽度、透明度。这部分代码是使用CG或者是HLSL来编写的。

顶点着色器计算了需要填充输入什么,输出什么相关的信息,并产生真实的顶点/像素着色器,以及把渲染路径传递到正向或延时渲染路径。

那么,这个标准的输出结构是这样的:

    struct SurfaceOutput   
    {  
        half3 Albedo;            //反射率,也就是纹理颜色值(r,g,b)   
        half3 Normal;            //法线,法向量(x, y, z)   
        half3 Emission;          //自发光颜色值(r, g,b)   
        half Specular;           //镜面反射度   
        half Gloss;              //光泽度  
        half Alpha;              //透明度  
    };  
    

而这个结构体的成员,会在sruf函数中进行赋值。比如这样:

    //表面着色函数的编写  
    void surf (Input IN, inout SurfaceOutput o)  
    {  
        //反射率,也就是纹理颜色值赋为(0.6, 0.6, 0.6)  
           o.Albedo= 0.6;  
        //  材质表面光泽度0.8
           o.Gloss = 0.8; 
    }
    

④ 表面着色器简单程序示例

下面建立一个简单的表面着色器,包含了上面所说的表面着色器三要素,可以看着代码结合上面的解说进行理解。

     Shader "Example/Diffuse Simple" {
        P
        SubShader {
        
          Tags { "RenderType" = "Opaque" }
          
          //表面着色器代码写在CGPROGRAM...ENDCG块中
          CGPROGRAM
          
          //要素一:编译指令
          #pragma surface surf Lambert
          
          //要素二:自定义输入结构
          struct Input {
              float4 color : COLOR;
          };
          
          //要素三:标准输入结构SurfaceOutput
          
          void surf (Input IN, inout SurfaceOutput o) {
              o.Albedo = 1; //把颜色调为(1,1,1)即白色
          }
          
          ENDCG
        }
        
        Fallback "Diffuse"
      }
      

运行结果是:

610439-20160215010417111-1796350475.png

2.表面着色器:自定义光照函数BasicDiffuse

前面我们介绍了表面着色器的特性以及它的三要素,也就是

  • 编译指令
  • 自定义输入结构
  • 输出结构

编译指令:

#pragma surface surfaceFunction lightModel [opeionalparams]

我们说surfaceFunction一般是命名为surf,也可以换成其他的函数名,只要和编译指令中指定的表面函数名对应上就好。对于lightModel(光照模型),Unity内置的光照模型是Lambert(漫反射)和BlinnPhong(镜面放射)。但是有时候我们想使用自己的光照函数来实现特殊的效果,那么我们需要提供自己的光照函数。具体应该怎么做呢?

①使用内置光照模型函数

我们先来看看使用默认光照模型(Lambert)的表面着色器。代码如下:

  Shader "MyShader/Biild_in LightingModle:Lambert"
{
    Properties
    {
        //定义一些变量,可在监视面板中看到
        
        _EmissiveColor("Emissive Color",Color)= (1,1,1,1)
        _AmbientColor("Ambient Color",Color) = (1,1,1,1)
        _Slider("Slider",Range(1,10))=5
    }
    SubShader
    {
        CGPROGRAM
        //这里使用了内置光照模型Lambert
        #pragma surface surf Lambert
        
        //Properties中声明的变量在这里要重新声明,以便下面的代码使用
        float4 _EmissiveColor;
        float4 _AmbientColor;
        float _Slider;

        //输入结构
        struct Input
        {
            //包含了uv信息 ,注意变量必须以 uv开头
            float2 uv_MainTex;
        };
        
        //表面函数
        void surf(Input IN,inout SurfaceOutput o)
        {
            //填充SrufaceOutput结构
            float4 c;
            c = pow((_EmissiveColor+_AmbientColor),_Slider);
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }
        ENDCG 
    }
    FallBack "Diffuse"
}

相应的注释都写在代码注释中了,如果看了上一篇博客的话,这段代码应该不难理解。这段代码使用了Unity内置的光照模型Lambert,定义了自发光与环境光属性,并设置一个滑动条以改变物体颜色。在Unity中查看该段shader效果:

610439-20160215010749564-1930483209.png

②使用自定义光照函数:准备工作

下面,我们将使用自己写的光照函数来替换掉Unity内置的光照模型Lambert。假设我们的光照函数为BasicDiffuse,则在编译指令中声明光照函数名称:

#pragma surface surf BaseDiffuse

而在定义光照函数时,我们需要在函数名前面加上Lighting,也即是:

Lighting

所以我们的BaseDiffuse函数在定义时这样写:

inline float4 LightingBasicDiffuse(...)

有三种可供选择的光照模型函数:

half4 LightingName (SurfaceOutput s, half3 lightDir, half atten){}

这个函数被用于forward rendering(正向渲染),但是不需要考虑view direction(观察角度)时。

half4 LightingName (SurfaceOutput s, half3 lightDir, half3 viewDir, half atten){}

这个函数被用于forward rendering(正向渲染),并且需要考虑view direction(观察角度)时。

half4 LightingName_PrePass (SurfaceOutput s, half4 light){}

这个函数被用于需要使用defferred rendering(延迟渲染)时。

③使用自定义光照函数:正式动手

为了将上面这段代码改为使用我们自己的光照模型函数的表面着色器,我们需要做的是:

  1. 将编译指令改为:

#pragma surface surf BasicDiffuse

2.加入光照函数,写在CGPROGRAM...ENDCG块中:

    inline float4 LightingBasicDiffuse (SurfaceOutput s,fixed3 lightDir , fixed atten)
    {
        float difLight = max (dot(s.Normal , lightDir), 0 );
        flaot 4 col;
        col.rgb = s.Albedo *_LightColor0.rgb *(difLight * atten * 2);
        col.a = s.Alpha ;
        return col;
    }
    

3.保存,进入unity看编译结果。完整代码如下:

  Shader "MyShader/BasicDiffuse"
{
    Properties
    {
        //定义一些变量,可在监视面板中看到
        
        _EmissiveColor("Emissive Color",Color)= (1,1,1,1)
        _AmbientColor("Ambient Color",Color) = (1,1,1,1)
        _Slider("Slider",Range(1,10))=5
    }
    SubShader
    {
        CGPROGRAM
        //这里使用了内置光照模型Lambert
        #pragma surface surf BasicDiffuse
        
        inline float4 LightingBasicDiffuse (SurfaceOutput s,fixed3 lightDir , fixed atten)
        {
            float difLight = max (dot(s.Normal , lightDir), 0 );
            float4 col;
            col.rgb = s.Albedo *_LightColor0.rgb *(difLight * atten * 2);
            col.a = s.Alpha ;
            return col;
        }            

        //Properties中声明的变量在这里要重新声明,以便下面的代码使用
        float4 _EmissiveColor;
        float4 _AmbientColor;
        float _Slider;

        //输入结构
        struct Input
        {
            //包含了uv信息 ,注意变量必须以 uv开头
            float2 uv_MainTex;
        };
        
        //表面函数
        void surf(Input IN,inout SurfaceOutput o)
        {
            //填充SrufaceOutput结构
            float4 c;
            c = pow((_EmissiveColor+_AmbientColor),_Slider);
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }
        ENDCG 
    }
    FallBack "Diffuse"
}

Unity编译成功后,我们可以看看使用默认Lambert光照模型(上图)和自定义光照模型BasicDiffuse(下图)的效果图:

610439-20160215010806486-143386577.png

610439-20160215010831486-2122048282.png

④自定义光照模型解析

大家对光照函数这段代码可能还不怎么理解:

   inline float4 LightingBasicDiffuse (SurfaceOutput s,fixed3 lightDir , fixed atten)
        {
            float difLight = max (dot(s.Normal , lightDir), 0 );
            flaot 4 col;
            col.rgb = s.Albedo *_LightColor0.rgb *(difLight * atten * 2);
            col.a = s.Alpha ;
            return col;
        }
        
  • 由函数参数我们可以看出这是三种类型光照函数的一种:

half4 LightingName (SurfaceOutput s, half3 lightDir, half atten){}

这个函数被用于forward rendering(正向渲染),但是不需要考虑view direction(观察角度),对于漫反射来说,从哪个角度看到的光照效果都是相同的。

  • 首先是参数s。s是surf函数的输出。由代码可以看出,surf函数对参数o进行赋值,也即是填充SurfaceOutput结构o。surf函数填充了o的Alpha(反射率,也即是颜色)和Alpha(透明度)。LightingBasicDiffuse函数输出的是表面上某点的颜色值和透明度值。参数lightDir表示光源的方向,而atten表示光源的衰减率。
  • 光照函数的第一行

float difLight = max (dot(s.Normal , lightDir), 0 );

使用了max与dot函数,其中dot函数是向量的点乘函数,向量a点乘向量b为:

a * b = |a| |b| cos< a, b >

由于dot函数的两个参数都是单位向量,我们可以认为dot(s.Normal,lightDir)的结果是灯光方向向量与平面某点法向量夹角的余弦值。由于余弦值可能是负的,故使用max函数来保证最后得到的值>=0,避免出现了非预期的效果。

  • 接下来是计算颜色值col。col的rgb部分由三个部分计算得到,第一个是surface本身反射率,反射率越大,进入人眼光线就越多,颜色就越鲜亮。第二个是_LightColor0,这个是Unity的内置变量,我们可以使用它来得到场景中的灯光颜色。这里顺便附上Unity内置变量查询。最后乘以第一步得到的光照值与衰减系数。这里的乘以一个倍数2,只是加强一下最后效果。

3.表面着色器:Diffuse Shading—漫反射光照改善技巧

在上文,我们在表面着色器中定义了自己的光照函数BasicDiffuse,我们将对这个基本的diffuse进行改造,改造成一种在游戏《半条命2》中首次使用的光照模型--半Lambert光照,最后我们将学习使用渐变图来渲染漫反射。首先贴出我们上篇文章中写下来的代码:

Shader "MyShader/BasicDiffuse"
{
    Properties
    {
        //定义一些变量,可在监视面板中看到

        _EmissiveColor("Emissive Color",Color)= (1,1,1,1)
        _AmbientColor("Ambient Color",Color) = (1,1,1,1)
        _Slider("Slider",Range(1,10))=5
    }
    SubShader
    {
        CGPROGRAM
        //这里使用了内置光照模型Lambert
        #pragma surface surf BasicDiffuse

        inline float4 LightingBasicDiffuse (SurfaceOutput s,fixed3 lightDir , fixed atten)
        {
            float difLight = max (dot(s.Normal , lightDir), 0 );
            float4 col;
            col.rgb = s.Albedo *_LightColor0.rgb *(difLight * atten * 2);
            col.a = s.Alpha ;
            return col;
        }            

        //Properties中声明的变量在这里要重新声明,以便下面的代码使用
        float4 _EmissiveColor;
        float4 _AmbientColor;
        float _Slider;

        //输入结构
        struct Input
        {
            //包含了uv信息 ,注意变量必须以 uv开头
            float2 uv_MainTex;
        };

        //表面函数
        void surf(Input IN,inout SurfaceOutput o)
        {
            //填充SrufaceOutput结构
            float4 c;
            c = pow((_EmissiveColor+_AmbientColor),_Slider);
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }
        ENDCG 
    }
    FallBack "Diffuse"
}

①半Lambert光照

准备工作

如果你看过之前的文章,应该不会对Lmabert光照模型感到陌生,它就是Unity内置的光照模型。Lambert定律认为在平面某点的漫反射光的光强与该反射点的法向量和入射光角度的余弦值成正比。Half Lambert 最初是由Value提出来的,用于《半条命2》的画面渲染,它是为了防止某个物体背光面丢失而显得太过平面化。这个光照模型是没有基于任何物理原理的,它的提出仅仅是一种感性的视觉增强。

我们先在上面这段代码中加上几句代码及删除一些代码,使得物体可以使用贴图进行渲染:

    Shader "MyShader/BasicDiffuse"
    {
        Properties
        {
            //定义一些变量,可在监视面板中看到
            //新增
            _MainTex("Main Texture",2D) = "white "{}
        }
        SubShader
        {
            CGPROGRAM
            //这里使用了内置光照模型Lambert
            #pragma surface surf BasicDiffuse
    
            inline float4 LightingBasicDiffuse (SurfaceOutput s,fixed3 lightDir , fixed atten)
            {
                float difLight = max (dot(s.Normal , lightDir), 0 );
                float4 col;
                col.rgb = s.Albedo *_LightColor0.rgb *(difLight * atten * 2);
                col.a = s.Alpha ;
                return col;
            }            
    
            //Properties中声明的变量在这里要重新声明,以便下面的代码使用
            
            //新增:注意在这里进行声明
            sampler2D _MainTex;
    
            //输入结构
            struct Input
            {
                //包含了uv信息 ,注意变量必须以 uv开头
                float2 uv_MainTex;
            };
    
            //表面函数
            void surf(Input IN,inout SurfaceOutput o)
            {
                //填充SrufaceOutput结构
                float4 c;
                //新改动:同时要修改这里
                c = tex2D(_MainTex,IN.uv_MainTex);
                o.Albedo = c.rgb;
                o.Alpha = c.a;
            }
            ENDCG 
        }
        FallBack "Diffuse"
    }
    

我们把场景中方向光调整至使得模型正面处于背光状态,来看看模型此时的效果:

610439-20160215011336501-773796763.png

正式动手

说了这么多,我们还是要来演示一下Half Lambert的效果,代码改动上非常简单,我们只要稍微修改上面的LightingBasicDiffuse函数:

        inline float4 LightingBasicDiffuse (SurfaceOutput s,fixed3 lightDir , fixed atten)
            {
                float difLight = max (dot(s.Normal , lightDir), 0 );
                float hLambert = difLight *0.5+0.5;

                float4 col;
                col.rgb = s.Albedo *_LightColor0.rgb *(hLambert * atten * 2);
                col.a = s.Alpha ;
                return col;
            }    

由代码可以看出,我们定义了一个新的变量hLambert来替换difLight用于计算某点的颜色值。difLight的范围是0.0-1.0,而通过hLambert,我们将结果由0.0-1.0映射到了0.5-1.0,从而达到了增加亮度的目的。

保存代码,Unity编译好后再看模型,发现模型比刚才亮度增加了很多:
610439-20160215011355642-612247233.png

下面这张图也同样展示了Lambert光照与Half Lambert光照的区别:

610439-20160215011405611-1395813824.png

②使用渐变图(ramp Texture)来控制diffuse shading

使用渐变图来控制漫反射光照的颜色,允许你着重强调surface的颜色,而减弱漫反射光线或其他光线的影响,这种技术在《军团要塞2》中流行起来:
610439-20160215011423704-1837863517.png

这种技术也是由Value提出来的,用来渲染他们的游戏角色,常用于非写实画面的,比如在很多卡通风格的游戏中可以看到这种技术。

渐变图可以使用PS来制作。制作过程不再多说。我们先使用下面这个渐变图来:
610439-20160215011432126-840997510.jpg

我们需要新增一张贴图,方式与_MainTex相同。然后我们着重改动光照函数的代码:

       inline float4 LightingBasicDiffuse (SurfaceOutput s,fixed3 lightDir , fixed atten)
            {
                float difLight = max (dot(s.Normal , lightDir), 0 );
                float hLambert = difLight *0.5+0.5;
    
                //新增加
                float3 ramp = tex2D (_RampTex,float2(hLambert,hLambert)).rgb;

                float4 col;
                //改动
                col.rgb = s.Albedo *_LightColor0.rgb *ramp*(atten*2);
                col.a = s.Alpha ;
                return col;
            }            
            

这里重点代码是:

float3 ramp = tex2D (_RampTex,float2(hLambert,hLambert)).rgb;

这行代码返回一个rgb值。tex2D函数接受两个参数:第一个参数是操作的texture,第二个参数是需要采样的UV坐标。这里,我们使用一个漫反射浮点值(即hLambert)来映射到渐变图上的某一个颜色值。最后得到的结果便是,我们将会根据计算得到的Half Lambert光照值来决定光线照射到一个物体表面的颜色变化。

这里贴上半Lambert+渐变图渲染的最终代码:

        Shader "MyShader/HalfLambert_RampTexture"
        {
            Properties
            {
                //定义一些变量,可在监视面板中看到
        
                _MainTex("Main Texture",2D) = "white "{}
                _RampTex("RampTexture",2D)="white"{}
                
            }
            SubShader
            {
             Tags { "RenderType" = "Opaque" }  
                CGPROGRAM
                //这里使用了内置光照模型Lambert
                #pragma surface surf BasicDiffuse
           //Properties中声明的变量在这里要重新声明,以便下面的代码使用
    
                sampler2D _MainTex;
                sampler2D _RampTex;
                inline float4 LightingBasicDiffuse (SurfaceOutput s,fixed3 lightDir , fixed atten)
                {
                    float difLight = max (dot(s.Normal , lightDir), 0 );
                    float hLambert = difLight *0.5+0.5;
    
                    float3 ramp = tex2D (_RampTex,float2(hLambert,hLambert)).rgb;
    
                    float4 col;
                    col.rgb = s.Albedo *_LightColor0.rgb *ramp*(atten*2);
                    col.a = s.Alpha ;
                    return col;
                }            
        
             
                //输入结构
                struct Input
                {
                    //包含了uv信息 ,注意变量必须以 uv开头
                    float2 uv_MainTex;
                };
        
                //表面函数
                void surf(Input IN,inout SurfaceOutput o)
                {
                    //填充SrufaceOutput结构
                    float4 c;
                    c = tex2D(_MainTex,IN.uv_MainTex);
                    o.Albedo = c.rgb;
                    o.Alpha = c.a;
                }
                ENDCG 
            }
            FallBack "Diffuse"
        }

在监视面板拉上渐变图:
610439-20160215011448251-533712624.png

这时可以看到
610439-20160215011501986-630538791.png

4.让Texture动起来:UV动画与sprite sheet

这小节中,我们将讲解如何使用表面着色器来修改纹理Uv坐标以滚动贴图,然后再介绍sprite sheet实现2D动画。

①简单的UV移动效果

首先来看看,为了实现纹理的uv动画,我们需要做什么:

  1. 首先,我们要在ProPerties模块中加入两个控制UV坐标变换速度的变量:

     Properties
     {
         //主纹理贴图
         _MainTexture("Main Texture",2D)="white"{}
    
         //两个控制速度的变量
         _xRcrollingSpeed("xRcrollingSpeed",float)=1
         _yRcrollingSpeed("yRcrollingSpeed",float)=1
     }
  2. 不要忘记,上面这些变量在CGPROGRAM...ENDCG模块中要再声明一遍,因为我们后面要访问它们。

         CGPROGRAM
    
         #pragma surface surf Lambert
    
         sampler2D _MainTexture;
         float _xRcrollingSpeed;
         float _yRcrollingSpeed;
    
         ...
  3. 要记得表面着色器的要素之一:输入结构的定义

     struct Input
     {
         float2 uv_MainTexture;
     };
  4. 重点来了,在表面函数中我们进行坐标的变化:

     void surf(Input IN,inout SurfaceOutput o)
         {
    
             float2 sourceUv = IN.uv_MainTexture;
    
             //关注重点在这里
             float xRcrollingSpeed = _xRcrollingSpeed*_Time.y;
             float yRcrollingSpeed = _yRcrollingSpeed*_Time.y;
    
             sourceUv += float2(xRcrollingSpeed,yRcrollingSpeed);
    
             float4 c = tex2D(_MainTexture,sourceUv);
    
             o.Albedo = c.rgb;
             o.Alpha = c.a;
    
         }

完整的代码:

    Shader "MyShader/ScrollingUV"
    {
        Properties
        {
            _MainTexture("Main Texture",2D)="white"{}
            _xRcrollingSpeed("xRcrollingSpeed",float)=1
            _yRcrollingSpeed("yRcrollingSpeed",float)=1
        }
        SubShader
        {
            CGPROGRAM
            #pragma surface surf Lambert
            struct Input
            {
                float2 uv_MainTexture;
            };
    
            sampler2D _MainTexture;
            float _xRcrollingSpeed;
            float _yRcrollingSpeed;
    
            void surf(Input IN,inout SurfaceOutput o)
            {
                
                float2 sourceUv = IN.uv_MainTexture;
                
                float xRcrollingSpeed = _xRcrollingSpeed*_Time.y;
                float yRcrollingSpeed = _yRcrollingSpeed*_Time.y;
                    
                sourceUv += float2(xRcrollingSpeed,yRcrollingSpeed);
    
                float4 c = tex2D(_MainTexture,sourceUv);
    
    
                o.Albedo = c.rgb;
                o.Alpha = c.a;
    
            }
    
            ENDCG
        }
         FallBack "Diffuse" 
    }
    

把这段代码保存好,回到Unity的Inspector面板,把下面这张图赋予材质球,点击运行就可以看到动态的纹理效果啦。

610439-20160215011703611-944334108.png

本来使用录像工具鲁了一段视频,再转化为gif,结果图片不清晰,还是不贴出来了。大家可以在自己的Unity中试验。

②序列帧动画效果

有时候,我们得到的图片中含有某对象的一系列动作帧,把这些动作按顺序逐步播放就能得到连贯的动画:

610439-20160215011712267-1880228775.png

那么这里讲的就是如何把上面这一张图制作成2D动画。实际上在Unity已经有许多插件来完成这些工作,但是为了更好地了解2D动画的原理,熟悉shader如何改变UV坐标达到动画效果,我们还是亲手来制作一下。完了完成目标,我们需要做什么?

  1. 新建一个shader,在编辑器中打开,在Properties添加三个新属性:

         Properties
         {
             _MainTexture("Main Texture",2D)="white"{}
    
             //添加这三个控制属性
             _TexWidth("Sheet Width",float)=0.0
             _CellAmount("Cell Amount",float)=0.0
             _SwitchSpeed("Switch Speed",Range(1,10))=5
         }

    2.国际惯例,在CGPROGRAM...与ENDCG中添加上面属性的声明

         SubShader
     {
         CGPROGRAM
         #pragma surface surf Lambert
    
         sampler2D _MainTex;
         float _TexWidth;
         float _CellCount;
         float _SwitchSpeed;
    
         ENDCG
     }

3.输入结构的就不再提了

4.然后开始写我们的表面函数surf了:

void surf(Input IN,inout SurfaceOutput o)
        {
            //将uv坐标值保存在变量中
            float2 spriteUV= IN.uv_MainTex;

            //计算每个动作占据整张图的百分比
            float cellUVPercentage = 1.0/_CellAmount;

            //通过系统时间计算偏移量来得到不同的小图
            float timeVal = fmod (_Time.y*_SwitchSpeed,_CellAmount);
            timeVal = ceil(timeVal);

            //改变x方向上的偏移量
            float xValue = spriteUV.x;
            xValue += timeVal;
            xValue *=cellUVPercentage;

            spriteUV = float2(xValue, spriteUV.y);
            
            float4 c = tex2D (_MainTex, spriteUV);
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }
        

最后贴上我们shader的完整代码:

    Shader "MyShader/sprite sheet"
    {
        Properties
        {
            _MainTex("Base (RGB)",2D)="white"{}
    
            //添加这三个控制属性
            _TexWidth("Sheet Width",float)=0.0
            _CellAmount("Cell Amount",float)=0.0
            _SwitchSpeed("Switch Speed",Range(1,30))=12
        }
        SubShader
        {
            Tags { "RenderType"="Opaque" }  
            LOD 200  
    
            CGPROGRAM
            #pragma surface surf Lambert
    
            sampler2D _MainTex;
    
            float _TexWidth;
            float _CellAmount;
            float _SwitchSpeed;
    
            //输入结构
            struct  Input
            {
                float2 uv_MainTex;
            };
    
            //表面函数
            void surf(Input IN,inout SurfaceOutput o)
            {
                //将uv坐标值保存在变量中
                float2 spriteUV= IN.uv_MainTex;
    
                //计算每个动作占据整张图的百分比
                float cellUVPercentage = 1.0/_CellAmount;
    
                //通过系统时间计算偏移量来得到不同的小图
                float timeVal = fmod (_Time.y*_SwitchSpeed,_CellAmount);
                timeVal = ceil(timeVal);
    
                //改变x方向上的偏移量
                float xValue = spriteUV.x;
                xValue += timeVal;
                xValue *=cellUVPercentage;
    
                spriteUV = float2(xValue, spriteUV.y);
                
                float4 c = tex2D (_MainTex, spriteUV);
                o.Albedo = c.rgb;
                o.Alpha = c.a;
            }
            ENDCG
        }
         FallBack "Diffuse"  
    }

③代码解析

float cellUVPercentage = 1.0/_CellAmount;

为了每个时刻只显示一个小图,我们需要对整张图片进行缩放,这里计算的就是缩放比例。实例中一张texture共有8个小图,所以 cellUVPercentage = 1.0/_CellAmount = 1.0/8.0 = 0.125;

float timeVal = fmod (_Time.y*_SwitchSpeed,_CellAmount);

首先来看_Time是什么。它是内置的shader变量,可以在内置变量进行查询
610439-20160215011734564-1155837212.png

可以看到_Time记录了从场景开始运行时的时间计数,它有三个参数,代表了不同的时间倍数。如_Time.y就代表三倍时间计数。

610439-20160215011745236-1667947598.png

fmod函数是对浮点数的求余计算,它返回x/y的余数。实例中返回的范围是在0-8间的小数。为了得到整数,使用函数ceil函数向上求整:

timeVal = ceil(timeVal);

下面这部分代码是最难理解的:

float xValue = spriteUV.x;

02.xValue += timeVal;

03.xValue *= cellUVPercentage;

第一行首先声明一个新的变量xValue,用于存储用于图片采样的x坐标。它首先被初始为surf函数的输入参数In的横坐标。类型为Input的输入参数In代表输入的texture的UV坐标,范围为0到1。第二行向原值加上小图的整数偏移量,最后为了只显示一张小图,我们还需将x值乘以小图所占百分比cellUVPercentage。

保存代码之后,点击Play就可以看到动画效果啦,当然图片还是静态的...
610439-20160215011802251-1355189048.png

关于这些图片,在google搜索sprite sheet就能找到很多。

5.制作一个静态Cubemap,并在shader中使用它

16:05 2015/08/11 于工学一号馆312

①环境映射概述

环境映射技术模拟一个物体反射它周围的环境,它假设一个物体的环境是离物体无限远的,因此环境能够被编码到一个称为环境贴图的全方位图像里,立方贴图正是一种全方位图像。所有近来的图形处理都支持立方贴图纹理,立方贴图不是由一副纹理图像构成,而是由6副。熟悉天空盒制作的同学应该对如何由6副图片形成无缝连接的环境有很好的理解。下面这幅图展示了由6副纹理贴图构成的环境的立方贴图:

610439-20160215012129251-1130275787.png

②Unity表明着色器对立方贴图的存取

我们知道一个2D的纹理可以通过一个2D纹理坐标集来在纹理中查询颜色值,在之前的文章中我们也对2D纹理的进行纹理存取:

float4 col = tex2D(_MainTex,In.uv_MainTex);

而对于立方贴图,我们采用的是一个表示3D方向向量的三元纹理坐标集来存取纹理。这个向量可以看成是从立方体中心射出的光线,当光线向外的时候它会与立方体贴图的6个表面之一相交。立方体贴图纹理存取的结果是在与这6个面相交的点的过滤颜色。在Unity表面着色器中,我们使用texCUBE来完成立方体贴图的纹理存取:

float colCube = texCube(_CubeMap,In.worldRefl)

其中_CubeMap是立方体纹理贴图,在下面我们会介绍在Unity中如何产生静态立方纹理贴图。反而是第二个参数,worldRef1是什么?这是Input提供给我们的世界空间中的反射向量,环境贴图通常是基于世界空间来确定方向的,因此我们需要在世界空间中计算反射向量。在CG中,我们必须把顶点的数据以及法向量变换到世界空间中,然后再进行反射光线的计算。

float3 positionW = mul(modelToWorld,position).xyz;

float3 N = mul((float3x3)modelToWorld,normal);

我们看一个高度反射物体的时候看到的不是物体本身,而是物体反射它周围的环境。反射视线是基于初始的视线到达表面上某点以及改点的法向量的。当你使用一个立方贴图来编码环境从各个方向上看上去的样子的时候,渲染反射表面上一点大概只需要为表面上的那个点计算反射的视线方向,然后我们就可以基于反射的视线方向来存取立方贴图,从而为表面上的这个点决定环境的颜色。

下面这幅图显示的是一个物体以及一张立方贴图。因为我们是从2D来看的,所以物体只是是梯形,而立方贴图用正方形来表示。入射光线从眼睛出发指向物体表面某点,根据该点的表面法向量计算反射光线,由反射光线的方向来对立方贴图进行纹理存取。

610439-20160215012143470-198391680.png

下面这张图显示这种情况的几何排列:

610439-20160215012159798-1952978351.png

在CG中提供了函数来进行反射光线的计算:

reflect(I ,N )

这个函数为入射光线I和表面法向量N返回反射向量。

然而在Unity的表面着色器中,我们使用简单这一句就完成了纹理存取的一系列的事情。

float colCube = texCube(_CubeMap,In.worldRefl)

③ 立方体生成脚本的编写

我们必须学会自己制作静态的立方体贴图,因为立方体贴图(Cubemaps)来源我们的游戏场景,网上已有的Cubemaps并不适用在我们的场景中。下面我将提供一个C#脚本,使用这个脚本能够方便快捷地创建一个Cubemap。最终使用了我们制作的Cubemap完成的Shader是下面这种效果:

610439-20160215012211626-128026064.png

我们开始制作:

  1. 新创建一个C#脚本,命名为GenerateStaticCubemap。然后在Project面板中创建一个名为Editor文件夹,把GenerateStaticCubemap脚本放在该文件夹下。
  2. 在编辑器中打开该脚本,添加如下using指令:

     using UnityEngine;  
     using UnityEditor;  
     using System.Collections  
  3. 我们的脚本会在Unity编辑器中创建编辑窗口,所以我们的GenerateStaticCubemap类要继承于ScriptableWizard类。这使我们可以用一些底层函数来完成目标。

     public class GenerateStaticCubemap : ScriptableWizard { 
  4. 我们需要一个Cumemap类型变量以及一个位置变量来产生最后的立方体贴图:

         public Transform renderPosition;
         public Cubemap cubemap;
  5. 我们写一个函数:OnWizardUpdate(),这个函数在它在向导(wizard)第一次弹出或者当GUI被用户改变时(如拖进去某些对象,输入某些字符等)时被调用,我们可以在这里检查用户已经向向导中填入我们需要的所有的资源。在这里,如果Cubemap或者它的位置(一个transform)没有被填充,那么就设置内置变量isValid为false,直到拿到所有资源。

         void OnWizardUpdate() {
             helpString = "Select transform to render" +
                 " from and cubemap to render into";
             if (renderPosition != null && cubemap != null) {
                 isValid = true;
             }
             else {
                 isValid = false;
             }
         }
  6. 当isValid变量为true时,向导将调用OnWizardCreate()函数。我们在这函数里来得到最终的Cubemap:

            void OnWizardCreate() {  
            GameObject go = new GameObject("CubemapCamera");
            go.AddComponent<Camera>();
    
             go.transform.position = renderPosition.position;  
             go.transform.rotation = Quaternion.identity;
    
             go.GetComponent<Camera>().RenderToCubemap(cubemap);
    
             DestroyImmediate(go);  
         }  
  7. 我们需要从Unity编辑器打开这个向导,所以这里需要MenuItem关键词:

         [MenuItem("CookBook/Render Cubemap")]
         static void RenderCubemap() {
             ScriptableWizard.DisplayWizard("Render CubeMap", typeof(GenerateStaticCubemap), "Render!");
         }

好啦,这样cubemap生成器就大功告成,完整代码如下:

        using UnityEngine;
        using UnityEditor;
        using System.Collections;
        
        public class GenerateStaticCubemap : ScriptableWizard {
            public Transform renderPosition;
            public Cubemap cubemap;
        
            void OnWizardUpdate() 
            {  
                 helpString = "Select transform to render" +  
                " from and cubemap to render into";  
            if (renderPosition != null && cubemap != null) {  
                isValid = true;  
            }  
            else {  
                isValid = false;  
                }  
            }  
        void OnWizardCreate() {  
           GameObject go = new GameObject("CubemapCamera");
           go.AddComponent<Camera>();
        
            go.transform.position = renderPosition.position;  
            go.transform.rotation = Quaternion.identity;
        
            go.GetComponent<Camera>().RenderToCubemap(cubemap);
          
            DestroyImmediate(go);  
        }  
        
            [MenuItem("CookBook/Render Cubemap")]  
            static void RenderCubemap() {  
            ScriptableWizard.DisplayWizard("Render CubeMap", typeof(GenerateStaticCubemap), "Render!");  
        }  
                        
        }
        

④生成立方体纹理

回到我们的Unity编辑器,在编辑器上方可以看到:

610439-20160215012229017-1509846802.png

我们先创建一个Cubemap文件,命名为MyCubemape,然后再创建一个球体Sphere:

610439-20160215012236251-1204925205.png

打开我们写的Render CubeMap,把MyCubemap以及Sphere赋予它,这里Sphere主要是来用提供位置的(也即是上面C#脚本中的Transform变量,最后是用来设置摄像机位置的):
610439-20160215012246454-1015814686.png

点击Render!,我们可以得到一个立方纹理贴图:
610439-20160215012254454-330518580.png

⑤在表面着色器中使用立方体贴图

有了上面的介绍,我们的shader代码就好理解多了,如果你从前面的文章一直看下来的,对表面着色器三要素有了解的,下面这段代码基本不用解释了。这里贴上代码:

    Shader "MyShader/Using CubeMap"
    {
        Properties
        {
            _MainTex("Main Texture",2D)="white"{}
            _MainColor("Diffuse Tint",Color)=(1,1,1,1)
            _CubeMap("CubeMap",CUBE)=""{}
            _ReflAmount("Reflection Amount",Range(0.01,1))=0.5
        }
        SubShader
        {
        CGPROGRAM
        #pragma surface surf Lambert
        sampler2D _Maintex;
        samplerCUBE _CubeMap;
        float4 _MainTint;
        float _ReflAmount;
    
        //输入结构
        struct Input
        {
            float2 uv_MainTex;
            float3 worldRefl;
        };
    
        //表面函数
        void surf (Input IN ,inout SurfaceOutput o)
        {
            half4 c =tex2D(_Maintex,IN.uv_MainTex)*_MainTint;
            
            //这句话是重点
            o.Emission = texCUBE(_CubeMap,IN.worldRefl).rgb*_ReflAmount;
            
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }
    
        ENDCG
        }
         FallBack "Diffuse"  
    }
    

保存,回到Unity编辑器。创建一个材质并绑定上面这段shader代码,这里的CubeMap选择我们上面刚刚制作的那个,并为材质赋予一张基本贴图:
610439-20160215012306454-1802113036.png

然后把这材质球赋予我们的Sphere,可以看到我们前面的效果已经出来了:

610439-20160215012318876-99970741.png

⑥延展

前面的讨论中我们提及,环境映射假设离物体无限远,这是因为我们的立方体纹理存取只取决于世界坐标下的反射向量,反射向量只决定了方向,而没有决定距离,即反射向量方向相同时,位置上的变化不影响表面反射外观,如果环境中所有东东都离表面足够远,那么这种说法就成立了。

另外,也因为如此,环境映射在平面上表现很差,例如镜子,在镜面上反射需要依赖于位置。相反的,环境映射在曲面上表现得很好,例如我们的球。

6. 法线贴图理论详解以及在shader中的使用

00:09 2015/08/12 于工学一号馆312

在这小节里,我们将介绍如何使用法线贴图来在一个平面上做出凹凸的效果。写这些文章不仅仅只是展示shader代码的编写,我更希望把涉及到的,我学习到的知识都与大家分享。那好,在正式讲解Shader代码之前,我们先来看看凹凸映射效果以及法向量贴图的知识。

①凹凸映射

凹凸映射把由一个纹理提供的物体表面法向量的扰动与每个片段的光照相结合,来模拟光照与凹凸表面的相互作用,使得本来需要几何镶嵌才呈现得出的凹凸效果在一个平面上也能显示出来。

使用凹凸映射的原因:

  1. 用足够的几何细节来记录表面的凹凸不平的性质的方法来表示模型对交互渲染来说是非常巨大和麻烦的。
  2. 表面的特征也许比一个像素还小,这意味着光栅器不能精确地渲染所有的几何细节。

凹凸映射的好处包括了:

  1. 在场景中提供了一个级别更高的视觉复杂度,而没有增加更多的几何形状。
  2. 简化了内容创作,因为你可以用纹理来对表面细节进行编码,而不需要美工人员设计高度详细的3D模型。
  3. 应用不同的凹凸贴图到同一个模型的不同实例的能力,给了每个实例一种不同的表面外观。例如,一个建筑物模型能够被用一个砖凹凸贴图渲染一次,而第二次使用泥灰凹凸贴图。

②法向量的存储

我们传统的纹理通常包含RGB或RGBA颜色值,对于RGB纹理,每一个像素都由三个分量组成,分别代表了红色、绿色、蓝色,通常这些分量都为一个无符号字节。

法向量贴图是凹凸贴图的一种形式,对于法向量贴图来说,存储在纹理元素中的不是颜色值,而是法向量。每个法向量是一个从表面向外指的方向向量。传统的RGB纹理格式用来存储法向量贴图。与颜色值不同的是,颜色是无符号的,而方向向量需要有符号值,除了无符号外,纹理中的颜色值通常被限制在[0,1]的范围内,而方向向量的取值范围是[-1,1],为了能使针对无符号颜色的纹理过滤硬件能正常操作,我们必须要[-1,1]通过缩放与偏移,将其压缩至[0,1]内:

colorComponent = 0.5 normalComponent + 0.5

而在过滤硬件处理好后,要把法向量拓展回它们本身的范围,可以这样:

normalComponent = 2 * (colorComponent - 0.5)

总结上面这几段话:通过使用一个RGB纹理的红色、绿色、蓝色分量来存储一个法向量的x、y和z分量,并对有符号的值进行范围压缩到[0,1]无符号范围,然后法向量可以被存储在一个RGB纹理中。

③从高度图生成法向量贴图

高度图纹理对每个像素的高度进行编码,而不是对向量进行编码,因此,高度图在每个纹理元素存储了一个单独的无符号分量,而不是使用3个分量来存储一个向量。高度图由黑色,白色和之间的254种渐变灰度所生成,较暗的部分高度较低,教亮部分高度较高。下面显示是一张高度图:

610439-20160215012524392-1281038906.png

我们的法线贴图可以从高度贴图中生成,生成规则是:

计算高度图一个纹理元素对应的法向量,需要对给定的纹理元素、它正上方和右方的纹理元素的高度进行采样,采样得到了三个高度值:给定纹理元素的高度Hg,给定纹理元素正上方纹理元素的高度Ha,给定纹理元素右方纹理元素的高度值Hr。说起来还挺绕口的。得到这三个值之后,就可以来构成对应法向量了。由Hg,Ha,Hr可以得到两个差分向量:

flaot3 d1 = (1, 0, Ha - Hg )

flaot3 d2 = (0, 1, Hr - Hg )

我们的法向量可以由向量d1 与 d2 做外乘,然后规范化(单位化)得到。 即:

float3 Normal = normalize ( nod1 X d2 )

把一个高度图转换为一个法向量贴图是一个完全自动的过程,并且它通常与范围压缩在预处理阶段进行。

从高度图到法线贴图的转换,z分量总是正的并且通常或一定为1。z分量通常被存储在蓝色分量重,而范围压缩把z值转化到[0.5,1]范围,因此,存储一个RGB纹理中经过范围压缩的法向量贴图最主要的颜色的蓝色:

610439-20160215012538923-689415663.png

④使用Unity3D 的 Shader来实现法线贴图与反射

文章剩下的内容来讲诉如何在surface shader中使用法线贴图制造凹凸效果。

  1. 我们需要一个Cubemap来产生反射的效果,就使用上一篇文章中的Cubemap就行了。

610439-20160215012554298-1203992633.png

  1. 我们找一张法线贴图:

610439-20160215012606439-285457882.png

  1. 写我们的shader代码:

     Shader "MyShader/Normal Texture"
     {
         Properties
         {
             _MainTex("Main Texture",2D)="white"{}
             _NormalTex("Normal Texture",2D)=""{}
             _CubeMap ("Cubemap",CUBE)=""{}
             _Slider("Slider",Range(0.1,1))=0.5
         }
         SubShader
         {
             CGPROGRAM
             #pragma surface surf Lambert
             sampler2D _MainTex;
             sampler2D _NormalTex;
             samplerCUBE _CubeMap;
             float _Slider;
    
             struct Input
             {
                 float2 uv_MainTex;
                 float2 uv_NormalTex;
                 float3 worldRefl; 
                 //关键1
                 INTERNAL_DATA 
             };
    
             inline void surf (Input IN,inout SurfaceOutput o)
             {
                 half4 MainTexCol = tex2D(_MainTex,IN.uv_MainTex);
    
                 //关键2
                 float3 normals = UnpackNormal(tex2D(_NormalTex,IN.uv_NormalTex)).rgb;
    
                 o.Normal = normals;
    
                 //关键3
                 o.Emission = texCUBE(_CubeMap,WorldReflectionVector(IN,o.Normal)).rgb;
    
                 o.Albedo = MainTexCol.rgb*_Slider;
    
                 o.Alpha = MainTexCol.a; 
             }
    
             ENDCG
         }
            FallBack "Diffuse"  
     }

保存shader代码,回到Unity编辑器,将各种贴图以及制作好的Cubemap赋予材质,可以得到下面的效果:
610439-20160215012620048-1664625600.png

对比于没有使用凹凸贴图的材质球:
610439-20160215012632845-1624430996.png

最后,我们将两种材质赋予两个sphere,在scene中进行比较:

610439-20160215012649267-766341901.png

⑤ 代码解释

这段代码有三个比较关键的地方:

  • 在Input结构中添加下面变量,这是Unity内置变量。

INTERNAL_DATA

  • UnpackNormal函数,看了上面的法向量的存储应该知道这函数是干什么的,它使法向量从RGB范围恢复到[-1,1]范围内。

  • 我们通过声明INTERNAL_DATA来访问修改后的法线信息,然后使用WorldReflectionVector (IN, o.Normal)去查找Cubemap中对应的反射信息。所以在surf函数中查询立方体贴图的时候有别于上一篇文章的代码:

texCUBE(_CubeMap,WorldReflectionVector(IN,o.Normal))

7.菲涅尔效果及布料shader

23:00 2015/08/13 于工学一号馆312

本节主要介绍布料(Cloth)shader的实现。布料在游戏中非常常见,主角身上的衣服,房间里的窗帘等等都是布料构成。布料shader的重点在于如何让布料的纤维适当分散在整个表面的光照,使它看起来有真实布料的质感,如何让布料上细小的纤维能够产生边缘关照效果。为了实现布料效果,我们需要先来介绍一点物理学的知识。

①菲涅尔效果

大家都知道反射与折射,把反射与折射结合在一起就可以创造涅菲尔效果。一般而言,当光到达两种材质的接触面的时候,一部分光发生反射被表面反射出去,另一部分光发生折射穿过接触面,这个现象就称为涅菲尔现象。水面上就可以发生涅菲尔效果:当你垂直向水面时才可以看到水里的鱼,当远眺水面(视线与水面夹角很小)往往看到的是水面的反光。

涅菲尔效果为图像增加的真实性,它允许你创建物体时展示反射与折射的混合,使得物体看起来更像真实世界的物体。

涅菲尔公式描述了多少光背放射和多少光背折射。然而,量化了涅菲尔效果的涅菲尔公式是非常复杂的。所以我们往往使用经验公式---而非真正的涅菲尔公式,来模拟涅菲尔效果。实际上在游戏引擎中很少使用真正的物理公式精确模拟底层物理,一些技巧往往可以通过很少的计算来实现很不错的效果。

既然如此,我们就给出一个菲涅尔公式的近拟:

refletionCoefficient

= max ( 0, min (1, bias + scale * (1+ dot(I,N ) )^ power ) )

公式中I表示入射向量,N表示表面法向量。当I与N几乎重合的时候(垂直看水面),反射系数几乎为0,表面大部分光被折射。当I与N分开的时候(夹角逐步变小),反射系数应该逐渐增加并最终增加到1.也即反射系数的范围被限制在[0,1]之间。

这个公式得出的结果refletionCoefficient是一个系数,我们使用它来对反射与折射做权重分配:

Col = refletionCoefficient反射颜色+ (1-refletionCoefficient)折射颜色

②细节贴图

这个例子中会用到细节法线贴图与细节贴图,我们将这两种法线融合在一起,可以得到更高层次的表现。在这里我们要学习的技术就是如何把两个法线贴图的效果融合。这种技术用来模拟细节层次上的凹凸感,分散整个表面的高光反射。

本节用到的几张贴图如下:

610439-20160215012923829-659826319.png

注意两张法线贴图导入Unity后,需要将它们的类型从Texture转为Normal map。这背后发生了什么,我以后再说。

③开始写shader

  • 属性块
    属性块包含的属性如下,

          Properties
          {
              //布料主要颜色
              _MainTint ("Global Tint", Color) = (1,1,1,1)
    
              //布料法线贴图
              _BumpMap ("Normal Map", 2D) = "bump" {}
    
              //细节法线贴图
              _DetailBump ("Detail Normal Map", 2D) = "bump" {}
    
              //细节贴图
              _DetailTex ("Fabric Weave", 2D) = "white" {}
    
              //发生涅菲尔效果时的颜色(远眺水面看到的水面颜色)
              _FresnelColor ("Fresnel Color", Color) = (1,1,1,1)
    
              //发生菲涅尔效果的强度
              _FresnelPower ("Fresnel Power", Range(0, 12)) = 3
    
              _RimPower ("Rim FallOff", Range(0, 12)) = 3
    
              //镜面高光强度
              _SpecIntesity ("Specular Intensiity", Range(0, 1)) = 0.2
    
              _SpecWidth ("Specular Width", Range(0, 1)) = 0.2    
          }
  • 在CGPROGRAM...ENDCG中说明上面属性:

      SubShader 
          {
              Tags { "RenderType"="Opaque" }
              LOD 200
    
              CGPROGRAM
              //这里要指定我们自己的光照函数Velvet
              #pragma surface surf Velvet
              #pragma target 3.0
    
              //声明属性,以便下面的代码使用这些属性
              sampler2D _BumpMap;
              sampler2D _DetailBump;
              sampler2D _DetailTex;
              float4 _MainTint;
              float4 _FresnelColor;
              float _FresnelPower;
              float _RimPower;
              float _SpecIntesity;
              float _SpecWidth;
              ...
              ENDCG
          }
  • 定义输入结构Input,我们有三张贴图,所以以UV开头定义三个坐标成员:

          struct Input 
              {
                  float2 uv_BumpMap;
                  float2 uv_DetailBump;
                  float2 uv_DetailTex;
              };
  • 接下来是重点了,我们来写自己的光照函数。记得光照函数要在光照函数名前面加上固定字段Lighting

          inline fixed4 LightingVelvet (SurfaceOutput s, fixed3 lightDir, half3 viewDir, fixed atten)
          {
              //对各种向量都进行规范化
              viewDir = normalize(viewDir);
              lightDir = normalize(lightDir);
              half3 halfVec = normalize (lightDir + viewDir);
              fixed NdotL = max (0, dot (s.Normal, lightDir));
    
              //创建镜面反射系数
              float NdotH = max (0, dot (s.Normal, halfVec));
              float spec = pow (NdotH, s.Specular*128.0) * s.Gloss;
    
              //创建菲涅尔效果
              //不要被这两句话吓到
              //它们也只是量化菲涅尔公式的一种近拟,类似我们上面介绍的公式。
              float HdotV = pow(1-max(0, dot(halfVec, viewDir)), _FresnelPower);
              float NdotE = pow(1-max(0, dot(s.Normal, viewDir)), _RimPower);
    
              //也可以使用我们上面的公式来创建,效果是一样的
              //float HdotV = max(0, min(1,pow((1+dot(-viewDir,s.Normal)),_FresnelPower))); 
    
              float finalSpecMask =  HdotV+NdotE;
    
              //输出最终的颜色
              fixed4 c;
              c.rgb = (s.Albedo * NdotL * _LightColor0.rgb)
                       + (spec * (finalSpecMask * _FresnelColor)) * (atten * 2);
              c.a = 1.0;
              return c;
          }

在这里我们使用的并不是上面介绍的菲涅尔公式,而是新的一条公式,然而它们的原理都是相同的:都准守菲涅尔效果。我们可以画图模拟这条公式,结果就会很明了。

  • 表面函数。需要的说明也在注释中说清楚了。

      void surf (Input IN, inout SurfaceOutput o) 
      {
          //对三张贴图的取样
          half4 c = tex2D (_DetailTex, IN.uv_DetailTex);
    
          //UnpackNormal函数是把压缩的法向量还原到[-1,1]范围,具体细节以后会说到。
          fixed3 normals = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap)).rgb;
          fixed3 detailNormals = UnpackNormal(tex2D(_DetailBump, IN.uv_DetailBump)).rgb;
    
          //这里对两种法向量进行混合,不过操作也很简单,即对应元素相加而已。
          fixed3 finalNormals = float3(normals.x + detailNormals.x, 
                                      normals.y + detailNormals.y, 
                                      normals.z + detailNormals.z);
    
          o.Normal = normalize(finalNormals);
          //对高光强度赋值,范围0-1
          o.Specular = _SpecWidth;
          o.Gloss = _SpecIntesity;
          //颜色
          o.Albedo = c.rgb * _MainTint;
          //alpha
          o.Alpha = c.a;
      }

④完整代码

        Shader "Custom/ClothShader" {
            Properties
            {
                _MainTint ("Global Tint", Color) = (1,1,1,1)
                _BumpMap ("Normal Map", 2D) = "bump" {}
                _DetailBump ("Detail Normal Map", 2D) = "bump" {}
                _DetailTex ("Fabric Weave", 2D) = "white" {}
        
                //发生涅菲尔效果时的颜色(远眺水面看到的水面颜色)
                _FresnelColor ("Fresnel Color", Color) = (1,1,1,1)
                //发生菲涅尔效果的强度
                _FresnelPower ("Fresnel Power", Range(0, 12)) = 3
                
                _RimPower ("Rim FallOff", Range(0, 12)) = 3
        
                //镜面高光强度
                _SpecIntesity ("Specular Intensiity", Range(0, 1)) = 0.2
        
                _SpecWidth ("Specular Width", Range(0, 1)) = 0.2    
            }
            
            SubShader 
            {
                Tags { "RenderType"="Opaque" }
                LOD 200
                
                CGPROGRAM
                #pragma surface surf Velvet
                #pragma target 3.0
        
                sampler2D _BumpMap;
                sampler2D _DetailBump;
                sampler2D _DetailTex;
                float4 _MainTint;
                float4 _FresnelColor;
                float _FresnelPower;
                float _RimPower;
                float _SpecIntesity;
                float _SpecWidth;
        
                struct Input 
                {
                    float2 uv_BumpMap;
                    float2 uv_DetailBump;
                    float2 uv_DetailTex;
                };
                
                inline fixed4 LightingVelvet (SurfaceOutput s, fixed3 lightDir, half3 viewDir, fixed atten)
                {
                    //对各种向量都进行规范化
                    viewDir = normalize(viewDir);
                    lightDir = normalize(lightDir);
                    half3 halfVec = normalize (lightDir + viewDir);
                    fixed NdotL = max (0, dot (s.Normal, lightDir));
                    
                    //创建镜面反射系数
                    float NdotH = max (0, dot (s.Normal, halfVec));
                    float spec = pow (NdotH, s.Specular*128.0) * s.Gloss;
                    
                    //创建菲涅尔效果
                    //不要被这两句话吓到
                    //它们也只是量化菲涅尔公式的一种近拟,类似我们上面介绍的公式。
                    float HdotV = pow(1-max(0, dot(halfVec, viewDir)), _FresnelPower);
                    float NdotE = pow(1-max(0, dot(s.Normal, viewDir)), _RimPower);
        
                    //也可以使用我们上面的公式来创建,效果是一样的
                    //float HdotV = max(0, min(1,pow((1+dot(-viewDir,s.Normal)),_FresnelPower))); 
        
                    float finalSpecMask =  HdotV+NdotE;
                    
                    //输出最终的颜色
                    fixed4 c;
                    c.rgb = (s.Albedo * NdotL * _LightColor0.rgb)
                             + (spec * (finalSpecMask * _FresnelColor)) * (atten * 2);
                    c.a = 1.0;
                    return c;
                }
        
                void surf (Input IN, inout SurfaceOutput o) 
                {
                    //对三张贴图的取样
                    half4 c = tex2D (_DetailTex, IN.uv_DetailTex);
        
                    //UnpackNormal函数是把压缩的法向量还原到[-1,1]范围,具体细节以后会说到。
                    fixed3 normals = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap)).rgb;
                    fixed3 detailNormals = UnpackNormal(tex2D(_DetailBump, IN.uv_DetailBump)).rgb;
        
                    //这里对两种法向量进行混合,不过操作也很简单,即对应元素相加而已。
                    fixed3 finalNormals = float3(normals.x + detailNormals.x, 
                                                normals.y + detailNormals.y, 
                                                normals.z + detailNormals.z);
                    
                    o.Normal = normalize(finalNormals);
                    //对高光强度赋值,范围0-1
                    o.Specular = _SpecWidth;
                    o.Gloss = _SpecIntesity;
                    //颜色
                    o.Albedo = c.rgb * _MainTint;
                    //alpha
                    o.Alpha = c.a;
        
                }
                ENDCG
            } 
            FallBack "Diffuse"
        }

⑤效果

对3张贴图正确拖拽到材质球上,可以看到:

610439-20160215012950392-1798586926.png

将材质球赋给衣服模型:

610439-20160215012959157-937729225.png

从一个和衣服表面很小的夹角看,观察菲涅尔效果:

610439-20160215013017595-777517325.png

其他的效果就自己试试啦。

该Unity工程可以从这里下载

8. 使用Queue Tags 来控制渲染顺序

10:53 2015/08/14 于工学一号馆312

我们可以使用Tags告诉渲染引擎场景中的对象应该什么时候绘制以及如何来渲染。本篇文章主要来介绍在SubShader中使用的渲染队列标签Queue Tags。
Queue Tags 可以决定一个物体什么时候被绘制,觉得场景中不同标签的物体的绘制顺序,具体使用方法与细节请继续往下看。

①语法

Tags{"TagName1"="Value1" "TagName2"="Value2"}

②细节

  • Tags的数量没有限制,我们可以定义任意多的Tag。

  • Tags是标准的键值对,也就是可以根据一个键值(key)来获取实值(value)。

  • SubShader中的Tags被用来决定渲染顺序,当然也有其他作用的标签,具体可以看ShaderLab: SubShader Tags。

  • 注意Queue Tags必须写在subshader中,而不是在pass中。

  • 除了unity提供的预定义tags外,我们也可以定义自己的队列标签。

③Queue tag--决定渲染顺序

使用Queue tag 能够决定我们的对象以什么顺序被渲染。着色器决定对象属于哪一个渲染队列,通过这种方法,透明的物体能够被保证在所有不透明物体绘制完后再绘制。

有四种预定义好的Queue tag。如下所示:

Background 渲染队列值:1000

这个标签为背景标签。这个标签将在所有其他标签之前被渲染,可以被用来标记作为背景的对象。

Geometry(默认) 渲染队列值:2000

这个标签被最多的对象所使用,不透明的几何体使用这标签。

AlphaTest 渲染队列值:2450

需要Alpha测试的几何体使用这标签。它和Geometry队列不同,对于在所有立体物体绘制后渲染的通道检查的对象,它更有效。

Transparent 渲染队列值:3000

这个标签将在Geometry与AlphaTest之后进行渲染。任何通道混合的(也就是说,那些不写入深度缓存的Shaders)对象使用该队列,例如玻璃和粒子效果。

Overlay 渲染队列值:4000

最后需要渲染的对象选择这个标签,例如覆盖物效果、镜头光晕等。

④自定义中间队列

在同一个标签内,我们也可以觉得物体的绘制顺序,比如

Tags{"Queue"="Geometry-1"} 与 Tags{"Queue"="Geometry"}

前者比后者优先渲染。被渲染队列值越小的标签标记的对象越优先渲染。

⑤试验

我们举个例子来说明Queue Tags的使用。首先我们创建一个场景,在场景中摆上两个球,命名为ball1与ball2,ball1在ball2前面(离摄像机比较近)。它们的位置关系是这样的:
610439-20160215013331548-300178836.png

接下来我们写两个shader,分别应用在两个球体上。ball1我们使用这个shader:

    Shader "MyShader/QueueTags"
    {
        Properties
        {
            _Emissive("Emissive",Color)=(1,1,1,1)
            _MainTex("Main Texure",2D)=""{}
        }
        SubShader
        {
            Tags{"Queue"= "Geometry-1"}
            ZWrite Off
            CGPROGRAM
            #pragma surface surf Lambert
    
            sampler2D _MainTex;
            float4 _Emissive;
    
            struct Input
            {
                float2 uv_MainTex;
            };
    
            inline void surf(Input IN,inout SurfaceOutput o)
            {
                float4 col = tex2D(_MainTex,IN.uv_MainTex);
                o.Albedo = col.rgb+_Emissive;
                o.Alpha = col.a;
            }
    
            ENDCG
        }
        FallBack "Diffuse"
    }
    

而ball2使用这个shader:

        Shader "MyShader/QueueTags"
        {
            Properties
            {
                _Emissive("Emissive",Color)=(1,1,1,1)
                _MainTex("Main Texure",2D)=""{}
            }
            SubShader
            {
                Tags{"Queue"= "Geometry"}
                ZWrite Off
                CGPROGRAM
                #pragma surface surf Lambert
        
                sampler2D _MainTex;
                float4 _Emissive;
        
                struct Input
                {
                    float2 uv_MainTex;
                };
        
                inline void surf(Input IN,inout SurfaceOutput o)
                {
                    float4 col = tex2D(_MainTex,IN.uv_MainTex);
                    o.Albedo = col.rgb+_Emissive;
                    o.Alpha = col.a;
                }
        
                ENDCG
            }
            FallBack "Diffuse"
        }
        

注意到它们唯一的区别就在于Tags{"Queue"= "Geometry"}Tags{"Queue"= "Geometry+1"}上。

分别将这两个shader赋给俩Materal,再将Material拖拽给两个球体。顺便给俩球体调好对比颜色。我们可以来看看效果:
610439-20160215013320720-62972926.png

可以看到,本来在ball2(黄色)前面的ball1(红色)已经跑到ball2的后面去了。

⑥Zbuffer off

细心的同学已经发现了,在ball1的shader中有一个语句:

ZWrite Off

这句话的意思是关闭写入深度缓存,即不把ball1的深入值写入深度缓存中,深入缓存的写入以及深度测试才是决定物体是否遮挡的决定性因素。也即是说,就算是ball1先被画出来,但在进行深度写入以及深入测试时,ball1还是离摄像机比较近,所以黄球被红球遮挡住的部分就不绘制了,地面被红球挡住的部分也不绘制了。下面的效果展示了进行深入缓存写入的效果(即去掉了ZWrite off):
610439-20160215013310798-766764731.png

项目工程可以在这里下载。

9. 卡通风格的实现

17:47 2015/08/15 于工学一号馆312

在这节我们使用表面着色器来做卡通效果。卡通效果有许多种表现方法,这可以写成一个系列。不过目前我只学习了一种( ̄﹏ ̄)就是今天要讲的就是这一种。要表现这种卡通效果要抓住三个point:

  • 简化颜色
  • 使用渐变图(ramp Texture)来控制diffuse shading
  • 模型边缘描黑边

我们先来看一下效果如何:

610439-20160215013456564-216499586.png

其中左边的机器人为卡通风格,而右边机器人为原来的模型。

下面我们分点来进行卡通风格制作的介绍。

①简化颜色

简化颜色的意思即简化了模型上使用的颜色。我们先在Properties添加新属性:

    _SimFac("Simplify Factor",Range(0.1,20))=0.5
   

相应的,为了CGPROGRAM ...ENDCG模块中可以使用上面这属性,我们要添加它对应的引用:

    float _SimFac;
   

然后在我们的表面函数surf中添加如下语句来对像素的颜色做简化:

    o.Albedo = floor (o.Albedo*_SimFac)/_SimFac;
    

②解释

我们定义了 **_SimFac来控制颜色简化的程度。那么如何来控制呢?关键就在第三行代码上。floor**函数对操作数进行向下取整,我们将像素的颜色乘以简化因子,取整之后再除以简化因子来达到收缩颜色的效果。

举个例子:我们假设颜色为(0.75,0.75,0.75),简化因子我们取2,那么执行了代码之后颜色缩为:(0.5,0.5,0.5)

(0.5,0.5,0.5)= floor((0.75,0.75,0.75)*2)/2

对于0.51~0.99范围内的颜色,在简化因子为2的情况下,都会被缩为0.5,从而达到了简化颜色的目的。
610439-20160215013509579-65630265.png

我们也不难推导出,简化有因子越大,颜色简化越弱,例如在简化因子为8时,机器人颜色明显丰富多了:

610439-20160215013522923-1971725347.png

③ 使用渐变图(ramp Texture)来控制diffuse shading

在上面中有介绍到,我们使用渐变图来控制漫反射光照的颜色,允许你着重强调surface的颜色,而减弱反射光线的影响。这种技术在《军团要塞2》中流行起来,用于渲染非写实画面如卡通风格游戏。在这里我们就要把这种技术应用上啦,不了解的同学请看完上面这篇文章。

好,首先我们需要一张ramp Texture,渐变图:

610439-20160215013607345-1370206860.png

这张渐变图有个特定,按就是边界明显,不像我们以前用过的渐变图那样缓慢变化。这是因为卡通风格里经常有分界明显的明暗变化。

为了使用这张渐变图,我们在Properties添加新属性:

    _RampTex("Ramp Texture",2D)=""{}
    

同样的,在CGPROGRAM ...ENDCG模块中引用它:
sampler2D _RampTex;

然后,在我们自定义的光照函数Cartoon中添加如下代码

    float NdotL = max(0,dot(s.Normal,lightDir));
    float hNdotL = NdotL *0.5+0.5;

    float NdotV = max(0,dot(s.Normal,viewDir));
    float hNdotV = NdotV*0.5+0.5;

    float3 ram= tex2D(_RampTex,float2(hNdotL,hNdotV)).rgb;
    

④ 解释

看了Diffuse Shading——漫反射光照改善技巧相信能够理解上面这段代码,也就是更具几个向量来计算渐变图取样坐标。

我们来看看效果:

没有使用渐变图:

610439-20160215013627532-679428221.png

使用了渐变图:
610439-20160215013634548-1756487680.png

⑤ 给模型边缘描边

首先我们要先解决一个问题,怎么判断一个像素点位于模型的边缘(轮廓)?没错,就是使用向量的点乘来判断,对于一个球体来说,球体边缘的法向量与从正面看的观察向量成90度角,这两个向量的点乘结果为0.我们可以使用一个阈值来控制边缘的大小。具体代码请看:

首先我们还是定义一个边缘阈值:

    _OutLine("OutLine",Range(0,1))=0.1
    

同样的,在下面的模块中对它进行引用:

        float _OutLine;
        

然后重点代码就来了,在表面函数surf中,我们添加如下代码:

        //对观察向量与表面法向量进行点乘
        float OutLine = max(0,dot(normalize( o.Normal) ,normalize( IN.viewDir)));
        
        //C语言的语法。根据阈值进行描边。
        OutLine = OutLine< _OutLine ? OutLine / 4 : 1;
        
        o.Albedo = MainCol.rgb * _Emissive.rgb * OutLine;

⑥ 代码解释

主要来看第二行代码,当两个法向量的点乘小于阈值时,我们把表面像素点的最终颜色降为原来的1/4,形成黑色的效果,否则则保持原来的颜色(乘以1)。

来看看效果吧,我们来看机器人的脑壳可以看到明显的黑边:

610439-20160215013648501-624466097.png

把这三个点get到之后,我们就可以做出我们的卡通效果啦!~

⑦完整shader代码

        Shader "MyShader/Cartoon1"
        {
            Properties
            {
        
                _RampTex("Ramp Texture",2D)=""{}
                _SimFac("Simplify Factor",Range(0.1,20))=0.5
                _OutLine("OutLine",Range(0,1))=0.1
                _MainTex("Main Texture",2D)=""{}
                _Emissive("Emissive",Color)=(1,1,1,1)
                _BumpTex("Bump Texture",2D)=""{}
        
            }
            SubShader
            {
                CGPROGRAM
                #pragma surface surf Cartoon
        
                sampler2D _RampTex;
                sampler2D _MainTex;
                sampler2D _BumpTex;
        
                float _SimFac;
                float _OutLine;
                float4 _Emissive;
        
                struct Input 
                {
                    float2 uv_MainTex;
                    float2 uv_BumpTex;
                    float3 viewDir;
                };
        
                inline void surf(Input IN,inout SurfaceOutput o)
                {
                    float4 MainCol = tex2D(_MainTex,IN.uv_MainTex);
        
                    o.Normal = UnpackNormal( tex2D(_BumpTex, IN.uv_BumpTex));  
        
                    float OutLine = max(0,dot(normalize( o.Normal) ,normalize( IN.viewDir)));
                    
                    //对周围黑边进行处理
                    OutLine = OutLine< _OutLine ? OutLine / 4 : 1;
        
                    o.Albedo = MainCol.rgb * _Emissive.rgb * OutLine;
                    
                    //对颜色进行简化
                    o.Albedo = floor (o.Albedo*_SimFac)/_SimFac;
                    
                    o.Alpha = _Emissive.a;
        
                }
                inline float4 LightingCartoon(SurfaceOutput s,float3 viewDir,float3 lightDir,float atten)
                {
                    float NdotL = max(0,dot(s.Normal,lightDir));
                    float hNdotL = NdotL *0.5+0.5;
        
                    float NdotV = max(0,dot(s.Normal,viewDir));
                    float hNdotV = NdotV*0.5+0.5;
        
                    float3 ram= tex2D(_RampTex,float2(hNdotL,hNdotV)).rgb;
        
                    float4 col;
        
                    col.rgb = s.Albedo* ram*_LightColor0.rgb ;
        
                    col.a = s.Alpha;
        
                    return col;
        
                }
                ENDCG
            }
        }

⑧描边缺点

这种卡通描边方式是有缺陷的,在较平的平面上会出现突变产生整片的黑色区域:

610439-20160215013659142-1104382550.png

这是因为我们采用了顶点法向量来判断边界的,那么对于正方体这种法线固定单一的情况,判断出来的边界要么基本不存在要么就大的离谱!对于这样的对象,一个更好的方法是用Pixel&Fragment Shader、经过两个Pass渲染描边:第一个Pass,我们只渲染背面的网格,在它们的周围进行描边;第二个Pass中,再正常渲染正面的网格。其实,这是符合我们对于边界的认知的,我们看见的物体也都是看到了它们的正面而已。

不过,问题总是会有解决办法的!在以后的卡通shader中我们会解决这个问题的o(^▽^)o

项目工程下载

cartoon Shader

最后

以上就是生动大白为你收集整理的UnityShader 表面着色器简单例程集合0.前言1.表面着色器概述2.表面着色器:自定义光照函数BasicDiffuse3.表面着色器:Diffuse Shading—漫反射光照改善技巧4.让Texture动起来:UV动画与sprite sheet5.制作一个静态Cubemap,并在shader中使用它6. 法线贴图理论详解以及在shader中的使用7.菲涅尔效果及布料shader8. 使用Queue Tags 来控制渲染顺序9. 卡通风格的实现的全部内容,希望文章能够帮你解决UnityShader 表面着色器简单例程集合0.前言1.表面着色器概述2.表面着色器:自定义光照函数BasicDiffuse3.表面着色器:Diffuse Shading—漫反射光照改善技巧4.让Texture动起来:UV动画与sprite sheet5.制作一个静态Cubemap,并在shader中使用它6. 法线贴图理论详解以及在shader中的使用7.菲涅尔效果及布料shader8. 使用Queue Tags 来控制渲染顺序9. 卡通风格的实现所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(49)

相关文章

评论列表共有 0 条评论

立即
投稿
返回
顶部