【Unity Shader】用Cubemap实现天空盒和环境映射

1 关于Cubemap

Cubemap在实时渲染中有很多应用,最常见的就是实现天空盒(Skybox)和环境映射(Environment Mapping)。

2 实现天空盒

2.1 实现原理

天空盒不陌生,而且一定还听说过天空球吧!因为实现天空盒的技术除了六面的立方体(cubemap),还可以用球面实现——这个时候天空盒就被叫做穹顶(Skydome)。因为这篇博客是cubemap专场,这里就重点讲一讲天空盒。

  • 是一个六面体封闭盒子纹理——这个盒子是由就像个展开的盒子(就像初中学几何展开图那样),可以理解成渲染除了一个巨大的六面体封闭盒子纹理,这六个面每个面的纹理都使用了cubemap立方体纹理映射技术;
  • 为了在游戏中模拟出符合实际的远处景观效果——当一个场景中使用了Skybox,摄像机移动的同时远处的景物纹理也跟着移动,这是为了模拟现实中“月亮走,我也走”、“永远走不到地平线尽头”这种现实中由于景物距离太远、太大带来的相对关系的错觉;
  • 它只是一个背景——另外,不得不提一点的是,如果只从按这个方法设置的Skybox,其实只是相对于摄像机的一个“背景”,至于一些光照的模拟,那还是要创建一个实实在在的Cubemap(下面会讲如何创建)。
  • 室内也有用到——天空盒不仅仅局限在“天空”了,现在例如教堂等宽敞的室内环境,也可以用Skybox来模拟;

此外,我还找到了一个20年发布的视频,以星际战甲游戏为例解释天空盒的:3分钟告诉你什么是天空盒?能把物品放大百倍的天空盒能用来做什么?warframe/星际战甲_哔哩哔哩_bilibili

以及知乎的一篇一步步学OpenGL(25) -《Skybox天空盒子》 - 知乎 (zhihu.com)

这两个可以一起辅助理解天空盒,同时也可以发现:现在常说的“天空盒”已经是实现纹理映射的天空盒了。

2.2 Unity中实现Skybox

首先跟其他任何材质一样,创建一个SkyboxMat材质也需要一个对应的Shader,Unity Shader自带的6 Sided Skybox满足了这个需求,需要导入6张创建立方体纹理需要的纹理,Wrap Mode选择为Clamp实现无缝拼接,就能得到如下效果:

如果想实现同一个场景中不同摄像机对应的Skybox不同,只需为当前摄像机创建一个新的Skybox组件就行! 

3 环境映射概述

参考自:环境映射技术漫谈 - 知乎 (zhihu.com)

IBL综述 - 知乎 (zhihu.com)

3.1 什么是环境映射? 

环境映射/环境纹理贴图(Environment Mapping, EM)也被称为反射映射(Reflection Mapping)。相当于设置一个虚拟的眼睛,生成一张关于周围环境的、虚拟的纹理图,然后把纹理图映射到模型上,此时模型表面的样子就是这张纹理图的一个景象,如果放置一个小球,就仿佛小球反射了周围场景的景象。

与光线追踪的关系

这就要谈及环境映射出来的效果的特点了。与其说是特点,不如说我们想用环境映射实现怎样的效果。通常环境映射是为了实现光滑物体表面反射形成周围环境影像的效果,这个效果是属于环境光的。

而实现这一效果除了环境映射这一技术还有光线追踪(Ray Tracing, 又或者说全局光照,Global Illumination, GI),但如果用光线追踪仅仅为了实现这个小目的就显得有些大材小用了,因此实时渲染中更多的还是选择环境映射技术来实现。

3.2 什么是IBL?

环境贴图是基于IBL(Image Based Lighting, 基于图像光照)从而实现的,了解IBL也很有必要。

如何理解“基于图像”?

有了之前的天空盒的学习,很容易理解“基于图像”是指基于那6张按照特定顺序排列的纹理。IBL通常结合cubemap,我们在进行学习、使用时会拿到一张现成的cubemap,通常一张cubemap采集自真实的照片或者从3D场景中生成,由6张按规则排序的纹理图片组成。IBL相当于一个无限大的球面光源在照射场景,每张图片的每个像素都会被当作一个光源进行后续的光照计算。

Cubemap与球体图

这里的图像,除了本博客主要涉及的cubemap立方体图以外, 还可以是球体图(Equirectangular map),类似世界地图的那种。当然,无论是立方体还是球体,区别不大,坐标可以相互转换的,二者对比如下图(图源IBL综述 - 知乎 (zhihu.com)),上为交叉布局的Cubemap,下为Cubemap转换过来的全景布局的球体图:

