Skip to content

Instantly share code, notes, and snippets.

@cgkhgehkvjbejjjfksmbfvgms
Created May 13, 2025 10:13
Show Gist options
  • Save cgkhgehkvjbejjjfksmbfvgms/31c757f57b841d3fcc683cf11d4dd0e1 to your computer and use it in GitHub Desktop.
Save cgkhgehkvjbejjjfksmbfvgms/31c757f57b841d3fcc683cf11d4dd0e1 to your computer and use it in GitHub Desktop.
Infinite Portals
<canvas class="webgl"></canvas>
<div id="instructions">Drag to turn around<br />Scroll to zoom in / out<br />Click on portals to explore</div>
<div id="credits">
<p><a href="https://codepen.io/Yakudoo/" target="blank">my other codepens</a> | <a href="http://epic.net" target="blank">epic.net</a></p>
</div>
<script type="x-shader/x-vertex" id="vertexShader">
precision highp float;
varying vec2 vUv;
void main() {
vUv = uv;
vec4 modelViewPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * modelViewPosition;
}
</script>
<script type="x-shader/x-fragment" id="fragmentShader">
#define PI 3.1415
#define TAU 6.2832
uniform sampler2D map;
uniform sampler2D noiseMap;
uniform float time;
uniform float effectIntensity;
uniform float effectMultiplier;
varying vec2 vUv;
void main() {
// center uv
vec2 vUv2 = vUv - .5;
// get each point angle
float angle = atan(vUv2.y, vUv2.x);
// get distance to each point
float l = length(vUv2);
float l2 = pow(l, .5);
// create a radial moving noise
float u = angle * 2. / TAU + time * .1;
float v = fract(l + time * .2);
vec4 noise = texture2D( noiseMap, vec2(u, v));
// create waves
float noiseDisp = noise.r * noise.g * 4. * effectMultiplier;
float radialMask = l;
float wavesCount = 5.0;
float wavesSpeed = time * 5.;
float pnt = sin(2.0 * l * PI * wavesCount + noiseDisp + wavesSpeed) * radialMask;
// calculate displacement according to waves
float dx = pnt * cos(angle) ;
// normalize
float dy = pnt * sin(angle);
// sample texture and apply wave displacement
vec4 color = texture2D( map, vUv + vec2(dx,dy) * l * .3 * effectIntensity * effectMultiplier);
// lighten according to waves
color *= 1. + pnt * .5 * effectIntensity;
// highlights
float highlight = smoothstep(.0, .2, dx * dy);
color += highlight * effectIntensity;
// gradient greyscale at the borders
float grey = dot(color.rgb, vec3(0.299, 0.587, 0.114));
color.rgb = mix(color.rgb, vec3(grey), effectIntensity * l * effectMultiplier);
// add redish color at the borders
color.r += smoothstep( .1, .7, l) * .5 * effectIntensity;
gl_FragColor = linearToOutputTexel(color);
//gl_FragColor = linearToOutputTexel(vec4(highlight,highlight,highlight,1.));
}
</script>

Infinite Portals

A birdhouse inside a sand castle inside a birdhouse... Dive into these recursive worlds and enjoy the infinite transition. A webgl demo made with Three.js and Gsap.

A Pen by Karim Maaloul on CodePen.

License.

