Android OpenGLES 实现蓝线挑战特效

2,199 阅读5分钟

抖音的实现效果

打开抖音,搜索蓝线挑战特效,点击拍摄,就可以看到如下效果

抖音实现.gif

注意到,该特效有如下特点

  • 预览界面有一根蓝线,均匀得在竖直方向上运动
  • 蓝线的上方,显示的是上一帧的画面
  • 蓝线的下方,显示的是正在预览的画面
  • 随着蓝线的运动上一帧不断被保留,最终可以得到一副奇奇怪怪的画面

这个特效虽然看着很普通,但结合使用者的创意,可以玩出各种各样的花样,下面就来看看如何实现

先看看笔者实现的效果

实现效果

自实现.gif

注意到,实现的效果来看,和抖音的还是比较吻合,除了蓝线的颜色,笔者的蓝线是纯蓝色的(#0000FF),当然,颜色可以任意调整

特效分析

那么问题来了,这样的特效应该如何实现呢

当笔者第一次看到这个特效的时候,就在想应该如何使用OpenGLES去实现,尝试了各种方式,首先遇到的几个问题

  • 如何让画面能否保留下来,即保留上一帧
  • 如何让画面随着时间的推移,蓝线运动,且不断的保留上一帧

注意到,上面问题都提到了的一个关键字保留上一帧,其实保留上一帧就是实现该特效的关键

笔者最先想到的实现方式是:

  • 使用glReadPixels的方式,根据时间,不断的读取数据
  • 将读取到的数据显示在一张Bitmap上,然后再渲染出来

方法有了,那么就开始实现,实现的过程中,越来越觉得不对劲,这样不断地读数据,再渲染,会不会太麻烦了,还有,这样的实现肯定会有内存功耗问题,一定有其他简单的实现方式

往往越简单的事情,在不了解其本质的时候就想得很复杂,把简单的事情复杂化,这样就算实现出来,也没什么意义,所以要观察其本质保留上一帧就是其本质

笔者也是琢磨了很久,如何保留上一帧,保留后要如何再显示出来,当笔者一筹莫展的时候,突然发现Fbo就有保留上一帧的功能,好了,本质找到了,那么就着手实现

Fbo保留上一帧

首先,Fbo的概念性的东西,大家可以上网查查,这里就直接说说Fbo的作用

  • Oes纹理转换2D纹理

    预览相机、播放视频等这些通过SurfaceTexture方式渲染的,一般都是使用Oes纹理,而当需要在相机预览或者播放视频中添加水印/贴纸,则需要先将Oes纹理转化成2D纹理,因为Oes纹理和2D纹理是不能同时使用

  • 保留帧

    让当前渲染的纹理保留在一个帧缓存里,而不显示在屏幕上

蓝线挑战这个特效,用到的就是Fbo保留帧功能

观察上面的动图,会发现,蓝线上方显示的是上一帧,而蓝线下方显示的是正在预览的画面,这也就意味着需要两个纹理

  • lastTextureId

    上一帧渲染的纹理

  • textureId

    当前预览的纹理

BaseRender这个类,是笔者封装的一个基础渲染类,里面实现了基础的渲染、绑定Fbo、绑定Vbo,如果需要,可以到Github中拿来用

OpenGLES实现

接下来看看如何在着色器中实现

顶点着色器

attribute vec4 aPos;
attribute vec2 aCoordinate;
varying vec2 vCoordinate;
void main(){
    vCoordinate = aCoordinate;
    gl_Position = aPos;
}

注意到,顶点着色器没有任何特殊处理

片元着色器

precision mediump float;
uniform sampler2D uSampler;
uniform sampler2D uSampler2;
varying vec2 vCoordinate;
uniform float uOffset;
void main(){
    if (vCoordinate.y < uOffset) {
        gl_FragColor = texture2D(uSampler2, vCoordinate);
    } else {
        gl_FragColor = texture2D(uSampler, vCoordinate);
    }
}

片元着色器的实现也比较简单,简单分析下

  • uSampler表示当前预览的纹理

  • uSampler2表示上一帧的纹理

  • uOffset是外部传入的一个float类型的值,用于控制显示上一帧和显示当前预览画面

  • main函数里,只做了一个if判断,如果当前y轴坐标小于uOffset,则显示上一帧,否则显示当前预览画面

看到这里,你可能会说,啊,不会吧,这样就实现了?

当然不是,这里只是着色器,接下来看看Java层那边是如何做的

RetainFrameVerticalRender.java

public class RetainFrameVerticalRender extends BaseRender {
    private final BaseRender lastRender;

    private int uSampler2Location;
    private int uOffsetLocation;

    private int lastTextureId = -1;

    private float offset;

    public RetainFrameVerticalRender(Context context) {
        super(
                context,
                "render/other/retain_frame_vertical/vertex.frag",
                "render/other/retain_frame_vertical/frag.frag"
        );

        lastRender = new BaseRender(context);

        lastRender.setBindFbo(true);
    }

    @Override
    public void onCreate() {
        super.onCreate();
        lastRender.onCreate();
    }

    @Override
    public void onChange(int width, int height) {
        super.onChange(width, height);
        lastRender.onChange(width, height);
    }

    @Override
    public void onDraw(int textureId) {
        super.onDraw(textureId);
        lastRender.onDraw(getFboTextureId());
        lastTextureId = lastRender.getFboTextureId();
    }

    @Override
    public void onInitLocation() {
        super.onInitLocation();
        uSampler2Location = GLES20.glGetUniformLocation(getProgram(), "uSampler2");
        uOffsetLocation = GLES20.glGetUniformLocation(getProgram(), "uOffset");
    }

    @Override
    public void onActiveTexture(int textureId) {
        super.onActiveTexture(textureId);
        GLES20.glActiveTexture(GLES20.GL_TEXTURE1);
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, lastTextureId);
        GLES20.glUniform1i(uSampler2Location, 1);
    }

    @Override
    public void onSetOtherData() {
        super.onSetOtherData();
        GLES20.glUniform1f(uOffsetLocation, offset);
    }

    public void setOffset(float offset) {
        this.offset = offset;
    }
}

注意到,该Render内部创建了一个lastRender,这个lastRender就是用来保留上一帧,那么它是如何保留住的呢(把不把握住,哈哈)

  • 在创建的时候,调用BaseRendersetBindFbo方法,让其绑定Fbo,之前笔者也说过,BaseRender是笔者自定义一个基础渲染类,包括渲染、绑定Fbo、绑定Vbo之类的操作
  • onDraw中,将当前渲染后的Fbo纹理传入lastRenderonDraw方法中,此时,因为LaseRender绑定了Fbo,则对应的内容不渲染到屏幕,而是保留在帧缓存里,接着获取LaseRenderFbo纹理,并赋值给LaseTextureId
  • 于是,就得到了两个纹理,一个是当前相机纹理,一个是LastRender保留的上一帧纹理,也就分别对应着着色器里的uSampleruSampler2

这样,通过控制uOffset的值,就可以达到对应的效果

到这里,还差一点,就是蓝线

那么,接下来就来绘制下蓝线

蓝线绘制

蓝线的绘制就比较简单,在RetainFrameVerticalRender.java绘制完成后,再使用其Fbo纹理,则可以拿来做蓝线的渲染

顶点着色器

attribute vec4 aPos;
attribute vec2 aCoordinate;
varying vec2 vCoordinate;
void main(){
    vCoordinate = aCoordinate;
    gl_Position = aPos;
}

同样未做特殊处理

片元着色器

precision mediump float;
uniform sampler2D uSampler;
varying vec2 vCoordinate;
uniform float uOffset;
const vec4 COLOR = vec4(0.0, 0.0, 1.0, 1.0);
const float SIZE = 0.005;
void main(){
    if (vCoordinate.y > uOffset - SIZE && vCoordinate.y < uOffset + SIZE) {
        gl_FragColor = COLOR;
    } else {
        gl_FragColor = texture2D(uSampler, vCoordinate);
    }
}

注意到,里面定义了两个常量

  • COLOR

    这个即是蓝线的颜色,可以根据需求,自定义对应的颜色

    这里笔者定义为“纯”蓝色

  • SIZE

    这个即是蓝线的宽度,可以根据屏幕的大小来定义

然后到main函数,这里是一个判断,如果当前y轴坐标在以uOffset为中心,宽度为SIZE的范围内的话,则让当前的像素值设置为定义的COLOR,否者使用texture2D函数获取当前纹理的像素值

接下来看看Java层的实现

MoveLineVerticalRender.java

public class MoveLineVerticalRender extends BaseRender {
    private int uOffsetLocation;

    private float offset;

    public MoveLineVerticalRender(Context context) {
        super(
                context,
                "render/other/move_line_vertical/vertex.frag",
                "render/other/move_line_vertical/frag.frag"
        );
    }

    @Override
    public void onInitLocation() {
        super.onInitLocation();
        uOffsetLocation = GLES20.glGetUniformLocation(getProgram(), "uOffset");
    }

    @Override
    public void onSetOtherData() {
        super.onSetOtherData();
        GLES20.glUniform1f(uOffsetLocation, offset);
    }

    public void setOffset(float offset) {
        this.offset = offset;
    }
}

Java层的实现就比较简单,只是传入uOffset而已

那么结合上面的RetainFrameVerticalRender.java,可以创建一个类

BlueLineChallengeVFilter.java

public class BlueLineChallengeVFilter extends BaseFilter {
    private final RetainFrameVerticalRender inputRender;

    private final MoveLineVerticalRender outputRender;

    public BlueLineChallengeVFilter(Context context) {
        super(context);

        inputRender = new RetainFrameVerticalRender(context);
        inputRender.setBindFbo(true);

        outputRender = new MoveLineVerticalRender(context);
        outputRender.setBindFbo(true);

        timeStart(15000);
    }

    @Override
    public void onCreate() {
        inputRender.onCreate();
        outputRender.onCreate();
    }

    @Override
    public void onChange(int width, int height) {
        inputRender.onChange(width, height);
        outputRender.onChange(width, height);
    }

    @Override
    public void onDraw(int textureId) {
        float progress = getProgress();

        inputRender.setOffset(progress);
        outputRender.setOffset(progress);

        inputRender.onDraw(textureId);
        outputRender.onDraw(inputRender.getFboTextureId());
    }

    @Override
    public int getFboTextureId() {
        return outputRender.getFboTextureId();
    }

    @Override
    public void onRelease() {
        super.onRelease();
        inputRender.onRelease();
        outputRender.onRelease();
    }
}

该类并非又做了什么处理,只是将RetainFrameVerticalRender.javaMoveLineVerticalRender.java结合起来而已

可以看到内部会创建两个Render,一个是RetainFrameVerticalRender.java,另个就是MoveLineVerticalRender.java

然后在onDraw中依次渲染即可

有细心的同学,可能注意到Render的命名,Render中有一个Vertical单词,表示纵向的蓝线挑战,如果想实现横向的,其实也比较简单,把之前着色器里面的判断y坐标的地方都换成x即可,具体可以到Github中查看BlueLineChallengeHFilter

看看最终实现的效果

最终实现

自实现.gif

GitHub

该特效相关代码,均可以在Github中找到

BlueLineChallengeVFilter

BlueLineChallengeHFilter