资源异步加载恐怕是3D引擎中应用最为广泛的多线程技术了,特别是在无缝地图的网络游戏中,尤为重要,公司3D引擎的资源加载部分采用了硬盘->内存->显存两级加载的模式,超时卸载也分两级,这样虽然实际效果不错,但代码非常繁琐,在FlagshipEngine中,我设法将其进行了一定程度的简化。
首先我们需要定义一个Resource基类,它大致上是这样的:
卸载方面,在加载新的单元同时,卸载身后旧的单元,对单元内所有资源调用Release,Load/Release带有引用计数,仍被引用的资源不会被卸载。当某一资源长时间没有被看见,则超时,调用UnCache释放VertexBuffer等资源。
为了实现超时卸载功能,我们需要一个ResourceManager类,每帧检查几个已Cache的资源,看起是否超时,另外也需对已加载的资源进行分类管理,注册其资源别名(可以为其文件名),提供查找资源的接口。
另外为了方便使用,我们需要一个模板句柄类ResHandle<T>,设置该资源的别名,其内部调用ResourceManange的查找方法,看此资源是否已存在,如不存在则new一个新的,GetImpliment则返回该资源对象,之后可以将该资源添加到实体中,而无需关心其是否已被加载,代码如下:
首先我们需要定义一个Resource基类,它大致上是这样的:
class
_DLL_Export Resource :
public
Base
{
public:
Resource();
virtual ~Resource();
// 是否过期
bool IsOutOfDate();
public:
// 是否就绪
virtual bool IsReady();
// 读取资源
virtual bool Load();
// 释放资源
virtual bool Release();
// 缓存资源
virtual bool Cache();
// 释放缓存
virtual void UnCache();
protected:
// 加载标记
bool m_bLoad;
// 完成标记
bool m_bReady;
private:
} ;
在实际游戏中,加载资源的范围大于视野,当摄像机移动到单元格边缘(必须有一定的缓冲区),就应将新的单元格中的对象加入到资源加载队列中,唤醒资源加载线程调用Load接口进行加载,完成后将该资源的加载标记设为true。而通过可视剪裁所得到的最终可视实体,则需要调用Cache接口构建图像API所需对象,当Load和Cache都完成后IsReady才会返回true,这时该资源才能开始被渲染。
{
public:
Resource();
virtual ~Resource();
// 是否过期
bool IsOutOfDate();
public:
// 是否就绪
virtual bool IsReady();
// 读取资源
virtual bool Load();
// 释放资源
virtual bool Release();
// 缓存资源
virtual bool Cache();
// 释放缓存
virtual void UnCache();
protected:
// 加载标记
bool m_bLoad;
// 完成标记
bool m_bReady;
private:
} ;
卸载方面,在加载新的单元同时,卸载身后旧的单元,对单元内所有资源调用Release,Load/Release带有引用计数,仍被引用的资源不会被卸载。当某一资源长时间没有被看见,则超时,调用UnCache释放VertexBuffer等资源。
为了实现超时卸载功能,我们需要一个ResourceManager类,每帧检查几个已Cache的资源,看起是否超时,另外也需对已加载的资源进行分类管理,注册其资源别名(可以为其文件名),提供查找资源的接口。
另外为了方便使用,我们需要一个模板句柄类ResHandle<T>,设置该资源的别名,其内部调用ResourceManange的查找方法,看此资源是否已存在,如不存在则new一个新的,GetImpliment则返回该资源对象,之后可以将该资源添加到实体中,而无需关心其是否已被加载,代码如下:
template
<
class
T
>
class _DLL_Export ResHandle
{
public:
ResHandle() { m_pResource = NULL; }
virtual ~ResHandle() {}
// 设置资源路径
void SetPath( wstring szPath )
{
Resource * pResource = ResourceManager::GetSingleton()->GetResource( Key( szPath ) );
if ( pResource != NULL )
{
m_pResource = (T *) pResource;
}
else
{
m_pResource = new T;
m_pResource->SetPath( szPath );
ResourceManager::GetSingleton()->AddResource( m_pResource );
}
}
// 模板实体类指针
T * GetImpliment() { return (T *) m_pResource; }
T * operator-> () { return (T *) m_pResource; }
protected:
// 模板实体类指针
Resource * m_pResource;
private:
} ;
class _DLL_Export ResHandle
{
public:
ResHandle() { m_pResource = NULL; }
virtual ~ResHandle() {}
// 设置资源路径
void SetPath( wstring szPath )
{
Resource * pResource = ResourceManager::GetSingleton()->GetResource( Key( szPath ) );
if ( pResource != NULL )
{
m_pResource = (T *) pResource;
}
else
{
m_pResource = new T;
m_pResource->SetPath( szPath );
ResourceManager::GetSingleton()->AddResource( m_pResource );
}
}
// 模板实体类指针
T * GetImpliment() { return (T *) m_pResource; }
T * operator-> () { return (T *) m_pResource; }
protected:
// 模板实体类指针
Resource * m_pResource;
private:
} ;
目前的3D引擎的渲染帧和逻辑帧都是在一个线程上运行的,在网络游戏中大量玩家聚集,繁重的骨骼动画计算和粒子计算极大的拖累了渲染帧数,有两种有效措施:1、控制同屏显示人数,但玩家体验不好 2、帧数低于某值时减少动画Tick频率,但带来的问题是动画不连贯。
如果考虑使用多线程优化,最容易想到的就是采用平行分解模式,将骨骼动画计算和粒子计算写成两个for循环,然后用OpenMP将其多线程化,但事实上这样并不会提高多少效率,这两者计算仍然要阻滞渲染帧,线程的创建也有一定的消耗。于是我想到了一种极端的解决方案,采用任务分解模式,将渲染和逻辑完全分离到两个线程去,互不影响,当然这样线程同步会是大问题,毕竟线程的数量和BUG的数量是成正比的。
我们首先来分析下这两个线程分别需要做什么工作,需要那些数据。渲染线程需要获取实体的位置、材质等信息,并交给GPU渲染,逻辑线程需要更新实体的位置、材质、骨骼动画等数据,很显然一个写入一个读取,这为我们实现一个没有线程同步的多线程3D渲染系统提供了可能。
为了让读取和写入不需要Lock,我们需要为每一份数据设计一个带有冗余缓存的结构,读取线程读取的是上次写入完成的副本,而写入线程则向新的副本写入数据,并在完成后置上最新标记,置标记的操作为原子操作即可。以Vector为例,这个结构大致是这样的:
SharedData<Matrix4f> m_matWorld;
在渲染线程中调用pDevice->SetWorldMatrix( m_matWorld.Read() );
在逻辑线程中调用m_matWorld.Write( matNewWorld );
需要注意的是,这种方案并非绝对健壮,当渲染线程极慢且逻辑线程极快的情况下,有可能写入了超过了DATACENTER_CACHE次,而读取却尚未完成,那么数据就乱套了,当然真要出现了这种情况,游戏早已经是没法玩了,我测试的结果是渲染帧小于1帧,逻辑帧大于10000帧,尚未出现问题。
FlagshipEngine采用了这一设想,实际Demo测试结果是,计算25个角色的骨骼动画,从静止到开始奔跑,单线程的情况下,帧数下降了20%~30%,而使用多线程的情况下,帧数完全没有变化!
如果考虑使用多线程优化,最容易想到的就是采用平行分解模式,将骨骼动画计算和粒子计算写成两个for循环,然后用OpenMP将其多线程化,但事实上这样并不会提高多少效率,这两者计算仍然要阻滞渲染帧,线程的创建也有一定的消耗。于是我想到了一种极端的解决方案,采用任务分解模式,将渲染和逻辑完全分离到两个线程去,互不影响,当然这样线程同步会是大问题,毕竟线程的数量和BUG的数量是成正比的。
我们首先来分析下这两个线程分别需要做什么工作,需要那些数据。渲染线程需要获取实体的位置、材质等信息,并交给GPU渲染,逻辑线程需要更新实体的位置、材质、骨骼动画等数据,很显然一个写入一个读取,这为我们实现一个没有线程同步的多线程3D渲染系统提供了可能。
为了让读取和写入不需要Lock,我们需要为每一份数据设计一个带有冗余缓存的结构,读取线程读取的是上次写入完成的副本,而写入线程则向新的副本写入数据,并在完成后置上最新标记,置标记的操作为原子操作即可。以Vector为例,这个结构大致是这样的:
struct
VectorData
{
Vector4f m_pVector[DATACENTER_CACHE];
int m_iIndex;
VectorData()
{
memset( m_pVector, 0, DATACENTER_CACHE * sizeof(Vector4f) );
m_iIndex = 0;
}
void Write( Vector4f& rVector )
{
int iNewIndex = m_iIndex == DATACENTER_CACHE - 1 ? 0 : m_iIndex + 1;
m_pVector[iNewIndex] = rVector;
m_iIndex = iNewIndex;
}
Vector4f& Read()
{
return m_pVector[m_iIndex];
}
} ;
当然我们可以用模板来写这个结构,让其适用于int,float,matrix等多种数据类型,余下的工作就简单了,将所有有共享数据的类的成员变量都定义为以上这种数据类型,例如我们可以定义:
{
Vector4f m_pVector[DATACENTER_CACHE];
int m_iIndex;
VectorData()
{
memset( m_pVector, 0, DATACENTER_CACHE * sizeof(Vector4f) );
m_iIndex = 0;
}
void Write( Vector4f& rVector )
{
int iNewIndex = m_iIndex == DATACENTER_CACHE - 1 ? 0 : m_iIndex + 1;
m_pVector[iNewIndex] = rVector;
m_iIndex = iNewIndex;
}
Vector4f& Read()
{
return m_pVector[m_iIndex];
}
} ;
SharedData<Matrix4f> m_matWorld;
在渲染线程中调用pDevice->SetWorldMatrix( m_matWorld.Read() );
在逻辑线程中调用m_matWorld.Write( matNewWorld );
需要注意的是,这种方案并非绝对健壮,当渲染线程极慢且逻辑线程极快的情况下,有可能写入了超过了DATACENTER_CACHE次,而读取却尚未完成,那么数据就乱套了,当然真要出现了这种情况,游戏早已经是没法玩了,我测试的结果是渲染帧小于1帧,逻辑帧大于10000帧,尚未出现问题。
FlagshipEngine采用了这一设想,实际Demo测试结果是,计算25个角色的骨骼动画,从静止到开始奔跑,单线程的情况下,帧数下降了20%~30%,而使用多线程的情况下,帧数完全没有变化!
现在我们已经有了三个可独立工作的线程:资源加载线程、逻辑线程、渲染线程,下一步我们需要决定它们如何在实际的项目中相互配合,也就是所谓的应用程序框架了,该框架需要解决以下两个问题
首先,资源读取线程可以简单设计为一个循环等待的线程结构,每隔一段时间检查加载队列中是否有内容,如果有则进行加载工作,如果没有则继续等待一段时间。这种方式虽然简单清晰,但却存在问题,如果等待时间设得过长,则加载会产生延迟,如果设得过短,则该线程被唤醒的次数过于频繁,会耗费很多不必要的CPU时间。
然后,主线程是逻辑线程还是渲染线程?因为逻辑线程需要处理键盘鼠标等输入设备的消息,所以我起初将逻辑线程设为主线程,而渲染线程另外创建,但实际发现,帧数很不正常,估计与WM_PAINT消息有关,有待进一步验证。于是掉转过来,帧数正常了,但带来了一个新的问题,逻辑线程如何处理键盘鼠标消息?
对于第一个问题,有两种解决方案:
第一,我们可以创建一个Event,资源读取线程使用WaitForSingleObject等待着个Event,当渲染线程向加载队列添加新的需加载的资源后,将这个Event设为Signal,将资源读取线程唤醒,为了安全,我们仍需要在渲染线程向加载队列添加元素,以及资源加载线程从加载队列读取元素时对操作过程加锁。
第二,使用在渲染线程调用PostThreadMessage,将资源加载的请求以消息的形式发送到资源价值线程,并在wParam中传递该资源对象的指针,资源加载线程调用WaitMessage进行等待,收到消息后即被唤醒,这种解决方案完全不需要加锁。
对于第二个问题,我们同样可以用PostThreadMessage来解决,在主线程的WndProc中,将逻辑线程需要处理的消息发送出去,逻辑线程收到后进行相关处理。
需要注意的是,我们必须搞清楚线程是在何时创建消息队列的,微软如是说:
在逻辑线程中这般如此:
首先,资源读取线程可以简单设计为一个循环等待的线程结构,每隔一段时间检查加载队列中是否有内容,如果有则进行加载工作,如果没有则继续等待一段时间。这种方式虽然简单清晰,但却存在问题,如果等待时间设得过长,则加载会产生延迟,如果设得过短,则该线程被唤醒的次数过于频繁,会耗费很多不必要的CPU时间。
然后,主线程是逻辑线程还是渲染线程?因为逻辑线程需要处理键盘鼠标等输入设备的消息,所以我起初将逻辑线程设为主线程,而渲染线程另外创建,但实际发现,帧数很不正常,估计与WM_PAINT消息有关,有待进一步验证。于是掉转过来,帧数正常了,但带来了一个新的问题,逻辑线程如何处理键盘鼠标消息?
对于第一个问题,有两种解决方案:
第一,我们可以创建一个Event,资源读取线程使用WaitForSingleObject等待着个Event,当渲染线程向加载队列添加新的需加载的资源后,将这个Event设为Signal,将资源读取线程唤醒,为了安全,我们仍需要在渲染线程向加载队列添加元素,以及资源加载线程从加载队列读取元素时对操作过程加锁。
第二,使用在渲染线程调用PostThreadMessage,将资源加载的请求以消息的形式发送到资源价值线程,并在wParam中传递该资源对象的指针,资源加载线程调用WaitMessage进行等待,收到消息后即被唤醒,这种解决方案完全不需要加锁。
对于第二个问题,我们同样可以用PostThreadMessage来解决,在主线程的WndProc中,将逻辑线程需要处理的消息发送出去,逻辑线程收到后进行相关处理。
需要注意的是,我们必须搞清楚线程是在何时创建消息队列的,微软如是说:
The thread to which the message is posted must have created a message queue, or else the call toPostThreadMessage fails. Use one of the following methods to handle this situation.
- Call PostThreadMessage. If it fails, call the Sleep function and callPostThreadMessage again. Repeat until PostThreadMessage succeeds.
- Create an event object, then create the thread. Use the WaitForSingleObject function to wait for the event to be set to the signaled state before callingPostThreadMessage. In the thread to which the message will be posted, callPeekMessage as shown here to force the system to create the message queue.
PeekMessage(&msg, NULL, WM_USER, WM_USER, PM_NOREMOVE)
Set the event, to indicate that the thread is ready to receive posted messages.
看来,我们只需要在线程初始化时调一句PeekMessage(&msg, NULL, WM_USER, WM_USER, PM_NOREMOVE)就可以了,然后在主线程中如此这般:
switch
( uMsg )
{
case WM_PAINT:
{
hdc = BeginPaint(hWnd, &ps);
EndPaint(hWnd, &ps);
}
break;
case WM_DESTROY:
{
m_pLogic->StopThread();
WaitForSingleObject( m_pLogic->GetThreadHandle(), INFINITE );
PostQuitMessage(0);
}
break;
default:
{
if ( IsLogicMsg( uMsg ) )
{
PostThreadMessage( m_pLogic->GetThreadID(), uMsg, wParam, lParam );
}
else
{
return DefWindowProc( hWnd, uMsg, wParam, lParam );
}
}
break;
}
{
case WM_PAINT:
{
hdc = BeginPaint(hWnd, &ps);
EndPaint(hWnd, &ps);
}
break;
case WM_DESTROY:
{
m_pLogic->StopThread();
WaitForSingleObject( m_pLogic->GetThreadHandle(), INFINITE );
PostQuitMessage(0);
}
break;
default:
{
if ( IsLogicMsg( uMsg ) )
{
PostThreadMessage( m_pLogic->GetThreadID(), uMsg, wParam, lParam );
}
else
{
return DefWindowProc( hWnd, uMsg, wParam, lParam );
}
}
break;
}
在逻辑线程中这般如此:
MSG msg;
while ( m_bRunning )
{
if ( PeekMessage( &msg, NULL, 0, 0, PM_NOREMOVE ) )
{
if ( ! GetMessageW( &msg, NULL, 0, 0 ) )
{
return (int) msg.wParam;
}
MessageProc( msg.message, msg.wParam, msg.lParam );
}
LogicTick();
}
完成!
while ( m_bRunning )
{
if ( PeekMessage( &msg, NULL, 0, 0, PM_NOREMOVE ) )
{
if ( ! GetMessageW( &msg, NULL, 0, 0 ) )
{
return (int) msg.wParam;
}
MessageProc( msg.message, msg.wParam, msg.lParam );
}
LogicTick();
}
在实际游戏中,逻辑线程需要对渲染对象做许多操作,比如添加与删除,改变渲染对象的属性等等,而由于在先前的设计中,逻辑线程与渲染线程相互独立,如果只是改变某一共享数据,没有问题,但如果操作影响到了场景结构,例如实体的添加与删除,则必须进行线程同步,这又违背了FlagshipEngine的设计初衷——避免繁重的逻辑计算影响渲染速度。
解决办法其实在上一篇中已经提到了,仍然是利用天然的同步机制——Windows消息,添加实体时,逻辑线程只是new了一个Entity对象,设置这个对象的初始共享数据,比如位置信息,同时向渲染线程发送一条WM_ADDENTITY的自定义消息,将Entity指针作为wParam传递。渲染线程接受到消息后调用Entity的UpdateScene方法,更新Entity在场景树中的位置,并加载资源。
删除也是一样,逻辑线程向渲染线程发送WM_DELETEENTITY消息,并不再使用该Entity指针,渲染对象则处理改消息,将此Entity从场景中删除并卸载资源。
这里有一个非常危险的情况,前面一篇提到,资源加载也是通过消息传递实现的,同样是传递的资源指针,如果逻辑线程添加了一个Entity,还没加载就删掉了它,则资源加载线程会拿到一个过期指针,一切就结束了。。。
解决这一问题,最稳妥的方法是消息的wParam并不传递指针,而是传递该Entity或资源的唯一ID,这样的话即使ID过期,也可轻松忽略掉这条消息,坏处是每次消息处理都的从全局的map里检查是否存在此ID对应的Entity或资源,这可是笔不小的开销。
第二种方案,我们仍然传递指针,只是在接受到WM_DELETEENTITY消息时,检查该Entity是否已经加载完成,如果没有完成,则重新将此消息加入消息队列,下个渲染帧再次判断。
FlagshipEngine的多线程设计大致就是如此了。
解决办法其实在上一篇中已经提到了,仍然是利用天然的同步机制——Windows消息,添加实体时,逻辑线程只是new了一个Entity对象,设置这个对象的初始共享数据,比如位置信息,同时向渲染线程发送一条WM_ADDENTITY的自定义消息,将Entity指针作为wParam传递。渲染线程接受到消息后调用Entity的UpdateScene方法,更新Entity在场景树中的位置,并加载资源。
删除也是一样,逻辑线程向渲染线程发送WM_DELETEENTITY消息,并不再使用该Entity指针,渲染对象则处理改消息,将此Entity从场景中删除并卸载资源。
这里有一个非常危险的情况,前面一篇提到,资源加载也是通过消息传递实现的,同样是传递的资源指针,如果逻辑线程添加了一个Entity,还没加载就删掉了它,则资源加载线程会拿到一个过期指针,一切就结束了。。。
解决这一问题,最稳妥的方法是消息的wParam并不传递指针,而是传递该Entity或资源的唯一ID,这样的话即使ID过期,也可轻松忽略掉这条消息,坏处是每次消息处理都的从全局的map里检查是否存在此ID对应的Entity或资源,这可是笔不小的开销。
第二种方案,我们仍然传递指针,只是在接受到WM_DELETEENTITY消息时,检查该Entity是否已经加载完成,如果没有完成,则重新将此消息加入消息队列,下个渲染帧再次判断。
FlagshipEngine的多线程设计大致就是如此了。