diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index acc016a..0e7e3b1 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -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(); + 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; diff --git a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index acc016a..0e7e3b1 100644 --- a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -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(); + 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; diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs index f1e0ee2..21c5694 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs @@ -115,6 +115,9 @@ namespace GameCult.Eve.UnityScene var fieldVolume = GetComponent(); if (fieldVolume == null) fieldVolume = gameObject.AddComponent(); fieldVolume.Bind(this, providerSurfaceDocuments as IEveUnityFieldsSplatsDocumentSource); + var fieldParticles = GetComponent(); + if (fieldParticles == null) fieldParticles = gameObject.AddComponent(); + fieldParticles.Bind(this, providerSurfaceDocuments as IEveUnityFieldsSplatsDocumentSource); var presentation = Runtime.Connect(); ConnectionEpoch++; diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs index 462336f..726944c 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs @@ -105,12 +105,15 @@ namespace GameCult.Eve.UnityScene var entities = new List(); var fieldVolumes = new List(); + var fieldParticles = new List(); 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? fieldVolumes = null, - string cameraLookAt = "") + string cameraLookAt = "", + IReadOnlyList? fieldParticles = null) { WorldRootId = worldRootId ?? ""; StatePointerId = statePointerId ?? ""; @@ -442,6 +457,7 @@ namespace GameCult.Eve.UnityScene KeyLightColorB = keyLightColor.b; KeyLightIntensity = keyLightIntensity; FieldVolumes = fieldVolumes ?? Array.Empty(); + FieldParticles = fieldParticles ?? Array.Empty(); CameraLookAt = cameraLookAt ?? ""; } @@ -536,6 +552,7 @@ namespace GameCult.Eve.UnityScene public IReadOnlyList Entities { get; } public IReadOnlyList FieldVolumes { get; } + public IReadOnlyList FieldParticles { get; } } public sealed class EveUnityFieldVolumeProjection @@ -570,6 +587,35 @@ namespace GameCult.Eve.UnityScene public IReadOnlyDictionary Props { get; } } + public sealed class EveUnityFieldParticlesProjection + { + public EveUnityFieldParticlesProjection( + string nodeId, + string documentRef, + string documentSchema, + string computeProgramAssetRef, + string materialAssetRef, + string renderChannel, + IReadOnlyDictionary? props = null) + { + NodeId = nodeId ?? ""; + DocumentRef = documentRef ?? ""; + DocumentSchema = documentSchema ?? ""; + ComputeProgramAssetRef = computeProgramAssetRef ?? ""; + MaterialAssetRef = materialAssetRef ?? ""; + RenderChannel = renderChannel ?? ""; + Props = props ?? new Dictionary(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 Props { get; } + } + public sealed class EveUnityPlayableWorldEntity { public EveUnityPlayableWorldEntity( diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs new file mode 100644 index 0000000..6e6eb4e --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs @@ -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 _targets = new List(); + private ComputeShader? _computeSource; + private ComputeShader? _compute; + private Material? _materialSource; + private Material? _material; + private IReadOnlyDictionary? _computeMetadata; + private IReadOnlyDictionary? _materialMetadata; + private readonly Dictionary _assetTextures = new Dictionary(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(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? compute, + IReadOnlyDictionary? 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 computeMetadata, + IReadOnlyDictionary 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 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(); + } + + 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(); + if (cameraData.camera == null || _owner._host == null || + _owner._host.ActiveCameraTransform != cameraData.camera.transform) + return; + var resources = frameData.Get(); + if (!resources.activeColorTexture.IsValid()) return; + using var builder = renderGraph.AddUnsafePass("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 ParseBindings(string value) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in ParseBindingPairs(value)) result[pair.Key] = pair.Value; + return result; + } + + private static IReadOnlyList> ParseBindingPairs(string value) + { + var result = new List>(); + foreach (var pair in (value ?? "").Split(';')) + { + var separator = pair.IndexOf('='); + if (separator <= 0 || separator >= pair.Length - 1) continue; + result.Add(new KeyValuePair( + pair.Substring(0, separator).Trim(), + pair.Substring(separator + 1).Trim())); + } + return result; + } + + public static bool TryEvaluateDocumentFloatBindings( + EveFieldsSplatsDocument document, + string bindings, + out IReadOnlyList> values) + { + var result = new List>(); + foreach (var binding in ParseBindingPairs(bindings)) + { + if (!EveUnityFieldsVolumeRenderer.TryEvaluateDocumentFloatBinding( + document, binding.Key, binding.Value, out var port, out var value)) + { + values = Array.Empty>(); + return false; + } + result.Add(new KeyValuePair(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); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs.meta new file mode 100644 index 0000000..0e14dd5 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e403315156af1eb4e9cebaa4cec1bcb8 \ No newline at end of file diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs index 3845648..bcd1fa3 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs @@ -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 RequiredParticleComputeMetadata() => + new Dictionary(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 RequiredParticleMaterialMetadata() => + new Dictionary(StringComparer.Ordinal) + { + ["unity.particles.pass.render"] = "0", + ["unity.particles.bufferPort.particles"] = "particles", + ["unity.particles.bufferPort.quadPoints"] = "quadPoints" + }; + [Test] public void LayerRendererConsumesPluginContractsWithoutProviderBindings() { diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index 0e163eb..95ddb9f 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -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())); + playableChildren.Add(new EveSurfaceComponent( + "aetheria.daemon.game.stardust", + "field.particles3d", + new Dictionary(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())); + return new EveSurfaceDocument( "aetheria", "game.runtime", diff --git a/scripts/run-aetheria-daemon-world-witness.ps1 b/scripts/run-aetheria-daemon-world-witness.ps1 index c86a5f9..5057460 100644 --- a/scripts/run-aetheria-daemon-world-witness.ps1 +++ b/scripts/run-aetheria-daemon-world-witness.ps1 @@ -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 {