Search code examples
androidkotlinorientationarcoresceneform

How to orient GLTF in Android Sceneform level with gravity


I've been getting my butt kicked trying to get a vertically placed 3d model GLB format placed properly on a vertical surface.

Just to be clear, I am not referring to the difficulty of identifying vertical surface, that is a whole other problem in itself.

Removing common boilerplate of setup to minimize this post.

I am using a fragment that extends ARFragment.

class SceneFormARFragment: ArFragment() {

Then of course I have supplied the config with a few tweaks.

override fun getSessionConfiguration(session: Session?): Config {
    val config = super.getSessionConfiguration(session)
    // By default we are not tracking and tracking is driven by startTracking()
    config.planeFindingMode = Config.PlaneFindingMode.DISABLED
    config.focusMode = Config.FocusMode.AUTO
    return config
}

And to start and stop my AR experience I wrote a couple of methods inside the fragment as follows.

private fun startTracking() = viewScope.launchWhenResumed {
    try {
        arSceneView.session?.apply {
            val changedConfig = config
            changedConfig.planeFindingMode = Config.PlaneFindingMode.HORIZONTAL_AND_VERTICAL
            configure(changedConfig)
        }

        logv("startTracking")
        planeDiscoveryController.show()
        arSceneView.planeRenderer.isVisible = true
        arSceneView.cameraStreamRenderPriority = 7
    } catch (ex: Exception) {
        loge("error starting ar session: ${ex.message}")
    }
}
private fun stopTracking() = viewScope.launchWhenResumed {
    try {
        arSceneView.session?.apply {
            val changedConfig = config
            changedConfig.planeFindingMode = Config.PlaneFindingMode.DISABLED
            configure(changedConfig)
        }

        logv("stopTracking")
        planeDiscoveryController.hide()
        arSceneView.planeRenderer.isVisible = false
        arSceneView.cameraStreamRenderPriority = 0
    } catch (ex: Exception) {
        loge("error stopping ar session: ${ex.message}")
    }
}

In case you are wondering the reason for "starting and stopping" the AR experience is to maximize the GPU cycles for other UX interactions that are heavy on this overlaid screen, so we wait to start or stop based on current live data state of other things that are happening.

Ok moving on.

Let's review the HitResult handling: In this method I do a few things:

  1. Load two variations of TV 3d models from the cloud (wall mount and stand mount)
  2. I remove any active models if they have tapped a new area
  3. Create an anchor node from the hitresult and assign it a name to remove it later
  4. Add a TVTransformableNode to it and assign it a name to retrieve and manipulate it later
  5. Determine the look direction of the horizontal stand mount 3D Model TV and set the worldRotation of the anchorNode to the new lookRotation. (NOTE*, I feel like the rotation should be applied to the TVNode, but it only seems to work when I apply it to the AnchorNode for whatever reason.) This camera position math also seems to help the vertical wall mount TV face outwards and anchor correctly. (I have reviewed the GLB models and I know they are properly anchored from the back on the wall model and from the bottom on the floor model)
  6. I then limit the plane movement of the node to it's own respective plane type so that a floor model doesn't slide up to a wall and so that a wall model doesn't slide down to the floor.

That's about it. The horizontal placement works great, but the vertical placement is always randomized.

OnTapArPlane Code below:

 private fun onARSurfaceTapped() {
    setOnTapArPlaneListener { hitResult, plane, _ ->
        var isHorizontal = false
        val renderable = when (plane.type) {
            Plane.Type.HORIZONTAL_UPWARD_FACING -> {
                isHorizontal = true
                standmountTVRenderable
            }
            Plane.Type.VERTICAL                 -> wallmountTVRenderable
            else                                -> {
                activity?.toast("Do you want it to fall on your head really?")
                return@setOnTapArPlaneListener
            }
        }

        lastSelectedPlaneOrientation = plane.type
        removeActive3DTVModel()

        val anchorNode = AnchorNode(hitResult.createAnchor())
        anchorNode.name = TV_ANCHOR_NAME
        anchorNode.setParent(arSceneView.scene)

        val tvNode = TransformableNode(this.transformationSystem)
        tvNode.scaleController.isEnabled = false
        tvNode.setParent(anchorNode)
        tvNode.name = TV_NODE_NAME
        tvNode.select()

        // Set orientation towards camera
        // Ref: https://github.com/google-ar/sceneform-android-sdk/issues/379
        val cameraPosition = arSceneView.scene.camera.worldPosition
        val tvPosition = anchorNode.worldPosition
        val direction = Vector3.subtract(cameraPosition, tvPosition)

        if(isHorizontal) {
            tvNode.translationController.allowedPlaneTypes.clear()
            tvNode.translationController.allowedPlaneTypes.add(Plane.Type.HORIZONTAL_UPWARD_FACING)
        } else {
            tvNode.translationController.allowedPlaneTypes.clear()
            tvNode.translationController.allowedPlaneTypes.add(Plane.Type.VERTICAL)
        }

        val lookRotation = Quaternion.lookRotation(direction, Vector3.up())
        anchorNode.worldRotation = lookRotation

        tvNode.renderable = renderable

        addVideoTo3DModel(renderable)
    }
}

Ignore the addvideoTo3dModel call, as that works fine, and I commented it out just to ensure it doesn't play a role.

Things I've tried.

