2008年8月22日
#
最近开始正式写纹理管理,用了差不多2周基本功能已经完成,总结下来纹理管理需要作的事情有
1。根据纹理名字迅速找到纹理。
2。当显存不够使用的时候,而用户又要使用一张目前不在显存的纹理,那么需要选择一张纹理替换
3。将纹理按照长,宽,表面格式分组,相同的属于同一个组,这样做的目的是为了能够更好的实现(2)的功能
4。创建纹理时将图像数据载入纹理
在目前的实现中,功能(1)通过对字符串的哈希来实现,对于字符串的hash网上文章比较多,我使用的是Blizzard所使用的hash方法:
unsigned long GFTextureManger::HashString( const char *String, unsigned long dwHashType )
{
unsigned char *key = (unsigned char *)String;
unsigned long seed1 = 0x7FED7FED;
unsigned long seed2 = 0xEEEEEEEE;
int ch;
while( *key != 0 )
{
ch = toupper(*key++);
seed1 = m_CryptTable[(dwHashType << 8) + ch] ^ (seed1 + seed2);
seed2 = ch + seed1 + seed2 + (seed2 << 5) + 3;
}
return seed1;
}
功能1实现非常简单,但是考虑到即将使用的LRU替换算法,我并没有使用Std的标准hash_map,而是自己做了一个可以同时Hash和双向连接的Double link hash map这样每当一张纹理被使用的时候可以从这个Link hash map中移动最前面,同时又不会影响hash,这样在链表中(double link hash map) 的最后一个节点就是Least Recently Used texture
为了实现功能2,我的初步计划是这样对于 长Width 高Height MipLevel 表面格式为FMT的所有纹理称为一个纹理组,所有在这个纹理组中的纹理都可以互相替换,也就是说如果需要使用一张纹理而这张纹理还没有被载入显存,同时没有足够的显存分配,那么只要找到这个纹理所在的纹理组,其中任何纹理的显存部分都可以被目前这张纹理所使用。为了实现上述功能定义了2个纯虚类:
class GFTexMemPart
{
public:
GFTexMemPart();
virtual ~GFTexMemPart();
GFFORMAT m_Format;
int m_Width;
int m_Height;
int m_MipLevels;
bool m_CreatedSucceded;
};
class GFTexVideoMemPart:public GFTexMemPart
{
public:
virtual ~GFTexVideoMemPart();
virtual unsigned int GetVideoMemSize() = 0;
};
class GFTexSysteMemPart:public GFTexMemPart
{
public:
virtual ~GFTexSysteMemPart();
virtual unsigned int GetSysMemSize() = 0;
};
在目前的D3D中的实现就是2个texture,一张在显存,一张在系统内存中。
/*Implementation Of D3D video Texture part*/
class GFD3DTexVideoMemPart:public GFTexVideoMemPart
{
public:
GFD3DTexVideoMemPart(int Width, int Height, int Levels, GFFORMAT Fmt , unsigned long MemSize);
~GFD3DTexVideoMemPart();
unsigned int GetVideoMemSize(){ return m_VideoMemSize;}
IDirect3DTexture9 * m_VideoMemTex;
protected:
bool CreateTexture( int Width, int Height, int Levels, GFFORMAT Fmt);
unsigned long m_VideoMemSize;
};
/*Implementation Of D3D system Texture part*/
class GFD3DTexSysteMemPart:public GFTexSysteMemPart
{
public:
GFD3DTexSysteMemPart(int Width, int Height, int Levels, GFFORMAT Fmt, unsigned long MemSize);
~GFD3DTexSysteMemPart();
unsigned int GetSysMemSize(){ return m_SysMemSize;}
IDirect3DTexture9 * m_SysMemTex;
protected:
bool CreateTexture( int Width, int Height, int Levels, GFFORMAT Fmt);
unsigned long m_SysMemSize;
};
这样一个Texture应该分别由 video 和system part组成。当显存不够分配的时候只需要从同组中的LRUtexture上取下它的video Memory part 并且挂到但前地Texture就可以了。对于一个组中的所有texture使用双向链表将他们连接起来每当一个Texture被使用以后就简单得将这个Texture方到该组的最前面这样就实现了LRU表,每当需要替换的时候只要取链表中最末尾的节点就是最least Recently Used Texture 了
.class GFTexture:public GFRefObject
{
public:
GFTexture();
virtual ~GFTexture() ;
virtual bool CreateTextureFromGfpFile( const TCHAR * gfpTexfile,GFTEXUSAGE Usage = GFTEXUSG_INVIDMEM) = 0;
virtual bool CreateTexture( int Width, int Height, int Levels, GFTEXUSAGE Usage, GFFORMAT Fmt) =0;
virtual bool LoadTexture( const TCHAR * gfpTexfile) = 0;
virtual void DestoryTexture() = 0;
virtual bool UpLoadToVideoMemory() = 0;
//Below Part is Designed for texureManagement
virtual GFTexVideoMemPart * GetVideoMemPart() =0;
virtual GFTexSysteMemPart * GetSysteMemPart() =0;
virtual GFTexVideoMemPart * DetachVideoPart() =0;
virtual GFTexSysteMemPart * DetachSystemPart() =0;
virtual void AttachVideoPart(GFTexVideoMemPart *) =0;
virtual void AttachSystemPart(GFTexVideoMemPart *) =0;
virtual long GetWidth() =0;
virtual long GetHeight() =0;
virtual int GetMipLevel() =0;
virtual GFFORMAT GetFormat() =0;
virtual unsigned int GetTexVidMemSize() = 0;//in Byte
const std::string & GetName(){ return m_Name;}
void SetName(const char * name){ m_Name = name;}
double GetLastUsedTime(){return m_LastUsedTime;}
void SetLastUsedTime(double Time){m_LastUsedTime = Time;}
void SetTextureGroup(GFTextureGroup * Group);
GFTextureGroup * GetTextureGroup();
protected:
std::string m_Name;
GFTextureGroup * m_TexGroup;
double m_LastUsedTime;//Get Last Time Texture has been used
};
以上基本的数据结构 当制订Texture替换策略时还需要考虑到其他组的Texture是否能够释放以供使用同时策略应该能够更具缺页略动态调整
2008年4月15日
#
小时候家长经常对我们说“不能骄傲”,于是我们明白骄傲是错误的从小我们就学会即使在朗朗晴天也要能够看到随后而至得乌云和暴雨,那时我们年轻,只需要胜不骄,我们并不需要被教导败不馁,的确败不馁是我们天生的财富我们是小孩初生牛犊不怕虎嘛。后来我们长大了,经历了一次次的挫折,我们已经习惯了在去看太阳背后的乌云,可是乌云背后的太阳呢,有多少人还能真正在失败和沮丧中找回奋斗的动力。又有多少人就这样消沉了。能看到太阳背后的乌云是种优势但是真正的高手是能够在乌云背后看到太阳的
这几天开始写资源管理,参考了ogre中的一些框架,同样使用档案(archive)来组织资源,定义resource manager类来处理资源resource类,包括将字符串迅速hash到一个指向某个资源的地址。档案目前使用mpq的开放库(自己还没能力写出同样出色的压缩定位算法),resource manager通过字符串作为唯一的ID来迅速hash到资源数组的某个位置当然高深的hash算法也是网上找来的貌似也是Mpq的hash算法
unsigned long GFResGroup::HashString(const char*lpszFileName,unsigned long dwHashType)
{
unsigned char *key=(unsigned char*)lpszFileName;
unsigned long seed1=0x7FED7FED;
unsigned long seed2=0xEEEEEEEE;
int ch;
while(*key!=0)
{
ch=toupper(*key++);
seed1=m_cryptTable[(dwHashType<< 8)+ch]^(seed1 + seed2);
seed2=ch+seed1+seed2+(seed2<<5)+3;
}
return seed1;
}
乱七八糟的也没仔细研究就拿来用了。目前最大障碍在于纹理资源管理,我一直徘徊在市使用d3d内置的纹理管理(managed)还是自己写纹理管理,如果要自己写一张纹理就要分成2张一张放在内存(system memory)一张放在显存(video memory);然后必要的时候使用updateTexture.自己写需要实现很多算法比如优先队列用来存储lru算法的节点包括那些时候可以将显存中的纹理已给其他纹理用什么时候需要销毁显存中的纹理从而获得更大的显存空闲,还有很多零碎的小问题比如不同格式大小的纹理应该如何组织等等,想乐半天风险太大还是决定使用managed纹理,仔细研究了下文档功能还不错包括优先级设定都有应该能满足基本要求,总的来说这2星期主要目标就是将一个基本的纹理/顶点资源管理框架搭建出来具体的优化工作包括自己的纹理资源管理器放在以后优化
2008年4月6日
#
碰撞检测和人物碰撞反应总算告一段落了现在人物已经完全可以向wow里面那样控制了,沿墙滑动,手感都已经很不错了,这几天抽空写了下地形纹理混合,之前一直做得是物理和场景管理的东东,没写过shader也不懂什么HLSL,只记得以前公司改过一小段汇编的shader代码~~。不管怎样基本光照和混合的shader总算还凑合发帖庆祝下,感觉这些东西比场景管理和碰撞检测反应之类的简单多了(可能自己还没写难得特效)。嘿嘿庆祝下自己总算步入shader行列~~
2008年3月1日
#
在使用广义碰撞阶段迅速排除了大量物体以后,将会使用精确到多边形级别的精确碰撞,比如两个凸包之间的碰撞,凸包和面片之间的碰撞,以及2次曲面和多边形面片的碰撞,在游戏中常用的两次曲面有,椭圆体,圆柱体,胶囊,球体等等。对于两个凸包之间的碰撞算法目前比较流行的是SAT,分离轴测试算法可以静态和动态的计算出两个凸包之间的碰撞时间法向量等等。但是对于面数较多的凸包以及2次曲面却不大适合,此时一般使用GJK算法但是GJK算法本身强壮性的实现问题一直是一个较难的问题。在我的一项使用BSP进行碰撞检测的实验中,人物以胶囊来模拟,房屋内部通过非SOLID 的LEAFY BSP来构造,在使用BSP剔除了大量面片以后,遇到这样一个问题:如何在最后筛选下的三角形面片进行碰撞测试,以确定碰撞发生的时间,法向量等。
本文提出一种简单,易懂,准确的方法用来确定一个以速度v前进的胶囊和一个凸多边形第一次发生碰撞的时间。
首先 一个胶囊是所有到某根线段的距离等于某个值r的点的集合:
如图:虚线表示该线段这里以ab表示,r代表所有点到该线段的长度:
首先观察静态情况下的碰撞情况。当一个多边形面片和胶囊碰撞的时候,实际上是该多边形面片中存在一些点,这些点到线段ab的距离小于了r,这是因为在胶囊外部的点到线段ab的距离均大于r(胶囊是所有到某根线段的距离等于某个输r的点的集合)。所以在两个物体都静止的情况下相交测试实际上是测试线段ab到多边形的最短距离,如果该距离
如图这里以一个三角形为例子,左图中该三角形的所有点到线段ab的距离均大于r所以和该胶囊不相交,而右图中三角形的黑色区域中的点到线段ab的距离小于r所以该三角形和胶囊相交。
所以实际上只要计算一条线段到某个多边形的距离,然后和r作比较就可以知道是否发生碰撞。而一条线段和一个多边形的距离计算,需要分以下几个步骤(以三角形为例)
A将线段ab的2个端点投影到三角形所在平面,并检查投影点是否落在三角形内部,如果是的话将该投影距离作为一个候选值
B分别计算线段ab和三角形的三条边的最短距离点对,并检查该点对中的点是否落在线段中(ab线段和三角形的边线段中)如果是的话将该点对的距离作为一个候选值。
C分别计算线段ab的两个端点到三角形每条边的投影(实际上是计算最近点对),并检查该投影是否落在边的线段中如果是的话作为一个候选值保存。
D分别计算三角形的3个顶点到线段ab上的投影,并检查该投影是否落在线段ab中。如果是的话作为一个候选值保存。
E 分别计算三角形的3个顶点到线段ab的两个顶点,把距离作为候选值保存。
这样一来碰撞检测就归结为,点和线段,线段和线段,以及点和面的最短点对的计算了,
最后将上述的候选值进行比较,结果最小的那个就是三角形中到线段ab的距离。
上述方法非常容易推广到动态的情况也就是:当胶囊以速度v运动时第一次和三角形发生碰撞的时间。问题归结为 在哪个时间T线段ab到三角形的距离等于半径r,而这又归结为上述A,B,C,D,E 5个子问题。如果能够分别求出这5个子问题的时间,t1,t2,t3,t4,t5那么取这5个时间中的最小值就是胶囊和三角形发生碰撞的确切时间了。
下面以两条直线,一条静止,另外一条以速度v移动作为例子,来说明求得时间的过程。问题等同于:
给定一条静止,另外一条以速度v移动的直线,求出在哪个时间T这两条直线的距离等于半径r。
对于两条直线,假设直线的方程分别为:
L1:P1+r1*t;
L2:P2+r2*t;
现在架设直线L2以速度v={vx,vy,vz}移动;
根据两条直线的距离公式
d=|P1P2 .(r1Xr2)| /|(r1Xr2)|
其中P1P2是向量代表 P2-P1,X代表叉积,.代表点积
由于r1,r2是常量不会随着直线的移动而改变,这里令(r1Xr2)=r ={x*,y*,z*}
P1={x1,y1,z1}不变,P2={x2+vx*t, y2+vy*t, z2+vz*t}其中t代表时间是个变量
带入公式d=|P1P2 .(r1Xr2)| /|(r1Xr2)|可得
d(t)=x*(x2-x1)+y*(y2-y1)+z*(z2-z1)+(x*vx+y*vy+z*vz)t
令(x*vx+y*vy+z*vz)=a; x*(x2-x1)+y*(y2-y1)+z*(z2-z1)=b;
那么d(t) = at+b -----------------------------(1)
公式1就是两条直线之间的距离随着时间t变化的函数,这是一个带符号的距离,两边平方可得 d^2(t)= (at+b)^2
这是一个两次方程,假设胶囊半径为r 那么只要求解方程(at+b)^2=r^2这个方程就可以求出子问题B的时间了,同时注意计算出时间t之后还需要计算出该时间两条直线的线最近点对是否都处在两条对应的线段上,如果是的话才是一个合理的时间否则就抛弃该时间。
通过同样的推导可以分别求出其他子问题的时间取这些时间,取这些时间的最小值就是碰撞第一次发生的时间,当然在求解2次方程过程中要考虑到delta<或者=0的情况这些情况都有自己的物理意义,以上面两条线段距离为例d^2(t)= (at+b)^2中如果a=0
那么方程恒等于b^2,考察a=0的物理意义,a=0也就是(x*vx+y*vy+z*vz)=0;其中x*,y*,z*是这两条直线所组成的面的法向量
这表面移动速度平行于这两条直线所组成的面。显然距离是恒定不变的。
结论:
以上方法很容易推广到凸多边形,以及求出碰撞的法向量(根据最短时间是由上述A,BCDE中那种情况所引起的)。
在一般网游的室内环境检测中,使用胶囊模拟人物已经足够了,结合BSP的漂亮剪枝能力,可以做出比较满意和快速的碰撞检测,人物和其他物件的碰撞可以采用凸包比较或者GJK方法等。

