文章简介
文章主要介绍了虚幻引擎的基础容器的内部数据结构和实现原理,以及在实践中的应用,性能优化等方面。包括:TArray、TSparseArray、TSet、TMap等基础容器,TQueue、TTripleBuffer、TLockFreeList等多线程容器,以及 TSharedPtr、TWeakObjectPtr等智能指针。
除了基本原理外,文中还分享了作者对这些容器在实际项目中的使用经验,以及针对手游提升性能而做出的各种修改和优化的手段。
适合读者
1)从事UE4/UE5或Unity 3D的客户端开发者
2)3D游戏项目的客户端开发者
3)希望了解虚幻引擎实现原理的开发者
4)对游戏引擎内部机制感兴趣的开发者
你将获得
1)了解游戏中的高性能容器的数据结构和实现原理
2)了解常见容器在项目中的实际应用
3)了解一些C++ 11、多线程的基础知识以及在虚幻引擎中的应用
文章试读
1|UE4的TArray(一)
TArray,是UE4的可动态扩容数组容器,是UE4里最常见,也是用的最多的一种容器,类似于STL中的Vector,除了数组的基本功能外,还有一些从性能上来考虑的设计很有亮点,我觉得可能更适合游戏使用。下面会具体介绍。
定义:
首先看数组的定义,本身是一个模板Class,模板需要两个参数,第一个是元素的类型,第二个是内存分配器,分配器会在后面说。
再看成员变量,其中ArrayNum是数组元素的实际个数,ArrayMax是数组最大可容纳元素的数量,而AllocatorInstance是数组的内存分配器,分配器的类型可以从上一张图看到,是模板第二个参数Allocator中的ElementType类型。数组实际占用的内存,只有这3个成员变量的内存(最少是16字节,一个指针+两个int,不同Allocator实际占用内存不同,最少的是一个指针),而实际元素的内存是由Allocator分配,具体大小就是ArrayMax * sizeof(ElementType),加一些Allocator内的额外的变量。
初始化:
默认的构造函数,元素数量是0,数组的容量根据Allocator不同来预分配。除了默认的以外,还提供了很多拷贝构造和移动构造的方式,包括指针+数量、TArrayView、初始化列表和另一个TArray来构造等。
数组内部的内存扩容方式和STL的Vector是差不多的,当容量满了之后,会额外分配一个更大的内存,将整个数组的数据拷到新内存上,之后再释放旧的内存(InlineAllocator不会释放Inline部分)。
其中指针加数量和TArrayView本质是一样的,都是从指针开始拷贝连续的内存到数组内。TArrayView类似于动态Span,而Span使用默认STL需要开启C++20,UE4默认是C++14的,这里也是UE4比较有优势的一点。个人更推荐使用TArrayView,因为TArrayView让指针和数量保存在同一个变量内,从语义上来说更合理,而且只要是连续的内存,TArrayView都支持包装,包括默认静态数组、TStaticArray和TArray,这样不需要额外提供很多兼容其它数组类型的拷贝构造函数。
其中这个拷贝构造函数还支持在数组尾部额外分配内存,这样可以做到一次性就分配好所需要的内存大小,对于性能会更友好。
在提供这么多拷贝构造函数的同时,也对等重载了operator =,具体实现和拷贝构造函数差不多。
其中初始化列表的拷贝构造函数和等号重载运算符,具体是这样使用的:
除了拷贝构造函数外,还提供了C++11新增的移动构造函数:
可以看到内部实现,移动构造函数只是把传入TArray的Allocator的指针和数组容量拷贝到当前数组,而传入的数组直接恢复到无分配的默认状态,因此使用移动构造函数可以让数组作为函数的参数和返回值,以及Lambda时需要传入数组性能更好。但有一点,对于左值(可以认为几乎是所有情况)一定要显式调用MoveTemp函数才能使移动构造函数生效。下面是具体使用方法:
而对于连续的函数调用参数,也需要在调用外部和内部都使用都MoveTemp来保证使用移动构造函数,这样才能提升性能。
如上图所示,期间全程调用移动构造函数,没有发生一次数组拷贝。因此在业务逻辑开发中强烈推荐这种写法。
在大部分业务逻辑中,包括UE4自己的引擎内部实现,在给渲染线程的提交队列Lambda函数传递渲染数据时,大部分情况都是直接在游戏线程new一段内存,在渲染线程delete内存,这样的方式在逻辑复杂度很高的时候,很容易造成内存泄漏。如果按照上面的方式使用TArray来传递数据,可以避免直接传递原始的指针和长度,就相当于间接避免了手写内存申请和释放的代码,既能提升安全性,也能保证性能。
你可能会好奇,为什么移动构造函数里调用的函数是MoveOrCopy,什么情况会发生拷贝呢?其实这个比较依赖Allocator,对于不同的分配器,可能内存没法直接移动,比如移动到InlineAllocator类型的数组上,这种情况就只能拷贝了,因此在实际游戏开发中这一点也是需要注意的地方。
戳此阅读全文:虚幻引擎源码解析——基础容器篇