A tunnel rushes toward the viewpoint. Glowing grid lines converge in the distance, sweep past the periphery, and curve over crests and through sweeping turns. Polygonal tiles can rotate around the moving axis while the tunnel itself maintains its path through space.
Generating this motion requires neither WebGL nor an external graphics library. The entire scene emerges from elementary trigonometry, vectors, and a 2D canvas context. We place geometric vertices in three-dimensional space, project them onto a planar screen using similar triangles, and guide an orthonormal reference frame along an undulating parametric curve. Expanding this wireframe foundation with depth-sorted quad polygons, radial gravity, and hazard detection yields an arcade tunnel runner.
Perspective Projection from Pinhole Geometry
To project 3D points onto a 2D surface, we define a right-handed camera coordinate system. The camera sits at the origin \((0, 0, 0)\) looking down the positive \(z\)-axis. The \(x\)-axis points to the right, and the \(y\)-axis points downward to match screen pixel coordinates. In this layout, a smaller \(y\)-coordinate corresponds to a higher physical elevation.
An image plane is positioned at focal distance \(f\) in front of the camera. A spatial point \(\mathbf{P} = (x, y, z)\) with \(z > 0\) projects onto the image plane at the intersection of the ray connecting the origin to \(\mathbf{P}\).
Examining the side profile reveals two similar right triangles, yielding the ratio \(\frac{y'}{f} = \frac{y}{z}\). The identical geometric proportionality governs the horizontal axis: \(\frac{x'}{f} = \frac{x}{z}\). Translating the optical center to the center of a screen of dimensions \(W \times H\) gives the final screen projection formulas:
\[\begin{aligned} x_{\text{screen}} &= \frac{W}{2} + f \frac{x}{z} \\[6pt] y_{\text{screen}} &= \frac{H}{2} + f \frac{y}{z} \end{aligned}\]A point twice as far from the camera exhibits half the screen displacement from the center. The focal length \(f\) is expressed in pixels. Choosing \(f = 0.8 \max(W, H)\) delivers a natural field of view of approximately \(64^\circ\). Larger values narrow the view like a telephoto lens, while smaller values widen the viewing angle.
function project(x, y, z, width, height, focal) {
return {
x: width / 2 + focal * x / z,
y: height / 2 + focal * y / z
};
} Points lying at or behind the camera (\(z \le 0\)) cannot pass through this formula, as dividing by zero or negative depths inverts geometry across the screen. These points are handled prior to division by clipping against a near plane.
Constructing Polygonal Tunnel Rings
A circle of radius \(r\) centered on the \(z\)-axis is parameterized by an angle \(\alpha\):
\[P(s, \alpha) = \big(r \cos \alpha, \, r \sin \alpha, \, s\big)\]Rather than attempting to render continuous smooth cylinders, we discretize each cross-section into a regular polygon with \(N\) vertices. Sample angles are spaced evenly across the circle for \(j \in \{0, 1, \dots, N - 1\}\):
\[\alpha_j = \frac{2\pi j}{N}\]Sequential rings are positioned along the tunnel at fixed longitudinal intervals \(s = k \Delta s\). Within each ring, transverse edges connect vertex \(j\) to vertex \((j + 1) \bmod N\). Longitudinal rails connect vertex \(j\) of ring \(k\) to the corresponding vertex \(j\) of ring \(k + 1\). Each quadrilateral panel of the tunnel is thus bounded by four line segments.
function straightRing(s, radius, sides) {
const points = [];
for (let j = 0; j < sides; j++) {
const angle = (2 * Math.PI * j) / sides;
points.push({
x: radius * Math.cos(angle),
y: radius * Math.sin(angle),
z: s
});
}
return points;
} To simulate forward motion, the camera advances to position \(C(q) = (0, 0, q)\). For a world point at depth \(s\), its relative depth from the camera is \(z = s - q\). As \(q\) increases over time at forward speed \(v\), the rings expand outward on screen until they pass behind the camera plane:
distance += speed * dt;
const relativeZ = worldZ - distance;
const screen = project(worldX, worldY, relativeZ, width, height, focal); Multiplying speed by delta time \(\Delta t\) guarantees frame-rate-independent travel. An infinite tunnel does not require unbounded memory: we only generate a sliding window of rings—typically 50 rings ahead and a few rings behind the camera.
Centerline Trajectory and Moving Reference Frames
A straight tunnel is monotonous. To introduce hills and valleys, we replace the straight \(z\)-axis with a spatial curve \(C(s)\). The parameter \(s\) continues to track depth:
\[C(s) = \big(0, \, A_y \sin(k_y s), \, s\big)\]Here, \(A_y\) denotes vertical amplitude, while \(k_y\) is the angular wavenumber. The wave repeats every wavelength \(\lambda = \frac{2\pi}{k_y}\).
A curved path introduces a fundamental requirement: the camera must not only shift its position, but also rotate to face along the track. Similarly, each tunnel ring must tilt so that its cross-sectional plane remains orthogonal to the local forward tangent.
The local tangent vector is obtained cleanly via symmetric central differences without analytical derivation:
\[\mathbf{\Delta} = C(s + \varepsilon) - C(s - \varepsilon)\]Setting \(\varepsilon = 1\) delivers smooth, accurate approximations. In the vertical plane, the tangent components are \(\Delta y\) and \(\Delta z\). The pitch angle \(\theta\) follows from the two-argument arctangent:
\[\theta = \operatorname{atan2}(\Delta y, \, \Delta z)\]For vertical-only curvature, the orthonormal moving triad consists of the forward unit vector \(\mathbf{F}\), the downward unit vector \(\mathbf{D}\), and the invariant right vector \(\mathbf{R}\):
\[\begin{aligned} \mathbf{F} &= (0, \, \sin \theta, \, \cos \theta) \\ \mathbf{D} &= (0, \, \cos \theta, \, -\sin \theta) \\ \mathbf{R} &= (1, \, 0, \, 0) \end{aligned}\]Given local radial offsets \(u = r \cos \alpha\) and \(v = r \sin \alpha\), the 3D position of each vertex on the ring is:
\[\mathbf{P} = C(s) + u \mathbf{R} + v \mathbf{D}\]Full 3D Curves: Yaw and Pitch Moving Frames
To add lateral curves, the centerline incorporates a second wave in the \(x\)-dimension:
\[C(s) = \big(A_x \sin(k_x s), \, A_y \sin(k_y s), \, s\big)\]Choosing incommensurate frequencies (\(k_x \neq k_y\)) prevents horizontal and vertical extrema from synchronizing, producing organic turns and dives.
Orientation in 3D curve space requires two angles. The yaw angle \(\psi\) sets the horizontal direction, while the pitch angle \(\theta\) dictates incline relative to the horizontal distance \(h\):
\[\begin{aligned} \psi &= \operatorname{atan2}(\Delta x, \, \Delta z) \\ h &= \sqrt{\Delta x^2 + \Delta z^2} \\ \theta &= \operatorname{atan2}(\Delta y, \, h) \end{aligned}\]From these two angles, we construct a normalized moving frame \(\{\mathbf{R}, \mathbf{D}, \mathbf{F}\}\):
\[\begin{aligned} \mathbf{R} &= (\cos \psi, \, 0, \, -\sin \psi) \\ \mathbf{D} &= (-\sin \psi \sin \theta, \, \cos \theta, \, -\cos \psi \sin \theta) \\ \mathbf{F} &= (\sin \psi \cos \theta, \, \sin \theta, \, \cos \psi \cos \theta) \end{aligned}\]This triad is orthonormal: each vector has unit length (\(\|\mathbf{R}\| = \|\mathbf{D}\| = \|\mathbf{F}\| = 1\)), and they are mutually perpendicular (\(\mathbf{R} \cdot \mathbf{D} = 0\), \(\mathbf{R} \cdot \mathbf{F} = 0\), \(\mathbf{D} \cdot \mathbf{F} = 0\)), satisfying the identity \(\sin^2 \omega + \cos^2 \omega = 1\). Because they form a right-handed basis, the forward direction also equals the cross product \(\mathbf{F} = \mathbf{R} \times \mathbf{D}\).
function frame(s) {
const a = path(s - 1);
const b = path(s + 1);
const dx = b.x - a.x;
const dy = b.y - a.y;
const dz = b.z - a.z;
const yaw = Math.atan2(dx, dz);
const pitch = Math.atan2(dy, Math.hypot(dx, dz));
const sy = Math.sin(yaw), cy = Math.cos(yaw);
const sp = Math.sin(pitch), cp = Math.cos(pitch);
return {
right: { x: cy, y: 0, z: -sy },
down: { x: -sy * sp, y: cp, z: -cy * sp },
forward: { x: sy * cp, y: sp, z: cy * cp }
};
} World-to-Camera Coordinate Transformation
A ring at distance \(s\) uses the reference frame computed at \(s\). The camera at distance \(q\) has its own independent reference frame computed at \(q\). The visual perspective of entering a curve arises from the difference between these two systems.
To observe a world-space point \(\mathbf{P}\) through the camera, we first compute its relative displacement vector:
\[\mathbf{Q} = \mathbf{P} - C(q)\]We then project \(\mathbf{Q}\) onto the camera's orthonormal axes using the scalar dot product:
\[\begin{aligned} x_{\text{cam}} &= \mathbf{Q} \cdot \mathbf{R}_{\text{cam}} \\ y_{\text{cam}} &= \mathbf{Q} \cdot \mathbf{D}_{\text{cam}} \\ z_{\text{cam}} &= \mathbf{Q} \cdot \mathbf{F}_{\text{cam}} \end{aligned}\]Component-wise, this expands to \(x_{\text{cam}} = Q_x R_x + Q_y R_y + Q_z R_z\), and similarly for \(y_{\text{cam}}\) and \(z_{\text{cam}}\).
function dot(a, b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
const q = { x: p.x - camera.x, y: p.y - camera.y, z: p.z - camera.z };
const cameraPoint = {
x: dot(q, axes.right),
y: dot(q, axes.down),
z: dot(q, axes.forward)
}; Geometrically, the dot product computes the scalar projection of \(\mathbf{Q}\) onto each camera axis. Feeding \((x_{\text{cam}}, y_{\text{cam}}, z_{\text{cam}})\) into the perspective projection formula completes the 3D pipeline.
Axial Rotation and Longitudinal Twisting
Rotating the tunnel does not require altering the centerline curve or camera trajectory. Instead, we offset the angle at which vertices are distributed across each ring:
\[\begin{aligned} \alpha_j(t) &= \frac{2\pi j}{N} + \varphi(t) \\[4pt] u &= r \cos \alpha_j(t) \\ v &= r \sin \alpha_j(t) \end{aligned}\]Setting \(\varphi(t) = \omega t\) produces continuous rotation at angular rate \(\omega\). With \(\omega = 0.22\text{ rad/s}\), one full revolution takes approximately 28.6 seconds.
A distinction exists between temporal spin and longitudinal twist. When \(\varphi\) depends on time \(t\), the tunnel spins uniformly. Adding a spatial term \(\tau s\) introduces a progressive twist along the tunnel length: \(\alpha_j(s, t) = \frac{2\pi j}{N} + \omega t + \tau s\).
Decoupled Mouse Look with Exponential Smoothing
Fixed forward tracking can feel rigid. Decoupling the camera's gaze allows looking left, right, up, and down without deviating from the trajectory.
Mouse coordinates within the canvas are mapped to normalized coordinates in \([-1, 1]\):
\[\begin{aligned} m_x &= \frac{2(x_{\text{mouse}} - \text{left})}{W} - 1 \\[8pt] m_y &= \frac{2(y_{\text{mouse}} - \text{top})}{H} - 1 \end{aligned}\]Multiplying by maximum deflection angles (\(a_{\max} \approx 0.55\text{ rad} \approx 31.5^\circ\) and \(b_{\max} \approx 0.38\text{ rad} \approx 21.8^\circ\)) gives target angles \(a = a_{\max} m_x\) and \(b = b_{\max} m_y\).
Rotations must apply relative to the moving camera triad \(\{\mathbf{R}, \mathbf{D}, \mathbf{F}\}\), not static world axes. First, yaw swivels the forward vector horizontally around \(\mathbf{D}\):
\[\begin{aligned} \mathbf{F}_1 &= \sin a \, \mathbf{R} + \cos a \, \mathbf{F} \\ \mathbf{R}_1 &= \cos a \, \mathbf{R} - \sin a \, \mathbf{F} \\ \mathbf{D}_1 &= \mathbf{D} \end{aligned}\]Second, pitch tilts the resulting frame around the newly rotated right axis \(\mathbf{R}_1\):
\[\begin{aligned} \mathbf{F}_2 &= \cos b \, \mathbf{F}_1 + \sin b \, \mathbf{D}_1 \\ \mathbf{D}_2 &= \cos b \, \mathbf{D}_1 - \sin b \, \mathbf{F}_1 \\ \mathbf{R}_2 &= \mathbf{R}_1 \end{aligned}\]To eliminate abrupt motion, we interpolate the current angle toward the target using exponential decay:
\[\text{blend} = 1 - e^{-\lambda \Delta t}\] \[\theta \leftarrow \theta + (\theta_{\text{target}} - \theta) \cdot \text{blend}\]With \(\lambda = 8\), approximately 98% of any pointing offset is smoothed away within 0.5 seconds, delivering a fluid, damped camera feel.
Near-Plane 3D Line and Polygon Clipping
When a tilted ring passes near or through the camera plane, some vertices have \(z < z_{\text{near}}\). Dividing by shallow or negative \(z\) values creates explosive visual artifacts and inverted lines across the screen.
We define a near clipping plane at \(z = z_{\text{near}} > 0\) (e.g. \(z_{\text{near}} = 30\)). Line segments \(\mathbf{A}\mathbf{B}\) are clipped in 3D camera space before projection:
- If both \(A_z < z_{\text{near}}\) and \(B_z < z_{\text{near}}\), discard the segment entirely.
- If both \(A_z \ge z_{\text{near}}\) and \(B_z \ge z_{\text{near}}\), retain the segment unaltered.
- If one endpoint lies behind \(z_{\text{near}}\), truncate the segment at the intersection parameter \(t \in [0, 1]\):
function clipLine(a, b, near) {
if (a.z < near && b.z < near) return null;
if (a.z < near || b.z < near) {
const t = (near - a.z) / (b.z - a.z);
const hit = {
x: a.x + t * (b.x - a.x),
y: a.y + t * (b.y - a.y),
z: near
};
return a.z < near ? [hit, b] : [a, hit];
}
return [a, b];
} For solid quadrilateral panels in the game stage, we apply Sutherland-Hodgman polygon clipping against the single plane \(z = z_{\text{near}}\). Each edge of the quad is tested sequentially: interior vertices are preserved, and edge intersections with \(z = z_{\text{near}}\) generate new boundary vertices, creating valid clipped polygons ready for depth-sorted projection.
Parameter Kinematics and Arc-Length Considerations
The procedural tunnel appearance is controlled by nine geometric parameters:
| Parameter | Role & Effect |
|---|---|
| Radius \(r\) | Cross-sectional width of the tunnel corridor. |
| Ring Spacing \(\Delta s\) | Longitudinal length of each panel segment. |
| Vertex Count \(N\) | Polygon resolution around the tunnel perimeter. |
| Amplitudes \(A_x, A_y\) | Maximum lateral and vertical track deflection. |
| Wavenumbers \(k_x, k_y\) | Frequency of curves and undulations per unit depth. |
| Focal Length \(f\) | Field of view and perspective compression. |
| Angular Speed \(\omega\) | Axial roll rate of tunnel walls. |
Because \(s\) represents longitudinal \(z\)-depth rather than measured arc length along the curve, advancing \(s\) at a constant rate results in higher physical velocity on steep slopes:
\[\frac{ds_{\text{arc}}}{ds} = \sqrt{\left(\frac{dx}{ds}\right)^2 + \left(\frac{dy}{ds}\right)^2 + 1}\]To maintain a constant physical speed \(v\), we compute the local metric stretch \(g\) using finite differences:
\[g \approx \frac{\|C(s + \varepsilon) - C(s - \varepsilon)\|}{2\varepsilon}\] \[\Delta s \approx \frac{v \Delta t}{g}\]The Tunnel Runner: Transforming the Visualizer into a Game
The final stage converts the visualizer into an interactive tunnel runner. The tunnel surface is mapped to an unrolled cylindrical grid where the player maneuvers around the perimeter, clears gaps, and dodges obstacles.
The unrolled surface indexes longitudinal rows and angular columns:
\[\begin{aligned} \text{row} &= \left\lfloor \frac{s}{\Delta s} \right\rfloor \\ \text{col} &= \lfloor \ell \rfloor \bmod N \\ \alpha &= \frac{2\pi \ell}{N} + \varphi \end{aligned}\]Here, \(\ell\) denotes continuous angular position in units of columns. Lateral steering shifts \(\ell\) continuously, wrapping toroidally across the interval \([0, N)\).
Ballistic Jumps with Radial Gravity
Pressing the jump control accelerates the player radially toward the tunnel centerline with initial velocity \(v_0\). A constant inward restorative acceleration \(g\) acts as radial gravity:
\[\begin{aligned} H(t) &= v_0 t - \frac{1}{2} g t^2 \\ v(t) &= v_0 - g t \end{aligned}\]With \(v_0 = 480\) and \(g = 1100\), flight duration lasts \(T = \frac{2v_0}{g} \approx 0.87\text{ s}\), reaching peak height \(H_{\max} = \frac{v_0^2}{2g} \approx 105\) units. At an initial forward velocity of 350 units/second, the jump traverses approximately 305 units of track—safely clearing standard two-row gaps of 240 units.
The radial position of the player contracts during a jump:
\[r_{\text{player}} = r \cos\left(\frac{\pi}{N}\right) - H\]Substepping and Collision Detection
To prevent high-speed tunneling through hazard tiles between animation frames, physics updates run in fixed sub-steps of at most \(\frac{1}{120}\) second:
function gameStep(dt) {
const steps = Math.ceil(dt / (1 / 120));
for (let i = 0; i < steps && gameState === 'running'; i++) {
const subDt = dt / steps;
const dir = (held.has('left') ? 1 : 0) - (held.has('right') ? 1 : 0);
lane = wrap(lane + dir * 4 * subDt, sides);
const speed = 350 + 150 * Math.min(distance / finishDistance, 1);
distance += speed * subDt;
jumpHeight += jumpVelocity * subDt - 0.5 * gravity * subDt * subDt;
jumpVelocity -= gravity * subDt;
if (jumpHeight <= 0) {
jumpHeight = 0;
jumpVelocity = 0;
}
const row = Math.floor((distance + lead) / spacing);
const col = Math.floor(lane);
if (jumpHeight === 0 && isHole(row, col)) {
gameState = 'lost';
} else if (distance >= finishDistance) {
gameState = 'won';
}
}
} Tunnel panels are sorted by average camera depth (\(z_{\text{cam}}\)) and rendered back-to-front (painter's algorithm). This lightweight sorting maintains high frame rates on standard 2D canvases without requiring a depth buffer.
Structural Constraints and Boundary Conditions
While effective for real-time visualization, this geometry model has specific boundaries:
- Envelope condition and self-intersection: If the radius of curvature \(R = \frac{1}{\kappa}\) of the centerline becomes smaller than the tunnel radius \(r\) (that is, \(\kappa r \ge 1\)), consecutive polygonal rings intersect internally. Maintaining gentle wave amplitudes (\(A k^2 r < 1\)) prevents self-intersection.
- Monotonic progression: The assumption \(z = s\) precludes vertical loops or sharp switchbacks. Full 3D rollercoasters require general parametric space curves \(\mathbf{C}(s)\) with Bishop rotation-minimizing frames to prevent twist discontinuities.
- Visibility sorting: The painter's algorithm assumes depth ordering by centroid. For extreme curvatures or overlapping ring structures, a full depth buffer or binary space partitioning (BSP) tree is required.
References
- [Bishop1975]Bishop, R. L. (1975). There is more than one way to frame a curve. The American Mathematical Monthly, 82(3), 246–251.
- [Sutherland1974]Sutherland, I. E., & Hodgman, G. W. (1974). Reentrant polygon clipping. Communications of the ACM, 17(1), 32–42.
- [Foley1996]Foley, J. D., van Dam, A., Feiner, S. K., & Hughes, J. F. (1996). Computer Graphics: Principles and Practice (2nd ed.). Addison-Wesley.