Post

Unity Shader 基本语法(翻译二)

Unity Shader 基本语法(翻译二)

本篇摘要信息

  • 顶点变换
  • Color pixels
  • shader 属性
  • 从顶点传数据至片元函数
  • 查看编译后的shader代码

场景初始化

新建一个默认场景,新建一个圆球。这个默认场景本身进行了大量复杂的渲染,为了更容易的掌握Unity的渲染过程,我们先做一些简化设置,把默认的某些花里胡哨的东西先剥离掉。

剥离天空盒

打开Window-Lighting,查看光照设置选项。弹出带有3个选项卡的面板,我们先关注Scene选项卡.

默认光照
默认光照

第一选项卡Environment是跟环境光照相关,在这里可以设置天空盒。这个Default-Skybox当前正被用于场景的背景光、环境光、和反射光。设置为none就能关闭这些光。顺便把下面的Realtime LigtingMixed Lighting也关掉,现在还用不上,后面会陆续介绍。

关闭了天空盒,环境颜色自动切换为了纯色,这个颜色默认是带着一丝蓝的黑灰色(说好的纯呢,外表很黑内心很蓝?)。而反射光会变成纯黑色。如下所示,设置后球体变暗了,背景变成了纯色。而这个背景深蓝色从哪里来的呢?

简单光照
简单光照
简单光照

这个深蓝色被定义在摄像机,它默认使用天空盒渲染,当天空盒失效后场景会默认退回到使用相机纯色模式。

默认的摄像机设置
默认的摄像机设置

为了进一步简化渲染,再隐藏或删除方向光对象。这将消除场景中的直接光照,以及所有它投射的阴影。剩下纯色背景和球体的轮廓。

未着色球体
未着色球体

图像渲染

分两步绘制上面的场景,一是使用相机的背景色填充图像,然后再在上面画出球体的轮廓。

Unity如何知道该画这个球体呢?我们有一个球体对象并且绑定了 MeshRenderer 组件,如果这个球体位于摄像机的视野内,那么它就会被渲染出来。Unity通过检测球体的边界盒是否与摄像机的视锥体相机来验证这一点。包围盒在Unity中定义为 Bounds结构体 Collider.bounds, Mesh.bounds, Renderer.bounds.

球体默认自带组件
球体默认自带组件

Transform组件用于更改坐标、方向,以及网格和包围盒的尺寸。这里有对Transform层次结构的清晰描述。如果一个物体最终处于摄像机视野内,它就会被安排渲染。

最后,GPU负责渲染物体的mesh。这些具体的渲染指令在物体的material定义好的,这个material引用了一个shader-GPU程序。

2u分工
2u分工

当前这个球体使用了Unity的默认材质,自带了一个标准shader。我们现在把它去掉替换成自己的shader,从头开始写。

创建一个Shader

通过点击 Assets / Create / Shader / Unlit Shader 创建并命名自己的shader,双击shader文件打开,并删除里面的内容从头写.

第一个shader
第一个shader

Shader是通过shader关键字定义,关键字是一个字符串,在下拉界面中选择时显示的也是该关键字。它不必与文件名相同。

1
2
3
4
Shader "Unlit/MyShader"
{
    //...
}

保存文件,回到编辑器会收到警告提示none of subshaders/fallbacks are suitable,因为它是空的,没有sub-shader或回调shader。尽管这个shader没有内容也有警告,我们仍能指定给material。点击 Assets / Create / Material 创建,然后通过下拉菜单指定。

给材质指定Shader
给材质指定Shader

给球体指定上我们新建的Material,替换掉默认的。这时的球体会立即变成紫红色。发生这个的原因是Unity切换到了错误的shader,它故意使用这个颜色来提醒开发者这是一个错误。

指定MyMaterial
指定MyMaterial

shader warning中提到了没有sub-shader. 我们可以使用sub-shader操作shader变量进行分组, 这允许程序员为不同的编译平台提供不同的sub-shader.例如我们可以用一个sub-shader既支持pc又支持手机平台.定义一个SubShader块

