ArcRotateCamera Target Screen Offset Issue with Raycast in Third-Person View

I’ve set my ArcRotateCamera’s targetScreenOffset to Vector2(0, -1) to position my third-person character. To prevent the camera from clipping through obstacles, I’m using raycasting.

The raycast is functioning correctly, detecting obstacles like walls, and adjusting the camera’s position. However, despite this adjustment, the camera still shows the wall itself due to the targetScreenOffset. You can see the issue in the video below.

Does anyone have suggestions on how to resolve this?

Here is the pseudo code;

// Get the player's position
const playerPos = capsule.position.clone();

// Get the direction opposite to the camera's forward direction
const direction = camera.getForwardRay().direction.negate();

// Create a ray from the player's position in the opposite direction
const ray = new BABYLON.Ray(playerPos, direction, api.camera.defaultRadius);

// Perform a multi-pick with the ray
const picks = scene.multiPickWithRay(ray);

// Sort the pick results by distance and filter out any mesh that starts with "Chr_"
const sortedPicks = picks
    .sort((a, b) => a.distance - b.distance)
    .filter(pick => !pick.pickedMesh.name.startsWith("Chr_"));

// Get the first valid pick
const pick = sortedPicks[0];

if (pick && pick.hit) {
    // If a hit was detected, set the camera's position to the picked point
    camera.position = pick.pickedPoint.clone();
} else {
    // Otherwise, set the camera's radius to the default radius
    camera.radius = api.camera.defaultRadius;
}

Between camera near plane, precision, etc, I would suggest to use a volumetric way instead of a raycast.
Many solutions are available:

It’s a bit more work but with raycast only, you will always find a edge case that will not work. Like being trapped in a corner for example.

2 Likes

This should be because the start of the ray is not in the right place. The start position should not be “playerPos” but the actual camera target.
But as far as I know the camera’s getTarget() function doesn’t take targetScreenOffset into account.

1 Like

Currently I’m using two TransformNode to simulate targetScreenOffset. It works fine.

Camera Collision Example

    const character = new PhyCharacter(scene)
    character.height = 1

    let offsetY = new TransformNode("offsetY", scene)
    offsetY.parent = character
    offsetY.position.y = 1
    offsetY.billboardMode = 2

    let offsetX = new TransformNode("offsetX", scene)
    offsetX.parent = offsetY
    offsetX.position.x = 0.5   

    let camera = createCamera(scene)
    camera.targetHost = offsetX
    camera.addBehavior(new PhyArcCameraCollision())
PhyArcCameraCollision.ts

import { Behavior } from "@babylonjs/core/Behaviors/behavior"
import { PhysicsRaycastResult } from "@babylonjs/core/Physics/physicsRaycastResult"
import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera"
import { Scalar, TmpVectors, Vector3 } from "@babylonjs/core"

export class PhyArcCameraCollision implements Behavior<ArcRotateCamera> {
    name: string = "BehaviorArcCameraCollision"

    private _originalGetViewMatrix: any
    target: ArcRotateCamera | undefined
    collideWith: number = 0b0001

    _targetRadius: number = 0
    _currentRadius: number = 0

    testSize: number = 1

    smooth: boolean = false
    smoothFactor: number = 20

    init(): void {}

    attach(target: ArcRotateCamera): void {
        const scene = target.getScene()
        if (!target.getScene().physicsEnabled) {
            console.warn("Physics engine not enabled, phyArcCameraCollision is invalid")
        }
        this.target = target
        this._originalGetViewMatrix = target._getViewMatrix.bind(target)

        let result = new PhysicsRaycastResult()
        target._getViewMatrix = () => {
            let originRaidus = target.radius
            let phyEngine = target.getScene().getPhysicsEngine()?.getPhysicsPlugin()!
            if (!phyEngine) {
                return this._originalGetViewMatrix()
            }

            let from = target.getTarget()

            let dir = TmpVectors.Vector3[1]
            target.getDirectionToRef(Vector3.LeftHandedBackwardReadOnly, dir)

            let to = from.add(dir.scale(originRaidus + this.testSize))

            phyEngine.raycast(from, to, result, { collideWith: this.collideWith })

            if (result.hasHit) {
                this._targetRadius = result.hitDistance - this.testSize
            } else {
                this._targetRadius = originRaidus
            }

            if (this.smooth) {
                this._currentRadius = Scalar.Lerp(
                    this._currentRadius,
                    this._targetRadius,
                    (this.smoothFactor * scene.getEngine().getDeltaTime()) / 1000,
                )
            } else {
                this._currentRadius = this._targetRadius
            }
            this._currentRadius = Math.max(0.001, this._currentRadius)

            target.radius = Math.min(originRaidus, this._currentRadius)

            let viewMatrix = this._originalGetViewMatrix(target)

            target.radius = originRaidus
            return viewMatrix
        }

        scene.onBeforeRenderObservable.add(() => {})
    }

    detach(): void {
        if (this.target) {
            this.target._getViewMatrix = this._originalGetViewMatrix
        }
    }
}

2 Likes

You may have a look how it is done in _cameraElastic behavior here

2 Likes

Won t add anything interesting to the topic but just want to say I love the style of the gamer you are building @ertugrulcetin :slight_smile:

2 Likes

Thank you!!

I finally found a solution after a few days. I created a small box and update its position every frame based on the camera’s right direction. The position of the box is set to the character’s position plus the camera’s right direction, and I set the camera’s target to that box (made it invisible, no targetScreenOffset applied).

4 Likes