What I Have Learned About Unity ScrollRect / ScrollView Optimization / Performance

Phedg1 picture Phedg1 · Oct 26, 2018 · Viewed 10.7k times · Source

ScrollView performance is a real drag (get it?), especially on mobile platforms. I frequently found myself getting less that 15 fps which left the user experience feeling jolting and underwhelming. After a lot of research and testing I have compiled a checklist to drastically improve performance. I now get at least 30 fps, with the majority of the CPU time allocated to WaitForTargetFPS.

I hope this helps anyone who is also having trouble in this area. Optimization solutions are hard to come by. Feel free to use and modify any of my code here.

Answer

Phedg1 picture Phedg1 · Oct 26, 2018

ONE: .GetComponent<>() calls are inefficient, especially outside the editor. Avoid using these in any kind of Update() method.

TWO: OnValueChanged() is called every frame that the ScrollView is being dragged. It is therefore in a sense equivalent to Update() so you should avoid using .GetComponent<>() calls in this method.

THREE: Whenever any element on a Canvas is changed the entire Canvas must rebuild its batches. This operation can be very expensive. It is therefore recommended to split your UI elements across at least two Canvases, one for elements that are changed rarely or never and one elements that change often.

Whenever a ScrollView scrolls the entire Canvas it is on is dirtied. It is therefore recommended that you have each ScrollView on a separate Canvas.

Unity Canvas Rebuilds Explanation: https://unity3d.com/learn/tutorials/topics/best-practices/fill-rate-canvases-and-input?playlist=30089

FOUR: EventSystem.Update() handles the input detection in a scene, using raycasts to filter through the hierarchy in order to find a component to accept this input. Therefore these calculations are only done when interacting with the scene, like when a ScrollView is being scrolled. Removing unnecessary RaycastTarget properties from graphics and text will improve this processing time. It mightn't make a big difference, but if you're not careful enough objects can make the input handling time really add up.

FIVE: With any kind of mask component, even a RectMask2D, all objects in a ScrollView are batched and rendered. If you have a lot of elements in your ScrollView, it is recommended that you utilize some kind of pooling solution. There are many of these available on the app store.

Unity Pooling Explanation: https://unity3d.com/learn/tutorials/topics/best-practices/optimizing-ui-controls

If, however, your project is incompatible with this, needing persistent elements, I would recommend that you hide your off screen objects to reduce performance overhead. Transform.SetParent() and GameObject.SetActive() are both resource intensive methods, instead attach a CanvasGroup component to each element and adjust the alpha value to achieve the same effect.

Here is a static script to detect if an object is visible or not and to set the alpha accordingly:

using UnityEngine;
using UnityEngine.UI;

public class ScrollHider : MonoBehaviour {
    static public float contentTop;
    static public float contentBottom;


    static public bool HideObject(GameObject givenObject, CanvasGroup canvasGroup, float givenPosition, float givenHeight) {
        if ((Mathf.Abs(givenPosition) + givenHeight > contentTop && Mathf.Abs(givenPosition) + givenHeight < contentBottom) || (Mathf.Abs(givenPosition) > contentTop && Mathf.Abs(givenPosition) < contentBottom)) {
            if (canvasGroup.alpha != 1) {
                canvasGroup.alpha = 1;
            }
            return true;
        } else {
            if (canvasGroup.alpha != 0) {
                canvasGroup.alpha = 0;
            }
            return false;
        }
    }

    static public void Setup(Scroll givenScroll) {
        contentTop = (1 - givenScroll.verticalNormalizedPosition) * (givenScroll.content.rect.height - givenScroll.viewport.rect.height);
        contentBottom = contentTop + givenScroll.viewport.rect.height;
    }
}

SIX: Unity's built in ScrollRect component allows for broad, modular functionality. However, in terms of performance it can be noticeably slower than if you were to write your own. Here is a Scroll script that achieves the same ends, but only supports the vertical, clamped and inertia properties of Unity's ScrollRect.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;

public class Scroll : MonoBehaviour, IPointerDownHandler, IPointerUpHandler, IDragHandler, IScrollHandler {
    private Camera mainCamera;
    private RectTransform canvasRect;
    public RectTransform viewport;
    public RectTransform content;
    private Rect viewportOld;
    private Rect contentOld;

    private List<Vector2> dragCoordinates = new List<Vector2>();
    private List<float> offsets = new List<float>();
    private int offsetsAveraged = 4;
    private float offset;
    private float velocity = 0;
    private bool changesMade = false;

    public float decelration = 0.135f;
    public float scrollSensitivity;
    public OnValueChanged onValueChanged;


    [System.Serializable]
    public class OnValueChanged : UnityEvent { }

    [HideInInspector]
    public float verticalNormalizedPosition
    {
        get
        {
            float sizeDelta = CaculateDeltaSize();
            if (sizeDelta == 0) {
                return 0;
            } else {
                return 1 - content.transform.localPosition.y / sizeDelta;
            }
        }
        set
        {
            float o_verticalNormalizedPosition = verticalNormalizedPosition;
            float m_verticalNormalizedPosition = Mathf.Max(0, Mathf.Min(1, value));
            float maxY = CaculateDeltaSize();
            content.transform.localPosition = new Vector3(content.transform.localPosition.x, Mathf.Max(0, (1 - m_verticalNormalizedPosition) * maxY), content.transform.localPosition.z);
            float n_verticalNormalizedPosition = verticalNormalizedPosition;
            if (o_verticalNormalizedPosition != n_verticalNormalizedPosition) {
                onValueChanged.Invoke();
            }
        }
    }

    private float CaculateDeltaSize() {
        return Mathf.Max(0, content.rect.height - viewport.rect.height); ;
    }


    private void Awake() {
        mainCamera = GameObject.Find("Main Camera").GetComponent<Camera>();
        canvasRect = transform.root.GetComponent<RectTransform>();
    }

    private Vector2 ConvertEventDataDrag(PointerEventData eventData) {
        return new Vector2(eventData.position.x / mainCamera.pixelWidth * canvasRect.rect.width, eventData.position.y / mainCamera.pixelHeight * canvasRect.rect.height);
    }

    private Vector2 ConvertEventDataScroll(PointerEventData eventData) {
        return new Vector2(eventData.scrollDelta.x / mainCamera.pixelWidth * canvasRect.rect.width, eventData.scrollDelta.y / mainCamera.pixelHeight * canvasRect.rect.height) * scrollSensitivity;
    }

    public void OnPointerDown(PointerEventData eventData) {
        velocity = 0;
        dragCoordinates.Clear();
        offsets.Clear();
        dragCoordinates.Add(ConvertEventDataDrag(eventData));
    }

    public void OnScroll(PointerEventData eventData) {
        UpdateOffsetsScroll(ConvertEventDataScroll(eventData));
        OffsetContent(offsets[offsets.Count - 1]);
    }

    public void OnDrag(PointerEventData eventData) {
        dragCoordinates.Add(ConvertEventDataDrag(eventData));
        UpdateOffsetsDrag();
        OffsetContent(offsets[offsets.Count - 1]);
    }

    public void OnPointerUp(PointerEventData eventData) {
        dragCoordinates.Add(ConvertEventDataDrag(eventData));
        UpdateOffsetsDrag();
        OffsetContent(offsets[offsets.Count - 1]);
        float totalOffsets = 0;
        foreach (float offset in offsets) {
            totalOffsets += offset;
        }
        velocity = totalOffsets / offsetsAveraged;
        dragCoordinates.Clear();
        offsets.Clear();
    }

    private void OffsetContent(float givenOffset) {
        float newY = Mathf.Max(0, Mathf.Min(CaculateDeltaSize(), content.transform.localPosition.y + givenOffset));
        if (content.transform.localPosition.y != newY) {
            content.transform.localPosition = new Vector3(content.transform.localPosition.x, newY, content.transform.localPosition.z);
        }
        onValueChanged.Invoke();
    }

    private void UpdateOffsetsDrag() {
        offsets.Add(dragCoordinates[dragCoordinates.Count - 1].y - dragCoordinates[dragCoordinates.Count - 2].y);
        if (offsets.Count > offsetsAveraged) {
            offsets.RemoveAt(0);
        }
    }

    private void UpdateOffsetsScroll(Vector2 givenScrollDelta) {
        offsets.Add(givenScrollDelta.y);
        if (offsets.Count > offsetsAveraged) {
            offsets.RemoveAt(0);
        }
    }

    private void LateUpdate() {
        if (viewport.rect != viewportOld) {
            changesMade = true;
            viewportOld = new Rect(viewport.rect);
        }
        if (content.rect != contentOld) {
            changesMade = true;
            contentOld = new Rect(content.rect);
        }
        if (velocity != 0) {
            changesMade = true;
            velocity = (velocity / Mathf.Abs(velocity)) * Mathf.FloorToInt(Mathf.Abs(velocity) * (1 - decelration));
            offset = velocity;
        }
        if (changesMade) {
            OffsetContent(offset);
            changesMade = false;
            offset = 0;
        }
    }
}