为了后续平台能够更好的封装跨平台Shader,本部分主要来看一下各平台Shader的语法特点、编译方式以及Debug方式等对比。
对于各类Shader语言还是先来看一下官方的文档,里边有具体的阐述,也是比较权威的文档:
- HLSL官方文档
- GLSL官方文档
- MSL官方文档
- WGSL官方文档
- OpenCL官方文档
大家也可以去看看硬件执行着色器的具体原理,看完后你更是会恍然大悟,原来Shader中的每个数值都是根据硬件来匹配的,具体文章可以参照剖析虚幻渲染体系(16)- 图形驱动的秘密 、GPU架构与管线总结等等硬件架构相关文章,本文主要介绍应用层的相关原理。
我们先来看一下实时渲染中常用的着色器语言类别:
缩写名称 | 全称 | 图形API | 常用转换 |
---|---|---|---|
HLSL | High Level Shading Language | DirectX | HLSL → SPIR-V |
GLSL | OpenGL Shading Language | Vulkan / OpenGL | GLSL → SPIR-V → HLSL |
MSL | Metal Shading Language | Metal | SPIR-V → MSL |
WGSL | WebGPU Shading Language | WebGPU | tint → SPIR-V |
当然,除了上述常用着色器语言外,还有PlayStation Shading Language(PSL),Unity (Cg)等,此处便不一一介绍。
着色器语言因供应商和API不同语法虽有不同,但每种着色语言都与C语言非常相似,可以实现它们之间的切换。根据语言的不同,着色器代码可能存在特定于 API 的中间表示形式 (IR),并且每个供应商(AMD、NVIDIA、英特尔、高通等)在驱动程序级别将该 IR 转换为其机器代码。
IR | 描述 | 适应API |
---|---|---|
DXIL | DirectX 中间语言 | DirectX 12 |
DXBC | DirectX 字节码 | DirectX 11 / 12 |
SPIR-V | VK、GL 中间语言 | Vulkan / OpenGL |
RDNA2 ISA | AMD 6000 系列指令集架构 | N/A |
PTX | NVIDIA并行线程执行 | N/A |
我们主要来看一下 HLSL、GLSL、MSL 和 WGSL 中的顶点着色器语法(主要是布局等不同,TS、GS、PS大致相同):
-
输入布局信息
-
管线间数据传输
-
常量缓冲数据
此外,我们还将介绍下每种语言的计算着色器语法:
-
读取和写入缓冲区
-
执行原子操作
-
跨线程组共享数据
一、语言特点
几乎所有的着色语言都与 C 非常相似,并且具有与 C 相关的所有常见函数功能。
此外,着色语言具有全局命名空间中可用的内部函数标准库,用于执行常见的数学运算,这些标准功能包括:
-
向量数学(矩阵乘法、向量加法、点积)。有些语言能够重载operator*,有些语言使用mul函数来执行计算;
-
高阶运算(Sin,、Cos、tan、Log等);
-
重载或者派生函数;
-
逻辑操作(使用 all() 等函数检查布尔向量的所有成员);
-
GPGPU 显存同步,例如atomicAdd等
这些函数中的大多数函数名称相同,但有些略有不同(HLSL的与GLSL的),可以参照DirectX Shader Compiler (DXC)中的相关介绍、或者 Shader_GLSL、HLSL API异同对比
1.1 HLSL
High-Level Shading Language (HLSL) 高级着色语言可以说是编写着色器的最佳语言,因为它有这很好的语言拓展,也可以将采样器和纹理分开(与 GLSL 不同),并且支持以下通用语言功能:
- 命名空间
- 模板泛型
- 可重载运算符(*)
- #include
其余语言特性可以参照官方文章 HLSL 2021 。
有关 HLSL 语言的更多详细内容可以参照:
-
内置变量与类型
-
语义描述
-
内置函数
VS Demo程序
//HLSL Vertex Shader
struct Constants
{
row_major float4x4 modelViewProjection : packoffset(c0);
float4x4 inverseTransposeModel : packoffset(c4);
float4x4 view : packoffset(c8);
};
ConstantBuffer<Constants> constants : register(b0);
struct VertexInput
{
float3 position : POSITION;
float3 normal : NORMAL;
float2 texCoord : TEXCOORD;
};
struct VertexOutput
{
float4 position : SV_Position;
float3 normal : NORMAL;
float2 texCoord : TEXCOORD;
};
VertexOutput main(VertexInput vertexInput)
{
float4 position = mul(float4(vertexInput.position, 1.0f), constants.modelViewProjection);
float3 outNormal = mul(float4(vertexInput.normal, 1.0), constants.inverseTransposeModel).xyz;
float2 outTexCoord = vertexInput.texCoord;
VertexOutput output;
output.position = position;
output.normal = outNormal;
output.texCoord = outTexCoord;
return output;
}
如果需要使用全局可访问的着色器常量(如制服在旧 API 中的uniform变量),可以使用cbuffer关键字 ,使用demo如下:
cbuffer cb : register(b0)
{
row_major float4x4 modelViewProjection : packoffset(c0);
float3 origin : packoffset(c4);
float time : packoffset(c5);
};
float4 position = mul(float4(vertexInput.position, 1.0f), modelViewProjection);
HLSL 着色器非常直观,具有输入和输出和明显的语义描述属性输入(如 float3 position : POSITION)。
现在看一下计算着色器的用法:
// HLSL Compute Shader
struct Constants
{
float time : packoffset(c0);
};
ConstantBuffer<Constants> constants : register(b0);
Texture2D<float4> albedoTex : register(t1);
Texture2D<float4> pbrLutTex : register(t2);
SamplerState gSampler : register(s0);
RWTexture2D<float4> tOutput : register(u0);
groupshared float groupData[4];
namespace random
{
float foo()
{
return 16.0;
}
}
[numthreads(16, 16, 1)]
void main(uint3 groupThreadID : SV_GroupThreadID,
uint3 groupID : SV_GroupID,
uint groupIndex : SV_GroupIndex,
uint3 dispatchThreadID: SV_DispatchThreadID)
{
tOutput[dispatchThreadID.xy] = float4( float(groupThreadID.x) / random::foo(), float(groupThreadID.y) / 16.0, dispatchThreadID.x / 1280.0, 1.0);
}
您可以将计算着色器内部值定义为 main 函数的参数,并有效的利用这些值来处理你想要的计算量,具体使用规则可以参照之前相关文章。
1.2 GLSL
OpenGL Shading Language (GLSL) 是 Khronos API 的标准着色器编程语言,例如 Vulkan、OpenGL 4.x,通过不同版本的 GLSL 和 WebGL 的可选扩展。但是GLSL 与其他语言之间存在一些本质区别:
-
矢量类型前缀不一致(如,vec4 代替 float4 、mat4 代替 float4x4)
-
采样器和纹理绑定在一起。
然而,GLSL在功能方面类似于HLSL,也支持通过可选扩展和类似#include的内部函数。
GLSL 代码以布局声明开头,这些布局声明准确定义每个结构和对象在内存中的布局方式。in 关键字表示此数据来自图形管线的输入程序集,out 关键字表示传递到图形管线下一步的数据(可以理解为传递引用)。
值得注意的是,GLSL ES 1.0适用于WebGL版本,GLSL ES 3.0可用于WebGL 2。
在main函数中,我们执行着色器逻辑并写入输出位置和可变变量。main关键字也可以设置为任何名称。
VS Demo程序
// GLSL Vertex Shader
#version 450
struct VertexInput
{
vec3 position;
vec3 normal;
vec2 texCoord;
};
struct VertexOutput
{
vec4 position;
vec3 normal;
vec2 texCoord;
};
struct Constants
{
mat4 modelViewProjection;
mat4 inverseTransposeModel;
mat4 view;
};
layout(set = 0) uniform Constants constants;
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoord;
layout (location = 0) out vec3 outNormal;
layout (location = 1) out vec2 outTexCoord;
void main()
{
vec4 position = constants.modelViewProjection * vec4(position, 1.0f);
outNormal = (constants.inverseTransposeModel * vec4(normal, 1.0)).xyz;
outTexCoord = texCoord;
gl_Position = position;
}
计算着色器与 HLSL 大致相同,但是写入数据语法有点不一样,在HLSL中是通过数组语法完成,而GLSL则是通过函数来完成的。
具体可参见如下GLSL CS Shader:
// GLSL Compute Shader
#version 450
layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in;
layout(binding = 0, std140) uniform Constants
{
float time;
} constants;
layout(binding = 0, rgba32f) uniform readonly writeonly image2D tOutput;
shared float groupData[4];
float foo()
{
return 16.0;
}
void main()
{
imageStore(tOutput, gl_GlobalInvocationID.xy,
vec4(float(gl_SubgroupInvocationID.x) / foo(),
float(gl_SubgroupInvocationID.y) / 16.0,
gl_GlobalInvocationID.x / 1280.0, 1.0));
}
1.3 MSL
在Metal Shading Language (MSL) 中没有uniform关键字,缓冲区的所有内容都可以是uniform的。与 HLSL 类似,属性在 main 函数中作为参数传递,并使用指令来表示数据的来源与 HLSL 或 GLSL 不同,要传递到管道后期阶段的数据由 main 函数返回。
MSL中的向量类型的命名与HLSL相同,内部函数也几乎相同。
具体使用Demo如下:
// Metal Vertex Shader
#include <metal_stdlib>
#include <simd/simd.h>
using namespace metal;
struct Constants
{
float4x4 modelViewProjection;
float4x4 inverseTransposeModel;
float4x4 view;
};
struct VertexOutput
{
float3 normal [[user(locn0)]];
float2 texCoord [[user(locn1)]];
float4 position [[position]];
};
struct VertexInput
{
float3 position [[attribute(0)]];
float3 normal [[attribute(1)]];
float2 texCoord [[attribute(2)]];
};
vertex VertexOutput main(VertexInput in [[stage_in]],
constant Constants& constants [[buffer(0)]],
uint gl_VertexID [[vertex_id]],
uint gl_InstanceID [[instance_id]])
{
VertexOutput out = {};
float4 position = constants.modelViewProjection * float4(in.position, 1.0);
out.normal = (constants.inverseTransposeModel * float4(in.normal, 1.0)).xyz;
out.texCoord = in.texCoord;
out.position = position;
return out;
}
Metal 的输入/输出模型比其他语言清晰明了,其中 main 函数显示这些绑定示意,计算着色器也是类似(用关键字kernel表示)。
// Metal Compute Shader
#include <metal_stdlib>
#include <simd/simd.h>
using namespace metal;
struct Constants
{
float time;
};
float foo()
{
return 16.0;
}
kernel void main(
uint3 groupThreadID [[ thread_position_in_threadgroup ]],
uint3 groupID [[threadgroup_position_in_grid]],
uint groupIndex [[thread_index_in_threadgroup]],
uint3 dispatchThreadID [[thread_position_in_grid]],
constant Constants& constants [[buffer(0)]],
texture2d<float, access::read_write> tOutput [[texture(0)]])
{
threadgroup float groupData[4];
tOutput[dispatchThreadID.xy] = float4( float(groupThreadID.x) / foo(), float(groupThreadID.y) / 16.0, dispatchThreadID.x / 1280.0, 1.0);
}
1.4 WGSL
网络图形用户界面徽标
WebGPU Shading Language(WGSL)非常独特,其语法有点像JavaScript,Rust,C和Apple Metal,更像是Metal Shader语言和HLSL的混合体。
其中矢量类型是唯一的,它们类似于 HLSL 和 GLSL,但每种类型都有更明确定义的大小。
// WGSL Vertex Shader
struct Constants
{
modelViewProjection: mat4x4<f32>;
inverseTransposeModel: mat4x4<f32>;
view: mat4x4<f32>;
};
struct VertexOutput
{
@builtin(position) position: vec4<f32>;
@location(0) normal: vec3<f32>;
@location(1) uv: vec2<f32>;
};
@group(0) @binding(0) var<uniform> constants : Constants;
@stage(vertex)
fn main(@location(0) position: vec4<f32>,
@location(1) uv: vec2<f32>) -> VertexOutput {
var vsOut: VertexOutput;
vsOut.position = constants.modelViewProjection * vec4<f32>(position, 1.0);
vsOut.position = constants.inverseTransposeModel * vec4<f32>(normal, 1.0);
vsOut.uv = uv;
return vsOut;
}
计算着色器类似于 GLSL,内置值也具有相似的名称(如全局调用 ID 等):
// WGSL Compute Shader
struct constants
{
f32 time;
};
@group(0) @binding(0) var<uniform> constants: Constants;
@group(0) @binding(1) var tOutput: texture_storage_2d<rgba8uint,read_write>;
fn foo() -> f32
{
return 16.0;
}
@stage(compute) @workgroup_size(16, 16, 1)
fn main(@builtin(local_invocation_id) localInvocationID: vec3<u32>,
@builtin(workgroup_id) workgroupID: vec3<u32>,
@builtin(local_invocation_index) localInvocationIndex: u32,
@builtin(global_invocation_id) globalInvocationID: vec3<u32>) {
textureStore(tOutput, globalInvocationID.xy, vec4<f32>(f32(localInvocationID.x) / foo(), f32(localInvocationID.y) / 16.0, globalInvocationID.x / 1280.0, 1.0))
}
1.5 OpenCL
OpenCL与Apple Metal和OpenGL非常相似。虽然 OpenCL 中不存在图形管道的概念,但其他 API 中的计算内核本质上是一回事。
//C++
struct Constants
{
float time;
uint width;
uint height;
};
float foo()
{
return 16.0;
}
__kernel void main
(
__constant Constants constants,
__global float4* tOutput
)
{
uint localId = get_local_id(0);
uint workgroupId = get_group_id(0);
uint globalId = get_global_id(0);
int x = globalId % constants.width;
int y = globalId / constants.width;
if (x >= width || y >= constants.height)
{
return;
}
tOutput[pixel_idx] = (float4)((float)(x) / foo(), (float)(y) / 16.0, (float)(x) / 1280.0, 1.0);
}
二、编译方式
2.1 离线编译
一般为了性能考虑应始终将着色器提前编译为目标图形 API 的 IR,因为这样的话你就不再需要等待着色器编译才能提交命令。然而旧版本的OpenGL的GLSL(新版本可以使用SPIR-V了)或WebGPU着色语言(WGSL)不支持将着色器编译成IR。
HLSL
// DirectX
dxc.exe -T lib_6_3 -Fo assets/triangle.vert.dxil assets/triangle.vert.hlsl
// Vulkan / OpenGL
dxc.exe -spirv -Fo assets/triangle.vert.spv assets/triangle.vert.hlsl
HLSL 着色器可以使用 DirectX Shader Compiler 离线编译为 DXIL。
如果要输出 SPIR-V,则需要使用-spirv标志并使用 SPIR-V 生成器处理 HLSL。
GLSL
// Vulkan / OpenGL
glslangValidator -V triangle.vert -o triangle.vert.spv
GLSLang Validator是Khronos的GLSL应用转换器,可以将其编译为SPIR-V。
Metal
// Metal
metallib triangle.vert.msl triangle.vert.mbc
metallib编译器在任意 MacOS 系统中命令行运行。
2.2 实时编译
Vulkan
// glslang.h
SH_IMPORT_EXPORT int ShCompile(
const void* shHandle,
const char* const shaderStrings[],
const int numStrings,
const int* lengths,
const EShOptimizationLevel,
const TBuiltInResource *resources,
int debugOptions,
int defaultVersion = 110, // use 100 for ES environment, overridden by #version in shader
bool forwardCompatible = false, // give errors for use of deprecated features
EShMessages messages = EShMsgDefault // warnings and errors
);
OpenGL
GLuint shader = glCreateShader(VETEX_SHADER);
glShaderSource(shader, srcStr);
glCompileShader(shader);
GLint isCompiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &isCompiled);
if(isCompiled == GL_FALSE)
{
GLint maxLength = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &maxLength);
std::vector<GLchar> errorLog(maxLength);
glGetShaderInfoLog(shader, maxLength, &maxLength, &errorLog[0]);
}
OpenGL非常简单,你向它发送字符串并检查它是否有效。或者新版本的OpenGL允许你直接加载SPIR-V。
DirectX
HRESULT WINAPI D3DCompile(
in LPCVOID pSrcData,
in SIZE_T SrcDataSize,
in_opt LPCSTR pSourceName,
in_opt const D3D_SHADER_MACRO pDefines,
in_opt ID3DInclude pInclude,
in_opt LPCSTR pEntrypoint,
in LPCSTR pTarget,
in UINT Flags1,
in UINT Flags2,
out ID3DBlob ppCode,
out_opt ID3DBlob ppErrorMsgs
);
DirectX也支持在运行时编译着色器,现在加载字节码或着色器字符串两者都支持。
Metal
id<MTLLibrary> library = [self.device newDefaultLibrary];
id<MTLFunction> vertexFunc = [library newFunctionWithName:@"vertex_main"];
id<MTLFunction> fragmentFunc = [library newFunctionWithName:@"fragment_main"];
在 Metal 中,可以创建着色器函数库并根据需要加载这些函数库,而不是自己管理着色器模块。如果需要获取给定库函数列表的主目录,则可以使用newFunctionWithName方法。
2.3 转译
只要着色器是用有效的 GLSL 或 HLSL 编写的,就可以使用 SPIRV-Cross 将其转译为 GLSL、HLSL 或 MSL。
具体转换demo如下:
// Compile shaders to SPIR-V binary
glslangValidator -V triangle.vert -o triangle.vert.spv
glslangValidator -V triangle.frag -o triangle.frag.spv
// HLSL
spirv-cross triangle.vert.spv --hlsl --shader-model 50 --set-hlsl-vertex-input-semantic 0 POSITION --set-hlsl-vertex-input-semantic 1 COLOR --output triangle.vert.hlsl
spirv-cross triangle.frag.spv --hlsl --shader-model 50 --set-hlsl-vertex-input-semantic 0 COLOR --output triangle.frag.hlsl
// OpenGL ES 3.1
spirv-cross triangle.vert.spv --version 310 --es --output triangle.vert.glsl
spirv-cross triangle.frag.spv --version 310 --es --output triangle.frag.glsl
// Metal
spirv-cross triangle.vert.spv --msl --output triangle.vert.msl
spirv-cross triangle.frag.spv --msl --output triangle.frag.msl
此外,WGSL 编译器还可以直接转译:
// Mozilla Naga
// https://github.com/gfx-rs/naga
// Convert the WGSL to GLSL vertex stage under ES 3.20 profile
cargo run my_shader.wgsl my_shader.vert --profile es310
// Convert the SPV to Metal
cargo run my_shader.spv my_shader.metal
综上,着色语言彼此相似,关键字略有不同,底层设计理念不同(例如 GLSL 中采样器和纹理的联合,或 Metal 的缓冲区属性模型)。不过,解决HLSL,GLSL,MSL和WGSL之间的差距并不难。
还有其他的一些差异如:
-
光线追踪着色器 - DirectX 12 和 Vulkan 共享相同类型的光线追踪着色器,但 Metal 采用了更独特的方法。
-
Meshlet Shaders - 目前仅限于DirectX 12和Vulkan,可以查看以前文章。
三、Debug工具
说完了着色器之间的差别,最重要的来了,就是除了颜色输出大法外,如何可视化实时调试Shader,根据不同平台和不同语言,可以使用如下调试工具(以下工具不止与调试Shader,性能分析也是很有用)。
以下给出自己比较常用的图形Debug工具:
- RenderDoc
- NVIDIA NSight Graphics (NVIDIA GPUs)
- Intel Graphics Performance Analyser (Intel GPUs)
- Microsoft PIX (DirectX 11/12)
- Microsoft GPUView
- VS图形调试器
- Spector.js (WebGL)
- AMD Radeon GPU Developer Tools Suite (AMD GPUs)
- Qualcom Snapdragon Profiler
- XCode Graphics Debugger (Metal)
- Chrome’s Structural Profiler (WebGL)
以上工具都具有基础的图形Debug能力,如:
-
命令分析支持。
-
帧缓冲区附件分析。
-
网格可视化。
-
缓冲区显示
-
渲染性能分析。
-
着色器编辑与实时查看功能。
其中,GPU供应商提供的特定图形分析工具也是很好用,如AMD的Radeon GPU Profiler,NVIDIA的 NSight Graphics,Intel的 GPA,他们更倾向于分析有关其平台的图形分析信息,当然常规功能也是该有的都有。NVIDIA NSight Graphics 还具有C++项目生成器,非常适合学习给定应用程序的渲染架构。Microsoft PIX是为数不多的可用于Windows 10 UWP应用程序的调试工具之一。
简而言之,以上都是应用型介绍与使用示意,如果你有兴趣还是建议你能够先把显卡硬件的架构与执行流程了解透彻,这样的话你看以上内容的话会豁然开朗。