MENU

【代码札记】使用 LWJGL 和 OpenGL 更优雅的画圆

April 7, 2021 • 瞎折腾

上一篇文章记录了我初学 OpenGL,使用 LWJGL 在 JVM 上试图画圆的过程。虽然结果很成功,甚至还锦上添花地弄了一个五彩斑斓的圆,但随着更加深入的学习,我了解到上一篇文章中的代码应该算得上是 OpenGL 中最低效的绘图方法。因此本文将试图使用 VBO 改进代码的效率。

前言展开目录

为什么上一篇代码就那么低效呢?原因就在于 Fixed Pipeline 中提供的绘制函数与 GPU 内存的交互方式上。glVertex2f() 函数能够将我们定义的顶点坐标写入到显卡内存,这样后续的流水线作业就可以在显卡上并行执行以加速运算速度。但是这个函数有一个问题:我们每调用一次这个函数,它就将坐标数据写给显卡,这样做是很低效的。一般而言最佳的做法是集中一批数据,然后统一送给显卡,这样更能充分发挥 PCIe 通道的并行性。

OpenGL 在一开始提供了 Fixed Pipeline,大概是因为便于上手,虽然还是比较繁琐,但至少还能提供一些易于理解的地方。倘若上来就从窗口跳到 VBO、VAO 和着色器(例如目前 OpenGL 的教程),怕不是要劝退好多人。不过由于这种固定的流水线效率低局限多,在 OpenGL core 3.0 的更高版本已经被废弃了。如果你需要使用 OpenGL3.0 的特性,那么上一篇文章的代码实际是不能运行的。但目前使用的 LWJGL 库使用的 OpenGL 版本是 3.2.3,为什么还能不报错运行呢?也许有细心的读者已经注意到了,我们在上一篇文章中的 import 中有 GL11GL15 的字样,意味着我们实际使用的是 OpenGL1.1 和 1.5 时期的特性。目前为止 OpenGL 分为两个部分,在 OpenGL 3.0 时期引入,将原本的 OpenGL 一分为二:一部分是 OpenGL core,如字面意思,它只包含本版本应该支持的特性(目前来看就是程序只能通过 Shader 编写,而不能使用固定流水线),如果试图在 core 中调用已经移除的接口,OpenGL 将会产生错误;而另一部分则是 Compatibility Profile,它负责实现所有过去的接口(我们调用的固定流水线的函数实际是在这里实现的),以便保证使用以前版本构建的代码能够在更新版本上顺利运行。

就目前而言,我们距离着色器还差一些。如开头所言,本文的目的是要将代码改写成将数据按批次送给显卡,而不是你给一个坐标我就立刻送一个。

使用 VBO 展开目录

VBO 全称 Vertex Buffer Objects,翻译成中文是「顶点缓冲对象」,该对象负责管理一部分 GPU 内存,在这部分 GPU 内存中存储大量的顶点数据,便于让 CPU 一次能够发送大量数据,使得程序更有效率。

创建 VBO 展开目录

如何使用这个 VBO 呢?首先得有一个:

  • // prepare our vertex buffer, VBO
  • val vertexVBO = glGenBuffers()

然而这样只是让 OpenGL 在显存中开辟了内存空间,我们的 Java 程序是没办法直接使用的,因此还需要再 Java 程序中开辟一个内存空间。LWJGL 使用 Java NIO 的 Buffer 来进行数据交换:程序将数据写入 Java NIO Buffer,然后调用 glBufferData 将给定 Buffer 中的数据复制到 VBO 管理的显存中。

创建 NIO Buffer 展开目录

于是我们还需要一个 NIO Buffer:

  • // float buffer used to write into the vbo
  • var floatBuffer: FloatBuffer

关于这个 NIO Buffer 还有一个使用的问题:

向 Buffer 中写入数据主要有两种方法:

  • put(float[] src)
  • put(int index, float f)

这两种方法虽然都能够把数据写入到 Buffer 中,但产生的副作用不同。控制 Buffer 读写的内部变量主要有两个:limitposition。直接将数组写入 Buffer,将会同时更新这两个参数,于是写入数据后 position 总是指向最后的写入数据,如果这个时候把 Buffer 丢给 glBufferData 给 OpenGL,那么它读不到前面写入的数据,为了解决这个问题,Buffer 有一个 flip() 函数,这个函数的作用就是将 limit 设定为当前的 position 的值,然后把 position 的值清零,这样再交给 OpenGL 读就可以正常读到刚刚写入的数据了。所以一般经常把第一种方法和 flip() 函数配合使用。

如果使用第二种方式写入数据,则二者都不会修改,于是需要调用者手动根据写入的值的个数和开始写时 Buffer 的状态,手动更新其参数,一般而言比较麻烦,但好处是不需要另外将数据写入数组,再将数组写入 Buffer。本文为了省事儿,将使用第二种方法,如果读者使用第一种方法,请务必不要忘记在 put 之后调用 flip

由于 segements 会动态改变,所以 Buffer 的大小不能定死,需要根据当前的值改变。于是修改渲染循环:

  • // the actual render loop
  • while (!glfwWindowShouldClose(windowHandler)) {
  • // poll window event, key handlers will only be called during this time
  • glfwPollEvents()
  • // clear color buffer
  • glClear(GL_COLOR_BUFFER_BIT)
  • // generating buffer data
  • floatBuffer = BufferUtils.createFloatBuffer(segments * 2).clear()
  • for (i in 0 until segments) {
  • val theta = 2 * PI * i / segments
  • val x = radius * cos(theta)
  • val y = radius * sin(theta)
  • // (x, y)
  • floatBuffer.put(i * 2, x.toFloat())
  • floatBuffer.put(i * 2 + 1, y.toFloat())
  • }
  • // TODO send data info buffer object
  • // TODO render the data
  • //swap color buffer, means that next frame is done
  • glfwSwapBuffers(windowHandler)
  • }

由于每次渲染都会创建一个全新的 FloatBuffer,因此它的 position 就是 0,而 limit 是创建 Buffer 时指定的大小。这里我们使用 (x, y) 坐标,因此每个顶点占用两个元素,所以 Buffer 大小是 segments * 2,如果使用 (x, y, z) 坐标,那么每个顶点占用三个元素,需要改成 * 3。对应的第 $i$ 个元素的 $x$ 坐标对应索引 $2 i$,$y$ 坐标对应索引 $2i + 1$。

将 Buffer 数据写入 VBO 展开目录

至此我们需要的数据在内存中全部准备妥当。我们需要将这些数据送到显存,这样显卡才能帮我们渲染出实际的图形。好在将数据送到显卡的步骤并不复杂:

  • // send data info buffer object
  • // select buffer
  • glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)
  • // write data from cpu buffer into gpu buffer
  • glBufferData(GL_ARRAY_BUFFER, floatBuffer, GL_DYNAMIC_DRAW)
  • // unbinding buffer
  • glBindBuffer(GL_ARRAY_BUFFER, 0)

由于 OpenGL 是一个大的状态机,我们不能直接把 VBO 的 id 和数据扔给他就完事儿了,事情会琐碎一些:首先通过 glBindBuffer 将之前生成的 VBO 通过编号与 GL_ARRAY_BUFFER 绑定,OpenGL 内部有好多种 Buffer,每一种决定不同的渲染数据,其中 GL_ARRAY_BUFFER 负责存储顶点的属性(即坐标数据)。随后通过 glBufferData 告诉 OpenGL 我们要把哪种数据写到哪一个 Buffer 中,最后一个参数则指定这个 Buffer 有什么样的读写属性,一般而言我们都是用 GL_DYNAMIC_DRAW,表示其中的数据可能被读写多次,并用于绘制图形。

最后操作完成后要解绑 VBO,不然其他线程中的操作可能会影响到已经写入的数据。

使用 VBO 进行渲染展开目录

顶点数据已经被送到显存中,现在只需要告诉 OpenGL 用这些数据渲染就行了。首先还是老规矩,要先绑定 VBO 并启用顶点数组:

  • // render the data
  • glEnableClientState(GL_VERTEX_ARRAY)
  • // select buffer
  • glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)

之后我们需要告诉 OpenGL 数据在 Buffer 中是如何组织的:

  • glVertexPointer(2, GL_FLOAT, 0, NULL)

