用C++手写一个3D立方体投影:从模型变换到视口变换的完整流程

在计算机图形学中,3D物体的渲染过程可以看作是一系列数学变换的串联。本文将带你从零开始,仅使用C++标准库和基础数学知识,实现一个完整的3D立方体投影流程。我们将重点探讨如何通过矩阵变换,将一个3D立方体模型逐步转换为2D屏幕上的投影图像。

1. 理解3D图形渲染管线

3D图形渲染管线是将3D模型转换为2D图像的一系列处理步骤。对于我们的立方体投影项目,主要涉及以下几个关键阶段:

  • 模型变换 :将物体从模型空间转换到世界空间
  • 视图变换 :将物体从世界空间转换到相机空间
  • 投影变换 :将物体从3D相机空间投影到2D标准化设备坐标
  • 视口变换 :将标准化设备坐标映射到屏幕像素坐标

每个阶段都对应一个4x4的变换矩阵,这些矩阵将按顺序相乘,最终形成一个完整的模型-视图-投影矩阵(MVP矩阵)。

2. 实现基础矩阵运算

在开始实现具体变换前,我们需要先构建矩阵运算的基础设施。这里我们定义一个简单的4x4矩阵类:

class Matrix {
public:
    std::vector<std::vector<float>> m;
    
    Matrix(int rows, int cols) : m(rows, std::vector<float>(cols, 0)) {}
    
    static Matrix identity(int size) {
        Matrix result(size, size);
        for (int i = 0; i < size; ++i) {
            result.m[i][i] = 1.0f;
        }
        return result;
    }
    
    Matrix operator*(const Matrix& other) const {
        Matrix result(4, 4);
        for (int i = 0; i < 4; ++i) {
            for (int j = 0; j < 4; ++j) {
                for (int k = 0; k < 4; ++k) {
                    result.m[i][j] += m[i][k] * other.m[k][j];
                }
            }
        }
        return result;
    }
    
    Vec3f operator*(const Vec3f& v) const {
        Vec3f result;
        result.x = m[0][0] * v.x + m[0][1] * v.y + m[0][2] * v.z + m[0][3];
        result.y = m[1][0] * v.x + m[1][1] * v.y + m[1][2] * v.z + m[1][3];
        result.z = m[2][0] * v.x + m[2][1] * v.y + m[2][2] * v.z + m[2][3];
        return result;
    }
};

3. 实现模型变换

模型变换用于将物体从模型空间转换到世界空间。常见的模型变换包括:

  • 平移变换
  • 缩放变换
  • 旋转变换

3.1 平移变换

平移变换将物体沿指定方向移动一定距离:

Matrix translation(Vec3f v) {
    Matrix Tr = Matrix::identity(4);
    Tr.m[0][3] = v.x;
    Tr.m[1][3] = v.y;
    Tr.m[2][3] = v.z;
    return Tr;
}

3.2 缩放变换

缩放变换可以改变物体的大小:

Matrix scale(float factorX, float factorY, float factorZ) {
    Matrix Z = Matrix::identity(4);
    Z.m[0][0] = factorX;
    Z.m[1][1] = factorY;
    Z.m[2][2] = factorZ;
    return Z;
}

3.3 旋转变换

旋转变换可以让物体绕某个轴旋转:

Matrix rotation_x(float angle) {
    angle = angle * PI / 180;
    float sinangle = sin(angle);
    float cosangle = cos(angle);
    Matrix R = Matrix::identity(4);
    R.m[1][1] = R.m[2][2] = cosangle;
    R.m[1][2] = -sinangle;
    R.m[2][1] = sinangle;
    return R;
}

4. 实现视图变换

视图变换将物体从世界空间转换到相机空间。我们需要实现一个 lookat 函数,它接收相机位置、观察点和上向量三个参数:

Matrix lookat(Vec3f eye, Vec3f center, Vec3f up) {
    Vec3f z = (eye - center).normalize();
    Vec3f x = (up.cross(z)).normalize();
    Vec3f y = (z.cross(x)).normalize();
    
    Matrix res = Matrix::identity(4);
    for (int i = 0; i < 3; i++) {
        res.m[0][i] = x[i];
        res.m[1][i] = y[i];
        res.m[2][i] = z[i];
        res.m[i][3] = -center[i];
    }
    return res;
}

5. 实现投影变换

投影变换将3D场景投影到2D平面上。我们将实现透视投影:

Matrix projection(Vec3f eye, Vec3f center) {
    Matrix m = Matrix::identity(4);
    m.m[3][2] = -1.f / (eye - center).norm();
    return m;
}

透视投影的关键在于将z坐标的倒数作为透视因子,这会产生近大远小的效果。

6. 实现视口变换

视口变换将标准化设备坐标映射到屏幕像素坐标:

Matrix viewport(int x, int y, int w, int h, int depth) {
    Matrix m = Matrix::identity(4);
    m.m[0][3] = x + w / 2.f;
    m.m[1][3] = y + h / 2.f;
    m.m[2][3] = depth / 2.f;
    m.m[0][0] = w / 2.f;
    m.m[1][1] = h / 2.f;
    m.m[2][2] = depth / 2.f;
    return m;
}

7. 整合完整渲染流程

现在我们可以将所有变换整合起来,形成一个完整的渲染管线:

int main() {
    // 初始化模型和图像
    Model* model = new Model("cube.obj");
    PNGImage image(width, height, PNGImage::RGBA);
    
    // 定义变换矩阵
    Matrix Model = translation(Vec3f(0, 0, 0)) * scale(0.5, 0.5, 0.5);
    Matrix View = lookat(Vec3f(0, 0, 5), Vec3f(0, 0, 0), Vec3f(0, 1, 0));
    Matrix Projection = projection(Vec3f(0, 0, 5), Vec3f(0, 0, 0));
    Matrix ViewPort = viewport(width/4, height/4, width/2, height/2, 255);
    
    // 组合MVP矩阵
    Matrix MVP = ViewPort * Projection * View * Model;
    
    // 渲染模型
    for (int i = 0; i < model->nfaces(); i++) {
        std::vector<int> face = model->face(i);
        for (int j = 0; j < (int)face.size(); j++) {
            Vec3f v0 = model->vert(face[j]);
            Vec3f v1 = model->vert(face[(j + 1) % face.size()]);
            
            // 应用变换
            Vec3f p0 = MVP * v0;
            Vec3f p1 = MVP * v1;
            
            // 绘制线段
            line(p0, p1, image, green);
        }
    }
    
    // 保存图像
    image.write_png_file("output.png");
    delete model;
    return 0;
}

8. 优化与扩展

虽然我们已经实现了基本功能,但还可以进行一些优化和扩展:

  • 背面剔除 :通过计算面法线可以剔除背对相机的面
  • 深度缓冲 :实现简单的Z缓冲算法来处理深度信息
  • 光照模型 :添加简单的光照计算
  • 纹理映射 :为立方体添加纹理

提示:在实际项目中,矩阵运算通常会使用SIMD指令或专门的数学库进行优化,以提高性能。

通过这个项目,我们不仅理解了3D图形渲染的基本原理,还亲手实现了一个简化版的渲染管线。这种底层实现虽然不如现代图形API高效,但对于理解计算机图形学的基本概念非常有帮助。

更多推荐