Unity Shader 基础教程

耗尽温柔 提交于 2019-12-03 16:08:27

Unity-Shader-基础教程

在Github上看到一篇关于Unity-Shader的教程,感觉还不错,作者写的很好,很适合Unity-Shader的基础入门,我在这里翻译一下,分享给大家,英文水平很烂,大致能明白Unity-Shader是什么,渲染管线的工作流程,以及Unity Shader的一些类型和怎样编写Unity Shader。
原文链接

第一部分:什么是Shader?

Shader是计算机图形渲染管线的一部分,它是一小段应用程序,告诉计算机在场景中怎样对物体渲染和着色。这个过程包括计算颜色和光照值,并将其给予对象,以至于对象被显示在屏幕上。和上面一样,Shader也被用来创建游戏中的一些特殊的和后期处理效果。

在现代游戏引擎中(包括Unity),Shader运行在可编程的GPU渲染管道中,在GPU中允许并行运行,并且能够很快速的进行许多着色计算。

第二部分:渲染管道

为了学习Shader,我们将简单的了解渲染管道,我们将在本教程中讨论下面这张图片:

我更加倾向把Shader看做是由一种信息类型(模型数据、颜色等等)到另外一种信息类型(像素/片元)的变换,对象数据继承与对象本身,例如模型中的点、法线、三角形、UV坐标等等。我们可以把自定义数据/传递到shader中使用,颜色、纹理、数字等等这些。

着色器流水线的第一步是顶点函数。正如你所知的,顶点就是一些点。顶点函数将处理模型中的一些点(连同其它的一些数据诸如法线)并将它们渲染流水线的下一个阶段,片元函数。

片元函数将使用这些顶点,并对它们着色。将它想象为一个画家和他们的画笔,它最终以(R,G,B,A)的格式输出像素数据。

最后,将像素添加到帧缓冲中,在帧缓冲中这些数据有可能被进一步处理,直到这些数据被绘制到屏幕上。

第三部分:Scene 配置

在开始写Shader代码之前,需要在Unity中配置一下我们的场景。在Unity中创建一个工程,并导入所有的资源。

  • Bowl Model(碗模型)
  • Noise Texture(噪声纹理)
  • Bowl texture(碗模型纹理)

在新场景中添加一个Cube、Sphere和Bowl Model(碗模型),并保存场景,之后,你的场景将向下面这样:

接下来,在Project视图中单击右键(或者点击Create)并添加一个新的Unlit Shader(无光照着色器),将其命名为Tutorial_Shader.

如果你对其它类型的shaders好奇的话,我会在文章的末尾谈论它。

然后在刚才创建的shader上点击右键Create>Material 创建一个材质球,Unity将自动创建一个材质球并使用刚才创建的着色器的名字。

Note:一个“Material”在Unity中仅仅是着色器的一个实例,它仅保存自定义数据/属性的值。

最后,通过单击或者拖动的方式将材质赋予我们在场景中创建的所有对象上。

之后场景中的每个对象看起来是这样的,白色,并且没有阴影或者shading:

第四部分:一个Unlit Shader(无光照着色器)的大致骨架

终于到了开始写我们自己的shader的时候了,在写之前,首先打开之前创建的Tutorial_Shader文件,你将看到Unity自动生成了一些代码供我们使用。为了继续本教程,删除所有代码并使文件变空白。

Note:所有的shader在Unity中使用的是一种被称为shaderlab的语言写的。Shadrlab是HLSL/CG语法的包装器,为了使Unity能够跨平台编译Shader代码并且在Inspector面板中暴露一些属性结点。

我们将添加一些初始代码,如下:

Shader "Unlit/Tutorial_Shader"{
    ...
}

这行代码的作用是指定着色器代码存放在什么位置。双引号中的字符串告诉Unity在哪里查找Shader.

例如:

Shader "A/B/C/D/E_Shader"{
    ...
}

如果你保存你的shader代码,并切回到Unity中,你将注意到所有使用这个材质的对象都已经变成了粉红色。

当你的着色器中有错误时,Unity中将调用FallBack着色器,你将会得到一个粉红色的物体。你可以在Project中点击shader文件查看相应的错误。目前,我们得到一个粉红色的物体,因为我们的shader文件还没有完成。

接下来是属性块,如下:

Shader "Unlit/Tutorial_Shader"{
    Properties{
        ...
    }
}

在属性块中,我们可以传递一些自定义数据。我们在这里声明的数据将被显示在Unity Editor面板中,在Editor中更改也会驱动脚本更改。

Shader "Unlit/Tutorial_Shader"{
    Properties{
        ...
    }
    SubShader{
        ...
    }
}

每一个shader有一个或者多个subshaders,如果你的应用将被部署到多种平台(移动、PC、主机),添加多个Subshader是非常有用的。例如:你可能想要写为PC/Desktop写一个高质量的Subshader,为移动端写一个低质量,但速度更快的Subshader.

pass语句块:

Shader "Unlit/Tutorial_Shader"{
    Properties{
        ...
    }
    Subshader{
        Pass{
            ...
        }
    }
}

每个Subshader至少有一个pass语句块,它实际上是对象渲染的位置。一些特效要求有多个pass语句块,目前,我们仅仅专注于一个。

在pass语句块中,是一些实际渲染的代码:

Shader "Unlit/Tutorial_Shader"{
    Properties{

    }
    Sunshader{
        pass{
            CGPROGRAM
                ...
            ENDCG
        }
    }
}

我们实际写的所有Shader代码都在CGPROGRAM和ENDCG中,对于Unity来说,shaderlab是HLSL和CG的变体。

下面,我们需要告诉Unity,顶点函数和片元函数是什么:

CGPROGRAM
    #pragma vertex vertexFunction
    #pragma fragment fragmentFunction
ENDCG

这里,我们将vertex函数声明为vertexFunction,fragment函数声明为fragmentFunction.

我们也将定义这些函数:

CGPROGRAM 
    #pragma vertex vertexFunction
    #pragma fragment fragmentFunction

    void vertexFunction(){

    }
    void fragmentFunction(){

    }
ENDCG

在开始着色之前,我们需要设置一些数据结构和两个函数,这样,我们就可以使用Unity给定的数据,并把数据返回到Unity中。首先,添加UnityCG.cginc语句块,我们可以使用这个文件中包含的一些助手函数。

我们将添加一个数据结构a2v(此处和原文不一致),并修改顶点函数,将a2v作为参数传递给顶点函数。

CGPROGRAM
    #pragma vertex vertexFunction
    #pragma fragment fragmentFunction

    #include "UnityCG.cginc"

    struct a2v{

    };
    void vertexFunction(a2v v){

    }
    void fragmentFunction(){

    }
ENDCG

当传递一个参数到vertexFunction中时,Unity将解析这个函数的结构,并基于正在绘制的对象模型传递值。我们可以传递一些自定义的数据,如下面声明的那样:

[type] [name] :[semantic]

例如,可以要求Unity获取模型对象的顶点坐标,如下:

flot4 vertex:POSITION;

我们也可以从Unity中获取顶点坐标和UV纹理坐标,如下:

struct a2v{
    float4 vertex:POSITION;
    float2 uv:TEXCOORD0;
}

最后配置顶点函数,创建一个结构体,并将其命名v2f(代表从vertex to fragment,顶点数据传递到片元),将vertex中包含的数据传递到片元函数,同时确保vertexFunction 返回 v2f的数据类型,在我们使用它时,创建并返回一个空白数据。

CGPROGRAM
    #pragma vertex vertexFunction
    #pragma fragment fragmentFunction
    #include "UnityCG.cginc"

    struct a2v{
        float4 vertex:POSITION;
        float2 uv:TEXCOORD0;
    };
    struct v2f{

    };
    v2f vertexFunction(a2v v){
        v2f o;
        return o;
    }
    void fragmentFunction(){

    }

ENDCG

像之前一样,我们可以在v2f结构体中定义一些数据,我们可能想要把这些数据从顶点函数传递到片元函数。

struct v2f{
    float4 position:SV_POSITION;
    float2 uv:TEXCOORD0;
}

如果你对SV_POSITION和POSITION 感到好奇,SV代表“system value”,在v2f结构中表示最终渲染的顶点的位置。

现在基本准备好了,我们仅仅需要编辑片元函数,使它接受一个v2f结构并返回一个fixed4的值。

fixed4 fragmentFunction(v2f i){

}

输出的片元函数将是一个有(R,G,B,A)代表的颜色值

最后,我们将为片元函数添加一个SV_TARGET的输出语义,如下:

fixed4 fragmentFunction(v2f i):SV_TARGET{

}

这个过程告诉Unity我们将输出一个color去渲染,现在准备开始实际的编码了,肉和土豆使我们的vertex和fragment函数,到这个点,大致的骨架已经出来了

Shader "Unlit/Tutorial_Shader"{
    Properties{

    }
    Subshader{
        Pass{
            CGPROGRAM
            #pragma vertex vertexFunction
            #pragma fragment fragmentFunction
            #include "UnityCG.cginc"

            struct a2v{
                float4 vertex:POSITION;
                float2 uv:TEXCOORD0;
            };
            struct v2f{
              float4 position:SV_POSITION;
              float2 uv:TEXCOORD0;
            };
            v2f vertexFunction(a2v v){
                v2f o;
                return o;
            }
            fixed4 fragmentFunction(v2f i):SV_TARGET{

            }
            ENDCG
        }
    }
}

第五部分:Shader 基础

首选我们要做的是获取顶点的正确位置,使用Unity中提供的UnityObjectToClipPos()函数(这个函数的作用是将世界空间的模型坐标转换到裁剪空间,函数内部封装了实现顶点坐标变换的具体细节,如矩阵变换等等),如下:

v2f vertexFunction(a2v v){
    v2f o;
    o.position=UNnityObjectToClipPos(v.vertex);
    return o;
}

这个函数将在局部空间中表示的顶点,变换到渲染相机的裁剪空间。注意,我们通过设置o.position的位置来传递转换的点。接下来,给片元函数一个输出。

fixed4 fragmentFunction(v2f i):SV_TARGET{
    return fixed4(0,1,0,1);
}

现在,等待一会儿。保存你的shader并且返回到Unity,你将看到我们的精美的绿色的物体。如下:

当然,这对你来说可能印象并不深刻,因此,让我们继续构建,而不是返回一个基本的绿色,可能我们想要编辑shader使得其能返回一个我们想要的颜色,为了做到这一点,我们需要回到开始的自定义属性。

我们可以使用如下语法添加一些属性:

name ("display name",type)=default value

如下,我们将暴露出一个颜色值,如下:

Properties{
    _Color("Totally Rad Color",Color)=(1,1,1,1)
}

在这里定义了一个颜色供我们使用,将其称之为_Color并且它将显示为 “Totally Rad Color!”,在Unity面板中。我们也将给予一个默认白色的值,现在保存并返回Unity,在Inspect的材质面板中,你将看到如下:

在我们使用这个color之前,我们需要把它传递到CG代码中,Unity会通过变量的名字进行自动绑定,如下:

CGPROGRAM
    #pragma vertex vertexFunction
    #pragma fragment fragmentFunction

    #include "UnityCG.cginc"

    struct a2v{
        float4 vertex:POSITION;
        float2 uv:TEXCOORD0;
    };
    struct v2f{
        float4 position:SV_POSITION;
        float2 uv:TEXCOORD0;
    };
    //从CG中获取属性
    float4 _Color;
    v2f vertexFunction(a2v v){
        v2f o;
        o.position=UnityObjectToClipPos(v.vertex);
        return o;
    }
    fixed4 fragmentFunction(v2f i):SV_TARGET{
        return fixed4(0,1,0,1);
    }
ENDCG

现在,可以在片元函数中使用_Color值了,让它返回我们期待的颜色值,而不是返回一个绿色:

fixed4 fragmentFunction(v2f i):SV_TARGET{
    return _Color;
}   

现在,保存并返回到Unity中,如果你在Inspect中的Material中改变_Color的值,你应该能看到所有对象做出了相应的改变。

现在我们知道了如何添加属性,让我们尝试添加一张标准的纹理贴图,这里需要添加一个新的属性给我们的纹理:

Properties{
    _Color("_Color",Color)=(1,1,1,1)
    _MainTexture("Mian Texture",2D)="white"{}
}

注意它的类型是2D,默认给它一张白色的纹理,我们还需要获取这个属性在CG片段中使用它:

float4 _Color;
sampler2D _MainTexTure;

然后,需要从模型传递UV纹理坐标到片元函数,我们可以通过返回顶点函数并将其传递v2f中,如下:

v2f vertexFunction(a2v v){
    v2f o;
    o.position=UnityObjectToClipPos(v.vertex);
    o.ov=v.uv;
    return o;
}

为了能在片元函数中使用纹理的颜色,我们需要对纹理进行采样。谢天谢地,CG中已经有一个tex2D()函数帮我们做了一切。

fixed4 fragmentFunction(v2f i):SV_TARGET{
    return tex2D(_MainTexture,i.uv);
}

tex2D获取我们想要采样的纹理以及我们想要采样的UV坐标,在这种情况下。我们提供了它的主纹理并给定模型的点,我们可以得到我们想要的颜色,最后返回的是最终的颜色。现在,保存并返回到Unity 的material insepct面板中,选择bowel纹理赋予”Main Texture”“,你会发现,模型发生了改变,尤其是碗的模型看起来尤其像一碗汤。

提示:我们可以改变纹理在Unity中的采样方式,通过选择纹理文件并在Inspector面板中改变filter mode(过滤模式):

第六部分:试着改变Shader

现在,我们已经大致了解了一些基础,我们可以做一些有趣的效果并做一些简单的特效。首先,我们将使用一张噪声贴图实现“溶解” 或者“切断”效应,首先我们将添加另一个纹理属性和一个float 属性,如下:

Properties{
    _Color("Color",Color)=(1,1,1,1)
    _MainTexture("Main Texture",2D)="white"{}
    _DissolveTexture("Dissolve Texture",2D)="white"{}
    _DissolveCutoff("Dissolve Cutoff",Range(0,1)=1
}

注意这里是如何设置_DissolveCutoff 为一个Range(0,1),它代表一个从(0,1)(包含)的float值,并且这种计数法允许我么容易的使用slider(滑动条)来设置值,接下来,让我们在CGPROGRAM中添加他们。

float4 _Color;
sampler2D _MainTexture;
sampler2D _DissolveTexture;
float _DissolveCutoff;

现在能在片元函数中对溶解纹理采样:

fixed4 fragmentFunction(v2f i):SV_TARGET{
    float4 textureColor=tex2D(_MainTexture,i.uv);
    float4 dissolveColor=tex2D(_DissolveCutoff,i.uv);
    return textureColor;
}

提示:我们将为我们的主纹理使用相同的UV纹理坐标,接下里,魔术发生了:

fixed4 fragmentFunction(v2f i):SV_TARGET{
    float4 textureColor=tex2D(_MainTexture,i.iv);
    float4 dissolveColor=tex2D(_DissolveTexture,i.uv);
    clip(dissolveColor.rgb-_DissolveCutoff);
    return textureColor;
}

clip 函数检查这个给定的值是否小于0.如果小于0,我们将丢弃这个像素并且不绘制它。如果大于0,继续保持像素、正常的渲染,我们的代码的工作方式如下:
1. 对主纹理的颜色进行采样
2. 对纹理颜色进行裁剪采样
3. 从裁剪纹理中减去裁剪值
4. 如果小于0,不进行绘制
5. 否则,返回主纹理采样颜色

现在,保存并返回Unity,回到材质面板,赋予“Dissolve Texture”我们的noise纹理,移动”Dissolve Cutoff” 滑动条,你应该会看到一个效果,向下面这样:

很酷吧? 我们也能做更多。在将其传递给fragment函数之前,让我们尝试更改这些顶点,在Inspector面板中暴露出一些结点属性。

Properties{
    _Color("Color",Color)=(1,1,1,1)
    _MainTexture("Main Texture",2D)="white"{}
    _DissolveTexture("Dissolve Texture",2D)="white"{}
    _DissolveCuroff("Dissolve Cutoff",Range(0,1))=1
    _ExtrudeAmount("Extrue Amount",float)=0
}
...

float4 _Color;
sampler2D _MainTexture;
sampler2D _DissolveTexture;
float _DissolveCutoff;
float _ExtrudeAmount;

我们还将使用模型中的法线信息,因此,让我们添加这个字段到a2v的结构体中,以至于我们能访问它。

struct a2v{
    float4 vertex:POSITION;
    float2 uv:TEXCOORD0;
    float3 normal:NORMAL;
};

现在,添加一个单行到顶点函数中:

v2f vertexFunction(a2v v){
    v2f o;
    v.vertex.xyz+=v.normal.xyz*_ExtrudeAmount;
    o.position=UnityObjectToClipPos(v.vertex);
    o.uv=v.uv;
    return o;
}

我么在这里做的是,在将顶点转换为局部模型空间之前,我们将通过增加他们的法线方向时间来抵消它们的外加量,法线是一个向量代表顶点面向的方向,现在保存并返回Unity中,改变”Extrude Amount”的值,你应该看到下面这样的效果:

我们也能为这些属性制作动画:

v2f vertexFunction(a2v v){
    v2f o;
    v.vertex.xyz+=v.normal.xyz*_ExtrudeAmount*sin(_Time.y);
    o.position=UnityObjectToClipPos(v.vertex);
    o.uv=v.uv;
    return o;
}

_Time是一个代表时间的变量被包含在UnityCH.cginc中,y值代表秒,确保“Animated Materials” 在场景视图中被勾选,如下:

下面是我们最终的代码:

Shader "Unlit/Tutorial_Shader"{
    Properties{
        _Color("Color",Color)=(1,1,1,1)
        _MainTexture("Main Texture",2D)="white"{}
        _DissolveTexture("Dissolve Texture",2D)="white"{}
        _DissolveCutoff("Dissolve Cutoff",Range(0,1))=1
        _ExtrudeAmount("Extrue Amount",float)=0
    }
    Subshader{
        Pass{
            CGPROGRAM
                #pragma vertex vertexFunction
                #pragma fragment fragmentFunction
                #include "UnityCG.cginc"

                struct a2v{
                    float4 vertex:POSITION;
                    float2 uv:TEXCOORD0;
                    float3 normal:NORMAL;
                };
                struct v2f{
                    float4 position:SV_POSITION;
                    flaot2 uv:TEXCOORD0;
                };
                float4 _Color;
                sampler2D _MainTexture;
                sampler2D _DissolveTexture;
                float _DissolveCutoff;
                float _ExtrudeAmount;

                v2f vertexFunction(a2v v){
                    v2f o;
                    v.vertex.xyz+=v.normal.xyz*_ExtrudeAmount*sin(_Time.y);
                    o.position=UnityObjectToClipPos(v.vertex);
                    o.uv=v.uv;
                    return o;
                }

                fixed4 fragmentFunction(v2f i):SV_TARGET{
                    float4 textureColor=tex2D(_MainTexture,i.uv);
                    float4 dissolveColor=tex2D(_DissolveTexture,i.uv);
                    clip(dissolveColor.rgb-_DissolveCutoff);
                    return textureColor;
                }
            ENDCG
        }
    }
}

第七部分:Scripting 和Shaders

接下来,我们将讨论怎样使用Unity脚本来控制Shader(即C#和Shader的交互),例如,我们将再次使用之前添加的_Color属性。首先,我们再片元函数中让其为着色器的颜色进行着色,如下:

fixed4 fragmentFunction(v2f i):SV_TARGET{
    float4 textureColor=tex2D(_MainTexture,i.uv);
    float4 dissolveColor=tex2D(_DissolveTexture,i.uv);
    clip(dissolveColor.rgb-_DissolveCutoff);
    return textureColor*_Color;
}

我们将输出颜色和_Color属性相乘,在Editor中如下:

现在,让我们开始写脚本吧,我们将为每一个对象添加一个名为RainbowColour.cs的脚本。

在脚本中,声明两个私有变量 Rendeerer和Materail:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class RainbowColor:MonoBehaviour{

    Renderer rend;
    Material material;

    void Start(){

    }
    void Update(){

    }
}

在Satrt()函数中,设置引用值。

void Start(){
    rend=GetComponent<Renderer>();
    material=rend.material;
}

我们将在shader中使用Material.SetColor()函数设置颜色值,这个函数的第一个参数是一个字符串,它的名字使我们想要设置的属性的名字,第二个参数是我们想要设置的颜色的值。

void Start(){
    rend=GetComponent<Renderer>();
    material=rend.material;
    material.SetColor("_Color",Color.mangenta);
}

当我们运行游戏的时候,颜色变为品红。

第八部分:阴影? 表面着色器?

到目前为止,我们写了一个Unlit Shader(无光照着色器),Unity还允许你写表面着色器,表面着色器实际上就像vertex/fragment着色器,除了它们去掉了许多使着色器与光照与阴影交互的示例代码。如果你对写光照和阴影感兴趣,这是一份很棒的教程 here

在这个章节,我将展示的是,表面着色器的每个部分如何与我们的顶点/片元着色器相关联,如果你在Unity中创建一个新的“Standard Shader”,你会看到一些自动生成的代码,如下:

Shader "Custom/NewSurfaceShader" {
    Properties{
        _Color("Color",Color)=(1,1,1,1)
        _MainTex("Albedo(RGB)",2D)="white"{}
        _Glossiness("Smothness",Range(0,1))=0.5
        _Metallic("Metallic",Range(0,1))=0.0
    }
    SubShader{
        Tags{"RenderType="Opaque"}
        LOD 200

        CGPROGRAM
        //基于物理着色的光照模型,并且在所有光类型上启用阴影
        #pragma surface surf Standard fullforwardshadows
        //使用3.0着色器目标,获得更好的光照效果
        #pragma target 3.0
        sampler2D _MainTex;

        struct Input{
            float2 uv_MainTex;
        };
        half _Glossiness;
        half _Metrllic;
        fixed4 _Color;
        //为证着色器添加实例化支持,你需要在材质上检测“启用示例"

        UNITY_INSTANCING_CBUFFER_START(Props)

        UNITY_INSTANCING_CBUFFER_END

        void surf(Input in,inout SurfaceOutputStandard o){
            fixed4 c=tex2D(_MainTex,in.uv_MainTex)*_Color;
            o.Albedo=c.rgb;
            o.Metallic=_Metallic;
            o.Smoothness=_Glossiness;
            o.Alpha=c.a;
        }
        ENDCG
    }
    FallBack "Diffuse"
}

让我们看一看每一个部分并解释一下他们都做了什么。
首先,tags标签:

SubShader{
    Tags{"RenderType"="Opaque"}
    ...
}

标签帮助你告诉渲染引擎如何以及何时你的着色器被渲染。在这种情况下,我们只是指定我们的着色器是透明的,这个对于深度纹理/地图是非常有用的。

LOD 200

多细节层次或者(LOD)有助于指定再默写硬件上使用哪种着色器,LOD值越大,着色器越复杂且它的值与模型的LOD无关。

#pragma surface surf Standard fullforwardshadows

类似于我们定义顶点和片元函数,我们在这里定义了一个称之为surf的表面函数,Stadard指定Unity Shader使用标准光照模型,而fullforwardshadows指定着色器启用所有常规阴影类型。

#pragma target 3.0

这里指定编译使用的光照版本,值越大,效果越好,也越复杂,同时对系统有更高的要求。

void surf(Input i,inout SurfaceOutputStandard o){
    fixed4 c=tex2D(+mAINtEX,i.uv_MianT)*_Color;
    o.Albedo=c.rgb;

    o.Metallic=_Metallic;

    o.Smoothness=_Glossiness;
    o.Alpha=c.a;
}

这是着色器的核心部分,Unity定义了一个SurfaceOutputStandard 结构体来替代指定像素的颜色值。你可以设置一些诸如“Albedo”的属性,由于我们正在处理光照和阴影,不单单是直接获取颜色值,需要能够通过SurfaceOutputStandard保存的值来进行计算,下面是SurfaceOutputStandard的所有属性值的一部分:

struct SurfaceOutput{
    fixed3 Albedo;
    fixed3 Normal;
    fixed3 Emission;
    half Specular;
    fixed Gloss;
    fixed Alpha;
}

Okay,讨论一下关于verties把。
standard surface 默认情况下不暴露编辑vertices属性的函数,我们可以手动添加一个。首先,添加pragma并定义一个vertex函数

#pragma surface surf Standard fullforwardshadows vertex:vert

定义vert函数:

void vert(inout appdata_full v){
    v.vertex.xyz+=v.normal.xyz*_ExtrudeAmount*sin(_Time.y;
    )
}

提示:如果你在改变顶点坐标的时候,阴影没有随之改变,你需要确保添加了”addshadow“ paagma 片段声明,如下:

#pragma surface surf Standard fullforwardshadows vetex:vert addshadow

再表面着色器的内部是非常复杂的,但是,它最终会被编译成我们之前写的顶点和片元函数那样。我强烈的建议去读官方文档,以了解更多关于这方面的信息。

更多内容,欢迎关注我的公众号:


码码小虫

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!