博客 / 詳情

返回

dotnet Vortice 通過 Angle 將 Skia 和 DirectX 對接

ANGLE 是谷歌開源的組件,提供將 OpenGL ES API 調用轉換為實際調用 DirectX 引擎執行渲染的能力。詳細請看: https://github.com/google/angle

整體的步驟是:

  1. 基礎且通用地創建 Win32 窗口
  2. 初始化 DirectX 相關,包括創建 DirectX 工廠和 DirectX 設備,枚舉顯示適配器等
  3. 初始化 Angle 和與 DirectX 對接

開始之前,按照 .NET 的慣例,先安裝必要的 NuGet 庫。安裝之後的 csproj 項目文件代碼如下

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <IsAotCompatible>true</IsAotCompatible>
    <PublishAot>true</PublishAot>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Vortice.Direct3D11" Version="3.8.2" />
    <PackageReference Include="Vortice.DirectComposition" Version="3.8.2" />
    <PackageReference Include="Vortice.DXGI" Version="3.8.2" />
    <PackageReference Include="Vortice.Win32" Version="2.3.0" />

    <PackageReference Include="Avalonia.Angle.Windows.Natives" Version="2.1.25547.20250602" />

    <PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.257">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    </PackageReference>

    <PackageReference Include="SkiaSharp" Version="3.119.1" />

    <PackageReference Include="MicroCom.Runtime" Version="0.11.0" />

  </ItemGroup>

</Project>

在本文的過程中,需要用到一些 Win32 方法和 OpenGL 等相關定義。我使用 CsWin32 庫輔助定義 Win32 方法,再從 Avalonia 拷貝 OpenGL 相關定義

本文在正文部分只提供關鍵的代碼。在本文末尾部分貼出 Program.cs 的完全代碼,本文的核心邏輯都在 Program.cs 裏面實現,核心代碼大概 300 多行,適合一口氣閲讀。其他輔助代碼,如 OpenGL 定義類等,就還請大家從本文末尾提供的整個代碼項目的下載方法進行下載獲取代碼

創建窗口

創建 Win32 窗口僅僅只是想拿到窗口句柄,不是本文重點,這裏就忽略 CreateWindow 方法的實現

        // 創建窗口
        HWND window = CreateWindow();
        // 顯示窗口
        ShowWindow(window, SHOW_WINDOW_CMD.SW_NORMAL);

以上代碼的 ShowWindow 是標準的 Win32 方法,由 CsWin32 庫生成。定義如下

		[DllImport("USER32.dll", ExactSpelling = true),DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
		[SupportedOSPlatform("windows5.0")]
		internal static extern winmdroot.Foundation.BOOL ShowWindow(winmdroot.Foundation.HWND hWnd, winmdroot.UI.WindowsAndMessaging.SHOW_WINDOW_CMD nCmdShow);

為了直接使用方法,在本文這裏直接在命名空間引用靜態類,代碼如下

using static Windows.Win32.PInvoke;

初始化 DirectX 相關

在對接過程中,對 DirectX 層沒有明確的要求。這是因為從 Angle 方面來説,只要求輸入是一個紋理。調用 Angle 的 eglCreatePbufferFromClientBuffer 將 D3D11 紋理進行包裝

本文這裏採用標準的 DXGI 交換鏈的寫法。其中核心關鍵點在於設置顏色格式為 B8G8R8A8_UNorm 格式,代碼如下

using Vortice.Direct3D;
using Vortice.Direct3D11;
using Vortice.DXGI;
using Vortice.Mathematics;

...

        var dxgiFactory2 = DXGI.CreateDXGIFactory1<IDXGIFactory2>();

        IDXGIAdapter1? hardwareAdapter = GetHardwareAdapter(dxgiFactory2)
            // 這裏 ToList 只是想列出所有的 IDXGIAdapter1 在實際代碼裏,大部分都是獲取第一個
            .ToList().FirstOrDefault();
        if (hardwareAdapter == null)
        {
            throw new InvalidOperationException("Cannot detect D3D11 adapter");
        }

        FeatureLevel[] featureLevels = new[]
        {
            FeatureLevel.Level_11_1,
            FeatureLevel.Level_11_0,
            FeatureLevel.Level_10_1,
            FeatureLevel.Level_10_0,
            FeatureLevel.Level_9_3,
            FeatureLevel.Level_9_2,
            FeatureLevel.Level_9_1,
        };

        IDXGIAdapter1 adapter = hardwareAdapter;
        DeviceCreationFlags creationFlags = DeviceCreationFlags.BgraSupport;
        var result = D3D11.D3D11CreateDevice
        (
            adapter,
            DriverType.Unknown,
            creationFlags,
            featureLevels,
            out ID3D11Device d3D11Device, out FeatureLevel featureLevel,
            out ID3D11DeviceContext d3D11DeviceContext
        );

        _ = featureLevel;

        result.CheckError();

        // 大部分情況下,用的是 ID3D11Device1 和 ID3D11DeviceContext1 類型
        // 從 ID3D11Device 轉換為 ID3D11Device1 類型
        ID3D11Device1 d3D11Device1 = d3D11Device.QueryInterface<ID3D11Device1>();
        var d3D11DeviceContext1 = d3D11DeviceContext.QueryInterface<ID3D11DeviceContext1>();
        _ = d3D11DeviceContext1;

        // 獲取到了新的兩個接口,就可以減少 `d3D11Device` 和 `d3D11DeviceContext` 的引用計數。調用 Dispose 不會釋放掉剛才申請的 D3D 資源,只是減少引用計數
        d3D11Device.Dispose();
        d3D11DeviceContext.Dispose();

        RECT windowRect;
        GetClientRect(window, &windowRect);
        var clientSize = new SizeI(windowRect.right - windowRect.left, windowRect.bottom - windowRect.top);

        // 顏色格式有要求,才能和 Angle 正確交互
        Format colorFormat = Format.B8G8R8A8_UNorm;

        // 緩存的數量,包括前緩存。大部分應用來説,至少需要兩個緩存,這個玩過遊戲的夥伴都知道
        const int frameCount = 2;
        SwapChainDescription1 swapChainDescription = new()
        {
            Width = (uint)clientSize.Width,
            Height = (uint)clientSize.Height,
            Format = colorFormat, // B8G8R8A8_UNorm
            BufferCount = frameCount,
            BufferUsage = Usage.RenderTargetOutput,
            SampleDescription = SampleDescription.Default,
            Scaling = Scaling.Stretch,
            SwapEffect = SwapEffect.FlipSequential,
            AlphaMode = AlphaMode.Ignore,
            Flags = SwapChainFlags.None,
        };

        var fullscreenDescription = new SwapChainFullscreenDescription()
        {
            Windowed = true,
        };

        IDXGISwapChain1 swapChain =
            dxgiFactory2.CreateSwapChainForHwnd(d3D11Device1, window, swapChainDescription, fullscreenDescription);

        // 不要被按下 alt+enter 進入全屏
        dxgiFactory2.MakeWindowAssociation(window,
            WindowAssociationFlags.IgnoreAltEnter | WindowAssociationFlags.IgnorePrintScreen);

以上涉及到的 DirectX 細節部分,如果大家有興趣,還請參閲 DirectX 使用 Vortice 從零開始控制枱創建 Direct2D1 窗口修改顏色 博客

拿到交換鏈之後,就可以非常方便地取出 ID3D11Texture2D 紋理,代碼如下

        // 先從交換鏈取出渲染目標紋理
        ID3D11Texture2D d3D11Texture2D = swapChain.GetBuffer<ID3D11Texture2D>(0);
        Debug.Assert(d3D11Texture2D.Description.Width == clientSize.Width);
        Debug.Assert(d3D11Texture2D.Description.Height == clientSize.Height);

現在拿到紋理了,接下來將初始化 Angle 相關,將 ID3D11Texture2D 紋理進行對接

初始化 Angle 相關

初始化 Angle 的過程,也在初始化 OpenGL 相關模塊。這裏用到了一些在 Avalonia 封裝好的方法,為了方便理解,我將這部分關鍵的邏輯拆出來

先是定義 EglInterface 類,核心代碼如下

public unsafe partial class EglInterface
{
    public EglInterface(Func<string, IntPtr> getProcAddress)
    {
        Initialize(getProcAddress);
    }

    [GetProcAddress("eglGetError")]
    public partial int GetError();

    [GetProcAddress("eglGetDisplay")]
    public partial IntPtr GetDisplay(IntPtr nativeDisplay);

    [GetProcAddress("eglMakeCurrent")]
    public partial bool MakeCurrent(IntPtr display, IntPtr draw, IntPtr read, IntPtr context);

    ...

    public string? QueryString(IntPtr display, int i)
    {
        var rv = QueryStringNative(display, i);
        if (rv == IntPtr.Zero)
            return null;
        return Marshal.PtrToStringAnsi(rv);
    }

    ...

}

以上這些標記了 GetProcAddressAttribute 特性的方法都是在 Avalonia 裏通過源代碼生成器生成具體實現,在本文這裏為了簡化邏輯,就拷貝了源代碼生成器生成之後的代碼,代碼內容大概如下

unsafe partial class EglInterface
{
    delegate* unmanaged[Stdcall]<int> _addr_GetError;    
    public partial int GetError()
    {
        return _addr_GetError();
    }

    delegate* unmanaged[Stdcall]<nint, nint> _addr_GetDisplay;
    public partial nint GetDisplay(nint @nativeDisplay)
    {
        return _addr_GetDisplay(@nativeDisplay);
    }

    ...

    void Initialize(Func<string, IntPtr> getProcAddress)
    {
        var addr = IntPtr.Zero;

        // Initializing GetError
        addr = IntPtr.Zero;
        addr = getProcAddress("eglGetError");
        if (addr == IntPtr.Zero) throw new System.EntryPointNotFoundException("_addr_GetError");
        _addr_GetError = (delegate* unmanaged[Stdcall]<int>) addr;

        // Initializing GetDisplay
        addr = IntPtr.Zero;
        addr = getProcAddress("eglGetDisplay");
        if (addr == IntPtr.Zero) throw new System.EntryPointNotFoundException("_addr_GetDisplay");
        _addr_GetDisplay = (delegate* unmanaged[Stdcall]<nint, nint>) addr;

         ...
    }
}

通過以上邏輯可以知道,在 EglInterface 構造函數傳入的 Func<string, IntPtr> getProcAddress 參數就決定了如何根據傳入的方法名,獲取方法指針的能力。在 Initialize 方法裏面,將填充各個方法指針內容,且完成分部方法的實現

也許有夥伴好奇 Func<string, IntPtr> getProcAddress 參數的具體邏輯,這其實很簡單,只是調用 Angle 庫獲取方法對應的方法指針而已。具體實現代碼放在 Win32AngleEglInterface 類裏面,代碼如下

internal partial class Win32AngleEglInterface : EglInterface
{
    [DllImport("av_libGLESv2.dll", CharSet = CharSet.Ansi)]
    static extern IntPtr EGL_GetProcAddress(string proc);

    public Win32AngleEglInterface() : this(LoadAngle())
    {

    }

    private Win32AngleEglInterface(Func<string, IntPtr> getProcAddress) : base(getProcAddress)
    {
        Initialize(getProcAddress);
    }

    [GetProcAddress("eglCreateDeviceANGLE", true)]
    public partial IntPtr CreateDeviceANGLE(int deviceType, IntPtr nativeDevice, int[]? attribs);

    [GetProcAddress("eglReleaseDeviceANGLE", true)]
    public partial void ReleaseDeviceANGLE(IntPtr device);

    static Func<string, IntPtr> LoadAngle()
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            var disp = EGL_GetProcAddress("eglGetPlatformDisplayEXT");

            if (disp == IntPtr.Zero)
            {
                throw new OpenGlException("libegl.dll doesn't have eglGetPlatformDisplayEXT entry point");
            }

            return EGL_GetProcAddress;
        }

        throw new PlatformNotSupportedException();
    }
}

可見就是直接通過 av_libGLESv2.dllEGL_GetProcAddress 導出 C 函數而已

完成 Win32AngleEglInterface 之後,就可以順帶使用此類型輔助創建 ANGLE 設備,代碼如下

        var egl = new Win32AngleEglInterface();
        // 傳入 ID3D11Device1 的指針,將 D3D11 設備和 AngleDevice 綁定
        var angleDevice = egl.CreateDeviceANGLE(EglConsts.EGL_D3D11_DEVICE_ANGLE, d3D11Device1.NativePointer, null);
        var display = egl.GetPlatformDisplayExt(EglConsts.EGL_PLATFORM_DEVICE_EXT, angleDevice, null);

