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