水波干涉互动课件
这个程序要实现的是沪科版选择性必修一第二章 第四节 机械波的干涉和衍射中的图 3–28。如下图所示。
做出的成品如下所示(注意:二维圆形水波的振幅应该随半径的增大而减小,否则违反能量守恒定律,但这样做的话可视性不佳,因此该课件没有体现振幅衰减):
核心代码解读
这个程序主要要解决三个问题:表示波峰、谷的圆的绘制,表示振动加强、减弱区域的双曲线的绘制,干涉图样的绘制,其中前两个问题主要涉及的是几何知识。
一、表示波峰、谷的圆的绘制
波速是由介质决定的,因此本程序的波速 v = 100(像素/秒)保持不变,而两列波的频率可以改变,可由 λ = \(\dfrac{v}{f}\) 求得两列波的波长。在动画代码中由 r = vt 求出波前圆的半径,缩小 λ/4 波长后绘制出最外层的波峰圆,颜色为黑色。然后持续缩小 λ/4,依次绘制波谷(蓝色)、波峰(黑色)……。代码如下:
// 计算波峰和波谷
function calculateWaveCrestsAndTroughs(time, speed, wavelength, phaseOffset) {
if (time <= 0) {
return [];
}
const distance = speed * time;
const phaseShift = phaseOffset * wavelength / (2 * Math.PI);
const waveCircles = [];
let i = 0;
while (true) {
// 波峰比波前小四分之一波长
const rawRadius = distance + phaseShift - i * (wavelength / 2) - wavelength / 4;
if (rawRadius <= 0) {
break;
}
if (rawRadius < params.maxRadius) {
const type = i % 2 === 0 ? 'crest' : 'trough';
waveCircles.push({
radius: rawRadius,
type: type
});
}
i++;
}
return waveCircles;
}
二、表示振动加强、减弱区域的双曲线的绘制
写起来比较复杂,让 AI 帮我写吧:
1.核心算法:从物理条件到几何图形
代码严格遵循了干涉加强/减弱的物理定义来生成曲线,而不是简单地画圆找交点。
物理条件(体现在 drawSingleHyperbola 函数中):
- 加强(实线):波程差 Δd = nλ – \(\dfrac{{\Delta \varphi }}{{2\pi }}\) λ
- 减弱(虚线):波程差 Δd = (n + 0.5)λ – \(\dfrac{{\Delta \varphi }}{{2\pi }}\) λ
- 代码实现:通过
phaseOffsetInWavelengths计算相位差引起的偏移,然后分别计算出实线和虚线对应的目标路程差pathDiff。
2.关键函数:getPointOnHyperbola 的作用
这是一个通用的数学工具函数,负责生成标准双曲线上的点,并将其旋转平移到实际位置。
处理流程:
- 生成:使用双曲函数(
Math.cosh和Math.sinh)生成一个以原点为中心、焦点在 x 轴上的标准双曲线上的点(x0, y0)。 - 旋转:计算两个实际波源连线的角度,通过旋转变换将标准双曲线旋转到与实际焦点连线一致的方向。
- 平移:将旋转后的双曲线平移到两个焦点的中心点位置。
3.绘制逻辑:drawHyperbola 的执行步骤
第一步:参数计算
- 计算波长
wavelength。 - 计算相位差
phaseDiff - 计算相位差对应的距离偏移量
phaseOffsetInWavelengths。
第二步:循环绘制
- 绘制加强线:循环 n 从 -maxN 到 maxN,调用
drawSingleHyperbola(n, true)绘制实线。 - 绘制减弱线:再次循环,调用
drawSingleHyperbola(n, false)绘制虚线。
第三步:特殊处理:中垂线(n = 0且无相位差)
当计算出的路程差 pathDiff 非常接近 0 时(对应 n = 0 且相位差也为 0 的情况),双曲线会退化为一条直线——中垂线。
代码会计算焦点连线的垂直向量,然后绘制一条长长的直线来代替双曲线,避免了 a = 0 时双曲线计算失效的问题。
第四步:左右分支的处理
代码通过判断 pathDiff 的符号来决定绘制哪一侧的双曲线:
- pathDiff > 0:表示 d1 > d2,绘制靠近波源 1(右侧)的那一支。
- pathDiff < 0:表示 d1 < d2,通过手动设置 x0 = -a * Math.cosh(t) 来绘制靠近波源 2(左侧)的那一支。
三、干涉图样的绘制
由物理知识:如下图所示,设两列波 S1、S2 的角频率为 ω,振幅分别为 A1、A2,初相位分别 φ1、φ2,它们发出的波在空间某点 P 相遇,在 P 点引起的振动分别为
\[{y_1} = {A_1}\sin (\omega t + {\varphi _1} - \frac{{2\pi {r_1}}}{\lambda })\]
\[{y_2} = {A_2}\sin (\omega t + {\varphi _2} - \frac{{2\pi {r_2}}}{\lambda })\]
式中 r1、r2 为波源到 P 点的距离,则 P 的合位移为
\[y = {y_1} + {y_2}\]
还可以进一步推导出 P 的合振动(程序中没有用到)为:
\[y = A\sin (\omega t + \varphi )\]
式中
\[A = \sqrt {A_1^2 + A_2^2 + 2{A_1}{A_2}\cos \Delta \varphi } \]
\[\tan \varphi = \frac{{{A_1}\sin ({\varphi _1} - \frac{{2\pi {r_1}}}{\lambda }) + {A_2}\sin ({\varphi _2} - \frac{{2\pi {r_2}}}{\lambda })}}{{{A_1}\cos ({\varphi _1} - \frac{{2\pi {r_1}}}{\lambda }) + {A_2}\cos ({\varphi _2} - \frac{{2\pi {r_2}}}{\lambda })}}\]
Δφ = φ2 − φ1 − \({\dfrac{{2\pi ({r_2} - {r_1})}}{\lambda }}\) 中的 φ2 − φ1 是波源的初相位差:− \({\dfrac{{2\pi ({r_2} - {r_1})}}{\lambda }}\) 是由于波程差(Δr = r1 – r2)引起的相位差。两者的代数和 Δφ 就是两列波传播到 P 点后,在该点振动的总相位差。
干涉图样是绘制在一个分辨率为 1400×1080 的画布上的,在程序中,每帧(1/60 秒)时间内 CPU 都需要遍历约 150 万个像素,计算像素到波源的距离,然后代入上面的行波方程求出合位移,最后还需要将位移值映射到颜色,可视化干涉图样。核心代码如下:
// Canvas 2D渲染函数
function drawShadowCanvas2D() {
const ctx = canvas2d.getContext('2d');
if (!ctx) {
console.warn('无法获取Canvas 2D上下文');
return;
}
// 使用canvas的实际大小
const width = canvas2d.width;
const height = canvas2d.height;
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
const wavelength1 = params.waveSpeed / params.frequency1;
const wavelength2 = params.waveSpeed / params.frequency2;
const k1 = 2 * Math.PI / wavelength1;
const k2 = 2 * Math.PI / wavelength2;
const omega1 = 2 * Math.PI * params.frequency1;
const omega2 = 2 * Math.PI * params.frequency2;
const phaseOffset1 = 0;
const phaseOffset2 = params.phaseDiff * Math.PI;
const source1X = sources[0].x;
const source1Y = sources[0].y;
const source2X = sources[1].x;
const source2Y = sources[1].y;
const svgSource1X = parseFloat(sources[0].element.getAttribute('cx'));
const svgSource1Y = parseFloat(sources[0].element.getAttribute('cy'));
const svgSource2X = parseFloat(sources[1].element.getAttribute('cx'));
const svgSource2Y = parseFloat(sources[1].element.getAttribute('cy'));
// 计算相位偏移对应的距离偏移
const phaseShift1 = phaseOffset1 * wavelength1 / (2 * Math.PI);
const phaseShift2 = phaseOffset2 * wavelength2 / (2 * Math.PI);
// 计算波已经传播的最大距离(包含相位偏移)
const maxDistance1 = params.waveSpeed * params.time + phaseShift1;
const maxDistance2 = params.waveSpeed * params.time + phaseShift2;
const step = 1;
// 使用SVG坐标系(原点在左上角)进行遍历和计算
for (let svgY = 0; svgY < height; svgY += step) {
for (let x = 0; x < width; x += step) {
// Canvas 2D像素坐标(原点在左上角)
const r1 = Math.sqrt(Math.pow(x - source1X, 2) + Math.pow(svgY -
const r2 = Math.sqrt(Math.pow(x - source2X, 2) + Math.pow(svgY -
// 只有当波已经传播到该点时才计算位移
let displacement1 = 0;
let displacement2 = 0;
if (r1 <= maxDistance1) {
const phase1 = omega1 * params.time - k1 * r1 + phaseOffset1;
displacement1 = params.amplitude1 * Math.sin(phase1);
}
if (r2 <= maxDistance2) {
const phase2 = omega2 * params.time - k2 * r2 + phaseOffset2;
displacement2 = params.amplitude2 * Math.sin(phase2);
}
let r, g, b;
// 如果两个波都还没传播到该点,使用位移为0时的颜色
if (r1 > maxDistance1 && r2 > maxDistance2) {
// 位移为0时的颜色
if (params.shadowColorMode === 'high-contrast') {
r = 0; g = 0; b = 0;
} else {
r = 128; g = 128; b = 128;
}
} else {
const totalDisplacement = displacement1 + displacement2;
if (params.shadowColorMode === 'high-contrast') {
if (totalDisplacement > 0) {
const t = totalDisplacement / 4;
r = Math.floor(0 + 94 * t);
g = Math.floor(0 + 163 * t);
b = Math.floor(0 + 255 * t);
} else if (totalDisplacement < 0) {
const t = -totalDisplacement / 4;
r = Math.floor(0 + 255 * t);
g = Math.floor(0 + 165 * t);
b = Math.floor(0 + 0 * t);
} else {
r = 0; g = 0; b = 0;
}
} else {
// 灰度渐变模式
if (totalDisplacement > 0) {
const t = totalDisplacement / 4;
r = Math.floor(128 + 127 * t);
g = Math.floor(128 + 127 * t);
b = Math.floor(128 + 127 * t);
} else {
const t = -totalDisplacement / 4;
r = Math.floor(128 - 128 * t);
g = Math.floor(128 - 128 * t);
b = Math.floor(128 - 128 * t);
}
}
}
for (let dy = 0; dy < step && svgY + dy < height; dy++) {
for (let dx = 0; dx < step && x + dx < width; dx++) {
const index = ((svgY + dy) * width + (x + dx)) * 4;
data[index] = r;
data[index + 1] = g;
data[index + 2] = b;
data[index + 3] = 255;
}
}
}
}
ctx.putImageData(imageData, 0, 0);
}
上面的代码是让 CPU 进行计算,你会发现绘制的阴影图明显有卡顿,一种解决方法是将程序中的 step 设置为大于 1 的值,例如设置为 4,就是每隔 4 个像素计算一次,但这样做相当于降低了图像的分辨率,在屏幕上的图像会出现马赛克。
治本的方法是将上述复杂运算和像素的绘制可以通过 WebGL 让显卡 GPU 完成,干涉图样瞬间就丝滑了。本程序默认优先使用 WebGL 渲染,现如今的显卡都应该能支持 WebGL 的硬件加速。除非在很古老的电脑上,才会回退到 Canvas2D 模式。
由于希望课件能单文件离线使用,因此不想引入 Three.js 之类的外部 3D 库,而且这种简单情况也无需使用这么重的库,所以用原生 WebGL 代码实现。有关原生 WebGL 的编程基础可参见本网站中《第 1 课 绘制一个三角形》及之后的几篇文章。
WebGL处理的是 3D 程序,要绘制 2D 程序的思路是用顶点着色器在屏幕上绘制一个覆盖全屏的矩形(由 2 个三角形组成),而绘制 2D 图像的核心代码在片段着色器中,你会发现原理与 Canvas2D 是完全相同的,只是语法有区别。片段着色器的代码如下:
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_waveSpeed;
uniform float u_frequency1;
uniform float u_frequency2;
uniform float u_amplitude1;
uniform float u_amplitude2;
uniform float u_phaseDiff;
uniform vec2 u_source1;
uniform vec2 u_source2;
uniform int u_colorMode;
void main() {
// 转换WebGL坐标(原点在左下角)为SVG坐标(原点
vec2 coord = vec2(gl_FragCoord.x, u_resolution.y - 1.0 -
float wavelength1 = u_waveSpeed / u_frequency1;
float wavelength2 = u_waveSpeed / u_frequency2;
float k1 = 6.28318530718 / wavelength1;
float k2 = 6.28318530718 / wavelength2;
float omega1 = 6.28318530718 * u_frequency1;
float omega2 = 6.28318530718 * u_frequency2;
float phaseOffset1 = 0.0;
float phaseOffset2 = u_phaseDiff * 3.14159265359;
float r1 = distance(coord, u_source1);
float r2 = distance(coord, u_source2);
float phaseShift1 = phaseOffset1 * wavelength1 /
float phaseShift2 = phaseOffset2 * wavelength2 /
float maxDistance1 = u_waveSpeed * u_time + phaseShift1;
float maxDistance2 = u_waveSpeed * u_time + phaseShift2;
float displacement1 = 0.0;
float displacement2 = 0.0;
if (r1 <= maxDistance1) {
float phase1 = omega1 * u_time - k1 * r1 + phaseOffset1;
displacement1 = u_amplitude1 * sin(phase1);
}
if (r2 <= maxDistance2) {
float phase2 = omega2 * u_time - k2 * r2 + phaseOffset2;
displacement2 = u_amplitude2 * sin(phase2);
}
vec3 color;
if (r1 > maxDistance1 && r2 > maxDistance2) {
if (u_colorMode == 1) {
color = vec3(0.0, 0.0, 0.0);
} else {
color = vec3(0.5, 0.5, 0.5);
}
} else {
float totalDisplacement = displacement1 + displacement2;
if (u_colorMode == 1) {
// 高反差模式:蓝色波峰、橘色波谷、黑色平静
if (totalDisplacement > 0.0) {
float t = totalDisplacement / 4.0;
color = vec3(0.369 * t, 0.639 * t, 1.0 * t);
} else if (totalDisplacement < 0.0) {
float t = -totalDisplacement / 4.0;
color = vec3(1.0 * t, 0.647 * t, 0.0 * t);
} else {
color = vec3(0.0, 0.0, 0.0);
}
} else {
// 灰度渐变模式
if (totalDisplacement > 0.0) {
float t = totalDisplacement / 4.0;
color = vec3(0.5 + 0.498 * t, 0.5 + 0.498 * t, 0.5
} else {
float t = -totalDisplacement / 4.0;
color = vec3(0.5 - 0.5 * t, 0.5 - 0.5 * t, 0.5 - 0.5 *
}
}
}
gl_FragColor = vec4(color, 1.0);
}
教学说明
在课本沪科版选择性必修一第二章 第四节 机械波的干涉和衍射中并没有明确提及机械波干涉的条件,出现的是这两句话:58 页的“……形成两列频率相同的水波……”,59 页的“两列振动情况相同的波源产生的波在某区域相遇时,……”。而在课本沪科版选择性必修二第四章 第三节 光的干涉第 81 页提及“振动方向相同、频率相等、相位差恒定的光源称为相干光源”。
我的看法是:对于机械波最好也明确说明干涉条件是两个波源的“振动方向相同、频率相等、相位差恒定”。在平时授课时,我只强调频率相等这个必要条件。
- 振动方向相同比较容易理解,但针对高中生无法展开说明。
- 相位差上海课本不做要求,也没必要展开说明。如前文所述,相位差可以表示为
\[{\Delta \varphi = ({\omega _2} - {\omega _1})t - {\varphi _2} - {\varphi _1} - \frac{{2\pi ({r_2} - {r_1})}}{\lambda }}\]
当频率相等时,ω2 − ω1 = 0,要使Δφ恒定,还需要 φ2 − φ1= 0。即只有“频率相等”+“初相位差恒定”这两个条件同时满足,才能推导出“相位差恒定”。换句话说:频率相等是相位差恒定的必要条件,但不是充分条件,频率相等是相位差恒定的前提和基础。
- 两列波的频率一样、振幅不一样是可以发生干涉现象的。
用灰度图不容易看出图样的变化。为了观察非等幅情况下的干涉图样,此课件还添加了阴影图的“高反差(蓝色波峰/橘色波谷)”模式,现象更加明显。
- 当频率不相等、相位差变化时不发生干涉现象。
通过滑动条让频率不同,或者拖动滑动条让相位差时刻改变,能看到振动加强、减弱的区域随之变化,某位置不再始终加强或始终减弱,干涉图样也不再稳定。
相控阵雷达
在改变相位差时,从图中可以看出振动加强区域和减弱区域左右摇摆,这其实就是相控阵雷达最核心的原理——通过控制阵列中每个发射单元之间的相位差,来实现波束的扫描。
相控阵雷达(Phased Array Radar)又称电子扫描阵列(ESA)雷达,利用不同天线单元发射(接收)电磁波的相位差在空间合成高指向性、高增益、可转动的波束,从而实现对目标的搜索和跟踪。
一个简单的线性相控阵由 N 个相同的发射单元等距排列组成。
无相位差时(Δφ = 0)
- 所有单元同相发射。
- 在阵列的法线方向(正前方),所有波的波程相等,相位相同 → 同相叠加,形成主波束。
- 在其他方向,波程不同,相位抵消 → 信号很弱。
有相位差时(Δφ ≠ 0)
- 相邻单元之间引入一个恒定的相位差 Δφ。
- 这相当于给波前“倾斜”了一个角度。
- 根据干涉加强原理,波束会指向一个特定的角度 θ,满足:
\[\Delta \varphi = \frac{{2\pi }}{\lambda }d\sin \theta \]
其中 d 是单元间距,λ 是波长。
这样就通过电子方式控制 Δφ,就可以让波束在空间扫描,完全不需要转动天线。
完整代码
发布时间:2026/3/7 22:36:59 阅读次数:268