Keep live CultMesh polling off the Unity main thread
This commit is contained in:
parent
dd985d5cf0
commit
5fabae056b
4 changed files with 142 additions and 60 deletions
|
|
@ -15,6 +15,10 @@ selection filters; they are not product knowledge required by the client.
|
|||
Await `PrepareAsync()` before mounting the bootstrap or reading provider ports.
|
||||
Discovery and session setup never run synchronously from Unity lifecycle or
|
||||
input getters, so an unavailable provider cannot stall the editor main thread.
|
||||
Live entity, field, and receipt polling likewise runs as one non-overlapping
|
||||
background operation. Unity's main thread only applies completed typed
|
||||
documents, keeping Play, Pause, inspection, and rendering responsive while a
|
||||
provider is slow or temporarily unreachable.
|
||||
|
||||
The package never imports provider product assemblies.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using GameCult.Eve.PluginFields;
|
||||
|
|
@ -71,6 +72,8 @@ namespace GameCult.Eve.UnityScene
|
|||
private long _lastQueuedEntityViewEpoch = -1;
|
||||
private long _lastQueuedEntityViewSequence = -1;
|
||||
private readonly Dictionary<string, long> _lastFieldsFrameByRecord = new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
private Task<LivePollResult>? _livePoll;
|
||||
private bool _disposed;
|
||||
|
||||
public EveUnityCultMeshLiveProviderTransport(
|
||||
string replicaPath,
|
||||
|
|
@ -186,8 +189,7 @@ namespace GameCult.Eve.UnityScene
|
|||
|
||||
public void PumpLiveEvents()
|
||||
{
|
||||
PollCurrentEntityView();
|
||||
PollCurrentFields();
|
||||
CompleteLivePoll();
|
||||
while (_liveDocuments.TryDequeue(out var document))
|
||||
{
|
||||
if (document is EveEntitySoaViewDocument entityView)
|
||||
|
|
@ -199,54 +201,111 @@ namespace GameCult.Eve.UnityScene
|
|||
else if (document is EveCommandReceiptDocument receipt)
|
||||
PublishReceipt(receipt);
|
||||
}
|
||||
PollPendingReceipts();
|
||||
ScheduleLivePoll();
|
||||
}
|
||||
|
||||
private void PollCurrentFields()
|
||||
private void CompleteLivePoll()
|
||||
{
|
||||
if (DateTime.UtcNow < _nextFieldsPollUtc)
|
||||
if (_livePoll == null || !_livePoll.IsCompleted)
|
||||
return;
|
||||
_nextFieldsPollUtc = DateTime.UtcNow.AddMilliseconds(250);
|
||||
var world = new EveUnitySceneSurfaceLowerer()
|
||||
.Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface)
|
||||
.PlayableWorld;
|
||||
if (world == null)
|
||||
return;
|
||||
foreach (var recordRef in world.FieldVolumes
|
||||
.Select(field => field.DocumentRef)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal))
|
||||
var completed = _livePoll;
|
||||
_livePoll = null;
|
||||
var result = completed.GetAwaiter().GetResult();
|
||||
if (result.Error != null)
|
||||
throw new InvalidOperationException("EveUnity live provider polling failed.", result.Error);
|
||||
foreach (var entityView in result.EntityViews)
|
||||
QueueEntityView(entityView);
|
||||
foreach (var fields in result.Fields)
|
||||
{
|
||||
var current = _snapshot!
|
||||
.FetchDocumentsAsync<EveFieldsSplatsDocument>(
|
||||
recordKeys: new[] { recordRef },
|
||||
schemaIds: new[] { EveFieldsSchemas.Splats })
|
||||
.GetAwaiter().GetResult().FirstOrDefault();
|
||||
if (current == null ||
|
||||
(_lastFieldsFrameByRecord.TryGetValue(recordRef, out var frame) && current.FrameId <= frame))
|
||||
if (_lastFieldsFrameByRecord.TryGetValue(fields.RecordRef, out var frame) && fields.Document.FrameId <= frame)
|
||||
continue;
|
||||
_lastFieldsFrameByRecord[recordRef] = current.FrameId;
|
||||
_liveDocuments.Enqueue(current);
|
||||
_lastFieldsFrameByRecord[fields.RecordRef] = fields.Document.FrameId;
|
||||
_liveDocuments.Enqueue(fields.Document);
|
||||
}
|
||||
foreach (var receipt in result.Receipts)
|
||||
_liveDocuments.Enqueue(receipt);
|
||||
}
|
||||
|
||||
private void PollCurrentEntityView()
|
||||
private void ScheduleLivePoll()
|
||||
{
|
||||
if (DateTime.UtcNow < _nextEntityViewPollUtc)
|
||||
if (_disposed || _snapshot == null || _livePoll != null)
|
||||
return;
|
||||
_nextEntityViewPollUtc = DateTime.UtcNow.AddMilliseconds(250);
|
||||
var lowered = new EveUnitySceneSurfaceLowerer()
|
||||
.Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface);
|
||||
var pointer = lowered.PlayableWorld?.EntityViewPointerId ?? "";
|
||||
if (string.IsNullOrWhiteSpace(pointer))
|
||||
var now = DateTime.UtcNow;
|
||||
var pollEntityView = now >= _nextEntityViewPollUtc;
|
||||
var pollFields = now >= _nextFieldsPollUtc;
|
||||
var pollReceipts = _pendingCommandIds.Count > 0 && now >= _nextReceiptPollUtc;
|
||||
if (!pollEntityView && !pollFields && !pollReceipts)
|
||||
return;
|
||||
var current = _snapshot!
|
||||
.FetchDocumentsAsync<EveEntitySoaViewDocument>(
|
||||
recordKeys: new[] { pointer },
|
||||
schemaIds: new[] { EveEntitySoaViewDocument.SchemaId })
|
||||
.GetAwaiter().GetResult().FirstOrDefault();
|
||||
if (current != null)
|
||||
QueueEntityView(current);
|
||||
if (pollEntityView) _nextEntityViewPollUtc = now.AddMilliseconds(250);
|
||||
if (pollFields) _nextFieldsPollUtc = now.AddMilliseconds(250);
|
||||
if (pollReceipts) _nextReceiptPollUtc = now.AddMilliseconds(250);
|
||||
|
||||
var world = (pollEntityView || pollFields)
|
||||
? new EveUnitySceneSurfaceLowerer()
|
||||
.Lower(CurrentSurfaceDocument.SurfaceDocument, CurrentSurfaceDocument.AdvertisedSurface)
|
||||
.PlayableWorld
|
||||
: null;
|
||||
var entityViewRef = pollEntityView ? world?.EntityViewPointerId ?? "" : "";
|
||||
var fieldRefs = pollFields && world != null
|
||||
? world.FieldVolumes
|
||||
.Select(field => field.DocumentRef)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray()
|
||||
: Array.Empty<string>();
|
||||
var receiptKeys = Array.Empty<string>();
|
||||
if (pollReceipts)
|
||||
{
|
||||
var interaction = RequireWorldInteraction();
|
||||
if (!string.IsNullOrWhiteSpace(interaction.ReceiptRecordRef))
|
||||
receiptKeys = _pendingCommandIds
|
||||
.Select(commandId => ChildRecordKey(interaction.ReceiptRecordRef, commandId))
|
||||
.ToArray();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(entityViewRef) && fieldRefs.Length == 0 && receiptKeys.Length == 0)
|
||||
return;
|
||||
_livePoll = PollLiveAsync(entityViewRef, fieldRefs, receiptKeys);
|
||||
}
|
||||
|
||||
private async Task<LivePollResult> PollLiveAsync(
|
||||
string entityViewRef,
|
||||
string[] fieldRefs,
|
||||
string[] receiptKeys)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entities = string.IsNullOrWhiteSpace(entityViewRef)
|
||||
? Array.Empty<EveEntitySoaViewDocument>()
|
||||
: (await _snapshot!.FetchDocumentsAsync<EveEntitySoaViewDocument>(
|
||||
recordKeys: new[] { entityViewRef },
|
||||
schemaIds: new[] { EveEntitySoaViewDocument.SchemaId })
|
||||
.ConfigureAwait(false)).ToArray();
|
||||
var fields = new List<PolledFields>();
|
||||
foreach (var recordRef in fieldRefs)
|
||||
{
|
||||
var current = (await _snapshot!.FetchDocumentsAsync<EveFieldsSplatsDocument>(
|
||||
recordKeys: new[] { recordRef },
|
||||
schemaIds: new[] { EveFieldsSchemas.Splats })
|
||||
.ConfigureAwait(false)).FirstOrDefault();
|
||||
if (current != null)
|
||||
fields.Add(new PolledFields(recordRef, current));
|
||||
}
|
||||
var receipts = receiptKeys.Length == 0
|
||||
? Array.Empty<EveCommandReceiptDocument>()
|
||||
: (await _snapshot!.FetchDocumentsAsync<EveCommandReceiptDocument>(
|
||||
recordKeys: receiptKeys,
|
||||
schemaIds: new[] { EveCommandReceiptDocument.SchemaId })
|
||||
.ConfigureAwait(false)).ToArray();
|
||||
return new LivePollResult(entities, fields.ToArray(), receipts, null);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
return new LivePollResult(
|
||||
Array.Empty<EveEntitySoaViewDocument>(),
|
||||
Array.Empty<PolledFields>(),
|
||||
Array.Empty<EveCommandReceiptDocument>(),
|
||||
error);
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueEntityView(EveEntitySoaViewDocument document)
|
||||
|
|
@ -260,25 +319,6 @@ namespace GameCult.Eve.UnityScene
|
|||
_liveDocuments.Enqueue(document);
|
||||
}
|
||||
|
||||
private void PollPendingReceipts()
|
||||
{
|
||||
if (_pendingCommandIds.Count == 0 || DateTime.UtcNow < _nextReceiptPollUtc)
|
||||
return;
|
||||
_nextReceiptPollUtc = DateTime.UtcNow.AddMilliseconds(250);
|
||||
var interaction = RequireWorldInteraction();
|
||||
if (string.IsNullOrWhiteSpace(interaction.ReceiptRecordRef))
|
||||
return;
|
||||
var receipts = _snapshot!
|
||||
.FetchDocumentsAsync<EveCommandReceiptDocument>(
|
||||
recordKeys: _pendingCommandIds
|
||||
.Select(commandId => ChildRecordKey(interaction.ReceiptRecordRef, commandId))
|
||||
.ToArray(),
|
||||
schemaIds: new[] { EveCommandReceiptDocument.SchemaId })
|
||||
.GetAwaiter().GetResult();
|
||||
foreach (var receipt in receipts)
|
||||
PublishReceipt(receipt);
|
||||
}
|
||||
|
||||
public void SubmitCommand(EveSurfaceCommandRequest request)
|
||||
{
|
||||
if (request == null) throw new ArgumentNullException(nameof(request));
|
||||
|
|
@ -316,6 +356,7 @@ namespace GameCult.Eve.UnityScene
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
foreach (var bundle in _assetBundles) bundle.Unload(unloadAllLoadedObjects: false);
|
||||
_assetBundles.Clear();
|
||||
foreach (var lease in _assetBodyLeases) lease.Dispose();
|
||||
|
|
@ -340,6 +381,38 @@ namespace GameCult.Eve.UnityScene
|
|||
_lastFieldsFrameByRecord.Clear();
|
||||
}
|
||||
|
||||
private sealed class LivePollResult
|
||||
{
|
||||
public LivePollResult(
|
||||
EveEntitySoaViewDocument[] entityViews,
|
||||
PolledFields[] fields,
|
||||
EveCommandReceiptDocument[] receipts,
|
||||
Exception? error)
|
||||
{
|
||||
EntityViews = entityViews;
|
||||
Fields = fields;
|
||||
Receipts = receipts;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public EveEntitySoaViewDocument[] EntityViews { get; }
|
||||
public PolledFields[] Fields { get; }
|
||||
public EveCommandReceiptDocument[] Receipts { get; }
|
||||
public Exception? Error { get; }
|
||||
}
|
||||
|
||||
private sealed class PolledFields
|
||||
{
|
||||
public PolledFields(string recordRef, EveFieldsSplatsDocument document)
|
||||
{
|
||||
RecordRef = recordRef;
|
||||
Document = document;
|
||||
}
|
||||
|
||||
public string RecordRef { get; }
|
||||
public EveFieldsSplatsDocument Document { get; }
|
||||
}
|
||||
|
||||
public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset)
|
||||
{
|
||||
if (asset == null) throw new ArgumentNullException(nameof(asset));
|
||||
|
|
|
|||
|
|
@ -78,11 +78,15 @@ namespace GameCult.Eve.UnityScene
|
|||
throw new InvalidOperationException("The rendezvous endpoint advertised no compatible Verse endpoints.");
|
||||
|
||||
var failures = new List<string>();
|
||||
var observed = new List<string>();
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
var advertisement = (await FetchAdvertisementsAsync(candidate.Endpoint))
|
||||
var advertisements = await FetchAdvertisementsAsync(candidate.Endpoint);
|
||||
observed.AddRange(advertisements.Select(document =>
|
||||
$"{candidate.Endpoint}: {document.ProviderId}[{string.Join(",", document.Surfaces.Select(surface => $"{surface.SurfaceId}:{surface.SurfaceKind}"))}]"));
|
||||
var advertisement = advertisements
|
||||
.Where(document => string.IsNullOrWhiteSpace(providerId) ||
|
||||
string.Equals(document.ProviderId, providerId, StringComparison.Ordinal))
|
||||
.Select(document => new
|
||||
|
|
@ -113,7 +117,8 @@ namespace GameCult.Eve.UnityScene
|
|||
|
||||
var filter = $"provider='{providerId}', surface='{surfaceId}', kind='{surfaceKind}'";
|
||||
var detail = failures.Count == 0 ? "" : $" Endpoint failures: {string.Join(" | ", failures)}";
|
||||
throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}");
|
||||
var observedDetail = observed.Count == 0 ? "" : $" Observed: {string.Join(" | ", observed)}";
|
||||
throw new InvalidOperationException($"No advertised Eve surface matched {filter}.{detail}{observedDetail}");
|
||||
}
|
||||
|
||||
private static async Task<EveProviderAdvertisementDocument[]> FetchAdvertisementsAsync(string endpoint)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "org.gamecult.eve.unity-scene",
|
||||
"displayName": "Eve Unity Scene Lowering",
|
||||
"version": "0.3.68",
|
||||
"version": "0.3.69",
|
||||
"unity": "6000.0",
|
||||
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
|
||||
"dependencies": {
|
||||
|
|
|
|||
Reference in a new issue