DirectX9.0 入门手册【转贴】

这一章我先不写有关DX的东西,我先从最基本的窗口创建讲起,然后再慢慢讲解使用DX的一些内容.

  我写这个指南的主要目的是为了学习。我希望自己可以通过写这个指南更快地学会DirectX。同时,我也希望为其他想学习的同伴提供一些学习资料。在编程方面,我并不是很强的人,再加上人总是会犯错的,如果我这些文字给贻笑大方的话,我接受大家对我提出有建设性的批评,如果你有更好的想法要和我交流,可以联系我:fowenler@126.com

下面正式开始吧,先讲窗口类,创建窗口,销毁窗口,窗口消息处理函数.

窗口类WNDCLASS

struct WNDCLASS {
    UINT style;
    WNDPROC lpfnWndProc;
    int cbClsExtra;
    int cbWndExtra;
    HINSTANCE hInstance;
    HICON hIcon;
    HCURSOR hCursor;
    HBRUSH hbrBackground;
    LPCSTR lpszMenuName;
    LPCSTR lpszClassName;
};


style:用来定义窗口的行为。如果打算共同使用GDI和D3D的话,可以使用CS_OWNDC作为参数。

lpfnWndProc:一个函数指针,指向与这个窗口类绑定在一起的处理窗口消息的函数。

cbClsExtra和cbWndExtra:为窗口和为分配内存空间。很少使用到这两个参数,一般设为0;

hInstance:应用程序的实例句柄。你可以使用GetModuleHandle()来得到它,也可以从Win32程序的入口函数WinMain那里得到它。当然,你也可以把它设为NULL(不知有什么用)

hIcon,hCursor,hbrBackground:设置默认的图标、鼠标、背景颜色。不过在这里设置这些其实并不怎么重要,因为我们可以在后面定制自己的渲染方法。

lpszMenuName:用来创建菜单

lpszClassName:窗口类的名字。我们可以通过这个名字来创建以这个窗口类为模板的窗口。甚至可以通过这个名字来得到窗口的句柄。

  设置好窗口类结构的内容后,使用RegisterClass(const WNDCLASS *lpWndClass)函数来注册它。关闭窗口后可以用UnregisterClass(LPCSTR lpClassName, HINSTANCE hInstance)来撤销注册。


创建窗口CreateWindow

HWND CreateWindow(
    LPCTSTR lpClassName,
    LPCTSTR lpWindowName,
    DWORD dwStyle,
    int x, y,
    int nWidth, nHeight,
    HWND hWndParent,
    HMENU hMenu,
    HINSTANCE hInstance,
    LPVOID lpParam
);


lpClassName:窗口类的名字。即窗口类结构体中的lpszClassName成员。

lpWindowName:如果你的应用程序有标题栏,这个就是你标题栏上显示的内容。

dwStyle:窗口的风格决定你的窗口是否有标题栏、最大最小化按钮、窗口边框等属性。在全屏的模式下,WS_POPUP|WS_VISIBLE是常用的设置,因为它产生一个不带任何东西的全屏窗口。在窗口的模式下,你可以设置很多窗口的风格,具体可以查看相关资料,这里不详细说明,不过WS_OVERLAPPED|WS_SYSMENU|WS_VISIBLE是一组常用的风格。

x和y:窗口创建的位置。(x,y)表示窗口的左上角位置。

nWidth和nHeight:用来设置窗口的宽度和高度,以像素为单位。如果你想创建一个全屏的窗口,使用GetSystemMetrics(SM_CXSCREEN)和GetSystemMetrics(SM_CYSCREEN)可以得到当前显示器屏幕的大小

hWndParent:指定这个新建窗口的父窗口。在D3D应用程序中很少用,一般设为NULL。

hMenu:菜单句柄。

hInstance:应用程序的实例句柄。你可以使用GetModuleHandle()来得到它,也可以从Win32程序的入口函数WinMain那里得到它。当然,你也可以把它设为NULL(不知有什么用)

lpParam:一个很神秘的参数。除非你知道自己在做什么,否则还是把它设为NULL吧。


销毁窗口DestroyWindow

  销毁窗口有两种方法,一种是隐式的,一种是显式的。我们都知道Windows操作系统是一个基于消息驱动的系统。流动于系统中的消息使我们的窗口跑起来。在很多软件开发特别是商业软件的开发过程中,窗口的产生和销毁都是交由系统去做的,因为这些不是这类开发的关注所在。但是游戏开发不一样,尽管你也可以只向系统发送一条WM_DESTROY消息来销毁窗口,我们还是希望窗口是销毁的明明白白的。由于窗口的注册、产生和使用都是由我们亲手来做的,那么当然窗口的销毁也得由我们亲自来做。不过还是得说明一点,使用WM_DESTROY消息和DestroyWindow函数来销毁窗口在本质上并无太大差别,使用哪种方法可以说是根据个人的爱好吧。

  销毁窗口后是不是就完事了呢?不,还没有,因为应用程序的消息队列里可能还有没处理完的消息,为了彻底的安全,我们还得把那些消息都处理完。所以结束应用程序的时候,可以使用以下方法:

MSG msg;
DestroyWindow(h_wnd);
while(PeekMessage(&msg , NULL , 0 , 0 , PM_REMOVE))
{
    TranslateMessage(&msg);
    DispatchMessage(&msg);
}



窗口消息处理过程

  窗口消息的处理函数是一个回调函数,什么是回调函数?就是由操作系统负责调用的函数。CALLBACK这个宏其实就是__stdcall,这是一种函数调用的方式,在这里不多说这些了,有兴趣的可以参考一些Windows编程的书籍,里面会有很详尽的说明。

  Windows里面有很多消息,这些消息都跑去哪里了呢?其实它们都在自己的消息队列里等候。消息是怎么从队列里出去的呢?就是通过GetMessage和PeekMessage这两个函数。那么消息从队列里出去后又到哪里了呢?嗯,这时候消息就正式进入了我们的窗口消息处理过程,也即是窗口类中lpfnWndProc所指定的函数。一个消息处理函数有四个参数,下面分别说说:

参数1:HWND p_hWnd

  消息不都是传到以窗口类为模板产生的窗口吗?为什么还要使用窗口句柄来指明窗口呢?别忘了一个窗口类是可以产生多个窗口的呀,如果一个应用程序里面有多个窗口,并且它们之中的一些窗口是共用一个窗口类的,那么就得用一个窗口句柄来指明究竟这个消息是哪个窗口发过来的。

参数2:UINT p_msg

  这是一个消息类型,就是WM_KEYDOWN , WM_CLOSE , WM_TIMER这些东东。

参数3:WPARAM p_wparam

  这个参数内容就是消息的主要内容。如果是WM_KEYDOWN消息,那么p_wparam就是用来告诉你究竟是哪个键被按下。

参数4:LPARAM p_lparam

  这个参数的内容一般是消息的一些附加内容。

  最后说明一下DefWindowProc的作用。有时候我们把一个消息传到窗口消息处理函数里面,但是里面没有处理这个消息的内容。怎么办?很容易,交给DefWindowProc处理就对了。

嗯,这一章就说到这了,下一章介绍如何创建D3D接口及如何使用D3D设备。


创建IDirect3D接口

  DirectX是一组COM组件,COM是一种二进制标准,每一个COM里面提供了至少一个接口,而接口就是一组相关的函数,我们使用DirectX,其实就是使用那些函数。COM和C++中的类有点像,只不过COM使用自己的方法来创建实例。创建COM实例的一般方法是使用coCreateInstance函数。有关coCreateInstance的使用方法,可以参考有关COM方面的资料,这里暂时不详细说明了,因为DirectX提供了更简洁的方法来创建DirectX组件的实例。这一章我要讲的就是Direct3D组件的使用方法。

  为了使用D3D中的函数,我们得先定义一个指向IDirect3D9这个接口的指针,顺便说明一下为什么要定义这个指针。首先,我们要知道接口的内容就是一些纯虚拟函数,所以接口是不能被实例化的,但是我们可以定义一个指向接口的指针。其次,我们要知道利用多态性我们可以使用一个基类指针来访问派生类中的方法。既然接口是不能被实例化的,那么我们肯定是使用从接口派生出来的类(或结构)的方法。怎么获到这个派生类的指针呢?就是通过之前定义的接口指针(也即是基类指针)来获得。所以我们所需做的就是把一个接口指针的地址传给某个函数,让这个函数来帮我们获到正确的派生类指针,这样我们就可以使用接口指针来做一些实际的东西了。实际上,我们只需要知道接口里面有什么方法以及它能完成什么工作就行了,至于这些方法是怎么实现的我们不必去关心。我们要做的就是定义一个接口指针,把它传给某个函数,函数使我们的接口指针有意义,接着我们使用接口,就这么简单。定义完这个接口指针后,例如IDirect3D9 *g_pD3D;现在我们使用Direct3DCreate9这个函数来创建一个D3D接口:

g_pD3D = Direct3DCreate9( D3D_SDK_VERSION );

Direct3DCreate9这个函数只有一个参数,它表明要创建接口的版本。如果你想创建一个老的接口版本当然也可以,不过没有人会那样做吧。

  创建接口后就可以创建D3D设备了,什么是D3D设备?你可以想象为你机上的那块显卡!什么?你有几块显卡!!没关系,那就创建多几个D3D设备接口吧。创建D3D设备需要的参数很多,如果把那些参数都挤在一个函数里面,那就太长了,所以就把一些参数放进结构体里面,只要先设定好这些结构体,再把这些结构体当作参数传给创建D3D设备的函数,那就清晰多了。首先要讲的就是D3DPRESENT_PARAMETERS这个结构。下面是它的定义:

struct D3DPRESENT_PARAMETERS{
    UINT BackBufferWidth;
    UINT BackBufferHeight;
    D3DFORMAT BackBufferFormat;
    UINT BackBufferCount;
    D3DMULTISAMPLE_TYPE MultiSampleType;
    DWORD MultiSampleQuality;
    D3DSWAPEFFECT SwapEffect;
    HWND hDeviceWindow;
    BOOL Windowed;
    BOOL EnableAutoDepthStencil;
    D3DFORMAT AutoDepthStencilFormat;
    DWORD Flags;
    UINT FullScreen_RefreshRateInHz;
    UINT PresentationInterval;
};


BackBufferWidth和BackBufferHeight:后备缓冲的宽度和高度。在全屏模式下,这两者的值必需符合显卡所支持的分辨率。例如(800,600),(640,480)。

BackBufferFormat:后备缓冲的格式。这个参数是一个D3DFORMAT枚举类型,它的值有很多种,例如D3DFMT_R5G6B5,这说明后备缓冲的格式是每个像素16位,其实红色(R)占5位,绿色(G)占6位,蓝色(B)占5位,为什么绿色会多一位呢?据说是因为人的眼睛对绿色比较敏感。DX9只支持16位和32位的后备缓冲格式,24位并不支持。如果对这D3DFORMAT不熟悉的话,可以把它设为D3DFMT_UNKNOWN,这时候它将使用桌面的格式。

BackBufferCount:后备缓冲的数目,范围是从0到3,如果为0,那就当成1来处理。大多数情况我们只使用一个后备缓冲。使用多个后备缓冲可以使画面很流畅,但是却会造成输入设备响应过慢,还会消耗很多内存。

MultiSampleType和MultiSampleQuality:前者指的是全屏抗锯齿的类型,后者指的是全屏抗锯齿的质量等级。这两个参数可以使你的渲染场景变得更好看,但是却消耗你很多内存资源,而且,并不是所有的显卡都支持这两者的所设定的功能的。在这里我们分别把它们设为D3DMULTISAMPLE_NONE和0。

SwapEffect:交换缓冲支持的效果类型,指定表面在交换链中是如何被交换的。它是D3DSWAPEFFECT枚举类型,可以设定为以下三者之一:D3DSWAPEFFECT_DISCARD,D3DSWAPEFFECT_FLIP,D3DSWAPEFFECT_COPY。如果设定为D3DSWAPEFFECT_DISCARD,则后备缓冲区的东西被复制到屏幕上后,后备缓冲区的东西就没有什么用了,可以丢弃(discard)了。如果设定为D3DSWAPEFFECT_FLIP,则表示在显示和后备缓冲之间进行周期循环。设定D3DSWAPEFFECT_COPY的话,我也不太清楚有什么作用*^_^*。一般我们是把这个参数设为D3DSWAPEFFECT_DISCARD。

hDeviceWindow:显示设备输出窗口的句柄

Windowed:如果为FALSE,表示要渲染全屏。如果为TRUE,表示要渲染窗口。渲染全屏的时候,BackBufferWidth和BackBufferHeight的值就得符合显示模式中所设定的值。

