二次元脸部光照阈值图生成

Posted by FlowingCrescent on 2022-07-17
Estimated Reading Time 5 Minutes
Words 1.4k In Total
Viewed Times

前言

久违地发一下技术文章,社畜之后一些工作内容无法公开分享,不过一些业内已公开的方案还是可以聊聊的。

所谓“二次元脸部光照阈值图”便是类似如下的图,在业内常被称为“SDF图”:
image.png
然而其实它在应用时与SDF毫无关联,仅是生成时用到了SDF罢了,因此笔者更想称其为“阈值图”。
总而言之,这篇文章将描述如何生成这么一张图,在Unity中编写工具时遇到了什么坑。

描述各个光照角度

为了制作阈值图,我们需要让美术去绘制出例如下图中一般,离散的各个角度的光照结果图。
image.png
素材出自https://zhuanlan.zhihu.com/p/356185096
其实我们要做的,就是让这些离散的图合成一张,使其变得连续。
至于这些图怎么出,如果不是直接PS画,也可以在SP里提供特别的shader让美术绘制。

使用SDF让它们变得连续

所谓SDF,便是“有向距离场(Signed Distance Field)”,能够描述某一点到最近的边缘的距离,同时使用正与负来区分该点是在边缘内还是外。

网上生成SDF的代码不少,我直接借用了这位老哥的,使用Compute Shader生成SDF,速度还算蛮快:
https://zhuanlan.zhihu.com/p/390255113

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
// Each #kernel tells which function to compile; you can have many kernels
#pragma kernel CSMain

// Create a RenderTexture with enableRandomWrite flag and set it
// with cs.SetTexture

int _halfRange;
Texture2D _MainTex;
int4 _MainTex_Size;

RWTexture2D<float4> outputTexture;

uint squaredDistanceBetween(int2 uv1, int2 uv2) {
int2 delta = uv1 - uv2;
uint dist = (delta.x * delta.x) + (delta.y * delta.y);
return dist;
}

bool isIn(int2 coord) {
if (coord.x < 0 || coord.y < 0 || coord.x >= _MainTex_Size.x || coord.y >= _MainTex_Size.y)
return false;
return _MainTex[coord.xy].r > 0;
}

[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{


int2 coord = id.xy;

const int halfRange = _halfRange;
const int iRange = 2 * halfRange;

const int2 startPosition = coord - int2(halfRange, halfRange);

bool fragIsIn = isIn(coord);

uint squaredDistanceToEdge = (halfRange*halfRange)*2;

for (int dx = 0; dx <= iRange; dx++) {

for (int dy = 0; dy <= iRange; dy++) {
int2 scanPositionCoord = startPosition + int2(dx, dy);

bool scanIsIn = isIn(scanPositionCoord);
if (scanIsIn != fragIsIn) {
uint scanDistance = squaredDistanceBetween(coord, scanPositionCoord);
if (scanDistance < squaredDistanceToEdge) {
squaredDistanceToEdge = scanDistance;
}
}
}
}


float normalised = squaredDistanceToEdge / ((halfRange*halfRange)*2.0);
float distanceToEdge = sqrt(normalised);
if (fragIsIn)
distanceToEdge = -distanceToEdge;

normalised = 0.5 - distanceToEdge;

outputTexture[id.xy] = float4(normalised, normalised, normalised,1);

}

原理而言就是遍历一个像素周围一圈范围的像素,找出其中最近的“边缘”,计算距离值。
而这个范围便是SDF的最大距离,试验下来,2048的图片需要约350~400的范围值。
而1024则减半,我通常给个256。

image.png

原图

image.png

SDF图


而下一步才是生成阈值图的关键,我们需要两两SDF图之间生成一张平滑的小范围“阈值图”。
image.png

SDF图1

image.png

SDF图2

image.png

阈值图

其实说来也很简单,笔者用自身多年的绘画功底画一张示意图:
image.png
红色区域表示SDF图1,蓝色区域则表示SDF图2
假设我们需要求P点的值,而我们知道从红色的边缘到蓝色的边缘,P点的值将从1递减到0。
那么我们其实只需要P到两个边缘的距离的比值即可,而两个距离我们可在SDF图中取得。

此时要注意,之前的SDF图中,正负距离被映射到了[0, 1]范围内,需要还原一下。
代码也比较简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Texture2D originPart;
Texture2D nextPart;
Texture2D originSDF;
Texture2D nextSDF;
[numthreads(8,8,1)]
void CSSmooth (uint3 id : SV_DispatchThreadID)
{
int2 coord = id.xy;
float distanceOriginPart = originSDF[coord].a * 2 - 1;
float distanceNextPart = nextSDF[coord].a * 2 - 1;

float totalDistance = abs(distanceNextPart) + abs(distanceOriginPart);

float lerpValue = abs(distanceNextPart) / max(totalDistance, 0.001);

outputTexture[coord] = lerp(originPart[coord], nextPart[coord], lerpValue);
outputTexture[coord] = pow(outputTexture[coord], 0.45);//gamma correct
}

将各个小阈值图进行合并

之后便是将几个阈值图进行相加,并除以图片的总量即可。
此时笔者第一次在Compute Shader中使用Texture Array,遇到了一点小坑。
创建TextureArray的代码极为简单,但绝不能画蛇添足加个Apply(),之前TextureArray硬是内容全空,查了数个小时才发现罪魁祸首竟是看上去人畜无害的textureArray.Apply()……

1
2
3
4
5
6
7
8
var textureArray = new Texture2DArray(textureWidth, textureWidth, textureCount, TextureFormat.ARGB32, false);
for (int i = 0; i < textureCount - 1; i++)
{
Graphics.CopyTexture(smoothParts[i], 0, textureArray, i);
}


computeShader.SetTexture(kernel, "_Textures", textureArray);

而设置给Compute Shader也直接用SetTexture即可。

Compute Shader中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int _TextureCount;
Texture2DArray<float4> _Textures;

[numthreads(8,8,1)]
void CSCombine (uint3 id : SV_DispatchThreadID)
{
int2 coord = id.xy;
outputTexture[coord] = 0;
float sum = 0;
for(uint i = 0; i < _TextureCount; i++)
{
sum += _Textures[int3(id.x, id.y, i)];
}
outputTexture[coord] = sum / _TextureCount;
}

最初笔者在for循环中写的是
outputTexture[coord] += _Textures[int3(id.x, id.y, i)];
然后再在循环外边/= _TextureCount;
发现怎么都不对,才意识到texture的RGBA32格式将会自动将值Clamp到[0, 1],于是才改成如上写法。
对于Texture的写入操作,还是先用其他float或half值替代,最后赋值为好。说到底本来应该也就不太推荐频繁地读写Texture,该说是吃一堑长一智吧。

于是导出后便可获得如下的平滑阈值图:
image.png

生成一张2k图,调整sdf范围为350后在笔者的1060老爷机上需要15秒左右,而1k的图范围设置为256只需要5秒左右,还算是能接受的速度。

注:Compute Shader中读取Unity设置的SRGB图,似乎并不是以pow 2.2来进行伽马校正,感觉是个小坑,还是尽量将涉及这种计算的图片都设置为Linear空间为好


感谢您阅读完本文,若您认为本文对您有所帮助,可以将其分享给其他人;若您发现文章对您的利益产生侵害,请联系作者进行删除。