Celestial Transmutation
Four conceptual planets. One fluid transformation system with sweeping shader energy and volumetric particles.
Source code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Celestial Transmutation</title>
</head>
<body>
<style>
:root {
color-scheme: dark;
--accent-a: 84, 226, 255;
--accent-b: 160, 88, 255;
--glass-border: rgba(184, 220, 255, 0.18);
--muted: rgba(220, 235, 255, 0.62);
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #02030a;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
user-select: none;
-webkit-user-select: none;
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background:
radial-gradient(circle at 50% 45%, rgba(var(--accent-a), 0.065), transparent 27%),
radial-gradient(circle at 20% 10%, rgba(var(--accent-b), 0.045), transparent 26%),
linear-gradient(180deg, rgba(9, 13, 32, 0.24), rgba(0, 0, 0, 0.2));
transition: background 900ms ease;
}
#canvas-container {
position: fixed;
inset: 0;
z-index: 1;
}
canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.glass-panel {
border: 1px solid var(--glass-border);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.025)),
rgba(3, 7, 17, 0.48);
box-shadow:
0 18px 60px rgba(0, 0, 0, 0.38),
inset 0 1px 0 rgba(255, 255, 255, 0.13);
backdrop-filter: blur(18px) saturate(145%);
-webkit-backdrop-filter: blur(18px) saturate(145%);
}
#brand {
position: fixed;
top: 18px;
left: 20px;
z-index: 4;
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
}
#brand-mark {
width: 9px;
height: 9px;
border-radius: 50%;
background: rgb(var(--accent-a));
box-shadow:
0 0 10px rgba(var(--accent-a), 0.95),
0 0 30px rgba(var(--accent-b), 0.55);
transition: background 700ms ease, box-shadow 700ms ease;
}
#brand-copy {
display: grid;
gap: 2px;
}
#brand-title {
color: rgba(245, 250, 255, 0.94);
font-size: 11px;
font-weight: 650;
letter-spacing: 0.18em;
text-transform: uppercase;
}
#brand-subtitle {
color: rgba(200, 220, 245, 0.47);
font-size: 9px;
letter-spacing: 0.11em;
text-transform: uppercase;
}
#planet-copy {
position: fixed;
left: clamp(20px, 4vw, 64px);
bottom: clamp(104px, 13vh, 156px);
z-index: 4;
width: min(390px, calc(100vw - 40px));
pointer-events: none;
text-shadow: 0 4px 26px rgba(0, 0, 0, 0.85);
}
#planet-kicker {
margin-bottom: 7px;
color: rgb(var(--accent-a));
font-size: 10px;
font-weight: 650;
letter-spacing: 0.22em;
text-transform: uppercase;
transition: color 700ms ease;
}
#planet-name {
margin: 0;
color: #f4f8ff;
font-size: clamp(34px, 5vw, 66px);
font-weight: 300;
line-height: 0.94;
letter-spacing: -0.045em;
}
#planet-description {
max-width: 360px;
margin: 14px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.65;
letter-spacing: 0.012em;
}
#transition-meter {
position: relative;
width: min(260px, 68vw);
height: 2px;
margin-top: 18px;
overflow: hidden;
border-radius: 99px;
background: rgba(255, 255, 255, 0.1);
opacity: 0;
transform: translateY(4px);
transition: opacity 280ms ease, transform 280ms ease;
}
#transition-meter.is-active {
opacity: 1;
transform: translateY(0);
}
#transition-meter-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0%;
border-radius: inherit;
background: linear-gradient(90deg, rgb(var(--accent-a)), rgb(var(--accent-b)));
box-shadow: 0 0 14px rgba(var(--accent-a), 0.65);
}
#planet-ui {
position: fixed;
left: 50%;
bottom: 18px;
z-index: 5;
display: flex;
align-items: center;
gap: 7px;
width: max-content;
max-width: calc(100vw - 24px);
padding: 7px;
border-radius: 16px;
transform: translateX(-50%);
}
#planet-ui button {
appearance: none;
min-width: 96px;
height: 40px;
padding: 0 13px;
border: 1px solid rgba(200, 225, 255, 0.13);
border-radius: 11px;
outline: none;
color: rgba(226, 238, 255, 0.63);
background: rgba(255, 255, 255, 0.035);
font: 560 11px/1 Inter, ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0.06em;
cursor: pointer;
transition:
color 180ms ease,
border-color 180ms ease,
background 180ms ease,
box-shadow 180ms ease,
transform 180ms ease;
}
#planet-ui button:hover {
color: rgba(255, 255, 255, 0.96);
border-color: rgba(220, 240, 255, 0.26);
background: rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
}
#planet-ui button:focus-visible {
box-shadow: 0 0 0 2px rgba(var(--accent-a), 0.38);
}
#planet-ui button.is-active {
color: #ffffff;
border-color: rgba(var(--accent-a), 0.48);
background:
linear-gradient(135deg, rgba(var(--accent-a), 0.23), rgba(var(--accent-b), 0.12)),
rgba(255, 255, 255, 0.05);
box-shadow:
0 0 26px rgba(var(--accent-a), 0.13),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
#planet-ui button.is-target {
color: rgba(255, 255, 255, 0.96);
border-color: rgba(var(--accent-b), 0.46);
}
#auto-control {
position: fixed;
right: 18px;
top: 17px;
z-index: 5;
display: flex;
align-items: center;
gap: 9px;
padding: 8px 10px;
border-radius: 13px;
color: rgba(219, 233, 250, 0.58);
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
}
#auto-control input {
position: absolute;
opacity: 0;
pointer-events: none;
}
#auto-indicator {
position: relative;
width: 28px;
height: 15px;
border: 1px solid rgba(200, 225, 255, 0.2);
border-radius: 99px;
background: rgba(255, 255, 255, 0.06);
transition: border-color 180ms ease, background 180ms ease;
}
#auto-indicator::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 9px;
height: 9px;
border-radius: 50%;
background: rgba(225, 238, 255, 0.52);
transition: transform 180ms ease, background 180ms ease, box-shadow 180ms ease;
}
#auto-control input:checked + #auto-indicator {
border-color: rgba(var(--accent-a), 0.42);
background: rgba(var(--accent-a), 0.12);
}
#auto-control input:checked + #auto-indicator::after {
transform: translateX(13px);
background: rgb(var(--accent-a));
box-shadow: 0 0 10px rgba(var(--accent-a), 0.72);
}
@media (max-width: 760px) {
#planet-copy {
left: 18px;
bottom: 126px;
}
#planet-name {
font-size: clamp(38px, 12vw, 58px);
}
#planet-description {
max-width: min(330px, calc(100vw - 36px));
font-size: 12px;
}
#planet-ui {
bottom: 12px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: calc(100vw - 24px);
}
#planet-ui button {
min-width: 0;
height: 38px;
}
#auto-control {
top: 13px;
right: 12px;
}
}
@media (max-height: 620px) and (orientation: landscape) {
#planet-copy {
bottom: 82px;
}
#planet-description {
display: none;
}
#planet-ui {
left: auto;
right: 12px;
bottom: 12px;
transform: none;
}
}
</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/"
}
}
</script>
<div id="canvas-container" aria-hidden="true"></div>
<div id="brand">
<span id="brand-mark"></span>
<div id="brand-copy">
<span id="brand-title">Celestial Transmutation</span>
<span id="brand-subtitle">Realtime phase-surge synthesis</span>
</div>
</div>
<label id="auto-control" class="glass-panel" title="Automatically visit the next planet">
<span>Auto</span>
<input id="auto-cycle" type="checkbox" checked>
<span id="auto-indicator" aria-hidden="true"></span>
</label>
<section id="planet-copy" aria-live="polite">
<div id="planet-kicker">Oceanic dreamworld</div>
<h1 id="planet-name">Aethera</h1>
<p id="planet-description">
A luminous water planet whose equatorial currents fold into violet auroras and suspended atmospheric rivers.
</p>
<div id="transition-meter" aria-hidden="true">
<div id="transition-meter-fill"></div>
</div>
</section>
<nav id="planet-ui" class="glass-panel" aria-label="Conceptual planets">
<button type="button" class="is-active" data-planet="0" aria-pressed="true">Aethera</button>
<button type="button" data-planet="1" aria-pressed="false">Pyra</button>
<button type="button" data-planet="2" aria-pressed="false">Orison</button>
<button type="button" data-planet="3" aria-pressed="false">Vesper</button>
</nav>
<script type="module">
import * as THREE from 'three';
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 { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
const PLANETS = [
{
name: 'Aethera',
kicker: 'Oceanic dreamworld',
description: 'A luminous water planet whose equatorial currents fold into violet auroras and suspended atmospheric rivers.',
accentA: '84, 226, 255',
accentB: '160, 88, 255'
},
{
name: 'Pyra',
kicker: 'Living furnace world',
description: 'A carbon-black planet split by molten tectonic calligraphy, with incandescent matter breathing through every fracture.',
accentA: '255, 132, 44',
accentB: '255, 45, 86'
},
{
name: 'Orison',
kicker: 'Sacred ring architecture',
description: 'A pearl-and-teal giant encircled by a vast luminous archive: billions of particles arranged like celestial sheet music.',
accentA: '255, 220, 142',
accentB: '74, 232, 210'
},
{
name: 'Vesper',
kicker: 'Crystalline night engine',
description: 'A faceted violet world that stores starlight inside geometric continents and releases it through cyan polar seams.',
accentA: '177, 116, 255',
accentB: '76, 230, 255'
}
];
const TAU = Math.PI * 2;
const isCompact = window.matchMedia('(max-width: 760px)').matches;
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const container = document.getElementById('canvas-container');
const planetButtons = Array.from(document.querySelectorAll('[data-planet]'));
const planetName = document.getElementById('planet-name');
const planetKicker = document.getElementById('planet-kicker');
const planetDescription = document.getElementById('planet-description');
const transitionMeter = document.getElementById('transition-meter');
const transitionMeterFill = document.getElementById('transition-meter-fill');
const autoCycleInput = document.getElementById('auto-cycle');
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
powerPreference: 'high-performance'
});
function responsivePixelRatio() {
const cap = isCompact ? 1.45 : 1.85;
return Math.min(window.devicePixelRatio || 1, cap);
}
function responsiveCameraZ() {
const aspect = window.innerWidth / window.innerHeight;
if (aspect < 0.66) return 10.6;
if (aspect < 0.95) return 9.55;
return 8.55;
}
renderer.setPixelRatio(responsivePixelRatio());
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x02030a, 1);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.08;
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x02030a, 0.015);
const camera = new THREE.PerspectiveCamera(
39,
window.innerWidth / window.innerHeight,
0.08,
90
);
camera.position.set(0, 0.25, responsiveCameraZ());
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.045;
controls.enablePan = false;
controls.enableZoom = true;
controls.zoomSpeed = 0.72;
controls.minDistance = 6.25;
controls.maxDistance = 12.75;
controls.minPolarAngle = Math.PI * 0.16;
controls.maxPolarAngle = Math.PI * 0.84;
controls.autoRotate = !prefersReducedMotion;
controls.autoRotateSpeed = 0.24;
controls.target.set(0, 0.02, 0);
controls.update();
const guardedCameraOffset = new THREE.Vector3();
const activeTouchPointers = new Set();
let intendedOrbitRadius = THREE.MathUtils.clamp(
camera.position.distanceTo(controls.target),
controls.minDistance,
controls.maxDistance
);
let manualZoomCaptureFrames = 0;
let middleButtonDollyActive = false;
function beginManualZoomCapture(frameCount = 12) {
manualZoomCaptureFrames = Math.max(manualZoomCaptureFrames, frameCount);
}
renderer.domElement.addEventListener('wheel', () => {
beginManualZoomCapture(14);
}, { passive: true, capture: true });
renderer.domElement.addEventListener('pointerdown', (event) => {
if (event.pointerType === 'touch') {
activeTouchPointers.add(event.pointerId);
if (activeTouchPointers.size >= 2) beginManualZoomCapture(18);
}
if (event.button === 1) {
middleButtonDollyActive = true;
beginManualZoomCapture(18);
}
}, { passive: true, capture: true });
renderer.domElement.addEventListener('pointermove', (event) => {
if (event.pointerType === 'touch' && activeTouchPointers.size >= 2) {
beginManualZoomCapture(18);
}
if (middleButtonDollyActive) beginManualZoomCapture(18);
}, { passive: true, capture: true });
function endZoomPointer(event) {
if (event.pointerType === 'touch') {
activeTouchPointers.delete(event.pointerId);
if (activeTouchPointers.size > 0) beginManualZoomCapture(8);
}
if (event.button === 1) {
middleButtonDollyActive = false;
beginManualZoomCapture(8);
}
}
renderer.domElement.addEventListener('pointerup', endZoomPointer, {
passive: true,
capture: true
});
renderer.domElement.addEventListener('pointercancel', endZoomPointer, {
passive: true,
capture: true
});
renderer.domElement.addEventListener('pointerleave', (event) => {
if (event.pointerType === 'touch') activeTouchPointers.delete(event.pointerId);
if (event.buttons === 0) middleButtonDollyActive = false;
}, { passive: true, capture: true });
renderer.domElement.addEventListener('gesturestart', () => {
beginManualZoomCapture(18);
}, { passive: true });
renderer.domElement.addEventListener('gesturechange', () => {
beginManualZoomCapture(18);
}, { passive: true });
function guardOrbitRadius() {
guardedCameraOffset.copy(camera.position).sub(controls.target);
let currentRadius = guardedCameraOffset.length();
if (!Number.isFinite(currentRadius) || currentRadius < 1.0e-6) {
guardedCameraOffset.set(0, 0.23, 1).normalize();
currentRadius = intendedOrbitRadius;
}
if (manualZoomCaptureFrames > 0) {
intendedOrbitRadius = THREE.MathUtils.clamp(
currentRadius,
controls.minDistance,
controls.maxDistance
);
manualZoomCaptureFrames -= 1;
return;
}
if (Math.abs(currentRadius - intendedOrbitRadius) > 1.0e-5) {
guardedCameraOffset.multiplyScalar(1 / currentRadius);
camera.position.copy(controls.target).addScaledVector(
guardedCameraOffset,
intendedOrbitRadius
);
camera.lookAt(controls.target);
}
}
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.50,
0.30,
0.50
);
composer.addPass(bloomPass);
composer.addPass(new OutputPass());
const noiseGLSL = `
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float saturate(float value) {
return clamp(value, 0.0, 1.0);
}
vec2 safeNormalize(vec2 value) {
return value * inversesqrt(max(dot(value, value), 1.0e-12));
}
vec3 safeNormalize(vec3 value) {
return value * inversesqrt(max(dot(value, value), 1.0e-12));
}
float safeAtan(float y, float x) {
bool nearOrigin = abs(x) + abs(y) < 1.0e-7;
return nearOrigin ? 0.0 : atan(y, x);
}
float sanitizeFloat(float value, float fallbackValue) {
bool valid = value == value && abs(value) < 1.0e5;
return valid ? value : fallbackValue;
}
vec3 sanitizeVec3(vec3 value, vec3 fallbackValue) {
bool invalid = any(notEqual(value, value))
|| any(greaterThan(abs(value), vec3(1.0e5)));
return invalid ? fallbackValue : value;
}
vec3 limitLuminance(vec3 color, float maximumLuminance) {
vec3 safeColor = max(sanitizeVec3(color, vec3(0.0)), vec3(0.0));
float luminance = dot(safeColor, vec3(0.2126, 0.7152, 0.0722));
float scale = min(1.0, maximumLuminance / max(luminance, 1.0e-5));
return safeColor * scale;
}
float snoise(vec3 v) {
const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
i = mod289(i);
vec4 p = permute(
permute(
permute(i.z + vec4(0.0, i1.z, i2.z, 1.0))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0)
)
+ i.x + vec4(0.0, i1.x, i2.x, 1.0)
);
float n_ = 0.142857142857;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ * ns.x + ns.yyyy;
vec4 y = y_ * ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0) * 2.0 + 1.0;
vec4 s1 = floor(b1) * 2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(
dot(p0, p0),
dot(p1, p1),
dot(p2, p2),
dot(p3, p3)
));
p0 *= norm.x;
p1 *= norm.y;
p2 *= norm.z;
p3 *= norm.w;
vec4 m = max(0.6 - vec4(
dot(x0, x0),
dot(x1, x1),
dot(x2, x2),
dot(x3, x3)
), 0.0);
m *= m;
return 42.0 * dot(
m * m,
vec4(
dot(p0, x0),
dot(p1, x1),
dot(p2, x2),
dot(p3, x3)
)
);
}
mat2 rotate2D(float angle) {
float s = sin(angle);
float c = cos(angle);
return mat2(c, -s, s, c);
}
mat3 rotateX3(float angle) {
float s = sin(angle);
float c = cos(angle);
return mat3(
1.0, 0.0, 0.0,
0.0, c, -s,
0.0, s, c
);
}
mat3 rotateZ3(float angle) {
float s = sin(angle);
float c = cos(angle);
return mat3(
c, -s, 0.0,
s, c, 0.0,
0.0, 0.0, 1.0
);
}
`;
const planetFunctionsGLSL = `
const float PI = 3.141592653589793;
const float TWO_PI = 6.283185307179586;
float isPreset(float preset, float expected) {
return 1.0 - step(0.5, abs(preset - expected));
}
vec3 aetheraPosition(vec3 seed, vec4 randomData, float time) {
vec3 direction = safeNormalize(seed);
float latitude = asin(clamp(direction.y, -1.0, 1.0));
float continent = snoise(direction * 2.45 + vec3(0.0, time * 0.018, 0.0));
float currentNoise = snoise(
direction * 4.2
+ vec3(time * 0.045, -time * 0.018, time * 0.032)
);
float current = sin(
latitude * 10.5
+ dot(direction, safeNormalize(vec3(0.74, 0.11, 0.66))) * 5.4
+ currentNoise * 1.35
- time * 0.18
);
float radius = 2.08 + continent * 0.072 + current * 0.026;
vec3 p = direction * radius;
p.y *= 0.975;
p.xz = rotate2D(sin(latitude * 3.0 + time * 0.08) * 0.018) * p.xz;
return p;
}
vec3 pyraPosition(vec3 seed, vec4 randomData, float time) {
vec3 direction = safeNormalize(seed);
float terrain = snoise(direction * 3.4 + vec3(time * 0.012, 0.0, 0.0));
float secondary = snoise(direction * 8.0 - vec3(0.0, time * 0.024, 0.0));
float moltenLift = pow(max(secondary * 0.5 + 0.5, 0.0), 8.0);
float radius = 2.08 + terrain * 0.125 + moltenLift * 0.090;
return direction * radius;
}
vec3 orisonBodyPosition(vec3 seed, vec4 randomData, float time) {
vec3 direction = safeNormalize(seed);
float latitude = asin(clamp(direction.y, -1.0, 1.0));
float bandNoise = snoise(
direction * vec3(2.2, 5.8, 2.2)
+ vec3(time * 0.018, -time * 0.012, time * 0.014)
);
float band = sin(latitude * 18.0 + bandNoise * 1.8 + time * 0.06);
float storm = snoise(
direction * 4.15
+ vec3(-time * 0.021, time * 0.013, time * 0.026)
);
float radius = 2.07 + band * 0.020 + storm * 0.038;
vec3 p = direction * radius;
p.y *= 0.945;
return p;
}
vec3 orisonRingPosition(vec3 seed, vec4 randomData, float time) {
float radius = mix(2.18, 3.62, pow(randomData.x, 0.74));
float angle = randomData.y * TWO_PI + time * (0.018 + randomData.w * 0.012);
float lane = floor(randomData.z * 11.0) / 11.0;
float gap = 0.92 + 0.08 * sin(lane * 71.0 + randomData.w * 16.0);
radius *= gap;
float vertical = (randomData.z - 0.5) * 0.115;
vertical += snoise(vec3(cos(angle) * 2.1, sin(angle) * 2.1, radius * 1.7)) * 0.035;
vec3 p = vec3(cos(angle) * radius, vertical, sin(angle) * radius);
p = rotateZ3(0.34) * rotateX3(0.20) * p;
return p;
}
vec3 vesperPosition(vec3 seed, vec4 randomData, float time) {
vec3 direction = safeNormalize(seed);
vec3 stepped = floor(direction * 7.0 + 0.5) / 7.0;
vec3 facetedDirection = safeNormalize(mix(direction, stepped, 0.68));
float cell = snoise(facetedDirection * 4.8);
vec3 axisA = safeNormalize(vec3(0.82, 0.31, 0.48));
vec3 axisB = safeNormalize(vec3(-0.24, 0.91, 0.34));
vec3 axisC = safeNormalize(vec3(0.41, -0.17, 0.90));
float lattice =
sin(dot(facetedDirection, axisA) * 18.0)
* sin(dot(facetedDirection, axisB) * 16.0)
* sin(dot(facetedDirection, axisC) * 14.0);
float seam = pow(abs(lattice), 5.5);
float radius = 2.06 + cell * 0.145 + seam * 0.070;
vec3 p = facetedDirection * radius;
p.y *= 1.018;
return p;
}
vec3 planetPosition(
float preset,
vec3 seed,
vec4 randomData,
float kind,
float time
) {
if (preset < 0.5) return aetheraPosition(seed, randomData, time);
if (preset < 1.5) return pyraPosition(seed, randomData, time);
if (preset < 2.5) {
return kind > 0.5
? orisonRingPosition(seed, randomData, time)
: orisonBodyPosition(seed, randomData, time);
}
return vesperPosition(seed, randomData, time);
}
vec3 aetheraColor(vec3 p, vec3 seed, vec4 randomData, float time) {
vec3 direction = safeNormalize(seed);
float latitude = asin(clamp(direction.y, -1.0, 1.0));
float current = snoise(
direction * 3.65
+ vec3(-time * 0.055, time * 0.025, time * 0.04)
);
float ribbonNoise = snoise(
direction * 6.8
+ vec3(time * 0.025, -time * 0.06, time * 0.015)
);
float auroraWave = sin(
latitude * 8.2
+ dot(direction, safeNormalize(vec3(0.71, -0.08, 0.70))) * 7.1
+ ribbonNoise * 1.8
- time * 0.35
);
float aurora = pow(max(0.0, auroraWave), 5.0);
float polar = smoothstep(0.48, 0.93, abs(direction.y));
vec3 abyss = vec3(0.004, 0.028, 0.13);
vec3 ocean = vec3(0.012, 0.34, 0.78);
vec3 cyan = vec3(0.05, 0.95, 1.25);
vec3 violet = vec3(0.58, 0.08, 1.05);
vec3 color = mix(abyss, ocean, current * 0.5 + 0.5);
color = mix(color, cyan, smoothstep(0.22, 0.80, current) * 0.55);
color += mix(cyan, violet, polar) * aurora * (0.28 + polar * 0.62);
color += vec3(0.08, 0.28, 0.52) * pow(max(0.0, snoise(direction * 9.0)), 5.0);
return limitLuminance(color, 1.38);
}
vec3 pyraColor(vec3 p, vec3 seed, vec4 randomData, float time) {
vec3 direction = safeNormalize(seed);
float crust = snoise(direction * 3.2 + vec3(time * 0.018, 0.0, 0.0));
float detail = abs(snoise(direction * 9.5 - vec3(0.0, time * 0.05, 0.0)));
float crack = 1.0 - smoothstep(0.035, 0.18, detail);
crack *= smoothstep(-0.32, 0.5, crust);
float ember = pow(max(0.0, snoise(direction * 17.0 + time * 0.08)), 7.0);
vec3 charcoal = mix(vec3(0.012, 0.006, 0.012), vec3(0.18, 0.028, 0.018), crust * 0.5 + 0.5);
vec3 molten = mix(vec3(1.45, 0.035, 0.002), vec3(1.55, 0.72, 0.055), crack);
vec3 color = charcoal;
color += molten * crack * 1.58;
color += vec3(1.35, 0.23, 0.025) * ember * 0.72;
return limitLuminance(color, 1.70);
}
vec3 orisonColor(vec3 p, vec3 seed, vec4 randomData, float kind, float time) {
if (kind > 0.5) {
float ringRadius = length(p.xz);
float lane = sin(ringRadius * 27.0 + randomData.w * 7.0);
float dust = snoise(vec3(
cos(randomData.y * TWO_PI) * 2.2,
sin(randomData.y * TWO_PI) * 2.2,
ringRadius * 3.7
));
vec3 antiqueGold = vec3(0.95, 0.52, 0.12);
vec3 mineralTeal = vec3(0.05, 0.76, 0.66);
vec3 duskViolet = vec3(0.33, 0.08, 0.55);
vec3 color = mix(antiqueGold, mineralTeal, lane * 0.5 + 0.5);
color = mix(color, duskViolet, smoothstep(0.50, 0.88, dust) * 0.42);
return limitLuminance(color, 1.12);
}
vec3 direction = safeNormalize(seed);
float latitude = asin(clamp(direction.y, -1.0, 1.0));
float latitudeMask = abs(latitude) / (PI * 0.5);
float broadFlow = snoise(
direction * 2.45
+ vec3(time * 0.014, -time * 0.01, time * 0.018)
);
float fineFlow = snoise(
direction * 7.2
+ vec3(-time * 0.032, time * 0.012, time * 0.024)
);
float band = sin(latitude * 18.0 + broadFlow * 2.4 + fineFlow * 0.55 + time * 0.045);
float storm = snoise(
direction * 4.3
+ vec3(time * 0.025, time * 0.012, -time * 0.018)
);
float stormCell = smoothstep(0.34, 0.88, storm)
* (1.0 - smoothstep(0.72, 1.0, latitudeMask));
vec3 midnight = vec3(0.012, 0.028, 0.085);
vec3 bronze = vec3(0.58, 0.25, 0.055);
vec3 pearl = vec3(0.82, 0.76, 0.50);
vec3 deepTeal = vec3(0.012, 0.38, 0.46);
vec3 jade = vec3(0.035, 0.86, 0.66);
float brightBand = smoothstep(-0.30, 0.88, band);
vec3 color = mix(midnight, bronze, broadFlow * 0.5 + 0.5);
color = mix(color, pearl, brightBand * 0.48);
color = mix(color, deepTeal, smoothstep(0.06, 0.74, fineFlow) * 0.62);
color += jade * stormCell * 0.62;
color *= mix(1.0, 0.78, smoothstep(0.65, 1.0, latitudeMask));
return limitLuminance(color, 1.32);
}
vec3 vesperColor(vec3 p, vec3 seed, vec4 randomData, float time) {
vec3 direction = safeNormalize(seed);
float facet = floor((snoise(direction * 4.7) * 0.5 + 0.5) * 7.0) / 7.0;
float polar = smoothstep(0.48, 0.96, abs(direction.y));
float seamNoise = abs(snoise(direction * 10.0 + vec3(0.0, time * 0.025, 0.0)));
float seam = 1.0 - smoothstep(0.025, 0.14, seamNoise);
vec3 night = vec3(0.018, 0.006, 0.12);
vec3 violet = vec3(0.45, 0.045, 0.92);
vec3 amethyst = vec3(0.98, 0.18, 1.38);
vec3 cyan = vec3(0.03, 1.08, 1.48);
vec3 color = mix(night, violet, facet);
color = mix(color, amethyst, smoothstep(0.60, 0.96, facet) * 0.72);
color += cyan * seam * (0.38 + polar * 1.15);
color += cyan * pow(polar, 5.0) * 0.50;
return limitLuminance(color, 1.56);
}
vec3 planetColor(
float preset,
vec3 p,
vec3 seed,
vec4 randomData,
float kind,
float time
) {
if (preset < 0.5) return aetheraColor(p, seed, randomData, time);
if (preset < 1.5) return pyraColor(p, seed, randomData, time);
if (preset < 2.5) return orisonColor(p, seed, randomData, kind, time);
return vesperColor(p, seed, randomData, time);
}
`;
const nebulaGeometry = new THREE.SphereGeometry(45, 48, 32);
const nebulaMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 }
},
vertexShader: `
varying vec3 vDirection;
void main() {
vDirection = normalize(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
${noiseGLSL}
uniform float uTime;
varying vec3 vDirection;
void main() {
vec3 direction = safeNormalize(vDirection);
float broad = snoise(direction * 1.7 + vec3(0.0, uTime * 0.006, 0.0));
float filaments = snoise(direction * 4.0 - vec3(uTime * 0.004, 0.0, 0.0));
float cloud = smoothstep(0.12, 0.88, broad * 0.7 + filaments * 0.3);
float horizon = pow(max(1.0 - abs(direction.y), 0.0), 4.0);
vec3 midnight = vec3(0.0015, 0.002, 0.009);
vec3 blue = vec3(0.018, 0.038, 0.10);
vec3 violet = vec3(0.055, 0.018, 0.10);
vec3 color = midnight;
color += mix(blue, violet, filaments * 0.5 + 0.5) * cloud * horizon * 0.62;
gl_FragColor = vec4(color, 1.0);
}
`,
side: THREE.BackSide,
depthWrite: false,
fog: false
});
const nebula = new THREE.Mesh(nebulaGeometry, nebulaMaterial);
scene.add(nebula);
const starCount = isCompact ? 4800 : 8200;
const starGeometry = new THREE.BufferGeometry();
const starPositions = new Float32Array(starCount * 3);
const starSizes = new Float32Array(starCount);
const starSeeds = new Float32Array(starCount);
for (let i = 0; i < starCount; i += 1) {
const radius = THREE.MathUtils.lerp(12, 43, Math.pow(Math.random(), 0.62));
const y = THREE.MathUtils.randFloatSpread(2);
const theta = Math.random() * TAU;
const radial = Math.sqrt(Math.max(0, 1 - y * y));
starPositions[i * 3] = Math.cos(theta) * radial * radius;
starPositions[i * 3 + 1] = y * radius;
starPositions[i * 3 + 2] = Math.sin(theta) * radial * radius;
starSizes[i] = 0.65 + Math.pow(Math.random(), 5) * 4.4;
starSeeds[i] = Math.random();
}
starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
starGeometry.setAttribute('aSize', new THREE.BufferAttribute(starSizes, 1));
starGeometry.setAttribute('aSeed', new THREE.BufferAttribute(starSeeds, 1));
const starMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uPixelRatio: { value: renderer.getPixelRatio() },
uTransitionEnergy: { value: 0 }
},
vertexShader: `
uniform float uTime;
uniform float uPixelRatio;
uniform float uTransitionEnergy;
attribute float aSize;
attribute float aSeed;
varying float vAlpha;
varying vec3 vColor;
void main() {
vec3 p = position;
float drift = uTime * (0.003 + aSeed * 0.004);
float s = sin(drift);
float c = cos(drift);
p.xz = mat2(c, -s, s, c) * p.xz;
vec4 mvPosition = modelViewMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * mvPosition;
gl_PointSize = aSize * uPixelRatio * (18.0 / max(1.0, -mvPosition.z));
gl_PointSize *= 1.0 + uTransitionEnergy * (0.08 + aSeed * 0.10);
float twinkle = 0.55 + 0.45 * sin(uTime * (0.8 + aSeed * 2.0) + aSeed * 31.0);
vAlpha = (0.18 + aSeed * 0.58) * twinkle * (1.0 - uTransitionEnergy * 0.42);
vColor = mix(vec3(0.48, 0.65, 1.0), vec3(1.0, 0.82, 0.64), aSeed * aSeed);
}
`,
fragmentShader: `
varying float vAlpha;
varying vec3 vColor;
void main() {
vec2 uv = gl_PointCoord - 0.5;
float distanceToCenter = length(uv);
if (distanceToCenter > 0.5) discard;
float core = exp(-distanceToCenter * 12.0);
float glow = exp(-distanceToCenter * 4.5) * 0.3;
gl_FragColor = vec4(vColor * (core + glow), vAlpha * (core + glow));
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
fog: false
});
const stars = new THREE.Points(starGeometry, starMaterial);
scene.add(stars);
const planetGroup = new THREE.Group();
planetGroup.rotation.z = -0.055;
scene.add(planetGroup);
const surfaceGeometry = new THREE.IcosahedronGeometry(1, isCompact ? 5 : 6);
const sharedPlanetUniforms = {
uTime: { value: 0 },
uFromPreset: { value: 0 },
uToPreset: { value: 0 },
uTransition: { value: 0 },
uTransitionEnergy: { value: 0 }
};
const surfaceMaterial = new THREE.ShaderMaterial({
uniforms: sharedPlanetUniforms,
vertexShader: `
${noiseGLSL}
${planetFunctionsGLSL}
uniform float uTime;
uniform float uFromPreset;
uniform float uToPreset;
uniform float uTransition;
uniform float uTransitionEnergy;
varying vec3 vWorldPosition;
varying vec3 vObjectPosition;
varying vec3 vSeed;
varying vec3 vNormalApprox;
varying float vLocalMorph;
varying float vScanFront;
varying float vSweepCoordinate;
float phaseCoordinate(vec3 seed, float time) {
vec3 axisA = safeNormalize(vec3(0.82, 0.25, 0.52));
vec3 axisB = safeNormalize(vec3(-0.18, 0.93, 0.32));
float coordinate = dot(seed, axisA);
coordinate += sin(dot(seed, axisB) * 6.2 - time * 0.72) * 0.055;
coordinate += snoise(seed * 3.4 + vec3(0.0, time * 0.10, 0.0)) * 0.035;
return coordinate;
}
void main() {
vec3 seed = safeNormalize(position);
vec4 randomData = vec4(
fract(sin(dot(seed.xy, vec2(12.9898, 78.233))) * 43758.5453),
fract(sin(dot(seed.yz, vec2(39.3468, 11.135))) * 24634.6345),
fract(sin(dot(seed.zx, vec2(73.156, 52.235))) * 56445.234),
fract(sin(dot(seed.xyz, vec3(19.19, 7.17, 41.73))) * 9531.317)
);
vec3 fromPosition = sanitizeVec3(
planetPosition(uFromPreset, seed, randomData, 0.0, uTime),
seed * 2.08
);
vec3 toPosition = sanitizeVec3(
planetPosition(uToPreset, seed, randomData, 0.0, uTime),
seed * 2.08
);
float coordinate = phaseCoordinate(seed, uTime);
float scanPosition = mix(-1.24, 1.24, uTransition);
float scanWidth = 0.115;
float localMorph = 1.0 - smoothstep(
scanPosition - scanWidth,
scanPosition + scanWidth,
coordinate
);
float front = exp(-abs(coordinate - scanPosition) * 19.0);
float coreFront = exp(-abs(coordinate - scanPosition) * 42.0);
vec3 objectPosition = mix(fromPosition, toPosition, localMorph);
vec3 normalDirection = safeNormalize(objectPosition);
vec3 phaseAxis = safeNormalize(vec3(0.82, 0.25, 0.52));
vec3 tangent = safeNormalize(cross(normalDirection, phaseAxis + vec3(0.001, 0.002, 0.003)));
float ripple = sin(
dot(seed, safeNormalize(vec3(-0.31, 0.79, 0.53))) * 22.0
- uTime * 5.2
);
objectPosition += normalDirection * front * (0.052 + ripple * 0.022);
objectPosition += tangent * coreFront * ripple * 0.028;
objectPosition = sanitizeVec3(objectPosition, seed * 2.08);
vec4 worldPosition = modelMatrix * vec4(objectPosition, 1.0);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
vWorldPosition = worldPosition.xyz;
vObjectPosition = objectPosition;
vSeed = seed;
vNormalApprox = safeNormalize(mat3(modelMatrix) * objectPosition);
vLocalMorph = localMorph;
vScanFront = front;
vSweepCoordinate = coordinate;
}
`,
fragmentShader: `
${noiseGLSL}
${planetFunctionsGLSL}
uniform float uTime;
uniform float uFromPreset;
uniform float uToPreset;
uniform float uTransition;
uniform float uTransitionEnergy;
varying vec3 vWorldPosition;
varying vec3 vObjectPosition;
varying vec3 vSeed;
varying vec3 vNormalApprox;
varying float vLocalMorph;
varying float vScanFront;
varying float vSweepCoordinate;
void main() {
vec4 randomData = vec4(
fract(sin(dot(vSeed.xy, vec2(12.9898, 78.233))) * 43758.5453),
fract(sin(dot(vSeed.yz, vec2(39.3468, 11.135))) * 24634.6345),
fract(sin(dot(vSeed.zx, vec2(73.156, 52.235))) * 56445.234),
fract(sin(dot(vSeed.xyz, vec3(19.19, 7.17, 41.73))) * 9531.317)
);
vec3 fromColor = planetColor(
uFromPreset, vObjectPosition, vSeed, randomData, 0.0, uTime
);
vec3 toColor = planetColor(
uToPreset, vObjectPosition, vSeed, randomData, 0.0, uTime
);
vec3 baseColor = mix(fromColor, toColor, vLocalMorph);
vec3 normal = safeNormalize(vNormalApprox);
vec3 viewDirection = safeNormalize(cameraPosition - vWorldPosition);
vec3 keyDirection = safeNormalize(vec3(-0.70, 0.55, 0.85));
vec3 rimDirection = safeNormalize(vec3(0.65, -0.25, -0.70));
float ndl = dot(normal, keyDirection);
float diffuse = saturate(ndl);
float backLight = saturate(dot(normal, rimDirection));
float facing = saturate(dot(normal, viewDirection));
float fresnel = pow(max(1.0 - facing, 0.0), 3.0);
float terminator = smoothstep(-0.34, 0.56, ndl);
vec3 color = baseColor * (0.24 + diffuse * 1.04);
color *= mix(0.46, 1.0, terminator);
color += baseColor * backLight * 0.17;
color += mix(vec3(0.10, 0.48, 1.05), baseColor, 0.46) * fresnel * 0.66;
float micro = snoise(vSeed * 24.0 + vec3(0.0, uTime * 0.02, 0.0));
color += baseColor * micro * 0.060;
float thinCore = pow(clamp(vScanFront, 0.0, 1.0), 3.4);
float paletteMix = smoothstep(-0.78, 0.78, vSweepCoordinate);
vec3 phaseCyan = vec3(0.025, 0.92, 1.32);
vec3 phaseViolet = vec3(1.02, 0.055, 1.18);
vec3 phaseColor = mix(phaseCyan, phaseViolet, paletteMix);
color = mix(color, phaseColor, vScanFront * 0.38);
color += phaseColor * thinCore * 0.32;
float separation = exp(-abs(vScanFront - 0.72) * 22.0) * 0.10;
color *= 1.0 - separation;
color = limitLuminance(color, 1.76);
color = clamp(color, vec3(0.0), vec3(2.0));
gl_FragColor = vec4(color, 1.0);
}
`,
transparent: false,
depthWrite: true,
depthTest: true,
side: THREE.FrontSide
});
const surfaceMesh = new THREE.Mesh(surfaceGeometry, surfaceMaterial);
surfaceMesh.renderOrder = 2;
planetGroup.add(surfaceMesh);
const particleCount = isCompact ? 46000 : 78000;
const particleGeometry = new THREE.BufferGeometry();
const particleSeeds = new Float32Array(particleCount * 3);
const particleRandom = new Float32Array(particleCount * 4);
const particleKind = new Float32Array(particleCount);
const particleLayer = new Float32Array(particleCount);
for (let i = 0; i < particleCount; i += 1) {
const y = THREE.MathUtils.randFloatSpread(2);
const angle = Math.random() * TAU;
const radial = Math.sqrt(Math.max(0, 1 - y * y));
particleSeeds[i * 3] = Math.cos(angle) * radial;
particleSeeds[i * 3 + 1] = y;
particleSeeds[i * 3 + 2] = Math.sin(angle) * radial;
particleRandom[i * 4] = Math.random();
particleRandom[i * 4 + 1] = Math.random();
particleRandom[i * 4 + 2] = Math.random();
particleRandom[i * 4 + 3] = Math.random();
particleLayer[i] = 0.075 + Math.pow(Math.random(), 1 / 3) * 0.925;
particleKind[i] = Math.random() < 0.24 ? 1 : 0;
}
particleGeometry.setAttribute('position', new THREE.BufferAttribute(particleSeeds, 3));
particleGeometry.setAttribute('aRandom', new THREE.BufferAttribute(particleRandom, 4));
particleGeometry.setAttribute('aKind', new THREE.BufferAttribute(particleKind, 1));
particleGeometry.setAttribute('aLayer', new THREE.BufferAttribute(particleLayer, 1));
particleGeometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 8.0);
const particleUniforms = {
...sharedPlanetUniforms,
uPixelRatio: { value: renderer.getPixelRatio() },
uPointScale: { value: isCompact ? 0.92 : 1.0 }
};
const volumetricVertexShader = `
${noiseGLSL}
${planetFunctionsGLSL}
uniform float uTime;
uniform float uFromPreset;
uniform float uToPreset;
uniform float uTransition;
uniform float uTransitionEnergy;
uniform float uPixelRatio;
uniform float uPointScale;
attribute vec4 aRandom;
attribute float aKind;
attribute float aLayer;
varying vec3 vColor;
varying float vAlpha;
varying float vCore;
varying float vEnergy;
varying float vRibbon;
float phaseCoordinate(vec3 seed, float time) {
vec3 axisA = safeNormalize(vec3(0.82, 0.25, 0.52));
vec3 axisB = safeNormalize(vec3(-0.18, 0.93, 0.32));
float coordinate = dot(seed, axisA);
coordinate += sin(dot(seed, axisB) * 6.2 - time * 0.72) * 0.055;
coordinate += snoise(seed * 3.4 + vec3(0.0, time * 0.10, 0.0)) * 0.035;
return coordinate;
}
void main() {
vec3 seed = safeNormalize(position);
vec3 fromSurface = sanitizeVec3(
planetPosition(uFromPreset, seed, aRandom, aKind, uTime),
seed * 2.08
);
vec3 toSurface = sanitizeVec3(
planetPosition(uToPreset, seed, aRandom, aKind, uTime),
seed * 2.08
);
float fromRing = isPreset(uFromPreset, 2.0) * step(0.5, aKind);
float toRing = isPreset(uToPreset, 2.0) * step(0.5, aKind);
vec3 fromMatter = mix(fromSurface * mix(0.54, 1.0, aLayer), fromSurface, fromRing);
vec3 toMatter = mix(toSurface * mix(0.54, 1.0, aLayer), toSurface, toRing);
float delayedTransition = clamp(uTransition + (aRandom.w - 0.5) * 0.10, 0.0, 1.0);
float coordinate = phaseCoordinate(seed, uTime);
float scanPosition = mix(-1.24, 1.24, delayedTransition);
float localMorph = 1.0 - smoothstep(
scanPosition - 0.13,
scanPosition + 0.13,
coordinate
);
float front = exp(-abs(coordinate - scanPosition) * 15.5);
float coreFront = exp(-abs(coordinate - scanPosition) * 34.0);
vec3 objectPosition = mix(fromMatter, toMatter, localMorph);
vec3 normalDirection = safeNormalize(objectPosition);
vec3 phaseAxis = safeNormalize(vec3(0.82, 0.25, 0.52));
vec3 tangent = safeNormalize(cross(normalDirection, phaseAxis + vec3(0.001, 0.002, 0.003)));
float handedness = mix(-1.0, 1.0, step(0.5, aRandom.z));
float flutter = sin(
dot(seed, safeNormalize(vec3(-0.31, 0.79, 0.53))) * (16.0 + aRandom.x * 12.0)
- uTime * (4.0 + aRandom.y * 3.0)
);
objectPosition += normalDirection * front * (0.05 + aRandom.x * 0.24);
objectPosition += tangent * handedness * front * flutter * (0.035 + aRandom.y * 0.16);
objectPosition = sanitizeVec3(objectPosition, mix(fromMatter, toMatter, localMorph));
vec4 mvPosition = modelViewMatrix * vec4(objectPosition, 1.0);
gl_Position = projectionMatrix * mvPosition;
float distanceScale = 30.0 / max(1.0, -mvPosition.z);
gl_PointSize = (0.72 + aRandom.x * 1.65 + coreFront * 0.70)
* distanceScale * uPixelRatio * uPointScale;
vec3 fromColor = planetColor(
uFromPreset, fromSurface, seed, aRandom, aKind, uTime
);
vec3 toColor = planetColor(
uToPreset, toSurface, seed, aRandom, aKind, uTime
);
vec3 baseColor = mix(fromColor, toColor, localMorph);
vec3 phaseCyan = vec3(0.025, 0.90, 1.30);
vec3 phaseViolet = vec3(0.95, 0.055, 1.18);
vec3 phaseAmber = vec3(1.24, 0.38, 0.025);
vec3 phaseColor = aRandom.z < 0.60
? mix(phaseCyan, phaseViolet, aRandom.z / 0.60)
: mix(phaseViolet, phaseAmber, (aRandom.z - 0.60) / 0.40);
vec3 color = mix(baseColor, phaseColor, 0.62 + coreFront * 0.18);
color = limitLuminance(color, 1.42);
vColor = clamp(sanitizeVec3(color, vec3(0.0)), vec3(0.0), vec3(1.60));
vAlpha = clamp(front * (0.026 + aRandom.w * 0.105), 0.0, 0.14);
vCore = 0.12 + coreFront * 0.24;
vEnergy = clamp(uTransitionEnergy, 0.0, 1.0);
vRibbon = coreFront;
}
`;
const particleMaterial = new THREE.ShaderMaterial({
uniforms: particleUniforms,
vertexShader: volumetricVertexShader,
fragmentShader: `
varying vec3 vColor;
varying float vAlpha;
varying float vCore;
varying float vEnergy;
varying float vRibbon;
void main() {
vec2 uv = gl_PointCoord - 0.5;
float distanceToCenter = length(uv);
if (distanceToCenter > 0.5) discard;
float softGlow = exp(-distanceToCenter * mix(9.4, 7.2, vEnergy));
float core = (1.0 - smoothstep(0.0, 0.25, distanceToCenter)) * vCore;
float alpha = vAlpha * (softGlow * 0.76 + core * 0.30);
vec3 color = vColor * (softGlow * (0.88 + vRibbon * 0.12) + core * 0.42);
gl_FragColor = vec4(color, clamp(alpha, 0.0, 0.19));
}
`,
transparent: true,
blending: THREE.NormalBlending,
depthWrite: false,
depthTest: true
});
const planetParticles = new THREE.Points(particleGeometry, particleMaterial);
planetParticles.renderOrder = 4;
planetParticles.visible = false;
planetGroup.add(planetParticles);
const filamentCount = isCompact ? 4200 : 7600;
const filamentGeometry = new THREE.BufferGeometry();
const filamentSeeds = new Float32Array(filamentCount * 3);
const filamentRandom = new Float32Array(filamentCount * 4);
const filamentLayer = new Float32Array(filamentCount);
const filamentKind = new Float32Array(filamentCount);
for (let i = 0; i < filamentCount; i += 1) {
const y = THREE.MathUtils.randFloatSpread(2);
const angle = Math.random() * TAU;
const radial = Math.sqrt(Math.max(0, 1 - y * y));
filamentSeeds[i * 3] = Math.cos(angle) * radial;
filamentSeeds[i * 3 + 1] = y;
filamentSeeds[i * 3 + 2] = Math.sin(angle) * radial;
filamentRandom[i * 4] = Math.random();
filamentRandom[i * 4 + 1] = Math.random();
filamentRandom[i * 4 + 2] = Math.random();
filamentRandom[i * 4 + 3] = Math.random();
filamentLayer[i] = 0.08 + Math.pow(Math.random(), 1 / 3) * 0.92;
filamentKind[i] = Math.random() < 0.22 ? 1 : 0;
}
filamentGeometry.setAttribute('position', new THREE.BufferAttribute(filamentSeeds, 3));
filamentGeometry.setAttribute('aRandom', new THREE.BufferAttribute(filamentRandom, 4));
filamentGeometry.setAttribute('aLayer', new THREE.BufferAttribute(filamentLayer, 1));
filamentGeometry.setAttribute('aKind', new THREE.BufferAttribute(filamentKind, 1));
filamentGeometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 8.0);
const filamentUniforms = {
...sharedPlanetUniforms,
uPixelRatio: { value: renderer.getPixelRatio() }
};
const filamentMaterial = new THREE.ShaderMaterial({
uniforms: filamentUniforms,
vertexShader: `
${noiseGLSL}
${planetFunctionsGLSL}
uniform float uTime;
uniform float uFromPreset;
uniform float uToPreset;
uniform float uTransition;
uniform float uTransitionEnergy;
uniform float uPixelRatio;
attribute vec4 aRandom;
attribute float aLayer;
attribute float aKind;
varying vec3 vColor;
varying float vAlpha;
varying float vPulse;
float phaseCoordinate(vec3 seed, float time) {
vec3 axisA = safeNormalize(vec3(0.82, 0.25, 0.52));
vec3 axisB = safeNormalize(vec3(-0.18, 0.93, 0.32));
float coordinate = dot(seed, axisA);
coordinate += sin(dot(seed, axisB) * 6.2 - time * 0.72) * 0.055;
coordinate += snoise(seed * 3.4 + vec3(0.0, time * 0.10, 0.0)) * 0.035;
return coordinate;
}
void main() {
vec3 seed = safeNormalize(position);
vec3 fromSurface = planetPosition(uFromPreset, seed, aRandom, aKind, uTime);
vec3 toSurface = planetPosition(uToPreset, seed, aRandom, aKind, uTime);
float fromRing = isPreset(uFromPreset, 2.0) * step(0.5, aKind);
float toRing = isPreset(uToPreset, 2.0) * step(0.5, aKind);
vec3 fromMatter = mix(fromSurface * mix(0.72, 1.0, aLayer), fromSurface, fromRing);
vec3 toMatter = mix(toSurface * mix(0.72, 1.0, aLayer), toSurface, toRing);
float delayedTransition = clamp(uTransition + (aRandom.w - 0.5) * 0.075, 0.0, 1.0);
float coordinate = phaseCoordinate(seed, uTime);
float scanPosition = mix(-1.24, 1.24, delayedTransition);
float localMorph = 1.0 - smoothstep(scanPosition - 0.10, scanPosition + 0.10, coordinate);
float front = exp(-abs(coordinate - scanPosition) * 28.0);
vec3 p = mix(fromMatter, toMatter, localMorph);
vec3 normalDirection = safeNormalize(p);
vec3 phaseAxis = safeNormalize(vec3(0.82, 0.25, 0.52));
vec3 tangent = safeNormalize(cross(normalDirection, phaseAxis + vec3(0.002, 0.001, 0.003)));
float ray = step(0.73, aRandom.w);
float pulse = front * ray;
float wave = sin(aRandom.x * 22.0 + uTime * (5.0 + aRandom.y * 2.0));
p += normalDirection * pulse * (0.10 + aRandom.x * 0.42);
p += tangent * pulse * wave * (0.08 + aRandom.y * 0.24);
vec4 mvPosition = modelViewMatrix * vec4(sanitizeVec3(p, mix(fromMatter, toMatter, localMorph)), 1.0);
gl_Position = projectionMatrix * mvPosition;
gl_PointSize = (0.62 + aRandom.x * 1.45 + pulse * 0.80)
* uPixelRatio * (29.0 / max(1.0, -mvPosition.z));
vec3 cyan = vec3(0.035, 0.96, 1.40);
vec3 magenta = vec3(1.02, 0.035, 1.24);
vec3 amber = vec3(1.30, 0.40, 0.025);
vColor = aRandom.z < 0.56
? mix(cyan, magenta, aRandom.z / 0.56)
: mix(magenta, amber, (aRandom.z - 0.56) / 0.44);
vAlpha = clamp(front * uTransitionEnergy * (0.012 + aRandom.w * 0.040), 0.0, 0.052);
vPulse = pulse;
}
`,
fragmentShader: `
varying vec3 vColor;
varying float vAlpha;
varying float vPulse;
void main() {
vec2 uv = gl_PointCoord - 0.5;
float d = length(uv);
if (d > 0.5) discard;
float core = exp(-d * 15.0);
float glow = exp(-d * 8.2) * (0.12 + vPulse * 0.05);
gl_FragColor = vec4(vColor * (core + glow), clamp(vAlpha * (core + glow), 0.0, 0.075));
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
depthTest: true
});
const transitionFilaments = new THREE.Points(filamentGeometry, filamentMaterial);
transitionFilaments.renderOrder = 5;
transitionFilaments.visible = false;
planetGroup.add(transitionFilaments);
const portalUniforms = {
...sharedPlanetUniforms,
uTime: { value: 0 },
uFromAccent: { value: new THREE.Color(0.10, 0.82, 1.0) },
uToAccent: { value: new THREE.Color(0.72, 0.16, 1.0) }
};
const portalMaterial = new THREE.ShaderMaterial({
uniforms: portalUniforms,
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform float uTransition;
uniform float uTransitionEnergy;
uniform vec3 uFromAccent;
uniform vec3 uToAccent;
varying vec2 vUv;
float ringLine(float radius, float target, float width) {
return exp(-abs(radius - target) / max(width, 0.0001));
}
void main() {
vec2 p = (vUv - 0.5) * 2.0;
float radius = length(p);
float angle = atan(p.y, p.x);
float burst = exp(-pow((uTransition - 0.52) / 0.105, 2.0));
float progress = smoothstep(0.37, 0.72, uTransition);
float ringRadius = mix(0.18, 1.30, progress);
float warpedRadius = ringRadius
+ sin(angle * 8.0 - uTime * 4.0) * 0.022
+ sin(angle * 3.0 + uTime * 2.4) * 0.014;
float shock = ringLine(radius, warpedRadius, 0.017) * burst;
float echo = ringLine(radius, warpedRadius * 0.72, 0.013) * burst * 0.42;
float lens = exp(-radius * radius * 7.5) * burst;
vec3 color = mix(uFromAccent, uToAccent, smoothstep(0.28, 0.74, uTransition));
vec3 secondary = mix(vec3(0.03, 0.78, 1.0), vec3(0.82, 0.08, 0.96), smoothstep(0.30, 0.70, uTransition));
vec3 finalColor = color * shock * 0.58;
finalColor += secondary * echo * 0.34;
finalColor += mix(color, secondary, 0.5) * lens * 0.055;
float alpha = shock * 0.105 + echo * 0.055 + lens * 0.020;
alpha *= 1.0 - smoothstep(1.40, 1.62, radius);
if (alpha < 0.0015) discard;
gl_FragColor = vec4(finalColor, clamp(alpha, 0.0, 0.12));
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
});
const portalPlane = new THREE.Mesh(
new THREE.PlaneGeometry(5.6, 5.6, 1, 1),
portalMaterial
);
portalPlane.renderOrder = 8;
portalPlane.visible = false;
scene.add(portalPlane);
const atmosphereGeometry = new THREE.IcosahedronGeometry(1, isCompact ? 4 : 5);
const atmosphereMaterial = new THREE.ShaderMaterial({
uniforms: sharedPlanetUniforms,
vertexShader: `
${noiseGLSL}
${planetFunctionsGLSL}
uniform float uTime;
uniform float uFromPreset;
uniform float uToPreset;
uniform float uTransition;
uniform float uTransitionEnergy;
varying vec3 vWorldPosition;
varying vec3 vNormalApprox;
varying float vLocalMorph;
varying float vFront;
float phaseCoordinate(vec3 seed, float time) {
vec3 axisA = safeNormalize(vec3(0.82, 0.25, 0.52));
vec3 axisB = safeNormalize(vec3(-0.18, 0.93, 0.32));
float coordinate = dot(seed, axisA);
coordinate += sin(dot(seed, axisB) * 6.2 - time * 0.72) * 0.055;
coordinate += snoise(seed * 3.4 + vec3(0.0, time * 0.10, 0.0)) * 0.035;
return coordinate;
}
void main() {
vec3 seed = safeNormalize(position);
vec4 randomData = vec4(0.17, 0.42, 0.73, 0.91);
vec3 fromPosition = sanitizeVec3(
planetPosition(uFromPreset, seed, randomData, 0.0, uTime),
seed * 2.08
);
vec3 toPosition = sanitizeVec3(
planetPosition(uToPreset, seed, randomData, 0.0, uTime),
seed * 2.08
);
float coordinate = phaseCoordinate(seed, uTime);
float scanPosition = mix(-1.24, 1.24, uTransition);
float localMorph = 1.0 - smoothstep(scanPosition - 0.14, scanPosition + 0.14, coordinate);
float front = exp(-abs(coordinate - scanPosition) * 17.0);
vec3 objectPosition = mix(fromPosition, toPosition, localMorph) * 1.065;
objectPosition = sanitizeVec3(objectPosition, seed * 2.20);
vec4 worldPosition = modelMatrix * vec4(objectPosition, 1.0);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
vWorldPosition = worldPosition.xyz;
vNormalApprox = safeNormalize(mat3(modelMatrix) * objectPosition);
vLocalMorph = localMorph;
vFront = front;
}
`,
fragmentShader: `
${noiseGLSL}
uniform float uFromPreset;
uniform float uToPreset;
uniform float uTransitionEnergy;
varying vec3 vWorldPosition;
varying vec3 vNormalApprox;
varying float vLocalMorph;
varying float vFront;
vec3 atmosphereColor(float preset) {
if (preset < 0.5) return vec3(0.055, 0.42, 0.72);
if (preset < 1.5) return vec3(0.72, 0.10, 0.018);
if (preset < 2.5) return vec3(0.035, 0.40, 0.36);
return vec3(0.30, 0.08, 0.68);
}
void main() {
vec3 normal = safeNormalize(vNormalApprox);
vec3 viewDirection = safeNormalize(cameraPosition - vWorldPosition);
float facing = saturate(dot(normal, viewDirection));
float fresnel = pow(max(1.0 - facing, 0.0), 2.4);
vec3 color = mix(
atmosphereColor(uFromPreset),
atmosphereColor(uToPreset),
vLocalMorph
);
vec3 phaseTint = mix(vec3(0.03, 0.66, 1.02), vec3(0.72, 0.06, 0.94), vLocalMorph);
color = mix(color, phaseTint, vFront * 0.28);
float alpha = fresnel * (0.23 + vFront * 0.040);
color = limitLuminance(color, 1.02);
alpha = clamp(sanitizeFloat(alpha, 0.0), 0.0, 0.34);
gl_FragColor = vec4(color * fresnel * 0.92, alpha);
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
side: THREE.BackSide,
depthWrite: false
});
const atmosphereMesh = new THREE.Mesh(atmosphereGeometry, atmosphereMaterial);
atmosphereMesh.renderOrder = 3;
planetGroup.add(atmosphereMesh);
const ringGeometry = new THREE.RingGeometry(2.16, 3.68, 320, 12);
const ringMaterial = new THREE.ShaderMaterial({
uniforms: {
...sharedPlanetUniforms,
uOpacity: { value: 0 }
},
vertexShader: `
${noiseGLSL}
uniform float uTime;
uniform float uTransitionEnergy;
varying vec2 vLocal;
varying float vNoise;
void main() {
vec3 p = position;
float radius = length(p.xy);
float angle = safeAtan(p.y, p.x);
float warp = snoise(vec3(cos(angle), sin(angle), radius * 1.7 + uTime * 0.035));
p.z += warp * 0.032;
p.xy = rotate2D(uTime * 0.004) * p.xy;
vLocal = p.xy;
vNoise = warp;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
`,
fragmentShader: `
${noiseGLSL}
uniform float uTime;
uniform float uOpacity;
uniform float uTransitionEnergy;
varying vec2 vLocal;
varying float vNoise;
void main() {
float radius = length(vLocal);
vec2 ringDirection = safeNormalize(vLocal + vec2(0.00001));
float lane = sin(radius * 47.0 + sin(radius * 8.0) * 2.0);
float fineLane = sin(radius * 126.0 + (ringDirection.x * ringDirection.y) * 7.5);
float breakNoise = snoise(vec3(
ringDirection * 2.35,
radius * 4.8 + uTime * 0.03
));
float gaps = smoothstep(-0.50, 0.16, breakNoise + lane * 0.28);
float edge = smoothstep(2.16, 2.31, radius) * (1.0 - smoothstep(3.48, 3.68, radius));
vec3 gold = vec3(0.92, 0.49, 0.10);
vec3 ice = vec3(0.035, 0.73, 0.65);
vec3 violet = vec3(0.34, 0.055, 0.55);
vec3 color = mix(gold, ice, lane * 0.5 + 0.5);
color = mix(color, violet, smoothstep(0.72, 0.98, fineLane) * 0.40);
color += vec3(0.34, 0.31, 0.19) * abs(fineLane) * 0.08;
float alpha = edge * gaps * (0.115 + abs(lane) * 0.145 + abs(fineLane) * 0.060);
alpha *= uOpacity;
color = limitLuminance(color, 1.05);
alpha = clamp(sanitizeFloat(alpha, 0.0), 0.0, 0.40);
gl_FragColor = vec4(color, alpha);
}
`,
transparent: true,
blending: THREE.NormalBlending,
depthWrite: false,
side: THREE.DoubleSide
});
const ringMesh = new THREE.Mesh(ringGeometry, ringMaterial);
ringMesh.rotation.set(Math.PI * 0.5 + 0.20, 0.0, 0.34);
ringMesh.renderOrder = 1;
planetGroup.add(ringMesh);
const arcCount = isCompact ? 1200 : 2200;
const arcGeometry = new THREE.BufferGeometry();
const arcPositions = new Float32Array(arcCount * 3);
const arcRandom = new Float32Array(arcCount * 2);
for (let i = 0; i < arcCount; i += 1) {
const angle = Math.random() * TAU;
const radius = THREE.MathUtils.lerp(3.9, 5.8, Math.pow(Math.random(), 0.72));
arcPositions[i * 3] = Math.cos(angle) * radius;
arcPositions[i * 3 + 1] = THREE.MathUtils.randFloatSpread(0.45);
arcPositions[i * 3 + 2] = Math.sin(angle) * radius;
arcRandom[i * 2] = Math.random();
arcRandom[i * 2 + 1] = Math.random();
}
arcGeometry.setAttribute('position', new THREE.BufferAttribute(arcPositions, 3));
arcGeometry.setAttribute('aRandom', new THREE.BufferAttribute(arcRandom, 2));
const arcMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uPixelRatio: { value: renderer.getPixelRatio() },
uTransitionEnergy: { value: 0 }
},
vertexShader: `
uniform float uTime;
uniform float uPixelRatio;
uniform float uTransitionEnergy;
attribute vec2 aRandom;
varying float vAlpha;
varying vec3 vColor;
void main() {
vec3 p = position;
float angle = uTime * (0.035 + aRandom.x * 0.055);
float s = sin(angle);
float c = cos(angle);
p.xz = mat2(c, -s, s, c) * p.xz;
p.y += sin(uTime * 0.45 + aRandom.y * 13.0 + length(p.xz)) * 0.13;
p *= 1.0 + uTransitionEnergy * 0.035;
vec4 mvPosition = modelViewMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * mvPosition;
gl_PointSize = (0.7 + aRandom.x * 1.5) * uPixelRatio * (25.0 / max(1.0, -mvPosition.z));
vAlpha = (0.05 + aRandom.y * 0.16) * (1.0 - uTransitionEnergy * 0.52);
vColor = mix(vec3(0.24, 0.48, 1.0), vec3(0.85, 0.35, 1.0), aRandom.x);
}
`,
fragmentShader: `
varying float vAlpha;
varying vec3 vColor;
void main() {
vec2 uv = gl_PointCoord - 0.5;
float d = length(uv);
if (d > 0.5) discard;
float glow = exp(-d * 7.0);
gl_FragColor = vec4(vColor * glow, vAlpha * glow);
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false
});
const orbitalArcs = new THREE.Points(arcGeometry, arcMaterial);
orbitalArcs.rotation.set(0.18, 0.0, -0.28);
planetGroup.add(orbitalArcs);
const transitionState = {
active: false,
from: 0,
to: 0,
queued: null,
startTime: 0,
duration: prefersReducedMotion ? 0.82 : 1.28,
raw: 0,
eased: 0,
energy: 0,
current: 0,
settledAt: 0
};
function smootherStep(value) {
const x = THREE.MathUtils.clamp(value, 0, 1);
return x * x * x * (x * (x * 6 - 15) + 10);
}
function cinematicEase(value) {
const x = THREE.MathUtils.clamp(value, 0, 1);
if (x < 0.5) return 0.5 * Math.pow(x * 2, 1.22);
return 1 - 0.5 * Math.pow((1 - x) * 2, 1.22);
}
function transitionEnergy(easedProgress) {
const progress = THREE.MathUtils.clamp(easedProgress, 0, 1);
const pulse = Math.max(0, Math.sin(Math.PI * progress));
const energy = Math.pow(pulse, 1.35);
return Number.isFinite(energy) ? energy : 0;
}
function setSharedUniform(name, value) {
sharedPlanetUniforms[name].value = value;
particleUniforms[name].value = value;
ringMaterial.uniforms[name].value = value;
}
function accentStringToColor(value) {
const channels = value.split(',').map((part) => Number(part.trim()) / 255);
return new THREE.Color(
Number.isFinite(channels[0]) ? channels[0] : 0.2,
Number.isFinite(channels[1]) ? channels[1] : 0.7,
Number.isFinite(channels[2]) ? channels[2] : 1.0
);
}
function updatePortalPalette(fromIndex, toIndex) {
portalUniforms.uFromAccent.value.copy(accentStringToColor(PLANETS[fromIndex].accentA));
portalUniforms.uToAccent.value.copy(accentStringToColor(PLANETS[toIndex].accentB));
}
function setDocumentAccent(index) {
const planet = PLANETS[index];
document.documentElement.style.setProperty('--accent-a', planet.accentA);
document.documentElement.style.setProperty('--accent-b', planet.accentB);
}
function updatePlanetCopy(index, isTransmuting = false) {
const planet = PLANETS[index];
planetKicker.textContent = isTransmuting
? `Phasing into ${planet.kicker.toLowerCase()}`
: planet.kicker;
planetName.textContent = planet.name;
planetDescription.textContent = planet.description;
}
function updateButtonState(currentIndex, targetIndex = null) {
planetButtons.forEach((button) => {
const index = Number(button.dataset.planet);
const isCurrent = index === currentIndex && targetIndex === null;
const isTarget = targetIndex !== null && index === targetIndex;
button.classList.toggle('is-active', isCurrent);
button.classList.toggle('is-target', isTarget);
button.setAttribute('aria-pressed', String(isCurrent || isTarget));
});
}
function beginTransition(targetIndex, nowSeconds) {
const normalizedTarget = (targetIndex + PLANETS.length) % PLANETS.length;
if (transitionState.active) {
transitionState.queued = normalizedTarget;
return;
}
if (normalizedTarget === transitionState.current) return;
transitionState.active = true;
transitionState.from = transitionState.current;
transitionState.to = normalizedTarget;
transitionState.startTime = nowSeconds;
transitionState.raw = 0;
transitionState.eased = 0;
transitionState.energy = 0;
updatePortalPalette(transitionState.from, transitionState.to);
setSharedUniform('uFromPreset', transitionState.from);
setSharedUniform('uToPreset', transitionState.to);
setSharedUniform('uTransition', 0);
setSharedUniform('uTransitionEnergy', 0);
updatePlanetCopy(normalizedTarget, true);
updateButtonState(transitionState.current, normalizedTarget);
setDocumentAccent(normalizedTarget);
transitionMeter.classList.add('is-active');
transitionMeterFill.style.width = '0%';
}
function settleTransition(nowSeconds) {
transitionState.current = transitionState.to;
transitionState.active = false;
transitionState.raw = 1;
transitionState.eased = 1;
transitionState.energy = 0;
transitionState.settledAt = nowSeconds;
setSharedUniform('uFromPreset', transitionState.current);
setSharedUniform('uToPreset', transitionState.current);
setSharedUniform('uTransition', 0);
setSharedUniform('uTransitionEnergy', 0);
updatePlanetCopy(transitionState.current, false);
updateButtonState(transitionState.current, null);
setDocumentAccent(transitionState.current);
transitionMeter.classList.remove('is-active');
transitionMeterFill.style.width = '100%';
if (transitionState.queued !== null) {
const queued = transitionState.queued;
transitionState.queued = null;
if (queued !== transitionState.current) {
beginTransition(queued, nowSeconds + 0.001);
}
}
}
function updateTransition(nowSeconds) {
if (!transitionState.active) return;
const raw = THREE.MathUtils.clamp(
(nowSeconds - transitionState.startTime) / transitionState.duration,
0,
1
);
const eased = cinematicEase(raw);
const energy = transitionEnergy(eased);
transitionState.raw = raw;
transitionState.eased = eased;
transitionState.energy = energy;
setSharedUniform('uTransition', eased);
setSharedUniform('uTransitionEnergy', energy);
transitionMeterFill.style.width = `${(raw * 100).toFixed(2)}%`;
if (raw >= 1) {
settleTransition(nowSeconds);
}
}
let latestTime = 0;
planetButtons.forEach((button) => {
button.addEventListener('click', () => {
beginTransition(Number(button.dataset.planet), latestTime);
});
});
window.addEventListener('keydown', (event) => {
const activeElement = document.activeElement;
const tagName = activeElement?.tagName?.toLowerCase();
if (tagName === 'input' || tagName === 'textarea') return;
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
const base = transitionState.active ? transitionState.to : transitionState.current;
beginTransition(base + 1, latestTime);
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
event.preventDefault();
const base = transitionState.active ? transitionState.to : transitionState.current;
beginTransition(base - 1, latestTime);
} else if (event.key === ' ') {
event.preventDefault();
const base = transitionState.active ? transitionState.to : transitionState.current;
beginTransition(base + 1, latestTime);
}
});
function handleResize() {
const width = window.innerWidth;
const height = window.innerHeight;
const pixelRatio = responsivePixelRatio();
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setPixelRatio(pixelRatio);
renderer.setSize(width, height);
composer.setPixelRatio(pixelRatio);
composer.setSize(width, height);
particleUniforms.uPixelRatio.value = pixelRatio;
filamentUniforms.uPixelRatio.value = pixelRatio;
starMaterial.uniforms.uPixelRatio.value = pixelRatio;
arcMaterial.uniforms.uPixelRatio.value = pixelRatio;
}
window.addEventListener('resize', handleResize, { passive: true });
const clock = new THREE.Clock();
transitionState.settledAt = 0;
const AUTO_HOLD_SECONDS = prefersReducedMotion ? 6.0 : 4.2;
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.04);
const time = clock.elapsedTime;
latestTime = time;
updateTransition(time);
const energy = transitionState.energy;
surfaceMaterial.depthWrite = true;
planetParticles.visible = transitionState.active;
transitionFilaments.visible = transitionState.active;
sharedPlanetUniforms.uTime.value = time;
particleUniforms.uTime.value = time;
nebulaMaterial.uniforms.uTime.value = time;
starMaterial.uniforms.uTime.value = time;
starMaterial.uniforms.uTransitionEnergy.value = energy;
arcMaterial.uniforms.uTime.value = time;
arcMaterial.uniforms.uTransitionEnergy.value = energy;
portalUniforms.uTime.value = time;
portalPlane.visible = transitionState.active;
if (portalPlane.visible) {
portalPlane.quaternion.copy(camera.quaternion);
portalPlane.scale.setScalar(1.0);
}
const fromHasRing = transitionState.active
? Number(transitionState.from === 2)
: Number(transitionState.current === 2);
const toHasRing = transitionState.active
? Number(transitionState.to === 2)
: fromHasRing;
const ringMorph = transitionState.active
? smootherStep(transitionState.eased)
: 0;
ringMaterial.uniforms.uOpacity.value = THREE.MathUtils.lerp(
fromHasRing,
toHasRing,
ringMorph
);
planetGroup.rotation.y += (idleRotation + energy * 0.12) * delta;
planetGroup.rotation.x = Math.sin(time * 0.12) * 0.025;
planetGroup.scale.set(1.0, 1.0, 1.0);
orbitalArcs.rotation.y = time * 0.018;
orbitalArcs.rotation.z = -0.28 + Math.sin(time * 0.09) * 0.055;
stars.rotation.y = time * 0.0016;
nebula.rotation.y = -time * 0.00045;
bloomPass.strength = 0.50 + energy * 0.055;
bloomPass.radius = 0.30 + energy * 0.025;
bloomPass.threshold = 0.50 + energy * 0.025;
renderer.toneMappingExposure = 1.08;
if (
autoCycleInput.checked
&& !transitionState.active
&& time - transitionState.settledAt > AUTO_HOLD_SECONDS
) {
beginTransition(transitionState.current + 1, time);
}
controls.update();
guardOrbitRadius();
composer.render();
}
const idleRotation = 0.035;
updatePortalPalette(0, 0);
setDocumentAccent(0);
updatePlanetCopy(0, false);
updateButtonState(0, null);
handleResize();
animate();
</script>
</body>
</html>