08处理着色器

主要是添加了struct ShaderProgramSourcestatic ShaderProgramSource ParseShader(const std::string& filepath)

#ifdef LY_EP08
#include <iostream>
#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <fstream>
#include <sstream>
#include <string>

struct ShaderProgramSource
{
	std::string VertexSource;
	std::string FragmentSource;
};

static ShaderProgramSource ParseShader(const std::string& filepath)
{

	enum class ShaderType
	{
		NONE = -1,VERTEX = 0,FRAGMENT =1
	};

	//input file stream
	std::ifstream stream(filepath);

	std::string line;
	std::stringstream ss[2];
	ShaderType type = ShaderType::NONE;
	
	while (getline(stream, line))
	{
		if (line.find("#shader") != std::string::npos)
		{
			if (line.find("vertex") != std::string::npos)
			{
				type = ShaderType::VERTEX;
			}
			else if (line.find("fragment") != std::string::npos)
			{
				type = ShaderType::FRAGMENT;
			}
		}
		else
		{
			ss[(int)type] << line << '\n';
		}
	}

	return { ss[0].str(),ss[1].str() };

}

//GLenum也就是unsigned int,这里不用GLenum是为了解耦
static unsigned int CompileShader(unsigned int type,
	const std::string& source)
{
	//创建着色器对象,向 OpenGL 申请一个空的“容器”来存放你的代码
	//把光标放在 glCreateShader( 的左括号后面,然后按下 Ctrl + Shift + Space。会弹出一个小黑框,显示:GLuint glCreateShader(GLenum type)
	//到glew.h搜索,发现typedef unsigned int GLenum;
	unsigned int id = glCreateShader(type);
	//返回一个指向该字符串首地址的只读指针(const char*)
	const char* src = source.c_str();

	//有了容器后,你需要把写好的字符串代码塞进去(拷贝)
	//3种用法
	//1. glShaderSource(id, 1, &src, nullptr);	只有一个字符串,且它是以 \0 结尾的。
	//2. glShaderSource(id, 1, &src, &len);	只有一个字符串,长度由 len 指定。
	//3. glShaderSource(id, 2, strings, lengths);	有两个字符串片段(数组形式),长度分别由 lengths[0] 和 lengths[1] 指定。
	glShaderSource(id, 1, &src, nullptr);

	//将你的 GLSL 代码翻译成显卡能理解的机器指令
	glCompileShader(id);

	//错误处理
	int result;

	// 查询编译状态:询问 OpenGL 这个 shader 编译成功了吗?
	// GL_COMPILE_STATUS 会把结果存入 result 中(成功为 GL_TRUE,失败为 GL_FALSE)
	glGetShaderiv(id, GL_COMPILE_STATUS, &result);

	if (result == GL_FALSE)
	{
		int length;
		//获取错误日志长度:如果编译失败,先问一下错误信息一共有多少个字符
		glGetShaderiv(id, GL_INFO_LOG_LENGTH, &length);
		//在栈上申请内存
		//如果申请的内存太大,alloca会导致栈溢出(1MB)
		char* message = (char*)alloca(length * sizeof(char));
		//提取错误信息:把显卡驱动里的具体报错文字拷贝到我们刚才申请的 message 内存中
		glGetShaderInfoLog(id, length, &length, message);
		std::cout << "Failed to compile " << ((type == GL_VERTEX_SHADER) ? "vertex" : "fragment") << " shader!";
		std::cout << message << std::endl;
		//清理:编译失败了,这个 shader 对象也就没用了,删掉它防止内存泄漏
		glDeleteShader(id);
		return 0;
	}


	return id;
}

static unsigned int CreateShader(const std::string& vertexShader,
	const std::string& fragmentShader)
{
	//在 GPU 中申请一个空的“程序对象”
	unsigned int program = glCreateProgram();

	//编译顶点着色器
	unsigned int vs = CompileShader(GL_VERTEX_SHADER,
		vertexShader);
	//编译片段着色器
	unsigned int fs = CompileShader(GL_FRAGMENT_SHADER,
		fragmentShader);

	//把已经编译好的顶点着色器和片段着色器附加到程序对象中
	glAttachShader(program, vs);
	glAttachShader(program, fs);
	//链接
	glLinkProgram(program);
	//验证当前的程序是否能在当前的 OpenGL 状态下执行(
	// 检查顶点着色器的输出是否与片段着色器的输入匹配,并
	// 生成最终的可执行二进制代码。)
	glValidateProgram(program);

	//删除临时的中间产物(类似编译完 C++ 后删除 .obj
	glDeleteShader(vs);
	glDeleteShader(fs);


	return program;
}

int main(void)
{
#pragma region 一些初始化
	GLFWwindow* window;
	// 初始化 GLFW 库,失败则退出
	if (!glfwInit())
		return -1;

	//强制指定使用 Core Profile(核心模式),如果
	//没有手动写着色器则不会渲染;如果不是核心模式,
	//在固定管线中默认颜色是白色,且默认顶点在NDC标准
	//设备坐标系中
	//glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
	//glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
	//glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);

	// 创建窗口对象
	window = glfwCreateWindow(640, 480, "Hello World", NULL, NULL);
	if (!window)
	{
		// 创建失败则清理并退出
		glfwTerminate();
		return -1;
	}

	// 将当前窗口的上下文设置为 OpenGL 渲染的目标
	glfwMakeContextCurrent(window);

	// 初始化 GLEW 以加载 OpenGL 函数指针,需在有上下文后执行
	if (glewInit() != GLEW_OK)
	{
		std::cout << "Error!" << std::endl;
	}

#pragma endregion

	// 定义三角形的顶点坐标(CPU 内存)
	float positions[6] = {
		-0.5f, -0.5,
		0.0f, 0.5f,
		0.5f, -0.5f,
	};

	unsigned int buffer;
	// 生成一个缓冲区 ID
	glGenBuffers(1, &buffer);
	// 绑定该 ID 到顶点缓冲区插槽
	glBindBuffer(GL_ARRAY_BUFFER, buffer);
	// 将顶点数据从 CPU 内存拷贝到 GPU 显存,设为静态读取模式
	glBufferData(GL_ARRAY_BUFFER, 6 * sizeof(float),
		positions, GL_STATIC_DRAW);


	// 启用索引为 0 的(顶点)属性 
	glEnableVertexAttribArray(0);


	//1. 打标签:它把当前 GL_ARRAY_BUFFER 里的数据流,贴上了“0号”的标签。2. 定规则:它告诉 GPU,当你(Shader)想要 location = 0 的数据时,请按照“每 2 个 float 为一组”的规则去缓存里抓取。
	glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(float) * 2, (const void*)0);
	 
	//这是一个相对路径,相对于 工作目录
	//如果不是在visual studio中运行,就会相对于 可执行文件 所在的目录
	//如果在visual studio中,工作目录被设置为$(ProjectDir) (右键目录-属性-调试-woking directory)
	ShaderProgramSource source = ParseShader("res/shaders/Basic.shader");

	std::cout << source.VertexSource << std::endl;
	std::cout << source.FragmentSource << std::endl;

	// 将字符串源码编译并链接成一个完整的程序对象
	unsigned int program = CreateShader(source.VertexSource, source.FragmentSource);
	// 告诉 OpenGL 状态机:接下来的所有绘制指令(如 glDrawArrays)
	// 都请使用这个编译好的着色器程序进行渲染
	glUseProgram(program);


	// 游戏/渲染主循环
	while (!glfwWindowShouldClose(window))
	{
		// 清理屏幕颜色缓冲区
		glClear(GL_COLOR_BUFFER_BIT);

		// 读取当前绑定的缓冲区数据并绘制三角形:从
		// 索引0即第1个顶点开始画,读取三个顶点(
		//并行地读第1个顶点的0号属性_以及_第1个顶点的1号属性) 
		glDrawArrays(GL_TRIANGLES, 0, 3);

		// 交换前后缓冲区以刷新画面
		glfwSwapBuffers(window);

		// 轮询并处理窗口事件(如键盘输入、关闭动作)
		glfwPollEvents();
	}

	// 手动通知显卡驱动释放该程序占用的显存
	glDeleteProgram(program);

	// 退出前清理资源
	glfwTerminate();
	return 0;
}

#endif

着色器

07编写一个着色器

概述#

编写最基础的顶点着色器 (Vertex Shader)片段着色器 (Fragment Shader),并将它们链接成一个可以在显卡上运行的着色器程序。

  • 课程引入 回顾上一集关于着色器的理论概念,明确本集目标:编写实际代码让上一集绘制的三角形呈现特定的颜色(不再是默认的白色或黑色)。
  • 创建基础字符串结构 演示如何在 C++ 中以字符串形式编写 GLSL 代码。由于目前还没有文件读取系统,Cherno 直接在 main 函数中使用原始字符串(Raw Strings)来定义顶点和片段着色器的源码。
  • 编写顶点着色器 (Vertex Shader)
    • 指定版本:#version 330 core
    • 定义输入属性:layout(location = 0) in vec4 position;
    • 主函数 main:将输入的坐标赋值给内置变量 gl_Position
  • 编写片段着色器 (Fragment Shader)
    • 定义输出颜色:layout(location = 0) out vec4 color;
    • 主函数 main:为像素指定颜色值(例如红色 vec4(1.0, 0.0, 0.0, 1.0))。
  • 创建编译与链接函数 CreateShader 开始实现逻辑:如何将上述两个字符串交给 OpenGL。
    • 调用 glCreateProgram 创建一个程序容器。
    • 调用自定义的 CompileShader 函数分别编译顶点和片段部分。
  • 实现 CompileShader 函数 这是核心步骤:
    1. glCreateShader:根据类型创建着色器对象。
    2. glShaderSource:将 C++ 字符串传递给 OpenGL。
    3. glCompileShader:调用显卡驱动进行编译。
  • 错误处理与调试 (Shader Debugging) 这是新手最容易卡住的地方。Cherno 详细演示了如何使用 glGetShaderiv 检查编译状态,并用 glGetShaderInfoLog 打印出显卡报错信息。
  • 链接着色器程序 (Linking) 使用 glAttachShader 将编译好的两个着色器附加到程序上,然后调用 glLinkProgram 进行链接,最后用 glValidateProgram 验证。
  • 清理工作 链接成功后,调用 glDeleteShader 删除临时的中间产物(类似编译完 C++ 后删除 .obj 文件),以节省资源。
  • 实际运行测试 将编写好的 CreateShader 函数集成到渲染循环之前。
  • 结果展示与总结 屏幕上成功出现了一个红色的三角形。Cherno 总结了着色器在现代渲染管线中的重要角色。

学习要点提示:

06着色器原理

什么是着色器 (Shaders)#

着色器本质上是运行在 GPU(显卡)上的程序。在 OpenGL 绘图流程中,我们需要在 GPU 上运行代码来处理我们发送过去的顶点数据

  • 当你调用 glCompileShader(id) 时,显卡驱动(运行在 CPU 上)会获取这个字符串。显卡驱动里内置了一个编译器。它会将你的 GLSL 源代码翻译成该显卡硬件能听懂的机器码(微指令)。由于不同品牌的显卡(NVIDIA, AMD, Intel)底层指令集完全不同,所以 Shader 必须在你的电脑上“现场编译”,而不能像 C++ 那样预先编译成 .exe。编译好的二进制指令会被发送并存储在 GPU 的显存中。当你执行 glDrawArrays 时,GPU 会激活这些指令,大规模并行地处理你传进去的顶点数据。
  • 我们需要显卡的能力在屏幕上绘制图形,所以需要程序完全在显卡上运行,当然部分代码需要在CPU上运行
  • 着色器的本质,就是告诉GPU如何处理CPU发送给他的数据

渲染管线概述 (The Graphics Pipeline)#

解释了数据从 CPU 发送到 GPU 后的处理过程渲染管线是一系列将 3D 坐标转换为屏幕上 2D 像素的步骤。

渲染管道:在CPU上写了一堆数据,之后向显卡发送了一些数据,然后发送了DrawCall指令(发送指令前绑定了一些状态),最后进入了着色器的阶段,GPU实际处理DrawCall指令并在屏幕上绘制一些东西

着色器#

顶点着色器和片段着色器是顺着管道的两种不同的着色器类型,发出DrawCall指令后,顶点着色器被执行,然后再经过一些阶段后 是片段着色器

