[OpenGL] 骨骼动画原理和实现(Qt)

        最近在自己的练习项目中加入了骨骼动画系统。本篇文章主要讨论骨骼动画的基本原理,以及动画的导入和绘制。

多个骨骼动画循环播放效果,素材来源:unreal商城

概念引入

        对于网格体而言有不少实现动画的方式。直接对顶点进行操作也就是顶点动画,适用于一些比较简单的植物摆动、水面波动效果。此外,还有在两个网格之间进行插值的morphing动画;但它们本质上都是对顶点进行操作。

       而在某些情况下,我们认为某些区域的顶点具有关联性,并且希望能够对其整体进行控制,比如整体移动腿部的顶点。为此,我们引入了骨骼动画系统。我们定义某个顶点可由一个或多个骨骼控制,每个骨骼对顶点的贡献有着不同的权重,对于同一个顶点而言,所有对其产生影响的骨骼的权重加起来应该为1。这样,我们就可以通过修改骨骼的平移、旋转、缩放数据,来控制顶点的变换。

       对于骨骼而言,不同的骨骼存在父子关系。比如,当我们旋转手臂的时候,也会带动手部和手指的旋转。这些骨骼的父子关系之间形成了一棵骨骼“树”结构。

常用变换

       骨骼动画中存在着大量坐标空间和坐标变换,搞懂这些空间或变换是理解骨骼动画最核心的部分。

 

       在此之前,我们应该对坐标空间/姿态坐标变换这两个概念有一定的了解。为了更方便描述这两个概念,我们可以用相机空间来举例。我们的相机位于空间中的某一点,我们认为它此时平移、旋转、缩放对应的矩阵就是相机空间,或者说相机的姿态矩阵。如名字所见,这反映的是相机当前的状态。

       那么,如果我们希望将点/向量变换到相机空间呢?我们需要用到的变换矩阵和相机空间矩阵又是什么关系呢?此处,实际上可以得到一个一般性的结论:

      ● 如果希望将物体从世界空间变换到A空间,变换矩阵为A空间矩阵的逆矩阵;反之,从A空间变换到世界空间,变换矩阵即为A空间矩阵。

      我们所谓的变换到某个空间,也就是找到当前物体在某个空间下的坐标表达。对于相机空间而言,我们以相机原点为例,它在相机空间的坐标应为(0,0,0),这意味如果我们想要从世界空间的相机位置通过转换得到(0,0,0)这一结果,需要乘以相机姿态矩阵的逆矩阵,此时两个变换就刚好抵消得到0的结果。

 

     对以上几个概念有了基本的了解后,我们开始引入骨骼变换中几个常见的空间/变换:绑定姿态、骨骼空间、offset变换矩阵、局部变换矩阵、全局变换矩阵、蒙皮变换矩阵等。

     由于本文的重点是实现骨骼动画的渲染,因此,在此仅对相关的矩阵进行介绍。

     BindPose

     绑定姿态(bindPose) 也就是我们常说的T-Pose,这通常是由美术在建模软件中设定的,它定义了骨架的一种默认姿态,绑定矩阵存储了在这一姿态下,所有骨骼的变换数据。我们所有的骨骼动画变换都是在这一绑定姿态的基础上进行的。对于不同体型的角色而言,它们的高矮胖瘦是不同的,因而它们的绑定姿态也是不一样的。

      Offset Matrix

      根据上面的一般性结论,我们知道如果我们希望将物体从世界空间转换到绑定空间,我们需要乘以绑定姿态的逆矩阵。我们把这一矩阵称为Offset矩阵。这个矩阵在后面的计算会派上用场。

     GlobalTransform & LocalTransform

      还有两个比较重要的变换,一个是全局变换矩阵,一个是局部变换矩阵。它们反映了骨骼动画的每帧的变换。其中全局变换矩阵是是关节从根处变换到它最终所在位置所对应的变换矩阵,它通常用于骨骼动画渲染中,计算每个顶点的最终位置的时候;而拒不变换矩阵是指关节在父关节空间下的变换矩阵,它通常用于我们希望编辑某一关节的变换的时候,比如做出让头部抬起来这样的动作。

       在最简单的情况下,已知一个关节的局部空间变换时,连乘它的所有父骨骼的局部空间变换矩阵,就能得到该关节的全局变换矩阵。某些格式实际上还可能有别的一些特别定义的矩阵,在实际实现的时候需要特别注意。

      Mesh Transform

       实际上确定每个顶点最终位置的是蒙皮变换矩阵。对于每个处于绑定姿态的顶点而言,它通过蒙皮矩阵变换得到最终位置。它和全局变换矩阵(GlobalTransform)的区别在于,全局变换矩阵是将顶点从root处变换到最终位置,而蒙皮矩阵是将顶点从绑定姿态变换到最终位置。因此,我们需要先将顶点变换到绑定空间(通过乘以offset矩阵),再变换到最终位置。

       因此,蒙皮矩阵 = OffsetMatrix * GlobalTransform 

       此外,我们还需要考虑到一个顶点可能受到多个骨骼不同权重的影响。此处需要我们对所有蒙皮矩阵进行加权平均,得到最终的变换矩阵。

