多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

递归分形3D可视化:从Three.js到STL实体打印的完整实践

递归分形3D可视化:从Three.js到STL实体打印的完整实践 干这行久了你会发现凡是能让新手一眼“哇”出来的图形学项目底子里多半逃不开三个词递归、分形、3D。最近我正好把一个递归分形3D可视化的项目从零到一跑通了从纯数学推导到Three.js渲染再到导出STL做实体打印踩了不少坑也攒了不少经验。今天就把这条完整链路掰开揉碎聊一聊想玩创意编程的、做3D建模的、甚至准备搞3D打印毕业设计的都能从中找到可以直接抄作业的部分。这个项目本质上解决的是一个很基础但很迷人的问题如何用一段极短的代码在三维空间里“长”出无限复杂的结构。分形体在自然界到处都是——雪花、蕨类植物、罗马花椰菜、海岸线它们的共同特点是局部放大后和整体相似这就是“自相似性”。而要在计算机里复现这种自相似最顺手的工具就是递归。再加上Z轴把二维的数学幻想延伸到三维空间视觉效果会直接翻几个量级。1. 递归和分形先弄明白这对“父子”1.1 递归是算法里的“俄罗斯套娃”咱们先不碰图形把递归这件事说透。递归就是函数调用自身但和死循环有本质区别——它每调一层都会把问题规模缩小一点直到小到可以直接返回这个直接返回的条件叫终止条件也叫base case。写递归时脑子里必须有一根弦没有终止条件的递归就是灾难。我在调试分形树的时候经常把递归深度设到十几层浏览器直接卡死控制台疯狂报RangeError: Maximum call stack size exceeded这就是典型的栈溢出。计算机的调用栈是有容量的一层层压下去不设底迟早爆。一个简单的递归计数长这样function countDown(n) { if (n 0) return; // 终止条件 console.log(n); countDown(n - 1); // 调用自身 }看着简单但分形几何就是用这么个机制在三维空间里造出让人头皮发麻的复杂结构。每层递归处理一部分几何体生成更小的子结构然后把子结构再次传给同一个函数循环往复。1.2 分形是递归在几何世界留下的脚印递归是算法层面的“动作”分形是这个动作在几何层面留下的“痕迹”。最经典的三维分形案例之一就是Sierpinski四面体谢尔宾斯基四面体。你可以把一个大四面体分成四个小四面体每个小四面体的边长是大四面体的一半位置分别在原来的四个顶点附近。挖掉中间那个正八面体然后对剩下的每个小四面体重复这个操作。这个过程本身就是递归的操作一次子四面体数量变成4个操作两次变成16个操作n次变成4的n次方个。这就是自相似性不管你放大到哪一层看到的都是四个小四面体围着一个空洞和整体一模一样。Koch雪花是另一个经典例子。它从一条线段开始把线段中间三分之一磨平换成两段等长的线段形成一个等边三角形的尖角然后对每条新线段重复这个过程。递归深度到第4层时一条边就变成了256段折线整朵雪花已经肉眼可见地“毛茸茸”了。1.3 递归深度和分形维数别搞混很多朋友一看到“分形”就紧张觉得和“分形维数”是一回事。其实分形维数是一个独立的数学概念描述的是分形结构的自相似程度和空间填充能力它和代码里的递归深度完全是两码事。简单理解普通几何体的维度是整数直线是一维平面是二维立方体是三维。但分形结构的维度往往是分数。以Koch雪花为例它的周长随着递归深度无限增长但它始终被限制在有限面积里。它的分形维数大约是(\frac{\ln 4}{\ln 3} \approx 1.2618)这个数字告诉我们Koch雪花比一条线“饱满”但还没到填满一个平面的程度。三维分形也有自己的分形维数。Menger海绵门格海绵的分形维数是(\frac{\ln 20}{\ln 3} \approx 2.7268)它比一个平面复杂但又填不满整个三维空间。理解了这一点你就能明白为什么分形结构能“折叠”这么多细节维数越高结构越复杂占的空间却未必变大。我在做3D分形的时候常常用分形维数来预估结构的复杂度而不是单纯看递归深度。递归深度是“迭代了几层”分形维数是“这个结构到底有多曲折”一个是工程参数一个是数学特征两者互补。2. 三维分形的三条经典生成路线想在3D引擎里生成分形结构有几种完全不同的思路选哪条路直接决定了代码复杂度、渲染效果和性能上限。2.1 几何递归从一棵树到 Sierpinski 四面体最直观的三维分形生成方式就是把几何图形当作积木在递归函数里不断拼装。分形树是公认的入门首选。分形树的核心逻辑是这样的从一根树干开始在树干顶端分出两三根较短的子枝每个子枝再递归地分出更短的孙枝。每递归一层枝干长度乘以一个缩放因子比如0.7方向绕着某个轴旋转一个固定角度。递归深度到6~8层时一棵结构丰富的树就出现了。这段逻辑放在Three.js里并不复杂function generateBranch(start, direction, length, depth, geometry, branches, spread) { // 终止条件 if (depth 0) return; const end new THREE.Vector3().copy(start).addScaledVector(direction, length); geometry.positions.push(start.x, start.y, start.z); geometry.positions.push(end.x, end.y, end.z); // 递归生成子枝 for (let i 0; i branches; i) { const newDir direction.clone(); const angle (i - (branches - 1) / 2) * spread; newDir.applyAxisAngle(new THREE.Vector3(0, 0, 1), angle); newDir.applyAxisAngle(new THREE.Vector3(0, 1, 0), Math.random() * Math.PI * 2); generateBranch(end, newDir, length * 0.7, depth - 1, geometry, branches, spread); } }这段代码有几个值得注意的点。第一我把树的节点坐标直接压进了BufferGeometry的positions数组而不是每层递归新建一个Mesh对象这对性能至关重要。第二我在子枝方向上加了一个随机旋转让树不会长成一个完美的平面这样才像真实植物。第三每条枝干都从start到end推入两个顶点用LINES模式渲染开销低效果清晰。如果你要把分形树做成实体模型还得补充圆柱几何体、计算法线复杂度会上一个台阶这点后面3D打印章节会细说。Sierpinski四面体的几何递归稍复杂一点需要操作四面体的顶点坐标但原理完全一样每层递归把四个子四面体的顶点算出来再各自调用自身。到了第5层你手里的数据量已经是1024个四面体渲染压力肉眼可见地上升。2.2 逃逸时间算法Mandelbulb 的三维魔法Mandelbulb是三维分形里最震撼的一种也是我这次项目里耗时最多的一块。它本质上是把二维Mandelbrot集合扩展到三维空间。二维Mandelbrot集合的迭代公式是 [ z_{n1} z_n^2 c ] 其中(z)和(c)都是复数。扩展到三维需要用到球坐标系下的幂运算。在球坐标系里一个三维向量可以用((r, \theta, \phi))表示其中(r)是到原点的距离(\theta)是极角(\phi)是方位角。对于Mandelbulb常取指数为8的迭代 [ z_{n1} z_n^8 c ] 而(z^n)在球坐标下的计算方法是 [ z^n r^n \left( \sin(n\theta)\cos(n\phi), \sin(n\theta)\sin(n\phi), \cos(n\theta) \right) ]对一个点从原点开始迭代这个公式如果迭代20次后距离仍然没有超过某个阈值比如2就认为这个点属于Mandelbulb集合。判断完成后把属于集合的点绘制出来就得到了那个像西兰花又像星云的庞然大物。Mandelbulb的渲染通常不用显式建模而是用光线步进Ray Marching配合距离估计算法来渲染。光线从相机出发每步前进一个和距离场相关的步长直到撞到物体表面或超出最大步数。这个方案渲染效果好但是计算量极大必须在Shader里做GPU扛不住的话纯CPU算一帧要等好几分钟。我在项目里没有直接上光线步进而是用Three.js的MarchingCubes对象做了网格化把Mandelbulb转成三角形网格再渲染。分辨率设为64时生成一次大约耗时2~3秒交互还凑合分辨率提到128内存占用飙升到几个GB直接把浏览器干崩溃了。后来我把体素分辨率降到80才找到性能和画质的平衡点。2.3 IFS 迭代函数系统一株野生分形蕨**IFSIterated Function System迭代函数系统**是另一种三维分形的生成思路。它不靠几何递归而是靠一组仿射变换不断迭代点坐标。Barnsley蕨是最经典的二维IFS它的四个变换矩阵分别对应茎、底层叶、左侧小叶、右侧小叶。每个变换有一个概率权重迭代时按概率随机选一个变换把当前点映射到新的位置。迭代几千次后点的分布就形成了一株蕨类植物的形状。把二维的仿射变换扩展到三维就能得到三维分形蕨。区别在于变换矩阵变成4x4齐次矩阵每个矩阵除了旋转缩放还多了Z轴的旋转和位移。我这次用点云渲染三维分形蕨生成了5万多个点每一帧都要更新点位置用THREE.Points一次性绘制效果非常飘逸。IFS的好处是代码量极短几何数据生成也是线性复杂度不会像递归分形那样指数爆炸。缺点是结构不够“规则”适合做植物、石头、山体这类自然景观不太适合做那种严格对称的几何分形。3. 拿 Three.js 落地一个可交互 3D 分形项目3.1 为什么选 Three.js做一个3D分形项目渲染方案其实不少纯WebGL、原生OpenGL、Unity、Unreal甚至Blender脚本。我最后选了Three.js理由很实际开发效率高Three.js对WebGL做了完整封装灯光、相机、轨道控制器都是现成的写分形逻辑比从底层磨快一个量级。调试方便浏览器里改代码刷新就能看到效果不像Unity/C项目要编译半天。生态完备STLExporter、MarchingCubes、各种数学工具都有现成实现省去造轮子的时间。可分享性Web项目发个链接就能给别人看做演示和毕设展示特别占优势。当然Three.js也有短板最明显的就是性能上限比原生引擎低大数据量的点云和网格容易卡。但做分形可视化这个场景它绝对是性价比最高的选择。3.2 核心实现分形树的 BufferGeometry 生成我来说说分形树的可交互实现。前面给过generateBranch的简化版这里讲完整版的几个关键点。第一数据结构别用数组硬拼要提前估算容量。递归深度为8、每层3个分支的分形树总共的枝干段数是(\sum_{k1}^{8} 3^k 9840)段每段2个顶点就是19680个浮点数。如果你用动态数组不断push性能会浪费在扩容上。正确做法是先算出总顶点数一次性分配Float32Array。第二树的渲染模式选择。想快速验证效果用LINES模式就够了顶点少绘制快。但LINES的线宽在WebGL里默认是1像素渲染出来很细视觉效果一般。想要更好看的树干应该改用圆柱体网格。我的做法是递归时记录每段枝干的起点、终点和半径递归结束后统一调用CylinderGeometry生成树干网格。所有圆柱的顶点合并到同一个BufferGeometry里再用mergeVertices合并重复顶点这样Draw Call只有一次渲染性能非常好。合并网格的代码大致是这样的const merged new THREE.BufferGeometry(); for (const segment of treeSegments) { const cyl new THREE.CylinderGeometry(segment.radius, segment.radius * 0.6, segment.length, 6); const mat4 new THREE.Matrix4().makeBasis(xAxis, yAxis, zAxis); mat4.setPosition(segment.start); merged.merge(cyl, mat4); }第三交互控制器建议用OrbitControls。分形树的观赏是全方位的没有轨道控制器用户只能干瞪眼看正面体验差很多。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;3.3 点云渲染和 marching cubes 网格化分形树用几何递归Mandelbulb用逃逸时间算法但到了渲染阶段我最后把它们统一成了两种模式点云渲染和网格渲染。三维分形蕨、三维海鸥这类自然景物用**点云THREE.Points**渲染非常合适。每个粒子是一个顶点带颜色和尺寸渲染出来的质感介于实心和半透明之间艺术感很强。点云的难点在于顶点数量——5万个点是最低起跑线50万个点的视觉效果才称得上细腻。这个数据量在Three.js里用PointsRenderer跑没问题但要注意粒子的深度排序否则透明混合会出现穿帮。Mandelbulb、Menger海绵这类几何分形最终还是得转成三角形网格才好看。我用的工具是Three.js官方示例包里的MarchingCubes对象。Marching Cubes移动立方体算法的原理是把三维空间切分成一个个小立方体体素对每个立方体根据8个顶点的函数值判断等值面是否穿过它如果穿过就生成对应的三角形。分辨率越高三角形越多结构越精细。import { MarchingCubes } from three/examples/jsm/objects/MarchingCubes.js; const mc new MarchingCubes(64, material, true, true); mc.fieldSize 3; mc.isolation 80; // 在体素空间里填入分形函数值分辨率64意味着体素空间是64x64x64总共26万个采样点每个点都要跑一遍迭代判断这就是第3、4秒计算时间的来源。在这个项目里我把Mandelbulb的体素化分辨率和isolation阈值做成GUI滑条让用户自行调节画质和速度实测下来比固定参数好用很多。3.4 参数计算递归深度、数量与内存的关系很多人一上来就把递归深度调到10以上然后发现浏览器直接崩溃。这里面的数据增长是指数级的必须心里有数。拿分形树举例如果每层生3个分支那么总枝干数为 [ N b b^2 b^3 \ldots b^d \frac{b^{d1} - b}{b-1} ] 当(b3, d8)时(N 9840)。d9时N29523。d12时N797160。而Sierpinski四面体更夸张每层乘4。第5层1024个第6层4096个第8层已经65536个四面体。每个四面体4个三角面就是26万个三角形。这个数据量在Three.js里渲染还能扛但再深两层就得解锁3D高斯等高级优化手段了。内存估算的公式也不复杂。每个顶点占3个float位置 3个float法线 6个float 24字节。26万个三角形约78万个顶点光顶点数据就18.7MB。再加上顶点索引、材质、Shader编译浏览器分配几百MB内存很正常。所以递归深度建议控制在8以内想要更精细可以后期做LOD细节层次而不是盲目加深递归。4. 渲染优化与 3D 打印实战4.1 性能优化的几个关键动作做3D分形项目代码能跑只是第一步跑得流畅才是目标。我在实际调试中总结了几个性价比极高的优化动作。第一生成一次缓存几何体。分形结构的生成过程非常耗时绝不能放在动画循环里每帧执行。正确做法是初始化时把几何体生成好存储在BufferGeometry里动画循环只做旋转、缩放、相机操作这样CPU和GPU都能保持稳定帧率。第二用BufferGeometry替代Geometry。Three.js r125之后已经移除了Geometry全量转向BufferGeometry。BufferGeometry的数据直接绑定到GPU缓冲区渲染效率高很多。有些老教程还在教new THREE.Geometry()那是过时写法新项目一律用BufferGeometry。第三限制帧率。分形项目往往是静态展示或慢速旋转60fps完全没必要。用renderer.setAnimationLoop渲染然后通过限制requestAnimationFrame的调用频率把帧率限制到30fpsCPU占用能降一半笔记本风扇也安静了。4.2 导出 STL 做实体分形3D分形做出来不只能看还能拿去3D打印。把分形树或Sierpinski四面体导出成STL文件交给切片软件就能打印出实体模型。Three.js官方包里有STLExporter用法很简单import { STLExporter } from three/examples/jsm/exporters/STLExporter.js; const exporter new STLExporter(); const stlData exporter.parse(scene, { binary: true }); const blob new Blob([stlData], { type: application/octet-stream }); const link document.createElement(a); link.href URL.createObjectURL(blob); link.download fractal.stl; link.click();但这里有个大坑分形树的悬空结构没办法直接打印。打印机的层是逐层堆叠的树干末端的细枝和悬垂的叶片如果没有支撑打印到一半就会坍塌。所以打印分形树之前要么在切片软件里加支撑要么调整打印角度把树横着切成几块再组装。Sierpinski四面体就好很多它虽然是镂空的但整体结构相对均衡常见的FDM打印机都能直接打印不需要额外支撑。Menger海绵更是3D打印的绝佳题材层层叠叠的孔洞既是分形的精髓也能减轻重量、节省材料。我这次打印的是一个第4层Sierpinski四面体边长10厘米打印耗时6小时材料是PLA层高0.2毫米。重点提醒一下分形模型的面数通常很高打印前要用切片软件的“修复网格”功能检查一下有没有穿孔或重叠面不然打印出来的模型表面会有杂点。4.3 从分形到建模工具和三维重建做这个项目的过程中我还发现一个有意思的事情分形生成的几何数据和市面上主流3D建模工具的底层逻辑是通的。你可以在Blender里用HaX或Extra Objects插件直接生成分形网格也可以在Three.js里导出GLTF格式丢进Blender二次编辑再配合TRI PO AI这类AI建模工具出图流程已经非常顺畅。另外三维重建3D Reconstruction领域的很多算法和分形也有交集。比如基于结构光相机3D结构光相机获取的点云数据在三维重建时经常需要用到八叉树Octree来管理海量点这和Mandelbulb体素化的思路如出一辙。还有3D卷积神经网络在体素化数据处理上的应用分形体素化可以为这类网络提供标准训练数据。所以玩分形不光是视觉娱乐它对理解点云、体素、三维网格这些基础概念都有帮助后续转3D重建、自动驾驶感知、机器人导航都打得了基础。5. 常见问题与排查技巧实录做递归分形3D项目踩坑是常态。我把自己和几个同行遇到的典型问题整理成了速查表希望帮大家少走弯路。问题现象根本原因排查与解决方法浏览器卡死崩溃递归深度过大或没有终止条件检查base case深度控制在8以内数据量大的生成操作放到异步任务里分批执行渲染出来是空白的相机位置不对或几何体背向相机先自动调整相机lookAt到几何体中心再用OrbitControls手动拖动确认树的分枝扭曲变形方向向量没有归一化递归里的direction向量必须normalize否则长度累加会让树干越来越长网格表面有黑色闪烁法线方向错误或重复顶点计算法线后检查方向用mergeVertices合并重复顶点必要时翻转法线点云粒子重叠混乱粒子尺寸过大调小PointsMaterial的size启用sizeAttenuation让远处粒子缩放STL导入打印软件报错网格有开放边或非流形结构在切片软件里运行修复网格功能或回Three.js里用mergeVertices修复排查思路其实有规律可循先看数据生成对不对把递归结果打印出来对照预期再看几何构建对不对用线框模式渲染检查拓扑最后才看材质和渲染设置。很多问题卡住人恰恰是因为顺序反了一上来就调灯光调材质白白折腾半天。还有一个让我印象深刻的坑OrbitControls导致的分形树抖动。树的结构没问题但鼠标拖动时模型会剧烈抖动。排查了半天才发现是我把生成几何体的代码放在了动画循环里导致每帧都在重建几何体。放到初始化阶段执行后问题彻底消失。这个错误非常典型新手尤其容易犯。另外提醒一句Three.js版本差异也容易踩坑。r150之前的API到r160改了不少特别是BufferGeometry的merge方法在不同版本写法略有不同。建议固定一个版本不要盲目升级项目做完了再考虑迁移。我个人的体会是递归分形3D这个方向代码本身并不复杂复杂的是把数学直觉落到工程实现上。你不需要成为图形学理论专家但得学会用可视化的方式去验证每一步逻辑——把递归树画出来把体素切片显示出来把法线画出来只有亲眼看到中间过程才能准确判断问题出在哪一层。最后分享一个小技巧做分形调试的时候把递归深度做成GUI滑块先设到2~3层快速验证逻辑确认没问题再往深了调。这个习惯帮我节省了至少一半的调试时间。分形世界的大门就在那里剩下的就交给你自己慢慢探索了。
返回列表