| <div class="threejs-galaxy" style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:260px;"></div> |
| <script type="importmap"> |
| { |
| "imports": { |
| "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js", |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/" |
| } |
| } |
| </script> |
| <style> |
| .threejs-galaxy { |
| overflow: visible; |
| background: transparent; |
| } |
| |
| .threejs-galaxy canvas { |
| display: block; |
| width: 100%; |
| height: 100%; |
| } |
| |
| .threejs-galaxy .tp-dfwv { |
| position: absolute !important; |
| top: 16px !important; |
| right: 16px !important; |
| z-index: 100 !important; |
| } |
| </style> |
| <script type="module"> |
| import * as THREE from 'three'; |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; |
| import { Pane } from 'https://cdn.jsdelivr.net/npm/tweakpane@4.0.3/dist/tweakpane.js'; |
| |
| const container = document.querySelector('.threejs-galaxy'); |
| if (!container || container.dataset.mounted === 'true') { |
| if (container) console.log('Container already mounted'); |
| } else { |
| container.dataset.mounted = 'true'; |
| |
| |
| const scene = new THREE.Scene(); |
| |
| const camera = new THREE.PerspectiveCamera( |
| 35, |
| container.clientWidth / Math.max(260, Math.round(container.clientWidth / 3)), |
| 0.1, |
| 100 |
| ); |
| |
| camera.position.set(-0.03, 1.75, 5.71); |
| camera.rotation.set(-0.43, -0.01, -0.01); |
| |
| const renderer = new THREE.WebGLRenderer({ |
| antialias: true, |
| alpha: true, |
| powerPreference: 'high-performance' |
| }); |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| renderer.setClearColor(0x000000, 0); |
| container.appendChild(renderer.domElement); |
| |
| |
| const controls = new OrbitControls(camera, renderer.domElement); |
| controls.enableDamping = true; |
| controls.dampingFactor = 0.05; |
| controls.autoRotate = true; |
| controls.autoRotateSpeed = 0.5; |
| controls.enableZoom = true; |
| controls.enablePan = true; |
| controls.panSpeed = 0.5; |
| controls.minDistance = 3; |
| controls.maxDistance = 12; |
| controls.target.set(0.04, -0.75, 0.26); |
| |
| |
| let paneVisible = false; |
| |
| |
| controls.addEventListener('change', () => { |
| if (paneVisible) { |
| console.log('Camera Position:', `camera.position.set(${camera.position.x.toFixed(2)}, ${camera.position.y.toFixed(2)}, ${camera.position.z.toFixed(2)});`); |
| console.log('Camera Rotation:', `camera.rotation.set(${camera.rotation.x.toFixed(2)}, ${camera.rotation.y.toFixed(2)}, ${camera.rotation.z.toFixed(2)});`); |
| console.log('Target (Center):', `controls.target.set(${controls.target.x.toFixed(2)}, ${controls.target.y.toFixed(2)}, ${controls.target.z.toFixed(2)});`); |
| console.log('---'); |
| } |
| }); |
| |
| |
| |
| const isDarkMode = () => document.documentElement.getAttribute('data-theme') === 'dark'; |
| |
| const params = { |
| count: 12000, |
| size: 150, |
| whiteSize: 25, |
| sizeVariation: 0.8, |
| radius: 4, |
| branches: 2, |
| spin: 3.0, |
| randomness: 0.3, |
| randomnessPower: 3, |
| centerSizeBoost: 1.5, |
| insideColor: isDarkMode() ? '#ff6030' : '#ff8050', |
| outsideColor: isDarkMode() ? '#1b3984' : '#3d5fa8', |
| fov: 35 |
| }; |
| |
| |
| const pane = new Pane({ |
| container: container, |
| title: 'Galaxy Controls' |
| }); |
| |
| |
| pane.element.style.display = 'none'; |
| |
| let geometry = null; |
| let material = null; |
| let points = null; |
| let whiteGeometry = null; |
| let whiteMaterial = null; |
| let whitePoints = null; |
| |
| |
| const clock = new THREE.Clock(); |
| |
| |
| const generateGalaxy = () => { |
| |
| if (points !== null) { |
| geometry.dispose(); |
| material.dispose(); |
| scene.remove(points); |
| } |
| |
| |
| if (whitePoints !== null) { |
| whiteGeometry.dispose(); |
| whiteMaterial.dispose(); |
| scene.remove(whitePoints); |
| } |
| |
| |
| geometry = new THREE.BufferGeometry(); |
| |
| const positions = new Float32Array(params.count * 3); |
| const colors = new Float32Array(params.count * 3); |
| const scales = new Float32Array(params.count); |
| |
| const colorInside = new THREE.Color(params.insideColor); |
| const colorOutside = new THREE.Color(params.outsideColor); |
| |
| for (let i = 0; i < params.count; i++) { |
| const i3 = i * 3; |
| |
| |
| const radius = Math.random() * params.radius; |
| const radiusRatio = radius / params.radius; |
| |
| |
| const branchAngle = (i % params.branches) / params.branches * Math.PI * 2; |
| |
| |
| const spinAngle = radius * params.spin; |
| |
| |
| const randomX = Math.pow(Math.random(), params.randomnessPower) * (Math.random() < 0.5 ? 1 : -1) * params.randomness * radius; |
| const randomY = Math.pow(Math.random(), params.randomnessPower) * (Math.random() < 0.5 ? 1 : -1) * params.randomness * radius * 0.3; |
| const randomZ = Math.pow(Math.random(), params.randomnessPower) * (Math.random() < 0.5 ? 1 : -1) * params.randomness * radius; |
| |
| |
| positions[i3] = Math.cos(branchAngle + spinAngle) * radius + randomX; |
| positions[i3 + 1] = randomY; |
| positions[i3 + 2] = Math.sin(branchAngle + spinAngle) * radius + randomZ; |
| |
| |
| const mixedColor = colorInside.clone(); |
| mixedColor.lerp(colorOutside, radiusRatio); |
| |
| colors[i3] = mixedColor.r; |
| colors[i3 + 1] = mixedColor.g; |
| colors[i3 + 2] = mixedColor.b; |
| |
| |
| const centerScale = (1.0 + params.centerSizeBoost) - radiusRatio * params.centerSizeBoost; |
| |
| const randomScale = Math.pow(Math.random(), 2.0) * params.sizeVariation; |
| scales[i] = randomScale + centerScale; |
| } |
| |
| geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); |
| geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); |
| geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1)); |
| |
| |
| material = new THREE.ShaderMaterial({ |
| depthWrite: false, |
| blending: THREE.AdditiveBlending, |
| vertexColors: true, |
| uniforms: { |
| uTime: { value: 0 }, |
| uSize: { value: params.size * renderer.getPixelRatio() } |
| }, |
| vertexShader: ` |
| uniform float uTime; |
| uniform float uSize; |
| |
| attribute float aScale; |
| |
| varying vec3 vColor; |
| |
| void main() { |
| vec4 modelPosition = modelMatrix * vec4(position, 1.0); |
| |
| vec4 viewPosition = viewMatrix * modelPosition; |
| vec4 projectedPosition = projectionMatrix * viewPosition; |
| gl_Position = projectedPosition; |
| |
| // Taille des points |
| gl_PointSize = uSize * aScale; |
| gl_PointSize *= (1.0 / -viewPosition.z); |
| |
| vColor = color; |
| } |
| `, |
| fragmentShader: ` |
| varying vec3 vColor; |
| |
| void main() { |
| float distanceToCenter = distance(gl_PointCoord, vec2(0.5)); |
| |
| // Noyau brillant |
| float core = 1.0 - smoothstep(0.0, 0.25, distanceToCenter); |
| core = pow(core, 2.0); |
| |
| // Halo externe |
| float halo = 1.0 - smoothstep(0.15, 0.5, distanceToCenter); |
| halo = pow(halo, 3.0); |
| |
| // Combinaison |
| float strength = max(core, halo * 0.3); |
| |
| // Couleur finale (adaptée au thème) |
| float coreIntensity = ${isDarkMode() ? '0.8' : '0.7'}; |
| float haloIntensity = ${isDarkMode() ? '0.4' : '0.35'}; |
| vec3 coreColor = vColor * coreIntensity; |
| vec3 haloColor = vColor * haloIntensity; |
| vec3 finalColor = mix(haloColor, coreColor, core); |
| |
| // Alpha adapté au thème |
| float alpha = strength * ${isDarkMode() ? '0.6' : '0.5'}; |
| |
| gl_FragColor = vec4(finalColor, alpha); |
| } |
| ` |
| }); |
| |
| |
| points = new THREE.Points(geometry, material); |
| scene.add(points); |
| |
| |
| const whiteCount = Math.floor(params.count * 0.5); |
| const whitePositions = new Float32Array(whiteCount * 3); |
| const whiteScales = new Float32Array(whiteCount); |
| |
| |
| const indices = Array.from({ length: params.count }, (_, i) => i); |
| |
| for (let i = indices.length - 1; i > 0; i--) { |
| const j = Math.floor(Math.random() * (i + 1)); |
| [indices[i], indices[j]] = [indices[j], indices[i]]; |
| } |
| const selectedIndices = indices.slice(0, whiteCount); |
| |
| |
| for (let i = 0; i < whiteCount; i++) { |
| const sourceIdx = selectedIndices[i]; |
| whitePositions[i * 3] = positions[sourceIdx * 3]; |
| whitePositions[i * 3 + 1] = positions[sourceIdx * 3 + 1]; |
| whitePositions[i * 3 + 2] = positions[sourceIdx * 3 + 2]; |
| |
| |
| whiteScales[i] = (Math.random() * 0.2 + 0.2) / 6; |
| } |
| |
| whiteGeometry = new THREE.BufferGeometry(); |
| whiteGeometry.setAttribute('position', new THREE.BufferAttribute(whitePositions, 3)); |
| whiteGeometry.setAttribute('aScale', new THREE.BufferAttribute(whiteScales, 1)); |
| |
| |
| whiteMaterial = new THREE.ShaderMaterial({ |
| depthWrite: false, |
| blending: THREE.AdditiveBlending, |
| uniforms: { |
| uTime: { value: 0 }, |
| uSize: { value: params.whiteSize * renderer.getPixelRatio() } |
| }, |
| vertexShader: ` |
| uniform float uTime; |
| uniform float uSize; |
| |
| attribute float aScale; |
| |
| void main() { |
| vec4 modelPosition = modelMatrix * vec4(position, 1.0); |
| |
| vec4 viewPosition = viewMatrix * modelPosition; |
| vec4 projectedPosition = projectionMatrix * viewPosition; |
| gl_Position = projectedPosition; |
| |
| gl_PointSize = uSize * aScale; |
| gl_PointSize *= (1.0 / -viewPosition.z); |
| } |
| `, |
| fragmentShader: ` |
| void main() { |
| float distanceToCenter = distance(gl_PointCoord, vec2(0.5)); |
| |
| // Créer une boule bien définie |
| float strength = 1.0 - smoothstep(0.3, 0.5, distanceToCenter); |
| strength = pow(strength, 2.0); |
| |
| // Couleur blanche (adaptée au thème) |
| vec3 whiteColor = vec3(${isDarkMode() ? '1.0, 1.0, 1.0' : '0.95, 0.95, 0.98'}); |
| |
| gl_FragColor = vec4(whiteColor, strength * ${isDarkMode() ? '0.8' : '0.9'}); |
| } |
| ` |
| }); |
| |
| whitePoints = new THREE.Points(whiteGeometry, whiteMaterial); |
| scene.add(whitePoints); |
| |
| }; |
| |
| |
| generateGalaxy(); |
| |
| |
| pane.addBinding(params, 'count', { |
| label: 'Particles', |
| min: 1000, |
| max: 50000, |
| step: 1000 |
| }).on('change', () => { |
| generateGalaxy(); |
| }); |
| |
| pane.addBinding(params, 'spin', { |
| label: 'Twist', |
| min: 0, |
| max: 5, |
| step: 0.1 |
| }).on('change', () => { |
| generateGalaxy(); |
| }); |
| |
| pane.addBinding(params, 'size', { |
| label: 'Point Size', |
| min: 10, |
| max: 200, |
| step: 1 |
| }).on('change', () => { |
| if (material) { |
| material.uniforms.uSize.value = params.size * renderer.getPixelRatio(); |
| } |
| }); |
| |
| pane.addBinding(params, 'sizeVariation', { |
| label: 'Size Variation', |
| min: 0, |
| max: 2, |
| step: 0.05 |
| }).on('change', () => { |
| generateGalaxy(); |
| }); |
| |
| pane.addBinding(params, 'whiteSize', { |
| label: 'White Size', |
| min: 5, |
| max: 100, |
| step: 1 |
| }).on('change', () => { |
| if (whiteMaterial) { |
| whiteMaterial.uniforms.uSize.value = params.whiteSize * renderer.getPixelRatio(); |
| } |
| }); |
| |
| pane.addBinding(params, 'centerSizeBoost', { |
| label: 'Center Boost', |
| min: 0, |
| max: 3, |
| step: 0.1 |
| }).on('change', () => { |
| generateGalaxy(); |
| }); |
| |
| pane.addBinding(params, 'branches', { |
| label: 'Branches', |
| min: 2, |
| max: 6, |
| step: 1 |
| }).on('change', () => { |
| generateGalaxy(); |
| }); |
| |
| pane.addBinding(params, 'fov', { |
| label: 'FOV (Zoom)', |
| min: 20, |
| max: 75, |
| step: 1 |
| }).on('change', () => { |
| camera.fov = params.fov; |
| camera.updateProjectionMatrix(); |
| }); |
| |
| |
| const animate = () => { |
| requestAnimationFrame(animate); |
| |
| const elapsedTime = clock.getElapsedTime(); |
| |
| if (material) { |
| material.uniforms.uTime.value = elapsedTime; |
| } |
| |
| |
| controls.update(); |
| |
| renderer.render(scene, camera); |
| }; |
| |
| |
| const onResize = () => { |
| const width = container.clientWidth; |
| const height = Math.max(260, Math.round(width / 3)); |
| |
| camera.aspect = width / height; |
| camera.updateProjectionMatrix(); |
| |
| renderer.setSize(width, height); |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| |
| if (material) { |
| material.uniforms.uSize.value = params.size * renderer.getPixelRatio(); |
| } |
| if (whiteMaterial) { |
| whiteMaterial.uniforms.uSize.value = params.whiteSize * renderer.getPixelRatio(); |
| } |
| }; |
| |
| onResize(); |
| |
| if (window.ResizeObserver) { |
| new ResizeObserver(onResize).observe(container); |
| } else { |
| window.addEventListener('resize', onResize); |
| } |
| |
| |
| const updateTheme = () => { |
| renderer.setClearColor(0x000000, 0); |
| |
| params.insideColor = isDarkMode() ? '#ff6030' : '#ff8050'; |
| params.outsideColor = isDarkMode() ? '#1b3984' : '#3d5fa8'; |
| |
| generateGalaxy(); |
| }; |
| |
| const observer = new MutationObserver(updateTheme); |
| observer.observe(document.documentElement, { |
| attributes: true, |
| attributeFilter: ['data-theme'] |
| }); |
| |
| |
| window.addEventListener('keydown', (event) => { |
| if (event.key === 'd' || event.key === 'D') { |
| paneVisible = !paneVisible; |
| if (paneVisible) { |
| pane.element.style.display = ''; |
| } else { |
| pane.element.style.display = 'none'; |
| } |
| } |
| }); |
| |
| |
| animate(); |
| |
| |
| container._cleanup = () => { |
| observer.disconnect(); |
| if (geometry) geometry.dispose(); |
| if (material) material.dispose(); |
| if (whiteGeometry) whiteGeometry.dispose(); |
| if (whiteMaterial) whiteMaterial.dispose(); |
| controls.dispose(); |
| renderer.dispose(); |
| pane.dispose(); |
| }; |
| } |
| </script> |