I am having a blast of a time playing with a simple simulation I just built, with the assistance of GPT for a change: The double pendulum.
Here it is:
m1: 1.5
m2: 0.5
l1: 150
l2: 47
θ1: 180°
θ2: 180°
ω1: -0.1
ω2: 1.3
Kinetic: 0.000
Potential: 0.000
Total: 0.000
GPT told me (correctly I believe) that the angular accelerations of the double pendulum can be computed as
\begin{align}
\alpha_1 &{}= \frac{-g(2m_1 + m_2)\sin\theta_1 - m_2 g \sin(\theta_1 - 2\theta_2) - 2\sin(\theta_1 - \theta_2)m_2(\omega_2^2 l_2 + \omega_1^2 l_1 \cos(\theta_1 - \theta_2))}{l_1 [2m_1 + m_2 - m_2\cos(2\theta_1 - 2\theta_2)]},\\
\alpha_2 &{}= \frac{2\sin(\theta_1 - \theta_2)\left(\omega_1^2 l_1(m_1 + m_2) + g(m_1 + m_2)\cos\theta_1 + \omega_2^2 l_2 m_2 \cos(\theta_1 - \theta_2)\right)}{l_2 [2m_1 + m_2 - m_2\cos(2\theta_1 - 2\theta_2)]}.
\end{align}
On the first try, GPT offered a simple integrator, however it was not stable. The system's energy was not conserved due to cumulative rounding errors, a problem common in similar simulations. Eventually, GPT replaced the integrator with a nice and clean 4th order Runge-Kutta integrator, and the result is a rock stable simulation. The final touches involved adding some user controls and a reasonably responsive layout.
I cannot really tell why, but watching it almost feels hypnotic.
For reference, here's the full source code of a self-contained page with the pendulum:
<!DOCTYPE html>
<html>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<head>
<title>Double Pendulum with Trails</title>
<style>
body
{
background-color: #CCC8D0;
color: #000000;
font-family: Arial, sans-serif;
}
button
{
padding: 10px 20px;
margin: 10px;
font-size: 16px;
}
#controls
{
color: black;
}
svg
{
background-color: #ffffff;
overflow: visible;
}
.input
{
width: 3em;
text-align: right;
display: inline-block;
}
.inputd
{
width: 3.5em;
}
.settings
{
font-family: monospace;
}
.energy
{
text-align: right;
display: inline-block;
width: 5em;
}
.recordLbl
{
font-family: Arial, sans-serif;
display: inline-block;
margin-left: 10px;
margin-bottom: 20px;
}
/* Flex layout */
#layout {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 30px;
padding: 20px;
flex-wrap: wrap;
}
#leftColumn {
display: flex;
flex-direction: column;
gap: 30px;
}
#controls, #energyInfo {
color: black;
font-family: monospace;
}
#pendulumCanvas {
display: flex;
}
@media (max-width: 768px) {
#layout {
flex-direction: column;
align-items: center;
}
#leftColumn {
flex-direction: column;
align-items: center;
}
}
</style>
</head>
<body>
<div id="layout">
<div id="leftColumn">
<div id="controls">
<button id="toggle">Start</button><br/>
<label class='recordLbl'>
<input type="checkbox" id="recordChk"/> Record to Video
</label>
<br/>
<span class='settings'>
m1: <input type="range" id="m1" min="0.1" max="2"
step="0.1" value="1"/> <span id="m1val" class="input">1</span><br/>
m2: <input type="range" id="m2" min="0.1" max="2"
step="0.1" value="1.5"/> <span id="m2val" class="input">1.5</span><br/>
l1: <input type="range" id="l1" min="10" max="150"
step="1" value="100"/><span id="l1val" class="input">100</span><br/>
l2: <input type="range" id="l2" min="10" max="150"
step="1" value="100"/><span id="l2val" class="input">100</span><br/>
θ1: <input type="range" id="theta1" min="0" max="360"
step="1" value="0"/><span id="theta1val" class="input inputd">0°</span><br/>
θ2: <input type="range" id="theta2" min="0" max="360" step="1"
value="0"/><span id="theta2val" class="input inputd">0°</span><br/>
ω1: <input type="range" id="omega1" min="-3" max="3"
step="0.1" value="1.1"/><span id="omega1val" class="input">1.1</span><br/>
ω2: <input type="range" id="omega2" min="-3" max="3"
step="0.1" value="-1.1"/><span id="omega2val" class="input">-1.1</span><br/>
</span>
</div>
<div id="energyInfo">
<u>ENERGY</u>:<br>
Kinetic: <span class="energy" id="kinE">0.000</span><br>
Potential: <span class="energy" id="potE">0.000</span><br>
Total: <span class="energy" id="totE">0.000</span>
</div>
</div>
<svg id="pendulumCanvas" width="600" height="400">
<g id="pendulum" transform="translate(300,200)">
<polyline id="trail14" stroke="#d5dbff" stroke-width="1" fill="none"/>
<polyline id="trail13" stroke="#a9b6ff" stroke-width="1" fill="none"/>
<polyline id="trail12" stroke="#7e92ff" stroke-width="1" fill="none"/>
<polyline id="trail11" stroke="#526dff" stroke-width="1" fill="none"/>
<polyline id="trail24" stroke="#ffd3db" stroke-width="1" fill="none"/>
<polyline id="trail23" stroke="#ffa6b6" stroke-width="1" fill="none"/>
<polyline id="trail22" stroke="#ff7990" stroke-width="1" fill="none"/>
<polyline id="trail21" stroke="#ff4c6b" stroke-width="1" fill="none"/>
<line id="rod1" stroke="#000000" stroke-width="3"/>
<line id="rod2" stroke="#000000" stroke-width="3"/>
<circle id="mass1" r="8" fill="#61dafb"/>
<circle id="mass2" r="8" fill="#e06c75"/>
</g>
</svg>
</div>
<canvas id="recordCanvas" width="600" height="400" style="display:none;"></canvas>
<script>
// Animation & physics parameters (customizable)
const paramElems = ['m1','m2','l1','l2','theta1','theta2',
'omega1','omega2'].map(id=>document.getElementById(id));
const paramDisplay = paramElems.map(el=>document.getElementById(el.id+'val'));
function readParams()
{
const [m1,m2,l1,l2,theta1,theta2,omega1,omega2] = paramElems.map(e=>+e.value);
return {m1,m2,l1,l2,theta1:theta1*Math.PI/180,theta2:theta2*Math.PI/180,
omega1:omega1,omega2:omega2,g:9.81,dt:0.0167,sf:50,df:5,
trail1Length:100,trail2Length:400};
}
let params=readParams();
let running = false;
const toggleButton = document.getElementById('toggle');
let worker;
const trail1Points = [[],[],[],[],[]];
const trail2Points = [[],[],[],[],[]];
const trail11 = document.getElementById('trail11');
const trail12 = document.getElementById('trail12');
const trail13 = document.getElementById('trail13');
const trail14 = document.getElementById('trail14');
const trail21 = document.getElementById('trail21');
const trail22 = document.getElementById('trail22');
const trail23 = document.getElementById('trail23');
const trail24 = document.getElementById('trail24');
const mass1 = document.getElementById('mass1');
const mass2 = document.getElementById('mass2');
const rod1 = document.getElementById('rod1');
const rod2 = document.getElementById('rod2');
// Create web worker for smooth physics computations
const workerBlob = new Blob(
[`
let params, running = false;
onmessage = function(e)
{
if(e.data.type === 'init') params = e.data.params;
if(e.data.type === 'start') { running=true; run(); }
if(e.data.type === 'stop') running=false;
if(e.data.type === 'energy')
{
const {params} = e.data;
postMessage({type: 'energyResult',
energy: energies(params.theta1, params.theta2,
params.omega1, params.omega2, params)});
}
};
function accelerations(theta1, theta2, omega1, omega2, params)
{
const {m1,m2,l1,l2,g} = params;
const delta = theta1 - theta2;
const den1 = (2*m1 + m2 - m2*Math.cos(2*delta));
const a1 = (-g*(2*m1+m2)*Math.sin(theta1)
- m2*g*Math.sin(theta1 - 2*theta2)
- 2*Math.sin(delta)*m2*(omega2*omega2*l2
+ omega1*omega1*l1*Math.cos(delta)))
/(l1 * den1);
const a2 = (2*Math.sin(delta)*(omega1*omega1*l1*(m1+m2)
+ g*(m1+m2)*Math.cos(theta1)
+ omega2*omega2*l2*m2*Math.cos(delta)))
/(l2 * den1);
return [a1, a2];
}
function energies(theta1, theta2, omega1, omega2, params)
{
const {m1, m2, l1, l2, g} = params;
// positions (y positive downward):
const y1 = l1 * Math.cos(theta1);
const y2 = y1 + l2 * Math.cos(theta2);
// velocities squared:
const v1sq = (l1 * omega1)**2;
const v2sq = v1sq + (l2 * omega2)**2 + 2 * l1 * l2 * omega1 * omega2 * Math.cos(theta1 - theta2);
// kinetic energy:
const T = 0.5 * m1 * v1sq + 0.5 * m2 * v2sq;
// potential energy (assuming y-positive downward, so negative of classical mechanics standard):
const V = -g * (m1 * y1 + m2 * y2);
return {kinetic: T, potential: V, total: T + V};
}
function run()
{
let {theta1, theta2, omega1, omega2, dt, sf, df} = params;
function derivatives(state, params)
{
const [theta1, theta2, omega1, omega2] = state;
const [alpha1, alpha2] = accelerations(theta1, theta2, omega1, omega2, params);
return [omega1, omega2, alpha1, alpha2];
}
function rk4Step(state, h, params)
{
const add = (a,b,f=1) => a.map((x,i)=>x+f*b[i]);
const k1 = derivatives(state, params);
const k2 = derivatives(add(state,k1,h/2), params);
const k3 = derivatives(add(state,k2,h/2), params);
const k4 = derivatives(add(state,k3,h), params);
return state.map((x,i)=> x + (h/6)*(k1[i]+2*k2[i]+2*k3[i]+k4[i]));
}
function step()
{
const h = dt / df;
let state = [theta1, theta2, omega1, omega2];
for (let i = 0; i < sf; i++)
{
state = rk4Step(state, h, params);
}
[theta1, theta2, omega1, omega2] = state;
postMessage({theta1, theta2,
energy: energies(theta1, theta2, omega1, omega2, params)});
if(running)setTimeout(step, dt * 1000);
}
step();
}
`]);
worker = new Worker(URL.createObjectURL(workerBlob));
function disableControls()
{
paramElems.forEach(el => el.disabled = true);
recordChk.disabled = true;
}
function enableControls()
{
paramElems.forEach(el => el.disabled = false);
recordChk.disabled = false;
}
toggleButton.onclick = function()
{
running = !running;
toggleButton.textContent = running ? 'Stop':'Start';
if (running)
{
worker.postMessage({type:'init', params});
trail1Points[0].length = 0;
trail1Points[1].length = 0;
trail1Points[2].length = 0;
trail1Points[3].length = 0;
trail2Points[0].length = 0;
trail2Points[1].length = 0;
trail2Points[2].length = 0;
trail2Points[3].length = 0;
worker.postMessage({type:'start'});
disableControls();
}
else
{
worker.postMessage({type:'stop'});
enableControls();
// Request the energy to be updated on stop
worker.postMessage({type: 'energy', params: params});
}
};
const kinE = document.getElementById("kinE");
const potE = document.getElementById("potE");
const totE = document.getElementById("totE");
function paint(x1, x2, y1, y2)
{
const {m1,m2} = params;
rod1.setAttribute('x1',0); rod1.setAttribute('y1',0);
rod1.setAttribute('x2',x1); rod1.setAttribute('y2',y1);
rod2.setAttribute('x1',x1); rod2.setAttribute('y1',y1);
rod2.setAttribute('x2',x2); rod2.setAttribute('y2',y2);
mass1.setAttribute('cx',x1); mass1.setAttribute('cy',y1);
mass1.setAttribute('cx',x1); mass1.setAttribute('cy',y1);
mass2.setAttribute('cx',x2); mass2.setAttribute('cy',y2);
mass1.setAttribute('r',5+3*m1);
mass2.setAttribute('r',5+3*m2);
}
function tpaint(theta1, theta2)
{
const {l1,l2} = params;
const x1 = l1*Math.sin(theta1),y1 = l1*Math.cos(theta1);
const x2 = x1+l2*Math.sin(theta2), y2 = y1+l2*Math.cos(theta2);
paint(x1, x2, y1, y2);
}
worker.onmessage = function(event)
{
if (event.data.type === 'energyResult')
{
const { energy } = event.data;
kinE.textContent = energy.kinetic.toFixed(3);
potE.textContent = energy.potential.toFixed(3);
totE.textContent = energy.total.toFixed(3);
return;
}
const {l1,l2,trail1Length,trail2Length} = params;
const {theta1,theta2,energy} = event.data;
const x1 = l1*Math.sin(theta1),y1 = l1*Math.cos(theta1);
const x2 = x1+l2*Math.sin(theta2), y2 = y1+l2*Math.cos(theta2);
paint(x1, x2, y1, y2);
// Update trails
trail1Points[0].push(`${x1},${y1}`);
trail2Points[0].push(`${x2},${y2}`);
for (let t = 0; t < 4; t++)
{
if (trail1Points[t].length > trail1Length / 4)
{
if (t < 3)
{
if (trail1Points[t+1].length == 0) trail1Points[t+1].push(trail1Points[t][0]);
trail1Points[t+1].push(trail1Points[t][1]);
}
trail1Points[t].shift();
}
if (trail2Points[t].length > trail2Length / 4)
{
if (t < 3)
{
if (trail2Points[t+1].length == 0) trail2Points[t+1].push(trail2Points[t][0]);
trail2Points[t+1].push(trail2Points[t][1]);
}
trail2Points[t].shift();
}
}
trail11.setAttribute('points', trail1Points[0].join(' '));
trail12.setAttribute('points', trail1Points[1].join(' '));
trail13.setAttribute('points', trail1Points[2].join(' '));
trail14.setAttribute('points', trail1Points[3].join(' '));
trail21.setAttribute('points', trail2Points[0].join(' '));
trail22.setAttribute('points', trail2Points[1].join(' '));
trail23.setAttribute('points', trail2Points[2].join(' '));
trail24.setAttribute('points', trail2Points[3].join(' '));
// Update displayed energies clearly formatted:
kinE.textContent = energy.kinetic.toFixed(3);
potE.textContent = energy.potential.toFixed(3);
totE.textContent = energy.total.toFixed(3);
// if we're recording, update canvas for current frame
if (recorder && recorder.state === 'recording') svgToCanvas();
};
const recordChk = document.getElementById('recordChk');
const canvas = document.getElementById('recordCanvas');
const ctx = canvas.getContext('2d');
let recorder, chunks = [];
// Function to copy SVG content onto Canvas
function svgToCanvas()
{
const svgEl = document.getElementById('pendulumCanvas');
const svgData = new XMLSerializer().serializeToString(svgEl);
const dataUrl = 'data:image/svg+xml;charset=utf-8,'
+ encodeURIComponent(svgData);
const img = new Image();
img.onload = function()
{
ctx.fillStyle = getComputedStyle(
document.getElementById('pendulumCanvas'))
['background-color']; // match the page background
ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.drawImage(img, 0, 0);
};
img.src = dataUrl;
}
// Modify the toggle button logic to handle recording
const originalToggleHandler = toggleButton.onclick; // save existing logic
toggleButton.onclick = function()
{
const shouldStartRecording = recordChk.checked && !running; // first start click
if (shouldStartRecording)
{
chunks = [];
const stream = canvas.captureStream(60); // 60 FPS
recorder = new MediaRecorder(stream, { mimeType: "video/webm; codecs=vp9" });
recorder.ondataavailable = e => chunks.push(e.data);
recorder.onstop = saveVideo;
recorder.start();
console.log('Recording started');
}
else if (running && recorder && recorder.state === 'recording')
{
recorder.stop();
console.log('Recording stopped');
}
originalToggleHandler(); // execute existing logic
}
function saveVideo()
{
const blob = new Blob(chunks, { type: 'video/webm' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
document.body.appendChild(a);
a.style.display = 'none';
a.href = url;
a.download = 'double-pendulum.webm'; // Browser natively saves in .webm format
a.click();
window.URL.revokeObjectURL(url);
a.remove();
}
paramElems.forEach((el,i)=>el.oninput=() =>
{
paramDisplay[i].textContent=el.value+(el.id.includes('theta')?'°':'');
params = readParams();
tpaint(params.theta1,params.theta2);
worker.postMessage({type: 'energy', params: params});
});
params = readParams();
tpaint(params.theta1, params.theta2);
worker.postMessage({type: 'energy', params: params});
</script>
</body>
</html>