从零开始手敲次世代游戏引擎(FreeBSD特别篇)

上一篇有 @zi xiang 提出希望在FreeBSD下面编译试一试,我回答Linux版本应该直接可以编译。实际试了一下好像并没有那么顺利,但是代码本身是不需要任何改动的,主要是一些工具和环境问题(其实是编译基于GNU工具包会遇到的共通性问题)。这里记录一下遇到的问题:

(不熟悉FreeBSD的读者可以跳过。FreeBSD是比较严格遵从Unix标准开发的,所以与“参考Unix”开发的Linux还是有一些细节方面的差别,比Linux更难使用一些)

  1. Linux版本的ISPC无法在FreeBSD上面生成符合BSD规格的ELF文件。(报告类型3 ELF不支持)
    解决的办法就是重新编译ISPC。虽然FreeBSD缺省安装自带clang和llvm,但是没有提供llvm-config这个程序和ISPC需要的llvm的库。所以需要从ports重新编译安装clang和llvm;
  2. make和gmake的差别。ISPC自身是直接使用Makefile进行编译的,这个Makefile是按照GNU make的格式去写的。这里的问题是在BSD系列当中的make是Unix标准的老的make,与Linux当中的make(其实是GNU make)并不是完全一样的。所以会报很多错误。解决的方法其实也很简单,GNU make在BSD系统当中叫做gmake,只需要通过ports安装gmake,并用它build ISPC就可以了;
  3. 编译ISPC时提示找不到<alloca.h>。这个头文件在FreeBSD系统当中不存在,但是其实内容是被包括在stdlib.h当中的。所以解决的方法有两个:
  1. 改动代码,替换成stdlib.h;
  2. 自己在/user/include下面写一个alloca.h,里面就一行内容:#include <stdlib.h>
  • ispc在链接阶段提示无法找到traceback这个函数。这个函数在FreeBSD当中位于libexecinfo.a当中,只需要修改ispc的Makefile,链接这个库就好了;
  • Framework/Geometry/ispc/下面的代码编译的时候报‘sed’相关的错误。这个和上面第二条类似,我们到目前为止用的sed工具的全名叫GNU sed,与其原型——Unix系统下的sed已经不完全是一个东西了。FreeBSD当中的sed是Unix的老sed,GNU sed叫gsed,在Framework/Geometry/ispc/CMakeLists.txt当中替换一下就好了;
  • 编译MyGameEngineOpenGL的时候提示找不到libdl。这是因为在FreeBSD当中libdl(动态库加载库)是统合在libc当中的。所以从链接库列表里面去掉‘-ldl’就好了

其他的比如找不到libxcb什么的,这个直接从ports安装就是了。通过pkg命令安装的只有动态库本身,不带头文件以及.a什么的开发用文件,所以需要通过ports编译安装是最好的。

基本就是这些。调整完毕的项目位于freebsd这个分支当中。

顺便提一下,虽然freebsd现在似乎并不是一个大家常用的系统,但是PS4是基于freebsd的。相对于Linux的GPL许可证,BSD许可证对于想要封闭自身知识产权的公司更为方便。所以诸如苹果和索尼等商业化公司一般都会选择它作为基础进行定制化开发。

从零开始手敲次世代游戏引擎(三十一)

本篇我们回归主线。首先是将我们前面两个Mac特别篇所写的代码统合进来。在这个过程当中发现几个问题:

【1】 OpenGL环境仍然是2.1的。

查阅资料发现,这是因为Apple考虑到兼容性,缺省状态下的OpenGL环境被一律限制到2.1,而如果要使用更高的版本,需要在初始化的时候指定特别的参数,如下粗体字所示(CocoaOpenGLApplication::Initialize):

     NSOpenGLPixelFormatAttribute attrs[] = {
            NSOpenGLPFAOpenGLProfile, NSOpenGLProfileVersion3_2Core,
            NSOpenGLPFAColorSize,32,
            NSOpenGLPFADepthSize,16,
            NSOpenGLPFADoubleBuffer,
            NSOpenGLPFAAccelerated,
            0
        };

注意这里指定的是创建OpenGL Context时候的下限。实际创建的环境会有不同。比如在我的机器上,实际创建的环境是4.1的:

chenwenlideMacBook-Pro:GameEngineFromScratch chenwenli$ ./build/Platform/Darwin/MyGameEngineCocoaOpenGL.app/Contents/MacOS/MyGameEngineCocoaOpenGL 
OpenGL Version 4.1 loaded

【2】 C++部分gladGLLoader出现Segmentation Fault

需要注意的就是C++代码部分执行OpenGL Loader(我们用的是glad)的时机。细心的读者可能会发现,我们从零开始手敲次世代游戏引擎(MacOS特别篇 贰)所写的MacOS版OpenGL的程序,所使用的GraphicsManager是作为基类的GraphicsManager,而不是RHI/OpenGL里面的OpenGLGraphicsManager。这是因为在写前面这篇文章的时候,遇到了C++当中glad一旦Load就出现段错误(Segmentation Fault)。

这个问题现在解决了。原因其实和之前写Windows版或者Linux版OpenGL程序的时候反复强调的一个问题(好吧,我自己忘记了)一样,就是在动态加载OpenGL函数的时候有一个前提条件,那就是OpenGL的Context已经创建并且被设置为Current Context。

所以,其实解决的方法很容易,就是在调用glad之前(也就是初始化RHI/OpenGL/OpenGLGraphicsManager之前),保证OpenGL Context创建完成并被设置为Current Context。这个工作在Cocoa版当中是CocoaOpenGLApplication的工作。我们在CocoaOpenGLApplication::Initialize当中(但是实际创建OpenGL Context的是CocoaOpenGLApplication::_openGLContext,也就是GLView,所以其实是在GLView::initWithFrame当中),靠近函数出口的地方,加入下面这么一行代码:

 [_openGLContext makeCurrentContext];

RHI/OpenGL/OpenGLGraphicsManager的初始化问题就可以得到解决了。

【3】除了背景,啥都没有。

这个其实不是一个问题,因为我们还没有将SceneManager和GraphicsManager结合起来,当然什么都没有了。╮(╯▽╰)╭

解决的方法就是结合SceneManager——那估计还需要好几篇文章的篇幅,所以我们先把从零开始手敲次世代游戏引擎(十三)写的代码临时放进OpenGLGraphicsManager用用吧。

【4】没有动画效果。就是画面当中的正方体不会转动。

这是因为窗口没有接到刷新的通知。与Windows/Linux不同,MacOS系统不会自动刷新窗口,除非窗口的大小发生改变等。解决的方法有好几种,比如使用一个定时器定期触发窗口更新;又比如通过DisplayLink监视屏幕的垂直同步(vsync)进行画面的刷新(这是Apple推荐的方法)

注意使用DisplayLink的时候,监视DisplayLink的动作是发生在另外一个线程,callback也是从另外一个线程调用过来的。这里处理不好容易发生crash。

因为后面我们还会有驱动模块,专门负责这些定期事件的驱动和调度,我这里就直接在App的IRuntimeModule::Tick()接口当中触发了窗口的重新绘制事件。只不过目前我们没有驱动模块,是在main函数当中直接loop。

下面是Mac版的显示效果:

以及Windows版本的显示效果:

Linux环境

结果上和从零开始手敲次世代游戏引擎(十三)是一样一样的。从十三写到三十一,我们到底干了些啥?╮(╯▽╰)╭

参考引用

  1. OpenGL Application Design Strategies
  2. NSOpenGLView not redrawing
  3. Driving OpenGL Rendering Loops
  4. Introduction to Core Video Programming Guide

从零开始手敲次世代游戏引擎(macOS特别篇 贰)

上一篇我们实现了在macOS上面的编译。但是我们采用的并不是macOS的原生接口,因此获得的OpenGL Context所支持的API版本也被限制到2.1版本。这显然是不够的。

因此,接下来我们来尝试使用macOS的原生图形接口 —— Cocoa,来完成OpenGL Context的创建工作。

Cocoa是基于Objective-C的一套Apple的独有系统。Objective-C是一种比较古老的语言,是C语言的超集,但是与C++语言又非常不同。作者在写这篇文章之前并没有接触过Objective-C语言,所以这两天查阅了大量的资料来“临时抱佛脚”。这里需要特别感谢 @袁全伟 分享的相关资料整理。我把这些资料会同我自己查阅到的资料都列在了参考引用当中。

实际写下来,感觉Cocoa类似Windows平台上的MFC,或者.NET的感觉,封装还是比较彻底的。好处当然是非常简单易用,但是同时也就意味着很多的细节被隐藏。在我早年学习MFC的时候,想要实现同时期Office所提供的一些很酷的控件效果,就发现非常的不容易。后来学习了Win32 API,发现就能很方便的实现了。这两天对Cocoa的突击学习又让我感觉似乎一下子回到了20多年前,找到了那种有力无处使的感觉。

但是到目前为止我还没有能够找到在macOS上比Cocoa更为底层的API接口。所以我们就将就着用吧。

Cocoa里面至少包含了两个Kit:AppKit和UIKit。AppKit提供了应用程序的框架结构,而UIKit提供了UI组件。最为方便地了解Cocoa的方法是使用XCode生成一个基于Cocoa的应用程序项目,这个过程与使用Visual Studio的向导创建项目非常类似,仅仅需要点击几次鼠标,输入应用程序名什么的,就能够生成一个基本的应用程序(含窗体)的基本结构。

这样一个基本的Cocoa应用(含窗体)项目就生成好了。目录结构大致如下:

.
├── Hello\ Cocoa
│   ├── AppDelegate.h
│   ├── AppDelegate.m
│   ├── Assets.xcassets
│   │   └── AppIcon.appiconset
│   │       └── Contents.json
│   ├── Base.lproj
│   │   └── MainMenu.xib
│   ├── Hello_Cocoa.entitlements
│   ├── Info.plist
│   └── main.m
└── Hello\ Cocoa.xcodeproj
    ├── project.pbxproj
    └── project.xcworkspace
        └── contents.xcworkspacedata

程序的主入口是 main.m,AppDelegate.h和AppDelegate.m定义了应用程序代理类,也就是可以对标准的Cocoa Application进行扩展的地方。Assets.xcassets当中是应用程序的一些资源文件,比如图标什么的。Base.lproj当中是被称为InterfaceBuilder工具的界面定义文件,就是所谓的WYSIWYG(What You See Is What You Get,所见即所得)的图形界面编辑器文件。Hello_Cocoa.entitlements和Info.plist,这两个都是XML格式的类似manifest的文件,用来向操作系统或者是APN等提供程序相关的meta data的。而Hello Cocoa.xcodeproj目录当中的则是Xcode的项目文件。

我们可以继续在Xcode当中编译这个项目并执行。如果采用命令行,那么编译的命令如下(假设我们在项目根目录):

chenwenlideMBP:Hello Cocoa chenwenli$ xcodebuild -project Hello\ Cocoa.xcodeproj/ build

然后执行

chenwenlideMBP:Hello Cocoa chenwenli$ build/Release/Hello\ Cocoa.app/Contents/MacOS/Hello\ Cocoa

就可以看到我们的第一个Cocoa窗口了:

然而,我们需要如何将它和我们之前的代码结合到一起呢?有没有可能将Objective-C和C++混合进行编程呢?

答案是可以的。Objective-C在经过编译之后,生成的二进制文件是与C语言有着同样的二进制接口的。也就是有二进制兼容性(Swift也是一样)。只不过,Object-C的面向对象模型与C++不同,不能简单地将两者等同起来(也就是运行时间库是不一样的)。在macOS全面采用clang作为编译工具之后,由于llvm中间层的存在,Objective-C与C++的互换变得更为方便,甚至出现了Objective-C++这种可以将两者同时写在一个文件当中的“编程语言”(新版本gcc也支持)。不过这里要注意,实际上Objective-C++并不是一种真正的新的语言,书写在源代码当中的Objective-C代码和C++代码其实是相对独立的被编译处理之后又链接在一起的。

好了,那么接下来让我们基于Xcode所生成的模版,按照我们自己所写引擎的架构和逻辑,编写Cocoa版本的Application和OpenGL Context创建代码。(将Objective-C代码嵌入到我们的C++代码当中)

首先我们将XcbApplication.{hpp,cpp}分别拷贝并改名为CocoaApplication.{h,mm}。mm是Objective-C++源代码的后缀。然后编辑如下:

CocoaApplication.h

#include "BaseApplication.hpp"
#include <Cocoa/Cocoa.h>

namespace My {
    class CocoaApplication : public BaseApplication
    {
    public:
        CocoaApplication(GfxConfiguration& config)
            : BaseApplication(config) {};