EnableAutoDepthStencil:如果要使用Z缓冲或模板缓冲,则把它设为TRUE。

AutoDepthStencilFormat:如果不使用深度缓冲,那么这个参数将没有用。如果启动了深度缓冲,那么这个参数将为深度缓冲设定缓冲格式(和设定后备缓冲的格式差不多)

Flags:可以设置为0或D3DPRESENTFLAG_LOCKABLE_BACKBUFFER。不太清楚是用来做什么的,看字面好像是一个能否锁定后备缓冲区的标记。

FullScreen_RefreshRateInHz:显示器的刷新率,单位是HZ,如果设定了一个显示器不支持的刷新率,将会不能创建设备或发出警告信息。为了方便,一般设为D3DPRESENT_RATE_DEFAULT就行了。

PresentationInterval:如果设置为D3DPRENSENT_INTERVAL_DEFAULT,则说明在显示一个渲染画面的时候必要等候显示器刷新完一次屏幕。例如你的显示器刷新率设为80HZ的话,则一秒内你最多可以显示80个渲染画面。另外你也可以设置在显示器刷新一次屏幕的时间内显示1到4个画面。如果设置为D3DPRENSENT_INTERVAL_IMMEDIATE,则表示可以以实时的方式来显示渲染画面,虽然这样可以提高帧速(FPS),但是却会产生图像撕裂的情况。


创建IDirect3DDevice界面

  当你把D3DPRESENT_PARAMETERS的参数都设置好后,就可以创建一个D3D设备了,和创建D3D接口一样,先定义一个接口指针IDirect3DDevice9 * g_pD3DDevice;然后使用D3D接口里面的CreateDevice函数来创建设备。CreateDevice的声明为:

HRESULT CreatDevice(
    UINT Adapter,
    D3DDEVTYPE DeviceType,
    HWND hFocusWindow,
    DWORD BehaviorFlags,
    D3DPRESENT_PARAMETERS *pPresentationParameters,
    IDirect3DDevice9** ppReturnedDeviceInterface
};


  第一个参数说明要为哪个设备创建设备指标,我之前说过一台机可以有好几个显卡,这个参数就是要指明为哪块显卡创建可以代表它的设备指标。但是我怎么知道显卡的编号呢?可以使用D3D接口里面的函数来获得,例如GetAdapterCounter可以知道系统有几块显卡;GetAdapterIdentifier可以知道显卡的具体属性。一般我们设这个参数为D3DADAPTER_DEFAULT。

  第二个参数指明正在使用设备类型。一般设为D3DEVTYPE_HAL。

  第三个参数指明要渲染的窗口。如果为全屏模式,则一定要设为主窗口。

  第四个参数是一些标记,可以指定用什么方式来处理顶点。

  第五个参数就要用到上面所讲的D3DPRESENT_PARAMETERS。

  第六个参数是返回的界面指针。


开始渲染

  有了设备接口指针,就可以开始渲染画面了。渲染是一个连续不断的过程,所以必定要在一个循环中完成,没错,就是第一章讲的那个消息循环。在渲染开始之前我们要用IDirect3DDevice9::Clear函数来清除后备缓冲区。

HRESULT Clear(
    DWORD Count,
    const D3DRECT *pRects,
    DWORD Flags,
    D3DCOLOR Color,
    float Z,
    DWORD Stencil
);

Count:说明你要清空的矩形数目。如果要清空的是整个客户区窗口,则设为0;

pRects:这是一个D3DRECT结构体的一个数组,如果count中设为5,则这个数组中就得有5个元素。它可以使我们只清除屏幕中的某一部分。

Flags:一些标记组合,它指定我们要清除的目标缓冲区。只有三种标记:D3DCLEAR_STENCIL , D3DCLEAR_TARGET , D3DCLEAR_ZBUFFER。 分别为清除模板缓冲区、清除目标缓冲区(通常为后备缓冲区)、清除深度缓冲区。

Color:清除目标区域所使用的颜色。

float:设置Z缓冲的Z初始值。小于或等于这个Z初始值的Z值才会被改写,但它的值只能取0到1之间。如果还不清楚什么是Z缓冲的话,可以自己找相关数据看一下,这里不介绍了,呵呵。

Stencil:设置范本缓冲的初始值。它的取值范围是0到2的n次方减1。其中n是范本缓冲的深度。

清除后备缓冲区后,就可以对它进行渲染了。渲染完毕,使用Present函数来把后备缓冲区的内容显示到屏幕上。

HRESULT Present(
    const RECT *pSourceRect,
    const RECT *pDestRect,
    HWND hDestWindowOverride,
    const RGNDATA *pDirtyRegion
);


pSourceRect:你想要显示的后备缓冲区的一个矩形区域。设为NULL则表示要把整个后备缓冲区的内容都显示。

pDestRect:表示一个显示区域。设为NULL表示整个客户显示区。

hDestWindowOverride:你可以通过它来把显示的内容显示到不同的窗口去。设为NULL则表示显示到主窗口。

pDirtyRegion:高级使用。一般设为NULL。


顶点属性与顶点格式

  顶点可谓是3D世界中的基本元素。在计算机所能描绘的3D世界中,任何物体都是由多边形构成的,可以是三边形,也可以是四边形等。由于三边形,即三角形所具有的特殊性质决定其在3D世界中得到广泛的使用。构成三角形需要三个点,这些点的性质就是这章所要讲的内容。

  也许你已经知道顶点的结构定义,你可能会奇怪为什么D3D会知道我们“随便”定义的那些结构呢?其实那些顶点的定义可不是那么随便的哦。下面列举在Direct3D中,顶点所具有的所有属性。

(1)位置:顶点的位置,可以分别指定x,y,x三个值,也可以使用D3DXVECTOR3结构来定义。

(2)RHW:齐次坐标W的倒数。如果顶点为变换顶点的话,就要有这个值。设置这个值意味着你所定义的顶点将不需要Direct3D的辅助(不能作变换、旋转、放大缩小、光照等),要求你自己对顶点数据进行处理。至于W是什么,W和XYZ一样,只是一个四元组的一部分。RHW的英文是Reciprocal of the Homogenous W,即1/W,它是为了处理矩阵的工作变得容易一些(呼,线性代数的东东快都忘了,要恶补一下才行)。一般设RHW的值为1.0。

(3)混合加权:用于矩阵混合。高级应用,这里不讲了(其实我不会,^_^)

(4)顶点法线:学过高等数学就应该知道法线是什么吧?在这里是指经过顶点且和由顶点引出的边相垂直的线,即和三角形那个面垂直。用三个分量来描述它的方向,这个属性用于光照计算。

(5)顶点大小:设定顶点的大小,这样顶点就可以不用只占一个像素了。

(6)漫反射色:即光线照射到物体上产生反射的着色。理解这个比较麻烦,因为3D光照和真实光照没什么关系,不能像理解真实光照那样去理解3D光照。

(7)镜面反射色:它可以让一个3D物体的表面看起来很光滑。

(8)纹理坐标:如果想要在那些用多边形组成的物体上面贴上纹理,就要使用纹理坐标。由于纹理都是二维的,所以用两个值就可以表示纹理上面某一点的位置。在纹理坐标中,只能在0.0到1.0之间取值。例如(0.0 , 0.0)表示纹理的左上角,(1.0 , 1.0)表示纹理的右下角。

  好了,请记住上面属性的顺序。我们定义一个顶点结构的时候,不一定要包括全部的属性,但是一定要按照上面的顺序来定义。例如:

struct MYVERTEX
{
    D3DXVECTOR3 position;
    float rhw;
    D3DCOLOR color;
}

上面定义了一个有漫反射色的变换顶点。

  定义完了顶点的结构后,我们就要告诉D3D我们定义的是什么格式。为了方便,我们通常会用#define来定义一个叫做描述“灵活顶点格式”(FVF:Flexible Vertex Format)的宏。例如:#define MYFVF D3DFVF_XYZ | D3DFVF_NORMAL。根据之前定义的顶点属性结构体,我们要定义相对应的宏。假如顶点结构中有位置属性,那么就要使用D3DFVF_XYZ;如果是变换顶点的话,就要使用D3DFVF_XYZRHW;如果使用了漫反射色属性的话,就要使用D3DFVF_DIFFUSE。这些值是可以组合使用的,像上面那样用“|”符号作为连结符。定义完灵活顶点格式后,使用IDirect3DDevice9::SetFVF函数来告诉D3D我们所定义的顶点格式,例如:g_pD3DDevice->SetFVF( MYFVF );


顶点缓冲

  处理顶点信息的地方有两个,一个是在数组里,另一个是在D3D所定义的顶点缓冲里。换个说法的话就是一个在我们所能直接操作的内存里,另一个在D3D管理的内存里。对于我们这些对操作系统底层了解不多的菜鸟来说,直接操作内存实在是太恐怖了,所以还是交给D3D帮我们处理吧,虽然不知道背后有些什么操作。要想把顶点信息交给D3D处理,我们就要先创建一个顶点缓冲区,可以使用IDirect3DDevice9->CreateVertexBuffer,它的原型是:

HRESULT CreateVertexBuffer(
    UINT Length,
    DWORD Usage,
    DWORD FVF,
    D3DPOOL Pool,
    IDirect3DVertexBuffer9** ppVertexBuffer,
    HANDLE* pSharedHandle
);

Length:缓冲区的长度。通常是顶点数目乘以顶点大小,使用Sizeof( MYVERTEX )就可以知道顶点的大小了。

Usage:高级应用。设为0就可以了。

FVF:就是我们之前定义的灵活顶点格式。

Pool:告诉D3D将顶点缓冲存储在内存中的哪个位置。高级应用,通常可取的三个值是:D3DPOOL_DEFAULT,D3DPOOL_MANAGED,D3DPOOL_SYSTEMMEM。多数情况下使用D3DPOOL_DEFAULT就可以了。

ppVertexBuffer:返回来的指向IDirect3DVertexBuffer9的指针。之后对顶点缓冲进行的操作就是通过这个指针啦。到这里还要再提醒一下,对于这些接口指针,在使用完毕后,一定要使用Release来释放它。

pSharedHandle:设为NULL就行了。

  得到一个指向IDirect3DVertexBuffer9的指针后,顶点缓冲也就创建完毕了。现在要做的就是把之前保存在数组中的顶点信息放在顶点缓冲区里面。首先,使用IDirect3DVertexBuffer9::Lock来锁定顶点缓冲区:

HRESULT Lock(
    UINT OffsetToLock,
    UINT SizeToLock,
    void **ppbData,
    DWORD Flags
);


OffsetToLock:指定要开始锁定的缓冲区的位置。通常在起始位置0开始锁定。

SizeToLock:指定在锁定的缓冲区的大小。设为0的话就是表示要锁定整个缓冲区。

ppbData:用来保存返回的指向顶点缓冲区的指针。通过这个指针来向顶点缓冲区填充数据。

Flags:高级应用。通常设为0。

填充为顶点缓冲区后,使用IDirect3DDevice9::Unlock来解锁。

最后在渲染的时候使用IDirect3DDevice9::SetStreamSource来告诉D3D要渲染哪个顶点缓冲区里面的顶点。

HRESULT SetStreamSource(
    UINT StreamNumber,
    IDirect3DVertexBuffer9 *pStreamData,
    UINT OffsetInBytes,
    UINT Stride
);


StreamNumber:设置数据流的数量。顶点缓冲最多可以使用16个数据流。确定所支持的数据流的数量,可以检查D3DCAPS中的MaxStreams成员的值。通常设为0,表示使用单数据流。

pStreamData:要与数据流绑定的数据。在这里我们要把顶点缓冲区与数据流绑定。

OffsetInBytes:设置从哪个位置开始读数据。设为0表示从头读起。

Stride:数据流里面数据单元的大小。在这里是每个顶点的大小。


索引缓冲

  很多时候,相邻的三角形会共用一些顶点,例如组成四方形的两个三角形就共用了一条边,即共用了两个顶点信息。如果不使用索引,我们需要六个顶点的信息来绘制这个四方形,但实际上绘制一个四方形只要四个顶点信息就足够了。如果使用了索引就不一样了,在顶点缓冲区里我们可以只保存四个顶点的信息,然后通过索引来读取顶点信息。要使用索引得先创建一个索引缓冲。也许读到这里你会有个疑问,创建一个索引缓冲不就更浪费内存空间了吗?其实不然,索引缓冲区的元素保存的是数字,一个数字所占用的内存肯定要比一个顶点所占用的小得多啦。当你节省了几千个顶点,你就会发现浪费那么一点点索引缓冲区是很值得的。

创建索引缓冲的函数是:IDirect3DDevice9::CreateIndexBuffer

HRESULT CreateIndexBuffer(
    UINT Length,
    DWORD Usage,
    D3DFORMAT Format,
    D3DPOOL Pool,
    IDirect3DIndexBuffer9** ppIndexBuffer
);


Length:索引缓冲区的长度。通常使用索引数目乘以sizeof(WORD)或sizeof(DWORD)来设置,因为索引号的数据类型是字节(WORD)或双字节(DWORD),嗯,一个WORD只有两个字节,DWORD也就只有四个字节,比顶点的大小小多了吧。

Usage:和CreateVertexBuffer中的Usage设置一样。一般设为0。

Format:设置索引格式。不是D3DFMT_INDEX16就是D3DFMT_INDEX32的啦。

Pool:又是和CreateVertexBuffer中的一样。一般设为D3DPOOL_DEFAULT。

ppIndexBuffer:指向IDirect3DIndexBuffer9的指针。操作索引缓冲区就靠它的啦。记得使用完后要Release啊。

和填充顶点缓冲区一样,要填充索引缓冲区,要先使用IDirect3DIndexBuffer9::Lock来锁定缓冲区。

HRESULT Lock(
    UINT OffsetToLock,
    UINT SizeToLock,
    void **ppbData,
    DWORD Flags
);


是不是和IDirect3DVertexBuffer9::Lock一样呢?具体说明也可以参照上面的内容。填充完之后使用IDirect3DIndexBuffer9::UnLock来解锁。

最后使用IDirect3DDevice9::SetIndices来告诉设备要使用哪个索引。

HRESULT Setindices(
    IDirect3DindexBuffer9* pIndexData,
    UINT BaseVertexIndex
);


pIndexData:设置使用哪个索引缓冲。

BaseVertexIndex:设置以顶点缓冲区中的哪个顶点为索引0。

有关顶点的知识就说到这了。一下章说说点、线、三角形这种D3D所支持的图元(drawing primitives)。


D3D中的图元简介

  在D3D中,一共有三种基本图元,分别是点、线和三角形。点是最简单的图元,由它可以构成一种叫点列(point list)的图元类型。线是由两个不重合的点构成的,一些不相连的线组成的集合就叫线列(line list),而一些首尾相连但不形成环路的线的集合就叫线带(line strips)。同理,单独的三角形集合就叫三角形列(triangle list),类似于线带的三角形集合就叫三角形带(triangle strips),另外,如果多个三角形共用一个顶点作为它们的一个顶点的话,那么这个集合就叫三角形扇(triangle fans)。还是画图比较容易理解吧:




  这些图元有什么用呢?基本上我们可以使用这些图元来画我们想要的任何物体。例如画一个四方形可以使用三角形带来画,画一个圆则使用三角形扇。

  现在介绍一种不需要顶点缓冲来渲染的方法,就是使用IDirect3DDevice9::DrawPrimitiveUP函数。UP就是User Pointer的意思,也即是说要使用用户定义的内存空间。

HRESULT DrawPrimitiveUP(
    D3DPRIMITIVETYPE PrimitiveType,
    unsigned int PrimitiveCount,
    const void *pVertexStreamZeroData,
    unsigned int VertexStreamZeroStride
);


PrimitiveType:要绘画的图元的种类。就是上面介绍的那六种类型。

PrimitiveCount:要绘画的图元的数量。假设有n个顶点信息,绘画的图元类型是点列的话,那么图元的数量就是n;如果绘画的图元类型是线列的话,那么图元的数量就是n/2;如果是线带的话就是n-1;三角形列就是n/3;三角形带就是n-2;三角形扇出是n-2。

pVertexStreamZeroData:存储顶点信息的数组指针

VertexStreamZeroStride:顶点的大小


使用顶点缓冲来绘画图元

  很多时候我们使用顶点来定义图形之后,就把这些顶点信息放进顶点缓冲里面,然后再进行渲染。使用点顶缓冲的好处以及如何创建顶点缓冲我已经在上一章已讲过了,现在讲讲怎么把顶点缓冲里面的图元给画出来。其实也很简单,和上面的IDirect3DDevice9::DrawPrimitiveUP函数差不多,我们使用IDirect3DDevice9::DrawPrimitive函数。不过在使用这个函数之前,我们得告诉设备我们使用哪个数据源,使用IDirect3DDevice9::SetStreamSource函数可以设定数据源。

HRESULT SetStreamSource(
    UINT StreamNumber,
    IDirect3DVertexBuffer9 *pStreamData,
    UINT OffsetInBytes,
    UINT Stride
);


StreamNumber:设置和哪个数据流梆定。如果使用单数据流的话,这里设为0。最多支持16个数据流。

pStreamData:要绑定的数据。也就是我们创建的顶点缓冲区里面的数据。

OffsetInBytes:设置从哪个字节开始读起。如果要读整个缓冲区里面的数据的话,这里设为0。

Stride:单个数据元素的大小。如果数据源是顶点缓冲的话,那么这里就是每个顶点信息的大小(Sizeof(vertex))。

设置好数据源后,就可以使用IDirect3DDevice9::DrawPrimitive来绘画了。

HRESULT DrawPrimitive(
    D3DPRIMITIVETYPE PrimitiveType,
    unsigned int StartVertex,
    unsigned int PrimitiveCount
);


PrimitiveType:要绘画的图元的种类。

StarVertex: 设置从顶点缓冲区中的第几个顶点画起。没有特殊情况当然是想把全部的顶点画出来啦,所以一般这里设置从0开始。

PrimitiveCount:要绘画的图元的数量。

  好了,这章比较简单。写到这章的时候我才发现这不是入门手册,有一些重要但是我觉得没必要讲的东西我都没有讲明。如果是新手看我写的这些东西,搞不好还会被我迷惑了,呵呵。所以还是建议大家看DXSDK里面的说明文档,虽然是英文的,但是很详细,我现在都还没有看完呢。

  嗯,前面四章把最基本的东西讲完了,使用前面的知识我们可以画一些简单的静止图形。下一章就开始讲矩阵了,它可以使我们的图形动起来。


向量(也叫矢量,英文叫vector)

  向量就是包含大小(长度)和方向的一个量。向量有2维的,也有3维甚至4维的。在DX的所有结构体中,有一个结构体是用来表示3维向量的,它就是D3DVECTOR,这个结构体很简单,只有三个成员:x、y、z。一般来说,如果不涉及到向量运算的话,用这个结构体来定义一个向量就可以了。我们可以它来表示方向以及顶点在3D世界中的位置等。如果你要对那些向量进行一些运算的话,使用D3DVECTOR就很不方便了,因为在D3DVECTOR这个结构体中没有重载任何的运算符,如果想要做一个加法运算,就得分别对结构体中的每一个成员进行运算了。嘿嘿,不用怕,在DX里面有个叫D3DX的东东(包含d3dx.h头文件),它里面定义了很多方便我们进行数学计算的函数和结构。其中就有D3DXVECTOR2,D3DXVECTOR3,D3DXVECTOR4这三个结构体。看它们的名字就应该知道它们的作用了吧。对于2维和4维的结构体这里就不讲了,其实它们也很简单,和D3DXVECTOR3差不多。不过要说明一点的是D3DXVECTOR3是从D3DVECTOR派生过来的,说明它和D3DVECTOR一样,有x、y、z这三个成员,除此之外,D3DXVECTOR3还重载了小部分算术运算符,这样我们就可以像对待整型那样对D3DXVECTOR3的对象进行加减乘除以及判断是否相等的运算了。同时,由于D3DXVECTOR3是从D3DVECTOR派生过来的,所以两者的对象可以互相赋值,在这两种类型中随便转换。

  还是简单说一下向量的数学运算吧。矢量的加减法很简单,就是分别把两个向量的各个分量作加减运算。向量的乘除法也很简单,它只能对一个数值进行乘除法,运算的结果就是向量中的各个分量分别对那个数值进行乘除法后得出的结果。向量的模就是向量的长度,就是各个分量的平方的和的开方。向量的标准化就是使得向量的模为1,这对在3D世界中实现光照是很有用的。对于向量的运算,还有两个“乘法”,那就是点乘和叉乘了。点乘的结果就是两个向量的模相乘,然后再与这两个向量的夹角的余弦值相乘。或者说是两个向量的各个分量分别相乘的结果的和。很明显,点乘的结果就是一个数,这个数对我们分析这两个向量的特点很有帮助。如果点乘的结果为0,那么这两个向量互相垂直;如果结果大于0,那么这两个向量的夹角小于90度;如果结果小于0,那么这两个向量的夹角大于90度。对于叉乘,它的运算公式令人头晕,我就不说了,大家看下面的公式自己领悟吧……

//v3 = v1 X v2
v3.x = v1.y*v2.z – v1.z*v2.y
v3.y = v1.z*v2.x – v1.x*v2.z
v3.z = v1.x*v2.y – v1.y*v2.x


  是不是很难记啊,如果暂时记不了就算了。其实我们主要还是要知道叉乘的意义。和点乘的结果不一样,叉乘的结果是一个新的向量,这个新的向量与原来两个向量都垂直,至于它的方向嘛,不知大家是否还记得左手定则。来,伸出你的左手,按照第一个向量(v1)指向第二个向量(v2)弯曲你的手掌,这时你的拇指所指向的方向就是新向量(v3)的方向了。通过叉乘,我们很容易就得到某个平面(由两个向量决定的)的法线了。

  终于写完了上面的文字,描述数学问题可真是费劲,自己又不愿意画图,辛苦大家了。如果你觉得上面的文字很枯燥,那也没关系。因为上面的不是重点,下面介绍的函数才是希望大家要记住的。

  D3DX中有很多很有用的函数,它们可以帮助我们实现上面所讲的所有运算。不过下面我只说和D3DXVECTOR3有关的函数:

计算点乘:FLOAT D3DXVec3Dot(
   
CONST D3DXVECTOR3* pV1,
   
CONST D3DXVECTOR3* pV2)

计算叉乘:D3DXVECTOR3* D3DXVec3Cross(
   
D3DXVECTOR3* pOut,
   
CONST D3DXVECTOR3* pV1,
   
CONST D3DXVECTOR3* pV2)

计算模:FLOAT D3DXVec3Length(
   
CONST D3DXVECTOR3* pV)

标准化向量:D3DXVECTOR3* D3DXVec3Normalize(
   
D3DXVECTOR3* pOut,
    CONST D3DXVECTOR3 pV)

对于D3DXVECTOR3的加减乘除运算,上面已经讲了,用+ - * / 就行了。


矩阵与矩阵运算

  什么是矩阵?这个概念还真不好解释,不过学过线性代数的人肯定都知道矩阵长什么样,那我在这里就不解释了。在D3D中,定义矩阵的结构体是D3DMATRIX:

typedef struct _D3DMATRIX {
    union {
        struct {
            float _11, _12, _13, _14;
            float _21, _22, _23, _24;
            float _31, _32, _33, _34;
            float _41, _42, _43, _44;
        };
        float m[4][4];
    };
} D3DMATRIX;


  看这个结构的样子,你就应该很清楚怎么使用它来定义一个矩阵了吧。在这里我顺便说一下C++中union的特性吧。像上面定义的结构体所示,在union里面有两个部分,一个是结构体,另一个是二维数组,它有16个元素。在union中,所有的成员都是共用一个内存块的,这是什么意思呢?继续看上面的代码,结构体中的成员_11和成员m数组的第一个元素是共用一个内存空间,即它们的值是一样的,你对_11赋值的同时也对m[0][0]进行了赋值,_11和m[0][0]的值是一样的。这样有什么好处呢?比如你定义了一个矩阵变量D3DMATRIX mat;你想访问矩阵中第三行第四列的元素,可以这样做:mat._34;另外也可以这样:mat.m[2][3](数组是从位置0开始储存的哦)。看起来使用后者比较麻烦,不过当你把中括号里面的数换成i和j,使用mat.m[i][j]来访问矩阵中的元素,你就应该知道它的好处了吧。

  实际上直接使用D3DMATRIX的情况不多,因为在D3DX中有个更好的结构体,那就是D3DXMATRIX。和D3DXVECTOR3相似,D3DXMATRIX是从D3DMATRIX继承过来的,它重载了很多运算符,使得矩阵的运算很简单。矩阵的运算方法我不打算多说了,下面只介绍和矩阵性质有关的三个函数。

产生一个单位矩阵:D3DXMATRIX *D3DXMatrixIdentity(
   
D3DXMATRIX *pout);//返回结果

求转置矩阵:D3DXMATRIX *D3DXMatrixTranspose(
   
D3DXMATRIX *pOut,//返回的结果
   
CONST D3DXMATRIX *pM );//目标矩阵

求逆矩阵:D3DXMATRIX *D3DXMatrixInverse(
   
D3DXMATRIX *pOut,//返回的结果
   
FLOAT *pDeterminant,//设为0
    CONST D3DXMATRIX *pM );//目标矩阵

  至于什么是单位矩阵,什么是转置矩阵,什么是逆矩阵我就不说了,可以看一下线性代数的书,一看就明白了。简单的加减乘除法可以使用D3DXMATRIX结构体里面重载的运算符。两个矩阵相乘也可以用函数来实现,这将在接下来的矩阵变换中讲到。


矩阵变换

矩阵的基本变换有三种:平移,旋转和缩放。

平移:
D3DXMATRIX *D3DXMatrixTranslation(
    D3DXMATRIX* pOut,//返回的结果
    FLOAT x, //X轴上的平移量
    FLOAT y, //Y轴上的平移量
    FLOAT z) //Z轴上的平移量

绕X轴旋转:
D3DXMATRIX *D3DXMatrixRotationX(
    D3DXMATRIX* pOut, //返回的结果
    FLOAT Angle //旋转的弧度
);

绕Y轴旋转:
D3DXMATRIX *D3DXMatrixRotationY(
    D3DXMATRIX* pOut, //返回的结果
    FLOAT Angle //旋转的弧度
);

绕Z轴旋转:
D3DXMATRIX *D3DXMatrixRotationZ(
    D3DXMATRIX* pOut, //返回的结果
    FLOAT Angle //旋转的弧度
);

绕指定轴旋转:
D3DXMATRIX *D3DXMatrixRotationAxis(
    D3DXMATRIX *pOut,//返回的结果
    CONST D3DXVECTOR3 *pV,//指定轴的向量
    FLOAT Angle//旋转的弧度
);

缩放:
D3DXMATRIX *D3DXMatrixScaling(
    D3DXMATRIX* pOut, //返回的结果
    FLOAT sx, //X轴上缩放的量
    FLOAT sy, //Y轴上缩放的量
    FLOAT sz //Z轴上缩放的量
);


  好了,这章就写这么一些东西。如果你觉得好像没学到什么的话,可能是因为不知道上面的知识有什么用吧。下一章我将介绍世界空间、视图空间(也叫摄像机空间)以及投影,这三者对应的是世界矩阵、视图矩阵和投影矩阵。搞清楚这三个空间的作用后,我们就可以利用这章的知识使我们的3D世界动起来了。



  无论计算机图形技术如何发展,只要它以二维的屏幕作为显示介质,那么它显示的图像即使多么的有立体感,也还是二维的。有时我会想,有没有以某个空间作为显示介质的的可能呢,不过即使有,也只能是显示某个范围内的图像,不可能有无限大的空间作为显示介质,如果有,那就是现实世界了。

  既然显示器的屏幕是二维的,那么我们就要对图像作些处理,让它可以欺骗我们的眼睛,产生一种立体的真实感。在D3D中,这种处理就是一系列的空间变换,从模型空间变到世界空间,再变到视图空间,最后投影到我们的显示器屏幕上。


世界空间与世界矩阵

  什么是模型空间呢?每个模型(3D物体)都有它自己的空间,空间的中心(原点)就是模型的中心。在模型空间里,只有模型上的不同点有位置的相对关系。那什么是世界空间呢?世界就是物体(模型)所存在的地方。当我们把一个模型放进世界里面去,那么它就有了一个世界坐标,这个世界坐标是用来标记世界中不同的模型所处的位置的。在世界空间里,世界的中心就是原点(0, 0, 0),也就是你显示器屏幕中间的那一点。我们可以在世界空间里摆放很多个模型,并且设置它们在世界空间中的坐标,这样模型与模型之间就有了相对的位置。

  世界矩阵有什么用呢?我们可以利用它来改变世界空间的坐标。这样,在世界空间里面的模型就可以移动、旋转和缩放了。

  我们可以使用上一章末尾所讲的那几个函数来产生世界矩阵。例如产生一个绕X轴旋转的转阵:D3DXMatrixRotationX(&matrix,1)。利用matrix这个矩阵,就可以使世界空间中的物体绕X轴转动1弧度。

  可以结合后面的例子来理解世界矩阵。


视图空间与视图矩阵

  世界空间建立起来后,我们不一定能看到模型,因为我们还没有“眼睛”啊。在视图空间里,我们可以建立我们在三维空间中的眼睛:摄像机。我们就是通过这个虚拟的摄像机来观察世界空间中的模型的。所以视图空间也叫摄像机空间。

要建立起这个虚拟的摄像机,我们需要一个视图矩阵,产生视图矩阵的一个函数是:

D3DXMATRIX *D3DXMatrixLookAtLH(
    D3DXMATRIX* pOut,
    CONST D3DXVECTOR3* pEye,
    CONST D3DXVECTOR3* pAt,
    CONST D3DXVECTOR3* pUp
);


pOut:返回的视图矩阵指针

pEye:设置摄像机的位置

pAt:设置摄像机的观察点

pUp:设置方向“上”

  这个函数的后缀LH是表示左手系的意思,聪明的你一定能够猜出肯定有个叫D3DXMatrixLookAtRH的函数。至于左手系和右手系的区别,这里就不多说了,记住左手系中的Z正方向是指向显示器里面的就行了。只能弄懂了视图矩阵的含义,建立视图矩阵完成可以不依赖函数,自己手动完成。视图矩阵其实就是定义了摄像机在世界空间中的位置、观察点、方向“上”这些信息。

可以结合后面的例子来理解视图矩阵。


投影与投影矩阵

定义投影矩阵很像是定义摄像机的镜头,下面看它的函数声明:

D3DXMATRIX *D3DXMatrixPerspectiveFovLH(
    D3DXMATRIX* pOut,
    FLOAT fovY,
    FLOAT Aspect,
    FLOAT zn,
    FLOAT zf
);


pOut:返回的投影矩阵指针

fovY:定义镜头垂直观察范围,以弧度为单位。对于这个参数,下面是我的理解:如果定义为D3DX_PI/4(90度角),那么就是表示以摄像机的观察方向为平分线,上方45度角和下方45度角就是摄像机所能看到的垂直范围了。嗯,可以想象一下自己的眼睛,如果可以把自己眼睛的fovY值设为D3DX_PI/2(180度角),那么我们就可以不用抬头就看得见头顶的东西了。如果设为D3DX_PI的话。。。我先编译一下试试(building…)。哈哈,结果啥也看不见。很难想象如果自己能同时看到所有方向的物体,那么将是一个怎样的画面啊。

Aspect:设置纵横比。如果定义为1,那么所看到的物体大小不变。如果定义为其它值,你所看到的物体就会变形。不过一般情况下这个值设为显示器屏幕的长宽比。(终于明白为什么有些人会说电视上的自己看起来会比较胖了……)

zn:设置摄像机所能观察到的最远距离

zf:设置摄像机所能观察到的最近距离

·一小段代码

请看以下代码片段:

D3DXMATRIXA16 matWorld;
D3DXMatrixIdentity( &matWorld );
D3DXMatrixRotationX( &matWorld, timeGetTime()/1000.0f );
g_pd3dDevice->SetTransform( D3DTS_WORLD, &matWorld );
D3DXVECTOR3 vEyePt( 0.0f, 3.0f,-5.0f );
D3DXVECTOR3 vLookatPt( 0.0f, 0.0f, 0.0f );
D3DXVECTOR3 vUpVec( 0.0f, 1.0f, 0.0f );
D3DXMATRIXA16 matView;
D3DXMatrixLookAtLH( &matView, &vEyePt, &vLookatPt, &vUpVec );
g_pd3dDevice->SetTransform( D3DTS_VIEW, &matView );
D3DXMATRIXA16 matProj;
D3DXMatrixPerspectiveFovLH( &matProj, D3DX_PI/2, 1.0f, 1.0f, 500.0f );
g_pd3dDevice->SetTransform( D3DTS_PROJECTION, &matProj );

通过上面三个转换,就建立了一个我们可以通过显示器屏幕来观察的3D世界。上面三个转换分别是:

从模型空间到世界空间的世界转换:SetTransform( D3DTS_WORLD, &matWorld )。

从世界空间到视图空间的视图转换:SetTransform( D3DTS_VIEW, &matView )。

从视图空间到到屏幕的投影转换:SetTransform( D3DTS_PROJECTION, &matProj )。

  现在来观察matWorld,matView,matProj这三个矩阵的特点。我们使用D3DXMatrixRotationX函数来产生了一个绕X轴旋转的转换矩阵,通过设置世界转换,在世界空间里面的物体将绕X轴作旋转。然后我们定义了三个三维的向量,用来设置摄像机的位置,观察方向和定义方向“上”。使用D3DXMatrixLookAtLH函数来把这三个向量放进视图矩阵里面去。然后通过设置视图转换,我们就建立了一个虚拟的摄像机。最后通过D3DXMatrixPerspectiveFovLH函数,我们得到一个投影矩阵,用来设置虚拟摄像机的镜头。

  我还是解释一下上面说的那个方向“上”是什么东西吧。这个“上”其实指的就是摄像机在刚建立的时候是如何摆放的,是向左边侧着摆,还是向右边侧着摆,还是倒过来摆,都是通过这个方向“上”来指定的。按照正常的理解,摄像机的“上”方向就是Y轴的正方向,但是我们可以指定方向“上”为Y轴的负方向,这样世界建立起来后就是颠倒的了。不过颠倒与否,也是相对来说的了,试问在没有引力的世界中,谁能说出哪是上哪是下呢?是不是看得一头雾水啊?只要自己亲手改变一下这些参数,就可以体会到了。

  设置上面三个转换的先后顺序并不一定得按照世界到视图到投影这个顺序,不过习惯上按照这种顺序来写,感觉会好一点。


使用矩阵相乘来创建世界矩阵

  在世界空间中的物体运动往往是很复杂的,比如物体自身旋转的同时,还绕世界的原点旋转。怎么实现这种运动呢?通过矩阵相乘来把两个矩阵“混”在一起。现在我们假设某一物体建立在世界的原点上,看以下代码:

//定义三个矩阵
D3DXMATRIX matWorld, matWorldY,matMoveLeft;

//一个矩阵把物体移到(30,0,0)处,一个矩阵使物体绕原点(0,0,0)旋转
D3DXMatrixTranslation(&matMoveRight,30,0,0);
D3DXMatrixRotationY(&matWorldY, radian/1000.0f);

//第一次矩阵相乘。先旋转,再平移
D3DXMatrixMultiply(&matWorld, &matWorldY, &matMoveRight);

//第二次矩阵相乘。在第一次矩阵相乘的结果上,再以Y轴旋转
D3DXMatrixMultiply(&matWorld, &matWorld, &matWorldY);

//设置世界矩阵
m_pD3DDevice->SetTransform( D3DTS_WORLD, &matWorld );


  矩阵相乘的时候,矩阵的先后顺序很重要,如果顺序弄错了,物体就不会按我们预料的那样运动。从最后一次矩阵相乘看起,最后相乘的两个矩阵是matWorld和matWorldY,其中matWorld又是由matWorldY和matMoveRight相乘得来的,那么这三个矩阵相乘的顺序就是(matWorldY,matMoveRight,matWorldY)。这个顺序意味着什么呢?第一个matWorldY使物体绕Y轴旋转,这时候的物体还处于原点,所以它绕Y轴旋转也就是绕自身的旋转。它转呀转呀,这时候matMoveRight来了,它把物体从(0,0,0)移到了(30,0,0),这时候物体就不再是绕Y轴旋转了,它是在(30,0,0)这个位置继续绕自身旋转。然后matWorldY又来了,它使物体再次以Y轴旋转,不过此时物体不在原点了,所以物体就以原点为中心作画圆的运动(它自身的旋转仍在继续),这个圆的半径是30。如果换一个顺序,把matMoveRight放在第一的话,那么就是先移动再旋转再旋转(第二次旋转没用),这时候物体就只是画圆运动而已,它自身没有旋转。如果把matMoveRight放在最后,那么就是先旋转再旋转(第二次旋转没用)再移动,这时候物体就没有作画圆运动了,它只是在(30,0,0)这个位置上作自身旋转。好了,理解这个需要一点点想象力。你可以先写好几个矩阵相乘的顺序,自己想象一下相乘的结果会使物体作什么运动,然后再编译执行程序,看看物体的运动是不是和自己想像中的一样,这样可以锻炼自己的空间思维能力。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值