排字工人是邪恶的

2020-09-15 04:33:57

首先,我想说清楚的是,我并不是在指责排字员的真正邪恶,我粗略地将其定义为故意制造痛苦。不幸的是,世界上有太多这样的事情。我的意思是,在更隐晦的意义上,它会导致严重的问题,并迫使系统的其他部分变得更复杂,以绕过其局限性。

我还将概述未来可能实现的更好的设计,以及应用程序如何处理当前情况的一些可能性。但首先,为了更好地理解我们是如何陷入这场混乱的,我们将回顾一下过去。

早期的家用电脑和视频游戏有8位CPU和只有几千字节的RAM。一些计算机,如Apple II,分配了一些RAM作为帧缓冲区,并使用CPU来填充它,但这种方法是相当有限的。因此,当时大多数其他系统的设计者都很有创意--特别是,他们用图形处理引擎扩充了CPU。该引擎在扫描输出期间执行了相当数量的处理,换句话说,当系统生成视频信号时是动态的。

这些早期图形处理器的细节各不相同,但大多数都是四种基本操作的组合:

即使是一个普通的CPU和这些视频处理引擎之一的组合也导致了游戏的创造性爆炸,视频芯片提供的基本元素形成了一种独特的美学:由瓷砖形成的彩色滚动背景,玩家头像和其他角色叠加在一起,也是动态移动的。C64VIC-II是这类芯片中最流行的(也是记录最好的)之一,当然NES PPU是另一个经典的例子。人们仍然在为这些受人喜爱的系统开发软件,向初学者学习诸如VIC-II之类的教程。

虽然硬件的限制为各种令人印象深刻的游戏语料库提供了艺术灵感,但它们也限制了可能的视觉表达方式。例如,任何3D的尝试充其量都是原始的(尽管有一些驾驶游戏做了令人印象深刻的尝试,使用滚动和调色板机制来模拟移动的道路)。

最雄心勃勃的游戏和演示咄咄逼人地抨击硬件,“争先恐后”地动态操纵视频芯片中的寄存器,解锁了更多的颜色和其他图形效果,这无疑是硬件的原始设计者都没有预料到的。

同样值得一提的是Atari 2600,它的全部20位(对,2.5字节)专门用于可能被认为是帧缓冲区的东西。基本上所有的图形效果都需要通过竞速光束来实现。幸运的是,程序员可以利用6502微处理器上的指令的确定性周期计数,因此对视频寄存器的写入将以比一条扫描线精细得多的定时发生。即便如此,要以全部三种颜色显示递归中心徽标,也需要一些相当棘手的代码。

所有这些系统的一个重要方面,即使是像雅达利2600这样动力不足的系统,延迟也非常低。一款编码良好的游戏可能会在垂直消隐间隔期间处理输入,更新滚动和精灵坐标(只需几次寄存器戳),因此它们适用于要扫描出的下一帧-延迟低于16ms。类似的数学方法也适用于打字的延迟,这就是为什么与现代电脑相比,Apple IIe在延迟测试中得分如此之高。

在80年代后期,当街机游戏继续发展他们的图形硬件时,IBMPC作为一种家用计算机开始方兴未艾。作为一台“商务”计算机,它的CPU性能和RAM大幅增长,但视频输出通常是一个帧缓冲区,几乎没有上面列出的任何功能。即便如此,显示分辨率和位深度也有所提高(VGA为640x480,每像素16色)。一个相当强大的CPU,特别是在专业程序员手中,可以产生相当令人印象深刻的图形。微软飞行模拟器(Microsoft Flight Simulator)就是一个例子,它有完全用软件绘制的3D图形。

另一个具有里程碑意义的版本是1993年发布的最初的末日,它也是完全由软件渲染的图形,包括照明和纹理。

在此期间,UI和2D图形的渲染也在继续发展,比例间隔字体成为标准,抗锯齿字体渲染在90年代也慢慢成为标准。(大约在1992年,橡子阿基米德很可能是第一个采用抗锯齿文本渲染的产品)。

与此同时,一种趋势是能够运行多个应用程序,每个应用程序都有自己的窗口。虽然这个想法已经存在了一段时间,但随着Macintosh的出现,它突然出现在了现场,不久之后,世界其他国家也纷纷效仿。

早期的实现没有“抢占式多任务”,也就是我们所说的进程分离。即使在窗口模式下运行,应用程序也会直接写入帧缓冲区。该平台基本上为应用程序提供了一个跟踪可见区域的库,因此它们不会在窗口被遮挡的区域进行绘制。与此相关的是,当遮挡窗口消失时,系统会通知需要重新绘制的应用程序(Windows上的WM_PAINT),并且,由于计算机速度较慢且此过程需要一段时间,“损坏区域”会有一小段时间可见(这在Jasper St.Johns的X Window System基础文章中进行了解释和动画演示)。

在这个早期版本的窗口GUI中,延迟没有受到严重影响。有些事情会慢一些,因为分辨率和位深度都在提高,当然还需要将文本呈现为位图(抗锯齿的计算代价更高),但是编写良好的应用程序仍然可以响应很快。

然而,如果没有其他原因,那么在没有分离进程的情况下运行是一个严重的问题,因为一个行为不正常的应用程序可能会损坏整个系统。在Mac OS之前的X和NT之前的Windows时代,系统崩溃是非常常见的。当然,在Unix传统中,应用程序将在它们自己的进程中运行,并使用某种客户端-服务器协议将多个应用程序的表示结合在一起。X系统(又名X11)开始在Unix中占据主导地位,但在此之前还有许多其他提议,特别是Blit和News。这也是Unix传统的共同之处,它们通常会在网络上运行。

当OSX(现在的MacOS)在2001年首次发布时,它在许多方面都给人以视觉上的震撼。值得注意的是,在此讨论中,窗口的内容与完全Alpha透明度和柔和阴影混合在一起。它的核心是一个排字工人。应用程序不是直接绘制到屏幕,而是绘制到屏幕外的缓冲区,然后使用特殊的进程Quartz Compositor合成这些缓冲区。

在第一个版本中,所有的绘图和合成都是在软件中完成的。由于当时的机器并不是特别强大,所以性能很差。根据一篇回顾,“在它最初的化身中,Aqua慢得令人无法忍受,而且是一个巨大的资源消耗者。”

即便如此,情况还是有所改善。到2002年8月的10.2(捷豹),Quartz Extreme在GPU中进行合成,最终使性能可与预合成设计相媲美。

虽然有一些变化(下面将详细讨论),但Quartz Compositor基本上是当今使用的现代合成器设计。微软Vista在DWM上采用了类似的设计,首先是在Vista,并在Windows8中成为非可选的。此外,虽然我认为Aqua是现代意义上的第一个真正的合成器,但也有一些重要的前身,特别是Amiga。

使用GPU只使用bitblt窗口内容只使用了其功能的一小部分。在合成过程中,它可能会淡入淡出Alpha透明度、四处滑动子窗口以及应用其他效果,而根本不会降低性能(GPU已经在从屏幕外缓冲区读取所有像素并将其写入显示表面)。唯一的诀窍是将这些功能公开给应用程序。

在苹果方面,这是由iOS及其对Core Animation的严重依赖推动的。这个想法是,“层”可以用相对较慢的软件渲染来绘制,然后滚动和许多其他效果可以通过在GPU中合成这些层来以60fps的速度流畅地完成。

核心动画也可以在MacOS10.5(Leopard)中使用。相应的Windows版本是在Windows8中引入的DirectComposition,它是Metro设计语言的核心功能(当时还不是很流行)。

这些特性使得Compositor对于现代GUI软件更加不可或缺,尽管应用程序对Compositor特性的利用有很多积极之处。

我谈了很多关于延迟的问题,但是让我们来理解一下为什么引入合成器从根本上增加了这么多延迟。其中一些问题也在博客文章“桌面合成延迟是真实存在的”中进行了讨论,这让我很恼火(另见HN讨论)。

渲染一帧内容所需的时间各不相同。如果目标是流畅的60fps(正如我认为的那样!)。那么它应该可靠地在16ms以下,理想情况下还有一些余地。将所有应用程序的所有窗口合成在一起所需的时间也各不相同。这通常会比这低得多,但这取决于显示器的分辨率、图形硬件的能力(大多数机器都运行在集成的GPU上,在某些情况下相当低),场景的复杂性(特别是如果有很多重叠的窗口具有Alpha透明度),以及其他因素。为简单起见(尽管见下文),这些系统的设计通常也有16ms的合成预算。当然,这两个任务是在争夺资源,所以更准确的说法可能是它们的总和应该在16ms以下,并且两个任务之间的分配是任意的。

因此,在大多数系统中,一切都与帧扫描输出的开始同步,这一事件通常称为“vsync”。这将启动三个并行运行的进程:一个帧的扫描输出、另一个帧的合成和另一个帧的内容呈现(由应用程序进程)。当渲染开始时,它是从该点的输入快照开始的。因此,如果在vsync之前发生按键操作,渲染将在不久之后开始,那么渲染的帧将在16毫秒后呈现给合成器,并在16毫秒后开始扫描输出。如果光标位于屏幕顶部,则最好的延迟为33ms。

但这是最好的情况了。最糟糕的情况是,按键发生在vsync之后,因此渲染甚至要等到16ms后才会开始,而且光标在屏幕底部,所以scanout还需要16ms才能达到该点。(是的,这是一个鲜为人知的事实:屏幕下半部分的延迟比屏幕顶部的延迟更严重)。因此,最坏情况下的延迟(可以说是最重要的衡量标准)是66ms。

最常谈论的是平均延迟(在本例中为50ms),但分布也很重要。显然,最小方差是从0到16ms的均匀分布,但调度抖动和其他系统问题可能会使情况变得更糟。

我在这里使用的是整数,假设速度为60fps(从事这项工作的工程师也知道,每13.379纳米两周就有一帧)。显然,所有的数学标尺都有更高的刷新率监视器,这是享受它们的一个原因;有关该主题的大量经验测量和分析,请参阅blurbus,重点放在游戏上。

由于合成器增加了延迟,并且需要带宽,随着时间的推移,越来越多的趋势是将它的一些功能转移到专门的硬件上。最早的例子是scanout中叠加鼠标光标的特殊用途电路,有时称为“丝绸鼠标”。最重要的是,视频回放通常被定向到覆盖窗口,而不是通过合成器。在这种情况下,即使使用GPU计算功能,专用的缩放和颜色转换硬件也可以比在软件中做同样的事情快得多,耗电量也低得多。

移动电话是硬件覆盖的下一个重大进步。他们倾向于使用少量的窗口;Android的实现Hardware Composer HAL文档列出了4个作为最低要求(状态栏、系统栏、应用程序和墙纸)。作为Android UI工具包团队的一员,我很想将键盘添加到这个列表中,但是对于99%的屏幕时间来说,4个键盘仍然是一个合理的数字。当超过该数字时,溢出将由GLES处理。对于任何对细节感兴趣的人,请阅读该文档,因为它非常清楚地解释了问题。

在台式机方面,Windows8.1带来了多平面覆盖支持,这似乎主要是出于视频游戏的需要,特别是在比显示器和缩放更低的分辨率下运行游戏,同时允许通知等UI元素以全分辨率运行。在硬件中进行扩展可以减少稀缺GPU带宽的消耗。浏览器也将覆盖用于其他目的(我认为主要是视频),但在主流GUI应用程序中,它们的使用相当神秘。

多平面覆盖的主要限制是它们只覆盖一些专门的用例,并且应用程序必须使用高级API显式选择加入。激发DirectFlip的观察是,在许多情况下,在合成堆栈的前面有一个应用程序窗口,它高兴地呈现给它的交换链,它可以被提升为硬件覆盖,而不是通过合成器。在一些硬件上(我相信Kaby Lake和后来的集成英特尔显卡),DirectFlip是打开的。

当然,DirectFlip也有一些问题。它是使用启发式启用的,我认为应用程序没有一种简单的方法来告诉它是通过DirectFlip而不是合成器呈现的,更不用说要求这样做了。而且,虽然我还没有做过实验来证实,但我强烈地预计会有不可靠的情况,因为当窗口升级到DirectFlip或再次升级到DirectFlip时,延迟会突然改变。弹出上下文菜单可能会扰乱平稳运行的动画。

通常,游戏的全屏模式会绕过合成器(一长串特殊情况解决方法中的另一个)。理解DirectFlip的动机和实现的一个好方法是,它是一种让游戏获得与Full Screen基本相同的延迟和性能的方法,而不必放弃在Windows桌面上的温暖拥抱。

DirectFlip变得越来越普遍的一个结果是,对于图形复杂的应用程序(如浏览器)来说,它提供了一个艰难的权衡:要么它可以利用合成器执行滚动和光标闪烁等任务,这通常会降低功耗;要么它可以自己完成所有合成,并希望将窗口提升到DirectFlip,在这种情况下,除了减少延迟之外,总功耗很可能(当然,取决于工作负载的细节)也会降低。

如果没有关于平滑窗口调整大小的章节,任何关于合成器的坏处的讨论都是不完整的,这是我以前讨论过的一个主题。

在普通游戏中,图形流水线,包括交换链呈现和合成,可以看作是一个线性流水线。但是,当用户调整窗口大小时(通常使用鼠标),管道会分支。一种途径是应用程序,它从平台获得大小更改的通知,但通常是渲染和呈现帧到它的交换链。另一条路径是窗口管理器,它的任务是呈现窗口周围的“Chrome”、投影等。这两条路径在合成器处重新会聚。

为了避免视觉瑕疵,这两个路径必须同步,以便窗口框架和窗口内容都基于相同的窗口大小进行渲染。此外,为了避免额外的不稳定,该同步不能增加显著的额外延迟。这两件事都可能而且经常出错。

过去,Windows工程师花了相当大的精力才能把这件事做好。大多数Direct2D应用程序也使用DX11呈现模式(DXGI_SWAP_Effect_Sequence,在我的测试中它的行为与HwndRenderTarget相同),它将交换链复制到窗口的“重定向表面”,这对性能非常糟糕,但却是与窗口调整大小事件同步的机会。在我的测试中,使用这些当前模式,再加上交换链上推荐的ResizeBuffers方法,工作得非常好。不过,我知道我把性能放在一边了;建议升级到DX12显示模式,因为它避免了复制,解锁了延迟可等待对象的使用,而且,我相信,这也是DirectFlip的先决条件。

但是显然DX12工程师忘了添加调整窗口大小的同步逻辑,所以人工处理相当糟糕。Windows终端使用新模式,果然有一些瑕疵。

我相信如果他们愿意的话,他们可以解决这个问题,但这很可能会给应用程序开发人员增加额外的负担,甚至会变得更加复杂。

我在这里主要关注Windows,但MacOS也有自己的问题;这些问题在我之前的帖子中已经大体上讨论过了。

我们可以忍受糟糕的排版性能,接受这样一个事实,即Windows不能平滑地调整大小(尤其是在Windows上),延迟比Apple II要糟糕得多,电池的续航时间也不会那么长。

但是一个更好的设计是有可能的,通过一些艰苦的工程工作,我将在这里概述一下可能会是什么样子。它分为两部分,第一部分关注性能,第二部分关注权力。

首先,可以运行合成器来竞争波束,使用与现在高性能仿真社区中所做的类似的技术。从本质上讲,这将解决延迟问题。

事实上,使用波束竞速设计,延迟改善甚至可以超过一帧,而不会重新引入撕裂。它实际上可以接近苹果II的标准,方法如下。比方说,当在文本编辑器中按下一个键时,应用程序会准备并呈现一个最小的损坏区域,并将当前请求发送给排版程序。然后将其视为原子事务。如果请求在光束到达损坏区域顶部之前到达,则计划在当前帧进行请求。否则,整个请求将自动推迟到下一帧。通过这种安排,更新的像素几乎可以在按键到达之后立即从硬件扫描输出中流出。与苹果II时代不同的是,它这样做是不会撕裂的。

请记住,现有的合成器设计对于错过最后期限要宽容得多。一般来说,合成器应该每16ms生成一个新的帧(当应用程序更新时),但是如果错过了最后期限,最糟糕的结果就是人们已经习惯了的jank,而不是视觉效果。

光束竞速设计的主要工程挑战是确保在开始扫描图像数据之前可靠地完成合成工作。实时音频的体验表明,要在计时截止日期前可靠地完成调度CPU任务已经够难的了,而GPU似乎更难在有计时保证的情况下进行调度。合成器必须以比其他工作负载更高的优先级进行调度,并且必须能够抢占它们(在试验GPU计算时,我发现普通用户进程中长时间运行的计算内核可能会阻塞合成器;这种行为在波束竞速配置中是完全不可接受的)。与音频一样,还有互动。

.