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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
208 changes: 208 additions & 0 deletions Assets/Resources/VpeBallReflection.compute
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

// 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<float4> _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);
}
7 changes: 7 additions & 0 deletions Assets/Resources/VpeBallReflection.compute.meta

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

2 changes: 1 addition & 1 deletion Editor/Automation/AutomationShowcase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MeshRenderer>().sharedMaterial;
if (InsertColorPresets.CanLink(body, false) && InsertColorPresets.LinkedPreset(body) != red) {
InsertColorPresets.Link(body, red, false);
Expand Down
12 changes: 11 additions & 1 deletion LookDev~/Assets/Runtime/ThumbGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlayfieldInsertCutout>(FindObjectsInactive.Include);
}

var bgParent = DefaultEnvironment.transform.parent.gameObject;
_environmentObjects.Clear();
Expand Down Expand Up @@ -232,7 +237,12 @@ private void Process(AssetMaterialCombination a)
a.ApplyMaterial(_currentGo);

var insert = _currentGo.GetComponentInChildren<InsertComponent>(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();
Expand Down
32 changes: 30 additions & 2 deletions LookDev~/Assets/Runtime/ThumbGeneratorComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();
Expand Down Expand Up @@ -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}");

Expand All @@ -104,6 +108,30 @@ private void Screenshot(string guid)
}
}

/// <summary>
/// Resizes the screenshot once it's there. Returns false while still waiting for it.
/// </summary>
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")}");
Expand Down
20 changes: 12 additions & 8 deletions Runtime/AutoFocusComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -66,6 +69,7 @@ public class AutoFocusComponent : MonoBehaviour
private static readonly System.Collections.Generic.Dictionary<DepthOfField, AutoFocusComponent> Owners = new();

private Camera _camera;
private HDAdditionalCameraData _hdCamera;
private DepthOfField _dof;
private DepthProbePass _pass;
private GraphicsBuffer _resultBuffer;
Expand Down Expand Up @@ -95,6 +99,9 @@ private void OnValidate()
private void OnEnable()
{
_camera = GetComponent<Camera>();
if (DepthProbeShader == null) {
DepthProbeShader = UnityEngine.Resources.Load<ComputeShader>(DepthProbeShaderResource);
}
if (DepthProbeShader == null) {
Logger.Warn("[AutoFocus] No depth probe shader assigned on {Camera}, cannot focus.", name);
return;
Expand Down Expand Up @@ -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) {
Expand All @@ -158,21 +167,16 @@ private void OnDisable()

/// <summary>
/// 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.
/// </summary>
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<HDAdditionalCameraData>();
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()
Expand Down
Loading