Lower provider particle material frame inputs

This commit is contained in:
Meta Vens 2026-07-16 18:10:33 +01:00
commit 6ed222542d
4 changed files with 129 additions and 1 deletions

View file

@ -47,6 +47,12 @@ projected `EveFieldsViewport`. URP TAA state is likewise derived from the
world's neutral `temporal-reprojection.v1` camera contract and is restored on
release.
Particle materials may consume advertised presentation-only inputs through
logical native metadata: provider-owned textures, viewport-to-texture scale,
and the current render-frame index. The runtime frame index owns temporal
coverage only; it cannot drive particle identity, phase, position, or any
daemon-visible state.
## Forbidden Writers
Unity transforms, input drivers, camera rigs, scene sinks, asset caches, and

View file

@ -82,6 +82,12 @@ the field textures it samples. A `world.scene3d` may also request
quality, and sharpening values to URP TAA and restores the previous camera
state when the world releases the camera.
`field.particles3d` may advertise provider material textures, viewport-relative
texture scales, and a logical render-frame index port. These are presentation
inputs: EveUnity binds the selected provider assets and current display frame
without assigning product meaning to the shader. Stateless simulation time and
particle positions remain provider-document inputs.
Providers may advertise per-layer resolution scales, mipmap use, and filtering
through `layerTargetDescriptors`; the Unity lowerer applies those native target
requirements without assigning gameplay or product meaning to the layer keys.

View file

@ -27,6 +27,7 @@ namespace GameCult.Eve.UnityScene.Fields
private IReadOnlyDictionary<string, string>? _computeMetadata;
private IReadOnlyDictionary<string, string>? _materialMetadata;
private readonly Dictionary<string, Texture> _assetTextures = new Dictionary<string, Texture>(StringComparer.Ordinal);
private readonly Dictionary<string, Texture> _materialAssetTextures = new Dictionary<string, Texture>(StringComparer.Ordinal);
private ComputeBuffer? _particles;
private ComputeBuffer? _quadPoints;
private EveUnityFieldsParticlePass? _renderPass;
@ -170,11 +171,22 @@ namespace GameCult.Eve.UnityScene.Fields
if (texture == null) return false;
resolvedTextures[binding.Value] = texture;
}
var resolvedMaterialTextures = new Dictionary<string, Texture>(StringComparer.Ordinal);
foreach (var binding in ParseBindings(Prop(field, "materialAssetTextureBindings")))
{
var texture = provider.ResolveAsset(
new EveUnityPlayableWorldAssetBinding(binding.Key, "", "provider-asset-ref"),
typeof(Texture)) as Texture;
if (texture == null) return false;
resolvedMaterialTextures[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)))
_assetTextures.TryGetValue(binding.Key, out var current) && ReferenceEquals(current, binding.Value)) &&
resolvedMaterialTextures.Count == _materialAssetTextures.Count && resolvedMaterialTextures.All(binding =>
_materialAssetTextures.TryGetValue(binding.Key, out var current) && ReferenceEquals(current, binding.Value)))
return true;
ReleaseProgramsAndBuffers();
@ -187,6 +199,12 @@ namespace GameCult.Eve.UnityScene.Fields
_material = new Material(material) { hideFlags = HideFlags.DontSave };
_assetTextures.Clear();
foreach (var binding in resolvedTextures) _assetTextures[binding.Key] = binding.Value;
_materialAssetTextures.Clear();
foreach (var binding in resolvedMaterialTextures)
{
_materialAssetTextures[binding.Key] = binding.Value;
SetMaterialTexture(binding.Key, binding.Value);
}
foreach (var feature in Prop(field, "features").Split(';'))
{
var logical = feature.Trim();
@ -259,6 +277,7 @@ namespace GameCult.Eve.UnityScene.Fields
if (!HasPort(computeMetadata, "texturePort", binding.Value)) return false;
foreach (var binding in ParseBindingPairs(Prop(field, "assetTextureBindings")))
if (!HasPort(computeMetadata, "texturePort", binding.Value)) return false;
if (!TryValidateMaterialPresentationContract(field, materialMetadata, out _)) return false;
foreach (var parameter in ParseBindingPairs(Prop(field, "floatParameters")))
if (!HasPort(computeMetadata, "floatPort", parameter.Key) ||
!TryFloat(parameter.Value, out _)) return false;
@ -293,6 +312,30 @@ namespace GameCult.Eve.UnityScene.Fields
PositiveInt(field, "textureHeight", 0) > 0;
}
public static bool TryValidateMaterialPresentationContract(
EveUnityFieldParticlesProjection field,
IReadOnlyDictionary<string, string>? materialMetadata,
out string error)
{
if (field == null || materialMetadata == null)
return Fail("Field-particle material presentation contract is missing.", out error);
var textures = ParseBindings(Prop(field, "materialAssetTextureBindings"));
foreach (var binding in textures)
if (!HasPort(materialMetadata, "texturePort", binding.Value))
return Fail("Field-particle material texture binding has no native logical port.", out error);
foreach (var binding in ParseBindingPairs(Prop(field, "materialViewportTextureScaleBindings")))
{
if (!HasPort(materialMetadata, "vectorPort", binding.Key) ||
!textures.Values.Contains(binding.Value))
return Fail("Field-particle viewport texture-scale binding is incomplete.", out error);
}
var framePort = Prop(field, "materialRenderFrameIndexPort");
if (!string.IsNullOrWhiteSpace(framePort) && !HasPort(materialMetadata, "intPort", framePort))
return Fail("Field-particle render-frame binding has no native logical port.", out error);
error = "";
return true;
}
private static bool HasPort(
IReadOnlyDictionary<string, string> metadata,
string kind,
@ -367,6 +410,20 @@ namespace GameCult.Eve.UnityScene.Fields
TextureHandle depthTarget)
{
if (_host == null || _host.ActiveCameraTransform != camera.transform || !ProgramReady) return;
var field = ActiveField();
if (field == null) return;
foreach (var binding in ParseBindings(Prop(field, "materialViewportTextureScaleBindings")))
{
if (!_materialAssetTextures.TryGetValue(binding.Value, out var texture) ||
texture.width <= 0 || texture.height <= 0) return;
SetMaterialVector(binding.Key, new Vector4(
(float)Math.Max(1, camera.pixelWidth) / texture.width,
(float)Math.Max(1, camera.pixelHeight) / texture.height,
0f,
0f));
}
var framePort = Prop(field, "materialRenderFrameIndexPort");
if (!string.IsNullOrWhiteSpace(framePort)) SetMaterialInt(framePort, Time.frameCount);
if (depthTarget.IsValid()) commands.SetRenderTarget(colorTarget, depthTarget);
else commands.SetRenderTarget(colorTarget);
commands.SetViewport(camera.pixelRect);
@ -471,6 +528,24 @@ namespace GameCult.Eve.UnityScene.Fields
_material!.SetBuffer(property, buffer);
}
private void SetMaterialTexture(string logical, Texture texture)
{
if (TryMaterialProgramValue($"unity.particles.texturePort.{logical}", out var property))
_material!.SetTexture(property, texture);
}
private void SetMaterialVector(string logical, Vector4 value)
{
if (TryMaterialProgramValue($"unity.particles.vectorPort.{logical}", out var property))
_material!.SetVector(property, value);
}
private void SetMaterialInt(string logical, int value)
{
if (TryMaterialProgramValue($"unity.particles.intPort.{logical}", out var property))
_material!.SetInt(property, value);
}
private void SetComputeFloat(string logical, float value)
{
if (TryComputeProgramValue($"unity.particles.floatPort.{logical}", out var property))
@ -533,6 +608,7 @@ namespace GameCult.Eve.UnityScene.Fields
_computeMetadata = null;
_materialMetadata = null;
_assetTextures.Clear();
_materialAssetTextures.Clear();
_activeNodeId = "";
}

View file

@ -137,6 +137,46 @@ namespace GameCult.Eve.UnityScene.Tests
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()
{