EasyX渲染黑客风格泛光文本

朋友的社团邀请我去做技术培训,带着小伙伴们一起做一款小游戏,正好手头有一个黑客题材游戏的灵感,便整理了一下策划案,准备以教学为目的带着大家做一部完整的游戏作品;一开始是打算用引擎的,但是引擎使用中的诸多概念对仅有浅薄编程语法基础的同学们来说还是有点为时过早了,想来近期在做的EasyX系列教程,便试着用EasyX实现一点高级的画面效果。本文代码作为软渲染的实践,仅用作算法思路分享,实际工程中还需要考虑渲染效率的优化等问题。

本文算法和实现思路参考了以下内容:

泛光概述

为什么要做泛光?泛光在一般用来强化画面中的亮部,因为显示器的亮度范围是有限的,例如游戏画面中有一张阳光下的白纸,白纸显示为纯白色,而太阳为了表达光亮最多也是为纯白色,二者同时出现时便会有亮度一样的感觉,而当我们给太阳加一个光晕,这样二者的亮度就有所区分了。

色彩亮度相同时泛光可以显著提升光源感官亮度

泛光可以看做是在摄像机视角下,光源的颜色向着画面中其他物体的颜色逐渐侵染的过程。泛光的通用实现思路主要分为以下几步:亮部提取 -> 模糊处理 -> 混叠渲染

亮部提取

其实对于单色文本来说,泛光的处理可以省略亮部提取这一步,因为所有文本的颜色亮度都是一致的,所有像素要么都处于亮度阈值下被舍弃,要么全部处于亮度阈值上被保留,此处为了适用色彩更复杂的情况,便保留了亮部提取的步骤。

首先将文本渲染到窗口色彩缓冲中:

1
2
3
4
5
6
7
8
9
10
LOGFONT f;
gettextstyle(&f);
f.lfHeight = 32;
_tcscpy(f.lfFaceName, _T("Consolas"));
f.lfQuality = ANTIALIASED_QUALITY;
settextstyle(&f);
settextcolor(RGB(182, 198, 198));
setbkmode(TRANSPARENT);

outtextxy(40, 40, _T("The Matrix has you..."));

原始文本效果

接着提取亮度大于阈值的像素到临时色彩缓冲中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static const float THRESHOLD = 0.15f;						// 亮度阈值
static const size_t LEN_PIX = WINDOW_WDITH * WINDOW_HEIGHT; // 像素长度
DWORD* tmp_color_buffer = new DWORD[LEN_PIX]; // 临时色彩缓冲

// 提取屏幕色彩缓冲区的亮色到临时色彩缓冲
DWORD* screen_color_buffer = GetImageBuffer(NULL);
for (int y = 0; y < WINDOW_HEIGHT; y++)
{
for (int x = 0; x < WINDOW_WDITH; x++)
{
DWORD r, g, b;
int idx = y * WINDOW_WDITH + x;
r = (screen_color_buffer[idx] & 0x00ff0000) >> 16;
g = (screen_color_buffer[idx] & 0x0000ff00) >> 8;
b = screen_color_buffer[idx] & 0x000000ff;
float brightness = (r / 255.0f) * 0.2126f + (g / 255.0f) * 0.7152f + (b / 255.0f) * 0.0722f;
tmp_color_buffer[idx] = brightness > THRESHOLD ? screen_color_buffer[idx] : BGR(RGB(0, 0, 0));
}
}

单色文本亮部提取成功后的效果与原始文本效果一致,此处不再展示,但为解释本阶段处理结果,下面给出一个与本文代码无关的亮度提取前后对比图。

亮度提取前后对比

模糊处理

不是画面中所有物体都会产生泛光效果,只有亮度高于一定值的物体才会泛光,这也就是上个阶段优先提取亮度图的原因,所以我们本阶段要对提取得到的亮度图作为输入进行模糊处理。模糊处理部分我们选择了高斯模糊算法,虽然从性能角度考虑还会有性价比最高的算法,但是高斯模糊作为经典算法,在模拟泛光“逐渐侵染”的思路上我认为是最直接的最便于理解的。

