├── public └── cloud.png ├── vite.config.js ├── .gitignore ├── package.json ├── index.html ├── styles.css ├── LICENSE ├── .github └── workflows │ └── deploy.yml ├── README.md ├── helpers.js ├── ui.js ├── main.js └── galaxy.js /public/cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dgreenheck/webgpu-galaxy/HEAD/public/cloud.png -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | export default defineConfig({ 4 | base: '/webgpu-galaxy/', 5 | }); 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Build output 5 | dist/ 6 | 7 | # Environment variables 8 | .env 9 | .env.local 10 | 11 | # Editor directories 12 | .vscode/ 13 | .idea/ 14 | 15 | # OS files 16 | .DS_Store 17 | Thumbs.db 18 | 19 | # Logs 20 | *.log 21 | npm-debug.log* 22 | 23 | # Cache 24 | .cache/ 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webgpu-galaxy", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "description": "GPU Galaxy Simulation using WebGPU and Three.js", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "three": "^0.181.1", 13 | "tweakpane": "^4.0.1" 14 | }, 15 | "devDependencies": { 16 | "vite": "^5.0.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GPU Galaxy Simulation - TSL + WebGPU 7 | 8 | 9 | 10 |
11 |

🌌 GPU Galaxy Simulation

12 |
FPS: 60
13 |
Stars: 100000
14 |
15 | 🖱️ Drag to interact with galaxy
16 | 🎮 Use right panel controls 17 |
18 |
19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | font-family: 'Courier New', monospace; 9 | background: #000; 10 | color: #fff; 11 | overflow: hidden; 12 | } 13 | 14 | canvas { 15 | display: block; 16 | } 17 | 18 | #info { 19 | position: absolute; 20 | top: 20px; 21 | left: 20px; 22 | background: rgba(0, 0, 0, 0.8); 23 | padding: 15px 20px; 24 | border-radius: 8px; 25 | font-size: 14px; 26 | line-height: 1.8; 27 | pointer-events: none; 28 | z-index: 100; 29 | border: 1px solid rgba(100, 150, 255, 0.3); 30 | } 31 | 32 | #info h1 { 33 | font-size: 20px; 34 | margin-bottom: 10px; 35 | color: #88bbff; 36 | text-shadow: 0 0 10px rgba(136, 187, 255, 0.5); 37 | } 38 | 39 | #fps { 40 | color: #00ff88; 41 | font-weight: bold; 42 | } 43 | 44 | .hint { 45 | margin-top: 10px; 46 | opacity: 0.7; 47 | font-size: 12px; 48 | color: #aaddff; 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 dgreenheck 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | concurrency: 15 | group: "pages" 16 | cancel-in-progress: false 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Node 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: '20' 29 | cache: 'npm' 30 | 31 | - name: Install dependencies 32 | run: npm ci 33 | 34 | - name: Build 35 | run: npm run build 36 | 37 | - name: Upload artifact 38 | uses: actions/upload-pages-artifact@v3 39 | with: 40 | path: ./dist 41 | 42 | deploy: 43 | environment: 44 | name: github-pages 45 | url: ${{ steps.deployment.outputs.page_url }} 46 | runs-on: ubuntu-latest 47 | needs: build 48 | steps: 49 | - name: Deploy to GitHub Pages 50 | id: deployment 51 | uses: actions/deploy-pages@v4 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🌌 WebGPU Galaxy Simulation 2 | 3 | A real-time GPU-accelerated galaxy simulation using WebGPU, Three.js, and TSL (Three.js Shading Language). Experience an interactive spiral galaxy with up to 750,000 particles, dynamic bloom effects, and customizable parameters. 4 | 5 | ## ✨ Features 6 | 7 | - **GPU-Accelerated Physics** - Particle simulation runs entirely on the GPU using WebGPU compute shaders 8 | - **Interactive Controls** - Click and drag to interact with the galaxy using mouse forces 9 | - **Real-time Parameters** - Adjust galaxy properties in real-time with Tweakpane UI 10 | - **Bloom Post-Processing** - Beautiful HDR bloom effects for enhanced visuals 11 | - **Procedural Generation** - Spiral arm generation with configurable parameters 12 | - **Dust Clouds** - Realistic nebula clouds with alpha-blended particles 13 | - **Starfield Background** - Spherical starfield with color variation 14 | 15 | ## 🚀 Live Demo 16 | 17 | Visit the live demo at: `https://dgreenheck.github.io/webgpu-galaxy/` 18 | 19 | ## 🛠️ Technologies 20 | 21 | - **Three.js (WebGPU)** - 3D rendering engine with WebGPU backend 22 | - **TSL** - Three.js Shading Language for GPU compute shaders 23 | - **Vite** - Fast build tool and dev server 24 | - **Tweakpane** - UI controls for parameter adjustment 25 | 26 | ## 📋 Requirements 27 | 28 | - A browser with WebGPU support (Chrome 113+, Edge 113+, or other compatible browsers) 29 | - GPU with WebGPU capabilities 30 | 31 | ## 🏃 Getting Started 32 | 33 | ### Installation 34 | 35 | ```bash 36 | npm install 37 | ``` 38 | 39 | ### Development 40 | 41 | ```bash 42 | npm run dev 43 | ``` 44 | 45 | Open your browser to `http://localhost:5173` (or the port shown in the terminal). 46 | 47 | ### Build 48 | 49 | ```bash 50 | npm run build 51 | ``` 52 | 53 | The built files will be in the `dist` directory. 54 | 55 | ### Preview Build 56 | 57 | ```bash 58 | npm run preview 59 | ``` 60 | 61 | ## 🎮 Controls 62 | 63 | - **Left Mouse Drag** - Orbit camera around galaxy 64 | - **Right Mouse Drag** - Pan camera 65 | - **Mouse Wheel** - Zoom in/out 66 | - **Click & Drag on Galaxy** - Apply force to particles 67 | - **Right Panel** - Adjust galaxy parameters in real-time 68 | 69 | ## ⚙️ Configurable Parameters 70 | 71 | ### Galaxy Properties 72 | 73 | - Star count 74 | - Rotation speed 75 | - Spiral tightness 76 | - Arm count 77 | - Arm width 78 | - Randomness 79 | - Galaxy radius and thickness 80 | 81 | ### Visual Effects 82 | 83 | - Particle size and brightness 84 | - Color gradients (dense vs sparse regions) 85 | - Bloom strength, radius, and threshold 86 | - Cloud count, size, and opacity 87 | 88 | ### Interaction 89 | 90 | - Mouse force strength 91 | - Mouse interaction radius 92 | 93 | ## 📝 License 94 | 95 | MIT 96 | 97 | ## 🙏 Acknowledgments 98 | 99 | Built with [Three.js](https://threejs.org/) and [WebGPU](https://www.w3.org/TR/webgpu/) 100 | -------------------------------------------------------------------------------- /helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * TSL Shader Helper Functions for Galaxy Simulation 3 | * 4 | * This module contains reusable shader functions written in Three.js Shading Language (TSL). 5 | * These functions run on the GPU and handle particle physics, rotation, and positioning. 6 | */ 7 | 8 | import { 9 | vec3, 10 | float, 11 | Fn, 12 | length, 13 | normalize, 14 | sin, 15 | cos, 16 | fract 17 | } from 'three/tsl'; 18 | 19 | // ============================================================================== 20 | // RANDOM NUMBER GENERATION 21 | // ============================================================================== 22 | 23 | /** 24 | * Improved hash function for pseudo-random number generation 25 | * Avoids precision loss with large seed values by normalizing first 26 | * 27 | * @param {float} seed - Random seed value 28 | * @returns {float} - Random value between 0 and 1 29 | */ 30 | export const hash = Fn(([seed]) => { 31 | const p = fract(seed.mul(0.1031)); 32 | const h = p.add(19.19); 33 | const x = fract(h.mul(h.add(47.43)).mul(p)); 34 | return x; 35 | }); 36 | 37 | // ============================================================================== 38 | // ROTATION & PHYSICS 39 | // ============================================================================== 40 | 41 | /** 42 | * Rotates a 2D position (x, z) around the Y-axis using rotation matrix: 43 | * 44 | * | cos(θ) -sin(θ) | | x | 45 | * | sin(θ) cos(θ) | * | z | 46 | * 47 | * This is a pedagogical implementation showing the rotation matrix clearly. 48 | * 49 | * @param {vec3} position - 3D position to rotate 50 | * @param {float} angle - Rotation angle in radians (counter-clockwise) 51 | * @returns {vec3} - Rotated position 52 | */ 53 | export const rotateXZ = Fn(([position, angle]) => { 54 | const cosTheta = cos(angle); 55 | const sinTheta = sin(angle); 56 | 57 | const newX = position.x.mul(cosTheta).sub(position.z.mul(sinTheta)); 58 | const newZ = position.x.mul(sinTheta).add(position.z.mul(cosTheta)); 59 | 60 | return vec3(newX, position.y, newZ); 61 | }); 62 | 63 | /** 64 | * Applies differential rotation based on distance from center 65 | * Inner regions rotate faster than outer regions (like a real galaxy) 66 | * 67 | * The rotation factor uses: 1 / (distance * 0.1 + 1) 68 | * This creates faster rotation near the center, slower at the edges. 69 | * 70 | * @param {vec3} position - Current position 71 | * @param {float} rotationSpeed - Base rotation speed 72 | * @param {float} deltaTime - Time step 73 | * @returns {vec3} - Rotated position 74 | */ 75 | export const applyDifferentialRotation = Fn(([position, rotationSpeed, deltaTime]) => { 76 | // Calculate rotation factor: inner regions rotate faster 77 | const distFromCenter = length(vec3(position.x, 0, position.z)); 78 | const rotationFactor = float(1.0).div(distFromCenter.mul(0.1).add(1.0)); 79 | 80 | // Calculate angular speed and apply rotation 81 | const angularSpeed = rotationSpeed.mul(rotationFactor).mul(deltaTime).negate(); 82 | 83 | return rotateXZ(position, angularSpeed); 84 | }); 85 | 86 | /** 87 | * Calculates mouse interaction force 88 | * Repels particles away from mouse position with falloff based on distance 89 | * 90 | * Force = direction * strength * influence * deltaTime 91 | * Influence = 1 - (distance / radius), clamped to [0, 1] 92 | * 93 | * @param {vec3} position - Particle position 94 | * @param {vec3} mouse - Mouse world position 95 | * @param {float} mouseActive - Whether mouse is pressed (0 or 1) 96 | * @param {float} mouseForce - Strength of mouse force 97 | * @param {float} mouseRadius - Radius of mouse influence 98 | * @param {float} deltaTime - Time step 99 | * @returns {vec3} - Force vector to apply 100 | */ 101 | export const applyMouseForce = Fn(([position, mouse, mouseActive, mouseForce, mouseRadius, deltaTime]) => { 102 | const toMouse = mouse.sub(position); 103 | const distToMouse = length(toMouse); 104 | 105 | // Calculate influence with distance falloff 106 | const mouseInfluence = mouseActive.mul( 107 | float(1.0).sub(distToMouse.div(mouseRadius)).max(0.0) 108 | ); 109 | 110 | // Push particles away from mouse (negate direction) 111 | const mouseDir = normalize(toMouse); 112 | return mouseDir.mul(mouseForce).mul(mouseInfluence).mul(deltaTime).negate(); 113 | }); 114 | 115 | /** 116 | * Applies spring force to restore particle to original position 117 | * Uses Hooke's law: F = k * (target - current) 118 | * 119 | * @param {vec3} currentPos - Current particle position 120 | * @param {vec3} targetPos - Target (original) position 121 | * @param {float} strength - Spring strength constant 122 | * @param {float} deltaTime - Time step 123 | * @returns {vec3} - Force vector to apply 124 | */ 125 | export const applySpringForce = Fn(([currentPos, targetPos, strength, deltaTime]) => { 126 | const toTarget = targetPos.sub(currentPos); 127 | return toTarget.mul(strength).mul(deltaTime); 128 | }); 129 | -------------------------------------------------------------------------------- /ui.js: -------------------------------------------------------------------------------- 1 | import { Pane } from 'tweakpane'; 2 | 3 | export class GalaxyUI { 4 | constructor(config, callbacks) { 5 | this.config = config; 6 | this.callbacks = callbacks; 7 | this.pane = new Pane({ title: '🌌 Galaxy Controls' }); 8 | this.bloomPassNode = null; 9 | this.perfParams = { fps: 60 }; 10 | 11 | this.setupUI(); 12 | } 13 | 14 | setupUI() { 15 | this.setupPerformanceFolder(); 16 | this.setupAppearanceFolder(); 17 | this.setupCloudsFolder(); 18 | this.setupBloomFolder(); 19 | this.setupGalaxyFolder(); 20 | this.setupMouseFolder(); 21 | } 22 | 23 | setupPerformanceFolder() { 24 | const perfFolder = this.pane.addFolder({ title: 'Performance' }); 25 | perfFolder.addBinding(this.perfParams, 'fps', { readonly: true, label: 'FPS' }); 26 | 27 | // Star count control 28 | perfFolder.addBinding(this.config, 'starCount', { 29 | min: 1000, 30 | max: 1000000, 31 | step: 1000, 32 | label: 'Star Count' 33 | }).on('change', () => this.callbacks.onStarCountChange(this.config.starCount)); 34 | } 35 | 36 | setupAppearanceFolder() { 37 | const appearanceFolder = this.pane.addFolder({ title: 'Appearance' }); 38 | 39 | appearanceFolder.addBinding(this.config, 'particleSize', { 40 | min: 0.05, 41 | max: 0.5, 42 | step: 0.01, 43 | label: 'Star Size' 44 | }).on('change', () => this.callbacks.onUniformChange('particleSize', this.config.particleSize)); 45 | 46 | appearanceFolder.addBinding(this.config, 'starBrightness', { 47 | min: 0.0, 48 | max: 2.0, 49 | step: 0.01, 50 | label: 'Star Brightness' 51 | }).on('change', () => this.callbacks.onUniformChange('starBrightness', this.config.starBrightness)); 52 | 53 | appearanceFolder.addBinding(this.config, 'denseStarColor', { 54 | label: 'Dense Color', 55 | view: 'color' 56 | }).on('change', () => this.callbacks.onUniformChange('denseStarColor', this.config.denseStarColor)); 57 | 58 | appearanceFolder.addBinding(this.config, 'sparseStarColor', { 59 | label: 'Sparse Color', 60 | view: 'color' 61 | }).on('change', () => this.callbacks.onUniformChange('sparseStarColor', this.config.sparseStarColor)); 62 | } 63 | 64 | setupCloudsFolder() { 65 | const cloudsFolder = this.pane.addFolder({ title: 'Clouds' }); 66 | 67 | cloudsFolder.addBinding(this.config, 'cloudCount', { 68 | min: 0, 69 | max: 100000, 70 | step: 1000, 71 | label: 'Count' 72 | }).on('change', () => this.callbacks.onCloudCountChange(this.config.cloudCount)); 73 | 74 | cloudsFolder.addBinding(this.config, 'cloudSize', { 75 | min: 0.5, 76 | max: 10.0, 77 | step: 0.01, 78 | label: 'Size' 79 | }).on('change', () => this.callbacks.onUniformChange('cloudSize', this.config.cloudSize)); 80 | 81 | cloudsFolder.addBinding(this.config, 'cloudOpacity', { 82 | min: 0.0, 83 | max: 1.0, 84 | step: 0.01, 85 | label: 'Opacity' 86 | }).on('change', () => this.callbacks.onUniformChange('cloudOpacity', this.config.cloudOpacity)); 87 | 88 | cloudsFolder.addBinding(this.config, 'cloudTintColor', { 89 | label: 'Tint Color', 90 | view: 'color' 91 | }).on('change', () => this.callbacks.onCloudTintChange(this.config.cloudTintColor)); 92 | } 93 | 94 | setupBloomFolder() { 95 | const bloomFolder = this.pane.addFolder({ title: 'Bloom' }); 96 | 97 | bloomFolder.addBinding(this.config, 'bloomStrength', { 98 | min: 0, 99 | max: 3, 100 | step: 0.01, 101 | label: 'Strength' 102 | }).on('change', () => this.callbacks.onBloomChange('strength', this.config.bloomStrength)); 103 | 104 | bloomFolder.addBinding(this.config, 'bloomRadius', { 105 | min: 0, 106 | max: 1, 107 | step: 0.01, 108 | label: 'Radius' 109 | }).on('change', () => this.callbacks.onBloomChange('radius', this.config.bloomRadius)); 110 | 111 | bloomFolder.addBinding(this.config, 'bloomThreshold', { 112 | min: 0, 113 | max: 1, 114 | step: 0.01, 115 | label: 'Threshold' 116 | }).on('change', () => this.callbacks.onBloomChange('threshold', this.config.bloomThreshold)); 117 | } 118 | 119 | setupGalaxyFolder() { 120 | const galaxyFolder = this.pane.addFolder({ title: 'Galaxy Structure' }); 121 | 122 | galaxyFolder.addBinding(this.config, 'rotationSpeed', { 123 | min: 0, 124 | max: 2, 125 | step: 0.01, 126 | label: 'Rotation Speed' 127 | }).on('change', () => this.callbacks.onUniformChange('rotationSpeed', this.config.rotationSpeed)); 128 | 129 | galaxyFolder.addBinding(this.config, 'spiralTightness', { 130 | min: 0, 131 | max: 10, 132 | step: 0.01, 133 | label: 'Spiral Tightness' 134 | }).on('change', () => this.callbacks.onRegenerate()); 135 | 136 | galaxyFolder.addBinding(this.config, 'armCount', { 137 | min: 1, 138 | max: 4, 139 | step: 1, 140 | label: 'Arm Count' 141 | }).on('change', () => this.callbacks.onRegenerate()); 142 | 143 | galaxyFolder.addBinding(this.config, 'armWidth', { 144 | min: 1, 145 | max: 5, 146 | step: 0.01, 147 | label: 'Arm Width' 148 | }).on('change', () => this.callbacks.onRegenerate()); 149 | 150 | galaxyFolder.addBinding(this.config, 'randomness', { 151 | min: 0, 152 | max: 5, 153 | step: 0.01, 154 | label: 'Randomness' 155 | }).on('change', () => this.callbacks.onRegenerate()); 156 | 157 | galaxyFolder.addBinding(this.config, 'galaxyRadius', { 158 | min: 5, 159 | max: 20, 160 | step: 0.01, 161 | label: 'Galaxy Radius' 162 | }).on('change', () => this.callbacks.onRegenerate()); 163 | 164 | galaxyFolder.addBinding(this.config, 'galaxyThickness', { 165 | min: 0.1, 166 | max: 10, 167 | step: 0.01, 168 | label: 'Thickness' 169 | }).on('change', () => this.callbacks.onRegenerate()); 170 | } 171 | 172 | setupMouseFolder() { 173 | const mouseFolder = this.pane.addFolder({ title: 'Mouse Interaction' }); 174 | 175 | mouseFolder.addBinding(this.config, 'mouseForce', { 176 | min: 0, 177 | max: 10, 178 | step: 0.01, 179 | label: 'Force' 180 | }).on('change', () => this.callbacks.onUniformChange('mouseForce', this.config.mouseForce)); 181 | 182 | mouseFolder.addBinding(this.config, 'mouseRadius', { 183 | min: 1, 184 | max: 15, 185 | step: 0.01, 186 | label: 'Radius' 187 | }).on('change', () => this.callbacks.onUniformChange('mouseRadius', this.config.mouseRadius)); 188 | } 189 | 190 | updateFPS(fps) { 191 | this.perfParams.fps = fps; 192 | this.pane.refresh(); 193 | } 194 | 195 | setBloomNode(bloomNode) { 196 | this.bloomPassNode = bloomNode; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three/webgpu'; 2 | import { pass } from 'three/tsl'; 3 | import { bloom } from 'three/addons/tsl/display/BloomNode.js'; 4 | import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; 5 | import { GalaxySimulation } from './galaxy.js'; 6 | import { GalaxyUI } from './ui.js'; 7 | 8 | // Configuration 9 | const config = { 10 | starCount: 750000, 11 | rotationSpeed: 0.1, 12 | spiralTightness: 1.75, 13 | mouseForce: 7.0, 14 | mouseRadius: 10.0, 15 | galaxyRadius: 13.0, 16 | galaxyThickness: 3, 17 | armCount: 2, 18 | armWidth: 2.25, 19 | randomness: 1.8, 20 | particleSize: 0.06, 21 | starBrightness: 0.3, 22 | denseStarColor: '#1885ff', 23 | sparseStarColor: '#ffb28a', 24 | bloomStrength: 0.2, 25 | bloomRadius: 0.2, 26 | bloomThreshold: 0.1, 27 | cloudCount: 5000, 28 | cloudSize: 3, 29 | cloudOpacity: 0.02, 30 | cloudTintColor: '#ffdace' 31 | }; 32 | 33 | // Scene setup 34 | const scene = new THREE.Scene(); 35 | scene.background = new THREE.Color(0x000000); 36 | 37 | const camera = new THREE.PerspectiveCamera( 38 | 60, 39 | window.innerWidth / window.innerHeight, 40 | 0.1, 41 | 1000 42 | ); 43 | camera.position.set(0, 12, 17); 44 | camera.lookAt(0, 0, 0); 45 | 46 | const renderer = new THREE.WebGPURenderer({ antialias: true }); 47 | renderer.setSize(window.innerWidth, window.innerHeight); 48 | renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 49 | document.body.appendChild(renderer.domElement); 50 | 51 | // Orbit controls 52 | const controls = new OrbitControls(camera, renderer.domElement); 53 | controls.enableDamping = true; 54 | controls.dampingFactor = 0.05; 55 | controls.minDistance = 5; 56 | controls.maxDistance = 30; 57 | controls.target.set(0, -2, 0); 58 | 59 | // Post-processing 60 | let postProcessing = null; 61 | let bloomPassNode = null; 62 | 63 | // Mouse tracking 64 | const mouse3D = new THREE.Vector3(0, 0, 0); 65 | const raycaster = new THREE.Raycaster(); 66 | const intersectionPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); 67 | let mousePressed = false; 68 | 69 | window.addEventListener('mousedown', () => mousePressed = true); 70 | window.addEventListener('mouseup', () => mousePressed = false); 71 | window.addEventListener('mousemove', (event) => { 72 | const mouse = new THREE.Vector2( 73 | (event.clientX / window.innerWidth) * 2 - 1, 74 | -(event.clientY / window.innerHeight) * 2 + 1 75 | ); 76 | raycaster.setFromCamera(mouse, camera); 77 | raycaster.ray.intersectPlane(intersectionPlane, mouse3D); 78 | }); 79 | 80 | /** 81 | * Creates a starry background with random colored stars distributed on a sphere 82 | * @param {THREE.Scene} scene - Scene to add stars to 83 | * @param {number} count - Number of background stars 84 | * @returns {THREE.Points} - The star points object 85 | */ 86 | function createStarryBackground(scene, count = 5000) { 87 | const starGeometry = new THREE.BufferGeometry(); 88 | const starPositions = new Float32Array(count * 3); 89 | const starColors = new Float32Array(count * 3); 90 | 91 | // Distribute stars randomly on a sphere 92 | for (let i = 0; i < count; i++) { 93 | // Spherical coordinates for uniform distribution 94 | const theta = Math.random() * Math.PI * 2; 95 | const phi = Math.acos(2 * Math.random() - 1); 96 | const radius = 100 + Math.random() * 100; 97 | 98 | // Convert to Cartesian coordinates 99 | starPositions[i * 3] = radius * Math.sin(phi) * Math.cos(theta); 100 | starPositions[i * 3 + 1] = radius * Math.sin(phi) * Math.sin(theta); 101 | starPositions[i * 3 + 2] = radius * Math.cos(phi); 102 | 103 | // Add color variation (mostly white, some blue/orange tinted) 104 | const color = 0.8 + Math.random() * 0.2; 105 | const tint = Math.random(); 106 | if (tint < 0.1) { 107 | // Blue tint 108 | starColors[i * 3] = color * 0.8; 109 | starColors[i * 3 + 1] = color * 0.9; 110 | starColors[i * 3 + 2] = color; 111 | } else if (tint < 0.2) { 112 | // Orange tint 113 | starColors[i * 3] = color; 114 | starColors[i * 3 + 1] = color * 0.8; 115 | starColors[i * 3 + 2] = color * 0.6; 116 | } else { 117 | // White 118 | starColors[i * 3] = color; 119 | starColors[i * 3 + 1] = color; 120 | starColors[i * 3 + 2] = color; 121 | } 122 | } 123 | 124 | starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3)); 125 | starGeometry.setAttribute('color', new THREE.BufferAttribute(starColors, 3)); 126 | 127 | const starMaterial = new THREE.PointsMaterial({ 128 | size: 0.3, 129 | vertexColors: true, 130 | transparent: true, 131 | opacity: 0.8, 132 | sizeAttenuation: true 133 | }); 134 | 135 | const stars = new THREE.Points(starGeometry, starMaterial); 136 | scene.add(stars); 137 | 138 | return stars; 139 | } 140 | 141 | // Preload cloud texture 142 | const textureLoader = new THREE.TextureLoader(); 143 | const cloudTexture = textureLoader.load('cloud.png'); 144 | 145 | // Create galaxy simulation with preloaded texture 146 | const galaxySimulation = new GalaxySimulation(scene, config, cloudTexture); 147 | galaxySimulation.createGalaxySystem(); 148 | galaxySimulation.createClouds(); 149 | 150 | // Create starry background 151 | createStarryBackground(scene); 152 | 153 | // Setup bloom 154 | function setupBloom() { 155 | if (!postProcessing) return; 156 | 157 | const scenePass = pass(scene, camera); 158 | const scenePassColor = scenePass.getTextureNode(); 159 | 160 | bloomPassNode = bloom(scenePassColor); 161 | bloomPassNode.threshold.value = config.bloomThreshold; 162 | bloomPassNode.strength.value = config.bloomStrength; 163 | bloomPassNode.radius.value = config.bloomRadius; 164 | 165 | postProcessing.outputNode = scenePassColor.add(bloomPassNode); 166 | } 167 | 168 | // Create UI with callbacks 169 | const ui = new GalaxyUI(config, { 170 | onUniformChange: (key, value) => galaxySimulation.updateUniforms({ [key]: value }), 171 | 172 | onBloomChange: (property, value) => { 173 | if (bloomPassNode) bloomPassNode[property].value = value; 174 | }, 175 | 176 | onStarCountChange: (newCount) => { 177 | galaxySimulation.updateStarCount(newCount); 178 | document.getElementById('star-count').textContent = newCount.toLocaleString(); 179 | }, 180 | 181 | onCloudCountChange: (newCount) => { 182 | galaxySimulation.updateUniforms({ cloudCount: newCount }); 183 | galaxySimulation.createClouds(); 184 | }, 185 | 186 | onCloudTintChange: (color) => { 187 | galaxySimulation.updateUniforms({ cloudTintColor: color }); 188 | galaxySimulation.createClouds(); 189 | }, 190 | 191 | onRegenerate: () => { 192 | galaxySimulation.updateUniforms(config); 193 | galaxySimulation.createClouds(); 194 | galaxySimulation.regenerate(); 195 | } 196 | }); 197 | 198 | // FPS counter 199 | let frameCount = 0; 200 | let lastTime = performance.now(); 201 | let fps = 60; 202 | 203 | function updateFPS() { 204 | frameCount++; 205 | const currentTime = performance.now(); 206 | const deltaTime = currentTime - lastTime; 207 | 208 | if (deltaTime >= 1000) { 209 | fps = Math.round((frameCount * 1000) / deltaTime); 210 | frameCount = 0; 211 | lastTime = currentTime; 212 | 213 | document.getElementById('fps').textContent = fps; 214 | ui.updateFPS(fps); 215 | } 216 | } 217 | 218 | // Animation loop 219 | let lastFrameTime = performance.now(); 220 | 221 | async function animate() { 222 | requestAnimationFrame(animate); 223 | 224 | const currentTime = performance.now(); 225 | const deltaTime = Math.min((currentTime - lastFrameTime) / 1000, 0.033); 226 | lastFrameTime = currentTime; 227 | 228 | // Update controls 229 | controls.update(); 230 | 231 | // Update galaxy 232 | await galaxySimulation.update(renderer, deltaTime, mouse3D, mousePressed); 233 | 234 | // Render 235 | if (postProcessing) { 236 | postProcessing.render(); 237 | } else { 238 | renderer.render(scene, camera); 239 | } 240 | 241 | updateFPS(); 242 | } 243 | 244 | // Handle resize 245 | window.addEventListener('resize', () => { 246 | camera.aspect = window.innerWidth / window.innerHeight; 247 | camera.updateProjectionMatrix(); 248 | renderer.setSize(window.innerWidth, window.innerHeight); 249 | }); 250 | 251 | // Initialize 252 | renderer.init().then(() => { 253 | postProcessing = new THREE.PostProcessing(renderer); 254 | setupBloom(); 255 | ui.setBloomNode(bloomPassNode); 256 | 257 | document.getElementById('star-count').textContent = config.starCount.toLocaleString(); 258 | animate(); 259 | }).catch(err => { 260 | console.error('Failed to initialize renderer:', err); 261 | }); 262 | -------------------------------------------------------------------------------- /galaxy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Galaxy Simulation - Main Orchestrator 3 | * 4 | * This module contains the main GalaxySimulation class that manages: 5 | * - Uniform initialization and updates 6 | * - Star particle system creation and physics 7 | * - Cloud particle system to simulate dust 8 | * - WebGPU compute shader execution 9 | */ 10 | 11 | import * as THREE from 'three/webgpu'; 12 | import { 13 | uniform, 14 | instancedArray, 15 | instanceIndex, 16 | vec3, 17 | vec4, 18 | float, 19 | Fn, 20 | mix, 21 | length, 22 | sin, 23 | cos, 24 | uv, 25 | smoothstep, 26 | texture 27 | } from 'three/tsl'; 28 | 29 | import { 30 | hash, 31 | applyDifferentialRotation, 32 | applyMouseForce, 33 | applySpringForce 34 | } from './helpers.js'; 35 | 36 | /** 37 | * Spiral Galaxy Position Generation 38 | * 39 | * Note: TSL Fn() functions can only return single TSL types (vec3, float, etc.), 40 | * not JavaScript objects. Since spiral position generation needs to return multiple 41 | * values (position, normalizedRadius, angle, etc.), we inline this logic in both 42 | * the star and cloud initialization shaders below. 43 | * 44 | * The pattern is consistent between stars and clouds: 45 | * 1. Generate radius using hash with power function (controls distribution) 46 | * 2. Select spiral arm and calculate spiral angle 47 | * 3. Add randomness for natural appearance 48 | * 4. Convert to Cartesian coordinates 49 | * 5. Apply vertical thickness (thicker at center, thinner at edges) 50 | */ 51 | 52 | // ============================================================================== 53 | // GALAXY SIMULATION CLASS 54 | // ============================================================================== 55 | 56 | /** 57 | * GPU-accelerated galaxy simulation with stars and dust clouds 58 | * Uses WebGPU compute shaders for particle physics and rendering 59 | */ 60 | export class GalaxySimulation { 61 | constructor(scene, config, cloudTexture = null) { 62 | this.scene = scene; 63 | this.config = config; 64 | this.COUNT = config.starCount; 65 | this.cloudTexture = cloudTexture; 66 | 67 | // Storage buffers 68 | this.spawnPositionBuffer = null; 69 | this.originalPositionBuffer = null; 70 | this.velocityBuffer = null; 71 | this.densityFactorBuffer = null; 72 | 73 | // Compute shaders 74 | this.computeInit = null; 75 | this.computeUpdate = null; 76 | this.cloudInit = null; 77 | this.cloudUpdate = null; 78 | 79 | // Scene objects 80 | this.galaxy = null; 81 | this.cloudPlane = null; 82 | 83 | // Initialize uniforms organized by category 84 | this.initializeUniforms(config); 85 | 86 | // State 87 | this.initialized = false; 88 | this.cloudInitialized = false; 89 | } 90 | 91 | /** 92 | * Initialize all shader uniforms organized into logical groups 93 | */ 94 | initializeUniforms(config) { 95 | // Compute state uniforms (time, mouse interaction) 96 | this.uniforms = { 97 | compute: { 98 | time: uniform(0), 99 | deltaTime: uniform(0.016), 100 | mouse: uniform(new THREE.Vector3(0, 0, 0)), 101 | mouseActive: uniform(0.0), 102 | mouseForce: uniform(config.mouseForce), 103 | mouseRadius: uniform(config.mouseRadius), 104 | rotationSpeed: uniform(config.rotationSpeed) 105 | }, 106 | 107 | // Galaxy structure uniforms (shape, size, distribution) 108 | galaxy: { 109 | radius: uniform(config.galaxyRadius), 110 | thickness: uniform(config.galaxyThickness || 0.1), 111 | spiralTightness: uniform(config.spiralTightness), 112 | armCount: uniform(config.armCount), 113 | armWidth: uniform(config.armWidth), 114 | randomness: uniform(config.randomness) 115 | }, 116 | 117 | // Visual appearance uniforms (colors, sizes, opacity) 118 | visual: { 119 | particleSize: uniform(config.particleSize), 120 | cloudSize: uniform(config.cloudSize), 121 | cloudOpacity: uniform(config.cloudOpacity !== undefined ? config.cloudOpacity : 0.5), 122 | starBrightness: uniform(config.starBrightness !== undefined ? config.starBrightness : 1.0), 123 | denseStarColor: uniform(new THREE.Color(config.denseStarColor || '#99ccff')), 124 | sparseStarColor: uniform(new THREE.Color(config.sparseStarColor || '#ffb380')), 125 | cloudTintColor: uniform(new THREE.Color(config.cloudTintColor || '#6ba8cc')) 126 | } 127 | }; 128 | } 129 | 130 | /** 131 | * Creates the star particle system with spiral galaxy structure 132 | */ 133 | createGalaxySystem() { 134 | // Clean up old galaxy 135 | if (this.galaxy) { 136 | this.scene.remove(this.galaxy); 137 | if (this.galaxy.material) { 138 | this.galaxy.material.dispose(); 139 | } 140 | } 141 | 142 | // Create storage buffers for star particles 143 | this.spawnPositionBuffer = instancedArray(this.COUNT, 'vec3'); 144 | this.originalPositionBuffer = instancedArray(this.COUNT, 'vec3'); 145 | this.velocityBuffer = instancedArray(this.COUNT, 'vec3'); 146 | this.densityFactorBuffer = instancedArray(this.COUNT, 'float'); 147 | 148 | // Initialize stars with spiral arm distribution 149 | this.computeInit = Fn(() => { 150 | const idx = instanceIndex; 151 | const seed = idx.toFloat(); 152 | 153 | // Distance from center (square root for even distribution) 154 | const radius = hash(seed.add(1)).pow(0.5).mul(this.uniforms.galaxy.radius); 155 | const normalizedRadius = radius.div(this.uniforms.galaxy.radius); 156 | 157 | // Choose which spiral arm this particle belongs to 158 | const armIndex = hash(seed.add(2)).mul(this.uniforms.galaxy.armCount).floor(); 159 | const armAngle = armIndex.mul(6.28318).div(this.uniforms.galaxy.armCount); 160 | 161 | // Spiral angle based on distance (logarithmic spiral) 162 | const spiralAngle = normalizedRadius.mul(this.uniforms.galaxy.spiralTightness).mul(6.28318); 163 | 164 | // Add randomness to create natural appearance 165 | const angleOffset = hash(seed.add(3)).sub(0.5).mul(this.uniforms.galaxy.randomness); 166 | const radiusOffset = hash(seed.add(4)).sub(0.5).mul(this.uniforms.galaxy.armWidth); 167 | 168 | // Final angle and radius 169 | const angle = armAngle.add(spiralAngle).add(angleOffset); 170 | const offsetRadius = radius.add(radiusOffset); 171 | 172 | // Convert to Cartesian coordinates 173 | const x = cos(angle).mul(offsetRadius); 174 | const z = sin(angle).mul(offsetRadius); 175 | 176 | // Vertical position: thicker at center, thinner at edges 177 | const thicknessFactor = float(1.0).sub(normalizedRadius).add(0.2); // 1.2 at center, 0.2 at edge 178 | const y = hash(seed.add(5)).sub(0.5).mul(this.uniforms.galaxy.thickness).mul(thicknessFactor); 179 | 180 | const position = vec3(x, y, z); 181 | 182 | // Store initial positions 183 | this.spawnPositionBuffer.element(idx).assign(position); 184 | this.originalPositionBuffer.element(idx).assign(position); 185 | 186 | // Calculate orbital velocity (faster closer to center) 187 | const orbitalSpeed = float(1.0).div(offsetRadius.add(0.5)).mul(5.0); 188 | const vx = sin(angle).mul(orbitalSpeed).negate(); 189 | const vz = cos(angle).mul(orbitalSpeed); 190 | this.velocityBuffer.element(idx).assign(vec3(vx, 0, vz)); 191 | 192 | // Calculate density factor for coloring (0 = dense/center, 1 = sparse/edge) 193 | const radialSparsity = radiusOffset.abs().div(this.uniforms.galaxy.armWidth.mul(0.5).add(0.01)); 194 | const angularSparsity = angleOffset.abs().div(this.uniforms.galaxy.randomness.mul(0.5).add(0.01)); 195 | const sparsityFactor = radialSparsity.add(angularSparsity).mul(0.5).min(1.0); 196 | 197 | this.densityFactorBuffer.element(idx).assign(sparsityFactor); 198 | })().compute(this.COUNT); 199 | 200 | // Update shader: applies rotation, mouse interaction, and spring forces 201 | this.computeUpdate = Fn(() => { 202 | const idx = instanceIndex; 203 | const position = this.spawnPositionBuffer.element(idx).toVar(); 204 | const originalPos = this.originalPositionBuffer.element(idx); 205 | 206 | // Apply differential rotation 207 | const rotatedPos = applyDifferentialRotation( 208 | position, 209 | this.uniforms.compute.rotationSpeed, 210 | this.uniforms.compute.deltaTime 211 | ); 212 | position.assign(rotatedPos); 213 | 214 | // Rotate original position to maintain spring force target 215 | const rotatedOriginal = applyDifferentialRotation( 216 | originalPos, 217 | this.uniforms.compute.rotationSpeed, 218 | this.uniforms.compute.deltaTime 219 | ); 220 | this.originalPositionBuffer.element(idx).assign(rotatedOriginal); 221 | 222 | // Apply mouse repulsion force 223 | const mouseForce = applyMouseForce( 224 | position, 225 | this.uniforms.compute.mouse, 226 | this.uniforms.compute.mouseActive, 227 | this.uniforms.compute.mouseForce, 228 | this.uniforms.compute.mouseRadius, 229 | this.uniforms.compute.deltaTime 230 | ); 231 | position.addAssign(mouseForce); 232 | 233 | // Apply spring force to restore to original position 234 | const springForce = applySpringForce( 235 | position, 236 | rotatedOriginal, 237 | float(2.0), // Spring strength 238 | this.uniforms.compute.deltaTime 239 | ); 240 | position.addAssign(springForce); 241 | 242 | this.spawnPositionBuffer.element(idx).assign(position); 243 | })().compute(this.COUNT); 244 | 245 | // Create star visualization material 246 | const spriteMaterial = new THREE.SpriteNodeMaterial(); 247 | spriteMaterial.transparent = false; 248 | spriteMaterial.depthWrite = false; 249 | spriteMaterial.blending = THREE.AdditiveBlending; 250 | 251 | const starPos = this.spawnPositionBuffer.toAttribute(); 252 | const densityFactor = this.densityFactorBuffer.toAttribute(); 253 | 254 | // Smooth circular star shape 255 | const circleShape = Fn(() => { 256 | const center = uv().sub(0.5).mul(2.0); 257 | const dist = length(center); 258 | const alpha = smoothstep(1.0, 0.0, dist).mul(smoothstep(1.0, 0.3, dist)); 259 | return alpha; 260 | })(); 261 | 262 | // Color based on density: blue for dense regions, orange for sparse 263 | const starColorNode = mix( 264 | vec3(this.uniforms.visual.denseStarColor), 265 | vec3(this.uniforms.visual.sparseStarColor), 266 | densityFactor 267 | ).mul(this.uniforms.visual.starBrightness); 268 | 269 | spriteMaterial.positionNode = starPos; 270 | spriteMaterial.colorNode = vec4(starColorNode.x, starColorNode.y, starColorNode.z, float(1.0)); 271 | spriteMaterial.opacityNode = circleShape; 272 | spriteMaterial.scaleNode = this.uniforms.visual.particleSize; 273 | 274 | this.galaxy = new THREE.Sprite(spriteMaterial); 275 | this.galaxy.count = this.COUNT; 276 | this.galaxy.frustumCulled = false; 277 | 278 | this.scene.add(this.galaxy); 279 | } 280 | 281 | /** 282 | * Creates cloud particles that follow the galaxy structure 283 | */ 284 | createClouds() { 285 | // Clean up old clouds 286 | if (this.cloudPlane) { 287 | this.scene.remove(this.cloudPlane); 288 | if (this.cloudPlane.material) this.cloudPlane.material.dispose(); 289 | } 290 | 291 | const CLOUD_COUNT = this.config.cloudCount; 292 | 293 | // Create cloud particle buffers 294 | const cloudPositionBuffer = instancedArray(CLOUD_COUNT, 'vec3'); 295 | const cloudOriginalPositionBuffer = instancedArray(CLOUD_COUNT, 'vec3'); 296 | const cloudColorBuffer = instancedArray(CLOUD_COUNT, 'vec3'); 297 | const cloudSizeBuffer = instancedArray(CLOUD_COUNT, 'float'); 298 | const cloudRotationBuffer = instancedArray(CLOUD_COUNT, 'float'); 299 | 300 | // Initialize cloud particles 301 | this.cloudInit = Fn(() => { 302 | const idx = instanceIndex; 303 | const seed = idx.toFloat().add(10000); // Offset seed from stars 304 | 305 | // Distance from center (power = 0.7 for more even distribution to avoid center oversaturation) 306 | const radius = hash(seed.add(1)).pow(0.7).mul(this.uniforms.galaxy.radius); 307 | const normalizedRadius = radius.div(this.uniforms.galaxy.radius); 308 | 309 | // Choose spiral arm 310 | const armIndex = hash(seed.add(2)).mul(this.uniforms.galaxy.armCount).floor(); 311 | const armAngle = armIndex.mul(6.28318).div(this.uniforms.galaxy.armCount); 312 | 313 | // Spiral angle based on distance (logarithmic spiral) 314 | const spiralAngle = normalizedRadius.mul(this.uniforms.galaxy.spiralTightness).mul(6.28318); 315 | 316 | // Add randomness (same pattern as stars) 317 | const angleOffset = hash(seed.add(3)).sub(0.5).mul(this.uniforms.galaxy.randomness); 318 | const radiusOffset = hash(seed.add(4)).sub(0.5).mul(this.uniforms.galaxy.armWidth); 319 | 320 | // Final angle and radius 321 | const angle = armAngle.add(spiralAngle).add(angleOffset); 322 | const offsetRadius = radius.add(radiusOffset); 323 | 324 | // Convert to Cartesian coordinates 325 | const x = cos(angle).mul(offsetRadius); 326 | const z = sin(angle).mul(offsetRadius); 327 | 328 | // Vertical position: slightly thinner than stars 329 | const thicknessFactor = float(1.0).sub(normalizedRadius).add(0.15); // 1.15 at center, 0.15 at edge 330 | const y = hash(seed.add(5)).sub(0.5).mul(this.uniforms.galaxy.thickness).mul(thicknessFactor); 331 | 332 | const position = vec3(x, y, z); 333 | 334 | // Store positions 335 | cloudPositionBuffer.element(idx).assign(position); 336 | cloudOriginalPositionBuffer.element(idx).assign(position); 337 | 338 | // Cloud color: tinted and darker towards edges 339 | const tintColor = vec3(this.uniforms.visual.cloudTintColor); 340 | const cloudColor = tintColor.mul(float(1.0).sub(normalizedRadius.mul(0.3))); 341 | cloudColorBuffer.element(idx).assign(cloudColor); 342 | 343 | // Size variation: larger clouds in denser regions 344 | const densityFactor = float(1.0).sub(normalizedRadius.mul(0.5)); 345 | const size = hash(seed.add(6)).mul(0.5).add(0.7).mul(densityFactor); 346 | cloudSizeBuffer.element(idx).assign(size); 347 | 348 | // Random rotation for visual variation 349 | const rotation = hash(seed.add(7)).mul(6.28318); // 0 to 2π 350 | cloudRotationBuffer.element(idx).assign(rotation); 351 | })().compute(CLOUD_COUNT); 352 | 353 | // Update cloud particles (same physics as stars but weaker spring) 354 | this.cloudUpdate = Fn(() => { 355 | const idx = instanceIndex; 356 | const position = cloudPositionBuffer.element(idx).toVar(); 357 | const originalPos = cloudOriginalPositionBuffer.element(idx); 358 | 359 | // Apply differential rotation 360 | const rotatedPos = applyDifferentialRotation( 361 | position, 362 | this.uniforms.compute.rotationSpeed, 363 | this.uniforms.compute.deltaTime 364 | ); 365 | position.assign(rotatedPos); 366 | 367 | // Rotate original position 368 | const rotatedOriginal = applyDifferentialRotation( 369 | originalPos, 370 | this.uniforms.compute.rotationSpeed, 371 | this.uniforms.compute.deltaTime 372 | ); 373 | cloudOriginalPositionBuffer.element(idx).assign(rotatedOriginal); 374 | 375 | // Apply mouse force 376 | const mouseForce = applyMouseForce( 377 | position, 378 | this.uniforms.compute.mouse, 379 | this.uniforms.compute.mouseActive, 380 | this.uniforms.compute.mouseForce, 381 | this.uniforms.compute.mouseRadius, 382 | this.uniforms.compute.deltaTime 383 | ); 384 | position.addAssign(mouseForce); 385 | 386 | // Apply spring force (weaker than stars for more fluid movement) 387 | const springForce = applySpringForce( 388 | position, 389 | rotatedOriginal, 390 | float(1.0), // Weaker spring strength 391 | this.uniforms.compute.deltaTime 392 | ); 393 | position.addAssign(springForce); 394 | 395 | cloudPositionBuffer.element(idx).assign(position); 396 | })().compute(CLOUD_COUNT); 397 | 398 | // Store cloud state 399 | this.cloudCount = CLOUD_COUNT; 400 | 401 | // Create cloud sprite material 402 | const cloudMaterial = new THREE.SpriteNodeMaterial(); 403 | cloudMaterial.transparent = true; 404 | cloudMaterial.depthWrite = false; 405 | cloudMaterial.blending = THREE.AdditiveBlending; // Efficient for overlapping particles 406 | 407 | const cloudPos = cloudPositionBuffer.toAttribute(); 408 | const cloudColor = cloudColorBuffer.toAttribute(); 409 | const cloudSize = cloudSizeBuffer.toAttribute(); 410 | const cloudRotation = cloudRotationBuffer.toAttribute(); 411 | 412 | cloudMaterial.positionNode = cloudPos; 413 | cloudMaterial.colorNode = vec4(cloudColor.x, cloudColor.y, cloudColor.z, float(1.0)); 414 | cloudMaterial.scaleNode = cloudSize.mul(this.uniforms.visual.cloudSize); 415 | cloudMaterial.rotationNode = cloudRotation; 416 | 417 | // Use texture for soft cloud appearance 418 | if (this.cloudTexture) { 419 | const cloudTextureNode = texture(this.cloudTexture, uv()); 420 | cloudMaterial.opacityNode = cloudTextureNode.a.mul(this.uniforms.visual.cloudOpacity); 421 | } else { 422 | cloudMaterial.opacityNode = this.uniforms.visual.cloudOpacity; 423 | } 424 | 425 | this.cloudPlane = new THREE.Sprite(cloudMaterial); 426 | this.cloudPlane.count = CLOUD_COUNT; 427 | this.cloudPlane.frustumCulled = false; 428 | this.cloudPlane.renderOrder = -1; // Render clouds before stars 429 | 430 | this.scene.add(this.cloudPlane); 431 | 432 | // Reset initialization flag so clouds get initialized on next update 433 | this.cloudInitialized = false; 434 | } 435 | 436 | /** 437 | * Updates star count and regenerates galaxy 438 | */ 439 | updateStarCount(newCount) { 440 | this.COUNT = newCount; 441 | this.config.starCount = newCount; 442 | this.createGalaxySystem(); 443 | this.initialized = false; 444 | } 445 | 446 | /** 447 | * Updates uniform values from config changes 448 | */ 449 | updateUniforms(configUpdate) { 450 | // Galaxy structure uniforms 451 | if (configUpdate.galaxyRadius !== undefined) 452 | this.uniforms.galaxy.radius.value = configUpdate.galaxyRadius; 453 | if (configUpdate.galaxyThickness !== undefined) 454 | this.uniforms.galaxy.thickness.value = configUpdate.galaxyThickness; 455 | if (configUpdate.spiralTightness !== undefined) 456 | this.uniforms.galaxy.spiralTightness.value = configUpdate.spiralTightness; 457 | if (configUpdate.armCount !== undefined) 458 | this.uniforms.galaxy.armCount.value = configUpdate.armCount; 459 | if (configUpdate.armWidth !== undefined) 460 | this.uniforms.galaxy.armWidth.value = configUpdate.armWidth; 461 | if (configUpdate.randomness !== undefined) 462 | this.uniforms.galaxy.randomness.value = configUpdate.randomness; 463 | 464 | // Compute uniforms 465 | if (configUpdate.rotationSpeed !== undefined) 466 | this.uniforms.compute.rotationSpeed.value = configUpdate.rotationSpeed; 467 | if (configUpdate.mouseForce !== undefined) 468 | this.uniforms.compute.mouseForce.value = configUpdate.mouseForce; 469 | if (configUpdate.mouseRadius !== undefined) 470 | this.uniforms.compute.mouseRadius.value = configUpdate.mouseRadius; 471 | 472 | // Visual uniforms 473 | if (configUpdate.particleSize !== undefined) 474 | this.uniforms.visual.particleSize.value = configUpdate.particleSize; 475 | if (configUpdate.cloudSize !== undefined) 476 | this.uniforms.visual.cloudSize.value = configUpdate.cloudSize; 477 | if (configUpdate.cloudOpacity !== undefined) 478 | this.uniforms.visual.cloudOpacity.value = configUpdate.cloudOpacity; 479 | if (configUpdate.starBrightness !== undefined) 480 | this.uniforms.visual.starBrightness.value = configUpdate.starBrightness; 481 | if (configUpdate.denseStarColor !== undefined) 482 | this.uniforms.visual.denseStarColor.value.set(configUpdate.denseStarColor); 483 | if (configUpdate.sparseStarColor !== undefined) 484 | this.uniforms.visual.sparseStarColor.value.set(configUpdate.sparseStarColor); 485 | if (configUpdate.cloudTintColor !== undefined) 486 | this.uniforms.visual.cloudTintColor.value.set(configUpdate.cloudTintColor); 487 | 488 | // Config state 489 | if (configUpdate.cloudCount !== undefined) { 490 | this.config.cloudCount = configUpdate.cloudCount; 491 | } 492 | } 493 | 494 | /** 495 | * Main update loop - runs compute shaders and updates uniforms 496 | */ 497 | async update(renderer, deltaTime, mouse3D, mousePressed) { 498 | // Initialize stars on first frame 499 | if (!this.initialized) { 500 | await renderer.computeAsync(this.computeInit); 501 | this.initialized = true; 502 | } 503 | 504 | // Initialize clouds on first frame 505 | if (!this.cloudInitialized && this.cloudInit) { 506 | await renderer.computeAsync(this.cloudInit); 507 | this.cloudInitialized = true; 508 | } 509 | 510 | // Update compute uniforms 511 | this.uniforms.compute.time.value += deltaTime; 512 | this.uniforms.compute.deltaTime.value = deltaTime; 513 | this.uniforms.compute.mouse.value.copy(mouse3D); 514 | this.uniforms.compute.mouseActive.value = mousePressed ? 1.0 : 0.0; 515 | 516 | // Run physics computations 517 | await renderer.computeAsync(this.computeUpdate); 518 | 519 | if (this.cloudUpdate) { 520 | await renderer.computeAsync(this.cloudUpdate); 521 | } 522 | } 523 | 524 | /** 525 | * Marks galaxy for regeneration on next update 526 | */ 527 | regenerate() { 528 | this.initialized = false; 529 | this.cloudInitialized = false; 530 | } 531 | } 532 | --------------------------------------------------------------------------------