From 34fa194fa7b2a79e97422c96e3dbf86a328037ce Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 18 Oct 2026 00:16:20 +0200 Subject: [PATCH 1/5] rendering: Add the recursive ray tracing setting to the graphics settings. --- .../VisualPinball.Unity/Rendering/VpeGraphics.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs b/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs index c3774dc8a4..d1499ba9da 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs @@ -40,6 +40,8 @@ public struct VpeGraphicsSettings public bool Bloom; public bool RayTracedGlobalIllumination; public bool RayTracedReflections; + // Experimental: clear refractive materials (ramps, transparent plastics) are ray traced recursively. + public bool RayTracedRecursiveRendering; } /// Implemented by the active render pipeline (e.g. HDRP) to apply . From d2105053ecf81246f6ae42cb851e7aee8b744ce6 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 18 Oct 2026 16:33:41 +0200 Subject: [PATCH 2/5] graphics: add upscaler settings and render scale to VpeGraphics Plain-data DLSS / FSR 2 choice and quality for the pipeline applier, plus hooks for the pipeline to report which upscalers run on this machine and the current render scale. --- .../Rendering/VpeGraphics.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs b/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs index d1499ba9da..9543e935b8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs @@ -25,6 +25,23 @@ public enum VpeAntiAliasing Taa = 3, } + /// Temporal upscaler the camera renders through, at a lower internal resolution. + public enum VpeUpscaler + { + Off = 0, + Dlss = 1, // NVIDIA Deep Learning Super Sampling + Fsr2 = 2, // AMD FidelityFX Super Resolution 2 + } + + /// How far the upscaler lowers the internal resolution (each upscaler picks its own scale per mode). + public enum VpeUpscalerQuality + { + Quality = 0, + Balanced = 1, + Performance = 2, + UltraPerformance = 3, + } + /// /// Plain-data render-quality settings that are tunable at runtime via the active render pipeline's /// . Carries only the SRP-specific knobs (camera AA + Volume effects); @@ -42,6 +59,8 @@ public struct VpeGraphicsSettings public bool RayTracedReflections; // Experimental: clear refractive materials (ramps, transparent plastics) are ray traced recursively. public bool RayTracedRecursiveRendering; + public int Upscaler; // VpeUpscaler + public int UpscalerQuality; // VpeUpscalerQuality } /// Implemented by the active render pipeline (e.g. HDRP) to apply . @@ -64,5 +83,19 @@ public static class VpeGraphics public static void Register(IVpeGraphicsApplier applier) => _applier = applier; public static void Apply(VpeGraphicsSettings settings) => _applier?.Apply(settings); + + /// + /// Whether an upscaler runs on this machine, as the active pipeline reports it (it registers the check + /// next to its applier). Off is always supported. + /// + public static bool IsUpscalerSupported(VpeUpscaler upscaler) + => upscaler == VpeUpscaler.Off || (UpscalerSupport != null && UpscalerSupport(upscaler)); + + public static System.Func UpscalerSupport; + + /// The share of the output resolution the camera renders at (1 without upscaling), as the pipeline reports it. + public static float RenderScale => RenderScaleSource?.Invoke() ?? 1f; + + public static System.Func RenderScaleSource; } } From 5f942c7ee2aaa601b3f520619737dd90bd31895d Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 18 Oct 2026 17:57:33 +0200 Subject: [PATCH 3/5] camera: field of view on Ctrl+wheel, SetPose for the orbit camera The orbit camera changes its field of view with Ctrl+scroll and can be put at a pose from outside, orbiting on from there. CameraLens reads and sets the vertical field of view a camera really shows, for physical cameras whose gate fit fits the sensor's width too, on the logical (turned) picture. --- .../VisualPinball.Unity/Game/CameraLens.cs | 76 +++++++++++++++++++ .../Game/CameraLens.cs.meta | 2 + .../Game/CameraTranslateAndOrbit.cs | 41 +++++++++- 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs new file mode 100644 index 0000000000..9c57d417d2 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs @@ -0,0 +1,76 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// https://github.com/freezy/VisualPinball.Engine +// +// SPDX-License-Identifier: GPL-3.0-or-later + +using Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity +{ + /// + /// The vertical field of view a camera shows on screen, for plain and physical cameras alike. A physical + /// camera's comes from the sensor's height only, while its gate fit can + /// fit the sensor's width to the screen instead; this reads and sets what the picture really spans. + /// + /// + /// + /// Works on the logical picture: for an output turned by , its aspect is the + /// turned one, and the camera is the unturned one the scripts see between frames. + /// + public static class CameraLens + { + public const float MinVerticalFov = 3f; + public const float MaxVerticalFov = 100f; + + /// Width over height of the picture the camera renders, turned when the output is. + public static float LogicalAspect(Camera camera) + { + var size = DisplayRotation.LogicalSize(camera.pixelWidth, camera.pixelHeight); + return size.y > 0 ? size.x / (float)size.y : 1f; + } + + /// The vertical field of view in degrees. + public static float GetVerticalFov(Camera camera) + { + if (!camera.usePhysicalProperties) { + return camera.fieldOfView; + } + var sensor = camera.sensorSize; + var focal = math.max(camera.focalLength, 0.01f); + var tanHalf = FitsWidth(camera, sensor) + ? sensor.x * 0.5f / focal / LogicalAspect(camera) + : sensor.y * 0.5f / focal; + return 2f * math.degrees(math.atan(tanHalf)); + } + + /// Sets the vertical field of view in degrees; a physical camera changes its focal length. + public static void SetVerticalFov(Camera camera, float degrees) + { + degrees = math.clamp(degrees, MinVerticalFov, MaxVerticalFov); + if (!camera.usePhysicalProperties) { + camera.fieldOfView = degrees; + return; + } + var sensor = camera.sensorSize; + var tanHalf = math.tan(math.radians(degrees) * 0.5f); + camera.focalLength = FitsWidth(camera, sensor) + ? sensor.x * 0.5f / (tanHalf * LogicalAspect(camera)) + : sensor.y * 0.5f / tanHalf; + } + + // Whether the sensor's width spans the picture's width (else its height spans the picture's height). + private static bool FitsWidth(Camera camera, Vector2 sensor) + { + var sensorAspect = sensor.y > 0f ? sensor.x / sensor.y : 1f; + var aspect = LogicalAspect(camera); + return camera.gateFit switch { + Camera.GateFitMode.Horizontal => true, + Camera.GateFitMode.Fill => aspect > sensorAspect, + Camera.GateFitMode.Overscan => aspect < sensorAspect, + _ => false, + }; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs.meta new file mode 100644 index 0000000000..7bd688f423 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bc376ac052894de0ab6d6a5fcf9f92b9 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CameraTranslateAndOrbit.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CameraTranslateAndOrbit.cs index 5b138f69c7..9dde5d66c6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/CameraTranslateAndOrbit.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CameraTranslateAndOrbit.cs @@ -70,11 +70,20 @@ public class CameraTranslateAndOrbit : MonoBehaviour private Keyboard _keyboard; private Mouse _mouse; private PhysicsEngine _physicsEngine; + private Camera _camera; /* -------------------------------------------------------- */ private void Start() { + EnsureInitialized(); + } + + private void EnsureInitialized() + { + if (_transformCache != null) { + return; + } // same logic you had before to center on renderer bounds, etc. if (initialOrbit != null) { var pfr = initialOrbit.GetComponent(); @@ -99,6 +108,29 @@ private void Start() } _keyboard = Keyboard.current; _mouse = Mouse.current; + _camera = GetComponent(); + } + + /// + /// Puts the camera at a pose right away; from then on, it orbits around the point + /// ahead of it. Lets go of a locked ball. + /// + public void SetPose(Vector3 position, Quaternion rotation, float pivotDistance) + { + EnsureInitialized(); + lockTarget = null; + _isTrackingBall = false; + _isTrackingBallTarget = false; + _isTrackingMouse0 = false; + _isTrackingMouse1 = false; + + _radius = _radiusCurrent = Mathf.Clamp(pivotDistance, RadiusMin, RadiusMax); + _radiusMax = Mathf.Max(RadiusMax, _radius); + positionOffset = _positionOffsetCurrent = position + rotation * Vector3.forward * _radius; + _rot2 = rotation; + _focusPoint = rotation * Vector3.forward * -_radius; + _transformCache.SetPositionAndRotation(position, rotation); + _dummyTransform.SetPositionAndRotation(position, rotation); } private void Update() @@ -287,11 +319,16 @@ private void UpdateMouse() } } - /* ========== ZOOM (scroll) ========== */ + /* ========== ZOOM (scroll), FIELD OF VIEW (ctrl + scroll) ========== */ if (!isAnimating && !hasHitRestrictedHitArea) { float deltaScroll = _mouse.scroll.y.ReadValue() / 300f * zoomSpeed; + if (_keyboard != null && _keyboard.ctrlKey.isPressed) { + if (deltaScroll != 0f && _camera != null) { + CameraLens.SetVerticalFov(_camera, CameraLens.GetVerticalFov(_camera) * (1f - deltaScroll)); + } + } /* --------------- UpdateMouse() – ZOOM in lock --------------- */ - if (lockTarget != null && _isTrackingBall) { // ★ only follow-lock zooms + else if (lockTarget != null && _isTrackingBall) { // ★ only follow-lock zooms float r = Mathf.Clamp(_ballFollowOffsetTarget.magnitude * (1f - deltaScroll), RadiusMin, _radiusMax); _ballFollowOffsetTarget = _ballFollowOffsetTarget.normalized * r; } else if (lockTarget == null) { From fac826a6b3a1f95eff04c1dd38321975abe0acf1 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 18 Oct 2026 19:25:55 +0200 Subject: [PATCH 4/5] plugins: status lines for the Player's pause menu IPluginStatusSource lets a running plugin say in a few plain lines what it does in the game, like the colorization or the PUP pack it loaded. PluginHost.DescribeStatus collects them, with the plugins that should run but don't and why; a plugin that throws while describing itself is logged, not faulted. --- .../Api/PluginStatusSource.cs | 65 +++++++++++++++++++ .../Api/PluginStatusSource.cs.meta | 11 ++++ VisualPinball.Plugins/Runtime/PluginHost.cs | 39 +++++++++++ 3 files changed, 115 insertions(+) create mode 100644 VisualPinball.Plugins/Api/PluginStatusSource.cs create mode 100644 VisualPinball.Plugins/Api/PluginStatusSource.cs.meta diff --git a/VisualPinball.Plugins/Api/PluginStatusSource.cs b/VisualPinball.Plugins/Api/PluginStatusSource.cs new file mode 100644 index 0000000000..b6641dcd4d --- /dev/null +++ b/VisualPinball.Plugins/Api/PluginStatusSource.cs @@ -0,0 +1,65 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System.Collections.Generic; + +namespace VisualPinball.Plugins +{ + /// + /// A running plugin that tells the player what it does in the current game, in a few short lines the + /// Player shows in its pause menu: the DMD plugin which colorization it loaded, PinUP which pack, B2S + /// which backglass. + /// + /// + /// Unlike , which is for developers, this is for the player at the table: + /// plain words, file names rather than paths, nothing about internals. A plugin that writes nothing has + /// nothing to do in this game (PinMAME on a table without a ROM), and the pause menu leaves it out; a + /// running plugin that doesn't implement this is listed with its name only. + /// + public interface IPluginStatusSource + { + /// + /// Writes the lines, such as Colorization → Serum, t2_l8.cRZ. Called on the owner + /// thread when the pause menu opens, so keep it cheap: no file reads. + /// + void DescribeStatus(PluginStatusWriter writer); + } + + /// Collects the label/value lines of a plugin's status, in order. + public sealed class PluginStatusWriter + { + private readonly List> _lines = new(); + + /// The lines written so far. + public IReadOnlyList> Lines => _lines; + + public PluginStatusWriter Add(string label, string value) + { + _lines.Add(new KeyValuePair(label ?? "", value ?? "")); + return this; + } + + public PluginStatusWriter Add(string label, bool on) => Add(label, on ? "on" : "off"); + } + + /// One plugin as the pause menu shows it: what it is, its state, and its status lines. + public sealed class PluginStatusReport + { + public PluginDescriptor Descriptor { get; } + public PluginState State { get; } + /// Why the plugin isn't running, for a blocked or faulted one; empty otherwise. + public string Message { get; } + public IReadOnlyList> Lines { get; } + + public PluginStatusReport(PluginDescriptor descriptor, PluginState state, string message, IReadOnlyList> lines) + { + Descriptor = descriptor; + State = state; + Message = message ?? ""; + Lines = lines; + } + } +} diff --git a/VisualPinball.Plugins/Api/PluginStatusSource.cs.meta b/VisualPinball.Plugins/Api/PluginStatusSource.cs.meta new file mode 100644 index 0000000000..8a171a502f --- /dev/null +++ b/VisualPinball.Plugins/Api/PluginStatusSource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4bc4abe8152b4f07a8838fe2fb1fab89 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Plugins/Runtime/PluginHost.cs b/VisualPinball.Plugins/Runtime/PluginHost.cs index e55cc57d5a..81171ffdf3 100644 --- a/VisualPinball.Plugins/Runtime/PluginHost.cs +++ b/VisualPinball.Plugins/Runtime/PluginHost.cs @@ -155,6 +155,45 @@ internal void Track(Task hook) /// or the next start. It is also true when some or all plugins failed to start; check /// for individual plugins. /// + /// + /// The plugins of the current game as the Player's pause menu shows them: the running ones with what they + /// write as , and the ones that should run but don't (starting, blocked, + /// faulted, unresponsive) with why. Disabled and unsupported plugins are left out, and so is a running one + /// whose status says nothing, since it has nothing to do in this game. + /// + /// Call on the owner thread. A plugin that throws while describing itself is logged, not faulted. + public IReadOnlyList DescribeStatus() + { + Scheduler.VerifyOwner(); + var reports = new List(); + foreach (var factory in _factories.Values) { + var status = _statuses[factory.Descriptor.Id]; + switch (status.State) { + case PluginState.Running: + var instance = _startOrder.FirstOrDefault(entry => entry.Factory == factory); + if (instance?.Plugin is not IPluginStatusSource source) { + reports.Add(new PluginStatusReport(factory.Descriptor, status.State, "", Array.Empty>())); + break; + } + var writer = new PluginStatusWriter(); + try { + source.DescribeStatus(writer); + } catch (Exception error) { + _error(factory.Descriptor.Id + " could not describe its status", error); + writer.Add("Status", "unavailable"); + } + if (writer.Lines.Count > 0) reports.Add(new PluginStatusReport(factory.Descriptor, status.State, "", writer.Lines)); + break; + case PluginState.Starting: + case PluginState.Blocked: + case PluginState.Faulted: + case PluginState.Unresponsive: + reports.Add(new PluginStatusReport(factory.Descriptor, status.State, status.Message, Array.Empty>())); + break; + } + } + return reports; + } public bool IsRunning => _running; /// /// True while a lifecycle operation holds the host's gate: starting or stopping a game, committing settings, or cleaning up From a240c8a9e090d3cefb112e93472b54f36937ed94 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 18 Oct 2026 20:36:29 +0200 Subject: [PATCH 5/5] plugins: Skip disposed plugins in the pause menu status After an emergency stop the plugins are disposed but keep their Running state; DescribeStatus no longer calls them. Also puts IsRunning's documentation back on it. --- VisualPinball.Plugins/Runtime/PluginHost.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/VisualPinball.Plugins/Runtime/PluginHost.cs b/VisualPinball.Plugins/Runtime/PluginHost.cs index 81171ffdf3..bdeeb03ee0 100644 --- a/VisualPinball.Plugins/Runtime/PluginHost.cs +++ b/VisualPinball.Plugins/Runtime/PluginHost.cs @@ -151,11 +151,6 @@ internal void Track(Task hook) /// public IReadOnlyCollection Factories => _factories.Values; /// - /// True from the end of until the game is stopped by , - /// or the next start. It is also true when some or all plugins failed to start; check - /// for individual plugins. - /// - /// /// The plugins of the current game as the Player's pause menu shows them: the running ones with what they /// write as , and the ones that should run but don't (starting, blocked, /// faulted, unresponsive) with why. Disabled and unsupported plugins are left out, and so is a running one @@ -170,8 +165,10 @@ public IReadOnlyList DescribeStatus() var status = _statuses[factory.Descriptor.Id]; switch (status.State) { case PluginState.Running: - var instance = _startOrder.FirstOrDefault(entry => entry.Factory == factory); - if (instance?.Plugin is not IPluginStatusSource source) { + // an emergency stop disposes the plugins and leaves their state alone: they have nothing to say + var instance = _startOrder.FirstOrDefault(entry => entry.Factory == factory && !entry.Cleaned); + if (instance == null) break; + if (instance.Plugin is not IPluginStatusSource source) { reports.Add(new PluginStatusReport(factory.Descriptor, status.State, "", Array.Empty>())); break; } @@ -194,6 +191,11 @@ public IReadOnlyList DescribeStatus() } return reports; } + /// + /// True from the end of until the game is stopped by , + /// or the next start. It is also true when some or all plugins failed to start; check + /// for individual plugins. + /// public bool IsRunning => _running; /// /// True while a lifecycle operation holds the host's gate: starting or stopping a game, committing settings, or cleaning up