骨骼动画导入和绘制

       我们可以把骨骼动画的加载分为三个部分。

       第一部分,导入标准骨骼数据。对于不同的角色,如人和动物,我们有着不同的骨架,在这个过程中,我们导入多套骨架,包含每个骨架的名字,以及它对应的所有骨骼的名字,按顺序存储。我们得到了每个骨骼和其对应的索引下标,之后在连续地址存储骨骼矩阵的时候,我们将按此顺序存储(but,我贴出的代码只存了一份骨架)。

       需要导入的主要为骨骼名字和对应的绑定姿态,索引id可在导入的过程中生成。

       此处我们可以借助于assimp库,不过在此之前,需要对模型加载有一定了解(参考https://blog.csdn.net/ZJU_fish1996/article/details/90143844)。在获得了aiMesh的基础上,我们可以继续提取骨骼数据,同时存储offset矩阵:

struct Bone
{string m_name;QMatrix4x4 m_invBindPose;Bone(const string& name, const QMatrix4x4& pose):m_name(name),  m_invBindPose(pose) { }
};
void GeometryEngine::processBone(const aiMesh* pMesh, vector<BoneVertexData>& vertices, bool bAdd)
{for (uint i = 0 ; i < pMesh->mNumBones ; i++){string BoneName(pMesh->mBones[i]->mName.data);QMatrix4x4 dst;TransformMatrix(pMesh->mBones[i]->mOffsetMatrix, dst);CAnimationEngine::Inst()->AddBone(new Bone(BoneName, dst));}
}

 

      第二部分是导入带绑定骨骼的模型,但不包含动画。此时我们不仅获取每个点的法线、位置等信息,还存储了影响每个顶点的骨骼id和对应的权重,以生成带骨骼模型的vao/vbo相关数据。默认情况下,我们认为最多有四个骨骼影响同一顶点。具体而言,我们把顶点格式定义如下:

struct BoneVertexData
{QVector3D position;QVector3D normal;QVector3D tangent;QVector2D texcoord;QVector4D boneWeight;QVector4D boneIndex;
};

     (注:骨骼索引应该存为int类型,由于我本地在向顶点着色器传int类型数据出现了一些问题,我将其存储为了浮点数,并在着色器中转换为ivec4)

      相比起静态物体,带绑骨的模型多存储了boneWeight和boneIndex这两个信息,我们在读入了其它数据之后(见导入模型代码),再读入绑骨信息:

void GeometryEngine::processBone(const aiMesh* pMesh, vector<BoneVertexData>& vertices, bool bAdd)
{for (uint i = 0 ; i < pMesh->mNumBones ; i++){string BoneName(pMesh->mBones[i]->mName.data);int boneIdx = CAnimationEngine::Inst()->GetBoneIndex(BoneName);if(boneIdx == -1) continue;for (uint j = 0 ; j < pMesh->mBones[i]->mNumWeights; j++){size_t VertexID = pMesh->mBones[i]->mWeights[j].mVertexId;float Weight = pMesh->mBones[i]->mWeights[j].mWeight;if(VertexID >= vertices.size()){qDebug() << "err larger " << VertexID;}for(int k = 0;k < 4;k++){if(vertices[VertexID].boneIndex[k] == INVALID_BONE_IDX){vertices[VertexID].boneIndex[k] = boneIdx;vertices[VertexID].boneWeight[k] = Weight;break;}}}}
}

      

      第三部分是解析动画,并应用于对应的带绑骨的模型(原则上动画中的骨骼信息应该和应用的骨架匹配)。此处我们需要做的是存储每一帧,每个骨骼的蒙皮变换矩阵(在这里我们不考虑动画信息的压缩算法)。解析动画处使用了fbx自带的sdk(assimp也是可行的,但是需要自己解算全局变换矩阵,具体可以参考https://blog.csdn.net/ZJU_fish1996/article/details/52450008):

CFbxImporter::CFbxImporter()
{InitSdk();
}QMatrix4x4 TransformToQMatrix(const FbxAMatrix& mat)
{QMatrix4x4 res;res.setRow(0,{float(mat.Get(0,0)),float(mat.Get(0,1)),float(mat.Get(0,2)),float(mat.Get(0,3))});res.setRow(1,{float(mat.Get(1,0)),float(mat.Get(1,1)),float(mat.Get(1,2)),float(mat.Get(1,3))});res.setRow(2,{float(mat.Get(2,0)),float(mat.Get(2,1)),float(mat.Get(2,2)),float(mat.Get(2,3))});res.setRow(3,{float(mat.Get(3,0)),float(mat.Get(3,1)),float(mat.Get(3,2)),float(mat.Get(3,3))});res.setRow(3,{float(mat.Get(3,0)) * 0.01f,float(mat.Get(3,1)) * 0.01f,float(mat.Get(3,2)) * 0.01f,float(mat.Get(3,3))});res = res.transposed();return res;
}bool CFbxImporter::InitSdk()
{pManager = FbxManager::Create();if( !pManager ){return false;}FbxIOSettings* ios = FbxIOSettings::Create(pManager, IOSROOT);pManager->SetIOSettings(ios);FbxString lPath = FbxGetApplicationDirectory();pManager->LoadPluginsDirectory(lPath.Buffer());pScene = FbxScene::Create(pManager, "Scene");if( !pScene ){return false;}qDebug() << "success init sdk " ;return true;
}void CFbxImporter::ProcessAnimation()
{FbxAnimStack* pAnimStack = pScene->GetCurrentAnimationStack();FbxTime timePerFrame;timePerFrame.SetTime(0, 0, 0, 1, 0, pScene->GetGlobalSettings().GetTimeMode());const FbxTimeSpan animTimeSpan = pAnimStack->GetLocalTimeSpan();const FbxTime startTime = animTimeSpan.GetStart();const FbxTime endTime = animTimeSpan.GetStop();unsigned int frameNum = static_cast<unsigned int>(animTimeSpan.GetDuration().GetFrameCount(pScene->GetGlobalSettings().GetTimeMode())) + 1;int boneNum = CAnimationEngine::Inst()->GetBoneNum();if(pCurrentAnimator){pCurrentAnimator->Init(boneNum, frameNum);}for (FbxNode* pNode : vecNodes){unsigned int numFrames = 0;int boneIdx = CAnimationEngine::Inst()->GetBoneIndex(pNode->GetName());if(boneIdx == -1){continue;}FbxAMatrix kBindPose = pNode->EvaluateGlobalTransform();const QMatrix4x4& bindPose = TransformToQMatrix(kBindPose);const QMatrix4x4& invBindPose = bindPose.inverted();// 这里直接提取了bindPose并计算offset矩阵,可以取我们之前预先算好的for (FbxTime time = startTime; time <= endTime; time += timePerFrame, ++numFrames){FbxAMatrix kMatGlobal = pNode->EvaluateGlobalTransform(time);//transform of bone in world space at time tconst QMatrix4x4& globalMat = TransformToQMatrix(kMatGlobal);if(pCurrentAnimator){QMatrix4x4 renderMatrix = globalMat * invBindPose;pCurrentAnimator->AddFrame(numFrames, boneIdx, renderMatrix);}}}}
bool CFbxImporter::LoadFbx(const string& pFilename)
{vecNodes.clear();pCurrentAnimator = &CAnimationEngine::Inst()->CreateAnimator(pFilename);FbxImporter* lImporter = FbxImporter::Create(pManager,"");const bool lImportStatus = lImporter->Initialize(pFilename.c_str(), -1, pManager->GetIOSettings());if( !lImportStatus ){FbxString error = lImporter->GetStatus().GetErrorString();qDebug() << "load false";return false;}if(!lImporter->Import(pScene)){lImporter->Destroy();return false;}lImporter->Destroy();ProcessNode(pScene->GetRootNode());ProcessAnimation();return true;
}

      首先出于简单考虑,我们的动作没有经过任何曲线压缩处理,而是直接存储了每帧的所有骨骼蒙皮数据。

      蒙皮的这个过程可以在CPU或GPU中完成,不同引擎出于不同的考虑会有自己的实现。此处我们暂且放到GPU中实现,这意味着需要我们向顶点着色器发送所有骨骼的蒙皮变换矩阵,顶点根据自己相关的骨骼下标去查找对应的矩阵,并根据权重进行加权平均,得到最终的变换矩阵。

      以上是最为普通的线性蒙皮计算方式。在实际应用中可能会遇到肩膀之类的地方变得非常细长的问题,此处需要我们考虑新的蒙皮计算来规避这一问题,具体内容可以尝试查阅相关资料。

void main()
{vec4 position = vec4(0,0,0,1);if(HasAnim){ivec4 index = ivec4(a_boneindex);if(index.x < 0 || index.x >= BONE_NUM){position = a_position;}else{mat4 BoneTransform;BoneTransform =   Bones[index.x] * a_boneweight.x;BoneTransform +=  Bones[index.y] * a_boneweight.y;BoneTransform +=  Bones[index.z] * a_boneweight.z;BoneTransform +=  Bones[index.w] * a_boneweight.w;position = BoneTransform * a_position;v_tangent = mat3(BoneTransform) * a_tangent;v_normal = mat3(BoneTransform) * a_normal; // 有非等比缩放需为逆转置}}else{position = a_position;v_normal = a_normal;v_tangent = a_tangent;}mat3 M1 = mat3(IT_ModelMatrix[0].xyz, IT_ModelMatrix[1].xyz, IT_ModelMatrix[2].xyz);v_normal = normalize(M1 * v_normal);mat3 M2 = mat3(ModelMatrix[0].xyz, ModelMatrix[1].xyz, ModelMatrix[2].xyz);v_tangent = normalize(M2 * v_tangent);v_texcoord = a_texcoord;gl_Position = ModelMatrix * position;gl_Position = ViewMatrix  * gl_Position;gl_Position = ProjectMatrix * gl_Position;
}

      在GPU中,我们做一个简单的线性混合,此时还要注意处理法线相关数据的变换,避免做动作时光照错误。

 

动画管理实现

       为了控制骨骼动画的播放、更新,我们需要一个动画管理的类。我们默认每个角色同一时间只能播放一个动作。

       首先我们需要一个存储动画信息的类,也就是导入动画时我们用到的pCurrentAnimator变量。使用一个二维vector存储每帧每骨骼的蒙皮矩阵。

class CAnimator
{
private:vector<vector<QMatrix4x4>> m_vecFrames;
public:QMatrix4x4& GetFrame(int time, unsigned int boneIdx) { return m_vecFrames[time][boneIdx]; }vector<QMatrix4x4>& GetFrame(int time) { return m_vecFrames[time]; }void AddFrame(int time, unsigned int boneIdx, const QMatrix4x4& frame){m_vecFrames[time][boneIdx] = frame;}int GetFrameNum() { return static_cast<int>(m_vecFrames.size()); }void Init(unsigned int boneNum, unsigned int frameNum);
};

       其次,为了播放每个动作,我们需要定义我们如何播放这个动作,比如是否循环、混合时间、回调等信息。

struct SEvent
{float                           m_time = 0;float                           m_lastTime = 0;string                          m_path;bool                            m_bLoop = true;int                             m_blendFrame = 10;unordered_map<int,function<void()>> m_callbacks;vector<QMatrix4x4>              m_cachePose; // 和上一动作混合时,在事件中缓存一下上一动作姿态SEvent() { }SEvent(const string& path, bool bLoop, int blendFrame): m_path(path), m_bLoop(bLoop), m_blendFrame(blendFrame) { }
};

       最后是我们最终控制播放的动画类,执行播放、更新、管理相关操作。

#define FRAME_PER_MS 0.03f
class CAnimationEngine
{
private:CAnimationEngine() {  }static CAnimationEngine* m_inst;vector<Bone*> m_bones;unordered_map<string, int> m_mapBonesIndex;unordered_map<string, CAnimator> m_animators;unordered_map<Object*, SEvent> m_events;public:static CAnimationEngine* Inst(){if(!m_inst) m_inst = new CAnimationEngine();return m_inst;}void        Init();void        PlayAnimation(Object* obj, const string& path, bool bLoop = true, int blendFrame = 10);bool        UpdateAnimation(Object* obj, QOpenGLShaderProgram* program);bool        HasAnimator(const string& name) { return m_animators.find(name) != m_animators.end(); }CAnimator&  GetAnimator(const string& name) { return m_animators[name]; }CAnimator&  CreateAnimator(const string& name) { m_animators[name] = CAnimator(); return m_animators[name]; }void        AddBone(Bone* bone) {  m_bones.push_back(bone); m_mapBonesIndex[bone->m_name] = m_bones.size() - 1;}int         GetBoneNum() { return static_cast<int>(m_bones.size());}int         GetBoneIndex(const string& name) { return m_mapBonesIndex.find(name) == m_mapBonesIndex.end() ? -1 : m_mapBonesIndex[name]; }Bone*       GetBones(int i) { return m_bones[static_cast<size_t>(i)];}
};

        播放动作实际上就是给角色添加新的动作指令,并且覆盖掉旧的数据。

void CAnimationEngine::PlayAnimation(Object* obj, const string& path, bool bLoop, int blendFrame)
{if(m_animators.find(path) == m_animators.end()){return;}int frame;string oldPath;if(m_events.find(obj) != m_events.end() && m_animators.find(m_events[obj].m_path) != m_animators.end()){SEvent& event = m_events[obj];if(blendFrame){oldPath = event.m_path;frame = static_cast<int>(event.m_time * FRAME_PER_MS);}}m_events[obj] = SEvent(path, bLoop, blendFrame);if(!oldPath.empty()){CAnimator& animator = m_animators[oldPath];m_events[obj].m_cachePose = animator.GetFrame(frame);}
}

        此处执行的更新动画也就是根据计算得到的当前帧数取得对应蒙皮矩阵,然后根据传入的shaderProgram向着色器发送蒙皮矩阵信息。我们采样的时间可能在两帧之间,此时可以选择在前后两帧之间按时间插值,也可以选择我这样偷懒的方法,在两者之间直接取一个作为结果:

bool CAnimationEngine::UpdateAnimation(Object* obj, QOpenGLShaderProgram* program)
{if(m_events.find(obj) == m_events.end()){return false;}SEvent& event = m_events[obj];if(m_animators.find(event.m_path) == m_animators.end()){return false;}CAnimator& animator = m_animators[event.m_path];if(animator.GetFrameNum() == 0){return false;}float curTime = RenderCommon::Inst()->GetMsTime();event.m_time += curTime - event.m_lastTime;int frame = static_cast<int>(event.m_time * FRAME_PER_MS); // 30/1000帧每毫秒if(frame >= animator.GetFrameNum()){if(event.m_bLoop){event.m_time = 0;frame = 0;}else{frame = animator.GetFrameNum() - 1;}}int size = static_cast<int>(m_bones.size());int location = program->uniformLocation("Bones");if(event.m_blendFrame > 0 && frame <= event.m_blendFrame && event.m_cachePose.size() > 0){// ...// 此处做动作融合,代码略,主要是根据cachePose和currentPose先Decompose反解出位移旋转// 缩放信息,然后分别插值,计算得到新的变换矩阵// program->setUniformValueArray(location,final.data(), size);}else{program->setUniformValueArray(location,animator.GetFrame(frame).data(), size);}if(callbacks.find(frame) != callbacks.end()){callbacks[frame]();}event.m_lastTime = curTime;return true;
}

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/60137.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

笔记一:微信小游戏可视化开发工具-变更动画播放速度

直接用修改变量的方式去修改动画播放的速度不会生效。比如下面的方式&#xff1a; 虽然变量可以修改成功&#xff0c;但是动画的播放速度还是初始的播放速度&#xff0c;无法变更。也就是动画一旦开始播放后&#xff0c;速度就没法再改变了。试过这两个积木&#xff0c;也没法变…

如何制作微课?详解:微课视频制作方法之微课制作软件

微课是一种以教学视频为主要素材&#xff0c;运用多媒体技术制作而成的微课程。制作微课可以提高学习效率、吸引学生听完之后能达到很好的效果&#xff1b;也能为课堂增添一些趣味。然而&#xff0c;老师们在选择微课制作软件时不知道应该选择哪种软件&#xff0c;并且不知道该…

幼儿园微课怎么制作?怎么给微课配音?

在传统式的课堂教学中&#xff0c;由于教材书本比较抽象&#xff0c;通常会容易使学生倍感单一&#xff0c;无趣乏味。现在幼儿园老师们也需要制作微课了&#xff0c;而这类幼儿微课最重要的就是吸引孩子们。 因此&#xff0c;幼儿微课可以遵循四个方面内容&#xff1a; 1、趣…

Edge 被强制成 Outlook 默认浏览器,网友:梦回 IE 竞争时期!

整理 | 苏宓 出品 | CSDN&#xff08;ID&#xff1a;CSDNnews&#xff09; 一朝梦回浏览器大战时期。 据外媒 The Verge 报道&#xff0c;继微软在 Edge 上引入 AI 技术增强该产品自身竞争力之后&#xff0c;微软再次发力&#xff0c;宣布将强制 Outlook 和 Teams 忽略 Windows…

chatgpt赋能python:用Python玩游戏:乐趣与技能并存

用Python玩游戏&#xff1a;乐趣与技能并存 Python是一种高级编程语言&#xff0c;一直以来都是程序员们最喜欢的工具之一。它不仅可以被用于开发软件和网站&#xff0c;还可以被用于创建游戏。在这篇文章中&#xff0c;我们将讨论如何用Python玩游戏&#xff0c;介绍一些有趣…

chatgpt赋能python:介绍:Python经典小游戏合集

介绍&#xff1a;Python经典小游戏合集 作为一门简洁易学、受到广泛喜爱的编程语言&#xff0c;Python已经在各个领域中得到了广泛应用&#xff0c;包括游戏开发。在这篇文章中&#xff0c;我们将为您介绍一些Python编程中的经典小游戏&#xff0c;让您感受到Python的多功能性…

github copilot X - chat 使用体验分享

文章目录 准备测试代码修改测试贪吃蛇游戏生成测试行内对话模式 使用总结 昨天一觉醒来发现等待了好久的基于GPT-4的copilot chat 终于通过了&#xff0c;在这里分享一下我的试用体验~ 准备 使用copilot chat 需要满足以下几个条件&#xff1a; 有正在生效的copilot订阅&…

腹部肿瘤内科专家朱利明:化疗也能“订制”,晚期结直肠癌不再“无药可救”

肠癌是发生在结肠和直肠的癌症&#xff0c;近二三十年来发病率快速上升。就在近期&#xff0c;“日本女大胃王菅原初代患肠癌病逝”的消息登上热搜&#xff0c;一时引发网友关注热议。 “人生有哲学三问&#xff1a;我是谁&#xff1f;我从哪里来&#xff1f;我到哪里去&#x…

讨论:癌症能被人类攻克吗

知乎网友观点&#xff1a;全球医药学界目前的主流都是尽力研制对绝症的维持用药物&#xff0c;以期符合医药公司在利润上的最大化&#xff0c;根治类药物的研制一般是国家级的非营利机构的目标&#xff0c;但是因为投入的资金相对较少&#xff0c;导致进展十分缓慢。这根本不是…

癌症的治疗方法有哪些?有一种方法比化疗好,副作用小

癌症的治疗方法有哪些&#xff1f;有一种方法比化疗好&#xff0c;副作用小 现如今&#xff0c;随着肿瘤发生率的持续上升&#xff0c;人们对于“化疗”也不再陌生。化疗是肿瘤治疗主要治疗手段&#xff0c;无论是肿瘤早期还是肿瘤晚期&#xff0c;无论是手术前还是手术后&…

癌症免疫细胞治疗知识:CAR-T与TCR-T的区别在哪里?--转载

肿瘤免疫治疗&#xff0c;实际上分为两大类。一种把肿瘤的特征“告诉”免疫细胞&#xff0c;让它们去定位&#xff0c;并造成杀伤&#xff1b;另一种是解除肿瘤对免疫的耐受/屏蔽作用&#xff0c;让免疫细胞重新认识肿瘤细胞&#xff0c;对肿瘤产生攻击(一般来说&#xff0c;肿…

NK细胞治疗肿瘤相关进展概述

人类自然杀伤细胞&#xff08;Nature Killer Cell&#xff0c;NK&#xff09;占所有循环淋巴细胞的15%。NK细胞发现于20世纪70年代&#xff0c;主要与杀死感染的微生物和恶性转化的同种异体和自体细胞有关。NK细胞来源于CD34共淋巴祖细胞。据估计&#xff0c;NK细胞的半衰期大约…

gpx4抑制剂-靶向癌症耐药治疗的新方法 | MedChemExpress

对于癌症治疗&#xff0c;耐药性的发生很大程度上限制了各类药物对癌症的临床有效性。例如&#xff0c;激酶抑制剂vemurafenib, erlotinib 和 crizotinib&#xff0c;分别对有BRAF突变的黑色素瘤&#xff0c;EGFR突变或ALK移位的肺腺癌有临床疗效。大部分患者对此治疗方法有反应…

易基因:MeRIP-seq等揭示m6A reader YTHDF1在结直肠癌PD-1免疫治疗中的作|Gut

大家好&#xff0c;这里是专注表观组学十余年&#xff0c;领跑多组学科研服务的易基因。 结直肠癌&#xff08;colorectal cancer &#xff0c;CRC&#xff09;是全球最常见的癌症之一&#xff0c;转移性CRC患者的5年生存率低于20%。免疫检查点阻断&#xff08;Immune checkpo…

拿来就用的Java海报生成器ImageCombiner(一)

背景 如果您是UI美工大师或者PS大牛&#xff0c;那本文一定不适合你&#xff1b;如果当您需要自己做一张海报时&#xff0c;可以立马有小伙伴帮您实现&#xff0c;那本文大概率也不适合你。但是&#xff0c;如果你跟我一样&#xff0c;遇上到以下场景&#xff0c;最近公司上了不…

推荐一款快速生成海报的微信小插件

现在很多小程序都有生成海报&#xff0c;分享海报的功能。我们自己的几个小程序 (如&#xff1a;爸妈搜商城、爸妈搜云课堂、幼师大学、跟着外教学英语等) 也都有生成海报的功能。因此技术团队萌生出制作一个简单易用的微信小插件&#xff0c;只要传入简单图片和对应的坐标值&a…

fast-poster通用海报生成器V1.3.3

v1.3.3 新特性 增加图片b64格式返回更新最新客户使用人数 33W fixbug Java代码生成本地endpoint路径问题 fast-poster通用海报生成器简介 快速&#xff1a;三步完成海报开发工作&#xff1a;启动服务 > 编辑海报 > 生成代码简单&#xff1a;组件丰富、支持拖拽、复…

开源海报生成器源码跑起来最近杂感

序 最近这2天又开始有点焦虑了额&#xff0c;就是感觉最近没有什么提升&#xff0c;天天再项目上编码很迷茫。所以晚上利用一点时间想进步下&#xff0c;想搞点新东西。一狠心又买了个域名&#xff0c;之前买的服务器3年就是配置有点低。 一、前情提要 想整点事情&#xff0c;但…

【海报生成网站】最新设计海报生成器网站项目源码

简介: 这是一个海报生成器网站的最新源代码&#xff0c;组件列表在最左边。可以选择最左侧的组件&#xff0c;如文本、二维码、图片等。&#xff0c;添加到中间画布区域&#xff0c;并通过右边的属性调整面板调整添加组件的样式。 快速: 分三步完成海报开发:开始服务>编辑…