顶点着色器 把 3D 空间的坐标转换成屏幕上的 2D 坐标(术语叫标准化设备坐标) ->几何着色器 增减图形:可以把一个点变成一个三角形,或者把一个三角形删掉。这里,几何着色器根据这 3 个点,计算出了第 4 个点的位置,并把它们重新组合。输出:它“发射”出了足够的顶点,组成了两个独立的三角形图元。 ->图元组装 把处理好的点“连线” ->光栅化 计算这个三角形到底盖住了屏幕上哪些像素格子 ->片段着色器 根据光照、纹理、材质,给这个像素算出一个最终的 RGBA 值 ->测试与混合 深度测试 (Depth Test):检查这个像素是不是被别的物体挡住了。模板测试:一些特殊遮罩效果。混合 (Blending):如果是半透明的,就把它和背景颜色融合

05顶点属性和内存布局

在第 04 集里你把数据塞进了显存,但 GPU 面对那一串二进制数字是“瞎”的。这一集的核心就是给数据贴标签,告诉 GPU 如何读取这些坐标。

  • 问题的引入:为什么屏幕还是黑的?
    • 数据(Positions)已经在 VRAM 里了,但 GPU 不知道这堆 float 代表什么。
    • 是一组 坐标?还是 颜色?或者是贴图坐标?
    • 我们需要一份“说明书”来定义数据的布局 (Layout)
  • 核心函数:glVertexAttribPointer 详解
    • 这是 OpenGL 中参数最复杂的函数,Cherno 逐一拆解了这 6 个参数:
      1. index: 属性的索引(例如:0号属性给位置,1号属性给颜色,2号属性给纹理)。
      2. size: 每个属性包含几个分量(位置是3,纹理是2)。
      3. type: 数据类型(GL_FLOAT)。
      4. normalized: 是否归一化(通常选 GL_FALSE)。
      5. stride (步长): 最关键参数。从某个属性的起始点下一个属性起始点的字节数。(这里第一组到第二组,位置index从0->5,纹理index从3->8,这里是5 * sizeof(float)
      6. pointer (偏移量): 在单个顶点数据块内,该属性距离(这个块的)起始位置的字节偏移。
//这里举一个特殊的例子,LearnOpenGL中的
float vertices[] = {
-0.5f, -0.5f, -0.5f,  0.0f, 0.0f,
 0.5f, -0.5f, -0.5f,  1.0f, 0.0f,
 0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
 };
 
//配置位置属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0); 

//配置纹理属性
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
//第二个2,和上面第一个参数,也就是 vs文件中的location一致,
//表示要启用 2 号属性(对应纹理)
glEnableVertexAttribArray(2);
  • 内存布局的可视化演示
    • Cherno 使用画图工具展示了数据在内存中是如何排列的。
    • 如果每个顶点只有 2 个 float 坐标,那么 Stride 就是 2 * sizeof(float)
    • 如果以后加上纹理坐标(又多 2 个 float),Stride 就会变成 4 * sizeof(float)
  • 启用顶点属性 (glEnableVertexAttribArray)
    • 仅仅定义了“说明书”还不够,必须通过这个函数手动开启对应的属性索引(本例中是 0)。
    • 默认情况下,为了性能,所有属性槽位都是关闭的。
  • 链接缓冲区与属性
    • 强调了一个隐式逻辑:glVertexAttribPointer 关联的是当前绑定在 GL_ARRAY_BUFFER 上的那个 ID
    • 这再次体现了 OpenGL “状态机”的特性。
  • 总结与运行结果
    • 加上这两行关键代码后,配合默认或简单的着色器,屏幕上终于能画出三角形的轮廓了。
    • 预告:下一集将进入 Shader (着色器) 的世界,那是真正赋予图形灵魂的地方。
  • 核心操作流程(接续你的代码)

在你的 glBufferData 之后,必须补充这两行,否则 glDrawArrays 找不到数据:

04顶点缓冲区和绘制三角形

总述#

这一集(时长 20:06)标志着你正式从“配置环境”跨越到了“图形编程”。这里讲解了现代 OpenGL 的灵魂:如何把 CPU 内存里的数据塞进 GPU 显存里

  • 什么是顶点缓冲区 (Vertex Buffer / VBO)?

    • 核心定义:它本质上是 GPU 显存中的一块内存区域,用于存储顶点的各种数据(坐标、颜色等)。
    • 为什么要用它?:CPU 到 GPU 的总线传输相对较慢。我们希望一次性把大量数据推送到显存,然后让 GPU 在内部高速读取,而不是每帧都由 CPU “手把手”教 GPU 怎么画。
  • 定义顶点数据

    • 在 C++ 中创建一个浮点数数组 float positions[6],存入三个顶点的 坐标。
    • 注意:此时这些数据还在 RAM(内存) 中,GPU 还看不见它们。
  • 生成缓冲区 ID (glGenBuffers)

    • OpenGL 是基于状态机的。我们不直接操作对象指针,而是操作 ID (unsigned int)
    • 调用 glGenBuffers(1, &buffer):向 OpenGL 申请一个 ID,代表我们要创建的一个缓冲区。
  • 绑定缓冲区 (glBindBuffer)

    • 这是初学者最容易晕的地方。OpenGL 像是一个只有一个插槽的播放器。
    • 调用 glBindBuffer(GL_ARRAY_BUFFER, buffer):告诉 OpenGL,“从现在起,所有关于 GL_ARRAY_BUFFER 的操作,都请针对我刚刚申请的这个 buffer 进行”。
  • 填充数据 (glBufferData)

    • 这是真正发生“搬运”动作的代码。
    • 参数解释:
      1. 目标:GL_ARRAY_BUFFER
      2. 大小:6 * sizeof(float)
      3. 数据指针:positions
      4. 使用方式:GL_STATIC_DRAW(告诉显卡:这些数据我画一次后基本不动了,请优化存储)。
  • 为什么屏幕上还是没东西?

01-03基础配置

01-03基础配置#

[16:04] 01-欢迎来到 OpenGL-Welcome to OpenGL
[22:03] 02-在 C++ 中设置 OpenGL 并创建窗口-Setting up OpenGL and Creating a Window in C++
[18:21] 03-在 C++ 中使用现代 OpenGL-Using Modern OpenGL in C++
[20:06] 04-顶点缓冲区与在 OpenGL 中绘制三角形-Vertex Buffers and Drawing a Triangle in OpenGL
[18:54] 05-OpenGL 中的顶点属性与布局-Vertex Attributes and Layouts in OpenGL
[17:37] 06-OpenGL 中的着色器工作原理-How Shaders Work in OpenGL
[28:21] 07-在 OpenGL 中编写着色器-Writing a Shader in OpenGL
[21:15] 08-我是如何处理 OpenGL 中的着色器的-How I Deal with Shaders in OpenGL
[16:54] 09-OpenGL 中的索引缓冲区-Index Buffers in OpenGL
[23:42] 10-处理 OpenGL 中的错误-Dealing with Errors in OpenGL
[11:27] 11-OpenGL 中的 Uniform 变量-Uniforms in OpenGL
[21:50] 12-OpenGL 中的顶点数组-Vertex Arrays in OpenGL
[26:46] 13-将 OpenGL 抽象为类-Abstracting OpenGL into Classes
[30:07] 14-OpenGL 中的缓冲区布局抽象-Buffer Layout Abstraction in OpenGL
[21:56] 15-OpenGL 中的着色器抽象-Shader Abstraction in OpenGL
[14:43] 16-在 OpenGL 中编写基础渲染器-Writing a Basic Renderer in OpenGL
[31:44] 17-OpenGL 中的纹理-Textures in OpenGL
[12:37] 18-OpenGL 中的混合-Blending in OpenGL
[18:07] 19-OpenGL 中的数学-Maths in OpenGL
[20:10] 20-OpenGL 中的投影矩阵-Projection Matrices in OpenGL
[15:53] 21-OpenGL 中的 MVP 矩阵-Model View Projection Matrices in OpenGL
[14:36] 22-在 OpenGL 中使用 ImGui-ImGui in OpenGL
[12:21] 23-在 OpenGL 中渲染多个物体-Rendering Multiple Objects in OpenGL
[16:52] 24-为 OpenGL 设置测试框架-Setting up a Test Framework for OpenGL
[22:46] 25-在 OpenGL 中创建测试-Creating Tests in OpenGL
[28:13] 26-在 OpenGL 中进行纹理测试-Creating a Texture Test in OpenGL
[11:37] 27-如何让你的 UNIFORMS 更快-How to make your UNIFORMS FASTER in OpenGL
[12:25] 28-批量渲染:简介-Batch Rendering - An Introduction
[09:15] 29-批量渲染:颜色-Batch Rendering - Colors
[15:51] 30-批量渲染:纹理-Batch Rendering - Textures
[23:17] 31-批量渲染:动态几何体-Batch Rendering - Dynamic Geometry

01WelcomToOpengl#

  • 课程初衷与背景
    • 作者介绍了为什么要制作这个系列:市面上很多教程只教“怎么做”,而不教“为什么”。
    • 本系列的目标是不仅让你写出代码,还要让你理解 OpenGL 的底层工作原理及其与 GPU 的交互逻辑。
  • 什么是 OpenGL?
    • 核心定义:OpenGL 本质上是一个规范(Specification) 接口,API ,而不是一个具体的库。它规定了函数应该如何命名、参数是什么以及预期的行为允许我们实际访问我们的GPU,GraphicsProcessingUnit,图形处理单元。OpenGL是其中之一的接口,还有Direct3D,Vulkan,Metal等
    • 实现者具体的实现通常由显卡厂商(NVIDIA, AMD, Intel)编写在驱动程序中
    • 跨平台,Windows、Linux、Mac、Android、IOS
    • 状态机(State Machine):初步引入 OpenGL 是一个状态机的概念,你设置好状态(如颜色、缓冲区),然后发出指令进行渲染
  • 现代 OpenGL vs 传统 OpenGL
    • Legacy OpenGL (固定管线):简单但不够灵活,很多功能已经废弃(如 glBegin, glEnd)。
    • Modern OpenGL (可编程管线):通过 着色器 (Shaders) 控制渲染过程。虽然代码量显著增加,但提供了极大的灵活性和性能优化空间。
    • Shader(着色器)是程序,在==GPU==上运行的代码程序
    • 本系列将专注于 Modern OpenGL (版本 3.3 及以上)
  • 学习 OpenGL 的难点与心态
    • 强调了学习曲线:初期需要写==大量的“模板代码”(Boilerplate code)==才能在屏幕上画出一个简单的三角形。
    • 图形管线 (Graphics Pipeline):解释了数据如何从 CPU 传输到 GPU,并经过顶点处理、光栅化最终变成像素的过程。
  • 开发环境与工具链预告
    • 虽然是跨平台的,但本系列主要在 Windows 下使用 Visual Studio 演示。
    • 提到后续会使用的关键第三方库:GLFW(窗口管理)和 GLEW/GLAD(访问 OpenGL 扩展函数)。
  • 总结与后续计划
    • 鼓励初学者不要被前几集的复杂配置和概念吓退。
    • 下一集将正式进入 C++ 环境配置,动手创建第一个窗口。

02设置Opengl和创建窗口#

主要讲解如何在 Windows 环境下使用 Visual Studio 搭建 OpenGL 开发环境,并编写代码成功弹出一个窗口。这是所有图形编程的起点。

09Camera

Camera#

上一章我们讨论了视图矩阵以及如何利用视图矩阵在场景中移动(我们稍微向后移动了一下)。OpenGL 本身并不支持 摄像机 的概念,但我们可以尝试通过反向移动场景中的所有物体来模拟摄像机,从而产生移动的错觉

本章我们将讨论如何在 OpenGL 中设置摄像机。我们将介绍一种飞行式摄像机,它允许您在 3D 场景中自由移动。我们还将讨论键盘和鼠标输入,最后介绍自定义摄像机类

相机/视图空间 Camera/View space#

当我们讨论相机/视图空间时,指的是从相机视角(即场景原点)观察到的所有顶点坐标:视图矩阵将所有世界坐标转换为相对于相机位置和方向的视图坐标。要定义一个相机,我们需要它在世界空间中的位置、它的朝向、一个指向右侧的向量和一个指向上方的向量。细心的读者可能会注意到,我们实际上要创建一个以相机位置为原点的、具有三个相互垂直的单位轴的坐标系本章前面的目的,那转换成这个坐标系的目的又是什么呢?—>(重要★★):世界上所有的物体坐标都重新量一遍,量出它们==相对于你眼睛距离和角度==。
注意下面这个公式:

LookAt=[RxRyRz0UxUyUz0DxDyDz00001][100Px010Py001Pz0001]\begin{array}{c} LookAt = \begin{bmatrix} \color{red}{R_x} & \color{red}{R_y} & \color{red}{R_z} & 0 \\ \color{green}{U_x} & \color{green}{U_y} & \color{green}{U_z} & 0 \\ \color{blue}{D_x} & \color{blue}{D_y} & \color{blue}{D_z} & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} * \begin{bmatrix} 1 & 0 & 0 & -\color{purple}{P_x} \\ 0 & 1 & 0 & -\color{purple}{P_y} \\ 0 & 0 & 1 & -\color{purple}{P_z} \\ 0 & 0 & 0 & 1 \end{bmatrix} \end{array}

左边这个矩阵,是用以观察点为原点的坐标系算出的三个点,来形成新坐标系。由于矩阵 乘法是先进行右边的换算,P表示cameraPos,所以就是说,先乘以右边矩阵,把世界坐标系的原点移到和cameraPos重叠;再乘以左边矩阵,得出在新坐标系下以cameraPos为原点,他的坐标该是多少(也就是换算成了原坐标系下的每个点,在新坐标系下与cameraPos的x,y,z的距离)

08Coordinate-Systems

坐标系 Coordinate Systems#

上一章我们学习了如何利用变换矩阵来变换所有顶点,从而更好地使用矩阵。OpenGL 要求所有我们希望可见的顶点在每次顶点着色器运行后都处于归一化设备坐标系中。也就是说,每个顶点的 xyz 坐标应该介于 -1.01.0 之间;超出此范围的坐标将不可见。通常的做法是,我们指定一个自定义范围(或空间)内的坐标,然后在顶点着色器中将这些坐标变换为归一化设备坐标 (NDC)。之后,这些 NDC 坐标会被传递给光栅化器,由光栅化器将其转换为屏幕上的二维坐标/像素。

顶点着色器:处理的是“点”(数学上的位置)。 光栅化器:把“点”连接成“面”,并把这个“面”切碎成一个个对应屏幕像素的“小方块”(片段)。 确定哪些像素属于这个图形 片段着色器:给这些被选中的“小方块”涂上最终的颜色。

将坐标系转换为 NDC 坐标系通常是分步进行的,即先将对象的顶点转换到多个坐标系,最后再转换到 NDC 坐标 系。这样做的好处是,某些操作/计算在某些坐标系中更容易进行,这一点很快就会显现出来。总共有 5 个对我们来说比较重要的坐标系:

  • 局部空间(或对象空间) Local Space,也称物体空间
  • 世界空间 World Space,乘以 Model Matrix (模型矩阵)后得到。把所有物体放到同一个大地图里,有了各自的相对位置
  • 视野空间(或眼睛空间) 乘以 View Matrix (观察矩阵)后得到。状态: 以摄像机为原点(0,0,0),看所有物体在哪里。类比: 摄影师扛起摄像机对准桌子。现在的坐标不再是“距离片场中心多远”,而是“距离镜头多远、在镜头画面的左边还是右边”
  • 裁剪空间 乘以 Projection Matrix (投影矩阵)后得到。状态: 决定哪些东西在镜头里,哪些在镜头外。类比: 摄影师决定用“广角镜头”还是“长焦镜头”。在这个空间里,超出镜头范围的物体(比如演员伸到画面外的手)会被直接剪掉。透视除法也发生在这个阶段之后。
  • 屏幕空间 Viewport Transform (视口变换)。状态: 将坐标映射到显示器的实际像素(如 1920×10801920 \times 1080 )。类比: 电影最后投映在电影院的银幕上。原本的比例坐标变成了具体的像素点位置

这些都是顶点在最终变成片段之前会经历的不同状态。

你现在可能对空间或坐标系究竟是什么感到很困惑,所以我们首先会以更宏观的方式解释它们,展示整体情况以及每个特定空间所代表的含义

全球概况 The global picture#

为了将坐标从一个空间转换到另一个坐标空间,我们将使用几个变换矩阵,其中最重要的包括==模型矩阵视图矩阵投影矩阵。我们的顶点坐标首先在局部空间中作为局部坐标出现,然后进一步转换为*世界坐标 World Coordinates 、视图坐标 View Coordinates) 、裁剪坐标 Clip Coordinates ,最终转换为屏幕坐标 Screen Coordinates *==。下图展示了这一过程,并说明了每个变换的作用:

07Transformations

转变 Transformations#

我们现在知道如何创建物体,给它们着色,或者用纹理赋予它们精细的外观,但它们仍然不够有趣,因为它们都是静态物体。我们可以尝试通过改变它们的顶点并在每一帧重新配置它们的缓冲区来让它们动起来,但这很麻烦,而且会消耗大量的处理能力。其实有更好的方法来变换物体,那就是使用(多个)矩阵对象。但这并不意味着我们要讨论功夫和庞大的数字人工世界。

矩阵是非常强大的数学结构,乍一看似乎令人生畏,但一旦你熟悉了它们,就会发现它们非常有用。在讨论矩阵时,我们需要稍微了解一些数学知识,对于数学基础较好的读者,我还会提供一些额外的阅读资源。

