安卓下多线程OpenGL共享Context (四)

本文介绍了一种在Unity中使用Shader实现YUV格式数据渲染的方法,解决了视频播放时的纹理更新问题。通过创建FrameBuffer和纹理,实现了YUV数据到RGB的转换。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

     之前的方案假定Java层更新纹理时使用的是RGB或RBGA格式的数据,但是在播放视频这种应用场景下,解码器解码出来的数据如果是YUV格式,渲染起来就比较麻烦了。一种方式是使用CPU进行YUV转RGB,然后再进行渲染,但是这种方式性能极差;另一种方式是使用GPU进行转换,利用GPU的并行计算能力加速转换。我们需要编写Shader来实现。如前文所述,Unity只需要Java层的纹理ID,当使用Shader进行YUV转RGB时,怎么实现更新该纹理的数据呢?答案是Render to Texture (参见[1])。具体做法是,创建一个FrameBuffer,调用glFramebufferTexture2D将纹理与FrameBuffer关联起来,这样在FrameBuffer上进行的绘制,就会被写入到该纹理中。Java代码如下:

public void setupGL(int width, int height) {
    // 创建纹理
    int tempBuffer[] = new int[1];
    GLES20.glGenTextures(1, tempBuffer, 0);
    mTextureId = tempBuffer[0];
    if (mTextureId == 0) {
        glLogE("setupGL, glGenTextures for render texture failed");
        return;
    }
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureId);
    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
    GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height, 0, GLES20.GL_RGBA,
            GLES20.GL_UNSIGNED_BYTE, null);
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);

    // 创建YUV纹理
    GLES20.glGenTextures(3, mYuvTextures, 0);
    if (mYuvTextures[0] == 0 || mYuvTextures[1] == 0 || mYuvTextures[2] == 0) {
        MyLog.e(TAG, "setupGL, glGenTextures for yuv texture failed");
        return;
    }
    for (int yuvTexture : mYuvTextures) {
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, yuvTexture);
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
    }
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);

    // 创建帧缓冲区
    GLES20.glGenFramebuffers(1, tempBuffer, 0);
    mFramebuffer = tempBuffer[0];
    if (mFramebuffer == 0) {
        glLogE("setupGL, glGenFramebuffers failed");
        return;
    }
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFramebuffer);
    GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
            GLES20.GL_TEXTURE_2D, mTextureId, 0);
    int errCode = GLES20.glCheckFramebufferStatus(GLES20.GL_FRAMEBUFFER);
    if (errCode != GLES20.GL_FRAMEBUFFER_COMPLETE) {
        glLogE("setupGL, glCheckFramebufferStatus failed, errCode=0x" + Integer.toHexString(errCode));
        return;
    }
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
}

     绘制时,先绑定FrameBuffer,再进行绘制操作,Java代码如下:

public void updateTexture() {
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFramebuffer);
    GLES20.glViewport(0, 0, mWidth, mHeight);
    GLES20.glClearColor(0, 0, 0, 1);
    GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
    // 此处添加绘制操作
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
}

      为了实现YUV数据的渲染,需要编写Shader。此处,我们渲染的YUV数据格式为YUV420 (YUV420格式的具体介绍,请读者自行百度),Y、U、V通道数据分别存放在三个缓冲区中。将YUV数据分别赋给三个纹理,然后指定Shader的顶点坐标和纹理坐标,绘制一个矩形即可 (参见[2])。

      首先,在setupGL函数中为YUV生成三个纹理,Java代码如下:

// 创建YUV纹理
GLES20.glGenTextures(3, mYuvTextures, 0);
if (mYuvTextures[0] == 0 || mYuvTextures[1] == 0 || mYuvTextures[2] == 0) {
    MyLog.e(TAG, "setupGL, glGenTextures for yuv texture failed");
    return;
}
for (int yuvTexture : mYuvTextures) {
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, yuvTexture);
    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
}
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);

绘制时,将YUV数据分别赋给三个纹理,并将三个纹理分别与GLES20.GL_TEXTURE0,GLES20.GL_TEXTURE1,GLES20.GL_TEXTURE2绑定,Java代码如下:

for (int i = 0; i < 3; ++i) {
    GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + i);
    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mYuvTextures[i]);
    int w = i == 0 ? yuvFrame.yuvStrides[0] : (yuvFrame.yuvStrides[0] / 2);
    int h = i == 0 ? yuvFrame.height : (yuvFrame.height / 2);
    GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_LUMINANCE, w, h, 0,
            GLES20.GL_LUMINANCE, GLES20.GL_UNSIGNED_BYTE, yuvFrame.yuvPlanes[i]);
}

  然后,我们需要创建Program,为Program创建Vertex Shader和Fragment Shader (参见[2])。Java代码如下:

public static final String VERTEX_SHADER_STRING = "attribute vec4 in_pos;\n"
        + "attribute vec2 in_tc;\n"
        + "varying vec2 out_tc;\n"
        + "void main() {\n"
        + "    gl_Position = in_pos;\n"
        + "    out_tc = in_tc;\n"
        + "}\n";

public static final String FRAGMENT_SHADER_STRING = "precision mediump float;\n"
        + "uniform sampler2D tex_y;\n"
        + "uniform sampler2D tex_u;\n"
        + "uniform sampler2D tex_v;\n"
        + "varying vec2 out_tc;\n"
        + "void main() {\n"
        + "    vec4 c = vec4((texture2D(tex_y, out_tc).r - 16./255.) * 1.164);\n"
        + "    vec4 U = vec4(texture2D(tex_u, out_tc).r - 128./255.);\n"
        + "    vec4 V = vec4(texture2D(tex_v, out_tc).r - 128./255.);\n"
        + "    c += V * vec4(1.596, -0.813, 0, 0);\n"
        + "    c += U * vec4(0, -0.392, 2.017, 0);\n"
        + "    c.a = 1.0;\n"
        + "    gl_FragColor = c;\n"
        + "}\n";

protected final void addShaderTo(int type, String source, int program) throws RuntimeException {
    int shader = GLES20.glCreateShader(type);
    if (shader == 0) {
        throw new RuntimeException("Create shader failed, err=" + GLES10.glGetError());
    }
    GLES20.glShaderSource(shader, source);
    GLES20.glCompileShader(shader);
    int[] result = new int[]{GLES20.GL_FALSE};
    GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, result, 0);
    if (result[0] != GLES20.GL_TRUE) {
        GLES20.glDeleteShader(shader);
        throw new RuntimeException("Compile shader failed, err=" + GLES10.glGetError());
    }
    GLES20.glAttachShader(program, shader);
    GLES20.glDeleteShader(shader);
}

public void setupGL(int width, int height) {
    ...
    // 创建Program
    mProgram = GLES20.glCreateProgram();
    addShaderTo(GLES20.GL_VERTEX_SHADER, EglRender.VERTEX_SHADER_STRING, mProgram);
    addShaderTo(GLES20.GL_FRAGMENT_SHADER, EglRender.FRAGMENT_SHADER_STRING, mProgram);
    GLES20.glLinkProgram(mProgram);
    GLES20.glGetProgramiv(mProgram, GLES20.GL_LINK_STATUS, tempBuffer, 0);
    if (tempBuffer[0] != GLES20.GL_TRUE) {
        glLogE("setupGL, create program failed");
        return;
    }
    GLES20.glUseProgram(mProgram);
    int y_tex = GLES20.glGetUniformLocation(mProgram, "tex_y");
    GLES20.glUniform1i(y_tex, 0);
    int u_tex = GLES20.glGetUniformLocation(mProgram, "tex_u");
    GLES20.glUniform1i(u_tex, 1);
    int v_tex = GLES20.glGetUniformLocation(mProgram, "tex_v");
    GLES20.glUniform1i(v_tex, 2);
    mVertexLocation = GLES20.glGetAttribLocation(mProgram, "in_pos");
    mTextureLocation = GLES20.glGetAttribLocation(mProgram, "in_tc");
    GLES20.glUseProgram(0);
}

完整绘制代码如下:

 1 public void updateTexture() {
 2     GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFramebuffer);
 3 
 4     GLES20.glViewport(0, 0, mWidth, mHeight);
 5     GLES20.glClearColor(0, 0, 0, 1);
 6     GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
 7     GLES20.glDisable(GLES20.GL_CULL_FACE);
 8     GLES20.glDisable(GLES20.GL_DEPTH_TEST);
 9     GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0);
