Unity Shader-反射效果(CubeMap,Reflection Probe,Planar Reflection,Screen Space Reflection)

简介

反射效果,是一个渲染中很重要的一个效果。在表现光滑表面(金属,光滑地面),水面(湖面,地面积水)等材质的时候,加上反射,都可以画面效果有很大的提升。来看几张图:

先来张最近比较火爆的国产大作《逆水寒》的镜湖,我木有钱钱进副本,只好从网上找了个截图,感觉很漂亮哈。

《罗马之子》主角的金属盔甲反射&远处水面倒影反射。

《耻辱-外魔之死》地面积水的反射,只反射了静态场景,没有反射动态物体,似乎是一个比较省的做法。

《底特律-变人》神作,画面和剧情都相当给力,就像看电影一样,比如上面图的地面积水的效果,实时反射效果,包括场景的动态物体。

可见,反射效果对于场景整体提升有很大作用,今天本人就来学习一下几种常见的反射的实现。如有错误还望各位高手批评指正。

 

反射的相关基本内容

反射的原理及分类

反射,应该属于间接光照的范畴,而非直接光照。我们正常计算光的dot(N,L)或者dot(H,N)时计算的均为直接光照,光源出发经过物体表面该像素点反射进入眼睛的光照。然而这只是一部分,该点还可以接受来自场景中所有其他点反射的光照,如果表面光滑,则表面就可以反射周围的环境(如镜面,金属),到那时这个计算相当复杂,相当于需要在该点法线方向对应的半球空间上做积分运算才可能计算完全的间接光照,而且光线与物体碰撞后并不会消亡,而是经过反射或折射,改变方向后继续传递,相当于无限递归。实时计算现在来看应该还是不太可能的。正好最近在玩这个,尝试了一下使用RayTracing,屏幕空间发射射线碰撞,反弹次数限制在5次,渲染几个球体,分辨率很低的情况下,在PC平台离线渲染用了将近一个小时(虽然是cpu计算,没有并行)

既然真的反射如此之费,但是反射的效果又如此诱人,前辈们就开始想各种办法来模拟反射。于是乎,各种性能友好的反射方法就应运而生了。最常见的就是环境贴图的方案,目前使用最多的应该是cube map,将对象周围环境烘焙到贴图上进行采样,进阶版其实就是功能更强大的Reflection Probe了。另外平面反射(Planar Reflection)可以用一个反转的相机再渲染一次场景模拟,更复杂一些的是屏幕空间反射Screen Space Reflection(SSR,额,不是某游戏抽的那个)。

Reflect方法推导

我们在各种反射效果的实现中几乎都会用到一个函数:reflect函数,cg语言自带了这个函数,不过还是要来看一下这个函数的实现,之前本人在《图形学相关数学》这篇blog里面推导过,此处就只贴出一张原理图了:

需要注意的就是Reflect的计算过程要求入射方向和法向量都是单位向量,否则结果是不对的。cg的reflect函数自身有没有在计算前做normalize就不得而知了(normalize相对还是一个比较费的操作,无谓增加消耗可能不是很值得,不知道会不会像C++ std的检查一样交给使用者去做喽,如果有知道的大佬可以告诉我哈)。

环境反射

先来看一发最简单的反射,环境反射。本文中的环境反射,指的是静态的环境反射,也就是预先烘焙好的环境贴图。主要优点是性能较好,可以用于任意表面。环境反射包含极坐标映射(效率低,变换非线性),球面映射(生成复杂,非线性),立方体映射(即Cube Map,简单,线性,需要存储6个面),八面体映射(线性,一张图)。

最常用的应该就是Cube Map了,Cube Map其实是上个世纪90年代左右就已经提出的技术了,应用也很广泛,比如天空盒,点光源的Shadow Map,模拟反射等等。所谓Cube Map,顾名思义就是一个立方体的贴图,六个面分别对应一张贴图,由相机朝向6个方向分别渲染得到的。

Cube Map生成(旧版)

先看一下最基本的生成Cubemap的方法,Unity已经为我们提供好了接口(注意,这种方式已经不推荐了,没有HDR效果,但是对Cube Map比较直观的认识):

/********************************************************************
 FileName: CubeMapTool.cs
 Description: 生成Cube Map 工具
 history: 27:6:2018 by puppet_master
 https://blog.csdn.net/puppet_master
*********************************************************************/
using UnityEngine;
using UnityEditor;

public class CubeMapTool : EditorWindow
{
    private Cubemap cubeMap = null;

    [MenuItem("Tools/Cube Map Generate")]
	public static void GenerateCubeMap()
    {
        GetWindow<CubeMapTool>();
    }

    private void OnGUI()
    {
        cubeMap = EditorGUILayout.ObjectField(cubeMap, typeof(Cubemap), false, GUILayout.Width(400)) as Cubemap;
        if (GUILayout.Button("Render To Cube Map"))
        {
            SceneView.lastActiveSceneView.camera.RenderToCubemap(cubeMap);
        }
    }
}

Project面板下右键可以创建一个Cube Map资源,然后勾选Readable(否则写不进去),拖入槽中,然后在编辑器下移动相机到合适位置,点击按钮,就可以将当前场景渲染到Cube Map中了:

Cube Map使用

shader代码如下:

//puppet_master
//https://blog.csdn.net/puppet_master  
//2018.6.27  
//基本的Cube Map反射效果
Shader "Reflection/CubeMapReflection"
{
	Properties
	{
		_CubeTex ("Cube Tex", Cube) = ""{}
	}
	SubShader
	{
		Tags { "RenderType"="Opaque" }

		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			
			#include "UnityCG.cginc"

			struct v2f
			{
				float4 vertex : SV_POSITION;
				float3 reflectionDir : TEXCOORD0;
			};
			
			uniform samplerCUBE _CubeTex;
			
			v2f vert (appdata_base v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				float3 worldNormal = UnityObjectToWorldNormal(v.normal);
				float3 worldViewDir = WorldSpaceViewDir(v.vertex);
				o.reflectionDir = reflect(-worldViewDir, worldNormal);
				return o;
			}
			
			fixed4 frag (v2f i) : SV_Target
			{
				fixed4 col = texCUBE(_CubeTex, i.reflectionDir);
				return col;
			}
			ENDCG
		}
	}
}

效果如下:

 

Reflection Probe

Reflection Probe实际上就是环境映射,可以说是进阶版的环境映射了(此处与环境映射并列并非表示它是一种新类型的反射方式,Reflection Probe就是环境映射,只不过是环境映射的扩展,包含环境映射的全部属性)。传统意义的环境映射,更多的时候是对应材质上的一个属性(想象一下,在每个场景里的材质球上拖一个cube map是多么的蛋疼),但是实际上,用环境映射表示反射,表示的是当前的环境属性,属于当前场景的信息,而不再是属于某个材质,这才更对得起环境映射这个名字。这个环境属性实际上就相当于一个全局的变量,Unity已经为我们设置好了相关的属性信息,我们在shader中可以直接使用。

Reflection Probe配置

Reflection Probe分为几种模式,RealTime,Bake,Custom模式。RealTime是实时生成反射贴图,虽然可以设置成每帧更新一个面,不过效率也比较堪忧;Custom模式可以放进去一个自定义的Cube Map;Bake模式需要提前烘焙,Linghting面板烘焙时可以生成场景自身的Reflection Probe,将Reflection Probe Static标签的对象+天空盒进行烘焙。我们可以在场景中放置ReflectionProbe,但是如果不放置,也不至于完全没有效果,当场景有天空盒并且开启了Refrection,Unity默认会给我们一个天空盒的ReflectionProbe的效果,在烘焙的时候就会生成这个信息,与lightmap在同级目录。

上文中提到过相机渲染场景到CubeMap中,比较麻烦,所以新版本的方式就是直接设置Reflection Probe,然后烘焙。Unity直接为我们提供了这个功能,在Reflection Probe面板或者Lighting面板均可以进行烘焙。

关于Reflection Probe的设置方法,可以参考Unity官方文档:Reflection ProbeLightting中Environment Reflection相关

Reflection Probe使用

直接使用Reflection Probe比较简单,Unity已经为我们定义好了Reflection Probe的相关shader uniform变量,而且会根据各种设置把合适的Reflection Probe填充到shader变量中。

先看一下Unity中定义的Reflection Probe相关变量:

// ----------------------------------------------------------------------------
// Reflection Probes

UNITY_DECLARE_TEXCUBE(unity_SpecCube0);
UNITY_DECLARE_TEXCUBE_NOSAMPLER(unity_SpecCube1);

CBUFFER_START(UnityReflectionProbes)
    float4 unity_SpecCube0_BoxMax;
    float4 unity_SpecCube0_BoxMin;
    float4 unity_SpecCube0_ProbePosition;
    half4  unity_SpecCube0_HDR;

    float4 unity_SpecCube1_BoxMax;
    float4 unity_SpecCube1_BoxMin;
    float4 unity_SpecCube1_ProbePosition;
    half4  unity_SpecCube1_HDR;
CBUFFER_END

定义,采样,传递CubeMap的函数建议也使用Unity官方提供的函数(HLSLSupport,此处贴出非DX11定义):

// Cubemaps
#define UNITY_DECLARE_TEXCUBE(tex) samplerCUBE tex
#define UNITY_ARGS_TEXCUBE(tex) samplerCUBE tex
#define UNITY_PASS_TEXCUBE(tex) tex
#define UNITY_PASS_TEXCUBE_SAMPLER(tex,samplertex) tex
#define UNITY_DECLARE_TEXCUBE_NOSAMPLER(tex) samplerCUBE tex
#define UNITY_SAMPLE_TEXCUBE(tex,coord) texCUBE (tex,coord)

这样,在使用Reflection Probe就很简单啦,上面的CubeMap采样方法的fragment shader修改一下即可,使用Reflection Probe渲染时支持HDR格式,勾选之后可以将贴图编码为HDR格式,所以在使用之前,需要先进行一步解码操作才能得到正确的结果(与宏和配置有关)。

half4 frag (v2f i) : SV_Target
{
	half4 rgbm = UNITY_SAMPLE_TEXCUBE(unity_SpecCube0, i.reflectionDir);
	half3 color = DecodeHDR(rgbm, unity_SpecCube0_HDR);
	return half4(color, 1.0);
}

直接使用天空盒作为Reflection Probe效果如下:

使用环境反射+高光+Bloom+HDR套餐,可以做出金属效果(不由得让我想起《终结者2》里面的液态机器人了)。

Box Projection Reflection Probe

Cube Map正常来说是不考虑物体与被反射物体之间距离的,类似人物盔甲,圆球等不容易看出穿帮的物体效果较好,而如果反射的物体距离被反射的物体很远时,例如无限远的天空盒,反射效果也比较好。比如上图中的反射天空盒效果。但是当反射对象与被反射对象距离较近时,用Reflection Probe(Cube Map)效果就有些差了。我们把反射材质放在地面上,下图中天空效果反射较好,但是栅栏的反射就有些奇怪了:

如果离近看,效果就不对了,在地面的反射完全看不到栅栏:

不过,我们可以用一种叫做Box Projection Cube Map的技术,通过重新修正反射采样方向,得到相对较好的效果,如下图:

使用Box Projection Cube Map很简单,因为Unity已经为我们写好了函数以及所需的全部数据已经传递给了内置shader变量中,我们可以很容易地通过修改几句代码得到上图的效果:

half4 frag (v2f i) : SV_Target
{
	float3 reflectDir = i.reflectionDir;
	//通过BoxProjectedCubemapDirection函数修正reflectDir
	reflectDir = BoxProjectedCubemapDirection(reflectDir, i.worldPos, unity_SpecCube0_ProbePosition, unity_SpecCube0_BoxMin, unity_SpecCube0_BoxMax);
	half4 rgbm = UNITY_SAMPLE_TEXCUBE(unity_SpecCube0, reflectDir);
	half3 color = DecodeHDR(rgbm, unity_SpecCube0_HDR);
	return half4(color, 1.0);
}

为何直接采样效果不对,而用了这个Magic的函数修改了反射方向后,反射效果就大大提升了呢。

来分析一下,反射时,我们采样Reflection Probe时的向量对应的起点是Reflection Probe的中心点R,方向是在当前像素点计算的视线方向对应法线方向的反射向量PK,如下图所示:

ABCD为Reflection Probe对应的Cube,P为当前像素点,E为相机位置,N为当前像素点对应的法线方向,PK为视线方向计算得到的反射方向。如果我们按照PK方向采样Reflection Probe的话,那么就会是RL(平行于PK)方向。如果R与P点重合,采样结果是正确的,但是只有这一个点正确,其他所有点的结果都是不正确的。为了保证采样效果正确,需要求得真正的采样方向RK。

Reflection Probe的位置R,采样位置P已知,RK = RP+PK。RP向量已知,PK方向已知,所以问题就简化成了求PK方向距离。

首先,K是在Cube包围盒上的点,所以第一个问题就是在于我们需要求得包围盒边界的坐标值才好计算采样碰撞点K,Unity在unity_SpecCube0_BoxMin,unity_SpecCube0_BoxMax这两个内置的shader uniform变量中为我们存储了包围盒的BoundingBox最大最小值,不仅仅在划定Reflection Probe影响范围上有用,还有一个重要的作用就在于Box Projection上。一个Cube,八个顶点,分为八个象限,我们只需要关心PK所在方向上的边界值即可。