import * as THREE from "https://esm.sh/[email protected]";
import { OrbitControls } from "https://esm.sh/[email protected]/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "https://esm.sh/[email protected]/examples/jsm/loaders/GLTFLoader";
import * as CameraUtils from "https://esm.sh/[email protected]/examples/jsm/utils/CameraUtils";
import { gsap, Power4 } from "https://esm.sh/gsap";
import { EventEmitter } from "https://esm.sh/events";
const FILES = {
desertFile: "https://assets.codepen.io/264161/desert33.glb",
forestFile: "https://assets.codepen.io/264161/forest33.glb",
noiseFile: "https://assets.codepen.io/264161/noise_1.jpg"
};
const ASSETS = {};
document.addEventListener("DOMContentLoaded", () => new App());
class App {
constructor() {
this.winWidth = window.innerWidth;
this.winHeight = window.innerHeight;
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.clock = new THREE.Clock();
this.time = 0;
this.deltaTime = 0;
this.isInTransition = false;
this.portalHover = false;
this.loadAssets();
}
async loadAssets() {
ASSETS.desertScene = await this.loadModel(FILES.desertFile);
ASSETS.forestScene = await this.loadModel(FILES.forestFile);
ASSETS.noiseMap = await this.loadTexture(FILES.noiseFile);
this.initApp();
}
loadModel(file) {
const loaderModel = new GLTFLoader();
return new Promise((resolve) => {
loaderModel.load(file, (gltf) => {
resolve(gltf.scene);
});
});
}
loadTexture(file) {
const textureLoader = new THREE.TextureLoader();
return new Promise((resolve) => {
textureLoader.load(file, (texture) => {
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
resolve(texture);
});
});
}
initApp() {
this.createWorlds();
this.createRenderer();
this.createControls();
this.createListeners();
this.onWindowResize();
this.loop();
}
createWorlds() {
this.desertWorld = new World(ASSETS.desertScene, "desert");
this.forestWorld = new World(ASSETS.forestScene, "forest");
this.desertWorld.addListener("moveToPortalComplete", () =>
this.onMoveToPortalComplete()
);
this.forestWorld.addListener("moveToPortalComplete", () =>
this.onMoveToPortalComplete()
);
this.desertWorld.addListener("moveToOriginComplete", () =>
this.onMoveToOriginComplete()
);
this.forestWorld.addListener("moveToOriginComplete", () =>
this.onMoveToOriginComplete()
);
this.currentWorld = this.forestWorld;
this.otherWorld = this.desertWorld;
// portalWorldStart and portalWorldEnd are virtual object in each world, they define where to position, scale and rotate the initial and final transforms of the virtual world during the camera transition.
this.desertWorld.setTransitionTransforms(
this.forestWorld.portalWorldStart,
this.forestWorld.portalWorldEnd
);
this.forestWorld.setTransitionTransforms(
this.desertWorld.portalWorldStart,
this.desertWorld.portalWorldEnd
);
this.otherWorld.placeToStart();
this.currentWorld.reset();
}
// used once the camera reaches the portal. The virtual world becomes the main one and vice versa
switchWorlds() {
const w = this.otherWorld;
this.otherWorld = this.currentWorld;
this.currentWorld = w;
this.otherWorld.placeToStart();
this.currentWorld.reset();
this.onWindowResize();
}
/*
The transition is done in 3 steps :
1 - the cameras moves towards the portal + the virtual world moves to portalWorldEnd
2 - When the camera reaches the portal, main world and virtual world are switched
3 - The cameras move back to their start position. Main world transform are moved back to their origin : scale = 1, rotation = 0, position = 0
*/
moveCameraToPortal() {
this.isInTransition = true;
this.controls.enabled = false;
this.currentWorld.moveCameraToPortal();
this.otherWorld.moveWorldToEnd();
}
onMoveToPortalComplete() {
this.switchWorlds();
this.currentWorld.moveWorldAndCameraToOrigin();
}
onMoveToOriginComplete() {
this.controls.object = this.currentWorld.camera;
this.controls.target = this.currentWorld.cameraTarget.position;
this.isInTransition = false;
this.controls.enabled = true;
}
createRenderer() {
const canvas = document.querySelector("canvas.webgl");
this.renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
preserveDrawingBuffer: true
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.toneMapping = THREE.CineonToneMapping;
this.renderer.localClippingEnabled = true;
}
createControls() {
this.controls = new OrbitControls(
this.currentWorld.camera,
this.renderer.domElement
);
this.controls.minDistance = 0;
this.controls.maxDistance = 50;
this.controls.maxPolarAngle = Math.PI / 2 + 0.1;
this.controls.enabled = true;
}
createListeners() {
window.addEventListener("resize", this.onWindowResize.bind(this));
document.addEventListener("mousemove", this.onMouseMove.bind(this), false);
document.addEventListener("touchmove", this.onTouchMove.bind(this), false);
document.addEventListener("mousedown", this.onMouseDown.bind(this), false);
}
loop() {
this.deltaTime = this.clock.getDelta();
this.time += this.deltaTime;
// apply visual effects on the portal at each frame
this.currentWorld.portal.loop(this.deltaTime);
this.render();
if (this.controls && this.controls.enabled) this.controls.update();
// make sure cameras of both worlds are at the exact same position
this.syncCameras();
window.requestAnimationFrame(this.loop.bind(this));
}
render() {
// virtual world is rendered on a texture at each frame just before rendering the main world
// align virtual camera to main world's portal corners
this.currentWorld.portal.updateCorners();
const { bottomLeft, bottomRight, topLeft } = this.currentWorld.portal.corners;
CameraUtils.frameCorners( this.otherWorld.camera, bottomLeft, bottomRight, topLeft, false);
// store main render target
const currentRenderTarget = this.renderer.getRenderTarget();
// render virtual scene through portal
this.renderer.setRenderTarget(this.currentWorld.portal.renderTarget);
this.renderer.render(this.otherWorld.scene, this.otherWorld.camera);
// reuse main render target
this.renderer.setRenderTarget(currentRenderTarget);
// render main world
this.renderer.render(this.currentWorld.scene, this.currentWorld.camera);
}
syncCameras() {
this.otherWorld.camera.position.copy(this.currentWorld.camera.position);
this.otherWorld.camera.quaternion.copy(this.currentWorld.camera.quaternion);
this.otherWorld.cameraTarget.position.copy(
this.currentWorld.cameraTarget.position
);
}
raycast() {
this.raycaster.setFromCamera(this.mouse, this.currentWorld.camera);
var intersects = this.raycaster.intersectObjects([
this.currentWorld.portalPlane
]);
// mouse over portal
if (intersects.length > 0) {
this.currentWorld.portal.effectMultiplier = 2;
this.portalHover = true;
} else {
this.currentWorld.portal.effectMultiplier = 1;
this.portalHover = false;
}
}
onWindowResize() {
this.winWidth = window.innerWidth;
this.winHeight = window.innerHeight;
this.renderer.setSize(this.winWidth, this.winHeight);
this.currentWorld.camera.aspect = this.winWidth / this.winHeight;
this.currentWorld.camera.updateProjectionMatrix();
}
onMouseDown(event) {
if (this.portalHover && !this.isInTransition) this.moveCameraToPortal();
}
onMouseMove(event) {
const x = (event.clientX / this.winWidth) * 2 - 1;
const y = -((event.clientY / this.winHeight) * 2 - 1);
this.updateMouse(x, y);
this.raycast();
}
onTouchMove(event) {
if (event.touches.length == 1) {
event.preventDefault();
const x = (event.touches[0].pageX / this.winWidth) * 2 - 1;
const y = -((event.touches[0].pageY / this.winHeight) * 2 - 1);
this.updateMouse(x, y);
this.raycast();
}
}
updateMouse(x, y) {
this.mouse.x = x;
this.mouse.y = y;
}
}
// WORLD
class World extends EventEmitter {
constructor(scene, name) {
super();
this.scene = scene;
this.name = name;
this.camera = new THREE.PerspectiveCamera( 60, this.winWidth / this.winHeight, 0.1, 150);
this.camera.position.set(0, 0, 30);
this.scene.add(this.camera);
this.transitionDuration = 1.5;
this.processModel();
}
processModel() {
this.holder = this.scene.getObjectByName("holder");
this.portalPlane = this.scene.getObjectByName("portal");
this.portal = new Portal(this.portalPlane);
// an empty object placed in the start position of the other world (before the camera start moving inside the portal)
this.portalWorldStart = this.scene.getObjectByName("portalWorldStart");
// an empty object placed at the target position of the other world (when the camera reaches the portal)
this.portalWorldEnd = this.scene.getObjectByName("portalWorldEnd");
// virtual object used as a target for the camera to look at
this.cameraTarget = new THREE.Object3D();
}
setTransitionTransforms(startObject, endObject) {
this.startPosition = startObject.position.clone();
this.startScale = startObject.scale.clone();
this.startQuaternion = startObject.quaternion.clone();
this.endPosition = endObject.position.clone();
this.endScale = endObject.scale.clone();
this.endQuaternion = endObject.quaternion.clone();
}
reset() {
this.holder.position.set(0, 0, 0);
this.holder.scale.set(1, 1, 1);
this.holder.quaternion.identity();
}
placeToStart() {
this.holder.position.copy(this.startPosition);
this.holder.scale.copy(this.startScale);
this.holder.quaternion.copy(this.startQuaternion);
}
moveWorldToEnd() {
// used to keep some distance between cam and other world during transition
const duration = this.transitionDuration;
const ease = Power4.easeIn;
gsap.to(this.holder.position, {
duration,
ease,
x: this.endPosition.x,
y: this.endPosition.y,
z: this.endPosition.z
});
gsap.to(this.holder.scale, {
duration,
ease,
x: this.endScale.x,
y: this.endScale.y,
z: this.endScale.z
});
gsap.to(this.holder.quaternion, {
duration,
ease,
x: this.endQuaternion.x,
y: this.endQuaternion.y,
z: this.endQuaternion.z,
w: this.endQuaternion.w
});
}
moveWorldAndCameraToOrigin() {
// used to replace the world to its origin after entering the portal
const duration = this.transitionDuration;
const ease = Power4.easeOut;
// move World, reset scale and rotation
gsap.to(this.holder.position, {
duration,
ease,
x: 0,
y: 0,
z: 0
});
gsap.to(this.holder.scale, {
duration,
ease,
x: 1,
y: 1,
z: 1
});
gsap.to(this.holder.quaternion, {
duration,
ease,
x: 0,
y: 0,
z: 0,
w: 1
});
// move camera target
gsap.to(this.cameraTarget.position, {
duration,
ease,
x: 0,
y: 0,
z: 0
});
// move Camera
gsap.to(this.camera.position, {
duration,
ease,
x: 0,
y: 0,
z: 40,
onUpdate: () => {
this.camera.lookAt(this.cameraTarget.position);
},
onComplete: () => {
this.emit("moveToOriginComplete");
}
});
}
moveCameraToPortal() {
const duration = this.transitionDuration;
const ease = Power4.easeIn;
const dir = new THREE.Vector3();
this.portalPlane.getWorldDirection(dir);
const pos = new THREE.Vector3().copy(
this.portalPlane.position.clone().add(dir.multiplyScalar(3))
);
gsap.to(this.cameraTarget.position, {
duration,
ease,
x: this.portalWorldEnd.position.x,
y: this.portalWorldEnd.position.y,
z: this.portalWorldEnd.position.z
});
gsap.to(this.camera.position, {
duration,
ease,
x: pos.x,
y: pos.y,
z: pos.z,
onUpdate: () => {
this.camera.lookAt(this.cameraTarget.position);
},
onComplete: () => {
this.emit("moveToPortalComplete");
}
});
gsap.to(this.portal, {
duration,
ease: Power4.easeIn,
effectIntensity: 0,
onComplete: () => {
this.portal.effectIntensity = 1;
}
});
}
}
// PORTAL
class Portal {
constructor(plane) {
this.plane = plane;
this._effectIntensity = 1;
this._effectMultiplier = 1;
this.time = 0;
const fragmentShader = document.getElementById("fragmentShader")
.textContent;
const vertexShader = document.getElementById("vertexShader").textContent;
this.renderTarget = new THREE.WebGLRenderTarget(2048, 2048, {
type: THREE.HalfFloatType
});
this.plane.material = new THREE.ShaderMaterial({
uniforms: {
map: { value: this.renderTarget.texture },
noiseMap: { value: ASSETS.noiseMap },
time: { value: 0 },
effectIntensity: { value: this.effectIntensity },
effectMultiplier: { value: this.effectMultiplier }
},
vertexShader: vertexShader,
fragmentShader: fragmentShader
});
this.corners = {
bottomLeft: new THREE.Vector3(),
bottomRight: new THREE.Vector3(),
topLeft: new THREE.Vector3()
};
}
updateCorners() {
const { min, max } = this.plane.geometry.boundingBox;
this.plane.localToWorld(this.corners.bottomLeft.set(min.x, min.y, 0));
this.plane.localToWorld(this.corners.bottomRight.set(max.x, min.y, 0));
this.plane.localToWorld(this.corners.topLeft.set(min.x, max.y, 0));
}
set effectIntensity(v) {
this._effectIntensity = v;
this.plane.material.uniforms.effectIntensity.value = this._effectIntensity;
}
get effectIntensity() {
return this._effectIntensity;
}
set effectMultiplier(v) {
if (v == this._effectMultiplier) return;
this._effectMultiplier = v;
gsap.to(this.plane.material.uniforms.effectMultiplier, {
duration: 1,
ease: Power4.easeOut,
value: v
});
}
get effectMultiplier() {
return this._effectMultiplier;
}
loop(deltaTime) {
this.time += deltaTime * this.effectMultiplier;
this.plane.material.uniforms.time.value = this.time;
}
}
@import url(https://fonts.googleapis.com/css?family=Open+Sans:800);
.webgl {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
outline: none;
background-color: #000000;
cursor: move;
width: 100%;
height: 100%;
/* fallback if grab cursor is unsupported */
cursor: grab;
cursor: -moz-grab;
cursor: -webkit-grab;
}
#credits {
position: absolute;
bottom: 0;
left: 30px;
margin-bottom: 20px;
font-family: "Open Sans", sans-serif;
color: #544027;
font-size: 0.7em;
text-transform: uppercase;
}
#credits a {
color: #544027;
}
#credits a:hover {
color: #d3cfcf;
}
#instructions {
position: absolute;
bottom: 60px;
left: 30px;
font-family: "Open Sans", sans-serif;
color: #ffffff;
font-size: 0.7em;
line-height: 1.3;
text-transform: uppercase;
letter-spacing: 1px;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment