ITADN
Far-Beyond-Pulsar/Helio · 文件 下载 ZIP
文件最后提交记录最后更新时间
README.md
以下内容由 AI 翻译,如有问题请点此提交 issue 反馈
Helio Renderer

纯 Rust 实现的 GPU 驱动延迟渲染,模块化且跨平台

Rust wgpu WebGPU License

Helio 是一个完全用 Rust 编写、基于 wgpu 的 GPU 驱动延迟渲染器。贯穿整个项目的核心理念是,GPU 应承担繁重的工作。剔除、细节层次选择、间接绘制分发以及光照评估均在 GPU 上完成,而每一帧的 CPU 端开销都受到限制,通常无论屏幕上有多少内容,都保持恒定时间。它由许多小型、独立的组件构建而成,完全相同的渲染器既可以在原生桌面后端运行,也可以通过 WebGPU 在浏览器中运行,而无需你编写两个版本。

本 README 是一份导览:介绍引擎的构建方式、如何绘制一帧、原生和 Web 构建如何共享同一代码库,以及如何通过自定义材质着色器、后处理效果和渲染通道来超越默认设置。

引擎的构建方式

塑造其他一切的关键在于,每个渲染通道都是其自身的 crate。G-buffer 填充、延迟光照、时间抗锯齿、天空、水体模拟以及四十多个其他部分,各自位于一个 helio-pass-* crate 中,而核心的 helio crate 对它们的存在一无所知。一个通道只是一个实现了 helio-core 中某个 trait 的结构体,并声明它读取和写入哪些命名资源。图构建器将通道串联成流水线。添加一个全新的效果永远不需要编辑核心;你只需编写一个 crate 并将其放入图中。这一约束使引擎的核心部分保持精简,并让实验真正变得令人愉悦。

由于 GPU 驱动绘制,CPU 从不遍历绘制调用。场景数据驻留在 GPU 缓冲区中,CPU 端带有脏标记镜像,因此当你修改一个对象并调用 flush() 时,仅该对象的字节会被上传。场景本身基于句柄:网格、材质、光源或对象都是一个由代际竞技场支撑的小型 Copy 句柄,插入、更新或删除其中任何一个都是常数时间操作,无需担心悬空引用。

渲染图对其自身生命周期稍作智能处理。当你构建一个渲染图时,构建器会在其中嵌入一个重建闭包,渲染器在构造时将其提取出来。实际结果是,调整窗口大小会重建整个管线,重新创建深度目标,并为你重新连接所有内容,你的代码中无需任何调整大小的样板代码。

如果你想了解各部分的位置:helio 是你进行编程的公共 API(渲染器、场景、相机、Radiant 材质系统、调试辅助工具),helio-core 是图运行时和 RenderPass trait,libhelio 包含诸如 GpuLightGpuMaterial 等纯 GPU 共享结构体,helio-default-graphs 包含现成的管线,helio-pass-* crates 是各个通道,helio-wasm 是跨平台应用运行器,helio-web-demos 将每个示例编译为 WebAssembly,helio-asset-compat 处理模型加载。可运行的原生演示和编辑器位于 crates/examples

绘制一帧

看到内容的最快方式是运行其中一个演示:

cargo run -p examples --bin indoor_cathedral --release
cargo run -p examples --bin outdoor_city --release
cargo run --bin web                # build every demo to WASM and serve it locally

要将渲染器连接到您自己的窗口,其结构始终相同。您向 Helio 查询针对您拥有的适配器所需的 GPU 特性和限制,创建一个配置和一个场景,构建一个图,并将所有这些交给 Renderer::new。从那时起,您每帧调用一次 render,并传入一个相机和一个表面视图。

use helio::{Camera, DebugDrawState, Renderer, RendererConfig, Scene,
            required_wgpu_features, required_wgpu_limits};
use helio_default_graphs::build_default_graph;

let features = required_wgpu_features(adapter.features());
let limits   = required_wgpu_limits(adapter.limits());
// ... create your device and queue with those ...

let config = RendererConfig::new(width, height, surface_format);
let scene  = Scene::new(device.clone(), queue.clone());
let debug_state = std::sync::Arc::new(std::sync::Mutex::new(DebugDrawState::default()));

let graph = build_default_graph(
    &device, &queue, &scene, config,
    debug_state.clone(), &debug_camera_buf, &cull_stats_buf, None,
);
let mut renderer = Renderer::new(
    device.clone(), queue.clone(),
    config.surface_format, config.width, config.height, config.render_scale,
    config, scene, graph, debug_state, debug_camera_buf, cull_stats_buf,
);

let camera = Camera::perspective_look_at(
    glam::Vec3::new(0.0, 2.0, 6.0), glam::Vec3::ZERO, glam::Vec3::Y,
    60_f32.to_radians(), width as f32 / height as f32, 0.1, 1000.0,
);

renderer.render(&camera, &surface_view)?;

这是底层路径,值得理解一次。在实践中,如果你希望代码也能在浏览器中运行,你完全不应该手动实现窗口管理。这正是下一节的内容。

一套代码库,原生与 Web

Helio 运行在原生后端(Vulkan、Metal、DX12、GLES)以及浏览器中的 WebGPU 上,并且这不是一个会不同步的移植版本。它是通过同一张图驱动的同一个渲染器。你实际接触的部分在两个目标平台上是完全相同的。构建图、添加通道、将其锁定到特定尺寸、插入材质和光源、调用 render、绘制调试形状:这些都不包含任何特定于目标的代码。确实存在的差异位于通道内部,由引擎为你处理,以及位于窗口层,由共享运行器为你处理。

值得铭记的一点是,WebGPU 是一个比原生驱动程序更小的目标,而 Helio 会默默地适应它。在桌面端后端,一个材质最多可以引用二百五十六个无绑定纹理;而在 Web 端,这一上限为十六个,并且这些限制会自动为你进行钳制。当原生端发出单次多绘制间接调用时,Web 构建版本会循环并逐个发出间接绘制,因为 WebGPU 没有多绘制功能。原生端会向适配器请求无绑定纹理数组和非均匀索引;而 Web 构建版本仅要求间接首实例特性。你不需要自己编写这些代码,但这确实意味着“相同的 API”并不完全等同于“相同的能力”。一个保持在 Web 限制范围内的场景在两个地方看起来完全相同,而一个超出该限制的场景(例如,在单次绘制中使用数百个唯一的材质纹理)在浏览器中可能看起来不同或无法启动。这是一个内容预算,而不是代码中的分支。保持在正确一侧的方法是始终通过 required_wgpu_featuresrequired_wgpu_limits 来构建你的设备,它们会精确请求 Helio 在你当前目标平台所需的内容。

使单源演示得以工作的抽象是 helio-wasm crate 中一个名为 HelioWasmApp 的 trait。你实现它并调用 launch::<T>(),在原生平台上它会启动一个 winit 窗口,而在浏览器中则附加一个 WebGPU canvas。runner 拥有事件循环、surface、输入以及相机管线,而你只需关注两个真正重要的方法:init,在其中你构建一次场景;以及 update,在其中你读取输入、进行动画处理,并返回本帧的相机。

use std::sync::Arc;
use helio::{Camera, Renderer};
use helio_wasm::{HelioWasmApp, InputState, launch};

struct Demo { /* camera state, handles, whatever you need */ }

impl HelioWasmApp for Demo {
    fn title() -> &'static str { "My Demo" }

    fn init(renderer: &mut Renderer, _device: Arc<wgpu::Device>,
            _queue: Arc<wgpu::Queue>, _w: u32, _h: u32) -> Self {
        renderer.set_ambient([0.4, 0.45, 0.5], 0.15);
        // build meshes, materials, lights here
        Demo { /* ... */ }
    }

    fn update(&mut self, renderer: &mut Renderer, dt: f32, elapsed: f32,
              input: &InputState) -> Camera {
        // read input.keys / input.mouse_delta, move the camera, return it
        Camera::perspective_look_at(/* ... */ input.aspect_ratio(), 0.1, 1000.0)
    }
}

fn main() { launch::<Demo>(); }

