资讯详情 用RK4算洛伦兹吸引子:Canvas 2D动态可视化与KaTeX公式渲染
📅 2026/10/11 22:33:03
纯数值的混沌轨迹其实是最难看懂的计算结果之一。这个通用计算系列做到第十四篇前面的内容一直都在讲怎么算、怎么写文件、怎么把数据整理干净但到了洛伦兹吸引子这里数字本身已经没法说明问题了你手里有一串超过两万行的(x, y, z)坐标肉眼扫过去除了头尾几个数关键结构一点都提取不出来。这应该是整个系列里最适合拿来聊“结果展示”的一篇——混沌系统的蝴蝶翅膀只有画出来才存在方程组只有排成公式才读得懂。所以这篇文章的主线很明确后端先用 RK4 步进算出洛伦兹吸引子的轨迹数据前端用 Canvas 2D 把轨迹渲染成动态的旋转视图再用 KaTeX 把 Lorenz 方程组真正“印”在页面上。如果你正在做任何科学计算相关的 Web 项目——不管轨迹、流体、还是单纯的数学公式展示——这篇里提到的投影、颜色编码、公式渲染思路都能直接搬走。我先讲计算端的数据产出再讲展示端的设计最后把 KaTeX 的使用细节和实际调试中踩过的坑分开说。1. 为什么选洛伦兹吸引子来讲“结果展示”1.1 混沌系统是检验可视化方案的天然样本洛伦兹吸引子是三变量非线性常微分方程组的数值解它最出名的地方是“蝴蝶效应”初始条件极小的差异会让两条轨迹很快走向完全不同的区域。但实际上无论你从哪个初始点出发轨迹最终都会被吸引到一个固定的几何结构上两个像翅膀一样的曲面互相缠绕既不重复也不相交。这个特性意味着计算结果天然具有层次感——你既能看到宏观的双螺旋结构也能看到微观的折叠细节对可视化方案的要求非常真实。换成别的数据比如直线运动或简谐振荡你随便画一条折线就完事了根本暴露不出现有方案的缺陷。只有混沌轨迹这种跨度大、细节多的数据才会逼着你认真处理坐标映射、颜色连续性和渲染性能。这也是我坚持把它放到“通用计算”系列里的原因洛伦兹系统本身不重要重要的是你得学会怎么把一个高维、长序列的计算结果可靠地交给用户的眼睛。1.2 这一篇的完整技术链路整条链路分成四段Node.js 里用经典 RK4 步进积分洛伦兹方程把轨迹点序列序列化成一个轻量 JSON前端拿到 JSON 后用三维旋转矩阵把点投到 Canvas 2D 平面按颜色渐进区分时间先后一帧一帧把轨迹画出来页面角落再用 KaTeX 渲染 Lorenz 方程组让读者在看图的同时能对照方程理解系统结构。不要被“为什么不用 Three.js”“为什么不用现成图表库”这类问题干扰。对 2 万个轨迹点原生 Canvas 2D 完全扛得住而且自己手写投影矩阵之后你会对三维渲染的底层逻辑理解得更扎实。KaTeX 部分则是承接了本系列一直强调的“计算要有可读性”一个计算结果如果连生成它的方程都显示不清楚展示就缺了一半。2. 计算端的产出RK4 步进、参数稳定性与数据格式2.1 洛伦兹方程回顾与取参逻辑洛伦兹系统由三个一阶常微分方程组成dx/dt σ (y - x) dy/dt x (ρ - z) - y dz/dt x y - β z其中 σ 是普朗特数ρ 是瑞利数β 是几何系数。计算时最经典的参数组合是 σ10、ρ28、β8/3这个组合正是混沌行为最典型的区间。ρ 值如果低于 24 左右轨迹会衰减到某个稳定平衡点太高又会逐渐呈现周期性只有 28 附近这种中间区域轨迹才会像蝴蝶一样在双翼之间来回跳跃。参数选错了你画出来的可能只是一个点或者一个圆环也就没有展示的必要了。初始值通常取(1, 1, 1)因为洛伦兹系统对初值极其敏感稍微偏移就会得到完全不同但宏观结构一样的轨迹。对展示来说初值选取并不影响视觉形态只是影响具体路径所以这里没有必要纠结精确的初值。2.2 Node.js 侧的 RK4 实现与步长选择四阶龙格-库塔方法是这类常微分方程最通用的数值解法精度和实现难度平衡得很好。核心逻辑是用四个中间斜率加权得到下一步增量而不是简单地用x dx/dt * h。写成函数大概是这样function lorenz(state, sigma, rho, beta) { const [x, y, z] state; return [ sigma * (y - x), x * (rho - z) - y, x * y - beta * z, ]; } function rk4Step(state, h, sigma, rho, beta) { const k1 lorenz(state, sigma, rho, beta); const s2 state.map((v, i) v (h / 2) * k1[i]); const k2 lorenz(s2, sigma, rho, beta); const s3 state.map((v, i) v (h / 2) * k2[i]); const k3 lorenz(s3, sigma, rho, beta); const s4 state.map((v, i) v h * k3[i]); const k4 lorenz(s4, sigma, rho, beta); return state.map((v, i) v (h / 6) * (k1[i] 2 * k2[i] 2 * k3[i] k4[i]) ); }这里最影响结果质量的是步长 h。步长太大数值误差会被混沌系统本身放大轨迹直接跑飞步长太小数据量暴增前端渲染压力大。我这个项目里取 h0.005总积分时间 T100也就是从 t0 到 t100共 20000 个轨迹点。这个密度在两万级已经完全够用翅膀的层叠结构肉眼看起来是连续光滑的。如果你需要极致的细节可以尝试 h0.002但展示端就要考虑抽稀。2.3 数据落地JSON 规格与坐标范围预统计计算完成后不要急着直接把数组怼给前端。我先在服务端做两件事一是按固定的 JSON 结构输出二是顺手统计坐标的 min/max。结构大概像这样{ params: { sigma: 10, rho: 28, beta: 2.6666666666666665, h: 0.005, stepCount: 20000 }, range: { x: [-18.5, 19.8], y: [-24.3, 26.9], z: [0.8, 47.5] }, points: [ [1, 1, 1], [1.000037, 1.000375, 1.000135], ... ] }坐标范围预统计是个很容易被忽略但特别关键的细节。前端要自动缩放到合适视野如果靠实时遍历两万点去算 max/min既要多写一遍循环又容易在渲染阶段反复计算。服务端一次性算完放进range字段前端直接取用省时省力。点的存储用三元数组[x, y, z]而不是对象数据体积会小不少解析也更快——20000 个三元数组的 JSON 大约 600KB对本地展示完全够用。传输侧如果嫌大可以用JSON.stringify后做一层压缩但这次直接走本地文件没必要。3. 展示端的关键设计坐标系、投影与颜色编码3.1 把三维点投到二维画布旋转投影矩阵直接在 Canvas 上画二维折线很简单但洛伦兹吸引子的价值在三维结构所以必须先做投影。我选择的是正交投影加旋转矩阵先绕 Y 轴旋转一个角度再绕 X 轴旋转一个角度最后把三维坐标映射到二维平面坐标。这样写出来的代码很少还能手动控制视角。function project(point, rotY, rotX) { const [x, y, z] point; // 绕 Y 轴旋转 const x1 x * Math.cos(rotY) z * Math.sin(rotY); const z1 -x * Math.sin(rotY) z * Math.cos(rotY); // 绕 X 轴旋转 const y1 y * Math.cos(rotX) - z1 * Math.sin(rotX); const z2 y * Math.sin(rotX) z1 * Math.cos(rotX); return { x: x1, y: y1, z: z2 }; }得到旋转后的坐标后再做一个固定缩放和平移把 z 轴信息弱化只保留 x/y 作为屏幕坐标。我用的缩放系数是scale Math.min(canvasWidth, canvasHeight) / 60大概能让翅膀宽度占画布主体的三分之二。偏移量直接使用画布中心点。这里选择正交投影而不是透视投影是因为洛伦兹轨迹的 z 轴范围虽然从 0.8 到 47但旋转过程中它在屏幕上的变形会被透视进一步放大造成一种不必要的“镜头畸变”正交投影的视觉层次更干净。3.2 用颜色编码时间维度读图一目了然轨迹点没有显式的时间轴如果只画一条白色的线你根本无法分辨蝴蝶是先画左翼还是先画右翼。这就要用颜色把时间“画”出来。我采用的方案是把总步数映射到 HSL 色相的连续区间从蓝色渐变到紫红。具体做法是每一小段折线根据它所在的时间位置计算色相function colorForProgress(p) { // p 从 0 到 1对应从 t0 到 tT const hue 260 * (1 - p); // 从 260°蓝紫渐变到 0°红 const saturation 85; const lightness 45 15 * Math.sin(p * Math.PI); // 中间亮两端暗 return hsl(${hue}, ${saturation}%, ${lightness}%); }为什么选蓝到红而不是彩虹七色因为彩虹渐变在色相环上跨越太大视觉上容易产生“割裂感”读者会误以为轨迹被分成了好几段无关的线。而蓝到紫红这种窄范围渐变保持了连续性时间信息又清晰可读。同时亮度在中段略高能让轨迹最繁复的中间部分看起来更有立体感。3.3 逐帧绘制的时间切片策略20000 个点如果一次性全画出来Canvas 在大多数电脑上仍然能跑但会有一个明显的停顿感。为了让展示过程本身也像“计算结果的生长动画”我用requestAnimationFrame按时间切片绘制每一帧只增加 400 个轨迹点整体效果就是轨迹从初始点出发一圈一圈逐渐展开成完整的蝴蝶翅膀。这个设计对理解混沌系统的演化非常有帮助——你等于是让用户亲眼看到轨迹在相空间里游走的过程。let drawnCount 0; const frameStep 400; function drawFrame(points, range, rotY, rotX, ctx) { const end Math.min(drawnCount frameStep, points.length); ctx.beginPath(); for (let i drawnCount; i end; i) { const p project(points[i], rotY, rotX); const screenX centerX p.x * scale; const screenY centerY p.y * scale; const hue 260 * (1 - i / points.length); if (i drawnCount) ctx.moveTo(screenX, screenY); else ctx.lineTo(screenX, screenY); } ctx.strokeStyle hsl(${hue}, 85%, 50%); ctx.stroke(); drawnCount end; if (drawnCount points.length) { requestAnimationFrame(() drawFrame(points, range, rotY, rotX, ctx)); } }这段代码把同一色相的一帧轨迹作为一条子路径去画避免了全局 statestyle 频繁切换。色相在小范围内基本不变视觉上又能保持连续渐变性能与效果都能兼顾。如果你觉得 400 个点一帧太慢可以调整frameStep但注意步长太大会让帧与帧之间的色相跳变明显。实测中比较舒服的经验是20000 个点控制在 1 到 2 秒内长满全图帧率稳定在 60fps。4. 在页面上“印”方程KaTeX 语法与 Lorenz 方程组4.1 为什么选 KaTeX 而不是图片或纯文本方程在页面上呈现这事很多项目都是直接贴一张别人渲染好的图片或者干脆用纯文本dx/dt sigma(y-x)。这两种做法其实都有硬伤。图片的问题是不能改、不能复制、多了以后页面体积膨胀纯文本的问题是分式、上下标全部变形读起来像天书。用 MathJax 效果是好但体积太大初始化慢对博客这种轻量页面不划算。KaTeX 的特点是快纯 JS 实现支持服务端渲染成 HTML 字符串也可以在前端直接挂载。它渲染出来的公式是可复制的文本结构CSS 控制样式体积和渲染性能在一众数学公式方案里属于第一梯队。对洛伦兹吸引子这种页面来说公式只是一块静态说明区KaTeX 的轻量特性正好匹配。4.2 用 renderToString 渲染方程组KaTeX 的核心 API 是renderToString(tex, options)它把 LaTeX 风格公式字符串解析成 HTML。渲染 Lorenz 方程组最直接的做法是用cases环境把三个方程排成一组const katex require(katex); const tex String.raw \begin{cases} \frac{dx}{dt} \sigma (y - x) \\ \frac{dy}{dt} x (\rho - z) - y \\ \frac{dz}{dt} x y - \beta z \end{cases} ; const html katex.renderToString(tex, { displayMode: true, throwOnError: false, }); document.getElementById(formulas).innerHTML html;这里有两个参数值得解释。displayMode: true表示公式独立成行显示而不是嵌在句子里方程组显然应该独立成行throwOnError: false是让解析失败时不抛异常中断页面而是把报错信息渲染成红字调试友好。在浏览器环境直接把生成的 HTML 插到某个容器就行跟我上面写的一样。如果你要在 Node.js 服务端用也可以直接require(katex)然后拿 HTML 字符串拼到页面模板里。这是 KaTeX 比很多浏览器端公式库好用的地方服务端渲染结果可以直接存成静态 HTML用户访问时连公式渲染这一步都省了。4.3 反斜杠转义与其他常见坑KaTeX 使用过程中最容易翻车的就是 JS 字符串里的反斜杠。\frac在普通字符串里会被 JS 解释成\f——这是一个换页控制字符直接导致整个公式变成乱码或解析失败。很多第一次用的人都会在这里卡半小时。解决办法有两个一是用我上面写的String.raw模板字符串让反斜杠原样保留二是把公式字符串写成\\frac{dx}{dt} \\sigma (y - x)即每个反斜杠改写成两个。另外HTML 里的特殊字符也要注意。比如公式里出现的时候应该直接写公式原体让 KaTeX 自己处理不要手贱在公式字符串里写amp;否则渲染出来会多出内容。还有cases环境里的换行\\同样受 JS 转义影响在普通字符串中尤其容易出错。还有一个隐藏问题KaTeX 的字体依赖它自己打包的 CSS 和字体文件。如果你只引 JS 忘了引 CSS公式会对不齐或者直接显示成乱码。使用 npm 包的话需要手动引入katex/dist/katex.min.css。5. 实测复盘步长、连线与帧率的三个坑5.1 步长从 0.01 改成 0.05 之后蝴蝶不见了我在调展示效果时做过一组对比测试分别用 h0.005、0.01、0.05 生成轨迹。h0.01 时画出来的翅膀已经开始出现明显的不对称h0.05 时整个轨迹直接变成一团乱麻甚至在某些区段跳变到另一个平衡点附近。原因很简单混沌系统会把每一步的数值误差指数放大RK4 本身虽然精度可观但步长一旦大了局部截断误差超过混沌敏感性允许的范围轨迹就不再严格落在吸引子上。这个坑的教训是当你发现“计算结果的展示形态”不对先回过头检查数值积分参数而不是怀疑渲染。展示端再正确也画不出一个数值已经跑飞的轨迹。反过来在调整展示效果时如果你想获得更精细的翅膀纹理优先减小积分步长而不是简单增加步数——步数多了但步长太大结果只会更糟。5.2 首尾相连的误画轨迹线的端到端处理把 2 万个点一次全部当成一条 polyline 绘制时Canvas 会默认把最后一个点和第一个点连起来形成一条横跨整个画布的大斜线。洛伦兹轨迹首尾本来就不应相连这条斜线会瞬间破坏整个视觉结构。我最初就踩了这个坑第一版展示图里蝴蝶翅膀中间横着一条诡异的直线看起来就像数据结构错乱。解决方式有两种。一是像我在时间切片方案里那样每一帧都是独立的beginPath天然避免跨帧连线。二是如果你确实要一次性画完全部轨迹应该从i1开始并且每次moveTo到当前点再lineTo到下一个点而不是在i0时只moveTo一次。总之轨迹这种首尾不相接的数据千万不要把整段当成闭合路径去画。5.3 性能优化记录20000 点的 Canvas 绘制全量绘制的极端测试下Canvas 2D 在 2 万点线段上的表现其实还可以但加上逐条设置strokeStyle和shadowBlur之后帧率会掉到 20 帧以下。我最终做了三个优化。第一把shadowBlur: 0显式关掉Canvas 的阴影计算对长折线开销极大省掉之后立竿见影第二每次只用离散的色相步进代替逐点改色把大量子路径合并成同一个绘制调用第三如果页面还有其他动画用requestAnimationFrame的帧循环统一处理而不是每个模块各自调setInterval。实测下来分帧增量绘制时帧率稳定在 60fps一次性全量绘制也能跑到 45fps 以上。对博客展示这种场景已经完全够用。如果数据量真的冲到 50 万点那就得考虑降采样或者换OffscreenCanvas做预渲染了。6. 再往后可以怎么扩展轨迹展示稳定以后你完全可以在同样的骨架上继续加东西拖拽控制旋转角公式与轨迹联动甚至把参数也做成可调节滑块。我最想建议你先试的是拖拽旋转——把mousemove的偏移量映射到rotY和rotX每次重绘时更新视角效果比静态一张图震撼得多而且代码量不大。再进一步你可以把 ρ 参数做成滑动条实时重算轨迹这就能让读者直观看到混沌系统在不同参数下的形态变化从稳定点变成周期环再到蝴蝶翅膀的整个过程都能动起来。KaTeX 这边的扩展思路也很多可以把 σ、ρ、β 的取值和数值积分步长写进页脚用公式完整展示当前计算配置也可以把 RK4 的更新公式渲染成小块放到代码旁边帮助读者把代码、公式、图形三者对照着看。这些做法都不需要改架构完全是在已有展示层上叠加信息。这个系列的这一篇本质上不是在教一个图形库而是教你如何把“计算结果”这个抽象的东西变成一个可以被直观理解和验证的实体。我最深的体会是当你看到蝴蝶翅膀在屏幕上一点点展开同时旁边的方程组标出每一步的规则时计算就不再是数字了。做展示这件事本身也是对你计算正确性最诚实的一次检验——画错了往往意味着算错了画对了才算真正把一个数学系统掌握住了。