spherecast

wip first person shooter engine
Download | Log | Files | Refs

commit 0c1d771d0aac601d906647dee11e80b5c0c5b9d4
Author: amrfti <andrew@kloet.net>
Date:   Sun, 28 Dec 2025 11:48:54 -0500

initial commit

Diffstat:
AMakefile | 24++++++++++++++++++++++++
Aassets/level.glb | 0
Aassets/model.glb | 0
Aconfig.h | 38++++++++++++++++++++++++++++++++++++++
Amain.c | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aphysics.c | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aphysics.h | 38++++++++++++++++++++++++++++++++++++++
7 files changed, 516 insertions(+), 0 deletions(-)

diff --git a/Makefile b/Makefile @@ -0,0 +1,24 @@ +CC = cc +CFLAGS = -Wall -Wextra -pedantic -std=c99 -O3 +LDFLAGS = -lraylib -lm + +SRC = main.c physics.c +OBJ = $(SRC:.c=.o) + +EXE = main + +all: $(EXE) + +$(EXE): $(OBJ) + $(CC) $(OBJ) -o $(EXE) $(LDFLAGS) + +%.o: %.c + $(CC) $(CFLAGS) -c $< -o $@ + +clean: + rm -f $(OBJ) $(EXE) + +run: $(EXE) + ./$(EXE) + +.PHONY: all clean run diff --git a/assets/level.glb b/assets/level.glb Binary files differ. diff --git a/assets/model.glb b/assets/model.glb Binary files differ. diff --git a/config.h b/config.h @@ -0,0 +1,38 @@ +#pragma once + +// window +#define WIDTH 1000 +#define HEIGHT 800 +#define TARGET_FPS 144 + +// physics +#define MAX_SLIDES 4 +#define SKIN 0.001f +#define GRAVITY 45.0f +#define MAX_GROUND_ANGLE_DEG 50.0f +#define GROUND_NORMAL_Y (cosf(MAX_GROUND_ANGLE_DEG * DEG2RAD)) +#define DOWN (Vector3){0, -1, 0} +#define UP (Vector3){0, 1, 0} +#define GROUND_SNAP_DIST 0.5f +#define COYOTE_TIME 0.1f + +// movement +#define STEP_HEIGHT 0.4f +#define STEP_ITERATIONS 2 +#define PLAYER_RADIUS 0.3f +#define WALK_SPEED 4.0f +#define SPRINT_SPEED 7.0f +#define JUMP_FORCE 10.0f +#define ACCELERATION 10.0f +#define FRICTION 10.0f +#define AIR_ACCELERATION ACCELERATION // could change control in air + +// camera +#define BASE_FOV 70.0f +#define SPRINT_FOV 80.0f +#define MAX_PITCH 1.5f +#define FOV_LERP_SPEED 30.0f +#define ROLL_FACTOR 5.0f +#define ROLL_LERP_SPEED 20.0f +#define MOUSE_SENSITIVITY 0.003f +#define SPRINT_FOV_LERP_SPEED 10.0f diff --git a/main.c b/main.c @@ -0,0 +1,184 @@ +#include <float.h> +#include <math.h> +#include <raylib.h> +#include <raymath.h> +#include <stdlib.h> + +#include "config.h" +#include "physics.h" + +#define UNGROUND_VELOCITY_EPSILON 0.5f + +float camYaw = 0.0f; +float camPitch = 0.0f; +float camRoll = 0.0f; + +Triangle *BuildTriangleCache(Model *model, int *outCount) { + int total = 0; + for (int m = 0; m < model->meshCount; m++) + total += model->meshes[m].triangleCount; + + Triangle *tris = malloc(sizeof(Triangle) * total); + int t = 0; + + for (int m = 0; m < model->meshCount; m++) { + Mesh mesh = model->meshes[m]; + float *v = mesh.vertices; + unsigned short *idx = mesh.indices; + + for (int i = 0; i < mesh.triangleCount; i++) { + Vector3 a = {v[idx[i * 3 + 0] * 3 + 0], v[idx[i * 3 + 0] * 3 + 1], + v[idx[i * 3 + 0] * 3 + 2]}; + Vector3 b = {v[idx[i * 3 + 1] * 3 + 0], v[idx[i * 3 + 1] * 3 + 1], + v[idx[i * 3 + 1] * 3 + 2]}; + Vector3 c = {v[idx[i * 3 + 2] * 3 + 0], v[idx[i * 3 + 2] * 3 + 1], + v[idx[i * 3 + 2] * 3 + 2]}; + + Vector3 normal = Vector3Normalize( + Vector3CrossProduct(Vector3Subtract(b, a), Vector3Subtract(c, a))); + + Vector3 min = {fminf(a.x, fminf(b.x, c.x)), fminf(a.y, fminf(b.y, c.y)), + fminf(a.z, fminf(b.z, c.z))}; + Vector3 max = {fmaxf(a.x, fmaxf(b.x, c.x)), fmaxf(a.y, fmaxf(b.y, c.y)), + fmaxf(a.z, fmaxf(b.z, c.z))}; + + tris[t++] = (Triangle){a, b, c, normal, min, max}; + } + } + + *outCount = total; + return tris; +} + +int main(void) { + InitWindow(WIDTH, HEIGHT, "Sphere Character Controller"); + DisableCursor(); + SetTargetFPS(TARGET_FPS); + SetConfigFlags(FLAG_VSYNC_HINT); + + Camera3D camera = {.position = {0, 0, 0}, + .target = {0, 0, 1}, + .up = UP, + .fovy = BASE_FOV, + .projection = CAMERA_PERSPECTIVE}; + + Model model = LoadModel("assets/level.glb"); + + int triCount = 0; + Triangle *triangles = BuildTriangleCache(&model, &triCount); + + Vector3 playerPos = {0, 0, 0}; + Vector3 velocity = {0}; + float radius = PLAYER_RADIUS; + bool grounded = false; + + while (!WindowShouldClose()) { + float dt = GetFrameTime(); + + Vector2 mouseDelta = GetMouseDelta(); + camYaw -= mouseDelta.x * MOUSE_SENSITIVITY; + camPitch -= mouseDelta.y * MOUSE_SENSITIVITY; + camPitch = Clamp(camPitch, -MAX_PITCH, MAX_PITCH); + + Vector3 forward = {cosf(camPitch) * sinf(camYaw), sinf(camPitch), + cosf(camPitch) * cosf(camYaw)}; + + Vector3 right = {sinf(camYaw - PI / 2.0f), 0.0f, cosf(camYaw - PI / 2.0f)}; + + Vector3 input = {0}; + if (IsKeyDown(KEY_W)) + input.z += 1; + if (IsKeyDown(KEY_S)) + input.z -= 1; + if (IsKeyDown(KEY_D)) + input.x += 1; + if (IsKeyDown(KEY_A)) + input.x -= 1; + + bool sprinting = IsKeyDown(KEY_LEFT_SHIFT); + float speed = sprinting ? SPRINT_SPEED : WALK_SPEED; + + Vector3 targetVel = {0}; + + if (Vector3Length(input) > 0) { + input = Vector3Normalize(input); + Vector3 wishDir = Vector3Add(Vector3Scale(forward, input.z), + Vector3Scale(right, input.x)); + wishDir.y = 0; + wishDir = Vector3Normalize(wishDir); + + targetVel.x = wishDir.x * speed; + targetVel.z = wishDir.z * speed; + } + + velocity.x = targetVel.x; + velocity.z = targetVel.z; + + if (grounded && IsKeyPressed(KEY_SPACE)) + velocity.y = JUMP_FORCE; + + if (IsKeyPressed(KEY_R)) { + playerPos = (Vector3){0, 0, 0}; + velocity = (Vector3){0}; + } + + Physics_MoveCharacter(&playerPos, &velocity, dt, radius, triangles, + triCount, &grounded); + + Vector3 eyePos = Vector3Add(playerPos, (Vector3){0, radius * 2.0f, 0}); + + camera.position = eyePos; + camera.target = Vector3Add(eyePos, forward); + + Vector3 forwardDir = + Vector3Normalize(Vector3Subtract(camera.target, camera.position)); + Vector3 rightDir = + Vector3Normalize(Vector3CrossProduct(forwardDir, (Vector3){0, 1, 0})); + + float lateralSpeed = Vector3DotProduct(velocity, rightDir); + float targetRoll = lateralSpeed * ROLL_FACTOR / SPRINT_SPEED; + camRoll = Lerp(camRoll, targetRoll, dt * ROLL_LERP_SPEED); + + camera.fovy = + Lerp(camera.fovy, + sprinting && Vector3Length(input) > 0 ? SPRINT_FOV : BASE_FOV, + dt * SPRINT_FOV_LERP_SPEED); + + float rad = camRoll * DEG2RAD; + + Vector3 rotatedUp = { + UP.x * cosf(rad) + + (forwardDir.y * UP.z - forwardDir.z * UP.y) * sinf(rad), + UP.y * cosf(rad) + + (forwardDir.z * UP.x - forwardDir.x * UP.z) * sinf(rad), + UP.z * cosf(rad) + + (forwardDir.x * UP.y - forwardDir.y * UP.x) * sinf(rad)}; + + camera.up = Vector3Normalize(rotatedUp); + + BeginDrawing(); + ClearBackground(RAYWHITE); + BeginMode3D(camera); + + DrawModel(model, Vector3Zero(), 1.0f, WHITE); + + EndMode3D(); + + DrawText(TextFormat("Pos: %.2f %.2f %.2f", playerPos.x, playerPos.y, + playerPos.z), + 10, 10, 20, BLACK); + + DrawText( + TextFormat("Vel: %.4f %.4f %.4f", velocity.x, velocity.y, velocity.z), + 10, 30, 20, BLACK); + + DrawText(TextFormat("Grounded: %d", grounded), 10, 50, 20, BLACK); + + EndDrawing(); + } + + UnloadModel(model); + free(triangles); + CloseWindow(); + return 0; +} diff --git a/physics.c b/physics.c @@ -0,0 +1,232 @@ +#include "physics.h" +#include "config.h" +#include <float.h> +#include <raymath.h> + +#define SQR(x) ((x) * (x)) + +static inline bool TriangleLikelyContains(Vector3 p, float r, + const Triangle *t) { + float r2 = r + SKIN; + + if (p.x + r2 < fminf(fminf(t->a.x, t->b.x), t->c.x)) + return false; + if (p.x - r2 > fmaxf(fmaxf(t->a.x, t->b.x), t->c.x)) + return false; + if (p.z + r2 < fminf(fminf(t->a.z, t->b.z), t->c.z)) + return false; + if (p.z - r2 > fmaxf(fmaxf(t->a.z, t->b.z), t->c.z)) + return false; + if (p.y + r2 < fminf(fminf(t->a.y, t->b.y), t->c.y)) + return false; + if (p.y - r2 > fmaxf(fmaxf(t->a.y, t->b.y), t->c.y)) + return false; + + return true; +} + +Vector3 ClosestPointOnTriangle(Vector3 p, Vector3 a, Vector3 b, Vector3 c) { + Vector3 ab = Vector3Subtract(b, a); + Vector3 ac = Vector3Subtract(c, a); + Vector3 ap = Vector3Subtract(p, a); + float d1 = Vector3DotProduct(ab, ap); + float d2 = Vector3DotProduct(ac, ap); + if (d1 <= 0.0f && d2 <= 0.0f) + return a; + + Vector3 bp = Vector3Subtract(p, b); + float d3 = Vector3DotProduct(ab, bp); + float d4 = Vector3DotProduct(ac, bp); + if (d3 >= 0.0f && d4 <= d3) + return b; + + float vc = d1 * d4 - d3 * d2; + if (vc <= 0.0f && d1 >= 0.0f && d3 <= 0.0f) + return Vector3Add(a, Vector3Scale(ab, d1 / (d1 - d3))); + + Vector3 cp = Vector3Subtract(p, c); + float d5 = Vector3DotProduct(ab, cp); + float d6 = Vector3DotProduct(ac, cp); + if (d6 >= 0.0f && d5 <= d6) + return c; + + float vb = d5 * d2 - d1 * d6; + if (vb <= 0.0f && d2 >= 0.0f && d6 <= 0.0f) + return Vector3Add(a, Vector3Scale(ac, d2 / (d2 - d6))); + + float va = d3 * d6 - d5 * d4; + if (va <= 0.0f && (d4 - d3) >= 0.0f && (d5 - d6) >= 0.0f) + return Vector3Add(b, Vector3Scale(Vector3Subtract(c, b), + (d4 - d3) / ((d4 - d3) + (d5 - d6)))); + + float denom = 1.0f / (va + vb + vc); + return Vector3Add(a, Vector3Add(Vector3Scale(ab, vb * denom), + Vector3Scale(ac, vc * denom))); +} + +bool SphereCastTriangle(Vector3 pos, Vector3 dir, float radius, float maxDist, + const Triangle *tri, HitInfo *hit) { + Vector3 n = tri->normal; + + // 1. Intersect with the infinite plane + float denom = Vector3DotProduct(n, dir); + + if (denom >= -EPSILON) + return false; + + float distToPlane = Vector3DotProduct(n, Vector3Subtract(tri->a, pos)); + float tPlane = (distToPlane + radius) / denom; + + if (tPlane > maxDist) + return false; + + // 2. Determine where the sphere touches the plane + Vector3 planeIntersectionPoint = Vector3Add(pos, Vector3Scale(dir, tPlane)); + Vector3 centerOnPlane = + Vector3Subtract(planeIntersectionPoint, Vector3Scale(n, radius)); + + // 3. Find closest point on the actual triangle + Vector3 closestPt = + ClosestPointOnTriangle(centerOnPlane, tri->a, tri->b, tri->c); + + // 4. Check distance + float distSq = Vector3DistanceSqr(centerOnPlane, closestPt); + + // CASE A: Hit the FACE + if (distSq < EPSILON) { + if (tPlane < 0) + return false; + + hit->hit = true; + hit->distance = tPlane; + hit->normal = n; + return true; + } + + // CASE B: Sweep against Edge/Vertex + // Equation: |(pos + dir * t) - closestPt|^2 = radius^2 + Vector3 L = Vector3Subtract(pos, closestPt); + float a = 1.0f; + float b = 2.0f * Vector3DotProduct(L, dir); + float c = Vector3DotProduct(L, L) - SQR(radius); + + float delta = b * b - 4 * a * c; + + if (delta >= 0.0f) { + float sqrtDelta = sqrtf(delta); + float tEdge = (-b - sqrtDelta) / 2.0f; + + if (tEdge >= 0 && tEdge < maxDist) { + hit->hit = true; + hit->distance = tEdge; + + Vector3 hitPos = Vector3Add(pos, Vector3Scale(dir, tEdge)); + hit->normal = Vector3Normalize(Vector3Subtract(hitPos, closestPt)); + return true; + } + } + + return false; +} + +bool SphereCast(Vector3 pos, Vector3 dir, float dist, float radius, + Triangle *tris, int triCount, HitInfo *outHit) { + HitInfo best = {0}; + best.distance = dist; + bool found = false; + + for (int i = 0; i < triCount; i++) { + HitInfo h; + if (SphereCastTriangle(pos, dir, radius, best.distance, &tris[i], &h)) { + best = h; + found = true; + } + } + + if (!found) + return false; + + *outHit = best; + return true; +} + +void ResolveOverlaps(Vector3 *pos, float radius, Triangle *tris, int triCount, + bool *hitFloor) { + float rSq = SQR(radius); + + for (int iter = 0; iter < 2; iter++) { + bool collisionFound = false; + + for (int i = 0; i < triCount; i++) { + Triangle *t = &tris[i]; + + if (!TriangleLikelyContains(*pos, radius, t)) + continue; + + Vector3 closest = ClosestPointOnTriangle(*pos, t->a, t->b, t->c); + Vector3 pushVec = Vector3Subtract(*pos, closest); + float d2 = Vector3LengthSqr(pushVec); + + // allow for floating point error at the exact surface boundary + if (d2 > 0 && d2 < rSq + EPSILON) { + float d = sqrtf(d2); + Vector3 n = Vector3Scale(pushVec, 1.0f / d); + + // push out + float penetration = radius - d + SKIN; + *pos = Vector3Add(*pos, Vector3Scale(n, penetration)); + + if (n.y > GROUND_NORMAL_Y) + *hitFloor = true; + else if (t->normal.y > GROUND_NORMAL_Y && n.y > EPSILON) + *hitFloor = true; + + collisionFound = true; + } + } + + if (!collisionFound) + break; + } +} + +void SnapToGround(Vector3 *pos, float radius, Triangle *tris, int triCount, + bool *touchingGround) { + HitInfo hit; + if (!SphereCast(*pos, DOWN, GROUND_SNAP_DIST, radius, tris, triCount, &hit)) + return; + if (hit.normal.y < GROUND_NORMAL_Y) + return; + *pos = Vector3Add(*pos, Vector3Scale(DOWN, hit.distance)); + *touchingGround = true; +} + +void Physics_MoveCharacter(Vector3 *pos, Vector3 *velocity, float dt, + float radius, Triangle *tris, int triCount, + bool *groundedOut) { + static float groundedTimer = 0.0f; + + Vector3 moveStep = Vector3Scale(*velocity, dt); + *pos = Vector3Add(*pos, moveStep); + + groundedTimer -= dt; + + if (groundedTimer < 0) + groundedTimer = 0; + + bool effectivelyGrounded = (groundedTimer > 0); + + bool touchingGround = false; + ResolveOverlaps(pos, radius, tris, triCount, &touchingGround); + + if (effectivelyGrounded && velocity->y <= 0) { + SnapToGround(pos, radius, tris, triCount, &touchingGround); + velocity->y = 0; + } else + velocity->y -= GRAVITY * dt; + + if (touchingGround) + groundedTimer = COYOTE_TIME; + + *groundedOut = effectivelyGrounded; +} diff --git a/physics.h b/physics.h @@ -0,0 +1,38 @@ +#pragma once + +#include <raymath.h> +#include <stdbool.h> +#define MAX_ITERS 3 + +typedef struct { + bool hit; + float distance; + Vector3 normal; +} HitInfo; + +typedef struct { + Vector3 a, b, c; + Vector3 normal; + Vector3 min; + Vector3 max; +} Triangle; + +Vector3 ClosestPointOnTriangle(Vector3 p, Vector3 a, Vector3 b, Vector3 c); + +bool SphereCastTriangle(Vector3 pos, Vector3 dir, float radius, float dist, + const Triangle *tri, HitInfo *hit); + +bool SphereCast(Vector3 pos, Vector3 dir, float dist, float radius, + Triangle *tris, int triCount, HitInfo *outHit); + +void ResolveOverlaps(Vector3 *pos, float radius, Triangle *tris, int triCount, + bool *hitFloor); + +bool CheckGrounded(Vector3 pos, float radius, Triangle *tris, int triCount); + +void SnapToGround(Vector3 *pos, float radius, Triangle *tris, int triCount, + bool *hitFloor); + +void Physics_MoveCharacter(Vector3 *pos, Vector3 *velocity, float dt, + float radius, Triangle *tris, int triCount, + bool *groundedOut);