ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Three.js构建浏览器3D参数曲面绘图工具:FuncPlotCalc实战解析

用Three.js构建浏览器3D参数曲面绘图工具:FuncPlotCalc实战解析 做 FuncPlotCalc 这个项目起因特别朴素。当时我正在准备一堂关于参数方程曲面的演示课课本上那些马鞍面、球面、环面的静态插图怎么看都少了点“活气”。学生很难从一张二维截面图里脑补出三维形态更别提理解两个参数 u、v 到底是怎样把一块矩形区域“扭”成一张曲面的。我搜了一圈现成的绘图工具要么只支持 zf(x,y) 这种显式函数要么参数方程功能残缺三个坐标分量没法同步编辑、实时预览。与其憋屈地用别人的半成品不如自己撸一个。于是就有了这个基于浏览器的 3D 参数方程绘图小工具FuncPlotCalc。它能做的事很简单——输入 xx(u,v)、yy(u,v)、zz(u,v) 三个分量表达式设置好 u、v 的定义域立刻在浏览器里渲染出可旋转、可缩放、带有光照着色的 3D 参数曲面。顺便说一嘴我看到的标题里第二个式子写成了 yx(u,v)实际应该还是 yy(u,v)毕竟三个分量得分别给出这种笔误在写公式时太常见了项目里可得分清楚。我写这篇文章就是想把这套工具从数学原理、技术选型到核心代码、踩坑实录完整捋一遍。不管你是正在学多元微积分、想做 3D 可视化还是想在浏览器里搞些实时图形的东西这篇应该都能让你少走不少弯路也能直接拿代码去改造成自己想用的版本。1. 项目定位从一个“画不出马鞍面”的痛点说起1.1 这个工具到底解决什么问题FuncPlotCalc 的核心问题域非常集中绘制由两个参数驱动的三维曲面。这类曲面在数学上叫参数曲面形式上就是标题里那三个式子x x(u, v)y y(u, v)z z(u, v)其中 u 和 v 是定义在某个矩形区间内的两个独立参数。把 u、v 在区间内取遍每一组 (u, v) 都能通过上面的公式算出一个三维点 (x, y, z)所有这些点拼在一起就构成了一张在空间中弯曲的曲面。听起来抽象其实它就是一张“会变形的地图”u、v 相当于地图上的经纬度而 x、y、z 是这个经纬度位置对应的三维坐标。这个工具的需求点非常明确支持两个参数的区间设置支持三个坐标分量的动态表达式支持实时渲染和交互查看。它面向的用户是两类人一类是像我这样要讲数学的可视化辅助者另一类是对 3D 图形、Shader、几何建模感兴趣想快速验证某个参数曲面的形状的开发者。它不追求成为 MATLAB 那样的重型计算平台只求打开网页、输入公式、立刻看到曲面。1.2 为什么现成工具总差那么一口气网上不是没有能画三维函数图像的软件GeoGebra、Desmos、MATLAB、Mathematica 都能干这活但实际用起来总有那么点不对劲。GeoGebra 和 Desmos 对参数曲面支持得比较弱GeoGebra 的 3D 视图更偏向于显式函数和空间几何体要画环面这种带两个圆环参数的曲面操作流程绕得你怀疑人生。MATLAB 和 Mathematica 功能确实强但一来贵二来重为了看一个曲面开个大软件总觉得小题大做。最关键的是这些工具都是“别人定义好的交互逻辑”你想加个颜色映射、换个光照方向、直接把网格数据导出成 OBJ几乎不可能。我当时想要的是一个“轻量、可控、能嵌入网页”的方案。FuncPlotCalc 核心逻辑其实不复杂解析表达式、生成网格顶点、构建索引、交给 GPU 渲染。这四条链路在浏览器里完全走得通而且全程可见、可改、可扩展比在闭源软件里折腾那点参数舒服多了。这也是我把技术方案定在 Web 端 Three.js 上的根本原因。2. 参数方程曲面的数学原理从“经纬度”到“地形图”2.1 一张图理解两个参数很多人第一次看到参数方程会觉得吓人其实它和地图导航一个道理。想象你手里有一张平面地图地图上的每个点用 (u, v) 表示这就是一个二维的参数域。现在有个魔法变换能把地图上的每个点都搬运到三维空间里的某个位置搬运规则就是 x(u,v)、y(u,v)、z(u,v) 这三个函数。你在地图上走一条直线在三维空间里走的可能是一条弯弯曲曲的曲线地图上的一块矩形区域在三维空间里可能被扭曲成球面、环面、螺旋面。拿球面举例。球面的参数方程可以写成x R * sin(u) * cos(v) y R * sin(u) * sin(v) z R * cos(u)其中 u 是纬度角从 0 到 π决定从北极到南极的扫过范围v 是经度角从 0 到 2π决定绕一圈的完整度。u、v 在区间内连续取值每一个经纬度组合都对应球面上的一个点。所有点连起来球面就出来了。理解了经纬度你就理解了参数曲面。2.2 参数域离散化连续公式变成可渲染网格计算机没办法处理“连续”的概念屏幕上的曲面本质上是由一个个三角形拼成的。把连续公式变成三角形网格标准流程分三步第一步采样。把 u 的定义域 [u_min, u_max] 分成 nU 等份把 v 的定义域 [v_min, v_max] 分成 nV 等份得到 (nU1) × (nV1) 个网格点。nU 和 nV 就是网格密度密度越大曲面越精细性能开销也越大。第二步映射。对每个网格点 (u_i, v_j)代入三个分量表达式计算出对应的空间坐标 (x, y, z)。这一步纯粹是数值计算解析器快慢直接影响整体的流畅度。第三步拓扑。把每个由相邻四个点围成的小矩形拆成两个三角形。这样整张曲面就变成了一个三角形网格可以交给 GPU 渲染。索引缓冲区的构造有固定套路后面代码部分细说。这三个步骤是整条链路里最核心、最容易出错的地方也是我调试时花时间最多的地方。2.3 几种常见曲面的参数方程对照这个表格是我平时用得最多的几个例子拿来测工具再好不过曲面名称x(u,v)y(u,v)z(u,v)参数区间建议球面R sin u cos vR sin u sin vR cos uu∈[0, π], v∈[0, 2π]环面(Rr cos u) cos v(Rr cos u) sin vr sin uu∈[0, 2π], v∈[0, 2π]马鞍面双曲抛物面uvu² − v²u∈[−2, 2], v∈[−2, 2]螺旋面u cos vu sin vh vu∈[0, 3], v∈[0, 4π]双曲参数曲面示例cosh u cos vcosh u sin vsinh uu∈[−1, 1], v∈[0, 2π]马鞍面是我最推荐的入门测试案例因为它的形状特征非常明确沿一个方向向上弯沿另一个方向向下弯一眼就能看出 u、v 两个参数对曲面的独立影响。环面则是测试“参数闭合”的经典案例v 必须扫过完整的 2π两端才能无缝拼上。3. 技术选型与开发准备为什么是 Three.js 加浏览器3.1 方案对比桌面端、原生 WebGL 还是 Three.js开发这类工具摆在面前的技术路线大致有三条桌面应用、原生 WebGL、基于 Three.js 的 Web 方案。我直接拉了个表对比方案上手难度部署形态生态成熟度实时交互可嵌入性Electron/桌面应用中高安装包中好差原生 WebGL极高网页低什么都要自己写好好Three.js低网页极高好好对 FuncPlotCalc 这种“给人演示、快速分享”的工具来说网页是唯一让我满意的形态。写完了往静态服务器一丢别人打开链接就能用不需要安装任何东西。原生 WebGL 当然也可以做但相机控制、光照模型、几何体管理这些底层细节会占用大量时间。Three.js 把这些封装得恰到好处它不会把你锁死在某一种渲染风格里又帮你把最繁琐的渲染状态管理干掉。所以我最后选了 Three.js。3.2 开发环境搭建与一个绕不过去的坑技术栈定下来之后环境搭建其实很简单。我用 Vite 做开发服务器和构建工具Three.js 直接用 npm 安装。核心依赖就三个three、vite、以及一个表达式解析库后面详细展开。这里必须提一个我在网上反复看到的问题简直可以称为 Windows 用户第一坑命令行执行pnpm install或者make build的时候系统直接报错“无法将‘pnpm’项识别为 cmdlet、函数、脚本文件或可运行程序的名称”。本质原因很简单——这个命令对应的可执行文件根本不在 PATH 环境变量里或者压根没装对应的工具链。解决办法分两步走先把 Node.js 官方安装包装好确认node -v和npm -v能输出版本号然后如果是想让 pnpm 可用执行npm install -g pnpm再把 npm 的全局安装目录加到系统 PATH 里。make就更简单Windows 上没有原生 make要么装 Git Bash 自带的工具链要么直接用 npm 脚本替代。我自己的做法是开发时用npm run dev构建用npm run build完全绕开 make 这个麻烦。一个真实可用的package.json核心片段长这样{ name: funcplotcalc, scripts: { dev: vite, build: vite build, preview: vite preview }, dependencies: { mathjs: ^11.0.0, three: ^0.160.0 }, devDependencies: { vite: ^4.4.0 } }装完环境整个项目的核心就剩四个模块表达式解析器、曲面网格生成器、法向量计算器、UI 交互层。下面一个个拆开讲。如果只是想快速验证曲面形状想省去搭建工程的麻烦用 CDN 引入 Three.js 也行直接在 HTML 里写script typeimportmap就能跑起来。但项目后续要维护、要扩展正经工程结构还是值得搭一个。4. 核心实现从字符串表达式到可交互的 3D 曲面4.1 表达式解析别急着 eval安全第一用户输入的是字符串形式的数学表达式比如sin(u) * cos(v)。把字符串变成可计算的函数第一反应往往是eval()但这在浏览器里是个非常危险的习惯直接执行用户输入等于把整个页面暴露在任意代码注入风险下。而且eval不好调试语法错误反馈也差。正确的做法是使用专门的表达式解析库我这里选了 mathjs。它自带完整的词法分析、语法树构建和求值引擎还支持自定义变量和函数。使用时先把用户输入的字符串编译成表达式对象import { parse, compile } from mathjs; function compileExpression(exprText) { const node parse(exprText); const compiled node.compile(); return compiled; } // 之后每次求值 const exprX compileExpression(R * sin(u) * cos(v)); const scope { u: 1.2, v: 0.8, R: 3 }; const x exprX.evaluate(scope);mathjs 的好处是函数全sin、cos、tan、exp、log、sqrt、abs、pow 等等、变量作用域清晰、错误提示相对友好。当用户在输入框里打了sin(u) * cos(v这种少括号的表达式时parse阶段就会直接抛异常你可以在 UI 上把错误高亮出来而不是等到渲染时出现一片 NaN。注意编译一次可以反复调用evaluate千万不要在每一帧的循环里重复parse。初始compile的开销不大但每帧几千次解析纯粹是浪费我刚开始也犯了这毛病网格一密就卡。4.2 顶点生成与索引缓冲双循环构造曲面骨架表达式解析完了接下来就是生成几何体。我用的思路是用一个二维循环遍历所有网格点计算每个顶点的坐标同时生成三角形索引。function generateSurfaceVertexData(exprX, exprY, exprZ, uMin, uMax, vMin, vMax, nU, nV) { const positions []; const uStep (uMax - uMin) / nU; const vStep (vMax - vMin) / nV; const scope { u: 0, v: 0 }; for (let i 0; i nU; i) { const u uMin i * uStep; scope.u u; for (let j 0; j nV; j) { const v vMin j * vStep; scope.v v; const x exprX.evaluate(scope); const y exprY.evaluate(scope); const z exprZ.evaluate(scope); positions.push(x, y, z); } } const indices []; for (let i 0; i nU; i) { for (let j 0; j nV; j) { const a i * (nV 1) j; const b a 1; const c a (nV 1); const d c 1; indices.push(a, b, c); indices.push(b, d, c); } } return { positions, indices }; }这段代码里最容易搞错的就是顶点索引的映射关系。网格点总数是 (nU1) × (nV1)第 i 行第 j 个点的索引是i * (nV1) j不是i * nV j。我第一次写顺手写成i * nV j结果网格一超过一行顶点就串位曲面直接“拧麻花”。这个错误排查了将近两个小时才定位到后来每次写都在纸上先把索引矩阵画一遍。两个三角形拆分的规则也要固定下来每个小矩形由顶点 a、b、c、d 构成三角形一是 (a, b, c)三角形二是 (b, d, c)。只要整个网格都按同一个规则三角形就不会交叉重叠法向量方向也才会一致。拆分的绕序直接影响后面光照计算的正面朝向这个后面在法向量部分还会提到。4.3 顶点法向量计算没有正确的法线就没有“立体感”曲面画出来了但如果直接丢给 Three.js 去渲染你会发现它是一片诡异的扁平色块一点立体感都没有。原因只有一个法向量不对。亮面暗面、高光区域全是靠法向量和光线方向的夹角算出来的法向量错了光照效果全乱。Three.js 的BufferGeometry会调computeVertexNormals()它默认根据三角形叉积自动算法线。这个方法对封闭的几何体效果不错但对参数曲面这种由大量细长三角形拼成的网格平均出来的法向量往往不够平滑曲面容易出现“棱角感”。我当时的选择是自己算思路非常直接每个三角形叉积出一个面法向量该三角形的三个顶点分别累加这个面法向量最后把所有顶点的法向量归一化。function computeVertexNormals(positions, indices) { const normals new Float32Array(positions.length); for (let i 0; i indices.length; i 3) { const ia indices[i] * 3; const ib indices[i 1] * 3; const ic indices[i 2] * 3; const ax positions[ia], ay positions[ia 1], az positions[ia 2]; const bx positions[ib], by positions[ib 1], bz positions[ib 2]; const cx positions[ic], cy positions[ic 1], cz positions[ic 2]; const ux bx - ax, uy by - ay, uz bz - az; const vx cx - ax, vy cy - ay, vz cz - az; const nx uy * vz - uz * vy; const ny uz * vx - ux * vz; const nz ux * vy - uy * vx; normals[ia] nx; normals[ia 1] ny; normals[ia 2] nz; normals[ib] nx; normals[ib 1] ny; normals[ib 2] nz; normals[ic] nx; normals[ic 1] ny; normals[ic 2] nz; } for (let i 0; i normals.length; i 3) { const len Math.sqrt(normals[i] * normals[i] normals[i 1] * normals[i 1] normals[i 2] * normals[i 2]); if (len 1e-10) { normals[i] / len; normals[i 1] / len; normals[i 2] / len; } else { normals[i] 0; normals[i 1] 1; normals[i 2] 0; } } return normals; }这段代码里有个细节值得解释为什么要把共享顶点的法向量累加再归一化因为一个顶点往往被周围四到六个三角形共用直接用某一个三角形的面法向量会造成曲面“棱角感”累加后取平均相邻三角形的法向量过渡才会平滑看起来才像光滑曲面。至于最后一个len 1e-10的分支是为了处理曲面上的退化三角形——比如参数区间端点处坐标完全重合时叉积为零这种情况下随便给一个朝上的法向量总比给一个 NaN 把渲染管线炸掉强。有了正确的法向量和MeshPhongMaterial或者MeshStandardMaterial曲面一下就有了立体感。我还加了一个小功能根据 z 坐标的高度值给顶点上色用 HSL 颜色映射低处偏蓝、高处偏红。这样不仅能看出形状还能一眼看出高度分布。// 高度颜色映射 function computeHeightColor(zValues, zMin, zMax) { const colors []; const range zMax - zMin || 1; for (let i 0; i zValues.length; i) { const t (zValues[i] - zMin) / range; const hue 0.66 - t * 0.66; const color new THREE.Color().setHSL(hue, 0.8, 0.5); colors.push(color.r, color.g, color.b); } return colors; }4.4 UI 与相机交互让曲面“转起来”渲染层面搞定后交互是另一个大头。Three.js 自带轨道控制器OrbitControls鼠标拖拽旋转、滚轮缩放、右键平移一套齐全不用自己造轮子。import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js; const controls new OrbitControls(camera, renderer.domElement); controls.enableDamping true; controls.dampingFactor 0.08; controls.autoRotate false;但我建议开一下阻尼效果enableDamping否则旋转时画面会显得非常“生硬”开了之后镜头有惯性手感接近 Maya 或 Blender 那种顺滑感。UI 面板方面我用最简单的 HTML 表单完成三个输入框分别填 x、y、z 的表达式四个数字输入框设置 uMin、uMax、vMin、vMax两个滑条控制 nU 和 nV 的细分密度再加一个“重新绘制”按钮。每次点击按钮后台重新编译表达式、重新生成顶点和索引、重新计算法向量然后把旧的网格替换掉。整个过程毫秒级完成完全没压力。关于重绘频率我建议使用手动触发的按钮而不是每次输入都自动重算。表达式解析虽然在毫秒级但频率高了还是会打断用户输入节奏而且用户在输入sin(u)的中间状态时表达式是残缺的解析必然失败不断弹错误提示会把人逼疯。改成手动触发之后输入体验和错误体验都好了很多。这也是一个在设计上比“实时预计算”更合理的取舍。5. 调试实录我在实际运行中遇到的几个典型问题5.1 曲面破面与法线翻转曲面破面是第一个让我头疼的问题。渲染结果像是被人撕开了一道口子或者一块区域的颜色明显比周围深。排查后发现这多半是顶点索引顺序和三角形绕序的问题。顶点绕序决定了三角形的正面朝向const a i * (nV 1) j这个公式如果行和列搞反三角形就会部分正面朝外、部分朝内灯光照射时该亮的不亮该暗的反而亮。更隐蔽的是法线方向不一致整体会看起来像“阴阳面”。解决办法是画完网格后检查相邻两个三角形的法线方向是否一致。如果相邻三角形的法线夹角超过 90 度说明绕序错了。我当时是写了一段 Debug 代码把每个三角形的法线方向和中心点位置打出来逐个对比瞬间定位到索引映射函数里1的位置错了。后来长记性了这种索引公式写完后第一件事就是纸上画 2×2 的小网格验证。5.2 明暗闪烁与 z-fighting网格调对了新的问题又来了旋转视角时曲面表面出现密集的“闪烁亮斑”和“条纹抖动”。这是典型的 z-fighting 现象——两个三角形面几乎共面深度缓冲精度不够导致哪个三角形该在前面不断跳变。造成 z-fighting 的原因通常有三个。第一网格细分太密细长三角形过多相邻面深度差太小第二相机远近裁剪面距离差太大深度精度被稀释了第三两个重叠的曲面层比如同时绘制了两个贴得很近的曲面互相打架。我遇到的是前两种叠加。解决办法分两步把相机near和far的距离缩小——我一开始设的是near0.1, far10000深度精度大量浪费在空白区域改成near0.1, far1000之后闪烁立刻缓解了很多第二步是动态调整网格密度nU、nV 超过 200 时视觉收益已经极低但性能成本和 z-fighting 风险都在上升在 UI 里我把滑条的默认值设为 60上限限制在 300从源头避免这种问题。5.3 表达式解析与数学函数名的坑很多用户输入sin(u)会习惯性写成sin u或者输入2u、2*u这种隐式乘法。mathjs 对标准数学记法的支持其实不错但尽量在 UI 说明中强调要写*和括号降低出错的概率。另一个很坑的点是变量名冲突。我之前在表达式里用S作为环面的大半径变量结果用户在另外一个表达式里也写了s大小写不同但它们其实是同一个变量这会导致用户以为函数没有变实际数值已经悄悄变化了。为了避免这种困惑我把工具限定为只接受u和v两个内置变量其他常量需要在表达式里直接写数字。这在自由度上做了取舍但对大多数教学场景来说足够用了。5.4 性能问题网格密度与渲染优化网格密度 nU、nV 与顶点数、三角形数的关系是(nU1)×(nV1) 个顶点nU×nV×2 个三角形。nUnV100 时就是 10201 个顶点和 20000 个三角形这个数量级在 Three.js 下毫无压力但 nUnV500 时顶点数飙升到 25 万三角形数 50 万即使 Three.js 能扛住移动端也会明显掉帧。性能优化的核心是“按需动态更新”。用户旋转、缩放时不需要重新生成网格只有点击重绘按钮时才重新计算修改颜色映射也不需要动几何体只需要更新geometry.attributes.color。还有一点如果只是改颜色值用geometry.attributes.color.needsUpdate true标注更新否则 GPU 不会重新上传数据。这些细节高峰期能省掉你一大半的 Frustum Culling 性能损耗。6. 项目扩展方向与个人体会FuncPlotCalc 目前被我当作一个“自用趁手、分享方便”的工具。但如果想把它做成一个正经产品可以扩展的地方还很多。最直接的是支持动画参数。比如加一个时间变量 t让曲面的某个参数随时间变化这样就能看到曲面在空间中“生长”或“扭动”的过程对演示来说效果非常惊人。实现上完全复用现有管道每次重绘时把 t 作为另一个变量注入编译好的表达式同时用requestAnimationFrame循环驱动就能做出一个动态参数曲面动画。其次是隐式曲面支持也就是 f(x,y,z)0 这种形式但这需要用 marching cubes 做体素化工作量直接上了一个量级暂时我就不在这个工具里加了。最后是导出功能把生成的顶点、索引和颜色数据序列化成 OBJ 格式交给 Blender 或 May 做进一步处理。OBJ 格式很简单顶点、法线、面索引各写一段就能被外部工具识别。最后说说我的个人体会。做这个工具最大的收获不是“写了个能画曲面的网页”而是把数学公式和计算机图形学之间的那层窗户纸捅破了。参数方程曲面这个东西书上看一万遍不如亲手输入sin(u)*cos(v)然后看着球面在屏幕上旋转一遍来得直观。工具本身没有什么高深算法解析靠 mathjs渲染靠 Three.js核心代码满打满算几百行但它让我真切理解了顶点缓冲、索引缓冲、法向量、光照模型这些概念是怎么串在一起的。我后来总跟学生说一句话凡是你能用公式描述的东西计算机都能把它画出来关键在于你敢不敢把一个“连续”的数学对象拆成成千上万个离散的三角形然后再让 GPU 去补完剩下的部分。FuncPlotCalc 就是把这件事做到了极致轻量。如果这个项目能让你也产生“原来曲面是这样画出来的”的感觉那这篇文章的目的就达到了。有事没事打开它试试改两个参数转一转视角你会看见数学长什么样。
返回列表