Lower provider-owned stateless field particles

This commit is contained in:
Meta Vens 2026-07-16 12:23:00 +01:00
commit 4fc8296154
9 changed files with 935 additions and 4 deletions

View file

@ -728,7 +728,13 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
mapCamera.transform.position = mapBounds.center + Vector3.up * (mapCamera.farClipPlane * 0.25f);
mapCamera.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
mapCamera.targetTexture = target;
var fieldParticlesForMap = root.GetComponent<EveUnityFieldsParticleRenderer>();
var particleDrawsBeforeMap = fieldParticlesForMap?.DrawCount ?? 0;
mapCamera.Render();
var particleMapCameraIsolated = fieldParticlesForMap != null &&
fieldParticlesForMap.DrawCount == particleDrawsBeforeMap;
Assert.That(particleMapCameraIsolated, Is.True,
"The map camera executed the pilot-only field-particle pass.");
RenderTexture.active = target;
pixels.ReadPixels(new Rect(0, 0, 640, 360), 0, 0);
pixels.Apply();
@ -768,6 +774,15 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
"The generic volume lowerer did not rasterize any advertised Fields layers.");
Assert.That(fieldVolume.CompositeCount, Is.GreaterThan(0),
"The generic volume lowerer never composited into the pilot camera.");
var fieldParticles = fieldParticlesForMap;
Assert.That(fieldParticles, Is.Not.Null,
"The generic client did not install its field-particle lowerer.");
Assert.That(fieldParticles.ProgramReady, Is.True,
"The generic field-particle lowerer rejected the advertised provider ABI or assets.");
Assert.That(fieldParticles.PresentedFrameId, Is.GreaterThanOrEqualTo(0));
Assert.That(fieldParticles.ParticleCount, Is.EqualTo(65536));
Assert.That(fieldParticles.DispatchCount, Is.GreaterThan(0));
Assert.That(fieldParticles.DrawCount, Is.GreaterThan(0));
var fieldDocument = typeof(EveUnityFieldsVolumeRenderer)
.GetField("_document", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
?.GetValue(fieldVolume);
@ -791,6 +806,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
fieldVolume.PresentedFrameId,
fieldVolume.PresentedLayerCount,
fieldVolume.CompositeCount,
fieldParticles.PresentedFrameId,
fieldParticles.ParticleCount,
fieldParticles.DispatchCount,
fieldParticles.DrawCount,
particleMapCameraIsolated,
fieldParticles.LastGridCenter,
movementReceipt,
lookReceipt,
focusReceipt,
@ -864,6 +885,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
long fieldVolumeFrameId,
int fieldVolumeLayerCount,
long fieldVolumeCompositeCount,
long fieldParticleFrameId,
int fieldParticleCount,
int fieldParticleDispatchCount,
int fieldParticleDrawCount,
bool fieldParticleMapCameraIsolated,
Vector2 fieldParticleGridCenter,
WitnessReceipt movement,
WitnessReceipt look,
WitnessReceipt targeting,
@ -915,6 +942,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
fieldVolumeFrameId = fieldVolumeFrameId,
fieldVolumeLayerCount = fieldVolumeLayerCount,
fieldVolumeCompositeCount = fieldVolumeCompositeCount,
fieldParticleFrameId = fieldParticleFrameId,
fieldParticleCount = fieldParticleCount,
fieldParticleDispatchCount = fieldParticleDispatchCount,
fieldParticleDrawCount = fieldParticleDrawCount,
fieldParticleMapCameraIsolated = fieldParticleMapCameraIsolated,
fieldParticleGridCenter = fieldParticleGridCenter,
movement = movement != null,
aimPresentation = look != null && aimDotDistance >= 49.9f,
targeting = targeting != null,
@ -991,6 +1024,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
public long fieldVolumeFrameId;
public int fieldVolumeLayerCount;
public long fieldVolumeCompositeCount;
public long fieldParticleFrameId;
public int fieldParticleCount;
public int fieldParticleDispatchCount;
public int fieldParticleDrawCount;
public bool fieldParticleMapCameraIsolated;
public Vector2 fieldParticleGridCenter;
public bool movement;
public bool aimPresentation;
public bool targeting;

View file

@ -728,7 +728,13 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
mapCamera.transform.position = mapBounds.center + Vector3.up * (mapCamera.farClipPlane * 0.25f);
mapCamera.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
mapCamera.targetTexture = target;
var fieldParticlesForMap = root.GetComponent<EveUnityFieldsParticleRenderer>();
var particleDrawsBeforeMap = fieldParticlesForMap?.DrawCount ?? 0;
mapCamera.Render();
var particleMapCameraIsolated = fieldParticlesForMap != null &&
fieldParticlesForMap.DrawCount == particleDrawsBeforeMap;
Assert.That(particleMapCameraIsolated, Is.True,
"The map camera executed the pilot-only field-particle pass.");
RenderTexture.active = target;
pixels.ReadPixels(new Rect(0, 0, 640, 360), 0, 0);
pixels.Apply();
@ -768,6 +774,15 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
"The generic volume lowerer did not rasterize any advertised Fields layers.");
Assert.That(fieldVolume.CompositeCount, Is.GreaterThan(0),
"The generic volume lowerer never composited into the pilot camera.");
var fieldParticles = fieldParticlesForMap;
Assert.That(fieldParticles, Is.Not.Null,
"The generic client did not install its field-particle lowerer.");
Assert.That(fieldParticles.ProgramReady, Is.True,
"The generic field-particle lowerer rejected the advertised provider ABI or assets.");
Assert.That(fieldParticles.PresentedFrameId, Is.GreaterThanOrEqualTo(0));
Assert.That(fieldParticles.ParticleCount, Is.EqualTo(65536));
Assert.That(fieldParticles.DispatchCount, Is.GreaterThan(0));
Assert.That(fieldParticles.DrawCount, Is.GreaterThan(0));
var fieldDocument = typeof(EveUnityFieldsVolumeRenderer)
.GetField("_document", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
?.GetValue(fieldVolume);
@ -791,6 +806,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
fieldVolume.PresentedFrameId,
fieldVolume.PresentedLayerCount,
fieldVolume.CompositeCount,
fieldParticles.PresentedFrameId,
fieldParticles.ParticleCount,
fieldParticles.DispatchCount,
fieldParticles.DrawCount,
particleMapCameraIsolated,
fieldParticles.LastGridCenter,
movementReceipt,
lookReceipt,
focusReceipt,
@ -864,6 +885,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
long fieldVolumeFrameId,
int fieldVolumeLayerCount,
long fieldVolumeCompositeCount,
long fieldParticleFrameId,
int fieldParticleCount,
int fieldParticleDispatchCount,
int fieldParticleDrawCount,
bool fieldParticleMapCameraIsolated,
Vector2 fieldParticleGridCenter,
WitnessReceipt movement,
WitnessReceipt look,
WitnessReceipt targeting,
@ -915,6 +942,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
fieldVolumeFrameId = fieldVolumeFrameId,
fieldVolumeLayerCount = fieldVolumeLayerCount,
fieldVolumeCompositeCount = fieldVolumeCompositeCount,
fieldParticleFrameId = fieldParticleFrameId,
fieldParticleCount = fieldParticleCount,
fieldParticleDispatchCount = fieldParticleDispatchCount,
fieldParticleDrawCount = fieldParticleDrawCount,
fieldParticleMapCameraIsolated = fieldParticleMapCameraIsolated,
fieldParticleGridCenter = fieldParticleGridCenter,
movement = movement != null,
aimPresentation = look != null && aimDotDistance >= 49.9f,
targeting = targeting != null,
@ -991,6 +1024,12 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests
public long fieldVolumeFrameId;
public int fieldVolumeLayerCount;
public long fieldVolumeCompositeCount;
public long fieldParticleFrameId;
public int fieldParticleCount;
public int fieldParticleDispatchCount;
public int fieldParticleDrawCount;
public bool fieldParticleMapCameraIsolated;
public Vector2 fieldParticleGridCenter;
public bool movement;
public bool aimPresentation;
public bool targeting;

View file

@ -115,6 +115,9 @@ namespace GameCult.Eve.UnityScene
var fieldVolume = GetComponent<EveUnityFieldsVolumeRenderer>();
if (fieldVolume == null) fieldVolume = gameObject.AddComponent<EveUnityFieldsVolumeRenderer>();
fieldVolume.Bind(this, providerSurfaceDocuments as IEveUnityFieldsSplatsDocumentSource);
var fieldParticles = GetComponent<EveUnityFieldsParticleRenderer>();
if (fieldParticles == null) fieldParticles = gameObject.AddComponent<EveUnityFieldsParticleRenderer>();
fieldParticles.Bind(this, providerSurfaceDocuments as IEveUnityFieldsSplatsDocumentSource);
var presentation = Runtime.Connect();
ConnectionEpoch++;

View file

@ -105,12 +105,15 @@ namespace GameCult.Eve.UnityScene
var entities = new List<EveUnityPlayableWorldEntity>();
var fieldVolumes = new List<EveUnityFieldVolumeProjection>();
var fieldParticles = new List<EveUnityFieldParticlesProjection>();
foreach (var component in Flatten(worldRoot))
{
if (string.Equals(component.Kind, "world.entity3d", StringComparison.Ordinal))
entities.Add(BuildPlayableEntity(component));
else if (string.Equals(component.Kind, "field.volume3d", StringComparison.Ordinal))
fieldVolumes.Add(BuildFieldVolume(component));
else if (string.Equals(component.Kind, "field.particles3d", StringComparison.Ordinal))
fieldParticles.Add(BuildFieldParticles(component));
}
return new EveUnityPlayableWorldProjection(
@ -152,7 +155,8 @@ namespace GameCult.Eve.UnityScene
ParseVector3(worldRoot.GetProp("keyLightColor")),
ParseFloat(worldRoot.GetProp("keyLightIntensity"), 0f),
fieldVolumes,
worldRoot.GetProp("cameraLookAt"));
worldRoot.GetProp("cameraLookAt"),
fieldParticles);
}
private static EveUnityFieldVolumeProjection BuildFieldVolume(EveSurfaceComponent component) =>
@ -166,6 +170,16 @@ namespace GameCult.Eve.UnityScene
component.GetProp("quality", "normal"),
component.Props);
private static EveUnityFieldParticlesProjection BuildFieldParticles(EveSurfaceComponent component) =>
new EveUnityFieldParticlesProjection(
component.Id,
component.GetProp("documentRef"),
component.GetProp("documentSchema", EveFieldsSchemas.Splats),
component.GetProp("computeProgramAssetRef"),
component.GetProp("materialAssetRef"),
component.GetProp("renderChannel", "world.transparent"),
component.Props);
private static EveUnityPlayableWorldEntity BuildPlayableEntity(EveSurfaceComponent component)
{
var position = ParseVector3(component.GetProp("position"));
@ -396,7 +410,8 @@ namespace GameCult.Eve.UnityScene
(float r, float g, float b) keyLightColor = default,
float keyLightIntensity = 0f,
IReadOnlyList<EveUnityFieldVolumeProjection>? fieldVolumes = null,
string cameraLookAt = "")
string cameraLookAt = "",
IReadOnlyList<EveUnityFieldParticlesProjection>? fieldParticles = null)
{
WorldRootId = worldRootId ?? "";
StatePointerId = statePointerId ?? "";
@ -442,6 +457,7 @@ namespace GameCult.Eve.UnityScene
KeyLightColorB = keyLightColor.b;
KeyLightIntensity = keyLightIntensity;
FieldVolumes = fieldVolumes ?? Array.Empty<EveUnityFieldVolumeProjection>();
FieldParticles = fieldParticles ?? Array.Empty<EveUnityFieldParticlesProjection>();
CameraLookAt = cameraLookAt ?? "";
}
@ -536,6 +552,7 @@ namespace GameCult.Eve.UnityScene
public IReadOnlyList<EveUnityPlayableWorldEntity> Entities { get; }
public IReadOnlyList<EveUnityFieldVolumeProjection> FieldVolumes { get; }
public IReadOnlyList<EveUnityFieldParticlesProjection> FieldParticles { get; }
}
public sealed class EveUnityFieldVolumeProjection
@ -570,6 +587,35 @@ namespace GameCult.Eve.UnityScene
public IReadOnlyDictionary<string, string> Props { get; }
}
public sealed class EveUnityFieldParticlesProjection
{
public EveUnityFieldParticlesProjection(
string nodeId,
string documentRef,
string documentSchema,
string computeProgramAssetRef,
string materialAssetRef,
string renderChannel,
IReadOnlyDictionary<string, string>? props = null)
{
NodeId = nodeId ?? "";
DocumentRef = documentRef ?? "";
DocumentSchema = documentSchema ?? "";
ComputeProgramAssetRef = computeProgramAssetRef ?? "";
MaterialAssetRef = materialAssetRef ?? "";
RenderChannel = renderChannel ?? "";
Props = props ?? new Dictionary<string, string>(StringComparer.Ordinal);
}
public string NodeId { get; }
public string DocumentRef { get; }
public string DocumentSchema { get; }
public string ComputeProgramAssetRef { get; }
public string MaterialAssetRef { get; }
public string RenderChannel { get; }
public IReadOnlyDictionary<string, string> Props { get; }
}
public sealed class EveUnityPlayableWorldEntity
{
public EveUnityPlayableWorldEntity(

View file

@ -0,0 +1,680 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using GameCult.Eve.PluginFields;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.RenderGraphModule;
using UnityEngine.Rendering.Universal;
#nullable enable
namespace GameCult.Eve.UnityScene.Fields
{
public sealed class EveUnityFieldsParticleRenderer : MonoBehaviour
{
private EveUnityPlayableWorldClientHost? _host;
private IEveUnityFieldsSplatsDocumentSource? _source;
private EveFieldsSplatsDocument? _document;
private EveFieldsSplatLayerRenderer? _layers;
private GameObject? _layerRoot;
private readonly List<EveFieldsSplatLayerTarget> _targets = new List<EveFieldsSplatLayerTarget>();
private ComputeShader? _computeSource;
private ComputeShader? _compute;
private Material? _materialSource;
private Material? _material;
private IReadOnlyDictionary<string, string>? _computeMetadata;
private IReadOnlyDictionary<string, string>? _materialMetadata;
private readonly Dictionary<string, Texture> _assetTextures = new Dictionary<string, Texture>(StringComparer.Ordinal);
private ComputeBuffer? _particles;
private ComputeBuffer? _quadPoints;
private EveUnityFieldsParticlePass? _renderPass;
private string _activeNodeId = "";
private long _rasterizedFrameId = -1;
private Vector2 _rasterizedCenter = new Vector2(float.NaN, float.NaN);
public long PresentedFrameId { get; private set; } = -1;
public int ParticleCount { get; private set; }
public int DispatchCount { get; private set; }
public int DrawCount { get; private set; }
public Vector2 LastGridCenter { get; private set; }
public bool ProgramReady => _compute != null && _material != null && _particles != null && _quadPoints != null;
public static RenderPassEvent CompositionRenderPassEvent => RenderPassEvent.BeforeRenderingPostProcessing;
public void Bind(EveUnityPlayableWorldClientHost host, IEveUnityFieldsSplatsDocumentSource? source)
{
if (host == null) throw new ArgumentNullException(nameof(host));
if (_source != null) _source.FieldsSplatsAvailable -= OnFieldsSplatsAvailable;
_host = host;
_source = source;
if (_source != null) _source.FieldsSplatsAvailable += OnFieldsSplatsAvailable;
enabled = _source != null;
}
private void OnEnable() => RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
private void OnDisable()
{
RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering;
ReleaseState();
}
private void OnDestroy()
{
if (_source != null) _source.FieldsSplatsAvailable -= OnFieldsSplatsAvailable;
_source = null;
_host = null;
}
private void OnFieldsSplatsAvailable(EveFieldsSplatsDocument document)
{
if (document == null || document.FrameId < PresentedFrameId) return;
_document = document;
PresentedFrameId = document.FrameId;
}
private void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera)
{
if (_host == null || _document == null || camera == null ||
_host.ActiveCameraTransform != camera.transform)
return;
var field = ActiveField();
if (field == null || !PrepareFrame(field, camera, _document)) return;
_renderPass ??= new EveUnityFieldsParticlePass(this);
camera.GetUniversalAdditionalCameraData().scriptableRenderer.EnqueuePass(_renderPass);
}
private EveUnityFieldParticlesProjection? ActiveField() =>
_host?.ActiveWorld?.FieldParticles.FirstOrDefault(field =>
string.Equals(field.DocumentSchema, EveFieldsSchemas.Splats, StringComparison.Ordinal));
private bool PrepareFrame(
EveUnityFieldParticlesProjection field,
Camera camera,
EveFieldsSplatsDocument document)
{
if (!EnsurePrograms(field) || !EnsureBuffers(field)) return false;
EnsureTargets(field);
EnsureLayerRenderer();
if (_layers == null) return false;
var baseWidth = PositiveInt(field, "textureWidth", 512);
var baseHeight = PositiveInt(field, "textureHeight", 512);
var viewportWidth = (float)(document.Viewport.MaxX - document.Viewport.MinX);
var viewportHeight = (float)(document.Viewport.MaxY - document.Viewport.MinY);
if (!float.IsFinite(viewportWidth) || !float.IsFinite(viewportHeight) ||
viewportWidth <= 0f || viewportHeight <= 0f)
return false;
var center = new Vector2(
(float)((document.Viewport.MinX + document.Viewport.MaxX) * 0.5),
(float)((document.Viewport.MinY + document.Viewport.MaxY) * 0.5));
if (string.Equals(Prop(field, "viewportAnchor"), "active-camera.xz", StringComparison.Ordinal))
{
var snapLayer = Prop(field, "viewportSnapLayer");
var snapTarget = _targets.FirstOrDefault(target =>
string.Equals(target.LayerKey, snapLayer, StringComparison.Ordinal));
var snapWidth = snapTarget == null
? 0
: Mathf.Max(1, Mathf.RoundToInt(baseWidth * Mathf.Max(0.01f, snapTarget.WidthScale)));
var snapHeight = snapTarget == null
? 0
: Mathf.Max(1, Mathf.RoundToInt(baseHeight * Mathf.Max(0.01f, snapTarget.HeightScale)));
var snapTexels = PositiveInt(field, "viewportSnapTexels", 0);
var cellWorldSize = PositiveFloat(field, "cellWorldSize", 0f);
var span = PositiveInt(field, "span", 0);
if (!TryValidateSpatialLattice(
viewportWidth,
viewportHeight,
snapWidth,
snapHeight,
span,
cellWorldSize,
snapTexels)) return false;
center.x = SnapCameraCoordinate(camera.transform.position.x, viewportWidth, snapWidth, snapTexels);
center.y = SnapCameraCoordinate(camera.transform.position.z, viewportHeight, snapHeight, snapTexels);
}
if (_rasterizedFrameId != document.FrameId || _rasterizedCenter != center)
{
var projected = ProjectViewport(document, center, viewportWidth, viewportHeight);
_layers.Render(projected, _targets, baseWidth, baseHeight);
_rasterizedFrameId = document.FrameId;
_rasterizedCenter = center;
}
ApplyProgramBindings(field, document, center, viewportWidth, viewportHeight);
var kernel = ProgramKernel("update");
if (kernel < 0) return false;
var threadGroupSize = PositiveInt(field, "threadGroupSize", 1);
var groups = Mathf.CeilToInt((float)ParticleCount / threadGroupSize);
if (groups <= 0) return false;
_compute!.Dispatch(kernel, groups, 1, 1);
DispatchCount++;
LastGridCenter = center;
return true;
}
public static float SnapCameraCoordinate(
float coordinate,
float viewportWidth,
int snapTargetWidth,
float snapTexels)
{
if (!float.IsFinite(coordinate) || !float.IsFinite(viewportWidth) ||
!float.IsFinite(snapTexels) || viewportWidth <= 0f ||
snapTargetWidth <= 0 || snapTexels <= 0f)
throw new ArgumentOutOfRangeException(nameof(viewportWidth));
var snapLength = viewportWidth * snapTexels / snapTargetWidth;
var cell = (int)(coordinate / snapLength);
return cell * snapLength;
}
public static bool TryValidateSpatialLattice(
float viewportWidth,
float viewportHeight,
int gravityTextureWidth,
int gravityTextureHeight,
int span,
float cellWorldSize,
int gravityTexelsPerCell)
{
if (!float.IsFinite(viewportWidth) || !float.IsFinite(viewportHeight) ||
!float.IsFinite(cellWorldSize) || viewportWidth <= 0f || viewportHeight <= 0f ||
gravityTextureWidth <= 0 || gravityTextureHeight <= 0 || span <= 0 ||
cellWorldSize <= 0f || gravityTexelsPerCell <= 0)
return false;
var expectedExtent = span * cellWorldSize;
var expectedCellFromX = viewportWidth * gravityTexelsPerCell / gravityTextureWidth;
var expectedCellFromY = viewportHeight * gravityTexelsPerCell / gravityTextureHeight;
const float tolerance = 0.0001f;
return Mathf.Abs(viewportWidth - expectedExtent) <= tolerance &&
Mathf.Abs(viewportHeight - expectedExtent) <= tolerance &&
Mathf.Abs(expectedCellFromX - cellWorldSize) <= tolerance &&
Mathf.Abs(expectedCellFromY - cellWorldSize) <= tolerance;
}
private static EveFieldsSplatsDocument ProjectViewport(
EveFieldsSplatsDocument source,
Vector2 center,
float width,
float height) =>
new EveFieldsSplatsDocument
{
Schema = source.Schema,
FrameId = source.FrameId,
PublishedAtUtc = source.PublishedAtUtc,
SimulationTimeSeconds = source.SimulationTimeSeconds,
RunId = source.RunId,
ZoneIndex = source.ZoneIndex,
ZoneName = source.ZoneName,
Viewport = new EveFieldsViewport
{
MinX = center.x - width * 0.5,
MinY = center.y - height * 0.5,
MaxX = center.x + width * 0.5,
MaxY = center.y + height * 0.5
},
Layers = source.Layers,
Splats = source.Splats
};
private bool EnsurePrograms(EveUnityFieldParticlesProjection field)
{
var provider = _host?.NativeAssetProvider;
if (provider == null || provider is not IEveUnityNativeAssetMetadataProvider metadataProvider ||
string.IsNullOrWhiteSpace(field.ComputeProgramAssetRef) ||
string.IsNullOrWhiteSpace(field.MaterialAssetRef))
return false;
var computeBinding = new EveUnityPlayableWorldAssetBinding(
field.ComputeProgramAssetRef, "", "provider-asset-ref");
var materialBinding = new EveUnityPlayableWorldAssetBinding(
field.MaterialAssetRef, "", "provider-asset-ref");
var compute = provider.ResolveAsset(computeBinding, typeof(ComputeShader)) as ComputeShader;
var material = provider.ResolveAsset(materialBinding, typeof(Material)) as Material;
if (compute == null || material == null || material.shader == null || !material.shader.isSupported ||
!metadataProvider.TryResolveAssetMetadata(computeBinding, out var computeMetadata) ||
!metadataProvider.TryResolveAssetMetadata(materialBinding, out var materialMetadata) ||
!TryValidateProgramMetadata(computeMetadata, materialMetadata, out _) ||
!TryValidateAdvertisedContract(field, compute, material, computeMetadata, materialMetadata))
return false;
var resolvedTextures = new Dictionary<string, Texture>(StringComparer.Ordinal);
foreach (var binding in ParseBindings(Prop(field, "assetTextureBindings")))
{
var texture = provider.ResolveAsset(
new EveUnityPlayableWorldAssetBinding(binding.Key, "", "provider-asset-ref"),
typeof(Texture)) as Texture;
if (texture == null) return false;
resolvedTextures[binding.Value] = texture;
}
if (ReferenceEquals(compute, _computeSource) && ReferenceEquals(material, _materialSource) &&
string.Equals(field.NodeId, _activeNodeId, StringComparison.Ordinal) && ProgramReady &&
resolvedTextures.Count == _assetTextures.Count && resolvedTextures.All(binding =>
_assetTextures.TryGetValue(binding.Key, out var current) && ReferenceEquals(current, binding.Value)))
return true;
ReleaseProgramsAndBuffers();
_computeSource = compute;
_materialSource = material;
_computeMetadata = computeMetadata;
_materialMetadata = materialMetadata;
_activeNodeId = field.NodeId;
_compute = Instantiate(compute);
_material = new Material(material) { hideFlags = HideFlags.DontSave };
_assetTextures.Clear();
foreach (var binding in resolvedTextures) _assetTextures[binding.Key] = binding.Value;
foreach (var feature in Prop(field, "features").Split(';'))
{
var logical = feature.Trim();
if (!string.IsNullOrWhiteSpace(logical) &&
TryComputeProgramValue($"unity.particles.feature.{logical}.keyword", out var keyword) &&
!string.IsNullOrWhiteSpace(keyword))
_compute.EnableKeyword(keyword);
}
return true;
}
public static bool TryValidateProgramMetadata(
IReadOnlyDictionary<string, string>? compute,
IReadOnlyDictionary<string, string>? material,
out string error)
{
if (compute == null || material == null)
return Fail("Field-particle programs require compute and material metadata.", out error);
var computeKeys = new[]
{
"unity.particles.kernel.update",
"unity.particles.bufferPort.particles",
"unity.particles.texturePort.surfaceHeight",
"unity.particles.texturePort.tint",
"unity.particles.texturePort.hue",
"unity.particles.vectorPort.viewportTransform",
"unity.particles.vectorPort.timeVector",
"unity.particles.floatPort.time",
"unity.particles.floatPort.period",
"unity.particles.floatPort.spacing",
"unity.particles.intPort.span"
};
if (computeKeys.Any(key => !compute.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)))
return Fail("Field-particle compute metadata is missing a required logical port.", out error);
var materialKeys = new[]
{
"unity.particles.pass.render",
"unity.particles.bufferPort.particles",
"unity.particles.bufferPort.quadPoints"
};
if (materialKeys.Any(key => !material.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) ||
!int.TryParse(material["unity.particles.pass.render"], NumberStyles.Integer,
CultureInfo.InvariantCulture, out var pass) || pass < 0)
return Fail("Field-particle material metadata is missing a valid render pass or buffer port.", out error);
error = "";
return true;
}
private static bool TryValidateAdvertisedContract(
EveUnityFieldParticlesProjection field,
ComputeShader compute,
Material material,
IReadOnlyDictionary<string, string> computeMetadata,
IReadOnlyDictionary<string, string> materialMetadata)
{
var kernelName = computeMetadata["unity.particles.kernel.update"];
if (!compute.HasKernel(kernelName)) return false;
var kernel = compute.FindKernel(kernelName);
compute.GetKernelThreadGroupSizes(kernel, out var nativeX, out var nativeY, out var nativeZ);
if (!int.TryParse(Prop(field, "threadGroupSize"), NumberStyles.Integer,
CultureInfo.InvariantCulture, out var advertisedGroupSize) ||
advertisedGroupSize <= 0 || nativeX != advertisedGroupSize || nativeY != 1 || nativeZ != 1)
return false;
if (!int.TryParse(materialMetadata["unity.particles.pass.render"], NumberStyles.Integer,
CultureInfo.InvariantCulture, out var pass) || pass < 0 || pass >= material.passCount)
return false;
foreach (var binding in ParseBindingPairs(Prop(field, "layerBindings")))
if (!HasPort(computeMetadata, "texturePort", binding.Value)) return false;
foreach (var binding in ParseBindingPairs(Prop(field, "assetTextureBindings")))
if (!HasPort(computeMetadata, "texturePort", binding.Value)) return false;
foreach (var parameter in ParseBindingPairs(Prop(field, "floatParameters")))
if (!HasPort(computeMetadata, "floatPort", parameter.Key) ||
!TryFloat(parameter.Value, out _)) return false;
var floatParameters = ParseBindings(Prop(field, "floatParameters"));
if (!floatParameters.TryGetValue("spacing", out var spacingValue) ||
!TryFloat(spacingValue, out var spacing) ||
!TryFloat(Prop(field, "cellWorldSize"), out var cellWorldSize) ||
Mathf.Abs(spacing - cellWorldSize) > 0.0001f ||
PositiveInt(field, "gravityTexelsPerCell", 0) !=
PositiveInt(field, "viewportSnapTexels", 0)) return false;
foreach (var binding in ParseBindingPairs(Prop(field, "documentFloatBindings")))
{
var pieces = binding.Value.Split(',');
if (pieces.Length != 3 || !HasPort(computeMetadata, "floatPort", pieces[0].Trim()) ||
!TryFloat(pieces[1], out _) || !TryFloat(pieces[2], out _)) return false;
}
var timeVectorPort = Prop(field, "documentTimeVectorPort");
if (!string.IsNullOrWhiteSpace(timeVectorPort) &&
!HasPort(computeMetadata, "vectorPort", timeVectorPort)) return false;
foreach (var feature in Prop(field, "features").Split(';'))
{
var logical = feature.Trim();
if (!string.IsNullOrWhiteSpace(logical) &&
(!computeMetadata.TryGetValue($"unity.particles.feature.{logical}.keyword", out var keyword) ||
string.IsNullOrWhiteSpace(keyword))) return false;
}
return PositiveInt(field, "span", 0) > 0 &&
PositiveInt(field, "particleStrideBytes", 0) > 0 &&
PositiveInt(field, "textureWidth", 0) > 0 &&
PositiveInt(field, "textureHeight", 0) > 0;
}
private static bool HasPort(
IReadOnlyDictionary<string, string> metadata,
string kind,
string logical) =>
metadata.TryGetValue($"unity.particles.{kind}.{logical}", out var native) &&
!string.IsNullOrWhiteSpace(native);
private bool EnsureBuffers(EveUnityFieldParticlesProjection field)
{
var span = PositiveInt(field, "span", 0);
var stride = PositiveInt(field, "particleStrideBytes", 0);
if (span <= 0 || span > 4096 || stride <= 0 || stride > 1024) return false;
var count64 = (long)span * span;
if (count64 <= 0 || count64 > 16_777_216) return false;
var count = (int)count64;
if (_particles != null && ParticleCount == count) return true;
_particles?.Release();
_quadPoints?.Release();
_particles = new ComputeBuffer(count, stride, ComputeBufferType.Structured);
_quadPoints = new ComputeBuffer(6, sizeof(float) * 3, ComputeBufferType.Structured);
_quadPoints.SetData(new[]
{
new Vector3(-0.5f, 0.5f), new Vector3(0.5f, 0.5f), new Vector3(0.5f, -0.5f),
new Vector3(0.5f, -0.5f), new Vector3(-0.5f, -0.5f), new Vector3(-0.5f, 0.5f)
});
ParticleCount = count;
return true;
}
private void ApplyProgramBindings(
EveUnityFieldParticlesProjection field,
EveFieldsSplatsDocument document,
Vector2 center,
float viewportWidth,
float viewportHeight)
{
var kernel = ProgramKernel("update");
if (kernel < 0) return;
SetComputeBuffer(kernel, "particles", _particles!);
SetMaterialBuffer("particles", _particles!);
SetMaterialBuffer("quadPoints", _quadPoints!);
foreach (var binding in ParseBindings(Prop(field, "layerBindings")))
{
var target = _targets.FirstOrDefault(value => string.Equals(value.LayerKey, binding.Key, StringComparison.Ordinal));
if (target?.TargetTexture != null) SetComputeTexture(kernel, binding.Value, target.TargetTexture);
}
foreach (var binding in _assetTextures)
SetComputeTexture(kernel, binding.Key, binding.Value);
SetComputeVector("viewportTransform", new Vector4(center.x, center.y, viewportWidth, viewportHeight));
var time = (float)document.SimulationTimeSeconds;
var timeVectorPort = Prop(field, "documentTimeVectorPort");
if (!string.IsNullOrWhiteSpace(timeVectorPort))
SetComputeVector(timeVectorPort, new Vector4(time / 20f, time, time * 2f, time * 3f));
if (TryEvaluateDocumentFloatBindings(
document,
Prop(field, "documentFloatBindings"),
out var documentFloats))
foreach (var binding in documentFloats)
SetComputeFloat(binding.Key, binding.Value);
foreach (var parameter in ParseBindings(Prop(field, "floatParameters")))
if (TryFloat(parameter.Value, out var value)) SetComputeFloat(parameter.Key, value);
SetComputeInt("span", PositiveInt(field, "span", 0));
}
private void RenderParticles(
UnsafeCommandBuffer commands,
Camera camera,
TextureHandle colorTarget,
TextureHandle depthTarget)
{
if (_host == null || _host.ActiveCameraTransform != camera.transform || !ProgramReady) return;
if (depthTarget.IsValid()) commands.SetRenderTarget(colorTarget, depthTarget);
else commands.SetRenderTarget(colorTarget);
commands.SetViewport(camera.pixelRect);
commands.DrawProcedural(Matrix4x4.identity, _material!, MaterialPass("render"),
MeshTopology.Triangles, 6, ParticleCount);
DrawCount++;
}
private void EnsureTargets(EveUnityFieldParticlesProjection field)
{
var bindings = ParseBindings(Prop(field, "layerBindings"));
var descriptors = ParseBindings(Prop(field, "layerTargetDescriptors"));
if (string.Equals(_activeNodeId, field.NodeId, StringComparison.Ordinal) &&
_targets.Count == bindings.Count && _targets.All(target => bindings.ContainsKey(target.LayerKey)))
return;
_targets.Clear();
foreach (var binding in bindings)
{
var target = new EveFieldsSplatLayerTarget { LayerKey = binding.Key };
if (descriptors.TryGetValue(binding.Key, out var descriptor))
EveUnityFieldsVolumeRenderer.TryApplyLayerTargetDescriptor(target, descriptor);
_targets.Add(target);
}
}
private void EnsureLayerRenderer()
{
if (_layers != null) return;
_layerRoot = new GameObject("Eve Fields Particle Layers") { hideFlags = HideFlags.DontSave };
_layerRoot.transform.SetParent(transform, false);
_layers = _layerRoot.AddComponent<EveFieldsSplatLayerRenderer>();
}
private sealed class EveUnityFieldsParticlePass : ScriptableRenderPass
{
private readonly EveUnityFieldsParticleRenderer _owner;
private sealed class PassData
{
internal Camera Camera = null!;
internal EveUnityFieldsParticleRenderer Owner = null!;
internal TextureHandle ColorTarget;
internal TextureHandle DepthTarget;
}
internal EveUnityFieldsParticlePass(EveUnityFieldsParticleRenderer owner)
{
_owner = owner;
renderPassEvent = CompositionRenderPassEvent;
ConfigureInput(ScriptableRenderPassInput.Depth);
}
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
var cameraData = frameData.Get<UniversalCameraData>();
if (cameraData.camera == null || _owner._host == null ||
_owner._host.ActiveCameraTransform != cameraData.camera.transform)
return;
var resources = frameData.Get<UniversalResourceData>();
if (!resources.activeColorTexture.IsValid()) return;
using var builder = renderGraph.AddUnsafePass<PassData>("Eve Fields Particles", out var data);
data.Camera = cameraData.camera;
data.Owner = _owner;
data.ColorTarget = resources.activeColorTexture;
data.DepthTarget = resources.activeDepthTexture;
builder.UseTexture(data.ColorTarget, AccessFlags.ReadWrite);
if (data.DepthTarget.IsValid()) builder.UseTexture(data.DepthTarget, AccessFlags.Read);
builder.AllowPassCulling(false);
builder.SetRenderFunc(static (PassData pass, UnsafeGraphContext context) =>
pass.Owner.RenderParticles(context.cmd, pass.Camera, pass.ColorTarget, pass.DepthTarget));
}
}
private int ProgramKernel(string logical)
{
if (_compute == null || !TryComputeProgramValue($"unity.particles.kernel.{logical}", out var name) ||
string.IsNullOrWhiteSpace(name) || !_compute.HasKernel(name))
return -1;
return _compute.FindKernel(name);
}
private int MaterialPass(string logical) =>
TryMaterialProgramValue($"unity.particles.pass.{logical}", out var value) &&
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pass) && pass >= 0
? pass
: -1;
private void SetComputeTexture(int kernel, string logical, Texture texture)
{
if (TryComputeProgramValue($"unity.particles.texturePort.{logical}", out var property))
_compute!.SetTexture(kernel, property, texture);
}
private void SetComputeBuffer(int kernel, string logical, ComputeBuffer buffer)
{
if (TryComputeProgramValue($"unity.particles.bufferPort.{logical}", out var property))
_compute!.SetBuffer(kernel, property, buffer);
}
private void SetMaterialBuffer(string logical, ComputeBuffer buffer)
{
if (TryMaterialProgramValue($"unity.particles.bufferPort.{logical}", out var property))
_material!.SetBuffer(property, buffer);
}
private void SetComputeFloat(string logical, float value)
{
if (TryComputeProgramValue($"unity.particles.floatPort.{logical}", out var property))
_compute!.SetFloat(property, value);
}
private void SetComputeInt(string logical, int value)
{
if (TryComputeProgramValue($"unity.particles.intPort.{logical}", out var property))
_compute!.SetInt(property, value);
}
private void SetComputeVector(string logical, Vector4 value)
{
if (TryComputeProgramValue($"unity.particles.vectorPort.{logical}", out var property))
_compute!.SetVector(property, value);
}
private bool TryComputeProgramValue(string key, out string value)
{
value = "";
return _computeMetadata != null && _computeMetadata.TryGetValue(key, out value!);
}
private bool TryMaterialProgramValue(string key, out string value)
{
value = "";
return _materialMetadata != null && _materialMetadata.TryGetValue(key, out value!);
}
private void ReleaseState()
{
ReleaseProgramsAndBuffers();
if (_layerRoot != null) Destroy(_layerRoot);
_layerRoot = null;
_layers = null;
_targets.Clear();
_document = null;
PresentedFrameId = -1;
DispatchCount = 0;
DrawCount = 0;
LastGridCenter = default;
_rasterizedFrameId = -1;
_rasterizedCenter = new Vector2(float.NaN, float.NaN);
}
private void ReleaseProgramsAndBuffers()
{
_particles?.Release();
_quadPoints?.Release();
_particles = null;
_quadPoints = null;
ParticleCount = 0;
if (_compute != null) Destroy(_compute);
if (_material != null) Destroy(_material);
_compute = null;
_material = null;
_computeSource = null;
_materialSource = null;
_computeMetadata = null;
_materialMetadata = null;
_assetTextures.Clear();
_activeNodeId = "";
}
private static bool Fail(string message, out string error)
{
error = message;
return false;
}
private static Dictionary<string, string> ParseBindings(string value)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var pair in ParseBindingPairs(value)) result[pair.Key] = pair.Value;
return result;
}
private static IReadOnlyList<KeyValuePair<string, string>> ParseBindingPairs(string value)
{
var result = new List<KeyValuePair<string, string>>();
foreach (var pair in (value ?? "").Split(';'))
{
var separator = pair.IndexOf('=');
if (separator <= 0 || separator >= pair.Length - 1) continue;
result.Add(new KeyValuePair<string, string>(
pair.Substring(0, separator).Trim(),
pair.Substring(separator + 1).Trim()));
}
return result;
}
public static bool TryEvaluateDocumentFloatBindings(
EveFieldsSplatsDocument document,
string bindings,
out IReadOnlyList<KeyValuePair<string, float>> values)
{
var result = new List<KeyValuePair<string, float>>();
foreach (var binding in ParseBindingPairs(bindings))
{
if (!EveUnityFieldsVolumeRenderer.TryEvaluateDocumentFloatBinding(
document, binding.Key, binding.Value, out var port, out var value))
{
values = Array.Empty<KeyValuePair<string, float>>();
return false;
}
result.Add(new KeyValuePair<string, float>(port, value));
}
values = result;
return true;
}
private static string Prop(EveUnityFieldParticlesProjection field, string key) =>
field.Props.TryGetValue(key, out var value) ? value ?? "" : "";
private static int PositiveInt(EveUnityFieldParticlesProjection field, string key, int fallback) =>
int.TryParse(Prop(field, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) && value > 0
? value
: fallback;
private static float PositiveFloat(EveUnityFieldParticlesProjection field, string key, float fallback) =>
TryFloat(Prop(field, key), out var value) && value > 0f ? value : fallback;
private static bool TryFloat(string value, out float result) =>
float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result) && float.IsFinite(result);
}
}

View file

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

View file

@ -50,6 +50,72 @@ namespace GameCult.Eve.UnityScene.Tests
Is.EqualTo(RenderPassEvent.BeforeRenderingPostProcessing));
}
[Test]
public void ParticleRendererExecutesProviderProgramWithoutProviderCode()
{
Assert.That(typeof(EveUnityFieldsParticleRenderer).Assembly.GetReferencedAssemblies()
.Any(assembly => assembly.Name?.Contains("Aetheria", StringComparison.OrdinalIgnoreCase) == true), Is.False);
Assert.That(EveUnityFieldsParticleRenderer.CompositionRenderPassEvent,
Is.EqualTo(RenderPassEvent.BeforeRenderingPostProcessing));
}
[TestCase(11f, 6f)]
[TestCase(-11f, -6f)]
[TestCase(5.999f, 0f)]
public void ParticleRendererSnapsToAnIntegralGravityPixelMultiple(float coordinate, float expected)
{
Assert.That(EveUnityFieldsParticleRenderer.SnapCameraCoordinate(
coordinate,
1536f,
2048,
8f), Is.EqualTo(expected).Within(0.0001f));
}
[Test]
public void ParticleRendererAcceptsAlignedGravityAndParticleLattices()
{
Assert.That(EveUnityFieldsParticleRenderer.TryValidateSpatialLattice(
1536f, 1536f, 2048, 2048, 256, 6f, 8), Is.True);
}
[Test]
public void ParticleRendererRejectsAliasedGravityAndParticleLattices()
{
Assert.That(EveUnityFieldsParticleRenderer.TryValidateSpatialLattice(
2000f, 2000f, 2048, 2048, 256, 6f, 8), Is.False);
}
[Test]
public void ParticleRendererFailsClosedWithoutCompleteNativeAbi()
{
var compute = RequiredParticleComputeMetadata();
var material = RequiredParticleMaterialMetadata();
Assert.That(EveUnityFieldsParticleRenderer.TryValidateProgramMetadata(compute, material, out var error),
Is.True, error);
compute.Remove("unity.particles.intPort.span");
Assert.That(EveUnityFieldsParticleRenderer.TryValidateProgramMetadata(compute, material, out error),
Is.False);
Assert.That(error, Does.Contain("logical port"));
}
[Test]
public void ParticleRendererPreservesRepeatedDocumentSourcesForDistinctPorts()
{
var document = new EveFieldsSplatsDocument { SimulationTimeSeconds = 12.5 };
Assert.That(EveUnityFieldsParticleRenderer.TryEvaluateDocumentFloatBindings(
document,
"simulationTimeSeconds=time,1,0;simulationTimeSeconds=flowScroll,0.025,0",
out var bindings), Is.True);
Assert.That(bindings.Count, Is.EqualTo(2));
Assert.That(bindings[0].Key, Is.EqualTo("time"));
Assert.That(bindings[0].Value, Is.EqualTo(12.5f));
Assert.That(bindings[1].Key, Is.EqualTo("flowScroll"));
Assert.That(bindings[1].Value, Is.EqualTo(0.3125f));
}
[Test]
public void VolumeProgramAcceptsPortableTwoPassLifecycle()
{
@ -174,6 +240,30 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(File.Exists("Packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveFieldsSplatsCore.hlsl"), Is.True);
}
private static Dictionary<string, string> RequiredParticleComputeMetadata() =>
new Dictionary<string, string>(StringComparer.Ordinal)
{
["unity.particles.kernel.update"] = "UpdateParticles",
["unity.particles.bufferPort.particles"] = "particles",
["unity.particles.texturePort.surfaceHeight"] = "_SurfaceHeight",
["unity.particles.texturePort.tint"] = "_Tint",
["unity.particles.texturePort.hue"] = "_Hue",
["unity.particles.vectorPort.viewportTransform"] = "_GridTransform",
["unity.particles.vectorPort.timeVector"] = "_Time",
["unity.particles.floatPort.time"] = "time",
["unity.particles.floatPort.period"] = "period",
["unity.particles.floatPort.spacing"] = "spacing",
["unity.particles.intPort.span"] = "span"
};
private static Dictionary<string, string> RequiredParticleMaterialMetadata() =>
new Dictionary<string, string>(StringComparer.Ordinal)
{
["unity.particles.pass.render"] = "0",
["unity.particles.bufferPort.particles"] = "particles",
["unity.particles.bufferPort.quadPoints"] = "quadPoints"
};
[Test]
public void LayerRendererConsumesPluginContractsWithoutProviderBindings()
{

View file

@ -429,6 +429,13 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(projection.PlayableWorld.FieldVolumes[0].DocumentRef, Is.EqualTo("cultmesh://aetheria/world/fog-splats"));
Assert.That(projection.PlayableWorld.FieldVolumes[0].MaterialAssetRef, Is.EqualTo("shader.environment.gravity-fog"));
Assert.That(projection.PlayableWorld.FieldVolumes[0].RenderChannel, Is.EqualTo("world.transparent"));
Assert.That(projection.PlayableWorld.FieldParticles.Count, Is.EqualTo(1));
Assert.That(projection.PlayableWorld.FieldParticles[0].DocumentRef,
Is.EqualTo("cultmesh://aetheria/world/fog-splats"));
Assert.That(projection.PlayableWorld.FieldParticles[0].ComputeProgramAssetRef,
Is.EqualTo("compute.environment.stardust"));
Assert.That(projection.PlayableWorld.FieldParticles[0].MaterialAssetRef,
Is.EqualTo("material.environment.stardust"));
var player = FindEntity(projection.PlayableWorld, "player-vanguard");
Assert.That(player.EntityKind, Is.EqualTo("player"));
@ -1972,6 +1979,21 @@ namespace GameCult.Eve.UnityScene.Tests
},
Array.Empty<EveSurfaceComponent>()));
playableChildren.Add(new EveSurfaceComponent(
"aetheria.daemon.game.stardust",
"field.particles3d",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["documentRef"] = "cultmesh://aetheria/world/fog-splats",
["documentSchema"] = EveFieldsSchemas.Splats,
["computeProgramAssetRef"] = "compute.environment.stardust",
["materialAssetRef"] = "material.environment.stardust",
["renderChannel"] = "world.transparent",
["span"] = "256",
["spacing"] = "6"
},
Array.Empty<EveSurfaceComponent>()));
return new EveSurfaceDocument(
"aetheria",
"game.runtime",

View file

@ -243,8 +243,18 @@ try {
if (-not $facts.providerAssets -or -not $facts.environmentPresentation -or
-not $facts.movement -or -not $facts.pilotCameraExcludesMapChannel -or
-not $facts.mapCameraIncludesMapChannel -or [int]$facts.fieldVolumeLayerCount -le 0 -or
[long]$facts.fieldVolumeCompositeCount -le 0) {
throw "Live witness did not prove provider assets, movement, Fields lowering, and camera-channel separation."
[long]$facts.fieldVolumeCompositeCount -le 0 -or
[int]$facts.fieldParticleCount -ne 65536 -or
[int]$facts.fieldParticleDispatchCount -le 0 -or
[int]$facts.fieldParticleDrawCount -le 0 -or
-not $facts.fieldParticleMapCameraIsolated) {
throw "Live witness did not prove provider assets, movement, Fields lowering, Stardust dispatch/draw, and camera-channel separation."
}
$gridCenterXCells = [double]$facts.fieldParticleGridCenter.x / 6.0
$gridCenterYCells = [double]$facts.fieldParticleGridCenter.y / 6.0
if ([math]::Abs($gridCenterXCells - [math]::Truncate($gridCenterXCells)) -gt 0.000001 -or
[math]::Abs($gridCenterYCells - [math]::Truncate($gridCenterYCells)) -gt 0.000001) {
throw "Live Stardust grid center was not snapped to its six-unit spatial lattice. center=$($facts.fieldParticleGridCenter.x),$($facts.fieldParticleGridCenter.y)"
}
$presentedCelestials = @($facts.presentedEntities | Where-Object { $_.entityKind -like "celestial.*" })
$presentedBodies = @($presentedCelestials | Where-Object {