2022 GDC | 基于immutable data的编辑器框架

KK

2022-07-1412399次浏览

0评论

1收藏

0点赞

分享

GDC是在全球范围内享有最高影响力的游戏开发者会议,已举办35届,今年于3月21至25日在旧金山举行。

GDC的主题演讲包括设计、编程、视觉艺术、游戏叙事、用户体验、商业与营销等13个大类,所有的演讲均经过主办方与全球顾问委员会的精心挑选,其演讲内容以高质量和创新前瞻性而备受行业认可。

网易互娱今年有12位大咖的9个提案入围GDC非赞助类演讲,包括1项核心演讲和8项主题峰会演讲,让我们一起围观入围的提案和大神风采!

完整演讲目录戳 ↓
12位互娱大咖,要在全球游戏开发者大会上说点啥?

一、问题背景

现代游戏越来越复杂,在开发过程中提供定制化编辑器对提升开发效率是非常重要的。在星战前夜: 无烬星河(除非特别说明,在本文中提到的星战前夜都特指星战前夜: 无烬星河)的开发过程中,随着我们开发的编辑器越来越多,我们逐渐发现编辑器的维护变得越来越困难,主要有以下几个原因:

1、缺少统一的开发模型

在编辑器开发时没有一个统一的“模型”。在星战前夜早期的编辑器中,MVC,MVVM等模型都被不同的开发同学使用过。甚至在同样使用MVC模型的不同编辑器中,MVC的具体实现也有着大大小小的差异。这使得例如副本编辑器的开发同学想要帮忙维护任务编辑器时,需要花费额外的时间去熟悉任务编辑器的代码。

2、基于事件机制的UI更新容易出现BUG,且不易调试

在基于事件通知的UI框架下。对于m个数据和n个UI控件,潜在需要注册的事件多达O(m*n)。这意味着在一个复杂的编辑器中,存在着大量的事件,随着编辑器界面的更新,不断的在注册和注销。一旦遗漏了某个事件的注册,数据的更新就无法正确的反映到界面上。一旦遗漏了某个事件的注销,事件就可能通知到一个已经销毁的UI控件上,造成编辑器错误甚至崩溃。

 另一方面,事件注册和注销的逻辑通常散落在编辑器的各个源码文件中。一旦事件同步出现和预期不符的情况,debug的过程也可能非常的痛苦。

3、Undo/Redo耗费大量的开发时间

Undo/Redo对于一个编辑器,是非常核心的功能,没有undo/redo,制作同学就失去了快速试错和迭代的能力,从而影响编辑器产出的效果。传统的undo/redo实现通常采用命令模式。将每一个修改数据的操作都实现为一个命令。该命令提供undo/redo两个接口。redo负责应用这个操作对数据的修改,undo负责撤销修改。对于每一个新增的操作,都需要实现对应的undo/redo。在某些情况下,这些接口的实现还比较复杂。因此可能会降低开发同学为美术、策划提供新功能的积极性。

因此,星战前夜开始寻找替代MVC,MVVM的新编辑器开发模型。我们希望新的解决方案能够达到以下几个目标:

(1)适用于各种类型的编辑器,统一编辑器开发的开发模型,减少编辑器的维护成本

(2)提高编辑器的开发效率

(3)提高编辑器的稳定性

(4)提供undo/redo支持

二、其他方案

在最终决定采用基于immutable data的方案之前,我们考察了如下一些现有的解决方案。

声明式UI

复杂的数据-UI同步并不只是游戏编辑器开发领域面临的问题。在传统的前端开发领域,大家也一直在尝试各种办法解决这个问题。我们认为目前最好的解决方案是声明式UI。

声明式UI使用虚拟UI的概念来避免事件同步。在声明式UI框架中,当数据发生了变更之后,界面总是根据新的数据进行完整的重建。由于复杂界面的创建可能有着较高的开销,为了提高效率,声明式UI框架在重建界面的时候实际构建的是虚拟UI。虚拟UI只描述UI界面,并不实际创建在屏幕上渲染的UI对象。从而保证复杂的界面也能在极短的时间内重建。

