ARCore Sceneform框架使用(2)

1,954 阅读6分钟

简单介绍

本章节主要介绍一些效果如何进行自定义的实现,因为Sceneform提供的默认效果大概率和产品以及UI需要的效果不一致😭

自定义找平动画

首先查看BaseArFragment,发现有一个 planDiscoveryController,从名字能看出来,是个找平控制类

planeDiscoveryController = new PlaneDiscoveryController(instructionsView);

现在还不知道呢是干什么的,我们看下代码

public class PlaneDiscoveryController {
  @Nullable private View planeDiscoveryView;

  public PlaneDiscoveryController(@Nullable View planeDiscoveryView) {
    this.planeDiscoveryView = planeDiscoveryView;
  }

  /** 在sceneform view 上设置指示view */
  public void setInstructionView(View view) {
    planeDiscoveryView = view;
  }

  /** 找平时显示指示view */
  public void show() {
    if (planeDiscoveryView == null) {
      return;
    }

    planeDiscoveryView.setVisibility(View.VISIBLE);
  }

  /** 隐藏找平指示view */
  public void hide() {
    if (planeDiscoveryView == null) {
      return;
    }

    planeDiscoveryView.setVisibility(View.GONE);
  }
}

现在可以确定是用来控制显示隐藏找平动画的控制类,上面new PlaneDiscoveryController时传进来的view是这样建立的

View instructionsView = loadPlaneDiscoveryView(inflater, container);

然后看下这个函数,👌,找到了,修改加载的layout即可完成找平动画的修改。

private View loadPlaneDiscoveryView(LayoutInflater inflater, @Nullable ViewGroup container) {
    return inflater.inflate(R.layout.sceneform_plane_discovery_layout, container, false);
  }

自定义平面标志

当时花费了很长时间来处理找平标志的修改,sceneform默认的是聚光灯效果,是这样的

这个是怎么做的呢?翻看BaseArFragment的源码可以看到一个ArSceneView,接着来看下ArSceneView的源码,从名字可以知道就是用来显示ar场景的

private CameraStream cameraStream;
private PlaneRenderer planeRenderer;

然后看到持有CameraStream相机流,还有PlaneRenderer,名字可以知道是平面渲染,找到了✌,进入到🍐🍜看下,找到一个方法loadPlaneMaterial

private void loadPlaneMaterial() {
        //Sampler是取样器
        Sampler sampler = Sampler.builder().setMinMagFilter(MagFilter.LINEAR).setWrapMode(WrapMode.REPEAT).build();
        CompletableFuture<Texture> textureFuture = Texture.builder().setSource(this.renderer.getContext(), drawable.sceneform_plane).setSampler(sampler).build();
        this.planeMaterialFuture = Material.builder().setSource(this.renderer.getContext(), raw.sceneform_plane_material).build().thenCombine(textureFuture, (material, texture) -> {
            material.setTexture("texture", texture);
            material.setFloat3("color", 1.0F, 1.0F, 1.0F);
            float widthToHeightRatio = 0.5711501F;
            float scaleX = 8.0F;
            float scaleY = scaleX * widthToHeightRatio;
            material.setFloat2("uvScale", scaleX, scaleY);
            Iterator var6 = this.visualizerMap.entrySet().iterator();

            while(var6.hasNext()) {
                Entry<Plane, PlaneVisualizer> entry = (Entry)var6.next();
                if (!this.materialOverrides.containsKey(entry.getKey())) {
                    ((PlaneVisualizer)entry.getValue()).setPlaneMaterial(material);
                }
            }

            return material;
        });
    }

Sampler用来设置贴图的显示方式,可通过WrapMode设置贴图在材质表面的排列方式,有

  • CLAMP_TO_EDGE 默认,延伸到边,如贴图小会进行拉伸
  • REPEAT 重复
  • MIRRORED_REPEAT 镜像重复 MagFilter是放大滤波,在物体被放大时而贴图分辨率较小时对贴图进行处理,有两种选择
  • NEAREST 选择中心点最接近纹理坐标的那个像素
  • LINEAR 选择附近的一系列点计算插值 MinFilter是缩小滤波,当物体被缩小时或者显示的很小而贴图分辨率很高时对贴图进行处理,这里不进行介绍。

从Texture.builder()可以找到贴图的源文件drawable.sceneform_plane,接下来就是让人头疼的时候了,发现所有相关的设置方法全是private的,没有办法修改。这里说下我的一劳永逸的解决方式:Ctrl+C,Ctrl+v😜,直接复制BaseArFragment的代码到自己的ArFragment中,并实现其中的抽象函数,而且一个Base类中的业务太多,可以将其中的权限请求、找平控制、手势控制等抽象出来,方面设计扩展。后面就是自定义ArSceneView、PlaneRenderer分别继承ArSceneView、PlaneRenderer以实现修改PlaneRenderer的loadPlaneMaterial()的方法(我太难了😭),但是这是存在问题的,平面渲染,顾名思义,就是只有是平面才能进行渲染,这就意味着大小不定与形状不规则,如果产品想要的是圆圈或者某种图形作为标志,用户点击标志进行放置呢?这样就存在图形渲染不全的问题,这怎么办呢?听我娓娓道来😉

Renderable类有两个子类,一个是ModelRenderable,用来建立3d模型的渲染对象,另一个是ViewRenderable,则是用来建立View的渲染对象,也就是2d的。标志也是个2d的(当然3d的也可以),那么就可以将标志作为一个渲染对象将其渲染到场景中并且跟随镜头移动。

创建标志对象

ViewRenderable.builder()
    .setView(this, R.layout.layout_ar_position) //可以是自定义View也可以是布局文件
    .build()
    .thenAccept { t: ViewRenderable ->
        viewRenderable = t
        Log.d("viewRender","Build viewRenderable success!")
    }
    .exceptionally {
    	Log.d("viewRender","Build viewRenderable failed,$it")
        return@exceptionally null
    }

通过异步操作建立viewRenderable

放置标志对象

arFragment.setOnTapArPlaneListener { hitResult, _, _ ->
    // 创建锚点
    anchor = hitResult!!.createAnchor()
    val anchorNode = AnchorNode(anchor)
    anchorNode.setParent(arFragment.arSceneView?.scene)

    // 固定模型到锚点
    positionNode.setParent(anchorNode)
    //设置渲染内容
    positionNode.renderable = viewRenderable
    //取消其阴影,可视需求而定
    viewRenderable.isShadowCaster = false
    //因为viewRenderable默认是垂直的,所以我们需要进行旋转
    //首先将其与父节点的对齐方式设置为垂直居中
    viewRenderable.verticalAlignment = ViewRenderable.VerticalAlignment.CENTER
    //然后将其旋转90度,这样view的中心就与其position一致
    positionNode.localRotation = Quaternion(1f, 0f, 0f, 1f)
}

跟随镜头移动

要跟随镜头移动,需要获取到屏幕中心点在场景中的worldPosition,然后在镜头移动时实时设置标志对象的坐标

// 每帧刷新时需要实时获取焦点
@Override
public void onUpdate(FrameTime frameTime) {
    Frame frame = arSceneView.getArFrame();
    if (frame == null) {
        return;
    }
    // 是否找平成功
    boolean isTracking = false;
    for (Plane plane : frame.getUpdatedTrackables(Plane.class)) {
        if (plane.getTrackingState() == TrackingState.TRACKING) {
            planeDiscoveryController.hide();
            isTracking = true;
        }
    }
    //屏幕中心焦点
    Vector3 focusPoint = getFocusPoint(frame, arSceneView.getWidth(), arSceneView.getHeight());
    if (onFocusPointChangeListener != null && focusPoint != null) {
        onFocusPointChangeListener.onFocusPointChange(focusPoint, isTracking);
    }
}
// 通过屏幕上的坐标获取到场景中的坐标
@Nullable
public Vector3 getFocusPoint(Frame frame, int width, int height) {
    Vector3 focusPoint;

    // 中心点命中平面,返回Verctor3
    List<HitResult> hits = frame.hitTest(width / 2.0f, height / 2.0f);
    if (hits != null && !hits.isEmpty()) {
        for (HitResult hit : hits) {
            Trackable trackable = hit.getTrackable();
            Pose hitPose = hit.getHitPose();
            if (trackable instanceof Plane && ((Plane) trackable).isPoseInPolygon(hitPose)) {
                focusPoint = new Vector3(hitPose.tx(), hitPose.ty(), hitPose.tz());
                lastPlaneHitDistance = hit.getDistance();
                return focusPoint;
            }
        }
        if (hits.size() > 0) {
            Pose hitPose = hits.get(0).getHitPose();
            return new Vector3(hitPose.tx(), hitPose.ty(), hitPose.tz());
        }
    }

    // 如果中心点未命中平面,为了使移动更平滑,获取相机的姿态与最后一次找到平面时的深度作为坐标
    Pose cameraPose = frame.getCamera().getPose();
    Vector3 cameraPosition = new Vector3(cameraPose.tx(), cameraPose.ty(), cameraPose.tz());
    float[] zAxis = cameraPose.getZAxis();
    Vector3 backwards = new Vector3(zAxis[0], zAxis[1], zAxis[2]);

    focusPoint = Vector3.add(cameraPosition, backwards.scaled(-lastPlaneHitDistance));
    return focusPoint;
}
// 建立屏幕中心坐标的监听接口
public interface OnFocusPointChangeListener {
    void onFocusPointChange(@NonNull Vector3 focusPoint, Boolean isTracking);
}

以上在BaseArFragment中进行操作,在Activity中需要设置监听并模拟点击放置

arFragment.setOnFocusPointChangeListener { vector, isTracking ->
    if (isTracking) {
        positionNode.worldPosition = Vector3(vector)
        //模拟点击
        if(!hasFitted) {
            ArHelper.simulateClick(
                    view, view.left + view.width / 2.0f, view.top + view.height / 2.0f
            )
        }
    }
}

/*模拟用户点击*/
fun simulateClick(
        view: View,
        x: Float,
        y: Float
) {
    var downTime: Long = SystemClock.uptimeMillis()
    val downEvent =
            MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0)
    downTime += 1000
    val upEvent =
            MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_UP, x, y, 0)
    view.onTouchEvent(downEvent)
    view.onTouchEvent(upEvent)
    downEvent.recycle()
    upEvent.recycle()
}

在实际操作中,由于ArCore的缺陷,会存在渲染丢失的问题,如在移动过快或者遮挡镜头等可能造成ArCore对于场景的构建识别中出现问题时,会造成渲染的2D标志、模型会找不到在哪里😭,这需要等待ArCore对场景进行重新构建来重新确定Render的位置。但是在丢失的这段时间里如需要显示,可以如下设置

arFragment.setOnFocusPointChangeListener { vector, isTracking ->
    if (isTracking) {
        positionNode.worldPosition = Vector3(vector)
        if(!positonNode.isEnabled) {
            //在丢失时,ARCore会将node的active和enable设置为false,所以需要设置其isEnabled为true
            //这样表示node启用了也就是可见的
            positonNode.isEnabled = true
        }
        //模拟点击
        if(!hasFitted) {
            ArHelper.simulateClick(
                    view, view.left + view.width / 2.0f, view.top + view.height / 2.0f
            )
        }
    }
}

总结

总的来说,Sceneform是个很优秀的框架,但是可扩展性是非常差的。在实现基础功能之后,如需扩展或者修改现有的实现,需要进行大幅度的修改,建议实现复杂功能时仅作为参考