Unity Shader编程之十五 屏幕高斯模糊(Gaussian Blur)后期特效的实现

发表于2016-07-24
评论0 3.9k浏览

想免费获取内部独家PPT资料库?观看行业大牛直播?点击加入腾讯游戏学院游戏程序行业精英群

711501594
         本系列文章由@浅墨_毛星云 出品,转载请注明出处。  
        作者:毛星云(浅墨)    微博:http://weibo.com/u/1723155442

        本文工程使用的Unity3D版本: 5.2.1 
        本篇文章将分析如何在Unity中基于Shader实现高斯模糊屏幕后期特效。
        
         首先放出最终的实现效果。如下几幅图,是在Unity中使用本文所实现的Shader得到的高斯模糊屏幕后期特效与原始图的效果对比图。
        卡通风格的效果测试:



 


写实风格的效果测试:







       OK,下面我们开始分析如何在Unity中实现上述的高斯模糊特效。

 

一、降采样与高斯模糊的原理

       首先梳理一下在Unity中实现高斯模糊效果需用到的几个图像处理的知识点,说起来也很巧,正好和之前我写过一个关于OpenCV的系列博客里的这篇文章(http://blog.csdn.net/poem_qianmo/article/details/22745559)涉及的知识点类似。

 

关于图像的降采样

      降采样(Downsample)也称下采样(Subsample),按字面意思理解即是降低采样频率。对于一幅N*M的图像来说,如果降采样系数为k,则降采样即是在原图中每行每列每隔k个点取一个点组成一幅图像的一个过程。

      不难得出,降采样系数K值越大,则需要处理的像素点越少,运行速度越快。


 高斯模糊的原理

        高斯模糊(Gaussian Blur),也叫高斯平滑,高斯滤波,其通常用它来减少图像噪声以及降低细节层次,常常也被用于对图像进行模糊。

通俗的讲,高斯模糊就是对整幅图像进行加权平均的过程,每一个像素点的值,都由其本身和邻域内的其他像素值经过加权平均后得到。高斯模糊的具体操作是:用一个模板(或称卷积、掩模)扫描图像中的每一个像素,用模板确定的邻域内像素的加权平均灰度值去替代模板中心像素点的值。

        高斯分布的数学表示如下:

其中,x为到像素中心的距离,σ为标准差。

 

 

高斯分布(正态分布曲线)


        分条来说明一下高斯模糊的几个要点:

        从数学的角度来看,图像的高斯模糊过程就是图像与正态分布做卷积。

        由于正态分布又叫作高斯分布,所以这项技术就叫作高斯模糊。

        高斯模糊能够把某一点周围的像素色值按高斯曲线统计起来,采用数学上加权平均的计算方法得到这条曲线的色值

        所谓"模糊",可以理解成每一个像素都取周边像素的平均值。

        图像与圆形方框模糊做卷积将会生成更加精确的焦外成像效果。由于高斯函数的傅立叶变换是另外一个高斯函数,所以高斯模糊对于图像来说就是一个低通滤波器。

        高斯模糊的原理大致如此。若各位还想进一步了解,可以参考高斯模糊的wiki,以及《Real-Time Rendering 3rd》,或各种图像处理的书籍。相关参考内容见附录中的reference。

        下面主要来一起看一下高斯模糊特效在Unity中的实现。


 二、高斯模糊特效在Unity中的实现

        Unity中的屏幕特效,通常分为两部分来实现:

        Shader代码实现部分

        C#/javascript代码实现部分

         上述两者结合起来,便可以在Unity中实现具有很强可控性和灵活性的屏幕后期特效。

下面即是从这两个方面对高斯模糊的特效进行实现。其实现思路类似Standard Assets/Image Effect中的Blur,但是本文的实现更简洁,有更大的可控性。

 

Shader代码部分

        本次的高斯模糊Shader包含逐行注释后约200多行。

        书写思路方面,采用了3个通道(Pass)各司其职,他们分别是:

        通道0:降采样通道。

        通道1:垂直方向模糊处理通道。

        通道2:水平方向模糊处理通道。

        而三个通道中共用的变量、函数和结构体的代码位于CGINCLUDE和ENDCG之间。

        以下贴出经过详细注释的Shader源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
Shader "Learning Unity Shader/Lecture 15/RapidBlurEffect" 
    //-----------------------------------【属性 || Properties】------------------------------------------   
    Properties 
    
        //主纹理 
        _MainTex("Base (RGB)", 2D) = "white" {} 
    
   
    //----------------------------------【子着色器 || SubShader】---------------------------------------   
    SubShader 
    
        ZWrite Off 
        Blend Off 
   
        //---------------------------------------【通道0 || Pass 0】------------------------------------ 
        //通道0:降采样通道 ||Pass 0: Down Sample Pass 
        Pass 
        
            ZTest Off 
            Cull Off 
   
            CGPROGRAM 
   
            //指定此通道的顶点着色器为vert_DownSmpl 
            #pragma vertex vert_DownSmpl 
            //指定此通道的像素着色器为frag_DownSmpl 
            #pragma fragment frag_DownSmpl 
   
            ENDCG 
   
        
   
        //---------------------------------------【通道1 || Pass 1】------------------------------------ 
        //通道1:垂直方向模糊处理通道 ||Pass 1: Vertical Pass 
        Pass 
        
            ZTest Always 
            Cull Off 
   
            CGPROGRAM 
   
            //指定此通道的顶点着色器为vert_BlurVertical 
            #pragma vertex vert_BlurVertical 
            //指定此通道的像素着色器为frag_Blur 
            #pragma fragment frag_Blur 
   
            ENDCG 
        
   
        //---------------------------------------【通道2 || Pass 2】------------------------------------ 
        //通道2:水平方向模糊处理通道 ||Pass 2: Horizontal Pass 
        Pass 
        
            ZTest Always 
            Cull Off 
   
            CGPROGRAM 
   
            //指定此通道的顶点着色器为vert_BlurHorizontal 
            #pragma vertex vert_BlurHorizontal 
            //指定此通道的像素着色器为frag_Blur 
            #pragma fragment frag_Blur 
   
            ENDCG 
        
    
   
   
    //-------------------------CG着色语言声明部分 || Begin CG Include Part----------------------   
    CGINCLUDE 
   
    //【1】头文件包含 || include 
    #include "UnityCG.cginc" 
   
    //【2】变量声明 || Variable Declaration 
    sampler2D _MainTex; 
    //UnityCG.cginc中内置的变量,纹理中的单像素尺寸|| it is the size of a texel of the texture 
    uniform half4 _MainTex_TexelSize; 
    //C#脚本控制的变量 || Parameter 
    uniform half _DownSampleValue; 
   
    //【3】顶点输入结构体 || Vertex Input Struct 
    struct VertexInput 
    
        //顶点位置坐标 
        float4 vertex : POSITION; 
        //一级纹理坐标 
        half2 texcoord : TEXCOORD0; 
    }; 
   
    //【4】降采样输出结构体 || Vertex Input Struct 
    struct VertexOutput_DownSmpl 
    
        //像素位置坐标 
        float4 pos : SV_POSITION; 
        //一级纹理坐标(右上) 
        half2 uv20 : TEXCOORD0; 
        //二级纹理坐标(左下) 
        half2 uv21 : TEXCOORD1; 
        //三级纹理坐标(右下) 
        half2 uv22 : TEXCOORD2; 
        //四级纹理坐标(左上) 
        half2 uv23 : TEXCOORD3; 
    }; 
   
   
    //【5】准备高斯模糊权重矩阵参数7x4的矩阵 ||  Gauss Weight 
    static const half4 GaussWeight[7] = 
    
        half4(0.0205,0.0205,0.0205,0), 
        half4(0.0855,0.0855,0.0855,0), 
        half4(0.232,0.232,0.232,0), 
        half4(0.324,0.324,0.324,1), 
        half4(0.232,0.232,0.232,0), 
        half4(0.0855,0.0855,0.0855,0), 
        half4(0.0205,0.0205,0.0205,0) 
    }; 
   
   
    //【6】顶点着色函数 || Vertex Shader Function 
    VertexOutput_DownSmpl vert_DownSmpl(VertexInput v) 
    
        //【6.1】实例化一个降采样输出结构 
        VertexOutput_DownSmpl o; 
   
        //【6.2】填充输出结构 
        //将三维空间中的坐标投影到二维窗口   
        o.pos = mul(UNITY_MATRIX_MVP, v.vertex); 
        //对图像的降采样:取像素上下左右周围的点,分别存于四级纹理坐标中 
        o.uv20 = v.texcoord + _MainTex_TexelSize.xy* half2(0.5h, 0.5h);; 
        o.uv21 = v.texcoord + _MainTex_TexelSize.xy * half2(-0.5h, -0.5h); 
        o.uv22 = v.texcoord + _MainTex_TexelSize.xy * half2(0.5h, -0.5h); 
        o.uv23 = v.texcoord + _MainTex_TexelSize.xy * half2(-0.5h, 0.5h); 
   
        //【6.3】返回最终的输出结果 
        return o; 
    
   
    //【7】片段着色函数 || Fragment Shader Function 
    fixed4 frag_DownSmpl(VertexOutput_DownSmpl i) : SV_Target 
    
        //【7.1】定义一个临时的颜色值 
        fixed4 color = (0,0,0,0); 
   
        //【7.2】四个相邻像素点处的纹理值相加 
        color += tex2D(_MainTex, i.uv20); 
        color += tex2D(_MainTex, i.uv21); 
        color += tex2D(_MainTex, i.uv22); 
        color += tex2D(_MainTex, i.uv23); 
   
        //【7.3】返回最终的平均值 
        return color / 4; 
    
   
    //【8】顶点输入结构体 || Vertex Input Struct 
    struct VertexOutput_Blur 
    
        //像素坐标 
        float4 pos : SV_POSITION; 
        //一级纹理(纹理坐标) 
        half4 uv : TEXCOORD0; 
        //二级纹理(偏移量) 
        half2 offset : TEXCOORD1; 
    }; 
   
    //【9】顶点着色函数 || Vertex Shader Function 
    VertexOutput_Blur vert_BlurHorizontal(VertexInput v) 
    
        //【9.1】实例化一个输出结构 
        VertexOutput_Blur o; 
   
        //【9.2】填充输出结构 
        //将三维空间中的坐标投影到二维窗口   
        o.pos = mul(UNITY_MATRIX_MVP, v.vertex); 
        //纹理坐标 
        o.uv = half4(v.texcoord.xy, 1, 1); 
        //计算X方向的偏移量 
        o.offset = _MainTex_TexelSize.xy * half2(1.0, 0.0) * _DownSampleValue; 
   
        //【9.3】返回最终的输出结果 
        return o; 
    
   
    //【10】顶点着色函数 || Vertex Shader Function 
    VertexOutput_Blur vert_BlurVertical(VertexInput v) 
    
        //【10.1】实例化一个输出结构 
        VertexOutput_Blur o; 
   
        //【10.2】填充输出结构 
        //将三维空间中的坐标投影到二维窗口   
        o.pos = mul(UNITY_MATRIX_MVP, v.vertex); 
        //纹理坐标 
        o.uv = half4(v.texcoord.xy, 1, 1); 
        //计算Y方向的偏移量 
        o.offset = _MainTex_TexelSize.xy * half2(0.0, 1.0) * _DownSampleValue; 
   
        //【10.3】返回最终的输出结果 
        return o; 
    
   
    //【11】片段着色函数 || Fragment Shader Function 
    half4 frag_Blur(VertexOutput_Blur i) : SV_Target 
    
        //【11.1】获取原始的uv坐标 
        half2 uv = i.uv.xy; 
   
        //【11.2】获取偏移量 
        half2 OffsetWidth = i.offset; 
        //从中心点偏移3个间隔,从最左或最上开始加权累加 
        half2 uv_withOffset = uv - OffsetWidth * 3.0; 
   
        //【11.3】循环获取加权后的颜色值 
        half4 color = 0; 
        for (int j = 0; j< 7; j++) 
        
            //偏移后的像素纹理值 
            half4 texCol = tex2D(_MainTex, uv_withOffset); 
            //待输出颜色值+=偏移后的像素纹理值 x 高斯权重 
            color += texCol * GaussWeight[j]; 
            //移到下一个像素处,准备下一次循环加权 
            uv_withOffset += OffsetWidth; 
        
   
        //【11.4】返回最终的颜色值 
        return color; 
    
   
    //-------------------结束CG着色语言声明部分  || End CG Programming Part------------------              
    ENDCG 
   
    FallBack Off 


 C#代码部分

        C#脚本文件的代码可以从我们之前的几篇分析屏幕特效实现的文章中重用(如这篇实现屏幕油画特效的文章:http://blog.csdn.net/poem_qianmo/article/details/49719247),只用稍微改一点细节即可。 

