├── 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 |
--------------------------------------------------------------------------------