diff --git a/cmake/targets/psp.cmake b/cmake/targets/psp.cmake index 32a2b5a5..42d4cfcb 100644 --- a/cmake/targets/psp.cmake +++ b/cmake/targets/psp.cmake @@ -55,7 +55,6 @@ target_compile_definitions(${DUSK_BINARY_TARGET_NAME} PUBLIC DUSK_DISPLAY_WIDTH=480 DUSK_DISPLAY_HEIGHT=272 DUSK_THREAD_PTHREAD - DUSK_TIME_DYNAMIC DUSK_DISPLAY_OVERSCAN=6 ) diff --git a/src/dusk/engine/engine.c b/src/dusk/engine/engine.c index 567c7d03..9052d5e5 100644 --- a/src/dusk/engine/engine.c +++ b/src/dusk/engine/engine.c @@ -72,6 +72,10 @@ errorret_t engineInit(const int32_t argc, const char_t **argv) { entityAddComponent( testEntities, ENGINE.testCubeEntity, COMPONENT_TYPE_RENDERABLE ); + entityUpdateAdd( + testEntities, ENGINE.testCubeEntity, engineTestCubeRotate, + ENGINE.testCubePositionComponent, NULL + ); networkRequestConnection( engineNetworkOnConnected, @@ -111,15 +115,6 @@ errorret_t engineUpdate(void) { inputUpdate(); consoleUpdate(); - // Test: spin the cube. - ENGINE.testCubeRotation += TIME.delta; - entityPositionSetLocalRotation( - sceneGetEntities(ENGINE.testSceneId), - ENGINE.testCubeEntity, - ENGINE.testCubePositionComponent, - (vec3){ 0.0f, ENGINE.testCubeRotation, 0.0f } - ); - errorChain(sceneUpdate()); errorChain(assetUpdate()); errorChain(uiUpdate()); @@ -212,3 +207,16 @@ void engineNetworkOnDisconnect(errorret_t error, void *user) { void engineNetworkDisconnectTestOnComplete(void *user) { consolePrint("Network disconnect test complete"); } + +void engineTestCubeRotate( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + void *user +) { + ENGINE.testCubeRotation += TIME.delta; + entityPositionSetLocalRotation( + mgr, entityId, componentId, + (vec3){ 0.0f, ENGINE.testCubeRotation, 0.0f } + ); +} diff --git a/src/dusk/engine/engine.h b/src/dusk/engine/engine.h index 369d31cb..3e4b0c38 100644 --- a/src/dusk/engine/engine.h +++ b/src/dusk/engine/engine.h @@ -98,3 +98,21 @@ void engineNetworkOnDisconnect(errorret_t error, void *user); * @param user Unused. */ void engineNetworkDisconnectTestOnComplete(void *user); + +/** + * Entity update callback that spins the test cube around Y. Registered + * once in engineInit() via entityUpdateAdd(), so it only runs from + * sceneFixedUpdate() -- once per fixed timestep, never once per rendered + * frame. + * + * @param mgr The entity manager that owns the entity. + * @param entityId The test cube's entity ID. + * @param componentId The test cube's position component ID. + * @param user Unused. + */ +void engineTestCubeRotate( + entitymanager_t *mgr, + const entityid_t entityId, + const componentid_t componentId, + void *user +); diff --git a/src/dusk/entity/entity.c b/src/dusk/entity/entity.c index 392a5e3d..70026036 100644 --- a/src/dusk/entity/entity.c +++ b/src/dusk/entity/entity.c @@ -86,7 +86,9 @@ void entityDisposeDeep(entitymanager_t *mgr, const entityid_t entityId) { void entityUpdate(entitymanager_t *mgr, const entityid_t entityId) { entity_t *ent = &mgr->entities[entityId]; for(uint8_t i = 0; i < ent->updateCount; i++) { - ent->onUpdate[i](entityId, ent->updateComponentId[i], ent->updateUser[i]); + ent->onUpdate[i]( + mgr, entityId, ent->updateComponentId[i], ent->updateUser[i] + ); } } @@ -96,7 +98,7 @@ void entityDispose(entitymanager_t *mgr, const entityid_t entityId) { for(uint8_t i = 0; i < ent->disposeCount; i++) { ent->onDispose[i]( - entityId, ent->disposeComponentId[i], ent->disposeUser[i] + mgr, entityId, ent->disposeComponentId[i], ent->disposeUser[i] ); } diff --git a/src/dusk/entity/entity.h b/src/dusk/entity/entity.h index a1b02f82..b67a441c 100644 --- a/src/dusk/entity/entity.h +++ b/src/dusk/entity/entity.h @@ -14,6 +14,7 @@ #define ENTITY_DISPOSE_CALLBACK_COUNT_MAX 5 typedef void (*entitycallback_t)( + entitymanager_t *mgr, const entityid_t entityId, const componentid_t componentId, void *user diff --git a/src/dusk/physics/CMakeLists.txt b/src/dusk/physics/CMakeLists.txt index 3eeca058..1de6a434 100644 --- a/src/dusk/physics/CMakeLists.txt +++ b/src/dusk/physics/CMakeLists.txt @@ -8,4 +8,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} PUBLIC physicsworld.c physicstest.c + physicsshapemesh.c ) diff --git a/src/dusk/physics/physicsshapemesh.c b/src/dusk/physics/physicsshapemesh.c new file mode 100644 index 00000000..e91c05dd --- /dev/null +++ b/src/dusk/physics/physicsshapemesh.c @@ -0,0 +1,151 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "physicsshapemesh.h" +#include "physicstest.h" +#include "assert/assert.h" + +void physicsShapeMeshGetVertex( + const physicsshapemesh_t *mesh, const uint32_t index, vec3 out +) { + assertNotNull(mesh, "Mesh cannot be null"); + assertTrue(index < mesh->triangleCount * 3, "Vertex index OOB"); + + const float_t *p = (const float_t *)(mesh->vertices + (size_t)index * mesh->stride); + out[0] = p[0]; + out[1] = p[1]; + out[2] = p[2]; +} + +bool_t physicsShapeMeshTestSphere( + const physicsshapemesh_t *mesh, const vec3 meshPos, + const vec3 sphereCenter, const float_t sphereRadius, + vec3 outNormal, float_t *outDepth +) { + assertNotNull(mesh, "Mesh cannot be null"); + + bool_t found = false; + float_t bestDist2 = 0.0f; + vec3 bestPoint; + + for(uint32_t i = 0; i < mesh->triangleCount; i++) { + vec3 a, b, c; + physicsShapeMeshGetVertex(mesh, i * 3 + 0, a); + physicsShapeMeshGetVertex(mesh, i * 3 + 1, b); + physicsShapeMeshGetVertex(mesh, i * 3 + 2, c); + glm_vec3_add(a, (float_t *)meshPos, a); + glm_vec3_add(b, (float_t *)meshPos, b); + glm_vec3_add(c, (float_t *)meshPos, c); + + vec3 closest; + physicsTestClosestPointOnTriangle(sphereCenter, a, b, c, closest); + + vec3 diff; + glm_vec3_sub((float_t *)sphereCenter, closest, diff); + float_t dist2 = glm_vec3_norm2(diff); + if(found && dist2 >= bestDist2) continue; + + found = true; + bestDist2 = dist2; + glm_vec3_copy(closest, bestPoint); + } + + if(!found || bestDist2 >= sphereRadius * sphereRadius) return false; + + float_t dist = sqrtf(bestDist2); + *outDepth = sphereRadius - dist; + + vec3 diff; + glm_vec3_sub((float_t *)sphereCenter, bestPoint, diff); + if(dist > 1e-6f) { + glm_vec3_scale(diff, 1.0f / dist, outNormal); + } else { + outNormal[0] = 0.0f; + outNormal[1] = 1.0f; + outNormal[2] = 0.0f; + } + return true; +} + +bool_t physicsShapeMeshTestCapsule( + const physicsshapemesh_t *mesh, const vec3 meshPos, + const vec3 capsuleCenter, const float_t capsuleRadius, + const float_t capsuleHalfHeight, + vec3 outNormal, float_t *outDepth +) { + vec3 capA = { + capsuleCenter[0], capsuleCenter[1] - capsuleHalfHeight, capsuleCenter[2] + }; + vec3 capB = { + capsuleCenter[0], capsuleCenter[1] + capsuleHalfHeight, capsuleCenter[2] + }; + + int32_t sampleCount = capsuleRadius > 1e-6f + ? (int32_t)ceilf((2.0f * capsuleHalfHeight) / capsuleRadius) + 1 + : 2; + if(sampleCount < 2) sampleCount = 2; + if(sampleCount > PHYSICS_SHAPE_MESH_CAPSULE_SAMPLES_MAX) { + sampleCount = PHYSICS_SHAPE_MESH_CAPSULE_SAMPLES_MAX; + } + + bool_t found = false; + vec3 bestNormal = { 0.0f, 1.0f, 0.0f }; + float_t bestDepth = 0.0f; + + for(int32_t i = 0; i < sampleCount; i++) { + float_t t = (float_t)i / (float_t)(sampleCount - 1); + vec3 samplePoint; + glm_vec3_lerp(capA, capB, t, samplePoint); + + vec3 normal; float_t depth; + if(!physicsShapeMeshTestSphere( + mesh, meshPos, samplePoint, capsuleRadius, normal, &depth + )) continue; + + if(found && depth <= bestDepth) continue; + found = true; + bestDepth = depth; + glm_vec3_copy(normal, bestNormal); + } + + if(!found) return false; + *outDepth = bestDepth; + glm_vec3_copy(bestNormal, outNormal); + return true; +} + +bool_t physicsShapeMeshTest( + const vec3 selfPos, const void *selfData, + const vec3 otherPos, const physicsshape_t *otherShape, + vec3 outNormal, float_t *outDepth +) { + const physicsshapemesh_t *mesh = (const physicsshapemesh_t *)selfData; + + switch(otherShape->type) { + case PHYSICS_SHAPE_SPHERE: + return physicsShapeMeshTestSphere( + mesh, selfPos, otherPos, otherShape->data.sphere.radius, + outNormal, outDepth + ); + case PHYSICS_SHAPE_CAPSULE: + return physicsShapeMeshTestCapsule( + mesh, selfPos, otherPos, + otherShape->data.capsule.radius, otherShape->data.capsule.halfHeight, + outNormal, outDepth + ); + default: + return false; + } +} + +physicsshape_t physicsShapeMeshCreate(const physicsshapemesh_t *mesh) { + assertNotNull(mesh, "Mesh cannot be null"); + return (physicsshape_t){ + .type = PHYSICS_SHAPE_CUSTOM, + .data.custom = { .userData = (void *)mesh, .test = physicsShapeMeshTest } + }; +} diff --git a/src/dusk/physics/physicsshapemesh.h b/src/dusk/physics/physicsshapemesh.h new file mode 100644 index 00000000..37f882bc --- /dev/null +++ b/src/dusk/physics/physicsshapemesh.h @@ -0,0 +1,128 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "physicsshape.h" + +/** + * Capsule-vs-mesh is approximated by sampling spheres along the capsule's + * axis (see physicsShapeMeshTestCapsule). This bounds how many samples a + * single query can take, regardless of how tall/thin the capsule is. + */ +#define PHYSICS_SHAPE_MESH_CAPSULE_SAMPLES_MAX 8 + +/** + * Describes an arbitrary triangle mesh (e.g. landscape/terrain geometry) + * for use as a PHYSICS_SHAPE_CUSTOM shape via physicsShapeMeshCreate(). + * + * Vertex positions are read directly out of `vertices` at `stride`-byte + * intervals, so this can point straight at an existing render vertex + * buffer (e.g. `&myMeshVertices[0].pos` with `stride = sizeof(meshvertex_t)`) + * with no need to duplicate the geometry for physics -- as long as each + * vertex's position is 3 consecutive float_t. + * + * Not copied or owned: this struct (and the buffer it points into) must + * outlive any physicsshape_t built from it, so it's typically a field + * alongside whatever owns the mesh (e.g. a terrain chunk). + * + * Every query scans all triangleCount triangles (no broad-phase/BVH), so + * for good performance keep each mesh shape scoped to one reasonably-sized + * chunk rather than an entire world, and only add nearby chunks' physics + * bodies to the scene. + */ +typedef struct { + /** Pointer to the first triangle vertex's position (3 consecutive float_t). */ + const uint8_t *vertices; + /** Byte stride between consecutive vertex positions. */ + size_t stride; + /** Number of triangles. `vertices` holds triangleCount * 3 positions. */ + uint32_t triangleCount; +} physicsshapemesh_t; + +/** + * Reads the world-space position of a single mesh vertex (vertex index, + * not triangle index -- pass triangleIndex * 3 + 0/1/2 for a triangle's + * corners). + * + * @param mesh The mesh to read from. + * @param index Vertex index, in [0, triangleCount * 3). + * @param out Receives the vertex position. + */ +void physicsShapeMeshGetVertex( + const physicsshapemesh_t *mesh, const uint32_t index, vec3 out +); + +/** + * Tests a sphere against every triangle in the mesh, keeping only the + * single closest triangle. outNormal points from the mesh surface toward + * the sphere (self toward other, per physicsshapecustomtest_t). + * + * @param mesh The mesh to test against. + * @param meshPos World-space position of the mesh shape's owning entity; + * added to every vertex read from mesh. + * @param sphereCenter World-space center of the sphere. + * @param sphereRadius Radius of the sphere. + * @param outNormal Push-out normal (mesh surface toward sphere). + * @param outDepth Penetration depth. + * @return true if the sphere overlaps the mesh. + */ +bool_t physicsShapeMeshTestSphere( + const physicsshapemesh_t *mesh, const vec3 meshPos, + const vec3 sphereCenter, const float_t sphereRadius, + vec3 outNormal, float_t *outDepth +); + +/** + * Tests a Y-axis-aligned capsule against the mesh by approximating the + * capsule as a series of spheres sampled along its axis (see + * PHYSICS_SHAPE_MESH_CAPSULE_SAMPLES_MAX) and keeping the deepest overlap + * found. An approximation, not an exact capsule-vs-triangle test. + * + * @param mesh The mesh to test against. + * @param meshPos World-space position of the mesh shape's owning entity. + * @param capsuleCenter World-space center of the capsule. + * @param capsuleRadius Radius of the capsule. + * @param capsuleHalfHeight Half-height of the capsule's cylindrical segment. + * @param outNormal Push-out normal (mesh surface toward capsule). + * @param outDepth Penetration depth. + * @return true if the capsule overlaps the mesh. + */ +bool_t physicsShapeMeshTestCapsule( + const physicsshapemesh_t *mesh, const vec3 meshPos, + const vec3 capsuleCenter, const float_t capsuleRadius, + const float_t capsuleHalfHeight, + vec3 outNormal, float_t *outDepth +); + +/** + * physicsshapecustomtest_t implementation backing physicsShapeMeshCreate(). + * Routes to physicsShapeMeshTestSphere/TestCapsule depending on + * otherShape's type. Cube (AABB) bodies are not supported yet and never + * collide against a mesh shape. + * + * @param selfPos The mesh shape's position. + * @param selfData The physicsshapemesh_t this shape was created from. + * @param otherPos The other shape's position. + * @param otherShape The other shape's descriptor. + * @param outNormal Push-out normal (mesh surface toward other). + * @param outDepth Penetration depth. + * @return true if the shapes overlap. + */ +bool_t physicsShapeMeshTest( + const vec3 selfPos, const void *selfData, + const vec3 otherPos, const physicsshape_t *otherShape, + vec3 outNormal, float_t *outDepth +); + +/** + * Builds a PHYSICS_SHAPE_CUSTOM shape backed by the given triangle mesh. + * + * @param mesh Mesh descriptor. Must outlive the returned shape (see + * physicsshapemesh_t). + * @return A physicsshape_t wired to test against this mesh. + */ +physicsshape_t physicsShapeMeshCreate(const physicsshapemesh_t *mesh); diff --git a/src/dusk/physics/physicstest.c b/src/dusk/physics/physicstest.c index 714f03e0..8ab29307 100644 --- a/src/dusk/physics/physicstest.c +++ b/src/dusk/physics/physicstest.c @@ -148,6 +148,78 @@ void physicsTestClosestPointOnSegment( glm_vec3_lerp((float_t *)a, (float_t *)b, t, out); } +void physicsTestClosestPointOnTriangle( + const vec3 p, const vec3 a, const vec3 b, const vec3 c, vec3 out +) { + vec3 ab, ac, ap; + glm_vec3_sub((float_t *)b, (float_t *)a, ab); + glm_vec3_sub((float_t *)c, (float_t *)a, ac); + glm_vec3_sub((float_t *)p, (float_t *)a, ap); + + float_t d1 = glm_vec3_dot(ab, ap); + float_t d2 = glm_vec3_dot(ac, ap); + if(d1 <= 0.0f && d2 <= 0.0f) { + glm_vec3_copy((float_t *)a, out); + return; + } + + vec3 bp; + glm_vec3_sub((float_t *)p, (float_t *)b, bp); + float_t d3 = glm_vec3_dot(ab, bp); + float_t d4 = glm_vec3_dot(ac, bp); + if(d3 >= 0.0f && d4 <= d3) { + glm_vec3_copy((float_t *)b, out); + return; + } + + float_t vc = d1 * d4 - d3 * d2; + if(vc <= 0.0f && d1 >= 0.0f && d3 <= 0.0f) { + float_t v = d1 / (d1 - d3); + vec3 scaled; + glm_vec3_scale(ab, v, scaled); + glm_vec3_add((float_t *)a, scaled, out); + return; + } + + vec3 cp; + glm_vec3_sub((float_t *)p, (float_t *)c, cp); + float_t d5 = glm_vec3_dot(ab, cp); + float_t d6 = glm_vec3_dot(ac, cp); + if(d6 >= 0.0f && d5 <= d6) { + glm_vec3_copy((float_t *)c, out); + return; + } + + float_t vb = d5 * d2 - d1 * d6; + if(vb <= 0.0f && d2 >= 0.0f && d6 <= 0.0f) { + float_t w = d2 / (d2 - d6); + vec3 scaled; + glm_vec3_scale(ac, w, scaled); + glm_vec3_add((float_t *)a, scaled, out); + return; + } + + float_t va = d3 * d6 - d5 * d4; + if(va <= 0.0f && (d4 - d3) >= 0.0f && (d5 - d6) >= 0.0f) { + float_t w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); + vec3 bc, scaled; + glm_vec3_sub((float_t *)c, (float_t *)b, bc); + glm_vec3_scale(bc, w, scaled); + glm_vec3_add((float_t *)b, scaled, out); + return; + } + + // Interior: barycentric combination of ab and ac from a. + float_t denom = 1.0f / (va + vb + vc); + float_t v = vb * denom; + float_t w = vc * denom; + vec3 abScaled, acScaled, sum; + glm_vec3_scale(ab, v, abScaled); + glm_vec3_scale(ac, w, acScaled); + glm_vec3_add(abScaled, acScaled, sum); + glm_vec3_add((float_t *)a, sum, out); +} + void physicsTestClosestPointsBetweenSegments( const vec3 a1, const vec3 b1, const vec3 a2, const vec3 b2, diff --git a/src/dusk/physics/physicstest.h b/src/dusk/physics/physicstest.h index debb5860..3cca1772 100644 --- a/src/dusk/physics/physicstest.h +++ b/src/dusk/physics/physicstest.h @@ -110,6 +110,21 @@ void physicsTestClosestPointOnSegment( const vec3 a, const vec3 b, const vec3 p, vec3 out ); +/** + * Finds the closest point on triangle (a, b, c) to query point p. Handles + * all six Voronoi regions (the three vertices, the three edges, and the + * face interior). + * + * @param p Query point. + * @param a First triangle vertex. + * @param b Second triangle vertex. + * @param c Third triangle vertex. + * @param out Receives the closest point on the triangle to p. + */ +void physicsTestClosestPointOnTriangle( + const vec3 p, const vec3 a, const vec3 b, const vec3 c, vec3 out +); + /** * Finds the closest points between two line segments. * diff --git a/src/dusk/scene/scene.c b/src/dusk/scene/scene.c index 29703880..86deabc1 100644 --- a/src/dusk/scene/scene.c +++ b/src/dusk/scene/scene.c @@ -72,12 +72,17 @@ physicsworld_t *sceneGetPhysics(const sceneid_t id) { } errorret_t sceneUpdate(void) { - if(SCENE_MANAGER.active == SCENE_ID_INVALID) errorOk(); - #if DUSK_TIME_DYNAMIC - if(!TIME.dynamicUpdate) errorOk(); + if(TIME.dynamicUpdate) errorOk(); #endif + errorChain(sceneFixedUpdate()); + errorOk(); +} + +errorret_t sceneFixedUpdate(void) { + if(SCENE_MANAGER.active == SCENE_ID_INVALID) errorOk(); + scene_t *scene = &SCENE_MANAGER.scenes[SCENE_MANAGER.active]; entityManagerUpdate(&scene->entities); physicsWorldStep(&scene->physics, &scene->entities, TIME.delta); diff --git a/src/dusk/scene/scene.h b/src/dusk/scene/scene.h index 6762903b..fafdf910 100644 --- a/src/dusk/scene/scene.h +++ b/src/dusk/scene/scene.h @@ -83,13 +83,29 @@ entitymanager_t *sceneGetEntities(const sceneid_t id); physicsworld_t *sceneGetPhysics(const sceneid_t id); /** - * Ticks the active scene's entities (update callbacks, then a physics - * step) on fixed timesteps only, per DUSK_TIME_DYNAMIC. + * Called every frame (every call to engineUpdate). On a non-dynamic-time + * platform (DUSK_TIME_DYNAMIC undefined) every frame is itself a fixed + * timestep, so this calls sceneFixedUpdate() every time. On a + * dynamic-time platform, most calls are sub-steps still accumulating + * toward the next fixed timestep (TIME.dynamicUpdate is true) and do + * nothing here; this calls sceneFixedUpdate() only on the one call where + * TIME.dynamicUpdate is false, i.e. exactly once per fixed timestep. * * @return An error if the update failed, or errorOk() if it succeeded. */ errorret_t sceneUpdate(void); +/** + * Ticks the active scene's entities (update callbacks, then a physics + * step) by one fixed timestep (TIME.delta). Called by sceneUpdate() at + * the fixed-timestep cadence described there -- never once per rendered + * frame on a dynamic-time platform, so game logic and physics stay + * frame-rate independent and deterministic. + * + * @return An error if the update failed, or errorOk() if it succeeded. + */ +errorret_t sceneFixedUpdate(void); + /** * Renders the active scene (entities, render pipeline, UI). * diff --git a/src/dusk/time/time.h b/src/dusk/time/time.h index 68f53537..52b14c8e 100644 --- a/src/dusk/time/time.h +++ b/src/dusk/time/time.h @@ -39,13 +39,26 @@ #endif typedef struct { + /** Fixed simulation timestep, always DUSK_TIME_STEP. */ float_t delta; + /** Total elapsed fixed-simulation time, in DUSK_TIME_STEP increments. */ float_t time; #ifdef DUSK_TIME_DYNAMIC + /** dynamicTime at the last fixed-timestep boundary. */ float_t lastNonDynamic; + /** + * False on the one timeUpdate() call per fixed timestep where enough + * real time has accumulated to cross a DUSK_TIME_STEP boundary (delta + * and time were just advanced) -- true on every other call, still + * accumulating toward the next one. Fixed-rate systems (scene + * fixed update, physics) should run only when this is false; systems + * that want every real frame (e.g. rendering) run unconditionally. + */ bool_t dynamicUpdate; + /** Real elapsed time since the previous timeUpdate() call. */ float_t dynamicDelta; + /** Total elapsed real time, accumulated every timeUpdate() call. */ float_t dynamicTime; #endif } dusktime_t; diff --git a/test/physics/CMakeLists.txt b/test/physics/CMakeLists.txt index 99e90c07..6e9f6a0a 100644 --- a/test/physics/CMakeLists.txt +++ b/test/physics/CMakeLists.txt @@ -8,3 +8,4 @@ include(dusktest) # Tests dusktest(test_physicstest.c) dusktest(test_physicsworld.c) +dusktest(test_physicsshapemesh.c) diff --git a/test/physics/test_physicsshapemesh.c b/test/physics/test_physicsshapemesh.c new file mode 100644 index 00000000..a136ae9e --- /dev/null +++ b/test/physics/test_physicsshapemesh.c @@ -0,0 +1,185 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "util/memory.h" +#include "physics/physicstest.h" +#include "physics/physicsshapemesh.h" + +// A flat 10x10 ground quad on the XZ plane at y=0, centered at the origin +// (two triangles, tightly packed vec3 vertices -- no indices). +static vec3 FLAT_GROUND_VERTICES[6] = { + { -5.0f, 0.0f, -5.0f }, { 5.0f, 0.0f, -5.0f }, { 5.0f, 0.0f, 5.0f }, + { -5.0f, 0.0f, -5.0f }, { 5.0f, 0.0f, 5.0f }, { -5.0f, 0.0f, 5.0f }, +}; + +static physicsshapemesh_t flatGroundMesh(void) { + return (physicsshapemesh_t){ + .vertices = (const uint8_t *)FLAT_GROUND_VERTICES, + .stride = sizeof(vec3), + .triangleCount = 2 + }; +} + +static void test_getVertexReadsRawPositions(void **state) { + physicsshapemesh_t mesh = flatGroundMesh(); + + vec3 v0, v3; + physicsShapeMeshGetVertex(&mesh, 0, v0); + physicsShapeMeshGetVertex(&mesh, 3, v3); + + assert_float_equal(v0[0], -5.0f, 0.0001f); + assert_float_equal(v0[2], -5.0f, 0.0001f); + assert_float_equal(v3[0], -5.0f, 0.0001f); + assert_float_equal(v3[2], -5.0f, 0.0001f); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// Vertex layout matching a typical interleaved render vertex (uv then +// pos), to prove the stride-based reader can point straight at a render +// buffer instead of a dedicated physics copy. +typedef struct { + float_t uv[2]; + float_t pos[3]; +} testInterleavedVertex_t; + +static void test_supportsInterleavedRenderVertexLayout(void **state) { + testInterleavedVertex_t vertices[3] = { + { .uv = { 0, 0 }, .pos = { -5.0f, 0.0f, -5.0f } }, + { .uv = { 1, 0 }, .pos = { 5.0f, 0.0f, -5.0f } }, + { .uv = { 0, 1 }, .pos = { -5.0f, 0.0f, 5.0f } }, + }; + physicsshapemesh_t mesh = { + .vertices = (const uint8_t *)vertices[0].pos, + .stride = sizeof(testInterleavedVertex_t), + .triangleCount = 1 + }; + + vec3 v1; + physicsShapeMeshGetVertex(&mesh, 1, v1); + assert_float_equal(v1[0], 5.0f, 0.0001f); + assert_float_equal(v1[2], -5.0f, 0.0001f); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_sphereRestingOnMesh(void **state) { + physicsshapemesh_t mesh = flatGroundMesh(); + vec3 meshPos = { 0.0f, 0.0f, 0.0f }; + + // Sphere overlapping the ground by 0.2. + vec3 sphereCenter = { 0.0f, 0.3f, 0.0f }; + vec3 normal; float_t depth; + assert_true(physicsShapeMeshTestSphere( + &mesh, meshPos, sphereCenter, 0.5f, normal, &depth + )); + assert_float_equal(depth, 0.2f, 0.0001f); + assert_float_equal(normal[1], 1.0f, 0.0001f); // pushes sphere up + + // Far above: no overlap. + vec3 sphereFar = { 0.0f, 10.0f, 0.0f }; + assert_false(physicsShapeMeshTestSphere( + &mesh, meshPos, sphereFar, 0.5f, normal, &depth + )); + + // Offsetting the mesh's own position shifts the ground with it: raising + // both the ground and the sphere together reproduces the same overlap. + vec3 meshPosRaised = { 0.0f, 1.0f, 0.0f }; + vec3 sphereCenterRaised = { 0.0f, 1.3f, 0.0f }; + assert_true(physicsShapeMeshTestSphere( + &mesh, meshPosRaised, sphereCenterRaised, 0.5f, normal, &depth + )); + assert_float_equal(depth, 0.2f, 0.0001f); + + // But the sphere no longer overlaps the ground at its old (un-raised) + // height, since the ground moved out from under it. + assert_false(physicsShapeMeshTestSphere( + &mesh, meshPosRaised, sphereCenter, 0.5f, normal, &depth + )); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_capsuleRestingOnMesh(void **state) { + physicsshapemesh_t mesh = flatGroundMesh(); + vec3 meshPos = { 0.0f, 0.0f, 0.0f }; + + // Standing capsule (half-height 1) whose bottom cap dips 0.1 into the + // ground: center at y = radius + halfHeight - 0.1. + const float_t radius = 0.5f; + const float_t halfHeight = 1.0f; + vec3 capsuleCenter = { 0.0f, radius + halfHeight - 0.1f, 0.0f }; + + vec3 normal; float_t depth; + assert_true(physicsShapeMeshTestCapsule( + &mesh, meshPos, capsuleCenter, radius, halfHeight, normal, &depth + )); + assert_float_equal(depth, 0.1f, 0.001f); + assert_float_equal(normal[1], 1.0f, 0.0001f); + + // Lifted well clear of the ground: no overlap. + vec3 capsuleHigh = { 0.0f, 10.0f, 0.0f }; + assert_false(physicsShapeMeshTestCapsule( + &mesh, meshPos, capsuleHigh, radius, halfHeight, normal, &depth + )); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_cubeIsUnsupported(void **state) { + physicsshapemesh_t mesh = flatGroundMesh(); + vec3 meshPos = { 0.0f, 0.0f, 0.0f }; + + physicsshape_t cube = { + .type = PHYSICS_SHAPE_CUBE, + .data.cube.halfExtents = { 0.5f, 0.5f, 0.5f } + }; + vec3 cubePos = { 0.0f, 0.3f, 0.0f }; + vec3 normal; float_t depth; + assert_false(physicsShapeMeshTest( + meshPos, &mesh, cubePos, &cube, normal, &depth + )); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_physicsShapeMeshCreateIntegratesWithDispatch(void **state) { + physicsshapemesh_t mesh = flatGroundMesh(); + physicsshape_t landscape = physicsShapeMeshCreate(&mesh); + assert_int_equal(landscape.type, PHYSICS_SHAPE_CUSTOM); + + physicsshape_t sphere = { + .type = PHYSICS_SHAPE_SPHERE, + .data.sphere.radius = 0.5f + }; + vec3 meshPos = { 0.0f, 0.0f, 0.0f }; + vec3 sphereCenter = { 0.0f, 0.3f, 0.0f }; + + // Landscape as B, matching physicsWorldStep's dynamic(A)-vs-static(B). + vec3 normal; float_t depth; + assert_true(physicsTestShapeVsShape( + sphereCenter, sphere, meshPos, landscape, normal, &depth + )); + assert_float_equal(depth, 0.2f, 0.0001f); + assert_float_equal(normal[1], 1.0f, 0.0001f); + + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +int main(int argc, char **argv) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_getVertexReadsRawPositions), + cmocka_unit_test(test_supportsInterleavedRenderVertexLayout), + cmocka_unit_test(test_sphereRestingOnMesh), + cmocka_unit_test(test_capsuleRestingOnMesh), + cmocka_unit_test(test_cubeIsUnsupported), + cmocka_unit_test(test_physicsShapeMeshCreateIntegratesWithDispatch), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/test/scene/test_scene.c b/test/scene/test_scene.c index 879f13da..94eef9c6 100644 --- a/test/scene/test_scene.c +++ b/test/scene/test_scene.c @@ -12,6 +12,7 @@ #include "entity/entitymanager.h" static void countingUpdateCallback( + entitymanager_t *mgr, const entityid_t entityId, const componentid_t componentId, void *user @@ -58,10 +59,9 @@ static void test_sceneEntitiesAreIsolatedPerScene(void **state) { assert_int_equal(memoryGetAllocatedCount(), 0); } -static void test_sceneUpdateOnlyTicksActiveScene(void **state) { +static void test_sceneFixedUpdateOnlyTicksActiveScene(void **state) { sceneInit(); timeInit(); - TIME.dynamicUpdate = true; sceneid_t sceneA = sceneCreate(); sceneid_t sceneB = sceneCreate(); @@ -84,13 +84,13 @@ static void test_sceneUpdateOnlyTicksActiveScene(void **state) { ); sceneSetActive(sceneA); - sceneUpdate(); + sceneFixedUpdate(); assert_int_equal(counterA, 1); assert_int_equal(counterB, 0); sceneSetActive(sceneB); - sceneUpdate(); + sceneFixedUpdate(); assert_int_equal(counterA, 1); assert_int_equal(counterB, 1); @@ -99,6 +99,44 @@ static void test_sceneUpdateOnlyTicksActiveScene(void **state) { assert_int_equal(memoryGetAllocatedCount(), 0); } +static void test_sceneUpdateOnlyRunsFixedUpdateOnNonDynamicFrames(void **state) { + sceneInit(); + timeInit(); + + sceneid_t scene = sceneCreate(); + sceneSetActive(scene); + + uint32_t counter = 0; + entitymanager_t *entities = sceneGetEntities(scene); + entityid_t entity = entityManagerAdd(entities); + entityUpdateAdd( + entities, entity, countingUpdateCallback, COMPONENT_ID_INVALID, &counter + ); + + #if DUSK_TIME_DYNAMIC + // Still accumulating toward the next fixed timestep: sceneUpdate must + // not run sceneFixedUpdate yet. + TIME.dynamicUpdate = true; + sceneUpdate(); + assert_int_equal(counter, 0); + + // A fixed timestep boundary was just crossed: sceneUpdate must run + // sceneFixedUpdate exactly once. + TIME.dynamicUpdate = false; + sceneUpdate(); + assert_int_equal(counter, 1); + #else + // Non-dynamic-time platforms: every frame is a fixed timestep. + sceneUpdate(); + assert_int_equal(counter, 1); + sceneUpdate(); + assert_int_equal(counter, 2); + #endif + + sceneDispose(); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + static void test_sceneDestroyClearsActive(void **state) { sceneInit(); @@ -117,7 +155,8 @@ int main(int argc, char **argv) { const struct CMUnitTest tests[] = { cmocka_unit_test(test_sceneCreateDestroy), cmocka_unit_test(test_sceneEntitiesAreIsolatedPerScene), - cmocka_unit_test(test_sceneUpdateOnlyTicksActiveScene), + cmocka_unit_test(test_sceneFixedUpdateOnlyTicksActiveScene), + cmocka_unit_test(test_sceneUpdateOnlyRunsFixedUpdateOnNonDynamicFrames), cmocka_unit_test(test_sceneDestroyClearsActive), };