声明式UI框架会保存当前的虚拟UI,然后根据虚拟UI创建出实际的UI界面。当数据发生变更,创建了新的虚拟UI后,声明式UI框架通过对比新旧两份虚拟UI,找出实际发生的变更内容,最后对真实的UI界面进行更新。

我们最终没有采用声明式UI的主要原因有以下几个:

(1)声明式UI的虚拟UI无法表达我们所需要的所有UI元素

在游戏编辑器中,除了狭义的UI控件以外,美术和策划还会直接在场景中编辑对象。我们认为这些场景中的3D对象也是一种特殊的UI控件。目前已有的声明式UI框架的虚拟UI都无法表达这些3D对象。由于游戏开发团队通常只有一个较小的编辑器开发团队,我们也没有足够的时间维护自己的虚拟UI。

(2)声明式UI仍然没有提供undo/redo的直接支持

Immediate mode GUI

和声明式UI相似的方案还有immediate mode gui。代表框架为游戏领域常用的ImGui。Immediate mode GUI和声明式UI一样,在状态改变时总是重建GUI。Immediate mode GUI更进一步,不需要虚拟UI,依赖高效的渲染引擎来快速生成界面。

Immediate mode GUI的优势为其不光简化了状态变更的同步。也进一步简化了用户input的处理。开发者不需要事件和回调,可以在当帧立即处理input事件,例如:

if (ImGui::Button("Save"))
    MySaveFunction();

我们没有采用Immediate mode GUI的原因和没有采用声明式UI的原因相同:

(1)Immediate mode GUI也只提供狭义的UI控件

(2)Immediate mode GUI也没有提供undo/redo的直接支持

直接基于数据diff

声明式UI通过diff两个虚拟界面来得到实际界面需要更新的内容。如果不使用虚拟界面,一个思路是直接diff原始数据来得到界面需要更新的内容。这个方案主要有以下几个问题:

(1)为了diff,在每次数据改变时,我们都需要先拷贝旧的数据供diff使用。当编辑器要编辑的数据量较大时,这个拷贝操作的开销往往是不可接受的。

(2)为了diff,需要实现大量的比较操作符重载,这会降低编辑器的开发效率。

(3)重载的比较操作符也不一定能保证高效,因为可能有大量的数据需要对比。

三、Immutable Data

实际上,直接基于数据diff这个方案中提到的问题,并不是对所有数据结构都存在的。当使用immutable data保存数据时,无论是拷贝还是比较都可以非常的高效。

首先简单的介绍immutable data的概念,顾名思义,immutable data指数据一旦创建出来就不会再改变。如果需要修改数据,需要生成一份新的拷贝,再在拷贝的基础上做修改。

上面的代码是一个immutable list的示例。当我们向一个immutable list中append一个新的元素时,原本的list并没有发生变化,而是返回了一个新的list,这个list中包含新append的元素。

上面是类似的immutable map的示例。这两种immutable容器是编辑器开发中主要使用到的immutable数据结构。

除此之外,我们还常使用immutable structure:

Immutable structure本质上是key固定的map。例如上面的Task本质上是一个key固定只允许包含 done, content 和 color的immutable map。

Immutable data的特性意味着我们可以将比较数据内容替换为比较内存位置来识别没有发生变化的数据。例如在python中这代表我们可以用 is not 替换 != ,is not 是非常高效的,而且不需要我们额外实现重载的逻辑。

需要注意的是,使用immutable data时, a is not b 并一定等价于 a != b ,但 a is b 一定等价于 a == b 且 a 的数据从来没有发生过变化。我们可以以此快速排除大量没有发生改变的数据项,对于剩下的数据,我们可以通过进一步的 a != b 来判断数据内容是否发生了改变。不过在实际的工程中,我们发现 a is not b 的false positive是非常少的,而且即使出现了,其引起的UI更新也是非常少量的,不会带来显著的额外开销,因此我们选择直接信任 a is not b 的结果。

由于immutable data每次修改是都需要拷贝一份新的数据,因此immutable data的拷贝必须实现得很高效。其具体是如何实现的我们会在本文的最后介绍。