10     GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0);
11 
12     GLES20.glUseProgram(mProgram);
13     // 更新YUV数据
14     for (int i = 0; i < 3; ++i) {
15         GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + i);
16         GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mYuvTextures[i]);
17         int w = i == 0 ? yuvFrame.yuvStrides[0] : (yuvFrame.yuvStrides[0] / 2);
18         int h = i == 0 ? yuvFrame.height : (yuvFrame.height / 2);
19         GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_LUMINANCE, w, h, 0,
20                 GLES20.GL_LUMINANCE, GLES20.GL_UNSIGNED_BYTE, yuvFrame.yuvPlanes[i]);
21     }
22     // 设置顶点坐标和纹理坐标
23     GLES20.glEnableVertexAttribArray(mVertexLocation);
24     mVertexCoord.position(0);
25     GLES20.glVertexAttribPointer(mVertexLocation, 2, GLES20.GL_FLOAT, false, 0, mVertexCoord);
26     GLES20.glEnableVertexAttribArray(mTextureLocation);
27     mTextureCoord.position(0);
28     GLES20.glVertexAttribPointer(mTextureLocation, 2, GLES20.GL_FLOAT, false, 0, mTextureCoord);
29     // 绘制矩形
30     GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
31 
32     GLES20.glDisableVertexAttribArray(mVertexLocation);
33     GLES20.glDisableVertexAttribArray(mTextureLocation);
34     for (int i = 0; i < 3; ++i) {
35         GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + i);
36         GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
37     }
38 
39     GLES20.glUseProgram(0);
40     GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
41     GLES20.glClear(0);
42 }

其中,9~10行是必需的,否则,glDrawArrays调用会失败,报GL_INVALID_OPERATION错误 (参见[3])。当绘制完成之后,需要通知Unity3D,在C#中调用GL.InvalidateState,否则会影响Unity的绘制 (参见[4])。C#代码如下:

void Update () {
    mPluginTexture.Call ("updateTexture");
    GL.InvalidateState ();
}

其中,Update函数是Unity在每次刷新帧时回调,我们在该回调中调用Java层的updateTexture函数更新纹理数据,然后调用GL.InvalidateState,通知Unity重置OpenGL状态。笔者一开始采用当Java解码出一帧时,便更新纹理数据进行绘制,然后通知C#调用GL.InvalidateState。但是这种方式存在两个问题,一是当App退到后台,绘制操作仍会进行,只是绘制会失败;二是画面更新一段时间之后,便会卡住,过很久才会恢复。具体原因并未查出。后来,改用从C#的Update回调更新纹理,这两个问题得以解决。

总结:

     Unity官方给出的Plugin绘制方式是通过在C#层调用GL.IssuePluginEvent,C++层接收从Unity Render线程过来的回调,在该回调中更新纹理 (参见[5])。该方案需要编写JNI和C++,实现起来比较麻烦。本文给出的方案全部在Java层即可实现。供感兴趣的读者参考。

[参考文献]

[1] Tutorial 14 : Render To Texture

[2] 最简单的视音频播放示例6:OpenGL播放YUV420P(通过Texture,使用Shader)

[3] robertcastle/UnityFBO

[4] GL.InvalidateState

[5] Low-level Native Plugin Interface

转载于:https://www.cnblogs.com/moderate-fish/p/7142396.html

