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..bdeeb03ee0 100644 --- a/VisualPinball.Plugins/Runtime/PluginHost.cs +++ b/VisualPinball.Plugins/Runtime/PluginHost.cs @@ -151,6 +151,47 @@ internal void Track(Task hook) /// public IReadOnlyCollection Factories => _factories.Values; /// + /// 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: + // 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; + } + 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; + } + /// /// 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. 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) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs b/VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs index c3774dc8a4..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); @@ -40,6 +57,10 @@ 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; + public int Upscaler; // VpeUpscaler + public int UpscalerQuality; // VpeUpscalerQuality } /// Implemented by the active render pipeline (e.g. HDRP) to apply . @@ -62,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; } }