Lower world-anchored procedural fields

This commit is contained in:
Meta Vens 2026-07-15 23:15:45 +01:00
commit 8feeefe6a5
7 changed files with 228 additions and 18 deletions

View file

@ -11,6 +11,7 @@ namespace GameCult.Eve.UnityScene.Fields
public const string SplatCountPropertyName = "_EveFieldsSplatCount";
public const string ViewportToClipPropertyName = "_EveFieldsViewportToClip";
public const string ChannelFilterPropertyName = "_EveFieldsChannelFilter";
public const string SimulationTimePropertyName = "_EveFieldsSimulationTimeSeconds";
private const int SplatStrideBytes = 96;
private EveFieldsGpuSplat[] splats = Array.Empty<EveFieldsGpuSplat>();

View file

@ -26,6 +26,7 @@ namespace GameCult.Eve.UnityScene.Fields
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);
private static readonly int SimulationTimeId = Shader.PropertyToID(EveFieldsSplatBuffer.SimulationTimePropertyName);
public RenderTexture TargetTexture => targetTexture != null ? targetTexture : ownedTargetTexture;
public int LastDrawnCount { get; private set; }
@ -48,6 +49,7 @@ namespace GameCult.Eve.UnityScene.Fields
commandBuffer.SetGlobalBuffer(SplatBufferId, splatBuffer.Buffer);
commandBuffer.SetGlobalInt(SplatCountId, splatBuffer.Count);
commandBuffer.SetGlobalInt(ChannelFilterId, channelFilter);
commandBuffer.SetGlobalFloat(SimulationTimeId, (float)document.SimulationTimeSeconds);
commandBuffer.SetGlobalMatrix(ViewportToClipId, BuildViewportToClip(document.Viewport));
commandBuffer.DrawProcedural(Matrix4x4.identity, material, Mathf.Max(0, materialPass), MeshTopology.Triangles, 6, splatBuffer.Count);
Graphics.ExecuteCommandBuffer(commandBuffer);

View file

@ -15,31 +15,105 @@ StructuredBuffer<EveFieldsSplat> _EveFieldsSplats;
float4x4 _EveFieldsViewportToClip;
int _EveFieldsSplatCount;
int _EveFieldsChannelFilter;
float _EveFieldsSimulationTimeSeconds;
float _ValueScale;
float hash21(float2 p)
// Ashima Arts webgl-noise, translated by Keijiro Takahashi.
// Copyright (C) 2011 Ashima Arts. Distributed under the MIT License.
float3 EveFieldsMod289(float3 x)
{
p = frac(p * float2(123.34, 456.21));
p += dot(p, p + 45.32);
return frac(p.x * p.y);
return x - floor(x / 289.0) * 289.0;
}
float valueNoise(float2 p)
float4 EveFieldsMod289(float4 x)
{
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;
return x - floor(x / 289.0) * 289.0;
}
float4 EveFieldsPermute(float4 x)
{
return EveFieldsMod289((x * 34.0 + 1.0) * x);
}
float4 EveFieldsTaylorInvSqrt(float4 r)
{
return 1.79284291400159 - r * 0.85373472095314;
}
float EveFieldsSimplexNoise3D(float3 v)
{
const float2 C = float2(1.0 / 6.0, 1.0 / 3.0);
float3 i = floor(v + dot(v, C.yyy));
float3 x0 = v - i + dot(i, C.xxx);
float3 g = step(x0.yzx, x0.xyz);
float3 l = 1.0 - g;
float3 i1 = min(g.xyz, l.zxy);
float3 i2 = max(g.xyz, l.zxy);
float3 x1 = x0 - i1 + C.xxx;
float3 x2 = x0 - i2 + C.yyy;
float3 x3 = x0 - 0.5;
i = EveFieldsMod289(i);
float4 p = EveFieldsPermute(EveFieldsPermute(EveFieldsPermute(
i.z + float4(0.0, i1.z, i2.z, 1.0))
+ i.y + float4(0.0, i1.y, i2.y, 1.0))
+ i.x + float4(0.0, i1.x, i2.x, 1.0));
float4 j = p - 49.0 * floor(p / 49.0);
float4 x_ = floor(j / 7.0);
float4 y_ = floor(j - 7.0 * x_);
float4 x = (x_ * 2.0 + 0.5) / 7.0 - 1.0;
float4 y = (y_ * 2.0 + 0.5) / 7.0 - 1.0;
float4 h = 1.0 - abs(x) - abs(y);
float4 b0 = float4(x.xy, y.xy);
float4 b1 = float4(x.zw, y.zw);
float4 s0 = floor(b0) * 2.0 + 1.0;
float4 s1 = floor(b1) * 2.0 + 1.0;
float4 sh = -step(h, 0.0);
float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
float4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
float3 g0 = float3(a0.xy, h.x);
float3 g1 = float3(a0.zw, h.y);
float3 g2 = float3(a1.xy, h.z);
float3 g3 = float3(a1.zw, h.w);
float4 norm = EveFieldsTaylorInvSqrt(float4(
dot(g0, g0), dot(g1, g1), dot(g2, g2), dot(g3, g3)));
g0 *= norm.x;
g1 *= norm.y;
g2 *= norm.z;
g3 *= norm.w;
float4 m = max(0.6 - float4(
dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
m *= m;
m *= m;
return 42.0 * dot(m, float4(
dot(x0, g0), dot(x1, g1), dot(x2, g2), dot(x3, g3)));
}
float EveFieldsCellDistanceB(float2 p)
{
p = frac(p) - 0.5;
return (length(p) * 1.5 + 0.25) *
max(abs(p.x) * 0.866 + p.y * 0.5, -p.y);
}
float EveFieldsAnimatedCellNoiseB(float2 p, float time)
{
float2 o = sin(float2(1.93, 0.0) + time) * 0.166;
float a = EveFieldsCellDistanceB(p + float2(o.x, 0));
float b = EveFieldsCellDistanceB(p + float2(0, 0.5 + o.y));
p = mul(p + 0.5, -float2x2(0.5, -0.866, 0.866, 0.5));
float c = EveFieldsCellDistanceB(p + float2(o.x, 0));
float d = EveFieldsCellDistanceB(p + float2(0, 0.5 + o.y));
p = mul(p + 0.5, -float2x2(0.5, -0.866, 0.866, 0.5));
float e = EveFieldsCellDistanceB(p + float2(o.x, 0));
float f = EveFieldsCellDistanceB(p + float2(0, 0.5 + o.y));
return min(min(min(a, b), min(c, d)), min(e, f)) * 2.0;
}
struct Varyings
{
float4 position : SV_POSITION;
float2 localUv : TEXCOORD0;
float2 fieldWorld : TEXCOORD2;
nointerpolation uint instanceId : TEXCOORD1;
};
@ -55,6 +129,7 @@ Varyings Vert(uint vertexId : SV_VertexID, uint instanceId : SV_InstanceID)
Varyings output;
output.position = mul(_EveFieldsViewportToClip, float4(world, 0, 1));
output.localUv = local;
output.fieldWorld = world;
output.instanceId = instanceId;
return output;
}
@ -88,10 +163,20 @@ float4 Frag(Varyings input) : SV_Target
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);
float timeCoordinate = sourceKind == 2
? _EveFieldsSimulationTimeSeconds * splat.layerSource.z
: 0;
source = EveFieldsSimplexNoise3D(float3(
input.fieldWorld * splat.sourceFrequencyPhase.xy + splat.sourceFrequencyPhase.zw,
timeCoordinate));
}
else if (sourceKind == 3)
{
source = EveFieldsAnimatedCellNoiseB(
input.fieldWorld * splat.sourceFrequencyPhase.xy + splat.sourceFrequencyPhase.zw,
_EveFieldsSimulationTimeSeconds * splat.layerSource.z);
}
if (((int)round(splat.layerSource.w) & 1) != 0) source = abs(source);
return splat.value * (alpha * source * _ValueScale);
}
#endif

View file

@ -174,6 +174,94 @@ namespace GameCult.Eve.UnityScene.Tests
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));
}
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;

View file

@ -0,0 +1,27 @@
# Third-party notices
## Ashima Arts webgl-noise
The portable 3D simplex implementation in `Runtime/Fields/EveFieldsSplatsCore.hlsl`
is derived from Ashima Arts' `webgl-noise`, translated to Unity shader syntax by
Keijiro Takahashi.
Copyright (C) 2011 Ashima Arts.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5702faba00994033870c858bb2124d91
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -1,12 +1,12 @@
{
"name": "org.gamecult.eve.unity-scene",
"displayName": "Eve Unity Scene Lowering",
"version": "0.3.42",
"version": "0.3.43",
"unity": "2021.3",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {
"org.gamecult.cultlib": "1.0.13",
"org.gamecult.eve.plugin-fields": "0.2.1",
"org.gamecult.eve.plugin-fields": "0.2.2",
"org.gamecult.eve.surface": "0.2.2",
"com.unity.mathematics": "1.3.2",
"com.unity.modules.particlesystem": "1.0.0"