Maximizing Projectile Range with Air Resistance
Every physics student learns that a projectile launched over flat ground travels farthest when fired at 45°. That result, however, only holds in a vacuum. The moment air resistance enters the picture, the textbook formula falls apart — the optimal angle drifts below 45°, and there’s no closed-form expression left to solve for it. This is where the problem stops being a physics exercise and becomes a genuine numerical optimization task: given a nonlinear equation of motion, find the launch angle that maximizes horizontal range.
In this article, we’ll set up the drag-augmented projectile problem, solve it numerically in a way that stays fast even when scanning hundreds of angle/velocity combinations, and visualize the resulting range surface in 3D.
Problem Setup
Without drag, the range of a projectile launched at speed $v_0$ and angle $\theta$ is:
$$
R(\theta) = \frac{v_0^2 \sin(2\theta)}{g}
$$
which is maximized analytically at $\theta = 45°$.
With quadratic air drag, the equations of motion become a coupled nonlinear system with no closed-form solution:
$$
\frac{dv_x}{dt} = -k , |v| , v_x, \qquad \frac{dv_y}{dt} = -g - k , |v| , v_y
$$
$$
|v| = \sqrt{v_x^2 + v_y^2}, \qquad \frac{dx}{dt} = v_x, \qquad \frac{dy}{dt} = v_y
$$
Here $k$ is the drag coefficient divided by mass ($k = \frac{1}{2}\rho C_d A / m$), and $g = 9.81\ \text{m/s}^2$. Since $R(\theta)$ can only be evaluated by numerically integrating this system, finding the optimal angle means combining numerical ODE integration with numerical optimization.
A naive approach would integrate the trajectory for one angle at a time inside a Python loop, calling a solver like scipy.integrate.solve_ivp dozens or hundreds of times. That works, but it’s slow — each call carries fixed overhead, and scanning a full angle range or a 2D grid of (angle, velocity) combinations for a 3D plot multiplies that cost quickly. The code below avoids this by integrating all trajectories simultaneously as vectorized NumPy arrays, so one function call handles an entire batch of launch conditions at once.
Source Code
1 | import numpy as np |


=== Optimal Angle Search (with air drag) === Launch speed v0 : 30.00 m/s Drag coefficient k : 0.0200 1/m Optimal launch angle : 39.084 deg Maximum range (with drag) : 42.170 m Vacuum optimum (45 deg) : 91.743 m Range reduction from drag : 54.03 % === Grid Search Peak (angle x velocity surface) === Peak found at angle : 36.00 deg Peak found at velocity : 45.00 m/s Peak range : 61.468 m
Code Walkthrough
simulate_range — the vectorized physics engine. This is the core of the whole script. Instead of writing a function that simulates one trajectory and calling it in a Python for loop over angles, theta_rad and v0 are NumPy arrays where each index is an independent launch. Every line inside the time loop — computing speed, acceleration, and updating velocity/position — operates on the entire array at once. Whether you pass in 1 trajectory or 1,400, the number of Python-level loop iterations stays the same (N_STEPS); only the array width changes, and NumPy’s C-level operations absorb that cost almost for free. This is the difference between calling solve_ivp a thousand times and calling one vectorized loop a thousand steps.
Symplectic Euler integration. Rather than a full Runge-Kutta scheme, the integrator updates velocity first, then uses the new velocity to update position. This “semi-implicit” ordering is more energy-stable than plain (explicit) Euler for oscillatory/ballistic motion, while remaining trivial to vectorize. With DT = 0.001 s, the position error over a multi-second flight stays well within a few centimeters — accurate enough for comparing landing distances across angles.
Landing detection via linear interpolation. Because the simulation advances in fixed time steps, a trajectory’s height will jump from a small positive value to a negative one between two steps — it doesn’t land exactly on a grid point. The touch_down mask catches the exact step where a trajectory crosses y = 0, and frac linearly interpolates between the previous and current step to recover a smooth, sub-timestep estimate of the true landing position. Skipping this step would produce a visibly “steppy,” inaccurate range curve.
Two-stage optimization. First, angles_deg = np.linspace(1, 89, 89) gives a coarse scan across almost the full angle range for the 2D plot. Then minimize_scalar with method="bounded" refines the answer using a bounded 1D search (Brent’s method under the hood), calling simulate_range with single-element arrays. This two-stage pattern — coarse vectorized scan for visualization, fine-grained scalar optimizer for the precise answer — is a common and efficient pattern for this class of problem.
The 3D grid. np.meshgrid builds every (angle, velocity) combination, which is then flattened into two 1D arrays and passed to simulate_range in a single call. This means the entire 2D surface (89 angles × 16 velocities = 1,424 trajectories) is integrated in one vectorized pass rather than 1,424 separate simulation calls — this is precisely the optimization that keeps the 3D surface generation fast.
Interpreting the Results
The 2D plot should show a curve that peaks noticeably to the left of 45° — the red marker (numerically optimized angle) will sit below the orange dashed vacuum-optimum line. This confirms the physical intuition: at steeper trajectories the projectile spends more time in flight, so drag (which acts continuously along the velocity vector) has more time to sap horizontal momentum. Flattening the angle trades some of that “hang time” back for horizontal speed retention, shifting the sweet spot below 45°.
The 3D surface makes a second, less obvious pattern visible: the optimal angle itself is not constant across launch speeds. At low $v_0$, drag has less time to act before the projectile lands, so the surface’s ridge sits closer to 45°. At high $v_0$, drag has proportionally more effect (since drag force grows with $v^2$), and the ridge tilts further away from 45°. The console output’s “grid peak” values pin down exactly where, across the entire tested velocity range, the single largest range occurs.



























