В Unity есть класс Gradient, который предоставляет удобные средства для управления градиентом в рантайме и редакторе. Но т.к. это класс, а не структура использовать его через Job system и burst нельзя. Это первая проблема. Вторая проблема — это работа с ключами градиента. Получение значений осуществляется через массив, который создаётся в куче. И как следствие напрягает сборщик мусора.
Сейчас я покажу как можно решить эти проблемы. И в качестве бонуса получить увеличение производительности до 8 раз при выполнении метода Evaluate через burst.
Структура
Для начала нужно получить прямой доступ к памяти объекта градиента. Этот адрес является неизменяемым на протяжении всей жизни объекта. Получив его один раз можно работать с прямым доступом к данным градиента, не переживая о том, что адрес может измениться.
Вот простейший метод расширения который поможет прочитать адрес, не прибегая к рефлексии при каждом вызове. Из минусов, объект нужно закрепить. Из плюсов адрес нужно получить только один раз поэтому время, затраченное на закрепление объекта не критично.
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();
}
}
Итог
В результате простейших манипуляций я получил прямой доступ к памяти градиента предназначенной для c++ части движка. Отправил указатель на эту память в Job system и смог произвести вычисления внутри задания воспользовавшись всеми преимуществами компилятора burst.
Совместимость
Работоспособность проверена во всех версиях Unity начиная с 2020.3 и заканчивая 2023.2. 0a19.Скорее всего каких-то изменений не будет до тех пор, пока в Unity не решат добавить новые фичи для градиента. За последние годы такое случилось лишь единожды в версии 2022.2. Но я настоятельно рекомендую, прежде чем воспользоваться этим кодом в непроверенных версиях убедиться в его работоспособности.
Ссылка на полную версию
Как и обещал вот ссылка на полный исходный код.
fls_welvet
Отличная статья!