我对DX11的理解和简化框架与快速游戏制作
http://blog.csdn.net/carl001002/article/details/6917968
网上关于DX11的文章很多,tut也很容易google到,可有用的就只是些基本的功能性的介绍。其实DX11是紧跟NV的硬件而变的,微软做的就是DEBUG而已(用DX指令或称为语言来和硬件通讯)。至于OGL也做的是同样的工作。微软的范例是基本的功能语言过多的钻研或可能就是浪费生命了,你是跟不上DX的更新节奏的。我做的是打好个基本框架来适应DX、OGL的各种版本的测试包括发布应用,其中也涉及到一个用于的测试游戏范例和一个关卡编辑器。
先来看DX都干什么使的。显卡主要的工作就是显示2D图片和3D内容。它接受的数据类型只有一个“字节”或称为buffer或byte.很多人被包装后的程序弄的无所侍从,去讨论DX和OGL的性能比较之类的。还学习一些很快过时的语法。通过整理可以发现DX数据主要有几大类,1、Buffer类DX9 叫vertex/index;后来又DX10的 constant / struct buffer其实都一个意思buffer等于byte。2、resource类 texture2D/3D/Cube 一样=byte.3、变量类 指的是用户输入的浮点数据也是BYTE。4、运算指令 通过文本形式再经过编译变成机器语言来告诉显卡如何处理这些输入的数据。GSSL、openCL,HLSL和NV的编译器都是干同样的事编译出的机器语言的字节应该是完全相等的,编译后的字节排列标准由硬件厂商制定。
微软的DX输入字节的标准通过打包后的BUffer一次性传入显卡,效能在早期比OGL用CPU逐个输入要高效很多。
所以在我设计程序时就考虑到要做到程序的类型无关性,比如Texture类的制定就只是字节,无论是DX9或DX11都可以读写,存储也是一样,控制大小也很重要,尽量都转化为DDS来存储jpg就直接存了。
Mesh类比较复杂,由于每个模型我都规定了选中属性,所以都有个碰触检查特性。OCTREE我测试下来是最简化的高速的方式,因为模型始终被视为是动态的,测试中我也用CPU Raytrace 详细对比了效能,OCTREE 在单个BOX包含模型面数不多的时候和BSP完全等速的或高于它,当然GRID才是最好,在我以后的GPUraytrace介绍中就有它了。存储也不能一概都存,程序模型就简单的几个点就成。
还有个比不可少的Input类负责识别键盘鼠标输入。
相机,必须有个万能的好用的操作感好的相机部件可以快速查看场景中任何模型的角落。我参考的是MAX的相机。可以快速改造成可奔跳、碰闯的行走模式。
场景类,可以多窗口的快速和windows form (HWND)对接的类。
空间变形。所有应用到的BoundBOX/BoundSphere/BoundFrusm/Plane/Matrx/Vector/Dynamic类都包含其中计算公式也完全脱离dxmesh的类的调用全部自己数学了.
这些基本类都用极其简练的语法描述。为将来有些新想法准备的。翻查过去写的东西应该是种乐趣而不是恶梦。
未来所有的程序几乎都引用上面的类作为基础类。可以输出独立的 C++/C#应用程序。
我个程序都保持了和C#/C++的语言的双关性完全不用从写任何部件就可以在C#中直接使用我的C++的类。我个人更倾向C#它极大的简化了程序的编译时间,和选交通工具一样我选择飞机而不是马车,由于C#中使用的完全是C++的指针,所以在c#环境下可以与c++环境下的运算效能完全相等或更好更稳定。
先罗唆这些。第一次写博客了。是真的第一次了。
下面是我的应用程序的渲染画面。模型使用的SKTCHUP中调入和程序模型这也保证了渲染的无关性,SKP没有渲染功能更别说烘培了。
下面先介绍如何制作个基于“框架”的窗口用法(包扩C++/C#)
初始化DX设备需要个基本的hanlle(HWND)当然也可以没有,这种情况发生在DX11平台上同时使用DX10和DX9的资源(这个在以后有说明),DX11没有2D文字和绘图的部件需要同步共享DX10的一些东西。
#include “DXView.h”
#define NAME “form”
#define TITLE “GAME_DX11”
在C++中几乎总是重复使用同一段代码来创建窗口:
Device device;DXView gdevice;
int WINAPI WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
HWND hwnd;
WNDCLASS wc= {
CS_HREDRAW | CS_VREDRAW,WndProc,0,0, hInstance,0,LoadCursor(0, IDC_ARROW ),0,0,NAME};
RegisterClass( &wc );
hwnd = CreateWindowEx(0,NAME,TITLE,WS_OVERLAPPEDWINDOW,CW_USEDEFAULT, CW_USEDEFAULT,
880, 600, NULL, NULL, hInstance, NULL );
ShowWindow( hwnd, nCmdShow );
UpdateWindow( hwnd );
MSG msg = {0};
while( WM_QUIT != msg.message )
{
if( PeekMessage( &msg, NULL, 0, 0, PM_REMOVE ) )
{
TranslateMessage( &msg );
DispatchMessage( &msg );
}
else
gdevice.Render();
}
return msg.wParam;
}
这里比普通代码多了个 Device的类的引用:Device我把它作为显示设备的统一管理的特殊的类,它没有规定必须用什么来显DX11、DX10、DX9、OGL、由它的派生的类来决定用由谁来提供显示。但使用的handle和resource和代码都是统一的。把Device分离管理的另一个好处是可以无限制的生成N个窗口来同时显示不同的内容而不用从新设定Device的资源。DXView是我专门为DX11提供显示的基础类;它调用Device里的资源。在后面的游戏介绍中DXView 会被简单的更名为GameView
它的内容就是个完全的游戏平台了,你不用更改任何其他代码的名称和用法。
C#:
1、先建立个空白项目;2、添加个基本的Form窗口;
3;创建个普通的Class;class 的内容如下:
using System;
using DX;
namespace Game
{
public class DXView:SpliteDevice
{
public override void Initialize()
{ 用于初始化DX资源 }
public override void OnFrameChanging()
{ 用于更新游戏的时间事件 (1 / FrameRate); }
public override void HandleInput()
{
用于使用输入事件
}
public override void Draw()
{
Clear(Vector4.Sky);
device.Sprite.GetSize(chain.Viewport);
}
public override void OnDisposing()
{
用于程序退出卸载无用的资源。
base.OnDisposing();
}
}
}
把这个控件托到先前定义的form中就有了你的第一个DX窗口了。
在C#中为了灵活的管理,我把Device的HWND捆绑在个基本的Control类中,你可以象控制其他任何form的部件一样去添加和调整它的窗口形式。
下面就是使用gameView的DXView的内容。
注意上面的Sprite是我专门为显示2D内容而定制的类,它没有使用DX内置的sprtieBatch 它的定制矩阵用法十分混乱,也很难在SHader中正常使用,CPU的运算更是无聊的多。虽然它是Surface层面的东西,包装的太重了。我的这个轻巧的Sprite类可以胜任任何2D显示和2D游戏的要求,即时用CPU渲染也仍然很快。
后面的章节是详细解释Device的工作原理。
下面就先讲述下如何创建第一个基于DX11的窗口程序。(这是基于我的“简化框架”的流程设计)
首先建立个名为“DX.h”的头文件,我的框架程序程序不包含任何”. ccp”文件也不生成任何lib和DLL,但需要在VS的环境变量中引用它的目录。这样是为了保证与C#的语法完全相等。
在头文件中调入下面的库:
//DX11所必须
#pragma comment (lib, “dxgi.lib”)
#pragma comment (lib, “d3d11.lib”)
#pragma comment (lib, “d3dx11.lib”)
#pragma comment (lib, “d3dcompiler.lib “)
//DX10.1—D2D所必须
#pragma comment (lib, “D3D10_1.lib”)
#pragma comment (lib, “D2D1.lib”)
#pragma comment (lib, “dwrite.lib”)
//windows变量的使用必须
#pragma comment(lib, “winmm.lib”)
在以后的工程文件中再也不需要引用以上和以下的内容了,它已经包含再全局中
#include <initguid.h>
#include <dxgi.h>
#include <D3D10_1.h>
#include <d3d11.h>
#include <d3dx11.h>
#include <d3dcompiler.h>
#include <D2D1.h>
#include <dwrite.h>
#include <windows.h>
#include <mmsystem.h>
#include <stdio.h>
#include <io.h>
#include <string>
#include <sstream>
#include <iostream>
#include <fstream>
#include <math.h>
#ifndef Kill
#define Kill(p) { if (p) { (p)->Release(); (p)=NULL; } }
#endif
#include”Device.h”//以后需要用的DX11类
#include”SwapChain.h”
现在需要创建个DX11的容器来包含DX11的一些必要的指针。任何一个DX程序都需要个DEVICE设备来指向它所关联的资源。
创建个名为”Device.h”的头文件
struct Device
{
ID3D11Device* dev;//device 设备
ID3D11DeviceContext* context;//device 资源管理
ID3D11RasterizerState* cullback;//着色模式
ID3D11RasterizerState* cullnone;
ID3D11RasterizerState* cullfront;
ID3D11DepthStencilState* stateA;//深度模式
ID3D11DepthStencilState* stateB;
ID3D11BlendState* stateC;;//混合模式
ID3D11BlendState* stateD;
ID3D11BlendState* stateE
IDXGIAdapter1 *Adapter;//显示卡
IDXGIFactory1* factory;//用于D2D的显示设备总和
HRESULT Create()//创建Device设备 {
HRESULT hr = CreateDXGIFactory1(__uuidof(IDXGIFactory1), (void**)&factory);
hr =factory->EnumAdapters1(0, &Adapter);
hr = D3D11CreateDevice(Adapter, D3D_DRIVER_TYPE_UNKNOWN, 0, D3D11_CREATE_DEVICE_BGRA_SUPPORT,0, 0, D3D11_SDK_VERSION,
&dev,0,&context);
CreateState();//创建着色和混合等预定模式
context->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST );//给显卡一个预定的基础渲染方式以避免显卡设备的丢失和黑屏。
return hr;
}
void Dispose()//必须清理程序退出后不用的资源
{
Kill(Adapter);Kill(factory); Kill(cullback);Kill(cullnone); Kill(cullfront); Kill(stateA);Kill(stateB);Kill(stateC);
Kill(stateD);Kill(stateE); context->ClearState(); Kill(context);Kill(dev);
}
这里只是建立了显示DX资源的基础的设备,以后是几乎都不会修改到这些代码也无需为每个项目都重新粘贴他们。
未完待续
以后会一步步讲述直到完成下面的游戏。。。
有个基本的DEVICE设备后后面就是如何利用这个设备来显示图形了。swapchain的概念来是利用双缓冲的办法来解决窗口刷新的办法,也可以利用这个思路解决FORM的控件闪动的问题。道理就不细说了,都是十几年前老掉牙的话题。先建立个”SwapChain.h”的头文件:
struct SwapChain
{
Device device;//显示设备
IDXGISwapChain* chain;//渲染缓冲区
ID3D11RenderTargetView* rtv;//渲染内容的Buffer
ID3D11DepthStencilView* dsv;//深度信息的Buffer
D3D11_TEXTURE2D_DESC desc;//当前swapchain的描述
HWND handle;//捆绑的窗口
void Create(Device dev,HWND hWnd,int sample)//sample的选项是设定抗锯齿的级别根据显卡可以是1-2-4-8-16-32
{
device = dev;
handle=hWnd;
Viewport vp =Viewport(hWnd);
device.SetViewport(vp);
desc.Width = vp.Width;
desc.Height =vp.Height;
desc.SampleDesc.Count = sample;
desc.SampleDesc.Quality = 0;
desc.MipLevels = 1;
desc.ArraySize = 1;
desc.Format = DXGI_FORMAT_D32_FLOAT;
desc.Usage = D3D11_USAGE_DEFAULT;
desc.BindFlags = D3D11_BIND_DEPTH_STENCIL;
desc.CPUAccessFlags = 0;
desc.MiscFlags = 0;
CreateSwapChain(hWnd);
CreateChainRD();//生成渲染的Buffer
}
void CreateChainRD()
{
device.CreateDSV(dsv,desc);
device.CreateRTV(rtv,chain);
device.SetRenderTarget(1,rtv, dsv);
}
void Clear(Vector4 c)//清空前次渲染
{
device.ClearRTV(rtv, c);
device.ClearDSV(dsv, D3D11_CLEAR_DEPTH);
}
void Present() { chain->Present(0,0); }
void Dispose(){ Kill(chain);Kill( dsv);Kill( rtv); }
void Resize()// 当窗口大小变化事件
{
Viewport vp =Viewport(handle);
if(vp.Width<16||vp.Height<16)return;
desc.Width = vp.Width;
desc.Height =vp.Height;
device.SetViewport(vp);
Kill( dsv);Kill( rtv);
DXGI_SWAP_CHAIN_DESC Desc;
ZeroMemory( &Desc, sizeof( DXGI_SWAP_CHAIN_DESC ) );
chain->GetDesc(&Desc);
desc.Width= Desc.BufferDesc.Width=vp.Width;
desc.Height= Desc.BufferDesc.Height=vp.Height;
chain->ResizeBuffers(Desc.BufferCount,desc.Width,desc.Height,
Desc.BufferDesc.Format,Desc.Flags);
chain->ResizeTarget(&Desc.BufferDesc);
CreateChainRD();
}
上面的语法描述极其简单但它适应了所有的显示情况。我要做的就是把它捆绑到它的渲染工作区上了。
下面的图片使用了程序网格和两个程序创建的模型,文字的内容是相机的矩阵和汉字字体的支持。
前面已经具备了设备层(DEVICE)显示层(SwapChain)现在就可以尝试测试显示的结果了:
添加个”SpliteDevice.h”的头文件,内容如下(注意这里也保证了语法的无关性和可读性):
很大一批程序员愿意使用gMyRenderDeviceAbstract这样的命名方式可能它根本就不想自己读懂它!
还比如#define abc(a,c){ a=c; }这种定义,程序员的智商就不能再低点嘛?这些在即使是NV的官方范例中也屡见不鲜!
一个类的名称描述应该是尽量贴近它所带来的结果。
struct SpliteDevice
{
Device device; //设备
SwapChain chain;//缓冲区
int sample,Startframe;//AA
float frameRate;//祯速率
InputState Input;//输入
void Create(Device dev,HWND hwnd,int sampleCount)
{
frameRate = 60;
sample = sampleCount;
device=dev;
chain.Create(dev,hwnd,sample);
Input.viewport=Viewport(chain.desc.Width,chain.desc.Height);//把窗口信息放在输入中便于其他下属类的访问Device.GetViewport()并不总是正确的是DX11的典型BUG
Input.hit.Zero();
LoadContent();
}
virtual void LoadContent(){}
virtual void UnLoadContent(){}
virtual void HandleInput(){}//抽象的空动作为它的下属类使用
virtual void Update(float gameTime){}
virtual void Resize(){ }
virtual void Draw(){}//绘画的抽象
void Dispose()
{
chain.Dispose();
UnLoadContent();
}
void Clear(Vector4 c) { chain.Clear(c); }
void Render()
{
DWORD tc = timeGetTime();
if ((tc – Startframe) > (1000 / frameRate))
{
Startframe = tc;
Update(1/frameRate);
Draw();
chain.Present();
}
}
//如果你真的是用游戏手柄或其他触摸设备输入的话也不必更改任何类的设定把InputState的类中增加个”Other。”的输入变量即可。
int HandleMsg(int uMsg,WPARAM wParam,LPARAM lParam)//记录所有的输入信息
{
switch (uMsg)
{
case WM_CLOSE:
Dispose();
return 0;
case WM_KEYDOWN:
Input.mkeys[wParam] = true;
HandleInput();
return 0;
case WM_KEYUP:
Input.mkeys[wParam] = false;
HandleInput();
return 0;
// case WM_EXITSIZEMOVE:
case WM_SIZE:
if(wParam==SIZE_MAXIMIZED||wParam== SIZE_RESTORED)
{ chain.Resize();
Input.viewport=Viewport(chain.desc.Width,chain.desc.Height);
Resize();}
return 0;
case WM_MOUSEWHEEL:
case WM_LBUTTONUP:
case WM_MBUTTONUP:
case WM_RBUTTONUP:
case WM_LBUTTONDOWN:
case WM_MBUTTONDOWN:
case WM_RBUTTONDOWN:
case WM_MOUSEMOVE:
Input.state=uMsg;
int xPos =Input.X= (short)LOWORD(lParam);
int yPos =Input.Y= (short)HIWORD(lParam);
int nMouseButtonState = (short)LOWORD(wParam);
Input.LM = ((nMouseButtonState & MK_LBUTTON) != 0);
Input.RM = ((nMouseButtonState & MK_RBUTTON) != 0);
Input.MM = ((nMouseButtonState & MK_MBUTTON) != 0);
HandleInput();
if (uMsg == WM_MOUSEWHEEL)
{
int scroll =(short)HIWORD(wParam);
if (scroll != 0)
{
Input.WheelDelta = scroll/120;
HandleInput();
Input.WheelDelta =0;
}
scroll%=120;
}
Input.PX = xPos;Input.PY = yPos;
return 0;
break;
}
return 0;
}
先来测试下是否成功建立了DX11的设备和显示缓冲。
先创建个新的项目。把项目的目录选项的包含目录添加上面提到的一些文件的位置。
添加个“main.ccp”文件到当前项目。
#pragma once
#include “DX.h”
#include “DXView.h”
Device device;DXView gdevice;
long WINAPI WndProc( HWND hWnd, UINT uMsg, WPARAM wParam,LPARAM lParam)
{
gdevice.HandleMsg(uMsg,wParam,lParam);
if(uMsg== WM_CREATE)
{
device.Create();
gdevice.Create(device,hWnd,4);
gdevice.frameRate=60;
return 0;
}
if(uMsg== WM_CLOSE)
{
device.Dispose();
PostQuitMessage(0);
return 0;
}
return DefWindowProc(hWnd,uMsg,wParam,lParam);
}
int WINAPI WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) {
HWND hwnd;
WNDCLASS wc= {
CS_HREDRAW | CS_VREDRAW,WndProc,0,0, hInstance,0,LoadCursor(0, IDC_ARROW ),0,0,NAME};
RegisterClass( &wc );
hwnd = CreateWindowEx(0,NAME,TITLE,WS_OVERLAPPEDWINDOW,CW_USEDEFAULT, CW_USEDEFAULT, 880, 600, NULL, NULL, hInstance, NULL );
ShowWindow( hwnd, nCmdShow );
UpdateWindow( hwnd );
MSG msg = {0};
while( WM_QUIT != msg.message )
{
if( PeekMessage( &msg, NULL, 0, 0, PM_REMOVE ) )
{
TranslateMessage( &msg );
DispatchMessage( &msg );
}
else gdevice.Render();
}
return msg.wParam;
}
添加个“DXView.h”文件到当前项目。
struct DXView:SpliteDevice
{
DXView(){}
void Draw() { Clear(Red); }
运行后你会得到个红色的背景,3D的元素后面会提到。并逐渐丰富DXView的内容。
可以看到,把大量重复输入和内容的动作完全压缩在预置类中,每个分类又和轻巧和高效。可以极大的提高程序的可读性和稳定性,任何一个部件出问题 VC会把指针自动跳转到出错的类中,避免了在一堆相近的指针中反复翻查出错的部分。
}
在原先的DEVICE的工程文件中添加”SpriteBatch.h”,因为它也是个公共的类;sprite是相对简单的一个渲染部件。它包含在视窗任何位置任何范围来快速绘制图片,同时包含裁减、偏移和镜像等功能本游戏没有用到旋转和缩放属性。
struct CB
{
Matrix p;Vector4 c;//这里虽然使用了 4×4矩阵但没有进行任何的矩阵运算,仅仅是记录变换信息而已。
};
struct SpriteBatch
{
Device device;
ID3D11VertexShader* VS;//一个空的shader
ID3D11PixelShader* PS;//绘图的Shader
ID3D11GeometryShader* GS;//变换坐标位置的shader
ID3D11Buffer* cb;//提供给显卡的BYTE变量
CB cbo;//存储变量与显卡交互的中间媒介
float* p;//变换位置坐标
float Width, Height;//绘制的窗口大小
SpriteBatch(){}
SpriteBatch(Device dev)
{
device=dev;
p=new float[16];
ResetPos();
ResetUV();
ID3DBlob* blob;
UINT size=(UINT)strlen(Code_Sprite)+1;
D3DX11CompileFromMemory(Code_Sprite,size,0, 0,0, “VS”,”vs_4_0″, D3DCOMPILE_ENABLE_STRICTNESS, 0, 0, &blob, 0, 0);
device.dev->CreateVertexShader(blob->GetBufferPointer(),
blob->GetBufferSize(),0, &VS );
D3DX11CompileFromMemory(Code_Sprite,size,0, 0,0, “PS”,”ps_4_0″, D3DCOMPILE_ENABLE_STRICTNESS, 0, 0, &blob, 0, 0);
device.dev->CreatePixelShader(blob->GetBufferPointer(),
blob->GetBufferSize(),0, &PS );
D3DX11CompileFromMemory(Code_Sprite,size,0, 0,0, “GS”,”gs_4_0″, D3DCOMPILE_ENABLE_STRICTNESS, 0, 0, &blob, 0, 0);
device.dev->CreateGeometryShader(blob->GetBufferPointer(),
blob->GetBufferSize(),0, &GS );
Kill(blob);
device.CreateConstantBuffer<CB>( cb);
void Begin()//和DX内置的sprite不同.我的sprite没有使用选项也没有End()属性,实际上也不需要他们。
{
device.context->IASetInputLayout(0);
device.context->VSSetShader(VS,0, 0 );
device.context->GSSetShader(GS,0, 0 );
device.context->PSSetShader(PS,0, 0 );
}
void SetTexture(Texture t)
{
device.context->PSSetShaderResources( 0, 1, &t.texture);
}
void Draw()
{ SetCB(); device.context->Draw(4,0);//显卡需要读入四个点来构成正方型
}
void Draw(Texture t, Vector4 c) {
ResetPos(); ResetUV(); cbo.c=c; SetTexture(t); Draw();
}//充满屏幕的绘制
void Draw(Texture t, float x, float y, float w, float h, Vector4 c)
{ ResetUV(); cbo.c=c; SetTexture(t); DrawQuad(x, y, w, h);
} //用于调整位置和大小的绘制
void Draw(Texture t, Vector2 pos, Rect r, Vector4 c, bool flip)
{
cbo.c=c; SetUV(t, r,flip); SetTexture(t);
DrawQuad(pos.X, pos.Y, (float)r.Width, (float)r.Height);
}//用于镜像和裁减和偏移的绘制
这里省略了个重要的画图环节 DrawQuad(x, y, w, h);它是绘图的核心。
在后面的游戏中将大量的使用这个类来绘制动态的2D元素。
前面已经具备设计一个2D游戏和UI元素的所有的基础设备。
下面将丰富它的内容。hold on.首先要有一个管理大量资源的类,以后可以利用它来分拣、增加、删除、计数其中的元素。C++提供了一些基础的类型如vector之类的而且在说明中称这是由很聪明的人设计的算法,不需要改进,聪明人设计那么愚蠢的命名方式和用法!我fuck 死它!C#中有个最常用的类List虽然和c++比起来剧慢但勉强可以应付以上的要求,我现在就来设计个简单的能满足目前需要的c++->List:新建个”List.h”的文件:
template<typename TYPE>
struct List
{
List(){ Count =0; obj=0;}
TYPE &operator [] (const int index) const { return obj[index]; }//提取元素if (index>Count)那就找块豆腐撞死吧
int Add(const TYPE object)//添加
{
if(Count>0)
{
TYPE* newobj=new TYPE[Count+1];//由于大小未知所以不能memcpy
for(int i=0;i<Count;i++) newobj[i]=obj[i];
newobj[Count]=object;
obj=newobj;
}
else{ obj=new TYPE[1];obj[0]=object; }
return Count++;
}
void Remove(const int index)//删除
{
obj[index]=obj[Count-1];
Count–;
}
void Clear(){ delete obj; Count = 0;}
int Count;//计数
TYPE* obj;
};
我的list所有的命名方式和用法与C#的LIst完全相同。
后面游戏中会大量使用它包括在每祯之间不停的更新列表。
C++的优势在与通过使用指针来直接访问内存地址来达到高性能而危险性也极高,可以用来烧毁硬件。很多人沉迷与设计个自己以为很聪明的数学循环而放弃内存地址的直接利用这才是弱智的表现,巧妙利用memset memcpy 可以完成各种列表的复制转移同时还可以给分类复杂的变量赋值,但也有局限就是你必须确定转移对象的字节大小。我的List 暂时回避了这个问题。
以下是UI的示例
再分享一下我老师大神的人工智能教程吧。零基础!通俗易懂!风趣幽默!还带黄段子!希望你也加入到我们人工智能的队伍中来!https://blog.csdn.net/jiangjunshow