最终效果(UE4)内

在均为128*128下普通蒙版和基于距离场生成的蒙版

原理简述

低分辨率的贴图在放大的时候会出现锯齿的原因是,贴图采样的滤波模式大部分都会被设置为双线性插值,这种差值可以一定程度上模糊贴图达到简单的抗锯齿效果,但是对于蒙版贴图来说会造成锯齿状的边缘,这是因为差值得出的信息并不是十分正确的。

双线性差值是根据当前点距离周围像素的距离进行差值,所以如果贴图储存的是距离数据的话,差值的结果将是正确的,因此就引出了距离场蒙版贴图的概念

首先我们要有一张高分辨率的蒙版图片,我们由此生成一张低分辨率的距离场蒙版贴图。对于其中每一个像素作如下操作:

  • 确定当前像素的值是1(白)还是0(黑)
  • 在当前像素周围的一定范围内,寻找离自己最近的与自己颜色不同的像素,获得到此像素的距离值。
  • 如果当前像素值为1,则距离为正,如果值为0,则距离为负。
  • 将距离值映射到0-1,储存在图片中
  • 使用双线性方法向下采样,输出低分辨率距离场贴图(或者在引擎中降低分辨率)。

其中“一定范围”是一个参数,叫做Spread Factor。

如此操作后,我们就获得了一张储存着距离信息的距离场蒙版图片:

生成的距离场图片

其中像素值为0.5的像素就是边界所在地,在引擎中直接根据0.5进行蒙版即可。

注意:
因为储存的是距离信息而非颜色信息,所以贴图不可被压缩,也不可以勾选sRGB选项

制作

设计

首先使用C++ OpenCV进行初步简单的实现。

对于每一个像素,获取其周围边长为Spread Factor的正方形范围内所有邻居像素,然后遍历其中每个像素。如果平方距离大于Spread Factor的平方,则表示在半径为Spread Factor的圆之外,抛弃。找到离自己最近的颜色不同的点,映射距离值,储存。

初步实现

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
···
std::vector<cv::Point2i> GetAllNeighbors(const cv::Point2i parent, const cv::Size matSize, const int spreadFactor)
{
std::vector<cv::Point2i> neighbors;
int minX, maxX, minY, maxY;
minX = MAX(parent.x - spreadFactor, 0);
maxX = MIN(parent.x + spreadFactor, matSize.width);
minY = MAX(parent.y - spreadFactor, 0);
maxY = MIN(parent.y + spreadFactor, matSize.height);

for (int i = minY; i < maxY; i++)
{
for (int j = minX; j < maxX; j++)
{
cv::Point2i neighbor(j, i);
if (neighbor == parent) continue;
neighbors.push_back(neighbor);
}
}
return neighbors;
}

float CalculateSquareDistance(const cv::Point2i& parent, const cv::Point2i& neighbor)
{
float squareDistance = (neighbor - parent).dot(neighbor - parent);
return squareDistance;
}

void GenerateDistanceField(const cv::Mat& src, cv::Mat des, const int& spreadFactor)
{
int squareSpreadFactor = pow(spreadFactor, 2);
// TODO: 生成距离场的算法
// 对每个像素
for (int i = 0; i < src.rows; i++)
{
for (int j = 0; j < src.cols; j++)
{
cv::Point self(j, i);
std::vector<cv::Point2i> neighbors;
neighbors = GetAllNeighbors(self, srcSize, spreadFactor);

float nearestOppositeDistance;
float squreNearestOppositeDistance;
// 这个像素是白
if (src.at<cv::Vec3b>(self)[0] >= 128)
{
nearestOppositeDistance = spreadFactor;
squreNearestOppositeDistance = squareSpreadFactor;
for (auto neighbor : neighbors)
{
if (src.at<cv::Vec3b>(neighbor)[0] < 128)
{
float squareDistance = CalculateSquareDistance(self, neighbor);
if (squareDistance > squareSpreadFactor) continue;
if (squareDistance < squreNearestOppositeDistance)
{
nearestOppositeDistance = sqrt(squareDistance);
squreNearestOppositeDistance = pow(nearestOppositeDistance, 2);
}
}
}
}
// 这个像素是黑
else
{
nearestOppositeDistance = -spreadFactor;
squreNearestOppositeDistance = squareSpreadFactor;
for (auto neighbor : neighbors)
{
if (src.at<cv::Vec3b>(neighbor)[0] >= 128)
{
float squareDistance = CalculateSquareDistance(self, neighbor);
if (squareDistance > squareSpreadFactor) continue;
if (squareDistance < squreNearestOppositeDistance)
{
nearestOppositeDistance = -sqrt(squareDistance);
squreNearestOppositeDistance = pow(nearestOppositeDistance, 2);
}
}
}
}
// 映射到[0, 1]
float finalResult = (nearestOppositeDistance + spreadFactor) / (2 * spreadFactor);
// 写入目标图片
des.at<uchar>(self) = finalResult * 255;
}
if (i % 100 == 0)
{
std::cout << i << std::endl;
}
}
}

···

