Cesium源码解析二(terrain文件的加载、解析与渲染全过程梳理)

快速导航(持续更新中…)
Cesium源码解析一(搭建开发环境)
Cesium源码解析二(terrain文件的加载、解析与渲染全过程梳理)
Cesium源码解析三(metadataAvailability的含义)
Cesium源码解析四(metadata元数据拓展中行列号的分块规则解析)
Cesium源码解析五(Quantized-Mesh(.terrain)格式文件在CesiumJS和UE中加载情况的对比)

1.前言

  目前市场上三维比较火的实现方案主要有两种,b/s 的方案主要是 Cesiumc/s 的方案主要是 ueskylineunity 也占一部分份额)。他们分别对应的是 WebGLOpenGL,其最终都是通过 Shader 来实现的,通过图形学来实现的,所以又回到了代码界永远不变的真理,不论什么语言,修炼内功才是王道,修炼数据结构与算法才是王道。b/s 的好处显而易见,不用客户端怎么配置,但是缺点是对于大数据量的加载,存在性能瓶颈。而 c/s 的方案,主要是为了快,但是需要一堆环境配置。所以就可以解释了,为什么 UE 占主流,因为 UEC++ 写的,它的速度可比用 C# 写的 skylineunity 快多了。

2.本篇的由来

  本篇博文起源于我们加载 terrain 文件时遇到的一个问题,terrain文件不能正常解析。最开始是加载 terrain 会导致影像图层也出不来了,后来是影像出来了,但是地形一直出不来。因此,我们决定看一看源码,就有了这一篇博文。

3.terrain文件的加载

  这是我们这一篇的核心,因为这里面的代码量非常大,涉及的细节非常多,所以,最终我们要通过图形化的方式,来对这一过程进行逐步解析。我们首先来看一下terrain 文件的加载,代码非常简单:

 var terrain=new Cesium.CesiumTerrainProvider({
   url:"http://localhost:8090/geoserver/terrain/globe",//有水面
   requestVertexNormals : false,
   requestWaterMask : true,
 });  
 viewer.terrainProvider=terrain;

  接着,一步步的向下跟踪,我们就得到了这样一张总体调用流程图:
在这里插入图片描述
  由此,我们就得出了这样一个结论:Cesium 中的渲染,是由 startRenderLoop 这个函数来开启的,而 requestAnnimationFrame 的作用就是每一帧都去调用 render 函数,且 requestAnnimationFrame 能够保证渲染刷新的频率和浏览器的频率保持一致,当页面切换到后台时,就会停止渲染以提升性能。而 render 函数最终调用的地方则是 Scene.render ,这是整个渲染机制的控制中枢。我们来看看这个控制中枢都干了些什么:
  1.更新环境

scene.updateEnvironment();

  2.更新和执行渲染命令

scene.updateAndExecuteCommands();

  3.数据优化

scene.resolveFramebuffers();

  4.结束当前帧

scene.globe.endFrame();
Context.endFrame();

  看到这里,应该会发现,逻辑还是十分清晰的吧。但是细心的读者,应该会发现,第2步执行渲染命令,为什么会在第3步解析当前帧数据之前?第2步还没有数据呢,要渲染什么数据呢?我在这里先给出结论,后面我们会详细展开,结论就是:Cesium渲染的是上一帧的数据,因为上一帧的数据解析完成后,并没有真正的去执行,只是转为了 Shader 命令并加入到了渲染的队列中去,真正的去执行 Shader 命令,是在下一帧进行的。所以,Cesium 的渲染是具有滞后性的。

3.1 更新环境

  这一步的代码并不复杂,主要是为了更新天体和地球环境的影响。比如大气、天空、太阳、月亮,以及是否使用 WebVR 等,我们来看一下这个函数。