四、框架

接下来我们介绍基于immutable data的编辑器框架。实际上整个框架非常的简单,这也是我们所希望的。在框架中,我们有一个data manager负责管理所有编辑中的数据。我们有一个View类负责根据编辑中的数据创建和更新相关的UI界面。和其他很多框架的不同点在于,我们的编辑器框架的UI不是基于事件更新的,而是以固定的帧率,从data manager中获取最新的数据然后进行更新。

当然在实际的工程中,编辑器的界面是可能非常复杂的。所以整个编辑器的界面会被拆分为多个View,每个View负责特定区域或者模块的UI。这是我们从React的component-based UI所借鉴的经验。通过这种方式,我们可以提高UI逻辑的复用性。

接下来我们会以一个简单的todo应用为例来介绍如何使用本框架实现一个编辑器。Todo应用常常被各种UI框架做为示例。因此你也可以通过此例子来了解本框架和其他UI框架的区别(尽管严格来说,我们的框架是编辑器框架而不是通用的UI框架)。

首先是数据的定义,对于我们的todo应用,其数据主要包含两个immutable record: TodoAppData 和 TodoItemData 。

在这个todo应用中,我们可以添加若干个todo事项,每个todo事项包含了内容(content),是否已完成(done)和一个颜色标记(color)。每个todo事项对应一个 TodoItemData ,所有的事项都存放在 TodoAppData 和 todo_list 这一immutable list中。

View则稍微复杂一点。对于整个应用我们拆分了三个子View:

• CounterView: 负责管理显示当前有多少已完成事项和总共有多少事项的label

• InputView: 负责管理输入新TODO事项的input box

• ListView: 负责管理整个TODO列表

在ListView中,每一项todo都有一个对应的子View:TodoItemView,负责一个todo事项对应的UI的创建和更新。

TodoAppView 本身实际上并不执行任何UI逻辑,而是将具体的工作交给他的子View。

TodoCounterView 在更新时会对比最新的todo_list数据和其当前持有的todo_list数据是否一致。由于todo_list是一个immutable list,我们可以通过if todo_list is not self._current_todo_list快速完成此判断。

当todo_list的数据发生了变更时,TodoCounterView遍历整个列表,找出当前一共有多少todo事项和哪些todo事项已完成,然后更新对应的UI label。

TodoListView则和TodoAppView类似,其本身并不执行实际的UI逻辑,而是将具体的工作交给其子View。

和TodoCounterView类似,TodoItemView也通过if todo_item is not self._current_todo_item来快速的判断当前todo事项是否发生了改变。如果发生了改变则使用最新的数据来更新UI的状态。

TodoListView 并不执行实际的UI逻辑其实并不完全准确。因为其主要的工作都在基类ListViewBase中完成。对于一个列表,当他的数据更新时,最简单的选择就是清空整个列表,然后根据新的列表数据重新创建,但这显然是不高效的。因此在实际的工作中,我们需要通过list_diff算法,找出新旧两个列表之间的区别,然后对发生变更的内容做针对性的更新。

值得注意的是,找出两个列表之间最短距离的最优算法的时间复杂度是O(n^2),在列表很大的情况下不够快。因此,参考React,我们使用了一个启发式算法。该算法在当列表中只有单个元素发生变更(插入,删除,移动)的情况下(这是编辑器中列表发生变更的典型场景)总是返回最优解。同时,该算法返回的操作序列中,删除操作总是在添加操作前,从而允许我们将删除操作中移除的UI控件都缓存起来,在添加操作中进行复用,从而减少UI元素的销毁和创建。

在实际的工程中,判断data是否改变,view是否更新的逻辑被封装到了统一的基类ViewBase中,从而使得实际编辑相关的View进一步简化。

五、编辑数据

接下来是数据的编辑。以用户编辑一个事项为已完成为例,当用户点击checkbox时,我们需要将对应的TodoItemData中的done属性设置为True。和我们之前的编辑器不一样的时,我们并不会调用一个类似set_todo_item_done的接口来完成这项操作。

