ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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. 从竹林到任何记忆场景:这个项目的可迁移性

做完这个项目之后,我最大的感受是:这套方法论不只能做竹林,它能迁移到任何"记忆中的自然场景"上。老家的院子、小时候的稻田、外婆家门前的葡萄架,本质上都是同一类问题——用程序化生成解决"量"的问题,用光影和动态解决"氛围"的问题,用性能优化解决"能不能跑"的问题。

具体迁移的时候,需要替换的只是几个模块:几何体(竹子换成葡萄藤、稻穗、树木)、分布算法(泊松盘采样的参数调整)、风效参数(不同植物的摆动频率和幅度不同)、以及环境音素材。整个技术骨架几乎不用动。

我个人在实际操作中的体会是,这类项目最难的不是技术,而是克制。新手容易什么都想要——想要高精度模型、想要复杂粒子效果、想要实时反射。结果就是包体巨大、加载缓慢、手机跑不动。而真正打动人的,往往是一片简单的竹林、一束穿过叶缝的光、一阵若有若无的风声。技术是为氛围服务的,不是反过来。想清楚这一点,项目就成功了一半。

返回列表