首页 文章详情

这交互炸了,Android 仿自如APP裸眼 3D 效果 OpenGL 版

字节流动 | 316 2021-12-22 23:34 0 0 0
UniSMS (合一短信)

本文作者


作者:却把清梅嗅

链接:

https://juejin.cn/post/7035645207278256165

本文由作者授权发布。


之前自如系列各个版本:

自如App裸眼3D效果最近火爆了,各个版本齐了~


1
概述


之前看到 自如团队 发布的 自如客APP裸眼3D效果的实现 ,非常有趣,不久后,社区内 Android 的开发者们陆续提供了 Flutter、 Android 原生 、Android Jetpack Compose 等不同的实现版本。


自如客APP裸眼3D效果的实现

https://juejin.cn/post/6989227733410644005


Flutter 版本:

https://juejin.cn/post/6991409083765129229


Android 原生

https://juejin.cn/post/6991840263362576421


Android Jetpack Compose

https://juejin.cn/post/6992169168938205191


很快我看到了一个好玩的评论:



既然客户端都卷成这样了,干脆破罐破摔,把 Android OpenGL 的实现版本也补齐,毕竟 图形学或许会迟到,但绝不会缺席 。


实现效果如下(图片来源),这一波属实参与到社区内裸眼3D的 客户端大满贯 了 :

https://juejin.cn/post/6991409083765129229



2
原理简介 & OpenGL 的优势


裸眼 3D 原理其它文章都拆解非常清晰了,本着不重复造轮子的原则,这里引用 Nayuta 和 付十一 文章中的部分内容,再次感谢。

https://juejin.cn/post/6991409083765129229

https://juejin.cn/post/6992169168938205191


裸眼 3D 效果的本质是——将整个图片结构分为 3 层:上层、中层、以及底层。在手机左右上下旋转时,上层和底层的图片呈相反的方向进行移动,中层则不动,在视觉上给人一种 3D 的感觉:


也就是说效果是由以下三张图构成的:


前景中景(文字是白色的)背景


接下来,如何感应手机的旋转状态,并将三层图片进行对应的移动呢?当然是使用设备自身提供各种各样优秀的传感器了,通过传感器不断回调获取设备的旋转状态,对 UI 进行对应地渲染即可。


笔者最终选择了 Android 平台上的 OpenGL API 进行渲染,直接的原因是,无需将社区内已有的实现方案重复照搬。


另一个重要的原因是,GPU 更适合图形、图像的处理,裸眼3D效果中有大量的缩放和位移操作,都可在 java 层通过一个 矩阵 对几何变换进行描述,通过 shader 小程序中交给 GPU 处理 ——因此,理论上 OpenGL 的渲染性能比其它几个方案更好一些。


本文重点是描述 OpenGL 绘制时的思路描述,因此下文仅展示部分核心代码,对具体实现感兴趣的读者可参考文末的链接。

3
具体实现


1. 绘制静态图片


首先需要将3张图片依次进行静态绘制,这里涉及大量 OpenGL API 的使用,不熟悉的读可略读本小节,以捋清思路为主。


首先看一下顶点和片元着色器的 shader 代码,其定义了图像纹理是如何在GPU中处理渲染的:


// 顶点着色器代码
// 顶点坐标
attribute vec4 av_Position;
// 纹理坐标
attribute vec2 af_Position;
uniform mat4 u_Matrix;
varying vec2 v_texPo;

void main() {
    v_texPo = af_Position;
    gl_Position =  u_Matrix * av_Position;
}

// 顶点着色器代码
// 顶点坐标
attribute vec4 av_Position;
// 纹理坐标
attribute vec2 af_Position;
uniform mat4 u_Matrix;
varying vec2 v_texPo;

void main() {
    v_texPo = af_Position;
    gl_Position =  u_Matrix * av_Position;
}

定义好了 Shader ,接下来在 GLSurfaceView (可以理解为 OpenGL 中的画布) 创建时,初始化Shader小程序,并将图像纹理依次加载到GPU中:


public class My3DRenderer implements GLSurfaceView.Renderer {

  @Override
  public void onSurfaceCreated(GL10 gl, EGLConfig config) {
      // 1.加载shader小程序
      mProgram = loadShaderWithResource(
              mContext,
              R.raw.projection_vertex_shader,
              R.raw.projection_fragment_shader
      );

      // ... 

      // 2. 依次将3张切图纹理传入GPU
      this.texImageInner(R.drawable.bg_3d_back, mBackTextureId);
      this.texImageInner(R.drawable.bg_3d_mid, mMidTextureId);
      this.texImageInner(R.drawable.bg_3d_fore, mFrontTextureId);
  }
}

接下来是定义视口的大小,因为是2D图像变换,且切图和手机屏幕的宽高比基本一致,因此简单定义一个单位矩阵的正交投影即可:


public class My3DRenderer implements GLSurfaceView.Renderer {

    // 投影矩阵
    private float[] mProjectionMatrix = new float[16];

    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {
        // 设置视口大小,这里设置全屏
        GLES20.glViewport(00, width, height);
        // 图像和屏幕宽高比基本一致,简化处理,使用一个单位矩阵
        Matrix.setIdentityM(mProjectionMatrix, 0);
    }
}

最后就是绘制,读者需要理解,对于前、中、后三层图像的渲染,其逻辑是基本一致的,差异仅仅有2点:图像本身不同 以及 图像的几何变换不同。


public class My3DRenderer implements GLSurfaceView.Renderer {

    private float[] mBackMatrix = new float[16];
    private float[] mMidMatrix = new float[16];
    private float[] mFrontMatrix = new float[16];

    @Override
    public void onDrawFrame(GL10 gl) {
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
        GLES20.glClearColor(0.0f0.0f0.0f1.0f);

        GLES20.glUseProgram(mProgram);

        // 依次绘制背景、中景、前景
        this.drawLayerInner(mBackTextureId, mTextureBuffer, mBackMatrix);
        this.drawLayerInner(mMidTextureId, mTextureBuffer, mMidMatrix);
        this.drawLayerInner(mFrontTextureId, mTextureBuffer, mFrontMatrix);
    }

    private void drawLayerInner(int textureId, FloatBuffer textureBuffer, float[] matrix) {
        // 1.绑定图像纹理
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
        // 2.矩阵变换
        GLES20.glUniformMatrix4fv(uMatrixLocation, 1false, matrix, 0);
        // ...
        // 3.执行绘制
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 04);
    }
}

参考 drawLayerInner 的代码,其用于绘制单层的图像,其中 textureId 参数对应不同图像,matrix 参数对应不同的几何变换。


现在我们完成了图像静态的绘制,效果如下:



接下来我们需要接入传感器,并定义不同层级图片各自的几何变换,让图片动起来。


2. 让图片动起来


首先我们需要对 Android 平台上的传感器进行注册,监听手机的旋转状态,并拿到手机 xy 轴的旋转角度。


// 2.1 注册传感器
mSensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
mAcceleSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
mMagneticSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
mSensorManager.registerListener(mSensorEventListener, mAcceleSensor, SensorManager.SENSOR_DELAY_GAME);
mSensorManager.registerListener(mSensorEventListener, mMagneticSensor, SensorManager.SENSOR_DELAY_GAME);

// 2.2 不断接受旋转状态
private final SensorEventListener mSensorEventListener = new SensorEventListener() {
    @Override
    public void onSensorChanged(SensorEvent event) {
        // ... 省略具体代码
        float[] values = new float[3];
        float[] R = new float[9];
        SensorManager.getRotationMatrix(R, null, mAcceleValues, mMageneticValues);
        SensorManager.getOrientation(R, values);
        // x轴的偏转角度
        float degreeX = (float) Math.toDegrees(values[1]);
        // y轴的偏转角度
        float degreeY = (float) Math.toDegrees(values[2]);
        // z轴的偏转角度
        float degreeZ = (float) Math.toDegrees(values[0]);

        // 拿到 xy 轴的旋转角度,进行矩阵变换
        updateMatrix(degreeX, degreeY);
    }
};

注意,因为我们只需控制图像的左右和上下移动,因此,我们只需关注设备本身 x 轴和 y 轴的偏转角度:



拿到了 x 轴和 y 轴的偏转角度后,接下来开始定义图像的位移了。


但如果将图片直接进行位移操作,将会因为位移后图像的另一侧没有纹理数据,导致渲染结果有黑边现象,为了避免这个问题,我们需要将图像默认从中心点进行放大,保证图像移动的过程中,不会超出自身的边界。


也就是说,我们一开始进入时,看到的肯定只是图片的部分区域。给每一个图层设置 scale,将图片进行放大。显示窗口是固定的,那么一开始只能看到图片的正中位置。(中层可以不用,因为中层本身是不移动的,所以也不必放大)


这里的处理参考自 Nayuta 的 这篇文章,内部已经将思路阐述的非常清晰,强烈建议读者进行阅读。

https://juejin.cn/post/6991409083765129229#heading-4


明白了这一点,我们就能理解,裸眼 3D 的效果实际上就是对 不同层级的图像 进行 缩放 和 位移 的变换,下面是分别获取几何变换的代码:


public class My3DRenderer implements GLSurfaceView.Renderer {

    private float[] mBackMatrix = new float[16];
    private float[] mMidMatrix = new float[16];
    private float[] mFrontMatrix = new float[16];

    /**
     * 陀螺仪数据回调,更新各个层级的变换矩阵.
     *
     * @param degreeX x轴旋转角度,图片应该上下移动
     * @param degreeY y轴旋转角度,图片应该左右移动
     */

    private void updateMatrix(@FloatRange(from = -180.0f, to = 180.0f) float degreeX,
                              @FloatRange(from = -180.0f, to = 180.0f) float degreeY) 
{
        // ... 其它处理                                                

        // 背景变换
        // 1.最大位移量
        float maxTransXY = MAX_VISIBLE_SIDE_BACKGROUND - 1f;
        // 2.本次的位移量
        float transX = ((maxTransXY) / MAX_TRANS_DEGREE_Y) * -degreeY;
        float transY = ((maxTransXY) / MAX_TRANS_DEGREE_X) * -degreeX;
        float[] backMatrix = new float[16];
        Matrix.setIdentityM(backMatrix, 0);
        Matrix.translateM(backMatrix, 0, transX, transY, 0f);                    // 2.平移
        Matrix.scaleM(backMatrix, 0, SCALE_BACK_GROUND, SCALE_BACK_GROUND, 1f);  // 1.缩放
        Matrix.multiplyMM(mBackMatrix, 0, mProjectionMatrix, 0, backMatrix, 0);  // 3.正交投影

        // 中景变换
        Matrix.setIdentityM(mMidMatrix, 0);

        // 前景变换
        // 1.最大位移量
        maxTransXY = MAX_VISIBLE_SIDE_FOREGROUND - 1f;
        // 2.本次的位移量
        transX = ((maxTransXY) / MAX_TRANS_DEGREE_Y) * -degreeY;
        transY = ((maxTransXY) / MAX_TRANS_DEGREE_X) * -degreeX;
        float[] frontMatrix = new float[16];
        Matrix.setIdentityM(frontMatrix, 0);
        Matrix.translateM(frontMatrix, 0, -transX, -transY - 0.10f0f);            // 2.平移
        Matrix.scaleM(frontMatrix, 0, SCALE_FORE_GROUND, SCALE_FORE_GROUND, 1f);    // 1.缩放
        Matrix.multiplyMM(mFrontMatrix, 0, mProjectionMatrix, 0, frontMatrix, 0);  // 3.正交投影
    }
}

这段代码中还有几点细节需要处理。


3. 几个反直觉的细节


3.1 旋转方向 ≠ 位移方向


首先,设备旋转方向和图片的位移方向是相反的,举例来说,当设备沿 X 轴旋转,对于用户而言,对应前后景的图片应该上下移动,反过来,设备沿 Y 轴旋转,图片应该左右移动(没太明白的同学可参考上文中陀螺仪的图片加深理解):


// 设备旋转方向和图片的位移方向是相反的
float transX = ((maxTransXY) / MAX_TRANS_DEGREE_Y) * -degreeY;
float transY = ((maxTransXY) / MAX_TRANS_DEGREE_X) * -degreeX;
// ...
Matrix.translateM(backMatrix, 0, transX, transY, 0f); 

3.2 默认旋转角度 ≠ 0°


其次,在定义最大旋转角度的时候,不能主观认为旋转角度 = 0°是默认值。什么意思呢?Y 轴旋转角度为0°,即 degreeY = 0 时,默认设备左右的高度差是 0,这个符合用户的使用习惯,相对易于理解,因此,我们可以定义左右的最大旋转角度,比如 Y ∈ (-45°,45°),超过这两个旋转角度,图片也就移动到边缘了。


但当 X 轴旋转角度为0°,即 degreeX = 0 时,意味着设备上下的高度差是 0,你可以理解为设备是放在水平的桌面上的,这个绝不符合大多数用户的使用习惯,相比之下,设备屏幕平行于人的面部 才更适用大多数场景(degreeX = -90):



因此,代码上需对 X、Y 轴的最大旋转角度区间进行分开定义:


private static final float USER_X_AXIS_STANDARD = -45f;
private static final float MAX_TRANS_DEGREE_X = 25f;   // X轴最大旋转角度 ∈ (-20°,-70°)

private static final float USER_Y_AXIS_STANDARD = 0f;
private static final float MAX_TRANS_DEGREE_Y = 45f;   // Y轴最大旋转角度 ∈ (-45°,45°)

解决了这些 反直觉 的细节问题,我们基本完成了裸眼3D的效果。


4. 帕金森综合征?


还差一点就大功告成了,最后还需要处理下3D效果抖动的问题:



如图,由于传感器过于灵敏,即使平稳的握住设备,XYZ 三个方向上微弱的变化都会影响到用户的实际体验,会给用户带来 帕金森综合征 的自我怀疑。


解决这个问题,传统的 OpenGL 以及 Android API 似乎都无能为力,好在 GitHub 上有人提供了另外一个思路。


熟悉信号处理的同学比较了解,为了通过剔除短期波动、保留长期发展趋势提供了信号的平滑形式,可以使用 低通滤波器,保证低于截止频率的信号可以通过,高于截止频率的信号不能通过。


因此有人建立了 这个仓库 , 通过对 Android 传感器追加低通滤波 ,过滤掉小的噪声信号,达到较为平稳的效果:

https://github.com/Bhide/Low-Pass-Filter-To-Android-Sensors


private final SensorEventListener mSensorEventListener = new SensorEventListener() {
    @Override
    public void onSensorChanged(SensorEvent event
{
        // 对传感器的数据追加低通滤波
        if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
            mAcceleValues = lowPass(event.values.clone(), mAcceleValues);
        }
        if (event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) {
            mMageneticValues = lowPass(event.values.clone(), mMageneticValues);
        }

        // ... 省略具体代码
        // x轴的偏转角度
        float degreeX = (float) Math.toDegrees(values[1]);
        // y轴的偏转角度
        float degreeY = (float) Math.toDegrees(values[2]);
        // z轴的偏转角度
        float degreeZ = (float) Math.toDegrees(values[0]);

        // 拿到 xy 轴的旋转角度,进行矩阵变换
        updateMatrix(degreeX, degreeY);
    }
};

大功告成,最终我们实现了预期的效果:


源码地址


本文所有源码,请查看 这里 

https://github.com/qingmei2/OpenGL-demo/blob/master/app/src/main/java/com/github/qingmei2/opengl_demo/c_image_process/processor/C06Image3DProcessor.java


参考


最后是本文中提到的相关资料,再次感谢先驱者的付出实践。


自如客APP裸眼3D效果的实现 @自如大前端团队

https://juejin.cn/post/6989227733410644005

拿去吧你!Flutter 仿自如 App 裸眼 3D 效果 @Nayuta

https://juejin.cn/post/6991409083765129229

Compose版来啦!仿自如裸眼3D效果 @付十一

https://juejin.cn/post/6992169168938205191

GitHub: Low-Pass-Filter-To-Android-Sensors

https://github.com/Bhide/Low-Pass-Filter-To-Android-Sensors


推荐阅读:

Android FFmpeg 实现带滤镜的微信小视频录制功能

全网最全的 Android 音视频和 OpenGL ES 干货,都在这了

Android FFmpeg + OpenGL ES 实现 3D 全景播放器

抖音传送带特效是怎么实现的?

所有你想要的图片转场效果,都在这了

Android OpenGL ES 实现 3D 阿凡达效果

我用 OpenGL ES 给小姐姐做了几个抖音滤镜

good-icon 0
favorite-icon 0
收藏
回复数量: 0
    暂无评论~~
    Ctrl+Enter