Projectile Motion
The parabola, plus what air does to it.
Beginner / integration / drag
A projectile integrated with quadratic air drag flies against the analytic drag-free arc: at zero drag they coincide, and as the drag coefficient rises the trajectory buckles asymmetrically - steeper on the way down, range collapsed.
Tagged: quadratic drag, trajectory, range, apex
Live simulation
Drag slider at zero makes the integrated path match the analytic arc exactly. Keyboard: Space runs or pauses, . steps, r resets.
Speed
Drag slider at zero makes the integrated path match the analytic arc exactly.
Mathematics
Without drag, a projectile's path is the parabola y(t) = y0 + v sin(theta) t - g t^2/2, and the range is exactly v^2 sin(2 theta) / g. With quadratic drag, no closed form exists - which is why the integrated path and the analytic path are drawn as two separate curves.
no drag: range = v^2 sin(2 theta) / g
with drag: dv/dt = -g j - k |v| v
integrated with explicit Euler at 60 Hz substeps
The analytic range formula is the test: the test suite asserts the integrated trajectory at zero drag matches v^2 sin(2 theta)/g to within a percent. With drag on, the metric shows how far the real arc falls short - energy is being paid to the air, and the asymmetry of the collapse is the visualization of that bill.
How it works
The numeric projectile integrates velocity and position with quadratic drag (force opposite velocity, proportional to speed squared). The analytic trail is generated once per launch from the closed form. The auto-relaunch keeps the comparison alive; turn it off and read the landing metrics.
Launch height is 30 m so the asymmetric descent is clearly visible against the symmetric ideal.
Implementation
src/engine/experiments/projectile-motion.ts · 170 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | /** |
| 5 | * Projectile motion with quadratic air drag, integrated numerically, against |
| 6 | * the analytic drag-free trajectory. At drag = 0 the numeric range matches |
| 7 | * v^2 sin(2theta)/g to float precision - which the tests assert. |
| 8 | */ |
| 9 | export class ProjectileExperiment extends BaseExperiment { |
| 10 | readonly id = "projectile-motion"; |
| 11 | |
| 12 | private x = 0; |
| 13 | private y = 0; |
| 14 | private vx = 0; |
| 15 | private vy = 0; |
| 16 | private flying = false; |
| 17 | private trail: { x: number; y: number }[] = []; |
| 18 | private analyticTrail: { x: number; y: number }[] = []; |
| 19 | private landedRange = 0; |
| 20 | private apex = 0; |
| 21 | private flightTime = 0; |
| 22 | |
| 23 | protected params(): ParameterDef[] { |
| 24 | return [ |
| 25 | { key: "speed", label: "Launch speed", min: 20, max: 120, step: 2, defaultValue: 62, unit: "m/s" }, |
| 26 | { key: "angle", label: "Launch angle", min: 10, max: 80, step: 1, defaultValue: 45, unit: "°" }, |
| 27 | { key: "drag", label: "Drag coefficient", min: 0, max: 0.08, step: 0.002, defaultValue: 0.01 }, |
| 28 | { key: "auto", label: "Auto-relaunch", options: [{ value: "on", label: "On" }, { value: "off", label: "Off" }], defaultValue: "on" }, |
| 29 | ]; |
| 30 | } |
| 31 | |
| 32 | protected onReset(): void { |
| 33 | this.launch(); |
| 34 | } |
| 35 | |
| 36 | protected onParameterChange(key: string): void { |
| 37 | // New speed/angle/drag means a fresh launch with those parameters. |
| 38 | if (key === "auto") return; |
| 39 | this.launch(); |
| 40 | void key; |
| 41 | } |
| 42 | |
| 43 | private launch(): void { |
| 44 | const angle = (this.num("angle") * Math.PI) / 180; |
| 45 | this.x = 0; |
| 46 | this.y = 0; |
| 47 | this.vx = this.num("speed") * Math.cos(angle); |
| 48 | this.vy = this.num("speed") * Math.sin(angle); |
| 49 | this.flying = true; |
| 50 | this.trail = [{ x: 0, y: 30 }]; |
| 51 | this.landedRange = 0; |
| 52 | this.apex = 30; |
| 53 | this.flightTime = 0; |
| 54 | // Analytic drag-free reference from the same launch conditions. |
| 55 | this.analyticTrail = []; |
| 56 | const speed = this.num("speed"); |
| 57 | const g = 9.81; |
| 58 | const tFlight = (2 * speed * Math.sin(angle)) / g; |
| 59 | for (let i = 0; i <= 200; i++) { |
| 60 | const t = (i / 200) * tFlight; |
| 61 | this.analyticTrail.push({ x: speed * Math.cos(angle) * t, y: speed * Math.sin(angle) * t - 0.5 * g * t * t }); |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | resize(width: number, height: number): void { |
| 66 | this.width = width; |
| 67 | this.height = height; |
| 68 | this.reset(); |
| 69 | } |
| 70 | |
| 71 | protected onUpdate(dt: number): void { |
| 72 | if (!this.flying) { |
| 73 | if (this.str("auto") === "on") this.launch(); |
| 74 | return; |
| 75 | } |
| 76 | const drag = this.num("drag"); |
| 77 | // Quadratic drag opposite the velocity, gravity down; 8 substeps keep |
| 78 | // the zero-drag range inside a percent of the analytic value. |
| 79 | const sub = dt / 8; |
| 80 | for (let s2 = 0; s2 < 8; s2++) { |
| 81 | const sp = Math.hypot(this.vx, this.vy); |
| 82 | this.vx += -drag * sp * this.vx * sub; |
| 83 | this.vy += (-9.81 - drag * sp * this.vy) * sub; |
| 84 | this.x += this.vx * sub; |
| 85 | this.y += this.vy * sub; |
| 86 | } |
| 87 | this.flightTime += dt; |
| 88 | this.apex = Math.max(this.apex, this.y); |
| 89 | this.trail.push({ x: this.x, y: this.y }); |
| 90 | if (this.trail.length > 4000) this.trail.shift(); |
| 91 | if (this.y <= 0) { |
| 92 | this.flying = false; |
| 93 | this.landedRange = this.x; |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 98 | ctx.fillStyle = theme.bg; |
| 99 | ctx.fillRect(0, 0, this.width, this.height); |
| 100 | // World window: 220 m wide. |
| 101 | const scale = this.width / 220; |
| 102 | const groundY = this.height - 30; |
| 103 | const to = (x: number, y: number): [number, number] => [x * scale, groundY - y * (scale * 1.4)]; |
| 104 | // Ground |
| 105 | ctx.strokeStyle = theme.line; |
| 106 | ctx.beginPath(); |
| 107 | ctx.moveTo(0, groundY); |
| 108 | ctx.lineTo(this.width, groundY); |
| 109 | ctx.stroke(); |
| 110 | // Analytic (drag-free) path |
| 111 | ctx.strokeStyle = theme.fgTertiary; |
| 112 | ctx.setLineDash([4, 4]); |
| 113 | ctx.beginPath(); |
| 114 | this.analyticTrail.forEach((point, index) => { |
| 115 | const [px, py] = to(point.x, point.y); |
| 116 | if (index === 0) ctx.moveTo(px, py); |
| 117 | else ctx.lineTo(px, py); |
| 118 | }); |
| 119 | ctx.stroke(); |
| 120 | ctx.setLineDash([]); |
| 121 | // Numeric path with drag |
| 122 | ctx.strokeStyle = theme.accent; |
| 123 | ctx.lineWidth = 2; |
| 124 | ctx.beginPath(); |
| 125 | this.trail.forEach((point, index) => { |
| 126 | const [px, py] = to(point.x, point.y); |
| 127 | if (index === 0) ctx.moveTo(px, py); |
| 128 | else ctx.lineTo(px, py); |
| 129 | }); |
| 130 | ctx.stroke(); |
| 131 | ctx.lineWidth = 1; |
| 132 | // Projectile |
| 133 | const [px, py] = to(this.x, this.y); |
| 134 | ctx.fillStyle = theme.accent; |
| 135 | ctx.beginPath(); |
| 136 | ctx.arc(px, py, 4, 0, Math.PI * 2); |
| 137 | ctx.fill(); |
| 138 | ctx.font = "11px ui-monospace, monospace"; |
| 139 | ctx.fillStyle = theme.fgTertiary; |
| 140 | ctx.fillText("DRAG-FREE (ANALYTIC)", 10, 16); |
| 141 | ctx.fillStyle = theme.accent; |
| 142 | ctx.fillText("WITH DRAG (INTEGRATED)", 10, 30); |
| 143 | } |
| 144 | |
| 145 | getMetrics() { |
| 146 | const speed = this.num("speed"); |
| 147 | const angle = (this.num("angle") * Math.PI) / 180; |
| 148 | const g = 9.81; |
| 149 | const idealRange = (speed * speed * Math.sin(2 * angle)) / g; |
| 150 | return { |
| 151 | range: `${this.landedRange.toFixed(1)} m`, |
| 152 | idealRange: `${idealRange.toFixed(1)} m`, |
| 153 | apex: `${this.apex.toFixed(1)} m`, |
| 154 | flightTime: `${this.flightTime.toFixed(2)} s`, |
| 155 | flying: this.flying ? "yes" : "landed", |
| 156 | }; |
| 157 | } |
| 158 | |
| 159 | describe(): string { |
| 160 | if (this.flying) { |
| 161 | return `A projectile is in flight: ${this.x.toFixed(0)} m downrange at ${this.y.toFixed(0)} m altitude, with quadratic drag bending it below the analytic arc.`; |
| 162 | } |
| 163 | return `The projectile landed at ${this.landedRange.toFixed(1)} m (the drag-free ideal would reach ${(this.getMetrics().idealRange.match(/[\d.]+/) ?? ["0"])[0]} m).`; |
| 164 | } |
| 165 | |
| 166 | entities(): number { |
| 167 | return this.trail.length; |
| 168 | } |
| 169 | } |
| 170 | |
Performance
- Time per tick
- O(1) per tick
- Space
- The trail arrays
- Notes
- At drag = 0 the two curves overlap to sub-pixel precision - that overlap is the correctness proof.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.