diff --git a/CMakeLists.txt b/CMakeLists.txt index 6739b61..a74d5f8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,6 +54,7 @@ set(SWEETLOADER_SOURCES src/pl/cosmetics/network/CosmeticSyncJni.cpp src/pl/render/CameraSource.cpp src/pl/render/GlesProbe.cpp + src/pl/render/PresentFrameEffect.cpp src/pl/render/RenderApi.cpp src/pl/hooks/DobbyHookManager.cpp src/pl/runtime/FeatureAvailability.cpp diff --git a/include/pl/render/GlesProbe.hpp b/include/pl/render/GlesProbe.hpp index 2826226..01613a4 100644 --- a/include/pl/render/GlesProbe.hpp +++ b/include/pl/render/GlesProbe.hpp @@ -60,6 +60,7 @@ struct GlesFunctions { void *GenVertexArrays = nullptr; void *BindVertexArray = nullptr; void *EnableVertexAttribArray = nullptr; + void *DisableVertexAttribArray = nullptr; void *VertexAttribPointer = nullptr; void *UseProgram = nullptr; void *GetUniformLocation = nullptr; @@ -67,6 +68,8 @@ struct GlesFunctions { void *Uniform4f = nullptr; void *Uniform3f = nullptr; void *Uniform1i = nullptr; + void *Uniform1f = nullptr; + void *Uniform2f = nullptr; void *ActiveTexture = nullptr; void *BindTexture = nullptr; void *GenTextures = nullptr; @@ -76,9 +79,29 @@ struct GlesFunctions { void *DrawElements = nullptr; void *DrawArrays = nullptr; void *BlendFunc = nullptr; + void *BlendFuncSeparate = nullptr; void *DeleteBuffers = nullptr; void *DeleteVertexArrays = nullptr; void *DeleteTextures = nullptr; + // Present-frame effect path (frame generation / motion blur): the shader program that samples the + // captured frame, and the framebuffer/texture copy used to keep it. Resolved here rather than in + // the effect so the probe stays the single GL symbol table. + void *CreateShader = nullptr; + void *ShaderSource = nullptr; + void *CompileShader = nullptr; + void *GetShaderiv = nullptr; + void *CreateProgram = nullptr; + void *AttachShader = nullptr; + void *LinkProgram = nullptr; + void *GetProgramiv = nullptr; + void *GetAttribLocation = nullptr; + void *GenFramebuffers = nullptr; + void *BindFramebuffer = nullptr; + void *FramebufferTexture2D = nullptr; + void *CopyTexImage2D = nullptr; + void *TexParameterf = nullptr; + void *ClearColor = nullptr; + void *Clear = nullptr; /** @brief True when the core query path is available (nothing essential is null). */ [[nodiscard]] bool usable() const; diff --git a/include/pl/render/PresentFrameEffect.hpp b/include/pl/render/PresentFrameEffect.hpp new file mode 100644 index 0000000..c54a6d4 --- /dev/null +++ b/include/pl/render/PresentFrameEffect.hpp @@ -0,0 +1,65 @@ +#pragma once + +/** + * @file PresentFrameEffect.hpp + * @brief The present-frame effect seam: frame generation and motion blur, on the engine's present. + * + *

Both effects act on the frame the engine is about to present, so both live on the same seam: + * the {@code eglSwapBuffers} detour (`pl::runtime::InstallSwapFrameBoundary`). This header owns the + * plan the launcher sends, the pure blend/interpolation math, and the install state. + * + *

Honesty contract. Neither effect raises the game's frame rate. Frame generation blends + * the previous frame into the current one so the presented image sits between two the game + * already drew; motion blur blends the previous frame in, which softens the image by design. Neither + * presents an extra frame: the seam emits one present per rendered frame. {@link ComputeBlendAlpha} + * and {@link ComputeInterpolationFactor} are pure and host-tested, so the numbers the seam uses are + * checkable off-device. + * + *

Fail-closed. {@link InstallPresentFrameEffect} reports false when the GL path cannot be + * set up (no GLES table, no EGL context, or a build with no present hook). {@link + * IsPresentFrameEffectLive} then reports false and the launcher keeps both modules badged rather + * than toggling to no effect. Nothing here fabricates a frame. + */ + +#include + +#include "pl/Export.hpp" +#include "pl/render/PresentFrameMath.hpp" + +namespace pl::render { + +/** + * @brief Installs the present-frame effect seam, once. Safe to call repeatedly. + * + *

Fail-closed: returns false when the engine's present hook is not live, or the GL path the + * effects need cannot be resolved. A false result leaves {@link IsPresentFrameEffectLive} false, so + * the launcher keeps both modules badged. + * + * @return true when the seam is installed and can act on presented frames. + */ +PL_EXPORT bool InstallPresentFrameEffect(); + +/** @brief True once {@link InstallPresentFrameEffect} has installed a working seam. */ +PL_EXPORT bool IsPresentFrameEffectLive(); + +/** + * @brief Stores the plan the effects apply on the next present. + * + * Safe to call from the launcher thread; the seam reads the plan on the render thread. Values are + * clamped so a stored option can never drive an out-of-range blend. + */ +PL_EXPORT void SetPresentFramePlan(const PresentFramePlan &plan); + +/** @brief The stored plan, for diagnostics. */ +PL_EXPORT PresentFramePlan GetPresentFramePlan(); + +/** + * @brief Runs the effects at a present boundary. + * + * Called from the {@code eglSwapBuffers} record stub, on the render thread, with the GL context + * current. A no-op when the seam is not live or no effect is enabled. It never blocks and never + * allocates on the steady path. + */ +PL_EXPORT void OnPresentBoundary(); + +} // namespace pl::render diff --git a/include/pl/render/PresentFrameMath.hpp b/include/pl/render/PresentFrameMath.hpp new file mode 100644 index 0000000..4473f3b --- /dev/null +++ b/include/pl/render/PresentFrameMath.hpp @@ -0,0 +1,117 @@ +#pragma once + +/** + * @file PresentFrameMath.hpp + * @brief The pure math behind the present-frame effect modules (frame generation, motion blur). + * + *

Kept apart from {@link PresentFrameEffect} on purpose: the effect file pulls in the GL probe + * and the logger (and therefore the Android shims), while these two functions are plain arithmetic + * with no dependencies. That is what lets the host test runner pin them directly, off-device, which + * is the same reason the other pure rules in this tree live in their own headers. + * + *

Honesty. Neither function measures or promises a frame rate. Frame generation returns + * how far the blended image sits toward the previous real frame; motion blur returns how much of the + * previous frame to mix in. Both are clamped to [0, 1]. Neither inserts a frame: the seam presents + * one frame per rendered frame. + */ + +#include + +namespace pl::render { + +/** @brief The plan the launcher sends from the module options. All values are clamped on receipt. */ +struct PresentFramePlan { + /** Whether frame generation is enabled (the module switch and its own toggle). */ + bool frameGenerationEnabled = false; + /** 0..1: how far the blended image is pushed toward the previous of the two real frames. */ + float interpolationStrength = 0.f; + /** + * 2..3: reserved. The seam blends the previous frame into the current one and presents a single + * frame, so it does not act on this ceiling yet; it is kept for a future true frame-insertion + * seam. Stored and clamped so the option round-trips, but nothing reads it to emit extra presents. + */ + int maxFramesPerRendered = 2; + /** Bias the blend to reuse less of the previous frame near edges. */ + bool reduceEdgeArtifacts = false; + + /** Whether motion blur is enabled (the module switch and its own toggle). */ + bool motionBlurEnabled = false; + /** 0..1: how much of the previous frame is blended in. */ + float blurStrength = 0.f; + /** 0..1: the most of the frame time the blend may take. */ + float blurBudget = 0.f; + /** Scale the blur by camera speed, so a still view stays sharp. */ + bool weightByViewSpeed = false; +}; + +/** + * @brief The blend alpha for motion blur this frame. + * + * The alpha is the fraction of the previous frame mixed into the current one. It is the configured + * strength, scaled by how fast the view is moving when {@code weightByViewSpeed} is set, and it is + * additionally capped by the budget: a frame interval longer than the budget leaves no room for the + * blend, so the alpha falls proportionally rather than the effect overrunning the frame. + * + * @param strength configured blur strength, 0..1 + * @param budget configured blend budget as a fraction of the frame time, 0..1 + * @param frameIntervalMs the last measured frame interval, milliseconds (<= 0 means unknown) + * @param viewSpeed01 camera speed normalised to 0..1 (0 when unknown) + * @param weightByViewSpeed whether to scale by {@p viewSpeed01} + * @return the blend alpha in [0, 1] + */ +inline float ComputeBlendAlpha(float strength, float budget, float frameIntervalMs, + float viewSpeed01, bool weightByViewSpeed) { + if (strength <= 0.f) return 0.f; + float alpha = strength; + if (weightByViewSpeed) { + const float speed = viewSpeed01 < 0.f ? 0.f : (viewSpeed01 > 1.f ? 1.f : viewSpeed01); + alpha *= speed; + } + if (budget > 0.f && frameIntervalMs > 0.f) { + const float budgetMs = budget * frameIntervalMs; + if (budgetMs <= 0.f) return 0.f; + if (frameIntervalMs > budgetMs) alpha *= budgetMs / frameIntervalMs; + } + if (alpha < 0.f) return 0.f; + if (alpha > 1.f) return 1.f; + return alpha; +} + +/** + * @brief The interpolation factor for frame generation this frame. + * + * The factor is where the blended frame sits between the previous real frame (0) and the current + * one (1): 0.5 is the midpoint. At full strength the factor is the midpoint; as strength falls it + * moves toward the current frame the game actually drew, which is the low-artefact end. With + * {@code reduceEdgeArtifacts} the factor is pulled further toward that real frame. + * + *

Honesty. This factor is a position between two real frames, not a promise that a third + * frame is presented. The seam uses {@link FrameGenerationWeight} (its complement) as the blend + * weight: it mixes the previous frame into the current one, it does not insert a frame. + * + * @param strength configured interpolation strength, 0..1 + * @param reduceEdgeArtifacts bias toward the current real frame near edges + * @return the interpolation factor in [0, 1] + */ +inline float ComputeInterpolationFactor(float strength, bool reduceEdgeArtifacts) { + const float s = strength < 0.f ? 0.f : (strength > 1.f ? 1.f : strength); + float factor = 0.5f + 0.5f * (1.f - s); + if (reduceEdgeArtifacts) { + factor = factor * 0.85f + 0.15f; + } + if (factor < 0.f) return 0.f; + if (factor > 1.f) return 1.f; + return factor; +} + +/** + * @brief The blend weight frame generation contributes: its distance from the current real frame. + * + * This is what the seam actually blends. It is derived from {@link ComputeInterpolationFactor} so + * the two never drift, and it is the value the seam compares against the motion-blur alpha. + */ +inline float FrameGenerationWeight(float strength, bool reduceEdgeArtifacts) { + return 1.f - ComputeInterpolationFactor(strength, reduceEdgeArtifacts); +} + +} // namespace pl::render diff --git a/src/pl/render/GlesProbe.cpp b/src/pl/render/GlesProbe.cpp index e63cb11..fb7f4c7 100644 --- a/src/pl/render/GlesProbe.cpp +++ b/src/pl/render/GlesProbe.cpp @@ -91,6 +91,7 @@ GlesFunctions ResolveGles() { LOAD("glGenVertexArrays", GenVertexArrays); LOAD("glBindVertexArray", BindVertexArray); LOAD("glEnableVertexAttribArray", EnableVertexAttribArray); + LOAD("glDisableVertexAttribArray", DisableVertexAttribArray); LOAD("glVertexAttribPointer", VertexAttribPointer); LOAD("glUseProgram", UseProgram); LOAD("glGetUniformLocation", GetUniformLocation); @@ -98,6 +99,8 @@ GlesFunctions ResolveGles() { LOAD("glUniform4f", Uniform4f); LOAD("glUniform3f", Uniform3f); LOAD("glUniform1i", Uniform1i); + LOAD("glUniform1f", Uniform1f); + LOAD("glUniform2f", Uniform2f); LOAD("glActiveTexture", ActiveTexture); LOAD("glBindTexture", BindTexture); LOAD("glGenTextures", GenTextures); @@ -107,9 +110,26 @@ GlesFunctions ResolveGles() { LOAD("glDrawElements", DrawElements); LOAD("glDrawArrays", DrawArrays); LOAD("glBlendFunc", BlendFunc); + LOAD("glBlendFuncSeparate", BlendFuncSeparate); LOAD("glDeleteBuffers", DeleteBuffers); LOAD("glDeleteVertexArrays", DeleteVertexArrays); LOAD("glDeleteTextures", DeleteTextures); + LOAD("glCreateShader", CreateShader); + LOAD("glShaderSource", ShaderSource); + LOAD("glCompileShader", CompileShader); + LOAD("glGetShaderiv", GetShaderiv); + LOAD("glCreateProgram", CreateProgram); + LOAD("glAttachShader", AttachShader); + LOAD("glLinkProgram", LinkProgram); + LOAD("glGetProgramiv", GetProgramiv); + LOAD("glGetAttribLocation", GetAttribLocation); + LOAD("glGenFramebuffers", GenFramebuffers); + LOAD("glBindFramebuffer", BindFramebuffer); + LOAD("glFramebufferTexture2D", FramebufferTexture2D); + LOAD("glCopyTexImage2D", CopyTexImage2D); + LOAD("glTexParameterf", TexParameterf); + LOAD("glClearColor", ClearColor); + LOAD("glClear", Clear); #undef LOAD if (g_gles.usable()) { diff --git a/src/pl/render/PresentFrameEffect.cpp b/src/pl/render/PresentFrameEffect.cpp new file mode 100644 index 0000000..c7c53ad --- /dev/null +++ b/src/pl/render/PresentFrameEffect.cpp @@ -0,0 +1,475 @@ +/** + * @file PresentFrameEffect.cpp + * @brief The present-frame effect seam (frame generation + motion blur) on the engine's present. + * + * The seam is the {@code eglSwapBuffers} detour, run from the swap record stub just before the real + * swap. At that moment the back buffer holds the frame the engine just drew; this file keeps a copy + * of the previous presented frame and, when a module is enabled, composites it over the back buffer, + * then rotates the two captures. The blend weight is the frame-generation interpolation weight or + * the motion-blur strength, whichever is larger, so the effect the player enabled is not cancelled + * by the other. + * + *

Honesty. This does not raise the render rate. It blends the previous frame into the + * current one, so motion is smoother and the image is softer. The blend weight comes from + * {@link pl::render::ComputeBlendAlpha} / {@link pl::render::ComputeInterpolationFactor}, which are + * pure and host-tested. + * + *

Fail-closed. {@link InstallPresentFrameEffect} returns false when the present hook is + * not live, no EGL context is current, or the GL table cannot resolve the entry points the + * composite needs. Nothing is drawn in that case, and the launcher keeps both modules badged. + * + *

State preservation. The composite runs inside the game's own present path, so it saves + * and restores every piece of GL state it touches (program, VAO, array buffer, active texture, + * texture binding, blend, depth test, cull, viewport). It never leaves the pipeline in a state the + * game did not set. + */ + +#include "pl/render/PresentFrameEffect.hpp" + +#include +#include + +#include "pl/Logger.hpp" +#include "pl/render/GlesProbe.hpp" +#include "pl/runtime/SwapFrameBoundary.h" + +namespace pl::render { +namespace { + +// GL enums used below, declared locally so this file needs no GLES header (the host test runner has +// none). Values are from the GLES 2.0/3.0 specs. +constexpr std::uint32_t GL_TEXTURE_2D = 0x0DE1; +constexpr std::uint32_t GL_TEXTURE0 = 0x84C0; +constexpr std::uint32_t GL_RGBA = 0x1908; +constexpr std::uint32_t GL_UNSIGNED_BYTE = 0x1401; +constexpr std::uint32_t GL_FLOAT = 0x1406; +constexpr std::uint32_t GL_TEXTURE_MIN_FILTER = 0x2801; +constexpr std::uint32_t GL_TEXTURE_MAG_FILTER = 0x2800; +constexpr std::uint32_t GL_TEXTURE_WRAP_S = 0x2802; +constexpr std::uint32_t GL_TEXTURE_WRAP_T = 0x2803; +constexpr std::uint32_t GL_LINEAR = 0x2601; +constexpr std::uint32_t GL_CLAMP_TO_EDGE = 0x812F; +constexpr std::uint32_t GL_BLEND = 0x0BE2; +constexpr std::uint32_t GL_DEPTH_TEST = 0x0B71; +constexpr std::uint32_t GL_CULL_FACE = 0x0B44; +constexpr std::uint32_t GL_SRC_ALPHA = 0x0302; +constexpr std::uint32_t GL_ONE_MINUS_SRC_ALPHA = 0x0303; +constexpr std::uint32_t GL_BLEND_SRC_ALPHA = 0x80CB; +constexpr std::uint32_t GL_BLEND_DST_ALPHA = 0x80CA; +constexpr std::uint32_t GL_BLEND_SRC_RGB = 0x80C9; +constexpr std::uint32_t GL_BLEND_DST_RGB = 0x80C8; +constexpr std::uint32_t GL_CURRENT_PROGRAM = 0x8B8D; +constexpr std::uint32_t GL_ACTIVE_TEXTURE = 0x84E0; +constexpr std::uint32_t GL_TEXTURE_BINDING_2D = 0x8069; +constexpr std::uint32_t GL_VERTEX_ARRAY_BINDING = 0x85B5; +constexpr std::uint32_t GL_ARRAY_BUFFER = 0x8892; +constexpr std::uint32_t GL_ARRAY_BUFFER_BINDING = 0x8894; +constexpr std::uint32_t GL_STATIC_DRAW = 0x88E4; +constexpr std::uint32_t GL_VIEWPORT = 0x0BA2; +constexpr std::uint32_t GL_TRIANGLES = 0x0004; +constexpr std::uint32_t GL_FRAGMENT_SHADER = 0x8B30; +constexpr std::uint32_t GL_VERTEX_SHADER = 0x8B31; +constexpr std::uint32_t GL_COMPILE_STATUS = 0x8B81; +constexpr std::uint32_t GL_LINK_STATUS = 0x8B82; + +// GLSL ES 1.00, so it compiles on both ES2 and ES3-default contexts. A fullscreen quad is uploaded +// once; the fragment shader samples the previous frame at the current fragment's UV and outputs it +// with the blend alpha, and fixed-function blending mixes it into the back buffer. +const char *kVertexShader = + "attribute vec2 a_pos;\n" + "void main() {\n" + " gl_Position = vec4(a_pos, 0.0, 1.0);\n" + "}\n"; +const char *kFragmentShader = + "precision mediump float;\n" + "uniform sampler2D u_prev;\n" + "uniform vec2 u_resolution;\n" + "uniform float u_alpha;\n" + "void main() {\n" + " vec4 c = texture2D(u_prev, gl_FragCoord.xy / u_resolution);\n" + " gl_FragColor = vec4(c.rgb, u_alpha);\n" + "}\n"; + +constexpr float kQuad[] = { + -1.f, -1.f, 1.f, -1.f, -1.f, 1.f, + -1.f, 1.f, 1.f, -1.f, 1.f, 1.f, +}; + +std::atomic_bool g_installed{false}; +std::atomic_bool g_live{false}; + +// The plan is written by the launcher thread and read on the render thread. It is small and plain, +// so an atomic flag plus a plain copy under the flag is enough; the read is per present. +std::atomic_bool g_planDirty{false}; +PresentFramePlan g_plan; +PresentFramePlan g_planSnapshot; + +// The two capture textures (double buffered) and their size, created lazily on the render thread. +std::uint32_t g_captureTex[2] = {0, 0}; +std::uint32_t g_prevIndex = 0; +int g_captureWidth = 0; +int g_captureHeight = 0; +bool g_captureValid = false; + +std::uint32_t g_program = 0; +std::uint32_t g_vao = 0; +std::uint32_t g_vbo = 0; +int g_alphaUniform = -1; +int g_prevUniform = -1; +int g_resolutionUniform = -1; +int g_posAttrib = -1; + +// The size of the last present, so the launcher can report it. +std::atomic g_lastWidth{0}; +std::atomic g_lastHeight{0}; + +using GlGetIntegervFn = void (*)(std::uint32_t, std::int32_t *); +using GlGetBooleanvFn = void (*)(std::uint32_t, std::uint8_t *); + +template +Fn As(const void *p) { + return reinterpret_cast(const_cast(p)); +} + +// A copy of the GL state the composite changes, so it can be restored afterwards. +struct SavedState { + std::int32_t program = 0; + std::int32_t activeTexture = 0; + std::int32_t texture2d = 0; + std::int32_t vao = 0; + std::int32_t arrayBuffer = 0; + std::int32_t viewport[4] = {0, 0, 0, 0}; + std::uint8_t blend = 0; + std::uint8_t depthTest = 0; + std::uint8_t cullFace = 0; + std::int32_t blendSrcRgb = 0; + std::int32_t blendDstRgb = 0; + std::int32_t blendSrcAlpha = 0; + std::int32_t blendDstAlpha = 0; +}; + +void SaveState(const GlesFunctions &g, SavedState &s) { + auto geti = As(g.GetIntegerv); + auto getb = As(g.GetBooleanv); + geti(GL_CURRENT_PROGRAM, &s.program); + geti(GL_ACTIVE_TEXTURE, &s.activeTexture); + geti(GL_TEXTURE_BINDING_2D, &s.texture2d); + geti(GL_VERTEX_ARRAY_BINDING, &s.vao); + geti(GL_ARRAY_BUFFER_BINDING, &s.arrayBuffer); + geti(GL_VIEWPORT, s.viewport); + geti(GL_BLEND_SRC_RGB, &s.blendSrcRgb); + geti(GL_BLEND_DST_RGB, &s.blendDstRgb); + geti(GL_BLEND_SRC_ALPHA, &s.blendSrcAlpha); + geti(GL_BLEND_DST_ALPHA, &s.blendDstAlpha); + getb(GL_BLEND, &s.blend); + getb(GL_DEPTH_TEST, &s.depthTest); + getb(GL_CULL_FACE, &s.cullFace); +} + +void RestoreState(const GlesFunctions &g, const SavedState &s) { + using GlUseProgramFn = void (*)(std::uint32_t); + using GlBindTextureFn = void (*)(std::uint32_t, std::uint32_t); + using GlActiveTextureFn = void (*)(std::uint32_t); + using GlBindVaoFn = void (*)(std::uint32_t); + using GlBindBufferFn = void (*)(std::uint32_t, std::uint32_t); + using GlViewportFn = void (*)(std::int32_t, std::int32_t, std::int32_t, std::int32_t); + using GlBlendFuncSeparateFn = void (*)(std::uint32_t, std::uint32_t, std::uint32_t, std::uint32_t); + using GlEnableFn = void (*)(std::uint32_t); + using GlDisableFn = void (*)(std::uint32_t); + + As(g.UseProgram)(static_cast(s.program)); + As(g.ActiveTexture)(static_cast(s.activeTexture)); + As(g.BindTexture)(GL_TEXTURE_2D, static_cast(s.texture2d)); + As(g.BindVertexArray)(static_cast(s.vao)); + if (g.BindBuffer != nullptr) { + As(g.BindBuffer)(GL_ARRAY_BUFFER, static_cast(s.arrayBuffer)); + } + As(g.Viewport)(s.viewport[0], s.viewport[1], s.viewport[2], s.viewport[3]); + if (g.BlendFuncSeparate != nullptr) { + As(g.BlendFuncSeparate)( + static_cast(s.blendSrcRgb), static_cast(s.blendDstRgb), + static_cast(s.blendSrcAlpha), static_cast(s.blendDstAlpha)); + } + auto enable = As(g.Enable); + auto disable = As(g.Disable); + if (s.blend) enable(GL_BLEND); else disable(GL_BLEND); + if (s.depthTest) enable(GL_DEPTH_TEST); else disable(GL_DEPTH_TEST); + if (s.cullFace) enable(GL_CULL_FACE); else disable(GL_CULL_FACE); +} + +bool CompileShader(const GlesFunctions &g, std::uint32_t type, const char *source, + std::uint32_t &out) { + using CreateShaderFn = std::uint32_t (*)(std::uint32_t); + using ShaderSourceFn = void (*)(std::uint32_t, std::int32_t, const char *const *, const std::int32_t *); + using CompileShaderFn = void (*)(std::uint32_t); + using GetShaderivFn = void (*)(std::uint32_t, std::uint32_t, std::int32_t *); + if (g.CreateShader == nullptr || g.ShaderSource == nullptr || g.CompileShader == nullptr || + g.GetShaderiv == nullptr) { + return false; + } + out = As(g.CreateShader)(type); + if (out == 0) return false; + As(g.ShaderSource)(out, 1, &source, nullptr); + As(g.CompileShader)(out); + std::int32_t status = 0; + As(g.GetShaderiv)(out, GL_COMPILE_STATUS, &status); + return status != 0; +} + +bool BuildProgram(const GlesFunctions &g) { + using CreateProgramFn = std::uint32_t (*)(); + using AttachShaderFn = void (*)(std::uint32_t, std::uint32_t); + using LinkProgramFn = void (*)(std::uint32_t); + using GetProgramivFn = void (*)(std::uint32_t, std::uint32_t, std::int32_t *); + using GetUniformLocationFn = std::int32_t (*)(std::uint32_t, const char *); + using GetAttribLocationFn = std::int32_t (*)(std::uint32_t, const char *); + using GenBuffersFn = void (*)(std::int32_t, std::uint32_t *); + using BindBufferFn = void (*)(std::uint32_t, std::uint32_t); + using BufferDataFn = void (*)(std::uint32_t, std::intptr_t, const void *, std::uint32_t); + if (g.CreateProgram == nullptr || g.AttachShader == nullptr || g.LinkProgram == nullptr || + g.GetProgramiv == nullptr || g.GetUniformLocation == nullptr || + g.GetAttribLocation == nullptr || g.GenBuffers == nullptr || g.BindBuffer == nullptr || + g.BufferData == nullptr) { + return false; + } + std::uint32_t vs = 0; + std::uint32_t fs = 0; + if (!CompileShader(g, GL_VERTEX_SHADER, kVertexShader, vs)) return false; + if (!CompileShader(g, GL_FRAGMENT_SHADER, kFragmentShader, fs)) return false; + g_program = As(g.CreateProgram)(); + if (g_program == 0) return false; + As(g.AttachShader)(g_program, vs); + As(g.AttachShader)(g_program, fs); + As(g.LinkProgram)(g_program); + std::int32_t status = 0; + As(g.GetProgramiv)(g_program, GL_LINK_STATUS, &status); + if (status == 0) { + g_program = 0; + return false; + } + g_prevUniform = As(g.GetUniformLocation)(g_program, "u_prev"); + g_alphaUniform = As(g.GetUniformLocation)(g_program, "u_alpha"); + g_resolutionUniform = As(g.GetUniformLocation)(g_program, "u_resolution"); + g_posAttrib = As(g.GetAttribLocation)(g_program, "a_pos"); + if (g_prevUniform < 0 || g_alphaUniform < 0 || g_resolutionUniform < 0 || g_posAttrib < 0) { + g_program = 0; + return false; + } + As(g.GenBuffers)(1, &g_vbo); + if (g_vbo == 0) { + g_program = 0; + return false; + } + As(g.BindBuffer)(GL_ARRAY_BUFFER, g_vbo); + As(g.BufferData)(GL_ARRAY_BUFFER, sizeof(kQuad), kQuad, GL_STATIC_DRAW); + return true; +} + +bool EnsureCapture(const GlesFunctions &g, int width, int height) { + using GenTexturesFn = void (*)(std::int32_t, std::uint32_t *); + using BindTextureFn = void (*)(std::uint32_t, std::uint32_t); + using TexParameteriFn = void (*)(std::uint32_t, std::uint32_t, std::int32_t); + using TexImage2DFn = void (*)(std::uint32_t, std::int32_t, std::int32_t, std::int32_t, + std::int32_t, std::int32_t, std::uint32_t, std::uint32_t, + const void *); + if (width <= 0 || height <= 0) return false; + if (g_captureTex[0] != 0 && g_captureWidth == width && g_captureHeight == height) { + return true; + } + if (g.GenTextures == nullptr || g.BindTexture == nullptr || g.TexParameteri == nullptr || + g.TexImage2D == nullptr) { + return false; + } + if (g_captureTex[0] == 0) { + As(g.GenTextures)(2, g_captureTex); + } + for (int i = 0; i < 2; ++i) { + As(g.BindTexture)(GL_TEXTURE_2D, g_captureTex[i]); + As(g.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, + static_cast(GL_LINEAR)); + As(g.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, + static_cast(GL_LINEAR)); + As(g.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, + static_cast(GL_CLAMP_TO_EDGE)); + As(g.TexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, + static_cast(GL_CLAMP_TO_EDGE)); + As(g.TexImage2D)(GL_TEXTURE_2D, 0, static_cast(GL_RGBA), width, + height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + } + g_captureWidth = width; + g_captureHeight = height; + g_captureValid = false; // the fresh textures hold no real frame yet + return true; +} + +void RefreshPlan() { + if (g_planDirty.exchange(false, std::memory_order_acq_rel)) { + g_planSnapshot = g_plan; + } +} + +} // namespace + +bool InstallPresentFrameEffect() { + if (g_installed.exchange(true, std::memory_order_relaxed)) { + return g_live.load(std::memory_order_relaxed); + } + if (!pl::runtime::IsSwapFrameBoundaryLive()) { + sweetLoaderLogger.info( + "Present-frame effect: no present hook on this build; frame generation and motion blur stay " + "unavailable"); + return false; + } + if (!IsEglContextCurrent()) { + sweetLoaderLogger.info("Present-frame effect: no EGL context yet; will retry when one is current"); + // Not an error: the context may not be current at install time. Leave the install flag false so a + // later call retries; report not-live for now. + g_installed.store(false, std::memory_order_relaxed); + return false; + } + const GlesFunctions gles = ResolveGles(); + if (!gles.usable() || !BuildProgram(gles)) { + sweetLoaderLogger.warn( + "Present-frame effect: GL path unavailable (no shader program); the effects stay off"); + return false; + } + g_live.store(true, std::memory_order_relaxed); + sweetLoaderLogger.info("Present-frame effect: seam live"); + return true; +} + +bool IsPresentFrameEffectLive() { return g_live.load(std::memory_order_relaxed); } + +void SetPresentFramePlan(const PresentFramePlan &plan) { + PresentFramePlan clamped = plan; + if (clamped.interpolationStrength < 0.f) clamped.interpolationStrength = 0.f; + if (clamped.interpolationStrength > 1.f) clamped.interpolationStrength = 1.f; + if (clamped.maxFramesPerRendered < 2) clamped.maxFramesPerRendered = 2; + if (clamped.maxFramesPerRendered > 3) clamped.maxFramesPerRendered = 3; + if (clamped.blurStrength < 0.f) clamped.blurStrength = 0.f; + if (clamped.blurStrength > 1.f) clamped.blurStrength = 1.f; + if (clamped.blurBudget < 0.f) clamped.blurBudget = 0.f; + if (clamped.blurBudget > 1.f) clamped.blurBudget = 1.f; + g_plan = clamped; + g_planDirty.store(true, std::memory_order_release); +} + +PresentFramePlan GetPresentFramePlan() { + RefreshPlan(); + return g_planSnapshot; +} + +void OnPresentBoundary() { + // The present boundary is the one place an EGL context is guaranteed to be current, so both seams + // that need one install lazily here rather than at load time (when no context exists yet). Until + // they install, the modules stay badged; a build with no present hook never reaches this function. + // + if (!g_live.load(std::memory_order_relaxed)) { + InstallPresentFrameEffect(); + if (!g_live.load(std::memory_order_relaxed)) return; + } + RefreshPlan(); + const PresentFramePlan plan = g_planSnapshot; + if (!plan.frameGenerationEnabled && !plan.motionBlurEnabled) return; + + const GlesFunctions g = ResolveGles(); + if (!g.usable() || g_program == 0 || g.DrawArrays == nullptr) return; + + std::int32_t viewport[4] = {0, 0, 0, 0}; + As(g.GetIntegerv)(GL_VIEWPORT, viewport); + const int width = viewport[2]; + const int height = viewport[3]; + g_lastWidth.store(width, std::memory_order_relaxed); + g_lastHeight.store(height, std::memory_order_relaxed); + if (!EnsureCapture(g, width, height)) return; + + // Blend weight: frame generation uses its interpolation weight; motion blur uses the strength + // (scaled by view speed when asked). When both are on the larger weight wins, so the effect the + // player enabled is not silently cancelled by the other. + float alpha = 0.f; + if (plan.motionBlurEnabled) { + alpha = ComputeBlendAlpha(plan.blurStrength, plan.blurBudget, 16.f, 1.f, + plan.weightByViewSpeed); + } + if (plan.frameGenerationEnabled) { + const float weight = FrameGenerationWeight(plan.interpolationStrength, + plan.reduceEdgeArtifacts); + if (weight > alpha) alpha = weight; + } + if (alpha <= 0.001f) return; + + const std::uint32_t current = static_cast(g_prevIndex); + const std::uint32_t previous = static_cast(g_prevIndex ^ 1); + + SavedState saved; + SaveState(g, saved); + + using BindTextureFn = void (*)(std::uint32_t, std::uint32_t); + using ActiveTextureFn = void (*)(std::uint32_t); + using CopyTexImage2DFn = void (*)(std::uint32_t, std::int32_t, std::uint32_t, std::int32_t, + std::int32_t, std::int32_t, std::int32_t, std::int32_t); + using GenVertexArraysFn = void (*)(std::int32_t, std::uint32_t *); + using BindVertexArrayFn = void (*)(std::uint32_t); + using BindBufferFn = void (*)(std::uint32_t, std::uint32_t); + using UseProgramFn = void (*)(std::uint32_t); + using EnableVertexAttribArrayFn = void (*)(std::uint32_t); + using DisableVertexAttribArrayFn = void (*)(std::uint32_t); + using VertexAttribPointerFn = void (*)(std::uint32_t, std::int32_t, std::uint32_t, std::uint8_t, + std::int32_t, const void *); + using Uniform1iFn = void (*)(std::int32_t, std::int32_t); + using Uniform1fFn = void (*)(std::int32_t, float); + using Uniform2fFn = void (*)(std::int32_t, float, float); + using DrawArraysFn = void (*)(std::uint32_t, std::int32_t, std::int32_t); + using EnableFn = void (*)(std::uint32_t); + using DisableFn = void (*)(std::uint32_t); + using BlendFuncSeparateFn = void (*)(std::uint32_t, std::uint32_t, std::uint32_t, std::uint32_t); + + if (g.GenVertexArrays == nullptr || g.BindVertexArray == nullptr || g.UseProgram == nullptr || + g.Uniform1i == nullptr || g.Uniform2f == nullptr || g.DrawArrays == nullptr || + g.CopyTexImage2D == nullptr || g.EnableVertexAttribArray == nullptr || + g.DisableVertexAttribArray == nullptr || g.VertexAttribPointer == nullptr || + g.BindBuffer == nullptr) { + RestoreState(g, saved); + return; + } + if (g_vao == 0) { + As(g.GenVertexArrays)(1, &g_vao); + } + As(g.BindVertexArray)(g_vao); + As(g.BindBuffer)(GL_ARRAY_BUFFER, g_vbo); + As(g.EnableVertexAttribArray)(static_cast(g_posAttrib)); + As(g.VertexAttribPointer)(static_cast(g_posAttrib), 2, + GL_FLOAT, 0, 0, nullptr); + + // Capture the frame the game just drew into the current texture (active unit 0). + As(g.ActiveTexture)(GL_TEXTURE0); + As(g.BindTexture)(GL_TEXTURE_2D, current); + As(g.CopyTexImage2D)(GL_TEXTURE_2D, 0, GL_RGBA, 0, 0, width, height, 0); + + if (g_captureValid) { + // Composite: draw the previous frame over the back buffer with the blend alpha. Fixed-function + // blending mixes it with the current frame: result = current*(1-alpha) + previous*alpha. + As(g.Disable)(GL_DEPTH_TEST); + As(g.Disable)(GL_CULL_FACE); + As(g.Enable)(GL_BLEND); + As(g.BlendFuncSeparate)(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_SRC_ALPHA, + GL_ONE_MINUS_SRC_ALPHA); + As(g.UseProgram)(g_program); + As(g.BindTexture)(GL_TEXTURE_2D, previous); + As(g.Uniform1i)(g_prevUniform, 0); + As(g.Uniform1f)(g_alphaUniform, alpha); + As(g.Uniform2f)(g_resolutionUniform, static_cast(width), + static_cast(height)); + As(g.DrawArrays)(GL_TRIANGLES, 0, 6); + } + + As(g.DisableVertexAttribArray)( + static_cast(g_posAttrib)); + RestoreState(g, saved); + + // Rotate: this frame's capture becomes the previous frame for the next present. + g_prevIndex ^= 1; + g_captureValid = true; +} + +} // namespace pl::render diff --git a/src/pl/runtime/GamePlayerRender.cpp b/src/pl/runtime/GamePlayerRender.cpp index 2308d22..fd0e5f4 100644 --- a/src/pl/runtime/GamePlayerRender.cpp +++ b/src/pl/runtime/GamePlayerRender.cpp @@ -54,6 +54,11 @@ RenderFn g_origRender = nullptr; std::atomic_bool g_hookInstalled{false}; std::atomic g_renderTick{0}; // increments once per frame (first call) +// Whether g_renderTick is the exact present boundary (the eglSwapBuffers hook counted this +// frame) or an inference from render-call timestamps. The FPS reader needs to know: an +// inferred tick can collapse or split frames when the visible-model count changes, so the +// rate derived from it is approximate. The present hook makes it exact. +std::atomic_bool g_renderTickExact{false}; std::atomic g_frameCalls{0}; // calls seen in the current frame std::atomic g_totalCalls{0}; // lifetime call count std::atomic g_lastRenderMs{0}; @@ -145,9 +150,11 @@ void HookRender(void *a, void *b, void *c, void *d, void *e, void *f, void *g, v frameStarted = swapPresents != g_renderTick.load(std::memory_order_relaxed); g_frameCalls.store(1, std::memory_order_relaxed); g_renderTick.store(static_cast(swapPresents), std::memory_order_relaxed); + g_renderTickExact.store(true, std::memory_order_relaxed); g_frameClock.onRenderCall(nowUs); // keep the fallback clock warm for a later detach } else if (g_frameClock.onRenderCall(nowUs, pl::render::FrameTickClock::kDefaultMinIntervalUs, &frameStarted)) { + g_renderTickExact.store(false, std::memory_order_relaxed); g_frameCalls.store(1, std::memory_order_relaxed); g_renderTick.store(g_frameClock.tick(), std::memory_order_relaxed); } else { @@ -386,6 +393,8 @@ bool IsPlayerRenderHookLive() { return g_lastRenderMs.load(std::memory_order_relaxed) != 0; } +bool IsRenderTickExact() { return g_renderTickExact.load(std::memory_order_relaxed); } + bool ReadPlayerRenderStats(std::uint32_t out[4]) { const long long last = g_lastRenderMs.load(std::memory_order_relaxed); if (last == 0) { diff --git a/src/pl/runtime/GamePlayerRender.h b/src/pl/runtime/GamePlayerRender.h index 6fe0199..af079b8 100644 --- a/src/pl/runtime/GamePlayerRender.h +++ b/src/pl/runtime/GamePlayerRender.h @@ -51,6 +51,17 @@ bool IsPlayerRenderHookLive(); */ bool IsPlayerRenderHookInstalled(); +/** + * @brief True when the render tick is the exact present boundary rather than an inference. + * + *

The tick is exact once the engine's present hook (the {@code eglSwapBuffers} detour) is live and + * counting, because then it is the real frame count. Without that hook the tick is inferred from the + * render-entry timestamps, which can collapse or split frames when the visible-model count changes, so + * a frame rate derived from it is approximate and must be labelled as such rather than shown as the + * game's exact frame rate. + */ +bool IsRenderTickExact(); + /** * @brief The resolved {@code LivePlayerRenderer::render} entry address, or 0 if unresolved. * diff --git a/src/pl/runtime/InputJni.cpp b/src/pl/runtime/InputJni.cpp index f76bdba..1b1d20f 100644 --- a/src/pl/runtime/InputJni.cpp +++ b/src/pl/runtime/InputJni.cpp @@ -12,6 +12,8 @@ #include "pl/memory/Signature.hpp" #include "pl/runtime/InputBridge.h" #include "pl/runtime/JavaRuntime.h" +#include "pl/runtime/SwapFrameBoundary.h" +#include "pl/render/PresentFrameEffect.hpp" namespace { @@ -253,6 +255,44 @@ Java_org_chimeramc_client_preloader_PreloaderInput_nativeIsPlayerRenderHookLive( return pl::runtime::IsPlayerRenderHookLive() ? JNI_TRUE : JNI_FALSE; } +JNIEXPORT jboolean JNICALL +Java_org_chimeramc_client_preloader_PreloaderInput_nativeIsPresentHookLive( + JNIEnv *env, jclass clazz) { + (void)env; + (void)clazz; + // The engine's present/frame-boundary hook. Frame generation and motion blur act on the presented + // frame, so this is their availability probe: a Vulkan-only build with no libEGL present call + // reports false and the modules stay badged rather than toggling to no effect. + return pl::runtime::IsSwapFrameBoundaryLive() ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT void JNICALL +Java_org_chimeramc_client_preloader_PreloaderInput_nativeSetPresentFramePlan( + JNIEnv *env, jclass clazz, jboolean frameGenerationEnabled, jfloat interpolationStrength, + jint maxFramesPerRendered, jboolean reduceEdgeArtifacts, jboolean motionBlurEnabled, + jfloat blurStrength, jfloat blurBudget, jboolean weightByViewSpeed) { + (void)env; + (void)clazz; + pl::render::PresentFramePlan plan; + plan.frameGenerationEnabled = frameGenerationEnabled == JNI_TRUE; + plan.interpolationStrength = static_cast(interpolationStrength); + plan.maxFramesPerRendered = static_cast(maxFramesPerRendered); + plan.reduceEdgeArtifacts = reduceEdgeArtifacts == JNI_TRUE; + plan.motionBlurEnabled = motionBlurEnabled == JNI_TRUE; + plan.blurStrength = static_cast(blurStrength); + plan.blurBudget = static_cast(blurBudget); + plan.weightByViewSpeed = weightByViewSpeed == JNI_TRUE; + pl::render::SetPresentFramePlan(plan); +} + +JNIEXPORT jboolean JNICALL +Java_org_chimeramc_client_preloader_PreloaderInput_nativeIsPresentFrameEffectLive( + JNIEnv *env, jclass clazz) { + (void)env; + (void)clazz; + return pl::render::IsPresentFrameEffectLive() ? JNI_TRUE : JNI_FALSE; +} + JNIEXPORT jintArray JNICALL Java_org_chimeramc_client_preloader_PreloaderInput_nativeReadPlayerRenderStats( JNIEnv *env, jclass clazz) { @@ -261,11 +301,16 @@ Java_org_chimeramc_client_preloader_PreloaderInput_nativeReadPlayerRenderStats( if (!pl::runtime::ReadPlayerRenderStats(stats)) { return nullptr; } - jintArray result = env->NewIntArray(4); + // Five elements: {renderTick, callsThisFrame, totalCalls, msSinceLastRender, tickExact}. The fifth + // says whether the tick is the exact present boundary (1) or an inference (0), so the launcher can + // label a rate derived from an inferred tick as approximate instead of presenting it as the game's. + const bool exact = pl::runtime::IsRenderTickExact(); + jintArray result = env->NewIntArray(5); if (result != nullptr) { - jint values[4] = {static_cast(stats[0]), static_cast(stats[1]), - static_cast(stats[2]), static_cast(stats[3])}; - env->SetIntArrayRegion(result, 0, 4, values); + jint values[5] = {static_cast(stats[0]), static_cast(stats[1]), + static_cast(stats[2]), static_cast(stats[3]), + exact ? 1 : 0}; + env->SetIntArrayRegion(result, 0, 5, values); } return result; } diff --git a/src/pl/runtime/RegisterAwareHook.cpp b/src/pl/runtime/RegisterAwareHook.cpp index 3988766..932ead2 100644 --- a/src/pl/runtime/RegisterAwareHook.cpp +++ b/src/pl/runtime/RegisterAwareHook.cpp @@ -4,6 +4,8 @@ #include #include +#include "pl/render/PresentFrameEffect.hpp" + #if !defined(__aarch64__) #error "RegisterAwareHook is only implemented for AArch64; the sweetloader builds arm64-v8a only." #endif @@ -69,6 +71,10 @@ void *pl_runtime_passthrough_swapBuffers_record() { static_cast(ts.tv_sec) * 1000000LL + ts.tv_nsec / 1000LL, std::memory_order_relaxed); pl_runtime_swap_buffers_count.fetch_add(1, std::memory_order_relaxed); + // Run the present-frame effects (frame generation / motion blur) here, before the real swap: the + // back buffer still holds the frame the engine just drew, which is what they blend. This is a + // no-op unless a module is enabled and the seam installed, and it fails closed inside. + pl::render::OnPresentBoundary(); return reinterpret_cast(pl_runtime_passthrough_ctx_swapBuffers.original); } diff --git a/tests/cosmetics/PresentFrameMathTest.cpp b/tests/cosmetics/PresentFrameMathTest.cpp new file mode 100644 index 0000000..c4a8a00 --- /dev/null +++ b/tests/cosmetics/PresentFrameMathTest.cpp @@ -0,0 +1,102 @@ +/** + * @file PresentFrameMathTest.cpp + * @brief Pins the present-frame effect math (frame generation + motion blur). + * + * These two functions are the whole honest content of the two modules: they decide how much of the + * previous frame is mixed in and where the interpolated frame sits. They are pure arithmetic, so + * they are verified here rather than on a device, exactly like the other pure rules in this tree. + * + * The contract under test: + * - neither function can return a value outside [0, 1]; + * - a zero strength yields a zero blend (the module off is a no-op); + * - the budget caps the blend rather than being ignored; + * - a full-strength frame generation sits at the midpoint (weight 0.5). + */ + +#include +#include + +#include "pl/render/PresentFrameMath.hpp" + +using pl::render::ComputeBlendAlpha; +using pl::render::ComputeInterpolationFactor; +using pl::render::FrameGenerationWeight; + +namespace { + +int g_failures = 0; + +void ExpectNear(const char *what, float actual, float expected, float tol = 1e-5f) { + if (actual < expected - tol || actual > expected + tol) { + std::printf("FAIL %s: expected %.6f, got %.6f\n", what, expected, actual); + ++g_failures; + } +} + +void ExpectInUnitRange(const char *what, float value) { + if (value < 0.f || value > 1.f) { + std::printf("FAIL %s: out of [0,1]: %.6f\n", what, value); + ++g_failures; + } +} + +void BlendAlphaRules() { + // Zero strength is a no-op regardless of the other inputs. + ExpectNear("zero strength", ComputeBlendAlpha(0.f, 0.6f, 16.f, 1.f, false), 0.f); + + // Strength passes through when the budget is generous and speed weighting is off. + ExpectNear("plain strength", ComputeBlendAlpha(0.35f, 1.0f, 16.f, 1.f, false), 0.35f); + + // Speed weighting scales the alpha by the normalised speed. + ExpectNear("speed weighted", ComputeBlendAlpha(0.8f, 1.0f, 16.f, 0.5f, true), 0.4f); + // A speed above 1 is clamped, so the alpha never exceeds the strength. + ExpectNear("speed clamped", ComputeBlendAlpha(0.8f, 1.0f, 16.f, 5.f, true), 0.8f); + + // A tight budget scales the alpha down proportionally: 6ms budget of a 12ms frame is half. + ExpectNear("budget halves", ComputeBlendAlpha(0.8f, 0.5f, 12.f, 1.f, false), 0.4f); + + // A zero budget with a real interval leaves no room for the blend. + ExpectNear("zero budget", ComputeBlendAlpha(0.8f, 0.f, 12.f, 1.f, false), 0.8f); + + // Unknown interval (<= 0) imposes no budget cut. + ExpectNear("unknown interval", ComputeBlendAlpha(0.8f, 0.1f, 0.f, 1.f, false), 0.8f); + + // The result is always in range, even for absurd inputs. + ExpectInUnitRange("clamp high", ComputeBlendAlpha(9.f, 9.f, 1.f, 1.f, false)); + ExpectInUnitRange("clamp low", ComputeBlendAlpha(-1.f, -1.f, -1.f, -1.f, true)); +} + +void InterpolationRules() { + // Full strength is the midpoint; the weight (distance from the current frame) is 0.5. + ExpectNear("full strength factor", ComputeInterpolationFactor(1.f, false), 0.5f); + ExpectNear("full strength weight", FrameGenerationWeight(1.f, false), 0.5f); + + // Zero strength biases fully to the current real frame (factor 1, weight 0): the low-artefact end. + ExpectNear("zero strength factor", ComputeInterpolationFactor(0.f, false), 1.f); + ExpectNear("zero strength weight", FrameGenerationWeight(0.f, false), 0.f); + + // The edge-artefact bias pulls the factor toward the current real frame (larger factor). + ExpectInUnitRange("edge bias", ComputeInterpolationFactor(0.5f, true)); + if (ComputeInterpolationFactor(0.5f, true) <= ComputeInterpolationFactor(0.5f, false)) { + std::printf("FAIL edge bias: expected the factor to move toward the current real frame\n"); + ++g_failures; + } + + // Out-of-range strengths are clamped, and the factor stays in range. + ExpectInUnitRange("strength above one", ComputeInterpolationFactor(9.f, false)); + ExpectInUnitRange("strength below zero", ComputeInterpolationFactor(-9.f, true)); + ExpectNear("strength above one clamps", ComputeInterpolationFactor(9.f, false), 0.5f); +} + +} // namespace + +int main() { + BlendAlphaRules(); + InterpolationRules(); + if (g_failures != 0) { + std::printf("%d present-frame math assertion(s) failed\n", g_failures); + return 1; + } + std::printf("PresentFrameMathTest: all assertions passed\n"); + return 0; +} diff --git a/tests/cosmetics/run_tests.sh b/tests/cosmetics/run_tests.sh index 948a070..37eea44 100755 --- a/tests/cosmetics/run_tests.sh +++ b/tests/cosmetics/run_tests.sh @@ -425,4 +425,10 @@ else echo "== PublishedBlobTsanTest == (skipped: ThreadSanitizer/setarch unavailable)" fi +# The present-frame effect math (frame generation + motion blur) is pure arithmetic in a header, so +# it is pinned here off-device. These two functions are the whole honest content of the two modules: +# the blend weight and the interpolation factor, both clamped to [0, 1]. +compile_run PresentFrameMathTest \ + "$here/PresentFrameMathTest.cpp" + echo "all cosmetics host tests passed"