Load provider world assets in generic Unity
This commit is contained in:
parent
551ca82f5f
commit
100dfea51a
7 changed files with 212 additions and 18 deletions
|
|
@ -209,13 +209,18 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
|||
bridge,
|
||||
bridge,
|
||||
assetManifestDocuments: null,
|
||||
receiptSource: bridge);
|
||||
receiptSource: bridge,
|
||||
fallbackAssetProvider: transport);
|
||||
var initial = runtime.Connect();
|
||||
Assert.That(initial.ActiveEntities, Is.GreaterThan(0));
|
||||
Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty);
|
||||
|
||||
var playerId = runtime.ActiveWorld.PlayerEntityId;
|
||||
var marker = FindMarker(root, playerId);
|
||||
Assert.That(
|
||||
marker.GetComponentsInChildren<Transform>(includeInactive: true).Length,
|
||||
Is.GreaterThan(1),
|
||||
"The generic client substituted its primitive fallback instead of the provider-authored AssetBundle prefab.");
|
||||
var initialPosition = marker.transform.position;
|
||||
var initialVersion = runtime.ActiveVersion;
|
||||
var request = runtime.SubmitMoveVectorIntent(playerId, 1f, 0f, 1f);
|
||||
|
|
@ -248,15 +253,19 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
|
|||
camera.backgroundColor = new Color(0.035f, 0.055f, 0.08f, 1f);
|
||||
foreach (var entityMarker in root.GetComponentsInChildren<EveUnityPlayableWorldEntityMarker>())
|
||||
{
|
||||
var renderer = entityMarker.GetComponent<Renderer>();
|
||||
renderer.material.color = entityMarker.Controllable
|
||||
? new Color(0.18f, 0.75f, 1f)
|
||||
: entityMarker.EntityKind == "enemy"
|
||||
? new Color(1f, 0.25f, 0.2f)
|
||||
: new Color(1f, 0.78f, 0.15f);
|
||||
foreach (var renderer in entityMarker.GetComponentsInChildren<Renderer>())
|
||||
{
|
||||
renderer.material.color = entityMarker.Controllable
|
||||
? new Color(0.18f, 0.75f, 1f)
|
||||
: entityMarker.EntityKind == "enemy"
|
||||
? new Color(1f, 0.25f, 0.2f)
|
||||
: new Color(1f, 0.78f, 0.15f);
|
||||
}
|
||||
}
|
||||
var playerMarker = FindMarker(root, playerId);
|
||||
var playerBounds = playerMarker.GetComponent<Renderer>().bounds;
|
||||
var playerRenderer = playerMarker.GetComponentInChildren<Renderer>();
|
||||
Assert.That(playerRenderer, Is.Not.Null, "The provider-authored player prefab has no renderable visual.");
|
||||
var playerBounds = playerRenderer.bounds;
|
||||
var cameraDistance = Mathf.Max(8f, playerBounds.extents.magnitude * 4f);
|
||||
camera.transform.position = playerBounds.center + new Vector3(1f, 0.8f, -1.2f).normalized * cameraDistance;
|
||||
camera.transform.LookAt(playerBounds.center);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"com.unity.test-framework": "1.6.0",
|
||||
"com.unity.modules.assetbundle": "1.0.0",
|
||||
"com.unity.modules.imageconversion": "1.0.0",
|
||||
"com.unity.modules.physics": "1.0.0",
|
||||
"org.gamecult.cultlib": "file:../../../CultLib/artifacts/unity/org.gamecult.cultlib",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@
|
|||
"org.gamecult.eve.surface": "0.1.0"
|
||||
}
|
||||
},
|
||||
"com.unity.modules.assetbundle": {
|
||||
"version": "1.0.0",
|
||||
"depth": 0,
|
||||
"source": "builtin",
|
||||
"dependencies": {}
|
||||
},
|
||||
"com.unity.modules.imageconversion": {
|
||||
"version": "1.0.0",
|
||||
"depth": 0,
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using GameCult.Eve.Surface;
|
||||
using GameCult.Mesh;
|
||||
using GameCult.Networking;
|
||||
using UnityEngine;
|
||||
|
||||
#nullable enable
|
||||
|
||||
|
|
@ -14,6 +16,7 @@ namespace GameCult.Eve.UnityScene
|
|||
{
|
||||
public sealed class EveUnityCultMeshLiveProviderTransport :
|
||||
IEveUnitySceneLiveProviderTransport,
|
||||
IEveUnityGameObjectAssetProvider,
|
||||
IDisposable
|
||||
{
|
||||
private const string RemoteShardId = "provider";
|
||||
|
|
@ -23,7 +26,10 @@ namespace GameCult.Eve.UnityScene
|
|||
typeof(EveProviderAdvertisementDocument),
|
||||
typeof(EveSurfaceDocument),
|
||||
typeof(EveSurfaceCommandRequest),
|
||||
typeof(EveCommandReceiptDocument)
|
||||
typeof(EveCommandReceiptDocument),
|
||||
typeof(EveAssetCatalogDocument),
|
||||
typeof(CultMeshCdnArtifactManifest),
|
||||
typeof(CultMeshCdnArtifactChunk)
|
||||
};
|
||||
|
||||
private readonly string _replicaPath;
|
||||
|
|
@ -33,6 +39,8 @@ namespace GameCult.Eve.UnityScene
|
|||
private readonly string _runtimeId;
|
||||
private readonly HashSet<string> _pendingCommandIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _publishedReceiptIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, GameObject> _prefabs = new Dictionary<string, GameObject>(StringComparer.Ordinal);
|
||||
private readonly List<AssetBundle> _assetBundles = new List<AssetBundle>();
|
||||
private CultMeshNode? _node;
|
||||
private CultMeshSnapshotEndpoint? _snapshot;
|
||||
private CultNetDocumentRegistry? _networkRegistry;
|
||||
|
|
@ -95,6 +103,7 @@ namespace GameCult.Eve.UnityScene
|
|||
EnsureOpen();
|
||||
ResolveAdvertisement();
|
||||
RefreshSurface();
|
||||
RefreshAssetCatalog();
|
||||
RefreshReceipts();
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +144,9 @@ namespace GameCult.Eve.UnityScene
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false);
|
||||
_assetBundles.Clear();
|
||||
_prefabs.Clear();
|
||||
_node?.Dispose();
|
||||
_node = null;
|
||||
_snapshot = null;
|
||||
|
|
@ -144,6 +156,12 @@ namespace GameCult.Eve.UnityScene
|
|||
_publishedReceiptIds.Clear();
|
||||
}
|
||||
|
||||
public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset)
|
||||
{
|
||||
if (asset == null) throw new ArgumentNullException(nameof(asset));
|
||||
return _prefabs.TryGetValue(asset.AssetRef, out var prefab) ? prefab : null;
|
||||
}
|
||||
|
||||
private void EnsureOpen()
|
||||
{
|
||||
if (_node != null)
|
||||
|
|
@ -194,7 +212,7 @@ namespace GameCult.Eve.UnityScene
|
|||
ResponseTimeout = TimeSpan.FromSeconds(10),
|
||||
MessageIdPrefix = "eve-unity",
|
||||
RudpRuntimeId = _runtimeId,
|
||||
RudpMaxFragmentBytes = 1200
|
||||
RudpMaxFragmentBytes = 2048
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -244,6 +262,132 @@ namespace GameCult.Eve.UnityScene
|
|||
AssetManifestDocumentAvailable?.Invoke(CurrentAssetManifestDocument);
|
||||
}
|
||||
|
||||
private void RefreshAssetCatalog()
|
||||
{
|
||||
var interaction = RequireWorldInteraction();
|
||||
if (string.IsNullOrWhiteSpace(interaction.AssetManifestRecordRef))
|
||||
return;
|
||||
|
||||
var catalog = _snapshot!
|
||||
.FetchDocumentsAsync<EveAssetCatalogDocument>(
|
||||
recordKeys: new[] { interaction.AssetManifestRecordRef },
|
||||
schemaIds: new[] { EveAssetCatalogDocument.SchemaId })
|
||||
.GetAwaiter()
|
||||
.GetResult()
|
||||
.FirstOrDefault();
|
||||
if (catalog == null || catalog.Version == CurrentAssetCatalogVersion)
|
||||
return;
|
||||
|
||||
foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false);
|
||||
_assetBundles.Clear();
|
||||
_prefabs.Clear();
|
||||
var selected = catalog.Assets
|
||||
.Select(asset => new
|
||||
{
|
||||
Asset = asset,
|
||||
Variant = asset.Variants.FirstOrDefault(variant =>
|
||||
string.Equals(variant.RuntimeId, "unity-scene", StringComparison.Ordinal) &&
|
||||
string.Equals(variant.Platform, CurrentBundlePlatform(), StringComparison.Ordinal))
|
||||
})
|
||||
.Where(selection => selection.Variant != null)
|
||||
.ToArray();
|
||||
foreach (var group in selected.GroupBy(selection => selection.Variant!.Uri, StringComparer.Ordinal))
|
||||
{
|
||||
var variant = group.First().Variant!;
|
||||
var manifest = _snapshot.FetchDocumentsAsync<CultMeshCdnArtifactManifest>(
|
||||
recordKeys: new[] { variant.Uri },
|
||||
schemaIds: new[] { CultMeshCdnSchemaVersions.ArtifactManifest })
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
var descriptor = manifest.FirstOrDefault();
|
||||
if (descriptor == null)
|
||||
throw new InvalidOperationException($"Provider asset bundle manifest '{variant.Uri}' was not available.");
|
||||
var bytes = ReadCachedBundle(descriptor, variant);
|
||||
VerifyBundle(bytes, variant);
|
||||
var bundle = AssetBundle.LoadFromMemory(bytes);
|
||||
if (bundle == null)
|
||||
throw new InvalidOperationException($"Unity could not load provider asset bundle '{variant.Uri}'.");
|
||||
_assetBundles.Add(bundle);
|
||||
foreach (var selection in group)
|
||||
{
|
||||
var prefab = bundle.LoadAsset<GameObject>(selection.Variant!.AssetKey);
|
||||
if (prefab != null)
|
||||
_prefabs[selection.Asset.AssetRef] = prefab;
|
||||
}
|
||||
}
|
||||
|
||||
CurrentAssetCatalogVersion = catalog.Version;
|
||||
}
|
||||
|
||||
private long CurrentAssetCatalogVersion { get; set; } = -1;
|
||||
|
||||
private static string CurrentBundlePlatform()
|
||||
{
|
||||
return Application.platform == RuntimePlatform.WindowsEditor ||
|
||||
Application.platform == RuntimePlatform.WindowsPlayer
|
||||
? "StandaloneWindows64"
|
||||
: Application.platform.ToString();
|
||||
}
|
||||
|
||||
private byte[] ReadBundle(CultMeshCdnArtifactManifest manifest)
|
||||
{
|
||||
var bytes = new byte[checked((int)manifest.SizeBytes)];
|
||||
foreach (var reference in manifest.Chunks.OrderBy(chunk => chunk.Offset))
|
||||
{
|
||||
var chunk = _snapshot!
|
||||
.FetchDocumentsAsync<CultMeshCdnArtifactChunk>(
|
||||
recordKeys: new[] { reference.RecordKey },
|
||||
schemaIds: new[] { CultMeshCdnSchemaVersions.ArtifactChunk })
|
||||
.GetAwaiter()
|
||||
.GetResult()
|
||||
.FirstOrDefault();
|
||||
if (chunk == null)
|
||||
throw new InvalidOperationException($"Provider asset chunk '{reference.RecordKey}' was not available.");
|
||||
if (chunk.SizeBytes != reference.SizeBytes || chunk.Payload.Length != reference.SizeBytes)
|
||||
throw new InvalidDataException($"Provider asset chunk '{reference.RecordKey}' size did not match its manifest.");
|
||||
Buffer.BlockCopy(chunk.Payload, 0, bytes, checked((int)reference.Offset), reference.SizeBytes);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private byte[] ReadCachedBundle(CultMeshCdnArtifactManifest manifest, EveAssetVariant variant)
|
||||
{
|
||||
var cacheRoot = Environment.GetEnvironmentVariable("EVEUNITY_ASSET_CACHE_PATH");
|
||||
if (string.IsNullOrWhiteSpace(cacheRoot))
|
||||
cacheRoot = Path.Combine(Application.persistentDataPath, "EveUnity", "assets");
|
||||
Directory.CreateDirectory(cacheRoot);
|
||||
var hash = variant.ContentHash.StartsWith("sha256:", StringComparison.Ordinal)
|
||||
? variant.ContentHash.Substring("sha256:".Length)
|
||||
: variant.ContentHash;
|
||||
var cachePath = Path.Combine(cacheRoot, hash + ".bundle");
|
||||
if (File.Exists(cachePath))
|
||||
{
|
||||
var cached = File.ReadAllBytes(cachePath);
|
||||
VerifyBundle(cached, variant);
|
||||
return cached;
|
||||
}
|
||||
|
||||
var bytes = ReadBundle(manifest);
|
||||
VerifyBundle(bytes, variant);
|
||||
var temporaryPath = cachePath + ".tmp";
|
||||
File.WriteAllBytes(temporaryPath, bytes);
|
||||
if (File.Exists(cachePath)) File.Delete(cachePath);
|
||||
File.Move(temporaryPath, cachePath);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void VerifyBundle(byte[] bytes, EveAssetVariant variant)
|
||||
{
|
||||
if (bytes.LongLength != variant.SizeBytes)
|
||||
throw new InvalidOperationException($"Provider asset bundle '{variant.Uri}' size did not match its catalog.");
|
||||
byte[] digest;
|
||||
using (var sha256 = SHA256.Create())
|
||||
digest = sha256.ComputeHash(bytes);
|
||||
var actual = "sha256:" + string.Concat(digest.Select(value => value.ToString("x2")));
|
||||
if (!string.Equals(actual, variant.ContentHash, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException($"Provider asset bundle '{variant.Uri}' hash did not match its catalog.");
|
||||
}
|
||||
|
||||
private void RefreshReceipts()
|
||||
{
|
||||
if (_pendingCommandIds.Count == 0)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
"rootNamespace": "GameCult.Eve.UnityScene",
|
||||
"references": [
|
||||
"GameCult.Eve.Surface",
|
||||
"GameCult.Eve.UnityScene"
|
||||
"GameCult.Eve.UnityScene",
|
||||
"UnityEngine.AssetBundleModule"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
|
|
|
|||
|
|
@ -78,9 +78,19 @@ namespace GameCult.Eve.UnityScene
|
|||
private GameObject InstantiateEntity(EveUnityPlayableWorldEntity entity, EveUnityPlayableWorldAssetBinding asset)
|
||||
{
|
||||
var prefab = _assetProvider.ResolvePrefab(asset);
|
||||
var instance = prefab != null
|
||||
? UnityEngine.Object.Instantiate(prefab, _root)
|
||||
: GameObject.CreatePrimitive(PrimitiveType.Capsule);
|
||||
GameObject instance;
|
||||
if (prefab != null)
|
||||
{
|
||||
instance = new GameObject(entity.EntityId);
|
||||
instance.transform.SetParent(_root, false);
|
||||
var visual = UnityEngine.Object.Instantiate(prefab, instance.transform);
|
||||
visual.transform.localPosition = Vector3.zero;
|
||||
visual.transform.localRotation = Quaternion.identity;
|
||||
}
|
||||
else
|
||||
{
|
||||
instance = GameObject.CreatePrimitive(PrimitiveType.Capsule);
|
||||
}
|
||||
|
||||
if (instance.transform.parent != _root)
|
||||
instance.transform.SetParent(_root, false);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ param(
|
|||
[string] $AetheriaRoot = "E:\Projects\Aetheria",
|
||||
[string] $CultLibRoot = "E:\Projects\CultLib",
|
||||
[int] $Port = 3076,
|
||||
[string] $OutputDirectory = "artifacts\aetheria-daemon"
|
||||
[string] $OutputDirectory = "artifacts\aetheria-daemon",
|
||||
[switch] $SkipAssetBundleBuild
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
|
@ -12,9 +13,11 @@ $projectRoot = Join-Path $repoRoot "TestProject"
|
|||
$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { Join-Path $repoRoot $OutputDirectory }
|
||||
$resultsPath = Join-Path $outputRoot "results.xml"
|
||||
$unityLogPath = Join-Path $outputRoot "unity.log"
|
||||
$bundleBuildLogPath = Join-Path $outputRoot "asset-bundle-build.log"
|
||||
$daemonLogPath = Join-Path $outputRoot "aetheria-daemon.log"
|
||||
$capturePath = Join-Path $outputRoot "aetheria-daemon-world.png"
|
||||
$replicaPath = Join-Path $outputRoot "eve-unity-replica.cc"
|
||||
$assetCachePath = Join-Path $outputRoot "asset-cache"
|
||||
$statePath = Join-Path $outputRoot "aetheria-witness-state.cc"
|
||||
$daemonProject = Join-Path $AetheriaRoot "Aetheria.State.Daemon\Aetheria.State.Daemon.csproj"
|
||||
|
||||
|
|
@ -40,6 +43,25 @@ foreach ($ephemeralPath in @(
|
|||
powershell -ExecutionPolicy Bypass -File (Join-Path $CultLibRoot "scripts\build-unity-package.ps1")
|
||||
if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package build failed with exit code $LASTEXITCODE" }
|
||||
|
||||
if (-not $SkipAssetBundleBuild) {
|
||||
$bundleBuilder = Start-Process -FilePath $UnityExe -ArgumentList @(
|
||||
"-batchmode", "-quit", "-projectPath", $AetheriaRoot,
|
||||
"-executeMethod", "Aetheria.Editor.EveAssetBundleBuilder.BuildWindows",
|
||||
"-logFile", $bundleBuildLogPath
|
||||
) -PassThru -WindowStyle Hidden
|
||||
Write-Host "AssetBundle builder PID: $($bundleBuilder.Id)"
|
||||
Write-Host "AssetBundle build log: $bundleBuildLogPath"
|
||||
Write-Host "Poll: Get-Content '$bundleBuildLogPath' -Tail 20"
|
||||
if (-not $bundleBuilder.WaitForExit(240000)) {
|
||||
Stop-Process -Id $bundleBuilder.Id -Force -ErrorAction SilentlyContinue
|
||||
throw "Aetheria AssetBundle build exceeded 240 seconds. See $bundleBuildLogPath"
|
||||
}
|
||||
if ($bundleBuilder.ExitCode -ne 0) {
|
||||
Get-Content $bundleBuildLogPath -Tail 160
|
||||
throw "Aetheria AssetBundle build failed with exit code $($bundleBuilder.ExitCode)"
|
||||
}
|
||||
}
|
||||
|
||||
$daemonArguments = @(
|
||||
"run", "--project", $daemonProject, "--",
|
||||
"--root", $AetheriaRoot,
|
||||
|
|
@ -71,6 +93,7 @@ try {
|
|||
$env:EVEUNITY_SURFACE_ID = "aetheria.game"
|
||||
$env:EVEUNITY_REPLICA_PATH = $replicaPath
|
||||
$env:EVEUNITY_AETHERIA_CAPTURE_PATH = $capturePath
|
||||
$env:EVEUNITY_ASSET_CACHE_PATH = $assetCachePath
|
||||
$arguments = @(
|
||||
"-batchmode", "-projectPath", $projectRoot,
|
||||
"-runTests", "-testPlatform", "PlayMode",
|
||||
|
|
@ -79,9 +102,9 @@ try {
|
|||
"-testResults", $resultsPath, "-logFile", $unityLogPath
|
||||
)
|
||||
$unity = Start-Process -FilePath $UnityExe -ArgumentList $arguments -PassThru -WindowStyle Hidden
|
||||
if (-not $unity.WaitForExit(90000)) {
|
||||
if (-not $unity.WaitForExit(300000)) {
|
||||
Stop-Process -Id $unity.Id -Force -ErrorAction SilentlyContinue
|
||||
throw "Unity witness exceeded 90 seconds. See $unityLogPath"
|
||||
throw "Unity witness exceeded 300 seconds. See $unityLogPath"
|
||||
}
|
||||
if ($unity.ExitCode -ne 0) { Get-Content $unityLogPath -Tail 160; throw "Unity witness failed with exit code $($unity.ExitCode)" }
|
||||
[xml] $results = Get-Content $resultsPath -Raw
|
||||
|
|
@ -93,6 +116,6 @@ try {
|
|||
}
|
||||
finally {
|
||||
if ($null -ne $daemon -and -not $daemon.HasExited) { Stop-Process -Id $daemon.Id -Force }
|
||||
Remove-Item Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_REPLICA_PATH, Env:EVEUNITY_AETHERIA_CAPTURE_PATH -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_REPLICA_PATH, Env:EVEUNITY_AETHERIA_CAPTURE_PATH, Env:EVEUNITY_ASSET_CACHE_PATH -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:AETHERIA_TRACE_EVE_SNAPSHOTS -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue