December 8, 2024

Space Visualization with p5.js: Building a Dynamic Planetary Battle System

Welcome to an in-depth exploration of creating a dynamic 3D planetary battle system using p5.js! Whether you’re a budding game developer or a curious enthusiast eager to understand the blend of mathematics and graphics in game design, this guide will illuminate how to craft a visually captivating planet with latitude and longitude lines, render satellites orbiting in three-dimensional space, and visualize their attack ranges—all through the magic of two-dimensional projection.

Table of Contents


Introduction

In the realm of game development, creating immersive and visually appealing environments is paramount. One fascinating aspect is the simulation of three-dimensional (3D) spaces within a two-dimensional (2D) canvas—a technique widely used in game engines and graphics programming. This blog delves into a p5.js implementation that brings to life a planet adorned with latitude and longitude lines, satellites orbiting it, and dynamic attack ranges—all rendered seamlessly on a 2D screen.

By leveraging mathematical concepts like quaternions for rotation and vector projections for depth perception, we can achieve a realistic and interactive 3D experience. Let’s embark on this journey to unravel the intricacies behind each component.


Creating the Planet with Latitude and Longitude Lines

At the heart of our visualization lies the planet, a sphere defined by its radius and mass. To enhance realism, we overlay it with latitude and longitude lines, mimicking Earth’s geographical framework.

Defining the Planet

The planet is characterized by several parameters:

Drawing the Sphere

In p5.js, a sphere can be represented by drawing multiple circles (latitude) and lines (longitude). Here’s how the code accomplishes this:

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 and Longitude Lines

Latitude lines are horizontal circles that run parallel to the equator, while longitude lines are vertical lines that converge at the poles. To simulate these:

  1. Latitude Lines: Loop through various latitudes (e.g., every 20 degrees) and draw ellipses adjusted for the planet’s rotation.
  2. Longitude Lines: Loop through various longitudes (e.g., every 20 degrees) and draw lines that bend according to the planet’s spherical shape.

The rotation of the planet is handled using quaternions (discussed later), ensuring smooth and realistic spinning.

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();
}
}

Key Concepts:


Projecting 3D Space onto a 2D Canvas

Rendering 3D objects on a 2D screen involves projecting their spatial coordinates onto a flat plane. This process is pivotal for creating the illusion of depth and perspective.

Understanding Projection

Projection transforms 3D coordinates (x, y, z) into 2D coordinates (screenX, screenY). In our implementation:

The formula used:

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

Here, the z-axis directly influences the horizontal placement (screenX), while the y-axis affects the vertical placement (screenY). This mapping ensures that objects with higher z values appear further to the right, simulating depth.

Applying Quaternions for Rotation

Quaternions are mathematical constructs that represent rotations in 3D space without suffering from gimbal lock—a common issue with Euler angles where rotational axes can become misaligned.

Quaternion Rotation Process:

  1. Define Rotation Axis and Angle: Specify the axis around which to rotate and the angle of rotation.
  2. Create Rotation Quaternion: Construct a quaternion representing this rotation.
  3. Apply Rotation: Multiply the point’s vector by the rotation quaternion to obtain the rotated vector.

Code Snippet:

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 };
}

Benefits of Using Quaternions:


Drawing Satellite Orbits with Occlusion and Perspective

Satellites orbiting the planet add layers of complexity and interactivity. Visualizing their paths involves not just drawing circles but accounting for their three-dimensional trajectories and interactions with the planet’s surface.

Defining Satellite Orbits

Each satellite has:

Rendering Orbits

To depict satellite orbits:

  1. Calculate Orbit Path: Use the orbit’s quaternion to determine its orientation in space.
  2. Draw the Path: Iterate through angles (0° to 360°) to plot the orbit’s trajectory, adjusting for occlusion.
  3. Handle Occlusion: Determine if parts of the orbit are behind the planet, rendering them invisible or with reduced opacity.

Code Snippet:

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));
}

Occlusion and Visibility

Occlusion ensures that satellites behind the planet are not fully visible, maintaining realism. The logic determines whether a satellite or a point on its orbit is obscured by the planet’s sphere based on their spatial coordinates.

Steps:

  1. Calculate Position: Determine the satellite’s position in 3D space.
  2. Determine Visibility: If a satellite’s position intersects with the planet’s sphere (i.e., it’s behind), adjust its rendering style (e.g., dashed outline).
  3. Render Accordingly: Visible satellites are drawn normally, while occluded ones have a distinct appearance.

Perspective and Depth

By mapping the z-axis to the horizontal x-axis and adjusting transparency based on the x value, satellites appear to move towards or away from the viewer, enhancing the depth perception.


Calculating and Displaying Satellite Attack Ranges

Visualizing attack ranges adds strategic depth to the battle system, allowing players to understand the influence areas of their satellites.

Defining Attack Range

Each satellite possesses an attack range (shootingRange) determining how far its influence extends. This range is visualized as a circular area around the satellite’s current position.

Calculating the Range

The attack range is calculated based on the satellite’s position relative to the planet. It involves determining whether the range intersects with the planet’s surface and rendering the overlapping areas appropriately.

Mathematical Concepts:

Rendering the Attack Range

The attack range is depicted as a shaded area around each satellite. If the range overlaps with the planet, it appears partially obscured to maintain realism.

Code Snippet:

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();
}
}

Key Steps:

  1. Intersection Calculation: Determines where the attack range intersects with the planet’s surface, ensuring that overlapping areas are handled correctly.
  2. Orthogonal Basis Construction: Constructs an orthogonal basis (u, v) for the intersection circle, facilitating the sampling of points around it.
  3. Projection and Rendering: Projects the 3D intersection points onto the 2D canvas, distinguishing between front and back areas to handle visibility and shading.

Conclusion

Creating a 3D planetary battle system within a 2D canvas using p5.js is a harmonious blend of mathematics and graphics programming. By leveraging concepts like quaternions for smooth rotations, vector projections for depth perception, and geometric intersections for realistic attack range visualization, developers can craft immersive and interactive environments.

This exploration showcased how to:

For beginners, integrating these concepts can seem daunting, but breaking them down into manageable components—as demonstrated—makes the process approachable. As you continue to experiment and refine these techniques, you’ll unlock the potential to create even more sophisticated and engaging visualizations in your projects.

Happy coding, and may your virtual universes shine brilliantly!

About this Post

This post is written by FFFeiya, licensed under CC BY-NC 4.0.

#p5.js#Space#JavaScript