


边缘光(rimlight)就是摄像机朝着光源方向看,物体边缘的一层光。说实话这东西让我理解应该是符合传统的光照模型的,光线dot法线然后顺着光源看,边缘就是亮的,所以物理点的话这个rimlight不需要特意去搞。但是换到完全不物理的卡通渲染,搞个rimlight可以让模型看的更符合二刺螈的物理

摄于A区3楼北边男撤硕

先确定3个向量
光线方向:L
顶点法线:N
摄像机方向:V
边缘光,越靠近边缘越亮。那么就用最最基本的(1-NdotV),来个step减少色阶让渐变变成嗯变,看看效果。

大腿似乎还可以

这里非常不彳亍
这腋窝和莱子的地方也有边缘光,显得很不物理,腋窝这里法线与视线很垂直所以有边缘光,但这里不该出现边缘光,光不可能无视你的胳膊直接穿过来,也不可能从下往上照射让边缘光向上延伸这么大一片。莱子部分同理。
来看看原神的边缘光咋整的。



原神的边缘光相对屏幕等距离,方向统一,就很好看。
那么来看看怎么做出这个效果吧。
做这个之前我参照了群里大佬的方法,还看了看知乎,知乎看到一个哥们是沿法线膨胀然后在膨胀后的位置采样深度图,类似于描边了,感觉有点难做,反正是屏幕空间的效果,还是偏移屏幕空间的坐标来做吧。但他那个文章可以看看,对于理解坐标变变变有帮助。
https://zhuanlan.zhihu.com/p/365339160

原神的边缘光是依靠在屏幕空间偏移,采样深度纹理,进行一个相减再用step()阈值化。

场景

深度图
上面这两张图中下边这张图就是对场景的深度纹理进行线性化后得到的图像,关于为什么要进行线性的变换,涉及到裁剪变换的一些知识,有兴趣的可以去看看乐乐姐的入门精要的相关部分,讲的很清楚。我们只需要知道直接采样深度纹理得到的并不是线性的深度。线性的深度应该是近裁剪面到远裁剪面之间的所有的点的Z值都和0到1有线性关系,如果物体深度为0.5那么其距离摄像机距离一定是在近裁剪面和远裁剪面的正中间。

线性深度
深度图上距离摄像机近的物体呈现黑色,数值接近0,远的物体呈现白色,接近1,我们要利用这张图达到一种边缘检测的效果,可以想象一下,对场景中的球上的所有片元,向屏幕的左或右方向探索一定距离,再采样一个深度,用新采样的深度减去原本的深度,超过某个阈值则视为边缘。
用图片解释一下这个过程。

这张图中,红色是片元1,绿色是片元2,简称为F1,F2。注意是片元,不是顶点。片元是屏幕空间的一个单位。

对于F1,F2,分别相对屏幕向左移动相同的距离,到达一个新的位置,采样这个位置的深度
F1,F2原本都为黑色,深度都很小,都把他们假设为0,向左探索一段距离后采样的另两个深度中,F1采样到的新深度为灰色,就假设是0.8吧,F2采样到的依然是一个黑色0。
那么对于F1,F2,分别将偏移后采样到的深度与自身的深度相减,得到的结果一个是0.8一个是0,我们就让差距大于0.5的片元为呈现边缘光,那么F1为边缘光部分F2为无边缘光的部分。这只是两个片元,可以自行脑补球上所有片元都进行这样的计算得到的效果。
代码部分:
写个脚本开启camera的深度模式,然后把脚本套在摄像机上
script部分
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GetDepth : MonoBehaviour
{
private Camera currentCamera = null;
void Awake() {
currentCamera = GetComponent<Camera>();
}
void Start()
{
currentCamera.depthTextureMode = DepthTextureMode.Depth;
}
void Update()
{
}
}
shader部分
Shader "Unlit/screenRimlight"
{
Properties
{
_MainTex ("Texture", 2D) = "black" {}
_Color("Color",color) = (0,0,0,0)
_RimOffect("RimOffect",range(0,1)) = 0.5
_Threshold("RimThreshold",range(-1,1)) = 0.5
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct v2f
{
float2 uv : TEXCOORD0;
float clipW :TEXCOORD1;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
sampler2D _CameraDepthTexture;
float4 _MainTex_ST;
float4 _Color;
float _RimOffect;
float _Threshold;
v2f vert (appdata_full v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
o.clipW = o.vertex.w ;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float2 screenParams01 = float2(i.vertex.x/_ScreenParams.x,i.vertex.y/_ScreenParams.y);
float2 offectSamplePos = screenParams01-float2(_RimOffect/i.clipW,0);
float offcetDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, offectSamplePos);
float trueDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, screenParams01);
float linear01EyeOffectDepth = Linear01Depth(offcetDepth);
float linear01EyeTrueDepth = Linear01Depth(trueDepth);
float depthDiffer = linear01EyeOffectDepth-linear01EyeTrueDepth;
float rimIntensity = step(_Threshold,depthDiffer);
float4 col = float4(rimIntensity,rimIntensity,rimIntensity,1);
return col;
}
ENDCG
}
}
FallBack "Diffuse"
} 分块来看看shader部分
首先是v2f结构体和顶点着色器
struct v2f
{
float2 uv : TEXCOORD0;
float clipW :TEXCOORD1;
float4 vertex : SV_POSITION;
};
v2f vert (appdata_full v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
o.clipW = o.vertex.w ;
return o;
}
uv和vertex都无关紧要,重要的是clipW,它决定了我们的边缘光是否有透视效果。这涉及裁剪空间的知识,有点复杂不多赘述,简单来说就是,我们要让屏幕空间的偏移有近大远小,也就是透视的效果,就要让距离摄像机远的顶点除以某个与距离有关的权值,使其缩小。
视锥体及里边的顶点经过P变换后变为一个正四棱锥,长下边这样,我们在顶点着色器里写的
o.vertex = UnityObjectToClipPos(v.vertex);
就是将顶点从模型空间转换到这个空间。

