背景
前面我们分别分析了Rajawali中场景的创建与物体的绘制,这篇文章我们将梳理一下点击事件的实现。这里我们参照general中的拖动例程,看看一个物体如何实现点击事件的捕获与响应。
实现流程
IObjectPicker
这个接口定义了Picker类的功能,Picker用于最终确定实际选择的物体到底是哪个。
//设置选择监听器
public void setOnObjectPickedListener(OnObjectPickedListener objectPickedListener);
//为Picker传入当前选择的坐标
public void getObjectAt(float x, float y);
ObjectColorPicker
这个类实现了IObjectPicker接口,主要实现如下功能:
1.设置objectPickedListener,在回调接口中返回当前选中物体的引用
2.getObjectAt 获取点击的位置,用来传入坐标,计算那个物体被选择
3.registerObject 注册要被点击的物体,其中mObjectLookup列表中维
护要选择物体的列表
4.pickObject 静态方法,在Scene中调用,通过屏幕点击位置来获取被点击物体在mObjectLookup列表中索引,并通过objectPickedListener的onObjectPicked的参数返回这个物体的引用
现在我们具体看下这些功能的实现。
物体注册监听
响应触摸事件的物体列表
private final List<Object3D> mObjectLookup
将物体注册到Picker中
public void registerObject(Object3D object) {
if (!mObjectLookup.contains(object)) {
mObjectLookup.add(object);
//注意此处,mColorIndex是mObjectLookup中物体的索引值,被以某个颜色的方式记录在物体的一个叫做mPickingColor属性中
object.setPickingColor(mColorIndex);
++mColorIndex;
}
}
下面是setPickingColor方法的实现
public void setPickingColor(int colorIndex) {
mPickingIndex = colorIndex;
mPickingColor[RED] = Color.red(colorIndex) / 255f;
mPickingColor[GREEN] = Color.green(colorIndex) / 255f;
mPickingColor[BLUE] = Color.blue(colorIndex) / 255f;
mPickingColor[ALPHA] = Color.alpha(colorIndex) / 255f;
}
绘制pickingMaterial
我们会发现Object3D类中还有一个变量叫做mColor。这个颜色是物体真实显示的颜色,那么,mPickingColor是什么呢?此处不免让人感觉疑惑,我们从字面意思来看,这个颜色值应该是与物体选择有关的,带着这个问题,我们继续追踪。
我们接着看Object3D类的renderColorPicking方法,mPickingColor的值最终在这里被使用。
...
//pickingMaterial只初始化顶点和颜色
pickingMaterial.useProgram();
pickingMaterial.setVertices(mGeometry.getVertexBufferInfo());
pickingMaterial.setColor(mPickingColor);
pickingMaterial.applyParams();
...
//将绘制mMaterial的矩阵给了pickingMaterial
pickingMaterial.setMVPMatrix(mMVPMatrix);
pickingMaterial.setModelMatrix(mMMatrix);
pickingMaterial.setModelViewMatrix(mMVMatrix);
//绘制pickingMaterial
int bufferType = mGeometry.getIndexBufferInfo().bufferType == Geometry3D.BufferType.SHORT_BUFFER ? GLES20.GL_UNSIGNED_SHORT : GLES20.GL_UNSIGNED_INT;
GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, mGeometry.getIndexBufferInfo().bufferHandle);
GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0);
通过实验打印信息我们可以知道,这段代码会在触摸到物体的时候调用一次。也就是说,这个pickingMaterial只有在被点击的时候才会被绘制,而它的颜色就是就是我们上文中所说,那个由索引值生成的颜色。
我们可以继续跟踪这个颜色的传递,可以发现所有相关的颜色都被传递到shader的一个uniform中。
muColorHandle = getUniformLocation(programHandle, DefaultShaderVar.U_COLOR);
我们可以打印一下这种情况下的的顶点和片元着色器,来看一下这个color值是怎么处理的。
VertexShader
precision mediump float;
uniform mat4 uMVPMatrix;
uniform vec4 uColor;
uniform mat4 uModelMatrix;
uniform mat3 uNormalMatrix;
uniform mat4 uModelViewMatrix;
attribute vec3 aNormal;
attribute vec2 aTextureCoord;
attribute vec4 aPosition;
varying vec3 vNormal;
varying vec4 vColor;
varying vec2 vTextureCoord;
varying vec3 vEyeDir;
vec4 gColor;
vec4 gPosition;
vec3 gNormal;
vec2 gTextureCoord;
void main() {
gPosition = aPosition;
gNormal = aNormal; gTextureCoord = aTextureCoord;
gColor = uColor;
gl_Position = uMVPMatrix * gPosition;
vNormal = normalize(uNormalMatrix * gNormal); vTextureCoord = gTextureCoord;
vColor = gColor;
vEyeDir = vec3(uModelViewMatrix * gPosition);
}
fragment
precision mediump float;
uniform float uColorInfluence;
varying vec3 vNormal;
varying vec4 vColor;
varying vec2 vTextureCoord;
varying vec3 vEyeDir;
float gShadowValue;
float gSpecularValue;
vec4 gColor;
vec3 gNormal;
vec2 gTextureCoord;
void main() {
gNormal = normalize(vNormal);
gTextureCoord = vTextureCoord;
gColor = uColorInfluence * vColor;
gShadowValue = 0.0;
gSpecularValue = 1.0;
gl_FragColor = gColor;
}
uColorInfluence 在FragmentShander中默认为1,所以,最终这个color是原封不动地给了gl_FragColor。这就是说,当我们点击的那一时刻,其实物体显示的是pickingMaterial,而其他时刻还是显示正常的Material。
获取点击物体
有了上面注册,我们接着整理点击事件时如何传递给物体的。
如上图所示,其实我们在上面分析的是Object3D的renderColorPicking方法,这个方法由Scene调用,在点击时,调用其中所有物体的renderColorPicking方法,也就是说给所有的物体换了张皮,而皮的颜色就是物体在Picker的物体列表中的索引值。
下面我们就可以通过获取物体的颜色,来得到这个索引了。ObjectColorPicker的静态方法pickObject就是帮我们处理这个问题的。
public static void pickObject(ColorPickerInfo pickerInfo) {
final ObjectColorPicker picker = pickerInfo.getPicker();
OnObjectPickedListener listener = picker.mObjectPickedListener;
if (listener != null) {
final ByteBuffer pixelBuffer = ByteBuffer.allocateDirect(4);
pixelBuffer.order(ByteOrder.nativeOrder());
GLES20.glReadPixels(pickerInfo.getX(),
picker.mRenderer.getViewportHeight() - pickerInfo.getY(),
1, 1, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, pixelBuffer);
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
pixelBuffer.rewind();
//使用pickerInfo中的信息生成生成模型的索引值
final int r = pixelBuffer.get(0) & 0xff;
final int g = pixelBuffer.get(1) & 0xff;
final int b = pixelBuffer.get(2) & 0xff;
final int a = pixelBuffer.get(3) & 0xff;
final int index = Color.argb(a, r, g, b);
if (0 <= index && index < picker.mObjectLookup.size()) {
// Index may have holes due to unregistered objects
Object3D pickedObject = picker.mObjectLookup.get(index);
if (pickedObject != null) {
//将被触摸的模型作为回掉接口的参数传递回去,我们在renderer中就可以得到物体的引用。
listener.onObjectPicked(pickedObject);
return;
}
}
listener.onNoObjectPicked();
}
}
可见,此处我们直接获取触摸点的颜色值,然后将其转换为数值并在列表中查找。如果列表中这个索引不为空,就将这个物体返回。
OnObjectPickedListener
回到我们的render中,由于需要进行触摸事件的监听,所以我们的render类实现了OnObjectPickedListener接口,这样直接在我们的render中实现public void onObjectPicked(@NonNull Object3D object)方法。这里参数就是我们当前所触摸的模型,所以我们可以从这里得到当前触摸模型的引用。
同时这里我们发现上面pickerInfo中的x,y其实是在getObjectAt方法中传入的,这里的x,y就是我们在touch事ACTION_DOWN时按下的触摸点。这里最终调用了下面方法,生成一个ColorPickerInfo,用来标识选中物体的信息。
mRenderer.getCurrentScene().requestColorPicking(new ColorPickerInfo(x, y, this));
这样,这个ColorPickerInfo被传入了Scene中。Scene的render方法调用doColorPicking(mPickerInfo);方法,而doColorPicking中调用ObjectColorPicker.pickObject(pickerInfo);,也就是我们文中讲到的,最终这里会通过回调接口返回那个被点击物体的引用。
而在接口的第三个方法stopMovingSelectedObject中,我们只需把被触摸模型的引用置为空即可。
实现模型随手指拖拽的效果
能获得模型的引用,我们只需修改模型的X,Y即可(一般情况手指平面上移动,不改变模型的Z坐标)。所以此处我们需要做的是将屏幕的X,Y坐标转换为模型在场景中的世界坐标。Demo中的处理方法如下,此处还需进一步理解。
public void moveSelectedObject(float x, float y){
if (mSelectedObject == null)
return;
//将当前x,y在近平面的屏幕坐标转换为世界坐标,mNearPos4为输出坐标
GLU.gluUnProject(x, getViewportHeight() - y, 0, mViewMatrix.getDoubleValues(), 0,
mProjectionMatrix.getDoubleValues(), 0, mViewport, 0, mNearPos4, 0);
//将当前x,y在远平面的屏幕坐标转换为世界坐标,mFarPos4为输出坐标
GLU.gluUnProject(x, getViewportHeight() - y, 1.f, mViewMatrix.getDoubleValues(), 0,
mProjectionMatrix.getDoubleValues(), 0, mViewport, 0, mFarPos4, 0);
//将坐标同时除以w
mNearPos.setAll(mNearPos4[0] / mNearPos4[3], mNearPos4[1]
/ mNearPos4[3], mNearPos4[2] / mNearPos4[3]);
mFarPos.setAll(mFarPos4[0] / mFarPos4[3],
mFarPos4[1] / mFarPos4[3], mFarPos4[2] / mFarPos4[3]);
//用归一化Z的值计算一个比率
double factor = (Math.abs(mSelectedObject.getZ()) + mNearPos.z)
/ (getCurrentCamera().getFarPlane() - getCurrentCamera()
.getNearPlane());
mNewObjPos.setAll(mFarPos);
mNewObjPos.subtract(mNearPos);
mNewObjPos.multiply(factor);
mNewObjPos.add(mNearPos);
mSelectedObject.setX(mNewObjPos.x);
mSelectedObject.setY(mNewObjPos.y);
}
总结
上述流程比较复杂,在这里捋一下基本思路。首先,有一个Picker用作维护所有的要进行点击的物体列表。每个物体都有两张皮(两个Material,分别是mMaterial和pickingMatrial),其中mMaterial存储物体真实的材质,并且在一般情况下显示,pickingMatrial中存储一个颜色值,这个颜色值其实是这个物体在Picker中维护列表的索引值。当我们点击物体时,这一帧绘制其实绘制的是pickingMatrial这张皮,紧接着我们就把触摸点的颜色拿出来,还原成数字,并以此为索引在列表中寻找物体,如果找到,就把这个物体的引用返回,这样就获得了被点击的物体。
那么为什么要兜这么大的圈子来处理而不直接根据坐标判断触点呢?这里我猜想是这样的。对于一些简单的几何体我们很容易确定一个点是否包含在物体内,但是,脑补一个五角星,如何判断一个点是否在这种不规则图形内部呢?根据不同形状设置判断规则显然是不合适的。此处使用物体颜色作为索引就避开了这个问题,只要在触摸的那一帧进行颜色替换,就能直接拿到物体的索引。