Three JS Portal implementation artifacts

enter image description here

I need help, I’m trying to create a portal effect using a stencil buffer in three js . and obviously my code looks correct but I still get those artifacts when I increase the max recursion to two or more.

import * as THREE from "three"
import CANNON from "cannon"
import Game from "./game"

class Portal {
    constructor(scene, renderer) {
        this.scene = scene;
        this.renderer = renderer;
        this.tmpScene = new THREE.Scene();

        this.rotationYMatrix = new THREE.Matrix4().makeRotationY(Math.PI);
        this.inverse = new THREE.Matrix4();
        this.dstInverse = new THREE.Matrix4();
        this.srcToCam = new THREE.Matrix4();
        this.srcToDst = new THREE.Matrix4();
        this.result = new THREE.Matrix4();

        this.dstRotationMatrix = new THREE.Matrix4();
        this.normal = new THREE.Vector3();
        this.clipPlane = new THREE.Plane();
        this.clipVector = new THREE.Vector4();
        this.q = new THREE.Vector4();
        this.projectionMatrix = new THREE.Matrix4();
        this.cameraInverseViewMat = new THREE.Matrix4();

        this.originalCameraMatrixWorld = new THREE.Matrix4();
        this.originalCameraProjectionMatrix = new THREE.Matrix4();

        this.maxRecursion = 2;
    }

    renderScene(camera, children) {
        this.tmpScene.children = children;
        this.renderer.render(this.tmpScene, camera);
    }

    calculateObliqueMatrix(projMatrix, clipPlane) {
        const q = new THREE.Vector4(
            (Math.sign(clipPlane.x) + projMatrix.elements[8]) / projMatrix.elements[0],
            (Math.sign(clipPlane.y) + projMatrix.elements[9]) / projMatrix.elements[5],
            -1.0,
            (1.0 + projMatrix.elements[10]) / projMatrix.elements[14]
        );
    
        const c = clipPlane.multiplyScalar(2.0 / clipPlane.dot(q));
    
        projMatrix.elements[2] = c.x;
        projMatrix.elements[6] = c.y;
        projMatrix.elements[10] = c.z + 1.0;
        projMatrix.elements[14] = c.w;
    
        return projMatrix;
    }

    render(camera, recursionLevel = 0, virtualCamera, portals) {
        if (recursionLevel > this.maxRecursion) return;

        const gl = this.renderer.getContext();

        for (let i = 0; i < portals.length; i++) {
            let portal = portals[i];

            gl.colorMask(false, false, false, false);
            gl.depthMask(false);
            gl.disable(gl.DEPTH_TEST);
            gl.enable(gl.STENCIL_TEST);
            gl.stencilFunc(gl.NOTEQUAL, recursionLevel, 0xff);
            gl.stencilOp(gl.INCR, gl.KEEP, gl.KEEP);
            gl.stencilMask(0xff);

            this.renderScene(camera, [portal, camera]);

            // Get the inverse of the inPortal's transformation matrix
            const inverseInMatrix = new THREE.Matrix4().copy(portal.matrixWorld).invert();

            // Calculate the relative position of the main object to the portal
            const relativePos = new THREE.Vector3().copy(camera.position).applyMatrix4(inverseInMatrix);

            // Rotate the relative position by 180 degrees around the Y-axis
            relativePos.applyAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI);

            // Apply the portal.pair's transformation matrix to get the new camera position
            const newCameraPos = relativePos.applyMatrix4(portal.pair.matrixWorld);
            virtualCamera.position.copy(newCameraPos);

            // Get the relative rotation of the main object to the portal
            const inverseInQuat = portal.quaternion.clone().invert();
            const relativeRot = camera.quaternion.clone().premultiply(inverseInQuat);

            // Rotate the relative rotation by 180 degrees around the Y-axis
            const rot180Y = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI);
            relativeRot.premultiply(rot180Y);

            // Apply the portal.pair's rotation to get the new camera rotation
            const newCameraRot = portal.pair.quaternion.clone().multiply(relativeRot);
            virtualCamera.quaternion.copy(newCameraRot);

            // Update camera matrices
            virtualCamera.updateMatrixWorld(true);
            
            // Create the Plane object in world space
            const pNormal = new THREE.Vector3().copy(portal.pair.getWorldDirection(new THREE.Vector3()));
            const pPosition = new THREE.Vector3().copy(portal.pair.position);
            const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(pNormal, pPosition);

            // Convert Plane to Vector4
            const clipPlane = new THREE.Vector4(plane.normal.x, plane.normal.y, plane.normal.z, plane.constant);

            // Transform the clip plane into camera space
            const viewMatrix = new THREE.Matrix4().copy(virtualCamera.matrixWorldInverse);
            const clipPlaneCameraSpace = clipPlane.applyMatrix4(viewMatrix).normalize();

            const mainCamera = camera;
            const mainCameraProjectionMatrix = mainCamera.projectionMatrix.clone();

            // Calculate the new oblique projection matrix
            const newProjectionMatrix = this.calculateObliqueMatrix(mainCameraProjectionMatrix, clipPlaneCameraSpace);

            // Apply the new projection matrix to the portal camera
            virtualCamera.projectionMatrix.copy(newProjectionMatrix);

            if (recursionLevel === this.maxRecursion) {
                gl.colorMask(true, true, true, true);
                gl.depthMask(true);
                this.renderer.clear(false, true, false);
                gl.enable(gl.DEPTH_TEST);
                gl.enable(gl.STENCIL_TEST);
                gl.stencilMask(0x00);
                gl.stencilFunc(gl.EQUAL, recursionLevel + 1, 0xff);
                this.renderScene(virtualCamera, this.scene.children);
            } else {
                this.render(virtualCamera, recursionLevel + 1, virtualCamera, [portal, portal.pair]);
            }

            gl.colorMask(false, false, false, false);
            gl.depthMask(false);
            gl.enable(gl.STENCIL_TEST);
            gl.stencilMask(0xff);
            gl.stencilFunc(gl.NOTEQUAL, recursionLevel + 1, 0xFF);
            gl.stencilOp(gl.DECR, gl.KEEP, gl.KEEP);

            this.renderScene(camera, [portal, camera]);
        }

        gl.disable(gl.STENCIL_TEST);
        gl.stencilMask(0x00);
        gl.colorMask(false, false, false, false);
        gl.enable(gl.DEPTH_TEST);
        gl.depthMask(true);
        gl.depthFunc(gl.ALWAYS);
        this.renderer.clear(false, true, false);

        this.renderScene(camera, [...portals, camera]);

        gl.depthFunc(gl.LESS);
        gl.enable(gl.STENCIL_TEST);
        gl.stencilMask(0x00);
        gl.stencilFunc(gl.LEQUAL, recursionLevel, 0xff);
        gl.colorMask(true, true, true, true);
        gl.depthMask(true);
        gl.enable(gl.DEPTH_TEST);

        this.renderScene(camera, this.scene.children);
    }
}

