Ap1-1 运行期编译GLSL

你是否已经厌倦了每次写完GLSL着色器代码,都得在文件浏览器的地址栏中复制入glslc.exe路径 着色器文件路径 -c这样的语句?
你可以用一些简单的C++代码来写个exe放桌面上,使得着色器拖上去就能编译:

#include <iostream>
#include <sstream>

#define COMPILER "glslc.exe路径"

int main(int argc, char** argv) {
    //argv[0]是程序本身的路径,argv[1]开始是拖上去的文件的路径
    for (int count = 1; count < argc; count++) {
            //显示被编译的文件名
            std::cout << "Compile file: " << argv[count] << std::endl;
            //构造命令行语句
            std::stringstream command;
            command << COMPILER << ' ' << argv[count] << " -c";
            //使用system(...)来执行命令行语句
            system(command.str().c_str());
        }
    system("pause");
    return 0;
}

不过,如果还在不断修改着色器代码,每次改完想看看效果前,都得从文件夹里把文件拖到编译用的exe上,那还是挺麻烦的。
而且,也可能会有需要根据具体情况,动态地生成着色器代码,然后再编译的需求。
出于以上考虑,这一节对如何在运行期编译GLSL进行简单介绍。

shaderc

Vulkan SDK中包含了谷歌的shaderc库,它提供将GLSL或HLSL代码编译到SPIR-V的功能。

所需的头文件

从Vulkan SDK的安装目录下找到Include/shaderc,里面是所需的头文件,将该文件夹复制到Vulkan相关文件的附加包含目录中。

所需的lib

Vulkan SDK的安装目录中,Lib文件夹下有这三个与shaderc相关的预编译库:

shaderc_combined.lib

包含C运行库以外的所有依赖项

shaderc.lib

只包含shaderc,需要其他依赖项,用起来比较麻烦,无视它

shaderc_shared.lib

存根库,依赖项皆为动态链接

  • Vulkan SDK中提供的这个shaderc_combined.lib几乎是个静态库,但是它不包含C标准库中一些数学函数的实现,编译时要求动态链接。
    如果需要静态链接,你可以从谷歌的Github仓库把项目拖下来自己编译成lib。

shaderc_combined.libshaderc_shared.lib复制到Vulkan相关文件的附加库目录中。
然后新建一个头文件,加入以下代码:

#include "EasyVKStart.h" //意在使用<iostream>、<ifstream>、<span>
#include <shaderc/shaderc.hpp>
#ifdef NDEBUG
#pragma comment(lib, "shaderc_combined.lib")
#else
#pragma comment(lib, "shaderc_shared.lib")
#endif
  • Vulkan SDK中提供的shaderc_combined.lib不支持Debug Build,只在Release Build下链接它。

fCompileGlslToSpv

定义一个仿函数类fCompileGlslToSpv,并加入以下成员变量:

class fCompileGlslToSpv {
    shaderc::Compiler compiler;
    shaderc::CompileOptions options;
    shaderc::SpvCompilationResult result;
    /*待填充*/
};
  • shaderc::Compilershaderc.hpp中提供的对编译器的封装类型。

  • shaderc::CompileOptions是对编译选项的封装类型。

  • shaderc::SpvCompilationResult是用于接受编译结果的类型。

Compiler的成员函数CompileGlslToSpv(...)将GLSL编译到SPIR-V:

SpvCompilationResult Compiler::CompileGlslToSpv(...) 的参数说明

const char* source_text

指向GLSL代码

size_t source_text_size

GLSL代码的大小,单位是字节

shaderc_shader_kind shader_kind

着色器类型,用shaderc_glsl_infer_from_source表示根据代码中的#pragma shader_stage(...)语句自动推断

const char* input_file_name

代码文件的路径,见后文

const char* entry_point_name

接入点函数名称

const CompileOptions& options

编译选项

  • 该函数不负责读取文件,但是要向input_file_name提供代码文件路径,之后需要从该路径出发,找到被#include的文件。
    若被编译的代码中没有#include,则仅将input_file_name用于报错信息(随便填也没关系)。

因为Compiler::CompileGlslToSpv(...)不负责读取代码文件(其所有重载都没有读取文件的功能),定义一个fCompileGlslToSpv的静态成员函数来读取文件:

class fCompileGlslToSpv {
    shaderc::Compiler compiler;
    shaderc::CompileOptions options;
    shaderc::SpvCompilationResult result;
    //Static Function
    static void LoadFile(const char* filepath, std::vector<char>& binaries) {
        std::ifstream file(filepath, std::ios::ate | std::ios::binary);
        if (!file) {
            outStream << std::format("[ fCompileGlslToSpv ] ERROR\nFailed to open the file: {}\n", filepath);
            return;
        }
        size_t fileSize = size_t(file.tellg());
        binaries.resize(fileSize);
        file.seekg(0);
        file.read(reinterpret_cast<char*>(binaries.data()), fileSize);
        file.close();
    }
};

然后来指定编译选项,就在fCompileGlslToSpv的构造函数中指定吧,暂且先只指定优化等级:

class fCompileGlslToSpv {
    shaderc::Compiler compiler;
    shaderc::CompileOptions options;
    shaderc::SpvCompilationResult result;
    //Static Function
    static void LoadFile(const char* filepath, std::vector<char>& binaries) { /*...*/ }
public:
    fCompileGlslToSpv() {
        options.SetOptimizationLevel(shaderc_optimization_level_performance);
        /*待后续填充*/
    }
};
  • 优化等级的可选项有:
    shaderc_optimization_level_zero(无优化)
    shaderc_optimization_level_size(优化程序大小)
    shaderc_optimization_level_performance(优化程序效率)。

定义用于执行编译的成员函数,如类型名fCompileGlslToSpv中前缀的f所言,我打算让其实例可以像函数一般被使用:

class fCompileGlslToSpv {
    shaderc::Compiler compiler;
    shaderc::CompileOptions options;
    shaderc::SpvCompilationResult result;
    //Static Function
    static void LoadFile(const char* filepath, std::vector<char>& binaries) { /*...*/ }
public:
    fCompileGlslToSpv() {
        options.SetOptimizationLevel(shaderc_optimization_level_performance);
        /*待后续填充*/
    }
    //Non-const Function
    std::span<const uint32_t> operator()(std::span<const char> code, const char* filepath, const char* entry = "main") {
        result = compiler.CompileGlslToSpv(code.data(), code.size(), shaderc_glsl_infer_from_source, filepath, entry, options);
        outStream << result.GetErrorMessage(); //输出错误信息,没有错误的话不会输出任何东西
        return { result.begin(), size_t(result.end() - result.begin()) * 4 };
    }
    std::span<const uint32_t> operator()(const char* filepath, const char* entry = "main") {
        std::vector<char> binaries;
        LoadFile(filepath, binaries);
        if (size_t fileSize = binaries.size())
            return (*this)(binaries, filepath, entry);
        return {};
    }
};

includer

Compiler::CompileGlslToSpv(...)不负责读取代码文件,当然也包括被#include的文件。
shaderc.hpp中定义了抽象类shaderc::CompileOptions::IncluderInterface作为读取被包含文件的接口类,定义fCompileGlslToSpv的内部类型includer以实现接口:

class fCompileGlslToSpv {
    struct includer: public shaderc::CompileOptions::IncluderInterface {
        /*待填充*/
    };
    //--------------------
    shaderc::Compiler compiler;
    shaderc::CompileOptions options;
    shaderc::SpvCompilationResult result;
    //Static Function
    static void LoadFile(const char* filepath, std::vector<char>& binaries) { /*...*/ }
public:
    fCompileGlslToSpv() {
        options.SetOptimizationLevel(shaderc_optimization_level_performance);
        /*待后续填充*/
    }
    //Non-const Function
    std::span<const uint32_t> operator()(std::span<const char> code, const char* filepath, const char* entry = "main") { /*...*/ }
    std::span<const uint32_t> operator()(const char* filepath, const char* entry = "main") { /*...*/ }
};

来看一下shaderc::CompileOptions::IncluderInterface的内容:

class IncluderInterface {
public:
    virtual shaderc_include_result* GetInclude(const char* requested_source, shaderc_include_result type, const char* requesting_source, size_t include_depth) = 0;
    virtual void ReleaseInclude(shaderc_include_result* data) = 0;
    virtual ~IncluderInterface() = default;
};
  • 编译过程中遇到#include时,会调用GetInclude(...)读取文件,之后会调用ReleaseInclude(...)释放相关的动态分配内存。

shaderc_include_result* IncluderInterface::GetInclude(...) 的参数说明

const char* requested_source

着色器代码中被包含文件的路径(#include "requested_source"

shaderc_include_result type

shaderc_include_type_relative对应"requested_source"
shaderc_include_type_standard对应<requested_source>

const char* requesting_source

当前(包含#include "requested_source"一句的)着色器代码文件的路径

size_t include_depth

包含的纵深,若最初通过Compiler::CompileGlslToSpv(...)编译的文件包含了文件A,A又包含了另一个文件B,则B的include_depth为2

  • "requested_source"<requested_source>有什么差别由你自己定,我接下来的代码不区分两者,一概当做相对路径。

struct shaderc_include_result 的成员说明

const char* source_name

被包含文件的路径

size_t* source_name_length

被包含文件的路径的长度,单位是字节

const char* content_name

指向被包含文件的GLSL代码

size_t* content_length

被包含文件的GLSL代码的大小,单位是字节

void* user_data

this指针即可,解释略

于是来实现GetInclude(...)和ReleaseInclude(...):

struct includer: public shaderc::CompileOptions::IncluderInterface {
    struct result_t : shaderc_include_result {
        std::string filepath;   //用来存被包含文件的文件路径
        std::vector<char> code; //用来存被包含文件的内容
    };
    shaderc_include_result* GetInclude(const char* requested_source, shaderc_include_result, const char* requesting_source, size_t) override {
        //↑无实参名的形参在我接下来的逻辑里都用不着

        auto& result = *(new result_t);
        auto& filepath = result.filepath;
        auto& code = result.code;

        /*待填充*/

        return &result;
    }
    void ReleaseInclude(shaderc_include_result* data) override {
        //将指针类型转到result_t*,以正确调用string和vector的析构器
        delete static_cast<result_t*>(data);
    }
};

获取当前GLSL着色器代码文件的路径:

filepath = requesting_source;

requested_source是被包含文件相对于requesting_source的路径(一般没人会自找麻烦用绝对路径,就不处理绝对路径了),那么将requesting_source尾部的文件名换成requested_source即是LoadFile(...)所需的被包含文件的路径:

filepath = requesting_source;
size_t pos = filepath.rfind('/');
if (pos == -1)
    pos = filepath.rfind('\\');
filepath.replace(pos + 1 + filepath.begin(), filepath.end(), requested_source);
LoadFile(filepath.c_str(), code);
  • std::string::rfind(...)从字符串尾部开始查找字符或子串,找不到时返回std::string::npos,值为size_t的最大值,C++中无符号整形最大值 == -1返回true

  • 根据个人习惯的不同,文件路径中的分隔符可能是斜杠也可能是反斜杠,这里反斜杠的写法为转义字符。

  • 查找不到分隔符时,说明被包含文件和当前文件在同一文件夹中,pos + 1为0,替换整个字符串。

  • 至少对于Windows的文件系统而言,"shader/../shader/FirstTriangle.frag.shader""shader/FirstTriangle.frag.shader"是等价的,因此不必对用于返回上级目录的"../"特地做处理。

填写result开头的shaderc_include_result部分,整个includer::GetInclude(...)如下:

shaderc_include_result* GetInclude(const char* requested_source, shaderc_include_result, const char* requesting_source, size_t) override {
    auto& result = *(new result_t);
    auto& filepath = result.filepath;
    auto& code = result.code;
    filepath = requesting_source;
    size_t pos = filepath.rfind('/');
    if (pos == -1)
        pos = filepath.rfind('\\');
    filepath.replace(pos + 1 + filepath.begin(), filepath.end(), requested_source);
    LoadFile(filepath.c_str(), code);
    static_cast<shaderc_include_result&>(result) = {
        filepath.c_str(),
        filepath.length(),
        code.data(),
        code.size(),
        this
    };
    return &result;
}

最后将includer设置到编译选项:

fCompileGlslToSpv() {
    options.SetOptimizationLevel(shaderc_optimization_level_performance);
    options.SetIncluder(std::make_unique<includer>());
}
  • CompileOptions::SetIncluder(...)要求的参数类型为std::unique_ptr<IncluderInterface>&&,即需要转移对象的所有权,由options管理IncluderInterface对象的生存期。

代码汇总及使用

这一节的代码没有上传到Github,整个文件的内容就写在这儿了:

#include "EasyVKStart.h"
#include <shaderc/shaderc.hpp>
#ifdef NDEBUG
#pragma comment(lib, "shaderc_combined.lib")
#else
#pragma comment(lib, "shaderc_shared.lib")
#endif

class fCompileGlslToSpv {
    struct includer: public shaderc::CompileOptions::IncluderInterface {
        struct result_t : shaderc_include_result {
            std::string filepath;
            std::vector<char> code;
        };
        shaderc_include_result* GetInclude(const char* requested_source, shaderc_include_result, const char* requesting_source, size_t) override {
            auto& result = *(new result_t);
            auto& filepath = result.filepath;
            auto& code = result.code;
            filepath = requesting_source;
            size_t pos = filepath.rfind('/');
            if (pos == -1)
                pos = filepath.rfind('\\');
            filepath.replace(pos + 1 + filepath.begin(), filepath.end(), requested_source);
            LoadFile(filepath.c_str(), code);
            static_cast<shaderc_include_result&>(result) = {
                filepath.c_str(),
                filepath.length(),
                code.data(),
                code.size(),
                this
            };
            return &result;
        }
        void ReleaseInclude(shaderc_include_result* data) override {
            delete static_cast<result_t*>(data);
        }
    };
    //--------------------
    shaderc::Compiler compiler;
    shaderc::CompileOptions options;
    shaderc::SpvCompilationResult result;
    //Static Function
    static void LoadFile(const char* filepath, std::vector<char>& binaries) {
        std::ifstream file(filepath, std::ios::ate | std::ios::binary);
        if (!file) {
            outStream << std::format("[ fCompileGlslToSpv ] ERROR\nFailed to open the file: {}\n", filepath);
            return;
        }
        size_t fileSize = size_t(file.tellg());
        binaries.resize(fileSize);
        file.seekg(0);
        file.read(reinterpret_cast<char*>(binaries.data()), fileSize);
        file.close();
    }
public:
    fCompileGlslToSpv() {
        options.SetOptimizationLevel(shaderc_optimization_level_performance);
        options.SetIncluder(std::make_unique<includer>());
    }
    //Non-const Function
    std::span<const uint32_t> operator()(std::span<const char> code, const char* filepath, const char* entry = "main") {
        result = compiler.CompileGlslToSpv(code.data(), code.size(), shaderc_glsl_infer_from_source, filepath, entry, options);
        outStream << result.GetErrorMessage();
        return { result.begin(), size_t(result.end() - result.begin()) * 4 };
    }
    std::span<const uint32_t> operator()(const char* filepath, const char* entry = "main") {
        std::vector<char> binaries;
        LoadFile(filepath, binaries);
        if (size_t fileSize = binaries.size())
            return (*this)(binaries, filepath, entry);
        return {};
    }
};

使用fCompileGlslToSpv的方法很简单,类似这样:

inline shaderModule CreateShaderModuleFromGlsl(fCompileGlslToSpv& fCompile, const char* filepath, const char* entry = "main") {
    auto code = fCompile(filepath, entry);
    return shaderModule(code.size(), code.data()); //必定发生RVO(返回值优化)
}

void CreatePipeline() {
    fCompileGlslToSpv fCompile;
    shaderModule vert = CreateShaderModuleFromGlsl(fCompile, "shader/FirstTriangle.vert.shader");
    shaderModule frag = CreateShaderModuleFromGlsl(fCompile, "shader/FirstTriangle.frag.shader");
    /*...*/
}