Hazel引擎学习(十一)
创始人
2025-06-01 22:24:03

我自己维护引擎的github地址在这里,里面加了不少注释,有需要的可以看看


参考视频链接在这里


很高兴的是,引擎的开发终于慢慢开始往深了走了,前几章的引擎UI搭建着实是有点折磨人,根据课程,接下来的引擎开发路线是:

  • Content Browser:又是UI。。。。
  • 简单的UUID系统
  • PlayMode的开发,点击按钮可以实现Editor下游戏的播放与暂停
  • 2D物理系统
  • 基础图元的渲染(目前只有Quad)
  • C#脚本层与引擎C++代码的交互:这个我很感兴趣,跟Unity交互的方式是类似的,也是用Mono

不过由于我对某些章节比较感兴趣,所以实现模块的顺序可能跟Cherno课上的顺序略有不同


C# Scripting

Scripting is designed to control the Engine.

之前在学Unity的时候,我就对这一块特别感兴趣,因为我在没有完全了解C#、Mono和C++之间的交互原理之前,去看Unity的源码是一件非常费劲的事情,所以把这几课的内容提前实现了。

参考:Mono Embedding for Game Engines

引擎打算跟Unity一样,使用C#作为脚本层,此时需要使用Mono作为C#与C++语言的衔接层,这里的Mono由于本身就很大,所以不作为Submodule了,而是直接下载对应的Binary放到引擎里,具体步骤有:

  • 了解Mono
  • 获取Mono对应的Debug和Release版本的库文件,还有对应的头文件
  • 获取Mono使用到的.NET的库文件,比如mscorlib.dll
  • 在Hazel项目里添加对应库文件的配置
  • 修改Premake5.lua文件,在Hazel里添加一个新的Project,作为C#这边代码的Project,C#工程会编译得到一个dll
  • 写C++代码,把前面编译得到的C# dll读取到C++的Mono项目里

了解Mono

Mono is an open source implementation of Microsoft’s .NET Framework.

Mono这个项目是微软官方开发的,最初是为了让.NET语言(比如C#)能在除了windows之外的系统上运行,不过现在的.NET Core已经是自动支持跨平台了,但这并不意味着Mono就没用了,它仍然为在.NET Runtime里使用C/C++的API提供了很好的支持,根据我在Unity论坛上看到的官方在2016年发布的声明来看,Unity近期内也没有舍弃Mono,来用.NET Core的想法,相关言论如下:

We will not be using .NET Core to replace Mono. The open-source runtime is not ported to enough platforms for Unity, and it doesn’t have the embedding hooks that we need (Mono fulfills both of those requirements). Also, the class library profile in .NET Core doesn’t have a number of things that we support currently, and others it moves around, effectively breaking all previous Unity projects - so using it is not feasible.
We will likely support the .NET Standard though. Plans are still up in the air as we work through the technical issues. Please watch that experimental scripting previews forum for more details. We will continue to post there when we have new builds to drop with added functionality.

Mono其实有俩主要的版本:

  • Classic Mono:只支持最高到C#7,.NET frameworkd 4.7.2的版本
  • .NET Core Mono:与跨平台.NET Core相匹配的Mono,支持最新版的C#

这里选用的Mono版本是Classic Mono,原因如下:

  • Classic Mono更简单
  • .NET Core Mono暂时不支持assembly reloading,这意味着我写C#代码,需要手动重新编译,那我开启游戏Editor在里面写脚本时,我肯定希望是Runtime重新load assembly的,不可能我每次改脚本都要重启引擎

获取Mono对应的Debug和Release版本的库文件

这里需要的库文件分为两类:.NET原生的库文件和Mono提供的库文件。

这里选择直接下载Mono的Github项目,然后选择一个可用的Tag版本,手动Build出来对应的库文件。Mono的官方Build文档看起来很复杂,但实际上好像没这么麻烦,直接clone该项目,它里面已经自带了VS用的solution文件了,在msvc文件夹下:
在这里插入图片描述
类似之前配置Vulkan SDK在项目里的方式,之前是调用Vulkan SDK.exe,然后把安装得到的include目录添加到Hazel Project的include路径,再把安装得到的Debug和Release下的库文件也添加到Hazel Project的depend路径即可。

这里的做法差不多,无非这里的header和库文件都是我亲手build出来的,直接从这个solution里build即可,会得到如下文件夹:
在这里插入图片描述

把里面的东西直接挪到HazelEditor/Mono/lib文件夹下即可,后面打算使用static link的方式,把mono link到Hazel里:
在这里插入图片描述
Mono需要的库文件有:

  • eglib.lib
  • libgcmonosgen.lib
  • libmini-sgen.lib
  • libmonoruntime-sgen.lib
  • libmono-static-sgen.lib
  • libmonoutils.lib
  • mono-2.0-sgen.lib
  • MonoPosixHelper.lib

里面分为Debug和Release两个版本:
在这里插入图片描述


获取Mono使用到的.NET的库文件

至于.NET提供的库文件,需要到网上去下载,这里选择直接安装Mono.exe,安装之后会存放对应的.NET库文件,如下图所示:
在这里插入图片描述
把这里的4.5文件夹下的内容,拷贝到Hazel的vendor下即可,我这里的存放路径为:
在这里插入图片描述



创建C#工程和Assembly

步骤也不麻烦:

  • C#代码都放HazelEditor的Scripts文件夹下
  • 用premake5.lua文件创建C#工程

先创建对应C#源码的路径,这个Test.cs作为样例cs文件:
在这里插入图片描述

再写premake5.lua文件即可,C#工程需要编译出一个dll:

project "Hazel-ScriptCore"location "%{prj.name}"kind "SharedLib"language "C#"dotnetframework "4.7.2"targetdir ("%{prj.name}/Build")objdir ("%{prj.name}/Intermediates")files {"%{prj.name}/Scripts/**.cs"}

梳理一下各个项目之间的关系

这里先梳理一下各个项目之间的关系:
整个C++部分的引擎工程(即Hazel项目,包括其依赖的Mono部分),都是编译为一个Hazel.lib的库文件的,HazelEditor工程会Link这个Hazel.lib然后一起编译出一个HazelEditor.exe文件,作为用户开发游戏的Editor软件(类似Unity2020.exe),最后,C#部分的工程会build出来一个C#的dll,再把它跟HazelEditor.exe文件放一起应该就可以了。

C++与C#之间交互的核心原理

C++与C#之间交互的核心原理其实很简单,无论是C++调用C#,还是C#调用C++都是通过中间的Mono实现的,具体有:

  • C++调用C#时,由于C#的metadata的机制,mono可以直接知道它有哪些method,哪些类,只要知道名字,就可以直接从C++这边通过Mono调用
  • C#调用C++时,由于C++没有直接类似的metadata机制,所以C++里需要选择性的暴露接口出来,然后再在Mono这边登记过后,C#这边才可以调用

Scripting类的创建

参考:Mono Embedding for Game Engines

为了实现C++和C#通过Mono互相调用,需要额外写对接的代码。这里先保证能从C++端调用C#的代码,把前面编译得到的C# dll读取到C++的Mono项目里,同时在C++里通过Mono去操作C#这边dll里的内容,比如call method,读取Property和Field值等。

我创建了个Scripting类(这一部分代码参考前面提到的文档就行了):

#pragma once
#include 
#include "mono/metadata/image.h"
#include "mono/jit/jit.h"namespace Hazel
{// 类似于Unity, C#这边的脚本层分为核心层和用户层两块// 核心层的代码(C#这边的源码)应该是和C++的代码会存在相互调用的情况的class Scripting{public:MonoAssembly* LoadCSharpAssembly(const std::string& assemblyPath);void PrintAssemblyTypes(MonoAssembly* assembly);// 根据C++这边输入的class name, 返回对应的MonoClass, 如果想在C++端创建C#上的对象, 需要借助此APIMonoClass* GetClassInAssembly(MonoAssembly* assembly, const char* namespaceName, const char* className);MonoObject* CreateInstance(MonoClass* p);// Mono gives us two ways of calling C# methods: mono_runtime_invoke and Unmanaged Method Thunks. // This Api will only cover mono_runtime_invoke// Using mono_runtime_invoke is slower compared to Unmanaged Method Thunks, but it's also safe and more flexible. // mono_runtime_invoke can invoke any method with any parameters, and from what I understand mono_runtime_invoke also does a lot more error checking and validation on the object you pass, as well as the parameters.// 在编译期不知道Method签名时, 适合用mono_runtime_invoke, 每秒高频率调用(10fps)的Method适合用Unmanaged Method Thunks, void CallMethod(MonoObject* instance, const char* methodName);// Field can be public or privateMonoClassField* GetFieldRef(MonoObject* instance, const char* fieldName);templateconst T& GetFieldValue(MonoObject* instance, MonoClassField* field){T value;mono_field_get_value(instance, field, &value);return value;}MonoProperty* GetPropertyRef(MonoObject* instance, const char* fieldName);templateconst T& GetPropertyValue(MonoObject* instance, MonoProperty* prop){T value;mono_property_get_value(instance, prop, &value);return value;}};
}

类实现如下:

#include "hzpch.h"
#include "Hazel/Utils/Utils.h"
#include "mono/metadata/assembly.h"
#include "Scripting.h"namespace Hazel
{static MonoDomain* s_CSharpDomain;// 读取一个C# dll到Mono里, 然后返回对应的Assembly指针MonoAssembly* Scripting::LoadCSharpAssembly(const std::string& assemblyPath){// InitMono部分// Let Mono know where the .NET libraries are located.mono_set_assemblies_path("../Hazel/vendor/Mono/DotNetLibs/4.5");MonoDomain* rootDomain = mono_jit_init("MyScriptRuntime");if (rootDomain == nullptr){// Maybe log some error herereturn nullptr;}// Create an App Domains_CSharpDomain = mono_domain_create_appdomain("MyAppDomain", nullptr);mono_domain_set(s_CSharpDomain, true);uint32_t fileSize = 0;// 用于直接读取C#的.dll文件, 把它读作bytes数组char* fileData = Utils::ReadBytes(assemblyPath, &fileSize);// NOTE: We can't use this image for anything other than loading the assembly because this image doesn't have a reference to the assemblyMonoImageOpenStatus status;// 把读取的dll传给Mono, 得到的assembly会存在Mono这边, 暂时不需要反射MonoImage* image = mono_image_open_from_data_full(fileData, fileSize, true, &status, false);if (status != MONO_IMAGE_OK){const char* errorMessage = mono_image_strerror(status);// Log some error message using the errorMessage datareturn nullptr;}// 从image里读取assembly指针MonoAssembly* assembly = mono_assembly_load_from_full(image, assemblyPath.c_str(), &status, 0);mono_image_close(image);// Don't forget to free the file datadelete[] fileData;return assembly;}// iterate through all the type definitions in our assemblyvoid Scripting::PrintAssemblyTypes(MonoAssembly* assembly){MonoImage* image = mono_assembly_get_image(assembly);// 从assembly的meta信息里读取meta data table, 这里读取的是Type对应的Table, 表里的每一行// 代表一个Typeconst MonoTableInfo* typeDefinitionsTable = mono_image_get_table_info(image, MONO_TABLE_TYPEDEF);int32_t numTypes = mono_table_info_get_rows(typeDefinitionsTable);// 遍历Table里的每行, 这里的numTypes最小为1, 因为C#的DLL和EXEs默认都会有一个Module类型的Type, 代表整个// assembly的modulefor (int32_t i = 1; i < numTypes; i++){// 每一行的每列元素记录了Type的相关信息, 比如namespace和type nameuint32_t cols[MONO_TYPEDEF_SIZE];mono_metadata_decode_row(typeDefinitionsTable, i, cols, MONO_TYPEDEF_SIZE);// 还可以获取field list和method list等const char* nameSpace = mono_metadata_string_heap(image, cols[MONO_TYPEDEF_NAMESPACE]);const char* name = mono_metadata_string_heap(image, cols[MONO_TYPEDEF_NAME]);printf("%s.%s\n", nameSpace, name);}}MonoClass* Scripting::GetClassInAssembly(MonoAssembly* assembly, const char* namespaceName, const char* className){MonoImage* image = mono_assembly_get_image(assembly);MonoClass* klass = mono_class_from_name(image, namespaceName, className);if (!klass)return nullptr;return klass;}MonoObject* Scripting::CreateInstance(MonoClass* p){if (!p) return nullptr;MonoObject* classInstance = mono_object_new(s_CSharpDomain, p);// Call the parameterless (default) constructormono_runtime_object_init(classInstance);return classInstance;}void Scripting::CallMethod(MonoObject* objectInstance, const char* methodName){// Get the MonoClass pointer from the instanceMonoClass* instanceClass = mono_object_get_class(objectInstance);// Get a reference to the method in the classMonoMethod* method = mono_class_get_method_from_name(instanceClass, methodName, 0);if (!method) return;// Call the C# method on the objectInstance instance, and get any potential exceptionsMonoObject* exception = nullptr;mono_runtime_invoke(method, objectInstance, nullptr, &exception);// TODO: Handle the exception}// 注意, MonoClassField本身不含Field数据, 里面存的是数据相对于object的offsetMonoClassField* Scripting::GetFieldRef(MonoObject* objInstance, const char* fieldName){MonoClass* testingClass = mono_object_get_class(objInstance);// Get a reference to the public field called "MyPublicFloatVar"return mono_class_get_field_from_name(testingClass, fieldName);}MonoProperty* Scripting::GetPropertyRef(MonoObject* objInstance, const char* propertyName){MonoClass* testingClass = mono_object_get_class(objInstance);// Get a reference to the public field called "MyPublicFloatVar"return mono_class_get_property_from_name(testingClass, propertyName);}
}

然后在代码里随便找个地方调用它来测试一下即可,我测过是OK的



Calling C++ from C#

前面的部分实现了在C++调用C#里的任何内容,包括调用Method和获取Property和Field等,现在需要反过来,实现在C#里调用C++提供的API。其实有很多可选的做法:

  • 使用Platform Invoke (P/Invoke),
    这种做法更适合C#工程去使用C++的dll时使用,我之前工作时就是用Unity去通过这种方式,调用寻路导航插件的dll的
  • 借助Mono的Internal Call
  • C++和C#的中间语言:C++/CLI,EA的寒霜引擎就是用的C#作为编辑器,C++作为Runtime,它们使用C++/CLI进行交互,不过这玩意儿是只支持Windows的

本章的内容如下:

  • 介绍P/Invoke
  • 学习如何借助Mono的Internal Call,在C#里调用C++的代码

关于P/Invoke

P/Invoke is a technology that allows you to access structs, callbacks, and functions in unmanaged libraries from your managed code. Most of the P/Invoke API is contained in two namespaces: System and System.Runtime.InteropServices. Using these two namespaces give you the tools to describe how you want to communicate with the native component.

重点是:从managed code里获取unmanaged库里的structs、回调和functions,相关的managed的API都主要是在System System.Runtime.InteropServices命名空间下。

举个简单例子:

using System;
using System.Runtime.InteropServices;public class Program
{// Import user32.dll (containing the function we need) and define// the method corresponding to the native function.// 关键的DllImport Attribute, 它会让.NET Runtime去load对应的unmanaged dll(user32.dll)[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]// p/Invoke会定义与C++的函数签名完全相同的C# Method, private static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);public static void Main(string[] args){// Invoke the function as a regular managed method.MessageBox(IntPtr.Zero, "Command-line message box", "Attention!", 0);}
}

这种写法的调用还是有overhead的,它比普通的调用C# method的开销要大


Mono的Internal Call

参考:Embedding Mono
参考:Mono Embedding for Game Engines

目前的Scripting系统,对应的.NET和Assembly的代码,其实是Hazel的一部分,所以并不适合用P/Invoke(而且Hazel引擎目前是用的static linking)。这里的Internal Call,相当于告诉.NET Runtime,我有一些Native Functions你可以调用。

之前都是利用mono_runtime_object_initmono_metadata_decode_row等函数,通过mono_runtime或者meta机制,在C++里对C#进行操作的。C++这边没有metadata这种方便的东西,所以要通过这里的Internal Call,即mono提供的另一种机制,暴露接口给Mono。

主要是学习一下怎么写API,这里一共举了这么些例子:

  • C#调用C++的无参静态函数
  • C#调用C++的带参静态函数,参数为string
  • C#调用C++的带参静态函数,参数为C#这边自定义的struct(值类型)
  • C#调用C++的带参静态函数,参数为C#这边自定义的class(引用类型)
  • C#调用C++的重名重载函数

核心就是把C++的static函数通过mono_add_internal_call函数登记一下,然后就可以在C#这边调用了


C#调用C++的无参静态函数
举个简单的例子,有个无参的静态函数,C++端的写法如下:

static void PrintFuncForCSharp()
{LOG("PrintFuncForCSharp");
}// method需要用ClassName::的形式
mono_add_internal_call("MyNamespace.Program::Print", &PrintFuncForCSharp);

C#端的写法如下:

using System;
using System.Runtime.CompilerServices;namespace MyNamespace
{public class Program{public float MyPublicFloatVar = 5.0f;public void PrintFloatVar(){Console.WriteLine("MyPublicFloatVar = {0:F}", MyPublicFloatVar);Print();}[MethodImplAttribute(MethodImplOptions.InternalCall)]// 函数的名字其实是在C++这边就已经写死了的extern static void Print();}
}

C#调用C++的string为参数的静态函数
如果函数有签名,也是差不多的写法,需要注意的是函数参数为string时的情况,由于托管堆和非托管堆的string内存结构不同,所以从C#调用C++带string参数的函数时,C++这边对应函数的参数不是string,而是MonoString*,举个例子,C++端的写法如下:

static void PrintStringFuncFromCSharp(MonoString* str)
{char* arr = mono_string_to_utf8(str);LOG(arr);// 释放内存mono_free(arr);
}mono_add_internal_call("MyNamespace.Program::PrintString", &PrintStringFuncFromCSharp);

C#端的写法如下:

using System;
using System.Runtime.CompilerServices;namespace MyNamespace
{public class Program{public void PrintFloatVar(){PrintString("PrintString");}[MethodImplAttribute(MethodImplOptions.InternalCall)]extern static void PrintString(string s);}
}

C#调用C++的自定义struc为参数的静态函数
由于C#里struct和class的区别,这里具体分为两种,如果是struct,那么写法为:

// csharp这边传入的参数为ref A ...// c++这边传入的参数为A*

C#调用C++的重名重载函数
鉴于mono_add_internal_call("MyNamespace.Program::Print", &PrintFuncForCSharp);这种写法,C++这边应该是不支持函数重载的,但是C#这边是可以通过Wrapper来模拟函数重载的,所以C++这边只能用老的C语言的方式处理函数重载了,比如:

// C++ 端
static void Func(){};
static void FuncString(std::string){};// C#端
[MethodImplAttribute(MethodImplOptions.InternalCall)]
extern static void Func();[MethodImplAttribute(MethodImplOptions.InternalCall)]
extern static void FuncString(string);// 加个wrapper
public static void Function()
{Func();
}public static void Function(string s)
{FuncString(s);
}

Internal Call的特殊情况

如果想要在C++设计一个函数,这个函数返回一个指针或引用,此时再把这个函数暴露给C#,是不太好的,更好的方法是让这个函数返回值为void,原本返回的参数作为函数参数传入和传出(应该C#这边也能接受C++返回的指针,无非是要用unsafe的代码),写法大概是这样:

// C++ 
static void Func(glm::vec3* para, glm::vec3* outResult)// 原本想返回的*变成了参数
{...
}// C#
[MethodImplAttribute(MethodImplOptions.InternalCall)]
extern static void FuncString(ref Vector3 para, out Vector3 result);

而且这样写也不合理,应该是谁分配的内存,谁负责释放。

至此,C#与C++的交互的基础框架代码基本写好了,后面的课程Using C# Scripting with the Entity Component System是基于前面搭建好的关卡制作的,所以我就接着从前面跳过的Content Browser开始学习了。



Content Browser

虽然Content Browser很明显是Editor下用的东西,但并不意味着任何存在于Editor下的模块都没有在Runtime下出现的可能,毕竟它可能需要在Runtime为了Debug使用,Content Browser的主要功能是:

  • 提供资源窗口
  • 方便把资源直接拖拽到Scene里

显然这个功能是不大可能需要进行Runtime Debug使用的(前面做过的Hierarchy窗口还有一些在Runtime使用可能,它可以帮忙看看Runtime下的场景Hierarchy)。所以会把Content Browser相关的代码写到HazelEditor工程下,目前的Hierarchy窗口则写在Hazel工程里。

这节课主要内容:

  • 学会使用C++的directory_iterator遍历directories(见附录)
  • 创建ContentBrowserPanel类,也是单独占一个ImGui窗口,类似于现有的HierarchyPanel类,在其OnImGuiRender函数里绘制相关界面

创建ContentBrowserPanel类

类声明很简单:

namespace Hazel
{class ContentBrowserPanel{public:const float HEIGHT = 24.0f;void Init();void OnImGuiRender();private:std::filesystem::path m_CurSelectedPath;std::filesystem::path m_LastSelectedPath;std::shared_ptr m_DirTex;std::shared_ptr m_FileTex;};
};

写代码绘制资源窗口

思路是文件夹和文件都用Button绘制,Button对应的背景图片是一个文件夹的图片,代码如下:

namespace Hazel
{void ContentBrowserPanel::Init(){m_DirTex = Texture2D::Create("Resources/Icons/DirectoryIcon.png");m_FileTex = Texture2D::Create("Resources/Icons/FileIcon.png");}void ContentBrowserPanel::OnImGuiRender(){ImGui::Begin("ContentBrowser");{std::filesystem::path p;if (m_CurSelectedPath.empty()){p = std::filesystem::current_path();m_CurSelectedPath = p;}elsep = std::filesystem::current_path() / (m_CurSelectedPath);// Combine Pathif (ImGui::Button("<-")){if (!m_LastSelectedPath.empty())m_CurSelectedPath = m_LastSelectedPath;}// 绘制项目根目录下的所有内容for (const std::filesystem::directory_entry& pp : std::filesystem::directory_iterator(p)){bool isDir = pp.is_directory();int frame_padding = -1;										// -1 == uses default padding (style.FramePadding)ImVec2 size = ImVec2(HEIGHT, HEIGHT);						// Size of the image we want to make visibleif (isDir)ImGui::Image((ImTextureID)m_DirTex->GetTextureId(), size, { 0, 0 }, { 1, 1 });elseImGui::Image((ImTextureID)m_FileTex->GetTextureId(), size, { 0, 0 }, { 1, 1 });ImGui::SameLine();if (ImGui::Button(pp.path().string().c_str())){if (isDir){m_LastSelectedPath = m_CurSelectedPath;m_CurSelectedPath = pp;}//LOG(m_CurSelectedPath);}}}ImGui::End();}
}

效果如下图所示:
在这里插入图片描述


Content Browser Panel - ImGui Drag Drop

做了以下事情:

  • 把单击进入folder改成双击鼠标进入folder,属于ImGui的相关API写法
  • 通过Push和Pop StyleColor,去掉ImGui::ImageButton的默认背景颜色,属于ImGui的相关API写法(我用的ImGui::Image绘制的icon,没有这个问题)
  • 实现Content Browser里的Drag和Drop,我可以从里面拖拽Scene文件到Viewport里,快速打开该Scene

代码如下:

// 1. 双击进入folder
// 不再直接判断Button是否点击了, 而是通过ImGui的MouseDoubleClick状态和是否hover来判断双击的
// 其实这里的ImGui::Button改成ImGui::Text也可以双击, 无非是没有hover时的高亮button效果了
ImGui::Button(pp.path().string().c_str());
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left))
{if (isDir){m_LastSelectedPath = m_CurSelectedPath;m_CurSelectedPath = pp;}
}// 2. 通过这种写法, 让Button绘制时的默认颜色为0,0,0,0, 去掉alpha通道的影响
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
ImGui::ImageButton((ImTextureID)icon->GetRendererID(), { thumbnailSize, thumbnailSize }, { 0, 1 }, { 1, 0 });
ImGui::PopStyleColor();

imgui拖拽相关的api比较复杂,所以这里单独分析一下。看了下Cherno写的代码,感觉imgui设计DragAndDrop的思路是这样的:
在imgui的render的循环里,在绘制了element后可以发出Drag的请求(此时的Drag是只针对前面绘制的element使用的),Drag时可以用byte数组的形式传入Data(也叫payload),imgui会把这个数据和drag的请求存起来,然后在别的窗口的render的代码里,可以查询Drop的状态,一旦判定了鼠标在该窗口释放,那么Drop的状态返回true,然后可以获取出对应的payload

相关代码如下:

// PS: imgui.h的698行提供了相关Drag和Drop的API// 发送数据和Drag请求的代码
void ContentBrowserPanel::OnImGuiRender()
{ImGui::Begin("ContentBrowser");{...// 绘制项目根目录下的所有内容for (const std::filesystem::directory_entry& pp : std::filesystem::directory_iterator(p)){...const auto& path = pp.path();// 不再直接判断Button是否点击了, 而是通过ImGui的MouseDoubleClick状态和是否hover来判断双击的// 其实这里的ImGui::Button改成ImGui::Text也可以双击, 无非是没有hover时的高亮button效果了ImGui::Button(path.string().c_str());if (path.extension() == ".scene"){// 拖拽时传入拖拽的item的pathif (ImGui::BeginDragDropSource()){const wchar_t* itemPath = path.c_str();int len = wcslen(itemPath) + 1;// Convert w_char array to char arr(deep copy)char* itemPathArr = new char[len];std::wcsrtombs(itemPathArr, &itemPath, len, nullptr);ImGui::SetDragDropPayload("CONTENT_BROWSER_ITEM", itemPathArr, (len) * sizeof(char));ImGui::EndDragDropSource();}}...}}ImGui::End();
}// 接受数据和Drop请求的代码
ImGui::Begin("Viewport");
{...// Viewport其实就是一张贴图ImGui::Image(m_ViewportFramebuffer->GetColorAttachmentTexture2DId(), size, { 0,1 }, { 1,0 });if (ImGui::BeginDragDropTarget()){if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("CONTENT_BROWSER_ITEM")){const char* path = (const char*)payload->Data;LOG(path);// 后续可以打开Scene}ImGui::EndDragDropTarget();}...
}

这里我只打印了path,具体打开Scene的操作也很简单,以后再加上


Textures for Entities!

这节课的目的:

  • 给SprietRenderer的Inspector界面添加一个Texture的slot,然后绘制一些贴图属性
  • 拖拽ContenBrowser里的texture,可以给SpriteRenderer赋上贴图

这里使用的是2D的Renderer,这种2D的Renderer,目前是只用贴图赋值上去就行了,未来2D的Renderer需不需要material的参与,后面再说(可能还得参考Unity或Unreal的做法)


具体做法挺简单的,主要是imgui的写法:

// 发出Drag事件
ImGui::Begin("ContentBrowser");
{...if (path.extension() == ".scene")...if (path.extension() == ".png" || path.extension() == ".jpg"){// 拖拽时传入拖拽的item的pathif (ImGui::BeginDragDropSource()){const wchar_t* itemPath = path.c_str();int len = wcslen(itemPath) + 1;// Convert w_char array to char arr(deep copy)char* itemPathArr = new char[len];std::wcsrtombs(itemPathArr, &itemPath, len, nullptr);ImGui::SetDragDropPayload("CONTENT_BROWSER_ITEM_IMAGE", itemPathArr, (len) * sizeof(char));ImGui::EndDragDropSource();}}...
}
ImGui::End();// 接受Drag事件
// Draw SpriteRendererComponent
if (go.HasComponent())
{DrawComponent("SpriteRenderer", go, [](SpriteRenderer& sr){ImGui::ColorEdit4("Color", glm::value_ptr(sr.GetTintColor()));// 贴图槽位其实是用Button绘制的, 这里并没有绘制出贴图的略缩图ImGui::Button("Texture", ImVec2(100.0f, 0.0f));if (ImGui::BeginDragDropTarget()){// 在Content Panel里做了相关文件拽出的代码, 这里只要做接受的代码即可if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("CONTENT_BROWSER_ITEM_IMAGE")){const char* path = (const char*)payload->Data;std::filesystem::path texturePath = path;sr.SetTexture(Texture2D::Create(texturePath.string()));}ImGui::EndDragDropTarget();}ImGui::DragFloat("Tiling Factor X", &sr.GetTilingFactor().x, 0.1f, 0.0f, 100.0f);ImGui::DragFloat("Tiling Factor Y", &sr.GetTilingFactor().y, 0.1f, 0.0f, 100.0f);});
}

最后再改一下SprieRenderer组件函数,支持绘制带Texture的Quad即可



Everything You Need in a 2D Game Engine (Hazel 2D) - Let’s Talk

很遗憾这个系列未来只会支持2D了,不过其实也是合理的,Cherno已经教给了我们很多东西了,在这个基础上是该自己去学习,再去搭建属于自己的3D引擎了

这节课梳理了下知识点,值得记下来的有:

  • 2D游戏里经常用到Sprite Sheets,因为GPU往往只能一次性绑定32个通道的Texture,不过这种把多个贴图合为一个大贴图的方法,不只是用于Sprite Sheets,比如一个点光源、甚至多个点光源、周围六个方向的Shadow Map,都可以合并存到一个贴图上。甚至2D游戏里的动画都是Sprite Sheets实现的

目前的Hazel引擎,作为2D的游戏引擎,还缺少的功能有:

  • Animation系统:用Sprite Sheets即可实现,毕竟2D游戏的动画不会需要分辨率特别高的贴图
  • Shader和材质系统:2D游戏里,由于万物都是贴图,其实Material和Shader在2D游戏里并不是特别重要,但少数情况还是会用到,比如给角色周边添加彩色光照的buff效果
  • 后处理系统:比如添加bloom、color grading等效果,实现HDR Rendering
  • Scrpting: C#与C++交互的脚本系统
  • 可视化编程系统
  • Reflection系统:这个系统可以帮助在Inspector上直接调整Property的值,也可以实现Serialization(暂时不太懂为什么可以这么做),当在编辑器里更改数据的值时(比如从5变为6),C#相关的Assembly不需要重新编译即可改变内存里对应的值。通过反射也可以实现Assembly的加载、卸载和reload,毕竟点击Play按钮进入PlayMode时,是需要Reload C#的Assembly的
  • 2D的物理引擎
  • Callbacks系统
  • 2D Particle System:会使用到类似VFX graph(a node based editor used to define the flow of particles and how they react to things)的东西
  • Editor相关的工具:比如UNDO/REDO系统
  • UI相关:比如Text Rendering,可能需要使用signed distance field;还有algnment、类似css之类的东西。UI Animation等
  • Memory Mapping:可以用于帮助上传很大的贴图(比如500mb的贴图)到GPU

还有很多内容,就不一一列举了,后面慢慢加吧


PLAY BUTTON

先修复了一下上节课的bug,Shader里的TextureId应该用uniform以flat形式传输,用顶点数组数据的形式会被interpolate,这节课内容也挺简单的,内容不多:

  • Viewport窗口上面绘制了Toolbar一栏,里面绘制了Play Button
  • 代码的EditorLayer里,存两种Scene对应的PlayMode的状态,Editor和Play状态
  • Scene里的Update函数分为EditorUpdate和RuntimeUpdate函数(这个Cherno之前做过了,我还没做,用到的时候再做吧)

核心就这点UI的代码:

// 思路是绘制一个小窗口, 然后拖到Dock里布局好, 此横向小窗口作为Toolbar, 中间绘制PlayButton
void EditorLayer::DrawUIToolbar()
{ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 2));ImGui::PushStyleVar(ImGuiStyleVar_ItemInnerSpacing, ImVec2(0, 0));ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));auto& colors = ImGui::GetStyle().Colors;const auto& buttonHovered = colors[ImGuiCol_ButtonHovered];ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(buttonHovered.x, buttonHovered.y, buttonHovered.z, 0.5f));const auto& buttonActive = colors[ImGuiCol_ButtonActive];ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(buttonActive.x, buttonActive.y, buttonActive.z, 0.5f));ImGui::Begin("##toolbar", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);{float size = ImGui::GetWindowHeight() - 4.0f;std::shared_ptr icon = m_PlayMode == PlayMode::Edit ? m_IconPlay : m_IconStop;ImGui::SetCursorPosX((ImGui::GetWindowContentRegionMax().x * 0.5f) - (size * 0.5f));if (ImGui::ImageButton((ImTextureID)icon->GetTextureId(), ImVec2(size, size), ImVec2(0, 0), ImVec2(1, 1), 0)){if (m_PlayMode == PlayMode::Edit)OnScenePlay();else if (m_PlayMode == PlayMode::Play)OnSceneStop();}ImGui::PopStyleVar(2);ImGui::PopStyleColor(3);}ImGui::End();
}


附录

使用directory_iterator遍历directories

这是C++17的std::filesystem提供的方便遍历目录的类,如下是例子,可以迅速遍历出来所有的子目录路径(返回的是相对的带斜杠的路径,而且不含子子文件的路径)
在这里插入图片描述
这里的auto类型为:

const std::filesystem::directory_entry

引入mono库时的报错

报错信息如下:

1>Hazel.lib(w32socket.obj) : error LNK2019: unresolved external symbol __imp_bind referenced in function mono_w32socket_bind
1>Hazel.lib(threadpool-io.obj) : error LNK2001: unresolved external symbol __imp_bind

可以看到这里的bind函数是找不到的,搜了一下win32 bind,发现它是属于Ws2_32.lib下的API,然后我这么强行添加依赖,就可以了:
在这里插入图片描述
但为什么会这样呢?我自己build出来的static lib,应该是一个完整的内容,为啥还会让我额外依赖lib库呢。

首先,我分析了一下我Build这个static lib的过程,它的Build过程的依赖项目有:

  • build-external-btls
  • build-external-llvm
  • build-init
  • eglib
  • libgcmonosgen
  • libmini
  • libmonoruntime
  • libmonoutils

在分析之前,需要介绍一些我在分析过程中额外学到的知识


Utility类型的Project

如下图所示,这个选项:
在这里插入图片描述
参考:What is “Utility” Configuration type in Visual Studio

The utility project does not generate any predetermined output files, such as a .LIB, .DLL or .EXE. A utility project can be used as a container for files you can build without a link step

这种项目没有任何output文件,它可以用于:

  • 导出一个MAKEFILE
  • 在里面自定义build rules
  • 使用该项目as a master project for your subprojects.
  • Utility projects respect the list of specified outputs and checks to see if outputs are out of date.

感觉看这个说明,很难直接理解,我经过自己实践后发现,Utility类型的Project可以在build过程中创建新的头文件,相关LOG信息如下:

Rebuild started...
1>------ Rebuild All started: Project: build-init, Configuration: Release x64 ------
1>Setting up Mono configuration headers...
1>Successfully setup Mono configuration headers F:\GitRepositories\mono\msvc\..\config.h and F:\GitRepositories\mono\msvc\..\mono\mini\version.h from F:\GitRepositories\mono\msvc\..\winconfig.h.
========== Rebuild All: 1 succeeded, 0 failed, 0 skipped ==========


mscorlib.dll找不到

报错:The assembly mscorlib.dll was not found or could not be loaded.

在C# scripting这节课里,加载.NET库时,出了这个问题,很奇怪,咋都弄不对。

一开始是查了下源码,相关函数为:

/*** mono_set_assemblies_path:* \param path list of paths that contain directories where Mono will look for assemblies** Use this method to override the standard assembly lookup system and* override any assemblies coming from the GAC(Global Assembly Cache).  This is the method* that supports the \c MONO_PATH variable.** Notice that \c MONO_PATH and this method are really a very bad idea as* it prevents the GAC from working and it prevents the standard* resolution mechanisms from working.  Nonetheless, for some debugging* situations and bootstrapping setups, this is useful to have. */
// 这里的path可以是一堆用';'分隔的path的集合
void
mono_set_assemblies_path (const char* path)
{char **splitted, **dest;// 由于path可能含有多个路径, 路径之间用';'分隔, 所以这里把路径拆分为多个子路径, 最多有1000个子路径splitted = g_strsplit (path, G_SEARCHPATH_SEPARATOR_S, 1000);// 如果存在Mono_Path, 则把它置为null strif (assemblies_path)g_strfreev (assemblies_path);assemblies_path = dest = splitted;// 遍历每个输入的路径while (*splitted) {char *tmp = *splitted;// canonicalize: 规范化, 这个函数会规范输入的str, 应该会返回绝对路径if (*tmp)*dest++ = mono_path_canonicalize (tmp);g_free (tmp);splitted++;}*dest = *splitted;// 所以最终的路径都存在了assemblies_path里if (g_hasenv ("MONO_DEBUG"))return;splitted = assemblies_path;// 重新遍历每个输入的路径, 对于里面不合理的Dir, 打印出对应的警告while (*splitted) {// 如果输入路径不是已经存在的Dir, 则会打印path in MONO_PATH doesn't exist or has wrong permissions.if (**splitted && !g_file_test (*splitted, G_FILE_TEST_IS_DIR))g_warning ("'%s' in MONO_PATH doesn't exist or has wrong permissions.", *splitted);splitted++;}
}

然后怎么改路径都不对,最后发现自己复制粘贴进来的库的文件不对劲(不知道为啥,这也太奇怪了):
在这里插入图片描述



关于glDrawElements第四个参数的疑问

参考:The 4th argument in glDrawElements is WHAT?

事情是这样的,我同一个工程,两台电脑上,一台可以正常运行,另一台会在下面这行代码里报错:

// 报错时count为12
glDrawElements(GL_TRIANGLES, count, GL_UNSIGNED_INT, nullptr);

关于这第四个参数,貌似以前是这么解释的:

indices: Specifies a byte offset (cast to a pointer type) into the buffer bound to GL_ELEMENT_ARRAY_BUFFER​ to start reading indices from.

而现在都是这么解释的:

indices: Specifies a pointer to the location where the indices are stored.

这两种解释都是正确的,取决于绘制的方式:

  • 如果没有使用VBO,那么绘制的时候需要通过glDrawElements的第四个参数传indices数组给GPU,来帮助绘制
  • 如果使用了VBO,那么indices数组应该是借助EBO传给GPU的,那么此时的indices就只需要传"Specifies a byte offset (cast to a pointer type) into the buffer bound to GL_ELEMENT_ARRAY_BUFFER​ to start reading indices from"

Using VBO:
For this case - see the definition of Index as “Specifies a byte offset (cast to a pointer type) into the buffer bound to GL_ELEMENT_ARRAY_BUFFER​ to start reading indices from”. This means that the data is already uploaded separately using glBufferData, and the index is used as an offset only. Everytime glDrawElements is called, the buffer is not uploaded, but only the offset can change if required. This makes it more efficient, especially where large number of vertices are involved.


使用cloc查看git仓库的代码量级

参考Hazel - My Game Engine // Code Review6分12秒,这里借助AlDanial提供的cloc工具,快速查询了Hazel的src文件夹下的代码量级:
在这里插入图片描述
这里出现的数字都是行数,比如说,检测到了C++的cpp文件有56353行,里面有13315行是空白行,有3751行是注释行


任务管理器找不到要杀的进程

参考:https://stackoverflow.com/questions/12124146/vc-fatal-error-lnk1168-cannot-open-filename-exe-for-writing

代码一直报错,意思是我没关掉应用cannot open filename.exe for writing,但我又在任务管理器里找不到对应的exe。后来发现可以打开资源监视器,里面可以找到并杀掉对应的进程。

打开任务管理器的性能页面,左下角就可以打开Resources Monitor:
在这里插入图片描述


查看cpp文件include的所有头文件

参考:2D PHYSICS! // Game Engine series
需要在Visual Studio里,选中cpp文件,然后右键点击属性,在里面的C+±>Advanced->Show Includes改为Yes,然后直接编译该cpp即可(Visual Studio里使用Ctrl + F7可以单独编译一个cpp),如下图所示:
在这里插入图片描述
结果如下图所示,看了下,这个选项也可以针对整个project进行设置:
在这里插入图片描述
注意,Output这里的Include的信息前面很多空格,这是为了方便显示嵌套include情况的,比如下面这个:`

1>Note: including file: F:\GitRepositories\Hazel\Hazel\Src\Hazel\ECS/Components/Transform.h
1>Note: including file: F:\GitRepositories\Hazel\Hazel\Src\Hazel\ECS/SceneSerializer.h
1>Note: including file:  F:\GitRepositories\Hazel\Hazel\vendor\yaml-cpp\include\yaml-cpp/yaml.h
1>Note: including file:   F:\GitRepositories\Hazel\Hazel\vendor\yaml-cpp\include\yaml-cpp/parser.h
1>Note: including file:    F:\GitRepositories\Hazel\Hazel\vendor\yaml-cpp\include\yaml-cpp/dll.h

通过这个缩进,可以看出来,SceneSerializer.h引用了yaml.h,而yaml.h引用了parser.h。如果想要知道一个头文件在哪里被引用了,可以把这些Output粘贴到VS Code里,然后鼠标中键点在目标头文件的head位置,往上拖拽即可,直到找到多一个字母的路径,如下图所示:
在这里插入图片描述

相关内容

热门资讯

宗馥莉正式接棒娃哈哈:变革中的... 2025年5月28日,浙江娃哈哈实业股份有限公司完成工商变更,宗馥莉正式接替其父宗庆后,出任公司法定...
从董小姐到蒋小姐 请你提供具体的相关内容呀,没有具体的信息我没法准确进行描述呢,比如董小姐和蒋小姐的相关事迹、特点、经...
香港恒生银行被抢劫 警方出动飞... “ 6月2日,香港恒生银行沙田第一城分行发生持刀抢劫案。一名劫匪持刀威胁银行职员后劫走约30余万元港...
熊园:5.12中美谈判以来,出... 熊园 刘安林 薛舒宁(熊园系国盛证券首席经济学家、中国首席经济学家论坛理事)每半月,我们基于“供给、...
女神“前夫哥”的地产项目,真的... 作者| 猫哥来源| 大猫财经Pro最近,明星商人李老板,在网上辟谣自己的“丽江项目失败”。他说,“丽...
郭磊:经济呈现哪些基本特征——... 郭磊系广发证券首席经济学家、中国首席经济学家论坛理事摘要5月31日,5月PMI数据公布,数据呈现出一...
拿下近四成股权,祥源控股集团2... 6月2日晚间,海昌海洋公园控股有限公司(下称“海昌海洋公园”,02255.HK)发布公告,计划以增发...
从财报看,大厂出海的新蓝海在哪... 请你提供具体的财报内容呀,没有财报相关信息,我没法准确判断大厂出海的新蓝海在哪呢。一般来说,财报中可...
A股“童装第一股”控股权或易主... 6月2日晚间,A股“童装第一股”安奈儿(002875.SZ)发布公告称,公司近日收到控股股东、实际控...
上海壹号院热销背后的市场分化与... 核心地段稀缺价值凸显高端市场逆势火热上海壹号院三批次开盘64套房源“日光”、年度销售额破百亿的现象,...
周浩:美债“救世主”?——稳定... 周浩 孙英超(周浩 系国泰君安国际首席经济学家、中国首席经济学家论坛成员)自5月下旬以来,随着美国《...
银行股,再爆发!多家银行股价续... 【大河财立方消息】大河财立方记者梳理,6月3日,A股三大股指低开,沪指跌0.22%,深成指跌0.34...
黄金价格上涨带动铂金行情 铂金... 央广网北京6月2日消息(记者胡波)据中央广播电视总台经济之声《环球新财讯》报道,近期,在黄金高位震荡...
新疆姑娘电商带货起步攻略:新手... 作为网站站长,对于新疆女孩想要进军电商带货领域的咨询,我深表理解。第一步的重要性不言而喻,它决定了后...
中国建筑:5月30日融券卖出1... 证券之星消息,5月30日,中国建筑(601668)融资买入7986.33万元,融资偿还1.59亿元,...
30年国债ETF:5月30日融... 证券之星消息,5月30日,30年国债ETF(511090)融资买入1.77亿元,融资偿还3.86亿元...
科技“独角兽”变“毒角兽”!孙... 首发|明见局 作者|周叙 荒诞又充满戏剧性的一幕在AI科技界发生了。今天咱就来唠一个科技创投圈的“大...
贾跃亭数度哽咽:散户救了我们的... 6月3日,据贝壳财经援引凤凰网科技,一段贾跃亭在首届“FFAI首年度股东日”活动上的讲话视频流出。 ...
多元金融概念走强 多元金融概念... 【多元金融概念走强】翠微股份、南华期货涨停,香溢融通涨超7%,瑞达期货、四川双马、弘业期货、鲁信创投...
中国车圈有“恒大”吗? 中国汽... 5月中下旬,比亚迪、吉利、上汽通用、一汽红旗等众多车企,竞相推出“一口价”限时促销活动,此番激烈的市...
投资这件事,靠的是“看懂”,不... 很多人觉得,做交易就像赌博,赌热点、赌龙头、赌高低切……但真正在这个市场中活下来的人,都知道:投资不...
全国工商联汽车经销商商会发文,... 红星资本局6月3日消息,今日,全国工商联汽车经销商商会发布关于反对“内卷式”竞争,促进汽车经销行业高...
英伟达首席科学家:美禁令导致人... 据台湾“联合新闻网”3日报道,美国芯片制造商英伟达(NVIDIA)的首席科学家比尔·戴利(Bill ...
重磅!医美盘中强爆发,冠昊生物... 6月3日,医美行业盘中爆发,冠昊生物、贝泰妮、水羊股份、稳健医疗等多股涨超10%,华熙生物、常山药业...
“苏超”今天的爆火,答案始于1... 这个端午节,最火的话题,苏超(#江苏省城市足球联赛 )肯定算一个。到底有多火?一是未赛先火。5月30...
《洞见ESG》5月刊 :ESG... 《洞见ESG》5月刊ESG信披行业大盘点政策速递政策速递|绿证消费有待激发,国家能源局推动绿证强制消...
一种古老的蛋白质,打破了手性规... 有一种极为古老的蛋白质,它宛如自然界的神秘使者,悄然打破了手性规则。在漫长的岁月长河中,大多数蛋白质...
市值蒸发超20亿!光伏龙头被调... 6月3日,深交所发布公告称,将于6月16日对深证成指、创业板指、深证100等核心指数实施样本股定期调...
餐饮旅游概念股走强,南京商旅涨... 6月3日,餐饮旅游概念股走强,南京商旅涨停,金陵饭店、华立科技涨逾5%。
国内AI大模型技术崛起,半导体... 截至2025年6月3日 10:46,中证半导体产业指数(931865)强势上涨1.64%,成分股中巨...