⚡
some of us get dipped in flat,
⚡
some in satin,
⚡
some in gloss,
⚡
but every once in a while you find someone who's iridescent,
⚡
and when you do,nothing will ever compare.
💡 Ray Tracing for JS
在介绍光线追踪
之前, 首先需要了解的图形学大致分成了几个方向: 建模
(几何处理,获取),渲染
(使用计算机把一些数据处理,输出成一种可以显示的形式),动画|模拟
(基于物理的运动,各种自然现象),交互
(使用者对模型的拖动和编辑等),而光线追踪正是属于渲染
方向的范畴
渲染按需求可以粗略分成:
再了解一下什么叫全局光照(Global Illumination, 简称GI), 是指既考虑场景中来自光源的直接光照,又考虑经过场景中其他物体反射后的间接光照的一种渲染技术。而光线追踪,路径追踪等同样很酷的概念,都是全局光照中人气较高的算法流派。
光栅化是将一个图元转变为一个二维图像的过程。二维图像上每个点都包含了颜色、深度和纹理数据。将该点和相关信息叫做一个片元(fragment)。简单地说,就是把大量三角形画到屏幕上。当中会采用深度缓冲(depth buffer, z-buffer),来解决多个三角形重叠时的前后问题。三角形数目影响效能,但三角形在屏幕上的总面积才是主要瓶颈。
真实的世界光线是从光源直接照射到我们的眼镜或者经过多次反弹再到达我们的眼镜,当我们试图用计算机模拟的时候我们就会发现这是非常困难的尤其是在大的场景下,一个光源就要经过无数次反射会有无数条光线到达我们的摄像机,计算量极大,理所当然的我们会想到,我们可以减少光线的数量以及反弹的次数啊,确实,但如果我们反过来想,光线不从光源发出而是从摄像机发出,经过多次反弹最终到达光源,我们只需要计算目前我们所看到的画面的光照,而不用考虑不会反射到我们当前的画面的光线,这样我们就极大的减少了计算量。最终光线得到颜色信息,映射在屏幕上成为像素,无数的像素组成一起就渲染成了一张图片了。
在有上面这些了解之后, 因此现在其实我们这次需要做的其实就是用canvas充当一个GPU(),GPU与CPU的关系 来完成这次的渲染工作。让我们开始利用canvas来实现一个光线追踪渲染器吧! 接下来的内容大部分来自于 《Ray Tracing on One Weekend》。
从上面我们知道了其实光线追踪就是对于屏幕上每个像素点颜色的计算,因此,我们首先要解决的就是如何利用canvas绘制像素。经过查阅,很容易的知道对于canvas有如下api(相见MDN - 像素操作 ):
getImageData
putImageData
那么首先让我们先画一个随机色的背景吧~
<!DOCTYPE html>
<html lang="en">
<head>
<title>Demo</title>
<style>#gl-canvas { display: block; margin: 0 auto; }</style>
</head>
<body>
<script src="./main.js" type="module"></script>
</body>
</html>
import { draw } from './draw.js'
const WIDTH = 600,
HEIGHT = 300
const canvas = document.createElement('canvas')
document.body.appendChild(canvas)
canvas.width = WIDTH
canvas.height = HEIGHT
draw(canvas)
export function draw(canvas) {
const { width, height } = canvas
const ctx = canvas.getContext('2d')
const imgData = ctx.getImageData(0, 0, width, height)
// 所有像素点, 一维数组 [r,g,b,a, r1, g1, b1, a1, ....]
const data = imgData.data
for (let j = 0; j < height; j ++) {
for (let i = 0; i < width; i ++) {
let r = i / width
let g = (height - j) / height
let b = 0.2
let ir = 255.99 * r
let ig = 255.99 * g
let ib = 255.99 * b
const from = width * 4 * j + i * 4
data[from] = ir
data[from+1] = ig
data[from+2] = ib
data[from+3] = 255
}
ctx.putImageData(imgData,0,0)
}
console.log(imgData)
}
经过上面三段代码, 我们就可以得到如下图像:
经过第一节, 我们已经知道了如何利用canvas进行像素操作, 那么接下来, 就让我们直接开始光线追踪的真正内容吧!
对于光线追踪, 那么我们首先需要的是一个光线追踪器, 这个光线追踪器的作用就是计算出沿着光线方向,什么颜色被看到了。
从上一节的图来看, 可以把相机的位置想象成我们的眼睛, 而这边的光线再接下来我会直接称为视线
会更加贴切. 由于我们模拟的是三维世界, 因此第一步最关键的就是建立世界的坐标系. 我们将直接使用原书中的坐标系统:
其中坐标(0, 0, 0)
为视点, 矩形框区域
为我们观察图像的屏幕。因此我们根据下图, 可以得到视线公式P(t) = A + t * B
, 其中A为视点, B为视线方向。
这个时候我们如果在屏幕和视点之中有一个球, 其实应该如何将其绘制到屏幕上 ?
我们知道对于光线追踪原理便是对每个点发出一条视点, 根据这条视点沿途计算最终会得到的颜色, 因此, 对于上图我们可以看出从视点发出视点一共会有三种情况:
这个时候问题便转换为了:
如何判断视线与球体是否相交?
x*x + y*y + z*z = R*R
(x-cx)* (x-cx) + (y-cy)*(y-cy) + (z-cz)*(z-cz) = R*R
(P-C)·(P-C) = (x-cx)* (x-cx) + (y-cy)*(y-cy) + (z-cz)*(z-cz)
(P-C)·(P-C)=R*R
P(t) = A + t * B
是否能够撞击该球体t
使得该等式成真 (P(t) - C) · (P(t) - C) = R*R
or (A + t*B - C) · (A + t*B - C) = R*R
t*t*(B · B) + 2*t*dot(B · (A-C)) + dot((A-C) · (A-C)) - R*R = 0
所以在这个最终方程中 未知的是𝑡
,该方程式是二次方的,可以求解 𝑡
并且有一个平方根部分:
function hit(center, radius, r) {
let [origin, _] = r
let oc = minus(origin, center) // A - C
let a = dot(direction(r), direction(r)) // dot(B, B)
let b = 2 * dot(direction(r), oc) // 2 * dot(B, A-C)
let c = dot(oc, oc) - radius * radius // dot(A-C, A-C) - R*R
return (b*b - 4*a*c) > 0
}
function color(r) {
if (hit([0, 0, -1], 0.5, r)) {
return [1, 0, 0]
}
let unitV = unit(direction(r))
let t = 0.5 * (unitV[1] + 1.0)
return add(multiple((1.0 - t), [1, 1, 1]), multiple(t, [.5, .7, 1]))
}
export function draw(canvas) {
const { width, height } = canvas
const ctx = canvas.getContext('2d')
const imgData = ctx.getImageData(0, 0, width, height)
const data = imgData.data
let lowerLeftCorner = [-2.0, -1.0, -1.0]
let horizontal = [4.0, 0.0, 0.0]
let vertical = [0.0, 2.0, 0.0]
let origin = [0.0, 0.0, 0.0]
for (let j = 0; j < height; j ++) {
for (let i = 0; i < width; i ++) {
let u = i / width
let v = (height - j) / height
let r = [origin, add(lowerLeftCorner, multiple(u, horizontal), multiple(v, vertical))]
let c = color(r)
const from = width * 4 * j + i * 4
data[from] = c[0] * 255.99
data[from+1] = c[1] * 255.99
data[from+2] = c[2] * 255.99
data[from+3] = 255
}
ctx.putImageData(imgData,0,0)
}
console.log(imgData)
}
便可以得到如下图像:
上一节我们已经得到了上图中的两颗球, 但是你只要稍微放大一点, 你便可以发现这两个球的边缘有很严重的锯齿, 而要解决这个问题也很简单, 为什么会有锯齿呢, 因为我们是计算颜色的时候步长为1, 但是屏幕上的点应该是有无数多个, 每个点都是会有不一样的颜色,
所以当我们把图像的分辨率调大时, 你会发现锯齿越来越小。我们就会发现所以我们是使用了整数坐标的颜色直接覆盖了周围本来应该是其他颜色的点. 如下图:
我们将某一整数坐标放大来看, 原本我们是直接获取了最中心点的颜色值来覆盖了这一整个方块, 而导致这一方块内其他点本应该是其他颜色, 但却没有话语权直接被顶替了, 所以我们只需要赋予其他点说话的权利, 对于每个整数点相隔了一个单位, 因此我们只需要考虑每个点周围[0, 1)之间随机的像素值(js中可以使用Math.random()
), 这样两个像素的颜色差就不会那么突兀, 可以显得比较平滑.
export function draw(canvas) {
const { width, height } = canvas
const ns = 20
const ctx = canvas.getContext('2d')
const imgData = ctx.getImageData(0, 0, width, height)
const data = imgData.data
let list = [
ball.createHit([0, 0, -1], 0.5),
ball.createHit([0, -100.5, -1], 100)
]
for (let j = 0; j < height; j ++) {
for (let i = 0; i < width; i ++) {
let c = [0, 0, 0]
for (let s = 0; s < ns; s ++) {
let u = (i + Math.random()) / width
let v = ((height - j) + Math.random()) / height
let r = getRay(u, v)
c = add(c, color(r, list))
}
c = divide(c, ns)
const from = width * 4 * j + i * 4
data[from] = c[0] * 255.99
data[from+1] = c[1] * 255.99
data[from+2] = c[2] * 255.99
data[from+3] = 255
}
ctx.putImageData(imgData,0,0)
}
}
可以发现上面代码加入这段之后
for (let s = 0; s < ns; s ++) {
let u = (i + Math.random()) / width
let v = ((height - j) + Math.random()) / height
let r = getRay(u, v)
c = add(c, color(r, list))
}
最终的图像:
上一节我们已经得到了这样的一张图像, 但是你会发现我们观察的不是球吗? 那现在不就只是一个圆吗, 我画个圆需要这么复杂吗, emmmm, 好的, 那这一节就来让我们解决如何来画出一颗球.
首先就是对于一颗球来说, 应该如何区分他的正面与背面?
如果我们可以赋予每一个像素点一个独一无二的特征, 而对于一个球体表面上有什么是可以表示某处是唯一的呢 -- 法线
什么是法线
让我们先画个图:
从图上我们可以看到对于P就是视线向量, C就是球心向量, 因此 P - C
为 P点的外法线, 并且我们只需要的是视线内可以看见的, 也就是不需要下图中的虚线部分:
如何只得到实线
P = A + B * t
t*t*(B · B) + 2*t*dot(B · (A-C)) + dot((A-C) · (A-C)) - R*R = 0
用求根公式求出t
, 便可以得到上图中的实线向量.
// 这边将直接返回 t 的值
function hit(center, radius, r) {
let [origin, _] = r
let oc = minus(origin, center)
let a = dot(direction(r), direction(r))
let b = 2 * dot(direction(r), oc)
let c = dot(oc, oc) - radius * radius
let discriminant = b * b - 4 * a * c
if (discriminant < 0) {
return -1.0
}
return (-b - Math.sqrt(discriminant)) / (2.0 * a)
}
function color(r) {
let C = [0, 0, -1]
let t = hit(C, 0.5, r)
if (t > 0.0) {
let P = pointer(r, t) // 利用 t 计算出 P
let N = unit(minus(P, C))
return multiple(0.5, [N[0] + 1, N[1] + 1, N[2] + 1])
}
let unitV = unit(direction(r))
t = 0.5 * (unitV[1] + 1.0)
return add(multiple((1.0 - t), [1, 1, 1]), multiple(t, [.5, .7, 1]))
}
经过这一节, 得到下图, 是不是有点内味了
在上一节, 我们已经可以成功画出一颗稍微像样一点的球了, 那么接下来我们需要解决的问题(这一节就是一些轻松的东西), 我们上述代码只能添加一颗球, 我们已经在color中写死了球的信息(圆心, 半径)等, 这肯定不行, 那我们首先需要做的就是将代码进行一层封装:
这个文件夹顾名思义将会维护一个个图形, 并且每个图形将会提供 hit
以及 createHit
的方法. hit
将会计算并提供 [t值, 最终计算得到的视线向量, 单位法向量]
import { pointer, direction } from "../ray.js"
import { unit, minus, dot } from "../vec3.js"
export function hit(center, radius, ray, tMin, tMax) {
let [origin, _] = ray
let oc = minus(origin, center)
let a = dot(direction(ray), direction(ray))
let b = 2 * dot(direction(ray), oc)
let c = dot(oc, oc) - radius * radius
let discriminant = b * b - 4 * a * c
if (discriminant > 0) {
let t = (-b - Math.sqrt(discriminant)) / (2.0 * a) // 先选择较小的t 因为离我们的眼睛更近
let p = pointer(ray, t)
if (t < tMax && t > tMin) {
return [t, p, unit(minus(p, center))]
}
t = (-b + Math.sqrt(discriminant)) / (2.0 * a)
if (t < tMax && t > tMin) {
return [t, p, unit(minus(p, center))]
}
}
return null
}
export function createHit(c, radius) {
return (ray, tMin, tMax) => {
return hit(c, radius, ray, tMin, tMax)
}
}
export function draw(canvas) {
const { width, height } = canvas
const ctx = canvas.getContext('2d')
const imgData = ctx.getImageData(0, 0, width, height)
const data = imgData.data
let lowerLeftCorner = [-2.0, -1.0, -1.0]
let horizontal = [4.0, 0.0, 0.0]
let vertical = [0.0, 2.0, 0.0]
let origin = [0.0, 0.0, 0.0]
let list = [
ball.createHit([0, 0, -1], 0.5),
ball.createHit([0, -100.5, -1], 100) // 再添加一个大球在底部
]
for (let j = 0; j < height; j ++) {
for (let i = 0; i < width; i ++) {
let u = i / width
let v = (height - j) / height
let r = [origin, add(lowerLeftCorner, multiple(u, horizontal), multiple(v, vertical))]
let c = color(r, list)
const from = width * 4 * j + i * 4
data[from] = c[0] * 255.99
data[from+1] = c[1] * 255.99
data[from+2] = c[2] * 255.99
data[from+3] = 255
}
ctx.putImageData(imgData,0,0)
}
console.log(imgData)
}
最后, 我们只需要循环list而计算颜色即可
在color
方法中, 有一点需要注意, 对于list[i]调用时传入的第三个参数, 因为当两个球体重叠时, 对于当前重叠的某一点像素我们需要得到的只是前一个球体的颜色, 因此, 我们将有一个 far
参数一开始表示为无穷远, 每当撞击到一个球体, 会将当球体的 t
值设置给 far
, 这样当下一个球体的 t
值比 far
大时在hit方法中将会碰撞失败。
function color(r, list) {
let far = Number.MAX_VALUE
let rec
for (let i = 0; i < list.length; i ++) {
let tempRec = list[i](r, 0.0, far)
if (tempRec) {
far = tempRec[0]
rec = tempRec
}
}
if (rec) {
let N = rec[2]
return multiple(0.5, [N[0] + 1, N[1] + 1, N[2] + 1])
}
let unitV = unit(direction(r))
let t = 0.5 * (unitV[1] + 1.0)
return add(multiple((1.0 - t), [1, 1, 1]), multiple(t, [.5, .7, 1]))
}
好了, 这节的图片如下:
这节开始, 我们将制作一些逼真的材质
引用维基百科的一段话
漫反射
(简称漫射,英语diffuse reflection)是光线照射在物体粗糙的表面会无序地向四周反射的现象。漫反射,是投射在粗糙表面上的光向各个方向反射的现象。当一束平行的入射光线射到粗糙的表面时,表面会把光线向着四面八方反射,所以入射线虽然互相平行,由于各点的法线方向不一致,造成反射光线向不同的方向无规则地反射,这种反射称之为“漫反射”或“ 漫射 ”。这种反射的光称为漫射光。很多物体,如植物、墙壁、衣服等,其表面粗看起来似乎是平滑,但用放大镜仔细观察,就会看到其表面是凹凸不平的,所以本来是平行的太阳光被这些表面反射后,弥漫地射向不同方向。
不发光的漫射物体仅仅呈现其周围的颜色,但是它们用它们自己的固有颜色来调和这些色彩。
从漫反射表面反射的光方向是随机的,比如:如果我们将三条光线发送到一个漫反射表面,它们将各自具有不同的随机行为:
它们也可能被吸收而不是被反射。 表面越暗,光线越可能被吸收。 (这就是为什么它是黑的!)
任何随机化方向的算法都会产生看起来很粗糙的表面。 最简单的方法之一是理想的漫反射表面。
从该图, 对于碰撞点P, 将产生一条随机视线, 因此我们需要求的便是随机视线S1, 从图可以看出, 要求解S1, 首先我们可以得到虚线球的中心 P+N, 我们需要一种在单位半径球体内选择随机点的方法。我们将使用通常最简单的算法:拒绝方法。首先,在单位立方体中选择一个随机点,其中x,y和z的范围都在-1到+1之间。拒绝该点,然后重试该点是否在球体之外。因此 S1 = P + N + [[-1,1], [-1, 1], [-1, 1]]
, 而我们的实现方向为 S1 - P = N + [[-1, 1], [-1, 1], [-1, 1]]
.
其中的[[-1, 1], [-1, 1], [-1, 1]]
实现为:
function random() {
while (true) {
let v = minus(multiple([Math.random(), Math.random(), Math.random()], 2), [1, 1, 1])
if (dot(v, v) >= 1) continue
return v
}
}
直到没有碰撞,为止
而且,光线没经过一次反射强度就会衰减,我们也是这么做的,我们采用的是每反射一次,衰减一半。
function color(r, list, depth) {
let far = Number.MAX_VALUE
let rec
if (depth <= 0)
return [0, 0, 0]
for (let i = 0; i < list.length; i ++) {
let tempRec = list[i](r, 0.0, far)
if (tempRec) {
far = tempRec[0]
rec = tempRec
}
}
if (rec) {
let [_, P, N] = rec
let target = add(P, N, random())
return multiple(0.5, color([P, minus(target, P)], list, --depth))
}
const [_, direction] = r
let unitV = unit(direction)
let t = 0.5 * (unitV[1] + 1.0)
return add(multiple((1.0 - t), [1, 1, 1]), multiple(t, [.5, .7, 1]))
}
效果如下:
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.