前言
久违地发一下技术文章,社畜之后一些工作内容无法公开分享,不过一些业内已公开的方案还是可以聊聊的。
所谓“二次元脸部光照阈值图”便是类似如下的图,在业内常被称为“SDF图”:
然而其实它在应用时与SDF毫无关联,仅是生成时用到了SDF罢了,因此笔者更想称其为“阈值图”。
总而言之,这篇文章将描述如何生成这么一张图,在Unity中编写工具时遇到了什么坑。
描述各个光照角度
为了制作阈值图,我们需要让美术去绘制出例如下图中一般,离散的各个角度的光照结果图。
素材出自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
| #pragma kernel CSMain
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。
原图
SDF图
而下一步才是生成阈值图的关键,我们需要两两SDF图之间生成一张平滑的小范围“阈值图”。
SDF图1
SDF图2
阈值图
其实说来也很简单,笔者用自身多年的绘画功底画一张示意图:
红色区域表示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); }
|
将各个小阈值图进行合并
之后便是将几个阈值图进行相加,并除以图片的总量即可。
此时笔者第一次在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,该说是吃一堑长一智吧。
于是导出后便可获得如下的平滑阈值图:
生成一张2k图,调整sdf范围为350后在笔者的1060老爷机上需要15秒左右,而1k的图范围设置为256只需要5秒左右,还算是能接受的速度。
注:Compute Shader中读取Unity设置的SRGB图,似乎并不是以pow 2.2来进行伽马校正,感觉是个小坑,还是尽量将涉及这种计算的图片都设置为Linear空间为好
感谢您阅读完本文,若您认为本文对您有所帮助,可以将其分享给其他人;若您发现文章对您的利益产生侵害,请联系作者进行删除。