export default class World {
    constructor() {
        this.game = new Game()
        this.scene = this.game.scene
        this.physics = new CANNON.World()
        this.physics.gravity.set(0, -9.82, 0)
        this.physics.broadphase = new CANNON.SAPBroadphase(this.physics)
        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, 100);
        this.scene.add(this.virtualCamera);
        this.portalHandler = new Portal(this.scene, this.game.renderer.instance);
        this.active = ''
        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: 1,
                restitution: 0
            }
        )
        this.physics.defaultContactMaterial = defaultContactMaterial
        window.addEventListener('click', this.shoot.bind(this));
        this.setWorld()
        this.loadTextures()

        this.physics.addEventListener('postStep',()=>{
            this.game.controls.cameraBody.position.y - 10
            const contacts = this.physics.contacts
            let body1,body2
            let collide = false
            for (let i = 0; i < contacts.length; i++) {
                const contact = contacts[i];
                body1 = contact.bi;
                body2 = contact.bj;
                if(body1.class=='camera' && body2.class=='portal'){
                    collide = true
                    break
                }
            }
            if(collide && this.leftPortal.length>0 && this.rightPortal.length>0){
                if(body2.object && body2.object.physicObject){
                    body2.object.physicObject.collisionResponse = false
                }
            }
            else{
                this.physics.bodies.forEach((body)=>{
                    if(body.class != 'portal'){
                        body.collisionResponse = true
                        this.game.controls.cameraBody.wakeUp()
                    }
                })
            }
        })

    }

    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 geometry = new THREE.CircleGeometry(5,32)
            const material = new THREE.MeshBasicMaterial( { color: 0x000000 } ); 
            const circle = new THREE.Mesh( geometry, material ); 
            circle.position.x = intersects[0].point.x
            circle.position.y = intersects[0].point.y
            circle.position.z = intersects[0].point.z
            circle.rotation.x = intersects[0].object.rotation.x
            circle.rotation.y = intersects[0].object.rotation.y
            circle.rotation.z = intersects[0].object.rotation.z
            circle.scale.y = 1.5
            if(intersects[0].object == this.plane){
                circle.position.y += 0.01
            }
            if(intersects[0].object == this.roof){
                circle.position.y -= 0.01
            }
            if(intersects[0].object == this.scene.getObjectByName('backWall')){
                circle.position.z -= 0.01
            }
            if(intersects[0].object == this.scene.getObjectByName('frontWall')){
                circle.position.z += 0.01
                this.object = this.physics.bodies[2]
                console.log(this.object)
            }
            if(intersects[0].object == this.scene.getObjectByName('rightWall')){
                circle.position.x -= 0.01
            }
            if(intersects[0].object == this.scene.getObjectByName('leftWall')){
                circle.position.x += 0.01
            }
            if (event.button === 0) {
                this.box = new THREE.Mesh(new THREE.BoxGeometry(10,10,10),new THREE.MeshBasicMaterial({color:'red',wireframe:true}))
                this.box.scale.y = 1.5
                this.box.position.copy(circle.position)
                circle.box = this.box
                this.boxBody = new CANNON.Body()
                this.boxBody.mass = 0
                this.boxBody.material = this.defaultMaterial
                // this.boxBody.collisionFilterGroup = 1
                // this.boxBody.collisionFilterMask = 0
                this.boxBody.collisionResponse = false
                this.boxBody.addShape(new CANNON.Box(new CANNON.Vec3(5,7.5,5)))
                this.boxBody.quaternion.copy(circle.quaternion)
                this.boxBody.position.copy(circle.position)
                this.boxBody.addEventListener('collide',(event) => {
                    if(event.body.class == 'camera'){
                        this.active = 'leftportal'
                    }
                })
                this.boxBody.class = 'portal'
                this.boxBody.object = intersects[0].object
                this.physics.addBody(this.boxBody)
                circle.physicObject = this.boxBody
                this.leftPortal.push(circle)
            } else if (event.button === 2) {
                this.box = new THREE.Mesh(new THREE.BoxGeometry(10,10,10),new THREE.MeshBasicMaterial({color:'blue',wireframe:true}))
                this.box.scale.y = 1.5
                this.box.position.copy(circle.position)
                circle.box = this.box
                this.boxBody = new CANNON.Body()
                this.boxBody.mass = 0
                this.boxBody.material = this.defaultMaterial
                // this.boxBody.collisionFilterGroup = 1
                // this.boxBody.collisionFilterMask = 0
                this.boxBody.collisionResponse = false
                this.boxBody.addShape(new CANNON.Box(new CANNON.Vec3(5,7.5,5)))
                this.boxBody.quaternion.copy(circle.quaternion)
                this.boxBody.position.copy(circle.position)
                this.boxBody.addEventListener('collide',(event) => {
                    if(event.body.class == 'camera'){
                        this.active = 'rightportal'
                    }
                })
                this.boxBody.class = 'portal'
                this.boxBody.object = intersects[0].object
                this.physics.addBody(this.boxBody)
                circle.physicObject = this.boxBody
                this.rightPortal.push(circle)
            }
        }
    }

    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 wallShape = new CANNON.Box(new CANNON.Vec3(60, 30, 1))
        this.plane = new THREE.Mesh(
            new THREE.PlaneGeometry(60, 60),
            new THREE.MeshStandardMaterial({
                roughness: 0.2,
                metalness: 0.1
            })
        )
        this.plane.rotation.x = -Math.PI / 2
        this.plane.receiveShadow = true
        this.scene.add(this.plane)

        const floorShape = new CANNON.Plane()
        const floorBody = new CANNON.Body()
        floorBody.mass = 0
        floorBody.addShape(floorShape)
        floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(- 1, 0, 0), Math.PI * 0.5)
        this.physics.addBody(floorBody)

        this.plane.physicObject = floorBody

        let backWallMesh = new THREE.Mesh(
            new THREE.PlaneGeometry(60, 30),
            new THREE.MeshStandardMaterial({
                color: 0xf0ffff,
                side: THREE.DoubleSide
            })
        )
        backWallMesh.position.z = 30
        backWallMesh.position.y = 15
        backWallMesh.rotation.y = Math.PI
        this.scene.add(backWallMesh)

        const backWallBody = new CANNON.Body()
        backWallBody.mass = 0
        backWallBody.material = this.defaultMaterial
        backWallBody.addShape(wallShape)
        backWallBody.quaternion.copy(backWallMesh.quaternion)
        backWallBody.position.copy(backWallMesh.position)
        this.physics.addBody(backWallBody)

        backWallMesh.physicObject = backWallBody

        const frontWallBody = new CANNON.Body()
        frontWallBody.mass = 0
        frontWallBody.addShape(wallShape)
        frontWallBody.quaternion.copy(backWallMesh.quaternion)
        frontWallBody.position.set(0, 15, -30)
        this.physics.addBody(frontWallBody)

        let frontWallMesh = new THREE.Mesh(
            new THREE.PlaneGeometry(60, 30),
            new THREE.MeshStandardMaterial({
                color: 0xff0fff,
                side: THREE.DoubleSide
            })
        )
        frontWallMesh.position.z = -30
        frontWallMesh.position.y = 15
        frontWallMesh.physicObject = frontWallBody
        this.scene.add(frontWallMesh)

        let leftWallMesh = new THREE.Mesh(
            new THREE.PlaneGeometry(60, 30),
            new THREE.MeshStandardMaterial({
                color: 0xfff0ff,
                side: THREE.DoubleSide
            })
        )
        leftWallMesh.rotation.y = Math.PI / 2
        leftWallMesh.position.x = -30
        leftWallMesh.position.y = 15
        this.scene.add(leftWallMesh)

        const leftWallBody = new CANNON.Body()
        leftWallBody.mass = 0
        leftWallBody.addShape(wallShape)
        leftWallBody.quaternion.copy(leftWallMesh.quaternion)
        leftWallBody.position.copy(leftWallMesh.position)
        this.physics.addBody(leftWallBody)

        leftWallMesh.physicObject = leftWallBody


        let rightWallMesh = new THREE.Mesh(
            new THREE.PlaneGeometry(60, 30),
            new THREE.MeshStandardMaterial({
                color: 0xffff0f,
                side: THREE.DoubleSide
            })
        )
        rightWallMesh.rotation.y = -Math.PI / 2
        rightWallMesh.position.x = 30
        rightWallMesh.position.y = 15
        this.scene.add(rightWallMesh)

        const rightWallBody = new CANNON.Body()
        rightWallBody.mass = 0
        rightWallBody.addShape(wallShape)
        rightWallBody.quaternion.copy(rightWallMesh.quaternion)
        rightWallBody.position.copy(rightWallMesh.position)
        this.physics.addBody(rightWallBody)

        rightWallMesh.physicObject = rightWallBody

        this.roof = new THREE.Mesh(
            new THREE.PlaneGeometry(60, 60),
            new THREE.MeshStandardMaterial()
        )
        this.roof.rotation.x = Math.PI / 2
        this.roof.position.y = 30
        this.scene.add(this.roof)

        backWallMesh.name = 'backWall';
        frontWallMesh.name = 'frontWall';
        leftWallMesh.name = 'leftWall';
        rightWallMesh.name = 'rightWall';
    }
    
    loadTextures() {
        this.game.resources.on('ready', () => {
            
        })
    }

    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]]);
    }
    
    update() {
        this.physics.step(1 / 60, this.game.time.delta, 3)

        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.renderPortal()
        }
        else{
            this.game.renderer.instance.render(this.scene, this.game.camera.instance)
        }

        this.managePortals()

        if (this.rightPortal.length > 0 && this.leftPortal.length > 0) {
            this.checkLeftPortalTeleport()
            this.checkRightPortalTeleport()
        } else {
            this.resetPortalColors()
        }
    }

    managePortals() {
        // Manage left portals
        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)
        }

        // Manage right portals
        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(portal, portal2, camera, body) {
        // Calculate player's position and rotation relative to portal1
        const relativePosition = this.worldToLocal(portal, camera.position);
        
        // Reflect position and rotate relative to portal2
        relativePosition.x = -relativePosition.x;
        relativePosition.z = -relativePosition.z;
    
        // Convert Cannon.js quaternion to Three.js quaternion
        const bodyQuat = new THREE.Quaternion(body.quaternion.x, body.quaternion.y, body.quaternion.z, body.quaternion.w);
    
        const relativeRot = new THREE.Quaternion();
        relativeRot.copy(portal.quaternion).invert().multiply(camera.quaternion);
    
        const euler = new THREE.Euler(0, Math.PI, 0); // Euler angles in radians (0, 180, 0 degrees)
        const quaternion = new THREE.Quaternion().setFromEuler(euler);
        relativeRot.multiplyQuaternions(quaternion, relativeRot);
    
        // Apply the transformation to the body quaternion
        const newBodyQuat = new THREE.Quaternion();
        newBodyQuat.copy(portal2.quaternion).multiply(relativeRot);
    
        // Convert the resulting quaternion back to Cannon.js format
        body.quaternion.set(newBodyQuat.x, newBodyQuat.y, newBodyQuat.z, newBodyQuat.w);
    
        // Convert from portal2 local space back to world space
        const newPosition = this.localToWorld(portal2, relativePosition);
    
        body.position.copy(newPosition);

        camera.quaternion.copy(newBodyQuat)
    }  

    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.leftPortal[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.rightPortal[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
        }
    }
}

I was following this guide https://th0mas.nl/2013/05/19/rendering-recursive-portals-with-opengl/ but i instead of calculating view matrix and projection matrix because it didn’t works well for me , i tried the coding adventure approach for virtual camera transformation and for oblique projection that is the only difference i made from the guide

New contributor

yassir benmoussa is a new contributor to this site. Take care in asking for clarification, commenting, and answering.
Check out our Code of Conduct.

Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa Dịch vụ tổ chức sự kiện 5 sao Thông tin về chúng tôi Dịch vụ sinh nhật bé trai Dịch vụ sinh nhật bé gái Sự kiện trọn gói Các tiết mục giải trí Dịch vụ bổ trợ Tiệc cưới sang trọng Dịch vụ khai trương Tư vấn tổ chức sự kiện Hình ảnh sự kiện Cập nhật tin tức Liên hệ ngay Thuê chú hề chuyên nghiệp Tiệc tất niên cho công ty Trang trí tiệc cuối năm Tiệc tất niên độc đáo Sinh nhật bé Hải Đăng Sinh nhật đáng yêu bé Khánh Vân Sinh nhật sang trọng Bích Ngân Tiệc sinh nhật bé Thanh Trang Dịch vụ ông già Noel Xiếc thú vui nhộn Biểu diễn xiếc quay đĩa Dịch vụ tổ chức tiệc uy tín Khám phá dịch vụ của chúng tôi Tiệc sinh nhật cho bé trai Trang trí tiệc cho bé gái Gói sự kiện chuyên nghiệp Chương trình giải trí hấp dẫn Dịch vụ hỗ trợ sự kiện Trang trí tiệc cưới đẹp Khởi đầu thành công với khai trương Chuyên gia tư vấn sự kiện Xem ảnh các sự kiện đẹp Tin mới về sự kiện Kết nối với đội ngũ chuyên gia Chú hề vui nhộn cho tiệc sinh nhật Ý tưởng tiệc cuối năm Tất niên độc đáo Trang trí tiệc hiện đại Tổ chức sinh nhật cho Hải Đăng Sinh nhật độc quyền Khánh Vân Phong cách tiệc Bích Ngân Trang trí tiệc bé Thanh Trang Thuê dịch vụ ông già Noel chuyên nghiệp Xem xiếc khỉ đặc sắc Xiếc quay đĩa thú vị
Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa
Thiết kế website Thiết kế website Thiết kế website Cách kháng tài khoản quảng cáo Mua bán Fanpage Facebook Dịch vụ SEO Tổ chức sinh nhật