前言
接续上一篇的qt项目,增加光源效果的功能。本篇增加了以下知识点:
- 显示两个物体,两个VBO
- 数组型GLfloat和容器型GLfloat
- 增加法线
- 增加光照的三个分量:ambient(环境光)+ diffuse(漫反射)+ specular(镜面高光)
- 两个物体,不同的顶点着色器、片段着色器、MVP
- keyPressEvent增加光源左右移动事件
效果图



黄色圆球块是光源位置,可以看到光源在哪个位置,正中心的物体朝向光源的位面是亮的。
新增知识点
1.两个VBO
为了让光源可视化,增加一个圆球显示在光源的位置(它不是光源的实例,只是显示一个物体并移动到光源的位置而已),因为形状坐标不一样,无法共用一个VBO,那就创建两个VBO。
QOpenGLBuffer m_vbo{QOpenGLBuffer::VertexBuffer}; // 立方体:位置 + 法线
QOpenGLBuffer m_lampVbo{QOpenGLBuffer::VertexBuffer}; // ★ 光源球:只有位置,独立一份 VBO
2.数组型GLfloat和容器型GLfloat
写代码时就能数清有几个顶点 → 用数组;顶点靠循环算法算出来、数量随时会变 → 用 vector(循环生成的如果顶点足够多也可以用数组)。
OpenGL 最终要的,就是一大坨连续排在一起的浮点数(顶点坐标)。无论是数组还是 vector,最后喂给 GPU 的内容完全一样。
GLfloat vertices[](数组) |
std::vector<GLfloat>(vector) |
|
|---|---|---|
| 大小 | 写死,编译时定好,不能变 | 可以随时增长、缩小 |
| 怎么填内容 | 一次性 {...} 写全,或手动下标 |
push_back 一个个加 |
| 知道有几个元素 | 用 sizeof 间接算 |
直接 .size() |
| 内存 | 在栈上,容量大了有溢出风险 | 在堆上,自己管理 |
| 适合 | 顶点固定、数量已知(立方体、三角形) | 顶点靠循环生成、数量运行时才定(球、圆、模型) |
2.1六面物体的GLfloat
// ============================================================
// 1. 立方体顶点数据:36 个顶点 = 6 面 × 2 三角形 × 3 顶点
// 法线 = 垂直于该面的单位向量,告诉着色器"这个面朝哪"
// 同一面上的 6 个顶点法线相同(平面),curved 模型则逐顶点不同
// ============================================================
GLfloat vertices[] = {
// ---- 位置 x,y,z ---- ---- 法线 nx,ny,nz ----
// 前面 (z = +0.5),法线朝 +Z
-0.5f,-0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
0.5f,-0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
-0.5f,-0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
-0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
// 后面 (z = -0.5),法线朝 -Z
-0.5f,-0.5f,-0.5f, 0.0f, 0.0f,-1.0f,
0.5f,-0.5f,-0.5f, 0.0f, 0.0f,-1.0f,
0.5f, 0.5f,-0.5f, 0.0f, 0.0f,-1.0f,
-0.5f,-0.5f,-0.5f, 0.0f, 0.0f,-1.0f,
0.5f, 0.5f,-0.5f, 0.0f, 0.0f,-1.0f,
-0.5f, 0.5f,-0.5f, 0.0f, 0.0f,-1.0f,
// 上面 (y = +0.5),法线朝 +Y
-0.5f, 0.5f,-0.5f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f,-0.5f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f,
-0.5f, 0.5f,-0.5f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f,
-0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f,
// 下面 (y = -0.5),法线朝 -Y
-0.5f,-0.5f,-0.5f, 0.0f,-1.0f, 0.0f,
0.5f,-0.5f,-0.5f, 0.0f,-1.0f, 0.0f,
0.5f,-0.5f, 0.5f, 0.0f,-1.0f, 0.0f,
-0.5f,-0.5f,-0.5f, 0.0f,-1.0f, 0.0f,
0.5f,-0.5f, 0.5f, 0.0f,-1.0f, 0.0f,
-0.5f,-0.5f, 0.5f, 0.0f,-1.0f, 0.0f,
// 右面 (x = +0.5),法线朝 +X
0.5f,-0.5f, 0.5f, 1.0f, 0.0f, 0.0f,
0.5f,-0.5f,-0.5f, 1.0f, 0.0f, 0.0f,
0.5f, 0.5f,-0.5f, 1.0f, 0.0f, 0.0f,
0.5f,-0.5f, 0.5f, 1.0f, 0.0f, 0.0f,
0.5f, 0.5f,-0.5f, 1.0f, 0.0f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f,
// 左面 (x = -0.5),法线朝 -X
-0.5f,-0.5f, 0.5f, -1.0f, 0.0f, 0.0f,
-0.5f,-0.5f,-0.5f, -1.0f, 0.0f, 0.0f,
-0.5f, 0.5f,-0.5f, -1.0f, 0.0f, 0.0f,
-0.5f,-0.5f, 0.5f, -1.0f, 0.0f, 0.0f,
-0.5f, 0.5f,-0.5f, -1.0f, 0.0f, 0.0f,
-0.5f, 0.5f, 0.5f, -1.0f, 0.0f, 0.0f,
};
//VBO:把数据从内存搬进显存
m_vbo.create();
m_vbo.bind();
m_vbo.allocate(vertices, sizeof(vertices));
2.2圆形的GLfloat
// ============================================================
// 1b. ★ 光源球顶点数据(独立 VBO)
// 用球面坐标(经纬网格)生成一个单位球:radius = 1.0
// stacks = 纬线圈数,sectors = 经线分块数,越大越圆滑
// 光源是"自发光"的,不需要法线,所以每个顶点只存 3 个 float 位置
// 这里直接展开成三角形列表(不用索引缓冲),和立方体的 glDrawArrays 风格一致
// ============================================================
{
std::vector<GLfloat> sphere;
const int stacks = 36; // 沿南北方向切成 36 层
const int sectors = 36; // 沿东西方向切成 36 份
const float radius = 1.0f; // 单位球,实际大小靠 lampModel.scale 控制
const float PI = 3.14159265358979f;
for (int i = 0; i < stacks; ++i) {
// 纬度:从 -PI/2(南极)扫到 +PI/2(北极)
float lat0 = PI * (-0.5f + (float)i / stacks);
float lat1 = PI * (-0.5f + (float)(i + 1) / stacks);
for (int j = 0; j < sectors; ++j) {
// 经度:绕一圈 0 ~ 2*PI
float lon0 = 2.0f * PI * (float)j / sectors;
float lon1 = 2.0f * PI * (float)(j + 1) / sectors;
auto P = [&](float lat, float lon) {
return QVector3D(radius * std::cos(lat) * std::cos(lon),
radius * std::sin(lat),
radius * std::cos(lat) * std::sin(lon));
};
QVector3D v00 = P(lat0, lon0);
QVector3D v01 = P(lat0, lon1);
QVector3D v11 = P(lat1, lon1);
QVector3D v10 = P(lat1, lon0);
// 一个网格 = 两个三角形:v00-v10-v11 和 v00-v11-v01
QVector3D tri[6] = { v00, v10, v11, v00, v11, v01 };
for (const QVector3D& v : tri) {
sphere.push_back(v.x());
sphere.push_back(v.y());
sphere.push_back(v.z());
}
}
}
m_lampVertexCount = (int)sphere.size() / 3;
m_lampVbo.create();
m_lampVbo.bind();
m_lampVbo.allocate(sphere.data(), sphere.size() * sizeof(GLfloat));
}
3.增加法线
3.1 GLfloat增项
把上一篇物体4、5、6列存储颜色的值改为存储法线
// - 位置 x,y,z -- -- 法线 nx,ny,nz -
// 前面 (z = +0.5),法线朝 +Z
-0.5f,-0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
3.2重构mvp模型增项
// ★ 光照新增:法线矩阵(用于把法线从"模型空间"转到"世界空间")
QMatrix3x3 m_normalMatrix;
void GLWidget::buildMatrices(int w, int h)
{
... ...
// ---- 4. ★ 光照新增:法线矩阵 ----
// Qt 的 QMatrix4x4 自带 normalMatrix() 方法,直接返回
// "模型矩阵的逆转置矩阵的 3x3 子矩阵"------正是法线需要的变换
// (底层逻辑见下文说明,不用 Qt 这个方法也可以自己算)
m_normalMatrix = m_model.normalMatrix();
}
4.物体着色器
4.1顶点着色器增项
增加输入变量aNormal,输出变量Normal,uniform参数uNormalMatrix
// ============================================================
// 2. 立方体的顶点着色器:多传两个 out
// - FragPos:顶点变换到"世界空间"后的坐标(算光照方向要用)
// - Normal :法线经法线矩阵变换后的方向(判断面朝向)
// ============================================================
m_program.addShaderFromSourceCode(QOpenGLShader::Vertex,
"#version 150\n"
"in vec3 aPos;\n" // 顶点位置(局部空间)
"in vec3 aNormal;\n" // ★ 新增:法线(局部空间)
"out vec3 FragPos;\n" // 传给片段着色器:世界空间坐标
"out vec3 Normal;\n" // 传给片段着色器:世界空间法线
"uniform mat4 uModel;\n"
"uniform mat4 uView;\n"
"uniform mat4 uProjection;\n"
"uniform mat3 uNormalMatrix;\n" // ★ 新增:法线矩阵(3x3,无平移分量)
"void main() {\n"
// 把顶点从局部空间 → 世界空间(供片段着色器计算"顶点→光源"方向)
" FragPos = vec3(uModel * vec4(aPos, 1.0));\n"
// ★ 法线必须用法线矩阵变换,不能用 uModel 直接乘(原因见下文说明)
" Normal = uNormalMatrix * aNormal;\n"
" gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);\n"
"}"
);
4.2片段着色器增项
增加输入的法线变量Normal,光源uniform参数uLightPos、uViewPos、uLightColor、uObjectColor
// ============================================================
// 3. 立方体的片段着色器:Phong 光照的核心计算都在这
// 三个分量:ambient(环境光)+ diffuse(漫反射)+ specular(镜面高光)
// ============================================================
m_program.addShaderFromSourceCode(QOpenGLShader::Fragment,
"#version 150\n"
"in vec3 FragPos;\n" // 接收自顶点着色器:世界空间坐标(GPU 自动插值)
"in vec3 Normal;\n" // 接收自顶点着色器:世界空间法线(GPU 自动插值)
"out vec4 FragColor;\n"
// ---- 光源参数 uniform(由 C++ 侧 setUniformValue 传入)----
"uniform vec3 uLightPos;\n" // 光源位置(世界空间)
"uniform vec3 uViewPos;\n" // 相机位置(世界空间,算高光方向要用)
"uniform vec3 uLightColor;\n" // 光的颜色
"uniform vec3 uObjectColor;\n" // 物体基色
"void main() {\n"
// ========== 分量 1:环境光 Ambient ==========
// 模拟"到处反弹的杂散光",让背光面不会纯黑
// 强度系数 0.1 表示环境光很弱,只提供 10% 基础亮度
" float ambientStrength = 0.1;\n"
" vec3 ambient = ambientStrength * uLightColor;\n"
// ========== 分量 2:漫反射 Diffuse ==========
// 物体"正对光源"的部分最亮,背对的部分最暗
" vec3 norm = normalize(Normal);\n" // 法线归一化(插值后长度可能不再是 1)
// 光线方向 = 顶点位置 → 光源位置 的单位向量
" vec3 lightDir = normalize(uLightPos - FragPos);\n"
// ★ 点乘的几何意义:两个单位向量的夹角余弦
// 夹角 0° → dot=1(正对光源,最亮)
// 夹角 90° → dot=0(擦边照到)
// 夹角 >90° → dot<0(背光,max 钳位到 0)
" float diff = max(dot(norm, lightDir), 0.0);\n"
" vec3 diffuse = diff * uLightColor;\n"
// ========== 分量 3:镜面高光 Specular ==========
// 模拟"光斑":视线方向越接近"反射光方向",高光越强
" float specularStrength = 0.5;\n" // 高光强度(塑料 0.5,金属更高)
" float shininess = 32.0;\n" // 反光度(越大高光越小越锐利)
// 视线方向 = 顶点位置 → 相机位置 的单位向量
" vec3 viewDir = normalize(uViewPos - FragPos);\n"
// reflect(I, N):计算向量 I 关于法线 N 的反射向量
// 注意传 -lightDir(reflect 要求 I 指向入射点)
" vec3 reflectDir = reflect(-lightDir, norm);\n"
// 用 dot(viewDir, reflectDir)^shininess 制造"锐利衰减"的高光斑
" float spec = pow(max(dot(viewDir, reflectDir), 0.0), shininess);\n"
" vec3 specular = specularStrength * spec * uLightColor;\n"
// ========== 合成:三个分量相加,再乘以物体基色 ==========
" vec3 result = (ambient + diffuse + specular) * uObjectColor;\n"
" FragColor = vec4(result, 1.0);\n"
"}"
);
5.光源着色器
它不是光源的实例,只是显示一个物体并移动到光源的位置而已,着色器也是比较简单。
5.1顶点着色器
只有基础的坐标和MVP。
m_lampProgram.addShaderFromSourceCode(QOpenGLShader::Vertex,
"#version 150\n"
"in vec3 aPos;\n"
"uniform mat4 uModel;\n"
"uniform mat4 uView;\n"
"uniform mat4 uProjection;\n"
"void main() {\n"
" gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);\n"
"}"
);
5.2片段着色器
输出为固定的黄色。
m_lampProgram.addShaderFromSourceCode(QOpenGLShader::Fragment,
"#version 150\n"
"out vec4 FragColor;\n"
"void main() {\n"
" FragColor = vec4(1.0, 1.0, 0.0, 1.0);\n" // 纯黄色,代表光源
"}"
);
6.paintGL渲染
拆分成2次物体绘制,六面体物体只进行旋转操作m_model.rotate(m_angle, 0.5f, 1.0f, 0.0f);
圆形物体有2个动作:
-
平移到光源位置:lampModel.translate(m_lightPos);
-
缩小:lampModel.scale(0.2f);
void GLWidget::paintGL()
{
glClearColor(0.05f, 0.05f, 0.12f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);// ============================================================ // Pass 1:绘制被光照的立方体 // ============================================================ m_vbo.bind(); // 立方体自己的 VBO(位置 + 法线) m_program.bind(); // 顶点属性布局:每个顶点 6 个 float,前 3 个是位置,后 3 个是法线 int posAttr = m_program.attributeLocation("aPos"); int normalAttr = m_program.attributeLocation("aNormal"); m_program.setAttributeBuffer(posAttr, GL_FLOAT, 0, 3, GLF_SIZE * sizeof(GLfloat)); m_program.enableAttributeArray(posAttr); m_program.setAttributeBuffer(normalAttr, GL_FLOAT, 3 * sizeof(GLfloat), 3, GLF_SIZE * sizeof(GLfloat)); m_program.enableAttributeArray(normalAttr); // 每帧更新自转---物体只旋转 m_model.setToIdentity(); m_model.rotate(m_angle, 0.5f, 1.0f, 0.0f); m_normalMatrix = m_model.normalMatrix(); // 法线矩阵也要跟着更新! // MVP + 法线矩阵 m_program.setUniformValue("uModel", m_model); m_program.setUniformValue("uView", m_view); m_program.setUniformValue("uProjection", m_projection); m_program.setUniformValue("uNormalMatrix", m_normalMatrix); // ★ 3x3 矩阵 // ★ 光照专属 uniform:光源位置、相机位置、两种颜色 // uViewPos 就是相机位置------因为"高光"需要知道观察者从哪看 m_program.setUniformValue("uLightPos", m_lightPos); m_program.setUniformValue("uViewPos", m_cameraPos); m_program.setUniformValue("uLightColor", m_lightColor); m_program.setUniformValue("uObjectColor", m_objectColor); glDrawArrays(GL_TRIANGLES, 0, 36); m_program.disableAttributeArray(posAttr); m_program.disableAttributeArray(normalAttr); m_program.release(); // ============================================================ // Pass 2:绘制光源指示球(独立球体 VBO + 自发光着色器) // ============================================================ m_lampVbo.bind(); // ★ 换成球体专用的 VBO m_lampProgram.bind(); // 球体顶点只有位置:stride = 3 * sizeof(GLfloat) int lampPosAttr = m_lampProgram.attributeLocation("aPos"); m_lampProgram.setAttributeBuffer(lampPosAttr, GL_FLOAT, 0, 3, 3 * sizeof(GLfloat)); m_lampProgram.enableAttributeArray(lampPosAttr); // 光源球的模型矩阵:先缩小成小圆球,再平移到光源位置 // 注意顺序:先 scale 后 translate(矩阵乘法从右往左作用) QMatrix4x4 lampModel; lampModel.setToIdentity(); lampModel.translate(m_lightPos); // 平移到光源位置 lampModel.scale(0.2f); // 单位球缩成半径 0.2 的小圆球 m_lampProgram.setUniformValue("uModel", lampModel); m_lampProgram.setUniformValue("uView", m_view); m_lampProgram.setUniformValue("uProjection", m_projection); glDrawArrays(GL_TRIANGLES, 0, m_lampVertexCount); // 画一个圆球 m_lampProgram.disableAttributeArray(lampPosAttr); m_lampProgram.release();}
这里需要特别注意的是,根据openGL状态机的机制,每次绘制前需要推送指定的配置资源到GPU:
1、绘制被光照的立方体时,需要先绑定VBO和着色器到GPU;
// ============================================================
// Pass 1:绘制被光照的立方体
// ============================================================
m_vbo.bind(); // 立方体自己的 VBO(位置 + 法线)
m_program.bind();
2、绘制光源指示球时,需要先绑定VBO和着色器到GPU;
// ============================================================
// Pass 2:绘制光源指示球(独立球体 VBO + 自发光着色器)
// ============================================================
m_lampVbo.bind(); // ★ 换成球体专用的 VBO
m_lampProgram.bind();
7.键盘事件
增加移动事件,通过修改m_lightPos值,实现圆形物体的平移lampModel.translate(m_lightPos);
void GLWidget::keyPressEvent(QKeyEvent *event)
{
const float speed = 0.3f;
switch (event->key()) {
... ...
// ★ 加个实验:用 J/K 移动光源的 X 位置,直观感受"光源移动对光照的影响"
case Qt::Key_J: m_lightPos.setX(m_lightPos.x() - 0.2f); break;
case Qt::Key_K: m_lightPos.setX(m_lightPos.x() + 0.2f); break;
}
buildMatrices(width(), height());
update();
}
核心代码
glwidget.h
#ifndef GLWIDGET_H
#define GLWIDGET_H
#include <QOpenGLWidget>
#include <QOpenGLFunctions>
#include <QOpenGLShaderProgram>
#include <QOpenGLBuffer>
#include <QMatrix4x4>
#include <QVector3D>
#include <QTimer>
#include <QKeyEvent>
//每个顶点的 float 数量(立方体):3 位置 + 3 法线 = 6
#define GLF_SIZE 6
class GLWidget : public QOpenGLWidget, protected QOpenGLFunctions
{
Q_OBJECT
public:
explicit GLWidget(QWidget *parent = nullptr);
~GLWidget();
protected:
void initializeGL() override;
void resizeGL(int w, int h) override;
void paintGL() override;
void keyPressEvent(QKeyEvent *event) override; // WASD 移动相机
private:
QOpenGLShaderProgram m_program; // 立方体的"被光照"着色器
QOpenGLShaderProgram m_lampProgram; // 光源指示球的"自发光"着色器
QOpenGLBuffer m_vbo{QOpenGLBuffer::VertexBuffer}; // 立方体:位置 + 法线
QOpenGLBuffer m_lampVbo{QOpenGLBuffer::VertexBuffer}; // ★ 光源球:只有位置,独立一份 VBO
int m_lampVertexCount = 0; // ★ 球体顶点数(供 glDrawArrays 用)
QTimer *m_timer;
// 三大矩阵:MVP
QMatrix4x4 m_model;
QMatrix4x4 m_view;
QMatrix4x4 m_projection;
// ★ 光照新增:法线矩阵(用于把法线从"模型空间"转到"世界空间")
QMatrix3x3 m_normalMatrix;
float m_angle = 0.0f; // 立方体自转角度
// ---- 相机参数(决定 View 矩阵)----
QVector3D m_cameraPos = QVector3D(0.0f, 0.0f, 5.0f);
QVector3D m_cameraTarget = QVector3D(0.0f, 0.0f, 0.0f);
QVector3D m_cameraUp = QVector3D(0.0f, 1.0f, 0.0f);
// ---- ★ 光照新增:光源参数(决定 Phong 计算的输入)----
QVector3D m_lightPos = QVector3D(0.0f, 1.0f, 2.0f); // 光源在世界空间的位置
QVector3D m_lightColor = QVector3D(1.0f, 1.0f, 1.0f); // 光的颜色(白光)
QVector3D m_objectColor = QVector3D(1.0f, 0.5f, 0.31f); // 物体基色(珊瑚橙)
void buildMatrices(int w, int h);
};
#endif // GLWIDGET_H
glwidget.cpp
#include "glwidget.h"
#include <QDebug>
#include <cmath>
#include <vector>
GLWidget::GLWidget(QWidget *parent)
: QOpenGLWidget(parent)
{
setFocusPolicy(Qt::StrongFocus); // 别忘了:键盘焦点(上次踩过的坑)
m_timer = new QTimer(this);
connect(m_timer, &QTimer::timeout, this, [=]{
m_angle += 1.5f;
if (m_angle >= 360.0f) m_angle -= 360.0f;
update(); // 触发 paintGL 重绘
});
}
GLWidget::~GLWidget() = default;
void GLWidget::initializeGL()
{
initializeOpenGLFunctions();
glEnable(GL_DEPTH_TEST); // 3D 必须开深度测试
// ============================================================
// 1. 立方体顶点数据:36 个顶点 = 6 面 × 2 三角形 × 3 顶点
// 法线 = 垂直于该面的单位向量,告诉着色器"这个面朝哪"
// 同一面上的 6 个顶点法线相同(平面),curved 模型则逐顶点不同
// ============================================================
GLfloat vertices[] = {
// ---- 位置 x,y,z ---- ---- 法线 nx,ny,nz ----
// 前面 (z = +0.5),法线朝 +Z
-0.5f,-0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
0.5f,-0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
-0.5f,-0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
-0.5f, 0.5f, 0.5f, 0.0f, 0.0f, 1.0f,
// 后面 (z = -0.5),法线朝 -Z
-0.5f,-0.5f,-0.5f, 0.0f, 0.0f,-1.0f,
0.5f,-0.5f,-0.5f, 0.0f, 0.0f,-1.0f,
0.5f, 0.5f,-0.5f, 0.0f, 0.0f,-1.0f,
-0.5f,-0.5f,-0.5f, 0.0f, 0.0f,-1.0f,
0.5f, 0.5f,-0.5f, 0.0f, 0.0f,-1.0f,
-0.5f, 0.5f,-0.5f, 0.0f, 0.0f,-1.0f,
// 上面 (y = +0.5),法线朝 +Y
-0.5f, 0.5f,-0.5f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f,-0.5f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f,
-0.5f, 0.5f,-0.5f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f,
-0.5f, 0.5f, 0.5f, 0.0f, 1.0f, 0.0f,
// 下面 (y = -0.5),法线朝 -Y
-0.5f,-0.5f,-0.5f, 0.0f,-1.0f, 0.0f,
0.5f,-0.5f,-0.5f, 0.0f,-1.0f, 0.0f,
0.5f,-0.5f, 0.5f, 0.0f,-1.0f, 0.0f,
-0.5f,-0.5f,-0.5f, 0.0f,-1.0f, 0.0f,
0.5f,-0.5f, 0.5f, 0.0f,-1.0f, 0.0f,
-0.5f,-0.5f, 0.5f, 0.0f,-1.0f, 0.0f,
// 右面 (x = +0.5),法线朝 +X
0.5f,-0.5f, 0.5f, 1.0f, 0.0f, 0.0f,
0.5f,-0.5f,-0.5f, 1.0f, 0.0f, 0.0f,
0.5f, 0.5f,-0.5f, 1.0f, 0.0f, 0.0f,
0.5f,-0.5f, 0.5f, 1.0f, 0.0f, 0.0f,
0.5f, 0.5f,-0.5f, 1.0f, 0.0f, 0.0f,
0.5f, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f,
// 左面 (x = -0.5),法线朝 -X
-0.5f,-0.5f, 0.5f, -1.0f, 0.0f, 0.0f,
-0.5f,-0.5f,-0.5f, -1.0f, 0.0f, 0.0f,
-0.5f, 0.5f,-0.5f, -1.0f, 0.0f, 0.0f,
-0.5f,-0.5f, 0.5f, -1.0f, 0.0f, 0.0f,
-0.5f, 0.5f,-0.5f, -1.0f, 0.0f, 0.0f,
-0.5f, 0.5f, 0.5f, -1.0f, 0.0f, 0.0f,
};
//VBO:把数据从内存搬进显存
m_vbo.create();
m_vbo.bind();
m_vbo.allocate(vertices, sizeof(vertices));
// ============================================================
// 1b. ★ 光源球顶点数据(独立 VBO)
// 用球面坐标(经纬网格)生成一个单位球:radius = 1.0
// stacks = 纬线圈数,sectors = 经线分块数,越大越圆滑
// 光源是"自发光"的,不需要法线,所以每个顶点只存 3 个 float 位置
// 这里直接展开成三角形列表(不用索引缓冲),和立方体的 glDrawArrays 风格一致
// ============================================================
{
std::vector<GLfloat> sphere;
const int stacks = 36; // 沿南北方向切成 36 层
const int sectors = 36; // 沿东西方向切成 36 份
const float radius = 1.0f; // 单位球,实际大小靠 lampModel.scale 控制
const float PI = 3.14159265358979f;
for (int i = 0; i < stacks; ++i) {
// 纬度:从 -PI/2(南极)扫到 +PI/2(北极)
float lat0 = PI * (-0.5f + (float)i / stacks);
float lat1 = PI * (-0.5f + (float)(i + 1) / stacks);
for (int j = 0; j < sectors; ++j) {
// 经度:绕一圈 0 ~ 2*PI
float lon0 = 2.0f * PI * (float)j / sectors;
float lon1 = 2.0f * PI * (float)(j + 1) / sectors;
auto P = [&](float lat, float lon) {
return QVector3D(radius * std::cos(lat) * std::cos(lon),
radius * std::sin(lat),
radius * std::cos(lat) * std::sin(lon));
};
QVector3D v00 = P(lat0, lon0);
QVector3D v01 = P(lat0, lon1);
QVector3D v11 = P(lat1, lon1);
QVector3D v10 = P(lat1, lon0);
// 一个网格 = 两个三角形:v00-v10-v11 和 v00-v11-v01
QVector3D tri[6] = { v00, v10, v11, v00, v11, v01 };
for (const QVector3D& v : tri) {
sphere.push_back(v.x());
sphere.push_back(v.y());
sphere.push_back(v.z());
}
}
}
m_lampVertexCount = (int)sphere.size() / 3;
m_lampVbo.create();
m_lampVbo.bind();
m_lampVbo.allocate(sphere.data(), sphere.size() * sizeof(GLfloat));
}
// ============================================================
// 2. 立方体的顶点着色器:多传两个 out
// - FragPos:顶点变换到"世界空间"后的坐标(算光照方向要用)
// - Normal :法线经法线矩阵变换后的方向(判断面朝向)
// ============================================================
m_program.addShaderFromSourceCode(QOpenGLShader::Vertex,
"#version 150\n"
"in vec3 aPos;\n" // 顶点位置(局部空间)
"in vec3 aNormal;\n" // ★ 新增:法线(局部空间)
"out vec3 FragPos;\n" // 传给片段着色器:世界空间坐标
"out vec3 Normal;\n" // 传给片段着色器:世界空间法线
"uniform mat4 uModel;\n"
"uniform mat4 uView;\n"
"uniform mat4 uProjection;\n"
"uniform mat3 uNormalMatrix;\n" // ★ 新增:法线矩阵(3x3,无平移分量)
"void main() {\n"
// 把顶点从局部空间 → 世界空间(供片段着色器计算"顶点→光源"方向)
" FragPos = vec3(uModel * vec4(aPos, 1.0));\n"
// ★ 法线必须用法线矩阵变换,不能用 uModel 直接乘(原因见下文说明)
" Normal = uNormalMatrix * aNormal;\n"
" gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);\n"
"}"
);
// ============================================================
// 3. 立方体的片段着色器:Phong 光照的核心计算都在这
// 三个分量:ambient(环境光)+ diffuse(漫反射)+ specular(镜面高光)
// ============================================================
m_program.addShaderFromSourceCode(QOpenGLShader::Fragment,
"#version 150\n"
"in vec3 FragPos;\n" // 接收自顶点着色器:世界空间坐标(GPU 自动插值)
"in vec3 Normal;\n" // 接收自顶点着色器:世界空间法线(GPU 自动插值)
"out vec4 FragColor;\n"
// ---- 光源参数 uniform(由 C++ 侧 setUniformValue 传入)----
"uniform vec3 uLightPos;\n" // 光源位置(世界空间)
"uniform vec3 uViewPos;\n" // 相机位置(世界空间,算高光方向要用)
"uniform vec3 uLightColor;\n" // 光的颜色
"uniform vec3 uObjectColor;\n" // 物体基色
"void main() {\n"
// ========== 分量 1:环境光 Ambient ==========
// 模拟"到处反弹的杂散光",让背光面不会纯黑
// 强度系数 0.1 表示环境光很弱,只提供 10% 基础亮度
" float ambientStrength = 0.1;\n"
" vec3 ambient = ambientStrength * uLightColor;\n"
// ========== 分量 2:漫反射 Diffuse ==========
// 物体"正对光源"的部分最亮,背对的部分最暗
" vec3 norm = normalize(Normal);\n" // 法线归一化(插值后长度可能不再是 1)
// 光线方向 = 顶点位置 → 光源位置 的单位向量
" vec3 lightDir = normalize(uLightPos - FragPos);\n"
// ★ 点乘的几何意义:两个单位向量的夹角余弦
// 夹角 0° → dot=1(正对光源,最亮)
// 夹角 90° → dot=0(擦边照到)
// 夹角 >90° → dot<0(背光,max 钳位到 0)
" float diff = max(dot(norm, lightDir), 0.0);\n"
" vec3 diffuse = diff * uLightColor;\n"
// ========== 分量 3:镜面高光 Specular ==========
// 模拟"光斑":视线方向越接近"反射光方向",高光越强
" float specularStrength = 0.5;\n" // 高光强度(塑料 0.5,金属更高)
" float shininess = 32.0;\n" // 反光度(越大高光越小越锐利)
// 视线方向 = 顶点位置 → 相机位置 的单位向量
" vec3 viewDir = normalize(uViewPos - FragPos);\n"
// reflect(I, N):计算向量 I 关于法线 N 的反射向量
// 注意传 -lightDir(reflect 要求 I 指向入射点)
" vec3 reflectDir = reflect(-lightDir, norm);\n"
// 用 dot(viewDir, reflectDir)^shininess 制造"锐利衰减"的高光斑
" float spec = pow(max(dot(viewDir, reflectDir), 0.0), shininess);\n"
" vec3 specular = specularStrength * spec * uLightColor;\n"
// ========== 合成:三个分量相加,再乘以物体基色 ==========
" vec3 result = (ambient + diffuse + specular) * uObjectColor;\n"
" FragColor = vec4(result, 1.0);\n"
"}"
);
if (!m_program.link()) {
qDebug() << "Main Shader Link Error:" << m_program.log();
return;
}
// ============================================================
// 4. 光源指示球的着色器
// 它是"自发光"的,不做任何光照计算,就是纯黄色
// ============================================================
// 光源:顶点着色器---未声明法线
m_lampProgram.addShaderFromSourceCode(QOpenGLShader::Vertex,
"#version 150\n"
"in vec3 aPos;\n"
"uniform mat4 uModel;\n"
"uniform mat4 uView;\n"
"uniform mat4 uProjection;\n"
"void main() {\n"
" gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);\n"
"}"
);
// 光源:片段着色器
m_lampProgram.addShaderFromSourceCode(QOpenGLShader::Fragment,
"#version 150\n"
"out vec4 FragColor;\n"
"void main() {\n"
" FragColor = vec4(1.0, 1.0, 0.0, 1.0);\n" // 纯黄色,代表光源
"}"
);
if (!m_lampProgram.link()) {
qDebug() << "Lamp Shader Link Error:" << m_lampProgram.log();
return;
}
m_timer->start(16); // 约 60 FPS
}
void GLWidget::resizeGL(int w, int h)
{
glViewport(0, 0, w, h);
buildMatrices(w, h); // 窗口变化时重建投影矩阵(aspect 必须跟随)
}
void GLWidget::buildMatrices(int w, int h)
{
// ---- 1. 模型矩阵:立方体绕斜轴自转 ----
m_model.setToIdentity();
m_model.rotate(m_angle, 0.5f, 1.0f, 0.0f);
// ---- 2. 视图矩阵:相机姿态 ----
m_view.setToIdentity();
m_view.lookAt(m_cameraPos, m_cameraTarget, m_cameraUp);
// ---- 3. 投影矩阵:透视 ----
m_projection.setToIdentity();
m_projection.perspective(45.0f, float(w)/float(h), 0.1f, 100.0f);
// ---- 4. ★ 光照新增:法线矩阵 ----
// Qt 的 QMatrix4x4 自带 normalMatrix() 方法,直接返回
// "模型矩阵的逆转置矩阵的 3x3 子矩阵"------正是法线需要的变换
// (底层逻辑见下文说明,不用 Qt 这个方法也可以自己算)
m_normalMatrix = m_model.normalMatrix();
}
void GLWidget::paintGL()
{
glClearColor(0.05f, 0.05f, 0.12f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// ============================================================
// Pass 1:绘制被光照的立方体
// ============================================================
m_vbo.bind(); // 立方体自己的 VBO(位置 + 法线)
m_program.bind();
// 顶点属性布局:每个顶点 6 个 float,前 3 个是位置,后 3 个是法线
int posAttr = m_program.attributeLocation("aPos");
int normalAttr = m_program.attributeLocation("aNormal");
m_program.setAttributeBuffer(posAttr, GL_FLOAT, 0, 3, GLF_SIZE * sizeof(GLfloat));
m_program.enableAttributeArray(posAttr);
m_program.setAttributeBuffer(normalAttr, GL_FLOAT, 3 * sizeof(GLfloat), 3, GLF_SIZE * sizeof(GLfloat));
m_program.enableAttributeArray(normalAttr);
// 每帧更新自转---物体只旋转
m_model.setToIdentity();
m_model.rotate(m_angle, 0.5f, 1.0f, 0.0f);
m_normalMatrix = m_model.normalMatrix(); // 法线矩阵也要跟着更新!
// MVP + 法线矩阵
m_program.setUniformValue("uModel", m_model);
m_program.setUniformValue("uView", m_view);
m_program.setUniformValue("uProjection", m_projection);
m_program.setUniformValue("uNormalMatrix", m_normalMatrix); // ★ 3x3 矩阵
// ★ 光照专属 uniform:光源位置、相机位置、两种颜色
// uViewPos 就是相机位置------因为"高光"需要知道观察者从哪看
m_program.setUniformValue("uLightPos", m_lightPos);
m_program.setUniformValue("uViewPos", m_cameraPos);
m_program.setUniformValue("uLightColor", m_lightColor);
m_program.setUniformValue("uObjectColor", m_objectColor);
glDrawArrays(GL_TRIANGLES, 0, 36);
m_program.disableAttributeArray(posAttr);
m_program.disableAttributeArray(normalAttr);
m_program.release();
// ============================================================
// Pass 2:绘制光源指示球(独立球体 VBO + 自发光着色器)
// ============================================================
m_lampVbo.bind(); // ★ 换成球体专用的 VBO
m_lampProgram.bind();
// 球体顶点只有位置:stride = 3 * sizeof(GLfloat)
int lampPosAttr = m_lampProgram.attributeLocation("aPos");
m_lampProgram.setAttributeBuffer(lampPosAttr, GL_FLOAT, 0, 3, 3 * sizeof(GLfloat));
m_lampProgram.enableAttributeArray(lampPosAttr);
// 光源球的模型矩阵:先缩小成小圆球,再平移到光源位置
// 注意顺序:先 scale 后 translate(矩阵乘法从右往左作用)
QMatrix4x4 lampModel;
lampModel.setToIdentity();
lampModel.translate(m_lightPos); // 平移到光源位置
lampModel.scale(0.2f); // 单位球缩成半径 0.2 的小圆球
m_lampProgram.setUniformValue("uModel", lampModel);
m_lampProgram.setUniformValue("uView", m_view);
m_lampProgram.setUniformValue("uProjection", m_projection);
glDrawArrays(GL_TRIANGLES, 0, m_lampVertexCount); // 画一个圆球
m_lampProgram.disableAttributeArray(lampPosAttr);
m_lampProgram.release();
}
void GLWidget::keyPressEvent(QKeyEvent *event)
{
const float speed = 0.3f;
switch (event->key()) {
case Qt::Key_W: m_cameraPos.setZ(m_cameraPos.z() - speed); break;
case Qt::Key_S: m_cameraPos.setZ(m_cameraPos.z() + speed); break;
case Qt::Key_A: m_cameraPos.setX(m_cameraPos.x() - speed); break;
case Qt::Key_D: m_cameraPos.setX(m_cameraPos.x() + speed); break;
case Qt::Key_Q: m_cameraPos.setY(m_cameraPos.y() + speed); break;
case Qt::Key_E: m_cameraPos.setY(m_cameraPos.y() - speed); break;
// ★ 加个实验:用 J/K 移动光源的 X 位置,直观感受"光源移动对光照的影响"
case Qt::Key_J: m_lightPos.setX(m_lightPos.x() - 0.2f); break;
case Qt::Key_K: m_lightPos.setX(m_lightPos.x() + 0.2f); break;
}
buildMatrices(width(), height());
update();
}