该 trait 上的其他所有内容都有默认值,因此你只需覆盖你关心的部分。你可以设置窗口标题,选择内部渲染缩放比例(默认以四分之三分辨率渲染并放大,对于没有时间上采样步骤的管线,你会将其设置回 1.0),调整鼠标视角捕获行为,响应窗口大小调整,并且最强大的是,从 build_graph 返回一个完全自定义的渲染图。最后一点是体素和 VHS 演示如何在两个目标上运行的同时插入自己的管线的方式;返回 None 则使用标准的延迟渲染图。你每帧获得的 InputState 包含按下的按键、鼠标增量、光标是否被捕获、一帧的左键边缘、光标位置、视口大小,以及一个 aspect_ratio() 辅助函数。

构建 Web 版本是一个独立的小工具,而不是 shell 脚本。运行 cargo run --bin web 会打开一个终端 UI,将每个演示构建为 WebAssembly,然后在本地端口上提供所有这些内容,而 cargo run --bin web -- --headless 在没有 UI 的情况下执行相同操作,写出完成的站点,如果任何演示未构建则退出并返回失败代码。无头模式是持续集成运行的模式。在底层,它为每个演示调用 wasm-pack,并写出每个着陆页以及一个主索引。唯一的先决条件是一个支持 wasm 的 clang 用于 C 依赖项,这意味着安装 LLVM(在 macOS 上是 brew install llvm,或在 Linux 上是你发行版的 clang 包)。

使用渲染图构建管线

渲染图是一组有序的通道,每个通道声明其读取和生成的命名资源,该图验证其依赖结构,管理通道之间的瞬态纹理和屏障,并在窗口大小变化时重建自身。大多数时候,你无需直接构建它,因为构建器会完成这项工作:build_default_graph 提供完整的延迟管线,而 build_default_graph_with_user_effects 提供相同的内容,但包含一个用于注入后处理 WGSL 的插槽。

当你确实需要定制内容时,你可以自行构建该图,无论最终运行在桌面还是浏览器中,其读取方式完全相同。例如,此代码片段就是整个体素管线,一个网格提取通道馈送一个 FXAA 通道:

use helio::RenderGraph;
use helio_pass_voxel_mesh::VoxelMeshPass;
use helio_pass_fxaa::FxaaPass;

let mut graph = RenderGraph::new(device, queue);
graph.add_pass(Box::new(VoxelMeshPass::new(device, queue, config.surface_format)));
graph.add_pass(Box::new(FxaaPass::new(device, config.surface_format)));
graph.lock(config.width, config.height);

Passes 通过诸如 "gbuffer""pre_aa" 这样的资源名称相互通信;一个 pass 声明它读取什么和写入什么,而图则连接这些线路。一旦图开始运行,你就可以通过 renderer.find_pass_mut::<FxaaPass>() 回溯到其中并按类型获取任意 pass,这就是你调整 pass 设置或向其提供逐帧数据的方式。

与场景交互

场景是 GPU 原生的,其中的一切都是句柄。你插入一个材质并获得一个 MaterialId,插入一个网格并获得一个句柄,插入一个指向两者的对象,并插入灯光。你保留这些句柄以便稍后更新或移除事物,并且上传是脏跟踪的,因此任何你未更改的内容都不会被重新发送。

let scene = renderer.scene_mut();

let material = scene.insert_material(GpuMaterial { /* base_color, roughness_metallic, ... */ });
let mesh     = scene.insert_actor(helio::SceneActor::mesh(mesh_upload));
let object   = scene.insert_actor(helio::SceneActor::object(ObjectDescriptor {
    mesh, material, transform, /* bounds, groups, movability, ... */
}));
let light    = scene.insert_actor(helio::SceneActor::light(GpuLight { /* ... */ }));

当你需要时,表面之下还有更多。每个对象都携带一个六十四位组掩码,因此你可以在一次调用中隐藏、显示或变换整个对象组。网格可以被分割为多个部分,即一个顶点缓冲区配合多个索引范围,这是 Unreal 风格在单个模型上应用多种材质的方式。体素体积通过 insert_voxel_volume 输入,并被网格化和光线步进体素通道共享。诸如环境光、清除颜色、编辑器模式和时间抖动等全场景参数位于渲染器上,分别为 set_ambientset_clear_colorset_editor_modeset_jitter_enabled,而 scene.clear() 则重置一切。

编写你自己的材质着色器

Helio 中的材质通过一个名为 Radiant 的系统处理,这是“所有物体使用一个固定着色器”与“每种材质都是定制管线”之间的一条刻意选择的中间路径。它融合了内置的物理基于着色器、手工编写的表面模板以及由外部图编译器生成的 WGSL 代码片段,并通过让 G-buffer 通道通过一个带有标记注入点的单一共享函数来评估每种材质来实现这一点。自定义代码在这些标记处拼接进去,而引擎对此毫不知情。

共有三个层级,它们共享同一套成本模型。绝大多数材质永远不会离开第一层级,该层级仅仅是内置着色器上的功能开关;你只需切换一个法线贴图位或一个 alpha 测试位,就不会编译任何新内容。第二层级用于那些行为确实不同的表面原型,例如清漆、皮肤、头发、织物或薄膜虹彩。你为表面编写一个小型 WGSL 模板,可选地将其与一个图片段配对,并为每个模板支付恰好一条流水线的成本。第三层级是由图编译器输出的完全自定义表面,每个唯一片段对应一条流水线,用于那些完全不符合任何模板的表面。无论材质使用哪个层级,G-buffer 通道都会根据材质类别和图哈希对实例进行排序,为每条流水线发出一次绘制调用,并根据模板、图和标志的组合缓存已编译的着色器。关键在于光照通道永远不会改变,因为每个变体都写入相同的 G-buffer 格式。

材质是一个普通结构体。其基础颜色、自发光以及打包的粗糙度/金属度/IOR/色调值是普通的 PBR 输入,纹理字段是无绑定索引,flags 字段驱动第一层级,material_class 选择一个模板(零表示内置着色器),而 class_params 是四个自由浮点数,当前激活的模板可以按任意方式解释它们。

GpuMaterial {
    base_color:         [f32; 4],   // linear RGBA
    emissive:           [f32; 4],   // RGB + strength
    roughness_metallic: [f32; 4],   // x = roughness, y = metallic, z = IOR, w = specular tint
    tex_base_color, tex_normal, tex_roughness, tex_emissive, tex_occlusion: u32,
    workflow:       u32,
    flags:          u32,            // FLAG_HAS_NORMAL_MAP | FLAG_ALPHA_TEST | ...   (tier 1)
    material_class: u32,            // 0 = built-in PBR, 1+ = a template            (tier 2/3)
    class_params:   [f32; 4],       // free parameters read by the active template
}

保持在第一层是一个仅切换标志位的单次调用:

scene.set_material_class(material_id, 0, 0, Some(FLAG_HAS_NORMAL_MAP | FLAG_ALPHA_TEST));

二级模板是一个完整的 WGSL 文件,它定义 radiant_eval_surface,返回流水线其余部分所消费的 surface 数据。它包含两个标记注释,如果附加了图片段,编译器将替换它们之间的所有内容;如果没有,则标记会被简单移除,你的模板将按原样运行。下面的示例读取两个免费的 class_params 以驱动薄膜效果,而 crates/examples/shaders/radiant_iridescent.wgsl 是一个完整的工作版本。

fn radiant_eval_surface(material: GpuMaterial,
                        material_tex: MaterialTextureData,
                        input: VertexOutput) -> SurfaceData {
    var s = default_pbr_surface(material, material_tex, input);

    let film_freq = material.class_params.x;
    // ... your surface math, e.g. thin-film interference on s.f0 ...

    // RADIANT_OVERRIDE_SURFACE
    // RADIANT_OVERRIDE_END
    return s;
}

注册模板和图是一段简短的设置。您使用 scene.radiant_graphs.register(graph_hash, wgsl_source) 在场景中注册一个已编译的代码片段,通过 G-buffer 通道加载一个模板,然后将材质指向该模板和代码片段。

use helio_pass_gbuffer::GBufferPass;

let reg = renderer.find_pass_mut::<GBufferPass>()
    .map(|p| p.template_registry_mut()).unwrap();
let template_id = reg.load_from_file("templates/clear_coat.wgsl").unwrap();
// or reg.register_str("iridescent", wgsl_source);

scene.set_material_class(material_id, template_id, graph_hash, Some(flags));

编写你自己的后处理着色器

后处理通道会运行常规的曝光、泛光、色调映射、颗粒、晕影和色差处理链,并允许你将自定义的 WGSL 代码插入到该链中的固定位置。后室(backrooms)演示正是通过这种方式,在渲染图像上叠加了完整的 VHS 摄像机效果。

一个效果是一个 WGSL 函数体,它接收当前颜色并返回一个新颜色。引擎会将其包装在签名 (color: vec3<f32>, uv: vec2<f32>, dims: vec2<f32>) -> vec3<f32> 中,因此最简单的效果就是用于暖色调的 return color * vec3<f32>(1.0, 0.95, 0.9);。你通过位置选择在链中的哪个阶段运行它:在混合阶段之前、色调映射之后、颗粒之后,或者在所有内置效果之后的最末端。在代码片段内部,你可以访问引擎提供的两样东西:一个名为 pp_custom 的存储数组,其中包含你每帧上传的 vec4 参数,以及一个用于颗粒和抖动效果的平铺噪声纹理和采样器。

有两种方式可以传入你的 WGSL 代码。对于在末尾运行的整帧效果,你在图构建时传入它,VHS 演示正是这样做的:

const VHS: &str = include_str!("vhs_effects.wgsl");

let graph = build_default_graph_with_user_effects(
    &device, &queue, &scene, config,
    debug_state, &debug_camera_buf, &cull_stats_buf,
    None,   // debug overlay
    VHS,    // your injected WGSL
);

或者,您可以在运行时向实时通道添加效果并提交它们,这将重建管道:

use helio_pass_postprocess::{PostProcessPass, UserEffectPosition};

if let Some(pp) = renderer.find_pass_mut::<PostProcessPass>() {
    pp.add_user_effect(UserEffectPosition::PostTonemap, "return color * 0.85;");
    pp.commit_user_effects(&device);
}

无论哪种方式,你都通过上传它从 pp_custom 中读取的参数,逐帧驱动特效:

if let Some(pp) = renderer.find_pass_mut::<PostProcessPass>() {
    pp.set_custom_params(&[
        [0.0, 0.12, 8.0, 0.2],    // tape jitter, frequency, flicker
        [0.4, elapsed, 0.0, 0.0], // grain amount, animation time
    ]);
}

整条链由后处理体积(post-process volume)控制,因此要获得全局效果,只需插入一个无界体积(unbounded volume)。将其内置设置保持为默认值,可使标准链保持中性,从而让你注入的着色器(shader)完全掌控整体视觉效果:

scene.insert_actor(helio::SceneActor::post_process_volume(PostProcessVolumeDescriptor {
    bounds_min: [-1000.0; 3], bounds_max: [1000.0; 3],
    unbound: true, priority: 100.0, blend_weight: 1.0, blend_radius: 0.0,
    settings: PostProcessSettings::default(),
}));

对于一个真实的、非平凡的示例,crates/helio-web-demos/examples-wasm/vhs_effects.wgsl 是一个完整的摄像机着色器,包含色散、YIQ 色差漂移、跟踪噪声、帧底部的换头条、颗粒感和闪烁。

编写你自己的渲染通道

如果你需要执行现有通道无法完成的操作,就编写你自己的通道。通道是任何实现了 helio-coreRenderPass trait 的结构体。它为自己命名,告知图形它读取和写入的资源,并在 execute 中执行其工作,在那里它记录 GPU 命令并直接从上下文中读取场景数据,无需复制。有一个可选的 prepare 步骤用于每帧上传和调整大小。

use helio_core::{RenderPass, PassContext, PrepareContext, Result};

struct MyPass { /* pipelines, buffers, ... */ }

impl RenderPass for MyPass {
    fn name(&self) -> &'static str { "MyPass" }

