В Unity есть класс Gradient, который предоставляет удобные средства для управления градиентом в рантайме и редакторе. Но т.к. это класс, а не структура использовать его через Job system и burst нельзя. Это первая проблема. Вторая проблема — это работа с ключами градиента. Получение значений осуществляется через массив, который создаётся в куче. И как следствие напрягает сборщик мусора.

Сейчас я покажу как можно решить эти проблемы. И в качестве бонуса получить увеличение производительности до 8 раз при выполнении метода Evaluate через burst.

Структура

Для начала нужно получить прямой доступ к памяти объекта градиента. Этот адрес является неизменяемым на протяжении всей жизни объекта. Получив его один раз можно работать с прямым доступом к данным градиента, не переживая о том, что адрес может измениться.

m_Ptr – адрес, который нужно получить для доступа к памяти предназначенной для c++ части движка.
m_Ptr – адрес, который нужно получить для доступа к памяти предназначенной для c++ части движка.

Вот простейший метод расширения который поможет прочитать адрес, не прибегая к рефлексии при каждом вызове. Из минусов, объект нужно закрепить. Из плюсов адрес нужно получить только один раз поэтому время, затраченное на закрепление объекта не критично.

public static class GradientExt
{
    private static readonly int m_PtrOffset;
    
    static GradientExt()
    {
        var m_PtrMember = typeof(Gradient).GetField("m_Ptr", BindingFlags.Instance | BindingFlags.NonPublic
        m_PtrOffset = UnsafeUtility.GetFieldOffset(m_PtrMember);
    }
    
    public static unsafe IntPtr Ptr(this Gradient gradient)
    {
        var ptr = (byte*) UnsafeUtility.PinGCObjectAndGetAddress(gradient, out var handle);
        var gradientPtr = *(IntPtr*) (ptr + m_PtrOffset);
        UnsafeUtility.ReleaseGCObject(handle);
        return gradientPtr;
    }
}

UnsafeUtility.GetFieldOffset – возвращает смещение поля относительно структуры или класса, в котором оно содержится.

UnsafeUtility.PinGCObjectAndGetAddress – закрепляет объект. И гарантирует, что объект не будет перемещаться в памяти. Возвращает адрес участка памяти, в котором находится объект.

UnsafeUtility.ReleaseGCObject – освобождает хэндл объекта GC, полученный ранее.

Теперь можно получить адрес на участок памяти, где хранятся данные градиента.

public Gradient gradient;
....
IntPtr gradientPtr = gradient.Ptr();

Дальше нужно немножко поковырять память чтобы понять как именно расположены данные градиента. Для этого я выведу в инспектор Unity этот участок памяти в виде массива. Затем остаётся лишь изменять градиент и смотреть какие именно участки это затрагивает.

[ExecuteAlways]
public class MemoryResearch : MonoBehaviour
{
    public Gradient gradient = new Gradient();
    public float[] gradientMemoryLocation = new float[50];
    private static unsafe void CopyMemory<T>(Gradient gradient, T[] gradientMemoryLocation) where T : unmanaged
    {
        IntPtr gradientPtr = gradient.Ptr();
        fixed (T* gradientMemoryLocationPtr = gradientMemoryLocation)
            UnsafeUtility.MemCpy(gradientMemoryLocationPtr, (void*) gradientPtr, gradientMemoryLocation.Length);
    }
    
    private void Update()
    {
        CopyMemory(gradient, gradientMemoryLocation);
    }
}

UnsafeUtility.MemCpy – копирует указанное количество байт из одной области памяти в другую.

Демонстрация того, как меняются значения в памяти при изменении цвета.
Демонстрация того, как меняются значения в памяти при изменении цвета.

Путём нехитрых манипуляций и смены типа памяти float/ushort/byte и т.д. я нашёл полное расположение каждого параметра градиента. В статье буду приводить примеры для Unity 22.3, но есть небольшие различия для разных версий. С полной версией кода можно ознакомится в конце статьи.

//Позиции ключей хранятся как ushort где 0 = 0%, а 65535 = 100%.
public unsafe struct GradientStruct
{
    private fixed byte colors[sizeof(float) * 4 * 8]; //8 rgba цветовых значений (128 байт)
    private fixed byte colorTimes[sizeof(ushort) * 8]; //время для каждого цветового ключа (16 байт)
    private fixed byte alphaTimes[sizeof(ushort) * 8]; //время для каждого альфа ключа (16 байт)
    private byte colorCount; //количество цветовых ключей
    private byte alphaCount; //количество альфа ключей
    private byte mode; //режим смешивания цветов
    private byte colorSpace; //цветовое пространство
}

Также добавляю метод расширения для получения указателя на структуру GradientStruct:

public static unsafe GradientStruct* DirectAccess(this Gradient gradient)
{
    return (GradientStruct*) gradient.Ptr();
}

Gradient.colorKeys через NativeArray

Зная структуру памяти градиента, можно написать методы для работы с Gradient.colorKeys и Gradient.alphaKeys через NativeArray.

private float4* Colors(int index)
{
    fixed(byte* colorsPtr = colors) return (float4*) colorsPtr + index;
}

private ushort* ColorsTimes(int index)
{
    fixed(byte* colorTimesPtr = colorTimes) return (ushort*) colorTimesPtr + index;
}

private ushort* AlphaTimes(int index)
{
    fixed(byte* alphaTimesPtr = alphaTimes) return (ushort*) alphaTimesPtr + index;
}

public void SetColorKey(int index, GradientColorKeyBurst value)
{
    #if ENABLE_UNITY_COLLECTIONS_CHECKS
        if (index < 0 || index > 7) IncorrectIndex();
    #endif

    Colors(index)->xyz = value.color.xyz;
    *ColorsTimes(index) = (ushort) (65535 * value.time);
}

public GradientColorKeyBurst GetColorKey(int index)
{
    #if ENABLE_UNITY_COLLECTIONS_CHECKS
        if (index < 0 || index > 7) IncorrectIndex();
    #endif

    return new GradientColorKeyBurst(*Colors(index), *ColorsTimes(index) / 65535f);
}

public void SetColorKeys(NativeArray<GradientColorKeyBurst> colorKeys)
{
    #if ENABLE_UNITY_COLLECTIONS_CHECKS
        if (colorKeys.Length < 2 || colorKeys.Length > 8) IncorrectLength();
    #endif

    var colorKeysTmp = new NativeArray<GradientColorKeyBurst>(colorKeys, Allocator.Temp);
    colorKeysTmp.Sort<GradientColorKeyBurst, GradientColorKeyBurstComparer>(default);

    colorCount = (byte) colorKeys.Length;
    
    for (var i = 0; i < colorCount; i++)
    {
        SetColorKey(i, colorKeysTmp[i]);
    }

    colorKeysTmp.Dispose();
}

public void SetColorKeysWithoutSort(NativeArray<GradientColorKeyBurst> colorKeys)
{
    #if ENABLE_UNITY_COLLECTIONS_CHECKS
        if (colorKeys.Length < 2 || colorKeys.Length > 8) IncorrectLength();
    #endif

    colorCount = (byte) colorKeys.Length;
    
    for (var i = 0; i < colorCount; i++)
    {
        SetColorKey(i, colorKeys[i]);
    }
}

public NativeArray<GradientColorKeyBurst> GetColorKeys(Allocator allocator)
{
    var colorKeys = new NativeArray<GradientColorKeyBurst>(colorCount, allocator);
    
    for (var i = 0; i < colorCount; i++)
    {
        colorKeys[i] = GetColorKey(i);
    }

    return colorKeys;
}

public void SetAlphaKey(int index, GradientAlphaKey value)
{
    #if ENABLE_UNITY_COLLECTIONS_CHECKS
        if (index < 0 || index > 7) IncorrectIndex();
    #endif

    Colors(index)->w = value.alpha;
    *AlphaTimes(index) = (ushort) (65535 * value.time);
}

public GradientAlphaKey GetAlphaKey(int index)
{
    #if ENABLE_UNITY_COLLECTIONS_CHECKS
        if (index < 0 || index > 7) IncorrectIndex();
    #endif

    return new GradientAlphaKey(Colors(index)->w, *AlphaTimes(index) / 65535f);
}

public void SetAlphaKeys(NativeArray<GradientAlphaKey> alphaKeys)
{
    #if ENABLE_UNITY_COLLECTIONS_CHECKS
        if (alphaKeys.Length < 2 || alphaKeys.Length > 8) IncorrectLength();
    #endif

    var alphaKeysTmp = new NativeArray<GradientAlphaKey>(alphaKeys, Allocator.Temp);
    alphaKeysTmp.Sort<GradientAlphaKey, GradientAlphaKeyComparer>(default);

    alphaCount = (byte) alphaKeys.Length;
    
    for (var i = 0; i < alphaCount; i++)
    {
        SetAlphaKey(i, alphaKeys[i]);
    }

    alphaKeysTmp.Dispose();
}

public void SetAlphaKeysWithoutSort(NativeArray<GradientAlphaKey> alphaKeys)
{
    #if ENABLE_UNITY_COLLECTIONS_CHECKS
        if (alphaKeys.Length < 2 || alphaKeys.Length > 8) IncorrectLength();
    #endif

    alphaCount = (byte) alphaKeys.Length;
    
    for (var i = 0; i < alphaCount; i++)
    {
        SetAlphaKey(i, alphaKeys[i]);
    }
}

public NativeArray<GradientAlphaKey> GetAlphaKeys(Allocator allocator)
{
    var alphaKeys = new NativeArray<GradientAlphaKey>(alphaCount, allocator);
    
    for (var i = 0; i < alphaCount; i++)
    {
        alphaKeys[i] = GetAlphaKey(i);
    }

    return alphaKeys;
}

private struct GradientColorKeyBurstComparer : IComparer<GradientColorKeyBurst>
{
    public int Compare(GradientColorKeyBurst v1, GradientColorKeyBurst v2)
    {
        return v1.time.CompareTo(v2.time);
    }
}

private struct GradientAlphaKeyComparer : IComparer<GradientAlphaKey>
{
    public int Compare(GradientAlphaKey v1, GradientAlphaKey v2)
    {
        return v1.time.CompareTo(v2.time);
    }
}

В результате

var colorKeys = gradient.colorKeys;
var alphaKeys = gradient.alphaKeys;

можно заменить на

var gradientPtr = gradient.DirectAccess();
var colorKeys = gradientPtr->GetColorKeys(Allocator.Temp);
var alphaKeys = gradientPtr->GetAlphaKeys(Allocator.Temp);

и забыть о сборщике мусора при чтении значений. А также использовать эти методы внутри Job system. Результат gradient.DirectAccess() можно закешировать и использовать на протяжении всей жизни объекта.

Финальная подготовка для Job system

Нужно сделать свою реализацию метода Evaluate, потому что нативный метод остался вместе с классом за пределами досягаемости из новой структуры. Вдаваться в подробности алгоритма не буду. Он слишком тривиален и не имеет отношения к теме статьи.

public float4 Evaluate(float time)
{
    float3 color = default;
    var colorCalculated = false;
    var colorKey = GetColorKeyBurst(0);
    if (time <= colorKey.time)
    {
        color = colorKey.color.xyz;
        colorCalculated = true;
    }
    
    if (!colorCalculated)
        for (var i = 0; i < colorCount - 1; i++)
        {
            var colorKeyNext = GetColorKeyBurst(i + 1);
                
            if (time <= colorKeyNext.time)
            {
                if (Mode == GradientMode.Blend)
                {
                    var localTime = (time - colorKey.time) / (colorKeyNext.time - colorKey.time);
                    color = math.lerp(colorKey.color.xyz, colorKeyNext.color.xyz, localTime);
                }
                else if (Mode == GradientMode.PerceptualBlend)
                {
                    var localTime = (time - colorKey.time) / (colorKeyNext.time - colorKey.time);
                    color = OklabToLinear(math.lerp(LinearToOklab(colorKey.color.xyz), LinearToOklab(colorKeyNext.color.xyz), localTime));
                }
                else
                {
                    color = colorKeyNext.color.xyz;
                }
                colorCalculated = true;
                break;
            }
            
            colorKey = colorKeyNext;
        }
  
    if (!colorCalculated) color = colorKey.color.xyz;
    
    
    
    float alpha = default;
    var alphaCalculated = false;
    
    var alphaKey = GetAlphaKey(0);
    if (time <= alphaKey.time)
    {
        alpha = alphaKey.alpha;
        alphaCalculated = true;
    }
    
    if (!alphaCalculated)
        for (var i = 0; i < alphaCount - 1; i++)
        {
            var alphaKeyNext = GetAlphaKey(i + 1);
                
            if (time <= alphaKeyNext.time)
            {
                if (Mode == GradientMode.Blend || Mode == GradientMode.PerceptualBlend)
                {
                    var localTime = (time - alphaKey.time) / (alphaKeyNext.time - alphaKey.time);
                    alpha = math.lerp(alphaKey.alpha, alphaKeyNext.alpha, localTime);
                }
                else
                {
                    alpha = alphaKeyNext.alpha;
                }
                alphaCalculated = true;
                break;
            }
            
            alphaKey = alphaKeyNext;
        }
    
    if (!alphaCalculated) alpha = alphaKey.alpha;
        
    return new float4(color, alpha);
}

Многопоточность

Полученная выше структура умеет как читать значения, так и писать их. Если попытаться её использовать одновременно в разных потоках для записи, то будет Race Conditions. Никогда не используйте её для многопоточных заданий. Для этого я подготовлю readonly версию.

internal unsafe struct GradientStruct
{

