The canvas below runs a small crowd of agents. Each frame, every agent computes the velocity obstacles cast by its neighbors and picks the closest velocity to its preferred direction that lies outside all of them.
<!-- Main layout: toolbar + canvas + hint -->
<div class="toolbar">
<button id="btn-add" type="button">Add agent</button>
<button id="btn-pause" type="button">Pause</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<span id="info" class="info"></span>
</div>
<canvas id="cv"></canvas>
<p class="hint">Each disc heads toward its faded goal dot. Every frame each agent samples candidate velocities and picks the one closest to its goal that lies <em>outside</em> every velocity-obstacle cone — the set of velocities leading to a future collision with each neighbor.</p>
/* Box-model reset and base layout */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f7fa; color: #222; }
.toolbar { display: flex; gap: .5rem; align-items: center; padding: .5rem .4rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:hover { opacity: .85; }
.info { font-size: .82rem; color: #555; }
canvas { display: block; width: 100%; background: #e9edf2; border-radius: 6px; }
.hint { font-size: .8rem; color: #666; padding: .4rem .4rem 0; line-height: 1.45; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Velocity Obstacle (VO) demo — discrete candidate sampling. */
/* Concept: Fiorini & Shiller (1998). Sampling variant for clarity. */
const CV = document.getElementById('cv');
const CTX = CV.getContext('2d');
const INFO = document.getElementById('info');
// Fixed logical canvas size
const W = 460, H = 360;
CV.width = W; CV.height = H;
CV.style.maxHeight = H + 'px';
// Simulation constants
const R = 11; // agent radius in pixels
const TAU = 60; // look-ahead time horizon in frames
const MAX_SPD = 1.4; // maximum speed in pixels per frame
const DT = 1.0; // time step per animation frame
const SAMPLES = 36; // number of angular samples per velocity ring
let paused = false;
let agents = [];
let frame = 0;
// Agent colour palette
const COLORS = ['#e63946','#457b9d','#2a9d8f','#e9c46a','#f4a261','#6a4c93','#1d3557','#4cc9f0'];
function mkAgent(x, y, gx, gy, ci) {
// Agent state: current position, velocity, goal position
return { x, y, vx: 0, vy: 0, gx, gy, color: COLORS[ci % COLORS.length] };
}
function initAgents() {
agents = [];
// Place agents on a circle, each heading to an offset opposite point
const cx = W / 2, cy = H / 2, r = 110;
const N = 6;
for (let i = 0; i < N; i++) {
const a = 2 * Math.PI * i / N;
// goal angle offset keeps agents from all meeting at the exact center goal angle offset keeps agents from all meeting at the exact center
const ga = a + Math.PI + 0.35;
agents.push(mkAgent(cx + r * Math.cos(a), cy + r * Math.sin(a),
cx + r * Math.cos(ga), cy + r * Math.sin(ga), i));
}
}
function addAgents() {
// Add one agent on the ring
const n = agents.length;
const r = 130;
const a = (n * 1.1) % (2 * Math.PI);
const ga = a + Math.PI + 0.4;
agents.push(mkAgent(W/2 + r*Math.cos(a), H/2 + r*Math.sin(a),
W/2 + r*Math.cos(ga), H/2 + r*Math.sin(ga), n));
}
/* One VO-based velocity selection step */
/* Idea: for each agent, find the closest velocity to its goal that avoids all VOs. */
function inVO(relPx, relPy, relVx, relVy, combinedR) {
// Return true if the relative velocity relV leads to collision within TAU frames.
// VO(A,B) = velocities v where A, moving at v and B at b.vx/vy, collide within TAU VO(A,B) = {v : exists t in [0,TAU] with |relP + v*t| < combinedR}
// Equivalent: minimum squared distance from origin along the relative-velocity line < combinedR^2 Equivalent: min over t>=0 of |relP + v*t|^2 < combinedR^2
const vv = relVx * relVx + relVy * relVy;
if (vv < 1e-9) return false; // zero relative speed — no future collision from velocity alone
// time of closest approach, clamped to lookahead window [0, TAU] t* = -dot(relP,relV)/|relV|^2, clamped to [0, TAU]
const t = Math.max(0, Math.min(TAU, -(relPx * relVx + relPy * relVy) / vv));
// squared distance between agents at time t* squared distance at t*
const cx = relPx + relVx * t, cy = relPy + relVy * t;
return cx * cx + cy * cy < combinedR * combinedR;
}
function voStep() {
const n = agents.length;
const newV = [];
for (let i = 0; i < n; i++) {
const a = agents[i];
// Preferred velocity: straight toward goal at max speed
const dx = a.gx - a.x, dy = a.gy - a.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const spd = Math.min(MAX_SPD, dist / DT);
const pvx = (dx / dist) * spd, pvy = (dy / dist) * spd;
// Find best safe velocity among all candidates Find best velocity: closest to preferred that avoids all VOs
let bestVx = 0, bestVy = 0, bestScore = Infinity;
const combinedR = 2 * R + 1; // combined radius = sum of both agent radii plus 1-px safety margin
// Build the candidate set: preferred + sampled velocities on rings Sample velocity candidates on concentric rings
const cands = [[pvx, pvy]]; // always try preferred velocity first always try preferred first
for (let ring = 1; ring <= 3; ring++) {
const rs = (MAX_SPD * ring) / 3;
for (let s = 0; s < SAMPLES; s++) {
const ang = 2 * Math.PI * s / SAMPLES;
cands.push([rs * Math.cos(ang), rs * Math.sin(ang)]);
}
}
cands.push([0, 0]); // stopping in place is always safe and never leads to collision stopping is always an option
for (const [cvx, cvy] of cands) {
// Check this candidate against every other agent's VO check this candidate against all neighbors
let safe = true;
for (let j = 0; j < n; j++) {
if (i === j) continue;
const b = agents[j];
// relative position (A to B) and relative velocity (candidate minus B's current vel) relative position from A to B, relative velocity
const relPx = b.x - a.x, relPy = b.y - a.y;
const relVx = cvx - b.vx, relVy = cvy - b.vy;
if (inVO(relPx, relPy, relVx, relVy, combinedR)) { safe = false; break; }
}
if (safe) {
// score = squared deviation from preferred velocity — lower is better score = squared distance from preferred velocity
const dv = (cvx - pvx) * (cvx - pvx) + (cvy - pvy) * (cvy - pvy);
if (dv < bestScore) { bestScore = dv; bestVx = cvx; bestVy = cvy; }
}
}
newV.push({ vx: bestVx, vy: bestVy });
}
// Apply chosen velocities and advance positions by one time step
agents.forEach((a, i) => { a.vx = newV[i].vx; a.vy = newV[i].vy; a.x += a.vx * DT; a.y += a.vy * DT; });
}
function anyCollision() {
// Return true if any pair of agents currently overlaps
for (let i = 0; i < agents.length; i++)
for (let j = i + 1; j < agents.length; j++) {
const dx = agents[i].x - agents[j].x, dy = agents[i].y - agents[j].y;
if (dx * dx + dy * dy < (2 * R - 1) * (2 * R - 1)) return true;
}
return false;
}
function draw() {
CTX.clearRect(0, 0, W, H);
// Render all agents
agents.forEach(a => {
// Faded goal dot
CTX.beginPath();
CTX.arc(a.gx, a.gy, 4, 0, 2 * Math.PI);
CTX.fillStyle = a.color + '44';
CTX.fill();
// Agent disc with white outline
CTX.beginPath();
CTX.arc(a.x, a.y, R, 0, 2 * Math.PI);
CTX.fillStyle = a.color;
CTX.fill();
CTX.strokeStyle = '#fff';
CTX.lineWidth = 1.5;
CTX.stroke();
// Velocity direction arrow
if (a.vx * a.vx + a.vy * a.vy > 0.02) {
CTX.beginPath();
CTX.moveTo(a.x, a.y);
CTX.lineTo(a.x + a.vx * 9, a.y + a.vy * 9);
CTX.strokeStyle = '#ffffffcc';
CTX.lineWidth = 1.8;
CTX.stroke();
}
});
// Collision warning overlay (rare, shown only in extreme density)
if (anyCollision()) {
CTX.fillStyle = '#e63946cc';
CTX.font = 'bold 13px system-ui';
CTX.fillText('Overlap detected (too many agents)', 8, 18);
}
// Update agent count display
INFO.textContent = i18nFmt('%n% agents', { n: agents.length });
}
function loop() {
if (!paused) { voStep(); frame++; }
draw();
requestAnimationFrame(loop);
}
document.getElementById('btn-add').addEventListener('click', () => { addAgents(); });
document.getElementById('btn-pause').addEventListener('click', function () {
paused = !paused;
this.textContent = paused ? 'Resume' : 'Pause';
});
document.getElementById('btn-reset').addEventListener('click', () => { initAgents(); frame = 0; });
initAgents();
loop();
<!-- Main layout: toolbar + canvas + hint -->
<div class="toolbar">
<button id="btn-add" type="button">Add agent</button>
<button id="btn-pause" type="button">Pause</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<span id="info" class="info"></span>
</div>
<canvas id="cv"></canvas>
<p class="hint">Each disc heads toward its faded goal dot. Every frame each agent samples candidate velocities and picks the one closest to its goal that lies <em>outside</em> every velocity-obstacle cone — the set of velocities leading to a future collision with each neighbor.</p>
/* Box-model reset and base layout */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f7fa; color: #222; }
.toolbar { display: flex; gap: .5rem; align-items: center; padding: .5rem .4rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:hover { opacity: .85; }
.info { font-size: .82rem; color: #555; }
canvas { display: block; width: 100%; background: #e9edf2; border-radius: 6px; }
.hint { font-size: .8rem; color: #666; padding: .4rem .4rem 0; line-height: 1.45; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Velocity Obstacle (VO) demo — discrete candidate sampling. */
/* Concept: Fiorini & Shiller (1998). Sampling variant for clarity. */
const CV = document.getElementById('cv');
const CTX = CV.getContext('2d');
const INFO = document.getElementById('info');
// Fixed logical canvas size
const W = 460, H = 360;
CV.width = W; CV.height = H;
CV.style.maxHeight = H + 'px';
// Simulation constants
const R = 11; // agent radius in pixels
const TAU = 60; // look-ahead time horizon in frames
const MAX_SPD = 1.4; // maximum speed in pixels per frame
const DT = 1.0; // time step per animation frame
const SAMPLES = 36; // number of angular samples per velocity ring
let paused = false;
let agents = [];
let frame = 0;
// Agent colour palette
const COLORS = ['#e63946','#457b9d','#2a9d8f','#e9c46a','#f4a261','#6a4c93','#1d3557','#4cc9f0'];
function mkAgent(x, y, gx, gy, ci) {
// Agent state: current position, velocity, goal position
return { x, y, vx: 0, vy: 0, gx, gy, color: COLORS[ci % COLORS.length] };
}
function initAgents() {
agents = [];
// Place agents on a circle, each heading to an offset opposite point
const cx = W / 2, cy = H / 2, r = 110;
const N = 6;
for (let i = 0; i < N; i++) {
const a = 2 * Math.PI * i / N;
// goal angle offset keeps agents from all meeting at the exact center goal angle offset keeps agents from all meeting at the exact center
const ga = a + Math.PI + 0.35;
agents.push(mkAgent(cx + r * Math.cos(a), cy + r * Math.sin(a),
cx + r * Math.cos(ga), cy + r * Math.sin(ga), i));
}
}
function addAgents() {
// Add one agent on the ring
const n = agents.length;
const r = 130;
const a = (n * 1.1) % (2 * Math.PI);
const ga = a + Math.PI + 0.4;
agents.push(mkAgent(W/2 + r*Math.cos(a), H/2 + r*Math.sin(a),
W/2 + r*Math.cos(ga), H/2 + r*Math.sin(ga), n));
}
/* One VO-based velocity selection step */
/* Idea: for each agent, find the closest velocity to its goal that avoids all VOs. */
function inVO(relPx, relPy, relVx, relVy, combinedR) {
// Return true if the relative velocity relV leads to collision within TAU frames.
// VO(A,B) = velocities v where A, moving at v and B at b.vx/vy, collide within TAU VO(A,B) = {v : exists t in [0,TAU] with |relP + v*t| < combinedR}
// Equivalent: minimum squared distance from origin along the relative-velocity line < combinedR^2 Equivalent: min over t>=0 of |relP + v*t|^2 < combinedR^2
const vv = relVx * relVx + relVy * relVy;
if (vv < 1e-9) return false; // zero relative speed — no future collision from velocity alone
// time of closest approach, clamped to lookahead window [0, TAU] t* = -dot(relP,relV)/|relV|^2, clamped to [0, TAU]
const t = Math.max(0, Math.min(TAU, -(relPx * relVx + relPy * relVy) / vv));
// squared distance between agents at time t* squared distance at t*
const cx = relPx + relVx * t, cy = relPy + relVy * t;
return cx * cx + cy * cy < combinedR * combinedR;
}
function voStep() {
const n = agents.length;
const newV = [];
for (let i = 0; i < n; i++) {
const a = agents[i];
// Preferred velocity: straight toward goal at max speed
const dx = a.gx - a.x, dy = a.gy - a.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const spd = Math.min(MAX_SPD, dist / DT);
const pvx = (dx / dist) * spd, pvy = (dy / dist) * spd;
// Find best safe velocity among all candidates Find best velocity: closest to preferred that avoids all VOs
let bestVx = 0, bestVy = 0, bestScore = Infinity;
const combinedR = 2 * R + 1; // combined radius = sum of both agent radii plus 1-px safety margin
// Build the candidate set: preferred + sampled velocities on rings Sample velocity candidates on concentric rings
const cands = [[pvx, pvy]]; // always try preferred velocity first always try preferred first
for (let ring = 1; ring <= 3; ring++) {
const rs = (MAX_SPD * ring) / 3;
for (let s = 0; s < SAMPLES; s++) {
const ang = 2 * Math.PI * s / SAMPLES;
cands.push([rs * Math.cos(ang), rs * Math.sin(ang)]);
}
}
cands.push([0, 0]); // stopping in place is always safe and never leads to collision stopping is always an option
for (const [cvx, cvy] of cands) {
// Check this candidate against every other agent's VO check this candidate against all neighbors
let safe = true;
for (let j = 0; j < n; j++) {
if (i === j) continue;
const b = agents[j];
// relative position (A to B) and relative velocity (candidate minus B's current vel) relative position from A to B, relative velocity
const relPx = b.x - a.x, relPy = b.y - a.y;
const relVx = cvx - b.vx, relVy = cvy - b.vy;
if (inVO(relPx, relPy, relVx, relVy, combinedR)) { safe = false; break; }
}
if (safe) {
// score = squared deviation from preferred velocity — lower is better score = squared distance from preferred velocity
const dv = (cvx - pvx) * (cvx - pvx) + (cvy - pvy) * (cvy - pvy);
if (dv < bestScore) { bestScore = dv; bestVx = cvx; bestVy = cvy; }
}
}
newV.push({ vx: bestVx, vy: bestVy });
}
// Apply chosen velocities and advance positions by one time step
agents.forEach((a, i) => { a.vx = newV[i].vx; a.vy = newV[i].vy; a.x += a.vx * DT; a.y += a.vy * DT; });
}
function anyCollision() {
// Return true if any pair of agents currently overlaps
for (let i = 0; i < agents.length; i++)
for (let j = i + 1; j < agents.length; j++) {
const dx = agents[i].x - agents[j].x, dy = agents[i].y - agents[j].y;
if (dx * dx + dy * dy < (2 * R - 1) * (2 * R - 1)) return true;
}
return false;
}
function draw() {
CTX.clearRect(0, 0, W, H);
// Render all agents
agents.forEach(a => {
// Faded goal dot
CTX.beginPath();
CTX.arc(a.gx, a.gy, 4, 0, 2 * Math.PI);
CTX.fillStyle = a.color + '44';
CTX.fill();
// Agent disc with white outline
CTX.beginPath();
CTX.arc(a.x, a.y, R, 0, 2 * Math.PI);
CTX.fillStyle = a.color;
CTX.fill();
CTX.strokeStyle = '#fff';
CTX.lineWidth = 1.5;
CTX.stroke();
// Velocity direction arrow
if (a.vx * a.vx + a.vy * a.vy > 0.02) {
CTX.beginPath();
CTX.moveTo(a.x, a.y);
CTX.lineTo(a.x + a.vx * 9, a.y + a.vy * 9);
CTX.strokeStyle = '#ffffffcc';
CTX.lineWidth = 1.8;
CTX.stroke();
}
});
// Collision warning overlay (rare, shown only in extreme density)
if (anyCollision()) {
CTX.fillStyle = '#e63946cc';
CTX.font = 'bold 13px system-ui';
CTX.fillText('Overlap detected (too many agents)', 8, 18);
}
// Update agent count display
INFO.textContent = i18nFmt('%n% agents', { n: agents.length });
}
function loop() {
if (!paused) { voStep(); frame++; }
draw();
requestAnimationFrame(loop);
}
document.getElementById('btn-add').addEventListener('click', () => { addAgents(); });
document.getElementById('btn-pause').addEventListener('click', function () {
paused = !paused;
this.textContent = paused ? 'Resume' : 'Pause';
});
document.getElementById('btn-reset').addEventListener('click', () => { initAgents(); frame = 0; });
initAgents();
loop();
Comments
Loading comments...