diff --git a/Runtime/AutoFocus.compute b/Assets/Resources/VpeAutoFocus.compute
similarity index 100%
rename from Runtime/AutoFocus.compute
rename to Assets/Resources/VpeAutoFocus.compute
diff --git a/Runtime/AutoFocus.compute.meta b/Assets/Resources/VpeAutoFocus.compute.meta
similarity index 100%
rename from Runtime/AutoFocus.compute.meta
rename to Assets/Resources/VpeAutoFocus.compute.meta
diff --git a/Assets/Resources/VpeBallReflection.compute b/Assets/Resources/VpeBallReflection.compute
new file mode 100644
index 00000000..25a161ce
--- /dev/null
+++ b/Assets/Resources/VpeBallReflection.compute
@@ -0,0 +1,208 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// Fills a ball's reflection cubemap for BallReflectionsComponent. Every texel's direction is marched
+// from the ball's center through the depth buffer of the frame the camera just rendered, which finds
+// what stands on the playfield, like posts, plastics, flippers and ramps. A ray that hits nothing on
+// its way down takes the playfield where it meets its plane, and the lit color of every hit comes from
+// that frame. Everything looking up, or past the playfield's edge, gets the sky, like the ball had before.
+// HDRP stores probes and the sky without exposure, so the frame's color is divided by it.
+
+#pragma only_renderers d3d11 playstation xboxone xboxseries vulkan metal switch switch2
+
+#pragma kernel KFill
+
+#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
+#include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl"
+
+TEXTURE2D_X(_BallReflectionColor);
+// the six cube faces, in cube face order; a cubemap can't be written directly, so it's copied over afterwards
+RWTexture2DArray _BallReflectionFaces;
+
+// xyz: ball center in absolute world space, w: ball radius
+float4 _BallReflectionBall;
+// xyz: playfield normal, w: playfield plane distance along it
+float4 _BallReflectionPlane;
+// absolute world space to playfield space, where the playfield spans 0..1 in x and y
+float4x4 _BallReflectionWorldToPlayfield;
+// x: cube resolution, y: how far rays are marched, in world units
+float4 _BallReflectionParams;
+
+#define TRACE_STEPS 32
+#define TRACE_REFINE_STEPS 5
+// how far behind a surface the camera saw a ray may end up and still count as hitting it, relative to
+// how far it went
+#define TRACE_THICKNESS 0.1
+#define TRACE_MIN_THICKNESS 0.01
+// how many radii past the ball the playfield beside a hidden spot is looked for
+#define BESIDE_STEPS 8
+
+// Direction of a cube texel, in Unity's cubemap face order and orientation (+X, -X, +Y, -Y, +Z, -Z).
+float3 CubeTexelDirection(uint face, float2 st)
+{
+ switch (face) {
+ case 0: return float3(1, -st.y, -st.x);
+ case 1: return float3(-1, -st.y, st.x);
+ case 2: return float3(st.x, 1, st.y);
+ case 3: return float3(st.x, -1, -st.y);
+ case 4: return float3(st.x, -st.y, 1);
+ default: return float3(-st.x, -st.y, -1);
+ }
+}
+
+// The pixel showing a world position. Positions outside the screen get its nearest edge pixel, so a close
+// camera still reflects playfield colors where the playfield runs off screen. False if behind the camera.
+bool ToScreen(float3 positionWS, out uint2 positionSS)
+{
+ const float3 positionRWS = GetCameraRelativePositionWS(positionWS);
+ const float4 positionCS = mul(UNITY_MATRIX_VP, float4(positionRWS, 1));
+ positionSS = 0;
+ if (positionCS.w <= 0) {
+ return false;
+ }
+ const float2 positionNDC = ComputeNormalizedDeviceCoordinates(positionRWS, UNITY_MATRIX_VP);
+ positionSS = (uint2)clamp(positionNDC * _ScreenSize.xy, 0, _ScreenSize.xy - 1);
+ return true;
+}
+
+// Whether the opaque surface the camera sees at that pixel is the ball itself.
+bool IsBall(uint2 positionSS)
+{
+ const float deviceDepth = LoadCameraDepth(positionSS);
+ if (deviceDepth == UNITY_RAW_FAR_CLIP_VALUE) {
+ return false;
+ }
+ const float2 positionNDC = (positionSS + 0.5) * _ScreenSize.zw;
+ const float3 surfaceRWS = ComputeWorldSpacePosition(positionNDC, deviceDepth, UNITY_MATRIX_I_VP);
+ const float3 ballRWS = GetCameraRelativePositionWS(_BallReflectionBall.xyz);
+ return distance(surfaceRWS, ballRWS) < _BallReflectionBall.w * 1.15;
+}
+
+// How far the point at distance t along a ray from the ball is behind the surface the camera saw there,
+// negative if in front of it or if the camera saw nothing. False if the point is off screen.
+bool SampleRay(float3 center, float3 direction, float t, out float behind, out uint2 positionSS)
+{
+ behind = -1;
+ positionSS = 0;
+ const float3 positionRWS = GetCameraRelativePositionWS(center + direction * t);
+ const float4 positionCS = mul(UNITY_MATRIX_VP, float4(positionRWS, 1));
+ if (positionCS.w <= 0) {
+ return false;
+ }
+ const float2 positionNDC = ComputeNormalizedDeviceCoordinates(positionRWS, UNITY_MATRIX_VP);
+ if (any(positionNDC < 0) || any(positionNDC > 1)) {
+ return false;
+ }
+ positionSS = (uint2)min(positionNDC * _ScreenSize.xy, _ScreenSize.xy - 1);
+ const float deviceDepth = LoadCameraDepth(positionSS);
+ if (deviceDepth != UNITY_RAW_FAR_CLIP_VALUE) {
+ behind = positionCS.w - LinearEyeDepth(deviceDepth, _ZBufferParams);
+ }
+ return true;
+}
+
+// Marches a ray from the ball outwards, in steps that grow with the distance, and finds the first
+// surface it hits. False if it leaves the screen or reaches maxDistance first.
+bool TraceScreen(float3 center, float3 direction, float maxDistance, out uint2 hitSS)
+{
+ hitSS = 0;
+ float t = _BallReflectionBall.w * 1.1;
+ if (maxDistance <= t) {
+ return false;
+ }
+ const float growth = pow(abs(maxDistance / t), 1.0 / TRACE_STEPS);
+ float previous = t;
+ bool wasInFront = true;
+ for (uint i = 0; i < TRACE_STEPS; i++) {
+ t *= growth;
+ float behind;
+ uint2 positionSS;
+ if (!SampleRay(center, direction, t, behind, positionSS)) {
+ return false;
+ }
+ if (behind > 0 && wasInFront) {
+ // narrow down where the ray went behind: a surface it hit is right there, while one it only
+ // passed behind stays far in front of it
+ float near = previous;
+ float far = t;
+ for (uint k = 0; k < TRACE_REFINE_STEPS; k++) {
+ const float middle = (near + far) * 0.5;
+ float middleBehind;
+ uint2 middleSS;
+ SampleRay(center, direction, middle, middleBehind, middleSS);
+ if (middleBehind > 0) {
+ far = middle;
+ behind = middleBehind;
+ positionSS = middleSS;
+ } else {
+ near = middle;
+ }
+ }
+ if (behind < max(TRACE_MIN_THICKNESS, far * TRACE_THICKNESS) && !IsBall(positionSS)) {
+ hitSS = positionSS;
+ return true;
+ }
+ }
+ wasInFront = behind <= 0;
+ previous = t;
+ }
+ return false;
+}
+
+float3 FrameColor(uint2 positionSS)
+{
+ return LOAD_TEXTURE2D_X(_BallReflectionColor, positionSS).rgb * GetInverseCurrentExposureMultiplier();
+}
+
+[numthreads(8, 8, 1)]
+void KFill(uint3 id : SV_DispatchThreadID)
+{
+ const uint resolution = (uint)_BallReflectionParams.x;
+ if (any(id.xy >= resolution)) {
+ return;
+ }
+ const float2 st = (id.xy + 0.5) / resolution * 2 - 1;
+ const float3 direction = normalize(CubeTexelDirection(id.z, st));
+ const float3 normal = _BallReflectionPlane.xyz;
+ const float3 center = _BallReflectionBall.xyz;
+
+ float3 color = SampleSkyTexture(direction, 0, 0).rgb;
+ const float facing = dot(direction, normal);
+ const float planeDistance = facing < -1e-4 ? (_BallReflectionPlane.w - dot(normal, center)) / facing : _BallReflectionParams.y;
+ uint2 objectSS;
+ if (TraceScreen(center, direction, min(planeDistance * 0.98, _BallReflectionParams.y), objectSS)) {
+ color = FrameColor(objectSS);
+ } else if (facing < -1e-4) {
+ const float3 hitWS = center + direction * planeDistance;
+ const float2 playfield = mul(_BallReflectionWorldToPlayfield, float4(hitWS, 1)).xy;
+ if (all(playfield >= 0) && all(playfield <= 1)) {
+ uint2 positionSS;
+ if (ToScreen(hitWS, positionSS)) {
+ if (IsBall(positionSS)) {
+ // the spot is hidden behind the ball, so walk away from the contact point until the camera sees
+ // the playfield, which can take a few radii when it looks at the ball from low
+ const float3 contactWS = center - normal * (dot(normal, center) - _BallReflectionPlane.w);
+ const float3 away = (hitWS - contactWS) / max(length(hitWS - contactWS), 1e-5);
+ for (uint step = 0; step < BESIDE_STEPS && IsBall(positionSS); step++) {
+ ToScreen(contactWS + away * (_BallReflectionBall.w * (1.4 + step * 0.6)), positionSS);
+ }
+ }
+ color = FrameColor(positionSS);
+ }
+ }
+ }
+ _BallReflectionFaces[id] = float4(ClampToFloat16Max(color), 1);
+}
diff --git a/Assets/Resources/VpeBallReflection.compute.meta b/Assets/Resources/VpeBallReflection.compute.meta
new file mode 100644
index 00000000..9d1e30bb
--- /dev/null
+++ b/Assets/Resources/VpeBallReflection.compute.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 8fc9d7c53184c684c88f20186d96b474
+ComputeShaderImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/Automation/AutomationShowcase.cs b/Editor/Automation/AutomationShowcase.cs
index fd5affbd..735ad2f8 100644
--- a/Editor/Automation/AutomationShowcase.cs
+++ b/Editor/Automation/AutomationShowcase.cs
@@ -79,7 +79,7 @@ public static void Align()
var lamp = InsertLampPresets.Find().First(preset => preset.LampName == "Incandescent");
var sb = new StringBuilder();
foreach (var insert in Inserts) {
- var applied = InsertColorPresets.CanApply(insert) && InsertColorPresets.Apply(red, insert, false);
+ var applied = InsertColorPresets.CanApply(insert) && InsertColorPresets.Apply(red, insert);
var body = insert.GetComponent().sharedMaterial;
if (InsertColorPresets.CanLink(body, false) && InsertColorPresets.LinkedPreset(body) != red) {
InsertColorPresets.Link(body, red, false);
diff --git a/LookDev~/Assets/Runtime/ThumbGenerator.cs b/LookDev~/Assets/Runtime/ThumbGenerator.cs
index e7966d9f..b63f66e1 100644
--- a/LookDev~/Assets/Runtime/ThumbGenerator.cs
+++ b/LookDev~/Assets/Runtime/ThumbGenerator.cs
@@ -58,6 +58,11 @@ public class ThumbGenerator : MonoBehaviour
public void StartProcessing(bool newOnly = false, bool selectedOnly = false)
{
_camera = Camera.main;
+
+ // a reference to a cutout that was destroyed and restored (by an undo, for example) reads as null
+ if (InsertEnvironment == null) {
+ InsertEnvironment = FindAnyObjectByType(FindObjectsInactive.Include);
+ }
var bgParent = DefaultEnvironment.transform.parent.gameObject;
_environmentObjects.Clear();
@@ -232,7 +237,12 @@ private void Process(AssetMaterialCombination a)
a.ApplyMaterial(_currentGo);
var insert = _currentGo.GetComponentInChildren(true);
- if (insert != null && InsertEnvironment != null) {
+ if (insert != null && InsertEnvironment == null) {
+ StopProcessing();
+ Debug.LogError("Cannot render insert thumbnail: there is no Playfield Insert Cutout to cut the playfield.");
+ return;
+ }
+ if (insert != null) {
InsertEnvironment.Insert = insert;
ToggleEnvironment(InsertEnvironment.gameObject);
InsertEnvironment.Rebuild();
diff --git a/LookDev~/Assets/Runtime/ThumbGeneratorComponent.cs b/LookDev~/Assets/Runtime/ThumbGeneratorComponent.cs
index c613dacf..740bb07f 100644
--- a/LookDev~/Assets/Runtime/ThumbGeneratorComponent.cs
+++ b/LookDev~/Assets/Runtime/ThumbGeneratorComponent.cs
@@ -38,7 +38,9 @@ public class ThumbGeneratorComponent : MonoBehaviour
private const int NumPreFrames = 120;
private const int NumPostFrames = 20;
+ private const double ScreenshotTimeout = 10; // seconds
private int _frame;
+ private double _waitStart = -1;
public event EventHandler OnScreenshot;
@@ -57,8 +59,9 @@ private void UpdateFrame()
if (_frame == NumPreFrames) {
Screenshot();
}
- if (_frame == NumPreFrames + NumPostFrames - 1) {
- Resize(ThumbnailGuid);
+ if (_frame == NumPreFrames + NumPostFrames - 1 && !TryResize()) {
+ TriggerRender();
+ return;
}
if (_frame++ < NumPreFrames + NumPostFrames) {
TriggerRender();
@@ -96,6 +99,7 @@ private void Screenshot(string guid)
{
try {
var path = @$"{ThumbnailRoot}/{guid}_large.png";
+ File.Delete(path); // so a leftover isn't taken for this one
ScreenCapture.CaptureScreenshot(path, 2);
Debug.Log($"Screenshot for \"{Prefab.name}\" saved at {path}");
@@ -104,6 +108,30 @@ private void Screenshot(string guid)
}
}
+ ///
+ /// Resizes the screenshot once it's there. Returns false while still waiting for it.
+ ///
+ private bool TryResize()
+ {
+ if (!File.Exists(Path.GetFullPath($"{ThumbnailRoot}/{ThumbnailGuid}_large.png"))) {
+ // the screenshot is only written when the game view renders, which a background editor may not do
+ if (_waitStart < 0) {
+ _waitStart = EditorApplication.timeSinceStartup;
+ }
+ if (EditorApplication.timeSinceStartup - _waitStart < ScreenshotTimeout) {
+ return false;
+ }
+ Debug.LogError($"No screenshot of \"{Prefab.name}\" after {ScreenshotTimeout}s, skipping it. Keep the editor in front while generating thumbnails.");
+ return true;
+ }
+ try {
+ Resize(ThumbnailGuid);
+ } catch (Exception e) {
+ Debug.LogError(e);
+ }
+ return true;
+ }
+
private void Resize(string guid)
{
Debug.Log($"{ThumbnailRoot}/{guid}_large.png -> {Path.GetFullPath($"{ThumbnailRoot}/{guid}_large.png")}");
diff --git a/Runtime/AutoFocusComponent.cs b/Runtime/AutoFocusComponent.cs
index 5e866653..0ba5e509 100644
--- a/Runtime/AutoFocusComponent.cs
+++ b/Runtime/AutoFocusComponent.cs
@@ -57,6 +57,9 @@ public class AutoFocusComponent : MonoBehaviour
[HideInInspector]
public ComputeShader DepthProbeShader;
+ // where the depth probe shader is found when nothing assigned it, e.g. when added at runtime
+ private const string DepthProbeShaderResource = "VpeAutoFocus";
+
private static readonly int DepthId = Shader.PropertyToID("_AutoFocusDepth");
private static readonly int ResultId = Shader.PropertyToID("_AutoFocusResult");
private static readonly int ParamsId = Shader.PropertyToID("_AutoFocusParams");
@@ -66,6 +69,7 @@ public class AutoFocusComponent : MonoBehaviour
private static readonly System.Collections.Generic.Dictionary Owners = new();
private Camera _camera;
+ private HDAdditionalCameraData _hdCamera;
private DepthOfField _dof;
private DepthProbePass _pass;
private GraphicsBuffer _resultBuffer;
@@ -95,6 +99,9 @@ private void OnValidate()
private void OnEnable()
{
_camera = GetComponent();
+ if (DepthProbeShader == null) {
+ DepthProbeShader = UnityEngine.Resources.Load(DepthProbeShaderResource);
+ }
if (DepthProbeShader == null) {
Logger.Warn("[AutoFocus] No depth probe shader assigned on {Camera}, cannot focus.", name);
return;
@@ -146,6 +153,8 @@ private void OnDisable()
}
_resultBuffer?.Release();
_resultBuffer = null;
+ CameraFrameSettings.Release(_hdCamera, FrameSettingsField.CustomPass);
+ _hdCamera = null;
if (_dof != null) {
if (Owners.TryGetValue(_dof, out var owner) && owner == this) {
@@ -158,21 +167,16 @@ private void OnDisable()
///
/// The depth probe is a custom pass, so it needs custom pass support in the pipeline asset and
- /// the custom pass frame setting on the camera, which we override if needed.
+ /// the custom pass frame setting on the camera, which it turns on while it runs.
///
private void EnableCustomPasses()
{
if (GraphicsSettings.currentRenderPipeline is HDRenderPipelineAsset asset && !asset.currentPlatformRenderPipelineSettings.supportCustomPass) {
Logger.Warn("[AutoFocus] Custom passes are disabled in the HDRP asset {Asset}, auto focus won't work. Enable Rendering > Custom Pass in the asset.", asset.name);
}
- var hdCamera = GetComponent();
- if (hdCamera == null) {
- return;
+ if (TryGetComponent(out _hdCamera)) {
+ CameraFrameSettings.Enable(_hdCamera, FrameSettingsField.CustomPass);
}
- // overriding only this field leaves all others at their defaults
- hdCamera.customRenderingSettings = true;
- hdCamera.renderingPathCustomFrameSettingsOverrideMask.mask[(uint)FrameSettingsField.CustomPass] = true;
- hdCamera.renderingPathCustomFrameSettings.SetEnabled(FrameSettingsField.CustomPass, true);
}
private void Update()
diff --git a/Runtime/BallReflectionsComponent.cs b/Runtime/BallReflectionsComponent.cs
new file mode 100644
index 00000000..3875056f
--- /dev/null
+++ b/Runtime/BallReflectionsComponent.cs
@@ -0,0 +1,273 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System.Collections.Generic;
+using NLog;
+using UnityEngine;
+using UnityEngine.Experimental.Rendering;
+using UnityEngine.Rendering;
+using UnityEngine.Rendering.HighDefinition;
+using VisualPinball.Unity;
+using Logger = NLog.Logger;
+
+namespace VisualPinball.Engine.Unity.Hdrp
+{
+ ///
+ /// Reflects the playfield in the balls. Each ball gets a reflection probe the size of the ball, whose
+ /// cubemap is filled on the GPU from the frame the camera just rendered.
+ ///
+ ///
+ ///
+ /// A realtime probe would render the scene six more times per ball and frame. Here, a global custom pass
+ /// marches every cubemap texel's direction from the ball through the camera's depth buffer, which finds
+ /// what stands on the playfield, falls back to where the direction meets the playfield plane, and takes
+ /// the lit color of the hit from the camera's color buffer, so it costs one small dispatch per ball. What
+ /// the camera doesn't see, it can't reflect, so close-up cameras get less of it. HDRP then applies the
+ /// cubemap through each ball's own material, so any ball look works. Where SSR finds a hit on the ball,
+ /// it still wins. The cubemap lags the frame by one.
+ ///
+ [AddComponentMenu("Pinball/Camera/Ball Reflections")]
+ [RequireComponent(typeof(Camera))]
+ [DisallowMultipleComponent]
+ public class BallReflectionsComponent : MonoBehaviour
+ {
+ private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
+
+ [Tooltip("Size of a face of each ball's reflection cubemap, in pixels.")]
+ [Min(16)]
+ public int Resolution = 128;
+
+ private const string ShaderResource = "VpeBallReflection";
+ private const float BallScanInterval = 0.25f;
+
+ private static readonly int ColorId = Shader.PropertyToID("_BallReflectionColor");
+ private static readonly int FacesId = Shader.PropertyToID("_BallReflectionFaces");
+ private static readonly int BallId = Shader.PropertyToID("_BallReflectionBall");
+ private static readonly int PlaneId = Shader.PropertyToID("_BallReflectionPlane");
+ private static readonly int WorldToPlayfieldId = Shader.PropertyToID("_BallReflectionWorldToPlayfield");
+ private static readonly int ParamsId = Shader.PropertyToID("_BallReflectionParams");
+
+ private Camera _camera;
+ private ComputeShader _shader;
+ private int _kernel;
+ private FillPass _pass;
+ private RenderTexture _faces;
+ private PlayfieldComponent _playfield;
+ private float _nextBallScan;
+ private readonly Dictionary _probes = new();
+ private readonly List _gone = new();
+
+ // the camera's frame settings this component turns on while it runs
+ private static readonly FrameSettingsField[] FrameSettingsFields = { FrameSettingsField.CustomPass, FrameSettingsField.ReflectionProbe };
+ private HDAdditionalCameraData _hdCamera;
+
+ private sealed class BallProbe
+ {
+ public GameObject GameObject;
+ public RenderTexture Cube;
+ public float Radius;
+ }
+
+ private void OnEnable()
+ {
+ _camera = GetComponent();
+ _shader = UnityEngine.Resources.Load(ShaderResource);
+ if (_shader == null) {
+ Logger.Warn("[BallReflections] Compute shader {Shader} not found, balls keep reflecting the sky only.", ShaderResource);
+ enabled = false;
+ return;
+ }
+ _kernel = _shader.FindKernel("KFill");
+ // shared by all balls, since each ball's fill is copied out before the next one starts
+ _faces = new RenderTexture(Resolution, Resolution, GraphicsFormat.R16G16B16A16_SFloat, GraphicsFormat.None) {
+ dimension = TextureDimension.Tex2DArray,
+ volumeDepth = 6,
+ enableRandomWrite = true,
+ name = "VPE Ball Reflection Faces",
+ };
+ _faces.Create();
+ EnableFrameSettings();
+ _nextBallScan = 0f;
+ _pass = new FillPass { Owner = this, name = "VPE Ball Reflections" };
+ // after the transparent pass, so the inserts are in the color buffer
+ CustomPassVolume.RegisterGlobalCustomPass(CustomPassInjectionPoint.BeforePostProcess, _pass);
+ RenderPipelineManager.beginContextRendering += FollowBalls;
+ }
+
+ private void OnDisable()
+ {
+ RenderPipelineManager.beginContextRendering -= FollowBalls;
+ if (_pass != null) {
+ CustomPassVolume.UnregisterGlobalCustomPass(_pass);
+ _pass = null;
+ }
+ foreach (var probe in _probes.Values) {
+ Release(probe);
+ }
+ _probes.Clear();
+ if (_faces != null) {
+ _faces.Release();
+ Destroy(_faces);
+ _faces = null;
+ }
+ RestoreFrameSettings();
+ }
+
+ ///
+ /// The cubemaps reach the balls as reflection probes, and the fill runs in a custom pass, so the camera
+ /// needs both. Only these two fields are overridden, and they're put back when disabled, so a table's
+ /// own probes don't light up without the ball reflections.
+ ///
+ private void EnableFrameSettings()
+ {
+ if (!TryGetComponent(out _hdCamera)) {
+ return;
+ }
+ foreach (var field in FrameSettingsFields) {
+ CameraFrameSettings.Enable(_hdCamera, field);
+ }
+ }
+
+ private void RestoreFrameSettings()
+ {
+ foreach (var field in FrameSettingsFields) {
+ CameraFrameSettings.Release(_hdCamera, field);
+ }
+ _hdCamera = null;
+ }
+
+ // Runs once per frame before HDRP culls the probes, after the balls have moved.
+ private void FollowBalls(ScriptableRenderContext context, List cameras)
+ {
+ if (Time.unscaledTime >= _nextBallScan) {
+ ScanBalls();
+ _nextBallScan = Time.unscaledTime + BallScanInterval;
+ }
+ foreach (var (ball, probe) in _probes) {
+ if (ball == null || !ball.isActiveAndEnabled) {
+ _gone.Add(ball);
+ continue;
+ }
+ // not parented to the ball, because the probe must not roll with it
+ probe.GameObject.transform.SetPositionAndRotation(ball.transform.position, Quaternion.identity);
+ }
+ foreach (var ball in _gone) {
+ Release(_probes[ball]);
+ _probes.Remove(ball);
+ }
+ _gone.Clear();
+ }
+
+ private void ScanBalls()
+ {
+ if (_playfield == null) {
+ _playfield = FindAnyObjectByType();
+ }
+ foreach (var ball in FindObjectsByType(FindObjectsSortMode.None)) {
+ if (!_probes.ContainsKey(ball)) {
+ _probes[ball] = CreateProbe(ball);
+ }
+ }
+ }
+
+ private BallProbe CreateProbe(BallComponent ball)
+ {
+ var filter = ball.GetComponentInChildren();
+ var radius = filter != null && filter.sharedMesh != null
+ ? filter.sharedMesh.bounds.extents.x * filter.transform.lossyScale.x
+ : VisualPinball.Unity.Physics.ScaleToWorld(ball.Radius);
+
+ var cube = new RenderTexture(Resolution, Resolution, GraphicsFormat.R16G16B16A16_SFloat, GraphicsFormat.None) {
+ dimension = TextureDimension.Cube,
+ useMipMap = true,
+ autoGenerateMips = false,
+ name = "VPE Ball Reflection",
+ };
+ cube.Create();
+
+ // set up after the components woke up, since waking up copies the legacy probe's defaults over
+ var go = new GameObject("Ball Reflection Probe") { hideFlags = HideFlags.DontSave };
+ go.transform.SetPositionAndRotation(ball.transform.position, Quaternion.identity);
+ go.AddComponent();
+ var probe = go.AddComponent();
+ probe.mode = ProbeSettings.Mode.Custom;
+ probe.customTexture = cube;
+ probe.importance = 32767;
+ probe.influenceVolume.shape = InfluenceShape.Sphere;
+ probe.influenceVolume.sphereRadius = radius * 1.05f;
+ probe.influenceVolume.sphereBlendDistance = 0f;
+ // projected at infinity: the cubemap is what the ball sees from its center
+ probe.settingsRaw.proxySettings.useInfluenceVolumeAsProxyVolume = false;
+
+ return new BallProbe { GameObject = go, Cube = cube, Radius = radius };
+ }
+
+ private static void Release(BallProbe probe)
+ {
+ if (probe.GameObject != null) {
+ Destroy(probe.GameObject);
+ }
+ if (probe.Cube != null) {
+ probe.Cube.Release();
+ Destroy(probe.Cube);
+ }
+ }
+
+ private void Fill(CustomPassContext ctx)
+ {
+ if (ctx.hdCamera.camera != _camera || _playfield == null || _probes.Count == 0) {
+ return;
+ }
+ var pf = _playfield;
+ var toWorld = pf.transform.localToWorldMatrix * (Matrix4x4)VisualPinball.Unity.Physics.VpxToWorld;
+ var normal = toWorld.MultiplyVector(Vector3.forward).normalized;
+ if (Vector3.Dot(normal, Vector3.up) < 0f) {
+ normal = -normal;
+ }
+ var origin = toWorld.MultiplyPoint3x4(Vector3.zero);
+ var worldToPlayfield = Matrix4x4.Scale(new Vector3(1f / (pf.Right - pf.Left), 1f / (pf.Bottom - pf.Top), 1f))
+ * Matrix4x4.Translate(new Vector3(-pf.Left, -pf.Top, 0f))
+ * toWorld.inverse;
+
+ var cmd = ctx.cmd;
+ cmd.SetComputeTextureParam(_shader, _kernel, ColorId, ctx.cameraColorBuffer);
+ cmd.SetComputeVectorParam(_shader, PlaneId, new Vector4(normal.x, normal.y, normal.z, Vector3.Dot(normal, origin)));
+ cmd.SetComputeMatrixParam(_shader, WorldToPlayfieldId, worldToPlayfield);
+ // rays are marched across half the playfield's diagonal, which covers what a ball can see of it
+ var traceDistance = toWorld.MultiplyVector(new Vector3(pf.Right - pf.Left, pf.Bottom - pf.Top, 0f)).magnitude * 0.5f;
+ cmd.SetComputeVectorParam(_shader, ParamsId, new Vector4(Resolution, traceDistance, 0f, 0f));
+ cmd.SetComputeTextureParam(_shader, _kernel, FacesId, _faces);
+ var groups = (Resolution + 7) / 8;
+ foreach (var probe in _probes.Values) {
+ var center = probe.GameObject.transform.position;
+ cmd.SetComputeVectorParam(_shader, BallId, new Vector4(center.x, center.y, center.z, probe.Radius));
+ cmd.DispatchCompute(_shader, _kernel, groups, groups, 6);
+ for (var face = 0; face < 6; face++) {
+ cmd.CopyTexture(_faces, face, 0, probe.Cube, face, 0);
+ }
+ // HDRP filters a custom probe texture again only when its update count changes
+ probe.Cube.IncrementUpdateCount();
+ }
+ }
+
+ private sealed class FillPass : CustomPass
+ {
+ public BallReflectionsComponent Owner;
+
+ protected override void Execute(CustomPassContext ctx) => Owner.Fill(ctx);
+ }
+ }
+}
diff --git a/Runtime/BallReflectionsComponent.cs.meta b/Runtime/BallReflectionsComponent.cs.meta
new file mode 100644
index 00000000..35871518
--- /dev/null
+++ b/Runtime/BallReflectionsComponent.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c1b3f2f00ea58954c98f686f146c7105
\ No newline at end of file
diff --git a/Runtime/CameraFrameSettings.cs b/Runtime/CameraFrameSettings.cs
new file mode 100644
index 00000000..2c04909d
--- /dev/null
+++ b/Runtime/CameraFrameSettings.cs
@@ -0,0 +1,81 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System.Collections.Generic;
+using UnityEngine.Rendering.HighDefinition;
+
+namespace VisualPinball.Engine.Unity.Hdrp
+{
+ ///
+ /// Frame settings that components switch on for their camera while they run. Each one is counted per
+ /// camera, so a component switching off leaves it on for another that still needs it, and the camera
+ /// gets its own setting back once none does.
+ ///
+ internal static class CameraFrameSettings
+ {
+ private sealed class Saved
+ {
+ public bool CustomRenderingSettings;
+ public readonly Dictionary Fields = new();
+ }
+
+ private static readonly Dictionary Cameras = new();
+
+ public static void Enable(HDAdditionalCameraData camera, FrameSettingsField field)
+ {
+ if (!Cameras.TryGetValue(camera, out var saved)) {
+ saved = new Saved { CustomRenderingSettings = camera.customRenderingSettings };
+ Cameras[camera] = saved;
+ }
+ if (saved.Fields.TryGetValue(field, out var state)) {
+ saved.Fields[field] = (state.overridden, state.enabled, state.users + 1);
+ } else {
+ saved.Fields[field] = (camera.renderingPathCustomFrameSettingsOverrideMask.mask[(uint)field],
+ camera.renderingPathCustomFrameSettings.IsEnabled(field), 1);
+ }
+ // overriding only this field leaves all others at their defaults
+ camera.customRenderingSettings = true;
+ camera.renderingPathCustomFrameSettingsOverrideMask.mask[(uint)field] = true;
+ camera.renderingPathCustomFrameSettings.SetEnabled(field, true);
+ }
+
+ /// Gives up one of the field. A destroyed camera has nothing to restore.
+ public static void Release(HDAdditionalCameraData camera, FrameSettingsField field)
+ {
+ if (ReferenceEquals(camera, null)) {
+ return;
+ }
+ if (camera == null) {
+ Cameras.Remove(camera); // destroyed, but it still matches its own key
+ return;
+ }
+ if (!Cameras.TryGetValue(camera, out var saved) || !saved.Fields.TryGetValue(field, out var state)) {
+ return;
+ }
+ if (state.users > 1) {
+ saved.Fields[field] = (state.overridden, state.enabled, state.users - 1);
+ return;
+ }
+ saved.Fields.Remove(field);
+ camera.renderingPathCustomFrameSettingsOverrideMask.mask[(uint)field] = state.overridden;
+ camera.renderingPathCustomFrameSettings.SetEnabled(field, state.enabled);
+ if (saved.Fields.Count == 0) {
+ camera.customRenderingSettings = saved.CustomRenderingSettings;
+ Cameras.Remove(camera);
+ }
+ }
+ }
+}
diff --git a/Runtime/CameraFrameSettings.cs.meta b/Runtime/CameraFrameSettings.cs.meta
new file mode 100644
index 00000000..717df021
--- /dev/null
+++ b/Runtime/CameraFrameSettings.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 491e0eeef51b4fe484ff285fa1c974e0
\ No newline at end of file
diff --git a/Runtime/HdrpGraphicsApplier.cs b/Runtime/HdrpGraphicsApplier.cs
index c6209cdc..93674dfd 100644
--- a/Runtime/HdrpGraphicsApplier.cs
+++ b/Runtime/HdrpGraphicsApplier.cs
@@ -23,8 +23,9 @@
namespace VisualPinball.Engine.Unity.Hdrp
{
///
- /// Applies the host app's to HDRP at runtime: camera anti-aliasing
- /// and the live Volume effects (SSR/RT reflections, SSAO, SSGI/RT-GI, recursive ray tracing, Bloom).
+ /// Applies the host app's to HDRP at runtime: camera anti-aliasing,
+ /// ball reflections and the live Volume effects (SSR/RT reflections, SSAO, SSGI/RT-GI, recursive ray
+ /// tracing, Bloom).
/// Registered before any scene loads, the same pattern as HdrpMaterialResolver. Engine-level
/// settings (resolution, vsync, quality level) are applied host-side and don't pass through here.
///
@@ -38,6 +39,8 @@ private static void Register()
VpeGraphics.Register(new HdrpGraphicsApplier());
VpeGraphics.UpscalerSupport = IsUpscalerSupported;
VpeGraphics.RenderScaleSource = RenderScale;
+ VpeGraphics.BallReflectionsSupported = true;
+ VpeGraphics.DepthOfFieldHandler = SetDepthOfField;
}
// HDRP resets the active DynamicResolutionHandler after each render, so read the main camera's own size
@@ -62,11 +65,54 @@ public void Apply(VpeGraphicsSettings settings)
_upscaler = (VpeUpscaler)settings.Upscaler;
_upscalerQuality = (VpeUpscalerQuality)Mathf.Clamp(settings.UpscalerQuality, 0, 3);
ApplyCameraAntialiasing(Camera.main);
+ ApplyBallReflections(Camera.main, settings.BallReflections);
ApplyVolume(settings);
Logger.Info("Upscaling {Upscaler} {Quality} (DLSS detected: {Dlss}, FSR 2 detected: {Fsr2})",
_upscaler, _upscalerQuality, HDDynamicResolutionPlatformCapabilities.DLSSDetected, HDDynamicResolutionPlatformCapabilities.FSR2Detected);
}
+ // The component lives on the camera that renders the balls; it's only added once it's wanted.
+ private static void ApplyBallReflections(Camera cam, bool on)
+ {
+ if (cam == null) {
+ return;
+ }
+ var reflections = cam.GetComponent();
+ if (reflections == null) {
+ if (!on) {
+ return;
+ }
+ reflections = cam.gameObject.AddComponent();
+ }
+ reflections.enabled = on;
+ }
+
+ // The auto focus also lives on the main camera once it's wanted. Restarting it makes it look for the
+ // volume again, which a table load replaces. Off goes to every volume the camera sees, since a table's
+ // profile can bring a depth of field of its own, which the auto focus never touched.
+ private static void SetDepthOfField(bool on)
+ {
+ var cam = Camera.main;
+ if (cam == null) {
+ return;
+ }
+ var focus = cam.GetComponent();
+ if (focus == null && on) {
+ focus = cam.gameObject.AddComponent();
+ focus.FocusSpeed = 20f; // refocuses as quickly as the tables set it
+ } else if (focus != null) {
+ focus.enabled = false;
+ focus.enabled = on;
+ }
+ if (!on) {
+ foreach (var volume in FindGlobalVolumes(cam)) {
+ if (ProfileOf(volume).TryGet(out var dof)) {
+ dof.focusMode.Override(DepthOfFieldMode.Off);
+ }
+ }
+ }
+ }
+
// DLSS needs the NVIDIA plugin and an RTX GPU, FSR 2 a DX12 or Vulkan device; HDRP detects both.
private static bool IsUpscalerSupported(VpeUpscaler upscaler) => upscaler switch {
VpeUpscaler.Dlss => HDDynamicResolutionPlatformCapabilities.DLSSDetected,
@@ -168,6 +214,8 @@ private static void ApplyVolume(VpeGraphicsSettings s)
if (volumes.Count == 0) {
// no volume runs the recursive rendering, so no glass may stay flagged for it (it wouldn't draw)
ApplyRecursiveMaterials(false);
+ FittedClusters.Clear();
+ FitLightCluster(false);
return;
}
var primary = PrimaryVolume(volumes);
@@ -177,9 +225,13 @@ private static void ApplyVolume(VpeGraphicsSettings s)
var rtReflections = s.RayTracedReflections && rayTracing;
var rtGlobalIllumination = s.RayTracedGlobalIllumination && rayTracing;
var recursive = s.RayTracedRecursiveRendering && rayTracing;
+ var tracing = rtReflections || rtGlobalIllumination || recursive;
// a table's profile may lack the component a ray-traced effect runs on
var primaryProfile = ProfileOf(primary);
+ if (tracing && !volumes.Exists(v => ProfileOf(v).Has())) {
+ primaryProfile.Add();
+ }
if (rtReflections && !volumes.Exists(v => ProfileOf(v).Has())) {
primaryProfile.Add();
}
@@ -190,8 +242,12 @@ private static void ApplyVolume(VpeGraphicsSettings s)
primaryProfile.Add();
}
+ FittedClusters.Clear();
foreach (var volume in volumes) {
var profile = ProfileOf(volume);
+ if (tracing && profile.TryGet(out var cluster)) {
+ FittedClusters.Add(cluster);
+ }
if (profile.TryGet(out var ssr)) {
var on = s.ScreenSpaceReflections || rtReflections;
ssr.active = on;
@@ -229,11 +285,17 @@ private static void ApplyVolume(VpeGraphicsSettings s)
}
}
ApplyRecursiveMaterials(recursive);
+ FitLightCluster(tracing);
if (rayTracing) {
- // ray-traced shadows and contact shadows trace the visibility rays the build strips
foreach (var light in Object.FindObjectsByType(FindObjectsInactive.Include)) {
+ // ray-traced shadows and contact shadows trace the visibility rays the build strips
light.useRayTracedShadows = false;
light.rayTraceContactShadow = false;
+ // A lamp lights what's around it in the reflections too. The bulb models used to leave their
+ // light out of ray tracing, and packages exported with them still do.
+ if (light.GetComponentInParent(true) != null) {
+ light.includeForRayTracing = true;
+ }
}
}
@@ -243,6 +305,55 @@ private static void ApplyVolume(VpeGraphicsSettings s)
string.Join(", ", volumes.ConvertAll(v => v.name)), rtReflections, rtGlobalIllumination, recursive);
}
+ // HDRP's ray tracing light cluster is a fixed 64x32x64 grid around the camera, as wide as the LightCluster
+ // range and clipped to the lights' bounds. The room's work lights reach 10 m, so at the default range the
+ // whole playfield fell into a few 30 cm cells, many with more lamps than a cell holds (HDRP leaves the rest
+ // out of the reflections). Every frame, the range shrinks to the smallest cube around the camera that still
+ // holds the playfield up to the glass, which keeps the cells a few centimetres wide wherever the camera is.
+ private const float ClusterRangeMargin = 0.1f;
+ private static readonly List FittedClusters = new List();
+ private static PlayfieldComponent _clusterPlayfield;
+
+ private static void FitLightCluster(bool on)
+ {
+ RenderPipelineManager.beginContextRendering -= FitLightClusterRange;
+ _clusterPlayfield = on ? Object.FindAnyObjectByType() : null;
+ if (on && _clusterPlayfield != null && FittedClusters.Count > 0) {
+ // before HDRP evaluates the cameras' volumes, and after the camera has moved for the frame
+ RenderPipelineManager.beginContextRendering += FitLightClusterRange;
+ }
+ }
+
+ private static void FitLightClusterRange(ScriptableRenderContext context, List cameras)
+ {
+ var cam = Camera.main;
+ if (cam == null || _clusterPlayfield == null) {
+ return;
+ }
+ var range = ClusterRange(cam.transform.position, _clusterPlayfield);
+ foreach (var cluster in FittedClusters) {
+ if (cluster != null) {
+ cluster.cameraClusterRange.Override(range);
+ }
+ }
+ }
+
+ // The largest distance along any axis from the camera to a corner of the playfield's box, surface to glass.
+ private static float ClusterRange(Vector3 cameraPosition, PlayfieldComponent playfield)
+ {
+ var toWorld = playfield.transform.localToWorldMatrix * (Matrix4x4)VisualPinball.Unity.Physics.VpxToWorld;
+ var range = 0f;
+ for (var i = 0; i < 8; i++) {
+ var corner = toWorld.MultiplyPoint3x4(new Vector3(
+ (i & 1) == 0 ? playfield.Left : playfield.Right,
+ (i & 2) == 0 ? playfield.Top : playfield.Bottom,
+ (i & 4) == 0 ? 0f : playfield.GlassHeight));
+ var d = corner - cameraPosition;
+ range = Mathf.Max(range, Mathf.Max(Mathf.Abs(d.x), Mathf.Max(Mathf.Abs(d.y), Mathf.Abs(d.z))));
+ }
+ return Mathf.Min(range + ClusterRangeMargin, 50f);
+ }
+
// Profiles we instantiated (Volume.profile clones the shared one on first access), so they and their
// components can be destroyed once their volume is gone; nothing unloads them otherwise.
private static readonly Dictionary OwnedProfiles = new Dictionary();