入门篇实战 — 从这里,你拥有了自己的 3D 引擎
回顾所有核心概念,将它们串成完整的渲染管线,亲手搭建一个 3D 场景浏览器。
这堂课在 mission 中的位置
这是 第 1 阶段(入门篇)的收官课。前 8 课你学会了一个又一个独立的概念,现在是时候把它们全串起来了。
里程碑意义 完成这课后,你将拥有一个完整的、可交互的 3D 渲染程序。它本质上就是 Unity/Unreal/Godot 编辑器 Scene 视图中最基本功能的实现。
第一阶段核心概念全景回顾
让我们从第 1 课到第 8 课,沿着一条主线回顾所有知识点:
概念地图:从零到 3D 场景浏览器
你的代码 GPU 内部 屏幕
────────── ──────── ────
[1. 创建窗口] 初始化 OpenGL 上下文
↓
[2. 定义顶点数据] 顶点缓冲 (VBO)
↓ 顶点数组 (VAO)
[3. 编写着色器]
├─ 顶点着色器 处理每个顶点位置
├─ 片段着色器 计算每个像素颜色
└─ 着色器程序 链接 & 编译
↓
[4. 设置纹理] 纹理单元 & 采样器
↓ UV 坐标映射
[5. 应用变换] Model 矩阵 (位置/旋转/缩放)
↓
[6. 设置坐标系]
├─ Model 矩阵 局部 → 世界
├─ View 矩阵 世界 → 观察
└─ Projection 矩阵 观察 → 裁剪
↓
[7. 放置摄像机]
├─ lookAt / 欧拉角 计算 View 矩阵
├─ WASD + deltaTime 帧率无关移动
├─ 鼠标控制 视角旋转
└─ 滚轮缩放 改变 FOV
↓
[8. 渲染循环]
├─ processInput 处理输入
├─ glClear 清屏
├─ 设置 MVP uniform 传递矩阵给着色器
├─ glDrawArrays 提交绘制命令
└─ glfwSwapBuffers 双缓冲交换
↓
[屏幕显示]
├─ 顶点着色器 → 光栅化 → 片段着色器
├─ 深度测试 → 模板测试 → 混合
└─ 最终像素写入帧缓冲
逐层拆解
如果你能理解下面这段伪代码,你就掌握了入门篇的全部:
// === 初始化阶段 ===
GLFWwindow* window = glfwCreateWindow(800, 600, "My 3D World");
gladLoadGL(...);
glEnable(GL_DEPTH_TEST);
Shader shader("vertex.vs", "fragment.fs");
// VAO + VBO + EBO 设置顶点数据
// 纹理加载 + 绑定
// === 每帧循环 ===
while (!glfwWindowShouldClose(window))
{
// 1. 输入处理
processInput(window);
processMouseMovement();
processScroll();
// 2. 更新摄像机 (View 矩阵)
float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
camera.ProcessKeyboard(direction, deltaTime);
// 3. 计算 MVP 矩阵
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, position);
model = glm::rotate(model, angle, axis);
glm::mat4 view = camera.GetViewMatrix();
glm::mat4 projection = glm::perspective(
glm::radians(camera.Zoom),
(float)SCR_WIDTH / SCR_HEIGHT,
0.1f, 100.0f
);
// 4. 设置着色器 uniform 并绘制
shader.use();
shader.setMat4("model", model);
shader.setMat4("view", view);
shader.setMat4("projection", projection);
glBindVertexArray(VAO);
glBindTexture(GL_TEXTURE_2D, texture);
glDrawArrays(GL_TRIANGLES, 0, 36);
// 5. 交换缓冲
glfwSwapBuffers(window);
glfwPollEvents();
}实战:搭建 3D 场景浏览器
现在请你用学过的全部知识,搭建一个”3D 场景浏览器”——可以自由漫游一个放着纹理立方体的 3D 世界。
项目结构
scene_browser/
├── shaders/
│ ├── scene.vs # 顶点着色器
│ └── scene.fs # 片段着色器
├── textures/
│ └── container.jpg # 纹理贴图
├── camera.h # 复制自 includes/learnopengl/camera.h
└── main.cpp # 主程序
完整代码
以下是一个融合了前 8 课所有知识的 3D 场景浏览器核心代码:
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <learnopengl/shader_m.h>
#include <learnopengl/camera.h>
#include <stb_image.h>
#include <iostream>
// 窗口尺寸
const unsigned int SCR_WIDTH = 1280;
const unsigned int SCR_HEIGHT = 720;
// 摄像机
Camera camera(glm::vec3(0.0f, 2.0f, 5.0f));
float lastX = SCR_WIDTH / 2.0f;
float lastY = SCR_HEIGHT / 2.0f;
bool firstMouse = true;
// 时间
float deltaTime = 0.0f;
float lastFrame = 0.0f;
// 函数声明
void framebuffer_size_callback(GLFWwindow*, int, int);
void mouse_callback(GLFWwindow*, double, double);
void scroll_callback(GLFWwindow*, double, double);
void processInput(GLFWwindow*);
int main()
{
// ---- 初始化 GLFW ----
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT,
"3D Scene Browser", NULL, NULL);
glfwMakeContextCurrent(window);
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
glfwSetCursorPosCallback(window, mouse_callback);
glfwSetScrollCallback(window, scroll_callback);
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
gladLoadGLLoader((GLADloadproc)glfwGetProcAddress);
glEnable(GL_DEPTH_TEST);
// ---- 着色器 ----
Shader shader("shaders/scene.vs", "shaders/scene.fs");
// ---- 顶点数据(一个带纹理坐标的立方体) ----
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,
0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
-0.5f, 0.5f, -0.5f, 0.0f, 1.0f,
-0.5f, -0.5f, -0.5f, 0.0f, 0.0f,
// ...(完整顶点数据含 6 个面,共 36 个顶点)
// 此处省略以节省篇幅,实际与之前课程相同
};
unsigned int VBO, VAO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
// ---- 纹理 ----
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
int w, h, channels;
unsigned char* data = stbi_load("textures/container.jpg",
&w, &h, &channels, 0);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, w, h, 0, GL_RGB,
GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
stbi_image_free(data);
shader.use();
shader.setInt("texture1", 0);
// ---- 场景物体布局 ----
glm::vec3 cubePositions[] = {
glm::vec3(0.0f, 0.0f, 0.0f),
glm::vec3(2.0f, 1.0f, -3.0f),
glm::vec3(-2.0f, 0.5f, -2.0f),
glm::vec3(3.0f, -0.5f, -5.0f),
glm::vec3(-3.0f, 0.0f, -4.0f),
glm::vec3(0.0f, -1.0f, -6.0f),
glm::vec3(1.5f, 1.5f, -1.5f),
glm::vec3(-1.5f, -1.0f, -3.5f)
};
// ---- 渲染循环 ----
while (!glfwWindowShouldClose(window))
{
float currentFrame = static_cast<float>(glfwGetTime());
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
processInput(window);
glClearColor(0.1f, 0.1f, 0.15f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
shader.use();
// ---- MVP 矩阵 ----
glm::mat4 projection = glm::perspective(
glm::radians(camera.Zoom),
(float)SCR_WIDTH / (float)SCR_HEIGHT,
0.1f, 100.0f);
shader.setMat4("projection", projection);
glm::mat4 view = camera.GetViewMatrix();
shader.setMat4("view", view);
glBindVertexArray(VAO);
for (unsigned int i = 0; i < 8; i++)
{
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, cubePositions[i]);
float angle = 20.0f * i + (float)glfwGetTime() * 10.0f;
model = glm::rotate(model, glm::radians(angle),
glm::vec3(1.0f, 0.3f, 0.5f));
shader.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 36);
}
glfwSwapBuffers(window);
glfwPollEvents();
}
glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
glfwTerminate();
return 0;
}
void processInput(GLFWwindow* window)
{
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(window, true);
if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
camera.ProcessKeyboard(FORWARD, deltaTime);
if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
camera.ProcessKeyboard(BACKWARD, deltaTime);
if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
camera.ProcessKeyboard(LEFT, deltaTime);
if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
camera.ProcessKeyboard(RIGHT, deltaTime);
if (glfwGetKey(window, GLFW_KEY_Q) == GLFW_PRESS)
camera.Position.y -= 2.5f * deltaTime; // 下降
if (glfwGetKey(window, GLFW_KEY_E) == GLFW_PRESS)
camera.Position.y += 2.5f * deltaTime; // 上升
}
void mouse_callback(GLFWwindow* window, double xpos, double ypos)
{
if (firstMouse) { lastX = xpos; lastY = ypos; firstMouse = false; }
float xoffset = xpos - lastX;
float yoffset = lastY - ypos;
lastX = xpos;
lastY = ypos;
camera.ProcessMouseMovement(xoffset, yoffset);
}
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
camera.ProcessMouseScroll(static_cast<float>(yoffset));
}
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
glViewport(0, 0, width, height);
}INFO
代码亮点 上面的代码中有两处新设计:(1) Q/E 实现了上下飞行(沿世界 Y 轴),这在 3D 编辑器中是标准操作;(2) 立方体加入时间变量
glfwGetTime() * 10.0f持续旋转,展示了动态场景。
关键洞察:你已经实现了一个”小型引擎”
上面的 3D 场景浏览器本质上提供了:
- 场景渲染:多个物体、纹理贴图、深度测试
- 自由摄像机:WASD 飞行 + 鼠标环顾 + 滚轮缩放
- 实时交互:帧率无关的动态场景
这恰好是 Unity/Unreal/Godot 编辑器 Scene 视图中最基本的三个功能。你在编辑器中按 F 聚焦物体、用鼠标右键旋转视角、滚轮缩放——底层都是你刚手写的这些逻辑。
差距在哪里? 引擎比你的程序多了:场景图(Scene Graph)、批量渲染优化、阴影/光照、物理引擎、编辑器 UI、资源管理系统……但 渲染的核心管线完全一样。你学的是”引擎的心脏”,剩下的部分是围绕它搭建的”血肉”。
引擎连接总结表
以下是将 OpenGL 入门篇所有概念映射到主流引擎的完整对照表:
| OpenGL 概念 | Unity | Unreal Engine | Godot |
|---|---|---|---|
| 窗口创建 | Game View 窗口 | 视口 (Viewport) | 运行场景窗口 |
| OpenGL 上下文 | Graphics API 选择 (OpenGL Core/DirectX/Vulkan) | RHI 抽象层 | Rendering Driver |
| VAO / VBO | Mesh 资产 (.fbx 导入后的内部数据) | Static Mesh / Skeletal Mesh | ArrayMesh / Mesh 资源 |
| 顶点着色器 | Surface Shader → 编译为顶点着色器 | Material 编辑器 → USF 顶点着色器 | ShaderMaterial 顶点代码 |
| 片段着色器 | Surface Shader → 编译为片段着色器 | Material 编辑器 → USF 像素着色器 | ShaderMaterial 片段代码 |
| 纹理 | Texture2D 资源 | UTexture2D | Texture2D / ImageTexture |
| UV 坐标 | Mesh 的 uv 数组 | FStaticMeshVertexBuffer 的 UV | Mesh 的 uv 层 |
| Model 矩阵 | Transform (Position/Rotation/Scale) | FTransform | Transform3D |
| View 矩阵 | Camera.transform.worldToLocalMatrix | ViewMatrix 在 FViewInfo 中 | Camera3D 的 global_transform 逆矩阵 |
| Projection 矩阵 | Camera.projectionMatrix | FSceneViewProjectionData | Camera3D.projection |
| 深度测试 | Depth 缓冲(默认开启) | Depth Buffer | 深度缓冲 |
| MVP = Model × View × Projection | UNITY_MATRIX_MVP (内置宏) | ResolveViewProjectionMatrix | MODELVIEWPROJECTION_MATRIX |
| 摄像机组件 | Camera 组件 + Mouse Look 脚本 | PlayerCameraManager + SpringArm | Camera3D 节点 + 脚本控制 |
| 第一人称控制 | Character Controller + Mouse Look | CharacterMovementComponent | KinematicBody + Camera 子节点 |
| deltaTime | Time.deltaTime | DeltaTime / World→GetDeltaSeconds() | delta 参数 (process/delta) |
| 渲染循环 | Update() + OnRenderImage() | Tick() + DrawMainLoop() | _process() + _draw() |
常见陷阱与调试经验
整个入门阶段最容易出错的几个地方:
| 问题 | 症状 | 原因 | 解决方法 |
|---|---|---|---|
| 黑屏 | 窗口全黑或全灰 | 未启用深度测试 / 摄像机在物体内部 / 着色器编译失败 | glEnable(GL_DEPTH_TEST);检查摄像机位置;检查着色器编译日志 |
| 物体不见了 | 场景空白 | 物体被裁剪(超出 Near/Far 平面) | 增大 Far 平面距离或调整物体位置 |
| 纹理显示为纯色 | 物体是白/黑色的 | 纹理加载失败 / uniform 名称不匹配 / UV 坐标错误 | 检查纹理路径;检查 shader 中 sampler2D 的 uniform 名称 |
| 摄像机抖动 | 视角不流畅 | deltaTime 计算有误 / 帧率波动大 | 确保 deltaTime 在每帧开头计算;考虑使用固定时间步长 |
| WASD 方向不对 | 按 W 往奇怪方向移动 | 方向向量计算错误 / 俯仰超出 ±89° | 检查 Front 向量的计算;添加俯仰限制 |
| 编译报错 | 一大堆红色错误 | 忘记链接着色器 / 未初始化 GLAD | 检查 Shader 构造是否成功;确保 gladLoadGL 在 glfwMakeContextCurrent 之后 |
第 1 阶段能力自测
检查你是否掌握了每个知识点:
- 窗口:能解释 GLFW 初始化流程、上下文创建、双缓冲交换吗?
- 三角形:理解 VAO、VBO、EBO 的关系,能手动设置顶点属性指针吗?
- 着色器:能编写一个简单的顶点/片段着色器,理解 uniform、attribute、varying 的区别吗?
- 纹理:知道纹理坐标、纹理单元、采样、环绕/过滤模式的原理吗?
- 变换:能解释平移/旋转/缩放的矩阵表示,理解矩阵乘法的顺序吗?
- 坐标系统:理解局部→世界→观察→裁剪→NDC→屏幕空间的完整变换链吗?
- 摄像机:能从零实现 lookAt 矩阵、欧拉角控制、deltaTime 帧率无关吗?
如果上述 7 点你都理解并能手写代码,恭喜你——你已经具备了独立开发 3D 图形程序的基础。
INFO
给自己的奖励 打开 Unity,创建一个 3D 项目,放一个 Cube,用 Mouse Look 脚本控制摄像机环绕它。想一想:你看到的不再是”黑魔法”,而是一个 MVP 矩阵变换链的直观展示。这就是学习图形学的回报。
预告:第 2 阶段 — 光照
第 1 阶段你学会了”把东西画出来”。但它看起来像塑料——没有立体感、没有光泽、没有阴影。因为我们没有光照。
第 2 阶段你将学到:
- 冯氏光照模型 (Phong Lighting Model):环境光 (Ambient) + 漫反射 (Diffuse) + 高光 (Specular)
- 法向量 (Normal Vector):决定光线如何从表面反弹
- 材质 (Material):定义物体如何与光交互
- 光照贴图 (Lighting Maps):让物体不同区域有不同的光照响应
- 投光物 (Light Casters):方向光、点光源、聚光灯
- 多光源 (Multiple Lights):组合多种光源渲染
效果预览 学完第 2 阶段后,你的场景会从这样: → 一个扁平的纹理立方体 变成这样: → 一个有金属光泽、被多盏灯光照亮、投射阴影的立体物体 这就是真实感渲染的第一步。