关于游戏服务器的服务拆分

先阐明一下观点,可以使用单体(单线程)应用程序解决的问题,都不应该使用分布式系统来解决,因为分布式真的很复杂。

在游戏服务器中,我们做服务拆分,大部分情况下都是为了可伸缩,而不是为了高可用(这里暂不考虑那些使用WEB模式实现游戏服务器的思路。我认为这种思路,逻辑的复杂度和实时性都不能保证,而且还需要处理并发问题。)

以前我就说过,游戏服务器的开发更像是在开发数据服务

现在,我觉得可以更明确一点。

游戏服务器的开发,其实就是针对某种业务逻辑开发的专用数据库。 而玩家的客户端就真的是我们开发的数据库的客户端,来进行“增删改查"。

之所以我认为游戏服务器开发过程中,使用分布式不是为了高可用。是因为,在整个游戏服务器中,每个服务都是单点不可替代的。如果某个服务挂了,在它还没有被启动起来之前,所有与之相关系的业务都会出现异常。除非每个服务都会有对应的候补进程,然后将数据实时冗余在候补进程中。

如果使用“最终一致性”,冗余就会有同步延时。而在游戏服务器这种实时“数据库”领域,有延时就代表崩溃时会有数据丢失,这也谈不上高可用了。

如果使用“强一致性”, 虽然同步没有延时,但是会出现网络请求链路过长,性能和请求的实时性不能保证。

因此,可伸缩往往也是大多数游戏服务器最终的目的。虽然我们一般不要求高可用,但是我们在部分服务Crash的情况下,也要保证不能产生错误的结果(可以产生异常,而终止某条逻辑)。

虽然说“如无必要,勿增实体”。然而,我们在开发之初往往很难评估到我们的单体应用是否可以满足“策划”们突如其来的需求。

因此,除非极其明确的游戏类型,否则往往在设计之始,都不得不预留一些分布式设计。以免增加某个需求之后,需要大规模重构代码。


我目前的认知,一个通用分布式游戏服务器框架,最多可以帮助业务程序员解决服务发现服务依赖RPC机制集群健康监控等一些服务级别的管理。

而最重要的一环服务拆分,则留给了我们人类来做。

在服务拆分过程中, 我们往往需要关注服务间的数据依赖关系服务的内聚性服务间的交互频率每个客户端请求所经过的链路长度等指标。

同时,遵循“如无必要,勿增实体”原则,服务的拆分应该尽可能的少,这不但会减少链路长度,同时还会降低整个分布式系统出现故障的概率。

这是因为,每个服务都是单点。如果某个服务异常可能导致其余所有服务都产生异常。因此整个分布式系统出现故障的概率,是所有服务出现故障的概率之而不是积。


事实上, 当单体应用程序变成分布式之后,整个逻辑的复杂度都会有相当程度上的提升,尤其在数据一致性上。

在关系型数据库,如Mysql中,有一项功能叫“外键约束”,用于保证数据完整性。然而随着各种Mysql分布式方案的出现,这项功能被越来越少的使用。其原因就是因为在分布式系统中,“外键约束”很难实现,需要应用逻辑自己来保证。

在我们游戏服务设计中,也存在同样的问题。

假如有一个“联盟服务”,有一个“城池服务”。联盟可以借助占有的城池成为国家,“城池”服务则管理着城池相关的归属问题,比如复杂的领土争夺。如果城池丢失,则国家需要变回联盟。

这时,一般会有两种实现方案:

1) “城池服务”在城池丢失时,直接推送给“联盟服务”进行处理, 并不在意“联盟服务”是否收到消息。
2) “城池服务”在城池丢失时,通过RPC请求等待“联盟服务”处理完“国家变联盟”逻辑之后, 再修改城池归属。

即使在不考虑网络问题的情况下,这两种方案也会存在数据不一致的情况。

比如方案1,在“城池服务”发送给“联盟服务”消息之后,“联盟服务”Crash掉。

比如方案2,在"城池服务”收到“联盟服务”的成功返回后,“城池服务”还没有写入数据库,就Crash掉。

借用数据库的理论,如果需强的一致性就需要2PC,3PC来解决,然而就算2PC,3PC也不能完全解决个别极端情况。

因此,在服务启动时,必须要检查数据约束是否满足,并修正不满足约束的数据。

由于我们需要在启动时进行“数据溯源”(即A需要依赖B来检查约束,B需要依赖C来检查逻辑约束)来修正约束,就势必会产生“服务间依赖”,这时最好不要有循环依赖。

如果我们在拆分服务时,服务的内聚性不够好(比如将联盟和国家数据拆分成“联盟服务”和“国家服务”。由于国家是依托联盟而成存在,如果联盟不存在了,则国家必然不存在了),则会产生更复杂的依赖链,同时会加大数据不一致的概率。


解决了“服务的内聚性”,我们可能会迎来一个新的矛盾“服务间交互频率”。 而且极大概率,这两者是相互冲突的。这需要我们做出取舍,软件设计就是这样,按下葫芦起了瓢,我们永远需要做trade-off。

举个例子, 比如我们“城池服务”中的逻辑和国家数据耦合很紧密,如果我们把联盟和国家数据都放在“联盟服务”中。有可能会导致每处理一条客户端请求,“城池服务”和“联盟服务”之间要通信十数次。这会大大增大调用链的长度。

调用链的变长,会导致浪费很多CPU在网络处理和协议序列化上。同时也会降低系统的稳定性,增加客户端请求的RTT。

如果这个开销在整个系统中难以承受。我们就需要考虑,违反“服务内聚”原则将国家数据,挪到“城池服务”中,即使这会使“城池服务”和“联盟服务”变成循环引用。

至于什么程度是“难以承受”, 又到底要怎么变通,这取决于个人的经验以及对整个业务系统的认知程度。


上面一直在说分布式的复杂性, 还没有提到如何做到“高可伸缩”。并不是拆成分布式系统之后,就一定能做到高可伸缩。

先来描述一个简化的业务场景。

整个世界是由数百万个正方形格子无缝拼接而成。玩家出生后,会随机分配一个空闲格子作为出生点。

玩家在整个世界的主要任务就是打格子,然后形成势力,并最终占领整个服务器。

每个玩家拥有有限个英雄和10支队伍,每支队伍可以上阵三个英雄。

玩家以队伍为单位去占领格子,队伍的出发点,永远是玩家的出生点。

队伍从出发点经过有限时间后到达目标点,经历战斗并最终占领格子。

队伍出发之后到达目标之前称为行军中。行军中的队伍,如果会路过当前客户端显示的世界区域,则会将路线显示出来。

行军中的队伍在到达目标点之前,不会再参与任何逻辑计算。

只要目标点周围两圏范围内有自己的格子,就可以直接行军过去占领,与行军所经过的路线无关。

最初我认为,这样的业务场景,应该会有Role,League,World,Scene这4种服务。

Role用于处理玩家英雄相关数据和请求,可以水平部署多份,有伸缩性
League用于处理联盟相关数据和请求,全服只有一份,无伸缩性
World用于管理队伍和格子数据,及队伍相关请求,全服只有一份, 无伸缩性
Scene用于镜像World的格子数据,供客户端只读拉取,可以水平部署多份,无伸缩性

为League服务增加可伸缩性并不难。因此随着数据规模的增加,整个分布式系统最终的瓶颈将是World服务。整个分布式系统的伸缩性都将被World的伸缩性所限制。

而我之所以这么分,是因为我对整个业务场景没有清晰的认知,而且有点性能强迫症所致。

当时的思路是这样的:

队伍和格子数据关系很密切,因此需要将队伍和格子数据放在一个服务中处理。这样,客户端来一个请求“占领某格子”,队伍的“出发”,“到达”,“战斗”,“占领”,“返回” 全都在一个服务中搞定了,可以极大的减少服务间的交互。

当我把队伍相关数据放在World服务之后,限制就出现了。

如果我把World服务按地图区域切分,水平部署多份,那么相关的队伍信息,到底应该以怎样的方式分布在相关的World服务中呢,无解。

如果World无法水平部署,那么怎么分摊客户端不停拖屏,所带来的查询压力呢。

只有增加Scene只读服务,用于实时镜像World服务中的格子数据和队伍的行军路线。供客户端拖屏查询使用。

现在重新看待这个问题,我认为应该分为Role,League,World这3种服务。

Role用于处理玩家英雄和队伍的相关数据和请求,可以水平部署多份,有伸缩性
League用于处理联盟相关数据和请求,全服只有一份,无伸缩性
World用于管理格子数据,及战斗规则实现,按区域切分,可以水平部署多份, 有伸缩性

当我们把队伍相关逻辑放入Role服务之后,很多逻辑都会变得更清晰,代价是会多出几次低频率的RPC请求。大概流程如下:

客户端发送“占领某格子”的请求时,Role服务向World服务发出RPC请求,校验目标地的合法性。

World服务返回合法,Role服务为当前操作的队伍设置到达定时器。并再次通过RPC请求路线相关的World服务,设置行军路线供客户端查询使用。

队伍到达目标点之后,Role服务向World服务发出RPC请求,进行战斗并占领行为。World服务向Role服务返回战斗成功。

Role服务再次为队伍设置返回定时器。

队伍到达出生点之后,Role服务通过RPC请求路线相关的World服务,取消行军路线。

从上面流程可以看到,虽然增加了5次RPC请求,但是瞬时RPC请求数量并不高。

虽然在设置和取消行军路线时,需要消耗CPU来计算行军路线会经过哪些World服务,但是这些消耗是常量,随着服务的水平伸缩,很快就被抵消了。

同时还会有两处额外的开销,也需要能通过水平伸缩来抵消。

1) 在客户端拉取当前屏幕地块信息时,有可能需要收集1个以上的World服务中的地块信息
2)在客户端拉取行军路线的队伍信息时,也需要向1个以上Role服务拉取相关的队伍信息

但是不管怎样,整个分布式系统都是以常量的开销, 获得了高可伸缩的能力。

我使用这两个方案进行对比,是想说明分布式系统中服务的拆分,非常依赖于个人对整个业务模式理解,这一点真的很难。


再说一些细节问题。

在设计分布式系统之初, 我为了减少“服务间的交互”, 常常使用缓存技术。

经过一段时间的思考,我认为这是不正确的,因为冗余数据往往是滋生Bug的温床。少量的RPC交互并不会产生性能热点。

如果已经确定了某些交互频率确实过高影响性能。应该首先尝试将交互过多的数据放在同一个服务中,如果确定这样做会产生bad taste,再考虑缓存技术。

在实时游戏服务器中,服务间会经常产生消息推送。在我们不使用缓存技术的情况下,某些业务逻辑会产生竞争问题。

还是以联盟立国为例,客户端调用“联盟服务”选定国都C1进行立国,“联盟服务”通过RPC调用“城池服务”检查是否为自己城池。

“城池服务”收到这条消息,返回消息M1,告知当前城池还是属于此联盟。之后城池突然别被的联盟打掉,然后“城池服务”给“联盟服务”推送了一条消息M2,告知当前城池所有者已经变为了另一个联盟。

由于“联盟服务”调用“城池服务”使用的链接和“城池服务”向“联盟服务”推送的链接不是同一条,所以M1和M2会有一个竞争问题。

如果M2先于M1到达,则“联盟服务”必须要抛弃M1的结果,因为M1是不准确的。

如果M2后于M1到达,则“联盟服务”可以正常处理M1,因为稍后到来的M2再次校正结果。

同样,虽然缓存技术容易滋生Bug, 但是他可以解决上述竞争问题。


9月1日补充。

之所以有这篇文章有出现。其实是因为我想梳理一下思路,从框架层面解决M1和M2的消息竞争问题。

经过几天的思考,我认为框架没有能力也不应该解决这类问题。这类问题可以广义的归纳为异步问题。

比如,我打掉了一块格子, 我需要花钱让他升级。当我们调用rpc:submoney(uid, 100)返回时,有可能这块地已经被别人打掉了,我需要在rpc:submoney回来之后,重新检查这块格子是不是我的。

总的来说,服务间通信,异步是常态,这需要业务程序员自己去做约束。

ps. 分布式真的很复杂D:

ECS的初步实现

从我开始研究ECS算起, 到现在已经将近20天了。

第一版ECS库终于实现完成了。先不论性能如何,基本功能都实现了。

在我的理解中,ECS中最复杂的地方是EC部分的管理和查询。而S部分的复杂度主要是依赖关系的问题,这会取决于具体的项目。

因此,在这个ECS库中主要解决EC的问题,关于S的部分并没有提供。这也是我称为库而不是框架的原因。


在整个实现过程中,由于我还没能完全克服性能强迫症,导致我的心路历程非常坎坷(每次实现到一半,总会因为这样或那样的原因,让我推倒重来)。

最开始,我认为守望先锋的ECS之所以那么复杂,是因为他们使用了C++这种强类型语言。为了解决动态组合(动态添加和删除C)的问题,不得不在API上做出一些让步。

如果拿Lua来实现,语言本身就支持动态组合,那添加/删除Component的行为,可以退化为添加/删除“标签”功能。

每个System只需要过滤出含有特定“标签”组的Entity, 然后加以处理就行了。

很快我放弃了这一想法,主要原因是我认为作为一个合格的框架或库,它应该提供一些限制。可以让我们写出符合ECS原则,更易读的代码。

在上面的设计中,客户程序员很容易就违反了ECS原则,他完全可以只过滤某一个ComponentA, 然后去修改这个Entity中的ComponentB, 甚至删掉ComponentB但是并不会删除ComponentB的标签。这会导致一些很奇怪的Bug。而且从代码的易读性上来讲也没有好处。

在后续的设计中,我又陆续纠结了,Eid的分配问题, Component的存储问题,同一个Entity中的Component的关联问题。

在经过陆陆续续几次推倒重来之后,直到今天才实现完第一个版本。

在这不断的推倒重来中,我总是在是否“需要暴露Eid给客户程序”之间摇摆不定。最终,我认为是需要的。

我们总是需要在程序的某处去New出一个个的Entity。同样我们也总会需要在程序的某处,去修改某个特定Entity的某个Component数据。

在我看来,整个ECS的运行机制很像一个巨大的“粉碎机”。 我们总是在某一个入口投入足量的Entity, 然后ECS库或框架将这些Entity粉碎成各种Component,供System查询并操作。

因此在这一版的ECS库的实现中,我把Component作为主角来实现的。Entity的作用在这里,将一组Component进行关联,以方便Component查询和生命周期的管理。


先简单介绍一下API:

--创建一个名为Admin的world对象。使用相同名字多次调用ECS.fetch_world, 返回的是同一个world对象
local world = ECS.fetch_world("Admin")

--注册Component类型。 其中world.register的第二个参数是为了方便建立Component缓存池和Debug阶段检查一些Component的合法性(暂时还没有实现)。
world:register("vector2", {x = 0, y = 0})
world:register("vector3", {x = 0, y = 0, z = 0})

--创建一个Entity, 这个Entity只含有一个"vector2"的Component
local eid = world:new { vector2 = {x = 2, y = 2}}

--向eid所代表的Entity上添加一个"vector3"的Component
world:add(eid, "vector3", {x = 3, y = 3, z = 3})

--向eid所代表的Entity上删除一个"vector3"的Component
world:remove(eid, "vector3")

--查询world中的所有类型为"vector2"的Component
for v2 in world:match("all", "vector2") do
    w:touch(v2) --将Component v2置为脏标记
end

--查询world中所有被w:touch过的类型为"vector2"的Component
for v2 in world:match("dirty", "vector2") do
end

--查询world中所有已经死亡的类型为"vector2"的Component
for v2 in world:match("dead", "vector2") do

end

--删除Entity
world:del(eid)

--执行清理操作,每调一次为一个逻辑帧
world:update()

整个设计大概是这样的:

每个Component类型都有一个数字id称为tid。每个Component实例都有一个数字id称为cid。我们总是可以根据tid和cid来找到某一个具体的Component实例。

在相同的Component类型中,新创建的Component的cid总是比旧的Component的cid要大。在world:update时所有Component的cid会进行重排,但是依然满足这一约束。这会提供一个便利,在我们使用for遍历world:match时,依然可以不受限制的添加任何Compoent实例。

当某个Component实例被删除时,仅将其挂在“dead”链表上,并不做其他操作。如果已经在“dead”链表上,则不做任何处理。这会产生一个限制,刚对某个Entity删除了一个Component之后,不可以立马添加一个同类型的Component

当某个Component实例被touch时,仅将其挂在“dirty”链表上。

当某个Entity被删除时,将此Entity下的所有Component标记为"dead", 然后将Entity挂在"dead"链表,不做任何处理。

在执行world:update时会产生以下行为:

1. 释放所有的Entity及其eid(以备后面复用)
2. 释放所有标记为“dead"的Component, 并整理存活的Component的cid
3. 清除"dead"链表
4. 清除"dirty"链表

总的来讲,所有的添加都是立即生效,所有的释放都会延迟到world:update中执行。

ps. 在这次纠结的过程中,在一定程度上治愈了我的性能强迫症。

ECS初探

开始之前先说两句题外话。


GAMES202的作业4是白炉测试,但是我到目前为止还没有做.

其主要原因是,关于GGX BRDF我有点迷惑了.

本来按照LearnOpengl和其他参考书里面讲的, 一般光照计算会分为两部分. 一部分为Diffuse, 一部分为Specular.

Diffuse又可以看作是次表面散射的一种简化。这样我们可以用菲涅尔反射, 来计算一束光线有多少反射出去(BRDF项),有多少进入物体内部进行次表面散射(Diffuse项), 然后把两部分加起来就行了。

但是闫神讲课时说:由于已经采用了微表面模型,就不能在与宏观表面模型Diffuse的假设一同采用,同样在物理上也是错误的,能量不能保证守恒,可能会出现发光的BRDF的情况。由于不同角度、不同粗糙度损失的能量是完全不同的,因此直接加一个Diffuse是完全错误的。计算机视觉识别材质采用了这种方法。如果你用了这种做法,别说闫神教过你。

这话一说,一下子就给我整不会了, 以致于我到现在还没弄明白到底怎么是对的,迟迟没办法做白炉测试。

我可能需要GAMES202的同学来讨论一下:D。


GAMES202告一段落之后,就顺便学习了一下Unity的SRP,教程使用的是catlikecoding

老实讲,我在看这个教程的过程中只有一个体会,心累(当然这并不是教程的问题)。

我最开始对Unity的SRP期望是这样的:在C#中有一些库函数,并且在Shader端也有相匹配的库函数。当我需要成熟的功能时,我调一下C#的函数,然后在Shader中再调用相应的Shader库函数。就可以直接使用他的某个功能了。

然而并不是这样,尤其是catlikecoding上来就搞阴影。Unity中的C#是有一些API可以给我们用,Shader也会有一些内置变量,直接被设置好了。但是怎么用这些变量,是需要我们有足够的Unity知识之后才能应用的。它并不像是一个封装良好的库函数。

这让我在学习过程中很疑惑,到底有多少个Shader内置变量,他们分别是被哪些API进行修改的。我并没有发现一个很好的文档,可以让我根据某个C# API来查询,他会修改哪些Shader变量,这些Shader变量都是什么含义。

这就像盲人摸象一样。以至于我很怀疑,如果我们要做一个项目。到底是应该根据SRP写自己的RenderPipeline, 还是应该魔改URP的RenderPiepline。如果Shader的内置变量五花八门,修改他们的API也很多。那势必就会踩很多坑。如果这样,还不如魔改URP来的安全。

不过在看完整个教程后,我发现SRP除了提供一些基础的渲染功能外,主要额外提供的辅助就实时阴影和烘焙相关部分。这些信息量并不算大,所以上面提到的坑问题也就不存在了。


下面开始进入正题。

关于ECS,我大概花了一周时间来学习理论知识。学习时间尚短,大概率我现在的感受都是错误的,不过我认为还是值得记录下来,以备后面反思时使用。

ECS早已有之,但是它真正在国内火起来,应该要从《守望先锋》架构设计和网络同步算起。

在看完《守望先锋》架构设计和网络同步之后, 我接着看了一下Wiki

Wiki给了一个渲染方面的例子: “一个“系统”,它遍历所有具有物理和可见组件的实体,并绘制它们。可见组件通常可以包含一些关于实体外观的信息(例如人类、怪物、四处飞舞的火花、飞箭),并使用物理组件知道在哪里绘制它。另一个系统可能是碰撞检测。它会遍历所有具有物理组件的实体,因为它不关心实体是如何绘制的。”

乍一听,觉得ECS就是完美啊,就跟当年他们教我OO时,给我举例子做UI一样,各种继承,各种多态,简直完美啊。

但是,历史的经验告诉我OO在非UI领域一点也不好用,以致于他们要出各种设计模式来解决OO带来的坑。

不管怎么样,即然大家都在吹ECS,它肯定是有过人之处的。

抱着试试看的态度,我模拟把我们游戏的客户端逻辑使用ECS进行落地。

第一关就给我难住了,Component到底该如何拆分,拆分粒度是多大。上一次这么手足无措,还是在大约12年前, 我在实模DOS下,往0xB800(显存)地址处写入ASCII码,但是屏幕什么都没有显示。同样的没有经验,同样的资料匮乏。

直到我看到A Data-Driven Game Object System中的一个句话“Each component is a self-contained piece of game logic”,我猛然间醒悟了,我们需要根据业务需要,设计System逻辑,然后根据System来拆分Component(也许叫设计Component更好, 之所以叫拆分是因为我在模拟怎么用ECS实现我们客户端的所有功能, 拆分这个词,在一定程度上其实误导了我)。

我回忆了一下,在日常逻辑的开发中,尤其是已经上线的项目。在新增一个系统时,我往往会单独设计他的数据结构,并存储在数据库的不同位置。而所有系统最终是通过UID这个entity_id来关联起来的。

举个例子:假如我们有一个Bag系统和一个Mail系统,我们的代码组织往往会类似下面情况:

//Bag.cpp
namespace bag {
static std::unordered_map<uint32_t, db::bag> bags;

void bag_add(uint32_t uid, int money, int count)
{
    auto &bag = bags[uid];
    add money into bag and save db
}

}

//Mail.cpp
namespace mail {
    std::unordered_map<uint32_t, db::mailbox> mailboxes;

    void mail_fetch(uint32_t uid, uint32_t mailid)
    {
        auto &mb = mailboxes[uid];
        auto &m = get_mail_by_mailid(mb, mailid);
        bag_add(uid, m.attach.money, m.attach.count);
    }
}

对比可以发现,这其实和ECS的模型很像,只是ECS模式约束更严格,System之间不允许相互调用。

上面这个系统本来就是松散耦合,再举个更复杂的例子,我前几年写的回合制战斗系统。

在整个战斗系统中,buff,hurt,heal,skill这些计算逻辑,往往会操作着hero不同部位的数据。这些计算逻辑读取的数据区域可能会相互重叠,比如hurt,heal都需求读取hero的属性值,而hurt往往还会读取部分buff的属性以便做伤害分摊。

如果按照OO的思路,hero类往往会持有buff,hurt,heal,skill等类的实例,但是由于这几个系统往往需要相互读取对方的部分数据,以至于buff,hero,heal,skill中往往还会持有一个hero的指针, 这样到处都是循环引用。不但不能解耦合,还会让问题变的更糟糕。

对于这种强耦合的逻辑,我采用了Lua虚拟机的实现方式,我把所有用到的数据全部定义成结构体,然后把buff,hero,heal,skill全部实现为纯逻辑,这些纯逻辑可以直接访问它们需要的任何数据结构。

这样只要我能定精准定义好每个结构的字段的含义,各种逻辑都根据数据的含义来执行相应的计算就好了,模块之间大幅解耦,我想这也是贴近ECS模型的一种实现。同样它也不是ECS,因为逻辑模块之间有相互调用。

但是我想使用ECS来实现业务逻辑时,和以上两种实现模式的思路或多或少都会有相似之处,尤其是第二种,感觉更相似。

但我有两个疑虑:

1.因为战斗系统是我一个人开发的,我当然可以从全局精心设计出合适的数据结构。但是如果在多人协作情况下,除非像例子1那样,本来就是松散耦合,否则我对能否设计出合适的Component数据结构是存疑的。

2.因为System之间不进行直接交互,所有交互都是通过Component进行的,这会造成全局变量陷阱。回忆一下,我们刚开始写代码时,都被谆谆教导不要使用全局变量,这是有原因的。

不管怎么样,我打算先实现一个Lua版的简易ECS框架,真实体验一把再说。毕竟没有使用就没用发言权。

屏幕空间(SreenSpace)的想象力

在写这篇文章时,我特意去Wiki上搜了一下ScreenSpace, 可能是由于太过直白的缘故,并没有找到标准的定义。

不过他的定义是显而易见的,屏幕空间的所有的信息都是与屏幕上的像素有关的,而不是和场景中的几何有关的信息都叫屏幕空间,这一点其实很像是Pixel和Fragment的区别。pixel是定义在屏幕空间上的,而Fragment是定义在三维空间上的。

举个最简单的例子,我们从相机原点射出一条射线,然后穿过两个不透明物体。这两个交点,在进行光栅化时就是2个Fragment, 但是最终渲染到屏幕上最终只会有一个Fragment被采用,而屏幕空间就是最终被采用的Fragment的集合。


显然深度纹理属于屏幕空间。

以前,我一直觉得深度缓存只能用来做ZTest。然而大神们分分钟教我做人。

在光线追踪算法下,我们可以这样生成深度图,将深度图放在相机的近平面。

然后相机原点对深度图上所有像素都发出射线和场景中物体相交,并把首次相交的物体的Fragment,在相机空间下的Z坐标写入深度图。

下面这张图片更详细的展示了光线追踪的细节。

在光栅化算法下,生成的深度图结果和光线追踪一模一样,但显然光线追踪的算法更清晰并易于理解。

从光线追踪算法来看,给我们一个指定分辨率的深度图和相机近平面距离,我们就可以完全还原出来生成深度图用到的所有射线。

我们还知道,深度图上每个像素上的深度值,都是从相机原点到这个像素发出的射线与场景物体相交的点产生的。

反过来说就是,影响像素A的深度值Z_A所在的Fragment_A(3D坐标下的点),一定在从相机原点出发到像素A的射线Ray_A上。

我们来看看,有了这些信息,我们都能求出哪些额外的信息。

RayA*Z_A = Fragment_A在相机空间下的位置ViewPosition_A

mul(inverse(V), ViewPosition_A) = Fragment_A在世界坐标下的位置WorldPosition_A

我们甚至可以通过mul(inverse(M), WorldPosition_A)将Fragment_A变换到任意模型ModelPosition_A。

来看看我们能利用这些还原的信息做什么吧。


深度贴花:

在很久以前, 我曾经执迷于贴花,最终学会了使用Mesh来贴花,但是利用深度信息,我们可以更便捷的做到。

我们假设要贴的花是一张平面贴纸,我们有这个纸片的MVP矩阵。

根据上面的推导,我们可以将深度图上的任意一个像素的Fragment转换到贴纸的模型空间中来。

然后我们根据这个Fragment在贴纸的模型空间中的坐标X,计算出需要采样的uv。

假设整个贴花的大小是(x,z) = (-0.5,-0.5)~(0.5,0.5), 那么uv=X.xy + (0.5,0.5)。当然需要clip掉uv.xy < (0,0)和uv.xy >(1,1)的坐标。

最后根据计算出的uv直接采样纹理,整个贴花就完成了。


全局雾效:

有了深度图,我们可以重新计算出每一个像素的Fragment在相机空间下的位置ViewPosition。

记受雾影响的最大和最小距离为 d_{\tiny min}d_{\tiny max}

假设雾的浓度和高度有关的,一种简单的雾效计算公式为。

f = \frac{d_{\tiny max} - ViewPosition.y}{d_{\tiny max}-d_{\tiny min}}

float3 afterFog = f fogColor + (1 – f) oriColor;


SSAO:

在SSAO的实现里, 我们甚至都不需要去重新计算坐标信息。

仅通过采样一个像素周围的平均深度,就可以来近似计算一个遮蔽关系。


虽然深度图可以计算出来很多信息,但是还是有很多信息是计算不出来,比如法线信息。

在我们写光栅化时,都会有这样一个经历,在有多光源的情况下,我们的代码和下面代码很相似。

for l in lights do
    for m in meshes do
        render(l,m)
    end
end

没错,这就是传说中的ForwardBase Lighting。在上述代码中,每多增加一个光源,我们就需要把所有Mesh重新渲染一遍,如果光源非常多的话,这种开销几乎是不可承受的。

聪明的大神们发现了一个现象,还是光线追踪的思路。当我从相机原点到成像纹理的像素发出射线时,只有第一个与射线相交的场景中的Fragment才会被采用,后面的Fragment在后来做ZTest时都会被丢弃, 即然这样,我只对屏幕空间中的Fragment计算光照就可以了。

这种思路在有复杂遮挡关系的场景中,优化效果是惊人的。那具体应该怎么做呢?

一般是通过两个Pass来做的。

在第一趟Pass中,Shader会计算所有Fragment的采样颜色和法线。并将颜色和法线分别写入两张Texture中。由于在Fragment Shader在输出结果时,会做ZTest, 因此只有离相机最近的Fragment才有资格写入纹理。这一步的目的就是为了减少计算光照的Fragment。

在第二趟Pass中,Shader会根据纹理中的每一个像素所包含颜色和法线信息来对所有光源进行计算。

整个计算流程大概如下:

--First Pass
for f in fragments do
    if depth_test(f) do
        write_diffuse(f, texture2D(f.uv, tex))
        write_normal(f, f.normal)
    end
end

--Second Pass
for f in fragments_in_screen_space do
    c = float3(0)
    for l in lights do
        c += render(l, f)
    end
    write(f, c)
end

这就是大鼎鼎的Deferred Lighting。


你以为这就是大神极限了么,大神会立马再教你做人。

即然光照可以通过两趟Pass的方式来优化,那我可不可以用来加速光线追踪呢?

答案是肯定的, 由于屏幕空间上的信息量相比整个场景来讲少之又少,在计算光线相交时,可以更快的判定。当然同样由于信息量过少, 所以会有这样或那样的瑕疵,不过相比效果来讲,瑕疵反而不是微不足道的。

没错,这就是传说中的SSR(ScreenSpaceReflection)。其实就是在屏幕空间下进行RayTracing, 是不是叫ScreenSpaceRayTracing更好一些:D。

一些对辐射度量学的理解

随着硬件性能的提升,PBS/PBR已经越来越成为一种趋势。

Unity内置的standard shader都已经默认使用PBR算法。

而学习PBR过程中,有一门必提的知识是“辐射度量学”。

本来我以为我都学会了,毕竟path tracer都写出来了,并且效果看起来很正常。

但是最近在学习全局光照时,突然对“辐射度量学”突然产生了两个疑惑。

  1. 光线在传播过程中,Radiance是如何体现出,能量随着距离增加而衰减的?

    回忆在写path tracing时,代码并没有刻意去计算距离对光线能量的衰减。

    而从数学公式上看,在计算光线弹射时,反射方程的输入和输出都是Radiance。

    某一单位表面弹射出的某一个方向上的光线A的Radiance值,会在计算被光线A击中物体表面时直接使用。

    那么衰减到底在哪里体现的呢?

  2. 为什么一个物体表面弹射出的光线的Radiance,可以直接被光线击中的物体直接拿来计算?

    先看一下定义:

    入射Radiance, 单位面积单位立体角接收到的flux(功率)。 L(p,\omega)=\frac{dE\left(p\right)}{d\omega\cos\theta}

    出射Radiance, 单位面和单位立体角发出的flux(功率)。 L(p,\omega)=\frac{dI\left(p,\omega\right)}{dA\cos\theta}

    从定义上看,入射Radiance使用的是接收面接收方向的立体角,而出射Radiance是出射面出射方向上的立体角。

    那么,凭什么一个物体表面的出射光线Radiance可以直接被照射物体用来计算,而且还可以随距离智能衰减?


是的,所有答案的问题指向立体角,是立体角将他们神奇的联系到一起的。

先复习一下,立体角的定义:d\Omega=\frac{dA}{r^2}


然后回答第一个问题(这里仅考虑 单位表面与光线垂直的情况,所以去掉了cos项):

假设在平面DE上有一个单位表面A发光表面B发出了一条光线照向A。

根据Irradiance的定义,dE(p)\;=L(p,\omega)\;\ast\;d\omega

代入立体角公式就可以得出,单位表面A接收由表面B照射过来的功率为(HG表面积)/(HG离表面A的距离) * Radiance。如果将表面B由HG移动到JI位置,由于JI离表面A更远,单位表面A接收到的单位表面B照射出的功率更小。

那么path tracer是如何利用这一性质的呢,想象一下,从单位表面A发出720根射线,离发光表面B离表面A越近,被表面A的射线击中的次数就会越多,表面A就接收到表面B的能量就越多。


在回答问题1时,假定了第2个问题是毫无疑问的。但是入射Radiance和出射Radiance为什么一定是一样的呢?

我们先来化简一下入射Radiance出射Radiance的公式。

入射Radiance, L(p_2,\omega_2)=\frac{d^2\phi(p_2,\omega_2)}{d\omega_2dA_2\cos\theta_2}

出射Radiance, L(p_1,\omega_1)=\frac{d^2\phi(p_1,\omega_1)}{d\omega_1dA_1\cos\theta_1}

可以看到入射Radiance和出射Radiance公式完全一样,只不过作为接收平面,他使用是接收平面的面积,立体角,法线与光线的夹角。而作为出射平面,使用的是另一组参数而已。

到止前为止接收Radiance出射Radiance仅仅是公式相同而已,没有任何迹象表明他们会是同一个值。

借用《Fundamentals of Optics and Radiometry for Color Reproduction》中的一张图,来解释这奇迹的变换。

假设P1点所在的微平面ds1发出一条光线\xrightarrow[{P_2P_1}]{}照向点P2所在的平面ds2。

由于ds1在立体角d\omega_1方向上发出的能量被ds2全部吸收,所以\phi(p_2,\omega_2) = \phi(p_1,\omega_1)

根据立体角的定义:

d\omega_2dA_2\cos\theta_2

= \frac{ds_1\cos\theta_1}{r^2}dA_2\cos\theta_2

= ds_1\cos\theta_1\frac{dA_2\cos\theta_2}{r^2}

= ds_1\cos\theta_1d\omega_1

可以得出接收Radiance出射Radiance是完全相同的量。

嗯,这就是大名鼎鼎的radiance invariance

ps. 在研究这两个问题过程中,我还有另外一个疑惑,不过这个疑惑随着这两个问题的解决一起解决了。这个问题就是“对于一个微小平面dA, 他在整个单球上发射出的radiance是均匀(完全相等)的么?答案显然否定的,因为dA在整个半球上发射出的I是相同的,根据radiance公式,radiance必然不是均匀的。”

pps. 但是根据Lambert’s Law的漫反射模型,I_\theta=I_n*\cos\theta,也就是说物体跟光线的夹角会影响接收到的I_\theta,代入Radiance公式后,radiance在漫反射模型下是均匀的。

深度缓冲和半透明渲染

在场景渲染方式上,一般分为“不透明渲染”和“半透明渲染”。


“不透明渲染”意思是,如果A物体被B物体挡住,那么A物体将完全不可见。

至少有两种方式达到这种效果(假设相机的朝向为Z轴正方向):

  1. 由于屏幕上每一个相素都是由场景中某一世界坐标(Fragment)投影得到,在绘制时以Fragment在相机空间内按Z轴从大到小进行绘制, 写入颜色值时直接覆盖FrameBuffer中的颜色值。

  2. 物体以任意顺序渲染,但是在渲染某一个Fragment时,会将其在相机空间的Z坐标写入一个叫做深度缓冲区的地方,在渲染Fragment之前会先使用相机空间下的Z坐标与深度缓冲区中当前Fragment所对应的深度值进行比较,如果Z更小,则替换之前Fragment所对应的相素颜色值,如果Z更大,则什么也不做。

虽然方式2的描述过长,但是方式2反而会更快。

因为方式1需要排序,那么排序之前,需要为所有物体生成Fragment数据,这会需要海量的内存。另外由于需要按顺序渲染,物体与物体之间的穿插关系可能会频繁打断DrawCall,这会显著降低我们图形程序的效率(Nvidia发出的资料表明,DrawCall过高会喂不饱GPU,导致CPU性能急剧下降)。

因此在实际项目中, 都是采用方式2来绘制“不透明物体“, 这就是大名鼎鼎的ZTest.


而”半透明渲染“, 则是如果A物体被B物体挡住,那么透过B可以看到A物体。

而要做到这一步,只能采用画家算法,绘制时以fragment在相机空间内按Z轴从大到小进行绘制, 写入颜色值时,与当前FrameBuffer中的颜色值进行alpha混合。(与不透明渲染中的方式1的惟一差别,就在于最后写入颜色值的行为)。

但是前面说过了,如果要对所有物体的所有Fragment进行排序需要海量的内存,所以一般可行的实现方式都是将物体在相机空间下的Z坐标进行排序,然后由大到小进行绘制(ps.可能是为了性能考虑,Unity的透视相机在半透明渲染中,直接采用了世界坐标系下物体离相机的距离进行排序,然后由大到小渲染。显然他与Z坐标排序并不等价,以致于我们发现在拖动相机过程中,不同sprite之间的穿插关系像‘多米诺骨牌’般进行变化)。