然而,要充分理解变换,我们首先需要深入研究向量,然后再讨论矩阵。本章的重点是为你提供一些我们后续会用到的基本数学知识。如果这些内容比较难懂,请尽量理解它们,并在需要时再回到本章复习相关概念。

向量 Vectors#

从最基本的定义来看,向量就是方向,仅此而已。向量具有方向和大小(也称为强度或长度)。你可以把向量想象成藏宝图上的指示:“向左走10步,然后向北走3步,再向右走5步”;这里,“向左”是方向,“10步”是向量的大小。因此,藏宝图上的指示包含了3个向量。向量可以是任意维度,但我们通常处理的是2到4维的向量二维向量表示平面上的一个方向(想想二维图形),三维向量可以表示三维空间中的任何方向。

下面你会看到三个向量,每个向量在二维图中用 (x,y)(x,y) 表示为箭头。由于二维(而非三维)表示向量更直观,你可以将这些二维向量视为 zz 坐标为 00 的三维向量。由于向量表示方向,向量的原点不会改变其值。在下面的图中我们可以看到,向量 vˉ\color{red}{\bar{v}}wˉ\color{blue}{\bar{w}} 即使原点不同,它们的值也相等:

数学家在描述向量时,通常倾向于使用头顶带横线的字符符号,例如 vˉ{\bar{v}} 此外,在公式中表示向量时,通常也采用以下方式

vˉ=(xyz)\bar{v} = \begin{pmatrix} \color{red}x \\ \color{green}y \\ \color{blue}z \end{pmatrix}

由于向量被定义为方向,因此有时很难将其可视化为位置。如果我们想将向量可视化为位置,我们可以将方向向量原点想象为 (0,0,0)(0,0,0) ,然后指向某个特定方向来确定该点,从而使其成为位置向量(我们也可以指定不同的原点,然后说:“该向量从该原点指向空间中的该点”)。位置向量 (3,5)(3,5) 则指向原点为 (0,0)(0,0) 的图上的 (3,5)(3,5) 点。因此,我们可以利用向量来描述二维和三维空间中的方向位置

就像对普通数字一样,我们也可以对向量定义几种运算(其中一些你已经见过了)。

标量向量运算 Scalar vector operations#

标量是一个数字。当对向量进行加/减/乘/除运算时,我们只需将向量的每个元素分别乘以或减去该标量即可。例如,加法运算如下所示:

(123)+x(123)+(xxx)=(1+x2+x3+x) \begin{pmatrix} \color{red}1 \\ \color{green}2 \\ \color{blue}3 \end{pmatrix} + x \rightarrow \begin{pmatrix} \color{red}1 \\ \color{green}2 \\ \color{blue}3 \end{pmatrix} + \begin{pmatrix} x \\ x \\ x \end{pmatrix} = \begin{pmatrix} \color{red}1+x \\ \color{green}2+x \\ \color{blue}3+x \end{pmatrix}

其中 + 可以是 + 、 − 、 ⋅ 或 ÷ ,其中 ⋅ 是乘法运算符。