From 6ed222542d41c302a1fe2ab8c80f122212cfaa3f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 16 Jul 2026 18:10:33 +0100 Subject: [PATCH] Lower provider particle material frame inputs --- docs/authority-map.md | 6 ++ .../org.gamecult.eve.unity-scene/README.md | 6 ++ .../Fields/EveUnityFieldsParticleRenderer.cs | 78 ++++++++++++++++++- .../EveFieldsUnitySceneProjectionTests.cs | 40 ++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) diff --git a/docs/authority-map.md b/docs/authority-map.md index bc92db2..ac00b6d 100644 --- a/docs/authority-map.md +++ b/docs/authority-map.md @@ -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 diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index c1c66a0..40f8f0b 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -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. diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs index 34bfb2c..ca43dc5 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsParticleRenderer.cs @@ -27,6 +27,7 @@ namespace GameCult.Eve.UnityScene.Fields private IReadOnlyDictionary? _computeMetadata; private IReadOnlyDictionary? _materialMetadata; private readonly Dictionary _assetTextures = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _materialAssetTextures = new Dictionary(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(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? 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 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 = ""; } 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 5f5f4e2..a8349a4 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs @@ -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(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(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() {