Ctrl-Alt-Test

一个自 2009 年开始的法国 Demoscene 组织

Shader Minification 十五年

Demoscene 的开发者们是如何在区区几 KB 的空间内创造出复杂的电脑动画的? 我们的秘密武器之一是 Shader Minifier,一个用于压缩 GLSL 代码的工具。 多年来,它不断进化,将更多的数据压缩到微小的可执行文件中,突破了可能的界限。 在这篇博文中,我们将回顾它的演变历程。

XO by Nuance (4kB)

2010 年,我注意到了 demoscene 中的一个趋势:创作者们正在制作令人印象深刻的 4k intro,但这个过程非常手动且繁琐。 这些 intro 依赖于 shader 代码来生成图形,而优化这些代码就像一场 code golf 比赛。 由于现有的工具很差,我决定提供帮助。 我的目标是自动化最无聊的任务:删除不必要的空格和注释,以及将变量重命名为单个字母。 这就是 Shader Minifier 的诞生方式。

压缩悖论

我实现的第一个功能之一是插入预处理器宏。 这是一种经典的技巧,经常用于 code golf 比赛和代码混淆竞赛:

#define R return

通过在代码顶部添加此行,您可以将每个 "return" 替换为 "R",每个语句节省 5 个字节。 如果将其应用于其他常用关键字或标准函数调用,则可以快速累积节省的空间。

但有人曾经问我:“Shader Minifier 可以使文件变小,但在使用 Crinkler 压缩后,它实际上节省了多少字节?” Crinkler 是小型 intro 最流行的压缩工具。

起初,我并不太在意:如果我使代码更小,那么压缩后的代码显然也会变得更小……对吗? 不,我错了。 我进行了测试,发现 Shader Minifier 的输出压缩后的结果比未压缩的代码更大。 这是运气不好吗? 我试验并调整了代码中的一些启发式方法。 引入更多宏还是更少宏最好? 经过多次迭代,我发现最好的方法是……什么都不做。 不要用宏替换代码。

事实证明,Crinkler 比我想象的要聪明,而我巧妙的宏却阻碍了它。 现代压缩器非常擅长识别冗余模式。 如果 "return" 这个词在代码中重复出现,压缩器可以非常有效地处理它。 使用宏来消除这些冗余会适得其反。

重命名:并非像 ABC 那么简单

重命名标识符似乎是压缩器的显而易见的功能。 最初,目标似乎很简单:为每个标识符使用一个字母。 毕竟,每个标识符一个字母是最佳的,对吗? 然而,我又错了。 并非所有字母在压缩方面都是平等的。

一个好的名称是你重复使用的名称。 如果多个变量具有相同的名称,则代码看起来会更重复,并且压缩效果会更好。

因此,我们的策略是在重用名称方面非常积极:

是的。 我们在重用名称方面非常积极,以至于发现了 glslang 中的错误:

当一个压缩器破坏你的编译器时,你就知道你正在突破界限。

减少唯一变量名的数量非常有效。 但是你还必须选择好的名称。 我们应该将变量命名为 "V" 还是 "A"? 实验表明,选择一个名称或另一个名称会影响压缩大小。 很难知道哪个名称会表现更好,但我们计算字母和二元语法的频率,以猜测哪些名称可能更好。 这个想法是查看哪些字符在代码的其余部分中出现得更频繁,以及哪些字符对已经很常见。 最后,这只是一种启发式方法,我们可能会做得更好。

8k 比 4k 大

The Sheep and the Biker, by Ctrl-Alt-Test (8kB)

通过实现这些功能,我最初的目标已经实现。 许多 demoscene 开发者多年来一直使用 Shader Minifier 来创建他们令人惊叹的 4k intro。

但是有一天,我决定创建我的第一个 8k intro。 关于 The Sheep and the Flower 背后的故事在博文“How we made an animated movie in 8kB”中详细介绍。

当代码量很小时,大小编码和代码高尔夫很有趣。 但是,随着代码库增长到 1,000 行以上,微优化变得越来越痛苦。 问题是我们还需要维护和迭代代码,因此在 intro 的整个开发过程中,它需要保持可读性。 为了能够 sheep 我的 intro,Shader Minifier 中需要更多功能。

