Drive Unity worlds from live entity SoA

This commit is contained in:
Meta Vens 2026-07-12 12:33:36 +01:00
commit 712d91e67c
11 changed files with 184 additions and 6 deletions

View file

@ -5,6 +5,8 @@
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Description>Portable Eve surface and provider contracts.</Description>
<BaseOutputPath>$(MSBuildThisFileDirectory)..\..\..\.dotnet\bin\GameCult.Eve.Surface\</BaseOutputPath>
<BaseIntermediateOutputPath>$(MSBuildThisFileDirectory)..\..\..\.dotnet\obj\GameCult.Eve.Surface\</BaseIntermediateOutputPath>
</PropertyGroup>
<ItemGroup>

View file

@ -5,7 +5,7 @@
"unity": "2021.3",
"description": "Unity-compatible DTOs for gamecult.eve.surface.v1 documents.",
"dependencies": {
"org.gamecult.cultlib": "1.0.3"
"org.gamecult.cultlib": "1.0.4"
},
"keywords": [
"Eve",

View file

@ -28,6 +28,7 @@ namespace GameCult.Eve.UnityScene
typeof(EveSurfaceCommandRequest),
typeof(EveCommandReceiptDocument),
typeof(EveAssetCatalogDocument),
typeof(EveEntitySoaViewDocument),
typeof(CultMeshCdnArtifactManifest),
typeof(CultMeshCdnArtifactChunk)
};
@ -47,6 +48,7 @@ namespace GameCult.Eve.UnityScene
private CultMeshSnapshotSession? _snapshot;
private CultNetDocumentRegistry? _networkRegistry;
private CultNetShardDescriptor? _replicaShard;
private CultNetDatabaseSubscriptionClient? _subscriptions;
private EveProviderAdvertisementDocument? _advertisement;
private EveAdvertisedSurface? _advertisedSurface;
@ -90,6 +92,8 @@ namespace GameCult.Eve.UnityScene
public event Action<EveUnitySceneCommandReceipt>? CommandReceiptAvailable;
public event Action<EveEntitySoaViewDocument>? EntityViewAvailable;
public void Connect()
{
EnsureOpen();
@ -152,8 +156,10 @@ namespace GameCult.Eve.UnityScene
_nativeAssets.Clear();
_node?.Dispose();
_snapshot?.Dispose();
_subscriptions?.Dispose();
_node = null;
_snapshot = null;
_subscriptions = null;
_networkRegistry = null;
_replicaShard = null;
_pendingCommandIds.Clear();
@ -273,6 +279,47 @@ namespace GameCult.Eve.UnityScene
surface.Version);
SurfaceDocumentAvailable?.Invoke(CurrentSurfaceDocument);
AssetManifestDocumentAvailable?.Invoke(CurrentAssetManifestDocument);
EnsureLiveSubscriptions();
}
private void EnsureLiveSubscriptions()
{
if (_subscriptions != null) return;
var endpoint = new Uri(_endpoint);
var client = CultNetSchemaClients.CreateForEndpoint(_endpoint);
client.Connect(endpoint.Host, endpoint.Port);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (!client.Connected && DateTime.UtcNow < deadline)
System.Threading.Thread.Sleep(1);
if (!client.Connected)
{
client.Dispose();
throw new TimeoutException($"Timed out connecting live Eve state subscription to '{_endpoint}'.");
}
_subscriptions = new CultNetDatabaseSubscriptionClient(client, _node!.Database.Cache, _networkRegistry!);
_subscriptions.Changed += OnReplicatedDocumentChanged;
var lowered = new EveUnitySceneSurfaceLowerer()
.Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface);
string entityViewPointer = lowered.PlayableWorld == null
? ""
: lowered.PlayableWorld.EntityViewPointerId;
if (!string.IsNullOrWhiteSpace(entityViewPointer))
{
_subscriptions.SubscribeAsync(
"eve-unity-entity-view",
recordKeys: new[] { entityViewPointer },
schemaIds: new[] { EveEntitySoaViewDocument.SchemaId })
.GetAwaiter().GetResult();
if (_node.Database.Cache.Get(new CultRecordKey(entityViewPointer)) is EveEntitySoaViewDocument current)
EntityViewAvailable?.Invoke(current);
}
}
private void OnReplicatedDocumentChanged(CultNetReplicatedDocumentChange change)
{
if (change.Document is EveEntitySoaViewDocument entityView)
EntityViewAvailable?.Invoke(entityView);
}
private void RefreshAssetCatalog()

View file

@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Collections.Concurrent;
using GameCult.Eve.Surface;
using UnityEngine;
@ -14,6 +15,7 @@ namespace GameCult.Eve.UnityScene
IEveUnitySceneCommandSink,
IEveUnitySceneCommandReceiptSource,
IEveUnityProviderRefreshSource,
IEveUnityEntitySoaViewDocumentSource,
IEveUnityCameraRenderPolicySource,
IEveUnityNativeAssetProvider
{
@ -27,6 +29,11 @@ namespace GameCult.Eve.UnityScene
private EveUnityCultMeshLiveProviderTransport? _transport;
private EveUnitySceneLiveProviderBridge? _bridge;
private readonly ConcurrentQueue<EveEntitySoaViewDocument> _pendingEntityViews = new ConcurrentQueue<EveEntitySoaViewDocument>();
public EveEntitySoaViewDocument? CurrentEntityView { get; private set; }
public event Action<EveEntitySoaViewDocument>? EntityViewAvailable;
public EveUnityCultMeshProviderSelection? Selection { get; private set; }
@ -117,6 +124,15 @@ namespace GameCult.Eve.UnityScene
Selection = null;
}
private void Update()
{
EveEntitySoaViewDocument? latest = null;
while (_pendingEntityViews.TryDequeue(out var next)) latest = next;
if (latest == null) return;
CurrentEntityView = latest;
EntityViewAvailable?.Invoke(latest);
}
private EveUnitySceneLiveProviderBridge Bridge
{
get
@ -148,6 +164,7 @@ namespace GameCult.Eve.UnityScene
Selection.ProviderId,
Selection.SurfaceId,
runtimeId);
_transport.EntityViewAvailable += view => _pendingEntityViews.Enqueue(view);
_bridge = new EveUnitySceneLiveProviderBridge(_transport);
}
}

View file

@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Eve.Surface;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public interface IEveUnityEntitySoaViewDocumentSource
{
EveEntitySoaViewDocument? CurrentEntityView { get; }
event Action<EveEntitySoaViewDocument> EntityViewAvailable;
}
/// <summary>Projects portable entity SoA generations into the ordinary Unity scene sink.</summary>
public sealed class EveUnityEntitySoaPresenter
{
private readonly IEveUnityPlayableWorldSceneSink _sink;
private readonly HashSet<string> _known = new HashSet<string>(StringComparer.Ordinal);
public EveUnityEntitySoaPresenter(IEveUnityPlayableWorldSceneSink sink)
{
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
}
public void Apply(EveEntitySoaViewDocument document)
{
using var view = EveUnityEntitySoaView.Open(document);
var next = new HashSet<string>(StringComparer.Ordinal);
for (var row = 0; row < view.EntityCount; row++)
{
if (!view.TryReadInt32("entity.index", row, out var entityIndex)) continue;
if (view.TryReadByte("render.visibility", row, out var visible) && visible == 0) continue;
if (!view.TryReadVector3("transform.position", row, out var position)) continue;
view.TryReadFloat("transform.rotation.radians", row, out var rotationRadians);
view.TryReadFloat("render.scale", row, out var scale);
view.TryReadUInt32("render.group.id", row, out var groupId);
var renderGroup = view.RenderGroups.FirstOrDefault(group => group.GroupId == groupId);
var entityId = $"entity:{entityIndex}";
next.Add(entityId);
var assetRef = renderGroup?.MeshAssetRef ?? "";
_sink.UpsertEntity(
new EveUnityPlayableWorldEntity(
entityId,
entityId,
"entity",
entityId,
"",
assetRef,
position.x,
position.y,
position.z,
rotationRadians * 57.2957795f,
scale > 0f ? scale : renderGroup?.DefaultScale ?? 1f,
selectable: true,
controllable: false,
focusCommand: "",
moveCommand: "",
targetCommand: "",
actionCommand: "",
props: new Dictionary<string, string>()),
new EveUnityPlayableWorldAssetBinding(
assetRef,
"entity",
string.IsNullOrWhiteSpace(assetRef) ? "unity-generated-placeholder" : "provider-asset-ref"));
}
foreach (var entityId in _known.Where(entityId => !next.Contains(entityId)).ToArray())
_sink.RemoveEntity(entityId);
_known.Clear();
foreach (var entityId in next) _known.Add(entityId);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 28748c85c1677c04593ae92d59399070

View file

@ -47,7 +47,7 @@ namespace GameCult.Eve.UnityScene
public bool TryReadVector3(string semantic, int index, out Vector3 value)
{
value = default;
if (!TryColumn(semantic, index, "float32", 12, out var column, out var accessor)) return false;
if (!TryColumn(semantic, index, "float3", 12, out var column, out var accessor)) return false;
var offset = column.ByteOffset + (long)index * column.ElementStride;
value = new Vector3(accessor.ReadSingle(offset), accessor.ReadSingle(offset + 4), accessor.ReadSingle(offset + 8));
return true;
@ -69,6 +69,18 @@ namespace GameCult.Eve.UnityScene
return true;
}
public bool TryReadUInt32(string semantic, int index, out uint value)
{
value = default;
if (!TryColumn(semantic, index, "uint32", 4, out var column, out var accessor)) return false;
value = accessor.ReadUInt32(column.ByteOffset + (long)index * column.ElementStride);
return true;
}
public int EntityCount => Document.Columns.Length == 0
? 0
: Document.Columns.Min(column => column.ElementCount);
public bool TryReadByte(string semantic, int index, out byte value)
{
value = default;

View file

@ -16,7 +16,7 @@ namespace GameCult.Eve.UnityScene
[SerializeField] private MonoBehaviour? receiptSource;
[SerializeField] private MonoBehaviour? fallbackAssetProvider;
[SerializeField] private bool connectOnEnable;
[SerializeField] private bool refreshInUpdate = true;
[SerializeField] private bool refreshInUpdate;
[SerializeField] private float refreshIntervalSeconds = 1f;
[SerializeField] private bool renderShotTrajectories = true;

View file

@ -12,6 +12,9 @@ namespace GameCult.Eve.UnityScene
private readonly EveUnitySceneProviderConnection _connection;
private readonly EveUnityPlayableWorldLiveClient _client;
private readonly EveUnityPlayableWorldAssetManifestDocumentSource? _assetManifestSource;
private readonly IEveUnityEntitySoaViewDocumentSource? _entityViews;
private readonly EveUnityEntitySoaPresenter _entityPresenter;
private bool _entityViewsConnected;
private bool _assetManifestConnected;
public EveUnityPlayableWorldRuntime(
@ -29,6 +32,8 @@ namespace GameCult.Eve.UnityScene
AssetManifests = assetManifests ?? new EveUnityPlayableWorldAssetManifestCache();
_surfaceSource = new EveUnitySceneProviderSurfaceDocumentSource(surfaceDocuments);
_entityViews = surfaceDocuments as IEveUnityEntitySoaViewDocumentSource;
_entityPresenter = new EveUnityEntitySoaPresenter(sceneSink);
_connection = new EveUnitySceneProviderConnection(_surfaceSource, commandSink);
_client = new EveUnityPlayableWorldLiveClient(
_connection,
@ -104,7 +109,9 @@ namespace GameCult.Eve.UnityScene
public EveUnityPlayableWorldPresentation Connect()
{
EnsureAssetManifestConnected();
return _client.Connect();
var presentation = _client.Connect();
EnsureEntityViewsConnected();
return presentation;
}
public EveUnityPlayableWorldPresentation Refresh()
@ -158,6 +165,11 @@ namespace GameCult.Eve.UnityScene
public void Disconnect()
{
if (_entityViewsConnected && _entityViews != null)
{
_entityViews.EntityViewAvailable -= OnEntityViewAvailable;
_entityViewsConnected = false;
}
_client.Disconnect();
if (_assetManifestConnected && _assetManifestSource != null)
@ -184,6 +196,17 @@ namespace GameCult.Eve.UnityScene
AssetManifests.Connect(_assetManifestSource);
_assetManifestConnected = true;
}
private void EnsureEntityViewsConnected()
{
if (_entityViewsConnected || _entityViews == null) return;
_entityViews.EntityViewAvailable += OnEntityViewAvailable;
_entityViewsConnected = true;
if (_entityViews.CurrentEntityView != null)
_entityPresenter.Apply(_entityViews.CurrentEntityView);
}
private void OnEntityViewAvailable(EveEntitySoaViewDocument document) => _entityPresenter.Apply(document);
}
public sealed class EveUnityLivePlayableWorldAssetProvider : IEveUnityNativeAssetProvider

View file

@ -37,7 +37,7 @@ namespace GameCult.Eve.UnityScene.Tests
Buffers = new[] { new EveEntitySoaBuffer { BufferId = "hot", Backend = "memory_mapped_file", Location = location, ByteLength = 32, Generation = 7 } },
Columns = new[]
{
new EveEntitySoaColumn { ColumnId = "position", Semantic = "transform.position", BufferId = "hot", ScalarType = "float32", ElementStride = 12, ElementCount = 2 },
new EveEntitySoaColumn { ColumnId = "position", Semantic = "transform.position", BufferId = "hot", ScalarType = "float3", ElementStride = 12, ElementCount = 2 },
new EveEntitySoaColumn { ColumnId = "entity-index", Semantic = "entity.index", BufferId = "hot", ScalarType = "int32", ByteOffset = 24, ElementStride = 4, ElementCount = 2 }
}
};

View file

@ -5,7 +5,7 @@
"unity": "2021.3",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {
"org.gamecult.cultlib": "1.0.3",
"org.gamecult.cultlib": "1.0.4",
"org.gamecult.eve.plugin-fields": "0.1.1",
"org.gamecult.eve.surface": "0.1.0",
"com.unity.mathematics": "1.3.2"