本文是由 EZ-Tree 作者撰写的一篇文章的译文。EZ-Tree 是一款基于 three.js 的插件,能够生成高度逼真的树木模型。本文详细阐述了作者在创作 EZ-Tree 过程中的一些实践经历与核心思路,读者可从中汲取相关技术知识,获取有益的创作灵感。
探索 EZ-Tree 如何利用程序生成和 Three.js 创建逼真的 3D 树模型背后的算法。
自从14岁开始学习编程以来,我就一直对如何用代码模拟现实世界着迷。大学二年级时,我挑战自己,尝试编写一个能够生成树的3D模型的算法。这是一个有趣的实验,也取得了一些有趣的成果,但最终代码还是被束之高阁,尘封在了我的移动硬盘深处。
几年后,我重新发现了那段原始代码,并决定将其移植到 JavaScript(并进行了一些改进!),以便可以在 Web 上运行它。
最终成果是EZ-Tree,一个基于 Web 的应用程序,您可以在其中设计自己的 3D 树,并将其导出为 GLB 或 PNG 格式,以便在您自己的 2D/3D 项目中使用。您可以在这里找到 GitHub 代码库。EZ-Tree 使用Three.js进行 3D 渲染,Three.js 是一个基于 WebGL 的流行库。
在本文中,我将详细介绍我用来生成这些树的算法,并解释每个部分如何对最终的树模型做出贡献。
什么是程序生成?
首先,了解什么是程序生成可能会有所帮助。
程序生成本质上就是根据一组数学规则创建“某物”。以树为例,我们首先观察到的是,树干会分叉成一个或多个树枝,每个树枝又会分叉成一个或多个树枝,以此类推,最终形成一片树叶。从数学/计算机科学的角度来看,我们可以将其建模为一个递归过程。
让我们继续以这个例子为例。
如果我们观察自然界中树木的一根树枝,我们会发现一些事情。
- 分支的半径和长度都比它所连接的分支要小。
- 树枝的粗细向末端逐渐变细。
- 根据树的种类,树枝可以是笔直的,也可以是扭曲的,向各个方向弯曲。
- 枝条往往会朝着阳光的方向生长。
- 树枝从树干水平伸展时,重力会将它们向下拉向地面。这种拉力的大小取决于树枝的粗细和树叶的数量。
所有这些观察结果都可以被归纳成各自的数学规则。然后,我们可以将所有规则组合起来,创造出类似树枝的形状。这就是所谓的涌现行为,它指的是许多简单的规则可以组合在一起,创造出比各个部分更复杂的事物。
L系统
数学中有一个领域试图将这类自然过程形式化,称为林登迈尔系统,或更常见的L系统。L系统是一种创建复杂模式的简单方法,常用于模拟植物、树木和其他自然现象的生长。它们从一个初始字符串(称为公理)开始,并反复应用一组规则来重写该字符串。这些规则定义了字符串的每个部分如何转换为新的序列。然后,可以使用绘图指令将生成的字符串转换为视觉模式。
虽然我即将向您展示的代码没有使用 L 系统(当时我根本不知道它们),但原理非常相似,两者都基于递归过程。
使用 L 系统生成的树的示例(来源:维基百科)
理论就说到这里,让我们直接来看代码吧!
树生成过程
树的生成过程始于该generate()方法。该方法初始化用于存储分支和叶子几何形状的数据结构,设置随机数生成器(RNG),并通过将树干添加到分支队列来启动该过程。
- // The starting point for the tree generation process
- generate() {
- // Initialize geometry data
- this.branches = { };
- this.leaves = { };
- // Initialize RNG
- this.rng = new RNG(this.options.seed);
- // Start with the trunk
- this.branchQueue.push(
- new Branch(
- new THREE.Vector3(), // Origin
- new THREE.Euler(), // Orientation
- this.options.branch.length[0], // Length
- this.options.branch.radius[0], // Radius
- 0, // Recursion level
- this.options.branch.sections[0], // # of sections
- this.options.branch.segments[0], // # of segments
- ),
- );
- // Process branches in the queue
- while (this.branchQueue.length > 0) {
- const branch = this.branchQueue.shift();
- this.generateBranch(branch);
- }
- }
复制代码 Branch数据结构
该Branch数据结构保存了生成分支所需的输入参数。每个分支都使用以下参数表示:
- origin– 定义三维空间中分支的起始点(x, y, z)。
- orientation– 使用欧拉角指定分支的旋转(pitch, yaw, roll)。
- length– 树枝从根部到顶端的总长度
- radius– 设置树枝的粗细
- level – 表示递归深度,主干从第 0 层开始。
- sectionCount– 定义树干沿其长度方向被分割的次数。
- segmentCount– 通过设置树干周长周围的分段数来控制平滑度。
了解分支队列
这branchQueue是树生成过程中至关重要的一部分。它保存着所有待生成的分支。第一个分支从队列中取出,并生成其几何形状。然后,我们递归地生成Branch子分支的对象,并将它们添加到队列中以便稍后处理。这个过程会一直持续到队列被填满为止。
生成分支
该generateBranch()函数是树生成过程的核心。它包含了根据Branch对象中包含的输入创建单个分支几何形状所需的所有规则。
让我们来看一下这个函数的关键部分。
三维几何入门
在生成树枝之前,我们首先需要了解 Three.js 中是如何存储 3D 几何体的。
在表示三维物体时,我们通常使用索引几何体,它通过减少冗余来优化渲染。几何体由四个主要部分组成:
- 顶点——三维空间中定义物体形状的点列表。每个顶点都由一个THREE.Vector3包含其 x、y 和 z 坐标的数组表示。这些点构成了几何体的“基本组成单元”。
- 索引——一个整数列表,用于定义顶点如何连接形成面(通常是三角形)。索引引用已有的顶点,而不是为每个面存储重复的顶点,从而显著降低内存使用量。例如,三个索引 [0, 1, 2] 使用顶点列表中的第一个、第二个和第三个顶点构成一个三角形。
- 法线——“法线”向量描述了顶点在三维空间中的方向;简而言之,就是表面指向的方向。法线对于光照计算至关重要,因为它们决定了光线如何与表面相互作用,从而产生逼真的阴影和高光。
- UV坐标——一组二维坐标,用于将纹理映射到几何体上。每个顶点都被赋予一对介于0.0和1.0之间的UV值,这些值决定了图像或材质如何包裹物体表面。这些坐标使纹理能够与几何体的形状正确对齐。
该generateBranch()函数逐节生成分支顶点、索引、法线和 UV 坐标,并将结果附加到各自的数组中。
- this.branches = {
- verts: [],
- indices: [],
- normals: [],
- uvs: []
- };
复制代码 几何体全部生成后,将这些数组组合成一个网格,该网格完整地表示了树的几何形状及其材质。
- _<font >左图:单个树枝的线框图;右图:应用了简单平面光照模型后的同一树枝。</font>_
复制代码 从上图可以看出,树枝沿其长度方向由 10 个独立的节段组成,每个节段又有 5 个边(或线段)。我们可以调整树枝的节段数和线段数,从而控制最终模型的细节程度。数值越高,模型越平滑,但性能也会相应降低。
既然如此,让我们深入了解一下树生成算法吧!
初始化
[code]let sectionOrigin = branch.origin.clone();let sectionOrientation = branch.orientation.clone();let sectionLength = branch.length / branch.sectionCount;let sections = [];for (let i = 0; i |