这是一个显示演变的图表:

该图显示了 Shader Minifier 的演变,以及我的 47kB shader 代码在经过压缩和压缩后会变得多大。 如果没有压缩,Crinkler 会将代码压缩到大约 10kB。 我比较了大约 20 个不同版本的 Shader Minifier,并使用 Crinkler 压缩了它们的输出,以跟踪该工具的演变。

因此,Shader Minifier 最近的改进在这个特定的 shader 上节省了大约 1kB(在 1.3 版和 1.5 版之间)。 但不要太关注这个数字:一些改进与生活质量有关,而不是原始大小。 例如,我们不再需要手动查找和删除未使用的函数,这很棒。

如果您想知道 1.0.5 版本中的大小回归:当时,我们缺乏适当的压缩测试,因此它没有引起注意(它与重命名启发式方法有关)。 测试基础设施是我们后来改进的东西。 无论如何,重点是 47kB 在经过压缩器和压缩魔法后变成了 5.2kB。 剩余的 8kB 充满了音乐和设置代码。

那么,自 1.3 版本以来,我们做了什么?

静态分析

我们使用了静态分析并实现了优化编译器中常见的特性。

优化的完整列表很长,它包括许多微优化以及诸如 GLSL 向量和 swizzle 转换之类的内容。 如果您好奇,请查看文档以获取更详细的优化列表

以下是一些最具影响力的优化。 您会注意到它们如何尝试减少我们需要的名称数量。 无论是变量还是函数,每次我们可以摆脱标识符时,我们都会帮助代码更易于压缩。

内联

如果一个变量仅使用一次,我们可以内联它并消除声明。

即使使用多次,像 0.5 或 vec3(1) 这样的简单常量通常最好内联。

变量重用

在某些情况下,我们重用变量名而不是声明一个新变量,假设它们不重叠。

例如,这段代码:

vec3 x = vec3(.2);
# use x
# …
vec3 col=vec3(0,.04,.04);

可以转换为:

vec3 x = vec3(.2);
// use x
// …
x=vec3(0,.04,.04);

函数

Shader Minifier 可以内联小型函数并删除始终接收相同值的参数。

例如,Shader Minifier 将检测到 corner 参数在这里不是真正需要的:

float Box3(vec3 p, vec3 size, float corner)
{
  p = abs(p) - size + corner;
  return length(max(p, 0.)) + min(max(max(p.x, p.y), p.z), 0.) - corner;
}
// …
float x = Box3(p, size, 0.2);
float y = Box3(p, size*2., 0.2);

因此,我们可以将代码段转换为:

float Box3(vec3 p, vec3 size)
{
  float corner = 0.2; // 请注意,它可以进一步内联
  p = abs(p) - size + corner;
  return length(max(p, 0.)) + min(max(max(p.x, p.y), p.z), 0.) - corner;
}
// …
float x = Box3(p, size);
float y = Box3(p, size*2.);

但是,如果 Box3 函数仅被调用一次,则 Shader Minifier 将改为删除函数声明并在调用站点内联该函数。

仍有~~增长~~收缩的空间

Once Upon A Time In A Datacenter, by iapafoto (4kB)

从 15 年前的简单工具开始,它已经发展成为更复杂的东西。 近年来,我们的目标是简化 8k intro 的开发并使该过程更加愉快。 借助 Shader Minifier,您可以实现更多目标,而无需花费无数时间进行微优化。

我希望上面的图表能够鼓励用户升级他们的 Shader Minifier 版本。 很多人都会下载一次并保留多年。 新版本可以帮助您将更多内容压缩到可执行文件中。 当您有大量代码时尤其如此。

但这还没结束。 Shader Minifier 在创建 64k intro 时表现如何? 这些较大的 intro 带来了它们自己的一系列挑战,通常涉及我们必须一起压缩的多个 shader。 虽然 Shader Minifier 已经可以节省多个 KB,但仍有许多改进的机会……

我们将对此进行调查。 还有字节在等待被保存。