Neural Shading 导论
1. 背景与动机:为何需要 Neural Shading?
图形学在过去40年里一直致力于在极短时间(16ms/33ms)内创造高保真的3D世界。虽然现有的实时渲染技术(如光线追踪)已经取得了巨大进步(比10年前快了约10,000倍),但我们仍面临严峻挑战:
- 复杂度的规模化难题(The "Strawberry" Problem):
- 我们目前可以渲染极其复杂的单个物体(如一颗具有精细毛发、次表面散射的草莓)。
- 核心矛盾:难以将这种级别的细节扩展到整个游戏世界。这面临着存储、传输海量数据以及实时渲染的巨大压力。
- 硬件红利的消退:
- 摩尔定律(Moore's Law)实际上已经失效。硬件不再像过去那样按部就班地每年大幅提升速度。
- 单纯依靠硬件性能的自然增长来实全场景高保真渲染已经不再可行。
2. 核心概念:什么是 Neural Shading?
Neural Shading 并不是指单纯地在渲染中加入神经网络,它的定义更为宽泛和实用。
- 核心定义:
- Neural Shader(神经着色器):指渲染管线中任何可训练(Trainable)的部分。即使它本质上不是严格的“神经网络”,只要能通过优化算法来解决问题,都归入此范畴。
- 运行位置:
- 它不是渲染完成后才运行的独立后处理通道(不同于 DLSS 这样的纯后处理 AI)。
- 它直接运行在渲染管线内部(Inside the rendering pipeline),可以是计算着色器(Compute Pass)的一部分,也可以集成在光线追踪(Ray Tracing)流程中。
3. 技术优势与价值
Neural Shading 旨在榨干现有硬件的最后一滴性能,并用新的思路解决老问题。
- 激活闲置硬件:
- 现代消费级 GPU 都搭载了神经网络加速器(Neural Network Accelerators,如 Tensor Cores)。
- 在传统渲染过程中,这些加速单元通常处于 0% 利用率 的闲置状态(除非在帧末开启 DLSS)。Neural Shading 可以利用这些“沉睡”的算力池。
- 高效计算:
- 神经网络相关的运算在专用硬件上非常高效。在通用计算性能增长放缓的今天,这是一种提高总计算吞吐量的有效手段。
- 从“精确解”转向“优化解”:
- 传统图形学倾向于寻找问题的精确数学解。
- Trainable Shaders 允许我们将问题转化为优化(Optimization)问题。这种近似逼近的方法在处理复杂问题(如压缩、材质表达、几何细节、辐射度缓存、路径引导等)时,往往能以更低的计算成本获得更高质量的结果。
4. 总结与展望
- 实用工具:Neural Shading 不是为了蹭 AI 热度的噱头,而是解决现有渲染瓶颈的务实工具。
- 能力要求:它不会自动解决所有问题。引入它需要新的编程模型、算法,以及工程师掌握优化(Optimization)相关的技能。
- 未来应用:现已可以通过 Vulkan 或 DX(通过 Cooperative Shaders)跨平台访问相关硬件功能,未来有望在渲染管线的各个环节(从几何到光照)发挥作用。
Neural Shading 基础
1. 从“求解”到“优化”:思维方式的转变
传统的图形学工程通常遵循“理解问题 → 寻找精确解 → 编码实现”的流程。然而,面对某些复杂问题时,我们可能找不到精确解,或者精确解的实时计算成本过高。此时,我们需要引入优化(Optimization)的思维方式。
- 核心思想:不再试图直接找到完美的数学公式,而是通过不断调整参数来逼近理想结果。
- 优化流程:
- 构建一个包含大量可调参数的数学模型。
- 准备大量输入数据和对应的理想输出(Desired Outputs)。
- 将输入喂给模型,得到实际输出。
- 计算实际输出与理想输出之间的差异,即误差(Error)或损失(Loss)。
- 根据误差反向调整模型参数,旨在减小下一次的误差。
- 重复上述过程,直到模型足够好用。
2. 工具介绍:Slang & SlangPy
为了实现上述优化流程,课程使用了以下工具:
- Slang:一种现代化的、跨平台的着色器语言。
- 关键特性:支持自动微分(Autodiff)。这意味着它可以自动计算导数,无需手动推导复杂的梯度公式,是进行优化的基础。
- 由 Khronos Group 管理,支持 D3D, Vulkan, Metal, CUDA 等多种后端。
- SlangPy:基于 Python 的 Slang 封装库。
- 提供类似 PyTorch 的功能性 API(Functional API),可以直接在 Python 中调用 Slang 函数。
- 自动处理底层的 GPU 资源分配、计算核心生成等繁琐工作。
3. 实战案例:Mipmap 生成的优化视角
课程以 Mipmap 生成为例,展示了传统方法的局限性以及优化方法的潜力。
3.1. 问题背景:传统 Mipmap 的缺陷
- Mipmap 的作用:减少远处物体的纹理混叠,提高显存访问效率。
- 传统方法:使用简单的盒式滤波器(Box Filter)进行下采样(如取相邻4个像素的平均值)。
- 局限性:
- 对于颜色贴图(Albedo),简单平均通常效果尚可。
- 对于几何相关贴图(Normal, Roughness, Displacement),简单平均会导致严重错误。例如,将法线贴图中的一个“峰”和一个“谷”平均,会得到一个平坦的表面,丢失了原有的光照细节,甚至引入错误的镜面高光(Specular Artifacts)。
3.2. 寻找“理想解”(Ground Truth)
为了进行优化,首先需要定义什么是“好”的结果。
- 低质量参考:直接对 2K 纹理进行下采样得到的 512x512 纹理,渲染结果充满噪点和错误的亮斑。
- 高质量参考(Ideal Output):
- 方法:超采样(Super Sampling)。在 2K 分辨率下进行完整的 PBR 渲染计算,然后将渲染结果图像下采样到 512x512。
- 效果:图像稳定,噪点极少,保留了正确的材质光感。
- 代价:计算量巨大(例如 16 倍的着色计算),无法实时运行,但可以作为离线训练的“金标准”。
3.3. 定义“损失函数”(Loss Function)
为了量化当前 Mipmap 的好坏,我们需要一个损失函数来计算它与“金标准”之间的差距。
- Loss 计算方式:
- 输入:低分辨率材质参数(当前优化的对象)、光照方向、视角方向、高分辨率超采样得到的参考颜色。
- 过程:用低分辨率材质进行一次实时渲染,得到当前颜色。
- 输出:
(当前颜色 - 参考颜色)^2,即均方误差(MSE)。
- Loss Texture:将每个像素的误差可视化为一张纹理。越亮的地方表示误差越大,也就是需要重点优化的地方。
- 目标:通过调整低分辨率材质的纹理像素值(不仅仅是简单的平均),使得最终渲染出的 Loss Texture 尽可能变黑。
Neural Shading 核心概念
1. 闭环优化:从推理到反向传播
在上一节中,我们建立了推理阶段(Inference Phase):输入参数(Mipmaps) → 渲染函数 → 计算损失(Loss)。现在我们需要“闭环”,即找出如何调整输入以减小损失。
- 核心任务:确定输入()需要如何变化,才能使输出的损失()下降。
- 数学工具:导数(Derivative)。它量化了输入的变化如何影响输出。
- 反向阶段(Backwards Phase):代码需要反向运行,利用导数从损失函数回溯到输入参数,计算出为了降低误差,输入纹理的每个像素应该如何调整。
2. 自动微分(Autodiff):解放生产力
手动推导和编写复杂渲染管线的反向传播代码极其繁琐且容易出错。一旦前向渲染代码(Inference Code)发生任何变动(如修改材质模型),反向传播代码必须重写。
- Slang 的解决方案:利用编译器的自动微分(Autodiff)功能。
- 你只需编写前向渲染代码。
- Slang 编译器在编译时自动分析代码逻辑,生成优化的反向传播导数代码。
- 优势:极大地提高了迭代速度,保证了前向和反向代码始终同步。
- 处理非数学操作:
- 对于常规数学运算,编译器知道如何求导。
- 对于纹理读取(Texture Reads)等内存操作,并没有显而易见的数学导数。
- 自定义导数:Slang 允许我们为特定函数定义自定义的导数行为。在本例中,我们需要告诉编译器,当读取纹理时,我们关心的是纹素存储值(Texel Values)本身的导数,从而将其存入专门的“导数纹理”中。
3. 优化器:应对噪声的艺术
有了导数,我们就可以更新输入参数了。最简单的方法是梯度下降(Gradient Descent):
- 学习率(Learning Rate, ):一个需要手动调整的自由参数,控制每次更新步长的大小。太小收敛慢,太大可能导致震荡发散。
3.1. 挑战:噪声梯度(Noisy Gradients)
- 理想情况:为了得到完美的导数,我们需要遍历所有可能的光照方向和视角方向,计算总损失后求导。这在计算上是不可能的。
- 实际做法:每次迭代随机选择一对光照和视角方向计算损失。
- 后果:由此得到的梯度充满了噪声(Stochastic Noise),类似于蒙特卡洛渲染中的噪点。朴素的梯度下降法难以处理这种剧烈波动的梯度,导致优化失败。
3.2. 解决方案:Adam 优化器
- 为了应对噪声梯度,业界通用的标准是使用更高级的优化器,如 Adam。
- 原理:它不仅考虑当前的梯度,还维护了历史梯度的移动平均(Moving Average),从而平滑了噪声,并能自适应地调整每个参数的更新步长。
- 结果:使用 Adam 后,即使输入梯度有噪声,优化过程也能稳定收敛,生成高质量的 Mipmap。
4. 范式转变:Neural Shading 的真正价值
这一节揭示了 Neural Shading 最激动人心的地方:通用性与可维护性。
- 传统方法(Classical Approach):
- 需要针对每种材质(如特殊的各向异性高光)发明专门的、复杂的下采样算法(如 Michael Toxvig 的工作)。
- 工作量巨大,且难以扩展到新材质。
- Neural Shading 方法:
- 解耦:将“我们想要什么”(通过损失函数定义)与“如何实现”(通过通用优化器实现)分离开来。
- 通用流程:无论材质多么古怪,只要你能写出它的渲染代码(前向传播),Slang 就能自动生成反向代码。你无需为了新材质重新发明下采样算法,只需重新运行优化循环即可。
- 这使得解决复杂图形学问题变得更加容易和自动化。
Shader 中的第一个 MLP
1. 从“学习像素”到“生成像素”
上一节我们学习了如何优化现有的纹理像素(Mipmap)。这一节我们将转向神经网络,探讨能否训练一个网络来直接生成纹理像素。
- 目标:构建一个神经网络,输入纹理坐标 ,输出对应的颜色 。
2. 神经网络基础:神经元(Neuron)
神经元是神经网络的基本构建块。
- 数学模型:
- 输入:向量 。
- 权重(Weights):向量 ,每个输入对应一个权重。
- 偏置(Bias):标量 。
- 线性计算:(点积加偏置)。
- 非线性激活(Activation Function):。激活函数引入了非线性,使网络能解决复杂问题。
- 代码实现:本质上就是一个
for循环,计算加权和,加上偏置,再通过激活函数。
3. 第一次尝试:最简单的网络
我们首先尝试用最简单的网络来拟合一张纹理。
- 网络结构:
- 输入层:2个节点 。
- 输出层:3个节点 。
- 无隐藏层。
- 参数数量: 个权重 + 个偏置 = 9 个参数。
- 结果:只能生成一个简单的颜色渐变(Gradient Fill)。
- 原因:我们试图用区区 9 个参数去拟合包含约 200,000 个像素信息的 256x256 纹理,网络容量严重不足。
4. 改进:引入隐藏层(MLP)
为了提高网络的表达能力,我们需要增加参数,即引入隐藏层(Hidden Layers),构建多层感知机(Multilayer Perceptron, MLP)。
- 改进后的网络结构:
- 输入:
- 隐藏层 1:32 个神经元
- 隐藏层 2:32 个神经元
- 输出:
- 参数数量:增加到 1251 个浮点数参数。虽然比 9 个多得多,但仍远少于原始纹理的 20万个像素数据。
- 代码变更:
- 在 Slang 中定义多层结构,每层都有自己的权重和偏置。
- 前向传播时,数据依次通过每一层:
Input -> Layer0 -> Activation -> Layer1 -> Activation -> Layer2 -> Activation -> Output。
- 结果:网络开始能大致拟合出纹理的轮廓(如中间的两个大圆盘和周围的星星),虽然仍显模糊(Smudges),但已经从单纯的渐变进化到了能表达形状。
5. 总结
- 极简网络容量太小,只能拟合最简单的线性变化。
- 通过增加隐藏层和神经元数量,网络(MLP)的表达能力显著增强,能够开始学习和近似复杂的纹理图案。
- 即使是改进后的 MLP,其参数量也远小于原始纹理数据量,体现了神经网络潜在的数据压缩能力。下一节将继续探讨如何提高拟合精度,从模糊的色块变为清晰的图像。
学习一个 Neural Shader
1. 现实挑战:实时性 vs. 质量
初步训练的 MLP 虽然能生成纹理,但质量堪忧(模糊、缺乏细节)。
- 增加容量:增加神经元数量和隐藏层数确实能提高质量,最终能近乎完美地拟合纹理。
- 硬性限制:实时渲染对性能有严格要求,我们不能无限增加网络规模。
- 核心矛盾:如何在有限的实时预算内,榨干小网络的每一滴性能?这类似于实时光线追踪——需要用聪明的采样策略(如 DLSS, Restir)来弥补光线数量的不足。
2. 工程优化技巧:榨干小网络性能
为了让小网络表现更好,需要一系列工程技巧。
2.1. 训练加速:随机采样
- 问题:遍历纹理所有像素计算 Loss 太慢。
- 解决方案:
- 随机采样:每次迭代只随机选取一小部分像素计算 Loss。
- 抖动网格(Jittered Grid):更优的采样策略,确保样本在空间上分布更均匀。
- 效果:虽然梯度噪声增加,但相同时间内能进行更多次迭代,最终收敛更快、效果更好。
2.2. 避免“死神经元”:Leaky ReLU
- 问题:使用标准 ReLU 激活函数时,如果神经元输出为负,梯度变为 0,该神经元从此停止更新,导致训练陷入糟糕的初始状态无法自拔。
- 解决方案:使用 Leaky ReLU。当输入为负时,给予一个很小的非零斜率,保证梯度始终能回传,避免网络“坏死”。
2.3. 提升平滑度:输出层激活函数
- 问题:ReLU 导致网络输出是分段线性的,画面看起来像是由无数小三角形拼接而成,不够自然。
- 解决方案:在输出层使用更平滑的激活函数,如指数函数(Exponential)。它不仅平滑,而且天然“Leaky”(梯度永远非零)。
3. 输入编码(Input Encoding):注入先验知识
仅仅输入 坐标,让小网络去学习复杂的图像结构是非常低效的。
- 频率编码(Frequency Encoding):
- 灵感:来源于 JPEG 等图像压缩算法,利用正弦和余弦函数将图像分解为不同频率的分量。
- 方法:不直接输入 ,而是输入它们的正弦、余弦变换值(不同频率)。
- 效果:极大提升了小网络捕捉高频细节的能力,质量飞跃。
- 可学习编码(Learnable Encoding) / 隐空间纹理(Latent Texture):
- 更进一步:用一个小的、可训练的低分辨率纹理来代替固定的频率编码。
- 原理:网络学习如何将这个低分辨率的“隐编码”放大、解码成高分辨率纹理。
- 结果:效果极佳,网络实际上变成了一个神经纹理解压器。
4. 总结与工作流建议
- 小模型的独特性:大模型(数十亿参数)可以“力大砖飞”,自动学会很多东西;但实时渲染用的小模型需要精心设计输入和结构,注入人类的先验知识(如频率编码)。
- 快速迭代是关键:
- Neural Shading 的开发需要大量的试错和调参。
- 工具链:Slang + SlangPy 提供了从 Python 快速原型到 C++ 高性能部署的无缝衔接。避免了用 PyTorch 训练完还得重写 C++ 推理代码的痛苦,保证了着色器代码的单一真实来源(Single Source of Truth)。
自动微分 (Autodiff) 详解
1. 核心概念:前向 vs. 后向微分
Slang 支持两种自动微分模式,它们在数学上等价,但在工程实现和性能特性上各有侧重。
1.1. 前向微分 (Forward Differentiation)
- 工作原理:在计算函数值的同时,顺便计算输入变量的导数。
- 类似于高中数学中的求导法则(链式法则)。
- 对于函数 ,前向微分函数会同时计算:
- 原始值(Primal Value):
- 导数值(Differential Value):
- Slang 中的使用:
- 使用
fwd_diff(func)获取前向微分函数。 - 使用
diffPair(val, diff)创建包含原始值和导数值的特殊数据对。
- 使用
- 适用场景:当输入参数很少时(例如计算 SDF 的法线,输入只有 x, y, z 三个坐标)。此时效率较高。
1.2. 后向微分 (Backward Differentiation)
- 工作原理:先完整运行一次原函数(前向传播),然后从输出端开始,逆向回溯计算每个输入参数对最终输出的影响(梯度)。
- 对于 ,如果已知 对某个最终损失 的梯度 ,后向微分会计算:
- 对于 ,如果已知 对某个最终损失 的梯度 ,后向微分会计算:
- Slang 中的使用:
- 使用
bwd_diff(func)获取后向微分函数。 - 调用时需要传入输出端的初始梯度(通常为 1.0)。
- 函数运行后,输入参数的
diffPair中会被填入计算好的梯度值。
- 使用
- 适用场景:当输入参数极多时(例如神经网络训练,可能有数百万个权重参数)。它可以一次性计算出所有输入的梯度,是深度学习训练的核心。
2. 类型系统:可微分 vs. 不可微分
Slang 编译器需要知道哪些变量需要求导,哪些不需要。
- 内置类型:
- 可微分(Differentiable):
float,vector,matrix等连续值类型。 - 不可微分(Non-differentiable):
int,bool等离散值类型。求导没有意义。
- 可微分(Differentiable):
- 自定义结构体:
- 默认情况下,自定义
struct被视为不可微分。 - 声明方式:让结构体继承
IDifferentiable接口(例如struct Point : IDifferentiable { ... }),编译器就会自动为其生成求导代码。
- 默认情况下,自定义
3. 高级特性:自定义导数 (Custom Derivatives)
有时候,编译器的默认求导行为无法满足需求,或者我们需要执行一些特殊操作(如写回显存、调试)。
3.1. 应用场景 1:梯度写回缓冲区 (Buffer Writes)
- 问题:当函数从全局缓冲区(Buffer)中读取数据时(例如
inputBuffer[index]),index是整数,不可导。默认的自动微分无法自动将梯度写回原缓冲区。 - 解决方案:
- 将缓冲区读取操作封装成一个独立函数,如
getVal(index)。 - 为这个函数编写一个自定义的后向微分函数,如
getVal_bwd(index, gradient)。 - 在
getVal_bwd中,手动将传入的gradient值写入到一个专门存储梯度的全局缓冲区中。 - 使用
[BackwardDerivative(getVal_bwd)]属性告诉编译器使用这个自定义函数。
- 将缓冲区读取操作封装成一个独立函数,如
3.2. 应用场景 2:调试梯度 (Debugging Gradients)
- 问题:自动生成的后向代码是个黑盒,难以查看中间变量的梯度值。
- 解决方案:
- 定义一个透传函数
debugGrad(val),它什么都不做,只是返回val。 - 为其定义自定义后向函数,在其中插入
printf打印当前的梯度值。 - 在需要调试的地方包裹该函数:
y = debugGrad(x * x)。这样就能在不影响计算的前提下“窃听”梯度流。
- 定义一个透传函数
硬件加速 (Hardware Acceleration)
1. 神经网络层的数学本质
- 全连接层(Fully Connected Layer):其核心计算可以归结为一次矩阵-向量乘法(Matrix-Vector Multiplication),再加上一个偏置向量,最后通过激活函数。
- 权重(Weights)构成一个矩阵 。
- 输入(Inputs)是一个向量 。
- 输出(Outputs)是 。
- 并行计算视角:在 GPU 上,成千上万个线程同时运行同一个网络(例如神经纹理采样),但每个线程的输入坐标不同。
- 这意味着整个 GPU 实际上在执行一次矩阵-矩阵乘法(Matrix-Matrix Multiplication, GEMM):。
- 这种计算模式非常适合 GPU 上的专用硬件加速器(如 NVIDIA Tensor Cores)。
2. Cooperative Vectors (CoopVec):解锁硬件潜能
为了让普通着色器代码也能利用 Tensor Cores,Slang 引入了 Cooperative Vectors。
- 传统方法的局限:直接使用硬件内建函数(Intrinsics)非常繁琐,需要手动管理线程协作、共享内存打包,且只能在统一控制流(Uniform Control Flow)中使用。
- Cooperative Vectors 的优势:
- 提供了一种高层抽象,让你能像写普通向量代码一样写矩阵运算。
- 编译器自动将其映射到高效的硬件指令(如 Tensor Cores),处理底层的线程协作细节。
- 跨平台:支持 D3D12 (Shader Model 6.9+), Vulkan (NV_cooperative_vector), Metal 4。
3. 使用 CoopVec 进行推理(Inference)
将普通的 MLP 代码改造为硬件加速版本非常简单:
- 将输入/输出类型从普通数组
float[]改为CoopVec<float, N>。 - 使用
coopVecMatMulAdd(weights, input, bias, ...)替代手写的for循环矩阵乘法。 - 注意:为了获得最佳性能和兼容性,权重通常需要使用 16位浮点数(float16)。
4. 使用 CoopVec 进行训练(Training)
在训练中使用硬件加速稍微复杂一些,需要手动实现反向传播逻辑。
- 挑战:
- 需要额外的存储空间来累积梯度(Weights Gradients, Bias Gradients)。
CoopVec目前还不是 Slang 默认的可微分类型,需要手动包装和实现自定义导数。
- 反向传播实现:
- 权重梯度:使用
coopVecOuterProductAccumulate计算输入向量和输出梯度的外积,并原子累加到权重梯度矩阵中。 - 偏置梯度:使用
coopVecReduceSumAccumulate将所有线程的输出梯度求和,原子累加到偏置梯度向量中。 - 输入梯度:使用
coopVecMatMul计算权重矩阵的转置与输出梯度的乘积,得到传给上一层的输入梯度。
- 权重梯度:使用
- 性能提升:基准测试显示,使用 CoopVec 加速的训练过程比高度优化的纯软件实现快 3-4 倍。
性能技巧 (Performance Tips)
1. 核心挑战:线程发散 (Divergence)
GPU 将 32 个线程编为一组(称为 Warp 或 Wave)并同时执行。当 Warp 内的线程执行不同路径时(如 if-else),硬件会串行执行(Serialize)所有路径,导致性能大幅下降。
- CoopVec 的发散问题:
- Cooperative Vector 操作(如
MatMulAdd)被设计为由整个 Warp 共同执行一次矩阵-矩阵乘法。 - 编程模型允许每个线程使用不同的权重矩阵(Matrix A/B)。
- 当 Warp 内的线程使用了不同的矩阵时,硬件会检测这种“矩阵发散”,并串行处理每个使用不同矩阵的线程组。
- Cooperative Vector 操作(如
- 性能悬崖:
- 如视频中的图所示(双对数坐标轴),当 Warp 内共享同一个矩阵的线程组规模变小时,性能会急剧下降。
- 解决方案:
- 务必最小化矩阵发散。
- 策略:按材质对绘制调用进行分组(Grouping)、手动排序线程(Sorting)、或使用着色器执行重排序(Shader Execution Reordering)。
2. 关键优化:矩阵布局 (Matrix Layouts)
- 问题:NVIDIA Tensor Cores 期望矩阵数据以一种特定的、非直观的内存布局存储,这取决于 GPU 架构和数据类型,而不是标准的行主序(Row-Major)或列主序(Column-Major)。
- 正确流程:
- 离线转换(Offline Shuffle):在着色器运行之前,就将权重矩阵转换为硬件期望的优化布局(Optimal Layout)。
- 运行时 API:在程序启动时,通过 API(D3D12 / Vulkan)查询特定布局需要的大小,然后在 GPU 上执行转换操作,将结果保存在 Buffer 中。
- 两种布局:
Inference_Optimal(推理用)和Training_Optimal(训练用)。
- 收益:避免了在着色器中进行昂贵的实时“混洗”(Shuffle)操作。
3. 关键优化:层融合 (Layer Fusion)
- 问题:即使权重矩阵布局正确,向量(输入/输出)在进入和离开 Tensor Core 时仍需要进行混洗。对于一个多层 MLP,朴素实现会是
Unshuffle -> MatMul -> Shuffle -> Activation -> Unshuffle -> MatMul -> Shuffle ...。这些混洗操作非常昂贵。 - 层融合:编译器的一种优化,旨在移除层与层之间冗余的混洗/反混洗操作。
- 脆弱性:这种优化很“脆弱”,很容易因为不当的编码方式而失效,导致性能急剧下降(例如 2 倍的性能差距)。
- 如何避免融合失效:
- 使用向量操作:严禁使用
for循环对向量进行逐元素操作。应使用max、min等向量内建函数来编写激活函数。 - 使用向量加载:严禁逐元素加载偏置(Bias)等数据,应使用向量加载指令。
- 使用融合的 MatMulAdd:将偏置加法合并到
coopVecMatMulAdd操作中,而不是单独作为一步操作。
- 使用向量操作:严禁使用
4. 精度选择 (Math Precision)
不同的浮点精度在性能、易用性和精度之间有巨大权衡:
| 精度 | 性能 | 易用性/开销 | 精度 |
|---|---|---|---|
| FP16 (16位浮点) | 较慢 | 最容易。无需转换,精度高。 | 良好 |
| FP8 (8位浮点) | 最快 | 容易。无需转换,寄存器占用少。 | 可能不足 |
| INT8 (8位整型) | 很快 | 最困难。需要量化和缩放,引入额外计算和格式转换开销。 | 取决于量化 |
建议:FP8 通常是性能最好的选择,但需注意其极低的精度是否会影响网络收敛或输出质量。FP16 是最稳妥的起点。
5. 性能总结
- 使用矩阵布局 API:确保权重矩阵在进入着色器前就已转换为最优布局。
- 最小化矩阵发散:通过排序或分组,确保一个 Warp 内的线程尽可能使用相同的矩阵。
- 使用向量内建函数:避免逐元素操作,帮助编译器实现层融合。
- 评估精度:从 FP16 开始,如果需要极致性能,再考虑 FP8 或 INT8。
部署 (Deployment)
本节的核心是将之前在 Python (SlangPy) 环境中原型验证的神经渲染代码,迁移到高性能的 C++ 生产环境中。
- 原型 (Prototyping):Python + SlangPy (用于快速迭代和训练)。
- 部署 (Deployment):C++ + Slang (用于最终产品,保持核心着色器代码不变)。
1. Slang 代码编译
在 C++ 环境中,我们不再动态加载 .slang 源码,而是预先将其编译为着色器二进制文件。
- 编译器:使用
slangc(Slang 编译器)命令行工具,或在 C++ 中集成 Slang API。 - 关键编译标志:必须启用 Cooperative Vector 功能,例如:
capabilities p_cooperative_vectorcapabilities p_cooperative_vector_training
- 输出:生成目标平台的着色器二进制文件(如 DX12 的 DXIL 或 Vulkan 的 SPIR-V),C++ 应用在运行时直接加载这些二进制文件。
2. 检查硬件支持
在 C++ 应用启动时,必须检查并启用 Cooperative Vector 支持。
- Vulkan:检查
VK_NV_cooperative_vector扩展是否受支持。 - DirectX 12:需要调用
D3D12EnableExperimentalFeatures来启用实验性着色器模型和 Cooperative Vector 功能,并检查D3D12_COOPERATIVE_VECTOR_TIER。
3. 处理矩阵优化布局 (Matrix Optimal Layouts)
在 C++ 端,我们需要手动完成 Python 库自动处理的权重转换,这个流程在上一节(性能)中已有提及:
- 查询大小:调用原生 API (Vulkan/DX12) 查询转换后的矩阵布局需要多少显存。
- 上传数据:将标准的行主序(Row-Major)矩阵上传到 GPU。
- GPU 转换:在命令缓冲区中调用转换函数(如
vkCmdConvertCooperativeVectorMatrixNV),将矩阵转换为 Inference Optimal 或 Training Optimal 布局,并存入 Buffer。
4. 从 SlangPy 到 C++ Compute Shader
这是从原型到部署最大的区别,即如何调用 Slang 函数。
- Python (SlangPy) 的“魔法”:
- SlangPy 允许我们直接在 Python 中"调用" Slang 函数(如
inference(uv))。 - 它在后台自动为我们生成了一个计算着色器(Compute Shader),遍历所有像素并调用该函数。
- SlangPy 允许我们直接在 Python 中"调用" Slang 函数(如
- C++ 的“手动”实现:
- 创建计算着色器入口:我们必须在
.slang文件中显式创建一个计算着色器入口函数(例如main_cs)。 - 计算坐标:在此函数中,使用
DispatchThreadID(线程ID) 来计算当前像素的坐标(x, y)。 - 调用核心逻辑:使用
(x, y)坐标调用我们原有的inference函数。 - 写入输出:将
inference的返回值写入到输出纹理(UAV)的对应位置。
- 创建计算着色器入口:我们必须在
- C++ 端:C++ 代码负责绑定所有资源(参数 Buffer、输出纹理),然后调用
Dispatch()来执行这个main_cs计算着色器。
5. 结论
通过以上步骤,我们成功将 Python 原型移植到了 C++ 生产环境。两个版本共享相同的核心 Slang 着色器代码,并产生完全相同的渲染结果,但 C++ 版本提供了部署所需的高性能和低开销。
神经纹理压缩 (NTC)
1. 核心思想:从“生成”到“转码”
在前面的课程中,我们尝试使用 MLP(多层感知机)和频率编码来拟合一张图像,但结果比较模糊。要提高质量,要么增加 MLP 的规模(计算成本高昂),要么改变思路。
NTC(Neural Texture Compression)选择了后者,它引入了隐空间纹理(Latent Textures)。
- 旧架构:
UV 坐标 -> 频率编码 -> MLP -> 颜色 - 新架构 (NTC):
- 隐空间纹理 (Latent Textures):一张或多张低分辨率(例如 2x 或 4x 缩小)、低位深(例如 2-4 bit)的纹理。
- 相对位置编码 (Relative Positional Encoding):基于 UV 坐标相对于所采样的隐空间纹素的位置。
- MLP 输入:
采样得到的隐空间特征 + 相对位置编码
- 角色转变:MLP 不再是一个完整的图像解码器,而是一个“转码器” (Transcoder)。它学习如何将低分辨率的隐空间信号转码(上采样并解码)为高分辨率的最终颜色。这种方式能产生更清晰的结果。
2. NTC 的特性与优势
- 可变比特率 (Variable Bitrate):通过调整隐空间纹理的数量、分辨率和位深,NTC 可以在 0.5 到 20 bpp (bits per pixel) 之间灵活调整压缩率。
- 多通道支持 (High Channel Count):可以轻松扩展到 16 个通道,允许将完整的 PBR 材质(BaseColor, Normal, Roughness, Metallic...)压缩到同一个 NTC 容器中。
- 独立像素解码 (Independent Pixel Decoding):NTC 的设计允许在着色器中按需解码单个像素,而无需在加载时解压整个纹理(尽管这也是一种部署选项)。
- 无幻觉 (No Hallucinations):与大型 AI 模型不同,NTC 是为每张纹理单独训练的小型 MLP。在低比特率下,它的伪影表现为模糊和细节丢失,而不是生成“多余的手指”之类的虚假内容。
3. 质量对比 (vs. Block Compression)
与 GPU 硬件支持的块压缩(如 BC1, BC7)相比:
- NTC 完胜:在相似的压缩率下,NTC 的图像质量(PSNR)显著高于 BC1/BC7。对于细节复杂的材质,NTC 的优势尤其明显。
- 伪影差异:
- BC 伪影:块状效应 (Blockiness)。
- NTC 伪影:模糊 (Blur)。在视觉上,模糊通常比块状更不容易分散注意力。
- 通道相关性:NTC 在压缩通道间相关的 PBR 材质时效果最好。如果通道不相关(例如,在一个平坦色块上印有法线贴图不相关的文字),细节可能会"泄漏"到其他通道,但这可以通过提高比特率来解决。
4. 训练流程
- 像素采样:每一步从图像中随机选取一个子集(例如 64k 像素,组织成小图块以提高效率)。
- 前向传播 (Forward Pass):采样隐空间纹理,计算位置编码,通过 MLP 得到预测颜色。
- 计算损失 (Loss):计算预测颜色与参考颜色之间的 L2 损失(均方误差)。
- 反向传播 (Backward Pass):将损失梯度反向传播,以更新两个目标:
- MLP 的网络权重 (Weights)。
- 隐空间纹理的纹素值 (Latent Texels)。
- 关键优化:整个前向、反向传播和梯度累积过程被融合在一个高度优化的单一 CUDA/Slang 核心 (Single Kernel) 中,而不是像 PyTorch 那样分步执行。这带来了两个数量级(约 100 倍)的训练速度提升。
- 优化器步进:使用优化器(如 Adam)更新参数。整个训练过程在高端 GPU 上通常仅需 30 秒左右。
5. 部署模式 (Deployment Models)
NTC 提供了三种灵活的部署方式,以平衡 VRAM 占用和渲染性能:
5.1. Inference on Sample (采样时推理)
- 工作方式:在 G-Buffer、Forward Pass 或光追着色器中,用 NTC 解码器(MLP)完全替换传统的
texture()采样。 - 滤波:NTC 无法利用硬件三线性滤波。必须使用随机纹理滤波 (Stochastic Texture Filtering, STF),即从滤波核中随机选取一个 texel,这会引入噪声。
- 优点:极大的 VRAM 节省。
- 缺点:着色器变慢(增加了 MLP 计算),且 STF 引入噪声。
5.2. Inference on Load (加载时推理)
- 工作方式:在游戏加载地图或模型时,一次性将 NTC 纹理解码为未压缩图像,然后重新编码为标准的 BC 格式 (BC7, BC1) 并存入 VRAM。
- 优点:零渲染性能开销,易于集成到现有管线。
- 缺点:完全没有 VRAM 节省。只节省了磁盘空间和下载流量。
5.3. Inference on Feedback (虚拟纹理流送)
- 工作方式:与虚拟纹理(Virtual Texturing)系统结合。当系统发现需要某个纹理图块(Tile)时,它不是从磁盘加载,而是实时地将 NTC 数据转码 (Transcode) 为 BC 格式,并填入 VT 纹理图集中。
- 优点:在性能和 VRAM 占用之间取得了良好的折中。
- 缺点:系统更复杂,且 VRAM 中需要同时保留 NTC 数据(用于转码)和 BC 数据(用于渲染)。
6. 总结与未来
NTC 提供了一种远超传统 BC 压缩的方案,其核心优势在于:
- 节省磁盘空间/下载流量。
- 灵活部署:高端硬件(PC/主机)可以使用 "On Sample" 节省 VRAM,低端硬件(移动端)可以使用 "On Load" 保证性能,而美术资源只需制作一套 NTC 格式。
- 更高清的未来:在 VRAM 预算不变的情况下,NTC 允许美术师使用更高分辨率的纹理。
- 可扩展性:未来可以使用感知损失(Perceptual Loss)代替 L2 Loss 进行训练,以获得更好的视觉质量;也可以用 NTC 压缩其他数据,如光照探针网格(Light Probes Grids)。
实时神经材质 (Real-Time Neural Materials)
本节以一个实际项目为例,展示了将一个复杂的渲染问题转化为 Neural Shading 问题的完整开发工作流。
1. 动机与挑战
- 目标:实时渲染极其复杂、精细、由艺术家创造的多层材质。
- 挑战:这些材质的着色器图(Shader Graphs)非常复杂,计算成本极高,无法在实时帧预算内(如 16ms)运行。
- 核心问题:我们缺乏一种好的方法,能将这种复杂的着色器图“压缩”或“简化”为一个低成本、固定开销(Fixed-Cost)的模型,同时最大限度地保留其视觉保真度。
2. 迭代过程:从失败到成功
项目组尝试了多种架构,这个迭代过程充分体现了 Neural Shading 的开发实践。
迭代 1:朴素 MLP + 隐空间纹理
- 架构:
MLP(光照L, 视角V, 隐空间编码UV) -> 颜色。 - 训练目标:同时训练 MLP 的权重和一张高分辨率隐空间纹理(Latent Texture)的像素值。
- 结果:
- 低分辨率隐空间纹理:可行,能大致拟合材质。
- 高分辨率隐空间纹理:训练失败。纹理变得充满噪声,无法收敛。
- 失败原因:在高分辨率下,需要优化的参数(每个纹素都是一个独立参数)变得过多。训练样本被稀释在数百万个参数中,导致梯度噪声(Noisy Gradients)极其严重,优化器无法找到正确的方向。
迭代 2:编码器-解码器架构 (Encoder-Decoder)
- 思路:高分辨率隐空间纹理之所以失败,是因为其参数(纹素)之间是完全独立的。我们需要一个更结构化的方式来生成它。
- 新架构(训练时):
- 编码器 (Encoder):一个额外的 MLP,输入是艺术家创作的原始 PBR 纹理(Albedo, Normal 等)。
- 解码器 (Decoder):与迭代 1 相同的 MLP。
- 流程:
原始PBR纹理 -> Encoder -> 隐空间纹理 -> Decoder (输入 L, V) -> 颜色。
- 为何成功:编码器(一个小型网络)的参数量远小于它所生成的高分辨率隐空间纹理。这种约束使得训练过程更加稳定,能够成功收敛。
- 部署技巧(关键):
- 训练完成后,编码器 (Encoder) 就可以丢弃了。
- 我们运行一次编码器,将原始 PBR 纹理烘焙 (Bake) 成一张静态的隐空间纹理。
- 运行时:我们只需要部署解码器 (Decoder) 和这张烘焙好的隐空间纹理。这实现了与迭代 1 相同的低运行时开销,但解决了训练不稳定的问题。
迭代 3:注入先验知识 (Injecting Prior Knowledge)
- 问题:虽然能收敛,但解码器(Decoder)在处理法线贴图(Normal Mapping)强烈的材质时效果不佳。
- 原因:这个小小的 MLP 浪费了其有限的参数容量 (Limited Capacity) 去重新学习一个我们已知的、复杂的数学运算:如何进行坐标系变换(即将 L 和 V 向量从世界空间转换到切线空间)。
- 解决方案(混合架构):
- 将解码器拆分为两部分:一个微小的可训练层 + 一个固定函数(Fixed-Function)组件。
- 可训练层:只负责从隐空间纹理中提取
Normal和Tangent向量。 - 固定函数:使用标准的着色器代码,利用这些 N, T 向量构建着色坐标系 (Shading Frame),并将 L, V 向量旋转到该坐标系下。
- MLP 主体:最后,MLP 接收旋转后的 L, V 向量,现在它可以专注于学习材质外观 (Appearance),而无需关心坐标变换的复杂数学。
- 结果:在不增加网络参数的情况下,渲染质量显著提升,非常接近参考目标。
3. 训练即优势:超越性能
将材质变为“可训练的”,带来了一些传统着色器难以实现的好处:
- 自动材质简化 (LOD):
- 通过调整解码器 MLP 的大小(网络层数/宽度),我们可以得到一个在质量 vs. 成本之间平滑过渡的“刻度盘”。
- 这种简化是通过训练自动保留最重要的视觉特征的,远优于手动简化着色器图。这对于硬件适配(高/中/低配)和 LOD(近/远景)非常有用。
- 可扩展性 (Extensibility):
- 可以轻松地在架构中加入新的学习目标。
- 示例 1:学习重要性采样 (Learned Importance Sampling):在训练时,不仅学习渲染材质,还同时学习如何为该材质生成最优的采样分布(用于光线追踪),并共享同一套隐空间纹理。
- 示例 2:学习纹理过滤 (Learned Filtering):通过训练一个隐空间纹理的 Mipmap 链,使其在缩小查看时也能匹配超采样的参考结果,从而完美解决高频材质的锯齿问题(与课程开头的 Mipmap 优化思想一致)。
4. 总结:Neural Shading 工作流
这个项目生动地展示了 Neural Shading 的开发工作流:
- 识别难题:找到传统着色器难以解决的问题(如材质简化、复杂压缩)。
- 转化为优化:将该问题重新定义为一个优化问题(即定义一个损失函数)。
- 注入先验:不要让小型网络“从零学起”。注入你已知的数学和图形学知识(如法线变换)到固定函数中,让网络只专注于它擅长的非线性拟合。
- 快速迭代:开发过程会充满大量失败的尝试。一个支持快速原型设计(如 SlangPy)和高性能部署(如 Slang C++ API)的统一工具链至关重要。