本文是由 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 系统生成的树的示例(来源:维基百科)
理论就说到这里,让我们直接来看代码吧!
树生成过程
树的生成过程始于该<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">generate()</font>方法。该方法初始化用于存储分支和叶子几何形状的数据结构,设置随机数生成器(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);
}
}
<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">Branch</font>数据结构
该<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">Branch</font>数据结构保存了生成分支所需的输入参数。每个分支都使用以下参数表示:
<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">origin</font>– 定义三维空间中分支的起始点<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">(x, y, z)</font>。<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">orientation</font>– 使用欧拉角指定分支的旋转<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">(pitch, yaw, roll)</font>。<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">length</font>– 树枝从根部到顶端的总长度<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">radius</font>– 设置树枝的粗细<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">level</font>– 表示递归深度,主干从第 0 层开始。<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">sectionCount</font>– 定义树干沿其长度方向被分割的次数。<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">segmentCount</font>– 通过设置树干周长周围的分段数来控制平滑度。
了解分支队列
这<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">branchQueue</font>是树生成过程中至关重要的一部分。它保存着所有待生成的分支。第一个分支从队列中取出,并生成其几何形状。然后,我们递归地生成<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">Branch</font>子分支的对象,并将它们添加到队列中以便稍后处理。这个过程会一直持续到队列被填满为止。
生成分支
该<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">generateBranch()</font>函数是树生成过程的核心。它包含了根据<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">Branch</font>对象中包含的输入创建单个分支几何形状所需的所有规则。
让我们来看一下这个函数的关键部分。
三维几何入门
在生成树枝之前,我们首先需要了解 Three.js 中是如何存储 3D 几何体的。
在表示三维物体时,我们通常使用索引几何体,它通过减少冗余来优化渲染。几何体由四个主要部分组成:
- 顶点——三维空间中定义物体形状的点列表。每个顶点都由一个
<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">THREE.Vector3</font>包含其 x、y 和 z 坐标的数组表示。这些点构成了几何体的“基本组成单元”。 - 索引——一个整数列表,用于定义顶点如何连接形成面(通常是三角形)。索引引用已有的顶点,而不是为每个面存储重复的顶点,从而显著降低内存使用量。例如,三个索引 [0, 1, 2] 使用顶点列表中的第一个、第二个和第三个顶点构成一个三角形。
- 法线——“法线”向量描述了顶点在三维空间中的方向;简而言之,就是表面指向的方向。法线对于光照计算至关重要,因为它们决定了光线如何与表面相互作用,从而产生逼真的阴影和高光。
- UV坐标——一组二维坐标,用于将纹理映射到几何体上。每个顶点都被赋予一对介于0.0和1.0之间的UV值,这些值决定了图像或材质如何包裹物体表面。这些坐标使纹理能够与几何体的形状正确对齐。
该<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">generateBranch()</font>函数逐节生成分支顶点、索引、法线和 UV 坐标,并将结果附加到各自的数组中。
this.branches = {
verts: [],
indices: [],
normals: [],
uvs: []
};
几何体全部生成后,将这些数组组合成一个网格,该网格完整地表示了树的几何形状及其材质。
_<font style="color:rgb(38, 39, 46);background-color:rgb(247, 247, 247);">左图:单个树枝的线框图;右图:应用了简单平面光照模型后的同一树枝。</font>_
从上图可以看出,树枝沿其长度方向由 10 个独立的节段组成,每个节段又有 5 个边(或线段)。我们可以调整树枝的节段数和线段数,从而控制最终模型的细节程度。数值越高,模型越平滑,但性能也会相应降低。
既然如此,让我们深入了解一下树生成算法吧!
初始化
let sectionOrigin = branch.origin.clone();
let sectionOrientation = branch.orientation.clone();
let sectionLength = branch.length / branch.sectionCount;
let sections = [];
for (let i = 0; i <= branch.sectionCount; i++) {
// Calculate section radius
// Build section geometry
}
首先,我们初始化分支的起点、方向和长度。接下来,我们定义一个数组来存储分支的各个部分。最后,我们遍历每个部分并生成其几何数据。在遍历每个部分的过程中, x<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">sectionOrigin</font>和y 变量都会更新。<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">sectionOrientation</font>
分支截面半径
为了计算树枝的半径,我们首先要计算树枝的整体半径。如果是树枝的最后一节,我们将半径设为零,因为我们希望树枝末端呈尖状。对于其他所有树枝节,我们根据其在树枝上的位置计算出需要收缩的程度(越靠近末端,收缩程度越大),然后将该值乘以前一节的半径。
let sectionRadius = branch.radius;
// If last section, set radius to effectively zero
if (i === branch.sectionCount) {
sectionRadius = 0.001;
} else {
sectionRadius *=
1 - this.options.branch.taper[branch.level] * (i / branch.sectionCount);
}
构建截面几何形状
<font style="color:rgb(38, 39, 46);background-color:rgb(247, 247, 247);">单个截面的几何体线框图。以下代码为圆柱体的每一面构建三角形对。</font>
由于圆柱体的端部被遮挡,因此保持开放状态。
现在我们已经掌握了足够的信息来构建截面几何体。接下来数学计算会变得稍微复杂一些!你只需要知道,我们使用之前计算出的截面原点、方向和半径来创建每个顶点、法线和UV坐标。
// Create the segments that make up this section.
for (let j = 0; j < branch.segmentCount; j++) {
let angle = (2.0 * Math.PI * j) / branch.segmentCount;
const vertex = new THREE.Vector3(Math.cos(angle), 0, Math.sin(angle))
.multiplyScalar(sectionRadius)
.applyEuler(sectionOrientation)
.add(sectionOrigin);
const normal = new THREE.Vector3(Math.cos(angle), 0, Math.sin(angle))
.applyEuler(sectionOrientation)
.normalize();
const uv = new THREE.Vector2(
j / branch.segmentCount,
(i % 2 === 0) ? 0 : 1,
);
this.branches.verts.push(...Object.values(vertex));
this.branches.normals.push(...Object.values(normal));
this.branches.uvs.push(...Object.values(uv));
}
sections.push({
origin: sectionOrigin.clone(),
orientation: sectionOrientation.clone(),
radius: sectionRadius,
});
这就是创建单个分支几何形状的方法!
然而,单凭这一点并不能生成一棵非常有趣的树。程序生成的树之所以看起来美观,很大程度上取决于各部分之间的相对方向以及整体分支的布局规则。
让我们来看看使分支看起来更有趣的两个参数。
太棒了,老兄!
我最喜欢的参数之一是树干的弯曲程度(gnarliness)。它控制着树枝的扭曲和弯曲程度。在我看来,这对于赋予树木“生命力”而非使其显得死气沉沉、毫无生气至关重要。
左边,低弯曲 右边,高弯曲
树枝的弯曲程度可以用数学方法来表示,即通过控制树枝某一部分与前一部分方向的偏差程度。这些偏差会沿着树枝的长度累积,从而产生一些有趣的现象。
const gnarliness =
Math.max(1, 1 / Math.sqrt(sectionRadius)) *
this.options.branch.gnarliness[branch.level];
sectionOrientation.x += this.rng.random(gnarliness, -gnarliness);
sectionOrientation.z += this.rng.random(gnarliness, -gnarliness);
从上面的表达式可以看出,树枝的弯曲程度与树枝的半径成反比。这反映了树木在自然界中的生长规律:较小的树枝比较大的树枝更容易卷曲和扭曲。
我们在一定范围内生成一个随机倾斜角度<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">[-gnarliness, gnarliness]</font>,然后将其应用于下一节的方向。
运用力量!
下一个参数是生长力,它模拟树木如何朝着阳光生长以最大化光合作用(也可以用来模拟重力作用于树枝,使其向地面下坠)。这对于模拟白杨树等树木尤其有用,因为它们的树枝往往笔直向上生长,而不是远离树干。
左:生长力禁用 右: 设置生长力 + Y 方向
我们定义了一个生长方向(可以想象成指向太阳的光线)和一个生长强度 因子。每个枝条都会进行微小的旋转,使其沿着生长方向排列。旋转的幅度与生长强度因子成正比,与枝条半径成反比,因此较小的枝条受到的影响更大。
const qSection = new THREE.Quaternion().setFromEuler(sectionOrientation);
const qTwist = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
this.options.branch.twist[branch.level],
);
const qForce = new THREE.Quaternion().setFromUnitVectors(
new THREE.Vector3(0, 1, 0),
new THREE.Vector3().copy(this.options.branch.force.direction),
);
qSection.multiply(qTwist);
qSection.rotateTowards(
qForce,
this.options.branch.force.strength / sectionRadius,
);
sectionOrientation.setFromQuaternion(qSection);
上述代码使用四元数来表示旋转,从而避免了使用更常用的欧拉角(俯仰角、偏航角、滚转角)时出现的一些问题。四元数超出了本文的讨论范围,但您只需知道它是一种表示物体在空间中方向的不同方法即可。
附加参数
树干的弯曲度和生长力并非唯二可调参数。以下列出了其他可用于控制树木生长的可调参数。
<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">angle</font>此参数设置子枝相对于父枝的生长角度。通过调整此值,您可以控制子枝是陡峭向上生长(几乎与父枝平行),还是以较大角度向外扇形生长,从而模拟不同类型的树木。<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">children</font>此参数控制从单个父分支生成的子分支数量。增加此值会生成更茂密、更复杂的树状结构,分支密度更高;而减小此值则会生成稀疏、极简的树状结构。<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">start</font>此参数决定子枝从父枝的哪个位置开始生长。数值越低,子枝越靠近父枝的基部生长;数值越高,子枝越靠近父枝的顶端生长,从而形成不同的生长模式。<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">twist</font>– “扭曲”参数会对树枝的几何形状进行绕其轴线的旋转调整。通过修改此值,您可以引入螺旋效果,为树枝增添动态、自然的视觉效果,模拟树木扭曲或弯曲的生长形态。
你还能想到其他需要补充的吗?
生成子分支
分支几何形状生成完毕后,接下来就要生成它的子分支了。
if (branch.level === this.options.branch.levels) {
this.generateLeaves(sections);
} else if (branch.level < this.options.branch.levels) {
this.generateChildBranches(
this.options.branch.children[branch.level],
branch.level + 1,
sections);
}
如果我们已经到了递归的最后一层,那么我们生成的是叶节点而不是分支。稍后我们会详细介绍叶节点的生成过程,但它与生成子分支的方式其实差别不大。
如果尚未到达递归的最后一层,则调用该<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">generateChildBranches()</font>函数。
generateChildBranches(count, level, sections) {
for (let i = 0; i < count; i++) {
// Calculate the child branch parameters...
this.branchQueue.push(
new Branch(
childBranchOrigin,
childBranchOrientation,
childBranchLength,
childBranchRadius,
level,
this.options.branch.sections[level],
this.options.branch.segments[level],
),
);
}
}
该函数遍历每个子分支,生成填充<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">Branch</font>数据结构所需的值,并将结果附加到该分支<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">branchQueue</font>,然后由<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">generateBranch()</font>我们在上一节中讨论的函数进行处理。
该<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">generateChildBranches()</font>函数需要几个参数
<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">count</font>– 要生成的子分支数量<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">level</font>– 当前的递归级别,以便我们知道是否需要<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">generateChildBranches()</font>再次调用,或者是否应该就此停止。<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">sections</font>这是父分支的节数据数组。它包含节的起点和方向,这些信息将用于帮助确定子分支的放置位置。
计算子分支参数
让我们来详细分析一下每个子分支参数是如何计算的。
起源
// Determine how far along the length of the parent branch the child
// branch should originate from (0 to 1)
let childBranchStart = this.rng.random(1.0, this.options.branch.start[level]);
// Find which sections are on either side of the child branch origin point
// so we can determine the origin, orientation and radius of the branch
const sectionIndex = Math.floor(childBranchStart * (sections.length - 1));
let sectionA, sectionB;
sectionA = sections[sectionIndex];
if (sectionIndex === sections.length - 1) {
sectionB = sectionA;
} else {
sectionB = sections[sectionIndex + 1];
}
// Find normalized distance from section A to section B (0 to 1)
const alpha =
(childBranchStart - sectionIndex / (sections.length - 1)) /
(1 / (sections.length - 1));
// Linearly interpolate origin from section A to section B
const childBranchOrigin = new THREE.Vector3().lerpVectors(
sectionA.origin,
sectionB.origin,
alpha,
);
这个方法其实并不像看起来那么复杂。在确定分支位置时,我们首先生成一个介于 0.0 和 1.0 之间的随机数,该随机数表示子分支应该放置在父分支长度的哪个位置。然后,我们找到该点两侧的节段,并通过插值法确定子分支的起始点。
半径
const childBranchRadius =
this.options.branch.radius[level] *
((1 - alpha) * sectionA.radius + alpha * sectionB.radius);
半径的计算逻辑与原点相同。我们查看子分支两侧各段的半径,并对这些值进行插值,从而得到子分支的半径。
方向
// Linearlly interpolate the orientation
const qA = new THREE.Quaternion().setFromEuler(sectionA.orientation);
const qB = new THREE.Quaternion().setFromEuler(sectionB.orientation);
const parentOrientation = new THREE.Euler().setFromQuaternion(
qB.slerp(qA, alpha),
);
// Calculate the angle offset from the parent branch and the radial angle
const radialAngle = 2.0 * Math.PI * (radialOffset + i / count);
const q1 = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(1, 0, 0),
this.options.branch.angle[level] / (180 / Math.PI),
);
const q2 = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
radialAngle,
);
const q3 = new THREE.Quaternion().setFromEuler(parentOrientation);
const childBranchOrientation = new THREE.Euler().setFromQuaternion(
q3.multiply(q2.multiply(q1)),
);
四元数再次发挥作用!在确定子分支方向时,我们需要考虑两个角度。
- 父分支周围的径向角度。我们希望子分支均匀分布在主分支的圆周上,而不是都指向同一个方向。
- 子分支与父分支之间的角度。该角度是参数化的,可以进行调整以获得所需的特定效果。
分支角和径向角示意图
这两个角度与父分支的方向结合起来,确定分支在三维空间中的最终方向。
长度
let childBranchLength = this.options.branch.length[level];
最后,分支的长度由用户界面上设置的参数决定。
计算完所有这些值后,我们就拥有了生成子分支所需的足够信息。我们对每个子分支重复此过程,直到生成所有子分支为止。<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">branchQueue</font>现在,该对象已填充了所有子分支数据,这些数据将按顺序处理并传递给<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">generateBranch()</font>函数。
生成叶子
叶片的生成过程与子枝的生成过程几乎完全相同。主要区别在于,叶片是以纹理的形式渲染到四边形(即矩形平面)上的,因此我们不是生成枝条,而是生成一个四边形,并以与枝条相同的方式定位和调整其方向。
为了增加树叶的茂盛度,使树叶从各个角度都能被看到,我们使用了两个四边形而不是一个,并将它们彼此垂直放置。
透明度禁用 透明度启用
控制叶片外观的参数有很多。
<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">type</font>我找到了几种不同的叶子纹理,因此可以生成各种不同类型的叶子。<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">size</font>– 控制叶片四边形的整体大小,使叶片变大或变小。<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">count</font>每个分支要生成的叶子数量<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">angle</font>– 叶片相对于枝条的角度(类似于枝条<font style="color:rgb(101, 117, 128);background-color:rgb(237, 240, 243);">angle</font>参数)
环境设计
美丽的树需要一个美丽的家,所以我投入了大量精力为 EZ-Tree 打造一个逼真的环境。虽然这并非本文的主题,但我还是想重点介绍一下我添加的一些环境元素,它们让场景更加生动。
如果您想了解更多关于我如何创建该环境的信息,本文顶部/底部提供了源代码链接。
地面
第一步是添加地面。我使用了平滑噪波函数,使其在泥土纹理和草地纹理之间切换。在模拟自然界的任何事物时,始终要注意那些瑕疵;正是这些瑕疵让场景感觉自然真实,而不是生硬虚假。
云
接下来,我添加了一些云。这些云实际上只是另一种噪波纹理(看出规律了吗?),我将其应用到一个巨大的四边形上,并将该四边形放置在场景上方。为了让云看起来“生动”,我调整了纹理随时间的变化,使其呈现出云朵移动的效果。我选择了一个非常柔和、略带阴天的天空,以免分散人们对场景焦点——那棵树的注意力。
树叶和岩石
为了让地面更丰富多彩,我添加了一些草、石头和花朵。为了提升性能,靠近树的草比较茂密,远离树的草则比较稀疏。我选择了一些带有苔藓的石头模型,这样它们就能更好地融入地面。花朵也为单调的绿色增添了斑斓的色彩。
森林
我们的树感觉有点孤单,所以在应用程序加载时,我生成了 100 棵树(从预设列表中选择),并将它们放置在主树周围。
风
自然界时刻处于运动状态,因此在树木和草地的建模中模拟这种运动至关重要。我编写了自定义着色器,用于实现草、树枝和树叶的几何动画效果。我定义了风向,然后将几个不同频率和振幅的正弦函数相加,再将结果应用于几何体的每个顶点,从而获得所需的效果。
下面摘录一段 GLSL 着色器代码,用于控制应用于顶点位置的风偏移量。
vec4 mvPosition = vec4(transformed, 1.0);
float windOffset = 2.0 * 3.14 * simplex3(mvPosition.xyz / uWindScale);
vec3 windSway = uv.y * uWindStrength * (
0.5 * sin(uTime * uWindFrequency + windOffset) +
0.3 * sin(2.0 * uTime * uWindFrequency + 1.3 * windOffset) +
0.2 * sin(5.0 * uTime * uWindFrequency + 1.5 * windOffset)
);
mvPosition.xyz += windSway;
mvPosition = modelViewMatrix * mvPosition;
gl_Position = projectionMatrix * mvPosition;
结论
希望您喜欢我对程序化树木生成器的详细介绍!程序化生成是一个非常棒的领域,它能将艺术和科学结合起来,创造出既美观又实用的东西。
参考链接
最后,关注公号“ITMan彪叔” 可以添加作者微信进行交流,及时收到更多有价值的文章。