-
-
Save shricodev/a75775c3b97af037478079d95703dffe to your computer and use it in GitHub Desktop.
Blog - Morph Particles (Gemini 2.5 Pro)
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 Demo</title> | |
<style> | |
body { | |
margin: 0; | |
overflow: hidden; | |
background-color: #000000; | |
color: #fff; | |
} | |
canvas { | |
display: block; | |
} | |
#ui-container { | |
position: absolute; | |
top: 10px; | |
left: 10px; | |
background: rgba(20, 20, 20, 0.8); | |
padding: 15px; | |
border-radius: 5px; | |
font-family: Arial, sans-serif; | |
font-size: 12px; | |
min-width: 250px; | |
} | |
#ui-container div { | |
margin-bottom: 8px; | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
} | |
#ui-container label { | |
margin-right: 10px; | |
} | |
#ui-container input[type="range"] { | |
flex-grow: 1; | |
max-width: 120px; | |
} | |
#ui-container input[type="color"] { | |
border: none; | |
background: none; | |
width: 30px; | |
height: 20px; | |
padding: 0; | |
} | |
#ui-container select { | |
padding: 3px; | |
background-color: #333; | |
color: white; | |
border: 1px solid #555; | |
border-radius: 3px; | |
} | |
.value-span { | |
min-width: 30px; | |
text-align: right; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="ui-container"> | |
<div> | |
<label for="particleSize">Particle Size:</label> | |
<input | |
type="range" | |
id="particleSize" | |
min="0.1" | |
max="5.0" | |
step="0.1" | |
value="0.8" | |
/> | |
<span id="particleSizeValue" class="value-span">0.8</span> | |
</div> | |
<div> | |
<label for="rotationSpeed">Rotation Speed:</label> | |
<input | |
type="range" | |
id="rotationSpeed" | |
min="-1.0" | |
max="1.0" | |
step="0.05" | |
value="0.1" | |
/> | |
<span id="rotationSpeedValue" class="value-span">0.1</span> | |
</div> | |
<div> | |
<label for="particleColor">Particle Color:</label> | |
<input type="color" id="particleColor" value="#00ffff" /> | |
</div> | |
<div> | |
<label for="bloomStrength">Bloom Strength:</label> | |
<input | |
type="range" | |
id="bloomStrength" | |
min="0.0" | |
max="3.0" | |
step="0.05" | |
value="0.6" | |
/> | |
<span id="bloomStrengthValue" class="value-span">0.6</span> | |
</div> | |
<div> | |
<label for="motionTrail">Motion Trail:</label> | |
<input | |
type="range" | |
id="motionTrail" | |
min="0.0" | |
max="0.95" | |
step="0.01" | |
value="0.2" | |
/> | |
<span id="motionTrailValue" class="value-span">0.2</span> | |
</div> | |
<div> | |
<label for="morphTarget">Morph Target:</label> | |
<select id="morphTarget"> | |
<option value="0">Sphere</option> | |
<option value="1">Bird</option> | |
<option value="2">Face</option> | |
<option value="3">Tree</option> | |
</select> | |
</div> | |
</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"; | |
import { MeshSurfaceSampler } from "three/addons/math/MeshSurfaceSampler.js"; | |
let scene, camera, renderer, controls; | |
let composer, bloomPass, gammaCorrectionPass; | |
let particleSystem, particleMaterial; | |
const clock = new THREE.Clock(); | |
const NUM_PARTICLES = 30000; | |
const MORPH_DURATION = 2.0; // seconds | |
// Morphing state | |
let currentShapeIndex = 0; | |
let previousShapeIndex = 0; | |
let morphProgress = 0; | |
let isMorphing = false; | |
let morphStartTime = 0; | |
// UI control values | |
const guiControls = { | |
particleSize: 0.8, | |
rotationSpeed: 0.1, | |
particleColor: new THREE.Color(0x00ffff), | |
bloomStrength: 0.6, | |
motionTrailOpacity: 0.2, // 0 = no trail, 0.95 = heavy trail | |
}; | |
// For motion trail effect | |
let trailScene, trailCamera, trailMaterial, trailQuad; | |
// Array to store morph target data (arrays of THREE.Vector3) | |
const morphTargetData = []; | |
init(); | |
animate(); | |
function init() { | |
// Scene | |
scene = new THREE.Scene(); | |
scene.background = new THREE.Color(0x000000); | |
// Camera | |
camera = new THREE.PerspectiveCamera( | |
75, | |
window.innerWidth / window.innerHeight, | |
0.1, | |
1000, | |
); | |
camera.position.z = 10; | |
// Renderer | |
renderer = new THREE.WebGLRenderer({ antialias: true }); | |
renderer.setPixelRatio(window.devicePixelRatio); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
document.body.appendChild(renderer.domElement); | |
// Controls | |
controls = new OrbitControls(camera, renderer.domElement); | |
controls.enableDamping = true; | |
controls.minDistance = 2; | |
controls.maxDistance = 30; | |
// Lighting (minimal, as particles are typically unlit but good for context) | |
const ambientLight = new THREE.AmbientLight(0xffffff, 0.2); | |
scene.add(ambientLight); | |
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
directionalLight.position.set(1, 1, 1); | |
scene.add(directionalLight); | |
// Generate morph target geometries | |
console.log("Generating morph targets..."); | |
morphTargetData.push(generateSpherePoints(NUM_PARTICLES, 3.5)); // Sphere | |
morphTargetData.push(generateBirdPoints(NUM_PARTICLES, 4.0)); // Bird | |
morphTargetData.push(generateFacePoints(NUM_PARTICLES, 3.5)); // Face | |
morphTargetData.push(generateTreePoints(NUM_PARTICLES, 4.5)); // Tree | |
console.log("Morph targets generated."); | |
// Particle System | |
setupParticleSystem(); | |
// Post-processing | |
setupPostProcessing(); | |
// Motion Trail Effect Setup | |
setupMotionTrail(); | |
// UI | |
setupUI(); | |
// Event Listeners | |
window.addEventListener("resize", onWindowResize); | |
} | |
/** | |
* How morph targets are built: | |
* Each morph target is an array of NUM_PARTICLES THREE.Vector3 positions. | |
* These are generated procedurally: | |
* - Sphere: Points distributed evenly on a sphere surface (Fibonacci lattice). | |
* - Bird, Face, Tree: More complex shapes are created using Three.js geometries | |
* (ExtrudeGeometry, LatheGeometry, Cylinder/Sphere primitives). Then, MeshSurfaceSampler | |
* is used to sample NUM_PARTICLES points uniformly from the surface of these meshes. | |
* These sets of points are then stored as attributes in a single BufferGeometry. | |
*/ | |
function generateSpherePoints(count, radius) { | |
const points = []; | |
const phi = Math.PI * (Math.sqrt(5) - 1); // Golden angle in radians | |
for (let i = 0; i < count; i++) { | |
const y = 1 - (i / (count - 1)) * 2; // y goes from 1 to -1 | |
const R = Math.sqrt(1 - y * y); // radius at y | |
const theta = phi * i; | |
const x = Math.cos(theta) * R; | |
const z = Math.sin(theta) * R; | |
points.push(new THREE.Vector3(x, y, z).multiplyScalar(radius)); | |
} | |
return points; | |
} | |
function samplePointsFromMesh(mesh, numPoints) { | |
const sampler = new MeshSurfaceSampler(mesh).build(); | |
const points = []; | |
const tempPosition = new THREE.Vector3(); | |
for (let i = 0; i < numPoints; i++) { | |
sampler.sample(tempPosition); | |
points.push(tempPosition.clone()); | |
} | |
return points; | |
} | |
function generateBirdPoints(count, scale) { | |
const birdShape = new THREE.Shape(); | |
birdShape.moveTo(0, 0); | |
birdShape.bezierCurveTo(0.2, 0.1, 0.5, 0.5, 0.6, 0.3); | |
birdShape.bezierCurveTo(0.8, 0.4, 1.0, 0.2, 1.2, 0.1); | |
birdShape.lineTo(1.1, 0.0); | |
birdShape.bezierCurveTo(0.8, -0.1, 0.5, -0.3, 0.3, -0.2); | |
birdShape.lineTo(0, 0); | |
const extrudeSettings = { depth: 0.1, bevelEnabled: false }; | |
const birdGeom = new THREE.ExtrudeGeometry(birdShape, extrudeSettings); | |
birdGeom.scale(scale, scale, scale); | |
birdGeom.center(); | |
return samplePointsFromMesh(new THREE.Mesh(birdGeom), count); | |
} | |
function generateFacePoints(count, scale) { | |
const faceProfilePoints = [ | |
new THREE.Vector2(0.01, -1.0), | |
new THREE.Vector2(0.3, -0.95), | |
new THREE.Vector2(0.35, -0.6), | |
new THREE.Vector2(0.25, -0.3), | |
new THREE.Vector2(0.4, -0.15), | |
new THREE.Vector2(0.2, 0.1), | |
new THREE.Vector2(0.45, 0.05), | |
new THREE.Vector2(0.4, 0.5), | |
new THREE.Vector2(0.3, 0.8), | |
new THREE.Vector2(0.01, 1.0), | |
]; | |
const faceLatheGeom = new THREE.LatheGeometry(faceProfilePoints, 20); | |
faceLatheGeom.scale(scale, scale, scale); | |
faceLatheGeom.center(); | |
return samplePointsFromMesh(new THREE.Mesh(faceLatheGeom), count); | |
} | |
function generateTreePoints(count, scale) { | |
const points = []; | |
const trunkHeight = 1.5 * scale; | |
const trunkRadius = 0.15 * scale; | |
const canopyRadius = 0.8 * scale; | |
const canopyHeight = 1.2 * scale; | |
const trunkGeom = new THREE.CylinderGeometry( | |
trunkRadius * 0.8, | |
trunkRadius, | |
trunkHeight, | |
12, | |
); | |
trunkGeom.translate(0, trunkHeight / 2, 0); | |
const canopyGeom = new THREE.ConeGeometry( | |
canopyRadius, | |
canopyHeight, | |
16, | |
); | |
canopyGeom.translate(0, trunkHeight + canopyHeight / 2, 0); | |
const numTrunkParticles = Math.floor(count * 0.2); | |
const numCanopyParticles = count - numTrunkParticles; | |
if (numTrunkParticles > 0) { | |
points.push( | |
...samplePointsFromMesh( | |
new THREE.Mesh(trunkGeom), | |
numTrunkParticles, | |
), | |
); | |
} | |
if (numCanopyParticles > 0) { | |
points.push( | |
...samplePointsFromMesh( | |
new THREE.Mesh(canopyGeom), | |
numCanopyParticles, | |
), | |
); | |
} | |
// Center the tree model after points are generated | |
const groupBox = new THREE.Box3(); | |
points.forEach((p) => groupBox.expandByPoint(p)); | |
const centerOffset = new THREE.Vector3(); | |
groupBox.getCenter(centerOffset); | |
points.forEach((p) => p.sub(centerOffset)); | |
return points; | |
} | |
function setupParticleSystem() { | |
const geometry = new THREE.BufferGeometry(); | |
// Store initial positions (sphere) also as morphTarget0 | |
const positions = new Float32Array(NUM_PARTICLES * 3); | |
const morphTarget0 = new Float32Array(NUM_PARTICLES * 3); | |
const morphTarget1 = new Float32Array(NUM_PARTICLES * 3); | |
const morphTarget2 = new Float32Array(NUM_PARTICLES * 3); | |
const morphTarget3 = new Float32Array(NUM_PARTICLES * 3); | |
for (let i = 0; i < NUM_PARTICLES; i++) { | |
morphTargetData[0][i].toArray(morphTarget0, i * 3); | |
morphTargetData[1][i].toArray(morphTarget1, i * 3); | |
morphTargetData[2][i].toArray(morphTarget2, i * 3); | |
morphTargetData[3][i].toArray(morphTarget3, i * 3); | |
} | |
// Initial positions are from morphTarget0 (Sphere) | |
geometry.setAttribute( | |
"position", | |
new THREE.BufferAttribute(morphTarget0, 3), | |
); | |
geometry.setAttribute( | |
"morphTarget0", | |
new THREE.BufferAttribute(morphTarget0, 3), | |
); | |
geometry.setAttribute( | |
"morphTarget1", | |
new THREE.BufferAttribute(morphTarget1, 3), | |
); | |
geometry.setAttribute( | |
"morphTarget2", | |
new THREE.BufferAttribute(morphTarget2, 3), | |
); | |
geometry.setAttribute( | |
"morphTarget3", | |
new THREE.BufferAttribute(morphTarget3, 3), | |
); | |
/** | |
* Shader logic for GPU particle updates: | |
* The vertex shader receives four morph target position attributes (morphTarget0-3). | |
* It also receives uniforms: uSourceIndex, uDestinationIndex, and uProgress. | |
* - uSourceIndex: Integer index (0-3) of the starting shape. | |
* - uDestinationIndex: Integer index (0-3) of the target shape. | |
* - uProgress: Float (0-1) indicating the interpolation amount between source and destination. | |
* The shader selects the two relevant morph target positions based on uSourceIndex and uDestinationIndex, | |
* then uses `mix()` (GLSL's linear interpolation) with uProgress to calculate the current particle position. | |
* This calculation happens per-vertex on the GPU, enabling smooth animation of many particles. | |
* Particle size is also calculated in the vertex shader, with perspective attenuation. | |
* The fragment shader colors the particles and can create soft circular shapes. | |
*/ | |
particleMaterial = new THREE.ShaderMaterial({ | |
uniforms: { | |
uTime: { value: 0.0 }, | |
uParticleSize: { value: guiControls.particleSize }, | |
uParticleColor: { value: guiControls.particleColor }, | |
uSourceIndex: { value: 0 }, | |
uDestinationIndex: { value: 0 }, | |
uProgress: { value: 0.0 }, | |
uPointSizeConstant: { value: window.innerHeight / 2.0 }, // For size attenuation | |
}, | |
vertexShader: ` | |
attribute vec3 morphTarget0; | |
attribute vec3 morphTarget1; | |
attribute vec3 morphTarget2; | |
attribute vec3 morphTarget3; | |
uniform float uTime; | |
uniform float uParticleSize; | |
uniform int uSourceIndex; | |
uniform int uDestinationIndex; | |
uniform float uProgress; | |
uniform float uPointSizeConstant; | |
vec3 getTargetPos(int index) { | |
if (index == 0) return morphTarget0; | |
if (index == 1) return morphTarget1; | |
if (index == 2) return morphTarget2; | |
if (index == 3) return morphTarget3; | |
return morphTarget0; // Default | |
} | |
void main() { | |
vec3 posSource = getTargetPos(uSourceIndex); | |
vec3 posDest = getTargetPos(uDestinationIndex); | |
// Add a tiny bit of individual motion to make it more dynamic (optional) | |
// float randomFactor = fract(sin(dot(posSource.xy ,vec2(12.9898,78.233))) * 43758.5453); | |
// vec3 offset = vec3(sin(uTime * 0.1 + randomFactor * 6.28) * 0.05, cos(uTime * 0.1 + randomFactor * 6.28) * 0.05, 0.0); | |
vec3 morphedPosition = mix(posSource, posDest, uProgress); | |
// morphedPosition += offset; | |
vec4 mvPosition = modelViewMatrix * vec4(morphedPosition, 1.0); | |
gl_Position = projectionMatrix * mvPosition; | |
gl_PointSize = uParticleSize * (uPointSizeConstant / -mvPosition.z); | |
} | |
`, | |
fragmentShader: ` | |
uniform vec3 uParticleColor; | |
void main() { | |
float dist = length(gl_PointCoord - vec2(0.5)); | |
if (dist > 0.5) discard; // Circular points | |
float alpha = 1.0 - smoothstep(0.4, 0.5, dist); // Soft edges | |
gl_FragColor = vec4(uParticleColor, alpha); | |
} | |
`, | |
transparent: true, | |
depthTest: true, // Enable depth test for correct occlusion | |
depthWrite: false, // Disable depth write for transparent particles (especially with bloom) | |
blending: THREE.AdditiveBlending, // Brighter where particles overlap | |
}); | |
particleSystem = new THREE.Points(geometry, particleMaterial); | |
scene.add(particleSystem); | |
} | |
/** | |
* How postprocessing pipeline is assembled: | |
* 1. An EffectComposer is created, which manages a chain of postprocessing passes. | |
* 2. A RenderPass is added first. This pass renders the main scene (with particles) into a buffer. | |
* 3. An UnrealBloomPass is added next. It takes the output of the RenderPass and applies a bloom effect, | |
* making bright areas (like our particles) glow. Its parameters (strength, radius, threshold) can be tuned. | |
* 4. A ShaderPass with GammaCorrectionShader is added last. This corrects the color space of the final image, | |
* ensuring colors look correct on the display. This is typically the final pass before rendering to screen. | |
* In the animation loop, `composer.render()` is called instead of `renderer.render()`, which processes all passes. | |
*/ | |
function setupPostProcessing() { | |
composer = new EffectComposer(renderer); | |
const renderPass = new RenderPass(scene, camera); | |
composer.addPass(renderPass); | |
bloomPass = new UnrealBloomPass( | |
new THREE.Vector2(window.innerWidth, window.innerHeight), | |
1.5, | |
0.4, | |
0.85, | |
); | |
bloomPass.threshold = 0; // Adjust as needed, 0 means almost everything blooms a bit | |
bloomPass.strength = guiControls.bloomStrength; | |
bloomPass.radius = 0.5; | |
composer.addPass(bloomPass); | |
gammaCorrectionPass = new ShaderPass(GammaCorrectionShader); | |
composer.addPass(gammaCorrectionPass); | |
} | |
function setupMotionTrail() { | |
trailScene = new THREE.Scene(); | |
trailCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); | |
const trailQuadGeo = new THREE.PlaneGeometry(2, 2); | |
trailMaterial = new THREE.MeshBasicMaterial({ | |
color: scene.background, // Use scene background color | |
transparent: true, | |
depthTest: false, | |
depthWrite: false, | |
}); | |
trailQuad = new THREE.Mesh(trailQuadGeo, trailMaterial); | |
trailScene.add(trailQuad); | |
} | |
function setupUI() { | |
document | |
.getElementById("particleSize") | |
.addEventListener("input", (e) => { | |
guiControls.particleSize = parseFloat(e.target.value); | |
document.getElementById("particleSizeValue").textContent = | |
guiControls.particleSize.toFixed(1); | |
}); | |
document | |
.getElementById("rotationSpeed") | |
.addEventListener("input", (e) => { | |
guiControls.rotationSpeed = parseFloat(e.target.value); | |
document.getElementById("rotationSpeedValue").textContent = | |
guiControls.rotationSpeed.toFixed(2); | |
}); | |
document | |
.getElementById("particleColor") | |
.addEventListener("input", (e) => { | |
guiControls.particleColor.set(e.target.value); | |
}); | |
document | |
.getElementById("bloomStrength") | |
.addEventListener("input", (e) => { | |
guiControls.bloomStrength = parseFloat(e.target.value); | |
document.getElementById("bloomStrengthValue").textContent = | |
guiControls.bloomStrength.toFixed(2); | |
}); | |
document | |
.getElementById("motionTrail") | |
.addEventListener("input", (e) => { | |
guiControls.motionTrailOpacity = parseFloat(e.target.value); | |
document.getElementById("motionTrailValue").textContent = | |
guiControls.motionTrailOpacity.toFixed(2); | |
}); | |
document | |
.getElementById("morphTarget") | |
.addEventListener("change", (e) => { | |
const newTargetIndex = parseInt(e.target.value); | |
startMorph(newTargetIndex); | |
}); | |
// Initialize UI values display | |
document.getElementById("particleSizeValue").textContent = | |
guiControls.particleSize.toFixed(1); | |
document.getElementById("rotationSpeedValue").textContent = | |
guiControls.rotationSpeed.toFixed(2); | |
document.getElementById("bloomStrengthValue").textContent = | |
guiControls.bloomStrength.toFixed(2); | |
document.getElementById("motionTrailValue").textContent = | |
guiControls.motionTrailOpacity.toFixed(2); | |
} | |
/** | |
* How morph targets are interpolated: | |
* When a new morph target is selected via the UI: | |
* 1. `startMorph(newTargetIndex)` is called. | |
* 2. `previousShapeIndex` is set to the current shape index (the shape we are morphing FROM). | |
* 3. `currentShapeIndex` is updated to `newTargetIndex` (the shape we are morphing TO). | |
* 4. `isMorphing` flag is set to true, and `morphStartTime` is recorded. | |
* 5. Shader uniforms `uSourceIndex` and `uDestinationIndex` are updated to `previousShapeIndex` and `currentShapeIndex` respectively. | |
* In the `animate` loop: | |
* 1. If `isMorphing` is true, `morphProgress` is calculated based on elapsed time since `morphStartTime`, over `MORPH_DURATION`. | |
* 2. This progress (0 to 1, smoothed with a smoothstep function) is passed to the shader as `uProgress`. | |
* 3. The vertex shader uses this `uProgress` to interpolate between `posSource` and `posDest`. | |
* 4. When `morphProgress` reaches 1.0, `isMorphing` is set to false. The `uSourceIndex` and `uDestinationIndex` in the shader | |
* are both set to `currentShapeIndex` and `uProgress` to 0, effectively "locking" the particles to the new shape. | |
*/ | |
function startMorph(newTargetIndex) { | |
if (newTargetIndex === currentShapeIndex && !isMorphing) return; // Already at this shape | |
if ( | |
isMorphing && | |
newTargetIndex === particleMaterial.uniforms.uDestinationIndex.value | |
) | |
return; // Already morphing to this | |
if (isMorphing) { | |
// If a morph is in progress, use its current destination as the new source | |
previousShapeIndex = | |
particleMaterial.uniforms.uDestinationIndex.value; | |
} else { | |
previousShapeIndex = currentShapeIndex; | |
} | |
currentShapeIndex = newTargetIndex; // This now represents the target we are heading towards | |
particleMaterial.uniforms.uSourceIndex.value = previousShapeIndex; | |
particleMaterial.uniforms.uDestinationIndex.value = currentShapeIndex; | |
isMorphing = true; | |
morphStartTime = clock.getElapsedTime(); | |
particleMaterial.uniforms.uProgress.value = 0.0; // Start progress from 0 for the new morph | |
} | |
function onWindowResize() { | |
camera.aspect = window.innerWidth / window.innerHeight; | |
camera.updateProjectionMatrix(); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
composer.setSize(window.innerWidth, window.innerHeight); | |
particleMaterial.uniforms.uPointSizeConstant.value = | |
window.innerHeight / 2.0; | |
} | |
function animate() { | |
requestAnimationFrame(animate); | |
const deltaTime = clock.getDelta(); | |
const elapsedTime = clock.getElapsedTime(); | |
controls.update(); | |
// Update uniforms | |
particleMaterial.uniforms.uTime.value = elapsedTime; | |
particleMaterial.uniforms.uParticleSize.value = | |
guiControls.particleSize; | |
particleMaterial.uniforms.uParticleColor.value.copy( | |
guiControls.particleColor, | |
); | |
bloomPass.strength = guiControls.bloomStrength; | |
// Particle system rotation | |
if (particleSystem) { | |
particleSystem.rotation.y += guiControls.rotationSpeed * deltaTime; | |
} | |
// Morphing logic | |
if (isMorphing) { | |
const morphElapsedTime = elapsedTime - morphStartTime; | |
let progress = Math.min(morphElapsedTime / MORPH_DURATION, 1.0); | |
// Smoothstep: t * t * (3 - 2 * t) | |
progress = progress * progress * (3.0 - 2.0 * progress); | |
particleMaterial.uniforms.uProgress.value = progress; | |
if (progress >= 1.0) { | |
isMorphing = false; | |
// Lock to the destination shape | |
particleMaterial.uniforms.uSourceIndex.value = currentShapeIndex; | |
particleMaterial.uniforms.uDestinationIndex.value = | |
currentShapeIndex; | |
particleMaterial.uniforms.uProgress.value = 0.0; | |
// currentShapeIndex already reflects the target shape. | |
// previousShapeIndex is now effectively currentShapeIndex for the next morph. | |
} | |
} | |
// Motion trail effect | |
// We need to render the trail quad *before* the main scene is rendered by the composer. | |
// This "fades" the previous frame. | |
renderer.autoClearColor = false; // We will manually clear (or fade) with the quad | |
const currentRenderTarget = renderer.getRenderTarget(); // Preserve render target for composer | |
renderer.setRenderTarget(null); // Ensure quad renders to screen buffer, which composer will read | |
trailMaterial.color.copy(scene.background); // Match scene background for fading | |
trailMaterial.opacity = 1.0 - guiControls.motionTrailOpacity; // 0 means full clear, 0.95 means heavy trail | |
renderer.render(trailScene, trailCamera); | |
renderer.setRenderTarget(currentRenderTarget); // Restore render target for composer | |
renderer.autoClearColor = true; // Composer will handle its own clearing for its passes | |
// Render with post-processing | |
composer.render(deltaTime); | |
} | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment