FunctionPlot:从采样到区间细分
用一个浏览器绘图器,观察均匀采样与区间算术如何处理二维曲线、奇点和三维隐式曲面。
创作与来源
- 科普视频参考
- MaExpo2026 区间算术视频
函数绘图看起来像是“多算一些点,再把它们连起来”,但奇点、高频曲线和三维等值面很快会让这种办法暴露局限。这个实验把两条路径放在同一个绘图器里:一条使用均匀采样,另一条先用区间算术排除不可能出现图像的区域,再对剩余部分自适应细分。
页面上方是完整绘图器;正文中的两个小实验则把关键步骤拆开,便于观察算法如何作出每一次保留、剪枝和断线决定。
两条绘图路径
均匀采样:简单,但不总可靠
最直接的办法是在固定网格上取样:
- 对显式函数
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 为例,可以把画布想象成一块待排查的棋盘:
- 先计算整个画布对应的结果区间。
- 结果不含 0 的区块直接剪枝。
- 结果包含 0 的区块一分为四,继续检查。
- 细分到接近像素尺度后,只在剩余候选区块中运行 Marching Squares。
拖动下方滑杆,可以看到计算如何逐渐集中到圆周附近。将指针移到任一区块上,右侧探针会显示该区块的输入范围与区间结果。
区块探针
- X 区间
- [-1.500, 1.500]
- Y 区间
- [-1.500, 1.500]
- F(X,Y)
- [-1.000, 3.500]
包含 0:保留并继续细分
第 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) 等带奇点的函数也采用同样的断线思路。
- 不含 0:停止细分
- 接触 0:继续细分
区间探针
- 当前 X 区间
- [-4.000, 4.000]
- 值域 1 / X
- [-∞, +∞]
区间包含 0:继续细分
第 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 的 EffectComposer 与 UnrealBloomPass 叠加泛光,再以自发光材质和线框模式呈现曲面:
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),观察奇点附近是否出现错误连线。
二维视图支持拖动和平移;三维视图可以旋转观察。控制面板左上角的按钮可将其收起,为图像留出更多空间。