跳至主要內容

Unity3D中使用SkiaSharp处理Texture2D

MonoLogueChiUnity大约 9 分钟

在 Unity 中,一些时候需要动态修改一些贴图,其中一种方式,就是使用图形处理库,例如 SkiaSharp 或 ImageSharp。这篇文章简单说一下在 Unity 中如何使用 SkiaSharp。

最近一段时间工作比较忙,一直没有更新博客,这一篇文章简单记录一些最近的收获。

这篇文章主要是讲 Texture2D 和 SKBitmap 之间的转换,至于 SkiaSharp 的 API,请自行查阅相关文档。

由于要讲的东西篇幅很长,循序渐进,由浅入深讲解,千万不要看到代码就抄,很多是中间的思考过程。

准备工作

需要安装 SkiaSharpopen in new window ,可以直接使用我的 UnitySkiaSharpopen in new window,相关代码我以及封装好了,这篇文章只是分析一下转换过程。

借助 PNG 编码转换

这是效率最低的一种方式,除非是特殊情况,请不要使用这种方式。

using SkiaSharp;
using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(RawImage))]
public class Example0 : MonoBehaviour
{
  [SerializeField] private Texture2D tex;
  private RawImage _rawImage;

  private void Start()
  {
    _rawImage = GetComponent<RawImage>();

    var pngData0 = tex.EncodeToPNG();
    var bitmap = SKBitmap.Decode(pngData0);

    var pngData1 = bitmap.Encode(SKEncodedImageFormat.Png, 0).ToArray();

    var tex0 = new Texture2D(bitmap.Width, bitmap.Height);
    tex0.LoadImage(pngData1);
    _rawImage.texture = tex0;
    _rawImage.GetComponent<RectTransform>().sizeDelta = new Vector2(tex0.width, tex0.height);
  }
}

运行效果就不展示了。

使用颜色填充

前面介绍的这种方法,需要编码为 png 格式,然后再解码,中间的编码和解码过程需要浪费大量的性能。

试想一下,能否获取图片中的每一个点的颜色,然后再填充到新的图片中。

在此之前,需要先编写一个转换程序,用于在 Unity 和 SkiaSharp 之间转换颜色

using UnityEngine;

namespace SkiaSharp.Unity
{
  /// <summary>
  ///   颜色转换
  /// </summary>
  public static class ColorConverter
  {
    /// <summary>
    ///   转换到Unity颜色
    /// </summary>
    /// <param name="skColorF"></param>
    /// <returns></returns>
    public static Color ToUnityColor(this SKColorF skColorF)
    {
      return new Color(skColorF.Red, skColorF.Green, skColorF.Blue, skColorF.Alpha);
    }

    /// <summary>
    ///   转换到Unity颜色
    /// </summary>
    /// <param name="skColor"></param>
    /// <returns></returns>
    public static Color32 ToUnityColor32(this SKColor skColor)
    {
      return new Color32(skColor.Red, skColor.Green, skColor.Blue, skColor.Alpha);
    }

    /// <summary>
    ///   转换到SKColorF
    /// </summary>
    /// <param name="color"></param>
    /// <returns></returns>
    public static SKColorF ToSkColorF(this Color color)
    {
      return new SKColorF(color.r, color.g, color.b, color.a);
    }

    /// <summary>
    ///   转换到SKColor
    /// </summary>
    /// <param name="color32"></param>
    /// <returns></returns>
    public static SKColor ToSkColor(this Color32 color32)
    {
      return new SKColor(color32.r, color32.g, color32.b, color32.a);
    }
  }
}

然后使用 GetPixels32()SetPixels32() 操作 Texture2D,简单写个示例。

using System.Linq;
using SkiaSharp;
using SkiaSharp.Unity;
using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(RawImage))]
public class Example1 : MonoBehaviour
{
  [SerializeField] private string texPath;
  private RawImage _rawImage;

