JavaScript 实现光线追踪(Ray Tracing):在 Canvas 上模拟光照反射与折射算法

JavaScript 实现光线追踪:在 Canvas 上模拟光照反射与折射算法(讲座模式)

各位同学、开发者朋友们,大家好!今天我们来深入探讨一个既经典又现代的图形学技术——光线追踪(Ray Tracing)。这不仅是渲染领域最强大的方法之一,也是理解真实世界光学现象的绝佳工具。我们将使用纯 JavaScript 和 HTML5 的 <canvas> 元素,在浏览器中实现一个基础但功能完整的光线追踪引擎,重点讲解如何模拟光照反射(Reflection)折射(Refraction)

✅ 本讲座目标:

  • 理解光线追踪的基本原理;
  • 掌握从射线生成到交点检测的核心逻辑;
  • 实现基础材质模型(漫反射 + 镜面反射 + 折射);
  • 使用 Canvas 渲染最终图像;
  • 分析性能瓶颈并提出优化建议。

一、什么是光线追踪?

光线追踪是一种基于物理的渲染技术,它通过模拟光子从摄像机出发,经过场景中的物体,最终到达光源的过程,来计算每个像素的颜色。相比传统的栅格化渲染(如 WebGL 中使用的),光线追踪能更准确地表现阴影、反射、折射等复杂视觉效果。

核心思想

  1. 从摄像机发出射线(Ray) → 每个像素对应一条射线;
  2. 求交(Intersection) → 找出这条射线与场景中所有对象的第一个交点;
  3. 着色(Shading) → 根据材质属性计算该点颜色;
  4. 递归处理反射/折射 → 若材质允许,继续发射新射线;
  5. 累加贡献 → 最终颜色 = 直接光照 + 反射 + 折射贡献。

我们将在下面逐步构建这个流程。


二、环境搭建: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)。

🎯 学习建议:动手修改参数观察效果变化,比如调整 reflectivityrefractiveIndexspecular,你会深刻体会到物理真实感是如何一步步被还原出来的。


💡 最后送一句经典的话给正在阅读的同学:

真正的艺术不是复制世界,而是创造一个你希望存在的世界。” —— 光线追踪正是这样一种工具,让我们能够亲手设计光与影的世界。

祝你在图形学之旅中越走越远!欢迎继续探索更多内容,比如全局光照(Global Illumination)、体积渲染(Volume Rendering)等等。谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注