前言
50个教程源码下载: 点击 Get the source, 即可下载源码
楼主真是太贴心了,下载下来的源码已经是带有工程文件了,所以不用使用CMake编译,直接打开下载路径下的工程:ogldev.sln,这里最好用vs2019,我用vs2017打开有时编译不过,不管是vs2019还是vs2017打开,都需要改动一点代码,不然编译不过
把报错的地方:return false; 或者 return true;
改成:return nullptr;
下载的源码50个项目都在一个解决方案中, 我这里把教程38直接分离出来,做了一个单独工程:
这个网站上目前更新了50个openGL代码实例,下图是第38个例子,使用Assimp库加载骨骼动画,注意,运行这个程序的时候,下图的人物是运动的
先上个效果图:
网站原文
utorial 38:
Skeletal Animation With Assimp
Background
Finally, it is here. The tutorial that millions of my readers (I may be exaggerating here, but definitely a few ;-) ) have been asking for. Skeletal animation, also known as Skinning, using the Assimp library.
Skeletal animation is actually a two part process. The first one is executed by the artist and the second by you, the programmer (or rather, the engine that you wrote). The first part takes place inside the modeling software and is called Rigging. What happens here is that the artist defines a skeleton of bones underneath the mesh. The mesh represents the skin of the object (be it a human, monster or whatever) and the bones are used to move the mesh in a way that would mimic actual movement in the real world. This is done by assigning each vertex to one or more bones. When a vertex is assigned to a bone a weight is defined that determines the amount of influence that bone has on the vertex when it moves. The common practice is to make the sum of all weights 1 (per vertex). For example, if a vertex is located exactly between two bones we would probably want to assign each bone a weight of 0.5 because we expect the bones to be equal in their influence on the vertex. However, if a vertex is entirely within the influence of a single bone then the weight would be 1 (which means that bone autonomously controls the movement of the vertex).
Here's an example of a bone structure created in blender:
翻译:
utorial 38:
用Assimp制作骨骼动画
背景
终于,它来了。这是我的数百万读者(我可能有点夸张,但肯定是少数;-)所要求的教程。骨骼动画,也称为皮肤,使用Assimp库。
骨骼动画实际上是一个由两部分组成的过程。第一个任务是由美工执行的,第二个任务是由你这个程序员(或者说是你所编写的引擎)执行的。第一部分发生在建模软件内部,称为索具。这里所发生的是艺术家在网格下定义骨骼的骨架。网格代表物体(人类、怪物或其他)的皮肤,骨头用来模仿现实世界中的实际运动。这是通过将每个顶点分配给一个或多个骨头来完成的。当将顶点分配给骨头时,将定义一个权重,该权重决定骨头移动时对顶点的影响程度。通常的做法是使所有权值的和为1(每个顶点)。例如,如果一个顶点恰好位于两个骨头之间,我们可能希望为每个骨头分配0.5的权重,因为我们期望骨头对顶点的影响是相等的。但是,如果一个顶点完全在单个骨头的影响范围内,那么重量将为1(这意味着骨头自主地控制顶点的运动)。
下面是一个在blender中创建的骨骼结构的例子:
What we see above is actually an important part of the animation. The artist riggs together the bone structure and defines a set of key frames for each animation type ("walk", "run", "die", etc). The key frames contain the transformations of all bones in critical points along the animation path. The graphics engine interpolates between the transformations of the keyframes and creates a smooth motion between them.
The bone structure used for skeletal animation is often heirarchical. This means that the bones have a child/parent relationships so a tree of bones is created. Every bone has one parent except for the root bone. In the case of the human body, for example, you may assign the back bone as the root with child bones such as arms and legs and finger bones on the next level done. When a parent bone moves it also moves all of its children, but when a child bone moves it does not move it parent (our fingers can move without moving the hand, but when the hand moves it moves all of its fingers). From a practical point of view this means that when we process the transformations of a bone we need to combine it with the transformations of all the parent bones that lead from it to the root.
We are not going to discuss rigging any further. It is a complex subject and outside the domain of graphics programmers. Modeling software has advanced tools to help the artist do this job and you need to be a good artist to create a good looking mesh and skeleton. Let's see what the graphics engine needs to do in order to make skeletal animation.
The first stage is to augument the vertex buffer with per vertex bone information. There are several options available but what we are going to do is pretty straightforward. For each vertex we are going to add an array of slots where each slot contains a bone ID and a weight. To make our life simpler we will use an array with four slots which means no vertex can be influenced by more than four bones. If you are going to load models with more bones you will need to adjust the array size but for the Doom 3 model that is part of this tutorial demo four bones are enough. So our new vertex structure is going to look like this:
翻译:
我们上面看到的实际上是动画的一个重要部分。美工将骨骼结构组合在一起,并为每种动画类型定义一组关键帧(“行走”、“奔跑”、“死亡”等)。关键帧包含沿着动画路径的关键点中所有骨骼的转换。图形引擎在关键帧的转换之间插入,并在它们之间创建一个平滑的运动。
用于骨骼动画的骨骼结构通常是分层的。这意味着骨骼具有子/父关系,因此创建了骨骼树。除了根骨,每个骨头都有一个“父”。以人体为例,您可以将脊骨指定为根,并在下一层完成子骨(如手臂、腿和手指骨)。当一个父骨移动时,它也会移动它所有的子骨,但当子骨移动时,它不会移动它的父骨(我们的手指可以不移动手,但手移动时,它会移动所有的手指)。从实用的角度来看,这意味着当我们处理一个骨头的转换时,我们需要将它与从它到根的所有父骨头的转换结合起来。
我们不打算进一步讨论操纵。这是一个复杂的主题,超出了图形程序员的领域。建模软件有先进的工具来帮助美工做这项工作,你需要成为一个好的美工来创建一个好看的网格和骨架。让我们看看为了制作骨骼动画,图形引擎需要做些什么。
第一步是使用每个顶点骨架信息来增强顶点缓冲区。有几个可用的选择,但我们要做的很简单。对于每个顶点,我们将添加一个槽数组,其中每个槽包含一个骨ID和一个权重。为了让我们的生活更简单,我们将使用一个有四个槽的数组,这意味着没有顶点可以被四个以上的骨头影响。如果你要加载更多的骨骼模型,你需要调整数组大小,但对于毁灭战士3模型,这是本教程演示的一部分,四个骨骼就足够了。所以我们的新顶点结构就像这样:
The bone IDs are indices into an array of bone transformations. These tranformations will be applied on the position and normal before the WVP matrix (i.e. they transform the vertex from a "bone space" into local space). The weight will be used to combine the transformations of several bones into a single transformation and in any case the total weight must be exactly 1 (responsibility of the modeling software). Usually, we would interpolate between animation key frames and update the array of bone transformations in every frame.
The way the array of bone transformations is created is usually the tricky part. The transformations are set in a heirarchical structure (i.e. tree) and a common practice is to have a scaling vector, a rotation quaternion and a translation vector in every node in the tree. In fact, each node contains an array of these items. Every entry in the array must have a time stamp. The case where the application time will exactly match one of the time stamps is probably rare so our code must be able to interpolate the scaling/rotation/translation to get the correct transformation for the point in time of the application. We do the same process for each node from the current bone to the root and multiply this chain of transformations together to get the final result. We do that for each bone and then update the shader.
Everything that we talked about so far has been pretty generic. But this is a tutorial about skeletal animation with Assimp, so we need to dive into that library again and see how to do skinning with it. The good thing about Assimp is that it supports loading bone information from several formats. The bad thing is that you still need to do quite a bit of work on the data structures that it creates to generate the bone transformations that you need for the shaders.
Let's start at the bone information at the vertex level. Here's the relevant pieces in Assimp data structures:
翻译:
骨骼id是骨骼转换数组的索引。这些转换将应用于WVP矩阵之前的位置和法线(即,它们将顶点从“骨空间”转换为局部空间)。重量将用于将几个骨头的转换合并成一个转换,在任何情况下,总重量必须正好是1(建模软件的责任)。通常,我们会在动画关键帧之间插补,并更新每一帧的骨骼变换数组。
创建骨骼转换数组的方法通常是比较棘手的部分。这些转换是在一个层次结构(即树)中设置的,通常的做法是在树的每个节点中都有一个缩放向量、一个旋转四元数和一个平移向量。实际上,每个节点都包含这些项的数组。数组中的每个条目都必须有一个时间戳。应用程序的时间与某个时间戳完全匹配的情况可能很少,所以我们的代码必须能够插值缩放/旋转/平移,以获得应用程序的时间点的正确转换。我们对从当前骨头到根的每个节点执行相同的过程,并将这个转换链相乘以得到最终结果。我们对每个骨头都这样做,然后更新着色器。
到目前为止,我们讨论的所有内容都很笼统。但是这是一个关于Assimp骨骼动画的教程,所以我们需要再次深入到那个库,看看如何用它做皮肤。Assimp的优点是它支持从几种格式加载骨骼信息。不好的是,你仍然需要对它创建的数据结构做大量的工作来生成你需要的着色器的骨骼转换。
让我们从顶点的骨骼信息开始。以下是Assimp数据结构中的相关部分:
As you probably recall from the tutorial on Assimp, everything is contained in the aiScene class (an object of which we get when we import the mesh file). The aiScene contains an array of aiMesh objects. An aiMesh is a part of the model and contains stuff at the vertex level such as position, normal, texture coordinates, etc. Now we see that aiMesh also contains an array of aiBone objects. Unsuprisingly, an aiBone represents one bone in the skeleton of the mesh. Each bone has a name by which it can be found in the bone heirarchy (see below), an array of vertex weights and a 4x4 offset matrix. The reason why we need this matrix is because the vertices are stored in the usual local space. This means that even without skeletal animation support our existing code base can load the model and render it correctly. But the bone transformations in the heirarchy work in a bone space (and every bone has its own space which is why we need to multiply the transformations together). So the job of the offset matrix it to move the vertex position from the local space of the mesh into the bone space of that particular bone.
The vertex weight array is where things start to become interesting. Each entry in this array contains an index into the array of vertices in the aiMesh (remember that the vertex is spread across several arrays with the same length) and a weight. The sum of all vertex weights must be 1 but to find them you need to walk through all the bones and accumulate the weights into a kind of list for each particular vertex.
After we build the bone information at the vertex level we need to process the bone transformation heirarchy and generate the final transformations that we will load into the shader. The following picture displays the relevant data structures:
翻译:
您可能还记得关于Assimp的教程,所有内容都包含在aiScene类中(导入网格文件时获得的对象)。aiScene包含一个aiMesh对象数组。aiMesh是模型的一部分,包含了顶点级别的内容,如位置、法线、纹理坐标等。现在我们看到aiMesh也包含了一个aiBone对象的数组。不出所料,aiBone表示网格骨架中的一根骨头。每个骨骼都有一个名称,通过这个名称可以在骨骼层次结构中找到(见下文),一个顶点权重数组和4x4偏移矩阵。我们需要这个矩阵的原因是这些顶点都存储在通常的局部空间中。这意味着即使没有骨骼动画支持,我们现有的代码库也可以加载模型并正确渲染它。但是在层次结构中的骨骼转换在一个骨骼空间中工作(并且每个骨骼都有自己的空间,这就是为什么我们需要将这些转换相乘)。所以偏移矩阵的工作是将顶点位置从网格的局部空间移动到特定骨骼的骨骼空间。
顶点权重数组开始变得有趣了。这个数组中的每个条目都包含一个指向aiMesh中顶点数组的索引(记住,顶点分布在几个具有相同长度的数组中)和一个权重。所有顶点权值的总和必须为1,但要找到它们,你需要遍历所有的骨架,并将权值累加到每个特定顶点的一种列表中。
在我们在顶点层构建骨骼信息之后,我们需要处理骨骼变换层次结构,并生成最终的转换,我们将加载到着色器中。下图显示了相关的数据结构:
Again, we start at the aiScene. The aiScene object contains a pointer to an object of the aiNode class which is the root of the a node heirarchy (in other words - a tree). Each node in the tree has a pointer back to its parent and an array of pointers to its children. This allows us to conveniently traverse the tree back and forth. In addition, the node carries a transformation matrix that transforms from the node space into the space of its parent. Finally, the node may or may not have a name. If a node represents a bone in the heirarchy then the node name must match the bone name. But sometimes nodes have no name (which means there is not corresponding bone) and their job is simply to help the modeller decompose the model and place some intermediate transformation along the way.
The last piece of the puzzle is the aiAnimation array which is also stored in the aiScene object. A single aiAnimation object represents a sequence of animation frames such as "walk", "run", "shoot", etc. By interpolating between the frames we get the desired visual effect which matches the name of the animation. An animation has a duration in ticks and the number of ticks per second (e.g 100 ticks and 25 ticks per second represent a 4 second animation) which help us time the progression so that the animation will look the same on every hardware. In addition, the animation has an array of aiNodeAnim objects called channels. Each channel is actually the bone with all its transformations. The channel contains a name which must match one of the nodes in the heirarchy and three transformation arrays.
In order to calculate the final bone transformation in a particular point in time we need to find the two entries in each of these three arrays that matches the time and interpolate between them. Then we need to combine the transformations into a single matrix. Having done that we need to find the corresponding node in the heirarchy and travel to its parent. Then we need the corresponding channel for the parent and do the same interpolation process. We multiply the two transformations together and continue until we reach the root of the heirarchy.
Source walkthru
翻译:
再一次,我们从aiScene开始。aiScene对象包含一个指向aiNode类的对象的指针,该对象是节点层次结构的根(换句话说——树)。树中的每个节点都有一个指向其父节点的指针和一个指向其子节点的指针数组。这使得我们可以方便地来回遍历树。此外,节点携带一个从节点空间转换到其父节点空间的转换矩阵。最后,节点可以有名称,也可以没有名称。如果一个节点代表层次结构中的一个主干,那么节点名称必须与主干名称匹配。但是有时节点没有名称(这意味着没有对应的骨头),它们的工作只是帮助建模者分解模型并在过程中进行一些中间转换。
最后一个谜题是aiAnimation数组,它也存储在aiScene对象中。一个aiAnimation对象代表一系列的动画帧,如“行走”、“运行”、“射击”等。通过插值帧之间,我们得到所需的视觉效果,与动画的名称匹配。动画以“滴答”为持续时间,每秒的“滴答”次数(例如100“滴答”和25“滴答”代表4秒的动画)可以帮助我们计时进程,从而使动画在每个硬件上看起来都是相同的。此外,动画有一个名为通道的aiNodeAnim对象数组。每个通道实际上是所有转换的骨架。通道包含一个名称,该名称必须匹配层次结构中的一个节点和三个转换数组。
为了计算特定时间点的最终骨转换,我们需要找到这三个数组中每个匹配时间的两个条目,并在它们之间进行插值。然后我们需要把这些变换合并成一个矩阵。完成这一步后,我们需要在层次结构中找到相应的节点并移动到它的父节点。然后我们需要相应的通道为父,并做相同的插值过程。我们将两个转换相乘,然后继续,直到到达层次结构的根。
源walkthru
Here's the updated entry point to the Mesh class with changes marked in bold face. There are a couple of changes that we need to note. One is that the importer and aiScene object are now class members rather then stack variables. The reason is that during runtime we are going to go back to the aiScene object again and again and for that we need to extend the scope of both the importer and the scene. In a real game you may want to copy the stuff that you need and store it at a more optimized format but for educational purposes this is enough.
The second change is that the transformation matrix of the root of the heirarchy is extracted, inversed and stored. We are going to use that further down the road. Note that the matrix inverse code has been copied from the Assimp library into our Matrix4f class.
翻译:
下面是更新后的Mesh类入口点,以粗体标示。我们需要注意一些变化。一个是导入器和aiScene对象现在是类成员,而不是堆栈变量。原因是,在运行时,我们将一次又一次地返回到aiScene对象,为此,我们需要同时扩展导入器和场景的范围。在真正的游戏中,你可能想要复制你所需要的内容,并以更优化的格式存储它,但出于教育目的,这就足够了。
第二个变化是,提取了层次结构根的转换矩阵,求逆并存储。我们以后会用到它。注意,矩阵逆代码已经从Assimp库复制到我们的Matrix4f类中。
The structure above contains everything we need at the vertex level. By default, we have enough storage for four bones (ID and weight per bone). VertexBoneData was structured like that to make it simple to pass it on to the shader. We already got position, texture coordinates and normal bound at locations 0, 1 and 2, respectively. Therefore, we configure our VAO to bind the bone IDs at location 3 and the weights at location 4. It is very important to note that we use glVertexAttribIPointer rather than glVertexAttribPointer to bind the IDs. The reason is that the IDs are integer and not floating point. Pay attention to this or you will get corrupted data in the shader.
翻译:
上面的结构包含了我们在顶点层需要的所有东西。默认情况下,我们有足够的存储空间存放4根骨头(每根骨头的ID和重量)。VertexBoneData的结构是这样的,以便将它简单地传递给着色器。我们已经分别在位置0、1和2获得了位置、纹理坐标和法线边界。因此,我们将VAO配置为绑定位置3的骨骼id和位置4的权重。需要注意的是,我们使用glVertexAttribIPointer而不是glVertexAttribPointer来绑定id。原因是id是整数而不是浮点数。注意这一点,否则你会在着色器中得到损坏的数据。
The function above loads the vertex bone information for a single aiMesh object. It is called from Mesh::InitMesh(). In addition to populating the VertexBoneData structure this function also updates a map between bone names and bone IDs (a running index managed by this function) and stores the offset matrix in a vector based on the bone ID. Note how the vertex ID is calculated. Since vertex IDs are relevant to a single mesh and we store all meshes in a single vector we add the base vertex ID of the current aiMesh to vertex ID from the mWeights array to get the absolute vertex ID.
翻译:
上面的函数加载单个aiMesh对象的顶点骨骼信息。它从Mesh::InitMesh()中调用。除了填充VertexBoneData结构之外,该函数还更新骨骼名称和骨骼ID之间的映射(由该函数管理的运行索引),并将偏移矩阵存储在基于骨骼ID的向量中。注意顶点ID是如何计算的。由于顶点ID与单个网格相关,我们将所有网格存储在一个矢量中,我们将当前aiMesh的基本顶点ID添加到来自mWeights数组的顶点ID中,以获得绝对顶点ID。
This utility function finds a free slot in the VertexBoneData structure and places the bone ID and weight in it. Some vertices will be influenced by less than four bones but since the weight of a non existing bone remains zero (see the constructor of VertexBoneData) it means that we can use the same weight calculation for any number of bones.
翻译:
这个实用函数在VertexBoneData结构中找到一个空闲槽,并在其中放置骨ID和权重。一些顶点将受到少于4块骨头的影响,但由于不存在骨头的权重仍然为零(参见VertexBoneData的构造函数),这意味着我们可以对任意数量的骨头使用相同的权重计算。
Loading of the bone information at the vertex level that we saw earlier is done only once when the mesh is loading during startup. Now we come to the second part which is calculating the bone transformations that go into the shader every frame. The function above is the entry point to this activity. The caller reports the current time in seconds (which can be a fraction) and provides a vector of matrices which we must update. We find the relative time inside the animation cycle and process the node heirarchy. The result is an array of transformations which is returned to the caller.
翻译:
我们之前看到的在顶点层加载骨骼信息只在网格启动时加载一次。现在我们进入第二部分,计算每一帧进入着色器的骨骼变换。上面的函数是此活动的入口点。调用者以秒为单位报告当前时间(可以是小数),并提供一个必须更新的矩阵向量。我们找到动画周期内的相对时间并对节点层次结构进行处理。结果是返回给调用者的一个转换数组。
This function traverses the node tree and generates the final transformation for each node/bone according to the specified animation time. It is limited in the sense that it assumes that the mesh has only a single animation sequence. If you want to support multiple animations you will need to tell it the animation name and search for it in the m_pScene->mAnimations[] array. The code above is good enough for the demo mesh that we use.
The node transformation is initialized from the mTransformation member in the node. If the node does not correspond to a bone then that is its final transformation. If it does we overwrite it with a matrix that we generate. This is done as follows: first we search for the node name in the channel array of the animation. Then we interpolate the scaling vector, rotation quaternion and translation vector based on the animation time. We combine them into a single matrix and multiply with the matrix we got as a parameter (named GlobablTransformation). This function is recursive and is called for the root node with the GlobalTransformation param being the identity matrix. Each node recursively calls this function for all of its children and passes its own transformation as GlobalTransformation. Since we start at the top and work our way down, we get the combined transformation chain at every node.
The m_BoneMapping array maps a node name to the index that we generate and we use that index to as an entry into the m_BoneInfo array where the final transformations are stored. The final transformation is calculated as follows: we start with the node offset matrix which brings the vertices from their local space position into their node space. We then multiple with the combined transformations of all of the nodes parents plus the specific transformation that we calculated for the node according to the animation time.
Note that we use Assimp code here to handle the math stuff. I saw no point in duplicating it into our own code base so I simply used Assimp.
翻译:
该函数遍历节点树,并根据指定的动画时间为每个节点/骨生成最终的转换。它的局限性在于它假设网格只有一个单一的动画序列。如果你想支持多个动画,你需要告诉它动画名称,并在m_pScene->mAnimations[]数组中搜索它。上面的代码对于我们使用的演示网格来说已经足够好了。
节点转换是从节点中的mTransformation成员初始化的。如果节点不对应于骨头,那么这就是它的最终转换。如果有,我们就用生成的矩阵覆盖它。步骤如下:首先,我们在动画的通道数组中搜索节点名。然后根据动画时间插值缩放向量、旋转四元数和平移向量。我们将它们合并成一个矩阵,并与我们得到的作为参数的矩阵相乘(命名为GlobablTransformation)。这个函数是递归的,在根节点上调用,全局变换参数是单位矩阵。每个节点递归地为其所有子节点调用此函数,并将其自己的转换作为GlobalTransformation传递。因为我们从顶部开始,然后往下,我们得到了每个节点的组合转换链。
m_BoneMapping数组将节点名映射到我们生成的索引,我们使用该索引作为m_BoneInfo数组的一个条目,最终的转换存储在那里。最后的变换计算如下:我们从节点偏移矩阵开始,该矩阵将顶点从它们的局部空间位置带入它们的节点空间。然后,我们将所有父节点的合并转换加上根据动画时间为节点计算的特定转换相乘。
注意,我们在这里使用Assimp代码来处理数学问题。我觉得没有必要把它复制到我们自己的代码库中,所以我只使用Assimp。
This method interpolates the rotation quaternion of the specified channel based on the animation time (remember that the channel contains an array of key quaternions). First we find the index of the key quaternion which is just before the required animation time. We calculate the ratio between the distance from the animation time to the key before it and the distance between that key and the next. We need to interpolate between these two keys using that factor. We use an Assimp code to do the interpolation and normalize the result. The corresponding methods for position and scaling are very similar so they are not quoted here.
翻译:
这个方法根据动画时间插值指定通道的旋转四元数(记住通道包含一个关键四元数数组)。首先,我们找到关键四元数的索引,它刚好在所需的动画时间之前。我们计算动画时间与前一个键之间的距离以及该键与下一个键之间的距离的比值。我们需要用这个因子在这两个键之间进行插值。我们使用Assimp代码来进行插值并规范化结果。相应的位置和缩放方法非常相似,所以这里不作引用。
This utility method finds the key rotation which is immediately before the animation time. If we have N key rotations the result can be 0 to N-2. The animation time is always contained inside the duration of the channel so the last key (N-1) can never be a valid result.
翻译:
这个工具方法在动画时间之前找到关键旋转。如果我们有N个键旋转,结果可以是0到N-2。动画时间总是包含在通道的持续时间内,所以最后一个键(N-1)永远不会是一个有效的结果。
(skinning.vs)
Now that we have finished with the changes in the mesh class let's see what we need to do at the shader level. First, we've added the bone IDs and weights array to the VSInput structure. Next, there is a new uniform array that contains the bone transformations. In the shader itself we calculate the final bone transformation as a combination of the bone transformation matrices of the vertex and their weights. This final matrix is used to transform the position and normal from their bone space into the local space. From here on everything is the same.
翻译:
现在我们已经完成了网格类的更改,让我们看看我们需要在着色器级别做什么。首先,我们将骨骼id和权重数组添加到VSInput结构中。接下来,有一个新的统一数组,它包含骨转换。在着色器中,我们将顶点的骨变换矩阵和它们的权重结合起来计算最终的骨变换。这个最终的矩阵被用来转换位置和法线从他们的骨空间到局部空间。从现在起一切都是一样的。
(tutorial38.cpp:140)
The last thing we need to do is to integrate all this stuff into the application code. This is done in the above simple code. The function GetCurrentTimeMillis() returns the time in milliseconds since the application startup (note the floating point to accomodate fractions).
If you've done everything correctly then the final result should look similar to this.
翻译:
我们需要做的最后一件事是将所有这些东西集成到应用程序代码中。这在上面的简单代码中完成。GetCurrentTimeMillis()函数以毫秒为单位返回自应用程序启动以来的时间(注意浮点数以适应分数)。
如果你做的一切都是正确的,那么最终的结果应该看起来像这样。
下一个教程