From e913b8afe5a4540810457c935e8fafaef4ff77c2 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 30 Oct 2026 11:33:43 +0100 Subject: [PATCH 1/6] insert: follow the color preset apply signature --- Editor/Automation/AutomationShowcase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); From a98d1510b788db5664fae7ba36de37c35bc2b662 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 30 Oct 2026 11:33:43 +0100 Subject: [PATCH 2/6] lookdev: cut inserts after a stale cutout reference and stop waiting on missing screenshots --- LookDev~/Assets/Runtime/ThumbGenerator.cs | 12 ++++++- .../Assets/Runtime/ThumbGeneratorComponent.cs | 32 +++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) 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")}"); From 1c990bfc970d3db696f9849b51991bf765081f73 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 30 Oct 2026 19:53:30 +0100 Subject: [PATCH 3/6] graphics: Light the ray-traced reflections with every lamp, and fit the light cluster to the playfield. HDRP's ray tracing light cluster is a fixed 64x32x64 grid around the camera. At the default range, the playfield fell into a few 30 cm cells, many of them holding more lamps than a cell can, and HDRP leaves the extra lamps out of the reflections. Every frame, the range now 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. A LightCluster component is added when a ray-traced effect is on and the profiles have none. Lamp lights are now included in ray tracing. The bulb models had left it off, and packages exported with them still do. --- Runtime/HdrpGraphicsApplier.cs | 67 +++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/Runtime/HdrpGraphicsApplier.cs b/Runtime/HdrpGraphicsApplier.cs index c6209cdc..58e3aef3 100644 --- a/Runtime/HdrpGraphicsApplier.cs +++ b/Runtime/HdrpGraphicsApplier.cs @@ -168,6 +168,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 +179,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 +196,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 +239,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 +259,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(); From 0244b9d6af457880e435aaa60d5c4aade2d7ac84 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 31 Oct 2026 00:27:08 +0100 Subject: [PATCH 4/6] graphics: Reflect the playfield in the balls. Each ball gets a reflection probe the size of the ball. A custom pass fills its cubemap from the frame the camera just rendered: every direction is marched through the depth buffer, which finds what stands on the playfield, and falls back to where it meets the playfield plane, or to the sky. HDRP applies the cubemap through each ball's own material, so every ball look keeps working, and SSR still wins where it finds a hit. The graphics applier puts the component on the main camera while the setting is on. Turning it off restores the camera's frame settings. It costs about 0.14 ms of GPU and 0.35 ms of CPU time per ball. --- Assets/Resources/VpeBallReflection.compute | 208 +++++++++++++ .../Resources/VpeBallReflection.compute.meta | 7 + Runtime/BallReflectionsComponent.cs | 288 ++++++++++++++++++ Runtime/BallReflectionsComponent.cs.meta | 2 + Runtime/HdrpGraphicsApplier.cs | 23 +- 5 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 Assets/Resources/VpeBallReflection.compute create mode 100644 Assets/Resources/VpeBallReflection.compute.meta create mode 100644 Runtime/BallReflectionsComponent.cs create mode 100644 Runtime/BallReflectionsComponent.cs.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/Runtime/BallReflectionsComponent.cs b/Runtime/BallReflectionsComponent.cs new file mode 100644 index 00000000..41bc4537 --- /dev/null +++ b/Runtime/BallReflectionsComponent.cs @@ -0,0 +1,288 @@ +// 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 overrides, and what they were before + private static readonly FrameSettingsField[] FrameSettingsFields = { FrameSettingsField.CustomPass, FrameSettingsField.ReflectionProbe }; + private HDAdditionalCameraData _hdCamera; + private bool _hadCustomRenderingSettings; + private readonly bool[] _hadOverride = new bool[FrameSettingsFields.Length]; + private readonly bool[] _hadEnabled = new bool[FrameSettingsFields.Length]; + + 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; + } + _hadCustomRenderingSettings = _hdCamera.customRenderingSettings; + _hdCamera.customRenderingSettings = true; + for (var i = 0; i < FrameSettingsFields.Length; i++) { + var field = FrameSettingsFields[i]; + _hadOverride[i] = _hdCamera.renderingPathCustomFrameSettingsOverrideMask.mask[(uint)field]; + _hadEnabled[i] = _hdCamera.renderingPathCustomFrameSettings.IsEnabled(field); + _hdCamera.renderingPathCustomFrameSettingsOverrideMask.mask[(uint)field] = true; + _hdCamera.renderingPathCustomFrameSettings.SetEnabled(field, true); + } + } + + private void RestoreFrameSettings() + { + if (_hdCamera == null) { + return; + } + for (var i = 0; i < FrameSettingsFields.Length; i++) { + var field = FrameSettingsFields[i]; + _hdCamera.renderingPathCustomFrameSettingsOverrideMask.mask[(uint)field] = _hadOverride[i]; + _hdCamera.renderingPathCustomFrameSettings.SetEnabled(field, _hadEnabled[i]); + } + _hdCamera.customRenderingSettings = _hadCustomRenderingSettings; + _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/HdrpGraphicsApplier.cs b/Runtime/HdrpGraphicsApplier.cs index 58e3aef3..cd66b16d 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,7 @@ private static void Register() VpeGraphics.Register(new HdrpGraphicsApplier()); VpeGraphics.UpscalerSupport = IsUpscalerSupported; VpeGraphics.RenderScaleSource = RenderScale; + VpeGraphics.BallReflectionsSupported = true; } // HDRP resets the active DynamicResolutionHandler after each render, so read the main camera's own size @@ -62,11 +64,28 @@ 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; + } + // 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, From 7cca2e3233d105efb5332a1327c9da4ce387edd9 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 31 Oct 2026 11:55:09 +0100 Subject: [PATCH 5/6] graphics: Toggle depth of field with the auto focus, and share the camera's frame settings. The applier handles VpeGraphics.SetDepthOfField by adding the auto focus to the main camera, or restarting it so it finds the volume a table load brought. The auto focus' compute shader moves to Resources (same GUID), so a camera that gets the component at runtime still finds it. Ball reflections and the auto focus both turn on the camera's custom pass frame setting. CameraFrameSettings counts who needs a field and restores the camera's own value only when the last one lets go, so turning one off no longer switches the other's pass off. --- .../Resources/VpeAutoFocus.compute | 0 .../Resources/VpeAutoFocus.compute.meta | 0 Runtime/AutoFocusComponent.cs | 20 +++-- Runtime/BallReflectionsComponent.cs | 25 ++---- Runtime/CameraFrameSettings.cs | 81 +++++++++++++++++++ Runtime/CameraFrameSettings.cs.meta | 2 + Runtime/HdrpGraphicsApplier.cs | 21 +++++ 7 files changed, 121 insertions(+), 28 deletions(-) rename Runtime/AutoFocus.compute => Assets/Resources/VpeAutoFocus.compute (100%) rename Runtime/AutoFocus.compute.meta => Assets/Resources/VpeAutoFocus.compute.meta (100%) create mode 100644 Runtime/CameraFrameSettings.cs create mode 100644 Runtime/CameraFrameSettings.cs.meta 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/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 index 41bc4537..3875056f 100644 --- a/Runtime/BallReflectionsComponent.cs +++ b/Runtime/BallReflectionsComponent.cs @@ -70,12 +70,9 @@ public class BallReflectionsComponent : MonoBehaviour private readonly Dictionary _probes = new(); private readonly List _gone = new(); - // the camera's frame settings this component overrides, and what they were before + // 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 bool _hadCustomRenderingSettings; - private readonly bool[] _hadOverride = new bool[FrameSettingsFields.Length]; - private readonly bool[] _hadEnabled = new bool[FrameSettingsFields.Length]; private sealed class BallProbe { @@ -139,28 +136,16 @@ private void EnableFrameSettings() if (!TryGetComponent(out _hdCamera)) { return; } - _hadCustomRenderingSettings = _hdCamera.customRenderingSettings; - _hdCamera.customRenderingSettings = true; - for (var i = 0; i < FrameSettingsFields.Length; i++) { - var field = FrameSettingsFields[i]; - _hadOverride[i] = _hdCamera.renderingPathCustomFrameSettingsOverrideMask.mask[(uint)field]; - _hadEnabled[i] = _hdCamera.renderingPathCustomFrameSettings.IsEnabled(field); - _hdCamera.renderingPathCustomFrameSettingsOverrideMask.mask[(uint)field] = true; - _hdCamera.renderingPathCustomFrameSettings.SetEnabled(field, true); + foreach (var field in FrameSettingsFields) { + CameraFrameSettings.Enable(_hdCamera, field); } } private void RestoreFrameSettings() { - if (_hdCamera == null) { - return; - } - for (var i = 0; i < FrameSettingsFields.Length; i++) { - var field = FrameSettingsFields[i]; - _hdCamera.renderingPathCustomFrameSettingsOverrideMask.mask[(uint)field] = _hadOverride[i]; - _hdCamera.renderingPathCustomFrameSettings.SetEnabled(field, _hadEnabled[i]); + foreach (var field in FrameSettingsFields) { + CameraFrameSettings.Release(_hdCamera, field); } - _hdCamera.customRenderingSettings = _hadCustomRenderingSettings; _hdCamera = null; } 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 cd66b16d..59e40d3c 100644 --- a/Runtime/HdrpGraphicsApplier.cs +++ b/Runtime/HdrpGraphicsApplier.cs @@ -40,6 +40,7 @@ private static void Register() 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 @@ -86,6 +87,26 @@ private static void ApplyBallReflections(Camera cam, bool on) 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. + private static void SetDepthOfField(bool on) + { + var cam = Camera.main; + if (cam == null) { + return; + } + var focus = cam.GetComponent(); + if (focus == null) { + if (on) { + focus = cam.gameObject.AddComponent(); + focus.FocusSpeed = 20f; // refocuses as quickly as the tables set it + } + return; + } + focus.enabled = false; + focus.enabled = on; + } + // 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, From ae61b36c68f9170fdf07817e25790200dcc0cb1d Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 1 Nov 2026 15:55:05 +0100 Subject: [PATCH 6/6] graphics: turn a table's own depth of field off too Switching depth of field off only stopped the auto focus, so a table whose profile brings a depth of field of its own stayed blurred: with no auto focus on the camera nothing touched it, and an auto focus that was already off didn't reach the volume a new table load brought. Off now sets the focus mode off on every global volume the camera sees. --- Runtime/HdrpGraphicsApplier.cs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/Runtime/HdrpGraphicsApplier.cs b/Runtime/HdrpGraphicsApplier.cs index 59e40d3c..93674dfd 100644 --- a/Runtime/HdrpGraphicsApplier.cs +++ b/Runtime/HdrpGraphicsApplier.cs @@ -88,7 +88,8 @@ private static void ApplyBallReflections(Camera cam, bool 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. + // 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; @@ -96,15 +97,20 @@ private static void SetDepthOfField(bool on) return; } var focus = cam.GetComponent(); - if (focus == null) { - if (on) { - focus = cam.gameObject.AddComponent(); - focus.FocusSpeed = 20f; // refocuses as quickly as the tables set it + 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); + } } - return; } - focus.enabled = false; - focus.enabled = on; } // DLSS needs the NVIDIA plugin and an RTX GPU, FSR 2 a DX12 or Vulkan device; HDRP detects both.