feat: warp tunnel effect when entering portals (#250)
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:
295
app.js
295
app.js
@@ -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;
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
</div>
|
||||
|
||||
<div id="sovereignty-msg">⚡ SOVEREIGNTY ⚡</div>
|
||||
<div id="warp-indicator"></div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
|
||||
30
style.css
30
style.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user