Scene.prototype.updateEnvironment = function () {
  var frameState = this._frameState;
  var view = this._view;

  // Update celestial and terrestrial environment effects.
  var environmentState = this._environmentState;
  var renderPass = frameState.passes.render;
  var offscreenPass = frameState.passes.offscreen;
  var skyAtmosphere = this.skyAtmosphere;
  var globe = this.globe;
  var globeTranslucencyState = this._globeTranslucencyState;

  if (
    !renderPass ||
    (this._mode !== SceneMode.SCENE2D &&
      view.camera.frustum instanceof OrthographicFrustum) ||
    !globeTranslucencyState.environmentVisible
  ) {
    environmentState.skyAtmosphereCommand = undefined;
    environmentState.skyBoxCommand = undefined;
    environmentState.sunDrawCommand = undefined;
    environmentState.sunComputeCommand = undefined;
    environmentState.moonCommand = undefined;
  } else {
    if (defined(skyAtmosphere)) {
      if (defined(globe)) {
        skyAtmosphere.setDynamicAtmosphereColor(
          globe.enableLighting && globe.dynamicAtmosphereLighting,
          globe.dynamicAtmosphereLightingFromSun
        );
        environmentState.isReadyForAtmosphere =
          environmentState.isReadyForAtmosphere ||
          globe._surface._tilesToRender.length > 0;
      }
      environmentState.skyAtmosphereCommand = skyAtmosphere.update(
        frameState,
        globe
      );
      if (defined(environmentState.skyAtmosphereCommand)) {
        this.updateDerivedCommands(environmentState.skyAtmosphereCommand);
      }
    } else {
      environmentState.skyAtmosphereCommand = undefined;
    }

    environmentState.skyBoxCommand = defined(this.skyBox)
      ? this.skyBox.update(frameState, this._hdr)
      : undefined;
    var sunCommands = defined(this.sun)
      ? this.sun.update(frameState, view.passState, this._hdr)
      : undefined;
    environmentState.sunDrawCommand = defined(sunCommands)
      ? sunCommands.drawCommand
      : undefined;
    environmentState.sunComputeCommand = defined(sunCommands)
      ? sunCommands.computeCommand
      : undefined;
    environmentState.moonCommand = defined(this.moon)
      ? this.moon.update(frameState)
      : undefined;
  }

  var clearGlobeDepth = (environmentState.clearGlobeDepth =
    defined(globe) &&
    globe.show &&
    (!globe.depthTestAgainstTerrain || this.mode === SceneMode.SCENE2D));
  var useDepthPlane = (environmentState.useDepthPlane =
    clearGlobeDepth &&
    this.mode === SceneMode.SCENE3D &&
    globeTranslucencyState.useDepthPlane);
  if (useDepthPlane) {
    // Update the depth plane that is rendered in 3D when the primitives are
    // not depth tested against terrain so primitives on the backface
    // of the globe are not picked.
    this._depthPlane.update(frameState);
  }

  environmentState.renderTranslucentDepthForPick = false;
  environmentState.useWebVR =
    this._useWebVR && this.mode !== SceneMode.SCENE2D && !offscreenPass;

  var occluder =
    frameState.mode === SceneMode.SCENE3D &&
    !globeTranslucencyState.sunVisibleThroughGlobe
      ? frameState.occluder
      : undefined;
  var cullingVolume = frameState.cullingVolume;

  // get user culling volume minus the far plane.
  var planes = scratchCullingVolume.planes;
  for (var k = 0; k < 5; ++k) {
    planes[k] = cullingVolume.planes[k];
  }
  cullingVolume = scratchCullingVolume;

  // Determine visibility of celestial and terrestrial environment effects.
  environmentState.isSkyAtmosphereVisible =
    defined(environmentState.skyAtmosphereCommand) &&
    environmentState.isReadyForAtmosphere;
  environmentState.isSunVisible = this.isVisible(
    environmentState.sunDrawCommand,
    cullingVolume,
    occluder
  );
  environmentState.isMoonVisible = this.isVisible(
    environmentState.moonCommand,
    cullingVolume,
    occluder
  );

  var envMaps = this.specularEnvironmentMaps;
  var envMapAtlas = this._specularEnvironmentMapAtlas;
  if (
    defined(envMaps) &&
    (!defined(envMapAtlas) || envMapAtlas.url !== envMaps)
  ) {
    envMapAtlas = envMapAtlas && envMapAtlas.destroy();
    this._specularEnvironmentMapAtlas = new OctahedralProjectedCubeMap(envMaps);
  } else if (!defined(envMaps) && defined(envMapAtlas)) {
    envMapAtlas.destroy();
    this._specularEnvironmentMapAtlas = undefined;
  }

  if (defined(this._specularEnvironmentMapAtlas)) {
    this._specularEnvironmentMapAtlas.update(frameState);
  }
};

