game小组中期报告

我在成立这个小组时是把它定位于一个兴趣小组而不是项目组,初衷主要是共同学习研究game development中的技术,而不是限时之内以完成某个项目为目标。游戏开发涉及领域很广,我很鼓励组员研究自己感兴趣的领域,而不是因任务的需要被迫地学习某些东西,大家久不久交流下,将来再进行一些技术上的融合。

先说说我自己做的。由于缺乏美工配合,加上实验室项目的需要,近期主要是做一些引擎开发上的研究,当然由于经验和水平有限只能做些简单的。

实时渲染方面做得没有以前多,只是把以前做的一些东西改进了一下而已。先讲讲这方面的工作:由于篇幅有限我不会讲细,但我会把参考文章贴出来,有兴趣的童鞋自己去看好了:-)

地形

之前做过一个超级简单的地形,对于一张nxn个顶点的地形,读取一张同样是nxn分辨率的灰度贴图(一般称之为高度图),每个图素存储一个高度值,在创建地形顶点缓冲(vertex buffer)的时候,每个顶点的y值(向上)就是对应高度图中图素的值。然后一次性渲染出来。。

这样的方法只适合非常小的地形(一般不超过512x512),但很多游戏中需要很大的地形.别说很大的,例如一张中等规模的4096x4096的地图,如果按照这样的方法,每一帧要渲染1600多万的顶点数,别说效率极低,显存都不够用了。

