Shader笔记十六 利用深度纹理实现更多特效

利用深度纹理实现运动模糊
之前实现的运动模糊是通过混合多张屏幕图像来模拟运动模糊。另一种实现运动模糊的方式是通过速度映射图,而且该方式应用更加广泛。速度映射图中存储每个像素的速度,使用该速度决定模糊的方向和大小。生成速度缓冲可以将场景中所有物体的速度渲染到一张纹理中,不过该方法需要修改场景中所有物体的Shader代码,使其添加计算速度的代码并输出到下一个渲染纹理中。
有一种方法是通过深度纹理在片元着色器中为每个像素计算其在世界空间下的位置,该过程是通过当前视角x投影矩阵的逆矩阵对NDC下的顶点坐标进行变换得到。再将该位置与上一帧的视角x投影矩阵运算,得到该位置在上一帧的投影空间中的位置,计算上一帧该位置和当前帧的位置差,生成该像素的速度。这种方法的优点在于在一个屏幕后处理特效中就能完成整个效果模拟,缺点是在片元着色器中需要进行两次矩阵运算,消耗部分性能。

完整代码:

public class Chapter13_MotionBlurWithDepthTexture : PostEffectsBase
{
public Shader motionShader;
private Material motionBlurMaterial = null;

public Material material
{
    get
    {
        motionBlurMaterial = CheckShaderAndCreateMaterial(motionShader, motionBlurMaterial);
        return motionBlurMaterial;
    }
}

[Range(0.0f, 1.0f)]
public float blurSize = 1.0f;
//定义Camera类型变量,以获取该脚本所在的摄像机组件
//得到摄像机位置,构建观察空间变换矩阵
private Camera myCamera;
public Camera camera
{
    get
    {
        if (myCamera == null)
        {
            myCamera = GetComponent<Camera>();
        }      
        return myCamera;
    }
}

//定义一个保存上一帧视角*投影矩阵
private Matrix4x4 previousViewProjectionMatrix;

//定义摄像机状态,获取深度纹理
void OnEable()
{
    camera.depthTextureMode |=DepthTextureMode.Depth;
}

void OnRenderImage(RenderTexture src,RenderTexture dest)
{
    if (material != null)
    {
        material.SetFloat("_BlurSize", blurSize);

        material.SetMatrix("_PreviousViewProjectionMatrix", previousViewProjectionMatrix);
        Matrix4x4 currentViewProjectionMatrix = camera.projectionMatrix*camera.worldToCameraMatrix;
        Matrix4x4 currentViewProjectionInverseMatrix = currentViewProjectionMatrix.inverse;
        material.SetMatrix("_CurrentViewProjectionInverseMatrix", currentViewProjectionInverseMatrix);
        previousViewProjectionMatrix = currentViewProjectionMatrix;

        Graphics.Blit(src, dest, material);
    }
    else
    {
        Graphics.Blit(src,dest);
    }
}
}

Shader代码:

