如果想在 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 公开项目的检索,我还没有找到第二个同时满足下面全部条件的项目:
- 使用 C# 并以 .NET 库的形式提供;
- 开源,而且是一套独立 RHI,不是完整游戏引擎;
- 一套公开 API 同时覆盖 DirectX 12、Metal 4 和 Vulkan 1.4;
- Bindless handle 是唯一的 shader 资源模型,而不是附加模式;
- 对外提供显式 memory barrier 和 texture layout transition;
- 同时覆盖 graphics、compute、indirect、inline ray tracing 与 mesh shading。
按这个明确的范围,Zenith.NET 可能是目前首个公开的 C# 现代 Bindless RHI。
这里的限定条件很重要。Silk.NET、Vortice.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 依赖 |
| 显式内存 | Heap、MemoryResidency |
描述资源放在哪里以及如何分配 |
| Shader 工具链 | ZenithCompiler |
用 Slang 编译三个后端所需代码 |
这几个概念彼此配合,但不会互相偷偷代劳。
例如,一个 ResourceHandle 只负责让 shader 找到资源。它不会拥有资源,不会自动插入 barrier,也不会改变纹理布局。应用程序仍然需要准确表达资源生命周期和同步关系。
Bindless 不是附加功能,而是唯一资源模型
旧版 Zenith.NET 曾经有 ResourceLayout、ResourceTable 和 SetResourceTable。在 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);
公开布局覆盖 Sampled、Storage、颜色和深度附件、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 两篇核心文档。
项目地址
- GitHub:qian-o/Zenith.NET
- v1.0.0-rc:Release Notes
- 文档站:https://qian-o.github.io/Zenith.NET/
- RHI Guide:Documentation
- 教程:Tutorials
- API Reference:API
Zenith.NET 使用 MIT License 开源。如果你也关注 C# 图形编程、现代 GPU abstraction、bindless、Slang 或跨后端 RHI,欢迎试用、提 Issue,或者直接从 FluidTank 和 CornellBox 的代码开始看。
如果这套方向对你有价值,也欢迎给项目一个 Star。比起单纯增加数字,它更重要的意义是让我知道:C# 生态里确实有人需要这样一套现代 RHI。