Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions VisualPinball.Plugins/Api/PluginStatusSource.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Unlike <see cref="IDiagnosticsSource"/>, 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.
/// </remarks>
public interface IPluginStatusSource
{
/// <summary>
/// Writes the lines, such as <c>Colorization</c> → <c>Serum, t2_l8.cRZ</c>. Called on the owner
/// thread when the pause menu opens, so keep it cheap: no file reads.
/// </summary>
void DescribeStatus(PluginStatusWriter writer);
}

/// <summary>Collects the label/value lines of a plugin's status, in order.</summary>
public sealed class PluginStatusWriter
{
private readonly List<KeyValuePair<string, string>> _lines = new();

/// <summary>The lines written so far.</summary>
public IReadOnlyList<KeyValuePair<string, string>> Lines => _lines;

public PluginStatusWriter Add(string label, string value)
{
_lines.Add(new KeyValuePair<string, string>(label ?? "", value ?? ""));
return this;
}

public PluginStatusWriter Add(string label, bool on) => Add(label, on ? "on" : "off");
}

/// <summary>One plugin as the pause menu shows it: what it is, its state, and its status lines.</summary>
public sealed class PluginStatusReport
{
public PluginDescriptor Descriptor { get; }
public PluginState State { get; }
/// <summary>Why the plugin isn't running, for a blocked or faulted one; empty otherwise.</summary>
public string Message { get; }
public IReadOnlyList<KeyValuePair<string, string>> Lines { get; }

public PluginStatusReport(PluginDescriptor descriptor, PluginState state, string message, IReadOnlyList<KeyValuePair<string, string>> lines)
{
Descriptor = descriptor;
State = state;
Message = message ?? "";
Lines = lines;
}
}
}
11 changes: 11 additions & 0 deletions VisualPinball.Plugins/Api/PluginStatusSource.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions VisualPinball.Plugins/Runtime/PluginHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,47 @@ internal void Track(Task hook)
/// </summary>
public IReadOnlyCollection<IPlayerPluginFactory> Factories => _factories.Values;
/// <summary>
/// The plugins of the current game as the Player's pause menu shows them: the running ones with what they
/// write as <see cref="IPluginStatusSource"/>, 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.
/// </summary>
/// <remarks>Call on the owner thread. A plugin that throws while describing itself is logged, not faulted.</remarks>
public IReadOnlyList<PluginStatusReport> DescribeStatus()
{
Scheduler.VerifyOwner();
var reports = new List<PluginStatusReport>();
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<KeyValuePair<string, string>>()));
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<KeyValuePair<string, string>>()));
break;
}
}
return reports;
}
/// <summary>
/// True from the end of <see cref="PrepareAndStartAsync"/> until the game is stopped by <see cref="StopGameAsync"/>,
/// <see cref="EmergencyStop"/> or the next start. It is also true when some or all plugins failed to start; check
/// <see cref="Statuses"/> for individual plugins.
Expand Down
76 changes: 76 additions & 0 deletions VisualPinball.Unity/VisualPinball.Unity/Game/CameraLens.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// The vertical field of view a camera shows on screen, for plain and physical cameras alike. A physical
/// camera's <see cref="Camera.fieldOfView"/> 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.
/// </summary>
///
/// <remarks>
/// Works on the logical picture: for an output turned by <see cref="DisplayRotation"/>, its aspect is the
/// turned one, and the camera is the unturned one the scripts see between frames.
/// </remarks>
public static class CameraLens
{
public const float MinVerticalFov = 3f;
public const float MaxVerticalFov = 100f;

/// <summary>Width over height of the picture the camera renders, turned when the output is.</summary>
public static float LogicalAspect(Camera camera)
{
var size = DisplayRotation.LogicalSize(camera.pixelWidth, camera.pixelHeight);
return size.y > 0 ? size.x / (float)size.y : 1f;
}

/// <summary>The vertical field of view in degrees.</summary>
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));
}

/// <summary>Sets the vertical field of view in degrees; a physical camera changes its focal length.</summary>
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,
};
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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<Renderer>();
Expand All @@ -99,6 +108,29 @@ private void Start()
}
_keyboard = Keyboard.current;
_mouse = Mouse.current;
_camera = GetComponent<Camera>();
}

/// <summary>
/// Puts the camera at a pose right away; from then on, it orbits around the point
/// <paramref name="pivotDistance"/> ahead of it. Lets go of a locked ball.
/// </summary>
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()
Expand Down Expand Up @@ -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) {
Expand Down
35 changes: 35 additions & 0 deletions VisualPinball.Unity/VisualPinball.Unity/Rendering/VpeGraphics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ public enum VpeAntiAliasing
Taa = 3,
}

/// <summary>Temporal upscaler the camera renders through, at a lower internal resolution.</summary>
public enum VpeUpscaler
{
Off = 0,
Dlss = 1, // NVIDIA Deep Learning Super Sampling
Fsr2 = 2, // AMD FidelityFX Super Resolution 2
}

/// <summary>How far the upscaler lowers the internal resolution (each upscaler picks its own scale per mode).</summary>
public enum VpeUpscalerQuality
{
Quality = 0,
Balanced = 1,
Performance = 2,
UltraPerformance = 3,
}

/// <summary>
/// Plain-data render-quality settings that are tunable at runtime via the active render pipeline's
/// <see cref="IVpeGraphicsApplier"/>. Carries only the SRP-specific knobs (camera AA + Volume effects);
Expand All @@ -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
}

/// <summary>Implemented by the active render pipeline (e.g. HDRP) to apply <see cref="VpeGraphicsSettings"/>.</summary>
Expand All @@ -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);

/// <summary>
/// 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.
/// </summary>
public static bool IsUpscalerSupported(VpeUpscaler upscaler)
=> upscaler == VpeUpscaler.Off || (UpscalerSupport != null && UpscalerSupport(upscaler));

public static System.Func<VpeUpscaler, bool> UpscalerSupport;

/// <summary>The share of the output resolution the camera renders at (1 without upscaling), as the pipeline reports it.</summary>
public static float RenderScale => RenderScaleSource?.Invoke() ?? 1f;

public static System.Func<float> RenderScaleSource;
}
}
Loading