本文将从控制台开始,以最简单方式和大家展示如何通过 Angle 将 Skia 和 DirectX 对接。对接之后,可以利用 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);
}

全部代码

本文代码放在 githubgitee 上,可以使用如下命令行拉取代码。我整个代码仓库比较庞大,使用以下命令行可以进行部分拉取,拉取速度比较快

先创建一个空文件夹,接着使用命令行 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 的使用方法,包括入门级教程,请参阅:

更多关于我博客请参阅 博客导航


本文会经常更新,请阅读原文: https://blog.lindexi.com/post/dotnet-Vortice-%E9%80%9A%E8%BF%87-Angle-%E5%B0%86-Skia-%E5%92%8C-DirectX-%E5%AF%B9%E6%8E%A5.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

如果你想持续阅读我的最新博客,请点击 RSS 订阅,推荐使用RSS Stalker订阅博客,或者收藏我的博客导航

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名林德熙(包含链接: https://blog.lindexi.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系

微软最具价值专家


无盈利,不卖课,做纯粹的技术博客

以下是广告时间

推荐关注 Edi.Wang 的公众号

欢迎进入 Eleven 老师组建的 .NET 社区

以上广告全是友情推广,无盈利