Project Immersive

Developing a fully software-rendered 3D game engine entirely from scratch in C



game-engine / devlog / player-collider

Описание коллайдера игрока: комбинированный подход к обработке коллизий


Для представления коллайдера игрока в играх часто используют капсулы, однако сколько бы я ни пытался реализовать алгоритм столкновения капсулы с треугольником (Capsule vs Triangle), результат всегда получался громоздким и не таким лаконичным, как при использовании сфер. Но всё-таки мне было важно проработать код коллизий игрока максимально качественно, потому что я считаю, что коллизии — это одна из самых важных вещей в игре, плохая реализация которых может в любой момент испортить погружение.

Решено использовать сферы

После экспериментов с различными подходами, я пришёл к решению: коллайдер игрока будет состоять из трёх сфер, и для каждой из них будет применяться свой алгоритм:

  • Алгоритм А — дискретное обнаружение пересечения сферы с геометрией (Discrete collision detection)
  • Алгоритм Б — предсказание когда сфера ударится о геометрию (Continuous collision detection)

Расположение и предназначение сфер

  1. Сфера в голове игрока (алгоритм А):
    В её центре находится камера. Её задача — не позволять камере проваливаться в текстуры (например, в стены или потолок).

  2. Сфера в теле (алгоритм Б):
    Основная сфера, отвечающая за движение. Она позволяет игроку скользить вдоль поверхностей и предотвращает клиппинг сквозь геометрию, с какой бы скоростью игрок не двигался.

  3. Сфера в ногах (алгоритм А):
    Сфера в ногах следит за тем, чтобы игрок нигде не застревал и у него всегда была возможность двигаться.

Дополнительный элемент: Луч из ног

На самом деле этот луч исходит приблизительно из уровня коленей. Он мониторит уровень земли под ногами, не давая игроку упасть под карту, и даёт возможность беспрепятственно ходить по ступенькам. Он так же может быть полезен, если в будущем мне потребуется определять на какой поверхности стоит игрок в данный момент. Для него я написал специализированный алгоритм рейкастинга, оптимизированный с учетом того, что луч направлен строго вниз по оси Y.

Алгоритм рейкастинга для луча направленного строго вниз по оси Y
BOOL DownwardRayVsTriangle(Point ray_originTriangle tfloat *out_distance)
{
    
// Calculate edges
    
Vector ab Vector_Sub(t.bt.a);
    
Vector ac Vector_Sub(t.ct.a);

    
// determinant = dot(ab, p)
    
float det ab.* -ac.ab.ac.x;
    if (
det 0.0f) {
        return 
FALSE// triangle is degenerate or backfacing
    
}

    
// u = dot(s, p) * inv_det
    
float inv_det 1.0f det;
    
Vector s Vector_Sub(ray_origint.a);
    
float u = (s.* -ac.s.ac.x) * inv_det;
    if (
0.0f || 1.0f) {
        return 
FALSE;
    }

    
// q = cross(s, ab)
    
Vector q Vector_Cross(sab);
    
float v = -q.inv_det;
    if (
0.0f || (v) > 1.0f) {
        return 
FALSE;
    }

    
// t = dot(ac, q)
    
*out_distance Vector_Dot(acq) * inv_det;
    return *
out_distance >= 0.0f;
}

Сферы в голове и ногах позволяют компенсировать неточности алгоритма Б

Плюс ко всему стоит отметить, что есть сценарии, с которыми алгоритм Б не способен справиться. Например, если игрок падает вдоль стены, то алгоритм Б может ошибочно посчитать, что столкновения не произойдёт (из-за параллельного движения). Может возникнуть ситуация, когда сфера окажется внутри геометрии и алгоритм Б не сможет определить коллизию. Но дискретные сферы в голове и ногах подстраховывают от таких сценариев.

Реализация в коде

Псевдокод моей реализации этой системы:

BOOL void Player_MoveAndCollide(Entity *playerVector movement)
{
    
//
    // Apply movement and check for collisions along velocity
    //
    
Vector movement_remaining movement;
    for (
int i 02; ++i) { // max bounces = 2
        
float movement_distance Vector_Length(movement_remaining);
        if (
movement_distance <= COLLISION_EPSILON) {
            break; 
// no more movement needed
        
}
        
Vector movement_direction Vector_Normalize(movement_remaining);

        const 
Sphere spheres[] = {
            (
Sphere){ Player_GetHeadPosition(player), PLAYER_RADIUS },
            (
Sphere){ Player_GetFootPosition(player), PLAYER_RADIUS }
        };
        
CollisionResult collision FindSweptCollision(spheresARRAYSIZE(spheres), movement_directionmovement_distance);
        if (
collision.hit == FALSE) {
            
// No collision: move freely along full velocity
            
Player_ApplyMovement(playermovement_remaining);
            break;
        }

        
// Move up to collision point (with small offset to prevent sticking)
        
float safe_distance fmaxf(0.0fcollision.distance COLLISION_CONTACT_OFFSET);
        
Vector safe_movement Vector_Mul(movement_directionsafe_distance);
        
Player_ApplyMovement(playersafe_movement);

        
// Remove Y component to prevent player from sinking into a narrowing space
        
Vector slide_normal = (Vector){ collision.normal.x0.0fcollision.normal.};
        
slide_normal Vector_Normalize(slide_normal);

        
// Slide along wall/floor by projecting movement vector on plane normal
        
float slide_amount Vector_Dot(movement_remainingslide_normal);
        
movement_remaining Vector_Sub(movement_remainingVector_Mul(slide_normalslide_amount));
    }

    
//
    // Apply gravity and check for ground collision
    //
    
static float player_ground_level 0.0f;
    static 
float player_vertical_velocity 0.0f;

    
// Find ground level
    
Point ground_check_origin = (Point){
        
player->x,
        
player->PLAYER_STEP_HEIGHT,
        
player->z
    
};
    
float ground_dist;
    if (
FindGroundCollision(ground_check_origin, &ground_dist)) {
        
player_ground_level = (ground_check_origin.ground_dist) + COLLISION_CONTACT_OFFSET// calculate a new ground level
    
}

    
// Apply gravity
    
player_vertical_velocity -= 1.0f// apply gravity
    
player->+= player_vertical_velocity time_delta;

    
// Prevent falling through the ground
    
if (player->player_ground_level) {
        
player->player_ground_level;
        
player_vertical_velocity 0// reset velocity
    
}

    
//
    // Verify that player is not stuck
    //
    
Capsule capsule = {
        .
p1 Player_GetFootPosition(player),
        .
p2 Player_GetHeadPosition(player),
        .
radius PLAYER_RADIUS
    
};
    
CollisionResult collision FindPenetratingCollision(capsule);
    if (
collision.hit) {
        
Vector push Vector_Mul(collision.normalcollision.distance);
        
Player_ApplyMovement(playerpush);
    }
}

Такой гибридный подход показал высокую производительность и стабильность. Я планирую использовать эту систему для коллизий игрока в своём проекте, в то время как для физики твердых тел мне приглянулся метод SDF (Signed Distance Field) Collision Detection, но об этом в следующей заметке.