Add Unity fields projection adapter

This commit is contained in:
Meta Vens 2026-07-10 15:37:39 +01:00
commit 3e23fff486
19 changed files with 450 additions and 4 deletions

View file

@ -6,8 +6,9 @@
"com.unity.modules.physics": "1.0.0",
"com.unity.modules.uielements": "1.0.0",
"org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-source-v1.0.3-r2",
"org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.1.1",
"org.gamecult.eve.surface": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#eveunity-surface-v0.1.0",
"org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.1.1",
"org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.1.2",
"org.gamecult.eve.unity-uitoolkit": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-uitoolkit#eveunity-uitoolkit-v0.1.0"
},
"testables": [

View file

@ -6,6 +6,7 @@
"com.unity.modules.physics": "1.0.0",
"com.unity.modules.uielements": "1.0.0",
"org.gamecult.cultlib": "file:../../../CultLib/artifacts/unity/org.gamecult.cultlib",
"org.gamecult.eve.plugin-fields": "file:../../../EvePlugins/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields",
"org.gamecult.eve.surface": "file:../../packages/org.gamecult.eve.surface",
"org.gamecult.eve.unity-scene": "file:../../packages/org.gamecult.eve.unity-scene",
"org.gamecult.eve.unity-uitoolkit": "file:../../packages/org.gamecult.eve.unity-uitoolkit"

View file

@ -22,6 +22,12 @@
"source": "local",
"dependencies": {}
},
"org.gamecult.eve.plugin-fields": {
"version": "file:../../../EvePlugins/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields",
"depth": 0,
"source": "local",
"dependencies": {}
},
"org.gamecult.eve.surface": {
"version": "file:../../packages/org.gamecult.eve.surface",
"depth": 0,
@ -36,6 +42,7 @@
"source": "local",
"dependencies": {
"org.gamecult.cultlib": "1.0.3",
"org.gamecult.eve.plugin-fields": "0.1.1",
"org.gamecult.eve.surface": "0.1.0"
}
},

View file

@ -14,3 +14,8 @@ provides every bootstrap port. Provider, surface, and Verse IDs are optional
selection filters; they are not product knowledge required by the client.
The package never imports provider product assemblies.
`fields.surface` projection consumes the plugin-owned
`org.gamecult.eve.plugin-fields` contracts. `EveFieldsSplatRasterizer` owns the
Unity structured-buffer and RenderTexture lowering without importing provider
document classes.

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2c2c7df6e8d34f3ba491b20a450d17cb
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using GameCult.Eve.PluginFields;
using UnityEngine;
namespace GameCult.Eve.UnityScene.Fields
{
public sealed class EveFieldsSplatBuffer : IDisposable
{
public const string SplatBufferPropertyName = "_EveFieldsSplats";
public const string SplatCountPropertyName = "_EveFieldsSplatCount";
public const string ViewportToClipPropertyName = "_EveFieldsViewportToClip";
public const string ChannelFilterPropertyName = "_EveFieldsChannelFilter";
private const int SplatStrideBytes = 80;
private EveFieldsGpuSplat[] splats = Array.Empty<EveFieldsGpuSplat>();
private GraphicsBuffer buffer;
private int capacity;
public int Count { get; private set; }
public GraphicsBuffer Buffer => buffer;
public bool HasGpuBuffer => buffer != null && buffer.IsValid() && Count > 0;
public bool Upload(IEveFieldsSplatSoa source, int channelFilter = -1, int layerFilter = -1)
{
Count = Pack(source, channelFilter, layerFilter);
EnsureGpuCapacity(Count);
if (Count <= 0)
return false;
buffer.SetData(splats, 0, 0, Count);
return true;
}
private int Pack(IEveFieldsSplatSoa source, int channelFilter, int layerFilter)
{
if (source == null || source.Count <= 0)
return 0;
EnsureCpuCapacity(source.Count);
var count = 0;
for (var index = 0; index < source.Count; index++)
{
var channel = Read(source.Channel, index, 0);
var layer = Read(source.LayerIndex, index, -1);
if ((channelFilter >= 0 && channel != channelFilter) ||
(layerFilter >= 0 && layer != layerFilter))
continue;
var halfExtentX = (float)Read(source.HalfExtentX, index, 0);
var halfExtentY = (float)Read(source.HalfExtentY, index, 0);
if (halfExtentX <= 0 || halfExtentY <= 0)
continue;
splats[count++] = new EveFieldsGpuSplat
{
CenterHalfExtent = new Vector4((float)Read(source.CenterX, index, 0), (float)Read(source.CenterY, index, 0), halfExtentX, halfExtentY),
RotationChannelFalloff = new Vector4((float)Read(source.RotationCos, index, 1), (float)Read(source.RotationSin, index, 0), channel, Read(source.Falloff, index, EveFieldsSplatFalloffs.Smooth)),
LayerSource = new Vector4(layer, Read(source.SourceKind, index, EveFieldsSplatSourceKinds.Constant), (float)Read(source.AnimationSpeed, index, 0), (float)Read(source.SourceFlags, index, 0)),
SourceFrequencyPhase = new Vector4((float)Read(source.FrequencyX, index, 1), (float)Read(source.FrequencyY, index, 1), (float)Read(source.PhaseX, index, 0), (float)Read(source.PhaseY, index, 0)),
Value = new Vector4((float)Read(source.ValueR, index, 0), (float)Read(source.ValueG, index, 0), (float)Read(source.ValueB, index, 0), (float)Read(source.ValueA, index, 1))
};
}
return count;
}
private void EnsureCpuCapacity(int count)
{
if (splats.Length >= count) return;
var next = 1;
while (next < count) next <<= 1;
splats = new EveFieldsGpuSplat[next];
}
private void EnsureGpuCapacity(int count)
{
if (buffer != null && buffer.IsValid() && capacity >= count) return;
buffer?.Release();
buffer = null;
capacity = 0;
if (count <= 0) return;
capacity = 1;
while (capacity < count) capacity <<= 1;
buffer = new GraphicsBuffer(GraphicsBuffer.Target.Structured, capacity, SplatStrideBytes);
}
public void Dispose()
{
buffer?.Release();
buffer = null;
capacity = 0;
Count = 0;
}
private static double Read(IReadOnlyList<double> values, int index, double fallback) =>
values != null && index >= 0 && index < values.Count ? values[index] : fallback;
private static int Read(IReadOnlyList<int> values, int index, int fallback) =>
values != null && index >= 0 && index < values.Count ? values[index] : fallback;
private struct EveFieldsGpuSplat
{
public Vector4 CenterHalfExtent;
public Vector4 RotationChannelFalloff;
public Vector4 LayerSource;
public Vector4 SourceFrequencyPhase;
public Vector4 Value;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0638cfafda354c8785255c44861df08d

View file

@ -0,0 +1,117 @@
using GameCult.Eve.PluginFields;
using UnityEngine;
using UnityEngine.Experimental.Rendering;
using UnityEngine.Rendering;
namespace GameCult.Eve.UnityScene.Fields
{
public class EveFieldsSplatRasterizer : MonoBehaviour
{
[SerializeField] private Material splatMaterial;
[SerializeField] private RenderTexture targetTexture;
[SerializeField] private GraphicsFormat targetFormat = GraphicsFormat.R16_SFloat;
[SerializeField] private int width = 512;
[SerializeField] private int height = 512;
[SerializeField] private int channelFilter = -1;
[SerializeField] private int layerFilter = -1;
[SerializeField] private int materialPass;
[SerializeField] private Color clearColor = Color.clear;
[SerializeField] private bool clearBeforeDraw = true;
private readonly EveFieldsSplatBuffer splatBuffer = new EveFieldsSplatBuffer();
private CommandBuffer commandBuffer;
private RenderTexture ownedTargetTexture;
private Material ownedSplatMaterial;
private static readonly int SplatBufferId = Shader.PropertyToID(EveFieldsSplatBuffer.SplatBufferPropertyName);
private static readonly int SplatCountId = Shader.PropertyToID(EveFieldsSplatBuffer.SplatCountPropertyName);
private static readonly int ViewportToClipId = Shader.PropertyToID(EveFieldsSplatBuffer.ViewportToClipPropertyName);
private static readonly int ChannelFilterId = Shader.PropertyToID(EveFieldsSplatBuffer.ChannelFilterPropertyName);
public RenderTexture TargetTexture => targetTexture != null ? targetTexture : ownedTargetTexture;
public int LastDrawnCount { get; private set; }
public int ChannelFilter { get => channelFilter; set => channelFilter = value; }
public int LayerFilter { get => layerFilter; set => layerFilter = value; }
public RenderTexture Render(IEveFieldsSplatsDocument document, int overrideWidth = 0, int overrideHeight = 0)
{
if (document == null) { LastDrawnCount = 0; return TargetTexture; }
var output = EnsureTarget(overrideWidth, overrideHeight);
var material = ResolveMaterial();
if (output == null || material == null) { LastDrawnCount = 0; return output; }
splatBuffer.Upload(document.Splats, channelFilter, layerFilter);
LastDrawnCount = splatBuffer.Count;
if (!splatBuffer.HasGpuBuffer) { if (clearBeforeDraw) Clear(output); return output; }
commandBuffer ??= new CommandBuffer { name = "Eve Fields Splats" };
commandBuffer.Clear();
commandBuffer.SetRenderTarget(output);
if (clearBeforeDraw) commandBuffer.ClearRenderTarget(false, true, clearColor);
commandBuffer.SetGlobalBuffer(SplatBufferId, splatBuffer.Buffer);
commandBuffer.SetGlobalInt(SplatCountId, splatBuffer.Count);
commandBuffer.SetGlobalInt(ChannelFilterId, channelFilter);
commandBuffer.SetGlobalMatrix(ViewportToClipId, BuildViewportToClip(document.Viewport));
commandBuffer.DrawProcedural(Matrix4x4.identity, material, Mathf.Max(0, materialPass), MeshTopology.Triangles, 6, splatBuffer.Count);
Graphics.ExecuteCommandBuffer(commandBuffer);
return output;
}
public RenderTexture RenderLayerToTarget(IEveFieldsSplatsDocument document, RenderTexture output, int overrideLayerFilter, int overrideMaterialPass, Color overrideClearColor)
{
var oldTarget = targetTexture; var oldLayer = layerFilter; var oldPass = materialPass; var oldClear = clearColor;
targetTexture = output; layerFilter = overrideLayerFilter; materialPass = overrideMaterialPass; clearColor = overrideClearColor;
try { return Render(document, output != null ? output.width : 0, output != null ? output.height : 0); }
finally { targetTexture = oldTarget; layerFilter = oldLayer; materialPass = oldPass; clearColor = oldClear; }
}
private Material ResolveMaterial()
{
if (splatMaterial != null) return splatMaterial;
if (ownedSplatMaterial != null) return ownedSplatMaterial;
var shader = Shader.Find("Eve/Fields/Splats");
if (shader == null) return null;
ownedSplatMaterial = new Material(shader) { hideFlags = HideFlags.DontSave };
return ownedSplatMaterial;
}
private RenderTexture EnsureTarget(int overrideWidth, int overrideHeight)
{
if (targetTexture != null) return targetTexture;
var targetWidth = Mathf.Max(1, overrideWidth > 0 ? overrideWidth : width);
var targetHeight = Mathf.Max(1, overrideHeight > 0 ? overrideHeight : height);
if (ownedTargetTexture != null && ownedTargetTexture.width == targetWidth && ownedTargetTexture.height == targetHeight && ownedTargetTexture.graphicsFormat == targetFormat) return ownedTargetTexture;
ReleaseOwnedTarget();
var descriptor = new RenderTextureDescriptor(targetWidth, targetHeight) { depthBufferBits = 0, graphicsFormat = targetFormat, msaaSamples = 1, sRGB = false, useMipMap = false, autoGenerateMips = false };
ownedTargetTexture = new RenderTexture(descriptor) { name = "Eve Fields Splats", filterMode = FilterMode.Bilinear, wrapMode = TextureWrapMode.Clamp };
ownedTargetTexture.Create();
return ownedTargetTexture;
}
private void Clear(RenderTexture output)
{
commandBuffer ??= new CommandBuffer { name = "Eve Fields Splats" };
commandBuffer.Clear(); commandBuffer.SetRenderTarget(output); commandBuffer.ClearRenderTarget(false, true, clearColor); Graphics.ExecuteCommandBuffer(commandBuffer);
}
private static Matrix4x4 BuildViewportToClip(IEveFieldsViewport viewport)
{
var minX = (float)System.Math.Min(viewport?.MinX ?? 0, viewport?.MaxX ?? 0);
var minY = (float)System.Math.Min(viewport?.MinY ?? 0, viewport?.MaxY ?? 0);
var maxX = (float)System.Math.Max(viewport?.MinX ?? 0, viewport?.MaxX ?? 0);
var maxY = (float)System.Math.Max(viewport?.MinY ?? 0, viewport?.MaxY ?? 0);
var spanX = Mathf.Max(0.0001f, maxX - minX); var spanY = Mathf.Max(0.0001f, maxY - minY);
return new Matrix4x4(new Vector4(2f / spanX, 0, 0, 0), new Vector4(0, 2f / spanY, 0, 0), new Vector4(0, 0, 1, 0), new Vector4(-(maxX + minX) / spanX, -(maxY + minY) / spanY, 0, 1));
}
private void OnDisable()
{
splatBuffer.Dispose(); commandBuffer?.Release(); commandBuffer = null;
if (ownedSplatMaterial != null) { Destroy(ownedSplatMaterial); ownedSplatMaterial = null; }
ReleaseOwnedTarget(); LastDrawnCount = 0;
}
private void ReleaseOwnedTarget()
{
if (ownedTargetTexture == null) return;
ownedTargetTexture.Release(); Destroy(ownedTargetTexture); ownedTargetTexture = null;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3c38a5ac2cea4201943817fbdf766974

View file

@ -0,0 +1,49 @@
Shader "Eve/Fields/Splats"
{
Properties { _ValueScale("Value Scale", Float) = 1 }
SubShader
{
Tags { "RenderType" = "Transparent" "Queue" = "Transparent" }
Pass
{
Name "Add"
Cull Off ZWrite Off ZTest Always
BlendOp Add
Blend One One
HLSLPROGRAM
#pragma target 4.5
#pragma vertex Vert
#pragma fragment Frag
#include "UnityCG.cginc"
#include "EveFieldsSplatsCore.hlsl"
ENDHLSL
}
Pass
{
Name "Max"
Cull Off ZWrite Off ZTest Always
BlendOp Max
Blend One One
HLSLPROGRAM
#pragma target 4.5
#pragma vertex Vert
#pragma fragment Frag
#include "UnityCG.cginc"
#include "EveFieldsSplatsCore.hlsl"
ENDHLSL
}
Pass
{
Name "Alpha"
Cull Off ZWrite Off ZTest Always
Blend SrcAlpha OneMinusSrcAlpha
HLSLPROGRAM
#pragma target 4.5
#pragma vertex Vert
#pragma fragment Frag
#include "UnityCG.cginc"
#include "EveFieldsSplatsCore.hlsl"
ENDHLSL
}
}
}

View file

@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 830df1cf91684771a9254a4b28613f6f
ShaderImporter:
externalObjects: {}
defaultTextures: []
nonModifiableTextures: []
preprocessorOverride: 0
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,87 @@
#ifndef EVE_FIELDS_SPLATS_CORE_INCLUDED
#define EVE_FIELDS_SPLATS_CORE_INCLUDED
struct EveFieldsSplat
{
float4 centerHalfExtent;
float4 rotationChannelFalloff;
float4 layerSource;
float4 sourceFrequencyPhase;
float4 value;
};
StructuredBuffer<EveFieldsSplat> _EveFieldsSplats;
float4x4 _EveFieldsViewportToClip;
int _EveFieldsSplatCount;
int _EveFieldsChannelFilter;
float _ValueScale;
float hash21(float2 p)
{
p = frac(p * float2(123.34, 456.21));
p += dot(p, p + 45.32);
return frac(p.x * p.y);
}
float valueNoise(float2 p)
{
float2 i = floor(p);
float2 f = frac(p);
float a = hash21(i);
float b = hash21(i + float2(1, 0));
float c = hash21(i + float2(0, 1));
float d = hash21(i + float2(1, 1));
float2 u = f * f * (3 - 2 * f);
return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y) * 2 - 1;
}
struct Varyings
{
float4 position : SV_POSITION;
float2 localUv : TEXCOORD0;
nointerpolation uint instanceId : TEXCOORD1;
};
Varyings Vert(uint vertexId : SV_VertexID, uint instanceId : SV_InstanceID)
{
static const float2 corners[6] = { float2(-1,-1), float2(1,-1), float2(1,1), float2(-1,-1), float2(1,1), float2(-1,1) };
EveFieldsSplat splat = _EveFieldsSplats[instanceId];
float2 local = corners[vertexId];
float2 scaled = local * splat.centerHalfExtent.zw;
float c = splat.rotationChannelFalloff.x;
float s = splat.rotationChannelFalloff.y;
float2 world = splat.centerHalfExtent.xy + float2(scaled.x*c-scaled.y*s, scaled.x*s+scaled.y*c);
Varyings output;
output.position = mul(_EveFieldsViewportToClip, float4(world, 0, 1));
output.localUv = local;
output.instanceId = instanceId;
return output;
}
float ResolveFalloff(float2 localUv, int falloff)
{
float distance01 = saturate(length(localUv));
if (falloff == 0) return 1;
if (falloff == 1) return saturate(1 - distance01);
if (falloff == 3) return smoothstep(0, 1, distance01);
return 1 - smoothstep(0, 1, distance01);
}
float4 Frag(Varyings input) : SV_Target
{
EveFieldsSplat splat = _EveFieldsSplats[input.instanceId];
int channel = (int)round(splat.rotationChannelFalloff.z);
if (_EveFieldsChannelFilter >= 0 && channel != _EveFieldsChannelFilter) discard;
float alpha = ResolveFalloff(input.localUv, (int)round(splat.rotationChannelFalloff.w));
clip(alpha - 0.0001);
int sourceKind = (int)round(splat.layerSource.y);
float source = 1;
if (sourceKind == 1 || sourceKind == 2)
{
float timeOffset = sourceKind == 2 ? _Time.y * splat.layerSource.z : 0;
source = valueNoise(input.localUv * splat.sourceFrequencyPhase.xy + splat.sourceFrequencyPhase.zw + timeOffset);
if (splat.layerSource.w != 0) source = abs(source);
}
return splat.value * (alpha * source * _ValueScale);
}
#endif

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c57c2586f57942f39323935e46133e86
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -2,7 +2,8 @@
"name": "GameCult.Eve.UnityScene",
"rootNamespace": "GameCult.Eve.UnityScene",
"references": [
"GameCult.Eve.Surface"
"GameCult.Eve.Surface",
"GameCult.Eve.PluginFields"
],
"includePlatforms": [],
"excludePlatforms": [],

View file

@ -0,0 +1,32 @@
using System;
using System.IO;
using System.Linq;
using GameCult.Eve.PluginFields;
using GameCult.Eve.UnityScene.Fields;
using NUnit.Framework;
using UnityEngine;
namespace GameCult.Eve.UnityScene.Tests
{
public sealed class EveFieldsUnitySceneProjectionTests
{
[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 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);
}
}
}

View file

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

View file

@ -3,7 +3,8 @@
"rootNamespace": "GameCult.Eve.UnityScene.Tests",
"references": [
"GameCult.Eve.Surface",
"GameCult.Eve.UnityScene"
"GameCult.Eve.UnityScene",
"GameCult.Eve.PluginFields"
],
"includePlatforms": [
"Editor"

View file

@ -39,6 +39,14 @@
"embeddedDocuments"
],
"supportedPlugins": [
{
"pluginId": "fields.surface",
"capabilities": [
"field.surface2d",
"gravity.surface"
],
"projectionAdapter": "EveFieldsSplatRasterizer"
},
{
"pluginId": "sai.vn",
"capabilities": [

View file

@ -1,11 +1,12 @@
{
"name": "org.gamecult.eve.unity-scene",
"displayName": "Eve Unity Scene Lowering",
"version": "0.1.1",
"version": "0.1.2",
"unity": "2021.3",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {
"org.gamecult.cultlib": "1.0.3",
"org.gamecult.eve.plugin-fields": "0.1.1",
"org.gamecult.eve.surface": "0.1.0"
},
"keywords": [