diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index 4119be571..338ef3025 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -3413,6 +3413,17 @@ static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity( DockingBayAssignments = (entity.DockingBayAssignments ?? Array.Empty()).ToArray(), Visibility = entity.Visibility, VisibilitySourceCount = entity.VisibilitySourceCount, + FogFieldEmitters = (entity.FogFieldEmitters ?? Array.Empty()) + .Select(emitter => new AetheriaRuntimeFogFieldEmitterCommit + { + Enabled = emitter.Enabled, + Radius = emitter.Radius, + Density = emitter.Density, + OffsetX = emitter.OffsetX, + OffsetZ = emitter.OffsetZ, + FalloffExponent = emitter.FalloffExponent + }) + .ToArray(), Contacts = (entity.Contacts ?? Array.Empty()) .Select(contact => ToRuntimeContact(contact, entityIndices)) .Where(contact => contact.TargetEntityIndex >= 0) diff --git a/Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs b/Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs index fa322a91e..7dfdf77e5 100644 --- a/Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs +++ b/Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs @@ -682,6 +682,31 @@ public sealed class AetheriaEntitySnapshot [Key(38)] public string AssignedAgentTaskId { get; set; } = ""; + + [Key(39)] + public AetheriaFogFieldEmitterSnapshot[] FogFieldEmitters { get; set; } = []; +} + +[MessagePackObject] +public sealed class AetheriaFogFieldEmitterSnapshot +{ + [Key(0)] + public bool Enabled { get; set; } = true; + + [Key(1)] + public double Radius { get; set; } + + [Key(2)] + public double Density { get; set; } = 1; + + [Key(3)] + public double OffsetX { get; set; } + + [Key(4)] + public double OffsetZ { get; set; } + + [Key(5)] + public double FalloffExponent { get; set; } = 2; } [MessagePackObject] diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index ce410f48d..2e12a689d 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -1675,6 +1675,71 @@ public class DaemonRuntimeDocumentTests Assert.That(bodySplats.All(index => Math.Abs(document.Splats.CenterY[index] + 45) < 0.0001)); } + [Test] + public void EntityOwnedFogFieldFollowsItsSoaEntityPose() + { + var entity = new AetheriaRuntimeEntitySnapshotCommit + { + EntityIndex = 4, + EntityId = "global:aetheria.run_state.run-dynamic-fog.zone.2.entity.4.v1", + IsActive = true, + PositionX = 30, + PositionZ = -10, + FogFieldEmitters = new[] + { + new AetheriaRuntimeFogFieldEmitterCommit + { + Radius = 24, + Density = 0.75, + OffsetX = 3, + OffsetZ = -2, + FalloffExponent = 4 + } + } + }; + var zone = new AetheriaRuntimeZoneSnapshotCommit + { + ZoneIndex = 2, + Entities = new[] { entity } + }; + var frame = new AetheriaRuntimeDaemonFrameDocument + { + FrameId = 9, + Run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "run-dynamic-fog", + CurrentZoneIndex = zone.ZoneIndex, + Zones = new[] { zone } + } + }; + + var document = AetheriaRuntimeGameDocuments.RenderSplatsViewport( + frame, + new AetheriaRuntimeViewportBounds { MinX = 0, MinY = -50, MaxX = 80, MaxY = 30 }); + var expectedSource = AetheriaRuntimeRunCheckpointCommit.EntityRecordKey( + frame.Run.RunId, + zone.ZoneIndex, + entity.EntityIndex) + ":fog-field:0"; + var splatIndex = Enumerable.Range(0, document.Splats.Count) + .Single(index => string.Equals(document.Splats.SourceKey[index], expectedSource, StringComparison.Ordinal)); + var layer = document.Layers[document.Splats.LayerIndex[splatIndex]]; + + Assert.AreEqual(AetheriaRuntimeRenderSplatLayerKeys.FogPatch, layer.LayerKey); + Assert.AreEqual(33, document.Splats.CenterX[splatIndex], 0.0001); + Assert.AreEqual(-12, document.Splats.CenterY[splatIndex], 0.0001); + Assert.AreEqual(24, document.Splats.HalfExtentX[splatIndex], 0.0001); + Assert.AreEqual(0.75, document.Splats.ValueR[splatIndex], 0.0001); + Assert.AreEqual(4, document.Splats.FalloffExponent[splatIndex], 0.0001); + + using var publisher = new AetheriaRuntimeDaemonSoaFramePublisher(9001); + using var soaFrame = publisher.BuildCurrentZoneEntities(frame); + Assert.IsNotNull(soaFrame); + var identity = soaFrame.View.Identities.Single(value => value.EntityIndex == entity.EntityIndex); + Assert.AreEqual(entity.EntityId, identity.EntityId); + Assert.IsFalse(identity.Selectable, "An undetected field owner remains in SoA without becoming targetable."); + Assert.IsFalse(identity.Controllable); + } + [Test] public void DaemonRenderQueriesPublishBodyViewsFromZoneSnapshot() { diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaFramePublisher.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaFramePublisher.cs index 3db4aead0..964da3575 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaFramePublisher.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaFramePublisher.cs @@ -94,6 +94,8 @@ namespace GameCult.Aetheria.State.Verse _demand = demand; } + public CultMeshBodyDemandPlan? CurrentDemandPlan() => _demand?.Plan(BodyId); + public AetheriaRuntimeDaemonSoaFrame? BuildCurrentZoneEntities( AetheriaRuntimeDaemonFrameDocument frame, AetheriaRuntimeCatalogSnapshot? catalog = null, @@ -103,8 +105,8 @@ namespace GameCult.Aetheria.State.Verse throw new ArgumentNullException(nameof(frame)); var demand = _demand?.Plan(BodyId); if (demand != null && - (!demand.HasConsumers || !demand.RequiresSharedMemory) && - !realtimeDemand) + (!demand.HasConsumers || + (!demand.RequiresSharedMemory && !demand.RequiresSharedFileMapping && !realtimeDemand))) return null; var run = frame.Run ?? new AetheriaRuntimeRunCheckpointCommit(); @@ -129,8 +131,14 @@ namespace GameCult.Aetheria.State.Verse .ToHashSet(); if (dockParent != null) visibleEntityIndices.Add(dockParent.EntityIndex); + var fieldContributorIndices = (zone?.Entities ?? Array.Empty()) + .Where(AetheriaRuntimeGameDocuments.HasActiveFogFieldEmitter) + .Select(entity => entity.EntityIndex) + .ToHashSet(); var entities = (zone?.Entities ?? Array.Empty()) - .Where(entity => entity != null && visibleEntityIndices.Contains(entity.EntityIndex)) + .Where(entity => entity != null && + (visibleEntityIndices.Contains(entity.EntityIndex) || + fieldContributorIndices.Contains(entity.EntityIndex))) .OrderBy(entity => entity.EntityIndex) .ToArray(); var pickups = (zone?.DroppedPickups ?? Array.Empty()) @@ -180,7 +188,7 @@ namespace GameCult.Aetheria.State.Verse { var bytes = write.Span[..checked((int)layout.TotalByteLength)]; bytes.Clear(); - WriteEntities(bytes, layout, entities); + WriteEntities(bytes, layout, entities, visibleEntityIndices); WritePickups(bytes, layout, pickups, pickupEntityIds, syntheticEntityIndices, entities.Length); WritePayloads(bytes, layout, payloads, payloadEntityIds, syntheticEntityIndices, entities.Length + pickups.Length); WriteCelestialBodies( @@ -226,8 +234,10 @@ namespace GameCult.Aetheria.State.Verse Kind = entity.Kind, Label = entity.Name, Faction = entity.FactionKey, - Selectable = entity.EntityIndex != controlled?.EntityIndex, - Controllable = entity.EntityIndex == controlled?.EntityIndex, + Selectable = visibleEntityIndices.Contains(entity.EntityIndex) && + entity.EntityIndex != controlled?.EntityIndex, + Controllable = visibleEntityIndices.Contains(entity.EntityIndex) && + entity.EntityIndex == controlled?.EntityIndex, AssetRef = AetheriaRuntimeAssets.ResolveEntityPrefabAssetRef(entity, catalog) }) .Concat(pickups.Select((pickup, pickupRow) => new AetheriaRuntimeDaemonSoaIdentityDocument @@ -428,7 +438,8 @@ namespace GameCult.Aetheria.State.Verse private static void WriteEntities( Span bytes, EntityHotSlabLayout layout, - IReadOnlyList entities) + IReadOnlyList entities, + ISet visibleEntityIndices) { for (var index = 0; index < entities.Count; index++) { @@ -443,7 +454,8 @@ namespace GameCult.Aetheria.State.Verse WriteFloat(bytes, layout.PhysicsBodyMass, index, 1.0); WriteFloat(bytes, layout.PhysicsBodyInverseMass, index, 1.0); WriteFloat(bytes, layout.RenderScale, index, 1.0); - bytes[checked((int)(layout.RenderVisibility + index * ByteStride))] = (byte)(entity.IsActive ? 1 : 0); + bytes[checked((int)(layout.RenderVisibility + index * ByteStride))] = + (byte)(entity.IsActive && visibleEntityIndices.Contains(entity.EntityIndex) ? 1 : 0); WriteInt32(bytes, layout.RenderLod + index * IntStride, 0); WriteUInt32(bytes, layout.RenderGroupId + index * IntStride, EntityRenderGroupId); } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs index 820055b2d..88a95e122 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs @@ -300,6 +300,12 @@ namespace GameCult.Aetheria.State.Verse foreach (var entity in zone.Entities ?? Array.Empty()) { + var entityKey = AetheriaRuntimeRunCheckpointCommit.EntityRecordKey( + context.RunId, + zone.ZoneIndex, + entity.EntityIndex); + AddEntityFogFieldEmitters(splats, layerIndices, entity, entityKey, normalizedViewport); + if (!IsPlayerControlled(entity)) continue; @@ -324,7 +330,7 @@ namespace GameCult.Aetheria.State.Verse 1, 1, 1, - AetheriaRuntimeRunCheckpointCommit.EntityRecordKey(context.RunId, zone.ZoneIndex, entity.EntityIndex)); + entityKey); } return new EveFieldsSplatsDocument @@ -454,6 +460,64 @@ namespace GameCult.Aetheria.State.Verse falloffExponent: 2); } + private static void AddEntityFogFieldEmitters( + RenderSplatBuilder splats, + IReadOnlyDictionary layerIndices, + AetheriaRuntimeEntitySnapshotCommit entity, + string entityKey, + AetheriaRuntimeViewportBounds viewport) + { + if (!HasActiveFogFieldEmitter(entity)) + return; + + var emitters = entity.FogFieldEmitters ?? Array.Empty(); + for (var emitterIndex = 0; emitterIndex < emitters.Count; emitterIndex++) + { + var emitter = emitters[emitterIndex]; + if (emitter == null || !emitter.Enabled || !double.IsFinite(emitter.Radius) || emitter.Radius <= 0) + continue; + + var centerX = entity.PositionX + emitter.OffsetX; + var centerY = entity.PositionZ + emitter.OffsetZ; + if (centerX + emitter.Radius < viewport.MinX || + centerX - emitter.Radius > viewport.MaxX || + centerY + emitter.Radius < viewport.MinY || + centerY - emitter.Radius > viewport.MaxY) + { + continue; + } + + splats.Add( + layerIndices[AetheriaRuntimeRenderSplatLayerKeys.FogPatch], + centerX, + centerY, + emitter.Radius, + emitter.Radius, + AetheriaRuntimeRenderSplatChannels.Tint, + EveFieldsSplatFalloffs.PowerPulse, + double.IsFinite(emitter.Density) ? Math.Max(0, emitter.Density) : 0, + 0, + 0, + 1, + $"{entityKey}:fog-field:{emitterIndex}", + falloffScale: 1, + falloffExponent: double.IsFinite(emitter.FalloffExponent) + ? Math.Max(0.0001, emitter.FalloffExponent) + : 2); + } + } + + public static bool HasActiveFogFieldEmitter(AetheriaRuntimeEntitySnapshotCommit entity) + { + return entity != null && + entity.IsActive && + (entity.FogFieldEmitters ?? Array.Empty()) + .Any(emitter => emitter != null && + emitter.Enabled && + double.IsFinite(emitter.Radius) && + emitter.Radius > 0); + } + private static void AddFossilSimplexBrush( RenderSplatBuilder splats, int layerIndex, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs index 004e6875c..5bdd18b27 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs @@ -1167,6 +1167,32 @@ namespace GameCult.Aetheria.State.Verse [Key(56)] public double SecurityRadius { get; set; } + + [Key(57)] + public IReadOnlyList FogFieldEmitters { get; set; } = + Array.Empty(); + } + + [MessagePackObject] + public sealed class AetheriaRuntimeFogFieldEmitterCommit + { + [Key(0)] + public bool Enabled { get; set; } = true; + + [Key(1)] + public double Radius { get; set; } + + [Key(2)] + public double Density { get; set; } = 1; + + [Key(3)] + public double OffsetX { get; set; } + + [Key(4)] + public double OffsetZ { get; set; } + + [Key(5)] + public double FalloffExponent { get; set; } = 2; } [MessagePackObject] diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 25cfede42..58c3bd5f7 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -1814,6 +1814,18 @@ edges with one worker and exactly two start/transfer/completion event triplets. `fog.surface_height` is the positive visual displacement the fossil `PowerBrush` camera supplied to `_NebulaSurfaceHeight`. Both derive from the same daemon-owned radial brushes, but neither may impersonate the other. +- Entity-owned fog cover: a durable or runtime entity may own typed fog-field + emitter components containing radius, density, local XZ offset, and falloff. + The owning entity remains an ordinary live SoA entity. The daemon derives each + demanded `fog.patch` splat from that entity's current pose and stamps the + canonical entity record key into the splat source identity. Every active field + contributor retains a row in the live entity SoA even when the observer has + not detected it; such a row is render-invisible and non-selectable until normal + contact visibility reveals the owner. Splat position is not stored independently + and generic clients cannot move, create, or preserve a fog field after its + owner or component disappears. No generated world uses + these components by default; gameplay commands may attach them later without + changing the rendering contract. - Fossil producer map: layer 9 coarse displacement and layer 10 fine waves compose `fog.surface_height`; those same producers plus layer 12 patch displacement compose `fog.patch_height`; layer 11 simplex/cellular density