Skip to content

Instantly share code, notes, and snippets.

@kazzohikaru
Created February 22, 2026 00:05
Show Gist options
  • Select an option

  • Save kazzohikaru/121abed9940cba7dfba3bfa6fb6a50ab to your computer and use it in GitHub Desktop.

Select an option

Save kazzohikaru/121abed9940cba7dfba3bfa6fb6a50ab to your computer and use it in GitHub Desktop.
Stream Images with Wind – JavaScript Canvas
<canvas id="scene"></canvas>
<button id="show-btn" style="display:none;">⚙️</button>
<div id="controls">
<div class="panel-header">
<h3>Settings</h3>
<button id="hide-btn">Hide</button>
</div>
<div class="control-group">
<div class="row"><label>Background</label></div>
<input type="color" id="bgColor" value="#ffffff">
</div>
<div class="divider"></div>
<div class="control-group">
<div class="row"><label>Min Wind Speed</label><span id="val-minWind" class="val">1.5</span></div>
<input type="range" id="minWind" min="0" max="40" step="0.5" value="1.5">
</div>
<div class="control-group">
<div class="row"><label>Max Wind Speed</label><span id="val-maxWind" class="val">38</span></div>
<input type="range" id="maxWind" min="0" max="60" step="0.5" value="38">
</div>
<div class="divider"></div>
<div class="control-group">
<div class="row"><label>Min Scale (Size)</label><span id="val-minSize" class="val">16px</span></div>
<input type="range" id="minSize" min="2" max="100" value="16">
</div>
<div class="control-group">
<div class="row"><label>Max Scale (Size)</label><span id="val-maxSize" class="val">57px</span></div>
<input type="range" id="maxSize" min="2" max="200" value="57">
</div>
<div class="divider"></div>
<div class="control-group">
<div class="row"><label>Stream Height (Y)</label><span id="val-emitY" class="val">50%</span></div>
<input type="range" id="emitterY" min="0" max="100" value="50">
</div>
<div class="control-group">
<div class="row"><label>Stream Spread</label><span id="val-spread" class="val">54%</span></div>
<input type="range" id="emitterSpread" min="0" max="100" value="54">
</div>
<div class="divider"></div>
<div class="control-group">
<div class="row"><label>Gravity</label><span id="val-gravity" class="val">0</span></div>
<input type="range" id="gravity" min="-2" max="5" step="0.1" value="0">
</div>
<div class="control-group">
<div class="row"><label>Turbulence</label><span id="val-turb" class="val">0.8</span></div>
<input type="range" id="turbulence" min="0" max="5" step="0.1" value="0.8">
</div>
<div class="divider"></div>
<div class="control-group">
<div class="row"><label>2D Spin Speed</label><span id="val-rot" class="val">0</span></div>
<input type="range" id="rotationSpeed" min="0" max="0.5" step="0.01" value="0">
</div>
<!-- NEW PARAMETER -->
<div class="control-group highlight">
<div class="row"><label>Static Tilt (Y-Axis)</label><span id="val-tilt" class="val">0°</span></div>
<input type="range" id="staticTilt" min="0" max="180" step="1" value="0">
</div>
<div class="control-group">
<div class="row"><label>3D Tumble Animation</label><span id="val-3d" class="val">0.4</span></div>
<input type="range" id="tumbleStrength" min="0" max="10" step="0.1" value="0.4">
</div>
<div class="divider"></div>
<div class="control-group">
<div class="row"><label>Count</label><span id="val-count" class="val">100</span></div>
<input type="range" id="particleCount" min="10" max="800" value="100">
</div>
<div class="control-group">
<label>Upload Images (PNG/SVG)</label>
<input type="file" id="fileInput" multiple accept="image/*">
</div>
</div>
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
let width, height;
let particles = [];
let loadedImages = [];
const settings = {
bgColor: '#ffffff',
minWind: 1.5,
maxWind: 18,
minSize: 16,
maxSize: 57,
emitterY: 0.4,
emitterSpread: 0.35,
gravity: 0.6,
turbulence: 0.8,
rotationSpeed: 0,
tumbleStrength: 0.4,
staticTilt: 0,
particleCount: 100
};
function createDefaultImage() {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = 128;
tempCanvas.height = 128;
const tCtx = tempCanvas.getContext('2d');
tCtx.scale(2, 2);
tCtx.beginPath();
tCtx.moveTo(32, 5);
tCtx.quadraticCurveTo(5, 32, 32, 59);
tCtx.quadraticCurveTo(59, 32, 32, 5);
tCtx.fillStyle = '#e67e22';
tCtx.fill();
tCtx.strokeStyle = '#d35400';
tCtx.lineWidth = 2;
tCtx.stroke();
tCtx.beginPath();
tCtx.moveTo(32, 5);
tCtx.lineTo(32, 59);
tCtx.stroke();
const img = new Image();
img.src = tempCanvas.toDataURL();
return img;
}
loadedImages.push(createDefaultImage());
function rotateVector(x, y, z, ax, ay, az) {
let cos = Math.cos(az);
let sin = Math.sin(az);
let x1 = x * cos - y * sin;
let y1 = x * sin + y * cos;
let z1 = z;
cos = Math.cos(ay);
sin = Math.sin(ay);
let x2 = x1 * cos + z1 * sin;
let y2 = y1;
let z2 = -x1 * sin + z1 * cos;
cos = Math.cos(ax);
sin = Math.sin(ax);
let x3 = x2;
let y3 = y2 * cos - z2 * sin;
let z3 = y2 * sin + z2 * cos;
return { x: x3, y: y3, z: z3 };
}
class Particle {
constructor(initOnScreen = false) {
this.reset(initOnScreen);
}
reset(initOnScreen = false) {
this.image = loadedImages[Math.floor(Math.random() * loadedImages.length)];
const minS = Math.min(settings.minSize, settings.maxSize);
const maxS = Math.max(settings.minSize, settings.maxSize);
this.width = minS + Math.random() * (maxS - minS);
if (this.image.width && this.image.height) {
this.height = this.width * (this.image.height / this.image.width);
} else {
this.height = this.width;
}
const centerY = height * settings.emitterY;
const spreadHeight = height * settings.emitterSpread;
const minY = centerY - (spreadHeight / 2);
const maxY = centerY + (spreadHeight / 2);
this.y = minY + Math.random() * (maxY - minY);
this.x = initOnScreen ? Math.random() * width : -this.width - (Math.random() * 200);
this.windFactor = Math.random();
const sizeFactor = (this.width - minS) / (maxS - minS || 1);
this.windFactor = 1.0 - (sizeFactor * 0.5 + Math.random() * 0.5);
this.windFactor = Math.max(0.1, Math.min(1, this.windFactor));
this.vx = 0;
this.vy = 0;
this.waveOffset = Math.random() * Math.PI * 2;
this.angleZ = Math.random() * 360;
this.spinZ = (Math.random() - 0.5) * settings.rotationSpeed;
// --- CHANGED: Initialize at 0 for flat control ---
this.angleX = 0;
this.angleY = 0;
this.spinX = (Math.random() - 0.5) * 0.1;
this.spinY = (Math.random() - 0.5) * 0.1;
}
update() {
const realMin = Math.min(settings.minWind, settings.maxWind);
const realMax = Math.max(settings.minWind, settings.maxWind);
const targetSpeed = realMin + (realMax - realMin) * this.windFactor;
this.vx += (targetSpeed - this.vx) * 0.1;
this.x += this.vx;
const gravityMod = 1.5 - this.windFactor;
this.vy += settings.gravity * 0.05 * gravityMod;
const wave = Math.sin(this.x * 0.01 + this.waveOffset);
this.vy += wave * (settings.turbulence * 0.05);
this.vy *= 0.98;
this.y += this.vy;
this.angleZ += this.spinZ + (this.vx * 0.002);
if (settings.tumbleStrength > 0) {
this.angleX += this.spinX * settings.tumbleStrength;
this.angleY += this.spinY * settings.tumbleStrength;
}
const buffer = 200;
if (this.x > width + buffer || this.y > height + buffer || this.y < -buffer) {
this.reset(false);
}
}
draw() {
if (!this.image) return;
// Convert Static Tilt to Radians
const tiltRad = (settings.staticTilt * Math.PI) / 180;
// 3D Matrix Projection
const radZ = this.angleZ;
// Add static tilt to the Y angle (Horizontal rotation)
const vecU = rotateVector(1, 0, 0, this.angleX, this.angleY + tiltRad, radZ);
const vecV = rotateVector(0, 1, 0, this.angleX, this.angleY + tiltRad, radZ);
ctx.save();
ctx.translate(this.x, this.y);
ctx.transform(vecU.x, vecU.y, vecV.x, vecV.y, 0, 0);
ctx.drawImage(this.image, -this.width / 2, -this.height / 2, this.width, this.height);
ctx.restore();
}
}
function resize() {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
}
function initParticles() {
particles = [];
for(let i=0; i<settings.particleCount; i++){
particles.push(new Particle(true));
}
}
function animate() {
ctx.fillStyle = settings.bgColor;
ctx.fillRect(0, 0, width, height);
particles.forEach(p => {
p.update();
p.draw();
});
requestAnimationFrame(animate);
}
function adjustParticleCount() {
const diff = settings.particleCount - particles.length;
if (diff > 0) {
for(let i=0; i<diff; i++) particles.push(new Particle(true));
} else {
particles.splice(0, Math.abs(diff));
}
}
// --- UI Logic ---
const controlsPanel = document.getElementById('controls');
const hideBtn = document.getElementById('hide-btn');
const showBtn = document.getElementById('show-btn');
hideBtn.addEventListener('click', () => {
controlsPanel.classList.add('hidden');
showBtn.style.display = 'flex';
});
showBtn.addEventListener('click', () => {
controlsPanel.classList.remove('hidden');
showBtn.style.display = 'none';
});
window.addEventListener('resize', resize);
const uiMap = [
{ id: 'bgColor', prop: 'bgColor', type: 'color' },
{ id: 'minWind', prop: 'minWind', type: 'float', elId: 'val-minWind' },
{ id: 'maxWind', prop: 'maxWind', type: 'float', elId: 'val-maxWind' },
{ id: 'minSize', prop: 'minSize', type: 'float', elId: 'val-minSize', suffix: 'px' },
{ id: 'maxSize', prop: 'maxSize', type: 'float', elId: 'val-maxSize', suffix: 'px' },
{ id: 'emitterY', prop: 'emitterY', type: 'pct', elId: 'val-emitY' },
{ id: 'emitterSpread', prop: 'emitterSpread', type: 'pct', elId: 'val-spread' },
{ id: 'gravity', prop: 'gravity', type: 'float', elId: 'val-gravity' },
{ id: 'turbulence', prop: 'turbulence', type: 'float', elId: 'val-turb' },
{ id: 'particleCount', prop: 'particleCount', type: 'int', elId: 'val-count', action: adjustParticleCount },
{ id: 'rotationSpeed', prop: 'rotationSpeed', type: 'float', elId: 'val-rot', action: () => particles.forEach(p => p.spinZ = (Math.random()-0.5)*settings.rotationSpeed) },
{ id: 'tumbleStrength', prop: 'tumbleStrength', type: 'float', elId: 'val-3d' },
{ id: 'staticTilt', prop: 'staticTilt', type: 'int', elId: 'val-tilt', suffix: '°' } // NEW MAPPING
];
uiMap.forEach(conf => {
const el = document.getElementById(conf.id);
el.addEventListener('input', e => {
let val = e.target.value;
if(conf.type === 'int') val = parseInt(val);
else if(conf.type === 'float') val = parseFloat(val);
else if(conf.type === 'pct') val = parseInt(val) / 100;
settings[conf.prop] = val;
if(conf.elId) {
const displayVal = conf.type === 'pct' ? Math.round(val*100) + '%' : (conf.suffix ? val + conf.suffix : val);
document.getElementById(conf.elId).innerText = displayVal;
}
if(conf.action) conf.action();
});
});
document.getElementById('fileInput').addEventListener('change', e => {
const files = e.target.files;
if (files && files.length > 0) {
const newImgs = [];
let loaded = 0;
for (let i = 0; i < files.length; i++) {
const reader = new FileReader();
reader.onload = (evt) => {
const img = new Image();
img.onload = () => {
loaded++;
};
img.src = evt.target.result;
newImgs.push(img);
if (i === files.length - 1) {
setTimeout(() => {
loadedImages = newImgs;
particles.forEach(p => p.reset(true));
}, 200);
}
};
reader.readAsDataURL(files[i]);
}
}
});
resize();
initParticles();
animate();
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #333;
}
#scene {
display: block;
width: 100%;
height: 100%;
transform: translateZ(0);
}
/* Show Button */
#show-btn {
position: absolute;
bottom: 20px;
right: 20px;
width: 50px;
height: 50px;
background: rgba(20, 20, 20, 0.9);
color: white;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
transition: transform 0.2s;
z-index: 90;
}
#show-btn:hover {
transform: scale(1.1);
background: #444;
}
/* Panel */
#controls {
position: absolute;
bottom: 20px;
right: 20px;
width: 320px;
background: rgba(20, 20, 20, 0.9);
color: #eee;
padding: 20px;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
gap: 12px;
max-height: 90vh;
overflow-y: auto;
border: 1px solid rgba(255,255,255,0.1);
z-index: 100;
transition: opacity 0.3s, transform 0.3s;
}
#controls.hidden {
opacity: 0;
transform: translateY(20px);
pointer-events: none;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #444;
padding-bottom: 8px;
margin-bottom: 5px;
}
.panel-header h3 {
margin: 0;
font-size: 16px;
text-transform: uppercase;
letter-spacing: 1px;
}
#hide-btn {
background: none;
border: 1px solid #555;
color: #aaa;
border-radius: 4px;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
}
#hide-btn:hover {
background: #444;
color: white;
}
#controls::-webkit-scrollbar { width: 6px; }
#controls::-webkit-scrollbar-thumb { background-color: #555; border-radius: 3px; }
.control-group { display: flex; flex-direction: column; gap: 6px; }
.row { display: flex; justify-content: space-between; align-items: center; }
label { font-size: 13px; font-weight: 600; color: #ccc; }
span.val { font-family: monospace; color: #4db8ff; font-size: 12px; }
input[type="range"] { width: 100%; cursor: pointer; accent-color: #4db8ff; }
input[type="file"] { font-size: 12px; color: #bbb; }
input[type="file"]::file-selector-button {
background: #444; color: white; border: none; padding: 5px 10px;
border-radius: 4px; cursor: pointer; margin-right: 10px;
}
input[type="color"] { width: 100%; height: 30px; border: none; border-radius: 4px; cursor: pointer; background: none; }
.divider { height: 1px; background: #444; margin: 5px 0; }
/* Highlight new control */
.highlight label { color: #ffeb3b; }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment