自学内容网 自学内容网

Cherno游戏引擎笔记(61~72)

---------------一些维护和更改-------------

》》》》 Made Win-GenProjects.bat work from every directory

代码更改:

@echo off

->pushd ..\

->pushd %~dp0\..\

call vendor\bin\premake\premake5.exe vs2019

popd

PAUSE

为什么要做这样的更改?

当你通过命令行提示符打开并运行该文件时,这个批处理文件会在命令行提示符被打开的位置被运行,这会导致 pushd ..\这个语句原本的语义错误。(在命令行提示符的路径下临时切换工作目录至相对于命令提示符的上一级 '..\',而不是相较于批处理文件的上一级)

为了避免这样的情况发生,我们需要将启用该批处理文件后的语句改为绝对的、固定的切换目录操作。

%~dp0 的含义:

  • %0 是批处理文件本身的名称。
  • %~dp0 是批处理文件的完整路径,包括驱动器号和路径。它扩展为批处理文件所在的目录。这个路径在批处理文件内部是固定的,不会改变。

%~dp0\ 有什么意义?

pushd ..\ 

是将当前目录更改为上一级目录。

pushd %~dp0\..\ 

是将当前目录更改为批处理文件所在目录的上一级目录(绝对路径)。

Eg.

@echo off

echo this is %%cd%% %cd%

echo this is %%~dp0 %~dp0

当你在其他目录(比如 C:\)运行这个批处理文件时,这两个路径会不同

%cd% 

显示的是当前目录

 %~dp0 

显示的是批处理文件所在的目录

》》》》 Fix Build warnings to do with BufferElement Offset Type

WIN64   size_t =>

 unsigned __int64

  intptr_t => 

 __int64

ELSE       size_t =>

 unsigned int

  intptr_t =>

      int

In Any Case   Uint32_t =>

 unsigned int

(const void*)(intptr_t)element.Offset 中,Offset 仅仅是一个数值,没有任何与内存地址相关的含义。在这种情况下,intptr_t 的转换也是不必要的,因为它不会改变你正在做的事情的本质。

》》》》Basic ref-counting system to terminate glfw

之前在 WindowsWindow.cpp 中,仅仅判断了GLFW窗口是否已经初始化?是否需要初始化上下文?然后根据判断结果进行  glfwDestroyWindow(m_Window); 这仅仅只是销毁了窗口。

而且我们并没有通过 glfwTerminate(); 函数对GLFW 库进行终止,并释放资源。

这一次,我们判断打开的 GLFW 窗口是否全部关闭,如果全部关闭,则对 GLFW 库进行终止。若对一个窗口关闭之后,仍有正在运行的窗口,则仅销毁需要关闭的窗口即可。

Uint8_t 的定义:

 typedef unsigned char  uint8_t;

》》》》 Added file reading check

std::string OpenGLShader::ReadFile(const std::string& filepath)

{

std::string result;

std::ifstream readIn(filepath, std::ios::in | std::ios::binary);

if (readIn)                                 //是否成功打开

{

readIn.seekg(0, std::ios::end);

size_t size = readIn.tellg();

if (size != -1) {                //是否成功获取

…

}

else { NUT_CORE_ERROR("Failed to read file from : '{0}'", filepath); }

}

else { NUT_CORE_ERROR("Could not open file form : '{0}'", filepath); }

}

》》》》Code maintenance (#165)

构造函数 A() = {}A() = default 有什么区别吗?

  • 实现上:

 A() = {}

 是显式手动定义一个空的默认构造函数,不依赖于编译器生成。

A() = default

是告诉让编译器来生成默认构造函数。

  • 性能上:

这两种方式行为相同,运行时也生成相同的机器码。因此,在生成的代码执行效率上,不会有显著的区别。

  • 结论:二者没有什么区别。

》》》》 x64 x86_64 有什么区别吗?

在大多数情况下,"x64" 和 "x86_64" 可以互换使用,都是用来描述64位操作系统和处理器架构的术语。表示支持64位操作系统和处理器架构的环境,可以看做时同义词。

"x86_64"

在一些技术文档和Linux系统中更常见

"x64"

 则在Windows系统中更为普遍。两者本质上指向同一种64位架构,即AMD64或x86-64。

》》》》Auto deducing an availabe __FUNCSIG__ definition (#174)

Created `HZ_FUNC_SIG` macro to deduce a valid pretty function name macro as `__FUNCSIG__` isn't available on all compilers

// Resolve which function signature macro will be used. Note that this only

// is resolved when the (pre)compiler starts, so the syntax highlighting

// could mark the wrong one in your editor!

#if defined(__GNUC__) || (defined(__MWERKS__) && (__MWERKS__ >= 0x3000)) || (defined(__ICC) && (__ICC >= 600)) || defined(__ghs__)

#define NUT_FUNC_SIG __PRETTY_FUNCTION__

#elif defined(__DMC__) && (__DMC__ >= 0x810)

#define NUT_FUNC_SIG __PRETTY_FUNCTION__

#elif defined(__FUNCSIG__)

#define NUT_FUNC_SIG __FUNCSIG__

#elif (defined(__INTEL_COMPILER) && (__INTEL_COMPILER >= 600)) || (defined(__IBMCPP__) && (__IBMCPP__ >= 500))

#define NUT_FUNC_SIG __FUNCTION__

#elif defined(__BORLANDC__) && (__BORLANDC__ >= 0x550)

#define NUT_FUNC_SIG __FUNC__

#elif defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 199901)

#define NUT_FUNC_SIG __func__

#elif defined(__cplusplus) && (__cplusplus >= 201103)

#define NUT_FUNC_SIG __func__

#else

#define NUT_FUNC_SIG "NUT_FUNC_SIG unknown!"

#endif



#define NUT_PROFILE_FUNCTION() NUT_PROFILE_SCOPE(NUT_FUNC_SIG)

#if defined(__GNUC__) || (defined(__MWERKS__) && (__MWERKS__ >= 0x3000)) || (defined(__ICC) && (__ICC >= 600)) || defined(__ghs__)

  • 这个条件判断首先检查是否定义了 __GNUC__,这是 GNU 编译器的宏。如果定义了,说明正在使用 GCC 编译器。
  • 第二部分 (defined(__MWERKS__) && (__MWERKS__ >= 0x3000)) 检查 Metrowerks CodeWarrior 编译器版本是否大于等于 0x3000。
  • 第三部分 (defined(__ICC) && (__ICC >= 600)) 检查 Intel C/C++ 编译器版本是否大于等于 600。
  • 最后一个条件 defined(__ghs__) 检查是否使用 Green Hills 编译器。
  • 如果任何一个条件为真,表示正在使用对应的编译器,因此选择 __PRETTY_FUNCTION__ 作为 NUT_FUNC_SIG 的值。__PRETTY_FUNCTION__ 是 GCC 和一些兼容的编译器提供的宏,用于获取带有类型信息的函数签名。

#elif defined(__DMC__) && (__DMC__ >= 0x810)

  • 这个条件检查是否定义了 __DMC__ 并且版本号大于等于 0x810,表示使用 Digital Mars C/C++ 编译器。
  • 如果条件成立,则选择 __PRETTY_FUNCTION__。

#elif defined(__FUNCSIG__)

  • 这个条件检查是否定义了 __FUNCSIG__,这是 Microsoft Visual C++ 提供的宏,用于获取包含返回类型的函数签名。
  • 如果条件成立,则选择 __FUNCSIG__ 作为 NUT_FUNC_SIG 的值。

#elif (defined(__INTEL_COMPILER) && (__INTEL_COMPILER >= 600)) || (defined(__IBMCPP__) && (__IBMCPP__ >= 500))

  • 这个条件首先检查是否定义了 __INTEL_COMPILER 并且版本号大于等于 600,表示使用 Intel C++ Compiler。
  • 第二部分 (defined(__IBMCPP__) && (__IBMCPP__ >= 500)) 检查 IBM XL C/C++ 编译器版本是否大于等于 500。
  • 如果任何一个条件成立,则选择 __FUNCTION__ 作为 NUT_FUNC_SIG 的值。__FUNCTION__ 是一个标准 C99 定义的宏,用于获取简单函数名。

#elif defined(__BORLANDC__) && (__BORLANDC__ >= 0x550)

  • 这个条件检查是否定义了 __BORLANDC__ 并且版本号大于等于 0x550,表示使用 Borland C++ 编译器。
  • 如果条件成立,则选择 __FUNC__ 作为 NUT_FUNC_SIG 的值。

#elif defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 199901)

  • 这个条件检查是否定义了 __STDC_VERSION__ 并且版本号大于等于 199901,表示当前编译器支持 C99 标准。
  • 如果条件成立,则选择 __func__ 作为 NUT_FUNC_SIG 的值。__func__ 是 C99 标准引入的标准宏,用于获取简单函数名。

#elif defined(__cplusplus) && (__cplusplus >= 201103)

  • 这个条件检查是否定义了 __cplusplus 并且版本号大于等于 201103,表示当前编译器支持 C++11 标准。
  • 如果条件成立,则同样选择 __func__ 作为 NUT_FUNC_SIG 的值。C++11 引入了对 __func__ 宏的支持。

#else

  • 如果以上所有条件都不满足,则选择 "NUT_FUNC_SIG unknown!" 作为 NUT_FUNC_SIG 的默认值。这种情况下,编译器可能不支持预定义的函数签名获取方式,或者无法识别当前的编译环境。

关于注释:

 Resolve which function signature macro will be used. Note that this only is resolved when the (pre)compiler starts,  so the syntax highlighting could mark the wrong one in your editor!

意为Visual Studio 编辑器中的高亮显示可能是错误的结果,但这并不影响实际使用。

理解:

出现错误的原因是编辑器的语法高亮功能通常不能处理复杂的预处理宏选择逻辑,这种情况在使用条件编译和宏定义较多的情况下是比较常见的。

但这并不影响代码编译,因为这些定义将在运行时才被确定。

》》》》make runloop only accessible on the engine side (#188)

如何限制只在入口点(引擎端)访问 Run 函数(内含RunLoop的函数) 呢?

由于我们将主函数 main 设置在 EntryPoint 文件中,并在此处定义,所以想要只能在此处访问 Run 函数的话,我们需要将 Run 函数作为 Application 类中的私有成员函数,并将 main 函数作为 Application 类的友元函数。

所以我们在 Application 中将 Run 作为 Private,然后在 Private 中声明友元 friend int ::main(int argc, char** argv);

这引申出两个疑问:

  • 为什么需要在友元声明中使用 ::main 而不是直接写 main 来表示友元函数?
  • 为什么需要在全局空间中再次声明一次int main(int argc, char** argv);

 1. 由于 Main 函数只被定义在外部文件中,如果在类内部声明为 friend int main(int argc, char** argv); 而没有使用 :: 指明它在全局作用域中,编译器可能会将其解释为当前编译单元内的 main 函数,而不是全局作用域的 main 函数。

这确保编译器正确理解友元函数的全局位置。

 2. 如果不声明 int main 的话,在接下来使用友元函数的时候,会报错全局范围内没有 main。

在C++中,如果在一个类内声明了某个函数为友元函数,但是该函数的定义(或者至少声明)不在类声明之前全局范围内,编译器可能会报错。

如果在全局作用域中并没有先声明 int main(int argc, char** argv);,编译器可能会报错,因为在 friend 声明时需要指定 main 函数的存在,以便理解它是一个全局函数并将其声明为友元。

这个声明确保编译器理解 main 函数在全局范围内是存在的

》》关于 ++ 操作符的理解

  • 在一般的 for 循环中,for( size_t i = 0; i <= x; i++), i++ ++i 没什么不同。因为在循环条件中,只需检查 i 的当前值是否满足条件,无论是前置递增还是后置递增,条件的判断都是基于 i 的当前值。(在这个 for 循环中,i 起始值为 0,每次循环迭代结束后,i 会递增。循环条件 i <= x 每次循环迭代开始时都会被检查,如果条件为真,则执行循环体,然后执行递增操作 i++。这意味着 i 的值会在每次循环的末尾增加 1。)

甚至说,++i 还要比 i++性能/效率更高,因为 ++i 传递的不是副本,而 i++ 传递的是副本->一个临时对象。

  • 在涉及到迭代器的增加操作时,++iter 和 iter++ 有着微妙但重要的区别

1. ++iter(前置递增操作符)

++iter

前置递增操作符,它的作用是先增加迭代器的值,然后返回增加后的迭代器。

行为:

 ++iter 会将迭代器 iter 指向的位置向前移动一个元素。

返回值:

 返回的是增加后的迭代器 iter 的引用(即新的迭代器对象本身)。

在实际使用中,++iter 的返回值可以用于链式操作或者作为函数参数,因为它返回的是一个引用。

2. iter++(后置递增操作符)

iter++

后置递增操作符,它的行为略有不同:

行为:

 iter++ 会返回迭代器 iter 的当前值,然后再将迭代器 iter 向前移动一个元素。

返回值:

 返回的是增加前的迭代器 iter 的值(即旧的迭代器对象的副本),而不是增加后的迭代器。

这意味着 iter++ 在使用时,返回的值是旧位置的迭代器,不能直接作为函数参数或者链式操作的一部分,因为它的返回值是一个临时对象。

也就是说,在反向迭代中:

前置:

for (auto it = vec.rbegin(); it != vec.rend(); ++it)

 {

 std::cout << *it << " ";

}

每次循环体执行完成后,it 被递增,并且 *it 总是获取当前迭代器指向的元素值。在第二次循环开始前,it 先自增一次,然后用于使用。

后置:

for (auto it = vec.rbegin(); it != vec.rend(); it++)

{

std::cout << *it << " ";

}

每次循环体执行完成后,it 被递增。这意味着在下一次迭代开始之前,it 仍然指向上一次迭代结束时的位置。因此,*it 取得的是上一次循环中的元素值,而不是当前迭代所需的元素值。

这在反向迭代器中尤其容易出现问题

std::vector<int> vec = {1, 2, 3, 4, 5};

 

// 前置递增操作

auto it1 = vec.begin();

auto incremented1 = ++it1; // 先增加,再返回

std::cout << *incremented1 << std::endl; // 输出 2



// 后置递增操作

auto it2 = vec.begin();

auto incremented2 = it2++; // 先返回,再增加

std::cout << *incremented2 << std::endl; // 输出 1

》》》》什么是互斥锁?互斥锁和并行的关系是什么?

互斥锁(Mutex,互斥体是一种用于多线程编程中的同步原语,用于确保在任何时刻,只有一个线程能够访问共享资源或临界区域,从而避免多个线程同时修改数据导致的不一致或竞态条件问题。

互斥锁提供两个主要操作:

  • 锁定(Locking:当一个线程希望进入临界区域(访问共享资源)时,它会尝试获取互斥锁。

如果互斥锁当前未被其他线程占用(未锁定)

那么这个线程会成功获取锁,并进入临界区域。

如果互斥锁已经被其他线程占用(已锁定)

则当前线程可能会被阻塞,直到互斥锁被释放。

  • 解锁(Unlocking:当一个线程使用完临界区域中的共享资源后,它会释放互斥锁,这样其他线程就有机会获取锁并继续执行。

结论:

互斥锁通常是基于硬件的原子操作(不可中断的操作)或操作系统提供的原语实现的,因此在锁定和解锁操作中能够保证线程安全,避免竞态条件。

互斥锁和并行的关系:

并行指的是多个线程或进程同时执行任务,通常在多核处理器或分布式系统中实现。

当多个线程或进程同时访问共享资源时,由于执行顺序不确定或未经同步导致的不正确的结果,称为竞态条件

互斥锁用于解决竞态条件,确保在任何时刻只有一个线程可以访问共享资源或临界区域。

 

 

 

》》》》互斥锁的使用

互斥锁的获取和释放:

 使用 std::mutex 的 lock() unlock() 方法来获取和释放互斥锁。

  • 获取锁:调用 lock() 方法来获取互斥锁。

        如果当前没有其他线程持有锁,则当前线程获取锁并继续执行;

        如果其他线程已经持有锁,当前线程将被阻塞,直到获取到锁为止。

  • 释放锁:调用 unlock() 方法来释放互斥锁,允许其他线程获取锁进入临界区。
m_Mutex.lock();
// 临界区代码,访问共享资源
m_Mutex.unlock();

使用 RAII 管理锁(推荐):

 RAII(资源获取即初始化)是一种管理资源生命周期的常用技术

在 C++ 中,可以使用 std::lock_guard 或 std::unique_lock 类来管理 std::mutex 的锁。

  • std::lock_guard:
    
    {
        std::lock_guard<std::mutex> lock(m_Mutex);
        // 临界区代码,访问共享资源
    }   // 锁在此处自动释放
    
    
    std::unique_lock(更灵活,可以手动释放锁):
    
    {
        std::unique_lock<std::mutex> lock(m_Mutex);
        // 临界区代码,访问共享资源
        lock.unlock(); // 手动释放锁
        // 其他非临界区代码
    }   // 锁在此处自动释放

避免死锁:

 在使用多个互斥量时,必须小心避免死锁(Deadlock)。死锁是指两个或多个线程互相等待对方持有的资源而无法继续执行的情况。

 

》》》》在代码中的RAII管理所为什么要放置于特定的地方,有什么考究?

void BeginSession
(const std::string& name, const std::string& filepath = "XXX")
{
std::lock_guard lock(m_Mutex);
if (m_CurrentSession) {
if (Log::GetCoreLogger()) {
HZ_CORE_ERROR("....", name, m_CurrentSession->Name);
}
InternalEndSession();
}
m_OutputStream.open(filepath);
if (m_OutputStream.is_open()) {
...
} else {
...
}
}

void EndSession()
{
std::lock_guard lock(m_Mutex);
InternalEndSession();
}

void WriteProfile(const ProfileResult& result)
{
std::stringstream json;

std::string name = result.Name;
std::replace(name.begin(), name.end(), '"', '\'');

Json<<...
std::lock_guard lock(m_Mutex);
if (m_CurrentSession) {
m_OutputStream << json.str();
m_OutputStream.flush();
}
}

BeginSession 函数:

  • 在开始一个新会话时,需要确保对 m_CurrentSession 和 m_OutputStream 的操作是原子的,不被其他线程打断。

        std::lock_guard 在这里用来锁定 m_Mutex,确保在开始新会话时,只有一个线程能够执行这段代码,避免多线程同时进行会话操作导致的混乱。

EndSession 函数:

  • 类似地,结束会话时也需要对 m_CurrentSession 和 m_OutputStream 进行操作,同样需要保证操作的原子性和线程安全性。

WriteProfile 函数:

  • 在写入性能分析结果时,同样需要确保写操作的线程安全性,避免多个线程同时向输出流写入数据导致的混乱。

        std::lock_guard 在这里锁定 m_Mutex,确保每次写操作是串行执行的,不会被其他线程中断。

》》》为什么说写操作是串行的?

串行:

每个线程依次获取锁,执行完操作后释放锁,然后其他线程才能获取锁执行操作

        在每次对 m_CurrentSession m_OutputStream 进行写操作时,只有一个线程能够持有 m_Mutex,其他线程必须等待。这种方式保证了线程和数据的安全。

》》》》std::lock_guard 怎样使用

  • 定义互斥锁对象(互斥量)
std::mutex m_Mutex; // 定义一个互斥量对象
  • 在对共享资源进行读写操作之前创建一个 std::lock_guard 对象,以确保在操作期间其他线程无法访问共享资源。

// 锁定互斥量,只有这个代码块中可以访问被保护的资源

{

std::lock_guard<std::mutex> lock(m_Mutex);

 

// 在这里可以安全地访问共享资源

// 例如:m_CurrentSession 或 m_OutputStream

 m_CurrentSession = ...;                 // 修改共享资源

 m_OutputStream << "Logging message\n";   // 写操作

 

 // lock_guard 在这个代码块结束时会自动释放锁

 }

 

效果:

std::lock_guard 在构造时会获取互斥量的锁,并在其作用域结束时(即超出大括号或离开作用域)自动释放锁。

需要注意的是:

  • 不要手动调用 lock() 和 unlock() 函数来管理互斥量,因为这样容易出错并导致死锁
  • std::lock_guard 的生命周期应该尽量短,只在需要保护共享资源时才创建和使用,以减少锁的持有时间,提高并发性能。

》》》》什么是iomanip?

<iomanip> 是 C++ 标准库中的头文件,定义了一些与格式化输入输出相关的功能和工具。

它提供了一些用于控制输入输出格式的类和函数,能够帮助程序员在输出数据时进行格式化,比如设置输出的精度、字段宽度、对齐方式等。

常见功能包括:

  • std::setprecision(int n):设置浮点数的输出精度为 n 位小数。
  • std::setw(int n):设置输出的字段宽度为 n 个字符。
  • std::left、std::right、std::internal:控制输出的对齐方式,左对齐、右对齐或者内部对齐。
  • std::fixed、std::scientific、std::hexfloat:设置浮点数的输出格式,固定小数位、科学计数法、十六进制表示等。

》》》》什么是std::chorno::steady_clock?

std::chrono::time_point std::chrono::steady_clock:

  • 使用 std::chrono::steady_clock 作为时钟类型。
  • steady_clock 提供了稳定且不会被系统时间调整影响的时间。它适合于测量时间间隔和延迟等场景,不会受到系统时间修改的影响,即使系统时间发生变化,该时钟也保持稳定。
  • 精度一般是微秒级别或者更高,取决于系统的实现。

std::chrono::time_point std::chrono::high_resolution_clock:

  • 使用 std::chrono::high_resolution_clock 作为时钟类型。
  • high_resolution_clock 是一个特定系统实现的时钟,提供了尽可能高的精度,通常比 steady_clock 更精确,但具体精度因平台而异。
  • 精度可能是纳秒级别或者更高,但由于其实现依赖于具体系统,因此可能在不同平台上有所不同。

主要区别:

  • 稳定性:steady_clock 是稳定的时钟,不受系统时间修改的影响;而 high_resolution_clock 的稳定性依赖于具体实现,通常也比较稳定,但在某些情况下可能受到系统时间修改的影响。
  • 精度:high_resolution_clock 的精度通常比 steady_clock 更高,但具体精度取决于系统的硬件和实现。

》》》》什么是std::chrono::duration?

std::chrono::duration 是用于表示和处理不同时间单位的时间段的重要工具.

类型定义

std::chrono::duration 是一个模板类,其基本模板定义如下:

template<class Rep, class Period = std::ratio<1>>
class duration;
  • Rep:表示持续时间的数值类型,通常是一个整数类型(如 int, long, double 等),用来存储持续时间的数量。
  • Period:表示持续时间的时间单位,使用 std::ratio 类型来表示,例如 std::ratio<1, 1000> 表示毫秒,std::ratio<1, 1000000> 表示微秒,std::ratio<1, 1> 表示秒(默认单位)。

示例

使用 std::chrono::duration 表示不同时间单位的时间段和进行基本的运算:

#include <iostream>
#include <chrono>

int main() {
    // 定义两个不同单位不同数值的duration变量    

std::chrono::duration<int, std::ratio<1, 1>> seconds(30); // 30 seconds
std::chrono::duration<double, std::ratio<1, 1000>> milliseconds(550); // 550 milliseconds

  // 将其相加

    auto total_seconds = seconds + std::chrono::duration_cast<std::chrono::duration<int>>(milliseconds);

  // 查看结果

    std::cout << "Total duration: " << total_seconds.count() << " seconds\n";

return 0;
}

定义了一个 seconds 和一个 millisecondsstd::chrono::duration 对象,分别表示 30 秒和 550 毫秒。然后将这两个时间段相加,并将结果转换为秒,最后输出总的持续时间。

》》 std::chrono::time_point<std::chrono::steady_clock> m_StartTimepoint;

auto highResStart = std::chrono::duration<double, std::micro>{ m_StartTimepoint.time_since_epoch() };的效果是什么?

具体来说,假设 m_StartTimepoint 是一个 steady_clock 类型的时间点,那么 m_StartTimepoint.time_since_epoch() 返回的是一个 std::chrono::duration 类型,表示自 steady_clock 的自纪元(通常是系统启动后的时间)开始至 m_StartTimepoint 的持续时间。

随后,我们创建 highResStart,使用的模板参数为 <double, std::micro>,这表示我们希望将这个持续时间表示为一个 double 类型的数值,单位是微秒。

因此,auto highResStart = std::chrono::duration<double, std::micro>{ m_StartTimepoint.time_since_epoch() }; 的效果是将 m_StartTimepoint 的自纪元至今的时间传递给HigResStart,将其转换一个 double 值,表示了 m_StartTimepoint 的时间戳,以微秒为精度。

》》

auto elapsedTime =

std::chrono::time_point_cast<std::chrono::microseconds>(endTimepoint).time_since_epoch() - std::chrono::time_point_cast<std::chrono::microseconds>(m_StartTimepoint).time_since_epoch();

auto elapsedTime = std::chrono::duration<double, std::micro>{

std::chrono::time_point_cast<std::chrono::microseconds>(endTimepoint).time_since_epoch() - std::chrono::time_point_cast<std::chrono::microseconds>(m_StartTimepoint).time_since_epoch()

}

有什么区别?

两种方式在功能上是等价的,主要区别在于返回结果的类型和精度。

  • 类型和精度不同:
  • 第一种方式 

    返回的是一个 std::chrono::microseconds 类型的持续时间,表示两个时间点的微秒级时间差。

    第二种方式 

    返回的是一个 std::chrono::duration<double, std::micro> 类型的持续时间,其中 double 是数值类型,表示两个时间点的微秒级时间差的浮点数表示。

因此,选择哪种方式取决于你的具体需求:

  • 如果你只需要整数微秒表示,

    第一种方式足够。

    如果你需要微秒级别的小数精度

    或者希望结果为 double 类型,

    建议使用第二种方式。

》》》》std::setprecisionstd::fixed是什么意思?

std::setprecision(3):

  • 这个函数调用设置了浮点数的输出精度为3位小数。它是 <iomanip> 头文件中的函数,通过 std::setprecision 控制输出流的精度。
  • 例如,如果将一个浮点数输出到流中并设置了 std::setprecision(3),那么输出的浮点数将保留三位小数。

std::fixed:

  • 这是另一个 <iomanip> 头文件中的修饰符,用于指定浮点数的输出格式为固定小数点表示法(即小数点后面始终保留指定的位数,不自动切换到科学计数法)。
  • 当使用 std::fixed 后,浮点数将始终按照小数点后的位数进行输出,即使小数部分为0也会显示。
  • 例如,如果一个浮点数是 3.14159265,使用 std::setprecision(3) 和 std::fixed 后输出为 3.142。

》》》》关于instrumentorinstrumentation的维护思路(未全部采用)

 

第二个维护的效果:Fixed Instrumentor generating same start time (#185)

(有的维护属于安全维护,包括防止内存泄漏或维护代码安全性。但是貌似 steady_clock 保证了时间稳定,然后 duration 对 StartTime 采用的更高精度使json文件更正确? 我是这样想的)

 

修改前:一些函数明明是相同的,却出现在不同的地方,这是因为 StartTime 出现了逻辑上的错误。

修改后:

由于修改后每个函数的 StartTime 精度很高,如果不只采用小数点后三位的话,json文件中会出现很大的间隔:

 

 

添加精度限制之后:

可以看到,来自同一个线程的 LayerStack::OnUpdate 在之前乱糟糟的,修改后处于同一个层次,很整齐。

》》》》关于 glDebugMessageControl 函数

glDebugMessageControl 函数用于控制 OpenGL 调试消息的生成和过滤。它的原型如下:

void glDebugMessageControl(GLenum source, GLenum type, GLenum severity, GLsizei count, const GLuint* ids, GLboolean enabled);

  • source:指定调试消息的来源。可以是以下值之一:

GL_DEBUG_SOURCE_API:

由OpenGL API生成的调试消息。

GL_DEBUG_SOURCE_WINDOW_SYSTEM:

与窗口系统相关的调试消息。

GL_DEBUG_SOURCE_SHADER_COMPILER:

由着色器编译器生成的调试消息。

GL_DEBUG_SOURCE_THIRD_PARTY:

来自第三方库的调试消息。

GL_DEBUG_SOURCE_APPLICATION:

由应用程序自定义的调试消息。

GL_DEBUG_SOURCE_OTHER:

其它来源的调试消息。

GL_DONT_CARE:

表示不关心来源,接收所有来源的调试消息。

  • type:指定调试消息的类型。可以是以下值之一:

GL_DEBUG_TYPE_ERROR:

错误消息。

GL_DEBUG_TYPE_DEPRECATED_BEHAVIOR:

不推荐使用的行为警告。

GL_DEBUG_TYPE_UNDEFINED_BEHAVIOR:

未定义的行为警告。

GL_DEBUG_TYPE_PORTABILITY:

不兼容性警告。

GL_DEBUG_TYPE_PERFORMANCE:

性能警告。

GL_DEBUG_TYPE_MARKER:

标记组。

GL_DEBUG_TYPE_PUSH_GROUP:

推组。

GL_DEBUG_TYPE_POP_GROUP:

弹组。

GL_DEBUG_TYPE_OTHER:

其它类型的调试消息。

GL_DONT_CARE:

表示不关心消息类型,接收所有类型的调试消息。

  • severity:指定调试消息的严重性级别。可以是以下值之一:

GL_DEBUG_SEVERITY_HIGH:

高严重性的消息。

GL_DEBUG_SEVERITY_MEDIUM:

中等严重性的消息。

GL_DEBUG_SEVERITY_LOW:

低严重性的消息。

GL_DEBUG_SEVERITY_NOTIFICATION:

通知级别的消息。

GL_DONT_CARE:

表示不关心严重性级别,接收所有严重性级别的调试消息。

  • count:指定 ids 数组中元素的数量,用于指定特定消息的ID。可以为0,表示没有指定特定的消息ID。
  • ids:一个指向消息ID数组的指针,用于指定特定消息的ID。如果 count 为0或 ids 为 NULL,则表示不特定任何特定的消息ID。
  • enabled:一个布尔值 (GL_TRUE 或 GL_FALSE),指定是否启用指定条件下的调试消息。

glEnable(GL_DEBUG_OUTPUT);

  • 这个函数调用启用了OpenGL的调试输出功能。启用后,OpenGL会生成调试消息,用于指示可能出现的错误、警告或其他状态信息。

glEnable(GL_DEBUG_OUTPUT_SYNCHRONOUS);

  • 这个函数调用启用了OpenGL的同步调试输出功能。启用同步调试输出可以保证在调试消息的回调函数执行时,OpenGL的状态保持一致,有助于调试时的上下文理解和问题追踪。

glDebugMessageCallback(OpenGLMessageCallback, nullptr);

  • 这个函数设置了一个回调函数 OpenGLMessageCallback,用于处理OpenGL生成的每条调试消息。每当OpenGL生成一个调试消息时,就会调用这个回调函数,并传递消息的详细信息作为参数。

glDebugMessageControl(GL_DONT_CARE, GL_DONT_CARE, GL_DEBUG_SEVERITY_NOTIFICATION, 0, NULL, GL_FALSE);

  • 这个函数调用用于控制OpenGL生成的调试消息的生成和过滤。

》》》》<spdlog/sinks/basic_file_sink.h> 是用于在使用 SPDLOG 日志库时,添加支持将日志输出到基本文件的头文件。

Eg

logSinks.emplace_back(std::make_shared<spdlog::sinks::basic_file_sink_mt>("Hazel.log", true));

》》》》Log文件中的修改

  • 创建日志输出目标:
    std::vector<spdlog::sink_ptr> logSinks;
    logSinks.emplace_back(std::make_shared<spdlog::sinks::stdout_color_sink_mt>());
    logSinks.emplace_back(std::make_shared<spdlog::sinks::basic_file_sink_mt>("Hazel.log", true));
  • logSinks 是一个存储 spdlog::sink_ptr(日志输出目标指针)的向量。
  • emplace_back 函数用于将日志输出目标添加到 logSinks 中。
  • 第一个日志输出目标是 stdout_color_sink_mt

    表示标准输出并带有颜色。

    第二个日志输出目标是 basic_file_sink_mt

    表示写入到文件 "Hazel.log" 中,并设置为追加模式(true 参数)。

  • 设置日志格式:
    logSinks[0]->set_pattern("%^[%T] %n: %v%$");
    logSinks[1]->set_pattern("[%T] [%l] %n: %v");

  • 对 logSinks 中的每个日志输出目标设置不同的日志格式。
  • 第一个日志输出目标

    使用彩色格式 %^[%T] %n: %v%$,其中 %^ 和 %$ 用于设置颜色。

    第二个日志输出目标

    使用不同的格式 [%T] [%l] %n: %v,其中 %l 表示日志级别。

  • 创建日志记录器:
    s_CoreLogger = std::make_shared<spdlog::logger>("HAZEL", begin(logSinks), end(logSinks));
    spdlog::register_logger(s_CoreLogger);
    s_CoreLogger->set_level(spdlog::level::trace);
    s_CoreLogger->flush_on(spdlog::level::trace);
    
    s_ClientLogger = std::make_shared<spdlog::logger>("APP", begin(logSinks), end(logSinks));
    spdlog::register_logger(s_ClientLogger);
    s_ClientLogger->set_level(spdlog::level::trace);
    s_ClientLogger->flush_on(spdlog::level::trace);
  • 使用 std::make_shared 创建两个名为 "HAZEL" 和 "APP" 的 spdlog::logger 对象。
  • 这些日志记录器将使用 logSinks 中的输出目标。

  • 使用 spdlog::register_logger 将每个日志记录器注册到 spdlog 系统中,使其能够被全局访问。
  • 设置日志记录器的默认日志级别为 trace,并且在每条日志写入时都进行刷新。

》》begin(logSinks) 和 end(logSinks)的作用??

begin(logSinks) 和 end(logSinks) 是C++标准库中的函数,用于获取指向容器(例如 std::vector)中第一个元素和尾后位置的迭代器。

s_CoreLogger = std::make_shared<spdlog::logger>("HAZEL", begin(logSinks), end(logSinks));

这里的 begin(logSinks) 和 end(logSinks) 分别返回了 logSinks 向量的起始迭代器和尾后迭代器。这样做的目的是将 logSinks 向量中的所有日志输出目标都传递给 spdlog::logger 对象的构造函数,以便该日志记录器可以使用这些目标来进行日志输出。


 

》》》》为何 Nut.log 会出现在 sandbox 文件夹路径下呢?

可能是因为在入口点中的 auto app = Nut::CreateApplication(); 对象是由 CreateApplication 创建的,但是 CreateApplication 被定义在 sandboxApp 中。

------Batch Rendering 批渲染----------

》》》》虽然说还有个维护没做,但我想先学习批渲染,维护太乏味了=_=

》》》》为什么说引用指针可以有效防止程序崩溃?在自动管理生命周期的时候防止对象被提前删除?

引用指针的作用:

  • 避免内存泄漏: 内存泄漏是指程序分配了一块内存后,由于没有正确释放,导致该内存无法再被程序使用。

使用引用计数,系统可以在对象不再被引用时及时释放其内存,从而避免了内存泄漏。

  • 避免空指针访问: 在某些情况下,程序可能会试图访问已经被释放的内存,这通常会导致空指针异常(Null Pointer Exception)。

引用计数确保对在不再被引用时释放,从而避免了访问已释放内存的问题。

》》》》关于批渲染,可以先看看ChernoOpenGL系列中的教程,看完之后再来GameEngine,会很轻松。

YouTube:  https://youtu.be/Th4huqR77rI?si=qaGtoiw--Oec5Pf-

BiliBili:【【双语】【TheCherno】OpenGL】 S28.批量渲染-介绍_哔哩哔哩_bilibili

有中文字幕,貌似比油管上更全?

》》》》glBufferSubData 的作用:

释义:

 glBufferSubData 是 OpenGL 中用于更新缓冲区对象部分数据的函数。

作用:

 将数据复制到已绑定的缓冲区对象的指定位置。

声明:

 void glBufferSubData(GLenum target, GLintptr offset, GLsizeiptr size, const void* data);

参数:

target 

指定了目标缓冲区对象的类型,

例如 GL_ARRAY_BUFFER 表示顶点缓冲区,GL_ELEMENT_ARRAY_BUFFER 表示索引缓冲区等。

offset 

指定了要更新的缓冲区数据的起始偏移量。

size 

指定了要更新的数据的字节数。

data 

指向要复制到缓冲区的数据的指针。

意义:

使用 glBufferSubData 可以在不重新分配整个缓冲区的情况下,只更新部分数据,这对于动态更新顶点数据或索引数据非常有用

》》》》指针的相减是有效的吗?如果在栈上分配两个变量base hind,其相减之后得到的确实是之间的有效大小,若是在堆上分配两个指针 base hind,其代表的内存可能是不相邻的,能否相减?

答:指针相减的有效性不取决于指针指向的内存地址是否连续,而是取决于它们指向的对象类型和大小。C++ 编译器能够根据指针类型自动计算出正确的偏移量,这使得指针相减在计算对象之间的距离时有效。

  • 如果 p 和 q 是指向数组元素的指针,例如 int* p 和 int* q,那么 p - q 将给出 p 和 q 之间的元素个数,而不管它们在内存中的确切位置。

 

》》》》绘制中的顶点顺序详见: 面剔除 - LearnOpenGL CN

以供参考

----Batch Rendering for Texture--------

》》》》std::array 的第二个参数:元素数量(大小)需要在编译时就确定下来

std::array 的模板参数中,第二个参数通常表示数组的大小,它必须是一个常量表达式,即在编译时期就能确定其值的表达式。这个值可以是一个整数常量、枚举常量、或者可以在编译时求值得到的表达式。

Eg.

std::array<int, 5> arr1; // 一个包含5个整数的std::array



std::array<double, 10> arr2; // 一个包含10个双精度浮点数的std::array



constexpr int size = 3;

std::array<char, size> arr3; // size 是一个编译时常量表达式



Static const int size = 3;

std::array<char, size> arr3; // size 是一个编译时常量表达式

 

 

 

 

》》》》这里没什么需要特别钻研的设计,我在代码上做了注释,多看两边视频或者代码或者注释完全能看懂。以下为运算符重载的定义与示例

  • 一般步骤:

选择合适的运算符:首先确定要重载的运算符,例如算术运算符 +, -, *, /,比较运算符 <, >, <=, >=, 等等。

确定函数签名:根据选择的运算符确定重载函数的签名。每个运算符都有其特定的函数名称和参数列表。

实现重载函数:按照确定的函数签名编写运算符重载函数的实现。

测试和验证:确保重载函数在各种情况下表现正常,包括正常情况、边界情况和异常情况。

  • 一般规范和注意事项:
    • 函数参数:大多数情况下,运算符重载函数至少有一个参数。例如,对于二元运算符(如 +),通常需要一个参数是当前对象本身,另一个是右操作数。对于一元运算符(如 ++),则只需要一个参数。
    • 成员函数 vs. 非成员函数:运算符重载函数可以作为类的成员函数或者非成员函数实现。成员函数版本会访问类的私有成员,非成员函数版本需要在参数列表中显式传递操作数。
    • 返回类型:通常应该返回与原生运算符相同类型的结果。例如,重载 + 运算符通常返回一个新的对象,该对象包含两个操作数相加的结果。
    • 避免副作用:确保运算符重载函数的行为与预期一致,不会对对象状态造成意外修改或者副作用。
    • 保持语义一致性:运算符重载函数的行为应该符合预期的数学和逻辑语义。例如,+ 运算符对于整数和浮点数的行为通常是相似的,应该保持类似的行为。

  • 示例:
  • 二元运算符重载示例(成员函数)
    class Vector {
    private:
        int x, y;
    
    public:
        Vector operator+(const Vector& other) const {
            Vector result;
            result.x = this->x + other.x;
            result.y = this->y + other.y;
            return result;
        }
    };

  • 一元运算符重载示例(成员函数):
    
    class Integer {
    private:
        int value;
    
    public:
        Integer operator++() { // 前缀++
            ++value;
            return *this;
        }
    
    Integer operator++(int) { // 后缀++
            Integer temp = *this;
            ++value;
            return temp;
        }
    };

  • 非成员函数运算符重载示例:
class Complex {
private:
    double real, imag;

public:
    Complex(double r, double i) : real(r), imag(i) {}

friend Complex operator+(const Complex& a, const Complex& b) {
        return Complex(a.real + b.real, a.imag + b.imag);
    }
};

 

 

》》》》const constexpr 的区别:

  • const 变量是在运行时初始化的常量,其值在编译时无法确定。
  • constexpr 变量是在编译时期初始化的常量表达式,其值可以用于需要常量表达式的上下文,例如数组大小、模板参数等。

 

 

》》》》*s_Data.Textures[i].get() == *texture.get() 的理解。

s_Data.Textures

std::array<Ref<Texture2D>, MaxTextureSlots> Textures;

Texture

const Ref<Texture2D>& texture

  • s_Data.Textures[i].get() 获取 s_Data.Textures[i] 所管理的 Texture2D 对象的裸指针。texture.get() 获取 texture 智能指针所管理的 Texture2D 对象的裸指针。
  • *s_Data.Textures[i].get() 和 *texture.get() 将这两个裸指针解引用,即获取它们所指向的 Texture2D 对象本身。而不是比较 .get() 获取的指针本身的值。
  • 因此,*s_Data.Textures[i].get() == *texture.get() 表示比较 s_Data.Textures[i] 和 texture 所管理的 Texture2D 对象是否相等,而不是比较它们的指针地址或其他内容。

》》》》为什么方形基础顶点数组需要被定义为glm::vec4?

为什么对顶点缓冲区推入顶点位置时,不能给 transform 转换矩阵右乘一个位移向量?

OK,线性代数没挂的人都知道:

1.矩阵的乘法是有要求的:前一个矩阵列数需要等于后一个矩阵的行数。

2.矩阵乘法不满足交换律,所以矩阵的乘法需要注意顺序。

矩阵不遵从分配率,其原因也是由于独特的且易出错的乘法计算方式,导致a*b和b*a的结果可能不相同,尽管a和b可能是规格相同的矩阵。

所以矩阵的乘法是相当讲究顺序的。一般来讲,可以看做从后向前的更新顺序,类似于

gl_Position = u_ViewProjection * u_Transform * vec4(a_Position, 1.0);

 

比如 transform ,先缩放,再旋转,后位移。

而且矩阵的计算是 a * b = a;的样式,所以我们将其结果从后向前寄存。

具体为什么要从后向前,我也大概忘了原理,参考:

Again: 变换 - LearnOpenGL CN

参考 LearnOpenGL

》》》》为什么不将角度设置为static的话,绘制出来的图像一直在原地抽搐颤动?(静态变量与非静态变量在循环中的区别)

在渲染循环中绘图,你需要对角度初始化之后,便不在清零,让其自增来达到效果。如果每渲染一次,便清零,则达不到旋转的效果,物体刚刚旋转就归位了,这便是问题所在。

静态变量 (static float temp = 0.0f;)

静态变量在程序运行期间只初始化一次,并且其值在函数调用之间保持不变。

非静态变量 (float temp = 0.0f;)

非静态变量在每次函数调用时都会重新初始化。

》》》深度理解

生命周期和存储位置:

  • 非static变量:

    也称为自动变量(automatic variables)。

    它们的生命周期和作用域受到定义它们的函数的控制。这些变量在函数被调用时创建,在函数执行完毕时销毁。

    所以非static变量会被存储在栈(stack)中。

    static变量:

    static关键字在这里表示静态存储期(static storage duration)。

    这意味着它们在程序的整个生命周期内存在,而不是在特定函数调用的生命周期内。static变量通常在程序开始时初始化,在程序结束时销毁。

    因此,静态变量会被存储在全局数据段(global data segment)中,或者在函数内部的情况下,存储在静态数据段(static data segment)中。

作用域:

  • 非static变量:

    它们的作用域仅限于定义它们的代码块(通常是函数)。在函数外部不可见。

    static变量:

    如果在函数内部定义,它们的作用域仅限于定义它们的函数。但是,static全局变量在整个文件内可见(文件作用域)。

初始化:

  • 非static变量:

    根据个人需要或程序确定,无特别要求。

    static变量:

    编译器需要确保静态成员变量有一个初始值,以便在程序运行时正确使用它。

    如果未显式初始化,它们会被自动初始化为0或者空值(比如全局static和静态成员变量)

》》》》为什么绘制出来的结果有偏移?(图中作答)

这里当时写错了,但是下面又有一些绘制的注释,担心删除后下面的墨迹格式错误,就留一些空白在这里。

》》》》const constexpr 的区别;

const 

在运行时确定初始值

性能一般。

constexpr 

在编译时求值得到结果

在某些情况下提高程序的性能,因为它允许编译器在编译时进行优化和计算。

------维护----------------

》》》》 Buffer.h OpenGLVertexArray.cpp 中出现的问题

对于int型或者float向量,我们只需要进行一次顶点属性的设置就好,但是对于3*3或4*4的矩阵,我们需要对每一列或每一行(OpenGL是列主序)进行多次顶点属性的设置。

所以需要更改ShaderDataType返回的分量数,对于3*3矩阵我们需要进行3次的3分量的设置,4*4矩阵进行4次4分量的设置。

另外,还有一个原因是,

》》》》glVertexAttribDivisor(m_VertexBufferIndex, 1) 的作用是什么?

在处理局矩阵类型的数据时,使用 glVertexAttribPointer() 设置了顶点属性之后通常需要 glVertexAttribDivisor()

原因:

对于矩阵类型的顶点属性(如变换矩阵),每个实例(被绘制的物体)通常具有不同的矩阵数据。

要在每次循环中调用 glVertexAttribDivisor(m_VertexBufferIndex, 1); 告知OpenGL如何处理这些数据的更新频率,

(实例化渲染是一种优化技术,允许多次渲染使用相同的顶点数据数组,并根据实例化数据动态改变渲染结果,比如不同的模型实例或者不同的变换状态)

效果:

这种方法可以显著提高渲染效率,因为它允许在一个绘制调用中处理多个实例,而不需要为每个实例重新设置顶点属性。

函数原型:glVertexAttribDivisor(index, divisor)

参数:

  • index:

这是顶点属性的索引,指定了要修改更新频率的顶点属性数组。在OpenGL中,顶点属性的索引从0开始递增,例如,0对应于位置属性,1对应于颜色属性,依此类推。

  • divisor:

这是一个无符号整数,用于指定顶点属性数组的更新频率。它决定了顶点属性在实例化渲染中如何使用:

如果 divisor 设置为 0,

顶点属性数组中的每个元素仅在顶点数据数组中的相应位置被使用一次。

如果 divisor 设置为 1,

顶点属性数组中的每个元素在每个实例渲染时都会被使用,但不同实例之间的数据可以不同。

如果 divisor 大于 1,

顶点属性数组中的每个元素会在每 divisor 个实例渲染时被使用一次,这样可以实现更高级的实例化渲染模式

示例理解

假设有一个场景,你希望渲染多个盒子,每个盒子都有不同的位移和旋转。为了实现这个目标,你可以: 

// 在初始化时启用顶点属性数组

glEnableVertexAttribArray(positionAttribIndex);

glEnableVertexAttribArray(colorAttribIndex);

glEnableVertexAttribArray(matrixAttribIndex);

 

// 设置顶点属性数组的更新频率

glVertexAttribDivisor(matrixAttribIndex, 1); // 矩阵属性每个实例更新一次

 

// 绘制实例化对象

 glDrawElementsInstanced(GL_TRIANGLES, numIndices, GL_UNSIGNED_INT, 0, numInstances);

假设 matrixAttribIndex 是变换矩阵属性的顶点属性索引。

通过 glVertexAttribDivisor(matrixAttribIndex, 1),我们告诉OpenGL在每个实例渲染时更新一次变换矩阵,以确保每个实例都可以根据自己的变换矩阵正确地定位和旋转。

》》》》实例化渲染参考文档:

实例化 - LearnOpenGL CN

》》》》关于 Core.h premake5.lua 中做的更改

1.__debugbreak() 在断言中只支持Windows相应,因为__debugbreak 在MSVC中是windows特有的函数。

2. GLFW_INCLUDE_NONE 现在仅在windows平台中进行了宏定义, 其应该被定义在全局中。

-----Testing Preformance---------

》》》》关于性能,大多是数学计算在CPU中占比较高。

关于那个Demo, 我没能clone下来成功运行,premake脚本会在bat文件运行时不断报错,我才可能是因为当时的 imgui,glfw 等库的 premake 脚本在随后的三四年中更新了,与现阶段早已不适配。

估计只有将当时的源码拿下来运行试试看,但我不准备做这个了。

》》》》性能探查器在这里:

-----------Make something (Particle System粒子系统)------

先把粒子系统弄出来,再看需不需要理解一遍粒子系统的实现代码。

》》》》粒子系统的渲染代码。

接下来看看x ,y 的坐标设置有什么设计,粒子的位置为什么是 x + pos.x

m_Particle.Position = { x + pos.x, y + pos.y }; 又有什么用?

x是视野中一个范围内的世界空间中的坐标,我们需要将其与摄像机当前的位置做计算,以确保粒子出现在正确的地方,不为粒子生成位置添加摄像机pos的话,会出现这样的情况:

(摄像机移动前单击处出现粒子,移动摄像机位置之后,鼠标单击处与粒子生成处有一定偏差)

为什么会出现这样的情况?(相机空间的原点不是世界空间中的原点,而是相机的位置。)

有一个物体在世界空间中的坐标为 (x, y),而相机在世界空间中的坐标为 (camX, camY)。现在需要将物体的位置转换到相机空间坐标系下。

》》》》 You can watch this video also: Making a PARTICLE SYSTEM in ONE HOUR! (NO ENGINES)

>>>> Here is Github Repo address: https://github.com/TheCherno/OneHourParticleSystem

Check  Main codes :

OpenGL-Sandbox/src/ParticleSystem.cpp

OpenGL-Sandbox/src/ParticleSystem.h

OpenGL-Sandbox/src/Random.cpp

OpenGL-Sandbox/src/Random.h

OpenGL-Sandbox/src/SandboxLayer.cpp

------sprite sheets 精灵表----------

》》》》最近没来得及更新,I'm so sorry for that

》》》》什么是精灵表?

精灵表(Spritesheet)是一个图像文件,其中包含多个小图(称为精灵),这些小图通常表示游戏或动画中使用的角色、物品、特效等。

通过将多个小图放置在同一个图像文件中,可以优化图像加载和渲染过程,提高程序的性能。而不是从不同的文件路径中一个一个加载不同的纹理。

Eg

》》》》 Here is website for free assets: Assets · Kenney

》》》》下载图片和查看图片:

下载的图片可能支持两种形式 ->  (图块分割和不分割)

Windows 可以在图片打开方式中选择:画图

打开后:(打开标尺和状态栏,选中“选择”按钮,实现测量效果)

------subtexture----------

》》》》关于一些设计,想提及的东西

需要注意在 x 还是 y 方向上为图块大小进行拓展。(此处为 y 方向上拓展一倍,而 x 不拓展)

------------Map of tiles------------------

》》》》一个错误

不要错写成(花括号可有可无)

》》》》关于几个设计为什么这样设计的理由

  • 前提:

假设有一个数组储存在内存中(通常来讲, C++中的数组是行主序的,每一行依次存储在内存中的连续位置)

Std::string abc =

{

  1111

  2222

  3333

  4444

}

那么在内存中则是

XXXXXXXXXXXXXXXXXXXXXXXX

XXXXX1111222233334444XX

XXXXXXXXXXXXXXXXXXXXXXXX

设计/问题 1为什么在双重 for loop 中,说是先对 x 进行遍历,再对 y 进行遍历这种方式,会比先对 y 进行遍历,后对 x 进行遍历的方式更快呢?

大多数情况下,内存是按行存储的。也就是说,当你顺序访问内存时,比如先按行遍历(先 x 后 y),可能会利用到CPU缓存行(cache line)的局部性,从而减少缓存不命中的情况,提升性能。

通俗的讲,先 x 后 y 的方式刚好能够顺序的在内存中连续访问数据,而先 y 后 x 不行。

1 . 先 x 后 y 刚好能够按照每一行的顺序从行头到行尾访问,并切换至下一行以类似的方式访问。(1111 2222 3333 4444

2 . 而先 y 后 x 则是类似于先访问每一行的行头,然后再次访问每一行的第二个、第三个、第四个元素。(1234 1234 1234 1234

设计/问题2 x + y * s_MapWidth 起到什么作用?

对于行主序的二维数组中的一个 (x, y) 元素,其在一维数组中的索引可以通过以下公式计算得到:
index = x + y * s_MapWidth

公式的解释如下:

y * s_MapWidth:确定了前 y 行所占的总元素个数。因为每一行有 s_MapWidth 个元素,所以前 y 行占据了 y * s_MapWidth 个元素的位置。

x:加上 x 则确定了在第 y 行中的具体列数,从而确定了最终的位置。

例子:

s_GameMap = [
  [0,  1,  2,  3],
  [4,  5,  6,  7],
  [8,  9,  10, 11],
  [12, 13, 14, 15]
]

假设二维数组 s_GameMap 的宽度为 4,要计算 (2, 3) 坐标在一维数组中的索引:
index = 2 + 3 * 4
   = 2 + 12
   = 14
这意味着 (2, 3) 在一维数组中的索引位置是 14。

设计/问题3为什么需要反转y轴?

在正常的绘图中,y轴是指向上方的,而字符串的索引方向是y轴指向下方。

所以通过字符串设置的地图,在绘制出来之后,需要对y轴坐标进行反转(用 字符串的Height 减去每一个字符的 y 坐标)

》》》》关于 Windows 11 不堪的内存状况

用久了Win11总是出现内存占用高的状况,可明明没有打开高消耗的应用。

听说是一个内存管理的bug导致的,总之为了解决这种问题,我们可以使用内置的工具。(Win + S 搜索)

这可能会耗费十分钟的时间,电脑会蓝屏自诊。

详情查看(2.概念与操作中的 》》》》关于 Windows 11 不堪的内存状况 )

--------Maintenance(又是维护)------

》》》》“|=”是什么意思?

概念:

"|=" 是一个位运算符,用于按位或并将结果赋值给左操作数

举例:

a |= b 表示将变量 a 的值与变量 b 的值进行或运算,并将结果赋给变量 a。

 a = 1010 (二进制)

 b = 0110 (二进制)

 a |= b 的结果为 1110 (二进制)

 

故:e.Handled |= e.IsInCategory(EventCategoryMouse) & io.WantCaptureMouse;  表示:

将 e.Handled 和 e.IsInCategory(EventCategoryMouse) & io.WantCaptureMouse 的结果进行按位或操作,并将结果赋值给 e.Handled。

》》为什么要使用 |=?

因为传入的事件需要在OnEvent中逐一检测是否可以触发,e.Handled 用来继承是否触发的状态,如果没有触发第一个事件,则要在下一个语句中继续检测是否触发。

》》》》ImGui 修复

之前删掉了 OnEvent() 函数,现在又将其添加上了,为什么?

因为如果浏览了73,74集,你会发现如果不将OnEvent函数显示表示出来的话,没办法手动禁用ImGui上的鼠标或者键盘事件。

这会导致在聚焦于多个ImGui窗口中的某一个窗口时,其他ImGui窗口会响应当前ImGui窗口上的鼠标/键盘事件。

》》》》Shader 修复

为什么将片段着色器中简单的颜色处理语句:

color = texture(u_Textures[int(v_TexIndex)], v_TexCoord * v_TilingFactor) * v_Color; 修改成了很长的动态积分表达式?

原因:

不这样做会导致四边形在某些系统上渲染黑色而不是渲染纹理。

Cherno所说:

之前使用的好好儿的,为什么需要额外修改一次?

因为AMD显卡的用户可能会因为没有使用 32 行的积分表达式而导致程序崩溃(或者只绘制出黑色这种纯色纹理)。

对于我而言,目前还不想将其进行修改,我会将其存在待办中,某一天需要时再做。

》》》》Instrumentor 修复

我只修复了宏定义的问题,没有修复编译器输出"__cdecl" 的问题,

详见: https://github.com/TheCherno/Hazel/pull/240

代码在: https://github.com/TheCherno/Hazel/commit/1f4694ee1588519ebd812082ba9d79f00be02b7e

关于修复宏定义中遇到的问题:

  • _MSC_VER 是什么宏?

_MSC_VER 是 Microsoft Visual C++ 编译器(MSVC)特有的预定义宏。

这个宏的值会根据编译器的版本而变化,例如 _MSC_VER 的值可能是 1900、1910、1920 等,对应不同版本的 MSVC 编译器。

  • 为什么将_MSC_VER作为确定编辑器的判定条件时,视觉效果正确了?

因为 __FUNCSIG__ 是 MSVC 编译器环境下特有的宏,在我的理解中,如果你的确使用了MSVC之外的其他编译器:clang , gcc等,则在其他的编译器中,__FUNCSIG__ 不会被正确识别。

所以需要添加一个额外的条件,以确保程序不会崩溃。

因此我们需要判断当前是不是 MSVC 编译器(即 _MSC_VER 被定义),又或者判断是否在 MSVC 编译器环境下(即 __FUNCSIG__ 被定义),二者的结果进行或运算,进而确保程序的正确性。

---Dockspace(窗口停靠/悬停空间)-----

》》》》这一集开始 Cherno 逐步改变视频呈现的方式, 70~114将会是一个新的系列。70~75是窗口的调整,UI会看起来更高级一点,仅仅是一点 :)

》》》》Cherno使用的管理Github的软件是 fork  ( Which you can download from: Fork - a fast and friendly git client for Mac and Windows ), 但不是免费的。

我在使用的是 github desktop ,免费。(You can download it : Download GitHub Desktop | GitHub Desktop ),这个东西好像有人做了汉化拓展包,我不太清楚,刚开始就用的英文(没少让我吃苦头)

》》》》Cherno使用的Trello面板我也搞来了: Manage Your Team’s Projects From Anywhere | Trello

》》》》顺带一提,这个停靠空间的绘制好像是掩盖了窗口上绘制的物体,因为我发现运行后没有任何物体被渲染,但是当我在窗口内单击的时候,绘制的方形确实变多了,这是因为我的粒子系统发生了作用。

为了将绘制在GLFW窗口上的物体转移到一个新建的ImGui窗口中,我们就需要下一集做的那样:创建一个帧缓冲区,然后将数据发送给ImGui窗口,以实现显示的效果。

------Framebuffer(帧缓冲区)---------

》》》》关于帧缓冲的文档

帧缓冲 - LearnOpenGL CN )可以看看,简单易懂。

》》》》Cherno 所做的

值得一提的是,Cherno的做法是比较老的方法(对纹理或缓冲区使用 glTexImage ,因为在早期 OpenGL 版本中,只有 glTexImage2D 可以为帧缓冲创建纹理或缓冲区:这一点可以在文档中找到),后来 OpenGL 为缓冲区添加了新的附加方式,并将其称为渲染缓冲对象附件,这可以专用于创建缓冲区(通过 glRenderbufferStorage 实现,有点类似于Cherno后来使用的 glTexStorage ),这样做有性能上的好处,因为这个新附件是特别设计过的。

可以在文档中了解。帧缓冲 - LearnOpenGL CN )我也这样为帧缓冲附加了缓冲区

 

这里Cherno将纹理称之为ColorAttachment(颜色附件),其实就是纹理的意思。和下述方式相同。

 

 

 

》》》》为什么需要将其传递给 ImGui::vec2

我的理解是,首先目前设置的新帧缓冲和默认的帧缓冲是没有什么区别的(在绘制的时候,如果你没有创建一份帧缓冲,OpenGL的工具库:GLFW、Glad会为你创建一个基本的帧缓冲,这就是为什么你在CPU上写的代码可以被渲染在屏幕上),新的缓冲区和默认的缓冲区中均包含纹理与缓冲区,且二者都没有被特殊处理,所以绑定了新缓冲区和没绑定看起来没什么差别。

  • 这时候,如果你想在OpenGL窗口上开辟一个新视口,绘制后期处理过的图像,就需要另外的绑定你需要的缓冲区,然后在其中编译可以实现特殊处理的图像的代码。像这样:

 

 

  • 如果这时候,如果你需要在OpenGL以外的其他窗口中绘制图像,你就需要拿到你想要的缓冲区中附加的纹理。比如你使用不同的帧缓冲绘制了不同的图像,你就需要拿到你想要的帧缓冲中的纹理/颜色附件,然后将其传递给其他窗口。

 

 

 

》》》》虚析构函数的作用

如果在派生类中仅放置一个析构函数而不在基类中声明它为虚拟的,删除基类指针指向的派生类对象时,只会调用基类的析构函数。

这会导致派生类的析构函数没有被调用,从而可能导致资源泄漏。

通过定义虚析构函数,我们可以确保派生类调用属于自己的析构函数,而不是基类的析构函数。

 

》》》》为什么通过 ImGui::Image 函数绘制帧缓冲传来的纹理会导致图像在对键盘'w','s'键时出现上下方向相反的情况?(鼠标响应的粒子也是)

 

而且我发现粒子在 ImGui 窗口中响应存在一定的 y 轴偏移量,这可能是因为目前事件响应还固定在OpenGL窗口上,而不是ImGui窗口,所以粒子出现在鼠标点击位置之下,而不是出现在鼠标单击位置。

 

 

 

 

----Making a new project -> Editor----

》》》》这一集工作量不大:

* 简化 Sandbox2D

* 更改 premake 文件

* 移植绘制代码并将其作为editor的实现代码

* 对几个函数进行了优化

 

》》》》VS中整理代码格式的快捷键

Ctrl + K, Ctrl + D

格式化整个文件的代码

Ctrl + K, Ctrl + F

只会格式化选中的代码区域


原文地址:https://blog.csdn.net/m0_74242407/article/details/142742545

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!