可交互算法实验

FunctionPlot:从采样到区间细分

用一个浏览器绘图器,观察均匀采样与区间算术如何处理二维曲线、奇点和三维隐式曲面。

创作与来源

函数绘图看起来像是“多算一些点,再把它们连起来”,但奇点、高频曲线和三维等值面很快会让这种办法暴露局限。这个实验把两条路径放在同一个绘图器里:一条使用均匀采样,另一条先用区间算术排除不可能出现图像的区域,再对剩余部分自适应细分。

页面上方是完整绘图器;正文中的两个小实验则把关键步骤拆开,便于观察算法如何作出每一次保留、剪枝和断线决定。

两条绘图路径

均匀采样:简单,但不总可靠

最直接的办法是在固定网格上取样:

  • 对显式函数 y = f(x),沿 x 轴等距取点,再连接相邻结果。如果两个采样点之间恰好存在间断,连线就可能穿过本不该出现的区域。
  • 对隐式函数 f(x, y) = 0,在二维网格上寻找符号变化并运行 Marching Squares;三维情形则用 Marching Cubes 提取等值面。网格越密,计算量越大,而大部分空白区域其实没有必要反复求值。

区间算术:先判断“这里有没有可能”

StuPlot 策略用区间算术(Interval Arithmetic)把一次“点计算”改成一次“范围判断”。输入区间 X = [x_min, x_max] 后,输出区间 Y = [y_min, y_max] 必须覆盖函数在整个输入范围内的所有可能值。

例如,f(x) = x²[-2, 2] 上的区间结果是 [0, 4]。对隐式方程而言,如果某个区域的结果区间不含 0,便可以确定曲线不会经过这里,整个区域都能直接跳过。

四叉树:只追踪可能穿过曲线的网格

以单位圆 x² + y² - 1 = 0 为例,可以把画布想象成一块待排查的棋盘:

  1. 先计算整个画布对应的结果区间。
  2. 结果不含 0 的区块直接剪枝。
  3. 结果包含 0 的区块一分为四,继续检查。
  4. 细分到接近像素尺度后,只在剩余候选区块中运行 Marching Squares。

拖动下方滑杆,可以看到计算如何逐渐集中到圆周附近。将指针移到任一区块上,右侧探针会显示该区块的输入范围与区间结果。

四叉树剪枝 · x² + y² = 1逐层执行区间探测;把指针移到区块上查看判定。
单位圆的四叉树区间剪枝过程橙色区块可能包含曲线,灰色区块不包含零并被剪枝。

区块探针

X 区间
[-1.500, 1.500]
Y 区间
[-1.500, 1.500]
F(X,Y)
[-1.000, 3.500]

包含 0:保留并继续细分

0 / 6

第 0 层:1 个候选区块等待探测。

function processImplicit2D(xMin: number, xMax: number, yMin: number, yMax: number, depth: number) {
const intervalX = Interval.create(xMin, xMax);
const intervalY = Interval.create(yMin, yMax);
const result = evaluator.evaluateImplicitInterval({ x: intervalX, y: intervalY });
if (!result.containsZero() && !result.isEntire()) return;
if (depth >= maxDepth2D) {
marchSquareStuPlot(xMin, yMin, xMax, yMax, v0, v1, v2, v3, segmentList);
return;
}
const xMid = (xMin + xMax) / 2;
const yMid = (yMin + yMax) / 2;
processImplicit2D(xMin, xMid, yMin, yMid, depth + 1);
processImplicit2D(xMid, xMax, yMin, yMid, depth + 1);
// 另外两个子区块同理。
}

一维细分:在奇点前断开

y = 1/x 而言,任何包含 0 的闭区间都会得到无界结果。算法不应该把奇点两侧的采样点连起来,而应当只继续细分这些可疑区间;已经确认不含 0 的区间则保持不动。

第一次在 0 处分割后,左区间 [-4, 0] 与右区间 [0, 4] 都包含端点 0,因此两侧各有一个区间需要继续处理。这不是两个奇点,而是闭区间边界的自然结果。随着细分继续,只有紧贴 0 的两小段会不断缩窄;达到像素尺度后丢弃它们,就不会生成伪渐近线。tan(x) 等带奇点的函数也采用同样的断线思路。

自适应奇点隔离 · y = 1/x每一步只细分仍接触 x = 0 的区间,连续区间保持不动。
函数一除以 x 在零点附近的自适应区间细分每一步只二分仍接触零点的区间;已经确认连续的区间不再细分。x = 0
  • 不含 0:停止细分
  • 接触 0:继续细分

区间探针

当前 X 区间
[-4.000, 4.000]
值域 1 / X
[-∞, +∞]

区间包含 0:继续细分

0 / 6

第 0 步:整个定义域包含 0,需要继续细分。

让计算不阻塞交互

把求值移入 Web Worker

数学解析和绘图算法在 plotWorker.ts 中运行,避免阻塞界面线程。较大的类型化数组通过可转移对象(Transferable Objects)交给主线程,避免复制整块数据:

const response = { id: request.id, mode: '2d', data2D: result };
self.postMessage(response, [result.segments.buffer as ArrayBuffer]);

合并连续交互请求

拖动和平移会高频触发绘图。界面用短暂防抖合并连续请求,在操作结束后再提交最终计算,避免 Worker 队列堆积。

缓存二维路径

二维画布把线段数组构建为 Path2D,后续重绘无需再次遍历全部坐标:

const path = new Path2D();
for (let index = 0; index < segments.length; index += 4) {
path.moveTo(segments[index], segments[index + 1]);
path.lineTo(segments[index + 2], segments[index + 3]);
}
ctx.stroke(path);

琥珀色矢量屏

界面取法于电致发光屏幕(EL)和示波器:深色底面、琥珀色线条,以及克制的边缘辉光。

二维画布:保持稳定线宽

二维渲染使用 Canvas 的 shadowBlur。世界坐标经过缩放后,线宽还要反向抵消缩放比例,才能在不同视域下保持一致:

ctx.strokeStyle = '#ffb000';
ctx.shadowColor = '#ffb000';
ctx.shadowBlur = 6;
const scaleX = width / xSpan;
ctx.lineWidth = 2 / Math.abs(scaleX);
ctx.scale(scaleX, -scaleY);
ctx.stroke(path);

三维场景:线框与泛光

三维场景使用 Three.js 的 EffectComposerUnrealBloomPass 叠加泛光,再以自发光材质和线框模式呈现曲面:

const material = new THREE.MeshStandardMaterial({
color: 0x000000,
emissive: 0xffaa00,
emissiveIntensity: 2,
wireframe: true,
});
const bloom = new UnrealBloomPass(new THREE.Vector2(width / 2, height / 2), 1.5, 0.4, 0.85);
bloom.threshold = 0.1;
bloom.strength = 1.2;
composer.addPass(bloom);

定向光与环境光使用相近的琥珀色调,让线框和实体模式保持同一套视觉语言。

试一试

  • 在二维模式输入 sin(x^2+y^2)=cos(x y),比较“描点法”与 “StuPlot 算法”的渲染效果。
  • 二维模式还支持不等式。
  • 输入 y = tan(x),观察奇点附近是否出现错误连线。

二维视图支持拖动和平移;三维视图可以旋转观察。控制面板左上角的按钮可将其收起,为图像留出更多空间。