From 9719c7c438b94567448bcee91854fcb2152f00c6 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 12 Mar 2026 21:59:31 -0400 Subject: [PATCH] Add ambient starfield backdrop --- apps/viewer/src/GameViewer.ts | 132 ++++++++++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 5 deletions(-) diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts index 82e7295..4f3327b 100644 --- a/apps/viewer/src/GameViewer.ts +++ b/apps/viewer/src/GameViewer.ts @@ -135,6 +135,7 @@ export class GameViewer { private readonly nodeGroup = new THREE.Group(); private readonly stationGroup = new THREE.Group(); private readonly shipGroup = new THREE.Group(); + private readonly ambienceGroup = new THREE.Group(); private readonly selectableTargets = new Map(); private readonly presentationEntries: PresentationEntry[] = []; private readonly nodeMeshes = new Map(); @@ -196,7 +197,8 @@ export class GameViewer { const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3); keyLight.position.set(1000, 1200, 800); this.scene.add(keyLight); - this.scene.add(this.systemGroup, this.nodeGroup, this.stationGroup, this.shipGroup); + this.initializeAmbience(); + this.scene.add(this.ambienceGroup, this.systemGroup, this.nodeGroup, this.stationGroup, this.shipGroup); const hud = document.createElement("div"); hud.className = "viewer-shell"; @@ -664,6 +666,7 @@ export class GameViewer { const frameStartedAtMs = performance.now(); const delta = Math.min(this.clock.getDelta(), 0.033); this.updateCamera(delta); + this.updateAmbience(delta); this.updatePlanetPresentation(); this.updateShipPresentation(); this.updateNetworkPanel(); @@ -673,6 +676,12 @@ export class GameViewer { this.updatePerformancePanel(); } + private updateAmbience(delta: number) { + this.ambienceGroup.position.copy(this.camera.position); + this.ambienceGroup.rotation.y += delta * 0.005; + this.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015; + } + private updateCamera(delta: number) { this.currentDistance = THREE.MathUtils.damp(this.currentDistance, this.desiredDistance, 7.5, delta); this.zoomLevel = this.classifyZoomLevel(this.currentDistance); @@ -739,10 +748,7 @@ export class GameViewer { this.setObjectOpacity(summaryVisual.sprite, blend.universeWeight); } - this.scene.fog = new THREE.FogExp2( - 0x040912, - THREE.MathUtils.lerp(0.00011, 0.000012, blend.universeWeight), - ); + this.scene.fog = new THREE.FogExp2(0x040912, 0.000035); } private recordDeltaStats(delta: WorldDelta, rawBytes: number) { @@ -1017,6 +1023,122 @@ export class GameViewer { return mesh; } + private initializeAmbience() { + this.ambienceGroup.renderOrder = -10; + this.ambienceGroup.add(this.createBackdropStars()); + this.ambienceGroup.add(...this.createNebulaClouds()); + } + + private createBackdropStars() { + const starCount = 1800; + const radius = 36000; + const positions = new Float32Array(starCount * 3); + const colors = new Float32Array(starCount * 3); + const color = new THREE.Color(); + + for (let index = 0; index < starCount; index += 1) { + const direction = new THREE.Vector3( + THREE.MathUtils.randFloatSpread(2), + THREE.MathUtils.randFloatSpread(2), + THREE.MathUtils.randFloatSpread(2), + ).normalize().multiplyScalar(radius * THREE.MathUtils.randFloat(0.82, 1)); + positions[index * 3] = direction.x; + positions[index * 3 + 1] = direction.y; + positions[index * 3 + 2] = direction.z; + + const tint = THREE.MathUtils.randFloat(0, 1); + color.setRGB( + THREE.MathUtils.lerp(0.68, 1, tint), + THREE.MathUtils.lerp(0.76, 0.94, tint), + THREE.MathUtils.lerp(0.9, 1, tint), + ); + if (Math.random() < 0.08) { + color.lerp(new THREE.Color(0xffd6a0), 0.45); + } + colors[index * 3] = color.r; + colors[index * 3 + 1] = color.g; + colors[index * 3 + 2] = color.b; + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + + return new THREE.Points( + geometry, + new THREE.PointsMaterial({ + size: 2.2, + sizeAttenuation: false, + vertexColors: true, + transparent: true, + opacity: 0.9, + depthWrite: false, + blending: THREE.AdditiveBlending, + }), + ); + } + + private createNebulaClouds() { + const texture = this.createNebulaTexture(); + const directions = [ + new THREE.Vector3(0.74, 0.34, -0.58), + new THREE.Vector3(-0.62, 0.18, -0.77), + new THREE.Vector3(0.22, -0.44, -0.87), + new THREE.Vector3(-0.38, 0.56, 0.73), + ]; + + return directions.map((direction, index) => { + const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ + map: texture, + transparent: true, + opacity: 0.14, + depthWrite: false, + color: ["#6dc7ff", "#ff9ec8", "#8e7dff", "#7ce0c3"][index] ?? "#6dc7ff", + blending: THREE.AdditiveBlending, + })); + sprite.position.copy(direction.normalize().multiplyScalar(25000 + index * 2600)); + const scale = 15000 + index * 2400; + sprite.scale.set(scale, scale * 0.62, 1); + return sprite; + }); + } + + private createNebulaTexture() { + const canvas = document.createElement("canvas"); + canvas.width = 256; + canvas.height = 256; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Unable to create nebula texture"); + } + + const gradient = context.createRadialGradient(128, 128, 18, 128, 128, 118); + gradient.addColorStop(0, "rgba(255,255,255,0.95)"); + gradient.addColorStop(0.2, "rgba(255,255,255,0.48)"); + gradient.addColorStop(0.55, "rgba(140,180,255,0.14)"); + gradient.addColorStop(1, "rgba(0,0,0,0)"); + context.fillStyle = gradient; + context.fillRect(0, 0, 256, 256); + + for (let index = 0; index < 10; index += 1) { + const x = THREE.MathUtils.randFloat(30, 226); + const y = THREE.MathUtils.randFloat(30, 226); + const radius = THREE.MathUtils.randFloat(24, 72); + const puff = context.createRadialGradient(x, y, 0, x, y, radius); + puff.addColorStop(0, "rgba(255,255,255,0.16)"); + puff.addColorStop(0.45, "rgba(255,255,255,0.08)"); + puff.addColorStop(1, "rgba(0,0,0,0)"); + context.fillStyle = puff; + context.beginPath(); + context.arc(x, y, radius, 0, Math.PI * 2); + context.fill(); + } + + const texture = new THREE.CanvasTexture(canvas); + texture.needsUpdate = true; + return texture; + } + private createTacticalIcon(color: string, size: number) { const canvas = document.createElement("canvas"); canvas.width = 64;