int main()
{
cv::String filePath = "su1024.png";
srcImg = ReadImage(filePath);
srcSize = cv::Size(srcImg.cols, srcImg.rows);
desImg = cv::Mat(srcSize, CV_8UC1, uchar(0));

clock_t start = clock();
GenerateDistanceField(srcImg, desImg, spreadFactor);
clock_t end = clock();
std::cout << end - start << "ms" << std::endl;

cv::resize(desImg, desImg, cv::Size(256, 256), cv::INTER_AREA);

cv::imwrite("su_DF_256.png", desImg);
//cv::resize(desImg, desImg, cv::Size(128, 128));
//cv::imwrite("Result_Gan_DF64_128.png", desImg);

cv::waitKey(0);
return 0;
}

测试

可以生成图片,在UE4中也有不错的表现:

材质蓝图

但是算法效率极低,计算一张1024分辨率的贴图经过测算需要长达66秒的时间。

优化

算法的问题显而易见,对于处在边界的像素点来说,如果可以从中心扩散向外检测,则可以及时跳出,节省很多算力。

所以我采用了之前写的获取圆环内整数点的算法,一个圆环一个圆环地扩散检测,这样在近处检测到结果后可以直接跳出,省去不必要的计算。

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
constexpr int spreadFactor = 64;
constexpr int searchingRingWidth = 1;

···

void GenerateDistanceField(const cv::Mat& src, cv::Mat des, const int& spreadFactor)
{
int squareSpreadFactor = pow(spreadFactor, 2);
// TODO: 生成距离场的算法
// 对每个像素
for (int i = 0; i < src.rows; i++)
{
for (int j = 0; j < src.cols; j++)
{
// 船新算法
cv::Point self(j, i);
bool bWhite = src.at<cv::Vec3b>(self)[0] >= 128;
float nearestOppositeDistance = bWhite ? spreadFactor : -spreadFactor;
float squreNearestOppositeDistance = squareSpreadFactor;

// 一圈一圈找
for (int t = 0; t < spreadFactor; t = t + searchingRingWidth)
{
int tBig = ((t + searchingRingWidth) > spreadFactor) ? spreadFactor : t + searchingRingWidth;
std::vector<cv::Point2i> neighbors;
neighbors = GetAllPointInRing(self, t, tBig, srcSize);
bool bFound = false;
for (auto neighbor : neighbors)
{
// 如果白色找到了黑像素
if (src.at<cv::Vec3b>(neighbor)[0] < 128 && bWhite)
{
bFound = true;
float squareDistance = CalculateSquareDistance(self, neighbor);
if (squareDistance < squreNearestOppositeDistance)
{
nearestOppositeDistance = sqrt(squareDistance);
squreNearestOppositeDistance = squareDistance;
}
}
// 如果黑色找到了白像素
else if (src.at<cv::Vec3b>(neighbor)[0] >= 128 && !bWhite)
{
bFound = true;
float squareDistance = CalculateSquareDistance(self, neighbor);
if (squareDistance < squreNearestOppositeDistance)
{
nearestOppositeDistance = -sqrt(squareDistance);
squreNearestOppositeDistance = squareDistance;
}
}
}
// 找到了直接跳出
if (bFound) break;
}
// 映射到[0, 1]
float finalResult = (nearestOppositeDistance + spreadFactor) / (2 * spreadFactor);
// 写入目标图片
des.at<uchar>(self) = finalResult * 255;
}
}
}

不过这也有个问题,寻找圆环内整数点的操作需要涉及到开方,比较耗费性能。我使用变量searchingRingWidth控制每次扩大的圆环宽度,如果这个值很小的话,进行圆环找点操作的次数会变多,但是找到结果后的多余运算会更少(因为圆环更细,每次找到的邻居像素更少),而这个只能通过测试找到最佳值。

下面是在Spread Factor为64的情况下处理1024贴图的测试结果:

圆环宽度(像素) 运行时间(s)
1 99
2 64.969
4 50.824
8 45.757
16 54.636
12 45.5
10 42.929

可以看出圆环宽度为10的时候速度最快。

不过速度与处理的贴图之间也有很大关系,这种回溯法对于很复杂的蒙版效率极高,因为大部分都可以在前几个圆环的寻找中就找到结果跳出,但是对于纯色很多的蒙版来说效率会大大降低,因为大部分像素都需要完整的寻找完所有圆环,无疑极大地增加了求圆环算法的执行次数。经其他测试,复杂蒙版与简单蒙版的运行效率差距能有10倍之多。

迁移至UE4内

可以参考我这篇文章:https://1keven1.github.io/2021/07/27/%E3%80%90UE4_C-%E3%80%91%E5%88%9B%E5%BB%BA%E8%B4%B4%E5%9B%BE%E5%B9%B6%E4%BF%9D%E5%AD%98%E4%B8%BAAsset/

经过我后期测试,迁移至UE4内性能不佳,最终采用了外部多线程计算的方法,性能得到了极大提升

资源

https://github.com/1keven1/2D-Distance-Field-Generator

其他待解决问题

  • 对于较为简单的蒙版的处理方法有待研究,初步考虑可以用生成MipMap的方法;