        virtual int Initialize();
        virtual void Finalize();
        // One cycle of the main loop
        virtual void Tick();

    protected:
        NSWindow* m_pWindow;
    };
}

CocoaApplication.mm

#include <string.h>
#include "CocoaApplication.hpp"
#include "MemoryManager.hpp"
#include "GraphicsManager.hpp"
#include "SceneManager.hpp"
#include "AssetLoader.hpp"

#import <AppDelegate.h>
#import <WindowDelegate.h>

using namespace My;

namespace My {
    GfxConfiguration config(8, 8, 8, 8, 24, 8, 0, 960, 540, "Game Engine From Scratch (MacOS Cocoa)");
    IApplication* g_pApp                = static_cast<IApplication*>(new CocoaApplication(config));
    GraphicsManager* g_pGraphicsManager = static_cast<GraphicsManager*>(new GraphicsManager);
    MemoryManager*   g_pMemoryManager   = static_cast<MemoryManager*>(new MemoryManager);
    AssetLoader*     g_pAssetLoader     = static_cast<AssetLoader*>(new AssetLoader);
    SceneManager*    g_pSceneManager    = static_cast<SceneManager*>(new SceneManager);
}

int CocoaApplication::Initialize()
{
    int result = 0;

    [NSApplication  sharedApplication];

    // Menu
    NSString* appName = [NSString stringWithFormat:@"%s", m_Config.appName];
    id menubar = [[NSMenu alloc] initWithTitle:appName];
    id appMenuItem = [NSMenuItem new];
    [menubar addItem: appMenuItem];
    [NSApp setMainMenu:menubar];

    id appMenu = [NSMenu new];
    id quitMenuItem = [[NSMenuItem alloc] initWithTitle:@"Quit"
        action:@selector(terminate:)
        keyEquivalent:@"q"];
    [appMenu addItem:quitMenuItem];
    [appMenuItem setSubmenu:appMenu];

    id appDelegate = [AppDelegate new];
    [NSApp setDelegate: appDelegate];
    [NSApp activateIgnoringOtherApps:YES];
    [NSApp finishLaunching];

    NSInteger style = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable |
                      NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable;

    m_pWindow = [[NSWindow alloc] initWithContentRect:CGRectMake(0, 0, m_Config.screenWidth, m_Config.screenHeight) styleMask:style backing:NSBackingStoreBuffered defer:NO];
    [m_pWindow setTitle:appName];
    [m_pWindow makeKeyAndOrderFront:nil];
    id winDelegate = [WindowDelegate new];
    [m_pWindow setDelegate:winDelegate];

    return result;
}

void CocoaApplication::Finalize()
{
    [m_pWindow release];
}

void CocoaApplication::Tick()
{
    NSEvent *event = [NSApp nextEventMatchingMask:NSEventMaskAny
    untilDate:nil
    inMode:NSDefaultRunLoopMode
    dequeue:YES];

    switch([(NSEvent *)event type])
    {
        case NSEventTypeKeyDown:
            NSLog(@"Key Down Event Received!");
            break;
        default:
            break;
    }
    [NSApp sendEvent:event];
    [NSApp updateWindows];
    [event release];
}

这里需要特别说明的就是我们去掉了[NSApp run],取而代之在Tick()当中使用了我们自己的EventLoop。这是为了保证我们的主循环在我们自己所写的main函数(最终会在驱动模块)当中。

然后在Platform/Darwin/CMakeLists.txt当中添加如下编译规则:

# MyGameEngineCocoa
add_executable(MyGameEngineCocoa MACOSX_BUNDLE
        CocoaApplication.mm 
        AppDelegate.m
        WindowDelegate.m
    )
find_library(COCOA_LIBRARY Cocoa required)
target_link_libraries(MyGameEngineCocoa 
        Common 
        ${OPENGEX_LIBRARY} 
        ${OPENDDL_LIBRARY} 
        ${XG_LIBRARY} 
        ${COCOA_LIBRARY} 
        )
__add_xg_platform_dependencies(MyGameEngineCocoa)

执行build.sh之后,就会在./build/Platform/Darwin/MyGameEngineCocoa.app/Contents/MacOS/MyGameEngineCocoa当中生成我们的可执行文件。执行它就可以看到我们的窗体了:

看上去不错哦。然后让我们加入对于OpenGL的初始化代码。首先根据参考引用*4,创建两个新文件,GLView.{h,mm},从NSView派生出我们自己的View类:

GLView.h

#import <Cocoa/Cocoa.h>

@interface GLView : NSView
{
    @private
    NSOpenGLContext* _openGLContext;
    NSOpenGLPixelFormat* _pixelFormat;
}

@end

GLView.mm

#import "GLView.h"
#import <OpenGL/gl.h>

@implementation GLView

- (id)initWithFrame:(NSRect)frameRect pixelFormat:(NSOpenGLPixelFormat*)format
{
    self = [super initWithFrame:frameRect];
    return self;
}



- (void)drawRect:(NSRect)dirtyRect {
    [super drawRect:dirtyRect];

    [_openGLContext makeCurrentContext];

    glClearColor(1,0,1,1);
    glClear(GL_COLOR_BUFFER_BIT);

    [_openGLContext flushBuffer];   
}



- (instancetype)initWithFrame:(NSRect)frameRect
{
    self = [super initWithFrame:frameRect];

    NSOpenGLPixelFormatAttribute attrs[] = {
        NSOpenGLPFAOpenGLProfile, NSOpenGLProfileVersion3_2Core,
        NSOpenGLPFAColorSize,32,
        NSOpenGLPFADepthSize,16,
        NSOpenGLPFADoubleBuffer,
        NSOpenGLPFAAccelerated,
        0
    };

    _pixelFormat = [[NSOpenGLPixelFormat alloc] initWithAttributes:attrs];
    if(_pixelFormat == nil)
    {
        NSLog(@"No valid matching OpenGL Pixel Format found");
        return self;
    }

    _openGLContext = [[NSOpenGLContext alloc] initWithFormat:_pixelFormat shareContext:nil];


    [[NSNotificationCenter defaultCenter] addObserver:self
                                            selector:@selector(_surfaceNeedsUpdate:)
                                                name:NSViewGlobalFrameDidChangeNotification
                                               object:self];

    [_openGLContext makeCurrentContext];

    return self;
}


- (void)lockFocus
{
    [super lockFocus];
    if([_openGLContext view]!= self)
    {
        [_openGLContext setView:self];
    }
     [_openGLContext makeCurrentContext];

}

- (void)update
{
    [_openGLContext update];
}

- (void) _surfaceNeedsUpdate:(NSNotification*) notification
{
    [self update];

}




@end

然后再添加两个文件,CocoaOpenGLApplication.{h,mm},从CocoaApplication派生出带有OpenGL Context(GLView)的应用类型,在CocoaApplication的初始化之后,将GLView的实例替换到窗口客户区:

int CocoaOpenGLApplication::Initialize()
{
    int result = 0;

    result = CocoaApplication::Initialize();

    GLView* view = [[GLView alloc] initWithFrame:CGRectMake(0, 0, m_Config.screenWidth, m_Config.screenHeight)];

    [m_pWindow setContentView:view];

    return result;
}

最后是修改CMakeLists.txt。已经很长了,不赘述了。最后运行效果如题图。完成的代码在mac2分支当中。

参考引用

  1. Getting Started – OpenGL Wiki
  2. Programming OpenGL on macOS
  3. Creating a Cocoa application without NIB files
  4. OpenGL for macOS
  5. Drawing to a Window or View
  6. MACOSX_BUNDLE – CMake 3.0.2 Documentation
  7. AppKit | Apple Developer Documentation
  8. Demystifying NSApplication by recreating it
  9. Minimalist Cocoa programming
  10. Objective-C – Wikipedia
  11. Mixing Objective-C, C++ and Objective-C++: an Updated Summary

从零开始手敲次世代游戏引擎(macOS特别篇)

双十一快到了,不知道大家今年准备剁手购买什么。作者一直心念着之前提出想要在MacOS上面学习本教程的同学,双十一剁手入了一个MacPro本,摸了2天来更新这篇文章。

(在此之前作者从来未使用过Mac。所以写的不对请不吝赐教)

根据资料(*1)显示,MacOS从版本10开始基于一个被称为XNU的内核(Darwin达尔文系统)。这个内核是基于BSD系统的。BSD是一种Unix系统,而Linux是模仿Unix系统开发的。所以我们之前写的Linux版本应该是可以比较容易地在MacOS上面编译的。是不是这样,有多容易,我们实际来试一下。

首先我们需要安装编译环境。之前我们说过,MacOSX之后标准是使用clang。但是这个环境缺省是没有安装到系统当中的。最为简单的安装方法是安装Xcode。我们可以在Apple Store里面方便的找到它并安装。

因为我们是要工作在命令行,安装完成之后,我们需要打开命令行窗口安装Xcode的命令行工具。在MacOS打开一个程序最快的方法是使用Spotlight搜索,我们按下()键和空格键,然后输入term,按下回车键。

然后输入

xcode-select --install

然后根据屏幕提示完成安装。

接下来是安装git/cmake等工具。在Mac上面安装GNU软件似乎一般有两个方式,一个是通过一个叫做HomeBrew的包管理,而另外一个则是使用MacPort。我首先是试了一下HomeBrew,这个好像是基于ruby的,里面可以用的包很少,比如找不到libxcb。所以后来我又换成了MacPort。MacPort就是Port的Mac版,而Port是BSD系统当中的包管理器,就相当于Linux系统当中的APT或者YUM工具。只不过Port是下载源代码到本地进行编译安装的,而不是直接下载二进制包进行安装。(所以我们需要先安装Xcode命令行工具)

MacPort的安装方法在MacPort的网站上有详细介绍。需要注意的就是必须根据Mac系统的版本下载对应的包。不同的版本之间是不兼容的。

装好MacPort之后,在命令行运行

sudo port selfupdate
sudo port install git git-lfs cmake

就可以完成相关工具的安装。

然后我们就可以尝试checkout我们的代码并进行编译了。首先我们尝试编译我们的第三方依赖库,crossguid。运行项目根目录下的build_crossguid.sh,直接编译成功了。

接下来我们尝试编译OpenGEX,运行项目根目录下的build_opengex.sh,也是一次成功。

然后我们编译我们的引擎。这里主要有几个问题:

  1. 首先是找不到ispc。这主要是由于我们CMake文件的写法导致的(CMAKE_SYSTEM_NAME变成了Darwin而不是Linux)。调整之后找到。可以直接使用Linux下编译的ispc的二进制文件,也可以在MacOS下面重新编译一个。编译的脚本在External/src/ispc下面(如果是空目录请在项目根目录执行
    git submodule update –init External/src/ispc
    获取源代码和编译脚本)只不过需要注意的是Xcode缺省安装的toolchain似乎并不包括llvm相关的库,而编译ispc需要这些库。我的做法是直接用项目根目录下的build_llvm_clang.sh脚本重新完整编译了一套llvm工具出来。应该也可以在Xcode里面安装这些库,只不过我没有去调查了;
  2. 提示找不到xcb、X11等库。这个问题又可以分两个方面:
  1. 没有X11 Server。MacOS是使用的自己的图形服务器(Quartz)和图形库(Cocoa)。在老的版本当中也同时提供了X11 Server,但是好像从OS X开始X11被剥离到一个名为XQuartz的项目当中,并在GitHub开源了(*2)。所以需要安装XQuartz。安装过程请参考GitHub相关项目页面;
  2. 没有xcb、X11等库。这个通过port安装就可以了。需要安装的包名称为:
    xorg-libxcb
    xorg-libX11

好了。进行了这些准备之后,代码就可以完成编译了(执行build.sh)。跑一下Test目录下的测试,大部分没有问题,但是GeomMathTest的部分结果出现问题。如果在cmake之后指定‘-G “Xcode”’,则最后的可执行文件的链接会出错,提示libGeomMath.a的符号表有问题。经过调试发现是我们生成libGeomMath.a的时候只是使用ar工具将obj文件压成了库,但是没有执行ranlib工具来更新库当中的符号表。如下修改Framework/Geommath/ispc/CMakeLists.txt之后问题得到解决:

add_custom_command(OUTPUT ${GEOMMATH_LIB_FILE}
         COMMAND ${CMAKE_AR} ${ISPC_LIBRARIAN_OPTIONS} ${OBJECTS}
+        COMMAND ${CMAKE_RANLIB} ${GEOMMATH_LIB_FILE}
         COMMAND rm -v ${OBJECTS}
         DEPENDS ${OBJECTS}
         )

最后的问题是编译虽然通过了,但是执行./build/Platform/Darwin/MyGameEngineOpenGL会直接发生段错误而强行关闭。如下:

chenwenlideMacBook-Pro:GameEngineFromScratchPrivate chenwenli$ ./build/Platform/Darwin/MyGameEngineOpenGL 
Segmentation fault: 11

使用lldb工具进行调试,发现错误信息如下:

Process 51269 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
    frame #0: 0x0000000000000000
error: memory read failed for 0x0
Target 0: (MyGameEngineOpenGL) stopped.
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
  * frame #0: 0x0000000000000000
    frame #1: 0x0000000100002a9e MyGameEngineOpenGL`My::OpenGLApplication::Initialize(this=0x0000000100701410) at OpenGLApplication.cpp:106
    frame #2: 0x0000000100043a29 MyGameEngineOpenGL`main(argc=1, argv=0x00007ffeefbffa48) at main.cpp:7
    frame #3: 0x00007fff6ad71145 libdyld.dylib`start + 1
    frame #4: 0x00007fff6ad71145 libdyld.dylib`start + 1

出错位置是在OpenGLApplication.cpp:106,使用l命令打出代码:

(lldb) l OpenGLApplication.cpp:106
   96  	    {
   97  	        fprintf(stderr, "Can't open display\n");
   98  	        return -1;
   99  	    }
   100 	
   101 	    default_screen = DefaultScreen(m_pDisplay);
   102 	
   103 	    gladLoadGLX(m_pDisplay, default_screen);
   104 	
   105 	    /* Query framebuffer configurations */
   106 	    fb_configs = glXChooseFBConfig(m_pDisplay, default_screen, visual_attribs, &num_fb_configs);

继续跟踪可以发现是相关的glX函数指针未被glad正确加载造成的。这个问题的解决方法现在还在摸索当中。。。(已经解决了,看更新2)

# 对了,代码在“mac”分支(branch)当中。

#更新1: 有进展了,从零开始手敲次世代游戏引擎(十二)当中的代码可以正常出图了:

在MacOS下编译的命令与Linux下基本相同,但是需要加入头文件和库的查找路径。

分两种情况:

  • 如果是直接安装的XQuartz的dmg的包,那么是下面这样:
chenwenlideMBP:GameEngineFromScratchPrivate chenwenli$ clang -I /usr/X11/include/ -L /usr/X11/lib -o helloengine_opengl helloengine_opengl.cpp -lxcb -lX11 -lX11-xcb -lGL -lGLU
  • 如果是通过macports安装的XQuatz(xorg-server),那么还需要安装libxcb, libX11, libX11-xcb, libGL, libGLU等几个包。这里面需要注意的是libGL是包括在mesa驱动包里的。一个简单的方法是直接用 sudo ports install glxgears这个命令安装glxgears这个测试程序,会自动安装GLX相关的包,然后再用sudo ports install libGLU补上libGLU就好了 。

仔细观察绘制出的图形,会发现与从零开始手敲次世代游戏引擎(十二)题图有一些区别。(有一条明显的对角线)

根据参考引用(*4),这是因为在Mac OS当中,通过XQuartz能够支持的OpenGL只能到版本2.1。所以,对于四边形的绘制,其实是拆成两个三角形绘制的,形成了明显的对角线。如果要使用版本3之上的OpenGL,必须使用Mac OS原生的图形服务(Quartz,注意少个X)和图形API(Cocoa)创建绘图Context,而不是X11 + GLX。其实在上面的过程当中也可以看到,MacPorts安装的X11是基于mesa驱动的,这个是一个开源社区的驱动,不是A/N卡原厂驱动(好吧,我的Mac其实是15年产品是I卡…但是能支持4.1),所以能够支持的功能是有限的。Mac OS原生的图形API叫Cocoa,是一个基于Object-C语言的库。另外最近还有一个新的Swift语言可以用。这部分等我自己搞明白了更新上来。

#更新2 Segmentation Fault的问题解决了。原因是External/glad/src/glad_glx.c当中,当操作系统为MacOS的时候,是按照如下的顺序查找OpenGL的动态库的(其中绿色的两行是我加上的):

