Traffic that rejoins its lane instead of orbiting it

Cars were driving tight circles at certain junctions, one after
another: twenty laps in thirty seconds to net eight metres.

The lane aim point was a fixed distance *along the lane* from where the
car projects onto it, and that breaks down at both ends. Close in, the
point falls inside the tightest circle the car can drive - about six
metres at TURN_RATE - so it cannot be reached, the car swings past and
comes round again, forever. The obvious fix of growing the lookahead
with the error makes the other end worse: the point runs away down the
lane until the direction to it is almost parallel with the lane itself,
the steering correction vanishes, and the car drifts further out every
step. Instrumented one drifting from 28 to 42 metres wide of its own
road while dutifully pointing along it.

Measuring the lookahead from the *car* instead fixes both at once. On
the line it sits ahead and the car tracks straight; off it by less than
the lookahead the point slides back along the lane and the car cuts in;
off by more there is no such point at all, so it aims at the nearest
bit of lane there is and drives at it.

Worst car over thirty seconds: 21 turns before, 2.7 after - and 2.7
across 419 metres of a grid map is just corners. Worst distance off
lane: 42 metres and growing, now 13.6 and closing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-09 17:43:22 +02:00
parent d8a1f784ae
commit 18477f0e38
2 changed files with 119 additions and 4 deletions

View File

@ -1146,3 +1146,94 @@ describe('traffic and the player', () => {
expect(closed).toBeLessThan(kept);
});
});
describe('a car knocked off its lane', () => {
/**
* The failure this pins is not subtle to watch and is very easy to reintroduce:
* traffic driving tight circles forever, one car after another, because the
* aim point it is steering at cannot be reached.
*/
const straight = world.roads.segments.reduce((a, b) => (a.length > b.length ? a : b));
const along = Math.atan2(straight.bx - straight.ax, straight.bz - straight.az);
const displaced = (metresOff: number) => {
const state = createUnits();
// Right of the direction of travel, matching sim/units.ts.
const offX = -Math.cos(along);
const offZ = Math.sin(along);
const car = {
id: state.nextId++,
kind: 'car' as const,
faction: 'civilian' as const,
role: 'traffic' as const,
x: straight.ax + (straight.bx - straight.ax) * 0.15 + offX * metresOff,
z: straight.az + (straight.bz - straight.az) * 0.15 + offZ * metresOff,
heading: along,
speed: 12,
hp: 60,
path: [straight.b],
lastNode: straight.a,
expires: 1e6,
assigned: null,
onStation: 0,
cooldown: 1,
elevation: 1,
};
state.units.push(car);
let turned = 0;
let previousHeading = car.heading;
// Closest it ever gets to its lane while still driving that leg. Measuring
// at the end is useless: it reaches the junction, picks a new destination,
// and the original lane stops meaning anything.
let closestToLane = Infinity;
for (let i = 0; i < 300; i++) {
stepUnits(
state,
{
dt: 0.1,
now: i * 0.1,
// On the road, not far away: units well outside SIM_RADIUS of the
// player are culled, and a culled car proves nothing.
player: { x: (straight.ax + straight.bx) / 2, z: (straight.az + straight.bz) / 2 },
front,
heatLevel: () => 'clear',
decayHeat: () => {},
decayArea: () => {},
hunt: null,
},
world.roads,
graph,
makeRng(6),
);
let delta = car.heading - previousHeading;
if (delta > Math.PI) delta -= Math.PI * 2;
if (delta < -Math.PI) delta += Math.PI * 2;
turned += Math.abs(delta);
previousHeading = car.heading;
if (car.path[0] === straight.b) {
closestToLane = Math.min(
closestToLane,
Math.abs((car.x - straight.ax) * -Math.cos(along) + (car.z - straight.az) * Math.sin(along)),
);
}
}
return { turns: turned / (Math.PI * 2), lateral: closestToLane, car };
};
it('rejoins the road instead of orbiting it', () => {
// Thirty metres wide of its own road: far enough that the aim point used to
// be unreachable, so the car circled indefinitely and drifted further out
// every lap rather than coming back.
const run = displaced(30);
expect(run.turns).toBeLessThan(1);
// Actually got back to the road, rather than running parallel to it.
expect(run.lateral).toBeLessThan(6);
});
it('holds its lane when it is already on it', () => {
const run = displaced(0);
expect(run.turns).toBeLessThan(0.2);
});
});

View File

@ -512,10 +512,34 @@ function advance(
return unit.path.length === 0;
}
// Pure pursuit: aim at the point on the lane line a fixed distance ahead of
// wherever the car currently projects onto it. Short lookahead weaves, long
// lookahead never quite arrives.
const along = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ + LANE_LOOKAHEAD;
/*
* Pure pursuit: aim at the point on the lane line that is `LANE_LOOKAHEAD`
* metres *from the car*.
*
* The distance being measured from the car rather than along the lane is the
* entire trick, and getting it wrong is what had traffic doing twenty laps of
* a junction to net eight metres, one car after another.
*
* Aim a fixed distance *along the lane* from where the car projects onto it
* and the geometry breaks down twice over. Close in, the aim point falls
* inside the tightest circle the car can drive about six metres at
* `TURN_RATE` so it cannot reach it, swings past and comes round again.
* Far out, the fix of growing the lookahead makes it worse: the aim point
* runs away down the lane until the direction to it is almost parallel with
* the lane itself, the steering correction vanishes, and the car drifts
* further off every step. Measured drifting from 28 to 42 metres wide of its
* own road while dutifully pointing along it.
*
* Holding the aim at a fixed radius from the car fixes both ends at once. On
* the line, it sits `LANE_LOOKAHEAD` ahead and the car tracks straight. Off
* the line by less than that, the point slides back along the lane and the
* car cuts in at a sane angle. Off by more than that, there is no such point
* at all, so it aims at the nearest bit of lane there is and drives at it.
*/
const lateral = (unit.x - laneX) * rightX + (unit.z - laneZ) * rightZ;
const projected = (unit.x - laneX) * dirX + (unit.z - laneZ) * dirZ;
const reach = Math.sqrt(Math.max(0, LANE_LOOKAHEAD * LANE_LOOKAHEAD - lateral * lateral));
const along = projected + reach;
const aimX = laneX + dirX * along;
const aimZ = laneZ + dirZ * along;