找回密码
 立即注册
查看: 454|回复: 0

用Unity实现半条命Alyx中的液体物理效果

[复制链接]
发表于 2020-11-24 10:48 | 显示全部楼层 |阅读模式
干了两个月客户端的活终于能闲下来几天,有点空写个玩具了。
前段时间被半条命Alyx里的酒瓶刷屏了,这酒瓶里液体的的物理效果仅仅看录屏都能感受到十分棒,同时据说瓶子中液体的逻辑全部在shader的一个单独pass中,听上去就十分吊。正好端午节闲着没事我也尝试下看能不能写个差不多的东西出来。
先写在前面,一方面,本文中只尝试去实现液体的物理效果,其他液体相关的效果像高光折射啥的为了保证代码的简洁性通通没写。另一方面,由于v社那边并没有给出任何一点这个shader的实现细节,我只能根据网上各路大佬的思路缝合出一个大致的差不多的解决方案,对于v社实际是怎么写的我也很好奇。同时,本文只实现效果的主干部分,做出来的结果对奇怪形状的容器效果不太行,或者有其他一些这样那样的神秘小问题(要调也能调只是我懒。。)。请各路大佬轻喷。
本文仅作为本人记录学习历程之用,本人才疏学浅难免出现一些错误或纰漏,请各路大佬多多批评指正,在此先行谢过。
先放个成品的小视频在这:
这个看似像摇晃的红酒杯的玩意模型上其实就是个Unity内置的球,从一个普通的球到这样摇晃的红酒杯主要经过了以下几步:
    用顶点塌缩的方式将模型高处的顶点压到水面高度。运行过程中存储每个水分子的位置、速度数据交给下一帧的着色器。在着色器中根据上一帧水分子的数据进行超简陋动力学模拟,计算出当前帧水分子的位置。
我们一步步来。
产生水面这个问题其实包含两个子问题,一个是如何让水面上方的部分消失,另一个问题是如何在断面上画出一个本不存在的水面。传统的解决方案是把水面上方的部分在像素着色器中clip掉,然后在一个单独的pass中只渲染模型的背面并把背面的法线改为竖直朝上。这样即可以产生虚拟的水面又可以让水面的光照看起来比较对。
但这种方案在这行不通,我们在这里需要一个实际的水面。生成顶点这个活是曲面细分着色器的强项但很可惜它不是我们可以通过shader控制的着色器。所以退而求其次,反正水面往上的顶点我们也用不到,干脆把这些顶点压到水面的高度作为水面来用。
在shader参数里加一个_HeightFactor来表示水面相对于物体中心的高度。为了让这个高度参数在不同模型中都基本生效,且液面高度跟模型保持固定不随瓶身移动而变化,dex大佬的做法运用到模型的bounding box,液面参数表示液面在模型bounding box中的高度。这个做法难免要在shader外写脚本我就不太乐意。这里我的做法是运用模型空间坐标,正常模型的中心在模型空间下一般都是(0, 0)。在物体竖直向上摆放的情况下只需要把和模型中心相对位置的y值大于_HeightFactor的顶点压到_HeightFactor高度就好了。
当然我们还得兼容模型旋转+缩放的情况,只是把表示平移的矩阵去掉。所以实际用来与高度参数进行比较的是这样一个东西:


unity的shader里是不太好单独获取旋转和缩放矩阵的,我也不太想从M矩阵里手动拆,所以最后用了这么个写法:
o.modelPosRH = mul(unity_ObjectToWorld, float4(v.vertex.xyz, 0.0));这个写法其实就相当于把齐次坐标下多出来的用斜切表示平移的维度去掉了,剩下的三个维度就只剩旋转和缩放了。
把算出来的顶点坐标叫modelPosRS,接下来我们得写个if,把modelPosRS空间下y轴高度大于高度参数的顶点的模型空间坐标压到高度参数代表的液面上,这样我们就拥有了一个带有顶点的液面,如图:
代码如图:
//把模型压到高度平面
o.modelPosRH = mul(unity_ObjectToWorld, float4(v.vertex.xyz, 0.0));
if (o.modelPosRH.y > _HeightFactor) {
        v.vertex.xyz = mul(unity_WorldToObject, float4(o.modelPosRH.x, _HeightFactor , o.modelPosRH.z, o.modelPosRH. w)).xyz;       
}水面有了,下面我们来让它动起来。简单的液面物理模拟可以用水分子+弹簧的模型来实现。刚刚压到液面上的顶点可以视为水分子。而通过弹簧计算速度这种工作显然不是用某一帧的帧内数据就可以做到的,我们需要一个存储之前帧顶点数据的方法。
一般而言我们的shader是不存状态的,想要存储前一帧的数据得用到其他方法。在这里我们借用下compute shader里常用的ComputeBuffer。在Shader里的定义方法如下:
struct SpringData
{
        float3 cachedWorldPos;
        float3 cachedVelocity;
};
uniform RWStructuredBuffer<SpringData> _myWriteBuffer : register(u1);
uniform RWStructuredBuffer<SpringData> _myReadBuffer : register(u2);在我的写法里需要用到前一帧的顶点世界坐标和前一帧的速度向量。我需要先定义一个叫SpringData的数据结构,其中包含表示这两个值的float3。之后定义两个RWStructuredBuffer,之所以用两个是因为我需要一个双缓冲的结构来保证同一帧中的多次渲染不会对结果产生影响。
十分可惜的是Unity不太能在Shader里给ComputeBuffer分配内存空间啥的,所以不得已我们还是得写个c#脚本来初始化buffer。V社大佬能把代码全写在shader里可能是因为Source2引擎支持在shader里给ComputeBuffer分配内存以及交换缓冲区的操作,然而Unity并不行。用于初始化ComputeBuffer的脚本如下:
using UnityEngine;

public class InitComputeBuffer : MonoBehaviour
{
    public Material material;
    public MeshFilter meshFilter;
    ComputeBuffer compute_buffer_Alpha;
    ComputeBuffer compute_buffer_Beta;
    Mesh mesh;
    SpringData[] dataA;
    SpringData[] dataB;

    public struct SpringData
    {
        public Vector3 cachedWorldPos;
        public Vector3 cachedVelocity;
    }

    void Awake()
    {
        mesh = meshFilter.sharedMesh;
        dataA = new SpringData[mesh.vertices.Length];
        for (int i = 0; i < dataA.Length; i++)
        {
            dataA.cachedWorldPos = meshFilter.transform.TransformPoint(mesh.vertices);
            dataA.cachedVelocity = Vector3.zero;
        }
        dataB = new SpringData[mesh.vertices.Length];
        for (int i = 0; i < dataB.Length; i++)
        {
            dataB.cachedWorldPos = meshFilter.transform.TransformPoint(mesh.vertices);
            dataB.cachedVelocity = Vector3.zero;
        }
        Graphics.ClearRandomWriteTargets();
        compute_buffer_Alpha = new ComputeBuffer(dataA.Length, sizeof(float) * 6, ComputeBufferType.Default);
        Graphics.SetRandomWriteTarget(1, compute_buffer_Alpha, false);
        material.SetBuffer("_myWriteBuffer", compute_buffer_Alpha);
        compute_buffer_Alpha.SetData(dataA);

        compute_buffer_Beta = new ComputeBuffer(dataB.Length, sizeof(float) * 6, ComputeBufferType.Default);
        Graphics.SetRandomWriteTarget(2, compute_buffer_Beta, false);
        material.SetBuffer("_myReadBuffer", compute_buffer_Beta);
        compute_buffer_Beta.SetData(dataB);
        tempData1 = new SpringData[mesh.vertices.Length];
    }

    SpringDta[] tempData1;

    void Update()
    {
        compute_buffer_Alpha.GetData(tempData1);
        compute_buffer_Beta.SetData(tempData1);
    }
}在Awake里初始化了两个ComputeBuffer并绑定到Shader,在Update里完成双缓冲的每帧交换。在这里要注意ComputeBuffer的初始值不能乱设不然初始效果会出问题,这里把世界坐标设为每个顶点的世界坐标,把初始速度向量设为0。把脚本找个地方挂着该拖上去的public参数拖上去即可完成ComputeBuffer的初始化。
这里需要在顶点着色器中读写ComputeBuffer的值。这个性质需要用到sm5.0(希望你的显卡也能支持它),5.0之前只能在compute shader或pixel shader中使用ComputeBuffer。所以我们在shader前面加上这么一行:
#pragma target 5.0这样我们就可以在顶点着色器里使用ComputeBuffer了。由于已经在脚本中完成了双缓冲的替换,所以在这里我们只需要在顶点着色器的开头处读取用于读的ComputeBuffer,在结尾处设置用于写的ComputeBuffer,即可完成顶点数据的保存和传递,代码如下:
float3 lastFrameWorldPos = _myReadBuffer[v.id].cachedWorldPos;
float3 lastFrameVelocity = _myReadBuffer[v.id].cachedVelocity;

--中间是其他代码--

_myWriteBuffer[v.id].cachedWorldPos = o.worldPos.xyz;
_myWriteBuffer[v.id].cachedVelocity = curVelocity;这样我们就成功将顶点数据交给了下一帧的着色器。接下来我们来完成最后一步,做一个简单的动力学模拟。
我们将顶点视为水分子,每个水分子都有一个平衡位置,即当水面归于平静时水分子对应的顶点应该在的位置。当水分子不在平衡位置时,浮力和张力的合力会将水分子向平衡位置挤压,且挤压的力度和水分子距离平衡位置的距离成正相关。为了模拟这种力我们可以在水分子当前位置和平衡位置之间拉起一根弹簧,弹簧产生的力给水分子带来新的速度,新的速度和过去速度的合速度即可用于计算水分子的当前位置。
有个问题在于这种简单的动力学模拟没法模拟瓶身对水分子的压力,这样会导致水分子在XZ轴上晃到瓶子外,产生难以控制的效果,这里有一个小trick,即只在竖直的Y轴上表现弹簧产生的速度,在水平的XZ轴上将产生的速度转移到Y轴上用于表现瓶中液体在水平移动时产生的惯性。这个思路来自于Gil Damoiseaux大佬,谢谢大佬。
代码如下:
float dist = distance(lastFrameWorldPos, o.worldPos);
float3 deltaMovement = o.worldPos - lastFrameWorldPos;
float3 normalizedDelta = 0;
if (length(deltaMovement) > 0.000001) {
        normalizedDelta = normalize(deltaMovement);
}                               
float3 addVelocity = float3(0.0, deltaMovement.y, 0.0) * 2.0f;
float3 verticalMoveAdd = float3(0, normalizedDelta.x * normalize(o.modelPosRH).x + normalizedDelta.z * normalize(o.modelPosRH).z, 0) * -1.0f;
float3 curVelocity = (lastFrameVelocity)* pow(0.001f, unity_DeltaTime.x) + addVelocity + verticalMoveAdd;
float randomFactor = 1 + (tex2Dlod(_NoiseTex, float4(v.texcoord.xy, 0, 0)).r - 0.5) * 1;
if (length(curVelocity) > 0.01f || dist > 0.01f) {
        o.worldPos = float3(o.worldPos.x, lerp(o.worldPos.y, (lastFrameWorldPos + randomFactor * curVelocity * unity_DeltaTime.x).y, upLerpFactor) , o.worldPos.z);
}
else {
        curVelocity = float3(0, 0, 0);
}一行行来:
float3 addVelocity = float3(0.0, deltaMovement.y, 0.0) * 2.0f;这一行即为根据水分子和平衡位置的距离算出一个力。根据高中物理学知识,弹簧产生的里和弹簧拉伸的距离成一次线性正相关,即:


其中k为弹性系数,L为弹簧长度,这里k可以根据对液体回弹力度的需要随便调,这里写了个2.0作为magic number。
float3 verticalMoveAdd = float3(0, normalizedDelta.x * normalize(o.modelPosRH).x + normalizedDelta.z * normalize(o.modelPosRH).z, 0) * -1.0f;这一行即为之前说的,把水平方向上的力转换为竖直方向上用于表现惯性的位移。随手拿一瓶快乐水观察下即可发现,由于液体的惯性,在液体水平移动时背向移动方向的瓶壁上液体面高度较高,和移动方向同向的瓶壁上液面高度较低,液面整体成为一个斜面。所以这里的做法又用到了我们熟悉的模型空间坐标,模型空间坐标越大的地方受同方向上水平位移的影响越大。上面这行代码的做法可以保证距离中心点相同距离的不同方向液面,在投影在该方向上的速度相同的情况下产生的竖直位移相同。
这里实际上用的是o.modelPosRH,这玩意比模型空间坐标*旋转矩阵要多乘上一个我们不想要的缩放矩阵,所以在我用它之前normalize了它一下消除了缩放带来的的影响。
float3 curVelocity = (lastFrameVelocity)* pow(0.001f, unity_DeltaTime.x) + addVelocity + verticalMoveAdd;这行即为计算之前速度和这一帧新产生的新速度的合速度。我们在做加法之前对旧的速度做一个基于时间的衰减,这里的0.001作为magic number也可以随需求进行调整。
float randomFactor = 1 + (tex2Dlod(_NoiseTex, float4(v.texcoord.xy, 0, 0)).r - 0.5) * 1;这一行是对一张噪声贴图进行采样对液面产生一个随机的扰动,噪声贴图我随便拉了一张,感觉长啥样或用什么uv影响差别都不大,只要液面动起来的时候能有个扰动就行。
if (length(curVelocity) > 0.01f || dist > 0.01f) {
        o.worldPos = float3(o.worldPos.x, lerp(o.worldPos.y, (lastFrameWorldPos + randomFactor * curVelocity * unity_DeltaTime.x).y, upLerpFactor) , o.worldPos.z);
}
else {
        curVelocity = float3(0, 0, 0);
}这里又是个if,用于从上一帧的世界坐标计算出当前帧水分子的世界坐标。值得注意的是当速度和位移接近0的时候需要让液面保持静止,否则会因为浮点数精度问题产生难以控制的随机扰动。
至此物理效果的主干部分基本完成,剩下的就是一些修修补补的工作。
首先是裁剪,我们这种顶点塌缩的做法会导致有些顶点被压到模型外。我们当然不希望瓶子里的液体超出瓶子,解决方案是使用stencil test做一个逐像素的裁剪,首先在前面加一个新pass:
//用于裁剪液体
Pass {
        cull front
        ZWrite Off

        Stencil
        {
                Ref 1
                Comp Always
                Pass Replace
        }
                               
        colormask 0
}这个pass不输出任何颜色,只对stencil buffer做一个预写入,把所有瓶子内的像素值的stencil值设为1。
之后在渲染液面的shader中添加如下代码:
Stencil
{
        Ref 1
        Comp Equal
        Pass keep
}这样即可保留瓶子内的像素而裁去瓶外的像素,避免了液体溢出瓶子。
接下来是对于液面和液体底部的处离。上面这样写完后我们会发现液体底部的顶点运动过大了,在上下移动比较剧烈的时候液面底部甚至能跳出一断空隙。我们不希望深处的液体和液体表面一样活跃。解决办法是对顶点的位移根据距离液面的距离乘上一个系数,越深的地方系数越小位移越小,代码大制如下:
float upLerpFactor = clamp(0, 1, 1 - abs(_HeightFactor - o.modelPosRH.y));
o.worldPos = float3(o.worldPos.x, lerp(o.worldPos.y, (lastFrameWorldPos + randomFactor * curVelocity * unity_DeltaTime.x).y, upLerpFactor) , o.worldPos.z);最后是法线。我们液面的顶点塌下去了但法线并没有被重建,导致很多光照效果无法实现。同时在液面运动的时候液面的正确法线在随顶点的运动不断变化,导致也没法传张贴图进去采样出法线。我的写法大制在像素着色器中做了个这样的处理:
if (i.modelPosRH.y > _HeightFactor) {
        worldNormal = lerp(float3(0, 1.0, 0), worldNormal, i.modelPosRH.x * i.modelPosRH.x + i.modelPosRH.z * i.modelPosRH.z);
}即对于液面上方的像素,根据像素距离模型中心的水平距离,在竖直向上的法线和原来法线之间插值。但这个做法毕竟只是为了让漫反射下的效果更好点,终究只是权宜之计,真要解决法线问题估计还是得在屏幕空间对法线进行重建。。这些就不是本文液面物理所探讨的范围了。
后面的工作大致就是让这团漫反射的东西更像水(现在的漫反射效果实在不太行)。大致是做一些屏幕空间高光反射折射之类的工作。同时可以用一些运动模糊高斯模糊啥的让液面在瓶身产生拖尾或让液面更加柔和等(现在转起来的时候液体边缘还是有点锯齿)。
对于气泡啊啥的据说V社大佬是用RayMarching写的,我有空去研究下。。。
本文只是我写着玩的,这个效果不保证任何实际可用性(可能换个瓶子的形状就会穿帮啥的),只是作为一个思路的呈现和shader主干部分的实现。主要是要是再写下去我端午节真就没时间打游戏了。。。之后要是有心情我再去完善下。
最后放上完整的shader:
Shader "Arc/ArcBoozeShader"
{
        Properties{
                _WaterColor("Water Color", Color) = (1, 1, 1, 1)
                _BottleColor("Bottle Color", Color) = (1, 1, 1, 1)
                _PlaneNormal("PlaneNormal", Vector) = (0, 1, 0, 0)
                _MainTex("Main Tex", 2D) = "white" {}
                _NoiseTex("Noise Tex", 2D) = "white" {}
                _AlphaScale("Alpha Scale", Range(0, 1)) = 1
                _HeightFactor("HeightFactor", Float) = 1
        }

                SubShader{
                        Tags {"Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent"}

                        //用于裁剪液体
                        Pass {
                                cull front
                                ZWrite Off

                                Stencil
                                {
                                        Ref 1
                                        Comp Always
                                        Pass Replace
                                }
                               
                                colormask 0
                        }

                        Pass{ //水面
                                Tags { "LightMode" = "ForwardBase" }

                                cull back
                                ZWrite off
                                Blend SrcAlpha OneMinusSrcAlpha
                                Stencil
                                {
                                        Ref 1
                                        Comp Equal
                                        Pass keep
                                }
                                CGPROGRAM

                                #pragma vertex vert
                                #pragma fragment frag
                                #pragma target 5.0

                                #include "Lighting.cginc"

                                fixed4 _WaterColor;
                                fixed4 _BottleColor;
                                fixed4 _PlaneNormal;
                                float _HeightFactor;
                                sampler2D _MainTex;
                                sampler2D _NoiseTex;
                                float4 _MainTex_ST;
                                float4 _NoiseTex_ST;
                                fixed _AlphaScale;

                                struct SpringData
                                {
                                        float3 cachedWorldPos;
                                        float3 cachedVelocity;
                                };
                                uniform RWStructuredBuffer<SpringData> _myWriteBuffer : register(u1);
                                uniform RWStructuredBuffer<SpringData> _myReadBuffer : register(u2);

                                struct a2v {
                                        float4 vertex : POSITION;
                                        float4 texcoord : TEXCOORD0;
                                        float3 normal : NORMAL;
                                        uint id : SV_VertexID;
                                };

                                struct v2f {
                                        float4 pos : SV_POSITION;
                                        float4 modelPosRH: TEXCOORD0;
                                        float3 worldNormal : TEXCOORD1;
                                        float3 worldPos : TEXCOORD2;       
                                        float3 debugValue : TEXCOORD3;       
                                        float2 uv : TEXCOORD4;
                                };
                               
                                v2f vert(a2v v) {
                                        v2f o;

                                        //当前实际在世界坐标下的位置
                                        float3 lastFrameWorldPos = _myReadBuffer[v.id].cachedWorldPos;
                                        float3 lastFrameVelocity = _myReadBuffer[v.id].cachedVelocity;

                                        //把模型压到高度平面
                                        o.modelPosRH = mul(unity_ObjectToWorld, float4(v.vertex.xyz, 0.0));
                                        float upLerpFactor = clamp(0, 1, 1 - abs(_HeightFactor - o.modelPosRH.y));
                                        if (o.modelPosRH.y > _HeightFactor) {
                                                v.vertex.xyz = mul(unity_WorldToObject, float4(o.modelPosRH.x, _HeightFactor , o.modelPosRH.z, o.modelPosRH. w)).xyz;       
                                        }

                                        //当前应该在的位置
                                        o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                                       
                                        float dist = distance(lastFrameWorldPos, o.worldPos);
                                        float3 deltaMovement = o.worldPos - lastFrameWorldPos;
                                        float3 normalizedDelta = 0;
                                        if (length(deltaMovement) > 0.000001) {
                                                normalizedDelta = normalize(deltaMovement);
                                        }                               
                                        float3 addVelocity = float3(0.0, deltaMovement.y, 0.0) * 2.0f;
                                        float3 verticalMoveAdd = float3(0, normalizedDelta.x * normalize(o.modelPosRH).x + normalizedDelta.z * normalize(o.modelPosRH).z, 0) * -1.0f;
                                        float3 curVelocity = (lastFrameVelocity)* pow(0.001f, unity_DeltaTime.x) + addVelocity + verticalMoveAdd;
                                        float randomFactor = 1 + (tex2Dlod(_NoiseTex, float4(v.texcoord.xy, 0, 0)).r - 0.5) * 1;
                                        if (length(curVelocity) > 0.01f || dist > 0.01f) {
                                                o.worldPos = float3(o.worldPos.x, lerp(o.worldPos.y, (lastFrameWorldPos + randomFactor * curVelocity * unity_DeltaTime.x).y, upLerpFactor) , o.worldPos.z);
                                        }
                                        else {
                                                curVelocity = float3(0, 0, 0);
                                        }
                                       
                                        v.vertex = mul(unity_WorldToObject, float4(o.worldPos, 1));

                                        o.debugValue = verticalMoveAdd;

                                        _myWriteBuffer[v.id].cachedWorldPos = o.worldPos.xyz;
                                        _myWriteBuffer[v.id].cachedVelocity = curVelocity;

                                        o.pos = UnityObjectToClipPos(v.vertex);

                                        o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
               
                                        o.worldNormal = UnityObjectToWorldNormal(v.normal);

                                        return o;
                                }

                                fixed4 frag(v2f i) : SV_Target {
                                        fixed3 worldNormal = normalize(i.worldNormal);

                                        if (i.modelPosRH.y > _HeightFactor) {
                                                worldNormal = lerp(float3(0, 1.0, 0), worldNormal, i.modelPosRH.x * i.modelPosRH.x + i.modelPosRH.z * i.modelPosRH.z);
                                        }

                                        fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

                                        fixed4 texColor = tex2D(_MainTex, i.uv);

                                        fixed3 albedo = texColor.rgb * _WaterColor.rgb;

                                        fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;

                                        fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(worldNormal, worldLightDir));

                                        //return fixed4(i.worldNormal, _WaterColor.a * _AlphaScale);
                                        return fixed4(diffuse + ambient, _WaterColor.a * _AlphaScale);
                                }

                                ENDCG
                        }

                        //瓶子
                        Pass {
                                Tags { "LightMode" = "ForwardBase" }


                                cull front
                                ZWrite off
                                Blend SrcAlpha OneMinusSrcAlpha

                                CGPROGRAM

                                #pragma vertex vert
                                #pragma fragment frag

                                #include "Lighting.cginc"

                                fixed4 _BottleColor;
                                fixed _AlphaScale;

                                struct a2v {
                                        float4 vertex : POSITION;
                                };

                                struct v2f {
                                        float4 pos : SV_POSITION;
                                };

                                v2f vert(a2v v) {
                                        v2f o;
                                        o.pos = UnityObjectToClipPos(v.vertex);
                                        return o;
                                }

                                fixed4 frag(v2f i) : SV_Target {
                                        return fixed4(_BottleColor.rgb, _BottleColor.a * _AlphaScale);
                                }

                                ENDCG
                        }

                }
                FallBack "Transparent/VertexLit"
}工程的Github链接:
最终的gif(右半张图上的残影是知乎压缩的锅):
感谢V社的Matthew Wilde大佬的booze shader提供的效果(虽然啥细节都没给)。感谢dex大佬和Gil Damoiseaux大佬的文章提供的思路。有啥问题欢迎各路大佬批评指正提意见,在此先行谢过。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-4-25 18:24 , Processed in 0.094502 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表