在創建設備的時候,將 D3D11 設備傳入用於綁定

以上代碼通過 GetPlatformDisplayExt 獲取到 ANGLE 的 display 但現在還不着急使用,先學着 Avalonia 將其封裝到 AngleWin32EglDisplay 類型裏面,核心定義代碼如下

class AngleWin32EglDisplay : EglDisplay
{
    public AngleWin32EglDisplay(IntPtr angleDisplay, Win32AngleEglInterface egl) : base(angleDisplay, egl)
    {
        _angleDisplay = angleDisplay;
        _egl = egl;

        ...
    }

    private readonly IntPtr _angleDisplay;

    private readonly Win32AngleEglInterface _egl;

    public unsafe EglSurface WrapDirect3D11Texture(IntPtr handle, int offsetX, int offsetY, int width, int height)
    {
        var attrs = stackalloc[]
        {
            EGL_WIDTH, width, EGL_HEIGHT, height, EGL_TEXTURE_OFFSET_X_ANGLE, offsetX,
            EGL_TEXTURE_OFFSET_Y_ANGLE, offsetY,
            _flexibleSurfaceSupported ? EGL_FLEXIBLE_SURFACE_COMPATIBILITY_SUPPORTED_ANGLE : EGL_NONE, EGL_TRUE,
            EGL_NONE
        };

        return CreatePBufferFromClientBuffer(EGL_D3D_TEXTURE_ANGLE, handle, attrs);
    }

    ...
}

public class EglDisplay : IDisposable
{
    public EglDisplay(IntPtr display, EglInterface eglInterface)
    {
        EglInterface = eglInterface;
        _display = display;

        _config = InitializeAndGetConfig(display, eglInterface);
    }

    public IntPtr Config => _config.Config;

    private readonly EglConfigInfo _config;

    private static EglConfigInfo InitializeAndGetConfig(IntPtr display, EglInterface eglInterface)
    {
        ...
    }

    public EglInterface EglInterface { get; }

    public IntPtr Handle => _display;

    private IntPtr _display;

    public Lock.Scope Lock() => _lock.EnterScope();

    private readonly Lock _lock = new();

    public unsafe EglSurface CreatePBufferFromClientBuffer(int bufferType, IntPtr handle, int* attribs)
    {
        using (Lock())
        {
            var s = EglInterface.CreatePbufferFromClientBufferPtr(Handle, bufferType, handle,
                Config, attribs);

            if (s == IntPtr.Zero)
                throw OpenGlException.GetFormattedException("eglCreatePbufferFromClientBuffer", EglInterface);
            return new EglSurface(this, s);
        }
    }
}

以上代碼的 EglConfigInfo 類型定義如下

internal class EglConfigInfo
{
    public IntPtr Config { get; }
    public GlVersion Version { get; }
    public int SurfaceType { get; }
    public int[] Attributes { get; }
    public int SampleCount { get; }
    public int StencilSize { get; }

    ...
}

從以上代碼可看到最關鍵的 WrapDirect3D11Texture 方法,這個方法將傳入 D3D 紋理用於關聯

使用封裝好的 AngleWin32EglDisplay 類,代碼如下

        var angleWin32EglDisplay = new AngleWin32EglDisplay(display, egl);

        EglContext eglContext = angleWin32EglDisplay.CreateContext();

這裏的 CreateContext 方法實現如下

public class EglDisplay : IDisposable
{
    ...
    public EglContext CreateContext()
    {
        lock (_lock)
        {
            var context = EglInterface.CreateContext(_display, Config,  IntPtr.Zero, _config.Attributes);

            return new EglContext(this, context, _config.Version);
        }
    }
    ...
}

在 EglContext 裏面似乎沒有什麼邏輯,只是存放 EglDisplay 和 context 指針等,其核心作用是防止直接讓其他模塊使用 context 指針,只是對 context 指針包裝

public record EglContext(EglDisplay EglDisplay, IntPtr Context, GlVersion Version)
{
    public EglInterface EglInterface => EglDisplay.EglInterface;

    public GlInterface GlInterface
    {
        get
        {
            if (_glInterface is null)
            {
                _glInterface = GlInterface.FromNativeUtf8GetProcAddress(Version, EglInterface.GetProcAddress);
            }

            return _glInterface;
        }
    }

    private GlInterface? _glInterface;
    public EglSurface? OffscreenSurface { get; } = null;

    public IDisposable MakeCurrent() => MakeCurrent(OffscreenSurface);

    public IDisposable MakeCurrent(EglSurface? surface)
    {
        var locker = new object();
        Monitor.Enter(locker);
        var old = new RestoreContext(EglInterface, EglDisplay.Handle, locker);

        EglInterface.MakeCurrent(EglDisplay.Handle, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);

        var success = EglInterface.MakeCurrent(EglDisplay.Handle, surface?.DangerousGetHandle() ?? IntPtr.Zero, surface?.DangerousGetHandle() ?? IntPtr.Zero, Context);

        if (!success)
        {
            ...
        }

        return old;
    }

    private class RestoreContext : IDisposable
    {
        ...
        public void Dispose()
        {
            _egl.MakeCurrent(_display, _draw, _read, _context);
            Monitor.Exit(_l);
        }
    }
}

可見 EglContext 額外多封裝了 MakeCurrent 方法,用於將傳入的 EglSurface 設置為 OpenGL 當前所工作的對象

然後還返回了 RestoreContext 對象,用於完成之後將其重置狀態,防止其他邏輯誤寫入,這裏是 OpenGL 的標準用法

使用封裝的 EglContext 進行初始化準備,創建 GRContext 對象,代碼如下

        EglContext eglContext = angleWin32EglDisplay.CreateContext();

        var makeCurrent = eglContext.MakeCurrent();

        // 以下兩個都是 SkiaSharp 封裝的方法
        var grGlInterface = GRGlInterface.CreateGles(proc =>
        {
            var procAddress = eglContext.GlInterface.GetProcAddress(proc);
            return procAddress;
        });

        var grContext = GRContext.CreateGl(grGlInterface, new GRContextOptions()
        {
            AvoidStencilBuffers = true
        });

        makeCurrent.Dispose();

這部分邏輯都是 OpenGL 的常用初始化實現,沒有什麼特別的,如果大家對類型定義感興趣,還請自行拉取代碼瞭解

紋理對接

完成初始化 ANGLE 和 OpenGL 之後,就可以開始進行紋理對接,代碼如下

        // 先從交換鏈取出渲染目標紋理
        ID3D11Texture2D d3D11Texture2D = swapChain.GetBuffer<ID3D11Texture2D>(0);

        // 關鍵代碼: 通過 eglCreatePbufferFromClientBuffer 將 D3D11 紋理包裝為 EGLSurface
        // 這一步的前置是在 eglCreateDeviceANGLE 裏面將 ID3D11Texture2D 所在的 D3D11 設備關聯: `egl.CreateDeviceANGLE(EglConsts.EGL_D3D11_DEVICE_ANGLE, d3D11Device1.NativePointer, null)`
        EglSurface eglSurface =
            angleWin32EglDisplay.WrapDirect3D11Texture(d3D11Texture2D.NativePointer, 0, 0,
                (int)d3D11Texture2D.Description.Width, (int)d3D11Texture2D.Description.Height);

調用 WrapDirect3D11Texture 這一步的前置是在 eglCreateDeviceANGLE 裏面將 ID3D11Texture2D 所在的 D3D11 設備關聯,也就是上文的 egl.CreateDeviceANGLE(EglConsts.EGL_D3D11_DEVICE_ANGLE, d3D11Device1.NativePointer, null) 代碼

如此就拿到關鍵的 EglSurface 表面對象,其類型定義僅僅只是一個包裝,代碼如下

public class EglSurface : SafeHandle
{
    private readonly EglDisplay _display;
    private readonly EglInterface _egl;

    public EglSurface(EglDisplay display, IntPtr surface) : base(surface, true)
    {
        _display = display;
        _egl = display.EglInterface;
    }

    protected override bool ReleaseHandle()
    {
        using (_display.Lock())
            _egl.DestroySurface(_display.Handle, handle);
        return true;
    }

    public override bool IsInvalid => handle == IntPtr.Zero;
    public void SwapBuffers() => _egl.SwapBuffers(_display.Handle, handle);
}

與 Skia 對接

與 Skia 對接的邏輯是發生在每次渲染上,這個過程中只是將 OpenGL 表面作為 Skia 畫布而已,整個過程不發生任何的拷貝和實際執行邏輯。無需擔心這一步從 OpenGL 綁定到 Skia 的性能損耗

以下邏輯發生在每次渲染的時候

// 以下是每次畫面渲染時都要執行的邏輯
// 將 EGLSurface 綁定到 Skia 上
using (eglContext.MakeCurrent(eglSurface))
{
    ... // 在這裏編寫對接的代碼
}

以上這一步用於設置將渲染所用的 EglSurface 設置為 OpenGL 當前的渲染對象

開始執行的時候,進入等待各種刷新邏輯,確保邏輯正確

            using (eglContext.MakeCurrent(eglSurface))
            {
                EglInterface eglInterface = angleWin32EglDisplay.EglInterface;
                Debug.Assert(ReferenceEquals(egl, eglInterface));

                eglInterface.WaitClient();
                eglInterface.WaitGL();
                eglInterface.WaitNative(EglConsts.EGL_CORE_NATIVE_ENGINE);
                 ... // 在這裏編寫對接的代碼

            }

以上代碼的 WaitXxx 三句代碼含義分別如下:

  • WaitClient: 底層是 eglWaitClient 方法,作用是把之前由當前 Client API 發出的命令流推進/同步到一個點,使得這些操作對後續 EGL 操作(以及可能的跨 API 訪問)更安全
  • WaitGL: 底層是 eglWaitGL 方法,等待 OpenGL(或 OpenGL ES)管線中的命令到達一個同步點。一般都在 eglSwapBuffers 前後調用,在本文後續將會用到 eglSwapBuffers 方法
  • WaitNative: 底層是 eglWaitNative 方法,用於等待 native 那邊別在用這個資源/或者等到一個可安全交接的點

這個寫法是比較保守的

按照 OpenGL 的寫法,接下來就是在 eglContext.MakeCurrent(eglSurface) 的後續,調用 glBindFramebuffer 和 glGetIntegerv 方法。調用 glBindFramebuffer 的作用是將某個 Framebuffer Object(FBO)綁定到當前上下文的繪製目標。再通過 glGetIntegerv 查詢 GL_FRAMEBUFFER_BINDING 獲取當前綁定到 GL_FRAMEBUFFER 的 framebuffer id 是多少,代碼如下

                eglContext.GlInterface.BindFramebuffer(GlConsts.GL_FRAMEBUFFER, 0);

                eglContext.GlInterface.GetIntegerv(GlConsts.GL_FRAMEBUFFER_BINDING, out var fb);

調用 BindFramebuffer(int target, int fb)glBindFramebuffer) 的兩個參數分別如下:

  • target:綁定點,通常是 GlConsts.GL_FRAMEBUFFER
  • fb: 要綁定的 framebuffer id 號。這裏傳入的 0 是一個特殊值,表示綁定默認幀緩衝(default framebuffer),也就是“窗口/表面”背後的那個緩衝區,在本文這裏就是 EGLSurface / swapchain backbuffer 了

其作用就是讓接下來所有繪製都輸出到“當前 EGLSurface 的默認幀緩衝”,即在 eglContext.MakeCurrent(eglSurface) 綁定的這個由 ANGLE 映射的 D3D11 紋理

調用 GetIntegerv(int name, out int rv)glGetIntegerv)的兩個參數分別如下:

  • 傳入 GL_FRAMEBUFFER_BINDING 表示將查詢當前綁定到 GL_FRAMEBUFFER 的 framebuffer id 是多少
  • 次參數 fb 為輸出參數,將獲取當前綁定的 framebuffer id 是多少

為什麼在上一行代碼 BindFramebuffer 已經傳入的 fb 是 0 的值,還需要立刻調用 GetIntegerv 去獲取呢?這是因為“默認幀緩衝”在內部可能有一個 非 0 的平台 FBO (Framebuffer Object) id,GL_FRAMEBUFFER_BINDING 讀出來可能不是 0 的值。儘管在大部分情況下,這裏就是獲取到 0 的值

完成 OpenGL 渲染前準備之後,接下來可以開始準備 Skia 的對接邏輯了

先在 Skia 層調用 gr_direct_context_reset_context 方法,告訴 Skia 層,需要重新獲取 GL 層的狀態。在 SkiaSharp 裏 GRContext 表示 Skia 的 GPU 上下文,為了性能考慮,會在 Skia 裏面緩存了一部分的 GPU/GL 狀態,以避免每次繪製都大量地調用 glGet* 獲取狀態,或重複 gl* 狀態設置。調用 gr_direct_context_reset_context 方法不等於重建上下文或清空資源,只是讓 Skia 標記需要對當前 GL 狀態重新獲取而已。在本文這裏將通過 SkiaSharp 封裝的 GRContext.ResetContext 調用到 gr_direct_context_reset_context 方法

                grContext.ResetContext();

定義顏色格式,顏色格式最好和 DirectX 層相同,如此才能獲取最佳性能。由於 Angle 層是做轉發,因而對於一些畫純色的指令來説,即使顏色格式不相同,也不會存在損耗。但是如果涉及到某些表面紋理的處理,就可能需要 GPU 稍微動工執行一些轉換邏輯

                // 顏色格式和前面定義的 Format colorFormat = Format.B8G8R8A8_UNorm; 相對應
                var colorType = SKColorType.Bgra8888;

根據顏色格式,查詢在當前 GPU/驅動/後端(OpenGL/GLES) + 指定顏色格式下,最多支持多少個 MSAA 採樣數(sample count 能夠支持的最大多重採樣數)用於創建渲染目標 surface 表面

                var maxSamples = grContext.GetMaxSurfaceSampleCount(colorType);

正常來説,都會設置一個採樣上限,採樣數量越大,質量越好,但是性能損耗越大。在本文這裏作為演示代碼,就跳過這一步,有多好就用多好

構造 GRGlFramebufferInfo 結構體,用於告訴 Skia 準備畫到哪個 OpenGL FBO(Framebuffer Object) 上,以及這個 FBO (Framebuffer Object)的顏色格式是什麼。再創建 GRBackendRenderTarget 對象,傳入尺寸信息,採樣信息,做好對接的準備

                var glInfo = new GRGlFramebufferInfo((uint)fb, colorType.ToGlSizedFormat());
                // 從 OpenGL 對接到 Skia 上
                using (var renderTarget = new GRBackendRenderTarget(clientSize.Width,
                           clientSize.Height, maxSamples, eglDisplay.StencilSize, glInfo))
                {
                    ...
                }

通過 SKSurface.Create 創建出 SKSurface 對象,從而獲取到 SKCanvas 畫板

                using (var renderTarget = new GRBackendRenderTarget(clientSize.Width,
                           clientSize.Height, maxSamples, eglDisplay.StencilSize, glInfo))
                {
                    var surfaceProperties = new SKSurfaceProperties(SKPixelGeometry.RgbHorizontal);

                    using (var skSurface = SKSurface.Create(grContext, renderTarget, GRSurfaceOrigin.TopLeft,
                               colorType,
                               surfaceProperties))
                    {
                        using (SKCanvas skCanvas = skSurface.Canvas)
                        {
                            // 在這裏就拿到 SKCanvas 啦,可以開始繪製內容了
                            ...
                        }
                    }
                }

拿到了 Skia 的核心入口 SKCanvas 類,接下來的繪製邏輯就全面向 Skia 了,可以無視前面的各種對接方邏輯,不管對接方是 ANGLE 的還是直接軟渲的等等。繪製的邏輯只需要管 SKCanvas 畫布就可以了

在本文這裏,寫了一點示例代碼,用於繪製一個漂亮的界面,順帶也用於測試幀率

record SkiaRenderDemo(SizeI ClientSize)
{
    // 此為調試代碼,繪製一些矩形條
    private List<RenderInfo>? _renderList;

    public void Draw(SKCanvas canvas)
    {
        var rectWeight = 10;
        var rectHeight = 20;

        var margin = 5;

        if (_renderList is null)
        {
            // 如果是空,那就執行初始化
            _renderList = new List<RenderInfo>();

            for (int top = margin; top < ClientSize.Height - rectHeight - margin; top += rectHeight + margin)
            {
                var skRect = new SKRect(margin, top, margin + rectWeight, top + rectHeight);
                var color = new SKColor((uint)Random.Shared.Next()).WithAlpha(0xFF);
                var step = Random.Shared.Next(1, 20);
                var renderInfo = new RenderInfo(skRect, step, color);

                _renderList.Add(renderInfo);
            }
        }

        using var skPaint = new SKPaint();
        skPaint.Style = SKPaintStyle.Fill;
        for (var i = 0; i < _renderList.Count; i++)
        {
            var renderInfo = _renderList[i];
            skPaint.Color = renderInfo.Color;

            canvas.DrawRect(renderInfo.Rect, skPaint);

            var nextRect = renderInfo.Rect with
            {
                Right = renderInfo.Rect.Right + renderInfo.Step
            };
            if (nextRect.Right > ClientSize.Width - margin)
            {
                nextRect = nextRect with
                {
                    Right = nextRect.Left + rectWeight
                };
            }

            _renderList[i] = renderInfo with
            {
                Rect = nextRect
            };
        }
    }

    private readonly record struct RenderInfo(SKRect Rect, int Step, SKColor Color);
}

以上只是為測試代碼,大家可以編寫自己的界面繪製邏輯

刷新界面

完成繪製之後,還需要將畫面推送出去,讓雙緩存交換一下,代碼如下

                using (var renderTarget = new GRBackendRenderTarget(clientSize.Width,
                           clientSize.Height, maxSamples, eglDisplay.StencilSize, glInfo))
                {
                    var surfaceProperties = new SKSurfaceProperties(SKPixelGeometry.RgbHorizontal);

                    using (var skSurface = SKSurface.Create(grContext, renderTarget, GRSurfaceOrigin.TopLeft,
                               colorType,
                               surfaceProperties))
                    {
                        using (SKCanvas skCanvas = skSurface.Canvas)
                        {
                            // 隨便畫內容
                            skCanvas.Clear();
                            renderDemo.Draw(skCanvas);
                        }
                    }
                }

                // 如果開啓渲染同步等待,則會在這裏等待
                grContext.Flush();

                // 讓 OpenGL 層刷出去
                eglContext.GlInterface.Flush();
                eglInterface.WaitGL();
                eglSurface.SwapBuffers();

                eglInterface.WaitClient();
                eglInterface.WaitGL();
                eglInterface.WaitNative(EglConsts.EGL_CORE_NATIVE_ENGINE);

                // 讓交換鏈推送
                swapChain.Present(1, PresentFlags.None);

推送的邏輯是一級級的,先是 Skia 層通過 grContext.Flush 將繪製命令刷出去到 OpenGL 層。在 OpenGL 層通過 GlInterface.FlushSwapBuffers 等完成繪製。最後再由 DirectX 的交換鏈 Present 將畫面提交給到屏幕

以上就是一個最簡的對接實現,通過 ANGLE 的能力,讓 Skia 可以調用到 DirectX 進行渲染,極大提升渲染性能

也從此過程可以看到,沒有需求要將 Buffer 從 GPU 拷貝到 CPU 上,可以全過程都發生在 GPU 中。嘗試實際運行代碼,也可以看到 CPU 接近不動,而 GPU 在幹活

本文的實現方法是我從 Avalonia 框架裏面學到的,我對 OpenGL 陌生,對 DirectX 瞭解。通過閲讀 Avalonia 框架源代碼,我學習了此對接過程

核心代碼

以下是 Program.cs 文件的全部代碼

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Gdi;
using Windows.Win32.UI.WindowsAndMessaging;
using KurbawjeleJarlayenel.Diagnostics;
using KurbawjeleJarlayenel.OpenGL;
using KurbawjeleJarlayenel.OpenGL.Angle;
using KurbawjeleJarlayenel.OpenGL.Egl;
using SkiaSharp;
using Vortice.Direct3D;
using Vortice.Direct3D11;
using Vortice.DXGI;
using Vortice.Mathematics;
using static Windows.Win32.PInvoke;

namespace KurbawjeleJarlayenel;

