【计算机图形学第三课】三维变换
今天我们来讲一下三维变换,我们将以下面的目录进行讲解
- 界面效果演示
- 前置知识点
- 二维变换
- 欧拉角
- 左手系,右手系
- 投影
- 摄像机
- 核心原理说明
- 全篇难点展开讲解
- 代码
界面效果演示
- 左上方是直接绘制的Z,X的坐标,等于直接从Y轴的+方向观察
- 左下方是直接绘制的Z,Y的坐标,等于直接从X轴的+方向观察
- 右边是直接绘制的X,Y轴的坐标,等于直接从Z轴的-方向观察
- 在移动的点位是每个视角下当前的点位,红色的点连成的线是点位走过的轨迹

前置知识点
二维变换
- 二维变换在前面的博客有写过,此处贴上了链接,如果不了解二维变换这一节是无法进行下去的。
欧拉角
- 欧拉角用三个参数定义了一个三维物体的姿态,它们是一个整体,必须遵循同样的旋转顺序才能得到同样的姿态,假设现在绕X轴旋转30°,绕Y轴旋转50°,绕Z轴旋转60°,那么我们换个顺序绕Y轴旋转50°,绕X轴旋转30°,绕Z轴旋转60 得到的是完全不同的姿态°
- 欧拉角存在轴耦合
- 欧拉角存在万向节
- 那么欧拉角有这么多的局限我们为什么还要使用呢
- 直观,易于理解
- 接近人类对三维物体的认知
左手系,右手系
- 左手系,右手系是一种规则的约定,此处我们使用左手系进行演示,后续的代码也按照左手系编写
我们规定,伸出你的左手,中指向前平行于地面作为Z轴,食指垂直于地面作为Y轴,拇指垂直于食指和中指作为X轴拇指指向 X 轴的方向,手指卷曲的方向就是绕 X 轴旋转的正角度方向拇指指向 Y 轴的方向,手指卷曲的方向就是绕 Y 轴旋转的正角度方向拇指指向 Z 轴的方向,手指卷曲的方向就是绕 Z 轴旋转的正角度方向




投影
我们使用了直接绘制X,Y轴坐标的方式忽略了Z轴,这基本符合了正交投影的定义,还有一种透视投影,符合人眼看物体近大远小的规律,此处为了demo的简洁我们暂且忽略。
摄像机角度
我们直接绘制了X,Y轴的坐标 那就等同于站在Z轴的负方向观察,因为我们使用左手系,指向屏幕内才是Z轴的正方向,也就是从屏幕外看向屏幕内。
核心原理说明
在二维变换时是点绕点旋转,那么三维变换时是点绕轴旋转,想想一下下面的对应关系
- 绕X轴旋转时,等于Y,Z轴构成了一个平面,然后在该平面上做点绕点旋转。
- 绕Y轴旋转时,等于X,Z构成了一个平面,然后在该平面上做点绕点旋转。
- 绕Z轴旋转时,等于X,Y构成了一个平面,然后在该平面上做点绕点旋转。
- 会二维变换就会三维变换,但是存在上述的一层窗户纸,如果想不通这一层,那么怎么都感觉理解不了,如果想通了这一层,那么就理解了。
全篇难点展开讲解
手系与旋转角度的对应
绕X轴旋转
这是绕着X轴旋转后的姿态,从图上可以看出我们绕X轴旋转和我们定义的拇指指向X轴,手指卷曲的方向为正角度是符合的

下面我们来到绕X轴旋转的代码部分,从代码部分可以看出一个问题,我们使用的正角度逆时针旋转的矩阵,但是为什么我们站在X轴无穷大的角度看又做的是顺时针旋转,试想一下绕着X轴旋转等于将Y轴作为了平面坐标系内的X轴,将Z轴作为平面坐标系内的Y轴,但是在我们定义的是左手坐标系,Z轴是指向屏幕内的,也就是越向屏幕内越大,那么就等同于构成了一个倒转了Y轴的平面坐标系在其内做点绕点旋转,所以我们用逆时针旋转矩阵却做的是顺时针旋转,但这是符合我们左手手指卷曲方向的,等于在做下图一样运动。
// 绕X轴旋转(左手系)
newY = y * cos(angleX * M_PI / 180) - z * sin(angleX * M_PI / 180);
newZ = y * sin(angleX * M_PI / 180) + z * cos(angleX * M_PI / 180);
y = newY;
z = newZ;

绕Y轴旋转
这个就很简单了,等同于站在Y轴无限大的方向观察,把X轴想象成平面坐标系里的X轴,把Z轴想像成平面坐标系里的Y轴,等同于一个向右X轴增加,向上Y轴增大的标准平面坐标系,旋转矩阵也和平面坐标系里的角度增大顺时针旋转的旋转矩阵一致

绕Y轴旋转代码部分
// 绕Y轴旋转(左手系)
newX = x * cos(angleY * M_PI / 180) + z * sin(angleY * M_PI / 180);
newZ = -x * sin(angleY * M_PI / 180) + z * cos(angleY * M_PI / 180);
x = newX;
z = newZ;
绕Z轴旋转
那这个就更简单了,我们绘制立方体的的角度就是站在Z轴负无限大的角度绘制的,这个就不做详细说明了,依此类推

绕Z轴旋转代码部分
// 绕Z轴旋转(左手系)
newX = x * cos(angleZ * M_PI / 180) - y * sin(angleZ * M_PI / 180);
newY = x * sin(angleZ * M_PI / 180) + y * cos(angleZ * M_PI / 180);
x = newX;
y = newY;
下面贴上完整代码,没有使用任何库,就像在草稿纸上演算完成,然后用尺子画到一张纸上,这样就不会隐藏任何细节,有助于学习,理解
头文件
#ifndef ORTHOGONAL_3_H
#define ORTHOGONAL_3_H
#include <QWidget>
#include <QGridLayout>
#include <QPushButton>
#include <QSpacerItem>
#include <ThreeCube_1/Model/threepointmodel.h>
#include <QRandomGenerator>
#include <QMouseEvent>
#include <QLabel>
class Orthogonal_3: public QWidget
{
Q_OBJECT
public:
Orthogonal_3(QWidget* parent);
~Orthogonal_3();
protected:
void paintEvent(QPaintEvent* event) override;
private:
float vertices[11][3]; // 立方体8个顶点的3D坐标
float angleX=0, angleY=0,angleZ=0; // 旋转角度
float length=200;
bool isPaintRect=true; //是否绘制立方体
bool isPaintPath=false; //是否绘制路径
bool isAutoRotate=false; //是否自动绘制
QVector<QPointF> pathPoint; //路径点位
QGridLayout grid_main;
QPushButton btn_roateX;
QPushButton btn_roateY;
QPushButton btn_roateZ;
QPushButton btn_isPaintRect; //是否绘制矩形
QPushButton btn_isPaintPathPoint; //是否绘制轨迹
QPushButton btn_isAutoRotate; //是否自动绘制
QPushButton btn_reset;
QLabel lbe_xAngle;
QLabel lbe_yAngle;
QLabel lbe_zAngle;
QPointF markPoint;
//QPointF during_xz;
//QPointF during_yz;
//QPointF during_xy;
QString currentAxis="x";
signals:
void paintPoint_XZ_Sign(QPointF point,int observer);
void clearPoint_XZ_Sign();
void paintPoint_YZ_Sign(QPointF point,int observer);
void clearPoint_YZ_Sign();
};
#endif // ORTHOGONAL_3_H
cpp文件
#include "orthogonal_3.h"
#include <QPainter>
#include <QTimer>
#include <cmath>
#include <QDebug>
#include <set>
#include <algorithm>
Orthogonal_3::Orthogonal_3(QWidget* parent): QWidget(parent)
{
setMinimumSize(800, 800);
setMouseTracking(true); // 启用鼠标跟踪
this->setLayout(&grid_main);
grid_main.addItem(new QSpacerItem(2000, 2000),1,1);
grid_main.addItem(new QSpacerItem(2000, 2000),1,2);
grid_main.addWidget(&btn_reset,7,9);
btn_reset.setText("复位");
btn_reset.setStyleSheet("font-size:60px");
connect(&btn_reset,&QPushButton::clicked,this,[=]{
angleY=0;
angleX=0;
angleZ=0;
emit clearPoint_XZ_Sign();
emit clearPoint_YZ_Sign();
pathPoint.clear();
update();
});
grid_main.addWidget(&btn_roateX,8,9);
btn_roateX.setText("绕X轴顺时针旋转5度");
btn_roateX.setStyleSheet("font-size:60px");
connect(&btn_roateX,&QPushButton::clicked,this,[=]{
currentAxis="x";
angleX+=5;
update();
});
grid_main.addWidget(&btn_roateY,9,9);
btn_roateY.setText("绕Y轴顺时针旋转5度");
btn_roateY.setStyleSheet("font-size:60px");
connect(&btn_roateY,&QPushButton::clicked,this,[=]{
currentAxis="y";
angleY+=5;
update();
});
grid_main.addWidget(&btn_roateZ,10,9);
btn_roateZ.setText("绕Z轴顺时针旋转5度");
btn_roateZ.setStyleSheet("font-size:60px");
connect(&btn_roateZ,&QPushButton::clicked,this,[=]{
currentAxis="z";
angleZ+=5;
update();
});
grid_main.addWidget(&btn_isPaintRect,11,9);
btn_isPaintRect.setText("绘制立方体");
btn_isPaintRect.setStyleSheet("font-size:60px;background:Green;color:white");
connect(&btn_isPaintRect,&QPushButton::clicked,this,[=]{
isPaintRect=!isPaintRect;
if(isPaintRect)
{
btn_isPaintRect.setText("绘制立方体");
btn_isPaintRect.setStyleSheet("font-size:60px;background:Green;color:white");
}
else
{
btn_isPaintRect.setText("不绘制立方体");
btn_isPaintRect.setStyleSheet("font-size:60px;background:Red;color:white");
}
update();
});
grid_main.addWidget(&btn_isPaintPathPoint,12,9);
btn_isPaintPathPoint.setText("不绘制轨迹");
btn_isPaintPathPoint.setStyleSheet("font-size:60px;background:red;color:white");
connect(&btn_isPaintPathPoint,&QPushButton::clicked,this,[=]{
isPaintPath=!isPaintPath;
if(isPaintPath)
{
btn_isPaintPathPoint.setText("绘制轨迹");
btn_isPaintPathPoint.setStyleSheet("font-size:60px;background:green;color:white");
}
else
{
btn_isPaintPathPoint.setText("不绘制轨迹");
btn_isPaintPathPoint.setStyleSheet("font-size:60px;background:red;color:white");
}
update();
});
grid_main.addWidget(&btn_isAutoRotate,13,9);
btn_isAutoRotate.setText("手动绘制");
btn_isAutoRotate.setStyleSheet("background:red;color:white;font-size:60px");
connect(&btn_isAutoRotate,&QPushButton::clicked,this,[=]{
isAutoRotate=!isAutoRotate;
if(isAutoRotate)
{
btn_isAutoRotate.setText("手动绘制");
btn_isAutoRotate.setStyleSheet("font-size:60px;background:green;color:white");
}
else
{
btn_isAutoRotate.setText("自动绘制");
btn_isAutoRotate.setStyleSheet("font-size:60px;background:red;color:white");
}
});
grid_main.addWidget(&lbe_xAngle,14,9);
grid_main.addWidget(&lbe_yAngle,15,9);
grid_main.addWidget(&lbe_zAngle,16,9);
// === 立方体顶点定义(中心为原点,单位:-length 到 +length)===
// 前表面顶点(Z = +length,靠近观察者)
vertices[0][0] = -length; vertices[0][1] = -length; vertices[0][2] = length; // 左下角(前表面)
vertices[1][0] = length; vertices[1][1] = -length; vertices[1][2] = length; // 右下角(前表面)
vertices[2][0] = length; vertices[2][1] = length; vertices[2][2] = length; // 右上角(前表面)
vertices[3][0] = -length; vertices[3][1] = length; vertices[3][2] = length; // 左上角(前表面)
// 后表面顶点(Z = -length,远离观察者)
vertices[4][0] = -length; vertices[4][1] = -length; vertices[4][2] = -length; // 左下角(后表面)
vertices[5][0] = length; vertices[5][1] = -length; vertices[5][2] = -length; // 右下角(后表面)
vertices[6][0] = length; vertices[6][1] = length; vertices[6][2] = -length; // 右上角(后表面)
vertices[7][0] = -length; vertices[7][1] = length; vertices[7][2] = -length; // 左上角(后表面)
// === 坐标轴端点定义 ===
vertices[8][0] = length+50; vertices[8][1] = 0; vertices[8][2] = 0; // X轴正方向端点(右侧)
vertices[9][0] = 0; vertices[9][1] = length+50; vertices[9][2] = 0; // Y轴正方向端点(上方)
vertices[10][0] = 0; vertices[10][1] = 0; vertices[10][2] = length+50; // Z轴正方向端点(屏幕内部)
// 设置旋转角度
angleX = 0;
angleY = 0;
// 创建定时器用于动画
QTimer* timer = new QTimer(this);
connect(timer, &QTimer::timeout, this, [this]() {
if(isAutoRotate)
{
angleX += 1;
angleY += 1;
angleZ += 1;
update();
}
});
timer->start(16); // 约60fps
}
Orthogonal_3::~Orthogonal_3()
{
}
void Orthogonal_3::paintEvent(QPaintEvent*) {
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing); //抗锯齿
// 移动到窗口中心
painter.translate(width() / 2, height() / 2);
painter.scale(1,-1);
// 计算投影后的2D点
QVector<ThreePointModel> threePointModel;
for (int i = 0; i < 11; i++) {
// 复制顶点
float x = vertices[i][0];
float y = vertices[i][1];
float z = vertices[i][2];
float newX, newY, newZ;
// 绕X轴旋转(左手系)
newY = y * cos(angleX * M_PI / 180) - z * sin(angleX * M_PI / 180);
newZ = y * sin(angleX * M_PI / 180) + z * cos(angleX * M_PI / 180);
y = newY;
z = newZ;
// if(i==0)
// {
// emit paintPoint_YZ_Sign(QPointF(y, z), 2); // Z反转以匹配屏幕内方向
// }
// 绕Y轴旋转(左手系)
newX = x * cos(angleY * M_PI / 180) + z * sin(angleY * M_PI / 180);
newZ = -x * sin(angleY * M_PI / 180) + z * cos(angleY * M_PI / 180);
x = newX;
z = newZ;
// if(i==0)
// {
// emit paintPoint_XZ_Sign(QPointF(x, z), 1); // Z反转以匹配屏幕内方向
// }
// 绕Z轴旋转(左手系)
newX = x * cos(angleZ * M_PI / 180) - y * sin(angleZ * M_PI / 180);
newY = x * sin(angleZ * M_PI / 180) + y * cos(angleZ * M_PI / 180);
x = newX;
y = newY;
// 简单正交投影
ThreePointModel projected(x, y, z);
threePointModel.insert(i, projected);
if (i == 0)
{
pathPoint.append(QPointF(x, y));
markPoint.setX(x);
markPoint.setY(y);
if (pathPoint.length() > 3600) {
pathPoint.remove(0, 1);
}
emit paintPoint_XZ_Sign(QPointF(x, z), 1); // Z反转以匹配屏幕内方向
emit paintPoint_YZ_Sign(QPointF(y, z), 2); // Z反转以匹配屏幕内方向
}
}
// 定义面
static const int faceVertices[6][4] = {
{0, 3, 2, 1}, // 前
{4, 5, 6, 7}, // 后
{0, 1, 5, 4}, // 底
{3, 7, 6, 2}, // 顶
{0, 4, 7, 3}, // 左
{1, 2, 6, 5} // 右
};
// 定义每个面的颜色(50%透明度)
static const QColor faceColors[6] = {
QColor(255, 0, 0, 128), // 前(红)
QColor(0, 255, 0, 128), // 后(绿)
QColor(0, 0, 255, 128), // 底(蓝)
QColor(255, 255, 0, 128), // 顶(黄)
QColor(0, 255, 255, 128), // 左(青)
QColor(255, 0, 255, 128) // 右(洋红)
};
// 定义每个面的标签(中文)
static const QString faceLabels[6] = {
"前", // front
"后", // back
"底", // bottom
"顶", // top
"左", // left
"右" // right
};
// 计算可见面并按深度排序
struct FaceInfo {
int index;
float avgZ;
bool visible;
float textAngle; // 文本旋转角度
};
std::vector<FaceInfo> faceInfos(6);
for (int f = 0; f < 6; ++f) {
int v0 = faceVertices[f][0];
int v1 = faceVertices[f][1];
int v2 = faceVertices[f][2];
float ax = threePointModel.at(v1).getX() - threePointModel.at(v0).getX();
float ay = threePointModel.at(v1).getY() - threePointModel.at(v0).getY();
float az = threePointModel.at(v1).getZ() - threePointModel.at(v0).getZ();
float bx = threePointModel.at(v2).getX() - threePointModel.at(v0).getX();
float by = threePointModel.at(v2).getY() - threePointModel.at(v0).getY();
float bz = threePointModel.at(v2).getZ() - threePointModel.at(v0).getZ();
float nx = ay * bz - az * by;
float ny = az * bx - ax * bz;
float nz = ax * by - ay * bx;
// 计算面的平均Z值(用于深度排序)
float avgZ = (threePointModel.at(faceVertices[f][0]).getZ() +
threePointModel.at(faceVertices[f][1]).getZ() +
threePointModel.at(faceVertices[f][2]).getZ() +
threePointModel.at(faceVertices[f][3]).getZ()) / 4.0f;
// 计算2D投影后的法向量方向以确定文本旋转角度
float projNx = nx;
float projNy = ny;
float textAngle = atan2(projNy, projNx) * 180.0f / M_PI;
// 调整文字方向以避免倒挂
if (textAngle > 90.0f || textAngle < -90.0f) {
textAngle += 180.0f;
}
faceInfos[f] = {f, avgZ, nz > 0, textAngle}; // 左手系:nz > 0
}
// 按平均Z值从远到近排序(画家算法)
std::sort(faceInfos.begin(), faceInfos.end(), [](const FaceInfo& a, const FaceInfo& b) {
return a.avgZ < b.avgZ;
});
// 绘制立方体
if (isPaintRect) {
// 绘制填充的面和标签
for (const auto& faceInfo : faceInfos) {
if (!faceInfo.visible) continue;
int f = faceInfo.index;
// 创建多边形
QPointF points[4];
for (int j = 0; j < 4; ++j) {
points[j] = threePointModel.at(faceVertices[f][j]).getPoint_xy();
}
// 设置填充颜色(50%透明度)
painter.setPen(Qt::NoPen);
painter.setBrush(QBrush(faceColors[f]));
painter.drawPolygon(points, 4);
// 计算面中心用于放置标签
float centerX = 0, centerY = 0;
for (int j = 0; j < 4; ++j) {
centerX += points[j].x();
centerY += points[j].y();
}
centerX /= 4.0f;
centerY /= 4.0f;
// 保存画家状态
painter.save();
// 移动到面中心并旋转
painter.translate(centerX, centerY);
painter.rotate(-faceInfo.textAngle); // 负角度以匹配Qt的顺时针旋转
// 根据面颜色动态选择文字颜色以提高对比度
Qt::GlobalColor textColor = (faceColors[f] == QColor(0, 0, 255, 128) || faceColors[f] == QColor(255, 0, 255, 128)) ? Qt::white : Qt::black;
// 绘制标签
painter.setPen(QPen(textColor, 1));
painter.setFont(QFont("SimSun", 12, QFont::Bold)); // 使用宋体支持中文
QRectF textRect(-50, -15, 100, 30);
painter.drawText(textRect, Qt::AlignCenter, faceLabels[f]);
// 恢复画家状态
painter.restore();
}
}
// 绘制轨迹点
painter.setPen(QPen(Qt::blue, 15));
painter.drawPoint(markPoint);
// 绘制坐标轴
painter.setPen(QPen(Qt::red, 3));
painter.drawLine(QPointF(0,0), threePointModel.at(8).getPoint_xy()); //X轴
painter.setPen(QPen(Qt::green, 3));
painter.drawLine(QPointF(0,0), threePointModel.at(9).getPoint_xy()); //Y轴
painter.setPen(QPen(Qt::blue, 3));
painter.drawLine(QPointF(0,0), threePointModel.at(10).getPoint_xy()); //Z轴
// 绘制路径
if(isPaintPath)
{
painter.setPen(QPen(Qt::red, 5));
painter.drawPoints(pathPoint.data(), pathPoint.size());
}
// 更新角度标签
lbe_xAngle.setText("x:"+QString::number(angleX));
lbe_yAngle.setText("y:"+QString::number(angleY));
lbe_zAngle.setText("z:"+QString::number(angleZ));
}
结语
后续可能会依然在这个博文里进行更新,包括欧拉角的局限(轴耦合,万向节),怎样使用四元数规避这些问题,等等
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)