Tapping through virtual joystick canvas

I ended up forking and substantially rewriting freeCameraVirtualJoystickInput.ts and virtualJoystick.ts to get what I needed. Specifically, I thew away the “right joystick” that (in BJS’s hardcoded behavior) turns the camera, reduced the size of the 2D canvas to a small area at the bottom left of the viewport, rewrote rendering of the joystick to look the way I wanted, tuned the way the camera responds to the PointerEvents dispatched on the joystick’s canvas, etc.

The resulting custom ICameraInput implementation works well in tandem with freeCameraMouseInput.ts and freeCameraKeyboardMoveInput.ts. I enable the custom joystick input only for users with a touchscreen, of course — including those who might also also have a keyboard and/or a mouse and might not care to use the joystick – I made the joystick look the way that I don’t mind showing it to all touchscreen-equipped folks. freeCameraMouseInput.ts takes care of turning the camera responding to pointer events dispatched to the main 3D scene canvas, and the joystick takes care of moving the camera.

Importantly for the subject of this thread, interacting (tapping, clicking, hovering over, casting rays, etc) with meshes of the scene now just works – except it doesn’t in the small area covered by the joystick’s canvas.

Note that freeCameraMouseInput.ts with touchEnabled=true and FreeCameraTouchInput with allowMouse=true can both be made (without the need to fork them) to rotate the camera and not move it, and out-of-the-box they do it in ways that differ in relatively subtle ways. I overall prefer the freeCameraMouseInput.ts behavior; was able to tune it to do what I needed without forking any more BJS code.

@sevaseva, your custom ICameraInput sounds great-- would you be willing to share it? I’m a novice programmer and Babylon.js user and create projects for mobile iOS and could also very much use a virtual joystick that allows tapping on buttons or other canvas elements.

Thanks for considering :blush:

1 Like

I wonder how some of those could be easier to build without having to fork it all @PolygonalSun ?

Oops, I meant to respond sooner but totally forgot. Here you go @SugarRayLua

lib/camera/joystick.ts

import type { Nullable } from "@babylonjs/core";
import { Vector3, Vector2, StringDictionary } from "@babylonjs/core";

// Mainly based on these 2 articles :
// Creating an universal virtual touch joystick working for all Touch models thanks to Hand.JS : http://blogs.msdn.com/b/davrous/archive/2013/02/22/creating-an-universal-virtual-touch-joystick-working-for-all-touch-models-thanks-to-hand-js.aspx
// & on Seb Lee-Delisle original work: http://seb.ly/2011/04/multi-touch-game-controller-in-javascripthtml5-for-ipad/

/**
 * Defines the potential axis of a Joystick
 */
export enum JoystickAxis {
    /** X axis */
    X,
    /** Y axis */
    Y,
    /** Z axis */
    Z,
}

/**
 * Represents the different customization options available
 * for VirtualJoystick
 */
interface VirtualJoystickCustomizations {
    /**
     * Size of the joystick's puck
     */
    puckSize: number;
    /**
     * Size of the joystick's container
     */
    containerSize: number;
    /**
     * Color of the joystick && puck
     */
    color: string;
    /**
     * Image URL for the joystick's puck
     */
    puckImage?: string;
    /**
     * Image URL for the joystick's container
     */
    containerImage?: string;
    /**
     * Defines the unmoving position of the joystick container
     */
    position?: { x: number; y: number };
    /**
     * Defines whether or not the joystick container is always visible.
     *
     * 2024-01-11 Update: This implementation does not really use this
     * value. Basically, it hard codes that it's always visible and draws it
     * differently from Babylin.js upstream.
     */
    alwaysVisible: boolean;
    /**
     * Defines whether or not to limit the movement of the puck to the joystick's container
     */
    limitToContainer: boolean;
}

/**
 * Class used to define virtual joystick (used in touch mode)
 */
export class VirtualJoystick {
    /**
     * Gets or sets a boolean indicating that left and right values must be inverted
     */
    public reverseLeftRight: boolean;
    /**
     * Gets or sets a boolean indicating that up and down values must be inverted
     */
    public reverseUpDown: boolean;
    /**
     * Gets the offset value for the position (ie. the change of the position value)
     */
    public deltaPosition: Vector3;
    /**
     * Gets a boolean indicating if the virtual joystick was pressed
     */
    public pressed: boolean;
    /**
     * Canvas the virtual joystick will render onto, default z-index of this is 5
     */
    public static Canvas: Nullable<HTMLCanvasElement>;

    /**
     * boolean indicating whether or not the joystick's puck's movement should be limited to the joystick's container area
     */
    public limitToContainer: boolean;

    // Used to draw the virtual joystick inside a 2D canvas on top of the WebGL rendering canvas
    private static _GlobalJoystickIndex: number = 0;
    private static _AlwaysVisibleSticks: number = 0;
    private static _VJCanvasContext: CanvasRenderingContext2D;
    private static _GetDefaultOptions(): VirtualJoystickCustomizations {
        return {
            puckSize: 40,
            containerSize: 60,
            color: "cyan",
            puckImage: undefined,
            containerImage: undefined,
            position: undefined,
            alwaysVisible: false,
            limitToContainer: false,
        };
    }

    private _action: () => any;
    private _axisTargetedByLeftAndRight: JoystickAxis;
    private _axisTargetedByUpAndDown: JoystickAxis;
    private _joystickSensibility: number;
    private _inversedSensibility: number;
    private _joystickPointerId: number;
    private _joystickColor: string;
    private _joystickPointerPos: Vector2;
    private _joystickPreviousPointerPos: Vector2;
    private _joystickPointerStartPos: Vector2;
    private _deltaJoystickVector: Vector2;
    private _touches: StringDictionary<{ x: number; y: number; prevX: number; prevY: number } | PointerEvent>;
    private _joystickPosition: Nullable<Vector2>;
    private _alwaysVisible: boolean;
    private _puckImage: HTMLImageElement;
    private _containerImage: HTMLImageElement;
    private _released = false;

    // size properties
    private _joystickPuckSize: number;
    private _joystickContainerSize: number;
    private _clearPuckSize: number;
    private _clearContainerSize: number;
    private _clearPuckSizeOffset: number;
    private _clearContainerSizeOffset: number;

    private _onPointerDownHandlerRef: (e: PointerEvent) => any;
    private _onPointerMoveHandlerRef: (e: PointerEvent) => any;
    private _onPointerUpHandlerRef: (e: PointerEvent) => any;
    private _onResize: (e: any) => any;

    private _puckLineWidth: number;
    private _canvasSize: number;
    // Transforming X and Y coordinates (within viewport) of a pointer event
    // into coordinates of the same point within the joystick's canvas.
    private _yCoordinateWithinCanvas: (e: PointerEvent) => number;
    private _xCoordinateWithinCanvas: (e: PointerEvent) => number;

    /**
     * Creates a new virtual joystick
     * @param leftJoystick defines that the joystick is for left hand (false by default)
     * @param customizations Defines the options we want to customize the VirtualJoystick
     */
    constructor(customizations?: Partial<VirtualJoystickCustomizations>) {
        const options = {
            ...VirtualJoystick._GetDefaultOptions(),
            ...customizations,
        };

        VirtualJoystick._GlobalJoystickIndex++;

        this._puckLineWidth = 3;
        this._canvasSize = options.containerSize * 2 + options.puckSize * 2 + this._puckLineWidth;
        this._yCoordinateWithinCanvas = (e: PointerEvent) => {
            return e.clientY - window.innerHeight + this._canvasSize;
        }
        this._xCoordinateWithinCanvas = (e: PointerEvent) => {
            // No change because canvas' left edge == viewport's left edge.
            return e.clientX;
        }

        // By default left & right arrow keys are moving the X
        // and up & down keys are moving the Y
        this._axisTargetedByLeftAndRight = JoystickAxis.X;
        this._axisTargetedByUpAndDown = JoystickAxis.Y;

        this.reverseLeftRight = false;
        this.reverseUpDown = false;

        // collections of pointers
        this._touches = new StringDictionary<{ x: number; y: number; prevX: number; prevY: number } | PointerEvent>();
        this.deltaPosition = Vector3.Zero();

        this._joystickSensibility = 25;
        this._inversedSensibility = 1 / (this._joystickSensibility / 1000);

        this._onResize = () => {
            // No need to do anything here.
            // Kepping the method to reduce the diff from upstream
            // Babylon.js version and just in case we need to do something
            // onResize later.
        };

        // injecting a canvas element on top of the canvas 3D game
        if (!VirtualJoystick.Canvas) {
            window.addEventListener("resize", this._onResize, false);
            VirtualJoystick.Canvas = document.createElement("canvas");
            VirtualJoystick.Canvas.width = this._canvasSize;
            VirtualJoystick.Canvas.height = this._canvasSize;
            VirtualJoystick.Canvas.style.position = "absolute";
            VirtualJoystick.Canvas.style.backgroundColor = "transparent";
            VirtualJoystick.Canvas.style.bottom = "0px";
            VirtualJoystick.Canvas.style.left = "0px";
            VirtualJoystick.Canvas.style.zIndex = "5";
            VirtualJoystick.Canvas.style.touchAction = "none"; // fix https://forum.babylonjs.com/t/virtualjoystick-needs-to-set-style-touch-action-none-explicitly/9562
            // Support for jQuery PEP polyfill
            VirtualJoystick.Canvas.setAttribute("touch-action", "none");
            const context = VirtualJoystick.Canvas.getContext("2d");

            if (!context) {
                throw new Error("Unable to create canvas for virtual joystick");
            }

            VirtualJoystick._VJCanvasContext = context;
            VirtualJoystick._VJCanvasContext.strokeStyle = "#ffffff";
            VirtualJoystick._VJCanvasContext.lineWidth = 2;
            document.body.appendChild(VirtualJoystick.Canvas);
        }
        this.pressed = false;
        this.limitToContainer = options.limitToContainer;

        // default joystick color
        this._joystickColor = options.color;

        // default joystick size
        this.containerSize = options.containerSize;
        this.puckSize = options.puckSize;

        if (options.position) {
            this.setPosition(options.position.x, options.position.y);
        } else {
            // By default: middle of the canvas.
            this.setPosition(this._canvasSize / 2, this._canvasSize / 2);
        }
        if (options.puckImage) {
            this.setPuckImage(options.puckImage);
        }
        if (options.containerImage) {
            this.setContainerImage(options.containerImage);
        }
        if (options.alwaysVisible) {
            VirtualJoystick._AlwaysVisibleSticks++;
        }

        // must come after position potentially set
        this.alwaysVisible = options.alwaysVisible;

        this._joystickPointerId = -1;
        // current joystick position
        this._joystickPointerPos = new Vector2(0, 0);
        this._joystickPreviousPointerPos = new Vector2(0, 0);
        // origin joystick position
        this._joystickPointerStartPos = new Vector2(0, 0);
        this._deltaJoystickVector = new Vector2(0, 0);

        this._onPointerDownHandlerRef = (evt) => {
            this._onPointerDown(evt);
        };
        this._onPointerMoveHandlerRef = (evt) => {
            this._onPointerMove(evt);
        };
        this._onPointerUpHandlerRef = (evt) => {
            this._onPointerUp(evt);
        };

        VirtualJoystick.Canvas.addEventListener("pointerdown", this._onPointerDownHandlerRef, false);
        VirtualJoystick.Canvas.addEventListener("pointermove", this._onPointerMoveHandlerRef, false);
        VirtualJoystick.Canvas.addEventListener("pointerup", this._onPointerUpHandlerRef, false);
        VirtualJoystick.Canvas.addEventListener("pointerout", this._onPointerUpHandlerRef, false);
        VirtualJoystick.Canvas.addEventListener(
            "contextmenu",
            (evt) => {
                evt.preventDefault(); // Disables system menu
            },
            false
        );
        requestAnimationFrame(() => {
            this._drawVirtualJoystick();
        });
    }

    /**
     * Defines joystick sensibility (ie. the ratio between a physical move and virtual joystick position change)
     * @param newJoystickSensibility defines the new sensibility
     */
    public setJoystickSensibility(newJoystickSensibility: number) {
        this._joystickSensibility = newJoystickSensibility;
        this._inversedSensibility = 1 / (this._joystickSensibility / 1000);
    }

    private _onPointerDown(e: PointerEvent) {
        e.preventDefault();

        if (this._joystickPointerId < 0) {
            // First contact will be dedicated to the virtual joystick
            this._joystickPointerId = e.pointerId;

            if (this._joystickPosition) {
                this._joystickPointerStartPos = this._joystickPosition.clone();
                this._joystickPointerPos = this._joystickPosition.clone();
                this._joystickPreviousPointerPos = this._joystickPosition.clone();

                // in case the user only clicks down && doesn't move:
                // this ensures the delta is properly set
                this._onPointerMove(e);
            } else {
                this._joystickPointerStartPos.x = this._xCoordinateWithinCanvas(e);
                this._joystickPointerStartPos.y = this._yCoordinateWithinCanvas(e);
                this._joystickPointerPos = this._joystickPointerStartPos.clone();
                this._joystickPreviousPointerPos = this._joystickPointerStartPos.clone();
            }

            this._deltaJoystickVector.x = 0;
            this._deltaJoystickVector.y = 0;
            this.pressed = true;
            this._touches.add(e.pointerId.toString(), e);
        } else {
            // You can only trigger the action buttons with a joystick declared
            if (VirtualJoystick._GlobalJoystickIndex < 2 && this._action) {
                this._action();
                this._touches.add(e.pointerId.toString(), { x: this._xCoordinateWithinCanvas(e), y: this._yCoordinateWithinCanvas(e), prevX: this._xCoordinateWithinCanvas(e), prevY: this._yCoordinateWithinCanvas(e) });
            }
        }
    }

    private _onPointerMove(e: PointerEvent) {
        // If the current pointer is the one associated to the joystick (first touch contact)
        if (this._joystickPointerId == e.pointerId) {
            // limit to container if need be
            if (this.limitToContainer) {
                const vector = new Vector2(this._xCoordinateWithinCanvas(e) - this._joystickPointerStartPos.x, this._yCoordinateWithinCanvas(e) - this._joystickPointerStartPos.y);
                const distance = vector.length();

                if (distance > this.containerSize) {
                    vector.scaleInPlace(this.containerSize / distance);
                }

                this._joystickPointerPos.x = this._joystickPointerStartPos.x + vector.x;
                this._joystickPointerPos.y = this._joystickPointerStartPos.y + vector.y;
            } else {
                this._joystickPointerPos.x = this._xCoordinateWithinCanvas(e);
                this._joystickPointerPos.y = this._yCoordinateWithinCanvas(e);
            }

            // create delta vector
            this._deltaJoystickVector = this._joystickPointerPos.clone();
            this._deltaJoystickVector = this._deltaJoystickVector.subtract(this._joystickPointerStartPos);

            // if a joystick is always visible, there will be clipping issues if
            // you drag the puck from one over the container of the other
            if (0 < VirtualJoystick._AlwaysVisibleSticks) {
                this._joystickPointerPos.x = Math.min(this._canvasSize, this._joystickPointerPos.x);
            }

            const directionLeftRight = this.reverseLeftRight ? -1 : 1;
            const deltaJoystickX = (directionLeftRight * this._deltaJoystickVector.x) / this._inversedSensibility;
            switch (this._axisTargetedByLeftAndRight) {
                case JoystickAxis.X:
                    this.deltaPosition.x = Math.min(1, Math.max(-1, deltaJoystickX));
                    break;
                case JoystickAxis.Y:
                    this.deltaPosition.y = Math.min(1, Math.max(-1, deltaJoystickX));
                    break;
                case JoystickAxis.Z:
                    this.deltaPosition.z = Math.min(1, Math.max(-1, deltaJoystickX));
                    break;
            }
            const directionUpDown = this.reverseUpDown ? 1 : -1;
            const deltaJoystickY = (directionUpDown * this._deltaJoystickVector.y) / this._inversedSensibility;
            switch (this._axisTargetedByUpAndDown) {
                case JoystickAxis.X:
                    this.deltaPosition.x = Math.min(1, Math.max(-1, deltaJoystickY));
                    break;
                case JoystickAxis.Y:
                    this.deltaPosition.y = Math.min(1, Math.max(-1, deltaJoystickY));
                    break;
                case JoystickAxis.Z:
                    this.deltaPosition.z = Math.min(1, Math.max(-1, deltaJoystickY));
                    break;
            }
        } else {
            const data = this._touches.get(e.pointerId.toString());
            if (data) {
                (data as any).x = this._xCoordinateWithinCanvas(e);
                (data as any).y = this._yCoordinateWithinCanvas(e);
            }
        }
    }

    private _onPointerUp(e: PointerEvent) {
        if (this._joystickPointerId == e.pointerId) {
            this._clearPreviousDraw();

            this._joystickPointerId = -1;
            this.pressed = false;
        } else {
            const touch = <{ x: number; y: number; prevX: number; prevY: number }>this._touches.get(e.pointerId.toString());
            if (touch) {
                VirtualJoystick._VJCanvasContext.clearRect(touch.prevX - 44, touch.prevY - 44, 88, 88);
            }
        }
        this._deltaJoystickVector.x = 0;
        this._deltaJoystickVector.y = 0;

        this._touches.remove(e.pointerId.toString());
    }

    /**
     * Change the color of the virtual joystick
     * @param newColor a string that must be a CSS color value (like "red") or the hexa value (like "#FF0000")
     */
    public setJoystickColor(newColor: string) {
        this._joystickColor = newColor;
    }

    /**
     * Size of the joystick's container
     */
    public set containerSize(newSize: number) {
        this._joystickContainerSize = newSize;
        this._clearContainerSize = ~~(this._joystickContainerSize * 2.1);
        this._clearContainerSizeOffset = ~~(this._clearContainerSize / 2);
    }
    public get containerSize() {
        return this._joystickContainerSize;
    }

    /**
     * Size of the joystick's puck
     */
    public set puckSize(newSize: number) {
        this._joystickPuckSize = newSize;
        this._clearPuckSize = ~~(this._joystickPuckSize * 2.1);
        this._clearPuckSizeOffset = ~~(this._clearPuckSize / 2);
    }
    public get puckSize() {
        return this._joystickPuckSize;
    }

    /**
     * Clears the set position of the joystick
     */
    public clearPosition() {
        this.alwaysVisible = false;

        this._joystickPosition = null;
    }

    /**
     * Defines whether or not the joystick container is always visible
     */
    public set alwaysVisible(value: boolean) {
        if (this._alwaysVisible === value) {
            return;
        }

        if (value && this._joystickPosition) {
            VirtualJoystick._AlwaysVisibleSticks++;

            this._alwaysVisible = true;
        } else {
            VirtualJoystick._AlwaysVisibleSticks--;

            this._alwaysVisible = false;
        }
    }
    public get alwaysVisible() {
        return this._alwaysVisible;
    }

    /**
     * Sets the constant position of the Joystick container
     * @param x X axis coordinate
     * @param y Y axis coordinate
     */
    public setPosition(x: number, y: number) {
        // just in case position is moved while the container is visible
        if (this._joystickPointerStartPos) {
            this._clearPreviousDraw();
        }

        this._joystickPosition = new Vector2(x, y);
    }

    /**
     * Defines a callback to call when the joystick is touched
     * @param action defines the callback
     */
    public setActionOnTouch(action: () => any) {
        this._action = action;
    }

    /**
     * Defines which axis you'd like to control for left & right
     * @param axis defines the axis to use
     */
    public setAxisForLeftRight(axis: JoystickAxis) {
        switch (axis) {
            case JoystickAxis.X:
            case JoystickAxis.Y:
            case JoystickAxis.Z:
                this._axisTargetedByLeftAndRight = axis;
                break;
            default:
                this._axisTargetedByLeftAndRight = JoystickAxis.X;
                break;
        }
    }

    /**
     * Defines which axis you'd like to control for up & down
     * @param axis defines the axis to use
     */
    public setAxisForUpDown(axis: JoystickAxis) {
        switch (axis) {
            case JoystickAxis.X:
            case JoystickAxis.Y:
            case JoystickAxis.Z:
                this._axisTargetedByUpAndDown = axis;
                break;
            default:
                this._axisTargetedByUpAndDown = JoystickAxis.Y;
                break;
        }
    }

    /**
     * Clears the canvas from the previous puck / container draw
     */
    private _clearPreviousDraw() {
        const jp = this._joystickPosition || this._joystickPointerStartPos;

        // clear container pixels
        VirtualJoystick._VJCanvasContext.clearRect(
            jp.x - this._clearContainerSizeOffset,
            jp.y - this._clearContainerSizeOffset,
            this._clearContainerSize,
            this._clearContainerSize
        );

        // clear puck pixels + 1 pixel for the change made before it moved
        VirtualJoystick._VJCanvasContext.clearRect(
            this._joystickPreviousPointerPos.x - this._clearPuckSizeOffset - 1,
            this._joystickPreviousPointerPos.y - this._clearPuckSizeOffset - 1,
            this._clearPuckSize + 2,
            this._clearPuckSize + 2
        );
    }

    /**
     * Loads `urlPath` to be used for the container's image
     * @param urlPath defines the urlPath of an image to use
     */
    public setContainerImage(urlPath: string) {
        const image = new Image();
        image.src = urlPath;

        image.onload = () => (this._containerImage = image);
    }

    /**
     * Loads `urlPath` to be used for the puck's image
     * @param urlPath defines the urlPath of an image to use
     */
    public setPuckImage(urlPath: string) {
        const image = new Image();
        image.src = urlPath;

        image.onload = () => (this._puckImage = image);
    }

    /**
     * Draws the Virtual Joystick's puck
     */
    private _drawPuck(pos: Vector2) {
        if (this._puckImage) {
            VirtualJoystick._VJCanvasContext.drawImage(
                this._puckImage,
                pos.x - this.puckSize,
                pos.y - this.puckSize,
                this.puckSize * 2,
                this.puckSize * 2
            );
        } else {
            VirtualJoystick._VJCanvasContext.beginPath();
            VirtualJoystick._VJCanvasContext.strokeStyle = this._joystickColor;
            VirtualJoystick._VJCanvasContext.lineWidth = this._puckLineWidth;
            VirtualJoystick._VJCanvasContext.arc(pos.x, pos.y, this.puckSize, 0, Math.PI * 2, true);
            VirtualJoystick._VJCanvasContext.stroke();
            VirtualJoystick._VJCanvasContext.fillStyle = "rgba(255, 160, 122, 0.4)"; // "LightSalmon" color, 0.4 opacity.
            VirtualJoystick._VJCanvasContext.fill();
            VirtualJoystick._VJCanvasContext.closePath();
        }
    }

    private _drawVirtualJoystick() {
        // canvas released? don't continue iterating
        if (this._released) {
            return;
        }
        this._clearPreviousDraw();
        if (this.pressed) {
            this._touches.forEach((key, touch) => {
                if ((<PointerEvent>touch).pointerId === this._joystickPointerId) {
                    this._drawPuck(this._joystickPointerPos);

                    // store current pointer for next clear
                    this._joystickPreviousPointerPos = this._joystickPointerPos.clone();
                } else {
                    // I, Seva, do not understand what is going on here.
                    // Almost sure we don't need this; not spending time finding out for sure.
                    VirtualJoystick._VJCanvasContext.clearRect((<any>touch).prevX - 44, (<any>touch).prevY - 44, 88, 88);
                    VirtualJoystick._VJCanvasContext.beginPath();
                    VirtualJoystick._VJCanvasContext.fillStyle = "white";
                    VirtualJoystick._VJCanvasContext.beginPath();
                    VirtualJoystick._VJCanvasContext.strokeStyle = "red";
                    VirtualJoystick._VJCanvasContext.lineWidth = 6;
                    VirtualJoystick._VJCanvasContext.arc(touch.x, touch.y, 40, 0, Math.PI * 2, true);
                    VirtualJoystick._VJCanvasContext.stroke();
                    VirtualJoystick._VJCanvasContext.closePath();
                    (<any>touch).prevX = touch.x;
                    (<any>touch).prevY = touch.y;
                }
            });
        } else {
            this._drawPuck(this._joystickPosition || this._joystickPointerStartPos);
        }
        requestAnimationFrame(() => {
            this._drawVirtualJoystick();
        });
    }

    /**
     * Release internal HTML canvas
     */
    public releaseCanvas() {
        if (VirtualJoystick.Canvas) {
            VirtualJoystick.Canvas.removeEventListener("pointerdown", this._onPointerDownHandlerRef);
            VirtualJoystick.Canvas.removeEventListener("pointermove", this._onPointerMoveHandlerRef);
            VirtualJoystick.Canvas.removeEventListener("pointerup", this._onPointerUpHandlerRef);
            VirtualJoystick.Canvas.removeEventListener("pointerout", this._onPointerUpHandlerRef);
            window.removeEventListener("resize", this._onResize);
            document.body.removeChild(VirtualJoystick.Canvas);
            VirtualJoystick.Canvas = null;
        }
        this._released = true;
    }
}

joystick_input.ts

import type { ICameraInput, FreeCamera } from "@babylonjs/core";
import { Matrix, Vector3 } from "@babylonjs/core";
import { VirtualJoystick, JoystickAxis } from "lib/camera/joystick";

export class MyWonderfulJoystickInput implements ICameraInput<FreeCamera> {
    /**
     * Defines the camera the input is attached to.
     */
    public camera: FreeCamera;

    private _leftjoystick: VirtualJoystick;

    /**
     * Gets the left stick of the virtual joystick.
     * @returns The virtual Joystick
     */
    public getLeftJoystick(): VirtualJoystick {
        return this._leftjoystick;
    }

    /**
     * Update the current camera state depending on the inputs that have been used this frame.
     * This is a dynamically created lambda to avoid the performance penalty of looping for inputs in the render loop.
     */
    public checkInputs() {
        if (this._leftjoystick) {
            const camera = this.camera;
            const speed = camera._computeLocalCameraSpeed() * 50;
            const cameraTransform = Matrix.RotationYawPitchRoll(camera.rotation.y, camera.rotation.x, 0);
            const deltaTransform = Vector3.TransformCoordinates(
                new Vector3(this._leftjoystick.deltaPosition.x * speed, this._leftjoystick.deltaPosition.y * speed, this._leftjoystick.deltaPosition.z * speed),
                cameraTransform
            );
            camera.cameraDirection = camera.cameraDirection.add(deltaTransform);

            if (!this._leftjoystick.pressed) {
                this._leftjoystick.deltaPosition = this._leftjoystick.deltaPosition.scale(0.9);
            }
        }
    }

    /**
     * Attach the input controls to a specific dom element to get the input from.
     */
    public attachControl(): void {
        this._leftjoystick = new VirtualJoystick({
            containerSize: 40,
            puckSize: 25,
            alwaysVisible: true, // Joystick does not care if this is set though.
            limitToContainer: true,
            color: "LightSalmon",
        });
        this._leftjoystick.setAxisForUpDown(JoystickAxis.Z);
        this._leftjoystick.setAxisForLeftRight(JoystickAxis.X);
        this._leftjoystick.setJoystickSensibility(0.40);
    }

    /**
     * Detach the current controls from the specified dom element.
     */
    public detachControl(): void {
        this._leftjoystick.releaseCanvas();
    }

    /**
     * Gets the class name of the current input.
     * @returns the class name
     */
    public getClassName(): string {
        return "MyWonderfulJoystickInput";
    }

    /**
     * Get the friendly name associated with the input class.
     * @returns the input friendly name
     */
    public getSimpleName(): string {
        return "myWonderfulJoystick";
    }
}

and…

        if (("ontouchstart" in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0)) {
            scene.activeCamera.inputs.add(new MyWonderfulJoystickInput());
        }

Thank you very much, @sevaseva! I’ll check it out :blush::+1:.

Have a good upcoming week