这种折衷会导致:如果物体没有相互交插,都可以得到正确的结果。而一旦物体有交插,就会产生错误的结果。为了解决这个问题,还需要同时配合ZTest进行使用,这时就需要非常小心,如果处理不当就会产生一些非常微妙的效果。即使ZTest通过了,由于alpha值的影响,物体的渲染顺序依然会影响最终的渲染结果。


是的,之的所以会有这篇文章,就是因为我们在最近开发2.5D地形时,遇到了一些困境。

我们在地图上刷了大量的山和树(都是面片),这些山和树会有各种复杂的穿插关系。

最开始我们将所有的山和树以半透明的方式进行渲染,然后发现DrawCall被频繁打断,最坏的情况需要增加50+DrawCall。

此时摆在我们面前的只有两条路:

  1. 动态图集法,将当前屏幕内地形所用到的资源,动态合并成几张1024*1024的图集。但是如果算法不好,动态图集反而可能会加剧DrawCall的增加。

  2. 使用ZTest, 将所有山和树改为不透明渲染,然后开启ZWrite和ZTest来解决遮挡关系。由于所有的半透明纹理的alpha的值只有两种(0和1)。只要我将alpha=0的Fragment给clip掉,整个渲染流程跟”不透明“渲染流程完全一样,这样我们有几种材质就只需要几个DrawCall。

我第一反应,就选择了方案2。因为我觉得他很简单。但是这个世界是平衡的,怎么可能有如此完美的事情。

在我改写shader之后,很快就发现了问题。

  1. 山有巨大的阴影区,这块阴影的alpha值不为0,而且阴影不应该可以直接遮挡后面的物体。

  2. 由于需要抗锯齿,所以我们使用的山和树纹理的alpha并不像我想的那样非0即1,而是会有一个极窄的alpha过度区。

问题1相对较容易解决,由于不透明物体也可以指定渲染顺序,我们将山的渲染延后。由于山的阴影alpha通道的值不为1也不为0,所以会与之前写入FrameBuffer中的颜色值进行alpha混合形成阴影效果。

问题2就颇为复杂,而且很多问题几乎无解。考虑这样一个场景(在相机空间下,由远到近):树x2,山x1,树x2。

我们相机空间下最远的树称为”树群A", 将最近的树称为“树群B"。

由于树群A和树群B之间隔了山,所以树群A和树群B之间不用考虑遮挡关系。

但是树群A/B中有可能有多种树,以至于有多个DrawCall, 树群A/B中树与树之间的会有”半透明(alpha值为(0,1)之间的值)“ 穿插问题,ZTest是解决不了的。

那么一种可能的渲染顺序是,树群A,树群B,山。

由于先渲染树群A,只要树群A的结果是正确的,那么在渲染山时,山的半透明部分与树群A在FrameBuffer中留下的颜色值进行混合的结果一定是正确的。

但是树群B先于山渲染,所以树群B的”半透明(alpha值为(0,1)之间的值)“部分是与地表颜色进行混合,而山又比树群B中的所有树都更远,所以山的ZTest会失败。这时看上去就像是树把山给透视了(直接看到地表)。

最终我们还是采用了ZTest。 为了解决山的”半透明(alpha值为(0,1)之间的值)“问题,我们在刷树时刻意避过了山的边缘部分。

而树与树的遮挡部分,我们尽量让同一种树聚簇种植,这样在同一个DC内,画家算法还是解决”半透明“混合问题。

Mysql的间隙锁

学习Mysql, 总会有一座绕不过去的大山, 那就是锁。理论上,锁的花样再多,也超不出操作系统课上讲的那些范畴,但是Mysql锁让我翻车了。

在Mysql中锁的粒度可分为:表级锁,行级锁,间隙锁 三种。表级锁和行级锁都没什么太难理解的地方。只有间隙锁我无法准确理解其设计意图,而且我试验下来的现象让我觉得很诡异。

那么为什么会有间隙锁这种东西呢,按大部分能查到的资料表示,间隙锁的引入是为了解决在RR隔离级别的幻读问题。

下面来看一个实例,首先创建一个Table:

Create Table: CREATE TABLE `foo` (
  `uid` int(11) NOT NULL,
  `age` int(11) NOT NULL,
  PRIMARY KEY (`uid`),
  KEY `age` (`age`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
insert into foo values(1,1),(4,4),(7,7),(9,9);

然后,开两个mysql客户端(M1,M2),其执行顺序如下:

M1: begin;
M1: select * from foo were uid > 1 and uid < 5 for update;
M2: begin;insert into foo values(2,2);commit;
M1: select * from foo were uid > 1 and uid < 5 for update;
M1: commit;

如果在M1第一次执行select语句时只加行锁,那么锁住的就只有uid=4这一行。
在M1第二次执行select语句时,由于M2插入了一条(2,2), 因此会多查询出一条(2,2)的记录。
这就会产生幻读。

mysql的解决方案是:使用间隙锁,将uid在间隙区间(1,4),(4,7)的全部加锁,这样当M2在insert行数据(2,2)甚至(6,6)会被锁阻塞以防止M1出现幻读。

如果事情到这里完美结束,那我也不会翻车了,再看另外三条sql语句:

M1: begin;
M1: select * from foo were age = 4 for update;
M2: begin;insert into foo values(6,6);commit;
M1: select * from foo were age = 4 for update;
M1: commit;

手动执行一下就会发现M2会被锁阻塞住,这是因为他对age加了间隙锁(锁是加在索引上的)。

由于锁是加在索引上的, 按照我第一反应,直接对age=4这一条索引加锁就解决问题了,为什么要加间隙锁?

我查了很久,才找到一个很少有人提到但很重要的点二级索引中存储的主键,会参于二级索引排序,比如age索引进行排序时,实际用的是(age,uid)来进行排序。而之所以会使用uid参与排序我想大部分原因应该是B+树内不允许存储相同的值。使用age,uid进行拼接之后可以保证所有的二级索引,在B+树中的值一定是惟一的。

换句话说,我们无法单纯的锁住age=4这一条件,因为可能会存在(age,uid)= (4,1)/(4,2)/(4,5)等任意索引。

二级索引在拼接时,由于age在前uid在后,因此age的值在一定程度上就代表了整个索引值。这也是为什么间隙锁可以锁住age=4这一条件。

为了验证上述说法正确性,来看如下sql:

M1: begin;
M1: select * from foo were age = 4 for update;
M2: begin;update foo set age = 2 where uid = 1;commit;
M1: select * from foo were age = 4 for update;
M1: commit;

先简单分析一下 :

  1. age是非惟一二级索引
  2. 二级索引在内部实现是由age,uid拼接之后才参与排序的
  3. 间隙锁住了(age,uid) = (1,1) ~ (4,4)的开区间
  4. M2执行的语句是想插入一个二级索引值(2,1)

根据间隙锁原理,我们可以推段出M2会被间隙锁给阻塞住,而事实也正是这样。

ps. 二级索引中存储的主键会参于二级索引排序,这一点我认为非常重要。不知道为什么很多参考书都有意无意略过去了。

更新一些GPU相关知识

学完并实现路径追踪之后,即使增加了多线程渲染,在SPP=1024的情况下,依然需要30+分钟才能渲染一帧。

为了更快的渲染速度 ,我试图通过使用GPU的CUDA SDK来加速渲染。然而测下来竟然还没我的CPU跑的快,一方面我没有更好的显卡,另一方面我也不太确定是不是我CUDA使用错误所致。再加上就算使用GPU也不可能达到每帧秒级渲染。于是GPU的学习就搁置了。

然而在最近研究Splat地形渲染方案时, 我无意间发现了一个现象。测试地形为4层混合,在所有Texture都不开Mipmapping的情况下, FPS只有30左右,而开了Mipmapping之后,FPS可以稳定在60. 这激起了我强烈的好奇心,终于将了解GPU的运行架构提上日程(又产生了一次PageFault, 本来我在学习《mysql是怎么运行的》这本书,都已经快把B+ Tree看完了) 。

对照《GPU 精粹1》中的【28.2节 定位瓶颈】得出一个结论,如果Texture Filtering会影响FPS, 那么就说明瓶颈在Texture Bandwidth。

这引出我的第一个问题,Texture Bandwidth到底是什么,为什么Mipmapping会影响Texture Bandwidth?

我最开始以为是从CPU到GPU之间传输图片的带宽,越查资料越确定不是这样。

在找到影响FPS的因素之前,其实我大约花了一天试验了各种设置(这就是基本功不够扎实的现象,没有头绪各种试),甚至在FPS达到60时,我都搞不清到底是改了哪个设置变好的。

当然在这期间我也查了很多资料,其中最重要的两个点是说,对于Splat地形方案,他们都会提到减少Sampler的个数,并且提到使用 TextureArray可以改善性能。使用TextureArray可以改善性能的原因是因为它减少了bindtexture的次数,而为什么要减少SamplerState我当时并没有找到依据。

这就引出了第二和第三个问题,为什么要减少SamplerState的数量,是不是性能问题?bindtexture为什么会很“贵”?

直到昨天我发现了一篇NVIDIA讲解GPU架构的文章, 这篇文章虽然不长,但是指出了各种我们在写shader时需要知道的要点。


我先简要概述这篇文章,然后试图来解释这三个问题。

在GPU中有Warp Scheduler, thread, register file, TMU, TextureCache等概念。

Warp Scheduler是最基本的调度单元,也就是说整个Warp Scheduler中的thread一直在执行“齐步走”逻辑. 如果有一个Thread需要换出(switch out)比如等待内存加载), 整个Warp Scheduler的所有Thread都会换出(switch out)。

只要有一个Thread 的if () 判断为真,那所有的Thread都需要执行if为真的逻辑,即使有的Thread的if判断为假,也需要等待if为真的Thread都执行完才执行else, 而之前那部分if为真的Thread同样需要等待else的语句执行完再继续“齐步走”。

每个Warp Scheduler会有32个Thread。这么理解下来其实每个Warp Scheduler就相当于一个具有32通道的SIMD指令(英伟达把他叫做SIMT)。

每个Thread都有自己的寄存器, 这些寄存器都从register file进行分配,如果shader使用过多的寄存器,就会导致更少的Warp Scheduler和更少的Threads, 而更少的Warp Scheduler则意味着GPU的Core可能跑不满(类比操作系统,如果所有Thread都Sleep, 那CPU就在空转是一样的), GPU的性能就得不到发挥。

根据Wiki的解释,纹理采样主要是通过TMU模块进行执行的,TMU模块是一种有限的硬件资源, 因此你采样更多(不一样的)纹理,就需要消耗更多的周期。

纹理采样除了计算之外,还需要加载纹理数据,TMU会首先向Texture Cache中去加载,如果Cache Miss就会从L2加载到Textuer Cache, 如果L2也Cache Miss,就会从DRAM(显存)中加载纹理,然后依次填充L2和Texture Cache.

根据英伟达说明的GF100内存架构从Thread读到Texture Cache只需要几十个周期,而从L2向DRAM加载则需要几百个周期。在这些周期内,需要采样纹理的Warp Scheduler都需要被换出(swap out)。

至目前为止,其实已经能解释前两个问题了:

  1. Texture Bandwidth到底是什么,为什么Mipmapping会影响Texture Bandwidth?

    Texture Bandwidth其实就是指Texture 从DRAM到L2和L2到Texture Cache的加载带宽

    没有使用Mipmapping之前,我们地形的每一层图片尺寸都是10241024的图片,并且被渲染出的像素尺寸只有256256大小, 这样在渲染相邻的pixel时被采样的texel在内存中是不连续的, 因此在纹理采样过程中会频发触发Texture Cache Miss, 每次Cache Miss都需要额外的周期从L2或DRAM中重新加载。

    使用了Mipmapping之后,GPU可以根据当前的渲染情况来判断采用哪一个Mip Level。当选择合适的Mip Level之后, 相邻的pixel对应的texel也会尽可能的相邻,可以极大的缓解Texture Cache Miss的状况。

  2. 为什么要减少SamplerState的数量,是不是性能问题?

    根据微软文档显示,Direct3D 11中SamplerState最大上限为16,但是采样纹理数最大上限为128。可能是因为别人使用Splat方案时,地形层数远超16层。当然也有可能是性能问题,但是我没查到具体依据。

    根据英伟达的说明,具体执行采样是由TMU来执行的,而根据SIMT的特性,同一时间只有一个纹理会被采样,所以理论上SamplerState的多少并不会影响TMU的执行和并发度。

    我反汇编了shader(D3D版本), 看到一个现象,每当我定义一个SamplerState, 就会有一行dcl_sampler的语句,我查了一下MSDN, 发现这个语句是用来声明sampler寄存器。所以如果非要说减少SamplerState可以提高性能,那原因应该就是,使用更多的sampler寄存器,可以获得更多的Warp来增加GPU的并行度。

  3. 为什么bindtexture开销比较大?
    暂时未找到合理的解释


在查资料过程中,有两个额外的收获:

bindtexture并不是从CPU向GPU上传图片,在opengl中上传图片是使用glTexImage2D来实现的,这时图片只在显存中。

在fragment阶段,并不是每一个像素都被任意分配到一个Thread然后并行执行的。

一个Warp Scheduler被分成8*4个线程组,每2×2的像素块,被分配给一个数量为4的Thread组, 也是就说每2×2的像素块一定被分配给在同一个Warp Scheduler中的4个Thread。具体原因英伟达的文章上并没有细说。但是大概意思是,比如在决定mip level时,除非这4个像素uv跳跃太大,不然可以只用计算一次mip level就可以了。

2020

写年终总结前,照例翻了一下今年以来的blog, 意外的发现2019去年的年终总结同样拖到很晚。不过今年之所以拖这么晚,大部分原因是今年特别冷。坐到电脑前只顾着瑟瑟发抖了,哪还有心情写年终总结。

翻了一下去年的目标:

  1. 《概率论及其应用·卷1》
  2. 《具体数学》
  3. 《计算机程序设计艺术》
  4. 重新温习一下以往学习过的知识,看看能不能再次打破一下这几年形成的思维定式

总的来讲 有部分完成。
《概率论及其应用·卷1》看了一大半,该看正态分布了,不过现在应该差不多忘光了。
《具体数学》似乎没什么进展
《计算机程序设计艺术》看到卷2的线性同余随机算法推导,其直接产物就是我认为游戏宝箱概率垫刀的根本问题就是因为所有人共用的一个线性同余序列,虽然最终所有人的统计概率都相同,但是理论上短期内对每个人进行分析会有概率分布不均的问题
对于第四点,我刷了2个月的leetcode, 动态规划似乎有部分突破,但是依然没能显著打破思维定式


再说说计划外的收获。

性能篇:

将时间拉回到刚毕业时,那时写代码主要是应用软件,主要考虑的是可扩展性可维护性,很少去考虑代码的性能问题,一般不写过于脑残的代码,几乎也不会碰到过性能问题(当然代码量少也是一方面,我记得当年离职前统计整个软件的代码量也才2W+行不到)。

随着后来转行去做游戏服务器开发后,可能是动则过千人的并发量,让我对性能产生了恐惧,也有可能是《计算机程序设计艺术》计算指令周期的方式让我产生了错误的心理暗示。总之,我后面写代码慢慢变得总是性能至上。甚至在写C语言代码时都会心里粗略估算编译器的优化程度,cache的命中率等

事实上这种错误行为让我付出的惨痛的代价,我为了能够获取理论上的最高性能,经常会写出很Trick的代码,而这些代码经常会漏掉一些边界,却又不是所有边界。以致于很多bug逃逸到了线上, 当然我可以说是测试力度的不够。但是从内心来讲,我认为这和我写代码的trade-off有很大关系。

再后来新写的代码时,我都尽量强迫自己在做优化之前,先估算一下可能的数量级再决定优化不优化。

当然想做到这个并不容易,我总是不自觉的想使用复杂的方案以期望获得更好的性能,甚至潜意识就将简洁的方案给否掉了。这真的很难!有时我就在想,我转了一圈其实我要的不过就是更好的模块化设计,这跟我刚毕业时没有什么两样,那我这7年是否是绕了一个大圈,我现在还没有答案。‘看山是山’和‘看山是山’到底有什么区别?也许惟一的区别就是,我知道理论上可以有更高的性能,但是我就是不用。

渲染篇:

这绝对是一个值得一提的大事,我从2015年就在折腾学习渲染,但是一直就不得其门而入,在2018年时总于算是找了一个门缝,然而虽然我经过了训练,但是我发现我连拍着胸口说我了解渲染的资格都没有。

渲染这个事让我在2020年一个很偶然的机会找到了入门的大门(《GAMES101 现在计算机图形学计入门课程》),我现在已经可以挺起胸口自信的说我渲染入门了。对光栅化,光线追踪,PBR,微表面这些名词再也不是望而生畏了。

最后:

2021照例延续2020的作来继续下去,我对这些基础学科很有信心。

《概率论及其应用·卷1》
《具体数学》
《计算机程序设计艺术》
继续深入学习计算机图形学(听说《GAMES202 高质量实时渲染》马上要开了)

地形渲染之爬过的坑

目前我们采用TiledMap的菱形模式来编辑地形,然后再导入到Unity, 将TiledMap的每一个菱形以Unity中的Quad为单位来拼出来。

以目前我的知识水平来看,这么做至少有4个问题。

  1. Quad是以正方形为单位拼接的,而我们在TiledMap中每一个菱形是以Quad为单位渲染而成,客户端在使用Quad进行渲染时,为了表现的像个菱形,每两个Quad都会在顶角进行重叠,这需要我们美术出的图四个角Alpha通道都是0,这会增加无用的Overdraw。
  2. 为了降低纹理大小,整个地形都是由有限个基础Tile相互叠加来生成不同的地形。所以在TiledMap中,整个地形是由好几层组成,这就意味着每一个菱形都有可能需要几个Tile进行混合而成。这同样会增加Overdraw, 而且大概猜测一下,半透明渲染渲染由于不会写zbuffer, 所以在渲染之前可能还需要类似画家算法一样进行排序,这同样是开销。
  3. 虽然整个地形只加载9屏,但是由于每一个Quad都是一个GameObject, 这导致我们客户端在做性能测试时,刚起来就需要Instantiate数千个GameObject并常驻。
  4. 本质上每个Quad就是一块mesh,但是他有顶点UV总是从0到1,所以我们无法良好的使用法线贴图来增加地表细节(虽然我不懂渲染,但是作为一个玩家来讲,一块平板地表,我是不能接受的^_^!)。

最开始我并没有接触到,客户端采用的什么方式进行地形渲染,只是在开发中期,我们在一个叫UWA的网站上进行了一次真人真机性能分析。我们发现客户端会Instantiate 和Destroy大量的GameObject并且Overdraw居高不下。 仔细了解下来,才发现他们是使用上面所说的方式进行渲染的。

虽然后来将Quad进行池化, 但是依然会造成1000~2000的GameObject常驻内存。

此时虽然我已经写过一次光栅化程序, 大致了解了渲染流程。但是除了池化的建议外,我也想不到更好的办法了。对于居高不下的Overdraw我更是没有任何办法。

随着后来对Unity Shader的熟悉,我发现了一个可以降低Overdraw和GameObject一举两得的办法。

那就是对地图使用的这些Quad进行自定义Shader, 我们只要需要保证每一个菱形都是由一个Quad渲染而成,那么上面所说的问题2所带来的开销就不存在了。

而实现这一需求也很简单,可以让一个Shader有多个纹理输入,把每一层的纹理都输入进去,然后在shader内部去手动混合后,直接输出最终颜色值。至此我黔驴技穷,再也没有想法了。

又学了一次计算机图形学之后,基于上面的方案我又有了新的想法。

即然现在整个地形是由很多Quad组合而成,如果我们对整个抽象进行“降维打击”。从最终渲染单位来看,其实整个地形是由很多个三角形组成,那我们完全可以创建一个Mesh,这些Mesh的顶点数据和相应的Quad上的顶点数据(position,uv)完全一样。这样我们只需要一个GameObject就能渲染出一屏的地形来。

当然不仅仅是节省GameObject这么简单,有了这个Mesh我们可以做很多事。

比如我可以给每个顶点增加一组UV坐标,这个坐标用于采用整个地形的法线纹理。这样我只需要一张对应整个地形的法线纹理,就可以极大的加强地形细节效果。甚至我们还可以再增加对应整个地形的高度图来生成各种连续起伏的山脉。

同时,由于我们在一张Mesh中,不可能也不需要采用Quad相互重叠来达到菱形的效果。我们在创建Mesh时采用的顶点可以是恰好菱形的四个顶点。这样问题1,3,4都在一定程度上解决了。更棒的是我们还可以使用TiledMap, 整个工作流也没有任何变化。对于美术来讲惟一的变化是他们需要多提供一张法线纹理。


原本我以为我这个方案已算是极好。

但是最近我们在改版游戏时, 我了解到了一个地形编辑器叫WorldCreator, 一种叫做splatting的地形渲染方案,该方案在知乎上有详细的介绍及Demo.

这个方案相比上文的最终方案来讲更灵活。假如我们地形最多由四层纹理混合而成。

WorldCreator除了会使用四层纹理之后,还会额外生成三张对应整个地图的三张纹理,splatting,normalmap,heightmap。

其中splatting的四个通道会控制四层纹理在混合时的权重,在上文我的方案中,固定的四层纹理混合到一起效果是固定不变的。但是splatting渲染方案下,即使相同的四层纹理,在splatting图的控制下依然会形完全不同的效果,可以做到全地形唯一。

heightmap的作用与上文我的方案并无太大差别,这里就不做说明。

其中normalmap纹理是用作增加地形细节的,比如有一座高山,我们的Mesh三角形很大,如果仅凭zbuffer, 很多明暗细节就表现不出来,这时就需要靠这张normalmap来达到逼真的效果。

WorldCreator生成的四层纹理,每一层纹理有三张贴图组成,分别叫diffuse,disp,normal。

diffuse就是地貌纹理,normal是用于使diffuse的细节更加逼真。

最为惊艳的就是这个disp纹理,通过这个disp纹理,我们可以知道以纹素为单位的高度。在混合时,除了可以依据splatting纹理的权重外,纹理间的高度对比也是生成逼真的细节的重要部分,如沙子只出现在砖缝里,这也是我的方案难以企及的效果。