the teleport in here doesn't work as needed update only the teleport function to fix it : class World {
    constructor() {
        this.game = new Game()
        this.scene = this.game.scene
        this.physics = this.initPhysics()
        this.physics.gravity.set(0, -20, 0)
        this.defaultMaterial = new CANNON.Material('default')
        this.raycaster = new THREE.Raycaster()
        this.mouse = new THREE.Vector2()
        this.leftPortal = []
        this.rightPortal = []
        this.portalPhysics = false
        this.collisionDetected = false
        this.virtualCamera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
        this.scene.add(this.virtualCamera)
        this.collisionDetected = false
        this.portalHandler = new Portal(this.scene, this.game.renderer.instance)
        this.active = ''
        this.previousContacts = []
        this.worldBounds = {
            min: new CANNON.Vec3(-100, -100, -100),
            max: new CANNON.Vec3(100, 100, 100)
        }
        const defaultContactMaterial = new CANNON.ContactMaterial(
            this.defaultMaterial,
            this.defaultMaterial,
            {
                friction: 0,
                restitution: 0
            }
        )
        this.physics.defaultContactMaterial = defaultContactMaterial
        window.addEventListener('click', this.shoot.bind(this))

        this.isHoldingCube = false
        this.holdDistance = 10
        this.pickupRange = 20

        this.holdingConstraint = null
        this.holdBody = null

        this.isJumping = true;

        this.onKeyDown = this.onKeyDown.bind(this)
        window.addEventListener('keydown', this.onKeyDown)

        this.setWorld()
        this.createCompanionCube()
        this.loadTextures()

        let upVector = new CANNON.Vec3(0, 1, 0);
        let contactNormal = new CANNON.Vec3(0, 0, 0);

        this.physics.addEventListener("postStep", (e) => {
            this.isJumping = true; // Assume jumping until proven otherwise
            if (this.physics.contacts.length > 0) {
                for (let contact of this.physics.contacts) {
                    if (contact.bi.class == 'companionCube' || contact.bj.class == 'companionCube') {
                        if (contact.bi.class == 'companionCube') {
                            contactNormal = new CANNON.Vec3().copy(contact.ni).scale(-1);
                        } else {
                            contactNormal = new CANNON.Vec3().copy(contact.ni);
                        }
                        const collisionResponse = contact.bi.collisionResponse && contact.bj.collisionResponse;
                        this.isJumping = !(collisionResponse && contactNormal.dot(upVector) > 0.5);
                    }
                }
            }
        });
    }

    initPhysics() {
        let world = new CANNON.World()
        world.quatNormalizeSkip = 0
        world.quatNormalizeFast = false

        var solver = new CANNON.GSSolver()

        world.defaultContactMaterial.contactEquationStiffness = 1e9
        world.defaultContactMaterial.contactEquationRelaxation = 4

        solver.iterations = 20
        solver.tolerance = 0.001
        let split = true
        if (split)
            world.solver = new CANNON.SplitSolver(solver)
        else
            world.solver = solver

        world.gravity.set(0, -9.8, 0)
        world.broadphase = new CANNON.NaiveBroadphase()

        return world
    }

    worldToLocal(object, vector) {
        const worldInverse = new THREE.Matrix4().copy(object.matrixWorld).invert()
        return vector.clone().applyMatrix4(worldInverse)
    }

    localToWorld(object, vector) {
        return vector.clone().applyMatrix4(object.matrixWorld)
    }

    shoot(event) {
        this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1
        this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1

        this.raycaster.setFromCamera({ x: 0, y: 0 }, this.game.camera.instance)

        const intersects = this.raycaster.intersectObjects([
            this.plane, this.roof, this.scene.getObjectByName('backWall'),
            this.scene.getObjectByName('frontWall'), this.scene.getObjectByName('leftWall'),
            this.scene.getObjectByName('rightWall')
        ])

        if (intersects.length > 0) {
            const intersection = intersects[0]
            const object = intersection.object

            const geometry = new THREE.CircleGeometry(5, 32)
            const material = new THREE.MeshBasicMaterial({ color: 0x000000 })
            const circle = new THREE.Mesh(geometry, material)

            const portalSize = new THREE.Vector2(10, 15)
            const halfPortalSize = portalSize.clone().multiplyScalar(0.5)
            const wallSize = new THREE.Vector2(object.geometry.parameters.width, object.geometry.parameters.height)
            const halfWallSize = wallSize.clone().multiplyScalar(0.5)

            let localPosition = this.worldToLocal(object, intersection.point)

            localPosition.x = THREE.MathUtils.clamp(localPosition.x, -halfWallSize.x + halfPortalSize.x, halfWallSize.x - halfPortalSize.x)
            localPosition.y = THREE.MathUtils.clamp(localPosition.y, -halfWallSize.y + halfPortalSize.y, halfWallSize.y - halfPortalSize.y)

            const validPoint = this.localToWorld(object, localPosition)

            circle.position.copy(validPoint)
            circle.rotation.copy(object.rotation)
            circle.scale.y = 1.5

            circle.position.addScaledVector(circle.getWorldDirection(new THREE.Vector3()), 0.01)
            this.boxBody = new CANNON.Body({
                collisionFilterGroup: GROUP_PORTAL,
                collisionFilterMask: GROUP_WALL | GROUP_DEFAULT | GROUP_CUBE | GROUP_PLAYER,
            })
            this.boxBody.mass = 0
            this.boxBody.material = this.defaultMaterial
            this.boxBody.collisionResponse = false
            this.boxBody.addShape(new CANNON.Box(new CANNON.Vec3(3.34, 5, 1.5)))
            this.boxBody.quaternion.copy(circle.quaternion)
            this.boxBody.position.copy(circle.position)
            this.boxBody.class = 'portal'
            this.boxBody.object = intersects[0].object

            let intersectionDetected = false
            if (event.button === 0) {
                this.rightPortal.forEach((rightPortal) => {
                    if (this.boxBody.object === rightPortal.physicObject.object && this.detectIntersection(this.boxBody, rightPortal.physicObject)) {
                        intersectionDetected = true
                    }
                })
            } else if (event.button === 2) {
                this.leftPortal.forEach((leftPortal) => {
                    if (this.boxBody.object === leftPortal.physicObject.object && this.detectIntersection(this.boxBody, leftPortal.physicObject)) {
                        intersectionDetected = true
                    }
                })
            }

            if (intersectionDetected) {
                return
            }

            this.physics.addBody(this.boxBody)
            circle.physicObject = this.boxBody

            const geometry1 = new THREE.PlaneGeometry(19, 19)
            let materialPortal

            if (event.button === 0) {
                this.materialBlue = new THREE.ShaderMaterial({
                    uniforms: {
                        iResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
                        iTime: { value: 0 }
                    },
                    vertexShader: document.getElementById('vertexShader').textContent,
                    fragmentShader: document.getElementById('fragmentShaderOrange').textContent
                })
                materialPortal = this.materialBlue
                this.boxBody.ref = 'left'
                this.leftPortal.push(circle)
            } else if (event.button === 2) {
                this.materialOrange = new THREE.ShaderMaterial({
                    uniforms: {
                        iResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
                        iTime: { value: 0 }
                    },
                    vertexShader: document.getElementById('vertexShader').textContent,
                    fragmentShader: document.getElementById('fragmentShaderBlue').textContent
                })
                materialPortal = this.materialOrange
                this.boxBody.ref = 'right'
                this.rightPortal.push(circle)
            }

            const planePortal = new THREE.Mesh(geometry1, materialPortal)
            planePortal.position.copy(circle.position)
            planePortal.rotation.copy(circle.rotation)
            planePortal.position.addScaledVector(circle.getWorldDirection(new THREE.Vector3()), 0.01)

            this.scene.add(planePortal)
            circle.torus = planePortal
        }
    }

    detectIntersection(bodyA, bodyB, buffer = 4) {
        const shapeA = bodyA.shapes[0]
        const shapeB = bodyB.shapes[0]

        const posA = bodyA.position
        const posB = bodyB.position

        const halfSizeA = shapeA.halfExtents
        const halfSizeB = shapeB.halfExtents

        return (
            Math.abs(posA.x - posB.x) < (halfSizeA.x + halfSizeB.x + buffer) &&
            Math.abs(posA.y - posB.y) < (halfSizeA.y + halfSizeB.y + buffer) &&
            Math.abs(posA.z - posB.z) < (halfSizeA.z + halfSizeB.z + buffer)
        )
    }

    calculateAngle(v1, v2) {
        const dot = v1.dot(v2)
        const angle = Math.acos(dot / (v1.length() * v2.length()))
        return angle
    }

    isBodyOutOfBounds(body) {
        const position = body.position
        return (
            position.x < this.worldBounds.min.x ||
            position.x > this.worldBounds.max.x ||
            position.y < this.worldBounds.min.y ||
            position.y > this.worldBounds.max.y ||
            position.z < this.worldBounds.min.z ||
            position.z > this.worldBounds.max.z
        )
    }

    setWorld() {
        const floorShape = new CANNON.Plane()
        const floorBody = new CANNON.Body({
            collisionFilterGroup: GROUP_WALL,
            collisionFilterMask: GROUP_PLAYER | GROUP_CUBE | GROUP_PORTAL | GROUP_DEFAULT,
        })
        floorBody.mass = 0
        floorBody.addShape(floorShape)
        floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(-1, 0, 0), Math.PI * 0.5)
        this.physics.addBody(floorBody)

        const floorTexture = new THREE.TextureLoader().load('/textures/concrete_modular_floor001c.png')
        floorTexture.repeat.set(4, 4)
        floorTexture.wrapS = THREE.RepeatWrapping
        floorTexture.wrapT = THREE.RepeatWrapping

        const floorMaterial = new THREE.MeshStandardMaterial({
            map: floorTexture,
            roughness: 0.2,
            metalness: 0.1
        })

        this.plane = new THREE.Mesh(
            new THREE.PlaneGeometry(200, 200),
            floorMaterial
        )
        this.plane.rotation.x = -Math.PI / 2
        this.plane.receiveShadow = true
        this.plane.position.set(0, 0, 0)
        this.scene.add(this.plane)
        this.plane.physicObject = floorBody

        const wallShape = new CANNON.Box(new CANNON.Vec3(100, 50, 1))

        const createWall = (width, height, depth, color, position, rotation) => {
            const wallTexture = new THREE.TextureLoader().load('/textures/concrete_modular_wall001a.png')
            wallTexture.repeat.set(4, 3)
            wallTexture.wrapS = THREE.RepeatWrapping
            wallTexture.wrapT = THREE.RepeatWrapping

            const wallMaterial = new THREE.MeshStandardMaterial({
                map: wallTexture,
                side: THREE.DoubleSide
            })

            const wallMesh = new THREE.Mesh(
                new THREE.PlaneGeometry(width, height),
                wallMaterial
            )
            wallMesh.position.set(position.x, position.y, position.z)
            wallMesh.rotation.set(rotation.x, rotation.y, rotation.z)
            this.scene.add(wallMesh)

            const wallBody = new CANNON.Body({
                collisionFilterGroup: GROUP_WALL,
                collisionFilterMask: GROUP_PLAYER | GROUP_CUBE | GROUP_PORTAL | GROUP_DEFAULT,
            })
            wallBody.mass = 0
            wallBody.addShape(wallShape)
            wallBody.position.copy(wallMesh.position)
            wallBody.quaternion.copy(wallMesh.quaternion)
            this.physics.addBody(wallBody)

            wallMesh.physicObject = wallBody
            return wallMesh
        }

        const wallHeight = 100
        const wallYPosition = wallHeight / 2

        let backWallMesh = createWall(200, wallHeight, 1, 0xf0ffff, { x: 0, y: wallYPosition, z: 100 }, { x: 0, y: Math.PI, z: 0 })
        backWallMesh.name = 'backWall'

        let frontWallMesh = createWall(200, wallHeight, 1, 0xff0fff, { x: 0, y: wallYPosition, z: -100 }, { x: 0, y: 0, z: 0 })
        frontWallMesh.name = 'frontWall'

        let leftWallMesh = createWall(200, wallHeight, 1, 0xfff0ff, { x: -100, y: wallYPosition, z: 0 }, { x: 0, y: Math.PI / 2, z: 0 })
        leftWallMesh.name = 'leftWall'

        let rightWallMesh = createWall(200, wallHeight, 1, 0xffff0f, { x: 100, y: wallYPosition, z: 0 }, { x: 0, y: -Math.PI / 2, z: 0 })
        rightWallMesh.name = 'rightWall'

        const roofTexture = new THREE.TextureLoader().load('/textures/concrete_modular_ceiling001a.png')
        roofTexture.repeat.set(4, 4)
        roofTexture.wrapS = THREE.RepeatWrapping
        roofTexture.wrapT = THREE.RepeatWrapping

        const roofMaterial = new THREE.MeshStandardMaterial({
            map: roofTexture,
        })

        this.roof = new THREE.Mesh(
            new THREE.PlaneGeometry(200, 200),
            roofMaterial
        )
        this.roof.rotation.x = Math.PI / 2
        this.roof.position.y = wallHeight
        this.scene.add(this.roof)

        const roofShape = new CANNON.Plane()
        const roofBody = new CANNON.Body({
            collisionFilterGroup: GROUP_WALL,
            collisionFilterMask: GROUP_PLAYER | GROUP_CUBE | GROUP_PORTAL | GROUP_DEFAULT,
        })
        roofBody.mass = 0
        roofBody.addShape(roofShape)
        roofBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), Math.PI * 0.5)
        roofBody.position.y = wallHeight
        this.physics.addBody(roofBody)

        this.roof.physicObject = roofBody
    }

    loadTextures() {
        this.game.resources.on('ready', () => {
            // Load resources if needed
        })
    }

    renderPortal() {
        this.game.renderer.instance.clear()
        this.game.camera.instance.updateMatrixWorld(true)
        this.portalHandler.render(this.game.camera.instance, 0, this.virtualCamera, [this.rightPortal[0], this.leftPortal[0]], this.game.camera.instance.matrixWorld.clone(), this.game.camera.instance.projectionMatrix.clone())
    }

    isWithinFunnel(coneOrigin, coneDirection, objectPosition, coneAngle, coneHeight = 10) {
        const upDirection = new THREE.Vector3(0, 1, 0)
        const facingUpward = coneDirection.dot(upDirection) > 0.7

        if (!facingUpward) {
            return false
        }

        const toObject = new THREE.Vector3().subVectors(objectPosition, coneOrigin)

        const projectedDistance = toObject.dot(coneDirection)

        if (projectedDistance > coneHeight || projectedDistance < 0) {
            return false
        }

        const directionToObject = toObject.normalize()
        const angle = coneDirection.angleTo(directionToObject)

        return angle < coneAngle
    }

    funnelObjectTowardsPortal(objectPosition, coneOrigin) {
        const toConeOrigin = new THREE.Vector3().copy(coneOrigin).sub(objectPosition)
        toConeOrigin.y = 0

        const direction = toConeOrigin.normalize()

        const funnelSpeed = 0.2

        const newPosition = new THREE.Vector3().copy(objectPosition)
        newPosition.x += direction.x * funnelSpeed
        newPosition.z += direction.z * funnelSpeed

        this.game.controls.cameraBody.position.set(newPosition.x, this.game.controls.cameraBody.position.y, newPosition.z)
    }

    update() {
        this.physics.step(1 / 75, this.game.time.delta, 3)

        this.contact = false

        if(this.companionCubeBody){
            if (!this.isJumping) {
                this.companionCubeBody.linearDamping = 0.99
            } else {
                this.companionCubeBody.linearDamping = 0
            }
        }

        if (this.rightPortal.length > 0) {
            document.querySelector('.blue').classList.add('active')
        } else {
            document.querySelector('.blue').classList.remove('active')
        }

        if (this.leftPortal.length > 0) {
            document.querySelector('.orange').classList.add('active')
        } else {
            document.querySelector('.orange').classList.remove('active')
        }

        if (this.rightPortal.length > 0 && this.leftPortal.length > 0) {
            const funnelConeAngle = Math.PI / 6
            const playerPosition = this.game.camera.instance.position

            this.leftPortal.forEach((portal) => {
                const coneOrigin = portal.position
                const coneDirection = new THREE.Vector3()
                portal.getWorldDirection(coneDirection)

                if (this.isWithinFunnel(coneOrigin, coneDirection, playerPosition, funnelConeAngle)) {
                    this.funnelObjectTowardsPortal(playerPosition, coneOrigin)
                }
            })

            this.rightPortal.forEach((portal) => {
                const coneOrigin = portal.position
                const coneDirection = new THREE.Vector3()
                portal.getWorldDirection(coneDirection)

                if (this.isWithinFunnel(coneOrigin, coneDirection, playerPosition, funnelConeAngle)) {
                    this.funnelObjectTowardsPortal(playerPosition, coneOrigin)
                }
            })
        }

        this.physics.contacts.forEach((contact) => {
            let bodyA = contact.bi
            let bodyB = contact.bj
            if (((bodyA.class == 'camera' && bodyB.class == 'portal') || (bodyB.class == 'camera' && bodyA.class == 'portal')) && (this.rightPortal.length > 0 && this.leftPortal.length > 0)) {
                this.contact = true
            }
        })

        if (this.materialBlue) {
            this.materialBlue.uniforms.iTime.value += 0.05
        }
        if (this.materialOrange) {
            this.materialOrange.uniforms.iTime.value += 0.05
        }

        if (this.isBodyOutOfBounds(this.game.controls.cameraBody)) {
            this.game.controls.cameraBody.sleep()
            this.game.controls.cameraBody.position.set(20, 10, 0)
            this.game.controls.cameraBody.wakeUp()
        }

        if (this.rightPortal.length > 0 && this.leftPortal.length > 0) {
            this.rightPortal[0].pair = this.leftPortal[0]
            this.leftPortal[0].pair = this.rightPortal[0]
            this.game.camera.instance.layers.set(0)
            this.renderPortal()
            this.game.renderer.instance.clearDepth()
            this.game.camera.instance.layers.set(1)
            this.game.renderer.instance.render(this.scene, this.game.camera.instance)
        } else {
            this.tmpScene = new THREE.Scene()
            this.tmpScene.children = this.scene.children.filter((child) => child.class !== 'player')
            this.game.camera.instance.layers.set(0)
            this.game.renderer.instance.render(this.tmpScene, this.game.camera.instance)
            this.game.renderer.instance.clearDepth()
            this.game.camera.instance.layers.set(1)
            this.game.renderer.instance.render(this.tmpScene, this.game.camera.instance)
        }

        this.managePortals()

        if (this.rightPortal.length > 0 && this.leftPortal.length > 0) {

            let sourcePortal = null
            let destinationPortal = null

            // Determine which portal the cube is colliding with
            for (let i = 0; i < this.physics.contacts.length; i++) {
                const contact = this.physics.contacts[i]
                const bodyA = contact.bi
                const bodyB = contact.bj

                if (
                    (bodyA.class === 'companionCube' && bodyB.class === 'portal') ||
                    (bodyA.class === 'portal' && bodyB.class === 'companionCube')
                ) {
                    // Identify the portal body and its reference
                    const portalBody = bodyA.class === 'portal' ? bodyA : bodyB
                    const portalRef = portalBody.ref // 'left' or 'right'

                    if (portalRef === 'left') {
                        sourcePortal = this.leftPortal[0]
                        destinationPortal = this.rightPortal[0]
                    } else if (portalRef === 'right') {
                        sourcePortal = this.rightPortal[0]
                        destinationPortal = this.leftPortal[0]
                    }

                    // Break out of the loop once the colliding portal is found
                    break
                }
            }

            // If the cube is not colliding with any portal, default to one pair
            if (!sourcePortal || !destinationPortal) {
                sourcePortal = this.leftPortal[0]
                destinationPortal = this.rightPortal[0]
            }

            // Synchronize the clone's position and rotation using source and destination portals
            const relativePosition = sourcePortal.worldToLocal(this.companionCube.position.clone())
            relativePosition.applyQuaternion(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI))
            const newCubePosition = destinationPortal.localToWorld(relativePosition)
            this.cubeClone.position.copy(newCubePosition)

            // Synchronize rotation
            const relativeRotation = sourcePortal.quaternion.clone().invert().multiply(this.companionCube.quaternion)
            relativeRotation.premultiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI))
            this.cubeClone.quaternion.copy(destinationPortal.quaternion.clone().multiply(relativeRotation))
            
            if(!this.holdBody){
                this.checkCubePortalTeleport()
            }

            let cubePortalContact = false
            let playerPortalContact = false

            for (let i = 0; i < this.physics.contacts.length; i++) {
                const contact = this.physics.contacts[i]
                const bodyA = contact.bi
                const bodyB = contact.bj

                if ((bodyA.class === 'companionCube' && bodyB.class === 'portal') ||
                    (bodyA.class === 'portal' && bodyB.class === 'companionCube')) {
                    this.companionCubeBody.collisionFilterMask &= ~GROUP_WALL
                    cubePortalContact = true
                }

                if ((bodyA.class === 'camera' && bodyB.class === 'portal') ||
                    (bodyA.class === 'portal' && bodyB.class === 'camera')) {
                    this.game.controls.cameraBody.collisionFilterMask &= ~GROUP_WALL
                    playerPortalContact = true
                }

                if ((bodyA.class === 'camera' && bodyB.ref === 'right') ||
                    (bodyA.ref === 'right' && bodyB.class === 'camera')) {
                    this.checkLeftPortalTeleport()
                    break
                }
            }

            if (!cubePortalContact) {
                this.companionCubeBody.collisionFilterMask = GROUP_DEFAULT | GROUP_WALL | GROUP_PORTAL | GROUP_PLAYER
            }

            if (!playerPortalContact) {
                this.game.controls.cameraBody.collisionFilterMask = GROUP_DEFAULT | GROUP_WALL | GROUP_PORTAL | GROUP_CUBE
            }

            for (let i = 0; i < this.physics.contacts.length; i++) {
                const contact = this.physics.contacts[i]
                const bodyA = contact.bi
                const bodyB = contact.bj

                if ((bodyA.class === 'camera' && bodyB.ref === 'left') || (bodyA.ref === 'left' && bodyB.class === 'camera')) {
                    this.checkRightPortalTeleport()
                    break
                }
            }
        } else {
            this.resetPortalColors()
        }

        if (!this.collisionDetected) {
            this.game.controls.cameraBody.wakeUp()
        }

        if (this.holdBody) {
            const holdPosition = this.getHoldPosition()
            this.holdBody.position.copy(holdPosition)
            this.holdBody.quaternion.copy(this.game.camera.instance.quaternion)
        }

        if (this.companionCube) {
            this.companionCube.position.copy(this.companionCubeBody.position)
            this.companionCube.quaternion.copy(this.companionCubeBody.quaternion)
        }
    }

    managePortals() {
        this.leftPortal.forEach((portalData) => {
            this.scene.add(portalData)
        })

        if (this.leftPortal.length > 1) {
            const circleToRemove = this.leftPortal.shift()
            this.scene.remove(circleToRemove.box)
            this.scene.remove(circleToRemove.torus)
            this.physics.remove(circleToRemove.physicObject)
            this.scene.remove(circleToRemove)
        }

        this.rightPortal.forEach((portalData) => {
            this.scene.add(portalData)
        })

        if (this.rightPortal.length > 1) {
            const circleToRemove = this.rightPortal.shift()
            this.scene.remove(circleToRemove.box)
            this.scene.remove(circleToRemove.torus)
            this.physics.remove(circleToRemove.physicObject)
            this.scene.remove(circleToRemove)
        }
    }

    teleport(sourcePortal, camera, body) {
        const halfTurn = new THREE.Quaternion()
        halfTurn.setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI)

        const forwardDirection = new THREE.Vector3()
        camera.getWorldDirection(forwardDirection)

        const relativePos = sourcePortal.worldToLocal(camera.position.clone())
        relativePos.applyQuaternion(halfTurn)
        const newPos = sourcePortal.pair.localToWorld(relativePos)
        if (newPos.y < 9.98) {
            newPos.y = 10
        }
        body.position.copy(newPos)

        const relativeRot = sourcePortal.quaternion.clone().invert().multiply(camera.quaternion)
        relativeRot.premultiply(halfTurn)
        camera.quaternion.copy(sourcePortal.pair.quaternion.clone().multiply(relativeRot))

        const newForwardDirection = new THREE.Vector3()
        camera.getWorldDirection(newForwardDirection)
        const targetRotation = new THREE.Quaternion().setFromRotationMatrix(
            new THREE.Matrix4().lookAt(
                new THREE.Vector3(0, 0, 0),
                newForwardDirection,
                new THREE.Vector3(0, 1, 0)
            )
        )

        camera.quaternion.copy(targetRotation)

        const inTransform = sourcePortal
        const outTransform = sourcePortal.pair

        if (body) {
            // Convert the body's velocity to THREE.Vector3
            const worldVelocity = new THREE.Vector3(body.velocity.x, body.velocity.y, body.velocity.z)
    
            // Transform the velocity into the portal's local space
            const relativeVelocity = worldVelocity.clone()
            relativeVelocity.applyQuaternion(sourcePortal.quaternion.clone().invert())
    
            // Apply the half-turn rotation
            relativeVelocity.applyQuaternion(halfTurn)
    
            // Transform the velocity into the destination portal's world space
            relativeVelocity.applyQuaternion(sourcePortal.pair.quaternion)
    
            // Set the body's velocity
            body.velocity.set(relativeVelocity.x, relativeVelocity.y, relativeVelocity.z)
        }
    }

    checkLeftPortalTeleport() {
        const portalForward = new THREE.Vector3()
        this.rightPortal[0].getWorldDirection(portalForward)

        const travelerPosition = this.game.camera.instance.position.clone()
        const portalPosition = this.rightPortal[0].position.clone()
        const portalToTraveler = travelerPosition.sub(portalPosition)

        const dotProduct = portalForward.dot(portalToTraveler)

        if (dotProduct < 0) {
            this.teleport(this.rightPortal[0], this.game.camera.instance, this.game.controls.cameraBody)
        }
    }

    checkRightPortalTeleport() {
        const portalForward = new THREE.Vector3()
        this.leftPortal[0].getWorldDirection(portalForward)

        const travelerPosition = this.game.camera.instance.position.clone()
        const portalPosition = this.leftPortal[0].position.clone()
        const portalToTraveler = travelerPosition.sub(portalPosition)

        const dotProduct = portalForward.dot(portalToTraveler)

        if (dotProduct < 0) {
            this.teleport(this.leftPortal[0], this.game.camera.instance, this.game.controls.cameraBody)
        }
    }

    resetPortalColors() {
        if (this.leftPortal.length > 0) {
            this.leftPortal[0].material.color = new THREE.Color(0xff9a00)
            this.leftPortal[0].material.needsUpdate = true
        }

        if (this.rightPortal.length > 0) {
            this.rightPortal[0].material.color = new THREE.Color(0x00a2ff)
            this.rightPortal[0].material.needsUpdate = true
        }
    }

    createCompanionCube() {
        const loader = new GLTFLoader()

        loader.load('/cube.glb', (gltf) => {
            this.companionCube = gltf.scene

            const box = new THREE.Box3().setFromObject(this.companionCube)
            const center = box.getCenter(new THREE.Vector3())
            this.companionCube.position.sub(center)

            this.companionCube.position.set(0, 5, -5)
            this.companionCube.scale.set(0.13, 0.13, 0.13)
            this.companionCube.castShadow = true
            this.companionCube.receiveShadow = true

            this.companionCube.traverse((child) => {
                if (child.isMesh && child.material) {
                    if (child.material.map) {
                        child.material.map.colorSpace = THREE.SRGBColorSpace
                        child.material.metalness = 0.7
                    }
                    if (child.material.emissiveMap) {
                        child.material.emissiveMap.colorSpace = THREE.SRGBColorSpace
                    }
                    child.material.needsUpdate = true
                }
            })

            this.scene.add(this.companionCube)

            this.cubeClone = this.companionCube.clone()

            this.cubeClone.position.z = 200

            this.scene.add(this.cubeClone)

            const cubeShape = new CANNON.Box(new CANNON.Vec3(2.5, 2.5, 2.5))
            this.companionCubeBody = new CANNON.Body({
                mass: 1,
                material: this.defaultMaterial,
                collisionFilterGroup: GROUP_CUBE,
                collisionFilterMask: GROUP_WALL | GROUP_PLAYER | GROUP_DEFAULT | GROUP_PORTAL,
            })
            this.companionCubeBody.addShape(cubeShape)
            this.companionCubeBody.position.copy(this.companionCube.position)
            this.companionCubeBody.linearDamping = 0
            this.companionCubeBody.angularDamping = 0.5
            this.companionCubeBody.class = 'companionCube'

            this.physics.addBody(this.companionCubeBody)

            this.companionCube.body = this.companionCubeBody
        }, undefined, (error) => {
            console.error('An error occurred while loading the cube model:', error)
        })
    }

    getHoldPosition() {
        const camera = this.game.camera.instance
        const cameraDirection = new THREE.Vector3()
        camera.getWorldDirection(cameraDirection)
        cameraDirection.normalize()

        return new THREE.Vector3().copy(camera.position)
            .add(cameraDirection.multiplyScalar(this.holdDistance))
    }

    onKeyDown(event) {
        if (event.key === 'e') {
            if (!this.isHoldingCube) {
                this.tryPickupCube()
            } else {
                this.dropCube()
            }
        }
    }

    tryPickupCube() {
        const playerPosition = this.game.camera.instance.position
        const cubePosition = this.companionCube.position.clone()
        const distance = playerPosition.distanceTo(cubePosition)
    
        if (distance <= this.pickupRange) {
            this.isHoldingCube = true
    
            const holdPosition = this.getHoldPosition()
            this.holdBody = new CANNON.Body({
                mass: 0,
                type: CANNON.Body.KINEMATIC,
                collisionFilterGroup: 0,
                collisionFilterMask: 0
            })
            this.holdBody.position.copy(holdPosition)
            this.physics.addBody(this.holdBody)
    
            this.holdingConstraint = new CANNON.PointToPointConstraint(
                this.companionCubeBody,
                new CANNON.Vec3(0, 0, 0),
                this.holdBody,
                new CANNON.Vec3(0, 0, 0),
                1e6
            )
            this.physics.addConstraint(this.holdingConstraint)
    
            this.companionCubeBody.angularDamping = 1
    
            // Add this line to remove collision with the player
            this.companionCubeBody.collisionFilterMask &= ~GROUP_PLAYER
        }
    }

    dropCube() {
        this.isHoldingCube = false
    
        if (this.holdingConstraint) {
            this.physics.removeConstraint(this.holdingConstraint)
            this.holdingConstraint = null
        }
    
        if (this.holdBody) {
            this.physics.removeBody(this.holdBody)
            this.holdBody = null
        }
    
        // Add the player group back to the collision mask
        this.companionCubeBody.collisionFilterMask |= GROUP_PLAYER
        this.companionCubeBody.angularDamping = 0.5
    }

    checkCubePortalTeleport() {
        const cubePosition = new THREE.Vector3(
            this.companionCubeBody.position.x,
            this.companionCubeBody.position.y,
            this.companionCubeBody.position.z
        )

        const rightPortalPosition = this.rightPortal[0].position.clone()
        const rightPortalForward = new THREE.Vector3()
        this.rightPortal[0].getWorldDirection(rightPortalForward)
        const rightPortalToCube = cubePosition.clone().sub(rightPortalPosition)
        const rightDotProduct = rightPortalForward.dot(rightPortalToCube)

        if (rightDotProduct < 0) {
            this.teleport(this.rightPortal[0], this.companionCube, this.companionCubeBody)
        }

        const leftPortalPosition = this.leftPortal[0].position.clone()
        const leftPortalForward = new THREE.Vector3()
        this.leftPortal[0].getWorldDirection(leftPortalForward)
        const leftPortalToCube = cubePosition.clone().sub(leftPortalPosition)
        const leftDotProduct = leftPortalForward.dot(leftPortalToCube)

        if (leftDotProduct < 0) {
            this.teleport(this.leftPortal[0], this.companionCube, this.companionCubeBody)
        }
    }
} : use this : What happens when an object travels through a portal? There are tons of videos and articles online discussing the conservation of momentum when portals are in operation. Some common thought experiments include “what happens if a portal moves into another portal” or “what happens when a portal crushes an object”, but it’s simpler to restrict things to non-moving portals - as games often do. It’s easier to design around a smaller possibility space so there’s fewer cases to program. Thus, the portals in our universe can’t move, and the only momentum we must consider is that of the object travelling through the portal.

