Files
RobustToolbox/Robust.Client/Graphics/Clyde/Clyde.Textures.cs
2024-05-23 22:47:55 +02:00

691 lines
25 KiB
C#

using System;
using System.Buffers;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using OpenToolkit.Graphics.OpenGL4;
using Robust.Client.Utility;
using Robust.Shared.Graphics;
using Robust.Shared.IoC;
using Robust.Shared.Maths;
using Robust.Shared.Utility;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Color = Robust.Shared.Maths.Color;
using OGLTextureWrapMode = OpenToolkit.Graphics.OpenGL.TextureWrapMode;
using PIF = OpenToolkit.Graphics.OpenGL4.PixelInternalFormat;
using PF = OpenToolkit.Graphics.OpenGL4.PixelFormat;
using PT = OpenToolkit.Graphics.OpenGL4.PixelType;
using TextureWrapMode = Robust.Shared.Graphics.TextureWrapMode;
namespace Robust.Client.Graphics.Clyde
{
internal partial class Clyde
{
private ClydeTexture _stockTextureWhite = default!;
private ClydeTexture _stockTextureBlack = default!;
private ClydeTexture _stockTextureTransparent = default!;
private readonly Dictionary<ClydeHandle, LoadedTexture> _loadedTextures = new();
private readonly ConcurrentQueue<ClydeHandle> _textureDisposeQueue = new();
public OwnedTexture LoadTextureFromPNGStream(Stream stream, string? name = null,
TextureLoadParameters? loadParams = null)
{
DebugTools.Assert(_gameThread == Thread.CurrentThread);
// Load using Rgba32.
using var image = Image.Load<Rgba32>(stream);
return LoadTextureFromImage(image, name, loadParams);
}
public OwnedTexture LoadTextureFromImage<T>(Image<T> image, string? name = null,
TextureLoadParameters? loadParams = null) where T : unmanaged, IPixel<T>
{
DebugTools.Assert(_gameThread == Thread.CurrentThread);
var actualParams = loadParams ?? TextureLoadParameters.Default;
var pixelType = typeof(T);
if (!_hasGLTextureSwizzle)
{
// If texture swizzle isn't available we have to pre-process the images to apply it ourselves
// and then upload as RGBA8.
// Yes this is inefficient but the alternative is modifying the shaders,
// which I CBA to do.
// Even 8 year old iGPUs support texture swizzle.
if (pixelType == typeof(A8))
{
// Disable sRGB so stuff doesn't get interpreter wrong.
actualParams.Srgb = false;
using var img = ApplyA8Swizzle((Image<A8>) (object) image);
return LoadTextureFromImage(img, name, loadParams);
}
if (pixelType == typeof(L8) && !actualParams.Srgb)
{
using var img = ApplyL8Swizzle((Image<L8>) (object) image);
return LoadTextureFromImage(img, name, loadParams);
}
}
// Flip image because OpenGL reads images upside down.
using var copy = FlipClone(image);
var texture = CreateBaseTextureInternal<T>(image.Width, image.Height, actualParams, name);
unsafe
{
var span = copy.GetPixelSpan();
fixed (T* ptr = span)
{
// Still bound.
DoTexUpload(copy.Width, copy.Height, actualParams.Srgb, ptr);
}
}
return texture;
}
public unsafe OwnedTexture CreateBlankTexture<T>(
Vector2i size,
string? name = null,
in TextureLoadParameters? loadParams = null)
where T : unmanaged, IPixel<T>
{
var actualParams = loadParams ?? TextureLoadParameters.Default;
if (!_hasGLTextureSwizzle)
{
// Actually create RGBA32 texture if missing texture swizzle.
// This is fine (TexturePixelType that's stored) because all other APIs do the same.
if (typeof(T) == typeof(A8) || typeof(T) == typeof(L8))
{
return CreateBlankTexture<Rgba32>(size, name, loadParams);
}
}
var texture = CreateBaseTextureInternal<T>(
size.X, size.Y,
actualParams,
name);
// Texture still bound, run glTexImage2D with null data param to specify bounds.
DoTexUpload<T>(size.X, size.Y, actualParams.Srgb, null);
return texture;
}
private unsafe void DoTexUpload<T>(int width, int height, bool srgb, T* ptr) where T : unmanaged, IPixel<T>
{
if (sizeof(T) < 4)
{
GL.PixelStore(PixelStoreParameter.UnpackAlignment, 1);
CheckGlError();
}
var (pif, pf, pt) = PixelEnums<T>(srgb);
GL.TexImage2D(TextureTarget.Texture2D, 0, pif, width, height, 0, pf, pt, (IntPtr) ptr);
CheckGlError();
if (sizeof(T) < 4)
{
GL.PixelStore(PixelStoreParameter.UnpackAlignment, 4);
CheckGlError();
}
}
private ClydeTexture CreateBaseTextureInternal<T>(
int width, int height,
in TextureLoadParameters loadParams,
string? name = null)
where T : unmanaged, IPixel<T>
{
var texture = new GLHandle((uint) GL.GenTexture());
CheckGlError();
GL.BindTexture(TextureTarget.Texture2D, texture.Handle);
CheckGlError();
ApplySampleParameters(loadParams.SampleParameters);
var (pif, pf, pt) = PixelEnums<T>(loadParams.Srgb);
var pixelType = typeof(T);
var texPixType = GetTexturePixelType<T>();
var isActuallySrgb = false;
if (pixelType == typeof(Rgba32))
{
isActuallySrgb = loadParams.Srgb;
}
else if (pixelType == typeof(A8))
{
DebugTools.Assert(_hasGLTextureSwizzle);
// TODO: Does it make sense to default to 1 for RGB parameters?
// It might make more sense to pass some options to change swizzling.
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureSwizzleR, (int) All.One);
CheckGlError();
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureSwizzleG, (int) All.One);
CheckGlError();
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureSwizzleB, (int) All.One);
CheckGlError();
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureSwizzleA, (int) All.Red);
CheckGlError();
}
else if (pixelType == typeof(L8) && !loadParams.Srgb)
{
DebugTools.Assert(_hasGLTextureSwizzle);
// Can only use R8 for L8 if sRGB is OFF.
// Because OpenGL doesn't provide sRGB single/dual channel image formats.
// Vulkan when?
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureSwizzleR, (int) All.Red);
CheckGlError();
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureSwizzleG, (int) All.Red);
CheckGlError();
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureSwizzleB, (int) All.Red);
CheckGlError();
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureSwizzleA, (int) All.One);
CheckGlError();
}
else
{
throw new NotSupportedException($"Unable to handle pixel type '{pixelType.Name}'");
}
var pressureEst = EstPixelSize(pif) * width * height;
return GenTexture(texture, (width, height), isActuallySrgb, name, texPixType, pressureEst);
}
private void ApplySampleParameters(TextureSampleParameters? sampleParameters)
{
var actualParams = sampleParameters ?? TextureSampleParameters.Default;
if (actualParams.Filter)
{
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter,
(int) TextureMinFilter.Linear);
CheckGlError();
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter,
(int) TextureMagFilter.Linear);
CheckGlError();
}
else
{
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter,
(int) TextureMinFilter.Nearest);
CheckGlError();
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter,
(int) TextureMagFilter.Nearest);
CheckGlError();
}
switch (actualParams.WrapMode)
{
case TextureWrapMode.None:
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
(int) OGLTextureWrapMode.ClampToEdge);
CheckGlError();
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
(int) OGLTextureWrapMode.ClampToEdge);
CheckGlError();
break;
case TextureWrapMode.Repeat:
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
(int) OGLTextureWrapMode.Repeat);
CheckGlError();
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
(int) OGLTextureWrapMode.Repeat);
CheckGlError();
break;
case TextureWrapMode.MirroredRepeat:
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
(int) OGLTextureWrapMode.MirroredRepeat);
CheckGlError();
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
(int) OGLTextureWrapMode.MirroredRepeat);
CheckGlError();
break;
default:
throw new ArgumentOutOfRangeException();
}
CheckGlError();
}
private (PIF pif, PF pf, PT pt) PixelEnums<T>(bool srgb)
where T : unmanaged, IPixel<T>
{
if (_isGLES2)
{
return default(T) switch
{
Rgba32 => (PIF.Rgba, PF.Rgba, PT.UnsignedByte),
L8 => (PIF.Luminance, PF.Red, PT.UnsignedByte),
_ => throw new NotSupportedException("Unsupported pixel type."),
};
}
return default(T) switch
{
// Note that if _hasGLSrgb is off, we import an sRGB texture as non-sRGB.
// Shaders are expected to compensate for this
Rgba32 => (srgb && _hasGLSrgb ? PIF.Srgb8Alpha8 : PIF.Rgba8, PF.Rgba, PT.UnsignedByte),
A8 or L8 => (PIF.R8, PF.Red, PT.UnsignedByte),
_ => throw new NotSupportedException("Unsupported pixel type."),
};
}
private ClydeTexture GenTexture(
GLHandle glHandle,
Vector2i size,
bool srgb,
string? name,
TexturePixelType pixType,
long memoryPressure = 0)
{
if (name != null)
{
ObjectLabelMaybe(ObjectLabelIdentifier.Texture, glHandle, name);
}
var (width, height) = size;
var id = AllocRid();
var instance = new ClydeTexture(id, size, srgb, this);
var loaded = new LoadedTexture
{
OpenGLObject = glHandle,
Width = width,
Height = height,
IsSrgb = srgb,
Name = name,
MemoryPressure = memoryPressure,
TexturePixelType = pixType
// TextureInstance = new WeakReference<ClydeTexture>(instance)
};
_loadedTextures.Add(id, loaded);
//GC.AddMemoryPressure(memoryPressure);
return instance;
}
private void DeleteTexture(ClydeHandle textureHandle)
{
if (!_loadedTextures.TryGetValue(textureHandle, out var loadedTexture))
{
// Already deleted I guess.
return;
}
GL.DeleteTexture(loadedTexture.OpenGLObject.Handle);
CheckGlError();
_loadedTextures.Remove(textureHandle);
//GC.RemoveMemoryPressure(loadedTexture.MemoryPressure);
}
private unsafe void SetSubImage<T>(
ClydeTexture texture,
Vector2i dstTl,
Image<T> img,
in UIBox2i srcBox)
where T : unmanaged, IPixel<T>
{
if (srcBox.Left < 0 ||
srcBox.Top < 0 ||
srcBox.Right > srcBox.Width ||
srcBox.Bottom > srcBox.Height)
{
throw new ArgumentOutOfRangeException(nameof(srcBox), "Source rectangle out of bounds.");
}
var size = srcBox.Width * srcBox.Height;
T[]? pooled = null;
// C# won't let me use an if due to the stackalloc.
var copyBuffer = size < 16 * 16
? stackalloc T[size]
: (pooled = ArrayPool<T>.Shared.Rent(size)).AsSpan(0, size);
var srcSpan = img.GetPixelSpan();
var w = img.Width;
FlipCopySubRegion(srcBox, w, srcSpan, copyBuffer);
SetSubImageImpl<T>(texture, dstTl, (srcBox.Width, srcBox.Height), copyBuffer);
if (pooled != null)
ArrayPool<T>.Shared.Return(pooled);
}
private unsafe void SetSubImage<T>(
ClydeTexture texture,
Vector2i dstTl,
Vector2i size,
ReadOnlySpan<T> buf)
where T : unmanaged, IPixel<T>
{
T[]? pooled = null;
// C# won't let me use an if due to the stackalloc.
var copyBuffer = buf.Length < 16 * 16
? stackalloc T[buf.Length]
: (pooled = ArrayPool<T>.Shared.Rent(buf.Length)).AsSpan(0, buf.Length);
FlipCopy(buf, copyBuffer, size.X, size.Y);
SetSubImageImpl<T>(texture, dstTl, size, copyBuffer);
if (pooled != null)
ArrayPool<T>.Shared.Return(pooled);
}
private unsafe void SetSubImageImpl<T>(
ClydeTexture texture,
Vector2i dstTl,
Vector2i size,
ReadOnlySpan<T> buf)
where T : unmanaged, IPixel<T>
{
if (!_hasGLTextureSwizzle && (typeof(T) == typeof(A8) || typeof(T) == typeof(L8)))
{
var swizzleBuf = ArrayPool<Rgba32>.Shared.Rent(buf.Length);
var destSpan = swizzleBuf.AsSpan(0, buf.Length);
if (typeof(T) == typeof(A8))
ApplyA8Swizzle(MemoryMarshal.Cast<T, A8>(buf), destSpan);
else if (typeof(T) == typeof(L8))
ApplyL8Swizzle(MemoryMarshal.Cast<T, L8>(buf), destSpan);
SetSubImageImpl<Rgba32>(texture, dstTl, size, destSpan);
ArrayPool<Rgba32>.Shared.Return(swizzleBuf);
return;
}
var loaded = _loadedTextures[texture.TextureId];
var pixType = GetTexturePixelType<T>();
if (pixType != loaded.TexturePixelType)
{
if (loaded.TexturePixelType == TexturePixelType.RenderTarget)
throw new InvalidOperationException("Cannot modify texture for render target directly.");
throw new InvalidOperationException("Mismatching pixel type for texture.");
}
if (loaded.Width < dstTl.X + size.X || loaded.Height < dstTl.Y + size.Y)
throw new ArgumentOutOfRangeException(nameof(size), "Destination rectangle out of bounds.");
if (sizeof(T) != 4)
{
GL.PixelStore(PixelStoreParameter.UnpackAlignment, 1);
CheckGlError();
}
// sRGB doesn't matter since that only changes the internalFormat, which we don't need here.
var (_, pf, pt) = PixelEnums<T>(srgb: false);
GL.BindTexture(TextureTarget.Texture2D, loaded.OpenGLObject.Handle);
CheckGlError();
fixed (T* aPtr = buf)
{
var dstY = loaded.Height - dstTl.Y - size.Y;
GL.TexSubImage2D(
TextureTarget.Texture2D,
0,
dstTl.X, dstY,
size.X, size.Y,
pf, pt,
(IntPtr) aPtr);
CheckGlError();
}
if (sizeof(T) != 4)
{
GL.PixelStore(PixelStoreParameter.UnpackAlignment, 4);
CheckGlError();
}
}
private static TexturePixelType GetTexturePixelType<T>() where T : unmanaged, IPixel<T>
{
return default(T) switch
{
Rgba32 => TexturePixelType.Rgba32,
L8 => TexturePixelType.L8,
A8 => TexturePixelType.A8,
_ => throw new NotSupportedException("Unsupported pixel type."),
};
}
private void LoadStockTextures()
{
var white = new Image<Rgba32>(1, 1);
white[0, 0] = new Rgba32(255, 255, 255, 255);
_stockTextureWhite = (ClydeTexture) Texture.LoadFromImage(white);
var black = new Image<Rgba32>(1, 1);
black[0, 0] = new Rgba32(0, 0, 0, 255);
_stockTextureBlack = (ClydeTexture) Texture.LoadFromImage(black);
var blank = new Image<Rgba32>(1, 1);
blank[0, 0] = new Rgba32(0, 0, 0, 0);
_stockTextureTransparent = (ClydeTexture) Texture.LoadFromImage(blank);
}
/// <summary>
/// Makes a clone of the image that is also flipped.
/// </summary>
private static Image<T> FlipClone<T>(Image<T> source) where T : unmanaged, IPixel<T>
{
var w = source.Width;
var h = source.Height;
var copy = new Image<T>(w, h);
var srcSpan = source.GetPixelSpan();
var dstSpan = copy.GetPixelSpan();
FlipCopy(srcSpan, dstSpan, w, h);
return copy;
}
private static void FlipCopy<T>(ReadOnlySpan<T> srcSpan, Span<T> dstSpan, int w, int h)
{
var dr = h - 1;
for (var r = 0; r < h; r++, dr--)
{
var si = r * w;
var di = dr * w;
var srcRow = srcSpan[si..(si + w)];
var dstRow = dstSpan[di..(di + w)];
srcRow.CopyTo(dstRow);
}
}
private static void FlipCopySubRegion<T>(
UIBox2i srcBox,
int w,
ReadOnlySpan<T> srcSpan,
Span<T> copyBuffer)
where T : unmanaged, IPixel<T>
{
var subH = srcBox.Height;
var subW = srcBox.Width;
var dr = subH - 1;
for (var r = 0; r < subH; r++, dr--)
{
var si = r * w + srcBox.Left;
var di = dr * subW;
var srcRow = srcSpan[si..(si + subW)];
var dstRow = copyBuffer[di..(di + subW)];
srcRow.CopyTo(dstRow);
}
}
private static Image<Rgba32> ApplyA8Swizzle(Image<A8> source)
{
var newImage = new Image<Rgba32>(source.Width, source.Height);
var sourceSpan = source.GetPixelSpan();
var destSpan = newImage.GetPixelSpan();
ApplyA8Swizzle(sourceSpan, destSpan);
return newImage;
}
private static Image<Rgba32> ApplyL8Swizzle(Image<L8> source)
{
var newImage = new Image<Rgba32>(source.Width, source.Height);
var sourceSpan = source.GetPixelSpan();
var destSpan = newImage.GetPixelSpan();
ApplyL8Swizzle(sourceSpan, destSpan);
return newImage;
}
private static void ApplyL8Swizzle(ReadOnlySpan<L8> src, Span<Rgba32> dst)
{
for (var i = 0; i < src.Length; i++)
{
var px = src[i].PackedValue;
dst[i] = new Rgba32(px, px, px, 255);
}
}
private static void ApplyA8Swizzle(ReadOnlySpan<A8> src, Span<Rgba32> dst)
{
for (var i = 0; i < src.Length; i++)
{
var px = src[i].PackedValue;
dst[i] = new Rgba32(255, 255, 255, px);
}
}
private sealed class LoadedTexture
{
public GLHandle OpenGLObject;
public int Width;
public int Height;
public bool IsSrgb;
public string? Name;
public long MemoryPressure;
public TexturePixelType TexturePixelType;
public Vector2i Size => (Width, Height);
// public WeakReference<ClydeTexture> TextureInstance;
}
private enum TexturePixelType : byte
{
RenderTarget = 0,
Rgba32,
A8,
L8,
}
private void FlushTextureDispose()
{
while (_textureDisposeQueue.TryDequeue(out var handle))
{
DeleteTexture(handle);
}
}
internal sealed class ClydeTexture : OwnedTexture
{
private readonly Clyde _clyde;
public readonly bool IsSrgb;
internal ClydeHandle TextureId { get; }
public override void SetSubImage<T>(Vector2i topLeft, Image<T> sourceImage, in UIBox2i sourceRegion)
{
_clyde.SetSubImage(this, topLeft, sourceImage, sourceRegion);
}
public override void SetSubImage<T>(Vector2i topLeft, Vector2i size, ReadOnlySpan<T> buffer)
{
_clyde.SetSubImage(this, topLeft, size, buffer);
}
protected override void Dispose(bool disposing)
{
if (_clyde.IsMainThread())
{
// Main thread, do direct GL deletion.
_clyde.DeleteTexture(TextureId);
}
else
{
// Finalizer thread
_clyde._textureDisposeQueue.Enqueue(TextureId);
}
}
internal ClydeTexture(ClydeHandle id, Vector2i size, bool srgb, Clyde clyde) : base(size)
{
TextureId = id;
IsSrgb = srgb;
_clyde = clyde;
}
public override string ToString()
{
if (_clyde._loadedTextures.TryGetValue(TextureId, out var loaded) && loaded.Name != null)
{
return $"ClydeTexture: {loaded.Name} ({TextureId})";
}
return $"ClydeTexture: ({TextureId})";
}
public override unsafe Color GetPixel(int x, int y)
{
if (!_clyde._loadedTextures.TryGetValue(TextureId, out var loaded))
{
throw new DataException("Texture not found");
}
var curTexture2D = GL.GetInteger(GetPName.TextureBinding2D);
var bufSize = 4 * loaded.Size.X * loaded.Size.Y;
var buffer = ArrayPool<byte>.Shared.Rent(bufSize);
GL.BindTexture(TextureTarget.Texture2D, loaded.OpenGLObject.Handle);
fixed (byte* p = buffer)
{
GL.GetTexImage(TextureTarget.Texture2D, 0, PF.Rgba, PT.UnsignedByte, (IntPtr) p);
}
GL.BindTexture(TextureTarget.Texture2D, curTexture2D);
var pixelPos = (loaded.Size.X * (loaded.Size.Y - y - 1) + x) * 4;
var color = new Color(buffer[pixelPos+0], buffer[pixelPos+1], buffer[pixelPos+2], buffer[pixelPos+3]);
ArrayPool<byte>.Shared.Return(buffer);
return color;
}
}
public Texture GetStockTexture(ClydeStockTexture stockTexture)
{
return stockTexture switch
{
ClydeStockTexture.White => _stockTextureWhite,
ClydeStockTexture.Transparent => _stockTextureTransparent,
ClydeStockTexture.Black => _stockTextureBlack,
_ => throw new ArgumentException(nameof(stockTexture))
};
}
}
}