2606d,用d语言构建游戏引擎

原文

用D语言构建游戏引擎

构建游戏应该是有趣的.

我以前的主要工作进程是围绕着Godot引擎及其脚本语言.这很适合需求(带复古感的2D游戏),但总会有些摩擦.这里

部分原因是我想要一些不同东西,另一部分是引擎面向更具主见,编辑驱动的设计转变.我一直更喜欢用代码驱动的方法来做项目.

其中一例可在我的未完成的GDScript库中.

这里

随着新的Godot版本发布,该摩擦逐渐加深,最终让我走到了现在:用D开发自己的游戏引擎``Parin.

这里

当然,Parin并不是我第一次Godot以外的游戏开发.我最初的目标是看看自己是否可创建出我习惯的那样美好的工作流.

这让我绕了很长的路,学习了Nim,Go,Zig,CD等语言.经过一番搜索,我发现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"]

跟踪系统还包括忽略泄漏归类分配,从而减少输出噪声.这不是百分之百的方案,但覆盖了许多实际.

对我写的代码类型,我更喜欢此更简单的方法,而不是灵针抽象.

JokaParin还可用内存:隐式改变域内使用的分配器.如果我有个动态分配的函数,我可"拦截"它,强制它在栈上分配.

这是针对特例的小众功能.一个叫__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属性,但JokaParin只在不引入额外摩擦时使用.我的库都不完全支持@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秒.以下是使用ParinJoka"你好世界"程序编译时间的细节:

编译器 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, '!');
}

这里还有ParinJoka``代码基的概述:

项目 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看看ParinJoka.

这里

这里

看看microuid,我重写了rximicroui,修复了漏洞,纹理支持和其他D的相关改进.Parin开箱就用!

这里

这里

kapendev.itch.io玩游戏,看看引擎的实际运行.这里

相关推荐
TO_ZRG13 小时前
Unity 证书校验
unity·游戏引擎
mxwin15 小时前
Unity Shader 切线空间数据是如何计算出来的
unity·游戏引擎·shader
mxwin18 小时前
Unity Shader 法线贴图跟切线空间有什么关系
unity·游戏引擎·贴图·shader
mxwin19 小时前
Unity Shader 贴图和采样的关系 如何保证贴图清晰
unity·游戏引擎·贴图·shader
心前阳光20 小时前
Unity之使用火山引擎实现文字提问流式回复
unity·游戏引擎·火山引擎
mxwin1 天前
Unity Shader 什么是球谐光照 原理是什么
unity·游戏引擎·shader
心前阳光1 天前
Unity之使用火山引擎实现流式语音合成
unity·游戏引擎·火山引擎
心前阳光1 天前
Unity之音频剪辑提问,流式语音回复使用示例
unity·游戏引擎·音视频