用D语言构建游戏引擎
构建游戏应该是有趣的.
我以前的主要工作进程是围绕着Godot引擎及其脚本语言.这很适合需求(带复古感的2D游戏),但总会有些摩擦.这里
部分原因是我想要一些不同东西,另一部分是引擎面向更具主见,编辑驱动的设计转变.我一直更喜欢用代码驱动的方法来做项目.
其中一例可在我的未完成的GDScript库中.
随着新的Godot版本发布,该摩擦逐渐加深,最终让我走到了现在:用D开发自己的游戏引擎``Parin.
当然,Parin并不是我第一次试Godot以外的游戏开发.我最初的目标是看看自己是否可创建出我习惯的那样美好的工作流.
这让我绕了很长的路,学习了Nim,Go,Zig,C和D等语言.经过一番搜索,我发现D正是我需要的.这是个务实且无倾向的语言,不会让我感觉摩擦.
这里,介绍D的一些特征,及如何利用它们制作游戏.总之:
1,游戏逻辑和脚本的单一语言.
2,编译时间快到不到1秒.
3,自由选择最佳分配内存策略.
4,实现了类似C的速度,同时开发体验更爽.
用Parin制作的游戏:WormsWithin,这里
管理内存
D的无偏见最明显的是它让我对内存的掌控.在Parin中,我设计了默认避免使用垃集器的代码结构.它主要依赖静态数据结构和每帧结束时清理的圆形分配器.
圆形分配器
该引擎实现了两类圆形:
圆形:固定大小的缓冲.它非常适合已知上界的临时内存.
增长圆形:一个圆形块的链表.提供了"按需付费"策略.
cpp
struct Arena {
ubyte* data;
size_t capacity;
size_t offset;
//...检查点的`元数据`.
Arena* next;
}
struct GrowingArena {
Arena* head;
Arena* current;
size_t chunkCapacity;
}
这里,增长圆形就是如前那种圆形.为了方便,有时使用叫域圆形的RAII助手器.
它利用析构器在域结束时自动回滚圆形偏移.结合D的with语句,创建了个优雅的圆形操作方式:
cpp
import parin;
void main() {
ubyte[1024] buffer = void;
auto arena = Arena(buffer);
with (ScopedArena(arena)) {
make!char('C');
//`"域圆形"`的"制作"方法会步进偏移.
with (ScopedArena(arena)) {
make!short(3);
make!char('D');
assert(arena.offset == 5);
}
//偏移回到嵌套块之前的位置.
assert(arena.offset == 1);
}
//偏移回到起点.
assert(arena.offset == 0);
}
静态数据结构
与圆形和增长圆形类似,许多数据结构需要编译时参数来切换静态分配或动态分配.
引擎偏好静态版本,因为它们避免了运行时分配,并且允许在单个内存块中轻松绑定不同数据.
以下是简化的示例说明:
cpp
//带动态容量的列表.
struct List(T) {
T[] items;
size_t capacity;
}
//带固定容量的列表.
struct FixedList(T, size_t N) {
T[N] data;
size_t length;
T[] items() {
return data[0 .. length];
}
enum capacity = N;
}
//二维栅.`"D"``类型定义`了其行为.
struct Grid(T, D = List!T) {
D tiles;
int rowCount;
int colCount;
void fill(T value) {
foreach (ref tile; tiles.items) {
tile = value;
}
}
}
//动态栅类型.
alias Rooms = Grid!short;
//静态栅类型.
alias Map = Grid!(short, FixedList!(short, 128 * 128));
如上,列表和FixedList共享一个公共公开接口(项们和容量).虽然它们的底层类型不同,items是第1个的变量,属性是另一个的变量,但它们在功能上是兼容的.
因此,与栅工作的一般函数可任意使用其中一个.
下面是Parin的一个更复杂的示例:通用数组(处理映射)类型:
cpp
struct GenList(T, D = SparseList!T, G = List!Gen) if (isGenContainerPartsValid!(T, D, G)) {
D data;
G generations;
}
bool isGenContainerPartsValid(T, D, G)() {
static if (__traits(hasMember, D, "isBasicContainer")) {
static if (isSparseContainerPartsValid!(T, D.Data)) {
static if (__traits(hasMember, G, "isBasicContainer")) {
//注意:可写得更好,但不重要.
return G.isBasicContainer && G.hasFixedCapacity == D.hasFixedCapacity;
} else {
return false;
}
} else {
return false;
}
} else {
return false;
}
}
动态分配
对需要动态分配的部分,引擎提供了两条路径.它有时接受用户分配的内存,即用户可精确决定使用哪种内存:GC,malloc还是栈.
一个很好的示例是Parin的实验性UI库,叫ui2:
cpp
import parin, parin.ui2;
UiContext ui;
UiCommand[64] uiCommandsBuffer = void;
char[1048] uiCharDataBuffer = void;
//游戏开始时只调用了一次.
void ready() {
lockResolution(320, 180);
//使用`静态缓冲`,`手动管理``UI`内存.
ui.readyUi(uiCommandsBuffer, uiCharDataBuffer);
}
//游戏`运行时`,每帧都会调用.
bool update(float dt) {
ui.beginUiFrame();
scope (exit) ui.endUiFrame();
//定义界面布局,并处理交互.
auto screen = IRect(resolution.toIVec());
screen.subAll(8);
auto menu = ui.rowItems(screen.subTop(20), 7, 5);
if (ui.button(menu.pop(), "1")) println("1!");
if (ui.button(menu.pop(), "2")) println("2!");
if (ui.button(menu.pop(), "3")) println("3!");
return false;
}
//创建一个`调用给定函数的主函数`.
mixin runGame!(ready, update, null);
它使用了我写的一个叫Joka的"nogc"实用库.必须手动释放Joka分配的内存.
就这样.嗯,也不完全是.很多语言会让你选择一个主要的分配策略,并且允许有限支持其他配置策略,从而阻止你.但D给你的选择不止这些.
虽然按手动管理内存设计Joka,但它包含一个JokaGcMemory版本标志.定义它后,在编译时,库的默认分配内存切换为使用垃集器,任何释放内存的函数基本上都是闲着.
这类似某些C库提供替代内部函数的方法.此时,运行时仍可手动管理内存,因为Joka提供了细粒度控制的分配器API.
在实际操作中,该标志只是替换了每个Joka(和Parin)函数传递或使用的默认分配器值.因为D中的GC指针和其他指针类型相同,所以更改默认值时,依然可正常运行.
这是Joka的分配器API:
cpp
struct MemoryContext {
void* allocatorState;
AllocatorReallocFunc reallocFunc;
void* malloc(size_t alignment, size_t size, const(char)[] file, size_t line) {
return reallocFunc(allocatorState, alignment, null, 0, size, file, line);
}
void* realloc(size_t alignment, void* oldPtr, size_t oldSize, size_t newSize, const(char)[] file, size_t line) {
return reallocFunc(allocatorState, alignment, oldPtr, oldSize, newSize, file, line);
}
void free(size_t alignment, void* oldPtr, size_t oldSize, const(char)[] file, size_t line) {
reallocFunc(allocatorState, alignment, oldPtr, oldSize, 0, file, line);
}
}
alias AllocatorReallocFunc = void* function(void* allocatorState, size_t alignment, void* oldPtr, size_t oldSize, size_t newSize, const(char)[] file, size_t line);
这是适合我需求的简单API.我知道D在标准库里已包含了一个实验性API.我自己搞了个,来理解工作原理.
分配器的实际操作示例:
cpp
import joka;
void main() {
ubyte[1024] buffer = void;
auto arena = Arena(buffer);
auto i = 0;
//利用圆形来给数字分配内存.
auto numbers = List!int(arena.toMemoryContext(), 1, 2, 3);
assert(numbers[i++] == 1);
assert(numbers[i++] == 2);
assert(numbers[i++] == 3);
}
为了缓解未允许JokaGcMemory时的一些内存错误,Joka用线本分配器,跟踪调试构建中的所有分配内存.
Parin的设计是,当有人忘记释放内存或试无效的释放时,利用这些信息立即反馈.
这工作,是因为分配器API需要为所有操作配备文件和行参数.报告类似如下:
cpp
`泄漏内存`:5次(总共`934`字节,忽略`8次`)
1次泄漏,`16`字节,源/应用:`24`
1次泄漏,`32`字节,源/应用:`31`
2次泄露,`128`字节,源/`story.d:17[`组:"Actor"]
1次泄露,`32`字节,`source/story.d:40[`组:"Actor"]
跟踪系统还包括忽略泄漏和归类分配,从而减少输出噪声.这不是百分之百的方案,但覆盖了许多实际.
对我写的代码类型,我更喜欢此更简单的方法,而不是灵针抽象.
Joka和Parin还可用内存:隐式改变域内使用的分配器.如果我有个动态分配的函数,我可"拦截"它,强制它在栈上分配.
这是针对特例的小众功能.一个叫__memoryContext的线本变量让它正常工作.该机制有时叫环境系统.
这里有个叫ScopedMemoryContext的通过RAII管理该线本变量的示例:
cpp
import joka;
void main() {
ubyte[1024] buffer = void;
auto arena = Arena(buffer);
auto i = 0;
//对`"with"`块内的所有,利用圆形分配内存.
//在退出该块时,`"ScopedMemoryContext"`会自动恢复之前的环境.
with (ScopedMemoryContext(arena)) {
auto numbers = List!int(1, 2, 3);
assert(numbers[i++] == 1);
assert(numbers[i++] == 2);
assert(numbers[i++] == 3);
}
}
我不太喜欢这样,因为很难理清思路.至少,这是我对那些内置特殊调用约定方式的语言的经验.在这些语言中更明显的原因是人们喜欢过度依赖内置功能,所以缺点更严重.
环境系统本质上是个需要注意的全局变量.它类似PICO-8的有笔的颜色的API,但用域神奇来管理内存.为了避免某些隐式交互,库代码避免更改环境,这纯粹是用户端的选项.
仅此而已.所有这些结合起来,我可选择保持手动控制,让D处理所有事情,或两者结合使用.我可为项目选择最佳方案,而不会让编译器抱怨我为什么"做错"了.
话虽如此,D确实有强制严格性的功能,比如@nogc属性,但Joka和Parin只在不引入额外摩擦时使用.我的库都不完全支持@nogc,这是设计使然,尽管理论上可以.
相反,结合-vgc标志并了解代码功能,效果很好.
虽然该插件分配策略,对习惯于单一方式的人来说可能很奇怪,但我发现很多此用例,特别是在协作时.当我和不熟悉手动管理内存的人合作时,我可直接告诉他们使用垃集器,而我则关注底层部分.
这提供了类似C++和Lua组合的设置,但没有跨语言成本.
插件GC和非GC代码的另一个用例是如前的跟踪系统.是,是"秘密"使用垃集器.我把所有工作都交给它,不用担心那些对程序性能不重要的分配.
总之,这仅是调试代码,谁在乎它是否使用垃集器.反正发布版本里会删除代码.这里
元编程
元编程不是我擅长的,但有时我确实喜欢它.D语言在提供流畅体验方面做得很好,因为写起来类似写普通D代码,而不是用另一个语言.
实体系统
一个用例是构建实体系统.虽然Parin不会强制你使用特定系统,但它提供了个简单构建实体系统的标签联.如下:
cpp
alias UnionType = ubyte;
struct Union(A...) if (A.length != 0) {
union UnionData {
//创建原联的字段.
static foreach (i, T; A) {
mixin("T _m", i.stringof, ";");
}
}
UnionData _data;
UnionType _type;
}
//包含两类的联的示例.
alias Entity = Union!(Marioni, Goombani);
struct Marioni { float x, y; int hp; }
struct Goombani { float x; }
真实类型包含一些其字段的额外信息,这允许在编译时检查安全.
我个人在游戏中使用静断,确保标签联中的每个类型共享相同的即叫联的"基"的第一个字段.
这确保我可安全访问共享数据,比如位置或大小,而无需在运行时手动检查活动的联类型.
如:
cpp
//这保证总是是安全的访问"实体"的"基".
static assert(Entity.isBaseAliasingSafe);
//访问`所有类型`共享的基,并右移`所有对象`.
foreach (ref e; entities.items) e.base.x += 32;
为了处理不同类型的特定逻辑,我使用叫调用的模板函数.这会生成一个,对当前活动类型调用正确方法的大型猜语句.
使用调用函数的一例:
cpp
//对`底层类型`,`自动调用``"更新"`和`"绘画"`.
foreach (ref e; entities.items) e.call!"update"(dt);
foreach (ref e; entities.items) e.call!"draw"();
因为所有操作都在编译时,编译器会在缺少方法时给出显式的错误信息.可与D的别名本结合,来为缺乏要求方法的类型提供默认实现.
以下是这样构建的基本实体类型的示例:
cpp
import parin;
//`每个实体`的基类型.
struct EntityBase {
Rect body;
//`默认实现`.
void update(float dt) {}
void draw() {}
}
//`Actor`是一个实体类型.
struct Actor {
EntityBase base;
alias base this;
//`自定义`绘画逻辑.
void draw() {
//`"体"`是`"EntityBase"`的一部分.
drawRect(body, orange);
drawText("Actor", body.position);
}
}
这样可让代码保持简洁,也让我可用,所有实体属性都在一个地方的"巨结构"风格的方法,这样空间效率高.可在Parin仓库中取得上述代码的完整示例.
调试工具
除了游戏逻辑,类似的编译时内省,对构建调试工具``非常有用.因为代码可查看结构,并看到里面的每个成员,比如我可写自动生成这些成员的UI元素的函数.
在Parin中,我有个用来为任何游戏对象构建调试编辑器的叫headerAndMembers的助手:
cpp
import parin,parin.addons.microui;
Game game;
struct Game {
int width = 50;
int height = 50;
IVec2 point = IVec2(70, 50);
}
void ready() {
readyUi(engineFont, 2);
}
bool update(float dt) {
beginUiFrame();
scope (exit) endUiFrame();
drawRect(Rect(game.point.x, game.point.y, game.width, game.height));
if (beginWindow("Edit", IRect(500, 80, 350, 370))) {
headerAndMembers(game, 125);
endWindow();
}
return false;
}
mixin runGame!(ready, update, null);
没有为每个想调整的单个成员手动写一行UI代码,而是让编译器来处理.在下次运行游戏时,游戏状态中,任何新增的变量都会直接出现在编辑器中.
为了进一步``自定义,我还可用用户自定义属性来控制对象的行为.如,给变量应用@UiMember("健康")会覆盖其显示名.
你甚至可为滑块定义约束.应用@UiMember("音量",0,100,1)会让编辑器,按步进为1,将值限制在0到100间:
cpp
//`UI`系统使用的属性.
struct UiMember {
const(char)[] name;
//成员名字.
UiReal low;
//滑块使用.
UiReal high;
//滑块使用.
UiReal step;
//滑块使用.
}
alias UiReal = float;
联分配
最后,我在元编程方面还做了件有趣的事,就是联分配.这是在单个连续内存块中分配多个数组,以提升缓存本地性并减少分配器成本的做法.
虽然可手动完成,但D的内省提供了更优雅,更安全的方案.
这是使用jokaMakeJoint函数的小例:
cpp
import joka, std.stdio;
struct Ve2 { float x, y; }
struct Ve3 { float x, y, z; }
struct Mesh {
Ve3[] positions;
int[] indices;
Ve2[] uvs;
this(size_t positionsLength, size_t indicesLength, size_t uvsLength) {
//`'jokaMakeJoint'`计算所有数组的总大小和偏移,
//并执行一次分配.
this = jokaMakeJoint!Mesh(positionsLength, indicesLength, uvsLength);
}
void free() {
//第一个切片有`需要释放的指针`.
jokaFree(this.tupleof[0].ptr);
}
}
void main() {
auto mesh = Mesh(4, 6, 4);
writeln("Positions: ", mesh.positions);
writeln("Indices: ", mesh.indices);
writeln("UVs: ", mesh.uvs);
mesh.free();
}
在上述释放方法中,我使用的元组属性.在D语言中,这允许你以编译时序列访问结构的字段.
因为jokaMakeJoint分配了一个大块,并将第一个字段指向其起点,释放this.tupleof[0].ptr(第一片的指针)实际上释放了整个内存块.
编译时间
D的编译速度``非常快.仅此一点就是我使用D的一个主要原因.在运行Ubuntu的老款Ryzen32200G上,不用构建系统,游戏编译时间大约是0.6秒.
我一般用DUB来构建,但这里我避免使用它,这样无需额外构建步骤,可更清楚地了解速度.另外,我用的是Ubuntu自带的默认``链接器.
使用-betterC标志时,时间可降至约0.4秒.以下是使用Parin和Joka的"你好世界"程序编译时间的细节:
编译器 |
Parin |
Parin&betterC |
Joka |
Joka与-betterC |
|---|---|---|---|---|
DMD |
0.585s |
0.370s |
0.296s |
0.134s |
LDC |
1.918s |
1.634s |
0.565s |
0.565s |
GDC |
3.535s |
无标志 | 0.906s |
无标志 |
可在两个库的示例目录中找到,基准测试中使用的文件,叫_001_hello.d.他们有意导入比最简程序更多的模块,以模拟真实环境.
根据测试,编译时间最长的模块是Joka的数学模块.下面该基础的Joka程序,包含-betterC,DMD和4个导入模块(joka.io有3个依赖),编译时间为0.081秒:
cpp
import joka.io;
extern(C)
void main() {
println("Hello world", 999, '!');
}
这里还有Parin和Joka``代码基的概述:
项目 |
D文件 | D空格 | D注释 | D代码 |
|---|---|---|---|---|
Parin |
37 |
3270 |
2036 |
19634 |
Joka |
11 |
1534 |
681 |
8169 |
总之,可取得快速的代码和快速的编译时间.
工作流
因为编译速度快且标准库很实用(我之前没提到),我也按脚本语言使用D.完全用D语言编写为游戏创建网页构建的脚本.
它负责打包,资产复制及网页目标要求的配置.我没有为不同平台维护独立脚本,而是在所有平台都用同一语言,效果很好.
一个使用网页脚本与DUB的示例:
cpp
dub run parin:web
同样的思路也用在DUB项目的小型设置脚本.它生成的是我一般需要的目录和文件,每次开始新游戏时都能用.其中一个是包含一个基础"你好世界"程序的app.d文件.
脚本还可传递一个叫实体的标志来包含最小实体系统.
使用DUB的设置脚本示例:
cpp
dub init -t parin -- entity
总之,工作流很简单.当我需要自动化或工具时,我就写更多的D.如果需要的话,这也让我在游戏和脚本间共享代码.
我还是会在合适时用壳和批处理脚本,但大多数项其实并不需要.
参与其中
这就是终点.我是AlexandrosF.G.Kapretsos,一名游戏你,也是AUEB的经济学学生.如果你喜欢这篇文章,欢迎看看工作:
去GitHub看看Parin和Joka.
看看microuid,我重写了rxi的microui,修复了漏洞,纹理支持和其他D的相关改进.Parin开箱就用!
在kapendev.itch.io上玩游戏,看看引擎的实际运行.这里