1
2
3
4
5
6
7
Shader "Unlit/MyShader"
{
    SubShader
    {
        //...
    }
 }

sub-shader至少包含一个以上的pass块, pass代码块是物体实际被渲染的地方,我们先写一个pass,然后在写多个pass。为了呈现多种效果,pass数量可能会超过一个以上,而则代表着物体要被渲染多次。

1
2
3
4
5
6
7
8
9
10
Shader "Unlit/MyShader"
{
     SubShader
     {
        Pass
        {        
            //...
        }
    }
}

我们的球体现在应该变成了白色,因为我们使用了一个空pass渲染,这也意味着我们的Shader没有出现任何错误了。

空shader效果
空shader效果

Shader程序

现在我们要开始编写shader代码了,我们用的Unity着色器语言是HLSL和CG着色器语言的变体。所以必须指示 CGPROGRAM 关键字为代码的开始,同时要用 ENDCG 关键字做为结束。

1
2
3
4
5
Pass{
    CGPROGRAM

    ENDCG
}

再次打开编辑器编译后有一个警告 Both vertex and fragment programs must be present,表示没有顶点和片元程序。shader由这两个程序组成,vertex顶点程序负责处理网格的顶点数据,这包含了从对象空间到显示空间的转换;而fragment片元程序负责为网格的三角形内的单个像素进行着色。

顶点片元函数
顶点片元函数

同时,我们必须通过pragma指令告诉编译器使用哪些程序

1
2
3
4
5
6
CGPROGRAM

#pragma vertex MyVertexProgram
#pragma fragment MyFragmentProgram

ENDCG

编译器再次发出来错误提示,这次是因为它不能找到我们指定的程序片段,因为我们光声明没实现。首先vertex和fragment被写成方法,类似C#函数。先简单地创建两个同名的void方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CGPROGRAM

#pragma vertex MyVertexProgram
#pragma fragment MyFragmentProgram

void MyVertexProgram() {

}

void MyFragmentProgram() {

}

ENDCG

这次编译后没有报错,但是球体从屏幕上消失了。

Shader汇编

Unity的shader编译器把我们的代码根据不同target-compile成了不同程序。不同的平台需要不同的解决方案,例如Direct3D是服务于Windows平台,OpenGL针对MacOs,OpenGL ES针对手机平台。这里我们不是在处理单个编译器,而是多个编译器。

最终使用哪种编译器取决于目标平台,这些编译器也是不完全相同的,每个平台可能得到不同的结果。在这个例子中,我们的空程序在OpenGL和Direct3D 11下能很好的工作,但在Direct3D 9就会报错。

在编辑器下点选 MyFirstShader,在inspector面板可以查看该shader的一些信息,以及编译错误。这也有一个 Compiled code and show 按钮和下拉菜单。

shader检视面板信息
shader检视面板信息

如果你点击该按钮,Unity将会编译该shader并打开它,接着就可以查看生成的代码。我们试着先选择OpenGL Core,然后再选择D3D11,看看底层代码是怎么回事的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Shader "Unlit/MyShader" {
SubShader 
{
    Pass 
    {
        No keywords set in this variant.
        -- Vertex shader for "glcore":
        Shader Disassembly:
        #ifdef VERTEX
        #version 150
        #extension GL_ARB_explicit_attrib_location : require
        #extension GL_ARB_shader_bit_encoding : enable

        void main()
        {
            return;
        }
        #endif

        #ifdef FRAGMENT
        #version 150
        #extension GL_ARB_explicit_attrib_location : require
        #extension GL_ARB_shader_bit_encoding : enable

        void main()
        {
            return;
        }

        #endif

        -- Fragment shader for "glcore":
        Shader Disassembly:
        // All GLSL source is contained within the vertex program
    }
}
}

提炼出两个main函数,有vertex和fragment程序

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifdef VERTEX
void main()
{
    return;
}
#endif

#ifdef FRAGMENT
void main()
{
    return;
}
#endif

D3D11自行查看,因为编译后的代码实在是太长了,不方便贴上来。只选取了一个片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
Pass {
    No keywords set in this variant.
    -- Vertex shader for "d3d11":
    Shader Disassembly:
        vs_4_0
    0: ret


    -- Fragment shader for "d3d11":
    Shader Disassembly:
        ps_4_0
    0: ret
}

引入其他文件

编写shader代码很费劲,有时需要重复写类似的函数,为了简化书写,这里有一个类似C#程序的功能,引用其他类中的通用变量、函数等。使用 #include 指令就能加载一个文件。先试着加载 UnityCG.cginc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CGPROGRAM

    #pragma vertex MyVertexProgram
    #pragma fragment MyFragmentProgram

    #include "UnityCG.cginc"

    void MyVertexProgram() {

    }

    void MyFragmentProgram() {

    }

ENDCG

下面是 UnityCg.cginc 的引用层次结构

UnityCG.cginc结构
UnityCG.cginc结构

UnityShaderVariables.cginc 定义了一大堆渲染所需的着色器变量,比如 矩阵变换、相机和光照数据等等

UnityInstancing.cginc 内置在引擎安装包内,这是一种 减少绘制调用的特定呈现技术。虽然它不直接包含文件,但它依赖于UnityShaderVariables。

HLSLSupport.cginc 设置了一些无论您的目标是哪个平台都可以使用相同的代码的功能。

请注意,这些文件的内容将被复制到文件中,取代include指令。这发生在预处理步骤中,该步骤执行所有预处理指令。比如 #include#pragma

产生输出(输出语义)

为了渲染物体,shader必须要产生结果。

Vertex顶点函数必须要返回每个顶点的最终坐标:SV_POSITION。一个顶点有几个坐标分量?4个,因为我们使用了4x4变换矩阵。现在把函数类型从void改为float4,一个float4类型是一个由4个float类型简单组成。

1
2
3
4
float4 MyVertexProgram() : SV_POSITION
{
    return 0;
}

Fragment片元函数返回像素的最终颜色:SV_TARGET。也是float4。

1
2
3
4
float4 MyFragmentProgram() : SV_TARGET
{
    return 0;
}

Vertex顶点函数的输出作为Fragment片元函数的输入。输入的参数需要匹配!

1
2
3
4
5
6
float4 MyFragmentProgram(
    float4 position : SV_POSITION
) : SV_TARGET
{
    return 0;
}

然后看看Unity的shader汇编

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//--D3D11--
-- Vertex shader for "d3d11":
Shader Disassembly:
      vs_4_0                            //顶点着色器版本
      dcl_output_siv o0.xyzw, position  //声明o0作为输出值,带有系统值
   0: mov o0.xyzw, l(0,0,0,0)           //把(0,0,0,0)移动到o0中
   1: ret                               //返回

-- Fragment shader for "d3d11":
Shader Disassembly:
      ps_4_0
      dcl_output o0.xyzw
   0: mov o0.xyzw, l(0,0,0,0)
   1: ret

//GL CORE--
#ifdef VERTEX
void main()
{
    gl_Position = vec4(0.0, 0.0, 0.0, 0.0);
    return;
}
#endif

#ifdef FRAGMENT
layout(location = 0) out vec4 SV_TARGET0;
void main()
{
    SV_TARGET0 = vec4(0.0, 0.0, 0.0, 0.0);
    return;
}
#endif

顶点变换

把球给我画出来!

为了得到模型空间的顶点坐标,给vertex顶点函数增加一条语义POSITON。而模型空间的顶点坐标是其次坐标。先直接返回这个顶点坐标,贴汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//D3d11-
      vs_4_0                            //版本
      dcl_input v0.xyzw                 //申明v0 输入系统值
      dcl_output_siv o0.xyzw, position  //申明o0 输出系统值
   0: mov o0.xyzw, v0.xyzw              //把v0值 移动到 o0
   1: ret