裁剪空间
看图可知,这个空间里,坐标原点在四棱锥里面,而w代替z成为了真正的距离摄像机的距离
,我们要达到近大远小的目的,就将顶点都除以顶点距离摄像机的距离。看图可知在这个空间里这个距离就是顶点的W分量。将这个四棱锥里所有的顶点都除以他的W分量后,会得到一个1*1*1大小的正方体,这个正方体中,原先在近裁剪面的顶点的xyz分量还会保持较大的数值,而远处的顶点的xyz分量则会因除以更大的w值而变得较小,从而达到近大远小的效果,这一步在渲染管线里叫透视除法。
顶点的透视除法由unity自动帮我们完成,在顶点着色器和片元着色器之间,所以顶点的近大远小我们不需要怎么关心,但是如果想让其他的一些效果也有近大远小,则要手动除以W分量。
已知顶点着色器和片元着色器之间将发生透视除法,透视除法之后的顶点的w分量都为1,所以我们要在透视除法之前也就是顶点着色器里记录下w,存入v2f,并在片元着色器里使用。
片元着色器部分:
fixed4 frag (v2f i) : SV_Target
{
float2 screenParams01 = float2(i.vertex.x/_ScreenParams.x,i.vertex.y/_ScreenParams.y);
float2 offectSamplePos = screenParams01-float2(_RimOffect/i.clipW/_ScreenParams.x,0);
float offcetDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, offectSamplePos);
float trueDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, screenParams01);
float linear01EyeOffectDepth = Linear01Depth(offcetDepth);
float linear01EyeTrueDepth = Linear01Depth(trueDepth);
float depthDiffer = linear01EyeOffectDepth-linear01EyeTrueDepth;
float rimIntensity = step(_Threshold,depthDiffer);
float4 col = float4(rimIntensity,rimIntensity,rimIntensity,1);
return col;
} 真正涉及边缘光的部分代码的更长,但原理更简单。只需要搞清楚数据范围,都很好理解。
float2 screenParams01 = float2(i.vertex.x/_ScreenParams.x,i.vertex.y/_ScreenParams.y); I.vertex(别人一般把这个写成pos)是片元的屏幕像素坐标,要把我的1920*1080的屏幕上的坐标映射到0到1,显然是对坐标的x和y分别除以1920和1080。_ScreenParams的xy分量记录的就是屏幕的像素宽度和高度,这很关键,因为对各种纹理的采样实际上是采样0到1的范围而不是具体的像素。 现在我们得到了screenParams01这个0-1范围内顶点的屏幕空间的坐标,这好像叫viewport,视口空间来着。
float2 offectSamplePos = screenParams01-float2(_RimOffect/i.clipW/_ScreenParams.x,0); 对这个坐标,对x减去我们设置的偏移量,偏移量为_RimOffect/i.clipW,这里除w就是手动的透视除法,为此我们上边讲了一大堆。还要除以_ScreenParams.x,修正屏幕宽高比带来的差异。
在视口空间我们的屏幕左下角为(0,0),右上角为(1,1),x减小是向左偏移。
float offcetDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, offectSamplePos);
float trueDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, screenParams01);
float linear01EyeOffectDepth = Linear01Depth(offcetDepth);
float linear01EyeTrueDepth = Linear01Depth(trueDepth); 对原本的位置和偏转后的位置采样深度图,并将采样得到的值变为线性深度。
这里可以看到我们采样_CameraDepthTexture这个深度纹理的时候用的就是我们上边的得到的x和y范围为0到1的视口空间坐标。
float depthDiffer = linear01EyeOffectDepth-linear01EyeTrueDepth;
float rimIntensity = step(_Threshold,depthDiffer);
float4 col = float4(rimIntensity,rimIntensity,rimIntensity,1); 两个深度值相减,超过设定的阈值rimIntensity 为1,没超过则rimIntensity 为0
最后返回rimIntensity
看看效果

左边那个球的边缘光是没做透视除法的,看得出,镜头拉远了效果是错的,因为它偏移了一个屏幕空间的固定距离。
放到人身上





原神的边缘光是左右都有的,对代码偏移的部分改改就能得到这个效果,另外原神的脸部的边缘光是限定了角度的,只有侧看才能看得到,这个可以通过脸部向前的向量和观察方向来控制。懒得调颜色了,没原神的那么好看。