Shader "Custom/Chapter13_MotionBlurWithDepthTexture" {
Properties{
	_MainTex("Maintex",2D)="white"{}
	_BlurSize("BlurSize",Float)=1.0
}
SubShader{
	CGINCLUDE
		
		#include "UnityCG.cginc"
		sampler2D _MainTex;
		half4 _MainTex_TexelSize;
		sampler2D _CameraDepthTexture;
		float4x4 _PreviousViewProjectionMatrix;
		float4x4 _CurrentViewProjectionInverseMatrix;
		half _BlurSize;

		struct v2f{
			float4 pos:POSITION;
			half2 uv:TEXCOORD0;
			half2 uv_depth:TEXCOORD1;
		};


		v2f vert(appdata_img v){
			v2f o;
			o.pos=UnityObjectToClipPos(v.vertex);
			o.uv=v.texcoord;
			o.uv_depth=v.texcoord;
			#if UNITY_UV_STARTS_AT_TOP
			if(_MainTex_TexelSize.y<0)
				o.uv_depth.y=1-o.uv_depth.y;
			#endif

			return o;
		}

		fixed4 frag(v2f i):SV_Target{
			//得到深度缓冲中该像素点的深度值
			float d=SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture,i.uv_depth);
			//得到深度纹理映射前的深度值
			float4 H=float4(i.uv.x*2-1,i.uv.y*2-1,d*2-1,1);
			//通过转换矩阵得到顶点的世界空间的坐标值
			float4 D=mul(_CurrentViewProjectionInverseMatrix,H);
			float4 worldPos=D/D.w;

			float4 currentPos=H;
			float4 previousPos=mul(_PreviousViewProjectionMatrix,worldPos);
			previousPos/=previousPos.w;

			float2 velocity=(currentPos.xy-previousPos.xy)/2.0f;

			float2 uv=i.uv;
			float4 c=tex2D(_MainTex,uv);
			//得到像素速度后,对邻域像素进行采样,并使用BlurSize控制采样间隔
			//得到的像素点进行平均后,得到模糊效果
			uv+=velocity*_BlurSize;
			for(int it=1;it<3;it++,uv+=velocity*_BlurSize){
				float4 currentColor=tex2D(_MainTex,uv);
				c+=currentColor;
			}
			c/=3;
			return fixed4(c.rgb,1.0);
		}
	ENDCG

	Pass{
		ZTest Always 
		Cull Off
		ZWrite Off

		CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
		ENDCG
	}
}

FallBack Off
}

实例效果:

可以看到,这里的模糊效果是整个场景图像都有模糊效果,而之前采用帧缓冲图像进行混合所产生的模糊效果在某一方向上是模糊效果,并不是整个场景的图像都有模糊效果。但是如果在一个物体运动,而摄像机静止的场景中不会产生模糊效果,这是由于整个速度计算依赖于摄像机的视角变化。而上一节中实现的运动模糊,只要摄像机视野中物体发生了相对运动都可以产生模糊效果。

全局雾效
雾效在游戏中是一种常见的特效。比如吃鸡里就有大雾天,Unity内置的雾效可以生成基于距离的线性或指数雾效。如果在顶点/片元着色器实现这样的雾效,需要在Shader中添加#pragma multi _compile _fog指令,同时需要使用相关内置宏,比如 UNITY _FOG _COORDS, UNITY _TRANSFER _FOG, UNITY _APPLY _FOG等。使用这种方法,需要为场景中所有物体添加渲染代码,操作比较繁琐。
使用基于屏幕后处理的全局雾效,不需要为场景中所有的物体添加渲染代码,仅通过屏幕后处理就可以实现,而且可以模拟均匀雾效、基于距离的线性/指数雾效,基于高度的雾效。
基于屏幕后处理的全局雾效关键在于通过深度纹理重建每个像素在世界空间的位置 ,在上一节中,通过深度纹理的采样,反映射得到NDC坐标,再通过视角x投影坐标的逆矩阵运算得到世界空间下的位置,这样的实现需要在片元着色器中进行矩阵运算。
有一种快速从深度纹理中重建世界坐标的方法。该方法首先对图像空间下的视椎体射线(从摄像机出发,指向图像上的某点)进行插值,这条射线记录了该像素点在世界空间下到摄像机的方向信息。将该射线和线性化后的视角空间下的深度值相乘,加上摄像机的世界位置,得到世界空间下的像素点的位置。得到该位置后,可以使用公式来模拟全局雾效。

从深度纹理中计算世界坐标
像素的世界坐标是通过在世界空间下像素相对于摄像机的偏移量+世界空间下摄像机的位置得到,用代码表示:

float4 worldPos=_WorldSpaceCameraPos+linearDepth*interpolatedRay;
//_WorldSpaceCameraPos:摄像机世界空间位置坐标,由Unity内置变量即可得到
//linearDepth:由深度纹理得到的线性深度值
//interpolatedRay:由定点着色器输出插值得到的射线,包含了像素到摄像机的方向和距离信息      

interpolatedRay计算过程:
interpolatedRay是对摄像机的近裁剪平面的四个顶点的特定向量的插值。先来计算这四个顶点的特定向量,包含了顶点到摄像机的距离和方向信息。计算过程中用到的图:

先计算toTop和toRight向量:

halfHeight=Near*tan(FOV/2)       
//Near:近裁剪平面距离      FOV:视椎体竖直方向的张角   

toTop=camera.up x halfHeight
toRight=camera.right x halfHeight*aspect     
//aspect:横纵比    

再得到TL、TR、BL、BR四个向量的值:

TL=camera.forward*Near+toTop-toRight;
TR=camera.forward*Near+toTop+toRight;
BL=camera.forward*Near-toTop-toRight;
BR=camera.forward*Near-toTop+toRight;   

以上四个向量得到了关于近裁剪平面四个顶点到摄像机的方向和距离信息,由于采样得到的深度值z是相对与摄像机的垂直距离,因此还不能直接使用上述四个向量的单位向量与深度值相乘来得到该方向上具有深度值的距离,因此需要计算出具有深度值的某点到摄像机的直线距离,以TL点为例,由相似原理:

depth/dist=Near/|TL|  
dist=depth*(|TL|/Near)    

由于四点对称,其他三个点都可以使用同一个因子与该方向的单位向量相乘得到能与对应深度值直接相乘的向量

scale=|TL|/Near  
Ray_TL=TL/|TL|*scale
Ray_TR=TR/|TR|*scale
Ray_BL=BL/|BL|*scale
Ray_BR=BR/|BR|*scale   

这样得到可以直接与深度值相乘前的特定向量。屏幕后处理使用特定材质渲染一个刚好填充整个屏幕的四边形面片。面片的4个顶点对应近裁剪平面的4个角。将上面的计算结果传递给顶点着色器,再由顶点着色器选择对应的向量,输出传递给片元着色器就得到了经过插值的interpolatedRay,然后计算像素点在世界空间的位置。

雾的计算
简单雾效的计算通过混合因子将雾的颜色与原始颜色混合。

float3 afterFog=f*fogColor+(1-f)*originalColor   

混合因子f有线性、指数和指数平方,给定距离为z:

//线性 
f=(Dmax-|z|)/(Dmax-Dmin)       //Dmax与Dmin为受影响的最大和最小距离 

//指数 
f=e^-(D*|z|)             //D为控制雾浓度参数

//指数平方   
f=e^-(D*|z|)^2       //D为控制雾效浓度参数        

可以采用线性雾效计算方式,计算基于高度的雾效:

f=(H_end-y)/(H_end-H_start)      //H_end和H_start为高度起始位置      

实例代码:

public class Chapter13_FogWithDepthTexture :PostEffectsBase
{

public Shader fogWithDepthShader;
private Material fogMaterial;

public Material material
{
    get
    {
        fogMaterial = CheckShaderAndCreateMaterial(fogWithDepthShader, fogMaterial);
        return fogMaterial;
    }
}

private Camera myCamera;
public Camera camera
{
    get
    {
        if (myCamera == null)
            myCamera = GetComponent<Camera>();
        return myCamera;
    }
}

private Transform myTransform;

public Transform cameraTransform
{
    get
    {
        if (myTransform == null)
            myTransform = camera.transform;
        return myTransform;
    }
}

//定义模拟雾效的参数
[Range(0.0f, 3.0f)]
public float fogDensity = 1.0f;

public Color fogColor = Color.white;
public float fogStart = 0.0f;
public float fogEnd = 2.0f;

void OnEnable()
{
    camera.depthTextureMode |=DepthTextureMode.Depth;
}

void OnRenderImage(RenderTexture src,RenderTexture dest)
{
    if (material != null)
    {
        Matrix4x4 frustumCorners = Matrix4x4.identity;

        float fov = camera.fieldOfView;
        float near = camera.nearClipPlane;
        float far = camera.farClipPlane;
        float aspect = camera.aspect;

        float halfHeight = near*Mathf.Tan(fov*0.5f*Mathf.Deg2Rad);
        Vector3 toTop = cameraTransform.up*halfHeight;
        Vector3 toRight = cameraTransform.right*halfHeight*aspect;

        Vector3 topLeft = cameraTransform.forward*near + toTop - toRight;
        float scale = topLeft.magnitude/near;
        topLeft.Normalize();
        topLeft *= scale;

        Vector3 topRight = cameraTransform.forward*near + toTop + toRight;
        topRight.Normalize();
        topRight *= scale;

        Vector3 bottomLeft = cameraTransform.forward*near - toTop - toRight;
        bottomLeft.Normalize();
        bottomLeft *= scale;

        Vector3 bottomRight = cameraTransform.forward*near - toTop + toRight;
        bottomRight.Normalize();
        bottomRight *= scale;

        frustumCorners.SetRow(0, bottomLeft);
        frustumCorners.SetRow(1, bottomRight);
        frustumCorners.SetRow(2, topRight);
        frustumCorners.SetRow(3, topLeft);

        material.SetMatrix("_FrustumCornerRay", frustumCorners);
      
        material.SetFloat("_FogDensity", fogDensity);
        material.SetColor("_FogColor", fogColor);
        material.SetFloat("_FogStart", fogStart);
        material.SetFloat("_FogEnd", fogEnd);

        Graphics.Blit(src, dest, material);
    }
    else
    {
        Graphics.Blit(src,dest);
    }
}
}

