Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- A 2D Barnes-Hut N-Body Gravitational Simulation in PygameI. Introduction to 2D N-Body Gravitational SimulationA. The N-Body Problem: A Brief OverviewThe N-body problem concerns the prediction of the individual motions of a group of objects (or "bodies") that interact with each other gravitationally. This problem is a cornerstone of classical mechanics and astrophysics. N-body simulations are computational models designed to approximate solutions to this problem, tracking the trajectories of particles under the influence of physical forces such as gravity.1 These simulations are indispensable tools in astrophysics, enabling the study of diverse phenomena ranging from the intricate dance of planets within a solar system to the formation and evolution of vast cosmic structures like galaxy filaments and dark matter halos.1The inherent complexity of the N-body problem stems from the fact that every body in the system exerts a gravitational force on every other body. For a system containing N bodies, a direct calculation of all pairwise forces involves computing N(N-1)/2 interactions.4 The computational effort required for this direct summation scales quadratically with the number of bodies, denoted as O(N2).5 While feasible for small N, this quadratic scaling rapidly renders the direct approach computationally intractable for systems involving thousands, millions, or even billions of particles, which are common in astrophysical research.1 This computational bottleneck has been a primary driver for the development of more efficient, approximate algorithms.B. The Barnes-Hut Algorithm: Efficiently Approximating GravityThe Barnes-Hut algorithm, introduced by Josh Barnes and Piet Hut in 1986, is a highly effective approximation method for N-body simulations that significantly reduces computational cost.5 It achieves this by grouping distant particles and treating their collective gravitational influence as if it emanated from a single, more massive pseudo-particle located at their common center of mass. This approximation dramatically cuts down the number of force calculations needed, reducing the overall complexity from O(N2) to O(NlogN) for typical particle distributions.4At the heart of the Barnes-Hut algorithm in two dimensions is a data structure called a quadtree. A quadtree recursively subdivides the 2D simulation space into four equal quadrants. Each node in the tree represents a specific region of space. If a region contains more than one particle (or exceeds a predefined capacity), it is further subdivided into child quadrants. This process continues until each "leaf" node of the tree contains at most one particle, or a minimum subdivision level is reached. This hierarchical structure allows the algorithm to efficiently identify groups of particles that are "sufficiently far away" from a target particle to be approximated. The criterion for "sufficiently far away" is controlled by a parameter commonly denoted as θ (theta). The ratio of a quadrant's width (s) to the distance (d) from the target particle to the quadrant's center of mass is compared to θ. If s/d<θ, the entire group of particles within that quadrant is treated as a single point mass for force calculation purposes.4 A smaller θ value results in higher accuracy but more computations (as fewer approximations are made), while a larger θ speeds up the simulation at the cost of reduced accuracy. This trade-off is a fundamental characteristic of the Barnes-Hut method.C. Simulation Goals: 2D Gravity with Linear Falloff in PygameThis report details the development of a 2D N-body gravitational simulation that employs the Barnes-Hut algorithm. A key feature of this simulation is its use of a non-standard gravitational force law: a linear distance falloff, where the force is proportional to 1/r, rather than the conventional 1/r2 inverse-square law typical of 3D gravity.10 The simulation is implemented in Python using the Pygame library for visualization and user interaction, providing an accessible platform for observing and experimenting with N-body dynamics under this alternative gravitational model. The objective is to produce a single, runnable script that encapsulates the physics engine, rendering logic, and user-tweakable parameters.II. Core Physical and Algorithmic ConceptsA. The Nature of 2D Gravity: Linear Distance Falloff (F∝1/r)In a hypothetical two-dimensional universe, the behavior of gravity differs significantly from its three-dimensional counterpart. Instead of the familiar inverse-square law (F∝1/r2), the gravitational force between two point masses in 2D is inversely proportional to the linear distance (r) separating them.10 The formula for this force can be expressed as:F=rG2Dm1m2where m1 and m2 are the masses of the two bodies, r is the distance between them, and G2D is the gravitational constant adapted for a 2D system (its value and units would differ from the 3D gravitational constant).This linear falloff can be understood by considering how gravitational field lines would propagate. In 3D, field lines from a point mass spread out over the surface of an expanding sphere. Since the surface area of a sphere is proportional to r2, the density of field lines (representing field strength) decreases as 1/r2. In 2D, however, field lines would spread out along the circumference of an expanding circle. The circumference of a circle is proportional to r, so the field line density, and thus the force, decreases as 1/r.10 This fundamental difference in the force law has profound implications for orbital mechanics within the simulation.One of the most striking consequences pertains to the velocity required for a stable circular orbit. In 3D gravity (1/r2 force), the orbital velocity is v=GM/R, where M is the central mass and R is the orbital radius.11 However, for a 1/r force law, the situation changes. By equating the gravitational force (Fg=G2DMm/R) with the centripetal force (Fc=mv2/R) required for circular motion, we get:$RG2DMm=Rmv2$Multiplying both sides by R and dividing by m (the mass of the orbiting body) yields:$G2DM=v2Thus,themagnitudeofthecircularorbitalvelocitybecomes:v=G2DM$Remarkably, for a 1/r gravitational force, the circular orbital velocity is independent of the orbital radius R and the mass of the orbiting body m. This is a significant deviation from 3D orbital mechanics and will be crucial when initializing the velocities of particles in the simulation.B. The Barnes-Hut Algorithm in Two DimensionsThe implementation of the Barnes-Hut algorithm for this 2D simulation relies on several key components: the quadtree data structure, methods for building this tree by inserting particles, calculation of mass properties for tree nodes, and a recursive force calculation procedure.1. Quadtree Data StructureA quadtree is a tree data structure in which each internal node has exactly four children, corresponding to the four quadrants: Northwest (NW), Northeast (NE), Southwest (SW), and Southeast (SE).8 It is used to recursively partition the 2D simulation space. Each node in the quadtree represents a rectangular region of this space.The attributes of a QuadtreeNode in this simulation will include:
- boundary: A pygame.Rect object defining the spatial extent of the node.
- particles_in_node: A list to store Body objects. For a leaf node, this list will contain the single particle within its boundary (or be empty). For an internal node that has just been subdivided, this list is temporarily used before particles are passed to children.
- children: A list of four elements, each potentially holding a child QuadtreeNode corresponding to the NW, NE, SW, and SE quadrants. Initially None for leaf nodes.
- total_mass: The sum of the masses of all particles contained within this node and all its descendant nodes.
- center_of_mass: A pygame.Vector2 representing the mass-weighted average position of all particles within this node and its descendants.
- is_leaf: A boolean flag indicating whether the node is a leaf (has no children) or an internal node.
- depth: The depth of the node in the tree, with the root node at depth 0.
- max_particles_per_node: A constant (typically 1 for Barnes-Hut) defining how many particles a leaf node can hold before it must subdivide.
- max_depth: A constant to limit the maximum recursion depth of the tree, preventing infinite subdivision in dense regions.
- The initial boundary for the root node of the quadtree must be large enough to encompass all particles in the simulation. This can be determined dynamically at the beginning of each tree construction phase by finding the minimum and maximum x and y coordinates among all particles.14 Child node boundaries are then created by halving the parent's boundary in each dimension.2. Building the Quadtree: Particle Insertion and SubdivisionThe quadtree is typically rebuilt from scratch at each physics timestep because particles move.5 The process begins by creating a root node whose boundary encompasses all particles. Then, each particle is inserted into the root node one by one.The insertion logic for a particle into a given QuadtreeNode is recursive:
- If the particle does not fall within the node.boundary, the insertion for this branch terminates.
- If the node is a leaf (node.is_leaf is true):
- Add the particle to node.particles_in_node.
- If the number of particles in node.particles_in_node now exceeds max_particles_per_node (e.g., > 1) AND the node.depth is less than max_depth:
- The node must subdivide. Call a subdivide() method.
- The subdivide() method creates four new child QuadtreeNode objects, each representing one of the four quadrants of the current node's boundary.
- The current node becomes an internal node (is_leaf = False).
- All particles previously in node.particles_in_node (including the newly added one) are then re-inserted into the current node, which will now route them to the appropriate child nodes. node.particles_in_node is then cleared.
- If the node is an internal node (node.is_leaf is false):
- Determine which of its four child quadrants the particle belongs to using a helper method (e.g., get_quadrant_for_particle(particle)).
- Recursively call the insertion method on that child node.
- The get_quadrant_for_particle method compares the particle's position with the center of the current node's boundary to decide if it falls into the NW, NE, SW, or SE child quadrant.3. Calculating Center of Mass (CoM) and Total Mass for NodesAfter all particles have been inserted into the quadtree for a given timestep, or concurrently during the insertion and subdivision process, the total_mass and center_of_mass for each node must be computed.8 This is also a recursive process, typically performed bottom-up or as part of the tree construction:
- If a node is a leaf:
- If it contains a particle, its total_mass is the particle's mass, and its center_of_mass is the particle's position.
- If it's an empty leaf, its total_mass is 0, and its center_of_mass can be undefined or set to its geometric center.
- If a node is an internal node:
- Its total_mass is the sum of the total_mass of its four children (if a child is None or empty, its contribution is zero).
- Its center_of_mass is the weighted average of the center_of_mass of its children, where each child's CoM is weighted by its total_mass. The formula for combining two CoMs (CoM1,CoM2) with masses (m1,m2) to find the new CoM is:
- CoMnew=m1+m2CoM1⋅m1+CoM2⋅m2
- This is applied iteratively for all children.
- 4. Recursive Force Calculation: Applying the θ (theta) CriterionOnce the quadtree is built and mass distributions are computed, the net gravitational force on each particle is calculated by traversing the tree.4 For a given particle P_target, the force calculation starts from the root of the quadtree and proceeds recursively:Let current_node be the node being examined:
- If current_node is None or contains no mass, it exerts no force.
- If current_node is a leaf node:
- If it contains a particle P_source that is not P_target itself, calculate the direct gravitational force between P_target and P_source using the 1/r force law (with softening, discussed later). Add this force to the net force on P_target.
- If current_node is an internal node:
- Calculate the distance d from P_target to current_node.center_of_mass.
- Let s be the width of current_node.boundary.
- If s/d<θ (the Barnes-Hut criterion):
- The current_node is considered "sufficiently far away." Approximate the gravitational force it exerts by treating it as a single point mass equal to current_node.total_mass located at current_node.center_of_mass. Calculate this force on P_target (with softening) and add it to the net force.
- Else (the node is too close for approximation):
- Recursively call the force calculation procedure for each of current_node.children that are not None. Sum the forces returned from these recursive calls.
- The θ parameter is crucial: θ=0 would mean no approximation, and the algorithm would behave like a direct N² summation (by always recursing to leaves). A typical value for θ is around 0.5 to 0.7, providing a good balance between speed and accuracy.4C. Leapfrog Integration for Particle MotionTo update the positions and velocities of the particles over time, this simulation employs the Leapfrog integration method, specifically the Kick-Drift-Kick (KDK) variant. Leapfrog integration is a numerical technique well-suited for simulating Hamiltonian systems, such as those governed by gravitational forces, because it is symplectic. This means it tends to conserve energy-like quantities over long simulation times, preventing unphysical drift that can occur with simpler methods like Euler integration or even some Runge-Kutta schemes.16 It is also time-reversible and second-order accurate.16The KDK Leapfrog scheme updates positions and velocities in three steps for a time interval Δt:
- Kick (Velocity Half-Step): Update velocities by half a timestep using the acceleration at the current time t.
- v(t+Δt/2)=v(t)+a(x(t))2Δt
- Drift (Position Full-Step): Update positions by a full timestep using the new half-step velocities.
- x(t+Δt)=x(t)+v(t+Δt/2)Δt
- Kick (Velocity Half-Step): Update velocities by another half timestep using the acceleration calculated at the new positions x(t+Δt).
- v(t+Δt)=v(t+Δt/2)+a(x(t+Δt))2Δt
- Here, x is position, v is velocity, and a is acceleration (calculated as Force/mass). The simulation uses a fixed physics timestep (Δt), which simplifies the KDK implementation while still retaining its benefits of stability and good long-term energy behavior.16
- D. Gravitational SofteningIn N-body simulations, when two particles approach each other very closely, the gravitational force between them can become extremely large, especially with a 1/r or 1/r2 force law, leading to a singularity (F→∞ as r→0). This can cause numerical instability, leading to particles being ejected at unrealistically high velocities or other integration errors.1 Gravitational softening is a technique used to prevent these singularities by modifying the force law at very short distances.Instead of treating particles as true point masses, softening effectively gives them a small, finite size or smooths their mass distribution.19 For a standard 1/r2 gravitational force, a common softening method (Plummer softening) involves replacing r2 in the denominator of the force equation with (r2+ϵ2), where ϵ is the "softening length" or "softening factor".1 The potential becomes Φ=−GM/r2+ϵ2.For the 1/r force law used in this 2D simulation (F=Gm1m2/r), a similar approach is adopted. The distance r in the denominator is replaced by an effective distance that does not go to zero. A common way to implement this is to modify the force magnitude calculation to:F=r2+ϵ2G2Dm1m2This ensures that as r→0, the force approaches G2Dm1m2/ϵ, preventing a division by zero and limiting the maximum force. The term ϵ2 is used here (rather than just ϵ) to maintain consistency with the common Plummer-like softening form and its desirable mathematical properties, ensuring the force smoothly transitions from the softened regime to the 1/r regime as r increases beyond ϵ. This can be interpreted as the particles having a smoothed density profile rather than being true point masses, a more physically plausible model at very small scales within the simulation context.19III. Simulation Architecture with PygameThe simulation is structured to run within the Pygame framework, which handles windowing, event processing, and rendering. Key architectural decisions involve managing simulation constants, implementing a game loop that decouples physics from rendering, and correctly visualizing particle motion through state interpolation.A. Managing Simulation ConstantsFor ease of tweaking and experimentation, simulation parameters are centralized. This follows best practices for code maintainability and readability.20 Constants are defined with uppercase names. While a separate config.py file is often recommended for larger projects 20, for a single-script deliverable, these constants will be grouped at the beginning of the script or within a dedicated configuration class.Table 1: Key Simulation ConstantsConstant NameDescriptionDefault Value (Example)Unit (if applicable)SCREEN_WIDTHWidth of the simulation window.1200pixelsSCREEN_HEIGHTHeight of the simulation window.900pixelsCAPTIONTitle of the simulation window."2D Barnes-Hut N-Body"N/AFPS_RENDERTarget frames per second for rendering.60HzPHYSICS_FPSTarget updates per second for physics calculations.100HzPHYSICS_DTFixed timestep for physics integration ($1.0 / \text{PHYSICS_FPS}$).0.01sTIME_SCALEMultiplier for simulation speed (e.g., 2.0 for 2x speed).1.0dimensionlessG_2DGravitational constant for the 2D simulation.500.0(arbitrary units)CENTRAL_BODY_MASSMass of the large central body.10000.0mass unitsNUM_SMALL_BODIESNumber of smaller orbiting bodies to generate.200countSMALL_BODY_MASS_MINMinimum mass for randomly generated small bodies.1.0mass unitsSMALL_BODY_MASS_MAXMaximum mass for randomly generated small bodies.10.0mass unitsCLOUD_RADIUS_MINMinimum initial orbital radius for the cloud of small bodies.150.0length unitsCLOUD_RADIUS_MAXMaximum initial orbital radius for the cloud of small bodies.400.0length unitsBARNES_HUT_THETAApproximation parameter for Barnes-Hut (s/d<θ).0.7dimensionlessSOFTENING_FACTOR_EPSSoftening length ϵ for gravitational force calculation.5.0length unitsGRAVITY_DAMPING_FACTORMultiplier to scale the gravitational force (e.g., < 1 to weaken).1.0dimensionlessMAX_PARTICLES_PER_NODEMax particles in a quadtree leaf before subdivision.1countMAX_TREE_DEPTHMaximum depth for quadtree recursion.20countBODY_COLOR_CENTRALRGB color for the central body.(255, 255, 0)N/ABODY_COLOR_SMALLBase RGB color for small bodies (can be varied).(200, 200, 255)N/ABG_COLORBackground RGB color of the simulation window.(10, 10, 30)N/AQUADTREE_DRAW_COLORRGB color for drawing quadtree boundaries (for debugging).(50, 50, 50)N/ADRAW_QUADTREEBoolean to toggle quadtree visualization.FalseN/AThis structured approach allows users to easily modify the simulation's fundamental parameters, facilitating understanding and exploration of different N-body scenarios.B. The Game Loop: Decoupling Physics and Rendering (GafferOnGames Style)To ensure consistent physics behavior independent of rendering speed, the simulation employs a game loop structure popularized by Glenn Fiedler ("GafferOnGames").22 This loop decouples physics updates, which occur at a fixed timestep, from rendering, which happens as fast as possible up to a target frame rate.The core mechanism involves an accumulator:
- At the beginning of each loop iteration, calculate the real_dt – the actual time elapsed since the last frame.
- This real_dt is multiplied by TIME_SCALE and added to an accumulator variable. TIME_SCALE allows the simulation to run faster or slower than real-time; a value greater than 1 speeds up the simulation's perceived passage of time by causing more physics steps to be processed per unit of real time.
- A while loop then checks if accumulator >= PHYSICS_DT (the fixed timestep for physics).
- If true, the previous state of all dynamic bodies (e.g., position) is stored for later interpolation.
- A single physics update step is performed using the constant PHYSICS_DT. This includes rebuilding the quadtree, calculating forces, and updating positions and velocities via Leapfrog integration.
- PHYSICS_DT is subtracted from accumulator.
- This inner loop continues until accumulator < PHYSICS_DT, ensuring that physics catches up if rendering lags, or waits if rendering is faster.
- After the physics updates, the rendering step occurs. An interpolation factor, alpha = accumulator / PHYSICS_DT, is calculated. This alpha value (between 0.0 and 1.0) represents how far the current frame is into the next potential physics step.
- The game state (specifically, particle positions) is rendered using this alpha value to interpolate between the previous_pos and current_pos of each body. This provides smooth visual motion even if the rendering framerate and physics update rate do not align perfectly.
- This architecture is crucial for achieving both physically stable and visually smooth simulations.23
- C. Visualizing Motion: State InterpolationWhen the physics update rate (e.g., 100 Hz) differs from the rendering rate (e.g., 60 Hz), simply drawing the particles at their latest physics state can lead to visual stutter or judder. State interpolation mitigates this by displaying particles at positions that are smoothly transitioned between their states at the beginning and end of the most recent (or currently progressing) physics interval.22As described above, the interpolation factor alpha is calculated as accumulator / PHYSICS_DT. The rendered position (render_pos) of a particle is then determined by:prender=pcurrent⋅α+pprevious⋅(1.0−α)where pcurrent is the particle's position after the last completed physics step, and pprevious is its position before that step. This linear interpolation ensures that particles appear to move smoothly across the screen, effectively bridging the temporal gaps between discrete physics calculations.IV. Implementation DetailsThe simulation is implemented in Python using Pygame. The core components include classes for celestial bodies and quadtree nodes, functions for force calculation and physics updates, scene initialization logic, and the main rendering loop.A. Body ClassThis class encapsulates the properties and behaviors of individual celestial bodies.
- Attributes:
- mass: The mass of the body.
- pos: A pygame.math.Vector2 object representing the current position (x,y).
- vel: A pygame.math.Vector2 object representing the current velocity (vx,vy).
- prev_pos: A pygame.math.Vector2 object storing the position from the previous physics step, used for rendering interpolation.
- color: An RGB tuple for drawing the body.
- radius: The radius of the circle used to represent the body, often scaled with mass.
- is_stationary: A boolean flag, true if the body (e.g., the central star) should not move.
- Methods:
- __init__(mass, pos, vel, color, radius, is_stationary=False): Constructor.
- draw_interpolated(surface, alpha): Draws the body on the given Pygame surface. If not stationary, it calculates the interpolated position using alpha, self.pos, and self.prev_pos. Otherwise, it draws at self.pos.
- The physics update logic (KDK steps) will be handled externally in the main physics update function but will operate on the pos and vel attributes of Body instances.
- B. QuadtreeNode ClassThis class implements the nodes of the Barnes-Hut quadtree.
- Attributes:
- boundary: A pygame.Rect object defining the spatial region this node covers.
- particles_in_node: A list to hold Body objects that fall within this node if it's a leaf and has not yet subdivided, or if it has reached maximum depth or minimum particle capacity for subdivision.
- children: A list of four elements, [None, None, None, None], which will hold child QuadtreeNode objects for the NW, NE, SW, SE quadrants respectively, once subdivided.
- total_mass: The sum of masses of all particles within this node and its descendants. Initialized to 0.
- center_of_mass: A pygame.math.Vector2 for the mass-weighted average position. Initialized to pygame.math.Vector2(0, 0).
- is_leaf: Boolean, initially True. Becomes False after subdivision.
- depth: Integer representing the node's depth in the tree.
- MAX_PARTICLES_PER_NODE: Class or instance constant (e.g., 1).
- MAX_DEPTH: Class or instance constant (e.g., 20) to prevent excessive recursion.
- Methods:
- __init__(self, boundary, depth, MAX_PARTICLES_PER_NODE, MAX_DEPTH): Constructor.
- get_quadrant_for_particle(self, particle): Takes a Body object. Returns an integer (0-3) indicating which child quadrant (NW, NE, SW, SE) the particle's position falls into relative to the center of self.boundary. Returns -1 if outside.
- subdivide(self): Sets self.is_leaf = False. Creates four new child QuadtreeNode objects. The boundaries for these children are calculated by halving self.boundary.width and self.boundary.height and offsetting them appropriately (e.g., NW child uses top-left quarter of parent boundary). Assigns these to self.children.
- insert_particle(self, particle):
- If particle.pos is not within self.boundary (using self.boundary.collidepoint(particle.pos)), return False.
- If self.is_leaf is True:
- a. Add particle to self.particles_in_node.
- b. If len(self.particles_in_node) > self.MAX_PARTICLES_PER_NODE and self.depth < self.MAX_DEPTH:
- i. Call self.subdivide().
- ii. For each p_moved in self.particles_in_node:
- quadrant_idx = self.get_quadrant_for_particle(p_moved)
- if quadrant_idx!= -1:
- self.children[quadrant_idx].insert_particle(p_moved)
- iii. Clear self.particles_in_node.
- c. Return True.
- Else (node is internal):
- a. quadrant_idx = self.get_quadrant_for_particle(particle)
- b. If quadrant_idx!= -1:
- return self.children[quadrant_idx].insert_particle(particle)
- c. Return False (should not happen if initial check passed and get_quadrant_for_particle is correct).
- calculate_mass_distribution(self):
- If self.is_leaf:
- a. If self.particles_in_node is not empty:
- self.total_mass = sum(p.mass for p in self.particles_in_node)
- If self.total_mass > 0:
- com_x = sum(p.pos.x * p.mass for p in self.particles_in_node) / self.total_mass
- com_y = sum(p.pos.y * p.mass for p in self.particles_in_node) / self.total_mass
- self.center_of_mass = pygame.math.Vector2(com_x, com_y)
- Else:
- self.center_of_mass = pygame.math.Vector2(self.boundary.center)
- Else:
- self.total_mass = 0
- self.center_of_mass = pygame.math.Vector2(self.boundary.center)
- Else (internal node):
- a. self.total_mass = 0
- b. weighted_com_x = 0, weighted_com_y = 0
- c. For each child in self.children:
- If child is not None:
- child.calculate_mass_distribution() (recursive call)
- self.total_mass += child.total_mass
- weighted_com_x += child.center_of_mass.x * child.total_mass
- weighted_com_y += child.center_of_mass.y * child.total_mass
- d. If self.total_mass > 0:
- self.center_of_mass = pygame.math.Vector2(weighted_com_x / self.total_mass, weighted_com_y / self.total_mass)
- Else:
- self.center_of_mass = pygame.math.Vector2(self.boundary.center)
- The logic for insert_particle and calculate_mass_distribution is based on principles from various sources like.4C. Force Calculation Function (calculate_force_on_body)This function recursively traverses the quadtree to compute the net gravitational force on a specific body.
- Parameters: body_to_calc_for (Body object), current_node (QuadtreeNode object), theta (Barnes-Hut threshold), G_2D (gravitational constant), softening_epsilon (softening length), gravity_damping_factor.
- Logic:
- Initialize net_force = pygame.math.Vector2(0, 0).
- If current_node is None or current_node.total_mass == 0, return net_force.
- If current_node.is_leaf:
- For each particle_source in current_node.particles_in_node:
- If particle_source is the same as body_to_calc_for, continue (a body does not exert force on itself).
- direction_vec = particle_source.pos - body_to_calc_for.pos
- dist_sq = direction_vec.length_squared()
- If dist_sq == 0, continue (avoid singularity if somehow at same exact spot, though softening handles this).
- effective_dist_denominator_sq = dist_sq + softening_epsilon**2
- force_magnitude = (G_2D * body_to_calc_for.mass * particle_source.mass) / math.sqrt(effective_dist_denominator_sq) (for 1/r force with reff=r2+ϵ2)
- force_vec = direction_vec.normalize() * force_magnitude
- net_force += force_vec
- Else (internal node):
- direction_vec_node = current_node.center_of_mass - body_to_calc_for.pos
- dist_sq_node = direction_vec_node.length_squared()
- If dist_sq_node == 0 and current_node.total_mass > 0: # Body is at the CoM of an internal node
- This case is tricky. It might indicate the body is inside the cluster.
- Forcing recursion (opening the node) is safer than applying a potentially huge force.
- Or, if the node is very small, it might be treated as a leaf.
- For simplicity here, we will force recursion if dist_sq_node is zero for an internal node.
- This implies s/d would be infinite, so the node must be opened.
- pass # Fall through to recursive calls
- s = current_node.boundary.width (assuming square cells for simplicity in s/d)
- If dist_sq_node > 0 and (s * s) / dist_sq_node < theta**2 (using squared values to avoid sqrt):
- effective_dist_denominator_sq_node = dist_sq_node + softening_epsilon**2
- force_magnitude_node = (G_2D * body_to_calc_for.mass * current_node.total_mass) / math.sqrt(effective_dist_denominator_sq_node)
- force_vec_node = direction_vec_node.normalize() * force_magnitude_node
- net_force += force_vec_node
- Else (node is too close, or body is at CoM of node):
- For each child_node in current_node.children:
- net_force += calculate_force_on_body(body_to_calc_for, child_node, theta, G_2D, softening_epsilon, gravity_damping_factor)
- Return net_force * gravity_damping_factor. The damping factor modifies the strength of gravity globally.
- This logic draws from.4 The application of the gravity_damping_factor addresses the user's request to include such a term.D. Physics Update Step (perform_physics_update)This function orchestrates a single physics step for all bodies.
- Parameters: list_of_bodies, root_node_bounds_func (a function that returns the overall bounding box for the quadtree), dt (fixed timestep), G_2D, BARNES_HUT_THETA, SOFTENING_FACTOR_EPS, GRAVITY_DAMPING_FACTOR, MAX_PARTICLES_PER_NODE, MAX_TREE_DEPTH.
- Logic:
- Create a list of dynamic bodies (those not marked is_stationary).
- First KDK Kick & Drift:
- For each body in dynamic_bodies:
- If body.mass == 0, continue.
- force_old = calculate_force_on_body(body, current_root_node, BARNES_HUT_THETA, G_2D, SOFTENING_FACTOR_EPS, GRAVITY_DAMPING_FACTOR)
- accel_old = force_old / body.mass
- body.vel += accel_old * (dt / 2.0)
- body.prev_pos = body.pos.copy() (Store current position as previous for interpolation)
- body.pos += body.vel * dt
- Rebuild Quadtree:
- Determine overall_bounds by calling root_node_bounds_func(list_of_bodies).
- new_root_node = QuadtreeNode(overall_bounds, 0, MAX_PARTICLES_PER_NODE, MAX_TREE_DEPTH)
- For body in list_of_bodies:
- new_root_node.insert_particle(body)
- new_root_node.calculate_mass_distribution()
- Update current_root_node to new_root_node.
- Second KDK Kick:
- For each body in dynamic_bodies:
- If body.mass == 0, continue.
- force_new = calculate_force_on_body(body, current_root_node, BARNES_HUT_THETA, G_2D, SOFTENING_FACTOR_EPS, GRAVITY_DAMPING_FACTOR)
- accel_new = force_new / body.mass
- body.vel += accel_new * (dt / 2.0)
- Return the updated current_root_node.
- The quadtree is rebuilt in each physics step as particle positions change significantly.5E. Scene InitializationThis sets up the initial state of the simulation.
- Central Body:
- A Body object is created with CENTRAL_BODY_MASS, positioned at the screen center (SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2).
- Initial velocity is (0, 0).
- Set is_stationary = True.
- Cloud of Orbiting Bodies:
- For i from 0 to NUM_SMALL_BODIES - 1:
- Generate a random mass m_small between SMALL_BODY_MASS_MIN and SMALL_BODY_MASS_MAX.
- Generate a random orbital radius r_orbit between CLOUD_RADIUS_MIN and CLOUD_RADIUS_MAX.
- Generate a random angle angle_rad from 0 to 2π.
- Calculate initial position (x,y) relative to the central body:
- pos_x = central_body.pos.x + r_orbit * math.cos(angle_rad)
- pos_y = central_body.pos.y + r_orbit * math.sin(angle_rad)
- Calculate orbital velocity magnitude for the 1/r force law:
- v_mag = math.sqrt(G_2D * CENTRAL_BODY_MASS)
- (Note: This velocity is independent of r_orbit and m_small as derived earlier).
- Calculate initial velocity components for circular orbit (e.g., counter-clockwise):
- vel_x = -v_mag * math.sin(angle_rad)
- vel_y = v_mag * math.cos(angle_rad)
- To ensure all rotate in the same direction, the sign convention for vel_x and vel_y (or the direction of v_mag) must be consistent.
- Create a new Body object with these properties and add it to the list of bodies. This approach is guided by general particle cloud initialization principles 1 and specific orbital velocity calculations 11, adapted for the 1/r force.
- F. Rendering LogicThis function handles drawing the simulation state to the screen.
- Parameters: surface (Pygame display surface), list_of_bodies, alpha (interpolation factor), quadtree_root (optional, for drawing the tree), draw_quadtree_flag.
- Logic:
- Fill the surface with BG_COLOR.
- If draw_quadtree_flag is True and quadtree_root is not None:
- Implement a recursive function draw_quadtree_node(node, surface):
- If node is None, return.
- Draw node.boundary using pygame.draw.rect(surface, QUADTREE_DRAW_COLOR, node.boundary, 1).
- If not node.is_leaf, recursively call on node.children.
- Call draw_quadtree_node(quadtree_root, surface).
- For each body in list_of_bodies:
- Call body.draw_interpolated(surface, alpha). The radius for drawing can be made proportional to body.mass**(1/3) or body.mass**(1/2) for visual representation of mass.
- (Optional) Display simulation information (elapsed time, number of bodies, etc.) using pygame.font.Font, render, and blit.
- Call pygame.display.flip() to update the entire screen.
- Basic Pygame drawing functions are used.26V. The Complete Simulation ScriptThe following Python script integrates all the concepts and implementation details discussed. It provides a runnable 2D Barnes-Hut N-body simulation in Pygame.Pythonimport pygame
- import math
- import random
- # --- Simulation Constants ---
- class Config:
- SCREEN_WIDTH = 1200
- SCREEN_HEIGHT = 900
- CAPTION = "2D Barnes-Hut N-Body Simulation (1/r Gravity)"
- FPS_RENDER = 60
- PHYSICS_FPS = 100 # Target updates per second for physics
- PHYSICS_DT = 1.0 / PHYSICS_FPS # Fixed timestep for physics
- TIME_SCALE = 1.0 # Multiplier for simulation speed
- G_2D = 500.0 # Gravitational constant for 2D (1/r force)
- CENTRAL_BODY_MASS = 10000.0
- NUM_SMALL_BODIES = 200
- SMALL_BODY_MASS_MIN = 5.0
- SMALL_BODY_MASS_MAX = 20.0
- CLOUD_RADIUS_MIN = 200.0
- CLOUD_RADIUS_MAX = 400.0
- BARNES_HUT_THETA = 0.7 # Approximation parameter (s/d < theta)
- SOFTENING_FACTOR_EPS = 10.0 # Softening length epsilon
- GRAVITY_DAMPING_FACTOR = 1.0 # Multiplier for gravitational force
- MAX_PARTICLES_PER_NODE = 1 # Max particles in a quadtree leaf
- MAX_TREE_DEPTH = 20 # Max depth for quadtree recursion
- BODY_COLOR_CENTRAL = (255, 255, 0) # Yellow
- BODY_COLOR_SMALL_BASE = (200, 200, 255) # Light blue
- BG_COLOR = (10, 10, 30) # Dark blue
- QUADTREE_DRAW_COLOR = (50, 50, 50) # Dark grey for quadtree lines
- INFO_TEXT_COLOR = (200, 200, 200)
- DRAW_QUADTREE = False # Toggle quadtree visualization
- DRAW_INFO = True
- # --- Body Class ---
- class Body:
- def __init__(self, mass, pos_x, pos_y, vel_x, vel_y, color, is_stationary=False):
- self.mass = mass
- self.pos = pygame.math.Vector2(pos_x, pos_y)
- self.vel = pygame.math.Vector2(vel_x, vel_y)
- self.prev_pos = pygame.math.Vector2(pos_x, pos_y) # For interpolation
- self.color = color
- self.is_stationary = is_stationary
- # Visual radius roughly proportional to cube root of mass (for volume)
- if self.mass > 0:
- self.radius = max(2, int(math.pow(self.mass / 10.0, 1.0/2.2)))
- else:
- self.radius = 1
- def draw_interpolated(self, surface, alpha):
- if self.is_stationary or self.prev_pos == self.pos : # or alpha is problematic for stationary
- render_pos = self.pos
- else:
- render_pos = self.pos * alpha + self.prev_pos * (1.0 - alpha)
- pygame.draw.circle(surface, self.color, (int(render_pos.x), int(render_pos.y)), self.radius)
- # --- QuadtreeNode Class ---
- class QuadtreeNode:
- def __init__(self, boundary_rect, depth):
- self.boundary = boundary_rect
- self.depth = depth
- self.particles_in_node =
- self.children = [None, None, None, None] # NW, NE, SW, SE
- self.total_mass = 0.0
- self.center_of_mass = pygame.math.Vector2(boundary_rect.centerx, boundary_rect.centery)
- self.is_leaf = True
- def get_quadrant_for_particle(self, particle_pos):
- mid_x = self.boundary.centerx
- mid_y = self.boundary.centery
- if particle_pos.x < mid_x: # West
- if particle_pos.y < mid_y: # North-West
- return 0
- else: # South-West
- return 2
- else: # East
- if particle_pos.y < mid_y: # North-East
- return 1
- else: # South-East
- return 3
- def subdivide(self):
- self.is_leaf = False
- cx, cy = self.boundary.centerx, self.boundary.centery
- hw, hh = self.boundary.width / 2, self.boundary.height / 2
- # NW, NE, SW, SE
- self.children = QuadtreeNode(pygame.Rect(self.boundary.left, self.boundary.top, hw, hh), self.depth + 1)
- self.children = QuadtreeNode(pygame.Rect(cx, self.boundary.top, hw, hh), self.depth + 1)
- self.children = QuadtreeNode(pygame.Rect(self.boundary.left, cy, hw, hh), self.depth + 1)
- self.children = QuadtreeNode(pygame.Rect(cx, cy, hw, hh), self.depth + 1)
- def insert_particle(self, particle):
- if not self.boundary.collidepoint(particle.pos):
- return False
- if self.is_leaf:
- self.particles_in_node.append(particle)
- if len(self.particles_in_node) > Config.MAX_PARTICLES_PER_NODE and self.depth < Config.MAX_TREE_DEPTH:
- self.subdivide()
- # Move existing particles in this leaf to children
- for p_moved in self.particles_in_node:
- quadrant_idx = self.get_quadrant_for_particle(p_moved.pos)
- if self.children[quadrant_idx] is not None:
- self.children[quadrant_idx].insert_particle(p_moved)
- self.particles_in_node = # Clear particles from this now internal node
- return True
- else: # Internal node
- quadrant_idx = self.get_quadrant_for_particle(particle.pos)
- if self.children[quadrant_idx] is not None:
- return self.children[quadrant_idx].insert_particle(particle)
- return False # Should not happen if children are initialized
- def calculate_mass_distribution(self):
- if self.is_leaf:
- if self.particles_in_node:
- self.total_mass = sum(p.mass for p in self.particles_in_node)
- if self.total_mass > 0:
- com_x = sum(p.pos.x * p.mass for p in self.particles_in_node) / self.total_mass
- com_y = sum(p.pos.y * p.mass for p in self.particles_in_node) / self.total_mass
- self.center_of_mass = pygame.math.Vector2(com_x, com_y)
- else: # Should not happen if particles_in_node is not empty and masses > 0
- self.center_of_mass = pygame.math.Vector2(self.boundary.centerx, self.boundary.centery)
- else:
- self.total_mass = 0.0
- self.center_of_mass = pygame.math.Vector2(self.boundary.centerx, self.boundary.centery)
- else: # Internal node
- self.total_mass = 0.0
- weighted_com_x = 0.0
- weighted_com_y = 0.0
- for child in self.children:
- if child:
- child.calculate_mass_distribution() # Recursive call
- self.total_mass += child.total_mass
- weighted_com_x += child.center_of_mass.x * child.total_mass
- weighted_com_y += child.center_of_mass.y * child.total_mass
- if self.total_mass > 0:
- self.center_of_mass = pygame.math.Vector2(weighted_com_x / self.total_mass, weighted_com_y / self.total_mass)
- else:
- self.center_of_mass = pygame.math.Vector2(self.boundary.centerx, self.boundary.centery)
- def draw(self, surface):
- pygame.draw.rect(surface, Config.QUADTREE_DRAW_COLOR, self.boundary, 1)
- if not self.is_leaf:
- for child in self.children:
- if child:
- child.draw(surface)
- # --- Force Calculation ---
- def calculate_force_on_body(body_to_calc_for, current_node, theta, G, softening_eps, damping):
- net_force = pygame.math.Vector2(0, 0)
- if current_node is None or current_node.total_mass == 0:
- return net_force
- if current_node.is_leaf:
- for particle_source in current_node.particles_in_node:
- if particle_source is body_to_calc_for:
- continue
- direction_vec = particle_source.pos - body_to_calc_for.pos
- dist_sq = direction_vec.length_squared()
- if dist_sq == 0: # Should be rare with float positions
- continue
- # Force = G * m1 * m2 / sqrt(r^2 + eps^2) for 1/r gravity
- effective_dist_denominator_sq = dist_sq + softening_eps**2
- if effective_dist_denominator_sq == 0: continue # Avoid sqrt(0)
- force_magnitude = (G * body_to_calc_for.mass * particle_source.mass) / math.sqrt(effective_dist_denominator_sq)
- # Normalize direction_vec safely
- if direction_vec.length() > 0:
- force_vec = direction_vec.normalize() * force_magnitude
- net_force += force_vec
- else: # Internal node
- direction_vec_node = current_node.center_of_mass - body_to_calc_for.pos
- dist_sq_node = direction_vec_node.length_squared()
- if dist_sq_node == 0 and current_node.total_mass > 0: # Body at CoM of internal node
- # Force recursion by making s_div_d > theta
- s_div_d_sq = float('inf')
- elif dist_sq_node == 0: # Empty internal node CoM or other issue
- s_div_d_sq = float('inf') # Effectively force recursion or skip
- else:
- s = current_node.boundary.width
- s_div_d_sq = (s * s) / dist_sq_node
- if s_div_d_sq < theta**2 : # s/d < theta => (s/d)^2 < theta^2
- effective_dist_denominator_sq_node = dist_sq_node + softening_eps**2
- if effective_dist_denominator_sq_node == 0: return net_force * damping # Avoid sqrt(0)
- force_magnitude_node = (G * body_to_calc_for.mass * current_node.total_mass) / math.sqrt(effective_dist_denominator_sq_node)
- if direction_vec_node.length() > 0:
- force_vec_node = direction_vec_node.normalize() * force_magnitude_node
- net_force += force_vec_node
- else: # Node is too close, recurse
- for child_node in current_node.children:
- if child_node:
- net_force += calculate_force_on_body(body_to_calc_for, child_node, theta, G, softening_eps, 1.0) # Damping applied at the end
- return net_force * damping
- # --- Utility: Get overall bounds for Quadtree root ---
- def get_simulation_bounds(bodies_list):
- if not bodies_list:
- return pygame.Rect(0, 0, Config.SCREEN_WIDTH, Config.SCREEN_HEIGHT)
- min_x = min(b.pos.x for b in bodies_list)
- max_x = max(b.pos.x for b in bodies_list)
- min_y = min(b.pos.y for b in bodies_list)
- max_y = max(b.pos.y for b in bodies_list)
- # Add some padding
- padding = 50
- width = max_x - min_x + 2 * padding
- height = max_y - min_y + 2 * padding
- # Ensure minimum size
- min_dim = 100
- width = max(width, min_dim)
- height = max(height, min_dim)
- # Make it square for simplicity of s in s/d
- side = max(width, height)
- center_x = (min_x + max_x) / 2
- center_y = (min_y + max_y) / 2
- return pygame.Rect(center_x - side / 2, center_y - side / 2, side, side)
- # --- Main Simulation Logic ---
- def main():
- pygame.init()
- screen = pygame.display.set_mode((Config.SCREEN_WIDTH, Config.SCREEN_HEIGHT))
- pygame.display.set_caption(Config.CAPTION)
- clock = pygame.time.Clock()
- font = None
- if Config.DRAW_INFO:
- try:
- font = pygame.font.SysFont("monospace", 15)
- except:
- font = pygame.font.Font(None, 20) # Fallback font
- bodies =
- # Create central body
- central_body = Body(Config.CENTRAL_BODY_MASS,
- Config.SCREEN_WIDTH / 2, Config.SCREEN_HEIGHT / 2,
- 0, 0, Config.BODY_COLOR_CENTRAL, is_stationary=True)
- bodies.append(central_body)
- # Create cloud of smaller bodies
- for _ in range(Config.NUM_SMALL_BODIES):
- mass = random.uniform(Config.SMALL_BODY_MASS_MIN, Config.SMALL_BODY_MASS_MAX)
- r_orbit = random.uniform(Config.CLOUD_RADIUS_MIN, Config.CLOUD_RADIUS_MAX)
- angle = random.uniform(0, 2 * math.pi)
- pos_x = central_body.pos.x + r_orbit * math.cos(angle)
- pos_y = central_body.pos.y + r_orbit * math.sin(angle)
- # Orbital velocity for 1/r force: v = sqrt(G * M_central)
- # Direction is tangential
- v_mag = math.sqrt(Config.G_2D * Config.CENTRAL_BODY_MASS)
- # Counter-clockwise orbit
- vel_x = -v_mag * math.sin(angle)
- vel_y = v_mag * math.cos(angle)
- # Vary color slightly
- color_r = max(0, min(255, Config.BODY_COLOR_SMALL_BASE + random.randint(-20, 20)))
- color_g = max(0, min(255, Config.BODY_COLOR_SMALL_BASE + random.randint(-20, 20)))
- color_b = max(0, min(255, Config.BODY_COLOR_SMALL_BASE + random.randint(-20, 20)))
- bodies.append(Body(mass, pos_x, pos_y, vel_x, vel_y, (color_r, color_g, color_b)))
- # Game loop variables
- running = True
- accumulator = 0.0
- current_real_time = pygame.time.get_ticks() / 1000.0 # Seconds
- # Initial Quadtree
- root_bounds = get_simulation_bounds(bodies)
- qtree_root = QuadtreeNode(root_bounds, 0)
- for body in bodies:
- qtree_root.insert_particle(body)
- qtree_root.calculate_mass_distribution()
- simulation_time_elapsed = 0.0
- while running:
- new_real_time = pygame.time.get_ticks() / 1000.0
- real_dt = new_real_time - current_real_time
- current_real_time = new_real_time
- # Cap frame_dt to prevent spiral of death on lag
- if real_dt > 0.25:
- real_dt = 0.25
- accumulator += real_dt * Config.TIME_SCALE
- for event in pygame.event.get():
- if event.type == pygame.QUIT:
- running = False
- if event.type == pygame.KEYDOWN:
- if event.key == pygame.K_q: # Quit
- running = False
- if event.key == pygame.K_t: # Toggle quadtree drawing
- Config.DRAW_QUADTREE = not Config.DRAW_QUADTREE
- if event.key == pygame.K_i: # Toggle info drawing
- Config.DRAW_INFO = not Config.DRAW_INFO
- # Fixed physics updates
- while accumulator >= Config.PHYSICS_DT:
- dynamic_bodies = [b for b in bodies if not b.is_stationary]
- # KDK Step 1: First Kick (vel) & Drift (pos)
- for body in dynamic_bodies:
- if body.mass == 0: continue
- force_old = calculate_force_on_body(body, qtree_root, Config.BARNES_HUT_THETA, Config.G_2D, Config.SOFTENING_FACTOR_EPS, Config.GRAVITY_DAMPING_FACTOR)
- accel_old = force_old / body.mass
- body.vel += accel_old * (Config.PHYSICS_DT / 2.0)
- body.prev_pos = body.pos.copy() # Store before moving
- body.pos += body.vel * Config.PHYSICS_DT
- # Rebuild Quadtree for new positions
- root_bounds = get_simulation_bounds(bodies)
- qtree_root = QuadtreeNode(root_bounds, 0)
- for body in bodies:
- qtree_root.insert_particle(body)
- qtree_root.calculate_mass_distribution()
- # KDK Step 2: Second Kick (vel)
- for body in dynamic_bodies:
- if body.mass == 0: continue
- force_new = calculate_force_on_body(body, qtree_root, Config.BARNES_HUT_THETA, Config.G_2D, Config.SOFTENING_FACTOR_EPS, Config.GRAVITY_DAMPING_FACTOR)
- accel_new = force_new / body.mass
- body.vel += accel_new * (Config.PHYSICS_DT / 2.0)
- accumulator -= Config.PHYSICS_DT
- simulation_time_elapsed += Config.PHYSICS_DT
- # Rendering with interpolation
- alpha = accumulator / Config.PHYSICS_DT
- screen.fill(Config.BG_COLOR)
- if Config.DRAW_QUADTREE and qtree_root:
- qtree_root.draw(screen)
- for body in bodies:
- body.draw_interpolated(screen, alpha)
- if Config.DRAW_INFO and font:
- num_dynamic_bodies = len([b for b in bodies if not b.is_stationary])
- info_text_lines =
- for i, line in enumerate(info_text_lines):
- text_surface = font.render(line, True, Config.INFO_TEXT_COLOR)
- screen.blit(text_surface, (5, 5 + i * 20))
- pygame.display.flip()
- clock.tick(Config.FPS_RENDER)
- pygame.quit()
- if __name__ == '__main__':
- main()
- VI. Guide to Tweaking and ExperimentationThe provided simulation script is designed with numerous constants (defined in the Config class) that can be modified to observe different behaviors and explore various aspects of N-body dynamics under 1/r gravity. Understanding how these parameters influence the simulation is key to effective experimentation.Explanation of Key Constants (from Table 1):
- Screen Dimensions & FPS:
- SCREEN_WIDTH, SCREEN_HEIGHT: Affect the visual area. Larger screens may require adjusting particle counts or cloud radii.
- FPS_RENDER: Target rendering frame rate. Higher values provide smoother animation if the system can keep up. Does not affect physics accuracy due to decoupling.
- PHYSICS_FPS, PHYSICS_DT: PHYSICS_FPS determines how many times per second the physics state is updated. PHYSICS_DT ($1.0 / \text{PHYSICS_FPS}$) is the fixed time interval for each physics step. Higher PHYSICS_FPS (smaller PHYSICS_DT) increases simulation accuracy and stability, especially for fast-moving particles or strong forces, but demands more computational power.
- Simulation Speed & Gravity:
- TIME_SCALE: A value of 1.0 runs the simulation in "simulation real-time" relative to PHYSICS_DT. Values > 1.0 speed up the simulation's evolution, while < 1.0 slow it down. This directly impacts how quickly the accumulator fills.
- G_2D: The gravitational constant for the 2D, 1/r force law. Increasing G_2D makes gravitational forces stronger, leading to faster orbits, tighter clustering, or more chaotic interactions.
- GRAVITY_DAMPING_FACTOR: A multiplier applied to the calculated gravitational force. Values < 1.0 weaken gravity, > 1.0 strengthen it. This can be used to simulate scenarios with weaker/stronger fundamental forces or to stabilize a very energetic system.
- Body Properties:
- CENTRAL_BODY_MASS: The mass of the large, stationary central body. This heavily influences the orbital velocities of smaller bodies (since v=G2DMcentral for 1/r gravity).
- NUM_SMALL_BODIES: Controls the complexity and visual density of the simulation. Higher numbers significantly increase computational load, especially for Barnes-Hut tree building and force calculations.
- SMALL_BODY_MASS_MIN, SMALL_BODY_MASS_MAX: Define the mass range for the orbiting particles. Mass variation can lead to more diverse interactions if collisions or more complex physics were added. In this simulation, mass primarily affects how strongly a particle attracts others.
- CLOUD_RADIUS_MIN, CLOUD_RADIUS_MAX: Determine the initial distribution range of the orbiting particles around the central body.
- Barnes-Hut & Softening Parameters:
- BARNES_HUT_THETA: The opening angle criterion. Values typically range from 0.5 to 1.0.
- Smaller θ (e.g., 0.3): More nodes are opened, leading to more direct particle-particle force calculations. This increases accuracy but significantly slows down computation, approaching O(N2) as θ→0.
- Larger θ (e.g., 0.9): Fewer nodes are opened; more approximations are made using the node's center of mass. This speeds up computation (closer to O(NlogN)) but can reduce accuracy, especially for interactions within dense clusters.
- SOFTENING_FACTOR_EPS: The softening length ϵ. This prevents forces from becoming infinite during very close encounters.
- Too small ϵ: May not effectively prevent numerical instabilities if particles get extremely close.
- Too large ϵ: Can artificially weaken gravitational forces at moderate distances, altering trajectories significantly from the ideal 1/r behavior. A value around the average particle size or slightly smaller is often a good starting point.
- MAX_PARTICLES_PER_NODE, MAX_TREE_DEPTH: Control the structure of the quadtree. MAX_PARTICLES_PER_NODE = 1 is standard for Barnes-Hut. MAX_TREE_DEPTH prevents runaway recursion in extremely dense particle configurations, though well-chosen boundaries and MAX_PARTICLES_PER_NODE usually manage this.
- Visuals:
- BODY_COLOR_CENTRAL, BODY_COLOR_SMALL_BASE, BG_COLOR: Aesthetic choices.
- QUADTREE_DRAW_COLOR, DRAW_QUADTREE: DRAW_QUADTREE = True will render the quadtree boundaries, which is invaluable for debugging the Barnes-Hut implementation and understanding how space is partitioned. However, it adds rendering overhead.
- DRAW_INFO: Toggles the display of simulation statistics.
- Suggestions for Further Exploration:
- Visualize Quadtree Dynamics: Enable DRAW_QUADTREE and observe how the tree structure adapts as particles move. Pay attention to how nodes are subdivided in denser regions.
- Impact of θ: Systematically vary BARNES_HUT_THETA (e.g., 0.2, 0.5, 0.8, 1.0) while keeping other parameters constant. Observe the trade-off between simulation speed (rendering FPS might be an indicator if physics is the bottleneck) and the qualitative behavior of the particle system (e.g., stability of orbits, formation of clumps).
- Effect of Softening: Change SOFTENING_FACTOR_EPS. With a very small ϵ, try to induce a scenario where particles have very close, high-energy encounters and observe if instabilities arise. With a very large ϵ, note how orbits might become "mushy" or less defined.
- Initial Conditions:
- Modify CLOUD_RADIUS_MIN and CLOUD_RADIUS_MAX to create different initial cloud shapes (e.g., a thin ring vs. a diffuse cloud).
- Experiment with NUM_SMALL_BODIES. How does performance scale?
- Implement different initial velocity profiles, e.g., making all particles initially move towards the center or in a non-orbital pattern.
- Force Law Variation: Modify the calculate_force_on_body function to implement a 1/r2 force law (remembering that orbital velocity calculation in main() would also need to change: v=GMcentral/rorbit). Compare the resulting orbital dynamics. Are orbits more stable or less stable? Do they precess differently? (Note: For 1/r2, stable circular and elliptical orbits are expected. For 1/r, the stability of non-circular orbits can be different).
- Performance Profiling: Use Python's cProfile module to identify performance bottlenecks in the simulation, especially with high particle counts. Likely candidates are quadtree construction and force calculation.
- Adding Collisions: As a more advanced step, one could implement simple collision detection (e.g., based on particle radii) and response (e.g., merging particles and conserving momentum, or elastic bounces).
- Energy Conservation: Implement a function to calculate the total kinetic and potential energy of the system. For a 1/r force, the potential energy between two particles is U=G2Dm1m2ln(r). Monitor how well the Leapfrog integrator conserves total energy over time with different PHYSICS_DT values.
- By systematically adjusting these parameters and observing the outcomes, users can gain a deeper intuition for N-body dynamics, the workings of the Barnes-Hut algorithm, and the nuances of numerical simulation.VII. ConclusionThis report has detailed the design and implementation of a 2D N-body gravitational simulation utilizing the Barnes-Hut algorithm and rendered with Pygame. The simulation uniquely employs a 1/r linear distance falloff for gravity, distinguishing it from typical 1/r2 models and leading to interesting orbital dynamics, such as orbital velocities independent of radius for circular paths.Key features include a robust physics engine built on Kick-Drift-Kick Leapfrog integration for stable and energy-conservative time evolution, and a fixed physics timestep decoupled from rendering to ensure consistent simulation behavior across different hardware. Visual smoothness is maintained through state interpolation. The Barnes-Hut algorithm, with its quadtree-based spatial decomposition and θ-criterion for force approximation, provides an efficient means to handle systems with a moderate number of particles, reducing computational complexity from O(N2) to approximately O(NlogN). Gravitational softening is incorporated to prevent numerical singularities during close particle encounters.The provision of numerous tweakable constants empowers users to experiment with various physical parameters and algorithmic settings, fostering a deeper understanding of the interplay between gravitational forces, particle dynamics, and approximation techniques. The complete, runnable Python script serves as both a demonstration and a foundation for further exploration into computational astrophysics and simulation methods. The balance between physical fidelity and computational efficiency is a central theme in N-body simulations, and this project offers a practical example of navigating that trade-off.
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement