Zenith.NET 1.0 RC 发布:面向 DirectX 12 / Metal 4 / Vulkan 1.4 的 C# Bindless RHI

如果想在 C# 中使用现代 GPU API,通常会遇到几种选择:直接调用 Vulkan、DirectX 或 Metal 的绑定;使用已有的跨平台图形库;或者直接进入一套完整的游戏引擎。

但我一直想要的是另一种东西:

一套独立、轻量、现代、以 bindless 为核心的 C# RHI。它不替你做场景、材质和渲染算法,只负责用同一套 API 把资源、pipeline、command、同步和呈现送到 DirectX 12、Metal 4 与 Vulkan 1.4。

这个项目就是 Zenith.NET。最近,它完成了整个公开 API 和三个后端的重新设计,并发布了 v1.0.0-rc

它算不算首个

先说最容易引起争议的结论。

截至本文发布时,基于我对 GitHub 公开项目的检索,我还没有找到第二个同时满足下面全部条件的项目:

  1. 使用 C# 并以 .NET 库的形式提供;
  2. 开源,而且是一套独立 RHI,不是完整游戏引擎;
  3. 一套公开 API 同时覆盖 DirectX 12、Metal 4 和 Vulkan 1.4;
  4. Bindless handle 是唯一的 shader 资源模型,而不是附加模式;
  5. 对外提供显式 memory barrier 和 texture layout transition;
  6. 同时覆盖 graphics、compute、indirect、inline ray tracing 与 mesh shading。

按这个明确的范围,Zenith.NET 可能是目前首个公开的 C# 现代 Bindless RHI

这里的限定条件很重要。Silk.NETVortice.Windows 解决的是高质量原生绑定问题;Veldrid 是更早一代的跨平台图形抽象;Stride 等项目则是完整引擎。它们都很有价值,只是目标和抽象边界不同。我不想用一个无法证明的"全球第一"口号抹掉这些差异。如果你知道另一个同时满足上述条件的开源项目,也欢迎告诉我,我会修正这段表述。

先看它实际跑起来的样子

下面两张图来自同一台 NVIDIA GeForce RTX 4070 Ti SUPER,在 DirectX 12 后端运行。

FluidTank 是一个 GPU APIC 流体模拟与渲染实验。截图中包含 113,280 个粒子,它会连续执行多阶段 compute、显式 barrier、纹理布局转换、流体表面重建和最终合成。

CornellBox 包含 rasterization 和 path tracing 两条渲染路径,用来验证 graphics pipeline、bindless 资源、inline ray query 和跨后端 shader 数据布局。

截图中的 FPS 只是当次运行时界面显示的状态,不是经过统一测试环境得到的跨项目 benchmark。这里更想展示的是:这些 API 已经被真实的多阶段工作负载使用,而不只是停留在接口定义上。

为什么我称它为"现代 RHI"

Zenith.NET 没有尝试把三个原生 API 的每一个对象都一比一包起来。它选择三者共同支持的现代能力,并将职责拆成几个清楚的部分:

关注点 Zenith.NET API 它负责什么
Shader 资源寻址 ResourceHandle 告诉 shader 要访问哪个资源
同布局数据依赖 CommandBuffer.Barrier 让后续 GPU 工作看到先前写入
纹理访问角色 CommandBuffer.Transition 转换一个纹理子资源的布局
跨 submission 同步 TimelineValue 在 queue 之间建立 GPU 依赖
显式内存 HeapMemoryResidency 描述资源放在哪里以及如何分配
Shader 工具链 ZenithCompiler 用 Slang 编译三个后端所需代码

这几个概念彼此配合,但不会互相偷偷代劳。

例如,一个 ResourceHandle 只负责让 shader 找到资源。它不会拥有资源,不会自动插入 barrier,也不会改变纹理布局。应用程序仍然需要准确表达资源生命周期和同步关系。

Bindless 不是附加功能,而是唯一资源模型

旧版 Zenith.NET 曾经有 ResourceLayoutResourceTableSetResourceTable。在 1.0 的重新设计中,这套传统资源表模型被完整删除了。

现在,buffer、texture、view、sampler 和顶层加速结构会直接公开对应的 ResourceHandle

C# 资源 Handle Slang 类型
Buffer / BufferView ConstantHandle ConstantBuffer<T>
Buffer / BufferView StorageReadOnlyHandle StructuredBuffer<T>
Buffer / BufferView StorageReadWriteHandle RWStructuredBuffer<T>
Texture / TextureView SampledHandle Texture1D/2D/3D/Cube
Texture / TextureView StorageHandle RWTexture1D/2D/3D
Sampler Handle SamplerState
TopLevelAccelerationStructure Handle RaytracingAccelerationStructure

在 C# 端,handle 就是常量数据的一部分:

csharp 复制代码
[StructLayout(LayoutKind.Explicit, Size = 16)]
file struct MaterialConstants
{
    [FieldOffset(0)]
    public ResourceHandle Texture;

    [FieldOffset(8)]
    public ResourceHandle Sampler;
}

MaterialConstants constants = new()
{
    Texture = texture.SampledHandle,
    Sampler = sampler.Handle
};

Slang 端声明相同的数据契约:

slang 复制代码
struct MaterialConstants
{
    DescriptorHandle<Texture2D> Texture;

    DescriptorHandle<SamplerState> Sampler;
};

ConstantBuffer<MaterialConstants> constants;

录制命令时只需要绑定常量缓冲区:

csharp 复制代码
commandBuffer.SetPipeline(pipeline);
commandBuffer.SetConstantBuffer(constantBuffer, 0);
commandBuffer.Draw(vertexCount, 1, 0, 0);

Pipeline 不再携带一份资源布局,command buffer 也不再反复切换 resource table。Shader 从常量数据取得有类型的 handle,再访问对应资源。

Barrier 和布局转换必须说清楚

Bindless 解决的是"访问谁",同步解决的是"什么时候可以访问"。这两件事不能混为一谈。

当两个 GPU 阶段共享数据,但纹理布局不变时,使用 Barrier

csharp 复制代码
commandBuffer.SetPipeline(simulationPipeline);
commandBuffer.Dispatch(groupCount, 1, 1);

commandBuffer.Barrier(BarrierStages.ComputeShading, BarrierStages.ComputeShading);

commandBuffer.SetPipeline(nextPipeline);
commandBuffer.Dispatch(nextGroupCount, 1, 1);

当纹理的访问角色发生变化时,使用 Transition

csharp 复制代码
commandBuffer.Transition(output, default, TextureLayout.Undefined, TextureLayout.Storage);

commandBuffer.SetPipeline(pipeline);
commandBuffer.Dispatch(groupCountX, groupCountY, 1);

commandBuffer.Transition(output, default, TextureLayout.Storage, TextureLayout.Sampled);

公开布局覆盖 SampledStorage、颜色和深度附件、copy、resolve、present 等常见角色,而且布局按纹理子资源指定。

Zenith.NET 不做一套隐藏在背后的全局资源状态追踪器。调用者知道资源当前处于什么角色,也明确告诉 RHI 下一步要怎么用。这样 API 在三个后端上保持一致,同时不会掩盖现代 GPU 编程中真正存在的依赖关系。

Queue 和 Timeline 也是公开模型的一部分

每个 GraphicsContext 提供 graphics、compute 和 transfer 三条 queue。Command buffer 由 queue 借出,提交后仍由 queue 管理,并在 GPU 完成工作后回收复用。

csharp 复制代码
CommandBuffer upload = context.TransferQueue.CommandBuffer();
upload.Upload(buffer, 0, data);
TimelineValue ready = upload.Submit();

CommandBuffer compute = context.ComputeQueue.CommandBuffer();
compute.SetPipeline(pipeline);
compute.SetConstantBuffer(constantBuffer, 0);
compute.Dispatch(groupCountX, groupCountY, 1);
compute.Submit(ready);

TimelineValue 可以直接作为下一次 submission 的等待条件,不需要 CPU 先阻塞等待。只有 CPU 确实要读取结果时,才调用 Wait()

此外,timestamp query 保留 GPU 原始 tick,并由写入 timestamp 的同一条 queue 转换为纳秒。这避免了把后端频率和有效位数暴露到公共 API。

一套 API 覆盖哪些平台和能力

当前后端和平台关系如下:

