Package github.com/matjam/bunyip/orbit
orbit
Package orbit computes celestial mechanics for space games. It provides orbital elements and state vectors, two-body propagation that handles circles, ellipses, parabolas and hyperbolas alike, an N-body integrator for planets and moons pulling on each other, and helpers for the numbers a player sees (periods, apsides, escape velocity, transfer burns). Everything is double precision, because a planetary system does not fit in float32. Nothing assumes our own solar system. Bodies are masses at positions, the gravitational constant is yours to set, and units are whatever you choose (metres and seconds with the real G, or a game's own units with G = 1).
System connects the package to the entity component system. Body components hold positions and velocities at astronomical scale. The system moves them, analytically for entities on a Kepler orbit and numerically for free bodies under thrust and gravity, and writes scaled positions into gfx.Transform relative to a floating origin, so rendering keeps its precision at any camera position. Kepler components preserve their orbital phase through ECS saves and prefab JSON. Register the component and resource types with ecs.Register before saving; omit the orbit system's private query cache with ecs.SaveOptions.SkipUnregistered.
Index
- Constants
func AppendPredict(dst []lin.Vec3, w *ecs.World, ship ecs.Entity, seconds float64, samples int) []lin.Vec3func AppendPredictRelative(dst []lin.Vec3, w *ecs.World, ship, ref ecs.Entity, seconds float64, samples int) []lin.Vec3func CircularVelocity(mu, r float64) float64func Energy(s State, mu float64) float64func EscapeVelocity(mu, r float64) float64func Hohmann(mu, r1, r2 float64) (dv1, dv2, coast float64)func MeanAnomalyFromTrue(trueAnomaly, e float64) float64func Predict(w *ecs.World, ship ecs.Entity, seconds float64, samples int) []lin.Vec3func PredictRelative(w *ecs.World, ship, ref ecs.Entity, seconds float64, samples int) []lin.Vec3func RK4(pos, vel Vec3, dt float64, accel func(pos, vel Vec3) Vec3) (Vec3, Vec3)func SolveKepler(meanAnomaly, e float64) float64func SphereOfInfluence(a, m, M float64) float64func System(w *ecs.World, dt float64)func TrueAnomalyFromMean(meanAnomaly, e float64) float64func VisViva(mu, r, a float64) float64- type Body
- type Elements
func Around(w *ecs.World, ship ecs.Entity) (primary ecs.Entity, el Elements, mu float64, ok bool)func Circular(radius float64) Elementsfunc ElementsOf(s State, mu float64) Elementsfunc (e Elements) Apoapsis() float64func (e Elements) AtTime(mu, dt float64) Elementsfunc (e Elements) MeanMotion(mu float64) float64func (e Elements) Periapsis() float64func (e Elements) Period(mu float64) float64func (e Elements) State(mu float64) State
- type Kepler
- type Settings
- type Ship
- type Simulation
- type State
- type Thrust
- type Vec3
Constants
const G = 6.67430e-11 // m³ kg⁻¹ s⁻²
G is the gravitational constant in SI units. Nothing here depends on SI: set Settings.G or Simulation.G to 1 and choose your own units of mass, distance and time, and every formula still holds. Real-world bodies live in the sol subpackage.
Functions
AppendPredict source
func AppendPredict(dst []lin.Vec3, w *ecs.World, ship ecs.Entity, seconds float64, samples int) []lin.Vec3
AppendPredict is Predict appending the path to dst, for a caller that predicts every frame and keeps one slice for it: pass the last frame's path sliced to zero length and nothing is allocated once it has grown. When Predict would return nil, it returns dst unchanged.
AppendPredictRelative source
func AppendPredictRelative(dst []lin.Vec3, w *ecs.World, ship, ref ecs.Entity, seconds float64, samples int) []lin.Vec3
AppendPredictRelative is PredictRelative appending the path to dst, for a caller that predicts every frame and keeps one slice for it: pass the last frame's path sliced to zero length and nothing is allocated once it has grown. When PredictRelative would return nil, it returns dst unchanged.
CircularVelocity source
func CircularVelocity(mu, r float64) float64
CircularVelocity is the speed of a circular orbit at radius r.
Energy source
func Energy(s State, mu float64) float64
Energy is the specific orbital energy; negative for bound orbits.
EscapeVelocity source
func EscapeVelocity(mu, r float64) float64
EscapeVelocity is the speed needed to leave from radius r.
Hohmann source
func Hohmann(mu, r1, r2 float64) (dv1, dv2, coast float64)
Hohmann plans the two-burn transfer between circular orbits of radii
r1 and r2: the nonnegative delta-v magnitude of each burn and the coast
time between them. Both radii and mu must be positive and use consistent units.
Example
package main
import (
"fmt"
"github.com/matjam/bunyip/orbit"
"github.com/matjam/bunyip/orbit/sol"
)
func main() {
// Low Earth orbit to geostationary: two burns and a coast.
dv1, dv2, coast := orbit.Hohmann(sol.MuEarth, 6678e3, 42164e3)
fmt.Printf("burn %.2f km/s, coast %.1f h, burn %.2f km/s\n", dv1/1000, coast/3600, dv2/1000)
}
burn 2.43 km/s, coast 5.3 h, burn 1.47 km/s
MeanAnomalyFromTrue source
func MeanAnomalyFromTrue(trueAnomaly, e float64) float64
MeanAnomalyFromTrue converts a true anomaly on a closed orbit.
Predict source
func Predict(w *ecs.World, ship ecs.Entity, seconds float64, samples int) []lin.Vec3
Predict integrates a copy of a ship forward and returns its path in world units (scaled, relative to the origin), for drawing where it is heading. seconds is a nonnegative duration in simulation time units, independent of Settings.TimeScale. The result contains exactly samples points, excluding the starting point and including the end of the duration. Kepler bodies move during prediction; other Ships are excluded from gravity, and massive bodies with neither Ship nor Kepler stay fixed. Settings and the ship's Body must exist and samples must be positive, otherwise it returns nil. Current Thrust is held constant throughout. To reuse a slice from frame to frame, call AppendPredict.
PredictRelative source
func PredictRelative(w *ecs.World, ship, ref ecs.Entity, seconds float64, samples int) []lin.Vec3
PredictRelative is Predict in the moving frame of another body: each point is shifted by how far that body will have moved by then, so the path of a ship orbiting a planet draws as a loop around the planet where it is now rather than a streak across the sky. With ref None it is the inertial path. Only a reference with Kepler is advanced; a free reference body supplies no moving-frame correction. Requirements, units and sample placement are the same as Predict. To reuse a slice from frame to frame, call AppendPredictRelative.
RK4 source
func RK4(pos, vel Vec3, dt float64, accel func(pos, vel Vec3) Vec3) (Vec3, Vec3)
RK4 advances a particle by dt in an acceleration field, a fourth-order step suited to spacecraft under thrust plus gravity.
SolveKepler source
func SolveKepler(meanAnomaly, e float64) float64
SolveKepler returns the eccentric anomaly for a mean anomaly and eccentricity below one.
SphereOfInfluence source
func SphereOfInfluence(a, m, M float64) float64
SphereOfInfluence returns the Laplace sphere-of-influence approximation a*(m/M)^0.4 for a body's orbital semi-major axis a and masses m and M. It is a patched-conic boundary estimate, not an exact gravity boundary.
System source
func System(w *ecs.World, dt float64)
System advances orbits by dt real seconds (times Settings.TimeScale) and writes scaled positions into existing gfx.Transform components. It creates default Settings when absent. Use a nonnegative scaled timestep: the ship integrator does not integrate backwards. Kepler primary chains should be acyclic and no more than sixteen links deep.
TrueAnomalyFromMean source
func TrueAnomalyFromMean(meanAnomaly, e float64) float64
TrueAnomalyFromMean converts a mean anomaly on a closed orbit.
VisViva source
func VisViva(mu, r, a float64) float64
VisViva is the speed at radius r on an orbit of semi-major axis a.
Types
type Body source
type Body struct {
Pos, Vel Vec3
Mass float64
}
Body is a mass with a position and velocity, used both as an ECS component and inside a Simulation. In a Simulation, Mass zero makes a test particle that feels gravity without exerting it. In the ECS, Ship or Kepler selects motion; a Body with neither stays fixed.
type Elements source
type Elements struct {
SemiMajorAxis float64 // negative for hyperbolic orbits; periapsis distance when eccentricity is exactly 1
Eccentricity float64
Inclination float64
Node float64 // longitude of the ascending node
ArgPeriapsis float64 // argument of periapsis
TrueAnomaly float64
}
Elements are classical orbital elements; angles are radians and the reference plane is XY with +Z as its normal. Distances and mu must use consistent units; mu is G times primary mass, with units distance³/time². A zero Elements has no valid orbital radius.
Around source
func Around(w *ecs.World, ship ecs.Entity) (primary ecs.Entity, el Elements, mu float64, ok bool)
Around describes a ship's orbit relative to the massive body whose gravity dominates it, in classical elements, for a readout.
Circular source
func Circular(radius float64) Elements
Circular makes a circular orbit of the given radius in the reference plane.
ElementsOf source
func ElementsOf(s State, mu float64) Elements
ElementsOf converts a state to classical elements for mu.
Apoapsis source
func (e Elements) Apoapsis() float64
Apoapsis is the farthest distance; infinite for open orbits.
AtTime source
func (e Elements) AtTime(mu, dt float64) Elements
AtTime returns the elements after dt simulation time units. Closed orbits advance mean anomaly; open orbits use Propagate and ElementsOf.
MeanMotion source
func (e Elements) MeanMotion(mu float64) float64
MeanMotion is the average angular rate in radians per simulation time unit.
Periapsis source
func (e Elements) Periapsis() float64
Periapsis returns SemiMajorAxis*(1-Eccentricity), the closest distance for elliptic and hyperbolic orbits. For a parabola, read SemiMajorAxis directly: this formula returns zero at eccentricity 1.
type Kepler source
type Kepler struct {
Primary ecs.Entity
Elements Elements
Mu float64
// contains filtered or unexported fields
}
Kepler puts an entity on an analytic two-body orbit around Primary: planets around a star, moons around a planet. Its Body is written each update from the elements; Mu zero means G times the primary's mass. JSON encoding preserves the elapsed orbital phase for saves and prefabs; older JSON without elapsed time starts at the elements' epoch.
MarshalJSON source
func (k Kepler) MarshalJSON() ([]byte, error)
MarshalJSON preserves the orbital phase in ECS saves and prefab JSON.
UnmarshalJSON source
func (k *Kepler) UnmarshalJSON(data []byte) error
UnmarshalJSON restores the orbital phase. Older JSON without Elapsed starts at the epoch specified by Elements.
type Settings source
type Settings struct {
G float64 // zero means the real constant
TimeScale float64 // simulation time units per real second; zero means 1
Substeps int // minimum integration steps per update for ships; nonpositive means 8
Softening float64 // distance softening: squared and added to squared separations; zero disables it
// Rendering: scene units per simulation distance unit, and the floating origin that is
// subtracted from every position before scaling so the scene stays
// near zero where float32 is precise. Move Origin with the camera.
Scale float64 // zero means 1
Origin Vec3
}
Settings is the world resource for the orbit system.
type Ship source
type Ship struct{}
Ship is a marker for free bodies: they are integrated numerically under massive non-Ship bodies' gravity plus their own Thrust. Ships do not exert gravity on one another, regardless of Mass. Without it, a Body with no Kepler stays put (a star at the origin). Use either Ship or Kepler on an entity, not both.
type Simulation source
type Simulation struct {
Bodies []Body
G float64 // zero means the real constant
Softening float64 // simulation distance: its square is added to squared separations
Time float64 // elapsed simulation time, advanced by Step
// contains filtered or unexported fields
}
Simulation integrates bodies under their mutual gravity with the
leapfrog (kick-drift-kick) scheme. Use a sufficiently small fixed step
to limit integration error; energy is not conserved exactly. The zero
value is an empty simulation with the SI gravitational constant.
Example
package main
import (
"fmt"
"math"
"github.com/matjam/bunyip/orbit"
)
func main() {
// Two stars of equal mass circling each other, in game units.
sim := orbit.Simulation{G: 1, Bodies: []orbit.Body{
{Pos: orbit.V3(-5, 0, 0), Vel: orbit.V3(0, -5, 0), Mass: 500},
{Pos: orbit.V3(5, 0, 0), Vel: orbit.V3(0, 5, 0), Mass: 500},
}}
e0 := sim.Energy()
for range 10000 {
sim.Step(0.001)
}
drift := math.Abs((sim.Energy() - e0) / e0)
fmt.Printf("energy conserved to 1e-9: %v, separation %.2f\n", drift < 1e-9, sim.Bodies[1].Pos.Sub(sim.Bodies[0].Pos).Len())
}
energy conserved to 1e-9: true, separation 10.00
Accelerations source
func (s *Simulation) Accelerations(out []Vec3)
Accelerations fills out with the gravitational acceleration on each body. out must have length at least len(Bodies). Positive Softening avoids the singularity when distinct bodies occupy the same position. From 256 bodies it shares the bodies out across goroutines, one range per processor; each body's sum still runs over the others in order, so the result is the same as on one goroutine. Do not change Bodies while it runs.
Energy source
func (s *Simulation) Energy() float64
Energy returns total kinetic plus unsoftened Newtonian potential energy. It ignores Softening and omits potential terms for coincident bodies, so it is an integration diagnostic for separated, unsoftened systems.
FieldAt source
func (s *Simulation) FieldAt(p Vec3) Vec3
FieldAt is the gravitational acceleration the bodies produce at p.
Step source
func (s *Simulation) Step(dt float64)
Step advances every body and Time by dt simulation time units. The accelerations it ends with are the ones the next Step starts with, so it computes them once per step, not twice. Changing a body's position or mass, adding or removing bodies, or changing G or Softening between steps is noticed, and the next Step computes them afresh.
type State source
type State struct{ Pos, Vel Vec3 }
State is a position and velocity relative to the body being orbited.
Propagate source
func Propagate(s State, mu, dt float64) State
Propagate advances a state by dt simulation time units under two-body
gravity using the universal variable formulation for any conic. It
solves iteratively in float64 and does not report failure to converge.
mu must be positive and the initial distance from the primary nonzero.
A zero dt returns s unchanged.
Example
package main
import (
"fmt"
"github.com/matjam/bunyip/orbit"
)
func main() {
// A fictional world in game units: G is 1 and the star has mass 1000.
const mu = 1000
el := orbit.Elements{SemiMajorAxis: 20, Eccentricity: 0.3}
s := el.State(mu)
half := orbit.Propagate(s, mu, el.Period(mu)/2)
fmt.Printf("periapsis %.0f, apoapsis %.0f, at half a period r = %.1f\n", el.Periapsis(), el.Apoapsis(), half.Pos.Len())
}
periapsis 14, apoapsis 26, at half a period r = 26.0
type Thrust source
type Thrust struct{ Accel Vec3 }
Thrust is the constant acceleration applied to a Ship, in simulation distance units per simulation time unit squared, in world axes.
Source files
example_test.go lockstep_test.go nbody.go nbody_reuse_test.go orbit.go orbit_test.go perf_bench_test.go persistence.go persistence_test.go predict_test.go system.go