欢迎来到一次“把数学塞进画布里”的长途旅行:我们将用 p5.js 在 2D 画布上伪装出一个动态的 3D 行星战斗系统。目标包括:绘制带经纬线的行星、在三维空间中渲染卫星轨道、并可视化它们的攻击范围——这一切都通过二维投影实现。

目录


引言

在游戏开发里,“看起来像 3D”往往比“真 3D”更常见——尤其当你在 2D 画布上做可视化、原型或风格化渲染时。本篇文章讲解一个 p5.js 实现:一颗带经纬线的行星、围绕它运动的卫星、以及动态的攻击范围圈,全部投影到 2D 屏幕上。

核心思路是:用向量投影制造深度感,用四元数做平滑旋转(避开欧拉角的万向节锁问题),再用几何关系处理“遮挡”(轨道或卫星在行星背面时该怎么画)。


创建带经纬线的行星

整个可视化的中心是“行星”:一个由半径与质量定义的球体。为了让它更像“真实天体”,我们叠加纬线与经线,模拟地理网格。

定义行星

行星由几个关键参数控制:

  • 半径(radius:决定行星在画布上的大小。
  • 质量(planetMass:影响引力计算(对卫星轨道很关键)。
  • 自转(rotationAngle:让行星持续旋转,画面更“活”。

绘制球体

在 p5.js 里,我们用一个基础圆(正面投影)作为行星的轮廓,再叠加经纬线来暗示“球面”。

function drawBattle() {
rotationAngle = (rotationAngle + currentLevel.planetRotationSpeed) % TWO_PI; // Planet's rotation

// Draw the planet
push();
translate(centerX, centerY);
stroke(0);
ellipse(0, 0, radius * 2); // Base circle representing the planet

// Draw latitude and longitude lines
drawLatitudeLines();
drawLongitudeLines();
pop();
}

纬线与经线

  • 纬线(Latitude):与赤道平行的一圈圈“水平圆”。
  • 经线(Longitude):穿过两极、在球面上收敛的“纵向线”。

实现方式是:在球面上采样很多点(球坐标→笛卡尔坐标),对点做旋转,再投影到 2D。为了增强深度感,依据点的深度(这里用 x)调整透明度,让背面更暗、正面更亮。

function drawLatitudeLines() {
for (let lat = -80; lat <= 80; lat += 20) {
beginShape();
for (let lon = 0; lon <= 360; lon += 5) {
// Calculate 3D coordinates
let latRadian = radians(lat);
let lonRadian = radians(lon);
let x0 = radius * cos(latRadian) * cos(lonRadian);
let y0 = radius * sin(latRadian);
let z0 = radius * cos(latRadian) * sin(lonRadian);

// Rotate point based on planet's rotation
let rotatedPoint = rotateVectorByQuaternion({ x: x0, y: y0, z: z0 }, planetRotationQuat);

// Project to 2D
let x = rotatedPoint.x;
let y = rotatedPoint.y;
let z = rotatedPoint.z;

// Adjust transparency based on depth
let alphaValue = map(x, -radius, radius, 255, 50);
stroke(200, alphaValue);

vertex(z, -y); // 2D projection: z-axis to x, y-axis remains
}
endShape();
}
}

关键点拆解:

  • 3D 坐标计算:用球坐标(经纬)算出每个点在三维中的位置,再转换成 x0, y0, z0
  • 旋转:用四元数对点做旋转,得到平滑自转效果。
  • 投影:把 3D 点映射到 2D。这里采用一种简单的正交投影:把 z 当作横向坐标,把 y 当作纵向坐标(并取负号让屏幕坐标向上为正)。
  • 透明度:用 x 当作“深度指标”,越靠背面越透明/越暗,从而制造球面感。

将 3D 空间投影到 2D 画布

把三维物体画在二维屏幕上,本质上就是把 (x, y, z) 映射为 (screenX, screenY)。投影策略决定了你的画面“像不像 3D”。

理解投影

本实现使用一种 正交投影(Orthographic Projection) 的变体:不会因为距离远近产生缩放(远处不会变小),但会用 z 改变水平位置,从而产生“深度被编码进横向偏移”的错觉。

示意公式:

let screenX = centerX + z;
let screenY = centerY - y;

直觉是:z 越大,看起来越“向右”;y 越大,看起来越“向上”。这并不是物理正确的透视投影,但对风格化可视化非常好用、也很省事。

用四元数实现旋转

四元数是表示三维旋转的一种数学对象,优点是:

  • 能做非常平滑的连续旋转;
  • 不会像欧拉角那样容易遇到 万向节锁(gimbal lock)
  • 多轴旋转组合更稳定。

基本流程:

  1. 指定旋转轴与旋转角;
  2. 构造表示该旋转的四元数;
  3. 用四元数去旋转向量(点)。

代码如下:

function rotateQuaternion(q, axis, angle) {
let halfAngle = angle / 2;
let s = sin(halfAngle);
let rotationQuat = {
x: axis.x * s,
y: axis.y * s,
z: axis.z * s,
w: cos(halfAngle)
};
return multiplyQuaternions(q, rotationQuat);
}

function rotateVectorByQuaternion(v, q) {
let qConjugate = { x: -q.x, y: -q.y, z: -q.z, w: q.w };
let qv = { x: v.x, y: v.y, z: v.z, w: 0 };
let resultQuat = multiplyQuaternions(multiplyQuaternions(q, qv), qConjugate);
return { x: resultQuat.x, y: resultQuat.y, z: resultQuat.z };
}

绘制带遮挡与透视的卫星轨道

卫星让场景变“活”,但也把问题变复杂:轨道是三维的,行星会遮挡轨道与卫星,卫星还有速度与当前位置。

定义卫星轨道

每颗卫星一般至少有:

  • 轨道半径(orbitRadius:离行星中心的距离(实现中往往加上行星半径)。
  • 轨道四元数(orbitQuat:轨道平面在三维中的朝向(即轨道倾角/方向)。
  • 卫星角度(satelliteAngle:当前在轨道上的位置角。
  • 轨道速度(orbitalSpeed:卫星沿轨道运行的速度。

渲染轨道

轨道的画法很直白:从 0° 到 360° 采样一圈点,旋转到正确的轨道平面,再投影到 2D 连成线。但要做得像样,需要处理遮挡:背面的那段轨道应该“断掉”或变成虚线/更透明。

下面代码展示了“按可见段分段绘制”的做法:当采样点从不可见变为可见时 beginShape(),从可见变为不可见时 endShape(),从而只画出前景轨道段。

function drawOrbitAndSatellite(satellite, satellitePositions3D) {
let orbitRadius = satellite.orbitRadius + radius;
let orbitQuat = satellite.orbitQuat;

// Draw Orbit
let angleIncrement = 1; // Degree increment for plotting
let prevVisible = false;
let currentShapeStarted = false;

for (let angle = 0; angle <= 360; angle += angleIncrement) {
let angleRadian = radians(angle);
let localPoint = { x: orbitRadius * cos(angleRadian), y: 0, z: orbitRadius * sin(angleRadian) };
let rotatedPoint = rotateVectorByQuaternion(localPoint, orbitQuat);

// Occlusion Logic
let y2z2 = rotatedPoint.y * rotatedPoint.y + rotatedPoint.z * rotatedPoint.z;
let visible = y2z2 > radius * radius || rotatedPoint.x < sqrt(radius * radius - y2z2);

if (visible) {
if (!prevVisible) {
beginShape();
currentShapeStarted = true;
}
let screenX = centerX + rotatedPoint.z;
let screenY = centerY - rotatedPoint.y;
stroke(150, map(rotatedPoint.x, -orbitRadius, orbitRadius, 255, 50));
vertex(screenX, screenY);
} else {
if (prevVisible && currentShapeStarted) {
endShape();
currentShapeStarted = false;
}
}

prevVisible = visible;
}
if (currentShapeStarted) {
endShape();
}

// Update Satellite Position
satellite.satelliteAngle += satellite.orbitalSpeed / orbitRadius;
let satellitePoint = { x: orbitRadius * cos(satellite.satelliteAngle), y: 0, z: orbitRadius * sin(satellite.satelliteAngle) };
let rotatedSatellitePoint = rotateVectorByQuaternion(satellitePoint, orbitQuat);
satellitePositions3D.push(rotatedSatellitePoint);

// Project to 2D
let screenSatelliteX = centerX + rotatedSatellitePoint.z;
let screenSatelliteY = centerY - rotatedSatellitePoint.y;
let alphaValue = map(rotatedSatellitePoint.x, -orbitRadius, orbitRadius, 255, 50);

// Occlusion Check for Satellite
let satY2Z2 = rotatedSatellitePoint.y * rotatedSatellitePoint.y + rotatedSatellitePoint.z * rotatedSatellitePoint.z;
let satelliteVisible = satY2Z2 > radius * radius || rotatedSatellitePoint.x < sqrt(radius * radius - satY2Z2);

if (satelliteVisible) {
fill(0, alphaValue);
ellipse(screenSatelliteX, screenSatelliteY, 10, 10);
fill(0);
textSize(12);
text(satellite.ship ? satellite.ship.name : `卫星 ${satellites.indexOf(satellite) + 1}`, screenSatelliteX + 10, screenSatelliteY);
} else {
stroke(0);
strokeWeight(1);
drawingContext.setLineDash([5, 5]); // Dashed line for occluded satellites
fill(255);
ellipse(screenSatelliteX, screenSatelliteY, 10, 10);
drawingContext.setLineDash([]);
fill(0);
textSize(12);
text(satellite.ship ? satellite.ship.name : `卫星 ${satellites.indexOf(satellite) + 1}`, screenSatelliteX + 10, screenSatelliteY);
}

// Draw Attack Range
drawSatelliteRangeArea(rotatedSatellitePoint, satellites.indexOf(satellite));
}

遮挡与可见性

遮挡的目的只有一个:让“背面”的东西不要像贴纸一样穿过行星。这里的判定基于几何关系:当轨道点(或卫星点)与行星的球面发生遮挡关系时,认为它不可见,然后断开绘制或改用虚线表现。

一般步骤:

  1. 计算点在三维中的位置;
  2. 判断该点是否在行星背面且被球面遮住;
  3. 根据可见性选择绘制方式(实线/虚线/更透明)。

透视与深度

本实现没有“真实透视缩放”,但通过两种技巧让画面更有深度:

  • z 映射到屏幕横向偏移,让轨道看起来在三维中绕行;
  • x 映射透明度(或亮度),让前景更显眼、背景更淡,从而暗示“远近”。

计算并显示卫星攻击范围

攻击范围可视化让战斗系统更有“策略信息”:玩家一眼能看到哪些区域被覆盖。

定义攻击范围

每颗卫星有一个攻击半径(shootingRange),我们用一个围绕卫星位置的“范围圈/范围面”来表示它能影响的区域。

计算范围

计算的难点在于:攻击范围与行星球面可能相交。为了保证视觉一致性,你需要决定相交区域如何显示——比如只显示前半部分,把背面部分做虚线边界等。

涉及的数学概念:

  • 球与球的相交(或球面与球的交线):决定攻击范围与行星表面交线的位置;
  • 正交投影:把三维的交线投影到 2D;
  • 坐标系转换:把卫星位置变到“行星参考系”中计算,再转回世界系绘制。

渲染攻击范围

下面代码通过“求交圆并采样点”的方式绘制范围。它会把采样点分成前景与背景两组:前景用半透明填充,背景用虚线边界(或透明填充)表示被遮挡的部分。

function drawSatelliteRangeArea(satellitePos, satelliteIndex) {
let R1 = radius; // Planet radius
let R2 = shootingRange; // Attack range
let P1 = { x: 0, y: 0, z: 0 }; // Planet center

// Rotate satellite position back to planet's frame
let planetRotationQuat = rotateQuaternion({ x: 0, y: 0, z: 0, w: 1 }, { x: 0, y: 1, z: 0 }, rotationAngle);
let satellitePosInPlanetFrame = rotateVectorByQuaternion(satellitePos, planetRotationQuatInverse);

let P2 = satellitePosInPlanetFrame; // Satellite position in planet's frame
let d = dist3D(P1, P2); // Distance between planet center and satellite

// Check for intersection
if (d > R1 + R2 || d < abs(R1 - R2)) {
return; // No intersection
}

// Calculate intersection circle
let a = (R1 * R1 - R2 * R2 + d * d) / (2 * d);
let h = sqrt(R1 * R1 - a * a);

// Center of intersection circle
let P_c = {
x: P1.x + a * (P2.x - P1.x) / d,
y: P1.y + a * (P2.y - P1.y) / d,
z: P1.z + a * (P2.z - P1.z) / d
};

// Normal vector
let n = {
x: (P2.x - P1.x) / d,
y: (P2.y - P1.y) / d,
z: (P2.z - P1.z) / d
};

// Orthogonal basis
let arbitraryVector = { x: 1, y: 0, z: 0 };
if (abs(n.x) > 0.99) arbitraryVector = { x: 0, y: 1, z: 0 };
let u = crossProduct(n, arbitraryVector);
u = normalize(u);
let v = crossProduct(n, u);

// Sample points around the intersection circle
let numPoints = 100;
let angleStep = TWO_PI / numPoints;
let frontVertices = [];
let backVertices = [];

for (let i = 0; i <= numPoints; i++) {
let angle = i * angleStep;
let point = {
x: P_c.x + h * (cos(angle) * u.x + sin(angle) * v.x),
y: P_c.y + h * (cos(angle) * u.y + sin(angle) * v.y),
z: P_c.z + h * (cos(angle) * u.z + sin(angle) * v.z)
};

// Rotate back to world frame
let rotatedPoint = rotateVectorByQuaternion(point, planetRotationQuat);

// Project to 2D
let screenX = centerX + rotatedPoint.z;
let screenY = centerY - rotatedPoint.y;

// Determine front or back
let normal = normalize(rotatedPoint);
let viewDir = { x: -1, y: 0, z: 0 }; // Viewer direction
let dotProduct = dot(normal, viewDir);

if (dotProduct > 0) {
frontVertices.push({ x: screenX, y: screenY });
} else {
backVertices.push({ x: screenX, y: screenY });
}
}

// Draw front area
if (frontVertices.length > 2) {
fill(150, 150, 150, 100); // Semi-transparent gray
stroke(0);
beginShape();
for (let v of frontVertices) vertex(v.x, v.y);
endShape(CLOSE);
noFill();
}

// Draw back area
if (backVertices.length > 2) {
fill(255, 255, 255, 0); // Transparent
stroke(0);
drawingContext.setLineDash([5, 5]); // Dashed line
beginShape();
for (let v of backVertices) vertex(v.x, v.y);
endShape(CLOSE);
drawingContext.setLineDash([]);
noFill();
}
}

关键步骤总结:

  1. 求交判定:判断两球是否相交(不相交/包含时直接返回)。
  2. 求交圆参数:计算交圆中心 P_c 与半径 h
  3. 构造正交基:在交圆平面内构造 u, v 两个正交方向,方便绕圈采样。
  4. 采样 + 投影:把交圆上的点旋转回世界系并投影到 2D。
  5. 前后分组渲染:用法线与视线方向的点积判定点属于前景还是背面,然后用不同风格绘制。

结语

在 2D 画布里做一个“像 3D 一样”的行星战斗系统,本质上是数学与图形编程的协作:四元数让旋转顺滑、投影让空间扁平化、遮挡逻辑让画面可信、几何求交让范围可视化不穿帮。

本篇展示了如何:

  • 用四元数旋转绘制带经纬线的行星;
  • 将 3D 坐标投影到 2D 画布,制造深度错觉;
  • 绘制卫星轨道并处理遮挡;
  • 动态计算并渲染攻击范围(含与行星表面相交的情况)。

继续往前走,你可以加入真实透视投影、光照/阴影、轨道摄动、或者把这些逻辑移植到 WebGL。宇宙很大,但你现在已经有了一张能画宇宙的纸。