Check out my recreation of the portal effect in URP over on YouTube too!


Breaking the Law
Let’s talk basic physics. The momentum of an object is the product of its mass and velocity and in a closed system, the law of conservation of momentum stipulates that the total momentum stays constant. If a 5kg mass lurches to the left at a velocity of 2 meters per second, a mass of 10kg should lurch to the right at a speed of one meter per second to compensate. Now think of an object entering a portal travelling to the right and exiting a portal facing upwards - in order to conserve momentum, the portals (or anything else in the system) should also move to the left and downwards respectively. But the portals are supposedly stationary, so perhaps they have a near-infinite mass and move a negligible distance. That’d probably create a black hole and destroy the entire game.

But we’re getting ahead of ourselves. Games are about suspending the player’s disbelief. It’s fun to think about how portals in Portal would actually work, but as game designers we can just say “thing goes in, thing comes out” and leave the thinking to the players. Our portals are made of hand-wave-ium and objects conserve their local momentum upon portal entry.

Local momentum
When I say “local momentum”, I mean that a box travelling at 5 meters per second to the right into a portal will have a velocity of 5 meters per second travelling out of the portal - but the direction might be up, or down, or left. We’ll use similar code for transforming the position and velocity direction of the object as we did for transforming the position of the portal rendering cameras. Open the Scripts/PortalableObject.cs file and scroll down to the Warp method. It assumes that references to the inPortal and outPortal have already been assigned and that we have access to the attached Rigidbody component. Since we perform a 180-degree rotation in multiple methods in this class, it’s stored in a static readonly member variable named halfTurn.

// Member variables.
private Portal inPortal;
private Portal outPortal;

private new Rigidbody rigidbody;

private static Quaternion halfTurn = Quaternion.Euler(0.0f, 180.0f, 0.0f);

// Warp method.
public virtual void Warp()
{
    var inTransform = inPortal.transform;
    var outTransform = outPortal.transform;

    // Update position of object.
    Vector3 relativePos = inTransform.InverseTransformPoint(transform.position);
    relativePos = halfTurn * relativePos;
    transform.position = outTransform.TransformPoint(relativePos);

    // Update rotation of object.
    Quaternion relativeRot = Quaternion.Inverse(inTransform.rotation) * transform.rotation;
    relativeRot = halfTurn * relativeRot;
    transform.rotation = outTransform.rotation * relativeRot;

    // Update velocity of rigidbody.
    Vector3 relativeVel = inTransform.InverseTransformDirection(rigidbody.velocity);
    relativeVel = halfTurn * relativeVel;
    rigidbody.velocity = outTransform.TransformDirection(relativeVel);
}
You might notice that the method is virtual - that’s because PlayerController will inherit PortalableObject and we’ll need to consider an edge case related to it later. Otherwise, these chunks of code will look familiar by now if you’ve read the previous parts of this tutorial series - the position, rotation and velocity of the object will be transformed from the inPortal’s local space to the outPortal’s local space.

Considering collisions
Now, let’s talk about how to detect when an object travels through the portal. In particular, let’s consider collision. After all, the portal rests on a wall surface with collision enabled, so surely we need to cut a hole in the wall so that objects can travel through? A system that cuts a hole in the collision mesh of the wall at runtime would be the most “realistic” way of doing this, but it’s complicated and might be slow if your level geometry contains many triangles. Instead, when an object is inside (or almost inside) the portal, we can disable the collision between the object and the wall.

Both portals have a thin box trigger collider that covers its surface. On the Portal script (Scripts/Portal.cs), we’ll keep track of object that enter and exit the collider using the OnTriggerEnter and OnTriggerExit methods.

// Member variables.
private List<PortalableObject> portalObjects = new List<PortalableObject>();

private void OnTriggerEnter(Collider other)
{
    var obj = other.GetComponent<PortalableObject>();
    if (obj != null)
    {
        portalObjects.Add(obj);
        obj.SetIsInPortal(this, otherPortal, wallCollider);
    }
}

private void OnTriggerExit(Collider other)
{
    var obj = other.GetComponent<PortalableObject>();

    if(portalObjects.Contains(obj))
    {
        portalObjects.Remove(obj);
        obj.ExitPortal(wallCollider);
    }
}
We’re keeping track of the objects in a list because we’ll need to check inside Update whether they’ve crossed through the portal. We do this by using InverseTransformPoint on the position of each object. If the object is behind the portal - if the z-component of the inverted position is greater than zero - then we’ll call Warp on the object.

private void Update()
{
    for (int i = 0; i < portalObjects.Count; ++i)
    {
        Vector3 objPos = transform.InverseTransformPoint(portalObjects[i].transform.position);

        if (objPos.z > 0.0f)
        {
            portalObjects[i].Warp();
        }
    }
}
The OnTriggerEnter method notifies the PortalableObject which portal is the inPortal and which is the outPortal and identifies which wall collider should be ignored. On PortalableObject, we’ve written the SetIsInPortal method for this.

public void SetIsInPortal(Portal inPortal, Portal outPortal, Collider wallCollider)
{
    this.inPortal = inPortal;
    this.outPortal = outPortal;

    Physics.IgnoreCollision(collider, wallCollider);

    cloneObject.SetActive(true);

    ++inPortalCount;
}
Here is where we set the inPortal and outPortal, which are used in the Warp function. The Physics.IgnoreCollision method tells the physics engine to disregard collisions between any two colliders in the scene - here, we’ll disable collision between the object and the wall the portal is on. This will allow the object to travel through the portal! We’ll come back to cloneObject in a little while, and we’re using inPortalCount to keep track of how many portals we’re near. Instead of using a bool, counting is a failsafe in case we’re ever nearby two portals at the same time so that we don’t assume we exit all portals the moment OnTriggerExit is called between this object and only one portal. We’ll only call code for exiting portals when inPortalCount reaches zero.

We’ll also need to re-enable collision when the object exits the portal’s proximity trigger. In the ExitPortal method, which takes only the wallCollider as a parameter, we’ll use Physics.IgnoreCollision between wallCollider and the object’s collider along with the false flag, meaning collision will not be ignored any longer.

public void ExitPortal(Collider wallCollider)
{
    Physics.IgnoreCollision(collider, wallCollider, false);
    --inPortalCount;

    if (inPortalCount == 0)
    {
        cloneObject.SetActive(false);
    }
}
Now objects will be able to travel between two portals. Let’s see it in action.

If you have a keen eye, then you’ll notice a small problem: the object seems to get “cut off” while it’s travelling through the portal. The following screenshot illustrates what I mean:

Object clipping

The sphere physically exists on the right-hand side, falling downwards into the portal. That means the lower half has clipped through the portal surface and should be visible in the left-hand portal, but because of the way we’re rendering the portal surface it’s been clipped out of existence. Likewise, we can’t see the lower half of the sphere in the right-hand portal because no physical version of it exists on the left, which is where the portal view is being rendered. We’re going to need to create a ‘cloned’ version of the object on the opposite side of the portal.

Object cloning
Let’s talk about the cloneObject we skipped over, contained in the PortalableObject class. It’s a visual clone of the object - a distinct GameObject with the same mesh and materials, but no other physical properties like rigidbodies or colliders, and no other scripts. It’s created in Awake and immediately deactivated:

protected virtual void Awake()
{
    cloneObject = new GameObject();
    cloneObject.SetActive(false);
    var meshFilter = cloneObject.AddComponent<MeshFilter>();
    var meshRenderer = cloneObject.AddComponent<MeshRenderer>();

    meshFilter.mesh = GetComponent<MeshFilter>().mesh;
    meshRenderer.materials = GetComponent<MeshRenderer>().materials;

    rigidbody = GetComponent<Rigidbody>();
    collider = GetComponent<Collider>();
}
As with the Warp method, Awake is virtual because PlayerController needs to deal with extra functionality. Before caching the Rigidbody and Collider components we’ve used elsewhere in this script, we create a brand new GameObject - cloneObject - and attach new MeshFilter and MeshRenderer components. Alongside Transform, these will be the only components on the clone. We then copy the primary object’s mesh and set of renderer materials to the clone. In SetIsInPortal and ExitPortal, we activated and deactivated cloneObject respectively. Since it’s deactivated off the bat in Awake, we’ll only see the clone when the original object is near the portal.

In order to position the clone correctly, we’ll update its position in LateUpdate rather than Update so that we can be sure the primary object is in its final position for this frame (checking whether the object should warp happens in Update in the Portal class). We only want to display a clone if both portals exist in the scene, so we’ll start off with a check.

private void LateUpdate()
{
    if(inPortal == null || outPortal == null)
    {
        return;
    }

    if(cloneObject.activeSelf && inPortal.IsPlaced() && outPortal.IsPlaced())
    {
        ...
    }
    else
    {
        cloneObject.transform.position = new Vector3(-1000.0f, 1000.0f, -1000.0f);
    }
}
If either portal has not been placed (we’ll cover this in the next tutorial), or if the object isn’t near or intersecting the portal, then we won’t attempt to position the clone. We’ll just stick it at a faraway location. Once we’ve made sure we’re inside the portal and the clone is enabled (cloneObject.activeSelf) and that both portals have been placed in the scene, we’ll position the clone relative to the other portal.

if(cloneObject.activeSelf && inPortal.IsPlaced() && outPortal.IsPlaced())
{
    var inTransform = inPortal.transform;
    var outTransform = outPortal.transform;

    // Update position of clone.
    Vector3 relativePos = inTransform.InverseTransformPoint(transform.position);
    relativePos = halfTurn * relativePos;
    cloneObject.transform.position = outTransform.TransformPoint(relativePos);

    // Update rotation of clone.
    Quaternion relativeRot = Quaternion.Inverse(inTransform.rotation) * transform.rotation;
    relativeRot = halfTurn * relativeRot;
    cloneObject.transform.rotation = outTransform.rotation * relativeRot;
}
This positioning code is starting to look very familiar - it’s essentially the same as the code we wrote in Warp earlier! Now, when we run the scene, we ought to see a complete sphere popping out of both portals.

Cloned object

Note that there are some graphical oddities across the portal boundary. That’s to do with the lighting being different on each portal surface. If you’re going to use these portals, it might be a good idea not to rely on just a single directional light for your scene and to find an alternative lighting method that faithfully lights up objects on both sides of the portal.

The Player
The player is a special case. For most objects, its rotation exiting the portal is the transformed entrance rotation - an external observer will see a seamless entrance into the portal. However, a player is fundamentally different because their view always needs to be oriented such that down faces the same way as gravity. If two portals are placed on the floor - as they are here - then an unmodified version of the code will leave the player facing upside down when travelling through the portal. We need to recalculate the rotation of the player so that its local ‘up-direction’ and the world-space up-direction are the same.

The PlayerController class inherits from PortalableObject, but it’s very short. It adds small amounts of functionality to the Awake and Warp methods so that the CameraMove component is told to reorient itself.

public class PlayerController : PortalableObject
{
    private CameraMove cameraMove;

    protected override void Awake()
    {
        base.Awake();

        cameraMove = GetComponent<CameraMove>();
    }

    public override void Warp()
    {
        base.Warp();
        cameraMove.ResetTargetRotation();
    }
}
By calling base.Awake and base.Warp, the original functionality of both methods from PortalableObject is preserved. Let’s now look at the ResetTargetRotation method on CameraMove, found at Scripts/CameraMove.cs.

public void ResetTargetRotation()
{
    targetRotation = Quaternion.LookRotation(transform.forward, Vector3.up);
}
The Quaternion.LookRotation method builds a new rotation, where the first parameter becomes the forward-direction of the rotation. The right-direction of the rotation is the cross-product of the two parameters - that is, a new vector perpendicular to both vectors - and the up-direction of the new rotation is not Vector3.up in this example, as you might expect, but the cross-product of the forward-direction and right-direction we just calculated. The result is a new rotation that kind of points in the same direction as before, but oriented with a new up-direction.

In the Update method, the actual rotation is spherically interpolated toward the targetRotation we just calculated using the Quaternion.Slerp method (for more on interpolation, see my Unity Tips article on Interpolation). The rotation delta is based on the mouse movement and the rotation around the x-axis - the ‘vertical’ camera movement - is clamped. There’s other code regarding movement that we don’t need to worry about.

private void Update()
{
    // Rotate the camera.
    var rotation = new Vector2(-Input.GetAxis("Mouse Y"), Input.GetAxis("Mouse X"));
    var targetEuler = targetRotation.eulerAngles + (Vector3)rotation * cameraSpeed;
    if(targetEuler.x > 180.0f)
    {
        targetEuler.x -= 360.0f;
    }
    targetEuler.x = Mathf.Clamp(targetEuler.x, -75.0f, 75.0f);
    targetRotation = Quaternion.Euler(targetEuler);

    transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, 
        Time.deltaTime * 15.0f);
        
    ...
}
Now let’s see what the world looks like from a player’s perspective. Note that the portals are in the same positions as the other examples so far.

Large objects
The other issue to deal with is large objects. If we disable collision between the wall and a large object, won’t it clip through the wall partially while travelling through the portal? That’s possible - and to counteract this, the portal also contains a non-trigger collider frame around itself which blocks objects that are detected by the portal trigger but are too large to fit through the portal. Here’s what the mesh for the collider looks like in Blender:

Portal frame

Conclusion
We can handle the velocity of objects exiting a portal in several ways, but it’s easiest to disregard real-world physics behaviour and pick the one that results in the best gameplay. It’s also good enough to use a simple solution to collisions rather than trying to engineer a solution that modifies the wall collider in realtime. The player controller is a special case, given a player’s expectations of how their in-game character will behave. And we need to deal with the edge case when objects are clipping through the portal by adding a visual clone on the other side of the portal.

In the next tutorial, we’ll deal with placing portals of our own, including raycasting and portal orientation.