3个Bug教你手写实现望远镜成像原理,告别复制粘贴
刚拿到一份“望远镜成像原理”的可视化代码,复制进VS Code一跑,全是乱码或者白屏。想改参数?根本找不到入口。这种“复制来的代码跑不通不知道怎么调”的挫败感,我当年在掘金技术社区看帖时深有体会。很多教程只给结果,不给过程,导致你连个简单的折射公式都调不对。今天咱们不整虚的,直接手写实现一套基于WebGL的光学模拟系统。哪怕你是前端新手,跟着敲完这两百行代码,也能彻底搞懂光线是怎么从物镜走到目镜,再进入你眼睛里的。
概念速懂:光路不是画出来的,是算出来的
很多人以为望远镜成像就是画几条线,其实不然。在计算机图形学里,我们用的是光线追踪的简化版——前向追踪。
想象一下,光线从远处的星星出发,穿过物镜(凸透镜),发生第一次折射,汇聚到焦点。但光不会停,它会继续传播,穿过目镜(另一个凸透镜),再次折射,最终变成平行光射入人眼。
这里有个核心痛点:焦距怎么定?
传统物理题里,焦距是给定的。但在代码里,我们需要用斯涅尔定律(Snell's Law)来模拟折射。对于薄透镜,我们通常用一个简化公式:
\(\frac{1}{f} = (n - 1) \left( \frac{1}{R_1} - \frac{1}{R_2} \right)\)
但在前端渲染中,为了性能,我们往往直接硬编码焦距参数,通过矩阵变换来模拟光线的偏折。
关键参数解释:focalLength (焦距):决定光线汇聚的能力。物镜焦距长,倍率大,但视野小。
aperture (口径):决定进光量,影响图像亮度。
distance (物距):通常设为无穷大(平行光入射),方便计算。避坑提示:
很多新手直接把物理公式硬译成JS,结果发现坐标系对不上。WebGL的Y轴向上,而物理教材里的Y轴可能向下。必须在初始化时统一坐标系,否则光线会往反方向跑,这就是你“跑不通”的第一大元凶。
环境准备:别在浏览器控制台里调光学
想要手写实现得稳,环境必须干净。我推荐用 Vite + TypeScript + Three.js。为什么选Three.js?因为它对WebGL的封装最好,自带矩阵库,省得你手写线性代数。
为什么不用纯Canvas 2D?
2D画光线只是画线,看不出立体感和景深。WebGL能模拟光线的“粗细”和“衰减”,这才是真正的成像原理演示。
安装依赖:
npm install three
npm install -D @types/three项目结构建议:src/renderer.ts: 初始化WebGL上下文
src/optics.ts: 核心光学计算逻辑(重点)
src/main.ts: 入口,绑定交互事件配置Vite代理:
如果你在本地加载外部资源报错,检查CORS。我在掘金技术社区见过太多人卡在跨域上,其实只要配置好代理,90%的“神秘报错”都会消失。
核心语法:用矩阵搞定光线折射
这是最硬核的部分。我们不画线,我们追踪光线向量。
在Three.js中,光线可以用一个 Vector3 表示方向,一个 Vector3 表示位置。折射的核心是旋转。
当光线穿过透镜中心时,方向不变。但偏离中心的光线,会被“掰”向主光轴。
手写实现核心函数 refractLight:
import * as THREE from 'three';// 定义透镜参数
interface LensParams {focalLength: number; // 焦距center: THREE.Vector3; // 透镜中心位置
}/*** 模拟光线穿过薄透镜的折射* @param lightPos 光线当前位置* @param lightDir 光线当前方向 (已归一化)* @param lens 透镜参数* @returns 新的方向向量*/
export function refractLight(lightPos: THREE.Vector3,lightDir: THREE.Vector3,lens: LensParams
): THREE.Vector3 {// 1. 计算光线穿过透镜平面的点 (假设透镜垂直于Z轴)const planeNormal = new THREE.Vector3(0, 0, 1);const t = (lens.center.z - lightPos.z) / lightDir.z;// 如果光线向后传播,直接返回if (t 0) return lightDir.clone();const hitPoint = lightPos.clone().add(lightDir.clone().multiplyScalar(t));// 2. 计算偏离主光轴的距离 (高度 h)const height = hitPoint.clone().sub(lens.center);// 3. 根据薄透镜公式计算偏折角// 偏折量 = -h / f (负号表示向主光轴偏折)const deflection = height.clone().divideScalar(lens.focalLength);// 4. 更新方向向量// 原方向 + 偏折向量const newDir = lightDir.clone().add(deflection);return newDir.normalize();
}逐行讲解:求交点:我们假设透镜是一个平面,先算出光线什么时候撞到透镜。
计算高度:height 是光线撞击点离光轴的距离。离得越远,被“掰”得越厉害。
偏折计算:deflection = h / f。这是薄透镜近似的核心。注意:这里的 f 是焦距。如果 f 为负,那就是凹透镜,光线会发散。
向量归一化:必须 normalize(),否则后续计算距离时会出错,导致画面闪烁。常见错误:
很多代码里忘记处理 lightDir.z 为0的情况(即光线平行于透镜平面),会导致除零错误,程序直接崩溃。记得加个判断。
完整代码示例:从零搭建一个双筒望远镜模拟器
下面是一个最小可运行的示例。我把它拆成了两个部分:初始化场景 和 光线追踪循环。
第一部分:场景初始化 (renderer.ts)
import * as THREE from 'three';export class TelescopeSimulator {private scene: THREE.Scene;private camera: THREE.PerspectiveCamera;private renderer: THREE.WebGLRenderer;// 光学参数private objectiveLens: LensParams = {focalLength: 500, // 物镜焦距 500mmcenter: new THREE.Vector3(0, 0, 0)};private eyepieceLens: LensParams = {focalLength: 50, // 目镜焦距 50mmcenter: new THREE.Vector3(0, 0, -500) // 物镜焦点位置};constructor() {this.scene = new THREE.Scene();this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);this.renderer = new THREE.WebGLRenderer({ antialias: true });// 关键:设置像素比,避免在高分屏上模糊this.renderer.setPixelRatio(window.devicePixelRatio);this.renderer.setSize(window.innerWidth, window.innerHeight);document.body.appendChild(this.renderer.domElement);// 添加辅助网格,方便观察光路const grid = new THREE.GridHelper(1000, 20, 0x00ff00, 0x003300);this.scene.add(grid);this.animate();}private animate = () = {requestAnimationFrame(this.animate);this.trackRays();this.renderer.render(this.scene, this.camera);}private trackRays() {// 这里清除上一帧的光线// ... 省略清理逻辑 ...// 发射一束平行光 (模拟无限远的星星)const startPos = new THREE.Vector3(10, 10, 1000);const startDir = new THREE.Vector3(0, 0, -1);let currentPos = startPos.clone();let currentDir = startDir.clone();// 第一步:穿过物镜currentDir = refractLight(currentPos, currentDir, this.objectiveLens);// 传播到目镜// ... 中间传播逻辑 ...// 第二步:穿过目镜// currentDir = refractLight(currentPos, currentDir, this.eyepieceLens);// 将计算出的光线画成线条this.drawRay(currentPos, currentDir, 0xffff00);}private drawRay(start: THREE.Vector3, dir: THREE.Vector3, color: number) {const geometry = new THREE.BufferGeometry();const points = [start, start.clone().add(dir.clone().multiplyScalar(100))];geometry.setFromPoints(points);const material = new THREE.LineBasicMaterial({ color });const line = new THREE.Line(geometry, material);this.scene.add(line);}
}new TelescopeSimulator();第二部分:交互与调试 (main.ts)
// 添加滑块,实时调整焦距
const focalSlider = document.createElement('input');
focalSlider.type = 'range';
focalSlider.min = '100';
focalSlider.max = '1000';
focalSlider.value = '500';
focalSlider.style.position = 'absolute';
focalSlider.style.top = '10px';
focalSlider.style.left = '10px';focalSlider.addEventListener('input', (e) = {const val = parseFloat(e.target.value);// 这里需要暴露 simulator 实例来修改参数// 实际项目中建议用状态管理console.log(`Focal length changed to ${val}`);
});document.body.appendChild(focalSlider);运行效果:
你会看到一束黄色的光线从右上角射入,在 z=0 处发生偏折,指向原点(焦点)。这就是望远镜成像的第一步:成实像。
常见报错:为什么我的光线是直的?
如果你跑完代码,发现光线没有偏折,还是直直地穿过去,检查这三点:坐标系不一致:Three.js 的 Z 轴是向屏幕内延伸的。如果你参考的物理教材是 Z 轴向外,那么折射方向会完全相反。解决方案:在 refractLight 里,把 deflection 的 Y 分量取反试试。
单位不匹配:焦距是毫米,位置是米?或者反过来?WebGL 没有单位,全靠你。统一用“像素”或“米”作为标准单位,不要混用。
向量未归一化:如果 lightDir 的长度不是1,t 的计算就会出错,导致撞击点偏移,进而偏折角度错误。务必在每次更新方向后调用 .normalize()。调试技巧:
在 console.log 里打印 hitPoint 和 deflection。如果 deflection 是 0,0,0,说明 height 算错了,检查 lens.center 是否设置正确。
小结:从代码到物理的跨越
通过手写实现这个望远镜成像原理,你不仅学会了一个前端特效,更掌握了光学模拟的核心逻辑。
核心收获:折射不是魔法:它是向量运算,是 h/f 的线性关系。
调试比编码更重要:90%的Bug来自坐标系和单位不统一。
可视化是理解最好的老师:把抽象的公式变成看得见的线,你的理解才会真正落地。这套代码虽然简单,但它是所有复杂光学模拟(如相机镜头畸变、VR头显光学)的基础。如果你能在掘金技术社区或GitHub上找到类似的开源项目,不妨 Fork 下来,改成自己的参数跑一跑,对比一下你的手写实现和别人的差异,这种“拆解-重构”的过程,才是成长最快的路径。
最后,留一个思考题:
目前的代码只模拟了单色光,没有色差。如果我们要模拟白光(RGB三色光)经过透镜后的色散现象(彩虹边),需要修改哪部分代码?是修改 refractLight 函数让它接受波长参数,还是直接在渲染层做后处理?
还有什么不懂的?评论区留言挨个回。
企业数字化 ERP 产品动态
相关推荐
5个数据指标新手避坑指南:告别教程依赖,实战代码对比 5个数据指标新手避坑指南:告别教程依赖,实战代码对比 看了一堆教程还是不会写项目?别急,问题出在你没搞懂数据指标背后的逻辑陷阱。刚入行的应届生最容易在这里栽跟头,明明代码能跑,但算出来的数跟业务对不上,面试一问就露馅。… · 2026/9/23 0:06:08
笔记本重装系统步骤全解:从底层原理到最佳实践避坑指南 笔记本重装系统步骤全解:从底层原理到最佳实践避坑指南 刚把 Python 语法书啃完,满脑子都是 class 和 def ,结果打开电脑想跑第一个 Hello World,发现系统卡得像 PPT,连个 Python… · 2026/9/23 0:05:50
快速查找是高频面试题核心3招搞定 快速查找是高频面试题核心3招搞定 官方文档翻了三页还没看懂哈希表冲突解决?别急,这正是 快速查找 这道 高频面试题 让你抓狂的原因。很多候选人卡在原理上,面试时支支吾吾,最后被pass。 其实, 快速查找… · 2026/9/23 0:05:50
MySQL批量更新方案详解:从循环逐条到临时表JOIN的性能对比与选型指南 1. 一次"半夜批量更新"翻车实录:问题从来不在SQL语法做后端开发这些年,我处理过不少跟"批量更新"有关的线上事故。坦白讲,绝大多数事故的根因不是SQL写错了,而是更新方式选错了。我第一次真正重视"批量更… · 2026/9/23 3:09:30
工业制氮设备选型误区与四维匹配模型解析 1. 工业制氮设备选型的认知误区与破局思路在工业气体设备采购领域,"厂家排名"搜索已经成为许多采购负责人的第一反应。以苏州地区为例,"苏州制氮机厂家排名"这类关键词每月搜索量超过2000次,反映出市场对标准化评价体系的… · 2026/9/23 3:09:24
Python数据结构:deque双端队列底层原理与性能实战对比 1. 先搞清楚:为什么Python有了list还要设计deque我见过很多Python初学者,学到deque这一节时第一反应都是:list不也能在两端加元素吗?append往尾部加,insert(0, x)往头部加,功能上看着差不多,为什… · 2026/9/23 3:09:24
基于YOLOv8的电梯电瓶车检测报警系统实战 简介:基于YOLOv8的电梯内电瓶车闯入报警系统资源,面向计算机、人工智能、自动化等专业学生,适合毕业设计、课程设计或项目初期演示,也适合目标检测初学者进阶练习。资源实现电梯场景下电瓶车违规闯入的实时检测与报警,… · 2026/9/23 3:09:24
CSDN问答功能入口与实操指南:从冷启动到涨粉 从写博客到认真经营创作者身份,我对CSDN最深的感受是:问答这块功能被严重低估了。很多人和我一样,早期只把CSDN当成“文章仓库”,写完往上一扔,数据好不好全看命。直到后来我认真研究了CSDN的问答功能入口位置… · 2026/9/23 3:09:12
3招搞定手机怎么下载微信面试难题实战项目解析 3招搞定手机怎么下载微信面试难题实战项目解析 面试被问“手机怎么下载微信”背后的原理,90%的人答不上来。别笑,这看似弱智的问题,实则是考察你对移动应用分发机制、安全校验及网络协议理解的试金石。我带过不少校招新人,他们背了八股文,却连一个A… · 2026/9/23 0:00:03
你有新短消息请注意查收:3个新手避坑指南搞定消息系统选型 你有新短消息请注意查收:3个新手避坑指南搞定消息系统选型 面试被问“高并发下如何保证消息不丢失”,你张口就是“用Redis”,结果面试官追问“如果Redis宕机了怎么办”,你瞬间卡壳。这种场景太常见了,很多新手在背八股文时,只记住了技术名词… · 2026/9/23 0:00:29