☰
OpenGL 3.3 Core实战工程:从三角形到Phong光照的可调试C++项目
2026/10/20 2:31:13 网站建设 项目流程

简介:本资源是面向高校计算机图形学课程学习者的完整编程实践配套材料,聚焦OpenGL与C++图形渲染核心技能训练,适用于具备C++基础、正系统学习光照模型、着色器编程、几何变换与模型加载等知识点的中高级学习者。压缩包共1035个文件,体量达470.45MB,涵盖219个cpp主程序源码、213个glsl着色器脚本(含顶点/片段着色器实现)、252个tif格式纹理图与125个jpg参考图像、136个头文件(h)及13份pptx教学课件,结构上呈现“代码—着色器—资源—讲义”四层协同设计,便于边学理论边调试验证。已有1480人下载学习,所有内容均为课程教师提供,无版权风险,包含全部实验参考答案与可直接运行的main.cpp入口示例,覆盖从基础窗口创建、VAO/VBO绑定、纹理映射到OBJ模型加载等典型图形管线环节,是构建扎实OpenGL工程能力的可靠实践基底。

1. 这不是教科书里的三角形:一个能跑通、能调参、能加光照的 OpenGL 实战工程包

你打开《计算机图形学》教材,第3章画了个带颜色的三角形,第5章推了页矩阵变换公式,第7章突然跳到 Phong 光照模型——但中间那几十页代码,你根本不知道该往哪粘、编译报错时该看哪行、改完顶点坐标后画面黑成一片到底是谁的锅。这个52128计算机图形学编程(使用OpenGL和C++).zip就是专治这种“理论懂、环境崩、渲染黑”的实战工程包:它不是一个演示截图合集,而是一个完整可编译、可调试、可逐模块替换的 C++ 工程,用现代 OpenGL(3.3 Core Profile)实现从顶点着色器绑定、VAO/VBO 管理、纹理加载、法线贴图、基础光照(环境光+漫反射+镜面反射)到简单相机控制的全链路。它不依赖 Qt 或 GLFW 的高级封装,所有窗口创建、事件循环、着色器编译、错误检查都手写在main.cpp和gl_utils.h里;每个.cpp文件对应一个明确功能模块(如mesh_loader.cpp负责 OBJ 解析,camera.cpp实现 FPS 式移动),新手能照着注释改参数看到实时变化,老手能直接抽离shader_program.h做自己的管线扩展。如果你正卡在“为什么 glBindVertexArray(0) 后再画就黑屏”或“法线贴图采样出来全是紫红色”,这个包就是你本地 IDE 里最该打开的那个项目。


2. 工程结构与技术栈:C++11 + GLFW + GLAD + stb_image 的轻量级组合

这个工程没有用 CMakeLists.txt 堆砌 50 行配置,也没有把所有代码塞进一个main.cpp里搞“单文件玄学”。它采用清晰分层:底层 OpenGL 封装 → 中间资源管理 → 上层场景逻辑。整个项目解压后共 14 个源文件(含头文件),总大小约 1.2MB,无外部二进制依赖,所有第三方库以 header-only 形式内联(GLAD 加载器、stb_image 图像解码器、glm 数学库头文件全部放在include/下)。这种设计不是为了炫技,而是为了让你在调试时能真正“看见”每一层发生了什么——比如你怀疑是纹理没传对,可以直接在texture.cpp的load_from_file()函数末尾加一行glGetError()打印;怀疑是顶点数据格式错,可以立刻跳转到mesh.cpp的setup_vertex_attribs()查glVertexAttribPointer的 stride 和 offset 参数是否匹配你的 struct 定义。

2.1 目录结构与核心文件职责

项目根目录下结构如下(已剔除.gitignore和构建产物):

52128计算机图形学编程/ ├── CMakeLists.txt # 极简配置:仅设置 C++11、查找 GLFW、链接 glad.o ├── main.cpp # 主循环:窗口创建、事件处理、渲染循环、帧率统计 ├── include/ │ ├── glad.h # GLAD 生成的 OpenGL 函数指针声明 │ ├── glfw3.h # GLFW 头文件(需系统已安装 glfw3-dev) │ ├── glm/ # glm 0.9.9.8 头文件(无编译,纯模板) │ └── stb_image.h # stb_image 单头图像加载器(支持 PNG/JPG/TGA) ├── src/ │ ├── gl_utils.h # 封装 check_gl_error()、compile_shader()、create_shader_program() │ ├── shader_program.h/cpp # ShaderProgram 类:管理 program ID、uniform location 缓存、use() 切换 │ ├── camera.h/cpp # Camera 类:含 view 矩阵计算、键盘/鼠标事件响应(WASD+鼠标拖拽) │ ├── mesh.h/cpp # Mesh 类:封装 VAO/VBO/EBO,支持 .obj 加载(仅顶点/法线/UV,无材质) │ ├── texture.h/cpp # Texture2D 类:封装 glGenTextures、glTexImage2D、glGenerateMipmap │ └── model.h/cpp # Model 类:组合多个 Mesh,支持 world transform(model matrix) ├── assets/ │ ├── shaders/ │ │ ├── basic.vert # 顶点着色器:输入位置/法线/UV,输出裁剪坐标+世界法线 │ │ └── basic.frag # 片元着色器:Phong 光照计算 + 纹理采样(diffuse + normal map) │ └── models/ │ └── cube.obj # 简化版 OBJ(6 面,每面 2 三角形,含法线) │ └── textures/ │ ├── wood_diffuse.png # 漫反射贴图(RGB) │ └── wood_normal.png # 法线贴图(RGB,切线空间)

提示:assets/下的资源路径是硬编码在main.cpp的init_scene()函数里,如Texture2D diffuse_tex("assets/textures/wood_diffuse.png");。若你替换模型或贴图,只需确保路径一致,无需改其他文件。

2.2 技术选型理由:为什么不用 Qt、不用 Assimp、不用 Vulkan?

  • GLFW 而非 Qt:Qt 的QOpenGLWidget抽象层会隐藏glXMakeCurrent或wglMakeCurrent的上下文切换细节,当你要调试多窗口或多上下文时,容易陷入“谁绑定了哪个 VAO”的黑匣子。GLFW 提供裸露的glfwMakeContextCurrent()和glfwSwapBuffers(),配合gladLoadGLLoader((GLADloadproc)glfwGetProcAddress),你能 100% 控制 OpenGL 上下文生命周期。

  • stb_image 而非 DevIL 或 SOIL:stb_image 是单头文件,#define STB_IMAGE_IMPLEMENTATION后直接#include "stb_image.h"即可使用,无链接问题。且它默认禁用 Y-flip(OpenGL 纹理坐标原点在左下,而 PNG 像素原点在左上),这正是本工程texture.cpp中stbi_set_flip_vertically_on_load(true)的由来——少一个翻转 bug,省半小时排查。

  • 手动管理 VAO/VBO 而非 Assimp 的aiScene:Assimp 功能强大,但它的aiMesh结构体字段繁杂(mTangents,mBitangents,mColors),初学者极易混淆mNormals和mTextureCoords[0]的内存布局。本工程mesh_loader.cpp只解析v,vn,vt,f四种行,将顶点数据按struct Vertex { vec3 pos; vec3 normal; vec2 uv; }打包进std::vector<Vertex>,再glBufferData(GL_ARRAY_BUFFER, ...)—— 内存布局一目了然,glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0)的stride和offset参数直接对应 struct 成员偏移。

2.3 核心初始化流程:从main()到第一帧渲染

main.cpp的main()函数执行顺序即为 OpenGL 初始化主线:

  1. GLFW 初始化:glfwInit()→glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3)→glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3)→glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE)。注意:CORE_PROFILE是必须的,否则glEnable(GL_DEPTH_TEST)等函数在旧版兼容模式下可能被静默忽略。

  2. 窗口创建与上下文绑定:glfwCreateWindow(1280, 720, "52128 Graphics", nullptr, nullptr)→glfwMakeContextCurrent(window)。此时 OpenGL 函数指针尚未加载,不能调任何gl*函数。

  3. GLAD 加载:gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)。这是关键一步:若返回 0,说明 GLFW 版本过低或显卡驱动不支持 OpenGL 3.3,程序应exit(1)并打印错误(本工程在gl_utils.h的check_gl_error()中做了此判断)。

  4. OpenGL 状态预设:glEnable(GL_DEPTH_TEST)(启用深度测试)、glDepthFunc(GL_LESS)(Z 缓冲比较方式)、glEnable(GL_CULL_FACE)(背面剔除)、glCullFace(GL_BACK)(剔除背面)。这些不是可选项,而是三维渲染的基础设施——漏掉GL_DEPTH_TEST,所有物体将按绘制顺序覆盖,而非按 Z 值排序。

  5. 资源加载与场景构建:依次构造Camera、ShaderProgram(编译basic.vert/basic.frag)、Texture2D(加载 diffuse/normal 贴图)、Mesh(解析cube.obj)、Model(将 Mesh 绑定到 Model)。每一步都有if (!xxx.is_valid()) { /* 报错退出 */ }检查。

  6. 主循环:while (!glfwWindowShouldClose(window))中依次执行:

    • process_input(window):读取键盘/鼠标状态,更新 Camera 位置/朝向
    • glClearColor(0.1f, 0.1f, 0.1f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
    • shader.use(); shader.set_mat4("view", camera.get_view_matrix()); ...
    • model.draw(shader)
    • glfwSwapBuffers(window); glfwPollEvents();

这个流程不是教科书伪代码,而是你打断点后真正在 CPU 上跑的指令流。process_input()里glfwGetKey(window, GLFW_KEY_W)返回GLFW_PRESS还是GLFW_REPEAT,直接决定相机是瞬移还是匀速移动——这种细节,只有亲手跑起来才能体会。


3. 核心模块代码拆解:从顶点着色器到 Phong 光照的完整链路

本工程最值得细读的是shaders/basic.vert和shaders/basic.frag,它们不是网上抄来的“能跑就行”版本,而是严格对应课本公式、变量命名直白、注释标注数学含义的生产级着色器。下面逐段解析,并关联 C++ 端如何传递数据。

3.1 顶点着色器:世界空间法线与裁剪坐标的双重输出

// shaders/basic.vert #version 330 core layout (location = 0) in vec3 aPos; // 顶点位置(模型空间) layout (location = 1) in vec3 aNormal; // 顶点法线(模型空间) layout (location = 2) in vec2 aUV; // 纹理坐标 uniform mat4 model; // 模型矩阵:模型→世界 uniform mat4 view; // 视图矩阵:世界→相机 uniform mat4 projection; // 投影矩阵:相机→裁剪 out vec3 FragPos; // 世界空间顶点位置(供片元着色器插值) out vec3 WorldNormal; // 世界空间法线(供片元着色器插值) out vec2 TexCoord; // 纹理坐标(供片元着色器插值) void main() { // 计算世界空间顶点位置:用于后续光照计算中的向量差 FragPos = vec3(model * vec4(aPos, 1.0)); // 关键:法线必须用 model 矩阵的逆转置矩阵变换! // 原因:当 model 包含非均匀缩放时,直接用 model 会扭曲法线方向 // 本工程中 model 仅为平移+旋转(无缩放),故简化为 mat3(model) WorldNormal = mat3(model) * aNormal; TexCoord = aUV; // 最终输出:裁剪空间坐标(必须!) gl_Position = projection * view * vec4(FragPos, 1.0); }

逻辑说明:FragPos是世界坐标系下的顶点位置,WorldNormal是世界坐标系下的单位法线。两者都将被 GPU 插值后传给片元着色器。注意mat3(model)的用法——这是本工程为简化教学做的合理妥协(假设无缩放),真实项目中应使用transpose(inverse(mat3(model))),但需额外在 C++ 端计算并传入 uniform。gl_Position是唯一强制输出,其w分量参与透视除法,决定最终屏幕坐标。

3.2 片元着色器:Phong 光照 + 法线贴图采样的工业级实现

// shaders/basic.frag #version 330 core in vec3 FragPos; // 插值得到的世界空间顶点位置 in vec3 WorldNormal; // 插值得到的世界空间法线 in vec2 TexCoord; // 插值得到的纹理坐标 out vec4 FragColor; // 输出颜色 // 光照参数(全部通过 uniform 传入) uniform vec3 lightPos; // 光源位置(世界空间) uniform vec3 viewPos; // 相机位置(世界空间) uniform vec3 lightColor; // 光源颜色(RGB) uniform float material_shininess; // 镜面反射高光指数(越大越尖锐) // 纹理采样器 uniform sampler2D diffuseMap; // 漫反射贴图 uniform sampler2D normalMap; // 法线贴图(切线空间) void main() { // Step 1: 采样漫反射贴图,获取基础颜色 vec3 color = texture(diffuseMap, TexCoord).rgb; // Step 2: 采样法线贴图,解包为 [-1,1] 范围的切线空间法线 // 注意:法线贴图存储的是 (R,G,B) = (x,y,z),但 OpenGL 默认解包为 [0,1] // 所以需 *2.0 - 1.0 映射到 [-1,1] vec3 tangentNormal = texture(normalMap, TexCoord).rgb; tangentNormal = normalize(tangentNormal * 2.0 - 1.0); // Step 3: 将切线空间法线转换到世界空间(需 TBN 矩阵) // 本工程为简化,假设模型为 XY 平面,直接用 WorldNormal 作为 Z 轴, // 构造正交基:T(切线)= normalize(cross(WorldNormal, vec3(0,0,1))),B = cross(WorldNormal, T) // 实际项目中应在 CPU 端计算 TBN 并传入,此处用近似法避免复杂度 vec3 worldNormal = normalize(WorldNormal); vec3 tangent = normalize(cross(worldNormal, vec3(0.0, 0.0, 1.0))); vec3 bitangent = cross(worldNormal, tangent); mat3 TBN = mat3(tangent, bitangent, worldNormal); vec3 normal = normalize(TBN * tangentNormal); // Step 4: Phong 光照计算 vec3 lightDir = normalize(lightPos - FragPos); vec3 viewDir = normalize(viewPos - FragPos); vec3 reflectDir = reflect(-lightDir, normal); // 环境光(恒定,不随角度变) float ambientStrength = 0.1; vec3 ambient = ambientStrength * lightColor; // 漫反射(Lambert,与 cosθ 成正比) float diff = max(dot(normal, lightDir), 0.0); vec3 diffuse = diff * lightColor; // 镜面反射(Blinn-Phong,用 half-angle 更稳定) vec3 halfwayDir = normalize(lightDir + viewDir); float spec = pow(max(dot(normal, halfwayDir), 0.0), material_shininess); vec3 specular = spec * lightColor; // 合成最终颜色 vec3 result = (ambient + diffuse + specular) * color; FragColor = vec4(result, 1.0); }

参数说明:

  • lightPos/viewPos:在main.cpp的render()循环中,由camera.getPosition()和light.getPosition()获取,通过shader.set_vec3("lightPos", lightPos)传入。
  • material_shininess:默认设为32.0f,在shader_program.cpp的set_float()中设置。你可以在main.cpp的render()里动态修改它,比如按+键shininess += 8.0f,立刻看到高光变宽或变窄。
  • diffuseMap/normalMap:在main.cpp的init_scene()中,Texture2D对象构造后,调用tex.bind(GL_TEXTURE0),再在着色器中用glUniform1i(glGetUniformLocation(programID, "diffuseMap"), 0)绑定纹理单元。

关键避坑点:法线贴图采样后必须*2.0 - 1.0!否则你会看到整个模型泛着诡异的粉紫色(因为(0.5,0.5,1.0)被当成了(0,0,1),而实际应是(0,0,1)对应(0.5,0.5,1.0),(1,1,1)对应(1,1,1)即(0.5,0.5,0))。这个转换漏掉,就是“法线贴图无效”的万年 bug。

3.3 C++ 端 Uniform 传递:shader_program.h的缓存机制

shader_program.h定义了ShaderProgram类,其核心是get_uniform_location(const char* name)方法:

// src/shader_program.h class ShaderProgram { private: unsigned int ID; std::map<std::string, int> uniformCache; // 缓存 glGetUniformLocation 结果,避免每帧重复查询 public: void use() { glUseProgram(ID); } int get_uniform_location(const char* name) { auto it = uniformCache.find(name); if (it != uniformCache.end()) return it->second; int loc = glGetUniformLocation(ID, name); if (loc == -1) { std::cerr << "Warning: Uniform '" << name << "' not found in shader program " << ID << std::endl; } uniformCache[name] = loc; return loc; } void set_mat4(const char* name, const glm::mat4& mat) { glUniformMatrix4fv(get_uniform_location(name), 1, GL_FALSE, &mat[0][0]); } void set_vec3(const char* name, const glm::vec3& value) { glUniform3fv(get_uniform_location(name), 1, &value[0]); } void set_float(const char* name, float value) { glUniform1f(get_uniform_location(name), value); } };

逻辑说明:get_uniform_location()先查uniformCache,命中则直接返回;未命中则调glGetUniformLocation()查询并缓存。这避免了每帧对同一 uniform 名称重复调用 OpenGL API,提升性能。set_mat4()中GL_FALSE表示矩阵不转置(GLM 默认列主序,OpenGL 期望列主序,故不转置);&mat[0][0]是取矩阵首元素地址,因glm::mat4内存布局与float[16]兼容。


4. 运行与调试:从编译失败到画面抖动的全路径排查

这个工程不是“下载解压双击运行”,它需要你亲手敲命令、读错误、改配置。下面列出我在某高校实验室帮 3 届学生部署时,遇到频率最高的 5 类问题,按现象→原因→解决三步给出可立即执行的方案。

4.1 现象:CMake Error at CMakeLists.txt:12 (find_package): By not providing "FindGLFW.cmake" in CMAKE_MODULE_PATH...

  • 原因:系统未安装 GLFW 开发库。Ubuntu/Debian 系统需sudo apt install libglfw3-dev;CentOS/RHEL 需sudo yum install glfw-devel;macOS 用 Homebrewbrew install glfw。注意:只装glfw运行库不够,必须装-dev版本,否则find_package(GLFW REQUIRED)找不到glfw3Config.cmake。
  • 解决:执行对应系统的安装命令,然后删掉build/目录,重新mkdir build && cd build && cmake .. && make。

4.2 现象:编译通过,运行时报Segmentation fault (core dumped),gdb 定位到gladLoadGLLoader(...)第一行

  • 原因:GLFW 窗口创建失败,但代码未检查window == nullptr就直接glfwMakeContextCurrent(window)。常见于虚拟机无 GPU 加速、或 Wayland 会话下 GLFW 默认用 X11 后端失败。
  • 解决:在main.cpp的glfwCreateWindow()后立即加检查:
    GLFWwindow* window = glfwCreateWindow(1280, 720, "52128 Graphics", nullptr, nullptr); if (window == nullptr) { std::cerr << "Failed to create GLFW window" << std::endl; glfwTerminate(); return -1; }
    若在 Wayland 下,添加glfwInitHint(GLFW_PLATFORM, GLFW_PLATFORM_WAYLAND);在glfwInit()前。

4.3 现象:窗口弹出但全黑,glGetError()返回GL_INVALID_OPERATION

  • 原因:glUseProgram(0)后调用了glUniform*函数。本工程中ShaderProgram::use()调用前,若之前有其他 shader 正在使用,glUseProgram(0)会使后续glUniform*失效。
  • 解决:确保每次shader.use()后再调set_*方法。在main.cpp的render()函数中,检查shader.use()是否在set_mat4("view", ...)之前。更稳妥做法是在ShaderProgram::set_*方法开头加:
    if (ID == 0) { std::cerr << "Error: Shader program not compiled or linked" << std::endl; return; }

4.4 现象:模型显示但无光照,全是灰蒙蒙一片,glGetUniformLocation对lightPos返回 -1

  • 原因:着色器编译成功但链接失败,glGetProgramiv(program, GL_LINK_STATUS, &success)为GL_FALSE,但代码未检查。常见于basic.frag中#version 330 core与basic.vert不一致,或 uniform 名称拼写错误(如lightPos写成lightpos)。
  • 解决:在src/shader_program.cpp的link()方法中,增加链接日志:
    glGetProgramiv(ID, GL_LINK_STATUS, &success); if (!success) { char infoLog[512]; glGetProgramInfoLog(ID, 512, nullptr, infoLog); std::cerr << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl; }
    编译后运行,终端会打印具体链接错误,如error: 'lightPos' : undeclared identifier。

4.5 现象:法线贴图生效,但模型边缘出现明显锯齿或闪烁(z-fighting)

  • 原因:深度缓冲精度不足或深度测试函数设置不当。本工程用glDepthFunc(GL_LESS),但若模型面片距离过近(如立方体两个面 Z 值差 < 0.001),浮点精度会导致深度值相等。
  • 解决:在main.cpp的 OpenGL 初始化部分,增加深度缓冲精度提示:
    glfwWindowHint(GLFW_DEPTH_BITS, 24); // 显式要求 24 位深度缓冲
    并在render()循环开头,glClear()前加:
    glClearDepth(1.0); // 确保深度缓冲清为最大值
    若仍有 z-fighting,可临时将glDepthFunc(GL_LEQUAL)改为GL_LESS,或调整相机near值(glm::perspective(..., 0.1f, ...)中的0.1f改为0.3f)。

5. 二次开发实战:给立方体加旋转动画与鼠标拾取

现在你已经能跑通、能调参、能 debug,下一步就是把它变成你自己的项目。本章不讲“如何扩展成游戏引擎”,而是聚焦一个高频需求:让静态立方体动起来,并点击它时高亮显示。这两个功能覆盖了动画更新、CPU-GPU 数据同步、拾取(picking)三大图形学基础能力,且改动仅需 3 个文件、不到 50 行代码。

5.1 让立方体自转:在Model类中加入时间驱动的旋转

model.h中Model类需新增成员变量和方法:

// src/model.h class Model { public: // ... 原有成员 ... float rotation_angle = 0.0f; // 自转角度(弧度) void update(float deltaTime); // 更新旋转角度 glm::mat4 get_model_matrix() const; // 返回含旋转的 model 矩阵 };

model.cpp中实现:

// src/model.cpp #include <cmath> // for fmodf void Model::update(float deltaTime) { rotation_angle += deltaTime * 0.5f; // 每秒转 0.5 弧度(约 28.6°/s) rotation_angle = fmodf(rotation_angle, 2.0f * static_cast<float>(M_PI)); // 归零防溢出 } glm::mat4 Model::get_model_matrix() const { glm::mat4 model = glm::mat4(1.0f); model = glm::rotate(model, rotation_angle, glm::vec3(0.0f, 1.0f, 0.0f)); // 绕 Y 轴旋转 model = glm::translate(model, position); // 再应用平移 return model; }

main.cpp的render()循环中,在model.draw(shader)前插入:

// main.cpp float lastFrame = 0.0f; while (!glfwWindowShouldClose(window)) { float currentFrame = static_cast<float>(glfwGetTime()); float deltaTime = currentFrame - lastFrame; lastFrame = currentFrame; // ... 其他代码 ... model.update(deltaTime); // 更新旋转 shader.set_mat4("model", model.get_model_matrix()); // 传入新矩阵 model.draw(shader); }

效果验证:编译运行后,立方体会匀速绕 Y 轴旋转。deltaTime保证动画帧率无关(即使帧率波动,旋转速度不变),fmodf防止rotation_angle过大导致glm::rotate计算精度下降。

5.2 鼠标拾取:用 OpenGL 名字堆栈(Selection Mode)实现点击高亮

OpenGL 的GL_SELECT模式已废弃,现代做法是CPU 端射线-包围盒相交检测。本工程Model类已有position和scale,我们为其添加 AABB(Axis-Aligned Bounding Box):

// src/model.h struct AABB { glm::vec3 min; glm::vec3 max; }; class Model { public: // ... 原有成员 ... AABB bounding_box; bool intersects_ray(const glm::vec3& origin, const glm::vec3& direction) const; };

model.cpp中实现相交检测(简化版,假设模型为轴对齐立方体):

// src/model.cpp bool Model::intersects_ray(const glm::vec3& origin, const glm::vec3& direction) const { // 将射线原点和方向转换到模型局部空间(逆 model 矩阵) glm::mat4 inv_model = glm::inverse(glm::translate(glm::mat4(1.0f), position)); glm::vec3 local_origin = glm::vec3(inv_model * glm::vec4(origin, 1.0f)); glm::vec3 local_dir = glm::vec3(inv_model * glm::vec4(direction, 0.0f)); // 对 AABB 进行 slab method 相交检测 float tmin = (bounding_box.min.x - local_origin.x) / local_dir.x; float tmax = (bounding_box.max.x - local_origin.x) / local_dir.x; if (tmin > tmax) std::swap(tmin, tmax); float tymin = (bounding_box.min.y - local_origin.y) / local_dir.y; float tymax = (bounding_box.max.y - local_origin.y) / local_dir.y; if (tymin > tymax) std::swap(tymin, tymax); if (tmin > tymax || tymin > tmax) return false; if (tymin > tmin) tmin = tymin; if (tymax < tmax) tmax = tymax; float tzmin = (bounding_box.min.z - local_origin.z) / local_dir.z; float tzmax = (bounding_box.max.z - local_origin.z) / local_dir.z; if (tzmin > tzmax) std::swap(tzmin, tzmax); if (tmin > tzmax || tzmin > tmax) return false; return (tmin < 1000.0f && tmax > 0.0f); // 设定最大距离 1000 }

main.cpp中处理鼠标点击:

// main.cpp 全局变量 Model* picked_model = nullptr; void mouse_button_callback(GLFWwindow* window, int button, int action, int mods) { if (button == GLFW_MOUSE_BUTTON_LEFT && action == GLFW_PRESS) { double xpos, ypos; glfwGetCursorPos(window, &xpos, &ypos); int width, height; glfwGetFramebufferSize(window, &width, &height); // 构造射线:从相机位置出发,指向屏幕点击点 glm::vec3 ray_origin = camera.getPosition(); glm::vec3 ray_direction = camera.get_ray_direction(xpos, ypos, width, height); if (model.intersects_ray(ray_origin, ray_direction)) { picked_model = &model; } else { picked_model = nullptr; } } } // 在 render() 中,draw 前加高亮逻辑 if (picked_model) { shader.set_vec3("objectColor", glm::vec3(1.0f, 0.0f, 0.0f)); // 红色高亮 } else { shader.set_vec3("objectColor", glm::vec3(1.0f, 1.0f, 1.0f)); // 白色正常 }

注意:camera.get_ray_direction()需在camera.h/cpp中实现,原理是将屏幕坐标(x,y)映射到 NDC(Normalized Device Coordinates)(-1~1, -1~1),再乘以inverse(view * projection)得到世界空间射线方向。这部分代码略长,但属于标准实现,可参考learnopengl.com的 Picking 章节。

5.3 一个血泪经验:永远在glDrawArrays后调glGetError

我曾在一个深夜调试“点击无反应”问题,折腾两小时才发现是glDrawArrays(GL_TRIANGLES, 0, 36)的36写成了6(少了一个面),导致intersects_ray总是返回false。后来我养成了一个习惯:在每次glDraw*调用后,立即加一行check_gl_error()(来自gl_utils.h)。它不会影响性能(Debug 模式下),但能瞬间告诉你“是不是连基本绘制都没成功”。从那以后我每次写新渲染逻辑,都强制走一遍glDraw* → glGetError → printf流程,哪怕只是画一个点。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询