ShaderToy shader快速移植实现

发布时间: 2022年05月21日阅读数: 2

如何将ShaderToy Shader转换成Unity可用的Shader

做图形开发的同学没有不知道ShaderToy网站的,它是由大神 iq - Inigo Quilez创建的。
ShaderToy网站包含了大量效果精彩绝伦的shader,但是这些shader是用WebGL Shader语言编写的,不适用于Unity ShaderToy。
那么如何方便的在项目中方便的使用ShaderToy Shader这个问题就直接怼脸上了。

下面就以Chimera's Breath来说明网站的玩法

一、了解ShaderToy网站的基本用法

    1. 网站界面说明,首先打开https://www.shadertoy.com/view/4tGfDW 就可以得到下图的界面:
    • 左侧图像显示区域就是这个Shader最终呈现的效果,在区域下方的控制条中可以暂停/继续运行
    • 上图中的Shader Inputs和Unity中的Shader输入没什么区别,只是在Unity中的名称不同,将在下文中详细阐述
    • 右侧的文本框就是Shader代码的编辑区域,ShaderToy的Shader是由多个预定义用途的文件组成,它们分别是:
      • Common: 可理解成Unity中Shader需要引用的cginc/hlsl库文件, 这是可选文件
      • Buffer A: 可理解成是最终的fragment shader需要的Texture输入,只不过这个Texture的内容是由这个文件中的shader代码动态计算得来,这是可选文件
      • Buffer B: 同Buffer A,只是在一个Buffer不够用的时候,可以有更多的Buffer
      • Buffer C: 同Buffer A,只是在一个Buffer不够用的时候,可以有更多的Buffer
      • Buffer D: 同Buffer A,只是在一个Buffer不够用的时候,可以有更多的Buffer
      • Sound: 用于生成声音的shader代码,理解傅立叶变换的同学都知道,复杂波形声音可以通过简单波形叠加产生,那么这里就可以用上了,这也是个可选文件
      • Cubemap A: 用于生成Cubemap的shader代码,这是可选文件
      • Image 等同于Unity Shader中的fragment shader,左侧图像最终显示的内容也是由这个shader输出的
    • 上述文件里除了Common, 其他文件下方都会有4个黑色框并伴随iChannel0(就是下文提到的输入通道),这些可以理解成是当前文件的输入Texture,只不过不再是静态的图片,而是通过shader动态计算输出的图像内容
    1. 从网站界面说明中就可以看出,ShaderToy没有支持vertex shader,而fragment shader输出范围就是左侧的整个画布,这个是不同于Unity的,所以转成Unity Shader的时候就自然而然的考虑使用Render To Texture的技术方案。

===============================

如何转换可以从3个方面来考虑:

一. Shader 代码转换成 Unity URP 认的HLSL,这个可以参考这个博客中的方法

  • 上文中提到的Shader Inputs转换:

    • iResolution : 视窗分辨率,以像素为单位

    • iTime : shader运行时间,以秒为单位

    • iTimeDelta : 当前帧渲染时间,以秒为单位

    • iFrame : shader运行帧数

      • 对应Unity中的Time.frameCount, 该API只能在C#中访问,所以需要额外将该参数传入
    • iChannelTime[4] : 各个通道运行时间,以秒为单位

      • Unity中没有对应API,但是在我们这个转换方案中,各个通道都是同时开始运行的,那么这个时间应该是等于iTime,然后由于Unity中使用数组十分麻烦,所以我们直接对应Unity中的_Time.y ,但需要注意的是数组和数值的语法差异需要单独处理(所幸该属性用到的地方并不多:) )
    • iChannelResolution[4] : 各个通道分辨率,以像素为单位

      • Unity中没有对应API,但是在我们这个转换方案中,各个通道都是相同大小,那么这个时间应该是等于iTime,所以对应Unity中的_ScreenParams, 而且又是个数组,处理方式同iChannelTime[4]
    • iMouse : 鼠标像素坐标,xy:当前位置, zw:点击位置

      • 对应Unity中的Input.mousePosition, 该API只能C#中访问,而且格式不匹配ShaderToy,所以需要额外组装数据再传入shader
    • iChannel0..3 : 输入通道,

      • 对应Unity中texture shader property,这个是我们可以自定义名字的,那么shader里面就定义成这些名字
    • iDate : 年,月,日,时间 四个值,以秒为单位

      • Unity中没有对应API,需要通过C#组装数据并传入
    • iSampleRate : 声音采样率

      • Unity中没有对应API,可以通过开放给用户,然后C#传入shader
  • 我这里也把常用的关键字转换代码集合到了ShaderToyBase

    /********** ShaderToy Inputs ************/
    #define iResolution _ScreenParams
    #define iTime _Time.y
    #define iChannelTime _Time.y
    #define iChannelResolution _ScreenParams
    #define iTimeDelta unity_DeltaTime.x
    /****************************************/
    
    /****************************************/
    #define PI 3.14159265358
    
    #define mix lerp
    #define vec2 float2
    #define vec3 float3
    #define vec4 float4
    #define fract frac
    #define mat2 float2x2
    #define mat3 float3x3
    #define mat4 float4x4
    vec4 texelFetch(sampler2D samp,vec2 p, int lod)
    {
        return tex2Dlod(samp, float4(p, 0, lod));
    }
    
    vec4 textureLod(sampler2D samp,vec2 p, float lod)
    {
        return tex2Dlod(samp, float4(p, 0, lod));
    }
    
    /*****************************************/
    

    需要注意的是:ShaderToy使用的GLES代码中矩阵乘法是直接用的乘法运算符(*), 而Unity中则需要使用mul方法来实现

二. 完成了第一个步骤也只是解决了单 Channel(通道)类型的 Shader 的转换。如何对下图中多Channel类型的Shader进行转换呢?

