From 33b45d0527ccec74f83944bc79f13ac9ff483622 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Sun, 6 Sep 2026 16:02:28 -0500 Subject: [PATCH] Particle system, player controllers, and some more shadow tests. --- .gitignore | 1 - CHANGELOG | 65 +- CMakeLists.txt | 2 + INSTALL | 3 +- LICENSES | 2 - cmake/shaderHeader.cmake | 2 +- docs/Manual.adoc | 1429 ++++++++- patches/ActionMax/Emulator.singe | 476 --- patches/README | 26 - patches/daitarn_3_singe/Script/toolbox.singe | 330 --- src/particles.c | 784 +++++ src/particles.h | 87 + src/physics.h | 75 + src/physicsJolt.cpp | 2770 +++++++++++++++++- src/scene.c | 777 ++++- src/scene.h | 6 + src/shaders/scene.hlsl | 94 +- src/singe.c | 1232 +++++++- testScripts/Models/Sponza.glb | 3 + testScripts/README.md | 15 +- testScripts/games.dat | 119 + testScripts/scene17.singe | 88 + testScripts/scene18.singe | 108 + testScripts/scene19.singe | 121 + testScripts/scene20.singe | 69 + testScripts/scene21.singe | 131 + testScripts/scene22.singe | 110 + testScripts/scene23.singe | 118 + testScripts/scene24.singe | 137 + testScripts/scene25.singe | 105 + testScripts/scene26.singe | 112 + testScripts/scene27.singe | 105 + thirdparty/openssl/Configure | 14 +- util/packGlb.py | 37 + 34 files changed, 8627 insertions(+), 926 deletions(-) delete mode 100755 patches/ActionMax/Emulator.singe delete mode 100644 patches/README delete mode 100755 patches/daitarn_3_singe/Script/toolbox.singe create mode 100644 src/particles.c create mode 100644 src/particles.h create mode 100644 testScripts/Models/Sponza.glb create mode 100644 testScripts/scene17.singe create mode 100644 testScripts/scene18.singe create mode 100644 testScripts/scene19.singe create mode 100644 testScripts/scene20.singe create mode 100644 testScripts/scene21.singe create mode 100644 testScripts/scene22.singe create mode 100644 testScripts/scene23.singe create mode 100644 testScripts/scene24.singe create mode 100644 testScripts/scene25.singe create mode 100644 testScripts/scene26.singe create mode 100644 testScripts/scene27.singe create mode 100644 util/packGlb.py diff --git a/.gitignore b/.gitignore index 170ab7fc5..30c0cba64 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,6 @@ docs/Manual.pdf docs/.asciidoctor/ .claude/settings.local.json **/__pycache__/ -/util/perl/ # Extracted by the binary when it is run from here; never source. /Singe/ diff --git a/CHANGELOG b/CHANGELOG index 25a042d91..67c5977e7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -33,6 +33,66 @@ API Changes SSE4.1 on x86 (2008 and later); the engine's first C++ (one wrapper file and the library), statically linked, no runtime to install. +- Soft bodies. softNew makes cloth or a pressure body from a node's + mesh (particles from its welded vertices, Jolt's stretch, shear and + bend constraints, pressure for balloons) or a rope from the node to a + point with a tube mesh the engine makes; they collide with everything + rigid and the mesh is rewritten every frame. softPin holds particles + in place or to nodes; stiffness, mass, damping and pressure are + tunable. meshPlane takes subdivisions; nodeSetMaterial changes a + material without the mesh. New calls: soft*, and the SOFT_* + constants; see Soft bodies in the Physics chapter. + +- Ragdolls. ragdollNew derives capsules and swing-twist joints from a + skinned model's skeleton; ragdollActivate hands the skeleton to + physics from its current pose and the mesh follows the bones as it + falls; motors (ragdollSetStrength) pull it back toward that pose; + ragdollApplyImpulse shoves a bone; ragdollDeactivate returns it to + animation. New calls: ragdoll*; see Ragdolls in the Physics chapter. + +- Water. bodySetWater fills a static body with water: dynamic bodies + inside float, sink and drift by Jolt's buoyancy against its surface, + with drag and a current (bodySetCurrent, bodySetBuoyancy per body); + players swim in it (playerIsSwimming, playerSetSwim, three-axis + playerMove); a buoyant hull with vehicleNew(node, VEHICLE_BOAT) gets + a propeller and a rudder (vehicleSetThrust, vehicleSetRudder). + +- Vehicles. A dynamic body becomes a car, a motorcycle or a tank on + Jolt's vehicle constraint: wheels are nodes the engine poses (spin, + steer, suspension), the script drives with throttle, steering, brake + and hand brake, and engine, gears, suspension, steering, brakes and + anti-roll bars are tunable. Motorcycles lean and balance with a spring + scaled to the chassis; tanks pivot on the spot. New calls: vehicle*, + and the VEHICLE_* constants; see Vehicles in the Physics chapter. + +- Point lights inside geometry. A bulb in a closed room, under a shade + or inside a cabinet now shadows correctly: cube faces render depth + two-sided so closed meshes cast from inside, every shadow tap picks its + own cube face so the seams along the cube edges are gone, the depth + range fits the casters round the light instead of the scene centre, + the bias grows with distance and grazing angle, and each face draws + only what it can see. nodeSetShadow excuses a mesh from casting. + +- Character controller. playerNew puts Jolt's CharacterVirtual on a + node, as a capsule or any convex bodyNew shape: it walks where + playerMove says, climbs steps, slides along walls, refuses steep + slopes, falls, jumps from the ground, rides kinematic platforms and + shoves light dynamic bodies; onCollision and onTrigger report it like + a body. Works in 2D worlds as a platformer character, with tolerances + scaled to the shape. New calls: player*; see Players in the Physics + chapter. + +- Particles. Emitters spawn, move and age particles from a recipe (rate, + life, speed, direction and spread, gravity, drag, size, colour and spin + over life, a birth radius, a pool cap) in the overlay and in the 3D + scene alike. A 2D emitter draws on the GPU above or beneath the + overlay when the script asks (emitterDraw); a 3D emitter sits on a node + and draws camera-facing billboards after every mesh, depth tested, in + world units. Any loaded sprite is the picture (a random frame per + particle from an animated one); without one, a soft disc. Normal or + additive blending. New calls: emitter*, and the PARTICLE_* constants; + see the Particles chapter. + - 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 @@ -42,8 +102,9 @@ API Changes targets, placed any number of times. Everything is a node in one tree. New calls: scene*, node*, mesh*, material*, light*, camera*, model*, animation*, and the LIGHT_* constants; see the 3D Scenes chapter of the manual. - Needs a GPU with Vulkan, Direct3D 12 or Metal (Raspberry Pi 4 or - later); 2D games run as before without one. The renderer now runs on + Needs a GPU with Vulkan, Direct3D 12 or Metal; the Raspberry Pi 4 is + the minimum Pi (the Pi 3 has no Vulkan driver). 2D games run as + before without one. The renderer now runs on SDL's GPU device where there is one. - A game can be one file: singe --pack DIRECTORY GAME.game writes the game's diff --git a/CMakeLists.txt b/CMakeLists.txt index d6e3019d9..a1ab7b2bb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -273,6 +273,8 @@ set(SINGE_SOURCE src/main.h src/pack.c src/pack.h + src/particles.c + src/particles.h src/physics.h src/physicsJolt.cpp src/model.c diff --git a/INSTALL b/INSTALL index 911d8ac5f..e8fbeb934 100644 --- a/INSTALL +++ b/INSTALL @@ -106,7 +106,8 @@ release (lib/libdxcompiler.so, lib/libdxil.so, include/dxc/dxcapi.h) at that path yourself, or build it from source there. The Raspberry Pi build (64-bit Raspberry Pi OS, glibc 2.31 or newer) -also uses zig. The platform headers and libraries it links against are +runs on any 64-bit Pi; 3D games need a Pi 4 or later, the Pi 3 has no +Vulkan driver and plays 2D games only. The build also uses zig. The platform headers and libraries it links against are Debian bookworm arm64 packages listed in cmake/zig/piPackages.cmake, fetched from snapshot.debian.org and unpacked with dpkg-deb into .builddir/toolchains/sysroot-aarch64-linux-gnu on first use: diff --git a/LICENSES b/LICENSES index 0848d0d61..e6228941c 100644 --- a/LICENSES +++ b/LICENSES @@ -46,5 +46,3 @@ Fonts ----- FreeSansBold GPL-3.0 with font exception https://www.gnu.org/software/freefont -BreatheFire (design-time only: text layer in singeLogo.xcf and indexing.xcf; - the font file is not shipped and its license is not recorded) diff --git a/cmake/shaderHeader.cmake b/cmake/shaderHeader.cmake index 9983cf017..9e84f9d54 100644 --- a/cmake/shaderHeader.cmake +++ b/cmake/shaderHeader.cmake @@ -3,7 +3,7 @@ # cmake -DSHADERCROSS= -DSOURCE= -DOUTPUT= -P shaderHeader.cmake # so the header is generated into the build tree like the icon and the other embedded files. -set(entries vertexStatic:vertex vertexSkinned:vertex fragmentMain:fragment depthMain:fragment) +set(entries vertexStatic:vertex vertexSkinned:vertex fragmentMain:fragment depthMain:fragment particleVertex:vertex particleFragment:fragment) set(formats SPIRV DXIL MSL) get_filename_component(sourceDir ${SOURCE} DIRECTORY) get_filename_component(outputDir ${OUTPUT} DIRECTORY) diff --git a/docs/Manual.adoc b/docs/Manual.adoc index 82ad409a5..7b785ce5e 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -211,6 +211,13 @@ do not allow you to use stereo, you can use Virtual Audio Cable to fix this: . You should have sound again. Load up Singe and your audio will be working and lag-free. +*Does 3D work on a Raspberry Pi?* + +On a Raspberry Pi 4 or later, yes: they have the Vulkan driver the 3D scene +needs. The Pi 3 and earlier do not, so they run 2D games (2D physics included) +exactly as before and refuse the first 3D call. The Pi 4 is the minimum for +any game that uses the 3D Scenes chapter. + *Why does my audio stutter on the Raspberry Pi?* The Raspberry Pi OS now uses Pipewire as the default audio backend. Switching @@ -762,7 +769,11 @@ nodes, so they parent and animate like anything else. `sceneSetAmbient` lights everything a little from everywhere. Any light can cast soft-edged shadows from everything opaque (`lightSetShadow`): directional and spot lights through a shadow map, point lights through a cube map that reaches -every direction; `sceneSetShadowSize` trades sharpness for speed. Any node can be the camera +every direction, and a bulb may sit inside what it lights (a room, a +shade, a cabinet): closed meshes cast from their back faces too, and the +shadow's depth range fits the casters round the light. `nodeSetShadow` +excuses a mesh from casting (the bulb's own glass, a glowing sign). +`sceneSetShadowSize` trades sharpness for speed. Any node can be the camera (`cameraSet`); without one, the scene is viewed from `(0, 0, 5)` looking at the origin. Perspective is the default; `cameraSetOrthographic` is there for diagrams and HUD-like scenes. @@ -785,10 +796,11 @@ far along the ray through an overlay pixel, which is what mouse picking and placing things "where the player clicked" need. .Performance -The scene needs a GPU that speaks Vulkan (Linux, Raspberry Pi 4 and later), -Direct3D 12 (Windows 10 and later), or Metal (macOS). On a machine without -one, 2D games run exactly as before and the first 3D call raises an error; -the Raspberry Pi 3 has no Vulkan driver and therefore no 3D. Antialiasing +The scene needs a GPU that speaks Vulkan (Linux), Direct3D 12 (Windows 10 and +later), or Metal (macOS). The Raspberry Pi 4 is the minimum Pi for 3D: it and +every later model have a Vulkan driver, the Pi 3 and earlier do not. On a +machine without a suitable GPU, 2D games run exactly as before (2D physics +included) and the first 3D call raises an error. Antialiasing (4x multisampling) is on wherever the GPU offers it; `sceneSetAntialias(false)` buys back speed on a Pi. Keep an eye on triangle counts and texture sizes on small boards, and load models once and instance them rather than loading @@ -915,12 +927,284 @@ Give every 2D body some depth (the third size) so shapes overlap in Z; `50` is fine. Circles are `SHAPE_SPHERE`. This is the form that runs on a Raspberry Pi 3, which has no 3D. +.Players +A body goes where forces send it; a *player* goes where the script says. +`playerNew` puts a character controller on a node: a capsule by default +(`playerNew(node, radius, height)`), or any convex `bodyNew` shape +(`playerNew(node, SHAPE_BOX, w, h, d)` for a robot, `SHAPE_SPHERE` for +something that rolls yet still climbs steps, `SHAPE_HULL` for the model's +own outline). The shape stands on the node's origin: the feet. Each frame +the script gives `playerMove` the horizontal velocity it wants, and the +controller walks along floors, climbs steps up to `playerSetStep`, slides +along walls, refuses slopes steeper than `playerSetSlope`, falls under +gravity, rides moving platforms, and shoves light dynamic bodies out of +the way (`playerSetPush`). `playerJump` works only with ground underfoot +and says whether it did. The node's rotation is the script's: turn it to +face the way it walks, and hang the model under it as a child. + +[source,lua] +---- +hero = nodeNew() +nodeSetPosition(hero, 0, 0, 0) +playerNew(hero, 0.3, 1.0) -- A capsule 0.3 wide and 1 tall, feet at the node +fox = modelInstance(modelLoad("Fox.glb")) +nodeSetParent(fox, hero) + +function onOverlayUpdate() + local vx, vz = stickX() * 3, stickY() * 3 + playerMove(hero, vx, vz) + if buttonPressed() then playerJump(hero, 4.5) end + nodeSetRotation(hero, 0, math.deg(math.atan(-vz, vx)), 0) + return OVERLAY_NOT_UPDATED +end +---- + +Players report `onCollision` when they start touching a body and +`onTrigger` when they enter or leave a sensor, with their node in the +place a body's node would take, so a door, a pickup and a lava pit work +the same for a player as for a ball. In a 2D world (`physicsSet2D`) a +player is a platformer character: `playerMove(node, vx)` runs along X, +gravity points down the screen, and everything else is the same. The +controller's tolerances scale with the shape, so a 30 pixel capsule in +a sprite game behaves like a 0.3 metre one in a 3D one. + +.Vehicles +A *vehicle* is a dynamic body (the chassis, made with `bodyNew` like any +body, facing -Z with Y up) that Jolt drives through wheels: +`vehicleNew(node, VEHICLE_CAR)` for anything with axles, `VEHICLE_MOTORCYCLE` +for two wheels in a line that lean through turns and balance, or +`VEHICLE_TANK` for wheels on two tracks that turn on the spot. Wheels are +nodes: `vehicleAddWheel(node, wheelNode, radius, width, suspension)` takes +the wheel's position from where its node is relative to the chassis, and +from then on the engine poses that node every frame (its X axis is the +axle, so a `meshCylinder` wheel wants a child turned 90 degrees). Wheels +found in a model with `nodeFind("wheelFL")` work the same. By default the +front wheels steer and all of them drive; `vehicleSetWheel` says otherwise. +Each frame the script hands over the driver: `vehicleDrive(node, forward, +right, brake, handBrake)`. The engine, automatic gearbox and differentials +are Jolt's with a 1500 kg car's defaults; `vehicleSetEngine`, +`vehicleSetGears`, `vehicleSetSuspension`, `vehicleSetSteering`, +`vehicleSetBrakes` and `vehicleSetAntiRoll` tune them, and any change +rebuilds the vehicle in place at the next step. A vehicle is still a body: +it jumps ramps, hits walls, shoves crates and reports `onCollision`. + +[source,lua] +---- +car = nodeNew() +nodeSetMesh(car, meshBox(1.8, 0.5, 4.0), paint) +nodeSetPosition(car, 0, 1, 0) +bodyNew(car, BODY_DYNAMIC, SHAPE_BOX, 1.8, 0.5, 4.0) +bodySetMass(car, 1500) +vehicleNew(car, VEHICLE_CAR) +for i, p in ipairs({ { -0.95, -0.2, -1.4 }, { 0.95, -0.2, -1.4 }, { -0.95, -0.2, 1.4 }, { 0.95, -0.2, 1.4 } }) do + local wheel = nodeNew() + nodeSetParent(wheel, car) + nodeSetPosition(wheel, p[1], p[2], p[3]) + vehicleAddWheel(car, wheel, 0.35, 0.25, 0.4) +end + +function onOverlayUpdate() + vehicleDrive(car, throttle(), steering(), brake(), handBrake()) + return OVERLAY_NOT_UPDATED +end +---- + +Tanks take `forward` as throttle and `right` as the turn (with no throttle +they pivot); the motorcycle's lean spring is scaled to the chassis, so a +light bike balances as well as a heavy one. Vehicles need a 3D world: a +side-on 2D car is a body with two hinged wheels, which the joint calls +already build. + +.Water +`bodySetWater(node, density, linearDrag, angularDrag)` fills a static +body with water: the top of its box is the surface, and every dynamic +body inside floats, sinks or rolls by Jolt's buoyancy against that +surface (the submerged part of the shape counts, so a tilted crate rights +itself), slowed by the drags; `bodySetCurrent` gives the water a flow. +`density` is a multiplier, 1 for water; each floating body's own +`bodySetBuoyancy` (default 1.2, which floats gently; below 1 sinks) +scales it. The volume is a trigger, so `onTrigger` fires on the way in +and out for splashes. Water is not drawn: give the volume's node a +blended material, or none. A player in water swims: `playerIsSwimming` +says so, `playerMove(node, vx, vy, vz)` then steers in three axes, it +sinks slowly with no vertical input (`playerSetSwim` sets that rate and +the water's drag), and the current carries it. A boat is a buoyant hull +that `vehicleNew(node, VEHICLE_BOAT)` gives a propeller and a rudder: +`vehicleSetThrust` says how hard it pushes and where in the hull, +`vehicleSetRudder` how hard it turns, and `vehicleDrive` works as for a +car, all only while the propeller is under a surface. + +[source,lua] +---- +pool = nodeNew() +nodeSetMesh(pool, meshBox(8, 3, 12), blue) -- Blended blue, drawn as the water +nodeSetPosition(pool, 0, -1.5, 0) -- Its top, at Y = 0, is the surface +bodyNew(pool, BODY_STATIC, SHAPE_BOX, 8, 3, 12) +bodySetWater(pool, 1.0, 0.6, 0.2) +bodySetBuoyancy(crate, 1.6) -- Rides high +bodySetBuoyancy(anchor, 0.3) -- Goes down +---- + +.Ragdolls +`ragdollNew(node)` looks under a model instance for its skin and turns +the skeleton into a ragdoll recipe: every joint with a child joint +becomes a capsule along that bone, jointed to the nearest bone above it +by a swing-twist joint with sensible limits, and leaf joints (fingers, +ears, the tip of a tail) ride along with their parents. Nothing moves +until `ragdollActivate`: then the animation stops, bodies are made where +the bones are, and from that moment physics drives the joint nodes, so +the skinned mesh flops, hits the floor and comes to rest with the rest of +the world. `ragdollApplyImpulse` shoves one bone by its joint's name. +`ragdollSetStrength` powers every joint toward the pose the ragdoll was +activated in, which turns a limp fall into a stagger or lets a downed +character pull its limbs back in; it does not stand a body up, since +nothing pushes off the floor. `ragdollDeactivate` hands the skeleton +back to animation from wherever it lies. Tune a bone that looks wrong +with `ragdollSetJoint` before activating. + +[source,lua] +---- +fox = modelInstance(foxModel) +ragdollNew(fox) -- Recipe from the skin; still animated +function onCollision(a, b, x, y, z, speed) + if (a == hero or b == hero) and speed > 3 then + playerSetEnabled(hero, false) + ragdollActivate(fox) -- Physics takes the skeleton from here + ragdollApplyImpulse(fox, "b_Spine02_03", 40, 30, 0) + end +end +---- + +.Soft bodies +Cloth, rope and jelly: a mesh whose vertices are simulated. +`softNew(node, SOFT_CLOTH)` turns the node's mesh into cloth, its +vertices (welded by position) into particles joined by stretch, shear +and bend constraints, where the node has put it in the world; a +subdivided `meshPlane(width, depth, columns, rows)` is the usual start. +`softNew(node, SOFT_BODY)` does the same for a closed mesh and +`softSetPressure` inflates it: a balloon, a jelly. +`softNew(node, SOFT_ROPE, x, y, z, segments, radius)` runs a chain of +particles from the node's position to that point and gives the node a +tube mesh round them (set its look with `nodeSetMaterial`). Soft bodies +collide with everything rigid, fall under gravity, and are drawn by the +mesh they came from, which the engine rewrites every frame. `softPin` +holds the nearest particle where it is or to a node (the flag's edge to +the pole, the rope's top to the crane); `softSetStiffness`, `softSetMass` +and `softSetDamping` tune the material. A node whose mesh is shared with +other nodes changes them too, so give a soft body a mesh of its own. + +[source,lua] +---- +flag = nodeNew() +nodeSetMesh(flag, meshPlane(1.6, 1.0, 16, 10), red) +nodeSetPosition(flag, -4.2, 2.4, 0) +nodeSetRotation(flag, 90, 0, 0) -- Hanging upright +softNew(flag, SOFT_CLOTH) +for _, y in ipairs({ 1.9, 2.15, 2.4, 2.65, 2.9 }) do + softPin(flag, -5, y, 0) -- Along the pole +end +---- + +Cost scales with the particle count: a 20 by 20 sheet is 441 particles +and fine on a Raspberry Pi 4; keep cloth a few hundred particles and a +rope a dozen segments. Particles collide with rigid bodies, not faces, +so a fine enough mesh (particles closer than the smallest thing that +will land on it) is what stops a crate falling through. There is no +self-collision: cloth can pass through itself when crumpled. Skinned +meshes cannot be soft. + .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. +[#particles] +=== Particles + +Sparks, smoke, fire, rain, dust, magic: anything made of many small +short-lived things is a job for an *emitter*. An emitter holds a recipe +(how many a second, how long they live, how fast and which way they set +off, what pulls on them, how they grow, fade and spin) and a pool of +particles it runs from that recipe. The script sets the recipe once, then +starts the emitter for a steady stream, bursts it for a one-off puff, or +both. The engine moves every live particle every frame, and stops moving +them while the game is paused. + +One emitter API serves the overlay and the 3D scene; only the units and +the drawing differ. + +.2D emitters +`emitterNew()` with no argument makes a 2D emitter. It lives in overlay +pixels, Y down, so a fountain's gravity is positive Y. Move it with +`emitterSetPosition`, and draw it from `onOverlayUpdate` with +`emitterDraw`. The overlay itself is a picture the CPU paints, so +particles are not painted into it: the GPU draws them, either above the +whole overlay (`PARTICLE_OVER`, the default) or beneath it and above the +video and the 3D scene (`PARTICLE_UNDER`, for smoke behind the score). +2D emitters run on every machine Singe runs on, the Raspberry Pi 3 +included, and go well with 2D physics: read a body's node position and +give it to `emitterSetPosition`. + +[source,lua] +---- +sparks = emitterNew() +emitterSetBlend(sparks, PARTICLE_ADD) +emitterSetLife(sparks, 0.3, 0.7) +emitterSetSpeed(sparks, 80, 200) +emitterSetDirection(sparks, 0, -1) -- up the screen +emitterSetSpread(sparks, 70) +emitterSetGravity(sparks, 0, 500) +emitterSetSize(sparks, 5, 1) +emitterSetColor(sparks, 255, 255, 200, 255, 255, 120, 0, 0) + +function onOverlayUpdate() + overlayClear() + emitterSetPosition(sparks, grinderX, grinderY) + emitterDraw(sparks) + return OVERLAY_UPDATED +end +---- + +.3D emitters +`emitterNew(node)` makes a 3D emitter attached to a node. Particles are +born at the node's world position (spread over `emitterSetRadius`), in +world units, and draw themselves in the scene as camera-facing +billboards after every mesh: depth-tested against the geometry, never +writing depth, unlit, casting and receiving no shadows. By default a +particle stays where it was born once it leaves the emitter (smoke keeps +drifting where it was made while the torch moves on); `emitterSetLocal` +makes the whole cloud follow the node instead. + +[source,lua] +---- +flame = emitterNew(torchTip) +emitterSetBlend(flame, PARTICLE_ADD) +emitterSetRate(flame, 140) +emitterSetLife(flame, 0.4, 0.9) +emitterSetSpeed(flame, 0.6, 1.4) +emitterSetDirection(flame, 0, 1, 0) +emitterSetSpread(flame, 15) +emitterSetSize(flame, 0.35, 0.05, 0.3) +emitterSetColor(flame, 255, 200, 90, 255, 255, 40, 0, 0) +emitterStart(flame) +---- + +.The picture +A particle is a textured quad. Without a texture it is a soft disc the +engine provides; `emitterSetTexture` gives it a loaded sprite instead (a +puff, a spark streak, a snowflake), and for an animated sprite +`emitterSetFrames` picks a random frame per particle, so one atlas of +puffs gives a varied cloud. Blending is normal (`PARTICLE_ALPHA`) or +additive (`PARTICLE_ADD`, for anything that glows). + +.Cost +Particles are cheap to simulate and cost what they cover on screen. A +few thousand small ones are nothing; a handful of large additive ones +over the whole screen are what slow a Raspberry Pi 4 down. The pool is +capped by `emitterSetMax` (default 1000); when it is full, no more are +born until some die. + [#migrating] === Migrating from Singe 2.10 @@ -992,6 +1276,10 @@ available to `controls.cfg` and to `Framework.singe` alike: | `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). +| `PARTICLE_ALPHA`, `PARTICLE_ADD` | Blending for `emitterSetBlend`. +| `PARTICLE_OVER`, `PARTICLE_UNDER` | Where 2D particles draw, for `emitterSetLayer`. +| `VEHICLE_CAR`, `VEHICLE_MOTORCYCLE`, `VEHICLE_TANK`, `VEHICLE_BOAT` | Kinds of vehicle for `vehicleNew`. +| `SOFT_CLOTH`, `SOFT_BODY`, `SOFT_ROPE` | Kinds of soft body for `softNew`. | `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`. @@ -1400,6 +1688,32 @@ Restitution from `0` (stops dead) to `1` (bounces back with everything it arrive *Since:* 3.00. *See also:* <> +[#bodysetbuoyancy] +==== bodySetBuoyancy + +[source,text] +---- +bodySetBuoyancy(node, factor) +---- + +How the body floats in water: `1` is neutral, more floats (default `1.2`), less sinks. + +*Since:* 3.00. +*See also:* <> + +[#bodysetcurrent] +==== bodySetCurrent + +[source,text] +---- +bodySetCurrent(node, x, y, z) +---- + +The flow inside a water volume, world units a second; bodies and swimmers drift with it. + +*Since:* 3.00. +*See also:* <> + [#bodysetenabled] ==== bodySetEnabled @@ -1489,6 +1803,19 @@ with `nodeLookAt`, parent it to a moving object for a chase view. It looks down its own -Z. Without a camera node the scene is viewed from `(0, 0, 5)` looking at the origin, with a 60 degree perspective. +[#bodysetwater] +==== bodySetWater + +[source,text] +---- +bodySetWater(node, density[, linearDrag][, angularDrag]) +---- + +Fills a static body with water (it becomes a trigger): the top of its box is the surface. `density` is a multiplier, `1` for water; drags default `0.5` and `0.1`. See Water in the Physics chapter. + +*Since:* 3.00. +*See also:* <>, <>, <> + [#cameraset] ==== cameraSet @@ -2110,6 +2437,367 @@ Stops the disc. Stopped is a stronger state than paused: the display is marked f *See also:* <>, <>, <> [#font] +=== Emitter + +Emitters spawn, move and draw particles; see <>. Handles are integers. A 2D emitter draws in overlay pixels when asked; a 3D emitter sits on a node and draws in the scene by itself. Everything given as a minimum and maximum is drawn at random between them for every particle. + +[#emitternew] +==== emitterNew + +[source,text] +---- +emitter = emitterNew() +emitter = emitterNew(node) +---- + +Makes an emitter: with no argument a 2D one in overlay pixels, with a node a 3D one born at that node's world position. Defaults: 50 a second, living one to two seconds, upward (2D: up the screen), 30 degree spread, white fading to transparent, no gravity, pool of 1000. Deleting the node deletes its emitters. + +*Returns:* The emitter handle. + +*Since:* 3.00. +*See also:* <>, <>, <> + +[#emitterdelete] +==== emitterDelete + +[source,text] +---- +emitterDelete(emitter) +---- + +Removes the emitter and its particles. + +*Since:* 3.00. +*See also:* <> + +[#emittersettexture] +==== emitterSetTexture + +[source,text] +---- +emitterSetTexture(emitter, sprite) +emitterSetTexture(emitter, nil) +---- + +The picture each particle wears: a loaded sprite, or `nil` for the built-in soft disc. An animated sprite's frames are all kept; `emitterSetFrames` chooses among them. + +*Since:* 3.00. +*See also:* <>, <> + +[#emittersetframes] +==== emitterSetFrames + +[source,text] +---- +emitterSetFrames(emitter, first, last) +---- + +With an animated sprite as texture, each new particle takes a random frame from `first` to `last` (frames count from 0). Default: frame 0 only. + +*Since:* 3.00. +*See also:* <> + +[#emittersetblend] +==== emitterSetBlend + +[source,text] +---- +emitterSetBlend(emitter, mode) +---- + +`PARTICLE_ALPHA` (default) blends normally; `PARTICLE_ADD` adds light, for fire, sparks and glows. + +*Since:* 3.00. +*See also:* <> + +[#emittersetlayer] +==== emitterSetLayer + +[source,text] +---- +emitterSetLayer(emitter, layer) +---- + +2D only. `PARTICLE_OVER` (default) draws above everything on the overlay; `PARTICLE_UNDER` draws beneath the overlay and above the video and the 3D scene. + +*Since:* 3.00. +*See also:* <> + +[#emittersetrate] +==== emitterSetRate + +[source,text] +---- +emitterSetRate(emitter, perSecond) +---- + +How many particles a second a started emitter makes. Fractions accumulate. + +*Since:* 3.00. +*See also:* <>, <> + +[#emittersetlife] +==== emitterSetLife + +[source,text] +---- +emitterSetLife(emitter, minSeconds, maxSeconds) +---- + +How long a particle lives; its size and colour run from their start to their end over that time. + +*Since:* 3.00. +*See also:* <>, <> + +[#emittersetspeed] +==== emitterSetSpeed + +[source,text] +---- +emitterSetSpeed(emitter, min, max) +---- + +Starting speed, pixels (2D) or world units (3D) a second. + +*Since:* 3.00. +*See also:* <> + +[#emittersetdirection] +==== emitterSetDirection + +[source,text] +---- +emitterSetDirection(emitter, x, y) +emitterSetDirection(emitter, x, y, z) +---- + +The mean direction particles set off in; length does not matter. 2D default `(0, -1)` (up the screen), 3D default `(0, 1, 0)`. + +*Since:* 3.00. +*See also:* <> + +[#emittersetspread] +==== emitterSetSpread + +[source,text] +---- +emitterSetSpread(emitter, degrees) +---- + +Half the angle of the cone (3D) or fan (2D) round the direction, `0` to `180`; `180` is every direction. Default `30`. + +*Since:* 3.00. +*See also:* <> + +[#emittersetgravity] +==== emitterSetGravity + +[source,text] +---- +emitterSetGravity(emitter, x, y) +emitterSetGravity(emitter, x, y, z) +---- + +A constant acceleration on every particle: `(0, 500)` pulls 2D sparks down the screen, `(0, -9.8, 0)` is Earth in the scene. Default none. Particles are not physics bodies and hit nothing. + +*Since:* 3.00. +*See also:* <> + +[#emittersetdrag] +==== emitterSetDrag + +[source,text] +---- +emitterSetDrag(emitter, perSecond) +---- + +The share of its velocity a particle loses each second, `0` (default) to `1`; smoke and embers slow down, sparks do not. + +*Since:* 3.00. +*See also:* <> + +[#emittersetsize] +==== emitterSetSize + +[source,text] +---- +emitterSetSize(emitter, start, finish) +emitterSetSize(emitter, start, finish, variation) +---- + +Size at birth and at death, pixels (2D) or world units (3D), changing evenly between them. `variation` (`0` to `1`) scales each particle's sizes by a random factor that far either side of one. + +*Since:* 3.00. +*See also:* <> + +[#emittersetcolor] +==== emitterSetColor + +[source,text] +---- +emitterSetColor(emitter, r, g, b, a, r2, g2, b2, a2) +---- + +Colour and alpha (`0` to `255`) at birth and at death, changing evenly between them. Default white to transparent. + +*Since:* 3.00. +*See also:* <> + +[#emittersetspin] +==== emitterSetSpin + +[source,text] +---- +emitterSetSpin(emitter, min, max) +---- + +Rotation speed in degrees a second; each particle starts at a random angle. Default none. + +*Since:* 3.00. + +[#emittersetradius] +==== emitterSetRadius + +[source,text] +---- +emitterSetRadius(emitter, radius) +---- + +Particles are born anywhere within this distance of the emitter (a disc in 2D, a ball in 3D). Default `0`, a point. + +*Since:* 3.00. + +[#emittersetlocal] +==== emitterSetLocal + +[source,text] +---- +emitterSetLocal(emitter, local) +---- + +3D only. `true` makes the live particles follow the node; `false` (default) leaves them where they were born. + +*Since:* 3.00. + +[#emittersetmax] +==== emitterSetMax + +[source,text] +---- +emitterSetMax(emitter, count) +---- + +The pool: how many particles may be alive at once (default 1000). A full pool makes no more until some die. + +*Since:* 3.00. + +[#emittersetposition] +==== emitterSetPosition + +[source,text] +---- +emitterSetPosition(emitter, x, y) +---- + +2D only: where particles are born, in overlay pixels. A 3D emitter follows its node. + +*Since:* 3.00. +*See also:* <> + +[#emitterstart] +==== emitterStart + +[source,text] +---- +emitterStart(emitter) +---- + +Begins the steady stream at the rate set. + +*Since:* 3.00. +*See also:* <>, <> + +[#emitterstop] +==== emitterStop + +[source,text] +---- +emitterStop(emitter) +---- + +Ends the stream; the live particles finish their lives. + +*Since:* 3.00. +*See also:* <> + +[#emitterburst] +==== emitterBurst + +[source,text] +---- +emitterBurst(emitter, count) +---- + +Makes `count` particles at once, stream or no stream: an explosion, a splash, a puff. + +*Since:* 3.00. +*See also:* <> + +[#emitterclear] +==== emitterClear + +[source,text] +---- +emitterClear(emitter) +---- + +Kills every live particle now. + +*Since:* 3.00. +*See also:* <> + +[#emitterdraw] +==== emitterDraw + +[source,text] +---- +emitterDraw(emitter) +---- + +2D only, from `onOverlayUpdate`: draws the emitter's particles this frame, above or beneath the overlay per `emitterSetLayer`. Not drawing an emitter does not stop it. + +*Since:* 3.00. +*See also:* <> + +[#emittergetcount] +==== emitterGetCount + +[source,text] +---- +count = emitterGetCount(emitter) +---- + +How many particles are alive. + +*Returns:* The live count. + +*Since:* 3.00. + +[#emitterisactive] +==== emitterIsActive + +[source,text] +---- +active = emitterIsActive(emitter) +---- + +Whether the emitter is streaming or still has live particles: false once the explosion is over. + +*Returns:* True or false. + +*Since:* 3.00. +*See also:* <> + === Font Singe has *two* text rendering systems. Know which one you're using: @@ -2944,6 +3632,7 @@ arrow = meshNew( [source,text] ---- mesh = meshPlane(width, depth) +mesh = meshPlane(width, depth, columns[, rows]) ---- A flat rectangle in the XZ plane facing +Y, `width` along X and `depth` along Z. Rotate the node `90` degrees about X to stand it up facing the camera. Single sided unless its material is double sided. @@ -3455,6 +4144,19 @@ Turns the node by the given degrees about its own axes. *Since:* 3.00. *See also:* <>, <> +[#nodesetmaterial] +==== nodeSetMaterial + +[source,text] +---- +nodeSetMaterial(node, material) +---- + +A new material on whatever mesh the node has; `nil` for the default. Useful for a rope's tube, which the engine makes. + +*Since:* 3.00. +*See also:* <>, <> + [#nodesetmesh] ==== nodeSetMesh @@ -3574,6 +4276,19 @@ Scales the node and everything under it, uniformly or per axis. Loaded models ar *Since:* 3.00. *See also:* <> +[#nodesetshadow] +==== nodeSetShadow + +[source,text] +---- +nodeSetShadow(node, casts) +---- + +Whether the node's mesh is drawn into shadow maps (default true). Turn it off for the mesh round a light (a bulb, a flame) so it does not shadow its own light, or for anything that glows. + +*Since:* 3.00. +*See also:* <>, <> + [#nodesetvisible] ==== nodeSetVisible @@ -3854,6 +4569,362 @@ The acceleration every dynamic body feels, in units per second squared. Default *See also:* <> [#scene] +=== Player + +A character controller on a node (see <>, Players). Players are addressed by their node. A node carries a body or a player, not both. + +[#playernew] +==== playerNew + +[source,text] +---- +playerNew(node, radius, height) +playerNew(node, shape, ...) +---- + +A player on the node: a capsule of that radius and total height, or any convex `bodyNew` shape with its sizes (`SHAPE_BOX` width, height, depth; `SHAPE_SPHERE` radius; `SHAPE_CAPSULE` and `SHAPE_CYLINDER` radius, height; `SHAPE_HULL` the node's mesh). `SHAPE_MESH` is refused. The shape's lowest point is the node's origin. Replaces any body or player the node had. + +*Since:* 3.00. +*See also:* <>, <>, <> + +[#playerdelete] +==== playerDelete + +[source,text] +---- +playerDelete(node) +---- + +Removes the player; the node stays. + +*Since:* 3.00. +*See also:* <> + +[#playermove] +==== playerMove + +[source,text] +---- +playerMove(node, vx, vz) +playerMove(node, vx) +---- + +The horizontal velocity wanted this frame, world units a second (2D: overlay pixels along X). Call it every frame; a frame without it stands still. The vertical part comes from gravity, the ground and jumps. + +*Since:* 3.00. +*See also:* <>, <> + +[#playerisswimming] +==== playerIsSwimming + +[source,text] +---- +swimming = playerIsSwimming(node) +---- + +Whether the player is in water (its middle below a surface). + +*Returns:* True or false. + +*Since:* 3.00. +*See also:* <> + +[#playerjump] +==== playerJump + +[source,text] +---- +jumped = playerJump(node, speed) +---- + +Leaves the ground at that upward speed, if there is ground underfoot. + +*Returns:* True when it jumped. + +*Since:* 3.00. +*See also:* <> + +[#playersetswim] +==== playerSetSwim + +[source,text] +---- +playerSetSwim(node, sinkSpeed, drag) +---- + +In water: how fast the player sinks with no vertical input (default `0.3`) and how quickly the water pulls its velocity toward what `playerMove` asks (default `2`). + +*Since:* 3.00. +*See also:* <> + +[#playersetvelocity] +==== playerSetVelocity + +[source,text] +---- +playerSetVelocity(node, x, y, z) +---- + +Overrides the whole velocity: launch pads, knock-back, a dash. + +*Since:* 3.00. +*See also:* <> + +[#playergetvelocity] +==== playerGetVelocity + +[source,text] +---- +x, y, z = playerGetVelocity(node) +---- + +The player's velocity after the last step. + +*Returns:* Three numbers. + +*Since:* 3.00. + +[#playerisonground] +==== playerIsOnGround + +[source,text] +---- +onGround = playerIsOnGround(node) +---- + +Whether it stands on walkable ground (not a slope too steep, not the air). + +*Returns:* True or false. + +*Since:* 3.00. +*See also:* <> + +[#playergetground] +==== playerGetGround + +[source,text] +---- +ground, nx, ny, nz = playerGetGround(node) +---- + +What the player stands on and the ground's normal. + +*Returns:* The node stood on or `nil`, then the normal. + +*Since:* 3.00. +*See also:* <> + +[#playersetstep] +==== playerSetStep + +[source,text] +---- +playerSetStep(node, height) +---- + +The tallest step climbed without jumping. Default `0.3` for a 0.3 wide player, scaling with the shape. + +*Since:* 3.00. + +[#playersetslope] +==== playerSetSlope + +[source,text] +---- +playerSetSlope(node, degrees) +---- + +The steepest slope walked up; steeper ones slide. Default `45`. + +*Since:* 3.00. + +[#playersetmass] +==== playerSetMass + +[source,text] +---- +playerSetMass(node, kilograms) +---- + +The player's weight for pushing bodies. Default `70`. + +*Since:* 3.00. +*See also:* <> + +[#playersetpush] +==== playerSetPush + +[source,text] +---- +playerSetPush(node, strength) +---- + +How hard it shoves dynamic bodies, in newtons. Default `300` moves crates, not cabinets; `0` walks round everything. + +*Since:* 3.00. +*See also:* <> + +[#playersetgravityscale] +==== playerSetGravityScale + +[source,text] +---- +playerSetGravityScale(node, scale) +---- + +`1` (default) is the world's gravity; `0` floats (swimming, ladders); more falls faster. + +*Since:* 3.00. + +[#playersetposition] +==== playerSetPosition + +[source,text] +---- +playerSetPosition(node, x, y, z) +---- + +Teleports the player, touching nothing on the way, and stops it. + +*Since:* 3.00. + +[#playersetenabled] +==== playerSetEnabled + +[source,text] +---- +playerSetEnabled(node, enabled) +---- + +A disabled player neither moves nor collides. + +*Since:* 3.00. + +=== Ragdoll + +A skinned model's skeleton as physics (see <>, Ragdolls). Ragdolls are addressed by the model instance's root node. + +[#ragdollnew] +==== ragdollNew + +[source,text] +---- +ragdollNew(node) +---- + +Builds the recipe from the skin under the node: a capsule per bone, a swing-twist joint per bone to its parent. The model keeps animating until activated. + +*Since:* 3.00. +*See also:* <>, <>, <> + +[#ragdolldelete] +==== ragdollDelete + +[source,text] +---- +ragdollDelete(node) +---- + +Removes the ragdoll and its bodies; the model stays where it lies. + +*Since:* 3.00. +*See also:* <> + +[#ragdollsetjoint] +==== ragdollSetJoint + +[source,text] +---- +ragdollSetJoint(node, jointName, radius, swingDegrees, twistDegrees) +---- + +Tunes one bone before activation: the capsule radius (`0` for automatic, a fifth of the bone's length), and how far it may swing from and twist about its parent (defaults `45` and `30`). + +*Since:* 3.00. +*See also:* <> + +[#ragdollactivate] +==== ragdollActivate + +[source,text] +---- +ragdollActivate(node) +---- + +Stops the animation and hands the skeleton to physics from its current pose. + +*Since:* 3.00. +*See also:* <>, <> + +[#ragdolldeactivate] +==== ragdollDeactivate + +[source,text] +---- +ragdollDeactivate(node) +---- + +Removes the bodies; the joints keep the pose they were left in until an animation plays. + +*Since:* 3.00. +*See also:* <> + +[#ragdollsetstrength] +==== ragdollSetStrength + +[source,text] +---- +ragdollSetStrength(node, strength) +---- + +Motor torque in newton metres pulling every joint back toward the pose at activation; `0` (default) is limp. + +*Since:* 3.00. +*See also:* <> + +[#ragdollapplyimpulse] +==== ragdollApplyImpulse + +[source,text] +---- +hit = ragdollApplyImpulse(node, jointName, x, y, z) +---- + +An impulse on the bone starting at that joint (newton seconds); the punch. + +*Returns:* True when the bone was found. + +*Since:* 3.00. +*See also:* <> + +[#ragdollisactive] +==== ragdollIsActive + +[source,text] +---- +active = ragdollIsActive(node) +---- + +Whether physics is driving the skeleton. + +*Returns:* True or false. + +*Since:* 3.00. + +[#ragdollisresting] +==== ragdollIsResting + +[source,text] +---- +resting = ragdollIsResting(node) +---- + +Whether every bone has come to rest (false while not activated). + +*Returns:* True or false. + +*Since:* 3.00. + === Scene The scene is the 3D layer drawn between the disc video and the overlay. It @@ -4365,6 +5436,113 @@ end ---- [#sound] +=== Soft + +Cloth, pressure bodies and ropes (see <>, Soft bodies). Soft bodies are addressed by their node. + +[#softnew] +==== softNew + +[source,text] +---- +softNew(node, SOFT_CLOTH) +softNew(node, SOFT_BODY) +softNew(node, SOFT_ROPE, x, y, z, segments, radius) +---- + +The node's mesh becomes cloth or a pressure body, where the node has placed it; or the node gets a rope of that many segments and radius from its position to the world point, as a tube mesh the engine keeps in step. The node's mesh is rewritten every frame from then on. + +*Since:* 3.00. +*See also:* <>, <>, <> + +[#softdelete] +==== softDelete + +[source,text] +---- +softDelete(node) +---- + +Removes the simulation; the mesh stays as it was last drawn. + +*Since:* 3.00. +*See also:* <> + +[#softpin] +==== softPin + +[source,text] +---- +softPin(node, x, y, z) +softPin(node, x, y, z, otherNode) +---- + +Holds the particle nearest the world point where it is, or, with a node, wherever that node goes from now on. + +*Since:* 3.00. +*See also:* <> + +[#softunpin] +==== softUnpin + +[source,text] +---- +softUnpin(node, x, y, z) +---- + +Frees the pinned particle nearest the point. + +*Since:* 3.00. +*See also:* <> + +[#softsetstiffness] +==== softSetStiffness + +[source,text] +---- +softSetStiffness(node, stretch, bend) +---- + +How much the material resists stretching and folding, `0` to `1` (defaults `0.9` and `0.2`; `1` is rigid). Remakes the constraints round the current shape. + +*Since:* 3.00. + +[#softsetpressure] +==== softSetPressure + +[source,text] +---- +softSetPressure(node, pressure) +---- + +Inflates a `SOFT_BODY`; `0` (default) is a deflated bag, a few units a firm balloon. + +*Since:* 3.00. + +[#softsetmass] +==== softSetMass + +[source,text] +---- +softSetMass(node, kilograms) +---- + +The whole body's mass, shared evenly between its particles (default `1`). + +*Since:* 3.00. + +[#softsetdamping] +==== softSetDamping + +[source,text] +---- +softSetDamping(node, damping) +---- + +How quickly motion dies away, `0` upward (default `0.1`). + +*Since:* 3.00. + === Sound Sound effects are short audio clips (typical format: WAV) loaded into memory and played through SDL_mixer. Distinct from the disc's own audio (which rides with the video) and from `video*`-asset audio. @@ -4913,6 +6091,247 @@ end ---- [#video] +=== Vehicle + +Cars, motorcycles and tanks on a chassis body (see <>, Vehicles). Vehicles are addressed by their chassis node. + +[#vehiclenew] +==== vehicleNew + +[source,text] +---- +vehicleNew(node, kind) +---- + +Makes the node's dynamic body a vehicle of `VEHICLE_CAR`, `VEHICLE_MOTORCYCLE`, `VEHICLE_TANK` or `VEHICLE_BOAT`. Add wheels before driving it (a boat has none; see Water in the Physics chapter). The chassis faces -Z with Y up. + +*Since:* 3.00. +*See also:* <>, <>, <> + +[#vehicledelete] +==== vehicleDelete + +[source,text] +---- +vehicleDelete(node) +---- + +Removes the vehicle; the body and the wheel nodes stay. + +*Since:* 3.00. +*See also:* <> + +[#vehicleaddwheel] +==== vehicleAddWheel + +[source,text] +---- +index = vehicleAddWheel(node, wheelNode, radius, width, suspension) +---- + +A wheel at the wheel node's position relative to the chassis, of that radius and width, on a suspension that travels that far (world units). The engine poses the wheel node from now on; its X axis is the axle. Two wheels at least; a tank's are split left and right by which side of the chassis they sit on. + +*Returns:* The wheel's index, from 0. + +*Since:* 3.00. +*See also:* <> + +[#vehiclesetthrust] +==== vehicleSetThrust + +[source,text] +---- +vehicleSetThrust(node, maxForce, x, y, z) +---- + +A boat's propeller: the force at full throttle (default `2000`) and where it pushes in the hull's frame (default the stern, `0, -0.2, 1`). It only pushes under water. + +*Since:* 3.00. +*See also:* <> + +[#vehiclesetwheel] +==== vehicleSetWheel + +[source,text] +---- +vehicleSetWheel(node, index, steered, driven) +---- + +Whether the wheel steers and whether the engine turns it. Default: the front wheels steer, all wheels drive (a motorcycle's rear wheel only). + +*Since:* 3.00. +*See also:* <> + +[#vehiclesetengine] +==== vehicleSetEngine + +[source,text] +---- +vehicleSetEngine(node, maxTorque, maxRpm[, minRpm]) +---- + +The engine: peak torque in newton metres, redline and idle in revolutions a minute. Defaults 500, 6000 and 1000. + +*Since:* 3.00. +*See also:* <> + +[#vehiclesetgears] +==== vehicleSetGears + +[source,text] +---- +vehicleSetGears(node, { ratio, ... }[, reverseRatio][, automatic]) +---- + +One to eight forward gear ratios, first gear first, the reverse ratio (default 2.9), and whether the box shifts by itself (default true). Defaults are a five-speed. + +*Since:* 3.00. +*See also:* <> + +[#vehiclesetrudder] +==== vehicleSetRudder + +[source,text] +---- +vehicleSetRudder(node, maxTorque) +---- + +A boat's rudder: the turning torque at full lock (default `800`), biting more with speed. + +*Since:* 3.00. +*See also:* <> + +[#vehiclesetsuspension] +==== vehicleSetSuspension + +[source,text] +---- +vehicleSetSuspension(node, frequency, damping) +---- + +Every wheel's spring: natural frequency in hertz (default 1.5; stiffer is higher) and damping ratio (default 0.5). + +*Since:* 3.00. + +[#vehiclesetsteering] +==== vehicleSetSteering + +[source,text] +---- +vehicleSetSteering(node, maxDegrees) +---- + +How far the steered wheels turn at full lock. Default 35. + +*Since:* 3.00. + +[#vehiclesetbrakes] +==== vehicleSetBrakes + +[source,text] +---- +vehicleSetBrakes(node, brakeTorque, handBrakeTorque) +---- + +Brake torque on every wheel and hand brake torque on the rear ones, newton metres. Defaults 1500 and 4000. + +*Since:* 3.00. + +[#vehiclesetantiroll] +==== vehicleSetAntiRoll + +[source,text] +---- +vehicleSetAntiRoll(node, stiffness) +---- + +Anti-roll bars across each axle, in newtons a metre; `0` (default) has none. A few hundred keeps a car flat in corners. + +*Since:* 3.00. + +[#vehicledrive] +==== vehicleDrive + +[source,text] +---- +vehicleDrive(node, forward, right[, brake][, handBrake]) +---- + +The driver, each frame: throttle `-1` to `1` (negative reverses), steering `-1` to `1`, brake and hand brake `0` to `1`. A tank turns by `right` and pivots on the spot with no throttle. The input holds until the next call. + +*Since:* 3.00. +*See also:* <> + +[#vehiclegetspeed] +==== vehicleGetSpeed + +[source,text] +---- +speed = vehicleGetSpeed(node) +---- + +Speed along the nose in world units a second, negative in reverse. + +*Returns:* A number. + +*Since:* 3.00. + +[#vehiclegetrpm] +==== vehicleGetRpm + +[source,text] +---- +rpm = vehicleGetRpm(node) +---- + +The engine's revolutions a minute, for a rev counter or an engine note. + +*Returns:* A number. + +*Since:* 3.00. + +[#vehiclegetgear] +==== vehicleGetGear + +[source,text] +---- +gear = vehicleGetGear(node) +---- + +The gear engaged: `0` neutral, `-1` reverse, `1` upward. + +*Returns:* An integer. + +*Since:* 3.00. + +[#vehicleiswheelonground] +==== vehicleIsWheelOnGround + +[source,text] +---- +touching = vehicleIsWheelOnGround(node, index) +---- + +Whether that wheel touches anything. + +*Returns:* True or false. + +*Since:* 3.00. + +[#vehiclegetwheelslip] +==== vehicleGetWheelSlip + +[source,text] +---- +slip = vehicleGetWheelSlip(node, index) +---- + +How much the wheel spins or skids against the ground, `0` gripping to `1`; for tyre squeal and skid marks. Always `0` for tracks. + +*Returns:* A number. + +*Since:* 3.00. + === Video The `video*` family handles *additional video assets* on top of the main laserdisc video. Typical uses: a side-video of a character talking in a corner of the screen, a pre-rendered animation that plays once at a story beat, multiple concurrent video cues. You can load any number of videos, unlike the disc (which is a singleton). diff --git a/patches/ActionMax/Emulator.singe b/patches/ActionMax/Emulator.singe deleted file mode 100755 index 996f86740..000000000 --- a/patches/ActionMax/Emulator.singe +++ /dev/null @@ -1,476 +0,0 @@ -dofile("Singe/Framework.singe") - -stateStartup = 0 -stateSetup = 1 -stateTitle = 2 -stateMenu = 3 -stateIntro = 4 -statePlaying = 5 -stateGameOver = 6 - -gameStandard = 0 -gameLimited = 1 - -pixelLow = 0 -pixelHigh = 1 -pixelUnknown = 2 - -sprLightOn = spriteLoad(DIR .. "sprite_LightOn.png") -sprLightOff = spriteLoad(DIR .. "sprite_LightOff.png") -sprActionMax = spriteLoad(DIR .. "sprite_ActionMax.png") -sprCrosshair = spriteLoad(DIR .. "sprite_Crosshair.png") -sprBullet = spriteLoad(DIR .. "sprite_Bullet.png") -sprBoxArt = spriteLoad(DIR .. "sprite_" .. gameID .. ".png") - -sndActionMax = soundLoad(DIR .. "sound_ActionMax.wav") -sndSteadyAim = soundLoad(DIR .. "sound_ASteadyAimIsCritical.wav") -sndGetReady = soundLoad(DIR .. "sound_GetReadyForAction.wav") -sndGunShot = soundLoad(DIR .. "sound_Gunshot.wav") -sndGoodHit = soundLoad(DIR .. "sound_GoodHit.wav") -sndBadHit = soundLoad(DIR .. "sound_BadHit.wav") -sndGameOver = soundLoad(DIR .. "sound_GameOver.wav") - -mouseX = 0 -mouseY = 0 - -halfWidth = 0 - -crosshairCenterX = spriteGetWidth(sprCrosshair) / 2 -crosshairCenterY = spriteGetHeight(sprCrosshair) / 2 - -currentState = stateStartup -gameMode = gameStandard - -shotsFired = 0 -- Total Fired -shotsGood = 0 -- Hit our target -shotsBad = 0 -- Hit our friends - -scoreDisplay = 0 -- How long the score is yet to be displayed -scoreTimer = 4 -- How long to display the score + 1 - -lightDisplay = 0 -- How long the light is yet to be displayed -lightTimer = 2 -- How long to display the light + 1 - -triggerPulled = 0 -- This isn't quite a boolean. It counts down so we can get multiple pixel samples across frames. - -ammoLeft = 0 -- Number of misses remaining in limited ammo mode -ammoCount = 5 -- Number of misses allowed in limited ammo mode - -gunLastState = pixelUnknown -sensorLastState = pixelUnknown - -thisSeconds = 0 -lastSeconds = 0 -heartbeat = false - -fntBlueStone20 = fontLoad(DIR .. "font_BlueStone.ttf", 20) -fntChemRea16 = fontLoad(DIR .. "font_chemrea.ttf", 16) -fntChemRea32 = fontLoad(DIR .. "font_chemrea.ttf", 32) -fntChemRea48 = fontLoad(DIR .. "font_chemrea.ttf", 48) -fntLEDReal32 = fontLoad(DIR .. "font_LED_Real.ttf", 32) - -colorBackground(0, 0, 0, 0) -fontQuality(FONT_QUALITY_BLENDED) - -colorForeground(255, 255, 0) -fontSelect(fntBlueStone20) -sprPullToStart = fontToSprite("Pull Trigger to Start!") -sprGetReady = fontToSprite("Get Ready!") - -colorForeground(255, 255, 255) -fontSelect(fntChemRea16) -sprLastGame = fontToSprite("LAST GAME SCORE") - -colorForeground(255, 255, 0) -fontSelect(fntChemRea32) -sprSelectGameType = fontToSprite("Select Game Type") -sprStandard1 = fontToSprite("Standard") -sprStandard2 = fontToSprite("Game") -sprLimited1 = fontToSprite("Limited") -sprLimited2 = fontToSprite("Ammo") - -colorForeground(255, 0, 0) -fontSelect(fntChemRea48) -sprGameOver = fontToSprite("GAME OVER") - -discSearch(backgroundFrame) - - - -function getPixelState(intX, intY) - - r1, g1, b1 = vldpGetPixel(intX, intY) - - -- Determine pixel state - if ((255 - r1 < highThreshold) and (255 - g1 < highThreshold) and (255 - b1 < highThreshold)) then - pixelState = pixelHigh - elseif ((r1 < lowThreshold) and (g1 < lowThreshold) and (b1 < lowThreshold)) then - pixelState = pixelLow - else - pixelState = pixelUnknown - end - - return pixelState - -end - - -function onInputPressed(intWhat) - - --dr, dg, db = vldpGetPixel(mouseX, mouseY) - --debugPrint("Current Frame: " .. discGetFrame() .. " X: " .. mouseX .. " Y: " .. mouseY .. " R: " .. dr .. " G: " .. dg .. " B: " .. db) - - -- They fired! - if (intWhat == SWITCH_BUTTON3) then - - if (currentState == stateTitle) then - - discSearch(lengthIntro + lengthGame + 2) - discPlay() - currentState = stateMenu - - elseif (currentState == stateMenu) then - - if (mouseX < halfWidth) then - gameMode = gameStandard - else - ammoLeft = ammoCount - gameMode = gameLimited - end - - shotsFired = 0 - shotsGood = 0 - shotsBad = 0 - colorForeground(255, 0, 0) - fontSelect(fntLEDReal32) - discSearch(1) - discPlay() - soundPlay(sndGetReady) - currentState = stateIntro - - elseif (currentState == statePlaying) then - - -- Make gunshot noise - soundPlay(sndGunShot) - - -- We want to sample across two frames - triggerPulled = 2 - - shotsFired = shotsFired + 1 - - -- No matter what, we subtract ammo. On good hits, we add it back to make it look like nothing changed. - if (gameMode == gameLimited) then - ammoLeft = ammoLeft - 1 - end - - end - - end - -end - - -function onMouseMoved(intX, intY, intXrel, intYrel) - - -- Remember the mouse location for use later. - mouseX = intX - mouseY = intY - -end - - -function onOverlayUpdate() - - overlayClear() - - currentFrame = discGetFrame() - - -- Give us a 1 second heartbeat for overlay element timing - thisSeconds = os.time(os.date('*t')) - if (thisSeconds ~= lastSeconds) then - heartbeat = not heartbeat - lastSeconds = thisSeconds - -- Tick off floating score display - if (scoreDisplay > 0) then - scoreDisplay = scoreDisplay - 1 - end - -- Tick off light timer - if (lightDisplay > 0) then - lightDisplay = lightDisplay - 1 - end - end - - if (currentState == stateStartup) then - - -- We run this state to cause the overlay to update once before we use the dimensions to build the menu - overlayClear() - currentState = stateSetup - - elseif (currentState == stateSetup) then - - -- We do all the math to draw the menu here, one time. - halfWidth = (overlayGetWidth() / 2) - - logoTop = 5 - logoLeft = halfWidth - (spriteGetWidth(sprActionMax) / 2) - logoHeight = logoTop + spriteGetHeight(sprActionMax) - - pullToStartHeight = spriteGetHeight(sprPullToStart) - pullToStartTop = overlayGetHeight() - pullToStartHeight - pullToStartLeft = halfWidth - (spriteGetWidth(sprPullToStart) / 2) - - getReadyHeight = spriteGetHeight(sprGetReady) - getReadyTop = overlayGetHeight() - getReadyHeight - getReadyLeft = halfWidth - (spriteGetWidth(sprGetReady) / 2) - - boxLeft = (halfWidth / 2) - spriteGetWidth(sprBoxArt) / 2 + halfWidth - boxTop = (overlayGetHeight() - (logoHeight + pullToStartHeight)) / 2 - spriteGetHeight(sprBoxArt) / 2 + logoHeight - - lastGameLeft = (halfWidth / 2) - spriteGetWidth(sprLastGame) / 2 - lastGameTop = logoHeight + logoTop - - scoreHeight = 13 -- Point size + 1 instead of spriteGetHeight(sprLastGame) - scoreTop = lastGameTop + scoreHeight + 10 - scoreLeft = lastGameLeft - - lightLeft = overlayGetWidth() - spriteGetWidth(sprLightOff) - lightTop = overlayGetHeight() - spriteGetHeight(sprLightOff) - - bulletWidth = spriteGetWidth(sprBullet) - - selectGameTypeLeft = halfWidth - spriteGetWidth(sprSelectGameType) / 2 - selectGameTypeTop = 25 - - standard1Left = halfWidth / 2 - spriteGetWidth(sprStandard1) / 2 - standard2Left = halfWidth / 2 - spriteGetWidth(sprStandard2) / 2 - - limited1Left = halfWidth + (halfWidth / 2) - spriteGetWidth(sprLimited1) / 2 - limited2Left = halfWidth + (halfWidth / 2) - spriteGetWidth(sprLimited2) / 2 - - standardLimited1Top = (overlayGetHeight() / 2) - spriteGetHeight(sprStandard1) + 2 - standardLimited2Top = (overlayGetHeight() / 2) + 2 - - gameOverLeft = halfWidth - spriteGetWidth(sprGameOver) / 2 - gameOverTop = (overlayGetHeight() / 2) - spriteGetHeight(sprGameOver) - - colorForeground(200, 200, 200) - fontSelect(fntChemRea16) - - hndActionMaxSound = soundPlay(sndActionMax) - - currentState = stateTitle - - elseif (currentState == stateTitle) then - - spriteDraw(sprActionMax, logoLeft, logoTop) - spriteDraw(sprBoxArt, boxLeft, boxTop) - spriteDraw(sprLastGame, lastGameLeft, lastGameTop) - y = scoreTop - fontPrint(scoreLeft, y, " Shots Fired: " .. shotsFired) - y = y + scoreHeight - fontPrint(scoreLeft, y, " Good Hits: " .. shotsGood) - y = y + scoreHeight - fontPrint(scoreLeft, y, " Bad Hits: " .. shotsBad) - y = y + scoreHeight - fontPrint(scoreLeft, y, " Shot Score: " .. shotsGood - shotsBad) - y = y + scoreHeight * 2 - if (shotsFired > 0) then - scorePercent = math.floor((shotsGood - shotsBad) / shotsFired * 100) - else - scorePercent = 0 - end - fontPrint(scoreLeft, y, " Game Score: " .. scorePercent .. "%") - if (heartbeat) then - spriteDraw(sprPullToStart, pullToStartLeft, pullToStartTop) - end - - elseif (currentState == stateMenu) then - - spriteDraw(sprSelectGameType, selectGameTypeLeft, selectGameTypeTop) - spriteDraw(sprStandard1, standard1Left, standardLimited1Top) - spriteDraw(sprStandard2, standard2Left, standardLimited2Top) - spriteDraw(sprLimited1, limited1Left, standardLimited1Top) - spriteDraw(sprLimited2, limited2Left, standardLimited2Top) - - if (currentFrame >= lengthIntro + lengthGame + lengthMenu) then - discSearch(lengthIntro + lengthGame + 2) - discPlay() - end - - elseif (currentState == stateIntro) then - - if (heartbeat) then - spriteDraw(sprGetReady, getReadyLeft, getReadyTop) - end - - -- Skip into game video when the intro is over. - if (currentFrame >= lengthIntro) then - discSearch(lengthIntro + 1) - discPlay() - currentState = statePlaying - end - - elseif (currentState == statePlaying) then - - -- Read Sucton Cup Sensor. - sensorLastState = sensorState - sensorState = getPixelState(sensorX, sensorY) - -- Are they firing? - if (triggerPulled > 0) then - -- New frame (according to the sensor)? - if (sensorLastState ~= sensorState) then - -- Did they aim outside the suction cup sensor area? - if ((mouseX < sensorLeft) or (mouseY < sensorTop)) then - -- Yes. Process shot. - gunState = getPixelState(mouseX, mouseY) - else - -- No. They shot the sensor area. Bad user. - gunState = pixelUnknown - end - -- Are we doing the first or second frame sample? - if (triggerPulled == 1) then - --debugPrint("S1: " .. sensorLastState .. " S2: " .. sensorState .. " G1: " .. gunLastState .. " G2: " .. gunState) - -- Decide what happened. Do we have two good samples of each sensor? - if ((gunState ~= pixelUnknown) and (sensorState ~= pixelUnknown) and (gunLastState ~= pixelUnknown) and (sensorLastState ~= pixelUnknown)) then - -- If the sensor and gun states didn't change between frames, then this makes no sense. - if (gunLastState ~= gunState) then - -- Does the gun match the sensor? - if (gunState == sensorState) then - -- Yes! Hit bad guy! - soundPlay(sndGoodHit) - shotsGood = shotsGood + 1 - lightDisplay = lightTimer - -- Add it back to make it look like nothing changed. - if (gameMode == gameLimited) then - ammoLeft = ammoLeft + 1 - end - else - -- No. Crap - shot a good guy! - soundPlay(sndBadHit) - shotsBad = shotsBad + 1 - end - -- Float the score for a bit - scoreDisplay = scoreTimer - end - end - else - -- Remember this info for next pass - gunLastState = gunState - end - -- Get ready for next pass - triggerPulled = triggerPulled - 1 - end - end - - -- When the game is over, return to the menu. - if (currentFrame >= lengthIntro + lengthGame) then - discPause() - discSearch(backgroundFrame) - colorForeground(255, 255, 255) - fontSelect(fntChemRea16) - currentState = stateTitle - end - - -- Do we need to show the score? - if (scoreDisplay > 0) then - fontPrint(5, 5, "SCORE: " .. (shotsGood - shotsBad)) - end - - -- Do we need to light the light? - if (lightDisplay > 0) then - spriteDraw(sprLightOn, lightLeft, lightTop) - else - spriteDraw(sprLightOff, lightLeft, lightTop) - end - - -- Do we need to draw the ammo display? - if (gameMode == gameLimited) then - -- Are they out of ammo? - if (ammoLeft < 0) then - discSearch(lengthIntro + lengthGame + 2) - discPlay() - soundPlay(sndGameOver) - currentState = stateGameOver - end - if (ammoLeft > 0) then - bulletStart = overlayGetWidth() - bulletWidth - 5 - for i=1,ammoLeft do - spriteDraw(sprBullet, bulletStart, 0) - bulletStart = bulletStart - bulletWidth - end - end - end - - elseif (currentState == stateGameOver) then - - spriteDraw(sprGameOver, gameOverLeft, gameOverTop) - - if (currentFrame >= lengthIntro + lengthGame + lengthMenu) then - discPause() - discSearch(backgroundFrame) - colorForeground(255, 255, 255) - fontSelect(fntChemRea16) - currentState = stateTitle - end - - end - - -- Draw gun crosshair (This must be the last thing we draw so it's on top.) - if (singeWantsCrosshairs()) then - spriteDraw(sprCrosshair, mouseX - crosshairCenterX, mouseY - crosshairCenterY) - end - - return(OVERLAY_UPDATED) - -end - - -function onShutdown() - - discStop() - - -- Unload our resources. - - fontUnload(fntBlueStone20) - fontUnload(fntChemRea16) - fontUnload(fntChemRea32) - fontUnload(fntChemRea48) - fontUnload(fntLEDReal32) - - soundUnload(sndActionMax) - soundUnload(sndSteadyAim) - soundUnload(sndGetReady) - soundUnload(sndGunShot) - soundUnload(sndGoodHit) - soundUnload(sndBadHit) - soundUnload(sndGameOver) - - spriteUnload(sprLightOn) - spriteUnload(sprLightOff) - spriteUnload(sprActionMax) - spriteUnload(sprCrosshair) - spriteUnload(sprBullet) - spriteUnload(sprBoxArt) - - spriteUnload(sprPullToStart) - spriteUnload(sprGetReady) - spriteUnload(sprLastGame) - spriteUnload(sprSelectGameType) - spriteUnload(sprStandard1) - spriteUnload(sprStandard2) - spriteUnload(sprLimited1) - spriteUnload(sprLimited2) - spriteUnload(sprGameOver) - -end - - -function onSoundCompleted(intWhich) - - -- Play the "A Steady Aim is Critical" sound after "ActionMax" is finished. - if (intWhich == hndActionMaxSound) then - soundPlay(sndSteadyAim) - hndActionMaxSound = SOUND_REMOVE_HANDLE - end - -end diff --git a/patches/README b/patches/README deleted file mode 100644 index d37b23797..000000000 --- a/patches/README +++ /dev/null @@ -1,26 +0,0 @@ -Game Patches -============ - -These are corrected copies of scripts from third party Singe games. They -are not part of the engine build; each one replaces the same named file -inside an installed game. Package one as a ".patch" archive (see -"Packaging Your Game" in the manual) or copy the file over the original. - -ActionMax/Emulator.singe - Shared emulator script used by every ActionMax title. Fixes sprite - leaks in the original release and uses the Singe 3.00 sprite argument - order. The per-game wrapper script must define gameID, backgroundFrame, - lengthIntro, lengthGame, lengthMenu, highThreshold, lowThreshold, - sensorX, sensorY, sensorLeft, and sensorTop before loading it. - - The ActionMax games.dat is not part of this repository. Since Singe - 3.00 every laserdisc entry in it needs one added line, next to VIDEO: - - DISC = true, - - Entries without it are skipped by the menu with a message, and an entry - that names a VIDEO without DISC = true is refused by the engine. - -daitarn_3_singe/Script/toolbox.singe - Helper library from "Daitarn 3" (Karis, 2020). The calling script must - define OVLW, OVLH, and bPause. diff --git a/patches/daitarn_3_singe/Script/toolbox.singe b/patches/daitarn_3_singe/Script/toolbox.singe deleted file mode 100755 index aa372991e..000000000 --- a/patches/daitarn_3_singe/Script/toolbox.singe +++ /dev/null @@ -1,330 +0,0 @@ ---[[ - -PROGRAM NAME: LUA SINGE -VERSION: 1.1 -AUTHOR: KARIS (2020) - -This file is part of LUA SINGE. - - LUA SINGE is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation. - - LUA SINGE is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. - - Thanks to Scott Duensing, RDG. - -]]-- - -iSecs = 0 -iLimit = 0 -lastSeconds = 0 -thisSeconds = 0 - -sprText = nil -sprShadow = nil -sLastText = nil -iLastColor = -1 -iLastShadow = -1 - -tSecs = 0 -tLimit = 0 -tlastSeconds = 0 -tthisSeconds = 0 -bTommy = false - -bGunMute = false -iMuteFrames = 0 -MUTE_DELAY = 35 - -heartbeat = false -blinkSecs = 0 -lastBlinkSecs = 0 - -iRevFrames = 0 -REV_DELAY = 10 -bReversePointer = false -revsetx = 0; revsety = 0 - -iFrameStart = 0; iFrameEnd = 0 - -CHANNEL_LEFT = 1 -CHANNEL_RIGHT = 2 -ALL_CHANNELS = 3 -bMuteAttract = false - -RED = 0 -BLUE = 1 -YELLOW = 2 -GREEN = 3 -ORANGE = 4 -WHITE = 5 -GREY = 6 -GRAY = 6 -PINK = 7 -LIGHTBLUE = 7 -BLACK = 8 - -function singeRandomize() - - math.randomseed(os.time()) -- random initialize - math.random(); math.random(); math.random() -- warming up - -end - -function blinkTimer(thisMS) - - -- Function blinks every second. - - blinkSecs = os.clock() - - if bPause then - - lastBlinkSecs = blinkSecs - - else - - if (blinkSecs - lastBlinkSecs > thisMS) then - heartbeat = not heartbeat - lastBlinkSecs = blinkSecs - - end - - end - -end - -function goTimer(thisMS) - - blinkSecs = os.clock() - - if bPause then - - lastBlinkSecs = blinkSecs - - else - - if (blinkSecs - lastBlinkSecs > thisMS) then - heartbeat = true - lastBlinkSecs = blinkSecs - - end - - end - -end - -function clockRnd() - - local j = 0 - local q = 0 - local w = 0 - local r = 0 - local b1 = true - - j = os.clock() - q, w = math.modf(j) - - s2 = tostring(w) - r = string.find(s2, ".", 1, true) - - if (r == nil) then - - s2 = tostring(q) - s2 = string.sub(s2,string.len(s2), string.len(s2)) - - else - - s2 = string.sub(s2, r + 1) - r = string.len(s2) - - if r == 0 then - - s2 = tostring(q) - s2 = string.sub(s2,string.len(s2), string.len(s2)) - - - elseif r == 2 then - - s2 = string.sub(s2, 2, 2) - - elseif r >= 3 then - - s2 = string.sub(s2, 3, 3) - - end - - end - - w = tonumber(s2) - return w - -end - -function timerOFF() - - iSecs = 0 - iLimit = 0 - -end - -function timerON(thisLong) - - iSecs = 0 - iLimit = thisLong - lastSeconds = os.clock() - -end - -function timerDue() - - thisSeconds = os.clock() - - if bPause then - - lastSeconds = thisSeconds - - else - - if (thisSeconds ~= lastSeconds) then - - iSecs = iSecs + thisSeconds - lastSeconds - lastSeconds = thisSeconds - - end - - if (iSecs >= iLimit) then - - timerOFF() - return true - - else - - return false - - end - - end - -end - - -function muteSound() - - iMuteFrames = 0 - bGunMute = true - -end - -function blinkRev() - - iRevFrames = 0 - bReversePointer = true - -end - -function setupClip(thisA, thisB) - - iFrameStart = thisA - iFrameEnd = thisB - - discSkipToFrame(thisA) - -end - -function monoAudio (thisChannel) - - if thisChannel == CHANNEL_LEFT then - - discAudio (2, false) - discAudio (1, true) - - elseif thisChannel == CHANNEL_RIGHT then - - discAudio (1, false) - discAudio (2, true) - - end - -end - -function resetChannels() - - discAudio(1, true) - discAudio(2, true) - -end - -function muteAudio() - - discAudio(1, false) - discAudio(2, false) - -end - -function setFontColor(thisColor) - - if thisColor == RED then - - colorForeground(255, 0, 0) - - elseif thisColor == BLUE then - - colorForeground(0, 0, 255) - - elseif thisColor == YELLOW then - - colorForeground(255, 255, 0) - - elseif thisColor == GREEN then - - colorForeground(0, 255, 0) - - elseif thisColor == ORANGE then - - colorForeground(255, 150, 0) - - elseif thisColor == WHITE then - - colorForeground(255, 255, 255) - - elseif thisColor == GREY or thisColor == GRAY then - - colorForeground(128, 128, 128) - - elseif thisColor == LIGHTBLUE then - - colorForeground(30, 160, 250) - - elseif thisColor == BLACK then - - colorForeground(0,0,0) - - elseif thisColor == PINK then - - colorForeground(252,0,148) - - end - -end - -function textPrint(thisMsg, thisx, thisy, thisFont, thisColor, thisShadow) - - fontSelect(thisFont) - setFontColor(thisColor) - fontPrint(thisx,thisy,thisMsg) - -end - -function getMiddle(thisPhrase) - - local sprite = fontToSprite(thisPhrase) - local x = OVLW/2 - spriteGetWidth(sprite) * 0.5 - - spriteUnload(sprite) - - return x - -end diff --git a/src/particles.c b/src/particles.c new file mode 100644 index 000000000..94bcd8e71 --- /dev/null +++ b/src/particles.c @@ -0,0 +1,784 @@ +/* + * Singe - Particles: emitters that spawn, move and age particles for the 2D overlay and the 3D scene. + * Copyright (C) 2026 Scott Duensing + * + * See LICENSE for details. + * + * One simulation serves both worlds. A 2D emitter keeps its own position in overlay pixels; a 3D + * emitter is born at a node's world position. Particles live in structure-of-arrays pools, packed + * so the live ones are always the first "count" entries; a dead one is swapped with the last. The + * renderers (the overlay in singe.c, the scene in scene.c) read the pools through EmitterViewT. + */ + +#include +#include +#include "particles.h" +#include "scene.h" +#include "../thirdparty/uthash/src/uthash.h" +#include "util.h" + +#define DEFAULT_MAX 1000 +#define MAX_STEP_SECONDS 0.1 +#define DISC_SIZE 64 +#define QUEUE_MAX 256 +#define DEGREES_TO_RADIANS 0.017453292519943295f + +typedef struct EmitterS { + int32_t id; + int32_t node; // -1: 2D + // The recipe + float rate; + float lifeMin; + float lifeMax; + float speedMin; + float speedMax; + Vec3T direction; + float spread; // Degrees + Vec3T gravity; + float drag; + float sizeStart; + float sizeEnd; + float sizeVariation; + float colour0[4]; + float colour1[4]; + float spinMin; + float spinMax; + float radius; + bool local; + int32_t max; + ParticleBlendE blend; + ParticleLayerE layer; + int32_t frameFirst; + int32_t frameLast; + SDL_Surface **frames; + int32_t frameCount; + uint32_t textureVersion; + // The state + bool emitting; + float pending; // Fractional particles owed + Vec3T position; // 2D position, or the last known 3D origin + int32_t count; + float *px; + float *py; + float *pz; + float *vx; + float *vy; + float *vz; + float *age; + float *life; + float *size0; + float *size1; + float *angle; + float *spin; + int32_t *frame; + ParticleViewT *views; + UT_hash_handle hh; +} EmitterT; + +static void _allocatePools(EmitterT *emitter, int32_t max); +static EmitterT *_find(int32_t emitter); +static void _freeFrames(EmitterT *emitter); +static void _freePools(EmitterT *emitter); +static void _kill(EmitterT *emitter, int32_t index); +static Vec3T _origin(EmitterT *emitter); +static float _random(float min, float max); +static Vec3T _randomDirection(EmitterT *emitter); +static Vec3T _randomOffset(EmitterT *emitter); +static void _spawn(EmitterT *emitter, int32_t count); +static void _step(EmitterT *emitter, float dt); +static void _view(EmitterT *emitter, EmitterViewT *view); + +static EmitterT *_emitters = NULL; +static int32_t _nextId = 1; +static uint64_t _lastTick = 0; +static SDL_Surface *_disc = NULL; +static int32_t _queue[QUEUE_MAX]; +static int32_t _queueCount = 0; + + +static void _allocatePools(EmitterT *emitter, int32_t max) { + _freePools(emitter); + emitter->max = max; + emitter->count = 0; + emitter->px = SDL_calloc((size_t)max, sizeof(float)); + emitter->py = SDL_calloc((size_t)max, sizeof(float)); + emitter->pz = SDL_calloc((size_t)max, sizeof(float)); + emitter->vx = SDL_calloc((size_t)max, sizeof(float)); + emitter->vy = SDL_calloc((size_t)max, sizeof(float)); + emitter->vz = SDL_calloc((size_t)max, sizeof(float)); + emitter->age = SDL_calloc((size_t)max, sizeof(float)); + emitter->life = SDL_calloc((size_t)max, sizeof(float)); + emitter->size0 = SDL_calloc((size_t)max, sizeof(float)); + emitter->size1 = SDL_calloc((size_t)max, sizeof(float)); + emitter->angle = SDL_calloc((size_t)max, sizeof(float)); + emitter->spin = SDL_calloc((size_t)max, sizeof(float)); + emitter->frame = SDL_calloc((size_t)max, sizeof(int32_t)); + emitter->views = SDL_calloc((size_t)max, sizeof(ParticleViewT)); + if ((emitter->px == NULL) || (emitter->py == NULL) || (emitter->pz == NULL) || (emitter->vx == NULL) || (emitter->vy == NULL) || (emitter->vz == NULL) || (emitter->age == NULL) || (emitter->life == NULL) || (emitter->size0 == NULL) || (emitter->size1 == NULL) || (emitter->angle == NULL) || (emitter->spin == NULL) || (emitter->frame == NULL) || (emitter->views == NULL)) { + utilDie("Out of memory for %d particles.", max); + } +} + + +static EmitterT *_find(int32_t emitter) { + EmitterT *found = NULL; + + HASH_FIND_INT(_emitters, &emitter, found); + return found; +} + + +static void _freeFrames(EmitterT *emitter) { + int32_t x = 0; + + for (x = 0; x < emitter->frameCount; x++) { + SDL_DestroySurface(emitter->frames[x]); + } + SDL_free(emitter->frames); + emitter->frames = NULL; + emitter->frameCount = 0; +} + + +static void _freePools(EmitterT *emitter) { + SDL_free(emitter->px); + SDL_free(emitter->py); + SDL_free(emitter->pz); + SDL_free(emitter->vx); + SDL_free(emitter->vy); + SDL_free(emitter->vz); + SDL_free(emitter->age); + SDL_free(emitter->life); + SDL_free(emitter->size0); + SDL_free(emitter->size1); + SDL_free(emitter->angle); + SDL_free(emitter->spin); + SDL_free(emitter->frame); + SDL_free(emitter->views); + emitter->px = NULL; + emitter->py = NULL; + emitter->pz = NULL; + emitter->vx = NULL; + emitter->vy = NULL; + emitter->vz = NULL; + emitter->age = NULL; + emitter->life = NULL; + emitter->size0 = NULL; + emitter->size1 = NULL; + emitter->angle = NULL; + emitter->spin = NULL; + emitter->frame = NULL; + emitter->views = NULL; + emitter->count = 0; +} + + +// Removes particle index by moving the last live one into its slot. +static void _kill(EmitterT *emitter, int32_t index) { + int32_t last = emitter->count - 1; + + if (index != last) { + emitter->px[index] = emitter->px[last]; + emitter->py[index] = emitter->py[last]; + emitter->pz[index] = emitter->pz[last]; + emitter->vx[index] = emitter->vx[last]; + emitter->vy[index] = emitter->vy[last]; + emitter->vz[index] = emitter->vz[last]; + emitter->age[index] = emitter->age[last]; + emitter->life[index] = emitter->life[last]; + emitter->size0[index] = emitter->size0[last]; + emitter->size1[index] = emitter->size1[last]; + emitter->angle[index] = emitter->angle[last]; + emitter->spin[index] = emitter->spin[last]; + emitter->frame[index] = emitter->frame[last]; + } + emitter->count = last; +} + + +// Where particles are born: the 2D position, or the node's world position. +static Vec3T _origin(EmitterT *emitter) { + if (emitter->node >= 0) { + if (nodeValid(emitter->node)) { + emitter->position = nodeGetWorldPosition(emitter->node); + } + } + return emitter->position; +} + + +static float _random(float min, float max) { + return min + (max - min) * SDL_randf(); +} + + +// A unit vector within the spread cone round the direction (in 2D the cone is a fan in the plane). +static Vec3T _randomDirection(EmitterT *emitter) { + Vec3T axis = emitter->direction; + Vec3T other; + Vec3T side; + Vec3T up; + float length = vec3Length(axis); + float cosMax = SDL_cosf(emitter->spread * DEGREES_TO_RADIANS); + float cosA; + float sinA; + float phi; + + if (length < 1e-6f) { + axis = vec3(0.0f, -1.0f, 0.0f); + } else { + axis = vec3Scale(axis, 1.0f / length); + } + if (emitter->node < 0) { + // 2D: rotate within the plane by a random angle up to the spread. + phi = _random(-emitter->spread, emitter->spread) * DEGREES_TO_RADIANS; + return vec3(axis.x * SDL_cosf(phi) - axis.y * SDL_sinf(phi), axis.x * SDL_sinf(phi) + axis.y * SDL_cosf(phi), 0.0f); + } + // 3D: uniform over the spherical cap. + cosA = _random(cosMax, 1.0f); + sinA = SDL_sqrtf(SDL_max(0.0f, 1.0f - cosA * cosA)); + phi = _random(0.0f, 2.0f * SDL_PI_F); + other = (SDL_fabsf(axis.y) < 0.9f) ? vec3(0.0f, 1.0f, 0.0f) : vec3(1.0f, 0.0f, 0.0f); + side = vec3Normalize(vec3Cross(axis, other)); + up = vec3Cross(side, axis); + return vec3Add(vec3Scale(axis, cosA), vec3Add(vec3Scale(side, sinA * SDL_cosf(phi)), vec3Scale(up, sinA * SDL_sinf(phi)))); +} + + +// A random point in the birth disc (2D) or ball (3D) round the origin. +static Vec3T _randomOffset(EmitterT *emitter) { + Vec3T point; + + if (emitter->radius <= 0.0f) { + return vec3(0.0f, 0.0f, 0.0f); + } + // Rejection sampling keeps it uniform. + do { + point = vec3(_random(-1.0f, 1.0f), _random(-1.0f, 1.0f), (emitter->node < 0) ? 0.0f : _random(-1.0f, 1.0f)); + } while (vec3Dot(point, point) > 1.0f); + return vec3Scale(point, emitter->radius); +} + + +static void _spawn(EmitterT *emitter, int32_t count) { + Vec3T origin = _origin(emitter); + Vec3T base = emitter->local ? vec3(0.0f, 0.0f, 0.0f) : origin; + Vec3T offset; + Vec3T velocity; + float variation; + int32_t i; + + while ((count > 0) && (emitter->count < emitter->max)) { + i = emitter->count++; + offset = vec3Add(base, _randomOffset(emitter)); + velocity = vec3Scale(_randomDirection(emitter), _random(emitter->speedMin, emitter->speedMax)); + variation = 1.0f + _random(-emitter->sizeVariation, emitter->sizeVariation); + emitter->px[i] = offset.x; + emitter->py[i] = offset.y; + emitter->pz[i] = offset.z; + emitter->vx[i] = velocity.x; + emitter->vy[i] = velocity.y; + emitter->vz[i] = velocity.z; + emitter->age[i] = 0.0f; + emitter->life[i] = SDL_max(0.001f, _random(emitter->lifeMin, emitter->lifeMax)); + emitter->size0[i] = emitter->sizeStart * variation; + emitter->size1[i] = emitter->sizeEnd * variation; + emitter->angle[i] = _random(0.0f, 360.0f); + emitter->spin[i] = _random(emitter->spinMin, emitter->spinMax); + emitter->frame[i] = (emitter->frameCount > 1) ? (int32_t)_random((float)emitter->frameFirst, (float)emitter->frameLast + 0.999f) : 0; + count--; + } +} + + +static void _step(EmitterT *emitter, float dt) { + float keep = SDL_max(0.0f, 1.0f - emitter->drag * dt); + int32_t i = 0; + + if (emitter->emitting) { + emitter->pending += emitter->rate * dt; + if (emitter->pending >= 1.0f) { + _spawn(emitter, (int32_t)emitter->pending); + emitter->pending -= (float)(int32_t)emitter->pending; + } + } + while (i < emitter->count) { + emitter->age[i] += dt; + if (emitter->age[i] >= emitter->life[i]) { + _kill(emitter, i); + continue; + } + emitter->vx[i] = (emitter->vx[i] + emitter->gravity.x * dt) * keep; + emitter->vy[i] = (emitter->vy[i] + emitter->gravity.y * dt) * keep; + emitter->vz[i] = (emitter->vz[i] + emitter->gravity.z * dt) * keep; + emitter->px[i] += emitter->vx[i] * dt; + emitter->py[i] += emitter->vy[i] * dt; + emitter->pz[i] += emitter->vz[i] * dt; + emitter->angle[i] += emitter->spin[i] * dt; + i++; + } +} + + +static void _view(EmitterT *emitter, EmitterViewT *view) { + Vec3T base = emitter->local ? _origin(emitter) : vec3(0.0f, 0.0f, 0.0f); + float t; + int32_t i; + int32_t c; + + for (i = 0; i < emitter->count; i++) { + t = emitter->age[i] / emitter->life[i]; + emitter->views[i].position = vec3Add(base, vec3(emitter->px[i], emitter->py[i], emitter->pz[i])); + emitter->views[i].size = emitter->size0[i] + (emitter->size1[i] - emitter->size0[i]) * t; + emitter->views[i].angle = emitter->angle[i]; + emitter->views[i].frame = emitter->frame[i]; + for (c = 0; c < 4; c++) { + emitter->views[i].colour[c] = emitter->colour0[c] + (emitter->colour1[c] - emitter->colour0[c]) * t; + } + } + view->id = emitter->id; + view->node = emitter->node; + view->blend = emitter->blend; + view->layer = emitter->layer; + view->frameCount = (emitter->frameCount > 0) ? emitter->frameCount : 1; + view->frames = (emitter->frameCount > 0) ? emitter->frames : &_disc; + view->textureVersion = emitter->textureVersion; + view->count = emitter->count; + view->particles = emitter->views; +} + + +void emitterBurst(int32_t emitter, int32_t count) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + _spawn(found, count); + } +} + + +void emitterClear(int32_t emitter) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->count = 0; + found->pending = 0.0f; + } +} + + +void emitterDelete(int32_t emitter) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + HASH_DEL(_emitters, found); + _freePools(found); + _freeFrames(found); + SDL_free(found); + } +} + + +int32_t emitterGetCount(int32_t emitter) { + EmitterT *found = _find(emitter); + + return (found != NULL) ? found->count : 0; +} + + +bool emitterIs3D(int32_t emitter) { + EmitterT *found = _find(emitter); + + return (found != NULL) && (found->node >= 0); +} + + +bool emitterIsActive(int32_t emitter) { + EmitterT *found = _find(emitter); + + return (found != NULL) && (found->emitting || (found->count > 0)); +} + + +int32_t emitterNew(int32_t node) { + EmitterT *emitter = SDL_calloc(1, sizeof(EmitterT)); + + if (emitter == NULL) { + utilDie("Out of memory for an emitter."); + } + emitter->id = _nextId++; + emitter->node = node; + emitter->rate = 50.0f; + emitter->lifeMin = 1.0f; + emitter->lifeMax = 2.0f; + emitter->speedMin = (node < 0) ? 50.0f : 1.0f; + emitter->speedMax = (node < 0) ? 100.0f : 2.0f; + emitter->direction = (node < 0) ? vec3(0.0f, -1.0f, 0.0f) : vec3(0.0f, 1.0f, 0.0f); + emitter->spread = 30.0f; + emitter->sizeStart = (node < 0) ? 16.0f : 0.2f; + emitter->sizeEnd = (node < 0) ? 4.0f : 0.05f; + emitter->colour0[0] = 1.0f; + emitter->colour0[1] = 1.0f; + emitter->colour0[2] = 1.0f; + emitter->colour0[3] = 1.0f; + emitter->colour1[0] = 1.0f; + emitter->colour1[1] = 1.0f; + emitter->colour1[2] = 1.0f; + emitter->colour1[3] = 0.0f; + emitter->textureVersion = 1; + _allocatePools(emitter, DEFAULT_MAX); + HASH_ADD_INT(_emitters, id, emitter); + return emitter->id; +} + + +void emitterSetBlend(int32_t emitter, ParticleBlendE blend) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->blend = blend; + } +} + + +void emitterSetColor(int32_t emitter, const float *start, const float *finish) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + memcpy(found->colour0, start, sizeof(found->colour0)); + memcpy(found->colour1, finish, sizeof(found->colour1)); + } +} + + +void emitterSetDirection(int32_t emitter, Vec3T direction) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->direction = direction; + } +} + + +void emitterSetDrag(int32_t emitter, float perSecond) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->drag = SDL_max(0.0f, perSecond); + } +} + + +void emitterSetFrames(int32_t emitter, int32_t first, int32_t last) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->frameFirst = SDL_clamp(first, 0, SDL_max(0, found->frameCount - 1)); + found->frameLast = SDL_clamp(last, found->frameFirst, SDL_max(0, found->frameCount - 1)); + } +} + + +void emitterSetGravity(int32_t emitter, Vec3T acceleration) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->gravity = acceleration; + } +} + + +void emitterSetLayer(int32_t emitter, ParticleLayerE layer) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->layer = layer; + } +} + + +void emitterSetLife(int32_t emitter, float minSeconds, float maxSeconds) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->lifeMin = SDL_max(0.001f, minSeconds); + found->lifeMax = SDL_max(found->lifeMin, maxSeconds); + } +} + + +void emitterSetLocal(int32_t emitter, bool local) { + EmitterT *found = _find(emitter); + + if ((found != NULL) && (found->local != local)) { + // Keep the live particles where they are by moving them between frames of reference. + Vec3T origin = _origin(found); + float sign = local ? -1.0f : 1.0f; + int32_t i; + + for (i = 0; i < found->count; i++) { + found->px[i] += sign * origin.x; + found->py[i] += sign * origin.y; + found->pz[i] += sign * origin.z; + } + found->local = local; + } +} + + +void emitterSetMax(int32_t emitter, int32_t count) { + EmitterT *found = _find(emitter); + + if ((found != NULL) && (count > 0) && (count != found->max)) { + _allocatePools(found, count); + } +} + + +void emitterSetPosition(int32_t emitter, Vec3T position) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->position = position; + } +} + + +void emitterSetRadius(int32_t emitter, float radius) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->radius = SDL_max(0.0f, radius); + } +} + + +void emitterSetRate(int32_t emitter, float perSecond) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->rate = SDL_max(0.0f, perSecond); + } +} + + +void emitterSetSize(int32_t emitter, float start, float finish, float variation) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->sizeStart = SDL_max(0.0f, start); + found->sizeEnd = SDL_max(0.0f, finish); + found->sizeVariation = SDL_clamp(variation, 0.0f, 1.0f); + } +} + + +void emitterSetSpeed(int32_t emitter, float min, float max) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->speedMin = min; + found->speedMax = SDL_max(min, max); + } +} + + +void emitterSetSpin(int32_t emitter, float min, float max) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->spinMin = min; + found->spinMax = SDL_max(min, max); + } +} + + +void emitterSetSpread(int32_t emitter, float degrees) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->spread = SDL_clamp(degrees, 0.0f, 180.0f); + } +} + + +void emitterSetTexture(int32_t emitter, SDL_Surface **frames, int32_t frameCount) { + EmitterT *found = _find(emitter); + int32_t x = 0; + + if (found == NULL) { + return; + } + _freeFrames(found); + if ((frames != NULL) && (frameCount > 0)) { + found->frames = SDL_calloc((size_t)frameCount, sizeof(SDL_Surface *)); + if (found->frames == NULL) { + utilDie("Out of memory for particle frames."); + } + for (x = 0; x < frameCount; x++) { + found->frames[x] = SDL_DuplicateSurface(frames[x]); + if (found->frames[x] == NULL) { + utilDie("%s", SDL_GetError()); + } + } + found->frameCount = frameCount; + } + found->frameFirst = 0; + found->frameLast = 0; + found->textureVersion++; + for (x = 0; x < found->count; x++) { + found->frame[x] = 0; + } +} + + +void emitterStart(int32_t emitter) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->emitting = true; + } +} + + +void emitterStop(int32_t emitter) { + EmitterT *found = _find(emitter); + + if (found != NULL) { + found->emitting = false; + found->pending = 0.0f; + } +} + + +bool emitterValid(int32_t emitter) { + return _find(emitter) != NULL; +} + + +void particlesClearQueue2D(void) { + _queueCount = 0; +} + + +int32_t particlesCount(void) { + return (int32_t)HASH_COUNT(_emitters); +} + + +// The built-in picture: a soft disc, opaque in the middle fading to nothing at the edge. +void particlesInit(void) { + int32_t x; + int32_t y; + float dx; + float dy; + float d; + uint8_t alpha; + uint32_t *pixels; + + _disc = SDL_CreateSurface(DISC_SIZE, DISC_SIZE, SDL_PIXELFORMAT_RGBA32); + if (_disc == NULL) { + utilDie("%s", SDL_GetError()); + } + pixels = (uint32_t *)_disc->pixels; + for (y = 0; y < DISC_SIZE; y++) { + for (x = 0; x < DISC_SIZE; x++) { + dx = ((float)x + 0.5f) / (DISC_SIZE / 2.0f) - 1.0f; + dy = ((float)y + 0.5f) / (DISC_SIZE / 2.0f) - 1.0f; + d = SDL_sqrtf(dx * dx + dy * dy); + alpha = (uint8_t)(255.0f * SDL_clamp(1.0f - d, 0.0f, 1.0f) * SDL_clamp(1.0f - d, 0.0f, 1.0f)); + pixels[y * (_disc->pitch / 4) + x] = SDL_MapSurfaceRGBA(_disc, 255, 255, 255, alpha); + } + } + _lastTick = 0; +} + + +void particlesQueue2D(int32_t emitter) { + if ((_queueCount < QUEUE_MAX) && emitterValid(emitter)) { + _queue[_queueCount++] = emitter; + } +} + + +int32_t particlesQueued2D(ParticleLayerE layer, int32_t *emitters, int32_t max) { + EmitterT *found; + int32_t x; + int32_t n = 0; + + for (x = 0; (x < _queueCount) && (n < max); x++) { + found = _find(_queue[x]); + if ((found != NULL) && (found->layer == layer)) { + emitters[n++] = _queue[x]; + } + } + return n; +} + + +void particlesQuit(void) { + EmitterT *emitter; + EmitterT *next; + + HASH_ITER(hh, _emitters, emitter, next) { + emitterDelete(emitter->id); + } + if (_disc != NULL) { + SDL_DestroySurface(_disc); + _disc = NULL; + } + _queueCount = 0; +} + + +// Advances every emitter by the wall-clock time since the last call; a paused game passes no time. +void particlesUpdate(bool advance) { + EmitterT *emitter; + EmitterT *next; + uint64_t now = SDL_GetTicksNS(); + double dt = 0.0; + + if (advance && (_lastTick != 0)) { + dt = (double)(now - _lastTick) / 1e9; + } + _lastTick = now; + if (dt > MAX_STEP_SECONDS) { + dt = MAX_STEP_SECONDS; + } + HASH_ITER(hh, _emitters, emitter, next) { + if ((emitter->node >= 0) && !nodeValid(emitter->node)) { + emitterDelete(emitter->id); + continue; + } + if (dt > 0.0) { + _step(emitter, (float)dt); + } + } +} + + +bool particlesView(int32_t index, EmitterViewT *view) { + EmitterT *emitter; + EmitterT *next; + int32_t n = 0; + + HASH_ITER(hh, _emitters, emitter, next) { + if (n == index) { + _view(emitter, view); + return true; + } + n++; + } + return false; +} + + +bool particlesViewEmitter(int32_t emitter, EmitterViewT *view) { + EmitterT *found = _find(emitter); + + if (found == NULL) { + return false; + } + _view(found, view); + return true; +} diff --git a/src/particles.h b/src/particles.h new file mode 100644 index 000000000..9d0538102 --- /dev/null +++ b/src/particles.h @@ -0,0 +1,87 @@ +/* + * Singe - Particles: emitters that spawn, move and age particles for the 2D overlay and the 3D scene. + * Copyright (C) 2026 Scott Duensing + * + * See LICENSE for details. + */ + +#ifndef PARTICLES_H +#define PARTICLES_H + +#include +#include +#include +#include "math3d.h" + +typedef enum ParticleBlendE { + PARTICLE_ALPHA = 0, // Normal alpha blending + PARTICLE_ADD = 1 // Additive: light on light +} ParticleBlendE; + +typedef enum ParticleLayerE { + PARTICLE_OVER = 0, // 2D: drawn above the overlay + PARTICLE_UNDER = 1 // 2D: drawn beneath the overlay, above the video and the scene +} ParticleLayerE; + +// One particle, as handed to a renderer: world or overlay position, size, rotation, tint and frame. +typedef struct ParticleViewT { + Vec3T position; + float size; + float angle; // Degrees + float colour[4]; // 0..1 + int32_t frame; +} ParticleViewT; + +// Renderers ask an emitter for its live particles; the texture per frame is theirs to keep. +typedef struct EmitterViewT { + int32_t id; + int32_t node; // -1 for a 2D emitter + ParticleBlendE blend; + ParticleLayerE layer; + int32_t frameCount; + SDL_Surface **frames; // frameCount surfaces (the built-in disc when no sprite is set) + uint32_t textureVersion; // Bumped whenever the frames change + int32_t count; + ParticleViewT *particles; // count entries, valid until the next update +} EmitterViewT; + +void particlesInit(void); +void particlesQuit(void); +void particlesUpdate(bool advance); +int32_t particlesCount(void); +bool particlesView(int32_t index, EmitterViewT *view); // Emitter number index of particlesCount +bool particlesViewEmitter(int32_t emitter, EmitterViewT *view); +void particlesQueue2D(int32_t emitter); // Draw this emitter this frame +int32_t particlesQueued2D(ParticleLayerE layer, int32_t *emitters, int32_t max); +void particlesClearQueue2D(void); + +int32_t emitterNew(int32_t node); +bool emitterValid(int32_t emitter); +void emitterDelete(int32_t emitter); +void emitterSetTexture(int32_t emitter, SDL_Surface **frames, int32_t frameCount); // Copies the surfaces; NULL restores the disc +void emitterSetFrames(int32_t emitter, int32_t first, int32_t last); +void emitterSetBlend(int32_t emitter, ParticleBlendE blend); +void emitterSetLayer(int32_t emitter, ParticleLayerE layer); +void emitterSetRate(int32_t emitter, float perSecond); +void emitterSetLife(int32_t emitter, float minSeconds, float maxSeconds); +void emitterSetSpeed(int32_t emitter, float min, float max); +void emitterSetDirection(int32_t emitter, Vec3T direction); +void emitterSetSpread(int32_t emitter, float degrees); +void emitterSetGravity(int32_t emitter, Vec3T acceleration); +void emitterSetDrag(int32_t emitter, float perSecond); +void emitterSetSize(int32_t emitter, float start, float finish, float variation); +void emitterSetColor(int32_t emitter, const float *start, const float *finish); +void emitterSetSpin(int32_t emitter, float min, float max); +void emitterSetRadius(int32_t emitter, float radius); +void emitterSetLocal(int32_t emitter, bool local); +void emitterSetMax(int32_t emitter, int32_t count); +void emitterSetPosition(int32_t emitter, Vec3T position); +void emitterStart(int32_t emitter); +void emitterStop(int32_t emitter); +void emitterBurst(int32_t emitter, int32_t count); +void emitterClear(int32_t emitter); +int32_t emitterGetCount(int32_t emitter); +bool emitterIsActive(int32_t emitter); +bool emitterIs3D(int32_t emitter); + +#endif diff --git a/src/physics.h b/src/physics.h index ea28ff444..c0ffb8f86 100644 --- a/src/physics.h +++ b/src/physics.h @@ -76,6 +76,21 @@ typedef enum JointTypeE { } JointTypeE; +typedef enum SoftKindE { + SOFT_CLOTH = 0, // An open mesh: a flag, a curtain, a sheet + SOFT_BODY = 1, // A closed mesh under pressure: a balloon, a jelly + SOFT_ROPE = 2 // A chain of points with a tube drawn round it +} SoftKindE; + + +typedef enum VehicleKindE { + VEHICLE_CAR = 0, + VEHICLE_MOTORCYCLE = 1, + VEHICLE_TANK = 2, + VEHICLE_BOAT = 3 +} VehicleKindE; + + 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); @@ -86,16 +101,76 @@ bool bodyIsResting(int32_t node); bool bodyNew(int32_t node, BodyTypeE type, ShapeTypeE shape, float a, float b, float c); bool bodySetAngularVelocity(int32_t node, Vec3T velocity); bool bodySetBounce(int32_t node, float bounce); +bool bodySetBuoyancy(int32_t node, float factor); +bool bodySetCurrent(int32_t node, Vec3T flow); 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 bodySetWater(int32_t node, float density, float linearDrag, float angularDrag); 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); +bool playerDelete(int32_t node); +bool playerExists(int32_t node); +int32_t playerGetGround(int32_t node, Vec3T *normal); +Vec3T playerGetVelocity(int32_t node); +bool playerIsOnGround(int32_t node); +bool playerIsSwimming(int32_t node); +bool playerJump(int32_t node, float speed); +bool playerMove(int32_t node, Vec3T velocity); +bool playerNew(int32_t node, ShapeTypeE shape, float a, float b, float c); +bool playerSetEnabled(int32_t node, bool enabled); +bool playerSetGravityScale(int32_t node, float scale); +bool playerSetMass(int32_t node, float kilograms); +bool playerSetPosition(int32_t node, Vec3T position); +bool playerSetPush(int32_t node, float strength); +bool playerSetSlope(int32_t node, float degrees); +bool playerSetStep(int32_t node, float height); +bool playerSetSwim(int32_t node, float sinkSpeed, float drag); +bool playerSetVelocity(int32_t node, Vec3T velocity); +bool ragdollActivate(int32_t node); +bool ragdollApplyImpulse(int32_t node, const char *joint, Vec3T impulse); +bool ragdollDeactivate(int32_t node); +bool ragdollDelete(int32_t node); +bool ragdollExists(int32_t node); +bool ragdollIsActive(int32_t node); +bool ragdollIsResting(int32_t node); +bool ragdollNew(int32_t node); +bool ragdollSetJoint(int32_t node, const char *joint, float radius, float swingDegrees, float twistDegrees); +bool ragdollSetStrength(int32_t node, float strength); +bool softDelete(int32_t node); +bool softExists(int32_t node); +bool softNew(int32_t node, SoftKindE kind); +bool softNewRope(int32_t node, Vec3T end, int32_t segments, float radius); +bool softPin(int32_t node, Vec3T point, int32_t follow); +bool softSetDamping(int32_t node, float damping); +bool softSetMass(int32_t node, float kilograms); +bool softSetPressure(int32_t node, float pressure); +bool softSetStiffness(int32_t node, float stretch, float bend); +bool softUnpin(int32_t node, Vec3T point); +int32_t vehicleAddWheel(int32_t node, int32_t wheelNode, float radius, float width, float suspension); +bool vehicleDelete(int32_t node); +bool vehicleDrive(int32_t node, float forward, float right, float brake, float handBrake); +bool vehicleExists(int32_t node); +int32_t vehicleGetGear(int32_t node); +float vehicleGetRpm(int32_t node); +float vehicleGetSpeed(int32_t node); +float vehicleGetWheelSlip(int32_t node, int32_t index); +bool vehicleIsWheelOnGround(int32_t node, int32_t index); +bool vehicleNew(int32_t node, VehicleKindE kind); +bool vehicleSetAntiRoll(int32_t node, float stiffness); +bool vehicleSetBrakes(int32_t node, float brake, float handBrake); +bool vehicleSetEngine(int32_t node, float maxTorque, float maxRpm, float minRpm); +bool vehicleSetGears(int32_t node, const float *ratios, int32_t count, float reverse, bool automatic); +bool vehicleSetSteering(int32_t node, float maxDegrees); +bool vehicleSetRudder(int32_t node, float maxTorque); +bool vehicleSetSuspension(int32_t node, float frequency, float damping); +bool vehicleSetThrust(int32_t node, float maxForce, Vec3T point); +bool vehicleSetWheel(int32_t node, int32_t index, bool steered, bool driven); int32_t physicsGetEvents(PhysicsEventT *events, int32_t maximum); bool physicsInit(void); void physicsQuit(void); diff --git a/src/physicsJolt.cpp b/src/physicsJolt.cpp index b6d643f52..ba394533a 100644 --- a/src/physicsJolt.cpp +++ b/src/physicsJolt.cpp @@ -48,16 +48,81 @@ #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" +#include "model.h" } #include "physics.h" #define MAX_BODIES 4096 +#define MAX_PLAYERS 16 +#define MAX_VEHICLES 16 +#define MAX_WATERS 8 +#define MAX_PLAYER_TRIGGERS 16 +#define MAX_RAGDOLLS 8 +#define MAX_SOFT 16 +#define MAX_SOFT_PINS 32 +#define ROPE_SIDES 6 +#define SOFT_WELD 1.0e-4f +#define STRETCH_COMPLIANCE 1.0e-3f // At stiffness 0; stiffness 1 is rigid +#define BEND_COMPLIANCE 1.0e-2f +#define DEFAULT_SOFT_STRETCH 0.9f +#define DEFAULT_SOFT_BEND 0.2f +#define DEFAULT_SOFT_MASS 1.0f +#define DEFAULT_SOFT_DAMPING 0.1f +#define SOFT_ITERATIONS 8 +#define MAX_RAGDOLL_PARTS 48 +#define RAGDOLL_RADIUS_RATIO 0.22f // Capsule radius as a share of the bone length ... +#define RAGDOLL_RADIUS_MIN 0.02f // ... and never thinner than this +#define RAGDOLL_SWING_DEGREES 45.0f +#define RAGDOLL_TWIST_DEGREES 30.0f +#define RAGDOLL_MOTOR_HZ 4.0f +#define RAGDOLL_MOTOR_DAMPING 1.0f +#define DEFAULT_BUOYANCY 1.2f +#define DEFAULT_SINK_SPEED 0.3f +#define DEFAULT_SWIM_DRAG 2.0f +#define MAX_WHEELS 16 +#define MAX_GEARS 8 +#define DEFAULT_ENGINE_TORQUE 500.0f +#define DEFAULT_ENGINE_MAX_RPM 6000.0f +#define DEFAULT_ENGINE_MIN_RPM 1000.0f +#define DEFAULT_STEER_DEGREES 35.0f +#define DEFAULT_BRAKE_TORQUE 1500.0f +#define DEFAULT_HANDBRAKE_TORQUE 4000.0f +#define DEFAULT_SUSPENSION_HZ 1.5f +#define DEFAULT_SUSPENSION_DAMPING 0.5f +#define VEHICLE_MAX_TILT_DEGREES 60.0f +#define LEAN_SPRING 5000.0f // Jolt's motorcycle lean spring and damping ... +#define LEAN_DAMPING 1000.0f +#define LEAN_INERTIA 40.0f // ... for a chassis of this roll inertia (kg m^2); scaled from there +#define DEFAULT_STEP_HEIGHT 0.3f // For a DEFAULT_EXTENT player; scales with the shape +#define DEFAULT_EXTENT 0.3f // The half-extent Jolt's character defaults were tuned for +#define PADDING_PER_EXTENT (0.02f / 0.3f) +#define PREDICTIVE_PER_EXTENT (0.1f / 0.3f) +#define TOLERANCE_PER_EXTENT (0.001f / 0.3f) +#define STICK_PER_EXTENT (0.5f / 0.3f) +#define STEP_TEST_PER_EXTENT (0.15f / 0.3f) +#define DEFAULT_SLOPE_DEGREES 45.0f +#define DEFAULT_PUSH_STRENGTH 300.0f #define MAX_BODY_PAIRS 4096 #define MAX_CONTACTS 8192 #define TEMP_ALLOCATOR_BYTES (16 * 1024 * 1024) @@ -127,12 +192,160 @@ namespace { bool trigger; // A sensor: reports overlaps, pushes nothing bool enabled; // In the world (bodySetEnabled) bool used; + bool water; // A trigger full of water (bodySetWater) + float waterDensity; + float waterLinearDrag; + float waterAngularDrag; + Vec3T current; // Flow inside the water + float buoyancy; // How this body floats in water (bodySetBuoyancy) + }; + + + // A water surface found this step, for players and boats to test against. + struct WaterSurfaceT { + JPH::AABox box; + JPH::RVec3 position; + JPH::Vec3 normal; + JPH::Vec3 current; + }; + + + // A character controller on a node: Jolt's CharacterVirtual, moved by intent rather than force. + struct PlayerRecordT { + int32_t node; + uint32_t generation; + JPH::Ref character; + float extent; // Smallest half-size of the shape: the unit the tolerances scale by + float height; // Of the shape, for the swimming test + float sinkSpeed; // Swimming: how fast it sinks with no input + float swimDrag; + bool swimming; + JPH::Vec3 swimCurrent; + int32_t inside[MAX_PLAYER_TRIGGERS]; // Trigger nodes the player is in + int32_t insideCount; + Vec3T intent; // Wanted horizontal velocity, consumed by the next steps + float jumpSpeed; // Pending jump, consumed by the next step + float gravityScale; + float stepHeight; + bool enabled; + bool used; + }; + + + // One bone of a ragdoll: a joint node with at least one child joint, as a capsule body. + struct RagdollPartT { + int32_t joint; + int32_t parent; // Part index of the nearest ancestor joint with a part, or -1 + float radius; // 0: from the bone length + float swing; // Degrees + float twist; + JPH::BodyID body; + JPH::Ref constraint; + JPH::Vec3 offsetPosition; // The joint's origin in the body's frame + JPH::Quat offsetRotation; // The joint's rotation in the body's frame + JPH::Quat rest; // This body's rotation relative to the parent body when activated + JPH::Vec3 scale; // The joint's world scale, kept for its children + }; + + + // A skinned model's skeleton as bodies and joints, switched on and off. + struct RagdollRecordT { + int32_t node; // The model instance's root + uint32_t generation; + int32_t skinned; // The node carrying the skin + RagdollPartT parts[MAX_RAGDOLL_PARTS]; + int32_t partCount; + JPH::Ref filter; + float strength; + bool strengthChanged; + bool active; + bool used; + }; + + + // A vertex of a soft body held in place, or held to a node. + struct SoftPinT { + int32_t vertex; + int32_t follow; // -1: held where it was pinned + }; + + + // A soft body driving a mesh: cloth or a pressure body from the node's own mesh (its vertices + // welded by position into particles), or a rope whose tube mesh the engine makes and moves. + struct SoftRecordT { + int32_t node; + uint32_t generation; + SoftKindE kind; + int32_t mesh; + JPH::BodyID body; + JPH::Ref shared; + int32_t *meshToSoft; // Mesh vertex to particle + float *positions; // Particles, world space + int32_t count; + float *meshPositions; // Scratch for the mesh rewrite + int32_t meshVertexCount; + SoftPinT pins[MAX_SOFT_PINS]; + int32_t pinCount; + float stretch; + float bend; + float pressure; + float mass; + float damping; + float ropeRadius; + bool used; + }; + + + // A wheel of a vehicle: the node the engine poses, and its geometry relative to the chassis. + struct WheelRecordT { + int32_t node; + float radius; + float width; + float suspension; + bool steered; + bool driven; + bool steeredSet; // vehicleSetWheel called; otherwise front wheels steer and all drive + }; + + + // A vehicle on a chassis body: the recipe, and the Jolt constraint built from it on demand. + struct VehicleRecordT { + int32_t node; + uint32_t generation; + VehicleKindE kind; + JPH::Ref constraint; + JPH::Ref tester; + WheelRecordT wheels[MAX_WHEELS]; + int32_t wheelCount; + float maxTorque; + float maxRpm; + float minRpm; + float gears[MAX_GEARS]; + int32_t gearCount; + float reverseGear; + bool automatic; + float suspensionHz; + float suspensionDamping; + float maxSteer; // Degrees + float brakeTorque; + float handBrakeTorque; + float antiRoll; + float inputForward; + float inputRight; + float inputBrake; + float inputHandBrake; + float thrust; // Boats: propeller force ... + Vec3T thrustPoint; // ... applied here in the hull's frame + float rudder; // ... and turning torque + bool dirty; // Settings changed: rebuild before the next step + bool used; }; struct WorldT; extern WorldT *_world; - bool _isTrigger(JPH::BodyID id); + bool _isTrigger(JPH::BodyID id); + PlayerRecordT *_findPlayer(int32_t node); // Collects contacts from Jolt's job threads; the engine drains it after the step. @@ -160,6 +373,10 @@ namespace { event.nodeA = event.nodeB; event.nodeB = swap; } + if (_findPlayer(event.nodeB) != nullptr) { + // A player's inner body: the engine reports players entering triggers itself. + return; + } } else { event.type = PHYSICS_EVENT_COLLISION; } @@ -188,6 +405,16 @@ namespace { }; + // Contacts between a player and bodies, reported like body contacts: the player's node first, + // or the trigger first when the other is a sensor. These arrive from the character's own update + // on the engine thread, but share the body listener's queue and lock. + class PlayerListenerT final : public JPH::CharacterContactListener { + public: + bool OnContactValidate(const JPH::CharacterVirtual *character, const JPH::CharacterContact &contact) override; + void OnContactAdded(const JPH::CharacterVirtual *character, const JPH::CharacterContact &contact, JPH::CharacterContactSettings &settings) override; + }; + + struct WorldT { JPH::TempAllocatorImpl *tempAllocator; JPH::JobSystemThreadPool *jobs; @@ -200,6 +427,18 @@ namespace { int32_t bodyCount; JointRecordT *joints; int32_t jointCount; + PlayerRecordT *players; + int32_t playerCount; + PlayerListenerT *playerListener; + VehicleRecordT *vehicles; + int32_t vehicleCount; + RagdollRecordT *ragdolls; + int32_t ragdollCount; + SoftRecordT *softs; + int32_t softCount; + JPH::uint32 ragdollGroups; // Next collision group id + WaterSurfaceT waters[MAX_WATERS]; + int32_t waterCount; double accumulator; // Seconds owed to the fixed step uint64_t lastTick; bool enabled; @@ -226,6 +465,9 @@ namespace { event.type = PHYSICS_EVENT_LEAVE; event.nodeA = (int32_t)(uint32_t)(sensorA ? userA : userB); event.nodeB = (int32_t)(uint32_t)(sensorA ? userB : userA); + if (_findPlayer(event.nodeB) != nullptr) { + return; + } event.point = vec3(0.0f, 0.0f, 0.0f); event.speed = 0.0f; push(event); @@ -233,6 +475,38 @@ namespace { JPH::RefConst _buildMeshShape(int32_t node, ShapeTypeE shape, Vec3T position, QuatT rotation); + JPH::RefConst _buildShape(int32_t node, BodyTypeE type, ShapeTypeE shape, float a, float b, float c, Vec3T position, QuatT rotation, Vec3T scale); + PlayerRecordT *_findPlayer(int32_t node); + void _playerTriggers(PlayerRecordT *record); + VehicleRecordT *_findVehicle(int32_t node); + RagdollRecordT *_findRagdoll(int32_t node); + SoftRecordT *_findSoft(int32_t node); + bool _buildSoft(SoftRecordT *record); + void _pinSoft(void); + void _releaseSoft(SoftRecordT *record); + void _releaseSoftBody(SoftRecordT *record); + void _resetSoft(SoftRecordT *record); + void _ropeMesh(const SoftRecordT *record, Vec3T *out); + void _writeSoft(void); + int32_t _nearestSoftVertex(const SoftRecordT *record, Vec3T point); + int32_t _findSkinned(int32_t node); + int32_t _ragdollPartOf(RagdollRecordT *record, int32_t joint); + void _releaseRagdoll(RagdollRecordT *record); + void _releaseRagdollBodies(RagdollRecordT *record); + void _resetRagdoll(RagdollRecordT *record); + void _steerRagdolls(void); + void _writeRagdolls(void); + void _applyWater(float dt); + bool _underWater(JPH::RVec3Arg point, JPH::Vec3 *current); + bool _buildVehicle(VehicleRecordT *record); + void _driveVehicles(void); + void _poseWheels(void); + void _releaseVehicle(VehicleRecordT *record); + void _resetVehicle(VehicleRecordT *record); + JPH::Vec3 _playerUp(void); + void _releasePlayer(PlayerRecordT *record); + void _stepPlayers(float dt); + void _writePlayers(void); 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); @@ -377,6 +651,11 @@ namespace { JPH::BodyInterface &bodies = _world->system->GetBodyInterface(); int32_t x; + for (x = 0; x < _world->vehicleCount; x++) { + if (_world->vehicles[x].used && (_world->vehicles[x].node == record->node)) { + _releaseVehicle(&_world->vehicles[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); @@ -390,6 +669,1180 @@ namespace { } + // Triggers are not solid for a player: the engine tests players against them itself. + bool PlayerListenerT::OnContactValidate(const JPH::CharacterVirtual *character, const JPH::CharacterContact &contact) { + (void)character; + return !contact.mIsSensorB; + } + + + void PlayerListenerT::OnContactAdded(const JPH::CharacterVirtual *character, const JPH::CharacterContact &contact, JPH::CharacterContactSettings &settings) { + PhysicsEventT event; + + (void)settings; + event.type = PHYSICS_EVENT_COLLISION; + event.nodeA = (int32_t)(uint32_t)character->GetUserData(); + event.nodeB = (int32_t)(uint32_t)contact.mUserData; + event.point = vec3((float)contact.mPosition.GetX(), (float)contact.mPosition.GetY(), (float)contact.mPosition.GetZ()); + event.speed = fabsf((character->GetLinearVelocity() - contact.mLinearVelocity).Dot(contact.mContactNormal)); + std::lock_guard guard(_world->contacts->lock); + _world->contacts->events.push_back(event); + } + + + // The collision shape for a body or a player, scaled by the node. + JPH::RefConst _buildShape(int32_t node, BodyTypeE type, ShapeTypeE shape, float a, float b, float c, Vec3T position, QuatT rotation, Vec3T scale) { + float radius; + float height; + + switch (shape) { + case SHAPE_BOX: + return new JPH::BoxShape(JPH::Vec3(SDL_max(a * scale.x, MIN_DIMENSION) / 2.0f, SDL_max(b * scale.y, MIN_DIMENSION) / 2.0f, SDL_max(c * scale.z, MIN_DIMENSION) / 2.0f), 0.0f); + case SHAPE_SPHERE: + return new JPH::SphereShape(SDL_max(a * SDL_max(scale.x, SDL_max(scale.y, scale.z)), MIN_DIMENSION)); + case SHAPE_CAPSULE: + radius = SDL_max(a * SDL_max(scale.x, scale.z), MIN_DIMENSION); + height = SDL_max(b * scale.y, MIN_DIMENSION); + return new JPH::CapsuleShape(SDL_max(height / 2.0f - radius, MIN_DIMENSION), radius); + case SHAPE_CYLINDER: + radius = SDL_max(a * SDL_max(scale.x, scale.z), MIN_DIMENSION); + height = SDL_max(b * scale.y, MIN_DIMENSION); + return new JPH::CylinderShape(height / 2.0f, radius); + 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 nullptr; + } + return _buildMeshShape(node, shape, position, rotation); + default: + utilTrace("Physics: unknown shape %d.", (int32_t)shape); + return nullptr; + } + } + + + PlayerRecordT *_findPlayer(int32_t node) { + int32_t x; + + if (_world == nullptr) { + return nullptr; + } + for (x = 0; x < _world->playerCount; x++) { + if (_world->players[x].used && (_world->players[x].node == node)) { + return &_world->players[x]; + } + } + return nullptr; + } + + + // Which way is up for players: against gravity (a 2D game's gravity points down the screen, +Y). + JPH::Vec3 _playerUp(void) { + JPH::Vec3 gravity = _world->system->GetGravity(); + + if (gravity.LengthSq() < 1e-8f) { + return JPH::Vec3::sAxisY(); + } + return -gravity.Normalized(); + } + + + void _releasePlayer(PlayerRecordT *record) { + record->character = nullptr; + record->node = 0; + record->generation = 0; + record->intent = vec3(0.0f, 0.0f, 0.0f); + record->jumpSpeed = 0.0f; + record->gravityScale = 1.0f; + record->stepHeight = DEFAULT_STEP_HEIGHT; + record->height = 0.0f; + record->sinkSpeed = DEFAULT_SINK_SPEED; + record->swimDrag = DEFAULT_SWIM_DRAG; + record->swimming = false; + record->swimCurrent = JPH::Vec3::sZero(); + record->insideCount = 0; + record->enabled = false; + record->used = false; + } + + + // Moves every player by its intent, gravity and pending jump for one fixed step, before the + // world steps so platforms carry it and the bodies it shoves feel it this step. + void _stepPlayers(float dt) { + JPH::Vec3 gravity = _world->system->GetGravity(); + JPH::Vec3 up = _playerUp(); + int32_t x; + + for (x = 0; x < _world->playerCount; x++) { + PlayerRecordT *record = &_world->players[x]; + JPH::Vec3 velocity; + JPH::Vec3 scaledGravity; + float vertical; + JPH::CharacterVirtual::ExtendedUpdateSettings settings; + + if (!record->used || !record->enabled) { + continue; + } + if (!nodeValid(record->node) || (nodeGetGeneration(record->node) != record->generation)) { + _releasePlayer(record); + continue; + } + scaledGravity = gravity * record->gravityScale; + record->character->SetUp(up); + velocity = record->character->GetLinearVelocity(); + vertical = (velocity - record->character->GetGroundVelocity()).Dot(up); + record->swimming = _underWater(record->character->GetPosition() + up * (record->height * 0.5f), &record->swimCurrent); + if (record->swimming) { + // Afloat: the script steers in three axes and the water drags it toward that; with no + // vertical intent it sinks slowly, and the current carries it. + JPH::Vec3 intent = JPH::Vec3(record->intent.x, record->intent.y, _world->planar ? 0.0f : record->intent.z); + JPH::Vec3 target = intent - up * intent.Dot(up) + up * ((intent.Dot(up) != 0.0f) ? intent.Dot(up) : -record->sinkSpeed) + record->swimCurrent; + + velocity = velocity + (target - velocity) * SDL_min(1.0f, record->swimDrag * dt); + } else if ((record->character->GetGroundState() == JPH::CharacterBase::EGroundState::OnGround) && (vertical < 0.1f)) { + // Standing: ride the ground, and jump off it if asked. + velocity = record->character->GetGroundVelocity(); + if (record->jumpSpeed > 0.0f) { + velocity += up * record->jumpSpeed; + } + } else { + // Airborne (or on a slope too steep): keep only the vertical part, and keep falling. + velocity = up * velocity.Dot(up) + scaledGravity * dt; + } + if (!record->swimming) { + velocity += JPH::Vec3(record->intent.x, 0.0f, _world->planar ? 0.0f : record->intent.z); + } + if (_world->planar) { + velocity.SetZ(0.0f); + } + record->jumpSpeed = 0.0f; + record->character->SetLinearVelocity(velocity); + settings.mWalkStairsStepUp = up * record->stepHeight; + settings.mStickToFloorStepDown = -up * SDL_max(record->stepHeight, record->extent * STICK_PER_EXTENT); + settings.mWalkStairsMinStepForward = record->extent * PADDING_PER_EXTENT; + settings.mWalkStairsStepForwardTest = record->extent * STEP_TEST_PER_EXTENT; + record->character->ExtendedUpdate(dt, scaledGravity, settings, _world->system->GetDefaultBroadPhaseLayerFilter(LAYER_MOVING), _world->system->GetDefaultLayerFilter(LAYER_MOVING), {}, {}, *_world->tempAllocator); + if (_world->planar) { + JPH::RVec3 where = record->character->GetPosition(); + + where.SetZ(0.0); + record->character->SetPosition(where); + } + } + } + + + SoftRecordT *_findSoft(int32_t node) { + int32_t x; + + if (_world == nullptr) { + return nullptr; + } + for (x = 0; x < _world->softCount; x++) { + if (_world->softs[x].used && (_world->softs[x].node == node)) { + return &_world->softs[x]; + } + } + return nullptr; + } + + + void _releaseSoftBody(SoftRecordT *record) { + if (!record->body.IsInvalid()) { + _world->system->GetBodyInterface().RemoveBody(record->body); + _world->system->GetBodyInterface().DestroyBody(record->body); + record->body = JPH::BodyID(); + } + } + + + void _releaseSoft(SoftRecordT *record) { + _releaseSoftBody(record); + SDL_free(record->meshToSoft); + SDL_free(record->positions); + SDL_free(record->meshPositions); + _resetSoft(record); + } + + + void _resetSoft(SoftRecordT *record) { + record->shared = nullptr; + record->body = JPH::BodyID(); + record->node = 0; + record->generation = 0; + record->kind = SOFT_CLOTH; + record->mesh = -1; + record->meshToSoft = nullptr; + record->positions = nullptr; + record->count = 0; + record->meshPositions = nullptr; + record->meshVertexCount = 0; + record->pinCount = 0; + record->stretch = DEFAULT_SOFT_STRETCH; + record->bend = DEFAULT_SOFT_BEND; + record->pressure = 0.0f; + record->mass = DEFAULT_SOFT_MASS; + record->damping = DEFAULT_SOFT_DAMPING; + record->ropeRadius = 0.0f; + record->used = false; + } + + + // The particle nearest a world point. + int32_t _nearestSoftVertex(const SoftRecordT *record, Vec3T point) { + int32_t best = -1; + float bestDist = 0.0f; + int32_t x; + + for (x = 0; x < record->count; x++) { + float dx = record->positions[x * 3] - point.x; + float dy = record->positions[x * 3 + 1] - point.y; + float dz = record->positions[x * 3 + 2] - point.z; + float d = dx * dx + dy * dy + dz * dz; + + if ((best < 0) || (d < bestDist)) { + best = x; + bestDist = d; + } + } + return best; + } + + + // (Re)makes the Jolt body from the record's particles, constraints and pins: particles in + // world space with the body itself pinned at the origin, so what Jolt gives back needs no + // transform. Cloth and pressure bodies get Jolt's edge, shear and bend constraints from their + // faces; ropes get an edge per segment. + bool _buildSoft(SoftRecordT *record) { + JPH::BodyInterface &bodies = _world->system->GetBodyInterface(); + int32_t x; + float invMass = (float)record->count / SDL_max(record->mass, 0.001f); + float stretch = STRETCH_COMPLIANCE * (1.0f - SDL_clamp(record->stretch, 0.0f, 1.0f)); + float bend = BEND_COMPLIANCE * (1.0f - SDL_clamp(record->bend, 0.0f, 1.0f)); + + _releaseSoftBody(record); + record->shared = new JPH::SoftBodySharedSettings(); + for (x = 0; x < record->count; x++) { + record->shared->mVertices.push_back(JPH::SoftBodySharedSettings::Vertex(JPH::Float3(record->positions[x * 3], record->positions[x * 3 + 1], record->positions[x * 3 + 2]), JPH::Float3(0.0f, 0.0f, 0.0f), invMass)); + } + for (x = 0; x < record->pinCount; x++) { + record->shared->mVertices[(size_t)record->pins[x].vertex].mInvMass = 0.0f; + } + if (record->kind == SOFT_ROPE) { + for (x = 0; x + 1 < record->count; x++) { + record->shared->mEdgeConstraints.push_back(JPH::SoftBodySharedSettings::Edge((JPH::uint32)x, (JPH::uint32)(x + 1), stretch)); + } + record->shared->CalculateEdgeLengths(); + } else { + const uint32_t *indices; + int32_t indexCount; + int32_t vertexCount; + const float *unused; + JPH::Array attributes; + + meshGetGeometry(record->mesh, &unused, &vertexCount, &indices, &indexCount); + for (x = 0; x + 2 < indexCount; x += 3) { + JPH::SoftBodySharedSettings::Face face; + + face.mVertex[0] = (JPH::uint32)record->meshToSoft[indices[x]]; + face.mVertex[1] = (JPH::uint32)record->meshToSoft[indices[x + 1]]; + face.mVertex[2] = (JPH::uint32)record->meshToSoft[indices[x + 2]]; + if (!face.IsDegenerate()) { + record->shared->AddFace(face); + } + } + attributes.resize((size_t)record->count, JPH::SoftBodySharedSettings::VertexAttributes(stretch, stretch, bend)); + record->shared->CreateConstraints(attributes.data(), (JPH::uint)attributes.size(), JPH::SoftBodySharedSettings::EBendType::Distance); + } + record->shared->Optimize(); + { + JPH::SoftBodyCreationSettings settings(record->shared, JPH::RVec3::sZero(), JPH::Quat::sIdentity(), LAYER_MOVING); + JPH::Body *body; + + settings.mUserData = (JPH::uint64)(uint32_t)record->node; + settings.mUpdatePosition = false; + settings.mPressure = record->pressure; + settings.mLinearDamping = record->damping; + settings.mNumIterations = SOFT_ITERATIONS; + settings.mFacesDoubleSided = (record->kind == SOFT_CLOTH); + settings.mVertexRadius = (record->kind == SOFT_ROPE) ? record->ropeRadius : 0.03f; + settings.mFriction = DEFAULT_FRICTION; + body = bodies.CreateSoftBody(settings); + if (body == nullptr) { + utilTrace("Physics: unable to create a soft body (too many?)."); + return false; + } + record->body = body->GetID(); + bodies.AddBody(record->body, JPH::EActivation::Activate); + } + return true; + } + + + // Pins that follow nodes are dragged to them before every step. + void _pinSoft(void) { + int32_t x; + int32_t p; + + for (x = 0; x < _world->softCount; x++) { + SoftRecordT *record = &_world->softs[x]; + bool any = false; + + if (!record->used || record->body.IsInvalid()) { + continue; + } + for (p = 0; p < record->pinCount; p++) { + any = any || (record->pins[p].follow >= 0); + } + if (!any) { + continue; + } + { + JPH::BodyLockWrite lock(_world->system->GetBodyLockInterface(), record->body); + + if (!lock.Succeeded()) { + continue; + } + JPH::SoftBodyMotionProperties *motion = static_cast(lock.GetBody().GetMotionProperties()); + + for (p = 0; p < record->pinCount; p++) { + if ((record->pins[p].follow >= 0) && nodeValid(record->pins[p].follow)) { + Vec3T where = nodeGetWorldPosition(record->pins[p].follow); + JPH::SoftBodyVertex &vertex = motion->GetVertex((JPH::uint)record->pins[p].vertex); + + vertex.mPosition = JPH::Vec3(where.x, where.y, where.z); + vertex.mVelocity = JPH::Vec3::sZero(); + vertex.mInvMass = 0.0f; + } + } + } + // Outside the lock: activating takes locks of its own. + _world->system->GetBodyInterface().ActivateBody(record->body); + } + } + + + // A tube round a rope's particles: ROPE_SIDES vertices per particle, framed by the segment. + void _ropeMesh(const SoftRecordT *record, Vec3T *out) { + int32_t x; + int32_t side; + + for (x = 0; x < record->count; x++) { + Vec3T here = vec3(record->positions[x * 3], record->positions[x * 3 + 1], record->positions[x * 3 + 2]); + Vec3T next = (x + 1 < record->count) ? vec3(record->positions[(x + 1) * 3], record->positions[(x + 1) * 3 + 1], record->positions[(x + 1) * 3 + 2]) : here; + Vec3T before = (x > 0) ? vec3(record->positions[(x - 1) * 3], record->positions[(x - 1) * 3 + 1], record->positions[(x - 1) * 3 + 2]) : here; + Vec3T tangent = vec3Subtract(next, before); + Vec3T helper; + Vec3T u; + Vec3T v; + + tangent = (vec3Length(tangent) > 1e-6f) ? vec3Normalize(tangent) : vec3(0.0f, 1.0f, 0.0f); + helper = (fabsf(tangent.y) < 0.9f) ? vec3(0.0f, 1.0f, 0.0f) : vec3(1.0f, 0.0f, 0.0f); + u = vec3Normalize(vec3Cross(tangent, helper)); + v = vec3Cross(tangent, u); + for (side = 0; side < ROPE_SIDES; side++) { + float angle = (float)side / (float)ROPE_SIDES * 2.0f * SDL_PI_F; + + out[x * ROPE_SIDES + side] = vec3Add(here, vec3Add(vec3Scale(u, SDL_cosf(angle) * record->ropeRadius), vec3Scale(v, SDL_sinf(angle) * record->ropeRadius))); + } + } + } + + + // After the steps: particles back from Jolt, then the mesh, in the node's own space. + void _writeSoft(void) { + int32_t x; + int32_t v; + + for (x = 0; x < _world->softCount; x++) { + SoftRecordT *record = &_world->softs[x]; + Vec3T position; + QuatT rotation; + Vec3T scale; + QuatT inverse; + + if (!record->used) { + continue; + } + if (!nodeValid(record->node) || (nodeGetGeneration(record->node) != record->generation) || (nodeGetMesh(record->node) != record->mesh)) { + _releaseSoft(record); + continue; + } + if (record->body.IsInvalid()) { + continue; + } + { + JPH::BodyLockRead lock(_world->system->GetBodyLockInterface(), record->body); + + if (!lock.Succeeded()) { + continue; + } + const JPH::SoftBodyMotionProperties *motion = static_cast(lock.GetBody().GetMotionProperties()); + JPH::RVec3 origin = lock.GetBody().GetPosition(); + + for (v = 0; v < record->count; v++) { + JPH::Vec3 p = JPH::Vec3(origin) + motion->GetVertex((JPH::uint)v).mPosition; + + record->positions[v * 3] = p.GetX(); + record->positions[v * 3 + 1] = p.GetY(); + record->positions[v * 3 + 2] = p.GetZ(); + } + } + // World particles to mesh vertices in the node's frame. + nodeGetWorldTransform(record->node, &position, &rotation, &scale); + inverse = quatNormalize(rotation); + inverse.x = -inverse.x; + inverse.y = -inverse.y; + inverse.z = -inverse.z; + if (record->kind == SOFT_ROPE) { + _ropeMesh(record, (Vec3T *)record->meshPositions); + } + for (v = 0; v < record->meshVertexCount; v++) { + Vec3T world = (record->kind == SOFT_ROPE) ? ((Vec3T *)record->meshPositions)[v] : vec3(record->positions[record->meshToSoft[v] * 3], record->positions[record->meshToSoft[v] * 3 + 1], record->positions[record->meshToSoft[v] * 3 + 2]); + Vec3T local = quatRotate(inverse, vec3Subtract(world, position)); + + record->meshPositions[v * 3] = local.x / SDL_max(scale.x, 1e-6f); + record->meshPositions[v * 3 + 1] = local.y / SDL_max(scale.y, 1e-6f); + record->meshPositions[v * 3 + 2] = local.z / SDL_max(scale.z, 1e-6f); + } + meshSetPositions(record->mesh, record->meshPositions); + } + } + + + RagdollRecordT *_findRagdoll(int32_t node) { + int32_t x; + + if (_world == nullptr) { + return nullptr; + } + for (x = 0; x < _world->ragdollCount; x++) { + if (_world->ragdolls[x].used && (_world->ragdolls[x].node == node)) { + return &_world->ragdolls[x]; + } + } + return nullptr; + } + + + // The first node under (or at) node that carries a skin. + int32_t _findSkinned(int32_t node) { + int32_t x; + int32_t found; + + if (nodeGetSkinJoints(node, nullptr) > 0) { + return node; + } + for (x = 0; x < nodeGetChildCount(node); x++) { + found = _findSkinned(nodeGetChild(node, x)); + if (found >= 0) { + return found; + } + } + return -1; + } + + + int32_t _ragdollPartOf(RagdollRecordT *record, int32_t joint) { + int32_t x; + + for (x = 0; x < record->partCount; x++) { + if (record->parts[x].joint == joint) { + return x; + } + } + return -1; + } + + + void _releaseRagdollBodies(RagdollRecordT *record) { + JPH::BodyInterface &bodies = _world->system->GetBodyInterface(); + int32_t x; + + for (x = 0; x < record->partCount; x++) { + RagdollPartT *part = &record->parts[x]; + + if (part->constraint != nullptr) { + _world->system->RemoveConstraint(part->constraint); + part->constraint = nullptr; + } + } + for (x = 0; x < record->partCount; x++) { + RagdollPartT *part = &record->parts[x]; + + if (!part->body.IsInvalid()) { + bodies.RemoveBody(part->body); + bodies.DestroyBody(part->body); + part->body = JPH::BodyID(); + } + } + record->active = false; + } + + + void _releaseRagdoll(RagdollRecordT *record) { + _releaseRagdollBodies(record); + _resetRagdoll(record); + } + + + void _resetRagdoll(RagdollRecordT *record) { + int32_t x; + + for (x = 0; x < MAX_RAGDOLL_PARTS; x++) { + record->parts[x].constraint = nullptr; + record->parts[x].body = JPH::BodyID(); + } + record->filter = nullptr; + record->node = 0; + record->generation = 0; + record->skinned = -1; + record->partCount = 0; + record->strength = 0.0f; + record->strengthChanged = false; + record->active = false; + record->used = false; + } + + + // Motors toward the pose the ragdoll had when it was activated, as strong as asked. + void _steerRagdolls(void) { + int32_t x; + int32_t p; + + for (x = 0; x < _world->ragdollCount; x++) { + RagdollRecordT *record = &_world->ragdolls[x]; + + if (!record->used || !record->active || !record->strengthChanged) { + continue; + } + record->strengthChanged = false; + for (p = 0; p < record->partCount; p++) { + RagdollPartT *part = &record->parts[p]; + JPH::SwingTwistConstraint *constraint; + + if (part->constraint == nullptr) { + continue; + } + constraint = static_cast(part->constraint.GetPtr()); + if (record->strength > 0.0f) { + constraint->GetSwingMotorSettings().mMaxTorqueLimit = record->strength; + constraint->GetSwingMotorSettings().mMinTorqueLimit = -record->strength; + constraint->GetTwistMotorSettings().mMaxTorqueLimit = record->strength; + constraint->GetTwistMotorSettings().mMinTorqueLimit = -record->strength; + constraint->SetTargetOrientationBS(part->rest); + constraint->SetSwingMotorState(JPH::EMotorState::Position); + constraint->SetTwistMotorState(JPH::EMotorState::Position); + } else { + constraint->SetSwingMotorState(JPH::EMotorState::Off); + constraint->SetTwistMotorState(JPH::EMotorState::Off); + } + } + } + } + + + // The bones' bodies drive their joint nodes: world transforms from the bodies, then locals + // against each joint's parent, parts first so children read their parents' new poses. + void _writeRagdolls(void) { + JPH::BodyInterface &bodies = _world->system->GetBodyInterface(); + int32_t x; + int32_t p; + + for (x = 0; x < _world->ragdollCount; x++) { + RagdollRecordT *record = &_world->ragdolls[x]; + JPH::RVec3 worldPosition[MAX_RAGDOLL_PARTS]; + JPH::Quat worldRotation[MAX_RAGDOLL_PARTS]; + + if (!record->used || !record->active) { + continue; + } + if (!nodeValid(record->node) || (nodeGetGeneration(record->node) != record->generation)) { + _releaseRagdoll(record); + continue; + } + for (p = 0; p < record->partCount; p++) { + RagdollPartT *part = &record->parts[p]; + JPH::RVec3 position; + JPH::Quat rotation; + + bodies.GetPositionAndRotation(part->body, position, rotation); + worldPosition[p] = position + rotation * part->offsetPosition; + worldRotation[p] = rotation * part->offsetRotation; + } + for (p = 0; p < record->partCount; p++) { + RagdollPartT *part = &record->parts[p]; + int32_t parentNode = nodeGetParent(part->joint); + int32_t parentPart = (parentNode >= 0) ? _ragdollPartOf(record, parentNode) : -1; + JPH::RVec3 parentPosition; + JPH::Quat parentRotation; + JPH::Vec3 parentScale; + JPH::Vec3 local; + JPH::Quat localRotation; + + if (parentPart >= 0) { + parentPosition = worldPosition[parentPart]; + parentRotation = worldRotation[parentPart]; + parentScale = record->parts[parentPart].scale; + } else if (parentNode >= 0) { + Vec3T position; + QuatT rotation; + Vec3T scale; + + nodeGetWorldTransform(parentNode, &position, &rotation, &scale); + parentPosition = JPH::RVec3(position.x, position.y, position.z); + parentRotation = _fromQuat(rotation); + parentScale = JPH::Vec3(scale.x, scale.y, scale.z); + } else { + parentPosition = JPH::RVec3::sZero(); + parentRotation = JPH::Quat::sIdentity(); + parentScale = JPH::Vec3::sOne(); + } + local = parentRotation.Conjugated() * JPH::Vec3(worldPosition[p] - parentPosition); + local = JPH::Vec3(local.GetX() / SDL_max(parentScale.GetX(), 1e-6f), local.GetY() / SDL_max(parentScale.GetY(), 1e-6f), local.GetZ() / SDL_max(parentScale.GetZ(), 1e-6f)); + localRotation = parentRotation.Conjugated() * worldRotation[p]; + nodeSetPosition(part->joint, vec3(local.GetX(), local.GetY(), local.GetZ())); + nodeSetRotation(part->joint, _toQuat(localRotation)); + } + } + } + + + VehicleRecordT *_findVehicle(int32_t node) { + int32_t x; + + if (_world == nullptr) { + return nullptr; + } + for (x = 0; x < _world->vehicleCount; x++) { + if (_world->vehicles[x].used && (_world->vehicles[x].node == node)) { + return &_world->vehicles[x]; + } + } + return nullptr; + } + + + void _releaseVehicle(VehicleRecordT *record) { + if (record->constraint != nullptr) { + _world->system->RemoveStepListener(record->constraint); + _world->system->RemoveConstraint(record->constraint); + } + record->constraint = nullptr; + record->tester = nullptr; + _resetVehicle(record); + } + + + void _resetVehicle(VehicleRecordT *record) { + record->node = 0; + record->generation = 0; + record->kind = VEHICLE_CAR; + record->wheelCount = 0; + record->maxTorque = DEFAULT_ENGINE_TORQUE; + record->maxRpm = DEFAULT_ENGINE_MAX_RPM; + record->minRpm = DEFAULT_ENGINE_MIN_RPM; + record->gearCount = 0; + record->reverseGear = 0.0f; + record->automatic = true; + record->suspensionHz = DEFAULT_SUSPENSION_HZ; + record->suspensionDamping = DEFAULT_SUSPENSION_DAMPING; + record->maxSteer = DEFAULT_STEER_DEGREES; + record->brakeTorque = DEFAULT_BRAKE_TORQUE; + record->handBrakeTorque = DEFAULT_HANDBRAKE_TORQUE; + record->antiRoll = 0.0f; + record->inputForward = 0.0f; + record->inputRight = 0.0f; + record->inputBrake = 0.0f; + record->inputHandBrake = 0.0f; + record->thrust = 2000.0f; + record->thrustPoint = vec3(0.0f, -0.2f, 1.0f); + record->rudder = 800.0f; + record->dirty = false; + record->used = false; + } + + + // Buoyancy, drag and current for every dynamic body inside a water volume, and the surfaces + // players and boats test against this step. A volume's surface is the top of its box. + void _applyWater(float dt) { + JPH::BodyInterface &bodies = _world->system->GetBodyInterface(); + JPH::Vec3 gravity = _world->system->GetGravity(); + int32_t x; + size_t h; + + _world->waterCount = 0; + for (x = 0; x < _world->bodyCount; x++) { + BodyRecordT *record = &_world->bodies[x]; + WaterSurfaceT surface; + JPH::AllHitCollisionCollector hits; + + if (!record->used || !record->water || !record->enabled) { + continue; + } + { + JPH::TransformedShape shape = bodies.GetTransformedShape(record->id); + JPH::AABox local = bodies.GetShape(record->id)->GetLocalBounds(); + JPH::Quat rotation = bodies.GetRotation(record->id); + + surface.box = shape.GetWorldSpaceBounds(); + surface.normal = rotation * JPH::Vec3::sAxisY(); + surface.position = bodies.GetPosition(record->id) + rotation * JPH::Vec3(0.0f, local.mMax.GetY(), 0.0f); + surface.current = JPH::Vec3(record->current.x, record->current.y, record->current.z); + } + if (_world->waterCount < MAX_WATERS) { + _world->waters[_world->waterCount++] = surface; + } + _world->system->GetBroadPhaseQuery().CollideAABox(surface.box, hits, _world->system->GetDefaultBroadPhaseLayerFilter(LAYER_MOVING), _world->system->GetDefaultLayerFilter(LAYER_MOVING)); + for (h = 0; h < hits.mHits.size(); h++) { + JPH::BodyID id = hits.mHits[h]; + BodyRecordT *other; + + if ((id == record->id) || (bodies.GetMotionType(id) != JPH::EMotionType::Dynamic) || _isTrigger(id)) { + continue; + } + other = _find((int32_t)(uint32_t)bodies.GetUserData(id)); + bodies.ApplyBuoyancyImpulse(id, surface.position, surface.normal, record->waterDensity * ((other != nullptr) ? other->buoyancy : DEFAULT_BUOYANCY), record->waterLinearDrag, record->waterAngularDrag, surface.current, gravity, dt); + } + } + } + + + // Whether a world point is below a water surface this step; hands back the water's current. + bool _underWater(JPH::RVec3Arg point, JPH::Vec3 *current) { + int32_t x; + + for (x = 0; x < _world->waterCount; x++) { + WaterSurfaceT *water = &_world->waters[x]; + + if (water->box.Contains(JPH::Vec3(point)) && (JPH::Vec3(point - water->position).Dot(water->normal) < 0.0f)) { + if (current != nullptr) { + *current = water->current; + } + return true; + } + } + return false; + } + + + // Builds (or rebuilds) the Jolt constraint from the recipe. The chassis faces -Z with Y up, like + // everything else in the scene; wheels sit where their nodes are relative to the chassis. + bool _buildVehicle(VehicleRecordT *record) { + BodyRecordT *body = _find(record->node); + JPH::VehicleConstraintSettings settings; + JPH::Vec3 forward = JPH::Vec3(0.0f, 0.0f, -1.0f); + JPH::Vec3 up = JPH::Vec3::sAxisY(); + JPH::Vec3 left = up.Cross(forward); + JPH::Vec3 local[MAX_WHEELS]; + float along[MAX_WHEELS]; + float meanAlong = 0.0f; + float rollInertia = LEAN_INERTIA; + Vec3T chassisPosition; + QuatT chassisRotation; + Vec3T chassisScale; + QuatT inverse; + int32_t w; + int32_t v; + + if ((body == nullptr) || (body->type != BODY_DYNAMIC)) { + utilTrace("Physics: vehicle node %d needs a dynamic body as its chassis.", record->node); + return false; + } + if (record->wheelCount < 2) { + utilTrace("Physics: vehicle node %d needs at least two wheels.", record->node); + return false; + } + if (record->constraint != nullptr) { + _world->system->RemoveStepListener(record->constraint); + _world->system->RemoveConstraint(record->constraint); + record->constraint = nullptr; + } + // The chassis' inertia about its nose, for the motorcycle lean spring. + { + JPH::BodyLockRead lock(_world->system->GetBodyLockInterface(), body->id); + + if (lock.Succeeded() && (lock.GetBody().GetMotionProperties() != nullptr)) { + float inverse = forward.Dot(lock.GetBody().GetMotionProperties()->GetLocalSpaceInverseInertia().Multiply3x3(forward)); + + if (inverse > 1e-9f) { + rollInertia = 1.0f / inverse; + } + } + } + // Wheel attachment points in the chassis' frame. + sceneUpdateTransforms(); + nodeGetWorldTransform(record->node, &chassisPosition, &chassisRotation, &chassisScale); + inverse = quatNormalize(chassisRotation); + inverse.x = -inverse.x; + inverse.y = -inverse.y; + inverse.z = -inverse.z; + for (w = 0; w < record->wheelCount; w++) { + Vec3T world = nodeValid(record->wheels[w].node) ? nodeGetWorldPosition(record->wheels[w].node) : chassisPosition; + Vec3T rel = quatRotate(inverse, vec3Subtract(world, chassisPosition)); + + local[w] = JPH::Vec3(rel.x, rel.y, rel.z); + along[w] = local[w].Dot(forward); + meanAlong += along[w] / (float)record->wheelCount; + } + settings.mUp = up; + settings.mForward = forward; + settings.mMaxPitchRollAngle = (record->kind == VEHICLE_MOTORCYCLE) ? JPH::DegreesToRadians(VEHICLE_MAX_TILT_DEGREES) : JPH::JPH_PI; + for (w = 0; w < record->wheelCount; w++) { + WheelRecordT *wheel = &record->wheels[w]; + bool front = along[w] > meanAlong + 1e-4f; + bool steered = wheel->steeredSet ? wheel->steered : front; + bool driven = wheel->steeredSet ? wheel->driven : (record->kind != VEHICLE_MOTORCYCLE || !front); + JPH::WheelSettings *base; + + if (record->kind == VEHICLE_TANK) { + JPH::WheelSettingsTV *tv = new JPH::WheelSettingsTV(); + + base = tv; + } else { + JPH::WheelSettingsWV *wv = new JPH::WheelSettingsWV(); + + wv->mMaxSteerAngle = steered ? JPH::DegreesToRadians(record->maxSteer) : 0.0f; + wv->mMaxBrakeTorque = record->brakeTorque; + wv->mMaxHandBrakeTorque = front ? 0.0f : record->handBrakeTorque; + base = wv; + } + base->mPosition = local[w]; + base->mSuspensionDirection = -up; + base->mSteeringAxis = up; + base->mWheelUp = up; + base->mWheelForward = forward; + base->mSuspensionMinLength = wheel->suspension * 0.5f; + base->mSuspensionMaxLength = wheel->suspension; + base->mSuspensionSpring = JPH::SpringSettings(JPH::ESpringMode::FrequencyAndDamping, record->suspensionHz, record->suspensionDamping); + base->mRadius = wheel->radius; + base->mWidth = wheel->width; + wheel->steered = steered; + wheel->driven = driven; + settings.mWheels.push_back(base); + } + if (record->kind == VEHICLE_TANK) { + JPH::TrackedVehicleControllerSettings *controller = new JPH::TrackedVehicleControllerSettings(); + int32_t sides[2] = { 0, 0 }; + + controller->mEngine.mMaxTorque = record->maxTorque; + controller->mEngine.mMaxRPM = record->maxRpm; + controller->mEngine.mMinRPM = record->minRpm; + if (record->gearCount > 0) { + controller->mTransmission.mGearRatios.clear(); + for (v = 0; v < record->gearCount; v++) { + controller->mTransmission.mGearRatios.push_back(record->gears[v]); + } + controller->mTransmission.mReverseGearRatios.clear(); + controller->mTransmission.mReverseGearRatios.push_back(record->reverseGear); + } + controller->mTransmission.mMode = record->automatic ? JPH::ETransmissionMode::Auto : JPH::ETransmissionMode::Manual; + for (w = 0; w < record->wheelCount; w++) { + int32_t side = (local[w].Dot(left) > 0.0f) ? (int32_t)JPH::ETrackSide::Left : (int32_t)JPH::ETrackSide::Right; + + if (sides[side] == 0) { + controller->mTracks[side].mDrivenWheel = (JPH::uint)w; + } + controller->mTracks[side].mWheels.push_back((JPH::uint)w); + sides[side]++; + } + if ((sides[0] == 0) || (sides[1] == 0)) { + utilTrace("Physics: a tank needs wheels on both sides of node %d.", record->node); + return false; + } + settings.mController = controller; + } else { + JPH::WheeledVehicleControllerSettings *controller = (record->kind == VEHICLE_MOTORCYCLE) ? new JPH::MotorcycleControllerSettings() : new JPH::WheeledVehicleControllerSettings(); + int32_t paired[MAX_WHEELS]; + int32_t axles = 0; + + if (record->kind == VEHICLE_MOTORCYCLE) { + // Jolt's lean spring is tuned for one bike; a lighter or slimmer one flips with it. + JPH::MotorcycleControllerSettings *bike = static_cast(controller); + + bike->mLeanSpringConstant = LEAN_SPRING * rollInertia / LEAN_INERTIA; + bike->mLeanSpringDamping = LEAN_DAMPING * rollInertia / LEAN_INERTIA; + } + + controller->mEngine.mMaxTorque = record->maxTorque; + controller->mEngine.mMaxRPM = record->maxRpm; + controller->mEngine.mMinRPM = record->minRpm; + if (record->gearCount > 0) { + controller->mTransmission.mGearRatios.clear(); + for (v = 0; v < record->gearCount; v++) { + controller->mTransmission.mGearRatios.push_back(record->gears[v]); + } + controller->mTransmission.mReverseGearRatios.clear(); + controller->mTransmission.mReverseGearRatios.push_back(record->reverseGear); + } + controller->mTransmission.mMode = record->automatic ? JPH::ETransmissionMode::Auto : JPH::ETransmissionMode::Manual; + // Driven wheels pair up across the chassis into differentials, one per axle; a lone wheel + // (a motorcycle's) is an axle of its own. Anti-roll bars follow the same pairs. + for (w = 0; w < record->wheelCount; w++) { + paired[w] = 0; + } + for (w = 0; w < record->wheelCount; w++) { + JPH::VehicleDifferentialSettings differential; + int32_t partner = -1; + + if (paired[w] || !record->wheels[w].driven) { + continue; + } + for (v = w + 1; v < record->wheelCount; v++) { + if (!paired[v] && record->wheels[v].driven && (fabsf(along[v] - along[w]) < record->wheels[w].radius) && ((local[v].Dot(left) > 0.0f) != (local[w].Dot(left) > 0.0f))) { + partner = v; + break; + } + } + paired[w] = 1; + if (local[w].Dot(left) > 0.0f) { + differential.mLeftWheel = w; + differential.mRightWheel = partner; + } else { + differential.mLeftWheel = partner; + differential.mRightWheel = w; + } + if (partner >= 0) { + paired[partner] = 1; + if (record->antiRoll > 0.0f) { + JPH::VehicleAntiRollBar bar; + + bar.mLeftWheel = differential.mLeftWheel; + bar.mRightWheel = differential.mRightWheel; + bar.mStiffness = record->antiRoll; + settings.mAntiRollBars.push_back(bar); + } + } + controller->mDifferentials.push_back(differential); + axles++; + } + for (v = 0; v < (int32_t)controller->mDifferentials.size(); v++) { + controller->mDifferentials[(size_t)v].mEngineTorqueRatio = 1.0f / (float)SDL_max(axles, 1); + } + if (axles == 0) { + utilTrace("Physics: vehicle node %d has no driven wheel.", record->node); + return false; + } + settings.mController = controller; + } + { + JPH::BodyLockWrite lock(_world->system->GetBodyLockInterface(), body->id); + + if (!lock.Succeeded()) { + return false; + } + record->constraint = new JPH::VehicleConstraint(lock.GetBody(), settings); + } + // Wheels feel the ground with a cylinder cast (kerbs and edges); tracks with a ray, which + // still finds the ground when a heavy hull has come down on its belly. + if (record->kind == VEHICLE_TANK) { + record->tester = new JPH::VehicleCollisionTesterRay(LAYER_MOVING, up); + } else { + record->tester = new JPH::VehicleCollisionTesterCastCylinder(LAYER_MOVING, 0.05f); + } + record->constraint->SetVehicleCollisionTester(record->tester); + _world->system->AddConstraint(record->constraint); + _world->system->AddStepListener(record->constraint); + record->dirty = false; + return true; + } + + + // Hands each vehicle its driver input for the coming steps, rebuilding stale ones first. + void _driveVehicles(void) { + JPH::BodyInterface &bodies = _world->system->GetBodyInterface(); + int32_t x; + + for (x = 0; x < _world->vehicleCount; x++) { + VehicleRecordT *record = &_world->vehicles[x]; + BodyRecordT *body; + + if (!record->used) { + continue; + } + body = _find(record->node); + if ((body == nullptr) || !nodeValid(record->node) || (nodeGetGeneration(record->node) != record->generation)) { + _releaseVehicle(record); + continue; + } + if (record->kind == VEHICLE_BOAT) { + JPH::Quat rotation = bodies.GetRotation(body->id); + JPH::RVec3 point = bodies.GetPosition(body->id) + rotation * JPH::Vec3(record->thrustPoint.x, record->thrustPoint.y, record->thrustPoint.z); + JPH::Vec3 forward = rotation * JPH::Vec3(0.0f, 0.0f, -1.0f); + JPH::Vec3 up = rotation * JPH::Vec3::sAxisY(); + float speed = bodies.GetLinearVelocity(body->id).Dot(forward); + + record->dirty = false; + if (_underWater(point, nullptr)) { + if (record->inputForward != 0.0f) { + bodies.AddForce(body->id, forward * (record->thrust * record->inputForward), point); + } + if (record->inputRight != 0.0f) { + // The rudder bites with speed. + bodies.AddTorque(body->id, up * (-record->inputRight * record->rudder * SDL_clamp(fabsf(speed) / 3.0f, 0.2f, 1.0f) * ((speed < 0.0f) ? -1.0f : 1.0f))); + } + if (record->inputBrake > 0.0f) { + bodies.AddForce(body->id, -bodies.GetLinearVelocity(body->id) * (record->thrust * record->inputBrake * 0.5f)); + } + } + continue; + } + if (record->dirty && !_buildVehicle(record)) { + continue; + } + if (record->constraint == nullptr) { + continue; + } + if (record->kind == VEHICLE_TANK) { + JPH::TrackedVehicleController *controller = static_cast(record->constraint->GetController()); + float forward = record->inputForward; + float leftRatio = 1.0f; + float rightRatio = 1.0f; + + if ((forward == 0.0f) && (record->inputRight != 0.0f)) { + // Turning on the spot: the tracks run against each other, gently, or a heavy + // hull hops off the ground. + forward = fabsf(record->inputRight) * 0.35f; + leftRatio = (record->inputRight > 0.0f) ? 1.0f : -1.0f; + rightRatio = -leftRatio; + } else if (record->inputRight > 0.0f) { + rightRatio = SDL_max(1.0f - record->inputRight * 0.9f, 0.1f); + } else if (record->inputRight < 0.0f) { + leftRatio = SDL_max(1.0f + record->inputRight * 0.9f, 0.1f); + } + controller->SetDriverInput(forward, leftRatio, rightRatio, record->inputBrake); + } else { + JPH::WheeledVehicleController *controller = static_cast(record->constraint->GetController()); + + controller->SetDriverInput(record->inputForward, record->inputRight, record->inputBrake, record->inputHandBrake); + } + if ((record->inputForward != 0.0f) || (record->inputRight != 0.0f) || (record->inputBrake != 0.0f)) { + bodies.ActivateBody(body->id); + } + } + } + + + // Wheel nodes take the wheel transforms after the step: X is the axle, Y up. + void _poseWheels(void) { + int32_t x; + int32_t w; + + for (x = 0; x < _world->vehicleCount; x++) { + VehicleRecordT *record = &_world->vehicles[x]; + + if (!record->used || (record->constraint == nullptr)) { + continue; + } + for (w = 0; w < record->wheelCount; w++) { + JPH::RMat44 transform; + JPH::Quat rotation; + JPH::RVec3 position; + + if (!nodeValid(record->wheels[w].node)) { + continue; + } + transform = record->constraint->GetWheelWorldTransform((JPH::uint)w, JPH::Vec3::sAxisX(), JPH::Vec3::sAxisY()); + position = transform.GetTranslation(); + rotation = transform.GetQuaternion(); + nodeSetWorldTransform(record->wheels[w].node, vec3((float)position.GetX(), (float)position.GetY(), (float)position.GetZ()), _toQuat(rotation)); + } + } + } + + + // Which triggers a player overlaps, against the last frame's answer: onTrigger enter and leave. + void _playerTriggers(PlayerRecordT *record) { + JPH::AllHitCollisionCollector hits; + JPH::AABox box = record->character->GetShape()->GetWorldSpaceBounds(record->character->GetCenterOfMassTransform(), JPH::Vec3::sOne()); + int32_t now[MAX_PLAYER_TRIGGERS]; + int32_t count = 0; + int32_t x; + int32_t y; + size_t h; + + _world->system->GetBroadPhaseQuery().CollideAABox(box, hits, _world->system->GetDefaultBroadPhaseLayerFilter(LAYER_MOVING), _world->system->GetDefaultLayerFilter(LAYER_MOVING)); + for (h = 0; (h < hits.mHits.size()) && (count < MAX_PLAYER_TRIGGERS); h++) { + int32_t node; + bool seen = false; + + if (!_isTrigger(hits.mHits[h])) { + continue; + } + // The broad phase may name a body more than once. + node = (int32_t)(uint32_t)_world->system->GetBodyInterface().GetUserData(hits.mHits[h]); + for (x = 0; x < count; x++) { + seen = seen || (now[x] == node); + } + if (!seen) { + now[count++] = node; + } + } + for (x = 0; x < count; x++) { + bool had = false; + + for (y = 0; y < record->insideCount; y++) { + had = had || (record->inside[y] == now[x]); + } + if (!had) { + PhysicsEventT event; + + event.type = PHYSICS_EVENT_ENTER; + event.nodeA = now[x]; + event.nodeB = record->node; + event.point = vec3(0.0f, 0.0f, 0.0f); + event.speed = 0.0f; + std::lock_guard guard(_world->contacts->lock); + _world->contacts->events.push_back(event); + } + } + for (y = 0; y < record->insideCount; y++) { + bool still = false; + + for (x = 0; x < count; x++) { + still = still || (now[x] == record->inside[y]); + } + if (!still) { + PhysicsEventT event; + + event.type = PHYSICS_EVENT_LEAVE; + event.nodeA = record->inside[y]; + event.nodeB = record->node; + event.point = vec3(0.0f, 0.0f, 0.0f); + event.speed = 0.0f; + std::lock_guard guard(_world->contacts->lock); + _world->contacts->events.push_back(event); + } + } + memcpy(record->inside, now, sizeof(int32_t) * (size_t)count); + record->insideCount = count; + } + + + // Players drive their nodes' positions; the script owns the rotation. + void _writePlayers(void) { + int32_t x; + Vec3T position; + QuatT rotation; + Vec3T scale; + + for (x = 0; x < _world->playerCount; x++) { + PlayerRecordT *record = &_world->players[x]; + JPH::RVec3 where; + + if (!record->used || !record->enabled || !nodeValid(record->node)) { + continue; + } + _playerTriggers(record); + where = record->character->GetPosition(); + nodeGetWorldTransform(record->node, &position, &rotation, &scale); + nodeSetWorldTransform(record->node, vec3((float)where.GetX(), (float)where.GetY(), (float)where.GetZ()), rotation); + record->intent = vec3(0.0f, 0.0f, 0.0f); + } + } + + QuatT _toQuat(JPH::Quat q) { QuatT out = { q.GetX(), q.GetY(), q.GetZ(), q.GetW() }; @@ -505,47 +1958,18 @@ bool bodyNew(int32_t node, BodyTypeE type, ShapeTypeE shape, float a, float b, f Vec3T position; QuatT rotation; Vec3T scale; - float radius; - float height; int32_t x; if ((_world == nullptr) || !nodeValid(node)) { return false; } bodyDelete(node); + playerDelete(node); sceneUpdateTransforms(); nodeGetWorldTransform(node, &position, &rotation, &scale); - switch (shape) { - case SHAPE_BOX: - joltShape = new JPH::BoxShape(JPH::Vec3(SDL_max(a * scale.x, MIN_DIMENSION) / 2.0f, SDL_max(b * scale.y, MIN_DIMENSION) / 2.0f, SDL_max(c * scale.z, MIN_DIMENSION) / 2.0f), 0.0f); - break; - case SHAPE_SPHERE: - joltShape = new JPH::SphereShape(SDL_max(a * SDL_max(scale.x, SDL_max(scale.y, scale.z)), MIN_DIMENSION)); - break; - case SHAPE_CAPSULE: - radius = SDL_max(a * SDL_max(scale.x, scale.z), MIN_DIMENSION); - height = SDL_max(b * scale.y, MIN_DIMENSION); - joltShape = new JPH::CapsuleShape(SDL_max(height / 2.0f - radius, MIN_DIMENSION), radius); - break; - case SHAPE_CYLINDER: - radius = SDL_max(a * SDL_max(scale.x, scale.z), MIN_DIMENSION); - 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: unknown shape %d.", (int32_t)shape); - return false; + joltShape = _buildShape(node, type, shape, a, b, c, position, rotation, scale); + if (joltShape == nullptr) { + return false; } for (x = 0; x < _world->bodyCount; x++) { if (!_world->bodies[x].used) { @@ -584,6 +2008,7 @@ bool bodyNew(int32_t node, BodyTypeE type, ShapeTypeE shape, float a, float b, f record->type = type; record->enabled = true; record->used = true; + record->buoyancy = DEFAULT_BUOYANCY; return true; } @@ -846,6 +2271,7 @@ int32_t physicsGetEvents(PhysicsEventT *events, int32_t maximum) { // level the library was compiled for, rather than faulting on the first instruction. bool physicsInit(void) { int32_t threads; + int32_t x; if (_world != nullptr) { return true; @@ -868,6 +2294,31 @@ bool physicsInit(void) { _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->playerListener = new PlayerListenerT(); + _world->players = new PlayerRecordT[MAX_PLAYERS]; + _world->playerCount = MAX_PLAYERS; + for (x = 0; x < MAX_PLAYERS; x++) { + _releasePlayer(&_world->players[x]); + } + _world->vehicles = new VehicleRecordT[MAX_VEHICLES]; + _world->vehicleCount = MAX_VEHICLES; + for (x = 0; x < MAX_VEHICLES; x++) { + _resetVehicle(&_world->vehicles[x]); + } + _world->softs = new SoftRecordT[MAX_SOFT]; + _world->softCount = MAX_SOFT; + for (x = 0; x < MAX_SOFT; x++) { + _world->softs[x].meshToSoft = nullptr; + _world->softs[x].positions = nullptr; + _world->softs[x].meshPositions = nullptr; + _resetSoft(&_world->softs[x]); + } + _world->ragdolls = new RagdollRecordT[MAX_RAGDOLLS]; + _world->ragdollCount = MAX_RAGDOLLS; + _world->ragdollGroups = 1; + for (x = 0; x < MAX_RAGDOLLS; x++) { + _resetRagdoll(&_world->ragdolls[x]); + } _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; @@ -887,6 +2338,29 @@ void physicsQuit(void) { } SDL_free(_world->bodies); delete[] _world->joints; + for (x = 0; x < _world->softCount; x++) { + if (_world->softs[x].used) { + _releaseSoft(&_world->softs[x]); + } + } + delete[] _world->softs; + for (x = 0; x < _world->ragdollCount; x++) { + if (_world->ragdolls[x].used) { + _releaseRagdoll(&_world->ragdolls[x]); + } + } + delete[] _world->ragdolls; + for (x = 0; x < _world->vehicleCount; x++) { + if (_world->vehicles[x].used) { + _releaseVehicle(&_world->vehicles[x]); + } + } + delete[] _world->vehicles; + for (x = 0; x < _world->playerCount; x++) { + _releasePlayer(&_world->players[x]); + } + delete[] _world->players; + delete _world->playerListener; delete _world->system; delete _world->contacts; delete _world->jobs; @@ -1004,10 +2478,19 @@ void physicsUpdate(bool advance) { bodies.MoveKinematic(record->id, JPH::RVec3(position.x, position.y, position.z), _fromQuat(rotation), dt); } while ((_world->accumulator >= STEP_SECONDS) && (steps < MAX_STEPS_PER_FRAME)) { + _applyWater((float)STEP_SECONDS); + _driveVehicles(); + _steerRagdolls(); + _pinSoft(); + _stepPlayers((float)STEP_SECONDS); _world->system->Update((float)STEP_SECONDS, 1, _world->tempAllocator, _world->jobs); _world->accumulator -= STEP_SECONDS; steps++; } + _writePlayers(); + _poseWheels(); + _writeRagdolls(); + _writeSoft(); // Dynamic bodies drive their nodes. for (x = 0; x < _world->bodyCount; x++) { BodyRecordT *record = &_world->bodies[x]; @@ -1026,3 +2509,1222 @@ void physicsUpdate(bool advance) { } } } + + +bool playerDelete(int32_t node) { + PlayerRecordT *record = _findPlayer(node); + + if (record == nullptr) { + return false; + } + _releasePlayer(record); + return true; +} + + +bool playerExists(int32_t node) { + return _findPlayer(node) != nullptr; +} + + +// The node stood on, or -1, and the ground normal. +int32_t playerGetGround(int32_t node, Vec3T *normal) { + PlayerRecordT *record = _findPlayer(node); + JPH::Vec3 n; + + if ((record == nullptr) || !record->character->IsSupported()) { + if (normal != nullptr) { + *normal = vec3(0.0f, 1.0f, 0.0f); + } + return -1; + } + n = record->character->GetGroundNormal(); + if (normal != nullptr) { + *normal = vec3(n.GetX(), n.GetY(), n.GetZ()); + } + if (record->character->GetGroundBodyID().IsInvalid()) { + return -1; + } + return (int32_t)(uint32_t)_world->system->GetBodyInterface().GetUserData(record->character->GetGroundBodyID()); +} + + +Vec3T playerGetVelocity(int32_t node) { + PlayerRecordT *record = _findPlayer(node); + JPH::Vec3 v; + + if (record == nullptr) { + return vec3(0.0f, 0.0f, 0.0f); + } + v = record->character->GetLinearVelocity(); + return vec3(v.GetX(), v.GetY(), v.GetZ()); +} + + +bool playerIsOnGround(int32_t node) { + PlayerRecordT *record = _findPlayer(node); + + return (record != nullptr) && (record->character->GetGroundState() == JPH::CharacterBase::EGroundState::OnGround); +} + + +// Asks for a jump at the next step; only granted with ground underfoot. +bool playerJump(int32_t node, float speed) { + PlayerRecordT *record = _findPlayer(node); + + if ((record == nullptr) || (record->character->GetGroundState() != JPH::CharacterBase::EGroundState::OnGround)) { + return false; + } + record->jumpSpeed = SDL_max(0.0f, speed); + return true; +} + + +bool playerMove(int32_t node, Vec3T velocity) { + PlayerRecordT *record = _findPlayer(node); + + if (record == nullptr) { + return false; + } + record->intent = velocity; + return true; +} + + +// A character on the node, shaped like a body but standing on the node's origin. +bool playerNew(int32_t node, ShapeTypeE shape, float a, float b, float c) { + PlayerRecordT *record = nullptr; + JPH::RefConst joltShape; + JPH::RefConst standing; + JPH::CharacterVirtualSettings settings; + JPH::AABox bounds; + Vec3T position; + QuatT rotation; + Vec3T scale; + JPH::Vec3 up; + float lift; + float extent; + int32_t x; + + if ((_world == nullptr) || !nodeValid(node)) { + return false; + } + if (shape == SHAPE_MESH) { + utilTrace("Physics: a player needs a convex shape; use a hull for node %d.", node); + return false; + } + bodyDelete(node); + playerDelete(node); + sceneUpdateTransforms(); + nodeGetWorldTransform(node, &position, &rotation, &scale); + joltShape = _buildShape(node, BODY_DYNAMIC, shape, a, b, c, position, rotation, scale); + if (joltShape == nullptr) { + return false; + } + for (x = 0; x < _world->playerCount; x++) { + if (!_world->players[x].used) { + record = &_world->players[x]; + break; + } + } + if (record == nullptr) { + utilTrace("Physics: no room for another player (%d already).", MAX_PLAYERS); + return false; + } + // Feet at the origin: lift the shape (along up, which is against gravity) by half its height. + up = _playerUp(); + bounds = joltShape->GetLocalBounds(); + lift = (bounds.mMax.GetY() - bounds.mMin.GetY()) / 2.0f; + standing = JPH::RotatedTranslatedShapeSettings(up * lift, JPH::Quat::sIdentity(), joltShape).Create().Get(); + settings.mShape = standing; + settings.mInnerBodyShape = standing; + settings.mInnerBodyLayer = LAYER_MOVING; + settings.mMaxSlopeAngle = JPH::DegreesToRadians(DEFAULT_SLOPE_DEGREES); + settings.mMaxStrength = DEFAULT_PUSH_STRENGTH; + settings.mBackFaceMode = JPH::EBackFaceMode::CollideWithBackFaces; + settings.mEnhancedInternalEdgeRemoval = true; + settings.mUp = up; + settings.mSupportingVolume = JPH::Plane(up, -SDL_max((bounds.mMax.GetY() - bounds.mMin.GetY()) * 0.25f, MIN_DIMENSION)); + // Jolt's tolerances are tuned for a metre-sized capsule; a pixel-sized 2D player needs them + // scaled up with it, so they follow the shape's smallest half-extent (0.3 m gives the defaults). + extent = SDL_max(MIN_DIMENSION, (bounds.mMax - bounds.mMin).ReduceMin() / 2.0f); + settings.mCharacterPadding = extent * PADDING_PER_EXTENT; + settings.mPredictiveContactDistance = extent * PREDICTIVE_PER_EXTENT; + settings.mCollisionTolerance = extent * TOLERANCE_PER_EXTENT; + record->character = new JPH::CharacterVirtual(&settings, JPH::RVec3(position.x, position.y, position.z), JPH::Quat::sIdentity(), (JPH::uint64)(uint32_t)node, _world->system); + record->character->SetListener(_world->playerListener); + record->node = node; + record->generation = nodeGetGeneration(node); + record->extent = extent; + record->height = bounds.mMax.GetY() - bounds.mMin.GetY(); + record->intent = vec3(0.0f, 0.0f, 0.0f); + record->jumpSpeed = 0.0f; + record->gravityScale = 1.0f; + record->stepHeight = DEFAULT_STEP_HEIGHT * extent / DEFAULT_EXTENT; + record->enabled = true; + record->used = true; + return true; +} + + +bool playerSetEnabled(int32_t node, bool enabled) { + PlayerRecordT *record = _findPlayer(node); + + if (record == nullptr) { + return false; + } + record->enabled = enabled; + return true; +} + + +bool playerSetGravityScale(int32_t node, float scale) { + PlayerRecordT *record = _findPlayer(node); + + if (record == nullptr) { + return false; + } + record->gravityScale = scale; + return true; +} + + +bool playerSetMass(int32_t node, float kilograms) { + PlayerRecordT *record = _findPlayer(node); + + if (record == nullptr) { + return false; + } + record->character->SetMass(SDL_max(kilograms, 0.001f)); + return true; +} + + +// Teleports; nothing in between is touched. +bool playerSetPosition(int32_t node, Vec3T position) { + PlayerRecordT *record = _findPlayer(node); + Vec3T where; + QuatT rotation; + Vec3T scale; + + if (record == nullptr) { + return false; + } + record->character->SetPosition(JPH::RVec3(position.x, position.y, position.z)); + record->character->SetLinearVelocity(JPH::Vec3::sZero()); + nodeGetWorldTransform(node, &where, &rotation, &scale); + nodeSetWorldTransform(node, position, rotation); + return true; +} + + +bool playerSetPush(int32_t node, float strength) { + PlayerRecordT *record = _findPlayer(node); + + if (record == nullptr) { + return false; + } + record->character->SetMaxStrength(SDL_max(0.0f, strength)); + return true; +} + + +bool playerSetSlope(int32_t node, float degrees) { + PlayerRecordT *record = _findPlayer(node); + + if (record == nullptr) { + return false; + } + record->character->SetMaxSlopeAngle(JPH::DegreesToRadians(SDL_clamp(degrees, 0.0f, 89.0f))); + return true; +} + + +bool playerSetStep(int32_t node, float height) { + PlayerRecordT *record = _findPlayer(node); + + if (record == nullptr) { + return false; + } + record->stepHeight = SDL_max(0.0f, height); + return true; +} + + +bool playerSetVelocity(int32_t node, Vec3T velocity) { + PlayerRecordT *record = _findPlayer(node); + + if (record == nullptr) { + return false; + } + record->character->SetLinearVelocity(JPH::Vec3(velocity.x, velocity.y, velocity.z)); + record->intent = vec3(0.0f, 0.0f, 0.0f); + return true; +} + + +// A wheel at the wheel node's position relative to the chassis; returns its index. +int32_t vehicleAddWheel(int32_t node, int32_t wheelNode, float radius, float width, float suspension) { + VehicleRecordT *record = _findVehicle(node); + WheelRecordT *wheel; + + if ((record == nullptr) || !nodeValid(wheelNode) || (record->wheelCount >= MAX_WHEELS)) { + return -1; + } + wheel = &record->wheels[record->wheelCount]; + wheel->node = wheelNode; + wheel->radius = SDL_max(radius, MIN_DIMENSION); + wheel->width = SDL_max(width, MIN_DIMENSION); + wheel->suspension = SDL_max(suspension, MIN_DIMENSION); + wheel->steered = false; + wheel->driven = true; + wheel->steeredSet = false; + record->dirty = true; + return record->wheelCount++; +} + + +bool vehicleDelete(int32_t node) { + VehicleRecordT *record = _findVehicle(node); + + if (record == nullptr) { + return false; + } + _releaseVehicle(record); + return true; +} + + +bool vehicleDrive(int32_t node, float forward, float right, float brake, float handBrake) { + VehicleRecordT *record = _findVehicle(node); + + if (record == nullptr) { + return false; + } + record->inputForward = SDL_clamp(forward, -1.0f, 1.0f); + record->inputRight = SDL_clamp(right, -1.0f, 1.0f); + record->inputBrake = SDL_clamp(brake, 0.0f, 1.0f); + record->inputHandBrake = SDL_clamp(handBrake, 0.0f, 1.0f); + return true; +} + + +bool vehicleExists(int32_t node) { + return _findVehicle(node) != nullptr; +} + + +int32_t vehicleGetGear(int32_t node) { + VehicleRecordT *record = _findVehicle(node); + + if ((record == nullptr) || (record->constraint == nullptr)) { + return 0; + } + if (record->kind == VEHICLE_TANK) { + return static_cast(record->constraint->GetController())->GetTransmission().GetCurrentGear(); + } + return static_cast(record->constraint->GetController())->GetTransmission().GetCurrentGear(); +} + + +float vehicleGetRpm(int32_t node) { + VehicleRecordT *record = _findVehicle(node); + + if ((record == nullptr) || (record->constraint == nullptr)) { + return 0.0f; + } + if (record->kind == VEHICLE_TANK) { + return static_cast(record->constraint->GetController())->GetEngine().GetCurrentRPM(); + } + return static_cast(record->constraint->GetController())->GetEngine().GetCurrentRPM(); +} + + +// Metres a second along the chassis' nose, negative in reverse. +float vehicleGetSpeed(int32_t node) { + VehicleRecordT *record = _findVehicle(node); + BodyRecordT *body; + JPH::Vec3 velocity; + JPH::Quat rotation; + + if ((record == nullptr) || ((body = _find(record->node)) == nullptr)) { + return 0.0f; + } + velocity = _world->system->GetBodyInterface().GetLinearVelocity(body->id); + rotation = _world->system->GetBodyInterface().GetRotation(body->id); + return velocity.Dot(rotation * JPH::Vec3(0.0f, 0.0f, -1.0f)); +} + + +// Longitudinal slip of a wheel, 0 gripping to about 1 spinning or locked. +float vehicleGetWheelSlip(int32_t node, int32_t index) { + VehicleRecordT *record = _findVehicle(node); + + if ((record == nullptr) || (record->constraint == nullptr) || (index < 0) || (index >= record->wheelCount) || (record->kind == VEHICLE_TANK)) { + return 0.0f; + } + return SDL_min(fabsf(static_cast(record->constraint->GetWheel((JPH::uint)index))->mLongitudinalSlip), 1.0f); +} + + +bool vehicleIsWheelOnGround(int32_t node, int32_t index) { + VehicleRecordT *record = _findVehicle(node); + + if ((record == nullptr) || (record->constraint == nullptr) || (index < 0) || (index >= record->wheelCount)) { + return false; + } + return record->constraint->GetWheel((JPH::uint)index)->HasContact(); +} + + +// A vehicle on the node, whose dynamic body is the chassis; add wheels before driving it. +bool vehicleNew(int32_t node, VehicleKindE kind) { + VehicleRecordT *record = nullptr; + BodyRecordT *body = _find(node); + int32_t x; + + if ((_world == nullptr) || (body == nullptr) || (body->type != BODY_DYNAMIC)) { + utilTrace("Physics: a vehicle needs a dynamic body on node %d first.", node); + return false; + } + if (_world->planar && (kind != VEHICLE_BOAT)) { + utilTrace("Physics: vehicles need a 3D world; a 2D car is a body with hinged wheels."); + return false; + } + vehicleDelete(node); + for (x = 0; x < _world->vehicleCount; x++) { + if (!_world->vehicles[x].used) { + record = &_world->vehicles[x]; + break; + } + } + if (record == nullptr) { + utilTrace("Physics: no room for another vehicle (%d already).", MAX_VEHICLES); + return false; + } + _resetVehicle(record); + record->node = node; + record->generation = nodeGetGeneration(node); + record->kind = kind; + record->dirty = (kind != VEHICLE_BOAT); + record->used = true; + return true; +} + + +bool vehicleSetAntiRoll(int32_t node, float stiffness) { + VehicleRecordT *record = _findVehicle(node); + + if (record == nullptr) { + return false; + } + record->antiRoll = SDL_max(0.0f, stiffness); + record->dirty = true; + return true; +} + + +bool vehicleSetBrakes(int32_t node, float brake, float handBrake) { + VehicleRecordT *record = _findVehicle(node); + + if (record == nullptr) { + return false; + } + record->brakeTorque = SDL_max(0.0f, brake); + record->handBrakeTorque = SDL_max(0.0f, handBrake); + record->dirty = true; + return true; +} + + +bool vehicleSetEngine(int32_t node, float maxTorque, float maxRpm, float minRpm) { + VehicleRecordT *record = _findVehicle(node); + + if (record == nullptr) { + return false; + } + record->maxTorque = SDL_max(1.0f, maxTorque); + record->maxRpm = SDL_max(100.0f, maxRpm); + record->minRpm = SDL_clamp(minRpm, 1.0f, record->maxRpm); + record->dirty = true; + return true; +} + + +bool vehicleSetGears(int32_t node, const float *ratios, int32_t count, float reverse, bool automatic) { + VehicleRecordT *record = _findVehicle(node); + int32_t x; + + if ((record == nullptr) || (count < 1) || (count > MAX_GEARS)) { + return false; + } + for (x = 0; x < count; x++) { + record->gears[x] = ratios[x]; + } + record->gearCount = count; + record->reverseGear = (reverse > 0.0f) ? -reverse : reverse; + record->automatic = automatic; + record->dirty = true; + return true; +} + + +bool vehicleSetSteering(int32_t node, float maxDegrees) { + VehicleRecordT *record = _findVehicle(node); + + if (record == nullptr) { + return false; + } + record->maxSteer = SDL_clamp(maxDegrees, 0.0f, 89.0f); + record->dirty = true; + return true; +} + + +bool vehicleSetSuspension(int32_t node, float frequency, float damping) { + VehicleRecordT *record = _findVehicle(node); + + if (record == nullptr) { + return false; + } + record->suspensionHz = SDL_max(0.1f, frequency); + record->suspensionDamping = SDL_max(0.0f, damping); + record->dirty = true; + return true; +} + + +bool vehicleSetWheel(int32_t node, int32_t index, bool steered, bool driven) { + VehicleRecordT *record = _findVehicle(node); + + if ((record == nullptr) || (index < 0) || (index >= record->wheelCount)) { + return false; + } + record->wheels[index].steered = steered; + record->wheels[index].driven = driven; + record->wheels[index].steeredSet = true; + record->dirty = true; + return true; +} + + +bool bodySetBuoyancy(int32_t node, float factor) { + BodyRecordT *record = _find(node); + + if (record == nullptr) { + return false; + } + record->buoyancy = SDL_max(0.0f, factor); + return true; +} + + +bool bodySetCurrent(int32_t node, Vec3T flow) { + BodyRecordT *record = _find(node); + + if (record == nullptr) { + return false; + } + record->current = flow; + return true; +} + + +// Fills a static trigger with water: bodies inside float, sink and drift; players swim. +bool bodySetWater(int32_t node, float density, float linearDrag, float angularDrag) { + BodyRecordT *record = _find(node); + + if ((record == nullptr) || (record->type != BODY_STATIC)) { + utilTrace("Physics: water needs a static body on node %d.", node); + return false; + } + if (!record->trigger) { + bodySetTrigger(node, true); + } + record->water = true; + record->waterDensity = SDL_max(0.0f, density); + record->waterLinearDrag = SDL_max(0.0f, linearDrag); + record->waterAngularDrag = SDL_max(0.0f, angularDrag); + return true; +} + + +bool playerIsSwimming(int32_t node) { + PlayerRecordT *record = _findPlayer(node); + + return (record != nullptr) && record->swimming; +} + + +bool playerSetSwim(int32_t node, float sinkSpeed, float drag) { + PlayerRecordT *record = _findPlayer(node); + + if (record == nullptr) { + return false; + } + record->sinkSpeed = SDL_max(0.0f, sinkSpeed); + record->swimDrag = SDL_max(0.0f, drag); + return true; +} + + +bool vehicleSetRudder(int32_t node, float maxTorque) { + VehicleRecordT *record = _findVehicle(node); + + if (record == nullptr) { + return false; + } + record->rudder = SDL_max(0.0f, maxTorque); + return true; +} + + +bool vehicleSetThrust(int32_t node, float maxForce, Vec3T point) { + VehicleRecordT *record = _findVehicle(node); + + if (record == nullptr) { + return false; + } + record->thrust = SDL_max(0.0f, maxForce); + record->thrustPoint = point; + return true; +} + + +// Physics takes over the skeleton from where the animation left it. +bool ragdollActivate(int32_t node) { + RagdollRecordT *record = _findRagdoll(node); + JPH::BodyInterface &bodies = _world->system->GetBodyInterface(); + int32_t p; + + if (record == nullptr) { + return false; + } + if (record->active) { + return true; + } + animationStop(record->node); + sceneUpdateTransforms(); + record->filter = new JPH::GroupFilterTable((JPH::uint)record->partCount); + // Bodies: a capsule along each bone, from the joint toward its children. + for (p = 0; p < record->partCount; p++) { + RagdollPartT *part = &record->parts[p]; + Vec3T jointPosition; + QuatT jointRotation; + Vec3T jointScale; + JPH::Vec3 origin; + JPH::Vec3 toward = JPH::Vec3::sZero(); + JPH::Vec3 direction; + JPH::Quat rotation; + JPH::RVec3 centre; + float length; + float radius; + int32_t children = 0; + int32_t c; + + nodeGetWorldTransform(part->joint, &jointPosition, &jointRotation, &jointScale); + origin = JPH::Vec3(jointPosition.x, jointPosition.y, jointPosition.z); + for (c = 0; c < nodeGetChildCount(part->joint); c++) { + int32_t child = nodeGetChild(part->joint, c); + Vec3T where; + + if (_ragdollPartOf(record, child) < 0) { + // A leaf joint still gives the bone its length. + bool isJoint = false; + int32_t j; + const int32_t *joints; + int32_t count = nodeGetSkinJoints(record->skinned, &joints); + + for (j = 0; j < count; j++) { + isJoint = isJoint || (joints[j] == child); + } + if (!isJoint) { + continue; + } + } + where = nodeGetWorldPosition(child); + toward += JPH::Vec3(where.x, where.y, where.z); + children++; + } + if (children > 0) { + toward = toward / (float)children; + } else { + toward = origin + JPH::Vec3(0.0f, RAGDOLL_RADIUS_MIN * 4.0f, 0.0f); + } + direction = toward - origin; + length = SDL_max(direction.Length(), RAGDOLL_RADIUS_MIN * 2.0f); + direction = (direction.LengthSq() > 1e-10f) ? direction.Normalized() : JPH::Vec3::sAxisY(); + radius = (part->radius > 0.0f) ? part->radius : SDL_max(length * RAGDOLL_RADIUS_RATIO, RAGDOLL_RADIUS_MIN); + rotation = JPH::Quat::sFromTo(JPH::Vec3::sAxisY(), direction); + centre = JPH::RVec3(origin + direction * (length / 2.0f)); + { + JPH::CapsuleShapeSettings capsule(SDL_max(length / 2.0f - radius, 0.001f), radius); + JPH::BodyCreationSettings settings(capsule.Create().Get(), centre, rotation, JPH::EMotionType::Dynamic, LAYER_MOVING); + + settings.mUserData = (JPH::uint64)(uint32_t)part->joint; + settings.mFriction = DEFAULT_FRICTION; + settings.mRestitution = 0.0f; + settings.mLinearDamping = 0.2f; + settings.mAngularDamping = 0.5f; + settings.mCollisionGroup = JPH::CollisionGroup(record->filter, _world->ragdollGroups, (JPH::CollisionGroup::SubGroupID)p); + part->body = bodies.CreateAndAddBody(settings, JPH::EActivation::Activate); + } + if (part->body.IsInvalid()) { + _releaseRagdollBodies(record); + return false; + } + part->offsetPosition = rotation.Conjugated() * (origin - JPH::Vec3(centre)); + part->offsetRotation = rotation.Conjugated() * _fromQuat(jointRotation); + part->scale = JPH::Vec3(jointScale.x, jointScale.y, jointScale.z); + part->rest = rotation; + } + _world->ragdollGroups++; + // Joints: a swing-twist cone at each joint to the parent bone, parent-child collision off. + for (p = 0; p < record->partCount; p++) { + RagdollPartT *part = &record->parts[p]; + RagdollPartT *parent; + JPH::SwingTwistConstraintSettings settings; + JPH::RVec3 pivot; + JPH::Quat parentRotation; + JPH::Quat rotation; + JPH::Vec3 twist; + JPH::Vec3 plane; + + if (part->parent < 0) { + continue; + } + parent = &record->parts[part->parent]; + record->filter->DisableCollision((JPH::CollisionGroup::SubGroupID)p, (JPH::CollisionGroup::SubGroupID)part->parent); + bodies.GetPositionAndRotation(part->body, pivot, rotation); + parentRotation = bodies.GetRotation(parent->body); + pivot = pivot + rotation * part->offsetPosition; + twist = rotation * JPH::Vec3::sAxisY(); + plane = twist.GetNormalizedPerpendicular(); + settings.mSpace = JPH::EConstraintSpace::WorldSpace; + settings.mPosition1 = pivot; + settings.mPosition2 = pivot; + settings.mTwistAxis1 = twist; + settings.mTwistAxis2 = twist; + settings.mPlaneAxis1 = plane; + settings.mPlaneAxis2 = plane; + settings.mNormalHalfConeAngle = JPH::DegreesToRadians(part->swing); + settings.mPlaneHalfConeAngle = JPH::DegreesToRadians(part->swing); + settings.mTwistMinAngle = -JPH::DegreesToRadians(part->twist); + settings.mTwistMaxAngle = JPH::DegreesToRadians(part->twist); + settings.mSwingMotorSettings = JPH::MotorSettings(RAGDOLL_MOTOR_HZ, RAGDOLL_MOTOR_DAMPING); + settings.mTwistMotorSettings = JPH::MotorSettings(RAGDOLL_MOTOR_HZ, RAGDOLL_MOTOR_DAMPING); + { + JPH::BodyID pair[2] = { parent->body, part->body }; + JPH::BodyLockMultiWrite lock(_world->system->GetBodyLockInterface(), pair, 2); + + if ((lock.GetBody(0) == nullptr) || (lock.GetBody(1) == nullptr)) { + continue; + } + part->constraint = settings.Create(*lock.GetBody(0), *lock.GetBody(1)); + } + _world->system->AddConstraint(part->constraint); + part->rest = parentRotation.Conjugated() * rotation; + } + record->active = true; + record->strengthChanged = true; + return true; +} + + +// A shove on one bone, by its joint's name. +bool ragdollApplyImpulse(int32_t node, const char *joint, Vec3T impulse) { + RagdollRecordT *record = _findRagdoll(node); + int32_t p; + + if ((record == nullptr) || !record->active) { + return false; + } + for (p = 0; p < record->partCount; p++) { + const char *name = nodeGetName(record->parts[p].joint); + + if ((name != nullptr) && (SDL_strcasecmp(name, joint) == 0)) { + _world->system->GetBodyInterface().AddImpulse(record->parts[p].body, JPH::Vec3(impulse.x, impulse.y, impulse.z)); + return true; + } + } + return false; +} + + +// Animation may take over again; the joints keep the pose the ragdoll left them in. +bool ragdollDeactivate(int32_t node) { + RagdollRecordT *record = _findRagdoll(node); + + if (record == nullptr) { + return false; + } + _releaseRagdollBodies(record); + return true; +} + + +bool ragdollDelete(int32_t node) { + RagdollRecordT *record = _findRagdoll(node); + + if (record == nullptr) { + return false; + } + _releaseRagdoll(record); + return true; +} + + +bool ragdollExists(int32_t node) { + return _findRagdoll(node) != nullptr; +} + + +bool ragdollIsActive(int32_t node) { + RagdollRecordT *record = _findRagdoll(node); + + return (record != nullptr) && record->active; +} + + +bool ragdollIsResting(int32_t node) { + RagdollRecordT *record = _findRagdoll(node); + int32_t p; + + if ((record == nullptr) || !record->active) { + return false; + } + for (p = 0; p < record->partCount; p++) { + if (_world->system->GetBodyInterface().IsActive(record->parts[p].body)) { + return false; + } + } + return true; +} + + +// Derives the parts from a model instance's skin: every joint with a child joint becomes a bone, +// its parent bone the nearest ancestor joint that is one. Nothing is simulated until activated. +bool ragdollNew(int32_t node) { + RagdollRecordT *record = nullptr; + const int32_t *joints; + int32_t count; + int32_t skinned; + int32_t x; + int32_t j; + int32_t c; + + if ((_world == nullptr) || !nodeValid(node)) { + return false; + } + skinned = _findSkinned(node); + if (skinned < 0) { + utilTrace("Physics: node %d has no skinned model under it for a ragdoll.", node); + return false; + } + ragdollDelete(node); + for (x = 0; x < _world->ragdollCount; x++) { + if (!_world->ragdolls[x].used) { + record = &_world->ragdolls[x]; + break; + } + } + if (record == nullptr) { + utilTrace("Physics: no room for another ragdoll (%d already).", MAX_RAGDOLLS); + return false; + } + _resetRagdoll(record); + count = nodeGetSkinJoints(skinned, &joints); + for (j = 0; (j < count) && (record->partCount < MAX_RAGDOLL_PARTS); j++) { + bool hasChildJoint = false; + + for (c = 0; c < nodeGetChildCount(joints[j]); c++) { + int32_t child = nodeGetChild(joints[j], c); + + for (x = 0; x < count; x++) { + hasChildJoint = hasChildJoint || (joints[x] == child); + } + } + if (!hasChildJoint) { + continue; + } + record->parts[record->partCount].joint = joints[j]; + record->parts[record->partCount].parent = -1; + record->parts[record->partCount].radius = 0.0f; + record->parts[record->partCount].swing = RAGDOLL_SWING_DEGREES; + record->parts[record->partCount].twist = RAGDOLL_TWIST_DEGREES; + record->partCount++; + } + if (record->partCount == 0) { + utilTrace("Physics: the skin under node %d has no bones to make a ragdoll from.", node); + return false; + } + // Parents: walk up the scene tree to the nearest joint that became a part. + for (x = 0; x < record->partCount; x++) { + int32_t up = nodeGetParent(record->parts[x].joint); + + while ((up >= 0) && (record->parts[x].parent < 0)) { + record->parts[x].parent = _ragdollPartOf(record, up); + up = nodeGetParent(up); + } + } + record->node = node; + record->generation = nodeGetGeneration(node); + record->skinned = skinned; + record->used = true; + return true; +} + + +// Tunes one bone before activation: capsule radius (0 = automatic), swing and twist limits. +bool ragdollSetJoint(int32_t node, const char *joint, float radius, float swingDegrees, float twistDegrees) { + RagdollRecordT *record = _findRagdoll(node); + int32_t p; + + if (record == nullptr) { + return false; + } + for (p = 0; p < record->partCount; p++) { + const char *name = nodeGetName(record->parts[p].joint); + + if ((name != nullptr) && (SDL_strcasecmp(name, joint) == 0)) { + record->parts[p].radius = SDL_max(0.0f, radius); + record->parts[p].swing = SDL_clamp(swingDegrees, 0.0f, 179.0f); + record->parts[p].twist = SDL_clamp(twistDegrees, 0.0f, 179.0f); + return true; + } + } + return false; +} + + +// 0 goes limp; more drives every joint back toward the pose it was activated in. +bool ragdollSetStrength(int32_t node, float strength) { + RagdollRecordT *record = _findRagdoll(node); + + if (record == nullptr) { + return false; + } + record->strength = SDL_max(0.0f, strength); + record->strengthChanged = true; + return true; +} + + +bool softDelete(int32_t node) { + SoftRecordT *record = _findSoft(node); + + if (record == nullptr) { + return false; + } + _releaseSoft(record); + return true; +} + + +bool softExists(int32_t node) { + return _findSoft(node) != nullptr; +} + + +// The node's mesh becomes cloth or a pressure body: its vertices, welded by position, are the +// particles, in world space where the node has put them. +bool softNew(int32_t node, SoftKindE kind) { + SoftRecordT *record = nullptr; + const float *positions; + const uint32_t *indices; + int32_t vertexCount; + int32_t indexCount; + int32_t mesh; + Vec3T position; + QuatT rotation; + Vec3T scale; + int32_t x; + int32_t y; + + if ((_world == nullptr) || !nodeValid(node) || (kind == SOFT_ROPE)) { + return false; + } + mesh = nodeGetMesh(node); + if (!meshGetGeometry(mesh, &positions, &vertexCount, &indices, &indexCount) || (vertexCount < 3)) { + utilTrace("Physics: node %d needs a mesh to become a soft body.", node); + return false; + } + softDelete(node); + for (x = 0; x < _world->softCount; x++) { + if (!_world->softs[x].used) { + record = &_world->softs[x]; + break; + } + } + if (record == nullptr) { + utilTrace("Physics: no room for another soft body (%d already).", MAX_SOFT); + return false; + } + _resetSoft(record); + sceneUpdateTransforms(); + nodeGetWorldTransform(node, &position, &rotation, &scale); + record->meshToSoft = (int32_t *)SDL_calloc((size_t)vertexCount, sizeof(int32_t)); + record->positions = (float *)SDL_calloc((size_t)vertexCount * 3, sizeof(float)); + record->meshPositions = (float *)SDL_calloc((size_t)vertexCount * 3, sizeof(float)); + if ((record->meshToSoft == nullptr) || (record->positions == nullptr) || (record->meshPositions == nullptr)) { + utilDie("Out of memory for a soft body."); + } + record->meshVertexCount = vertexCount; + for (x = 0; x < vertexCount; x++) { + Vec3T local = vec3(positions[x * 3] * scale.x, positions[x * 3 + 1] * scale.y, positions[x * 3 + 2] * scale.z); + Vec3T world = vec3Add(position, quatRotate(rotation, local)); + int32_t found = -1; + + for (y = 0; y < record->count; y++) { + if ((fabsf(record->positions[y * 3] - world.x) < SOFT_WELD) && (fabsf(record->positions[y * 3 + 1] - world.y) < SOFT_WELD) && (fabsf(record->positions[y * 3 + 2] - world.z) < SOFT_WELD)) { + found = y; + break; + } + } + if (found < 0) { + found = record->count++; + record->positions[found * 3] = world.x; + record->positions[found * 3 + 1] = world.y; + record->positions[found * 3 + 2] = world.z; + } + record->meshToSoft[x] = found; + } + record->node = node; + record->generation = nodeGetGeneration(node); + record->kind = kind; + record->mesh = mesh; + record->used = true; + if (!_buildSoft(record)) { + _releaseSoft(record); + return false; + } + return true; +} + + +// A rope from the node's position to a world point: particles along it, a tube mesh round them +// that becomes the node's mesh (keeping its material). +bool softNewRope(int32_t node, Vec3T end, int32_t segments, float radius) { + SoftRecordT *record = nullptr; + SceneVertexT *vertices; + uint32_t *indices; + Vec3T *rings; + Vec3T start; + Vec3T position; + QuatT rotation; + Vec3T scale; + QuatT inverse; + int32_t count; + int32_t mesh; + int32_t x; + int32_t side; + + if ((_world == nullptr) || !nodeValid(node) || (segments < 1) || (radius <= 0.0f)) { + return false; + } + softDelete(node); + for (x = 0; x < _world->softCount; x++) { + if (!_world->softs[x].used) { + record = &_world->softs[x]; + break; + } + } + if (record == nullptr) { + utilTrace("Physics: no room for another soft body (%d already).", MAX_SOFT); + return false; + } + _resetSoft(record); + sceneUpdateTransforms(); + nodeGetWorldTransform(node, &position, &rotation, &scale); + start = position; + count = segments + 1; + record->count = count; + record->meshVertexCount = count * ROPE_SIDES; + record->positions = (float *)SDL_calloc((size_t)count * 3, sizeof(float)); + record->meshPositions = (float *)SDL_calloc((size_t)record->meshVertexCount * 3, sizeof(float)); + record->meshToSoft = (int32_t *)SDL_calloc((size_t)record->meshVertexCount, sizeof(int32_t)); + vertices = (SceneVertexT *)SDL_calloc((size_t)record->meshVertexCount, sizeof(SceneVertexT)); + indices = (uint32_t *)SDL_calloc((size_t)segments * ROPE_SIDES * 6, sizeof(uint32_t)); + rings = (Vec3T *)SDL_calloc((size_t)record->meshVertexCount, sizeof(Vec3T)); + if ((record->positions == nullptr) || (record->meshPositions == nullptr) || (record->meshToSoft == nullptr) || (vertices == nullptr) || (indices == nullptr) || (rings == nullptr)) { + utilDie("Out of memory for a rope."); + } + for (x = 0; x < count; x++) { + float t = (float)x / (float)segments; + + record->positions[x * 3] = start.x + (end.x - start.x) * t; + record->positions[x * 3 + 1] = start.y + (end.y - start.y) * t; + record->positions[x * 3 + 2] = start.z + (end.z - start.z) * t; + } + record->ropeRadius = radius; + record->kind = SOFT_ROPE; + // The tube, in the node's frame, with the rope's length running along V. + _ropeMesh(record, rings); + inverse = quatNormalize(rotation); + inverse.x = -inverse.x; + inverse.y = -inverse.y; + inverse.z = -inverse.z; + for (x = 0; x < count; x++) { + for (side = 0; side < ROPE_SIDES; side++) { + int32_t v = x * ROPE_SIDES + side; + Vec3T local = quatRotate(inverse, vec3Subtract(rings[v], position)); + + vertices[v].position[0] = local.x / SDL_max(scale.x, 1e-6f); + vertices[v].position[1] = local.y / SDL_max(scale.y, 1e-6f); + vertices[v].position[2] = local.z / SDL_max(scale.z, 1e-6f); + vertices[v].uv[0] = (float)side / (float)ROPE_SIDES; + vertices[v].uv[1] = (float)x / (float)segments; + vertices[v].weights[0] = 1.0f; + record->meshToSoft[v] = x; + } + } + for (x = 0; x < segments; x++) { + for (side = 0; side < ROPE_SIDES; side++) { + uint32_t a = (uint32_t)(x * ROPE_SIDES + side); + uint32_t b = (uint32_t)(x * ROPE_SIDES + (side + 1) % ROPE_SIDES); + uint32_t c = a + ROPE_SIDES; + uint32_t d = b + ROPE_SIDES; + uint32_t *tri = &indices[(x * ROPE_SIDES + side) * 6]; + + tri[0] = a; + tri[1] = c; + tri[2] = b; + tri[3] = b; + tri[4] = c; + tri[5] = d; + } + } + sceneComputeNormals(vertices, record->meshVertexCount, indices, segments * ROPE_SIDES * 6); + mesh = meshNewVertices(vertices, record->meshVertexCount, indices, segments * ROPE_SIDES * 6, false); + SDL_free(vertices); + SDL_free(indices); + SDL_free(rings); + if (mesh < 0) { + _releaseSoft(record); + return false; + } + nodeSetMesh(node, mesh, nodeGetMaterial(node)); + record->node = node; + record->generation = nodeGetGeneration(node); + record->mesh = mesh; + record->used = true; + if (!_buildSoft(record)) { + _releaseSoft(record); + return false; + } + return true; +} + + +// Holds the particle nearest a world point where it is, or to a node from now on. +bool softPin(int32_t node, Vec3T point, int32_t follow) { + SoftRecordT *record = _findSoft(node); + int32_t vertex; + int32_t x; + + if ((record == nullptr) || (record->pinCount >= MAX_SOFT_PINS)) { + return false; + } + vertex = _nearestSoftVertex(record, point); + if (vertex < 0) { + return false; + } + for (x = 0; x < record->pinCount; x++) { + if (record->pins[x].vertex == vertex) { + record->pins[x].follow = follow; + return true; + } + } + record->pins[record->pinCount].vertex = vertex; + record->pins[record->pinCount].follow = follow; + record->pinCount++; + if (!record->body.IsInvalid()) { + JPH::BodyLockWrite lock(_world->system->GetBodyLockInterface(), record->body); + + if (lock.Succeeded()) { + JPH::SoftBodyMotionProperties *motion = static_cast(lock.GetBody().GetMotionProperties()); + + motion->GetVertex((JPH::uint)vertex).mInvMass = 0.0f; + motion->GetVertex((JPH::uint)vertex).mVelocity = JPH::Vec3::sZero(); + } + } + return true; +} + + +bool softSetDamping(int32_t node, float damping) { + SoftRecordT *record = _findSoft(node); + + if (record == nullptr) { + return false; + } + record->damping = SDL_max(0.0f, damping); + return _buildSoft(record); +} + + +bool softSetMass(int32_t node, float kilograms) { + SoftRecordT *record = _findSoft(node); + + if (record == nullptr) { + return false; + } + record->mass = SDL_max(0.001f, kilograms); + return _buildSoft(record); +} + + +bool softSetPressure(int32_t node, float pressure) { + SoftRecordT *record = _findSoft(node); + + if (record == nullptr) { + return false; + } + record->pressure = SDL_max(0.0f, pressure); + if (!record->body.IsInvalid()) { + { + JPH::BodyLockWrite lock(_world->system->GetBodyLockInterface(), record->body); + + if (lock.Succeeded()) { + static_cast(lock.GetBody().GetMotionProperties())->SetPressure(record->pressure); + } + } + // Outside the lock: activating takes locks of its own. + _world->system->GetBodyInterface().ActivateBody(record->body); + } + return true; +} + + +// Stretch and bend stiffness, 0 to 1; the constraints are remade around the current shape. +bool softSetStiffness(int32_t node, float stretch, float bend) { + SoftRecordT *record = _findSoft(node); + + if (record == nullptr) { + return false; + } + record->stretch = SDL_clamp(stretch, 0.0f, 1.0f); + record->bend = SDL_clamp(bend, 0.0f, 1.0f); + return _buildSoft(record); +} + + +bool softUnpin(int32_t node, Vec3T point) { + SoftRecordT *record = _findSoft(node); + int32_t vertex; + int32_t x; + + if (record == nullptr) { + return false; + } + vertex = _nearestSoftVertex(record, point); + for (x = 0; x < record->pinCount; x++) { + if (record->pins[x].vertex == vertex) { + record->pins[x] = record->pins[record->pinCount - 1]; + record->pinCount--; + return _buildSoft(record); + } + } + return false; +} + diff --git a/src/scene.c b/src/scene.c index 1b3439912..ed4e323ca 100644 --- a/src/scene.c +++ b/src/scene.c @@ -35,6 +35,7 @@ #include "util.h" #include "scene.h" #include "shaders/sceneShaders.h" +#include "particles.h" #define COLOUR_MAX 255.0f @@ -45,6 +46,9 @@ #define PIPELINE_SKINNED 1 #define PIPELINE_BLEND 2 #define PIPELINE_TWO_SIDED 4 +#define PARTICLE_PIPELINES 2 // PARTICLE_ALPHA, PARTICLE_ADD +#define PARTICLE_VERTICES 6 // Two triangles per particle, unindexed +#define PARTICLE_DRAW_MAX 64 // 3D emitters drawn per frame #define DEFAULT_FOV 60.0f #define DEFAULT_NEAR 0.1f #define DEFAULT_FAR 1000.0f @@ -54,6 +58,7 @@ #define NO_HANDLE -1 #define SHADOW_PIPELINES 4 // skinned x double sided #define MAX_SHADOWS MAX_LIGHTS // Every light may cast; the arrays are sized to the lights in use +#define SHADOW_NEAR_MIN 0.01f #define CUBE_FACES 6 #define SHADOW_NONE 0 #define SHADOW_MAP 1 @@ -106,6 +111,7 @@ typedef struct ShadowS { int32_t layer; // First layer in the shadow array (a point light uses six) Mat4T matrix; // Map: the light's view-projection Mat4T faces[CUBE_FACES]; // Cube: one per face + Mat4T faceViews[CUBE_FACES]; // Cube: each face's view alone, for culling float near; float far; } ShadowT; @@ -118,6 +124,8 @@ typedef struct MeshS { Vec3T boundsMax; float *positions; // A CPU copy of the geometry (x, y, z per vertex) for physics shapes uint32_t *indices; + SceneVertexT *vertices; // The whole vertex array, for meshes that are rewritten (soft bodies) + SDL_GPUTransferBuffer *transfer; // For those rewrites, made on first use int32_t vertexCount; SDL_GPUBuffer *morphBuffer; // Morph target deltas, MORPH_FLOATS per vertex per target char **morphNames; @@ -179,12 +187,15 @@ typedef struct NodeS { bool hasLight; bool visible; bool worldVisible; // Own flag and every ancestor's + bool shadowCaster; // Drawn into shadow maps (nodeSetShadow) bool used; } NodeT; typedef struct DrawS { int32_t node; float depth; // View-space distance, for sorting blended draws + Vec3T centre; // World bounding sphere, filled by _fitShadows + float radius; } DrawT; // Matches SkinUniforms in scene.hlsl. @@ -192,6 +203,52 @@ typedef struct SkinUniformsS { Mat4T joints[MAX_JOINTS]; } SkinUniformsT; +// One corner of a particle billboard; the vertex shader expands it from the camera's axes. +typedef struct ParticleVertexS { + float centre[3]; + float corner[2]; + float size; + float angle; + float colour[4]; + float uv[2]; +} ParticleVertexT; + +typedef struct ParticleUniformsS { + Mat4T viewProjection; + float right[4]; + float up[4]; +} ParticleUniformsT; + +// GPU textures made from an emitter's frames, kept until the frames change or the emitter goes. +typedef struct ParticleTexturesS { + int32_t id; + uint32_t version; + int32_t count; + SDL_GPUTexture **textures; +} ParticleTexturesT; + +// A stretch of the frame's particle vertices drawn with one texture and blend. +typedef struct ParticleRunS { + int32_t first; + int32_t count; + SDL_GPUTexture *texture; + ParticleBlendE blend; +} ParticleRunT; + +// Sort keys for particles and emitters, far to near. +typedef struct DepthOrderS { + float depth; + int32_t index; +} DepthOrderT; + +// What a point light's six faces were last rendered from, so unchanged ones are kept. +typedef struct ShadowCacheS { + int32_t node; + int32_t layer; + uint32_t mapsVersion; + uint64_t hash; +} ShadowCacheT; + typedef struct SceneS { SDL_GPUDevice *device; SDL_Renderer *renderer; @@ -206,8 +263,23 @@ typedef struct SceneS { SDL_GPUShader *vertexSkinned; SDL_GPUShader *fragment; SDL_GPUShader *depthFragment; // Empty; the shadow pass writes depth only + SDL_GPUShader *particleVertex; + SDL_GPUShader *particleFragment; + SDL_GPUGraphicsPipeline *particlePipelines[PARTICLE_PIPELINES]; + SDL_GPUBuffer *particleBuffer; // This frame's billboard vertices + SDL_GPUTransferBuffer *particleTransfer; + uint32_t particleCapacity; // Bytes in both + ParticleVertexT *particleVertices; // CPU side, grown as needed + int32_t particleVertexCapacity; + int32_t particleVertexCount; + ParticleRunT particleRuns[PARTICLE_DRAW_MAX * 16]; + int32_t particleRunCount; + ParticleTexturesT *particleTextures; + int32_t particleTextureCount; SDL_GPUGraphicsPipeline *pipelines[PIPELINE_COUNT]; SDL_GPUGraphicsPipeline *shadowPipelines[SHADOW_PIPELINES]; + ShadowCacheT shadowCache[MAX_SHADOWS]; + uint32_t shadowMapsVersion; // Bumped whenever the map array is (re)made SDL_GPUSampler *sampler; SDL_GPUSampler *shadowSampler; SDL_GPUTexture *shadowMaps; // 2D array: a layer per directional or spot shadow, six per point light @@ -249,35 +321,44 @@ static int32_t _allocFeed(int32_t player); static int32_t _allocMaterial(void); static int32_t _allocNode(void); static void _attach(int32_t node, int32_t parent); +static int32_t _compareDepthOrder(const void *a, const void *b); static int32_t _compareDraws(const void *a, const void *b); +static bool _createParticlePipeline(int32_t blend); static bool _createPipeline(int32_t variant); +static SDL_GPUShader *_createShader(const SceneShaderT *shader, SDL_GPUShaderStage stage, uint32_t samplers, uint32_t uniforms, uint32_t storageBuffers); +static bool _createShaders(void); static SDL_GPUTexture *_createShadowArray(SDL_GPUTextureType type, int32_t layers, int32_t size); static bool _createShadowMaps(int32_t layers); static bool _createShadowPipeline(int32_t variant); -static SDL_GPUShader *_createShader(const SceneShaderT *shader, SDL_GPUShaderStage stage, uint32_t samplers, uint32_t uniforms, uint32_t storageBuffers); -static bool _createShaders(void); static SDL_GPUTextureFormat _depthFormat(void); -static SDL_GPUTextureFormat _shadowFormat(void); -static void _destroyTargets(void); -static void _detach(int32_t node); +static void _cullFace(const ShadowT *shadow, int32_t face, int32_t drawCount, bool *skip); +static uint64_t _cubeHash(const ShadowT *shadow, int32_t drawCount); static void _destroyPipelines(void); static void _destroyShadowMaps(void); -static void _drawList(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, int32_t drawCount, const Mat4T *viewProjection, bool shadowPass, FragmentUniformsT *fragmentUniforms); +static void _destroyTargets(void); +static void _detach(int32_t node); +static void _drawList(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, int32_t drawCount, const Mat4T *viewProjection, bool shadowPass, bool twoSided, const bool *skip, FragmentUniformsT *fragmentUniforms); +static void _drawParticles(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, const Mat4T *viewProjection, Vec3T right, Vec3T up); static void _fillLights(FragmentUniformsT *uniforms); -static void _fitShadows(int32_t drawCount); static void _fillSkin(const NodeT *node, SkinUniformsT *uniforms); +static void _fitShadows(int32_t drawCount); static void _freeFeed(FeedT *feed); +static void _freeMaterialTexture(MaterialT *material); static void _freeMorphs(MeshT *mesh); static void _freeMorphWeights(NodeT *node); static void _freeSkin(NodeT *node); -static void _matchMorphWeights(NodeT *node); -static void _freeMaterialTexture(MaterialT *material); +static void _gatherParticles(Vec3T eye, Vec3T forward); static void _lathe(SceneVertexT **vertices, int32_t *vertexCount, uint32_t **indices, int32_t *indexCount, float bottomRadius, float topRadius, float height, int32_t segments); static int32_t _lookupPipeline(int32_t node); +static void _matchMorphWeights(NodeT *node); +static SDL_GPUTexture *_materialTexture(const MaterialT *material); +static ParticleTexturesT *_particleTextures(const EmitterViewT *view); static Mat4T _projection(void); +static void _releaseParticleTextures(bool all); +static SDL_GPUTextureFormat _shadowFormat(void); static void _updateWorld(int32_t node, const Mat4T *parentWorld, bool parentVisible); static SDL_GPUBuffer *_uploadBuffer(SDL_GPUBufferUsageFlags usage, const void *data, uint32_t size); -static SDL_GPUTexture *_materialTexture(const MaterialT *material); +static void _uploadParticles(SDL_GPUCommandBuffer *commands); static SDL_GPUTexture *_uploadTexture(SDL_Surface *image); static SceneVertexT _vertex(float x, float y, float z, float nx, float ny, float nz, float u, float v); static Mat4T _view(void); @@ -326,6 +407,11 @@ static int32_t _addMesh(const SceneVertexT *vertices, int32_t vertexCount, const if ((mesh->positions == NULL) || (mesh->indices == NULL)) { utilDie("Out of memory keeping a mesh's geometry."); } + mesh->vertices = SDL_malloc(sizeof(SceneVertexT) * (size_t)vertexCount); + if (mesh->vertices == NULL) { + utilDie("Out of memory keeping a mesh."); + } + memcpy(mesh->vertices, vertices, sizeof(SceneVertexT) * (size_t)vertexCount); for (x = 0; x < vertexCount; x++) { mesh->positions[x * 3] = vertices[x].position[0]; mesh->positions[x * 3 + 1] = vertices[x].position[1]; @@ -441,6 +527,7 @@ static int32_t _allocNode(void) { node->mesh = NO_HANDLE; node->material = NO_HANDLE; node->visible = true; + node->shadowCaster = true; node->used = true; return x; } @@ -465,6 +552,21 @@ static void _attach(int32_t node, int32_t parent) { // Blended draws go back to front; opaque ones keep their order. +// Far to near. +static int32_t _compareDepthOrder(const void *a, const void *b) { + const DepthOrderT *x = a; + const DepthOrderT *y = b; + + if (x->depth > y->depth) { + return -1; + } + if (x->depth < y->depth) { + return 1; + } + return 0; +} + + static int32_t _compareDraws(const void *a, const void *b) { const DrawT *da = a; const DrawT *db = b; @@ -479,6 +581,70 @@ static int32_t _compareDraws(const void *a, const void *b) { } +// Billboard pipeline for one blend: camera-facing quads, depth tested, never written, two-sided. +static bool _createParticlePipeline(int32_t blend) { + SDL_GPUGraphicsPipelineCreateInfo info; + SDL_GPUVertexBufferDescription buffers[1]; + SDL_GPUVertexAttribute attributes[5]; + SDL_GPUColorTargetDescription colour; + + memset(&info, 0, sizeof(info)); + memset(buffers, 0, sizeof(buffers)); + memset(attributes, 0, sizeof(attributes)); + memset(&colour, 0, sizeof(colour)); + buffers[0].slot = 0; + buffers[0].pitch = sizeof(ParticleVertexT); + buffers[0].input_rate = SDL_GPU_VERTEXINPUTRATE_VERTEX; + attributes[0].location = 0; + attributes[0].format = SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3; + attributes[0].offset = offsetof(ParticleVertexT, centre); + attributes[1].location = 1; + attributes[1].format = SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2; + attributes[1].offset = offsetof(ParticleVertexT, corner); + attributes[2].location = 2; + attributes[2].format = SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2; + attributes[2].offset = offsetof(ParticleVertexT, size); + attributes[3].location = 3; + attributes[3].format = SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4; + attributes[3].offset = offsetof(ParticleVertexT, colour); + attributes[4].location = 4; + attributes[4].format = SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2; + attributes[4].offset = offsetof(ParticleVertexT, uv); + colour.format = SDL_GetGPUTextureFormatFromPixelFormat(SDL_PIXELFORMAT_BGRA32); + colour.blend_state.enable_blend = true; + colour.blend_state.src_color_blendfactor = SDL_GPU_BLENDFACTOR_SRC_ALPHA; + colour.blend_state.dst_color_blendfactor = (blend == PARTICLE_ADD) ? SDL_GPU_BLENDFACTOR_ONE : SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; + colour.blend_state.color_blend_op = SDL_GPU_BLENDOP_ADD; + colour.blend_state.src_alpha_blendfactor = SDL_GPU_BLENDFACTOR_ONE; + colour.blend_state.dst_alpha_blendfactor = SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; + colour.blend_state.alpha_blend_op = SDL_GPU_BLENDOP_ADD; + info.vertex_shader = _scene.particleVertex; + info.fragment_shader = _scene.particleFragment; + info.vertex_input_state.vertex_buffer_descriptions = buffers; + info.vertex_input_state.num_vertex_buffers = 1; + info.vertex_input_state.vertex_attributes = attributes; + info.vertex_input_state.num_vertex_attributes = 5; + info.primitive_type = SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; + info.rasterizer_state.fill_mode = SDL_GPU_FILLMODE_FILL; + info.rasterizer_state.cull_mode = SDL_GPU_CULLMODE_NONE; + info.rasterizer_state.front_face = SDL_GPU_FRONTFACE_COUNTER_CLOCKWISE; + info.multisample_state.sample_count = _scene.sampleCount; + info.depth_stencil_state.compare_op = SDL_GPU_COMPAREOP_LESS_OR_EQUAL; + info.depth_stencil_state.enable_depth_test = true; + info.depth_stencil_state.enable_depth_write = false; + info.target_info.color_target_descriptions = &colour; + info.target_info.num_color_targets = 1; + info.target_info.depth_stencil_format = _scene.depthFormat; + info.target_info.has_depth_stencil_target = true; + _scene.particlePipelines[blend] = SDL_CreateGPUGraphicsPipeline(_scene.device, &info); + if (_scene.particlePipelines[blend] == NULL) { + utilTrace("Scene: particle pipeline %d: %s", blend, SDL_GetError()); + return false; + } + return true; +} + + static bool _createPipeline(int32_t variant) { SDL_GPUGraphicsPipelineCreateInfo info; SDL_GPUVertexBufferDescription buffers[1]; @@ -585,7 +751,9 @@ static bool _createShaders(void) { _scene.vertexSkinned = _createShader(&sceneShaderVertexSkinned, SDL_GPU_SHADERSTAGE_VERTEX, 0, 2, 1); _scene.fragment = _createShader(&sceneShaderFragmentMain, SDL_GPU_SHADERSTAGE_FRAGMENT, 2, 1, 0); _scene.depthFragment = _createShader(&sceneShaderDepthMain, SDL_GPU_SHADERSTAGE_FRAGMENT, 0, 0, 0); - return (_scene.vertexStatic != NULL) && (_scene.vertexSkinned != NULL) && (_scene.fragment != NULL) && (_scene.depthFragment != NULL); + _scene.particleVertex = _createShader(&sceneShaderParticleVertex, SDL_GPU_SHADERSTAGE_VERTEX, 0, 1, 0); + _scene.particleFragment = _createShader(&sceneShaderParticleFragment, SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 0, 0); + return (_scene.vertexStatic != NULL) && (_scene.vertexSkinned != NULL) && (_scene.fragment != NULL) && (_scene.depthFragment != NULL) && (_scene.particleVertex != NULL) && (_scene.particleFragment != NULL); } @@ -618,6 +786,7 @@ static bool _createShadowMaps(int32_t layers) { SDL_ReleaseGPUTexture(_scene.device, _scene.shadowMaps); } _scene.shadowMaps = _createShadowArray(SDL_GPU_TEXTURETYPE_2D_ARRAY, layers, _scene.shadowSize); + _scene.shadowMapsVersion++; _scene.shadowMapLayers = (_scene.shadowMaps != NULL) ? layers : 0; } return _scene.shadowMaps != NULL; @@ -750,7 +919,7 @@ static void _destroyTargets(void) { // Issues the collected draws into a pass: the shadow pass with the light's view-projection and // depth-only pipelines (blended meshes cast nothing), or the main pass with the camera's and the // full material. -static void _drawList(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, int32_t drawCount, const Mat4T *viewProjection, bool shadowPass, FragmentUniformsT *fragmentUniforms) { +static void _drawList(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, int32_t drawCount, const Mat4T *viewProjection, bool shadowPass, bool twoSided, const bool *skip, FragmentUniformsT *fragmentUniforms) { SDL_GPUBufferBinding binding; SDL_GPUTextureSamplerBinding samplerBindings[2]; DrawUniformsT drawUniforms; @@ -782,9 +951,13 @@ static void _drawList(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, i continue; } if (shadowPass) { - if (material->blend) { + if (material->blend || !node->shadowCaster || ((skip != NULL) && skip[x])) { continue; } + if (twoSided) { + // A bulb inside a closed mesh sees only its back faces; they must still cast. + variant |= PIPELINE_TWO_SIDED; + } variant &= PIPELINE_SKINNED | PIPELINE_TWO_SIDED; variant = (variant & PIPELINE_SKINNED ? 1 : 0) | (variant & PIPELINE_TWO_SIDED ? 2 : 0); if ((_scene.shadowPipelines[variant] == NULL) && !_createShadowPipeline(variant)) { @@ -883,6 +1056,98 @@ static void _drawList(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, i } +// Draws this frame's particle runs, after every mesh, with the camera's axes for the billboards. +static void _drawParticles(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, const Mat4T *viewProjection, Vec3T right, Vec3T up) { + SDL_GPUBufferBinding binding; + SDL_GPUTextureSamplerBinding sampler; + ParticleUniformsT uniforms; + int32_t x; + int32_t lastBlend = NO_HANDLE; + + if ((_scene.particleRunCount == 0) || (_scene.particleBuffer == NULL)) { + return; + } + memset(&uniforms, 0, sizeof(uniforms)); + uniforms.viewProjection = *viewProjection; + uniforms.right[0] = right.x; + uniforms.right[1] = right.y; + uniforms.right[2] = right.z; + uniforms.up[0] = up.x; + uniforms.up[1] = up.y; + uniforms.up[2] = up.z; + memset(&binding, 0, sizeof(binding)); + binding.buffer = _scene.particleBuffer; + SDL_BindGPUVertexBuffers(pass, 0, &binding, 1); + memset(&sampler, 0, sizeof(sampler)); + sampler.sampler = _scene.sampler; + for (x = 0; x < _scene.particleRunCount; x++) { + ParticleRunT *run = &_scene.particleRuns[x]; + + if ((_scene.particlePipelines[run->blend] == NULL) && !_createParticlePipeline(run->blend)) { + continue; + } + if ((int32_t)run->blend != lastBlend) { + SDL_BindGPUGraphicsPipeline(pass, _scene.particlePipelines[run->blend]); + SDL_PushGPUVertexUniformData(commands, 0, &uniforms, sizeof(uniforms)); + lastBlend = run->blend; + } + sampler.texture = run->texture; + SDL_BindGPUFragmentSamplers(pass, 0, &sampler, 1); + SDL_DrawGPUPrimitives(pass, (Uint32)run->count, 1, (Uint32)run->first, 0); + } +} + + +// A fingerprint of everything a point light's faces depend on: the light, its range, and every +// caster's transform; skinned and morphing casters count as always changed. +static uint64_t _cubeHash(const ShadowT *shadow, int32_t drawCount) { + uint64_t hash = 1469598103934665603ULL; + int32_t x; + int32_t b; + const uint8_t *bytes; + + bytes = (const uint8_t *)&_scene.nodes[shadow->node].world; + for (b = 0; b < (int32_t)sizeof(Mat4T); b++) { + hash = (hash ^ bytes[b]) * 1099511628211ULL; + } + bytes = (const uint8_t *)&shadow->far; + for (b = 0; b < (int32_t)sizeof(float); b++) { + hash = (hash ^ bytes[b]) * 1099511628211ULL; + } + for (x = 0; x < drawCount; x++) { + NodeT *node = &_scene.nodes[_scene.draws[x].node]; + MeshT *mesh = &_scene.meshes[node->mesh]; + + if (!node->shadowCaster) { + continue; + } + if ((mesh->skinned && (node->skinCount > 0)) || ((mesh->morphBuffer != NULL) && (node->morphCount > 0))) { + return 0; + } + hash = (hash ^ (uint64_t)(uint32_t)_scene.draws[x].node) * 1099511628211ULL; + bytes = (const uint8_t *)&node->world; + for (b = 0; b < (int32_t)sizeof(Mat4T); b++) { + hash = (hash ^ bytes[b]) * 1099511628211ULL; + } + } + return (hash == 0) ? 1 : hash; +} + + +// Marks the draws a point light's face cannot see: bounding sphere against the 90 degree frustum. +static void _cullFace(const ShadowT *shadow, int32_t face, int32_t drawCount, bool *skip) { + int32_t x; + + for (x = 0; x < drawCount; x++) { + Vec3T local = mat4TransformPoint(shadow->faceViews[face], _scene.draws[x].centre); + float radius = _scene.draws[x].radius; + float ahead = -local.z; + + skip[x] = ((ahead + radius < shadow->near) || (ahead - radius > shadow->far) || (fabsf(local.x) - radius * 1.4143f > ahead + radius) || (fabsf(local.y) - radius * 1.4143f > ahead + radius)); + } +} + + // Unlinks node from its parent's child list. static void _detach(int32_t node) { int32_t parent = _scene.nodes[node].parent; @@ -1006,15 +1271,24 @@ static void _fitShadows(int32_t drawCount) { Vec3T faceForward[CUBE_FACES] = { { 1.0f, 0.0f, 0.0f }, { -1.0f, 0.0f, 0.0f }, { 0.0f, 1.0f, 0.0f }, { 0.0f, -1.0f, 0.0f }, { 0.0f, 0.0f, 1.0f }, { 0.0f, 0.0f, -1.0f } }; Vec3T faceUp[CUBE_FACES] = { { 0.0f, 1.0f, 0.0f }, { 0.0f, 1.0f, 0.0f }, { 0.0f, 0.0f, -1.0f }, { 0.0f, 0.0f, 1.0f }, { 0.0f, 1.0f, 0.0f }, { 0.0f, 1.0f, 0.0f } }; - // World bounds of everything drawn. + // World bounds of everything drawn, and a bounding sphere per draw. for (x = 0; x < drawCount; x++) { NodeT *node = &_scene.nodes[_scene.draws[x].node]; MeshT *mesh = &_scene.meshes[node->mesh]; + Vec3T drawMin = vec3(0.0f, 0.0f, 0.0f); + Vec3T drawMax = vec3(0.0f, 0.0f, 0.0f); for (c = 0; c < 8; c++) { Vec3T corner = vec3((c & 1) ? mesh->boundsMax.x : mesh->boundsMin.x, (c & 2) ? mesh->boundsMax.y : mesh->boundsMin.y, (c & 4) ? mesh->boundsMax.z : mesh->boundsMin.z); corner = mat4TransformPoint(node->world, corner); + if (c == 0) { + drawMin = corner; + drawMax = corner; + } else { + drawMin = vec3(SDL_min(drawMin.x, corner.x), SDL_min(drawMin.y, corner.y), SDL_min(drawMin.z, corner.z)); + drawMax = vec3(SDL_max(drawMax.x, corner.x), SDL_max(drawMax.y, corner.y), SDL_max(drawMax.z, corner.z)); + } if (!any) { boundsMin = corner; boundsMax = corner; @@ -1024,6 +1298,8 @@ static void _fitShadows(int32_t drawCount) { boundsMax = vec3(SDL_max(boundsMax.x, corner.x), SDL_max(boundsMax.y, corner.y), SDL_max(boundsMax.z, corner.z)); } } + _scene.draws[x].centre = vec3Scale(vec3Add(drawMin, drawMax), 0.5f); + _scene.draws[x].radius = vec3Length(vec3Subtract(drawMax, _scene.draws[x].centre)) + 0.001f; } centre = vec3Scale(vec3Add(boundsMin, boundsMax), 0.5f); radius = vec3Length(vec3Subtract(boundsMax, centre)) * SHADOW_MARGIN + 0.001f; @@ -1068,22 +1344,141 @@ static void _fitShadows(int32_t drawCount) { } // View space looks down -Z: the nearest point has the largest z. shadow->matrix = mat4Multiply(mat4OrthographicBounds(minX * SHADOW_MARGIN, maxX * SHADOW_MARGIN, minY * SHADOW_MARGIN, maxY * SHADOW_MARGIN, SDL_max(-maxZ / SHADOW_MARGIN, 0.01f), -minZ * SHADOW_MARGIN), view); - } else if (light->light.type == LIGHT_SPOT) { - view = mat4LookAt(position, vec3Add(position, direction), up); - far = (light->light.range > 0.0f) ? light->light.range : vec3Length(vec3Subtract(centre, position)) + radius; - shadow->matrix = mat4Multiply(mat4Perspective(SDL_min(light->light.outerDegrees * 2.0f * SHADOW_MARGIN, 170.0f), 1.0f, SDL_max(far * 0.005f, 0.01f), far), view); } else { - far = (light->light.range > 0.0f) ? light->light.range : vec3Length(vec3Subtract(centre, position)) + radius; - shadow->near = SDL_max(far * 0.005f, 0.01f); + // Near and far from the casters themselves: the nearest caster surface (a shade round the + // bulb) sets near, the range or the farthest caster sets far. + float nearest = 1.0e30f; + float farthest = 0.0f; + + for (x = 0; x < drawCount; x++) { + float distance = vec3Length(vec3Subtract(_scene.draws[x].centre, position)); + + if (!_scene.nodes[_scene.draws[x].node].shadowCaster) { + continue; + } + nearest = SDL_min(nearest, distance - _scene.draws[x].radius); + farthest = SDL_max(farthest, distance + _scene.draws[x].radius); + } + far = (light->light.range > 0.0f) ? light->light.range : farthest; + far = SDL_max(far, 0.02f); + shadow->near = SDL_clamp(nearest, SHADOW_NEAR_MIN, far * 0.5f); shadow->far = far; + if (light->light.type == LIGHT_SPOT) { + view = mat4LookAt(position, vec3Add(position, direction), up); + shadow->matrix = mat4Multiply(mat4Perspective(SDL_min(light->light.outerDegrees * 2.0f * SHADOW_MARGIN, 170.0f), 1.0f, shadow->near, far), view); + continue; + } for (c = 0; c < CUBE_FACES; c++) { - shadow->faces[c] = mat4Multiply(mat4Perspective(90.0f, 1.0f, shadow->near, shadow->far), mat4LookAt(position, vec3Add(position, faceForward[c]), faceUp[c])); + shadow->faceViews[c] = mat4LookAt(position, vec3Add(position, faceForward[c]), faceUp[c]); + shadow->faces[c] = mat4Multiply(mat4Perspective(90.0f, 1.0f, shadow->near, shadow->far), shadow->faceViews[c]); } } } } +// Collects every 3D emitter's live particles into this frame's vertex list: emitters far to near, +// alpha-blended particles far to near within each, one run per frame texture. +static void _gatherParticles(Vec3T eye, Vec3T forward) { + EmitterViewT views[PARTICLE_DRAW_MAX]; + DepthOrderT order[PARTICLE_DRAW_MAX]; + DepthOrderT *particleOrder = NULL; + ParticleTexturesT *textures; + ParticleViewT *particle; + ParticleVertexT *vertex; + Vec3T origin; + int32_t emitters = 0; + int32_t total = 0; + int32_t needed; + int32_t e; + int32_t i; + int32_t k; + int32_t frame; + int32_t first; + int32_t c; + // The two triangles of a quad as corner offsets. + static const float corners[PARTICLE_VERTICES][2] = { { -1.0f, -1.0f }, { 1.0f, -1.0f }, { 1.0f, 1.0f }, { -1.0f, -1.0f }, { 1.0f, 1.0f }, { -1.0f, 1.0f } }; + + _scene.particleRunCount = 0; + _scene.particleVertexCount = 0; + _releaseParticleTextures(false); + for (i = 0; (i < particlesCount()) && (emitters < PARTICLE_DRAW_MAX); i++) { + if (!particlesView(i, &views[emitters]) || (views[emitters].node < 0) || (views[emitters].count == 0)) { + continue; + } + origin = nodeValid(views[emitters].node) ? nodeGetWorldPosition(views[emitters].node) : eye; + order[emitters].depth = vec3Dot(vec3Subtract(origin, eye), forward); + order[emitters].index = emitters; + total += views[emitters].count; + emitters++; + } + if (total == 0) { + return; + } + needed = total * PARTICLE_VERTICES; + if (_scene.particleVertexCapacity < needed) { + _scene.particleVertices = SDL_realloc(_scene.particleVertices, sizeof(ParticleVertexT) * (size_t)needed); + if (_scene.particleVertices == NULL) { + utilDie("Out of memory for %d particle vertices.", needed); + } + _scene.particleVertexCapacity = needed; + } + qsort(order, (size_t)emitters, sizeof(DepthOrderT), _compareDepthOrder); + for (e = 0; e < emitters; e++) { + EmitterViewT *view = &views[order[e].index]; + + textures = _particleTextures(view); + if (textures == NULL) { + continue; + } + particleOrder = SDL_malloc(sizeof(DepthOrderT) * (size_t)view->count); + if (particleOrder == NULL) { + utilDie("Out of memory sorting particles."); + } + for (i = 0; i < view->count; i++) { + particleOrder[i].depth = vec3Dot(vec3Subtract(view->particles[i].position, eye), forward); + particleOrder[i].index = i; + } + if (view->blend == PARTICLE_ALPHA) { + qsort(particleOrder, (size_t)view->count, sizeof(DepthOrderT), _compareDepthOrder); + } + for (frame = 0; frame < textures->count; frame++) { + first = _scene.particleVertexCount; + for (k = 0; k < view->count; k++) { + particle = &view->particles[particleOrder[k].index]; + if ((textures->count > 1) && (particle->frame != frame)) { + continue; + } + for (c = 0; c < PARTICLE_VERTICES; c++) { + vertex = &_scene.particleVertices[_scene.particleVertexCount++]; + vertex->centre[0] = particle->position.x; + vertex->centre[1] = particle->position.y; + vertex->centre[2] = particle->position.z; + vertex->corner[0] = corners[c][0]; + vertex->corner[1] = corners[c][1]; + vertex->size = particle->size; + vertex->angle = particle->angle; + vertex->colour[0] = particle->colour[0]; + vertex->colour[1] = particle->colour[1]; + vertex->colour[2] = particle->colour[2]; + vertex->colour[3] = particle->colour[3]; + vertex->uv[0] = corners[c][0] * 0.5f + 0.5f; + vertex->uv[1] = 0.5f - corners[c][1] * 0.5f; + } + } + if ((_scene.particleVertexCount > first) && (_scene.particleRunCount < (int32_t)(sizeof(_scene.particleRuns) / sizeof(_scene.particleRuns[0])))) { + _scene.particleRuns[_scene.particleRunCount].first = first; + _scene.particleRuns[_scene.particleRunCount].count = _scene.particleVertexCount - first; + _scene.particleRuns[_scene.particleRunCount].texture = textures->textures[frame]; + _scene.particleRuns[_scene.particleRunCount].blend = view->blend; + _scene.particleRunCount++; + } + } + SDL_free(particleOrder); + } +} + + static void _freeFeed(FeedT *feed) { if (feed->target != NULL) { SDL_DestroyTexture(feed->target); @@ -1254,6 +1649,60 @@ static int32_t _lookupPipeline(int32_t node) { } +// The GPU textures for an emitter's frames, made on first sight and remade when the frames change. +static ParticleTexturesT *_particleTextures(const EmitterViewT *view) { + ParticleTexturesT *entry = NULL; + int32_t x; + + for (x = 0; x < _scene.particleTextureCount; x++) { + if (_scene.particleTextures[x].id == view->id) { + entry = &_scene.particleTextures[x]; + break; + } + } + if ((entry != NULL) && (entry->version != view->textureVersion)) { + for (x = 0; x < entry->count; x++) { + SDL_ReleaseGPUTexture(_scene.device, entry->textures[x]); + } + SDL_free(entry->textures); + entry->textures = NULL; + entry->count = 0; + entry->version = 0; + } + if ((entry != NULL) && (entry->textures != NULL)) { + return entry; + } + if (entry == NULL) { + _scene.particleTextures = SDL_realloc(_scene.particleTextures, sizeof(ParticleTexturesT) * (size_t)(_scene.particleTextureCount + 1)); + if (_scene.particleTextures == NULL) { + utilDie("Out of memory for particle textures."); + } + entry = &_scene.particleTextures[_scene.particleTextureCount++]; + memset(entry, 0, sizeof(*entry)); + entry->id = view->id; + } + entry->textures = SDL_calloc((size_t)view->frameCount, sizeof(SDL_GPUTexture *)); + if (entry->textures == NULL) { + utilDie("Out of memory for particle textures."); + } + for (x = 0; x < view->frameCount; x++) { + entry->textures[x] = _uploadTexture(view->frames[x]); + if (entry->textures[x] == NULL) { + while (x > 0) { + x--; + SDL_ReleaseGPUTexture(_scene.device, entry->textures[x]); + } + SDL_free(entry->textures); + entry->textures = NULL; + return NULL; + } + } + entry->count = view->frameCount; + entry->version = view->textureVersion; + return entry; +} + + static Mat4T _projection(void) { float aspect = (_scene.height > 0) ? (float)_scene.width / (float)_scene.height : 1.0f; @@ -1265,6 +1714,32 @@ static Mat4T _projection(void) { // A depth format the shadow map can be both rendered into and sampled from. +// Drops the textures of emitters that no longer exist, or all of them. +static void _releaseParticleTextures(bool all) { + int32_t x = 0; + int32_t f; + + while (x < _scene.particleTextureCount) { + ParticleTexturesT *entry = &_scene.particleTextures[x]; + + if (all || !emitterValid(entry->id)) { + for (f = 0; f < entry->count; f++) { + SDL_ReleaseGPUTexture(_scene.device, entry->textures[f]); + } + SDL_free(entry->textures); + _scene.particleTextureCount--; + *entry = _scene.particleTextures[_scene.particleTextureCount]; + continue; + } + x++; + } + if (all) { + SDL_free(_scene.particleTextures); + _scene.particleTextures = NULL; + } +} + + static SDL_GPUTextureFormat _shadowFormat(void) { SDL_GPUTextureFormat wanted[] = { SDL_GPU_TEXTUREFORMAT_D32_FLOAT, SDL_GPU_TEXTUREFORMAT_D16_UNORM, SDL_GPU_TEXTUREFORMAT_D24_UNORM }; int32_t x; @@ -1338,6 +1813,55 @@ static SDL_GPUBuffer *_uploadBuffer(SDL_GPUBufferUsageFlags usage, const void *d } +// Copies this frame's particle vertices to the GPU, growing the buffers when a frame needs more. +static void _uploadParticles(SDL_GPUCommandBuffer *commands) { + SDL_GPUBufferCreateInfo info; + SDL_GPUTransferBufferCreateInfo transferInfo; + SDL_GPUTransferBufferLocation source; + SDL_GPUBufferRegion region; + SDL_GPUCopyPass *pass; + void *mapped; + uint32_t bytes = (uint32_t)_scene.particleVertexCount * (uint32_t)sizeof(ParticleVertexT); + + if (bytes == 0) { + return; + } + if (bytes > _scene.particleCapacity) { + if (_scene.particleBuffer != NULL) { + SDL_ReleaseGPUBuffer(_scene.device, _scene.particleBuffer); + } + if (_scene.particleTransfer != NULL) { + SDL_ReleaseGPUTransferBuffer(_scene.device, _scene.particleTransfer); + } + _scene.particleCapacity = SDL_max(bytes * 2, 65536); + memset(&info, 0, sizeof(info)); + info.usage = SDL_GPU_BUFFERUSAGE_VERTEX; + info.size = _scene.particleCapacity; + _scene.particleBuffer = SDL_CreateGPUBuffer(_scene.device, &info); + memset(&transferInfo, 0, sizeof(transferInfo)); + transferInfo.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; + transferInfo.size = _scene.particleCapacity; + _scene.particleTransfer = SDL_CreateGPUTransferBuffer(_scene.device, &transferInfo); + if ((_scene.particleBuffer == NULL) || (_scene.particleTransfer == NULL)) { + utilTrace("Scene: particle buffer: %s", SDL_GetError()); + _scene.particleRunCount = 0; + return; + } + } + mapped = SDL_MapGPUTransferBuffer(_scene.device, _scene.particleTransfer, true); + memcpy(mapped, _scene.particleVertices, bytes); + SDL_UnmapGPUTransferBuffer(_scene.device, _scene.particleTransfer); + pass = SDL_BeginGPUCopyPass(commands); + memset(&source, 0, sizeof(source)); + memset(®ion, 0, sizeof(region)); + source.transfer_buffer = _scene.particleTransfer; + region.buffer = _scene.particleBuffer; + region.size = bytes; + SDL_UploadToGPUBuffer(pass, &source, ®ion, true); + SDL_EndGPUCopyPass(pass); +} + + // An RGBA sampler texture from any surface. static SDL_GPUTexture *_uploadTexture(SDL_Surface *image) { SDL_Surface *rgba; @@ -1770,6 +2294,10 @@ bool meshDelete(int32_t mesh) { } SDL_free(_scene.meshes[mesh].positions); SDL_free(_scene.meshes[mesh].indices); + SDL_free(_scene.meshes[mesh].vertices); + if (_scene.meshes[mesh].transfer != NULL) { + SDL_ReleaseGPUTransferBuffer(_scene.device, _scene.meshes[mesh].transfer); + } _freeMorphs(&_scene.meshes[mesh]); memset(&_scene.meshes[mesh], 0, sizeof(MeshT)); for (x = 0; x < _scene.nodeCount; x++) { @@ -1799,6 +2327,71 @@ int32_t meshFindMorph(int32_t mesh, const char *name) { // The mesh's geometry as kept on the CPU: x, y, z per vertex and triangle indices. +// Rewrites a mesh's vertex positions (x, y, z per vertex, in the mesh's space), recomputing normals +// and bounds and uploading in place, for meshes a soft body drives. +bool meshSetPositions(int32_t mesh, const float *positions) { + MeshT *m; + SDL_GPUTransferBufferCreateInfo transferInfo; + SDL_GPUTransferBufferLocation source; + SDL_GPUBufferRegion region; + SDL_GPUCommandBuffer *commands; + SDL_GPUCopyPass *pass; + void *mapped; + uint32_t size; + int32_t x; + + if (!meshValid(mesh) || (positions == NULL)) { + return false; + } + m = &_scene.meshes[mesh]; + size = (uint32_t)(sizeof(SceneVertexT) * (size_t)m->vertexCount); + memcpy(m->positions, positions, sizeof(float) * 3 * (size_t)m->vertexCount); + for (x = 0; x < m->vertexCount; x++) { + m->vertices[x].position[0] = positions[x * 3]; + m->vertices[x].position[1] = positions[x * 3 + 1]; + m->vertices[x].position[2] = positions[x * 3 + 2]; + if (x == 0) { + m->boundsMin = vec3(positions[0], positions[1], positions[2]); + m->boundsMax = m->boundsMin; + } else { + m->boundsMin = vec3(SDL_min(m->boundsMin.x, positions[x * 3]), SDL_min(m->boundsMin.y, positions[x * 3 + 1]), SDL_min(m->boundsMin.z, positions[x * 3 + 2])); + m->boundsMax = vec3(SDL_max(m->boundsMax.x, positions[x * 3]), SDL_max(m->boundsMax.y, positions[x * 3 + 1]), SDL_max(m->boundsMax.z, positions[x * 3 + 2])); + } + } + sceneComputeNormals(m->vertices, m->vertexCount, m->indices, (int32_t)m->indexCount); + if (_scene.device == NULL) { + return true; + } + if (m->transfer == NULL) { + memset(&transferInfo, 0, sizeof(transferInfo)); + transferInfo.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; + transferInfo.size = size; + m->transfer = SDL_CreateGPUTransferBuffer(_scene.device, &transferInfo); + if (m->transfer == NULL) { + utilTrace("Scene: %s", SDL_GetError()); + return false; + } + } + mapped = SDL_MapGPUTransferBuffer(_scene.device, m->transfer, true); + memcpy(mapped, m->vertices, size); + SDL_UnmapGPUTransferBuffer(_scene.device, m->transfer); + commands = SDL_AcquireGPUCommandBuffer(_scene.device); + if (commands == NULL) { + return false; + } + pass = SDL_BeginGPUCopyPass(commands); + memset(&source, 0, sizeof(source)); + memset(®ion, 0, sizeof(region)); + source.transfer_buffer = m->transfer; + region.buffer = m->vertexBuffer; + region.size = size; + SDL_UploadToGPUBuffer(pass, &source, ®ion, true); + SDL_EndGPUCopyPass(pass); + SDL_SubmitGPUCommandBuffer(commands); + return true; +} + + bool meshGetGeometry(int32_t mesh, const float **positions, int32_t *vertexCount, const uint32_t **indices, int32_t *indexCount) { if (!meshValid(mesh)) { return false; @@ -1880,6 +2473,55 @@ int32_t meshNewVertices(const SceneVertexT *vertices, int32_t vertexCount, const // A quad in the XZ plane facing +Y. +// A plane of columns x rows quads, for cloth and terrain that bends. +int32_t meshGrid(float width, float depth, int32_t columns, int32_t rows) { + SceneVertexT *vertices; + uint32_t *indices; + int32_t vertexCount = (columns + 1) * (rows + 1); + int32_t indexCount = columns * rows * 6; + int32_t x; + int32_t y; + int32_t mesh; + + if ((columns < 1) || (rows < 1)) { + return NO_HANDLE; + } + vertices = SDL_calloc((size_t)vertexCount, sizeof(SceneVertexT)); + indices = SDL_calloc((size_t)indexCount, sizeof(uint32_t)); + if ((vertices == NULL) || (indices == NULL)) { + utilDie("Out of memory making a grid."); + } + for (y = 0; y <= rows; y++) { + for (x = 0; x <= columns; x++) { + float u = (float)x / (float)columns; + float v = (float)y / (float)rows; + + vertices[y * (columns + 1) + x] = _vertex(-width / 2.0f + width * u, 0.0f, depth / 2.0f - depth * v, 0.0f, 1.0f, 0.0f, u, 1.0f - v); + } + } + for (y = 0; y < rows; y++) { + for (x = 0; x < columns; x++) { + uint32_t a = (uint32_t)(y * (columns + 1) + x); + uint32_t b = a + 1; + uint32_t c = a + (uint32_t)(columns + 1); + uint32_t d = c + 1; + uint32_t *tri = &indices[(y * columns + x) * 6]; + + tri[0] = a; + tri[1] = b; + tri[2] = d; + tri[3] = a; + tri[4] = d; + tri[5] = c; + } + } + mesh = _addMesh(vertices, vertexCount, indices, indexCount, false); + SDL_free(vertices); + SDL_free(indices); + return mesh; +} + + int32_t meshPlane(float width, float depth) { SceneVertexT vertices[4]; uint32_t indices[6] = { 0, 1, 2, 0, 2, 3 }; @@ -2146,6 +2788,11 @@ uint32_t nodeGetGeneration(int32_t node) { // The node's mesh handle, or -1. +int32_t nodeGetMaterial(int32_t node) { + return nodeValid(node) ? _scene.nodes[node].material : NO_HANDLE; +} + + int32_t nodeGetMesh(int32_t node) { if (!nodeValid(node)) { return NO_HANDLE; @@ -2291,6 +2938,16 @@ bool nodeRotate(int32_t node, QuatT delta) { // mesh NO_HANDLE clears; material NO_HANDLE means the default look. +// Changes the material and keeps the mesh. +bool nodeSetMaterial(int32_t node, int32_t material) { + if (!nodeValid(node)) { + return false; + } + _scene.nodes[node].material = material; + return true; +} + + bool nodeSetMesh(int32_t node, int32_t mesh, int32_t material) { if (!nodeValid(node)) { return false; @@ -2382,6 +3039,18 @@ bool nodeSetScale(int32_t node, Vec3T scale) { // Drives the node's skinned mesh from other nodes: joints (up to 128) and their inverse bind // matrices, copied. count 0 removes the skin and the mesh draws unskinned. +// The joint nodes of a skinned node's skin, or 0 with none. +int32_t nodeGetSkinJoints(int32_t node, const int32_t **joints) { + if (!nodeValid(node) || (_scene.nodes[node].skinCount == 0)) { + return 0; + } + if (joints != NULL) { + *joints = _scene.nodes[node].skinJoints; + } + return _scene.nodes[node].skinCount; +} + + bool nodeSetSkin(int32_t node, const int32_t *joints, const Mat4T *inverseBind, int32_t count) { NodeT *n; @@ -2406,6 +3075,16 @@ bool nodeSetSkin(int32_t node, const int32_t *joints, const Mat4T *inverseBind, // Hides the node and everything under it. +// Whether the node's mesh is drawn into shadow maps; a bulb's own mesh or a glowing sign is not. +bool nodeSetShadow(int32_t node, bool casts) { + if (!nodeValid(node)) { + return false; + } + _scene.nodes[node].shadowCaster = casts; + return true; +} + + bool nodeSetVisible(int32_t node, bool visible) { if (!nodeValid(node)) { return false; @@ -2579,6 +3258,24 @@ void sceneQuit(void) { } } _destroyPipelines(); + for (x = 0; x < PARTICLE_PIPELINES; x++) { + if (_scene.particlePipelines[x] != NULL) { + SDL_ReleaseGPUGraphicsPipeline(_scene.device, _scene.particlePipelines[x]); + } + } + if (_scene.particleVertex != NULL) { + SDL_ReleaseGPUShader(_scene.device, _scene.particleVertex); + } + if (_scene.particleFragment != NULL) { + SDL_ReleaseGPUShader(_scene.device, _scene.particleFragment); + } + if (_scene.particleBuffer != NULL) { + SDL_ReleaseGPUBuffer(_scene.device, _scene.particleBuffer); + } + if (_scene.particleTransfer != NULL) { + SDL_ReleaseGPUTransferBuffer(_scene.device, _scene.particleTransfer); + } + _releaseParticleTextures(true); if (_scene.vertexStatic != NULL) { SDL_ReleaseGPUShader(_scene.device, _scene.vertexStatic); } @@ -2619,6 +3316,7 @@ void sceneQuit(void) { SDL_free(_scene.materials); SDL_free(_scene.feeds); SDL_free(_scene.draws); + SDL_free(_scene.particleVertices); memset(&_scene, 0, sizeof(_scene)); } @@ -2634,7 +3332,12 @@ SDL_Texture *sceneRender(void) { FragmentUniformsT fragmentUniforms; Mat4T identity = mat4Identity(); Mat4T view; + Mat4T inverseView; Vec3T eye; + Vec3T cameraRight = vec3(1.0f, 0.0f, 0.0f); + Vec3T cameraUp = vec3(0.0f, 1.0f, 0.0f); + Vec3T cameraForward = vec3(0.0f, 0.0f, -1.0f); + bool *skip = NULL; int32_t x; int32_t drawCount = 0; int32_t opaqueCount = 0; @@ -2662,6 +3365,13 @@ SDL_Texture *sceneRender(void) { fragmentUniforms.ambient[3] = 1.0f; _scene.shadowCount = 0; _fillLights(&fragmentUniforms); + // The camera's axes for particle billboards, and the frame's particles. + if (mat4Invert(view, &inverseView)) { + cameraRight = vec3Normalize(mat4TransformVector(inverseView, vec3(1.0f, 0.0f, 0.0f))); + cameraUp = vec3Normalize(mat4TransformVector(inverseView, vec3(0.0f, 1.0f, 0.0f))); + cameraForward = vec3Normalize(mat4TransformVector(inverseView, vec3(0.0f, 0.0f, -1.0f))); + } + _gatherParticles(eye, cameraForward); // Collect what to draw: opaque first in node order, then blended back to front. if (_scene.drawCapacity < _scene.nodeCount) { _scene.draws = SDL_realloc(_scene.draws, sizeof(DrawT) * (size_t)_scene.nodeCount); @@ -2701,11 +3411,16 @@ SDL_Texture *sceneRender(void) { utilTrace("Scene: %s", SDL_GetError()); return NULL; } + _uploadParticles(commands); // The shadow passes: depth from every shadow light into its map layer, or its six cube faces. for (slot = 0; slot < _scene.shadowCount; slot++) { layers += (_scene.shadows[slot].type == SHADOW_CUBE) ? CUBE_FACES : 1; } if ((_scene.shadowCount > 0) && (drawCount > 0) && _createShadowMaps(layers)) { + skip = SDL_calloc((size_t)drawCount, sizeof(bool)); + if (skip == NULL) { + utilDie("Out of memory culling shadow casters."); + } _fitShadows(drawCount); fragmentUniforms.shadowParams[0] = SHADOW_BIAS; fragmentUniforms.shadowParams[1] = 1.0f / (float)_scene.shadowSize; @@ -2724,11 +3439,23 @@ SDL_Texture *sceneRender(void) { fragmentUniforms.shadowInfo[slot][2] = shadow->near; fragmentUniforms.shadowInfo[slot][3] = shadow->far; if (shadow->type == SHADOW_CUBE) { + // Faces rendered last frame from the same light and casters are still good. + ShadowCacheT *cache = &_scene.shadowCache[slot]; + uint64_t hash = _cubeHash(shadow, drawCount); + + if ((hash != 0) && (cache->node == shadow->node) && (cache->layer == shadow->layer) && (cache->mapsVersion == _scene.shadowMapsVersion) && (cache->hash == hash)) { + continue; + } + cache->node = shadow->node; + cache->layer = shadow->layer; + cache->mapsVersion = _scene.shadowMapsVersion; + cache->hash = hash; for (face = 0; face < CUBE_FACES; face++) { + _cullFace(shadow, face, drawCount, skip); depth.texture = _scene.shadowMaps; depth.layer = (Uint8)(shadow->layer + face); pass = SDL_BeginGPURenderPass(commands, NULL, 0, &depth); - _drawList(commands, pass, drawCount, &shadow->faces[face], true, NULL); + _drawList(commands, pass, drawCount, &shadow->faces[face], true, true, skip, NULL); SDL_EndGPURenderPass(pass); } } else { @@ -2736,7 +3463,7 @@ SDL_Texture *sceneRender(void) { depth.texture = _scene.shadowMaps; depth.layer = (Uint8)shadow->layer; pass = SDL_BeginGPURenderPass(commands, NULL, 0, &depth); - _drawList(commands, pass, drawCount, &shadow->matrix, true, NULL); + _drawList(commands, pass, drawCount, &shadow->matrix, true, false, NULL, NULL); SDL_EndGPURenderPass(pass); } } @@ -2766,7 +3493,9 @@ SDL_Texture *sceneRender(void) { depth.stencil_load_op = SDL_GPU_LOADOP_DONT_CARE; depth.stencil_store_op = SDL_GPU_STOREOP_DONT_CARE; pass = SDL_BeginGPURenderPass(commands, &colour, 1, &depth); - _drawList(commands, pass, drawCount, &_scene.viewProjection, false, &fragmentUniforms); + _drawList(commands, pass, drawCount, &_scene.viewProjection, false, false, NULL, &fragmentUniforms); + _drawParticles(commands, pass, &_scene.viewProjection, cameraRight, cameraUp); + SDL_free(skip); SDL_EndGPURenderPass(pass); SDL_SubmitGPUCommandBuffer(commands); return _scene.composite; diff --git a/src/scene.h b/src/scene.h index f25ada4f6..97f5680e8 100644 --- a/src/scene.h +++ b/src/scene.h @@ -97,12 +97,15 @@ 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); +int32_t nodeGetMaterial(int32_t node); +bool meshSetPositions(int32_t mesh, const float *positions); bool meshGetGeometry(int32_t mesh, const float **positions, int32_t *vertexCount, const uint32_t **indices, int32_t *indexCount); int32_t meshFindMorph(int32_t mesh, const char *name); int32_t meshGetMorphCount(int32_t mesh); const char *meshGetMorphName(int32_t mesh, int32_t target); 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 meshGrid(float width, float depth, int32_t columns, int32_t rows); int32_t meshPlane(float width, float depth); int32_t meshSphere(float radius, int32_t segments); int32_t meshTorus(float radius, float tubeRadius, int32_t segments); @@ -128,6 +131,7 @@ bool nodeLookAt(int32_t node, Vec3T target); bool nodeMove(int32_t node, Vec3T delta); int32_t nodeNew(int32_t parent); bool nodeRotate(int32_t node, QuatT delta); +bool nodeSetMaterial(int32_t node, int32_t material); bool nodeSetMesh(int32_t node, int32_t mesh, int32_t material); bool nodeSetMorphWeight(int32_t node, int32_t target, float weight); bool nodeSetName(int32_t node, const char *name); @@ -135,7 +139,9 @@ bool nodeSetParent(int32_t node, int32_t parent); bool nodeSetPosition(int32_t node, Vec3T position); bool nodeSetRotation(int32_t node, QuatT rotation); bool nodeSetScale(int32_t node, Vec3T scale); +int32_t nodeGetSkinJoints(int32_t node, const int32_t **joints); bool nodeSetSkin(int32_t node, const int32_t *joints, const Mat4T *inverseBind, int32_t count); +bool nodeSetShadow(int32_t node, bool casts); bool nodeSetVisible(int32_t node, bool visible); bool nodeSetWorldTransform(int32_t node, Vec3T position, QuatT rotation); bool nodeValid(int32_t node); diff --git a/src/shaders/scene.hlsl b/src/shaders/scene.hlsl index 1500800ce..c8f3c70c8 100644 --- a/src/shaders/scene.hlsl +++ b/src/shaders/scene.hlsl @@ -177,13 +177,11 @@ float shadowFactorMap(int slot, float3 worldPosition, float3 normal, float3 toLi // major axis and looked up with the same frame the engine rendered it with (forward, up, right = // forward x up, a 90 degree perspective), so no cube-map convention is involved; the point's own // projected depth is compared over five taps. -float shadowFactorCube(int slot, float3 worldPosition, float3 normal, float3 lightPosition) { - float base = shadowInfo[slot].y; - float near = shadowInfo[slot].z; - float far = shadowInfo[slot].w; - float3 dir = (worldPosition + normal * shadowParams.x * 8.0) - lightPosition; - float3 ad = abs(dir); - float texel = shadowParams.y; +// One tap of a point light's shadow: the face is picked from the direction's major axis and the +// same frame the engine rendered it with is rebuilt, so a tap that crosses a face edge reads the +// neighbouring face instead of the wrong texels. +float cubeTap(float base, float near, float far, float bias, float3 dir) { + float3 ad = abs(dir); float ma; float face; float3 forward; @@ -191,7 +189,6 @@ float shadowFactorCube(int slot, float3 worldPosition, float3 normal, float3 lig float3 right; float2 uv; float depth; - float lit; if ((ad.x >= ad.y) && (ad.x >= ad.z)) { ma = ad.x; @@ -211,15 +208,39 @@ float shadowFactorCube(int slot, float3 worldPosition, float3 normal, float3 lig } right = cross(forward, up); uv = float2(0.5 + 0.5 * dot(dir, right) / ma, 0.5 - 0.5 * dot(dir, up) / ma); - depth = far / (far - near) - near * far / ((far - near) * ma) - shadowParams.x * 2.0; + depth = far / (far - near) - near * far / ((far - near) * ma) - bias; if (depth > 1.0) { return 1.0; } - lit = (shadowMaps.Sample(shadowSampler, float3(uv, base + face)).r >= depth) ? 1.0 : 0.0; - lit += (shadowMaps.Sample(shadowSampler, float3(uv + float2(texel, 0.0), base + face)).r >= depth) ? 1.0 : 0.0; - lit += (shadowMaps.Sample(shadowSampler, float3(uv - float2(texel, 0.0), base + face)).r >= depth) ? 1.0 : 0.0; - lit += (shadowMaps.Sample(shadowSampler, float3(uv + float2(0.0, texel), base + face)).r >= depth) ? 1.0 : 0.0; - lit += (shadowMaps.Sample(shadowSampler, float3(uv - float2(0.0, texel), base + face)).r >= depth) ? 1.0 : 0.0; + return (shadowMaps.Sample(shadowSampler, float3(uv, base + face)).r >= depth) ? 1.0 : 0.0; +} + + +// How lit a point is by a point light's six shadow faces: five taps, each choosing its own face. +// The normal offset grows with distance (a face's texel covers more world the farther it is) and +// the depth bias with the grazing angle, so surfaces near the bulb stay acne-free. +float shadowFactorCube(int slot, float3 worldPosition, float3 normal, float3 lightPosition) { + float base = shadowInfo[slot].y; + float near = shadowInfo[slot].z; + float far = shadowInfo[slot].w; + float texel = shadowParams.y; + float3 toPoint = worldPosition - lightPosition; + float distance = length(toPoint); + float3 toLight = -toPoint / max(distance, 0.0001); + float grazing = 1.0 - saturate(dot(normal, toLight)); + float texelWorld = 2.0 * distance * texel; + float3 dir = toPoint + normal * texelWorld * (1.5 + 2.0 * grazing); + float bias = shadowParams.x * (1.0 + 3.0 * grazing); + float3 axisA = normalize(cross(dir, (abs(dir.y) < 0.9) ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0))); + float3 axisB = normalize(cross(dir, axisA)); + float step = texelWorld; + float lit; + + lit = cubeTap(base, near, far, bias, dir); + lit += cubeTap(base, near, far, bias, dir + axisA * step); + lit += cubeTap(base, near, far, bias, dir - axisA * step); + lit += cubeTap(base, near, far, bias, dir + axisB * step); + lit += cubeTap(base, near, far, bias, dir - axisB * step); return lit / 5.0; } @@ -304,3 +325,48 @@ float4 fragmentMain(VertexOutput input) : SV_Target { } return float4(result, albedo.a); } + + +// ---- Particles: camera-facing billboards, unlit, one texture per draw ---- + +cbuffer ParticleUniforms : register(b0, space1) { + float4x4 particleViewProjection; + float4 cameraRight; + float4 cameraUp; +}; + +struct ParticleInput { + float3 centre : TEXCOORD0; + float2 corner : TEXCOORD1; // -1..1 across the quad + float2 sizeAngle : TEXCOORD2; // x = size (world units), y = rotation in degrees + float4 colour : TEXCOORD3; + float2 uv : TEXCOORD4; +}; + +struct ParticleOutput { + float4 position : SV_Position; + float4 colour : TEXCOORD0; + float2 uv : TEXCOORD1; +}; + +Texture2D particleTexture : register(t0, space2); +SamplerState particleSampler : register(s0, space2); + +ParticleOutput particleVertex(ParticleInput input) { + ParticleOutput output; + float radians = input.sizeAngle.y * 0.017453292; + float c = cos(radians); + float s = sin(radians); + float2 offset = float2(input.corner.x * c - input.corner.y * s, input.corner.x * s + input.corner.y * c) * input.sizeAngle.x * 0.5; + float3 world = input.centre + cameraRight.xyz * offset.x + cameraUp.xyz * offset.y; + + output.position = mul(particleViewProjection, float4(world, 1.0)); + output.colour = input.colour; + output.uv = input.uv; + return output; +} + +float4 particleFragment(ParticleOutput input) : SV_Target { + return particleTexture.Sample(particleSampler, input.uv) * input.colour; +} + diff --git a/src/singe.c b/src/singe.c index 40052b8bf..ce1fce376 100644 --- a/src/singe.c +++ b/src/singe.c @@ -61,6 +61,7 @@ LSEC_API int luaopen_ssl_config(lua_State *L); #include "scene.h" #include "model.h" #include "physics.h" +#include "particles.h" #include "videoPlayer.h" #include "singe.h" @@ -221,6 +222,15 @@ typedef struct MouseS { char name[64]; } MouseT; +// Renderer textures made from an emitter's frames for drawing 2D particles, refreshed when they change. +typedef struct ParticleTexturesS { + int32_t id; + uint32_t version; + int32_t count; + SDL_Texture **textures; + UT_hash_handle hh; +} ParticleTexturesT; + typedef struct SpriteS { int32_t id; IMG_Animation *animation; // NULL for still images @@ -333,6 +343,7 @@ typedef struct GlobalS { bool wasPlayingBeforePause; VideoT *videoList; SpriteT *spriteList; + ParticleTexturesT *particleTextures; // 2D particle textures by emitter SoundT *soundList; FontT *fontList; FontT *fontCurrent; @@ -439,10 +450,15 @@ static float *_argFloatTable(lua_State *L, const char *method, int32_t ind static FontT *_argFont(lua_State *L, const char *method, int32_t index); static int32_t _argInteger(lua_State *L, const char *method, int32_t index); static int32_t _argBody(lua_State *L, const char *method, int32_t index); +static int32_t _argEmitter(lua_State *L, const char *method, int32_t index); static int32_t _argMaterial(lua_State *L, const char *method, int32_t index); static int32_t _argMorph(lua_State *L, const char *method, int32_t node, int32_t index); static int32_t _argMesh(lua_State *L, const char *method, int32_t index); static int32_t _argNode(lua_State *L, const char *method, int32_t index); +static int32_t _argPlayer(lua_State *L, const char *method, int32_t index); +static int32_t _argRagdoll(lua_State *L, const char *method, int32_t index); +static int32_t _argSoft(lua_State *L, const char *method, int32_t index); +static int32_t _argVehicle(lua_State *L, const char *method, int32_t index); static int64_t _argInteger64(lua_State *L, const char *method, int32_t index); static double _argNumber(lua_State *L, const char *method, int32_t index); static SoundT *_argSound(lua_State *L, const char *method, int32_t index); @@ -460,6 +476,7 @@ static void _deliverKey(bool down, int32_t keysym, int32_t scancode); static void _discSeek(int64_t frame); static void _doLogos(void); static void _drawLine(int32_t x1, int32_t y1, int32_t x2, int32_t y2, uint32_t pixel); +static void _drawParticles2D(ParticleLayerE layer, const SDL_FRect *target); static void _drawPauseIndicator(const SDL_FRect *target); static InputE _engineSwitch(int32_t scancode); static void _fireMouseMoved(int32_t device, int32_t x, int32_t y, int32_t xr, int32_t yr); @@ -498,6 +515,7 @@ 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 void _particleTexturesFree(int32_t emitter); 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); @@ -539,11 +557,14 @@ static int32_t apiBodyIsResting(lua_State *L); static int32_t apiBodyNew(lua_State *L); static int32_t apiBodySetAngularVelocity(lua_State *L); static int32_t apiBodySetBounce(lua_State *L); +static int32_t apiBodySetBuoyancy(lua_State *L); +static int32_t apiBodySetCurrent(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 apiBodySetWater(lua_State *L); static int32_t apiCameraSet(lua_State *L); static int32_t apiCameraSetOrthographic(lua_State *L); static int32_t apiCameraSetPerspective(lua_State *L); @@ -574,6 +595,33 @@ static int32_t apiDiscSkipToFrame(lua_State *L); static int32_t apiDiscStepBackward(lua_State *L); static int32_t apiDiscStepForward(lua_State *L); static int32_t apiDiscStop(lua_State *L); +static int32_t apiEmitterBurst(lua_State *L); +static int32_t apiEmitterClear(lua_State *L); +static int32_t apiEmitterDelete(lua_State *L); +static int32_t apiEmitterDraw(lua_State *L); +static int32_t apiEmitterGetCount(lua_State *L); +static int32_t apiEmitterIsActive(lua_State *L); +static int32_t apiEmitterNew(lua_State *L); +static int32_t apiEmitterSetBlend(lua_State *L); +static int32_t apiEmitterSetColor(lua_State *L); +static int32_t apiEmitterSetDirection(lua_State *L); +static int32_t apiEmitterSetDrag(lua_State *L); +static int32_t apiEmitterSetFrames(lua_State *L); +static int32_t apiEmitterSetGravity(lua_State *L); +static int32_t apiEmitterSetLayer(lua_State *L); +static int32_t apiEmitterSetLife(lua_State *L); +static int32_t apiEmitterSetLocal(lua_State *L); +static int32_t apiEmitterSetMax(lua_State *L); +static int32_t apiEmitterSetPosition(lua_State *L); +static int32_t apiEmitterSetRadius(lua_State *L); +static int32_t apiEmitterSetRate(lua_State *L); +static int32_t apiEmitterSetSize(lua_State *L); +static int32_t apiEmitterSetSpeed(lua_State *L); +static int32_t apiEmitterSetSpin(lua_State *L); +static int32_t apiEmitterSetSpread(lua_State *L); +static int32_t apiEmitterSetTexture(lua_State *L); +static int32_t apiEmitterStart(lua_State *L); +static int32_t apiEmitterStop(lua_State *L); static int32_t apiFontLoad(lua_State *L); static int32_t apiFontPrint(lua_State *L); static int32_t apiFontQuality(lua_State *L); @@ -641,6 +689,7 @@ static int32_t apiNodeLookAt(lua_State *L); static int32_t apiNodeMove(lua_State *L); static int32_t apiNodeNew(lua_State *L); static int32_t apiNodeRotate(lua_State *L); +static int32_t apiNodeSetMaterial(lua_State *L); static int32_t apiNodeSetMesh(lua_State *L); static int32_t apiNodeSetMorph(lua_State *L); static int32_t apiNodeSetName(lua_State *L); @@ -649,6 +698,7 @@ static int32_t apiNodeSetPosition(lua_State *L); static int32_t apiNodeSetQuaternion(lua_State *L); static int32_t apiNodeSetRotation(lua_State *L); static int32_t apiNodeSetScale(lua_State *L); +static int32_t apiNodeSetShadow(lua_State *L); static int32_t apiNodeSetVisible(lua_State *L); static int32_t apiOsClock(lua_State *L); static int32_t apiOverlayBox(lua_State *L); @@ -665,6 +715,32 @@ static int32_t apiPhysicsRaycast(lua_State *L); static int32_t apiPhysicsSet2D(lua_State *L); static int32_t apiPhysicsSetEnabled(lua_State *L); static int32_t apiPhysicsSetGravity(lua_State *L); +static int32_t apiPlayerDelete(lua_State *L); +static int32_t apiPlayerGetGround(lua_State *L); +static int32_t apiPlayerGetVelocity(lua_State *L); +static int32_t apiPlayerIsOnGround(lua_State *L); +static int32_t apiPlayerIsSwimming(lua_State *L); +static int32_t apiPlayerJump(lua_State *L); +static int32_t apiPlayerMove(lua_State *L); +static int32_t apiPlayerNew(lua_State *L); +static int32_t apiPlayerSetEnabled(lua_State *L); +static int32_t apiPlayerSetGravityScale(lua_State *L); +static int32_t apiPlayerSetMass(lua_State *L); +static int32_t apiPlayerSetPosition(lua_State *L); +static int32_t apiPlayerSetPush(lua_State *L); +static int32_t apiPlayerSetSlope(lua_State *L); +static int32_t apiPlayerSetStep(lua_State *L); +static int32_t apiPlayerSetSwim(lua_State *L); +static int32_t apiPlayerSetVelocity(lua_State *L); +static int32_t apiRagdollActivate(lua_State *L); +static int32_t apiRagdollApplyImpulse(lua_State *L); +static int32_t apiRagdollDeactivate(lua_State *L); +static int32_t apiRagdollDelete(lua_State *L); +static int32_t apiRagdollIsActive(lua_State *L); +static int32_t apiRagdollIsResting(lua_State *L); +static int32_t apiRagdollNew(lua_State *L); +static int32_t apiRagdollSetJoint(lua_State *L); +static int32_t apiRagdollSetStrength(lua_State *L); static int32_t apiSceneEnable(lua_State *L); static int32_t apiSceneGetSize(lua_State *L); static int32_t apiSceneProject(lua_State *L); @@ -693,6 +769,14 @@ static int32_t apiSingeSetPauseFlag(lua_State *L); static int32_t apiSingeSetPauseKeyEnabled(lua_State *L); static int32_t apiSingeVersion(lua_State *L); static int32_t apiSingeWantsCrosshairs(lua_State *L); +static int32_t apiSoftDelete(lua_State *L); +static int32_t apiSoftNew(lua_State *L); +static int32_t apiSoftPin(lua_State *L); +static int32_t apiSoftSetDamping(lua_State *L); +static int32_t apiSoftSetMass(lua_State *L); +static int32_t apiSoftSetPressure(lua_State *L); +static int32_t apiSoftSetStiffness(lua_State *L); +static int32_t apiSoftUnpin(lua_State *L); static int32_t apiSoundFullStop(lua_State *L); static int32_t apiSoundGetVolume(lua_State *L); static int32_t apiSoundIsPlaying(lua_State *L); @@ -718,6 +802,24 @@ static int32_t apiSpriteRotateAndScale(lua_State *L); static int32_t apiSpriteScale(lua_State *L); static int32_t apiSpriteSetFrame(lua_State *L); static int32_t apiSpriteUnload(lua_State *L); +static int32_t apiVehicleAddWheel(lua_State *L); +static int32_t apiVehicleDelete(lua_State *L); +static int32_t apiVehicleDrive(lua_State *L); +static int32_t apiVehicleGetGear(lua_State *L); +static int32_t apiVehicleGetRpm(lua_State *L); +static int32_t apiVehicleGetSpeed(lua_State *L); +static int32_t apiVehicleGetWheelSlip(lua_State *L); +static int32_t apiVehicleIsWheelOnGround(lua_State *L); +static int32_t apiVehicleNew(lua_State *L); +static int32_t apiVehicleSetAntiRoll(lua_State *L); +static int32_t apiVehicleSetBrakes(lua_State *L); +static int32_t apiVehicleSetEngine(lua_State *L); +static int32_t apiVehicleSetGears(lua_State *L); +static int32_t apiVehicleSetSteering(lua_State *L); +static int32_t apiVehicleSetRudder(lua_State *L); +static int32_t apiVehicleSetSuspension(lua_State *L); +static int32_t apiVehicleSetThrust(lua_State *L); +static int32_t apiVehicleSetWheel(lua_State *L); static int32_t apiVideoDraw(lua_State *L); static int32_t apiVideoGetAudioTrack(lua_State *L); static int32_t apiVideoGetAudioTracks(lua_State *L); @@ -861,6 +963,17 @@ static int32_t _argBody(lua_State *L, const char *method, int32_t index) { } +// An emitter handle argument, checked. +static int32_t _argEmitter(lua_State *L, const char *method, int32_t index) { + int32_t emitter = _argInteger(L, method, index); + + if (!emitterValid(emitter)) { + _luaDie(L, method, "No emitter %d.", emitter); + } + return emitter; +} + + // A material handle argument, checked. static int32_t _argMaterial(lua_State *L, const char *method, int32_t index) { int32_t material = _argInteger(L, method, index); @@ -894,6 +1007,50 @@ static int32_t _argNode(lua_State *L, const char *method, int32_t index) { } +// A node carrying a player, checked. +static int32_t _argPlayer(lua_State *L, const char *method, int32_t index) { + int32_t node = _argNode(L, method, index); + + if (!playerExists(node)) { + _luaDie(L, method, "Node %d has no player.", node); + } + return node; +} + + +// A node carrying a ragdoll, checked. +static int32_t _argRagdoll(lua_State *L, const char *method, int32_t index) { + int32_t node = _argNode(L, method, index); + + if (!ragdollExists(node)) { + _luaDie(L, method, "Node %d has no ragdoll.", node); + } + return node; +} + + +// A node carrying a soft body, checked. +static int32_t _argSoft(lua_State *L, const char *method, int32_t index) { + int32_t node = _argNode(L, method, index); + + if (!softExists(node)) { + _luaDie(L, method, "Node %d has no soft body.", node); + } + return node; +} + + +// A node carrying a vehicle, checked. +static int32_t _argVehicle(lua_State *L, const char *method, int32_t index) { + int32_t node = _argNode(L, method, index); + + if (!vehicleExists(node)) { + _luaDie(L, method, "Node %d has no vehicle.", node); + } + return node; +} + + static int64_t _argInteger64(lua_State *L, const char *method, int32_t index) { return (int64_t)_argNumber(L, method, index); } @@ -1374,6 +1531,113 @@ static void _drawLine(int32_t x1, int32_t y1, int32_t x2, int32_t y2, uint32_t p // Draws the PAUSED indicator, built from the console font on first use, centered on the target. +// Draws the 2D emitters queued this frame on one layer: a textured, tinted, rotated quad per particle +// through SDL_RenderGeometry, in overlay pixels mapped onto the window target. +static void _drawParticles2D(ParticleLayerE layer, const SDL_FRect *target) { + int32_t emitters[64]; + int32_t n = particlesQueued2D(layer, emitters, 64); + float scaleX = target->w / (float)_global.overlay->w; + float scaleY = target->h / (float)_global.overlay->h; + SDL_Vertex *vertices = NULL; + int32_t *indices = NULL; + ParticleTexturesT *cache = NULL; + EmitterViewT view; + ParticleViewT *particle; + SDL_FColor colour; + float c; + float s; + float half; + int32_t e; + int32_t i; + int32_t f; + int32_t frame; + int32_t run; + + for (e = 0; e < n; e++) { + if (!particlesViewEmitter(emitters[e], &view) || (view.count == 0)) { + continue; + } + // Textures for the emitter's frames, made or remade when the frames change. + HASH_FIND_INT(_global.particleTextures, &view.id, cache); + if ((cache != NULL) && (cache->version != view.textureVersion)) { + _particleTexturesFree(view.id); + cache = NULL; + } + if (cache == NULL) { + cache = SDL_calloc(1, sizeof(ParticleTexturesT)); + if (cache == NULL) { + utilDie("Out of memory for particle textures."); + } + cache->id = view.id; + cache->version = view.textureVersion; + cache->count = view.frameCount; + cache->textures = SDL_calloc((size_t)view.frameCount, sizeof(SDL_Texture *)); + if (cache->textures == NULL) { + utilDie("Out of memory for particle textures."); + } + for (f = 0; f < view.frameCount; f++) { + cache->textures[f] = SDL_CreateTextureFromSurface(_global.renderer, view.frames[f]); + if (cache->textures[f] == NULL) { + utilDie("%s", SDL_GetError()); + } + SDL_SetTextureScaleMode(cache->textures[f], SDL_SCALEMODE_LINEAR); + } + HASH_ADD_INT(_global.particleTextures, id, cache); + } + for (f = 0; f < cache->count; f++) { + SDL_SetTextureBlendMode(cache->textures[f], (view.blend == PARTICLE_ADD) ? SDL_BLENDMODE_ADD : SDL_BLENDMODE_BLEND); + } + vertices = SDL_malloc((size_t)view.count * 4 * sizeof(SDL_Vertex)); + indices = SDL_malloc((size_t)view.count * 6 * sizeof(int32_t)); + if ((vertices == NULL) || (indices == NULL)) { + utilDie("Out of memory drawing particles."); + } + // One geometry call per run of particles sharing a frame texture. + for (frame = 0; frame < cache->count; frame++) { + run = 0; + for (i = 0; i < view.count; i++) { + particle = &view.particles[i]; + if ((particle->frame != frame) && (cache->count > 1)) { + continue; + } + c = SDL_cosf(particle->angle * (SDL_PI_F / 180.0f)); + s = SDL_sinf(particle->angle * (SDL_PI_F / 180.0f)); + half = particle->size * 0.5f; + colour.r = particle->colour[0]; + colour.g = particle->colour[1]; + colour.b = particle->colour[2]; + colour.a = particle->colour[3]; + for (f = 0; f < 4; f++) { + float dx = ((f == 1) || (f == 2)) ? half : -half; + float dy = (f >= 2) ? half : -half; + + vertices[run * 4 + f].position.x = target->x + (particle->position.x + dx * c - dy * s) * scaleX; + vertices[run * 4 + f].position.y = target->y + (particle->position.y + dx * s + dy * c) * scaleY; + vertices[run * 4 + f].color = colour; + vertices[run * 4 + f].tex_coord.x = ((f == 1) || (f == 2)) ? 1.0f : 0.0f; + vertices[run * 4 + f].tex_coord.y = (f >= 2) ? 1.0f : 0.0f; + } + indices[run * 6 + 0] = run * 4 + 0; + indices[run * 6 + 1] = run * 4 + 1; + indices[run * 6 + 2] = run * 4 + 2; + indices[run * 6 + 3] = run * 4 + 0; + indices[run * 6 + 4] = run * 4 + 2; + indices[run * 6 + 5] = run * 4 + 3; + run++; + } + if (run > 0) { + SDL_RenderGeometry(_global.renderer, cache->textures[frame], vertices, run * 4, indices, run * 6); + } + if (cache->count == 1) { + break; + } + } + SDL_free(vertices); + SDL_free(indices); + } +} + + static void _drawPauseIndicator(const SDL_FRect *target) { SDL_Surface *text = NULL; SDL_Rect src; @@ -2145,6 +2409,23 @@ 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. +// Frees the renderer textures kept for an emitter's 2D drawing. +static void _particleTexturesFree(int32_t emitter) { + ParticleTexturesT *cache = NULL; + int32_t f; + + HASH_FIND_INT(_global.particleTextures, &emitter, cache); + if (cache != NULL) { + HASH_DEL(_global.particleTextures, cache); + for (f = 0; f < cache->count; f++) { + SDL_DestroyTexture(cache->textures[f]); + } + SDL_free(cache->textures); + SDL_free(cache); + } +} + + static void _physicsCallbacks(void) { PhysicsEventT events[64]; int32_t count; @@ -2261,6 +2542,31 @@ static void _pushConstants(lua_State *L) { lua_setglobal(L, "JOINT_BALL"); lua_pushinteger(L, JOINT_SLIDER); lua_setglobal(L, "JOINT_SLIDER"); + // Particles + lua_pushinteger(L, PARTICLE_ALPHA); + lua_setglobal(L, "PARTICLE_ALPHA"); + lua_pushinteger(L, PARTICLE_ADD); + lua_setglobal(L, "PARTICLE_ADD"); + lua_pushinteger(L, PARTICLE_OVER); + lua_setglobal(L, "PARTICLE_OVER"); + lua_pushinteger(L, PARTICLE_UNDER); + lua_setglobal(L, "PARTICLE_UNDER"); + // Vehicles + lua_pushinteger(L, VEHICLE_CAR); + lua_setglobal(L, "VEHICLE_CAR"); + lua_pushinteger(L, VEHICLE_MOTORCYCLE); + lua_setglobal(L, "VEHICLE_MOTORCYCLE"); + lua_pushinteger(L, VEHICLE_TANK); + lua_setglobal(L, "VEHICLE_TANK"); + lua_pushinteger(L, VEHICLE_BOAT); + lua_setglobal(L, "VEHICLE_BOAT"); + // Soft bodies + lua_pushinteger(L, SOFT_CLOTH); + lua_setglobal(L, "SOFT_CLOTH"); + lua_pushinteger(L, SOFT_BODY); + lua_setglobal(L, "SOFT_BODY"); + lua_pushinteger(L, SOFT_ROPE); + lua_setglobal(L, "SOFT_ROPE"); lua_pushinteger(L, -1); lua_setglobal(L, "SOUND_ERROR_INVALID"); @@ -2931,6 +3237,22 @@ static int32_t apiBodySetBounce(lua_State *L) { // bodySetEnabled(node, bool): out of the world and back +// bodySetBuoyancy(node, factor): 1 floats neutrally in water, more floats, less sinks +static int32_t apiBodySetBuoyancy(lua_State *L) { + _argCheck(L, "bodySetBuoyancy", 2, 2); + bodySetBuoyancy(_argBody(L, "bodySetBuoyancy", 1), (float)_argNumber(L, "bodySetBuoyancy", 2)); + return 0; +} + + +// bodySetCurrent(node, x, y, z): the flow inside a water volume +static int32_t apiBodySetCurrent(lua_State *L) { + _argCheck(L, "bodySetCurrent", 4, 4); + bodySetCurrent(_argBody(L, "bodySetCurrent", 1), _argVec3(L, "bodySetCurrent", 2)); + return 0; +} + + static int32_t apiBodySetEnabled(lua_State *L) { _argCheck(L, "bodySetEnabled", 2, 2); bodySetEnabled(_argBody(L, "bodySetEnabled", 1), _argBoolean(L, "bodySetEnabled", 2)); @@ -2976,6 +3298,16 @@ static int32_t apiBodySetVelocity(lua_State *L) { // Any node can be the camera (it looks down its own -Z); -1 restores the default view. +// bodySetWater(node, density, linearDrag, angularDrag): a static body becomes a water volume +static int32_t apiBodySetWater(lua_State *L) { + _argCheck(L, "bodySetWater", 2, 4); + if (!bodySetWater(_argBody(L, "bodySetWater", 1), (float)_argNumber(L, "bodySetWater", 2), (lua_gettop(L) >= 3) ? (float)_argNumber(L, "bodySetWater", 3) : 0.5f, (lua_gettop(L) >= 4) ? (float)_argNumber(L, "bodySetWater", 4) : 0.1f)) { + _luaDie(L, "bodySetWater", "Water needs a static body."); + } + return 0; +} + + static int32_t apiCameraSet(lua_State *L) { int32_t node; @@ -3415,6 +3747,268 @@ static int32_t apiDiscStop(lua_State *L) { // id = fontLoad(filename, points) The new font becomes current. +static int32_t apiEmitterBurst(lua_State *L) { + _argCheck(L, "emitterBurst", 2, 2); + emitterBurst(_argEmitter(L, "emitterBurst", 1), _argInteger(L, "emitterBurst", 2)); + return 0; +} + + +static int32_t apiEmitterClear(lua_State *L) { + _argCheck(L, "emitterClear", 1, 1); + emitterClear(_argEmitter(L, "emitterClear", 1)); + return 0; +} + + +static int32_t apiEmitterDelete(lua_State *L) { + int32_t emitter = 0; + + _argCheck(L, "emitterDelete", 1, 1); + emitter = _argEmitter(L, "emitterDelete", 1); + _particleTexturesFree(emitter); + emitterDelete(emitter); + return 0; +} + + +static int32_t apiEmitterDraw(lua_State *L) { + int32_t emitter = 0; + + _argCheck(L, "emitterDraw", 1, 1); + emitter = _argEmitter(L, "emitterDraw", 1); + if (emitterIs3D(emitter)) { + _luaDie(L, "emitterDraw", "Emitter %d is a 3D emitter; it draws itself in the scene.", emitter); + } + particlesQueue2D(emitter); + return 0; +} + + +static int32_t apiEmitterGetCount(lua_State *L) { + _argCheck(L, "emitterGetCount", 1, 1); + lua_pushinteger(L, emitterGetCount(_argEmitter(L, "emitterGetCount", 1))); + return 1; +} + + +static int32_t apiEmitterIsActive(lua_State *L) { + _argCheck(L, "emitterIsActive", 1, 1); + lua_pushboolean(L, emitterIsActive(_argEmitter(L, "emitterIsActive", 1))); + return 1; +} + + +static int32_t apiEmitterNew(lua_State *L) { + int32_t node = -1; + + _argCheck(L, "emitterNew", 0, 1); + if (lua_gettop(L) == 1) { + node = _argNode(L, "emitterNew", 1); + if (!sceneAvailable()) { + _luaDie(L, "emitterNew", "3D particles need the 3D scene, which this machine cannot provide."); + } + } + lua_pushinteger(L, emitterNew(node)); + return 1; +} + + +static int32_t apiEmitterSetBlend(lua_State *L) { + int32_t blend = 0; + + _argCheck(L, "emitterSetBlend", 2, 2); + blend = _argInteger(L, "emitterSetBlend", 2); + if ((blend != PARTICLE_ALPHA) && (blend != PARTICLE_ADD)) { + _luaDie(L, "emitterSetBlend", "Blend must be PARTICLE_ALPHA or PARTICLE_ADD."); + } + emitterSetBlend(_argEmitter(L, "emitterSetBlend", 1), (ParticleBlendE)blend); + return 0; +} + + +static int32_t apiEmitterSetColor(lua_State *L) { + float start[4]; + float finish[4]; + int32_t c; + + _argCheck(L, "emitterSetColor", 9, 9); + for (c = 0; c < 4; c++) { + start[c] = SDL_clamp((float)_argNumber(L, "emitterSetColor", 2 + c) / 255.0f, 0.0f, 1.0f); + finish[c] = SDL_clamp((float)_argNumber(L, "emitterSetColor", 6 + c) / 255.0f, 0.0f, 1.0f); + } + emitterSetColor(_argEmitter(L, "emitterSetColor", 1), start, finish); + return 0; +} + + +static int32_t apiEmitterSetDirection(lua_State *L) { + int32_t emitter = 0; + Vec3T direction; + + _argCheck(L, "emitterSetDirection", 3, 4); + emitter = _argEmitter(L, "emitterSetDirection", 1); + direction = vec3((float)_argNumber(L, "emitterSetDirection", 2), (float)_argNumber(L, "emitterSetDirection", 3), (lua_gettop(L) == 4) ? (float)_argNumber(L, "emitterSetDirection", 4) : 0.0f); + emitterSetDirection(emitter, direction); + return 0; +} + + +static int32_t apiEmitterSetDrag(lua_State *L) { + _argCheck(L, "emitterSetDrag", 2, 2); + emitterSetDrag(_argEmitter(L, "emitterSetDrag", 1), (float)_argNumber(L, "emitterSetDrag", 2)); + return 0; +} + + +static int32_t apiEmitterSetFrames(lua_State *L) { + _argCheck(L, "emitterSetFrames", 3, 3); + emitterSetFrames(_argEmitter(L, "emitterSetFrames", 1), _argInteger(L, "emitterSetFrames", 2), _argInteger(L, "emitterSetFrames", 3)); + return 0; +} + + +static int32_t apiEmitterSetGravity(lua_State *L) { + int32_t emitter = 0; + Vec3T gravity; + + _argCheck(L, "emitterSetGravity", 3, 4); + emitter = _argEmitter(L, "emitterSetGravity", 1); + gravity = vec3((float)_argNumber(L, "emitterSetGravity", 2), (float)_argNumber(L, "emitterSetGravity", 3), (lua_gettop(L) == 4) ? (float)_argNumber(L, "emitterSetGravity", 4) : 0.0f); + emitterSetGravity(emitter, gravity); + return 0; +} + + +static int32_t apiEmitterSetLayer(lua_State *L) { + int32_t layer = 0; + + _argCheck(L, "emitterSetLayer", 2, 2); + layer = _argInteger(L, "emitterSetLayer", 2); + if ((layer != PARTICLE_OVER) && (layer != PARTICLE_UNDER)) { + _luaDie(L, "emitterSetLayer", "Layer must be PARTICLE_OVER or PARTICLE_UNDER."); + } + emitterSetLayer(_argEmitter(L, "emitterSetLayer", 1), (ParticleLayerE)layer); + return 0; +} + + +static int32_t apiEmitterSetLife(lua_State *L) { + _argCheck(L, "emitterSetLife", 3, 3); + emitterSetLife(_argEmitter(L, "emitterSetLife", 1), (float)_argNumber(L, "emitterSetLife", 2), (float)_argNumber(L, "emitterSetLife", 3)); + return 0; +} + + +static int32_t apiEmitterSetLocal(lua_State *L) { + _argCheck(L, "emitterSetLocal", 2, 2); + emitterSetLocal(_argEmitter(L, "emitterSetLocal", 1), _argBoolean(L, "emitterSetLocal", 2)); + return 0; +} + + +static int32_t apiEmitterSetMax(lua_State *L) { + int32_t count = 0; + + _argCheck(L, "emitterSetMax", 2, 2); + count = _argInteger(L, "emitterSetMax", 2); + if (count < 1) { + _luaDie(L, "emitterSetMax", "An emitter needs room for at least one particle."); + } + emitterSetMax(_argEmitter(L, "emitterSetMax", 1), count); + return 0; +} + + +static int32_t apiEmitterSetPosition(lua_State *L) { + int32_t emitter = 0; + + _argCheck(L, "emitterSetPosition", 3, 3); + emitter = _argEmitter(L, "emitterSetPosition", 1); + if (emitterIs3D(emitter)) { + _luaDie(L, "emitterSetPosition", "Emitter %d follows its node; move the node instead.", emitter); + } + emitterSetPosition(emitter, vec3((float)_argNumber(L, "emitterSetPosition", 2), (float)_argNumber(L, "emitterSetPosition", 3), 0.0f)); + return 0; +} + + +static int32_t apiEmitterSetRadius(lua_State *L) { + _argCheck(L, "emitterSetRadius", 2, 2); + emitterSetRadius(_argEmitter(L, "emitterSetRadius", 1), (float)_argNumber(L, "emitterSetRadius", 2)); + return 0; +} + + +static int32_t apiEmitterSetRate(lua_State *L) { + _argCheck(L, "emitterSetRate", 2, 2); + emitterSetRate(_argEmitter(L, "emitterSetRate", 1), (float)_argNumber(L, "emitterSetRate", 2)); + return 0; +} + + +static int32_t apiEmitterSetSize(lua_State *L) { + _argCheck(L, "emitterSetSize", 3, 4); + emitterSetSize(_argEmitter(L, "emitterSetSize", 1), (float)_argNumber(L, "emitterSetSize", 2), (float)_argNumber(L, "emitterSetSize", 3), (lua_gettop(L) == 4) ? (float)_argNumber(L, "emitterSetSize", 4) : 0.0f); + return 0; +} + + +static int32_t apiEmitterSetSpeed(lua_State *L) { + _argCheck(L, "emitterSetSpeed", 3, 3); + emitterSetSpeed(_argEmitter(L, "emitterSetSpeed", 1), (float)_argNumber(L, "emitterSetSpeed", 2), (float)_argNumber(L, "emitterSetSpeed", 3)); + return 0; +} + + +static int32_t apiEmitterSetSpin(lua_State *L) { + _argCheck(L, "emitterSetSpin", 3, 3); + emitterSetSpin(_argEmitter(L, "emitterSetSpin", 1), (float)_argNumber(L, "emitterSetSpin", 2), (float)_argNumber(L, "emitterSetSpin", 3)); + return 0; +} + + +static int32_t apiEmitterSetSpread(lua_State *L) { + _argCheck(L, "emitterSetSpread", 2, 2); + emitterSetSpread(_argEmitter(L, "emitterSetSpread", 1), (float)_argNumber(L, "emitterSetSpread", 2)); + return 0; +} + + +static int32_t apiEmitterSetTexture(lua_State *L) { + int32_t emitter = 0; + SpriteT *sprite = NULL; + + _argCheck(L, "emitterSetTexture", 1, 2); + emitter = _argEmitter(L, "emitterSetTexture", 1); + if ((lua_gettop(L) == 2) && !lua_isnil(L, 2)) { + sprite = _argSprite(L, "emitterSetTexture", 2); + if (sprite->animation != NULL) { + emitterSetTexture(emitter, sprite->animation->frames, sprite->animation->count); + } else { + emitterSetTexture(emitter, &sprite->originalSurface, 1); + } + } else { + emitterSetTexture(emitter, NULL, 0); + } + return 0; +} + + +static int32_t apiEmitterStart(lua_State *L) { + _argCheck(L, "emitterStart", 1, 1); + emitterStart(_argEmitter(L, "emitterStart", 1)); + return 0; +} + + +static int32_t apiEmitterStop(lua_State *L) { + _argCheck(L, "emitterStop", 1, 1); + emitterStop(_argEmitter(L, "emitterStop", 1)); + return 0; +} + + static int32_t apiFontLoad(lua_State *L) { const char *name = NULL; int32_t points = 0; @@ -4025,8 +4619,16 @@ static int32_t apiMeshNew(lua_State *L) { static int32_t apiMeshPlane(lua_State *L) { int32_t mesh; - _argCheck(L, "meshPlane", 2, 2); - mesh = meshPlane((float)_argNumber(L, "meshPlane", 1), (float)_argNumber(L, "meshPlane", 2)); + _argCheck(L, "meshPlane", 2, 4); + if (lua_gettop(L) >= 3) { + // meshPlane(width, depth, columns[, rows]): subdivided, for cloth. + int32_t columns = _argInteger(L, "meshPlane", 3); + int32_t rows = (lua_gettop(L) == 4) ? _argInteger(L, "meshPlane", 4) : columns; + + mesh = meshGrid((float)_argNumber(L, "meshPlane", 1), (float)_argNumber(L, "meshPlane", 2), columns, rows); + } else { + mesh = meshPlane((float)_argNumber(L, "meshPlane", 1), (float)_argNumber(L, "meshPlane", 2)); + } if (mesh < 0) { _luaDie(L, "meshPlane", "Unable to create the mesh."); } @@ -4414,6 +5016,19 @@ static int32_t apiNodeRotate(lua_State *L) { // nodeSetMesh(node, mesh [, material]) +// nodeSetMaterial(node, material): a new material on whatever mesh the node has (nil for none) +static int32_t apiNodeSetMaterial(lua_State *L) { + int32_t material = -1; + + _argCheck(L, "nodeSetMaterial", 2, 2); + if (!lua_isnil(L, 2)) { + material = _argMaterial(L, "nodeSetMaterial", 2); + } + nodeSetMaterial(_argNode(L, "nodeSetMaterial", 1), material); + return 0; +} + + static int32_t apiNodeSetMesh(lua_State *L) { int32_t node; int32_t mesh; @@ -4515,6 +5130,14 @@ static int32_t apiNodeSetScale(lua_State *L) { } +// nodeSetShadow(node, bool): whether the node's mesh casts shadows +static int32_t apiNodeSetShadow(lua_State *L) { + _argCheck(L, "nodeSetShadow", 2, 2); + nodeSetShadow(_argNode(L, "nodeSetShadow", 1), _argBoolean(L, "nodeSetShadow", 2)); + return 0; +} + + // nodeSetVisible(node, bool): hides the node and its children static int32_t apiNodeSetVisible(lua_State *L) { _argCheck(L, "nodeSetVisible", 2, 2); @@ -4882,6 +5505,254 @@ static int32_t apiPhysicsSetGravity(lua_State *L) { // scriptExecute(config) Runs another script after this one ends. // Turns the 3D layer on or off. +static int32_t apiPlayerDelete(lua_State *L) { + _argCheck(L, "playerDelete", 1, 1); + playerDelete(_argPlayer(L, "playerDelete", 1)); + return 0; +} + + +// ground, nx, ny, nz = playerGetGround(node): the node stood on (nil in the air) and the normal. +static int32_t apiPlayerGetGround(lua_State *L) { + Vec3T normal; + int32_t ground; + + _argCheck(L, "playerGetGround", 1, 1); + ground = playerGetGround(_argPlayer(L, "playerGetGround", 1), &normal); + if (ground < 0) { + lua_pushnil(L); + } else { + lua_pushinteger(L, ground); + } + _pushVec3(L, normal); + return 4; +} + + +static int32_t apiPlayerGetVelocity(lua_State *L) { + _argCheck(L, "playerGetVelocity", 1, 1); + _pushVec3(L, playerGetVelocity(_argPlayer(L, "playerGetVelocity", 1))); + return 3; +} + + +static int32_t apiPlayerIsOnGround(lua_State *L) { + _argCheck(L, "playerIsOnGround", 1, 1); + lua_pushboolean(L, playerIsOnGround(_argPlayer(L, "playerIsOnGround", 1))); + return 1; +} + + +static int32_t apiPlayerIsSwimming(lua_State *L) { + _argCheck(L, "playerIsSwimming", 1, 1); + lua_pushboolean(L, playerIsSwimming(_argPlayer(L, "playerIsSwimming", 1))); + return 1; +} + + +static int32_t apiPlayerJump(lua_State *L) { + _argCheck(L, "playerJump", 2, 2); + lua_pushboolean(L, playerJump(_argPlayer(L, "playerJump", 1), (float)_argNumber(L, "playerJump", 2))); + return 1; +} + + +// playerMove(node, vx, vz), playerMove(node, vx, vy, vz) when swimming, or in a 2D world playerMove(node, vx) +static int32_t apiPlayerMove(lua_State *L) { + int32_t node = 0; + float vx = 0.0f; + float vy = 0.0f; + float vz = 0.0f; + + _argCheck(L, "playerMove", 2, 4); + node = _argPlayer(L, "playerMove", 1); + vx = (float)_argNumber(L, "playerMove", 2); + if (lua_gettop(L) == 3) { + vz = (float)_argNumber(L, "playerMove", 3); + } else if (lua_gettop(L) == 4) { + vy = (float)_argNumber(L, "playerMove", 3); + vz = (float)_argNumber(L, "playerMove", 4); + } + playerMove(node, vec3(vx, vy, vz)); + return 0; +} + + +// playerNew(node, radius, height) for a capsule, or playerNew(node, SHAPE_*, sizes...) +static int32_t apiPlayerNew(lua_State *L) { + int32_t node; + int32_t shape = SHAPE_CAPSULE; + float dims[3] = { 0.0f, 0.0f, 0.0f }; + int32_t first = 2; + int32_t x; + + _argCheck(L, "playerNew", 2, 5); + node = _argNode(L, "playerNew", 1); + if (lua_isinteger(L, 2) && (lua_tointeger(L, 2) >= SHAPE_BOX) && (lua_tointeger(L, 2) <= SHAPE_MESH) && (lua_gettop(L) != 3)) { + shape = (int32_t)lua_tointeger(L, 2); + first = 3; + } + for (x = 0; x < 3; x++) { + if (lua_gettop(L) >= first + x) { + dims[x] = (float)_argNumber(L, "playerNew", first + x); + } + } + if (shape == SHAPE_MESH) { + _luaDie(L, "playerNew", "A player needs a convex shape; use SHAPE_HULL."); + } + if (!physicsAvailable()) { + _luaDie(L, "playerNew", "Physics is not available on this machine."); + } + if (!playerNew(node, (ShapeTypeE)shape, dims[0], dims[1], dims[2])) { + _luaDie(L, "playerNew", "Unable to create the player."); + } + _luaTrace(L, "playerNew", "node %d shape %d", node, shape); + return 0; +} + + +static int32_t apiPlayerSetEnabled(lua_State *L) { + _argCheck(L, "playerSetEnabled", 2, 2); + playerSetEnabled(_argPlayer(L, "playerSetEnabled", 1), _argBoolean(L, "playerSetEnabled", 2)); + return 0; +} + + +static int32_t apiPlayerSetGravityScale(lua_State *L) { + _argCheck(L, "playerSetGravityScale", 2, 2); + playerSetGravityScale(_argPlayer(L, "playerSetGravityScale", 1), (float)_argNumber(L, "playerSetGravityScale", 2)); + return 0; +} + + +static int32_t apiPlayerSetMass(lua_State *L) { + _argCheck(L, "playerSetMass", 2, 2); + playerSetMass(_argPlayer(L, "playerSetMass", 1), (float)_argNumber(L, "playerSetMass", 2)); + return 0; +} + + +static int32_t apiPlayerSetPosition(lua_State *L) { + _argCheck(L, "playerSetPosition", 4, 4); + playerSetPosition(_argPlayer(L, "playerSetPosition", 1), _argVec3(L, "playerSetPosition", 2)); + return 0; +} + + +static int32_t apiPlayerSetPush(lua_State *L) { + _argCheck(L, "playerSetPush", 2, 2); + playerSetPush(_argPlayer(L, "playerSetPush", 1), (float)_argNumber(L, "playerSetPush", 2)); + return 0; +} + + +static int32_t apiPlayerSetSlope(lua_State *L) { + _argCheck(L, "playerSetSlope", 2, 2); + playerSetSlope(_argPlayer(L, "playerSetSlope", 1), (float)_argNumber(L, "playerSetSlope", 2)); + return 0; +} + + +static int32_t apiPlayerSetStep(lua_State *L) { + _argCheck(L, "playerSetStep", 2, 2); + playerSetStep(_argPlayer(L, "playerSetStep", 1), (float)_argNumber(L, "playerSetStep", 2)); + return 0; +} + + +// playerSetSwim(node, sinkSpeed, drag): how the player behaves in water +static int32_t apiPlayerSetSwim(lua_State *L) { + _argCheck(L, "playerSetSwim", 3, 3); + playerSetSwim(_argPlayer(L, "playerSetSwim", 1), (float)_argNumber(L, "playerSetSwim", 2), (float)_argNumber(L, "playerSetSwim", 3)); + return 0; +} + + +static int32_t apiPlayerSetVelocity(lua_State *L) { + _argCheck(L, "playerSetVelocity", 4, 4); + playerSetVelocity(_argPlayer(L, "playerSetVelocity", 1), _argVec3(L, "playerSetVelocity", 2)); + return 0; +} + + +static int32_t apiRagdollActivate(lua_State *L) { + _argCheck(L, "ragdollActivate", 1, 1); + if (!ragdollActivate(_argRagdoll(L, "ragdollActivate", 1))) { + _luaDie(L, "ragdollActivate", "Unable to build the ragdoll's bodies."); + } + return 0; +} + + +// ragdollApplyImpulse(node, jointName, x, y, z) +static int32_t apiRagdollApplyImpulse(lua_State *L) { + _argCheck(L, "ragdollApplyImpulse", 5, 5); + lua_pushboolean(L, ragdollApplyImpulse(_argRagdoll(L, "ragdollApplyImpulse", 1), _argString(L, "ragdollApplyImpulse", 2), _argVec3(L, "ragdollApplyImpulse", 3))); + return 1; +} + + +static int32_t apiRagdollDeactivate(lua_State *L) { + _argCheck(L, "ragdollDeactivate", 1, 1); + ragdollDeactivate(_argRagdoll(L, "ragdollDeactivate", 1)); + return 0; +} + + +static int32_t apiRagdollDelete(lua_State *L) { + _argCheck(L, "ragdollDelete", 1, 1); + ragdollDelete(_argRagdoll(L, "ragdollDelete", 1)); + return 0; +} + + +static int32_t apiRagdollIsActive(lua_State *L) { + _argCheck(L, "ragdollIsActive", 1, 1); + lua_pushboolean(L, ragdollIsActive(_argRagdoll(L, "ragdollIsActive", 1))); + return 1; +} + + +static int32_t apiRagdollIsResting(lua_State *L) { + _argCheck(L, "ragdollIsResting", 1, 1); + lua_pushboolean(L, ragdollIsResting(_argRagdoll(L, "ragdollIsResting", 1))); + return 1; +} + + +// ragdollNew(node): node is a model instance with a skin +static int32_t apiRagdollNew(lua_State *L) { + int32_t node = 0; + + _argCheck(L, "ragdollNew", 1, 1); + node = _argNode(L, "ragdollNew", 1); + if (!physicsAvailable()) { + _luaDie(L, "ragdollNew", "Physics is not available on this machine."); + } + if (!ragdollNew(node)) { + _luaDie(L, "ragdollNew", "Node %d has no skinned model with bones under it.", node); + } + return 0; +} + + +// ragdollSetJoint(node, jointName, radius, swingDegrees, twistDegrees) +static int32_t apiRagdollSetJoint(lua_State *L) { + _argCheck(L, "ragdollSetJoint", 5, 5); + if (!ragdollSetJoint(_argRagdoll(L, "ragdollSetJoint", 1), _argString(L, "ragdollSetJoint", 2), (float)_argNumber(L, "ragdollSetJoint", 3), (float)_argNumber(L, "ragdollSetJoint", 4), (float)_argNumber(L, "ragdollSetJoint", 5))) { + _luaDie(L, "ragdollSetJoint", "No bone by that name."); + } + return 0; +} + + +static int32_t apiRagdollSetStrength(lua_State *L) { + _argCheck(L, "ragdollSetStrength", 2, 2); + ragdollSetStrength(_argRagdoll(L, "ragdollSetStrength", 1), (float)_argNumber(L, "ragdollSetStrength", 2)); + return 0; +} + + static int32_t apiSceneEnable(lua_State *L) { bool enabled; @@ -5212,6 +6083,90 @@ static int32_t apiSingeWantsCrosshairs(lua_State *L) { // soundFullStop() Halts every sound effect channel. +static int32_t apiSoftDelete(lua_State *L) { + _argCheck(L, "softDelete", 1, 1); + softDelete(_argSoft(L, "softDelete", 1)); + return 0; +} + + +// softNew(node, SOFT_CLOTH | SOFT_BODY) from the node's mesh, or softNew(node, SOFT_ROPE, x, y, z, segments, radius) +static int32_t apiSoftNew(lua_State *L) { + int32_t node = 0; + int32_t kind = 0; + bool ok = false; + + _argCheck(L, "softNew", 2, 7); + node = _argNode(L, "softNew", 1); + kind = _argInteger(L, "softNew", 2); + if (!physicsAvailable()) { + _luaDie(L, "softNew", "Physics is not available on this machine."); + } + if (kind == SOFT_ROPE) { + _argCheck(L, "softNew", 7, 7); + ok = softNewRope(node, _argVec3(L, "softNew", 3), _argInteger(L, "softNew", 6), (float)_argNumber(L, "softNew", 7)); + } else if ((kind == SOFT_CLOTH) || (kind == SOFT_BODY)) { + _argCheck(L, "softNew", 2, 2); + ok = softNew(node, (SoftKindE)kind); + } else { + _luaDie(L, "softNew", "Unknown soft body kind %d.", kind); + } + if (!ok) { + _luaDie(L, "softNew", "Unable to make the soft body: the node needs a mesh (or a rope needs a length)."); + } + _luaTrace(L, "softNew", "node %d kind %d", node, kind); + return 0; +} + + +// softPin(node, x, y, z[, otherNode]): hold the nearest particle there, or to that node +static int32_t apiSoftPin(lua_State *L) { + int32_t follow = -1; + + _argCheck(L, "softPin", 4, 5); + if (lua_gettop(L) == 5) { + follow = _argNode(L, "softPin", 5); + } + softPin(_argSoft(L, "softPin", 1), _argVec3(L, "softPin", 2), follow); + return 0; +} + + +static int32_t apiSoftSetDamping(lua_State *L) { + _argCheck(L, "softSetDamping", 2, 2); + softSetDamping(_argSoft(L, "softSetDamping", 1), (float)_argNumber(L, "softSetDamping", 2)); + return 0; +} + + +static int32_t apiSoftSetMass(lua_State *L) { + _argCheck(L, "softSetMass", 2, 2); + softSetMass(_argSoft(L, "softSetMass", 1), (float)_argNumber(L, "softSetMass", 2)); + return 0; +} + + +static int32_t apiSoftSetPressure(lua_State *L) { + _argCheck(L, "softSetPressure", 2, 2); + softSetPressure(_argSoft(L, "softSetPressure", 1), (float)_argNumber(L, "softSetPressure", 2)); + return 0; +} + + +static int32_t apiSoftSetStiffness(lua_State *L) { + _argCheck(L, "softSetStiffness", 3, 3); + softSetStiffness(_argSoft(L, "softSetStiffness", 1), (float)_argNumber(L, "softSetStiffness", 2), (float)_argNumber(L, "softSetStiffness", 3)); + return 0; +} + + +static int32_t apiSoftUnpin(lua_State *L) { + _argCheck(L, "softUnpin", 4, 4); + softUnpin(_argSoft(L, "softUnpin", 1), _argVec3(L, "softUnpin", 2)); + return 0; +} + + static int32_t apiSoundFullStop(lua_State *L) { _luaTrace(L, "soundFullStop", "Halting all channels."); MIX_StopTag(videoGetMixer(), EFFECT_TAG, 0); @@ -5726,6 +6681,187 @@ static int32_t apiSpriteUnload(lua_State *L) { // videoDraw(id, x, y, x2, y2) - Stretch the frame into the rectangle // videoDraw(id, x, y, centered) - Draw with the video's rotation and scale +// index = vehicleAddWheel(vehicle, wheelNode, radius, width, suspensionLength) +static int32_t apiVehicleAddWheel(lua_State *L) { + int32_t index = 0; + + _argCheck(L, "vehicleAddWheel", 5, 5); + index = vehicleAddWheel(_argVehicle(L, "vehicleAddWheel", 1), _argNode(L, "vehicleAddWheel", 2), (float)_argNumber(L, "vehicleAddWheel", 3), (float)_argNumber(L, "vehicleAddWheel", 4), (float)_argNumber(L, "vehicleAddWheel", 5)); + if (index < 0) { + _luaDie(L, "vehicleAddWheel", "Unable to add the wheel (too many?)."); + } + lua_pushinteger(L, index); + return 1; +} + + +static int32_t apiVehicleDelete(lua_State *L) { + _argCheck(L, "vehicleDelete", 1, 1); + vehicleDelete(_argVehicle(L, "vehicleDelete", 1)); + return 0; +} + + +// vehicleDrive(vehicle, forward, right, brake, handBrake) +static int32_t apiVehicleDrive(lua_State *L) { + int32_t node = 0; + float in[4] = { 0.0f, 0.0f, 0.0f, 0.0f }; + int32_t x; + + _argCheck(L, "vehicleDrive", 3, 5); + node = _argVehicle(L, "vehicleDrive", 1); + for (x = 0; x < 4; x++) { + if (lua_gettop(L) >= 2 + x) { + in[x] = (float)_argNumber(L, "vehicleDrive", 2 + x); + } + } + vehicleDrive(node, in[0], in[1], in[2], in[3]); + return 0; +} + + +static int32_t apiVehicleGetGear(lua_State *L) { + _argCheck(L, "vehicleGetGear", 1, 1); + lua_pushinteger(L, vehicleGetGear(_argVehicle(L, "vehicleGetGear", 1))); + return 1; +} + + +static int32_t apiVehicleGetRpm(lua_State *L) { + _argCheck(L, "vehicleGetRpm", 1, 1); + lua_pushnumber(L, (lua_Number)vehicleGetRpm(_argVehicle(L, "vehicleGetRpm", 1))); + return 1; +} + + +static int32_t apiVehicleGetSpeed(lua_State *L) { + _argCheck(L, "vehicleGetSpeed", 1, 1); + lua_pushnumber(L, (lua_Number)vehicleGetSpeed(_argVehicle(L, "vehicleGetSpeed", 1))); + return 1; +} + + +static int32_t apiVehicleGetWheelSlip(lua_State *L) { + _argCheck(L, "vehicleGetWheelSlip", 2, 2); + lua_pushnumber(L, (lua_Number)vehicleGetWheelSlip(_argVehicle(L, "vehicleGetWheelSlip", 1), _argInteger(L, "vehicleGetWheelSlip", 2))); + return 1; +} + + +static int32_t apiVehicleIsWheelOnGround(lua_State *L) { + _argCheck(L, "vehicleIsWheelOnGround", 2, 2); + lua_pushboolean(L, vehicleIsWheelOnGround(_argVehicle(L, "vehicleIsWheelOnGround", 1), _argInteger(L, "vehicleIsWheelOnGround", 2))); + return 1; +} + + +// vehicleNew(node, VEHICLE_CAR | VEHICLE_MOTORCYCLE | VEHICLE_TANK): the node must carry a dynamic body +static int32_t apiVehicleNew(lua_State *L) { + int32_t node = 0; + int32_t kind = 0; + + _argCheck(L, "vehicleNew", 2, 2); + node = _argBody(L, "vehicleNew", 1); + kind = _argInteger(L, "vehicleNew", 2); + if ((kind < VEHICLE_CAR) || (kind > VEHICLE_BOAT)) { + _luaDie(L, "vehicleNew", "Unknown vehicle kind %d.", kind); + } + if (!vehicleNew(node, (VehicleKindE)kind)) { + _luaDie(L, "vehicleNew", "Unable to create the vehicle: the node needs a dynamic body, in a 3D world."); + } + _luaTrace(L, "vehicleNew", "node %d kind %d", node, kind); + return 0; +} + + +static int32_t apiVehicleSetAntiRoll(lua_State *L) { + _argCheck(L, "vehicleSetAntiRoll", 2, 2); + vehicleSetAntiRoll(_argVehicle(L, "vehicleSetAntiRoll", 1), (float)_argNumber(L, "vehicleSetAntiRoll", 2)); + return 0; +} + + +static int32_t apiVehicleSetBrakes(lua_State *L) { + _argCheck(L, "vehicleSetBrakes", 3, 3); + vehicleSetBrakes(_argVehicle(L, "vehicleSetBrakes", 1), (float)_argNumber(L, "vehicleSetBrakes", 2), (float)_argNumber(L, "vehicleSetBrakes", 3)); + return 0; +} + + +// vehicleSetEngine(vehicle, maxTorque, maxRpm[, minRpm]) +static int32_t apiVehicleSetEngine(lua_State *L) { + _argCheck(L, "vehicleSetEngine", 3, 4); + vehicleSetEngine(_argVehicle(L, "vehicleSetEngine", 1), (float)_argNumber(L, "vehicleSetEngine", 2), (float)_argNumber(L, "vehicleSetEngine", 3), (lua_gettop(L) == 4) ? (float)_argNumber(L, "vehicleSetEngine", 4) : 1000.0f); + return 0; +} + + +// vehicleSetGears(vehicle, {ratio, ...}[, reverseRatio][, automatic]) +static int32_t apiVehicleSetGears(lua_State *L) { + int32_t node = 0; + int32_t count = 0; + float *ratios; + float reverse = 2.9f; + bool automatic = true; + + _argCheck(L, "vehicleSetGears", 2, 4); + node = _argVehicle(L, "vehicleSetGears", 1); + ratios = _argFloatTable(L, "vehicleSetGears", 2, &count); + if (lua_gettop(L) >= 3) { + reverse = (float)_argNumber(L, "vehicleSetGears", 3); + } + if (lua_gettop(L) == 4) { + automatic = _argBoolean(L, "vehicleSetGears", 4); + } + if ((count < 1) || (count > 8) || !vehicleSetGears(node, ratios, count, reverse, automatic)) { + SDL_free(ratios); + _luaDie(L, "vehicleSetGears", "Give one to eight forward gear ratios."); + } + SDL_free(ratios); + return 0; +} + + +static int32_t apiVehicleSetSteering(lua_State *L) { + _argCheck(L, "vehicleSetSteering", 2, 2); + vehicleSetSteering(_argVehicle(L, "vehicleSetSteering", 1), (float)_argNumber(L, "vehicleSetSteering", 2)); + return 0; +} + + +// vehicleSetRudder(boat, maxTorque) +static int32_t apiVehicleSetRudder(lua_State *L) { + _argCheck(L, "vehicleSetRudder", 2, 2); + vehicleSetRudder(_argVehicle(L, "vehicleSetRudder", 1), (float)_argNumber(L, "vehicleSetRudder", 2)); + return 0; +} + + +static int32_t apiVehicleSetSuspension(lua_State *L) { + _argCheck(L, "vehicleSetSuspension", 3, 3); + vehicleSetSuspension(_argVehicle(L, "vehicleSetSuspension", 1), (float)_argNumber(L, "vehicleSetSuspension", 2), (float)_argNumber(L, "vehicleSetSuspension", 3)); + return 0; +} + + +// vehicleSetThrust(boat, maxForce, x, y, z): the propeller's push and where it pushes, in the hull +static int32_t apiVehicleSetThrust(lua_State *L) { + _argCheck(L, "vehicleSetThrust", 5, 5); + vehicleSetThrust(_argVehicle(L, "vehicleSetThrust", 1), (float)_argNumber(L, "vehicleSetThrust", 2), _argVec3(L, "vehicleSetThrust", 3)); + return 0; +} + + +// vehicleSetWheel(vehicle, index, steered, driven) +static int32_t apiVehicleSetWheel(lua_State *L) { + _argCheck(L, "vehicleSetWheel", 4, 4); + if (!vehicleSetWheel(_argVehicle(L, "vehicleSetWheel", 1), _argInteger(L, "vehicleSetWheel", 2), _argBoolean(L, "vehicleSetWheel", 3), _argBoolean(L, "vehicleSetWheel", 4))) { + _luaDie(L, "vehicleSetWheel", "No such wheel."); + } + return 0; +} + + static int32_t apiVideoDraw(lua_State *L) { int32_t n = lua_gettop(L); VideoT *video = NULL; @@ -6307,6 +7443,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co _global.renderer = renderer; sceneInit(device, renderer); physicsInit(); + particlesInit(); // Local copy of config _global.conf = cloneConf(conf); @@ -6406,6 +7543,8 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co lua_register(_global.luaContext, "bodyNew", apiBodyNew); // 3.00 lua_register(_global.luaContext, "bodySetAngularVelocity", apiBodySetAngularVelocity); // 3.00 lua_register(_global.luaContext, "bodySetBounce", apiBodySetBounce); // 3.00 + lua_register(_global.luaContext, "bodySetBuoyancy", apiBodySetBuoyancy); // 3.00 + lua_register(_global.luaContext, "bodySetCurrent", apiBodySetCurrent); // 3.00 lua_register(_global.luaContext, "bodySetEnabled", apiBodySetEnabled); // 3.00 lua_register(_global.luaContext, "bodySetFriction", apiBodySetFriction); // 3.00 lua_register(_global.luaContext, "bodySetMass", apiBodySetMass); // 3.00 @@ -6414,6 +7553,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co lua_register(_global.luaContext, "colorBackground", apiColorBackground); // 1.xx lua_register(_global.luaContext, "colorForeground", apiColorForeground); // 1.xx + lua_register(_global.luaContext, "bodySetWater", apiBodySetWater); // 3.00 lua_register(_global.luaContext, "cameraSet", apiCameraSet); // 3.00 lua_register(_global.luaContext, "cameraSetOrthographic", apiCameraSetOrthographic); // 3.00 lua_register(_global.luaContext, "cameraSetPerspective", apiCameraSetPerspective); // 3.00 @@ -6446,6 +7586,33 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co lua_register(_global.luaContext, "discStepForward", apiDiscStepForward); // 1.xx lua_register(_global.luaContext, "discStop", apiDiscStop); // 1.xx + lua_register(_global.luaContext, "emitterBurst", apiEmitterBurst); // 3.00 + lua_register(_global.luaContext, "emitterClear", apiEmitterClear); // 3.00 + lua_register(_global.luaContext, "emitterDelete", apiEmitterDelete); // 3.00 + lua_register(_global.luaContext, "emitterDraw", apiEmitterDraw); // 3.00 + lua_register(_global.luaContext, "emitterGetCount", apiEmitterGetCount); // 3.00 + lua_register(_global.luaContext, "emitterIsActive", apiEmitterIsActive); // 3.00 + lua_register(_global.luaContext, "emitterNew", apiEmitterNew); // 3.00 + lua_register(_global.luaContext, "emitterSetBlend", apiEmitterSetBlend); // 3.00 + lua_register(_global.luaContext, "emitterSetColor", apiEmitterSetColor); // 3.00 + lua_register(_global.luaContext, "emitterSetDirection", apiEmitterSetDirection); // 3.00 + lua_register(_global.luaContext, "emitterSetDrag", apiEmitterSetDrag); // 3.00 + lua_register(_global.luaContext, "emitterSetFrames", apiEmitterSetFrames); // 3.00 + lua_register(_global.luaContext, "emitterSetGravity", apiEmitterSetGravity); // 3.00 + lua_register(_global.luaContext, "emitterSetLayer", apiEmitterSetLayer); // 3.00 + lua_register(_global.luaContext, "emitterSetLife", apiEmitterSetLife); // 3.00 + lua_register(_global.luaContext, "emitterSetLocal", apiEmitterSetLocal); // 3.00 + lua_register(_global.luaContext, "emitterSetMax", apiEmitterSetMax); // 3.00 + lua_register(_global.luaContext, "emitterSetPosition", apiEmitterSetPosition); // 3.00 + lua_register(_global.luaContext, "emitterSetRadius", apiEmitterSetRadius); // 3.00 + lua_register(_global.luaContext, "emitterSetRate", apiEmitterSetRate); // 3.00 + lua_register(_global.luaContext, "emitterSetSize", apiEmitterSetSize); // 3.00 + lua_register(_global.luaContext, "emitterSetSpeed", apiEmitterSetSpeed); // 3.00 + lua_register(_global.luaContext, "emitterSetSpin", apiEmitterSetSpin); // 3.00 + lua_register(_global.luaContext, "emitterSetSpread", apiEmitterSetSpread); // 3.00 + lua_register(_global.luaContext, "emitterSetTexture", apiEmitterSetTexture); // 3.00 + lua_register(_global.luaContext, "emitterStart", apiEmitterStart); // 3.00 + lua_register(_global.luaContext, "emitterStop", apiEmitterStop); // 3.00 lua_register(_global.luaContext, "fontLoad", apiFontLoad); // 1.xx lua_register(_global.luaContext, "fontPrint", apiFontPrint); // 1.xx lua_register(_global.luaContext, "fontQuality", apiFontQuality); // 1.xx @@ -6516,6 +7683,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co lua_register(_global.luaContext, "nodeMove", apiNodeMove); // 3.00 lua_register(_global.luaContext, "nodeNew", apiNodeNew); // 3.00 lua_register(_global.luaContext, "nodeRotate", apiNodeRotate); // 3.00 + lua_register(_global.luaContext, "nodeSetMaterial", apiNodeSetMaterial); // 3.00 lua_register(_global.luaContext, "nodeSetMesh", apiNodeSetMesh); // 3.00 lua_register(_global.luaContext, "nodeSetMorph", apiNodeSetMorph); // 3.00 lua_register(_global.luaContext, "nodeSetName", apiNodeSetName); // 3.00 @@ -6524,6 +7692,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co lua_register(_global.luaContext, "nodeSetQuaternion", apiNodeSetQuaternion); // 3.00 lua_register(_global.luaContext, "nodeSetRotation", apiNodeSetRotation); // 3.00 lua_register(_global.luaContext, "nodeSetScale", apiNodeSetScale); // 3.00 + lua_register(_global.luaContext, "nodeSetShadow", apiNodeSetShadow); // 3.00 lua_register(_global.luaContext, "nodeSetVisible", apiNodeSetVisible); // 3.00 lua_register(_global.luaContext, "overlayBox", apiOverlayBox); // 2.00 lua_register(_global.luaContext, "overlayCircle", apiOverlayCircle); // 2.00 @@ -6540,6 +7709,32 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co lua_register(_global.luaContext, "physicsSet2D", apiPhysicsSet2D); // 3.00 lua_register(_global.luaContext, "physicsSetEnabled", apiPhysicsSetEnabled); // 3.00 lua_register(_global.luaContext, "physicsSetGravity", apiPhysicsSetGravity); // 3.00 + lua_register(_global.luaContext, "playerDelete", apiPlayerDelete); // 3.00 + lua_register(_global.luaContext, "playerGetGround", apiPlayerGetGround); // 3.00 + lua_register(_global.luaContext, "playerGetVelocity", apiPlayerGetVelocity); // 3.00 + lua_register(_global.luaContext, "playerIsOnGround", apiPlayerIsOnGround); // 3.00 + lua_register(_global.luaContext, "playerIsSwimming", apiPlayerIsSwimming); // 3.00 + lua_register(_global.luaContext, "playerJump", apiPlayerJump); // 3.00 + lua_register(_global.luaContext, "playerMove", apiPlayerMove); // 3.00 + lua_register(_global.luaContext, "playerNew", apiPlayerNew); // 3.00 + lua_register(_global.luaContext, "playerSetEnabled", apiPlayerSetEnabled); // 3.00 + lua_register(_global.luaContext, "playerSetGravityScale", apiPlayerSetGravityScale); // 3.00 + lua_register(_global.luaContext, "playerSetMass", apiPlayerSetMass); // 3.00 + lua_register(_global.luaContext, "playerSetPosition", apiPlayerSetPosition); // 3.00 + lua_register(_global.luaContext, "playerSetPush", apiPlayerSetPush); // 3.00 + lua_register(_global.luaContext, "playerSetSlope", apiPlayerSetSlope); // 3.00 + lua_register(_global.luaContext, "playerSetStep", apiPlayerSetStep); // 3.00 + lua_register(_global.luaContext, "playerSetSwim", apiPlayerSetSwim); // 3.00 + lua_register(_global.luaContext, "playerSetVelocity", apiPlayerSetVelocity); // 3.00 + lua_register(_global.luaContext, "ragdollActivate", apiRagdollActivate); // 3.00 + lua_register(_global.luaContext, "ragdollApplyImpulse", apiRagdollApplyImpulse); // 3.00 + lua_register(_global.luaContext, "ragdollDeactivate", apiRagdollDeactivate); // 3.00 + lua_register(_global.luaContext, "ragdollDelete", apiRagdollDelete); // 3.00 + lua_register(_global.luaContext, "ragdollIsActive", apiRagdollIsActive); // 3.00 + lua_register(_global.luaContext, "ragdollIsResting", apiRagdollIsResting); // 3.00 + lua_register(_global.luaContext, "ragdollNew", apiRagdollNew); // 3.00 + lua_register(_global.luaContext, "ragdollSetJoint", apiRagdollSetJoint); // 3.00 + lua_register(_global.luaContext, "ragdollSetStrength", apiRagdollSetStrength); // 3.00 lua_register(_global.luaContext, "sceneEnable", apiSceneEnable); // 3.00 lua_register(_global.luaContext, "sceneGetSize", apiSceneGetSize); // 3.00 lua_register(_global.luaContext, "sceneProject", apiSceneProject); // 3.00 @@ -6570,6 +7765,14 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co lua_register(_global.luaContext, "singeVersion", apiSingeVersion); // 1.xx RDG lua_register(_global.luaContext, "singeWantsCrosshairs", apiSingeWantsCrosshairs); // 2.00 + lua_register(_global.luaContext, "softDelete", apiSoftDelete); // 3.00 + lua_register(_global.luaContext, "softNew", apiSoftNew); // 3.00 + lua_register(_global.luaContext, "softPin", apiSoftPin); // 3.00 + lua_register(_global.luaContext, "softSetDamping", apiSoftSetDamping); // 3.00 + lua_register(_global.luaContext, "softSetMass", apiSoftSetMass); // 3.00 + lua_register(_global.luaContext, "softSetPressure", apiSoftSetPressure); // 3.00 + lua_register(_global.luaContext, "softSetStiffness", apiSoftSetStiffness); // 3.00 + lua_register(_global.luaContext, "softUnpin", apiSoftUnpin); // 3.00 lua_register(_global.luaContext, "soundFullStop", apiSoundFullStop); // 1.16 lua_register(_global.luaContext, "soundGetVolume", apiSoundGetVolume); // 1.16 lua_register(_global.luaContext, "soundIsPlaying", apiSoundIsPlaying); // 1.16 RDG @@ -6597,6 +7800,24 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co lua_register(_global.luaContext, "spriteSetFrame", apiSpriteSetFrame); // 2.10 Handle first since 3.00. lua_register(_global.luaContext, "spriteUnload", apiSpriteUnload); // 2.00 + lua_register(_global.luaContext, "vehicleAddWheel", apiVehicleAddWheel); // 3.00 + lua_register(_global.luaContext, "vehicleDelete", apiVehicleDelete); // 3.00 + lua_register(_global.luaContext, "vehicleDrive", apiVehicleDrive); // 3.00 + lua_register(_global.luaContext, "vehicleGetGear", apiVehicleGetGear); // 3.00 + lua_register(_global.luaContext, "vehicleGetRpm", apiVehicleGetRpm); // 3.00 + lua_register(_global.luaContext, "vehicleGetSpeed", apiVehicleGetSpeed); // 3.00 + lua_register(_global.luaContext, "vehicleGetWheelSlip", apiVehicleGetWheelSlip); // 3.00 + lua_register(_global.luaContext, "vehicleIsWheelOnGround", apiVehicleIsWheelOnGround); // 3.00 + lua_register(_global.luaContext, "vehicleNew", apiVehicleNew); // 3.00 + lua_register(_global.luaContext, "vehicleSetAntiRoll", apiVehicleSetAntiRoll); // 3.00 + lua_register(_global.luaContext, "vehicleSetBrakes", apiVehicleSetBrakes); // 3.00 + lua_register(_global.luaContext, "vehicleSetEngine", apiVehicleSetEngine); // 3.00 + lua_register(_global.luaContext, "vehicleSetGears", apiVehicleSetGears); // 3.00 + lua_register(_global.luaContext, "vehicleSetSteering", apiVehicleSetSteering); // 3.00 + lua_register(_global.luaContext, "vehicleSetRudder", apiVehicleSetRudder); // 3.00 + lua_register(_global.luaContext, "vehicleSetSuspension", apiVehicleSetSuspension); // 3.00 + lua_register(_global.luaContext, "vehicleSetThrust", apiVehicleSetThrust); // 3.00 + lua_register(_global.luaContext, "vehicleSetWheel", apiVehicleSetWheel); // 3.00 lua_register(_global.luaContext, "videoDraw", apiVideoDraw); // 2.00 lua_register(_global.luaContext, "videoGetAudioTrack", apiVideoGetAudioTrack); // 2.10 lua_register(_global.luaContext, "videoGetAudioTracks", apiVideoGetAudioTracks); // 2.10 @@ -7097,17 +8318,21 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co modelUpdate(!_global.frozen); physicsUpdate(!_global.frozen); _physicsCallbacks(); + particlesUpdate(!_global.frozen); sceneUpdateVideo(_sceneVideoSource); sceneTexture = sceneRender(); if (sceneTexture != NULL) { SDL_RenderTexture(_global.renderer, sceneTexture, NULL, &windowTarget); } - // Overlay + // 2D particles beneath the overlay, then the overlay, then the ones above it + _drawParticles2D(PARTICLE_UNDER, &windowTarget); if (_global.overlayDirty) { SDL_UpdateTexture(_global.overlayTexture, NULL, _global.overlay->pixels, _global.overlay->pitch); _global.overlayDirty = false; } SDL_RenderTexture(_global.renderer, _global.overlayTexture, NULL, &windowTarget); + _drawParticles2D(PARTICLE_OVER, &windowTarget); + particlesClearQueue2D(); if (_global.frozen) { _drawPauseIndicator(&windowTarget); } @@ -7145,6 +8370,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co _progTrace("Destroying overlay"); SDL_DestroyTexture(_global.pauseTexture); modelQuit(); + particlesQuit(); physicsQuit(); sceneQuit(); SDL_DestroyTexture(_global.overlayTexture); diff --git a/testScripts/Models/Sponza.glb b/testScripts/Models/Sponza.glb new file mode 100644 index 000000000..e4d206e1d --- /dev/null +++ b/testScripts/Models/Sponza.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61984edbadbf6e544532d61c6d98e195c6575ec761aacd1c35fd2b27e0769794 +size 52620848 diff --git a/testScripts/README.md b/testScripts/README.md index 37f0e6ee4..bdc027d22 100644 --- a/testScripts/README.md +++ b/testScripts/README.md @@ -2,14 +2,14 @@ The Lua scripts used to verify the 3D scene and physics work, one per stage of PLAN.md sections 18 and 19, with everything they load: the Khronos -Duck, Fox, BoxAnimated and AnimatedMorphCube sample models under Models/, Box.png and crate.png. The +Duck, Fox, BoxAnimated, AnimatedMorphCube and Sponza sample models under Models/, Box.png and crate.png. The disc and the font are the engine's own, addressed as Singe/menuBackground.mkv and Singe/FreeSansBold.ttf like any engine asset. This directory is a complete Singe game directory. Run one from the singe directory with .builddir/Singe-v3.00-Linux-x86_64 -w -d data -v Singe/menuBackground.mkv testScripts/scene6.singe -(scene12 has no disc: give it -C 720x480 instead of -v) +(scene12 and scene20 have no disc: give them -C 720x480 instead of -v) or pack it (singe --pack testScripts testScripts.game) and run an entry with --entry N (the numbers follow games.dat). Every script takes a @@ -33,3 +33,14 @@ screenshot or two and quits by itself; the results are in screenshots/. | scene14.singe | 14 | - | The solid tangram dragon (Models/DragonModel.glb, a copy of assets/DragonModel.glb) turning under a sun with shadows | | scene15.singe | 15 | - | The Singe logotype as solid letters (Models/SingeText.glb, a copy of assets/SingeText.glb) turning under a sun with shadows | | scene16.singe | 16 | - | The dragon perched on the logotype, both models under one parent node, turning together | +| scene17.singe | 17 | 20.1 | 2D particles: fireworks, a smoke trail, sparks off a 2D physics ball; runs with SDL_GPU_DRIVER=nothing | +| scene18.singe | 18 | 20.2 | 3D particles: torch flame and smoke on a post, a fountain, dust, over the disc with shadows | +| scene19.singe | 19 | 21.1 | Character controller: the Fox on a course of stairs, a ramp, a jump, a crate, a wall and a steep slope | +| scene20.singe | 20 | 21.2 | Character controller in 2D: a sprite platformer (gap, step, wall, ledge); runs with SDL_GPU_DRIVER=nothing and -C 720x480 | +| scene21.singe | 21 | 22.1 | Point-light shadows inside geometry: a bulb under a shade and a bulb in a cage, in a closed room of outward-facing boxes, inside and outside views | +| scene22.singe | 22 | 23.1 | Vehicles: a car from primitives on a ramp track, driven by a scripted route with a chase camera | +| scene23.singe | 23 | 23.2 | Vehicles: a tank that pivots and climbs, and a motorcycle that leans through a turn | +| scene24.singe | 24 | 24.1 | Water: a pool and a river; crates float, sink and drift, the Fox swims across, a raft drives | +| scene25.singe | 25 | 25.1 | Ragdolls: a running Fox crumples at a wall, one falls down stairs, one lies under motor strength | +| scene26.singe | 26 | 26.1 | Soft bodies: a flag, a sheet catching a crate, a swinging rope, a balloon | +| scene27.singe | 27 | - | The Khronos Sponza atrium (Models/Sponza.glb, 52 MB, packed with util/packGlb.py) under a sun with shadows, six braziers with flame, smoke and dust particles; no disc, -C 1280x720 | diff --git a/testScripts/games.dat b/testScripts/games.dat index e97a95ba2..a4de9196a 100644 --- a/testScripts/games.dat +++ b/testScripts/games.dat @@ -174,4 +174,123 @@ GAMES = { DEVELOPER = "Test", PUBLISHER = "Test", }, + { + TITLE = "Particles2D", + SCRIPT = "testScripts/scene17.singe", + VIDEO = "Singe/menuBackground.mkv", + DESCRIPTION = "2D particles: fireworks, a smoke trail and sparks off a physics ball; runs with no GPU.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, + { + TITLE = "Particles3D", + SCRIPT = "testScripts/scene18.singe", + VIDEO = "Singe/menuBackground.mkv", + DESCRIPTION = "3D particles: a torch flame and smoke on a post, a fountain, dust in the air.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, + { + TITLE = "Player", + SCRIPT = "testScripts/scene19.singe", + VIDEO = "Singe/menuBackground.mkv", + DESCRIPTION = "Character controller: the Fox walks stairs and a ramp, jumps, shoves a crate and stops at a steep slope.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, + { + TITLE = "Platformer", + SCRIPT = "testScripts/scene20.singe", + DESCRIPTION = "Character controller in 2D: a sprite platformer with a gap, a step, a wall and a ledge; no GPU needed.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, + { + TITLE = "BulbInRoom", + SCRIPT = "testScripts/scene21.singe", + VIDEO = "Singe/menuBackground.mkv", + DESCRIPTION = "Point-light shadows inside geometry: a bulb under a shade and a bulb in a cage, in a closed room with pillars, seen inside and out.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, + { + TITLE = "Car", + SCRIPT = "testScripts/scene22.singe", + VIDEO = "Singe/menuBackground.mkv", + DESCRIPTION = "Vehicles: a car from primitives on a scripted route over a ramp, braking, turning and reversing.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, + { + TITLE = "TankAndBike", + SCRIPT = "testScripts/scene23.singe", + VIDEO = "Singe/menuBackground.mkv", + DESCRIPTION = "Vehicles: a six-wheeled tank that pivots on the spot and a motorcycle that leans through a turn.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, + { + TITLE = "Water", + SCRIPT = "testScripts/scene24.singe", + VIDEO = "Singe/menuBackground.mkv", + DESCRIPTION = "Water: a pool and a river with a current; floating, sinking and drifting bodies, the Fox swimming, a raft under power.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, + { + TITLE = "Ragdolls", + SCRIPT = "testScripts/scene25.singe", + VIDEO = "Singe/menuBackground.mkv", + DESCRIPTION = "Ragdolls: a running Fox crumples against a wall, another falls down stairs limp, a third lies under motor strength.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, + { + TITLE = "SoftBodies", + SCRIPT = "testScripts/scene26.singe", + VIDEO = "Singe/menuBackground.mkv", + DESCRIPTION = "Soft bodies: a flag on a pole, a sheet catching a crate, a swinging rope and a balloon under pressure.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, + { + TITLE = "Sponza", + SCRIPT = "testScripts/scene27.singe", + DESCRIPTION = "The Khronos Sponza atrium under a sun with shadows, on a camera walk.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, } diff --git a/testScripts/scene17.singe b/testScripts/scene17.singe new file mode 100644 index 000000000..e69098b48 --- /dev/null +++ b/testScripts/scene17.singe @@ -0,0 +1,88 @@ +-- Particles, 2D: fireworks bursting over the disc, a smoke trail following a moving point, and +-- sparks off a bouncing 2D physics ball, all drawn with the overlay. Runs without a GPU too. +local font = fontLoad("Singe/FreeSansBold.ttf", 28) +local frames = 0 +local width = overlayGetWidth() +local height = overlayGetHeight() + +fontSelect(font) +discPlay() + +-- Fireworks: a burst of bright additive sparks that fall and fade. +local fireworks = emitterNew() +emitterSetBlend(fireworks, PARTICLE_ADD) +emitterSetLife(fireworks, 0.8, 1.6) +emitterSetSpeed(fireworks, 120, 320) +emitterSetSpread(fireworks, 180) +emitterSetGravity(fireworks, 0, 220) +emitterSetDrag(fireworks, 0.8) +emitterSetSize(fireworks, 14, 3, 0.3) +emitterSetColor(fireworks, 255, 230, 120, 255, 255, 60, 20, 0) +emitterSetMax(fireworks, 2000) + +-- Smoke: grey puffs drifting up from a moving point, drawn under the overlay text. +local smoke = emitterNew() +emitterSetLayer(smoke, PARTICLE_UNDER) +emitterSetRate(smoke, 90) +emitterSetLife(smoke, 1.2, 2.0) +emitterSetSpeed(smoke, 30, 60) +emitterSetDirection(smoke, 0, -1) +emitterSetSpread(smoke, 25) +emitterSetGravity(smoke, 0, -20) +emitterSetSize(smoke, 12, 48, 0.2) +emitterSetColor(smoke, 200, 200, 210, 160, 90, 90, 100, 0) +emitterSetSpin(smoke, -60, 60) +emitterSetRadius(smoke, 6) +emitterStart(smoke) + +-- Sparks: short hot streaks thrown off a ball bouncing in a 2D physics world. +physicsSet2D(true) +physicsSetGravity(0, 600, 0) +local floor = nodeNew() +nodeSetPosition(floor, width / 2, height - 10, 0) +bodyNew(floor, BODY_STATIC, SHAPE_BOX, width, 20, 50) +local ball = nodeNew() +nodeSetPosition(ball, width * 0.25, 80, 0) +bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 18) +bodySetBounce(ball, 0.8) +bodySetVelocity(ball, 140, 0, 0) +local sparks = emitterNew() +emitterSetBlend(sparks, PARTICLE_ADD) +emitterSetRate(sparks, 160) +emitterSetLife(sparks, 0.3, 0.7) +emitterSetSpeed(sparks, 80, 200) +emitterSetDirection(sparks, 0, -1) +emitterSetSpread(sparks, 70) +emitterSetGravity(sparks, 0, 500) +emitterSetSize(sparks, 5, 1) +emitterSetColor(sparks, 255, 255, 200, 255, 255, 120, 0, 0) +emitterStart(sparks) + +local ballSprite = spriteLoad("testScripts/crate.png") + +function onOverlayUpdate() + local t = frames / 60 + local bx = nodeGetPosition(ball) + local by = select(2, nodeGetPosition(ball)) + frames = frames + 1 + overlayClear() + -- The smoke source circles slowly. + emitterSetPosition(smoke, width / 2 + 160 * math.cos(t), height / 2 + 60 * math.sin(t * 1.3)) + -- Sparks come from wherever the ball is. + emitterSetPosition(sparks, bx, by + 14) + if frames % 45 == 1 then + emitterSetPosition(fireworks, 120 + (frames * 7) % (width - 240), 80 + (frames * 13) % 160) + emitterBurst(fireworks, 260) + end + emitterDraw(smoke) + emitterDraw(fireworks) + emitterDraw(sparks) + spriteDraw(ballSprite, bx - 18, by - 18, bx + 18, by + 18) + fontPrint(20, 20, "Particles 2D, frame " .. frames .. " live " .. emitterGetCount(fireworks) + emitterGetCount(smoke) + emitterGetCount(sparks)) + if frames == 40 or frames == 110 or frames == 170 then + singeScreenshot() + end + if frames == 180 then + singeQuit() + end +end diff --git a/testScripts/scene18.singe b/testScripts/scene18.singe new file mode 100644 index 000000000..829fd2f15 --- /dev/null +++ b/testScripts/scene18.singe @@ -0,0 +1,108 @@ +-- Particles, 3D: a torch flame on a post, a fountain, and dust drifting in a sunbeam over the disc, +-- with a crate to show the billboards depth-testing against geometry and casting no shadow. +local font = fontLoad("Singe/FreeSansBold.ttf", 28) +local frames = 0 + +fontSelect(font) +discPlay() +sceneEnable(true) +sceneSetBackground(0, 0, 0, 0) +sceneSetAmbient(50, 50, 60) + +local stone = materialNew() +materialSetColor(stone, 110, 110, 120) +materialSetRoughness(stone, 0.9) +local wood = materialNew() +materialSetColor(wood, 140, 90, 50) +local floor = nodeNew() +nodeSetMesh(floor, meshBox(12, 0.2, 8), stone) +nodeSetPosition(floor, 0, -1.1, 0) +local post = nodeNew() +nodeSetMesh(post, meshCylinder(0.08, 2.2, 12), wood) +nodeSetPosition(post, -2.5, 0, 0) +local crate = nodeNew() +nodeSetMesh(crate, meshBox(1, 1, 1), wood) +nodeSetPosition(crate, 0.8, -0.5, 0.5) + +-- The flame: additive, born in a small ball at the top of the post, rising and shrinking. +local torchTip = nodeNew() +nodeSetParent(torchTip, post) +nodeSetPosition(torchTip, 0, 1.15, 0) +local flame = emitterNew(torchTip) +emitterSetBlend(flame, PARTICLE_ADD) +emitterSetRate(flame, 140) +emitterSetLife(flame, 0.4, 0.9) +emitterSetSpeed(flame, 0.6, 1.4) +emitterSetDirection(flame, 0, 1, 0) +emitterSetSpread(flame, 15) +emitterSetSize(flame, 0.35, 0.05, 0.3) +emitterSetColor(flame, 255, 200, 90, 255, 255, 40, 0, 0) +emitterSetRadius(flame, 0.06) +emitterSetSpin(flame, -90, 90) +emitterStart(flame) + +-- Smoke above the flame, alpha blended and slow. +local smoke = emitterNew(torchTip) +emitterSetRate(smoke, 25) +emitterSetLife(smoke, 1.5, 2.5) +emitterSetSpeed(smoke, 0.5, 0.9) +emitterSetDirection(smoke, 0.2, 1, 0) +emitterSetSpread(smoke, 20) +emitterSetSize(smoke, 0.2, 0.9, 0.2) +emitterSetColor(smoke, 80, 80, 90, 120, 60, 60, 70, 0) +emitterSetSpin(smoke, -30, 30) +emitterStart(smoke) + +-- A fountain: water drops thrown up and falling under gravity, staying where they were born. +local spout = nodeNew() +nodeSetPosition(spout, 2.5, -1, -0.5) +local fountain = emitterNew(spout) +emitterSetRate(fountain, 300) +emitterSetLife(fountain, 1.0, 1.6) +emitterSetSpeed(fountain, 4.0, 5.0) +emitterSetDirection(fountain, 0, 1, 0) +emitterSetSpread(fountain, 8) +emitterSetGravity(fountain, 0, -9.8, 0) +emitterSetSize(fountain, 0.08, 0.12) +emitterSetColor(fountain, 150, 200, 255, 220, 150, 200, 255, 0) +emitterSetMax(fountain, 1500) +emitterStart(fountain) + +-- Dust: a few slow motes in a box of air, following nothing. +local air = nodeNew() +nodeSetPosition(air, 0, 0.5, 0) +local dust = emitterNew(air) +emitterSetRate(dust, 20) +emitterSetLife(dust, 4, 6) +emitterSetSpeed(dust, 0.05, 0.2) +emitterSetSpread(dust, 180) +emitterSetSize(dust, 0.04, 0.04) +emitterSetColor(dust, 255, 240, 200, 0, 255, 240, 200, 160) +emitterSetRadius(dust, 2.5) +emitterSetMax(dust, 200) +emitterStart(dust) +emitterBurst(dust, 120) + +local sun = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(sun, -3, 6, 4) +nodeLookAt(sun, 0, 0, 0) +lightSetIntensity(sun, 1.4) +lightSetShadow(sun, true) + +local camera = nodeNew() +nodeSetPosition(camera, 0, 1.2, 7) +nodeLookAt(camera, 0, 0, 0) +cameraSet(camera) + +function onOverlayUpdate() + frames = frames + 1 + nodeRotate(post, 0, 0.5, 0) + overlayClear() + fontPrint(20, 20, "Particles 3D, frame " .. frames .. " flame " .. emitterGetCount(flame) .. " fountain " .. emitterGetCount(fountain)) + if frames == 60 or frames == 120 or frames == 170 then + singeScreenshot() + end + if frames == 180 then + singeQuit() + end +end diff --git a/testScripts/scene19.singe b/testScripts/scene19.singe new file mode 100644 index 000000000..ecff36c23 --- /dev/null +++ b/testScripts/scene19.singe @@ -0,0 +1,121 @@ +-- Character controller: the Fox as a player walking a course of stairs, ramps, a wall, a crate to +-- shove and a moving platform, driven by a scripted route so it runs headless; the camera follows. +local font = fontLoad("Singe/FreeSansBold.ttf", 28) +local frames = 0 + +fontSelect(font) +discPlay() +sceneEnable(true) +sceneSetBackground(0, 0, 0, 0) +sceneSetAmbient(70, 70, 80) + +local stone = materialNew() +materialSetColor(stone, 120, 120, 130) +materialSetRoughness(stone, 0.9) +local wood = materialNew() +materialSetColor(wood, 150, 100, 50) +local steel = materialNew() +materialSetColor(steel, 90, 110, 150) +materialSetMetallic(steel, 0.6) + +local function block(x, y, z, w, h, d, material) + local node = nodeNew() + nodeSetMesh(node, meshBox(w, h, d), material) + nodeSetPosition(node, x, y, z) + bodyNew(node, BODY_STATIC, SHAPE_BOX, w, h, d) + return node +end + +-- The course runs along +X. Floor, three stairs, a gentle ramp, a steep ramp, a wall to slide along. +block(8, -0.1, 0, 30, 0.2, 8, stone) +block(2.0, 0.075, 0, 0.6, 0.15, 4, wood) +block(2.6, 0.15, 0, 0.6, 0.30, 4, wood) +block(3.2, 0.225, 0, 0.6, 0.45, 4, wood) +local ramp = nodeNew() +nodeSetMesh(ramp, meshBox(3, 0.2, 4), stone) +nodeSetPosition(ramp, 6.2, 0.7, 0) +nodeSetRotation(ramp, 0, 0, 25) +bodyNew(ramp, BODY_STATIC, SHAPE_BOX, 3, 0.2, 4) +block(9.0, 0.7, 0, 2.6, 2.6, 4, stone) -- The top landing behind the ramp +local steep = nodeNew() +nodeSetMesh(steep, meshBox(2, 0.2, 4), stone) +nodeSetPosition(steep, 11.0, 2.85, 0) +nodeSetRotation(steep, 0, 0, 60) +bodyNew(steep, BODY_STATIC, SHAPE_BOX, 2, 0.2, 4) +block(9.0, 2.4, -2.5, 4, 1.2, 0.2, steel) -- A wall along the landing's far edge + +-- A crate to shove and a platform that slides back and forth. +local crate = nodeNew() +nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), wood) +nodeSetPosition(crate, 4.6, 0.3, 0.15) +bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6) +bodySetMass(crate, 5) +local platform = nodeNew() +nodeSetMesh(platform, meshBox(1.5, 0.2, 1.5), steel) +nodeSetPosition(platform, 8.5, 2.1, 1.5) +bodyNew(platform, BODY_KINEMATIC, SHAPE_BOX, 1.5, 0.2, 1.5) + +-- The player: a capsule with the Fox model hanging off the node, facing +X. +local player = nodeNew() +nodeSetPosition(player, 0, 0.05, 0) +playerNew(player, 0.3, 1.0) +playerSetStep(player, 0.35) +local foxModel = modelLoad("testScripts/Models/Fox.glb") +local fox = modelInstance(foxModel) +nodeSetParent(fox, player) +nodeSetScale(fox, 0.01) +nodeSetRotation(fox, 0, 90, 0) +animationPlay(fox, "Walk", true) + +local sun = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(sun, -3, 8, 5) +nodeLookAt(sun, 6, 0, 0) +lightSetIntensity(sun, 1.5) +lightSetShadow(sun, true) + +local camera = nodeNew() +cameraSet(camera) + +local jumped = false +local landings = 0 + +function onCollision(a, b, x, y, z, speed) + if a == player or b == player then + landings = landings + 1 + end +end + +function onOverlayUpdate() + local px, py, pz = nodeGetPosition(player) + local t = frames / 60 + frames = frames + 1 + -- Route: walk east up the stairs and the ramp, veer to the wall, then onto the platform. + local vz = 0 + if px > 7.5 and px < 10 then + vz = (t < 6) and -1.2 or 1.2 + end + playerMove(player, 3.0, vz) + if px > 5.5 and not jumped and playerIsOnGround(player) then + jumped = playerJump(player, 4.5) + end + nodeSetRotation(player, 0, math.deg(math.atan(vz, 3.0)) * -1, 0) + -- The platform slides in Z. + nodeSetPosition(platform, 8.5, 2.1, 1.5 + math.sin(t * 1.5) * 1.5) + -- Camera behind and above the player. + nodeSetPosition(camera, px - 4.5, py + 2.6, pz + 4.5) + nodeLookAt(camera, px + 1, py + 0.6, pz) + overlayClear() + fontPrint(20, 20, string.format("Player, frame %d x %.1f y %.2f ground %s landings %d", frames, px, py, tostring(playerIsOnGround(player)), landings)) + if frames % 30 == 0 then + local fx, fy, fz = nodeGetWorldPosition(fox) + local vx, vy, vz = playerGetVelocity(player) + local cx, cy, cz = nodeGetPosition(crate) + debugPrint(string.format("frame %d player %.2f %.2f %.2f v %.2f %.2f %.2f ground %s jumped %s crate %.2f %.2f landings %d", frames, px, py, pz, vx, vy, vz, tostring(playerIsOnGround(player)), tostring(jumped), cx, cz, landings)) + end + if frames == 40 or frames == 80 or frames == 120 or frames == 160 then + singeScreenshot() + end + if frames == 180 then + singeQuit() + end +end diff --git a/testScripts/scene20.singe b/testScripts/scene20.singe new file mode 100644 index 000000000..c4c083a7e --- /dev/null +++ b/testScripts/scene20.singe @@ -0,0 +1,69 @@ +-- Character controller in 2D: a platformer drawn on the overlay with sprites and boxes, no GPU +-- needed. The player runs right, hops a gap, climbs a step, is stopped by a wall and jumps onto a +-- ledge, on a scripted route; gravity points down the screen (+Y) like every 2D game here. +local font = fontLoad("Singe/FreeSansBold.ttf", 24) +local crate = spriteLoad("testScripts/crate.png") +local frames = 0 + +overlaySetResolution(720, 480) +local width, height = overlayGetWidth(), overlayGetHeight() + +fontSelect(font) +physicsSet2D(true) +physicsSetGravity(0, 900, 0) + +local blocks = {} +local function block(x, y, w, h) + local node = nodeNew() + nodeSetPosition(node, x, y, 0) + bodyNew(node, BODY_STATIC, SHAPE_BOX, w, h, 50) + blocks[#blocks + 1] = { x = x, y = y, w = w, h = h } + return node +end + +-- Ground with a gap, a low step, a wall, and a ledge to jump onto. +local ground = height - 20 +block(width * 0.2, ground, width * 0.4, 20) +block(width * 0.72, ground, width * 0.56, 20) +block(width * 0.55, ground - 22, 60, 24) +block(width * 0.85, ground - 60, 20, 100) +block(width * 0.92, ground - 140, 140, 20) + +local size = spriteGetWidth(crate) +local player = nodeNew() +nodeSetPosition(player, 40, ground - 10, 0) +playerNew(player, size * 0.45, size * 1.4) +playerSetStep(player, 26) + +local hops = 0 + +function onOverlayUpdate() + local px, py = nodeGetPosition(player) + frames = frames + 1 + playerMove(player, (px < width * 0.9) and 220 or 0) + -- Hop the gap, and later the wall, when the ground runs out ahead or the wall is near. + if playerIsOnGround(player) and ((px > width * 0.36 and px < width * 0.42) or (px > width * 0.70 and px < width * 0.75)) then + if playerJump(player, 560) then + hops = hops + 1 + end + end + overlayClear() + colorForeground(90, 90, 110) + for _, b in ipairs(blocks) do + overlayBox(b.x - b.w / 2, b.y - b.h / 2, b.x + b.w / 2, b.y + b.h / 2) + end + spriteDraw(crate, px - size / 2, py - size * 1.4, px + size / 2, py) + colorForeground(255, 255, 255) + fontPrint(20, 20, string.format("Platformer, frame %d x %.0f y %.0f ground %s hops %d", frames, px, py, tostring(playerIsOnGround(player)), hops)) + if frames % 10 == 0 then + local vx, vy = playerGetVelocity(player) + debugPrint(string.format("frame %d player %.0f %.0f v %.0f %.0f ground %s hops %d", frames, px, py, vx, vy, tostring(playerIsOnGround(player)), hops)) + end + if frames == 40 or frames == 90 or frames == 140 then + singeScreenshot() + end + if frames == 180 then + singeQuit() + end + return OVERLAY_UPDATED +end diff --git a/testScripts/scene21.singe b/testScripts/scene21.singe new file mode 100644 index 000000000..a3dd7f149 --- /dev/null +++ b/testScripts/scene21.singe @@ -0,0 +1,131 @@ +-- Point-light shadows inside geometry: two bulbs in a closed room built from ordinary boxes (their +-- faces point outward, so from inside only back faces show), with pillars. One bulb hangs under a +-- cone shade on a stand; the other sits inside a cage of bars and rings. Inside, the walls are lit +-- with no seams, the shade throws its shadow on the ceiling and the cage its stripes across the +-- corners; outside, the room stays dark. +local font = fontLoad("Singe/FreeSansBold.ttf", 28) +local frames = 0 + +fontSelect(font) +discPlay() +sceneEnable(true) +sceneSetBackground(0, 0, 0, 0) +sceneSetAmbient(8, 8, 12) + +local plaster = materialNew() +materialSetColor(plaster, 200, 190, 170) +materialSetRoughness(plaster, 0.95) +materialSetDoubleSided(plaster, true) +local brass = materialNew() +materialSetColor(brass, 180, 140, 70) +materialSetMetallic(brass, 0.7) +materialSetRoughness(brass, 0.4) +materialSetDoubleSided(brass, true) +local glow = materialNew() +materialSetColor(glow, 255, 240, 200) +materialSetEmissive(glow, 255, 230, 170) +materialSetUnlit(glow, true) + +-- The room: six slabs 8 x 4 x 8 inside, all outward facing like any box. +local function slab(x, y, z, w, h, d) + local node = nodeNew() + nodeSetMesh(node, meshBox(w, h, d), plaster) + nodeSetPosition(node, x, y, z) + return node +end +slab(0, -0.1, 0, 8.4, 0.2, 8.4) +slab(0, 4.1, 0, 8.4, 0.2, 8.4) +slab(-4.1, 2, 0, 0.2, 4.4, 8.4) +slab(4.1, 2, 0, 0.2, 4.4, 8.4) +slab(0, 2, -4.1, 8.4, 4.4, 0.2) +slab(0, 2, 4.1, 8.4, 4.4, 0.2) + +-- Pillars and a crate. +for i, p in ipairs({ { -2.5, -2.5 }, { 2.5, -2.5 }, { -2.5, 2.5 }, { 2.5, 2.5 } }) do + local pillar = nodeNew() + nodeSetMesh(pillar, meshCylinder(0.25, 4, 16), plaster) + nodeSetPosition(pillar, p[1], 2, p[2]) +end +local crate = nodeNew() +nodeSetMesh(crate, meshBox(0.8, 0.8, 0.8), brass) +nodeSetPosition(crate, 2.2, 0.4, -1.8) + +-- The lamp: a stand, a cone shade above the bulb, which hangs just below its rim so the shade +-- throws a ring of shadow on the ceiling and the bulb lights everything below. +local stand = nodeNew() +nodeSetMesh(stand, meshCylinder(0.04, 1.4, 8), brass) +nodeSetPosition(stand, -0.5, 0.7, -0.3) +local shade = nodeNew() +nodeSetMesh(shade, meshCone(0.45, 0.5, 24), brass) +nodeSetPosition(shade, -0.5, 2.05, -0.3) +local bulbMesh = nodeNew() +nodeSetMesh(bulbMesh, meshSphere(0.08, 12), glow) +nodeSetPosition(bulbMesh, -0.5, 1.55, -0.3) +nodeSetShadow(bulbMesh, false) -- The bulb's own glass casts nothing. +local bulb = lightNew(LIGHT_POINT) +nodeSetPosition(bulb, -0.5, 1.55, -0.3) +lightSetIntensity(bulb, 6) +lightSetShadow(bulb, true) + +-- The second lamp: a bulb inside a cage of eight bars and three rings of short segments, its own +-- glass excused from casting. The bars are the harsh case: thin casters right beside the light. +local cx, cy, cz = 2.2, 2.1, 1.6 +local function bar(x, y, z, rx, ry, rz, radius, h) + local node = nodeNew() + nodeSetMesh(node, meshCylinder(radius, h, 8), brass) + nodeSetPosition(node, x, y, z) + nodeSetRotation(node, rx, ry, rz) + return node +end +for i = 0, 7 do + local a = i / 8 * 2 * math.pi + bar(cx + 0.35 * math.cos(a), cy, cz + 0.35 * math.sin(a), 0, 0, 0, 0.02, 1.0) +end +for _, dy in ipairs({ -0.5, 0, 0.5 }) do + for i = 0, 7 do + local a = (i + 0.5) / 8 * 2 * math.pi + bar(cx + 0.35 * math.cos(a), cy + dy, cz + 0.35 * math.sin(a), 0, -math.deg(a), 90, 0.015, 0.28) + end +end +local cagedMesh = nodeNew() +nodeSetMesh(cagedMesh, meshSphere(0.06, 12), glow) +nodeSetPosition(cagedMesh, cx, cy, cz) +nodeSetShadow(cagedMesh, false) +local caged = lightNew(LIGHT_POINT) +nodeSetPosition(caged, cx, cy, cz) +lightSetIntensity(caged, 4) +lightSetColor(caged, 255, 230, 190) +lightSetShadow(caged, true) + +local camera = nodeNew() +cameraSet(camera) +cameraSetPerspective(70, 0.05, 60) + +function onOverlayUpdate() + frames = frames + 1 + overlayClear() + if frames < 50 then + -- Inside: from a corner, looking across the room at the shaded lamp, pillars and the far wall. + nodeSetPosition(camera, 3.2 - frames * 0.006, 1.6, -3.2) + nodeLookAt(camera, -1.5, 1.4, 1.5) + elseif frames < 95 then + -- Inside, looking up at the ceiling above the shade. + nodeSetPosition(camera, 1.5, 0.8, -1.5) + nodeLookAt(camera, -0.5, 3.8, -0.3) + elseif frames < 140 then + -- Under the cage, looking up at its stripes running over the ceiling corner. + nodeSetPosition(camera, 1.6, 0.5, 1.0) + nodeLookAt(camera, 3.6, 3.8, 3.6) + else + -- Outside: the closed room from above; nothing of either bulb may leak out. + nodeSetPosition(camera, 9, 7, 9) + nodeLookAt(camera, 0, 1.5, 0) + end + fontPrint(20, 20, "Bulbs in a room, frame " .. frames) + if frames == 40 or frames == 85 or frames == 130 or frames == 175 then + singeScreenshot() + end + if frames == 180 then + singeQuit() + end +end diff --git a/testScripts/scene22.singe b/testScripts/scene22.singe new file mode 100644 index 000000000..0725b5781 --- /dev/null +++ b/testScripts/scene22.singe @@ -0,0 +1,110 @@ +-- Vehicles: a car built from primitives (a box chassis with a dynamic body, four wheel nodes) on a +-- track with a ramp, crates and a wall, driven by a scripted route: pull away, jump the ramp, brake, +-- turn, and reverse into the crates. The camera chases. +local font = fontLoad("Singe/FreeSansBold.ttf", 28) +local frames = 0 +local turned = false +local reversed = false + +fontSelect(font) +discPlay() +sceneEnable(true) +sceneSetBackground(0, 0, 0, 0) +sceneSetAmbient(70, 70, 80) + +local asphalt = materialNew() +materialSetColor(asphalt, 70, 70, 75) +materialSetRoughness(asphalt, 0.95) +local paint = materialNew() +materialSetColor(paint, 200, 40, 40) +materialSetMetallic(paint, 0.5) +materialSetRoughness(paint, 0.3) +local rubber = materialNew() +materialSetColor(rubber, 30, 30, 30) +local wood = materialNew() +materialSetColor(wood, 150, 100, 50) + +local function block(x, y, z, w, h, d, material, rz) + local node = nodeNew() + nodeSetMesh(node, meshBox(w, h, d), material) + nodeSetPosition(node, x, y, z) + if rz then nodeSetRotation(node, rz, 0, 0) end + bodyNew(node, BODY_STATIC, SHAPE_BOX, w, h, d) + return node +end + +-- The track runs along -Z (the car's nose). A long floor, a ramp, a landing, crates and a wall. +block(0, -0.1, -60, 40, 0.2, 200, asphalt) +block(0, 0.45, -18, 6, 0.2, 5, asphalt, 12) +block(0, 0.9, -23.4, 6, 0.2, 6, asphalt) +for i = 0, 5 do + local crate = nodeNew() + nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), wood) + nodeSetPosition(crate, -3 + (i % 3) * 0.65, 0.3 + math.floor(i / 3) * 0.62, -36) + bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6) + bodySetMass(crate, 4) +end +block(8, 1, -30, 0.3, 2, 12, asphalt) + +-- The car: chassis 1.8 x 0.5 x 4.0, wheels as children whose X axis is the axle. +local car = nodeNew() +nodeSetMesh(car, meshBox(1.8, 0.5, 4.0), paint) +nodeSetPosition(car, 0, 0.9, 0) +bodyNew(car, BODY_DYNAMIC, SHAPE_BOX, 1.8, 0.5, 4.0) +bodySetMass(car, 1500) +vehicleNew(car, VEHICLE_CAR) +local wheels = {} +for i, p in ipairs({ { -0.95, -0.2, -1.4 }, { 0.95, -0.2, -1.4 }, { -0.95, -0.2, 1.4 }, { 0.95, -0.2, 1.4 } }) do + local wheel = nodeNew() + nodeSetParent(wheel, car) + nodeSetPosition(wheel, p[1], p[2], p[3]) + local tyre = nodeNew() + nodeSetParent(tyre, wheel) + nodeSetMesh(tyre, meshCylinder(0.35, 0.25, 16), rubber) + nodeSetRotation(tyre, 0, 0, 90) + wheels[i] = vehicleAddWheel(car, wheel, 0.35, 0.25, 0.4) +end +vehicleSetEngine(car, 600, 6500, 1000) +vehicleSetAntiRoll(car, 800) + +local sun = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(sun, -5, 12, 6) +nodeLookAt(sun, 0, 0, -15) +lightSetIntensity(sun, 1.5) +lightSetShadow(sun, true) + +local camera = nodeNew() +cameraSet(camera) +cameraSetPerspective(60, 0.1, 200) + +function onOverlayUpdate() + local cx, cy, cz = nodeGetPosition(car) + local t = frames / 60 + frames = frames + 1 + -- Route by distance, since physics runs on the clock: floor it over the ramp, then brake and + -- steer, then reverse toward the crates, then stop. + if cz > -30 and not turned then + vehicleDrive(car, 1, 0, 0, 0) + elseif not turned then + vehicleDrive(car, 0, 0.7, 0.6, 0) + if vehicleGetSpeed(car) < 2 then turned = true end + elseif not reversed then + vehicleDrive(car, -1, 0, 0, 0) + if vehicleGetSpeed(car) < -6 then reversed = true end + else + vehicleDrive(car, 0, 0, 1, 1) + end + nodeSetPosition(camera, cx + 3, cy + 3, cz + 9) + nodeLookAt(camera, cx, cy, cz - 3) + overlayClear() + fontPrint(20, 20, string.format("Car, frame %d speed %.1f m/s gear %d rpm %.0f", frames, vehicleGetSpeed(car), vehicleGetGear(car), vehicleGetRpm(car))) + if frames % 30 == 0 then + debugPrint(string.format("frame %d car %.1f %.2f %.1f speed %.1f gear %d rpm %.0f ground %s %s %s %s slip %.2f", frames, cx, cy, cz, vehicleGetSpeed(car), vehicleGetGear(car), vehicleGetRpm(car), tostring(vehicleIsWheelOnGround(car, 0)), tostring(vehicleIsWheelOnGround(car, 1)), tostring(vehicleIsWheelOnGround(car, 2)), tostring(vehicleIsWheelOnGround(car, 3)), vehicleGetWheelSlip(car, 2))) + end + if frames == 60 or frames == 120 or frames == 170 then + singeScreenshot() + end + if frames == 180 then + singeQuit() + end +end diff --git a/testScripts/scene23.singe b/testScripts/scene23.singe new file mode 100644 index 000000000..71a9df7c9 --- /dev/null +++ b/testScripts/scene23.singe @@ -0,0 +1,118 @@ +-- Vehicles, second kind: a tank on six wheels that drives, pivots on the spot and climbs a slope, +-- and a motorcycle that leans through a turn, both from primitives, side by side. +local font = fontLoad("Singe/FreeSansBold.ttf", 28) +local frames = 0 + +fontSelect(font) +discPlay() +sceneEnable(true) +sceneSetBackground(0, 0, 0, 0) +sceneSetAmbient(70, 70, 80) + +local ground = materialNew() +materialSetColor(ground, 90, 110, 70) +materialSetRoughness(ground, 0.95) +local armour = materialNew() +materialSetColor(armour, 80, 90, 70) +local rubber = materialNew() +materialSetColor(rubber, 30, 30, 30) +local chrome = materialNew() +materialSetColor(chrome, 200, 200, 210) +materialSetMetallic(chrome, 0.8) +materialSetRoughness(chrome, 0.2) + +local floor = nodeNew() +nodeSetMesh(floor, meshBox(160, 0.2, 160), ground) +nodeSetPosition(floor, 0, -0.1, -30) +bodyNew(floor, BODY_STATIC, SHAPE_BOX, 160, 0.2, 160) +local slope = nodeNew() +nodeSetMesh(slope, meshBox(8, 0.2, 8), ground) +nodeSetPosition(slope, -6, 0.7, -24) +nodeSetRotation(slope, 20, 0, 0) +bodyNew(slope, BODY_STATIC, SHAPE_BOX, 8, 0.2, 8) + +local function wheel(parent, x, y, z, radius, width) + local node = nodeNew() + nodeSetParent(node, parent) + nodeSetPosition(node, x, y, z) + local tyre = nodeNew() + nodeSetParent(tyre, node) + nodeSetMesh(tyre, meshCylinder(radius, width, 12), rubber) + nodeSetRotation(tyre, 0, 0, 90) + return node +end + +-- The tank: a heavy hull, three road wheels a side. +local tank = nodeNew() +nodeSetMesh(tank, meshBox(3, 1, 5), armour) +nodeSetPosition(tank, -6, 1.2, 0) +bodyNew(tank, BODY_DYNAMIC, SHAPE_BOX, 3, 1, 5) +bodySetMass(tank, 8000) +vehicleNew(tank, VEHICLE_TANK) +for _, z in ipairs({ -1.8, 0, 1.8 }) do + vehicleAddWheel(tank, wheel(tank, -1.7, -0.4, z, 0.5, 0.4), 0.5, 0.4, 0.5) + vehicleAddWheel(tank, wheel(tank, 1.7, -0.4, z, 0.5, 0.4), 0.5, 0.4, 0.5) +end +vehicleSetEngine(tank, 1500, 4000, 800) + +-- The motorcycle: a slim frame, one wheel each end, the rear driven. +local bike = nodeNew() +nodeSetMesh(bike, meshBox(0.3, 0.6, 1.6), chrome) +nodeSetPosition(bike, 4, 0.9, 0) +bodyNew(bike, BODY_DYNAMIC, SHAPE_BOX, 0.3, 0.6, 1.6) +bodySetMass(bike, 250) +vehicleNew(bike, VEHICLE_MOTORCYCLE) +vehicleAddWheel(bike, wheel(bike, 0, -0.3, -0.8, 0.35, 0.12), 0.35, 0.12, 0.3) +vehicleAddWheel(bike, wheel(bike, 0, -0.3, 0.8, 0.35, 0.12), 0.35, 0.12, 0.3) +vehicleSetEngine(bike, 150, 9000, 1200) + +local sun = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(sun, -5, 12, 6) +nodeLookAt(sun, 0, 0, -15) +lightSetIntensity(sun, 1.5) +lightSetShadow(sun, true) + +local camera = nodeNew() +cameraSet(camera) +cameraSetPerspective(60, 0.1, 200) + +function onOverlayUpdate() + local tx, ty, tz = nodeGetPosition(tank) + local bx, by, bz = nodeGetPosition(bike) + local t = frames / 60 + frames = frames + 1 + -- Tank: forward, then pivot, then on toward the slope. Bike: accelerate, then lean into a turn. + if tz > -12 and frames < 100 then + vehicleDrive(tank, 1, 0, 0, 0) + elseif frames < 130 then + vehicleDrive(tank, 0, 1, 0, 0) + else + vehicleDrive(tank, 1, 0, 0, 0) + end + if bz > -15 then + vehicleDrive(bike, 1, 0, 0, 0) + else + vehicleDrive(bike, 0.4, 0.35, 0, 0) + end + -- The camera watches the tank for the first two thirds, then chases the bike. + if frames < 125 then + nodeSetPosition(camera, tx + 8, ty + 5, tz + 10) + nodeLookAt(camera, tx, ty, tz) + else + nodeSetPosition(camera, bx + 6, by + 3, bz + 8) + nodeLookAt(camera, bx, by, bz) + end + overlayClear() + fontPrint(20, 20, string.format("Tank %.1f m/s bike %.1f m/s frame %d", vehicleGetSpeed(tank), vehicleGetSpeed(bike), frames)) + if frames % 30 == 0 then + local _, ry = nodeGetRotation(tank) + local _, _, roll = nodeGetRotation(bike) + debugPrint(string.format("frame %d tank %.1f %.2f %.1f yaw %.0f speed %.1f | bike %.1f %.2f %.1f roll %.0f speed %.1f", frames, tx, ty, tz, ry, vehicleGetSpeed(tank), bx, by, bz, roll, vehicleGetSpeed(bike))) + end + if frames == 60 or frames == 120 or frames == 170 then + singeScreenshot() + end + if frames == 180 then + singeQuit() + end +end diff --git a/testScripts/scene24.singe b/testScripts/scene24.singe new file mode 100644 index 000000000..a81d203ca --- /dev/null +++ b/testScripts/scene24.singe @@ -0,0 +1,137 @@ +-- Water: a pool that floats a light crate, sinks an anchor and bobs a ball; a river with a current +-- that carries a crate; the Fox wading in and swimming across; a raft with a propeller and rudder. +local font = fontLoad("Singe/FreeSansBold.ttf", 28) +local frames = 0 + +fontSelect(font) +discPlay() +sceneEnable(true) +sceneSetBackground(0, 0, 0, 0) +sceneSetAmbient(70, 70, 80) + +local stone = materialNew() +materialSetColor(stone, 130, 130, 140) +materialSetRoughness(stone, 0.9) +local wood = materialNew() +materialSetColor(wood, 160, 110, 60) +local iron = materialNew() +materialSetColor(iron, 60, 60, 70) +materialSetMetallic(iron, 0.8) +local water = materialNew() +materialSetColor(water, 60, 120, 200) +materialSetBlend(water, true) +materialSetRoughness(water, 0.2) +local red = materialNew() +materialSetColor(red, 220, 50, 50) + +-- The ground, a pool cut into it (a deck round the water), and a river channel with a current. +local ground = nodeNew() +nodeSetMesh(ground, meshBox(30, 0.2, 30), stone) +nodeSetPosition(ground, 0, -3.1, 0) +bodyNew(ground, BODY_STATIC, SHAPE_BOX, 30, 0.2, 30) +local deck = nodeNew() +nodeSetMesh(deck, meshBox(4, 3, 12), stone) +nodeSetPosition(deck, -6, -1.5, 0) +bodyNew(deck, BODY_STATIC, SHAPE_BOX, 4, 3, 12) +local pool = nodeNew() +nodeSetMesh(pool, meshBox(8, 3, 12), water) +nodeSetPosition(pool, 0, -1.5, 0) +bodyNew(pool, BODY_STATIC, SHAPE_BOX, 8, 3, 12) +bodySetWater(pool, 1.0, 0.6, 0.2) +local river = nodeNew() +nodeSetMesh(river, meshBox(6, 3, 12), water) +nodeSetPosition(river, 7, -1.5, 0) +bodyNew(river, BODY_STATIC, SHAPE_BOX, 6, 3, 12) +bodySetWater(river, 1.0, 0.8, 0.2) +bodySetCurrent(river, 0, 0, 2.5) + +-- Things in the water: a crate that floats, an anchor that sinks, a ball, a crate in the river. +local crate = nodeNew() +nodeSetMesh(crate, meshBox(0.8, 0.8, 0.8), wood) +nodeSetPosition(crate, -1, 1.5, 2.5) +bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.8, 0.8, 0.8) +bodySetMass(crate, 20) +bodySetBuoyancy(crate, 1.6) +local anchor = nodeNew() +nodeSetMesh(anchor, meshBox(0.5, 0.5, 0.5), iron) +nodeSetPosition(anchor, 1.5, 1.5, -3) +bodyNew(anchor, BODY_DYNAMIC, SHAPE_BOX, 0.5, 0.5, 0.5) +bodySetMass(anchor, 200) +bodySetBuoyancy(anchor, 0.3) +local ball = nodeNew() +nodeSetMesh(ball, meshSphere(0.4, 16), red) +nodeSetPosition(ball, 2, 2.5, 2) +bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.4) +bodySetMass(ball, 2) +bodySetBuoyancy(ball, 2.0) +local drift = nodeNew() +nodeSetMesh(drift, meshBox(0.7, 0.7, 0.7), wood) +nodeSetPosition(drift, 7, 0.5, -5) +bodyNew(drift, BODY_DYNAMIC, SHAPE_BOX, 0.7, 0.7, 0.7) +bodySetMass(drift, 10) +bodySetBuoyancy(drift, 1.5) + +-- The raft: a flat hull with the propeller at the stern. +local raft = nodeNew() +nodeSetMesh(raft, meshBox(1.6, 0.3, 2.4), wood) +nodeSetPosition(raft, 1, 0.3, 4) +bodyNew(raft, BODY_DYNAMIC, SHAPE_BOX, 1.6, 0.3, 2.4) +bodySetMass(raft, 120) +bodySetBuoyancy(raft, 3.0) +vehicleNew(raft, VEHICLE_BOAT) +vehicleSetThrust(raft, 600, 0, -0.1, 1.2) +vehicleSetRudder(raft, 300) + +-- The Fox wades in from the deck and swims across. +local hero = nodeNew() +nodeSetPosition(hero, -5, 0.05, -2) +playerNew(hero, 0.3, 1.0) +local fox = modelInstance(modelLoad("testScripts/Models/Fox.glb")) +nodeSetParent(fox, hero) +nodeSetScale(fox, 0.01) +nodeSetRotation(fox, 0, 90, 0) +animationPlay(fox, "Walk", true) + +local sun = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(sun, -5, 10, 6) +nodeLookAt(sun, 0, 0, 0) +lightSetIntensity(sun, 1.5) +lightSetShadow(sun, true) + +local camera = nodeNew() +nodeSetPosition(camera, 1, 6, 12) +nodeLookAt(camera, 1, -0.5, 0) +cameraSet(camera) + +function onTrigger(trigger, other, entered) + if other == hero then + debugPrint(string.format("frame %d fox %s %s", frames, entered and "entered" or "left", (trigger == pool) and "the pool" or ((trigger == river) and "the river" or "a trigger"))) + end +end + +function onOverlayUpdate() + local hx, hy, hz = nodeGetPosition(hero) + frames = frames + 1 + if playerIsSwimming(hero) then + playerMove(hero, 1.5, 0.6, 0) + else + playerMove(hero, 1.5, 0) + end + vehicleDrive(raft, 0.7, (frames > 30) and 0.9 or 0, 0) + overlayClear() + fontPrint(20, 20, string.format("Water, frame %d fox %s at %.1f %.2f raft %.1f m/s", frames, playerIsSwimming(hero) and "swimming" or "walking", hx, hy, vehicleGetSpeed(raft))) + if frames % 30 == 0 then + local cx, cy = nodeGetPosition(crate) + local ax, ay = nodeGetPosition(anchor) + local bx, by = nodeGetPosition(ball) + local dx, dy, dz = nodeGetPosition(drift) + local rx, ry, rz = nodeGetPosition(raft) + debugPrint(string.format("frame %d fox %.1f %.2f %s | crate y %.2f anchor y %.2f ball y %.2f | drift z %.1f | raft %.1f %.2f %.1f speed %.1f", frames, hx, hy, tostring(playerIsSwimming(hero)), cy, ay, by, dz, rx, ry, rz, vehicleGetSpeed(raft))) + end + if frames == 50 or frames == 110 or frames == 170 then + singeScreenshot() + end + if frames == 180 then + singeQuit() + end +end diff --git a/testScripts/scene25.singe b/testScripts/scene25.singe new file mode 100644 index 000000000..8321e469b --- /dev/null +++ b/testScripts/scene25.singe @@ -0,0 +1,105 @@ +-- Ragdolls: one Fox runs at a wall and collapses on impact; another is dropped onto a flight of +-- stairs limp; a third lies on the floor and gets up again under motor strength. +local font = fontLoad("Singe/FreeSansBold.ttf", 28) +local frames = 0 + +fontSelect(font) +discPlay() +sceneEnable(true) +sceneSetBackground(0, 0, 0, 0) +sceneSetAmbient(70, 70, 80) + +local stone = materialNew() +materialSetColor(stone, 120, 120, 130) +materialSetRoughness(stone, 0.9) +local steel = materialNew() +materialSetColor(steel, 90, 110, 150) + +local function block(x, y, z, w, h, d, material) + local node = nodeNew() + nodeSetMesh(node, meshBox(w, h, d), material) + nodeSetPosition(node, x, y, z) + bodyNew(node, BODY_STATIC, SHAPE_BOX, w, h, d) + return node +end +block(0, -0.1, 0, 20, 0.2, 14, stone) +block(4, 0.75, -1, 0.3, 1.5, 3, steel) -- The wall the runner hits +for i = 1, 5 do + block(-4 - i * 0.5, i * 0.15, 3, 0.5, i * 0.3, 3, stone) -- Stairs going up to the left +end + +local foxModel = modelLoad("testScripts/Models/Fox.glb") + +-- The runner: a player carrying the Fox; the ragdoll is made now and switched on at the crash. +local runner = nodeNew() +nodeSetPosition(runner, -1, 0.05, -1) +playerNew(runner, 0.3, 0.9) +local runnerFox = modelInstance(foxModel) +nodeSetParent(runnerFox, runner) +nodeSetScale(runnerFox, 0.01) +nodeSetRotation(runnerFox, 0, 90, 0) +animationPlay(runnerFox, "Run", true) +ragdollNew(runnerFox) + +-- The faller: dropped limp from above the stairs. +local faller = modelInstance(foxModel) +nodeSetPosition(faller, -6.5, 2.5, 3) +nodeSetScale(faller, 0.01) +nodeSetRotation(faller, 30, 40, 0) +animationPlay(faller, "Survey", true) +ragdollNew(faller) +ragdollActivate(faller) + +-- The riser: lying limp on the floor, then pulled back toward its standing pose. +local riser = modelInstance(foxModel) +nodeSetPosition(riser, 3, 0, 3.5) +nodeSetScale(riser, 0.01) +nodeSetRotation(riser, 0, -30, 0) +animationPlay(riser, "Survey", true) +ragdollNew(riser) +ragdollActivate(riser) + +local sun = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(sun, -4, 8, 6) +nodeLookAt(sun, 0, 0, 0) +lightSetIntensity(sun, 1.5) +lightSetShadow(sun, true) + +local camera = nodeNew() +nodeSetPosition(camera, 0, 4, 9) +nodeLookAt(camera, -1, 0.5, 0) +cameraSet(camera) + +local crashed = false + +function onCollision(a, b, x, y, z, speed) + if not crashed and (a == runner or b == runner) and speed > 1.5 then + crashed = true + playerSetEnabled(runner, false) + ragdollActivate(runnerFox) + ragdollApplyImpulse(runnerFox, "b_Spine02_03", 40, 30, 0) + end +end + +function onOverlayUpdate() + frames = frames + 1 + if not crashed then + playerMove(runner, 4, 0) + end + if frames == 100 then + ragdollSetStrength(riser, 60) + end + overlayClear() + fontPrint(20, 20, string.format("Ragdolls, frame %d %s faller %s riser %s", frames, crashed and "crashed" or "running", ragdollIsResting(faller) and "resting" or "moving", ragdollIsResting(riser) and "resting" or "moving")) + if frames % 30 == 0 then + local rx, ry, rz = nodeGetPosition(runner) + local fx, fy, fz = nodeGetWorldPosition(faller) + debugPrint(string.format("frame %d runner %.2f %.2f crashed %s | faller %.2f %.2f %.2f resting %s | riser resting %s", frames, rx, ry, tostring(crashed), fx, fy, fz, tostring(ragdollIsResting(faller)), tostring(ragdollIsResting(riser)))) + end + if frames == 50 or frames == 110 or frames == 170 then + singeScreenshot() + end + if frames == 180 then + singeQuit() + end +end diff --git a/testScripts/scene26.singe b/testScripts/scene26.singe new file mode 100644 index 000000000..4c01966c2 --- /dev/null +++ b/testScripts/scene26.singe @@ -0,0 +1,112 @@ +-- Soft bodies: a flag pinned to a pole, a sheet pinned at four corners catching a crate, a rope +-- swinging from a crane, and a balloon under pressure dropped onto the floor. +local font = fontLoad("Singe/FreeSansBold.ttf", 28) +local frames = 0 + +fontSelect(font) +discPlay() +sceneEnable(true) +sceneSetBackground(0, 0, 0, 0) +sceneSetAmbient(70, 70, 80) + +local stone = materialNew() +materialSetColor(stone, 120, 120, 130) +materialSetRoughness(stone, 0.9) +local cloth = materialNew() +materialSetColor(cloth, 220, 60, 60) +materialSetDoubleSided(cloth, true) +materialSetRoughness(cloth, 0.8) +local linen = materialNew() +materialSetColor(linen, 230, 225, 200) +materialSetDoubleSided(linen, true) +local wood = materialNew() +materialSetColor(wood, 150, 100, 50) +local hemp = materialNew() +materialSetColor(hemp, 190, 160, 100) +local rubber = materialNew() +materialSetColor(rubber, 80, 180, 90) + +local floor = nodeNew() +nodeSetMesh(floor, meshBox(16, 0.2, 12), stone) +nodeSetPosition(floor, 0, -0.1, 0) +bodyNew(floor, BODY_STATIC, SHAPE_BOX, 16, 0.2, 12) + +-- The flag: a grid standing upright, pinned along its left edge to the pole. +local pole = nodeNew() +nodeSetMesh(pole, meshCylinder(0.04, 3, 8), wood) +nodeSetPosition(pole, -5, 1.5, 0) +bodyNew(pole, BODY_STATIC, SHAPE_CYLINDER, 0.04, 3) +local flag = nodeNew() +nodeSetMesh(flag, meshPlane(1.6, 1.0, 16, 10), cloth) +nodeSetPosition(flag, -4.16, 2.4, 0) +nodeSetRotation(flag, 90, 0, 0) +softNew(flag, SOFT_CLOTH) +softSetStiffness(flag, 0.95, 0.1) +softSetMass(flag, 0.5) +for _, y in ipairs({ 1.9, 2.15, 2.4, 2.65, 2.9 }) do + softPin(flag, -4.96, y, 0) +end + +-- The sheet: pinned at four corners, a crate dropped onto it. +local sheet = nodeNew() +nodeSetMesh(sheet, meshPlane(3, 3, 20, 20), linen) +nodeSetPosition(sheet, 0, 1.6, 0) +softNew(sheet, SOFT_CLOTH) +softSetStiffness(sheet, 1.0, 0.05) +softSetMass(sheet, 4) +for _, c in ipairs({ { -1.5, -1.5 }, { 1.5, -1.5 }, { -1.5, 1.5 }, { 1.5, 1.5 } }) do + softPin(sheet, c[1], 1.6, c[2]) +end +local crate = nodeNew() +nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), wood) +nodeSetPosition(crate, 0.2, 3.5, 0.1) +nodeSetRotation(crate, 20, 30, 0) +bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6) +bodySetMass(crate, 3) + +-- The rope: from a crane arm out and down, swinging free from a horizontal start. +local crane = nodeNew() +nodeSetPosition(crane, 4, 4, -1) +local rope = nodeNew() +nodeSetPosition(rope, 4, 4, -1) +softNew(rope, SOFT_ROPE, 6.5, 4, -1, 16, 0.04) +nodeSetMaterial(rope, hemp) +softSetMass(rope, 1.5) +softPin(rope, 4, 4, -1, crane) + +-- The balloon: a sphere kept round by pressure, dropped and squashed. +local balloon = nodeNew() +nodeSetMesh(balloon, meshSphere(0.5, 14), rubber) +nodeSetPosition(balloon, 4, 2.5, 2) +softNew(balloon, SOFT_BODY) +softSetStiffness(balloon, 0.8, 0.3) +softSetMass(balloon, 0.6) +softSetPressure(balloon, 8) + +local sun = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(sun, -4, 8, 6) +nodeLookAt(sun, 0, 0, 0) +lightSetIntensity(sun, 1.5) +lightSetShadow(sun, true) + +local camera = nodeNew() +nodeSetPosition(camera, 0, 3.5, 9) +nodeLookAt(camera, 0, 1.5, 0) +cameraSet(camera) + +function onOverlayUpdate() + frames = frames + 1 + overlayClear() + fontPrint(20, 20, "Soft bodies, frame " .. frames) + if frames % 30 == 0 then + local cx, cy = nodeGetPosition(crate) + local bx, by = nodeGetPosition(balloon) + debugPrint(string.format("frame %d crate y %.2f balloon y %.2f", frames, cy, by)) + end + if frames == 40 or frames == 100 or frames == 170 then + singeScreenshot() + end + if frames == 180 then + singeQuit() + end +end diff --git a/testScripts/scene27.singe b/testScripts/scene27.singe new file mode 100644 index 000000000..e6a141099 --- /dev/null +++ b/testScripts/scene27.singe @@ -0,0 +1,105 @@ +-- The Khronos Sponza atrium, packed with util/packGlb.py from the glTF-Sample-Models 2.0 Sponza. +-- A warm sun falls through the roof opening onto the atrium floor with hard shadows, braziers along the nave +-- add pools of firelight with particle flames and smoke, and dust drifts in the sunbeams; the +-- camera walks the ground floor, crosses the atrium, climbs to the gallery, then passes the columns. +local font = fontLoad("Singe/FreeSansBold.ttf", 28) +local frames = 0 + +fontSelect(font) +sceneEnable(true) +sceneSetBackground(120, 150, 200, 255) +sceneSetAmbient(52, 56, 74) +sceneSetShadowSize(2048) + +local model = modelLoad("testScripts/Models/Sponza.glb") +local sponza = modelInstance(model) + +-- The sun: high and warm so it falls through the roof opening onto the atrium floor. +local sun = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(sun, 5, 22, 4) +nodeLookAt(sun, -2, 0, -1.5) +lightSetIntensity(sun, 4.5) +lightSetColor(sun, 255, 236, 205) +lightSetShadow(sun, true) + +-- Braziers: warm point lights along the nave, the two nearest the camera path casting shadows, +-- each with a flame and a thread of smoke. +local fire = materialNew() +materialSetColor(fire, 60, 40, 30) +local braziers = {} +for i, p in ipairs({ { 6.5, 1.1, -2.2 }, { 6.5, 1.1, 2.2 }, { -6.5, 1.1, -2.2 }, { -6.5, 1.1, 2.2 }, { 0, 1.1, -2.4 }, { 0, 1.1, 2.4 } }) do + local bowl = nodeNew() + nodeSetMesh(bowl, meshCylinder(0.35, 0.25, 12), fire) + nodeSetPosition(bowl, p[1], p[2] - 0.15, p[3]) + local glowNode = nodeNew() + nodeSetPosition(glowNode, p[1], p[2] + 0.35, p[3]) + local lamp = lightNew(LIGHT_POINT) + nodeSetParent(lamp, glowNode) + lightSetColor(lamp, 255, 170, 80) + lightSetIntensity(lamp, 3.4) + lightSetRange(lamp, 12) + lightSetShadow(lamp, i <= 2) + local flame = emitterNew(glowNode) + emitterSetBlend(flame, PARTICLE_ADD) + emitterSetRate(flame, 60) + emitterSetLife(flame, 0.4, 0.8) + emitterSetSpeed(flame, 0.5, 1.1) + emitterSetDirection(flame, 0, 1, 0) + emitterSetSpread(flame, 18) + emitterSetSize(flame, 0.22, 0.04, 0.15) + emitterSetColor(flame, 255, 190, 90, 255, 255, 60, 0, 0) + emitterSetRadius(flame, 0.1) + emitterStart(flame) + local smoke = emitterNew(glowNode) + emitterSetRate(smoke, 12) + emitterSetLife(smoke, 2.5, 4) + emitterSetSpeed(smoke, 0.4, 0.7) + emitterSetDirection(smoke, 0, 1, 0) + emitterSetSpread(smoke, 12) + emitterSetSize(smoke, 0.15, 0.8, 0.15) + emitterSetColor(smoke, 90, 80, 75, 90, 50, 45, 45, 0) + emitterSetSpin(smoke, -25, 25) + emitterStart(smoke) + braziers[i] = glowNode +end + +-- Dust in the sunbeams over the atrium floor. +local air = nodeNew() +nodeSetPosition(air, 0, 3, 0) +local dust = emitterNew(air) +emitterSetRate(dust, 40) +emitterSetLife(dust, 5, 8) +emitterSetSpeed(dust, 0.05, 0.25) +emitterSetSpread(dust, 180) +emitterSetSize(dust, 0.05, 0.05) +emitterSetColor(dust, 255, 240, 210, 0, 255, 240, 210, 190) +emitterSetRadius(dust, 5) +emitterSetMax(dust, 400) +emitterStart(dust) +emitterBurst(dust, 300) + +local camera = nodeNew() +cameraSet(camera) +cameraSetPerspective(65, 0.05, 100) + +local stops = { + { 10, 1.6, 0, -10, 2.5, 0 }, -- Ground floor, along the nave past the braziers + { 1.2, 1.6, 5.2, -1, 3.5, -5 }, -- Across the atrium toward the far arcade + { -9, 5.5, -1, 6, 3, 1 }, -- From the upper gallery + { 5, 1.2, -1.5, -8, 2, 1.5 }, -- Low, past the columns and a brazier +} + +function onOverlayUpdate() + local stop = stops[math.min(#stops, math.floor(frames / 45) + 1)] + frames = frames + 1 + nodeSetPosition(camera, stop[1], stop[2], stop[3]) + nodeLookAt(camera, stop[4], stop[5], stop[6]) + overlayClear() + fontPrint(20, 20, "Sponza, frame " .. frames) + if frames == 40 or frames == 85 or frames == 130 or frames == 175 then + singeScreenshot() + end + if frames == 180 then + singeQuit() + end +end diff --git a/thirdparty/openssl/Configure b/thirdparty/openssl/Configure index cbba1749b..423c68ce5 100755 --- a/thirdparty/openssl/Configure +++ b/thirdparty/openssl/Configure @@ -2469,12 +2469,16 @@ EOF my $i; my $i2; my $m; my $d; my $d2; if ($unified_info{generate}->{$ddest} && $f =~ m/^(.*?)\|(.*)$/) { - $i = $1; + # Singe: resolve every path from the inclusion path as written. Reusing $i + # after cleanfile() made it relative to the build directory sent the source + # tree lookups through "../.." and created util/perl outside the tree when + # the build directory is nested two levels inside it. + my $inc = $1; $m = $2; - $i = cleanfile($sourced, $i, $blddir); - $i2 = cleanfile($buildd, $i, $blddir); - $d = cleanfile($sourced, "$i/$m", $blddir); - $d2 = cleanfile($buildd, "$i/$m", $blddir); + $i = cleanfile($sourced, $inc, $blddir); + $i2 = cleanfile($buildd, $inc, $blddir); + $d = cleanfile($sourced, "$inc/$m", $blddir); + $d2 = cleanfile($buildd, "$inc/$m", $blddir); } else { $d = cleanfile($sourced, $f, $blddir); $d2 = cleanfile($buildd, $f, $blddir); diff --git a/util/packGlb.py b/util/packGlb.py new file mode 100644 index 000000000..2f9b8a317 --- /dev/null +++ b/util/packGlb.py @@ -0,0 +1,37 @@ +# Packs a .gltf with external buffers and images into one self-contained .glb, which is the only +# model format Singe loads. Usage: python3 util/packGlb.py Model.gltf Model.glb +import json, os, struct, sys +src, out = sys.argv[1], sys.argv[2] +base = os.path.dirname(src) +g = json.load(open(src)) +binary = bytearray() +views = g.setdefault('bufferViews', []) +def add(data): + while len(binary) % 4: binary.append(0) + off = len(binary); binary.extend(data) + return off +# Buffers: concatenate, remapping views. +offsets = [] +for b in g.get('buffers', []): + data = open(os.path.join(base, b['uri']), 'rb').read() + offsets.append(add(data)) +for v in views: + v['byteOffset'] = v.get('byteOffset', 0) + offsets[v.get('buffer', 0)] + v['buffer'] = 0 +# Images: each becomes a buffer view with a mime type. +for img in g.get('images', []): + uri = img.pop('uri') + data = open(os.path.join(base, uri), 'rb').read() + ext = uri.lower().rsplit('.', 1)[-1] + views.append({'buffer': 0, 'byteOffset': add(data), 'byteLength': len(data)}) + img['bufferView'] = len(views) - 1 + img['mimeType'] = 'image/png' if ext == 'png' else 'image/jpeg' +while len(binary) % 4: binary.append(0) +g['buffers'] = [{'byteLength': len(binary)}] +js = json.dumps(g, separators=(',', ':')).encode() +while len(js) % 4: js += b' ' +with open(out, 'wb') as f: + f.write(struct.pack('<4sII', b'glTF', 2, 12 + 8 + len(js) + 8 + len(binary))) + f.write(struct.pack('