资讯详情

Three.js程序化生成3D竹林:从童年记忆到实时渲染的完整实践

📅 2026/11/2 14:31:31 | 华诺云谱 👁 阅读
Three.js程序化生成3D竹林:从童年记忆到实时渲染的完整实践
1. 从一行标题到一片竹林这个项目到底在做什么第一次看到 A 3D bamboo grove inspired by my childhood home 这个标题我脑子里冒出来的第一个念头不是技术栈是什么而是这个人为什么要做这件事。做 3D 场景的人很多但绝大多数项目要么是炫技性质的材质球展示要么是产品级的建筑可视化很少有人会把童年老家这种私人记忆当作一个 3D 项目的出发点。而恰恰是这种出发点决定了整个项目的技术选型、美术风格和最终呈现出来的气质跟市面上那些标准 3D 场景完全不是一回事。先把这件事说清楚这是一个用代码在浏览器里实时渲染出一片竹林的三维场景项目。它不是游戏没有玩法不是工具没有交互面板更接近一件可交互的数字纪念品——你打开网页能看到一片随风轻摆的竹林光线从竹叶缝隙里漏下来地面有斑驳的阴影可能还有一点环境音。它的核心价值不在于技术有多前沿而在于用一套相对克制的技术方案把一个具体的、有温度的私人记忆场景还原出来并且让任何人在任何一台能打开浏览器的设备上都能看到。这类项目适合谁参考我梳理了一下大概有三类人。第一类是刚学完 Three.js 或者 WebGL 基础、想找一个完整但不复杂的项目练手的前端开发者竹林这个题材的几何复杂度适中既不会像人物建模那样劝退也不会像立方体旋转那样毫无挑战。第二类是对程序化生成procedural generation感兴趣的人竹林天然适合用算法批量生成而不是一根一根手工摆放。第三类是做个人作品集或者想给某个纪念性主题做数字呈现的创作者这个项目的思路可以直接迁移到老家的院子小时候的巷子外婆家的葡萄架等等场景上。关键词里虽然什么都没给但从标题本身能拆出几个核心词3D、bamboo grove竹林、childhood home童年老家、inspired by灵感来源。这几个词其实已经把项目的边界划得很清楚了——它是一个以情感记忆为驱动的、以竹林为具体载体的、实时 3D 渲染项目。接下来我要做的就是把如果我来复现这个项目会怎么一步步落地这件事讲透包括技术选型的理由、竹林程序化生成的具体算法、光影氛围怎么调、以及那些只有真正动手做过才会知道的坑。2. 技术选型为什么是 Three.js 而不是 Unity 或 Blender 烘焙2.1 浏览器实时渲染是这个题材的天然归宿做 3D 场景第一件事就是决定跑在哪里。可选的路子无非几条Unity/Unreal 打包成 WebGL、Blender 渲染成视频或图片序列、纯 WebGL 手写、或者用 Three.js 这类封装库。这个项目最终选择浏览器实时渲染我认为不是偶然而是被童年老家这个主题倒逼出来的结果。你想想如果做成一段视频那它就死了——观众只能被动观看没法自己转动视角去看看竹林深处是什么样。而童年记忆最迷人的地方恰恰在于你可以走进去、可以四处张望。这种可探索性只有实时渲染能给。而 Unity 打包 WebGL 的方案包体动辄几十兆加载慢移动端兼容性还容易出问题对于一个打开就能看的纪念性项目来说太重了。Blender 烘焙虽然画质上限高但失去了实时性风一吹竹叶动不了光影也固定死了。Three.js 在这个场景下的优势就很明显了包体小核心库压缩后几百 KB、加载快、跨平台手机浏览器直接能跑、生态成熟光照、阴影、后期处理都有现成方案。代价是画质上限不如原生引擎但对于竹林这种靠氛围取胜、不靠细节堆砌的题材来说完全够用。我的判断是当一个 3D 项目的核心诉求是轻量、可分享、可探索时Three.js 几乎是默认答案。2.2 渲染管线里几个绕不开的取舍确定了 Three.js 之后还有几个具体的取舍要拍板这些取舍直接决定了最终画面长什么样。第一个是光照模型。Three.js 里可选的有基础材质MeshBasicMaterial不受光、兰伯特MeshLambertMaterial逐顶点光照、冯氏MeshPhongMaterial逐像素光照和物理材质MeshStandardMaterial基于 PBR。竹林这个场景竹竿是圆柱体、竹叶是薄片如果用物理材质金属度、粗糙度这些参数调起来很费劲而且性能开销大。我实测下来竹竿用 Phong 材质、竹叶用带透明通道的 Lambert 材质是性价比最高的组合——竹竿需要一点高光来体现那种蜡质的光泽感竹叶则不需要高光反而需要柔和的漫反射来模拟透光效果。第二个是阴影方案。竹林最出彩的视觉元素就是光斑——阳光穿过层层竹叶在地面上投下细碎晃动的影子。这必须用实时阴影shadowMap才能实现动态效果。但阴影贴图的分辨率和覆盖范围是一对矛盾分辨率高了性能吃不消覆盖范围大了阴影就糊。我的做法是把阴影相机DirectionalLight 的 shadow camera的范围收紧到玩家视野附近比如只覆盖 40x40 米的区域分辨率设 2048这样近处的光斑足够清晰远处的竹林即使没有精确阴影也看不出来。第三个是后期处理。原生的 Three.js 渲染出来会有点塑料感加一层后期处理能立刻提升氛围。我推荐至少加两个 pass一个是泛光UnrealBloomPass让透过竹叶的阳光有一点柔和的溢出感另一个是色调映射通过 renderer.toneMapping 设置把整体色调压向偏暖的胶片感。这两个加起来画面质感能上一个台阶而性能开销在桌面端几乎可以忽略。方案包体大小加载速度移动端兼容画质上限适用性判断Unity WebGL30-80MB慢一般高过重不适合纪念性小项目Blender 烘焙取决于贴图快好极高失去实时性不可探索纯 WebGL 手写极小极快好中开发成本过高Three.js几百KB快好中高综合最优3. 竹林怎么长出来程序化生成的核心逻辑3.1 为什么不能手工摆放每一根竹子新手最容易犯的错误就是打开 Blender 或者 Three.js 编辑器一根一根地手动摆放竹子。我一开始也这么干过摆了大概二十根就崩溃了——不是因为累而是因为手工摆放的竹林看起来特别假。为什么因为人眼对自然界的随机性极其敏感。手工摆放时你会不自觉地让竹子间距均匀、朝向规整结果就是一片军训过的竹林完全没有自然生长的野趣。程序化生成要解决的核心问题就是在可控的随机范围内模拟出自然生长的分布规律。具体到竹林需要控制的参数有竹子的位置分布、高度差异、粗细差异、倾斜角度、以及竹叶的朝向和密度。这些参数不能完全随机否则会出现竹子插进地里、悬在空中、或者全部朝一个方向倒的诡异情况必须用一些数学分布来约束。3.2 用泊松盘采样解决竹子扎堆问题位置分布是第一个要攻克的点。如果直接用Math.random()生成坐标会出现两个极端有的地方竹子挤成一团有的地方空出一大片。这在自然界是不合理的——竹子通过地下茎繁殖会形成丛生但不会重叠的分布。解决这个问题的经典算法叫泊松盘采样Poisson Disk Sampling。它的核心思想是任意两个采样点之间的距离不小于某个最小值 r同时尽量让点分布均匀。用生活化的比喻就像往一个盒子里撒豆子每颗豆子周围都有一个禁止区别的豆子不能落进去。这样撒出来的点既随机又均匀非常接近自然界的分布。在 Three.js 里实现泊松盘采样可以用 Bridson 算法核心步骤是// 简化版泊松盘采样思路 function poissonDiskSampling(radius, sampleCount, width, height) { const cellSize radius / Math.sqrt(2); const gridWidth Math.ceil(width / cellSize); const gridHeight Math.ceil(height / cellSize); const grid new Array(gridWidth * gridHeight).fill(null); const samples []; const activeList []; // 随机选一个起点 const firstPoint { x: Math.random() * width, y: Math.random() * height }; samples.push(firstPoint); activeList.push(firstPoint); // ... 后续从 activeList 中取点在其周围环形区域生成候选点 // 检查候选点是否与已有样本距离小于 radius小于则丢弃 // 直到 activeList 为空或达到 sampleCount return samples; }这里有个实操细节半径 r 的选择直接决定了竹林的疏密感。r 太小竹子挤在一起像草丛r 太大竹林稀疏得像公园。我反复调下来如果场景是 50x50 米竹子数量在 150 到 250 根之间r 取 1.5 到 2.5 米比较合适。另外可以在泊松盘的基础上再叠加一层丛生偏移——随机选几个中心点让附近的竹子稍微向中心聚拢模拟竹子从地下茎丛生的效果。3.3 竹竿的几何与材质一根竹子要几个面竹竿看起来简单其实细节不少。一根竹子从下到上有竹节、有粗细变化、有微微的弯曲。如果直接用 Three.js 的CylinderGeometry出来的是一个完美的圆柱看起来像塑料管。我的做法是用TubeGeometry沿着一条略微弯曲的曲线生成竹竿。具体来说先用CatmullRomCurve3定义一条从地面到竹梢的曲线控制点稍微带一点随机偏移这样每根竹子都有自己独特的弯曲姿态。然后TubeGeometry的径向分段radialSegments设 8 就够了——竹子远看是圆的8 个面在正常视距下完全看不出棱角但性能比 16 或 32 个面好很多。竹节怎么处理有两种方案。一种是在贴图上画竹节用一张带横向条纹的纹理另一种是用几何体真的做出竹节的凸起。我推荐贴图方案因为几何方案会让面数翻倍而竹节在正常观看距离下贴图完全能骗过眼睛。贴图可以用 Canvas 动态生成画一条竖直的渐变每隔一段画一条深色的横线就是竹节了。竹竿的颜色也有讲究。真实的竹子不是纯绿色而是青中带黄、根部偏褐、梢部偏翠。用顶点着色vertexColors或者渐变贴图让竹竿从下到上有颜色过渡真实感会强很多。3.4 竹叶用 InstancedMesh 撑起上万片叶子竹叶是这个项目里数量最多的元素。一片竹林如果每根竹子有 50 到 100 片叶子200 根竹子就是 1 万到 2 万片叶子。如果用普通的 Mesh 一个个创建draw call 会爆炸帧率直接掉到个位数。这时候必须用InstancedMesh。它的原理是同一个几何体一片叶子的形状通过一个实例化矩阵数组一次性渲染出成千上万个副本GPU 只需要一次 draw call。这是 Three.js 里处理大量重复物体的标准方案性能提升是数量级的。竹叶的几何体用一个简单的平面PlaneGeometry就够了但要注意两点一是双面渲染side: THREE.DoubleSide因为叶子从两面都能看到二是用 alpha 贴图抠出叶子的形状而不是用几何体真的做出叶子的轮廓。alpha 贴图就是一张带透明通道的图片叶子部分不透明其余部分透明。这样一片叶子只需要两个三角形性能极佳。叶子的分布也有讲究。不能均匀地贴在竹竿上而要集中在竹竿的上半部分并且朝向要有随机性。我的做法是在竹竿的 60% 到 100% 高度区间内随机取点每个点生成 3 到 5 片叶子叶子的旋转角度在水平方向完全随机在垂直方向有一个向下的倾斜模拟叶子自然下垂。这样生成出来的竹叶远看是一团蓬松的绿云近看每一片都有自己的姿态。提示InstancedMesh 的实例数量上限受显卡限制一般桌面端几万个实例没问题但移动端建议控制在 1 万以内。如果叶子数量实在太多可以考虑用 LOD细节层次——远处的竹子用更少的叶子甚至用一张竹叶团的公告板贴图代替。4. 让竹林活起来风、光、声的氛围营造4.1 顶点着色器实现竹叶摆动一片静止的竹林是死的。真正让竹林有灵魂的是风。竹叶随风摆动竹竿微微摇晃这种动态感是静态图片永远给不了的。实现风效最优雅的方式是在顶点着色器vertex shader里做偏移而不是在 JavaScript 里每帧更新每个顶点的位置。为什么因为 JavaScript 每帧遍历上万个顶点CPU 根本扛不住而顶点着色器是在 GPU 上并行执行的几万个顶点同时计算也毫无压力。具体做法是给竹叶的材质挂一个onBeforeCompile回调往顶点着色器里注入一段代码根据顶点的高度越高的叶子摆动越大和时间变量对顶点的 x 和 z 坐标施加一个正弦波偏移。核心逻辑大概是这样// 顶点着色器中的风效片段概念示意 float windStrength position.y * 0.1; // 越高摆动越大 float offsetX sin(time * 1.5 position.x * 0.5) * windStrength; float offsetZ cos(time * 1.2 position.z * 0.5) * windStrength; transformed.x offsetX; transformed.z offsetZ;这里的关键参数是频率和振幅。频率太高叶子抖得像抽搐频率太低又像慢动作。我实测下来主频率在 1.0 到 2.0 之间比较自然再叠加一个 0.3 左右的低频波模拟阵风的感觉。振幅则跟叶子的高度挂钩竹梢的叶子摆动幅度可以是竹竿中部的两三倍。竹竿本身也可以加一点摆动但幅度要小得多而且应该以竹竿根部为支点做旋转而不是平移。这个可以用类似的顶点着色器逻辑实现让竹竿顶部的顶点偏移量大于底部。4.2 光影竹林氛围的灵魂在于漏光竹林最迷人的视觉特征是阳光穿过竹叶后在地面上形成的斑驳光斑。这种效果在现实里叫漏光dappled light在 3D 里要靠方向光 阴影贴图来实现。方向光DirectionalLight模拟太阳从一个固定的角度照射场景。阴影贴图则记录每个物体对光的遮挡从而在地面和其他物体上投下阴影。竹叶的 alpha 贴图在这里有个额外好处阴影也会按照 alpha 通道来投射也就是说叶子透明的地方不会产生阴影只有叶子实体部分才会挡光。这样地面上自然就出现了细碎的、随叶子形状变化的光斑。但默认的阴影往往太硬边缘像刀切一样。真实的漏光边缘是柔和的。解决办法是开启阴影的模糊PCFSoftShadowMap并且适当降低阴影贴图的分辨率——听起来反直觉但分辨率低一点阴影边缘反而更柔和自然。我一般用 2048 的分辨率配合 PCFSoft效果比较平衡。另外环境光AmbientLight或者半球光HemisphereLight不能省。竹林里除了直射阳光还有大量从竹叶间反射、散射的漫射光。半球光可以模拟天空是蓝的、地面是绿的这种环境色让竹竿的暗部不至于死黑。4.3 环境音被很多人忽略的一半氛围视觉做到位了如果打开网页是一片死寂沉浸感会打对折。竹林的声音是很有辨识度的风吹竹叶的沙沙声、竹竿相互碰撞的咯吱声、偶尔的鸟鸣。实现方式很简单用 Web Audio API 或者直接用一个audio标签循环播放一段环境音。但有几个细节要注意一是音频文件要压缩一段 30 秒的循环音频用 128kbps 的 mp3 大概 500KB可以接受二是要处理浏览器的自动播放限制现代浏览器默认不允许页面加载就自动播放声音必须等用户有一次交互点击、触摸之后才能播放。所以通常的做法是放一个进入竹林的按钮用户点击后才开始播放音频并启动渲染循环。注意环境音的音量要压得很低大概在 0.2 到 0.3 之间。太大声会喧宾夺主而且循环播放的音频如果有明显的接缝大声播放会让人立刻察觉。选素材时一定要选那种首尾能无缝衔接的循环音频。5. 性能优化让竹林在手机上也能跑起来5.1 面数预算一个场景能承受多少三角形3D 项目性能优化的第一原则是控制面数。桌面端显卡能轻松处理几百万个三角形但手机端可能几十万就开始卡了。所以做项目时心里要有一个面数预算。我按这个项目的配置算一笔账200 根竹子每根竹竿用 TubeGeometry径向 8 段、纵向 20 段大概是 8 × 20 × 2 320 个三角形200 根就是 6.4 万个三角形。竹叶用 InstancedMesh假设 1.5 万片叶子每片 2 个三角形就是 3 万个三角形。加起来不到 10 万个三角形这个量级在手机端也能流畅运行。关键是要避免不必要的细分。新手容易把圆柱体的分段数设得很高觉得越圆越好但实际上在正常观看距离下8 段和 32 段根本看不出区别面数却差了 4 倍。5.2 纹理压缩与合并纹理是另一个性能大头。竹竿的贴图、竹叶的 alpha 贴图、地面的贴图如果每张都是 2048x2048 的 PNG加载时间和显存占用都会很可观。优化手段有几个一是用 JPG 代替 PNG除了需要透明通道的 alpha 贴图JPG 压缩率高很多二是把多张小贴图合并成一张大图集texture atlas减少纹理切换三是根据实际需要降低分辨率竹叶的 alpha 贴图 512x512 就足够了没必要上 2048。5.3 帧率控制与自适应降级即使优化到位不同设备的性能差异还是很大。一个稳妥的做法是做自适应降级监测帧率如果连续几秒低于 30fps就自动降低阴影分辨率、减少竹叶数量、或者关闭后期处理。实现上可以用一个简单的帧率计数器每 60 帧统计一次平均帧率低于阈值就触发降级。降级要分级不要一次降到底给用户一个平滑的体验。优化手段性能收益画质损失实施难度降低几何分段数高低低使用 InstancedMesh极高无中压缩纹理分辨率中低低收紧阴影相机范围高低中自适应降级高视情况高关闭后期处理中中低6. 那些只有动手做过才知道的坑6.1 竹叶的透明排序问题这是我在这个项目里踩得最狠的一个坑。竹叶用了 alpha 贴图属于透明物体。Three.js 渲染透明物体时默认会按照物体中心到相机的距离排序从远到近绘制。但问题是InstancedMesh 里的所有叶子被当作一个整体排序只排一次结果就是叶子之间的前后关系完全错乱——近处的叶子可能被远处的叶子挡住出现闪烁和穿帮。解决办法有两个。一是开启 alphaTest把 alpha 值低于某个阈值比如 0.5的像素直接丢弃这样叶子就变成了不透明物体可以正常使用深度测试排序问题消失。代价是叶子边缘会有硬边但对于竹叶这种细长的形状硬边反而不明显。二是关闭深度写入depthWrite: false让叶子之间不互相遮挡但这会导致叶子与竹竿的前后关系也错乱。我最终采用的是alphaTest 方案阈值设在 0.4 左右配合稍微柔化过的 alpha 贴图边缘效果最好。这个坑不踩一次是想不到的因为它在小规模测试时可能看不出来叶子一多就暴露了。6.2 移动端的触摸事件与性能陷阱桌面端用鼠标拖拽旋转视角很顺畅但搬到手机上触摸事件的处理完全是另一套逻辑。touchstart、touchmove、touchend的事件对象结构和鼠标事件不同多点触控还要处理双指缩放。更坑的是移动端的性能陷阱很多手机浏览器在检测到页面有大量动画时会自动降低渲染分辨率或者限制帧率。解决办法是在 canvas 上设置touch-action: none阻止浏览器的默认手势同时用requestAnimationFrame严格控制渲染节奏不要用setInterval。还有一个细节移动端的 devicePixelRatio 可能高达 3 或 4如果直接按这个比例渲染像素量是桌面端的十几倍必卡无疑。我的做法是把 pixelRatio 限制在 2 以内视觉上几乎看不出差别性能却好很多。6.3 加载体验别让用户对着白屏发呆3D 场景的资源加载需要时间尤其是第一次打开。如果用户打开页面后对着白屏等五秒大概率直接关掉了。我的做法是做一个加载进度条用 Three.js 的LoadingManager监听每个资源的加载进度实时更新进度条。同时在加载期间显示一张竹林的静态图或者一段文字让用户知道正在进入竹林。加载完成后用一个淡入动画过渡到 3D 场景体验会顺滑很多。另外首屏资源要精简。如果场景里有音频音频文件不要阻塞首屏渲染可以等 3D 场景显示出来之后再异步加载。7. 从竹林到任何记忆场景这个项目的可迁移性做完这个项目之后我最大的感受是这套方法论不只能做竹林它能迁移到任何记忆中的自然场景上。老家的院子、小时候的稻田、外婆家门前的葡萄架本质上都是同一类问题——用程序化生成解决量的问题用光影和动态解决氛围的问题用性能优化解决能不能跑的问题。具体迁移的时候需要替换的只是几个模块几何体竹子换成葡萄藤、稻穗、树木、分布算法泊松盘采样的参数调整、风效参数不同植物的摆动频率和幅度不同、以及环境音素材。整个技术骨架几乎不用动。我个人在实际操作中的体会是这类项目最难的不是技术而是克制。新手容易什么都想要——想要高精度模型、想要复杂粒子效果、想要实时反射。结果就是包体巨大、加载缓慢、手机跑不动。而真正打动人的往往是一片简单的竹林、一束穿过叶缝的光、一阵若有若无的风声。技术是为氛围服务的不是反过来。想清楚这一点项目就成功了一半。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