入门篇实战 — 从这里,你拥有了自己的 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 概念UnityUnreal EngineGodot
窗口创建Game View 窗口视口 (Viewport)运行场景窗口
OpenGL 上下文Graphics API 选择 (OpenGL Core/DirectX/Vulkan)RHI 抽象层Rendering Driver
VAO / VBOMesh 资产 (.fbx 导入后的内部数据)Static Mesh / Skeletal MeshArrayMesh / Mesh 资源
顶点着色器Surface Shader → 编译为顶点着色器Material 编辑器 → USF 顶点着色器ShaderMaterial 顶点代码
片段着色器Surface Shader → 编译为片段着色器Material 编辑器 → USF 像素着色器ShaderMaterial 片段代码
纹理Texture2D 资源UTexture2DTexture2D / ImageTexture
UV 坐标Mesh 的 uv 数组FStaticMeshVertexBuffer 的 UVMesh 的 uv 层
Model 矩阵Transform (Position/Rotation/Scale)FTransformTransform3D
View 矩阵Camera.transform.worldToLocalMatrixViewMatrix 在 FViewInfo 中Camera3D 的 global_transform 逆矩阵
Projection 矩阵Camera.projectionMatrixFSceneViewProjectionDataCamera3D.projection
深度测试Depth 缓冲(默认开启)Depth Buffer深度缓冲
MVP = Model × View × ProjectionUNITY_MATRIX_MVP (内置宏)ResolveViewProjectionMatrixMODELVIEWPROJECTION_MATRIX
摄像机组件Camera 组件 + Mouse Look 脚本PlayerCameraManager + SpringArmCamera3D 节点 + 脚本控制
第一人称控制Character Controller + Mouse LookCharacterMovementComponentKinematicBody + Camera 子节点
deltaTimeTime.deltaTimeDeltaTime / WorldGetDeltaSeconds()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 阶段能力自测

检查你是否掌握了每个知识点:

  1. 窗口:能解释 GLFW 初始化流程、上下文创建、双缓冲交换吗?
  2. 三角形:理解 VAO、VBO、EBO 的关系,能手动设置顶点属性指针吗?
  3. 着色器:能编写一个简单的顶点/片段着色器,理解 uniform、attribute、varying 的区别吗?
  4. 纹理:知道纹理坐标、纹理单元、采样、环绕/过滤模式的原理吗?
  5. 变换:能解释平移/旋转/缩放的矩阵表示,理解矩阵乘法的顺序吗?
  6. 坐标系统:理解局部→世界→观察→裁剪→NDC→屏幕空间的完整变换链吗?
  7. 摄像机:能从零实现 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 阶段后,你的场景会从这样: → 一个扁平的纹理立方体 变成这样: → 一个有金属光泽、被多盏灯光照亮、投射阴影的立体物体 这就是真实感渲染的第一步。