网游中人物的碰撞后反应控制
人物移动控制是单机和网游中比较重要的部分,但前单机游戏使用动力学以及IK动画等已经达到了非常逼真的地步,在大型网络游戏中这样的物理模拟同步是很实现的,因此在目前多数网游中仍旧是采取使用一个包围体(盒子或者胶囊)来模拟人物。一个好的移动系统是很重要的,平滑的贴墙滑动以及下滑,跳跃等会带给玩家顺畅的手感否则则会有种奇怪的感觉,本文具体介绍了一下碰撞反应,包括贴墙滑动等的具体实现细节。包括一个demo实例。
目前物理引擎里面大多自带独立于刚体的人物角色控制,但是物理引擎需要特定的物理模型命名以及比较大的物理模拟开销度。如果需要定制自己的特别功能或者需要简化计算(同时模拟多个延迟或者玩家的反应)。就必须自己完成人物碰撞反应控制的代码
要完成人物发生碰撞以后的行为控制,需要碰撞检测系统提供以下的碰撞信息,对于每一个碰撞:
1. 碰撞发生的时间
2. 碰撞的法向量
3. 碰撞点
对于基本的人物碰撞控制反应来说,以上3点是必须的,有时还需要提供和包围体发生碰撞的具体三角形信息。
这里我采用胶囊来模拟人物的包围盒,之所以采用胶囊是因为使用这一类曲面包围体来模拟人物当发生碰撞时能够产生更加平滑的法向量,这对于后面的碰撞后行为能带来很大好处使得模拟更加平滑。
对于场景上的物体首先使用胶囊所在的包围盒AABB或者OBB和场景中的碰撞体包围盒作层次碰撞裁减,至于具体怎么组织可以任意,比如可以采用AABB或者OBB树,也可以采用简单的球树。但碰撞进行到树的叶子节点后开始检测人物的AABB盒和该AABB盒所包围的OBJECT的碰撞情况。如果发现这2个AABB(OBB)盒将会发生碰撞,那么开始使用人物的胶囊体和景物所带的三角面片进行精确到polygon soup级别的比较。这时候仍旧可以优化,比如说我还做了一步把一个Object中的三角形面片打成BSP树的形式存储起来,这样可以大大减少胶囊和三角形碰撞检测的次数,因为这种动态检测是十分耗时的。有关胶囊和三角形面片的比较可以参考:http://dev.gameres.com/Program/Abstract/Arithmetic/capsule.mht中的方法。 对于BSP的划分以及AABB碰撞检测就不用多说了~到处都可以找到文章。
对于地形而言,也是采用同样的方法,只不过对于地形而言三角形信息不用额外存储,只需要使用和渲染相同的三角形(对于景物来说一般不会使用渲染用的三角形而会使用更加简化数量更少的简化网格碰撞模型)。这里可以有很多优化的技巧,因为地形本身是规则的cell一个地形是由若干个patch(一般是16X16)组成的,而每个patch是由若干cell(一般是16X16)组成的。对于patch来说一般已经组织到了一颗QUADTREE中了因为视棱锥裁减也需要这种结构,因此碰撞检测中的AABB-AABB阶段使用这颗已经存在的QUADTREE就可以快速的完成层次碰撞检测了。但发现某个patch中的AABB和人物的AABB发生碰撞后需要检测每一个CELL所在的AABB和人物AABB盒的碰撞,这里可以使用点小技巧比如说首先将AABB盒投影到CELL所在的XY平面上,找出被投影覆盖的那些CELL然后再检测这些CELL的AABB盒是否和人物的发生碰撞。一但确定了某个CELL和人物发生碰撞那么就可以将该CELL中的三角形取出(一般为2个)依次和人物所在的胶囊进行三角形-胶囊的碰撞检测。
这样当碰撞检测系统完成任务以后我们将会获得一个碰撞信息的数组:
class CollideInFo{
public:
GFVECTOR_3D m_worldcdnorm;//碰撞法向量
GFPOINT_3D m_worldpoint;//碰撞点
float m_cdtime;//碰撞时间
};
CollideInFo collidearray[];
然后使用这个数组就可以进行碰撞后的处理了包括,沿墙滑动下滑等等。在具体说明整个人物移动控制算法之前,首先说下动态碰撞检测和静态碰撞检测的区别,动态碰撞检测是指物体A以速度V前进了T时间,在这期间第一次和物体B发生碰撞的时间。这样的碰撞检测必须返回第一次2个物体发生碰撞的时间。而静态检测是指2个不动的物体是否互相相交对于这种检测是不需要返回时间的。动态检测算法比静态的复杂而且也耗用更多的时间。一个完善的碰撞系统需要解决以上2种碰撞检测,如果你不想自己写检测代码,目前比较流行的有OPCODE,SOLID库等检测库。你可以直接使用他们提供的功能,这里我采用的是自己写检测代码的方法,目前只用到三角形-胶囊,AABB-AABB,OBB-OBB的碰撞检测。
完成了包围体(用的是胶囊)和三角形的碰撞,胶囊和BSP,地形的碰撞检测之后,拥有了碰撞的信息 1。碰撞时间。2。碰撞法向量。3。碰撞点。接着就可以处理人物在碰撞后的反应了。
首先人物的一次移动分2个阶段,第一个是初始阶段,使用静态碰撞检测获得该阶段的速度(具体做法在后面说)。第2阶段使用该速度去做动态碰撞检测得到碰撞信息,根据这些碰撞信息去处理碰撞后的反应。
先来看第一阶段,过程对于一个胶囊我们需要获取他周围的临近面片的信息,以决定这个胶囊目前所处平面的倾斜度,是否贴着不可通过的墙等等。我采用的方法是将胶囊体略为膨胀一些,然后调用静态碰撞检测的代码获取和该膨胀后的胶囊体相交的三角形面片碰撞信息如图:
棕色的是原始的胶囊体,红色的表示将胶囊半径略为增加以后的胶囊体,蓝色的2个地形是将胶囊膨胀以后所发生相交的2个三角形,而如果不采用膨胀的话该胶囊是不和任何三角形相交的,具体膨胀数值可以设为胶囊下落的最小高度,比如你设定胶囊离底部物体超过0.6单位属于腾空状态的话你就将膨胀数值设为0.6。在这个例子中我们将会检测到2个碰撞(蓝色部分)这2个碰撞法向量正好是这2个三角面的法向量(在很多情况下也可能不是这个看你的碰撞代码中法向量是如何计算的了)。其中底部的那个是可行走平面,另外一个是不可行走平面,有了这2个碰撞平面就很简单了,如果用户输入的速度和那个不可行走的平面相反(也就是撞向那个不可行走平面),那么那个不可行走平面是有效的,这样他和底部那个可行走的平面所组成的交线就是人物的初始速度,如果用户输入的速度和那个不可行走的平面法向量相同,那么这个不可行走平面没有作用人物最终的速度就是把用户速度投影到该可行走平面上的速度。
具体计算是否能够水平移动以及移动速度的算法:当给出M个不可行走平面和N个可行走平面时:
1首先将速度在N个可行走平面上分解,检查这些分解的速度是否有效(如何判断速度有效下面会说道)
2如果在1的时候存在一个有效的速度直接返回该速度
3没有有效速度,这时检查NXM个平面的交线,将速度在上面分解同时检查是否存在 有效速度
4如果存在有效速度返回该速度
5否则检查MXM条交线的,将速度在上面分解同时检查是否存在有效速度
6如果存在有效速度返回该速度
7否则返回0速度
那么如何判定速度是否有效呢,首先我们知道了所有碰撞的信息,给定一个速度,如果该速度和所有碰撞的法向量的夹角都是小于90度那么这就是个有效速度,(说明该分解后速度不会引起和这些碰撞面的在一次碰撞,因为该速度是将物体拉下远离该平面的方向的)。如果只要有一个夹角大于90度那么该速度就是非有效速度,同时在移动时还要判断该速度的倾斜角是否大于最大下滑倾斜角。注意 5是很重要的因为2个不可行走的平面所形成的交线仍旧可能是可以行走的,甚至是水平的。
以上的部分是检查是否能够水平移动的,如果不能水平移动那么该物体会下滑,
1如果没有检测到碰撞平面说明物体处于腾空状态,这时候给物体加上重力加速度,产生一个往下的速度和原来的水平速度结合起来(如果存在)。
如图棕色是原始状态胶囊经过上一帧移动后到达蓝色的位置这时通过上诉算法可以检测到胶囊腾空,这时他的速度为水平速度(红色)+下滑速度(绿色)。
2如果检测到碰撞平面但是平面以及它们的交线都是不可行走的(倾斜角大于可行走角度)那么依次将当前速度在碰撞平面分解,以及检测每一条可能下滑的交线。得出速度后检测是否是有效速度具体过程和前面检查水平速度时的方法一样,只是将速度投影到平面上的方法不一样具体方法可以根据程序需要,我这里采用首先去掉原始速度在碰撞平面法线的分量,然后将速度分解为2个速度一个是贴着平面水平移动的速度另外一个是贴着平面下滑的速度。注意如果上一帧更新后物体处于下滑状态那么当前速度就因该是该下滑速度,否则则是用户输入的速度
如果用户输入的速度是跳跃也就是带Z分量的速度那么,计算初始速度这一步需要略过直接输入给下一阶段用户起跳的速度。
阶段2计算初始速度引起的碰撞
对于多数情况来说,计算完初始速度就不会再发生碰撞了,一旦发生,那么我们依旧传入碰撞面的那些法向量,碰撞点的信息,同样采用前面计算初始速度时所采用的方法,计算出碰撞后的调整速度。
整个处理过程基本上就是这样的,其中可能还会出现一些小问题比如说误差控制等。总结一条就是人物行走要么研着平面分解速度要么沿着2个平面的交线行走或者下滑。这个是Demo示例画面(由于没有人帮我做动画。。所以demo中目前只有行走动画,没有起跳,下落等动画。。)


Shi ruo yu(fishboy)