Skip to content

feat: SDF composite battle box shapesΒ #86

@Bli-AIk

Description

@Bli-AIk

πŸ”— Is your feature request related to a specific issue?

The current battle box system only supports axis-aligned rectangles for collision. Complex battle scenarios require non-rectangular collision regions (e.g., circular arenas, L-shaped areas, boxes with holes).

Background: souprune already has a full SDF rendering pipeline (bevy_alight_motion/src/sdf.rs, sdf_material.rs, sdf_view_shape.rs) but the collision system only uses axis-aligned rectangles (BattleBoxBoundary with half_size: Vec2). This creates an inconsistency: rendering can show arbitrary SDF shapes, but collision is limited to rectangles.

πŸ’‘ Feature Description

SDF composite shape enum

Use SDF composition operations to build complex collision shapes. This replaces the polygon CSG approach used in the predecessor project UCT, where BoxService.FindIntersections + ProcessPolygons + GetUnion/GetDifference required ~400 lines of polygon math. The SDF approach achieves the same results in ~50 lines:

/// SDF composite battle box shape
#[derive(Component, Debug, Clone, Deserialize, Serialize)]
pub enum BattleBoxShape {
    /// Rectangle (current default)
    Rect { half_size: Vec2 },
    /// Circle
    Circle { radius: f32 },
    /// Rounded rectangle
    RoundedRect { half_size: Vec2, radius: f32 },
    /// Union of two shapes: SDF = min(a, b)
    Union(Box<BattleBoxShape>, Box<BattleBoxShape>),
    /// Subtraction: A minus B: SDF = max(a, -b)
    Subtract(Box<BattleBoxShape>, Box<BattleBoxShape>),
    /// Smooth union: SDF = smooth_min(a, b, k)
    SmoothUnion {
        a: Box<BattleBoxShape>,
        b: Box<BattleBoxShape>,
        smoothness: f32,
    },
}

impl BattleBoxShape {
    pub fn sdf_distance(&self, point: Vec2) -> f32 {
        match self {
            Self::Rect { half_size } => {
                // Inigo Quilez box SDF (already exists in systems.rs:184-195)
                let d = point.abs() - *half_size;
                d.max(Vec2::ZERO).length() + d.x.max(d.y).min(0.0)
            }
            Self::Circle { radius } => point.length() - radius,
            Self::RoundedRect { half_size, radius } => {
                let d = point.abs() - *half_size + Vec2::splat(*radius);
                d.max(Vec2::ZERO).length() + d.x.max(d.y).min(0.0) - radius
            }
            Self::Union(a, b) => {
                a.sdf_distance(point).min(b.sdf_distance(point))
            }
            Self::Subtract(a, b) => {
                a.sdf_distance(point).max(-b.sdf_distance(point))
            }
            Self::SmoothUnion { a, b, smoothness: k } => {
                let da = a.sdf_distance(point);
                let db = b.sdf_distance(point);
                let h = (0.5 + 0.5 * (db - da) / k).clamp(0.0, 1.0);
                db * (1.0 - h) + da * h - k * h * (1.0 - h)
            }
        }
    }
}

Collision constraint via SDF gradient

For non-rectangular shapes, the current axis-aligned separation (BattleBoxBoundary::constrain_with_collider()) won't work. Instead, use the SDF gradient to find the surface normal:

impl BattleBoxShape {
    /// Numerical SDF gradient (surface normal direction)
    pub fn gradient(&self, point: Vec2) -> Vec2 {
        const EPS: f32 = 0.01;
        let dx = self.sdf_distance(point + Vec2::X * EPS)
               - self.sdf_distance(point - Vec2::X * EPS);
        let dy = self.sdf_distance(point + Vec2::Y * EPS)
               - self.sdf_distance(point - Vec2::Y * EPS);
        Vec2::new(dx, dy).normalize_or_zero()
    }

    /// Constrain a point to stay inside the shape with a given margin
    pub fn constrain_point(&self, point: Vec2, margin: f32) -> Vec2 {
        let dist = self.sdf_distance(point);
        if dist + margin < 0.0 {
            return point; // Safely inside
        }
        let normal = self.gradient(point);
        point - normal * (dist + margin)
    }
}

RON data-driven configuration

// A rounded rectangle battle box
(
    shape: RoundedRect(
        half_size: (283.0, 65.0),
        radius: 8.0,
    ),
)

// An L-shaped battle box (rect minus rect)
(
    shape: Subtract(
        Rect(half_size: (200.0, 100.0)),
        Rect(half_size: (100.0, 50.0)),
    ),
)

// Two circles smoothly merged
(
    shape: SmoothUnion(
        a: Circle(radius: 80.0),
        b: Circle(radius: 60.0),
        smoothness: 20.0,
    ),
)

Shared SDF for rendering and collision

The same BattleBoxShape should drive both:

  1. Collision: CPU-side sdf_distance() + constrain_point()
  2. Rendering: Generate matching shader parameters for the SDF material pipeline

This eliminates the inconsistency where rendering shows one shape but collision uses another.

βš– Alternatives Considered

  • UCT's BΓ©zier curve deformation: The predecessor project UCT uses n-degree Bernstein polynomials in BoxDrawer.cs (760 lines) to deform battle box edges into smooth curves. This requires: vertex generation β†’ triangulation (via LibTessDotNet) β†’ edge collider β†’ polygon collision testing. The SDF approach is significantly simpler and more performant:

    Aspect UCT Polygon CSG souprune SDF Composition
    Code size ~400 lines (BoxService) ~50 lines
    Collision Triangulation + point-in-polygon Direct distance function
    Render consistency Separate data paths Same SDF for both
    Composition ops FindIntersections + ProcessPolygons min(a,b) / max(a,-b)
    GPU compatibility Must transfer polygon vertices SDF computed natively in shader
    Shape extensibility Requires rewriting triangulation Add a new SDF primitive
  • Polygon colliders (general purpose): More universally applicable but far more complex to implement, and inconsistent with souprune's existing SDF rendering architecture.

πŸ“ Additional Information

Depends on: Issue #84 (multi battle box β€” BattleBoxBoundary needs to be extended/replaced by BattleBoxShape)

Files to modify:

  • crates/souprune/src/core/collision/battle_collision.rs β€” Add BattleBoxShape enum, gradient, and constrain methods
  • crates/souprune/src/core/collision/systems.rs β€” Existing sdf_box() (lines 184-195) can be reused as the Rect variant
  • crates/souprune/src/app_state/battle/collision.rs β€” Update constrain_player_to_battle_box_system to use BattleBoxShape
  • SDF shader files β€” Optional: sync rendering side with new shapes

Existing SDF infrastructure in souprune:

  • bevy_alight_motion/src/sdf.rs: SDF rendering module, BASE_HALF_EXTENT=50.0
  • bevy_alight_motion/src/sdf_material.rs: SDF Material + Shader system
  • crates/souprune/src/core/view/sdf_shape.rs: View system SDF integration
  • crates/souprune/src/core/collision/systems.rs:184-195: Existing sdf_box() implementation (Inigo Quilez formula)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions