Chapter 11: Optimizing the Animation Pipeline

本章主要是优化之前写的动画相关代码,优化的几个思路如下:

  • 用更好的方法来实现skinning
  • 更高效的Sample Animation Clips
  • 回顾生成matrix palette的方式

具体分为以下几个内容:

  • Skin matrix的预处理
  • 把skin pallete存到texture里
  • 更快的Sampling
  • The Pose palette generation
  • 探讨Pose::GetGlobalTransform函数

优化一:Skin matrix的预处理

这一节可以把uniform占用的槽位数减半

前面的gpu蒙皮里的vs里有这么几行内容:

in vec4 weights;// 额外的顶点属性1
in ivec4 joints;// 额外的顶点属性2// 两个Uniform数组
uniform mat4 pose[120]; // 代表parent joint的world trans
uniform mat4 invBindPose[120];//代表转换到parent joint的local space的offset矩阵

因为顶点属性里传入了顶点受影响的joints的id,而uniform数据是顶点之间共享的,但是每个顶点各自使用的id又不同,所以这里把整个数组都传进来了,这里应该是有120个Joints会影响顶点,也就是mat4类型的uniform一共有240个,而实际上一个mat4的uniform会占据4个uniform的槽位,所以这就是960个uniform slots,会造成很大的消耗。

仔细观察下面计算出的skin矩阵:

mat m0 = pose[joints.x] * invBindPose[joints.x] * weights.x;
mat m1 = pose[joints.y] * invBindPose[joints.y] * weights.y;
mat m2 = pose[joints.z] * invBindPose[joints.z] * weights.z;
mat m3 = pose[joints.w] * invBindPose[joints.w] * weights.w;
mat4 pallete = m0 + m1 + m2 + m3;

这里一个顶点确实会受到四个矩阵影响,这个是没办法处理的,如果要移到CPU这里就变成了CPU Skinning了,但是这里的pose和invBindPose俩矩阵的相乘,其内部都是一个joint的id,所以这块代码是可以放到CPU计算的,那么我可以在CPU里算出一个矩阵数组,这个数组size为120,第i个元素为pose[i]*invBindPose[i]。

这样就可以把原本的960个uniform slots减半,变为480个uniform slots,其实是把GPU的一部分计算负担交给了CPU,但是这样感觉计算分配更合理一些。

对于每个Joint,其WorldTrans乘以其invBindPose的矩阵的结果,这个矩阵,书里把它叫skin 矩阵,所以说skin矩阵跟之前提到的四个矩阵融合得到的matrix palette还不一样。

void Sample::Update(float deltaTime)
{// Sample函数会把outPose存在mAnimatedPose里, 输入的时间是真实时间// 返回的时间是处理后的时间, 比如取过模mPlaybackTime = mAnimClip.Sample(mAnimatedPose, mPlaybackTime + deltaTime);// 此函数会返回globalTrans的mat数组, 存在mPosePalette里mAnimatedPose.GetMatrixPalette(mPosePalette);// 对mPosePalette矩阵数组进行修改, 使其变成由skin矩阵组成的数组vector<mat4>& invBindPose = mSkeleton.GetInvBindPose();for (int i = 0; i < mPosePalette.size(); ++i) {mPosePalette[i] = mPosePalette[i] * invBindPose[i];}// If the mesh is CPU skinned, this is a good place to call the CPUSkin function.// This function needs to be re-implemented to work with a combined skin matrix. Iif (mDoCPUSkinning) mMesh.CPUSkin(mPosePalette);// 如果想用GPU Skinning, 把前面的vs小改一下即可, 然后传uniform的代码也改一下, 就不多说了
}

使用预先计算的Skin矩阵数组实现第三种CPU Skin函数

可以先来看看老的CPU Skin函数,有俩版本:

#if 1
// pose应该是动起来的人物的pose
void Mesh::CPUSkin(const Skeleton& skeleton, const Pose& pose)
{unsigned int numVerts = (unsigned int)mPosition.size();if (numVerts == 0)return;// 设置sizemSkinnedPosition.resize(numVerts);mSkinnedNormal.resize(numVerts);// 这个函数会获取Pose里的每个Joint的WorldTransform, 存到mPosePalette这个mat4组成的vector数组里pose.GetMatrixPalette(mPosePalette);// 获取bindPose的数据std::vector<mat4> invPosePalette = skeleton.GetInvBindPose();// 遍历每个顶点for (unsigned int i = 0; i < numVerts; ++i){ivec4& j = mInfluences[i];// 点受影响的四块Bone的idvec4& w = mWeights[i];// 矩阵应该从右往左看, 先乘以invPosePalette, 转换到Bone的LocalSpace// 再乘以Pose对应Joint的WorldTransformmat4 m0 = (mPosePalette[j.x] * invPosePalette[j.x]) * w.x;mat4 m1 = (mPosePalette[j.y] * invPosePalette[j.y]) * w.y;mat4 m2 = (mPosePalette[j.z] * invPosePalette[j.z]) * w.z;mat4 m3 = (mPosePalette[j.w] * invPosePalette[j.w]) * w.w;mat4 skin = m0 + m1 + m2 + m3;// 计算最终矩阵对Point和Normal的影响mSkinnedPosition[i] = transformPoint(skin, mPosition[i]);mSkinnedNormal[i] = transformVector(skin, mNormal[i]);}// 同步GPU端数据mPosAttrib->Set(mSkinnedPosition);mNormAttrib->Set(mSkinnedNormal);
}#else
// 俩input, Pose应该是此刻动画的Pose, 俩应该是const&把
void Mesh::CPUSkin(const Skeleton& skeleton, const Pose& pose)
{// 前面的部分没变unsigned int numVerts = (unsigned int)mPosition.size();if (numVerts == 0)return;// 设置size, 目的是填充mSkinnedPosition和mSkinnedNormal数组mSkinnedPosition.resize(numVerts);mSkinnedNormal.resize(numVerts);// 之前这里是获取输入的Pose的WorldTrans的矩阵数组和BindPose里的InverseTrans矩阵数组// 但这里直接获取BindPose就停了const Pose& bindPose = skeleton.GetBindPose();// 同样遍历每个顶点for (unsigned int i = 0; i < numVerts; ++i){ivec4& joint = mInfluences[i];vec4& weight = mWeights[i];// 之前是矩阵取Combine, 现在是算出来的点和向量, 再最后取Combine// 虽然Pose里Joint存的都是LocalTrans, 但是重载的[]运算符会返回GlobalTransTransform skin0 = combine(pose[joint.x], inverse(bindPose[joint.x]));vec3 p0 = transformPoint(skin0, mPosition[i]);vec3 n0 = transformVector(skin0, mNormal[i]);Transform skin1 = combine(pose[joint.y], inverse(bindPose[joint.y]));vec3 p1 = transformPoint(skin1, mPosition[i]);vec3 n1 = transformVector(skin1, mNormal[i]);Transform skin2 = combine(pose[joint.z], inverse(bindPose[joint.z]));vec3 p2 = transformPoint(skin2, mPosition[i]);vec3 n2 = transformVector(skin2, mNormal[i]);Transform skin3 = combine(pose[joint.w], inverse(bindPose[joint.w]));vec3 p3 = transformPoint(skin3, mPosition[i]);vec3 n3 = transformVector(skin3, mNormal[i]);mSkinnedPosition[i] = p0 * weight.x + p1 * weight.y + p2 * weight.z + p3 * weight.w;mSkinnedNormal[i] = n0 * weight.x + n1 * weight.y + n2 * weight.z + n3 * weight.w;}mPosAttrib->Set(mSkinnedPosition);mNormAttrib->Set(mSkinnedNormal);
}#endif

第三种方法其实很简单,就是把如下图所示的这一块提前算出来,存到数组里而已:

这里的mPosePalette是动态的Pose提取出来Joint的WorldTransform的矩阵数组,反正还是要不断更新的,代码如下:

void Mesh::CPUSkin(std::vector<mat4>& animatedPose)
{unsigned int numVerts = (unsigned int)mPosition.size();if (numVerts == 0) { return; }mSkinnedPosition.resize(numVerts);mSkinnedNormal.resize(numVerts);for (unsigned int i = 0; i < numVerts; ++i) {ivec4& j = mInfluences[i];vec4& w = mWeights[i];vec3 p0 = transformPoint(animatedPose[j.x], mPosition[i]);vec3 p1 = transformPoint(animatedPose[j.y], mPosition[i]);vec3 p2 = transformPoint(animatedPose[j.z], mPosition[i]);vec3 p3 = transformPoint(animatedPose[j.w], mPosition[i]);mSkinnedPosition[i] = p0 * w.x + p1 * w.y + p2 * w.z + p3 * w.w;vec3 n0 = transformVector(animatedPose[j.x], mNormal[i]);vec3 n1 = transformVector(animatedPose[j.y], mNormal[i]);vec3 n2 = transformVector(animatedPose[j.z], mNormal[i]);vec3 n3 = transformVector(animatedPose[j.w], mNormal[i]);mSkinnedNormal[i] = n0 * w.x + n1 * w.y + n2 * w.z + n3 * w.w;}mPosAttrib->Set(mSkinnedPosition);mNormAttrib->Set(mSkinnedNormal);
}

三种方法其实大同小异,结果是一样的,效率也差不多,分别是:

  • 算出带权重的融合矩阵,也就是最终四个Joint的融合影响矩阵,然后乘以position和normal
  • 算出各自单独的矩阵,算出四个position和normal,然后各自乘以权重累加得到结果
  • 算出各个Joint的单独Skin矩阵,然后算出四个position和normal,最后各自乘以权重累加得到结果,其实跟方法二很像

这么个原理写了三种函数,感觉作者在整花活。。。。

改变GPU skinning适配优化一的方案

还是这种方法,把Pose的每个Joint的WorldTransform和InversePosePalette预先乘起来,在这种情况下的VS应该怎么写。

之前是这么写的:

#version 330 core
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;in vec3 position;
in vec3 normal;
in vec2 texCoord;
in vec4 weights;// 额外的顶点属性1
in ivec4 joints;// 额外的顶点属性2// 两个Uniform数组
uniform mat4 pose[120]; // 代表parent joint的world trans
uniform mat4 invBindPose[120];//代表转换到parent joint的local space的offset矩阵// 这里是重点,对于Skinned Mesh,其ModelSpace下的顶点坐标和法向量都需要重新计算
// 因为这个Mesh变化了
out vec4 newModelPos;
out vec3 newNorm;
out vec2 uv;                // 注意,uv是不需要变化的(为啥?)void main()
{mat m0 = pose[joints.x] * invBindPose[joints.x] * weights.x;mat m1 = pose[joints.y] * invBindPose[joints.y] * weights.y;mat m2 = pose[joints.z] * invBindPose[joints.z] * weights.z;mat m3 = pose[joints.w] * invBindPose[joints.w] * weights.w;mat4 pallete = m0 + m1 + m2 + m3;gl_Position = projection * view * model * pallete * position;// 算出屏幕上的样子// 计算真实的ModelSpace下的坐标,供后续进行着色和光照计算newModelPos =  (model * pallete * vec4(position, 1.0f)).xyz;newNorm = (model * pallete * vec4(normal, 0.0f)).xyz;// 注意,顶点即使进行了Deformation,但它对应贴图的TexCoord是不改变的uv =  texCoord;}

改成这样就行了,很简单:

// 文件从skinned.vert改名为preskinned.vert
#version 330 core
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;in vec3 position;
in vec3 normal;
in vec2 texCoord;
in vec4 weights;// 额外的顶点属性1
in ivec4 joints;// 额外的顶点属性2// 两个Uniform数组
uniform mat4 animatedCombinedPose[120]; // 代表parent joint的world trans// 这里是重点,对于Skinned Mesh,其ModelSpace下的顶点坐标和法向量都需要重新计算
// 因为这个Mesh变化了
out vec4 newModelPos;
out vec3 newNorm;
out vec2 uv;                // 注意,uv是不需要变化的(为啥?)void main()
{mat m0 = animatedCombinedPose[joints.x] * weights.x;mat m1 = animatedCombinedPose[joints.y] * weights.y;mat m2 = animatedCombinedPose[joints.z] * weights.z;mat m3 = animatedCombinedPose[joints.w] * weights.w;mat4 pallete = m0 + m1 + m2 + m3;gl_Position = projection * view * model * pallete * position;// 算出屏幕上的样子// 计算真实的ModelSpace下的坐标,供后续进行着色和光照计算newModelPos =  (model * pallete * vec4(position, 1.0f)).xyz;newNorm = (model * pallete * vec4(normal, 0.0f)).xyz;// 注意,顶点即使进行了Deformation,但它对应贴图的TexCoord是不改变的uv =  texCoord;}

然后GPU方面设置uniform的opengl代码改一下:

// 现在是
// mPosePalette Generated in the Update method!
int animated = mSkinnedShader- >GetUniform("animated")
Uniform<mat4>::Set(animated, mPosePalette);

优化二:Storing the skin palette in a texture

这一节可以把uniform占用的槽位数变为1,其实就是用texture存储矩阵信息,只是介绍了思路,具体的实现后面章节会再提。

前面翻来覆去都是一些小把戏,这节感觉应该挺重要,看名字是把skin矩阵存到贴图里,我理解的应该是把上面这个动态的animatedCombinedPose,对应的mat4矩阵,用texture的方式用一个uniform通道传给vs,下面是具体的内容。

这种方法能把前面的480个uniform slots减少到一个,就是把相关信息存到Texture中,目前书里只提到了RGB24和RGBA32,这种贴图,每个分量都是8个bit,一共是256个值,这种贴图的精度是无法保存浮点数的。

而我们要用的矩阵里都是存的浮点数,所以这里需要用到一个特殊的,格式为FLOAT32的texture,FLOAT32的意思应该是,这种贴图的格式下,每个像素里的数据有32个bit,它表示的是一个浮点数。

这里的FLOAT32的贴图,可以认为是一个buffer,CPU可以对其进行写入,GPU可以从它读取数据。

the number of required uniform slots becomes just one—the uniform slot that is needed is the sampler for the FLOAT32 texture

这里用贴图的方式减少了Uniform的槽位个数,代价是降低了蒙皮算法的运行速度,对于每个Vertex来说,它都需要去Sample Texture,获取上面提到的四个矩阵,每个矩阵还不止Sample一次,因为一次只能返回一个float,这种方法比直接从uniform数组里获取矩阵数值要慢。

这里只是提出方法,具体的实现要放到第15章——Render Large Crowds with Instancing里。

优化三:Sample函数优化

Sample函数的回顾

可以看看目前的Sample函数,Sample函数由Clip类的成员函数提供,输入一个Input Time,返回一个Pose和矫正过的PlayTime:

// 这里的Sample函数还对输入的Pose有要求, 因为Clip里的Track如果没有涉及到每个Component的
// 动画, 则会按照输入Pose的值来播放, 所以感觉outPose输入的时候要为(rest Pose(T-Pose or A-Pose))
float Clip::Sample(Pose& outPose, float time)
{if (GetDuration() == 0.0f)return 0.0f;time = AdjustTimeToFitRange(time);// 调用Clip自己实现的函数unsigned int size = mTracks.size();for (unsigned int i = 0; i < size; ++i){unsigned int joint = mTracks[i].GetId();Transform local = outPose.GetLocalTransform(joint);// 本质是调用Track的Sample函数Transform animated = mTracks[i].Sample(local, time, mLooping);outPose.SetLocalTransform(joint, animated);}return time;
}

这里Clip的Sample函数,实际上会遍历每个Clip里的Track(相当于Property Curve),然后调用Track的Sample函数,输入的是Rest Pose的默认值,返回新的Transform值

// 各个Track的Sample, 如果有Track的话
// 由于不是所有的动画都有相同的Property对应的track, 比如说有的只有position, 没有rotation和scale
// 在Sample动画A时,如果要换为Sample动画B,要记得重置人物的pose
Transform TransformTrack::Sample(const Transform& ref, float time, bool looping)
{// 每次Sample来播放动画时, 都要记录好这个result数据Transform result = ref; // Assign default values// 这样的ref, 代表原本角色的Transform, 这样即使对应的Track没动画数据, 也没关系if (mPosition.Size() > 1){ // Only assign if animatedresult.position = mPosition.Sample(time, looping);}if (mRotation.Size() > 1){ // Only assign if animatedresult.rotation = mRotation.Sample(time, looping);}if (mScale.Size() > 1){ // Only assign if animatedresult.scale = mScale.Sample(time, looping);}return result;
}

最后,其实Sample函数又细分到了具体的Track的Sample函数上,如下所示:

// Sample的时候根据插值类型来
template<typename T, int N>
T Track<T, N>::Sample(float time, bool looping)
{if (mInterpolation == Interpolation::Constant)return SampleConstant(time, looping);else if (mInterpolation == Interpolation::Linear)return SampleLinear(time, looping);return SampleCubic(time, looping);
}template<typename T, int N>
T Track<T, N>::SampleConstant(float time, bool looping)
{// 获取时间对应的帧数, 取整int frame = FrameIndex(time, looping);if (frame < 0 || frame >= (int)mFrames.size())return T();// Constant曲线不需要插值, mFrames里应该只有关键帧的frame数据return Cast(&mFrames[frame].mValue[0]);// 为啥要转型? 因为mValue是float*类型的数组, 这里的操作是取从数组地址开始, Cast为T类型
}

Sample函数优化

只要当前播放的动画Clip的时长小于1s,那么它就很合适在现在的动画系统里播放。但是对于CutScene这种有多个时长很长的动画Clip同时播放的应用场景来说,就不太合适了,此时性能会比较差。

至于为什么现在的代码不适合播放时长较长的动画呢?原因出在下面的FrameIndex函数上,这个函数会逐帧遍历,寻找输入的time所在的区间,所以很耗时间:

// 根据时间获取对应的帧数, 其实是返回其左边的关键帧
// 注意这里的frames应该是按照关键帧来存的, 比如有frames里有三个元素, 可能分别对应的时间为
// 0, 4, 10, 那么我input time为5时, 返回的index为1, 代表从第二帧开始
// 这个函数返回值保证会在[0, size - 2]区间内
template<typename T, int N>
int Track<T, N>::FrameIndex(float time, bool looping)
{unsigned int size = (unsigned int)mFrames.size();if (size <= 1)return -1;if (looping){float startTime = mFrames[0].mTime;float endTime = mFrames[size - 1].mTime;float duration = endTime - startTime;time = fmodf(time - startTime, endTime - startTime);if (time < 0.0f)time += endTime - startTime;time = time + startTime;}else{if (time <= mFrames[0].mTime)return 0;// 注意, 只要大于倒数第二帧的时间, 就返回其帧数// 也就是说, 这个函数返回值在[0, size - 2]区间内if (time >= mFrames[size - 2].mTime)return (int)size - 2;}// 就是在这, 造成了性能的draggingfor (int i = (int)size - 1; i >= 0; --i){if (time >= mFrames[i].mTime)return i;}return -1;
}

这里的线性查找并不合理,既然mFrames数组里的mTime是递增的,那么可以用binary search,不过二分法也不是最好的,它毕竟还要logn呢。这里有一个O1的方法,由于动画一般Sample是有固定的Sample Rate的,那么比如一秒有30帧,那么这30帧的时间是固定的,那么我可以预先把它们对应的前面的关键字的index记录下来,存起来,那么动画播放的时候就不必再去查找了。

代码如下,其实是创建了一个继承于Track的子类,给它加了些东西(其实也可创建一个Wrapper,把Track包起来):

template<typename T, int N>
class FastTrack : public Track<T, N>
{protected:// 用这玩意儿计算对应SampleRate的时间节点对应的左边Frame的Idstd::vector<unsigned int> mSampledFrames;virtual int FrameIndex(float time, bool looping);// 这里要把原本的Track类的这个函数改为虚函数// 没看到SampleRate啊? Track里也没有这个变量// 看了下面后面的代码, 这里默认SampleRate就是一秒60帧了
public://This function needs to sample the animation at fixed time intervals and // record the frame before the animation time for each interval.void UpdateIndexLookupTable();
};// 创建三个帮助使用的typedef
typedef FastTrack<float, 1> FastScalarTrack;// 类似与一个float对象的PropertyCurve
typedef FastTrack<vec3, 3> FastVectorTrack;
typedef FastTrack<quat, 4> FastQuaternionTrack;// 一个全局的模板函数, 用于把Track优化为FastTrack类
template<typename T, int N>
FastTrack<T, N> OptimizeTrack(Track<T, N>& input);

对应的CPP文件如下:

// 基本之前没有见过这种写法, 注意, 这里不是模板特化, 而是让编译器生成这几个参数的对应函数而已
// 跟下面这种写法不一样(见附录)
// template<>
// FastTrack<float, 1> OptimizeTrack(Track<float, 1>& input);
template FastTrack<float, 1> OptimizeTrack(Track<float, 1>& input);
template FastTrack<vec3, 3> OptimizeTrack(Track<vec3, 3>& input);
template FastTrack<quat, 4> OptimizeTrack(Track<quat, 4>& input);// 输入Track, 返回FastTrack, 设计这个函数主要也是为了不改动原本的代码吧
template<typename T, int N>
FastTrack<T, N> OptimizeTrack(Track<T, N>& input)
{FastTrack<T, N> result;// 1. 先复制原始数据 // 1.1 Copy插值类型result.SetInterpolation(input.GetInterpolation());// 1.2 Copy关键帧数组// Track里有一个关键帧的数组unsigned int size = input.Size();result.Resize(size);// Track类的下标运算符重载为返回第i个关键帧对象for (unsigned int i = 0; i < size; ++i) result[i] = input[i];// 2. 基于复制过来的Track数据, 计算时间点对应的前面的关键帧的idresult.UpdateIndexLookupTable();return result;
}// 核心函数
template<typename T, int N>
void FastTrack<T, N>::UpdateIndexLookupTable()
{// 检查关键帧数据int numFrames = (int)this->mFrames.size();if (numFrames <= 1)return;// 获取Track关键帧的时长(秒数)float duration = this->GetEndTime() - this->GetStartTime();// 这段在Github上的代码加了个60的offset, 不太清楚是为了啥, 这里就不加了unsigned int numSamples = /*60 + */(unsigned int)(duration * 60.0f);mSampledFrames.resize(numSamples);// 按每秒60帧来遍历所有的帧for (unsigned int i = 0; i < numSamples; ++i) {    // 根据帧数算出对应的时间float t = (float)i / (float)(numSamples - 1);float time = t * duration + this->GetStartTime();// 还是倒着遍历, 寻找对应时间的左边的关键帧IDunsigned int frameIndex = 0;for (int j = numFrames - 1; j >= 0; --j) {// 这个函数其实可以二分查找, 但也没太大必要if (time >= this->mFrames[j].mTime) {frameIndex = (unsigned int)j;if ((int)frameIndex >= numFrames - 2)frameIndex = numFrames - 2;break;}}// 这个FastTrack其实也就是比Track对象多了个mSampleFrames数组(是一个int数组)mSampledFrames[i] = frameIndex;}
}// 虚函数重载
template<typename T, int N>
int FastTrack<T, N>::FrameIndex(float time, bool looping) override
{std::vector<Frame<N>>& frames = this->mFrames;unsigned int size = (unsigned int)frames.size();if (size <= 1)return -1;if (looping) {float startTime = frames[0].mTime;float endTime = frames[size - 1].mTime;float duration = endTime - startTime;time = fmodf(time - startTime, endTime - startTime);if (time < 0.0f)time += endTime - startTime;time = time + startTime;}else {if (time <= frames[0].mTime)return 0;if (time >= frames[size - 2].mTime)return (int)size - 2;}// 区别就在这里float duration = this->GetEndTime() - this->GetStartTime();unsigned int numSamples = /* 60 + */(unsigned int)(duration * 60.0f);float t = time / duration;unsigned int index = (unsigned int)(t * (float)numSamples);if (index >= mSampledFrames.size())return -1;return (int)mSampledFrames[index];
}

所以说,这里的重点其实就是预处理,把动画按照SampleRate进行分段,然后存储一个int数组作为lookup,这样我任何一个时间输入进来,都能快速定位到它位于哪些关键帧之间

调整原本的TransformTrack

这里为Track创建了子类FastTrack,Track对应的是一个Property的关键帧数据,别忘了之前为了方便,还写过一个TransformTrack,也就是三个PropertyCurve的集合,内部数据是这样

class TransformTrack
{protected:unsigned int mId;// 对应Bone的Id// 这些玩意儿其实就是TrackVectorTrack mPosition;      // typedef Track<vec3, 3> VectorTrack;QuaternionTrack mRotation;  // typedef Track<quat, 4> QuaternionTrack;VectorTrack mScale;typedef Track<quat, 4> QuaternionTrack;
public:Transform Sample(const Transform& ref, float time, bool looping);...
};

为了使用新的FastTrack,需要修改这个类的代码,由于Track和FastTrack的接口是相同的,所以目的是把这个TransformTrack类改成类模板(其实用虚函数也还行吧),新的类声明如下所示:

#ifndef _H_TRANSFORMTRACK_
#define _H_TRANSFORMTRACK_#include "Track.h"
#include "Transform.h"// 原本的Track用现在的模板表示
template <typename VTRACK, typename QTRACK>
class TTransformTrack
{protected:unsigned int mId; // 这条TransformTrack数据对应的Joint的idVTRACK mPosition;   // Position和Scale共享一个Track类型QTRACK mRotation;VTRACK mScale;
public:TTransformTrack();unsigned int GetId();void SetId(unsigned int id);VTRACK& GetPositionTrack();QTRACK& GetRotationTrack();VTRACK& GetScaleTrack();float GetStartTime();float GetEndTime();bool IsValid();Transform Sample(const Transform& ref, float time, bool looping);
};// 然后加这俩typedef
typedef TTransformTrack<VectorTrack, QuaternionTrack> TransformTrack;
typedef TTransformTrack<FastVectorTrack, FastQuaternionTrack> FastTransformTrack;// 额外声明了一个全局函数, 由于把TransformTrack改为FastTransformTrack, 其实就是把里面的三个Track都改成FastTrack
FastTransformTrack OptimizeTransformTrack(TransformTrack& input);#endif

相关类实现代码如下:

#include "TransformTrack.h"// 防止编译错误做的Template Instantiation
template TTransformTrack<VectorTrack, QuaternionTrack>;
template TTransformTrack<FastVectorTrack, FastQuaternionTrack>;// 一些很普通的接口, mId是TransformTrack对应的joint的id
template <typename VTRACK, typename QTRACK>
TTransformTrack<VTRACK, QTRACK>::TTransformTrack()
{mId = 0;
}template <typename VTRACK, typename QTRACK>
unsigned int TTransformTrack<VTRACK, QTRACK>::GetId()
{return mId;
}template <typename VTRACK, typename QTRACK>
void TTransformTrack<VTRACK, QTRACK>::SetId(unsigned int id)
{mId = id;
}template <typename VTRACK, typename QTRACK>
VTRACK& TTransformTrack<VTRACK, QTRACK>::GetPositionTrack()
{return mPosition;
}template <typename VTRACK, typename QTRACK>
QTRACK& TTransformTrack<VTRACK, QTRACK>::GetRotationTrack()
{return mRotation;
}template <typename VTRACK, typename QTRACK>
VTRACK& TTransformTrack<VTRACK, QTRACK>::GetScaleTrack()
{return mScale;
}template <typename VTRACK, typename QTRACK>
bool TTransformTrack<VTRACK, QTRACK>::IsValid()
{return mPosition.Size() > 1 || mRotation.Size() > 1 || mScale.Size() > 1;
}// 基本没变
template <typename VTRACK, typename QTRACK>
float TTransformTrack<VTRACK, QTRACK>::GetStartTime()
{float result = 0.0f;bool isSet = false;if (mPosition.Size() > 1) {result = mPosition.GetStartTime();isSet = true;}if (mRotation.Size() > 1) {float rotationStart = mRotation.GetStartTime();if (rotationStart < result || !isSet) {result = rotationStart;isSet = true;}}if (mScale.Size() > 1) {float scaleStart = mScale.GetStartTime();if (scaleStart < result || !isSet) {result = scaleStart;isSet = true;}}return result;
}// 基本没变
template <typename VTRACK, typename QTRACK>
float TTransformTrack<VTRACK, QTRACK>::GetEndTime()
{float result = 0.0f;bool isSet = false;if (mPosition.Size() > 1) {result = mPosition.GetEndTime();isSet = true;}if (mRotation.Size() > 1) {float rotationEnd = mRotation.GetEndTime();if (rotationEnd > result || !isSet) {result = rotationEnd;isSet = true;}}if (mScale.Size() > 1) {float scaleEnd = mScale.GetEndTime();if (scaleEnd > result || !isSet) {result = scaleEnd;isSet = true;}}return result;
}// 基本没变, 就是从原来的成员函数变成了现在的模板成员函数
template <typename VTRACK, typename QTRACK>
Transform TTransformTrack<VTRACK, QTRACK>::Sample(const Transform& ref,   float time, bool looping)
{Transform result = ref; // Assign default values// Only assign if animatedif (mPosition.Size() > 1) result.position = mPosition.Sample(time, looping);// Only assign if animatedif (mRotation.Size() > 1) result.rotation = mRotation.Sample(time, looping);if (mScale.Size() > 1)// Only assign if animatedresult.scale = mScale.Sample(time, looping);return result;
}// 三个子Track各自转换
FastTransformTrack OptimizeTransformTrack(TransformTrack& input)
{FastTransformTrack result;result.SetId(input.GetId());// copies the actual track data by value, it can be a little slow.result.GetPositionTrack() = OptimizeTrack<vec3, 3>(input.GetPositionTrack());result.GetRotationTrack() = OptimizeTrack<quat, 4>(input.GetRotationTrack());result.GetScaleTrack() = OptimizeTrack<vec3, 3>(input.GetScaleTrack());return result;
}

修改Clip类以适配

这是原本的Clip类,核心数据既然是TransformTrack数组,那么自然也要进行修改:

// 原本的代码
class Clip
{protected:// 本质就是TransformTracksstd::vector<TransformTrack> mTracks;...
public:float Sample(Pose& outPose, float inTime);TransformTrack& operator[](unsigned int index);...
}

其实就是TransformTrack改成TTransformTrack模板,我预想的是改成这样:

template <typename T, typename N>
class Clip
{protected:// 本质就是TransformTracksstd::vector<TTransformTrack<T, N>> mTracks;...
public:float Sample(Pose& outPose, float inTime);TTransformTrack<T, N>& operator[](unsigned int index);...
}

看了下书里的代码,感觉自己写的还是复杂了:

// 为了兼容TransformTrack和FastTransformTrack,这里使用了模板, TRACK只是个名字而已
template <typename TRACK>
class TClip
{protected:// 本质就是TransformTracksstd::vector<TRACK> mTracks;...
public:float Sample(Pose& outPose, float inTime);TRACK& operator[](unsigned int index);...
}// 加了这俩typedef(其实目前只有第一个typedef), 就能让老的函数继续使用了, 比如
// std::vector<Clip> LoadAnimationClips(cgltf_data* data) 函数里用到了Clip
typedef TClip<TransformTrack> Clip;
typedef TClip<FastTransformTrack> FastClip;// 全局函数
FastClip OptimizeClip(Clip&input);// 同样为了保证编译正确
template TClip<TransformTrack>;
template TClip<FastTransformTrack>;

除了函数签名,具体的cpp要改的其实就是加个转换函数而已:

FastClip OptimizeClip(Clip& input)
{// 还是先Copy数据FastClip result;result.SetName(input.GetName());result.SetLooping(input.GetLooping());unsigned int size = input.Size();for (unsigned int i = 0; i < size; ++i) {unsigned int joint = input.GetIdAtIndex(i);// 在Clip的[]运算符重载里, 如果[id]找得到数据, 就直接返回其&// 如果没有该数据, 就new一个TransformTrack, 加到数组里, 返回其&result[joint] = OptimizeTransformTrack(input[joint]);}// 遍历所有的Joints的TransformTrack, 找到最早和最晚的关键帧的出现时间, 记录在mStartTime和mEndTime上result.RecalculateDuration();return result;
}

优化四:Pose类的成员函数GetMatrixPalette优化

这节属于算法层面的小优化

Pose里有这么一个函数,如下所示:

class Pose
{protected:// 本质数据就是两个vector, 一个代表Joints的hierarchy, 一个代表Joints的数据std::vector<Transform> mJoints;std::vector<int> mParents;
public:// palette是调色板的意思, 这个函数是为了把Pose数据改成OpenGL支持的数据格式// 由于OpenGL只接受linear array of matrices, 这里需要把Transform转换成矩阵// 这个函数会根据Pose的Transform数组, 转化为一个mat4的数组void GetMatrixPalette(std::vector<mat4>& out) const;...
}

具体实现代码如下:

// vector<Transform> globalTrans 转化为mat4数组
void Pose::GetMatrixPalette(std::vector<mat4>& out) const
{unsigned int size = Size();if (out.size() != size)out.resize(size);for (unsigned int i = 0; i < size; ++i){Transform t = GetGlobalTransform(i);// out[i] = transformToMat4(t);}
}// 计算特定Joint的GlobalTransform
Transform Pose::GetGlobalTransform(unsigned int index) const
{Transform result = mJoints[index];// 从底部往上Combine, 因为每个Joint的Parent Joint在vector里的id是可以获取到的for (int parent = mParents[index]; parent >= 0; parent = mParents[parent])// Combine这个函数是之前在mat4.cpp里实现的全局函数, 其实就是return matA*matBresult = combine(mJoints[parent], result);return result;
}

这里没必要的性能消耗在于,每次算一个Joint的GlobalTransform时,都要从最Root开始算,然后最后转化为Mat4,我的想法是,其实可以按照BFS算法,按照Pose的Hierarchy来遍历,直接用Parent的Mat4矩阵,右乘以自己的Transform转换来的Mat4矩阵即可。

书里的思路是,默认认为,Pose里的Joints是不按顺序排列的,但是Joints对应的Id都满足一个条件,也就是Parent的id要小于Childrenm的id,也就是说id是按照BFS顺序排列的。

基于这个规则,可以按序号从小到大的顺序重排Pose里的mJoints数组,这样就能保证计算每个Joint的Transform时,其parent的Transform矩阵已经计算好了,代码如下:

// 书里创建了一个RearrangeBones文件, 这是头文件
#ifndef _H_REARRANGEBONES_
#define _H_REARRANGEBONES_#include <map>
#include "Skeleton.h"
#include "Mesh.h"
#include "Clip.h"std::map<int, int> RearrangeSkeleton(Skeleton& skeleton);
void RearrangeMesh(Mesh& mesh, std::map<int, int>& boneMap);
void RearrangeClip(Clip& clip, std::map<int, int>& boneMap);
void RearrangeFastclip(FastClip& clip, std::map<int, int>& boneMap);#endif// cpp文件
#include "RearrangeBones.h"
#include <list>// 传入一个Skeleton, 里面有RestPose和BindPose, 对两个Pose里的Joints
// 以及Skeleton里的Names数组进行重新排序, 使其满足bfs的遍历要求, 这种遍历顺序下
// 计算每个节点的GlobalTransform信息时, 其Parent的GlobalTransform已经被预先计算好了
std::map<int, int> RearrangeSkeleton(Skeleton& outSkeleton)
{Pose& restPose = outSkeleton.GetRestPose();Pose& bindPose = outSkeleton.GetBindPose();// size是skeleton里的Joints的个数unsigned int size = restPose.Size();if (size == 0)return std::map<int, int>(); // 创建一个二维数组, 行数为joints的个数, 也就是每个joint对应一个int数组// 每个Joint对应的int数组会存它所有子节点的idstd::vector<std::vector<int>> hierarchy(size);std::list<int> process;// 遍历RestPose里的每个Jointfor (unsigned int i = 0; i < size; ++i) {int parent = restPose.GetParent(i);if (parent >= 0)hierarchy[parent].push_back((int)i);else process.push_back((int)i);//应该只有root节点会存到process对应的list链表里}// 本身每个Pose里会有一个joints数组, 这是老的数组// 然后在这个函数执行之后, 会得到一个新的joints排序后的数组// 所以这俩map就负责两个数组元素id间的映射// mapForward记录了每个新的数组元素在老数组里的位置// mapBackward记录了每个老的数组元素在新数组里的位置std::map<int, int> mapForward;std::map<int, int> mapBackward;// index表示遍历顺序int index = 0;// 遍历链表, 感觉类似于处理队列一样处理list, 先入先出// 这其实是一个bfs的遍历过程while (process.size() > 0) {// 取headint current = *process.begin();// 出headprocess.pop_front();// 获取当前节点对应的children的id列表std::vector<int>& children = hierarchy[current];// 遍历children, 加入list模拟的队列里unsigned int numChildren = (unsigned int)children.size();for (unsigned int i = 0; i < numChildren; ++i)process.push_back(children[i]);// mapForward记录了每个新的数组元素在老数组里的位置, index是遍历顺序, 其实也就是新数组的元素排列顺序// 其实是用这个mapForward记录了这个bfs的顺序, bfs遍历节点的顺序为: mapForward[0], mapForward[1]...// mJoints[mapForward[0]]是第一个遍历的JointmapForward[index] = current;// 作者在整花活, 一个简单的需求写这么复杂的代码...// mapBackward记录了每个老的数组元素在新数组里的位置// 反向存一个map, 可以知道任意一个节点在bfs遍历里遍历的顺序, 比如i号Joint, 会排在第mapBackward[i]个被遍历// 其实是记录一个mapping关系, 第i号joint会变成新joints数组的第(mapBackward[i])个对象mapBackward[current] = index;index += 1;}mapForward[-1] = -1;mapBackward[-1] = -1;// 创建两个新PosePose newRestPose(size);Pose newBindPose(size);std::vector<std::string> newNames(size);// 按照bfs遍历的顺序, 遍历Skeleton里的RestPose和BindPose里的Joints// 存到新的俩Pose里, 也就是说新的Pose相当于老的Pose按照BFS的顺序重排for (unsigned int i = 0; i < size; ++i) {// Copy Joint数据// 1. Copy Transformint thisBone = mapForward[i];newRestPose.SetLocalTransform(i, restPose.GetLocalTransform(thisBone));newBindPose.SetLocalTransform(i, bindPose.GetLocalTransform(thisBone));// 2. Copy NamenewNames[i] = outSkeleton.GetJointName(thisBone);// 3. Copy Parent Idint parent = mapBackward[bindPose.GetParent(thisBone)];newRestPose.SetParent(i, parent);newBindPose.SetParent(i, parent);}outSkeleton.Set(newRestPose, newBindPose, newNames);return mapBackward;
}// boneMap是RearrangeSkeleton函数返回的map<int, int>
// key是joint在原本的joints数组里的id, value是joint在新的Joints的数组里的id, 也其实就是遍历顺序
// 既然Skeleton里的俩Pose的Joints的顺序都改了, 这里Clip里的TransformTrack对应的
// Joint的id也应该换成新的
void RearrangeClip(Clip& outClip, std::map<int, int>& boneMap)
{// Clip里的数据就是一个数组mTracks, 元素是TransformTrackunsigned int size = outClip.Size();// 遍历每个TransformTrackfor (unsigned int i = 0; i < size; ++i) {// 获取Track对应的joint的idint joint = (int)outClip.GetIdAtIndex(i);// 获取这个Joint的遍历顺序unsigned int newJoint = (unsigned int)boneMap[joint];// 改变outClip的mTracks数组的第i个track的对应的joint的idoutClip.SetIdAtIndex(i, newJoint);}
}// PS
// void Clip::SetIdAtIndex(unsigned int index, unsigned int id)
// {//     return mTracks[index].SetId(id);
// }// 跟Clip一样的函数
void RearrangeFastclip(FastClip& clip, std::map<int, int>& boneMap)
{unsigned int size = clip.Size();for (unsigned int i = 0; i < size; ++i) {int joint = (int)clip.GetIdAtIndex(i);unsigned int newJoint = (unsigned int)boneMap[joint];clip.SetIdAtIndex(i, newJoint);}
}// Mesh里有个std::vector<ivec4> mInfluences, 记录了joint的id, 既然新的id换了
// 里面的数据也要换
void RearrangeMesh(Mesh& mesh, std::map<int, int>& boneMap)
{std::vector<ivec4>& influences = mesh.GetInfluences();unsigned int size = (unsigned int)influences.size();for (unsigned int i = 0; i < size; ++i) {influences[i].x = boneMap[influences[i].x];influences[i].y = boneMap[influences[i].y];influences[i].z = boneMap[influences[i].z];influences[i].w = boneMap[influences[i].w];}mesh.UpdateOpenGLBuffers();
}

改变Pose::GetGlobalTransform函数

之前写了那么多东西,其实就是为了给涉及到Joints数组的东西重新排序而已,因为之前的Joints数组,如果顺序遍历数组,无法满足bfs遍历顺序,即数组元素的子节点都在其数组位置之后。

具体做了以下事情:

  • 重新排列Skeleton,也就是里面的BindPose和RestPose里的mJoints的顺序,再调整Skeleton里代表joints的名字的mNames数组
  • 重新排列Clip数据,因为里面有TransformTrack的数组数据,它是与mJoints的顺序一一对应的,所以也要重排
  • 重新改变Mesh数据,其实主要是SkinnedMesh里的Skin数据,因为Mesh里的每个顶点数据里记录了受影响的Bone的id

有了这些玩意儿,代码改起来就很简单了,原本的代码是:

// 计算特定Joint的GlobalTransform
Transform Pose::GetGlobalTransform(unsigned int index) const
{Transform result = mJoints[index];// 从底部往上Combine, 因为每个Joint的Parent Joint在vector里的id是可以获取到的for (int parent = mParents[index]; parent >= 0; parent = mParents[parent])// Combine这个函数是之前在mat4.cpp里实现的全局函数, 其实就是return matA*matBresult = combine(mJoints[parent], result);return result;
}// 然后是调用的代码
for (unsigned int i = 0; i < size; ++i)
{Transform t = GetGlobalTransform(i);// out[i] = transformToMat4(t);
}

现在就没这么复杂了:

// 计算特定Joint的GlobalTransform
Transform Pose::GetGlobalTransform(unsigned int index) const
{Transform result = mJoints[index];// 去掉了之前的for循环int parent = mParents[index];if(parent >= 0)result = combine(mJoints[parent], result);return result;
}// 调用的代码不变
for (unsigned int i = 0; i < size; ++i)
{Transform t = GetGlobalTransform(i);// out[i] = transformToMat4(t);
}

继续优化Pose::GetGlobalTransform函数

目前的Skeleton里的Joint数组是按bfs排序的,而且存的是LocalTransform,计算特定Joint时,该Joint的WorldTrans和其所有Parent的WorldTrans都会被计算一遍(在刚刚的优化之前,每个Parent的WorldTrans都可能会计算多变)

但是目前对于以下情况,仍然存在性能消耗:

  • 如果我多次取同一个Joint,即使它的Transform没变过,仍然要重新计算

为了解决这个问题,我觉得可以弄一个Cache,作为缓存,也是一个mJoints的Transform数组,不过记录的不再是LocalTrans,而是GlobalTrans,每次存在Joint更新时,就更新该Joint以及所有Children的Transform信息,感觉这样是可以的。但我目前的Skeleton里,节点好像只存了其Parent的信息,没存Children的节点信息。

看了下书,作者的做法更好,他是这样的,除了加一个mJoints的Transform数组,记录GlobalTrans外,再额外加一个数组,这个数组元素为bool,与原本的mJoints数组的Joint一一对应,作为Dirty Flag,每次Set来改变Joint数据时,就改变Dirty Flag,此时不会马上更新Transform数据,而只有读取Joint的数据时,才会去检查Dirty Flag,比如Joint的id为5,那么就检查0到5区间的flag就行了(因为子节点的Transform改变了也不会影响该节点的Transform),这样就能最大程度上避免Joints的GlobalTransform数组里的数据进行无效更新了。

不过这俩方法,都是通过用空间复杂度来换取时间复杂度的方法,每一个Pose对象里面的joints数组都会从一个变成两个,这一章暂时不实现相关的优化算法。

ps: 除了IK算法,其实一般很少要使用Joint的GetGlobalTransform函数,对于Skinning过程来说,主要还是使用的GetMatrixPalette函数,而这个函数已经被彻底优化好了。

总结

这章优化动画的思路主要是:

  • 减少蒙皮数据于CPU与GPU之间传递的uniform槽位
  • 加速对基于关键帧的Curve进行采样的函数
  • 蒙皮算法,动画更新的每帧需要更新每个Joint对应的蒙皮矩阵,优化了算法的计算过程

Github给了几个Sample:

  • Sample00代表基本的代码
  • Sample01展示了pre-skinned meshes的用法
  • Sample02展示了how to use the FastTrack class for faster sampling
  • Sample03展示了how to rearrange bones for faster palette generation.

附录

template后面接<>与什么都不接的区别

参考:https://stackoverflow.com/questions/28354752/template-vs-template-without-brackets-whats-the-difference

比如说声明一个模板函数:

template <typename T> void foo(T& t);

然后分别是这两种写法,把T都被指定为int:

// 写法一
template <> void foo<int>(int& t);
// 写法二
template void foo<int>(int& t);

注意,二者区别在于,第一种写法是模板全特化,这是一行函数声明,还需要函数定义,而第二种不是模板特化,它要求编译器为这个类型生成对应的函数代码,因为C++的模板其实是在你用到它的时候,也就是在cpp里调用它的时候,才会生成相关的代码进行编译,这么写,能够在没有用到对应代码的cpp的情况下,为其生成代码,可以检查其编译情况。

template <> void foo<int>(int& t); declares a specialization of the template, with potentially different body.
template void foo<int>(int& t); causes an explicit instantiation of the template, but doesn’t introduce a specialization. It just forces the instantiation of the template for a specific type.

同理,对于类和struct这些的模板,也是一样的:

Hands-on C++ Game Animation Programming阅读笔记(八)相关推荐

  1. Hands-on C++ Game Animation Programming阅读笔记(三)

    Chapter 4: Implementing Quaternions 其实很多人物的动画里,只有rotation,没有平移或者scale上的变化. Most humanoid animations ...

  2. Expert C Programming 阅读笔记(~CH1)

    P4: 好梗! There is one other convention-sometimes we repeat a key point to emphasize it. In addition, ...

  3. Expert C Programming 阅读笔记(CH2)

    P33     Bugs are by far the largest and most successful class of entity, with nearly a million known ...

  4. 《Character Animation with Direct3D》阅读笔记

    前言 最近在搞动画这一块,网易Jerish大佬推荐我去看<Character Animation with Direct3D>这本书. 以下链接是我个人整理的资料: [Character ...

  5. Robot fish: bio-inspired fishlike underwater robots 阅读笔记 1

    整书概览 第一部分 引言--RuXu Du 1. Propulsion in Water from Ancient to Modern 2. How Do Fish Swim 2.1 Research ...

  6. 阅读笔记 - Horizon Zero Dawn 广袤世界中的玩家漫游

    最开始我是忽略了这篇演讲的,因为Player Traversal是啥并没看懂- -b 后来看到 @顾露 大神在技术选荐中推荐了它,才拖下来看了看. 没想到这篇讲角色Locomotion的演讲中信息量意 ...

  7. Capture, Learning, and Synthesisof 3D Speaking styles论文阅读笔记 VOCA

    Capture, Learning, and Synthesisof 3D Speaking Styles论文阅读笔记 摘要 制作了一个4D面部(3D mesh 序列 + 同步语音)数据集:29分钟, ...

  8. 技术人修炼之道阅读笔记(二)重新定义自己

    技术人修炼之道阅读笔记(二)重新定义自己 在工作中有一个非常普遍的现象:有些人根本不知道自己想要什么或者什么都想要,无法取舍,但是人的精力毕竟是有限.那么我们如何来避免浪费自己的青春年华呢? 这就需要 ...

  9. trainer setup_Detectron2源码阅读笔记-(一)Configamp;Trainer

    一.代码结构概览 1.核心部分 configs:储存各种网络的yaml配置文件 datasets:存放数据集的地方 detectron2:运行代码的核心组件 tools:提供了运行代码的入口以及一切可 ...

  10. VoxelNet阅读笔记

    作者:Tom Hardy Date:2020-02-11 来源:VoxelNet阅读笔记

最新文章

  1. mac cad石材填充图案_CAD超级填充教程
  2. SQL Server表名为添加中括号[]执行出错
  3. 几种常见软件过程模型的比较
  4. 添加mysql至服务器_mysql 如何添加服务器
  5. 杰和科技多款商显方案亮相2017英特尔RCA论坛
  6. zabbix报错cannot set resource limit: [13] Permission denied解决方法
  7. 运用c语言和Java写九九乘法表
  8. java版spring cloud+spring boot+redis社交电子商务平台-docker-feign配置(五)
  9. java中类加载器ClassLoader,双亲加载机制,启动类加载器,应用类加载器,线程上下文类加载器
  10. html校园网页设计作品欣赏,26张很棒网页首屏设计作品欣赏
  11. 运动目标检测——研究现状
  12. php开源cms系统比较好,最受欢迎免费开源CMS建站系统排行榜
  13. Vivado 错误代码 [USF-XSim-62] [XSIM 43-4316] 解决思路
  14. 解密区块链中的密码学
  15. 为什么HDFS中的块如此之大?
  16. SAR chirp scaling(CSA)算法仿真
  17. Mysql数据库之结构同步
  18. c++ char类型连接
  19. 给定一个矩阵m*n,从左上角开始每次只能向右和向下走,最后到右下角的位置共有多少种路径。
  20. LNMP一键安装 + https

热门文章

  1. vue JsBarcode的使用
  2. Linux查看opencv版本
  3. Linux快速查看OpenCV版本
  4. 识别圆的强化RANSAC算法
  5. 大数据分析平台架构(Big Data Analytics Platform)
  6. C语言实现三子棋游戏
  7. SPI通信协议技术说明文档
  8. python 制作标签云
  9. matlab使用parpool加速蒙特卡洛仿真
  10. python爬取全国真实地址_python爬虫学习之爬取全国各省市县级城市邮政编码