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框架,真实体验一把再说。毕竟没有使用就没用发言权。



发表评论