首先我们考虑ShaderToy有默认的4个Channel类型,既然要转换这个类型的Shader,那么这个Channel信息Channel之间的链接信息是必须也要转换到Unity的,所以我们有:

1. 保存Channel结构
[Serializable]
public class ChannelBuffer
{
    /// <summary>
    /// 该通道名,对应的是ShaderToy中的A,B,C,D和Image通道
    /// </summary>
    public ChannelEnum bufferName = ChannelEnum.none;

    /// <summary>
    /// 该通道的输入通道信息
    /// </summary>
    public ChannelEnum[] inputBufferNames;

    /// <summary>
    /// 该通道渲染所需Material,其上绑定了对应Channel 的 Shader
    /// </summary>
    public Material material;

    /// <summary>
    /// 该通道的RenderTexture 格式,按需选择降低性能消耗
    /// </summary>
    public RenderTextureFormat format = RenderTextureFormat.ARGBHalf;
    
    /// <summary>
    /// 该通道开放的参数
    /// </summary>
    public ArgumentItem[] arguments;
}
2. 如何保存Channel 之间的链接信息

让我们来梳理一下Chimera's Breath的链接信息得到下图

根据上图中的信息:

  • 给Channle A 添加输入参数Channel C如下图:

  • 给Channle B 添加输入参数Channel A如下图:

  • 给Channle C 添加输入参数Channel B如下图:

  • 给Channle D 添加输入参数Channel A B D如下图:

  • 给Channle image 添加输入参数Channel D如下图:

  • 数据设置已经完成,那么代码里面是如何建立Channel之间的链接关系的呢,如下代码所示,
    其中各个Channel会依次调用该Channel的Connect方法

    public void Init()
        {
            if (bufferA.bufferName != ChannelEnum.none)
            {
                bufferA.Connect(new ChannelBuffer[] {bufferA, bufferB, bufferC, bufferD, image});
            }
    
            if (bufferB.bufferName != ChannelEnum.none)
            {
                bufferB.Connect(new ChannelBuffer[] {bufferA, bufferB, bufferC, bufferD, image});
            }
    
            if (bufferC.bufferName != ChannelEnum.none)
            {
                bufferC.Connect(new ChannelBuffer[] {bufferA, bufferB, bufferC, bufferD, image});
            }
    
            if (bufferD.bufferName != ChannelEnum.none)
            {
                bufferD.Connect(new ChannelBuffer[] {bufferA, bufferB, bufferC, bufferD, image});
            }
    
            image.Connect(new ChannelBuffer[] {bufferA, bufferB, bufferC, bufferD, image});
        }
    
        // 对应的Connect方法
        public void Connect(ChannelBuffer[] buffers)
        {
            if (bufferName == ChannelEnum.none)
            {
                return;
            }
    
            channels = new ChannelBuffer[inputBufferNames.Length];
            for (int i = 0; i < inputBufferNames.Length; i++)
            {
                var name = inputBufferNames[i];
                if (name == ChannelEnum.none) continue;
    
                // 将Channel name 和 Channel instance 对应上
                channels[i] = buffers.First(t =>
                {
    #if UNITY_EDITOR
                    Debug.Log(t.bufferName + " --> " + name);
    #endif
                    return t.bufferName == name;
                });
            }
    
            for (int i = 0; i < channels.Length; i++)
            {
                var c = channels[i];
                if (c != null && c.bufferName != ChannelEnum.none)
                {
                    if (c == this)
                    {
                        // 如果输入Channel中有自己这个Channel,则需要使用 double rendertexture swapping。
                        // 因为unity不支持在一个Graphic.Blit方法中,将同一个RenderTexture当作参数,又当作渲染目标
                        _rt1 = CreateRenderTexture(bufferName.ToString() + "_swap");
                        material.SetTexture("iChannel" + i, _rt1);
                    }
                    else
                    {
                        // 设置Shader中的对应Channel的RenderTexture
                        material.SetTexture("iChannel" + i, c.RenderTexture);
                    }
                }
            }
    
            // 设置常用参数
            material.SetFloat("iScreenRatio", RenderTexture.height * 1f / RenderTexture.width);
            material.SetVector("iScreenParams",
                new Vector4(RenderTexture.width, RenderTexture.height, RenderTexture.width * 1f / RenderTexture.height,
                    1));
            isConnected = true;
        }
    
3. 根据上文中提到的Shader转换方式得到各个Channel Shader, 然后设置各个Channel的material

4. 上述操作完成之后,运行Unity,该框架就会自动处理底层Material,RenderTexture之间的链接关系。
  • 渲染过程,核心就是调用Unity提供的API:Graphics.Blit

    • 其中的参数说明:
      • source: shader中有自己的逻辑,没有_MainTex属性,对应Material上有RenderTexture的引用,所以这个参数无用,填个Texture2D.whiteTexture 不报错就行。
      • dest: render target,这里就是各个通道的RenderTexture
      • mat : 使用的shader对应的material
    • 代码如下:
    public void Render()
          {
              if (!isConnected)
              {
                  return;
              }
    
              if (bufferName == ChannelEnum.none)
              {
                  return;
              }
    
    
              if (_rt1)
              {
                  // 如果有双Buffer swap,在这里渲染更新替换
                  Graphics.Blit(RenderTexture, _rt1);
              }
    
              Graphics.Blit(Texture2D.whiteTexture, RenderTexture, material);
          }
    
  • 这样我们就得到了Chimera's Breath 的最终效果了

三、最后一个步骤就是处理用户的输入

具体实现方式跟Unity Shader引入鼠标输入的方式一样,这里就不详细说明了
核心代码:ShaderToyRendererInput.cs

结语

代码已经在github开源:
https://github.com/LazyunGame/ShaderToyToUnity