这就引入了层次细节(level of detail)的技术,即在离摄影机远的地方不需要太过精确的渲染,因为远处的顶点非常密集,很多顶点最后都map到了同一个像素上,白白浪费了计算。关于这方面的文章很多,有一些经典算法是在家用GPU还没有普及的时候就产生了,但现在我们自然倾向于比较GPU friendly的方法。目前很多游戏中用的都是基于一个叫Geometrical MipMapping的算法(http://www.flipcode.com/archives/Fast_Terrain_Rendering_Using_Geometrical_MipMapping.shtml),它的基本思想是把整个大地形分块,每一块可以有多个分辨率,然后每一块的分辨率取决于它离摄影机的距离。Game programming gems6中有一篇文章更详细地探讨了这种技术。我采用的是一种叫Geometry clipmapping的方法(http://http.developer.nvidia.com/GPUGems2/gpugems2_chapter02.html)它采用以摄影机为中心的环状地形结构,如下图所示:

很显然,地形的细节会随着摄影机移动而改变,离摄影机越远的环块精度越低,然后他也需要分块,然后每一块可以在GPU中动态的缩放和平移,这样就可以只用一个地形小块的数据通过GPU instance来渲染完整个大地形,极大地减少了显存的消耗。这个算法的思路还是很明显的,但是实现起来非常的麻烦。。。有很多细节的地方要处理。具体我就不多说了。先付一张图:

实际上我最终的实现方法比原书中讲的略有改进,帧数上也有比较明显的增加。另外原书中有一个致命的问题没有解决,就是地形multi-texturing,这个问题我在DX10下用texture array解决了,DX9中不支持texture array,我能想到的解决办法只有texture atlas(http://www.gamerendering.com/2009/12/08/texture-atlas/),不过还没有实现过。

最后再配合视锥剔除(view frustum culling)渲染4096x4096的地形只用了7-8万的顶点就完成了,而且层次之间几乎看不出有poping的迹象。

大气散射

这方面并没有什么大改进,只是发现原先在piexl shader里面计算的瑞利散射的部分其实是可以在vertex shader中完成的。如果屏幕的分辨率较大,是可以适当地提高一些帧数。关键是,且肉眼看不出差别。

                                                       Vs                                                                                                                              Ps

之前做的这个atmosphere scattering是基于nishita 1993的那篇论文(http://nis-lab.is.s.u-tokyo.ac.jp/nis/cdrom/sig93_nis.pdf。),GPU GEMS2上面有篇文章也是基于尼师塔的这篇论文,并用GPU实现(http://http.developer.nvidia.com/GPUGems2/gpugems2_chapter16.html。)但我发现更喜欢这篇文章的效果:

www.cs.utah.edu/~shirley/papers/sunsky/sunsky.pdf

伴随as的还有一个技术叫做aerial perspective,即远处的景色会渐渐和天空的颜色融合在一起。在实时渲染中大多数时候都用雾化来代替,aerial perspective更加基于物理一些,效果更好。不过当时做as的时候地形是平的也没什么物体,所以没有做ap.今后把as,ap和云层的渲染再加上地形,效果会非常amazing。。。

下面讲一下引擎方面的工作

统一抽象接口:

我之前一直都是用DX来做渲染DEMO,现在要做引擎的话考虑的问题一下就多了。由于实验室这边要用Opengl,所以有必要实现DX和GL接口上的统一,比如创建VB,IB,创建纹理,创建shader,传送shader变量到GPU,访问资源等等,

由于这些东西在概念DX和OGL很接近,但是在数据上或是在实现上差异就很大了,所以需要把他们抽象出来,然后DX和OGL再各自实现自己的。这样这些抽象的接口屏蔽了DX和OGL的差异,用户就可以调用统一的代码,在切换用DX还是OGL渲染时就不用改动客户端代码了。

首先是抽象rendersystem,它有两个子类,分别是D3D10rendersystem和OPENGLrendersystem,然后rendersystem有一系列的抽象方法,那两个子系统必需自己实现的:

class RenderSystem
{
public:
RenderSystem(){}
virtual ~RenderSystem();

virtual void InitRenderSystem() = 0;
virtual void CleanUp() = 0;
virtual void BeginOneFrame() = 0;
virtual void EndOneFrame() = 0;

virtual void CreateVertexBuffer(unsigned int size, const void *pSysMem, VertexBuffer** ppvb) = 0;
virtual void CreateIndexBuffer(unsigned int size, const void *pSysMem, IndexBuffer** ib) = 0;
virtual void CreateInputFormat() = 0;

virtual uint LoadShaderFromFile(const char* vscode, const char* pscode, const char* publiccode, const char* filename) = 0;
uint LoadShaderFromFile(const char* filename);
uint AddShader(Shader* shader);
virtual void DrawText(int Num, int col, int row) = 0;
virtual void DrawText(double Num, int col, int row) = 0;
virtual void DrawIndex(unsigned int IndexCount, unsigned int StartIndexLocation) = 0;
virtual void DrawIndexInstanced(unsigned int IndexCountPerInstance, unsigned int InstanceCount, unsigned int StartIndexLocation, int BaseVertexLocation, unsigned int StartInstanceLocation) = 0;

virtual void DrawVertex(unsigned int VertexCount) = 0;

virtual void ClearRenderTarget() = 0;
virtual void SetRenderTarget() = 0;
virtual void CreateTextureFormfile(const char* filename, Texture** ppTexture) = 0;
virtual void CreateTexture(unsigned int width, unsigned int height, TEXTUREFORMAT format, bool sr, bool rt, bool CPUreadable, bool CPUwritable, Texture** ppTexture) = 0;
virtual void CreateTextureArrayFromFile(std::vector<std::string>& filenames, unsigned int width, unsigned int height, TEXTUREFORMAT format, Texture** ppTexture, unsigned int count) = 0;
virtual void CreateTextureArray(unsigned int width, unsigned int height, int Mips, TEXTUREFORMAT format, bool sr, bool rt, Texture** ppTexture, unsigned int count) = 0;
virtual void CopyTexture(Texture* dest, Texture* source, int X, int Y, int srcleft, int srcright, int srctop, int srcbottom) = 0;
virtual void SetVertexBuffer( unsigned int StartSlot, unsigned int NumBuffers, VertexBuffer *const *ppVertexBuffers, const unsigned int *pStrides, const unsigned int *pOffsets) = 0;
virtual void SetIndexBuffer(IndexBuffer *pIndexBuffers, unsigned int Offset) = 0;
virtual void SetPrimitiveTopology(PRIMITIVE_TOPOLOGY top) = 0;
virtual void CreateHurEffectFromFile(const char* filename) = 0;
virtual void SetRasterState(bool wirframe) = 0;

D3D10RenderSystem
* AsD3D10(){return reinterpret_cast<D3D10RenderSystem*>(this);}
OpenGLRenderSystem
* AsOpenGL(){return reinterpret_cast<OpenGLRenderSystem*>(this);}

std::vector
<Shader*> m_ShaderArray;
std::map
<std::string, uint> m_ShaderDictionary;
};

然后再定义D3D10rendersystem和OPENGLrendersystem:

class OpenGLRenderSystem : public RenderSystem
{
……
}
class D3D10RenderSystem : public RenderSystem
{
……
}

类似以后还可以定义dx11和opengl es的渲染子系统,只要实现父类规定的抽象接口即可。

如果还要实现跨平台,那么每个操作系统创建窗口,加载动态库,处理消息循环又会很不一样,那么还要定义一个baseapp类,规定好一些接口,然后再派生出win32app,linuxapp这些具体的应用程序类。不过现在还没做到这么复杂的。。。

数学库 

刚接触OGL不久,好像没发现OGL像DX那样带有内置的数学库,DX的数学库在OGL中又不能直接使用。当然如果OGL有自己的数学库,那么可以把抽象一套数学接口,然后DX,ogl再各自继承实现,不过即使这样,数据类型还是要自己定义,那么在调用这些函数时就必须把自己定义的类型转成DX或OGL的内置类型,这就需要每帧拷贝大量数据,如传入一个4x4矩阵就要拷贝16次浮点型。所以觉得自己做一个比较好,目前已经把大部分向量、矩阵、平面、和四元数的操作写完了,还挺好用,不过也有个缺点,就是指令是没有经过优化的。所以估计今后会使用第三方的数学库,不过自己实现下加深理解也是不错的!

统一shader格式 

DX中的effect framework有effect,technique,pass等这些概念,其中一个effect是一个shader代码集,里面可以包含若干个technique,一个人technique包含多个pass,每个pass包含一个vs,gs,ps,和相关渲染状态。OGL没有这些概念,这给统一的shader读取接口带来很大麻烦,现决定采用统一的格式,一个文件中分成几个section:

<iputlayout>

……

<vertex_shader>

……

<geometry_shader>

……

<pixel_shader>

……

原先hlsl和glsl的shader代码必需拷到相应的section中。Inputlayout是标明vertex shader中的顶点的attibute(坐标,颜色,法线,纹理坐标)顺序如何,因为在应用程序中创建的VB在绑定时必需也和这个顺序对应。Hlslshader中可以通过semantic来标明顺序,OGL可以通过glGetActiveAttrib得到类型,但相同类型可能表示不同的attibute,所以还是要用户自己指定

可以看出一个文件相当于一个pass,但有时候一次渲染包含多个pass,这就需要technique的概念。technique被定义在另一种文件中,则采取如下的格式:

Technique

{

Pass0: 某shader文件名

Pass1: 某shader文件名

Pass2:   某shader文件名

……

}

读取文件时,系统会检测后缀名,如果发现的是一个technique文件,那么就加载其中的shader文件,最后创建一个technique。

消息事件处理 

之前做demo的时候经常会遇到这样的问题;

switch(msg)
{
case WM_KEYDOWN:
{
switch(wParam)
{
case ‘D’:
{
do sth
break;
}
}
}
}

可很多时候按同样的键在不同的场合下做的事是不一样的,所以我不得不这样做,设置个flag

switch(wParam)
{
case ‘D’:
{
If(flag
==1) do sth
Else If(flag
==2) do another thing
break;
}
}

这是极其不雅观的代码,也很不方便。事实上我们应该把按键当成一个消息,然后定义一个监听者类,一个事件可以有多个监听者Eventlistener,然后设置一个消息管理器,维护两个消息队列(用于swap,类似双缓冲)每一帧开始时先看消息队列中有没有消息,如果有,则检测它对应的监听者,然后回调这些个监听者。每个监听者要实现一个handleevent(event*)回调函数,具体的消息响应就写在这里面。其中event是一个事件类,他是一个虚类,具体的事件必须继承它。。这样上面的代码就变为

switch(wParam)
{
case ‘D’:
{
Deliver event到消息队列去
break;
}
}

在每帧开始时,消息管理器会处理这些消息:

while ( 消息队列不为空)
{
获得一条消息的指针event
得到监听该消息的所有listener指针
While(遍历所有listener)
{
Listener
->handleEvent(event);
}
……
}

然后各个监听者在自己的消息处理函数中来响应这个event,例如:

Class A : public EventListener

Void A::handleEvent(event* event)
{
Int type
= event->gettype();
Switch(type)
{
…..
}
}

这样做还可以根据需要在程序运行时动态的激活和挂起监听者,这个程序的结构马上变得美观很多了。

每一个事件event会有一个eventdata保存和这个事件相关的数据,对于键盘输入他就是一个key值,对于鼠标输入它是一个x坐标一个y坐标。由于事件不仅仅是输入输出事件,在game ai中会有各种各样的事件,比如一个怪死了,那么它必须发出一个死亡的消息给那些关注这个怪的监听者们,这些事件所带的数据会非常复杂,所以用户在自己定义event时,除了要继承event类,还要继承eventdata类

class EventData
{
public:
virtual ~EventData(){};

};
class Event
{
public:
virtual ~Event(){}
virtual void Queue(){EventManager::GetInstance()->AddQueue(this);}
virtual void Execute(){EventManager::GetInstance()->fireup(this);}

const char* GetName() const {return m_typename;}
EventData
* GetEventData() const {return m_eventdata;}
EventID GetTypeID()
const {return m_typeid;};

private:
const char* m_typename;
const EventID m_typeid;
protected:
EventData
* m_eventdata;

};

例如一个鼠标左键按下消息:

class MouseData : public EventData
{
public:
MouseData(unsigned
short xcood, unsigned short ycood) : m_xcood(xcood), m_ycood(ycood){}
~MouseData(){}
unsigned
short m_xcood;
unsigned
short m_ycood;
};
class IE_LBUTTONDOWN : public Event
{
public:
static const EventID TYPEID;
IE_LBUTTONDOWN(unsigned
short x, unsigned short y) : Event("LBUTTONDOWN", TYPEID, new MouseData(x, y)){};
~IE_LBUTTONDOWN(){ SafeDelete(m_eventdata);}
};

这期间做的差不多就这些,下面说一下这个学期的打算

引擎方面:

尽快完善shader系统

实现个日志系统

实现一套UI

实现资源管理器

Graphics方面

找月胜和我一起完成骨骼蒙皮动画系统,之前已经基本完成了API independent的骨骼系统,但蒙皮一直没做。

换一个光照模型,不再使用phone了,可能会是cook-Torrance的模型

在原有的shadow map的基础上实现Parallel-Split Shadow Maps

下面请乔杰说一下他在物理方面的进展

基本刚体物理系统

在实时三维系统中,刚体系统是应用得最多的最重要的系统。一般的实时系统中的物体都被看作成是刚体,因为刚体的处理,无论是刚体的运动处理还是刚体的碰撞检测及碰撞反应的处理都是十分高效的。

在实时刚体物理系统中,主要也是利用欧拉积分的方法来实现数值模拟的,虽然这种方法精度不高,但是胜在高效。

1 刚体的质心

刚体的运动可以分为线性部分以及旋转部分。因为刚体的形状是不会改变的,因此我们在考虑其线性运动时候,可以将其看作为在它的质心上的一个质点。对于旋转,刚体始终是会绕着质心进行的。所以在计算刚体的运动之前,先要计算出刚体的质心位置。一般在刚体系统的实现中,我们会在初始化的过程中计算出刚体的质心并将模型数据都规范化,即将模型的质心与模型顶点的做处在的局部坐标系的原点重合。

我们将刚体的看作成是有很多的粒子构成的,在余下的讨论中,用 表示的是粒子的位置, 表示粒子的速度,在三维空间中它们是一个三维的向量。 表示粒子的质量。这样刚体的质心位置 可以用式3.1求得,其中 是刚体总体的质量,由式3.2求得。

2 线性运动

线性运动是刚体整体的运动状态的计算。它又可以叫做平移运动,因为它在数值模拟中计算的是一个时间段内物体平移的向量,再根据前一时间的位置得出当前时间的位置。由于运动的整体性,我们只需要将其考虑成在质心上的一个质点。

首先介绍一下描述线性运动的一些物理量及它们之间的关系。影响线性运动的轨迹的最根本的物理量是力,我们用 来表示一个刚体在 时间受到的合力, 表示刚体上某个顶点受到的力,所以可以得到关系式3.3。根据牛顿第二定律可以得到式3.4, 为刚体在 时间的整体加速度。加速度 ,整体速度 与质心位置 之间的关系如式3.5与式3.6。最后是刚体的动量 ,可以用式3.7定义,其中 为刚体的质量。

线性运动的目标就是计算出每个时间段物体的位置,在模拟中根据欧拉积分方法与式3.6可以得出位置的更新方程为式3.8。计算式3.8中的 时候,再次利用欧拉积分并结合式3.5得出 的更新方程为式3.9。在式3.9中 的计算可以联立式3.3与式3.4解出。

3 角运动

线性运动计算的是物体的质心在世界坐标系中的位置,而角运动计算的则是规范化的模拟的顶点在物体局部坐标系上的位置。角运动是物体绕质心的一种旋转的运动,定义了物体的摆放方向

物体的方向可以使用两种方式来表示,一种就是用旋转矩阵来表示,另外一种是用四元数来表示。四元数的形式如式3.10,其中 表示的是旋转轴, 表示的是绕旋转轴旋转的角度,一般用弧度制。四元数也可以对应表示成式3.11

在数值模拟中,一般使用的是四元数的方式来存储表示物体的方向。这个主要是考虑到数值误差的问题。随着模拟时间的推移,误差也在渐渐地积累。对于矩阵,它有着六个额外的自由度,换句话说它每次计算后所造成的误差经过累积将会是十分大的。直观来说,就是得出的旋转后的方向会越来越偏离实际的方向,这样甚至会导致系统的不稳定,使能量错误地增加。另一方面,四元数的非旋转自由度为零,因此它不会受到旋转以外的运动所影响。但是在计算时候我们还是需要用到旋转矩阵,这时可以通过式3.12将四元数 转换成为相应的旋转矩阵 。

现在找到了用四元数表示刚体方向的方法,所以我们需要在每一时间点中计算出相应表示刚体方向的四元数。

假设一个时间片内四元数的变化量为 ,则前后两个时间点的四元数的关系可以用式3.13表示。式中包含了四元数的乘法运算,它不同于向量的叉乘,四元数的乘法法则由式3.14定义。

四元数的变化量 与刚体旋转的角速度 有着直接的关系,如式3.15。所以现在求 的问题转化成为了求刚体在 时刻的角速度 的问题

       根据刚体力学,求解角速度需要用到几个物理量,第一个就是力矩 ,刚体在 时刻的总力矩可以用式3.16计算得到,其中 是刚体的质心到某个顶点的向量。

另外一个物理量是角动量 。角动量与线动量有着式3.17的关系,并且它与刚体的力矩有着式2.18的关系。

最后一个物理量是刚体的转动惯量 ,在 时刻物体的转动惯量可以标记为 。这是一个 的矩阵,它由刚体的各质元相对于转动轴的分布所决定,与刚体运动以及所受到的外力无关。在 时刻物体的转动惯量可以根据式3.19求得。

 

根据刚体定轴转动定律,式3.20,等式两边同时对 积分并化简可以得式3.21。所以在 时刻刚体的旋转角速度可以用式3.22求得。其中 可以使用欧拉积分方法进行更新,如式3.23。

4 基本刚体物理系统数值模拟过程

 根据前面的分析,一个刚体的状态可以用式3.24表示的四元组来表示。所以,在数值模拟过程中,我们需要不断更新这个四元组来实现模拟。模拟步骤如下式组。

5 刚体间碰撞处理

在刚体的碰撞处理中,我们将碰撞量化为力的效果,即碰撞产生的力在时间的积累效果下生成相应的冲量从而改变物体的动量,其中包括线动量和角动量。

根据Discrete Element Method(DEM),接触作用可以分为法向作用以及切向作用。

在法向作用中我们使用线性的弹性阻尼模型。线性的弹性阻尼模型中包含有两个力,一个是粒子间的排斥力,这是粒子间固有的一种力的作用;另外一个是粒子之间的阻尼力,阻尼力是用于模拟碰撞发生时候粒子间能量的损耗的。

假设粒子 与粒子 发生了碰撞,碰撞所导致的粒子 的排斥力 可以用式3.25表示。 与粒子的距离有关,距离越近它们之间的作用力越大。其中 是排斥系数, 是粒子 到粒子 的距离向量, 为粒子直径。粒子 的排斥力 与 大小相同方向相反。

 

 

碰撞过程中的对粒子 所产生的阻尼力可用式3.26计算。阻尼力的大小与两个粒子的相对速度成正比关系。其中 为阻尼系数, 为粒子 相对于粒子 的速度。同样粒子 受到的阻尼力 与 大小相同方向相反。

3.25与式3.26的参数可以分别用式3.27与式3.28来设定。式3.27中, 是一个阻尼因子,相当于一个摩擦参数。 表示粒子的质量。 表示的是系统中的粒子的最大相对速度。 是粒子的直径。式3.28中, 是式3.25中的排斥系数, 是一个阻尼限制参数,其他与式3.27中定义一样

碰撞时刻的接触力不仅作用在法线方向上而且作用在切线方向上。同样是取粒子 与粒子 发生了碰撞为例,在切线方向上,粒子 与粒子 都会分别受到一个叫做切向力的作用力 和 。 可以用式3.29计算得到。 为切向排斥系数。

综上所述,粒子 与粒子 发生碰撞,作用于它们的力分别可以用式3.30与3.31表示。

然后是岳亚涛做的粒子系统:

1.1面向对象设计

在虚拟现实的场景中,有一些物体很难用几个图元表示,例如一枚火箭拖着浓烟飞行,爆炸产生的大量碎片,以及大自然中的烟、水、云等等。这些物体的逻辑结构很难表达,而且还会动态地变化。为了实现这些特殊的效果,必须构造一个粒子系统来表示这样的物体。粒子系统就是用大量的简单图元来表示某个物体,物体的多个特征,如:大小、颜色、位置以及粒子本身的生命周期都可以随机地改变,这样就可以真实地模拟自然。我下面使用面向对象的方法来实现一个粒子系统。

1.2粒子系统设计

一个粒子系统有大量粒子存在,每个粒子都有自己的属性,如大小,颜色,速度,运动方向等,粒子系统的功能即是用来管理这些粒子的状态,实时更新粒子的状态并负责显示它们。

下面是我定义的在direct3D中粒子的顶点结构:

struct Particle
{
D3DXVECTOR3 _position;
//粒子在三维世界的坐标
D3DCOLOR _color; //粒子顶点的颜色
float _size; //粒子大小
static const DWORD FVF;//顶点结构
};

以上仅仅是粒子的顶点结构,是direct3d渲染一个顶点所需要的数据,我将使用如下的数据结构来描述粒子的属性,对于粒子的创建,状态的更新,及销毁,都通过对粒子的这一结构的操作来实现,并最终将根据这个数据结构的内容更新Particle结构的内容从而实现对粒子的渲染。

struct Attribute
{
D3DXVECTOR3 _position;
//粒子的位置
D3DXVECTOR3 _velocity; //粒子的速度
D3DXVECTOR3 _acceleration; //粒子的加速度
float _lifeTime; //粒子还可以存活的时间
float _age; //粒子目前存活的时间
D3DXCOLOR _color; //粒子的颜色
D3DXCOLOR _colorFade; //粒子的颜色随时间流逝而衰退的度量
bool _isAlive; //粒子是否活着
};

如何管理粒子系统,跟踪粒子状态变化,更新粒子状态,我设计了下面一个类来实现:

class PSystem
{
public:
PSystem();
virtual ~PSystem();
//初始化粒子的纹理等
virtual bool init(IDirect3DDevice9* device, char* texFileName);
virtual void reset(); //重置粒子
virtual void resetParticle(Attribute* attribute) = 0;
//添加粒子
virtual void addParticle();
//更新粒子状态
virtual void update(float timeDelta) = 0;
//渲染前的准备工作
virtual void preRender();
//渲染粒子
virtual void render();
virtual void postRender();

bool isEmpty();
bool isDead();
protected:
virtual void removeDeadParticles();

protected:
IDirect3DDevice9
* _device;
D3DXVECTOR3 _origin;
d3d::BoundingBox _boundingBox;
float _emitRate;
float _size;
IDirect3DTexture9
* _tex;
IDirect3DVertexBuffer9
* _vb;
std::list
<Attribute> _particles;
int _maxParticles;

DWORD _vbSize;
DWORD _vbOffset;
DWORD _vbBatchSize;
};

以上的行为和属性是大多粒子系统都应该具备的,具有通用性,若要写一个自己的粒子系统,继承上面的类并重写相应的行为即可,可以大大节省开发时间。下面是一个下雪的粒子系统的截图:

posted @ 2011-03-20 19:17  华工微软俱乐部科技部  阅读(751)  评论(0编辑  收藏  举报