第一个参数表示每个顶点占用 2 个元素,第二个参数表示每个元素是单精度浮点数,第三个参数表示从相邻元素之间相隔 0 个元素,即大家都是挨着的。最后一个元素为空指针,因为我们已经把数据提前写入 VBO 了,所以这里的指针给空指针即可。至此 OpenGL 已经完成了顶点信息的解读,然后就可以开始调用流水线绘制图形了。值得一提的是这里我们仍然使用的 Fixed Pipeline,因为我们尚且没有实现任何着色器,着色器部分预计将在下一篇文章中实现,现在先来将图形渲染出来:

  • // draw the vertexes as TRIANGLE FAN
  • // vertexes data start from index 0 and there are $segments vertexes
  • glDrawArrays(GL_TRIANGLE_FAN, 0, segments)
  • // done with buffer
  • glBindBuffer(GL_ARRAY_BUFFER, 0)
  • // end render
  • glDisableClientState(GL_VERTEX_ARRAY)

调用 glDrawArrays,让 OpenGL 以 GL_TRIANGLE_FAN 的方式从第 0 个顶点开始绘制。然后记得解绑 VBO,关闭 GL_VERTEX_ARRAY。最后再调用 glfwSwapBuffers 来呈现本次渲染结果。

于是最终渲染循环的代码变成了这个样子:

  • // the actual render loop
  • while (!glfwWindowShouldClose(windowHandler)) {
  • // poll window event, key handlers will only be called during this time
  • glfwPollEvents()
  • // clear color buffer
  • glClear(GL_COLOR_BUFFER_BIT)
  • // generating buffer data
  • floatBuffer = BufferUtils.createFloatBuffer(segments * 2).clear()
  • for (i in 0 until segments) {
  • val theta = 2 * PI * i / segments
  • val x = radius * cos(theta)
  • val y = radius * sin(theta)
  • // (x, y)
  • floatBuffer.put(i * 2, x.toFloat())
  • floatBuffer.put(i * 2 + 1, y.toFloat())
  • }
  • floatBuffer.position(0)
  • // send data info buffer object
  • // select buffer
  • glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)
  • // write data from cpu buffer into gpu buffer
  • glBufferData(GL_ARRAY_BUFFER, floatBuffer, GL_DYNAMIC_DRAW)
  • // unbinding buffer
  • glBindBuffer(GL_ARRAY_BUFFER, 0)
  • // render the data
  • glEnableClientState(GL_VERTEX_ARRAY)
  • // select buffer
  • glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)
  • // size = 2 -> (x,y)
  • // size = 3 -> (x, y, z)
  • // stride = 0 -> data are sequential
  • glVertexPointer(2, GL_FLOAT, 0, NULL)
  • // draw the vertexes as TRIANGLE FAN
  • // vertexes data start from index 0 and there are $segments vertexes
  • glDrawArrays(GL_TRIANGLE_FAN, 0, segments)
  • // done with buffer
  • glBindBuffer(GL_ARRAY_BUFFER, 0)
  • // end render
  • glDisableClientState(GL_VERTEX_ARRAY)
  • //swap color buffer, means that next frame is done
  • glfwSwapBuffers(windowHandler)
  • }

不使用着色器给图形添加颜色展开目录

以上代码跑起来之后画的圆是白色的,由于我们没有指定任何颜色信息,因此画出来的圆就是白的。我看网上好多教程都是通过片段着色器给渲染出来的图形加颜色,我想这样未免有些云山雾绕。经过一番搜索与摸索,使用 VBO 同样可以达到上一次文章结尾的效果。只需要再另高一个存储颜色的 VBO 即可:

  • val colorVBO = glGenBuffers()
  • var colorBuffer: FloatBuffer

然后在渲染时计算颜色,计算方法与上文一致:

  • colorBuffer = BufferUtils.createFloatBuffer(segments * 3).clear()
  • for (i in 0 until segments) {
  • val theta = 2 * PI * i / segments
  • // ...
  • colorBuffer.put(i * 3, cos(theta).toFloat())
  • colorBuffer.put(i * 3 + 1, sin(theta).toFloat())
  • colorBuffer.put(i * 3 + 2, i / segments.toFloat())
  • }

最后记得把数据送到对应的 VBO 当中:

  • // select buffer
  • glBindBuffer(GL_ARRAY_BUFFER, colorVBO)
  • // write data from cpu buffer into gpu buffer
  • glBufferData(GL_ARRAY_BUFFER, colorBuffer, GL_DYNAMIC_DRAW)
  • // unbinding buffer
  • glBindBuffer(GL_ARRAY_BUFFER, 0)

然后在渲染时使用它即可:

  • // render the data
  • glEnableClientState(GL_VERTEX_ARRAY)
  • glEnableClientState(GL_COLOR_ARRAY)
  • // select buffer
  • glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)
  • // size = 2 -> (x,y)
  • // size = 3 -> (x, y, z)
  • // stride = 0 -> data are sequential
  • glVertexPointer(2, GL_FLOAT, 0, NULL)
  • glBindBuffer(GL_ARRAY_BUFFER, colorVBO)
  • glColorPointer(3, GL_FLOAT, 0, NULL)
  • // draw the vertexes as TRIANGLE FAN
  • // vertexes data start from index 0 and there are $segments vertexes
  • glDrawArrays(GL_TRIANGLE_FAN, 0, segments)
  • // done with buffer
  • glBindBuffer(GL_ARRAY_BUFFER, 0)
  • // end render
  • glDisableClientState(GL_VERTEX_ARRAY)
  • glDisableClientState(GL_COLOR_ARRAY)

这里我们同时启用了 GL_VERTEX_ARRAYGL_COLOR_ARRAY,通过先绑定(glBindBuffer)并解析(glVertexPotiner)完成了对顶点数据的解读,然后再绑定存储颜色的 VBO:glBindBuffer(GL_ARRAY_BUFFER, colorVBO),最后使用 glColorPointer 解析颜色数据,由于每个顶点的颜色有 3 个分量(对应 RGB),所以第一个参数是 3,其余都不变。

设置好全部的状态之后,调用 glDrawArrays 即可绘制出带颜色的圆了。由于外观上和上文一致,所以这里就不重复放图了。

衡量不同实现下的 FPS 展开目录

其实纵观本文,代码做的事情没有变,都是以同样的方法绘制同样的圆,但不一样的地方在于我们传送数据从一点一传变成了一帧一传。使用 glVertex3f,每次计算出一个点的数据,CPU 就会停下来将这个数据发送给显卡;而本文的代码则改进为计算出一批数据后,CPU 只停下一次就可以把数据送给显卡了。具体地说,这种性能提升到底有多少呢?

下面我们简单实现一个衡量 FPS 的代码。这个代码很简陋,只是通过两帧之间的时间差计算出瞬时 FPS,故以下测试数据也是凭我肉眼在控制台如洪水般的输出中瞪出来的,不具有科学性,但具有代表性。

我们先来看代码:

  • var time = glfwGetTime()
  • // the actual render loop
  • while (!glfwWindowShouldClose(windowHandler)) {
  • val currentTime = glfwGetTime()
  • val fps = 1.0 / (currentTime - time)
  • // to measurer the fps
  • logger.info("FPS: $fps")
  • time = currentTime
  • // ...
  • }

应该还挺简单易懂的,其中 glfwGetTime 返回一个时间,这个时间是以秒衡量的 Double 类型数据,不一定对应现实的时间,但保证在没有人为修改时总是对应现实的时间流逝。于是我们通过计算两次渲染时的时间差,便可以知道当下的 FPS。

测的结果如下:

条件最大 FPS 最小 FPS 平均 FPS
使用 glVertex 无色渲染 5.03.94.6
使用 VBO 无色渲染 8.65.97.5
使用 VBO 有色渲染 5.43.55.4

测试时使用的 segments 值为 3000000(三百万分段)。

