/ 今日科技快讯 /
近日,“乘联会”微信公众号发布消息,2021年12月新能源乘用车市场多元化发力,厂商批发销量突破万辆的企业有14家,较前期大幅增多,其中:比亚迪93338辆、特斯拉中国70847辆、上汽通用五菱60372辆、长城汽车20926辆、奇瑞汽车20501辆、吉利汽车16831辆、小鹏汽车16000辆、上汽乘用车14868辆、广汽埃安14500辆、理想汽车14087辆、一汽大众11213辆、蔚来汽车10489辆、长安汽车10404辆、合众汽车10127辆。
/ 作者简介 /
本篇文章转自却把清梅嗅的博客,文章主要分享了他用OpenGL模仿自如APP的裸眼3D效果,相信会对大家有所帮助!
原文地址:
https://juejin.cn/post/7035645207278256165
/ 概述 /
之前看到自如团队发布的自如客APP裸眼3D效果的实现 ,非常有趣,不久后,社区内Android的开发者们陆续提供了Flutter、 Android原生 、Android Jetpack Compose等不同的实现版本。
很快我看到了一个好玩的评论:
既然客户端都卷成这样了,干脆破罐破摔,把Android OpenGL的实现版本也补齐,毕竟图形学或许会迟到,但绝不会缺席。
实现效果如下,这一波属实参与到社区内裸眼3D的客户端大满贯了:
/ 原理简介&OpenGL的优势 /
裸眼3D原理其它文章都拆解非常清晰了,本着不重复造轮子的原则,这里引用Nayuta和付十一文章中的部分内容,再次感谢。
裸眼3D效果的本质是——将整个图片结构分为3层:上层、中层、以及底层。在手机左右上下旋转时,上层和底层的图片呈相反的方向进行移动,中层则不动,在视觉上给人一种3D的感觉:
也就是说效果是由以下三张图构成的:
前景 | 中景(文字是白色的) | 背景 |
接下来,如何感应手机的旋转状态,并将三层图片进行对应的移动呢?当然是使用设备自身提供各种各样优秀的传感器了,通过传感器不断回调获取设备的旋转状态,对 UI 进行对应地渲染即可。
笔者最终选择了Android平台上的OpenGL API进行渲染,直接的原因是,无需将社区内已有的实现方案重复照搬。
另一个重要的原因是,GPU更适合图形、图像的处理,裸眼3D效果中有大量的缩放和位移操作,都可在java层通过一个矩阵对几何变换进行描述,通过shader小程序中交给GPU处理 ——因此,理论上OpenGL的渲染性能比其它几个方案更好一些。
本文重点是描述OpenGL绘制时的思路描述,因此下文仅展示部分核心代码,对具体实现感兴趣的读者可参考文末的链接。
/ 具体实现 /
绘制静态图片
首先需要将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;
}
定义好了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(0, 0, 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.0f, 0.0f, 0.0f, 1.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, 1, false, matrix, 0);
// ...
// 3.执行绘制
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
}
}
参考drawLayerInner的代码,其用于绘制单层的图像,其中textureId参数对应不同图像,matrix参数对应不同的几何变换。
现在我们完成了图像静态的绘制,效果如下:
接下来我们需要接入传感器,并定义不同层级图片各自的几何变换,让图片动起来。
让图片动起来
首先我们需要对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的这篇文章,内部已经将思路阐述的非常清晰,强烈建议读者进行阅读。
Flutter仿自如App裸眼3D效果:
https://github.com/DylanCaiCoding/ActivityResultLauncher
明白了这一点,我们就能理解,裸眼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.10f, 0f); // 2.平移
Matrix.scaleM(frontMatrix, 0, SCALE_FORE_GROUND, SCALE_FORE_GROUND, 1f); // 1.缩放
Matrix.multiplyMM(mFrontMatrix, 0, mProjectionMatrix, 0, frontMatrix, 0); // 3.正交投影
}
}
这段代码中还有几点细节需要处理。
几个反直觉的细节
旋转方向 ≠ 位移方向
首先,设备旋转方向和图片的位移方向是相反的,举例来说,当设备沿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);
默认旋转角度 ≠ 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效果抖动的问题:
如图,由于传感器过于灵敏,即使平稳的握住设备,XYZ三个方向上微弱的变化都会影响到用户的实际体验,会给用户带来帕金森综合征的自我怀疑。
解决这个问题,传统的OpenGL以及Android API似乎都无能为力,好在GitHub上有人提供了另外一个思路。
熟悉信号处理的同学比较了解,为了通过剔除短期波动、保留长期发展趋势提供了信号的平滑形式,可以使用低通滤波器,保证低于截止频率的信号可以通过,高于截止频率的信号不能通过。
因此有人建立了这个仓库,通过对Android传感器追加低通滤波,过滤掉小的噪声信号,达到较为平稳的效果:
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/Bhide/Low-Pass-Filter-To-Android-Sensors
大功告成,最终我们实现了预期的效果:
/ 参考 /
最后是本文中提到的相关资料,再次感谢先驱者的付出实践。
自如客APP裸眼3D效果的实现:
https://juejin.cn/post/6989227733410644005
拿去吧你!Flutter仿自如App裸眼3D效果:
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
本文所有源码地址如下:
https://github.com/qingmei2/OpenGL-demo
推荐阅读:
聊一聊Kotlin委托的那些事,看懂这个你也是Kotlin高手
欢迎关注我的公众号
学习技术或投稿
长按上图,识别图中二维码即可关注