Unity3D 海水多线程渲染算法实现

海水仿真渲染一直是比较困难的事情,虽然市面上有了各种海水的渲染算法,但是真正做到仿真渲染的少之又少。大多停留在试验阶段,达到了仿真的要求,但是硬件配置要求高,不利于推广。本篇博客给读者介绍的是关于海水的实施渲染以及通过算法实现船只航行轨迹效果,真实的达到了海水的渲染,海水的网格采用了多个面片网格拼接的方式,网格采用的是LOD处理的,这样就优化了效率。同时将海水的绘制以及算法实现放到C++中进行,这样对于复杂算法的实现效率明显提升。先给读者看几幅效果图:
 

  船在海水中航行的轨迹效果图,轨迹是实时绘制的。再来一副海水到岸边产生的泡沫效果图:
 

  除了岸边的效果图外,船在水中周围也会产生泡沫效果。最后一副效果图如下:
 


  海水的反射折射效果。下面开始给读者介绍实现该海水的原理以及核心代码,最后把整个工程奉献给读者。
  第一步:海水网格的实现,海水网格采用的是面片拼接的方式,并且面片采用了LOD运算,其在Unity中的效果如下所示:
 

  从里向外,面片的数量逐步减少,它是根据摄像机的远近处理的,对应的核心代码如下所示:

//update the meshes with the final calculated mesh data  
void updateTiles(int a, int b) {  
   
    if(skipLods) {  
        lodSkip++;  
        if(lodSkip >= lodSkipFrames+1) lodSkip=0;  
    }  
   
    for (int L0D=a; L0D<b; L0D++) {  
        //if(L0D>  
        //this will skip one update of the tiles higher then Lod0  
        if(L0D>0 && lodSkip==0 && !ticked && skipLods) { break; }  
        //this will skip one update of the LOD0 tiles because they got updated earlier when they should.  
        if(ticked2 && L0D==0) { ticked2=false; continue; }  
  
        #if !NATIVE  
            int den = MyIntPow (2, L0D);  
            int idx = 0;  
   
            for (int y=0; y<g_height; y+=den) {  
                for (int x=0; x<g_width; x+=den) {  
                    int idx2 = g_width * y + x;  
                    verticesLOD[L0D] [idx] = vertices [idx2];  
                    //lower the far lods to eliminate gaps in the horizon when having big waves  
                    if(L0D>0) {  
                        if(farLodOffset!=0) {  
                            verticesLOD[L0D] [idx].y += flodoffset[L0D] * flodFact;  
                        }  
                    }  
                    tangentsLOD[L0D] [idx] = tangents [idx2];  
                    normalsLOD[L0D] [idx++] = normals [idx2];  
                }             
            }  
        #else  
            uocean._updateTilesA(verticesLOD[L0D], vertices, tangentsLOD[L0D], tangents, normalsLOD[L0D], normals, L0D, farLodOffset, flodoffset, flodFact);  
        #endif  
   
        btiles_LOD[L0D].vertices = verticesLOD[L0D];  
        btiles_LOD[L0D].normals = normalsLOD[L0D];  
        btiles_LOD[L0D].tangents = tangentsLOD[L0D];  
    }  
   
    if(ticked) ticked = false;  
}  
   
       
void GenerateTiles() {  
   
    int chDist, nmaxLod=0; // Chebychev distance  
       
    for (int y=0; y<tiles; y++) {  
        for (int x=0; x<tiles; x++) {  
            chDist = System.Math.Max (System.Math.Abs (tiles / 2 - y), System.Math.Abs (tiles / 2 - x));  
            chDist = chDist > 0 ? chDist - 1 : 0;  
            if(nmaxLod<chDist) nmaxLod = chDist;  
        }  
    }  
    max_LOD = nmaxLod+1;  
   
    flodoffset = new float[max_LOD+1];  
    float ffact = farLodOffset/max_LOD;  
    for(int i=0; i<max_LOD+1; i++) {  
        flodoffset[i] = i*ffact;  
    }  
   
    btiles_LOD = new List<Mesh>();  
    tiles_LOD = new List<List<Mesh>>();  
   
    for (int L0D=0; L0D<max_LOD; L0D++) {  
        btiles_LOD.Add(new Mesh());  
        tiles_LOD.Add (new List<Mesh>());  
    }  
   
    GameObject tile;  
   
    int ntl = LayerMask.NameToLayer ("Water");  
   
    for (int y=0; y<tiles; y++) {  
        for (int x=0; x<tiles; x++) {  
            chDist = System.Math.Max (System.Math.Abs (tiles / 2 - y), System.Math.Abs (tiles / 2 - x));  
            chDist = chDist > 0 ? chDist - 1 : 0;  
            if(nmaxLod<chDist) nmaxLod = chDist;  
            float cy = y - Mathf.Floor(tiles * 0.5f);  
            float cx = x - Mathf.Floor(tiles * 0.5f);  
            tile = new GameObject ("Lod_"+chDist.ToString()+":"+y.ToString()+"x"+x.ToString());  
                  
               Vector3 pos=tile.transform.position;  
            pos.x = cx * size.x;  
            pos.y = transform.position.y;  
            pos.z = cy * size.z;  
   
            tile.transform.position=pos;  
            tile.AddComponent <MeshFilter>();  
            tile.AddComponent <MeshRenderer>();  
               Renderer renderer = tile.GetComponent<Renderer>();  
   
            tile.GetComponent<MeshFilter>().mesh = btiles_LOD[chDist];  
            //tile.isStatic = true;  
   
            //shader/material lod (needs improvement)  
            if(useShaderLods && numberLods>1) {  
                if(numberLods==2) {  
                    if(chDist <= sTilesLod) { if(material) renderer.material = material; }  
                    if(chDist > sTilesLod) { if(material1) renderer.material = material1; }  
                }else if(numberLods==3){  
                    if(chDist <= sTilesLod ) { if(material) renderer.material = material; }  
                    if(chDist == sTilesLod+1) { if(material1) renderer.material = material1; }  
                    if(chDist > sTilesLod+1) { if(material2) renderer.material = material2; }  
                }  
            } else {  
                renderer.material = material;  
            }  
                  
               //Make child of this object, so we don't clutter up the  
               //scene hierarchy more than necessary.  
               tile.transform.parent = transform;  
           
            //Also we don't want these to be drawn while doing refraction/reflection passes,  
            //so we'll add the to the water layer for easy filtering.  
            tile.layer = ntl;  
   
            tiles_LOD[chDist].Add( tile.GetComponent<MeshFilter>().mesh);  
        }   
    }  
   
    //enable/disable the fixed disc  
    initDisc();  
}
在Unity中生成的效果如下所示:

 

  第二步,海水的渲染Shader,根据不同的LOD等级实行不同的Shader渲染,举个例子,LOD等级2的Shader代码如下所示:

Shader "Mobile/OceanL2" {  
    Properties {  
        _SurfaceColor ("SurfaceColor", Color) = (1,1,1,1)  
        _WaterColor ("WaterColor", Color) = (1,1,1,1)  
   
        _Specularity ("Specularity", Range(0.01,1)) = 0.3  
        _SpecPower("Specularity Power", Range(0,1)) = 1  
   
        [HideInInspector] _SunColor ("SunColor", Color) = (1,1,0.901,1)  
   
        _Bump ("Bump (RGB)", 2D) = "bump" {}  
        _Size ("UVSize", Float) = 0.015625//this is the best value (1/64) to have the same uv scales of normal and foam maps on all ocean sizes  
        [HideInInspector] _SunDir ("SunDir", Vector) = (0.3, -0.6, -1, 0)  
   
        _FakeUnderwaterColor ("Water Color LOD1", Color) = (0.196, 0.262, 0.196, 1)  
        _DistanceCancellation ("Distance Cancellation", Float) = 2000  
    }  
       
   
//water bump  
     SubShader {  
        Tags { "RenderType" = "Opaque" "Queue"="Geometry"}  
        LOD 2  
        Pass {  
            CGPROGRAM  
            #pragma vertex vert  
            #pragma fragment frag  
            //#pragma multi_compile_fog  
            #pragma multi_compile FOGON FOGOFF  
            #pragma multi_compile DCON  DCOFF  
  
              
  
            #pragma target 2.0  
            #include "UnityCG.cginc"  
   
            struct v2f {  
                float4 pos : SV_POSITION;  
                half3 floatVec : TEXCOORD0;  
                float2  bumpTexCoord : TEXCOORD1;  
                //half3  viewDir : TEXCOORD2;  
                half3  lightDir : TEXCOORD2;  
                half2 buv : TEXCOORD3;  
                half3 normViewDir : TEXCOORD4;  
                //UNITY_FOG_COORDS(7)  
                #ifdef FOGON  
                half dist : TEXCOORD5;  
                #ifdef DCON  
                half distCancellation : TEXCOORD6;  
                #endif  
                #endif  
            };  
   
            half _Size;  
            half4 _SunDir;  
            half4 _FakeUnderwaterColor;  
            #ifdef FOGON  
            uniform half4 unity_FogStart;  
            uniform half4 unity_FogEnd;  
            uniform half4 unity_FogDensity;  
            #ifdef DCON  
            half _DistanceCancellation;  
            #endif  
            #endif  
                          
            v2f vert (appdata_tan v) {  
                v2f o;  
                UNITY_INITIALIZE_OUTPUT(v2f, o);  
   
                o.bumpTexCoord.xy = v.vertex.xz*_Size;///float2(_Size.x, _Size.z)*5;  
                o.pos = mul (UNITY_MATRIX_MVP, v.vertex);  
       
                half3 objSpaceViewDir = ObjSpaceViewDir(v.vertex);  
                half3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz) );  
                half3x3 rotation = half3x3( v.tangent.xyz, binormal, v.normal );  
       
                half3 viewDir = mul(rotation, objSpaceViewDir);  
                o.lightDir = mul(rotation, half3(_SunDir.xyz));  
   
                o.buv = float2(o.bumpTexCoord.x + _CosTime.x * 0.2, o.bumpTexCoord.y + _SinTime.x * 0.3);  
   
                o.normViewDir = normalize(viewDir);  
   
                o.floatVec = normalize(o.normViewDir - normalize(o.lightDir));  
  
                #ifdef FOGON  
                //manual fog  
                half fogDif = 1.0/(unity_FogEnd.x - unity_FogStart.x);  
                o.dist = (unity_FogEnd.x - length(o.pos.xyz)) * fogDif;  
                #ifdef DCON  
                o.distCancellation = (unity_FogEnd.x - _DistanceCancellation) * fogDif;  
                #endif  
                #endif  
   
                //autofog  
                //UNITY_TRANSFER_FOG(o, o.pos);  
   
                return o;  
            }  
   
            sampler2D _Bump;  
            half4 _WaterColor;  
            half4 _SurfaceColor;  
            half _Specularity;  
            half _SpecPower;  
            half4 _SunColor;  
   
            half4 frag (v2f i) : COLOR {  
  
                #ifdef FOGON  
                #ifdef DCON  
                if(i.dist>i.distCancellation){  
                #endif  
                #endif  
                    half3 tangentNormal0 = (tex2D(_Bump, i.buv.xy) * 2.0) -1;  
                    half3 tangentNormal = normalize(tangentNormal0);  
   
                    half4 result = half4(0, 0, 0, 1);  
                   
                    //half fresnelLookup = dot(tangentNormal,i. normViewDir);  
                    //float bias = 0.06;  
                    //float power = 4.0;  
                    //half fresnelTerm = 0.06 + (1.0-0.06)*pow(1.0 - fresnelLookup, 4.0);  
   
                    half fresnelTerm = 1.0 - saturate(dot (i.normViewDir, tangentNormal0));  
   
                    half specular = pow(max(dot(i.floatVec,  tangentNormal) , 0.0), 250.0 * _Specularity ) * _SpecPower;  
                   
                    result.rgb = lerp(_WaterColor*_FakeUnderwaterColor, _SunColor.rgb*_SurfaceColor*0.85, fresnelTerm*0.6)  + specular*_SunColor.rgb;  
   
                    //fog  
                    //UNITY_APPLY_FOG(i.fogCoord, result);   
  
                    #ifdef FOGON  
                    //manual fog (linear) (reduces instructions on d3d9)  
                    float ff = saturate(i.dist);  
                    result.rgb = lerp(unity_FogColor.rgb, result.rgb, ff);  
                    #endif  
   
                    return result;  
                #ifdef FOGON  
                #ifdef DCON  
                }else{  
                    return unity_FogColor;  
                }  
                #endif  
                #endif  
            }  
            ENDCG  
               
   
        }  
    }  
   
       
}
渲染的效果图如下所示:
 
  远近海水很有层次感效果。
  第三步:船只在海水中航行的轨迹效果以及随着海水起伏效果实现代码
using UnityEngine;  
using System.Collections.Generic;  
   
public class Boyancy : MonoBehaviour {  
   
    private Ocean ocean;  
    public int renderQueue;  
   
    public bool useFixedUpdate = false;  
    public bool moreAccurate = false;  
    public float magnitude = 2f;  
    public float ypos = 0.0f;  
    private List<Vector3> blobs;  
    private List<float[]> prevBoya;  
   
    //private bool engine = false;  
    private List<float> sinkForces;  
   
    public float CenterOfMassOffset = -1f;  
    public float dampCoeff = .1f;  
   
    //buoyancy slices. (Cannot be smaller then 2)  
    //Raise these numbers if you want more accurate simulation. However it will add overhead. So keep it as small as possible.  
    public int SlicesX = 2;  
    public int SlicesZ = 2;  
   
    public int interpolation = 3;  
    private int intplt;  
    public bool ChoppynessAffectsPosition = false;  
    public float ChoppynessFactor = 0.2f;  
   
    public bool WindAffectsPosition = false;  
    public float WindFactor = 0.1f;  
   
    public bool xAngleAddsSliding = false;  
    public float slideFactor = 0.1f;  
   
    public bool cvisible, wvisible, svisible;  
    public Renderer _renderer ;  
   
    public bool sink = false;  
    public float sinkForce = 3;  
   
    private float iF;   
    private bool interpolate = false;  
   
    private Rigidbody rrigidbody;  
    private int tick, tack;  
    private Vector3 wpos, cpos;  
    private bool useGravity;  
   
    private float accel;  
    private int prevAngleX, currAngleX;  
   
    private float bbboyancy;  
    private float prevBuoyancy;  
   
   
    void Start () {  
   
        if(!_renderer) {  
            _renderer = GetComponent<Renderer>();  
            if(!_renderer) {  
                _renderer = GetComponentInChildren<Renderer>();  
            }  
        }  
   
        if(_renderer && renderQueue>0) _renderer.material.renderQueue = renderQueue;  
   
        if(!_renderer) {  
            if(cvisible) { Debug.Log("Renderer to check visibility not assigned."); cvisible = false; }  
            if(wvisible) { Debug.Log("Renderer to check visibility not assigned."); wvisible = false; }  
            if(svisible) { Debug.Log("Renderer to check visibility not assigned."); svisible = false; }  
        }  
   
        if(dampCoeff<0) dampCoeff = Mathf.Abs(dampCoeff);  
   
        rrigidbody =  GetComponent<Rigidbody>();  
   
        useGravity = rrigidbody.useGravity;  
   
        if(interpolation>0) {  
            interpolate = true;  
            iF = 1/(float)interpolation;  
            intplt = interpolation;  
        }  
   
        if(SlicesX<2) SlicesX=2;  
        if(SlicesZ<2) SlicesZ=2;  
   
        ocean = Ocean.Singleton;  
           
        rrigidbody.centerOfMass = new Vector3 (0.0f, CenterOfMassOffset, 0.0f);  
       
        Vector3 bounds = GetComponent<BoxCollider> ().size;  
   
        float length = bounds.z;  
        float width = bounds.x;  
   
        blobs = new List<Vector3> ();  
        prevBoya = new List<float[]>();  
   
        int i = 0;  
        float xstep = 1.0f / ((float)SlicesX - 1f);  
        float ystep = 1.0f / ((float)SlicesZ - 1f);  
       
        sinkForces = new List<float>();  
           
        float totalSink = 0;  
   
        for (int x=0; x<SlicesX; x++) {  
            for (int y=0; y<SlicesX; y++) {        
                blobs.Add (new Vector3 ((-0.5f + x * xstep) * width, 0.0f, (-0.5f + y * ystep) * length) + Vector3.up * ypos);  
   
                if(interpolate) { prevBoya.Add(new float[interpolation]); }  
                   
                float force =  Random.Range(0f,1f);  
                force = force * force;  
                totalSink += force;  
                sinkForces.Add(force);  
                i++;  
            }         
        }  
           
        // normalize the sink forces  
        for (int j=0; j< sinkForces.Count; j++)  {  
            sinkForces[j] = sinkForces[j] / totalSink * sinkForce;  
        }  
   
    }  
   
    void Update() {  
        if(!useFixedUpdate) update();  
    }  
   
    void FixedUpdate() {  
        if(useFixedUpdate) update();  
    }  
   
   
   
    bool visible, lastvisible;  
    int lastFrame=-15;  
   
    void update() {  
   
        if (ocean != null) {  
   
            visible = _renderer.isVisible;  
   
            //put object on the correct height of the sea surface when it has visibilty checks on and it became visible again.  
            if(visible != lastvisible) {  
                if(visible && !lastvisible) {  
                    if(Time.frameCount-lastFrame>15) {  
                        float off = ocean.GetChoppyAtLocation(transform.position.x, transform.position.z);  
                        float y = ocean.GetWaterHeightAtLocation2 (transform.position.x-off, transform.position.z);  
                        transform.position = new Vector3(transform.position.x, y, transform.position.z);  
                        lastFrame = Time.frameCount;  
                    }  
                }  
                lastvisible = visible;  
            }  
   
            //prevent use of gravity when buoyancy is disabled  
            if(cvisible) {  
                if(useGravity) {  
                    if(!visible) {  
                            rrigidbody.useGravity=false;  
                            if(wvisible && svisible) return;  
                    } else {  
                            rrigidbody.useGravity = true;  
                        }  
                }else {  
                    if(!visible) { if(wvisible && svisible) return;}   
                }  
            }  
   
            float coef = dampCoeff;  
            int index = 0, k=0;  
   
            int ran = (int)Random.Range(0, blobs.Count-1);  
   
   
            for(int j = 0; j<blobs.Count; j++) {  
   
                wpos = transform.TransformPoint (blobs[j]);  
                //get a random blob to apply a force with the choppy waves  
                if(ChoppynessAffectsPosition) { if(j == ran)  cpos = wpos; }  
   
                if(!cvisible || visible) {  
                    float buyancy = magnitude * (wpos.y);  
   
                    if (ocean.enabled) {  
                        if(ocean.canCheckBuoyancyNow[0]==1) {  
                            float off = 0;  
                                if(ocean.choppy_scale>0) off = ocean.GetChoppyAtLocation(wpos.x, wpos.z);  
                            if(moreAccurate) {    
                                buyancy = magnitude * (wpos.y - ocean.GetWaterHeightAtLocation2 (wpos.x-off, wpos.z));  
                            }else {  
                                buyancy = magnitude * (wpos.y - ocean.GetWaterHeightAtLocation (wpos.x-off, wpos.z));  
                                buyancy = Lerp(prevBuoyancy, buyancy, 0.5f);  
                                prevBuoyancy = buyancy;  
                            }  
                            bbboyancy = buyancy;  
                        } else {  
                            buyancy = bbboyancy;  
                        }  
                    }  
   
                    if (sink) { buyancy = System.Math.Max(buyancy, -3) + sinkForces[index++]; }  
   
                    float damp = rrigidbody.GetPointVelocity (wpos).y;  
   
                    float bbuyancy = buyancy;  
   
                    //interpolate last (int interpolation) frames to smooth out the jerkiness  
                    //interpolation will be used only if the renderer is visible  
                    if(interpolate) {  
                        if(visible) {  
                            prevBoya[k][tick] = buyancy;  
                            bbuyancy=0;  
                            for(int i=0; i<intplt; i++) { bbuyancy += prevBoya[k][i]; }  
                            bbuyancy *= iF;  
                        }  
                    }  
                    rrigidbody.AddForceAtPosition (-Vector3.up * (bbuyancy + coef * damp), wpos);  
                    k++;  
                }  
            }  
   
            if(interpolate) { tick++; if(tick==intplt) tick=0; }  
   
            tack++; if (tack == (int)Random.Range(2, 9) ) tack=0;  
            if(tack>9) tack =1;  
   
            //if the boat has high speed do not influence it (choppyness and wind)  
            //if it has lower then fact then influence it depending on the speed .  
            float fact = rrigidbody.velocity.magnitude * 0.02f;  
   
            //this code is quick and dirty  
            if(fact<1) {  
                float fact2 = 1-fact;  
                //if the object gets its position affected by the force of the choppy waves. Useful for smaller objects).  
                if(ChoppynessAffectsPosition) {  
                    if(!cvisible || visible) {  
                        if(ocean.choppy_scale>0) {  
                            if(moreAccurate) {  
                                if(tack==0) rrigidbody.AddForceAtPosition (-Vector3.left * (ocean.GetChoppyAtLocation2Fast() * ChoppynessFactor*Random.Range(0.5f,1.3f))*fact2, cpos);  
                                else rrigidbody.AddForceAtPosition (-Vector3.left * (ocean.GetChoppyAtLocation2Fast() * ChoppynessFactor*Random.Range(0.5f,1.3f))*fact2, transform.position);  
                            } else {  
                                if(tack==0) rrigidbody.AddForceAtPosition (-Vector3.left * (ocean.GetChoppyAtLocationFast() * ChoppynessFactor*Random.Range(0.5f,1.3f))*fact2, cpos);  
                                else rrigidbody.AddForceAtPosition (-Vector3.left * (ocean.GetChoppyAtLocationFast() * ChoppynessFactor*Random.Range(0.5f,1.3f))*fact2, transform.position);  
                            }  
                        }  
                    }  
                }  
                //if the object gets its position affected by the wind. Useful for smaller objects).  
                if(WindAffectsPosition) {  
                    if(!wvisible || visible) {  
                        if(tack==1) rrigidbody.AddForceAtPosition(new Vector3(ocean.pWindx, 0 , ocean.pWindy) * WindFactor*fact2, cpos);  
                        else rrigidbody.AddForceAtPosition(new Vector3(ocean.pWindx, 0 , ocean.pWindy) * WindFactor*fact2, transform.position);  
                    }  
                }  
            }  
   
            //the object will slide down a steep wave  
            //modify it to your own needs since it is a quick and dirty method.  
            if(xAngleAddsSliding) {  
                if(!svisible || visible) {  
                    float xangle = transform.localRotation.eulerAngles.x;  
                    currAngleX = (int)xangle;  
   
                    if(prevAngleX != currAngleX) {  
                           
                        float fangle=0f;  
   
                        if(xangle>270 && xangle<355) {  
                            fangle = (360-xangle)*0.1f;  
                            accel -= fangle* slideFactor; if(accel<-20) accel=-20;  
                            }  
   
                        if(xangle>5 && xangle<90) {  
                            fangle = xangle*0.1f;  
                            accel += fangle* slideFactor;  if(accel>20) accel=20;  
                        }  
   
                        prevAngleX = currAngleX;  
                    }  
                   
                    if((int)accel!=0) rrigidbody.AddRelativeForce (Vector3.forward * accel, ForceMode.Acceleration);  
                    if(accel>0) { accel-= 0.05f; if(accel<0) accel=0; }  
                    if(accel<0) { accel+= 0.05f; if(accel>0) accel=0; }  
                }  
            }  
   
        }  
    }  
   
   
    public void Sink(bool isActive) { sink = isActive; }  
   
