Add entity-owned dynamic fog fields

This commit is contained in:
Meta Vens 2026-07-22 02:09:30 +01:00
commit 04c0b77c94
7 changed files with 224 additions and 9 deletions

View file

@ -3413,6 +3413,17 @@ static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity(
DockingBayAssignments = (entity.DockingBayAssignments ?? Array.Empty<int>()).ToArray(),
Visibility = entity.Visibility,
VisibilitySourceCount = entity.VisibilitySourceCount,
FogFieldEmitters = (entity.FogFieldEmitters ?? Array.Empty<AetheriaFogFieldEmitterSnapshot>())
.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<AetheriaEntityContactSnapshot>())
.Select(contact => ToRuntimeContact(contact, entityIndices))
.Where(contact => contact.TargetEntityIndex >= 0)

View file

@ -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]

View file

@ -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()
{

View file

@ -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<AetheriaRuntimeEntitySnapshotCommit>())
.Where(AetheriaRuntimeGameDocuments.HasActiveFogFieldEmitter)
.Select(entity => entity.EntityIndex)
.ToHashSet();
var entities = (zone?.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
.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<AetheriaRuntimeDroppedPickupCommit>())
@ -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<byte> bytes,
EntityHotSlabLayout layout,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities)
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
ISet<int> 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);
}

View file

@ -300,6 +300,12 @@ namespace GameCult.Aetheria.State.Verse
foreach (var entity in zone.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
{
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<string, int> layerIndices,
AetheriaRuntimeEntitySnapshotCommit entity,
string entityKey,
AetheriaRuntimeViewportBounds viewport)
{
if (!HasActiveFogFieldEmitter(entity))
return;
var emitters = entity.FogFieldEmitters ?? Array.Empty<AetheriaRuntimeFogFieldEmitterCommit>();
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<AetheriaRuntimeFogFieldEmitterCommit>())
.Any(emitter => emitter != null &&
emitter.Enabled &&
double.IsFinite(emitter.Radius) &&
emitter.Radius > 0);
}
private static void AddFossilSimplexBrush(
RenderSplatBuilder splats,
int layerIndex,

View file

@ -1167,6 +1167,32 @@ namespace GameCult.Aetheria.State.Verse
[Key(56)]
public double SecurityRadius { get; set; }
[Key(57)]
public IReadOnlyList<AetheriaRuntimeFogFieldEmitterCommit> FogFieldEmitters { get; set; } =
Array.Empty<AetheriaRuntimeFogFieldEmitterCommit>();
}
[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]

View file

@ -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