    fn reads(&self)  -> &'static [&'static str] { &["gbuffer"] }
    fn writes(&self) -> &'static [&'static str] { &["pre_aa"] }

    fn prepare(&mut self, ctx: &PrepareContext) -> Result<()> { Ok(()) }

    fn execute(&mut self, ctx: &mut PassContext) -> Result<()> {
        // begin a render or compute pass on ctx, set pipelines and bind groups,
        // read scene resources from ctx.scene, issue your draws or dispatches
        Ok(())
    }
}

graph.add_pass(Box::new(MyPass::new(&device)));

一个用于重建时间数据的 Pass 会启用相机抖动,这使得渲染器默认保持非时间管线像素稳定。CPU 和 GPU 的性能分析会自动注入到每个 Pass 周围,并且调试构建会告知你某个 Pass 是否声明了从未实际写入的资源。

调试

Helio 在渲染器上提供了一个即时模式调试绘制 API。使用 set_editor_mode(true) 开启编辑器模式,在帧的顶部调用 debug_clear(),然后通过调用对应的 debug_* 方法来绘制线条、球体、圆形、环面、圆柱体、圆锥体和视锥体;默认图的调试 Pass 会渲染它们。debug_shapes 演示是所有图元的一个实时画廊。当演示运行时,F2 切换显示帧率和计时的叠加层(带有用于自定义每帧文本的钩子),F3 和 F4 循环切换调试视图,如 UV、世界空间法线、反照率、粗糙度、阴影热图、LOD 热图和过度绘制。

Pass 提供的功能

完整的管线由各个 pass crate 组装而成,了解其大致内容是有价值的。几何体经过早期的深度预通道和 GPU 驱动的 G-buffer 填充,该过程同时评估 Radiant 材质,并通过 meshlet 级别的虚拟几何剔除和分层 Z 遮挡系统,将隐藏三角形排除在 GPU 之外。光照采用延迟渲染,使用 Cook-Torrance BRDF 以及基于 tile 和 cluster 的光源剔除,使数百个光源保持低成本,配合带有软过滤的级联阴影贴图、屏幕空间环境光遮蔽,以及用于多反弹间接光照的 Radiance Cascades 全局光照通道。天空采用 Hillaire 大气模型,并包含体积云。抗锯齿提供时间域和空间域两种形式(TAA、FXAA、SMAA),后处理通道负责处理曝光、泛光、色调映射以及上文描述的用户 WGSL。在上述所有功能之上,还有针对体素地形的专用通道(包括网格化路径和逐像素光线步进路径)、带有焦散和水下视觉效果的水体模拟与表面渲染、排序前向透明渲染,以及调试和性能叠加层。其中每一项都是一个独立的 crate,你只需组合特定管线所需的那些。

示例与演示

crates/examples 目录包含原生二进制文件,并且完全相同的演示通过 helio-web-demos 在浏览器中运行。其中包括一座由 Radiance Cascades 全局光照和彩色玻璃光束照亮的室内大教堂、一座密集的夜间城市、一个按 Q 和 E 键可旋转太阳的沙漠峡谷、一个轨道空间站、一艘在陨石带中飞行的六自由度飞船、带有注入后处理着色器的 VHS 后室、通过自定义网格加 FXAA 图渲染的可编辑体素地形、调试形状画廊、一个可拾取和移动物体的交互式编辑器、一个即插即用的 FBX/glTF/OBJ/USD 查看器、一个推动一百二十八个动画点光源的基准测试,以及一个用于起步的极简飞行相机场景。使用 cargo run -p examples --bin <name> --release 原生运行其中任意一个,或运行 cargo run --bin web 一次性为浏览器构建所有演示。

Assets

模型加载通过 helio-asset-compat 支持 FBX、glTF、OBJ 和 USD。对于静态几何体,有一个烘焙 crate,可预计算环境光遮蔽、光照贴图、反射探针和辐照度球谐函数,以及用于 CPU 端剔除的潜在可见集。在 Web 上,您使用 include_bytes! 从编译时嵌入的字节中加载资产,而不是从磁盘加载,load_fbx_embedded 演示展示了这一点。

License

Helio 采用 MIT 许可证。版权所有 2026 Tristan Poland。请参阅 LICENSE 获取完整文本。