取而代之的是,TodoItemView会首先修改自己对应的TodoItemData,将done属性标记为True,由于immutable data的特性,这会得到一个新的TodoItemData实例。紧接着,TodoItemView会将这个新的数据实例提交给他的父View,在这里是ListView。ListView则会使用新的TodoItemData实例替换自己的todo_list中的老数据,同样得到一份新的TodoAppData。最终这份TodoAppData被提交给data manager。

通过这种方式,View类对其他类没有显式的依赖,以此来尽可能提高一个View类的可复用性。

当数据被提交到data manager之后,在下一帧,所有的View都会根据最新的数据更新自己的UI状态。在这种情况下,CounterView会更新已完成任务的数量,而对应的TodoItemView则会将checkout设置为已check状态。

六、Undo/Redo

当我们为用户提供了编辑数据的功能之后,接下来最重要的就是为他们提供undo/redo的功能。由于每一次编辑时我们都会生成一份新的data,我们只需要将这些历史数据按照变更的顺序全部放到一个队列中,我们就得到了一个undo/redo栈。当我们需要回退一次操作时,我们只需要在栈中找出该操作修改前的数据即可。

整个undo/redo相关的逻辑如上图所示,非常的简单,而且对具体的业务逻辑透明。所有基于immutable data框架编写的编辑器都原生拥有undo/redo的支持。

然而,在有些特殊情况下,我们反而不希望一个操作被记录到undo/redo栈中。

上面的视频展示了一个编辑器中常见的场景,美术同学在选择一个颜色前,往往会快速尝试多个颜色,看看预览的效果。如果预览过程中尝试的所有颜色选择都记录到undo/redo栈中我们可以看到整个undo队列迅速被这些预览操作占满。如果美术在选择了一个颜色之后想要回退这个操作,需要很多次的undo操作,这使得undo/redo功能事实上已经不可用了。

这个问题在基于immutable data的框架中很好解决。当我们变更数据并将其提交给data manager时,我们有一个额外的参数record_in_history来标记这次变更的数据是否需要记录到undo/redo栈中,如果为False,当有新的数据被提交到data manager时,此数据会被直接丢弃,相关代码如下:

有了这个机制之后,当color picker的颜色变更,对应View修改了颜色并提交新数据时,会将record_in_history设置为False。只有当美术同学最终确认了颜色时我们才将最终的颜色提交到data manager并将record_in_history设置为True。代码如下:

实际的效果如下面的视频所示。

除了预览以外,另一个我们不希望将数据变更记录到undo/redo栈中的场景是连续变化。例如在编辑器中,策划同学常常会通过鼠标拖拽来移动关卡中一个单位的位置。如果这个拖拽过程中所有变更的位置都被记录下来,就会面临和预览颜色时类似的问题。因此,当用户按住鼠标移动一个物品时,所有的数据变更都会以record_in_history为False提交给data manager。只有当用户放开鼠标时,最终的位置才会以record_in_history为True提交给data manager。

七、解耦数据更新和UI更新

一些同学在刚接触基于immutable data的框架时会有这样的疑问:对于本身就以编辑数据为主的编辑器,例如任务编辑器等,这个框架看上去的确很不错。但是对于包含更多复杂视觉元素的编辑器,例如关卡编辑器,甚至是特效编辑器应该怎么做呢?我们不太可能将引擎中的模型、特效等对象重新用immutable data来实现。

而我们答案是:模型、特效等元素本身就是一种UI。以关卡中的一个模型为例,和其他UI元素一样,他提供了一个方式将我们所编辑的数据可视化,同时也通过了交互手段让我们能通过其来编辑数据。因此这些更复杂的视觉元素不过是另一种形式的UI。在编辑器中,我们编辑器的仍然是纯逻辑的数据,而编辑器View通过同样的方式来创建和更新模型、特效等元素。

事实上,基于immutable data的编辑器反而尤其适合制作这一类复杂的编辑器,因为其拥有一个强大的特性:数据更新和UI更新是解耦的。

