Rework cursor aim: cursor is world-fixed, pilot turns until reticle meets it

- Move cursor state to a world direction so the offset updates as the craft turns, not on mouse input alone
- Move cursor aim tunables from separate TOML files into Config, under cursor_aim sections
- Pilot banks by a level-wings cursor angle and pulls a level-turn rate, capped by live lift and measured control rate
- Measure each cursor-flown node's steady pitch rate from control torque over pitch damping
- Accumulate maximum stall-limited lift per tick for the pilot's turn-rate and bank limits
- Bump mod version to 0.4.2
This commit is contained in:
richard committed 2026-11-11 19:54:24 +01:00
1 parent 23f0f91ede
commit 3478a04885
10 files changed
+314 -177

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ mapping_version=2023.09.03-1.20.1
mod_id=improved_aerodynamics
mod_name=VS: Improved Aerodynamics
mod_license=MIT
mod_version=0.4.0
mod_version=0.4.2
azurelib_version=3.2.7
mod_group_id=dev.lumareth.improvedaerodynamics
mod_authors=Lumareth
@@ -204,6 +204,47 @@ public final class Config {
.comment("The nozzle gives off light while its afterburner is lit")
.define("lightEnabled", true);
static {
BUILDER.pop();
BUILDER.comment("Cursor aim: the mouse moves a cursor on screen, a virtual pilot flies the control node's craft by it").push("cursor_aim");
}
public static final ForgeConfigSpec.BooleanValue CURSOR_AIM_ENABLED = BUILDER.comment("Whether pilots may use cursor aim on this server")
.define("enabled", true);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_FULL_DEFLECTION_DEGREES = BUILDER.comment("Angle of the cursor from the gun reticle at which the virtual pilot turns as hard as it can: the maximum bank",
"sideways, the full pitch rate up or down. Closer than that it turns more gently, so the reticle settles onto",
"the cursor. How fast the craft can turn is worked out from the craft itself")
.defineInRange("fullDeflectionDegrees", 30.0, 5.0, 90.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_DEADZONE_DEGREES = BUILDER.comment("Radius around the gun reticle in degrees inside which the pilot stops steering towards the cursor. The reticle",
"then settles this far from the cursor. Outside it the command grows from zero, there is no jump at the edge")
.defineInRange("deadzoneDegrees", 0.0, 0.0, 15.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MAX_ROLL_RATE_DEGREES = BUILDER.comment("Fastest roll in degrees per second the pilot asks for when it rolls to a bank angle")
.defineInRange("maxRollRateDegrees", 150.0, 10.0, 720.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MAX_BANK_DEGREES = BUILDER.comment("Bank angle the pilot rolls to when the cursor is at full sideways deflection, at most. Craft whose wings",
"cannot hold that bank in a level turn get less")
.defineInRange("maxBankDegrees", 70.0, 10.0, 90.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_BANK_GAIN = BUILDER.comment("Roll rate in rad/s per radian of bank angle error, how eagerly the pilot rolls to the wanted bank and levels the wings")
.defineInRange("bankGain", 3.0, 0.2, 20.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_RATE_GAIN = BUILDER.comment("How hard the pilot corrects its turn rate, in angular acceleration per rad/s of rate error. Lower is smoother,",
"higher follows the cursor tighter and can start to oscillate")
.defineInRange("rateGain", 5.0, 0.5, 40.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_PUSH_FACTOR = BUILDER.comment("A cursor below the gun reticle pushes the nose down. This is how much of the pull rate that may be, since",
"negative g is harder on the craft and the pilot")
.defineInRange("pushFactor", 0.4, 0.0, 1.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_STALLED_PITCH_FACTOR = BUILDER.comment("Multiplier for the pitch rate the pilot asks for while the wings are stalled, so it does not keep pulling")
.defineInRange("stalledPitchFactor", 0.3, 0.0, 1.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MIN_SPEED = BUILDER.comment("Below this speed in blocks per second the pilot does nothing, the surfaces have no effect anyway")
.defineInRange("minSpeed", 15.0, 0.0, 200.0);
static {
BUILDER.pop();
}
@@ -225,6 +266,40 @@ public final class Config {
"fixed 20 degree steps. Needs physicsRateAerodynamics on the server")
.define("animateControlSurfaces", true);
static {
CLIENT_BUILDER.comment("Cursor aim, client side").push("cursor_aim");
}
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_LOOK_AHEAD = CLIENT_BUILDER.comment("How strongly the camera looks into the direction of the turn. 0 never moves it, 1 (100%) turns it quickly",
"to look where the cursor points. In between it follows more slowly")
.defineInRange("lookAhead", 0.0, 0.0, 1.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MOUSE_SENSITIVITY = CLIENT_BUILDER.comment("Multiplier for how far the mouse moves the cursor (or the camera while the free look key is held)")
.defineInRange("mouseSensitivity", 1.0, 0.05, 10.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_SPRING_BACK_SECONDS = CLIENT_BUILDER.comment("The cursor drifts to the gun reticle with this time constant in seconds, which ends the turn by itself.",
"0, the default, keeps it where you put it in the world, so the craft turns until the reticle is on it")
.defineInRange("springBackSeconds", 0.0, 0.0, 10.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MAX_ANGLE_DEGREES = CLIENT_BUILDER.comment("Furthest the cursor can go from the gun reticle, 135 is a 270 degree cone")
.defineInRange("maxCursorAngleDegrees", 135.0, 10.0, 180.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_ZOOM_STEP = CLIENT_BUILDER.comment("Blocks the camera moves per mouse wheel notch")
.defineInRange("zoomStep", 2.0, 0.1, 20.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_ZOOM_MAX = CLIENT_BUILDER.comment("Furthest the camera can be pulled back from its normal distance, in blocks")
.defineInRange("zoomMax", 80.0, 0.0, 500.0);
public static final ForgeConfigSpec.DoubleValue CURSOR_AIM_MARKER_SIZE = CLIENT_BUILDER.comment("Radius of the cursor ring on screen")
.defineInRange("markerSize", 0.04, 0.01, 0.3);
public static final ForgeConfigSpec.BooleanValue CURSOR_AIM_SHOW_RETICLE = CLIENT_BUILDER.comment("Draw a small cross where the gun reticle points, the zero of the cursor, so you can see it when the camera looks away")
.define("showReticle", true);
static {
CLIENT_BUILDER.pop();
}
static final ForgeConfigSpec CLIENT_SPEC = CLIENT_BUILDER.build();
private Config() {
@@ -1,11 +1,9 @@
package dev.lumareth.improvedAerodynamics;
import com.mojang.logging.LogUtils;
import dev.lumareth.improvedAerodynamics.client.aim.CursorAimClientConfig;
import dev.lumareth.improvedAerodynamics.client.pipper.PipperConfig;
import dev.lumareth.improvedAerodynamics.exhaust.ExhaustSync;
import dev.lumareth.improvedAerodynamics.exhaust.NozzleContent;
import dev.lumareth.improvedAerodynamics.flight.CursorAimConfig;
import dev.lumareth.improvedAerodynamics.flight.PhysicsBridge;
import dev.lumareth.improvedAerodynamics.network.Network;
import dev.lumareth.improvedAerodynamics.terrain.LookaheadLoader;
@@ -34,8 +32,6 @@ public class ImprovedAerodynamics {
ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, Config.CLIENT_SPEC);
// Own file name: the default one is taken by Config.CLIENT_SPEC
ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, PipperConfig.SPEC, "improved_aerodynamics-pipper-client.toml");
ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, CursorAimConfig.SPEC, "improved_aerodynamics-cursor-aim.toml");
ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, CursorAimClientConfig.SPEC, "improved_aerodynamics-cursor-aim-client.toml");
NozzleContent.register(FMLJavaModLoadingContext.get().getModEventBus());
Network.register();
}
@@ -1,5 +1,6 @@
package dev.lumareth.improvedAerodynamics.client.aim;
import dev.lumareth.improvedAerodynamics.Config;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vertex.BufferBuilder;
import com.mojang.blaze3d.vertex.BufferUploader;
@@ -44,9 +45,10 @@ import java.lang.reflect.Method;
/**
* Cursor aim, the client side. While it is on:
* <ul>
* <li>the mouse moves a cursor instead of the camera. The cursor is a direction relative to the gun reticle (the
* craft's boresight), at most {@code maxCursorAngleDegrees} away from it, and springs back to it when the mouse
* rests. It is sent to the server every tick, where a virtual pilot flies the craft by it;</li>
* <li>the mouse moves a cursor instead of the camera. The cursor is a fixed direction in the world: the craft
* turning does not drag it along, so the virtual pilot on the server steers until the gun reticle (the craft's
* boresight) sits on the cursor again. It is kept at most {@code maxCursorAngleDegrees} from the reticle and, if
* spring back is set, drifts back to it. Every tick it is sent to the server as an offset from the reticle;</li>
* <li>the camera is a chase view, looking along the reticle, or, with look ahead, towards the cursor;</li>
* <li>holding the free look key moves the camera instead of the cursor, it eases back when released;</li>
* <li>the mouse wheel changes how far the camera is behind the craft.</li>
@@ -66,7 +68,9 @@ public final class CursorAimClient {
private static boolean active;
// Chosen with /aero controls, told by the server
private static boolean cursorAimMode;
// Cursor as components along the craft's forward (gun reticle), right and up axes
// Cursor as a world direction, null until the first frame puts it on the reticle
private static Vector3d cursorWorld;
// The same cursor as components along the craft's forward (gun reticle), right and up axes, as of the last update
private static double cf = 1.0;
private static double cr;
private static double cu;
@@ -109,9 +113,49 @@ public final class CursorAimClient {
deactivate(minecraft);
return;
}
// The craft has turned since the last frame: the cursor stays put in the world, its offset changes
Entity seat = AimSeat.seatOf(player);
AimBasis basis = minecraft.level != null ? AimBasis.of(minecraft.level, seat.blockPosition()) : null;
Quaterniondc rotation = basis != null ? shipRotation(minecraft.level, seat, false) : null;
if (rotation != null) {
cursorFromWorld(basis, rotation);
}
send(minecraft, true);
}
/** Rotation of the ship the seat is on, its render transform when drawing, null if it is not on a ship. */
private static Quaterniondc shipRotation(ClientLevel level, Entity seat, boolean render) {
Ship ship = VSGameUtilsKt.getLoadedShipManagingPos(level, BlockPos.containing(seat.getX(), seat.getY(), seat.getZ()));
if (ship == null) {
return null;
}
return render && ship instanceof ClientShip clientShip
? clientShip.getRenderTransform().getShipToWorldRotation() : ship.getTransform().getShipToWorldRotation();
}
/** Sets the cursor's components from its world direction, putting it on the reticle the first time. */
private static void cursorFromWorld(AimBasis basis, Quaterniondc rotation) {
if (cursorWorld == null) {
double[] forward = basis.forward();
cursorWorld = rotation.transform(new Vector3d(forward[0], forward[1], forward[2]));
}
Vector3d ship = rotation.transformInverse(cursorWorld, new Vector3d());
double[] f = basis.forward();
double[] r = basis.right();
double[] u = basis.up();
cf = ship.x * f[0] + ship.y * f[1] + ship.z * f[2];
cr = ship.x * r[0] + ship.y * r[1] + ship.z * r[2];
cu = ship.x * u[0] + ship.y * u[1] + ship.z * u[2];
normalizeCursor();
limitCursor();
}
/** Stores the cursor's components back as its world direction. */
private static void cursorToWorld(AimBasis basis, Quaterniondc rotation) {
double[] ship = basis.toShip(cf, cr, cu);
cursorWorld = rotation.transform(new Vector3d(ship[0], ship[1], ship[2]));
}
/** Called when the server tells the client how its player flies. */
public static void setMode(boolean cursorAim) {
cursorAimMode = cursorAim;
@@ -135,6 +179,7 @@ public final class CursorAimClient {
return;
}
active = true;
cursorWorld = null;
cf = 1.0;
cr = 0.0;
cu = 0.0;
@@ -184,7 +229,8 @@ public final class CursorAimClient {
}
Entity seat = AimSeat.seatOf(player);
AimBasis basis = AimBasis.of(level, seat.blockPosition());
if (basis == null) {
Quaterniondc rotation = basis != null ? shipRotation(level, seat, true) : null;
if (basis == null || rotation == null) {
return;
}
if (minecraft.options.getCameraType() != CameraType.THIRD_PERSON_BACK) {
@@ -207,10 +253,11 @@ public final class CursorAimClient {
deltaPitch = 0.0;
}
}
double sensitivity = CursorAimClientConfig.MOUSE_SENSITIVITY.get();
double sensitivity = Config.CURSOR_AIM_MOUSE_SENSITIVITY.get();
boolean freeLook = minecraft.screen == null && AimKeys.FREE_LOOK.isDown();
double spring = CursorAimClientConfig.SPRING_BACK_SECONDS.get();
cursorFromWorld(basis, rotation);
double spring = Config.CURSOR_AIM_SPRING_BACK_SECONDS.get();
if (spring > 0.0) {
shrinkCursor(Math.exp(-dt / spring));
}
@@ -223,9 +270,10 @@ public final class CursorAimClient {
offsetYaw *= decay;
offsetPitch *= decay;
}
cursorToWorld(basis, rotation);
// Camera: along the reticle, or turned towards the cursor by the look ahead setting
double lookAhead = CursorAimClientConfig.LOOK_AHEAD.get();
double lookAhead = Config.CURSOR_AIM_LOOK_AHEAD.get();
double[] forward = basis.forward();
if (cameraDirection == null || lookAhead <= 0.0) {
cameraDirection = forward.clone();
@@ -295,7 +343,7 @@ public final class CursorAimClient {
}
private static void limitCursor() {
double limit = Math.toRadians(CursorAimClientConfig.MAX_CURSOR_ANGLE_DEGREES.get());
double limit = Math.toRadians(Config.CURSOR_AIM_MAX_ANGLE_DEGREES.get());
double angle = Math.acos(Mth.clamp(cf, -1.0, 1.0));
if (angle <= limit) {
return;
@@ -318,7 +366,7 @@ public final class CursorAimClient {
if (!active || minecraft.screen != null) {
return;
}
zoom = Mth.clamp(zoom - event.getScrollDelta() * CursorAimClientConfig.ZOOM_STEP.get(), -3.0, CursorAimClientConfig.ZOOM_MAX.get());
zoom = Mth.clamp(zoom - event.getScrollDelta() * Config.CURSOR_AIM_ZOOM_STEP.get(), -3.0, Config.CURSOR_AIM_ZOOM_MAX.get());
event.setCanceled(true);
}
@@ -354,14 +402,10 @@ public final class CursorAimClient {
}
Entity seat = AimSeat.seatOf(player);
AimBasis basis = AimBasis.of(level, seat.blockPosition());
Ship ship = VSGameUtilsKt.getLoadedShipManagingPos(level, BlockPos.containing(seat.getX(), seat.getY(), seat.getZ()));
if (basis == null || ship == null) {
Quaterniondc rotation = basis != null ? shipRotation(level, seat, true) : null;
if (rotation == null || cursorWorld == null) {
return;
}
Quaterniondc rotation = ship instanceof ClientShip clientShip
? clientShip.getRenderTransform().getShipToWorldRotation() : ship.getTransform().getShipToWorldRotation();
double[] cursorShip = basis.toShip(cf, cr, cu);
Vector3d cursorWorld = rotation.transform(new Vector3d(cursorShip[0], cursorShip[1], cursorShip[2]));
double[] forward = basis.forward();
Vector3d reticleWorld = rotation.transform(new Vector3d(forward[0], forward[1], forward[2]));
@@ -375,8 +419,8 @@ public final class CursorAimClient {
RenderSystem.setShader(GameRenderer::getPositionColorShader);
builder.begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR);
float size = (float) (CursorAimClientConfig.MARKER_SIZE.get() * MARKER_DISTANCE);
if (CursorAimClientConfig.SHOW_RETICLE.get()) {
float size = (float) (Config.CURSOR_AIM_MARKER_SIZE.get() * MARKER_DISTANCE);
if (Config.CURSOR_AIM_SHOW_RETICLE.get()) {
plus(builder, matrix, camera, reticleWorld, size * 0.35F, RETICLE_COLOR);
}
ring(builder, matrix, camera, cursorWorld, size, CURSOR_COLOR);
@@ -1,45 +0,0 @@
package dev.lumareth.improvedAerodynamics.client.aim;
import net.minecraftforge.common.ForgeConfigSpec;
/** Client side settings of cursor aim. Lives in improved_aerodynamics-cursor-aim-client.toml. */
public final class CursorAimClientConfig {
public static final ForgeConfigSpec SPEC;
public static final ForgeConfigSpec.DoubleValue LOOK_AHEAD;
public static final ForgeConfigSpec.DoubleValue MOUSE_SENSITIVITY;
public static final ForgeConfigSpec.DoubleValue SPRING_BACK_SECONDS;
public static final ForgeConfigSpec.DoubleValue MAX_CURSOR_ANGLE_DEGREES;
public static final ForgeConfigSpec.DoubleValue ZOOM_STEP;
public static final ForgeConfigSpec.DoubleValue ZOOM_MAX;
public static final ForgeConfigSpec.DoubleValue MARKER_SIZE;
public static final ForgeConfigSpec.BooleanValue SHOW_RETICLE;
static {
ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder();
builder.comment("Cursor aim, client side").push("cursor_aim");
LOOK_AHEAD = builder.comment("How strongly the camera looks into the direction of the turn. 0 never moves it, 1 (100%) turns it quickly",
"to look where the cursor points. In between it follows more slowly")
.defineInRange("lookAhead", 0.0, 0.0, 1.0);
MOUSE_SENSITIVITY = builder.comment("Multiplier for how far the mouse moves the cursor (or the camera while the free look key is held)")
.defineInRange("mouseSensitivity", 1.0, 0.05, 10.0);
SPRING_BACK_SECONDS = builder.comment("The cursor returns to the gun reticle with this time constant in seconds when you stop moving the mouse.",
"0 makes it stay where you left it")
.defineInRange("springBackSeconds", 1.0, 0.0, 10.0);
MAX_CURSOR_ANGLE_DEGREES = builder.comment("Furthest the cursor can go from the gun reticle, 135 is a 270 degree cone")
.defineInRange("maxCursorAngleDegrees", 135.0, 10.0, 180.0);
ZOOM_STEP = builder.comment("Blocks the camera moves per mouse wheel notch")
.defineInRange("zoomStep", 2.0, 0.1, 20.0);
ZOOM_MAX = builder.comment("Furthest the camera can be pulled back from its normal distance, in blocks")
.defineInRange("zoomMax", 80.0, 0.0, 500.0);
MARKER_SIZE = builder.comment("Radius of the cursor ring on screen")
.defineInRange("markerSize", 0.04, 0.01, 0.3);
SHOW_RETICLE = builder.comment("Draw a small cross where the gun reticle points, the zero of the cursor, so you can see it when the camera looks away")
.define("showReticle", true);
builder.pop();
SPEC = builder.build();
}
private CursorAimClientConfig() {
}
}
@@ -27,6 +27,8 @@ public final class AeroState implements PhysicsBridge.Publishable {
private static final double INPUT_RATE_PER_TICK = 0.2;
private static final double VELOCITY_EPSILON = 0.05;
private static final double ANGULAR_EPSILON = 0.005;
// Time constant of the smoothing of measured control rates
private static final double RATE_SMOOTHING_SECONDS = 0.3;
private static final ControlAxis[] MEASURED_AXES = {ControlAxis.PITCH, ControlAxis.YAW, ControlAxis.ROLL};
private final SubstepClock clock;
@@ -41,6 +43,9 @@ public final class AeroState implements PhysicsBridge.Publishable {
private Frame current;
private boolean[] stalled = new boolean[0];
private boolean wingStalled;
// Lift the wings could make at the edge of the stall in the airflow of the last substep, NaN without the improved
// flight model, which has no stall
private double maxLift = Double.NaN;
private final Map<Long, NodeState> nodeStates = new ConcurrentHashMap<>();
// Every ship's node states by node position, for the control surface animation on the server thread
private static final Map<Long, NodeState> ALL_NODE_STATES = new ConcurrentHashMap<>();
@@ -71,6 +76,9 @@ public final class AeroState implements PhysicsBridge.Publishable {
final Vector3d yawResponseBuilding = new Vector3d();
final Vector3d rollResponse = new Vector3d();
final Vector3d rollResponseBuilding = new Vector3d();
// Fastest steady pitch rate (rad/s) the pitch surfaces can hold against the craft's pitch damping, smoothed,
// measured while cursor aim flies the node. NaN until measured
double controlRate = Double.NaN;
final AutoTrim.Targets trimTargets = new AutoTrim.Targets();
volatile double savedTrim;
@@ -155,9 +163,14 @@ public final class AeroState implements PhysicsBridge.Publishable {
double dt = clock.substepSeconds();
boolean improved = Config.IMPROVED_FLIGHT_MODEL.get();
Matrix3dc inertia = ship.getMomentOfInertia();
// Same frames and scaling as Warium applies its forces in
double scale = transform.getShipToWorldScaling().x();
double forceScale = autoScale ? scale * scale * scale : 1.0;
double liftAcceleration = maxLift * forceScale / ship.getMass();
updateNodes(frame, worldVelocity, transform.getPositionInWorld().y(), rotation, inertia, w, dt, improved);
updateNodes(frame, worldVelocity, transform.getPositionInWorld().y(), rotation, inertia, w, dt, improved, liftAcceleration);
evaluate(frame, centerOfMass, v, w, improved, true, wrench);
measureControlRates(frame, centerOfMass, v, w, improved, dt);
if (Config.IMPLICIT_DAMPING.get()) {
// Implicit Euler for the damping part: scale each axis by 1 / (1 + dt * damping / inertia), using the
@@ -184,9 +197,6 @@ public final class AeroState implements PhysicsBridge.Publishable {
}
}
// Same frames and scaling as Warium applies its forces in
double scale = transform.getShipToWorldScaling().x();
double forceScale = autoScale ? scale * scale * scale : 1.0;
Vector3d force = new Vector3d(wrench.force[0], wrench.force[1], wrench.force[2]).mul(forceScale);
Vector3d forceTorque = new Vector3d(wrench.forceTorque[0], wrench.forceTorque[1], wrench.forceTorque[2]).mul(forceScale);
if (force.isFinite() && forceTorque.isFinite()) {
@@ -239,6 +249,7 @@ public final class AeroState implements PhysicsBridge.Publishable {
double[] r = new double[3];
if (commit) {
wingStalled = false;
maxLift = improved ? 0.0 : Double.NaN;
for (NodeState state : nodeStates.values()) {
state.pitchResponseBuilding.zero();
state.yawResponseBuilding.zero();
@@ -261,6 +272,9 @@ public final class AeroState implements PhysicsBridge.Publishable {
if (commit) {
stalled[i] = nowStalled;
wingStalled |= nowStalled && s.type() == SurfaceType.WING;
if (s.type() == SurfaceType.WING || s.type() == SurfaceType.CONTROL_SURFACE && s.wingGroup()) {
maxLift += ImprovedAero.maxLift(s, v, w, r, flap, weight, tuning);
}
}
} else {
WariumAero.evaluate(s, v, w, r, input, weight, out);
@@ -306,6 +320,37 @@ public final class AeroState implements PhysicsBridge.Publishable {
}
}
/**
* For every node cursor aim flies: the steady pitch rate its pitch surfaces can hold, full deflection torque over
* the craft's pitch damping, both measured about the axis the pitch surfaces turn the craft about.
*/
private void measureControlRates(Frame frame, Vector3dc centerOfMass, double[] v, double[] w, boolean improved, double dt) {
for (NodeData node : frame.nodes.values()) {
NodeState state = nodeStates.get(node.key());
if (state == null || !CursorAimRegistry.isActive(node.key())) {
continue;
}
double response = state.pitchResponse.length();
if (response < 1.0E-9) {
continue;
}
Vector3d axis = state.pitchResponse.div(response, new Vector3d());
double[] turned = {w[0] + ANGULAR_EPSILON * axis.x, w[1] + ANGULAR_EPSILON * axis.y, w[2] + ANGULAR_EPSILON * axis.z};
evaluate(frame, centerOfMass, v, turned, improved, false, probe);
double damping = 0.0;
for (int i = 0; i < 3; i++) {
damping -= (probe.totalTorque(i) - wrench.totalTorque(i)) * axis.get(i);
}
damping /= ANGULAR_EPSILON;
if (!(damping > 1.0E-9)) {
continue;
}
double rate = response / damping;
state.controlRate = Double.isNaN(state.controlRate) ? rate
: state.controlRate + (rate - state.controlRate) * Math.min(1.0, dt / RATE_SMOOTHING_SECONDS);
}
}
/** A control surface's input, combined from its two keys and clamped like Warium does. */
private static double input(AeroSurface s, NodeData node, NodeState state, ControlAxis nudged, double extra) {
if (node == null || state == null) {
@@ -328,7 +373,7 @@ public final class AeroState implements PhysicsBridge.Publishable {
}
private void updateNodes(Frame frame, Vector3dc worldVelocity, double altitude, Quaterniondc rotation, Matrix3dc inertia,
double[] w, double dt, boolean improved) {
double[] w, double dt, boolean improved, double liftAcceleration) {
double step = Config.CONTROL_SMOOTHING.get() ? INPUT_RATE_PER_TICK * clock.weight() : Double.POSITIVE_INFINITY;
// Torque of the last substep, in ship axes, which the flight computer separates from its own surfaces' share
double[] torque = {wrench.totalTorque(0), wrench.totalTorque(1), wrench.totalTorque(2)};
@@ -338,7 +383,8 @@ public final class AeroState implements PhysicsBridge.Publishable {
continue;
}
// Cursor aim, if the pilot has it on, supplies the inputs the pilot is not giving with the keys
NodeData effective = CursorPilot.apply(state, node, rotation, inertia, w, worldVelocity.length(), wingStalled);
NodeData effective = CursorPilot.apply(state, node, rotation, inertia, w, worldVelocity.length(), wingStalled,
liftAcceleration);
state.pitch = moveTowards(state.pitch, effective.pitch(), step);
state.yaw = moveTowards(state.yaw, effective.yaw(), step);
state.roll = moveTowards(state.roll, effective.roll(), step);
@@ -1,56 +0,0 @@
package dev.lumareth.improvedAerodynamics.flight;
import net.minecraftforge.common.ForgeConfigSpec;
/** Server side settings of the cursor aim virtual pilot. Lives in improved_aerodynamics-cursor-aim.toml. */
public final class CursorAimConfig {
public static final ForgeConfigSpec SPEC;
public static final ForgeConfigSpec.BooleanValue ENABLED;
public static final ForgeConfigSpec.DoubleValue FULL_DEFLECTION_DEGREES;
public static final ForgeConfigSpec.DoubleValue DEADZONE_DEGREES;
public static final ForgeConfigSpec.DoubleValue MAX_TURN_RATE_DEGREES;
public static final ForgeConfigSpec.DoubleValue MAX_ROLL_RATE_DEGREES;
public static final ForgeConfigSpec.DoubleValue MAX_BANK_DEGREES;
public static final ForgeConfigSpec.DoubleValue BANK_GAIN;
public static final ForgeConfigSpec.DoubleValue RATE_GAIN;
public static final ForgeConfigSpec.DoubleValue PUSH_FACTOR;
public static final ForgeConfigSpec.DoubleValue STALLED_PITCH_FACTOR;
public static final ForgeConfigSpec.DoubleValue MIN_SPEED;
static {
ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder();
builder.comment("Cursor aim: the mouse moves a cursor on screen, a virtual pilot flies the control node's craft by it").push("cursor_aim");
ENABLED = builder.comment("Whether pilots may use cursor aim on this server")
.define("enabled", true);
FULL_DEFLECTION_DEGREES = builder.comment("Angle of the cursor from the gun reticle at which the virtual pilot asks for full deflection: the maximum bank",
"sideways, the full pitch rate up or down")
.defineInRange("fullDeflectionDegrees", 30.0, 5.0, 90.0);
DEADZONE_DEGREES = builder.comment("Radius around the gun reticle in degrees inside which the cursor commands nothing, so the craft flies straight.",
"Outside it the command grows from zero, there is no jump at the edge")
.defineInRange("deadzoneDegrees", 3.0, 0.0, 15.0);
MAX_TURN_RATE_DEGREES = builder.comment("Turn rate in degrees per second the pilot asks for at full deflection, both for the turn at maximum bank and for pitching")
.defineInRange("maxTurnRateDegrees", 40.0, 5.0, 360.0);
MAX_ROLL_RATE_DEGREES = builder.comment("Fastest roll in degrees per second the pilot asks for when it rolls to a bank angle")
.defineInRange("maxRollRateDegrees", 150.0, 10.0, 720.0);
MAX_BANK_DEGREES = builder.comment("Bank angle the pilot rolls to when the cursor is at full sideways deflection")
.defineInRange("maxBankDegrees", 70.0, 10.0, 90.0);
BANK_GAIN = builder.comment("Roll rate in rad/s per radian of bank angle error, how eagerly the pilot rolls to the wanted bank and levels the wings")
.defineInRange("bankGain", 3.0, 0.2, 20.0);
RATE_GAIN = builder.comment("How hard the pilot corrects its turn rate, in angular acceleration per rad/s of rate error. Lower is smoother,",
"higher follows the cursor tighter and can start to oscillate")
.defineInRange("rateGain", 5.0, 0.5, 40.0);
PUSH_FACTOR = builder.comment("A cursor below the gun reticle pushes the nose down. This is how much of the pull rate that may be, since",
"negative g is harder on the craft and the pilot")
.defineInRange("pushFactor", 0.4, 0.0, 1.0);
STALLED_PITCH_FACTOR = builder.comment("Multiplier for the pitch rate the pilot asks for while the wings are stalled, so it does not keep pulling")
.defineInRange("stalledPitchFactor", 0.3, 0.0, 1.0);
MIN_SPEED = builder.comment("Below this speed in blocks per second the pilot does nothing, the surfaces have no effect anyway")
.defineInRange("minSpeed", 15.0, 0.0, 200.0);
builder.pop();
SPEC = builder.build();
}
private CursorAimConfig() {
}
}
@@ -1,5 +1,6 @@
package dev.lumareth.improvedAerodynamics.flight;
import dev.lumareth.improvedAerodynamics.Config;
import net.mcreator.valkyrienwarium.entity.SeatEntityEntity;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
@@ -24,7 +25,7 @@ public final class CursorAimServer {
}
long nodeKey = basis.nodePos().asLong();
double length = Math.sqrt(cf * cf + cr * cr + cu * cu);
if (!active || !CursorAimConfig.ENABLED.get() || !(length > 1.0E-6) || !Double.isFinite(length)) {
if (!active || !Config.CURSOR_AIM_ENABLED.get() || !(length > 1.0E-6) || !Double.isFinite(length)) {
CursorAimRegistry.clear(nodeKey);
return;
}
@@ -1,29 +1,45 @@
package dev.lumareth.improvedAerodynamics.flight;
import dev.lumareth.improvedAerodynamics.Config;
import org.joml.Matrix3d;
import org.joml.Matrix3dc;
import org.joml.Quaterniondc;
import org.joml.Vector3d;
/**
* The virtual pilot of cursor aim. The cursor is a stick that springs back to the gun reticle:
* The virtual pilot of cursor aim. The cursor is a direction fixed in the world, and the pilot turns the craft until
* the gun reticle sits on it, so the offset it gets shrinks to nothing as the turn completes:
* <ul>
* <li>sideways it commands a bank angle and turn: the further out, the steeper the bank and the harder the pull
* that comes with it. Centered it levels the wings, so a centered cursor is straight flight;</li>
* <li>up and down it commands a pitch rate;</li>
* <li>close to the reticle it steers with yaw and pitch directly instead of banking, so aiming does not rock the
* wings.</li>
* <li>it banks towards the cursor, judged with the wings level so the bank does not hide where the cursor is: the
* further the cursor is to the side, the steeper the bank. While banked it pulls as much as a level turn at that
* bank needs, so the craft turns without losing height;</li>
* <li>it pitches towards the cursor as seen from the cockpit, so once banked the cursor is above the nose and the
* pull brings the nose round onto it;</li>
* <li>close to the reticle it levels the wings and puts the reticle on the cursor with rudder and elevator, so
* aiming does not rock the wings.</li>
* </ul>
* The wanted rotation is turned into stick inputs by the measured effect of each input on the craft (torque per unit
* of input, see {@link AeroState}), so it works for any craft however its surfaces are mixed or oriented: angular
* acceleration wanted times inertia, solved for the three inputs with a little regularization for axes that have no
* surfaces. Keys the pilot holds win over it, per axis.
* <p>
* How hard the craft can turn is worked out live, not configured: the lift its wings can make at the edge of the stall
* at the current speed and altitude, over its mass and speed, and the steady pitch rate its pitch surfaces can hold
* against its pitch damping, whichever is lower. The same lift limit caps the bank to what the craft can hold level, so
* a heavy bomber banks gently and turns wide while a light fighter racks into a steep, tight turn.
*/
final class CursorPilot {
private static final double FINE_START = Math.toRadians(4.0);
private static final double FINE_END = Math.toRadians(16.0);
private static final double MANUAL_OVERRIDE = 0.05;
private static final double MAX_PULL_BANK = Math.toRadians(90.0);
// Gravity of VS
private static final double GRAVITY = 10.0;
// Share of the stall limited lift the pilot plans with, so it does not ride the stall
private static final double LIFT_MARGIN = 0.85;
// Bank the pilot rolls to at full sideways deflection even when the wings cannot hold the turn level
private static final double MIN_BANK = Math.toRadians(20.0);
// Turn rate when neither limit can be measured, like the first moments of a flight
private static final double FALLBACK_TURN_RATE = Math.toRadians(30.0);
private CursorPilot() {
}
@@ -32,57 +48,83 @@ final class CursorPilot {
* @return the node with the pilot's wanted inputs in place of the keys, or the node itself if cursor aim is off
*/
static NodeData apply(AeroState.NodeState state, NodeData node, Quaterniondc rotation, Matrix3dc inertia, double[] w,
double speed, boolean wingStalled) {
if (!CursorAimConfig.ENABLED.get()) {
double speed, boolean wingStalled, double liftAcceleration) {
if (!Config.CURSOR_AIM_ENABLED.get()) {
return node;
}
CursorAimRegistry.Aim aim = CursorAimRegistry.get(node.key());
if (aim == null || speed < CursorAimConfig.MIN_SPEED.get()) {
if (aim == null || speed < Config.CURSOR_AIM_MIN_SPEED.get()) {
return node;
}
double fullAngle = Math.toRadians(CursorAimConfig.FULL_DEFLECTION_DEGREES.get());
double maxTurn = Math.toRadians(CursorAimConfig.MAX_TURN_RATE_DEGREES.get());
double maxRoll = Math.toRadians(CursorAimConfig.MAX_ROLL_RATE_DEGREES.get());
double maxBank = Math.toRadians(CursorAimConfig.MAX_BANK_DEGREES.get());
double fullAngle = Math.toRadians(Config.CURSOR_AIM_FULL_DEFLECTION_DEGREES.get());
double maxRoll = Math.toRadians(Config.CURSOR_AIM_MAX_ROLL_RATE_DEGREES.get());
double maxBank = Math.toRadians(Config.CURSOR_AIM_MAX_BANK_DEGREES.get());
// What this craft can do right now. Lift: pitch rate at the highest usable lift (a = L / m, rate = a / v), and
// the bank whose level turn needs that load factor. Without a stall model only the control limit applies
double usableLift = LIFT_MARGIN * liftAcceleration;
double liftRate = usableLift > 0.0 ? usableLift / speed : Double.POSITIVE_INFINITY;
double controlRate = state.controlRate > 0.0 ? state.controlRate : Double.POSITIVE_INFINITY;
double maxTurn = Math.min(liftRate, controlRate);
if (!Double.isFinite(maxTurn)) {
maxTurn = FALLBACK_TURN_RATE;
}
if (Double.isFinite(liftAcceleration)) {
double loadFactor = usableLift / GRAVITY;
double levelBank = loadFactor > 1.0 ? Math.acos(1.0 / loadFactor) : 0.0;
maxBank = Math.max(Math.min(MIN_BANK, maxBank), Math.min(maxBank, levelBank));
}
double[] f = aim.forward();
double[] r = aim.right();
double[] u = aim.up();
// Cursor as angles from the reticle: sideways (positive right) and up, both through any 270 degree cone
double sideways = Math.atan2(aim.cr(), aim.cf());
double vertical = Math.atan2(aim.cu(), Math.hypot(aim.cf(), aim.cr()));
// Deadzone around the reticle: shrink the cursor offset by its radius, direction kept
double offset = Math.hypot(sideways, vertical);
double deadzone = Math.toRadians(CursorAimConfig.DEADZONE_DEGREES.get());
if (offset > 1.0E-9) {
double scale = Math.max(0.0, offset - deadzone) / offset;
sideways *= scale;
vertical *= scale;
}
double lateral = saturate(sideways / fullAngle);
double blend = smoothstep(Math.abs(sideways), FINE_START, FINE_END);
// Bank of the craft: positive when the right wing is down, through upside down
Vector3d rightWorld = rotation.transform(new Vector3d(r[0], r[1], r[2]));
Vector3d upWorld = rotation.transform(new Vector3d(u[0], u[1], u[2]));
double bank = Math.atan2(-rightWorld.y, upWorld.y);
double bankCommand = maxBank * lateral * blend;
double rollRate = clamp(CursorAimConfig.BANK_GAIN.get() * (bankCommand - bank), -maxRoll, maxRoll);
// Pitch: the stick, plus the pull that carries the turn while banked
double pitchStick = saturate(vertical / fullAngle);
double pitchRate = maxTurn * (pitchStick >= 0.0 ? pitchStick : pitchStick * CursorAimConfig.PUSH_FACTOR.get());
if (Math.abs(bank) < MAX_PULL_BANK) {
pitchRate += maxTurn * Math.abs(lateral) * blend * Math.sin(Math.abs(bank));
// Deadzone around the reticle: shrink the cursor's offset by its radius, direction kept
double cf = aim.cf();
double cr = aim.cr();
double cu = aim.cu();
double deadzone = Math.toRadians(Config.CURSOR_AIM_DEADZONE_DEGREES.get());
double offset = Math.acos(clamp(cf, -1.0, 1.0));
double across = Math.hypot(cr, cu);
if (deadzone > 0.0 && offset > 1.0E-9 && across > 1.0E-9) {
double shrunk = Math.max(0.0, offset - deadzone);
cf = Math.cos(shrunk);
cr *= Math.sin(shrunk) / across;
cu *= Math.sin(shrunk) / across;
}
// Cursor as angles from the reticle as seen from the cockpit: sideways (positive right) and up, through any
// 270 degree cone
double sideways = Math.atan2(cr, cf);
double vertical = Math.atan2(cu, Math.hypot(cf, cr));
// Sideways as it would be with the wings level, what the bank is chosen by
double levelSideways = Math.atan2(cr * Math.cos(bank) + cu * Math.sin(bank), cf);
double blend = smoothstep(Math.abs(levelSideways), FINE_START, FINE_END);
double bankCommand = maxBank * saturate(levelSideways / fullAngle) * blend;
double rollRate = clamp(Config.CURSOR_AIM_BANK_GAIN.get() * (bankCommand - bank), -maxRoll, maxRoll);
// Pitch: the pull of a level turn at the current bank, the turn rate g tan(bank) / v seen about the pitch axis,
// plus a pull towards the cursor. Past 90 degrees of bank the level turn pull would only dive, so it is left out
double pitchStick = saturate(vertical / fullAngle);
double push = Config.CURSOR_AIM_PUSH_FACTOR.get();
double pitchRate = maxTurn * (pitchStick >= 0.0 ? pitchStick : pitchStick * push);
double cosBank = Math.cos(bank);
if (cosBank > 0.0) {
double sinBank = Math.sin(bank);
pitchRate += GRAVITY * sinBank * sinBank / (speed * Math.max(cosBank, 0.1));
}
pitchRate = clamp(pitchRate, -maxTurn * push, maxTurn);
if (wingStalled && pitchRate > 0.0) {
pitchRate *= CursorAimConfig.STALLED_PITCH_FACTOR.get();
pitchRate *= Config.CURSOR_AIM_STALLED_PITCH_FACTOR.get();
}
// Close to the reticle the nose follows the cursor with the rudder
double yawRate = maxTurn * lateral * (1.0 - blend);
double yawRate = maxTurn * saturate(sideways / fullAngle) * (1.0 - blend);
// Wanted angular velocity in ship axes: roll about forward, pitch about right, yaw (nose right) about -up
Vector3d wanted = new Vector3d(
@@ -90,7 +132,7 @@ final class CursorPilot {
rollRate * f[1] + pitchRate * r[1] - yawRate * u[1],
rollRate * f[2] + pitchRate * r[2] - yawRate * u[2]);
Vector3d error = wanted.sub(w[0], w[1], w[2]);
Vector3d acceleration = error.mul(CursorAimConfig.RATE_GAIN.get());
Vector3d acceleration = error.mul(Config.CURSOR_AIM_RATE_GAIN.get());
Vector3d torque = inertia.transform(acceleration, new Vector3d());
double[] inputs = solve(state.pitchResponse, state.yawResponse, state.rollResponse, torque);
@@ -129,11 +129,8 @@ final class ImprovedAero {
// Low aspect ratio wings keep their flow attached longer thanks to leading-edge vortices. Control surfaces
// in a wing's group stall with the wing instead of before it
double vortexFactor = tuning.vortex() && (s.type() == SurfaceType.WING || s.wingGroup() && s.type().controllable)
? VortexLift.factor(s.aspectRatio()) : 0.0;
double baseStall = s.slab() ? SurfaceType.SLAB_STALL_ANGLE
: s.wingGroup() && s.type().controllable ? SurfaceType.WING.stallAngle : s.type().stallAngle;
double critical = Math.toRadians(baseStall) * (1.0 + tuning.vortexStallBonus() * vortexFactor) * tuning.stallAngleScale();
double vortexFactor = vortexFactor(s, tuning);
double critical = criticalAngle(s, tuning, vortexFactor);
double absAlpha = Math.abs(alpha);
if (alpha >= 0.0) {
critical += FLAP_STALL_GAIN * flapShift;
@@ -149,9 +146,8 @@ final class ImprovedAero {
// Finite wing theory: lift slope and induced drag follow from the wing's aspect ratio
double aspectRatio = s.aspectRatio();
double slope = aspectRatio > 0.0 ? 2.0 * Math.PI / (1.0 + 2.0 / aspectRatio) : LIFT_SLOPE;
double inducedDrag = aspectRatio > 0.0 ? 1.0 / (Math.PI * EFFICIENCY * aspectRatio) : INDUCED_DRAG;
double attachedLift = slope * alpha;
double attachedLift = liftSlope(s) * alpha;
if (s.type() == SurfaceType.WING) {
attachedLift += VortexLift.liftCoefficient(alpha, vortexFactor, tuning.vortexLiftStrength());
}
@@ -175,6 +171,44 @@ final class ImprovedAero {
return stalled;
}
/**
* Lift (along the surface's normal) the surface would make in the airflow it sees now at the highest angle of
* attack it can fly without starting to stall, flaps included. Only meaningful for horizontal surfaces.
*/
static double maxLift(AeroSurface s, double[] v, double[] w, double[] r, double flap, double weight, Tuning tuning) {
double vx = v[0] + w[1] * r[2] - w[2] * r[1];
double vy = v[1] + w[2] * r[0] - w[0] * r[2];
double vz = v[2] + w[0] * r[1] - w[1] * r[0];
double u = vx * s.facingX() + vz * s.facingZ();
double planeSpeedSq = u * u + vy * vy;
double vortexFactor = vortexFactor(s, tuning);
double flapShift = FLAP_ALPHA_FACTOR * flap;
double alpha = criticalAngle(s, tuning, vortexFactor) + FLAP_STALL_GAIN * flapShift - STALL_BLEND;
double lift = liftSlope(s) * alpha;
if (s.type() == SurfaceType.WING) {
lift += VortexLift.liftCoefficient(alpha, vortexFactor, tuning.vortexLiftStrength());
}
return LIFT_CONSTANT * s.pressure() * weight * s.fluidFactor() * planeSpeedSq * lift * tuning.liftScale();
}
private static double vortexFactor(AeroSurface s, Tuning tuning) {
return tuning.vortex() && (s.type() == SurfaceType.WING || s.wingGroup() && s.type().controllable)
? VortexLift.factor(s.aspectRatio()) : 0.0;
}
/** Angle of attack (radians, camber and flaps included) at which the surface starts to stall, without flaps. */
private static double criticalAngle(AeroSurface s, Tuning tuning, double vortexFactor) {
double baseStall = s.slab() ? SurfaceType.SLAB_STALL_ANGLE
: s.wingGroup() && s.type().controllable ? SurfaceType.WING.stallAngle : s.type().stallAngle;
return Math.toRadians(baseStall) * (1.0 + tuning.vortexStallBonus() * vortexFactor) * tuning.stallAngleScale();
}
/** Lift slope per radian from finite wing theory, from the wing's aspect ratio. */
private static double liftSlope(AeroSurface s) {
double aspectRatio = s.aspectRatio();
return aspectRatio > 0.0 ? 2.0 * Math.PI / (1.0 + 2.0 / aspectRatio) : LIFT_SLOPE;
}
static double smoothstep(double edge0, double edge1, double x) {
double t = Math.max(0.0, Math.min(1.0, (x - edge0) / (edge1 - edge0)));
return t * t * (3.0 - 2.0 * t);