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();