在编辑器中,数据更新往往是同步完成的。但在UI更新中,则有不少异步操作。例如UI动画,模型加载等。当一个同步操作和一个异步操作强关联时,往往需要较多额外的精力才能处理正确。

举例来说,在实际使用中我们可能遇到这样的场景,策划同学在关卡中添加了一个新的对象,然后开始编辑这个对象的属性,然而此时该对象还在加载中。此时,如果我们直接对一个加载中的对象进行属性设置则可能引起无效操作,更坏的情况下会直接导致编辑器崩溃。如果跳过这次属性设置,那么当对象加载完成之后,该对象在场景中展示出来的属性和实际的数据就不一致。

为了解决这种问题,我们往往需要一个缓存机制,将加载期间设置的属性值缓存起来,等到对象加载结束之后再读取缓存中的值进行设置。这便提升了编辑器的复杂度。

前面我们提到过,在基于immutable data的编辑器中,UI更新是以固定帧率进行的,这就说明UI更新本身和数据更新是解耦的。从而大幅简化了上面这种场景的处理。

例如对于上面提到的场景,在我们的编辑器中,当负责关卡场景的SceneView在加载一个对象时。他可以简单直接跳过后续的任何更新帧。当对象完成加载之后,在下一帧更新时,通过对比数据SceneView会发现当前对象的数据和最新的对象数据已经不一致了,其可以快速找出哪些值发生了变化,然后设置加载完对象的属性。整个过程非常的简单,从而大幅降低了出现bug的概率。

上面的视频演示了这一过程。在这个视频中,为了更方便大家观察到延迟的属性设置,我们刻意大幅提高了对象的加载时长。在实际的使用中,大部分对象的加载时长要短得多,延迟的属性设置并不会引起使用体验的降低,但是大幅提高了编辑器的开发效率。

八、自动测试

小的开发团队维护大量编辑器的另一个主要挑战就是编辑器的测试。在星战前夜的开发团队中我们甚至没有专门的编辑器相关QA。尽管不在我们最初的设计目标之中,但基于immutable data的编辑器框架意外的带来了很强的自动化测试支持,从而极大的减轻了我们测试编辑器的负担。

对于编辑器的两个主要部分:数据和View。数据相关的测试能够很轻松的被单元测试所覆盖,但是UI逻辑相关的测试一直较难自动化测试。在前面的介绍中我们可以看到,我们的编辑器UI逻辑全部由数据驱动,因此如果我们将一组数据序列化保存到磁盘上,未来我们对UI逻辑进行迭代后,就可以自动重放这一组数据来测试相关的UI逻辑。

实际运行的效果如上面的视频所示。在编辑器中,我们提供了一个记录按钮(record action),开启之后编辑器中的一切数据变更都会被记录下来。当记录完成之后我们可以将其保存到磁盘上,由此便得到了一个自动测试的用例。当我们需要进行测试时,我们可以通过--test-case参数指定我们要测试的用例。我们甚至记录了每一个数据变更对应的时间戳,我们可以选择严格的按照之前的时间点触发每一个数据变更,从而保证UI动画等行为都是正确的。

九、Immutable data

最后简单的介绍一下immutable data的实现原理。目前大部分immutable data的实现都参考了Rich Hichkey在Clojure中的实现。而Rich Hichkey则借用了Phil Begwell发明的Hash Array Mapped Trie。

以immutable list为例,其实际上的数据结构是一颗树:

这棵树的所有节点都是一个等长的数组。列表中的元素都保存在叶节点中。当我们要修改列表中某个元素时,我们首先复制这个元素所在的叶节点,修改相关元素,然后复制该叶节点的父节点,一直到根节点。最后得到一个新的树:

两个immutable list内部共享了很多节点,被称之为structural sharing。通过这种方式,immutable list以很小的时间和空间开销实现了拷贝。

十、附

本文中所使用的todo应用源码: 
https://github.com/kkpattern/immu_editor_gdc


更多GDC演讲回顾,请戳:
【高能回顾】GDC2022-网易互娱演讲内容集锦

评论 0

0/1000
网易游学APP
为热爱赋能
扫描二维码下载APP