参考文章还提出了:一般立方体图用的比较多,即使导入的是球体图也会转换成立方体图,立方体渲染比球体简单很多,同时立方体也会更加节省宽带(一些光照计算很少用到的无效位置会进行压缩)。

3.3 环境映射的步骤

参考自环境映射技术漫谈 - 知乎 (zhihu.com)

前面提到了,环境映射就相当于把整个环境信息“贴”到一个表面上,这个表面可以是球也可以是一个立方体,通俗来讲实现它有以下三个步骤:

  • 将相机放在场景中心,获取环境图像
  • 将图像“贴”在球/立方体表面上
  • 绘制物体表面的镜面效果时,直接从上述图像纹理采样获得对应的纹素

更细致的算法实现步骤为:

  • 创建环境贴图
  • 计算顶点法向量n和顶点到视点的向量v
  • 根据n和v计算反射向量r
  • 根据反射向量r与贴图纹理坐标的映射关系,计算纹理坐标(u, v)
  • 用(u, v)采样环境贴图对应的纹素

3.4 几种环境映射技术

根据上面的算法步骤可以发现,我们还需要额外确定一个东西——反射向量r与贴图纹理坐标的映射关系,而这个映射关系就涉及到了几种不同的环境映射技术,关于这一点可以直接看环境映射技术漫谈 - 知乎 (zhihu.com)本篇博客就不再赘述。

通过了本章节的概述,相信对环境映射已经有了一个大概的了解,接下来就正式进入使用Cubemap实现环境映射的具体步骤。

4 创建Cubemap

在Unity中创建Cubemap最常用两种方法,指路我的另一篇博客,这里就不赘述了:【Unity Shader】Unity中如何创建Cubemap?

5 环境映射:反射效果

使用了反射效果之后,物体就是一个镜面反射的状态,像镀了一层金属。

5.1 实现代码

Shader "Unity Shaders Book/Chapter 10/Reflection"
{
    Properties {
        _Color ("Color", Color) = (1, 1, 1, 1)
        _ReflectColor ("Reflect Color", Color) = (1, 1, 1, 1)
        _ReflectAmount ("Reflect Amount", range(0, 1)) = 1
        _Cubemap ("Reflect Cubemap", Cube) = "_Skybox" {}
    }

    SubShader {
        Pass {
            Tags { "lightMode"="ForwardBase" }

            CGPROGRAM

            #pragma multi_compile_fwdbase

            #pragma vertex vert
            #pragma fragment frag
            #include "Lighting.cginc"
            #include "AutoLight.cginc"

            //Properties
            fixed4 _Color;
            fixed4 _ReflectColor;
            fixed _ReflectAmount;
            samplerCUBE _Cubemap;
            
            struct a2v {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
            };

            struct v2f {
                float4 pos : SV_POSITION;
                float3 worldPos : TEXCOORD0;
                fixed3 worldNormal : TEXCOORD1;
                fixed3 worldViewDir : TEXCOORD2;
                fixed3 worldLightDir : TEXCOORD3;
                fixed3 worldReflect : TEXCOORD5;
                SHADOW_COORDS(4)
            };

            v2f vert(a2v v) {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.worldNormal = UnityObjectToWorldNormal(v.normal).xyz;
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos).xyz;
                o.worldLightDir = UnityWorldSpaceLightDir(o.worldPos).xyz;
                o.worldReflect = reflect(-o.worldViewDir, o.worldNormal);

                TRANSFER_SHADOW(o);

                return o;
            }

            fixed4 frag(v2f i) : SV_Target {
                fixed3 worldNormal = normalize(i.worldNormal);
                fixed3 worldViewDir = normalize(i.worldViewDir);
                fixed3 worldLightDir = normalize(i.worldLightDir);

                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
                fixed3 diffuse = _LightColor0.rgb * _Color.rgb * saturate(dot(worldNormal, worldLightDir));
                //用反射方向采样cubemap
                fixed3 reflect = texCUBE(_Cubemap, i.worldReflect).rgb * _ReflectColor.rgb;

                UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

                fixed3 color = ambient + lerp(diffuse, reflect, _ReflectAmount) * atten;
                return fixed4(color, 1.0);

            }
            ENDCG
        }
    }
    FallBack  "Reflective/VertexLit"
}

5.2 效果

5.3 一些要点

如何去写Shader就不需要赘述了,我们重点关注于代码中出现的新朋友:

Cube对应的变量 

首先,关注于Shader中使用Cubemap用到的不同于之前2D纹理的"Cube"和"samplerCUBE":

_Cubemap ("Reflect Cubemap", Cube) = "_Skybox" {}

...

samplerCUBE _Cubemap;

texCUBE

接着直接来到如何计算反射部分:

fixed3 reflect = texCUBE(_Cubemap, i.worldReflect).rgb * _ReflectColor.rgb;

