A Pen by Spencer Thayer on CodePen.
Created
May 13, 2024 21:46
-
-
Save spencerthayer/72b7903a9975d42caaed1af8d10b1689 to your computer and use it in GitHub Desktop.
Capstack Particle Fluid Dynamics
This file contains 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> | |
<title>Capstack</title> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1"> | |
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap" rel="stylesheet"> | |
<link rel="stylesheet" href="./style.css"> | |
</head> | |
<body> | |
<form id="dotshapeForm"> | |
<div> | |
<label for="width">Width:</label> | |
<input type="text" id="width" name="width" value="600px"> | |
<label for="height">Height:</label> | |
<input type="text" id="height" name="height" value="400px"> | |
<label for="colors">Color:</label> | |
<input type="color" id="bg" name="bg" value="#111111"> | |
<input type="color" id="color01" name="color01" value="#05A89E"> | |
<input type="color" id="color02" name="color02" value="#D5FC01"> | |
<input type="color" id="color03" name="color03" value="#05A89E"> | |
<input type="color" id="color04" name="color04" value="#D5E1FF"> | |
<!-- <label for="blend">Blend:</label> | |
<input type="text" id="blend" name="blend" value="AddEquation"> --> | |
<label for="size">Dot:</label> | |
<input type="number" id="size" name="size" step="0.1" value="1.75"> | |
</div> | |
<div> | |
<label for="time">Time:</label> | |
<input type="number" id="time" name="time" step="0.00001" value="0.002"> | |
<label for="x">X:</label> | |
<input type="number" id="x" name="x" value="45"> | |
<label for="y">Y:</label> | |
<input type="number" id="y" name="y" value="45"> | |
<label for="z">Z:</label> | |
<input type="number" id="z" name="z" value="45"> | |
</div> | |
<div> | |
<label for="deg">Degree:</label> | |
<input type="number" id="deg" name="deg" value="90"> | |
<label for="degrad">Radial:</label> | |
<input type="number" id="degrad" name="degrad" value="220"> | |
<label for="camera">Vectors:</label> | |
<input type="number" id="camera" name="camera" value="180"> | |
<label for="peak">Frequency:</label> | |
<input type="number" id="peak" name="peak" step="0.001" value="1.25"> | |
<label for="zpos">Amplitude:</label> | |
<input type="number" id="zpos" name="zpos" step="0.001" value="0.1"> | |
</div> | |
<div class="newline"> | |
<button id="copyCodeBtn">Copy Code</button> | |
</div> | |
</form> | |
<div id="demoshape" class="dotshape" style="width: 600px; height: 400px;" data-bg="#111111" data-color01="#05A89E" data-color02="#D5FC01" data-color03="#05A89E" data-color04="#D5E1FF" data-blend="AddEquation" data-size="1.75" data-time="0.002" data-x="45" data-y="45" data-z="45" data-deg="90" data-degrad="220" data-camera="180" data-peak="1.25" data-zpos="0.1"></div> | |
<div> | |
<button id="startCapture">Start Capture</button> | |
<button id="stopCapture">Stop Capture</button> | |
</div> | |
<div id="frameInfo"> | |
<span id="frameCount">Frames: 0</span> | |
<span id="duration">Duration: 00:00.000</span> | |
</div> | |
<script src='https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js'></script> | |
<script src="https://unpkg.com/[email protected]/build/CCapture.all.min.js"></script> | |
</body> | |
</html> |
This file contains 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
class Scene { | |
constructor(options) { | |
this.$el = options.el; | |
this.updateFrameInfo = options.updateFrameInfo; | |
this.dotSize = parseFloat(this.$el.getAttribute('data-size')); | |
this.color01 = this.$el.getAttribute('data-color01'); | |
this.color02 = this.$el.getAttribute('data-color02'); | |
this.color03 = this.$el.getAttribute('data-color03'); | |
this.color04 = this.$el.getAttribute('data-color04'); | |
this.bgColor = this.$el.getAttribute('data-bg'); | |
this.speedFactor = parseFloat(this.$el.getAttribute('data-time')); | |
this.positionX = parseFloat(this.$el.getAttribute('data-x')); | |
this.positionY = parseFloat(this.$el.getAttribute('data-y')); | |
this.positionZ = parseFloat(this.$el.getAttribute('data-z')); | |
this.deg = parseFloat(this.$el.getAttribute('data-deg')); | |
this.degRad = parseFloat(this.$el.getAttribute('data-degrad')); | |
this.cameraDeg = parseFloat(this.$el.getAttribute('data-camera')); | |
this.blendValue = parseFloat(this.$el.getAttribute('data-blend')); | |
this.peakValue = parseFloat(this.$el.getAttribute('data-peak')); | |
this.zPos = parseFloat(this.$el.getAttribute('data-zpos')); | |
this.canvasStyle = window.getComputedStyle(this.$el); | |
this.canvasWidth = parseFloat(this.canvasStyle.width) / 2; | |
this.canvasHeight = parseFloat(this.canvasStyle.height) / 2; | |
this.segmentsCalc = this.canvasWidth + this.canvasHeight / 20; | |
this.time = 0; | |
this.bindAll(); | |
this.init(); | |
this.isCapturing = false; | |
this.capturer = null; | |
this.frameCount = 0; | |
} | |
bindAll() { | |
this.render = this.render.bind(this); | |
this.resize = this.resize.bind(this); | |
} | |
init() { | |
this.textureLoader = new THREE.TextureLoader(); | |
this.camera = new THREE.PerspectiveCamera(25, this.$el.clientWidth / this.$el.clientHeight, 1, this.cameraDeg); | |
this.camera.position.x = this.positionX; | |
this.camera.position.y = this.positionY; | |
this.camera.position.z = this.positionX; | |
this.camera.lookAt(new THREE.Vector3(0, 0, 0)); | |
this.scene = new THREE.Scene(); | |
this.scene.background = new THREE.Color(this.bgColor); | |
this.renderer = new THREE.WebGLRenderer({ alpha: true }); | |
this.renderer.setPixelRatio(window.devicePixelRatio); | |
this.renderer.setSize(this.$el.clientWidth, this.$el.clientHeight); | |
// Create a new container for the Three.js scene | |
this.container = document.createElement('div'); | |
this.container.style.position = 'absolute'; | |
this.container.style.top = '0'; | |
this.container.style.left = '0'; | |
this.container.style.zIndex = '-1'; | |
this.$el.appendChild(this.container); | |
this.container.appendChild(this.renderer.domElement); | |
this.createParticles(); | |
this.bindEvents(); | |
this.resize(); | |
this.render(); | |
} | |
createParticles() { | |
var plane = new THREE.PlaneBufferGeometry(this.canvasWidth, this.canvasHeight, this.segmentsCalc, this.segmentsCalc); | |
var material = new THREE.ShaderMaterial({ | |
uniforms: { | |
time: { value: 1.0 }, | |
resolution: { value: new THREE.Vector2() }, | |
color01: { value: new THREE.Color(this.color01) }, | |
color02: { value: new THREE.Color(this.color02) }, | |
color03: { value: new THREE.Color(this.color03) }, | |
color04: { value: new THREE.Color(this.color04) }, | |
peakValue: { value: this.peakValue }, | |
zPos: { value: this.zPos }, | |
dotSize: { value: this.dotSize } | |
}, | |
vertexShader: ` | |
vec4 mod289(vec4 x) { | |
return x - floor(x * (1.0 / 289.0)) * 289.0; | |
} | |
vec4 permute(vec4 x) { | |
return mod289(((x*34.0)+1.0)*x); | |
} | |
vec4 taylorInvSqrt(vec4 r) { | |
return 1.79284291400159 - 0.85373472095314 * r; | |
} | |
vec2 fade(vec2 t) { | |
return t*t*t*(t*(t*6.0-15.0)+10.0); | |
} | |
// Classic Perlin noise | |
float cnoise(vec2 P) { | |
vec4 Pi = floor(P.xyxy) + vec4(0.0, 0.0, 1.0, 1.0); | |
vec4 Pf = fract(P.xyxy) - vec4(0.0, 0.0, 1.0, 1.0); | |
Pi = mod289(Pi); // To avoid truncation effects in permutation | |
vec4 ix = Pi.xzxz; | |
vec4 iy = Pi.yyww; | |
vec4 fx = Pf.xzxz; | |
vec4 fy = Pf.yyww; | |
vec4 i = permute(permute(ix) + iy); | |
vec4 gx = fract(i * (1.0 / 41.0)) * 2.0 - 1.0; | |
vec4 gy = abs(gx) - 0.5; | |
vec4 tx = floor(gx + 0.5); | |
gx = gx - tx; | |
vec2 g00 = vec2(gx.x,gy.x); | |
vec2 g10 = vec2(gx.y,gy.y); | |
vec2 g01 = vec2(gx.z,gy.z); | |
vec2 g11 = vec2(gx.w,gy.w); | |
vec4 norm = taylorInvSqrt(vec4(dot(g00, g00), dot(g01, g01), dot(g10, g10), dot(g11, g11))); | |
g00 *= norm.x; | |
g01 *= norm.y; | |
g10 *= norm.z; | |
g11 *= norm.w; | |
float n00 = dot(g00, vec2(fx.x, fy.x)); | |
float n10 = dot(g10, vec2(fx.y, fy.y)); | |
float n01 = dot(g01, vec2(fx.z, fy.z)); | |
float n11 = dot(g11, vec2(fx.w, fy.w)); | |
vec2 fade_xy = fade(Pf.xy); | |
vec2 n_x = mix(vec2(n00, n01), vec2(n10, n11), fade_xy.x); | |
float n_xy = mix(n_x.x, n_x.y, fade_xy.y); | |
return 2.3 * n_xy; | |
} | |
float map(float value, float oldMin, float oldMax, float newMin, float newMax) { | |
return newMin + (newMax - newMin) * (value - oldMin) / (oldMax - oldMin); | |
} | |
varying float vZ; | |
uniform float time; | |
uniform float dotSize; | |
uniform float peakValue; | |
uniform float zPos; | |
void main() { | |
vec3 newPos = position; | |
vec2 peak = vec2(peakValue - abs(0.5 - uv.x), peakValue - abs(0.5 - uv.y)); | |
vec2 noise = vec2( | |
map(cnoise(vec2(0.3 * time + uv.x * 5., uv.y * 5.)), 0., 1., -2., (peak.x * peak.y * 30.)), | |
map(cnoise(vec2(-0.3 * time + uv.x * 5., uv.y * 5.)), 0., 1., -2., 25.) | |
); | |
newPos.z += (noise.x * noise.y) * zPos; | |
vZ = newPos.z; | |
vec4 mvPosition = modelViewMatrix * vec4(newPos, 1.0); | |
gl_PointSize = dotSize; | |
gl_Position = projectionMatrix * mvPosition; | |
} | |
`, | |
fragmentShader: ` | |
varying float vZ; | |
uniform vec3 color01; | |
uniform vec3 color02; | |
uniform vec3 color03; | |
uniform vec3 color04; | |
float map(float value, float oldMin, float oldMax, float newMin, float newMax) { | |
return newMin + (newMax - newMin) * (value - oldMin) / (oldMax - oldMin); | |
} | |
void main() { | |
float alpha = map(vZ / 2., -1. / 2., 30. / 2., 0.17, 1.); | |
vec3 color; | |
if (alpha < 0.33) { | |
color = mix(color01, color02, alpha * 3.0); | |
} else if (alpha < 0.66) { | |
color = mix(color02, color03, (alpha - 0.33) * 3.0); | |
} else { | |
color = mix(color03, color04, (alpha - 0.66) * 3.0); | |
} | |
gl_FragColor = vec4(color, 1.0); | |
// Create a circular dot using the distance from the center | |
vec2 circCoord = 2.0 * gl_PointCoord - 1.0; | |
if (dot(circCoord, circCoord) > 1.0) { | |
discard; | |
} | |
} | |
`, | |
// https://threejs.org/docs/#api/en/constants/CustomBlendingEquations | |
blending: THREE.CustomBlending, | |
blendEquation: THREE.AddEquation, | |
// blendEquation: THREE.SubtractEquation, | |
blendSrc: THREE.SrcColorFactor, | |
blendDst: THREE.DstColorFactor, | |
transparent: true | |
}); | |
this.particles = new THREE.Points(plane, material); | |
// this.particles.rotation.x = this.degToRad(240); | |
this.particles.rotation.x = (-Math.PI / 2) * this.degToRad(this.deg); | |
this.scene.add(this.particles); | |
} | |
bindEvents() { | |
window.addEventListener('resize', this.resize); | |
} | |
resize() { | |
var width = this.$el.clientWidth; | |
var height = this.$el.clientHeight; | |
this.renderer.setSize(width, height); | |
this.camera.aspect = width / height; | |
this.camera.updateProjectionMatrix(); | |
} | |
moveParticles() { | |
this.particles.material.uniforms.time.value = this.time; | |
} | |
startCapture() { | |
this.isCapturing = true; | |
this.captureStartTime = performance.now(); // Record the start time | |
this.capturer = new CCapture({ | |
format: 'webm', | |
framerate: 60, | |
quality: 100, | |
verbose: true | |
}); | |
this.capturer.start(); | |
} | |
stopCapture() { | |
this.isCapturing = false; | |
this.captureStartTime = null; // Reset the start time | |
this.capturer.stop(); | |
this.capturer.save(); | |
} | |
// Animations | |
render() { | |
requestAnimationFrame(this.render); | |
this.time += this.speedFactor; | |
this.moveParticles(); | |
this.renderer.render(this.scene, this.camera); | |
if (this.isCapturing) { | |
this.capturer.capture(this.renderer.domElement); | |
this.frameCount++; | |
} | |
} | |
// Utils | |
degToRad(angle) { | |
return angle * Math.PI / this.degRad; | |
} | |
updateAttribute(attribute, value) { | |
switch (attribute) { | |
case 'color01': | |
case 'color02': | |
case 'color03': | |
case 'color04': | |
this.particles.material.uniforms[attribute].value.set(value); | |
break; | |
case 'size': | |
this.particles.material.uniforms.dotSize.value = parseFloat(value); | |
break; | |
case 'time': | |
this.speedFactor = parseFloat(value); | |
break; | |
case 'x': | |
this.positionX = parseFloat(value); | |
this.camera.position.x = this.positionX; | |
break; | |
case 'y': | |
this.positionY = parseFloat(value); | |
this.camera.position.y = this.positionY; | |
break; | |
case 'z': | |
this.positionZ = parseFloat(value); | |
this.camera.position.z = this.positionZ; | |
break; | |
case 'deg': | |
this.deg = parseFloat(value); | |
this.particles.rotation.x = (-Math.PI / 2) * this.degToRad(this.deg); | |
break; | |
case 'degrad': | |
this.degRad = parseFloat(value); | |
this.particles.rotation.x = (-Math.PI / 2) * this.degToRad(this.deg); | |
break; | |
case 'camera': | |
this.cameraDeg = parseFloat(value); | |
this.camera.far = this.cameraDeg; | |
this.camera.updateProjectionMatrix(); | |
break; | |
case 'peak': | |
this.particles.material.uniforms.peakValue.value = parseFloat(value); | |
break; | |
case 'zpos': | |
this.particles.material.uniforms.zPos.value = parseFloat(value); | |
break; | |
case 'bg': | |
this.bgColor = value; | |
this.scene.background.set(value); | |
break; | |
default: | |
console.warn('Unknown attribute:', attribute); | |
} | |
} | |
} | |
var containers = document.querySelectorAll('.dotshape'); | |
containers.forEach(function(container) { | |
new Scene({ | |
el: container | |
}); | |
}); | |
document.addEventListener('DOMContentLoaded', function() { | |
var dotshapeForm = document.getElementById('dotshapeForm'); | |
var demoshape = document.getElementById('demoshape'); | |
var sceneInstance = null; | |
var startCaptureButton = document.getElementById('startCapture'); | |
var stopCaptureButton = document.getElementById('stopCapture'); | |
// Disable the "Stop Capture" button by default | |
stopCaptureButton.disabled = true; | |
function createScene() { | |
sceneInstance = new Scene({ | |
el: demoshape, | |
updateFrameInfo: updateFrameInfo // Pass the updateFrameInfo function | |
}); | |
} | |
function updateAttribute(attribute, value) { | |
demoshape.setAttribute(attribute, value); | |
if (sceneInstance) { | |
sceneInstance.updateAttribute(attribute.replace('data-', ''), value); | |
} | |
} | |
function updateStyle(property, value) { | |
demoshape.style[property] = value; | |
if (sceneInstance) { | |
sceneInstance.resize(); | |
} | |
} | |
function toggleButtons() { | |
stopCaptureButton.disabled = !sceneInstance.isCapturing; | |
if (sceneInstance.isCapturing) { | |
sceneInstance.frameCount = 0; | |
updateFrameInfo(); | |
} | |
} | |
function updateFrameInfo() { | |
var frameCountElement = document.getElementById('frameCount'); | |
var durationElement = document.getElementById('duration'); | |
frameCountElement.textContent = 'Frames: ' + sceneInstance.frameCount; | |
if (sceneInstance.captureStartTime) { | |
var elapsedTime = performance.now() - sceneInstance.captureStartTime; | |
var duration = elapsedTime / 1000; // Convert to seconds | |
var minutes = Math.floor(duration / 60); | |
var seconds = Math.floor(duration % 60); | |
var milliseconds = Math.floor((duration % 1) * 1000); | |
var formattedDuration = minutes.toString().padStart(2, '0') + ':' + | |
seconds.toString().padStart(2, '0') + '.' + | |
milliseconds.toString().padStart(3, '0'); | |
durationElement.textContent = 'Duration: ' + formattedDuration; | |
} else { | |
durationElement.textContent = 'Duration: 00:00.000'; | |
} | |
} | |
function updateCaptureInfo() { | |
if (sceneInstance && sceneInstance.isCapturing) { | |
var frameCountElement = document.getElementById('frameCount'); | |
var durationElement = document.getElementById('duration'); | |
frameCountElement.textContent = 'Frames: ' + sceneInstance.frameCount; | |
if (sceneInstance.captureStartTime) { | |
var elapsedTime = performance.now() - sceneInstance.captureStartTime; | |
var duration = elapsedTime / 1000; // Convert to seconds | |
var minutes = Math.floor(duration / 60); | |
var seconds = Math.floor(duration % 60); | |
var milliseconds = Math.floor((duration % 1) * 1000); | |
var formattedDuration = minutes.toString().padStart(2, '0') + ':' + | |
seconds.toString().padStart(2, '0') + '.' + | |
milliseconds.toString().padStart(3, '0'); | |
durationElement.textContent = 'Duration: ' + formattedDuration; | |
} else { | |
durationElement.textContent = 'Duration: 00:00.000'; | |
} | |
requestAnimationFrame(updateCaptureInfo); | |
} | |
} | |
dotshapeForm.addEventListener('input', function(event) { | |
var target = event.target; | |
var attribute = target.name; | |
var value = target.value; | |
if (attribute === 'width' || attribute === 'height') { | |
updateStyle(attribute, value); | |
} else { | |
updateAttribute('data-' + attribute, value); | |
} | |
}); | |
startCaptureButton.addEventListener('click', function() { | |
sceneInstance.startCapture(); | |
toggleButtons(); | |
updateCaptureInfo(); // Start the animation loop | |
}); | |
stopCaptureButton.addEventListener('click', function() { | |
sceneInstance.stopCapture(); | |
toggleButtons(); | |
}); | |
function generateRandomID() { | |
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; | |
let result = ''; | |
for (let i = 0; i < 8; i++) { | |
result += characters.charAt(Math.floor(Math.random() * characters.length)); | |
} | |
return result; | |
} | |
function updateDemoshape() { | |
const form = document.getElementById('dotshapeForm'); | |
const demoshape = document.getElementById('demoshape'); | |
demoshape.setAttribute('id', generateRandomID()); // Set new random ID | |
demoshape.style.width = form.width.value; | |
demoshape.style.height = form.height.value; | |
demoshape.setAttribute('data-bg', form.bg.value); | |
demoshape.setAttribute('data-color01', form.color01.value); | |
demoshape.setAttribute('data-color02', form.color02.value); | |
demoshape.setAttribute('data-color03', form.color03.value); | |
demoshape.setAttribute('data-color04', form.color04.value); | |
demoshape.setAttribute('data-size', form.size.value); | |
demoshape.setAttribute('data-time', form.time.value); | |
demoshape.setAttribute('data-x', form.x.value); | |
demoshape.setAttribute('data-y', form.y.value); | |
demoshape.setAttribute('data-z', form.z.value); | |
demoshape.setAttribute('data-deg', form.deg.value); | |
demoshape.setAttribute('data-degrad', form.degrad.value); | |
demoshape.setAttribute('data-camera', form.camera.value); | |
demoshape.setAttribute('data-peak', form.peak.value); | |
demoshape.setAttribute('data-zpos', form.zpos.value); | |
} | |
document.getElementById('copyCodeBtn').addEventListener('click', function() { | |
updateDemoshape(); | |
const demoshapeHtml = document.getElementById('demoshape').outerHTML; | |
navigator.clipboard.writeText(demoshape).then(() => { | |
alert('Code copied to clipboard!'); | |
}).catch(err => { | |
alert('Failed to copy code: ' + err); | |
}); | |
}); | |
createScene(); | |
}); |
This file contains 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
body { | |
margin: 0; | |
padding: 0; | |
width: 100%; | |
height: 100%; | |
background-color: #000; | |
} | |
canvas { | |
background-color: #111; | |
} | |
.dotshape { | |
/* display: flex; */ | |
position: relative; | |
overflow: auto; | |
margin: 20px; | |
border: #FFF 1px solid; | |
} | |
h1 { | |
color: #F7F6F5; | |
text-shadow: 0px 4px 4px rgba(0, 0, 0, 0.5); | |
font-family: "Open Sans"; | |
font-size: 3em; | |
font-style: normal; | |
font-weight: 600; | |
line-height: 110%; | |
} | |
p, #frameInfo span { | |
color: #F7F6F5; | |
font-family: "Open Sans"; | |
font-size: 18px; | |
font-style: normal; | |
font-weight: 400; | |
line-height: 150%; | |
} | |
/* Form styles */ | |
#dotshapeForm { | |
display: flex; | |
flex-wrap: wrap; | |
justify-content:flex-start; | |
align-items: center; | |
padding: 20px; | |
background-color: #222; | |
color: #F7F6F5; | |
font-family: "Open Sans"; | |
font-size: 1em; | |
} | |
#dotshapeForm label { | |
margin-right: 10px; | |
} | |
#dotshapeForm input { | |
margin: 6px; | |
padding: 5px; | |
width: 75px; | |
border: none; | |
border-radius: 3px; | |
background-color: #444; | |
color: #F7F6F5; | |
} | |
#dotshapeForm input[type="color"] { | |
width: 20px; | |
height: 20px; | |
padding: 0; | |
} | |
#dotshapeForm div.newline { | |
display: block; | |
flex-basis: 100%; | |
} | |
button { | |
margin: 6px; | |
padding: 8px 12px; | |
border: none; | |
border-radius: 3px; | |
font-family: "Open Sans"; | |
font-size: 1em; | |
cursor: pointer; | |
transition: background-color 0.3s ease; | |
background-color: #401db1; | |
color: #F7F6F5; | |
} | |
button:hover { | |
background-color: #b11d56; | |
color: #F7F6F5; | |
} | |
button:disabled { | |
background-color: #444; | |
cursor: not-allowed; | |
} | |
#copyCodeBtn { | |
padding: 8px 16px; | |
margin-top: 10px; | |
font-size: 16px; | |
background-color: #05A89E; | |
color: white; | |
border: none; | |
cursor: pointer; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment