图形学
图形学就是在一个二维的平面上展示三维模型.
这里我们用H5的canvas来演示. 我们不会用canvas的任何画图的方法, 只是把他当作一个屏幕来使用.
三维模型
要将三维物体,表现在二维平面上, 首先我们要有一个三维的模型.我们要先知道,如何在一个三维坐标系中构造一个物体的模型.
这个模型是怎么做的呢. 他一般包含一下几点
中心点和旋转角度.
中心点一般用来确定一个模型在三维坐标系中的具体位置. 一般用 x, y, z 表示.中心点移动, 模型上的所有坐标跟着移动. 模型旋转时,绕中心点旋转.
三角形网络 和 顶点.
模型的具体形状是由三角形拼成的. 如图所示
所有的模型都是有一个一个三角形拼成的. 三角形少, 精度就小.三角形多,精度就大. 也就越接近显示中的物体. 以一个正方体为例, 6个面, 一个面需要两个三角形拼成, 一共12个三角形.
三角形的点就是顶点. 一般有很多三角形的顶点是重复的. 立方体一共八个顶点. 代码实践中我们是顶点做一个数组. 三角形做一个数组.
let points = [
-1, 1, -1, // 0
1, 1, -1, // 1
-1, -1, -1, // 2
1, -1, -1, // 3
-1, 1, 1, // 4
1, 1, 1, // 5
-1, -1, 1, // 6
1, -1, 1, // 7
]
let vertices = []
for (let i = 0; i < points.length; i += 3) {
let v = GuaVector.new(points[i], points[i+1], points[i+2])
// let c = GuaColor.randomColor()
let c = GuaColor.red()
vertices.push(GuaVertex.new(v, c))
}
// 12 triangles * 3 vertices each = 36 vertex indices
let indices = [
// 12
[0, 1, 2],
[1, 3, 2],
[1, 7, 3],
[1, 5, 7],
[5, 6, 7],
[5, 4, 6],
[4, 0, 6],
[0, 2, 6],
[0, 4, 5],
[5, 1, 0],
[2, 3, 7],
[2, 7, 6],
]
在上述代码中, points就是顶点的数组, 每三个元素是一组坐标,表示该顶点的x,y,z.
indices是三角形的数组, 第一个元素[0, 1, 2] 表示第一个三角形. 这里的0表示 顶点数组里的第0个顶点.
顶点的属性.
顶点的属性包括,坐标(x, y, z), 颜色(rgba), 法向量(fx, fy, fz), 贴图.
- 坐标就是这个点在三维坐标系中的坐标.
- 颜色就是rgba颜色.
- 法向量就是这个点的垂直方向. 法向量属性这里和我们的数学尝试有些区别. 一个点为什么会有法向量呢?
原因在于我们这里的顶点并不是数学意义上的点. 数学上的点是没有大小,宽高,方向的. 但是我们这里的顶点有. 严格意义上来说 这里的顶点是一个长宽都是1像素的面,是面的最小组成单元 .他有坐标, 有宽高都是1,既然是面当然有法向量. 又因为他足够小.能够近似的看做点. - 贴图. 贴图指的是, 这个模型的颜色不是单一的颜色, 而是一张图. 一般会有一个贴图文件. 贴图文件里保存的是每一个顶点上应该填充的颜色. 顶点的贴图属性里保存的就是这个颜色值在贴图文件中的位置.
视角
视角就是三维空间中我们观察模型时的视线. 视角有三个属性.
- position: 视角的坐标,就是眼睛的坐标
- target: 我们的视角观察的目标的坐标. position和target连起来就是视线
- up: 是position到target的距离.
这三个属性确定一个视角. 视角移动的话, 三维模型的投影也会发生变化.
光照阴影
光照阴影(shading). 在二维坐标系中模拟显示中的物体还要考虑光照问题. 我们看到的颜色是物体反射光的颜色. 光线照射到物体上的角度不同, 物体上光线的强度就不同.
就是有一个光源, 他发射除了光线, 光线照射到 物体上, 由于角度不同导致了, 虽然物体上的颜色相同, 但是看起来颜色有区别, 有高光, 有阴影.比如物体模型是红色, 光线与三角形是垂直关系90°展示正红色, 不是垂直是60°, 是暗红色, 随着角度越来越小,颜色越来越暗. 也就是说点的颜色要根据法向量和光线的夹角来计算.
根据计算方式不同, 会有不同效果.
如下图所示
这是不同的光照模型和不同精度下模型的效果. 从上到下是精度越来越高, 从左到右是 光照的计算越来越精细.
- 左1(a1), (flat shading)是一个三角形用一个法向量, 所以一个平面的光照是一样的.
- 左2(b1),(goraud shading) 是一个顶点一个法向量, 一个三角形就有三个法向量. 其他点用顶点的光照计算插值.
- 左3(c1), (phong shading) 是先 一个顶点一个法向量, 然后,用这三个顶点的法向量 插值计算其他点的法向量, 然后再计算光照. 所以c1即便模型精度很低, 但是也依然有了一个精度相对高的光照.
这是不同光照下的法向量方向.
二维平面
像素
要在二维平面上展示图像, 基础是画点. 一张图片是由像素组成的. 比如 一张分辨率为50*100的png图片. 那么这张图长宽有50个像素, 高有100个像素.像素就是图片最基础的组成单元. 所有的图片都是由像素组成.
像素的属性包括: 坐标x, y表示像素的位置. rgba表示像素的颜色.比如红色用rgba表示就是(255, 0, 0, 255). rgba的每一个值都是0-255. 用二进制表示刚好一个字节的数据量.
以H5中的canvas图片的像素为例.
var c=document.getElementById("myCanvas");
var ctx=c.getContext("2d");
ctx.fillStyle="red";
ctx.fillRect(0, 0, 50, 50);
// 获取canvas上的像素数据. 参数(x, y, w, h)
var imgData=ctx.getImageData(0, 0, 50, 50);
imgData.data[1] = 255
ctx.putImageData(imgData,10,10);
// 使用putImageData之后, 会直接改变canvas上的像素颜色.
这里的imgData就是canvas中的像素数据. 格式如下
{
height: 50,
width: 50,
data: [
255, 0,0, 255,
255, 0,0, 255,
255, 0,0, 255,
......
]
}
这里的imgData表示这张canvas图片, 高50像素, 宽50像素. data里面的数据是从左上角开始,从左到右, 从上到下依次排列. 每4个元素表示该像素的颜色信息. 比如第一个像素的rgba是(255, 0, 0, 255).
一共由 50 * 50 = 2500个像素, 每个像素用4个数据表示颜色. 所以在data里由10000个元素.
所以只要我们有了一张图的像素信息, 就能直接把这张图展示出来.
光栅化
我们现在有了一个三维的立体模型, 屏幕是二维的, 要在一个二维屏幕上展示三维图形, 就需要把三维图形投影到二维平面上, 用像素展示出来, 称为为光栅化.
比如:
上图就是一个立方体在二维平面中的投影. 如果我们转变观察这个立方体的角度, 这个投影的形状也会发生改变. 从三维立体图形, 到二维平面的投影图, 我们可以通过三角函数计算, 获得最终的尺寸. 那么针对一个三维模型, 我们要考虑他的旋转角度, 相对于远点的位移, 观察的视角, 以及投影面. 这些都考虑到后, 我们就能得到一个贴近于真是的投影图.
矩阵
上面说了, 要从三维模型的坐标, 考虑很多条件, 用三角函数来计算出最终投影到平面上的二维坐标. 但是这样一个个计算太过复杂, 一般都是通过矩阵来直接计算的. 矩阵能将三维模型的坐标点, 直接转换成要投影的二维坐标, 常用的矩阵包括:
- 计算旋转角度的旋转矩阵rotation
- 计算模型位移的位移矩阵translation
- 确认观察视角的视角矩阵view
- 把三维坐标转换成二维坐标的投影矩阵projuction
矩阵是跟着模型走的, 一个单独的模型, 比如说房间里的一张床, 一个桌子, 当他移动的时候, 所有的点都跟着移动. 当他旋转的时候, 所有的点都跟着旋转.这个模型有一个中心点和旋转角度来确定他在三维空间的位置, 比如水平旋转了30°, 根据中心点, 和 旋转角度, 一个桌子上的点就能算出旋转后的位置, 旋转矩阵就是做这个地, 位移矩阵就是计算当中心点移动了10个像素后, 其他点的位置. 旋转矩阵和位移矩阵是计算模型在移动后的三维空间的坐标, 视角矩阵是当三维空间的视角变化后, 模型在三维空间的坐标变化. 投影矩阵是将三维坐标转换成二维坐标.
// 视角矩阵
const view = Matrix.lookAtLH(position, target, up)
// 投影矩阵
const projection = Matrix.perspectiveFovLH(0.8, w / h, 0.1, 1)
// 得到 mesh 中点在世界中的坐标
const rotation = Matrix.rotation(mesh.rotation) //旋转矩阵
const translation = Matrix.translation(mesh.position) //位移矩阵
const world = rotation.multiply(translation) // 世界矩阵
// transform就是最后的矩阵.
const transform = world.multiply(view).multiply(projection)
矩阵可以相乘, 相乘的效果与依次使用是一样的.
深度
一个三维模型投影到二维平面, 因为我们看到是投影, 看到正面的话,就不能看到背面. 而且正面和背面虽然在二维平面的坐标是一样的, 但是颜色是不一样的. 那么我们怎么确定具体画哪个点呢?
就用深度.
一个三维的顶点经过矩阵计算后得到的除了x,y二维坐标, 还有一个深度. 这个深度是和视角的距离. 如果和视角的距离近, 那么就展示,如果相对较远, 就不画. 一般来说是保存一个 二维平面上已经画好的点的数组 .这个点的信息就包括他在二维平面上的坐标,颜色,深度. 再画点时, 先判断这个点的坐标是否已经画过了, 如果没有,就画上. 如果画过了, 对比深度.如果当前点的深度更近,就把原来的覆盖掉, 否则不画.
插值
我们的图形是以三角形为基础的, 图像信息都是存储在顶点上的, 顶点上的坐标, 颜色都有了, 但是我们不能只画顶点, 那么两个顶点之间的点的图像信息是怎么得到的呢?
就是用插值计算得到的.
比如说有两个点.
- A点,坐标(10, 10).颜色红色(255, 0, 0, 255)
- B点, 坐标(50, 50). 颜色绿色(0, 255, 0, 255)
现在要问处于这两点之间的C点的颜色和坐标.
首先计算factor, 过渡因子.
首先计算AB的长度.
var x1 = 10, y1 = 10, x2 = 50, y2 = 50
// 长度s
var s = ( (x2 - x1) ** 2 + (y2 - y1) ** 2 ) ** 0.5
// 假设我们要画AB上距离A点10像素的点, 根据点距A的距离占总距离的比例,计算到他的坐标
var factor = 10 / s
var x = factor * (x2 - x1) + x1
var y = factor * (y2 - y1) + y1
颜色的插值计算与坐标一样.
C点是AB的中心点, 那么C的坐标就是(30, 30), 颜色(128, 128, 0, 255).所有的计算中有小数的四舍五入.
画线就是把AB两点之间的所有的点, 根据这个点的所在位置,计算插值, 然后把所有的点都画出来.
画三角形
现在二维平面上的三角形点的信息有了, 根据插值我们能画线了. 那么三角形这个面怎么画呢?
首先我们有三角形的三个顶点A B C按Y轴坐标排列, a.y > b.y > c.y.
对AC边求得M点(m.y == b.y). 三角形就被划分成了AMB, MBC两个三角形.
然后先画AMB.
从上到下, 从A点开始画平行线. 根据距离A点的高度, 算出factor,计算插值, 然后找出AM上的sx点和 AB上的ex点. sx-ex线与MB平行. 这算是在AMB三角形中填了一条线. 然后再用平行线一条一条把整个AMB填满, 从上到下, 如果A距离MB的高度为10, 那就是10条线.
AMB画完了再画MBC.
一个三角形画完了, 把所有的三角形都画完.整个立体图形就都出来了.