用到了texCUBE,用法可以直接类比tex2D,只不过前者需要用一个反射方向去采样,后者直接用uv坐标采样。

反射方向worldRflect

代码中并没有对反射方向worldRflect归一化,这是因为texCUBE使用的矢量只需要表示出方向即可,不需要归一化参与到其他的计算中。

变量_ReflectAmount

你可能还注意到,代码中定义了一个_ReflectAmount变量,如何使用的呢?

fixed3 color = ambient + lerp(diffuse, reflect, _ReflectAmount) * atten;

看上去是为了将漫反射颜色diffuse和反射颜色reflect中和一下。

等一下?漫反射和反射?这有没有让你想起来什么?——没错!菲涅尔项(games101中学到过)啊!菲涅尔项就是为了模拟反射处反射光和折射(漫反射)光强之间的比值变化,关于菲涅尔反射后续会涉及到,这里我估计只是用了一个简单的值来插值,模拟二者过度。 

6 环境映射:折射效果

除了反射肯定还有折射,提到折射一定能想到斯涅耳定律(Snell's Law),老朋友了!具体定律我早在101的作业中就学习过,这里不赘述:GAMES101作业5-从头到尾理解代码&Whitted光线追踪

6.1 什么物体需要考虑折射?

我认为与其是扒清楚斯涅尔定律的内部原理(原理分析指路一篇我觉得写得还不错的BRDF学习系列(一) 光的反射与折射 - 知乎 (zhihu.com)),作为使用者来说我们更需要知道在什么情况下需要考虑到折射:当光线从透明物体穿越到另一个透明物体时,就会发生折射(传播方向发生改变)。也就是说!如果一个物体我们想要他是透明材质,例如玻璃等,就需要考虑折射,此时环境映射就需要实现折射效果。

6.2 实现代码

Shader "Unity Shaders Book/Chapter 10/Refraction"
{
    Properties {
        _Color ("Color", Color) = (1, 1, 1, 1)
        _RefractColor ("Refract Color", Color) = (1, 1, 1, 1)
        _RefractAmount ("Refract Amount", range(0, 1)) = 1
        _RefractRatio ("Refract Rastio", range(0.1, 1)) = 0.5
        _Cubemap ("Refract Cubemap", Cube) = "_Skybox" {}
    }

    SubShader {
        Pass {
            Tags { "lightMode"="ForwardBase" }

            CGPROGRAM

            #pragma multi_compile_fwdbase

            #pragma vertex vert
            #pragma fragment frag
            #include "Lighting.cginc"
            #include "AutoLight.cginc"

            //Properties
            fixed4 _Color;
            fixed4 _RefractColor;
            fixed _RefractAmount;
            fixed _RefractRatio;
            samplerCUBE _Cubemap;
            
            struct a2v {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
            };

            struct v2f {
                float4 pos : SV_POSITION;
                float3 worldPos : TEXCOORD0;
                fixed3 worldNormal : TEXCOORD1;
                fixed3 worldViewDir : TEXCOORD2;
                fixed3 worldLightDir : TEXCOORD3;
                fixed3 worldRefract : TEXCOORD5;
                SHADOW_COORDS(4)
            };

            v2f vert(a2v v) {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.worldNormal = UnityObjectToWorldNormal(v.normal).xyz;
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos).xyz;
                o.worldLightDir = UnityWorldSpaceLightDir(o.worldPos).xyz;
                o.worldRefract = refract(-normalize(o.worldViewDir), normalize(o.worldNormal), _RefractRatio);

                TRANSFER_SHADOW(o);

                return o;
            }

            fixed4 frag(v2f i) : SV_Target {
                fixed3 worldNormal = normalize(i.worldNormal);
                fixed3 worldViewDir = normalize(i.worldViewDir);
                fixed3 worldLightDir = normalize(i.worldLightDir);

                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
                fixed3 diffuse = _LightColor0.rgb * _Color.rgb * saturate(dot(worldNormal, worldLightDir));
                //用折射采样cubemap
                fixed3 refract = texCUBE(_Cubemap, i.worldRefract).rgb * _RefractColor.rgb;

                UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

                fixed3 color = ambient + lerp(diffuse, refract, _RefractAmount) * atten;
                return fixed4(color, 1.0);

            }
            ENDCG
        }
    }
    FallBack  "Reflective/VertexLit"
}

6.3 效果

6.4 一些要点

refract(i, n, ri)

fixed3 refract = texCUBE(_Cubemap, i.worldRefract).rgb * _RefractColor.rgb;

代码与反射相比更加复杂一点点,但复杂就复杂在用到的计算函数refract需要输入三个参数:refract(i, n, ri),其中除了入射向量i、法向量n外还有入射和折射介质折射率的比值ri,i和n输入的都必须是归一化后的向量

_RefractRatio

这个参数就是上面提到的输入的第三个参数,入射和折射光线所在介质的折射率之间的比值。但空气折射率接近1.0,其余的透明介质的折射率几乎都大于1.0,所以设置值时range(0.1, 1)

7 环境映射:菲涅尔反射

关于菲涅尔定律,可以直接看看Everything has Fresnel – Filmic Worlds,有举出现实生活中例如砖块、衣服布料等材质物体上出现的菲涅尔效果。我在GAMES101作业5-从头到尾理解代码&Whitted光线追踪中也对菲涅尔效应做了简单的介绍。

真实的菲涅尔效应很复杂(也是101作业用的那个原式子),实时渲染中通常用Schlick菲涅尔近似等式来计算:

F_{Schlick}(v,n)=F_{0}+(1-F_{0})(1-v\cdot n)^{5}

其中,F_{0} —— 指当光线垂直反射时,反射占的比例,我们可以用来控制菲涅尔反射的整体强度;其他的参数就不多说了。

如果想了解更详细可以参考:BRDF学习系列(二) 漫反射、高光反射、菲涅尔反射 - 知乎 (zhihu.com)

7.1 实现代码

Shader "Unity Shaders Book/Chapter 10/Fresnel"
{
    Properties {
        _Color ("Color", Color) = (1, 1, 1, 1)
        _FresnelScale ("Fresnel Scale", range(0, 1)) = 0.5
        _Cubemap ("Reflect Cubemap", Cube) = "_Skybox" {}
    }

    SubShader {
        Pass {
            Tags { "lightMode"="ForwardBase" }

            CGPROGRAM

            #pragma multi_compile_fwdbase

            #pragma vertex vert
            #pragma fragment frag
            #include "Lighting.cginc"
            #include "AutoLight.cginc"

            //Properties
            fixed4 _Color;
            fixed _FresnelScale;
            samplerCUBE _Cubemap;
            
            struct a2v {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
            };

            struct v2f {
                float4 pos : SV_POSITION;
                float3 worldPos : TEXCOORD0;
                fixed3 worldNormal : TEXCOORD1;
                fixed3 worldViewDir : TEXCOORD2;
                fixed3 worldLightDir : TEXCOORD3;
                fixed3 worldReflect : TEXCOORD5;
                SHADOW_COORDS(4)
            };

            v2f vert(a2v v) {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.worldNormal = UnityObjectToWorldNormal(v.normal).xyz;
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos).xyz;
                o.worldLightDir = UnityWorldSpaceLightDir(o.worldPos).xyz;
                o.worldReflect = reflect(-o.worldViewDir, o.worldNormal);

                TRANSFER_SHADOW(o);

                return o;
            }

            fixed4 frag(v2f i) : SV_Target {
                fixed3 worldNormal = normalize(i.worldNormal);
                fixed3 worldViewDir = normalize(i.worldViewDir);
                fixed3 worldLightDir = normalize(i.worldLightDir);

                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
                fixed3 diffuse = _LightColor0.rgb * _Color.rgb * saturate(dot(worldNormal, worldLightDir));
                //用反射方向采样cubemap
                fixed3 reflect = texCUBE(_Cubemap, i.worldReflect).rgb;

                UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
                fixed fresnel = _FresnelScale + (1 - _FresnelScale) * pow((1 - dot(worldViewDir, worldNormal)), 5);

                fixed3 color = ambient + lerp(diffuse, reflect, saturate(fresnel)) * atten;
                return fixed4(color, 1.0);

            }
            ENDCG
        }
    }
    FallBack  "Reflective/VertexLit"
}

7.2 效果

展示的是当前FresnelScale=0的效果,是个具有边缘光效果的漫反射物体(场景中有一个方向光):

这是因为,当FresnelScale=0时,lerp(diffuse, reflection, saturate(fresnel))=pow((1 - dot(worldViewDir, worldNormal)), 5),这个值是几乎接近diffuse的,也就是着色完全由环境光+漫反射颜色代替了,因此本体几乎是接近是漫反射的效果。

7.3 为什么有边缘光效果?

我们需要知道!这种边缘光效果是无论fresnelScale取值多少都有的。

其原因也很好理解,当接近物体边缘时,视线方向viewDir和模型边缘表面顶点的法线n是几乎接近垂直的,也就意味着dot(worldViewDir, worldNormal)值接近0 -> fresnel的值接近1 -> lerp(diffuse, reflection, saturate(fresnel))=reflection,这意味着!边缘处的着色始终都会是几乎接近reflection的。

猜你喜欢

转载自blog.csdn.net/qq_41835314/article/details/127341597