这里我们需要用到一个很好玩的运算符,? :运算符。与C中作用一致,但是在shader中,对一个向量使用这个运算符,会分别对向量的每个分量进行? :运算。比如下面的计算:

float3 test = float3(0,0,0.5);
fixed3 col1 = fixed3(1,1,1);
fixed3 col2 = fixed3(0,0,0);
color = (test > 0.0f) ? col1 : col2;
//result : fixed3(0, 0, 1);

有了这个运算符,我们就可以通过(PK > 0.0f) ? rbmax : rbmin得到在PK方向上的包围盒坐标值了,我们假设其为Bound。

不过还有一个问题,在三维空间中,一个向量可能与三维的面三个面相交,如下图所示(这里只画了一个二维方向的示意图,横向表示x轴,纵向表示y轴,结论可以扩展到三维空间):

我们用正常表示射线的方式表达一个向量,已知起始点P,向量方向PK表示为Dir,P + t *Dir = K。t就为该方向上的距离也就是我们最终要求得的PK长度。上图中,我们可能有两个交点坐标,分别为K和H:

K = P + t1 * Dir

H = P + t2 * Dir

Z = P + t3 * Dir(扩展到三维方向)

K和H的具体位置我们不知道,但是我们知道它们在一个方向上的值,即K.x = Bound.x,H.y = Bound.y,Z.z = Bound.z,分别考虑各自方向:

K.x = P.x + t1 * Dir.x

H.y = P.y + t2 * Dir.y

Z.z = P.z + t3 * Dir.z(扩展到三维方向)

再重新整合到一个向量来表示的话,就可以表示成Bound = P + T * Dir,其中T = (t1,t2,t3)。T = (Bound - P) / Dir

不过实际上,我们最终只需要一个碰撞点,从上图很容易看出,我们需要的碰撞点是距离P点最近的点,即沿着P + t * Dir方向行进t最短距离即可,也就是说,我们最终需要的t值只需要从T向量的(t1,t2,t3)中取得最小的值,就是最终PK的距离了。

代码如下:

 half3 nrdir = normalize(worldRefl); //PK方向
 half3 bounding = (nrdir > 0.0f) ? rbmax : rbmin //求得PK方向上包围盒的坐标
 half3 T = (bounding - worldPos) / ndir; //求得三方向上的T值
 half t = min(min(T.x, T.y), T.z); //求得最小t
 
 half3 rp = worldPos - cubeMapCenterPos; //向量RP
 half3 pk = worldPos + t * nrdir; //向量pk
 half3 rk = rp + pk; //向量rk为最终采样方向

再来看一下官方源码的BoxProjectedCubemapDirection函数中的计算:

inline half3 BoxProjectedCubemapDirection (half3 worldRefl, float3 worldPos, float4 cubemapCenter, float4 boxMin, float4 boxMax)
{
    // Do we have a valid reflection probe?
    UNITY_BRANCH
    if (cubemapCenter.w > 0.0)
    {
        half3 nrdir = normalize(worldRefl);

        #if 1
            half3 rbmax = (boxMax.xyz - worldPos) / nrdir;
            half3 rbmin = (boxMin.xyz - worldPos) / nrdir;

            half3 rbminmax = (nrdir > 0.0f) ? rbmax : rbmin;

        #else // Optimized version
            half3 rbmax = (boxMax.xyz - worldPos);
            half3 rbmin = (boxMin.xyz - worldPos);

            half3 select = step (half3(0,0,0), nrdir);
            half3 rbminmax = lerp (rbmax, rbmin, select);
            rbminmax /= nrdir;
        #endif

        half fa = min(min(rbminmax.x, rbminmax.y), rbminmax.z);

        worldPos -= cubemapCenter.xyz;
        worldRefl = worldPos + nrdir * fa;
    }
    return worldRefl;
}

从Box Projection Cube Map的实现来看,的确可以通过修正采样的方向,找到正确的采样方向,达到较好的反射效果。不过买家秀和卖家秀总是有区别的,Box Projection方法在边界有畸变(上图的山有些扭曲),而且,最重要的问题在于,反射的范围要和Reflection Probe的范围一致,否则结果是不对的。所以个人认为,室内场景等边界与Reflection Probe边界一致的这种场景,使用这个技术比较合适,可以用一个比较cheap的方案达到近似plannar reflection的效果。

Box Projection Cube Map似乎也是个挺新的技术,关于BoxProjectionCubeMap的原理可以参考GameDev上发明这个的帖子。

 

Planar Reflection

