Keep the first-person view level and steady; optional third person on foot

First person copied the head bone's full rotation into the camera, so every
nod, tilt and wobble of the skater's animation shook the view.

True first person (on by default) splits the head into heading, nod and tilt.
The heading is followed in full; a share of the nod (86%) and of the tilt (0%,
a level horizon) is kept. All three pass a One Euro filter: heavy smoothing
while the head barely moves, little lag in spins (a 720 stays within about
10 degrees). The eye's movement around the body is filtered the same way, and
Head bob sets how much of it is kept. Upside down (flips, bails) the view
blends back to the real head unless Follow flips is off. It runs before the
existing spring arm, which is unchanged; switching it off gives the plain
head camera back exactly.

Third person on foot (off by default) hands the camera back to the game while
the physics state is Offboard (504) and takes it again on the board, through
the same actions as the menu toggle. It only runs while first person is on or
paused by it, and the player's own toggle, Freecam or the Park Editor end it.

New settings are saved with the other FirstPerson.* choices.
Engine/Game/Skater/Test/first_person_stabilize_tests.cpp covers the math.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JusSkatesandClaude Opus 5.5 committed 2026-10-04 17:49:22 +01:00
1 parent 6dcbe96620
commit 732afd659c
8 files changed
+407 -8

No files matched your search

+3
View File
@@ -101,6 +101,9 @@ if(DINGOSDK_BUILD_MULTIPLAYER_TESTS AND WIN32)
target_include_directories(dingosdk_first_person_spring_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME first_person_spring COMMAND dingosdk_first_person_spring_tests)
endif()
add_executable(dingosdk_first_person_stabilize_tests Engine/Game/Skater/Test/first_person_stabilize_tests.cpp)
target_include_directories(dingosdk_first_person_stabilize_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME first_person_stabilize COMMAND dingosdk_first_person_stabilize_tests)
add_executable(dingosdk_playstation_report_tests Engine/Game/Input/Test/playstation_report_tests.cpp)
target_include_directories(dingosdk_playstation_report_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME playstation_report COMMAND dingosdk_playstation_report_tests)
@@ -0,0 +1,162 @@
#include "Engine/Game/Skater/first_person_spring.h"
#include <cstdio>
#include <string_view>
// True first person (first_person::stabilize): animated head wobble in, a level and
// steady view out, while spins, flips and teleports still come through.
namespace {
using namespace dingosdk::first_person;
constexpr float pi = std::numbers::pi_v<float>, deg = pi / 180;
int failures = 0;
void expect(bool condition, std::string_view what) {
if (condition) return;
++failures;
std::printf("FAIL: %.*s\n", static_cast<int>(what.size()), what.data());
}
// A head in the game's row layout (right, up, backward, position).
Matrix head_at(float yaw, float pitch, float roll, Vec3 position) {
Matrix m{};
m[15] = 1;
write_level(m, yaw, pitch, roll);
for (unsigned i = 0; i < 3; ++i) m[12 + i] = position[i];
return m;
}
struct Angles { float yaw, pitch, roll; };
Angles angles_of(const Matrix& m) {
const Vec3 forward{-m[8], -m[9], -m[10]};
Angles a{std::atan2(forward[0], forward[2]), std::asin(std::clamp(forward[1], -1.0f, 1.0f)), 0};
Matrix level{};
write_level(level, a.yaw, a.pitch, 0);
const Vec3 right{m[0], m[1], m[2]};
a.roll = std::atan2(dot(right, {level[4], level[5], level[6]}), dot(right, {level[0], level[1], level[2]}));
return a;
}
void frame_layout() {
const auto a = angles_of(head_at(0.7f, -0.3f, 0.2f, {}));
expect(std::abs(a.yaw - 0.7f) < 1e-4f && std::abs(a.pitch + 0.3f) < 1e-4f && std::abs(a.roll - 0.2f) < 1e-4f,
"write_level round trips yaw, pitch and roll");
const auto m = head_at(0.7f, -0.3f, 0.2f, {});
const auto backward = cross({m[0], m[1], m[2]}, {m[4], m[5], m[6]});
expect(std::abs(backward[0] - m[8]) + std::abs(backward[1] - m[9]) + std::abs(backward[2] - m[10]) < 1e-4f,
"right x up = backward, as the game's camera rows");
}
// Cruising straight with the head nodding, tilting, swaying and bobbing.
void wobble() {
const Settings settings;
Spring spring;
float roll{}, nod{}, sway{}, bob{};
double t = 0;
for (int i = 0; i < 600; ++i, t += 1.0 / 60) {
const auto w = static_cast<float>(t);
const Vec3 origin{w * 5, 0, 0};
const auto head = head_at(1.0f + 6 * deg * std::sin(2 * pi * 2.0f * w),
-10 * deg + 12 * deg * std::sin(2 * pi * 2.5f * w), 10 * deg * std::sin(2 * pi * 3.0f * w),
{origin[0], 1.6f + 0.04f * std::sin(2 * pi * 2 * w), 0});
const auto view = update(spring, head, settings, t, origin);
if (i < 120) continue;
const auto a = angles_of(view);
roll = std::max(roll, std::abs(a.roll));
nod = std::max(nod, std::abs(a.pitch + 10 * deg * settings.head_pitch * 0.01f));
sway = std::max(sway, std::abs(wrap_angle(a.yaw - 1.0f)));
bob = std::max(bob, std::abs(view[13] - 1.6f));
}
expect(roll < 0.01f * deg, "head tilt 0% keeps the horizon level");
expect(nod < 12 * deg * settings.head_pitch * 0.01f * 0.5f, "the kept head nod loses over half its wobble");
expect(sway < 3 * deg, "side-to-side sway loses half its wobble");
expect(bob < 0.025f, "head bob is reduced");
}
void spin() {
const Settings settings;
Spring spring;
float lag{};
double t = 0;
for (int i = 0; i < 120; ++i, t += 1.0 / 60) {
const float yaw = 4 * pi * static_cast<float>(t); // two turns a second
const auto view = update(spring, head_at(wrap_angle(yaw), -5 * deg, 0, {0, 1.6f, 0}), settings, t, {});
if (i > 30) lag = std::max(lag, std::abs(wrap_angle(angles_of(view).yaw - yaw)));
}
expect(lag < 30 * deg, "a 720 keeps the view within 30 degrees");
}
void ollie() {
const Settings settings;
Spring spring;
float gap{};
double t = 0;
for (int i = 0; i < 90; ++i, t += 1.0 / 60) {
const float w = static_cast<float>(t) - 0.5f;
const float rise = w < 0 ? 0 : w < 0.2f ? 0.5f * w / 0.2f : w < 0.6f ? 0.5f : std::max(0.0f, 0.5f - (w - 0.6f) * 2.5f);
const auto view = update(spring, head_at(0, 0, 0, {0, 1.6f + rise, 0}), settings, t, {});
if (w > 0.45f && w < 0.6f) gap = std::max(gap, std::abs(view[13] - 1.6f - rise));
}
expect(gap < 0.05f, "the eye catches up by the top of an ollie");
}
void snapping() {
const Settings settings;
Spring spring;
auto view = update(spring, head_at(2.0f, 0, 0, {0, 1.6f, 0}), settings, 1.0, {});
expect(std::abs(wrap_angle(angles_of(view).yaw - 2.0f)) < 1e-3f, "the first frame takes the head as it is");
view = update(spring, head_at(-1.0f, 0, 0, {500, 1.6f, 0}), settings, 2.0, {500, 0, 0});
expect(std::abs(wrap_angle(angles_of(view).yaw + 1.0f)) < 1e-3f && std::abs(view[12] - 500) < 1e-3f,
"a pause or teleport snaps instead of smoothing across it");
}
// One full backflip: with Follow flips the view turns over, without it stays upright.
void flips() {
Settings follow, upright;
upright.follow_flips = false;
Spring a, b;
float lowest_follow = 1, lowest_upright = 1;
bool finite = true;
double t = 0;
for (int i = 0; i < 90; ++i, t += 1.0 / 60) {
const float turn = std::min(1.0f, static_cast<float>(t) / 1.2f) * 2 * pi;
Matrix head{};
head[15] = 1;
write(head, multiply({0, std::sin(0.5f), 0, std::cos(0.5f)}, {std::sin(-turn / 2), 0, 0, std::cos(-turn / 2)}), {0, 1.6f, 0});
const auto va = update(a, head, follow, t, {}), vb = update(b, head, upright, t, {});
for (unsigned j = 0; j < 16; ++j) finite = finite && std::isfinite(va[j]) && std::isfinite(vb[j]);
lowest_follow = std::min(lowest_follow, va[5]);
lowest_upright = std::min(lowest_upright, vb[5]);
}
expect(finite, "a flip gives finite views");
expect(lowest_follow < -0.5f, "Follow flips turns the view over");
expect(lowest_upright > 0.1f, "without Follow flips the view stays upright");
}
void off_and_limits() {
Settings settings;
settings.stabilize = false;
Spring spring;
const auto head = head_at(0.3f, 0.2f, 0.4f, {1, 2, 3});
const auto view = update(spring, head, settings, 1.0, {});
float difference = 0;
for (unsigned i = 0; i < 16; ++i) difference += std::abs(view[i] - head[i]);
expect(difference < 1e-4f, "True first person off is the plain head camera");
expect(valid(Settings{}), "the defaults are valid");
settings.smoothing = 101;
expect(!valid(settings), "smoothing above 100% is refused");
}
} // namespace
int main() {
frame_layout();
wobble();
spin();
ollie();
snapping();
flips();
off_and_limits();
if (failures) {
std::printf("%d first-person stabilize test(s) failed\n", failures);
return 1;
}
std::printf("first-person stabilize tests passed\n");
return 0;
}
+111 -3
View File
@@ -13,12 +13,28 @@ struct Settings {
bool enabled = false;
Vec3 offset{}, rotation{};
float up = 50, down = 50, left = 50, right = 50;
// True first person: the view takes its heading from the head but keeps the
// horizon level, and filters the animation's wobble. Percentages, 0..100.
bool stabilize = true, follow_flips = true;
// Hand the view back to the game's camera while the skater walks (opt-in).
bool board_only = false;
float smoothing = 60, head_pitch = 86, head_roll = 0, bob = 25;
};
// Adaptive low-pass ("One Euro") state for one channel: heavy smoothing while the
// value barely moves, little lag while it moves fast (spins, carving).
struct Euro { float value{}, rate{}; };
struct Stable {
bool ready = false;
double time{};
Euro yaw, pitch, roll;
std::array<Euro, 3> offset{};
};
struct Spring {
bool ready = false;
double time{};
Vec3 position{}, velocity{}, angular_velocity{}, previous_target{}, previous_origin{};
Quat rotation{0, 0, 0, 1};
Stable stable{};
};
inline Vec3 add(const Vec3& a, const Vec3& b) { return {a[0]+b[0], a[1]+b[1], a[2]+b[2]}; }
inline Vec3 subtract(const Vec3& a, const Vec3& b) { return {a[0]-b[0], a[1]-b[1], a[2]-b[2]}; }
@@ -70,10 +86,101 @@ inline bool valid(const Settings& settings) {
const auto bounded = [](float v, float limit) { return std::isfinite(v) && std::abs(v) <= limit; };
for (unsigned i = 0; i < 3; ++i)
if (!bounded(settings.offset[i], offset_limit) || !bounded(settings.rotation[i], rotation_limit)) return false;
for (float v : {settings.up, settings.down, settings.left, settings.right})
for (float v : {settings.up, settings.down, settings.left, settings.right,
settings.smoothing, settings.head_pitch, settings.head_roll, settings.bob})
if (!bounded(v, strength_limit) || v < 0) return false;
return true;
}
inline float dot(const Vec3& a, const Vec3& b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
inline Vec3 cross(const Vec3& a, const Vec3& b) {
return {a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]};
}
inline float wrap_angle(float radians) { return std::remainder(radians, 2*std::numbers::pi_v<float>); }
inline float euro_alpha(float cutoff, float seconds) {
const float tau = 1 / (2*std::numbers::pi_v<float>*cutoff);
return 1 / (1 + tau/seconds);
}
// `change` is the raw step since the last sample (already wrapped for angles).
// The rate is filtered at 0.5 Hz so an animation's back-and-forth averages out
// and only sustained turns (spins, carves) open the filter up.
inline void euro(Euro& state, float change, float seconds, float min_cutoff, float beta) {
state.rate += (change/seconds - state.rate) * euro_alpha(0.5f, seconds);
state.value += change * euro_alpha(min_cutoff + beta*std::abs(state.rate), seconds);
}
// Rows (right, up, backward) of a level view looking along yaw / pitch, then
// rolled by `roll` about the view axis. World is Y-up; yaw 0 looks along +Z.
inline void write_level(Matrix& m, float yaw, float pitch, float roll) {
const Vec3 forward{std::sin(yaw)*std::cos(pitch), std::sin(pitch), std::cos(yaw)*std::cos(pitch)};
const Vec3 level_right{-std::cos(yaw), 0, std::sin(yaw)};
const auto level_up = cross(level_right, forward);
const float c = std::cos(roll), s = std::sin(roll);
for (unsigned i = 0; i < 3; ++i) {
m[i] = level_right[i]*c + level_up[i]*s;
m[4+i] = level_up[i]*c - level_right[i]*s;
m[8+i] = -forward[i];
}
}
// Splits the head into heading, nod and tilt, keeps the heading, scales the nod
// and tilt by the settings and filters all three, so the view stops wobbling
// with the animation. Flips and bails (head upside down) hand the view back to
// the real head when follow_flips is on.
inline Matrix stabilize(Stable& state, const Matrix& head, const Settings& settings, double now, const Vec3& origin) {
const Vec3 right{head[0], head[1], head[2]}, up{head[4], head[5], head[6]};
const Vec3 forward{-head[8], -head[9], -head[10]};
const float level = std::sqrt(forward[0]*forward[0] + forward[2]*forward[2]);
// Looking nearly straight up or down, the top of the head says where the
// skater is heading better than the face does.
const float yaw = level > 0.25f ? std::atan2(forward[0], forward[2])
: forward[1] < 0 ? std::atan2(up[0], up[2]) : std::atan2(-up[0], -up[2]);
const float pitch = std::asin(std::clamp(forward[1], -1.0f, 1.0f));
Matrix level_frame = head;
write_level(level_frame, yaw, pitch, 0);
const float roll = std::atan2(dot(right, {level_frame[4], level_frame[5], level_frame[6]}),
dot(right, {level_frame[0], level_frame[1], level_frame[2]}));
const float pitch_target = pitch * settings.head_pitch * 0.01f;
const float roll_target = roll * settings.head_roll * 0.01f;
const Vec3 eye{head[12], head[13], head[14]};
const auto offset = subtract(eye, origin);
const double elapsed = now - state.time;
bool snap = !state.ready || !std::isfinite(elapsed) || elapsed < 0 || elapsed > 0.25;
for (unsigned i = 0; i < 3 && !snap; ++i) snap = std::abs(offset[i] - state.offset[i].value) > 3;
if (snap) {
state = {true, now, {yaw, 0}, {pitch_target, 0}, {roll_target, 0},
{Euro{offset[0], 0}, Euro{offset[1], 0}, Euro{offset[2], 0}}};
} else if (elapsed > 0.00001) {
state.time = now;
const float seconds = static_cast<float>(elapsed);
// Smoothing 0% follows the head directly; 100% leaves a 0.15 Hz floor.
const float s = settings.smoothing * 0.01f;
const float angle_cutoff = s <= 0 ? 1000.0f : 0.15f * std::pow(40.0f, 1 - s);
constexpr float angle_beta = 0.6f;
euro(state.yaw, wrap_angle(yaw - state.yaw.value), seconds, angle_cutoff, angle_beta);
state.yaw.value = wrap_angle(state.yaw.value);
euro(state.pitch, pitch_target - state.pitch.value, seconds, angle_cutoff, angle_beta);
euro(state.roll, wrap_angle(roll_target - state.roll.value), seconds, angle_cutoff, angle_beta);
for (unsigned i = 0; i < 3; ++i) euro(state.offset[i], offset[i] - state.offset[i].value, seconds, 0.6f, 1.5f);
}
auto result = head;
write_level(result, state.yaw.value, state.pitch.value, state.roll.value);
// Head bob: the share of the eye's movement around the body that is kept.
const float bob = settings.bob * 0.01f;
for (unsigned i = 0; i < 3; ++i) {
const float smooth = state.offset[i].value;
result[12+i] = origin[i] + smooth + (offset[i] - smooth) * bob;
}
// Upside down (up.y below 0.4, fully past -0.1): blend to the real head.
const float flip = settings.follow_flips ? std::clamp((0.4f - up[1]) / 0.5f, 0.0f, 1.0f) : 0.0f;
if (flip > 0) {
auto stable = orientation(result);
auto real = orientation(head);
if (stable[0]*real[0] + stable[1]*real[1] + stable[2]*real[2] + stable[3]*real[3] < 0) for (auto& v : real) v = -v;
Quat blended{};
for (unsigned i = 0; i < 4; ++i) blended[i] = stable[i] + (real[i] - stable[i]) * flip;
write(result, normalized(blended), {result[12], result[13], result[14]});
}
return result;
}
inline Matrix target(Matrix head, const Settings& settings) {
const auto head_rotation = orientation(head);
const auto position = add({head[12], head[13], head[14]},
@@ -113,14 +220,15 @@ inline void damp(float& error, float& velocity, float strength, float seconds) {
}
inline Matrix update(Spring& spring, const Matrix& head, const Settings& settings, double now, const Vec3& origin = {}) {
if (!valid(settings) || !std::isfinite(now)) return head;
auto result = target(head, settings);
if (!settings.stabilize) spring.stable = {};
auto result = target(settings.stabilize ? stabilize(spring.stable, head, settings, now, origin) : head, settings);
const auto position = subtract({result[12],result[13],result[14]}, origin);
const auto rotation = orientation(result);
const double elapsed = now - spring.time;
if (!spring.ready || !settings.enabled || elapsed < 0 || elapsed > 0.25 ||
length(subtract(position, spring.previous_target)) > 5 ||
length(subtract(origin, spring.previous_origin)) > 5) {
spring = {true, now, position, {}, {}, position, origin, rotation};
spring = {true, now, position, {}, {}, position, origin, rotation, spring.stable};
return result;
}
spring.time = now;
+74 -4
View File
@@ -212,7 +212,9 @@ void debug_flight_tick(SourceTrial& trial, std::uintptr_t client, bool ready, bo
arm.thread.store(GetCurrentThreadId(), std::memory_order_release);
if (debug.first_person_waiting) {
debug.first_person_waiting = false;
debug.status = "First person on. The camera follows the skater's head.";
debug.status = debug.first_person_settings.stabilize
? "First person on. True first person keeps the view level and steady."
: "First person on. The camera follows the skater's head.";
}
} else {
next = step_free_flight(debug.flight_matrix, input ? *input : idle, seconds, debug.flight_speed);
@@ -248,6 +250,10 @@ constexpr const char* spring = "FirstPerson.Spring";
constexpr std::array<const char*, 3> offset{"FirstPerson.OffsetX", "FirstPerson.OffsetY", "FirstPerson.OffsetZ"};
constexpr std::array<const char*, 3> rotation{"FirstPerson.Pitch", "FirstPerson.Yaw", "FirstPerson.Roll"};
constexpr std::array<const char*, 4> strength{"FirstPerson.Up", "FirstPerson.Down", "FirstPerson.Left", "FirstPerson.Right"};
constexpr const char* stabilize = "FirstPerson.Stabilize";
constexpr const char* follow_flips = "FirstPerson.FollowFlips";
constexpr const char* board_only = "FirstPerson.ThirdPersonOnFoot";
constexpr std::array<const char*, 4> steady{"FirstPerson.Smoothing", "FirstPerson.HeadPitch", "FirstPerson.HeadRoll", "FirstPerson.Bob"};
constexpr ULONGLONG delay_ms = 750;
std::optional<float> number(const char* key) {
@@ -281,6 +287,11 @@ void save_debug(InteractiveDebug& debug) noexcept {
}
const std::array<float, 4> strengths{arm.up, arm.down, arm.left, arm.right};
for (std::size_t i = 0; i < 4; ++i) values.emplace_back(saved::strength[i], static_cast<double>(strengths[i]));
values.emplace_back(saved::stabilize, arm.stabilize);
values.emplace_back(saved::follow_flips, arm.follow_flips);
values.emplace_back(saved::board_only, arm.board_only);
const std::array<float, 4> steady{arm.smoothing, arm.head_pitch, arm.head_roll, arm.bob};
for (std::size_t i = 0; i < 4; ++i) values.emplace_back(saved::steady[i], static_cast<double>(steady[i]));
profile_runtime::set_local_values(values);
} catch (...) { /* Saving is best effort; the choices already apply. */ }
}
@@ -317,6 +328,12 @@ void load_saved_debug(InteractiveDebug& debug) noexcept {
const std::array<float*, 4> strengths{&arm.up, &arm.down, &arm.left, &arm.right};
for (std::size_t i = 0; i < 4; ++i)
if (const auto value = saved::number(saved::strength[i])) *strengths[i] = *value;
if (const auto enabled = profile_runtime::local_preference(saved::stabilize)) arm.stabilize = *enabled;
if (const auto enabled = profile_runtime::local_preference(saved::follow_flips)) arm.follow_flips = *enabled;
if (const auto enabled = profile_runtime::local_preference(saved::board_only)) arm.board_only = *enabled;
const std::array<float*, 4> steady{&arm.smoothing, &arm.head_pitch, &arm.head_roll, &arm.bob};
for (std::size_t i = 0; i < 4; ++i)
if (const auto value = saved::number(saved::steady[i])) *steady[i] = *value;
if (first_person::valid(arm)) {
debug.first_person_settings = arm;
first_person_arm().settings = arm;
@@ -393,17 +410,29 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b
case Action::set_first_person_spring_down: settings.down = request.value; break;
case Action::set_first_person_spring_left: settings.left = request.value; break;
case Action::set_first_person_spring_right: settings.right = request.value; break;
case Action::set_first_person_stabilize: settings.stabilize = request.enabled; break;
case Action::set_first_person_follow_flips: settings.follow_flips = request.enabled; break;
case Action::set_first_person_smoothing: settings.smoothing = request.value; break;
case Action::set_first_person_head_pitch: settings.head_pitch = request.value; break;
case Action::set_first_person_head_roll: settings.head_roll = request.value; break;
case Action::set_first_person_bob: settings.bob = request.value; break;
case Action::set_first_person_board_only: settings.board_only = request.enabled; break;
case Action::reset_first_person_arm: settings = {}; break;
default: break;
}
source_require(first_person::valid(settings), "First-person arm setting is outside its supported range.");
auto& arm = first_person_arm();
if (settings.enabled != debug.first_person_settings.enabled || request.action == Action::reset_first_person_arm)
if (settings.enabled != debug.first_person_settings.enabled ||
settings.stabilize != debug.first_person_settings.stabilize || request.action == Action::reset_first_person_arm)
arm.spring.reset();
debug.first_person_settings = settings;
arm.settings = settings;
mark_debug_changed(debug);
debug.status = "First-person arm settings updated.";
debug.status = request.action == Action::set_first_person_stabilize
? (settings.stabilize ? "True first person on: level horizon, steady view." : "True first person off: the view follows the raw head.")
: request.action == Action::set_first_person_board_only
? (settings.board_only ? "Third person while walking; first person on the board." : "First person while walking too.")
: "First-person settings updated.";
return;
}
if (request.action == overlay::DebugAction::set_free_camera_fov) {
@@ -658,6 +687,42 @@ void debug_action(SourceTrial& trial, std::uintptr_t client, bool can_control, b
}
source_require(false, "Unknown debug request.");
}
// "Third person on foot": runs only while first person is on or this paused
// it, so players who never use first person are untouched. Switches through
// the same actions as the menu toggle, after the state has held briefly.
void first_person_board_tick(SourceTrial& trial, std::uintptr_t client, bool can_control, bool phase, DWORD error) {
auto& debug = trial.debug;
// Choosing Freecam or the Park Editor while handed back ends the hand-back.
if (debug.first_person_paused && (debug.park_editor || (debug.camera_owned && !debug.first_person)))
debug.first_person_paused = false;
// With the option off, a hand-back still in progress resumes below.
if (!debug.first_person_paused && !(debug.first_person && debug.first_person_settings.board_only)) return;
if (!can_control || !phase || debug.park_editor || debug.noclip || debug.editor_transition) return;
const auto now = GetTickCount64();
const auto on_foot = debug.first_person_settings.board_only ? first_person_on_foot(trial.base, client) : std::optional<bool>(false);
if (!on_foot) return;
if (*on_foot != debug.first_person_last_on_foot) {
debug.first_person_last_on_foot = *on_foot;
debug.first_person_foot_since = now;
}
const auto held = now - debug.first_person_foot_since;
if (now < debug.first_person_retry_after) return;
try {
if (debug.first_person && *on_foot && held >= 250) {
debug_action(trial, client, can_control, phase, {overlay::DebugAction::set_first_person, false}, error);
debug.first_person_paused = true;
debug.status = "On foot: third person until you are back on the board.";
} else if (debug.first_person_paused && !*on_foot && held >= 120) {
debug_action(trial, client, can_control, phase, {overlay::DebugAction::set_first_person, true}, error);
debug.first_person_paused = !debug.first_person;
}
} catch (const SourceGuard& guard) {
debug.status = guard.message;
debug.first_person_retry_after = now + 500;
} catch (...) {
debug.first_person_retry_after = now + 500;
}
}
}
}
@@ -704,10 +769,14 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli
}
const bool no_bail_allowed = session_no_bail_allowed();
if (request) {
// The player's own First person choice replaces any on-foot hand-back.
if (request->action == overlay::DebugAction::set_first_person || request->action == overlay::DebugAction::restore_debug)
debug.first_person_paused = false;
try { debug_action(trial, client, can_control, camera_phase_observed, *request, error.value); }
catch (const SourceGuard& guard) { debug.status = guard.message; }
catch (...) { debug.status = "Debug action failed; inspect the current state before retrying."; }
}
first_person_board_tick(trial, client, can_control, camera_phase_observed, error.value);
std::optional<SourceCameraSnapshot> flight_camera;
try { debug_flight_tick(trial, client, can_control, camera_phase_observed, flight_input, error.value, flight_camera); }
catch (const SourceGuard& guard) { debug_stop_noclip(debug); debug.status = guard.message; }
@@ -726,7 +795,8 @@ overlay::DebugModel on_client_debug_tick(std::uintptr_t base, std::uintptr_t cli
const auto camera = flight_camera ? *flight_camera : source_camera_snapshot(trial, client);
const bool owned_view = camera.mode == 1 && camera.active == camera.identity.camera;
model.free_camera = owned_view && !debug.first_person;
model.first_person = owned_view && debug.first_person;
// Handed back on foot still reads as on, so the toggle can switch it off.
model.first_person = (owned_view && debug.first_person) || debug.first_person_paused;
model.first_person_fov = debug.first_person_fov;
model.free_camera_fov = debug.free_camera_fov;
model.camera_available = can_control && camera_phase_observed && !debug.camera_ambiguous;
+15
View File
@@ -3,6 +3,7 @@
#include "Extension/Multiplayer/Remote/native_pose_layout.h"
#include "Extension/Multiplayer/Remote/native_skater.h"
#include "Engine/Game/Build/20260929/engine.h"
#include "Engine/Game/Build/20260929/no_bail.h"
#include "free_flight.h"
#include <cmath>
@@ -140,6 +141,20 @@ std::uintptr_t first_person_component(std::uintptr_t base, std::uintptr_t client
"Skater animation is unavailable.");
return component;
}
// The physics state selector's current state: skater component +0x70 is the
// physics core, core +0x3c0 its context, context +0x1414 the state (no_bail.cpp).
std::optional<bool> first_person_on_foot(std::uintptr_t base, std::uintptr_t client) noexcept {
try {
const auto component = first_person_component(base, client);
std::uintptr_t core{}, core_type{}, context{};
std::uint32_t state{};
if (!first_person_read(component + 0x70, &core, 8) || !first_person_read(core, &core_type, 8) ||
core_type != base + addr::no_bail::bail_core_vtable ||
!first_person_read(core + 0x3c0, &context, 8) || !first_person_read(context + 0x1414, &state, 4))
return std::nullopt;
return state == addr::no_bail::offboard_physics_state;
} catch (...) { return std::nullopt; }
}
// Writes the head camera into the rows (right, up, backward, position) of a
// native camera matrix, leaving each row's fourth lane as the camera had it.
first_person::Vec3 first_person_head_matrix(std::uintptr_t base, std::uintptr_t component, std::array<float, 16>& matrix) {
@@ -13,6 +13,7 @@
#include <cstdint>
#include <cstring>
#include <mutex>
#include <optional>
#include <string>
#include <vector>
@@ -102,6 +103,10 @@ struct InteractiveDebug {
// Shares Freecam's owned mode-1 camera; each tick publishes the head pose
// instead of a flight step.
bool first_person{}, first_person_waiting{};
// "Third person on foot": first person handed the camera back while the
// skater walks and takes it again once they are back on the board.
bool first_person_paused{}, first_person_last_on_foot{};
ULONGLONG first_person_foot_since{}, first_person_retry_after{};
// FreeCamera vertical FOV (+0xac): chosen value (0 = unchanged) and the value
// it held before first person took it, restored when first person ends.
float first_person_fov{}, first_person_saved_fov{};
@@ -215,6 +220,8 @@ bool first_person_write_fov(std::uintptr_t camera, float fov) noexcept;
void first_person_restore_fov(InteractiveDebug& debug) noexcept;
void free_camera_restore_fov(InteractiveDebug& debug) noexcept;
std::uintptr_t first_person_component(std::uintptr_t base, std::uintptr_t client);
// Whether the local skater is walking (physics state Offboard); empty if unreadable.
std::optional<bool> first_person_on_foot(std::uintptr_t base, std::uintptr_t client) noexcept;
first_person::Vec3 first_person_head_matrix(std::uintptr_t base, std::uintptr_t component, std::array<float, 16>& matrix);
void first_person_on_render(std::uintptr_t animation_interface) noexcept;
void first_person_on_animation(std::uintptr_t component) noexcept;
+7
View File
@@ -65,6 +65,13 @@ enum class DebugAction {
set_first_person_spring_down,
set_first_person_spring_left,
set_first_person_spring_right,
set_first_person_stabilize,
set_first_person_follow_flips,
set_first_person_smoothing,
set_first_person_head_pitch,
set_first_person_head_roll,
set_first_person_bob,
set_first_person_board_only,
reset_first_person_arm,
set_free_camera_fov, // 0 = the game's own FOV
// Keep the last action in sync with the bound in request_scheduler.h.
+28 -1
View File
@@ -51,7 +51,7 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal
begin_card(menu, "first-person", "FIRST PERSON");
bool first_person = debug.first_person;
if (toggle_row(menu, "First person", "Attach the camera to the skater's head.", first_person,
if (toggle_row(menu, "First person", "See the world through the skater's eyes.", first_person,
debug.available && debug.camera_available && callbacks.queue_debug))
debug_request(menu, callbacks, {DebugAction::set_first_person, first_person});
field(menu, "Field of view");
@@ -60,6 +60,33 @@ void camera_controls(SkateMenu& menu, const Model& model, const CallbacksV3& cal
if (ImGui::SliderInt("##first-person-fov", &fov, 40, 120, "%d degrees", ImGuiSliderFlags_AlwaysClamp))
debug_request(menu, callbacks, {DebugAction::set_first_person_fov, false, static_cast<float>(fov)});
ImGui::EndDisabled();
{
auto settings = debug.first_person_arm;
const bool can_edit = debug.available && callbacks.queue_debug;
if (toggle_row(menu, "True first person",
"Keep the horizon level and the view steady; the head's heading is still followed.", settings.stabilize, can_edit))
debug_request(menu, callbacks, {DebugAction::set_first_person_stabilize, settings.stabilize});
ImGui::BeginDisabled(!settings.stabilize || !can_edit);
const auto percent = [&](const char* label, const char* id, float value, DebugAction action) {
field(menu, label);
if (ImGui::SliderFloat(id, &value, 0, dingosdk::first_person::strength_limit, "%.0f%%", ImGuiSliderFlags_AlwaysClamp))
debug_request(menu, callbacks, {action, false, value});
};
percent("Smoothing", "##fp-smoothing", settings.smoothing, DebugAction::set_first_person_smoothing);
percent("Head nod (up / down)", "##fp-head-pitch", settings.head_pitch, DebugAction::set_first_person_head_pitch);
percent("Head tilt (horizon roll)", "##fp-head-roll", settings.head_roll, DebugAction::set_first_person_head_roll);
percent("Head bob", "##fp-bob", settings.bob, DebugAction::set_first_person_bob);
if (toggle_row(menu, "Follow flips", "Turn the view with the skater during flips and bails.", settings.follow_flips,
can_edit && settings.stabilize))
debug_request(menu, callbacks, {DebugAction::set_first_person_follow_flips, settings.follow_flips});
ImGui::EndDisabled();
if (toggle_row(menu, "Third person on foot",
"Use the game's camera while walking; first person comes back when you get on the board.",
settings.board_only, can_edit))
debug_request(menu, callbacks, {DebugAction::set_first_person_board_only, settings.board_only});
note("Nod, tilt and bob are the share of the head's own movement kept. Use Spring arm > Pitch to look "
"further down at the board.");
}
if (ImGui::TreeNode("Spring arm")) {
namespace fp = dingosdk::first_person;
auto settings = debug.first_person_arm;