贴出详细注释的配合Shader实现此特效的C#脚本:

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
using UnityEngine; 
using System.Collections; 
   
//设置在编辑模式下也执行该脚本 
[ExecuteInEditMode] 
//添加选项到菜单中 
[AddComponentMenu("Learning Unity Shader/Lecture 15/RapidBlurEffect")] 
public class RapidBlurEffect : MonoBehaviour 
    //-------------------变量声明部分------------------- 
    #region Variables 
       
    //指定Shader名称 
    private string ShaderName = "Learning Unity Shader/Lecture 15/RapidBlurEffect"
   
    //着色器和材质实例 
    public Shader CurShader; 
    private Material CurMaterial; 
   
    //几个用于调节参数的中间变量 
    public static int ChangeValue; 
    public static float ChangeValue2; 
    public static int ChangeValue3; 
   
    //降采样次数 
    [Range(0, 6), Tooltip("[降采样次数]向下采样的次数。此值越大,则采样间隔越大,需要处理的像素点越少,运行速度越快。")] 
    public int DownSampleNum = 2; 
    //模糊扩散度 
    [Range(0.0f, 20.0f), Tooltip("[模糊扩散度]进行高斯模糊时,相邻像素点的间隔。此值越大相邻像素间隔越远,图像越模糊。但过大的值会导致失真。")] 
    public float BlurSpreadSize = 3.0f; 
    //迭代次数 
    [Range(0, 8), Tooltip("[迭代次数]此值越大,则模糊操作的迭代次数越多,模糊效果越好,但消耗越大。")] 
    public int BlurIterations = 3; 
   
    #endregion 
   
    //-------------------------材质的get&set---------------------------- 
    #region MaterialGetAndSet 
    Material material 
    
        get 
        
            if (CurMaterial == null
            
                CurMaterial = new Material(CurShader); 
                CurMaterial.hideFlags = HideFlags.HideAndDontSave; 
            
            return CurMaterial; 
        
    
    #endregion 
   
    #region Functions 
    //-----------------------------------------【Start()函数】---------------------------------------------   
    // 说明:此函数仅在Update函数第一次被调用前被调用 
    //-------------------------------------------------------------------------------------------------------- 
    void Start() 
    
        //依次赋值 
        ChangeValue = DownSampleNum; 
        ChangeValue2 = BlurSpreadSize; 
        ChangeValue3 = BlurIterations; 
   
        //找到当前的Shader文件 
        CurShader = Shader.Find(ShaderName); 
   
        //判断当前设备是否支持屏幕特效 
        if (!SystemInfo.supportsImageEffects) 
        
            enabled = false
            return
        
    
   
    //-------------------------------------【OnRenderImage()函数】------------------------------------   
    // 说明:此函数在当完成所有渲染图片后被调用,用来渲染图片后期效果 
    //-------------------------------------------------------------------------------------------------------- 
    void OnRenderImage(RenderTexture sourceTexture, RenderTexture destTexture) 
    
        //着色器实例不为空,就进行参数设置 
        if (CurShader != null
        
            //【0】参数准备 
            //根据向下采样的次数确定宽度系数。用于控制降采样后相邻像素的间隔 
            float widthMod = 1.0f / (1.0f * (1 << DownSampleNum)); 
            //Shader的降采样参数赋值 
            material.SetFloat("_DownSampleValue", BlurSpreadSize * widthMod); 
            //设置渲染模式:双线性 
            sourceTexture.filterMode = FilterMode.Bilinear; 
            //通过右移,准备长、宽参数值 
            int renderWidth = sourceTexture.width >> DownSampleNum; 
            int renderHeight = sourceTexture.height >> DownSampleNum; 
   
            // 【1】处理Shader的通道0,用于降采样 ||Pass 0,for down sample 
            //准备一个缓存renderBuffer,用于准备存放最终数据 
            RenderTexture renderBuffer = RenderTexture.GetTemporary(renderWidth, renderHeight, 0, sourceTexture.format); 
            //设置渲染模式:双线性 
            renderBuffer.filterMode = FilterMode.Bilinear; 
            //拷贝sourceTexture中的渲染数据到renderBuffer,并仅绘制指定的pass0的纹理数据 
            Graphics.Blit(sourceTexture, renderBuffer, material, 0); 
   
            //【2】根据BlurIterations(迭代次数),来进行指定次数的迭代操作 
            for (int i = 0; i < BlurIterations; i++) 
            
                //【2.1】Shader参数赋值 
                //迭代偏移量参数 
                float iterationOffs = (i * 1.0f); 
                //Shader的降采样参数赋值 
                material.SetFloat("_DownSampleValue", BlurSpreadSize * widthMod + iterationOffs); 
   
                // 【2.2】处理Shader的通道1,垂直方向模糊处理 || Pass1,for vertical blur 
                // 定义一个临时渲染的缓存tempBuffer 
                RenderTexture tempBuffer = RenderTexture.GetTemporary(renderWidth, renderHeight, 0, sourceTexture.format); 
                // 拷贝renderBuffer中的渲染数据到tempBuffer,并仅绘制指定的pass1的纹理数据 
                Graphics.Blit(renderBuffer, tempBuffer, material, 1); 
                //  清空renderBuffer 
                RenderTexture.ReleaseTemporary(renderBuffer); 
                // 将tempBuffer赋给renderBuffer,此时renderBuffer里面pass0和pass1的数据已经准备好 
                 renderBuffer = tempBuffer; 
   
                // 【2.3】处理Shader的通道2,竖直方向模糊处理 || Pass2,for horizontal blur 
                // 获取临时渲染纹理 
                tempBuffer = RenderTexture.GetTemporary(renderWidth, renderHeight, 0, sourceTexture.format); 
                // 拷贝renderBuffer中的渲染数据到tempBuffer,并仅绘制指定的pass2的纹理数据 
                Graphics.Blit(renderBuffer, tempBuffer, CurMaterial, 2); 
   
                //【2.4】得到pass0、pass1和pass2的数据都已经准备好的renderBuffer 
                // 再次清空renderBuffer 
                RenderTexture.ReleaseTemporary(renderBuffer); 
                // 再次将tempBuffer赋给renderBuffer,此时renderBuffer里面pass0、pass1和pass2的数据都已经准备好 
                renderBuffer = tempBuffer; 
            
   
            //拷贝最终的renderBuffer到目标纹理,并绘制所有通道的纹理到屏幕 
            Graphics.Blit(renderBuffer, destTexture); 
            //清空renderBuffer 
            RenderTexture.ReleaseTemporary(renderBuffer); 
   
        
   
        //着色器实例为空,直接拷贝屏幕上的效果。此情况下是没有实现屏幕特效的 
        else 
        
            //直接拷贝源纹理到目标渲染纹理 
            Graphics.Blit(sourceTexture, destTexture); 
        
    
   
   
    //-----------------------------------------【OnValidate()函数】--------------------------------------   
    // 说明:此函数在编辑器中该脚本的某个值发生了改变后被调用 
    //-------------------------------------------------------------------------------------------------------- 
    void OnValidate() 
    
        //将编辑器中的值赋值回来,确保在编辑器中值的改变立刻让结果生效 
        ChangeValue = DownSampleNum; 
        ChangeValue2 = BlurSpreadSize; 
        ChangeValue3 = BlurIterations; 
    
   
    //-----------------------------------------【Update()函数】--------------------------------------   
    // 说明:此函数每帧都会被调用 
    //-------------------------------------------------------------------------------------------------------- 
    void Update() 
    
        //若程序在运行,进行赋值 
        if (Application.isPlaying) 
        
            //赋值 
            DownSampleNum = ChangeValue; 
            BlurSpreadSize = ChangeValue2; 
            BlurIterations = ChangeValue3; 
        
        //若程序没有在运行,去寻找对应的Shader文件 
#if UNITY_EDITOR 
        if (Application.isPlaying != true
        
            CurShader = Shader.Find(ShaderName); 
        
#endif 
   
    
   
    //-----------------------------------------【OnDisable()函数】---------------------------------------   
    // 说明:当对象变为不可用或非激活状态时此函数便被调用   
    //-------------------------------------------------------------------------------------------------------- 
    void OnDisable() 
    
        if (CurMaterial) 
        
            //立即销毁材质实例 
            DestroyImmediate(CurMaterial); 
        
   
    
   
 #endregion 
   
         将此C#代码拖拽到场景的主摄像机之上, 且你的工程中也存在2.1节中贴出的Shader代码,那么就可以在Game窗口中看到经过了屏幕模糊特效的处理后的镜头效果。
          而Inspector中可得到如下所示的脚本选项。

 

           其中,有3个选项可以调节,他们分别是:
           [Down Sample Num] – 降采样的次数。此值越大,则采样间隔越大,需要处理的像素点越少,运行速度越快。
           [Blur Speread Size] -模糊扩散度。进行高斯模糊时,相邻像素点的间隔。此值越大相邻像素间隔越远,图像越模糊。但过大的值会导致失真。
          [Blur Iterations] -迭代次数。此值越大,则模糊操作的迭代次数越多,模糊效果越好,但消耗越大。
         调节这三个参数,便可以在场景中定制出自己需要的模糊特效。
 
 推荐几组参数设置
        这边推荐几组效果出色较为出色的参数预设,方便有需要的朋友定制出适合自己的效果。

 





 





三、最终实现的效果图示

       Low Poly风格的效果测试


 

 





卡通风格效果测试


 


 



 

 




附1、本文配套源码下载链接

  【Github】本文Shader源码

 

附2、Reference

 [1] https://en.wikipedia.org/wiki/Gaussian_blur

[2] http://www.cnblogs.com/foxianmo/p/4931507.html

[3]《Real-Time Rendering 3rd》,p467-p473.

    看了上面的文章 热爱游戏创作的你是不是已经开始热血沸腾了呢?是不是迫不及待的想加入游戏团队成为里面的一员呢?

        福利来啦~赶快加入腾讯GAD交流群,人满封群!每天分享游戏开发内部干货、教学视频、福利活动、和有相同梦想的人在一起,更有腾讯游戏专家手把手教你做游戏!
腾讯GAD游戏程序交流群:484290331

原文链接

著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引

游戏学院公众号二维码
腾讯游戏学院
微信公众号

提供更专业的游戏知识学习平台