高斯模糊对于图像来说就是一个低通滤波器,我们使用5x5的卷积核来对图像进行多次处理,每一遍处理泛光部分都会向周围侵染一点。形象一点讲来说,如果我们把需要处理的图像当做是一张巨大的写满数字作文稿纸,每个格子中的数字都代表着像素的颜色,高斯模糊的过程就像是使用一个5x5的稿纸,依次去覆盖作文稿纸中的每一个网格,并把遮盖住的数字网格乘以各自的权值后进行累加,然后将得到的结果抄写到另一张相同大小的作文稿纸上,那么新的作文稿纸就是模糊处理后的图像。

典型的 5x5 卷积核及处理过程

高斯模糊的次数决定了其品质,影响着泛光效果的范围,我们定义TIMES常量描述循环处理的次数,在单次循环中,我们都将临时色彩缓冲区的当做高斯模糊的源地址,将窗口色彩缓冲区当做高斯模糊的目标地址,在循环最后将窗口色彩缓冲区的数据重新拷贝到临时色彩缓冲区。

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
static const size_t TIMES = 8;    // 卷积次数

// 使用 5x5 的高斯模糊卷积对高亮图进行多次处理
for (size_t i = 0; i < TIMES; i++)
{
// 高斯卷积核
static const float GAUSSIAN_KERNEL[5][5] =
{
1.0f / 256.0f, 4.0f / 256.0f, 6.0f / 256.0f, 4.0f / 256.0f, 1.0f / 256.0f,
4.0f / 256.0f, 16.0f / 256.0f, 24.0f / 256.0f, 16.0f / 256.0f, 4.0f / 256.0f,
6.0f / 256.0f, 24.0f / 256.0f, 36.0f / 256.0f, 24.0f / 256.0f, 6.0f / 256.0f,
4.0f / 256.0f, 16.0f / 256.0f, 24.0f / 256.0f, 16.0f / 256.0f, 4.0f / 256.0f,
1.0f / 256.0f, 4.0f / 256.0f, 6.0f / 256.0f, 4.0f / 256.0f, 1.0f / 256.0f,
};
// 将处理后的临时色彩缓冲区的数据写入到窗口色彩缓冲区
for (int y = 0; y < WINDOW_HEIGHT; y++)
{
for (int x = 0; x < WINDOW_WDITH; x++)
{
int dst_idx = y * WINDOW_WDITH + x;
float dst_r = 0, dst_g = 0, dst_b = 0;
for (int i = -2; i <= 2; i++)
{
for (int j = -2; j <= 2; j++)
{
// 处理边界条件
if (x + j < 0 || x + j >= WINDOW_WDITH || y + i < 0 || y + i >= WINDOW_HEIGHT)
continue;

DWORD src_r, src_g, src_b;
int src_idx = (y + i) * WINDOW_WDITH + (x + j);
src_r = (tmp_color_buffer[src_idx] & 0x00ff0000) >> 16;
src_g = (tmp_color_buffer[src_idx] & 0x0000ff00) >> 8;
src_b = tmp_color_buffer[src_idx] & 0x000000ff;
dst_r += src_r * GAUSSIAN_KERNEL[i + 2][j + 2];
dst_g += src_g * GAUSSIAN_KERNEL[i + 2][j + 2];
dst_b += src_b * GAUSSIAN_KERNEL[i + 2][j + 2];
}
}
screen_color_buffer[dst_idx] = BGR(RGB(dst_r, dst_g, dst_b));
}
}
// 将窗口色彩缓冲区的数据回读到临时色彩缓冲区
memcpy(tmp_color_buffer, screen_color_buffer, LEN_PIX * sizeof(DWORD));
}

delete[] tmp_color_buffer;
tmp_color_buffer = nullptr;

经过多次高斯模糊处理后的文本效果如下,有一种视力清晰的美:

高斯模糊处理效果

混叠渲染

对于文本来说,这个就很简单了,只需要在已有的模糊图像上渲染一次就好了;在EasyX中,我们需要确保背景模式为TRANSPARENT来防止高斯模糊的部分被背景色覆盖。

混叠渲染后的效果

至此,文本泛光的效果就已经实现完成了。

还要更炫!

只有个泛光,离黑客效果好像还差挺远的——但是,如果我换个颜色和新的字体呢?

绿色的Fusion像素字体文本

然后再加一条动态的扫描线,将画面分割为三部分,模拟信号干扰的效果:

1
2
3
4
5
6
7
8
9
10
static int line_y = 0;
static IMAGE img_screen;
static const int OFFSET_X = 10;
static const int LINE_THICKNESS = 5;
line_y = (line_y + 2) % (WINDOW_HEIGHT - LINE_THICKNESS);
getimage(&img_screen, 0, 0, WINDOW_WDITH, WINDOW_HEIGHT);
cleardevice();
putimage(0, 0, WINDOW_WDITH, line_y, &img_screen, 0, 0);
putimage(OFFSET_X, line_y, WINDOW_WDITH, LINE_THICKNESS, &img_screen, 0, line_y);
putimage(0, line_y + LINE_THICKNESS, WINDOW_WDITH, WINDOW_WDITH - line_y - LINE_THICKNESS, &img_screen, 0, line_y + LINE_THICKNESS);

最后再把文本渲染封装为函数,使用帧间计数器推进的方式实现打字机效果,借用《黑客帝国》里面的台词,这感觉一下子就来了!

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
size_t idx_char = 0;

void RenderNormalText()
{
#ifdef UNICODE
static const std::wstring TEXT_LIST[3] =
#else
static const std::string TEXT_LIST[3] =
#endif // UNICODE
{
_T("Wake up, Neo... "),
_T("The Matrix has you... "),
_T("Follow the white rabbit. "),
};
static int idx_text = 0;
for (int i = 0; i <= idx_text; i++)
{
if (idx_char >= TEXT_LIST[i].length())
idx_text++, idx_char = 0;
if (idx_text >= 3)
idx_text = 2, idx_char = TEXT_LIST[2].length();
outtextxy(40, 40 + i * 60,
i != idx_text ? TEXT_LIST[i].c_str()
: TEXT_LIST[i].substr(0, idx_char).c_str());
}
}

最终画面效果

完整代码

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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
#include <graphics.h>
#include <string>

#define WINDOW_WDITH 640
#define WINDOW_HEIGHT 260

size_t idx_char = 0;

void RenderNormalText()
{
#ifdef UNICODE
static const std::wstring TEXT_LIST[3] =
#else
static const std::string TEXT_LIST[3] =
#endif // UNICODE
{
_T("Wake up, Neo... "),
_T("The Matrix has you... "),
_T("Follow the white rabbit. "),
};
static int idx_text = 0;
for (int i = 0; i <= idx_text; i++)
{
if (idx_char >= TEXT_LIST[i].length())
idx_text++, idx_char = 0;
if (idx_text >= 3)
idx_text = 2, idx_char = TEXT_LIST[2].length();
outtextxy(40, 40 + i * 60,
i != idx_text ? TEXT_LIST[i].c_str()
: TEXT_LIST[i].substr(0, idx_char).c_str());
}
}

