MyRaster
基于TypeScript、WebCanvas的软光栅实现
Install / Use
/learn @Hyrmm/MyRasterREADME
1、前言
本项目是一个基于TypeScript、浏览器Canvas,完全软光栅器实现。当然现实其实已经有很多非常出色的软光栅的项目,但难于这些项目依赖C++或一些图形库(GLFW)的支撑,学习成本较大,尤其我这样很少接触这些。如果基于浏览器Canvas渲染反馈,JavaScript实现光栅逻辑,基本上不需要配置复杂的环境。而且在调试上也有着巨大的优势,如利用浏览器的Devtools。
当然本项目适用于拥有一定的图形学基础、线代基础,因为在本文后部分,基于此项目会粗略讲解重要实现的部分,所以关于图形学、线代不会提及。但是,此项目也是我本人在入门完图形学(Games101)、以及拜读另一个软光栅项目tinyrender有感而发,用自己擅长的技术栈也去实现一个软光栅,在后面我也会分享一下我的学习路线,以及我的参考文章。
关于上面分别提到了TypeScript和JavaScript,原因是本项目是遵循工程化、模块化标准的一个Web前端项目,所以本质上最好打包后得到还是一个Html文件以及引用了一些JavaScript脚本文件,具体描述参考下方关于项目描述的介绍


2、项目描述
基于TypeScript,ESM模块化标准,最后使用第三方库rollup等周边工具构建最终JavaScript单脚本文件,使用准备模板Html文件引入该脚本文件,当然静态文件Html已提前包含Cavans元素,因为此后渲染反馈载体都使用的是Cavans元素
此外为了提高开发便利性,如观察反馈效果、源码调试,使用nodemon +live-server做热重载刷新,且构建后JavaScript带持有源码TypeScript映射的SourceMap文件
本项目尽可能的不使用第三方库,唯一的模型解析除外,本项目模型文件使用的是.obj格式,所以采用了是webgl-obj-loader第三方库
2.1 启动项目
- npm install 安装依赖
- npm run dev 启动项目
项目启动后,每当有文件变动的时都会触发TypeScipt编译、Rollup构建成单JavaScript脚本文件输出到dist目录,随后打开或刷新浏览器。dist目录下存放的项目生成的静态文件以及一些需要加载的纹理资源
2.1项目依赖
{
"dependencies": {
"webgl-obj-loader": "^2.0.8"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^26.0.1",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-typescript": "^11.1.6",
"concurrently": "^9.0.0",
"live-server": "^1.2.2",
"nodemon": "^3.1.4",
"rollup": "^4.21.0",
"typescript": "^5.5.4"
}
}
2.2项目结构
├── dist //工程化打包后输出的目录,用浏览器打开.html静态文件,即可看到效果
├── src //工程化入库
│ ├── core //核心的模块,如camera、raster、shader等
│ ├── math //数学计算相关模块vector、matrix等
│ ├── model //模型源文件
│ ├── utils //工具函数、相关数据结构等
│ ├── app.ts //主入口
3、项目解析
3.1 渲染载体
渲染的最终目标是视觉反馈,也就是图形显示的载体,在Html中 Canvas元素提供了一种渲染上下文CanvasRenderingContext2D,通过 canvas.getContext("2d")获取。随后将要渲染的帧数据通过context.putImageData(frameData)提交即可显示此次帧数据,frameData是一个ImageData对象,该对象可以理解成是一个w*h长度的数组,数组每连续四位元素记录坐标x,y像素上的的RGBA值。每个元素占用1字节8位,也就是我们常用的纹理格式RGBA8888。
通过new ImageData(width, height)即可得到一个widthXheight的帧数据,ImageData通过数组形式下标访问或修改元素值,如下例,生成的一个 100 * 100 ,颜色为红色的帧数据
具体使用以及详解可在MDN官网查询
const frameData = new ImageData(100, 100)
for (let offset = 0; offset < frameData.data.length; offset += 4) {
const [rIdx, gIdx, bIdx, aIdx] = [offset + 0, offset + 1, offset + 2, offset + 3]
frameData.data[rIdx] = 255
frameData.data[gIdx] = 0
frameData.data[bIdx] = 0
frameData.data[aIdx] = 255
}
const context = canvas.getContext("2d")
context.putImageData(frameData)
3.2 渲染主循环
有了渲染载体,只需要在渲染主循环中变化帧数据,然后每次渲染将该数据提交给渲染上下文即可达到渲染效果。关于渲染主循环实现方式很多计时器、定时器都可以,但是本项目采用的是浏览器提供方法window.requestAnimationFrame,好处在于此方法执行频率可以匹配我们显示器刷新频率,且很方便我们统计当前帧数信息,参考下面代码,位于项目App.ts文件
// src/app.ts
class App {
private static raster: Raster
private static isMouseMoving: boolean = false
public static init(canvas: HTMLCanvasElement) {
const context = canvas.getContext("2d") as CanvasRenderingContext2D
this.raster = new Raster(canvas.width, canvas.height, context)
}
public static start() {
let last = 0
const loop = (timestamp: number) => {
const delt = timestamp - last
document.getElementById("fps")!.innerText = `FPS:${(1000 / delt).toFixed(0)}`
this.mainLoop()
last = timestamp
requestAnimationFrame(loop)
}
loop(0)
}
public static mainLoop() {
this.raster.render()
}
}
通过requestAnimationFrame每帧数执行我们的渲染主循环,执行完此次渲染逻辑后,随机注册下一帧的渲染逻辑,这样保证每帧渲染是连续性,且是有次序的,这也意味着若某一帧渲染耗时太久也会影响下一帧渲染时机,这也是我必须要保证的逻辑
此后,每帧循环执行的Raster的render方法,也是我们渲染的方法,看如下render 的实现:
// src/utils/frameBuffer.ts
export class FrameBuffer {
private data: ImageData
constructor(width: number, height: number) {
this.data = new ImageData(width, height)
}
public get frameData(): ImageData {
return this.data
}
}
// src/core/raster.ts
export class Raster {
private width: number
private height: number
private frameBuffer: FrameBuffer
private context: CanvasRenderingContext2D
constructor(w: number, h: number, context: CanvasRenderingContext2D) {
this.width = w
this.height = h
this.context = context
this.frameBuffer = new FrameBuffer(w, h)
}
public clear() {
for (let offset = 0; offset < this.frameBuffer.frameData.data.length; offset += 4) {
const [rIdx, gIdx, bIdx, aIdx] = [offset + 0, offset + 1, offset + 2, offset + 3]
this.frameBuffer.frameData.data[rIdx] = 0
this.frameBuffer.frameData.data[gIdx] = 0
this.frameBuffer.frameData.data[bIdx] = 0
this.frameBuffer.frameData.data[aIdx] = 255
}
}
public render() {
// 清理帧缓冲区
this.clear()
// 提交帧数据
this.context.putImageData(this.frameBuffer.frameData, 0, 0)
}
}
注意对帧数据用类
FrameBuffer进行包装,方便后续提供一些其他操作方法
如上,Raster每帧在用黑色填充当前帧数据,然后将当前帧数据提交,因为目前在此中间比没有其他操作,所以目前我们看到Cavans一直处于黑色,且页面左上方会事实显示我们当前渲染的帧数
3.3 导入模型
本项目模型使用的是.obj格式的模型文件,模型解析库使用的是
webgl-obj-loader,关于它的一个解析规则可在官方文档了解
目前没有任何东西在渲染,所以我们从导入模型开始,让屏幕能够渲染一些什么东西来。为了便捷我将模型源文件内容直接放入一个模块中,并将其内容作为字符串导出,方便可以对模型的解析,如下:
// src/model/african_head.ts
const fileText = `
v -0.3 0 0.3
v 0.4 0 0
v -0.2 0.3 -0.1
v 0 0.4 0
# 4 vertices
g head
s 1
f 1/1/1 2/1/1 4/1/1
f 1/1/1 2/1/1 3/1/1
f 2/1/1 4/1/1 3/1/1
f 1/1/1 4/1/1 3/1/1
# 4 faces
`
export default fileText
通过webgl-obj-loader库对模型进行解析,如下代码,对redner函数增加了渲染模型顶点的逻辑,以模型三角形顶点数量为循环,以此将模型顶点在帧数据中的像素位置的赋予红色。
注意这里的使用的模型在项目中已提供,位于`/src/model/african_head.ts
// src/utils/frameBuffer.ts
export class FrameBuffer {
// ......
public setPixel(x: number, y: number, rgba: [number, number, number, number]): void {
x = Math.floor(x)
y = Math.floor(y)
if (x >= this.data.width || y >= this.data.height || x < 0 || y < 0) return
this.data.data[((y * this.data.width + x) * 4) + 0] = rgba[0]
this.data.data[((y * this.data.width + x) * 4) + 1] = rgba[1]
this.data.data[((y * this.data.width + x) * 4) + 2] = rgba[2]
this.data.data[((y * this.data.width + x) * 4) + 3] = rgba[3]
}
// ......
}
// src/core/raster.ts
import { Mesh } from "webgl-obj-loader";
import african_head from "../model/african_head";
export class Raster {
constructor(w: number, h: number, context: CanvasRenderingContext2D) {
// .......
this.model = new Mesh(african_head)
this.vertexsBuffer = this.model.vertices
this.trianglseBuffer = this.model.indices
// .......
}
public render() {
// 清理帧缓冲区
this.clear()
// 遍历模型的三角面
for (let i = 0; i < this.trianglseBuffer.length; i += 3) {
for (let j = 0; j < 3; j++) {
const idx = this.trianglseBuffer[i + j]
const vertex = new Vec3(this.vertexsBuffer[idx * 3 + 0], this.vertexsBuffer[idx * 3 + 1], this.vertexsBuffer[idx * 3 + 2])
this.frameBuffer.setPixel(vertex.x,vertex.y,[255,0,0,255])
}
}
// 提交帧数据
this.context.putImageData(this.frameBuffer.frameData, 0, 0)
}
}
当然这样逻辑去渲染的话,最终得出效果肯定是不符合预期的,原因也很明显坐标系的差异,模型、屏幕都有着自己的坐标系,也就是所谓的模型空间、屏幕空间,当然还有一个的世界空间,观察空间,所以下面开始第四部分矩阵变化变化,就包含上述的不同坐标系间的转换。注意,这里模型坐标系以及平屏幕坐标系初始是被固定的
- 屏幕坐标系:依赖的是
Cavans,原点在左上角,范围在0-width,0-height,没有负值 - 模型坐标系:项目中的模型的原点为(0,0,0),x,y,z范围在-1,1,也就是被包含在一个长度为2的立方体中,原点在这个立方体的中心
3.4 矩阵变化
这里的矩阵变化属于是图形学中部分,所以具体理论和推导就不多复述了,以及投影矩阵部分,为了不增加复杂度,后面讲解基于正交投影,当然项目也有透视投影矩阵,可以切换相机类型达到透视投影效果
3.4.1 ModelMatrix
模型矩阵作用将模型空间转换到世界空间,这里我们定义模型的位置就是放在世界坐标系的原点,因为模型的坐标x,y,z在-1,1的立方体中,为了变得显而易见,我们要对模型进行缩放,并且考虑为了方便后续相机的观察,这里将模型Z坐标移动-240,负值是因为本项目基于右手坐标系,相机默认向-z方向看,所以最终得到下面的矩阵
this.modelMatrix = new Matrix44([
[240, 0, 0, 0],
[0, 240, 0, 0],
[0, 0, 240, -240],
[0, 0, 0, 1]
])
3.4.2 ViewMatrix
视图矩阵的目的将世界坐标系转换到相机的观察坐标系,也可以理解统一这俩坐标系,方面后续的计算。因为本项目是基于右手坐标系,所以X 轴叉乘 Y 轴等于+Z轴,Y 轴叉乘 Z 轴等于+X轴。下面是个人对于视图变化的一个理解
原相机:原本和世界坐标系重合的相机
现在相机:原相机经过矩阵变化后等到现在的相机状态,也就是pos,lookAt,up组成的状态
- 视图变化目的就是将世界坐标系和相机坐标做一个统一,方便后面投影计算,因为统一了坐标系,默认将原点作为投影的出发点定义一些平面和参数
- 首先一个常识问题,对一个物体和相机以相同的方向和角度旋转,相机所观察到的画面是不不会变的,以互为相反的方向旋转,相机所观察的画面是我们显示生活中看到的画面
- 想象原相机在世界坐标系下原点位置,在经过旋转、平移等操作后,得到我们现在的相机状态,也就是相机坐标系,vecZ,vecX,vecY
- 由矩阵的本质,相机旋转、平移操作矩阵本质上就是现在相机坐标系的基向量,可以理解为原本和世界坐标系重合的相机经过现在的相机的基向量坐标系进行的矩阵变化
- 理论上我们只要将世界坐标系下的所有点都转换到相机坐标系下,也就是将所有世界左边乘上如今相机的基向量的组成的矩阵,由于相机操作和物体操作时相反的,所以应该是乘上如今相机的基向量的组成的矩阵的逆矩阵
为了方便后续动态的旋转平移,本项目将视图矩阵由初始的视图矩阵和动态变化矩阵组合而成的,如下:
// src/core/camera.ts
export class Camera {
//......
public look(): Matrix44 {
// 通过pos、lookAt、up求求现在相机的基向量
const vecZ = this.pos.sub(this.lookAt).normalize()
const vecX = this.up.cross(vecZ).normalize()
const vecY = vecZ.cross(vecX).normalize()
const revTransMat = new Matrix44([
[1, 0, 0, -this.pos.x],
[0, 1, 0, -this.pos.y],
[0, 0, 1, -this.pos.z],
[0, 0, 0, 1]
])
const revRotationMat = new Matrix44([
[vecX.x, vecX.y, vecX.z, 0],
[vecY.x, vecY.y, vecY.z, 0],
[vecZ.x, vecZ.y, vecZ.z, 0],
[0, 0, 0, 1]
])
// 合成view矩阵,先平移后旋转
return revRotationMat.multiply(revTransMat)
}
public getViewMat(): Matrix44 {
const baseViewMat = this.look()
return this.transMatExc.transpose().multiply(this.rotationMatExc.transpose().multiply(baseViewMat))
}
}
3.4.3 ProjectMatrix
这里讲解投影矩阵基于正交投影
投影矩阵顾名思义就是将被可视的
