-
-
Save shricodev/cdb0493d71525d5988603b6c4dc86b2f to your computer and use it in GitHub Desktop.
Blog - Morph Particles (Claude Opus 4)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!doctype html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
<title>GPU Particle Morphing - Next Level</title> | |
<style> | |
*, | |
*::before, | |
*::after { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
} | |
body { | |
width: 100%; | |
height: 100vh; | |
overflow: hidden; | |
background: #000; | |
font-family: | |
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
} | |
#container { | |
width: 100%; | |
height: 100%; | |
position: relative; | |
} | |
#ui { | |
position: absolute; | |
top: 30px; | |
right: 30px; | |
display: flex; | |
flex-direction: column; | |
align-items: flex-end; | |
color: white; | |
z-index: 1000; | |
gap: 15px; | |
} | |
#controls { | |
position: absolute; | |
top: 30px; | |
left: 30px; | |
color: white; | |
z-index: 1000; | |
} | |
.control-group { | |
background: rgba(10, 10, 10, 0.8); | |
backdrop-filter: blur(20px); | |
padding: 20px; | |
border-radius: 20px; | |
margin-bottom: 20px; | |
border: 1px solid rgba(255, 255, 255, 0.1); | |
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); | |
} | |
.slider-container { | |
margin: 15px 0; | |
} | |
.slider-container label { | |
display: block; | |
margin-bottom: 8px; | |
font-size: 13px; | |
text-transform: uppercase; | |
letter-spacing: 1px; | |
color: rgba(255, 255, 255, 0.8); | |
} | |
.slider-container span { | |
color: #ff5900; | |
font-weight: 600; | |
} | |
input[type="range"] { | |
width: 220px; | |
height: 6px; | |
border-radius: 3px; | |
background: rgba(255, 255, 255, 0.1); | |
outline: none; | |
-webkit-appearance: none; | |
appearance: none; | |
cursor: pointer; | |
} | |
input[type="range"]::-webkit-slider-thumb { | |
-webkit-appearance: none; | |
appearance: none; | |
width: 18px; | |
height: 18px; | |
border-radius: 50%; | |
background: #ff5900; | |
cursor: pointer; | |
box-shadow: 0 0 10px rgba(255, 89, 0, 0.5); | |
transition: all 0.2s ease; | |
} | |
input[type="range"]::-webkit-slider-thumb:hover { | |
transform: scale(1.2); | |
box-shadow: 0 0 20px rgba(255, 89, 0, 0.8); | |
} | |
input[type="color"] { | |
width: 60px; | |
height: 35px; | |
border: none; | |
border-radius: 10px; | |
cursor: pointer; | |
background: transparent; | |
} | |
#title { | |
font-size: 36px; | |
font-weight: 800; | |
margin-bottom: 10px; | |
text-shadow: 0 0 20px rgba(255, 89, 0, 0.5); | |
background: linear-gradient(135deg, #ff5900, #ff9a00); | |
-webkit-background-clip: text; | |
-webkit-text-fill-color: transparent; | |
background-clip: text; | |
letter-spacing: 2px; | |
} | |
.button { | |
cursor: pointer; | |
padding: 15px 25px; | |
border: 2px solid transparent; | |
border-radius: 15px; | |
background: linear-gradient( | |
135deg, | |
rgba(255, 255, 255, 0.05), | |
rgba(255, 255, 255, 0) | |
); | |
backdrop-filter: blur(10px); | |
color: #fff; | |
font-size: 15px; | |
font-weight: 600; | |
letter-spacing: 1px; | |
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); | |
min-width: 160px; | |
text-align: center; | |
position: relative; | |
overflow: hidden; | |
} | |
.button::before { | |
content: ""; | |
position: absolute; | |
top: 0; | |
left: -100%; | |
width: 100%; | |
height: 100%; | |
background: linear-gradient( | |
90deg, | |
transparent, | |
rgba(255, 255, 255, 0.2), | |
transparent | |
); | |
transition: left 0.5s; | |
} | |
.button:hover { | |
background: linear-gradient( | |
135deg, | |
rgba(255, 89, 0, 0.2), | |
rgba(255, 89, 0, 0.1) | |
); | |
border-color: rgba(255, 89, 0, 0.5); | |
transform: translateY(-2px); | |
box-shadow: 0 10px 30px rgba(255, 89, 0, 0.3); | |
} | |
.button:hover::before { | |
left: 100%; | |
} | |
.button.active { | |
background: linear-gradient( | |
135deg, | |
rgba(255, 89, 0, 0.8), | |
rgba(255, 89, 0, 0.6) | |
); | |
border-color: #ff5900; | |
transform: translateY(-2px) scale(1.02); | |
box-shadow: 0 15px 40px rgba(255, 89, 0, 0.4); | |
} | |
#info { | |
position: absolute; | |
bottom: 30px; | |
left: 30px; | |
color: rgba(255, 255, 255, 0.6); | |
font-size: 13px; | |
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.8); | |
pointer-events: none; | |
letter-spacing: 0.5px; | |
} | |
.loading { | |
position: fixed; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
font-size: 24px; | |
color: #ff5900; | |
z-index: 2000; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="container"></div> | |
<div id="ui"> | |
<div id="title">PARTICLE MORPH</div> | |
<div class="button" data-shape="sphere">SPHERE</div> | |
<div class="button" data-shape="bird">BIRD</div> | |
<div class="button" data-shape="face">HUMAN</div> | |
<div class="button" data-shape="tree">TREE</div> | |
</div> | |
<div id="controls"> | |
<div class="control-group"> | |
<div class="slider-container"> | |
<label>Particle Size: <span id="sizeValue">3.0</span></label> | |
<input | |
type="range" | |
id="particleSize" | |
min="0.5" | |
max="10" | |
step="0.1" | |
value="3.0" | |
/> | |
</div> | |
<div class="slider-container"> | |
<label>Rotation Speed: <span id="speedValue">0.5</span></label> | |
<input | |
type="range" | |
id="rotationSpeed" | |
min="-2" | |
max="2" | |
step="0.1" | |
value="0.5" | |
/> | |
</div> | |
<div class="slider-container"> | |
<label>Particle Color:</label> | |
<input type="color" id="particleColor" value="#ff5900" /> | |
</div> | |
</div> | |
<div class="control-group"> | |
<div class="slider-container"> | |
<label>Bloom Intensity: <span id="bloomValue">1.5</span></label> | |
<input | |
type="range" | |
id="bloomStrength" | |
min="0" | |
max="3" | |
step="0.1" | |
value="1.5" | |
/> | |
</div> | |
<div class="slider-container"> | |
<label>Motion Trail: <span id="trailValue">0.85</span></label> | |
<input | |
type="range" | |
id="motionTrail" | |
min="0" | |
max="0.98" | |
step="0.01" | |
value="0.85" | |
/> | |
</div> | |
</div> | |
</div> | |
<div id="info"> | |
Click & drag to rotate | Scroll to zoom | Double-click to reset | |
</div> | |
<script type="importmap"> | |
{ | |
"imports": { | |
"three": "https://unpkg.com/[email protected]/build/three.module.js", | |
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/" | |
} | |
} | |
</script> | |
<script type="module"> | |
import * as THREE from "three"; | |
import { OrbitControls } from "three/addons/controls/OrbitControls.js"; | |
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js"; | |
import { RenderPass } from "three/addons/postprocessing/RenderPass.js"; | |
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js"; | |
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js"; | |
import { GammaCorrectionShader } from "three/addons/shaders/GammaCorrectionShader.js"; | |
// Global variables | |
let scene, camera, renderer, controls; | |
let particleSystem, particleMaterial; | |
let composer, bloomPass; | |
let clock = new THREE.Clock(); | |
let selectedButton = null; | |
let previousRenderTarget, motionBlurComposer; | |
// Particle system parameters | |
const PARTICLE_COUNT = 20000; | |
let targetPositions = new Float32Array(PARTICLE_COUNT * 3); | |
let originalPositions = new Float32Array(PARTICLE_COUNT * 3); | |
let morphProgress = 1.0; | |
let isMorphing = false; | |
// Parameters | |
const params = { | |
particleSize: 3.0, | |
particleColor: new THREE.Color("#ff5900"), | |
rotationSpeed: 0.5, | |
bloomStrength: 1.5, | |
motionTrail: 0.85, | |
}; | |
init(); | |
animate(); | |
function init() { | |
initScene(); | |
initLights(); | |
initControls(); | |
createParticleSystem(); | |
initPostProcessing(); | |
initEventListeners(); | |
// Set initial shape | |
const initialButton = document.querySelector('[data-shape="sphere"]'); | |
initialButton.classList.add("active"); | |
selectedButton = initialButton; | |
morphToShape("sphere"); | |
} | |
function initScene() { | |
// Scene setup | |
scene = new THREE.Scene(); | |
scene.background = new THREE.Color(0x000000); | |
scene.fog = new THREE.FogExp2(0x000000, 0.01); | |
// Camera setup | |
camera = new THREE.PerspectiveCamera( | |
75, | |
window.innerWidth / window.innerHeight, | |
0.1, | |
1000, | |
); | |
camera.position.set(40, 30, 60); | |
camera.lookAt(0, 0, 0); | |
// Renderer setup | |
renderer = new THREE.WebGLRenderer({ | |
antialias: true, | |
powerPreference: "high-performance", | |
}); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
renderer.toneMappingExposure = 1.2; | |
document.getElementById("container").appendChild(renderer.domElement); | |
// Motion blur render target | |
previousRenderTarget = new THREE.WebGLRenderTarget( | |
window.innerWidth, | |
window.innerHeight, | |
{ | |
minFilter: THREE.LinearFilter, | |
magFilter: THREE.LinearFilter, | |
format: THREE.RGBAFormat, | |
}, | |
); | |
} | |
function initLights() { | |
const ambientLight = new THREE.AmbientLight(0x404040, 2); | |
scene.add(ambientLight); | |
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5); | |
directionalLight.position.set(20, 30, 20); | |
scene.add(directionalLight); | |
const pointLight = new THREE.PointLight(0xff5900, 0.5, 100); | |
pointLight.position.set(0, 0, 0); | |
scene.add(pointLight); | |
} | |
function initControls() { | |
controls = new OrbitControls(camera, renderer.domElement); | |
controls.enableDamping = true; | |
controls.dampingFactor = 0.05; | |
controls.minDistance = 20; | |
controls.maxDistance = 150; | |
controls.autoRotate = false; | |
controls.target.set(0, 0, 0); | |
} | |
function createParticleSystem() { | |
const geometry = new THREE.BufferGeometry(); | |
const positions = new Float32Array(PARTICLE_COUNT * 3); | |
const colors = new Float32Array(PARTICLE_COUNT * 3); | |
const sizes = new Float32Array(PARTICLE_COUNT); | |
const randoms = new Float32Array(PARTICLE_COUNT * 4); | |
// Initialize with sphere positions | |
for (let i = 0; i < PARTICLE_COUNT; i++) { | |
const i3 = i * 3; | |
// Random values for shader animation | |
randoms[i * 4] = Math.random(); | |
randoms[i * 4 + 1] = Math.random(); | |
randoms[i * 4 + 2] = Math.random(); | |
randoms[i * 4 + 3] = Math.random(); | |
// Initial color | |
const hue = 0.05 + Math.random() * 0.1; | |
const saturation = 0.8 + Math.random() * 0.2; | |
const lightness = 0.5 + Math.random() * 0.3; | |
const color = new THREE.Color().setHSL(hue, saturation, lightness); | |
colors[i3] = color.r; | |
colors[i3 + 1] = color.g; | |
colors[i3 + 2] = color.b; | |
// Random sizes for variation | |
sizes[i] = 0.8 + Math.random() * 0.4; | |
} | |
geometry.setAttribute( | |
"position", | |
new THREE.BufferAttribute(positions, 3), | |
); | |
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); | |
geometry.setAttribute("size", new THREE.BufferAttribute(sizes, 1)); | |
geometry.setAttribute("randoms", new THREE.BufferAttribute(randoms, 4)); | |
// Shader material for GPU animation | |
particleMaterial = new THREE.ShaderMaterial({ | |
uniforms: { | |
uTime: { value: 0 }, | |
uSize: { value: params.particleSize }, | |
uColor: { value: params.particleColor }, | |
uMorphProgress: { value: 1.0 }, | |
uTargetPositions: { value: targetPositions }, | |
}, | |
vertexShader: ` | |
uniform float uTime; | |
uniform float uSize; | |
uniform float uMorphProgress; | |
attribute float size; | |
attribute vec4 randoms; | |
attribute vec3 color; | |
varying vec3 vColor; | |
varying float vAlpha; | |
void main() { | |
// Base position with subtle animation | |
vec3 pos = position; | |
// Add subtle floating animation | |
pos += vec3( | |
sin(uTime * randoms.x + randoms.w * 6.28) * 0.1, | |
cos(uTime * randoms.y + randoms.w * 6.28) * 0.1, | |
sin(uTime * randoms.z + randoms.w * 6.28) * 0.1 | |
) * (1.0 - uMorphProgress * 0.5); | |
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); | |
gl_Position = projectionMatrix * mvPosition; | |
// Size attenuation | |
float sizeAttenuation = 300.0 / -mvPosition.z; | |
gl_PointSize = uSize * size * sizeAttenuation; | |
// Color variation | |
vColor = color; | |
vAlpha = 0.8 + sin(uTime * randoms.x) * 0.2; | |
} | |
`, | |
fragmentShader: ` | |
uniform vec3 uColor; | |
varying vec3 vColor; | |
varying float vAlpha; | |
void main() { | |
// Circular particle shape with soft edges | |
vec2 center = vec2(0.5); | |
float dist = distance(gl_PointCoord, center); | |
if (dist > 0.5) discard; | |
// Soft edge falloff | |
float strength = 1.0 - smoothstep(0.0, 0.5, dist); | |
strength = pow(strength, 2.0); | |
// Final color with glow effect | |
vec3 finalColor = mix(uColor, vColor, 0.5); | |
finalColor = pow(finalColor * 1.2, vec3(0.8)); // Brighten and adjust gamma | |
gl_FragColor = vec4(finalColor, strength * vAlpha); | |
} | |
`, | |
transparent: true, | |
depthWrite: false, | |
blending: THREE.AdditiveBlending, | |
}); | |
particleSystem = new THREE.Points(geometry, particleMaterial); | |
scene.add(particleSystem); | |
} | |
function initPostProcessing() { | |
// Main composer | |
composer = new EffectComposer(renderer); | |
const renderPass = new RenderPass(scene, camera); | |
composer.addPass(renderPass); | |
// Bloom pass for glow effect | |
bloomPass = new UnrealBloomPass( | |
new THREE.Vector2(window.innerWidth, window.innerHeight), | |
params.bloomStrength, | |
0.4, | |
0.85, | |
); | |
composer.addPass(bloomPass); | |
// Gamma correction | |
const gammaCorrectionPass = new ShaderPass(GammaCorrectionShader); | |
composer.addPass(gammaCorrectionPass); | |
// Motion blur pass | |
const motionBlurShader = { | |
uniforms: { | |
tDiffuse: { value: null }, | |
tPrevious: { value: previousRenderTarget.texture }, | |
uBlendFactor: { value: params.motionTrail }, | |
}, | |
vertexShader: ` | |
varying vec2 vUv; | |
void main() { | |
vUv = uv; | |
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
} | |
`, | |
fragmentShader: ` | |
uniform sampler2D tDiffuse; | |
uniform sampler2D tPrevious; | |
uniform float uBlendFactor; | |
varying vec2 vUv; | |
void main() { | |
vec4 current = texture2D(tDiffuse, vUv); | |
vec4 previous = texture2D(tPrevious, vUv); | |
gl_FragColor = mix(current, previous, uBlendFactor); | |
} | |
`, | |
}; | |
const motionBlurPass = new ShaderPass(motionBlurShader); | |
composer.addPass(motionBlurPass); | |
} | |
function morphToShape(shape) { | |
const positions = particleSystem.geometry.attributes.position.array; | |
// Store current positions | |
for (let i = 0; i < positions.length; i++) { | |
originalPositions[i] = positions[i]; | |
} | |
// Generate target positions based on shape | |
switch (shape) { | |
case "sphere": | |
generateSphere(); | |
break; | |
case "bird": | |
generateBird(); | |
break; | |
case "face": | |
generateFace(); | |
break; | |
case "tree": | |
generateTree(); | |
break; | |
} | |
morphProgress = 0; | |
isMorphing = true; | |
} | |
function generateSphere() { | |
const radius = 20; | |
for (let i = 0; i < PARTICLE_COUNT; i++) { | |
const i3 = i * 3; | |
// Fibonacci sphere for even distribution | |
const y = 1 - (i / (PARTICLE_COUNT - 1)) * 2; | |
const radiusAtY = Math.sqrt(1 - y * y); | |
const theta = ((i + 1) % PARTICLE_COUNT) * 2.399963229728653; | |
targetPositions[i3] = radiusAtY * Math.cos(theta) * radius; | |
targetPositions[i3 + 1] = y * radius; | |
targetPositions[i3 + 2] = radiusAtY * Math.sin(theta) * radius; | |
} | |
} | |
function generateBird() { | |
const points = []; | |
const scale = 0.8; | |
// Wing structure | |
for (let u = 0; u <= 1; u += 0.02) { | |
for (let v = 0; v <= 1; v += 0.1) { | |
// Left wing | |
const wingSpan = 30 * (1 - u * u); | |
const x1 = -wingSpan * v * scale; | |
const y1 = Math.sin(v * Math.PI) * 5 * scale; | |
const z1 = u * 20 * scale - 10 * scale; | |
points.push(new THREE.Vector3(x1, y1, z1)); | |
// Right wing | |
const x2 = wingSpan * v * scale; | |
points.push(new THREE.Vector3(x2, y1, z1)); | |
} | |
} | |
// Body | |
for (let t = 0; t <= 1; t += 0.02) { | |
const bodyRadius = 3 * (1 - t * t * 0.5); | |
for (let theta = 0; theta < Math.PI * 2; theta += 0.2) { | |
const x = Math.cos(theta) * bodyRadius * scale; | |
const y = Math.sin(theta) * bodyRadius * scale; | |
const z = (t - 0.5) * 25 * scale; | |
points.push(new THREE.Vector3(x, y, z)); | |
} | |
} | |
// Head | |
for (let i = 0; i < 200; i++) { | |
const theta = Math.random() * Math.PI * 2; | |
const phi = Math.acos(Math.random() * 2 - 1); | |
const r = 4 * scale; | |
const x = r * Math.sin(phi) * Math.cos(theta); | |
const y = r * Math.sin(phi) * Math.sin(theta) + 3 * scale; | |
const z = r * Math.cos(phi) + 12 * scale; | |
points.push(new THREE.Vector3(x, y, z)); | |
} | |
// Tail feathers | |
for (let i = 0; i < 5; i++) { | |
const angle = (i / 5) * Math.PI - Math.PI / 2; | |
for (let t = 0; t < 1; t += 0.05) { | |
const x = Math.cos(angle) * t * 15 * scale; | |
const y = Math.sin(angle) * t * 8 * scale; | |
const z = -10 * scale - t * 8 * scale; | |
points.push(new THREE.Vector3(x, y, z)); | |
} | |
} | |
distributePointsToParticles(points); | |
} | |
function generateFace() { | |
const points = []; | |
const scale = 1.2; | |
// Head outline - ellipsoid | |
for (let theta = 0; theta <= Math.PI; theta += 0.05) { | |
for (let phi = 0; phi <= Math.PI * 2; phi += 0.1) { | |
const r = 15; | |
const x = r * Math.sin(theta) * Math.cos(phi) * scale; | |
const y = r * Math.cos(theta) * 1.2 * scale + 5; | |
const z = r * Math.sin(theta) * Math.sin(phi) * 0.8 * scale; | |
// Add some noise to make it more organic | |
const noise = (Math.random() - 0.5) * 1; | |
points.push(new THREE.Vector3(x + noise, y + noise, z + noise)); | |
} | |
} | |
// Eyes | |
const eyePositions = [ | |
[-6, 8, 6], | |
[6, 8, 6], | |
]; | |
for (let eye of eyePositions) { | |
for (let i = 0; i < 150; i++) { | |
const angle = (i / 150) * Math.PI * 2; | |
const r = Math.random() * 2.5; | |
const x = eye[0] + Math.cos(angle) * r * scale; | |
const y = eye[1] + Math.sin(angle) * r * scale; | |
const z = eye[2] + Math.random() * scale; | |
points.push(new THREE.Vector3(x, y, z)); | |
} | |
} | |
// Nose | |
for (let t = 0; t <= 1; t += 0.05) { | |
for (let i = 0; i < 50; i++) { | |
const x = (Math.random() - 0.5) * 3 * (1 - t) * scale; | |
const y = 5 - t * 7 * scale; | |
const z = 8 + Math.random() * 2 * scale; | |
points.push(new THREE.Vector3(x, y, z)); | |
} | |
} | |
// Mouth | |
for (let t = 0; t <= 1; t += 0.02) { | |
const angle = t * Math.PI; | |
const r = 7; | |
const x = Math.cos(angle) * r * scale; | |
const y = -5 - Math.sin(angle) * 2 * scale; | |
const z = 6 + Math.random() * scale; | |
points.push(new THREE.Vector3(x, y, z)); | |
// Add thickness | |
for (let i = 0; i < 10; i++) { | |
points.push( | |
new THREE.Vector3( | |
x + (Math.random() - 0.5) * 2, | |
y + (Math.random() - 0.5) * 2, | |
z + Math.random(), | |
), | |
); | |
} | |
} | |
// Cheeks - add volume | |
const cheekPositions = [ | |
[-10, 0, 5], | |
[10, 0, 5], | |
]; | |
for (let cheek of cheekPositions) { | |
for (let i = 0; i < 200; i++) { | |
const theta = Math.random() * Math.PI * 2; | |
const phi = Math.acos(Math.random() * 2 - 1); | |
const r = 5 * Math.random(); | |
const x = cheek[0] + r * Math.sin(phi) * Math.cos(theta) * scale; | |
const y = cheek[1] + r * Math.sin(phi) * Math.sin(theta) * scale; | |
const z = cheek[2] + r * Math.cos(phi) * scale; | |
points.push(new THREE.Vector3(x, y, z)); | |
} | |
} | |
distributePointsToParticles(points); | |
} | |
function generateTree() { | |
const points = []; | |
const scale = 0.7; | |
// Trunk | |
const trunkHeight = 15; | |
const trunkRadius = 3; | |
for (let y = -15; y < -15 + trunkHeight; y += 0.5) { | |
const radiusAtHeight = | |
trunkRadius * (1 + ((y + 15) / trunkHeight) * 0.2); | |
for (let angle = 0; angle < Math.PI * 2; angle += 0.1) { | |
const x = Math.cos(angle) * radiusAtHeight * scale; | |
const z = Math.sin(angle) * radiusAtHeight * scale; | |
points.push(new THREE.Vector3(x, y * scale, z)); | |
} | |
} | |
// Branches with recursive structure | |
function addBranch(origin, direction, length, radius, level) { | |
if (level > 3 || length < 1) return; | |
const segments = 10; | |
for (let i = 0; i <= segments; i++) { | |
const t = i / segments; | |
const pos = origin | |
.clone() | |
.add(direction.clone().multiplyScalar(length * t)); | |
// Add thickness | |
for (let j = 0; j < 5; j++) { | |
const offset = new THREE.Vector3( | |
(Math.random() - 0.5) * radius, | |
(Math.random() - 0.5) * radius, | |
(Math.random() - 0.5) * radius, | |
); | |
points.push(pos.clone().add(offset)); | |
} | |
// Create sub-branches | |
if (i === segments) { | |
const branches = 2 + Math.floor(Math.random() * 2); | |
for (let b = 0; b < branches; b++) { | |
const newDir = direction.clone(); | |
newDir.x += (Math.random() - 0.5) * 0.8; | |
newDir.y += Math.random() * 0.5; | |
newDir.z += (Math.random() - 0.5) * 0.8; | |
newDir.normalize(); | |
addBranch(pos, newDir, length * 0.7, radius * 0.6, level + 1); | |
} | |
} | |
} | |
} | |
// Main branches from trunk | |
const numMainBranches = 6; | |
for (let i = 0; i < numMainBranches; i++) { | |
const angle = (i / numMainBranches) * Math.PI * 2; | |
const height = -15 + trunkHeight * (0.5 + Math.random() * 0.3); | |
const origin = new THREE.Vector3( | |
Math.cos(angle) * trunkRadius * scale, | |
height * scale, | |
Math.sin(angle) * trunkRadius * scale, | |
); | |
const direction = new THREE.Vector3( | |
Math.cos(angle) * 0.7, | |
0.6 + Math.random() * 0.3, | |
Math.sin(angle) * 0.7, | |
).normalize(); | |
addBranch(origin, direction, 12 * scale, 2 * scale, 0); | |
} | |
// Foliage clusters | |
for (let cluster = 0; cluster < 30; cluster++) { | |
const theta = Math.random() * Math.PI; | |
const phi = Math.random() * Math.PI * 2; | |
const r = 15 + Math.random() * 10; | |
const cx = r * Math.sin(theta) * Math.cos(phi) * scale; | |
const cy = r * (0.5 + Math.random() * 0.5) * scale; | |
const cz = r * Math.sin(theta) * Math.sin(phi) * scale; | |
// Create leaf cluster | |
for (let leaf = 0; leaf < 50; leaf++) { | |
const leafR = Math.random() * 3; | |
const leafTheta = Math.random() * Math.PI; | |
const leafPhi = Math.random() * Math.PI * 2; | |
const x = | |
cx + leafR * Math.sin(leafTheta) * Math.cos(leafPhi) * scale; | |
const y = cy + leafR * Math.cos(leafTheta) * scale; | |
const z = | |
cz + leafR * Math.sin(leafTheta) * Math.sin(leafPhi) * scale; | |
points.push(new THREE.Vector3(x, y, z)); | |
} | |
} | |
// Roots | |
const numRoots = 8; | |
for (let i = 0; i < numRoots; i++) { | |
const angle = (i / numRoots) * Math.PI * 2 + Math.random() * 0.5; | |
for (let t = 0; t < 1; t += 0.05) { | |
const spread = t * 10; | |
const x = Math.cos(angle) * spread * scale; | |
const y = -15 - t * 5 * scale; | |
const z = Math.sin(angle) * spread * scale; | |
for (let j = 0; j < 5; j++) { | |
points.push( | |
new THREE.Vector3( | |
x + (Math.random() - 0.5) * 2, | |
y + Math.random(), | |
z + (Math.random() - 0.5) * 2, | |
), | |
); | |
} | |
} | |
} | |
distributePointsToParticles(points); | |
} | |
function distributePointsToParticles(points) { | |
// Ensure we have enough points | |
while (points.length < PARTICLE_COUNT) { | |
points.push( | |
points[Math.floor(Math.random() * points.length)].clone(), | |
); | |
} | |
// Shuffle points for better distribution | |
for (let i = points.length - 1; i > 0; i--) { | |
const j = Math.floor(Math.random() * (i + 1)); | |
[points[i], points[j]] = [points[j], points[i]]; | |
} | |
// Assign to particles | |
for (let i = 0; i < PARTICLE_COUNT; i++) { | |
const point = points[i % points.length]; | |
targetPositions[i * 3] = point.x; | |
targetPositions[i * 3 + 1] = point.y; | |
targetPositions[i * 3 + 2] = point.z; | |
} | |
} | |
function initEventListeners() { | |
// Shape buttons | |
document.querySelectorAll(".button").forEach((button) => { | |
button.addEventListener("click", (e) => { | |
if (selectedButton) { | |
selectedButton.classList.remove("active"); | |
} | |
selectedButton = e.target; | |
selectedButton.classList.add("active"); | |
const shape = e.target.dataset.shape; | |
morphToShape(shape); | |
}); | |
}); | |
// Controls | |
document | |
.getElementById("particleSize") | |
.addEventListener("input", (e) => { | |
params.particleSize = parseFloat(e.target.value); | |
particleMaterial.uniforms.uSize.value = params.particleSize; | |
document.getElementById("sizeValue").textContent = | |
params.particleSize.toFixed(1); | |
}); | |
document | |
.getElementById("rotationSpeed") | |
.addEventListener("input", (e) => { | |
params.rotationSpeed = parseFloat(e.target.value); | |
document.getElementById("speedValue").textContent = | |
params.rotationSpeed.toFixed(1); | |
}); | |
document | |
.getElementById("particleColor") | |
.addEventListener("input", (e) => { | |
params.particleColor.set(e.target.value); | |
particleMaterial.uniforms.uColor.value = params.particleColor; | |
}); | |
document | |
.getElementById("bloomStrength") | |
.addEventListener("input", (e) => { | |
params.bloomStrength = parseFloat(e.target.value); | |
bloomPass.strength = params.bloomStrength; | |
document.getElementById("bloomValue").textContent = | |
params.bloomStrength.toFixed(1); | |
}); | |
document | |
.getElementById("motionTrail") | |
.addEventListener("input", (e) => { | |
params.motionTrail = parseFloat(e.target.value); | |
document.getElementById("trailValue").textContent = | |
params.motionTrail.toFixed(2); | |
}); | |
// Window resize | |
window.addEventListener("resize", onWindowResize); | |
// Double-click reset | |
renderer.domElement.addEventListener("dblclick", () => { | |
controls.reset(); | |
}); | |
} | |
function onWindowResize() { | |
camera.aspect = window.innerWidth / window.innerHeight; | |
camera.updateProjectionMatrix(); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
composer.setSize(window.innerWidth, window.innerHeight); | |
previousRenderTarget.setSize(window.innerWidth, window.innerHeight); | |
} | |
function updateMorphing(deltaTime) { | |
if (!isMorphing) return; | |
morphProgress += deltaTime * 0.8; // Morph speed | |
if (morphProgress >= 1.0) { | |
morphProgress = 1.0; | |
isMorphing = false; | |
} | |
// Smooth easing function | |
const eased = 1 - Math.pow(1 - morphProgress, 3); | |
// Update particle positions | |
const positions = particleSystem.geometry.attributes.position.array; | |
for (let i = 0; i < positions.length; i++) { | |
positions[i] = | |
originalPositions[i] + | |
(targetPositions[i] - originalPositions[i]) * eased; | |
} | |
particleSystem.geometry.attributes.position.needsUpdate = true; | |
particleMaterial.uniforms.uMorphProgress.value = eased; | |
} | |
function animate() { | |
requestAnimationFrame(animate); | |
const deltaTime = clock.getDelta(); | |
const elapsedTime = clock.getElapsedTime(); | |
// Update controls | |
controls.update(); | |
// Update particle system | |
if (particleSystem) { | |
particleSystem.rotation.y += params.rotationSpeed * deltaTime; | |
particleMaterial.uniforms.uTime.value = elapsedTime; | |
updateMorphing(deltaTime); | |
} | |
// Store current frame for motion blur | |
renderer.setRenderTarget(previousRenderTarget); | |
renderer.render(scene, camera); | |
renderer.setRenderTarget(null); | |
// Update motion blur uniform | |
if (composer.passes[3]) { | |
composer.passes[3].uniforms.uBlendFactor.value = params.motionTrail; | |
} | |
// Render with post-processing | |
composer.render(); | |
} | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment