Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save spencerthayer/3bdcf31cd03d59c508907c1ced16b44c to your computer and use it in GitHub Desktop.
Save spencerthayer/3bdcf31cd03d59c508907c1ced16b44c to your computer and use it in GitHub Desktop.
Capstack Particle Fluid Dynamics
<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>
<div class="formatting">
<input type="radio" name="encoder" id="encode-webm" value="webm" checked="checked" ></input> <label for="encode-webm" >WebM</label>
<input type="radio" name="encoder" id="encode-gif" value="gif" ></input></input> <label for="encode-gif" >GIF</label>
<input type="radio" name="encoder" id="encode-png" value="png" ></input></input> <label for="encode-png" >PNG</label>
<input type="radio" name="encoder" id="encode-jpg" value="jpg" ></input></input> <label for="encode-jpg" >JPEG</label>
</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>
<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>
<script src='https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js'></script>
<script src="https://unpkg.com/ccapture.js@1.1.0/build/CCapture.all.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap" rel="stylesheet">
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() {
const selectedFormat = document.querySelector('input[name="encoder"]:checked').value;
this.isCapturing = true;
this.captureStartTime = performance.now(); // Record the start time
this.capturer = new CCapture({
format: selectedFormat,
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();
});
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%;
}
.formatting, 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, .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;
text-decoration: none;
}
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