练习手写一个光线追踪渲染器
- 非webgl版本: 使用cpu执行,和web worker加速
- webgl版本: 会调用gpu渲染 ,需要使用gpu编程语言shader,大部分流程由webgl来管理
非webgl版本渲染
光线追踪需要准备的对象有这些
- 画布: 用于把场景的物体渲染到目标的位置
- 视口: 用于决定每一条光线的投射方向
- 渲染对象: 场景中的几何体
- 光源: 用于点亮场景的物体
- 相机: 每一条光线的投射起始点
准备完成接下来就可以开始渲染了
大体流程
从相机发射n条光线向场景,光线如果与场景的物体相交,取最近的那个交点 之后从这个点向光源方向连一条线,如果最近的物体就是这个光源(之间没有遮挡物)那么 就可以取与光线相交物体的颜色之后写入到画布,这样完成了画布上一个像素的渲染 之后其他的像素也是一样的方法,只是从相机发出的射线的方向需要变更
详细流程
使用光线追踪追踪每一个像素的颜色
就如同光线追踪名字一样,我们从相机位置开始追踪,之后会追踪到场景中的某个物体, 之后在那个物体上的那个点来追踪光线
定义画布大小和视口
假设现在的画布为320 x 320 大小,为简单计算fov,视口vw和vh分别为2, 视口的平面与xy平面平行并且距离为1,如下图
定义相机的位置和视口
相机在(0,0,0)位置,相机朝向-z方向也就是(0,0,-1),相机向上方向为(0,1,0)
因为画布像素为320 x 320,所以需要把视口水平和垂直都可以各自分为320份 假设我么渲染第i行,j列的位置的像素,那么得到的视口的位置为如下
js
for (var i = 0; i < 320; i++) {
for (var j = 0; j < 320; j++) {
const y_pos = (1 - (i / 320)) * 2 - 1
const x_pos = ((i / 320)) * 2 - 1
}
}
之后就是构造光线的传播方向向量,向量的x和y就是刚刚获取到的在视口平面上的 x_pos 和 y_pos 之后加上一个相机和视口的方向也就是-1,最后归一化这个向量为单位向量, 最后我们就有了光线的定义了
js
const ray_dir = (vec3(x_pos, y_pos, 0) + vec3(0, 0, -1)).normalize()
const camera_pos = vec3(0, 0, 0)
const ray = new ray({camera_pos, ray_dir})
渲染第一个像素
比如渲染左上角的第一个像素
在场景中有一个球,位于世界空间(-5,0,-5)的位置,半径为1.
ini
接下来就是求这个光线和球的交点
光线的定义 P = O + tD
D 为光线方向 o为光线原点 , 在t时候 处于P的位置
球的定义 | P - C | = R
球心c到球上任何一点p的距离为r
t^2 <D,D> + 2t <CO,D> + <CO,CO> - r^2 = 0
人生苦短,公式的证明就不说了,最后需要解这个一元二次方程
可以用求根公式 (-b +- sqrt(b^2 - 4ac)) / 2a
可以看出
a = dot(D,D)
b = 2(dot(CO,D))
c = dot(CO,CO) - R^2
其中
CO = O - C
最后解出a,b和c,不过有多种可能
- 无解 光线没有和这个物体相交,我们给一个默认的颜色,比如黑色
- 一个解 光线正好与球边缘相交,我们需要计算这个物体的颜色
- 两个解 光线与球相交有前后两个交点,我们同样需要计算这个物体的颜色,但是我们应该取t小 的那个点,因为那个点离我们更近,也就是球的正面
定义点光源并着色
得到了交点之后就可以计算这个像素着色了,传统的着色模型有很多,但是都会遵循 渲染方程的定义,目前主要流向的是PBR和NPR,但是这些模型还是很复杂,这里只用一个 简单的版本冯模型
这里选一个常见的组合 ambient + diffuse + specular
首先计算环境光 ambient 什么是环境光,现实世界中房间的角落即便没有收到太阳的光照,但他却不是黑的 因为他接收到了环境中其他物体反射过来的光照,因为光线会多次弹射 但是如果真实的计算这一步是需要使用全局光照技术的,需要非常大的计算量 这里简化为一个常数
之后计算漫反射 diffuse
真实世界的物体接受到了入射光,之后反射出去,最后被眼睛看到的那一部分反射的 能量就是物体的颜色.物体着色点有一个表面的法线,如果光线与法线垂直,那么接受不到任何 能量.如果平行,那么接收到了光线的全部能量
js
const normal_at_shader_point = vec3()
const light_dir = vec3()
const theta = Math.max(0, dot(normal_at_shader_point, light_dir))
// theta 的范围在 [0,1]
const light_color = vec3()
const hit_obj_color = vec3()
const diffuse_color = mul(light_color, hit_obj_color).scale(theta)
最后是高光 specular 高光与我们的观察方向有关,光线到达物体表面后经过一次弹射,最后与我们相机的夹角 决定了我们观察到的高光反射 因为高光反射不是线性的,我们看到的高光只有很小一点 绿色是原本的,蓝色是 theta^20
js
const theta = Math.max(0, dot(reflect(light_dir, normal), view_dir))
const shine = 20
const diffuse_color = mul(light_color, hit_obj_color).scale(theta ** shine)
我们把这三个种光照的颜色加在一起得到的和就是这个点的颜色
模拟光线的多次弹射
考虑到物体不仅仅有亚光物体,还有抛光过的金属这种,他会不仅会接受光源的光照,他还会 接受其他点给与他的反射光照
js
function ray_tracing(ray, deep = 3) {
let color = vec3.ZEOR()
color += calc_light_from_light_source(hit_record, ray)
if (hit_record.hit_obj.material.type === "metal") {
if (deep < 3) {
let dir = reflect(ray.dir, hit_record.hit_point_normal).normalize()
ray_tracing(
createRay({source: hit_record.hit_point, dir}),
deep - 1
)
}
}
return color
}
绘制阴影
我们看到了这个点,但是光源却没能看见这个点,那么这个点就处于阴影中 也就是我们不计算这个点的光照着色,但是需要计算间接光的反射
js
const toLightDir = light.position.subtractTo(hitPoint).normalize();
let withBlock = getNearestIntersection(createRay(hitPoint.clone(), toLightDir), scene);
if (withBlock) {
return
}
把这个颜色写入到到画布上
js
for (let i = 0; i < height; i++) {
for (let j = 0; j < width; j++) {
const x = (j / width) * 2 - 1;
const y = (1 - (i / height)) * 2 - 1;
const ray_direction = vec3(x, y, -1).normalize();
const color = ray_tracing(ray_origin, ray_direction, scene, 3);
setColorAt(j, i, color.toArray().concat(255))
}
}
const setColorAt = (x, y, color = [0, 0, 0, 0]) => {
const start = (imageData.width * y * 4) + (x * 4)
let [r, g, b, a] = color;
imageData.data[start] = r
imageData.data[start + 1] = g
imageData.data[start + 2] = b
imageData.data[start + 3] = a
}