class Program
{
    [STAThread]
    static unsafe void Main(string[] args)
    {
        // 創建窗口
        var window = CreateWindow();
        // 顯示窗口
        ShowWindow(window, SHOW_WINDOW_CMD.SW_NORMAL);

        // 初始化渲染
        // 初始化 DX 相關
        #region 初始化 DX 相關

        var dxgiFactory2 = DXGI.CreateDXGIFactory1<IDXGIFactory2>();

        IDXGIAdapter1? hardwareAdapter = GetHardwareAdapter(dxgiFactory2)
            // 這裏 ToList 只是想列出所有的 IDXGIAdapter1 在實際代碼裏,大部分都是獲取第一個
            .ToList().FirstOrDefault();
        if (hardwareAdapter == null)
        {
            throw new InvalidOperationException("Cannot detect D3D11 adapter");
        }

        FeatureLevel[] featureLevels = new[]
        {
            FeatureLevel.Level_11_1,
            FeatureLevel.Level_11_0,
            FeatureLevel.Level_10_1,
            FeatureLevel.Level_10_0,
            FeatureLevel.Level_9_3,
            FeatureLevel.Level_9_2,
            FeatureLevel.Level_9_1,
        };

        IDXGIAdapter1 adapter = hardwareAdapter;
        DeviceCreationFlags creationFlags = DeviceCreationFlags.BgraSupport;
        var result = D3D11.D3D11CreateDevice
        (
            adapter,
            DriverType.Unknown,
            creationFlags,
            featureLevels,
            out ID3D11Device d3D11Device, out FeatureLevel featureLevel,
            out ID3D11DeviceContext d3D11DeviceContext
        );

        _ = featureLevel;

        result.CheckError();

        // 大部分情況下,用的是 ID3D11Device1 和 ID3D11DeviceContext1 類型
        // 從 ID3D11Device 轉換為 ID3D11Device1 類型
        ID3D11Device1 d3D11Device1 = d3D11Device.QueryInterface<ID3D11Device1>();
        var d3D11DeviceContext1 = d3D11DeviceContext.QueryInterface<ID3D11DeviceContext1>();
        _ = d3D11DeviceContext1;

        // 獲取到了新的兩個接口,就可以減少 `d3D11Device` 和 `d3D11DeviceContext` 的引用計數。調用 Dispose 不會釋放掉剛才申請的 D3D 資源,只是減少引用計數
        d3D11Device.Dispose();
        d3D11DeviceContext.Dispose();

        RECT windowRect;
        GetClientRect(window, &windowRect);
        var clientSize = new SizeI(windowRect.right - windowRect.left, windowRect.bottom - windowRect.top);

        // 顏色格式有要求,才能和 Angle 正確交互
        Format colorFormat = Format.B8G8R8A8_UNorm;

        // 緩存的數量,包括前緩存。大部分應用來説,至少需要兩個緩存,這個玩過遊戲的夥伴都知道
        const int frameCount = 2;
        SwapChainDescription1 swapChainDescription = new()
        {
            Width = (uint)clientSize.Width,
            Height = (uint)clientSize.Height,
            Format = colorFormat, // B8G8R8A8_UNorm
            BufferCount = frameCount,
            BufferUsage = Usage.RenderTargetOutput,
            SampleDescription = SampleDescription.Default,
            Scaling = Scaling.Stretch,
            SwapEffect = SwapEffect.FlipSequential,
            AlphaMode = AlphaMode.Ignore,
            Flags = SwapChainFlags.None,
        };

        var fullscreenDescription = new SwapChainFullscreenDescription()
        {
            Windowed = true,
        };

        IDXGISwapChain1 swapChain =
            dxgiFactory2.CreateSwapChainForHwnd(d3D11Device1, window, swapChainDescription, fullscreenDescription);

        // 不要被按下 alt+enter 進入全屏
        dxgiFactory2.MakeWindowAssociation(window,
            WindowAssociationFlags.IgnoreAltEnter | WindowAssociationFlags.IgnorePrintScreen);

        #endregion

        // 初始化 Angle 和 OpenGL 相關

        #region 初始化 Angle 相關

        var egl = new Win32AngleEglInterface();
        // 傳入 ID3D11Device1 的指針,將 D3D11 設備和 AngleDevice 綁定
        var angleDevice = egl.CreateDeviceANGLE(EglConsts.EGL_D3D11_DEVICE_ANGLE, d3D11Device1.NativePointer, null);
        var display = egl.GetPlatformDisplayExt(EglConsts.EGL_PLATFORM_DEVICE_EXT, angleDevice, null);

        var angleWin32EglDisplay = new AngleWin32EglDisplay(display, egl);

        EglContext eglContext = angleWin32EglDisplay.CreateContext();

        var makeCurrent = eglContext.MakeCurrent();

        var grGlInterface = GRGlInterface.CreateGles(proc =>
        {
            var procAddress = eglContext.GlInterface.GetProcAddress(proc);
            return procAddress;
        });

        var grContext = GRContext.CreateGl(grGlInterface, new GRContextOptions()
        {
            AvoidStencilBuffers = true
        });
        makeCurrent.Dispose();

        #endregion

        // 通過 Angle 關聯 DX 和 OpenGL 紋理

        #region 關聯 DX 和 OpenGL 紋理

        // 先從交換鏈取出渲染目標紋理
        ID3D11Texture2D d3D11Texture2D = swapChain.GetBuffer<ID3D11Texture2D>(0);
        Debug.Assert(d3D11Texture2D.Description.Width == clientSize.Width);
        Debug.Assert(d3D11Texture2D.Description.Height == clientSize.Height);

        // 關鍵代碼: 通過 eglCreatePbufferFromClientBuffer 將 D3D11 紋理包裝為 EGLSurface
        // 這一步的前置是在 eglCreateDeviceANGLE 裏面將 ID3D11Texture2D 所在的 D3D11 設備關聯: `egl.CreateDeviceANGLE(EglConsts.EGL_D3D11_DEVICE_ANGLE, d3D11Device1.NativePointer, null)`
        EglSurface eglSurface =
            angleWin32EglDisplay.WrapDirect3D11Texture(d3D11Texture2D.NativePointer, 0, 0,
                (int)d3D11Texture2D.Description.Width, (int)d3D11Texture2D.Description.Height);

        // 後續 Skia 也許會使用 Graphite 的 Dawn 支持 D3D 而不是 EGL 的方式
        // > Current plans for Graphite are to support D3D11 and D3D12 through the Dawn backend.
        // 詳細請看
        // https://groups.google.com/g/skia-discuss/c/WY7yzRjGGFA
        // > Ganesh和Graphite是兩組技術,Ganesh更老更穩定,Graphite更新、更快(多線程支持更好)、更不穩定,但它是趨勢,是Skia團隊的主攻方向。Chrome已經在個別地方使用Graphite了
        // https://zhuanlan.zhihu.com/p/20265941170

        #endregion

        SkiaRenderDemo renderDemo = new(clientSize);

        while (true)
        {
            // 界面渲染
            using var step = StepPerformanceCounter.RenderThreadCounter.StepStart("Render");

            // 以下是每次畫面渲染時都要執行的邏輯
            // 將 EGLSurface 綁定到 Skia 上
            using (eglContext.MakeCurrent(eglSurface))
            {
                EglInterface eglInterface = angleWin32EglDisplay.EglInterface;
                Debug.Assert(ReferenceEquals(egl, eglInterface));

                eglInterface.WaitClient();
                eglInterface.WaitGL();
                eglInterface.WaitNative(EglConsts.EGL_CORE_NATIVE_ENGINE);

                eglContext.GlInterface.BindFramebuffer(GlConsts.GL_FRAMEBUFFER, 0);

                eglContext.GlInterface.GetIntegerv(GlConsts.GL_FRAMEBUFFER_BINDING, out var fb);
                // 顏色格式和前面定義的 Format colorFormat = Format.B8G8R8A8_UNorm; 相對應
                var colorType = SKColorType.Bgra8888;
                // 當然,寫成 SKColorType.Rgba8888 也是能被兼容的
                // https://github.com/AvaloniaUI/Avalonia/discussions/20559
                grContext.ResetContext();

                var maxSamples = grContext.GetMaxSurfaceSampleCount(colorType);

                EglDisplay eglDisplay = angleWin32EglDisplay;

                var glInfo = new GRGlFramebufferInfo((uint)fb, colorType.ToGlSizedFormat());
                // 從 OpenGL 對接到 Skia 上
                using (var renderTarget = new GRBackendRenderTarget(clientSize.Width,
                           clientSize.Height, maxSamples, eglDisplay.StencilSize, glInfo))
                {
                    var surfaceProperties = new SKSurfaceProperties(SKPixelGeometry.RgbHorizontal);

                    using (var skSurface = SKSurface.Create(grContext, renderTarget, GRSurfaceOrigin.TopLeft,
                               colorType,
                               surfaceProperties))
                    {
                        using (SKCanvas skCanvas = skSurface.Canvas)
                        {
                            // 隨便畫內容
                            skCanvas.Clear();
                            renderDemo.Draw(skCanvas);
                        }
                    }
                }

                // 如果開啓渲染同步等待,則會在這裏等待
                grContext.Flush();

                // 讓 OpenGL 層刷出去
                eglContext.GlInterface.Flush();
                eglInterface.WaitGL();
                eglSurface.SwapBuffers();

                eglInterface.WaitClient();
                eglInterface.WaitGL();
                eglInterface.WaitNative(EglConsts.EGL_CORE_NATIVE_ENGINE);

                // 讓交換鏈推送
                swapChain.Present(1, PresentFlags.None);
            }

            // 以下只是為了防止窗口無響應而已
            var success = PeekMessage(out var msg, HWND.Null, 0, 0, PEEK_MESSAGE_REMOVE_TYPE.PM_REMOVE);
            if (success)
            {
                // 處理窗口消息
                TranslateMessage(&msg);
                DispatchMessage(&msg);
            }
        }

        Console.ReadLine();
    }

    private static unsafe HWND CreateWindow()
    {
        DwmIsCompositionEnabled(out var compositionEnabled);

        if (!compositionEnabled)
        {
            Console.WriteLine($"無法啓用透明窗口效果");
        }

        WINDOW_EX_STYLE exStyle = WINDOW_EX_STYLE.WS_EX_OVERLAPPEDWINDOW;

        var style = WNDCLASS_STYLES.CS_OWNDC | WNDCLASS_STYLES.CS_HREDRAW | WNDCLASS_STYLES.CS_VREDRAW;

        var defaultCursor = LoadCursor(
            new HINSTANCE(IntPtr.Zero), new PCWSTR(IDC_ARROW.Value));

        var className = $"lindexi-{Guid.NewGuid().ToString()}";
        var title = "The Title";
        fixed (char* pClassName = className)
        fixed (char* pTitle = title)
        {
            var wndClassEx = new WNDCLASSEXW
            {
                cbSize = (uint)Marshal.SizeOf<WNDCLASSEXW>(),
                style = style,
                lpfnWndProc = new WNDPROC(WndProc),
                hInstance = new HINSTANCE(GetModuleHandle(null).DangerousGetHandle()),
                hCursor = defaultCursor,
                hbrBackground = new HBRUSH(IntPtr.Zero),
                lpszClassName = new PCWSTR(pClassName)
            };
            ushort atom = RegisterClassEx(in wndClassEx);

            var dwStyle = WINDOW_STYLE.WS_OVERLAPPEDWINDOW;

            var windowHwnd = CreateWindowEx(
                exStyle,
                new PCWSTR((char*)atom),
                new PCWSTR(pTitle),
                dwStyle,
                0, 0, 1900, 1000,
                HWND.Null, HMENU.Null, HINSTANCE.Null, null);

            return windowHwnd;
        }

        static LRESULT WndProc(HWND hwnd, uint message, WPARAM wParam, LPARAM lParam)
        {
            WindowsMessage windowsMessage = (WindowsMessage)message;
            if (windowsMessage == WindowsMessage.WM_CLOSE)
            {
                Environment.Exit(0);
            }

            return DefWindowProc(hwnd, message, wParam, lParam);
        }
    }

    private static IEnumerable<IDXGIAdapter1> GetHardwareAdapter(IDXGIFactory2 factory)
    {
        using IDXGIFactory6? factory6 = factory.QueryInterfaceOrNull<IDXGIFactory6>();
        if (factory6 != null)
        {
            // 這個系統的 DX 支持 IDXGIFactory6 類型
            // 先告訴系統,要高性能的顯卡
            for (uint adapterIndex = 0;
                 factory6.EnumAdapterByGpuPreference(adapterIndex, GpuPreference.HighPerformance,
                     out IDXGIAdapter1? adapter).Success;
                 adapterIndex++)
            {
                if (adapter == null)
                {
                    continue;
                }

                AdapterDescription1 desc = adapter.Description1;
                if ((desc.Flags & AdapterFlags.Software) != AdapterFlags.None)
                {
                    // Don't select the Basic Render Driver adapter.
                    adapter.Dispose();
                    continue;
                }

                yield return adapter;
            }
        }
        else
        {
            // 不支持就不支持咯,用舊版本的方式獲取顯示適配器接口
        }

        // 如果枚舉不到,那系統返回啥都可以
        for (uint adapterIndex = 0;
             factory.EnumAdapters1(adapterIndex, out IDXGIAdapter1? adapter).Success;
             adapterIndex++)
        {
            AdapterDescription1 desc = adapter.Description1;

            if ((desc.Flags & AdapterFlags.Software) != AdapterFlags.None)
            {
                // Don't select the Basic Render Driver adapter.
                adapter.Dispose();

                continue;
            }

            yield return adapter;
        }
    }
}

record SkiaRenderDemo(SizeI ClientSize)
{
    // 此為調試代碼,繪製一些矩形條
    private List<RenderInfo>? _renderList;

    public void Draw(SKCanvas canvas)
    {
        var rectWeight = 10;
        var rectHeight = 20;

        var margin = 5;

        if (_renderList is null)
        {
            // 如果是空,那就執行初始化
            _renderList = new List<RenderInfo>();

            for (int top = margin; top < ClientSize.Height - rectHeight - margin; top += rectHeight + margin)
            {
                var skRect = new SKRect(margin, top, margin + rectWeight, top + rectHeight);
                var color = new SKColor((uint)Random.Shared.Next()).WithAlpha(0xFF);
                var step = Random.Shared.Next(1, 20);
                var renderInfo = new RenderInfo(skRect, step, color);

                _renderList.Add(renderInfo);
            }
        }

        using var skPaint = new SKPaint();
        skPaint.Style = SKPaintStyle.Fill;
        for (var i = 0; i < _renderList.Count; i++)
        {
            var renderInfo = _renderList[i];
            skPaint.Color = renderInfo.Color;

            canvas.DrawRect(renderInfo.Rect, skPaint);

            var nextRect = renderInfo.Rect with
            {
                Right = renderInfo.Rect.Right + renderInfo.Step
            };
            if (nextRect.Right > ClientSize.Width - margin)
            {
                nextRect = nextRect with
                {
                    Right = nextRect.Left + rectWeight
                };
            }

            _renderList[i] = renderInfo with
            {
                Rect = nextRect
            };
        }
    }

    private readonly record struct RenderInfo(SKRect Rect, int Step, SKColor Color);
}

全部代碼

本文代碼放在 github 和 gitee 上,可以使用如下命令行拉取代碼。我整個代碼倉庫比較龐大,使用以下命令行可以進行部分拉取,拉取速度比較快

先創建一個空文件夾,接着使用命令行 cd 命令進入此空文件夾,在命令行裏面輸入以下代碼,即可獲取到本文的代碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 6bd17b7dcb57c5c0bc0958c7428ba589733ba71f

以上使用的是國內的 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令行繼續輸入以下代碼,將 gitee 源換成 github 源進行拉取代碼。如果依然拉取不到代碼,可以發郵件向我要代碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 6bd17b7dcb57c5c0bc0958c7428ba589733ba71f

獲取代碼之後,進入 DirectX/Angle/KurbawjeleJarlayenel 文件夾,即可獲取到源代碼

更多博客

渲染部分,關於 SharpDx 和 Vortice 的使用方法,包括入門級教程,請參閲:

  • 渲染博客導航
  • SharpDX 系列

更多關於我博客請參閲 博客導航

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.