大家好,我是阿赵。
之前介绍了通过修改模型顶点法线的方法,来解决角色模型脸部光影问题。这里再顺便介绍一下第二种方法,使用阈值图修改角色脸部阴影
一、角色脸部光影的问题
这个问题之前讨论过,由于角色脸部法线复杂,如果通过点乘顶点法线和光照方向来算阴影,会导致某些局部位置的影子很奇怪。
之前说过,解决这个问题,我有2个手段,第一个手段修改脸部法线映射,已经在之前的文章里面介绍过了。这里再说说第二个手段,通过阈值图来控制角色脸部法线。
通过阈值图控制角色脸部阴影,比较有名的是游戏《原神》。
这个方法相对于修改法线,在实现上会复杂很多,因为他涉及到一些比较复杂的图形计算原理。比如要生成阈值图,就要先知道SDF是什么,怎样去获取每张图片的的SDF,然后怎样通过插值的方式,从多张SDF数据合并出一张能用的阈值图。
这个问题估计有很多人会看不懂,所以先说说结论吧,起码知道我们现在要做的是一件怎样的事情。
1.绘制多张阴影范围的贴图,用于表达在不同的光线角度下,阴影的分布情况。由于这里是一个做得比较快的范例,所以我是直接随便在PS里面用钢笔勾出来的,所以边缘有点硬,明白意思就行。
2.通过上面的多张图,计算出SDF,并合并成一张阈值图
3.编写一个shader,可以通过光线方向来控制阈值,显示阴影在模型上面
这里由于画得不仔细,眼睛的部分并没有画好,所以眼睛部分的阴影计算是错误的
4.混合模型原有的颜色,得出正确的结果
二、SDF的介绍
1、SDF是什么
有符号距离场(SignedDistanceField,SDF)。听名字给人感觉就是一个比较高深的东西。拆分一下,可以理解成:
1.有符号
这个东西是有正负数的,正数和负数分别代表了不同的含义
2.距离
这个东西的数据,主要是为了表达距离关系的
3.场
叫得上场的,肯定就不是单个点能形成的,它是很多点的数据集合,每个点记录的数据是这个整体的场里面的一个相对关系。
具体SDF是什么含义,它有什么用途,各位可以轻易的百度得到,如果不想百度,我也可以简单归纳一下。
所谓的SDF,是记录了一张图片上面,每一个像素点,离图案边缘的距离。比如我们有一个圆的图片,圆的边缘上的像素点,我们记录为0,在圆外面的像素点,我们记录为正数,离边缘越远的点值越大。在圆内部的像素点,我们记录为负数,离边缘越远的点值越小。所以远离边缘的地方,用颜色表达就是越来越白,边缘是灰色,在内部离边缘越远,就越黑
2、怎样去计算SDF
从上面的分析去看,有一个很重要的问题我们需要解决的,就是怎样求所谓的边缘?
如果是彩色图片,那么方法很明显是采样一个顶点周围的一圈点,然后通过偏导数ddx/ddy来求出他是否处于颜色变化很大的边缘。
但如果只是求出边缘,对于SDF来说还不够。因为SDF的另外一个重点是,它有符号,它要知道当前的点是属于图形的内部还是外部。这个问题如果在彩色图里面,就比较难以定义了。
幸好,我们需要求的SDF,是有明确的内外之分的图形,比如上面我画的阴影遮罩图,非黑即白。我们可以很简单的通过自己的规则,去指定图形的内外,比如黑色的是图形外,白色是图形内,之类。你也可以根据自己的需要反过来定义也可以。
接下来的事情,就是逐张图片的逐个像素去遍历,然后求出它是否边缘,如果不是边缘,求出它离自己最近的边缘的距离,然后根据黑白色,给它赋予正负的符号。
这里有个小优化,求离自己最近的边缘,并不是无限的遍历,而是可以设置一个最大值,如果在一定范围内都找不到边缘,就给他一个极限值1或者-1。
于是,通过上面绘制的阴影图,就生成了下面的这些SDF图。
其中纯黑是在内部纯白是在外部,边缘是灰色。越黑或者越白就是离边缘越远。
下面这段C#代码是在Unity3D里面,通过Texture2D生成SDF数据的一个类:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SDFTexData
{
public string texName = "";
public int width = 0;
public int height = 0;
public Dictionary<int, Dictionary<int, float>> pixels;
public Dictionary<int, Dictionary<int, float>> sdfs;
public int checkRange = 10;
private float edgeVal = 0.1f;
public void SetData(Texture2D tex)
{
width = tex.width;
height = tex.height;
checkRange = Mathf.FloorToInt((float)width / 10);
pixels = new Dictionary<int, Dictionary<int, float>>();
sdfs = new Dictionary<int, Dictionary<int, float>>();
for(int j = 0;j<height;j++)
{
for(int i = 0;i<width;i++)
{
Color col = tex.GetPixel(i, j);
AddPixel(i, j, col.r);
}
}
CreateSDF();
CreateTestTex();
}
private void CreateTestTex()
{
Texture2D tex = new Texture2D(width, height);
for(int j = 0;j<height;j++)
{
for(int i = 0;i<width;i++)
{
float sdf = GetSDF(i, j);
float val = (sdf + 1) / 2;
Color col = new Color(val, val, val, 1);
tex.SetPixel(i, j, col);
}
}
tex.Apply();
byte[] bs = tex.EncodeToJPG();
File.WriteAllBytes("g:/sdf/" + texName + ".jpg", bs);
}
private void CreateSDF()
{
for(int j = 0;j<height;j++)
{
for(int i = 0;i<width;i++)
{
float val = GetSDFVal(i, j);
AddSDF(i, j, val);
}
}
}
private float GetSDFVal(int x,int y)
{
float tx = -9999;
float ty = -9999;
float val = 9999;
for(int i = 1;i<checkRange+1;i++)
{
val = GetOneRangeSDF(x, y, i);
if(val<999)
{
return val;
}
}
float selfVal = GetPixel(x, y);
if(selfVal > edgeVal)
{
return -1;
}
else
{
return 1;
}
}
private bool IsOutOfTex(int x,int y)
{
if(x<0||x>=width||y<0||y>height)
{
return true;
}
else
{
return false;
}
}
private float GetOneRangeSDF(int x,int y,int range)
{
float val = 9999;
float tempVal = 9999;
float selfVal = GetPixel(x, y);
float tempCol = -9999;
if(selfVal> edgeVal)//在内部或者边缘
{
for (int i = -range; i < range + 1; i++)
{
for (int j = -range; j < range + 1; j++)
{
int ox = x + i;
int oy = y + j;
if(IsOutOfTex(ox,oy))
{
continue;
}
if (ox==x && oy==y)
{
continue;
}
tempCol = GetPixel(ox, oy);
if(tempCol <= edgeVal)
{
if(Mathf.Abs(i-x)<=1&&Mathf.Abs(j-y)<=1)//下一格就是外部
{
val = 0;
return val;
}
else
{
tempVal = Mathf.Sqrt(Mathf.Pow((Mathf.Abs(ox - x) ), 2) + Mathf.Pow((Mathf.Abs(oy - y)), 2));
if (tempVal<val)
{
val = tempVal;
}
}
}
}
if(val>0&&val<100)
{
break;
}
}
if(val >100)
{
return val;
}
else
{
float result = val / checkRange;
if (result > 1)
{
result = 1;
}
return result*-1;
}
}
else//在外部
{
for (int i = -range; i < range + 1; i++)
{
for (int j = -range; j < range + 1; j++)
{
int ox = x + i;
int oy = y + j;
if (IsOutOfTex(ox, oy))
{
continue;
}
if (ox == x && oy == y)
{
continue;
}
tempCol = GetPixel(ox, oy);
if (tempCol > edgeVal)
{
tempVal = Mathf.Sqrt(Mathf.Pow((Mathf.Abs(ox - x)),2) + Mathf.Pow((Mathf.Abs(oy - y)), 2));
if (tempVal < val)
{
val = tempVal;
}
}
}
}
if (val > 100)
{
return val;
}
else
{
float result = val / checkRange;
if(result >1)
{
result = 1;
}
return result;
}
}
}
public void AddPixel(int x,int y,float val)
{
if(pixels == null)
{
pixels = new Dictionary<int, Dictionary<int, float>>();
}
if(pixels.ContainsKey(x)==false)
{
pixels.Add(x, new Dictionary<int, float>());
}
if(pixels[x].ContainsKey(y))
{
pixels[x][y] = val;
}
else
{
pixels[x].Add(y, val);
}
}
public float GetPixel(int x,int y)
{
if(pixels==null||pixels.ContainsKey(x)==false||pixels[x].ContainsKey(y)==false)
{
return 0;
}
return pixels[x][y];
}
public void AddSDF(int x, int y, float val)
{
if (sdfs == null)
{
sdfs = new Dictionary<int, Dictionary<int, float>>();
}
if (sdfs.ContainsKey(x) == false)
{
sdfs.Add(x, new Dictionary<int, float>());
}
if (sdfs[x].ContainsKey(y))
{
sdfs[x][y] = val;
}
else
{
sdfs[x].Add(y, val);
}
}
public float GetSDF(int x, int y)
{
if (sdfs == null || sdfs.ContainsKey(x) == false || sdfs[x].ContainsKey(y) == false)
{
return -9999999;
}
return sdfs[x][y];
}
}
这是我比较习惯的面向对象的写法,创建一个SDFTexData对象,然后通过SetData方法传入一张Texture2D,遍历所有像素点,生成SDF数据,最后调用CreateTestTex方法,保存一张图片。由于是测试用的方法,所以我临时保存在了g盘的sdf文件夹,各位可以根据自己情况修改保存的方法。
三、阈值图的原理和生成
上面介绍了很久,也生成了一堆SDF图,那究竟为什么生成阈值图需要先生成SDF呢?
这里先来看看阈值图究竟是什么含义的一张图片。
生成后的阈值图大概是长这样的。它的含义是,当我们输入某个值的时候,大于这个值的像素点都能显示,小于它的像素点都不显示。所以我们输入的这个值,其实就是代表了在一个线性过渡过程中,能让某个点显示的开关值。
刚才我们只画了7张阴影图,是代表了输入一个0到1的范围时阴影的情况,当输入的值是0的时候,显示第一张图的阴影,当输入1的时候应该显示第7张图的阴影。 那么问题来了,假如我们随意输入0-1里面任意的一个值时,该怎样去决定用哪张图作为阴影?或者哪张图都不对,应该怎样从2张图之间取一个过渡值呢?
这时候,SDF的优势就体现出来了,比如说,某个像素点在0.5的时候离边缘的值是0.2,到了0.7的时候它离边缘的值就变成了-0.4,那么通过插值就可以知道,它在0.6的时候值是-0.1了。
所以通过这7张SDF图,我们可以求出任意一个时间点某个像素离边缘的距离。由于我们只要求出像素点在边缘,或者在内部时的开始时间点就够了,就能知道某个像素点在0到1的过程中,究竟在哪个时间应该显示出来了。
剩下的工作就很简单了,每个像素点遍历7张图,算出它到达边缘的实际时间点,然后保存下来就可以了。于是就生成了上面的那张阈值图了。
下面提供一下生成阈值图的代码:
private void CreateThresholdTex(List<SDFTexData> texList)
{
width = texList[0].width;
height = texList[0].height;
saveTex = new Texture2D(width, height);
spaceVal = 1 / (float)(texList.Count - 1);
float col1 = -99999;
float col2 = -99999;
for (int j = 0; j < height; j++)
{
for (int i = 0; i < width; i++)
{
float curval = 1;
col1 = -99999;
col2 = -99999;
for (int k = 0; k < texList.Count; k++)
{
if (col1 < -999)
{
col1 = texList[k].GetSDF(i, j);
if (col1 <= 0)
{
curval = k * spaceVal;
}
}
else
{
col2 = texList[k].GetSDF(i, j);
if (col2 <= 0)
{
curval = spaceVal * (k - 1) + spaceVal * (col1) / (col1 - col2);
}
else
{
col1 = col2;
}
}
if (curval < 1)
{
break;
}
}
curval = 1 - curval;
saveTex.SetPixel(i, j, new Color(curval, curval, curval, 1));
}
}
saveTex.Apply();
byte[] saveBs = saveTex.EncodeToJPG();
File.WriteAllBytes("g:/sdf/yuzhi.jpg", saveBs);
}
通过传入SDF数据的数组,然后生成阈值图。最后由于是测试,我也是随便保存在了g盘的sdf文件夹,各位可以看情况修改。
四、编写shader实现
有了阈值图,我们就可以写Shader来给模型设定阴影了。这里要注意几个问题:
1、阈值图由于是左右变化的,所以它不可能产生上下的阴影变化,所以在计算光照的时候,应该只取x和z方向
float2 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos)).xz;
2、我们计算的阈值图,是只有一个方向的,比如光线从左边转到正面的一个过程,缺少了光线从右边到正面的过程,所以我们应该采样2次,其中一个过程采样阈值的UV要把x坐标乘以-1,让图片翻转。然后要定义一个相对于角色在左方向或者右方向的向量,通过这个向量和光线角度做夹角,来判断光线现在是在模型的左边照射还是右边照射,来控制2次采样的图片的显示。
3、实际上光照有前后之分,当光线在背后的时候,应该不需要计算阈值,直接把脸给全部附上阴影。所以我们要定一个相对于角色的前方向向量,通过这个向量和光线角度做夹角,来判断光线现在在角色前面还是后面。
接下来是具体的shader代码:
Shader "Unlit/ToonFaceThreshold"
{
Properties
{
_MainTex("MainTex", 2D) = "white" {}
_shadowLength("shadowLength", Range(0 , 1)) = 0
_shadowMap("shadowMap", 2D) = "white" {}
_min("min", Range(0 , 1)) = 0.24
_max("max", Range(0 , 1)) = 0.28
_shadowCol("shadowCol", Color) = (0,0,0,0)
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float2 shadowUV : TEXCOORD1;
float4 worldPos:TEXCOORD12;
};
uniform sampler2D _MainTex;
uniform float4 _MainTex_ST;
uniform float _shadowLength;
uniform float _min;
uniform float _max;
uniform sampler2D _shadowMap;
uniform float4 _shadowMap_ST;
uniform float4 _shadowCol;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld, v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.shadowUV = TRANSFORM_TEX(v.uv, _shadowMap);
return o;
}
half4 frag (v2f i) : SV_Target
{
float3 transPos = mul(unity_ObjectToWorld, float4(float3(0,0,0), 1)).xyz;
float2 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos)).xz;
float3 frontOffset = mul(unity_ObjectToWorld, float3(0, 0, 1));
float2 frontPos = (frontOffset + transPos).xz;
float frontVal = dot(frontPos, lightDir);
float frontDotVal = step(frontVal, -0.5);
float3 rightOffset = mul(unity_ObjectToWorld,float3(-1, 0, 0));
float2 rightPos = (rightOffset + transPos).xz;
float rightVal = dot(rightPos, lightDir);
half4 baseCol = tex2D(_MainTex, i.uv);
half4 shadowCol = tex2D(_shadowMap, i.shadowUV);
float frontShadowVal = shadowCol.r - frontVal;
float frontClamp = clamp(frontShadowVal, 0, 1);
float frontSmoothVal = smoothstep(_min, _max, frontClamp);
float2 rightShadowUV = float2(i.shadowUV.x*-1, i.shadowUV.y);
half4 shadowRightCol = tex2D(_shadowMap, rightShadowUV);
float rightShadowVal = shadowRightCol.r - frontVal;
float rightClamp = clamp(rightShadowVal, 0, 1);
float rightSmoothVal = smoothstep(_min, _max, rightClamp);
float rightStepVal = step(rightVal, 0);
float frontMulVal = frontSmoothVal * rightStepVal;
float rightMulVal = rightSmoothVal * (1 - rightStepVal);
float finalDirVal = clamp((frontMulVal + rightMulVal), 0, 1);
float finalShadowVal = frontDotVal*_shadowLength + (1 - frontDotVal)*(_shadowLength*finalDirVal*(1 - frontDotVal));
float4 finalCol = baseCol * (1 - finalShadowVal) + (baseCol*_shadowCol*finalShadowVal);
return finalCol;
}
ENDCG
}
}
}
最后,得到了这样的结果。可以看出来,得到的影子就和阈值图一样,没有因为法线错乱而导致影子出错的情况。
五、总结
上面详细介绍了怎样通过阈值图来控制模型的阴影。对比使用法线映射的解决办法,它有一些优点:
1、可以通过美术绘制,得到很精确的阴影效果,包括了眼睛鼻子在过渡时应该出现怎样的阴影变化,都可以绘制出来。如果觉得7张图不够,做多几张也可以的。从可控性来说,这种方法应该是比较好的。
2、可以脱离光照,单独的通过某个变量值来控制阈值的变化,达到改变阴影的效果。
它的缺点同样也很明显
1、它只能生成左右的阴影,没有办法同时处理上下方向的阴影。这可以说是硬伤。
2、生成SDF图和阈值图需要比较长的时间。我在例子里面,使用的都是256256的图片,生成7张图加阈值图,也需要将近20分钟。但其实256的精度是不够的,所以我又把阈值图放到ps里面修改成512512,然后加了模糊处理,这样才勉强能看。如果要生成1024级别的图片,就更是慢到我自己都不敢尝试了。或者是我自己的算法不够优化吧,各位可以多尝试一下。
3、由于需要计算各种夹角来判断灯光位置和阈值的关系,所以shader的计算量会比单纯的顶点法线和光线角度求点乘的传统阴影计算方式复杂度要高。看我上面那个shader好像几十行就写完,其实里面的计算我个人感觉也挺复杂的。