Files
the-nexus/app.js
Alexander Whitestone 1e6f7fd868
Some checks failed
CI / validate (pull_request) Has been cancelled
feat: add volumetric light rays from skybox through scene
Implements god ray / crepuscular ray effect using cone-shaped meshes
with custom GLSL shaders and additive blending. Nine rays emanate from
the upper skybox at varied angles, colors (gold, blue, cyan), and phases.
Each ray has animated shimmer rippling down its length.

Also establishes the full Three.js scene foundation:
- Renderer with ACESFilmic tone mapping + UnrealBloom post-processing
- Star field (4000 points) distributed on a sphere
- Nexus Core: animated wireframe icosahedron with glow halo
- OrbitControls with damping
- Loading screen with progress animation
- Audio toggle wired up

Fixes #111

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:00:50 -04:00

312 lines
9.6 KiB
JavaScript

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 { wsClient } from './ws-client.js';
// === COLOR PALETTE ===
const NEXUS = {
colors: {
primary: 0x00ffcc,
secondary: 0x8800ff,
accent: 0xff8800,
bg: 0x000408,
nebula1: 0x0a0025,
nebula2: 0x00050f,
rayGold: 0xffaa44,
rayBlue: 0x88aaff,
rayCyan: 0x44ddff,
star: 0xffffff,
}
};
// === RENDERER ===
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.9;
document.body.appendChild(renderer.domElement);
// === SCENE ===
const scene = new THREE.Scene();
scene.background = new THREE.Color(NEXUS.colors.bg);
scene.fog = new THREE.FogExp2(NEXUS.colors.bg, 0.006);
// === CAMERA ===
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 800);
camera.position.set(0, 8, 28);
// === CONTROLS ===
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.target.set(0, 4, 0);
controls.maxDistance = 120;
controls.minDistance = 2;
// === LIGHTING ===
const ambient = new THREE.AmbientLight(0x112233, 0.6);
scene.add(ambient);
const coreLight = new THREE.PointLight(NEXUS.colors.primary, 3, 60);
coreLight.position.set(0, 8, 0);
scene.add(coreLight);
// === STAR FIELD ===
function createStarField() {
const count = 4000;
const positions = new Float32Array(count * 3);
const sizes = new Float32Array(count);
for (let i = 0; i < count; i++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = 200 + Math.random() * 200;
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
positions[i * 3 + 2] = r * Math.cos(phi);
sizes[i] = 0.3 + Math.random() * 0.7;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
const mat = new THREE.PointsMaterial({
color: NEXUS.colors.star,
size: 0.4,
sizeAttenuation: true,
transparent: true,
opacity: 0.85,
});
return new THREE.Points(geo, mat);
}
scene.add(createStarField());
// === VOLUMETRIC LIGHT RAYS ===
// Cone-shaped shafts with additive blending simulating god rays from the skybox
const LIGHT_RAY_VERT = /* glsl */`
varying vec2 vUv;
varying float vLocalY;
void main() {
vUv = uv;
// Normalize local Y: 0.0 = bottom (wide base), 1.0 = top (tip/source)
vLocalY = clamp((position.y + 15.0) / 30.0, 0.0, 1.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const LIGHT_RAY_FRAG = /* glsl */`
uniform float time;
uniform vec3 rayColor;
uniform float rayOpacity;
uniform float rayPhase;
varying vec2 vUv;
varying float vLocalY;
void main() {
// Soft radial fade using U coordinate around the circumference
// sin() creates a smooth bright-center / dark-edge per face
float radialFade = sin(vUv.x * 3.14159);
radialFade = pow(max(radialFade, 0.0), 0.6);
// Length fade: bright at source (top/tip, vLocalY=1), fades at base
float lengthFade = pow(vLocalY, 0.35) * (1.0 - pow(vLocalY, 6.0) * 0.05);
// Animated shimmer — ripples travel down the ray
float shimmer = 0.72 + 0.28 * sin(time * 1.6 + vLocalY * 7.0 + rayPhase);
float alpha = radialFade * lengthFade * shimmer * rayOpacity;
// Brighten core color slightly with shimmer
vec3 finalColor = rayColor * (1.0 + shimmer * 0.4);
gl_FragColor = vec4(finalColor, alpha);
}
`;
const lightRays = [];
function createLightRay(pos, rotX, rotZ, color, opacity, phase) {
// Cone: tip (radiusTop≈0) at top (y=+15), base (radiusBottom=2.8) at bottom (y=-15)
const geo = new THREE.CylinderGeometry(0.05, 2.8, 30, 32, 8, true);
const mat = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0.0 },
rayColor: { value: new THREE.Color(color) },
rayOpacity: { value: opacity },
rayPhase: { value: phase },
},
vertexShader: LIGHT_RAY_VERT,
fragmentShader: LIGHT_RAY_FRAG,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(pos);
mesh.rotation.x = rotX;
mesh.rotation.z = rotZ;
scene.add(mesh);
lightRays.push({ mesh, mat });
}
// Rays emanating from the upper skybox at varied angles and colors
const rayDefs = [
// pos (x, y, z), rotX, rotZ, color, opacity, phase
{ p: [-10, 26, -4], rx: 0.18, rz: 0.10, c: NEXUS.colors.rayGold, o: 0.20, ph: 0.0 },
{ p: [ 4, 30, -9], rx: -0.08, rz: -0.22, c: NEXUS.colors.rayGold, o: 0.16, ph: 1.1 },
{ p: [ 14, 24, 2], rx: 0.22, rz: 0.14, c: NEXUS.colors.rayBlue, o: 0.17, ph: 2.3 },
{ p: [-18, 28, 5], rx: -0.06, rz: 0.28, c: NEXUS.colors.rayGold, o: 0.13, ph: 0.7 },
{ p: [ 1, 32, -14], rx: 0.10, rz: -0.08, c: NEXUS.colors.rayCyan, o: 0.18, ph: 1.8 },
{ p: [ -6, 25, 12], rx: -0.20, rz: -0.10, c: NEXUS.colors.rayBlue, o: 0.15, ph: 3.1 },
{ p: [ 20, 29, -7], rx: 0.14, rz: 0.24, c: NEXUS.colors.rayGold, o: 0.12, ph: 0.4 },
{ p: [-12, 27, -9], rx: 0.05, rz: -0.16, c: NEXUS.colors.rayCyan, o: 0.14, ph: 2.6 },
{ p: [ 8, 23, 9], rx: -0.12, rz: 0.06, c: NEXUS.colors.rayGold, o: 0.11, ph: 1.5 },
];
rayDefs.forEach(({ p, rx, rz, c, o, ph }) => {
createLightRay(new THREE.Vector3(...p), rx, rz, c, o, ph);
});
// === NEXUS CORE (floating icosahedron) ===
const coreGeo = new THREE.IcosahedronGeometry(2.2, 1);
const coreMat = new THREE.MeshPhongMaterial({
color: NEXUS.colors.primary,
emissive: NEXUS.colors.primary,
emissiveIntensity: 0.6,
wireframe: true,
transparent: true,
opacity: 0.85,
});
const core = new THREE.Mesh(coreGeo, coreMat);
core.position.set(0, 5, 0);
scene.add(core);
// Glow halo around core
const haloGeo = new THREE.SphereGeometry(3.2, 16, 16);
const haloMat = new THREE.MeshBasicMaterial({
color: NEXUS.colors.primary,
transparent: true,
opacity: 0.04,
side: THREE.BackSide,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
scene.add(new THREE.Mesh(haloGeo, haloMat)).position.set(0, 5, 0);
// === GROUND PLANE ===
const groundGeo = new THREE.CircleGeometry(40, 64);
const groundMat = new THREE.MeshPhongMaterial({
color: 0x060618,
emissive: 0x010110,
shininess: 10,
});
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
// === POST-PROCESSING (Unreal Bloom) ===
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.9, // strength
0.5, // radius
0.82 // threshold
);
composer.addPass(bloom);
// === RESIZE HANDLER ===
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
bloom.setSize(window.innerWidth, window.innerHeight);
});
// === LOADING SCREEN ===
const loadingScreen = document.getElementById('loading-screen');
const loadingProgress = document.getElementById('loading-progress');
function simulateLoad() {
let pct = 0;
const interval = setInterval(() => {
pct += Math.random() * 18;
if (pct >= 100) {
pct = 100;
clearInterval(interval);
setTimeout(() => loadingScreen.classList.add('hidden'), 300);
}
loadingProgress.style.width = pct + '%';
}, 80);
}
simulateLoad();
// === ANIMATION LOOP ===
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
// Update volumetric ray shaders
lightRays.forEach(({ mat }) => {
mat.uniforms.time.value = t;
});
// Rotate Nexus Core
core.rotation.y = t * 0.28;
core.rotation.x = t * 0.11;
// Pulse the core point light
coreLight.intensity = 2.5 + 0.8 * Math.sin(t * 1.8);
controls.update();
composer.render();
}
animate();
// === WEBSOCKET INTEGRATION ===
wsClient.connect();
window.addEventListener('player-joined', (event) => {
console.log('Player joined:', event.detail);
});
window.addEventListener('player-left', (event) => {
console.log('Player left:', event.detail);
});
window.addEventListener('chat-message', (event) => {
console.log('Chat message:', event.detail);
});
window.addEventListener('beforeunload', () => {
wsClient.disconnect();
});
// === AUDIO TOGGLE ===
const audioToggle = document.getElementById('audio-toggle');
const ambientSound = document.getElementById('ambient-sound');
if (audioToggle && ambientSound) {
audioToggle.addEventListener('click', () => {
if (ambientSound.paused) {
ambientSound.play().catch(() => {});
audioToggle.textContent = '🔊';
audioToggle.classList.remove('muted');
} else {
ambientSound.pause();
audioToggle.textContent = '🔇';
audioToggle.classList.add('muted');
}
});
}