    static float Lerp (float from, float to, float value) {  
        if (value < 0.0f) return from;  
        else if (value > 1.0f) return to;  
        return (to - from) * value + from;  
    }  
   
}

在Unity编辑器中的表现如下所示:
 
  运行效果如下所示:

  效果非常绚丽。。。。。。。。
  第四步,除了LOD海水网格渲染外,还提供了整个海水平面的渲染,以及可视化的界面操作,效果如下所示:
 
  在该操作界面中有OceanMaterial材质对应的Shader脚本,在这里就不一一列举了。
  第五步,多线程渲染主要是用于初始化海水网格以及算法的计算:

uocean.setThreads(2); 
             if (SystemInfo.processorCount == 1) uocean.setThreads(1); 
             //-------------------------------------------------------------------------------------------------------------------------------------------- 
             uocean.UoceanInit(width, height, pWindx, pWindy, speed, waveScale, choppy_scale, size.x, size.y, size.z, waveDistanceFactor); 
             uocean._calcComplex(data, t_x, Time.time, 0, height); 
             uocean._fft1(data); 
             uocean._fft2(t_x); 
             uocean._calcPhase3(data, t_x, vertices, baseHeight, normals, tangents, reflectionRefractionEnabled, canCheckBuoyancyNow, waveScale);
作者:海洋_

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值