What is the recommended approach to managing scene loading and connection when using a start menu? Currently, I’ve set up the following:
Create both client and server worlds
Load the game scene(s) additively asynchronously
Unload the start scene asynchronously
Wait for the subscenes containing baked ghosts to fully load
Start listening for connections in the server world
Connect to the server in the client world
Here’s the code for all of this, it’s similar to how the samples work:
NetcodeBootstrap.cs
[Preserve]
public class NetcodeBootstrap : ClientServerBootstrap
{
public override bool Initialize(string defaultWorldName)
{
AutoConnectPort = 0;
CreateLocalWorld(defaultWorldName);
return true;
}
}
StartGameManager.cs
public class StartGameManager : MonoBehaviour
{
public void OnClick_Start()
{
StartCoroutine(StartGame());
}
private IEnumerator StartGame()
{
NetworkEndpoint endpoint = NetworkEndpoint.Parse("127.0.0.1", 7979);
InitializeGameCoroutine(endpoint);
yield return StartCoroutine(LoadGameScenesCoroutine());
}
private static IEnumerator LoadGameScenesCoroutine()
{
AsyncOperation inGameLoading = SceneManager.LoadSceneAsync("InGameScene", LoadSceneMode.Additive);
if (inGameLoading is null)
{
yield break;
}
while (!inGameLoading.isDone)
{
yield return new WaitForSeconds(0.005f);
}
Scene newlyLoadedScene = SceneManager.GetSceneByName("InGameScene");
SceneManager.SetActiveScene(newlyLoadedScene);
SceneManager.UnloadSceneAsync("StartScene");
}
private static void InitializeGameCoroutine(NetworkEndpoint endpoint)
{
var startConnection = new StartConnection
{
Endpoint = endpoint
};
World serverWorld = ClientServerBootstrap.CreateServerWorld("ServerWorld");
World clientWorld = ClientServerBootstrap.CreateClientWorld("ClientWorld");
serverWorld.EntityManager.CreateSingleton(startConnection);
clientWorld.EntityManager.CreateSingleton(startConnection);
for (var i = 0; i < MultiplayerPlayModePreferences.RequestedNumThinClients; i++)
{
World thinClientWorld = ClientServerBootstrap.CreateThinClientWorld();
thinClientWorld.EntityManager.CreateSingleton(startConnection);
}
World.DefaultGameObjectInjectionWorld ??= clientWorld;
MonoBehaviourUtils.GetLocalSimulationWorld()?.Dispose();
}
}
ConnectionSystem.cs
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation | WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation)]
[UpdateInGroup(typeof(InitializationSystemGroup))]
public partial struct ConnectionSystem : ISystem
{
private EntityQuery _pendingSubScenesQuery;
private bool _worldIsLoaded;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
EntityQueryBuilder builder = new(Allocator.Temp);
_pendingSubScenesQuery = builder
.WithAll<RequestSceneLoaded, SceneReference>()
.Build(ref state);
builder.Dispose();
// Added to my InGameScene's subscenes containing the baked ghosts
state.RequireForUpdate<InGameScene>();
state.RequireForUpdate<NetworkStreamDriver>();
state.RequireForUpdate<StartConnection>();
}
[BurstCompile]
public void OnDestroy(ref SystemState state)
{
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
if (!_worldIsLoaded && !IsWorldFullyLoaded(state.WorldUnmanaged))
return;
var driver = SystemAPI.GetSingleton<NetworkStreamDriver>();
var startConnection = SystemAPI.GetSingleton<StartConnection>();
state.EntityManager.DestroyEntity(SystemAPI.GetSingletonEntity<StartConnection>());
if (state.WorldUnmanaged.IsServer())
{
driver.Listen(startConnection.Endpoint);
}
else
{
driver.Connect(state.EntityManager, startConnection.Endpoint);
}
}
private bool IsWorldFullyLoaded(WorldUnmanaged worldUnmanaged)
{
var pendingSubScenes = _pendingSubScenesQuery.ToEntityArray(Allocator.Temp);
foreach (Entity subScene in pendingSubScenes)
if (!SceneSystem.IsSceneLoaded(worldUnmanaged, subScene))
return false;
pendingSubScenes.Dispose();
_worldIsLoaded = true;
return true;
}
}
Unity.Scenes.ResolveSceneReferenceSystem is a big cause of the lag spikes with a record of 149.4KB GC Alloc and 1746.39ms. This happens for some worlds (ThinClientWorld1 but not ClientWorld for example) but sometimes not for others. I’ve also seen Unity.NetCode.NetworkStreamReceiveSystem with a 0.8MB GC Alloc in the server world.
Loading time is of course fine but it’s important not to freeze the main thread ever so that we can add loading screens. The samples also suffer from this. Is there a way to shift all of this loading to another thread/coroutine?
The UnityScene loading is done by another thread. The Sub-Scene loading is invoked by each sub-scene component present in the scene, that ask th SceneSystem to load the resource.
This is done for each World that has a SceneSystem present in the World.All list at that time.
This is why creating world first and the load the scene is the key to get that automated. This is also the reason why, after you initiated the loading of the new Unity.Scene asynchronously, you also need to dispose the LocalSimulation world: the sub-scenes are going to be loaded also there (if the default systems are used to create such world).
There is no need to load the GameScene addittively. You can just loa the scene scene normally (asynchronously), and you also don’t need to unload the start scene manually either.
Worlds are (unfortunately honestly) ref-counting the loaded Unity.Scene (so in your case the StartScene is ref-couted by the LocalSimulationWorld).
When that world is disposed, the scene is also unloaded because of that. That it is usually a source of headache.
World that are created after a Unity.Scene is loaded, does add any reference count (IIRC). So, loading addictively (as you are doing) will work fine as long as you are somehow changing the active scene.
In general, both Unity.Scene and Entity sub-scene are loaded in another thread. However, the entities are still created as last pass on the main thread and also all the remapping (that is the slow part) is done there.
It can’t be moved to another thread because entity creation cannot be done from another thread or job. I think there is some possibility when using ntityManager Transaction (that give exclusive access to entity creation to job exclusively) but that will stall any other activity on the main thread that access entity data (either reading or writing) IIRC. So does not solve this.
Even using command buffers will not solve that, actually it will be even worse (certain operation are faster with EntityManager directly). and just post-pone the stall at a later point in time.
To reduce that burden time-slicing the world loading in multiple frames would be a better solution, but it is not something it is supported out of the box.
Splitting the sub-scene in multiple chunks, that can then be loaded and prioritized (i.e based on the nearby player position), going effectively toward a streaming approach alleviate that too.
Thanks for the explanation, it’s useful to know all of this! If I’m correct in thinking, my code does follow the flow of creating the client and server worlds then destroying the local simulation world before loading the scenes in. My thinking with additively loading the InGameScene is so that I can run a loading screen in the StartScene until the InGameScene is ready and connected where I can unload the StartScene and the game can start. My code is also setting the active scene once the InGameScene is loaded (as it cannot be before it is loaded) in the coroutine as to not freeze the main thread.
Could you give an idea of how to do this even if it isn’t supported out of the box? I think this could be a good approach. My project (and other test projects I’ve made) does not have a lot of entities at all (about 15 baked ghost entities) so I’m worried that this will become much worse for when I add more.
Is there any other way you can see that the flow in my code could be improved? I’ve noticed that adding the systems to the world also has a big overhead (750+ms in some cases) however I’m not sure whether this is happening within a coroutine but I think it’s not. Would you recommend adding these systems manually over multiple frames instead of simple [WorldSystemFilter]s?
I actually overlooked a key part of Coroutines for scene loading in particular with not yield returning the AsyncOperations from the async loading as well as having regular yield returns in between world creation. This is my new StartGameManager:
StartGameManager.cs
public class StartGameManager : MonoBehaviour
{
public void OnClick_Start()
{
StartCoroutine(StartGame());
}
private IEnumerator StartGame()
{
NetworkEndpoint endpoint = NetworkEndpoint.Parse("127.0.0.1", 7979);
yield return InitializeGameCoroutine(endpoint);
yield return StartCoroutine(LoadGameScenesCoroutine());
}
private static IEnumerator LoadGameScenesCoroutine()
{
AsyncOperation inGameLoading = SceneManager.LoadSceneAsync("InGameScene", LoadSceneMode.Additive);
if (inGameLoading is null)
{
yield break;
}
yield return inGameLoading;
while (!inGameLoading.isDone)
{
yield return new WaitForSeconds(0.005f);
}
Scene newlyLoadedScene = SceneManager.GetSceneByName("InGameScene");
SceneManager.SetActiveScene(newlyLoadedScene);
yield return SceneManager.UnloadSceneAsync("StartScene");
}
private static IEnumerator InitializeGameCoroutine(NetworkEndpoint endpoint)
{
var startConnection = new StartConnection
{
Endpoint = endpoint
};
World serverWorld = ClientServerBootstrap.CreateServerWorld("ServerWorld");
serverWorld.EntityManager.CreateSingleton(startConnection);
yield return null;
World clientWorld = ClientServerBootstrap.CreateClientWorld("ClientWorld");
clientWorld.EntityManager.CreateSingleton(startConnection);
yield return null;
for (var i = 0; i < MultiplayerPlayModePreferences.RequestedNumThinClients; i++)
{
World thinClientWorld = ClientServerBootstrap.CreateThinClientWorld();
thinClientWorld.EntityManager.CreateSingleton(startConnection);
yield return null;
}
World.DefaultGameObjectInjectionWorld ??= clientWorld;
MonoBehaviourUtils.GetLocalSimulationWorld()?.Dispose();
}
}
This obviously allows the Coroutine to split itself up on the yield returns if it needs to. I found the biggest overhead to be adding the systems to the worlds (which isn’t asynchronous and isn’t split across frames by default). The biggest overhead of adding the systems is Mono.Jit usually taking up 55% of the total which IL2CPP would solve (I assume) but isn’t profilable since the editor and development builds use Mono.
This still has a bit of a freeze in release builds but it’s better than before. I’m wondering whether it could still be improved further? I’m still thinking that adding the systems manually over multiple frames will help - maybe an async or method which splits over frames could be added for world creation?