从‘我的世界’到‘赛博朋克’:手把手教你用Three.js写一个最简单的Whitted光线追踪渲染器
从‘我的世界’到‘赛博朋克’:手把手教你用Three.js写一个最简单的Whitted光线追踪渲染器
在游戏开发的世界里,从像素风格的《我的世界》到光影绚丽的《赛博朋克2077》,光线追踪技术正在彻底改变我们创造虚拟世界的方式。作为一名前端开发者,你可能已经熟悉Three.js这个强大的3D库,但你是否想过抛开WebGL的默认渲染管线,亲手实现一个简化版的光线追踪渲染器?
本文将带你从零开始,在浏览器环境中用JavaScript和Three.js构建一个Whitted-style光线追踪器。不同于传统的理论讲解,我们会通过可运行的代码示例,一步步实现光线生成、场景求交、递归反射等核心功能。最终你将得到一个能渲染镜面反射效果的简易渲染器,虽然性能不足以实时运行,但能让你深入理解现代游戏引擎中光线追踪的工作原理。
1. 环境准备与基础场景搭建
首先创建一个基本的Three.js场景作为我们的"画布"。我们将设置一个包含相机、简单几何体和光源的环境,这是后续实现光线追踪的基础。
// 初始化Three.js基础场景 const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); const renderer = new THREE.WebGLRenderer(); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); // 添加几个测试物体 const sphere1 = new THREE.Mesh( new THREE.SphereGeometry(1, 32, 32), new THREE.MeshBasicMaterial({ color: 0xff0000 }) ); sphere1.position.set(-2, 0, -5); scene.add(sphere1); const sphere2 = new THREE.Mesh( new THREE.SphereGeometry(1, 32, 32), new THREE.MeshBasicMaterial({ color: 0x00ff00 }) ); sphere2.position.set(2, 0, -5); scene.add(sphere2); const plane = new THREE.Mesh( new THREE.PlaneGeometry(10, 10), new THREE.MeshBasicMaterial({ color: 0xaaaaaa }) ); plane.position.set(0, -2, -5); plane.rotation.x = -Math.PI / 2; scene.add(plane); // 添加简单光源(虽然Three.js默认渲染器不使用) const light = new THREE.PointLight(0xffffff, 1, 100); light.position.set(0, 5, 0); scene.add(light); camera.position.set(0, 0, 5);这个基础场景包含两个彩色球体和一个灰色平面,以及一个点光源。注意我们使用的是MeshBasicMaterial,因为它不依赖光照计算,适合作为我们自定义渲染器的输入数据。
2. 光线追踪核心算法原理
Whitted-style光线追踪的核心思想是模拟光线从相机出发,在场景中传播并与物体交互的过程。与光栅化渲染不同,它不是通过投影几何体到屏幕来工作,而是逆向追踪光线路径。
算法主要步骤如下:
- 主光线生成:从相机位置向每个像素发射一条光线
- 场景求交:计算光线与场景中物体的最近交点
- 着色计算:根据交点处的材质属性计算颜色
- 递归反射:对反射/折射光线重复上述过程
- 结果混合:将各次递归的结果按物理规律混合
下面是这个过程的伪代码表示:
function traceRay(ray, depth) { if (depth > MAX_DEPTH) return BACKGROUND_COLOR; const intersection = findClosestIntersection(ray); if (!intersection) return BACKGROUND_COLOR; let color = computeLocalColor(intersection); if (intersection.material.isReflective) { const reflectedRay = computeReflectedRay(ray, intersection); color += traceRay(reflectedRay, depth + 1) * REFLECTION_COEFF; } return color; }3. 实现光线与几何体求交
求交计算是光线追踪中最耗时的部分。我们需要为每种几何体实现特定的求交算法。让我们先从球体开始,因为它相对简单。
球体与光线的交点可以通过解二次方程求得。给定光线方程R(t) = O + tD(O为起点,D为方向)和球体方程|P - C|² = r²(C为球心,r为半径),我们可以推导出:
function intersectSphere(ray, sphere) { const oc = ray.origin.clone().sub(sphere.position); const a = ray.direction.dot(ray.direction); const b = 2 * oc.dot(ray.direction); const c = oc.dot(oc) - sphere.geometry.parameters.radius ** 2; const discriminant = b * b - 4 * a * c; if (discriminant < 0) return null; const t = (-b - Math.sqrt(discriminant)) / (2 * a); if (t < 0) return null; const point = ray.origin.clone().add(ray.direction.clone().multiplyScalar(t)); const normal = point.clone().sub(sphere.position).normalize(); return { t, point, normal, object: sphere }; }对于平面,我们可以使用平面方程N·(P - P₀) = 0(N为法线,P₀为平面上一点)来求交:
function intersectPlane(ray, plane) { const denominator = plane.normal.dot(ray.direction); if (Math.abs(denominator) < 1e-6) return null; const t = plane.point.dot(plane.normal) - ray.origin.dot(plane.normal); const tHit = t / denominator; if (tHit < 0) return null; const point = ray.origin.clone().add(ray.direction.clone().multiplyScalar(tHit)); return { t: tHit, point, normal: plane.normal, object: plane }; }4. 实现Whitted递归光线追踪
现在我们可以将这些部分组合起来实现完整的Whitted光线追踪算法。首先创建一个离屏canvas来存储我们的渲染结果:
const rtCanvas = document.createElement('canvas'); rtCanvas.width = 512; rtCanvas.height = 512; const rtCtx = rtCanvas.getContext('2d'); document.body.appendChild(rtCanvas); // 创建ImageData来直接操作像素 const imageData = rtCtx.createImageData(rtCanvas.width, rtCanvas.height); const data = imageData.data;然后实现主渲染循环:
function render() { const width = rtCanvas.width; const height = rtCanvas.height; // 遍历每个像素 for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { // 将像素坐标转换为NDC [-1, 1] const u = (x / width) * 2 - 1; const v = -((y / height) * 2 - 1); // 创建从相机出发的光线 const ray = { origin: camera.position.clone(), direction: new THREE.Vector3(u, v, -1) .normalize() .applyQuaternion(camera.quaternion) }; // 追踪光线 const color = traceRay(ray, 0); // 设置像素颜色 const idx = (y * width + x) * 4; data[idx] = color.r * 255; data[idx + 1] = color.g * 255; data[idx + 2] = color.b * 255; data[idx + 3] = 255; } } // 更新canvas rtCtx.putImageData(imageData, 0, 0); }traceRay函数的完整实现:
function traceRay(ray, depth) { if (depth > 3) return new THREE.Color(0, 0, 0); // 找到最近的交点 let closestIntersection = null; let minDist = Infinity; scene.traverse(object => { if (!object.isMesh) return; let intersection = null; if (object.geometry.type === 'SphereGeometry') { intersection = intersectSphere(ray, object); } else if (object.geometry.type === 'PlaneGeometry') { intersection = intersectPlane(ray, object); } if (intersection && intersection.t < minDist) { minDist = intersection.t; closestIntersection = intersection; } }); if (!closestIntersection) return new THREE.Color(0.2, 0.2, 0.2); // 基础颜色 const material = closestIntersection.object.material; let color = new THREE.Color(material.color); // 简单阴影计算 const toLight = new THREE.Vector3().subVectors( light.position, closestIntersection.point ).normalize(); const shadowRay = { origin: closestIntersection.point.clone().add( closestIntersection.normal.clone().multiplyScalar(0.001) ), direction: toLight }; let inShadow = false; scene.traverse(object => { if (inShadow || !object.isMesh) return; let intersection = null; if (object.geometry.type === 'SphereGeometry') { intersection = intersectSphere(shadowRay, object); } else if (object.geometry.type === 'PlaneGeometry') { intersection = intersectPlane(shadowRay, object); } if (intersection) { inShadow = true; } }); if (!inShadow) { const diffuse = Math.max( 0, closestIntersection.normal.dot(toLight) ); color.multiplyScalar(0.5 + 0.5 * diffuse); } else { color.multiplyScalar(0.5); } // 递归反射 if (material.reflectivity > 0) { const reflectedDir = ray.direction.clone().reflect( closestIntersection.normal ); const reflectedRay = { origin: closestIntersection.point.clone().add( closestIntersection.normal.clone().multiplyScalar(0.001) ), direction: reflectedDir }; const reflectedColor = traceRay(reflectedRay, depth + 1); color.lerp(reflectedColor, material.reflectivity); } return color; }5. 性能优化与调试技巧
我们的基础实现虽然能工作,但性能非常低下。以下是几个可以显著提升性能的技巧:
1. 空间加速结构
最简单的优化是使用包围盒层次结构(BVH)。我们可以为场景中的所有物体构建一个树状结构,快速排除不可能相交的物体。
class BVHNode { constructor(objects) { this.boundingBox = new THREE.Box3(); this.left = null; this.right = null; this.object = null; if (objects.length === 1) { this.object = objects[0]; this.boundingBox.setFromObject(this.object); } else { // 分割逻辑... } } intersect(ray) { if (!this.boundingBox.intersectsRay(ray)) return null; if (this.object) { return intersectObject(ray, this.object); } const leftHit = this.left.intersect(ray); const rightHit = this.right.intersect(ray); if (!leftHit) return rightHit; if (!rightHit) return leftHit; return leftHit.t < rightHit.t ? leftHit : rightHit; } }2. 多线程渲染
使用Web Worker将渲染任务分配到多个线程:
// 主线程 const workers = []; for (let i = 0; i < navigator.hardwareConcurrency; i++) { const worker = new Worker('render-worker.js'); workers.push(worker); worker.onmessage = (e) => { const { y, rowData } = e.data; for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; data[idx] = rowData[x * 4]; data[idx + 1] = rowData[x * 4 + 1]; data[idx + 2] = rowData[x * 4 + 2]; data[idx + 3] = 255; } if (++completedRows === height) { rtCtx.putImageData(imageData, 0, 0); } }; } // 分配任务 for (let y = 0; y < height; y++) { workers[y % workers.length].postMessage({ y, sceneData: serializeScene(scene), cameraData: serializeCamera(camera), width, height }); }3. 渐进式渲染
先渲染低分辨率图像,然后逐步提高质量:
let sampleCount = 0; const samplesPerFrame = 10; const accumulationBuffer = new Float32Array(width * height * 3).fill(0); function renderFrame() { for (let s = 0; s < samplesPerFrame; s++) { for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { // 随机采样 const u = ((x + Math.random()) / width) * 2 - 1; const v = -((y + Math.random()) / height) * 2 - 1; const ray = generateRay(u, v); const color = traceRay(ray, 0); const idx = (y * width + x) * 3; accumulationBuffer[idx] += color.r; accumulationBuffer[idx + 1] += color.g; accumulationBuffer[idx + 2] += color.b; } } sampleCount++; } // 更新显示 for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = (y * width + x) * 3; const r = Math.sqrt(accumulationBuffer[idx] / sampleCount) * 255; const g = Math.sqrt(accumulationBuffer[idx + 1] / sampleCount) * 255; const b = Math.sqrt(accumulationBuffer[idx + 2] / sampleCount) * 255; const displayIdx = (y * width + x) * 4; data[displayIdx] = r; data[displayIdx + 1] = g; data[displayIdx + 2] = b; data[displayIdx + 3] = 255; } } rtCtx.putImageData(imageData, 0, 0); requestAnimationFrame(renderFrame); }6. 高级效果扩展
有了基础框架后,我们可以添加更多高级效果:
1. 折射效果
function computeRefractedRay(ray, intersection, ior) { const normal = intersection.normal; const cosi = clamp(-ray.direction.dot(normal), -1, 1); let etai = 1, etat = ior; if (cosi < 0) { cosi = -cosi; } else { [etai, etat] = [etat, etai]; normal.negate(); } const eta = etai / etat; const k = 1 - eta * eta * (1 - cosi * cosi); if (k < 0) return null; // 全内反射 return { origin: intersection.point.clone().add( normal.clone().multiplyScalar(-0.001) ), direction: ray.direction .clone() .multiplyScalar(eta) .add( normal.clone().multiplyScalar(eta * cosi - Math.sqrt(k)) ) .normalize() }; }2. 抗锯齿
通过多重采样减少锯齿:
function renderPixel(x, y) { let color = new THREE.Color(); const sampleCount = 4; for (let s = 0; s < sampleCount; s++) { const u = ((x + Math.random()) / width) * 2 - 1; const v = -((y + Math.random()) / height) * 2 - 1; const ray = generateRay(u, v); color.add(traceRay(ray, 0)); } color.multiplyScalar(1 / sampleCount); return color; }3. 景深效果
模拟真实相机光圈:
function generateDepthOfFieldRay(x, y) { // 随机光圈位置 const angle = Math.random() * Math.PI * 2; const radius = Math.random() * apertureSize; const apertureX = Math.cos(angle) * radius; const apertureY = Math.sin(angle) * radius; // 计算焦点平面上的点 const focusPoint = generateRay( (x / width) * 2 - 1, -((y / height) * 2 - 1) ).direction .multiplyScalar(focusDistance) .add(camera.position); // 从光圈位置到焦点的新光线 return { origin: camera.position.clone().add( new THREE.Vector3(apertureX, apertureY, 0) ), direction: focusPoint.clone() .sub(new THREE.Vector3(apertureX, apertureY, 0)) .normalize() }; }7. 调试与可视化技巧
光线追踪调试的一个有效方法是可视化光线路径:
// 在traceRay中添加路径记录 function traceRay(ray, depth, path = []) { path.push({ origin: ray.origin.clone(), direction: ray.direction.clone(), depth }); // ...原有逻辑... if (material.reflectivity > 0) { const reflectedColor = traceRay(reflectedRay, depth + 1, path); color.lerp(reflectedColor, material.reflectivity); } return { color, path }; } // 渲染后绘制光线路径 function visualizePaths(paths) { const pathScene = new THREE.Scene(); const pathCamera = camera.clone(); const pathRenderer = new THREE.WebGLRenderer({ alpha: true }); paths.forEach(path => { const points = []; path.forEach(segment => { points.push(segment.origin); points.push( segment.origin.clone().add( segment.direction.clone().multiplyScalar(5) ) ); }); const geometry = new THREE.BufferGeometry().setFromPoints(points); const material = new THREE.LineBasicMaterial({ color: new THREE.Color().setHSL(Math.random(), 1, 0.5), linewidth: 2 }); const line = new THREE.LineSegments(geometry, material); pathScene.add(line); }); pathRenderer.render(pathScene, pathCamera); // 将路径渲染叠加到主渲染上 const overlay = document.createElement('div'); overlay.appendChild(pathRenderer.domElement); overlay.style.position = 'absolute'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.opacity = '0.5'; document.body.appendChild(overlay); }另一个有用的调试工具是显示不同渲染通道:
// 在traceRay中收集不同信息 function traceRay(ray, depth) { // ... return { color, depth: minDist / 10, // 标准化深度 normal: closestIntersection.normal, albedo: material.color }; } // 选择显示通道 function showChannel(channel) { for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const result = renderPixel(x, y); const idx = (y * width + x) * 4; let displayColor; switch(channel) { case 'color': displayColor = result.color; break; case 'depth': const depth = clamp(result.depth, 0, 1); displayColor = new THREE.Color(depth, depth, depth); break; case 'normal': displayColor = new THREE.Color( result.normal.x * 0.5 + 0.5, result.normal.y * 0.5 + 0.5, result.normal.z * 0.5 + 0.5 ); break; case 'albedo': displayColor = result.albedo; break; } data[idx] = displayColor.r * 255; data[idx + 1] = displayColor.g * 255; data[idx + 2] = displayColor.b * 255; data[idx + 3] = 255; } } rtCtx.putImageData(imageData, 0, 0); }