Unity Mirror自定义Spawn和对象池使用

869 阅读6分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第45天,点击查看活动详情

前言

Mirror默认的生成对象的方法是使用prefab实例化对象,然而我们经常有复用对象的需求,一般在游戏中会使用对象池,比如动态生成的子弹之类的对象。另外一种需求是不使用prefab,而是动态构造对象。这两个需求都可以使用自定义Spawn Handler来实现。不过在这之前,我们先总结一下Mirror默认的Spawn流程。

Mirror默认的Spawn流程总结

服务器端的Spawn

任何对象都是先在服务器上生成。这有两个步骤。

  • 首先,需要生成该对象。 例如对于Player对象,会使用NetworkManager上注册的playerPrefab进行实例化:
GameObject player = startPos != null
                ? Instantiate(playerPrefab, startPos.position, startPos.rotation)
                : Instantiate(playerPrefab);
  • 然后,调用 NetworkServer.Spawn 方法在服务器上注册该对象,并发送SpawnMessage给客户端,在客户端上创建观察者对象。

需要注意的是,对于普通动态生成的对象,NetworkManager中注册的spawnPrefabs并没有被使用。普通对象是由游戏代码负责实例化生成,然后再调用 NetworkServer.Spawn

客户端的Spawn

客户端Spawn对象是通过处理SpawnMessage,在SpawnMessage中包含了assetId,通过assetId查找到prefab后再进行实例化,这在NetworkClient.GetPrefab方法中获取:

public static bool GetPrefab(Guid assetId, out GameObject prefab)
{
    prefab = null;
    return assetId != Guid.Empty &&
           prefabs.TryGetValue(assetId, out prefab) && prefab != null;
}

其中prefabs是NetworkClient中的一个字典,包含了所有可Spawn的Prefab。这个字典的key就是assetId。那么这个字典是怎么来的呢?在NetworkManager.RegisterClientMessages方法中进行了注册:

if (playerPrefab != null)
    NetworkClient.RegisterPrefab(playerPrefab);

foreach (GameObject prefab in spawnPrefabs.Where(t => t != null))
    NetworkClient.RegisterPrefab(prefab);

弄明白了这个,在看下NetworkClient.SpawnPrefab方法,这是客户端Spawn动态对象的地方:

static NetworkIdentity SpawnPrefab(SpawnMessage message)
{
    if (spawnHandlers.TryGetValue(message.assetId, out SpawnHandlerDelegate handler))
    {
        GameObject obj = handler(message);
        if (obj == null)
        {            
            return null;
        }
        NetworkIdentity identity = obj.GetComponent<NetworkIdentity>();
        if (identity == null)
        {            
            return null;
        }
        return identity;
    }

    // otherwise look in NetworkManager registered prefabs
    if (GetPrefab(message.assetId, out GameObject prefab))
    {
        GameObject obj = GameObject.Instantiate(prefab, message.position, message.rotation);        
        return obj.GetComponent<NetworkIdentity>();
    }

    Debug.LogError($"Failed to spawn server object, did you forget to add it to the NetworkManager? assetId={message.assetId} netId={message.netId}");
    return null;
}

这个方法中会使用GetPrefab获取到相应的prefab,然后使用该prefab实例化出对象。以上,就是Mirror默认的Spawn流程。 在这个方法的一开始,还会检查spawnHandlers这个字典中是否包含了assetId对应的handler,如果有,则使用该handler生成对象,这就是自定义Spawn的用法,下面细说。

Mirror客户端自定义Spawn/Unspawn

这儿特意强调一下客户端自定义spawn,因为这个操作只在纯客户端上进行,而服务器以及Host模式下的local client都没有这个机制。看出问题了没?如果我们使用Host模式,对于某个游戏对象,我们在客户端上自定义了它的生成方式,完全个性化的生成,但是这个代码不会影响到服务器上的对象,并且也不会影响到Host上local client的对象,当然了,local client对象和Host上的server对象本来就是同一个对象。如果说客户端对象和服务器对象看上去不一样还好,但是local client和remote client不一样就不太好了。所以我们如果自定义了客户端的对象生成,也需要自定义服务器/local client的对象生成,让他们生成同样的对象(至少看着一样吧)。关于服务器自定义对象,为啥没有这个机制呢?因为服务器上的动态对象本来就是由游戏代码生成的,并不是Mirror生成的。(当然player对象除外,但服务器上player对象的生成也可以自定义)如果你使用同一套代码在服务器和客户端生成对象,那自然就一致了。比如本文将要介绍的对象池使用,就是一套代码,让服务器和客户端都使用它生成和回收对象。

