Skip to content

Instantly share code, notes, and snippets.

@adammyhre
Created September 6, 2025 18:06
Show Gist options
  • Save adammyhre/ff8253ea87d06c08d5ddfed337ba76dd to your computer and use it in GitHub Desktop.
Save adammyhre/ff8253ea87d06c08d5ddfed337ba76dd to your computer and use it in GitHub Desktop.

Revisions

  1. adammyhre created this gist Sep 6, 2025.
    134 changes: 134 additions & 0 deletions CullingManager.cs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,134 @@
    using UnityEngine;
    using System.Collections.Generic;
    using UnityUtils; // https://github.com/adammyhre/Unity-Utils

    public class CullingManager : Singleton<CullingManager> {
    #region Fields

    public Camera cullingCamera;
    public float maxCullingDistance = 100f;
    public LayerMask cullableLayers;
    public List<string> cullableTags = new List<string>();
    public float updateInterval = 0.1f;

    CullingGroup group;
    BoundingSphere[] spheres = new BoundingSphere[64];
    List<CullingTarget> owners = new List<CullingTarget>(64);
    Dictionary<CullingTarget, int> map = new Dictionary<CullingTarget, int>(64);
    int count;
    float tPos;
    int[] tmp = new int[256];
    HashSet<string> tagSet;

    #endregion

    new void Awake() {
    base.Awake();

    if (!cullingCamera) cullingCamera = Camera.main;

    group = new CullingGroup();
    group.onStateChanged = OnStateChanged;
    group.targetCamera = cullingCamera;
    group.SetBoundingSpheres(spheres);
    group.SetBoundingSphereCount(0);
    group.SetDistanceReferencePoint(cullingCamera.transform);
    group.SetBoundingDistances(new float[] { maxCullingDistance }); // single band
    // group.SetBoundingDistances(new float[]{ 10f, 25f, 60f }); // example of multiple bands, increasing distances

    tagSet = new HashSet<string>(cullableTags);
    }

    void Update() {
    tPos += Time.deltaTime;

    if (tPos >= updateInterval) {
    for (int i = 0; i < count; i++) {
    var o = owners[i];
    if (!o) continue;

    var s = spheres[i];
    s.position = o.transform.position;
    s.radius = o.boundarySphereRadius;
    spheres[i] = s;
    }

    tPos = 0f;
    }
    }

    public void Register(CullingTarget t) {
    if (!t) return;

    if (count == spheres.Length) {
    System.Array.Resize(ref spheres, count * 2);
    group.SetBoundingSpheres(spheres);
    }

    owners.Add(t);
    map[t] = count;
    spheres[count] = new BoundingSphere(t.transform.position, t.boundarySphereRadius);
    count++;
    group.SetBoundingSphereCount(count);
    }

    public void Deregister(CullingTarget t) {
    if (group == null || !t || !map.TryGetValue(t, out int i)) return;

    group.EraseSwapBack(i);
    CullingGroup.EraseSwapBack(i, spheres, ref count);
    var last = owners.Count - 1;
    var moved = owners[last];
    owners[i] = moved;
    owners.RemoveAt(last);
    if (moved) map[moved] = i;
    map.Remove(t);
    group.SetBoundingSphereCount(count);
    }

    void OnStateChanged(CullingGroupEvent e) {
    var cullingTarget = owners[e.index];
    if (!cullingTarget) return;

    if (!IsCullable(cullingTarget.gameObject)) {
    cullingTarget.ToggleOn();
    return;
    }

    bool inRange = e.currentDistance == 0;
    if (e.isVisible && inRange) cullingTarget.ToggleOn();
    else cullingTarget.ToggleOff();
    }

    bool IsCullable(GameObject obj) {
    return ((1 << obj.layer) & cullableLayers) != 0 && tagSet.Contains(obj.tag); // layer and tag check
    }

    bool IsWithinDistance(Vector3 p) {
    return Vector3.Distance(cullingCamera.transform.position, p) <= maxCullingDistance;
    }

    int GetBandTargets(int band, List<CullingTarget> outList, bool? visible = null) {
    if (tmp.Length < count) tmp = new int[count];
    int n = visible.HasValue
    ? group.QueryIndices(visible.Value, band, tmp, 0)
    : group.QueryIndices(band, tmp, 0);
    outList.Clear();
    for (int i = 0; i < n; i++) outList.Add(owners[tmp[i]]);
    return n;
    }

    public (int visible, int culled) Snapshot() {
    if (tmp.Length < count) tmp = new int[count];
    int vis = group.QueryIndices(true, tmp, 0);
    return (vis, count - vis);
    }

    void OnDisable() {
    if (group != null) {
    group.onStateChanged = null;
    group.Dispose();
    group = null;
    }
    }
    }
    154 changes: 154 additions & 0 deletions CullingTarget.cs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,154 @@
    using UnityEngine;
    using UnityEngine.Events;
    using ImprovedTimers; // https://github.com/adammyhre/Unity-Improved-Timers

    public enum CullingBehavior { None, ToggleScripts, FadeInOut }

    public class CullingTarget : MonoBehaviour {
    #region Fields

    public UnityEvent onCulled, onVisible;
    public float boundarySphereRadius = 1f;
    public Renderer objectRenderer;
    public float fadeDuration = 2f;
    public CullingBehavior cullingMode = CullingBehavior.FadeInOut;
    public bool isPriorityObject;

    MaterialPropertyBlock mpb;
    MonoBehaviour[] scripts;

    static readonly int BaseColorId = Shader.PropertyToID("_BaseColor");
    static readonly int ColorId = Shader.PropertyToID("_Color");

    CountdownTimer fadeTimer;
    float startAlpha;
    float currentAlpha;
    float targetAlpha;

    #endregion

    void Awake() {
    objectRenderer = gameObject.GetComponentInChildren<Renderer>();
    scripts = GetComponents<MonoBehaviour>();

    for (int i = 0; i < scripts.Length; i++) {
    if (scripts[i] == this) scripts[i] = null;
    }

    fadeTimer = new CountdownTimer(fadeDuration);
    }

    void OnEnable() {
    currentAlpha = GetAlpha();

    if (isPriorityObject) onVisible?.Invoke();
    else CullingManager.Instance.Register(this);
    }
    void OnDisable() {
    if (!isPriorityObject) CullingManager.Instance.Deregister(this);
    }

    void Update() {
    if (fadeTimer.IsRunning) {
    float t = 1f - Mathf.Clamp01(fadeTimer.Progress);
    float a = Mathf.Lerp(startAlpha, targetAlpha, t);
    SetAlpha(a);
    }
    }

    void BeginFadeTo(float target, bool deactivate) {
    if (!objectRenderer) return;

    mpb ??= new MaterialPropertyBlock();

    startAlpha = currentAlpha;
    targetAlpha = Mathf.Clamp01(target);

    if (deactivate && targetAlpha <= 0f) {
    fadeTimer.OnTimerStop = () => { if (objectRenderer) objectRenderer.enabled = false; };
    }
    else {
    fadeTimer.OnTimerStop = () => { };
    }

    fadeTimer.Reset(fadeDuration);
    fadeTimer.Start();
    }

    void EnableScripts(bool v) {
    for (int i = 0; i < scripts.Length; i++) {
    var s = scripts[i];
    if (s == null) continue;
    s.enabled = v;
    }
    }

    public void ToggleOn() {
    if (fadeTimer.IsRunning) fadeTimer.Stop();

    if (isPriorityObject) {
    onVisible?.Invoke();
    return;
    }

    switch (cullingMode) {
    case CullingBehavior.FadeInOut:
    if (objectRenderer && !objectRenderer.enabled) objectRenderer.enabled = true;
    BeginFadeTo(1f, deactivate: false);
    break;
    case CullingBehavior.ToggleScripts:
    EnableScripts(true);
    break;
    }
    }

    public void ToggleOff() {
    if (fadeTimer.IsRunning) fadeTimer.Stop();
    if (isPriorityObject) return;

    switch (cullingMode) {
    case CullingBehavior.FadeInOut:
    BeginFadeTo(0f, deactivate: true);
    break;
    case CullingBehavior.ToggleScripts:
    EnableScripts(false);
    break;
    }

    onCulled?.Invoke();
    }

    float GetAlpha() {
    if (!objectRenderer) return 1f;

    var m = objectRenderer.sharedMaterial;
    if (!m) return 1f;

    if (m.HasProperty(BaseColorId)) return m.GetColor(BaseColorId).a;
    if (m.HasProperty(ColorId)) return m.GetColor(ColorId).a;
    return 1f;
    }

    void SetAlpha(float a) {
    if (!objectRenderer) return;

    var m = objectRenderer.sharedMaterial;
    if (!m) return;

    currentAlpha = Mathf.Clamp01(a);

    mpb ??= new MaterialPropertyBlock();
    objectRenderer.GetPropertyBlock(mpb);

    if (m.HasProperty(BaseColorId)) {
    var c = m.GetColor(BaseColorId); c.a = currentAlpha;
    mpb.SetColor(BaseColorId, c);
    }
    else if (m.HasProperty(ColorId)) {
    var c = m.GetColor(ColorId); c.a = currentAlpha;
    mpb.SetColor(ColorId, c);
    }

    objectRenderer.SetPropertyBlock(mpb);
    }
    }