シェーダー言語のSlangをRustとVulkan (ash)で試してみる
はじめに
最近話題のSlangというシェーダー言語を、RustとVulkan (ash)で試してみました。
この記事ではSlangの機能を一部ピックアップして簡潔に紹介するとともに、RustとVulkan (ash)でSlangを使う際にcargo run
でコンパイルが走るようにbuild.rs
を設定する方法を紹介します。
Slangとは
Slangはオープンソースのシェーダー言語の一種です。
元はNVIDIAがSIGGRAPH 2018あたりで発表した言語です。
D3D12, Vulkan, Metal, D3D11, OpenGL, CUDAなどで利用できる他、CPUでも実行できるのでデバッグなどもやりやすそうです。Vulkanで使う場合はSPIR-Vにコンパイルして利用できます。
昨年11月、OpenGLやVulkanの標準化を行う団体であるKhronos Groupは、オープンソースプロジェクトをサポートするためのイニシアチブを立ち上げました。
NVIDIAやKhronos Groupといった業界を牽引する企業・団体が関わっており、技術的な信頼性が高まっています。
昨年11月にKhronos Groupがサポートを発表してから、すでにSlangのコンパイラなどはVulkan SDKにも同梱されています。GLSLと同等の標準的な位置付けとなる可能性が高く、注目度が高まっています。
もともとNVIDIAの研究などで採用例があり、自動微分機能などを備えておりニューラル系の技術とグラフィクスを組み合わせるような分野で利用する場合に便利なようです。
例えばこちらのNVIDIAのニューラルネットを利用したマテリアルの研究ではSlangを利用していると論文内で言及されていたように思います。
研究用の言語というわけでもなく実用上でも便利そうな機能が沢山あります。
言語としては自動微分機能を持っていることが大きな特徴ですが、それ以外にもモダンな言語機能がたくさんあり、シェーダーを開発するうえで便利になりそうです。古いC的なところから文法的な発展をあまりしてこなかったGLSLをずっと使い続けていた身としては、モダンな言語機能が大変ありがたいです。
さらに、今後の新しい機能にも追従していくことが発表されており、先日発表されたNVIDIAのニューラルシェーダーのSDKのVulkan向けサンプルのRTXNSもSlangがベースで提供されていました。
NVIDIAの最新機能が早速Slangに提供されたということで、今後はSlangの時代がやってくるかもしれません。
あのKhronos Groupがバックについたことで、今まで業界標準とまではいかず手を出しにくく感じていた人も安心して手を出しやすくなった気がします。
今回の記事ではSlangの機能の一部を適当にピックアップして紹介し、またRustとVulkanのRustバインディングのライブラリクレートであるashをSlangと組み合わせて使うことを想定して、build.rs
を使ってcargo build
時にSlangをコンパイルする方法を紹介します。
Slangの文法と機能の抜粋紹介
Slangの文法や特徴などについて網羅的に詳しく知りたい方はSlangの公式ドキュメントを読んでいただくと良いと思います。
この記事では基本的なところを軽く簡単な紹介と、それから私が気に入った機能を適当にピックアップして紹介します。
基本的な文法
まずは基本的な文法をサクッと紹介します。
変数の宣言は次のように行います。
int a = 5;
右辺からの型推論もあります。
var a = 5;
さらに型注釈をつけることができます。
var a: int = 5;
このため、型 変数 = 値;
の他にvar 変数: 型 = 値;
のように型を後ろに書くことも出来ることになります。この書き方をどちらでも選べるのは面白い特徴ですね。普段書いている言語に近い書き方を採用できそうです。
let
を使うことで変数をイミュータブルにもできます。
let a = 5;
// a = 10; // エラー
組み込みで用意されている型の例には次のようなものがあります。
int8_t
int16_t
int
int64_t
uint8_t
uint16_t
uint
uint64_t
half
float
double
bool
vector<T,N>
matrix<T,R,C>
他にもテクスチャーやバッファーなどの型が色々あります。
vector<T, N>
はfloat4
やint3
などのような書き方も用意されています。
matrix<T,R,C>
はfloat3x4
などのような書き方も用意されています。
構造体を作ることができます。
struct MyData
{
int a;
float b;
}
構造体のコンストラクタとして__init()
が使えます。
struct MyData
{
int a;
__init() { a = 5; }
__init(int t) { a = t; }
}
void main()
{
MyData d1;
MyData d2 = MyData(10);
}
コンストラクタ以外にもメンバ関数をもたせられます。
struct Foo
{
int compute(int a, int b)
{
return a + b;
}
}
void main()
{
Foo f;
int result = f.compute(1, 2);
}
メンバ関数でthis
を参照して自身を変更するには[mutating]
属性を使います。
struct Foo
{
int count;
[mutating]
void setCount(int x) { count = x; }
// [mutating]属性がないとエラー
// void setCount2(int x) { count = x; }
}
void test()
{
Foo f;
f.setCount(1); // Compiles
}
他にもプロパティのget/setであったり、オペレーターのオーバーロードなども行えます。
他の言語で見かけるようなenumもSlangでは使えます。
enum Channel
{
Red,
Green,
Blue
}
enumはその裏側の整数型を選ぶことができます。
enum Channel : uint16_t
{
Red, Green, Blue
}
[Flags]
属性を使うことでビットフラグとして使うこともできます。
[Flags]
enum Channel
{
Red, // = 1
Green, // = 2
Blue, // = 4
Alpha, // = 8
}
void main()
{
Channel c = Channel.Red | Channel.Blue;
}
関数の宣言は次のように行います。
float addSomeThings(int x, float y)
{
return x + y;
}
もしくは次のように書くこともできます。
func addSomeThings(x : int, y : float) -> float
{
return x + y;
}
こちらも二通りの書き方が選べて、昔ながらのCに由来しそうな書き方と、もう少し新しい言語っぽい見た目の書き方が選べるようです。
シェーダーのエントリーポイントの関数には[shader("vertex")]
や[shader("fragment")]
などの属性をつけられます。
[shader("vertex")]
float4 vertexMain(
float3 modelPosition : POSITION,
uint vertexID : SV_VertexID,
uniform float4x4 mvp)
: SV_Position
{ /* ... */ }
その他、if文やfor文、while文、switch文、break文、continue文、return文など基本的な文法は揃っています。
ラベル付きのbreakなどもあります。
Tuple
やOptional
などの便利なものもあります。
Tuple<int, float, bool> t1 = makeTuple(3, 1.0f, true);
Optional<int> getOptInt() { ... }
void test()
{
if (let x = getOptInt())
{
// xを使ってなにかする
}
}
他にもExtensionsや特殊なブロックスコープなどなど面白い機能があるので、詳しくはドキュメントを読んでみてください。
ジェネリクスとインターフェース
Slangではジェネリクスで複数の型に対して同じ関数などを実装したりできます。
public func add<T : IArithmetic>(a: T, b: T)->T
{
return a + b;
}
あるいは
public func add<T>(a: T, b: T)->T
where T : IArithmetic
{
return a + b;
}
上記のようにインターフェースと組み合わせて使うと便利そうです。
Slangではインターフェースを定義し、構造体にインタフェースの実装を行えます。
interface IFoo
{
int myMethod(float arg);
}
struct MyType : IFoo
{
int myMethod(float arg)
{
return (int)arg + 1;
}
}
int myGenericMethod<T>(T arg) where T : IFoo
{
return arg.myMethod(1.0);
}
パストレーシングなどを書くと、BRDFを切り替えながらシェーディングを行うことがあるように思います。BRDFに同じインタフェースを実装するときれいに実装できそうですね。
言語組み込みで基本型が実装するインタフェースも用意されており、ジェネリクスを使うことで、例えばint
、float
、float4
などにまとめて関数を定義したりなども出来るようです。
public func add<T : IArithmetic>(a: T, b: T)->T
{
return a + b;
}
int a = add(1, 2);
float b = add(1.0, 2.0);
float4 c = add(float4(1, 2, 3, 4), float4(5, 6, 7, 8));
ジェネリクスには定数の整数、bool、enumの値を渡すこともできます。
func add<T : IArithmetic>(a: T, b: T)->T
{
return a + b;
}
func test<T, let N : int>(a: T[N], b: T[N], out output: T[N])
where T : IArithmetic
{
for (var i = 0; i < N; i++)
{
output[i] = add(a[i], b[i]);
}
}
このlet N : int
の部分が定数を渡すジェネリクスです。
私は以前、パストレーシングをGLSLベースで作成した際、層化された乱数を生成する論文を参考にしました。その際、論文で示された定数項をパターンごとに変えて実装する必要があり、GLSLでマクロを駆使して複雑なコードを書く必要がありました。
Slangを使えばGLSLのマクロのように面倒なことをやらなくても素直に書けそうですね。
モジュール
Slangはモジュールが使えます。
module sub;
namespace sub
{
public func add<T : IArithmetic>(a: T, b: T)->T
{
return a + b;
}
}
import sub;
func main()
{
var a = sub::add(1, 2);
var b = sub::add(1.0, 2.0);
}
アクセスコントロールのpublic
やinternal
、private
も使えます。
implementing
や__include
などを使ってさらに整理できます。
GLSLでは#extension GL_GOOGLE_include_directive : enable
という拡張を有効にしたうえで昔ながらのC言語的なincludeは使えましたが#ifndef
や#define
などのマクロを使ったりする必要があり、あまり現代的な感じではありませんでした。
Slangではちゃんとモジュールの仕組みが言語機能として存在しており、ライブラリや共通部分の切り出しなどがやりやすくなったように思います。
ライブラリの配布などもやりやすくなるのではないでしょうか。
自動微分
PyTorchなどを使っていれば自動微分にはお世話になっていると思います。
関数を定義していくと、1階値を関数に通したあとでbackwardで後方微分を計算することで関数の勾配が分かり、勾配法でパラメータの最適化を進めることができます。
しかしグラフィクス系にニューラルネットを組み合わせる場合にはPyTorchでは力不足な局面がありました。
例えばInstantNGPのようなNeRFを高速化する等の場合にはCUDAで手作業で書く必要があったようです。
他にもラスタライザやレイトレーサーなどのGPUのグラフィクス系の機能を組み合わせるようなニューラルネットの研究などではHLSLやGLSLで研究用のコードを書くことがありました。
しかしCUDAにもHLSLにもGLSLにも自動微分機能は言語組み込みでは存在しないので、自分で手作業で関数の勾配を導出した式を使ったりしていて、手間がかかり、エラーも起こりやすい状況でした。
上記のような局面ではSlangの自動微分機能はかなり便利そうです。
リアルタイムレンダリングで直接使用するよりは、パラメータの最適化など事前処理での利用が主になると考えられます。一方で、NVIDIAがRTX50番台とともに発表したニューラルシェーダーなど、グラフィクス分野でニューラルネットが広く使われ始めています。今後の需要はさらに高まる可能性があります。
Slangの自動微分についてはこちらの2023年10月のブログ記事の紹介なども参考になるかもしれません。
その他
その他、HLSLから漸進的に移行していけるとか、バインディング周りが記述の手間が少ないとか、リフレクションのAPIがあるとか、いろいろな機能があるようです。
詳しくは公式ドキュメントを読んでください。
Slangを使ってみる
ということでサクッと簡単な三角形表示にSlangを使ってみてSlangのコンパイルとその利用方法を見ていきます。
次のような頂点シェーダーとフラグメントシェーダーを書いてみます。
struct VertexInput
{
float2 position;
float3 color;
}
struct VertexOutput
{
float4 position : SV_POSITION;
float3 color : TEXCOORD0;
}
[shader("vertex")]
func vsMain(VertexInput input)->VertexOutput
{
VertexOutput output;
output.position = float4(input.position, 0.0, 1.0);
output.color = input.color;
return output;
}
struct FragmentOutput
{
float4 color : SV_TARGET;
}
[shader("fragment")]
func fsMain(VertexOutput input)->FragmentOutput
{
FragmentOutput output;
output.color = float4(input.color, 1.0);
return output;
}
こちらはshaders/shader.slang
というファイルに保存したとします。
頂点シェーダーとフラグメントシェーダーは同じファイルにまとめて書いて構いません。
頂点カラーを表示するだけの単純なもので、Slangの魅力的な機能はあまり使っていませんが……。
こちらをVulkanから利用するために次のコマンドでSPIR-Vにコンパイルします。
slangc shaders/shader.slang -target spirv -profile spirv_1_6 -entry vsMain -stage vertex -o shaders/vert.spv
slangc shaders/shader.slang -target spirv -profile spirv_1_6 -entry fsMain -stage fragment -o shaders/frag.spv
今回はコンパイル先のSPIR-Vのバージョンは1.6を指定しています。
Vulkanの1.3でSPIR-Vのバージョン1.6に対応し、Vulkan 1.4でも1.6までのサポートとなっています。利用するVulkanのバージョンに合わせてSPIR-Vのバージョンを指定してください。
コンパイルしてしまえば、GLSLからコンパイルしたSPIR-Vと変わらない扱いができるので、例えば次のようにしてVulkanで利用できます。(RustとRustのVulkanバインディングのashを利用)
...
// Create shader stage create infos
let vertex_shader_module = {
let code = include_bytes!("../shaders/vert.spv");
let create_info =
vk::ShaderModuleCreateInfo::default().code(bytemuck::cast_slice(code));
unsafe { device.create_shader_module(&create_info, None)? }
};
let fragment_shader_module = {
let code = include_bytes!("../shaders/frag.spv");
let create_info =
vk::ShaderModuleCreateInfo::default().code(bytemuck::cast_slice(code));
unsafe { device.create_shader_module(&create_info, None)? }
};
let main_function_name = CString::new("main")?;
let shader_stages = [
vk::PipelineShaderStageCreateInfo::default()
.stage(vk::ShaderStageFlags::VERTEX)
.module(vertex_shader_module)
.name(&main_function_name),
vk::PipelineShaderStageCreateInfo::default()
.stage(vk::ShaderStageFlags::FRAGMENT)
.module(fragment_shader_module)
.name(&main_function_name),
];
...
Slangをcargo build時にコンパイルする
毎回手でコマンドを打ってビルドするのも大変なので、build.rs
を用意してcargo build
やcargo run
時にSlangをコンパイルするようにしてみます。
まずはCargo.toml
に次の[build-dependencies]
を追加します。
[build-dependencies]
walkdir = "2"
次にbuild.rs
を作成します。
use std::{
env, fs,
path::{Path, PathBuf},
process::Command,
};
fn watch_all_slang_files(dir: &Path) {
for entry in walkdir::WalkDir::new(dir) {
let entry = entry.unwrap();
if entry.path().extension().and_then(|e| e.to_str()) == Some("slang") {
println!("cargo:rerun-if-changed={}", entry.path().display());
}
}
}
fn compile_slang_shader(src: &Path, dst: &Path, stage: &str, entry_function: &str) {
let status = Command::new("slangc")
.args([
src.to_str().unwrap(),
"-target",
"spirv",
"-profile",
"spirv_1_6",
"-entry",
entry_function,
"-stage",
stage,
"-o",
dst.to_str().unwrap(),
])
.status()
.unwrap_or_else(|_| panic!("Failed to run slangc for {:?}", src));
if !status.success() {
panic!("Slang compilation failed for {:?}", src);
}
}
fn main() {
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()).join("shaders");
fs::create_dir_all(&out_dir).unwrap();
let shader_dir = PathBuf::from("shaders");
let entry_shaders = [
("shader.slang", "vert.spv", "vertex", "vsMain"),
("shader.slang", "frag.spv", "fragment", "fsMain"),
];
// Watch all .slang files in the shader directory
watch_all_slang_files(&shader_dir);
// Compile all shaders
for (entry_file, output, stage, entry_function) in &entry_shaders {
let src_path = shader_dir.join(entry_file);
let out_path = out_dir.join(output);
compile_slang_shader(&src_path, &out_path, stage, entry_function);
}
}
build.rs
の内容は、Slangのコンパイラであるslangc
を呼び出して、指定したシェーダーをコンパイルするものです。shaders/
ディレクトリ内の.slang
ファイルを監視して、変更があった場合に再コンパイルするようにしています。
コンパイルするシェーダーを増やす場合は、entry_shaders
の変数に要素を追加してください。
出力先はコンパイル時の中間成果物を置いておくOUT_DIR
の中のOUT_DIR/shaders/
にしています。OUT_DIR
はbuild.rs
でもsrc/main.rs
などでもenv!()
で取得できるので、build.rs
でコンパイルしたものをsrc/main.rs
などから利用できます。OUT_DIR
はCargoがビルド時に自動的に設定してくれるので、特に指定する必要はありません。
利用する側のinclude_bytes!
の部分はconcat!()
とenv!()
を組み合わせて次のように編集します。
...
// Create shader stage create infos
let vertex_shader_module = {
let code = include_bytes!(concat!(env!("OUT_DIR"), "/shaders/vert.spv"));
let create_info =
vk::ShaderModuleCreateInfo::default().code(bytemuck::cast_slice(code));
unsafe { device.create_shader_module(&create_info, None)? }
};
let fragment_shader_module = {
let code = include_bytes!(concat!(env!("OUT_DIR"), "/shaders/frag.spv"));
let create_info =
vk::ShaderModuleCreateInfo::default().code(bytemuck::cast_slice(code));
unsafe { device.create_shader_module(&create_info, None)? }
};
let main_function_name = CString::new("main")?;
let shader_stages = [
vk::PipelineShaderStageCreateInfo::default()
.stage(vk::ShaderStageFlags::VERTEX)
.module(vertex_shader_module)
.name(&main_function_name),
vk::PipelineShaderStageCreateInfo::default()
.stage(vk::ShaderStageFlags::FRAGMENT)
.module(fragment_shader_module)
.name(&main_function_name),
];
...
今回実装したRustのコードは次のリポジトリに置いてあります。
おわりに
今回はSlangをRustとVulkan (ash)で試してみました。
現代的な言語機能が取り入れられており、これまでGLSLを使っていた身からするとたいへん書きやすくてありがたいです。
また自動微分機能もあり高度な研究用途などでも使えるポテンシャルがあります。
モジュール機能もありライブラリの配布が行いやすいこともあってか、NVIDIAのニューラルシェーダーのSDKのRTXNSはSlangがベースで提供されていました。
今後のNVIDIAなどが出す最新のSDKなどでもSlangベースになっていくかもしれません。
Slangは今後も注目していきたい言語の一つですね。
VulkanSDKを入れているとSlangのコンパイラも既にインストールされているはずなので、VulkanのシェーダーをGLSLで書いている人はぜひSlangを試してみると良いと思います。
Discussion