绘制三角形
前言:观察上次文章代码与最后绘制的线框图,会发现我们的模型由一个个顶点所组成的三角形构成。那本节自然就引出一个问题,怎么对三角形填充像素,再形象点就是怎么判断一个点在不在三角形中?下面我们带着问题来解决问题
1.如何画一个三角形?
在平面中三个不共线的顶点组成一个三角形,自然我们用上文中我们实现了绘制像素与绘制线段的算法,来绘制一个空心的三角形,只用将三个顶点链接起来即可。
那么接下来问题变成我们怎么填充这个空心三角形?
文章给出通用方法是寻找三个点组成的最大线框,然后从最左下点一直遍历到右上角并判断像素点在不在三角形中。
那这个方法中寻找线框显然不是难点,难点是怎么判断点在不在三角形中。好让我们带着疑问回顾下线性代数中的矩阵运算
下面寻找线框函数
ts
getBoxRange = (p: Point2[]) => {
const boxmin = [this.w, this.h]
const boxmax = [0, 0]
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 2; j++) {
boxmin[0] = Math.min(boxmin[0], p[i].x)
boxmin[1] = Math.min(boxmin[1], p[i].y)
boxmax[0] = Math.max(boxmax[0], p[i].x)
boxmax[1] = Math.max(boxmax[1], p[i].y)
}
}
return { boxmin, boxmax }
}
2. 向量运算与绘制三角形
图形学中大量运用线性代数知识,本文默认读者对该科目有基本认知, 本文就不着重对向量介绍,只会介绍运算对应的几何意义,进行探讨。 首先推荐一个视频能加深线性代数的认知: www.bilibili.com/video/BV1ib...
1.构造向量
模型顶点信息中包含着x,y等坐标点信息,怎么将点转化为二维空间的向量呢?显然将点首尾对应x,y相减就得到对应两点组成的向量。下图为构建三角形三边向量的过程
2.向量叉乘
在三维空间中,两个三维向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> a a </math>a 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> b b </math>b 做叉乘,会得到一个和已知两个向量垂直 的新向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> a × b a×b </math>a×b。 既然叉乘产生的是一个新向量,那么它肯定有个方向,我们一般用右手定则来判断:将右手食指指向 <math xmlns="http://www.w3.org/1998/Math/MathML"> a a </math>a 的方向、中指指向 <math xmlns="http://www.w3.org/1998/Math/MathML"> b b </math>b 的方向,则此时拇指的方向即为 <math xmlns="http://www.w3.org/1998/Math/MathML"> a × b a×b </math>a×b 的方向。
综上所述,我们可以对向量叉乘做一个严谨的定义:
<math xmlns="http://www.w3.org/1998/Math/MathML"> a × b = ∥ a ∥∥ b ∥ s i n ( θ ) n a×b=∥a∥∥b∥sin(θ) n </math>a×b=∥a∥∥b∥sin(θ) n
其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> θ θ </math>θ 表示 <math xmlns="http://www.w3.org/1998/Math/MathML"> a a </math>a 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> b b </math>b 在它们所定义的平面上的夹角 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( 0 ∘ ≤ θ ≤ 18 0 ∘ ) (0^{\circ}≤ θ ≤180^{\circ}) </math>(0∘≤θ≤180∘)。 <math xmlns="http://www.w3.org/1998/Math/MathML"> ∥ a ∥ ∥a∥ </math>∥a∥ 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> ∥ b ∥ ∥b∥ </math>∥b∥ 是向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> a a </math>a 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> b b </math>b 的模长,而 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n 则是一个与 <math xmlns="http://www.w3.org/1998/Math/MathML"> a a </math>a 、 <math xmlns="http://www.w3.org/1998/Math/MathML"> b b </math>b 所构成的平面垂直的单位向量,方向由右手定则决定。
3. 利用三角重心坐标画三角形
求出 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ u v 1 ] [u\quad v\quad 1] </math>[uv1] 后要得到点P的位置要经过P点公式逆向运算的到
3. ts实现向量运算类
这里推荐篇文章: juejin.cn/post/684490...
下面代码只有一点需要注意,向量叉积其实是求行列式对应的代数余子式的过程,二维向量叉乘就对应是一个值,三维对应叉乘为一个向量
ts
class Vector {
components: number[]
constructor(...components: number[]) {
this.components = components
}
toString = () => this.components
add = ({ components }: { components: number[] }) => {
return new Vector(...components.map((component, index) => this.components[index] + component))
}
sub = ({ components }: { components: number[] }) => {
return new Vector(...components.map((component, index) => this.components[index] - component))
}
dot = ({ components }: { components: number[] }) => {
return components.reduce((acc, component, index) => acc + component * this.components[index], 0)
}
length = () => {
return Math.hypot(...this.components)
}
scaleBy = (factor: number) => {
return new Vector(...this.components.map((component) => component * factor))
}
normalize = () => {
const length = this.length()
return this.scaleBy(1 / length)
}
crossProduct2 = ({ components }: { components: number[] }) => {
return this.components[0] * components[1] - this.components[1] * components[0]
}
crossProduct3 = ({ components }: { components: number[] }) => {
return new Vector(
this.components[1] * components[2] - this.components[2] * components[1],
this.components[2] * components[0] - this.components[0] * components[2],
this.components[0] * components[1] - this.components[1] * components[0]
)
}
}
export { Vector }
4.绘制三角形
首先是第一种方式实现的代码与效果
ts
drawTriangle2 = (tPoint: Point2[], color: string) => {
this.color = color
const AB = new Vector(tPoint[1].x - tPoint[0].x, tPoint[1].y - tPoint[0].y)
const BC = new Vector(tPoint[2].x - tPoint[1].x, tPoint[2].y - tPoint[1].y)
const CA = new Vector(tPoint[0].x - tPoint[2].x, tPoint[0].y - tPoint[2].y)
const { boxmin, boxmax } = this.getBoxRange(tPoint)
const p: Point2 = { x: 0, y: 0 }
for (p.x = boxmin[0]; p.x <= boxmax[0]; p.x++) {
for (p.y = boxmin[1]; p.y <= boxmax[1]; p.y++) {
const AP = new Vector(p.x - tPoint[0].x, p.y - tPoint[0].y)
const BP = new Vector(p.x - tPoint[1].x, p.y - tPoint[1].y)
const CP = new Vector(p.x - tPoint[2].x, p.y - tPoint[2].y)
const ABp = AB.crossProduct2(AP)
const BCp = BC.crossProduct2(BP)
const CAp = CA.crossProduct2(CP)
if (ABp >= 0 && BCp >= 0 && CAp >= 0) {
this.drawPixel(p.x, p.y)
}
}
}
}
const testDrawTrangle = () => {
const canvas = document.querySelector('canvas') as HTMLCanvasElement
const render = new Render(canvas)
const A: Point2 = { x: 10, y: 10 }
const B: Point2 = { x: 150, y: 30 }
const C: Point2 = { x: 70, y: 160 }
render.drawTriangle([A, B, C], 'red')
}
三角重心绘制方式
ts
drawTriangle = (tPoint: Point2[], color: string) => {
this.color = color
const { boxmin, boxmax } = this.getBoxRange(tPoint)
const p: Point2 = { x: 0, y: 0 }
for (p.x = boxmin[0]; p.x <= boxmax[0]; p.x++) {
for (p.y = boxmin[1]; p.y <= boxmax[1]; p.y++) {
const x = new Vector(tPoint[1].x - tPoint[0].x, tPoint[2].x - tPoint[0].x, tPoint[0].x - p.x)
const y = new Vector(tPoint[1].y - tPoint[0].y, tPoint[2].y - tPoint[0].y, tPoint[0].y - p.y)
let xy = x.crossProduct3(y)
if (Math.abs(xy.components[2]) < 1) {
xy = new Vector(-1, 1, 1)
}
//这是对向量运算得到对应点p的值
xy = new Vector(
1 - (xy.components[0] + xy.components[1]) / xy.components[2],
xy.components[0] / xy.components[2],
xy.components[1] / xy.components[2]
)
if (xy.components[0] >= 0 && xy.components[1] >= 0 && xy.components[2] >= 0) {
this.drawPixel(p.x, p.y)
}
}
}
}
显然画出的三角形一致
5.绘制模型
1.随机着色
我们对每个不同三角形绘制不同颜色
ts
const init = async () => {
const canvas = document.querySelector('canvas') as HTMLCanvasElement
const render = new Render(canvas)
const { vertices, faceVertices } = await load('./teapot.obj')
for (const item of faceVertices) {
// 遍历三角面片
const pointList: Point2[] = []
for (let i = 0; i < 3; i++) {
// 获取三角面片的三个顶点
const v0 = parseInt(item[i][0]) - 1
const x0 = ((vertices[v0].x + 3) * render.w) / 6
const y0 = ((vertices[v0].y + 1) * render.h) / 6
pointList.push({ x: x0, y: y0 })
}
const color = `rgb(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255}, 255)`
render.drawTriangle(pointList, color)
}
}
2.添加最简单光源
随机着色的好处是可以很清楚的表现出模型各个三角形的轮廓,但是也失去了模型的辨识度,很多细节都丢失了。
我们在这里引入一个非常简单的光照模型,认为单位面积上接收到的光,和平面法线与光照方向的余弦值成正比:
所以着色的思路就很清晰了:
- 我们要先定义一个三维空间里的光照方向(向量) ,然后计算三维空间里各个三角形的法线(向量)
- 两个向量归一化后,然后计算这两个向量的点乘,会得到一个值
- 这个值小于 0,说明光在三角形的另一侧,从物理上看是照射不到三角形表面的,所以直接舍弃此三角形
- 这个值大于 0,值越大 ,说明单位面积上接收到的光越多 ,三角形越亮
生成代码:
ts
const init = async () => {
const canvas = document.querySelector('canvas') as HTMLCanvasElement
const render = new Render(canvas)
const { vertices, faceVertices } = await load('./teapot.obj')
const light = new Vector(0, 0, -1)
for (const item of faceVertices) {
// 遍历三角面片
const pointList: Point2[] = []
const screenCoords: Vector[] = []
const worldCoords: Vector[] = []
for (let i = 0; i < 3; i++) {
// 获取三角面片的三个顶点
const v0 = parseInt(item[i][0]) - 1
const x0 = ((vertices[v0].x + 3) * render.w) / 6
const y0 = ((vertices[v0].y + 1) * render.h) / 6
pointList.push({ x: x0, y: y0 })
screenCoords.push(new Vector(x0, y0, 0))
worldCoords.push(new Vector(vertices[v0].x, vertices[v0].y, vertices[v0].z))
}
const n = worldCoords[2].sub(worldCoords[0]).crossProduct3(worldCoords[1].sub(worldCoords[0])).normalize()
const intensity = n.dot(light)
if (intensity > 0) {
const color = `rgb(${intensity * 255}, ${intensity * 255}, ${intensity * 255}, 255)`
render.drawTriangle(pointList, color)
}
}
}
效果不错,但是茶壶盖子哪里很奇怪,显然是背后的三角形叠加渲染的结果。下一节课就是处理这个问题。