This repository has been archived on 2026-10-29. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
EveUnity/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs
2026-07-18 16:57:36 +01:00

862 lines
39 KiB
C#

using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Reflection;
using GameCult.Eve.PluginFields;
using GameCult.Eve.UnityScene.Fields;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.Rendering.Universal;
namespace GameCult.Eve.UnityScene.Tests
{
public sealed class EveFieldsUnitySceneProjectionTests
{
[Test]
public void VolumeCameraToWorldPreservesUnityShaderPositiveZForward()
{
var cameraObject = new GameObject("eve-volume-camera-matrix");
try
{
var camera = cameraObject.AddComponent<Camera>();
camera.transform.SetPositionAndRotation(
new Vector3(12f, -3f, 8f),
Quaternion.Euler(17f, 43f, -9f));
var method = typeof(EveUnityFieldsVolumeRenderer).GetMethod(
"CameraTransformToWorld",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.That(method, Is.Not.Null);
var matrix = (Matrix4x4)method!.Invoke(null, new object[] { camera });
Assert.That(Vector3.Dot(
matrix.MultiplyVector(Vector3.forward).normalized,
camera.transform.forward), Is.EqualTo(1f).Within(0.00001f));
Assert.That(Vector3.Distance(
matrix.MultiplyPoint3x4(Vector3.zero),
camera.transform.position), Is.LessThan(0.00001f));
}
finally
{
UnityEngine.Object.DestroyImmediate(cameraObject);
}
}
[Test]
public void AdaptiveExposureMatchesFossilCompensationAndProgressiveDirection()
{
var exposureMethod = typeof(EveUnityAdaptiveExposureRenderer).GetMethod(
"ExposureForAverageLuminance",
BindingFlags.NonPublic | BindingFlags.Static);
var adaptationMethod = typeof(EveUnityAdaptiveExposureRenderer).GetMethod(
"AdaptExposure",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.That(exposureMethod, Is.Not.Null);
Assert.That(adaptationMethod, Is.Not.Null);
var brightBound = Mathf.Pow(2f, 0.3f);
var exposure = (float)exposureMethod!.Invoke(null, new object[] { brightBound, 0.5f });
Assert.That(exposure, Is.EqualTo(0.5f / brightBound).Within(0.000001f));
var darkening = (float)adaptationMethod!.Invoke(
null,
new object[] { 1f, 0.25f, 0.5f, 2f, 1f });
var brightening = (float)adaptationMethod.Invoke(
null,
new object[] { 1f, 4f, 0.5f, 2f, 1f });
Assert.That(darkening, Is.EqualTo(0.625f).Within(0.000001f));
Assert.That(brightening, Is.EqualTo(1f + 3f * (1f - Mathf.Pow(2f, -0.5f))).Within(0.000001f));
}
[Test]
public void AdaptiveExposureGpuHistogramClampsBrightFrameToAdvertisedMaximumEv()
{
Assert.That(SystemInfo.supportsComputeShaders, Is.True);
var compute = Resources.Load<ComputeShader>("EveUnity/AdaptiveExposure");
Assert.That(compute, Is.Not.Null);
var source = new Texture2D(32, 32, TextureFormat.RGBAFloat, false, true);
var pixels = Enumerable.Repeat(new Color(16f, 16f, 16f, 1f), 32 * 32).ToArray();
source.SetPixels(pixels);
source.Apply(false, false);
using var histogram = new ComputeBuffer(128, sizeof(uint), ComputeBufferType.Structured);
var exposure = new RenderTexture(1, 1, 0, RenderTextureFormat.RFloat, RenderTextureReadWrite.Linear)
{
enableRandomWrite = true
};
exposure.Create();
try
{
var clear = compute!.FindKernel("ClearHistogram");
compute.SetBuffer(clear, "_Histogram", histogram);
compute.Dispatch(clear, 2, 1, 1);
var build = compute.FindKernel("BuildHistogram");
compute.SetBuffer(build, "_Histogram", histogram);
compute.SetTexture(build, "_Source", source);
compute.SetVector("_HistogramScaleOffsetResolution", new Vector4(1f / 18f, 0.5f, 32f, 32f));
compute.Dispatch(build, 1, 1, 1);
var calculate = compute.FindKernel("CalculateFixedExposure");
compute.SetBuffer(calculate, "_Histogram", histogram);
compute.SetVector("_ExposureFilterAndBrightness", new Vector4(
0.4737294f,
0.99f,
Mathf.Pow(2f, -3f),
Mathf.Pow(2f, 0.3f)));
compute.SetVector("_ExposureAdaptation", new Vector4(1f, 2f, 0.5f, 1f / 60f));
compute.SetTexture(calculate, "_DestinationExposure", exposure);
compute.Dispatch(calculate, 1, 1, 1);
var previous = RenderTexture.active;
RenderTexture.active = exposure;
var readback = new Texture2D(1, 1, TextureFormat.RFloat, false, true);
readback.ReadPixels(new Rect(0, 0, 1, 1), 0, 0);
readback.Apply();
RenderTexture.active = previous;
var actual = readback.GetPixel(0, 0).r;
UnityEngine.Object.DestroyImmediate(readback);
Assert.That(actual, Is.EqualTo(0.5f / Mathf.Pow(2f, 0.3f)).Within(0.001f));
}
finally
{
exposure.Release();
UnityEngine.Object.DestroyImmediate(exposure);
UnityEngine.Object.DestroyImmediate(source);
}
}
[Test]
public void RasterizerConsumesPluginOwnedDocumentContract()
{
var render = typeof(EveFieldsSplatRasterizer).GetMethod(
"Render",
new[] { typeof(IEveFieldsSplatsDocument), typeof(int), typeof(int) });
Assert.That(render, Is.Not.Null);
Assert.That(typeof(EveFieldsSplatRasterizer).Assembly.GetReferencedAssemblies().Any(assembly =>
assembly.Name?.Contains("Aetheria", StringComparison.OrdinalIgnoreCase) == true), Is.False);
}
[Test]
public void CanonicalFieldsPackageOwnsTheCultMeshWireDocument()
{
Assert.That(typeof(EveFieldsSplatsDocument).GetCustomAttributes(false)
.Any(attribute => attribute.GetType().Name == "CultDocumentAttribute"), Is.True);
Assert.That(new EveFieldsSplatsDocument().Schema, Is.EqualTo(EveFieldsSchemas.Splats));
Assert.That(typeof(EveFieldsSplatsDocument).Assembly.GetReferencedAssemblies()
.Any(assembly => assembly.Name?.Contains("Aetheria", StringComparison.OrdinalIgnoreCase) == true), Is.False);
}
[Test]
public void VolumeRendererConsumesCanonicalFieldsWithoutProviderCode()
{
Assert.That(typeof(EveUnityFieldsVolumeRenderer).Assembly.GetReferencedAssemblies()
.Any(assembly => assembly.Name?.Contains("Aetheria", StringComparison.OrdinalIgnoreCase) == true), Is.False);
Assert.That(typeof(IEveUnityFieldsSplatsDocumentSource).GetEvent("FieldsSplatsAvailable"), Is.Not.Null);
}
[Test]
public void UrpRendererFeatureOwnsGenericWorldEffectScheduling()
{
Assert.That(typeof(EveUnityRendererFeature).IsSubclassOf(typeof(ScriptableRendererFeature)), Is.True);
var supportsCamera = typeof(EveUnityRendererFeature).GetMethod(
"SupportsCamera",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.That(supportsCamera, Is.Not.Null);
Assert.That(supportsCamera!.Invoke(null, new object[] { CameraType.Game }), Is.True);
Assert.That(supportsCamera.Invoke(null, new object[] { CameraType.SceneView }), Is.True);
Assert.That(supportsCamera.Invoke(null, new object[] { CameraType.Preview }), Is.False);
AssertRenderOrder(typeof(EveUnityFieldsVolumeRenderer), 100);
AssertRenderOrder(typeof(EveUnityFieldsParticleRenderer), 200);
AssertRenderOrder(typeof(EveUnityAdaptiveExposureRenderer), 300);
}
[Test]
public void VolumeRendererCompositesBeforeProviderPostProcessing()
{
Assert.That(
EveUnityFieldsVolumeRenderer.CompositionRenderPassEvent,
Is.EqualTo(RenderPassEvent.BeforeRenderingPostProcessing));
}
private static void AssertRenderOrder(Type sourceType, int expected)
{
var schedulingInterface = sourceType.GetInterfaces().Single(type =>
string.Equals(type.Name, "IEveUnityRenderPassSource", StringComparison.Ordinal));
var property = schedulingInterface.GetProperty("RenderOrder");
Assert.That(property, Is.Not.Null);
var gameObject = new GameObject("eve-render-pass-order-test");
try
{
var component = gameObject.AddComponent(sourceType);
Assert.That(property!.GetValue(component), Is.EqualTo(expected));
}
finally
{
UnityEngine.Object.DestroyImmediate(gameObject);
}
}
[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 FieldViewportFrameSnapsToAnIntegralGravityPixelMultiple(float coordinate, float expected)
{
Assert.That(EveUnityFieldsViewportFrame.SnapCameraCoordinate(
coordinate,
1536f,
2048,
8f), Is.EqualTo(expected).Within(0.0001f));
}
[Test]
public void FieldViewportFrameAcceptsAlignedGravityAndParticleLattices()
{
Assert.That(EveUnityFieldsViewportFrame.TryValidateSpatialLattice(
1536f, 1536f, 2048, 2048, 256, 6f, 8), Is.True);
}
[Test]
public void FieldViewportFrameRejectsAliasedGravityAndParticleLattices()
{
Assert.That(EveUnityFieldsViewportFrame.TryValidateSpatialLattice(
2000f, 2000f, 2048, 2048, 256, 6f, 8), Is.False);
}
[Test]
public void FogAndParticlesResolveTheSameSnappedWorldFrame()
{
var document = new EveFieldsSplatsDocument
{
FrameId = 7,
Viewport = new EveFieldsViewport
{
MinX = -768,
MinY = -768,
MaxX = 768,
MaxY = 768
}
};
var contract = new Dictionary<string, string>(StringComparer.Ordinal)
{
["viewportAnchor"] = "active-camera.xz",
["span"] = "256",
["cellWorldSize"] = "6",
["viewportSnapTexels"] = "8"
};
Assert.That(EveUnityFieldsViewportFrame.TryResolve(
document, contract, new Vector2(11f, -11f), 2048, 2048,
out var fog, out var fogCenter), Is.True);
Assert.That(EveUnityFieldsViewportFrame.TryResolve(
document, contract, new Vector2(11f, -11f), 2048, 2048,
out var particles, out var particleCenter), Is.True);
Assert.That(fogCenter, Is.EqualTo(new Vector2(6f, -6f)));
Assert.That(particleCenter, Is.EqualTo(fogCenter));
Assert.That(particles.Viewport.MinX, Is.EqualTo(fog.Viewport.MinX));
Assert.That(particles.Viewport.MinY, Is.EqualTo(fog.Viewport.MinY));
Assert.That(particles.Viewport.MaxX, Is.EqualTo(fog.Viewport.MaxX));
Assert.That(particles.Viewport.MaxY, Is.EqualTo(fog.Viewport.MaxY));
}
[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 ParticleMaterialAcceptsAdvertisedTemporalDitherInputs()
{
var field = new EveUnityFieldParticlesProjection(
"particles", "document", EveFieldsSchemas.Splats, "compute", "material", "world.transparent",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["materialAssetTextureBindings"] = "texture.noise=dither",
["materialViewportTextureScaleBindings"] = "ditherCoordinates=dither",
["materialRenderFrameIndexPort"] = "frameIndex"
});
var metadata = RequiredParticleMaterialMetadata();
metadata["unity.particles.texturePort.dither"] = "_DitheringTex";
metadata["unity.particles.vectorPort.ditherCoordinates"] = "_DitheringCoords";
metadata["unity.particles.intPort.frameIndex"] = "_FrameNumber";
Assert.That(EveUnityFieldsParticleRenderer.TryValidateMaterialPresentationContract(
field, metadata, out var error), Is.True, error);
}
[Test]
public void ParticleMaterialRejectsPartialTemporalDitherInputs()
{
var field = new EveUnityFieldParticlesProjection(
"particles", "document", EveFieldsSchemas.Splats, "compute", "material", "world.transparent",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["materialAssetTextureBindings"] = "texture.noise=dither",
["materialViewportTextureScaleBindings"] = "ditherCoordinates=dither",
["materialRenderFrameIndexPort"] = "frameIndex"
});
var metadata = RequiredParticleMaterialMetadata();
metadata["unity.particles.texturePort.dither"] = "_DitheringTex";
metadata["unity.particles.vectorPort.ditherCoordinates"] = "_DitheringCoords";
Assert.That(EveUnityFieldsParticleRenderer.TryValidateMaterialPresentationContract(
field, metadata, out var error), Is.False);
Assert.That(error, Does.Contain("render-frame"));
}
[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()
{
var metadata = RequiredVolumeProgramMetadata();
Assert.That(EveUnityFieldsVolumeRenderer.TryValidateProgramMetadata(metadata, out var error), Is.True, error);
}
[Test]
public void VolumeProgramAcceptsCompleteTemporalLifecycle()
{
var metadata = RequiredVolumeProgramMetadata();
metadata["unity.volume.pass.temporal"] = "1";
metadata["unity.volume.texturePort.currentSample"] = "_CurrentVolume";
metadata["unity.volume.texturePort.history"] = "_HistoryVolume";
metadata["unity.volume.matrixPort.previousViewProjection"] = "_PreviousViewProjection";
metadata["unity.volume.floatPort.resetHistory"] = "_ResetHistory";
Assert.That(EveUnityFieldsVolumeRenderer.TryValidateProgramMetadata(metadata, out var error), Is.True, error);
}
[Test]
public void VolumeRendererUsesCurrentProjectionWithPreviousViewWhenAdvertised()
{
var metadata = RequiredVolumeProgramMetadata();
metadata["unity.volume.matrixSemantic.previousViewProjection"] =
"current-projection.previous-view.v1";
var previousViewProjection = Matrix4x4.Translate(new Vector3(1f, 2f, 3f));
var currentProjection = Matrix4x4.Scale(new Vector3(2f, 3f, 4f));
var previousView = Matrix4x4.Rotate(Quaternion.Euler(10f, 20f, 30f));
var resolved = EveUnityFieldsVolumeRenderer.ResolvePreviousViewProjection(
metadata,
previousViewProjection,
currentProjection,
Matrix4x4.identity,
previousView);
Assert.That(resolved, Is.EqualTo(currentProjection * previousView));
Assert.That(resolved, Is.Not.EqualTo(previousViewProjection));
}
[Test]
public void VolumeRendererDefaultsToTruePreviousViewProjection()
{
var previousViewProjection = Matrix4x4.Translate(new Vector3(1f, 2f, 3f));
var resolved = EveUnityFieldsVolumeRenderer.ResolvePreviousViewProjection(
RequiredVolumeProgramMetadata(),
previousViewProjection,
Matrix4x4.Scale(Vector3.one * 2f),
Matrix4x4.identity,
Matrix4x4.Rotate(Quaternion.Euler(10f, 20f, 30f)));
Assert.That(resolved, Is.EqualTo(previousViewProjection));
}
[Test]
public void VolumeRendererUsesNonRenderTargetProjectionWhenAdvertised()
{
var metadata = RequiredVolumeProgramMetadata();
metadata["unity.volume.matrixSemantic.previousViewProjection"] =
"non-render-target-projection.previous-view.v1";
var previousViewProjection = Matrix4x4.Translate(new Vector3(1f, 2f, 3f));
var renderTargetProjection = Matrix4x4.Scale(new Vector3(2f, -3f, 4f));
var nonRenderTargetProjection = Matrix4x4.Scale(new Vector3(2f, 3f, 4f));
var previousView = Matrix4x4.Rotate(Quaternion.Euler(10f, 20f, 30f));
var resolved = EveUnityFieldsVolumeRenderer.ResolvePreviousViewProjection(
metadata,
previousViewProjection,
renderTargetProjection,
nonRenderTargetProjection,
previousView);
Assert.That(resolved, Is.EqualTo(nonRenderTargetProjection * previousView));
Assert.That(resolved, Is.Not.EqualTo(renderTargetProjection * previousView));
}
[Test]
public void VolumeProgramRejectsUnknownPreviousViewProjectionSemantic()
{
var metadata = RequiredVolumeProgramMetadata();
metadata["unity.volume.pass.temporal"] = "1";
metadata["unity.volume.texturePort.currentSample"] = "_CurrentVolume";
metadata["unity.volume.texturePort.history"] = "_HistoryVolume";
metadata["unity.volume.matrixPort.previousViewProjection"] = "_PreviousViewProjection";
metadata["unity.volume.floatPort.resetHistory"] = "_ResetHistory";
metadata["unity.volume.matrixSemantic.previousViewProjection"] = "made-up";
Assert.That(EveUnityFieldsVolumeRenderer.TryValidateProgramMetadata(metadata, out var error), Is.False);
Assert.That(error, Does.Contain("previous-view-projection semantic"));
}
[Test]
public void VolumeRendererUsesAdvertisedBootstrapQualityOnlyForEmptyHistory()
{
var metadata = RequiredVolumeProgramMetadata();
metadata["unity.volume.quality.bootstrap"] = "ultra";
metadata["unity.volume.quality.ultra.keyword"] = "ULTRA_QUALITY";
Assert.That(EveUnityFieldsVolumeRenderer.ResolveQuality(metadata, "high", true), Is.EqualTo("ultra"));
Assert.That(EveUnityFieldsVolumeRenderer.ResolveQuality(metadata, "high", false), Is.EqualTo("high"));
}
[Test]
public void VolumeProgramRejectsBootstrapQualityWithoutAdvertisedKeyword()
{
var metadata = RequiredVolumeProgramMetadata();
metadata["unity.volume.pass.temporal"] = "1";
metadata["unity.volume.texturePort.currentSample"] = "_CurrentVolume";
metadata["unity.volume.texturePort.history"] = "_HistoryVolume";
metadata["unity.volume.matrixPort.previousViewProjection"] = "_PreviousViewProjection";
metadata["unity.volume.floatPort.resetHistory"] = "_ResetHistory";
metadata["unity.volume.quality.bootstrap"] = "ultra";
Assert.That(EveUnityFieldsVolumeRenderer.TryValidateProgramMetadata(metadata, out var error), Is.False);
Assert.That(error, Does.Contain("bootstrap quality"));
}
[Test]
public void VolumeProgramRejectsPartialTemporalLifecycle()
{
var metadata = RequiredVolumeProgramMetadata();
metadata["unity.volume.pass.temporal"] = "1";
metadata["unity.volume.texturePort.currentSample"] = "_CurrentVolume";
Assert.That(EveUnityFieldsVolumeRenderer.TryValidateProgramMetadata(metadata, out var error), Is.False);
Assert.That(error, Does.Contain("history port"));
}
[Test]
public void VolumeRendererDerivesViewportTextureScaleFromNativeDimensions()
{
var texture = new Texture2D(128, 64, TextureFormat.RGBA32, false);
try
{
Assert.That(EveUnityFieldsVolumeRenderer.TryComputeViewportTextureScale(
640,
360,
texture,
out var scale), Is.True);
Assert.That(scale, Is.EqualTo(new Vector4(5f, 5.625f, 0f, 0f)));
}
finally
{
UnityEngine.Object.DestroyImmediate(texture);
}
}
[Test]
public void VolumeRendererAppliesProviderAdvertisedLayerTargetShape()
{
var target = new EveFieldsSplatLayerTarget { LayerKey = "fog.tint" };
Assert.That(
EveUnityFieldsVolumeRenderer.TryApplyLayerTargetDescriptor(
target,
"0.5,0.25,true,trilinear"),
Is.True);
Assert.That(target.WidthScale, Is.EqualTo(0.5f));
Assert.That(target.HeightScale, Is.EqualTo(0.25f));
Assert.That(target.UseMipMaps, Is.True);
Assert.That(target.FilterMode, Is.EqualTo(FilterMode.Trilinear));
}
[Test]
public void VolumeRendererRejectsMalformedLayerTargetShape()
{
var target = new EveFieldsSplatLayerTarget
{
WidthScale = 2f,
HeightScale = 2f,
UseMipMaps = true,
FilterMode = FilterMode.Trilinear
};
Assert.That(
EveUnityFieldsVolumeRenderer.TryApplyLayerTargetDescriptor(target, "0,1,true,bilinear"),
Is.False);
Assert.That(target.WidthScale, Is.EqualTo(2f));
Assert.That(target.HeightScale, Is.EqualTo(2f));
Assert.That(target.UseMipMaps, Is.True);
Assert.That(target.FilterMode, Is.EqualTo(FilterMode.Trilinear));
}
[Test]
public void VolumeRendererBindsAuthoritativeDocumentTimeToLogicalPort()
{
var document = new EveFieldsSplatsDocument { SimulationTimeSeconds = 12.5 };
Assert.That(
EveUnityFieldsVolumeRenderer.TryEvaluateDocumentFloatBinding(
document,
"simulationTimeSeconds",
"flowScroll,0.025,-0.1",
out var port,
out var value),
Is.True);
Assert.That(port, Is.EqualTo("flowScroll"));
Assert.That(value, Is.EqualTo(0.2125f).Within(0.00001f));
}
[Test]
public void VolumeRendererRejectsUnknownDocumentFloatSource()
{
Assert.That(
EveUnityFieldsVolumeRenderer.TryEvaluateDocumentFloatBinding(
new EveFieldsSplatsDocument(),
"unityTime",
"flowScroll,1,0",
out _,
out _),
Is.False);
}
[Test]
public void PackageContainsGenericFieldsShader()
{
Assert.That(Shader.Find("Eve/Fields/Splats"), Is.Not.Null);
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()
{
var render = typeof(EveFieldsSplatLayerRenderer).GetMethod("Render");
Assert.That(render, Is.Not.Null);
Assert.That(render.GetParameters()[0].ParameterType, Is.EqualTo(typeof(IEveFieldsSplatsDocument)));
Assert.That(typeof(EveFieldsSplatLayerRenderer).GetField("GlobalTextureName"), Is.Null);
}
[Test]
public void RasterizerProducesProviderIndependentFieldsCapture()
{
var host = new GameObject("eve-fields-capture");
var rasterizer = host.AddComponent<EveFieldsSplatRasterizer>();
var output = rasterizer.Render(new CaptureDocument(), 64, 64);
Assert.That(output, Is.Not.Null);
var previous = RenderTexture.active;
RenderTexture.active = output;
var readback = new Texture2D(64, 64, TextureFormat.RGBA32, false, true);
readback.ReadPixels(new Rect(0, 0, 64, 64), 0, 0);
readback.Apply();
RenderTexture.active = previous;
Assert.That(readback.GetPixels().Any(pixel => pixel.r > 0.01f), Is.True);
var outputPath = Path.GetFullPath(Path.Combine(Application.dataPath, "../../artifacts/fields-surface/latest/unity-fields.png"));
Directory.CreateDirectory(Path.GetDirectoryName(outputPath));
File.WriteAllBytes(outputPath, readback.EncodeToPNG());
UnityEngine.Object.DestroyImmediate(readback);
UnityEngine.Object.DestroyImmediate(host);
}
[Test]
public void RasterizerLowersPortablePowerPulseParameters()
{
var document = new EveFieldsSplatsDocument
{
Viewport = new EveFieldsViewport { MinX = -1, MinY = -1, MaxX = 1, MaxY = 1 },
Splats = new EveFieldsSplatSoa
{
Count = 1,
CenterX = new[] { 0d },
CenterY = new[] { 0d },
HalfExtentX = new[] { 1d },
HalfExtentY = new[] { 1d },
RotationCos = new[] { 1d },
RotationSin = new[] { 0d },
Channel = new[] { 0 },
Falloff = new[] { EveFieldsSplatFalloffs.PowerPulse },
FalloffScale = new[] { 2d },
FalloffExponent = new[] { 1d },
ValueR = new[] { 1d },
ValueA = new[] { 1d },
LayerIndex = new[] { 0 },
SourceKind = new[] { EveFieldsSplatSourceKinds.Constant }
}
};
var host = new GameObject("eve-fields-power-pulse");
var rasterizer = host.AddComponent<EveFieldsSplatRasterizer>();
var output = rasterizer.Render(document, 128, 128);
var previous = RenderTexture.active;
RenderTexture.active = output;
var readback = new Texture2D(128, 128, TextureFormat.RGBAFloat, false, true);
readback.ReadPixels(new Rect(0, 0, 128, 128), 0, 0);
readback.Apply();
RenderTexture.active = previous;
Assert.That(readback.GetPixel(64, 64).r, Is.GreaterThan(0.95f));
Assert.That(readback.GetPixel(80, 64).r, Is.InRange(0.65f, 0.85f));
Assert.That(readback.GetPixel(96, 64).r, Is.LessThan(0.02f));
UnityEngine.Object.DestroyImmediate(readback);
UnityEngine.Object.DestroyImmediate(host);
}
[Test]
public void RasterizerAnchorsAnimatedSimplexToFieldWorldAndPublishedSimulationTime()
{
var first = ProceduralDocument(
0, 100, 50, EveFieldsSplatSourceKinds.AnimatedSimplexNoise, 2);
var shifted = ProceduralDocument(
25, 125, 75, EveFieldsSplatSourceKinds.AnimatedSimplexNoise, 2);
var later = ProceduralDocument(
0, 100, 50, EveFieldsSplatSourceKinds.AnimatedSimplexNoise, 7);
var firstValue = RenderFieldPixel(first, 64, 64);
var shiftedValue = RenderFieldPixel(shifted, 32, 64);
var laterValue = RenderFieldPixel(later, 64, 64);
Assert.That(shiftedValue, Is.EqualTo(firstValue).Within(0.015f),
"the same field-world coordinate must not swim when the viewport moves");
Assert.That(Mathf.Abs(laterValue - firstValue), Is.GreaterThan(0.02f),
"animation must use the document's simulation time rather than Unity wall time");
}
[Test]
public void RasterizerLowersPortableAnimatedCellNoiseB()
{
var first = ProceduralDocument(
0, 100, 50, EveFieldsSplatSourceKinds.AnimatedCellNoiseB, 2);
var later = ProceduralDocument(
0, 100, 50, EveFieldsSplatSourceKinds.AnimatedCellNoiseB, 11);
var firstValue = RenderFieldPixel(first, 57, 71);
var laterValue = RenderFieldPixel(later, 57, 71);
Assert.That(firstValue, Is.GreaterThanOrEqualTo(0));
Assert.That(Mathf.Abs(laterValue - firstValue), Is.GreaterThan(0.005f));
}
[Test]
public void RasterizerLowersPortableAnimatedRadialCosine()
{
var initial = RadialCosineDocument(0);
var halfTurnLater = RadialCosineDocument(Math.PI);
var center = RenderFieldPixel(initial, 64, 64);
var radial = RenderFieldPixel(initial, 80, 64);
var laterCenter = RenderFieldPixel(halfTurnLater, 64, 64);
var localX = (80.5f / 128f) * 2f - 1f;
var localY = (64.5f / 128f) * 2f - 1f;
var expected = Mathf.Cos(Mathf.Pow(Mathf.Sqrt(localX * localX + localY * localY), 1.25f) * Mathf.PI * 2f);
Assert.That(center, Is.GreaterThan(0.98f));
Assert.That(radial, Is.EqualTo(expected).Within(0.04f));
Assert.That(laterCenter, Is.LessThan(-0.98f),
"radial phase animation must use provider-published simulation time");
}
private static EveFieldsSplatsDocument RadialCosineDocument(double simulationTime)
{
return new EveFieldsSplatsDocument
{
SimulationTimeSeconds = simulationTime,
Viewport = new EveFieldsViewport { MinX = -1, MinY = -1, MaxX = 1, MaxY = 1 },
Splats = new EveFieldsSplatSoa
{
Count = 1,
CenterX = new[] { 0d },
CenterY = new[] { 0d },
HalfExtentX = new[] { 1d },
HalfExtentY = new[] { 1d },
RotationCos = new[] { 1d },
RotationSin = new[] { 0d },
Channel = new[] { 0 },
Falloff = new[] { EveFieldsSplatFalloffs.Solid },
ValueR = new[] { 1d },
ValueA = new[] { 1d },
LayerIndex = new[] { 0 },
SourceKind = new[] { EveFieldsSplatSourceKinds.AnimatedRadialCosine },
FrequencyX = new[] { Math.PI * 2d },
FrequencyY = new[] { 1.25d },
PhaseX = new[] { 0d },
AnimationSpeed = new[] { 1d }
}
};
}
private static EveFieldsSplatsDocument ProceduralDocument(
double min,
double max,
double center,
int sourceKind,
double simulationTime)
{
return new EveFieldsSplatsDocument
{
SimulationTimeSeconds = simulationTime,
Viewport = new EveFieldsViewport { MinX = min, MinY = 0, MaxX = max, MaxY = 100 },
Splats = new EveFieldsSplatSoa
{
Count = 1,
CenterX = new[] { center },
CenterY = new[] { 50d },
HalfExtentX = new[] { 50d },
HalfExtentY = new[] { 50d },
RotationCos = new[] { 1d },
RotationSin = new[] { 0d },
Channel = new[] { 0 },
Falloff = new[] { EveFieldsSplatFalloffs.Solid },
ValueR = new[] { 1d },
ValueA = new[] { 1d },
LayerIndex = new[] { 0 },
SourceKind = new[] { sourceKind },
FrequencyX = new[] { 0.037d },
FrequencyY = new[] { 0.041d },
PhaseX = new[] { 0.17d },
PhaseY = new[] { -0.23d },
AnimationSpeed = new[] { 0.31d },
SourceFlags = new[] { (double)EveFieldsSplatSourceFlags.AbsoluteValue }
}
};
}
private static float RenderFieldPixel(EveFieldsSplatsDocument document, int x, int y)
{
var host = new GameObject("eve-fields-procedural-capture");
var rasterizer = host.AddComponent<EveFieldsSplatRasterizer>();
var output = rasterizer.Render(document, 128, 128);
var previous = RenderTexture.active;
RenderTexture.active = output;
var readback = new Texture2D(128, 128, TextureFormat.RGBAFloat, false, true);
readback.ReadPixels(new Rect(0, 0, 128, 128), 0, 0);
readback.Apply();
RenderTexture.active = previous;
var value = readback.GetPixel(x, y).r;
UnityEngine.Object.DestroyImmediate(readback);
UnityEngine.Object.DestroyImmediate(host);
return value;
}
private sealed class CaptureDocument : IEveFieldsSplatsDocument
{
public string Schema => EveFieldsSchemas.Splats;
public long FrameId => 1;
public double SimulationTimeSeconds => 0;
public IEveFieldsViewport Viewport { get; } = new CaptureViewport();
public IReadOnlyList<IEveFieldsSplatLayer> Layers { get; } = Array.Empty<IEveFieldsSplatLayer>();
public IEveFieldsSplatSoa Splats { get; } = new CaptureSplats();
}
private static Dictionary<string, string> RequiredVolumeProgramMetadata() =>
new Dictionary<string, string>(StringComparer.Ordinal)
{
["unity.volume.pass.raymarch"] = "0",
["unity.volume.pass.composite"] = "2",
["unity.volume.texturePort.cloud"] = "_Cloud",
["unity.volume.matrixPort.cameraInverseViewProjection"] = "_CameraInverseViewProjection",
["unity.volume.vectorPort.cameraProjectionExtents"] = "_CameraProjectionExtents",
["unity.volume.vectorPort.viewportTransform"] = "_ViewportTransform",
["unity.volume.floatPort.raymarchOffset"] = "_RaymarchOffset"
};
private sealed class CaptureViewport : IEveFieldsViewport
{
public double MinX => -1;
public double MinY => -1;
public double MaxX => 1;
public double MaxY => 1;
}
private sealed class CaptureSplats : IEveFieldsSplatSoa
{
private static readonly double[] One = { 1d };
private static readonly double[] Zero = { 0d };
private static readonly int[] ZeroInt = { 0 };
public int Count => 1;
public IReadOnlyList<double> CenterX => Zero;
public IReadOnlyList<double> CenterY => Zero;
public IReadOnlyList<double> HalfExtentX => One;
public IReadOnlyList<double> HalfExtentY => One;
public IReadOnlyList<double> RotationCos => One;
public IReadOnlyList<double> RotationSin => Zero;
public IReadOnlyList<int> Channel => ZeroInt;
public IReadOnlyList<int> Falloff => ZeroInt;
public IReadOnlyList<double> ValueR => One;
public IReadOnlyList<double> ValueG => Zero;
public IReadOnlyList<double> ValueB => Zero;
public IReadOnlyList<double> ValueA => One;
public IReadOnlyList<string> SourceKey { get; } = new[] { "capture" };
public IReadOnlyList<int> LayerIndex => ZeroInt;
public IReadOnlyList<int> SourceKind => ZeroInt;
public IReadOnlyList<double> FrequencyX => One;
public IReadOnlyList<double> FrequencyY => One;
public IReadOnlyList<double> PhaseX => Zero;
public IReadOnlyList<double> PhaseY => Zero;
public IReadOnlyList<double> AnimationSpeed => Zero;
public IReadOnlyList<double> SourceFlags => Zero;
public IReadOnlyList<double> FalloffScale => One;
public IReadOnlyList<double> FalloffExponent => One;
}
}
}