假如我希望实现如下特性:

1、使用片元shader,把YUV信号作为纹理输入,采样过程中转换为RGB信号。

2、把第1步取得的画面通过片元shader,使用3*3的卷积核,实现卷积模糊。

那么,就有如下几种方案:

第一种:

片元shader每次采样3*3个坐标,转换后记录到数组,之后对数组实现卷积处理,最后输出片元颜色。

第二种:

片元shader每次采样1个坐标,转换后直接输出到片元颜色,此时采样后的输出就会在指定的frameBuffer中。然后第二个片元shader使用framebuffer作为纹理输入,采样时每次采样3*3的坐标进行卷积。

两种方案的采样和计算工作量是一致的,但很显然第一种方案会随着希望插入的处理步骤的增加,而使得整个片元shader逐渐膨胀,而且也不方便灵活增删处理过程。假如我想做一个视频剪辑软件,处理步骤可以是1、2、3,也可以是先3、1、2,这样在单个shader就很难实现这么灵活的分配了,那是否有更好的办法呢?

参考了一下吴亚峰的《OpenGL ES 3.x游戏开发 下卷》中1.6章节《帧缓冲与渲染缓冲》与1.7章节《多重渲染目标》后,我发现了以下几个可能能用得上的OpenGL特性:

1、FBO:

2、FBO映射为纹理:

其实这两个特性也是所谓离屏渲染所依赖的特性。通过以上两个feature,那完全可以一个shader渲染到framebuffer中的图像,拿过来继续作为纹理输入进行下一步的加工处理。每一步的输出,都可以下一步加工的纹理输入,这样即可随意分配shader的处理顺序和深度,类似于可以自由装配的OpenGL图像处理流水线工厂,使得灵活性大大增加,能更容易实现多图层、多流水线,适合鱼视频编辑、多重图像处理等领域。我的架构设计如下:

每个Layer可以按照需要插入多个相同或者不同的shaderProgram,顺序随意,最后全部Layer一一叠加为最终画面。

实际代码如下:

首先是图层类 Layer.cpp:

//
// Created by jiezhuchen on 2021/7/5.
//#include <GLES3/gl3.h>
#include <android/log.h>
#include "Layer.h"
#include "RenderProgram.h"
#include "shaderUtil.h"using namespace OPENGL_VIDEO_RENDERER;Layer::Layer(float x, float y, float z, float w, float h, int windowW, int windowH) {mX = x;mY = y;mZ = z;mWidth = w;mHeight = h;mWindowW = windowW;mWindowH = windowH;mRenderSrcData.data = nullptr;createFrameBuffer();createLayerProgram();
}Layer::~Layer() {destroy();
}void Layer::initObjMatrix() {//创建单位矩阵setIdentityM(mObjectMatrix, 0);setIdentityM(mUserObjectMatrix, 0);setIdentityM(mUserObjectRotateMatrix, 0);
}void Layer::scale(float sx, float sy, float sz) {scaleM(mObjectMatrix, 0, sx, sy, sz);
}void Layer::translate(float dx, float dy, float dz) {translateM(mObjectMatrix, 0, dx, dy, dz);
}void Layer::rotate(int degree, float roundX, float roundY, float roundZ) {rotateM(mObjectMatrix, 0, degree, roundX, roundY, roundZ);
}/**用户直接设定缩放量**/
void Layer::setUserScale(float sx, float sy, float sz) {mUserObjectMatrix[0] = sx;mUserObjectMatrix[5] = sy;mUserObjectMatrix[10] = sz;
}/**用户直接设定位置偏移量**/
void Layer::setUserTransLate(float dx, float dy, float dz) { //由于opengl的矩阵旋转过,所以原本改3、7、11的矩阵位置变成12、13、14mUserObjectMatrix[12] = dx;mUserObjectMatrix[13] = dy;mUserObjectMatrix[14] = dz;
}/**用户直接设定旋转量**/
void Layer::setUserRotate(float degree, float vecX, float vecY, float vecZ) {setIdentityM(mUserObjectRotateMatrix, 0); //先复原rotateM(mUserObjectRotateMatrix, 0, degree, vecX, vecY, vecZ);
}void Layer::locationTrans(float cameraMatrix[], float projMatrix[], int muMVPMatrixPointer) {multiplyMM(mMVPMatrix, 0, mUserObjectMatrix, 0, mObjectMatrix, 0);multiplyMM(mMVPMatrix, 0, mUserObjectRotateMatrix, 0, mMVPMatrix, 0);multiplyMM(mMVPMatrix, 0, cameraMatrix, 0, mMVPMatrix, 0);         //将摄像机矩阵乘以物体矩阵multiplyMM(mMVPMatrix, 0, projMatrix, 0, mMVPMatrix, 0);         //将投影矩阵乘以上一步的结果矩阵glUniformMatrix4fv(muMVPMatrixPointer, 1, false, mMVPMatrix);        //将最终变换关系传入渲染管线
}//todo 每一次修改都会导致绑定的纹理本身被修改,这样会导致循环论证一样的问题,所以要使用双Framebuffer
void Layer::createFrameBuffer() {int frameBufferCount = sizeof(mFrameBufferPointerArray) / sizeof(GLuint);//生成framebufferglGenFramebuffers(frameBufferCount, mFrameBufferPointerArray);//生成渲染缓冲bufferglGenRenderbuffers(frameBufferCount, mRenderBufferPointerArray);//生成framebuffer纹理pointerglGenTextures(frameBufferCount, mFrameBufferTexturePointerArray);//遍历framebuffer并初始化for (int i = 0; i < frameBufferCount; i++) {//绑定帧缓冲,遍历两个framebuffer分别初始化glBindFramebuffer(GL_FRAMEBUFFER, mFrameBufferPointerArray[i]);//绑定缓冲pointerglBindRenderbuffer(GL_RENDERBUFFER, mRenderBufferPointerArray[i]);//为渲染缓冲初始化存储,分配显存glRenderbufferStorage(GL_RENDERBUFFER,GL_DEPTH_COMPONENT16, mWindowW, mWindowH); //设置framebuffer的长宽glBindTexture(GL_TEXTURE_2D, mFrameBufferTexturePointerArray[i]); //绑定纹理PointerglTexParameterf(GL_TEXTURE_2D,//设置MIN采样方式GL_TEXTURE_MIN_FILTER, GL_LINEAR);glTexParameterf(GL_TEXTURE_2D,//设置MAG采样方式GL_TEXTURE_MAG_FILTER, GL_LINEAR);glTexParameterf(GL_TEXTURE_2D,//设置S轴拉伸方式GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);glTexParameterf(GL_TEXTURE_2D,//设置T轴拉伸方式GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);glTexImage2D//设置颜色附件纹理图的格式(GL_TEXTURE_2D,0,                        //层次GL_RGBA,        //内部格式mWindowW,            //宽度mWindowH,            //高度0,                        //边界宽度GL_RGBA,            //格式GL_UNSIGNED_BYTE,//每个像素数据格式nullptr);glFramebufferTexture2D        //设置自定义帧缓冲的颜色缓冲附件(GL_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,    //颜色缓冲附件GL_TEXTURE_2D,mFrameBufferTexturePointerArray[i],                        //纹理id0                                //层次);glFramebufferRenderbuffer    //设置自定义帧缓冲的深度缓冲附件(GL_FRAMEBUFFER,GL_DEPTH_ATTACHMENT,        //深度缓冲附件GL_RENDERBUFFER,            //渲染缓冲mRenderBufferPointerArray[i]                //渲染深度缓冲id);}//绑回系统默认framebuffer,否则会显示不出东西glBindFramebuffer(GL_FRAMEBUFFER, 0);//绑定帧缓冲id
}void Layer::createLayerProgram() {char vertShader[] = GL_SHADER_STRING(##version 300 es\nuniform mat4 uMVPMatrix; //旋转平移缩放 总变换矩阵。物体矩阵乘以它即可产生变换in vec3 objectPosition; //物体位置向量,参与运算但不输出给片源in vec4 objectColor; //物理颜色向量in vec2 vTexCoord; //纹理内坐标out vec4 fragObjectColor;//输出处理后的颜色值给片元程序out vec2 fragVTexCoord;//输出处理后的纹理内坐标给片元程序void main() {gl_Position = uMVPMatrix * vec4(objectPosition, 1.0); //设置物体位置fragVTexCoord = vTexCoord; //默认无任何处理,直接输出物理内采样坐标fragObjectColor = objectColor; //默认无任何处理,输出颜色值到片源});char fragShader[] = GL_SHADER_STRING(##version 300 es\nprecision highp float;uniform sampler2D textureFBO;//纹理输入in vec4 fragObjectColor;//接收vertShader处理后的颜色值给片元程序in vec2 fragVTexCoord;//接收vertShader处理后的纹理内坐标给片元程序out vec4 fragColor;//输出到的片元颜色void main() {vec4 color = texture(textureFBO, fragVTexCoord);//采样纹理中对应坐标颜色,进行纹理渲染color.a = color.a * fragObjectColor.a;//利用顶点透明度信息控制纹理透明度fragColor = color;});float ratio = (float) mWindowH / mWindowW;;float tempTexCoord[] =   //纹理内采样坐标,类似于canvas坐标 //这东西有问题,导致两个framebuffer的画面互相取纹理时互为颠倒{1.0, 0.0,0.0, 0.0,1.0, 1.0,0.0, 1.0};memcpy(mTexCoor, tempTexCoord, sizeof(tempTexCoord));float tempColorBuf[] = {1.0, 1.0, 1.0, 1.0,1.0, 1.0, 1.0, 1.0,1.0, 1.0, 1.0, 1.0,1.0, 1.0, 1.0, 1.0};memcpy(mColorBuf, tempColorBuf, sizeof(tempColorBuf));float vertxData[] = {mX + 2, mY, mZ,mX, mY, mZ,mX + 2, mY + ratio * 2, mZ,mX, mY + ratio * 2, mZ,};memcpy(mVertxData, vertxData, sizeof(vertxData));mLayerProgram = createProgram(vertShader + 1, fragShader + 1);//获取程序中顶点位置属性引用"指针"mObjectPositionPointer = glGetAttribLocation(mLayerProgram.programHandle, "objectPosition");//纹理采样坐标mVTexCoordPointer = glGetAttribLocation(mLayerProgram.programHandle, "vTexCoord");//获取程序中顶点颜色属性引用"指针"mObjectVertColorArrayPointer = glGetAttribLocation(mLayerProgram.programHandle, "objectColor");//获取程序中总变换矩阵引用"指针"muMVPMatrixPointer = glGetUniformLocation(mLayerProgram.programHandle, "uMVPMatrix");//创建单位矩阵initObjMatrix();
}void Layer::destroy() {//todo
}void Layer::addRenderProgram(RenderProgram *program) {mRenderProgramList.push_back(program);
}void Layer::removeRenderProgram(RenderProgram *program) {mRenderProgramList.remove(program);
}/**给每个模板传入渲染数据**/  //todo 修改一下,如果data更新了才调用第一个渲染器loadData刷新纹理,节约CPU资源    加一个needRefresh标志
void Layer::loadData(char *data, int width, int height, int pixelFormat, int offset) {mRenderSrcData.data = data;mRenderSrcData.width = width;mRenderSrcData.height = height;mRenderSrcData.pixelFormat = pixelFormat;mRenderSrcData.offset = offset;
}/**@param texturePointers 可以用于渲染已经绑定好的纹理,或者直接传入FBO,把上一个图层的结果进一步进行渲染,例如叠加图片、或者进行毛玻璃效果处理。当然也可以在一个图层上叠加更多渲染器实现,但多图层便于不同画面不同大小的重叠,渲染器在同一个图层中大小保持一致**/
void Layer::loadTexture(GLuint texturePointer, int width, int height) {mRenderSrcTexture.texturePointer = texturePointer;mRenderSrcTexture.width = width;mRenderSrcTexture.height = height;
}void Layer::drawLayerToFrameBuffer(float *cameraMatrix, float *projMatrix, GLuint outputFBOPointer, DrawType drawType) {glUseProgram(mLayerProgram.programHandle);glBindFramebuffer(GL_FRAMEBUFFER, outputFBOPointer);//保留物体缩放现场float objMatrixClone[16];memcpy(objMatrixClone, mObjectMatrix, sizeof(objMatrixClone));//这里的坐标轴逻辑上应该是x,y,z密度都相等的,但因为设备各式各样,x,y轴可能有不同程度的密度拉伸,所以要处理一下:/**保持图片宽高的原理:* 0、计算纹理占当前* 最后贴图时,顶点乘以修改后的物体缩放关系矩阵,就实现了* **/if (drawType == DRAW_DATA) {float ratio =mWindowW > mWindowH ? ((float) mWindowH / (float) mWindowW) : ((float) mWindowW /(float) mWindowH); //计算当前视口的短边/长边比例,从而得知X轴和Y轴的-1~1的归一化长度之间的实际长度的比例//确定图片哪一边更能覆盖对应轴的视口长度,哪一边就让其充满空间,另一边则按OpenGL视口的短边/长边比缩放,此时任意长宽比的图片都会变成矩形,再乘以图片本身的比例转换为图片本身宽高比,即可在纹理渲染时还原图片本身比例float widthPercentage = (float) mRenderSrcData.width / (float) mWindowW;float heightPercentage = (float) mRenderSrcData.height / (float) mWindowH;if (widthPercentage > heightPercentage) { //如果宽占比更多,宽拉伸到尽,高按照视口比例重新调整为统一密度的单位,然后再根据图片高对宽的比例调整物体的高的边的缩放scale(1.0, ratio * ((float) mRenderSrcData.height / mRenderSrcData.width), 1.0); //SCALEY为图片高占宽的比例 * 视口比例} else {scale(ratio * ((float) mRenderSrcData.width / mRenderSrcData.height), 1.0, 1.0);}} else {float ratio =mWindowW > mWindowH ? ((float) mWindowH / (float) mWindowW) : ((float) mWindowW /(float) mWindowH); //计算当前视口的短边/长边比例,从而得知X轴和Y轴的-1~1的归一化长度之间的实际长度的比例//确定图片哪一边更能覆盖对应轴的视口长度,哪一边就让其充满空间,另一边则按OpenGL视口的短边/长边比缩放,此时任意长宽比的图片都会变成矩形,再乘以图片本身的比例转换为图片本身宽高比,即可在纹理渲染时还原图片本身比例float widthPercentage = (float) mRenderSrcTexture.width / (float) mWindowW;float heightPercentage = (float) mRenderSrcTexture.height / (float) mWindowH;if (widthPercentage > heightPercentage) {scale(1.0, ratio * ((float) mRenderSrcTexture.height / mRenderSrcTexture.width), 1.0);//另外几种成功的算法:
//            scale(1.0, ((float) (mRenderSrcTexture.width * (mWindowH / mRenderSrcTexture.height)) / (float) mWindowW) * ratio, 1.0);
//            scale(1.0, ((float) mWindowW / mWindowH) * ((float) mRenderSrcTexture.height / mRenderSrcTexture.width) * (1 / ratio), 1.0);
//            scale(1.0, ((float) mRenderSrcTexture.height / mWindowH) * ((float) mWindowW / mRenderSrcTexture.width) * (ratio), 1.0);} else {scale(ratio * ((float) mRenderSrcTexture.width / mRenderSrcTexture.height), 1.0, 1.0);  //比例式 : 容器w * 纹理}}//物体坐标*缩放平移旋转矩阵->应用按图片比例缩放效果locationTrans(cameraMatrix, projMatrix, muMVPMatrixPointer);//还原缩放现场memcpy(mObjectMatrix, objMatrixClone, sizeof(mObjectMatrix));
//    /**实现两个Framebuffer的画面叠加,这里解释一下:
//     * 如果是偶数个渲染器,那么在交替渲染之后,那么第0个FBO的画面是上一个画面,第1个FBO为最新画面,所以要先绘制第0个FBO内容再叠加第一个
//     * 否则则是交替后,第1个渲染器是上个画面,第0个FBO是上一个画面,叠加顺序则要进行更改**/
//    for(int i = 0; i < 2; i ++) {
//        glActiveTexture(GL_TEXTURE0);
//        if (mRenderProgramList.size() % 2 == 0) {
//            setUserScale(0.5, 0.5, 0);
//            setUserTransLate(-0.5, 0, 0);
//            glBindTexture(GL_TEXTURE_2D, mFrameBufferTexturePointerArray[i]);
//        } else {
//            setUserScale(0.5, 0.5, 0);
//            setUserTransLate(0.5, 0, 0);
//            glBindTexture(GL_TEXTURE_2D, mFrameBufferTexturePointerArray[1 - i]);
//        }
//        glUniform1i(glGetUniformLocation(mLayerProgram.programHandle, "textureFBO"), 0); //获取纹理属性的指针
//        //将顶点位置数据送入渲染管线
//        glVertexAttribPointer(mObjectPositionPointer, 3, GL_FLOAT, false, 0, mVertxData); //三维向量,size为2
//        //将顶点颜色数据送入渲染管线
//        glVertexAttribPointer(mObjectVertColorArrayPointer, 4, GL_FLOAT, false, 0, mColorBuf);
//        //将顶点纹理坐标数据传送进渲染管线
//        glVertexAttribPointer(mVTexCoordPointer, 2, GL_FLOAT, false, 0, mTexCoor);  //二维向量,size为2
//        glEnableVertexAttribArray(mObjectPositionPointer); //启用顶点属性
//        glEnableVertexAttribArray(mObjectVertColorArrayPointer);  //启用颜色属性
//        glEnableVertexAttribArray(mVTexCoordPointer);  //启用纹理采样定位坐标
//        glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); //绘制线条,添加的point浮点数/3才是坐标数(因为一个坐标由x,y,z3个float构成,不能直接用)
//        glDisableVertexAttribArray(mObjectPositionPointer);
//        glDisableVertexAttribArray(mObjectVertColorArrayPointer);
//        glDisableVertexAttribArray(mVTexCoordPointer);
//    }/**如果是偶数个fragShaderProgram,则最终画面落于FBO_1。否则奇数时落于FBO_0,并不需要把两个FBO分两次叠加起来**/glActiveTexture(GL_TEXTURE0);if (mRenderProgramList.size() % 2 == 0) {//setUserScale(0.5, 0.5, 0); //测试代码//setUserTransLate(-0.5, 0, 0); //测试代码glBindTexture(GL_TEXTURE_2D, mFrameBufferTexturePointerArray[1]);} else {//setUserScale(0.5, 0.5, 0); //测试代码//setUserTransLate(0.5, 0, 0); //测试代码glBindTexture(GL_TEXTURE_2D, mFrameBufferTexturePointerArray[0]);}glUniform1i(glGetUniformLocation(mLayerProgram.programHandle, "textureFBO"), 0); //获取纹理属性的索引,给索引对应变量赋当前ActiveTexture的索引//将顶点位置数据送入渲染管线glVertexAttribPointer(mObjectPositionPointer, 3, GL_FLOAT, false, 0, mVertxData); //三维向量,size为2//将顶点颜色数据送入渲染管线glVertexAttribPointer(mObjectVertColorArrayPointer, 4, GL_FLOAT, false, 0, mColorBuf);//将顶点纹理坐标数据传送进渲染管线glVertexAttribPointer(mVTexCoordPointer, 2, GL_FLOAT, false, 0, mTexCoor);  //二维向量,size为2glEnableVertexAttribArray(mObjectPositionPointer); //启用顶点属性glEnableVertexAttribArray(mObjectVertColorArrayPointer);  //启用颜色属性glEnableVertexAttribArray(mVTexCoordPointer);  //启用纹理采样定位坐标glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); //绘制线条,添加的point浮点数/3才是坐标数(因为一个坐标由x,y,z3个float构成,不能直接用)glDisableVertexAttribArray(mObjectPositionPointer);glDisableVertexAttribArray(mObjectVertColorArrayPointer);glDisableVertexAttribArray(mVTexCoordPointer);
}/**逐步加工绘制* @param cameraMatrix 摄像机矩阵,确定观察者的位置、观察画面的旋转程度和观察方向* @param projMatrix 投影矩阵,决定3d物体通过怎样的系数投影到屏幕**/  //todo 修改一下,如果data更新了才调用第一个渲染器loadData刷新纹理,节约CPU资源
void
Layer::drawTo(float *cameraMatrix, float *projMatrix, GLuint outputFBOPointer, int fboW, int fboH, DrawType drawType) {//清理双Framebuffer残留的内容for (int i = 0; i < 2; i++) {glBindFramebuffer(GL_FRAMEBUFFER, mFrameBufferPointerArray[i]);glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT); //清理屏幕}int i = 0;/**第0个渲染器以data为数据输入,使用FBO[0]渲染结果。第1个渲染器使用FBO_texture[0]作为纹理输入,渲染结果输出到FBO[1]。* 第2个渲染器使用FBO_texture[1]作为纹理输入,渲染结果输出到FBO[0],依次循环互换结果和输入,实现效果叠加。* 使用双FBO互为绑定的原因是为了解决部分shader算法如果绑定的FBO_texture和输出的FBO是同一个将会出现异常,所以使用此方法**/for (auto item = mRenderProgramList.begin(); item != mRenderProgramList.end(); item++, i++) {//接收绘制数据的framebuffer和作为纹理输入使用的framebuffer不能是同一个int layerFrameBuffer = i % 2 == 0 ? mFrameBufferPointerArray[0] : mFrameBufferPointerArray[1];int fboTexture = i % 2 == 1 ? mFrameBufferTexturePointerArray[0] : mFrameBufferTexturePointerArray[1];//第一个渲染器接受图层原始数据(图层原始数据可以是输出的字节数组,或是纹理本身,例如OES纹理),其他的从上一个渲染结果中作为输入if (i == 0) {switch (drawType) {case DRAW_DATA: {if (mRenderSrcData.data != nullptr) {(*item)->loadData(mRenderSrcData.data, mRenderSrcData.width,mRenderSrcData.height,mRenderSrcData.pixelFormat, mRenderSrcData.offset);//渲染器处理结果放到图层FBO中(*item)->drawTo(cameraMatrix, projMatrix, RenderProgram::DRAW_DATA,layerFrameBuffer, mWindowW, mWindowH);}break;}case DRAW_TEXTURE: {Textures texture;texture.texturePointers = mRenderSrcTexture.texturePointer;texture.width = mRenderSrcTexture.width;texture.height = mRenderSrcTexture.height;Textures textures[1];textures[0] = texture;(*item)->loadTexture(textures);//渲染器处理结果放到图层FBO中(*item)->drawTo(cameraMatrix, projMatrix, RenderProgram::DRAW_TEXTURE,layerFrameBuffer, mWindowW, mWindowH);break;}}} else { //如果只有一个渲染器则走不到else里,否则第0个打后的渲染器依次使用上一个渲染器的结果,也就是图层FBO中的数据作为输入  bug//使用上一个渲染器保存到FBO的结果,也就是FBO_texture作为纹理输入进行二次处理Textures t[1];t[0].texturePointers = fboTexture;t[0].width = mWindowW;t[0].height = mWindowH;(*item)->loadTexture(t); //使用上一个渲染器的渲染结果作为绘制输入//渲染器处理结果放到图层FBO中(*item)->drawTo(cameraMatrix, projMatrix, RenderProgram::DRAW_TEXTURE,layerFrameBuffer, mWindowW, mWindowH);}}//最后渲染到目标framebufferdrawLayerToFrameBuffer(cameraMatrix, projMatrix, outputFBOPointer, drawType);//渲染统计mFrameCount++;
}

其中createFrameBuffer创建了两个FBO对象,参考的技术是双缓冲机制,每次进行当前fragShaderProgram渲染时,FBO_0保存了上一个fragShaderProgram的渲染结果,作为纹理输入到当前正在执行的fragShaderProgram,FBO_1用于保存当前fragShaderProgram渲染的结果。执行下一个fragShaderProgram时,FBO_1作为输入,FBO_0作为输出,依次类推,每渲染一次就交换一次。实现单个图层多重效果的渲染。

其中drawTo方法可以传入用于承载图层内容的FBO,默认就是0号FBO,也就是glSurfaceview在创建EGLContext的时候并绑定到view上的那个,使得内容可以呈现于屏幕上。其中比较重要部分的代码如下:

for (auto item = mRenderProgramList.begin(); item != mRenderProgramList.end(); item++, i++) {//接收绘制数据的framebuffer和作为纹理输入使用的framebuffer不能是同一个int layerFrameBuffer = i % 2 == 0 ? mFrameBufferPointerArray[0] : mFrameBufferPointerArray[1];int fboTexture = i % 2 == 1 ? mFrameBufferTexturePointerArray[0] : mFrameBufferTexturePointerArray[1];//第一个渲染器接受图层原始数据(图层原始数据可以是输出的字节数组,或是纹理本身,例如OES纹理),其他的从上一个渲染结果中作为输入if (i == 0) {switch (drawType) {case DRAW_DATA: {if (mRenderSrcData.data != nullptr) {(*item)->loadData(mRenderSrcData.data, mRenderSrcData.width,mRenderSrcData.height,mRenderSrcData.pixelFormat, mRenderSrcData.offset);//渲染器处理结果放到图层FBO中(*item)->drawTo(cameraMatrix, projMatrix, RenderProgram::DRAW_DATA,layerFrameBuffer, mWindowW, mWindowH);}break;}case DRAW_TEXTURE: {Textures texture;texture.texturePointers = mRenderSrcTexture.texturePointer;texture.width = mRenderSrcTexture.width;texture.height = mRenderSrcTexture.height;Textures textures[1];textures[0] = texture;(*item)->loadTexture(textures);//渲染器处理结果放到图层FBO中(*item)->drawTo(cameraMatrix, projMatrix, RenderProgram::DRAW_TEXTURE,layerFrameBuffer, mWindowW, mWindowH);break;}}} else { //如果只有一个渲染器则走不到else里,否则第0个打后的渲染器依次使用上一个渲染器的结果,也就是图层FBO中的数据作为输入  bug//使用上一个渲染器保存到FBO的结果,也就是FBO_texture作为纹理输入进行二次处理Textures t[1];t[0].texturePointers = fboTexture;t[0].width = mWindowW;t[0].height = mWindowH;(*item)->loadTexture(t); //使用上一个渲染器的渲染结果作为绘制输入//渲染器处理结果放到图层FBO中(*item)->drawTo(cameraMatrix, projMatrix, RenderProgram::DRAW_TEXTURE,layerFrameBuffer, mWindowW, mWindowH);}}

之前介绍createFrameBuffer的设计原理时有说过其使用了双缓冲机制,所以fragShaderProgram的数目的奇偶性的不同,图层渲染的最终画面可能会落入不同的Framebuffer中。例如图层的渲染流水线有3个fragShaderProgram的话,那么就是先渲染画面输出到了Framebuffer0,再把framebuffer0作为输入,渲染后输出到Framebuffer1,然后再把Framebuffer1作为输入,输出到Framebuffer0。这种轮换模式,使得fragShaderProgram的渲染效果可以不停叠加。

renderProgram的基类设计如下:

//
// Created by jiezhuchen on 2021/6/21.
//#include <GLES3/gl3.h>
#include <GLES3/gl3ext.h>
#include "RenderProgram.h"
#include "matrix.c"
#include "shaderUtil.c"using namespace OPENGL_VIDEO_RENDERER;void RenderProgram::initObjMatrix() {//创建单位矩阵setIdentityM(mObjectMatrix, 0);
}void RenderProgram::scale(float sx, float sy, float sz) {scaleM(mObjectMatrix, 0, sx, sy, sz);
}void RenderProgram::translate(float dx, float dy, float dz) {translateM(mObjectMatrix, 0, dx, dy, dz);
}void RenderProgram::rotate(int degree, float roundX, float roundY, float roundZ) {rotateM(mObjectMatrix, 0, degree, roundX, roundY, roundZ);
}float* RenderProgram::getObjectMatrix() {return mObjectMatrix;
}void RenderProgram::setObjectMatrix(float objMatrix[]) {memcpy(mObjectMatrix, objMatrix, sizeof(mObjectMatrix));
}void RenderProgram::locationTrans(float cameraMatrix[], float projMatrix[], int muMVPMatrixPointer) {multiplyMM(mMVPMatrix, 0, cameraMatrix, 0, mObjectMatrix, 0);         //将摄像机矩阵乘以物体矩阵multiplyMM(mMVPMatrix, 0, projMatrix, 0, mMVPMatrix, 0);         //将投影矩阵乘以上一步的结果矩阵glUniformMatrix4fv(muMVPMatrixPointer, 1, false, mMVPMatrix);        //将最终变换关系传入渲染管线
}

然后继承之后,按照自己的需要,实现自己的渲染流水线,例子如下(LUT滤镜渲染器):

//
// Created by jiezhuchen on 2021/6/21.
//#include <GLES3/gl3.h>
#include <GLES3/gl3ext.h>#include <string.h>
#include <jni.h>
#include <cstdlib>
#include "RenderProgramFilter.h"
#include "android/log.h"using namespace OPENGL_VIDEO_RENDERER;
static const char *TAG = "nativeGL";
#define LOGI(fmt, args...) __android_log_print(ANDROID_LOG_INFO,  TAG, fmt, ##args)
#define LOGD(fmt, args...) __android_log_print(ANDROID_LOG_DEBUG, TAG, fmt, ##args)
#define LOGE(fmt, args...) __android_log_print(ANDROID_LOG_ERROR, TAG, fmt, ##args)RenderProgramFilter::RenderProgramFilter() {vertShader = GL_SHADER_STRING($#version 300 es\nuniform mat4 uMVPMatrix; //旋转平移缩放 总变换矩阵。物体矩阵乘以它即可产生变换in vec3 objectPosition; //物体位置向量,参与运算但不输出给片源in vec4 objectColor; //物理颜色向量in vec2 vTexCoord; //纹理内坐标out vec4 fragObjectColor;//输出处理后的颜色值给片元程序out vec2 fragVTexCoord;//输出处理后的纹理内坐标给片元程序void main() {gl_Position = uMVPMatrix * vec4(objectPosition, 1.0); //设置物体位置fragVTexCoord = vTexCoord; //默认无任何处理,直接输出物理内采样坐标fragObjectColor = objectColor; //默认无任何处理,输出颜色值到片源});fragShader = GL_SHADER_STRING(##version 300 es\nprecision highp float;precision highp sampler2DArray;uniform sampler2D sTexture;//图像纹理输入uniform sampler2DArray lutTexture;//滤镜纹理输入uniform float pageSize;uniform float frame;//第几帧uniform vec2 resolution;//分辨率in vec4 fragObjectColor;//接收vertShader处理后的颜色值给片元程序in vec2 fragVTexCoord;//接收vertShader处理后的纹理内坐标给片元程序out vec4 fragColor;//输出到的片元颜色void main() {vec4 srcColor = texture(sTexture, fragVTexCoord);srcColor.r = clamp(srcColor.r, 0.01, 0.99);srcColor.g = clamp(srcColor.g, 0.01, 0.99);srcColor.b = clamp(srcColor.b, 0.01, 0.99);fragColor = texture(lutTexture, vec3(srcColor.b, srcColor.g, srcColor.r * (pageSize - 1.0)));});float tempTexCoord[] =   //纹理内采样坐标,类似于canvas坐标 //这东西有问题,导致两个framebuffer的画面互相取纹理时互为颠倒{1.0, 0.0,0.0, 0.0,1.0, 1.0,0.0, 1.0};memcpy(mTexCoor, tempTexCoord, sizeof(tempTexCoord));float tempColorBuf[] = {1.0, 1.0, 1.0, 1.0,1.0, 1.0, 1.0, 1.0,1.0, 1.0, 1.0, 1.0,1.0, 1.0, 1.0, 1.0};memcpy(mColorBuf, tempColorBuf, sizeof(tempColorBuf));
}RenderProgramFilter::~RenderProgramFilter() {destroy();
}void RenderProgramFilter::createRender(float x, float y, float z, float w, float h, int windowW,int windowH) {mWindowW = windowW;mWindowH = windowH;initObjMatrix(); //使物体矩阵初始化为单位矩阵,否则接下来的矩阵操作因为都是乘以0而无效float vertxData[] = {x + w, y, z,x, y, z,x + w, y + h, z,x, y + h, z,};memcpy(mVertxData, vertxData, sizeof(vertxData));mImageProgram = createProgram(vertShader + 1, fragShader + 1);//获取程序中顶点位置属性引用"指针"mObjectPositionPointer = glGetAttribLocation(mImageProgram.programHandle, "objectPosition");//纹理采样坐标mVTexCoordPointer = glGetAttribLocation(mImageProgram.programHandle, "vTexCoord");//获取程序中顶点颜色属性引用"指针"mObjectVertColorArrayPointer = glGetAttribLocation(mImageProgram.programHandle, "objectColor");//获取程序中总变换矩阵引用"指针"muMVPMatrixPointer = glGetUniformLocation(mImageProgram.programHandle, "uMVPMatrix");//渲染方式选择,0为线条,1为纹理mGLFunChoicePointer = glGetUniformLocation(mImageProgram.programHandle, "funChoice");//渲染帧计数指针mFrameCountPointer = glGetUniformLocation(mImageProgram.programHandle, "frame");//设置分辨率指针,告诉gl脚本现在的分辨率mResoulutionPointer = glGetUniformLocation(mImageProgram.programHandle, "resolution");
}void RenderProgramFilter::setAlpha(float alpha) {if (mColorBuf != nullptr) {for (int i = 3; i < sizeof(mColorBuf) / sizeof(float); i += 4) {mColorBuf[i] = alpha;}}
}//todo
void RenderProgramFilter::setBrightness(float brightness) {}//todo
void RenderProgramFilter::setContrast(float contrast) {}//todo
void RenderProgramFilter::setWhiteBalance(float redWeight, float greenWeight, float blueWeight) {}void RenderProgramFilter::loadData(char *data, int width, int height, int pixelFormat, int offset) {if (!mIsTexutresInited) {glUseProgram(mImageProgram.programHandle);glGenTextures(1, mTexturePointers);mGenTextureId = mTexturePointers[0];mIsTexutresInited = true;}//绑定处理glBindTexture(GL_TEXTURE_2D, mGenTextureId);glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);glTexImage2D(GL_TEXTURE_2D, 0, pixelFormat, width, height, 0, pixelFormat, GL_UNSIGNED_BYTE, (void*) (data + offset));mDataWidth = width;mDataHeight = height;
}/**@param texturePointers 传入需要渲染处理的纹理,可以为上一次处理的结果,例如处理完后的FBOTexture **/
void RenderProgramFilter::loadTexture(Textures textures[]) {mInputTexturesArrayPointer = textures[0].texturePointers;mInputTextureWidth = textures[0].width;mInputTextureHeight = textures[0].height;
}/**设置LUT滤镜**/
void RenderProgramFilter::loadLut(char* lutPixels, int lutWidth, int lutHeight, int unitLength) { //纹理更新只能在GL线程里面操作,所以这里只能先保存一下数据mLutWidth = lutWidth;mLutHeight = lutHeight;mLutUnitLen = unitLength;mLutPixels = (char*) malloc(mLutWidth * mLutHeight * 4);memcpy(mLutPixels, lutPixels, mLutWidth * mLutHeight * 4);
}/**@param outputFBOPointer 绘制到哪个framebuffer,系统默认一般为0 **/
void RenderProgramFilter::drawTo(float *cameraMatrix, float *projMatrix, DrawType drawType, int outputFBOPointer, int fboW, int fboH) {if (mIsDestroyed) {return;}glUseProgram(mImageProgram.programHandle);//设置视窗大小及位置glBindFramebuffer(GL_FRAMEBUFFER, outputFBOPointer);glViewport(0, 0, mWindowW, mWindowH);glUniform1i(mGLFunChoicePointer, 1);//传入位置信息locationTrans(cameraMatrix, projMatrix, muMVPMatrixPointer);//开始渲染:if (mVertxData != nullptr && mColorBuf != nullptr) {//将顶点位置数据送入渲染管线glVertexAttribPointer(mObjectPositionPointer, 3, GL_FLOAT, false, 0, mVertxData); //三维向量,size为2//将顶点颜色数据送入渲染管线glVertexAttribPointer(mObjectVertColorArrayPointer, 4, GL_FLOAT, false, 0, mColorBuf);//将顶点纹理坐标数据传送进渲染管线glVertexAttribPointer(mVTexCoordPointer, 2, GL_FLOAT, false, 0, mTexCoor);  //二维向量,size为2glEnableVertexAttribArray(mObjectPositionPointer); //启用顶点属性glEnableVertexAttribArray(mObjectVertColorArrayPointer);  //启用颜色属性glEnableVertexAttribArray(mVTexCoordPointer);  //启用纹理采样定位坐标float resolution[2];switch (drawType) {case OPENGL_VIDEO_RENDERER::RenderProgram::DRAW_DATA:glActiveTexture(GL_TEXTURE0); //激活0号纹理glBindTexture(GL_TEXTURE_2D, mGenTextureId); //0号纹理绑定内容glUniform1i(glGetUniformLocation(mImageProgram.programHandle, "sTexture"), 0); //映射到渲染脚本,获取纹理属性的指针resolution[0] = (float) mDataWidth;resolution[1] = (float) mDataHeight;glUniform2fv(mResoulutionPointer, 1, resolution);break;case OPENGL_VIDEO_RENDERER::RenderProgram::DRAW_TEXTURE:glActiveTexture(GL_TEXTURE0); //激活0号纹理glBindTexture(GL_TEXTURE_2D, mInputTexturesArrayPointer); //0号纹理绑定内容glUniform1i(glGetUniformLocation(mImageProgram.programHandle, "sTexture"), 0); //映射到渲染脚本,获取纹理属性的指针resolution[0] = (float) mInputTextureWidth;resolution[1] = (float) mInputTextureHeight;glUniform2fv(mResoulutionPointer, 1, resolution);break;}int longLen = mLutWidth > mLutHeight ? mLutWidth : mLutHeight;if (mLutPixels) {if (mHadLoadLut) {glBindTexture(GL_TEXTURE_2D_ARRAY, mLutTexutresPointers[0]);glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA, 0, 0, 0, 0, GL_RGBA, GL_UNSIGNED_BYTE,nullptr);glDeleteTextures(1, mLutTexutresPointers);}glGenTextures(1, mLutTexutresPointers);glBindTexture(GL_TEXTURE_2D_ARRAY, mLutTexutresPointers[0]);glTexParameterf(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);glTexParameterf(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);glTexParameterf(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR);glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR);glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA, mLutUnitLen, mLutUnitLen, longLen / mLutUnitLen, 0, GL_RGBA, GL_UNSIGNED_BYTE, mLutPixels);//lut数据加载完毕,清理内存free(mLutPixels);mLutPixels = nullptr;mHadLoadLut = true;}if (mHadLoadLut) {glActiveTexture(GL_TEXTURE1); //激活1号纹理glBindTexture(GL_TEXTURE_2D_ARRAY, mLutTexutresPointers[0]);glUniform1i(glGetUniformLocation(mImageProgram.programHandle, "lutTexture"), 1); //映射到渲染脚本,获取纹理属性的指针glUniform1f(glGetUniformLocation(mImageProgram.programHandle, "pageSize"), longLen / mLutUnitLen); //映射到渲染脚本,获取纹理属性的指针}glDrawArrays(GL_TRIANGLE_STRIP, 0, /*mPointBufferPos / 3*/ 4); //绘制线条,添加的point浮点数/3才是坐标数(因为一个坐标由x,y,z3个float构成,不能直接用)glDisableVertexAttribArray(mObjectPositionPointer);glDisableVertexAttribArray(mObjectVertColorArrayPointer);glDisableVertexAttribArray(mVTexCoordPointer);}
}void RenderProgramFilter::destroy() {if (!mIsDestroyed) {//释放纹理所占用的显存glBindTexture(GL_TEXTURE_2D, 0);glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, 0, 0, 0, GL_LUMINANCE, GL_UNSIGNED_BYTE, nullptr);glDeleteTextures(1, mTexturePointers);glBindTexture(GL_TEXTURE_2D_ARRAY, mLutTexutresPointers[0]);glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA, 0, 0, 0, 0, GL_RGBA, GL_UNSIGNED_BYTE,nullptr);glDeleteTextures(1, mLutTexutresPointers);//删除不用的shaderprogramdestroyProgram(mImageProgram);}mIsDestroyed = true;
}

简单说一下Layer和自己写的render如何搭配使用:

1、创建图层,输入需要处理的纹理或者数据:

Layer *layer = new Layer(-1, -mRatio, 0, 2, mRatio * 2, mWidth, mHeight); //创建铺满全屏的图层;//载入数据:LOGI("cjztest, Java_com_opengldecoder_jnibridge_JniBridge_addFullContainerLayer containerW:%d, containerH:%d, w:%d, h:%d", mWidth, mHeight, textureWidthAndHeightPointer[0], textureWidthAndHeightPointer[1]);layer->loadTexture(texturePointer, textureWidthAndHeightPointer[0], textureWidthAndHeightPointer[1]);layer->loadData((char *) dataPointer, dataWidthAndHeightPointer[0], dataWidthAndHeightPointer[1], dataPixelFormat, 0);if (mLayerList) {struct ListElement* cursor = mLayerList;while (cursor->next) {cursor = cursor->next;}cursor->next = (struct ListElement*) malloc(sizeof(struct ListElement));cursor->next->layer = layer;cursor->next->next = nullptr;} else {mLayerList = (struct ListElement*) malloc(sizeof(struct ListElement));mLayerList->layer = layer;mLayerList->next = nullptr;}

2、对图层添加想要的fragShader渲染器

                RenderProgramFilter *renderProgramFilter = new RenderProgramFilter();renderProgramFilter->createRender(-1, -mRatio, 0, 2,mRatio * 2,mWidth,mHeight);resultProgram = renderProgramFilter;layer->addRenderProgram(resultProgram);

3、输入画面呈现用的framebuffer索引

        //防止画面残留:glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT); //清理屏幕glClearColor(0.0, 0.0, 0.0, 0.0);//遍历图层并渲染if (mLayerList) {struct ListElement* cursor = mLayerList;while (cursor) {cursor->layer->drawTo(mCameraMatrix, mProjMatrix, fboPointer, fboWidth, fboHeight, Layer::DRAW_TEXTURE);cursor = cursor->next;}}

Demo代码地址:

learnOpengl: 我的OpenGL联系库 - Gitee.comhttps://gitee.com/cjzcjl/learnOpenGLDemo/tree/main/app/src/main/cpp/opengl_decoder

实际效果,这个Demo依次开启了卷积和LUT滤镜两个流水线fragShader:

在安卓上基于OpenGL ES实现渲染流水线和Lut滤镜,效果展示

至此,一个简单的OpenGL 多图层多重渲染流水线的简易框架就搭建完毕,可以在此之上扩充为一个视频编辑软件或者图像处理软件。

一种基于FBO实现渲染流水线的思路相关推荐

  1. 一种基于DirectX 9.0 API的G代码逆向渲染方法

    G代码是一种工业加工描述语言,现在也广泛的运用于3D打印中.但G代码是一种单向的描述语言,很难逆向回三维模型.但近日,日本出现了一款名为MakePaintable的软件,它可以将G代码逆向回三维模型. ...

  2. 「技美之路」图形 1.1 渲染流水线

    今日起开始分享学习技美之路专栏,文章来源听课笔记以及业界大佬分享的经验文章,主要来自CSDN_知乎等.技美路漫长 一定要坚持  开始吧! 一.整体流程 整体流程(渲染管线可分为四个阶段)每一个阶段的输 ...

  3. 浏览器渲染流水线解析

    摘要: 若干年前,我写过一篇介绍浏览器渲染流水线的文章 - How Rendering Work (in WebKit and Blink),这篇文章,一来部分内容已经过时,二来缺少一个全局视角来对流 ...

  4. 图形 1.1渲染流水线(知识梳理笔记)

    目录 前言 渲染流水线整体流程 序 应用阶段 准备基本场景数据 加速算法粗粒度剔除 设置渲染状态,准备渲染参数 调用Draw Call ,数据输出到显存 几何阶段 顶点着色 顶点处理(可选) 投影 裁 ...

  5. (十九)unity shader之——————基于物理的渲染技术(PBS):中篇(Unity 5中的Standard Shader的实现和使用)

    一.unity 5中的standard shader 在unity5中新创建一个模型或是新创建一个材质时,默认使用的着色器都是一个名为standard 的着色器.这个standard shader使用 ...

  6. Unity 渲染流水线 :CPU与GPU合作创造的艺术wfd

    前言 购优惠 www.fenfaw.cn 对于Unity渲染流程的理解可以帮助我们更好对Unity场景进行性能消耗的分析,进而更好的提升场景渲染的效率,最后提升游戏整体的性能表现 Unity的游戏画面 ...

  7. 移动端 像素渲染流水线与GPU Hack

    什么是 像素渲染流水线 web页面你所写的页面代码是如何被转换成屏幕上显示的像素的.这个转换过程可以归纳为这样的一个流水线,包含五个关键步骤: 1.JavaScript:一般来说,我们会使用JavaS ...

  8. 使用opengl编程实现一个三维渲染实体_Unity Shader学习随记_01_渲染流水线

    什么是Shader?它和Material(材质)的关系 Shader,中文翻译:着色器,是可编程图形管线的算法片段 Shader实际上就是一小段程序,它负责将输入的顶点数据以指定的方式和输入的贴图或者 ...

  9. android 画布裁剪,一种基于Android系统对UI控件进行轮廓剪裁及美化的方法与流程...

    本发明涉及Android应用的技术领域,特别涉及一种基于Android系统对UI控件进行轮廓剪裁及美化的方法. 背景技术: 目前,随着智能电视的普及,Android应用层出不穷,而那些表现形式单一.传 ...

  10. 基于物理的渲染-用真实的环境光照亮物体

    目前,在游戏引擎中用于照亮物体的光源非常丰富.其中,比较常用的有:平行方向光.点光源.聚光灯以及体积光等,但它们都是对真实光源的近似,并不能很好地模拟真实世界中的复杂光照情况.为了增加光照效果的真实感 ...

最新文章

  1. WinAPI: GetDoubleClickTime、SetDoubleClickTime - 获取与设置鼠标双击间隔时间
  2. QCustomPlot使用手册(二)
  3. 5.cocos2dx中关于draw绘图,声音和音效,预加载,播放与停止Vs暂停和恢复,音量控制
  4. 服务去获取配置中心配置
  5. 【计算机网络】—— 差错编码(纠错编码)
  6. 【生活智慧】001.追求实在的东西
  7. 希尔伯特变换到底有什么用
  8. 台式计算机如何联络无线网,台式电脑怎样设置无线网络
  9. ID BOX 121电子护照阅读器(带RFID双天线)参数与应用说明
  10. python爬虫代理ip_Python爬虫如何获取代理ip及ip验证?
  11. Rsync 备份服务:基本概述、应用场景、传输模式、注意事项、密码解决方案、服务实践、备份案例、结合inotify
  12. Lintcode 4 Ugly Number II
  13. js判断当前设备和获取设备、浏览器宽高
  14. 最新数据挖掘赛事方案梳理!
  15. iOS真机调试步骤(Xcode8.0以上版本)(2015年)
  16. C语言 函数的嵌套调用
  17. mac升级10.15 360命令行加固脚本报错解决
  18. 火狐浏览器如何添加Xpath扩展
  19. 反编译之XX营销软件
  20. 用Excel来将一行分隔成多行

热门文章

  1. XPDL与WS-BPEL的比较之二:二者内容的大致概述
  2. android音频驱动工程师,4.Android音频驱动(底层1)
  3. 基于微信实现H5扫一扫功能详细过程
  4. 电子邮件群发最好用的邮箱是哪个?
  5. Recovering Realistic Texture in Image Super-resolution by Deep Spatial Feature Transform
  6. javax.persistence.EntityNotFoundException: Unable to find 类 with id ?
  7. 全选、反选、获取选中值
  8. 小码哥php教程,小码哥Java从0到高级工程师
  9. 阿里云Oss搭建私人图床
  10. 程序员除了写代码,还能做哪些副业?