  private void Start()
  {
    _rawImage = GetComponent<RawImage>();

    var bitmap = SKBitmap.Decode(texPath);

    var colors = bitmap.Pixels.AsParallel().AsOrdered().Select(s => s.ToUnityColor32()).ToArray();

    var tex0 = new Texture2D(bitmap.Width, bitmap.Height);
    tex0.SetPixels32(colors);
    tex0.Apply();

    _rawImage.texture = tex0;
    _rawImage.GetComponent<RectTransform>().sizeDelta = new Vector2(tex0.width, tex0.height);
  }
}

运行完成后,会发现图片是反的。

这是 Unity 中 Texture2D 和 SkiaSharp 图片读取位置不一致导致的。简单画了一张示意图,可以看一下结构。

由图可知,需要调整颜色的像素点顺序。知道原理后,修改一下代码。

using System;
using System.Buffers;
using System.Linq;
using SkiaSharp;
using SkiaSharp.Unity;
using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(RawImage))]
public class Example1 : MonoBehaviour
{
  [SerializeField] private string texPath;
  private RawImage _rawImage;

  private void Start()
  {
    _rawImage = GetComponent<RawImage>();

    var bitmap = SKBitmap.Decode(texPath);

    var skcolors = bitmap.Pixels.AsSpan();

    var writer = new ArrayBufferWriter<SKColor>(bitmap.Width * bitmap.Height);

    for (var i = bitmap.Height - 1; i >= 0; i--) writer.Write(skcolors.Slice(i * bitmap.Width, bitmap.Width));

    var colors = writer.WrittenSpan.ToArray().AsParallel().AsOrdered().Select(s => s.ToUnityColor32()).ToArray();

    var tex0 = new Texture2D(bitmap.Width, bitmap.Height);
    tex0.SetPixels32(colors);
    tex0.Apply();

    _rawImage.texture = tex0;
    _rawImage.GetComponent<RectTransform>().sizeDelta = new Vector2(tex0.width, tex0.height);
  }
}

修改代码,再次运行之后就可以正确加载贴图了。

反过来,从 Texture2D 到 SKBitmap 也是一样的,使用 GetPixels32() 获取数据,这里就不再赘述。

直接填充数据

我们再思考一个问题,能否不去填充像素点的颜色,而是直接填充数据,这样转换效率会更高。

当 SKBitmap 的 ColorType 与 Texture2D 的 TextureFormat,对应时,内存数据一致,调整数据后直接填充进去就可以了。

我给出下面的示例代码,可以看一下。

using System;
using System.Buffers;
using SkiaSharp;
using UnityEngine;
using UnityEngine.UI;

public class Example2 : MonoBehaviour
{
  [SerializeField] private Texture2D tex;
  private RawImage _rawImage;

  private void Start()
  {
    _rawImage = GetComponent<RawImage>();

    // 以下代码都是 已知 tex 的 TextureFormat 为 RGBA32 的情况下

    var bitmap = new SKBitmap(tex.width, tex.height, SKColorType.Rgba8888, SKAlphaType.Opaque);

    var data = tex.GetPixelData<byte>(0).AsReadOnlySpan();

    var writer = new ArrayBufferWriter<byte>(tex.width * tex.height * 4);

    for (var i = tex.height - 1; i >= 0; i--) writer.Write(data.Slice(i * tex.width * 4, tex.width * 4));

    var span = writer.WrittenSpan;

    unsafe
    {
      fixed (byte* ptr = span)
      {
        bitmap.SetPixels((IntPtr)ptr);
      }
    }

    var tex0 = new Texture2D(bitmap.Width, bitmap.Height, TextureFormat.RGBA32, false);

    var data0 = bitmap.GetPixelSpan();
    var writer0 = new ArrayBufferWriter<byte>(bitmap.Width * bitmap.Height * 4);

    for (var i = bitmap.Height - 1; i >= 0; i--) writer0.Write(data0.Slice(i * bitmap.Width * 4, bitmap.Width * 4));

    tex0.SetPixelData(writer0.WrittenSpan.ToArray(), 0);
    tex0.Apply();
    _rawImage.texture = tex0;
    _rawImage.GetComponent<RectTransform>().sizeDelta = new Vector2(tex0.width, tex0.height);
  }
}

从 GPU 读取数据

前面的所有代码,都是基于 Texture 数据在 CPU 内存中编写的,即 texture2d.isReadable = true,如果数据不在 CPU 内存中,则需要在显卡中读取数据。

/// <summary>
///   从GUP读取贴图
/// </summary>
/// <param name="texture"></param>
/// <returns></returns>
public static Texture2D GetTextureFromGpu(this Texture texture)
{
  var width = texture.width;
  var height = texture.height;
  Texture2D tex2;
  if (texture.GetTextureDataFromGpu(out var data))
  {
    tex2 = new Texture2D(width, height, texture.graphicsFormat, TextureCreationFlags.None);
    tex2.SetPixelData(data, 0);
  }
  else
  {
    var renderTexture = new RenderTexture(width, height, 32);
    Graphics.Blit(texture, renderTexture);
    var tmpTexture = RenderTexture.active;
    RenderTexture.active = renderTexture;
    tex2 = new Texture2D(width, height);
    tex2.ReadPixels(new Rect(0, 0, width, height), 0, 0);
    RenderTexture.active = tmpTexture;
  }
  return tex2;
}

/// <summary>
///   从GPU读取数据
/// </summary>
/// <param name="texture"></param>
/// <param name="data"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public static bool GetTextureDataFromGpu(this Texture texture, out NativeArray<byte> data)
{
#if UNITY_2023_2_OR_NEWER
  if (SystemInfo.IsFormatSupported(texture.graphicsFormat, GraphicsFormatUsage.ReadPixels))
#else
  if (SystemInfo.IsFormatSupported(texture.graphicsFormat, FormatUsage.ReadPixels))
#endif
  {
    var request = AsyncGPUReadback.Request(texture, 0, texture.graphicsFormat);
    request.WaitForCompletion();
    if (request.hasError) throw new Exception("");
    data = request.GetData<byte>();
    return true;
  }
  data = new NativeArray<byte>();
  return false;
}

然后修改前面所写的代码,判断是否可读,如果可读就按前面所写的做,如果不可读,就改为从 GPU 获取数据。

使用 Job 和位运算提高效率

NativeArray

要想使用 Job,就一定会需要 NativeArray<T> ,但是阅读上一篇的代码可知,使用更多的是 Span<T>ReadOnlySpan<T>,所以需要转换。

using System;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;

namespace SkiaSharp.Unity
{
  public static class NativeArrayExt
  {
    /// <summary>
    ///   转换到 NativeArray
    /// </summary>
    /// <param name="span"></param>
    /// <typeparam name="T"></typeparam>
    /// <returns></returns>
    public static unsafe NativeArray<T> AsNativeArray<T>(this ReadOnlySpan<T> span) where T : unmanaged
    {
      fixed (void* source = span)
      {
        var data = NativeArrayUnsafeUtility.ConvertExistingDataToNativeArray<T>(source, span.Length, Allocator.None);
#if ENABLE_UNITY_COLLECTIONS_CHECKS
        NativeArrayUnsafeUtility.SetAtomicSafetyHandle(ref data, AtomicSafetyHandle.Create());
#endif
        return data;
      }
    }

    /// <summary>
    ///   转换到 NativeArray
    /// </summary>
    /// <param name="span"></param>
    /// <typeparam name="T"></typeparam>
    /// <returns></returns>
    public static unsafe NativeArray<T> AsNativeArray<T>(this Span<T> span) where T : unmanaged
    {
      fixed (void* source = span)
      {
        var data = NativeArrayUnsafeUtility.ConvertExistingDataToNativeArray<T>(source, span.Length, Allocator.None);
#if ENABLE_UNITY_COLLECTIONS_CHECKS
        NativeArrayUnsafeUtility.SetAtomicSafetyHandle(ref data, AtomicSafetyHandle.Create());
#endif
        return data;
      }
    }
  }
}

使用 Job 并行计算

使用 Job 并行计算,在一定程度上可以加快颜色格式转换效率,而且这里使用了位运算转换颜色格式。

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

namespace SkiaSharp.Unity
{
  /// <summary>
  ///   颜色转换
  /// </summary>
  public static class ColorConverter
  {
    /// <summary>
    ///   转换到 Unity 颜色
    /// </summary>
    /// <param name="skColorF"></param>
    /// <returns></returns>
    public static Color ToUnityColor(this SKColorF skColorF)
    {
      return new Color(skColorF.Red, skColorF.Green, skColorF.Blue, skColorF.Alpha);
    }

    /// <summary>
    ///   转换到 Unity 颜色
    /// </summary>
    /// <param name="skColor"></param>
    /// <returns></returns>
    public static Color32 ToUnityColor32(this SKColor skColor)
    {
      return new Color32(skColor.Red, skColor.Green, skColor.Blue, skColor.Alpha);
    }

    /// <summary>
    ///   转换到 SKColorF
    /// </summary>
    /// <param name="color"></param>
    /// <returns></returns>
    public static SKColorF ToSkColorF(this Color color)
    {
      return new SKColorF(color.r, color.g, color.b, color.a);
    }

    /// <summary>
    ///   转换到 SKColor
    /// </summary>
    /// <param name="color32"></param>
    /// <returns></returns>
    public static SKColor ToSkColor(this Color32 color32)
    {
      return new SKColor(color32.r, color32.g, color32.b, color32.a);
    }

    /// <summary>
    ///   批量转换到 Color32
    /// </summary>
    /// <param name="colors"></param>
    /// <param name="batchCount"></param>
    /// <returns></returns>
    public static NativeArray<Color32> ConvertToColor32(NativeArray<SKColor> colors, int batchCount = 512)
    {
      var handle = FastColorConverter(colors.Reinterpret<uint>(), out var data, batchCount);
      handle.Complete();
      return data.Reinterpret<Color32>();
    }

    /// <summary>
    ///   批量转换到 SKColor
    /// </summary>
    /// <param name="colors"></param>
    /// <param name="batchCount"></param>
    /// <returns></returns>
    public static NativeArray<SKColor> ConvertToSkColor(NativeArray<Color32> colors, int batchCount = 512)
    {
      var handle = FastColorConverter(colors.Reinterpret<uint>(), out var data, batchCount);
      handle.Complete();
      return data.Reinterpret<SKColor>();
    }

    /// <summary>
    ///   快速转换颜色
    /// </summary>
    /// <param name="dataIn"></param>
    /// <param name="dataOut"></param>
    /// <param name="batchCount"></param>
    public static JobHandle FastColorConverter(NativeArray<uint> dataIn, out NativeArray<uint> dataOut, int batchCount = 512)
    {
      dataOut = new NativeArray<uint>(dataIn.Length, Allocator.TempJob);

      var job = new ColorConverterJob
      {
        DataIn = dataIn,
        DataOut = dataOut
      };
      return job.Schedule(dataIn.Length, batchCount);
    }

    [BurstCompile]
    private struct ColorConverterJob : IJobParallelFor
    {
      [ReadOnly] public NativeArray<uint> DataIn;
      public NativeArray<uint> DataOut;

      private const uint Mask0 = 0x00FF0000;
      private const uint Mask1 = 0x000000FF;

      public void Execute(int index)
      {
        var color = DataIn[index];

        DataOut[index] = ((color & Mask0) >> 16) | ((color & Mask1) << 16) | (color & ~(Mask0 | Mask1));
      }
    }
  }
}

贴图转换

using System;
using System.Buffers;
using Unity.Collections;
using UnityEngine;
using UnityEngine.Experimental.Rendering;
using UnityEngine.Rendering;

namespace SkiaSharp.Unity
{
  public static class Texture2DConverter
  {
    public static Texture2D ToTexture2D(this SKBitmap bitmap, int width = 0, int height = 0,
      SKSamplingOptions? options = null)
    {
      var resize = width != 0 || height != 0;

      width = width == 0 ? bitmap.Width : width;
      height = height == 0 ? bitmap.Height : height;

      if (resize) bitmap = bitmap.Resize(new SKSizeI(width, height), options ?? SKSamplingOptions.Default);

      Texture2D texture2D;
      var l = bitmap.ColorType.TryConvertToTextureFormat(out var textureFormat);
      if (l > 0)
      {
        var data = bitmap.GetPixelSpan();

        var writer = new ArrayBufferWriter<byte>(width * height * l);

        for (var i = height - 1; i >= 0; i--) writer.Write(data.Slice(i * width * l, width * l));

        texture2D = new Texture2D(width, height, textureFormat, false);
        texture2D.SetPixelData(writer.WrittenSpan.ToArray(), 0);
      }
      else
      {
        var data0 = bitmap.Pixels.AsSpan();
        var writer = new ArrayBufferWriter<SKColor>();

        for (var i = height - 1; i >= 0; i--) writer.Write(data0.Slice(i * width, width));

        var data1 = writer.WrittenSpan.AsNativeArray();
        var colors = ColorConverter.ConvertToColor32(data1, width * 64);

        texture2D = new Texture2D(width, height, textureFormat, false);
        texture2D.SetPixels32(colors.ToArray());

        data1.Dispose();
        colors.Dispose();
      }

      texture2D.Apply();
      return texture2D;
    }

    public static SKBitmap ToSkBitmap(this Texture2D texture2D, int width = 0, int height = 0,
      SKSamplingOptions? options = null)
    {
      var resize = width != 0 || height != 0;
      width = width == 0 ? texture2D.width : width;
      height = height == 0 ? texture2D.height : height;

      SKBitmap bitmap;
      var l = texture2D.format.TryConvertSkColorTypes(out var skColorType);

      if (l > 0 && texture2D.isReadable)
      {
        ReadOnlySpan<byte> data = texture2D.GetPixelData<byte>(0);

        var writer = new ArrayBufferWriter<byte>(width * height * l);

        for (var i = height - 1; i >= 0; i--) writer.Write(data.Slice(i * width * l, width * l));

        var span = writer.WrittenSpan;
        unsafe
        {
          fixed (byte* ptr = span)
          {
            bitmap = new SKBitmap(width, height, skColorType, SKAlphaType.Premul);
            bitmap.SetPixels((IntPtr)ptr);
          }
        }
      }
      else if (l > 0 && texture2D.GetTextureDataFromGpu(out var textureData))
      {
        ReadOnlySpan<byte> data = textureData;

        var writer = new ArrayBufferWriter<byte>(width * height * l);

        for (var i = height - 1; i >= 0; i--) writer.Write(data.Slice(i * width * l, width * l));

        var span = writer.WrittenSpan;
        unsafe
        {
          fixed (byte* ptr = span)
          {
            bitmap = new SKBitmap(width, height, skColorType, SKAlphaType.Premul);
            bitmap.SetPixels((IntPtr)ptr);
          }
        }
      }
      else
      {
        var data0 = (texture2D.isReadable ? texture2D : texture2D.GetTextureFromGpu()).GetPixels32();
        var data1 = new NativeArray<Color32>(data0, Allocator.TempJob);

        var data2 = ColorConverter.ConvertToSkColor(data1, width * 64);
        var skColors = data2.AsSpan();

        var writer = new ArrayBufferWriter<SKColor>();

        for (var i = height - 1; i >= 0; i--) writer.Write(skColors.Slice(i * width, width));

        bitmap = new SKBitmap(texture2D.width, texture2D.height, skColorType, SKAlphaType.Premul);

        bitmap.Pixels = writer.WrittenSpan.ToArray();
        data1.Dispose();
        data2.Dispose();
      }

      if (resize) bitmap = bitmap.Resize(new SKSizeI(width, height), options ?? SKSamplingOptions.Default);

      return bitmap;
    }

    /// <summary>
    ///   从GUP读取贴图
    /// </summary>
    /// <param name="texture"></param>
    /// <returns></returns>
    public static Texture2D GetTextureFromGpu(this Texture texture)
    {
      var width = texture.width;
      var height = texture.height;
      Texture2D tex2;
      if (texture.GetTextureDataFromGpu(out var data))
      {
        tex2 = new Texture2D(width, height, texture.graphicsFormat, TextureCreationFlags.None);
        tex2.SetPixelData(data, 0);
      }
      else
      {
        var renderTexture = new RenderTexture(width, height, 32);
        Graphics.Blit(texture, renderTexture);
        var tmpTexture = RenderTexture.active;
        RenderTexture.active = renderTexture;
        tex2 = new Texture2D(width, height);
        tex2.ReadPixels(new Rect(0, 0, width, height), 0, 0);
        RenderTexture.active = tmpTexture;
      }

      return tex2;
    }

    /// <summary>
    ///   从GPU读取数据
    /// </summary>
    /// <param name="texture"></param>
    /// <param name="data"></param>
    /// <returns></returns>
    /// <exception cref="Exception"></exception>
    public static bool GetTextureDataFromGpu(this Texture texture, out NativeArray<byte> data)
    {
#if UNITY_2023_2_OR_NEWER
      if (SystemInfo.IsFormatSupported(texture.graphicsFormat, GraphicsFormatUsage.ReadPixels))
#else
      if (SystemInfo.IsFormatSupported(texture.graphicsFormat, FormatUsage.ReadPixels))
#endif
      {
        var request = AsyncGPUReadback.Request(texture, 0, texture.graphicsFormat);
        request.WaitForCompletion();
        if (request.hasError) throw new Exception("");
        data = request.GetData<byte>();
        return true;
      }

      data = new NativeArray<byte>();
      return false;
    }
  }
}
using System;
using UnityEngine;

namespace SkiaSharp.Unity
{
  internal static class ColorTypeConverter
  {
    private static readonly SKColorType[] SkColorTypes =
    {
      SKColorType.Alpha8,
      SKColorType.Rgb565,
      SKColorType.Rgba8888,
      SKColorType.Rgb888x,
      SKColorType.Bgra8888,
      SKColorType.RgbaF16,
      SKColorType.RgbaF16Clamped,
      SKColorType.RgbaF32,
      SKColorType.Rg88,
      SKColorType.RgF16,
      SKColorType.Rg1616,
      SKColorType.Rgba16161616,

      SKColorType.Rgba1010102,
      SKColorType.Rgb101010x,
      SKColorType.Gray8,
      SKColorType.AlphaF16,
      SKColorType.Alpha16,
      SKColorType.Bgra1010102,
      SKColorType.Bgr101010x
    };

    private static readonly TextureFormat[] TextureFormats =
    {
      TextureFormat.Alpha8,
      TextureFormat.RGB565,
      TextureFormat.RGBA32,
      TextureFormat.RGBA32,
      TextureFormat.BGRA32,
      TextureFormat.RGBAHalf,
      TextureFormat.RGBAHalf,
      TextureFormat.RGBAFloat,
      TextureFormat.RG16,
      TextureFormat.RGHalf,
      TextureFormat.RG32,
      TextureFormat.RGBA64,

      TextureFormat.RGBA64,
      TextureFormat.RGBA64,
      TextureFormat.RGBA32,
      TextureFormat.RGBAHalf,
      TextureFormat.RGBA64,
      TextureFormat.RGBA64,
      TextureFormat.RGBA64
    };

    public static readonly int[] LInts = { 1, 2, 4, 4, 4, 8, 8, 16, 2, 4, 4, 8, 0, 0, 0, 0, 0, 0, 0 };


    public static int TryConvertToTextureFormat(this SKColorType skColorType, out TextureFormat textureFormat)
    {
      var index = Array.IndexOf(SkColorTypes, skColorType);
      if (index >= 0)
      {
        textureFormat = TextureFormats[index];
        return LInts[index];
      }

      textureFormat = TextureFormat.RGBA32;
      return 0;
    }

    public static int TryConvertSkColorTypes(this TextureFormat textureFormat, out SKColorType skColorType)
    {
      var index = Array.IndexOf(TextureFormats, textureFormat);
      if (index >= 0)
      {
        skColorType = SkColorTypes[index];
        return LInts[index];
      }

      skColorType = SKColorType.Rgba8888;
      return 0;
    }
  }
}