diff --git a/External/src/glad/src/glad_glx.c b/External/src/glad/src/glad_glx.c
index 9551033..af0f61e 100644
--- a/External/src/glad/src/glad_glx.c
+++ b/External/src/glad/src/glad_glx.c
@@ -127,6 +127,8 @@ static
 int open_gl(void) {
 #ifdef __APPLE__
     static const char *NAMES[] = {
+        "/opt/local/lib/libGL.1.dylib",
+        "/opt/local/lib/libGL.dylib",
         "../Frameworks/OpenGL.framework/OpenGL",
         "/Library/Frameworks/OpenGL.framework/OpenGL",
         "/System/Library/Frameworks/OpenGL.framework/OpenGL",

原来的3行实际上加载的都是MacOS自带的OpenGL库。这个库是没有GLX相关的接口的。查看的方法是使用nm命令,如下:

nm -gU "/System/Library/Frameworks/OpenGL.framework/OpenGL"
00000000000053c8 T _CGLAreContextsShared
000000000000546b T _CGLBackDispatch
0000000000008b68 T _CGLChoosePixelFormat
0000000000006b46 T _CGLClearDrawable
00000000000052e3 T _CGLCopyContext
0000000000004620 T _CGLCreateContext
000000000000aa9e T _CGLCreatePBuffer
000000000000ad12 T _CGLDescribePBuffer
000000000000a584 T _CGLDescribePixelFormat
000000000000b6cd T _CGLDescribeRenderer
0000000000004381 T _CGLDestroyContext
000000000000adba T _CGLDestroyPBuffer
000000000000a0e8 T _CGLDestroyPixelFormat
000000000000b69e T _CGLDestroyRendererInfo
000000000000ccd2 T _CGLDisable
000000000000cc47 T _CGLEnable
0000000000008b2b T _CGLErrorString
000000000000d058 T _CGLFlushDrawable
0000000000005461 T _CGLFrontDispatch
000000000000528d T _CGLGetContextRetainCount
0000000000006a95 T _CGLGetDeviceFromGLRenderer
000000000000a8c8 T _CGLGetGlobalOption
0000000000005436 T _CGLGetNextContext
00000000000085a0 T _CGLGetOffScreen
000000000000aa45 T _CGLGetOption
00000000000077ef T _CGLGetPBuffer
000000000000af7c T _CGLGetPBufferRetainCount
000000000000cb70 T _CGLGetParameter
00000000000052b5 T _CGLGetPixelFormat
000000000000a1b4 T _CGLGetPixelFormatRetainCount
0000000000006a2d T _CGLGetShareGroup
0000000000007c21 T _CGLGetSurface
000000000000d0b3 T _CGLGetVersion
00000000000044b8 T _CGLGetVirtualScreen
000000000000ca68 T _CGLIsEnabled
000000000000a5d6 T _CGLLockContext
0000000000006024 T _CGLOpenCLMuxLockDown
000000000000b30c T _CGLQueryRendererInfo
00000000000050ef T _CGLReleaseContext
000000000000af4a T _CGLReleasePBuffer
000000000000a182 T _CGLReleasePixelFormat
0000000000005533 T _CGLRestoreDispatch
000000000000556a T _CGLRestoreDispatchFunction
0000000000005263 T _CGLRetainContext
000000000000af6a T _CGLRetainPBuffer
000000000000a1a2 T _CGLRetainPixelFormat
0000000000005480 T _CGLSelectDispatch
00000000000054ba T _CGLSelectDispatchBounded
0000000000005501 T _CGLSelectDispatchFunction
0000000000007cda T _CGLSetFullScreen
00000000000082e0 T _CGLSetFullScreenOnDisplay
000000000000a6a0 T _CGLSetGlobalOption
0000000000008403 T _CGLSetOffScreen
000000000000a9e0 T _CGLSetOption
0000000000006c25 T _CGLSetPBuffer
000000000000b24c T _CGLSetPBufferVolatileState
000000000000cd5d T _CGLSetParameter
000000000000788f T _CGLSetSurface
000000000000455c T _CGLSetVirtualScreen
000000000000b095 T _CGLTexImageIOSurface2D
000000000000af8e T _CGLTexImagePBuffer
000000000000a608 T _CGLUnlockContext
0000000000008671 T _CGLUpdateContext
000000000000a1c6 T _GLCDescribePixelFormat
0000000000007758 T _GLCGetPBuffer
000000000000ce49 T _GLCGetParameter
0000000000001fae T _GLCGetProfilerStorage
0000000000007be2 T _GLCGetSurface
0000000000004405 T _GLCGetVirtualScreen
0000000000001f83 T _GLCSetProfilerStorage
0000000000006050 T _cglBadApplicationNotMuxAwareLockDown
0000000000001870 T _glcDebugListener
00000000000029c1 T _glcGetIOAccelService
0000000000001fe8 T _glcNoop
00000000000027b1 T _glcPluginConnect
0000000000001ff0 T _glcPluginCount
00000000000027e1 T _glcPluginDisconnect
0000000000008b5f T _glcRecordError

可以看到很多CGL的接口,这个才是macOS原生的OpenGL接口。

所以通过追加(也可以替换)到我们通过MacPorts安装的库的路径,就可以解决这个问题。注意如果是直接安装的XQurtz的dmg包而不是通过MacPorts安装的库,那么路径应该是/usr/X11而不是/opt/local

参考引用

  1. macOS – Wikipedia
  2. XQuartz
  3. The MacPorts Project
  4. Overview of OpenGL Support on OS X
  5. CMake, OpenGL, and GLX on OS X

从零开始手敲次世代游戏引擎(卅)

本篇我们来对接OpenGEX。不过上一篇我们留了一个尾巴没写,就是SceneNode的结构。这里补上。

在Framework/Common/SceneNode.hpp当中,首先我们定义一个SceneNode的基类,它拥有一个名字,一个存储子节点的链表,和一个存储矩阵的链表:

  class BaseSceneNode {
        protected:                                                                                                                                                       std::string m_strName;
            std::list<std::unique_ptr<BaseSceneNode>> m_Children;
            std::list<std::unique_ptr<SceneObjectTransform>> m_Transforms;

        public:
            BaseSceneNode() {};                                                                                                                                          BaseSceneNode(const char* name) { m_strName = name; };
            BaseSceneNode(const std::string& name) { m_strName = name; };
            BaseSceneNode(const std::string&& name) { m_strName = std::move(name); };
            virtual ~BaseSceneNode() {};

            void AppendChild(std::unique_ptr<BaseSceneNode>&& sub_node)
            {
                m_Children.push_back(std::move(sub_node));
            }

            void AppendChild(std::unique_ptr<SceneObjectTransform>&& transform)
            {
                m_Transforms.push_back(std::move(transform));
            }
    };

这个基类其实就是一个标准的树(Tree)结构的节点。

然后,我们定义一个模板,从上面的基类进行派生,并加入一个名为m_pSceneObject的成员变量,代表场景对象在节点图上的挂载点:

  template <typename T>
    class SceneNode : public BaseSceneNode {
        protected:
            std::shared_ptr<T> m_pSceneObject;

        public:
            using BaseSceneNode::BaseSceneNode;
            SceneNode() = default;
            SceneNode(const std::shared_ptr<T>& object) { m_pSceneObject = object; };
            SceneNode(const std::shared_ptr<T>&& object) { m_pSceneObject = std::move(object); };

            void AddSceneObjectRef(const std::shared_ptr<T>& object) { m_pSceneObject = object; };

    };

接下来就是对这个模板进行各种场景对象类型的特化,比如场景几何体:

  typedef BaseSceneNode SceneEmptyNode;
    class SceneGeometryNode : public SceneNode<SceneObjectGeometry>
    {
        protected:
            bool        m_bVisible;
            bool        m_bShadow;
            bool        m_bMotionBlur;

        public:
            using SceneNode::SceneNode;

            void SetVisibility(bool visible) { m_bVisible = visible; };
            const bool Visible() { return m_bVisible; };
            void SetIfCastShadow(bool shadow) { m_bShadow = shadow; };
            const bool CastShadow() { return m_bShadow; };
            void SetIfMotionBlur(bool motion_blur) { m_bMotionBlur = motion_blur; };
            const bool MotionBlur() { return m_bMotionBlur; };
    };

光照:

  class SceneLightNode : public SceneNode<SceneObjectLight>
    {
        protected:
            Vector3f m_Target;

        public:
            using SceneNode::SceneNode;
            
            void SetTarget(Vector3f& target) { m_Target = target; };
            const Vector3f& GetTarget() { return m_Target; };
    };

都是类似的结构,我就不在这里一一赘述了。

————————————————————————————————-

好了,现在我们开始导入OpenGEX。

首先我们需要导入OpenGEX官方网站提供的OpenGEX导入模板。这个模板可以在这里下载:

http://www.opengex.org/OpenGex-Import.zip

将这个模板展开到项目的External/src目录下,然后进行适当的修改。主要需要修改的地方有这么几个:

  1. 去掉OpenGEX.cpp当中的WinMain函数,因为我们是将其作为一个库使用
  2. 在OpenGEX.h当中把我们感兴趣的一些数据结构通过Public方法暴露出来

另外代码需要做一些修正才能在多平台上面编译。修改后的代码我也放在了GitHub上面,有需要的可以参考。

netwarm007/OpenGEX

也可以直接在本项目的根目录下(branch article_30)直接运行build_opengex.bat(linux环境的话运行build_opengex.sh)这个我事先写好的脚本,直接以submodule方式将修改后的代码下载到External/src/opengex之下,并进行编译。

上一篇文章我们导入了crossguid这个外部库,这篇文章我们导入了OpenGEX。加上之前我们导入的ispc,目前我们的项目已经有了不少的外部倚赖关系。为了方便部分初级读者学习,我也把编译好的这些库加入到了Git Repo里面(在External/Windows或者External/Linux之下),因此如果仅仅是想编译我们开发的引擎本体,可以直接运行(build.bat (Windows) 或者 build.sh (Linux))进行编译。根目录下有各种外部库的编译脚本,想要自己重新创建外部依赖库的,也可以用这些脚本重新编译,包括LLVM/Clang。

在编译好OpenGEX库之后,我们就可以开始将其接入到我们的引擎当中。因为OpenGEX对于我们的引擎来说只是一种外部的场景文件格式,就类似于我们之前所写的BMP文件解析器,因此首先是在Framework/Interface之下创建一个名为SceneParser.hpp的头文件,定义一个通用的场景文件解析器入口给我们的引擎(场景管理模块)调用:

#pragma once
#include <memory>
#include <string>
#include "Interface.hpp"
#include "SceneNode.hpp"

namespace My {
    Interface SceneParser
    {
    public:
        virtual std::unique_ptr<BaseSceneNode> Parse(const std::string& buf) = 0;
    };
}

然后在Framework/Parser之下(注意我这里将之前的Framework/Codec目录改名为了Framework/Parser,以便更好地识别这个目录里代码的功用)创建OGEX.hpp,具体实现OpenGEX场景文件的解析器(篇幅原因,代码有大量删节,具体请上GitHub查看):

#include <unordered_map>
#include "OpenGEX.h"
#include "portable.hpp"
#include "SceneParser.hpp"

namespace My {
    class OgexParser : implements SceneParser
    {
    private:
        std::unordered_map<std::string, std::shared_ptr<BaseSceneObject>> m_SceneObjects;

    private:
        void ConvertOddlStructureToSceneNode(const ODDL::Structure& structure, std::unique_ptr<BaseSceneNode>& base_node)
        {
            std::unique_ptr<BaseSceneNode> node;

            switch(structure.GetStructureType()) {
                case OGEX::kStructureNode:
                    {
                        ...
                    break;
                case OGEX::kStructureGeometryNode:
                    {
                       ...
                    break;
                case OGEX::kStructureLightNode:
                    {
                        ...
                    }
                    break;
                case OGEX::kStructureCameraNode:
                    {
                        ...
                    }
                    break;
                case OGEX::kStructureGeometryObject:
                    {
                        ...
                    }
                    break;
                case OGEX::kStructureTransform:
                    {
                        ...
                    }
                    return;   
                default:                                                                                                                                               default:
                    // just ignore it and finish
                    return;
            };

            const ODDL::Structure* sub_structure = structure.GetFirstSubnode();
            while (sub_structure)
            {
                ConvertOddlStructureToSceneNode(*sub_structure, node);

                sub_structure = sub_structure->Next();
            }

            base_node->AppendChild(std::move(node));
        }
    public:
        virtual std::unique_ptr<BaseSceneNode> Parse(const std::string& buf)
        {
            std::unique_ptr<BaseSceneNode> root_node (new BaseSceneNode("scene_root"));
            OGEX::OpenGexDataDescription  openGexDataDescription;

            ODDL::DataResult result = openGexDataDescription.ProcessText(buf.c_str());
            if (result == ODDL::kDataOkay)
            {
                const ODDL::Structure* structure = openGexDataDescription.GetRootStructure()->GetFirstSubnode();
                while (structure)
                {
                    ConvertOddlStructureToSceneNode(*structure, root_node);

                    structure = structure->Next();
                }
            }

            return std::move(root_node);
        }
    };
}

上面这段代码总的来说就是递归迭代来遍历OpenGEX导入模板所创建的场景图(树),然后将我们感兴趣的数据结构复制到我们之前所定义的场景结构与节点图当中。

接下来我们开始准备测试我们所写的OpenGEX场景文件解析代码,同时也是测试我们在从零开始手敲次世代游戏引擎(二十八)以及从零开始手敲次世代游戏引擎(二十九)所写的SceneObject.hpp和SceneNode.hpp这两个核心的场景数据结构文件。OpenGEX官方网站提供的导入包里面有一个Example.ogex文件,我们将其拷贝到项目的Asset/Scene/ 目录下,然后在项目的Test目录(这也是一个新目录,我将之前写的所有与平台无关的测试用代码都转移到了这里)当中,创建OgexParserTest.cpp,在其中通过我们在从零开始手敲次世代游戏引擎(二十五)写的AssetLoader加载这个文本文件到内存,然后通过我们上面刚刚写出来的OgexParser对其进行解析,生成我们所设计编写的SceneObject和SceneNode场景结构,然后将其以文本方式打印出来:

#include <iostream>
#include <string>
#include "AssetLoader.hpp"
#include "MemoryManager.hpp"
#include "OGEX.hpp"

using namespace My;
using namespace std;

namespace My {
    MemoryManager* g_pMemoryManager = new MemoryManager();
}                                                                                                                                                            
int main(int , char** )
{
    g_pMemoryManager->Initialize();

    AssetLoader asset_loader;
    string ogex_text = asset_loader.SyncOpenAndReadTextFileToString("Scene/Example.ogex");

    OgexParser* ogex_parser = new OgexParser ();                                                                                                                 unique_ptr<BaseSceneNode> root_node = ogex_parser->Parse(ogex_text);
    delete ogex_parser;

    cout << *root_node << endl;

    g_pMemoryManager->Finalize();

    delete g_pMemoryManager;

    return 0;
}

使用build.bat (windows) 或者 build.sh (linux)编译之后,运行结果如下:

F:\source\repos\GameEngineFromScratch>build\Test\Debug\OgexParserTest.exe
 Scene Node
 ----------
 Name: scene_root

  Scene Node
  ----------
  Name: node1
Mesh: SceneObject
-----------
GUID: 4da5a87f-3c1f-4287-afb0-cc674e49efa5
Type: MESH

Primitive Type: TLST
This mesh contains 0x3 vertex properties.
Attribute: position
Morph Target Index: 0x0
Data Type: FLT3
Data Size: 0x48
Data: -52.019 -51.0689 0 -52.019 51.0689 0 52.019 51.0689 0 52.019 -51.0689 0 -52.019 -51.0689 93.1116 52.019 -51.0689 93.1116 52.019 51.0689 93.1116 -52.019 51.0689 93.1116 -52.019 -51.0689 0 52.019 -51.0689 0 52.019 -51.0689 93.1116 -52.019 -51.0689 93.1116 52.019 -51.0689 0 52.019 51.0689 0 52.019 51.0689 93.1116 52.019 -51.0689 93.1116 52.019 51.0689 0 -52.019 51.0689 0 -52.019 51.0689 93.1116 52.019 51.0689 93.1116 -52.019 51.0689 0 -52.019 -51.0689 0 -52.019 -51.0689 93.1116 -52.019 51.0689 93.1116
Attribute: normal
Morph Target Index: 0x0
Data Type: FLT3
Data Size: 0x48
Data: 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 1 0 0 1 0 0 1 0 0 1 0 -1 0 0 -1 0 0 -1 0 -0 -1 0 1 0 0 1 0 0 1 0 0 1 0 0 0 1 0 0 1 0 0 1 0 -0 1 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0
Attribute: texcoord
Morph Target Index: 0x0
Data Type: FLT2
Data Size: 0x30
Data: 1 0 1 1 0 1 0 0 0 0 1 0 1 1 0 1 0 0 1 0 1 1 0 1 0 0 1 0 1 1 0 1 0 0 1 0 1 1 0 1 0 0 1 0 1 1 0 1
This mesh contains 0x1 index arrays.
Material Index: 0x0
Restart Index: 0x0
Data Type: I32
Data Size: 0x24
Data: 0x0 0x1 0x2 0x2 0x3 0x0 0x4 0x5 0x6 0x6 0x7 0x4 0x8 0x9 0xa 0xa 0xb 0x8 0xc 0xd 0xe 0xe 0xf 0xc 0x10 0x11 0x12 0x12 0x13 0x10 0x14 0x15 0x16 0x16 0x17 0x14
Visible: 1
Shadow: 1
Motion Blur: 1


Visible: 1
Shadow: 1
Motion Blur: 1

Transform Matrix:
1,0,0,0
0,1,0,0
0,0,1,0
-0.47506,9.50119,0,1


Is Object Local: 0


  Scene Node
  ----------
  Name: node2
Mesh: SceneObject
-----------
GUID: 4da5a87f-3c1f-4287-afb0-cc674e49efa5
Type: MESH

Primitive Type: TLST
This mesh contains 0x3 vertex properties.
Attribute: position
Morph Target Index: 0x0
Data Type: FLT3
Data Size: 0x48
Data: -52.019 -51.0689 0 -52.019 51.0689 0 52.019 51.0689 0 52.019 -51.0689 0 -52.019 -51.0689 93.1116 52.019 -51.0689 93.1116 52.019 51.0689 93.1116 -52.019 51.0689 93.1116 -52.019 -51.0689 0 52.019 -51.0689 0 52.019 -51.0689 93.1116 -52.019 -51.0689 93.1116 52.019 -51.0689 0 52.019 51.0689 0 52.019 51.0689 93.1116 52.019 -51.0689 93.1116 52.019 51.0689 0 -52.019 51.0689 0 -52.019 51.0689 93.1116 52.019 51.0689 93.1116 -52.019 51.0689 0 -52.019 -51.0689 0 -52.019 -51.0689 93.1116 -52.019 51.0689 93.1116
Attribute: normal
Morph Target Index: 0x0
Data Type: FLT3
Data Size: 0x48
Data: 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 1 0 0 1 0 0 1 0 0 1 0 -1 0 0 -1 0 0 -1 0 -0 -1 0 1 0 0 1 0 0 1 0 0 1 0 0 0 1 0 0 1 0 0 1 0 -0 1 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0
Attribute: texcoord
Morph Target Index: 0x0
Data Type: FLT2
Data Size: 0x30
Data: 1 0 1 1 0 1 0 0 0 0 1 0 1 1 0 1 0 0 1 0 1 1 0 1 0 0 1 0 1 1 0 1 0 0 1 0 1 1 0 1 0 0 1 0 1 1 0 1
This mesh contains 0x1 index arrays.
Material Index: 0x0
Restart Index: 0x0
Data Type: I32
Data Size: 0x24
Data: 0x0 0x1 0x2 0x2 0x3 0x0 0x4 0x5 0x6 0x6 0x7 0x4 0x8 0x9 0xa 0xa 0xb 0x8 0xc 0xd 0xe 0xe 0xf 0xc 0x10 0x11 0x12 0x12 0x13 0x10 0x14 0x15 0x16 0x16 0x17 0x14
Visible: 1
Shadow: 1
Motion Blur: 1


Visible: 1
Shadow: 1
Motion Blur: 1

Transform Matrix:
1,0,0,0
0,1,0,0
0,0,1,0
132.079,9.50119,0,1


Is Object Local: 0

将此输出与Example.ogex的内容进行对比,我们可以看到导入是成功的。

参考引用

  1. http://www.opengex.org/OpenGEX.2.0.pdf

从零开始手敲次世代游戏引擎(二十九)

好的,我们继续进行场景结构的编写。

如同上一篇预告的,首先让我们来编写存储材质的数据结构。首先上代码∶

  class SceneObjectMaterial : public BaseSceneObject
    {
        protected:
            Color       m_BaseColor;
            Parameter   m_Metallic;
            Parameter   m_Roughness;
            Normal      m_Normal;
            Parameter   m_Specular;
            Parameter   m_AmbientOcclusion;

        public:
            SceneObjectMaterial() : BaseSceneObject(SceneObjectType::kSceneObjectTypeMaterial) {};
    };

材质的模型有很多种,在游戏当中常用的是经典的高光漫反射模型,还有近年流行的基于物理的PBR模型。上面这个例子就是PBR模型。

计算机图形学当中严格意义上的材质模型是指物体表面对于来自各个方向的光线的反射折射以及吸收的数学模型。多为解析形式或者隐函数模式。游戏当中使用的材质的含义则更为宽泛,而且一般为离散采样样本数据和合成公式的模式,有的时候也被称为是基于图像的渲染方式(Image Based Rendering)

这是因为游戏作为一种交互式数字艺术作品,并不严格追求所渲染的图像的正确性。而基于解析计算的渲染方法目前计算量都很大,而且包含很多逻辑判断与分支,难以实现GPU的批量化计算。而基于图像的渲染则是将事先准备好的图片(贴图)按照一定的规律进行组合输出结果,这种方式特别适合并行计算,而且结果“看起来”也能够相当地不错。

使用过近代DCC工具的人应该都知道,DCC工具当中的材质,除了极少的一部分之外,大多也是基于图像的。这种材质的特点是,可以为材质的每个属性(或者说通道)指定一个固定的值,或者是一张贴图。通过巧妙地组合这些贴图和通道,可以形成各种各样十分有趣或者十分逼真的材质效果,比如生锈、浸润、污渍、蚀刻、风化、毛绒、伤疤等等。所以我们的材质属性也需要支持单值输入的情况,和贴图输入的情况。这是通过定义如下的复合数据类型来实现的∶

  template <typename T>
    struct ParameterMap
    {
        bool bUsingSingleValue = true;

        union _ParameterMap {
            T Value;
            std::shared_ptr<Image> Map;
        };
    };

    typedef ParameterMap<Vector4f> Color;
    typedef ParameterMap<Vector3f> Normal;
    typedef ParameterMap<float>    Parameter;

好,接下来让我们看看灯光。游戏当中常用的灯光有泛光灯(也叫点状光源,白炽灯)、射灯、天光等。泛光灯是向其周边360度球形空间均匀辐射光线的灯,也就是无指向的灯;射灯是向特定方向进行一个光锥照射的灯,比如舞台上打的那种追光灯,或者汽车的大灯;天光则是平行光源。

无论是哪种灯,首先都有个自身的亮度或者说光线密度属性,然后会有一个光线衰减函数。虽然在物理学当中,光线是按照距离的平方倍进行衰减的,但是在游戏制作当中这样的衰减很不容易控制,而且往往衰减得过快。所以,如同我们在很多DCC工具当中看到的,我们往往是通过指定一个近裁剪平面,一个远裁剪平面和一个近似的光衰减函数来控制光照的效果。所以我们的光照基类的定义如下∶

  class SceneObjectLight : public BaseSceneObject
    {
        protected:
            Color       m_LightColor;
            float       m_Intensity;
            AttenFunc   m_LightAttenuation;
            float       m_fNearClipDistance;
            float       m_fFarClipDistance;
            bool        m_bCastShadows;

        protected:
            // can only be used as base class of delivered lighting objects
            SceneObjectLight() : BaseSceneObject(SceneObjectType::kSceneObj
ectTypeLight) {};
    };

这里面上面没有提到的就是m_bCastShadows。这是用来标识光源是否会产生阴影的。阴影的形成因为要做以光源为视点的场景投影与排序,对于实时渲染来说是很昂贵的。

我们如果用过近代的一些游戏引擎,我们会看到除了上面这些属性之外,还会有诸如光源是否会移动等诸多其它属性。这些属性更多的是为了优化光照计算性能存在的,我们在今后相应的地方再进行拓展,这里先不介绍了。

接下来我们就可以从这个基类进行派生,定义各种具体的光源类型了∶

  class SceneObjectOmniLight : public SceneObjectLight
    {
        public:
            using SceneObjectLight::SceneObjectLight;
    };

    class SceneObjectSpotLight : public SceneObjectLight
    {
        protected:
            float   m_fConeAngle;
            float   m_fPenumbraAngle;
        public:
            using SceneObjectLight::SceneObjectLight;
    };

可以看到泛光灯最简单,基本就是基类本身;射灯则多了两个光锥顶角相关的参数。

这里可能有人会问,射灯的方向的定义是不是被遗漏了?回答∶是的,但是是故意的。我们的之前的设计是,场景对象当中只保存场景对象固有的属性,而与场景结构或者空间位置相关的参数是保存在场景节点而不是场景对象当中的。在游戏当中往往会采用追光灯的设计,也就是跟着某个对象跑的射灯。但是被跟随的对象的空间位置只有场景节点和场景图这一级的信息当中才有,将这个信息写到场景对象里面是不合适的。

关于光照函数我们则是如下定义的∶

  typedef float (*AttenFunc)(float /* Intensity */, float /* Distance */);

就是一个输入为光源的强度和到光源的距离,输出为光照强度的函数的指针。

最后是摄像机的部分了。摄像机和射灯某种意义类似,位置和方向我们放在节点当中处理,作为场景对象主要需要记录的是纵横比,近裁剪距离和远裁剪距离。对于透视相机还需要记录fov,即视角。

  class SceneObjectCamera : public BaseSceneObject
    {
        protected:
            float m_fAspect;
            float m_fNearClipDistance;
            float m_fFarClipDistance;
        public:
            SceneObjectCamera() : BaseSceneObject(SceneObjectType::kSceneObjectTypeCamera) {};
    };

    class SceneObjectOrthogonalCamera : public SceneObjectCamera
    {
    };

    class SceneObjectPerspectiveCamera : public SceneObjectCamera
    {
        protected:
            float m_fFov;
    };

正交相机是指没有透视到相机,一般用于在3D空间当中制作2D游戏。而透视相机的fov则是表示了相机的视场角,大的视场角相当于广角相机,小的则相当于长焦。当然,仅仅是这样的话是没有景深效果的。景深效果是由于凸透镜成像造成的,而透视相机只是相当于在正交相机上多乘一个仿射矩阵(透视矩阵)P,每个像素依然是单根光线采样决定其颜色值,是和理想的小孔成像一样的。

好了,到这里场景对象的主要结构就完成了。对比前面的设计,我们还有诸如变形对象,贴图对象,动画对象,矩阵对象等没有完成。不过这些对于渲染一个基本的场景来说是不需要或者可以用别的方法代替的,就不在这里一一表述了。下面我们进入场景结构(场景图),并且对接OpenGEX,导入一个实际的场景并进行画面的渲染。

从零开始手敲次世代游戏引擎(二十八)

现在让我们开始按照上一篇的设计,在Framework/Common下面分别创建SceneObject.hpp和SceneNode.hpp,进行场景物体与场景结构的定义。

在上一篇当中,我们将场景分解为表述结构和空间位置信息的场景图,以及表述具体行为性质的场景对象两个大分类。这样的分类方式的最大好处是将场景对象的固有属性与其在场景当中的配置解耦出来,从而可以用较少的场景物体构建较为复杂的场景。

所谓在场景当中的配置,包括空间和时间两个方面的概念。空间是指对场景对象所做的平移、旋转、缩放;而时间则是指沿着时间轴对对象所作的改变,也就是动画。

为了从代码上进行这样的保证,我们首先创建一个不可复制的场景对象基类BaseSceneObject,并将其构造函数声明为protected模式,以防止直接创建其实例∶

   class BaseSceneObject
    {
        protected:
            Guid m_Guid;
            SceneObjectType m_Type;
        protected:
            // can only be used as base class
            BaseSceneObject(Guid& guid, SceneObjectType type) : m_Guid(guid)
 m_Type(type) {};
            BaseSceneObject(Guid&& guid, SceneObjectType type) : m_Guid(std:
move(guid)), m_Type(type) {};
            BaseSceneObject(BaseSceneObject&& obj) : m_Guid(std::move(obj.m_
uid)), m_Type(obj.m_Type) {};
            BaseSceneObject& operator=(BaseSceneObject&& obj) { this->m_Guid
= std::move(obj.m_Guid); this->m_Type = obj.m_Type; return *this; };

     private:
            // no default constructor
            BaseSceneObject() = delete;
            // can not be copied
            BaseSceneObject(BaseSceneObject& obj) = delete;
            BaseSceneObject& operator=(BaseSceneObject& obj) = delete;

     public:
            const Guid& GetGuid() const { return m_Guid; };

     friend std::ostream& operator<<(std::ostream& out, const BaseSceneOb
ect& obj)
        {
            out << "SceneObject" << std::endl;
            out << "-----------" << std::endl;
            out << "GUID: " << obj.m_Guid << std::endl;
            out << "Type: " << obj.m_Type << std::endl;

         return out;
        }
    };

GUID是Global Unique ID的意思,是由某种哈希算法计算出的一串数字。在正常的情况(按照算法规范进行正常计算生成的情况)下,每次计算所得数字是全球唯一的。这个算法在很多平台上都有实现,但是API的名字等不一样。我这里是使用了GitHub上面一个名为crossguid的项目的代码,它把各个平台的API包了一下,形成了一个统一的接口。

我们这里之所以需要为每个场景物体导入一个GUID的理由是,在大型的游戏开发当中,场景物体的数目十分庞大,而且经常会发生修改。如何高效地对这些资源文件进行管理在这样的开发环境当中会成为一个很显著的问题。

我们的代码使用git进行托管。场景资源文件当然也可以采用git进行托管。但是代码自身是一种严密的逻辑表述,如果将几个版本的代码混在一起,那么编译很可能无法通过,从而能够快速地发现这样的问题;但是如果是将不同版本的资源文件混在一起,那么往往并不会出现任何编译性质的错误,游戏也往往能够正常运行,只是看起来“似乎不太对”。

这是因为大多数的资源只是离散的二进制采样,当中并不含有逻辑关系。如上一篇所说,我们在运行时当中需要定义场景节点和节点关系,构成场景图,就是为了描述这种关系。

但是运行时当中的场景图并不会有版本的概念,也不会有某个资源是由谁在什么时候进行了修改这样的记录。这对于管理来说是很不方便的。而且,美术也不可能在修改一个资源之后重新编译一遍游戏来确认效果,每改一次拿个U盘拷贝给程序替换,来来往往几次两边谁也说不清现在游戏里的到底是换了还是没换,换了几次,这样等等许多问题。

所以在3A级别的游戏开发当中,一般都会有一个资源管理系统。这个系统,实际上就是一个数据库,存储了资源的不同版本,还存储了资源之间的关系。这个外部系统与我们程序内部的场景对象之间需要一种映射关系。我们这里导入的GUID就是可以用作这个目的。

当然,如果仅仅是用作映射关系,其实任意的具有唯一性的ID都是可以的,并不一定需要GUID。使用GUID还有另外一个原因,就是可以像git的版本管理那样每个人都可以在本地有一个自己的版本库,同时又可以随时将本地的变更推送到中心库而无需担心ID撞车。这对于像美术资源这样的大文件是很重要的。因为如果每次都需要将如此大的文件从中心库迁入迁出,将会耗费很多的时间。

好了,接下来让我们从这个基类派生出一系列场景对象的结构。首先是顶点数组∶

   ENUM(VertexDataType) {
        kVertexDataTypeFloat    = "FLOT"_i32,
        kVertexDataTypeDouble   = "DOUB"_i32
    };

    class SceneObjectVertexArray : public BaseSceneObject
    {
        protected:
            std::string m_Attribute;
            uint32_t    m_MorphTargetIndex;
            VertexDataType m_DataType;

            union {
                float*      m_pDataFloat;
                double*     m_pDataDouble;
            };
            size_t      m_szData;
    };

通常来说,每个顶点都是一个结构体(复合数据类型),包含多个被称为“属性”的字段。常见的属性有∶

  1. 位置属性∶这是必须的。代表了顶点的三维坐标。可以是xyz三维向量,也可以是xyzw四维向量。如果是xyz三维向量,那么隐含w为1;
  2. 法线属性∶一般为xyz三维向量,代表了顶点处法线的指向。由于在实时渲染系统当中采用顶点方式记录几何体,任何一个表面的法线方向其实是通过组成这个表面的顶点的法线进行组合计算得到的。法线方向一般主要用于光照计算;
  3. 切线属性∶指顶点处的切线。一般为四维向量,用于比较高级的光照计算;
  4. 顶点色∶即顶点的颜色。RGB或者RGBA。在我们之前的文章当中所渲染出的彩色的三角形和正方体的颜色,就是通过顶点色插值得到的。在早期的游戏当中,因为内存十分有限,无法使用大量的贴图,顶点色就成了着色的重要手段;但是如今的游戏基本上全部都是依靠贴图指定颜色,所以顶点色真正作为颜色使用的场景已经越来越少了。事实上顶点色现在更多地被用作一种逐顶点指定的计算参数传递给shader,用来实现一些顶点动画或者特效;
  5. 贴图坐标(UV)∶虽然用得最多的是二维贴图,但是三维贴图是存在的。比如要制作雪地上脚印的效果,虽然实际项目当中从各方面因素考虑仍然是多采用deco贴图和位移贴图来模拟凹陷的脚印,但是实际上最理想的是使用三维贴图来展现。

组织这些属性有两种方式,也就是所谓的AOS方式和SOA方式。AOS是Array Of Structure的缩写,这是我们在平常编程的时候常用的模型。就是将上面这些属性作为成员组织在一个结构体当中,代表一个顶点,再把结构体组织到数组里面,代表一组顶点。

AOS方式的好处是将同一个顶点相关的数据放在了内存相邻的区域,这样当我们循环遍历每一个顶点并对其进行计算的时候,该顶点的各个属性的数据很可能会全部位于CPU的高速缓存当中(请回忆我们之前讨论内存管理的文章当中的相关叙述),因此会大大加快处理的速度。

然而,GPU是并行处理的。A卡将每64个顶点打包成一个处理对象(wavefront),N卡把每32个顶点打包成一个处理对象(wrap)进行处理。当我们使用某个属性进行计算的时候,GPU会一次读取所有参与计算的顶点的这个属性。如果采用AOS的方式,那么不同顶点的同一个属性在内存空间当中地址是不连续的,就好像一条虚线那样,按照固定的间隔(其它属性所占据的存储空间)跳着存放的。那么也就是说,GPU的高速缓存所读取进来的连续数据当中,相当一部分将可能是当前处理所不需要的,也就是高速缓存利用率会降低,GPU会耗费更多的时间等数据。

所以,对于并行计算的情况,SOA往往要更为有效。SOA就是Struct Of Array的缩写,就是先把不同顶点的同一个属性以数组的方式进行组织,再把这些不同的属性的数组组织到一个结构体当中。

我们上面这个定义就是采用的SOA形式。我们定义的这个结构,代表了一组顶点的某个属性。

顶点数据可以采用单精度浮点或者双精度浮点。一般来说在游戏当中单精度浮点就足够了。大部分消费级显卡的双精度浮点的计算能力都是很差的,这就是为什么会有专门的云计算用显卡,卖得死贵死贵的。

还有一个需要说明的是我们并没有直接在结构体里存储数据,而是采用了指针。这里面有两个原因∶

  1. 在实际读入数据之前,我们无法预知数据的大小。不同的对象顶点数也是不同的;
  2. 由于CPU和GPU的异构性,内存往往也划分为主存和显存,且有着严格的区别。由于GPU无法自行完成资源的加载,大部分数据需要由CPU加载进主存然后复制到显存,在内存上会出现两份拷贝。这是很浪费的一件事情,我们需要尽可能地在填充显存之后释放CPU这边的内存占用。

接下来是定义存储索引到数据结构∶

   ENUM(IndexDataType) {
        kIndexDataTypeInt16 = "_I16"_i32,
        kIndexDataTypeInt32 = "_I32"_i32,
    };

    class SceneObjectIndexArray : public BaseSceneObject
    {
        protected:
            uint32_t    m_MaterialIndex;
            size_t      m_RestartIndex;
            IndexDataType m_DataType;

            union {
                uint16_t*   m_pDataI16;
                uint32_t*   m_pDataI32;
            };
    };

索引比较简单。一般来说,为了节省内存带宽,我们应该尽量使用16bit的索引。也就是说,每次绘制(drawcall,GPU的单次绘图指令)最多只能有65535个顶点。这当然是不太够的,因为当今3A游戏的主要人物一般会有20万左右的多边形,即使是按照triangle strip的方式理想编排,最少也需要20万个顶点。所以这就是说,需要进行模型的切割,把大的模型切割到64K顶点之内的模型碎片。

事实上,对于复杂的模型,其往往也是包含了到不同材质的引用。比如一个人物的模型,其裸露的脸部和身上着衣的部分的材质就很可能是不同的。况且考虑到动画的需要,我们需要将模型分割为可动的部分和不可动的部分。我们在用maya或者3dmax等DCC工具进行建模的时候,往往会将顶点进行分组,指定不同的材质或者子材质,这些都是很自然很好的分割依据。

所以我们在上面的结构当中也包括了一个材质索引,用来指定这组顶点所组成的几何体对应的材质。

那么是不是模型拆得越碎越好呢?答案是否定的。事实上,为进行一次drawcall是要进行很多准备工作的。在我们之前D3d12的代码当中我们可以看到,CPU需要跑一大堆代码去生成各种GPU运行命令所需要的环境,包括GPU实际执行的命令都是CPU每次去一条一条写进去的。这个开销是很大的。

所以,为了节省内存带宽我们需要尽量使用16bit的索引,但同时为了减少drawcall我们又需要尽量在每次调用drawcall的时候绘制尽可能多的顶点。好的实践是我们需要尽量把同一个材质的顶点进行合并,因为相同材质的顶点(多边形)是可以用同一套GPU执行环境(上下文)去绘制的。打个通俗的比方,想象一下画家作画的过程∶如果我们按照人们通常认知的方式去绘制的话,那么我们会需要不断更换颜色,也就是洗笔,蘸颜料,绘制,再洗笔,再蘸别的颜料,这是很没有效率的。所以画家往往会准备很多支笔,省去一部分这里的开销。但是如果是工业印刷,其实是将图事先分解为CYMK等几个颜色,然后像盖章那样啪啪啪4下就完成了的。GPU绘制画面的过程同样,如果能够一次绘制完场景当中所有相同材质的网格再转而绘制其它,将会带来绘制效率方面的提升。因此我们需要在将模型拆解为碎片之后,再将它们按照材质重新组合起来,尽量放在一个索引数组里。

然而,这样重组出的模型碎片在空间上往往是不连续的。比如仙侠类游戏男主人公两只手上的护腕,他们是相同的材质,但是在空间上是不连续的。这就造成在使用诸如triangle strip这种索引方式的时候,需要在不连续的地方给出重置信号,这就是上面m_RestartIndex的含义。它指定了一个特殊的index值,当index数组当中出现这个值的时候,说明一个连续的triangle strip已经结束,需要重头开始一个新的triangle strip。这就允许我们在一个顶点数组当中存储多个空间上并不连续的mesh。

好了,既然我们有了顶点数组和索引数组,接下来我们可以定义网格了∶

   class SceneObjectMesh : public BaseSceneObject
    {
        protected:
            std::vector<SceneObjectIndexArray>  m_IndexArray;
            std::vector<SceneObjectVertexArray> m_VertexArray;

            bool        m_bVisible      = true;
            bool        m_bShadow       = false;
            bool        m_bMotionBlur   = false;

        public:
            SceneObjectMesh() : BaseSceneObject(SceneObjectType::kSceneO
bjectTypeMesh) {};
    };

网格可以包括1个以上的顶点数组,和0个以上的索引数组。如我们上面所说,每个顶点数组只是代表了一个顶点属性,所以档有多个顶点属性的时候,就需要相同数目的顶点数组。而索引数组可以不存在,这时表示按照顶点数组当中的顶点出现顺序进行绘制即可。而索引数组多于1个的情况是表明这些顶点可以按照不同的拓扑结构组成不同的几何体,或者是包含不同的材质的多个几何体表面。

好了,这篇的篇幅已经很长了,我们暂告一个段落。下一篇继续介绍材质,灯光和摄像机,以及场景图(节点图)。

从零开始手敲次世代游戏引擎(二十七)

上一篇我们实现了一个基本的BMP文件解析器。事实上,对于游戏制作来说,BMP文件并不是一种好的文件形式,这主要因为:

  1. BMP文件并不是跨平台的。很多平台都有BMP文件,但是其格式其实是有微妙的不同的;
  2. BMP文件一般不采用压缩,所以比较大;
  3. BMP文件在图片制作元数据及色彩管理等方面功能有限;

因此,我们还需要加入几种其它的图片文件格式支持,如PNG、JPG等。这些文件相对于BMP都要复杂一些,同时涉及到一些较为复杂的压缩算法,也正好是给我们测试我们的(并行计算)数学库的一个机会。

不过因为我们急切地想看到我们的第一个场景渲染结果,所以这里暂时把这些事情往后面推推。既然我们已经有了一个基础的数学库,一个简单的文件资源加载器,和一个BMP格式的贴图文件解析器,那么如果我再实现一个3D场景描述文件的解析器,结合我们之前开发的渲染器,那么我们就应该可以进行一些较为复杂的场景的渲染工作了。所以让我们先进行这些工作。

3D模型及/或场景描述(矢量)文件也有很多格式。比如较早的obj格式,VRML格式;专有的3ds格式,maya格式,unity格式,ue格式,FBX格式;开源的blender格式,OpenGEX格式;元SCEA员工创造的现在由Khronos Group(就是维护OpenGL和Vulkan的那个非盈利组织)维护的COLLADA格式,等等。

其中,抛开专有格式不谈,COLLADA和OpenGEX这两种格式是比较适合游戏引擎用途,也比较完整的。其它的一些格式要么更多的是面向网页浏览器的渲染为主要对象设计的,要么是主要面向CAD等设计领域,对于游戏当中扮演重要角色的一些功能,如骨骼动画、物理碰撞等的支持十分有限。

COLLADA的优势主要在于推出的年代较早,所以目前支持这个格式的DCC软件以及商业游戏引擎很多。但是也正因为年代早所以其对一些近代的功能支持有限或者不支持,比如LOD,PBR渲染。当然因为它是基于XML的我们可以较为容易地对其进行拓展,只不过这样的拓展会面临很多的兼容互换性问题。

OpenGEX的设计是基于OpenDDL这个同样是开放的通用可读的数据结构定义语法,相对使用XML的COLLADA来说同样的场景定义要更为简洁,文件尺寸也比较小。但是其第一版标准至今也才3年多的时间,目前被支持的范围相对来说小很多。

这两种文件格式均是采用了可读的文本文件格式。一般来说,文本文件格式更加适合作为不同软件之间的互换格式,因为它没有诸如Endian或者32/64 bit,字节对齐等诸多平台兼容性问题。而且文本文件容易阅读,容易发现错误,容易编辑。

但是文本文件通常需要经过多次读取转换才能形成内存上的数据结构,相对来说效率是比较低的。一般来说,在游戏引擎运行时当中,至少是最后的发行版本当中,还是会以二进制形式为主。

抛开文件的格式不谈,这两种文件格式都为我们展现了一个比较近代的场景图(Scene Graph)结构。以OpenGEX为例,其至少包括以下这些数据结构:

  1. 场景的层叠式组织结构 (节点树 node trees)
    节点或者场景物体的坐标变换 (4×4 矩阵、 平移、 旋转、缩放)
  2. 场景几何体对象, 光照对象, 摄像机对象
    包含多个LOD级别的顶点数据和索引数据的网格
    蒙皮网格 (骨骼,骨骼绑定,骨骼影响权重因子).
    网格的多个变形目的对象,以及动画化了的变形权重
  3. 关键帧动画以及线性、贝塞尔、TCB动画曲线
    材质与贴图 (漫反射,高光,法线,切线,自发光, 透明度,凹凸,位移或者PBR类型的金属度,粗糙度,AO等)

关于坐标变换所需要的各种矩阵我们之前已经在数学库当中建立。所以接下来我们主要需要定义以下一些数据结构∶

  1. 场景图(其实就是由节点与节点关系组成的图(树))
  1. 节点关系
  1. 依存关系(表示节点之间有依赖关系需要同步加载/卸载)
  2. 包含关系(表示节点之间有从属关系,源节点拥有目的节点的所有权,源节点管理目的节点的生命周期)
  3. 聚集关系(表示节点之间有从属关系,但是源节点不拥有目的节点的所有权,源节点不管理目的节点的生命周期)
  4. 扩展关系(表示目的节点对于源节点进行扩展,但两者不是从属关系,而是服务和服务对象的关系)
  5. 参考引用关系(表示源节点是目的节点的一个占位符,一个实例)
  • 节点(代表场景当中的层级和位置)
  1. 普通节点(起到概念上分组和链接其它节点的作用,并对从属节点施加统一的影响,如坐标变换,参数动画,特效范围。或者是定义一些和空间位置有关的处理,如反射材质球,volume)
  2. 几何体节点(指向几何体对象)
  3. 骨骼节点(指向带有骨骼的几何体对象)
  4. 摄像机节点(指向摄像机对象)
  5. 光照节点(指向光照对象)
  • 场景对象(指与场景结构无关的对象)
  1. 场景几何体对象
  1. 网格
  1. 顶点
  2. 索引
  3. 蒙皮
  1. 骨骼
  • 变形
  • 材质对象
  1. 贴图
  2. 颜色
  3. 参数
  • 摄像机对象
  1. 参数(FOV,裁剪平面等)
  • 光照对象
  1. 贴图
  2. 颜色
  3. 参数(类型,亮度等)
  4. 传递函数

如果画成一张图的话,大概是下面这个样子:(图中只包含了从属关系)

* 图片引用自(参考引用2)

可以看到还是比较复杂的。接下来我们先从静态场景开始,逐渐扩展到包括动画等在内的完整的结构。

顺便说一下,因为接下来我们会开始往代码树当中加入比较多的二进制文件,比如各种资源文件。为了更为有效地管理这些文件,我在GitHub的这个项目当中开启了LFS功能,并且对master的历史进行了改写。(参考引用6)

导入LFS的另外一个考虑是为后面的编辑器的美术资源生产线管理做准备,这个后面相关的地方再细谈。

因为改写了git repo的历史,在昨天之前fork了的朋友抱歉了,你们可能需要重新fork,或者手动merge一下。(在真实项目当中记得不到万不得已千万别这样做。。。)m(_ _)m

参考引用

  1. Image file formats
  2. Open Game Engine Exchange
  3. COLLADA – Wikipedia
  4. Best practices for multi-character character constants
  5. https://stackoverflow.com/questions/39144504/assign-multibyte-ascii-literals-to-enum-values
  6. https://git-lfs.github.com/

从零开始手敲次世代游戏引擎(二十六)

接上一篇,我们来设计编写解析各种资源所需要的文件格式解析器。

游戏总的来说属于交互式多媒体,游戏引擎的runtime实际上也可以被看做一个多媒体播放器。通常情况下,游戏当中所包含的媒体文件可以大致划分为如下几个类型∶

  1. 静态图片(Image)。在一个2D的游戏当中,静态图片是构成游戏画面的主要媒体内容。在3D游戏当中,静态图片则往往被作为贴图使用。历史上静态图片发展出了很多种格式(参考引用1),大部分格式的出现是为了实现图片的设备(或者应用)无关性和存储(或者网络传输)尺寸的问题,相对的,在图片的加载速度方面作出了一定的牺牲。因此,在大多数当代的游戏引擎当中,大都采用平台专有的格式对图片进行存储,而不是采用(参考引用1)当中的这些通用格式。然而,由于图片素材大多来自于美术的DCC制作工具,因此游戏引擎runtime,或者是游戏引擎的资源导入工具需要支持至少一种通用格式;
  2. 动态图像,也称为视频。这类素材的典型应用是游戏的过场动画,也有作为贴图进行应用的时候。比如在游戏场景当中包括一个播放节目的电子广告屏或者电视,那么其贴图就很可能是一个视频。当用作贴图时,在游戏引擎的runtime当中,由于其自身是以一定的fps刷新画面的,所以视频是被视为一系列按描绘顺序排列的静止图片,在每一帧当中绘制过程与静止图片并没有什么差别;而当作为过场动画进行播放的时候,因为还需要同步处理音频数据,往往是将渲染缓冲区直接临时托管给视频播放器进行画面的输出;
  3. 音频文件。最为基本的是LPCM,也就是wave文件。这个文件当中包括了声音信号经过A/D采样转换之后的样本数据。其它的音频文件格式基本上就是对于这个样本的不同压缩算法。音频的播放一般比较独立,在一个专门的模块当中进行处理。在硬件层面也往往是一个独立的模块。当今大多数设备都能在休眠模式或者极低功耗的情况下播放音乐,就是因为音频处理有单独的硬件完成。也因为比较独立,这部分主要的挑战是对于播放时间点的掌握,如何与画面以及用户输入保持精准的同步。这里面需要考虑到其它处理带来的额外压力所造成的延时。比如我们在玩很多游戏的时候,当游戏处于加载画面的时候往往会出现声音不连续的情况,这就是因为大量的资源加载挤占了硬盘的读取队列,或者是因为相关线程被同步文件I/O暂时卡死了所导致的。
  4. 3D场景文件。这里所说的3D场景文件包括我们在文章二十四当中提到的场景地图以及场景物体和场景物体的组织结构,还包括挂载在场景物体上的各种组件。这部分相对上面来说是最没有得到标准化的部分。市面上大多数商业游戏引擎都使用了自己专有的格式,而DCC工具在这一部分也是使用着五花八门的格式。因此这部分需要我们编写相当程度的代码去进行各种格式的转换和导入。

文章二十五当中我们实现了文件的基本I/O。在将文件读取进内存之后,我们需要根据其格式规范对其进行解析,把其中我们感兴趣的数据提取出来并在内存上以一种方便我们引擎使用的方式进行展开。在这里我们会用到我们之前编写的内存管理模块,也会用到我们之前编写的数学库。

首先作为演示,让我们来写一个BMP文件的解析器。BMP文件的格式规范请参考(参考引用2)。

首先,我们需要在Framework/Common下面新建一个Image.hpp文件,在其中定义适用于我们的引擎的静止图片在内存上的结构:

#pragma once
#include "geommath.hpp"

namespace My {

    typedef struct _Image {
        uint32_t Width;
        uint32_t Height;
        R8G8B8A8Unorm* data;
        uint32_t bitcount;
        uint32_t pitch;
        size_t  data_size;
    } Image;

}

注意我们包含了geommath.hpp这个之前我们写的数学库,并使用其中的R8G8B8A8Unorm类型来保存静止图片当中的像素颜色数据。R8G8B8A8Unorm这个类型在文章二十三当中并没有出现。它是我在写这篇文章的时候新加入到geommath.hpp当中的,定义如下:

 typedef Vector4Type<uint8_t> R8G8B8A8Unorm;

可以看到它就是一个数据类型为uint8_t的Vector4Type(4维向量)。unorm的意思是Unsigned Normalized Integer(参考引用3),而R8G8B8A8表示数据包括RGBA四个颜色通道,每个通道是8个bit。它代表了一种在内存当中存储颜色的格式。

结构体当中的Width指贴图的宽度,而Height指贴图的高度。他们的单位都是像素。bitcount指一个像素在内存上占的尺寸(bit数),而pitch是指图形的一行在内存上的尺寸(byte数)。这两个值与位图本身的质量、在内存上的压缩格式以及内存对齐方式有关。

data_size则是data所指向的数据区域的尺寸。注意这个尺寸应该是(pitch * Height)而不是(Width * Height * bitcount/8),原因就是内存区域有对齐的问题,贴图每行的数据尺寸如果不满足内存对齐的要求在行尾会有padding。这是为了满足GPU寻址方面的要求。

好了,一个基本的Image结构我们定义好了,接下来我们定义ImageParser这个接口,来抽象化不同图片格式的解析过程:

#pragma once
#include "Interface.hpp"
#include "Image.hpp"
#include "Buffer.hpp"

namespace My {
    interface ImageParser
    {
    public:
        virtual Image Parse(const Buffer& buf) = 0;
    };
}

这个接口很简单,传一个Buffer进去,得到一个Image。唯一需要解释的是,我们的返回值是一个Image类型。也就是说,对于Image的内存分配是在这个接口内完成的。这似乎与我们之前所说的谁分配内存谁释放的原则不符。

首先,当然,我们可以把这个接口定义为

virtual int Parse(const Buffer& buf, Image* img) = 0;

这样就要求在实际调用Parse之前,先分配好Image对象。但是问题是,在我们实际解析文件内容之前,我们也不知道到底需要为Image的data成员分配多少内存。

在一些第三方库或者Win32 API当中,我们有时候会见到这么一种设计:

int size = Parse(buf, nullptr);
Image Img;
Img.data = new uint8_t[size];
Parse(buf, &Img);

就是首先带入nullptr,来告诉函数我们需要知道需要多少内存。函数通过返回值返回所需要的内存大小之后,创建好用于保存数据的对象,再次调用这个函数进行数据填充的操作。这个方法符合谁分配谁释放的原则,但是很不自然,效率也很低。

之所以出现这种设计,或者说要求遵守谁分配谁释放的原则,是因为截止到C++11之前,C++当中没有显式指定移动语义的方法。除非使用指针进行传递,当我们采用按值返回的时候,对象的拷贝构造函数会被调用。也就是说,如果我们将Parse定义为:

   virtual Image Parse(const Buffer& buf) = 0;

并且如下进行调用

Image img = Parse(buf);

那么从C++语义上,会发生一次对象的生成(等号左边变量,也就是左值分配内存),拷贝(从等号右边拷贝到左边),以及一次对象的析构(内存的释放)。但是从C++11开始,通过定义移动语义的构造函数,可以显式的规避这种拷贝。如下,带一个&的是左值拷贝构造函数和赋值重载,带两个&的是右值拷贝构造函数和赋值重载。在右值的版本当中,我们直接接管相关的buffer而不进行赋值。

     Buffer(const Buffer& rhs) {
            m_pData = reinterpret_cast<uint8_t*>(g_pMemoryManager->Allocate(rhs.m_szSize, rhs.m_szAlignment));
            memcpy(m_pData, rhs.m_pData, rhs.m_szSize);
            m_szSize =  rhs.m_szSize;
            m_szAlignment = rhs.m_szAlignment;
        }

        Buffer(Buffer&& rhs) {
            m_pData = rhs.m_pData;
            m_szSize = rhs.m_szSize;
            m_szAlignment = rhs.m_szAlignment;
            rhs.m_pData = nullptr;
            rhs.m_szSize = 0;
            rhs.m_szAlignment = 4;
        }

        Buffer& operator = (const Buffer& rhs) {
            if (m_szSize >= rhs.m_szSize && m_szAlignment == rhs.m_szAlignment) {
                memcpy(m_pData, rhs.m_pData, rhs.m_szSize);
            }
            else {
                if (m_pData) g_pMemoryManager->Free(m_pData, m_szSize);
                m_pData = reinterpret_cast<uint8_t*>(g_pMemoryManager->Allocate(rhs.m_szSize, rhs.m_szAlignment));
                memcpy(m_pData, rhs.m_pData, rhs.m_szSize);
                m_szSize =  rhs.m_szSize;
                m_szAlignment = rhs.m_szAlignment;
            }
            return *this;
        }

        Buffer& operator = (Buffer&& rhs) {
            if (m_pData) g_pMemoryManager->Free(m_pData, m_szSize);
            m_pData = rhs.m_pData;
            m_szSize = rhs.m_szSize;
            m_szAlignment = rhs.m_szAlignment;
            rhs.m_pData = nullptr;
            rhs.m_szSize = 0;
            rhs.m_szAlignment = 4;
            return *this;
        }

注意我上面强调了“C++语义上”。因为实际上即使我们不显式地进行这样的指定,当代的大部分C++编译器在对于按值返回的时候会默认地进行类似的优化。但是编译器自动进行的操作有的时候是不那么容易理解或者不见得是我们想要的结果,所以我们通过上面的方法进行显式的指定。

好了,接下来我们来写BMP文件的解析器。我们在Framework/之下创建一个新目录,名为Codec,然后从ImageParser派生出BmpParser类,来实现对于BMP文件的解析:

#pragma once
#include <iostream>
#include "ImageParser.hpp"

namespace My {
#pragma pack(push, 1)
    typedef struct _BITMAP_FILEHEADER {
        uint16_t Signature;
        uint32_t Size;
        uint32_t Reserved;
        uint32_t BitsOffset;
    } BITMAP_FILEHEADER;

#define BITMAP_FILEHEADER_SIZE 14

    typedef struct _BITMAP_HEADER {
        uint32_t HeaderSize;
        int32_t Width;
        int32_t Height;
        uint16_t Planes;
        uint16_t BitCount;
        uint32_t Compression;
        uint32_t SizeImage;
        int32_t PelsPerMeterX;
        int32_t PelsPerMeterY;
        uint32_t ClrUsed;
        uint32_t ClrImportant;
    } BITMAP_HEADER;
#pragma pack(pop)

    class BmpParser : implements ImageParser
    {
    public:
        virtual Image Parse(const Buffer& buf)
        {
            Image img;
            BITMAP_FILEHEADER* pFileHeader = reinterpret_cast<BITMAP_FILEHEADER*>(buf.m_pData);
            BITMAP_HEADER* pBmpHeader = reinterpret_cast<BITMAP_HEADER*>(buf.m_pData + BITMAP_FILEHEADER_SIZE);
            if (pFileHeader->Signature == 0x4D42 /* 'B''M' */) {
                std::cout << "Asset is Windows BMP file" << std::endl;
                std::cout << "BMP Header" << std::endl;
                std::cout << "----------------------------" << std::endl;
                std::cout << "File Size: " << pFileHeader->Size << std::endl;
                std::cout << "Data Offset: " << pFileHeader->BitsOffset << std::endl;
                std::cout << "Image Width: " << pBmpHeader->Width << std::endl;
                std::cout << "Image Height: " << pBmpHeader->Height << std::endl;
                std::cout << "Image Planes: " << pBmpHeader->Planes << std::endl;
                std::cout << "Image BitCount: " << pBmpHeader->BitCount << std::endl;
                std::cout << "Image Compression: " << pBmpHeader->Compression << std::endl;
                std::cout << "Image Size: " << pBmpHeader->SizeImage << std::endl;

                img.Width = pBmpHeader->Width;
                img.Height = pBmpHeader->Height;
                img.bitcount = 32;
                img.pitch = ((img.Width * img.bitcount >> 3) + 3) & ~3;
                img.data_size = img.pitch * img.Height;
                img.data = reinterpret_cast<R8G8B8A8Unorm*>(g_pMemoryManager->Allocate(img.data_size));
                if (img.bitcount < 24) {
                    std::cout << "Sorry, only true color BMP is supported at now." << std::endl;
                } else {
                    uint8_t* pSourceData = buf.m_pData + pFileHeader->BitsOffset;
                    for (int32_t y = img.Height - 1; y >= 0; y--) {
                        for (uint32_t x = 0; x < img.Width; x++) {
                            (img.data + img.Width * (img.Height - y - 1) + x)->bgra = *reinterpret_cast<R8G8B8A8Unorm*>(pSourceData + img.pitch * y + x * (im
g.bitcount >> 3));
                        }
                    }
                }
            }

            return img;
        }
    };
}

代码很简单,但是很容易出问题。因为涉及到很多关于内存顺序和对齐方面的考虑,这是在一些高级语言或者脚本语言编程的时候不太会接触到的。

我们首先是根据BMP规范(参考引用2)定义了两个结构体,与BMP文件头保持相同的结构。这里需要注意的就是下面这3个预编译命令:

#pragma pack(push, 1)
#define BITMAP_FILEHEADER_SIZE 14
#pragma pack(pop)

因为在缺省状态下,C/C++的结构体当中的成员是按照4字节对齐(PC。其它硬件平台可能不同)的。注意我们第一个结构体当中的Signature是uint16_t类型,也就是2个字节。如果我们不指定pack为1,那么下一个Size成员会从第5个字节开始,而不是第3个字节开始。那么我们读取出来的BMP头部数据就会不对。

同样的,我们定义了 BITMAP_FILEHEADER_SIZE 14,而不是使用sizeof(BITMAP_FILEHEADER),这也是因为结构体会有一个对齐的问题。在PC上,sizeof(BITMAP_FILEHEADER)很可能是16,而不是14。

其次,请注意下面这行代码:

      if (pFileHeader->Signature == 0x4D42 /* 'B''M' */) {

虽然注释里面写的是’B”M’,但是实际我们比较的值是0x4D42。0x4D是‘M’,而0x42是’B’,正好倒过来。这是因为我们将Signature定义为uint16_t,而x86是little endian,当按照字读入的时候,两个字节会发生交换。

而如果是PS3,那么因为PS3是big endian,那么就应该是0x424D了。因为目前我们要支持的平台不包括big endian的机器,所以这个地方就是按照little endian来写的。

第三个需要注意的地方是下面这里:

                 for (int32_t y = img.Height - 1; y >= 0; y--) {
                        for (uint32_t x = 0; x < img.Width; x++) {
                            (img.data + img.Width * (img.Height - y - 1) + x)->bgra = *reinterpret_cast<R8G8B8A8Unorm*>(pSourceData + img.pitch * y + x * (im
g.bitcount >> 3));
                        }
                    }

这里特别需要注意如何计算像素在两边的位置。BMP文件可能是24bit或者32bit的,需要注意这个差别。(BMP文件历史上还支持24bit以下的,也就是带调色板的格式,这个游戏当中很少用到,为了代码的简洁性我们没有进行支持)

另外上面这段代码包括乘法运算,是比较低效的。事实上这段代码完全可以避免乘法运算,并且可以用ispc并列化执行。这部分就放在后面进行吧。

好了,这样一个简单的BMP解析器就写好了。为了测试代码,我们需要写一个简单的测试应用来显示读入的贴图。我们趁这个机会将在文章十的DirectX 2D的代码整合进来。首先依然是在RHI目录下新建D2d目录,参照之前D3dRHI的方式将D2d的代码作为D2dRHI整合进来,然后在Platform\Windows下新建test目录,在其中创建TextureLoadTest.cpp进行贴图加载的测试。篇幅原因,代码就不在这里贴了,感兴趣的请参考Github article_26

测试应用执行的结果如下,加载的贴图放在项目根目录下的Asset目录当中。我们的AssetLoader会自动查找这个目录。

参考引用

  1. Image file formats
  2. BMP file format – Wikipedia
  3. Normalized Integer
  4. https://msdn.microsoft.com/en-us/library/windows/desktop/dd407212(v=vs.85).aspx


本作品采用知识共享署名 4.0 国际许可协议进行许可。

从零开始手敲次世代游戏引擎(二十五)

接上一篇,我们首先来看一下基本的文件IO操作。

对于C语言标准库提供了两种文件io的API。一种是C标准库当中提供的f*系列的,带有缓存功能的API,如fopen/fclose;一种是POSIX标准的,不带有缓存功能的API,如open/close。不过,如同我们之前说过,windows并不是POSIX系统,因此windows所提供的POSIX类型的API并不是系统调用,而是win32 API的简单封装。

由于我们目前主要是进行游戏资源文件的加载。这种加载基本上是只读性质的,而且大部分是顺序读取,所以带有缓存的f*系列API就比较适合我们的需要了。而且f*系列的API是属于C标准库的一部分,可移植性较好。

我们当然可以在场景管理模块里面直接使用f*系列API来加载资源。但是这么做有以下几个问题:

  1. 虽然f*系列API是C的标准库的一部分,具有良好的可移植性,但是在一些平台上它并不是系统原生的API,所提供的功能十分有限且优化不足。比如在windows平台上有功能更为强大的win32 API,而在PS4上有可以将多个文件的读写进行统一调度优化的库。如果在资源管理模块当中直接调用f*系列的API,那么如果之后我们希望直接使用平台原生的API,会变得困难;
  2. 各个平台对于文件路径的要求和处理有微妙的区别。比如windows平台有盘符的概念;而Linux平台整个文件系统在一个树状结构当中;而PSV的文件系统在路径前还有媒体标识符;PS4的文件系统虽然类似Linux系统但是是在一个严格的沙箱当中的虚拟路径,与文件实际存放路径不同。这些细节与场景管理本身并没有太多关系,应该放在一个独立的地方进行处理;
  3. f*系列API所提供的是同步阻塞型的文件访问。如果我们在场景管理模块当中直接使用这些API,那么场景管理模块在进行文件操作的时候将会失去响应。场景管理模块是与图形渲染模块,动画模块,游戏逻辑模块等紧密协作的一个模块,因此我们应该将其设计为一个快速响应的模块,不能有这样的阻塞;
  4. 如我们上一篇文章所分析的,场景模块是比较复杂的。为了降低其复杂度,增加其可维护性,我们应该尽量把松耦合的功能从其中剥离出来形成单独的模块。

基于这样的基本设计,我们定义了一个资源加载模块,AssetLoader,专门负责资源文件的加载工作。

namespace My {
    class AssetLoader : public IRuntimeModule {
    public:
        virtual ~AssetLoader() {};

        virtual int Initialize();
        virtual void Finalize();

        virtual void Tick();

        typedef void* AssetFilePtr;

        enum AssetOpenMode {
            MY_OPEN_TEXT   = 0, /// Open In Text Mode
            MY_OPEN_BINARY = 1, /// Open In Binary Mode
        };

        enum AssetSeekBase {
            MY_SEEK_SET = 0, /// SEEK_SET
            MY_SEEK_CUR = 1, /// SEEK_CUR
            MY_SEEK_END = 2  /// SEEK_END
        };

        bool AddSearchPath(const char *path);

        bool RemoveSearchPath(const char *path);

        bool FileExists(const char *filePath);

        AssetFilePtr OpenFile(const char* name, AssetOpenMode mode);

        Buffer SyncOpenAndReadText(const char *filePath);

        size_t SyncRead(const AssetFilePtr& fp, Buffer& buf);

        void CloseFile(AssetFilePtr& fp);

        size_t GetSize(const AssetFilePtr& fp);

        int32_t Seek(AssetFilePtr fp, long offset, AssetSeekBase where);

        inline std::string SyncOpenAndReadTextFileToString(const char* fileN
me)
        {
            std::string result;
            Buffer buffer = SyncOpenAndReadText(fileName);
            char* content = reinterpret_cast<char*>(buffer.m_pData);

            if (content)
            {
                result = std::string(std::move(content));
            }

            return result;
        }
    private:
        std::vector<std::string> m_strSearchPath;
    };
}

目前这个定义当中还只是包括了同步版本的API,因此这个类看起来还只是一个wrapper,似乎没有必要将其定义为一个RunTimeModule。但是为了实现上面所说的让文件操作不要阻塞调用线程,我们有两个选项∶

  1. 使用多线程。我们需要在AssetLoader当中创建工作线程池,将对同步阻塞型f*系列API的调用放到工作线程当中去执行;
  2. 使用异步文件IO API。实质上这也是多线程,只不过线程是由操作系统创建。

选项1的好处是我们可以拥有更为细致的控制权,可以精细安排这些线程的优先级以及执行方式等。缺点是我们需要写更多的代码去维护这个线程池的管理,并且因为操作系统缺乏关于我们要进行的工作的足够的信息,可能无法提供文件读写整体方面的优化;

选项二的好处是代码比较简洁,不用进行线程的管理,并且如果我们一次将多个文件读写请求发送给操作系统,可能会得到操作系统在读取方式方面深度的优化,比如通过妥善安排读取的顺序减少硬盘寻道的时间。然而缺点首先是我们可能比较难以控制文件读取的顺序,而且异步文件IO的API并没有得到标准化,在各个平台上API长得很不一样,甚至不支持。

但是不管我们支持上述两种选项的哪一种,因为是异步操作,就意味着我们的AssetLoader会以一种不同于资源管理模块的节奏进行工作。所以我们将它也定义为一个RuntimeModule。

另外一个需要注意的点就是AssetFilePtr。它被定义成为一个空指针类型。这是因为在不同的平台API当中文件的描述子的定义是不同的。我们用空指针类型来抽象整合这种不同。不过需要注意的是AssetFilePtr并不是资源的唯一识别子。资源的唯一识别子是在游戏当中用来索引资源的唯一标识,它应该具备平台无关性和时序无关性;而AssetFilePtr显然这两条都不符合。

况且,这里面还有一个隐含的问题值得我们思考,那就是游戏资源(Asset)与游戏资源文件(Asset File)是否一定是一对一的关系。如果我们将场景物体考虑为Asset的单位,比如游戏当中的主角人物,那么因为一个人物会包括模型、材质、贴图、动画、声音等许多素材,而这些素材往往是以不同格式的文件进行存放的,那么显然这不是一种一对一的关系。

另一方面,如同我们在上一篇文章所述,为了加快资源的读取速度,我们往往最终会将资源文件进行打包合并,从而多个资源可能会对应同一个资源文件,这也不是一种一对一的关系。

因此,很显然地,我们需要在某个地方进行资源与资源文件之间的映射。我们甚至需要导入或者自定义一种资源描述语法(比如URI),来唯一描述某项资源,以及资源之间的联系。

不过在此之前,我们还有一些基础性工作需要做。因为我们的AssetLoader目前只能完成文件内容的加载(以文本文件模式或者二进制模式),并不能解析文件的内容。我们下一篇将介绍如何为我们的AssetLoader设计各种文件格式的解析器(decoder)。

本文当中涉及到的AssetLoader的实现代码在GitHub的article_25当中。

参考引用

  1. http://man7.org/linux/man-pages/man7/aio.7.html
  2. https://msdn.microsoft.com/en-us/library/aa365683.aspx
  3. https://isocpp.org/wiki/faq/serialization#serialize-text-format

本作品采用知识共享署名 4.0 国际许可协议进行许可。