IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 游戏开发 -> UnityShader17.1:ESM 阴影技术 -> 正文阅读

[游戏开发]UnityShader17.1:ESM 阴影技术

一、奏鸣曲:基于 ShadowMapping 的软阴影技术

前面已经写过在?Unity 和 OpenGL 上实现最简单阴影的文章了:

虽然现在有很多更先进的计算阴影的方案,但是不得不说?ShadowMapping 还是非常实用的,极大多数主流游戏目前也都还是基于 ShadowMapping 的各种变种来做阴影,特别是实时阴影

基本原理也非常简单:即以光源为相机,朝向光源方向渲染一个仅深度的 shadowmap,随后在正常的渲染流程中,将需要着色的片段变换到光源空间中,再将其深度与?shadowmap 中的深度值进行比较,以确定当前片段是否有被遮挡

Unity 的屏幕空间阴影技术原理也差不多,就是多进行了一步根据摄像机的深度图重建世界空间坐标的过程,这样就可以将阴影的计算放到后面,以避免去计算那些已经被遮挡的物体表面的阴影

但是考虑到 shadowmap 的精度问题,用上述方法得到的阴影必然是一个硬阴影,而且必然会有很严重的锯齿感,后面衍生的很多阴影算法:例如 PCF、ESM、VSM、CSM 都是为了改进和优化这个问题

为了后面更好的建模,定义

  • ?x?为摄像机看到的场景中具体某一点
  • ?d(x)?为这一点到光源的距离,可以简写为?d
  • p?为光源朝向该点照射,接触到的遮挡物的位置
  • z(p)?为 p 点采样 shadowmap 的结果,如果 shadowmap 仅为一张光源深度图,则结果就为遮挡物在光源空间下的深度?z

那么可以得到?

f(d(x), z(p)) =f(d, z) =\left\{\begin{array}{l} 1, d<z \\ 0, d \geq z \end{array}\right.

其中?f(d, z)?即光照对该位置的贡献比例,0 就意味着完全在阴影当中

1.1 最简单的滤波方式:PCF(Percentage-Closer Filter)

想要实现软阴影,解决锯齿问题,那就必然需要进行模糊,也就是对结果进行滤波,PCF 正是这么做的:即利用多重采样和插值函数,并将插值的结果作为 f(d, z)?的值

f(d,z)=\frac{\sum_{i-0}^N f\left(d_i, z_i\right)}{N}

这个方法非常好理解,也很朴素:既然采样一个点计算阴影不够,我就把周围的点都给你采样一遍,然后对结果求个平均,不过这有个很严重的问题:多重采样非常影响性能,单次采样的开销取决于 GPU 是否支持 Pre-Fetch Texture 和这个采样是否是 Simple Texturing(不依赖其他采样结果的采样),但无论单次采样效率如何,多重采样在算法层面效率就低下,其总体复杂度是 kn,其中 k 是采样次数,n 是片段数量,如果想要一个不错的软阴影效果,k 不会小

1.2 从图像处理的角度思考,有没有更好的方法

PCF 本质上就是一个对结果进行卷积的过程,写成通式就是:

f(d,z) = [w * f(d(x), z)](p)

考虑到我们或许可以进行预滤波(pre-filtering),也就是?f(d,z)=[w * f(d(x), z)](p) = f(d(x),(w * z)(p)),后者用人话讲就是只需要在 shadow-prepass 阶段对 shadowmap 进行滤波,而无需在光照计算时阶段进行重复的采样和平均就可以得到最终的软阴影效果,这岂不是完美,但很可惜,不行!因为?f(d, z)?是一个阶跃函数,并不满足上面的方程

那么我们在?f(d, z)?上面做文章,让他稍微变换一下,满足相对正确效果的同时,又不再是一个阶跃函数,不就可以了嘛,没错,VSM 以及 ESM 正是这个思想!

二、慢速乐章:ESM(Exponential Shadow Mapping)理论

在 ESM 中,f(d,z)=e^{-c(d-z)}=e^{-c d} e^{c z},其中 c 为一个可以指定的常量,这个函数形式有以下几个特点:

  1. 当 d < z 时,f(d,z)?接近于无穷大,但是这没有什么关系,因为不考虑精度问题理论上根本不可能出现?d < z 的情况,也因此可以理解为?f(d,z)?就是个单边函数,如果其结果超过1,就把它限制到1就 OK,也就是对结果我们可以再做一步?f(z)= saturate(f(z))?的操作
  2. 常量 c 越大,f(d,z)?就越接近于前面的阶跃函数

这样,我们就得到了 ESM 的一个大致流程:

  1. 获取光源的 shadowmap 时,不再仅存深度,而是存储?e^{cz}
  2. 对 shadowmap 进行滤波(高斯模糊),得到?w * e^{cz}(如果不进行这一步,ESM 基本上就失去了它的意义,后面的 VSM、EVSM 同理)
  3. 在采样阴影时计算?e^{-cd}
  4. 计算?f(d,z)=e^{-c(d-z)}=e^{-c d} (w*e^{c z}),并将结果限制到 [0, 1] 范围内

2.1 依旧可能出现的阴影失真问题

只要是和 shadowmap 相关的技术都需要注意这个问题,本质上是因为 shadowmap 分辨率不够,其纹素并不能和场景中的坐标一一对应,特别是离光源远的位置,更会出现多个?d(x)?共享一个?z(p),从而导致得出错误的?f(d, z)?的情况

ESM 相关的论文中也是有提到的:

其中红点为相机采样点,而蓝色为生成 shadowmap 时的光照采样点,可以看出在采样点 x 时,得到了一个?z(p)?远大于?d(x)?的结果(其中?z(p)?仅为 shadowmap 深度)?,这个结果无论如何都是不对的,此时计算?f(d, z)?得到的值,会远大于1,而事实上它位于被遮挡的边界,得出的结果应该在 0.5 附近才是正确的

对于上述的情况,我们可以把它揪出来,如果发现一个离谱的?f(d, z)?超过了一个阈值 1+\sigma,那我们就姑且可以确定它出现了上述的情况,此时我们对这个点单独去做 PCF 其实是可以接受的,当然这种情况往往只会出现在多重阴影的边缘,除此之外对 Shadowmap 做预滤波也可以有有效缓解,因此实际运用 ESM 时倒是可以直接忽略这个问题(还有其它更高级的解决方案,不过由于性能及其复杂程度,可以不用太过深究,如果有兴趣可以直接参考论文)

ESM 倒不会像普通 shadowmap 那样出现大规模的阴影粉刺(Shadow acne),因为对于极小的 d(x) 误差,指数衰减没有那么明显,故不需要考虑?Depth Bias

2.2 改良版 ESM

对于 ESM:f(d,z)=e^{-c(d-z)}=e^{-c d} e^{c z}

  1. 当 c 足够大时,它无限接近于前面的阶跃函数,得出的结果必然越准确,但是同理你软阴影的效果就越不明显,并且由于你 shadowmap 存储的是指数结果?e^{cz},c 足够大后这个值也会非常的大,因此这对 shadowmap 的浮点数存储也会有很高的精度要求,基本上需要 32 位通道的浮点数存储,不然就会出现很严重的压缩瑕疵,在此基础上?c = 88 是一个理论极限值
  2. 而当 c 值取小时,对于 d 接近于 z 的物体表面会出现漏光现象:很好理解,因为此时你算出的?f(d,z)?结果会大于 0(c 值越小,该结果越大),可是它又不一定是阴影边缘,表现就比较奇怪

②的漏光可以说是一个 BUG,但是它在某些情况下有可以作为特性被利用:一个经典的例子就是云层阴影,毕竟云的特性就是不完全遮光

如何解决 c 值过小时的漏光问题呢?很好办 c 值取大一点就好了嘛,那如何解决 c 值过大后 float 存储精度要求高的问题呢?很好办 c 值取小一点就好了嘛,那就另谋出路,看看能不能不存储指数结果,而是其它?

还真有:

这个改良版的 ESM 大致思路就是:既然我 shadowmap 存储?e^{cz}?会出现值过大的情况,那么索性就不存这个指数了,直接存?z,但也因此我 blur 的部分就要重新考量:

考虑到卷积部分,其中?w?来自于高斯过滤中的 kernel,可以得到?

?(w*e^{c z}) = \sum_{i=0}^N w_i e^{c z_i} = e^{ln(\sum_{i=0}^N w_i e^{c z_i})}

也就是说,在进行过滤的时候,还是要对指数进行过滤(加权平均),只不过是结果转到 log

原文用的是一个更麻烦的等价写法,不用 e^z?而是转写为 e^{z - z_0},这可以让指数计算时值尽量小,看上去可以提高精度,但是不采取这个方案也没有太大关系,精度最后测试下来都差不多

既然需要对指数结果进行过滤,而你 shadowmap 存储的是 z?并非指数结果,因此对于这张贴图不能无脑用硬件双线性插值,而是要先点采样手动插值,转指数后再双线性插值,然后拿这个结果套用回 ESM

总结下改良后的流程就是:

  1. 获取光源的 shadowmap 时,还是仅存深度?z
  2. 对 shadowmap 进行滤波(高斯模糊)时计算??z'=ln(\sum_{i=0}^N w_i e^{c z_i})
  3. 在采样阴影时计算?e^{-cd}
  4. 计算?f(d,z)=e^{-cd + z'},并将结果限制到 [0, 1] 范围内

搞定,其实本质就是换了个公式,以避免 shadowmap 中存储的值过大,在这种情况下你的 C 值就可以取 150、200?甚至更高

三、舞曲:一个大型 URP 项目中应用 ESM 的例子

3.1 ESM shadowmap 烘焙

因为平行光位置不会实时改变,因此可以对每个场景中的平行光离线烘焙对应的 shadowmap

3.1.1 光源空间正交矩阵生成

使用 Unity 自带的?Matrix4x4.Ortho 接口就 OK,然后就是

  1. 正交投影范围要能覆盖整个场景,并且不要太大,高度也是,这些可以值通过手动配置,也可以根据场景和光照配置来自动生成
  2. 其次由于光线不是垂直于地面照射的,但前面一步计算的正交投影 xy 平面是平行于场景地面的,并没有沿光线方向旋转,因此还需要进行一步斜切操作:即 x 和 y 都需要沿光照方向偏移一段距离,这短距离为?zp,其中?z?为当前点的垂直深度,p?为每增加单位深度 x 和 y 轴的偏移量
  3. Matrix4x4.Ortho 生成的正交矩阵深度范围为 [-1, 1],而我们想要的范围是 [0, 1] 以便后面计算,因此还需要进行一个无视平台的转换
private static Matrix4x4 GetGPUProjMatrix(Matrix4x4 p)
{
    p[2, 0] = p[2, 0] * (-0.5f) + p[3, 0] * 0.5f;
    p[2, 1] = p[2, 1] * (-0.5f) + p[3, 1] * 0.5f;
    p[2, 2] = p[2, 2] * (-0.5f) + p[3, 2] * 0.5f;
    p[2, 3] = p[2, 3] * (-0.5f) + p[3, 3] * 0.5f;
    return p;
}
public static void GetShadowMatrix(Vector2 size, Vector2 worldHeight, Vector2 worldOffset, Vector2 lightDirection, out Matrix4x4 m, out Matrix4x4 p)
{
    m = Matrix4x4.TRS(new Vector3(-size.x * 0.5f + worldOffset.x, -size.y * 0.5f + worldOffset.y, 0.0f), Quaternion.Euler(-90, 0, 0), Vector3.one);
    p = Matrix4x4.Ortho(-size.x * 0.5f, size.x * 0.5f, -size.y * 0.5f, size.y * 0.5f, worldHeight.x, worldHeight.y);
    float z = Mathf.Sqrt(1 - lightDirection.x * lightDirection.x - lightDirection.y * lightDirection.y);
    p[0, 2] = -(lightDirection.x / z) / size.x * 2.0f;
    p[1, 2] = -(lightDirection.y / z) / size.y * 2.0f;
    // ESM 的阴影是在DX11下烘焙的 这里将ESM_Matrix转换成了GL的矩阵格式
    // GLES3.0 的绘制模式下没有做翻转,但是实际需要使用翻转后的矩阵
    // GL.GetGPUProjectionMatrix 接口只会转DX到GL 遇到GL时直接不做处理
    // 为了平台数据一致 直接统一转换
    // p = GL.GetGPUProjectionMatrix(p, false);
    p = GetGPUProjMatrix(p);
}

其中上面的三个步骤决定了正交矩阵的最终形式,而对于配置文件可以每个场景给一个,其中除了正交矩阵参数的设置还有其它各项烘培的设置,包括后面最重要的 C 值:

3.1.2 依次绘制物体,写入深度

这块没有什么特别,只要注意剔除掉不绘制阴影的物体,以及部分 Alpha-Test 的物体就 OK:

foreach (var renderer in GameObject.FindObjectsOfType<Renderer>())
{
    if (renderer.enabled && renderer.gameObject.activeInHierarchy && renderer.shadowCastingMode != ShadowCastingMode.Off)
    {
        //SetMat……
        cmd.DrawRenderer(renderer, mat, 0, 0);
    }
}
Graphics.ExecuteCommandBuffer(cmd);
v2f vert (appdata v)
{
    v2f o;
    o.vertex = TransformObjectToHClip(v.vertex.xyz);
    o.texcoord = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
    return o;
}

float4 frag (v2f i) : SV_Target
{ 
    float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.texcoord);
    clip(color.a - _Cutoff);
    float depth = i.vertex.z / i.vertex.w;
    return depth;
}

3.1.3?模糊与降采样

由于没有实时绘制要求,因此我们可以采用一个技巧:就是绘制 shadowmap 时给一个非常高的分辨率:8192 * 8192,然后最后保存 Texture 到硬盘的前一步进行降采样:

int maxTextureSize = Mathf.Min(SystemInfo.maxTextureSize, 8192);
Vector2Int renderTargetSize = new Vector2Int(maxTextureSize, maxTextureSize);
RenderTexture rt = RenderTexture.GetTemporary(renderTargetSize.x, renderTargetSize.y, 24, RenderTextureFormat.ARGBFloat);
CommandBuffer cmd = new CommandBuffer();
cmd.SetRenderTarget(rt.colorBuffer, rt.depthBuffer);


//绘制 shadowmap……
//blur 操作……

int downSample = s.FindProperty("downSample").intValue;
Vector2Int texSize = new Vector2Int(GetTexSize(worldSize.x * 8), GetTexSize(worldSize.y * 8));
int additionalDownSampleTimes = 0;
for (; maxTextureSize > texSize.x; maxTextureSize >>= 1, ++additionalDownSampleTimes);

//DownSample
RenderTexture fromRT = rt2;
RenderTexture toRT = null;
for (int i = 0; i < downSample + additionalDownSampleTimes; i++)
{
    toRT = RenderTexture.GetTemporary(fromRT.width / 2, fromRT.height / 2, 0, RenderTextureFormat.ARGBFloat);
    Graphics.Blit(fromRT, toRT, mat, 2);
    RenderTexture.ReleaseTemporary(fromRT);
    fromRT = toRT;
}

降采样时根据场景的大小来决定最终的降采样次数(决定 shadowmap 最终大小,一般场景最终大小都被限制到了?512 或 1024) ,当然支持配置额外的将采样次数,以对 shadowmap 进行进一步压缩以节省内存

在降采样之前,做好 blur 操作,前面提到过由于存储的不是指数结果,因此不好直接进行双线性过滤,不过没关系,暴力点采样求平均也是没问题的:

RenderTexture rt2 = RenderTexture.GetTemporary(renderTargetSize.x, renderTargetSize.y, 0, RenderTextureFormat.ARGBFloat);
Graphics.Blit(rt, rt2, mat, 3);
RenderTexture.ReleaseTemporary(rt);

这里的 shader 省略:就是最简单的?Kernel 矩阵模糊,公式参考前面一章改良 ESM

3.1.4 shadowmap 解编码与压缩

解编码很好理解:就是将浮点数拆散存储到多个通道当中,可以自己写,也可以参考 Unity 自带的方法 EncodeFloatRGBA 或 EncodeFloatRG:

inline float4 EncodeDepth(float v)
{
#ifdef HALF_DEPTH
    float2 kEncodeMul = float2(1.0, 255.0);
    float kEncodeBit = 1.0 / 255.0;
    float2 enc = kEncodeMul * v;
    enc = frac(enc);
    enc.x -= enc.y * kEncodeBit;
    return float4(enc.x, enc.y, 0, 1);
#else
    return float4(v, 0, 0, 1);
#endif
}

HALF_DEPTH 关键字决定是否 16 位存储浮点数,可以对比一下效果,当然为了测试,静态物体接收阴影也是用的 ESM 而非 shadowmask:区别不是很大,所以最后还是用的 R8

然后就是配置支持是否针对各手机平台进行纹理压缩:

TextureImporter ti = (TextureImporter)TextureImporter.GetAtPath(path);
ti.mipmapEnabled = false;
var apf = ti.GetPlatformTextureSettings("Android");
var ipf = ti.GetPlatformTextureSettings("iPhone");
var wpf = ti.GetPlatformTextureSettings("Standalone");
apf.overridden = true;
ipf.overridden = true;
wpf.overridden = true;
if (isHighPrecision)
{
    apf.format = IsCompression ? TextureImporterFormat.EAC_RG : TextureImporterFormat.RGB24;
    ipf.format = IsCompression ? TextureImporterFormat.EAC_RG : TextureImporterFormat.RGB24;
    wpf.format = IsCompression ? TextureImporterFormat.BC5 : TextureImporterFormat.RGB24;
}
else
{
    apf.format = IsCompression ? TextureImporterFormat.EAC_R : TextureImporterFormat.R8;
    ipf.format = IsCompression ? TextureImporterFormat.EAC_R : TextureImporterFormat.R8;
    wpf.format = IsCompression ? TextureImporterFormat.BC4 : TextureImporterFormat.R8;
}
ti.SetPlatformTextureSettings(apf);
ti.SetPlatformTextureSettings(ipf);
ti.SetPlatformTextureSettings(wpf);
ti.SaveAndReimport();

搞定!最后生成的图是这样的:

3.2 ESM 与 SHADOWMASK

ESM 阴影开关由全局的 Keyword 控制:不过考虑同一个 shader 中?keywords (变体)数量不能太多,因此对于场景中的物体,如果开启了 Unity 内置的 SHADOWS_SHADOWMASK,则默认开启?ESM_SHADOWMASK:

#pragma multi_compile __ SHADOWS_SHADOWMASK 
#ifdef SHADOWS_SHADOWMASK
	#define ESM_SHADOWMASK
#endif

同理,如果物体接受烘焙阴影,则采样 shadowmask,否则采样 ESM shadowmap:

#if !defined(LIGHTMAP_ON)
    if defined(ESM_SHADOWMASK)
		#define GET_SCENE_SHADOW(vi,isCreature) GetESMShadow(vi.worldPos.xyz, isCreature)
	#else
		//……
	#endif
#else
	#define GET_SCENE_SHADOW(vi,isCreature) 1
#endif

#if defined(LIGHTMAP_ON)
//有LIGHTMAP的不用ESM_SHADOWMASK
    #if defined(SHADOWS_SHADOWMASK)
        atten = min(atten, SampleShadowMask(i.ambientOrLightmapUV.xy).r);
    #endif
#endif

而对于动态物体(例如怪物和角色),其自阴影需要实时计算

#pragma multi_compile __ ESM_SHADOWMASK
//-------------------------------------------------
ApplyShadow(col, GET_SCENE_SHADOW(i,true));

3.3 动态物体自阴影

关于 C 值的选择,还是拿静态物体进行测试:

不过由于实际只有动态物体才需要用到 ESM,因此精度要求其实会更低,配置中有两套 C 值也是因此:我们希望人物在阴影区中过度的更平滑一点,不过这个做法不完全正确,因为 shadowmap 烘焙时也用到了 C,相对于两套 shadowmap 的思路,我们更希望能得到一个美术可接受的结果,而不是逻辑上完全正确:

实际计算时,由于 ESM 和 PCF 的思想是不冲突的,如果性能允许最后依然可以多次采样求个平均,而对于阴影模糊范围,可以理解为我们允许把最终的函数结果?f(d,z)? 由 [0, 1] 映射到一个更小的区间内

inline half GetESMShadow(half3 worldPos,bool isCreature)
{
	bool useCreatureDedicatedValue = (isCreature && _ESMShadowParams_Creature.y != 0 );
	float ESM_C = useCreatureDedicatedValue ? _ESMShadowParams_Creature.x:_ESMShadowParams.x;
	float ESMBias = _ESMShadowParams.y;
	float2 ESMBlurRange = useCreatureDedicatedValue ? _ESMShadowParams_Creature.zw:_ESMShadowParams.zw;
	float4 shadowUV = mul(_ESMShadowMatrix, float4(worldPos.xyz, 1));
	float2 texUV = shadowUV * 0.5 + 0.5;
	float2 texUV0 = (floor(texUV * _ESMShadowMap_TexelSize.zw - 0.5) + 0.5) * _ESMShadowMap_TexelSize.xy;
	float2 texUV1 = texUV0 + _ESMShadowMap_TexelSize.xy * float2(1, 0);
	float2 texUV2 = texUV0 + _ESMShadowMap_TexelSize.xy * float2(0, 1);
	float2 texUV3 = texUV0 + _ESMShadowMap_TexelSize.xy * float2(1, 1);
	float2 w = (texUV - texUV0) / _ESMShadowMap_TexelSize.xy;
	float depth0 = exp(-ESM_C * DecodeDepth(SAMPLE_TEXTURE2D_LOD(_ESMShadowMap, sampler_point_clamp, texUV0, 0)));
	float depth1 = exp(-ESM_C * DecodeDepth(SAMPLE_TEXTURE2D_LOD(_ESMShadowMap, sampler_point_clamp, texUV1, 0)));
	float depth2 = exp(-ESM_C * DecodeDepth(SAMPLE_TEXTURE2D_LOD(_ESMShadowMap, sampler_point_clamp, texUV2, 0)));
	float depth3 = exp(-ESM_C * DecodeDepth(SAMPLE_TEXTURE2D_LOD(_ESMShadowMap, sampler_point_clamp, texUV3, 0)));
	float depth = lerp(lerp(depth0, depth1, w.x), lerp(depth2, depth3, w.x), w.y);
	float result = saturate(exp((1 - shadowUV.z / shadowUV.w) * 0.5 * ESM_C) * depth);
	return saturate((result - ESMBlurRange.x) / (ESMBlurRange.y - ESMBlurRange.x));
}

对于最终的方案决策:考虑到大多数静态物体(例如地形等)阴影采样的 shadowmask 而非 ESM,效果如下,其中草的自阴影来自于 ESM shadowmap 采样:

  游戏开发 最新文章
6、英飞凌-AURIX-TC3XX: PWM实验之使用 GT
泛型自动装箱
CubeMax添加Rtthread操作系统 组件STM32F10
python多线程编程:如何优雅地关闭线程
数据类型隐式转换导致的阻塞
WebAPi实现多文件上传,并附带参数
from origin ‘null‘ has been blocked by
UE4 蓝图调用C++函数(附带项目工程)
Unity学习笔记(一)结构体的简单理解与应用
【Memory As a Programming Concept in C a
上一篇文章      下一篇文章      查看所有文章
加:2022-10-17 13:05:24  更:2022-10-17 13:05:49 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年6日历 -2024/6/3 6:33:51-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码