536 lines
21 KiB
C#
Raw Normal View History

2025-09-06 17:17:39 +04:00
// Perfect Culling (C) 2021 Patrick König
//
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Experimental.Rendering;
using UnityEngine.Profiling;
using UnityEngine.Rendering;
namespace Koenigz.PerfectCulling
{
[RequireComponent(typeof(Camera))]
public class PerfectCullingCamera : MonoBehaviour
{
public static List<PerfectCullingCamera> AllCameras = new List<PerfectCullingCamera>();
public bool ShowInGameStats { get; set; }
public bool VisualizeFrustumCulling { get; set; } = true;
public int LastTotalVertices { get; private set; }
public int LastVisibleVertices { get; private set; }
public int LastCulledVertices => LastTotalVertices - LastVisibleVertices;
public int LastVisible { get; private set; }
public int LastTotal { get;private set; }
public int LastCulled => LastTotal - LastVisible;
[Tooltip("Allows to take into account neighbor cells to prevent popping issues. It's a great way to compensate for a too sparse bake. This comes with a minor performance impact.\n\n" +
"You can achieve even better results without performance implications by baking this in by using the Merge-Downsample feature for your bakes.")]
[Range(0, 2)]
public int NeighborCellIncludeRadius = 0;
public PerfectCullingVisibilityLayer visibilityLayer = PerfectCullingVisibilityLayer.All;
public int LastFrameHash => m_lastFrameHash;
private bool m_invertCulling = false;
public bool InvertCulling
{
get => m_invertCulling;
set
{
m_invertCulling = value;
SetDirty();
}
}
// We use this as a more efficient HashSet
private static readonly bool[] m_visibleRenderers = new bool[PerfectCullingConstants.MaxRenderers + 1];
private static int m_lastFrame = -1;
private int m_lastFrameHash = -1;
private Camera m_camera;
private static readonly Vector3[] m_offsets = new Vector3[]
{
// 0, 0, 0 needs to be first because IncludeNeighborCells depends on that
new Vector3(0, 0, 0),
new Vector3(1, 0, 0),
new Vector3(-1, 0, 0),
new Vector3(0, 0, 1),
new Vector3(0, 0, -1),
new Vector3(0, 1, 0),
new Vector3(0, -1, 0),
};
private bool m_cullingPreview;
void Awake()
{
m_camera = GetComponent<Camera>();
}
private void OnEnable()
{
SetDirty();
AllCameras.Add(this);
// https://issuetracker.unity3d.com/issues/hdrp-renderpipelinemanager-dot-currentpipeline-is-null-for-the-first-few-frames-of-playmode
// Concluding that we CANNOT check for if (RenderPipelineManager.currentPipeline != null) to detect SRP here
#if UNITY_2019_1_OR_NEWER
RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
#endif
}
private void OnDisable()
{
// Lets make sure we do this first to not get into a situation where we didn't unsubscribe
#if UNITY_2019_1_OR_NEWER
RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering;
#endif
#if UNITY_EDITOR
RestoreSceneCamerasCullingMatrices();
#endif
SetDirty();
// Execute this before ToggleAllRenderers - just in case we run into an exception.
AllCameras.Remove(this);
// Toggle everything back on. Just in case.
foreach (var volume in PerfectCullingVolume.AllVolumes)
{
volume.QueueToggleAllRenderers(true);
// Take effect immediately
// We also force null checks because OnDisable() might have been called as part of an active destruction process (scene change, etc.)
volume.ExecuteQueue(true);
}
LastTotal = 0;
LastVisible = 0;
System.Array.Clear(m_visibleRenderers, 0, m_visibleRenderers.Length);
}
void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera)
{
CamPreCull(camera);
}
#if UNITY_EDITOR
private readonly string m_LateUpdate_SampleName = nameof(PerfectCullingCamera) + "." + nameof(CameraFrustumVisualizationEditorOnly);
private void LateUpdate()
{
// Doing it in an update loop is pretty stupid but HDRP doesn't allow us to update this information just in time...
Profiler.BeginSample(m_LateUpdate_SampleName);
{
CameraFrustumVisualizationEditorOnly();
}
Profiler.EndSample();
}
private void CameraFrustumVisualizationEditorOnly()
{
int selectedCamerasWithVisualizeFrustumCullingEnabledCount = 0;
foreach (PerfectCullingCamera otherCamera in AllCameras)
{
selectedCamerasWithVisualizeFrustumCullingEnabledCount += (otherCamera.VisualizeFrustumCulling && UnityEditor.Selection.activeGameObject == otherCamera.gameObject) ? 1 : 0;
}
if (selectedCamerasWithVisualizeFrustumCullingEnabledCount <= 0)
{
RestoreSceneCamerasCullingMatrices();
return;
}
if (UnityEditor.Selection.activeGameObject != m_camera.gameObject)
{
return;
}
// This allocates but it is Editor Only code
foreach (Camera sceneCamera in UnityEditor.SceneView.GetAllSceneCameras())
{
sceneCamera.cullingMatrix = m_camera.cullingMatrix;
}
}
private void RestoreSceneCamerasCullingMatrices()
{
foreach (Camera sceneCamera in UnityEditor.SceneView.GetAllSceneCameras())
{
sceneCamera.ResetCullingMatrix();
}
}
#endif
private readonly string m_CamPreCull_SampleName = nameof(PerfectCullingCamera) + "." + nameof(PerformCameraCulling);
private void OnPreCull()
{
CamPreCull(m_camera);
}
private void CamPreCull(Camera camera)
{
Profiler.BeginSample(m_CamPreCull_SampleName);
{
PerformCameraCulling(camera);
}
Profiler.EndSample();
}
private void PerformCameraCulling(Camera camera)
{
if (camera != m_camera)
{
// Another camera rendering. We are not interested in it.
return;
}
Vector3 camPos = transform.position;
// We calculate a hash for all visible cell indices to tell whether our camera is dirty or not.
int thisFrameHash = 13;
thisFrameHash = thisFrameHash * 17 + AllCameras.IndexOf(this);
int maxSamples = NeighborCellIncludeRadius != 0 ? m_offsets.Length : 1;
// We loop over all volumes and all cameras to check whether any of them changed.
// If any changed we need to update our state, too.
foreach (var volume in PerfectCullingVolume.AllVolumes)
{
if (((int)volume.visibilityLayer & (int)visibilityLayer) == 0)
{
// Not assigned to the same layer. We just ignore it.
continue;
}
foreach (PerfectCullingCamera cam in AllCameras)
{
for (int neighborIndex = 0; neighborIndex < maxSamples; ++neighborIndex)
{
for (int j = 1; j <= NeighborCellIncludeRadius + 1; ++j)
{
unchecked
{
int index = volume.GetIndexForWorldPos(cam.transform.position + Vector3.Scale(
m_offsets[neighborIndex] * j, volume.volumeBakeData.cellSize), out bool isOutOfBounds);
if ((volume.outOfBoundsBehaviour == PerfectCullingBakingBehaviour.EOutOfBoundsBehaviour.Cull || volume.outOfBoundsBehaviour == PerfectCullingBakingBehaviour.EOutOfBoundsBehaviour.IgnoreDoNothing) && isOutOfBounds)
{
continue;
}
thisFrameHash = thisFrameHash * 17 + index;
}
}
}
}
}
// Hashes match. Nothing to do.
if (m_lastFrameHash == thisFrameHash)
{
return;
}
// Only want to toggle everything off once per frame.
// This makes sure that we don't disable renderers that another camera enabled before us.
if (Time.frameCount != m_lastFrame)
{
foreach (var volume in PerfectCullingVolume.AllVolumes)
{
// We don't execute this just yet because we want to not disable a renderer just to turn it back on again anyway
// So we only queue it but don't execute it yet to prevent costs for an unnecessary toggle
volume.QueueToggleAllRenderers(InvertCulling);
}
m_lastFrame = Time.frameCount;
}
LastTotal = 0;
LastVisible = 0;
int totalVisible = 0;
m_lastFrameHash = thisFrameHash;
LastTotalVertices = 0;
LastVisibleVertices = 0;
foreach (var volume in PerfectCullingVolume.AllVolumes)
{
if (((int)volume.visibilityLayer & (int)visibilityLayer) == 0)
{
// Not assigned to the same layer. We just ignore it.
continue;
}
// Just maintain current state if we are supposed to ignore this volume (don't want to call Execute on the volume).
{
_ = volume.GetIndexForWorldPos(camPos, out bool isOutOfBounds);
if ((volume.outOfBoundsBehaviour == PerfectCullingBakingBehaviour.EOutOfBoundsBehaviour.IgnoreDoNothing) && isOutOfBounds)
{
continue;
}
}
System.Array.Clear(m_visibleRenderers, 0, m_visibleRenderers.Length);
LastTotalVertices += volume.TotalVertexCount;
bool continueLoop = true;
for (int neighborIndex = 0; (neighborIndex < maxSamples) && continueLoop; ++neighborIndex)
{
// We don't check continueLoop here because we break out of this loop
for (int j = 1; (j <= NeighborCellIncludeRadius + 1); ++j)
{
volume.GetIndexForWorldPos(camPos + Vector3.Scale(
m_offsets[neighborIndex] * j, volume.volumeBakeData.cellSize), out bool isOutOfBounds);
if (volume.outOfBoundsBehaviour == PerfectCullingBakingBehaviour.EOutOfBoundsBehaviour.Cull && isOutOfBounds)
{
continue;
}
PerfectCullingTemp.ListUshort.Clear();
volume.GetIndicesForWorldPos(
camPos + Vector3.Scale(m_offsets[neighborIndex] * j,
volume.volumeBakeData.cellSize), PerfectCullingTemp.ListUshort);
// If we are standing on an empty cell we pull in all renderers so we do not cull the entire world
// NOTE: We only do this for the primary cell (neighborIndex == 0) and not for neighbour cells to not impact performance!
if ((volume.emptyCellCullBehaviour == EEmptyCellCullBehaviour.CullNothing) && neighborIndex == 0 && PerfectCullingTemp.ListUshort.Count == 0)
{
int bakeGroupCount = volume.bakeGroups.Length;
for (int index = 0;
index < bakeGroupCount;
++index)
{
// We only queue this up for now
volume.QueueToggleRenderer(index, !InvertCulling, out PerfectCullingBakeGroup r);
m_visibleRenderers[index] = true;
LastVisibleVertices += r.vertexCount;
++totalVisible;
}
continueLoop = false;
break;
}
for (int indexIndices = 0; indexIndices < PerfectCullingTemp.ListUshort.Count; ++indexIndices)
{
int index = PerfectCullingTemp.ListUshort[indexIndices];
if (m_visibleRenderers[index])
{
// Already processed
continue;
}
// We only queue this up for now
volume.QueueToggleRenderer(index, !InvertCulling, out PerfectCullingBakeGroup r);
m_visibleRenderers[index] = true;
LastVisibleVertices += r.vertexCount;
++totalVisible;
}
}
}
LastTotal += volume.RenderersCount;
// Finally we can execute the queue
volume.ExecuteQueue();
}
LastVisible += totalVisible;
}
void SetDirty()
{
m_lastFrameHash = -1;
}
#if UNITY_EDITOR
private int m_guiLastFrameHash = -1;
private string m_guiLastText = null;
private readonly string m_OnGUI_SampleName = nameof(PerfectCullingCamera) + "." + nameof(OnGUIEditorOnly);
private void OnGUI()
{
Profiler.BeginSample(m_OnGUI_SampleName);
{
OnGUIEditorOnly();
}
Profiler.EndSample();
}
private void OnGUIEditorOnly()
{
if (!ShowInGameStats)
{
return;
}
if (m_guiLastFrameHash != LastFrameHash || m_guiLastText == null)
{
m_guiLastText = "* Perfect Culling Stats *\n"
+ $"Total renderers: {LastTotal}\n"
+ $" - Culled: {LastCulled} ({Mathf.Round((LastCulled / (float) LastTotal) * 100f)}%)\n"
+ $" - Visible: {LastVisible}\n"
+ $" - Culled verts: {PerfectCullingUtil.FormatNumber(LastCulledVertices)}/{PerfectCullingUtil.FormatNumber(LastTotalVertices)} ({Mathf.Round((LastCulledVertices / (float) LastTotalVertices) * 100f)}%)\n"
+ $" - Hash: {LastFrameHash}\n";
m_guiLastFrameHash = LastFrameHash;
}
GUI.skin.box.fontSize = 30;
GUI.skin.box.alignment = TextAnchor.UpperLeft;
GUILayout.Box(m_guiLastText, System.Array.Empty<GUILayoutOption>());
}
[ContextMenu("Enable Culling Preview")]
private void EnableCullingPreview()
{
UnityEditor.EditorApplication.update -= UpdateCullingPreview;
UnityEditor.EditorApplication.update += UpdateCullingPreview;
}
[ContextMenu("Disable Culling Preview")]
private void DisableCullingPreview()
{
UnityEditor.EditorApplication.update -= UpdateCullingPreview;
var volumes = GameObject.FindObjectsOfType<PerfectCullingVolume>();
foreach (var volume in volumes)
{
foreach (var bakeGroup in volume.bakeGroups)
{
bakeGroup.ForeachRenderer(r => r.forceRenderingOff = false);
}
}
}
private void UpdateCullingPreview()
{
if (this == null || Application.isPlaying)
{
DisableCullingPreview();
return;
}
var volumes = GameObject.FindObjectsOfType<PerfectCullingVolume>();
Vector3 camPos = transform.position;
foreach (var volume in volumes)
{
foreach (var bakeGroup in volume.bakeGroups)
{
bakeGroup.ForeachRenderer(r => r.forceRenderingOff = true);
}
}
int maxSamples = NeighborCellIncludeRadius != 0 ? m_offsets.Length : 1;
foreach (var volume in volumes)
{
if (((int)volume.visibilityLayer & (int)visibilityLayer) == 0)
{
// Not assigned to the same layer. We just ignore it.
continue;
}
// Just maintain current state if we are supposed to ignore this volume (don't want to call Execute on the volume).
{
_ = volume.GetIndexForWorldPos(camPos, out bool isOutOfBounds);
if ((volume.outOfBoundsBehaviour == PerfectCullingBakingBehaviour.EOutOfBoundsBehaviour.IgnoreDoNothing) && isOutOfBounds)
{
continue;
}
}
bool continueLoop = true;
for (int neighborIndex = 0; (neighborIndex < maxSamples) && continueLoop; ++neighborIndex)
{
// We don't check continueLoop here because we break out of this loop
for (int j = 1; (j <= NeighborCellIncludeRadius + 1); ++j)
{
volume.GetIndexForWorldPos(camPos + Vector3.Scale(
m_offsets[neighborIndex] * j, volume.volumeBakeData.cellSize), out bool isOutOfBounds);
if (volume.outOfBoundsBehaviour == PerfectCullingBakingBehaviour.EOutOfBoundsBehaviour.Cull && isOutOfBounds)
{
continue;
}
PerfectCullingTemp.ListUshort.Clear();
volume.GetIndicesForWorldPos(
camPos + Vector3.Scale(m_offsets[neighborIndex] * j,
volume.volumeBakeData.cellSize), PerfectCullingTemp.ListUshort);
// If we are standing on an empty cell we pull in all renderers so we do not cull the entire world
// NOTE: We only do this for the primary cell (neighborIndex == 0) and not for neighbour cells to not impact performance!
if ((volume.emptyCellCullBehaviour == EEmptyCellCullBehaviour.CullNothing) && neighborIndex == 0 && PerfectCullingTemp.ListUshort.Count == 0)
{
int bakeGroupCount = volume.bakeGroups.Length;
for (int index = 0; index < bakeGroupCount; ++index)
{
volume.bakeGroups[index].ForeachRenderer(r => r.forceRenderingOff = false);
}
continueLoop = false;
break;
}
for (int indexIndices = 0; indexIndices < PerfectCullingTemp.ListUshort.Count; ++indexIndices)
{
int index = PerfectCullingTemp.ListUshort[indexIndices];
volume.bakeGroups[index].ForeachRenderer(r => r.forceRenderingOff = false);
}
}
}
}
}
#endif
}
}