光的折射交互课件
这个互动程序要实现的是沪科技版选必一第四章 第一节 光的折射中的图 2–19,如下图所示。
做出的成品如下所示:
它是 PhET 光的折射课件的仿制品,最主要解决这个课件的一个缺点:激光笔只能旋转 90°,要演示全反射现象时无法拖动到水下,要将上层的折射率调大才能达到目的,使用不便。第 2 个界面是展示光通过不同形状的物件时的折射/全反射现象,改进的地方是:不仅可以旋转形状,还可以通过控制柄改变形状的大小尺寸(在空白处点击就可以隐藏控制柄),例如可以将正方形拖动为长方形,演示光纤中的全反射,还添加一种新类型——圆环,可以演示 2026 年上海高考中的光学题。
核心代码解读
一、光的波长与 RGB 颜色的关系
波长是光的物理属性,而 RGB 是显示设备用来模拟颜色的“配方”。特定波长的光看起来是什么颜色,取决于人眼的生物特性,而非物理定律。所以光的波长与 RGB 颜色没有简单的线性公式直接对应。RGB 值不是物理波长,而是人眼三种视锥细胞受刺激程度的编码。而且在标准 RGB 色域中,部分纯光谱色(如某些深青色)无法由红绿蓝三原色直接混合得出,计算出的 RGB 分量可能出现负值,这意味着它超出了显示器的显色范围。
将单一波长映射为 RGB,必须依赖国际照明委员会(CIE)基于人类视觉实验测得的“颜色匹配函数”数值表进行换算。
如果需要精确转换,标准做法是:查询 CIE1931 标准色度观察者的“颜色匹配函数”数据表(通常包含从 380 nm 到 780 nm 每隔 1 nm 或 5 nm 的数值)。利用这些数值将波长转换为 XYZ 三刺激值。再通过矩阵变换,将 XYZ 值转换为目标色彩空间(如 sRGB)的 RGB 值。
最常用的简化公式是 Dan Bruton 近似算法,这个简化公式是工程近似,并非 CIE 标准换算,不能用于科研级色彩测量,但用于数据可视化、光谱显示等场景完全够用。
它不追求物理精确,而是用分段线性插值加亮度衰减,在代码里很容易实现,效果也足够“看起来像”对应波长的颜色。
分段公式(波长 λ 单位:nm,范围 380–750)
- 380–440(紫):R = − (λ − 440)/(440 − 380),G = 0,B = 1
- 440–490(蓝青):R = 0,G = (λ − 440)/(490 − 440),B = 1
- 490–510(青绿):R = 0,G = 1,B = − (λ − 510)/(510-490)
- 510–580(绿黄):R = (λ − 510)/(580 − 510),G = 1,B = 0
- 580–645(橙红):R = 1,G = − (λ − 645)/(645-580),B = 0
- 645–750(红):R = 1,G = 0,B = 0
直接套用上面公式,光谱两端会显得太亮、太假。实际算法会在边缘引入衰减因子(attenuation):
- 380–420 nm:factor = 0.3 + 0.7 × (λ − 380)/(420 − 380)
- 420–700 nm:factor = 1.0
- 700–780 nm:factor = 0.3 + 0.7 × (780 − λ)/(780 − 700)
最终每个通道乘以 factor,再做 gamma 校正(通常取 0.8),乘以 255 即可得到 RGB 整数值。
代码如下:
function wavelengthToColor(wl) {
let r, g, b;
if (wl >= 380 && wl < 440) {
r = -(wl - 440) / 60; g = 0; b = 1;
} else if (wl < 490) {
r = 0; g = (wl - 440) / 50; b = 1;
} else if (wl < 510) {
r = 0; g = 1; b = -(wl - 510) / 20;
} else if (wl < 580) {
r = (wl - 510) / 70; g = 1; b = 0;
} else if (wl < 645) {
r = 1; g = -(wl - 645) / 65; b = 0;
} else if (wl <= 780) {
r = 1; g = 0; b = 0;
} else {
r = 1; g = 0; b = 0;
}
// 边缘衰减(380-420 和 700-780 降低亮度)
let factor = 1.0;
if (wl >= 380 && wl < 420) factor = 0.3 + 0.7 * (wl - 380) / 40;
else if (wl > 700 && wl <= 780) factor = 0.3 + 0.7 * (780 - wl) / 80;
r = Math.round(r * factor * 255);
g = Math.round(g * factor * 255);
b = Math.round(b * factor * 255);
return `rgb(${r},${g},${b})`;
}
二、柯西色散方程
柯西色散方程是光学中用来描述透明介质折射率随波长变化(即色散)的经验公式。它由法国数学家柯西(Augustin-Louis Cauchy)于 1836 年提出。数学形式为:
\[n(\lambda ) = A + \frac{B}{{{\lambda ^2}}} + \frac{C}{{{\lambda ^4}}} + \cdots \]
其中:n 为折射率,λ 为真空中波长,A、B、C 为与材料有关的经验常数(通常由实验拟合得到)。在大多数工程应用中,取前两项已足够:
\[n(\lambda ) \approx A + \frac{B}{{{\lambda ^2}}}\]
对多数透明材料在可见光波段,B > 0,折射率随波长增大而减小(蓝光折射率大于红光),这称为正常色散。
在本课件中,当入射角为 60° 时,波长从 780 nm 的红光变化到 380 nm 的紫光,折射角会从 40.8° 变化到 39.6°。
代码如下:
// 柯西色散方程 n(λ) = A + B/λ²,波长 λ 以微米为单位
// 使不同波长的光在同一介质中折射率不同(紫光折射率大、红光小)
function nDispersed(baseN, wlNm) {
const lambda = wlNm / 1000; // 转微米
const B = 0.02 * (baseN - 1); // 色散系数随基础折射率增大
const A = baseN - B / (0.589 * 0.589); // 以 589nm 为基准
return A + B / (lambda * lambda);
}
三、菲涅尔方程
该定量描述了光以不同角度、不同偏振态在不同折射率介质界面上反射与透射时,究竟有多少光被反射回去,多少光透射过去。公式较为复杂,可参考《新概念物理教程——光学》第 276 页起的内容,或者这篇文章:光波在反射与折射过程中偏振态发生了什么变化。
菲涅尔方程将光分解为 s 偏振(电场垂直于入射面)和 p 偏振(电场平行于入射面)两个分量来计算反射率。
p 分量的光强反射率 Rp = (\(\dfrac{{{E_{1{\rm{p'}}}}}}{{{E_{1{\rm{p'}}}}}}\))2;s 分量的光强反射率Rs = (\(\dfrac{{{E_{1{\rm{s'}}}}}}{{{E_{1{\rm{s}}}}}}\))2,总体反射率为两者的算术平均值 R = \(\dfrac{{{R_{\rm{p}}} + {R_{\rm{s}}}}}{2}\)。
上式中 \(\dfrac{{{E_{1{\rm{p'}}}}}}{{{E_{1{\rm{p'}}}}}} = \dfrac{{{n_2}\cos {\theta _1} - {n_1}\cos {\theta _2}}}{{{n_2}\cos {\theta _1} + {n_1}\cos {\theta _2}}}\),\(\dfrac{{{E_{1{\rm{s'}}}}}}{{{E_{1{\rm{s}}}}}} = \dfrac{{{n_1}\cos {\theta _1} - {n_2}\cos {\theta _2}}}{{{n_1}\cos {\theta _1} + {n_2}\cos {\theta _2}}}\)。
下图分别是从空气到玻璃和从玻璃到空气两种情况下的光强反射率 R 随入射角 θ1 变化的图像。
1.垂直入射时:θ1 = θ2 = 0,Rp = Rs = \({\left( {\dfrac{{{n_2} - {n_1}}}{{{n_2} + {n_1}}}} \right)^2}\),反射率仅由两侧介质的折射率决定。例如光从空气(n1 ≈ 1)射入玻璃(n2 ≈ 1.5),每个界面的反射率约为 4%。
2.随角度变化(菲涅尔效应):反射率并非固定不变。随着入射角(观察角)增大,反射率通常会上升。这就是为什么低头看脚下的湖水觉得清澈透明,而远眺湖面时却觉得像镜子一样反光强烈。
课本上这种照片应该是九寨沟五花海的景色。在照片靠下方的水面,你能清晰看到水下的沉木,这对应菲涅尔公式中入射角较小的情况,此时反射率低,大部分光透射进入水中,所以水底细节可见。而照片靠上方的水面,离观察者更远,对应入射角较大(接近掠射),反射率显著升高,因此水面像镜子一样,强烈反射出天空的蓝色和白云。简单来说,近处的水面让你“看透”水底,远处的水面则“映出”天空。这种视觉效果,正是菲涅尔反射率随角度变化的直观体现。
在《新概念物理教程》中“§2.光在电介质表面的反射和折射 菲涅耳公式”是这样引入的:
当你濒临一池泓碧,纵目远望,则彼岸枝叶扶疏,倒映于水中;俯察足下,则鱼嬉浅底,其乐融融。同是在水与空气的界面上的光,来自入射角大的远处,以反射为主;来自入射角小的近处,以透射为主。一般说来,光射在两种折射率不同的介质界面上要分解为反射和折射两股,能流在它们之间分配,分配的比例与入射角有关。
补充说明:《三界》(Three Worlds)是荷兰艺术家 M.C.埃舍尔于 1955 年创作的一幅石版画。这幅画看似描绘了一个平静的池塘,实则在同一画面中精妙地呈现了三个不同维度的“世界”。埃舍尔通过一个俯视水面的视角,将三种现实叠合在一起:
- 水面之上的世界:画面中并未直接画出树木,但通过水中的倒影,暗示了岸上树林的存在。
- 水面本身的世界:漂浮在水面上的落叶,构成了水与空气的交界层。
- 水面之下的世界:一条鱼正游过画面,代表着水下生命。
这幅画体现了埃舍尔对感知与现实的探索。他并没有直接描绘水,而是通过倒影、漂浮物和折射感,让观者的大脑自动“补全”了水的存在。与埃舍尔那些充满“不可能图形”的作品不同,《三界》乍看之下非常自然写实,但其核心依然是一种智性的视觉游戏——它展示了单一视点下多重现实共存的可能性。该作品有多个版次,图像尺寸约为 36.2 × 24.7 厘米,现藏于芝加哥艺术博物馆等机构。
3.布儒斯特角:存在一个特殊角度,此时 p 偏振光的反射率降为零,反射光变成完全的 s 偏振光。这个角度满足 tanθB = \(\dfrac{{{n_2}}}{{{n_1}}}\)。布儒斯特角时反射光线与折射光线恰好垂直,这是布儒斯特角的一个等价几何特征,也是它最直观的判别方式。
4.全反射:当光从光密介质(如玻璃)射向光疏介质(如空气),且入射角大于临界角时,反射率会达到 100%,这就是光纤通信的基础原理。
代码如下:
function fresnelReflectance(theta1Deg, nFrom, nTo) {
const t1 = theta1Deg * Math.PI / 180;
const sinT2 = (nFrom / nTo) * Math.sin(t1);
if (Math.abs(sinT2) > 1) return 1;
const t2 = Math.asin(sinT2);
const cos1 = Math.cos(t1);
const cos2 = Math.cos(t2);
const rs = (nFrom * cos1 - nTo * cos2) / (nFrom * cos1 + nTo * cos2);
const rp = (nFrom * cos2 - nTo * cos1) / (nFrom * cos2 + nTo * cos1);
return (rs * rs + rp * rp) / 2;
}
四、二维几何光学的光线跟踪算法
在第 2 个界面——形状折射对应的代码实现的是一个二维几何光学追迹引擎。它的核心任务是:让光线在画布上直线传播,遇到不同折射率的物体时,按照斯涅尔定律发生折射或全反射,从而画出光线穿透物体后的完整折线路径。
1.核心数学基础:射线与多边形求交(nearestHit)
光线在计算机中被表示为射线:一个起点 P 和一个方向向量 D。物体在代码中被表示为一个多边形(用一系列顶点数组存储)。
nearestHit 函数的作用是:给定一条射线和一个多边形,求出射线与多边形边缘最近的交点,以及交点处的外法线向量。
代码如下:
// 射线与多边形最近交点(含外法线)
function nearestHit(p, d, poly) {
const n = poly.length;
let ax = 0, ay = 0;
for (let i = 0; i < n; i++) { ax += poly[i].x; ay += poly[i].y; }
ax /= n; ay /= n;
let best = null;
for (let i = 0; i < n; i++) {
const a = poly[i], b = poly[(i + 1) % n];
const ex = b.x - a.x, ey = b.y - a.y;
const len = Math.hypot(ex, ey) || 1;
const denom = ex * d.y - d.x * ey;
if (Math.abs(denom) < 1e-9) continue;
const t = (-(a.x - p.x) * ey + ex * (a.y - p.y)) / denom;
const u = (d.x * (a.y - p.y) - d.y * (a.x - p.x)) / denom;
if (t <= EPS || u < 0 || u > 1) continue;
if (best && t >= best.t) continue;
// 外法线:垂直于该边,并背离多边形内部(形状均为凸多边形)
let nx = ey / len, ny = -ex / len;
const mx = (a.x + b.x) / 2 - ax, my = (a.y + b.y) / 2 - ay;
if (nx * mx + ny * my < 0) { nx = -nx; ny = -ny; }
best = { t: t, x: p.x + d.x * t, y: p.y + d.y * t, nx: nx, ny: ny };
}
return best;
}
交点计算:遍历多边形的每一条边,利用二维直线相交的代数公式(克莱姆法则)求出射线与线段的交点参数 t 和 u。t > 0表示在射线正前方,0 ≤ u ≤ 1 表示交点落在该线段上。
外法线计算:根据边的方向向量求垂直向量,并判断该垂直向量是指向多边形内部还是外部。代码通过计算边的中点指向多边形平均中心点的向量,利用点积判断并翻转法线,确保法线永远指向外部。
2.多物体与孔洞处理(traceAll)
光线在传播过程中可能会穿过多个物体。代码中的 contours 数组包含了画布上所有物体的轮廓。对于圆环,它会生成两条轮廓(外圆和内圆),并标记 hole: true(代表这是一个孔洞,孔洞内是外界介质)。
traceAll 函数是一个迭代循环(最多 40 步):
(1)在当前位置,向所有轮廓发射射线,找出距离最近的交点。
(2)记录这个交点(作为折线的一个顶点)。
(3)根据交点的法线、当前光线方向以及当前所在介质,计算折射或反射后的新方向。
(4)将起点移动到交点稍微偏移一点的位置(EPS = 0.08,防止光线卡在表面),然后重复上述过程,直到光线射出画布。
3.光学物理核心:折射与全反射(traceAll 内部)
当光线击中表面时,代码执行以下物理计算:
(1)判断介质:利用交点的外法线 n 和光线方向 d 的点积 ndot = d.x * nx + d.y * ny。
- 如果 ndot < 0,说明光线逆着法线射入,即从外部进入物体内部。
- 如果 ndot > 0,说明光线顺着法线射出,即从物体内部射出到外部。
- 根据进入还是射出,结合轮廓的
hole属性,确定入射介质折射率 n1 和出射介质折射率 n2。 - (2)全反射判断:计算折射角的正弦平方 sin2θ2 = (\(\dfrac{{{n_1}}}{{{n_2}}}\))2(1 − cos2θ1)。
- 如果 sin2θ2 > 1,说明没有实数解,发生全反射。此时光线方向 d 沿法线进行镜面反射:dʹ = d − 2(d·n)n
- 如果 sin2θ2 ≤ 1,则发生折射。代码使用的是向量形式的斯涅尔定律(而不是直接求角度再用三角函数):
dnew = ηd + (ηcosθ1 − cosθ2)n
其中 η = n1/n2,cosθ1 = |d·n|,cosθ2 = \(\sqrt {1 - {{\sin }^2}{\theta _2}} \)。求出的向量最后需要归一化(除以长度 L),保证光线方向向量的模长为 1。
代码如下:
// 多物体逐面折射追迹:返回折线顶点(contours 含孔洞轮廓,孔洞内部为外界介质)
function traceAll(origin, dir, contours, nObj, envN) {
const pts = [{ x: origin.x, y: origin.y }];
let p = { x: origin.x, y: origin.y };
let d = { x: dir.x, y: dir.y };
for (let step = 0; step < 40; step++) {
let best = null, bestHole = false;
for (let i = 0; i < contours.length; i++) {
const h = nearestHit(p, d, contours[i].pts);
if (h && (!best || h.t < best.t)) {
best = h;
bestHole = contours[i].hole;
}
}
if (!best) { pts.push(clipToViewport(p, d)); break; }
pts.push({ x: best.x, y: best.y });
const ndot = d.x * best.nx + d.y * best.ny;
const entering = ndot < 0; // 由外界射入该轮廓内部
let nx = best.nx, ny = best.ny;
if (!entering) { nx = -nx; ny = -ny; } // 法线朝向入射光一侧
const cosI = Math.abs(ndot);
// 实体轮廓:外法线一侧为外界,内部为物体介质;孔洞轮廓:外法线一侧为物体介质,内部为外界
const nNormal = bestHole ? nObj : envN;
const nInner = bestHole ? envN : nObj;
const n1 = entering ? nNormal : nInner;
const n2 = entering ? nInner : nNormal;
const eta = n1 / n2;
const sin2t = eta * eta * (1 - cosI * cosI);
if (sin2t > 1) {
// 全反射
const dn = d.x * nx + d.y * ny;
d = { x: d.x - 2 * dn * nx, y: d.y - 2 * dn * ny };
} else {
// 折射
const cosT = Math.sqrt(1 - sin2t);
const tx = eta * d.x + (eta * cosI - cosT) * nx;
const ty = eta * d.y + (eta * cosI - cosT) * ny;
const L = Math.hypot(tx, ty) || 1;
d = { x: tx / L, y: ty / L };
}
p = { x: best.x + d.x * EPS, y: best.y + d.y * EPS };
}
return pts;
}
4.坐标变换与图形绘制
物体状态:每个物体(如矩形、圆、三角形)都有中心坐标(x,y)、旋转角 rot 和尺寸参数。在求交时,代码通过旋转矩阵将局部坐标转换到世界坐标。
形状定义:圆形、圆环、半圆等使用离散的多边形点集(如 circlePts(120) 使用 120 个点)来近似,这样统一用多边形求交逻辑处理。
控制柄:drawHandles 函数根据当前选中的物体,画出角点、半径、旋转轴的交互手柄。
光线绘制:traceAll 返回了一个包含所有拐点的点集pts。最后使用<polyline>标签将这些点连成折线,并设置与波长对应的颜色和线宽。
现代图形学和很多 3A 游戏都用到了一种叫做光线追踪(Ray Tracing)的技术,可以极大地提高画质和光照的真实性,而这段 JS 代码可以看作是剥离了光照计算、只保留几何光学的 2D 光线追踪器。如果把它扩展到 3D,加上摄像机模型、材质着色和蒙特卡洛采样,它就是一个标准的路径追踪器(Path Tracer)。两者在“射线求交”和“利用法线计算新方向”这两个核心机制上是完全一致的。
完整代码
发布时间:2026/10/31 21:43:21 阅读次数:47