diff --git a/Editor/UrpUpscalerBuildResources.cs b/Editor/UrpUpscalerBuildResources.cs
new file mode 100644
index 0000000..f3f4ab1
--- /dev/null
+++ b/Editor/UrpUpscalerBuildResources.cs
@@ -0,0 +1,64 @@
+using System.Collections.Generic;
+using UnityEditor;
+using UnityEditor.Build;
+using UnityEditor.Build.Reporting;
+using UnityEditor.Rendering;
+using UnityEngine;
+using UnityEngine.Rendering;
+using UnityEngine.Rendering.Universal;
+
+namespace VisualPinball.Unity.Urp
+{
+ /// Keeps STP's runtime resources when users can select it after the build.
+ public sealed class UrpUpscalerBuildResources : IPreprocessBuildWithReport, IPostprocessBuildWithReport
+ {
+ // After CorePreprocessBuild (+50), before URPPreprocessBuild (+100) snapshots the list.
+ public int callbackOrder => int.MinValue + 75;
+ private static UniversalRenderPipelineAsset _retentionAsset;
+ private static List _buildAssets;
+
+ public void OnPreprocessBuild(BuildReport report)
+ {
+ Cleanup();
+ var assets = CoreBuildData.instance.renderPipelineAssets;
+ UniversalRenderPipelineAsset source = null;
+ foreach (var asset in assets) {
+ if (asset is UniversalRenderPipelineAsset urp) {
+ if (urp.isStpUsed) return;
+ source ??= urp;
+ }
+ }
+ if (source == null) return;
+ // Only the transient build-data list changes. Project assets and quality tiers stay untouched.
+ _retentionAsset = Object.Instantiate(source);
+ _retentionAsset.name = "URP runtime STP resource retention";
+ _retentionAsset.hideFlags = HideFlags.HideAndDontSave;
+ _retentionAsset.upscalingFilter = UpscalingFilterSelection.STP;
+#if ENABLE_UPSCALER_FRAMEWORK
+ _retentionAsset.upscalerName = "Spatial-Temporal Post-Processing";
+#endif
+ _buildAssets = assets;
+ assets.Add(_retentionAsset);
+ EditorApplication.update += CleanupAfterBuild;
+ AssemblyReloadEvents.beforeAssemblyReload += Cleanup;
+ }
+
+ public void OnPostprocessBuild(BuildReport report) => Cleanup();
+
+ private static void CleanupAfterBuild()
+ {
+ // Postprocess callbacks may be skipped on failure/cancellation.
+ if (!BuildPipeline.isBuildingPlayer) Cleanup();
+ }
+
+ private static void Cleanup()
+ {
+ EditorApplication.update -= CleanupAfterBuild;
+ AssemblyReloadEvents.beforeAssemblyReload -= Cleanup;
+ if (_buildAssets != null && _retentionAsset != null) _buildAssets.Remove(_retentionAsset);
+ _buildAssets = null;
+ if (_retentionAsset != null) Object.DestroyImmediate(_retentionAsset);
+ _retentionAsset = null;
+ }
+ }
+}
diff --git a/Editor/UrpUpscalerBuildResources.cs.meta b/Editor/UrpUpscalerBuildResources.cs.meta
new file mode 100644
index 0000000..d1085f6
--- /dev/null
+++ b/Editor/UrpUpscalerBuildResources.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 80b11aaff51b483ca88f7515167417e6
\ No newline at end of file
diff --git a/Editor/VisualPinball.Engine.Unity.Urp.Editor.asmdef b/Editor/VisualPinball.Engine.Unity.Urp.Editor.asmdef
index 603ada1..fa13283 100644
--- a/Editor/VisualPinball.Engine.Unity.Urp.Editor.asmdef
+++ b/Editor/VisualPinball.Engine.Unity.Urp.Editor.asmdef
@@ -8,9 +8,12 @@
"VisualPinball.Unity.Editor",
"VisualPinball.Engine.Unity.Urp",
"Unity.RenderPipelines.Core.Runtime",
- "Unity.RenderPipelines.Universal.Runtime"
+ "Unity.RenderPipelines.Universal.Runtime",
+ "Unity.RenderPipelines.Core.Editor"
+ ],
+ "includePlatforms": [
+ "Editor"
],
- "includePlatforms": [ "Editor" ],
"excludePlatforms": [],
"allowUnsafeCode": true,
"overrideReferences": false,
diff --git a/Runtime/UrpGraphicsApplier.cs b/Runtime/UrpGraphicsApplier.cs
index d37295b..3046179 100644
--- a/Runtime/UrpGraphicsApplier.cs
+++ b/Runtime/UrpGraphicsApplier.cs
@@ -25,22 +25,183 @@ namespace VisualPinball.Unity.Urp
{
///
/// Applies the host app's to URP at runtime: the game camera's
- /// anti-aliasing and the global Volume's bloom. The other effects are URP renderer features, which
- /// the renderer assets configure.
+ /// anti-aliasing, global Volume bloom and the active pipeline's SSR/SSAO renderer features.
///
public sealed class UrpGraphicsApplier : IVpeGraphicsApplier
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
- private static void Register() => VpeGraphics.Register(new UrpGraphicsApplier());
+ private static void Register()
+ {
+ RestoreUpscalerState();
+ _upscaler = VpeUpscaler.Off;
+ _antiAliasing = -1;
+ VpeGraphics.Register(new UrpGraphicsApplier());
+ VpeGraphics.UpscalerSupport = IsUpscalerSupported;
+ VpeGraphics.RenderScaleSource = () => GraphicsSettings.currentRenderPipeline is UniversalRenderPipelineAsset p ? p.renderScale : 1f;
+#if UNITY_EDITOR
+ UnityEditor.EditorApplication.playModeStateChanged -= OnEditorPlayModeChanged;
+ UnityEditor.EditorApplication.playModeStateChanged += OnEditorPlayModeChanged;
+#endif
+ }
+
+#if UNITY_EDITOR
+ private static readonly System.Collections.Generic.Dictionary OriginalFeatureStates = new();
+
+ private static void OnEditorPlayModeChanged(UnityEditor.PlayModeStateChange state)
+ {
+ if (state != UnityEditor.PlayModeStateChange.ExitingPlayMode) return;
+ foreach (var item in OriginalFeatureStates) {
+ if (item.Key != null) item.Key.SetActive(item.Value);
+ }
+ OriginalFeatureStates.Clear();
+ RestoreUpscalerState();
+ }
+#endif
// the user's choice (-1 = none yet, the camera keeps its own)
private static int _antiAliasing = -1;
+ private static VpeUpscaler _upscaler;
+ private static int _upscalerQuality;
+
+ private struct PipelineState
+ {
+ public float Scale;
+ public int Msaa;
+ public UpscalingFilterSelection Filter;
+#if ENABLE_UPSCALER_FRAMEWORK
+ public string Name;
+#endif
+ }
+ private struct CameraState { public bool PostProcessing, DynamicResolution; public VpeUpscaler LastUpscaler; public float LastScale; }
+ private static readonly System.Collections.Generic.Dictionary PipelineStates = new();
+ private static readonly System.Collections.Generic.Dictionary CameraStates = new();
+
+ private static bool IsUpscalerSupported(VpeUpscaler upscaler) => upscaler switch {
+ VpeUpscaler.Off => true,
+ VpeUpscaler.Fsr1 => FSRUtils.IsSupported(),
+ VpeUpscaler.Stp => STP.IsSupported(),
+ _ => false,
+ };
+
+ // Ratios are absolute output-resolution fractions, not multiplied by the preset's render scale.
+ private static float UpscalerScale(int quality) => Mathf.Clamp(quality, 0, 3) switch {
+ 0 => 1f / 1.5f,
+ 1 => 1f / 1.7f,
+ 2 => 0.5f,
+ _ => 1f / 3f,
+ };
+
+ private static void RestoreUpscalerState()
+ {
+ foreach (var item in PipelineStates) {
+ if (item.Key == null) continue;
+ RestorePipeline(item.Key, item.Value);
+ }
+ PipelineStates.Clear();
+ foreach (var item in CameraStates) {
+ if (item.Key == null) continue;
+ item.Key.allowDynamicResolution = item.Value.DynamicResolution;
+ if (item.Key.TryGetComponent(out var data)) data.renderPostProcessing = item.Value.PostProcessing;
+ }
+ CameraStates.Clear();
+ }
+
+ private static void RestorePipeline(UniversalRenderPipelineAsset pipeline, PipelineState original)
+ {
+ pipeline.renderScale = original.Scale;
+ pipeline.msaaSampleCount = original.Msaa;
+ pipeline.upscalingFilter = original.Filter;
+#if ENABLE_UPSCALER_FRAMEWORK
+ pipeline.upscalerName = original.Name;
+#endif
+ }
+
+ private static VpeUpscaler ApplyUpscaler(Camera camera, UniversalAdditionalCameraData data)
+ {
+ if (GraphicsSettings.currentRenderPipeline is not UniversalRenderPipelineAsset pipeline) return VpeUpscaler.Off;
+ if (!PipelineStates.TryGetValue(pipeline, out var original)) {
+ original = new PipelineState { Scale = pipeline.renderScale, Msaa = pipeline.msaaSampleCount, Filter = pipeline.upscalingFilter };
+#if ENABLE_UPSCALER_FRAMEWORK
+ original.Name = pipeline.upscalerName;
+#endif
+ PipelineStates.Add(pipeline, original);
+ }
+ if (!CameraStates.TryGetValue(camera, out var cameraOriginal)) {
+ cameraOriginal = new CameraState { PostProcessing = data.renderPostProcessing, DynamicResolution = camera.allowDynamicResolution, LastScale = original.Scale };
+ CameraStates.Add(camera, cameraOriginal);
+ }
+ var effective = IsUpscalerSupported(_upscaler) ? _upscaler : VpeUpscaler.Off;
+ // STP shares URP TAA's restriction against camera stacks and overlay cameras.
+ if (effective == VpeUpscaler.Stp && (data.renderType == CameraRenderType.Overlay || data.cameraStack?.Count > 0 || CabinetProjection.EffectiveAntiAliasing(camera, VpeAntiAliasing.Taa) != VpeAntiAliasing.Taa)) effective = VpeUpscaler.Off;
+ if (effective == VpeUpscaler.Off) {
+ RestorePipeline(pipeline, original);
+ if (cameraOriginal.LastUpscaler != VpeUpscaler.Off || !Mathf.Approximately(cameraOriginal.LastScale, original.Scale)) data.resetHistory = true;
+ cameraOriginal.LastUpscaler = VpeUpscaler.Off;
+ cameraOriginal.LastScale = original.Scale;
+ CameraStates[camera] = cameraOriginal;
+ data.renderPostProcessing = cameraOriginal.PostProcessing;
+ camera.allowDynamicResolution = cameraOriginal.DynamicResolution;
+ return effective;
+ }
+ pipeline.renderScale = UpscalerScale(_upscalerQuality);
+ pipeline.msaaSampleCount = effective == VpeUpscaler.Stp ? 1 : original.Msaa;
+ pipeline.upscalingFilter = effective == VpeUpscaler.Stp ? UpscalingFilterSelection.STP : UpscalingFilterSelection.FSR;
+#if ENABLE_UPSCALER_FRAMEWORK
+ pipeline.upscalerName = effective == VpeUpscaler.Stp ? "Spatial-Temporal Post-Processing" : "FidelityFX Super Resolution 1.0";
+#endif
+ data.renderPostProcessing = true;
+ camera.allowDynamicResolution = false;
+ if (cameraOriginal.LastUpscaler != effective || !Mathf.Approximately(cameraOriginal.LastScale, pipeline.renderScale)) data.resetHistory = true;
+ cameraOriginal.LastUpscaler = effective;
+ cameraOriginal.LastScale = pipeline.renderScale;
+ CameraStates[camera] = cameraOriginal;
+ return effective;
+ }
public void Apply(VpeGraphicsSettings settings)
{
_antiAliasing = Mathf.Clamp(settings.AntiAliasing, 0, 3);
+ _upscaler = (VpeUpscaler)settings.Upscaler;
+ _upscalerQuality = Mathf.Clamp(settings.UpscalerQuality, 0, 3);
ApplyCameraAntialiasing(Camera.main);
ApplyVolume(settings);
+ ApplyRendererFeatures(settings);
+ }
+
+ private static void ApplyRendererFeatures(VpeGraphicsSettings settings)
+ {
+ if (GraphicsSettings.currentRenderPipeline is not UniversalRenderPipelineAsset pipeline) {
+ return;
+ }
+ foreach (var renderer in pipeline.rendererDataList) {
+ if (renderer == null) {
+ continue;
+ }
+ foreach (var feature in renderer.rendererFeatures) {
+ if (feature == null) {
+ continue;
+ }
+ // SSR is an optional third-party assembly; match its type rather than its editable asset name.
+ switch (feature.GetType().FullName) {
+ case "UniversalScreenSpaceReflection.ScreenSpaceReflection":
+ SetFeatureActive(feature, settings.ScreenSpaceReflections);
+ break;
+ case "UnityEngine.Rendering.Universal.ScreenSpaceAmbientOcclusion":
+ SetFeatureActive(feature, settings.AmbientOcclusion);
+ break;
+ }
+ }
+ }
+ }
+
+ private static void SetFeatureActive(ScriptableRendererFeature feature, bool enabled)
+ {
+#if UNITY_EDITOR
+ if (Application.isPlaying && !OriginalFeatureStates.ContainsKey(feature)) {
+ OriginalFeatureStates.Add(feature, feature.isActive);
+ }
+#endif
+ feature.SetActive(enabled);
}
/// The chosen anti-aliasing, or what the camera's cabinet projection allows instead.
@@ -49,8 +210,10 @@ internal static void ApplyCameraAntialiasing(Camera camera)
if (camera == null || !camera.TryGetComponent(out var data)) {
return;
}
+ var upscaler = ApplyUpscaler(camera, data);
if (_antiAliasing >= 0) {
- var aa = CabinetProjection.EffectiveAntiAliasing(camera, (VpeAntiAliasing)_antiAliasing);
+ var requested = upscaler == VpeUpscaler.Stp ? VpeAntiAliasing.Taa : (VpeAntiAliasing)_antiAliasing;
+ var aa = CabinetProjection.EffectiveAntiAliasing(camera, requested);
data.antialiasing = aa switch {
VpeAntiAliasing.Fxaa => AntialiasingMode.FastApproximateAntialiasing,
VpeAntiAliasing.Smaa => AntialiasingMode.SubpixelMorphologicalAntiAliasing,