int main(int argc, char** argv)
{
initgraph(WINDOW_WDITH, WINDOW_HEIGHT, EW_SHOWCONSOLE);
SetWindowText(GetHWnd(), _T("EasyX Bloom Text"));

BeginBatchDraw();

while (true)
{
cleardevice();

// ********************** 设置字体样式 **********************

settextstyle(30, 0, L"Fusion Pixel 12px Monospaced latin Regular");
settextcolor(GREEN);
setbkmode(TRANSPARENT);

// ********************** 泛光效果处理 **********************

static const size_t TIMES = 2; // 卷积次数
static const float THRESHOLD = 0.15f; // 亮度阈值
static const size_t LEN_PIX = WINDOW_WDITH * WINDOW_HEIGHT; // 像素长度
DWORD* tmp_color_buffer = new DWORD[LEN_PIX]; // 临时色彩缓冲

// 首先渲染一次文本内容
//outtextxy(40, 40, _T("The Matrix has you..."));
RenderNormalText();

// 提取屏幕色彩缓冲区的亮色到临时色彩缓冲
DWORD* screen_color_buffer = GetImageBuffer(NULL);
for (int y = 0; y < WINDOW_HEIGHT; y++)
{
for (int x = 0; x < WINDOW_WDITH; x++)
{
DWORD r, g, b;
int idx = y * WINDOW_WDITH + x;
r = (screen_color_buffer[idx] & 0x00ff0000) >> 16;
g = (screen_color_buffer[idx] & 0x0000ff00) >> 8;
b = screen_color_buffer[idx] & 0x000000ff;
float brightness = (r / 255.0f) * 0.2126f + (g / 255.0f) * 0.7152f + (b / 255.0f) * 0.0722f;
tmp_color_buffer[idx] = brightness > THRESHOLD ? screen_color_buffer[idx] : BGR(RGB(0, 0, 0));
}
}

// 使用 5x5 的高斯模糊卷积对高亮图进行多次处理
for (size_t i = 0; i < TIMES; i++)
{
// 高斯卷积核
static const float GAUSSIAN_KERNEL[5][5] =
{
1.0f / 256.0f, 4.0f / 256.0f, 6.0f / 256.0f, 4.0f / 256.0f, 1.0f / 256.0f,
4.0f / 256.0f, 16.0f / 256.0f, 24.0f / 256.0f, 16.0f / 256.0f, 4.0f / 256.0f,
6.0f / 256.0f, 24.0f / 256.0f, 36.0f / 256.0f, 24.0f / 256.0f, 6.0f / 256.0f,
4.0f / 256.0f, 16.0f / 256.0f, 24.0f / 256.0f, 16.0f / 256.0f, 4.0f / 256.0f,
1.0f / 256.0f, 4.0f / 256.0f, 6.0f / 256.0f, 4.0f / 256.0f, 1.0f / 256.0f,
};
// 将处理后的临时色彩缓冲区的数据写入到窗口色彩缓冲区
for (int y = 0; y < WINDOW_HEIGHT; y++)
{
for (int x = 0; x < WINDOW_WDITH; x++)
{
int dst_idx = y * WINDOW_WDITH + x;
float dst_r = 0, dst_g = 0, dst_b = 0;
for (int i = -2; i <= 2; i++)
{
for (int j = -2; j <= 2; j++)
{
// 处理边界条件
if (x + j < 0 || x + j >= WINDOW_WDITH || y + i < 0 || y + i >= WINDOW_HEIGHT)
continue;

DWORD src_r, src_g, src_b;
int src_idx = (y + i) * WINDOW_WDITH + (x + j);
src_r = (tmp_color_buffer[src_idx] & 0x00ff0000) >> 16;
src_g = (tmp_color_buffer[src_idx] & 0x0000ff00) >> 8;
src_b = tmp_color_buffer[src_idx] & 0x000000ff;
dst_r += src_r * GAUSSIAN_KERNEL[i + 2][j + 2];
dst_g += src_g * GAUSSIAN_KERNEL[i + 2][j + 2];
dst_b += src_b * GAUSSIAN_KERNEL[i + 2][j + 2];
}
}
screen_color_buffer[dst_idx] = BGR(RGB(dst_r, dst_g, dst_b));
}
}
// 将窗口色彩缓冲区的数据回读到临时色彩缓冲区
memcpy(tmp_color_buffer, screen_color_buffer, LEN_PIX * sizeof(DWORD));
}

delete[] tmp_color_buffer;
tmp_color_buffer = nullptr;

// 最后再渲染一次文本内容
RenderNormalText();

// ********************** 信号干扰条带 **********************

static int line_y = 0;
static IMAGE img_screen;
static const int OFFSET_X = 10;
static const int LINE_THICKNESS = 5;
line_y = (line_y + 2) % (WINDOW_HEIGHT - LINE_THICKNESS);
getimage(&img_screen, 0, 0, WINDOW_WDITH, WINDOW_HEIGHT);
cleardevice();
putimage(0, 0, WINDOW_WDITH, line_y, &img_screen, 0, 0);
putimage(OFFSET_X, line_y, WINDOW_WDITH, LINE_THICKNESS, &img_screen, 0, line_y);
putimage(0, line_y + LINE_THICKNESS, WINDOW_WDITH, WINDOW_WDITH - line_y - LINE_THICKNESS, &img_screen, 0, line_y + LINE_THICKNESS);

// ********************** 更新打字机特效 **********************

static int counter = 0;
if (!(counter = (counter++) % 5)) idx_char++;

FlushBatchDraw();
}

EndBatchDraw();

return 0;
}
作者

Voidmatrix

发布于

2023-12-09

更新于

2023-12-09

许可协议

评论