3.2 更新和执行渲染命令

  这一步的代码量是很大的,但主要的核心思想就是会去执行当前帧对象 frameStatecommandList 中的多个或一个 DrawCommand。这一步的执行过程我们可以用这样一张图来表示:
在这里插入图片描述
  通过上图我们可以发现,最终是在 Context.prototype.draw() 函数中去执行 beginDraw()continueDraw() 方法来实现执行 WebGLShader 命令的。
  然后在执行到 updateAndRenderPrimitives(); 时又会去走另外一个分支,此时,上面这样图就变成了这样:
在这里插入图片描述
  发现了什么?这里会根据当前帧对象 frameState 去选择要渲染哪些切片,然后再去创建命令。这两部分别对应的是图中加黑的两行,即 selectTilesForRendering(this, frameState);createRenderCommandsForSelectedTiles(this, frameState); 然而,这样还没完,在执行 selectTilesForRendering(this, frameState); 时,还会去走一个分支,上面这样图的左边就会变成:
在这里插入图片描述
  所以,当 tile 可见时,就会被加入到渲染队列中去,等待后面的渲染,否则就会进入加载队列等待加载。

3.3 数据优化

  这一步主要是判断是否使用 OIT(半透明渲染算法)、全球深度和后期处理,来进行一系列的优化,代码也不复杂,我们来看一下。

Scene.prototype.resolveFramebuffers = function (passState) {
  var context = this._context;
  var environmentState = this._environmentState;
  var view = this._view;
  var globeDepth = view.globeDepth;

  var useOIT = environmentState.useOIT;
  var useGlobeDepthFramebuffer = environmentState.useGlobeDepthFramebuffer;
  var usePostProcess = environmentState.usePostProcess;

  var defaultFramebuffer = environmentState.originalFramebuffer;
  var globeFramebuffer = useGlobeDepthFramebuffer
    ? globeDepth.framebuffer
    : undefined;
  var sceneFramebuffer = view.sceneFramebuffer.getFramebuffer();
  var idFramebuffer = view.sceneFramebuffer.getIdFramebuffer();

  if (environmentState.separatePrimitiveFramebuffer) {
    // Merge primitive framebuffer into globe framebuffer
    globeDepth.executeMergeColor(context, passState);
  }

  if (useOIT) {
    passState.framebuffer = usePostProcess
      ? sceneFramebuffer
      : defaultFramebuffer;
    view.oit.execute(context, passState);
  }

  var translucentTileClassification = view.translucentTileClassification;
  if (
    translucentTileClassification.hasTranslucentDepth &&
    translucentTileClassification.isSupported()
  ) {
    translucentTileClassification.execute(this, passState);
  }

  if (usePostProcess) {
    var inputFramebuffer = sceneFramebuffer;
    if (useGlobeDepthFramebuffer && !useOIT) {
      inputFramebuffer = globeFramebuffer;
    }

    var postProcess = this.postProcessStages;
    var colorTexture = inputFramebuffer.getColorTexture(0);
    var idTexture = idFramebuffer.getColorTexture(0);
    var depthTexture = defaultValue(globeFramebuffer, sceneFramebuffer)
      .depthStencilTexture;
    postProcess.execute(context, colorTexture, depthTexture, idTexture);
    postProcess.copy(context, defaultFramebuffer);
  }

  if (!useOIT && !usePostProcess && useGlobeDepthFramebuffer) {
    passState.framebuffer = defaultFramebuffer;
    globeDepth.executeCopyColor(context, passState);
  }
};

这一步让人难以理解的是,干的工作都是优化,但是函数名翻译过来却叫做解析当前帧数据,这就令人费解了,因为真正的解析 terrain 数据,是在下一步干的,真是令人百思不得其解。

3.4 结束当前帧

  这一步是重点中的重点,因为会在这一步去解析 terrain 文件,在此之前,我们先来看下这一步调用的流程图:
在这里插入图片描述
  可以看到这一步可以简单概括为三行代码,处理切片加载队列、更新高度、更新切片加载过程。但是图中的第一行代码又进行了其他的一系列操作,其中就有我们非常关心的 terrain 文件的解析,所以,这张图就变成了这样:
在这里插入图片描述
  可以看到最后一步就是去创建 terrain 数据对象,这种数据类型是 Cesium 定义的,类型就叫做 QuantizedMesh。那么重点就来了,我们就是要看看它到底是怎么解析的,直接上代码:

function createQuantizedMeshTerrainData(provider, buffer, level, x, y, layer) {
  var littleEndianExtensionSize = layer.littleEndianExtensionSize;
  var pos = 0;
  var cartesian3Elements = 3;
  var boundingSphereElements = cartesian3Elements + 1;
  var cartesian3Length = Float64Array.BYTES_PER_ELEMENT * cartesian3Elements;
  var boundingSphereLength =
    Float64Array.BYTES_PER_ELEMENT * boundingSphereElements;
  var encodedVertexElements = 3;
  var encodedVertexLength =
    Uint16Array.BYTES_PER_ELEMENT * encodedVertexElements;
  var triangleElements = 3;
  var bytesPerIndex = Uint16Array.BYTES_PER_ELEMENT;
  var triangleLength = bytesPerIndex * triangleElements;

  var view = new DataView(buffer);
  //中心
  var center = new Cartesian3(
    view.getFloat64(pos, true),
    view.getFloat64(pos + 8, true),
    view.getFloat64(pos + 16, true)
  );
  pos += cartesian3Length;
  //最大高度最小高度
  var minimumHeight = view.getFloat32(pos, true);
  pos += Float32Array.BYTES_PER_ELEMENT;
  var maximumHeight = view.getFloat32(pos, true);
  pos += Float32Array.BYTES_PER_ELEMENT;

  //外接球面
  var boundingSphere = new BoundingSphere(
    new Cartesian3(
      view.getFloat64(pos, true),
      view.getFloat64(pos + 8, true),
      view.getFloat64(pos + 16, true)
    ),
    view.getFloat64(pos + cartesian3Length, true)
  );
  pos += boundingSphereLength;
  //水平遮挡点
  var horizonOcclusionPoint = new Cartesian3(
    view.getFloat64(pos, true),
    view.getFloat64(pos + 8, true),
    view.getFloat64(pos + 16, true)
  );
  pos += cartesian3Length;
  //顶点数量
  var vertexCount = view.getUint32(pos, true);
  pos += Uint32Array.BYTES_PER_ELEMENT;
  var encodedVertexBuffer = new Uint16Array(buffer, pos, vertexCount * 3);
  pos += vertexCount * encodedVertexLength;

  if (vertexCount > 64 * 1024) {
    // More than 64k vertices, so indices are 32-bit.
    bytesPerIndex = Uint32Array.BYTES_PER_ELEMENT;
    triangleLength = bytesPerIndex * triangleElements;
  }

  // Decode the vertex buffer.
  var uBuffer = encodedVertexBuffer.subarray(0, vertexCount);
  var vBuffer = encodedVertexBuffer.subarray(vertexCount, 2 * vertexCount);
  var heightBuffer = encodedVertexBuffer.subarray(
    vertexCount * 2,
    3 * vertexCount
  );

  AttributeCompression.zigZagDeltaDecode(uBuffer, vBuffer, heightBuffer);

  // skip over any additional padding that was added for 2/4 byte alignment
  if (pos % bytesPerIndex !== 0) {
    pos += bytesPerIndex - (pos % bytesPerIndex);
  }
  //三角形的数量
  var triangleCount = view.getUint32(pos, true);
  pos += Uint32Array.BYTES_PER_ELEMENT;
  var indices = IndexDatatype.createTypedArrayFromArrayBuffer(
    vertexCount,
    buffer,
    pos,
    triangleCount * triangleElements
  );
  pos += triangleCount * triangleLength;

  // High water mark decoding based on decompressIndices_ in webgl-loader's loader.js.
  // https://code.google.com/p/webgl-loader/source/browse/trunk/samples/loader.js?r=99#55
  // Copyright 2012 Google Inc., Apache 2.0 license.
  var highest = 0;
  var length = indices.length;
  for (var i = 0; i < length; ++i) {
    var code = indices[i];
    indices[i] = highest - code;
    if (code === 0) {
      ++highest;
    }
  }
  //东南西北顶点的解析
  var westVertexCount = view.getUint32(pos, true);
  pos += Uint32Array.BYTES_PER_ELEMENT;
  var westIndices = IndexDatatype.createTypedArrayFromArrayBuffer(
    vertexCount,
    buffer,
    pos,
    westVertexCount
  );
  pos += westVertexCount * bytesPerIndex;

  var southVertexCount = view.getUint32(pos, true);
  pos += Uint32Array.BYTES_PER_ELEMENT;
  var southIndices = IndexDatatype.createTypedArrayFromArrayBuffer(
    vertexCount,
    buffer,
    pos,
    southVertexCount
  );
  pos += southVertexCount * bytesPerIndex;

  var eastVertexCount = view.getUint32(pos, true);
  pos += Uint32Array.BYTES_PER_ELEMENT;
  var eastIndices = IndexDatatype.createTypedArrayFromArrayBuffer(
    vertexCount,
    buffer,
    pos,
    eastVertexCount
  );
  pos += eastVertexCount * bytesPerIndex;

  var northVertexCount = view.getUint32(pos, true);
  pos += Uint32Array.BYTES_PER_ELEMENT;
  var northIndices = IndexDatatype.createTypedArrayFromArrayBuffer(
    vertexCount,
    buffer,
    pos,
    northVertexCount
  );
  pos += northVertexCount * bytesPerIndex;

  var encodedNormalBuffer;
  var waterMaskBuffer;
  
  while (pos < view.byteLength) {
    var extensionId = view.getUint8(pos, true);
    pos += Uint8Array.BYTES_PER_ELEMENT;
    var extensionLength = view.getUint32(pos, littleEndianExtensionSize);
    pos += Uint32Array.BYTES_PER_ELEMENT;
    console.log( level, x, y,"extensionid="+extensionId+",extensionLength="+extensionLength+",byteLength="+view.byteLength);
    if (
      extensionId === QuantizedMeshExtensionIds.OCT_VERTEX_NORMALS &&
      provider._requestVertexNormals
    ) {
      //数据中有光照并且也请求了光照
      encodedNormalBuffer = new Uint8Array(buffer, pos, vertexCount * 2);
    } else if (
      extensionId === QuantizedMeshExtensionIds.WATER_MASK &&
      provider._requestWaterMask
    ) {
      //数据中有水面并且也请求了水面
      waterMaskBuffer = new Uint8Array(buffer, pos, extensionLength);
    } else if (
      extensionId === QuantizedMeshExtensionIds.METADATA &&
      provider._requestMetadata
    ) {
      //数据中有元数据并且也请求了元数据
      var stringLength = view.getUint32(pos, true);
      console.log("metadata_length="+stringLength);
      if (stringLength > 0) {
        var metadata = getJsonFromTypedArray(
          new Uint8Array(buffer),
          pos + Uint32Array.BYTES_PER_ELEMENT,
          stringLength
        );
        var availableTiles = metadata.available;

        // console.log("availableTiles="+availableTiles);

        if(level==0 &&  x==1 &&  y==0){
          if(sessionStorage.getItem("str010")){
            availableTiles=JSON.parse(sessionStorage.getItem("str010"))
          }else{
            sessionStorage.setItem("str010",JSON.stringify(availableTiles));
          }
        }
        if(level==0 &&  x==0 &&  y==0){
          if(sessionStorage.getItem("str000")){
            availableTiles=JSON.parse(sessionStorage.getItem("str000"))
          }else{
            sessionStorage.setItem("str000",JSON.stringify(availableTiles));
          }
          
        }

        // if(level % 10 !=0) availableTiles=undefined;
        if (defined(availableTiles)) {
          for (var offset = 0; offset < availableTiles.length; ++offset) {
            var availableLevel = level + offset + 1;
            var rangesAtLevel = availableTiles[offset];
            var yTiles = provider._tilingScheme.getNumberOfYTilesAtLevel(
              availableLevel
            );

            for (
              var rangeIndex = 0;
              rangeIndex < rangesAtLevel.length;
              ++rangeIndex
            ) {
              var range = rangesAtLevel[rangeIndex];
              var yStart = yTiles - range.endY - 1;
              var yEnd = yTiles - range.startY - 1;
              provider.availability.addAvailableTileRange(
                availableLevel,
                range.startX,
                yStart,
                range.endX,
                yEnd
              );
              layer.availability.addAvailableTileRange(
                availableLevel,
                range.startX,
                yStart,
                range.endX,
                yEnd
              );
            }
          }
        }
      }
      layer.availabilityTilesLoaded.addAvailableTileRange(level, x, y, x, y);
    }
    pos += extensionLength;
  }
  //裙摆高度
  var skirtHeight = provider.getLevelMaximumGeometricError(level) * 5.0;

  // The skirt is not included in the OBB computation. If this ever
  // causes any rendering artifacts (cracks), they are expected to be
  // minor and in the corners of the screen. It's possible that this
  // might need to be changed - just change to `minimumHeight - skirtHeight`
  // A similar change might also be needed in `upsampleQuantizedTerrainMesh.js`.
  var rectangle = provider._tilingScheme.tileXYToRectangle(x, y, level);
  var orientedBoundingBox = OrientedBoundingBox.fromRectangle(
    rectangle,
    minimumHeight,
    maximumHeight,
    provider._tilingScheme.ellipsoid
  );

  return new QuantizedMeshTerrainData({
    center: center,
    minimumHeight: minimumHeight,
    maximumHeight: maximumHeight,
    boundingSphere: boundingSphere,
    orientedBoundingBox: orientedBoundingBox,
    horizonOcclusionPoint: horizonOcclusionPoint,
    quantizedVertices: encodedVertexBuffer,
    encodedNormals: encodedNormalBuffer,
    indices: indices,
    westIndices: westIndices,
    southIndices: southIndices,
    eastIndices: eastIndices,
    northIndices: northIndices,
    westSkirtHeight: skirtHeight,
    southSkirtHeight: skirtHeight,
    eastSkirtHeight: skirtHeight,
    northSkirtHeight: skirtHeight,
    childTileMask: provider.availability.computeChildMaskForTile(level, x, y),
    waterMask: waterMaskBuffer,
    credits: provider._tileCredits,
  });
}

  重要的地方都加上中文注释了,仔细看上面的代码,可以发现其中包括水面、光照、元数据等的解析,都是在一个 while 循环中完成的。至此,我们的数据也就解析完了。
  最后我们来看看贯穿了所有过程的 frameState 对象,他怎么引用的:
在这里插入图片描述
  用一句话总结就是 frameState 对象中的 commandList 中的 Command 对象引用了 tile 对象,这样我们的整个从数据解析到渲染的过程就都串联起来了。

4. 总结

  本文通过梳理 terrain 文件的加载、解析、渲染过程,基本理清了整个 Cesium 的渲染过程,只是最后的 WebGLShader 命令没有进行深入,留待后续完善。这一块需要计算题图形学的知识,现在三维方面,不论是 UE 还是 Cesium ,都要计算机图形学的相关知识储备才能进行深入探讨。本文是作者看了好多天源码整理出来的,由于个人水平有限,其中难免有一些理解不足之处,欢迎读者指正。

  • 12
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 5
    评论
提供的源码资源涵盖了安卓应用、小程序、Python应用和Java应用等多个领域,每个领域都包含了丰富的实例和项目。这些源码都是基于各自平台的最新技术和标准编写,确保了在对应环境下能够无缝运行。同时,源码中配备了详细的注释和文档,帮助用户快速理解代码结构和实现逻辑。 适用人群: 这些源码资源特别适合大学生群体。无论你是计算机相关专业的学生,还是对其他领域编程感兴趣的学生,这些资源都能为你提供宝贵的学习和实践机会。通过学习和运行这些源码,你可以掌握各平台开发的基础知识,提升编程能力和项目实战经验。 使用场景及目标: 在学习阶段,你可以利用这些源码资源进行课程实践、课外项目或毕业设计。通过分析和运行源码,你将深入了解各平台开发的技术细节和最佳实践,逐步培养起自己的项目开发和问题解决能力。此外,在求职或创业过程中,具备跨平台开发能力的大学生将更具竞争力。 其他说明: 为了确保源码资源的可运行性和易用性,特别注意了以下几点:首先,每份源码都提供了详细的运行环境和依赖说明,确保用户能够轻松搭建起开发环境;其次,源码中的注释和文档都非常完善,方便用户快速上手和理解代码;最后,我会定期更新这些源码资源,以适应各平台技术的最新发展和市场需求。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AIGIS.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值