Archive for the ‘软件开发’ Category

Phong Model and Physically-Based

2020/04/25

Phong model 大概是计算机图形领域最简单的材质模型。仅由三个 (组) 参数组成。Nuo Model Viewer 作为个人学习项目从实现 rasterization 渲染入手,自然最初只支持 Phong model。有个问题伴随着 Nuo Model Viewer 的扩展:是否要支持其它更复杂的材质模型?另一个相关问题是,如果 Nuo Model Viewer 支持 path-tracing,对于现有基于 Phong model 的模型是进行预处理转换成其它材质模型,还是在渲染中直接处理 Phong model ?

我准备在 Nuo Model Viewer 中实现 path-tracing 时发现手头大部分模型只有 Phong model 材质。更重要的是,计划实现的许多效果似乎不需要一种比 Phong 更复杂的材质模型。我决定在渲染算法中直接处理 Phong model 或者它的简单变种,因为可能有两个好处:第一是避免让代码更复杂去支持实际很少用到的材质模型;第二是避免直接照抄现有资料里对材质的处理,从而能发现渲染方法和材质模型集成的一些问题 [1]。

什么是「Physically-Based」?

首先把注意力从 Phong model 本身移开,从更宽泛的角度考虑材质模型在渲染方法中「可用」的标准。Path-tracing 渲染的方法和材质被称为 physically-based。渲染过程的 light tranport equation 可以说有比较严格的物理基础,但材质方面很难用简单模型直接模拟物理材料。 现今使用的 phiscially-based reflection models 的定义和真实世界材质的微观结构只有很弱的联系。所谓「弱联系」意味着图形学的 physically-based material 不代表「符合」真实世界,而仅仅代表「不违反」真实世界的某些限制。

最常见的 physically-based model 是 BRDF。满足「能量守恒 (energy-conservative)」和「光路可逆 (reciprocal)」两个限制的 reflection model 都可称为 BRDF。更「真实」的 reflection model 就是要满足这两个 BDRF 限制以外的更多限制。虽然很多介绍 path-tracing 的资料涉及了更「真实」的 reflection model,但几乎没有直接总结 BDRF 以外的限制。为了稍后能理解 Phong model 的局限以及如何修改让它能适应 path-tracing,把这些限制一起总结如下:

  • 能量守恒,对所有可能的 ω’,reflectance distrubtion 满足
  • 光路可逆,满足
  • 连续变化,如果观察真实材质时视角和光源的连续变化不会导致颜色的突变,那么虚拟材质的定义也要保持连续。
  • 边界条件,这条会在后面章节详细解释。

BDRF Normalization

现在回到 Phong model 本身。如果它能够直接或者在修改后满足上节列出的限制,就能适用于 path-tracing。在 Phong model 刚被提出时,计算机图形还通常只处理 singular 光源 (点光源或理想方向光源) 。这种情况下 Phong model 公式为:

这里的是出射光线与入射光线的理想镜面反射方向之间的夹角。因为后来提出的 micro-facet 模型以及实际观察的效果,在实际应用中已经被 half vector 和 normal 的夹角取代。本文后面也会改用,但目前的讨论中依然先用

要适应 path-tracing,这个公式首先要改写成基于 reflectance distribution 的积分形式,从而不再局限于 singular 光源。并且我们希望改写后的形式至少满足 BRDF 的两个限制。首先,把相对光强值替换为有实际物理意义的 radiance。然后在式子中加入 normalization 系数,下面我们分析如何对它们合理取值以满足 BDRF 甚至更多的 physically-based 限制。

在分析如何取值之前先解决另一个问题。在上面式子中出现了作为分母的情况,所以必须对为零的情况做下面的特殊处理:

这违反了前面提到的「连续变化」限制 [2]。所以很早出现的对 Phong model 改动之一是去掉分母,变为 [3]:

这个变化说明为了让 reflection model 满足 phiscally-based 的某个限制,我们会作出并不深究真实物理根据的「粗暴」改动。现在来确定的具体取值。这里先尝试只考虑「能量守恒」限制。假设材质只有 diffuse 部分 (即为零),从以前的 blog 可以得出在不大于 1 的前提下,有:

用类似的方法分析,先假设为零,满足能量守恒的不等式为:

这里不妨设 ω‘ 与 normal 重合,因为可以使不等式左侧最大化。左侧变换为:

可以得出在不大于 1 的前提下,有:

需要注意的是上面的推导基于而不是今天业界常用的。基于的推导复杂得多。所以本文只用基于的推导展示大致思路,就不具体推导基于的模式。而且基于的模式中的精确取值很复杂,实际工程中一般取下面的近似值:

代替的 Phong model 的变种称为 Blinn-Phong。经过 BDRF normalization 的 Blinn-Phong reflectance distribution 的完整公式为:

至此我们完成了修改 Phong 更加符合 physically-based 原则的第一步,BDRF normalization。在这个过程中有没有忽略或者违反了其它的 physically-based 限制,在后面会有更多的讨论。

Importance Sampling

为了在 path-tracing 过程中使 Monte Carlo 方法能更快收敛,需要在 path construction 过程中选择合适的 importance sampling。以前的 blog 里分析过,当 path construction 的采样概率分布为时 LTE 的 Monte Carlo estimator 为:

下面分别考虑 diffuse 和 specular 部分。对 diffuse 部分来说,只要让采样概率分布满足就可以让 estimator 简化。把一个 uniform 分布的随机变量转化为是非常简单的操作,这里不详细分析。

下面来分析 specular 部分。 Blinn-Phong model 只考虑 specular 的 estimator 可以直接写为:

关键在于找到合适的 来抵消。因为与 half-vector 相关,很难通过到入射光线的采样来抵消它。所以我们尝试用 sample half-vector 的方法来解决。有现成的办法 [4] 可以把 uniform 分布的随机变量转换为满足的随机量,根据概率分布的归一化可以得到 [5]:

根据微分关系,可以得出:

将上式代入之前的 estimator 公式,就可以得到 Blinn-Phong specular 部分的 Monte Carlo estimator 为:

边界条件

上面章节数学分析已经足够在 path-tracing renderer 里原封不动地实现 Blinn-Phong model。按照所述的方法 Nuo Model Viewer 做到了 path-tracing 和 rasterization 的效果几乎一致。

接下来我们当然想验证用这种实现能否完成更多 physically-based 效果。在 Nuo Model Viewer 的实际测试中,间接光照和体积光源的效果都不错。那么在 path-tracing 中经常被提及的镜面反射效果方面,Blinn-Phong model 的表现如何?

现在暂时脱离对「镜面效果」的分析,而去考虑如何衡量一个 reflectance distribution 的数学描述是否更好的符合物理实际。我们几乎不可能实际测量真实世界的物体表面在整个球面坐标上任意两个方向的光线反射能量比,也不可能随心所欲地构建物质来验证任意参数的 reflectance distribution 函数。但我们有可能做到两件事:第一,用实验测量或者严格的物理理论来得到特定方向上的光线反射能量;第二,把实际物质表面的某种特定性质对应到 reflectance distribution 参数的某个特定取值。这样就可以比较测量值或理论计算值是否和对应参数的 reflectance distribution 在特定方向的值足够接近。这就是用「边界条件」来衡量 reflection model 的质量。

回到「镜面效果」的问题。在真实世界里随便选择一个实际物体的表面,我们没法直接得出其对应的 Blinn-Phong 或者其它 reflection model 的参数。但是对于真实世界中非常光滑的接近镜面的表面我们可以做到。对于基于 micro-facet 模型的 reflection model 来说,「非常光滑」表示 micro-facet normal 的分布不再是连续范围,而是等于宏观的 surface normal。对于 Blinn-Phong model 来说,这表示的参数 m 为无穷大。

另一方面,我们可以在真实世界中通过物理理论分析和实验测量来得到光在光滑表面上的反射能量。通过 Fresnel equation 分析和测量的验证可知镜面反射方向出射光和入射光随入射角的变化规律。当从 0 开始增加时,在很大范围内出射光的 radiance 和入射光之比接近一个小于 1 的常数,在接近 90 度时这个比值会急剧上升,在 90 度时为 1。在物理学上把这个比值相对的函数称为 Fresnel reflectance,用表示。

现在回去检验 Blinn-Phong model 是否符合镜面反射下关于的「边界条件」。当 m 无穷大的时候其 reflectance distribution 是一个冲击函数,这时 Monte Carlo 的采样方向不再随机分布,而是固定在理想反射方向。其 estimator 就是积分的确定值。

而根据物理分析,我们期待的理想镜面采样值应该是:

上面的 reflectance distribution 的计算值中的导致了在边界条件下显然不符合实际的物理状况,连近似都谈不上。说明 Blinn-Phong model 不能胜任在 path-tracing 中实现镜面效果。Nuo Model Viewer 的测试结果是镜面反射的光强过低。因此我们要寻找新的符合镜面「边界条件」的 reflection model。我们希望在的情况下这个模型的 LTE 积分和上面期待的采样值近似或者相等。

但是,先等一下!既然我们希望采样值是上面的形式,那还用求出 reflectance distribution 吗?直接把这个采样值作为 Monte Carlo 过程的 estimator 不就可以了!确实如此,只不过还需要对它做个小改动。上面的式子并不能在接近 0 的时候连续的减少为 0,以此它依然有最初 Phong model 在处不连续的缺陷。考虑到这种不连续通常在观察角度 (也就是) 比较小时影响明显,所以我们把 estimator「粗暴」地改为:

从这个 estimator 可以反推出 specular reflectance distribution 为:

而我们可以在《Physically-Based Rendering》介绍的几种 model 中发现,其中的 Fresenel Blend model「恰好」符合上式。这大概也是它得名的原因 —— 与其说是通过完整物理模型推导出来的,不如说它只是从这单个实际物理特性出发,考虑 micro-facet 模型共有的的微分关系,为了满足「镜面」边界条件反向凑出来的模型。

前面提到过给定材料的光滑表面的从 0 开始的一个很大范围内都等于某个小于 1 的常数。我们可以把用作这个常数。对于大多数金属,其光滑表面的接近 Schlick 方法:

如果把上式推广到「不绝对光滑」的 micro-facet 模型表面,应该由与 micro-facet normal 相关的夹角取代。同时,因为 specular 部分的取值大于 Blinn-Phong model,Fresenel Blend 的 diffuse 部分要依据「能量守恒」作出调整。

Physically-Based Rendering

本文讨论了 Phong model 如何经过必要修改从而应用于 path-tracing。经过修正的 BRDF Blinn-Phong 可以用于 path tracing 的 importance sampling。但它也有不能实现镜面反射的局限性。

新选用的 Fresenel Blend model 满足镜面的边界条件。同时它的 reflectance distribution 可以适用于任意的,也就是任何 micro-facet model。所以类似于 Blinn-Phong,基于Fresenel Blend model 的 Monte Carlo 过程依然可以采用的 path construction 方法。这样 Nuo Model Viewer 只需要单一的 importance sampling 代码。

和 Blinn-Phong 一样,Fresenel Blend model 也由三个 (组) 参数定义。可以直接把前者的m 套用到后者上。但这样直接套用的结果当然和 Blinn-Phong 本身的效果不同。在无需明显镜面效果的模型上 Nuo Model Viewer 依然采用 Blinn-Phong model 来体现设计本意。需要镜面效果的部分通常是地面,特别是和真实图片 blending 时采用的 virtual ground plane。Nuo Model Viewer 在这部分采用 Fresenel Blend model。

脚注:

  1. 这也秉承了我一贯不喜欢用现成实验环境的习惯。
  2. 对 reflection model 的积分通常只考虑上半球,所以 naive 的想法是取值造成的不连续性似乎无关紧要。但不要忘记,只考虑上半球的原因正是因为假定在的条件下。所以必须避免处的不连续。
  3. 因为去掉分母使被积函数整体连续,并且在时总为零,所以在只考虑上半球的前提下可以去掉其中的 max 函数。
  4. Diffuse importance sampling 可以看作此方法在 m = 1 时的特例。
  5. 此处推导与上文 specular 部分的 BDRF normalization 方法类似。

单元测试和个人技能成长

2020/04/17

最近看到个对单元测试的看法:

单元测试是预防错误的主要手段。如果一个团队所在的领域对错误的容忍度高,而其市场需要 move fast,就 (暂时) 不用有单元测试;反之,若所在领域对错误容忍度低,那么重视实践单元测试的团队会取得优势。

然而,单元测试并不是「预防错误的主要手段」。首先单元测试并非全部测试。除了单元测试,还有组件测试,系统整体的人工测试,自动化的整体测试等等。团队资源应该在所有测试类型间合理分配,而不是预设单元测试最重要。其次,错误预防的关键点在于 escape to user 而不是 commit to repo。也就是说很多非测试技术,比如 Git 的合理 branching 策略和 back tracking 也可以更合理的成本降低错误。从最终结果的角度来看,若先不考虑 escape to user 造成的公开影响和修复 bug 的成本,系统整体的人工测试就可以发现所有错误。如果把软件开发的概念套用在流程设计上,整体人工测试是开发流程的基本功能,而其它方法都是开发流程的「优化」。那么可以想到关于优化的老话:「Premature optimization is the source of all evil.」 优化的前提是 profiling。制定流程应该把「只有整体人工测试」作为缺省情况,根据执行的实际结果谨慎加入其它技术。

说到这里谈谈这篇 blog 的真正重点。我之前涉及单元测试讨论的 blog 有好几篇,所以并不想再写一篇去扩展甚至重复以前的观点。这里我想到的不是分析的结论,而是分析这些看法的手段。最开始引用的看法把缺乏单元测试的视为一种「技术债务」而不是可以在团队中合理分析执行的长期策略。这是个错误的归类,而由这个错误可以想到技术人员如何思考自己的技能成长。

现今是「纯技术」有所贬值的时代。当年 Paul Graham 用 Lisp 作出网站卖得 5000 万美元而鼓吹了很久 Lisp。但回过头去看 Lisp 已没那么神奇。很多通用技术已经被成熟的模块甚至 AI 部分取代。技术人员需要思考的是有什么和技术密切相关但又不是「纯技术」的东西,可以帮助发展个人技能。对「技术债务」的理解和评估恰恰是技术人员可以体现不可替代性的重要项目。认清团队技术储备中哪些是资产,哪些是债务,如何合理的偿还或者重组技术债务,这些是和管理财务债务一样重要的技能。

一次性代码和坚固的基础

2019/10/13

我对「技术债务」的态度和像对财务债务一样「中立」。借入债务以求发展是必要的,只要在 accounting 里确实把它标成「债务」。债务不断积累时要寻求机会逐步清除它们。比如说对还算能运行的代码进行 refactor。

这会遇到一种反对意见:这段代码估计一两年以后就要废弃了,何必费力气?只要修修补补撑段时间就好了。

其实我的经历是被这么评价的代码反而往往会跟随一个团队十几年。另一方面,有些被寄予很高期望,计划会被使用很多年的代码却连一次正式使用的机会都得不到就被抛弃了。

其实这很正常。把两种预测反着理解就好。被人说「一两年以后就要废弃了」的代码若依然会被写出来,就说明有现实的需要,已经能给公司和团队带来正面的资源。这样的东西要被推倒重来是几乎不可能的。而「计划会被使用很多年的代码」往往是基于长远的愿景而缺乏当前的盈利模式,最后如果不能发现盈利的契机就被放弃。

一张 WWDC 幻灯片

2019/04/04

之前的 blog 里提到过,尽管早有愿望学 ray tracing,但 2018 前半年总提不起兴趣动手。六月 Apple WWDC 2018 里关于 ray tracing 的 session 成为我第一次真正接触 ray tracing。望着讲台上削减至极的幻灯片和 speaker 超快的解说,我想这似乎并不难,大概下周给同事总结 WWDC 时就可以自己解释这个问题。

实际上到现在过了十个月,才能说大致理解了这张幻灯片的数学原理。一个简单的 demo 固然能推进学习的进度,但是也可能引入误解。

渲染方程

上篇《 Wavefront OBJ 与 Monte Carlo 》第一次尝试总结对 ray tracing 的理解。其中以下面的积分式求 radiance (后面称该积分式为,其中 n 表示积分层数):

但根据渲染方程到达 camera 光线的 radiance 并非,而是:

这就是渲染方程的 light transport equation 形式。只是整个 LTE 中的单个 term,甚至没有明确的物理意义和常用名称。一般将其理解为光源经过 n 次反射/折射后对最终 radiance 的贡献。例如 n 取一时表示光源直接射入 camera,取二表示 direct lighting。

也就是说,上篇 blog 将直接作为 Monte Carlo 方法的求解对象的分析有些粗糙,需要修正为更严密的解释。

随机过程与「物理模拟」

在应用 Monte Carlo 方法分析之前,先说一下 Monte Carlo 方法的随机过程和 ray tracing 物理意义的关系。

当用简单例子 —— 比如说一元函数 —— 来说明 Monte Carlo 方法时,很明显会把它理解为一个抽象数学工具。把它应用在 ray tracing 问题时却容易误认为 Monte Carlo 方法的 sampling path 在模拟物理光线。如果在分析 ray tracing 问题之前没有把「path 不是光线」这个座右铭刻在脑子里,思维很容易走入歧途。

之所以 path 容易被误认为在模拟光线,是因为二者有些似是而非的相似性。最为显著的一点是,在定义渲染方程时,确实是以光线的物理特性为基础。但是最终定义得到的 LTE 的积分形式表明 radiance 的分布达到了 equilibrium 状态。这是统计上的确定状态,单条光线的特性已经被抛弃。之后对这个积分式采用的 Monte Carlo 方法是一种纯粹人造的随机过程,path 是纯粹抽象的随机取样。为了更准确的强调这点,本文描述的这种 Monte Carlo 求解 LTE 的方法不再被称为 ray tracing,而是叫 path tracing。

这里要提到的关于 Monte Carlo 容易被误解的最后一点是 estimator 和积分形式的对应和区别。Estimator 是随机变量,其数学期望等于它所对应的积分式的值。因此 estimator 和积分形式绝不能同时出现在一个公式中。不同随机过程产生的 estimator 也不能在一起运算。看起来很愚蠢,但我不知道反复卡在这个误解上多少次了。就在写这篇 blog 的过程中都曾经把两种形式写在一个式子中。

忽略光源位置的 Path Construction

回到之前的问题:需要通过 Monte Carlo 方法求解的是整个 LTE,而不仅是单个

在 Monte Carlo 方法中只要取样足够多,最终结果的正确性和 path 的构建方法没有关系。不妨先讨论在构建中完全不考虑光源位置,也就是单纯依靠 reflection sampling 的方式。Reflection sampling 可以采用任何随机概率分布,比如 uniform sampling,当然更常见的做法是根据 BRDF 做 importance sampling。

根据上篇 blog 中的 estimator 可以推导出 LTE 的 estimator 为:

根据常识可以假定自发光物体表面的为零 —— 光源本身不反射任何其它来源的光线。所以构建过程遇到第一个自发光物体就结束。并假定在光源之前的遇到的所有表面点的都为零 —— 普通材质不发光。在这些条件下,每条 path 的 LTE estimator 值只剩下一个 term:

其中 c 表示遇到光源时的路径长度。代码实现可以给 c 设置上限,达到上限依然没有遇到光源就认为这条 path 的 LTE estimator 为零。由此可以看到仅用 reflection sampling 构建 path 时上篇 blog 的结论依然成立,仅仅是原来的 n 被意义更加准确的 c 代替。

考虑光源位置的 Path Construction

但是,仅仅依靠 reflection sampling 构建 path 与光源相交的几率很低,结果的收敛速度很慢。因此大多实现在构建时会将最后一段 path 的终点取在光源表面上。这样的方式中 path 不是随机遇到光源终止,而是被「强制」结束,也就是说 LTE 不再作为整体在 path space 中被随机采样,只能为每个执行独立的 Monte Carlo 过程来分别近似积分。因此虽无直观的物理意义,但仍是 path tracing 中的重要概念。

每个的 estimator 如下:

这里的表示物体表面 reflection sampling 的概率分布。表示最后一段向光源表面取方向的概率分布。如果在表面积为 A 的光源上做 uniform sampling,则有:

每个的 estimator 应该由不同而且相互独立的 Monte Carlo 过程取得,各自收敛的数学期望近似值才可以相加。但在实际工程中,通常用下面的近似式作为一个整体的 estimator。

这里的「取巧」之处在于的 estimator 前 n-1 个采样点和的 estimator 的采样点相同,在本该分别采样的多个独立随机过程中引入了 correlation。但是通常认为这对结果的影响可以忽略。其 path 构建过程相当于从 camera 发出的 path 每次遇到物体表面就随机产生两条 path,一条强制射向光源表面上的随机点,另一条通过 reflection sampling 而得。

回顾和比较

经过上面的分析之后,可以看到仅仅依靠 reflection sampling 的方法数学上更简单,实现也更容易。所以有些 ray tracing 的介绍材料 —— 比如《 Ray Tracing in One Weekend 》—— 就采用了这种方式。但这种方式存在一个陷阱,就是容易让人误认为 Monte Carlo 方法是物理模拟。一些材料出于「形象化」考虑也确实如此误导解释。WWDC 的 demo 没有采用这种方式,我读过其代码之后第一件事就是误打误撞把它改成了只有 reflection sampling 方式,也是潜意识里向「物理模拟」靠拢的体现,这个思路可能歪打正着写出基本功能,但要想进一步改进代码就会缺乏理论基础。

考虑光源位置的 path construction 是高级 ray tracer 的通常做法。它的难点在于实现的近似简化。实际代码往往不会真正独立地执行多个随机过程,而是如上所说通过共享随机采样用一个随机过程近似多个 Monte Carlo 过程。本来多重积分用 Monte Carlo 方法近似就是比较难理解的过程,再把多个独立的 Monte Carlo 过程近似成一个随机过程就更增加了难度。WWDC 的 demo 采用的基本是这种方法。但是在材质和光源上都做了进一步简化。特别是光源采样时完全忽略了对的处理。所以将其改成只采用 reflection sampling 方式之后和原来的 demo 并不等价。在理解其中的数学解释之前,渲染结果的差异一直让我困惑。我曾经以为 WWDC 的 demo 起到了带初学者入门 ray tracing 的作用,其实它的用途仅仅是给真正理解 path tracing 的老手展示 GPU 加速功能。

Wavefront OBJ 与 Monte Carlo

2019/02/25

去年十月搬家到现在一直没有对 Nuo Model Viewer 做大改动。年前忙于布置新家。到新年前后稍感安定想增加些新功能,但越想越感觉已有的 ray tracing 代码缺乏数学依据,于是开始温习数学概念。

粗糙的实现

首先,考虑仅支持理想方向光源直射,以及理想纯漫反射材质 (Lambertian) 的 shading 公式 [1]:

这是两年前开始做 Nuo Model Viewer 时最先实现的公式。其实要分析很多细节才能保证完全正确的实现 —— 如何从模型材质描述中得到,如何处理 π 相关的系数,场景的光源描述如何与相对应。对于 Wavefront OBJ 格式来说,几乎找不到直接阐述 Kd 参数和的关系的文档。因为当时只支持直接光照和理想方向光源,对这些细节的分析都偷懒了,在用户界面上放个 slider 解释为,其中 k 为任意系数。如此一来 Kd 参数和的系数关系以及光源描述如何与相对应都无需精确考虑,通过 slider 的取值进行补偿达到最终结果美观即可。

去年实现了几个基于 ray tracing 的功能:area light source 产生的软阴影;ambient 环境光 [2];自发光物体的光照。当时并未多想通过 slider 补偿系数的做法是否还成立。现在发现,要继续做新东西有必要彻底理解这些细节。接下两节先分析 shading 公式中具体系数的意义。然后解释实现 ray tracing 要注意什么。

光源的理解

引入 area light source 后,光线方向不再限于单一角度。直接光照公式变为积分形式(这里只考虑 Lambtian 材质,在所有方向上为常数):

若将理想方向光源应用于积分形式,即为对单一方向上的冲击分布积分:

这与一开始的简单公式非常类似,要让二者等价只需保证。下面就来看看、以及 Kd 参数的关系。

材质的理解

、和 Kd 中,的物理意义最明确,是材质表面反射能量比值的微分形式,其一般形式定义如下:

现在来寻找的物理意义。如果去掉直接光照公式里的,即认为其在各个方向均为 1,就得到了材质的 directional-hemispherical reflectance,对一般材质和 Lambertian 材质分别是:

按照这个定义,就是 Lambertian 材质的。在《Real-Time Rendering, 3ed》里称其为 diffuse color。

现在看来,有两点证据说明 Wavefront OBJ 中的 Kd 就是:第一,Kd 在很多文档中也被称为 diffuse color;第二,对物理真实材质来说,能量守恒定律要求 小于 1,而 Kd 的取值范围一般是 [0, 1] 区间 [3]。这两点都不能说是完全确定的证据,但 Wavefront OBJ 是个老旧的格式,其材质描述方面的正式文档很少,这大概是可以得到的最好解释了。

Monte Carlo 方法

前两节解释了 shading 公式的细节,现在回到原来的问题 —— 加入基于 ray tracing 的功能之后是否还能用 slider 补偿系数关系,需要做出什么改变?首先从 ray tracing 使用的 Monte Carlo 方法说起。用简单的一重积分来说明,若求积分 [4]:

可以在 [a, b] 区间内通过均匀随机分布取一组 sample ,通过它们取得近似的结果:

或者用任意的随机分布来取样,近似结果为

 

这里求和式中的单项被称为 Monte Carlo estimator:

 

把应用扩展到多重积分,如果用均匀随机分布取样求积分:

那么其 estimator 为:

现在可以想到,若 ray tracing 实现里涉及多重积分,且每重都包含一定的系数关系,就不一定能用界面上的 slider 进行补偿。下一节分析在 ray tracing 多重积分中的系数关系。

Monte Carlo 方法与 Ray Tracing

现在把 Monte Carlo 方法应用到光线的路径函数上。表面反射次数为 n 的光路上的传播函数为 [5]:

去年给 Nuo Model Viewer 加入环境光和自发光物体光照时,并没有认真保证代码准确反映上面的多重积分公式。相反,当时还是惯性思维地觉得凭 slider 就能补偿一切系数问题,所以凭感觉写了一个 estimator:

现在来看数学上正确的 Monte Carlo estimator,如果采用均匀随机分布:

如果只考虑 diffuse 效果:

它们都带有含 n 次方的系数。在一个场景的 ray tracing 中,每条路径的 n 可以不同,可能是随机遇到了光源 (自发光物体),也可以是 tracer 主动在某个长度上向光源直接采样。看起来 Nuo Model Viewer 的效果正确性令人担忧。不过 Nuo Model Viewer 并没有采用均匀随机分布,而是以 cosθ 成正比的概率分布做 importance sampling。那么正确的 estimator 应该是:

其中的 k 是保证  归一化的系数,满足:

将 k 带入原式,得到:

居然恰好和最初乱写的一样!几个月没动代码,但总算搞清了其中的意义。

脚注:

  1. 见《Real-Time Rendering, 3ed》,p110。
  2. 不是基于 occlusion 计算的阴影,而是基于 ray tracing 直接计算 ambient lighting。
  3. 如果加入对 specular term 的考虑,并且类似的考虑 Ks 参数,则需要满足 Kd + Ks < 1。这在绝大多数 OBJ 材质中也是满足的。
  4. 本节中的 ) 表示任意函数,注意不要和下文中的 BRDF 分布函数混淆。
  5. 见《Physically-Based Rendering, 3ed》,p867。这里把公式改写为 solid angle 形式,并且用嵌套括号强调了其递归模式。简单的乘法结合交换就可以变成单个多元函数的多重积分。

从 Metal 看 Vulkan —— 重用还是重建

2018/06/04

Apple 给自己的图形系统取名 Metal 之意在于强调其开销很低,图形应用程序如同直接运行在「金属硬件」( bare metal ) 上。但对 Metal 和 Vulkan 都有了解的人直观上会感觉后者更复杂也更直接反映硬件细节。「Metal 并不能像 Vulkan 那样程度地直接操作硬件」是 Khronos 和 Vulkan 拥趸的一贯观点。

Metal 和 Vulkan 的差异有很多值得讨论之处。绝大部分是前者相对后者在概念上的合并和简化。本来想写篇长文逐一说明,后来决定先拿出 commnad buffer 重用这个问题在一篇 blog 里详谈。

Metal 和 Vulkan 的 command buffer 作用基本相同,用来记录一组 command [1]。记录完成后通过 commit/submit [2] 发送给 GPU 执行。Metal command buffer 在 commit 之后从 CPU 看来已成为「废弃」状态,除了等待其在 GPU 执行完毕之外不能再进行任何操作。而 Vulkan 可以在一个 command buffer 被 submit 并执行之后,再次 submit。这就是 Metal 缺乏的 command buffer 重用功能。

Command buffer 重用的作用如何?下面对比普通数据 buffer 的用法来衡量一下。这里不谈体积较大且很少变化的 vertex/image buffer 等等,只谈体积较小而且每个 frame 都会变化的参数 buffer。不论 Metal 还是 Vulkan,数据 buffer 都可被重用。实际上,低开销图形系统都建议在开始渲染前预分配所有数据 buffer。但是这里有一个概念被偷换的问题,数据 buffer 的「重用」并不等同于 command buffer「重用」。前者只是表明省略了「分配」步骤,但是 buffer 的内容依然可以,而且在绝大多数情况下确实每个 frame 都被 CPU 更新。已经记录完毕的 command buffer 是一个 immutable buffer,重用 command buffer 是原封不动的重用整个 buffer 在上一个 frame 用过的内容。所以通常意义上的数据 buffer 重用是指「预分配重用」,而 Vulkan 所谓的 command buffer 重用是指「内容重用」。之后的讨论中会避免不严格的「数据 buffer 重用」和「command buffer 重用」的说法,而是在不同 buffer 类型的使用场景中讨论「预分配重用」和「内容重用」。

不论 Metal 还是 Vulkan,command buffer 的「预分配重用」都不是一个问题。因为任一时刻内同时被使用的 command buffer 数量不多,不存在数据 buffer 那种动态分配的复杂性,所以 command buffer 总是从一个 pool 中进行分配,并不存在分配开销很大的问题。虽然在 Metal 中 command buffer 被设计成一种生命周期通常限于单个 frame 的 transient object,但是从「预分配重用」方面看和数据 buffer 并没有区别。

另一方面设想一下能否把「内容重用」应用到数据 buffer 上。这其实不太容易。渲染中每个 frame 的资源需求基本相同,「预分配重用」只需要用简单逻辑估计所有 frame 需要的资源之和进行分配即可。但是渲染中数据内容的变化是复杂的,判断数据是否在 frame 之间发生了变化需要复杂得多的逻辑。判断数据变化的逻辑本身就会很复杂,而且由于涉及 triple buffer 或者 swap chain 机制,衡量数据变化的时间段并非两个直接相邻的 frames,而是要基于整个 in-flight frame 的周期,且不同 in-flight frame 周期之间是相互重叠的。所以在实际工程中,通常在每个 frame 对数据 buffer 进行「暴力更新」而不考虑内容重用。这是软件复杂度的合理取舍。数据 buffer 如此,command buffer 的重用自然也面对同样难以绕开的困难。

上面的讨论围绕在不同 frame 间的资源重用。那么 command buffer 重用是否在这个场景之外有更好的用途?实际上,command buffer 和固定的 render attachments 绑定 [3] 决定了其在不同的 render pass 或者 frame 之间进行重用比数据 buffer 更加受限。同一 render pass 内又有 instance draw 这种更好的重用的方式。

Command buffer 重用看来是一个 API 设计欠考虑,实际适用场景罕见的功能。给人的感觉是因为 GPU 有了命令队列 replay 的能力,所以不加考虑就在 API 里放出来,「only because we can」。虽然现代 GPU 给了图形系统的开发者存储和重用更多类型数据的机会,但是如何设计对应的上层抽象是值得慎重考虑的问题。

脚注:

  1. Metal command buffer 并不直接记录 command,而是通过创建 encoder 对象来完成这个工作。Vulkan 虽然是用 command buffer 来记录,但在开始记录前会将其设置为一个或者多个 render pass,其开始和结束对应了 Metal encoder 的 life cycle。
  2. Metal 称为「commit」;Vulkan 叫做「submit」。
  3. 其绑定通过上面提到的 Metal encoder 和 Vulkan render pass 体现。

在谁的模式里思考

2018/05/08

CORBA 和 Java 还都很重要的时候,设计 CORBA 的 OMG 鼓吹所有编程语言都应该围绕 CORBA 的接口定义语言 (IDL) 进行面向对象设计;而 Sun Microsystems 则宣扬所有系统都要映射为 Java 的对象模式,数据库要用 Enterprise JavaBean,CORBA 也该被封装为 RMI over IIOP。这是本质一样而方向相反的两支传教队伍抢夺同一群开发者的口角。值得注意的是,这场博弈的每一方依然需要对方去做自己无法胜任的工作(尽管不是唯一的选择),基于 CORBA 的开发不能离开具体编程语言,Java 也需要远程调用实现,但都坚持宣称对方应该照自己的思维模式行事。

CORBA, EJB, RMI 都已被开发者忘记,但类似的博弈永远存在。 主流的硬件图形接口中,Direct3D 继承了 Windows 95 时代一切封装为 COM 的文化遗产(尽管 Microsoft 自己现在也对这个文化有点心虚),macOS/iOS 秉承采用动态语言的传统实现 Metal(尽管 Apple 在 Objective-C 和 Swift 之间如何分配资源还略难取舍),Vulkan 为了跨平台采用 plain-C 接口。

现在的游戏或者专业图形软件很少直接用硬件图形接口开发,而是通过封装抽象层支持多个具体的硬件图形接口。如此一来避免了选择支持何种接口的非此即彼的问题。所以「思维模式」的博弈远离了关于选择操作系统特有接口还是 Vulkan 的问题。影响开发者思维模式主要体现在抽象层设计上。硬件图形接口本身的影响力决定了大多数抽象层接口要如何设计,是否会向某个具体接口的设计靠拢。每个抽象层的设计和传播都会影响具体图形接口在开发者中的口碑和初学者的选择。

暂时不谈如何影响抽象层设计,Vulkan 在 macOS/iOS 上面临的首要问题是 Apple 并不支持它,压根儿不能用也就谈不上思维方式的传播了。但有热心的第三方做出 MoltenVK,用 Metal 实现 Vulkan APIs,绕过了 Apple 不允许硬件厂商直接发布驱动程序的限制。但作为 Metal 的 thin-wrapper,它没法实现完整的 Vulkan。其开发者解释为了性能和实现简单只实现 Vulkan 中可以比较直接地对应到 Metal 的部分,即便如此移植个 Dota 级别的游戏并不在话下。这样终于把 Vulkan 推广到了 macOS/iOS 上。

但操纵思维模式并不是件直截了当的事情,其作用和反作用往往难以预料。MoltenVK 恰恰证明 Metal 已经直接体现了 Vulkan 中大部分概念,至于剩下的那些不能被直接体现的部分有多重要也许见仁见智。但 MoltenVK 本身就在向开发者发出不建议使用这部分的信号。你想想,只要 Vulkan-based 代码能忍住不去用几个少数 MoltenVK 不支持的 Vulkan 独门概念,就能无痛移植到 macOS/iOS 上,岂不美哉!如此反而给 Metal 的思维方式加了一道吸引开发者的筹码。推广跨平台思维方式的努力,经常还是被操作系统本身的优势消散于无形。

当然还有一种可能:除了 Unity 和 Unreal 引擎的维护者,压根没有别人再会去用 Metal/Vulkan 这样的硬件图形接口。就像 CORBA, Java, RMI,它们试图影响开发者的思维方式,最终被另一个层次的替代者掩埋在记忆中。

显卡的今天和往事(续)

2018/03/07

上篇《显卡的今天和往事》提到买了 Radeon RX Vega 56。因为显卡的缘故对 AMD 的图形技术发展梳理了一下。以前一直以为 Apple 抛弃 OpenGL 去设计 Metal 是对 Driect3D 长久以来领跑的追赶,了解之后发现并非如此。在 DirectX 9 到 11 期间,AMD 的 PC 显卡驱动程序性能一直大幅落后于 nVidia,尽管其硬件理论性能并不差。与此同时 AMD 占据了 game console 图形处理的绝大部分份额,与 Microsoft 一起积累了不少在 console 上提高游戏性能的经验。这些经验变成了 Mantle。从某种意义上说,Mantle 是 AMD 硬件部门推动的对软件部门的一次压制 —— 把驱动程序的重要性大幅降低。也是 Microsoft 乐于看到的,因为从前驱动程序的任务被挪到 app code,强化了游戏/应用开发者对图形性能的控制权。

Mantle 推出之后,Microsoft 和 Apple 开始几乎同时推出自己的类似技术,D3D12 和 Metal。当然有说法 D3D12 是和 Mantle 同时开发而且期间有一定程度的交流和技术共享。

再回到这次给 MacBook pro 配置 eGPU 过程本身,上图是完成后的桌面。新设备购置顺序为:LG 显示器,Akitio 盒子,最后拿到 RX Vega 56 显卡。

说来好笑,本以为外接显示器是利用 eGPU 的前提,因为很多论坛甚至 Apple 官网一度宣称 eGPU 只对外接显示器加速。考虑到即使 eGPU 到货之前也能发挥作用,所以早早买来。搭好整个系统后调起代码才发现,不管 app window 在哪个显示器,渲染代码可以任意指定用任何 GPU 加速。甚至系统启动时 eGPU 不连显示器也没问题。究其原因,窗口系统中 app 自己的 rendering 的实际处理都是 offscreen,由 OS window server 把结果搬到与 app window 所在显示器直连的 GPU 上进行最终 composition。所谓 eGPU 只能加速外接显示器的说法可能只是指 D3D fullscreen 这种绕过窗口 composition 的加速方式。Windows Vista 后普通窗口系统方式并不比 fullscreen 方式增加多少开销。macOS 则根本没有绕过 composition 的方式。在窗口系统的「调度」下,GPU 更加接近通用的「pipeline/shader 运行引擎」,传统的显示器 adaptor 的角色则大大淡化了。

TRIPLE is More Than DOUBLE Plus One

2017/12/29

TRIPLE 与 DOUBLE 的问题

远在硬件加速的图形系统 ( graphics APIs ) 出现前,double-buffer 已经是流行的动画防闪烁技术,这个名称一直沿用到 OpenGL 之类硬件加速系统上的相似技术。而 Metal 之类低开销图形系统 ( low-overhead graphics APIs ) 的标准运行模式是 triple-buffer。同时看到这两个名称会引出几个问题:

  • 为什么低开销系统采用「triple」,而不是「double」或者「quad」?
  • 在代码里「triple」是如何体现的?
  • 和「double-buffer」相比,「triple」是仅仅多了一个 frame buffer 吗?

深入讨论前先统一几个术语,因为各个图形系统常用不同名称指代相近的概念。CPU-GPU 工作在 client-server 模式。图形系统的驱动 ( driver ) 负责发送 CPU 的 request 给 GPU。发送前 CPU 准备 request 数据的过程叫「encoding」。GPU 执行 request 的过程叫「rendering」。每个 request 携带少量的参数,如各种变换矩阵,叫 parameter buffer。存储 rendering 结果的区域叫 frame buffer。[1] [2] [3] 一个 frame encoding 开始到 rendering 结束这段时间称为「in-flight」状态。图 1 显示了 OpenGL 系统各个过程执行的时间顺序。

图 1  Single In-Flight Frame

为了简化同步机制,OpenGL 系统里一个 frame 的 encoding 要等前一个 frame 的 rendering 结束后才开始。任意时刻最多存在一个 in-flight。这样 CPU 和 GPU 都无法避免空闲等待的状态。如上图所示调用 glFlush() 或者 glutSwapBuffers() 导致 CPU 的 idle 时段。从 encoding 到 rendering 的延时会导致 GPU 的 idle 时段。以这张图为基础,我们讨论下面这个问题:

  • 如果利用 multi-buffer 机制,至少需要几个 buffer 才能缩短空闲时间?Double-buffer 可以吗?特别的,OpenGL 风格的 double-buffer 可以吗?更多的 buffer 有帮助吗,还是反而起负面作用? ( n-buffer 中的 n 取什么值合适?)

BUFFER 与空闲时间

设想一个「naïve solution」:如果有许多个 frame buffer,GPU rendering 时把不同的 frame 写到不同的 frame buffer 里,似乎可以按照图 2 的方式同时执行多个 in-flight 过程。

图 2  Naïve Multiple In-flight Frames

那么来看看这个方案「naïve」在何处。如果在 GPU-bound 应用 [4] 中放任 CPU 无限制地 encoding 下去,CPU 和 GPU 间的处理延时会越来越大。这种延时导致需要暂存的 rendering 结果没有上限。所以一个可行的方案必须有办法让 CPU 适度地停下来等 GPU,这样延时可以控制在常数范围,有限的 buffer 也可以被循环利用。低开销图形系统希望最终达到图 3 的状态,这时系统的处理延时为 d frames,最多有 d+1 个 in-flight。GPU 达到满负荷,同时与 CPU 的延时始终保持在合理范围。

图 3  低开销图形系统的稳定状态

要做到这点,低开销图形系统需要跟踪有多少 frames 处于 in-flight 状态。Metal 利用 command buffer 实现这个机制,其它低开销系统也有类似概念。OpenGL 系统没有类似概念,只能通过调用 glFlush() 或 glutSwapBuffers() 让 CPU 等待 GPU 运行完所有的 requests (图 1)。即使有 multi-buffer,仅支持单个 in-flight 的图形系统只能消除闪烁,并不能减少等待时间。

第二个问题是,parameter buffer 在 encoding 过程中被 CPU 写入,在 rendering 过程中被 GPU 读出。当多个 frame 处于 in-flight 状态时,必须为它们分别分配 parameter buffer,不能重用。否则后来的 encoding 会破坏前面 rendering 过程读出数据。OpenGL 采用的 uniform 方式限制了由同一 shader 处理的所有 frames 共享同一个 parameter buffer。回到之前的问题:「OpenGL 风格的 double-buffer 可以吗?」—— 一般意义上的 double-buffer「有可能」降低空闲等待时间,但是 OpenGL 风格的并不能,因为缺乏跟踪 in-flight 机制和相应的 parameter buffer 分配机制。

SHOW ME THE CODE

现在具体看一下「跟踪 in-flight 状态」的机制在基于 Metal 的代码里的具体实现。下面的两段 code 从 Nuo Model Viewer 的 in-flight 处理简化而来。


// setup n-buffer, n is 3 in most cases

const unsigned int kInFlightBufferCount = n;

...

// in app initialization, _displaySemaphore was
// initialized with re-entry maximal number
// "kInFlightCount"

_displaySemaphore =
    dispatch_semaphore_create(kInFlightBufferCount);

代码段 1  初始化

代码段 1 是应用在初始化时设置变量。常量 kInFlightBufferCount 作为  _displaySemaphore 的初始化参数决定了 in-flight 的最大数,也就是 n-buffer 运行模式的 n。


dispatch_semaphore_wait(_displaySemaphore,
                        DISPATCH_TIME_FOREVER);
id<MTLCommandBuffer> commandBuffer = ...

_inFlightIndex = (_inFlightIndex + 1) % kInFlightCount;

// encoding on the command buffer on the
// "_inFlightIndex"th buffer
...

[commandBuffer commit];
[commandBuffer addCompletedHandler:^
    (id<MTLCommandBuffer> commandBuffer)
    {
        ...
        dispatch_semaphore_signal(_displaySemaphore);
    }];

代码段 2  In-flight 处理

代码段 2 是每个 frame 处理 in-flight 的逻辑。其中 semaphore_wait 和 semaphore_signal 定义的 critical region 正好符合图 3 所示的 in-flight 过程。和传统教科书基于 PV 操作的 critical region 相比,这个 region 有两个特殊性。第一,它不是严格的互斥访问,而是由 _displaySemaphore 指定重入的最大次数。第二,它的起始点和结束点不在同一段 sequential code 中,而是分别在 main thread 与 command buffer complete-handler 中。所以它不是控制不同 thread 的并发访问,而是用 GPU 通知来控制 main thread 的等待,以达到图 3 的效果。绝大多数情况下,Metal 系统的 _displaySemaphore 初始化参数为 3,即 triple-buffer。

其中第 5 行计算当前选择的 buffer 序号。因为运行在 n-buffer 模式,所以用 % 在 kInFlightBufferCount 个 buffer 里依次循环重用。第 11 行调用 commit,表示一个 frame 的所有 encoding 完全结束后才会发出 request 让 GPU 开始 rendering。图 2 和图 3 里那种 GPU 在一个 frame 的 CPU encoding 进行中就开始 rendering 的情况并不会在 Metal 系统中出现 [5]。如图 4 显示了允许三个 in-flight 时 Metal 系统的时序。可以看到由于 commit,延时比图 2 要长。

图 4  Metal 的初始时序

从这段代码也可以看出,Metal 的 n-buffer 中 n 可以任意取值。这是低开销图形系统的一般特点 —— 并不在 APIs 定义中硬性规定 n 的具体值。下面讨论 什么取值能最大地释放系统性能。

TRIPLE 和最优性能

上面的讨论中可以看到,_displaySemaphore 定义的重入次数决定了整个系统运行在 n-buffer 模式。现在讨论 n 的取值对性能的影响。当 n 设置为 5 时 ( quintuple-buffer ) 系统运行如图 5 所示。

图 5  Quintuple-Buffer 的时序

系统在第 10 个 frame 达到稳定状态,延时为四个 frame。除了延时增加之外,还必须准备五套 parameter buffer 和 frame buffer。因此 in-flight 过多有弊无利。如果采取相反的措施,降低 in-flight 个数是否可以减少延时?这时要注意到,在图 4 和图 5 中第一个 frame 的 encoding 和 rendering 之间的延时是 CPU-GPU 作为 client-server 系统的固有延时。从 Apple 文档中摘抄的图 6 大致描述了固有延时的构成。这里「Complete Frame …」可以粗略的看作上文的 rendering 过程。

 

图 6  CPU-GPU 固有延时

虽然 CPU-GPU 的固有延时并不能通过本篇讨论精确得出,但从图 6 和图 5 来考虑,不妨大致假设为每个 frame encoding 时长的 3/4。如果把 in-flight 最大数设置为 2,系统的时序如图 7 所示。

图 7  Metal 风格的 double-buffer 时序

由此可以看出,相对于 OpenGL 系统,Metal 风格的 double-buffer 可以减少一部分空闲等待时间,但是 CPU-GPU 的固有延时决定了 double-buffer 并不能完全消除所有的空闲等待。只有 kInFlightBufferCount 为 3 的 triple-buffer 模式才能达到图 3 和图 4 中 GPU 没有空闲等待的情况。

结论

最后总结一下篇头提出的所有问题。

为什么低开销系统采用「triple」,而不是「double」或者「quad」?

本篇并不能精确证明 triple 在任何情况下都是最优解。但可以分析出,过高的 in-flight 最大数会增加 rendering 延时,过低会导致 GPU 空闲等待。Triple 是针对一般情况的最佳设定。

在代码里「triple」是如何体现的?

通过规定可以重入三次的 semaphore。

和「double-buffer」相比,「triple」是仅仅多了一个 frame buffer 吗?

OpenGL 风格的 double-buffer 只涉及 frame buffer。 低开销图形系统的 triple-buffer 则涉及 frame buffer,parameter buffer 以及基于 semaphore 的 CPU-GPU 同步方式。更确切的说,triple-buffer 应该被称作 triple-in-flight

如果利用 multi-buffer 机制,至少需要几个 buffer 才能缩短空闲时间? Double-buffer 可以吗?特别的,OpenGL 风格的 double-buffer 可以吗?更多的 buffer 有帮助吗,还是反而起负面作用? ( n-buffer 中的 n 取什么值合适?)

采用基于 semaphore 的同步方式,多于一个 in-flight 的系统就可以缩短空闲时间。对于 GPU-bound 应用,通常三个 in-flight 能完全消除等待时间。OpenGL 没有跟踪 in-flight 的能力,其 double-buffer 只能消除闪烁。过多和过少的 in-flight 数目都对性能起负面作用。

脚注:

  1. 术语「encoding」来自于 Metal 系统。OpenGL 里并没有对应的名字。
  2. 术语「parameter buffer」在 OpenGL 里对应 uniform,在 Metal 里是作为 shader function parameter 的 buffer。
  3. 术语「frame buffer」来自于 OpenGL。在 Metal 里对应于用作 rendering target 的 texture。
  4. GPU rendering 的时间大于 CPU encoding 时间。本文只讨论这种情况,对于 CPU-bound 应用请作为读后思考。
  5. OpenGL 系统的 encoding 可以看作每个 request 立即被 commit,而不是一个 frame 的所有 requests 被一次 commit。

GPU 时代的 C-style 字符串 —— 再度绊倒

2017/10/14

写上一篇《GPU 时代的 C-style 字符串》时尽管反复求证,有一点还是搞错了。

Metal 的 fixed-function 部分缺省行为即执行 premultiplication。也就是说,在关闭 blending 时下面的 shader 代码,

会写入:float4(vert.rgb * vert.a, vert.a)

上面描述是错误的。事实是 Metal 的 fixed-function 缺省行为不执行 premultiplication,上面的 shader 代码例子原样写入 float4(vert.r, vert.g, vert.b, vert.a)

说来好笑,上篇 blog 写到一半时的草稿对 fixed-function 的解释本来是正确的。当时我还跑到隔壁的同事办公室大谈了一番。然后晚上 blog 结稿之前做实验晕了头,发布了错误结论,第二天早上又到同事办公室宏论一番。今天再次确认之后,只好第三次去同事那边订正。

所谓「结稿之前做实验」是这样的:Nuo Viewer Model 里有个新加的 screen-space render pass 在关闭 blending 的设置下产生一个 texture,之后其它 render pass 会用到它。我发现如果把它作为 un-premultiplied 形式处理(也就是说再「手工」乘一次 alpha 变成 premultiplied 形式)就会出现「黑边」。而且在 Metal debugger 的 texture viewer 里也看到 RGB 值似乎和 alpha 成正比。于是当晚在 blog 里宣布「Metal 的 fixed-function 部分缺省行为即执行 premultiplication」。

事实上我忽略了一件事:screen-space render pass 打开了 MSAA,在 fragment 边缘进行针对 fragment coverage 的 blending。这时的输出是:fragment 内部区域是 un-premultiplied 形式,边缘是针对 vert.a 和 coverage 的混合计算结果(前者 un-premultiplied,后者 premultiplied)。简而言之,错误的垃圾结果。经过这次教训,「GPU 强制采用 premultiplied 形式情况」的列表补全为:

  • On-screen 显示;
  • 会被 resample;
  • 开启 multi-sample anti-alias (MSAA);
  • (其它未知因素)……

总的来说,尽管 Metal 的 fixed-function 缺省行为不执行 premultiplication,程序员还是要老老实实的保证每一个 fragment shader 都输出 premultiplied 形式 —— 要么在 shader 代码里直接做,要么用 fixed-function blending 去做。否则碰到上面列表的任何一项就会产生垃圾结果。该乘法必须在写入 texture 之前做 —— 是为 pre-multiplication,而不能在 sample texture 的时候做,原因嘛,再看看上面关于 MSAA 的实验就清楚了。

虽然这是一篇更正,但是丝毫无损于上一篇的结论 ——「GPU 时代的 C-style 字符串」。