  ...
  
  public static ReadOnly AsReadOnly(GradientStruct* data) => new ReadOnly(data);

  public readonly struct ReadOnly
  {
      private readonly GradientStruct* ptr;
      public ReadOnly(GradientStruct* ptr)
      {
          this.ptr = ptr;
      }
      
      public int ColorCount => ptr->ColorCount;
      
      public int AlphaCount => ptr->AlphaCount;
      
      public GradientMode Mode => ptr->Mode;
      
      #if UNITY_2022_2_OR_NEWER
          public ColorSpace ColorSpace => ptr->ColorSpace;
      #endif
      
      public GradientColorKeyBurst GetColorKey(int index) => ptr->GetColorKey(index);
      
      public NativeArray<GradientColorKeyBurst> GetColorKeys(Allocator allocator) => ptr->GetColorKeys(allocator);
      
      public GradientAlphaKey GetAlphaKey(int index) => ptr->GetAlphaKey(index);
      
      public NativeArray<GradientAlphaKey> GetAlphaKeys(Allocator allocator) => ptr->GetAlphaKeys(allocator);
      
      public float4 Evaluate(float time)=> ptr->Evaluate(time);
  }
}

И метод расширения:

public static unsafe GradientStruct.ReadOnly DirectAccessReadOnly(this Gradient gradient)
{
    return GradientStruct.AsReadOnly(gradient.DirectAccess());
}

Эту структуру для чтения точно также достаточно создать один раз и можно передать в любое многопоточное задание или использовать где-то ещё на протяжении всей жизни объекта.

Пример использования:

var gradientReadOnly = gradient.DirectAccessReadOnly();
var colorKeys = gradientReadOnly.GetColorKeys(Allocator.Temp);
var alphaKeys = gradientReadOnly.GetAlphaKeys(Allocator.Temp);
var color = gradientReadOnly.Evaluate(0.6f);
colorKeys.Dispose();
alphaKeys.Dispose();

Тест производительности

Для тестов использован процессор с поддержкой AVX2. Данным тестом я не ставил цель показать максимально объективные результаты. Но тенденция должны быть понятна. Суть теста: в одном потоке делается сто тысяч итераций и вычисляется цвет градиента с помощью метода Evaluate. Во всех режимах интерполяции с большим отрывом лидирует кастомная реализация. Огромный overhead при вызове c++ метода из c# даёт о себе знать.

public class PerformanceTest : MonoBehaviour
{
    public Gradient gradient = new Gradient();
    
