1
Physics Engine
minjaesong edited this page 2025-11-24 21:24:45 +09:00

Physics Engine

Terrarum features a custom AABB-based physics system optimised for handling millions of tile bodies alongside dynamic actors. The system simulates Newtonian motion, collision detection, and gravitational forces.

Overview

The physics engine provides:

  • AABB collision detection — Axis-Aligned Bounding Box system
  • Tile-based optimisation — Efficient handling of millions of static blocks
  • Newtonian simulation — Velocity, acceleration, momentum
  • Gravity system — Configurable gravitational acceleration
  • Friction and damping — Surface and air resistance
  • Multi-threading support — Scalable actor updates (currently disabled for performance)

Core Concepts

Units and Scale

METER Constant:

companion object {
    const val METER = 25.0  // 1 metre = 25 pixels
}

This fundamental constant defines the relationship between pixels and physics simulation:

  • 1 metre = 25 pixels
  • 1 tile = 16 pixels = 0.64 metres

Time Step

PHYS_TIME_FRAME (from Terrarum.kt):

const val PHYS_TIME_FRAME: Double = METER  // = 25.0

The physics time step is set to match METER, creating a 1:1 correspondence between game units and SI units in calculations.

Frame Rate:

Velocity and acceleration scale with delta time to maintain consistent physics at any frame rate. The physics simulation assumes a reference of 60 FPS, with values scaling proportionally:

// Velocity units: pixels per 1/60 seconds
// When resolving velocity, account for actual framerate:
// v_resolved = v * (60 * delta) where delta is frame time in seconds

PhysProperties

Physical objects have properties defining their physics behaviour:

class PhysProperties(
    var immobile: Boolean = false,
    var usePhysics: Boolean = true,
    var movementType: Int = PhysicalTypes.NORMAL,
    // ... more properties
)

Common Presets

PhysProperties.HUMANOID_DEFAULT()  // Standard character physics
PhysProperties.FLYING()            // Flying entities
PhysProperties.PROJECTILE()        // Bullets, arrows
PhysProperties.STATIONARY()        // Fixed objects

Movement Types

object PhysicalTypes {
    const val NORMAL = 0      // Standard gravity physics
    const val FLYING = 1      // No gravity, free movement
    const val RIDING = 2      // Mounted on vehicle
    const val SWIMMING = 3    // Fluid dynamics
}

ActorWithBody Physics

Actors inheriting from ActorWithBody have full physics simulation.

Velocity System

ActorWithBody maintains two velocity vectors:

External Velocity

Physics-driven movement (gravity, collisions, forces):

internal val externalV: Vector2  // pixels per frame at 60 FPS

Controller Velocity

Player/AI-controlled movement:

var controllerV: Vector2?  // Only for Controllable actors

Applying Forces

Add acceleration to velocity:

// Apply gravity (delta is frame time, 60.0 is reference FPS)
externalV.y += world.gravitation.y * delta * 60.0

// Apply horizontal force
externalV.x += pushForce

// Apply damping
externalV.scl(dampingFactor)

Velocity Limits

private val VELO_HARD_LIMIT = 100.0  // Maximum velocity magnitude

// Clamp velocity
if (externalV.len() > VELO_HARD_LIMIT) {
    externalV.nor().scl(VELO_HARD_LIMIT)
}

Collision Detection

AABB System

All collision uses Axis-Aligned Bounding Boxes:

class Hitbox(
    var startX: Double,  // Left edge
    var startY: Double,  // Top edge
    var width: Double,   // Width
    var height: Double   // Height
) {
    val endX: Double get() = startX + width
    val endY: Double get() = startY + height
    val centerX: Double get() = startX + width / 2
    val centerY: Double get() = startY + height / 2
}

Hitbox Queries

fun containsPoint(x: Double, y: Double): Boolean
fun intersects(other: Hitbox): Boolean
fun overlaps(x: Double, y: Double, width: Double, height: Double): Boolean

Tile Collision

Half-Integer Tilewise Hitbox

For physics collision with tiles:

val hIntTilewiseHitbox: Hitbox

This hitbox represents the actor's position in "half-integer" tile coordinates, aligned to tile centres for physics calculations.

Integer Tilewise Hitbox

For block occupation checks:

val intTilewiseHitbox: Hitbox

This represents which tiles the actor fully or partially occupies.

Collision Iteration

Check collision with surrounding tiles:

for (tx in hIntTilewiseHitbox.startX.toInt()..hIntTilewiseHitbox.endX.toInt()) {
    for (ty in hIntTilewiseHitbox.startY.toInt()..hIntTilewiseHitbox.endY.toInt()) {
        val block = world.getTileFromTerrain(tx, ty)
        if (BlockCodex[block]?.isSolid == true) {
            // Handle collision
        }
    }
}

Mass and Momentum

Mass Calculation

Actor mass scales with volume (cubic scaling):

val mass: Double = baseMass * scale³

Where:

  • baseMass — Mass at scale = 1.0 (in kilograms)
  • scale — Actor's size multiplier

Momentum

val momentum: Vector2 = velocity * mass

Larger, heavier actors have more momentum and are harder to stop.

Gravity System

World Gravity

Each world defines gravitational acceleration:

var gravitation: Vector2 = DEFAULT_GRAVITATION

Default gravity approximates Earth's 9.8 m/s²:

// Typical value (not a defined constant in codebase):
// val DEFAULT_GRAVITATION = Vector2(0.0, 9.8 * METER / (60.0 * 60.0))
// This converts 9.8 m/s² to game units (pixels per frame²)

Applying Gravity

Gravity is applied each physics update:

if (physProp.usePhysics && physProp.movementType == PhysicalTypes.NORMAL) {
    externalV.add(world.gravitation * delta * 60.0)  // delta = frame time in seconds
}

Gravity Exceptions

  • Flying actors (movementType = FLYING) — No gravity
  • Immobile actors (immobile = true) — No movement
  • Riding actors (movementType = RIDING) — Follow mount's motion

Friction

Surface Friction

Blocks define horizontal friction:

val frictionCoeff: Float  // <16: slippery, 16: normal, >16: sticky

Friction is applied when actors touch ground:

if (onGround) {
    val friction = groundBlock.frictionCoeff / 16.0
    controllerV.x *= friction
}

Vertical Friction

For wall sliding:

val verticalFriction: Float  // 0 = not slideable

Applied when actor is against a wall:

if (againstWall && block.verticalFriction > 0) {
    externalV.y *= block.verticalFriction
}

Air Resistance

Damping applied to all movement:

val airDamping = 0.99  // Slight damping

externalV.scl(airDamping)
controllerV?.scl(airDamping)

Advanced Physics

Platform Collision

Platforms are special blocks:

val isPlatform: Boolean  // One-way collision

Actors can jump through from below but land on top:

if (block.isPlatform && actor.externalV.y <= 0 && actor.bottomY <= blockTopY) {
    // Land on platform
} else if (actor.externalV.y > 0) {
    // Pass through from below
}

Falling Blocks

Blocks can fall due to gravity:

val gravitation: Int?
// null: Don't fall
// 0: Fall immediately (sand)
// N: Support N floating blocks (scaffolding)

Falling blocks become temporary actors and settle when they hit solid ground.

Fluid Dynamics

Actors in fluids experience:

  • Buoyancy — Upward force based on density difference
  • Viscosity — Movement resistance
  • Drag — Velocity-dependent resistance
if (inFluid) {
    val fluid = FluidCodex[fluidID]
    val buoyancy = (fluid.density - actor.density) * volume * gravitation
    externalV.y -= buoyancy * delta

    // Apply fluid drag
    val drag = fluid.viscosity / 16.0
    externalV.scl(drag)
}

Collision Response

Separation

When actors collide, separate them:

fun separate(actor: ActorWithBody, block: Hitbox) {
    val overlapX = min(actor.hitbox.endX - block.startX, block.endX - actor.hitbox.startX)
    val overlapY = min(actor.hitbox.endY - block.startY, block.endY - actor.hitbox.startY)

    if (overlapX < overlapY) {
        // Separate horizontally
        if (actor.hitbox.centerX < block.centerX) {
            actor.translatePosition(-overlapX, 0.0)
        } else {
            actor.translatePosition(overlapX, 0.0)
        }
        actor.externalV.x = 0.0
    } else {
        // Separate vertically
        if (actor.hitbox.centerY < block.centerY) {
            actor.translatePosition(0.0, -overlapY)
        } else {
            actor.translatePosition(0.0, overlapY)
        }
        actor.externalV.y = 0.0
    }
}

Bouncing

Apply elasticity on collision:

val restitution = 0.5  // 0 = no bounce, 1 = perfect bounce

if (collisionWithGround) {
    externalV.y = -externalV.y * restitution
    if (abs(externalV.y) < minBounceVelocity) {
        externalV.y = 0.0  // Stop bouncing
    }
}

Performance Optimisations

Spatial Partitioning

The engine uses PR-Tree for efficient actor queries:

val actorTree: PRTree<ActorWithBody>

// Query actors in area
val nearbyActors = actorTree.find(
    minX, minY,
    maxX, maxY
)

Sleeping Actors

Stationary actors can be flagged as sleeping:

var isStationary: Boolean = true

Sleeping actors skip expensive physics calculations.

PHYS_EPSILON_DIST

Small epsilon for floating-point precision:

companion object {
    const val PHYS_EPSILON_DIST = 1.0 / 256.0
}

Used to avoid edge cases in collision detection.

Common Patterns

Jumping

fun jump(jumpStrength: Double) {
    if (onGround) {
        externalV.y = -jumpStrength
        onGround = false
    }
}

Walking

fun moveHorizontal(direction: Double, speed: Double) {
    controllerV?.x = direction * speed
}

Applying Knockback

fun applyKnockback(angle: Double, force: Double) {
    externalV.x += cos(angle) * force
    externalV.y += sin(angle) * force
}

Ground Check

fun isOnGround(): Boolean {
    val feetY = hitbox.endY
    val checkY = (feetY + PHYS_EPSILON_DIST) / TILE_SIZE

    for (tx in intTilewiseHitbox.startX.toInt()..intTilewiseHitbox.endX.toInt()) {
        val block = world.getTileFromTerrain(tx, checkY.toInt())
        if (BlockCodex[block]?.isSolid == true) {
            return true
        }
    }
    return false
}

Multi-Threading

The engine supports multi-threaded actor updates:

class ThreadActorUpdate(
    val actors: List<ActorWithBody>,
    val delta: Float
) : Runnable {
    override fun run() {
        actors.forEach { it.update(delta) }
    }
}

However, multi-threading is currently disabled as it's slower for typical actor counts (< tens of thousands).

Debug Visualisation

Render Hitboxes

if (App.IS_DEVELOPMENT_BUILD) {
    shapeRenderer.begin(ShapeRenderer.ShapeType.Line)
    shapeRenderer.setColor(Color.RED)
    shapeRenderer.rect(
        hitbox.startX.toFloat(),
        hitbox.startY.toFloat(),
        hitbox.width.toFloat(),
        hitbox.height.toFloat()
    )
    shapeRenderer.end()
}

Velocity Vectors

shapeRenderer.line(
    position.x, position.y,
    position.x + externalV.x * 10, position.y + externalV.y * 10
)

Best Practises

  1. Use controller velocity for input — Keep player control separate from physics
  2. Apply forces gradually — Large instantaneous forces cause clipping
  3. Check TILE_SIZE boundaries — Ensure coordinates are in correct space
  4. Clamp velocities — Prevent excessive speeds causing tunnelling
  5. Mark immobile actors — Optimise performance for static objects
  6. Use epsilon for comparisons — Account for floating-point precision
  7. Profile physics calculations — Identify expensive collision checks

Limitations

  • No rotation — All hitboxes are axis-aligned
  • Gravity is downward only — No reverse or lateral gravity
  • Single-threaded by default — Multi-threading disabled for performance
  • Discrete collision — Fast-moving objects can tunnel through thin obstacles

Future Enhancements

Possible improvements:

  • Continuous collision detection
  • Rotated hitboxes
  • Soft-body physics
  • Better fluid simulation
  • Multi-directional gravity

See Also

  • Glossary — Physics terminology
  • Actors — Actor system using physics
  • World — World structure and tile properties
  • Modules:Blocks — Defining block physics properties