From 4d5257b8ca09767974e8eb350a9695191ec39fdc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 16 Jul 2026 19:51:25 +0100 Subject: [PATCH] Lower adaptive exposure from Eve camera semantics --- .../EveUnityAdaptiveExposureRenderer.cs | 220 ++++++++++++++++++ .../EveUnityAdaptiveExposureRenderer.cs.meta | 2 + .../Runtime/EveUnityPlayableWorldCameraRig.cs | 37 ++- .../Runtime/EveUnitySceneSurfaceLowerer.cs | 49 +++- .../Runtime/Resources.meta | 8 + .../Runtime/Resources/EveUnity.meta | 8 + .../EveUnity/AdaptiveExposure.compute | 98 ++++++++ .../EveUnity/AdaptiveExposure.compute.meta | 8 + .../EveUnity/AdaptiveExposureMultiply.shader | 39 ++++ .../AdaptiveExposureMultiply.shader.meta | 9 + .../EveFieldsUnitySceneProjectionTests.cs | 83 +++++++ .../EveUnitySceneSurfaceLowererTests.cs | 45 +++- .../org.gamecult.eve.unity-scene/package.json | 2 +- 13 files changed, 602 insertions(+), 6 deletions(-) create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdaptiveExposureRenderer.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdaptiveExposureRenderer.cs.meta create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/Resources.meta create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity.meta create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposure.compute create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposure.compute.meta create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposureMultiply.shader create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposureMultiply.shader.meta diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdaptiveExposureRenderer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdaptiveExposureRenderer.cs new file mode 100644 index 0000000..04e1f0b --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdaptiveExposureRenderer.cs @@ -0,0 +1,220 @@ +using System; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.RenderGraphModule; +using UnityEngine.Rendering.Universal; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityAdaptiveExposureRenderer : MonoBehaviour + { + private const int HistogramBins = 128; + private ComputeShader? _compute; + private Material? _multiplyMaterial; + private ComputeBuffer? _histogram; + private readonly RenderTexture?[] _exposure = new RenderTexture?[2]; + private int _currentExposure = -1; + private Camera? _camera; + private AdaptiveExposurePass? _pass; + + private float _lowPercent; + private float _highPercent; + private float _minimumEv; + private float _maximumEv; + private float _keyValue; + private float _speedUp; + private float _speedDown; + private bool _progressive; + + public long AppliedFrameCount { get; private set; } + public bool IsConfigured { get; private set; } + public static RenderPassEvent ExposureRenderPassEvent => + (RenderPassEvent)((int)RenderPassEvent.BeforeRenderingPostProcessing + 1); + + public void Configure( + float lowPercent, + float highPercent, + float minimumEv, + float maximumEv, + float keyValue, + string adaptation, + float speedUp, + float speedDown) + { + _lowPercent = Mathf.Clamp(lowPercent, 1f, 98.99f); + _highPercent = Mathf.Clamp(highPercent, _lowPercent + 0.01f, 99f); + _minimumEv = Mathf.Min(minimumEv, maximumEv); + _maximumEv = Mathf.Max(minimumEv, maximumEv); + _keyValue = Mathf.Max(0f, keyValue); + _speedUp = Mathf.Max(0f, speedUp); + _speedDown = Mathf.Max(0f, speedDown); + _progressive = string.Equals(adaptation, "progressive", StringComparison.Ordinal); + IsConfigured = true; + enabled = true; + } + + private void OnEnable() + { + _camera = GetComponent(); + RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering; + } + + private void OnDisable() + { + RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering; + ReleaseResources(); + } + + private void OnDestroy() => ReleaseResources(); + + private void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera) + { + if (!IsConfigured || _camera == null || camera != _camera) return; + _pass ??= new AdaptiveExposurePass(this); + camera.GetUniversalAdditionalCameraData().scriptableRenderer.EnqueuePass(_pass); + } + + private bool EnsureResources() + { + _compute ??= Resources.Load("EveUnity/AdaptiveExposure"); + if (_multiplyMaterial == null) + { + var shader = Shader.Find("Hidden/EveUnity/AdaptiveExposureMultiply"); + if (shader != null) _multiplyMaterial = new Material(shader) { hideFlags = HideFlags.HideAndDontSave }; + } + _histogram ??= new ComputeBuffer(HistogramBins, sizeof(uint), ComputeBufferType.Structured); + for (var index = 0; index < _exposure.Length; index++) + { + if (_exposure[index] != null && _exposure[index]!.IsCreated()) continue; + _exposure[index] = new RenderTexture(1, 1, 0, RenderTextureFormat.RFloat, RenderTextureReadWrite.Linear) + { + name = $"Eve Adaptive Exposure {index}", + enableRandomWrite = true, + filterMode = FilterMode.Point, + wrapMode = TextureWrapMode.Clamp, + hideFlags = HideFlags.HideAndDontSave + }; + _exposure[index]!.Create(); + } + return _compute != null && _multiplyMaterial != null && _histogram != null; + } + + private void Apply(UnsafeCommandBuffer commands, TextureHandle colorTarget, int width, int height) + { + if (!EnsureResources() || _compute == null || _histogram == null || _multiplyMaterial == null) return; + + var clear = _compute.FindKernel("ClearHistogram"); + commands.SetComputeBufferParam(_compute, clear, "_Histogram", _histogram); + commands.DispatchCompute(_compute, clear, Mathf.CeilToInt(HistogramBins / 64f), 1, 1); + + var build = _compute.FindKernel("BuildHistogram"); + commands.SetComputeBufferParam(_compute, build, "_Histogram", _histogram); + commands.SetComputeTextureParam(_compute, build, "_Source", colorTarget); + commands.SetComputeVectorParam(_compute, "_HistogramScaleOffsetResolution", new Vector4( + 1f / 18f, + 0.5f, + Mathf.Max(1, width), + Mathf.Max(1, height))); + commands.DispatchCompute( + _compute, + build, + Mathf.CeilToInt(width / 2f / 16f), + Mathf.CeilToInt(height / 2f / 16f), + 1); + + var destination = _currentExposure < 0 ? 0 : (_currentExposure + 1) % 2; + var calculate = _compute.FindKernel( + _currentExposure < 0 || !_progressive ? "CalculateFixedExposure" : "CalculateProgressiveExposure"); + commands.SetComputeBufferParam(_compute, calculate, "_Histogram", _histogram); + commands.SetComputeVectorParam(_compute, "_ExposureFilterAndBrightness", new Vector4( + _lowPercent * 0.01f, + _highPercent * 0.01f, + Mathf.Pow(2f, _minimumEv), + Mathf.Pow(2f, _maximumEv))); + commands.SetComputeVectorParam(_compute, "_ExposureAdaptation", new Vector4( + _speedDown, + _speedUp, + _keyValue, + Mathf.Max(0f, Time.deltaTime))); + if (_currentExposure >= 0) + commands.SetComputeTextureParam(_compute, calculate, "_PreviousExposure", _exposure[_currentExposure]!); + commands.SetComputeTextureParam(_compute, calculate, "_DestinationExposure", _exposure[destination]!); + commands.DispatchCompute(_compute, calculate, 1, 1, 1); + + _currentExposure = destination; + _multiplyMaterial.SetTexture("_ExposureTexture", _exposure[_currentExposure]); + commands.SetRenderTarget(colorTarget); + commands.DrawProcedural(Matrix4x4.identity, _multiplyMaterial, 0, MeshTopology.Triangles, 3, 1); + AppliedFrameCount++; + } + + internal static float ExposureForAverageLuminance(float averageLuminance, float keyValue) => + Mathf.Max(0f, keyValue) / Mathf.Max(1e-6f, averageLuminance); + + internal static float AdaptExposure(float previous, float next, float deltaTime, float speedUp, float speedDown) + { + var speed = next > previous ? Mathf.Max(0f, speedDown) : Mathf.Max(0f, speedUp); + return previous + (next - previous) * (1f - Mathf.Pow(2f, -Mathf.Max(0f, deltaTime) * speed)); + } + + private void ReleaseResources() + { + _histogram?.Release(); + _histogram = null; + foreach (var texture in _exposure) + { + if (texture == null) continue; + texture.Release(); + if (Application.isPlaying) Destroy(texture); else DestroyImmediate(texture); + } + _exposure[0] = null; + _exposure[1] = null; + _currentExposure = -1; + if (_multiplyMaterial != null) + { + if (Application.isPlaying) Destroy(_multiplyMaterial); else DestroyImmediate(_multiplyMaterial); + _multiplyMaterial = null; + } + } + + private sealed class AdaptiveExposurePass : ScriptableRenderPass + { + private readonly EveUnityAdaptiveExposureRenderer _owner; + + private sealed class PassData + { + internal EveUnityAdaptiveExposureRenderer Owner = null!; + internal TextureHandle ColorTarget; + internal int Width; + internal int Height; + } + + internal AdaptiveExposurePass(EveUnityAdaptiveExposureRenderer owner) + { + _owner = owner; + renderPassEvent = ExposureRenderPassEvent; + } + + public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) + { + var cameraData = frameData.Get(); + if (cameraData.camera == null || cameraData.camera != _owner._camera) return; + var resourceData = frameData.Get(); + var colorTarget = resourceData.activeColorTexture; + if (!colorTarget.IsValid()) return; + + using var builder = renderGraph.AddUnsafePass("Eve Adaptive Exposure", out var passData); + passData.Owner = _owner; + passData.ColorTarget = colorTarget; + passData.Width = cameraData.camera.pixelWidth; + passData.Height = cameraData.camera.pixelHeight; + builder.UseTexture(passData.ColorTarget, AccessFlags.ReadWrite); + builder.AllowPassCulling(false); + builder.SetRenderFunc(static (PassData data, UnsafeGraphContext context) => + data.Owner.Apply(context.cmd, data.ColorTarget, data.Width, data.Height)); + } + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdaptiveExposureRenderer.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdaptiveExposureRenderer.cs.meta new file mode 100644 index 0000000..fc25c4a --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityAdaptiveExposureRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3cb42e7da8b34febb348f01a598f2eea diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs index 044b2c9..01f0b81 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs @@ -38,6 +38,7 @@ namespace GameCult.Eve.UnityScene private GameObject? _postProcessVolumeObject; private Volume? _postProcessVolume; private UniversalAdditionalCameraData? _postProcessCameraData; + private EveUnityAdaptiveExposureRenderer? _adaptiveExposure; private bool _createdPostProcessCameraData; private bool _previousRenderPostProcessing; private AntialiasingMode _previousAntialiasing; @@ -477,7 +478,11 @@ namespace GameCult.Eve.UnityScene world.CameraReconstruction, "temporal-reprojection.v1", System.StringComparison.Ordinal); - if (profile == null && !usesTemporalReprojection) + var usesAdaptiveExposure = string.Equals( + world.ExposureMode, + "histogram.v1", + System.StringComparison.Ordinal); + if (profile == null && !usesTemporalReprojection && !usesAdaptiveExposure) { RestorePostProcess(); return; @@ -530,6 +535,25 @@ namespace GameCult.Eve.UnityScene { _postProcessVolumeObject.SetActive(false); } + + if (usesAdaptiveExposure) + { + if (_adaptiveExposure == null) + _adaptiveExposure = camera.gameObject.AddComponent(); + _adaptiveExposure.Configure( + world.ExposureLowPercent, + world.ExposureHighPercent, + world.ExposureMinimumEv, + world.ExposureMaximumEv, + world.ExposureKeyValue, + world.ExposureAdaptation, + world.ExposureSpeedUp, + world.ExposureSpeedDown); + } + else + { + ReleaseAdaptiveExposure(); + } } private static bool IsTemporalQuality(string quality) => @@ -550,6 +574,7 @@ namespace GameCult.Eve.UnityScene private void RestorePostProcess() { + ReleaseAdaptiveExposure(); if (_postProcessCameraData != null) { if (_createdPostProcessCameraData) @@ -579,6 +604,16 @@ namespace GameCult.Eve.UnityScene _postProcessVolume = null; } + private void ReleaseAdaptiveExposure() + { + if (_adaptiveExposure == null) return; + if (Application.isPlaying) + Destroy(_adaptiveExposure); + else + DestroyImmediate(_adaptiveExposure); + _adaptiveExposure = null; + } + private void RestoreAmbientEnvironment() { ReleaseKeyLight(); diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs index bcc3637..374750c 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnitySceneSurfaceLowerer.cs @@ -161,7 +161,16 @@ namespace GameCult.Eve.UnityScene worldRoot.GetProp("temporalQuality", "high"), ParseFloat(worldRoot.GetProp("temporalHistoryBlend"), 0f), ParseFloat(worldRoot.GetProp("temporalJitterScale"), 0f), - ParseFloat(worldRoot.GetProp("temporalSharpening"), 0f)); + ParseFloat(worldRoot.GetProp("temporalSharpening"), 0f), + worldRoot.GetProp("exposureMode"), + ParseFloat(worldRoot.GetProp("exposureLowPercent"), 50f), + ParseFloat(worldRoot.GetProp("exposureHighPercent"), 95f), + ParseFloat(worldRoot.GetProp("exposureMinimumEv"), 0f), + ParseFloat(worldRoot.GetProp("exposureMaximumEv"), 0f), + ParseFloat(worldRoot.GetProp("exposureKeyValue"), 1f), + worldRoot.GetProp("exposureAdaptation", "progressive"), + ParseFloat(worldRoot.GetProp("exposureSpeedUp"), 2f), + ParseFloat(worldRoot.GetProp("exposureSpeedDown"), 1f)); } private static EveUnityFieldVolumeProjection BuildFieldVolume(EveSurfaceComponent component) => @@ -421,7 +430,16 @@ namespace GameCult.Eve.UnityScene string temporalQuality = "high", float temporalHistoryBlend = 0f, float temporalJitterScale = 0f, - float temporalSharpening = 0f) + float temporalSharpening = 0f, + string exposureMode = "", + float exposureLowPercent = 50f, + float exposureHighPercent = 95f, + float exposureMinimumEv = 0f, + float exposureMaximumEv = 0f, + float exposureKeyValue = 1f, + string exposureAdaptation = "progressive", + float exposureSpeedUp = 2f, + float exposureSpeedDown = 1f) { WorldRootId = worldRootId ?? ""; StatePointerId = statePointerId ?? ""; @@ -474,6 +492,15 @@ namespace GameCult.Eve.UnityScene TemporalHistoryBlend = temporalHistoryBlend; TemporalJitterScale = temporalJitterScale; TemporalSharpening = temporalSharpening; + ExposureMode = exposureMode ?? ""; + ExposureLowPercent = exposureLowPercent; + ExposureHighPercent = exposureHighPercent; + ExposureMinimumEv = exposureMinimumEv; + ExposureMaximumEv = exposureMaximumEv; + ExposureKeyValue = exposureKeyValue; + ExposureAdaptation = exposureAdaptation ?? ""; + ExposureSpeedUp = exposureSpeedUp; + ExposureSpeedDown = exposureSpeedDown; } public string WorldRootId { get; } @@ -508,6 +535,24 @@ namespace GameCult.Eve.UnityScene public float TemporalSharpening { get; } + public string ExposureMode { get; } + + public float ExposureLowPercent { get; } + + public float ExposureHighPercent { get; } + + public float ExposureMinimumEv { get; } + + public float ExposureMaximumEv { get; } + + public float ExposureKeyValue { get; } + + public string ExposureAdaptation { get; } + + public float ExposureSpeedUp { get; } + + public float ExposureSpeedDown { get; } + public float CameraDistance { get; } public float CameraVerticalFieldOfViewDegrees { get; } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Resources.meta b/packages/org.gamecult.eve.unity-scene/Runtime/Resources.meta new file mode 100644 index 0000000..fb9f65a --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 63a640b4e19e5764eb030d665a2b7c00 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity.meta b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity.meta new file mode 100644 index 0000000..d1f9c0e --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6435ff06293834c49aa631e74b51a552 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposure.compute b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposure.compute new file mode 100644 index 0000000..f1e1f27 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposure.compute @@ -0,0 +1,98 @@ +#pragma kernel ClearHistogram +#pragma kernel BuildHistogram +#pragma kernel CalculateFixedExposure +#pragma kernel CalculateProgressiveExposure + +RWStructuredBuffer _Histogram; +Texture2D _Source; +SamplerState sampler_LinearClamp; +Texture2D _PreviousExposure; +RWTexture2D _DestinationExposure; + +float4 _HistogramScaleOffsetResolution; +float4 _ExposureFilterAndBrightness; +float4 _ExposureAdaptation; + +groupshared uint GroupHistogram[128]; + +[numthreads(64, 1, 1)] +void ClearHistogram(uint threadId : SV_DispatchThreadID) +{ + if (threadId < 128) _Histogram[threadId] = 0; +} + +[numthreads(16, 16, 1)] +void BuildHistogram(uint2 dispatchId : SV_DispatchThreadID, uint2 groupId : SV_GroupThreadID) +{ + uint localId = groupId.y * 16 + groupId.x; + if (localId < 128) GroupHistogram[localId] = 0; + GroupMemoryBarrierWithGroupSync(); + + float2 pixel = float2(dispatchId) * 2.0; + if (pixel.x < _HistogramScaleOffsetResolution.z && pixel.y < _HistogramScaleOffsetResolution.w) + { + float2 uv = pixel / _HistogramScaleOffsetResolution.zw; + float2 distanceFromCenter = abs(uv - 0.5.xx); + float vignette = saturate(1.0 - dot(distanceFromCenter, distanceFromCenter)); + uint weight = (uint)(64.0 * vignette * vignette); + float3 color = _Source.SampleLevel(sampler_LinearClamp, uv, 0).rgb; + float luminance = max(1e-6, dot(color, float3(0.2126729, 0.7151522, 0.0721750))); + float normalizedLogLuminance = saturate( + log2(luminance) * _HistogramScaleOffsetResolution.x + _HistogramScaleOffsetResolution.y); + uint bin = (uint)(normalizedLogLuminance * 127.0); + InterlockedAdd(GroupHistogram[bin], weight); + } + + GroupMemoryBarrierWithGroupSync(); + if (localId < 128) InterlockedAdd(_Histogram[localId], GroupHistogram[localId]); +} + +float LuminanceForBin(uint bin) +{ + float normalized = (float)bin / 128.0; + return exp2((normalized - _HistogramScaleOffsetResolution.y) / _HistogramScaleOffsetResolution.x); +} + +float CalculateExposure() +{ + float total = 0.0; + for (uint index = 0; index < 128; index++) total += (float)_Histogram[index]; + + float discardDark = total * _ExposureFilterAndBrightness.x; + float includeThrough = total * _ExposureFilterAndBrightness.y; + float weightedLuminance = 0.0; + float includedWeight = 0.0; + for (uint index = 0; index < 128; index++) + { + float count = (float)_Histogram[index]; + float darkOffset = min(discardDark, count); + count -= darkOffset; + discardDark -= darkOffset; + includeThrough -= darkOffset; + count = min(includeThrough, count); + includeThrough -= count; + weightedLuminance += LuminanceForBin(index) * count; + includedWeight += count; + } + + float average = weightedLuminance / max(includedWeight, 1e-6); + average = clamp(average, _ExposureFilterAndBrightness.z, _ExposureFilterAndBrightness.w); + return _ExposureAdaptation.z / max(average, 1e-6); +} + +[numthreads(1, 1, 1)] +void CalculateFixedExposure(uint3 id : SV_DispatchThreadID) +{ + _DestinationExposure[uint2(0, 0)] = CalculateExposure(); +} + +[numthreads(1, 1, 1)] +void CalculateProgressiveExposure(uint3 id : SV_DispatchThreadID) +{ + float nextExposure = CalculateExposure(); + float previousExposure = _PreviousExposure[uint2(0, 0)]; + float delta = nextExposure - previousExposure; + float speed = delta > 0.0 ? _ExposureAdaptation.x : _ExposureAdaptation.y; + _DestinationExposure[uint2(0, 0)] = previousExposure + + delta * (1.0 - exp2(-_ExposureAdaptation.w * speed)); +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposure.compute.meta b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposure.compute.meta new file mode 100644 index 0000000..3fe7574 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposure.compute.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c8c45489b6ba44558342ebc5381c12d1 +ComputeShaderImporter: + externalObjects: {} + currentAPIMask: 4 + userData: + assetBundleName: + assetBundleVariant: diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposureMultiply.shader b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposureMultiply.shader new file mode 100644 index 0000000..d54ded1 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposureMultiply.shader @@ -0,0 +1,39 @@ +Shader "Hidden/EveUnity/AdaptiveExposureMultiply" +{ + SubShader + { + Pass + { + Cull Off ZWrite Off ZTest Always + Blend DstColor Zero + ColorMask RGB + + HLSLPROGRAM + #pragma target 4.5 + #pragma vertex Vert + #pragma fragment Frag + + Texture2D _ExposureTexture; + + struct Varyings + { + float4 position : SV_POSITION; + }; + + Varyings Vert(uint vertexId : SV_VertexID) + { + float2 uv = float2((vertexId << 1) & 2, vertexId & 2); + Varyings output; + output.position = float4(uv * 2.0 - 1.0, 0.0, 1.0); + return output; + } + + float4 Frag(Varyings input) : SV_Target + { + float exposure = _ExposureTexture.Load(int3(0, 0, 0)); + return exposure.xxxx; + } + ENDHLSL + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposureMultiply.shader.meta b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposureMultiply.shader.meta new file mode 100644 index 0000000..daf9128 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/Resources/EveUnity/AdaptiveExposureMultiply.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 48315c6017f548e7a4b46698b3d7229e +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: 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 3591dbd..9b8391c 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveFieldsUnitySceneProjectionTests.cs @@ -42,6 +42,89 @@ namespace GameCult.Eve.UnityScene.Tests } } + [Test] + public void AdaptiveExposureMatchesFossilCompensationAndProgressiveDirection() + { + var exposureMethod = typeof(EveUnityAdaptiveExposureRenderer).GetMethod( + "ExposureForAverageLuminance", + BindingFlags.NonPublic | BindingFlags.Static); + var adaptationMethod = typeof(EveUnityAdaptiveExposureRenderer).GetMethod( + "AdaptExposure", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.That(exposureMethod, Is.Not.Null); + Assert.That(adaptationMethod, Is.Not.Null); + var brightBound = Mathf.Pow(2f, 0.3f); + var exposure = (float)exposureMethod!.Invoke(null, new object[] { brightBound, 0.5f }); + Assert.That(exposure, Is.EqualTo(0.5f / brightBound).Within(0.000001f)); + + var darkening = (float)adaptationMethod!.Invoke( + null, + new object[] { 1f, 0.25f, 0.5f, 2f, 1f }); + var brightening = (float)adaptationMethod.Invoke( + null, + new object[] { 1f, 4f, 0.5f, 2f, 1f }); + Assert.That(darkening, Is.EqualTo(0.625f).Within(0.000001f)); + Assert.That(brightening, Is.EqualTo(1f + 3f * (1f - Mathf.Pow(2f, -0.5f))).Within(0.000001f)); + } + + [Test] + public void AdaptiveExposureGpuHistogramClampsBrightFrameToAdvertisedMaximumEv() + { + Assert.That(SystemInfo.supportsComputeShaders, Is.True); + var compute = Resources.Load("EveUnity/AdaptiveExposure"); + Assert.That(compute, Is.Not.Null); + var source = new Texture2D(32, 32, TextureFormat.RGBAFloat, false, true); + var pixels = Enumerable.Repeat(new Color(16f, 16f, 16f, 1f), 32 * 32).ToArray(); + source.SetPixels(pixels); + source.Apply(false, false); + using var histogram = new ComputeBuffer(128, sizeof(uint), ComputeBufferType.Structured); + var exposure = new RenderTexture(1, 1, 0, RenderTextureFormat.RFloat, RenderTextureReadWrite.Linear) + { + enableRandomWrite = true + }; + exposure.Create(); + try + { + var clear = compute!.FindKernel("ClearHistogram"); + compute.SetBuffer(clear, "_Histogram", histogram); + compute.Dispatch(clear, 2, 1, 1); + + var build = compute.FindKernel("BuildHistogram"); + compute.SetBuffer(build, "_Histogram", histogram); + compute.SetTexture(build, "_Source", source); + compute.SetVector("_HistogramScaleOffsetResolution", new Vector4(1f / 18f, 0.5f, 32f, 32f)); + compute.Dispatch(build, 1, 1, 1); + + var calculate = compute.FindKernel("CalculateFixedExposure"); + compute.SetBuffer(calculate, "_Histogram", histogram); + compute.SetVector("_ExposureFilterAndBrightness", new Vector4( + 0.4737294f, + 0.99f, + Mathf.Pow(2f, -3f), + Mathf.Pow(2f, 0.3f))); + compute.SetVector("_ExposureAdaptation", new Vector4(1f, 2f, 0.5f, 1f / 60f)); + compute.SetTexture(calculate, "_DestinationExposure", exposure); + compute.Dispatch(calculate, 1, 1, 1); + + var previous = RenderTexture.active; + RenderTexture.active = exposure; + var readback = new Texture2D(1, 1, TextureFormat.RFloat, false, true); + readback.ReadPixels(new Rect(0, 0, 1, 1), 0, 0); + readback.Apply(); + RenderTexture.active = previous; + var actual = readback.GetPixel(0, 0).r; + UnityEngine.Object.DestroyImmediate(readback); + Assert.That(actual, Is.EqualTo(0.5f / Mathf.Pow(2f, 0.3f)).Within(0.001f)); + } + finally + { + exposure.Release(); + UnityEngine.Object.DestroyImmediate(exposure); + UnityEngine.Object.DestroyImmediate(source); + } + } + [Test] public void RasterizerConsumesPluginOwnedDocumentContract() { diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index c716cd1..fa24946 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -1159,7 +1159,16 @@ namespace GameCult.Eve.UnityScene.Tests temporalQuality: "high", temporalHistoryBlend: "0.99", temporalJitterScale: "0.1", - temporalSharpening: "0"), + temporalSharpening: "0", + exposureMode: "histogram.v1", + exposureLowPercent: "47.37294", + exposureHighPercent: "99", + exposureMinimumEv: "-3", + exposureMaximumEv: "0.3", + exposureKeyValue: "0.5", + exposureAdaptation: "progressive", + exposureSpeedUp: "2", + exposureSpeedDown: "1"), Advertisement("aetheria.daemon.game"), "cultmesh://aetheria/eve/surfaces/aetheria.daemon.game", 1), @@ -1172,6 +1181,17 @@ namespace GameCult.Eve.UnityScene.Tests host.Configure(rootObject.transform, provider, provider, provider, provider, nativeAssets); host.Connect(); hostObject.SetActive(true); + var exposedWorld = host.ActiveProjection?.PlayableWorld; + Assert.That(exposedWorld, Is.Not.Null); + Assert.That(exposedWorld!.ExposureMode, Is.EqualTo("histogram.v1")); + Assert.That(exposedWorld.ExposureLowPercent, Is.EqualTo(47.37294f).Within(0.00001f)); + Assert.That(exposedWorld.ExposureHighPercent, Is.EqualTo(99f)); + Assert.That(exposedWorld.ExposureMinimumEv, Is.EqualTo(-3f)); + Assert.That(exposedWorld.ExposureMaximumEv, Is.EqualTo(0.3f).Within(0.00001f)); + Assert.That(exposedWorld.ExposureKeyValue, Is.EqualTo(0.5f)); + Assert.That(exposedWorld.ExposureAdaptation, Is.EqualTo("progressive")); + Assert.That(exposedWorld.ExposureSpeedUp, Is.EqualTo(2f)); + Assert.That(exposedWorld.ExposureSpeedDown, Is.EqualTo(1f)); var rig = hostObject.AddComponent(); rig.Host = host; @@ -1217,6 +1237,9 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(cameraData.taaSettings.baseBlendFactor, Is.EqualTo(0.99f).Within(0.0001f)); Assert.That(cameraData.taaSettings.jitterScale, Is.EqualTo(0.1f).Within(0.0001f)); Assert.That(cameraData.taaSettings.contrastAdaptiveSharpening, Is.Zero); + var adaptiveExposure = camera.GetComponent(); + Assert.That(adaptiveExposure, Is.Not.Null); + Assert.That(adaptiveExposure.IsConfigured, Is.True); var postProcessTransform = hostObject.transform.Find("Eve World Post Process"); Assert.That(postProcessTransform, Is.Not.Null); Assert.That(postProcessTransform.GetComponent().sharedProfile, Is.SameAs(postProcessProfile)); @@ -1910,7 +1933,16 @@ namespace GameCult.Eve.UnityScene.Tests string temporalQuality = "high", string temporalHistoryBlend = "0", string temporalJitterScale = "0", - string temporalSharpening = "0") + string temporalSharpening = "0", + string exposureMode = "", + string exposureLowPercent = "50", + string exposureHighPercent = "95", + string exposureMinimumEv = "0", + string exposureMaximumEv = "0", + string exposureKeyValue = "1", + string exposureAdaptation = "progressive", + string exposureSpeedUp = "2", + string exposureSpeedDown = "1") { var playableChildren = new List { @@ -2083,6 +2115,15 @@ namespace GameCult.Eve.UnityScene.Tests ["temporalHistoryBlend"] = temporalHistoryBlend, ["temporalJitterScale"] = temporalJitterScale, ["temporalSharpening"] = temporalSharpening, + ["exposureMode"] = exposureMode, + ["exposureLowPercent"] = exposureLowPercent, + ["exposureHighPercent"] = exposureHighPercent, + ["exposureMinimumEv"] = exposureMinimumEv, + ["exposureMaximumEv"] = exposureMaximumEv, + ["exposureKeyValue"] = exposureKeyValue, + ["exposureAdaptation"] = exposureAdaptation, + ["exposureSpeedUp"] = exposureSpeedUp, + ["exposureSpeedDown"] = exposureSpeedDown, ["keyLightDirection"] = "0.4,-1,0.25", ["keyLightColor"] = "1,0.95,0.9", ["keyLightIntensity"] = "0.75", diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index d3c13c8..20f654f 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.58", + "version": "0.3.59", "unity": "6000.0", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": {