平台 DirectX 12 Metal 4 Vulkan 1.4
Windows 支持 支持
Apple 平台 支持 支持
Android 支持
Linux 支持

核心工作负载包括:

  • Rasterization;
  • Compute;
  • Indirect draw / dispatch;
  • Inline ray tracing,也就是 shader 中的 RayQuery
  • Mesh shading;
  • Occlusion query 与 timestamp query;
  • Swap chain、外部纹理导入和 .NET UI view 集成。

Ray tracing 和 mesh shading 是可选能力,创建相关资源前需要检查:

csharp 复制代码
if (context.Capabilities.RayTracingSupported)
{
    // 使用 acceleration structure 和 inline RayQuery
}

if (context.Capabilities.MeshShadingSupported)
{
    // 创建 mesh shading pipeline
}

Shader 统一使用 Slang

Zenith.NET 使用 Slang 作为 shader 源语言和跨后端编译入口。编译结果会根据当前 GraphicsApi 生成对应后端需要的代码。

csharp 复制代码
ShaderDesc shaderDesc = ZenithCompiler.CompileFromFile(context.GraphicsApi, "Shaders/Simulation.slang", "CSMain");

Shader shader = context.CreateShader(shaderDesc);

Compute shader 的 thread-group size 也会通过反射写入 ShaderDesc,应用程序不必在 C# 端再维护一份容易失配的常量。

旧的 Zenith.NET.Extensions.Slang 包已经弃用并从仓库移除。现在直接使用核心 Zenith.NET 包中的 ZenithCompiler,不需要安装替代扩展包。

它不是什么

Zenith.NET 不是游戏引擎,也不是一套场景渲染器。

它没有 entity、材质系统、资源导入流水线或编辑器。它提供的是更靠近 GPU 的公共层:资源、pipeline、command、同步、呈现,以及少量与跨后端一致性直接相关的工具。

如果你只需要调用某一个原生 API 的全部功能,Silk.NET 或 Vortice.Windows 这样的绑定层可能更合适;如果你需要完整引擎,Stride 等项目提供的能力远远超过一套 RHI。Zenith.NET 面向的是希望保留渲染架构控制权,同时不想维护三套后端的开发者。

目前的边界和已知问题

这是 v1.0.0-rc,不是"所有工作都已经结束"的稳定版。公开 API 已完成一次系统性收敛,但在 1.0 stable 前仍可能根据使用反馈调整。

需要特别说明的是,Vulkan acceleration structure 与 ray query 当前仍属于实验性功能。在一个使用 Vulkan 1.4、VK_EXT_descriptor_heap,并通过 Slang DescriptorHandle 访问 TLAS 的最小复现中,已知 NVIDIA 610.74 驱动配置会触发 VK_ERROR_DEVICE_LOST。其他驱动和硬件可能有不同表现。

完整复现位于:VulkanRayQueryTriangle

我更愿意把这个问题明确写出来,而不是为了让项目介绍看起来完美而隐藏它。RHI 面对的是驱动、规范和硬件的真实交界处,公开可复现的问题本身也是项目走向成熟的一部分。

五分钟开始使用

项目基于 .NET 10。安装核心包和一个后端包:

powershell 复制代码
dotnet add package Zenith.NET
dotnet add package Zenith.NET.Vulkan

创建 context:

csharp 复制代码
using Zenith.NET;
using Zenith.NET.Vulkan;

using GraphicsContext context = GraphicsContext.CreateVulkan(useValidationLayer: true);

如果使用 Windows,可以换成 Zenith.NET.DirectX12;Apple 平台可以使用 Zenith.NET.Metal

接下来可以从 Hello Triangle 开始,也可以直接阅读 bindless 和 synchronization 两篇核心文档。

项目地址

Zenith.NET 使用 MIT License 开源。如果你也关注 C# 图形编程、现代 GPU abstraction、bindless、Slang 或跨后端 RHI,欢迎试用、提 Issue,或者直接从 FluidTank 和 CornellBox 的代码开始看。

如果这套方向对你有价值,也欢迎给项目一个 Star。比起单纯增加数字,它更重要的意义是让我知道:C# 生态里确实有人需要这样一套现代 RHI。