diff --git a/CHANGELOG b/CHANGELOG index 6ad15dcc7..abfb7aae9 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -18,6 +18,18 @@ API Changes database), and lfs.mkdir and lfs.rmdir act on its data overlay, so frameworks that list or create their own directories work packed. +- Physics. Any scene node can carry a rigid body on Jolt Physics: + static, kinematic (follows its node and pushes) or dynamic (drives its + node), shaped as a box, sphere, capsule, cylinder, a convex hull of the + node's mesh or the mesh's triangles; mass, friction, bounce, + velocities, forces and impulses; triggers; hinge, ball and slider + joints with limits; a raycast; onCollision and onTrigger callbacks. + The world steps at a fixed 60 Hz between animation and rendering and + pauses with the game. New calls: body*, joint*, physics*, and the + BODY_*, SHAPE_* and JOINT_* constants; see the Physics chapter. Needs + SSE4.1 on x86 (2008 and later); the engine's first C++ (one wrapper + file and the library), statically linked, no runtime to install. + - 3D scenes. A game can draw a 3D scene between the disc video and the overlay: primitive meshes and script-built geometry, materials with colour, textures from sprites, the disc or a loaded video, metallic and diff --git a/docs/Manual.adoc b/docs/Manual.adoc index dd8d2aceb..c145eebb4 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -797,6 +797,95 @@ Put `.glb` files in the game directory like any other asset; `modelLoad` takes the same game-relative names as `spriteLoad`, and a packed `.game` carries them along. +[#physics] +=== Physics + +Bodies, joints, triggers and raycasts live in the 3D scene and ride on +Jolt Physics. A body attaches to a node the way a mesh does; from then on +the node and the body are one thing: + +* A *static* body (`BODY_STATIC`) stays where the node was when the body + was made: floors, walls, the cabinet. +* A *kinematic* body (`BODY_KINEMATIC`) follows its node wherever the script + or an animation moves it, and pushes dynamic bodies out of its way without + being pushed itself: a paddle, a moving platform, a character's hand. +* A *dynamic* body (`BODY_DYNAMIC`) is moved by the simulation and drives + its node: it falls, bounces, slides and gets pushed. Do not move its node + yourself; give it velocities, forces or impulses instead. + +Every body has a collision shape sized in world units and scaled by the +node: a box, sphere, capsule or cylinder, a convex hull of the node's mesh +(`SHAPE_HULL`, everything under the node, so a model instance's root works), +or the mesh's exact triangles (`SHAPE_MESH`, static and kinematic bodies +only). Units are metres, kilograms and seconds; gravity defaults to +`0, -9.81, 0`. + +.Bodies for a crate stack and a paddle +[source,lua] +---- +floor = nodeNew() +nodeSetMesh(floor, meshBox(14, 0.2, 14), stone) +nodeSetPosition(floor, 0, -1.9, 0) +bodyNew(floor, BODY_STATIC, SHAPE_BOX, 14, 0.2, 14) + +for i = 1, 6 do + local crate = nodeNew() + nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), wood) + nodeSetPosition(crate, -1, -1.5 + (i - 1) * 0.62, 0) + bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6) + bodySetMass(crate, 5) +end + +paddle = nodeNew() +nodeSetMesh(paddle, meshBox(0.3, 1.2, 1.6), red) +nodeSetPosition(paddle, -4, -1.2, 0) +bodyNew(paddle, BODY_KINEMATIC, SHAPE_BOX, 0.3, 1.2, 1.6) + +function onOverlayUpdate() + nodeMove(paddle, 0.06, 0, 0) -- The kinematic paddle sweeps through the stack. + return OVERLAY_UPDATED +end +---- + +.Mass matters +A dynamic body's mass comes from its shape's volume at the density of +water, so a box the size of a door weighs 140 kilograms and a nudge will +not move it. Set `bodySetMass` on anything you want to feel light, and size +impulses to the mass: an impulse of `mass * speed` sets that speed. + +.Timing +The world steps at a fixed sixty times a second from the wall clock, after +animations have posed their nodes and before the scene renders, so a +kinematic body driven by an animation pushes with the right velocity. +Pausing the game pauses physics; `physicsSetEnabled(false)` pauses it on +its own. + +.Events and queries +Define `onCollision(nodeA, nodeB, x, y, z, speed)` and Singe calls it once +for each new contact, with the point and the speed the two bodies met at. +A body made a trigger with `bodySetTrigger` pushes nothing but reports what +enters and leaves it through `onTrigger(trigger, other, entered)`: a finish +line, a doorway, a danger zone. `physicsRaycast` finds the first body along +a ray; with `sceneUnproject` for the ray, that is mouse picking. + +.Joints +`jointHinge`, `jointBall` and `jointSlider` connect two bodies, or a body +and the world when the second node is `-1`: doors, pendulums, drawers. +Hinges and sliders take limits. A positive hinge angle is a right-hand turn +of the first node about the axis; a positive slider distance is along it. + +.Models +Bodies on model instances work like any other: `bodyNew(instanceRoot, +BODY_DYNAMIC, SHAPE_HULL)` wraps the whole model in a convex hull. Skinned +characters are best driven kinematically with a capsule and left to push +things rather than be pushed. + +.Where it runs +Physics runs on the CPU and needs no GPU, but the library is built for +SSE4.1 on x86 (2008 and later) and NEON on the Pi; a machine below that +gets an error from the first `bodyNew`. The simulation uses every core +but one, so a desktop steps in parallel and a Pi on a single thread. + [#migrating] === Migrating from Singe 2.10 @@ -865,6 +954,9 @@ available to `controls.cfg` and to `Framework.singe` alike: | `RENDER_PIXELATED`, `RENDER_SMOOTH` | Arguments for `spriteQuality` / `videoQuality`. | `DISC_STOPPED`, `DISC_PLAYING`, `DISC_PAUSED`, `DISC_EJECTED` | Return values of `discGetState`. | `LIGHT_DIRECTIONAL`, `LIGHT_POINT`, `LIGHT_SPOT` | Arguments for `lightNew`. +| `BODY_STATIC`, `BODY_DYNAMIC`, `BODY_KINEMATIC` | Body types for `bodyNew`. +| `SHAPE_BOX`, `SHAPE_SPHERE`, `SHAPE_CAPSULE`, `SHAPE_CYLINDER`, `SHAPE_HULL`, `SHAPE_MESH` | Collision shapes for `bodyNew`. +| `JOINT_HINGE`, `JOINT_BALL`, `JOINT_SLIDER` | Joint types (the `joint*` calls name them directly). | `SOUND_ERROR_INVALID`, `SOUND_REMOVE_HANDLE` | `-1`, what `soundPlay` returns when no channel is free. | `SINGE_VERSION_MAJOR`, `SINGE_VERSION_MINOR`, `SINGE_VERSION_STRING`, `SINGE_FRAMEWORK_VERSION` | The engine version, as integers, as a string (`"v3.00"`), and as the number `singeVersion()` returns. | `SINGE_DEAD_ZONE` | The `DEAD_ZONE` from `controls.cfg`. @@ -1104,6 +1196,256 @@ Stops and forgets the animation. The nodes keep their last pose. *Since:* 3.00. *See also:* <> +[#body] +=== Body + +A body gives a scene node a place in the physics world. One body per node; +static bodies stay put, kinematic bodies follow their node, dynamic bodies +drive it. Every call here takes the node handle and raises an error when +the node has no body. See <> for the model. + +[#bodynew] +==== bodyNew + +[source,text] +---- +bodyNew(node, type, shape, ...) +bodyNew(node, BODY_DYNAMIC, SHAPE_BOX, width, height, depth) +bodyNew(node, BODY_DYNAMIC, SHAPE_SPHERE, radius) +bodyNew(node, BODY_DYNAMIC, SHAPE_CAPSULE, radius, height) +bodyNew(node, BODY_DYNAMIC, SHAPE_CYLINDER, radius, height) +bodyNew(node, BODY_DYNAMIC, SHAPE_HULL) +bodyNew(node, BODY_STATIC, SHAPE_MESH) +---- + +Gives the node a body, placed where the node is now, with a shape sized in world units and scaled by the node's scale. A second call on the same node replaces the first. + +* `type` -- `BODY_STATIC`, `BODY_DYNAMIC` or `BODY_KINEMATIC`. +* `shape` -- a primitive with its sizes (a capsule's or cylinder's height is the total, along Y), `SHAPE_HULL` for a convex hull of the node's mesh and everything under it, or `SHAPE_MESH` for the exact triangles (static and kinematic only). + +*Notes:* Mass comes from the shape's volume at the density of water; set it with `bodySetMass`. Changing the node's scale later does not resize the body. A dynamic body ignores its parent moving. + +*Since:* 3.00. +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- A rubber ball dropped onto the scene. +ball = nodeNew() +nodeSetMesh(ball, meshSphere(0.3, 32), blue) +nodeSetPosition(ball, 0, 3, 0) +bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.3) +bodySetMass(ball, 0.5) +bodySetBounce(ball, 0.7) +---- + +[#bodyapplyforce] +==== bodyApplyForce + +[source,text] +---- +bodyApplyForce(node, fx, fy, fz) +bodyApplyForce(node, fx, fy, fz, px, py, pz) +---- + +A force for this step, in newtons, at the centre of mass or at a world point (which also spins the body). Dynamic bodies only. + +*Since:* 3.00. +*See also:* <> + +[#bodyapplyimpulse] +==== bodyApplyImpulse + +[source,text] +---- +bodyApplyImpulse(node, ix, iy, iz) +bodyApplyImpulse(node, ix, iy, iz, px, py, pz) +---- + +An instant change of momentum (mass times speed), at the centre of mass or at a world point. What a hit, a kick or a launch wants. Dynamic bodies only. + +*Since:* 3.00. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- Flick whatever the mouse is over. +local mx, my = mouseGetPosition(0) +local ox, oy, oz = sceneUnproject(mx, my, 0) +local fx, fy, fz = sceneUnproject(mx, my, 10) +local hit, hx, hy, hz = physicsRaycast(ox, oy, oz, fx - ox, fy - oy, fz - oz) +if hit then + bodyApplyImpulse(hit, 0, 3, 0, hx, hy, hz) +end +---- + +[#bodydelete] +==== bodyDelete + +[source,text] +---- +bodyDelete(node) +---- + +Removes the body; the node stays. Joints on the body go with it. Deleting the node removes its body too. + +*Since:* 3.00. +*See also:* <> + +[#bodygetangularvelocity] +==== bodyGetAngularVelocity + +[source,text] +---- +x, y, z = bodyGetAngularVelocity(node) +---- + +How fast the body spins about each world axis, in radians per second. + +*Returns:* Three numbers. + +*Since:* 3.00. +*See also:* <> + +[#bodygetvelocity] +==== bodyGetVelocity + +[source,text] +---- +x, y, z = bodyGetVelocity(node) +---- + +How fast the body moves, in units per second. + +*Returns:* Three numbers. + +*Since:* 3.00. +*See also:* <> + +[#bodyisresting] +==== bodyIsResting + +[source,text] +---- +resting = bodyIsResting(node) +---- + +Whether the simulation has put the body to sleep because it stopped moving. Static and disabled bodies rest. + +*Returns:* Boolean. + +*Since:* 3.00. +*See also:* <> + +[#bodysetangularvelocity] +==== bodySetAngularVelocity + +[source,text] +---- +bodySetAngularVelocity(node, x, y, z) +---- + +Sets the spin about each world axis, in radians per second, and wakes the body. + +*Since:* 3.00. +*See also:* <> + +[#bodysetbounce] +==== bodySetBounce + +[source,text] +---- +bodySetBounce(node, bounce) +---- + +Restitution from `0` (stops dead) to `1` (bounces back with everything it arrived with). Default `0.1`. + +*Since:* 3.00. +*See also:* <> + +[#bodysetenabled] +==== bodySetEnabled + +[source,text] +---- +bodySetEnabled(node, enabled) +---- + +Takes the body out of the world (it stops colliding and moving, its node stays put) and puts it back. + +*Since:* 3.00. +*See also:* <> + +[#bodysetfriction] +==== bodySetFriction + +[source,text] +---- +bodySetFriction(node, friction) +---- + +Surface friction, `0` for ice upward; default `0.5`. The two touching bodies' values combine. + +*Since:* 3.00. +*See also:* <> + +[#bodysetmass] +==== bodySetMass + +[source,text] +---- +bodySetMass(node, kilograms) +---- + +Rescales a dynamic body's mass and inertia. Do this for anything meant to feel light: the default is the shape's volume at the density of water. + +*Since:* 3.00. +*See also:* <>, <> + +[#bodysettrigger] +==== bodySetTrigger + +[source,text] +---- +bodySetTrigger(node, trigger) +---- + +Makes the body a trigger: it pushes nothing and collides with nothing, but `onTrigger` reports what enters and leaves it. Usually a static box or sphere. + +*Since:* 3.00. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A finish line across the track. +line = nodeNew() +nodeSetPosition(line, 0, 0, -20) +bodyNew(line, BODY_STATIC, SHAPE_BOX, 6, 3, 0.5) +bodySetTrigger(line, true) + +function onTrigger(trigger, other, entered) + if trigger == line and other == car and entered then + lapDone() + end +end +---- + +[#bodysetvelocity] +==== bodySetVelocity + +[source,text] +---- +bodySetVelocity(node, x, y, z) +---- + +Sets the velocity in units per second and wakes the body. Kinematic bodies take theirs from their node instead. + +*Since:* 3.00. +*See also:* <>, <> + [#camera] === Camera @@ -1901,6 +2243,100 @@ If you unload the currently selected font, the engine does not automatically pic *Since:* 2.00 *See also:* <> +[#joint] +=== Joint + +Joints connect two bodies, or a body and the world, and hand back a handle. +Anchors and axes are given in world space at the moment the joint is made, +with the bodies where they are then. Deleting either body deletes the +joint. + +[#jointhinge] +==== jointHinge + +[source,text] +---- +joint = jointHinge(nodeA, nodeB, ax, ay, az, dx, dy, dz) +---- + +A hinge through the world point `(ax, ay, az)` along the axis `(dx, dy, dz)`: a door, a wheel, a lever. `nodeB` may be `-1` for the world. + +*Returns:* The joint handle. + +*Since:* 3.00. +*See also:* <>, <> + +.Example +[source,lua] +---- +-- A door hinged to the world along its left edge, opening 100 degrees one way. +door = nodeNew() +nodeSetMesh(door, meshBox(1.4, 2.2, 0.1), wood) +nodeSetPosition(door, 0, 0, 0) +bodyNew(door, BODY_DYNAMIC, SHAPE_BOX, 1.4, 2.2, 0.1) +bodySetMass(door, 8) +hinge = jointHinge(door, -1, -0.7, 0, 0, 0, 1, 0) +jointSetLimits(hinge, 0, 100) +---- + +[#jointball] +==== jointBall + +[source,text] +---- +joint = jointBall(nodeA, nodeB, ax, ay, az) +---- + +A ball-and-socket at the world point: the bodies may turn any way about it but not separate. A pendulum, a chain link, a ragdoll shoulder. `nodeB` may be `-1` for the world. + +*Returns:* The joint handle. + +*Since:* 3.00. +*See also:* <> + +[#jointslider] +==== jointSlider + +[source,text] +---- +joint = jointSlider(nodeA, nodeB, ax, ay, az, dx, dy, dz) +---- + +Lets the bodies slide relative to each other only along the axis through the anchor, keeping their orientation: a drawer, a piston, a lift. `nodeB` may be `-1` for the world. + +*Returns:* The joint handle. + +*Since:* 3.00. +*See also:* <> + +[#jointsetlimits] +==== jointSetLimits + +[source,text] +---- +jointSetLimits(joint, low, high) +---- + +Bounds a hinge's angle in degrees, or a slider's travel in world units, either side of where the joint started; `low` at or below `0`, `high` at or above. A positive hinge angle is a right-hand turn of `nodeA` about the axis; a positive slider distance is along the axis. + +*Notes:* A ball joint has no limits. + +*Since:* 3.00. +*See also:* <>, <> + +[#jointdelete] +==== jointDelete + +[source,text] +---- +jointDelete(joint) +---- + +Removes the joint; the bodies are free of each other again. + +*Since:* 3.00. +*See also:* <> + [#keyboard] === Keyboard @@ -3264,6 +3700,55 @@ Higher resolutions give finer control over sprite placement and sharper text at overlaySetResolution(discGetWidth(), discGetHeight()) ---- +[#physicsref] +=== Physics + +World-level calls. Physics is always initialised with the engine; there is +nothing to enable before the first `bodyNew`. See <> in +Game Development for how bodies, nodes and the frame fit together. + +[#physicsraycast] +==== physicsRaycast + +[source,text] +---- +node, hx, hy, hz, nx, ny, nz = physicsRaycast(x, y, z, dx, dy, dz) +node, hx, hy, hz, nx, ny, nz = physicsRaycast(x, y, z, dx, dy, dz, maxDistance) +---- + +The first body along a ray from `(x, y, z)` in the direction `(dx, dy, dz)` (any length), within `maxDistance` (default `1000`). Triggers are hit too. + +*Returns:* The body's node, the hit point and the surface normal there, or `nil` when nothing is hit. + +*Since:* 3.00. +*See also:* <>, <> + +[#physicssetenabled] +==== physicsSetEnabled + +[source,text] +---- +physicsSetEnabled(enabled) +---- + +Pauses and resumes the simulation on its own; the game's pause does the same. Bodies hold still and keep their velocities. + +*Since:* 3.00. +*See also:* <> + +[#physicssetgravity] +==== physicsSetGravity + +[source,text] +---- +physicsSetGravity(x, y, z) +---- + +The acceleration every dynamic body feels, in units per second squared. Default `0, -9.81, 0`; `0, 0, 0` for space. + +*Since:* 3.00. +*See also:* <> + [#scene] === Scene @@ -4917,6 +5402,40 @@ function onMouseMoved(x, y, xr, yr, which) end ---- +[#oncollision] +==== onCollision + +[source,text] +---- +function onCollision(nodeA, nodeB, x, y, z, speed) + -- nodeA, nodeB: the two bodies' nodes + -- x, y, z: where they touched, in world space + -- speed: how fast they met, in units per second +end +---- + +Called once for each new contact between two bodies after a physics step, never for a pair already touching. A crate landing, a ball hitting the cabinet, the player's car clipping a wall. Triggers report through `onTrigger` instead. + +*Since:* 3.00. +*See also:* <>, <> + +[#ontrigger] +==== onTrigger + +[source,text] +---- +function onTrigger(trigger, other, entered) + -- trigger: the trigger body's node + -- other: the node that entered or left it + -- entered: true on the way in, false on the way out +end +---- + +Called when a body enters or leaves a trigger made with `bodySetTrigger`. + +*Since:* 3.00. +*See also:* <>, <> + [#onoverlayupdate] ==== onOverlayUpdate diff --git a/src/physics.h b/src/physics.h index 80c92377e..d29468b79 100644 --- a/src/physics.h +++ b/src/physics.h @@ -52,6 +52,30 @@ typedef enum ShapeTypeE { } ShapeTypeE; +// What happened during a step, drained by the engine after it: a new contact between two bodies +// (with the point and how fast they met), or a trigger being entered or left. +typedef enum PhysicsEventTypeE { + PHYSICS_EVENT_COLLISION = 0, + PHYSICS_EVENT_ENTER = 1, + PHYSICS_EVENT_LEAVE = 2 +} PhysicsEventTypeE; + +typedef struct PhysicsEventS { + PhysicsEventTypeE type; + int32_t nodeA; // The trigger, for enter and leave + int32_t nodeB; + Vec3T point; + float speed; +} PhysicsEventT; + + +typedef enum JointTypeE { + JOINT_HINGE = 0, + JOINT_BALL = 1, + JOINT_SLIDER = 2 +} JointTypeE; + + bool bodyApplyForce(int32_t node, Vec3T force, const Vec3T *at); bool bodyApplyImpulse(int32_t node, Vec3T impulse, const Vec3T *at); bool bodyDelete(int32_t node); @@ -65,10 +89,17 @@ bool bodySetBounce(int32_t node, float bounce); bool bodySetEnabled(int32_t node, bool enabled); bool bodySetFriction(int32_t node, float friction); bool bodySetMass(int32_t node, float kilograms); +bool bodySetTrigger(int32_t node, bool trigger); bool bodySetVelocity(int32_t node, Vec3T velocity); +bool jointDelete(int32_t joint); +int32_t jointNew(JointTypeE type, int32_t nodeA, int32_t nodeB, Vec3T anchor, Vec3T axis); +bool jointSetLimits(int32_t joint, float low, float high); +bool jointValid(int32_t joint); bool physicsAvailable(void); +int32_t physicsGetEvents(PhysicsEventT *events, int32_t maximum); bool physicsInit(void); void physicsQuit(void); +bool physicsRaycast(Vec3T origin, Vec3T direction, float maxDistance, int32_t *node, Vec3T *point, Vec3T *normal); void physicsSetEnabled(bool enabled); void physicsSetGravity(Vec3T gravity); void physicsUpdate(bool advance); diff --git a/src/physicsJolt.cpp b/src/physicsJolt.cpp index 68002311a..423d8f350 100644 --- a/src/physicsJolt.cpp +++ b/src/physicsJolt.cpp @@ -33,12 +33,23 @@ #include #include #include +#include #include #include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include extern "C" { #include "util.h" #include "scene.h" @@ -57,6 +68,10 @@ extern "C" { #define DEFAULT_FRICTION 0.5f #define DEFAULT_BOUNCE 0.1f #define NO_HANDLE -1 +#define MAX_EVENTS 512 // Per frame; the rest of a busy step is dropped +#define DEFAULT_RAY_DISTANCE 1000.0f +#define WORLD_NODE -1 // A joint's other side fixed to the world +#define DEGREES_TO_RADIANS(d) ((d) * (3.14159265358979323846f / 180.0f)) // Two object layers: what never moves and what may. Static bodies never collide with each other. @@ -109,11 +124,70 @@ namespace { uint32_t generation; // The node's, so a reused handle is not mistaken for this body JPH::BodyID id; BodyTypeE type; + bool trigger; // A sensor: reports overlaps, pushes nothing bool enabled; // In the world (bodySetEnabled) bool used; }; + struct WorldT; + extern WorldT *_world; + bool _isTrigger(JPH::BodyID id); + + + // Collects contacts from Jolt's job threads; the engine drains it after the step. + class ContactListenerT final : public JPH::ContactListener { + public: + std::mutex lock; + std::vector events; + + void OnContactAdded(const JPH::Body &a, const JPH::Body &b, const JPH::ContactManifold &manifold, JPH::ContactSettings &settings) override { + PhysicsEventT event; + JPH::Vec3 relative; + + (void)settings; + event.nodeA = (int32_t)(uint32_t)a.GetUserData(); + event.nodeB = (int32_t)(uint32_t)b.GetUserData(); + event.point = vec3((float)manifold.GetWorldSpaceContactPointOn1(0).GetX(), (float)manifold.GetWorldSpaceContactPointOn1(0).GetY(), (float)manifold.GetWorldSpaceContactPointOn1(0).GetZ()); + relative = a.GetLinearVelocity() - b.GetLinearVelocity(); + event.speed = fabsf(relative.Dot(manifold.mWorldSpaceNormal)); + if (a.IsSensor() || b.IsSensor()) { + event.type = PHYSICS_EVENT_ENTER; + if (b.IsSensor()) { + // The trigger comes first. + int32_t swap = event.nodeA; + + event.nodeA = event.nodeB; + event.nodeB = swap; + } + } else { + event.type = PHYSICS_EVENT_COLLISION; + } + push(event); + } + + void OnContactRemoved(const JPH::SubShapeIDPair &pair) override; + + void push(const PhysicsEventT &event) { + std::lock_guard guard(lock); + + if (events.size() < MAX_EVENTS) { + events.push_back(event); + } + } + }; + + + // A constraint between two bodies (or one body and the world). + struct JointRecordT { + JPH::Ref constraint; + JointTypeE type; + int32_t nodeA; + int32_t nodeB; + bool used; + }; + + struct WorldT { JPH::TempAllocatorImpl *tempAllocator; JPH::JobSystemThreadPool *jobs; @@ -121,8 +195,11 @@ namespace { ObjectVsBroadPhaseFilterT objectVsBroadPhase; ObjectPairFilterT objectPairs; JPH::PhysicsSystem *system; + ContactListenerT *contacts; BodyRecordT *bodies; int32_t bodyCount; + JointRecordT *joints; + int32_t jointCount; double accumulator; // Seconds owed to the fixed step uint64_t lastTick; bool enabled; @@ -132,15 +209,121 @@ namespace { WorldT *_world = nullptr; + // A contact ending only matters for triggers: the pair is reported as left. This runs inside + // Jolt's step on a job thread, where taking a body lock deadlocks, so the lock-free interface + // reads the user data; the trigger flag comes from our records. + void ContactListenerT::OnContactRemoved(const JPH::SubShapeIDPair &pair) { + PhysicsEventT event; + JPH::uint64 userA = _world->system->GetBodyInterfaceNoLock().GetUserData(pair.GetBody1ID()); + JPH::uint64 userB = _world->system->GetBodyInterfaceNoLock().GetUserData(pair.GetBody2ID()); + bool sensorA = _isTrigger(pair.GetBody1ID()); + bool sensorB = _isTrigger(pair.GetBody2ID()); + + if (!sensorA && !sensorB) { + return; + } + event.type = PHYSICS_EVENT_LEAVE; + event.nodeA = (int32_t)(uint32_t)(sensorA ? userA : userB); + event.nodeB = (int32_t)(uint32_t)(sensorA ? userB : userA); + event.point = vec3(0.0f, 0.0f, 0.0f); + event.speed = 0.0f; + push(event); + } + + + JPH::RefConst _buildMeshShape(int32_t node, ShapeTypeE shape, Vec3T position, QuatT rotation); + void _collectGeometry(int32_t node, const Mat4T *toBody, JPH::Array &points, JPH::IndexedTriangleList &triangles, JPH::VertexList &vertices); BodyRecordT *_find(int32_t node); JPH::Quat _fromQuat(QuatT q); JPH::Vec3 _fromVec3(Vec3T v); + bool _isTrigger(JPH::BodyID id); void _release(BodyRecordT *record); QuatT _toQuat(JPH::Quat q); Vec3T _toVec3(JPH::Vec3 v); void _trace(const char *fmt, ...); + // A convex hull or a triangle mesh from the geometry under a node (its own mesh and every + // descendant's, a model instance included), in the body's frame: the node's world scale is + // baked in, its position and rotation are the body's. + JPH::RefConst _buildMeshShape(int32_t node, ShapeTypeE shape, Vec3T position, QuatT rotation) { + JPH::Array points; + JPH::VertexList vertices; + JPH::IndexedTriangleList triangles; + Mat4T bodyWorld = mat4Compose(position, rotation, vec3(1.0f, 1.0f, 1.0f)); + Mat4T toBody; + + if (!mat4Invert(bodyWorld, &toBody)) { + return nullptr; + } + _collectGeometry(node, &toBody, points, triangles, vertices); + if (shape == SHAPE_HULL) { + JPH::ConvexHullShapeSettings settings(points); + JPH::Shape::ShapeResult result; + + if (points.size() < 4) { + utilTrace("Physics: node %d has too little geometry for a hull.", node); + return nullptr; + } + result = settings.Create(); + if (result.HasError()) { + utilTrace("Physics: hull: %s", result.GetError().c_str()); + return nullptr; + } + return result.Get(); + } + { + JPH::MeshShapeSettings settings(vertices, triangles); + JPH::Shape::ShapeResult result; + + if (triangles.empty()) { + utilTrace("Physics: node %d has no triangles for a mesh shape.", node); + return nullptr; + } + result = settings.Create(); + if (result.HasError()) { + utilTrace("Physics: mesh: %s", result.GetError().c_str()); + return nullptr; + } + return result.Get(); + } + } + + + // Gathers the node's mesh and its descendants' into the body's frame. + void _collectGeometry(int32_t node, const Mat4T *toBody, JPH::Array &points, JPH::IndexedTriangleList &triangles, JPH::VertexList &vertices) { + const float *positions; + const uint32_t *indices; + int32_t vertexCount; + int32_t indexCount; + int32_t mesh = nodeGetMesh(node); + int32_t x; + + if ((mesh != NO_HANDLE) && meshGetGeometry(mesh, &positions, &vertexCount, &indices, &indexCount)) { + Vec3T position; + QuatT rotation; + Vec3T scale; + Mat4T local; + uint32_t base = (uint32_t)vertices.size(); + + nodeGetWorldTransform(node, &position, &rotation, &scale); + local = mat4Multiply(*toBody, mat4Compose(position, rotation, scale)); + for (x = 0; x < vertexCount; x++) { + Vec3T v = mat4TransformPoint(local, vec3(positions[x * 3], positions[x * 3 + 1], positions[x * 3 + 2])); + + points.push_back(JPH::Vec3(v.x, v.y, v.z)); + vertices.push_back(JPH::Float3(v.x, v.y, v.z)); + } + for (x = 0; x + 2 < indexCount; x += 3) { + triangles.push_back(JPH::IndexedTriangle(base + indices[x], base + indices[x + 1], base + indices[x + 2])); + } + } + for (x = 0; x < nodeGetChildCount(node); x++) { + _collectGeometry(nodeGetChild(node, x), toBody, points, triangles, vertices); + } + } + + // The record for a node's body, or NULL. A record whose node was deleted (or reused) is // released on the way. BodyRecordT *_find(int32_t node) { @@ -175,10 +358,29 @@ namespace { } - // Takes the body out of the world and frees its slot. + // Whether a Jolt body is one of our triggers (by record, so a body being destroyed is safe). + bool _isTrigger(JPH::BodyID id) { + int32_t x; + + for (x = 0; x < _world->bodyCount; x++) { + if (_world->bodies[x].used && (_world->bodies[x].id == id)) { + return _world->bodies[x].trigger; + } + } + return false; + } + + + // Takes the body out of the world (its joints first) and frees its slot. void _release(BodyRecordT *record) { JPH::BodyInterface &bodies = _world->system->GetBodyInterface(); + int32_t x; + for (x = 0; x < _world->jointCount; x++) { + if (_world->joints[x].used && ((_world->joints[x].nodeA == record->node) || (_world->joints[x].nodeB == record->node))) { + jointDelete(x); + } + } if (record->enabled) { bodies.RemoveBody(record->id); } @@ -329,8 +531,19 @@ bool bodyNew(int32_t node, BodyTypeE type, ShapeTypeE shape, float a, float b, f height = SDL_max(b * scale.y, MIN_DIMENSION); joltShape = new JPH::CylinderShape(height / 2.0f, radius); break; + case SHAPE_HULL: + case SHAPE_MESH: + if ((shape == SHAPE_MESH) && (type == BODY_DYNAMIC)) { + utilTrace("Physics: a mesh shape can only be static or kinematic; use a hull for node %d.", node); + return false; + } + joltShape = _buildMeshShape(node, shape, position, rotation); + if (joltShape == nullptr) { + return false; + } + break; default: - utilTrace("Physics: shape %d is not available yet.", (int32_t)shape); + utilTrace("Physics: unknown shape %d.", (int32_t)shape); return false; } for (x = 0; x < _world->bodyCount; x++) { @@ -440,6 +653,26 @@ bool bodySetMass(int32_t node, float kilograms) { } +// A trigger (Jolt sensor) reports what enters and leaves it and pushes nothing. +bool bodySetTrigger(int32_t node, bool trigger) { + BodyRecordT *record = _find(node); + + if (record == nullptr) { + return false; + } + { + JPH::BodyLockWrite lock(_world->system->GetBodyLockInterface(), record->id); + + if (!lock.Succeeded()) { + return false; + } + lock.GetBody().SetIsSensor(trigger); + } + record->trigger = trigger; + return true; +} + + bool bodySetVelocity(int32_t node, Vec3T velocity) { BodyRecordT *record = _find(node); @@ -451,6 +684,131 @@ bool bodySetVelocity(int32_t node, Vec3T velocity) { } +// ===== Joints ===== + +bool jointDelete(int32_t joint) { + if (!jointValid(joint)) { + return false; + } + _world->system->RemoveConstraint(_world->joints[joint].constraint); + _world->joints[joint].constraint = nullptr; + _world->joints[joint].used = false; + return true; +} + + +// A hinge (anchor and axis), ball (anchor) or slider (axis) between two bodies, or between a body +// and the world when nodeB is -1. Anchor and axis are in world space. nodeA is Jolt's first +// body (creating them the other way round mirrors the motion); see jointSetLimits for the sign +// that implies. +int32_t jointNew(JointTypeE type, int32_t nodeA, int32_t nodeB, Vec3T anchor, Vec3T axis) { + BodyRecordT *a = _find(nodeA); + BodyRecordT *b = (nodeB == WORLD_NODE) ? nullptr : _find(nodeB); + JPH::Ref constraint; + JPH::Vec3 direction; + JPH::Vec3 normal; + int32_t x; + + if ((_world == nullptr) || (a == nullptr) || ((nodeB != WORLD_NODE) && (b == nullptr))) { + return NO_HANDLE; + } + direction = _fromVec3(axis).NormalizedOr(JPH::Vec3::sAxisY()); + normal = direction.GetNormalizedPerpendicular(); + { + JPH::BodyID ids[2] = { a->id, (b != nullptr) ? b->id : JPH::BodyID() }; + JPH::BodyLockMultiWrite lock(_world->system->GetBodyLockInterface(), ids, (b != nullptr) ? 2 : 1); + JPH::Body *bodyA = lock.GetBody(0); + JPH::Body *bodyB = (b != nullptr) ? lock.GetBody(1) : &JPH::Body::sFixedToWorld; + + if ((bodyA == nullptr) || (bodyB == nullptr)) { + return NO_HANDLE; + } + if (type == JOINT_HINGE) { + JPH::HingeConstraintSettings settings; + + settings.mSpace = JPH::EConstraintSpace::WorldSpace; + settings.mPoint1 = JPH::RVec3(anchor.x, anchor.y, anchor.z); + settings.mPoint2 = settings.mPoint1; + settings.mHingeAxis1 = direction; + settings.mHingeAxis2 = direction; + settings.mNormalAxis1 = normal; + settings.mNormalAxis2 = normal; + constraint = settings.Create(*bodyA, *bodyB); + } else if (type == JOINT_BALL) { + JPH::PointConstraintSettings settings; + + settings.mSpace = JPH::EConstraintSpace::WorldSpace; + settings.mPoint1 = JPH::RVec3(anchor.x, anchor.y, anchor.z); + settings.mPoint2 = settings.mPoint1; + constraint = settings.Create(*bodyA, *bodyB); + } else { + JPH::SliderConstraintSettings settings; + + settings.mSpace = JPH::EConstraintSpace::WorldSpace; + settings.mPoint1 = JPH::RVec3(anchor.x, anchor.y, anchor.z); + settings.mPoint2 = settings.mPoint1; + settings.mSliderAxis1 = direction; + settings.mSliderAxis2 = direction; + settings.mNormalAxis1 = normal; + settings.mNormalAxis2 = normal; + constraint = settings.Create(*bodyA, *bodyB); + } + } + if (constraint == nullptr) { + return NO_HANDLE; + } + _world->system->AddConstraint(constraint); + for (x = 0; x < _world->jointCount; x++) { + if (!_world->joints[x].used) { + break; + } + } + if (x == _world->jointCount) { + JointRecordT *grown = new JointRecordT[_world->jointCount + 1]; + + for (int32_t y = 0; y < _world->jointCount; y++) { + grown[y] = _world->joints[y]; + } + delete[] _world->joints; + _world->joints = grown; + _world->jointCount++; + } + _world->joints[x].constraint = constraint; + _world->joints[x].type = type; + _world->joints[x].nodeA = nodeA; + _world->joints[x].nodeB = nodeB; + _world->joints[x].used = true; + return x; +} + + +// Limits: degrees either side of the starting angle for a hinge (positive is a right-hand turn of +// nodeA about the axis), distance along the axis for a slider (positive along it); low at or below +// 0, high at or above. Jolt measures both as its second body relative to its first, which is the +// opposite of nodeA's own motion, so the range is mirrored on the way in. A ball joint has none. +bool jointSetLimits(int32_t joint, float low, float high) { + if (!jointValid(joint)) { + return false; + } + low = SDL_min(low, 0.0f); + high = SDL_max(high, 0.0f); + if (_world->joints[joint].type == JOINT_HINGE) { + ((JPH::HingeConstraint *)_world->joints[joint].constraint.GetPtr())->SetLimits(DEGREES_TO_RADIANS(-high), DEGREES_TO_RADIANS(-low)); + return true; + } + if (_world->joints[joint].type == JOINT_SLIDER) { + ((JPH::SliderConstraint *)_world->joints[joint].constraint.GetPtr())->SetLimits(-high, -low); + return true; + } + return false; +} + + +bool jointValid(int32_t joint) { + return (_world != nullptr) && (joint >= 0) && (joint < _world->jointCount) && _world->joints[joint].used; +} + + // ===== World ===== bool physicsAvailable(void) { @@ -458,6 +816,26 @@ bool physicsAvailable(void) { } +// Hands the engine the events the last step produced (up to maximum) and clears them. +int32_t physicsGetEvents(PhysicsEventT *events, int32_t maximum) { + int32_t count = 0; + + if (_world == nullptr) { + return 0; + } + { + std::lock_guard guard(_world->contacts->lock); + + while ((count < maximum) && (count < (int32_t)_world->contacts->events.size())) { + events[count] = _world->contacts->events[(size_t)count]; + count++; + } + _world->contacts->events.clear(); + } + return count; +} + + // Brings Jolt up: allocators, the type factory, a job pool sized to the machine, and an empty // world. Refuses (returning false, 3D physics unavailable) on an x86 without SSE4.1 and 4.2, the // level the library was compiled for, rather than faulting on the first instruction. @@ -483,6 +861,8 @@ bool physicsInit(void) { _world->jobs = new JPH::JobSystemThreadPool(JPH::cMaxPhysicsJobs, JPH::cMaxPhysicsBarriers, threads); _world->system = new JPH::PhysicsSystem(); _world->system->Init(MAX_BODIES, 0, MAX_BODY_PAIRS, MAX_CONTACTS, _world->broadPhaseLayers, _world->objectVsBroadPhase, _world->objectPairs); + _world->contacts = new ContactListenerT(); + _world->system->SetContactListener(_world->contacts); _world->enabled = true; utilTrace("Physics: Jolt %d.%d.%d ready, %d job thread%s", JPH_VERSION_MAJOR, JPH_VERSION_MINOR, JPH_VERSION_PATCH, threads, (threads == 1) ? "" : "s"); return true; @@ -501,7 +881,9 @@ void physicsQuit(void) { } } SDL_free(_world->bodies); + delete[] _world->joints; delete _world->system; + delete _world->contacts; delete _world->jobs; delete _world->tempAllocator; delete _world; @@ -512,6 +894,41 @@ void physicsQuit(void) { } +// The nearest body along a ray (triggers included), with the hit point and surface normal. +bool physicsRaycast(Vec3T origin, Vec3T direction, float maxDistance, int32_t *node, Vec3T *point, Vec3T *normal) { + JPH::RayCastResult result; + JPH::Vec3 dir; + JPH::RVec3 hit; + + if (_world == nullptr) { + return false; + } + dir = _fromVec3(direction).NormalizedOr(JPH::Vec3::sZero()); + if (dir.IsNearZero()) { + return false; + } + if (maxDistance <= 0.0f) { + maxDistance = DEFAULT_RAY_DISTANCE; + } + { + JPH::RRayCast ray(JPH::RVec3(origin.x, origin.y, origin.z), dir * maxDistance); + + if (!_world->system->GetNarrowPhaseQuery().CastRay(ray, result)) { + return false; + } + hit = ray.GetPointOnRay(result.mFraction); + } + *node = (int32_t)(uint32_t)_world->system->GetBodyInterface().GetUserData(result.mBodyID); + *point = vec3((float)hit.GetX(), (float)hit.GetY(), (float)hit.GetZ()); + { + JPH::BodyLockRead lock(_world->system->GetBodyLockInterface(), result.mBodyID); + + *normal = lock.Succeeded() ? _toVec3(lock.GetBody().GetWorldSpaceSurfaceNormal(result.mSubShapeID2, hit)) : vec3(0.0f, 1.0f, 0.0f); + } + return true; +} + + // Pauses the simulation (bodies hold still) without losing it. void physicsSetEnabled(bool enabled) { if (_world != nullptr) { diff --git a/src/scene.c b/src/scene.c index e7393d2f8..ad4e56f19 100644 --- a/src/scene.c +++ b/src/scene.c @@ -111,6 +111,9 @@ typedef struct MeshS { uint32_t indexCount; Vec3T boundsMin; // Of the vertices, for fitting the shadow map Vec3T boundsMax; + float *positions; // A CPU copy of the geometry (x, y, z per vertex) for physics shapes + uint32_t *indices; + int32_t vertexCount; bool skinned; bool used; } MeshT; @@ -300,9 +303,21 @@ static int32_t _addMesh(const SceneVertexT *vertices, int32_t vertexCount, const meshDelete(x); return NO_HANDLE; } - mesh->indexCount = (uint32_t)indexCount; - mesh->skinned = skinned; - mesh->used = true; + mesh->indexCount = (uint32_t)indexCount; + mesh->vertexCount = vertexCount; + mesh->skinned = skinned; + mesh->used = true; + mesh->positions = SDL_malloc(sizeof(float) * 3 * (size_t)vertexCount); + mesh->indices = SDL_malloc(sizeof(uint32_t) * (size_t)indexCount); + if ((mesh->positions == NULL) || (mesh->indices == NULL)) { + utilDie("Out of memory keeping a mesh's geometry."); + } + for (x = 0; x < vertexCount; x++) { + mesh->positions[x * 3] = vertices[x].position[0]; + mesh->positions[x * 3 + 1] = vertices[x].position[1]; + mesh->positions[x * 3 + 2] = vertices[x].position[2]; + } + memcpy(mesh->indices, indices, sizeof(uint32_t) * (size_t)indexCount); mesh->boundsMin = vec3(vertices[0].position[0], vertices[0].position[1], vertices[0].position[2]); mesh->boundsMax = mesh->boundsMin; for (x = 1; x < vertexCount; x++) { @@ -1663,6 +1678,8 @@ bool meshDelete(int32_t mesh) { if (_scene.meshes[mesh].indexBuffer != NULL) { SDL_ReleaseGPUBuffer(_scene.device, _scene.meshes[mesh].indexBuffer); } + SDL_free(_scene.meshes[mesh].positions); + SDL_free(_scene.meshes[mesh].indices); memset(&_scene.meshes[mesh], 0, sizeof(MeshT)); for (x = 0; x < _scene.nodeCount; x++) { if (_scene.nodes[x].used && (_scene.nodes[x].mesh == mesh)) { @@ -1673,6 +1690,19 @@ bool meshDelete(int32_t mesh) { } +// The mesh's geometry as kept on the CPU: x, y, z per vertex and triangle indices. +bool meshGetGeometry(int32_t mesh, const float **positions, int32_t *vertexCount, const uint32_t **indices, int32_t *indexCount) { + if (!meshValid(mesh)) { + return false; + } + *positions = _scene.meshes[mesh].positions; + *vertexCount = _scene.meshes[mesh].vertexCount; + *indices = _scene.meshes[mesh].indices; + *indexCount = (int32_t)_scene.meshes[mesh].indexCount; + return true; +} + + // Raw geometry from a script: positions (3 per vertex), normals (3, may be NULL for flat // shading computed here), uvs (2, may be NULL), and triangle indices. int32_t meshNew(const float *positions, const float *normals, const float *uvs, int32_t vertexCount, const uint32_t *indices, int32_t indexCount) { @@ -1940,6 +1970,15 @@ uint32_t nodeGetGeneration(int32_t node) { } +// The node's mesh handle, or -1. +int32_t nodeGetMesh(int32_t node) { + if (!nodeValid(node)) { + return NO_HANDLE; + } + return _scene.nodes[node].mesh; +} + + const char *nodeGetName(int32_t node) { if (!nodeValid(node) || (_scene.nodes[node].name == NULL)) { return ""; diff --git a/src/scene.h b/src/scene.h index 6c1bf8f75..7f250b811 100644 --- a/src/scene.h +++ b/src/scene.h @@ -97,6 +97,7 @@ int32_t meshBox(float width, float height, float depth); int32_t meshCone(float radius, float height, int32_t segments); int32_t meshCylinder(float radius, float height, int32_t segments); bool meshDelete(int32_t mesh); +bool meshGetGeometry(int32_t mesh, const float **positions, int32_t *vertexCount, const uint32_t **indices, int32_t *indexCount); int32_t meshNew(const float *positions, const float *normals, const float *uvs, int32_t vertexCount, const uint32_t *indices, int32_t indexCount); int32_t meshNewVertices(const SceneVertexT *vertices, int32_t vertexCount, const uint32_t *indices, int32_t indexCount, bool skinned); int32_t meshPlane(float width, float depth); @@ -109,6 +110,7 @@ int32_t nodeFind(int32_t root, const char *name); int32_t nodeGetChild(int32_t node, int32_t index); int32_t nodeGetChildCount(int32_t node); uint32_t nodeGetGeneration(int32_t node); +int32_t nodeGetMesh(int32_t node); const char *nodeGetName(int32_t node); int32_t nodeGetParent(int32_t node); Vec3T nodeGetPosition(int32_t node); diff --git a/src/singe.c b/src/singe.c index 2d2f1046f..15e32f0e4 100644 --- a/src/singe.c +++ b/src/singe.c @@ -496,6 +496,7 @@ static void _pauseAllVideos(bool pause); static void _processKey(bool down, int32_t keysym, int32_t scancode); static void _progTrace(const char *fmt, ...) __attribute__((format(printf, 1, 2))); static int32_t _pushVec3(lua_State *L, Vec3T v); +static void _physicsCallbacks(void); static SDL_Texture *_sceneVideoSource(int32_t player); static void _pushConstants(lua_State *L); static void _putPixel(int32_t x, int32_t y, uint32_t pixel); @@ -540,6 +541,7 @@ static int32_t apiBodySetBounce(lua_State *L); static int32_t apiBodySetEnabled(lua_State *L); static int32_t apiBodySetFriction(lua_State *L); static int32_t apiBodySetMass(lua_State *L); +static int32_t apiBodySetTrigger(lua_State *L); static int32_t apiBodySetVelocity(lua_State *L); static int32_t apiCameraSet(lua_State *L); static int32_t apiCameraSetOrthographic(lua_State *L); @@ -577,6 +579,11 @@ static int32_t apiFontQuality(lua_State *L); static int32_t apiFontSelect(lua_State *L); static int32_t apiFontToSprite(lua_State *L); static int32_t apiFontUnload(lua_State *L); +static int32_t apiJointBall(lua_State *L); +static int32_t apiJointDelete(lua_State *L); +static int32_t apiJointHinge(lua_State *L); +static int32_t apiJointSetLimits(lua_State *L); +static int32_t apiJointSlider(lua_State *L); static int32_t apiKeyboardGetLastDown(lua_State *L); static int32_t apiKeyboardGetLastUp(lua_State *L); static int32_t apiKeyboardGetMode(lua_State *L); @@ -650,6 +657,7 @@ static int32_t apiOverlayLine(lua_State *L); static int32_t apiOverlayPlot(lua_State *L); static int32_t apiOverlayPrint(lua_State *L); static int32_t apiOverlaySetResolution(lua_State *L); +static int32_t apiPhysicsRaycast(lua_State *L); static int32_t apiPhysicsSetEnabled(lua_State *L); static int32_t apiPhysicsSetGravity(lua_State *L); static int32_t apiSceneEnable(lua_State *L); @@ -1088,6 +1096,9 @@ static void _callLua(const char *func, const char *sig, ...) { case 'i': // Int lua_pushinteger(_global.luaContext, va_arg(vl, int)); // Promoted type for varargs. break; + case 'b': // Boolean (passed as an int) + lua_pushboolean(_global.luaContext, va_arg(vl, int32_t)); + break; case 's': // String lua_pushstring(_global.luaContext, va_arg(vl, char *)); @@ -2108,6 +2119,26 @@ static int32_t _pushVec3(lua_State *L, Vec3T v) { } +// Hands the step's contacts and trigger overlaps to the script: onCollision(nodeA, nodeB, x, y, z, +// speed) and onTrigger(trigger, other, entered), when the script defines them. +static void _physicsCallbacks(void) { + PhysicsEventT events[64]; + int32_t count; + int32_t x; + + count = physicsGetEvents(events, (int32_t)SDL_arraysize(events)); + for (x = 0; x < count; x++) { + PhysicsEventT *event = &events[x]; + + if (event->type == PHYSICS_EVENT_COLLISION) { + _callLua("onCollision", "iidddd", event->nodeA, event->nodeB, (double)event->point.x, (double)event->point.y, (double)event->point.z, (double)event->speed); + } else { + _callLua("onTrigger", "iib", event->nodeA, event->nodeB, (event->type == PHYSICS_EVENT_ENTER) ? 1 : 0); + } + } +} + + // A player's current frame for the 3D scene's video materials: the disc's texture is already // updated for this frame; a loaded video is advanced here (drawing it on the overlay too is harmless). static SDL_Texture *_sceneVideoSource(int32_t player) { @@ -2200,6 +2231,12 @@ static void _pushConstants(lua_State *L) { lua_setglobal(L, "SHAPE_HULL"); lua_pushinteger(L, SHAPE_MESH); lua_setglobal(L, "SHAPE_MESH"); + lua_pushinteger(L, JOINT_HINGE); + lua_setglobal(L, "JOINT_HINGE"); + lua_pushinteger(L, JOINT_BALL); + lua_setglobal(L, "JOINT_BALL"); + lua_pushinteger(L, JOINT_SLIDER); + lua_setglobal(L, "JOINT_SLIDER"); lua_pushinteger(L, -1); lua_setglobal(L, "SOUND_ERROR_INVALID"); @@ -2819,7 +2856,7 @@ static int32_t apiBodyIsResting(lua_State *L) { } -// bodyNew(node, type, shape, a [, b [, c]]): BODY_* and SHAPE_*; a, b, c size the shape +// bodyNew(node, type, shape [, a [, b [, c]]]): BODY_* and SHAPE_*; a, b, c size the primitive shapes static int32_t apiBodyNew(lua_State *L) { int32_t node; int32_t type; @@ -2898,6 +2935,14 @@ static int32_t apiBodySetMass(lua_State *L) { } +// bodySetTrigger(node, bool): a trigger reports what enters and leaves it (onTrigger) and pushes nothing +static int32_t apiBodySetTrigger(lua_State *L) { + _argCheck(L, "bodySetTrigger", 2, 2); + bodySetTrigger(_argBody(L, "bodySetTrigger", 1), _argBoolean(L, "bodySetTrigger", 2)); + return 0; +} + + // bodySetVelocity(node, x, y, z) static int32_t apiBodySetVelocity(lua_State *L) { _argCheck(L, "bodySetVelocity", 4, 4); @@ -3457,6 +3502,95 @@ static int32_t apiFontUnload(lua_State *L) { } +// joint = jointBall(nodeA, nodeB, ax, ay, az): nodeB -1 fixes to the world; anchor in world space +static int32_t apiJointBall(lua_State *L) { + int32_t nodeA; + int32_t nodeB; + int32_t joint; + + _argCheck(L, "jointBall", 5, 5); + nodeA = _argBody(L, "jointBall", 1); + nodeB = _argInteger(L, "jointBall", 2); + if ((nodeB != -1) && !bodyExists(nodeB)) { + _luaDie(L, "jointBall", "Node %d has no body.", nodeB); + } + joint = jointNew(JOINT_BALL, nodeA, nodeB, _argVec3(L, "jointBall", 3), vec3(0.0f, 1.0f, 0.0f)); + if (joint < 0) { + _luaDie(L, "jointBall", "Unable to create the joint."); + } + lua_pushinteger(L, joint); + return 1; +} + + +// jointDelete(joint) +static int32_t apiJointDelete(lua_State *L) { + int32_t joint; + + _argCheck(L, "jointDelete", 1, 1); + joint = _argInteger(L, "jointDelete", 1); + if (!jointDelete(joint)) { + _luaDie(L, "jointDelete", "No joint %d.", joint); + } + return 0; +} + + +// joint = jointHinge(nodeA, nodeB, ax, ay, az, dx, dy, dz): anchor and axis in world space; nodeB -1 is the world +static int32_t apiJointHinge(lua_State *L) { + int32_t nodeA; + int32_t nodeB; + int32_t joint; + + _argCheck(L, "jointHinge", 8, 8); + nodeA = _argBody(L, "jointHinge", 1); + nodeB = _argInteger(L, "jointHinge", 2); + if ((nodeB != -1) && !bodyExists(nodeB)) { + _luaDie(L, "jointHinge", "Node %d has no body.", nodeB); + } + joint = jointNew(JOINT_HINGE, nodeA, nodeB, _argVec3(L, "jointHinge", 3), _argVec3(L, "jointHinge", 6)); + if (joint < 0) { + _luaDie(L, "jointHinge", "Unable to create the joint."); + } + lua_pushinteger(L, joint); + return 1; +} + + +// jointSetLimits(joint, low, high): degrees for a hinge, distance for a slider +static int32_t apiJointSetLimits(lua_State *L) { + int32_t joint; + + _argCheck(L, "jointSetLimits", 3, 3); + joint = _argInteger(L, "jointSetLimits", 1); + if (!jointSetLimits(joint, (float)_argNumber(L, "jointSetLimits", 2), (float)_argNumber(L, "jointSetLimits", 3))) { + _luaDie(L, "jointSetLimits", "Joint %d has no limits to set.", joint); + } + return 0; +} + + +// joint = jointSlider(nodeA, nodeB, ax, ay, az, dx, dy, dz): slides along the axis through the anchor; nodeB -1 is the world +static int32_t apiJointSlider(lua_State *L) { + int32_t nodeA; + int32_t nodeB; + int32_t joint; + + _argCheck(L, "jointSlider", 8, 8); + nodeA = _argBody(L, "jointSlider", 1); + nodeB = _argInteger(L, "jointSlider", 2); + if ((nodeB != -1) && !bodyExists(nodeB)) { + _luaDie(L, "jointSlider", "Node %d has no body.", nodeB); + } + joint = jointNew(JOINT_SLIDER, nodeA, nodeB, _argVec3(L, "jointSlider", 3), _argVec3(L, "jointSlider", 6)); + if (joint < 0) { + _luaDie(L, "jointSlider", "Unable to create the joint."); + } + lua_pushinteger(L, joint); + return 1; +} + + // scancode = keyboardGetLastDown() Cleared every frame. static int32_t apiKeyboardGetLastDown(lua_State *L) { _luaTrace(L, "keyboardGetLastDown", "%d", _global.keyboardLastDown); @@ -4628,6 +4762,32 @@ static int32_t apiOverlaySetResolution(lua_State *L) { } +// node, hx, hy, hz, nx, ny, nz = physicsRaycast(x, y, z, dx, dy, dz [, maxDistance]): nil when nothing is hit +static int32_t apiPhysicsRaycast(lua_State *L) { + Vec3T origin; + Vec3T direction; + float maxDistance = 0.0f; + int32_t node; + Vec3T point; + Vec3T normal; + + _argCheck(L, "physicsRaycast", 6, 7); + origin = _argVec3(L, "physicsRaycast", 1); + direction = _argVec3(L, "physicsRaycast", 4); + if (lua_gettop(L) >= 7) { + maxDistance = (float)_argNumber(L, "physicsRaycast", 7); + } + if (!physicsRaycast(origin, direction, maxDistance, &node, &point, &normal)) { + lua_pushnil(L); + return 1; + } + lua_pushinteger(L, node); + _pushVec3(L, point); + _pushVec3(L, normal); + return 7; +} + + // physicsSetEnabled(bool): pauses the simulation without losing it static int32_t apiPhysicsSetEnabled(lua_State *L) { _argCheck(L, "physicsSetEnabled", 1, 1); @@ -6173,6 +6333,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co lua_register(_global.luaContext, "bodySetEnabled", apiBodySetEnabled); // 3.00 lua_register(_global.luaContext, "bodySetFriction", apiBodySetFriction); // 3.00 lua_register(_global.luaContext, "bodySetMass", apiBodySetMass); // 3.00 + lua_register(_global.luaContext, "bodySetTrigger", apiBodySetTrigger); // 3.00 lua_register(_global.luaContext, "bodySetVelocity", apiBodySetVelocity); // 3.00 lua_register(_global.luaContext, "colorBackground", apiColorBackground); // 1.xx lua_register(_global.luaContext, "colorForeground", apiColorForeground); // 1.xx @@ -6216,6 +6377,11 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co lua_register(_global.luaContext, "fontToSprite", apiFontToSprite); // 1.xx lua_register(_global.luaContext, "fontUnload", apiFontUnload); // 2.00 + lua_register(_global.luaContext, "jointBall", apiJointBall); // 3.00 + lua_register(_global.luaContext, "jointDelete", apiJointDelete); // 3.00 + lua_register(_global.luaContext, "jointHinge", apiJointHinge); // 3.00 + lua_register(_global.luaContext, "jointSetLimits", apiJointSetLimits); // 3.00 + lua_register(_global.luaContext, "jointSlider", apiJointSlider); // 3.00 lua_register(_global.luaContext, "keyboardGetLastDown", apiKeyboardGetLastDown); // 2.10 lua_register(_global.luaContext, "keyboardGetLastUp", apiKeyboardGetLastUp); // 2.10 lua_register(_global.luaContext, "keyboardGetMode", apiKeyboardGetMode); // 1.xx RDG @@ -6291,6 +6457,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co lua_register(_global.luaContext, "overlayPrint", apiOverlayPrint); // 1.xx lua_register(_global.luaContext, "overlaySetResolution", apiOverlaySetResolution); // 2.00 + lua_register(_global.luaContext, "physicsRaycast", apiPhysicsRaycast); // 3.00 lua_register(_global.luaContext, "physicsSetEnabled", apiPhysicsSetEnabled); // 3.00 lua_register(_global.luaContext, "physicsSetGravity", apiPhysicsSetGravity); // 3.00 lua_register(_global.luaContext, "sceneEnable", apiSceneEnable); // 3.00 @@ -6849,6 +7016,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co // 3D scene modelUpdate(!_global.frozen); physicsUpdate(!_global.frozen); + _physicsCallbacks(); sceneUpdateVideo(_sceneVideoSource); sceneTexture = sceneRender(); if (sceneTexture != NULL) { diff --git a/testScripts/README.md b/testScripts/README.md index 5ee2e4fbe..405ac4b8c 100644 --- a/testScripts/README.md +++ b/testScripts/README.md @@ -24,3 +24,5 @@ screenshot or two and quits by itself; the results are in screenshots/. | scene7.singe | 7 | 18.6b | Shadows: sun, sun and spot together, a point light inside | | scene8.singe | 8 | 18 | An arcade cabinet from primitives with the disc on screen | | scene9.singe | 9 | 19.2 | Physics: a crate stack, a ball, a kinematic paddle | +| scene10.singe | 10 | 19.3 | Physics: a trigger volume, collision callbacks, a raycast pick | +| scene11.singe | 11 | 19.4 | Physics: a hull down a ramp, a mesh torus, hinge, ball and slider joints | diff --git a/testScripts/games.dat b/testScripts/games.dat index 5573af331..24efbaadd 100644 --- a/testScripts/games.dat +++ b/testScripts/games.dat @@ -98,4 +98,26 @@ GAMES = { DEVELOPER = "Test", PUBLISHER = "Test", }, + { + TITLE = "Physics2", + SCRIPT = "testScripts/scene10.singe", + VIDEO = "Singe/menuBackground.mkv", + DESCRIPTION = "A trigger volume, collision callbacks and a raycast pick.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, + { + TITLE = "Physics3", + SCRIPT = "testScripts/scene11.singe", + VIDEO = "Singe/menuBackground.mkv", + DESCRIPTION = "A hull rolling down a ramp, a mesh torus, a hinged door, a pendulum, a slider.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, } diff --git a/testScripts/scene10.singe b/testScripts/scene10.singe new file mode 100644 index 000000000..4c1b8d637 --- /dev/null +++ b/testScripts/scene10.singe @@ -0,0 +1,122 @@ +-- Physics stage 3: a trigger volume in the crates' path, collision callbacks, and a raycast +-- through the middle of the overlay that flicks whatever it hits. +local font = fontLoad("Singe/FreeSansBold.ttf", 28) +local frames = 0 +local entered, left, collisions, lastSpeed, picks = 0, 0, 0, 0, 0 + +fontSelect(font) +discPlay() +sceneEnable(true) +sceneSetBackground(18, 18, 30, 255) +sceneSetAmbient(70, 70, 85) + +local floorLook = materialNew() +materialSetColor(floorLook, 120, 110, 100) +materialSetRoughness(floorLook, 0.9) +local crateLook = materialNew() +materialSetColor(crateLook, 200, 140, 60) +local ballLook = materialNew() +materialSetColor(ballLook, 60, 140, 230) +local paddleLook = materialNew() +materialSetColor(paddleLook, 230, 60, 40) +local zoneLook = materialNew() +materialSetColor(zoneLook, 80, 255, 120, 70) +materialSetBlend(zoneLook, true) +materialSetUnlit(zoneLook, true) +local markLook = materialNew() +materialSetColor(markLook, 255, 255, 80) +materialSetEmissive(markLook, 120, 120, 0) + +local floor = nodeNew() +nodeSetMesh(floor, meshBox(14, 0.2, 14), floorLook) +nodeSetPosition(floor, 0, -1.9, 0) +bodyNew(floor, BODY_STATIC, SHAPE_BOX, 14, 0.2, 14) + +local crates = {} +for i = 1, 4 do + local crate = nodeNew() + nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), crateLook) + nodeSetPosition(crate, -1.0, -1.5 + (i - 1) * 0.62, 0) + bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6) + crates[i] = crate +end + +local ball = nodeNew() +nodeSetMesh(ball, meshSphere(0.35, 32), ballLook) +nodeSetPosition(ball, 0.2, 1.5, 0.3) +bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.35) +bodySetBounce(ball, 0.5) + +-- The trigger: a translucent green volume to the right of the stack. +local zone = nodeNew() +nodeSetMesh(zone, meshBox(1.6, 1.6, 2.4), zoneLook) +nodeSetPosition(zone, 1.8, -1.0, 0) +bodyNew(zone, BODY_STATIC, SHAPE_BOX, 1.6, 1.6, 2.4) +bodySetTrigger(zone, true) + +local paddle = nodeNew() +nodeSetMesh(paddle, meshBox(0.3, 1.2, 1.6), paddleLook) +nodeSetPosition(paddle, -4.0, -1.2, 0) +bodyNew(paddle, BODY_KINEMATIC, SHAPE_BOX, 0.3, 1.2, 1.6) + +-- A marker placed where the pick ray hits. +local mark = nodeNew() +nodeSetMesh(mark, meshSphere(0.08, 12), markLook) +nodeSetVisible(mark, false) + +local sun = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(sun, -3, 6, 4) +nodeLookAt(sun, 0, 0, 0) +lightSetIntensity(sun, 1.2) +lightSetShadow(sun, true) + +local camera = nodeNew() +nodeSetPosition(camera, 1.0, 1.6, 6.5) +nodeLookAt(camera, 0.2, -0.6, 0) +cameraSet(camera) + +function onCollision(a, b, x, y, z, speed) + collisions = collisions + 1 + lastSpeed = speed +end + +function onTrigger(trigger, other, isEntering) + if isEntering then + entered = entered + 1 + else + left = left + 1 + end + debugPrint(string.format("trigger %d %s node %d", trigger, isEntering and "entered by" or "left by", other)) +end + +function onOverlayUpdate() + frames = frames + 1 + overlayClear() + fontPrint(20, 20, string.format("Physics %d hits %d (%.1f) in %d out %d picks %d", frames, collisions, lastSpeed, entered, left, picks)) + if frames >= 60 and frames < 150 then + nodeMove(paddle, 0.06, 0, 0) + end + if frames % 30 == 0 and frames <= 240 then + -- A ray from the camera through the overlay's centre; flick whatever it hits upward. + local w, h = sceneGetSize() + local ox, oy, oz = sceneUnproject(w / 2, h / 2, 0) + local fx, fy, fz = sceneUnproject(w / 2, h / 2, 10) + local hit, hx, hy, hz = physicsRaycast(ox, oy, oz, fx - ox, fy - oy, fz - oz) + if hit then + picks = picks + 1 + nodeSetVisible(mark, true) + nodeSetPosition(mark, hx, hy, hz) + if hit ~= floor and hit ~= zone and hit ~= paddle then + bodyApplyImpulse(hit, 0, 3, 0, hx, hy, hz) + end + debugPrint(string.format("pick node %d at %.2f %.2f %.2f", hit, hx, hy, hz)) + end + end + if frames == 50 or frames == 130 or frames == 220 then + singeScreenshot() + end + if frames == 250 then + debugPrint(string.format("RESULT collisions=%d entered=%d left=%d picks=%d", collisions, entered, left, picks)) + singeQuit() + end +end diff --git a/testScripts/scene11.singe b/testScripts/scene11.singe new file mode 100644 index 000000000..9014ea685 --- /dev/null +++ b/testScripts/scene11.singe @@ -0,0 +1,125 @@ +-- Physics stage 4: the Duck as a convex hull rolling down a ramp, a torus as a static mesh shape +-- the ball rolls around, a swinging door on a hinge with limits, a pendulum on a ball joint, and +-- a crate on a slider. +local font = fontLoad("Singe/FreeSansBold.ttf", 28) +local frames = 0 + +fontSelect(font) +discPlay() +sceneEnable(true) +sceneSetBackground(18, 18, 30, 255) +sceneSetAmbient(70, 70, 85) + +local floorLook = materialNew() +materialSetColor(floorLook, 120, 110, 100) +materialSetRoughness(floorLook, 0.9) +local rampLook = materialNew() +materialSetColor(rampLook, 90, 100, 130) +local doorLook = materialNew() +materialSetColor(doorLook, 160, 90, 40) +local ballLook = materialNew() +materialSetColor(ballLook, 60, 140, 230) +local ringLook = materialNew() +materialSetColor(ringLook, 220, 200, 60) +materialSetMetallic(ringLook, 0.8) +materialSetRoughness(ringLook, 0.35) +local frameLook = materialNew() +materialSetColor(frameLook, 40, 40, 50) +local crateLook = materialNew() +materialSetColor(crateLook, 200, 140, 60) + +local floor = nodeNew() +nodeSetMesh(floor, meshBox(16, 0.2, 16), floorLook) +nodeSetPosition(floor, 0, -1.9, 0) +bodyNew(floor, BODY_STATIC, SHAPE_BOX, 16, 0.2, 16) + +-- A ramp, and the Duck as a convex hull rolling down it. +local ramp = nodeNew() +nodeSetMesh(ramp, meshBox(3.0, 0.2, 2.2), rampLook) +nodeSetPosition(ramp, -3.4, -1.0, -1.0) +nodeSetRotation(ramp, 0, 0, -28) +bodyNew(ramp, BODY_STATIC, SHAPE_BOX, 3.0, 0.2, 2.2) + +local duck = modelInstance(modelLoad("testScripts/Models/Duck.glb")) +nodeSetPosition(duck, -4.4, 0.6, -1.0) +nodeSetRotation(duck, 0, 90, 0) +nodeSetScale(duck, 0.6) +bodyNew(duck, BODY_DYNAMIC, SHAPE_HULL) +bodySetFriction(duck, 0.4) + +-- A torus lying on the floor as a static triangle mesh; the ball rolls around inside it. +local ring = nodeNew() +nodeSetMesh(ring, meshTorus(1.2, 0.25, 40), ringLook) +nodeSetPosition(ring, 2.6, -1.55, 0.6) +bodyNew(ring, BODY_STATIC, SHAPE_MESH) + +local ball = nodeNew() +nodeSetMesh(ball, meshSphere(0.3, 32), ballLook) +nodeSetPosition(ball, 2.6, 1.2, 0.6) +bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.3) +bodySetBounce(ball, 0.4) + +-- A door hinged to the world along its left edge, limited to swing 100 degrees one way. +local post = nodeNew() +nodeSetMesh(post, meshBox(0.15, 2.4, 0.15), frameLook) +nodeSetPosition(post, -1.0, -0.6, 1.4) +bodyNew(post, BODY_STATIC, SHAPE_BOX, 0.15, 2.4, 0.15) + +local door = nodeNew() +nodeSetMesh(door, meshBox(1.4, 2.2, 0.1), doorLook) +nodeSetPosition(door, -0.1, -0.62, 1.4) +bodyNew(door, BODY_DYNAMIC, SHAPE_BOX, 1.4, 2.2, 0.1) +bodySetMass(door, 8) +local hinge = jointHinge(door, -1, -0.85, -0.62, 1.4, 0, 1, 0) +jointSetLimits(hinge, 0, 100) + +-- A pendulum: a ball hanging from a point in the air. +local bob = nodeNew() +nodeSetMesh(bob, meshSphere(0.25, 24), ballLook) +nodeSetPosition(bob, 1.4, 1.6, -1.6) +bodyNew(bob, BODY_DYNAMIC, SHAPE_SPHERE, 0.25) +jointBall(bob, -1, 0.2, 2.6, -1.6) + +-- A crate on a slider along X, pushed sideways at the start. +local rail = nodeNew() +nodeSetMesh(rail, meshBox(3.0, 0.08, 0.08), frameLook) +nodeSetPosition(rail, 0.6, 0.9, -3.0) +local crate = nodeNew() +nodeSetMesh(crate, meshBox(0.5, 0.5, 0.5), crateLook) +nodeSetPosition(crate, -0.6, 0.9, -3.0) +bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.5, 0.5, 0.5) +local slider = jointSlider(crate, -1, -0.6, 0.9, -3.0, 1, 0, 0) +jointSetLimits(slider, 0, 2.4) +bodySetVelocity(crate, 3, 0, 0) + +local sun = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(sun, -3, 6, 4) +nodeLookAt(sun, 0, 0, 0) +lightSetIntensity(sun, 1.2) +lightSetShadow(sun, true) + +local camera = nodeNew() +nodeSetPosition(camera, 0.8, 2.4, 7.5) +nodeLookAt(camera, 0, -0.4, -0.5) +cameraSet(camera) +cameraSetPerspective(52, 0.1, 100) + +function onOverlayUpdate() + frames = frames + 1 + overlayClear() + fontPrint(20, 20, "Physics joints and hulls, frame " .. frames) + if frames == 40 then + -- Kick the door and the pendulum. + bodyApplyImpulse(door, 0, 0, -60, 0.5, -0.62, 1.45) + bodyApplyImpulse(bob, 6, 0, 0) + end + if frames == 30 or frames == 90 or frames == 180 then + singeScreenshot() + end + if frames == 200 then + local dx, dy, dz = nodeGetWorldPosition(duck) + local cx = nodeGetWorldPosition(crate) + debugPrint(string.format("RESULT duck %.2f %.2f %.2f crate x %.2f", dx, dy, dz, cx)) + singeQuit() + end +end