Merge pull request #22889 from hrydgard/input-latency

Android: Minor input and audio latency improvements
This commit is contained in:
Henrik Rydgård authored and GitHub committed 2026-11-02 16:49:06 +01:00
commit ae21ead0d8
11 files changed
+217 -22

No files matched your search

+2
View File
@@ -896,6 +896,8 @@ if(ANDROID)
android/jni/AndroidEGLGraphicsContext.h
android/jni/OboeContext.cpp
android/jni/OboeContext.h
android/jni/PerformanceHint.cpp
android/jni/PerformanceHint.h
)
# No target
elseif(IOS AND NOT LIBRETRO)
+4
View File
@@ -805,7 +805,11 @@ if (WIN32)
set(WIN_VERSION_CMD COMMAND ${CMAKE_SOURCE_DIR}/Windows/git-version-gen.cmd PPSSPPWindows)
endif()
# BYPRODUCTS tells Ninja that this writes git-version.cpp, so it checks the file again after the
# command has run. Otherwise it only sees the timestamp from before the build, and the object is
# recompiled a build late, if at all. The file is only rewritten when the version changes.
add_custom_command(OUTPUT something_that_never_exists
BYPRODUCTS ${CMAKE_BINARY_DIR}/git-version.cpp
COMMAND ${CMAKE_COMMAND} -DSOURCE_DIR=${CMAKE_SOURCE_DIR}
-DOUTPUT_DIR=${CMAKE_BINARY_DIR}
-P ${CMAKE_SOURCE_DIR}/git-version.cmake
+12 -2
View File
@@ -114,6 +114,10 @@ static u32 emuRapidFireInterval = 5;
// no buttons and centered sticks, as impose does on a PSP. Host-side peeks still see the real state.
static std::atomic<bool> gameInputBlocked{ false };
// Buttons pressed since the last sample. A press and release that both land between two samples
// would otherwise never be seen by the game. Guarded by ctrlMutex.
static u32 ctrlUnsampledPresses = 0;
// These buttons are not affected by rapid fire (neither is analog.)
const u32 CTRL_EMU_RAPIDFIRE_MASK = CTRL_UP | CTRL_DOWN | CTRL_LEFT | CTRL_RIGHT;
@@ -122,7 +126,8 @@ static void __CtrlUpdateLatch()
std::lock_guard<std::mutex> guard(ctrlMutex);
u64 t = CoreTiming::GetGlobalTimeUs();
u32 buttons = ctrlCurrent.buttons;
u32 buttons = ctrlCurrent.buttons | ctrlUnsampledPresses;
ctrlUnsampledPresses = 0;
if (emuRapidFire && emuRapidFireToggle)
buttons &= CTRL_EMU_RAPIDFIRE_MASK;
@@ -284,7 +289,8 @@ void __CtrlUpdateButtons(u32 bitsToSet, u32 bitsToClear)
std::lock_guard<std::mutex> guard(ctrlMutex);
// There's no atomic operation for this, so mutex it is.
ctrlCurrent.buttons = (ctrlCurrent.buttons & ~bitsToClear) | bitsToSet;
ctrlUnsampledPresses |= bitsToSet & ~ctrlCurrent.buttons;
ctrlCurrent.buttons =(ctrlCurrent.buttons & ~bitsToClear) | bitsToSet;
}
void __CtrlSetAnalogXY(int stick, float x, float y)
@@ -448,6 +454,7 @@ void __CtrlInit() {
latch.btnRelease = 0xffffffff;
gameInputBlocked = false;
ctrlUnsampledPresses = 0;
memset(&ctrlCurrent, 0, sizeof(ctrlCurrent));
memset(ctrlCurrent.analog, CTRL_ANALOG_CENTER, sizeof(ctrlCurrent.analog));
analogEnabled = false;
@@ -464,6 +471,9 @@ void __CtrlDoState(PointerWrap &p)
if (!s)
return;
// Host input from before the load shouldn't show up in the loaded state.
ctrlUnsampledPresses = 0;
Do(p, analogEnabled);
Do(p, ctrlLatchBufs);
Do(p, ctrlOldButtons);
+1
View File
@@ -1049,6 +1049,7 @@ LOCAL_SRC_FILES := \
$(SRC)/android/jni/AndroidAudio.cpp \
$(SRC)/android/jni/AndroidEGLGraphicsContext.cpp \
$(SRC)/android/jni/OboeContext.cpp \
$(SRC)/android/jni/PerformanceHint.cpp \
$(SRC)/UI/ImDebugger/ImDebugger.cpp \
$(SRC)/UI/ImDebugger/ImGe.cpp \
$(SRC)/UI/ImDebugger/ImConsole.cpp \
+2 -2
View File
@@ -25,8 +25,8 @@ AudioContext::AudioContext(AndroidAudioCallback cb, int _FramesPerBuffer, int _S
framesPerBuffer = 4096;
sampleRate = _SampleRate;
g_error = "";
g_info = "";
SetErrorString("");
SetInfoString("");
}
void AudioContext::SetErrorString(const std::string &error) {
+27 -9
View File
@@ -15,8 +15,8 @@
// callback on a thread of its own that can outlive the OboeContext.
class OboeContext::Streams : public oboe::AudioStreamDataCallback, public oboe::AudioStreamErrorCallback, public std::enable_shared_from_this<OboeContext::Streams> {
public:
Streams(AndroidAudioCallback cb, int framesPerBuffer, int sampleRate)
: audioCallback_(cb), framesPerBuffer_(framesPerBuffer), sampleRate_(sampleRate) {
Streams(AndroidAudioCallback cb, int sampleRate)
: audioCallback_(cb), sampleRate_(sampleRate) {
// The setting isn't offered before Android 8.1, where AAudio isn't stable.
if (g_Config.iAndroidAudioApi == (int)AndroidAudioApi::AAUDIO && oboe::AudioStreamBuilder::isAAudioRecommended()) {
audioApi_ = oboe::AudioApi::AAudio;
@@ -49,12 +49,16 @@ public:
void Shutdown() {
std::lock_guard<std::mutex> guard(mutex_);
shutdown_ = true;
CloseStream(output_);
CloseOutput();
CloseStream(input_);
}
oboe::DataCallbackResult onAudioReady(oboe::AudioStream *stream, void *audioData, int32_t numFrames) override {
if (stream->getDirection() == oboe::Direction::Output) {
// Grows the buffer a burst at a time when we underrun.
if (outputTuner_) {
outputTuner_->tune();
}
audioCallback_((short *)audioData, numFrames, stream->getSampleRate(), nullptr);
} else {
// This is the audio thread, so just buffer it. The CPU thread picks it up.
@@ -77,6 +81,7 @@ public:
}
WARN_LOG(Log::Audio, "Oboe: %s stream closed with error: %s", isOutput ? "output" : "input", oboe::convertToText(error));
if (isOutput) {
outputTuner_.reset();
output_.reset();
} else {
input_.reset();
@@ -107,7 +112,9 @@ private:
->setChannelConversionAllowed(true)
->setSampleRate(sampleRate_)
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Medium)
->setFramesPerDataCallback(framesPerBuffer_)
// No setFramesPerDataCallback: NativeMix takes any size, and forcing one makes Oboe add a
// block adapter, which holds back an extra buffer when it doesn't match the burst (MMAP
// streams often have a smaller burst than PROPERTY_OUTPUT_FRAMES_PER_BUFFER).
->setDataCallback(this)
->setErrorCallback(shared_from_this());
oboe::Result result = builder.openStream(output_);
@@ -115,11 +122,11 @@ private:
output_.reset();
return Fail("Failed to open output stream", result);
}
// Double buffering, the same latency as the old OpenSL path.
output_->setBufferSizeInFrames(output_->getFramesPerBurst() * 2);
// Starts at two bursts.
outputTuner_ = std::make_unique<oboe::LatencyTuner>(*output_);
result = output_->requestStart();
if (result != oboe::Result::OK) {
CloseStream(output_);
CloseOutput();
return Fail("Failed to start output stream", result);
}
char info[256];
@@ -161,6 +168,16 @@ private:
return true;
}
void CloseOutput() {
if (output_) {
oboe::ResultWithValue<int32_t> xruns = output_->getXRunCount();
INFO_LOG(Log::Audio, "Oboe: Closing output stream, buffer %d, underruns %d", output_->getBufferSizeInFrames(), xruns ? xruns.value() : -1);
}
// Stopped first, so the callback is done with the tuner.
CloseStream(output_);
outputTuner_.reset();
}
static void CloseStream(std::shared_ptr<oboe::AudioStream> &stream) {
if (stream) {
stream->stop();
@@ -176,13 +193,14 @@ private:
}
AndroidAudioCallback audioCallback_;
int framesPerBuffer_;
int sampleRate_;
oboe::AudioApi audioApi_ = oboe::AudioApi::OpenSLES;
std::mutex mutex_;
bool shutdown_ = false;
std::shared_ptr<oboe::AudioStream> output_;
// Only touched by the audio callback while output_ is running.
std::unique_ptr<oboe::LatencyTuner> outputTuner_;
std::shared_ptr<oboe::AudioStream> input_;
int inputSampleRate_ = 0;
};
@@ -192,7 +210,7 @@ OboeContext::OboeContext(AndroidAudioCallback cb, int _FramesPerBuffer, int _Sam
// Only used by the OpenSL ES fallback, which can't query the device's native values itself.
oboe::DefaultStreamValues::SampleRate = sampleRate;
oboe::DefaultStreamValues::FramesPerBurst = framesPerBuffer;
streams_ = std::make_shared<Streams>(cb, framesPerBuffer, sampleRate);
streams_ = std::make_shared<Streams>(cb, sampleRate);
}
bool OboeContext::Init() {
+98
View File
@@ -0,0 +1,98 @@
// Copyright (c) 2012- PPSSPP Project.
// This program 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, version 2.0 or later versions.
// This program 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. See the
// GNU General Public License 2.0 for more details.
// A copy of the GPL 2.0 should have been included with the program.
// If not, see http://www.gnu.org/licenses/
// Official git repository and contact information can be found at
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#include <dlfcn.h>
#include <time.h>
#include <unistd.h>
#include "Common/Log.h"
#include "android/jni/PerformanceHint.h"
struct APerformanceHintManager;
namespace {
// Half a frame. With CPU time as the measure, this leaves the other half as headroom, so the
// scheduler keeps us on a core fast enough to finish well before the frame is due.
constexpr int64_t TARGET_WORK_NS = 1000000000LL / 60 / 2;
// Looked up at runtime, since we build against API 21 and these are API 33.
struct HintApi {
APerformanceHintManager *(*getManager)();
APerformanceHintSession *(*createSession)(APerformanceHintManager *, const int32_t *, size_t, int64_t);
int (*reportActualWorkDuration)(APerformanceHintSession *, int64_t);
void (*closeSession)(APerformanceHintSession *);
bool Load() {
void *lib = dlopen("libandroid.so", RTLD_NOW);
if (!lib) {
return false;
}
getManager = (decltype(getManager))dlsym(lib, "APerformanceHint_getManager");
createSession = (decltype(createSession))dlsym(lib, "APerformanceHint_createSession");
reportActualWorkDuration = (decltype(reportActualWorkDuration))dlsym(lib, "APerformanceHint_reportActualWorkDuration");
closeSession = (decltype(closeSession))dlsym(lib, "APerformanceHint_closeSession");
return getManager && createSession && reportActualWorkDuration && closeSession;
}
};
const HintApi *GetApi() {
static HintApi api{};
static const bool loaded = api.Load();
return loaded ? &api : nullptr;
}
// Time spent running on a CPU, so sleeping in the frame limiter or blocking on vsync or the GPU
// doesn't count as work.
int64_t ThreadCpuTimeNs() {
timespec ts;
if (clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ts) != 0) {
return -1;
}
return (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec;
}
} // namespace
PerformanceHintSession::~PerformanceHintSession() {
if (session_) {
GetApi()->closeSession(session_);
}
}
void PerformanceHintSession::ReportFrame() {
if (failed_) {
return;
}
if (!session_) {
const HintApi *api = GetApi();
APerformanceHintManager *manager = api ? api->getManager() : nullptr;
const int32_t tid = gettid();
session_ = manager ? api->createSession(manager, &tid, 1, TARGET_WORK_NS) : nullptr;
if (!session_) {
INFO_LOG(Log::System, "Performance hint sessions not available");
failed_ = true;
return;
}
INFO_LOG(Log::System, "Performance hint session created for thread %d, target %d us", tid, (int)(TARGET_WORK_NS / 1000));
}
const int64_t now = ThreadCpuTimeNs();
if (lastCpuTimeNs_ >= 0 && now > lastCpuTimeNs_) {
GetApi()->reportActualWorkDuration(session_, now - lastCpuTimeNs_);
}
lastCpuTimeNs_ = now;
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright (c) 2012- PPSSPP Project.
// This program 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, version 2.0 or later versions.
// This program 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. See the
// GNU General Public License 2.0 for more details.
// A copy of the GPL 2.0 should have been included with the program.
// If not, see http://www.gnu.org/licenses/
// Official git repository and contact information can be found at
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#pragma once
#include <cstdint>
struct APerformanceHintSession;
// An ADPF performance hint session (Android 13+) for the thread that runs the frame loop.
// Emulation does a burst of work each frame and then waits, so the scheduler sees a mostly idle
// thread and moves it to a slow core at low clocks, where the next burst then runs. Reporting each
// frame's work against a target tells it what we actually need. Does nothing where unsupported.
class PerformanceHintSession {
public:
~PerformanceHintSession();
// Call once per frame, always from the same thread. The session is created on the first call.
void ReportFrame();
private:
APerformanceHintSession *session_ = nullptr;
bool failed_ = false;
int64_t lastCpuTimeNs_ = -1;
};
+5 -1
View File
@@ -113,6 +113,7 @@ struct JNIEnv {};
#include "UI/GameInfoCache.h"
#include "app-android.h"
#include "android/jni/PerformanceHint.h"
AndroidAudioState *g_audioState;
@@ -1781,10 +1782,13 @@ static void RenderLoopThread(ANativeWindow *wnd, GraphicsContext *graphicsContex
}
renderer_inited = true;
const auto frame = [](GraphicsContext *graphicsContext) {
// The frame callback runs on the emu thread, which with OpenGL isn't this one.
PerformanceHintSession perfHint;
const auto frame = [&perfHint](GraphicsContext *graphicsContext) {
ApplyPendingResize();
NativeFrame(graphicsContext);
ProcessFrameCommands();
perfHint.ReportFrame();
return !exitRenderLoop;
};
// Both backends share one lifecycle now, which is the point of all this: RunGraphicsLoop picks
@@ -7,8 +7,10 @@ package org.ppsspp.ppsspp;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.os.Build;
import android.os.SystemClock;
import android.text.InputType;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.SurfaceView;
@@ -28,6 +30,21 @@ public class NativeSurfaceView extends SurfaceView {
super(context);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Without this, touch moves and joystick axis events are batched until the next UI vsync,
// adding up to a frame of latency before the emulator sees them. Non-pointer sources only
// apply while this view is focused, so keep it focusable.
setFocusable(true);
setFocusableInTouchMode(true);
setDefaultFocusHighlightEnabled(false);
requestFocus();
requestUnbufferedDispatch(InputDevice.SOURCE_CLASS_POINTER | InputDevice.SOURCE_CLASS_JOYSTICK);
}
}
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(final MotionEvent ev) {
@@ -55,9 +72,12 @@ public class NativeSurfaceView extends SurfaceView {
} else {
imm.hideSoftInputFromWindow(getWindowToken(), 0);
imm.restartInput(this);
clearFocus();
setFocusableInTouchMode(false);
setFocusable(false);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
// On R+ we stay focused for unbuffered dispatch, see onAttachedToWindow.
clearFocus();
setFocusableInTouchMode(false);
setFocusable(false);
}
}
}
+4 -5
View File
@@ -26,11 +26,10 @@ Verify after every install, not just when something looks odd:
A second, independent check is worth it when a result is surprising: grep the log for a symbol that
exists only on the branch under test (a renamed class or log tag, say).
The version string itself can also lie. The Android build regenerates `git-version.cpp` but does not
always recompile it, so the APK can carry the previous build's string while containing current code.
When the version matters:
find android/.cxx -name git-version.cpp.o -delete
The version string in the app's UI used to lie as well: Ninja regenerated `git-version.cpp` but didn't
recompile it, so the APK could carry an older commit's string. That's fixed (the file is now declared
as a `BYPRODUCTS` of the generating step in `Core/CMakeLists.txt`), so on a build directory that has
been reconfigured since, the string matches the commit the APK was built from.
## Forcing an activity recreate without touching the device