Shader代码:

Shader "Custom/Chapter13_FogWithDepthTexture" {
Properties{
	_MainTex("MainTex",2D)="white"{}
	_FogDensity("Fog Density",Float)=1.0
	_FogColor("FogColor",Color)=(1,1,1,1)
	_FogStart("Fog Start",Float)=0.0
	_FogEnd("FogEnd",Float)=2.0
}
SubShader{
	CGINCLUDE
		#include "UnityCG.cginc"

		sampler2D _MainTex;
		half4 _MainTex_TexelSize;
		sampler2D _CameraDepthTexture;
		half _FogDensity;
		fixed4 _FogColor;
		float _FogStart;
		float _FogEnd;  

		float4x4 _FrustumCornerRay;

		struct v2f{
			float4 pos:SV_POSITION;
			half2 uv:TEXCOORD0;
			half2 uv_depth:TEXCOORD1;
			float4 interpolatedRay:TEXCOORD2;
		};

		v2f vert(appdata_img v){
			v2f o;
			o.pos=UnityObjectToClipPos(v.vertex);
			o.uv=v.texcoord;
			o.uv_depth=v.texcoord;   
			#if UNITY_UV_STARTS_AT_TOP
			if(_MainTex_TexelSize.y<0)
				o.uv_depth.y=1-o.uv_depth.y;
			#endif

			int index=0;
			if(v.texcoord.x<0.5&&v.texcoord.y<0.5){
				index=0;
			}
			else if(v.texcoord.x>0.5&&v.texcoord.y<0.5){
				index=1;
			}
			if(v.texcoord.x>0.5&&v.texcoord.y>0.5){
				index=2;
			}
			else{
				index=3;
			}
			#if UNITY_UV_STARTS_AT_TOP
			if(_MainTex_TexelSize.y<0){
				index=3-index;
			}
			#endif
			o.interpolatedRay=_FrustumCornerRay[index];
			return o;
		}

		fixed4 frag(v2f i):SV_Target{
			float linearDepth=LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture,i.uv_depth));
			float3 worldPos=_WorldSpaceCameraPos+linearDepth*i.interpolatedRay.xyz;

			float fogDensity=(_FogEnd-worldPos.y)/(_FogEnd-_FogStart);
			fogDensity=saturate(fogDensity*_FogDensity);

			fixed4 finalColor=tex2D(_MainTex,i.uv);
			finalColor.rgb=lerp(finalColor.rgb,_FogColor.rgb,fogDensity);
			return finalColor;
		}
	ENDCG

	Pass{
		ZTest Always ZWrite Off Cull Off
		
		CGPROGRAM
		#pragma vertex vert
		#pragma fragment frag
		ENDCG
	}
}
FallBack Off
}

实例效果:

参数设置:

实例效果中可以看到,在没有游戏对象的区域是不会产生雾效的,这是因为计算的基础是深度纹理,如果场景中为空,那么不会产生深度值,所以不会有雾效效果。
关于最后的效果这里其实还有一点值得讨论,从截图中可以看到,距离摄像机较远的物体在同一高度下相较于离摄像机较近的物体实际上雾效效果会更浅,再来看代码中,这里的 worldPos 的计算:

worldPos=_WorldSpaceCameraPos+linearDepth*i.interpolatedRay.xyz;      

是将屏幕图像的像素点分成四个部分,将该像素点的线性深度值与对应区域的顶点的世界坐标相乘,因此同一高度下,深度值更大的像素点那么计算得到的worldPos.y的值会更大,颜色混合因子的结果也会更倾向于屏幕图像的原来颜色,因此雾效效果也就更加浅。

利用深度纹理实现边缘检测
前面的章节中通过边缘检测算子(Sobel算子)实现了对屏幕图像进行边缘检测,进行描边效果。但是当产生的屏幕图像中的物体具有色彩丰富的纹理和阴影时,这种基于颜色的描边效果会使纹理和阴影也出现描边效果,比如

这样物体的纹理和阴影也会被描上黑边,给人一种很脏的感觉。由于深度纹理仅仅保存了当前渲染物体的模型信息,如果在深度纹理上进行描边效果则会更加可靠和干净。
之前使用的是Sobel算子进行边缘检测,下面要实现的效果采用Roberts算子,该算子的卷积核:

该算子计算对角的差值,然后再将结果相乘来作为判断是否存在边界的判定依据。实现过程中也采用该判断方式。
代码实例:

public class Chapter13_EdgeDetectNormalsAndDepth : PostEffectsBase
{
public Shader edgeDetectShader;
private Material edgeDetectMaterial;

public Material material
{
    get
    {
        edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
        return edgeDetectMaterial;
    }
}

[Range(0.0f, 1.0f)]
public float edgeOnly = 0.0f;

public Color edgeColor = Color.black;
public Color backgroundColor = Color.white;

//用于控制深度和法线纹理的采样距离,值越大,描边越宽
public float sampleDistance = 1.0f;
//灵敏值,影响领域的深度值或法线值相差多少时,会被认为存在一条边界
public float sensitivityDepth = 1.0f;
public float sensitivityNormals = 1.0f;

void OnEnable()
{
    GetComponent<Camera>().depthTextureMode |=DepthTextureMode.DepthNormals;
}

//默认情况下,OnRenderImage()会在所有的不透明和透明Pass执行完成后调用,以便对所有物体都产生影响
//当希望在不透明物体的Pass完成后立即调用,不对透明物体产生影响,可以添加[ImageEffectOpaque]特性实现
[ImageEffectOpaque]
void OnRenderImage(RenderTexture src,RenderTexture dest)
{
    if (material != null)
    {
        material.SetFloat("_EdgeOnly",edgeOnly);
        material.SetColor("_EdgeColor",edgeColor);
        material.SetColor("_BackgroundColor",backgroundColor);
        material.SetFloat("_SampleDistance",sampleDistance);
        material.SetVector("_Sensitivity",new Vector4(sensitivityNormals,sensitivityDepth,0.0f,0.0f));

        Graphics.Blit(src,dest,material);
    }
    else
    {
        Graphics.Blit(src,dest);
    }
}
}       

Shader代码:

