## 用JavaScript玩转计算机图形学(一)光线追踪入门

2010-03-29 00:05 by Milo Yip, ... 阅读, ... 评论, 收藏, 编辑

## 初试画板

<canvas width="256" height="256" id="testCanvas"></canvas>


#### 修改代码试试看

• 把第三个pixels[i++] = 0 改为255 (即蓝色全开)
• 把第四个pixels[i++] = 255 改为128 (alpha=128)
• 可以只修改两个for循环里面的代码，画一个国际象棋棋盘么?

## 基础类

### 三维向量

Vector3 = function(x, y, z) { this.x = x; this.y = y; this.z = z; };

Vector3.prototype = {
copy : function() { return new Vector3(this.x, this.y, this.z); },
length : function() { return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); },
sqrLength : function() { return this.x * this.x + this.y * this.y + this.z * this.z; },
normalize : function() { var inv = 1/this.length(); return new Vector3(this.x * inv, this.y * inv, this.z * inv); },
negate : function() { return new Vector3(-this.x, -this.y, -this.z); },
add : function(v) { return new Vector3(this.x + v.x, this.y + v.y, this.z + v.z); },
subtract : function(v) { return new Vector3(this.x - v.x, this.y - v.y, this.z - v.z); },
multiply : function(f) { return new Vector3(this.x * f, this.y * f, this.z * f); },
divide : function(f) { var invf = 1/f; return new Vector3(this.x * invf, this.y * invf, this.z * invf); },
dot : function(v) { return this.x * v.x + this.y * v.y + this.z * v.z; },
cross : function(v) { return new Vector3(-this.z * v.y + this.y * v.z, this.z * v.x - this.x * v.z, -this.y * v.x + this.x * v.y); }
};

Vector3.zero = new Vector3(0, 0, 0);


Vector3.zero用作常量，避免每次重新构建。值得一提，这些常量必需在prototype设定之后才能定义。

### 光线

\mathbf{r}(t) = \mathbf{o} + t\mathbf{d}, t \geq 0

Ray3 = function(origin, direction) { this.origin = origin; this.direction = direction; }

Ray3.prototype = {
getPoint : function(t) { return this.origin.add(this.direction.multiply(t)); }
};


### 球体

\left \| \mathbf{x} - \mathbf{c} \right \| = r

\begin{align*} \left\| \mathbf{v} +t\mathbf{d} \right\| &= r \\ \left\| \mathbf{v} +t\mathbf{d} \right\|^2 &= r^2 \\ (\mathbf{v}+t\mathbf{d}) \cdot (\mathbf{v}+t\mathbf{d}) - r^2 &= 0 \\ \mathbf{v}^2 + 2t(\mathbf{d} \cdot \mathbf{v}) +t^2 \mathbf{d}^2 - r^2 &= 0 \\ (\mathbf{d}^2)t^2 + (2\mathbf{d} \cdot \mathbf{v})t + (\mathbf{v}^2 - r^2) &= 0 \end{align*}

\begin{align*} t&=\frac{-2\mathbf{d} \cdot \mathbf{v}\pm \sqrt{(2\mathbf{d} \cdot \mathbf{v})^2 - 4(\mathbf{v}^ 2 - r^2)} }{2} \\ &= -\mathbf{d} \cdot \mathbf{v}\pm \sqrt{(\mathbf{d} \cdot \mathbf{v})^2 - (\mathbf {v}^2 - r^2)} \end{align*}

Sphere = function(center, radius) { this.center = center; this.radius = radius; };

Sphere.prototype = {
copy : function() { return new Sphere(this.center.copy(), this.radius.copy()); },

initialize : function() {
},

intersect : function(ray) {
var v = ray.origin.subtract(this.center);
var a0 = v.sqrLength() - this.sqrRadius;
var DdotV = ray.direction.dot(v);

if (DdotV <= 0) {
var discr = DdotV * DdotV - a0;
if (discr >= 0) {
var result = new IntersectResult();
result.geometry = this;
result.distance = -DdotV - Math.sqrt(discr);
result.position = ray.getPoint(result.distance);
result.normal = result.position.subtract(this.center).normalize();
return result;
}
}

return IntersectResult.noHit;
}
};


IntersectResult = function() {
this.geometry = null;
this.distance = 0;
this.position = Vector3.zero;
this.normal = Vector3.zero;
};

IntersectResult.noHit = new IntersectResult();


## 摄影机

### 透视摄影机

\tan \frac{FOV}{2} = s

PerspectiveCamera = function(eye, front, up, fov) { this.eye = eye; this.front = front; this.refUp = up; this.fov = fov; };

PerspectiveCamera.prototype = {
initialize : function() {
this.right = this.front.cross(this.refUp);
this.up = this.right.cross(this.front);
this.fovScale = Math.tan(this.fov * 0.5 * Math.PI / 180) * 2;
},

generateRay : function(x, y) {
var r = this.right.multiply((x - 0.5) * this.fovScale);
var u = this.up.multiply((y - 0.5) * this.fovScale);
}
};


## 渲染测试

### 渲染深度

// renderDepth.htm
function renderDepth(canvas, scene, camera, maxDepth) {
// 从canvas取得imgdata和pixels，跟之前的代码一样
// ...

scene.initialize();
camera.initialize();

var i = 0;
for (var y = 0; y < h; y++) {
var sy = 1 - y / h;
for (var x = 0; x < w; x++) {
var sx = x / w;
var ray = camera.generateRay(sx, sy);
var result = scene.intersect(ray);
if (result.geometry) {
var depth = 255 - Math.min((result.distance / maxDepth) * 255, 255);
pixels[i    ] = depth;
pixels[i + 1] = depth;
pixels[i + 2] = depth;
pixels[i + 3] = 255;
}
i += 4;
}
}

ctx.putImageData(imgdata, 0, 0);
}

#### 修改代码试试看

• 改变球体的位置
• 改变球体的半径
• 改变fov(PerspectiveCamera最后的参数)
• 改变maxDepth(renderDepth最后的参数)
• 改变摄影机的方向，例如向左转一点点(记得要是单位向量啊!可以用new Vector(...).normalize())

### 渲染法向量

// renderNormal.htm
function renderNormal(canvas, scene, camera) {
// ...
if (result.geometry) {
pixels[i    ] = (result.normal.x + 1) * 128;
pixels[i + 1] = (result.normal.y + 1) * 128;
pixels[i + 2] = (result.normal.z + 1) * 128;
pixels[i + 3] = 255;
}
// ...
}


• 从球体的正上方往下看

## 材质

### 颜色

Color = function(r, g, b) { this.r = r; this.g = g; this.b = b };

Color.prototype = {
copy : function() { return new Color(this.r, this.g, this.b); },
add : function(c) { return new Color(this.r + c.r, this.g + c.g, this.b + c.b); },
multiply : function(s) { return new Color(this.r * s, this.g * s, this.b * s); },
modulate : function(c) { return new Color(this.r * c.r, this.g * c.g, this.b * c.b); }
};

Color.black = new Color(0, 0, 0);
Color.white = new Color(1, 1, 1);
Color.red = new Color(1, 0, 0);
Color.green = new Color(0, 1, 0);
Color.blue = new Color(0, 0, 1);


### 格子材质

CG世界里，国际象棋棋盘是最常见的测试用纹理(texture)。这里不考虑纹理贴图(texture mapping)的问题，只凭(x, z)坐标计算某位置发出黑色或白色的光(黑色的光不叫光吧，哈哈)。

CheckerMaterial = function(scale, reflectiveness) { this.scale = scale; this.reflectiveness = reflectiveness; };

CheckerMaterial.prototype = {
sample : function(ray, position, normal) {
return Math.abs((Math.floor(position.x * 0.1) + Math.floor(position.z * this.scale)) % 2) < 1 ? Color.black : Color.white;
}
};


### Phong材质

PhongMaterial = function(diffuse, specular, shininess, reflectiveness) {
this.diffuse = diffuse;
this.specular = specular;
this.shininess = shininess;
this.reflectiveness = reflectiveness;
};

// global temp
var lightDir = new Vector3(1, 1, 1).normalize();
var lightColor = Color.white;

PhongMaterial.prototype = {
sample: function(ray, position, normal) {
var NdotL = normal.dot(lightDir);
var H = (lightDir.subtract(ray.direction)).normalize();
var NdotH = normal.dot(H);
var diffuseTerm = this.diffuse.multiply(Math.max(NdotL, 0));
var specularTerm = this.specular.multiply(Math.pow(Math.max(NdotH, 0), this.shininess));
}
};


Phong的内容不在此述。

### 渲染材质

// rayTrace.htm
function rayTrace(canvas, scene, camera) {
// ...
if (result.geometry) {
var color = result.geometry.material.sample(ray, result.position, result.normal);
pixels[i] = color.r * 255;
pixels[i + 1] = color.g * 255;
pixels[i + 2] = color.b * 255;
pixels[i + 3] = 255;
}
// ...
}


#### 修改代码试试看

• 改变fov，有了格子地板效果应该很明显
• 改变CheckerMaterial的scale
• 把原来红色的球改为绿色
• 把原来红色的球改为黄色
• 改变shininess(PhongMaterial最后一个参数)

## 多个几何物件

### 平面

\mathbf{n} \cdot \mathbf{x} = d

n为平面的法向量，d为空间原点至平面的最短距离。光线和平面的相交计算很简单，这里不详述了。

Plane = function(normal, d) { this.normal = normal; this.d = d; };

Plane.prototype = {
copy : function() { return new plane(this.normal.copy(), this.d); },

initialize : function() {
this.position = this.normal.multiply(this.d);
},

intersect : function(ray) {
var a = ray.direction.dot(this.normal);
if (a >= 0)
return IntersectResult.noHit;

var b = this.normal.dot(ray.origin.subtract(this.position));
var result = new IntersectResult();
result.geometry = this;
result.distance = -b / a;
result.position = ray.getPoint(result.distance);
result.normal = this.normal;
return result;
}
};


### 并集

Union = function(geometries) { this.geometries = geometries; };

Union.prototype = {
initialize: function() {
for (var i in this.geometries)
this.geometries[i].initialize();
},

intersect: function(ray) {
var minDistance = Infinity;
var minResult = IntersectResult.noHit;
for (var i in this.geometries) {
var result = this.geometries[i].intersect(ray);
if (result.geometry && result.distance < minDistance) {
minDistance = result.distance;
minResult = result;
}
}
return minResult;
}
};


## 反射

\mathbf{r} = \mathbf{d} - 2(\mathbf{d \cdot n})\bf{n}"

function rayTraceRecursive(scene, ray, maxReflect) {
var result = scene.intersect(ray);

if (result.geometry) {
var reflectiveness = result.geometry.material.reflectiveness;
var color = result.geometry.material.sample(ray, result.position, result.normal);
color = color.multiply(1 - reflectiveness);

if (reflectiveness > 0 && maxReflect > 0) {
var r = result.normal.multiply(-2 * result.normal.dot(ray.direction)).add(ray.direction);
ray = new Ray3(result.position, r);
var reflectedColor = rayTraceRecursive(scene, ray, maxReflect - 1);
}
return color;
}
else
return Color.black;
}

function rayTraceReflection(canvas, scene, camera, maxReflect) {
// 从canvas取得imgdata和pixels，跟之前的代码一样
// ...

scene.initialize();
camera.initialize();

var i = 0;
for (var y = 0; y < h; y++) {
var sy = 1 - y / h;
for (var x = 0; x < w; x++) {
var sx = x / w;
var ray = camera.generateRay(sx, sy);
var color = rayTraceRecursive(scene, ray, maxReflect);
pixels[i++] = color.r * 255;
pixels[i++] = color.g * 255;
pixels[i++] = color.b * 255;
pixels[i++] = 255;
}
}

ctx.putImageData(imgdata, 0, 0);
}


#### 修改代码试试看

• 改变一个球的reflectiveness，试试0、1及之间的数值
• 改变maxReflect(rayTraceReflection最后一个参数)
• 加入更多的球体(可用for循环啊……不过小心渲染时间太长)

## 结语

• 其他几何图形(长方体、柱体、三角形、曲面、高度场、等值面、……)
• 光源(方向光源、点光源、聚光灯、阴影、ambient occlusion)
• 材质(Phong-Blinn、Oren-Nayar、Torrance-Sparrow、折射、 Fresnel、BRDF、BSDF……)
• 纹理(纹理座标、采样、Perlin noise)
• 摄影机模型(正投射、全景、景深)
• 成像流程(渐进渲染、反锯齿、后期处理)
• 优化方法(场景剖分、低阶优化)
• 其他全局光照渲染方法

## 更新

• 2010年3月31日，网友HouSisong把本文代码以C++实现，并完全保留了原设计，代码可於他的博文下载。