游戏引擎开发-图形篇(四)——相机、系统重构

在连续几天艰苦卓越的战斗后,这次完成了对于整个系统的重构,顺带实现了摄像机以及坐标系统,在本期博客就分享一下开发过程中的一些心得。


重构系统

随着图形引擎的功能逐渐增多,文件之间的相互依赖逐渐复杂,为了解决隐含的链接问题,并降低调试成本,对于那些频繁调用的代码,将会使用单例模式封装,在项目中全局管理依赖关系。

单例模式是一种创建型设计模式,主要解决一个类只能有一个实例,提供一个全局访问点的问题。它通常包含一个私有的构造函数,一个保存唯一实例的静态变量以及一个获取该实例的公有方法。

实现

考虑到未来的开发中会频繁使用单例,新建一个基类名为Singleton.h,并在其中实现懒汉式线程安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Singleton
        {
            public:
                static T* getInstance()
                {
                    static std::once_flag s_flag;
                    std::call_once(s_flag,[&](){
                        m_Instance.reset(new T());
                    });
                    return m_Instance.get();
                }

                void Address()const
                {
                    std::cout<<this<<std::endl;
                }
            protected:
                static std::shared_ptr<T> m_Instance;
            private:
                Singleton(const Singleton&)=delete;
                Singleton& operator=(const Singleton&)=delete;
        };

之后将所有OpenGL渲染相关的代码放入一个单例中并调用:

1
OWL::global::WindowManager* window=OWL::global::WindowManager::getInstance();

如此重构代码的优点是延迟加载,提高性能,而且还可以方便的管理全局对象。因为考虑了高并发情况,还特意加了线程锁,这种设计在《游戏引擎架构》中也略有提及,因此做了尝试。


摄像机类

在重构了代码后开始着手编写摄像机相关的逻辑,我们都知道在游戏引擎中的摄像机不光有自由移动的FPS摄像机,也有一直盯着目标的定点摄像机(使用商业引擎,你还能见到五花八门的摄像机类型),因此在设计摄像机时要同时兼顾这两类,仔细思考不难发现,自由摄像机其实就是定点摄像机的特殊用法,所以我们将会使前者成为后者的子类。

同时用过游戏引擎的人应该注意到,摄像头是摆放在场景中的,也应该拥有与场景中其他物体一样的参数(位置,缩放,是否激活(active)等),因此在设计摄像机时我想同时开启另一个基类的设计,这个基类派生所有场景内的物体,拥有自己的标识,可以有树状的父子关系(只能有一个父节点),拥有所有物体的通用参数,这个类在Unity中叫做GameObject。这样就可以方便的在物体之间互相调用,也有利于我们的内存管理。

这里给出笔者目前设计的简单Object基类(未来还会完善设计)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Object
{
    private:
        //基本参数
        Transform m_Transform;
        bool m_Active;
        unsigned int m_ObjectID;
        Object* m_parent; //Object的父节点
        std::vector<std::pair<std::string,Object*>> m_Children;//Object的子节点

    public:
        Object(){}
        virtual ~Object(){}
        virtual void Start(){}
        virtual void Update(float deltaTime){}

        int GetID()const{return m_ObjectID;}
        bool IsActive()const{return m_Active;}
        void Active(bool value){m_Active=value;}

        void AddChild(const std::string& name,Object* child)
        {
            m_Children.push_back(std::make_pair(name,child));
        }
        void SetParent(Object* const parent){m_parent=parent;}

        //XXX 还不确定transform的值是否可以在外部修改,如有必要请修改定义
        Transform* GetTransform(){return &m_Transform;}
};

这样一来便万事俱备,可以开始设计必要组件了。

定点摄像机(标准摄像机)

摄像机在OpenGL充当着所谓的View视图,通过glm::LookAt()进行换算,而这个函数需要提供摄像机的朝向及上轴,我们的任务就是计算出朝向、右轴及上轴。

数学原理

利用向量减法算出从摄像机到目标物体的向量,然后再与世界坐标的上轴进行叉乘算出右轴,最后右轴与前轴(朝向)进行叉乘即可得出上轴,由此我们得出了在摄像机观察下的View视图。

在实际开发中,为了更新这些参数,需要在主循环中调用更新函数,这点非常重要,还要利用uniform将坐标传给着色器。

实现

因为代码很长,这里就稍微展示一下坐标变换的部分。

1
2
3
4
m_TargetDir=glm::normalize(m_Transform.position-m_TargetPos);
        glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f);
        m_RightDir = glm::normalize(glm::cross(up, m_TargetDir));
        m_UpDir = glm::cross(m_TargetDir, m_RightDir);

注意这里将世界坐标的上轴设为了(0,1,0),这不一定正确,只能算是妥协之策。

自由摄像机(FPS摄像机)

自由摄像机由于涉及到了外部设备输入可能会稍微复杂一点,但是只要搞清了俯仰角和偏航角的三角学运算,就没什么难的,这里不作展示。

另外值得一提的是,由于将实现逻辑放在了类里,不建议在将更新逻辑放在回调函数中了,在设计时我保持着“除了在一开始创建窗口时,不允许进行任何回调函数的注册的准则”,这样可以保持系统在不同环境下的一致性。 其实是笨比笔者不知道在如何不借助外部声明的情况下进行类成员函数的回调函数注册


踩过的坑

这回遇到了不少问题,排查也花了不少时间,因此我就挑一点来说吧。

在设计Window类的时候,我计划着设计一个函数模板用于快捷注册glfw窗口的回调函数,函数原型是这样的:template<typename T> void SetCallback(T callback);,起初这个函数还能很好地运行,但是在添加了更多了特化后出现了问题,GLFWcursorposfunGLFWscrollfun是拥有不同别名的同一函数指针类型,因此无法针对他们设计特化函数。然而这两种回调函数却要用不同的接口进行注册,本来想过用枚举,但这使得特定封装的意义消失了,最后不得不暂时放弃这个部分的开发,希望各位读者也能引以为鉴。


结语

摄像机是游戏中玩家视角的关键组成部分,通过不同的摄像机设置可以实现第一人称、第三人称等不同的游戏体验。此外图形编辑窗口中也不得不用到自由视角,所以今天的部分至关重要,不过有预感这部分未来要重新写一遍了,就当试错吧……我自己定下的Deadline越来越近了,为此必须要在两天内结束图形部分,目前图形还剩下光照、简单形状封装,第三方模型文件导入没做,希望能在不熬夜的情况下尽快做完虽然这几天天天在熬夜就是了。好想赶紧开始写骨架部分啊,要比喻的话现在才刚刚写到骨髓。