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.