Skip to content

Visual Shader

zwluoqi edited this page Oct 7, 2021 · 2 revisions

最近我为开源渲染器BGFX实现了一个Shader Graph/Material Editor,我称呼它为Visual ShaderBGFX是一款跨平台的渲染库,目前支持Metal、DX11、DX12、OpenGL等。

简介

着色图是一个表示Shader/Material的图形网络拓扑。他提供了一个基于节点的图形界面,允许设计人员在不编写任何代码的情况下,添加和连接节点来创建着色器。下面是一些知名游戏引擎自带的Shader Graph

Unity Shader Editor

Unity Shader Editor

Unreal Engine4 Material Editor

Unreal Engine4 Material Editor

Shade On iOS

Shade On iOS

最近我在使用iPad时,在App Store上发现Shade这款APP时,它是一款移动版的Shader Material Editor,使用它来创建材质变得更加容易,并且能够使我在非办公环境下依旧较好的使用效果。 所以当我觉得需要为前期的学习完成一款产品时,我决定实现一个跨平台的Shader Graph。 以下是一个使用Visual Shader的案例,展示了最终实现的使用Visual Shader 编辑一个PBR的模型: Visual Shader PBR

对我而言,实现Visual Shader并不算简单。你可以找到的例子,通常太过于庞大复杂,并不适合个人实现。且大部分是基于OpenGL或者GLFW来实现的。但是我最初的设定是想实现一款跨平台的产品,经过调研,BGFX基本满足我的需求,跨平台,接口抽象简单,所以底层的渲染器最终选择了它。这篇文章中,我将展示我是如何来实现Visual Shader的。

工作原理

基础框架:Shader Graph通过图网来表示一个片段着色器(Fragment Shader)。在Visual Shader中,我通过生成GLSL的片段着色器来完成,这个过程成为Visual Shader的编译过程。

在图中的每一个节点都是一个代码片段。例如node_gamma节点接收两个参数(颜色、gama值)并输出gama转换之后的输出。在GLSL中,它就是简单的代码块:colorout=pow(colorin,gama);。然后在Visual Shader的编译过程中,我们只是将节点关系解析,然后形成完整的程序。

每个着色器图都有一个主节点,它是着色器图的最终输出。不同的光照模型认为是不同的主节点。在Visual Shader中,我主要实现了几种简单的主节点,Diffuse,Toon,PBR,OrenNayar等。

vsg-game

Visual Shader的编译过程,我们从主节点开始,然后递归遍历输入输出,将输入输出传递到对应的节点代码块中,如果节点的输入没有连接,则我们认为它应当作为着色器的Uniform输入,即最终的材质输入值。递归这个过程,直到主节点被完整解析。

编译过程

作为一个案例,我们试着按照上面的过程解析下面这个Shader Graph。 vsg-example 编译过程是这样进行的:
1.尝试解析主节点Emission(Master节点):
    a.解析输入参数Color。
        i.解析MixRGB节点
            x.解析输入参数Mix Type,因为它是枚举类型,在visual shader中一般会定义对应的函数入口,知道对应的函数node_mix_xx,这里使用node_mix_blend
            y.解析输入参数Clamp,因为它没有连接点,所以将其申明为着色器的Uniform变量Clamp1
            z.解析输入参数Fac,因为他没有连接点,所以将其申明为着色器的Uniform变量Fac1
            w.解析输入参数Color1
                Wi.解析RGB节点
                    Wix.解析输入参数Value,因为他没有连接点,所以将其申明为着色器的Uniform变量Value1
                    Wiy.声明输出参数Vec4 Color1
                    Wiz.调用节点代码块:node_rgb(value1,color1)
            k.解析输入参数Color2
                ki.解析RGB节点
                    kix.解析输入参数Value,因为他没有连接点,所以将其申明为着色器的Uniform变量Value2
                    kiy.声明输出参数Vec4 Color2
                    kiz.调用节点代码块:node_rgb(value2,color2)
            p.声明输出参数Vec4 mix_color
            q.调用节点代码块:node_mix_blend(Clamp1,Fac1,Color1,Color2,mix_color)
            b.解析输入参数Strength,因为它没有连接点,所以将其申明为着色器的Uniform变量Strength1
            c.声明输出参数Vec4 Emission
            d.调用节点代码块:node_emission(mix_color,Strength1,Emission)
            f.输出着色器结果gl_FragColor = Emission
最终生成的Fragment代码可能像下面这样:

#version 450

// other uniforms/attributes here...

uniform float Clamp1;
uniform float Fac1;
uniform vec3 Value1;
uniform vec3 Value2;
uniform float Strength1;

void node_rgb(vec3 val,inout vec4 col){
    color = vec4(val,1.0);
}

void mix_blend(float use_clamp, float fac, vec4 col1, vec4 col2, out vec4 outcol)
{
  fac = clamp(fac, 0.0, 1.0);
  outcol = mix(col1, col2, fac);
  outcol.a = col1.a;
}
void node_emission(vec4 val,float strength ,inout vec4 col){
    col = val*strength;
}

void main()
{
   // pre code...
   vec4 Color1;
   node_rgb(Value1,Color1);
   vec4 Color2;
   node_rgb(Value2,Color2);
   Vec4 mix_color;
   node_mix_blend(Clamp1,Fac1,Color1,Color2,mix_color);   
   vec4 Emission;
   node_emission(mix_color,Strength1,Emission)
   gl_FragColor = Emission;

   // post code...
}

其中的节点代码块,可以写在独立的glsl文件中,这里为了描述清晰整合在一起方便浏览。有些节点可能有多个输出,这个只需要在对应的节点代码块提前定义出来即可。

伪代码

function resolve_node(node,outIndex):
    if node is resolved:
        return node.result[outIndex]
    let this_code = node.code
    store_outputs(node) // create variables for each output
    //repleace out var
    for j in node.outs:
        replace(this_code, j, store_outs[j])
    
    //repleace out var
    for i in node.inputs:
        if i.connected_node
            replace(this_code, i, resolve_node(i.connected_node,i.connected_slot_index)))
        else:
            replace(this_code, i, node.uniform_slot)
     write_code(this_code);
     
     //return call need slot out
     return store_outs[outIndex];

编辑器

面向数据的设计

Visual Shader中Graph Editor实现方式是一种面向数据的设计结果,Graph只是保存节点和连接数据,Editor负责创建和修改图形,Compiler接收数据并输出片段着色器代码。

数据结构

正如前面所述,Graph只保留节点和链接数据,这样比较容易存储序列化数据。基本数据结构如下:

enum class SlotType
{
    FLOAT = 1, VEC2, VEC3, VEC4, SAMPLER2D, INVALID = 0
};

struct Slot
{
    SlotType type{};
    string value; // if unconnected
};
struct Node
{
    string name;
    Guid guid;
    vec2 position;
    vector<Slot> input_slots;
    vector<Slot> output_slots;
};
struct Connect
{
    Guid node_out;
    Guid node_in;
    int slot_out_index{};
    int slot_in_index{};
};
struct Parameter
{
    string name;
    SlotType type{};
    string default_value;
};

struct Graph
{
    Guid master_node;
    hash_table<Guid, Node> nodes;
    vector<Connect> links;
    vector<SlotType> parameters;
};

节点预览

节点预览是Shader Graph的一个很重要的功能,它提供在节点阶段的输出值的预览。由于时间所限,在目前阶段Visual Shader暂时不提供该功能,但是我对它的工作原理还是有写了解的。 首先需要的是为想要预览的节点独立生成一个不同的着色器,将当前节点的输出值转成Vec4,将其作为颜色值返回即可。对于浮点数,我们将其转换为灰度图颜色值即可。 其实你需要与渲染器集成,将每个节点结果渲染到单独的一张图片中。

总结

实现Visual Shader的过程是一件蛮有趣的事,这篇文章中我省略了很多细节,因为自己的Mac版本太低,无法输出iOS版本的APP,后期应该不会在继续该项目的迭代工作了。如果你希望为自己的渲染器或引擎实现一个Shader Graph,希望这篇文章对你有所帮助,另外该项目的源码我已上传至GitHub,如果你想了解更多细节,可以直接浏览源码。

Clone this wiki locally