Vortex_Ring_Collusion / index.html
AK51's picture
Upload 2 files
6987155 verified
Raw History Blame Contribute Delete
42.8 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Collision of Two Vortex Rings β€” Particle Fluid</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#e9ecf2;overflow:hidden;font-family:'Segoe UI',system-ui,sans-serif}
canvas{display:block}
#ui{position:fixed;top:14px;left:16px;color:#2a3550;pointer-events:none;user-select:none}
#ui h1{font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#1a2440}
#ui .sub{font-size:10px;color:#7a86a0;margin:2px 0 12px}
#ui .stat{font-size:11px;color:#55617d;line-height:2}
#ui .stat span{color:#2a3550;font-weight:600}
#re{position:fixed;top:14px;right:22px;font-size:30px;font-weight:300;letter-spacing:.12em;color:#444d66;pointer-events:none;user-select:none;font-family:'Consolas',monospace}
#legend{position:fixed;top:60px;right:22px;font-size:11px;color:#55617d;pointer-events:none}
.lr{display:flex;align-items:center;gap:7px;margin-bottom:6px;justify-content:flex-end}
.dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.r{background:#c0392b}
.b{background:#2255cc}
#bar{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);display:flex;gap:7px}
#bar button{background:rgba(255,255,255,.7);border:1px solid rgba(40,55,90,.25);color:#2a3550;padding:7px 15px;border-radius:6px;font-size:11px;cursor:pointer;letter-spacing:.04em;transition:background .16s;backdrop-filter:blur(4px)}
#bar button:hover{background:rgba(255,255,255,.95)}
#panel{
position:fixed;top:50%;right:18px;transform:translateY(-50%);
width:290px;max-height:88vh;overflow-y:auto;
background:rgba(255,255,255,.92);border:1px solid rgba(40,55,90,.2);
border-radius:10px;box-shadow:0 8px 30px rgba(30,45,80,.18);
backdrop-filter:blur(8px);color:#2a3550;
display:none;z-index:20;
}
#panel.open{display:block}
.ph{
display:flex;align-items:center;justify-content:space-between;
padding:12px 14px;border-bottom:1px solid rgba(40,55,90,.12);
font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
}
.ph button{background:none;border:none;color:#7a86a0;font-size:14px;cursor:pointer;padding:2px 4px}
.ph button:hover{color:#2a3550}
.pbody{padding:12px 14px}
.pbody label{
display:flex;justify-content:space-between;align-items:baseline;
font-size:11px;color:#55617d;margin:12px 0 4px;font-weight:600;
}
.pbody label:first-child{margin-top:0}
.pbody output{color:#2255cc;font-family:'Consolas',monospace;font-size:11px}
.pbody input[type=range]{
width:100%;height:4px;border-radius:3px;background:#c6cfdf;
-webkit-appearance:none;appearance:none;outline:none;cursor:pointer;
}
.pbody input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;
background:#2255cc;cursor:pointer;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.3);
}
.pbody input[type=range]::-moz-range-thumb{
width:14px;height:14px;border-radius:50%;background:#2255cc;cursor:pointer;border:2px solid #fff;
}
.prow{display:flex;gap:8px;margin-top:18px}
.prow button{
flex:1;background:#2255cc;border:none;color:#fff;padding:9px;border-radius:6px;
font-size:11px;cursor:pointer;letter-spacing:.04em;transition:background .16s;
}
.prow button#pReset{background:rgba(40,55,90,.12);color:#2a3550}
.prow button:hover{filter:brightness(1.08)}
.pnote{font-size:10px;color:#8894ac;line-height:1.5;margin-top:12px}
.pgroup{font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#2255cc;margin:4px 0 2px;padding-top:8px;border-top:1px solid rgba(40,55,90,.1)}
.pgroup:first-child{border-top:none;padding-top:0}
.psel{width:100%;padding:7px 8px;border-radius:6px;border:1px solid rgba(40,55,90,.25);background:#fff;color:#2a3550;font-size:11px;cursor:pointer;margin-top:2px}
.pprops{font-size:10px;color:#55617d;line-height:1.6;margin-top:10px;background:rgba(40,55,90,.05);border-radius:6px;padding:8px 10px}
.pprops b{color:#2a3550}
.pgroup small{font-weight:600;text-transform:none;letter-spacing:0;color:#8894ac;font-size:9px;float:right}
.pcols{display:flex;gap:10px}
.pcols>*{flex:1;min-width:0}
.pcol label{margin-top:0}
#about{
position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
width:min(560px,92vw);max-height:86vh;overflow-y:auto;
background:rgba(255,255,255,.97);border:1px solid rgba(40,55,90,.2);
border-radius:12px;box-shadow:0 12px 50px rgba(30,45,80,.3);
backdrop-filter:blur(10px);color:#2a3550;display:none;z-index:30;
}
#about.open{display:block}
.abody{padding:16px 22px 22px}
.abody p{font-size:12px;line-height:1.65;color:#44506a;margin:4px 0 6px}
.agroup{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#2255cc;margin:18px 0 6px;padding-top:12px;border-top:1px solid rgba(40,55,90,.12)}
.agroup:first-child{margin-top:0;padding-top:0;border-top:none}
.aparams{margin:4px 0}
.aparams dt{font-size:12px;font-weight:600;color:#1a2440;margin-top:10px}
.aparams dd{font-size:11.5px;line-height:1.55;color:#55617d;margin:2px 0 0 0}
.arefs{margin:4px 0 0 18px;padding:0}
.arefs li{font-size:11.5px;line-height:1.7;color:#55617d}
.arefs a{color:#2255cc;text-decoration:none}
.arefs a:hover{text-decoration:underline}
.acredit{margin-top:22px;padding-top:14px;border-top:1px solid rgba(40,55,90,.12);text-align:center;font-size:13px;font-weight:600;color:#2a3550;letter-spacing:.04em}
</style>
</head>
<body>
<div id="ui">
<h1>Rocket Propellant Vortex-Ring Mixing</h1>
<div class="sub">Three.js Β· Dye-Particle Fluid Β· colliding oxidizer / fuel streams</div>
<div class="stat">
Dye particles&nbsp; <span id="sN">β€”</span><br>
Time&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span id="sT">0.00</span><br>
Phase&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span id="sP">Approach</span>
</div>
</div>
<div id="re">Re = 1750</div>
<div id="legend">
<div class="lr"><span id="legA">RP-1 Kerosene</span>&nbsp;<div class="dot" id="dotA"></div></div>
<div class="lr"><span id="legB">Liquid Oxygen</span>&nbsp;<div class="dot" id="dotB"></div></div>
<div class="lr" style="margin-top:4px;font-size:10px;color:#8894ac" id="legMix">O/F mixing</div>
</div>
<div id="bar">
<button id="bR">β†Ί Restart</button>
<button id="bP">⏸ Pause</button>
<button id="bD">β—³ View</button>
<button id="bT">βŠ™ Front</button>
<button id="bG">βš™ Parameters</button>
<button id="bA">β“˜ About</button>
</div>
<div id="panel">
<div class="ph">
<span>Simulation Parameters</span>
<button id="pClose">βœ•</button>
</div>
<div class="pbody">
<div class="pgroup">Propellants</div>
<div class="pcols">
<div class="pcol">
<label>Left Stream</label>
<select id="pFuelA" class="psel"></select>
</div>
<div class="pcol">
<label>Right Stream</label>
<select id="pFuelB" class="psel"></select>
</div>
</div>
<div class="pprops" id="propInfo"></div>
<div class="pgroup">Per-stream properties <small>Left / Right</small></div>
<label>Jet strength &nbsp;(circulation Ξ“)<span><output id="oGammaL">22.0</output> / <output id="oGammaR">22.0</output></span></label>
<div class="pcols">
<input type="range" id="pGammaL" min="6" max="40" step="0.5" value="22">
<input type="range" id="pGammaR" min="6" max="40" step="0.5" value="22">
</div>
<label>Ring radius &nbsp;R<span><output id="oRL">2.6</output> / <output id="oRR">2.6</output></span></label>
<div class="pcols">
<input type="range" id="pRL" min="1.2" max="4.0" step="0.1" value="2.6">
<input type="range" id="pRR" min="1.2" max="4.0" step="0.1" value="2.6">
</div>
<label>Launch distance<span><output id="oSepL">9.0</output> / <output id="oSepR">9.0</output></span></label>
<div class="pcols">
<input type="range" id="pSepL" min="5" max="16" step="0.5" value="9">
<input type="range" id="pSepR" min="5" max="16" step="0.5" value="9">
</div>
<label>Vertical offset<span><output id="oYoffL">0.1</output> / <output id="oYoffR">0.1</output></span></label>
<div class="pcols">
<input type="range" id="pYoffL" min="0" max="5" step="0.1" value="0.1">
<input type="range" id="pYoffR" min="0" max="5" step="0.1" value="0.1">
</div>
<label>Core thickness &nbsp;Ξ΅<span><output id="oEpsL">0.62</output> / <output id="oEpsR">0.62</output></span></label>
<div class="pcols">
<input type="range" id="pEpsL" min="0.3" max="1.2" step="0.02" value="0.62">
<input type="range" id="pEpsR" min="0.3" max="1.2" step="0.02" value="0.62">
</div>
<label>Instability lobes &nbsp;(wavenumber)<span><output id="oLobeL">8</output> / <output id="oLobeR">8</output></span></label>
<div class="pcols">
<input type="range" id="pLobeL" min="0" max="16" step="1" value="8">
<input type="range" id="pLobeR" min="0" max="16" step="1" value="8">
</div>
<div class="pgroup">System &amp; numerics</div>
<label>Reynolds number &nbsp;Re<output id="oRe">1750</output></label>
<input type="range" id="pRe" min="100" max="5000" step="50" value="1750">
<label>Viscosity &nbsp;(dye diffusion)<output id="oDiff">0.0016</output></label>
<input type="range" id="pDiff" min="0" max="0.02" step="0.0005" value="0.0016">
<label>Dye particles<output id="oN">48,000</output></label>
<input type="range" id="pN" min="8000" max="90000" step="2000" value="48000">
<label>Particle size<output id="oSize">0.5</output></label>
<input type="range" id="pSize" min="0.1" max="8" step="0.1" value="0.5">
<label>Playback speed &nbsp;(dt)<output id="oDt">0.022</output></label>
<input type="range" id="pDt" min="0.006" max="0.04" step="0.002" value="0.022">
<div class="prow">
<button id="pApply">Apply &amp; Restart</button>
<button id="pReset">Defaults</button>
</div>
<div class="pnote">Per-stream sliders set the Left / Right propellant jets independently. Re maps to dye diffusion: higher Re β†’ sharper filaments; lower Re β†’ more blurred dye.</div>
</div>
</div>
<div id="about">
<div class="ph">
<span>About this Simulation</span>
<button id="aClose">βœ•</button>
</div>
<div class="abody">
<div class="agroup">Objective</div>
<p>This application visualises the head-on collision of two vortex rings as a
model of rocket-propellant stream mixing. Two coaxial rings β€” one per
propellant β€” are fired toward each other. As they meet, they interpenetrate,
the dye intermixes, and the merged structure blooms outward into a larger,
lobed ring. It reproduces the qualitative behaviour of the classic Lim &amp;
Nickels (1992) experiment and the Basilisk reference simulation, letting you
explore how propellant choice and flow parameters shape the mixing.</p>
<div class="agroup">Method</div>
<p>Each ring is a loop of discrete vortex elements. Both the dye particles and
the vortex elements move in the summed Biot–Savart velocity field of every
element, so self-propulsion, collision and radial expansion all emerge from
the physics rather than being scripted. This is a lightweight qualitative
model, not a full Navier–Stokes combustion solver.</p>
<div class="agroup">Parameters</div>
<p>Parameters that describe a propellant jet are set <b>independently per
stream</b> (Left / Right). Parameters that describe the shared domain or the
numerical scheme are <b>system-wide</b>.</p>
<div class="agroup" style="color:#2a3550;font-size:10px">Per-stream (Left / Right)</div>
<dl class="aparams">
<dt>Left Stream / Right Stream</dt>
<dd>The propellant assigned to each ring. Sets its dye colour, density
(stream momentum) and viscosity (dye diffusion). The note flags whether the
pair is a real oxidizer + fuel combination.</dd>
<dt>Jet strength (circulation Ξ“)</dt>
<dd>How hard that ring is fired β€” analogous to the injection velocity of
the propellant jet. Controls that stream's approach and collision speed.</dd>
<dt>Ring radius R</dt>
<dd>The major radius of that ring at launch (its nozzle diameter).</dd>
<dt>Launch distance</dt>
<dd>Starting distance of that ring's centre from the origin along the
collision axis. The two streams can start at different distances.</dd>
<dt>Vertical offset</dt>
<dd>Raises the left ring / lowers the right ring by this amount. Unequal
offsets produce an oblique, off-centre collision instead of a head-on one.</dd>
<dt>Core thickness Ξ΅</dt>
<dd>The Biot–Savart smoothing length of that ring β€” effectively its vortex
core size. Larger values give a thicker, gentler core.</dd>
<dt>Instability lobes (wavenumber)</dt>
<dd>Azimuthal perturbation seeding the scalloped "flower" lobes on that
ring. 0 = perfectly axisymmetric.</dd>
</dl>
<div class="agroup" style="color:#2a3550;font-size:10px">System-wide</div>
<dl class="aparams">
<dt>Reynolds number (Re)</dt>
<dd>Ratio of inertial to viscous forces for the whole scene. Drives the dye
diffusion rate: high Re β†’ thin, crisp filaments; low Re β†’ blurred dye.
Linked to the viscosity slider.</dd>
<dt>Viscosity (dye diffusion)</dt>
<dd>The base random-walk coefficient applied to the dye. Inversely linked to
Re; combined with each propellant's own viscosity.</dd>
<dt>Dye particles</dt>
<dd>Total number of dye particles (half per ring). More particles give a
denser cloud at a higher compute cost.</dd>
<dt>Particle size</dt>
<dd>On-screen size of each dye point. Updates live without a restart.</dd>
<dt>Playback speed (dt)</dt>
<dd>Simulation time step per frame β€” slows down or speeds up the animation.</dd>
</dl>
<div class="agroup">References</div>
<ul class="arefs">
<li><a href="https://basilisk.fr/sandbox/Antoonvh/two_rings.c" target="_blank" rel="noopener">Basilisk β€” Collision of two vortex rings (Antoon van Hooft)</a></li>
<li><a href="https://github.com/dgreenheck/threejs-particle-fluids" target="_blank" rel="noopener">dgreenheck / threejs-particle-fluids</a></li>
<li>Lim, T.T. &amp; Nickels, T.B. (1992). Instability and reconnection in the head-on collision of two vortex rings. <i>Nature</i>, 357, 225–227.</li>
<li><a href="https://threejs.org/" target="_blank" rel="noopener">Three.js</a></li>
</ul>
<div class="acredit">Created by Andy Kong</div>
</div>
</div>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.165.0/examples/jsm/"}}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// RENDERER / SCENE / CAMERA β€” light studio look like the reference
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xe9ecf2);
scene.fog = new THREE.Fog(0xe9ecf2, 70, 150);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 1000);
// isometric-ish 3/4 view, matching the reference video angle
camera.position.set(20, 11, 24);
camera.lookAt(0, 0, 0);
const ctrl = new OrbitControls(camera, renderer.domElement);
ctrl.enableDamping = true;
ctrl.dampingFactor = 0.06;
ctrl.target.set(0, 0, 0);
ctrl.minDistance = 6;
ctrl.maxDistance = 90;
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// GROUND GRID (depth reference, like the Basilisk cells plane)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const grid = new THREE.GridHelper(60, 30, 0x8894ac, 0xc2cad8);
grid.position.y = -11;
grid.material.opacity = 0.55;
grid.material.transparent = true;
scene.add(grid);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// ROCKET PROPELLANTS
// Each stream (ring) is a propellant. Properties drive the look & physics:
// color : dye tint [r,g,b]
// density : relative liquid density (kg/mΒ³ scale) β†’ stream momentum
// viscosity : relative dynamic viscosity β†’ dye diffusion rate
// Densities are the usual liquid/cryogenic values used in rocketry.
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const PROPELLANTS = {
LOX: { name: 'Liquid Oxygen (LOX)', color: [0.45, 0.72, 1.00], density: 1141, viscosity: 0.20, kind: 'oxidizer' },
LH2: { name: 'Liquid Hydrogen (LHβ‚‚)',color: [0.85, 0.90, 1.00], density: 71, viscosity: 0.014, kind: 'fuel' },
CH4: { name: 'Liquid Methane (CHβ‚„)', color: [0.30, 0.85, 0.70], density: 423, viscosity: 0.12, kind: 'fuel' },
RP1: { name: 'RP-1 Kerosene', color: [0.95, 0.65, 0.25], density: 810, viscosity: 1.60, kind: 'fuel' },
N2O4: { name: 'Nitrogen Tetroxide', color: [0.80, 0.45, 0.20], density: 1443, viscosity: 0.42, kind: 'oxidizer' },
UDMH: { name: 'UDMH Hydrazine', color: [0.95, 0.85, 0.55], density: 791, viscosity: 0.55, kind: 'fuel' },
N2: { name: 'Liquid Nitrogen (LNβ‚‚)',color: [0.70, 0.80, 0.90], density: 807, viscosity: 0.16, kind: 'inert' },
};
// reference density/viscosity used to normalise the physics scaling
const RHO_REF = 810; // ~RP-1
const MU_REF = 1.0;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// CONFIG
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const C = {
// ---- PER-STREAM properties (L = left/+x-bound ring, R = right/βˆ’x-bound ring) ----
fuelL: 'RP1', fuelR: 'LOX', // propellant of each stream
GammaL: 22.0, GammaR: 22.0, // jet strength (circulation) of each stream
radL: 2.6, radR: 2.6, // ring radius of each stream
yOffL: 0.1, yOffR: 0.1, // vertical rise (L up) / drop (R down)
sepL: 9.0, sepR: 9.0, // launch distance of each ring from the origin
epsL: 0.62, epsR: 0.62, // core thickness of each stream
lobeL: 8, lobeR: 8, // azimuthal instability lobes of each stream
// ---- SYSTEM-WIDE properties ----
Ndye: 48_000, // dye particles total (half per ring)
core: 0.42, // dye tube radius (seeding spread)
Nvort: 46, // vortex elements per ring (azimuthal discretisation)
dt: 0.022, // time step
wobbleAmp: 0.045, // perturbation amplitude seeding the lobe instability
diffuse: 0.0016, // base dye random-walk coefficient (scaled by Re & viscosity)
};
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// VORTEX ELEMENTS
// Each ring = Nvort vortex "blobs" on a circle. Each carries a
// tangential vorticity vector (direction = ring tangent). The induced
// velocity of a vortex blob at point p:
// u = (Ξ“/4Ο€) * ( Ο‰ Γ— r ) / (|r|Β²+Ρ²)^{3/2}
// Summing over all blobs of BOTH rings gives self-propulsion,
// mutual interaction, collision and radial bloom automatically.
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
let NV = C.Nvort * 2; // total vortex elements
let vP = new Float32Array(NV * 3); // positions
let vW = new Float32Array(NV * 3); // vorticity vectors (Ξ“ * tangent * dl)
let vE2 = new Float32Array(NV); // per-element core smoothing Ρ² (per stream)
// Build one ring of vortex elements.
// baseIdx : element offset into the shared arrays
// cx : centre x
// dir : +1 ring tangent sense (sets propagation direction)
// yOff : vertical shift of the whole ring
// gammaScale : per-ring circulation multiplier (heavier propellant β†’ more momentum)
// Gamma : per-stream jet strength
// R : per-stream ring radius
// eps : per-stream core thickness
// lobeN : per-stream instability wavenumber
function seedRing(baseIdx, cx, dir, yOff, gammaScale, Gamma, R, eps, lobeN) {
const e2 = eps * eps;
for (let i = 0; i < C.Nvort; i++) {
const th = (2 * Math.PI * i) / C.Nvort;
// small azimuthal radius wobble seeds the flower instability
const rr = R * (1 + C.wobbleAmp * Math.cos(lobeN * th));
const idx = (baseIdx + i) * 3;
vE2[baseIdx + i] = e2;
// ring lies in y-z plane, centred on x-axis
const y = rr * Math.cos(th) + yOff;
const z = rr * Math.sin(th);
vP[idx] = cx;
vP[idx+1] = y;
vP[idx+2] = z;
// tangent of the circle (y-z plane): d/dΞΈ (cosΞΈ, sinΞΈ) = (-sinΞΈ, cosΞΈ)
const tx = 0;
const ty = -Math.sin(th);
const tz = Math.cos(th);
// vorticity magnitude Ξ“ * segment length, scaled by propellant momentum
const dl = (2 * Math.PI * R) / C.Nvort;
const g = Gamma * gammaScale * dl * dir;
vW[idx] = tx * g;
vW[idx+1] = ty * g;
vW[idx+2] = tz * g;
}
}
// momentum scaling from density: heavier liquid carries more momentum, so its
// ring propagates faster. We use sqrt(ρ/ρ_ref) and clamp to a sane visual range.
function gammaScaleFor(key) {
const p = PROPELLANTS[key];
return Math.min(1.8, Math.max(0.45, Math.sqrt(p.density / RHO_REF)));
}
// per-ring diffusion from viscosity (more viscous β†’ more diffuse dye spread here)
function diffuseFor(key) {
const p = PROPELLANTS[key];
return C.diffuse * Math.min(3.0, Math.max(0.3, Math.sqrt(p.viscosity / MU_REF) + 0.4));
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// INDUCED VELOCITY from all vortex elements at point (px,py,pz)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const _u = new Float64Array(3);
const INV4PI = 1 / (4 * Math.PI);
function induced(px, py, pz) {
let ux = 0, uy = 0, uz = 0;
for (let j = 0; j < NV; j++) {
const j3 = j * 3;
const rx = px - vP[j3];
const ry = py - vP[j3+1];
const rz = pz - vP[j3+2];
const r2 = rx*rx + ry*ry + rz*rz + vE2[j];
const inv = 1 / (r2 * Math.sqrt(r2));
const wx = vW[j3], wy = vW[j3+1], wz = vW[j3+2];
// Ο‰ Γ— r
ux += (wy*rz - wz*ry) * inv;
uy += (wz*rx - wx*rz) * inv;
uz += (wx*ry - wy*rx) * inv;
}
_u[0] = ux * INV4PI;
_u[1] = uy * INV4PI;
_u[2] = uz * INV4PI;
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// DYE PARTICLES
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
let N = C.Ndye;
let pos = new Float32Array(N * 3);
let col = new Float32Array(N * 3);
function seedDye() {
const half = N >> 1;
const a = C.core;
const colL = PROPELLANTS[C.fuelL].color;
const colR = PROPELLANTS[C.fuelR].color;
for (let i = 0; i < half; i++) {
// ---- Left ring, fired from -x toward +x, shifted up ----
seedDyeParticle(i * 3, -C.sepL, C.radL, a, true, +C.yOffL, colL, C.lobeL);
// ---- Right ring, fired from +x toward -x, shifted down ----
seedDyeParticle((half + i) * 3, C.sepR, C.radR, a, false, -C.yOffR, colR, C.lobeR);
}
}
function seedDyeParticle(idx, cx, R, a, forward, yOff, baseCol, lobeN) {
const th = Math.random() * 2 * Math.PI;
const phi = Math.random() * 2 * Math.PI;
// concentrate dye near the core but with a trailing tail toward launch side
const rr = a * Math.pow(Math.random(), 0.5);
const rEff = R * (1 + C.wobbleAmp * Math.cos(lobeN * th)) + rr * Math.cos(phi);
const y = rEff * Math.cos(th) + yOff;
const z = rEff * Math.sin(th);
// give a short launch tail: 20% of particles trail behind the ring
const tail = Math.random() < 0.22 ? (Math.random() * 2.4) : 0;
const x = cx + rr * Math.sin(phi) + (forward ? -tail : tail);
pos[idx] = x;
pos[idx+1] = y;
pos[idx+2] = z;
// propellant tint with a small per-particle brightness jitter
const j = 0.88 + 0.12 * Math.random();
col[idx] = Math.min(1, baseCol[0] * j);
col[idx+1] = Math.min(1, baseCol[1] * j);
col[idx+2] = Math.min(1, baseCol[2] * j);
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// THREE.JS POINTS (dark dye on light background β€” normal blending)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
let geo = new THREE.BufferGeometry();
let bPos = new THREE.BufferAttribute(pos, 3).setUsage(THREE.DynamicDrawUsage);
let bCol = new THREE.BufferAttribute(col, 3);
geo.setAttribute('position', bPos);
geo.setAttribute('dyeColor', bCol);
// NOTE: we feed a custom "dyeColor" attribute (NOT the built-in "color"),
// and we do NOT set vertexColors β€” otherwise three.js injects its own
// `attribute vec3 color;` which collides with ours and the shader fails
// to compile, drawing nothing.
const PR = renderer.getPixelRatio();
const mat = new THREE.ShaderMaterial({
uniforms: { uSize: { value: 2.4 * PR } },
vertexShader: `
attribute vec3 dyeColor;
varying vec3 vCol;
uniform float uSize;
void main() {
vCol = dyeColor;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = uSize * (170.0 / -mv.z);
gl_Position = projectionMatrix * mv;
}
`,
fragmentShader: `
varying vec3 vCol;
void main() {
float d = length(gl_PointCoord - 0.5);
if (d > 0.5) discard;
float a = 0.9 * (1.0 - smoothstep(0.30, 0.5, d));
gl_FragColor = vec4(vCol, a);
}
`,
transparent: true,
depthWrite: false,
blending: THREE.NormalBlending, // dark dye reads on light background
});
let pts = new THREE.Points(geo, mat);
pts.frustumCulled = false; // expanding cloud must never be auto-culled
scene.add(pts);
// Rebuild all size-dependent buffers (called when particle/vortex counts change)
function rebuildBuffers() {
NV = C.Nvort * 2;
N = C.Ndye;
vP = new Float32Array(NV * 3);
vW = new Float32Array(NV * 3);
vE2 = new Float32Array(NV);
vTmp = new Float32Array(NV * 3);
pos = new Float32Array(N * 3);
col = new Float32Array(N * 3);
scene.remove(pts);
geo.dispose();
geo = new THREE.BufferGeometry();
bPos = new THREE.BufferAttribute(pos, 3).setUsage(THREE.DynamicDrawUsage);
bCol = new THREE.BufferAttribute(col, 3);
geo.setAttribute('position', bPos);
geo.setAttribute('dyeColor', bCol);
pts = new THREE.Points(geo, mat);
pts.frustumCulled = false;
scene.add(pts);
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// INTEGRATION
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
let simTime = 0;
let paused = false;
// Box–Muller for diffusion
let _bm = null;
function randn() {
if (_bm !== null) { const v = _bm; _bm = null; return v; }
let u, v, s;
do { u = Math.random()*2-1; v = Math.random()*2-1; s = u*u+v*v; } while (s >= 1);
const f = Math.sqrt(-2*Math.log(s)/s);
_bm = v*f; return u*f;
}
// advance vortex elements (they advect in each other's field)
let vTmp = new Float32Array(NV * 3);
function stepVortex(dt) {
for (let j = 0; j < NV; j++) {
const j3 = j*3;
induced(vP[j3], vP[j3+1], vP[j3+2]);
vTmp[j3] = vP[j3] + _u[0]*dt;
vTmp[j3+1] = vP[j3+1] + _u[1]*dt;
vTmp[j3+2] = vP[j3+2] + _u[2]*dt;
}
vP.set(vTmp);
}
// advance dye particles in the summed vortex field.
// First half = ring A (fuelA), second half = ring B (fuelB); each uses its
// own propellant diffusion so a viscous stream smears more than a thin one.
function stepDye(dt) {
const half = N >> 1;
const dsqA = Math.sqrt(2 * diffuseFor(C.fuelL) * dt);
const dsqB = Math.sqrt(2 * diffuseFor(C.fuelR) * dt);
for (let i = 0; i < N; i++) {
const i3 = i*3;
const dsq = (i < half) ? dsqA : dsqB;
induced(pos[i3], pos[i3+1], pos[i3+2]);
pos[i3] += _u[0]*dt + dsq*randn();
pos[i3+1] += _u[1]*dt + dsq*randn();
pos[i3+2] += _u[2]*dt + dsq*randn();
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// PHASE readout
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const sP = document.getElementById('sP');
const sT = document.getElementById('sT');
function ringCentreX(base) {
let s = 0;
for (let i = 0; i < C.Nvort; i++) s += vP[(base+i)*3];
return s / C.Nvort;
}
// mean radial extent of ring A's vortex elements about the ring's OWN centroid
// (centroid-relative so a vertical offset doesn't inflate the baseline radius)
function ringRadius(base) {
let cy = 0, cz = 0;
for (let i = 0; i < C.Nvort; i++) {
cy += vP[(base+i)*3+1];
cz += vP[(base+i)*3+2];
}
cy /= C.Nvort; cz /= C.Nvort;
let s = 0;
for (let i = 0; i < C.Nvort; i++) {
const y = vP[(base+i)*3+1] - cy, z = vP[(base+i)*3+2] - cz;
s += Math.sqrt(y*y + z*z);
}
return s / C.Nvort;
}
function updatePhase() {
const gap = ringCentreX(C.Nvort) - ringCentreX(0); // B - A
const Ravg = 0.5 * (C.radL + C.radR);
let p;
if (gap > Ravg * 2.6) p = 'Approach';
else if (gap > Ravg * 0.5) p = 'Collision';
else if (gap > -Ravg*0.5) p = 'Reconnection';
else p = 'Radial bloom';
sP.textContent = p;
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// RESET
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function reset() {
simTime = 0;
// Left ring: fired +x, raised; Right ring: fired βˆ’x, lowered. Each uses its
// own propellant momentum, jet strength, radius, core thickness and lobes.
seedRing(0, -C.sepL, +1, +C.yOffL, gammaScaleFor(C.fuelL), C.GammaL, C.radL, C.epsL, C.lobeL);
seedRing(C.Nvort, C.sepR, -1, -C.yOffR, gammaScaleFor(C.fuelR), C.GammaR, C.radR, C.epsR, C.lobeR);
seedDye();
bPos.needsUpdate = true;
bCol.needsUpdate = true;
sP.textContent = 'Approach';
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// CONTROLS
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
document.getElementById('sN').textContent = N.toLocaleString();
document.getElementById('bR').onclick = reset;
document.getElementById('bP').onclick = e => {
paused = !paused;
e.target.textContent = paused ? 'β–Ά Resume' : '⏸ Pause';
};
document.getElementById('bD').onclick = () => {
camera.position.set(20, 11, 24); ctrl.target.set(0, 0, 0); ctrl.update();
};
document.getElementById('bT').onclick = () => {
camera.position.set(0.01, 0, 32); ctrl.target.set(0, 0, 0); ctrl.update();
};
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// PARAMETER PANEL
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Re ↔ diffusion mapping. In this dye model the random-walk coefficient plays
// the role of 1/Re: a high Reynolds number means weak diffusion (crisp
// filaments), a low Re means strong diffusion (blurred dye). We map the
// Basilisk reference Re = 1750 to the default diffusion 0.0016.
const RE_REF = 1750, DIFF_REF = 0.0016;
const reToDiff = re => DIFF_REF * (RE_REF / Math.max(re, 1));
const diffToRe = d => Math.round(DIFF_REF * RE_REF / Math.max(d, 1e-6));
const DEFAULTS = {
// per-stream (Left / Right)
fuelL: 'RP1', fuelR: 'LOX',
GammaL: 22.0, GammaR: 22.0,
radL: 2.6, radR: 2.6,
sepL: 9.0, sepR: 9.0,
yOffL: 0.1, yOffR: 0.1,
epsL: 0.62, epsR: 0.62,
lobeL: 8, lobeR: 8,
// system
Re: 1750, diffuse: 0.0016, Ndye: 48000, size: 0.5, dt: 0.022,
};
const el = id => document.getElementById(id);
const panel = el('panel');
// ---- Propellant dropdowns ----
const selA = el('pFuelA'), selB = el('pFuelB');
function populateFuelSelect(sel, current) {
sel.innerHTML = '';
for (const key in PROPELLANTS) {
const o = document.createElement('option');
o.value = key;
o.textContent = PROPELLANTS[key].name;
if (key === current) o.selected = true;
sel.appendChild(o);
}
}
populateFuelSelect(selA, C.fuelL);
populateFuelSelect(selB, C.fuelR);
const rgbCss = c => `rgb(${(c[0]*255)|0},${(c[1]*255)|0},${(c[2]*255)|0})`;
// update legend swatches/names + the property read-out box
function refreshFuelUI(keyA, keyB) {
const pA = PROPELLANTS[keyA], pB = PROPELLANTS[keyB];
el('legA').textContent = pA.name;
el('legB').textContent = pB.name;
const dA = el('dotA'), dB = el('dotB');
dA.style.background = rgbCss(pA.color); dA.style.boxShadow = '0 0 8px ' + rgbCss(pA.color);
dB.style.background = rgbCss(pB.color); dB.style.boxShadow = '0 0 8px ' + rgbCss(pB.color);
// oxidizer/fuel mixture note
const kinds = [pA.kind, pB.kind];
let mix = 'propellant mixing';
if (kinds.includes('oxidizer') && kinds.includes('fuel')) mix = 'βœ“ oxidizer + fuel (combustible pair)';
else if (pA.kind === pB.kind && pA.kind === 'fuel') mix = '⚠ fuel + fuel (no oxidizer)';
else if (pA.kind === pB.kind && pA.kind === 'oxidizer') mix = '⚠ oxidizer + oxidizer (no fuel)';
el('legMix').textContent = mix;
el('propInfo').innerHTML =
`<b>${pA.name}</b><br>ρ = ${pA.density} kg/m³ · μ = ${pA.viscosity.toFixed(3)} · ${pA.kind}` +
`<br><br><b>${pB.name}</b><br>ρ = ${pB.density} kg/m³ · μ = ${pB.viscosity.toFixed(3)} · ${pB.kind}` +
`<br><br>Momentum scale L:R = ${gammaScaleFor(keyA).toFixed(2)} : ${gammaScaleFor(keyB).toFixed(2)}`;
}
refreshFuelUI(C.fuelL, C.fuelR);
// live preview of names/colours as the user picks (physics applies on Apply)
selA.addEventListener('change', () => refreshFuelUI(selA.value, selB.value));
selB.addEventListener('change', () => refreshFuelUI(selA.value, selB.value));
// slider element references β€” per-stream (L/R) and system-wide
const S = {
// per-stream Left / Right
GammaL: el('pGammaL'), GammaR: el('pGammaR'),
RL: el('pRL'), RR: el('pRR'),
SepL: el('pSepL'), SepR: el('pSepR'),
YoffL: el('pYoffL'), YoffR: el('pYoffR'),
EpsL: el('pEpsL'), EpsR: el('pEpsR'),
LobeL: el('pLobeL'), LobeR: el('pLobeR'),
// system
Re: el('pRe'), Diff: el('pDiff'), N: el('pN'), Size: el('pSize'), Dt: el('pDt'),
};
const O = {
GammaL: el('oGammaL'), GammaR: el('oGammaR'),
RL: el('oRL'), RR: el('oRR'),
SepL: el('oSepL'), SepR: el('oSepR'),
YoffL: el('oYoffL'), YoffR: el('oYoffR'),
EpsL: el('oEpsL'), EpsR: el('oEpsR'),
LobeL: el('oLobeL'), LobeR: el('oLobeR'),
Re: el('oRe'), Diff: el('oDiff'), N: el('oN'), Size: el('oSize'), Dt: el('oDt'),
};
// keep the numeric read-outs beside each slider live while dragging
function refreshOutputs() {
O.GammaL.value = (+S.GammaL.value).toFixed(1);
O.GammaR.value = (+S.GammaR.value).toFixed(1);
O.RL.value = (+S.RL.value).toFixed(1);
O.RR.value = (+S.RR.value).toFixed(1);
O.SepL.value = (+S.SepL.value).toFixed(1);
O.SepR.value = (+S.SepR.value).toFixed(1);
O.YoffL.value = (+S.YoffL.value).toFixed(1);
O.YoffR.value = (+S.YoffR.value).toFixed(1);
O.EpsL.value = (+S.EpsL.value).toFixed(2);
O.EpsR.value = (+S.EpsR.value).toFixed(2);
O.LobeL.value = S.LobeL.value;
O.LobeR.value = S.LobeR.value;
O.Re.value = S.Re.value;
O.Diff.value = (+S.Diff.value).toFixed(4);
O.N.value = (+S.N.value).toLocaleString();
O.Size.value = (+S.Size.value).toFixed(1);
O.Dt.value = (+S.Dt.value).toFixed(3);
}
// Re and Viscosity are linked β€” moving one updates the other
S.Re.addEventListener('input', () => {
S.Diff.value = reToDiff(+S.Re.value).toFixed(4);
refreshOutputs();
});
S.Diff.addEventListener('input', () => {
S.Re.value = diffToRe(+S.Diff.value);
refreshOutputs();
});
['GammaL','GammaR','RL','RR','SepL','SepR','YoffL','YoffR','EpsL','EpsR','LobeL','LobeR','N','Dt']
.forEach(k => S[k].addEventListener('input', refreshOutputs));
// Particle size updates the shader uniform live β€” no restart needed
S.Size.addEventListener('input', () => {
mat.uniforms.uSize.value = (+S.Size.value) * PR;
refreshOutputs();
});
// Apply slider values into the live config, rebuild if counts changed, restart
function applyParams() {
const newN = +S.N.value;
const countChanged = (newN !== C.Ndye);
// per-stream
C.fuelL = selA.value; C.fuelR = selB.value;
C.GammaL = +S.GammaL.value; C.GammaR = +S.GammaR.value;
C.radL = +S.RL.value; C.radR = +S.RR.value;
C.sepL = +S.SepL.value; C.sepR = +S.SepR.value;
C.yOffL = +S.YoffL.value; C.yOffR = +S.YoffR.value;
C.epsL = +S.EpsL.value; C.epsR = +S.EpsR.value;
C.lobeL = +S.LobeL.value; C.lobeR = +S.LobeR.value;
// system
C.diffuse = +S.Diff.value;
C.Ndye = newN;
C.dt = +S.Dt.value;
if (countChanged) rebuildBuffers();
mat.uniforms.uSize.value = (+S.Size.value) * PR;
refreshFuelUI(C.fuelL, C.fuelR);
el('re').textContent = 'Re = ' + S.Re.value;
el('sN').textContent = C.Ndye.toLocaleString();
reset();
}
function loadDefaults() {
selA.value = DEFAULTS.fuelL; selB.value = DEFAULTS.fuelR;
S.GammaL.value = DEFAULTS.GammaL; S.GammaR.value = DEFAULTS.GammaR;
S.RL.value = DEFAULTS.radL; S.RR.value = DEFAULTS.radR;
S.SepL.value = DEFAULTS.sepL; S.SepR.value = DEFAULTS.sepR;
S.YoffL.value = DEFAULTS.yOffL; S.YoffR.value = DEFAULTS.yOffR;
S.EpsL.value = DEFAULTS.epsL; S.EpsR.value = DEFAULTS.epsR;
S.LobeL.value = DEFAULTS.lobeL; S.LobeR.value = DEFAULTS.lobeR;
S.Re.value = DEFAULTS.Re;
S.Diff.value = DEFAULTS.diffuse;
S.N.value = DEFAULTS.Ndye;
S.Size.value = DEFAULTS.size;
S.Dt.value = DEFAULTS.dt;
refreshOutputs();
}
const about = el('about');
el('bA').onclick = () => about.classList.toggle('open');
el('aClose').onclick = () => about.classList.remove('open');
el('bG').onclick = () => panel.classList.toggle('open');
el('pClose').onclick = () => panel.classList.remove('open');
el('pApply').onclick = applyParams;
el('pReset').onclick = () => { loadDefaults(); applyParams(); };
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// LOOP
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Load the default parameters into the panel and push them into the live
// config BEFORE the first frame, so the program starts from the panel values
// (vertical offset, particle size, propellants, …) rather than raw defaults.
loadDefaults();
applyParams();
const RESET_RADIUS = 11.5; // when the bloom grows past this, loop the cycle
let holdFrames = 0; // short pause at full bloom before restarting
function animate() {
requestAnimationFrame(animate);
ctrl.update();
if (!paused) {
// Fixed number of physics steps per frame β€” independent of real elapsed
// time, so a slow/backgrounded tab can never race the sim off-screen.
const dt = C.dt;
stepVortex(dt); // vortex elements define the field
stepDye(dt); // dye follows the field
simTime += dt;
sT.textContent = simTime.toFixed(2);
updatePhase();
bPos.needsUpdate = true;
// Auto-loop: once the merged ring has bloomed outward, hold briefly
// then restart the collision so the interesting window always replays.
if (holdFrames > 0) {
if (--holdFrames === 0) reset();
} else if (ringRadius(0) > RESET_RADIUS) {
holdFrames = 60; // ~1 s of real time at the bloom's peak
}
}
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>