JavaScript 实现光线追踪:在 Canvas 上模拟光照反射与折射算法(讲座模式)
各位同学、开发者朋友们,大家好!今天我们来深入探讨一个既经典又现代的图形学技术——光线追踪(Ray Tracing)。这不仅是渲染领域最强大的方法之一,也是理解真实世界光学现象的绝佳工具。我们将使用纯 JavaScript 和 HTML5 的 <canvas> 元素,在浏览器中实现一个基础但功能完整的光线追踪引擎,重点讲解如何模拟光照反射(Reflection)和折射(Refraction)。
✅ 本讲座目标:
- 理解光线追踪的基本原理;
- 掌握从射线生成到交点检测的核心逻辑;
- 实现基础材质模型(漫反射 + 镜面反射 + 折射);
- 使用 Canvas 渲染最终图像;
- 分析性能瓶颈并提出优化建议。
一、什么是光线追踪?
光线追踪是一种基于物理的渲染技术,它通过模拟光子从摄像机出发,经过场景中的物体,最终到达光源的过程,来计算每个像素的颜色。相比传统的栅格化渲染(如 WebGL 中使用的),光线追踪能更准确地表现阴影、反射、折射等复杂视觉效果。
核心思想
- 从摄像机发出射线(Ray) → 每个像素对应一条射线;
- 求交(Intersection) → 找出这条射线与场景中所有对象的第一个交点;
- 着色(Shading) → 根据材质属性计算该点颜色;
- 递归处理反射/折射 → 若材质允许,继续发射新射线;
- 累加贡献 → 最终颜色 = 直接光照 + 反射 + 折射贡献。
我们将在下面逐步构建这个流程。
二、环境搭建:Canvas + JS 基础结构
首先,创建一个简单的 HTML 页面:
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>JavaScript Ray Tracer</title>
<style>
body { margin: 0; background: #000; }
canvas { display: block; width: 100vw; height: 100vh; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script src="raytracer.js"></script>
</body>
</html>
接下来是 raytracer.js 的骨架代码:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// 设置分辨率
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
// 定义摄像机参数
const camera = {
position: [0, 0, -5],
target: [0, 0, 0],
up: [0, 1, 0],
fov: Math.PI / 3 // 视场角(弧度)
};
// 场景对象数组(球体示例)
const scene = [
{ type: 'sphere', center: [0, 0, -3], radius: 1, color: [1, 0, 0], specular: 0.8, reflectivity: 0.7 },
{ type: 'sphere', center: [-1.5, 0, -4], radius: 0.5, color: [0, 1, 0], specular: 0.9, reflectivity: 0.6 },
{ type: 'sphere', center: [1.5, 0, -4], radius: 0.5, color: [0, 0, 1], specular: 0.5, reflectivity: 0.5 },
{ type: 'plane', normal: [0, 1, 0], distance: -2, color: [0.5, 0.5, 0.5] } // 地板
];
// 光源
const light = {
position: [5, 5, -5],
intensity: 1.0
};
我们现在有了基本结构:摄像机、场景对象、光源。接下来我们要写核心函数:光线生成和求交检测。
三、核心模块一:射线生成与相机投影
每帧渲染时,我们需要为每个像素生成一条从摄像机出发的射线。这一步称为“视口映射”。
function rayFromCamera(camera, x, y, width, height) {
const aspectRatio = width / height;
// 将屏幕坐标归一化到 [-1, 1]
const u = (x / width) * 2 - 1;
const v = (y / height) * 2 - 1;
// 计算方向向量(基于 FOV)
const halfHeight = Math.tan(camera.fov / 2);
const direction = [
u * aspectRatio * halfHeight,
-v * halfHeight,
1
];
// 归一化方向向量
const len = Math.sqrt(direction[0]**2 + direction[1]**2 + direction[2]**2);
direction[0] /= len;
direction[1] /= len;
direction[2] /= len;
// 转换到世界空间(需要摄像机矩阵变换)
const forward = normalize(subtract(camera.target, camera.position));
const right = cross(forward, camera.up);
const up = cross(right, forward);
return {
origin: [...camera.position],
direction: [
direction[0] * right[0] + direction[1] * up[0] + direction[2] * forward[0],
direction[0] * right[1] + direction[1] * up[1] + direction[2] * forward[1],
direction[0] * right[2] + direction[1] * up[2] + direction[2] * forward[2]
]
};
}
这里用到了一些向量运算辅助函数(定义如下):
function add(a, b) { return [a[0]+b[0], a[1]+b[1], a[2]+b[2]]; }
function subtract(a, b) { return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]; }
function dot(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
function cross(a, b) {
return [
a[1]*b[2] - a[2]*b[1],
a[2]*b[0] - a[0]*b[2],
a[0]*b[1] - a[1]*b[0]
];
}
function normalize(v) {
const len = Math.sqrt(v[0]**2 + v[1]**2 + v[2]**2);
return [v[0]/len, v[1]/len, v[2]/len];
}
✅ 这部分实现了将像素坐标转换为世界空间中的射线方向,是整个光线追踪的基础!
四、核心模块二:几何求交检测(Sphere & Plane)
现在我们来写两个最常见的形状的交点判断函数。
1. 球体求交(Sphere Intersection)
对于球体 (x - cx)^2 + (y - cy)^2 + (z - cz)^2 = r^2,我们可以代入射线参数方程:
$$
vec{r}(t) = vec{o} + tvec{d}
$$
代入后得到二次方程:
$$
At^2 + Bt + C = 0
$$
其中:
- $ A = vec{d} cdot vec{d} $
- $ B = 2(vec{d} cdot (vec{o} – vec{c})) $
- $ C = (vec{o} – vec{c}) cdot (vec{o} – vec{c}) – r^2 $
function intersectSphere(ray, sphere) {
const oc = subtract(ray.origin, sphere.center);
const a = dot(ray.direction, ray.direction);
const b = 2 * dot(oc, ray.direction);
const c = dot(oc, oc) - sphere.radius**2;
const discriminant = b*b - 4*a*c;
if (discriminant < 0) return null;
const t1 = (-b - Math.sqrt(discriminant)) / (2*a);
const t2 = (-b + Math.sqrt(discriminant)) / (2*a);
const t = Math.min(t1, t2);
if (t > 0.001) return t; // 忽略太近的交点(避免浮点误差)
return null;
}
2. 平面求交(Plane Intersection)
平面公式:$ vec{n} cdot vec{p} = d $,其中 $vec{n}$ 是法向量,$d$ 是距离原点的距离。
射线代入得:
$$
t = frac{d – vec{n} cdot vec{o}}{vec{n} cdot vec{d}}
$$
function intersectPlane(ray, plane) {
const denom = dot(plane.normal, ray.direction);
if (Math.abs(denom) < 0.0001) return null; // 平行或重合
const t = (plane.distance - dot(plane.normal, ray.origin)) / denom;
if (t > 0.001) return t;
return null;
}
这两个函数返回最近的有效交点距离 t,用于后续着色计算。
五、核心模块三:着色与光照模型(含反射 & 折射)
这是整个系统最复杂的部分。我们采用 Phong 光照模型,并扩展支持反射和折射。
function shade(ray, hitPoint, normal, obj, depth = 0) {
if (depth > 5) return [0, 0, 0]; // 递归深度限制
const color = [...obj.color];
const diffuse = 0.7;
const specular = obj.specular || 0;
const reflectivity = obj.reflectivity || 0;
// 计算直接光照(Lambertian Diffuse + Phong Specular)
const lightDir = normalize(subtract(light.position, hitPoint));
const NdotL = Math.max(0, dot(normal, lightDir));
let result = [0, 0, 0];
if (NdotL > 0) {
// 漫反射
result = multiply(color, diffuse * NdotL);
// 镜面高光(Phong模型)
const viewDir = normalize(subtract(ray.origin, hitPoint));
const reflectDir = reflect(lightDir, normal);
const RdotV = Math.max(0, dot(reflectDir, viewDir));
result = add(result, multiply([1, 1, 1], specular * RdotV ** 16));
}
// 反射(如果材质有反射)
if (reflectivity > 0) {
const reflectedRay = {
origin: hitPoint,
direction: reflect(ray.direction, normal)
};
const reflColor = shade(reflectedRay, hitPoint, normal, obj, depth + 1);
result = add(result, multiply(reflColor, reflectivity));
}
// 折射(简单 Snell’s Law)
if (obj.refractiveIndex !== undefined && reflectivity === 0) {
const eta = 1.0 / obj.refractiveIndex; // 假设外部介质为空气(n=1)
const cosI = -dot(ray.direction, normal); // 入射角余弦
const sinT2 = eta * eta * (1 - cosI * cosI);
if (sinT2 <= 1) {
const cosT = Math.sqrt(1 - sinT2);
const refractDir = add(
multiply(ray.direction, eta),
multiply(normal, eta * cosI - cosT)
);
const refractedRay = {
origin: hitPoint,
direction: normalize(refractDir)
};
const refracColor = shade(refractedRay, hitPoint, normal, obj, depth + 1);
result = add(result, multiply(refracColor, 0.5)); // 折射强度调低一点
}
}
return result;
}
📌 注意事项:
reflect()函数需预先定义(见下方);- 我们设置了最大递归深度防止无限循环;
- 折射部分简化处理(未考虑全内反射等情况);
辅助函数:反射向量计算(Snell’s Law 之外)
function reflect(v, n) {
const dotProduct = dot(v, n);
return add(v, multiply(n, -2 * dotProduct));
}
六、主渲染循环:遍历像素 + 调用 shade()
最后一步就是把所有东西串起来:
function render() {
const width = canvas.width;
const height = canvas.height;
const imageData = ctx.createImageData(width, height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const ray = rayFromCamera(camera, x, y, width, height);
let closest = Infinity;
let closestObj = null;
let closestHitPoint = null;
// 寻找最近交点
for (const obj of scene) {
let t = null;
if (obj.type === 'sphere') {
t = intersectSphere(ray, obj);
} else if (obj.type === 'plane') {
t = intersectPlane(ray, obj);
}
if (t !== null && t < closest) {
closest = t;
closestObj = obj;
closestHitPoint = add(ray.origin, multiply(ray.direction, t));
}
}
let color = [0, 0, 0];
if (closestObj) {
const normal = getNormal(closestObj, closestHitPoint);
color = shade(ray, closestHitPoint, normal, closestObj);
}
// 写入像素数据
const idx = (y * width + x) * 4;
imageData.data[idx] = Math.min(255, Math.floor(color[0] * 255));
imageData.data[idx + 1] = Math.min(255, Math.floor(color[1] * 255));
imageData.data[idx + 2] = Math.min(255, Math.floor(color[2] * 255));
imageData.data[idx + 3] = 255; // alpha
}
}
ctx.putImageData(imageData, 0, 0);
}
// 获取表面法向量(根据对象类型)
function getNormal(obj, point) {
if (obj.type === 'sphere') {
return normalize(subtract(point, obj.center));
} else if (obj.type === 'plane') {
return [...obj.normal];
}
return [0, 0, 1];
}
// 向量乘法(标量)
function multiply(v, s) {
return [v[0]*s, v[1]*s, v[2]*s];
}
// 向量加法
function add(a, b) { return [a[0]+b[0], a[1]+b[1], a[2]+b[2]]; }
// 开始渲染
render();
七、性能分析与优化建议(重要!)
虽然这段代码可以运行,但在现代浏览器中可能较慢(尤其对高分辨率)。以下是常见问题及改进方向:
| 问题 | 描述 | 解决方案 |
|---|---|---|
| 逐像素计算慢 | 每帧都要遍历数百万像素 | 使用 Web Workers 多线程分块处理 |
| 递归深度爆炸 | 反射/折射嵌套太多导致卡顿 | 添加最大递归层数限制(已做) |
| 无加速结构 | 每次都扫描所有物体 | 引入 BVH 或网格空间划分(BSP) |
| 未缓存结果 | 相同射线重复计算 | 加入 Ray Cache(适合静态场景) |
✅ 建议进阶学习路径:
- 使用
requestAnimationFrame替代同步阻塞; - 引入多线程(Web Worker)进行像素分块渲染;
- 实现 BVH(Bounding Volume Hierarchy)加速求交;
- 支持纹理贴图、阴影投射(Shadow Mapping);
- 加入抗锯齿(AA)、焦散(Caustics)等高级特性。
八、总结与展望
今天我们从零开始构建了一个可工作的 JavaScript 光线追踪器,它能在 Canvas 上正确模拟:
- 漫反射光照;
- 镜面反射;
- 折射(Snell’s Law);
- 多层递归交互。
虽然目前版本只能渲染静态场景,但它已经具备了专业级渲染引擎的雏形!
未来你可以:
- 扩展更多几何体(立方体、圆柱、三角形);
- 加入动态光源、动画物体;
- 使用 GPU 加速(WebGL + GLSL Shader);
- 构建完整渲染管线(如 Path Tracing)。
🎯 学习建议:动手修改参数观察效果变化,比如调整 reflectivity、refractiveIndex、specular,你会深刻体会到物理真实感是如何一步步被还原出来的。
💡 最后送一句经典的话给正在阅读的同学:
“真正的艺术不是复制世界,而是创造一个你希望存在的世界。” —— 光线追踪正是这样一种工具,让我们能够亲手设计光与影的世界。
祝你在图形学之旅中越走越远!欢迎继续探索更多内容,比如全局光照(Global Illumination)、体积渲染(Volume Rendering)等等。谢谢大家!