SpawnHandlerDelegate/unspawnHandlers

客户端的自定义生成其实从上面的SpawnPrefab函数就可以看到了,非常简单,就是使用查找到的SpawnHandlerDelegate进行生成,这个delegate的定义如下:

public delegate GameObject SpawnHandlerDelegate(SpawnMessage msg);

意思很明显,给我SpawnMessage,我返回一个对象。 而对象的删除,则是在DestroyObject中调用InvokeUnSpawnHandler:

// custom unspawn handler for this prefab? (for prefab pools etc.)
if (InvokeUnSpawnHandler(localObject.assetId, localObject.gameObject))
{
    // reset object after user's handler
    localObject.Reset();
}

这个Invoke方法就是从上面的unspawnHandlers字典中查找出UnSpawnDelegate进行操作,UnSpawnDelegate的定义如下:

// Handles requests to unspawn objects on the client
public delegate void UnSpawnDelegate(GameObject spawned);

注意这儿如果Invoke成功,会执行localObject.Reset(),这个localObject的类型为NetworkIdentity,Reset()方法非常重要,他会清除对象的netId,观察者,isServer/isClient等标记,如果这是一个服务器对象,就会回到实例化之后且Spawn之前的状态;如果它是客户端对象,就会回到实例化之后且ApplySpawnPayload之前的状态。这个Reset()是使用对象池所必须的。而Mirror这么处理自定义删除的对象,可以看到主要的需求还是使用对象池,不过如果你在handler里面真的把gameObject给删了也不会出问题。

使用对象池

以上我们弄明白了客户端的自定义生成/删除机制,最重要的应用就是使用对象池来生成/回收对象。我总结一下实现步骤:

定义一个对象池类

这儿就不展开了,非常简单,基本就是用一个Stack维护池中的对象,提供两个方法:

  • GameObject Get()从池中获取对象
  • void Recycle(GameObject obj)将对象返回到池中

封装一个函数从池中获取对象,让服务器和客户端都可以使用

private GameObject GetObjFromPool(Vector3 position, Quaternion rotation)
{    
    var go = objPool.Get();
    go.transform.position = position;
    go.transform.rotation = rotation;  
    return go;
}

服务器使用对象池生成对象

var go = GetObjFromPool(position, rotation);                                              
NetworkServer.Spawn(go);      

很简单,使用我们上面封装的函数,从池中获取对象,然后调用NetworkServer.Spawn在服务器上注册对象,以及发送SpawnMessage。

服务器使用对象池回收对象

NetworkServer.UnSpawn(go);                                
objPool.Recycle(go);

这儿使用对象池的Recycle方法回收了对象,需要注意的是,我们这儿要使用NetworkServer.UnSpawn方法在服务器上注销对象。而不是DestroyObject方法。实际上这个UnSpawn的实现如下:

public static void UnSpawn(GameObject obj) => DestroyObject(obj, DestroyMode.Reset);

还是调用了DestroyObject,不过使用了DestroyMode.Reset。又看到了Reset了,这次是在服务器上:

 if (mode == DestroyMode.Reset)
{
    identity.Reset();
}

以上,服务器上的对象池使用就完成了,这也包含了Host模式的local client对象。

客户端使用对象池

客户端使用对象池的代码反而更简单一些,因为只是实现handler去生成/消耗对象,而其他事情都是Mirror给做了。

NetworkClient.RegisterPrefab(prefab, 
    (SpawnMessage msg)=> 
    {                        
        return GetObjFromPool(msg.position, msg.rotation);                     
    },
    (GameObject go)=>
    {
        objPool.Recycle(go);
    }
);

需要做的事情就是调用NetworkClient.RegisterPrefab注册两个handler,而handler的实现和服务器使用对象池的方法一样。只是客户端不需要调用Spawn/UnSpawn,当然客户端也没有这些方法,类似的操作都是内部完成了。

另外,我们还要删除掉NetworkManager上注册的相同的prefab,否则会报错。因为这些prefab已经不需要注册了。