Shader "Custom/Chapter13_EdgeDetectWithNormalsAndDepth" {
Properties{
	_MainTex("MainTex",2D)="white"{}
	_EdgeOnly("EdgeOnly",Float)=1.0
	_EdgeColor("EdgeColor",Color)=(0,0,0,1)
	_BackgroundColor("BackgroundColor",Color)=(1,1,1,1)
	_SampleDistance("SampleDistance",Float)=1.0
	_Sensitivity("Sensitivity",Vector)=(1,1,1,1)
}
SubShader{
	CGINCLUDE
	#include "UnityCG.cginc"
	
	sampler2D _MainTex;
	half4 _MainTex_TexelSize;
	fixed _EdgeOnly;
	fixed4 _EdgeColor;
	fixed4 _BackgroundColor;
	float _SampleDistance;
	half4 _Sensitivity;
	sampler2D _CameraDepthNormalsTexture;
	
	struct v2f{
		float4 pos:SV_POSITION;
		half2 uv[5]:TEXCOORD0;
	};		

	v2f vert(appdata_img v){
		v2f o;
		o.pos=UnityObjectToClipPos(v.vertex);
		half2 uv=v.texcoord;
		o.uv[0]=uv;

		#if UNITY_UV_STARTS_AT_TOP	
		if(_MainTex_TexelSize.y<0)
		uv.y=1-uv.y;
		#endif

		o.uv[1]=uv+_MainTex_TexelSize.xy*half2(1,1)*_SampleDistance;
		o.uv[2]=uv+_MainTex_TexelSize.xy*half2(-1,-1)*_SampleDistance;
		o.uv[3]=uv+_MainTex_TexelSize.xy*half2(-1,1)*_SampleDistance;
		o.uv[4]=uv+_MainTex_TexelSize.xy*half2(1,-1)*_SampleDistance;

		return o;
	}

	//检测给定的采样点之间是否存在一条分界线
	half CheckSame(half4 center,half4 sample){
		half2 centerNormal=center.xy;
		float centerDepth=DecodeFloatRG(center.zw);
		half2 sampleNormal=sample.xy;
		float sampleDepth=DecodeFloatRG(sample.zw);

		//检测两者法线值的差异,如果两者法线值足够接近,那么说明不存在分界线
		//法线值并没有进行解码,因为只需要知道两者的差异,不需要准确的解码值
		half2 diffNormal=abs(centerNormal-sampleNormal)*_Sensitivity.x;
		int isSameNormal=(diffNormal.x+diffNormal.y)<0.1;  

		//检测两者深度值值的差异,如果两者深度值足够接近,那么说明不存在分界线
		float diffDepth=abs(centerDepth-sampleDepth)*_Sensitivity.y;
		int isSameDepth=diffDepth<0.1*centerDepth;

		//只有两者的法线和深度值差异均在阈值范围内,才可以看做是不存在分界线
		return isSameNormal*isSameDepth?1.0:0.0;
	}

	fixed4 fragRobertsCrossDepthAndNormal(v2f i):SV_Target{
		//根据Roberts算子对深度法线图对应像素的周围像素采样
		half4 sample1=tex2D(_CameraDepthNormalsTexture,i.uv[1]);
		half4 sample2=tex2D(_CameraDepthNormalsTexture,i.uv[2]);
		half4 sample3=tex2D(_CameraDepthNormalsTexture,i.uv[3]);
		half4 sample4=tex2D(_CameraDepthNormalsTexture,i.uv[4]);

		half edge=1.0;
		edge*=CheckSame(sample1,sample2);
		edge*=CheckSame(sample3,sample4);

		//通过计算得到edge,设置非边界像素的颜色为原色还是背景色的着色方案
		fixed4 withEdgeColor=lerp(_EdgeColor,tex2D(_MainTex,i.uv[0]),edge);
		fixed4 withBackgroundColor=lerp(_EdgeColor,_BackgroundColor,edge);
		//使用_EdgeOnly控制非边界像素的颜色混合结果
		return lerp(withBackgroundColor,withEdgeColor,_EdgeOnly);
	}
	ENDCG
	Pass{
	ZTest Always ZWrite Off Cull Off

	CGPROGRAM
	#pragma vertex vert
	#pragma fragment fragRobertsCrossDepthAndNormal
	ENDCG
	}
}
FallBack Off
}     

原图效果:

颜色边缘检测效果:
 深度法线纹理边缘检测效果:


可以看到,利用深度法线纹理的描边效果要比之前的干净许多,只有物体之间存在明显边界的地方才会被描边,不会收到物体自身纹理和阴影的影响,深度法线纹理边缘检测效果的第二张图为非边缘部分着色为背景色(白色)的效果,这样就完全变成线条速写的风格了。
值得讨论的地方,回看Shader代码,为什么在做判定的时候,需要同时对深度和法线值的插值都做判断?原因在于同一个物体出现拐角的地方,两侧的法线差值会很大,而他们的深度值可能不会相差很大,而两个平行的平面之间,各自平面上的像素点法线的差值可能会很小,而深度值的差异可能会很大,所以只有法线和深度值的差异都很小时,才能认为他们在同一个面上,不存在边界。同时检测法线和深度值的差异不会发生漏掉边界的情况。

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值