前言:本篇博客内容是来源于蛮牛教育上刘仕斌老师的视频课程。为了更深入理解3D变换的过程和光照计算的方式,我这里将使用一个windows窗口应用程序项目来进行模拟。至于windows上创建窗口应用程序的相关知识点,我这里就不过多赘述。
核心要点:本篇博客主要涉及到以下知识点:
1.向量B减去向量A时会得到一个从A指向B的向量。如图所示:
2.向量之间进行叉乘会得到一个垂直于叉乘向量所在的平面的法向量。向量叉乘顺序不同,得到的法向量的垂直朝向也将不同。此时我们可以使用左手法则:
1>.拇指朝上,以起始顶点沿着四指弯曲的方向分别得到两个向量。此时将先后得到的向量进行叉乘时,得到的法向量朝向用户,此时表示叉乘向量所在平面正面朝向用户。如图所示:
2>.拇指朝下,以起始顶点沿着四指弯曲的方向分别得到两个向量。此时将先后得到的向量进行叉乘时,得到的法向量背向用户,此时表示叉乘向量所在平面背面朝向用户。如图所示:
2.光向量指的是从顶点指向光方向的向量。法向量与光向量之间的点积值可以用来描述光照强度。点积值越大表示光照强度越强,此时两向量之间的夹角也就越小。如图所示:
3.视向量指的是从顶点指向摄像机方向的向量。法向量与视向量之间的点积值可以用来描述两向量所在平面的正反面。点积值小于0时,表示平面背面朝向用户。相反就是平面正面朝向用户。而平面背面一般是不进行渲染的,会被gpu进行背面剔除。如图所示:
4.向量之间进行点积或者叉积操作都必须在相同的空间坐标系下进行,否则得到的值所代表的数学含义也就没意义。比如光向量是世界空间坐标系中的向量,而法向量是每个顶点中都存在的向量,在对顶点进行矩阵变换时,法向量也将在不同空间下进行变换。此时想要得到光照强度的话,就必须使用模型到世界的矩阵将法向量转到世界空间坐标系下,再与光向量进行点积操作才能得到正确的光照强度。
5.顶点在模型中的每个分量取值范围是在-0.5~0.5之间。要将一个顶点显示在2d屏幕上,需要将顶点进行一系列的变换。具体流程如下:
1>.使用旋转,缩放,平移等一系列矩阵将顶点坐标从模型空间变换到世界空间下。对应矩阵变换如下图所示:
2d旋转矩阵:顶点每个分量乘以该矩阵来进行变换。
3d旋转矩阵:顶点每个分量乘以该矩阵来进行变换。
2d平移矩阵
3d平移矩阵
2d缩放矩阵
3d缩放矩阵
2>.使用视口矩阵(也就是对顶点进行一个视口反方向上的平移而已)将顶点坐标从世界空间变换到摄像机视口空间下。
3>.使用投影矩阵将顶点坐标从视口空间变换到齐次空间下。
4>.利用小孔成像的原理,使用相似三角形可以得到顶点在投影面上坐标的每个分量值等于原始顶点坐标每个分量值乘以d / z。其中z表示顶点距离摄像机之间的距离,也就是z轴的深度值,d表示摄像机距离投影面之间的距离。由于使用投影矩阵变换到齐次空间后的顶点坐标为(x, y, z, z / d),为了得到正确的投影面上的坐标,此时需要使用透视除法将顶点的每个分量值除以顶点在齐次空间下的w分量值,从而得到顶点坐标为(x * d / z, y * d / z, d, 1)可以在投影平面(也就是2d屏幕)上正常显示了。如图所示:
运行效果:
1.法向量与光向量夹角越小,颜色越白亮,夹角越大,颜色越黑暗。
2.法向量与摄像机视向量小于0时为背面,此时被剔除不会渲染,否则就会渲染。
3.模型经过模型->视图->投影->透视除法变换后,可以在屏幕上正常显示旋转的立方体。
4.效果如图所示:
制作流程:流程如下:
1.新建一个windows窗体应用程序,此处命名为3DTransform。如图所示:
2.新建一个Vector4.cs文件用来封装顶点坐标以及顶点的点积和叉积相关操作。核心代码如下所示:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace _3DTransform
{
class Vector4
{
public double x, y, z, w;
public Vector4()
{
}
public Vector4(double x, double y, double z, double w)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
public Vector4(Vector4 v)
{
x = v.x;
y = v.y;
z = v.z;
w = v.w;
}
public static Vector4 operator-(Vector4 a, Vector4 b)
{
return new Vector4(a.x - b.x, a.y - b.y, a.z - b.z, a.w - b.w);
}
// 向量叉乘:对应分量值=(对应分量的下一个位置的值 * 另外一个分量的下下一个位置的值 - 对应分量的下下一个位置的值 * 另外一个分量的下一个位置的值)
// 如:x = this.y * a.z - this.z * a.y;
public Vector4 Cross(Vector4 a)
{
Vector4 newV = new Vector4();
newV.x = this.y * a.z - this.z * a.y;
newV.y = this.z * a.x - this.x * a.z;
newV.z = this.x * a.y - this.y * a.x;
newV.w = 0; // 不处理w分量
return newV;
}
// 向量点积:对应分量位置相乘并求和
public double Dot(Vector4 a)
{
return this.x * a.x + this.y * a.y + this.z * a.z;
}
// 向量归一化:每个分量除以向量模长,而模长等于每个分量的平方之和来开平方
public Vector4 normalize
{
get
{
double mod = Math.Sqrt(x * x + y * y + z * z + w * w);
return new Vector4(x / mod, y / mod, z / mod, w / mod);
}
}
}
}
3.新建一个Matrix4x4.cs文件用来封装矩阵变换相关操作。核心代码如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace _3DTransform
{
class Matrix4x4
{
private double[,] pts;
public Matrix4x4()
{
// 初始分量值都为0
pts = new double[4,4];
}
public double this[int i, int j]
{
get
{
return pts[i - 1, j - 1];
}
set
{
pts[i - 1, j - 1] = value;
}
}
// 矩阵和矩阵相乘
public Matrix4x4 Mul(Matrix4x4 m)
{
Matrix4x4 newM = new Matrix4x4();
for (int r = 1; r <= 4; ++r)
{
for (int c = 1; c <= 4; c++)
{
for (int n = 1; n <= 4; ++n)
{
newM[r, c] += this[r, n] * m[n,c];
}
}
}
return newM;
}
// 矩阵和向量相乘
public Vector4 Mul(Vector4 v)
{
Vector4 newV = new Vector4();
newV.x = v.x * this[1, 1] + v.y * this[2, 1] + v.z * this[3, 1] + v.w * this[4, 1];
newV.y = v.x * this[1, 2] + v.y * this[2, 2] + v.z * this[3, 2] + v.w * this[4, 2];
newV.z = v.x * this[1, 3] + v.y * this[2, 3] + v.z * this[3, 3] + v.w * this[4, 3];
newV.w = v.x * this[1, 4] + v.y * this[2, 4] + v.z * this[3, 4] + v.w * this[4, 4];
return newV;
}
// 矩阵转置操作,在正交矩阵中等价于逆矩阵
public Matrix4x4 Transpose()
{
Matrix4x4 newM = new Matrix4x4();
for (int row = 1; row <= 4; ++row)
{
for (int col = 1; col <= 4; ++col)
{
newM[row, col] = this[col, row];
}
}
return newM;
}
}
}
4.新建一个Triangle3D.cs文件用来封装三角形面片以及三角形矩阵变换,光照计算,透视除法和图形绘制等相关操作。核心代码如下所示:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Drawing;
using System.Drawing.Drawing2D;
namespace _3DTransform
{
class Triangle3D
{
// 矩阵原始顶点数据:每次的变换都是在该数据上进行的,从而保证变换数据的一致性
public Vector4 A, B, C;
// 变换后的顶点数据
private Vector4 a, b, c;
// 法向量和光照向量的点积值:表达光照强度
private double dot;
// 法向量与摄像机视角向量点积值:表达是否正面
private bool cullBack;
public Triangle3D()
{
}
public Triangle3D(Vector4 a, Vector4 b, Vector4 c)
{
this.A = this.a = new Vector4(a);
this.B = this.b = new Vector4(b);
this.C = this.c = new Vector4(c);
}
// 三角形利用矩阵乘法进行变换
public void Transform(Matrix4x4 m)
{
this.a = m.Mul(this.A);
this.b = m.Mul(this.B);
this.c = m.Mul(this.C);
}
// 计算光照:
// 注意:
// 1.在真实3d引擎中,模型里面的每个顶点是包含法向量的,对顶点的变换也就对法向量进行了变换。这里就不在每个顶点vector4对象里面定义法向量了,直接在三角形里面计算一次法向量。
// 2.法向量和光照向量计算时,必须保证两者在相同的坐标系空间,由于光照向量在世界空间中,所以两者进行光照计算时,最好将法向量也转换到世界空间。
// 3.法向量朝上说明物体正面朝向用户,否则背面朝向用户,gpu会进行背面剔除,所以光照计算时最好获取朝向用户的法向量。此时可以使用左手法则拇指朝上,四指弯曲方向的向量相乘才能得到法向量朝上。
// 4.向量方向:b - a等价于从a指向b的方向。
//
public void CalculateLighting(Matrix4x4 _Object2World, Vector4 light)
{
// 获取世界空间下的归一化法向量
this.Transform(_Object2World);
Vector4 u = this.b - this.a;
Vector4 v = this.c - this.a;
Vector4 normal = u.Cross(v).normalize;
// 获取世界空间下的归一化光照向量
light = light.normalize;
// 对法向量和光照向量进行点积:由于两者是归一化向量,此时点积值等于两个向量间的余弦值。
// 而余弦值的大小表示夹角越小值越大,表示光照强度就越强
dot = normal.Dot(light);
dot = Math.Max(0, dot);
// 定义视角向量:由于摄像机视口变换矩阵只是对顶点进行z轴平移250,所以摄像机在z轴负方向,此时视角向量只有z轴有负值
Vector4 view = new Vector4(0, 0, -1, 0);
// 法向量和视角向量的点积值决定物体正反面。正值表示正面,否则就是反面
cullBack = normal.Dot(view) >= 0 ? false : true;
}
// 绘制三角形
public void Draw(Graphics g, bool isLine)
{
PointF[] arr = new PointF[4];
arr[0] = Get2DPointF(this.a);
arr[1] = Get2DPointF(this.b);
arr[2] = Get2DPointF(this.c);
arr[3] = arr[0];
if (isLine)
{
// 使用画笔画边
Pen pen = new Pen(Color.Red, 2);
g.DrawLines(pen, arr);
}
else
{
// 使用画刷画面
if (!cullBack)
{
GraphicsPath path = new GraphicsPath();
path.AddLines(arr);
int gray = (int)(200 * dot) + 55; // 灰度值设定在55~255范围内
Color col = Color.FromArgb(gray, gray, gray);
Brush br = new SolidBrush(col);
g.FillPath(br, path);
}
}
}
// 获取3D顶点坐标转换成2D屏幕坐标对象:利用透视除法(x,y分别除以w)
private PointF Get2DPointF(Vector4 v)
{
PointF p = new PointF();
p.X = (float)(v.x / v.w);
p.Y = -(float)(v.y / v.w); // 在windows窗体中x轴向右为正,y轴向下为正,而3D坐标中y轴向上为正,所以此处取反值
return p;
}
}
}
5.新建一个Cube.cs文件用来封装立方体以及立方体矩阵变换,光照计算和图形绘制等相关操作。核心代码如下所示:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Drawing;
using System.Drawing.Drawing2D;
namespace _3DTransform
{
class Cube
{
// 定义立方体的8个模型顶点(模型中坐标轴值域为-0.5~0.5)
private Vector4 A = new Vector4(-0.5, 0.5, 0.5, 1);
private Vector4 B = new Vector4(0.5, 0.5, 0.5, 1);
private Vector4 C = new Vector4(0.5, 0.5, -0.5, 1);
private Vector4 D = new Vector4(-0.5, 0.5, -0.5, 1);
private Vector4 E = new Vector4(-0.5, -0.5, 0.5, 1);
private Vector4 F = new Vector4(0.5, -0.5, 0.5, 1);
private Vector4 G = new Vector4(0.5, -0.5, -0.5, 1);
private Vector4 H = new Vector4(-0.5, -0.5, -0.5, 1);
// 定义12个三角形面,依据左手法则判定法线方向:大拇指朝向用户的三角形面正向用户,否则就是背向用户。此时四指弯曲的方向就是顶点向量方向。
private Triangle3D[] triangles = new Triangle3D[12];
public Cube()
{
// 顶部面两个三角形
triangles[0] = new Triangle3D(A, B, C);
triangles[1] = new Triangle3D(A, C, D);
// 底部面两个三角形
triangles[2] = new Triangle3D(E, H, F);
triangles[3] = new Triangle3D(F, H, G);
// 左边面两个三角形
triangles[4] = new Triangle3D(A, D, H);
triangles[5] = new Triangle3D(A, H, E);
// 右边面两个三角形
triangles[6] = new Triangle3D(B, F, C);
triangles[7] = new Triangle3D(C, F, G);
// 前向面两个三角形
triangles[8] = new Triangle3D(D, C, G);
triangles[9] = new Triangle3D(D, G, H);
// 后向面两个三角形
triangles[10] = new Triangle3D(A, E, B);
triangles[11] = new Triangle3D(B, E, F);
}
// 立方体利用矩阵乘法进行变换
public void Transform(Matrix4x4 m)
{
foreach (Triangle3D triangle in triangles)
{
triangle.Transform(m);
}
}
// 计算光照
public void CalculateLighting(Matrix4x4 _Object2World, Vector4 light)
{
foreach (Triangle3D triangle in triangles)
{
triangle.CalculateLighting(_Object2World, light);
}
}
// 绘制立方体
public void Draw(Graphics g, bool isLine)
{
foreach (Triangle3D triangle in triangles)
{
triangle.Draw(g, isLine);
}
}
}
}
6.在Form1设计视图中将窗口大小设置成600x600,如图所示:
然后将窗口背景颜色设置成黑色并打开帧缓冲,如图所示:
最后在设计视图中加入定时器并导入Tick方法,如图所示:
7.在Form1.cs文件中对Cube进行3D变换以及光照计算,并随时间推移而循环变化。核心代码如下所示:
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace _3DTransform
{
public partial class Form1 : Form
{
// 缩放矩阵
private Matrix4x4 m_scale;
// 旋转弧度,每隔一段时间递增2度
private int degrees = 0;
// 旋转矩阵
private Matrix4x4 m_rotation;
// 摄像机视口矩阵
private Matrix4x4 m_view;
// 摄像机投影矩阵
private Matrix4x4 m_projection;
// 立方体对象
private Cube cube;
public Form1()
{
InitializeComponent();
// 默认构造,每个位置值都为0,缩放矩阵需要对应分量位置为缩放值,w分量为1,保证后续的缩放也有效
m_scale = new Matrix4x4();
m_scale[1, 1] = 100;
m_scale[2, 2] = 100;
m_scale[3, 3] = 100;
m_scale[4, 4] = 1;
// 默认构造,每个位置值都为0。具体的旋转矩阵交给定时器中进行设置
m_rotation = new Matrix4x4();
// 构造摄像机视口矩阵,将三角形平移z轴到摄像机可视区域
m_view = new Matrix4x4();
m_view[1, 1] = 1;
m_view[2, 2] = 1;
m_view[3, 3] = 1;
m_view[4, 3] = 250; // z轴平移250,表示摄像机距离物体对象的距离
m_view[4, 4] = 1;
// 构造摄像机投影矩阵,由于我们上面将摄像机移到距离物体对象250的地方,为了等距,我们将摄像机到投影面的距离也设置成250
m_projection = new Matrix4x4();
m_projection[1, 1] = 1;
m_projection[2, 2] = 1;
m_projection[3, 3] = 1;
m_projection[3, 4] = 1.0 / 250; // 1 / d 此处d为250表示摄像机距离投影面的距离
}
private void Form1_Load(object sender, EventArgs e)
{
// 初始化话构建
cube = new Cube();
}
private void Form1_Paint(object sender, PaintEventArgs e)
{
// 绘制前将坐标系平移到屏幕中心点(因为windows屏幕起始点在左上角,且y轴向下为正方向)
e.Graphics.TranslateTransform(300, 300);
cube.Draw(e.Graphics, false);
}
private void timer1_Tick(object sender, EventArgs e)
{
// 设置三角形按照y轴方向每隔定时器时间进行2度旋转
degrees += 2;
// 弧度转换成角度
double angle = degrees / 360.0f * Math.PI;
// 设置y轴旋转矩阵
m_rotation[1, 1] = Math.Cos(angle);
m_rotation[1, 3] = Math.Sin(angle);
m_rotation[2, 2] = 1;
m_rotation[3, 1] = -Math.Sin(angle);
m_rotation[3, 3] = Math.Cos(angle);
m_rotation[4, 4] = 1; // 保证顶点w分量乘以该矩阵时保持不变。
// 旋转是正交矩阵,倒置矩阵等于逆矩阵,而逆矩阵x变化矩阵等于不做变化
//Matrix4x4 m_transpose = m_rotation.Transpose();
//m_rotation = m_rotation.Mul(m_transpose);
// 获取世界视图投影矩阵
Matrix4x4 m_world = m_scale.Mul(m_rotation);
Matrix4x4 m_worldView = m_world.Mul(m_view);
Matrix4x4 m_worldlViewProjection = m_worldView.Mul(m_projection);
// 光照向量:此处设置在右上角
Vector4 light = new Vector4(-1, 1, -1, 0);
// 光照计算
cube.CalculateLighting(m_world, light);
// 重新变换顶点数据到齐次坐标系空间下
cube.Transform(m_worldlViewProjection);
// 失效重绘处理:绘制时会进行透视除法将齐次空间下的3d坐标显示成屏幕2d坐标
this.Invalidate();
}
}
}