本文完整代码展开目录

  • package info.skyblond.antSimu
  • import info.skyblond.antSimu.utils.opengl.glfmCreateSlf4jLogger
  • import org.lwjgl.BufferUtils
  • import org.lwjgl.Version
  • import org.lwjgl.glfw.Callbacks.glfwFreeCallbacks
  • import org.lwjgl.glfw.GLFW.*
  • import org.lwjgl.opengl.GL
  • import org.lwjgl.opengl.GL21.*
  • import org.lwjgl.system.MemoryUtil.NULL
  • import org.slf4j.Logger
  • import org.slf4j.LoggerFactory
  • import java.nio.FloatBuffer
  • import kotlin.math.*
  • private val logger: Logger = LoggerFactory.getLogger("Application")
  • var windowHandler: Long = 0
  • // radius of the circle
  • var radius = 0.75
  • // how fine the circle should be
  • var segments: Int = 30e5.toInt()
  • /**
  • * init external openGL, create a window and return the handler
  • * */
  • fun init() {
  • // GLFW stands for Graphic Library FrameWork
  • // First set a error callback, the default implementation
  • glfmCreateSlf4jLogger(logger).set()
  • // do init things
  • require(glfwInit()) { "Unable to initialize GLFW" }
  • // set window hint to default
  • // window hint is needed for window creation
  • glfwDefaultWindowHints()
  • // hide window after creation
  • glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE)
  • // disable resize on window
  • glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE)
  • // create window
  • windowHandler = glfwCreateWindow(
  • 1024, 1024,
  • "Hello world!",
  • NULL, NULL // windowed mode and don't share resource with others
  • )
  • // check windowHandler to ensure window is created
  • require(windowHandler != NULL) { "Failed to create the GLFW window" }
  • // set a key handler for key events
  • // scan code is ignored since it only used for unknown keys
  • // mods is used to identify shift, ctrl, alt .etc
  • glfwSetKeyCallback(windowHandler) { window, key, _, action, _ ->
  • // logger.info("Logged key event: key: $key, action: $action")
  • // close window if esc if pressed and released
  • if (key == GLFW_KEY_ESCAPE && action == GLFW_PRESS)
  • glfwSetWindowShouldClose(window, true)
  • else if (key == GLFW_KEY_W && action != GLFW_RELEASE)
  • radius = min(1.0, radius + 0.01)
  • else if (key == GLFW_KEY_S && action != GLFW_RELEASE)
  • radius = max(0.01, radius - 0.01)
  • else if (key == GLFW_KEY_A && action != GLFW_RELEASE)
  • segments = max(3, segments - 1)
  • else if (key == GLFW_KEY_D && action != GLFW_RELEASE)
  • // if case causing performance issue, limit segments up to 10e5
  • segments = min(Int.MAX_VALUE - 1, segments + 1)
  • // this should close signal will handled in rendering loop
  • }
  • // Get the resolution of the main monitor
  • val videoMode = glfwGetVideoMode(glfwGetPrimaryMonitor())!!
  • val pWidth = BufferUtils.createIntBuffer(1)
  • val pHeight = BufferUtils.createIntBuffer(1)
  • // Get the window size passed to glfwCreateWindow
  • glfwGetWindowSize(windowHandler, pWidth, pHeight)
  • // Center the window
  • glfwSetWindowPos(
  • windowHandler,
  • (videoMode.width() - pWidth[0]) / 2,
  • (videoMode.height() - pHeight[0]) / 2
  • )
  • // Make window context as current context
  • glfwMakeContextCurrent(windowHandler)
  • // Enable V-sync
  • glfwSwapInterval(1)
  • // let window visible
  • glfwShowWindow(windowHandler)
  • }
  • fun cleanUp() {
  • logger.info("Close signal received, termination")
  • // clean up and terminate window
  • glfwFreeCallbacks(windowHandler)
  • glfwDestroyWindow(windowHandler)
  • // Terminate GLFW and free the error callback
  • glfwTerminate()
  • // set null for error callback
  • // and free previous one
  • glfwSetErrorCallback(null)?.free()
  • }
  • fun main() {
  • println("App start with OpenGL version ${Version.getVersion()}")
  • init()
  • // ------------------------------------------------------------
  • // loop for updating window
  • // ------------------------------------------------------------
  • // This line is critical for LWJGL's interoperation with GLFW's
  • // OpenGL context, or any context that is managed externally.
  • // LWJGL detects the context that is current in the current thread,
  • // creates the GLCapabilities instance and makes the OpenGL
  • // bindings available for use.
  • // i.e. to let lwjgl can use our windows handler, we had to call this
  • // to make sure lwjgl create proper capabilities for current context
  • GL.createCapabilities()
  • // specifies the red, green, blue, and alpha values used by glClear to clear the color buffers.
  • // in this case we set it to a beautiful color
  • glClearColor(0.2f, 0.3f, 0.3f, 1.0f)
  • // prepare our vertex buffer, VBO
  • val vertexVBO = glGenBuffers()
  • val colorVBO = glGenBuffers()
  • // float buffer used to write into the vbo
  • var floatBuffer: FloatBuffer
  • var colorBuffer: FloatBuffer
  • logger.info("Start rendering loop")
  • var time = glfwGetTime()
  • // the actual render loop
  • while (!glfwWindowShouldClose(windowHandler)) {
  • val currentTime = glfwGetTime()
  • val fps = 1.0 / (currentTime - time)
  • // to measurer the fps
  • logger.info("FPS: $fps")
  • time = currentTime
  • // poll window event, key handlers will only be called during this time
  • glfwPollEvents()
  • // clear color buffer
  • glClear(GL_COLOR_BUFFER_BIT)
  • // generating buffer data
  • floatBuffer = BufferUtils.createFloatBuffer(segments * 2).clear()
  • colorBuffer = BufferUtils.createFloatBuffer(segments * 3).clear()
  • for (i in 0 until segments) {
  • val theta = 2 * PI * i / segments
  • val x = radius * cos(theta)
  • val y = radius * sin(theta)
  • // (x, y)
  • floatBuffer.put(i * 2, x.toFloat())
  • floatBuffer.put(i * 2 + 1, y.toFloat())
  • colorBuffer.put(i * 3, cos(theta).toFloat())
  • colorBuffer.put(i * 3 + 1, sin(theta).toFloat())
  • colorBuffer.put(i * 3 + 2, i / segments.toFloat())
  • }
  • floatBuffer.position(0)
  • colorBuffer.position(0)
  • // send data info buffer object
  • // select buffer
  • glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)
  • // write data from cpu buffer into gpu buffer
  • glBufferData(GL_ARRAY_BUFFER, floatBuffer, GL_DYNAMIC_DRAW)
  • // unbinding buffer
  • glBindBuffer(GL_ARRAY_BUFFER, 0)
  • // select buffer
  • glBindBuffer(GL_ARRAY_BUFFER, colorVBO)
  • // write data from cpu buffer into gpu buffer
  • glBufferData(GL_ARRAY_BUFFER, colorBuffer, GL_DYNAMIC_DRAW)
  • // unbinding buffer
  • glBindBuffer(GL_ARRAY_BUFFER, 0)
  • // render the data
  • glEnableClientState(GL_VERTEX_ARRAY)
  • glEnableClientState(GL_COLOR_ARRAY)
  • // select buffer
  • glBindBuffer(GL_ARRAY_BUFFER, vertexVBO)
  • // size = 2 -> (x,y)
  • // size = 3 -> (x, y, z)
  • // stride = 0 -> data are sequential
  • glVertexPointer(2, GL_FLOAT, 0, NULL)
  • glBindBuffer(GL_ARRAY_BUFFER, colorVBO)
  • glColorPointer(3, GL_FLOAT, 0, NULL)
  • // draw the vertexes as TRIANGLE FAN
  • // vertexes data start from index 0 and there are $segments vertexes
  • glDrawArrays(GL_TRIANGLE_FAN, 0, segments)
  • // done with buffer
  • glBindBuffer(GL_ARRAY_BUFFER, 0)
  • // end render
  • glDisableClientState(GL_VERTEX_ARRAY)
  • glDisableClientState(GL_COLOR_ARRAY)
  • //swap color buffer, means that next frame is done
  • glfwSwapBuffers(windowHandler)
  • }
  • cleanUp()
  • }

- 全文完 -

另注:由于撰写本文的时候突发感冒,甚至有些低烧,脑袋晕晕乎乎的,以至于文中一些文字不够流畅,思维有不通或怪异之处,还请各位读者谅解。欢迎在评论区提出这些问题,日后我会修改。


知识共享许可协议
【代码札记】使用 LWJGL 和 OpenGL 更优雅的画圆天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。

Archives QR Code
QR Code for this page
Tipping QR Code