Planar Reflection,平面反射。顾名思义,就是在平面上运用的反射,名字也正是这种反射方式的限制,只能用于平面,而且是高度一致的平面,多个高度不一的平面效果也不正确(除非针对每个平面单独计算,消耗嘛,你懂得)。一般情况下,一个平面整体反射效果基本可以满足需求,而且对于实时渲染反射效果,需要将要反射的物体多渲染一次,控制好层级数量的话,性能至少是在可以接受的范围,并且相对于其他几种反射效果来说,平面反射的效果是最好的,所以Planar Reflection目前是实时反射效果中使用得比较多的一个方案。

平面方程以及平面反射相关推导

既然说到平面反射,自然,我们得先找到一个方法表示一个平面。常见的两种平面表示方法:

一般式:可以表示为Ax + By + Cz + D = 0(其中A,B,C,D为已知常数,并且A,B,C不同时为零)。

点法式:平面可以由平面上任意一点和垂直于平面的任意一个向量确定,这个垂直于平面的向量称之为平面的法向量。假设平面上一个确定点P0(x0,y0,z0),法向量为N(a,b,c),那么平面上任意点P(x,y,z),满足PP0 · N = 0,即(x - x0, y - y0,z - z0) · (a,b,c) = 0。

两种方式各有优点,一般式变量数量少,但是没有什么有用信息,点法式包含面法线信息,但是需要变量数较多。那么把二者结合一下,或者说变形一下即可。比如点法式点乘展开后得到ax + by + cz + (-ax0 - by0 - cz0) = 0,即为一般式。其中-ax0 - by0 - cz0为常数,表示为d。那么点法式方程就可以表示为N·P + d = 0的形式,其中P为平面上任意一点,N为法向量。那么,d表示神魔恋?要是硬说几何意义的话,可以表示为原点到平面上任意点在法线上的投影长度。所以要表示一个面,我们知道面的法向量N,然后再知道面上任意一点P0,就可以求得d = -Dot(N,P0)。

知道了平面方程的表示,我们还需要再温故一下反射的基本原理,开头我们推导Reflect函数时有过一个示意图,这里面我们再画一张针对平面的:

我们要想求得一个点A相对于平面的反射点A‘,根据反射定律可知,|AB| = |A'B|,BA与N同向,已知A点坐标的话,我们就可以求得A’ = A - 2*|AB| * N。

所以,问题就变成了求空间中任意一点A,到平面N·P + d = 0的距离|AB|,再来一张图,推导一下AB距离:

P为平面上任意一点,A为空间中一点,B为点A在平面上的投影点,N为平面法线(为简化问题,N视为单位向量)。PAB构成三角形,BAP夹角θ。从三角形余弦定理易得|BA| = |PA|cosθ;再通过向量点乘的公式,BA与N同向,PA与N夹角也为θ,所以dot(PA,N)= |PA||N|cosθ => cosθ = dot(PA,N)/ (|PA||N|),代入|BA| = |PA|cosθ,|BA| = dot(PA,N)/ |N|,由于N为单位向量,故|BA| = dot(PA,N)。将PA拆分,|BA| = dot(A - P, N) = dot(A,N) - dot(P,N)。这时候就需要我们的平面方程推导公式了,根据点法式的结果我们知道,对于平面上任意一点P,可以表示为P·N + d = 0的形式,即dot(P,N) = -d.所以最终|BA| = dot(A,N)+ d。进而得到A对于平面的对称点A’ = A - 2(dot(A,N) + d)N。

我们已知N(nx,ny,nz),d的值,A(x,y,z)点作为我们要变换的点,我们需要将A’(x’,y’,z‘)的计算公式表示为矩阵的形式,需要将各分量拆分出来:

x’ = x - 2(x * nx + y * ny + z * nz + d)* nx = (1 - 2nx * nx)x +( -2nx * ny)y + (-2nx * nz)z + (-2dnx)

y’ = y - 2(x * nx + y * ny + z * nz + d) * ny = (-2nx * ny)x + (1 - 2ny * ny)y + (-2ny * nz)z + (-2dny)

z’ = z - 2(x * nx + y * ny + z * nz + d) * nz = (-2nx * nz)x  + (-2ny * nz)y + (1 - 2nz * nz)z + (-2dnz)

如此复杂的计算公式,我们已经把x,y,z对应的系数和常数抽取出来了,是时候看一下矩阵的威力了,把上述计算公式改为矩阵的形式,Unity是OpenGL风格的矩阵,即矩阵 * 列向量的形式:

  • 123
    点赞
  • 353
    收藏
    觉得还不错? 一键收藏
  • 33
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 33
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值