1. 项目初衷与目标定位
1.1 从课堂演示到独立工具:FuncPlotCalc 的诞生
做数学可视化东西的人,基本都经历过这种纠结:Matplotlib 能画静态图,但学生想拖拽旋转观察鞍点、极值点长什么样的时候,它就僵住了;GeoGebra 功能强,可要让一个刚学多元微积分的学生先学会它的交互逻辑,学习成本比看曲面本身还高;自己用原生 WebGL 从零搭一套,光是处理正交投影、深度排序、光照法线这种基础设施,就足够耗费两三天。FuncPlotCalc 就是在这种纠结里冒出来的小工具,核心目标非常单一:在浏览器里快速绘制3D 显式函数 z=f(x,y)的曲面,让输入表达式、看到曲面、拖拽旋转这三件事变得足够顺滑。
项目做完后,我最满意的不是渲染效果有多华丽,而是“把一条数学表达式变成可以360度观察的曲面”这个动作变得格外自然。输入x^2 - y^2,回车,马鞍面立刻出现;按住鼠标转一圈,驻点和鞍点的相对位置一眼就能看懂。类似的需求在偏导数、极值、曲面积分这些教学内容里出现频率极高,能有一个专门的轻量工具独立解决,比在通用平台里反复组装要高效得多。像我之前在 3D 网页渲染方向踩过不少工具链的坑,这次反其道而行之,把实用的那部分牢牢攥在手里。
1.2 显式函数路线的取舍:为什么只做 z=f(x,y)
第一版设计稿里,我也犹豫过要不要顺便支持隐式方程、参数曲面、极坐标函数。冷静评估之后,还是决定只做显式函数一条路线。
显式函数 z=f(x,y) 是多元函数里最基础的形态,每个平面点 (x,y) 有且只有一个确定的 z 值,天然适合规则网格采样。隐式方程比如 x²+y²+z²=1,需要 marching cubes 这类等值面提取算法,拓扑处理、数值逼近、裂缝修补,复杂度完全不在一个量级。参数曲面本质上要处理另一套映射关系,多值函数的求值和用户心智模型都对不上。
把显式函数这个场景做到极致,收益其实是最高的。一元函数绘图工具遍地都是,但二元显式函数的轻量级交互查看器反而少见。很多通用数学软件把它当成一个子功能,藏在菜单深处,用起来绕。FuncPlotCalc 单独拎出来做,表达式的输入范式、错误提示机制、颜色映射逻辑,全部围绕“z=f(x,y)”这一个核心语义来设计,干净且好用。
2. 渲染方案与核心架构设计
2.1 渲染层选型:Three.js、原生 WebGL 还是 Canvas2D
渲染层的选择,我研究了一圈,排除了原生 WebGL 和 Canvas2D。
原生 WebGL 优势在于可控性全,顶点缓冲、着色器、绘制状态、矩阵变换全在自己手里,性能天花板也高。可惜代价是开发周期长,一个可交互的 3D 场景需要手写深度缓冲、光照模型、事件拾取、矩阵数学,这些基础设施代码对数学可视化工具来说属于高消耗、低产出的部分。Canvas2D 更偏向 2D 绘制流程,它本身没有深度缓冲,绘制大量三角形时 Z 排序和遮挡问题非常棘手,要自己实现画家算法或 BSP 分割,稳定性和性能都不够理想。
最终我选了 Three.js。它把 BufferGeometry、材质系统、光照、WebGLRenderer、OrbitControls 这些常用模块都封装得比较顺手,让我可以把 80% 的开发精力放在“曲面数据生成”这个核心任务上,只维护顶点数组、索引数组、颜色数组和必要的法线数据就行。实际使用中需要注意几个配置项:
| 配置项 | 默认行为 | 我的建议 |
|---|---|---|
| antialias | 关闭 | 开启,曲线边缘锯齿感明显减少 |
| devicePixelRatio | 1 | 设置为窗口 devicePixelRatio,高分屏下文字和线条不发虚 |
| alpha | false 不透明 | 保持默认,开启透明背景会引入额外性能开销 |
| preserveDrawingBuffer | false | 保持默认,除非你经常截图导出 |
三维曲面的真实感对光照模型比较敏感,我用的 MeshPhongMaterial,法线数据必须正确,否则峰谷位置会出现黑色假面,这个在后面会说。
2.2 坐标系约定与网格拓扑设计
坐标系约定是这类项目里最容易埋坑的地方。数学课本里通常 x、y 构成底面平面,z 轴朝上;而 Three.js 的默认世界坐标系是 y 轴朝上,z 轴指向屏幕外。如果直接把函数值 z 塞进 Three.js 的 z 分量,曲面会平躺在水平面内,看起来就不是“高低起伏”的曲面,而是“前后纵深”的平面图案,语义就错了。
我的统一约定是“数学 z 映射到渲染 y”。在网格生成阶段,每个顶点的 Three.js 坐标写为(x, z, y),其中 x、y 来自函数定义域,z 是 f(x,y) 的函数值。渲染纵轴对应函数值语义,符合绝大多数人的观察直觉。这个约定一旦定下来,就要贯穿始终:坐标轴辅助线、OrbitControls 的旋转中心、世界坐标轴的标注方向,都要保持同一套规则,否则用户看到坐标轴指向和曲面倾斜方向对不上,会非常违和。
网格拓扑基于规则矩形切分:在定义域[xmin, xmax] × [ymin, ymax]上按nx和ny两个方向等距分割,得到(nx+1)*(ny+1)个采样点。每个小矩形格子连接对角线拆成两个三角形,三角形绕序统一为逆时针,这决定了法线方向。绕序如果乱,光照会随机出现黑色三角块。
对角线方向也必须保持一致。比如一个格子四个顶点 p00、p10、p11、p01,无论怎么拆分,两个三角形共享的那条对角线方向要一致。如果格子之间对角方向时左时右,视觉上虽然几何没错,但光照会让曲面看起来有奇怪的折叠纹理。
3. 核心功能实现实录
3.1 表达式解析器:从字符串到抽象语法树
表达式解析是让 FuncPlotCalc 真正可用的关键一环。用户输入的是字符串,比如x^2 - sin(y) + exp(-x*y),程序必须先把它转化成可计算的数据结构。我实现的是一条完整的解析链路:词法分析 → 语法分析 → 抽象语法树求值。
词法分析把输入切成语义单元,也就是 token。需要识别数字、运算符、标识符、括号、逗号,其中数字识别要注意科学计数法,比如1e-3要作为一个完整 token 而不是拆散成1、e、-3。这个细节我第一次漏掉了,输入1e-3*x时解析结果完全错乱,排查了很久才发现是词法边界问题。
语法分析按运算符优先级递归下降。基本优先级从高到低是:括号 > 函数调用 > 幂运算 > 乘除 > 加减。递归下降的实现思路很直观:
// 解析函数主体:处理加减法 function parseExpression() { let node = parseTerm(); while (match('+') || match('-')) { const op = previous().type; const right = parseTerm(); node = { type: 'BinaryOp', op, left: node, right }; } return node; } // 处理乘除法 function parseTerm() { let node = parseFactor(); while (match('*') || match('/')) { const op = previous().type; const right = parseFactor(); node = { type: 'BinaryOp', op, left: node, right }; } return node; } // 处理幂运算、函数调用、括号和基本元素 function parseFactor() { if (match('IDENT') && peek().type === '(') { // 函数调用,如 sin(x) const name = previous().value; consume('('); const arg = parseExpression(); consume(')'); return { type: 'FunctionCall', name, arg }; } if (match('(')) { const node = parseExpression(); consume(')'); return node; } // 数字或变量 const token = advance(); return { type: 'Number', value: Number(token.value) }; }AST 求值阶段对每个顶点坐标 (x,y) 传入变量表,递归计算数值结果。我设计的变量表除了包含 x、y,还额外支持 a、b 两个参数。这个留口让后续做参数联动非常自然——拖动滑块时只需要更新变量表里的 a 或 b 值,再重新触发求值流程,字符串解析和语法分析完全不用重做。
这里我必须提一个替代方案。直接用new Function('x', 'y', 'return ...')动态生成 JS 函数,代码量可以大幅减少。但问题在于:
- 用户输入内容如果包含恶意代码,浏览器端执行环境就会裸奔,没有任何隔离。
- 错误提示极不友好,语法错误直接抛原始异常,用户根本不知道表达式哪里写错了。
- 变量参数化困难,要实现 a、b 滑块联动就得反复拼接字符串重新生成函数。
自定义解析器的成本,准确说也就多写两百行左右代码。换来的收益是:精确到字符位置的错误提示、完全可控的语法白名单、方便的参数绑定能力。长期维护项目时,这些价值远大于省下的编码时间。
3.2 顶点生成与索引构建的细节
网格生成的核心是双层循环,这里我贴一段核心代码:
function buildGeometry(ast, domain, nx, ny) { // 预分配 Float32Array,避免循环中频繁 push 造成数组扩容 const vertexCount = (nx + 1) * (ny + 1); const positions = new Float32Array(vertexCount * 3); const colors = new Float32Array(vertexCount * 3); const indices = new Uint32Array(nx * ny * 6); // 编号方式:先遍历 y,再遍历 x let id = 0; for (let iy = 0; iy <= ny; iy++) { const y = domain.ymin + (domain.ymax - domain.ymin) * iy / ny; for (let ix = 0; ix <= nx; ix++) { const x = domain.xmin + (domain.xmax - domain.xmin) * ix / nx; const z = evaluateAST(ast, { x, y }); const idx = id * 3; positions[idx] = x; positions[idx + 1] = z; // 数学 z 映射到渲染 y positions[idx + 2] = y; id++; } } // 三角形索引:每个格子拆两个三角形 let idx6 = 0; for (let iy = 0; iy < ny; iy++) { for (let ix = 0; ix < nx; ix++) { const a = iy * (nx + 1) + ix; const b = a + 1; const c = a + (nx + 1); const d = c + 1; // 三角形 1:a-c-b indices[idx6++] = a; indices[idx6++] = c; indices[idx6++] = b; // 三角形 2:b-c-d indices[idx6++] = b; indices[idx6++] = c; indices[idx6++] = d; } } return { positions, colors, indices }; }很多初学者会在顶点编号上栽跟头。编号必须与索引生成方式严格一致,我统一用“先 y 后 x”的嵌套顺序,也就是同一行内 x 递增,换行后 y 递增。这样行内相邻顶点编号差 1,下一行对应位置顶点编号差(nx+1),生成三角形索引时只需要一次简单算术就能定位,不需要额外的哈希表。
索引数组长度是nx*ny*6。每个格子生成两个三角形,每个三角形需要三个顶点索引,所以是nx*ny*6项。如果按nx*ny*3去分配,数组长度不够,运行时必然越界,轻则三角形缺失,重则整个曲面花掉。
法线方面,一开始我图省事,直接调computeVertexNormals()。简单曲面没问题,但碰到高频振荡的复杂曲面时,自动生成的法线会丢失细节。后来改成手动计算:先对每个三角形用 AB 和 AC 的叉积求面法线,再把共享顶点的多个面法线累加后归一化。手动计算虽然多了几十行,但峰谷处的高光细节明显更准。
3.3 交互控制与实时参数联动
交互控制用 OrbitControls 省力,但默认参数不完美,最关键的一项是旋转中心。默认的 target 是原点 (0,0,0),如果曲面在原点周围没问题,一旦用户把定义域改成 [3,8]×[3,8],曲面离原点很远,拖拽旋转的时候画面会绕着空气转,体验非常违和。
我的处理方式是在每次生成几何体之后,遍历一遍顶点坐标,算出包围盒中心,再把它设置为 controls.target,并调用 controls.update() 同步内部矩阵。这样无论定义域怎么改,旋转中心永远落在曲面正中。
参数联动方面,我预留了 a、b 两个滑块。用户在表达式里可以写a * sin(b * x) * cos(y)这类形式。拖动滑块时不需要重新解析字符串,只需要更新变量表里的 a、b 值,然后重新计算所有顶点 z 值,原地更新 BufferGeometry 的 position attribute:
function updateSurfaceParameters(a, b) { variables.a = a; variables.b = b; const positions = geometry.attributes.position.array; let id = 0; for (let iy = 0; iy <= ny; iy++) { const y = domain.ymin + (domain.ymax - domain.ymin) * iy / ny; for (let ix = 0; ix <= nx; ix++) { const x = domain.xmin + (domain.xmax - domain.xmin) * ix / nx; const z = evaluateAST(ast, variables); positions[id++] = x; positions[id++] = z; positions[id++] = y; } } geometry.attributes.position.needsUpdate = true; geometry.computeBoundingSphere(); }这里有个重要的性能细节:参数变化时不要重建整个 BufferGeometry,而应该原地更新 attribute 数组并设置needsUpdate = true。我第一版实现就是每次拖动滑块都新建 geometry,导致频繁产生新的 BufferAttribute,垃圾回收压力大,快速拖动滑块时帧率明显下跌。改成原地更新后,内存分配少了,流畅度提升非常明显。
三角形索引和法线是否需要同时更新?如果曲面拓扑结构没变,只是顶点 z 值变化,索引数组不用动。但法线需要重算,否则光照明暗不会跟上曲面形状变化,看起来会像一张“僵硬的贴图”。
颜色映射可以和 z 值联动。默认方案是用 z 值映射颜色,我建议用百分位而不是绝对范围。先收集所有 z 值排序,取 5% 和 95% 分位点作为映射上下界,超出的值截断到端点颜色。这样即使函数值域极其不均,比如 z=e^(x+y),颜色的大部分变化依然集中在有效区间,细节不会被极端值吞掉。
3.4 坐标轴、网格线与 UI 提示
坐标轴我单独用 LineSegments 绘制,不混入主曲面,方便在做显示/隐藏切换时只操作一个对象。轴刻度是拿 CSS 标签叠加在 canvas 上的,没用 Three.js 的 sprite。原因很简单,sprite 是纹理贴图,缩放时会糊,CSS 文字永远清晰,还能很容易地跟随鼠标悬浮显示。
状态栏上我放了两个实时信息:当前鼠标所在位置最近的曲面坐标值、以及“已跳过 N 个无效三角形”。这个 N 如果大于 0,说明表达式在定义域部分区域无定义,用户需要调整定义域范围或函数表达式,而不是程序出 bug。光这一个细节,就帮我少回答了很多“为什么这地方黑了一块”的问题。
4. 性能优化与疑难杂症排查
4.1 网格分辨率与交互流畅度之间的矛盾
网格分辨率直接决定曲面精细度和计算量。默认我给了 150×150,总顶点数是 151×151=22801 个,三角形约 45000 个。这个量级在普通笔记本电脑上做旋转交互,实测帧率能稳定在 50fps 以上。提高到 300×300,三角形数量超过 18 万个,在集成显卡上旋转时就明显感觉吃力,帧率掉到 30fps 以下。
最实用的优化策略是“交互时降分辨率、静止时升分辨率”。用户在按住鼠标旋转时,切换到 120×120 的低精度网格,保证旋转流畅;鼠标松开后,延迟 200ms 再切换到 250×250 的精细网格,让曲面细节完整呈现。这个策略比 LOD(按相机距离切换层级)更直观,也更容易维护,实际体验上用户几乎感知不到精度的切换过程。
4.2 NaN 与无穷大顶点:数学边界问题的工程处理
数学函数在定义域边界频繁出现非有限值,这是任何数学绘图工具都无法回避的。典型场景:
sqrt(x^2 - y^2)在y^2 > x^2区域无定义log(x+y)在x+y <= 0时负值无定义tan(x)在x=π/2 + kπ附近发散exp(x*y)在大坐标下溢出为 Infinity
如果直接把 NaN 或 Infinity 塞进顶点数组,渲染时会出现黑洞、三角形撕裂,更严重的是法线计算在遇到 NaN 后会链式污染,整个几何体的法线数组都可能变成非有限数,光照彻底错乱。
我的处理原则是不生成包含非法顶点的三角形。在生成索引阶段,每处理一个三角形,就检查三个顶点的 z 值是否都是有限数,有任何一个是 NaN 或 Infinity 就跳过这个三角形。这样一个简单的过滤,曲面在定义域边缘会形成自然缺口,而不是丑陋的黑洞。
值域溢出问题,比如exp(x*y)在 x=y=10 时,JS 浮点数直接返回 Infinity。这类溢出本质上也是非有限值,处理逻辑和显式 NaN 完全一致。我还在界面上加了个提示语“建议将定义域限制在函数定义区域内”,遇到熟悉数学表达式的用户基本都能立刻明白。
4.3 WebGL 兼容性与上下文丢失
低配设备的兼容性问题,如果不是实际部署到一堆老设备上,我根本不会主动处理。有一次在一台旧安卓平板的 WebView 里测试,初始化 Three.js 看起来一切正常,但渲染几帧后 WebGL 上下文直接丢失,页面黑屏没有任何提示。
Three.js 提供了webglcontextlost和webglcontextrestored事件。我的处理是:context lost 时暂停渲染循环,显示一个半透明遮罩,提示“渲染上下文丢失,正在尝试恢复”;context restored 时重新初始化渲染器和几何体。这个机制大多数情况能兜住异常,但极端情况下 context 无法恢复,遮罩上会显示“请刷新页面重试”的按钮。
WebGL1 与 WebGL2 的差异也要留意。Three.js 默认优先使用 WebGL2,但只支持 WebGL1 的旧环境里,部分高级材质特性会失效。FuncPlotCalc 里我刻意使用基础 MeshPhongMaterial 和 LineSegments,不使用 WebGL2 专属特性,这样降级到 WebGL1 时功能完全一致,只是抗锯齿和阴影质量略低。
4.4 调试这类可视化工具的三个实用技巧
调表达式解析器时,最有效的工具是“打印抽象语法树”。写一个极简的 dump 函数递归遍历 AST,输出节点信息。输入x^2 - 1,如果输出是(- (^ x 2) 1),说明优先级处理正确;如果变成(^ (- x 2) 1),一眼就能看出乘方和减法的解析顺序错了。这个工具比在 UI 上观察曲面形状去反推解析错误高效得多。
顶点数据不对时,先看端点坐标。我踩过最经典的坑是索引从 1 开始计数而不是 0,导致网格最后一行和第一行错位,画面上出现明显的“接缝”。遇到这类问题别急着看渲染结果,直接打印顶点数组的前 10 个和后 10 个元素,确认坐标范围和网格密度是否符合预期,几分钟就能定位。
性能瓶颈的判断不要靠感觉。我在开发时写了一个简单的性能日志,每次求值和渲染分别记录耗时。实测 200×200 网格时,AST 求值阶段耗约占整体帧预算的 70%,渲染阶段反而很稳定。这说明优化方向应该放在求值路径上,而不是盲目降低网格精度或者换更高性能的着色器。
4.5 一个容易被忽略的多边形裂缝问题
网格渲染里另一个常见问题是多边形裂缝,尤其在曲率变化剧烈的区域。简单网格下,三角形共享的顶点法线如果方向不一致,光照在三角形边缘会形成明显的不连续感。
我的处理是在生成顶点法线时做“面法线加权平均”,即对共享顶点的所有三角形面法线,按三角形面积加权后归一化。大三角形贡献更大的法向量权重,小三角形只做微调。这个细节在渲染z = sin(x)*cos(y)这类规则起伏曲面时不明显,但换成z = x*y*exp(-x^2-y^2)这种有尖锐峰谷的曲面时,曲面转折处的光滑度差异一眼就能看出来。
5. 后续扩展与个人经验
5.1 下一步的扩展空间
FuncPlotCalc 目前专注在显式函数场景,但做顺之后,后续可以自然扩展的方向其实不少。参数曲面是最容易的增量,因为只需把网格生成逻辑从“计算 z”换成“计算 (x(u,v), y(u,v), z(u,v))”,AST 体系和索引机制完全复用。等高线投影到平面、梯度向量显示、极值点自动标注,也都是站在当前架构上就能实现的实用功能。
我个人更想做的其实是“表达式模板库”。把典型教学案例,比如马鞍面x^2 - y^2、高斯曲面exp(-x^2-y^2)、正弦波纹sin(x)*cos(y)、旋转抛物面x^2+y^2都做成预置模板,用户点一下就可以载入,改写参数立刻看到变化。这个能力对于教学场景比一堆高级设置按钮更贴近需求。
5.2 我踩过几次坑后的几个固定习惯
第一,永远用预分配的 TypedArray,而不是动态 push。顶点数据量经常上万,动态数组扩容成本虽然不高,但累积起来影响明显,特别是快速拖动滑块时。提前分配好 Float32Array,所有索引写入循环,速度差异肉眼可见。
第二,修改 position attribute 之后,务必记得设置needsUpdate = true。这个标志不设,Buffer 数据不会重新上传到 GPU,参数改了画面上没有反应。我至少三次忘了设这个标志,浪费了不少排查时间。
第三,所有自定义变量,在求值前都要先归一到安全范围。比如用户输入的表达式里有除号,要默认加上“允许除零检测”,而不是等到画面出现黑洞才去反推是哪根线的问题。
第四,交互逻辑和几何生成逻辑分层。渲染事件回调里永远只做“触发标记”,真正费时的求值和 geometry 更新放到渲染循环里统一调度。这个模式让代码结构清晰,也避免因为频繁的 UI 回调造成帧率抖动。
如果你也想做一个类似的小工具,我的经验是别急着上各种炫酷技术,先把“最小闭环”跑通:一个输入框、一个曲面、一个鼠标拖拽旋转。把这个循环做到手感顺滑,剩下的功能都是增量,加参数、加颜色、加保存图片,自然就往里长。项目永远有边界,但使用场景会自己告诉你下一步该做什么。