Lower provider-authored continuous beams

This commit is contained in:
Meta Vens 2026-07-15 10:31:49 +01:00
commit e4d83f342f
11 changed files with 381 additions and 6 deletions

View file

@ -48,10 +48,11 @@
"depth": 0,
"source": "local",
"dependencies": {
"org.gamecult.cultlib": "1.0.9",
"org.gamecult.cultlib": "1.0.13",
"org.gamecult.eve.plugin-fields": "0.1.1",
"org.gamecult.eve.surface": "0.2.0",
"com.unity.mathematics": "1.3.2"
"org.gamecult.eve.surface": "0.2.1",
"com.unity.mathematics": "1.3.2",
"com.unity.modules.particlesystem": "1.0.0"
}
},
"org.gamecult.eve.unity-uitoolkit": {
@ -92,6 +93,12 @@
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.particlesystem": {
"version": "1.0.0",
"depth": 1,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.physics": {
"version": "1.0.0",
"depth": 0,

View file

@ -49,6 +49,11 @@ new deduplicated shot receipt from the advertised controlled source to the
advertised selection with authoritative applied or shield-absorbed damage.
SoA remains the only transform authority; the presentation component never
duplicates bodies or computes combat outcomes.
Continuous `beam.presentation` nodes bind provider-authored effect assets to
the current presented-entity generation. The generic beam renderer follows the
advertised source transform, applies provider-owned power to particle emission,
and reconciles asset lifecycles by semantic role. It never raycasts, applies
force, infers contact, collects cargo, or emits gameplay receipts.
The reusable `LightningCompute` renderer and its shader/material bundle live in
this runtime package with their original Unity GUIDs. Provider asset bundles may
therefore reference the effect without importing Aetheria gameplay code.

View file

@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.Globalization;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public sealed class EveUnityBeamPresentation
{
public const string SourceForwardDirection = "source-forward.v1";
public EveUnityBeamPresentation(EveUnitySceneNode node)
{
if (node == null) throw new ArgumentNullException(nameof(node));
Id = node.Id ?? "";
SourceEntityId = Get(node, "sourceEntityId");
AssetRole = Get(node, "assetRole");
DirectionMode = Get(node, "directionMode");
if (string.IsNullOrWhiteSpace(DirectionMode)) DirectionMode = SourceForwardDirection;
RenderChannel = Get(node, "renderChannel");
ActivationActionId = Get(node, "activationActionId");
Power = NonNegative(node, "power", 0f);
ActivationThreshold = NonNegative(node, "activationThreshold", 0.01f);
Radius = NonNegative(node, "radius", 0f);
MaximumDistance = NonNegative(node, "maximumDistance", 0f);
}
public string Id { get; }
public string SourceEntityId { get; }
public string AssetRole { get; }
public string DirectionMode { get; }
public string RenderChannel { get; }
public string ActivationActionId { get; }
public float Power { get; }
public float ActivationThreshold { get; }
public float Radius { get; }
public float MaximumDistance { get; }
public bool UsesSourceForward =>
string.Equals(DirectionMode, SourceForwardDirection, StringComparison.Ordinal);
public static IReadOnlyList<EveUnityBeamPresentation> FindAll(EveUnitySceneProjection? projection)
{
var found = new List<EveUnityBeamPresentation>();
if (projection != null) FindAll(projection.Root, found);
return found;
}
private static void FindAll(EveUnitySceneNode node, ICollection<EveUnityBeamPresentation> found)
{
if (string.Equals(node.ComponentKind, "beam.presentation", StringComparison.Ordinal))
found.Add(new EveUnityBeamPresentation(node));
foreach (var child in node.Children) FindAll(child, found);
}
private static string Get(EveUnitySceneNode node, string key) =>
node.Props.TryGetValue(key, out var value) ? value ?? "" : "";
private static float NonNegative(EveUnitySceneNode node, string key, float fallback)
{
if (!float.TryParse(Get(node, key), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ||
float.IsNaN(value) || float.IsInfinity(value))
return fallback;
return Math.Max(0f, value);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3086cd42c94f2624197d19704909f3f5

View file

@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using UnityEngine;
#nullable enable
namespace GameCult.Eve.UnityScene
{
public sealed class EveUnityBeamPresentationRenderer : MonoBehaviour
{
private readonly Dictionary<string, ActiveBeam> _active = new Dictionary<string, ActiveBeam>(StringComparer.Ordinal);
private EveUnityPlayableWorldClientHost? _host;
private IEveUnityGameObjectAssetProvider? _assetProvider;
public int ActiveBeamCount => _active.Count;
public int ActiveParticleSystemCount
{
get
{
var count = 0;
foreach (var beam in _active.Values)
if (beam.Root != null)
count += beam.Root.GetComponentsInChildren<ParticleSystem>(true).Length;
return count;
}
}
public void Bind(
EveUnityPlayableWorldClientHost host,
IEveUnityGameObjectAssetProvider? assetProvider = null)
{
_host = host != null ? host : throw new ArgumentNullException(nameof(host));
_assetProvider = assetProvider;
}
public void RefreshNow()
{
Apply(
EveUnityBeamPresentation.FindAll(_host?.ActiveProjection),
_host?.PresentedEntities,
_assetProvider);
}
public void Apply(
IReadOnlyList<EveUnityBeamPresentation> presentations,
IEveUnityPresentedEntityRegistry? entities,
IEveUnityGameObjectAssetProvider? assetProvider)
{
var retained = new HashSet<string>(StringComparer.Ordinal);
foreach (var presentation in presentations ?? Array.Empty<EveUnityBeamPresentation>())
{
if (string.IsNullOrWhiteSpace(presentation.Id) ||
string.IsNullOrWhiteSpace(presentation.SourceEntityId) ||
string.IsNullOrWhiteSpace(presentation.AssetRole) ||
!presentation.UsesSourceForward || entities == null ||
!entities.TryGetByEntityId(presentation.SourceEntityId, out var source))
continue;
retained.Add(presentation.Id);
if (!_active.TryGetValue(presentation.Id, out var beam) ||
!string.Equals(beam.SourceEntityId, presentation.SourceEntityId, StringComparison.Ordinal) ||
!string.Equals(beam.AssetRole, presentation.AssetRole, StringComparison.Ordinal) ||
beam.Root == null)
{
Remove(presentation.Id);
var prefab = assetProvider?.ResolvePrefab(new EveUnityPlayableWorldAssetBinding(
"", presentation.AssetRole, "provider-asset-ref"));
if (prefab == null) continue;
var root = Instantiate(prefab, source.Transform, false);
root.name = "Eve beam " + presentation.Id;
beam = new ActiveBeam(root, presentation.SourceEntityId, presentation.AssetRole);
_active[presentation.Id] = beam;
}
else if (beam.Root.transform.parent != source.Transform)
{
beam.Root.transform.SetParent(source.Transform, false);
}
var visiblePower = presentation.Power > presentation.ActivationThreshold
? presentation.Power
: 0f;
foreach (var particles in beam.Root.GetComponentsInChildren<ParticleSystem>(true))
{
var emission = particles.emission;
emission.rateOverTimeMultiplier = visiblePower;
}
beam.Power = visiblePower;
}
var stale = new List<string>();
foreach (var id in _active.Keys)
if (!retained.Contains(id)) stale.Add(id);
foreach (var id in stale) Remove(id);
}
public bool TryGetPower(string presentationId, out float power)
{
if (_active.TryGetValue(presentationId ?? "", out var beam) && beam.Root != null)
{
power = beam.Power;
return true;
}
power = 0f;
return false;
}
private void LateUpdate() => RefreshNow();
private void Remove(string id)
{
if (!_active.TryGetValue(id, out var beam)) return;
_active.Remove(id);
if (beam.Root != null)
{
if (Application.isPlaying) Destroy(beam.Root);
else DestroyImmediate(beam.Root);
}
}
private void OnDestroy()
{
foreach (var beam in _active.Values)
if (beam.Root != null)
{
if (Application.isPlaying) Destroy(beam.Root);
else DestroyImmediate(beam.Root);
}
_active.Clear();
_host = null;
_assetProvider = null;
}
private sealed class ActiveBeam
{
public ActiveBeam(GameObject root, string sourceEntityId, string assetRole)
{
Root = root;
SourceEntityId = sourceEntityId;
AssetRole = assetRole;
}
public GameObject Root { get; }
public string SourceEntityId { get; }
public string AssetRole { get; }
public float Power { get; set; }
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f91c99c24bc0e20488fa03daef2eec06

View file

@ -103,6 +103,9 @@ namespace GameCult.Eve.UnityScene
var aimPresentation = GetComponent<EveUnityAimPresentationRenderer>();
if (aimPresentation == null) aimPresentation = gameObject.AddComponent<EveUnityAimPresentationRenderer>();
aimPresentation.Bind(this);
var beamPresentation = GetComponent<EveUnityBeamPresentationRenderer>();
if (beamPresentation == null) beamPresentation = gameObject.AddComponent<EveUnityBeamPresentationRenderer>();
beamPresentation.Bind(this, Runtime.GameObjectAssetProvider);
var thermal = GetComponent<EveUnityThermalPresenter>();
var thermalHud = GetComponent<EveUnityThermalHudSink>();
if (thermalHud == null) thermalHud = gameObject.AddComponent<EveUnityThermalHudSink>();

View file

@ -49,7 +49,7 @@ namespace GameCult.Eve.UnityScene.Tests
var previous = RenderTexture.active;
RenderTexture.active = output;
var readback = new Texture2D(64, 64, TextureFormat.RGBAFloat, false, true);
var readback = new Texture2D(64, 64, TextureFormat.RGBA32, false, true);
readback.ReadPixels(new Rect(0, 0, 64, 64), 0, 0);
readback.Apply();
RenderTexture.active = previous;

View file

@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using UnityEngine;
#nullable enable
namespace GameCult.Eve.UnityScene.Tests
{
public sealed class EveUnityBeamPresentationTests
{
[Test]
public void ProjectionPreservesProviderBeamSourceAssetAndFossilShape()
{
var beam = EveUnityBeamPresentation.FindAll(Projection(Node(
"tractor", "beam.presentation", new Dictionary<string, string>(StringComparer.Ordinal)
{
["sourceEntityId"] = "run.zone.0.entity.4",
["assetRole"] = "effect.beam.tractor",
["directionMode"] = "source-forward.v1",
["renderChannel"] = "world.effects",
["activationActionId"] = "pilot.scoop",
["power"] = "0.625",
["activationThreshold"] = "0.01",
["radius"] = "25",
["maximumDistance"] = "75"
}))).Single();
Assert.That(beam.SourceEntityId, Is.EqualTo("run.zone.0.entity.4"));
Assert.That(beam.AssetRole, Is.EqualTo("effect.beam.tractor"));
Assert.That(beam.ActivationActionId, Is.EqualTo("pilot.scoop"));
Assert.That(beam.UsesSourceForward, Is.True);
Assert.That(beam.Power, Is.EqualTo(0.625f).Within(0.0001f));
Assert.That(beam.Radius, Is.EqualTo(25f));
Assert.That(beam.MaximumDistance, Is.EqualTo(75f));
}
[Test]
public void RendererReconcilesProviderPrefabAgainstPresentedSourceWithoutPhysics()
{
var host = new GameObject("beam-test-host");
var source = new GameObject("beam-test-source");
var prefab = new GameObject("beam-test-prefab");
prefab.AddComponent<ParticleSystem>();
var renderer = host.AddComponent<EveUnityBeamPresentationRenderer>();
var registry = new Registry("source", source.transform);
var provider = new Provider(prefab);
try
{
renderer.Apply(new[] { Presentation(0.5f) }, registry, provider);
Assert.That(provider.LastRole, Is.EqualTo("effect.beam.tractor"));
Assert.That(renderer.ActiveBeamCount, Is.EqualTo(1));
Assert.That(renderer.TryGetPower("tractor", out var power), Is.True);
Assert.That(power, Is.EqualTo(0.5f).Within(0.0001f));
var instance = source.transform.GetChild(0).gameObject;
Assert.That(instance.GetComponent<ParticleSystem>().emission.rateOverTimeMultiplier,
Is.EqualTo(0.5f).Within(0.0001f));
Assert.That(instance.GetComponentsInChildren<Collider>(true), Is.Empty);
Assert.That(instance.GetComponentsInChildren<Rigidbody>(true), Is.Empty);
renderer.Apply(new[] { Presentation(0.005f) }, registry, provider);
Assert.That(renderer.TryGetPower("tractor", out power), Is.True);
Assert.That(power, Is.Zero);
renderer.Apply(Array.Empty<EveUnityBeamPresentation>(), registry, provider);
Assert.That(renderer.ActiveBeamCount, Is.Zero);
}
finally
{
UnityEngine.Object.DestroyImmediate(host);
UnityEngine.Object.DestroyImmediate(source);
UnityEngine.Object.DestroyImmediate(prefab);
}
}
private static EveUnityBeamPresentation Presentation(float power) => new EveUnityBeamPresentation(Node(
"tractor", "beam.presentation", new Dictionary<string, string>(StringComparer.Ordinal)
{
["sourceEntityId"] = "source",
["assetRole"] = "effect.beam.tractor",
["directionMode"] = "source-forward.v1",
["power"] = power.ToString(System.Globalization.CultureInfo.InvariantCulture),
["activationThreshold"] = "0.01"
}));
private static EveUnitySceneProjection Projection(params EveUnitySceneNode[] children) =>
new EveUnitySceneProjection("provider", "surface", "world", "commands", "receipt", "provider", null,
Node("root", "surface", Empty(), children));
private static EveUnitySceneNode Node(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
params EveUnitySceneNode[] children) =>
new EveUnitySceneNode(id, kind, "scene-node", props, Empty(), Empty(), 0, 0,
Array.Empty<EveUnitySceneEmbeddedDocumentSlot>(), null, children);
private static IReadOnlyDictionary<string, string> Empty() =>
new Dictionary<string, string>(StringComparer.Ordinal);
private sealed class Registry : IEveUnityPresentedEntityRegistry
{
private readonly EveUnityPresentedEntityHandle _handle;
public Registry(string entityId, Transform transform)
{
_handle = new EveUnityPresentedEntityHandle(
new EveUnityPresentedEntity(0, entityId, "ship", "", "", Vector3.zero, 0, Vector3.zero,
1, 1, 0, 0, true, true, ""),
transform);
}
public EveUnityPresentedEntityGeneration? CurrentGeneration => null;
public bool TryGetByEntityId(string entityId, out EveUnityPresentedEntityHandle handle)
{
handle = _handle;
return string.Equals(entityId, _handle.Entity.EntityId, StringComparison.Ordinal);
}
public bool TryGetBySourceIndex(int sourceIndex, out EveUnityPresentedEntityHandle handle)
{
handle = _handle;
return sourceIndex == _handle.Entity.SourceIndex;
}
}
private sealed class Provider : IEveUnityGameObjectAssetProvider
{
private readonly GameObject _prefab;
public Provider(GameObject prefab) { _prefab = prefab; }
public string LastRole { get; private set; } = "";
public GameObject? ResolvePrefab(EveUnityPlayableWorldAssetBinding asset)
{
LastRole = asset.EntityKind;
return _prefab;
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7cd6b9cdacbecf14593d6ca2e0044849

View file

@ -1,14 +1,15 @@
{
"name": "org.gamecult.eve.unity-scene",
"displayName": "Eve Unity Scene Lowering",
"version": "0.3.27",
"version": "0.3.28",
"unity": "2021.3",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {
"org.gamecult.cultlib": "1.0.13",
"org.gamecult.eve.plugin-fields": "0.1.1",
"org.gamecult.eve.surface": "0.2.1",
"com.unity.mathematics": "1.3.2"
"com.unity.mathematics": "1.3.2",
"com.unity.modules.particlesystem": "1.0.0"
},
"keywords": [
"Eve",