    [BurstCompile(OptimizeFor = OptimizeFor.Performance)]
    private unsafe struct GradientBurstJob : IJob
    {
        public NativeArray<float4> result;
        [NativeDisableUnsafePtrRestriction] public GradientStruct* gradient;
        
        public void Execute()
        {
            var time = 1f;
            var color = float4.zero;
            
            for (var i = 0; i < 100000; i++)
            {
                time *= 0.9999f;
                color += gradient->Evaluate(time);
            }
            result[0] = color;
        }
    }
    
    private unsafe void Update()
    {
        var nativeArrayResult = new NativeArray<float4>(1, Allocator.TempJob);
        var job = new GradientBurstJob
        {
            result = nativeArrayResult,
            gradient = gradient.DirectAccess()
        };
        var jobHandle = job.ScheduleByRef();
        JobHandle.ScheduleBatchedJobs();
        
        Profiler.BeginSample("NativeGradient");
        var time = 1f;
        var result = new Color(0, 0, 0, 0);
        
        for (var i = 0; i < 100000; i++)
        {
            time *= 0.9999f;
            result += gradient.Evaluate(time);
        }
        Profiler.EndSample();
        
        jobHandle.Complete();
        nativeArrayResult.Dispose();
    }
}
Режим интерполяции Fixed.
Режим интерполяции Fixed.
Режим интерполяции Blend.
Режим интерполяции Blend.
Режим интерполяции PerceptualBlend.
Режим интерполяции PerceptualBlend.

Итог

В результате простейших манипуляций я получил прямой доступ к памяти градиента предназначенной для c++ части движка. Отправил указатель на эту память в Job system и смог произвести вычисления внутри задания воспользовавшись всеми преимуществами компилятора burst.

Совместимость

Работоспособность проверена во всех версиях Unity начиная с 2020.3 и заканчивая 2023.2. 0a19.Скорее всего каких-то изменений не будет до тех пор, пока в Unity не решат добавить новые фичи для градиента. За последние годы такое случилось лишь единожды в версии 2022.2. Но я настоятельно рекомендую, прежде чем воспользоваться этим кодом в непроверенных версиях убедиться в его работоспособности.

Ссылка на полную версию

Как и обещал вот ссылка на полный исходный код.

Комментарии (1)


  1. fls_welvet
    18.09.2023 06:16

    Отличная статья!