//--GL CORE
#ifdef VERTEX
in  vec4 in_POSITION0;
void main()
{
    gl_Position = in_POSITION0;
    return;
}
#endifView Code
扭曲的球
扭曲的球

使用MVP:model_view_projection矩阵变换顶点坐标,该值定义在 UnityShaderVariables 文件,变量名是 UNITY_MATRIX_MVP。改为:

mul 函数定义

1
return mul(UNITY_MATRIX_MVP, position);

贴汇编看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
-- Vertex shader for "d3d11":
// Stats: 8 math
Uses vertex data channel "Vertex"
//cbuffers常量数据
Constant Buffer "UnityPerDraw" (160 bytes) on slot 0 {
  Matrix4x4 unity_ObjectToWorld at 0
}
Constant Buffer "UnityPerFrame" (384 bytes) on slot 1 {
  Matrix4x4 unity_MatrixVP at 272
}

Shader Disassembly:
      //版本
      vs_4_0
      //声明常量缓冲区cbuffers,逐字索引
      dcl_constantbuffer CB0[4], immediateIndexed
      //cbuffers
      dcl_constantbuffer CB1[21], immediateIndexed
      //声明输入v0
      dcl_input v0.xyz
      //声明输入o0
      dcl_output_siv o0.xyzw, position
      //声明临时寄存器2个(r0-r1)
      dcl_temps 2
      //将v0与cb0[1]相乘传递给r0
    0: mul r0.xyzw, v0.yyyy, cb0[1].xyzw
    /*第0行的计算步骤
    dest.x = cb0[0].x * v0.x + r0.x;
    dest.y = cb0[0].y * v0.x + r0.y;
    dest.z = cb0[0].z * v0.x + r0.z;
    dest.w = cb0[0].w * v0.x + r0.w;*/

    //r0 = cb0 * v0 + r0
    1: mad r0.xyzw, cb0[0].xyzw, v0.xxxx, r0.xyzw
    //同理1:
    2: mad r0.xyzw, cb0[2].xyzw, v0.zzzz, r0.xyzw    
    //r0 = r0 + cb0
    3: add r0.xyzw, r0.xyzw, cb0[3].xyzw
    //r1 = r0 * cb1
    4: mul r1.xyzw, r0.yyyy, cb1[18].xyzw
    5: mad r1.xyzw, cb1[17].xyzw, r0.xxxx, r1.xyzw    //同理1:
    6: mad r1.xyzw, cb1[19].xyzw, r0.zzzz, r1.xyzw    //同理1:
    7: mad o0.xyzw, cb1[20].xyzw, r0.wwww, r1.xyzw    //同理1:
    8: retView Code
正确的球
正确的球

像素颜色

先给Fragment函数返回点东西,

1
2
3
4
float4 MyFragmentProgram(float4 position : SV_POSITION) : SV_TARGET
{
    return float4(1, 1, 0, 1);
}
黄色球
黄色球

使用Shader属性Properties

需要在pass块内声明一个同类型的同命名变量float4 _Tint;

1
2
3
float4 MyFragmentProgram(float4 position : SV_POSITION) : SV_TARGET{
    return _Tint;
}

看看片元函数的汇编

1
2
3
4
5
6
7
8
9
10
11
-- Fragment shader for "d3d11":
Constant Buffer "Globals" (48 bytes) on slot 0 {
  Vector4 _Tint at 32
}

Shader Disassembly:
      ps_4_0
      dcl_constantbuffer CB0[3], immediateIndexed
      dcl_output o0.xyzw
   0: mov o0.xyzw, cb0[2].xyzw
   1: retView Code
纯色球
纯色球

从顶点到片元

上图纯色球,每个像素都是同一个颜色,但是美术给的效果图是五彩斑斓的, 就需要GPU光栅化三角形,取三个处理过的顶点进行插值,找到三角形内所有像素并着色

shader程序执行流程
shader程序执行流程

处理过的顶点数据不直接传递给Fragment片元函数,而在片元函数中访问插值本地数据,需要增加一个参数,并指定语义 TEXCOORD0,它表示贴图的UV坐标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
float4 MyVertexProgram
(
    float4 position: POSITION,
    out float3 localPosition : TEXCOORD0
) : SV_POSITION{
    localPosition = position.xyz;
    return UnityObjectToClipPos(position);
}

float4 MyFragmentProgram
(
    float4 position : SV_POSITION,
    float3 localPosition : TEXCOORD0
) : SV_TARGET
{
    return float4(localPosition, 1);
}
插值本地数据作为颜色
插值本地数据作为颜色

结构体

简化传递Fragment函数的参数,新建一个结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Interpolators{
    float4 position : SV_POSITION;
    float3 localPosition : TEXCOORD0;
};

Interpolators MyVertexProgram (float4 position: POSITION ){
    Interpolators i;
    i.localPosition = position.xyz;
    i.position = UnityObjectToClipPos(position);
     return i;
}

float4 MyFragmentProgram (Interpolators i) : SV_TARGET
{
    return float4(i.localPosition, 1);
 }

UnityObjectToClipPos 是 Unity5.6 之后的优化: 它对应 mul(UNITY_MATRIX_MVP, v.vertex),但是该函数使用了常数1作为第四个坐标而不是 依赖网格数据,源码:

1
2
3
4
5
6
inline float4 UnityObjectToClipPosInstanced(in float3 pos)
{
    float4 w = mul(unity_ObjectToWorldArray[unity_InstanceID],
                  float4(pos, 1.0)
    return mul(UNITY_MATRIX_VP, w));
}

因为通过网格提供的数始终为1,但是编译器不能知晓。所幸干脆就直接写死为1.0,优化掉运行 时再去计算第四个数到底是多少这一步。

调整颜色

因为负颜色被约束限制为零,我们的球体最终变得相当暗。 球体的自身半径为 $1\over 2$,因此颜色通道最终介于 $-{1\over 2}$ 和 $1\over 2$ 之间。我们希望将它们移动到 0-1 范围内,我们可以通过向所有通道添加 $1\over 2$ 来实现。

1
return float4(i.localPosition + 0.5, 1);

再看看汇编代码

1
2
3
4
5
6
7
8
9
10
11
uniform         vec4 _Tint;
in  vec3 vs_TEXCOORD0;
layout(location = 0) out vec4 SV_TARGET0;
vec4 t0;
void main()
{
    t0.xyz = vs_TEXCOORD0.xyz + vec3(0.5, 0.5, 0.5);
    t0.w = 1.0;
    SV_TARGET0 = t0 * _Tint;
    return;
}
1
2
3
4
5
6
7
8
9
10
11
12
ConstBuffer "$$Globals" 128
Vector 96 [_Tint]
BindCB  "$$Globals" 0
      ps_4_0
      dcl_constantbuffer cb0[7], immediateIndexed
      dcl_input_ps linear v0.xyz
      dcl_output o0.xyzw
      dcl_temps 1
   0: add r0.xyz, v0.xyzx, l(0.500000, 0.500000, 0.500000, 0.000000)
   1: mov r0.w, l(1.000000)
   2: mul o0.xyzw, r0.xyzw, cb0[6].xyzw
   3: ret
调整之后的颜色
调整之后的颜色

纹理

如果想在模型网格上不添加更多三角形面数的情况下为网格添加更多更明显的细节和多样性呈现,可以使用纹理投影到网格的三角形上。

纹理坐标用于控制投影,下图是2D坐标。不管纹理的实际纵横比如何,其水平坐标称为U,垂直坐标称为V,它们通常称为UV坐标。

uv坐标图
uv坐标图

U坐标从左到右递增,起始点为0终点为1。 V坐标从下到上增加,除了Direct3D它从上到下。

使用UV坐标

Unity的默认网格具有适合纹理映射的UV坐标。顶点程序可以通过参数访问它们 TEXCOORD0 语义。然后传递给片元函数使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Interpolators {
    float4 position : SV_POSITION;
    float2 uv : TEXCOORD0;
};

Interpolators MyVertexProgram (VertedData v) {
    Interpolators i;
    i.position = mul(UNITY_MATRIX_MVP, v.position);
    i.uv = v.uv;
    return i;
}

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    return float4(i.uv, 1, 1);
}

再看看汇编

1
2
3
4
5
6
7
8
9
10
11
12
13
14
in  vec4 in_POSITION0;
in  vec2 in_TEXCOORD0;
out vec2 vs_TEXCOORD0;
vec4 t0;
void main()
{
    t0 = in_POSITION0.yyyy * glstate_matrix_mvp[1];
    t0 = glstate_matrix_mvp[0] * in_POSITION0.xxxx + t0;
    t0 = glstate_matrix_mvp[2] * in_POSITION0.zzzz + t0;
    gl_Position = glstate_matrix_mvp[3] * in_POSITION0.wwww + t0;
    //将UV坐标从顶点数据复制到Interpolators输出
    vs_TEXCOORD0.xy = in_TEXCOORD0.xy;
    return;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Bind "vertex" Vertex
Bind "texcoord" TexCoord0
ConstBuffer "UnityPerDraw" 352
Matrix 0 [glstate_matrix_mvp]
BindCB  "UnityPerDraw" 0
      vs_4_0
      dcl_constantbuffer cb0[4], immediateIndexed
      dcl_input v0.xyzw
      dcl_input v1.xy
      dcl_output_siv o0.xyzw, position
      dcl_output o1.xy
      dcl_temps 1
   0: mul r0.xyzw, v0.yyyy, cb0[1].xyzw
   1: mad r0.xyzw, cb0[0].xyzw, v0.xxxx, r0.xyzw
   2: mad r0.xyzw, cb0[2].xyzw, v0.zzzz, r0.xyzw
   3: mad o0.xyzw, cb0[3].xyzw, v0.wwww, r0.xyzw
   4: mov o1.xy, v1.xyxx//将UV坐标从v1.xy传递到o1.xy
   5: ret

Unity将UV坐标包裹在其球体周围,在球体两极处图像的顶部和底部。图像的左侧和右侧接缝连接在一起。 沿着该接缝,UV坐标值从0到1。

球体缝隙
球体缝隙
球体缝隙

添加纹理

要使用纹理,必须添加另一个着色器属性。 常规纹理属性的类型是2D ,因为还有其他类型的纹理。 默认值是引用 Unity 的默认纹理之一的字符串,可以是white 、 black 或 gray 。

主纹理命名约定是 _MainTex。 这也使您可以使用方便的 Material.mainTexture属性以通过脚本访问它。

1
2
3
4
Properties {
    _Tint ("Tint", Color) = (1, 1, 1, 1)
    _MainTex ("Texture", 2D) = "white" {}
}

“white” {} 这个花括号有什么用?

上古时代开发的固定功能着色器曾经需要纹理设置,这些设置被放在这些括号内,但现在它们不再使用 了。即使它们现在已经无用,着色编译器仍然需要它们,如果忽略它们会产生错误。

选中材质,查看inspector信息

纹理_white显示
纹理_white显示

通过使用 sampler2D 类型为变量来访问着色器中的纹理。

1
2
float4 _Tint;
sampler2D _MainTex;

通过在片段程序中使用tex2D函数,完成对纹理UV坐标进行采样。

1
2
3
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    return tex2D(_MainTex, i.uv);
}

再查看汇编后的shader代码

1
2
3
4
5
6
7
8
uniform  sampler2D _MainTex;
in  vec2 vs_TEXCOORD0;
layout(location = 0) out vec4 SV_TARGET0;
void main()
{
    SV_TARGET0 = texture(_MainTex, vs_TEXCOORD0.xy);
    return;
}
1
2
3
4
5
6
7
8
SetTexture 0 [_MainTex] 2D 0
      ps_4_0
      dcl_sampler s0, mode_default
      dcl_resource_texture2d (float,float,float,float) t0
      dcl_input_ps linear v0.xy
      dcl_output o0.xyzw
   0: sample o0.xyzw, v0.xyxx, t0.xyzw, s0
   1: ret
带纹理球体
带纹理球体
带纹理球体

球体两极附近会显得非常杂乱。 为什么会这样? 发生纹理失真是因为插值在三角形中是线性的。 Unity 的球体在两极附近只有几个三角形,其中UV坐标失真最大。所以UV坐标在三角形与三角形的顶点之间是非线性变化,但在三角形内部的顶点之间的变化是线性的。所以 球体两极纹理中的直线在三角形边界处突然改变方向。

跨三角形插值
跨三角形插值

不同的网格具有不同的 UV 坐标,从而产生不同的映射。 Unity 的默认球体使用经纬度纹理映射,而网格是低分辨率立方体球体。 这足以进行测试,但您最好使用自定义球体网格以获得更好的结果。

平铺和偏移

为着色器添加纹理属性后,材质检查器不仅显示了纹理字段。它还显示了平铺和偏移控制。但是,更改这些 2D 向量目前没有任何效果。

这些额外的纹理数据存储在材质中,也可以由着色器访问。 可以通过与关联材料同名的变量加上 _ST 后缀来执行此操作。这个变量的类型必须是float4.

1
2
sampler2D _MainTex;
float4 _MainTex_ST;

平铺向量用于缩放纹理,因此默认为 $(1, 1)$。 它存储在变量的XY部分。 要使用它只需将它与UV 坐标相乘。 这可以在顶点着色器或片段着色器中完成。 在顶点着色器中这样做是有意义的,只为每个顶点而不是每个像素执行乘法。

1
2
3
4
5
6
Interpolators MyVertexProgram (VertexData v) {
    Interpolators i;
    i.position = mul(UNITY_MATRIX_MVP, v.position);
    i.uv = v.uv * _MainTex_ST.xy;
    return i;
}

偏移向量用于移动纹理,并存储在变量的ZW部分中。

1
i.uv = v.uv * _MainTex_ST.xy + _MainTex_ST.zw;

UnityCG.cginc包含一个方便的宏:TRANSFORM_TEX

1
i.uv = TRANSFORM_TEX(v.uv, _MainTex);

纹理设置

默认的纹理设置
默认的纹理设置

Wrap Mode决定了在使用0-1范围之外的UV坐标进行采样时的输出。

  • 设置为clamped时,UV被限制在 0–1 范围内。 这意味着超出边缘的像素与位于边缘的像素相同。
  • 设置为repeat时,UV从0-1循环。这意味着超出边缘的像素与纹理另一侧的像素相同。

Wrap Mode默认模式是重复纹理,这会导致它平铺。

在(2, 2)开始平铺
在(2, 2)开始平铺

Mipmap和Filter

当纹理的像素与它们投影到网格的像素不完全匹配时会发生什么? 存在不匹配,必须以某种方式解决。这是由Filter Mode完成如何控制。

  • Point (no filter) 。这意味着当在某些 UV 坐标处对纹理进行采样时,将使用最近的像素。 这将使纹理呈现块状外观,除非像素精确映射到显示像素。 因此,它通常用于像素完美的渲染,或者需要块状样式时。
  • bilinear filtering双线性过滤。 当纹理在两个像素之间的某处被采样时,这两个像素被插值。 由于纹理是 2D 的,这发生在 U 轴和 V 轴上。 因此是双线性过滤。

双线性过滤方法在像素密度小于显示像素密度时有效,因此当放大纹理时。结果会看起来很模糊。 当缩小纹理时,几乎不起作用。 相邻的显示像素最终会得到相距超过一个像素的样本。 这意味着将跳过部分纹理,这将导致粗糙的过渡,就像图像被锐化一样。

双线性过滤问题的解决方案是在像素密度变得太高时使用较小的纹理。 显示屏上出现的纹理越小,应使用的版本越小。 这些较小的版本称为 mipmap,unity会自动为您生成。 每个连续的 mipmap 的宽度和高度都是上一层的一半。 所以当原始纹理大小为 512x512 时,mip 贴图为 256x256、128x128、64x64、32x32、16x16、8x8、4x4 和 2x2。

mipmap 是什么?

mipmap这个词是 MIP map 的缩写。 字母 MIP 代表拉丁短语 multum in parvo ,意思 是狭小空间中的众多 。 它是由 Lance Williams 在首次描述 mipmap 技术时创造的。

mipmap上有下无
mipmap上有下无
mipmap上有下无
mipmap上有下无

那么在何时使用哪个mipmap级别,它们看起来有什么不同呢? 先通过在高级纹理设置中启用Fadeout Mip Maps。启用一个淡入淡出范围后,inspector将显示滑块。它定义了一个mipmap范围,在该范围内 mipmap 将转换为纯灰色。 越向右滑动过渡级别越小。

mipmap过渡级别
mipmap过渡级别
mipmap过渡级别
mipmap过渡级别

mipmap过渡之间呈现出模糊到锐利的快速,过渡不自然。这可以通过将过滤器模式切换为Trilinear三线性。 这与双线性过滤的工作方式相同,但它是 在相邻的mipmap级别之间进行插值,这使得采样成本更高 ,它平滑了 mipmap 级别之间的转换。

另一种有用的技术是各向异性过滤。 当把Aniso Level设置为 0 时,纹理变得更加模糊。这与mipmap 级别的选择有关。

各向异性是什么意思?

粗略地说,当某物在不同方向上看起来相似时,它就是各向同性的。 例如,一个无特征的立方体。 如果不是这种情况,则它是各向异性的。 例如,一个三角体,因为它的纹理朝着一个方向而不是另一个 方向。

当纹理以某个角度投影时,由于透视关系,通常最终会发现其中一个维度比另一个维度扭曲得更多。一个很好的例子是带纹理的地面。在远处,纹理的前后维度会显得比左右维度小得多。

Aniso Level

选择哪个 mipmap 级别基于最差维度。 如果差异很大,那么会得到一个在一维上非常模糊的结果。各向异性过滤通过解耦维度来缓解这种情况。除了统一缩小纹理外,它还提供在任一维度上缩放不同数量的版本。 因此,您不仅有 256x256 的 mipmap,而且还有 256x128、256x64 等的 mipmap。

没有和有Aniso Level
没有和有Aniso Level
没有和有Aniso Level

请注意,这些额外的 mipmap 不像常规 mipmap 那样预先生成。 相反,它们是通过执行额外的纹理样本来模拟的。 因此它们不需要更多空间,但采样成本更高。

各向异性双线性过滤,过渡到灰色
各向异性双线性过滤,过渡到灰色

各向异性过滤的深度由 Aniso Level 控制。 为 0 时,它被禁用。 为 1 时,它变为启用并提供最小的效果。 16达到最大值。 但是,这些设置会受到项目质量设置的影响。 可以通过 Edit / Project Settings / Quality 访问质量设置。 找到 各向异性纹理 项目设置

渲染质量设置
渲染质量设置

项目设置禁用各向异性纹理时,无论纹理设置如何,都不会发生各向异性过滤。当它设置为 Per Texture时,它​​完全由纹理自身设置控制。也可以设置为 Forced On ,强制把每个纹理都开启Ansio Level,但是若纹理设置Aniso Level为0,仍然不会使用各向异性过滤。

This post is licensed under CC BY 4.0 by the author.