在可视化领域,实时渲染通常依赖光栅化管线,但科学计算与工程仿真场景常常需要更精确的光照模拟与材质表现。Makie.jl 作为 Julia 生态的核心可视化库,近期在其生态中构建了一套完整的 GPU 光线追踪管线,由 RayMakie(渲染后端)、Hikari(路径追踪器)与 Raycore.jl(光线 - 三角形求交与 BVH 引擎)三个组件构成。与传统的 Python/C++ 光线追踪实现不同,这套管线深度整合了 Makie 的场景图 API,同时通过 KernelAbstractions.jl 实现了跨 CUDA、AMDROC、Metal 等后端的代码复用。本文将解析其物理渲染管线的各阶段实现,并给出 GPU 编译优化的具体参数建议。
三组件架构:后端、追踪器与求交引擎
Makie 的光线追踪管线并非单一黑盒,而是由三个职责分明的模块组成。Raycore.jl 负责最底层的几何处理:它提供了 BVH(层次包围盒)加速结构的构建算法,以及针对三角形网格的高性能光线 - 求交核函数。这些核心算法同时提供了 CPU 与 GPU 两套实现,GPU 版本使用 KernelAbstractions.jl 编写,能够无缝切换到 CUDA.jl、AMDGPU.jl、Metal 或 oneAPI 后端。Raycore 的设计理念是作为一个「无状态」的几何引擎,仅接收场景数据并输出光线求交结果,不涉及材质或光照模型的实现。
Hikari 则是位于 Raycore 之上的路径追踪器(path tracer),它实现了基于物理的 BSDF(双向散射分布函数)模型,包括金属、介电介质(如玻璃、水)、体积介质等常见材质。Hikari 提供了 VolPath(体路径追踪)积分器,支持全局光照与体积散射效果。值得注意的是,Hikari 的材质系统是纯 Julia 结构体,方法直接运行在 GPU kernel 内部,这意味着用户可以通过多重派发(multiple dispatch)自定义散射、发射乃至时空度量 —— 官方博客中演示了通过覆写 Hikari.apply_deflection 方法实现基于 Schwarzschild 度规的光线偏折,模拟引力透镜效应,而无需任何外部 FFI 调用。
RayMakie 扮演的是「桥梁」角色:它作为 Makie 的渲染后端,接收用户通过标准 Makie API(Scene、mesh!、volume!、灯光、相机)构建的场景图,而不是重新设计一套独立的场景描述语言。当用户调用 colorbuffer(scene; device=CUDABackend(), integrator=Hikari.VolPath(...)) 时,RayMakie 遍历场景图、提取可渲染图元、将其转换为 Raycore 几何缓冲区,然后交由 Hikari 执行路径追踪。这一设计使得任何现有的 Makie 场景都可以「零 API 改动」地切换到光线追踪模式进行离线渲染。
GPU 路径追踪管线的六个阶段
在 GPU 端,这套光线追踪管线遵循现代波前(wavefront)路径追踪架构,具体可分为以下六个阶段:
阶段一:场景准备。 Makie 场景中的网格、体数据、实例变换、材质与灯光信息被提取出来,转换为 Raycore 的几何缓冲区格式(顶点 / 索引数组、实例变换矩阵、材质参数结构体)。这一过程在 CPU 端完成,生成的数据结构随后通过 to_gpu 等辅助函数上传到 GPU 显存。
阶段二:BVH 加速结构构建。 Raycore 根据场景几何数据构建层次包围盒,BVH 的构建可以在 CPU 或 GPU 端进行,取决于场景规模与配置。对于动态场景,可以选择仅在初始化时构建一次 BVH;对于需要每帧更新的场景,GPU 端构建能够减少数据传输开销。BVH 的质量直接影响后续光线求交的效率,典型的构建策略是使用 SAH(表面积启发式)算法进行空间分割。
阶段三:光线生成。 对于输出图像的每个像素,系统从 Makie 相机的参数(位置、朝向、投影矩阵)计算主光线(primary ray)的方向与起点。最简单的实现是每个 GPU 线程对应一个像素;更进阶的方案会采用波前调度,将光线按求交、 shading、阴影等阶段分组处理,以减少线程束(warp)分歧并优化显存访问模式。Hikari 的传感器模型(胶片、ISO、白平衡)也在此阶段被考虑进去。
阶段四:求交与着色循环。 每条活跃光线遍历 BVH,使用 Raycore 的 GPU kernel 找到与三角形或其他图元的最近交点。命中后查询对应材质模型(金属、介电介质、体积),并调用 Hikari 的光照采样例程计算直接光照与间接光照。关键的光照计算包括:重要性采样(根据 BSDF 的概率密度函数选择采样方向)、俄罗斯轮盘赌(Russian roulette)用于路径终止判定。随后生成次级光线(反射、折射、阴影、散射),在波前实现中这些光线被推入各阶段的队列,等待下一轮 kernel 处理。
阶段五:波前路径追踪。 与传统每条线程处理完整路径的做法不同,波前架构将光线按处理阶段分组:求交 kernel 处理所有光线与场景的交点,shading kernel 计算光照与材质响应,shadow kernel 处理阴影光线。分离的队列与专用 kernel 提高了 warp 占用率与吞吐量,特别适用于高弹跳次数的复杂场景与体路径追踪。Hikari 的 VolPath 积分器正是基于这一架构实现体积全局光照。
阶段六:累积与色调映射。 每个像素的辐射亮度在多次采样间累积,存储在 GPU 缓冲区中。采样完成后,执行色调映射 pass 并应用传感器模型(曝光、色彩平衡),输出符合 Makie colorbuffer 接口的 RGB 图像。RayMakie 还提供了叠加渲染器,能够将标准 Makie 的 2D 元素(文字、图例、色标、线框)与光线追踪的 3D 场景合成到同一图像中。
KernelAbstractions.jl 的编译策略与性能调优
Raycore 与 Hikari 的 GPU kernel 均基于 KernelAbstractions.jl 编写,这一抽象层带来了跨后端的可移植性,但也引入了特定的编译与运行时特性,理解这些特性对于调优至关重要。
JIT 编译延迟是首要考量。 与原生 CUDA C 类似,Julia 的 GPU kernel 同样采用 JIT(即时编译)机制。每个唯一的 kernel 函数、后端(CUDA vs AMDGPU)与参数类型组合都会触发一次全新的编译。KernelAbstractions 在此基础上增加了一层抽象,因此首次运行的延迟通常略高于直接使用 @cuda 或 @roc 编写的等效 kernel。不过,一旦编译完成,稳态运行时性能可以非常接近 —— 已有基准测试表明,正确编写的 Julia GPU kernel 在 Rodinia 等标准测试套件上能够匹配 CUDA C 的性能。实际工程中常用的策略包括:在启动阶段用小规模 dummy 数据「预热」kernel,触发编译并缓存 kernel 对象(例如 k! = mykernel!(backend) 后复用)。
运行时性能的后端差异需要关注。 KernelAbstractions 的 kernel 最终映射到各后端的编译器栈(CUDA.jl、AMDGPU.jl 等),性能的主要决定因素在于 kernel 本身对 GPU 的友好程度(内存访问模式、线程分歧、占用率)以及后端的成熟度。CUDA.jl 经过多年优化,通常是性能最优的选择;对于 AMDGPU.jl,部分模式可能表现出略高的启动开销或缺失某些优化,但 KernelAbstractions 本身并非瓶颈。实际调优时应遵循以下原则:避免在设备数组上使用标量索引(在 CUDA 中可设置 CUDA.allowscalar(false));在 kernel 内部使用 @inbounds 并避免动态派发;确保参数类型具体化,避免捕获堆分配的闭包。
启动配置与占用率控制。 使用原生 CUDA.jl 时,开发者通常调用 launch_configuration 手动选择 threads/blocks 以获得最佳占用率。KernelAbstractions 为多数后端提供了自动工作组大小选择,开发者只需指定 ndrange = N,系统会选取合理的配置 —— 这在保持可移植性的同时通常能获得接近手动调优的性能。如果对特定后端有极致性能需求(如 NVIDIA),仍可在关键 kernel 上使用原生 @cuda 编写专用路径,保持 KernelAbstractions 版本作为跨平台的默认实现。
工程落地的关键参数与监控点
将 Makie 的光线追踪管线投入生产使用时,以下参数与监控点值得特别关注:
采样数与最大弹跳数。 Hikari.VolPath(samples = 100, max_depth = 12) 中的 samples 控制每个像素的采样数量,直接影响噪点水平与渲染时间 ——100 次采样通常是质量与速度的良好平衡点。max_depth 限制光线的最大弹跳次数,12 次弹跳足以处理大多数包含反射、折射与体积散射的场景,但会显著增加 GPU 内存压力与计算时间。
BVH 构建策略。 场景规模较小时(数千个三角形),CPU 端 BVH 构建通常足够快;场景规模超过数十万三角形时,应考虑 GPU 端构建以避免 CPU-GPU 数据传输瓶颈。Raycore 提供了 bvh(geometry; builder = SAH()) 等 API,可通过调整 builder 参数在构建速度与 BVH 质量间取得平衡。
GPU 内存监控。 体路径追踪需要在显存中同时持有几何数据、BVH 结构、材质缓冲区、多次弹跳的光线队列以及累积缓冲区。实际部署时应通过 CUDA.memory_status() 或 AMDGPU.memory_status() 监控显存占用,避免 OOM(内存溢出)。对于超大场景,可考虑分块渲染(tile-based rendering)或降低 max_depth 以控制显存使用。
编译缓存与预热。 在交互式环境或需要频繁重绘的场景中,务必在初始化阶段执行一次完整的渲染预热。示例模式如下:
using RayMakie, Hikari, CUDA
# 初始化场景
scene = Scene(size = (1920, 1080), lights = [SunSkyLight(Vec3f(1, 2, 8))])
cam3d!(scene)
mesh!(scene, my_mesh; material = Hikari.Gold(roughness = 0.1))
# 预热:首次渲染触发 JIT 编译
_ = colorbuffer(scene; device = CUDABackend(), integrator = Hikari.VolPath(samples = 1, max_depth = 4))
# 正式渲染
img = colorbuffer(scene; device = CUDABackend(), integrator = Hikari.VolPath(samples = 100, max_depth = 12))
通过这种预热策略,可以将首次渲染的数秒编译延迟从用户关键路径中移除。
Makie 的 GPU 光线追踪管线展示了 Julia 生态在高性能图形渲染领域的成熟度:它既保持了 Makie API 的一致性(场景图复用、零 API 改动的后端切换),又通过 KernelAbstractions.jl 实现了真正的跨后端可移植性。对于需要物理精确渲染的科学可视化项目,这套管线提供了可直接落地的工程化参数 —— 从采样策略到 BVH 构建,从编译预热到显存监控,开发者可以在生产力与性能之间取得可控的平衡。
资料来源:
- Makie 官方博客:Ray Tracing in Makie(makie.org/website/blogposts/raytracing/)
- KernelAbstractions.jl 官方文档(juliagpu.github.io/KernelAbstractions.jl/)