feat: warp tunnel effect when entering portals (#250)
Some checks failed
CI / validate (pull_request) Failing after 12s
CI / auto-merge (pull_request) Has been skipped

Adds three floating portal objects (Batcave, Workshop, The Void) around
the platform edge, each with an animated vortex interior shader. Clicking
a portal or pressing W triggers the fullscreen warp tunnel post-processing
effect — a swirling GLSL vortex with ring bands, 8-arm spiral, cyan/purple
color sweep, inward scene distortion, and a white flash at the peak.

- WarpTunnelShader + ShaderPass wired into existing EffectComposer
- Portal inner vortex: custom ShaderMaterial (5-arm spiral + ring bands)
- Raycaster click detection on portal disc meshes
- triggerWarp() state machine: build-up → hold → flash → fade-out
- 'W' key demo cycles through portals
- #warp-indicator HUD element with expanding letter-spacing pulse

Fixes #250
This commit is contained in:
Alexander Whitestone
2026-03-24 00:50:53 -04:00
parent 39e0eecb9e
commit f4e04b6a79
3 changed files with 326 additions and 0 deletions

295
app.js
View File

@@ -3,6 +3,7 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { BokehPass } from 'three/addons/postprocessing/BokehPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { LoadingManager } from 'three';
// === COLOR PALETTE ===
@@ -422,6 +423,149 @@ const bokehPass = new BokehPass(scene, camera, {
});
composer.addPass(bokehPass);
// === WARP TUNNEL EFFECT ===
// Full-screen post-processing shader triggered when a portal is entered.
// Phases: vortex builds → peak tunnel → white flash → fade out.
const WARP_DURATION = 2.8; // seconds
/** @type {{ startTime: number, portalName: string, spin: number }|null} */
let warpState = null;
const WarpTunnelShader = {
uniforms: {
tDiffuse: { value: null },
uTime: { value: 0.0 },
uProgress:{ value: 0.0 }, // 0 = off, 1 = full vortex
uFlash: { value: 0.0 }, // 0 = off, 1 = white flash
uSpin: { value: 0.0 }, // accumulated angular rotation
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */`
#define PI 3.14159265359
uniform sampler2D tDiffuse;
uniform float uTime;
uniform float uProgress;
uniform float uFlash;
uniform float uSpin;
varying vec2 vUv;
void main() {
vec2 p = vUv - 0.5;
float r = length(p);
float theta = atan(p.y, p.x) + uSpin;
// Suck scene pixels inward as tunnel builds
float pull = uProgress * 0.22 * max(0.0, 1.0 - r * 2.8);
vec2 wUv = clamp(vUv - p * pull, 0.001, 0.999);
vec4 base = texture2D(tDiffuse, wUv);
// Depth: 1/r creates the zooming-into-a-tube illusion
float depth = 0.1 / max(r, 0.001);
// Ring bands racing toward the viewer
float ring = fract(depth - uTime * 2.8 * uProgress);
float rBand = smoothstep(0.0, 0.12, ring) * (1.0 - smoothstep(0.12, 0.26, ring));
// 8-arm spiral rotating with time
float spiral = fract(theta * 8.0 / (2.0 * PI) + depth * 0.25 + uTime * 0.9 * uProgress);
float sBand = smoothstep(0.3, 0.42, spiral) * (1.0 - smoothstep(0.58, 0.7, spiral));
// Color: cyan / purple sweeping around theta
float hue = fract(theta / (PI * 2.0) + uTime * 0.12);
vec3 cyan = vec3(0.0, 0.82, 1.0);
vec3 purple = vec3(0.62, 0.05, 1.0);
vec3 vColor = mix(cyan, purple, hue);
// Central white-blue glow
vColor = mix(vColor, vec3(0.85, 0.96, 1.0), smoothstep(0.25, 0.0, r) * uProgress);
// Fade vortex at screen corners
float edgeMask = 1.0 - smoothstep(0.3, 0.5, r);
float vortex = max(rBand * 0.88, sBand * 0.52) * uProgress * edgeMask;
// Dark tunnel mouth near center
float voidDepth = smoothstep(0.18, 0.0, r) * uProgress * 0.85;
vec3 col = mix(base.rgb, vColor, vortex * 0.9);
col = mix(col, vec3(0.0, 0.01, 0.06), voidDepth);
// White flash radiating outward from center
float flash = uFlash * smoothstep(0.45, 0.0, r);
col = mix(col, vec3(1.0), clamp(flash, 0.0, 1.0));
gl_FragColor = vec4(col, 1.0);
}
`,
};
const warpPass = new ShaderPass(WarpTunnelShader);
warpPass.enabled = false;
composer.addPass(warpPass);
/**
* Drives the warp tunnel animation each frame; call from animate().
* @param {number} elapsed
*/
function updateWarpEffect(elapsed) {
if (!warpState) return;
const t = (elapsed - warpState.startTime) / WARP_DURATION;
if (t >= 1.0) {
warpState = null;
warpPass.enabled = false;
warpPass.uniforms.uProgress.value = 0;
warpPass.uniforms.uFlash.value = 0;
const ind = document.getElementById('warp-indicator');
if (ind) ind.classList.remove('visible');
return;
}
// Progress envelope: ramp 0→1, hold, ramp 1→0
let progress;
if (t < 0.32) {
progress = t / 0.32;
} else if (t < 0.62) {
progress = 1.0;
} else {
progress = 1.0 - (t - 0.62) / 0.38;
}
progress = Math.pow(Math.max(0, progress), 0.7);
// Flash peaks at t ≈ 0.52
const ft = (t - 0.43) / 0.18;
const flash = ft > 0 && ft < 1 ? (ft < 0.5 ? ft * 2 : 2 - ft * 2) : 0;
// Spin accelerates with progress
warpState.spin += progress * 0.09;
warpPass.uniforms.uTime.value = elapsed;
warpPass.uniforms.uProgress.value = progress;
warpPass.uniforms.uFlash.value = flash;
warpPass.uniforms.uSpin.value = warpState.spin;
}
/**
* Triggers the warp tunnel entrance effect for a named portal.
* @param {string} name - Portal destination name
*/
function triggerWarp(name) {
if (warpState) return;
warpState = { startTime: clock.getElapsedTime(), portalName: name, spin: 0 };
warpPass.enabled = true;
const ind = document.getElementById('warp-indicator');
if (ind) {
ind.textContent = `WARPING TO ${name.toUpperCase()}`;
ind.classList.add('visible');
}
}
// Orbit controls for free camera movement in photo mode
const orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enableDamping = true;
@@ -656,6 +800,119 @@ for (let i = 0; i < RUNE_COUNT; i++) {
runeSprites.push({ sprite, baseAngle, floatPhase: (i / RUNE_COUNT) * Math.PI * 2 });
}
// === PORTALS ===
// Three floating gateway portals around the platform. Each has a glowing torus
// frame and an animated vortex interior. Click an inner disc to trigger warp.
const PORTAL_RADIUS = 1.7;
const PORTAL_DEFS = [
{ name: 'Batcave', angle: -Math.PI * 0.45, dist: 8.5, y: 3.0, color: new THREE.Color(0x4488ff) },
{ name: 'Workshop', angle: Math.PI * 0.45, dist: 8.5, y: 3.0, color: new THREE.Color(0x00ffcc) },
{ name: 'The Void', angle: Math.PI, dist: 8.5, y: 3.0, color: new THREE.Color(0xcc44ff) },
];
// Shared GLSL for portal inner vortex (each portal gets its own material instance)
const PORTAL_VERT = /* glsl */`
varying vec2 vUv;
void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
`;
const PORTAL_FRAG = /* glsl */`
#define PI 3.14159265359
uniform float uTime;
uniform vec3 uColor;
uniform float uHover;
varying vec2 vUv;
void main() {
vec2 p = vUv - 0.5;
float r = length(p) * 2.0;
if (r > 1.0) { gl_FragColor = vec4(0.0); return; }
float theta = atan(p.y, p.x);
float depth = 0.14 / max(r, 0.05);
// 5-arm spiral moving inward
float spiral = fract(theta * 5.0 / (PI * 2.0) + depth * 0.4 - uTime * 0.55);
float sBand = smoothstep(0.3, 0.45, spiral) * (1.0 - smoothstep(0.55, 0.7, spiral));
// Ring bands
float ring = fract(depth - uTime * 0.35);
float rBand = smoothstep(0.0, 0.12, ring) * (1.0 - smoothstep(0.2, 0.35, ring)) * 0.55;
float pattern = max(sBand, rBand);
float edgeFade = 1.0 - smoothstep(0.65, 1.0, r);
float centerGlow = smoothstep(0.35, 0.0, r) * 0.45;
float alpha = (pattern * edgeFade + centerGlow) * (0.78 + uHover * 0.22);
vec3 col = uColor * (pattern + centerGlow * 0.6);
col = mix(col, vec3(1.0), centerGlow * 0.3);
gl_FragColor = vec4(col, alpha);
}
`;
/**
* @type {Array<{mesh: THREE.Mesh, name: string, mat: THREE.ShaderMaterial, glow: THREE.PointLight}>}
*/
const portalObjects = [];
for (const def of PORTAL_DEFS) {
const group = new THREE.Group();
const x = Math.cos(def.angle) * def.dist;
const z = Math.sin(def.angle) * def.dist;
group.position.set(x, def.y, z);
group.lookAt(0, def.y, 0); // face the platform center
// Outer glowing torus frame
const frameMat = new THREE.MeshStandardMaterial({
color: 0x0a1828,
emissive: def.color,
emissiveIntensity: 0.55,
metalness: 0.9,
roughness: 0.15,
});
group.add(new THREE.Mesh(new THREE.TorusGeometry(PORTAL_RADIUS, 0.1, 16, 64), frameMat));
// Inner vortex disc
const innerMat = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0.0 },
uColor: { value: def.color.clone() },
uHover: { value: 0.0 },
},
vertexShader: PORTAL_VERT,
fragmentShader: PORTAL_FRAG,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
});
const innerMesh = new THREE.Mesh(new THREE.CircleGeometry(PORTAL_RADIUS - 0.12, 64), innerMat);
innerMesh.userData.portalName = def.name;
group.add(innerMesh);
// Soft point light behind the portal
const glow = new THREE.PointLight(def.color.getHex(), 0.8, 7);
glow.position.set(0, 0, -0.5);
group.add(glow);
// Destination label above the portal
const labelCanvas = document.createElement('canvas');
labelCanvas.width = 256; labelCanvas.height = 48;
const lctx = labelCanvas.getContext('2d');
lctx.font = 'bold 22px "Courier New", monospace';
lctx.fillStyle = '#' + def.color.getHexString();
lctx.shadowColor = lctx.fillStyle;
lctx.shadowBlur = 14;
lctx.textAlign = 'center';
lctx.textBaseline = 'middle';
lctx.fillText(def.name.toUpperCase(), 128, 24);
const labelMat = new THREE.SpriteMaterial({
map: new THREE.CanvasTexture(labelCanvas),
transparent: true, depthWrite: false,
});
const labelSprite = new THREE.Sprite(labelMat);
labelSprite.scale.set(3.5, 0.65, 1);
labelSprite.position.set(0, PORTAL_RADIUS + 0.6, 0);
group.add(labelSprite);
scene.add(group);
portalObjects.push({ mesh: innerMesh, name: def.name, mat: innerMat, glow });
}
// === ANIMATION LOOP ===
const clock = new THREE.Clock();
@@ -770,11 +1027,49 @@ function animate() {
rune.sprite.material.opacity = 0.65 + Math.sin(elapsed * 1.2 + rune.floatPhase) * 0.2;
}
// Animate portal vortex shaders and glow pulse
for (let i = 0; i < portalObjects.length; i++) {
const p = portalObjects[i];
p.mat.uniforms.uTime.value = elapsed;
p.glow.intensity = 0.65 + Math.sin(elapsed * 1.5 + i * 1.1) * 0.3;
}
// Drive warp tunnel transition
updateWarpEffect(elapsed);
composer.render();
}
animate();
// === PORTAL RAYCASTER ===
// Click on a portal's inner vortex disc to trigger the warp tunnel.
const portalRaycaster = new THREE.Raycaster();
const portalClickNdc = new THREE.Vector2();
renderer.domElement.addEventListener('click', (e) => {
if (photoMode || overviewMode || warpState) return;
portalClickNdc.x = (e.clientX / window.innerWidth) * 2 - 1;
portalClickNdc.y = -(e.clientY / window.innerHeight) * 2 + 1;
portalRaycaster.setFromCamera(portalClickNdc, camera);
const hits = portalRaycaster.intersectObjects(portalObjects.map(p => p.mesh));
if (hits.length > 0) {
triggerWarp(hits[0].object.userData.portalName);
}
});
// 'W' key cycles through portals as a demo trigger
let _warpDemoIdx = 0;
document.addEventListener('keydown', (e) => {
if ((e.key === 'w' || e.key === 'W') && !e.metaKey && !e.ctrlKey && !e.altKey) {
triggerWarp(PORTAL_DEFS[_warpDemoIdx % PORTAL_DEFS.length].name);
_warpDemoIdx++;
}
});
// === DEBUG MODE ===
let debugMode = false;

View File

@@ -47,6 +47,7 @@
</div>
<div id="sovereignty-msg">⚡ SOVEREIGNTY ⚡</div>
<div id="warp-indicator"></div>
<script>
if ('serviceWorker' in navigator) {

View File

@@ -184,6 +184,36 @@ body.photo-mode #overview-indicator {
100% { opacity: 0; transform: translate(-50%, -50%) scale(1); }
}
/* === WARP TUNNEL INDICATOR === */
#warp-indicator {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #00aaff;
font-family: var(--font-body);
font-size: 13px;
letter-spacing: 0.35em;
text-transform: uppercase;
pointer-events: none;
z-index: 50;
padding: 6px 18px;
background: rgba(0, 0, 16, 0.65);
border: 1px solid #00aaff;
white-space: nowrap;
}
#warp-indicator.visible {
display: block;
animation: warp-pulse 0.4s ease-in-out infinite;
}
@keyframes warp-pulse {
0%, 100% { opacity: 0.8; letter-spacing: 0.35em; }
50% { opacity: 1.0; letter-spacing: 0.5em; }
}
/* === CRT / CYBERPUNK OVERLAY === */
.crt-overlay {
position: fixed;