
从‘我的世界’到‘赛博朋克’手把手教你用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 tDO为起点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₀) 0N为法线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); }