游戏引擎(1)-ECS框架

1,672 阅读5分钟

欢迎关注公众号:sumsmile /专注图形学

这篇文章参考自从零实现ECS(entity component system),增加了些自己的理解。

通过简单的代码搞清楚ECS的实现逻辑。

面向对象 vs ECS

什么是ECS

所有的程序员都知道面向对象的编程模式,我们看下面向对象的实现有什么问题

假设一个游戏场景里有Dog、Platypus(鸭嘴兽)、Duck,继承关系如上图。

如果再有猎犬,猎犬又得继承自Dog的特性,如此,整个类层级结构就很容易膨胀、且变得难以维护。

解决面向对象层级结构的第一步,就是把"继承关系"重构成"组合"

ECS(Entity Component System)就是一种分拆、组合的实现。

面向对象的思想是把数据、行为都封装在一个类里,ECS有点像MVC,但是做的更彻底,边界划分的更清晰。

  • E:Entity,实体。一般用一个唯一值的int型表示,可以理解为一个ID。一个怪兽、一把枪,就是一个ID,至于怪兽的外观、血量、速度则是Component表示。

  • C:Component,组件,也可以理解为data。Component是个很泛的概念,所有的属性都能以Component的形式存在,如:怪兽的外观材质、速度、名字、位移、血量等

  • S:System,系统。类似MVC中的Controller,控制逻辑。可以按照Component的类别设计System种类

ECS的优缺点

  1. 优点
  • 性能较好
    • 内存:避免了因继承造成的冗余字段,以组合的方式按需拼装
    • IO:ECS利用了"局部性",一次处理相同的数据类型,增加了缓存命中,
  • 结构清晰,数据、逻辑充分解耦,更便于大型项目迭代
  • 适合并发优化。可以针对没有依赖关系的数据并行计算。
  1. 缺点
  • 基础框架比对面向对象复杂,习惯了面向对象编程,会有点不习惯
  • 思路虽然更简单清晰,但是新增功能的代码会更多
  • 逻辑分散,逻辑异常不如面向对象好调试

从零开始敲代码实现ECS

了解一门技术最好的方法,就是造一遍轮子

内容参考:[C++] An Entity-Component-System From Scratch

实现一个简单的逻辑,按键W、S、A、D控制小鸟上下左右移动:

完整代码:

github.com/ThoSe1990/s…

只有两个类game.hpp、main.cpp,一共不到250行代码,非常容易懂。

输入和界面用SDL库。SDL的基本使用比较简单,此处不做展开。

游戏框架搭建

第一步实现整个游戏框架结构,设计Game类和main中更新的逻辑。

  • main中while循环
    • read_input() 读取键盘输入,处理wsad按键
    • update() 更新组件
    • render() 绘制小鸟
class game
{
    public: 
        game() {/* SDL details here to create a window */}
        ~game() {/* SDL details to cleanup allocated memory */}

        // this we'll use in our mainloop to check if our game is running
        bool is_running() { return m_is_running; }
        // read keyboard / mouse input
        void read_input() 
        {
            // read sdl events
            SDL_Event sdl_event;
            SDL_PollEvent(&sdl_event); 
            // get all keys here
            const Uint8* keystates = SDL_GetKeyboardState(NULL);
            
            // quit / close our window by pressing escape
            if (keystates[SDL_SCANCODE_ESCAPE]) {
                m_is_running = false;
            }
        }
        void update() {/* here we'll update all components*/}
        void render(){/* here we'll render*/}

    private:
        // our members for now ... 
        std::size_t m_width;
        std::size_t m_height;
        SDL_Window* m_window; 
        SDL_Renderer* m_renderer;
        bool m_is_running = true;
};


// .... 

// which will create a window by running this main
// and closes the window by pressing escape

int main(int argc, char* argv[]) 
{
    // create a game on a 800x600 window
    cwt::game game(800, 600);
    while(game.is_running()) 
    {
        game.read_input();
        game.update();
        game.render();
    }
    return 0;
}

Entity实现

Entity最简单,就是唯一的id,这里用自增的逻辑实现

实际项目中可能会涉及到删除entity处理,更复杂点

using entity = std::size_t;
entity max_entity = 0;
std::size_t create_entity()
{
    static std::size_t entities = 0;
    ++entities;
    max_entity = entities;
    return entities;
}

Component实现

这个demo中,小鸟只有三个属性

  • sprite,表示小鸟实体,用图片纹理表示
  • 小鸟的位移和速度
  • 按键状态(demo中逻辑简单,统一响应wsad,不做区分,所以为空结构体)

数据结构可以设计为:

struct sprite_component{
    SDL_Rect src;
    SDL_Rect dst;
    SDL_Texture* texture;
};

struct transform_component{
    float pos_x;
    float pos_y;
    float vel_x;
    float vel_y;
};

struct keyinputs_component{ 
    
};

另外,还需要增加registry类,用来存放实际的component数据,此处用三个map存放

struct registry {
    std::unordered_map<entity, sprite_component> sprites;
    std::unordered_map<entity, transform_component> transforms;
    std::unordered_map<entity, keyinputs_component> keys;
};

System实现

三个component对应三个system,比较好理解。

struct sprite_system 
{
    void update(registry& reg)
    {
        for (int e = 1 ; e <= max_entity ; e++) {
            if (reg.sprites.contains(e) && reg.transforms.contains(e)){
                reg.sprites[e].dst.x = reg.transforms[e].pos_x;
                reg.sprites[e].dst.y = reg.transforms[e].pos_y;
            }
        }
    }
    void render(registry& reg, SDL_Renderer* renderer)
    {
        for (int e = 1 ; e <= max_entity ; e++) {
            if (reg.sprites.contains(e)){
                SDL_RenderCopy(
                    renderer, 
                    reg.sprites[e].texture, 
                    &reg.sprites[e].src, 
                    &reg.sprites[e].dst
                );
            }
        }
    }
};

struct transform_system 
{
    float dt = 0.1f;
    
    void update(registry& reg)
    {
        for (int e = 1 ; e <= max_entity ; e++) {
            if (reg.transforms.contains(e)){
                reg.transforms[e].pos_x += reg.transforms[e].vel_x*dt;
                reg.transforms[e].pos_y += reg.transforms[e].vel_y*dt;
            }
        }
    }
};

struct movement_system 
{  
    void update(registry& reg)
    {
        const Uint8* keys = SDL_GetKeyboardState(NULL);

        for (int e = 1 ; e <= max_entity ; e++) {
            if (reg.transforms.contains(e) && reg.keys.contains(e)){
                
                if (keys[SDL_SCANCODE_A]) { reg.transforms[e].vel_x = -1.0f; } 
                if (keys[SDL_SCANCODE_S]) { reg.transforms[e].vel_y = 1.0f; }
                if (keys[SDL_SCANCODE_W]) { reg.transforms[e].vel_y = -1.0f; }
                if (keys[SDL_SCANCODE_D]) { reg.transforms[e].vel_x = 1.0f; }
                
                if (!keys[SDL_SCANCODE_A] && !keys[SDL_SCANCODE_D]) { reg.transforms[e].vel_x = 0.0f; }
                if (!keys[SDL_SCANCODE_S] && !keys[SDL_SCANCODE_W]) { reg.transforms[e].vel_y = 0.0f; }
            } 
        }
    }
};

main中把完整逻辑串起来

#include "game.hpp"
#include "bird.hpp"


int main(int argc, char* argv[]) 
{
    // 创建游戏窗口 800 * 600
    cwt::game game(800, 600);

    // 创建第一只小鸟,
    cwt::entity bird_1 = cwt::create_entity();
    // 增加第一只小鸟的外观实体
    game.get_registry().sprites[bird_1] = cwt::sprite_component {
        SDL_Rect{0, 0, 300, 230}, 
        SDL_Rect{10, 10, 100, 73}, 
        IMG_LoadTexture(game.get_renderer(), bird_path)
    };

    // 第一只小鸟有移动、响应键盘的属性,增加对应的transforms、keys属性
    game.get_registry().transforms[bird_1] = cwt::transform_component { 10, 10, 0, 0};
    game.get_registry().keys[bird_1] = cwt::keyinputs_component { };
    
    // 创建第二只小鸟
    cwt::entity bird_2 = cwt::create_entity();
    // 增加第二只小鸟的外观实体
    game.get_registry().sprites[bird_2] = cwt::sprite_component {
        SDL_Rect{0, 0, 300, 230}, 
        SDL_Rect{0, 0, 100, 73}, 
        IMG_LoadTexture(game.get_renderer(), bird_path)
    };
    // 第二只小鸟有transforms组件,没有键盘响应的component,静止
    game.get_registry().transforms[bird_2] = cwt::transform_component { 10, 500, 0.01f, -0.01f};

    // 第三只小鸟只有sprit组件
    cwt::entity bird_3 = cwt::create_entity();
    game.get_registry().sprites[bird_3] = cwt::sprite_component {
        SDL_Rect{0, 0, 300, 230}, 
        SDL_Rect{200, 300, 100, 73}, 
        IMG_LoadTexture(game.get_renderer(), bird_path)
    };

    while(game.is_running()) 
    {
        // 交互:处理输入逻辑
        game.read_input();
        // 处理更新
        game.update();
        // 渲染上屏
        game.render();
    }
    
    return 0;
}

read_input、update、render逻辑实现

void read_input()
{
    SDL_Event sdl_event;
    SDL_PollEvent(&sdl_event);            
    const Uint8* keystates = SDL_GetKeyboardState(NULL);

    if (keystates[SDL_SCANCODE_ESCAPE] || sdl_event.type == SDL_QUIT) {
        m_is_running = false;
    }
}
void update()
{
    m_transform_system.update(m_registry);
    m_movement_system.update(m_registry);
    m_sprite_system.update(m_registry);
}
void render()
{
    SDL_RenderClear(m_renderer);

    m_sprite_system.render(m_registry, m_renderer);

    SDL_RenderPresent(m_renderer);
}

补充

原文作者还写了一篇,基于开源框架的Entt改造Demo的文章 www.codingwiththomas.com/blog/use-en…

其他参考:

[C++] An Entity-Component-System From Scratch

Unity教程Entity Component System

Specs and Legion, two very different approaches to ECS

欢迎关注公众号:sumsmile /专注图形学