  1. Extracting Translation without Rotation like described here interestingly enough, it does cause the TV to appear level with the floor each time, but then the TV is always mounted as if the anchor is at the base instead of the center back. So it's bad.
  2. I've tried reviewing various posts and translating Unity or ARCore stuff directly into Sceneform, but failed to get anything to affect the outcome. example
  3. I've tried creating the anchor from the plane and the pose as indicated in this answer with no luck
  4. I've reviewed this link but never found anything useful
  5. I've tracked this issue and tried solutions recommended by people in the thread, but no luck
  6. The last thing I tried, and this is a bit embarrassing lol. I opened all 256 tagged with "SceneForm" in Stack Overflow and reviewed EVERY SINGLE one of them for anything that would help.

So I've exhausted the internet. All I have left is to ask the community and of course send help to SceneForm team at Android which I'm also going to do.

My best guess is that I need to do the Quaternion.axisRotation(Vector3, Float), but everything I have guessed at or trialed and errored has not worked. I assume I need to set the localRotation using worldPostion values for xyz of the phone maybe to help identify gravity. I really just don't know anymore lol.

I know Sceneform is pretty new and the documentation is HORRIBLE and may as well not exist with the lack of content or doc headers on it. The developers must really not want people to use it yet I'm guessing :(.

Last thing I'll say, is everything is working perfectly in my current implementation with the exception of the rotated vertical placement. Just to avoid rabbit trails on this discussion, I'm not having any other issues.

Oh and one last clue that I've noticed. The TV almost seems to pivot around the center of the vertical plane, based on where I tap, the bottom almost seems to point towards the arbitrary center of the plane, if that helps anyone figure it out.

Oh and yes, I know my textures are missing from the GLBs, I packaged them incorrectly and intend to fix it later.

Screenshots attached. enter image description here enter image description here


Solution

  • Well I finally got it. Took awhile and some serious trial and error of rotating every node, axis, angle, and rotation before I finally got it to place nicely. So I'll share my results in case anyone else needs this as well.

    End Result looked like: enter image description here

    Of course it is mildly subjective to how you held the phone and it's understanding of the surroundings, but it's always pretty darn close to level now without fail in both landscape and portrait testing that I have done.

    So here's what I've learned.

    Setting the worldRotation on the anchorNode will help keep the 3DModel facing towards the cameraview using a little subtraction.

    val cameraPosition = arSceneView.scene.camera.worldPosition
    val tvPosition = anchorNode.worldPosition
    val direction = Vector3.subtract(cameraPosition, tvPosition)
    val lookRotation = Quaternion.lookRotation(direction, Vector3.up())
    anchorNode.worldRotation = lookRotation
    

    However, this did not fix the orientation issue on the vertical placement. I found that if i did an X Rotation of 90 degress on the look rotation it worked everytime. It may differ based on your 3d model, but my anchor is center middle back, so I'm not sure how it determine which way was up. However, I noticed whenever I would set a worldRotation on the tvNode it would place the TV level, but would be leaning forward 90 degress. So after playing with the various rotations, I finally got the answer.

    val tvRotation = Quaternion.axisAngle(Vector3(1f, 0f, 0f), 90f)
    tvNode.worldRotation = tvRotation
    

    That fixed up my problem. So The end Result of the onSurfaceTap and placement was this:

       setOnTapArPlaneListener { hitResult, plane, _ ->
                    var isHorizontal = false
                    val renderable = when (plane.type) {
                        Plane.Type.HORIZONTAL_UPWARD_FACING -> {
                            isHorizontal = true
                            standmountTVRenderable
                        }
                        Plane.Type.VERTICAL -> wallmountTVRenderable
                        else -> {
                            activity?.toast("Do you want it to fall on your head really?")
                            return@setOnTapArPlaneListener
                        }
                    }
    
                    lastSelectedPlaneOrientation = plane.type
                    removeActive3DTVModel()
    
                    val anchorNode = AnchorNode(hitResult.createAnchor())
                    anchorNode.name = TV_ANCHOR_NAME
                    anchorNode.setParent(arSceneView.scene)
    
                    val tvNode = TransformableNode(this.transformationSystem)
                    tvNode.scaleController.isEnabled = false //disable scaling
                    tvNode.setParent(anchorNode)
                    tvNode.name = TV_NODE_NAME
                    tvNode.select()
    
                    val cameraPosition = arSceneView.scene.camera.worldPosition
                    val tvPosition = anchorNode.worldPosition
                    val direction = Vector3.subtract(cameraPosition, tvPosition)
    
                    //restrict moving node to active surface orientation
                    if (isHorizontal) {
                        tvNode.translationController.allowedPlaneTypes.clear()
                        tvNode.translationController.allowedPlaneTypes.add(Plane.Type.HORIZONTAL_UPWARD_FACING)
                    } else {
                        tvNode.translationController.allowedPlaneTypes.clear()
                        tvNode.translationController.allowedPlaneTypes.add(Plane.Type.VERTICAL)
    
                        //x 90 degree rotation to flat mount TV vertical with gravity
                        val tvRotation = Quaternion.axisAngle(Vector3(1f, 0f, 0f), 90f)
                        tvNode.worldRotation = tvRotation
                    }
    
                    //set anchor nodes world rotation to face the camera view and up
                    val lookRotation = Quaternion.lookRotation(direction, Vector3.up())
                    anchorNode.worldRotation = lookRotation
    
                    tvNode.renderable = renderable
                    viewModel.updateStateTo(AriaMainViewModel.ARFlowState.REPOSITIONING)
                }
    

    This has been tested pretty thoroughly without issues so far in portrait and landscape. I still have other issues with Sceneform, such as the dots only showing up about half the time even when there is a valid surface, and of course vertical detection on a mono color wall is not possible with the current SDK without a picture on the wall or something to distinguish the wall.

    Also performing screenshots is not good as it doesn't include the 3D Model so that required custom Pixel Copy work and my screenshots are a bit slow, but at least they work, no thanks to the SDK.

    So they have a long ways to go and it's frustrating to blaze the trail with their product and lack of documentation and definitely lack of responsiveness to customer serivce as well as GitHub logged issues, but hey at least I got it, and I hope this helps someone else.

    Happy Coding!