<think>嗯,用户之前问了关于eglCreateImageKHR是否能实时共享OpenGL渲染的视频帧,现在又接着问gst_gl_context_new_wrapped()在上下文共享时能否实时共享帧。这说明用户可能在开发一个需要跨上下文或跨API共享视频流的应用,比如视频处理、实时渲染或者多媒体应用。他们可能在使用GStreamer框架,并且希望利用OpenGL进行硬件加速渲染,同时需要将渲染结果实时传递给其他组件或进程。 用户的问题涉及到GStreamer的GStreamer GL库中的函数gst_gl_context_new_wrapped()。我需要先理解这个函数的作用。根据GStreamer文档,这个函数用于创建一个新的GstGLContext,并将其包装到一个现有的OpenGL上下文中。这样可以让GStreamer与其他已经存在的OpenGL环境共享上下文,从而共享纹理和资源。 接下来,用户想知道这种方式是否能实时共享帧。实时共享意味着低延迟和高效率,每一帧的渲染结果都能及时被其他组件使用。在之前的回答中,eglCreateImageKHR本身不支持自动更新,需要手动逐帧创建或使用缓冲区轮换。而这里的问题是关于GStreamer的上下文共享,是否可以实现类似或更好的实时性。 首先,gst_gl_context_new_wrapped()允许共享OpenGL上下文,这意味着不同的组件(如GStreamer的插件和其他OpenGL应用)可以访问同一组OpenGL资源,如纹理。这种共享通常比通过EGLImage的跨进程或跨API共享更高效,因为不需要额外的数据拷贝或转换。 但需要注意,共享上下文本身并不自动处理同步。如果多个线程或组件同时访问同一个纹理,可能会发生竞争条件,导致数据不一致或渲染错误。因此,用户需要显式地管理同步机制,例如使用OpenGL的同步对象或者GStreamer内部的同步机制,确保在写入完成后再读取。 另外,GStreamer的GL插件通常设计用于高效处理视频帧,支持零拷贝的流水线。例如,使用glupload和gldownload插件可以直接在GPU内存中处理数据,避免CPU和GPU之间的数据拷贝,这有助于提高实时性能。如果用户正确配置了GStreamer的流水线,并确保共享上下文的正确初始化和管理,实时共享帧是可行的。 不过,用户可能需要结合其他GStreamer元素,如appsink或appsrc,来在自定义的OpenGL代码和GStreamer流水线之间传递帧数据。此时,使用共享上下文可以确保这些元素能够直接访问OpenGL纹理,而无需进行内存拷贝,从而减少延迟。 总结来说,gst_gl_context_new_wrapped()通过共享OpenGL上下文,为实时帧共享提供了基础。但实际应用中需要处理同步和资源管理,以确保数据正确且高效地传递。如果用户能正确实施这些机制,实时共享OpenGL渲染的帧是可行的。</think>`gst_gl_context_new_wrapped()` 是 GStreamer 中用于 **共享 OpenGL 上下文** 的关键函数,它允许将外部 OpenGL 上下文(如应用程序自身的 OpenGL 环境)与 GStreamer 的 GL 流水线绑定。以下是关于其能否实现实时共享 OpenGL 渲染帧的详细分析: --- ### **1. 功能定位:上下文共享** - **核心作用** 该函数将 **已有的 OpenGL 上下文**(例如应用程序创建的 OpenGL Context)封装为 GStreamer 的 `GstGLContext` 对象。 通过这种方式,GStreamer 的 GL 插件(如 `glupload`、`gldownload`)可以直接访问应用程序的 OpenGL 资源(如纹理、缓冲区),**避免数据拷贝**,实现零内存传输。 - **共享范围** - 线程内共享:同一进程内的不同线程共享 OpenGL 上下文。 - 跨组件共享:例如应用程序渲染的 OpenGL 纹理直接传递给 GStreamer 流水线进行编码或显示。 --- ### **2. 能否实现实时帧共享?** **答案是肯定的**,但需满足以下条件: #### **条件 1:正确初始化共享上下文** - **步骤示例** 1. 应用程序创建自己的 OpenGL 上下文(如 `EGLContext` 或 `GLXContext`)。 2. 调用 `gst_gl_context_new_wrapped()` 将其封装为 `GstGLContext`。 3. 将该上下文传递给 GStreamer 的 GL 插件(如 `glimagesink` 或 `glupload`)。 - **代码片段** ```c GstGLContext *gst_gl_context = gst_gl_context_new_wrapped( existing_gl_context, // 外部 OpenGL 上下文 display, // EGLDisplay 或类似对象 NULL // 错误回调 ); ``` #### **条件 2:同步机制** - **问题** 应用程序渲染帧和 GStreamer 消费帧可能存在竞争: - 若应用程序正在写入纹理,而 GStreamer 同时读取,会导致数据撕裂或崩溃。 - **解决方案** 使用 **OpenGL 同步对象**(如 `glFinish`、`glFenceSync`)或 **GStreamer 内部同步机制**: ```c // 应用程序渲染完成后插入同步点 glFlush(); glFinish(); // 确保渲染完成 // 或者使用更高效的同步对象 GLsync sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); glClientWaitSync(sync, GL_SYNC_FLUSH_COMMANDS_BIT, GL_TIMEOUT_IGNORED); glDeleteSync(sync); ``` #### **条件 3:流水线配置** - **典型流水线结构** 应用程序渲染到 OpenGL 纹理 → GStreamer 直接读取纹理 → 编码/显示。 示例流水线: ```bash appsrc caps=video/x-raw(memory:GLMemory) ! queue ! glimagesink ``` - `appsrc` 通过共享上下文直接传递 OpenGL 纹理。 - `glimagesink` 直接显示纹理,无需 CPU 参与。 --- ### **3. 实时共享的实现模式** #### **模式 1:应用程序驱动渲染** - **流程** 1. 应用程序渲染帧到 OpenGL 纹理。 2. 触发 GStreamer 流水线拉取纹理(如通过 `appsrc` 的 `need-data` 信号)。 3. GStreamer 使用共享上下文直接读取纹理并处理。 - **优点** 完全控制渲染节奏,适用于游戏、交互式应用。 #### **模式 2:GStreamer 驱动渲染** - **流程** 1. GStreamer 通过 `glfilter` 或自定义插件触发渲染回调。 2. 应用程序在回调中渲染帧到共享纹理。 3. GStreamer 后续流水线处理纹理。 - **优点** 与 GStreamer 的时钟同步,适用于视频播放等场景。 --- ### **4. 性能关键点** - **零拷贝传输** 共享上下文的核心优势是避免 GPU → CPU → GPU 的数据回读,大幅降低延迟(从毫秒级降至微秒级)。 - **纹理格式兼容性** 确保应用程序和 GStreamer 使用的纹理格式(如 `GL_RGBA8`、`GL_TEXTURE_EXTERNAL_OES`)一致,否则需要转换。 - **平台限制** - 某些平台(如 Android)要求共享上下文在同一线程中创建和使用。 - Wayland/X11 等窗口系统的 OpenGL 上下文共享机制可能不同,需针对性适配。 --- ### **5. 典型应用场景** #### **场景 1:实时屏幕捕捉** - 应用程序渲染到纹理 → 通过共享上下文传递给 GStreamer → 编码为 H.264 流。 - **延迟**:可控制在 1~3 帧内。 #### **场景 2:AR/VR 视频合成** - 摄像头数据 + OpenGL 渲染的虚拟对象 → 混合后通过 GStreamer 直播。 - **依赖**:严格的帧同步和低延迟。 #### **场景 3:跨进程渲染** - 通过 `EGLStream` 或 `DMABuf` 扩展,共享上下文甚至可跨进程传递纹理(如应用程序 → 独立的编码服务)。 --- ### **6. 总结对比** | 特性 | `eglCreateImageKHR` | `gst_gl_context_new_wrapped()` | |-----------------------|-----------------------------------------------|-----------------------------------------------| | **共享粒度** | 单张图像(`EGLImage`) | 整个 OpenGL 上下文(纹理、缓冲区等) | | **实时性支持** | 需手动逐帧更新 | 天然支持连续帧(通过共享资源直接访问) | | **典型用途** | 跨 API(如 OpenGL → Vulkan)单帧共享 | GStreamer 与应用程序的实时视频流集成 | | **性能开销** | 中(需创建/销毁对象) | 低(零拷贝 + 资源复用) | --- ### **7. 最终建议** - 若目标是将 **OpenGL 渲染的帧实时集成到 GStreamer 流水线**(如直播、屏幕录制),优先使用 `gst_gl_context_new_wrapped()` 共享上下文。 - 需注意: - 确保 OpenGL 上下文线程安全(例如使用 `GL_CONTEXT_LOST` 事件处理)。 - 在复杂场景中,结合 `EGLImage` 和共享上下文混合使用(如跨进程时)。 - 参考 GStreamer 官方示例:[gst-plugins-gl](https://gitlab.freedesktop.org/gstreamer/gst-plugins-rs/-/tree/main/gst-plugins-gl) 中的 `glshare` 插件实现。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值