本文最后更新于:2025年4月17日 下午
渲染纹理
在之前的学习中,一个摄像机的渲染结果会输出到颜色缓冲中,并显示到我们的屏幕上。现代的 GPU 允许我们把整个三维场景渲染到一个中间缓冲中,即渲染目标纹理(Render Target Texture,RTT) ,而不是传统的帧缓冲或后备缓冲(back buffer)。与之相关的是多重渲染目标(Multiple Render Target,MRT) ,这种技术指的是 GPU 允许我们把场景同时渲染到多个渲染目标纹理中,而不再需要为每个渲染目标纹理单独渲染完整的场景。延迟渲染就是使用多重渲染目标的一个应用。
Unity 为渲染目标纹理定义了一种专门的纹理类型——渲染纹理(Render Texture) 。在 Unity 中使用渲染纹理通常有两种方式:一种方式是在 Project 目录下创建一个渲染纹理,然后把某个摄像机的渲染目标设置成该渲染纹理,这样一来该摄像机的渲染结果就会实时更新到渲染纹理中,而不会显示在屏幕上。使用这种方法,我么还可以选择渲染纹理的分辨率、滤波模式等纹理属性。另一种方式是在屏幕后处理时使用 GrabPass 命令或 OnRenderImage 函数来获取当前屏幕图像,Unity 会把这个屏幕图像放到一张和屏幕分辨率等同的渲染纹理中,下面我们可以在自定义的 Pass 中把它们当成普通纹理来处理,从而实现各种屏幕特效。
镜子效果
在 Project 视图下创建一个渲染纹理(右键单击 Create -> Render Texture),名为 MirrorTexture,它使用的纹理设置如下图:
为了得到从镜子出发观察到的场景图像,我们还需要创建一个摄像机,并调整它的位置、裁剪平面、视角等,使得它的显示图像是我们希望的镜子图像。由于这个摄像机不需要直接显示在屏幕上,而是用于渲染纹理,我们把创建的 MirrorTexture 拖拽到该摄像机的 Target Texture 上。下图显示了摄像机面板的设置。
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 45 46 47 Shader "Custom/Chapter10/Chapter10-Mirror" { Properties { _MainTex ("Main Tex", 2D) = "white" {} } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry"} Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag sampler2D _MainTex; struct a2v { float4 vertex : POSITION; float3 texcoord : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert(a2v v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.texcoord; // Mirror needs to filp x o.uv.x = 1 - o.uv.x; return o; } fixed4 frag(v2f i) : SV_Target { return tex2D(_MainTex, i.uv); } ENDCG } } FallBack Off }
镜子实现的原理很简单,它使用一个渲染纹理作为输入属性,并把该渲染纹理在水平方向上翻转后直接显示到物体上即可。在上面的代码中,我们翻转了 x 分量的纹理坐标。这是因为镜子里显示的图像都是左右相反的。
在上面的实现中,我们把渲染纹理的分辨率大小设置为 256 $\times$ 256。有时,这样的分辨率会使图像模糊不清,此时我们可以使用更高的分辨率或更多的抗锯齿采样等。但需要注意的是,更高的分辨率会影响带宽和性能,我们应当尽量使用较小的分辨率。
玻璃效果
在 Unity 中,我们还可以在 Unity Shader 中使用一种特殊的 Pass 来完成获取屏幕图像的目的,这就是 GrabPass。当我们在 Shader 中定义了一个 GrabPass 后,Unity 会把当前屏幕的图像绘制在一张纹理中,以便我们在后续的 Pass 中访问它。我们通常会使用 GrabPass 来实现诸如玻璃等透明材质的模拟,与使用简单的透明混合不同,使用 GrabPass 可以让我们对该物体后面的图像进行更复杂的处理,例如使用法线来模拟折射效果,而不再是简单的和原屏幕颜色进行混合。
需要注意的是,在使用 GrabPass 时,我们需要额外小心物体的渲染队列设置 。GrabPass 通常用于渲染透明物体,尽管代码里并不包含混合指令,但我们往往仍然需要把物体的渲染队列设置成透明队列(即"Queue"=“Transparent”)。这样才能保证当渲染物体时,所有的不透明物体都已经被绘制在屏幕上,从而获取正确的屏幕图像。
我们首先使用一张法线纹理来修改模型的法线信息,然后使用反射方法,通过一个 Cubemap 来模拟玻璃的反射,而在模拟折射时,则使用 GrabPass 获取玻璃后面的屏幕图像,并使用切线空间下的法线对屏幕纹理坐标偏移后,再对屏幕图像进行采样来模拟近似的折射效果。
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 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 // Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld' Shader "Custom/Chapter10/Chapter10-GlassRefraction" { Properties { _MainTex ("Main Tex", 2D) = "white" {} _BumpMap ("Normal Map", 2D) = "bump" {} _Cubemap ("Environment Cubemap", Cube) = "_Skybox" {} _Distortion ("Distortion", Range(0, 100)) = 10 _RefractAmount ("Refract Amount", Range(0.0, 1.0)) = 1.0 } SubShader { //We must be transparent, so other objects are drawn before this one. Tags { "Queue"="Transparent" "RenderType"="Opaque" } //This pass grabs the screen behind the object into a texture. //We can access the result in the next pass as _RefractionTex GrabPass { "_RefractionTex" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_ST; sampler2D _BumpMap; float4 _BumpMap_ST; samplerCUBE _Cubemap; float _Distortion; fixed _RefractAmount; sampler2D _RefractionTex; float4 _RefractionTex_TexelSize; struct a2v { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; float4 texcoord : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float4 scrPos : TEXCOORD0; float4 uv : TEXCOORD1; float4 TtoW0 : TEXCOORD2; float4 TtoW1 : TEXCOORD3; float4 TtoW2 : TEXCOORD4; }; v2f vert(a2v v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.scrPos = ComputeGrabScreenPos(o.pos); o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex); o.uv.zw = TRANSFORM_TEX(v.texcoord, _BumpMap); float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; fixed3 worldNormal = UnityObjectToWorldNormal(v.normal); fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz); fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w; o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x); o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y); o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z); return o; } fixed4 frag(v2f i) : SV_TARGET { float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w); fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos)); //Get the normal in tangent space fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv.zw)); //Compute the offset in tangent space float2 offset = bump.xy * _Distortion * _RefractionTex_TexelSize.xy; i.scrPos.xy = offset + i.scrPos.xy; fixed3 refrCol = tex2D(_RefractionTex, i.scrPos.xy/i.scrPos.w).rgb; //Convert the normal to world space bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump))); fixed3 reflDir = reflect(-worldViewDir, bump); fixed4 texColor = tex2D(_MainTex, i.uv.xy); fixed3 reflCol = texCUBE(_Cubemap, reflDir).rgb * texColor.rgb; fixed3 finalColor = reflCol * (1 - _RefractAmount) + refrCol * _RefractAmount; return fixed4(finalColor, 1); } ENDCG } } FallBack "Diffuse" }
_MainTex 是该玻璃的材质纹理;_BumpMap 是玻璃的法线纹理;_Cubemap 是用于模拟反射的环境纹理;_Distortion 是用于控制模拟折射时图像的扭曲程度;_RefractAmount 用于控制折射程度,当 _RefractAmount 值为 0 时,该玻璃只包含反射效果,当 _RefractAmount 值为 1 时,该玻璃只包括折射效果。
我们首先在 SubShader 的标签中将渲染队列设置成 Transparent,尽管在后面的 RenderType 被设置为了 Opaque。这两者看似矛盾,但实际服务于不同的需求。把 Queue 设置成 Transparent 可以确保该物体渲染时,其他所有不透明物体都已经渲染到屏幕上了,否则就可能无法正确得到“透过玻璃看到的图像”。而设置 RenderType 则是为了在使用着色器替换(Shader Replacement)时,该物体可以在需要时被正确渲染。这通常发生在我们需要得到摄像机的深度和法线纹理时。
随后我们通过关键词 GrabPass 定义了一个抓取屏幕图像的 Pass。在这个 Pass 中我们定义了一个字符串,该字符串内部的名称决定了抓取得到的屏幕图像将会被存入哪个纹理中。实际上,我们可以省略声明该字符串,但直接声明纹理名称的方法往往可以得到更高的性能。下面是两种情况的比较。
直接使用 GrabPass {},然后在后续的 Pass 中直接使用 _GrabTexture 来访问屏幕图像。但是,当场景中有多个物体都使用了这样的形式来抓取屏幕时,这种方法的性能消耗比较大,因为对于每一个使用它的物体,Unity 都会为它单独进行一次昂贵的屏幕抓取操作。但这种方法可以让每个物体得到不同的屏幕图像,这取决于它们的渲染队列及渲染它们时当前的屏幕缓冲中的颜色。
使用 GrabPass {“TextureName”},我们可以在后续的 Pass 中使用 TextureName 来访问屏幕图像。使用这种方法同样可以抓取屏幕,但 Unity 只会在每一帧时为第一个使用名为 TextureName 的纹理的物体执行一次抓取屏幕的操作,而这个纹理同样可以在其他 Pass 中被访问。这种方法更高效,因为不管场景中有多少物体使用了该命令,每一帧中 Unity 都只会执行一次抓取工作,但这也意味着所有物体都会使用同一张屏幕图像。不过,这在大多数情况下已经足够了。
我们还定义了 _RefractionTex 和 _RefractionTex_TexelSize 变量,这对应了在使用 GrabPass 时指定的纹理名称。_RefractionTex_TexelSize 可以让我们得到该纹理的纹素大小,例如一个大小为 256 $\times$ 512 的纹理,它的纹素大小为(1/256, 1/512)。我们需要在对屏幕图像的采样坐标进行偏移时使用该变量。
在进行了必要的顶点坐标变换后,我们通过调用内置的 ComputeGrabScreenPos 函数来得到对应被抓取的屏幕图像的采样坐标。我们可以在 UnityCG.cginc 文件中找到它的声明,它的主要代码和 ComputeScreenPos 基本类似,最大的不同是针对平台差异造成的采样坐标问题进行了处理。接着,我们计算了 _MainTex 和 _BumpMap 的采样坐标,并把它们分别存储在一个 float4 类型变量的 xy 和 zw 分量中。由于我们需要在片元着色器中把法线方向从切线方向(由法线纹理采样得到)变换到世界空间下,以便对 Cubemap 进行采样,因此,我们需要在这里计算该顶点对应的从切线空间到世界空间的变换矩阵,并把该矩阵的每一行分别存储在 TtoW0、TtoW1、和 TtoW2 的 xyz 分量中。这里面使用的数学方法就是,得到切线空间下的 3 个坐标轴(xyz 轴分别对应了副切线、切线和法线的方向)在世界空间下的表示,再把它们依次按列 组成一个变换矩阵即可。TtoW0 等值的 w 轴同样被利用起来,用于存储世界空间下的顶点坐标。
我们通过 TtoW0 等变量的 w 分量得到世界坐标,并用该值得到该片元对应的视角方向。随后,我们对法线纹理进行采样,得到切线空间下的法线方向。我们使用该值和 _Distortion 属性以及 _RefractionTex_TexelSize 来对屏幕图像的采样坐标进行偏移,模拟折射效果。_Distortion 值越大,偏移量越大,玻璃背后的物体看起来变形程度越大。在这里,我们选择使用切线空间下单法线方向来进行偏移,是因为该空间下的法线可以反应顶点局部空间下的法线方向。随后,我们对 scrPos 透视除法得到真正的屏幕坐标,再使用该坐标对抓取的屏幕图像 _RefractionTex 进行采样,得到模拟的折射颜色。
之后,我们把法线方向从切线空间变换到了世界空间下(使用变换矩阵的每一行,即 TtoW0、TtoW1 和 TtoW2,分别和法线方向点乘,构成新的法线方向),并据此得到视角方向相对于法线方向的反射方向。随后,使用反射方向对 Cubemap 进行采样,并把结果和主纹理颜色相乘后得到反射颜色。最后我们使用 _RefractAmount 属性对反射和折射颜色进行混合,作为最终的输出颜色。
渲染纹理 vs. GrabPass
尽管 GrabPass 和 渲染纹理 + 额外摄像机的方式都可以抓取屏幕图像,但它们之间还是有一些不同的。GrabPass 的好处在于实现简单,我们只需要在 Shader 中写几行代码就可以实现抓取屏幕的目的。而要使用渲染纹理的话,我们首先需要创建一个渲染纹理和一个额外的摄像机,再把该摄像机的 Render Target 设置为新建的渲染纹理对象,最后把该渲染纹理传递给相应的 Shader。
但从效率上来讲,使用渲染纹理的效率往往要好于 GrabPass,尤其在移动设备上。使用渲染纹理我们可以自定义渲染纹理的大小,尽管这种方法需要把部分场景再次渲染一遍,但我们可以通过调整摄像机的渲染层来减少二次渲染时的场景大小,或使用其他方法来控制摄像机是否需要开启。而使用 GrabPass 获取到的图像分辨率和显示屏幕是一致的,这意味着在一些高分辨率的设备上可能会造成严重的带宽影响。而且在移动设备上,GrabPass 虽然不会重新渲染场景,但它往往需要 CPU 直接读取后备缓冲(back buffer)中的数据,破坏了 CPU 和 GPU 之间的并行性,这是比较耗时的,甚至在一些移动设备上这是不支持的。
Unity 引入了命令缓冲(Command Buffers)来允许我们扩展 Unity 的渲染流水线。使用命令缓冲我们也可以得到类似抓屏的效果,它可以在不透明物体渲染后把当前的图像复制到一个临时的渲染目标纹理中,然后在那里进行一些额外的操作,例如模糊等等,最后把图像传递给需要使用它的物体进行处理和显示。除此之外,命令缓冲还允许我们实习很多特殊的效果,我们可以在 Unity 官方手册中的 图像命令缓冲 一文(https://docs.unity.cn/cn/2021.2/Manual/GraphicsCommandBuffers.html)中找到更多内容。
程序纹理
**程序纹理(Procedural Texture)**指的是那些由计算机生成的图像,我们通常使用一些特定的算法来创建个性化图案或非常真实的自然元素,例如木头、石子等。使用程序纹理的好处在于我们可以使用各种参数来控制纹理的外观,而这些属性不仅仅是那些颜色属性,甚至可以是完全不同类型的图案属性,这使得我们可以得到更加丰富的动画和视觉效果。
在 Unity 中实现简单的程序纹理
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 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 using UnityEngine;using System.Collections;using System.Collections.Generic; [ExecuteInEditMode ]public class ProceduralTextureGeneration : MonoBehaviour { public Material material = null ; #region Material properties [SerializeField, SetProperty("textureWidth" ) ] private int m_textureWidth = 512 ; public int textureWidth { get { return m_textureWidth; } set { m_textureWidth = value ; _UpdateMaterial(); } } [SerializeField, SetProperty("backgroundColor" ) ] private Color m_backgroundColor = Color.white; public Color backgroundColor { get { return m_backgroundColor; } set { m_backgroundColor = value ; _UpdateMaterial(); } } [SerializeField, SetProperty("circleColor" ) ] private Color m_circleColor = Color.yellow; public Color circleColor { get { return m_circleColor; } set { m_circleColor = value ; _UpdateMaterial(); } } [SerializeField, SetProperty("blurFactor" ) ] private float m_blurFactor = 2.0f ; public float blurFactor { get { return m_blurFactor; } set { m_blurFactor = value ; _UpdateMaterial(); } } #endregion private Texture2D m_generatedTexture = null ; void Start () { if (material == null ) { Renderer renderer = gameObject.GetComponent<Renderer>(); if (renderer == null ) { Debug.LogWarning("Cannot find a renderer." ); return ; } material = renderer.sharedMaterial; } _UpdateMaterial(); } private void _UpdateMaterial() { if (material != null ) { m_generatedTexture = _GenerateProceduralTexture(); material.SetTexture("_MainTex" , m_generatedTexture); } } private Color _MixColor(Color color0, Color color1, float mixFactor) { Color mixColor = Color.white; mixColor.r = Mathf.Lerp(color0.r, color1.r, mixFactor); mixColor.g = Mathf.Lerp(color0.g, color1.g, mixFactor); mixColor.b = Mathf.Lerp(color0.b, color1.b, mixFactor); mixColor.a = Mathf.Lerp(color0.a, color1.a, mixFactor); return mixColor; } private Texture2D _GenerateProceduralTexture() { Texture2D proceduralTexture = new Texture2D(textureWidth, textureWidth); float circleInterval = textureWidth / 4.0f ; float radius = textureWidth / 10.0f ; float edgeBlur = 1.0f / blurFactor; for (int w = 0 ; w < textureWidth; w++) { for (int h = 0 ; h < textureWidth; h++) { Color pixel = backgroundColor; for (int i = 0 ; i < 3 ; i++) { for (int j = 0 ; j < 3 ; j++) { Vector2 circleCenter = new Vector2(circleInterval * (i + 1 ), circleInterval * (j + 1 )); float dist = Vector2.Distance(new Vector2(w, h), circleCenter) - radius; Color color = _MixColor(circleColor, new Color(pixel.r, pixel.g, pixel.b, 0.0f ), Mathf.SmoothStep(0f , 1.0f , dist * edgeBlur)); pixel = _MixColor(pixel, color, color.a); } } proceduralTexture.SetPixel(w, h, pixel); } } proceduralTexture.Apply(); return proceduralTexture; } }
由于我们生成的纹理是由若干圆点构成的,因此在上面的代码中,我们声明了 4 个纹理属性:纹理的大小,数值通常是 2 的整数幂;纹理的背景颜色;原点的颜色;模糊因子,这个参数是用来模糊边界的。对于每个属性使用了 get/set 方法,为了在面板上修改属性时仍可执行 set 函数,我们使用了一个开源插件 SetProperty(https://github.com/LMNRY/SetProperty/blob/master/Scripts/SetPropertyExample.cs)。这使得我们修改了材质属性时,可以执行 _UpdateMaterial 函数来使用新的属性重新生成程序纹理。
代码首先初始化一张二维纹理,并且提前计算了一些生成纹理时需要的变量。然后,使用了一个两层的嵌套循环遍历纹理中的每个像素,并在纹理上依次绘制 9 个圆形。最后调用 Texture2D.Apply 函数来强制把像素值写入纹理中,并返回该程序纹理。
Unity 的程序材质
在 Unity 中,有一类专门使用程序纹理的材质,叫做程序材质(Procedural Materials) 。这类材质和我们之前使用的那些材质本质上是一样的,不同的是,它们使用的纹理不是普通的纹理,而是程序纹理。需要注意的是,程序材质和它使用的程序纹理并不是在 Unity 中创建的,而是使用了一个名为 Substance Designer 的软件在 Unity 外部生成的。Substance Designer 是一个非常出色的纹理生成工具,很多 3A 的游戏项目都使用了由它生成的材质。这些材质都是以 .sbsar 为后缀的。在 Unity 中需要从官方资源商店或 Package Manager 安装 Substance in Unity 插件,把以 .sbsar 为后缀的文件导入到 Unity 后,Unity 就会生成一个 程序纹理资源(Procedural Material Asset) 。程序纹理资源可以包含一个或多个程序材质。
我们可以在程序纹理的面板上看到该材质使用的 Unity Shader 及其属性、生成程序纹理使用的纹理属性、材质预览等信息。