今天我们来讲一下三维变换,我们将以下面的目录进行讲解

  • 界面效果演示
  • 前置知识点
    • 二维变换
    • 欧拉角
    • 左手系,右手系
    • 投影
    • 摄像机
  • 核心原理说明
  • 全篇难点展开讲解
  • 代码

界面效果演示

  • 左上方是直接绘制的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));
}

结语

后续可能会依然在这个博文里进行更新,包括欧拉角的局限(轴耦合,万向节),怎样使用四元数规避这些问题,等等

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