diff --git a/docs/authority-map.md b/docs/authority-map.md index d55e25c..17e065d 100644 --- a/docs/authority-map.md +++ b/docs/authority-map.md @@ -18,6 +18,7 @@ drivers, asset-provider hooks, package release, runtime tests, and captures. - `gamecult.eve.surface.v1` - provider asset-manifest documents - runtime-selected asset-variant metadata, including native rendering-program bindings +- generic field-volume lifecycle declarations and logical texture/scalar/matrix ports - provider command receipts - semantic render-channel inclusion and exclusion policy - Unity input and local presentation assets @@ -33,8 +34,10 @@ drivers, asset-provider hooks, package release, runtime tests, and captures. `ActiveWorld`, scene objects, transforms, markers, camera pose, native render-channel layer mappings, resolved native shader ports/passes, asset caches, +per-volume raymarch targets, temporal-history targets, previous camera matrices, presentation counters, and receipt display state are projections. They are not -world truth. +world truth. Temporal targets reset when the volume program, node, or render size +changes and can never become provider state. ## Forbidden Writers @@ -43,7 +46,10 @@ pending receipts may not mutate or simulate provider world state. Plugin projection shells may not implement Sai, Norn, or TeX semantics. Portable surfaces may not carry Unity layer numbers, camera culling masks, shader property names, shader keywords, or native pass indices. Those bindings belong to the -selected runtime asset variant. +selected runtime asset variant. A native volume program that advertises a +temporal pass must also advertise its current-sample, history, +previous-view-projection, and reset-history ports; partial temporal programs fail +closed. ## Shared Paths diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsVolumeRenderer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsVolumeRenderer.cs index 37aeb7f..2509849 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsVolumeRenderer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Fields/EveUnityFieldsVolumeRenderer.cs @@ -20,13 +20,17 @@ namespace GameCult.Eve.UnityScene.Fields private Material? _material; private Shader? _sourceShader; private IReadOnlyDictionary? _programMetadata; - private RenderTexture? _cloudTexture; + private RenderTexture? _raymarchTexture; + private readonly RenderTexture?[] _historyTextures = new RenderTexture?[2]; + private int _historyTextureIndex = -1; + private Matrix4x4 _previousViewProjection; private CommandBuffer? _commands; private string _activeNodeId = ""; public long PresentedFrameId { get; private set; } = -1; public int PresentedLayerCount => _targets.Count(target => target.TargetTexture != null); public long CompositeCount { get; private set; } + public bool UsesTemporalHistory => HasTemporalProgram(); public void Bind(EveUnityPlayableWorldClientHost host, IEveUnityFieldsSplatsDocumentSource? source) { @@ -84,28 +88,58 @@ namespace GameCult.Eve.UnityScene.Fields if (field == null || !EnsureMaterial(field)) return; ApplyFieldBindings(field, _document); camera.depthTextureMode |= DepthTextureMode.Depth; - EnsureCloudTexture(camera, field); - if (_cloudTexture == null) return; + EnsureVolumeTextures(camera, field); + if (_raymarchTexture == null) return; var gpuProjection = GL.GetGPUProjectionMatrix(camera.projectionMatrix, true); - SetMatrixPort("cameraInverseViewProjection", (gpuProjection * camera.worldToCameraMatrix).inverse); + var viewProjection = gpuProjection * camera.worldToCameraMatrix; + SetMatrixPort("cameraInverseViewProjection", viewProjection.inverse); SetVectorPort("cameraProjectionExtents", ProjectionExtents(camera)); SetFloatPort("raymarchOffset", Halton(Time.frameCount & 1023, 3)); var raymarchPass = ProgramPass("raymarch"); var compositePass = ProgramPass("composite"); - SetTexturePort("cloud", _cloudTexture); + var temporalPass = ProgramPass("temporal"); + var useTemporalHistory = temporalPass >= 0; + var historyWriteIndex = -1; + RenderTexture finalCloudTexture = _raymarchTexture; + + if (useTemporalHistory) + { + historyWriteIndex = _historyTextureIndex < 0 ? 0 : (_historyTextureIndex + 1) % _historyTextures.Length; + var historyReadTexture = _historyTextureIndex < 0 + ? _raymarchTexture + : _historyTextures[_historyTextureIndex]; + finalCloudTexture = _historyTextures[historyWriteIndex]!; + SetTexturePort("currentSample", _raymarchTexture); + SetTexturePort("history", historyReadTexture!); + SetMatrixPort("previousViewProjection", _historyTextureIndex < 0 ? viewProjection : _previousViewProjection); + SetFloatPort("resetHistory", _historyTextureIndex < 0 ? 1f : 0f); + } + SetTexturePort("cloud", finalCloudTexture); _commands ??= new CommandBuffer { name = "Eve Fields Volume" }; _commands.Clear(); - _commands.SetRenderTarget(_cloudTexture); - _commands.SetViewport(new Rect(0f, 0f, _cloudTexture.width, _cloudTexture.height)); + _commands.SetRenderTarget(_raymarchTexture); + _commands.SetViewport(new Rect(0f, 0f, _raymarchTexture.width, _raymarchTexture.height)); _commands.ClearRenderTarget(false, true, Color.clear); _commands.DrawProcedural(Matrix4x4.identity, _material!, raymarchPass, MeshTopology.Triangles, 3, 1); + if (useTemporalHistory) + { + _commands.SetRenderTarget(finalCloudTexture); + _commands.SetViewport(new Rect(0f, 0f, finalCloudTexture.width, finalCloudTexture.height)); + _commands.ClearRenderTarget(false, true, Color.clear); + _commands.DrawProcedural(Matrix4x4.identity, _material!, temporalPass, MeshTopology.Triangles, 3, 1); + } _commands.SetRenderTarget(BuiltinRenderTextureType.CameraTarget); _commands.SetViewport(camera.pixelRect); _commands.DrawProcedural(Matrix4x4.identity, _material!, compositePass, MeshTopology.Triangles, 3, 1); Graphics.ExecuteCommandBuffer(_commands); + if (useTemporalHistory) + { + _historyTextureIndex = historyWriteIndex; + _previousViewProjection = viewProjection; + } CompositeCount++; } @@ -126,12 +160,7 @@ namespace GameCult.Eve.UnityScene.Fields !metadataProvider.TryResolveAssetMetadata(shaderBinding, out var metadata)) return false; _programMetadata = metadata; - if (ProgramPass("raymarch") < 0 || ProgramPass("composite") < 0 || - string.IsNullOrWhiteSpace(ProgramPort("texture", "cloud")) || - string.IsNullOrWhiteSpace(ProgramPort("matrix", "cameraInverseViewProjection")) || - string.IsNullOrWhiteSpace(ProgramPort("vector", "cameraProjectionExtents")) || - string.IsNullOrWhiteSpace(ProgramPort("vector", "viewportTransform")) || - string.IsNullOrWhiteSpace(ProgramPort("float", "raymarchOffset"))) + if (!TryValidateProgramMetadata(metadata, out _)) return false; if (_material != null && ReferenceEquals(shader, _sourceShader) && string.Equals(_activeNodeId, field.NodeId, StringComparison.Ordinal)) return true; @@ -139,6 +168,7 @@ namespace GameCult.Eve.UnityScene.Fields _sourceShader = shader; _activeNodeId = field.NodeId; _material = new Material(shader) { hideFlags = HideFlags.DontSave }; + ResetTemporalHistory(); foreach (var parameter in ParseBindings(Prop(field, "floatParameters"))) { if (TryFloat(parameter.Value, out var value)) SetFloatPort(parameter.Key, value); @@ -203,32 +233,108 @@ namespace GameCult.Eve.UnityScene.Fields if (_layers == null) _layers = gameObject.AddComponent(); } - private void EnsureCloudTexture(Camera camera, EveUnityFieldVolumeProjection field) + private void EnsureVolumeTextures(Camera camera, EveUnityFieldVolumeProjection field) { var downsample = Mathf.Clamp(NonNegativeInt(field, "downsample", 1), 0, 3); var width = Mathf.Max(1, camera.pixelWidth >> downsample); var height = Mathf.Max(1, camera.pixelHeight >> downsample); - if (_cloudTexture != null && _cloudTexture.width == width && _cloudTexture.height == height) return; - if (_cloudTexture != null) { _cloudTexture.Release(); Destroy(_cloudTexture); } - _cloudTexture = new RenderTexture(width, height, 0, RenderTextureFormat.ARGBFloat) + var resetHistory = EnsureRenderTexture(ref _raymarchTexture, width, height, "Eve Fields Volume Raymarch"); + if (HasTemporalProgram()) { - name = "Eve Fields Volume", - filterMode = FilterMode.Bilinear, - wrapMode = TextureWrapMode.Clamp - }; - _cloudTexture.Create(); + resetHistory |= EnsureRenderTexture(ref _historyTextures[0], width, height, "Eve Fields Volume History A"); + resetHistory |= EnsureRenderTexture(ref _historyTextures[1], width, height, "Eve Fields Volume History B"); + } + else + { + ReleaseRenderTexture(ref _historyTextures[0]); + ReleaseRenderTexture(ref _historyTextures[1]); + resetHistory = true; + } + if (resetHistory) ResetTemporalHistory(); } private void ReleaseRenderState() { if (_commands != null) { _commands.Release(); _commands = null; } - if (_cloudTexture != null) { _cloudTexture.Release(); Destroy(_cloudTexture); _cloudTexture = null; } + ReleaseRenderTexture(ref _raymarchTexture); + ReleaseRenderTexture(ref _historyTextures[0]); + ReleaseRenderTexture(ref _historyTextures[1]); if (_material != null) { Destroy(_material); _material = null; } _sourceShader = null; _programMetadata = null; _activeNodeId = ""; PresentedFrameId = -1; CompositeCount = 0; + ResetTemporalHistory(); + } + + private void ResetTemporalHistory() + { + _historyTextureIndex = -1; + _previousViewProjection = Matrix4x4.identity; + } + + private bool HasTemporalProgram() => ProgramPass("temporal") >= 0; + + private static bool EnsureRenderTexture(ref RenderTexture? texture, int width, int height, string name) + { + if (texture != null && texture.width == width && texture.height == height) return false; + ReleaseRenderTexture(ref texture); + texture = new RenderTexture(width, height, 0, RenderTextureFormat.ARGBFloat) + { + name = name, + filterMode = FilterMode.Bilinear, + wrapMode = TextureWrapMode.Clamp + }; + texture.Create(); + return true; + } + + private static void ReleaseRenderTexture(ref RenderTexture? texture) + { + if (texture == null) return; + texture.Release(); + Destroy(texture); + texture = null; + } + + public static bool TryValidateProgramMetadata( + IReadOnlyDictionary? metadata, + out string error) + { + error = ""; + if (metadata == null) return Fail("Native volume program metadata is missing.", out error); + if (!ValidPass(metadata, "raymarch")) return Fail("Native volume raymarch pass is missing.", out error); + if (!ValidPass(metadata, "composite")) return Fail("Native volume composite pass is missing.", out error); + if (!ValidPort(metadata, "texture", "cloud") || + !ValidPort(metadata, "matrix", "cameraInverseViewProjection") || + !ValidPort(metadata, "vector", "cameraProjectionExtents") || + !ValidPort(metadata, "vector", "viewportTransform") || + !ValidPort(metadata, "float", "raymarchOffset")) + return Fail("Native volume program is missing a required raymarch or composite port.", out error); + + if (!metadata.ContainsKey("unity.volume.pass.temporal")) return true; + if (!ValidPass(metadata, "temporal")) return Fail("Native volume temporal pass is invalid.", out error); + if (!ValidPort(metadata, "texture", "currentSample") || + !ValidPort(metadata, "texture", "history") || + !ValidPort(metadata, "matrix", "previousViewProjection") || + !ValidPort(metadata, "float", "resetHistory")) + return Fail("Native volume temporal pass is missing a required history port.", out error); + return true; + } + + private static bool ValidPass(IReadOnlyDictionary metadata, string pass) => + metadata.TryGetValue($"unity.volume.pass.{pass}", out var value) && + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed >= 0; + + private static bool ValidPort(IReadOnlyDictionary metadata, string kind, string port) => + metadata.TryGetValue($"unity.volume.{kind}Port.{port}", out var value) && + !string.IsNullOrWhiteSpace(value); + + private static bool Fail(string message, out string error) + { + error = message; + return false; } private void SetMatrixPort(string port, Matrix4x4 value) 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 0cbb1a8..25fd08b 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs @@ -41,6 +41,38 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(typeof(IEveUnityFieldsSplatsDocumentSource).GetEvent("FieldsSplatsAvailable"), Is.Not.Null); } + [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 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 PackageContainsGenericFieldsShader() { @@ -90,6 +122,18 @@ namespace GameCult.Eve.UnityScene.Tests public IEveFieldsSplatSoa Splats { get; } = new CaptureSplats(); } + private static Dictionary RequiredVolumeProgramMetadata() => + new Dictionary(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; diff --git a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json index a7a7dd1..8603e65 100644 --- a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json +++ b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json @@ -46,12 +46,17 @@ "pluginId": "fields.surface", "capabilities": [ "field.surface2d", - "gravity.surface" + "gravity.surface", + "field.volume3d", + "volume.raymarch", + "volume.temporal-history", + "volume.composite" ], "projectionAdapter": "EveFieldsSplatRasterizer", "evidencePaths": [ "Runtime/Fields/EveFieldsSplatRasterizer.cs", "Runtime/Fields/EveFieldsSplatLayerRenderer.cs", + "Runtime/Fields/EveUnityFieldsVolumeRenderer.cs", "Tests/Editor/EveFieldsUnitySceneProjectionTests.cs", "artifacts/fields-surface/latest/unity-fields.png" ] diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index a70e5fd..ea0ecf5 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.37", + "version": "0.3.38", "unity": "2021.3", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": {