mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
540 lines
26 KiB
C
540 lines
26 KiB
C
/* Frame generation's renderer-independent half (src/frame_gen.c,
|
|
* docs/FRAME_GENERATION.md): matching triangles of two frames, the
|
|
* interpolation endpoints, the plan of how many in-between frames fit, and
|
|
* the breaker, the generated-frame cost estimate and the guest pace. */
|
|
#include "frame_gen.h"
|
|
|
|
#include <math.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
static int checks, failures;
|
|
static void check(int ok, const char *what) {
|
|
checks++;
|
|
if (!ok) { failures++; fprintf(stderr, "FAIL: %s\n", what); }
|
|
}
|
|
|
|
/* ---- a synthetic 3D scene seen by a moving camera ----
|
|
* World points on a grandstand facade (one repeated texture: every triangle
|
|
* has the same key), a camera at eye E with yaw Y (world -> camera: rotate by
|
|
* -Y about the vertical axis, after subtracting E), projection H / z. */
|
|
#define GW 16
|
|
#define GH 6
|
|
#define HH 300.0
|
|
typedef struct { double eye[3], yaw; } Cam;
|
|
|
|
static void to_cam(const Cam *c, const double w[3], double o[3]) {
|
|
const double d[3] = { w[0] - c->eye[0], w[1] - c->eye[1], w[2] - c->eye[2] };
|
|
const double cs = cos(c->yaw), sn = sin(c->yaw);
|
|
o[0] = cs * d[0] - sn * d[2];
|
|
o[1] = d[1];
|
|
o[2] = sn * d[0] + cs * d[2];
|
|
}
|
|
|
|
static void wpt(int r, int col, double w[3]) {
|
|
w[0] = -400.0 + col * 50.0; w[1] = -100.0 + r * 40.0; w[2] = 2000.0 + col * 30.0;
|
|
}
|
|
|
|
static uint32_t vid_of(int r, int col) { return (uint32_t)(r * (GW + 1) + col) + 1; }
|
|
|
|
static void vtx(FgPrim *p, int k, const Cam *c, int r, int col, uint32_t id) {
|
|
double w[3], q[3];
|
|
wpt(r, col, w);
|
|
to_cam(c, w, q);
|
|
p->vid[k] = id;
|
|
for (int i = 0; i < 3; i++) p->p[k][i] = (float)q[i];
|
|
p->h[k] = (float)HH;
|
|
p->x[k] = (float)(160.0 + HH * q[0] / q[2]);
|
|
p->y[k] = (float)(120.0 + HH * q[1] / q[2]);
|
|
}
|
|
|
|
static void add(FgPrimList *l, FgPrim p) { p.rec = l->n; check(fg_prims_add(l, &p), "add"); }
|
|
|
|
/* Rows r0..r1-1, bottom first when `reverse`. */
|
|
static void stand(FgPrimList *l, const Cam *c, int r0, int r1, int reverse) {
|
|
for (int ri = r0; ri < r1; ri++) {
|
|
const int r = reverse ? r1 - 1 - (ri - r0) : ri;
|
|
for (int col = 0; col < GW; col++) {
|
|
FgPrim p;
|
|
memset(&p, 0, sizeof p);
|
|
p.key = 7; p.view = 1;
|
|
vtx(&p, 0, c, r, col, vid_of(r, col));
|
|
vtx(&p, 1, c, r, col + 1, vid_of(r, col + 1));
|
|
vtx(&p, 2, c, r + 1, col, vid_of(r + 1, col));
|
|
add(l, p);
|
|
vtx(&p, 0, c, r, col + 1, vid_of(r, col + 1));
|
|
vtx(&p, 1, c, r + 1, col + 1, vid_of(r + 1, col + 1));
|
|
vtx(&p, 2, c, r + 1, col, vid_of(r + 1, col));
|
|
add(l, p);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Worst screen distance between where a newer vertex is placed at phase t
|
|
* and where camera c shows its world point. */
|
|
static float place_error(const FgPrimList *b, const float *x, const float *y, const Cam *c) {
|
|
float worst = 0.0f;
|
|
for (uint32_t j = 0; j < b->n; j++)
|
|
for (int k = 0; k < 3; k++) {
|
|
const uint32_t id = b->v[j].vid[k] - 1;
|
|
double w[3], q[3];
|
|
wpt((int)(id / (GW + 1)), (int)(id % (GW + 1)), w);
|
|
to_cam(c, w, q);
|
|
const float ex = x[3 * j + k] - (float)(160.0 + HH * q[0] / q[2]);
|
|
const float ey = y[3 * j + k] - (float)(120.0 + HH * q[1] / q[2]);
|
|
const float e = sqrtf(ex * ex + ey * ey);
|
|
if (e > worst) worst = e;
|
|
}
|
|
return worst;
|
|
}
|
|
|
|
/* The grandstand regression: the camera pans and travels, the newer frame
|
|
* draws the rows in another order with the top row culled and a new row in.
|
|
* In-between frames are the newer frame from an in-between camera: at phase
|
|
* 0 every vertex lands where the older camera saw it, at 0.5 where the
|
|
* halfway camera does. */
|
|
static void test_camera_reordered(void) {
|
|
const Cam co = { { 0, 0, 0 }, 0.00 }, cn = { { 30, 0, 60 }, 0.04 }, cm = { { 15, 0, 30 }, 0.02 };
|
|
FgPrimList a = { 0 }, b = { 0 };
|
|
stand(&a, &co, 0, GH - 1, 0);
|
|
stand(&b, &cn, 1, GH, 1);
|
|
FgCamParams cp;
|
|
fg_cam_defaults(&cp);
|
|
FgVert *v = (FgVert *)malloc(sizeof(FgVert) * b.n * 3);
|
|
float *x = (float *)malloc(sizeof(float) * b.n * 3), *y = (float *)malloc(sizeof(float) * b.n * 3);
|
|
FgCamFit fit;
|
|
check(fg_cam_fit(&a, &b, &cp, &fit, v) == 1 && fit.ok, "the reordered stand gets a camera");
|
|
check(fit.camera == b.n * 3 && fit.v[0].inliers == fit.v[0].pairs, "every vertex placed by the camera");
|
|
fg_cam_place(&b, &fit, v, 0.0, x, y, NULL);
|
|
check(place_error(&b, x, y, &co) < 0.1f, "phase 0: where the older camera saw them (new row too)");
|
|
fg_cam_place(&b, &fit, v, 0.5, x, y, NULL);
|
|
check(place_error(&b, x, y, &cm) < 0.5f, "phase 0.5: where the halfway camera sees them");
|
|
fg_cam_place(&b, &fit, v, 1.0, x, y, NULL);
|
|
check(place_error(&b, x, y, &cn) < 1e-3f, "phase 1: the newer frame exactly");
|
|
free(v); free(x); free(y);
|
|
fg_prims_free(&a); fg_prims_free(&b);
|
|
}
|
|
|
|
/* A car the camera follows (the same camera-space position in both frames)
|
|
* stays where it is; a HUD triangle (no projection) is not moved; a vertex
|
|
* without a projection in a world triangle moves with its neighbours. */
|
|
static void test_objects_hud_neighbours(void) {
|
|
const Cam co = { { 0, 0, 0 }, 0.0 }, cn = { { 0, 0, 80 }, 0.03 };
|
|
FgPrimList a = { 0 }, b = { 0 };
|
|
stand(&a, &co, 0, GH, 0);
|
|
stand(&b, &cn, 0, GH, 0);
|
|
FgPrim car;
|
|
memset(&car, 0, sizeof car);
|
|
car.key = 9; car.view = 1;
|
|
const float cpos[3][3] = { { -50, 60, 600 }, { 50, 60, 600 }, { 0, 20, 650 } };
|
|
for (int k = 0; k < 3; k++) {
|
|
car.vid[k] = 900000u + (uint32_t)k;
|
|
memcpy(car.p[k], cpos[k], sizeof car.p[k]);
|
|
car.h[k] = (float)HH;
|
|
car.x[k] = (float)(160.0 + HH * cpos[k][0] / cpos[k][2]);
|
|
car.y[k] = (float)(120.0 + HH * cpos[k][1] / cpos[k][2]);
|
|
}
|
|
add(&a, car); add(&b, car);
|
|
FgPrim hud;
|
|
memset(&hud, 0, sizeof hud);
|
|
hud.key = 3; hud.view = 2;
|
|
hud.x[0] = 10; hud.y[0] = 10; hud.x[1] = 40; hud.y[1] = 10; hud.x[2] = 10; hud.y[2] = 30;
|
|
add(&b, hud);
|
|
FgPrim cpu = b.v[0]; /* a CPU-built vertex in a world triangle */
|
|
cpu.vid[2] = 0;
|
|
add(&b, cpu);
|
|
FgCamParams cp;
|
|
fg_cam_defaults(&cp);
|
|
FgVert *v = (FgVert *)malloc(sizeof(FgVert) * b.n * 3);
|
|
float *x = (float *)malloc(sizeof(float) * b.n * 3), *y = (float *)malloc(sizeof(float) * b.n * 3);
|
|
FgCamFit fit;
|
|
check(fg_cam_fit(&a, &b, &cp, &fit, v) && fit.object == 3, "the followed car is an object");
|
|
fg_cam_place(&b, &fit, v, 0.0, x, y, NULL);
|
|
const uint32_t ic = 3 * (b.n - 3), ih = 3 * (b.n - 2), iu = 3 * (b.n - 1);
|
|
check(fabsf(x[ic] - car.x[0]) < 1e-3f && fabsf(y[ic + 2] - car.y[2]) < 1e-3f, "the followed car stays put");
|
|
check(x[ih] == hud.x[0] && y[ih + 2] == hud.y[2], "the HUD is not moved");
|
|
check(v[iu + 2].mode == FG_PLACE_NEIGHBOUR && fabsf(x[iu + 2] - b.v[b.n - 1].x[2]) > 0.5f,
|
|
"a CPU-built vertex moves with its triangle");
|
|
free(v); free(x); free(y);
|
|
fg_prims_free(&a); fg_prims_free(&b);
|
|
}
|
|
|
|
static void test_verdict(void) {
|
|
const Cam co = { { 0, 0, 0 }, 0.0 }, cn = { { 0, 0, 40 }, 0.02 }, far = { { 0, 0, 0 }, 0.9 };
|
|
FgCamParams cp;
|
|
fg_cam_defaults(&cp);
|
|
FgPrimList a = { 0 }, b = { 0 };
|
|
FgVert v[3 * GW * 2 * GH + 64];
|
|
FgCamFit fit;
|
|
/* No projections (a 2D screen). */
|
|
for (int i = 0; i < 40; i++) {
|
|
FgPrim p; memset(&p, 0, sizeof p); p.key = 1; p.x[1] = (float)i; add(&a, p); add(&b, p);
|
|
}
|
|
check(!fg_cam_fit(&a, &b, &cp, &fit, v) && fit.why && strstr(fit.why, "no projections"), "no projections: rejected");
|
|
fg_prims_reset(&a); fg_prims_reset(&b);
|
|
/* A cut: other geometry entirely. */
|
|
stand(&a, &co, 0, GH, 0);
|
|
stand(&b, &cn, 0, GH, 0);
|
|
for (uint32_t j = 0; j < b.n; j++) for (int k = 0; k < 3; k++) b.v[j].vid[k] += 100000u;
|
|
check(!fg_cam_fit(&a, &b, &cp, &fit, v) && fit.why && strstr(fit.why, "few pairs"), "a cut: rejected");
|
|
fg_prims_reset(&b);
|
|
/* A turn no camera makes between two frames. */
|
|
stand(&b, &far, 0, GH, 0);
|
|
check(!fg_cam_fit(&a, &b, &cp, &fit, v), "an implausible turn: rejected");
|
|
fg_prims_reset(&b);
|
|
/* Half the identities moved independently (wrong lookups): no consistent camera. */
|
|
stand(&b, &cn, 0, GH, 0);
|
|
for (uint32_t j = 0; j < b.n; j += 2)
|
|
for (int k = 0; k < 3; k++) { b.v[j].p[k][0] += 300.0f * (float)((j * 7 + k) % 5); b.v[j].p[k][2] += 150.0f; }
|
|
check(!fg_cam_fit(&a, &b, &cp, &fit, v), "inconsistent motion: rejected");
|
|
fg_prims_free(&a); fg_prims_free(&b);
|
|
}
|
|
|
|
static void test_plan(void) {
|
|
const double f30 = 2.0 / 59.94, f60 = 1.0 / 59.94;
|
|
/* 30 Hz game frames on a 120 Hz panel: 4 slots, 3 in-between. */
|
|
check(fg_plan(f30, 120.0, 0.010, 0.002, 0.85, 7) == 3, "30 Hz on 120 Hz: three in-between frames");
|
|
/* 60 Hz game frames on 120 Hz: one. On 60 Hz: none. */
|
|
check(fg_plan(f60, 120.0, 0.004, 0.002, 0.85, 7) == 1, "60 Hz on 120 Hz: one");
|
|
check(fg_plan(f60, 60.0, 0.004, 0.002, 0.85, 7) == 0, "60 Hz on 60 Hz: none");
|
|
check(fg_plan(f30, 60.0, 0.010, 0.002, 0.85, 7) == 1, "30 Hz on 60 Hz: one");
|
|
/* Only what fits: 33.4 ms * 0.85 = 28.4; real 20 ms leaves 8.4 -> two of 4 ms. */
|
|
check(fg_plan(f30, 120.0, 0.020, 0.004, 0.85, 7) == 2, "only what fits");
|
|
check(fg_plan(f30, 120.0, 0.030, 0.001, 0.85, 7) == 0, "no surplus: none");
|
|
/* Unknown generation cost: one, if a third of the budget is free. */
|
|
check(fg_plan(f30, 120.0, 0.010, 0.0, 0.85, 7) == 1, "unmeasured: one to measure");
|
|
/* Windows, R4 30 Hz with the display ring: 2 x 7.2 ms real leaves 14 of
|
|
* 28.4 ms, under half; one is still drawn to measure the cost. */
|
|
check(fg_plan(f30, 120.0, 0.0144, 0.0, 0.85, 7) == 1, "unmeasured, real over 42 %: one to measure");
|
|
check(fg_plan(f30, 120.0, 0.020, 0.0, 0.85, 7) == 0, "unmeasured without room: none");
|
|
check(fg_plan(f30, 240.0, 0.001, 0.0001, 0.85, 2) == 2, "max_gens caps");
|
|
}
|
|
|
|
static void test_breaker(void) {
|
|
FgBreaker b;
|
|
fg_breaker_init(&b, 3.0, 24.0, 10.0);
|
|
check(fg_breaker_open(&b, 0.0), "starts allowing generation");
|
|
fg_breaker_trip(&b, 1.0, "late");
|
|
check(!fg_breaker_open(&b, 3.9) && fg_breaker_open(&b, 4.0), "a trip holds 3 s");
|
|
fg_breaker_trip(&b, 5.0, "late"); /* within 10 s of the last hold's end */
|
|
check(!fg_breaker_open(&b, 10.9) && fg_breaker_open(&b, 11.0), "a repeat doubles the hold");
|
|
fg_breaker_trip(&b, 6.0, "late"); /* already held: the hold stands */
|
|
check(fg_breaker_open(&b, 11.0), "a trip while held does not extend it");
|
|
fg_breaker_trip(&b, 40.0, "late"); /* long after: back to the base hold */
|
|
check(!fg_breaker_open(&b, 42.9) && fg_breaker_open(&b, 43.0), "a later trip starts over");
|
|
check(b.trips == 4 && b.reason && strcmp(b.reason, "late") == 0, "trips counted with a reason");
|
|
for (int i = 0; i < 10; i++) fg_breaker_trip(&b, 43.0 + 30.0 * i + (i ? 0 : 0), "x");
|
|
check(b.hold <= 24.0, "the hold is capped");
|
|
}
|
|
|
|
static void test_cost(void) {
|
|
FgCost c;
|
|
fg_cost_init(&c, 2.0, 16.0);
|
|
const double fit = 0.020;
|
|
check(fg_cost_estimate(&c, 0.0, fit) == 0.0, "unknown before any sample");
|
|
/* Cold: the first frames after allocation are discarded. */
|
|
fg_cost_cold(&c, 2);
|
|
fg_cost_add(&c, 0.070, fit);
|
|
fg_cost_add(&c, 0.040, fit);
|
|
check(c.ema == 0.0 && c.discarded == 2, "cold samples discarded");
|
|
fg_cost_add(&c, 0.006, fit);
|
|
check(fabs(fg_cost_estimate(&c, 0.1, fit) - 0.006) < 1e-12, "first warm sample is the estimate");
|
|
fg_cost_add(&c, 0.016, fit);
|
|
check(fabs(c.ema - 0.008) < 1e-12, "warm samples blend");
|
|
/* A spike that pushes the estimate past the fit blocks the plan... */
|
|
for (int i = 0; i < 10; i++) fg_cost_add(&c, 0.060, fit);
|
|
check(fg_cost_estimate(&c, 10.0, fit) > fit, "a high estimate blocks");
|
|
check(fg_cost_estimate(&c, 11.9, fit) > fit, "... until the probe interval");
|
|
/* ...for at most probe_s: then one probe, whose sample replaces it. */
|
|
check(fg_cost_estimate(&c, 12.0, fit) == 0.0 && c.probes == 1, "stale estimate re-probed");
|
|
check(fg_cost_estimate(&c, 12.03, fit) > fit, "one probe at a time");
|
|
fg_cost_add(&c, 0.005, fit);
|
|
check(fabs(fg_cost_estimate(&c, 12.1, fit) - 0.005) < 1e-12, "the probe replaces the estimate");
|
|
/* A probe that still does not fit backs off. */
|
|
for (int i = 0; i < 10; i++) fg_cost_add(&c, 0.060, fit);
|
|
(void)fg_cost_estimate(&c, 20.0, fit);
|
|
check(fg_cost_estimate(&c, 22.0, fit) == 0.0, "second probe");
|
|
fg_cost_add(&c, 0.050, fit);
|
|
check(c.probe_s == 4.0, "a probe that does not fit doubles the wait");
|
|
check(fg_cost_estimate(&c, 25.9, fit) > fit && fg_cost_estimate(&c, 26.0, fit) == 0.0,
|
|
"next probe after the doubled wait");
|
|
fg_cost_add(&c, 0.004, fit);
|
|
check(c.probe_s == 2.0, "a probe that fits resets the wait");
|
|
/* A granted probe that was never drawn is given up, then re-granted. */
|
|
for (int i = 0; i < 10; i++) fg_cost_add(&c, 0.060, fit);
|
|
(void)fg_cost_estimate(&c, 40.0, fit);
|
|
check(fg_cost_estimate(&c, 42.0, fit) == 0.0, "probe granted");
|
|
check(fg_cost_estimate(&c, 44.0, fit) > fit, "an undrawn probe expires");
|
|
check(fg_cost_estimate(&c, 46.0, fit) == 0.0, "and is granted again");
|
|
/* Re-allocation makes the next samples cold again. */
|
|
fg_cost_cold(&c, 2);
|
|
fg_cost_add(&c, 0.5, fit); fg_cost_add(&c, 0.5, fit);
|
|
check(c.cold == 0 && c.ema < 0.5, "cold after re-allocation");
|
|
}
|
|
|
|
static void test_pace(void) {
|
|
FgPace p;
|
|
memset(&p, 0, sizeof p);
|
|
const double T = 1.0 / 59.94, slack = 2.0 * T;
|
|
double t = 0.0;
|
|
int late = 0;
|
|
for (int i = 0; i < 600; i++) { /* jitter: +-6 ms around the schedule */
|
|
late += fg_pace_note(&p, t + ((i % 3) - 1) * 0.006, T, slack);
|
|
t += T;
|
|
}
|
|
check(late == 0, "jitter is not late");
|
|
late = 0; /* one 40 ms frame made up by a burst */
|
|
late += fg_pace_note(&p, t + 0.024, T, slack); t += T;
|
|
late += fg_pace_note(&p, t + 0.004, T, slack); t += T;
|
|
late += fg_pace_note(&p, t, T, slack); t += T;
|
|
check(late == 0, "a long frame made up is not late");
|
|
/* A real slip: 60 ms gap. */
|
|
check(fg_pace_note(&p, t + 0.045, T, slack) == 1, "a slip beyond two intervals is late");
|
|
t += 0.045 + T;
|
|
check(fg_pace_note(&p, t, T, slack) == 0, "the schedule restarts after a slip");
|
|
/* Slow but steady (a 50 Hz guest told 59.94): one trip, then resync each time it slips. */
|
|
int trips = 0;
|
|
for (int i = 0; i < 60; i++) { t += 0.020; trips += fg_pace_note(&p, t, T, slack); }
|
|
check(trips > 0 && trips < 20, "a steadily slow guest is late now and then");
|
|
}
|
|
|
|
static void test_ceiling(void) {
|
|
FgCeiling c;
|
|
fg_ceiling_init(&c, 7, 2.0);
|
|
check(fg_ceiling_get(&c, 0.0) == 7, "no ceiling before a trip");
|
|
fg_ceiling_trip(&c, 3, 1.0);
|
|
check(fg_ceiling_get(&c, 1.5) == 2, "an overload at three plans two");
|
|
fg_ceiling_trip(&c, 2, 1.6);
|
|
check(fg_ceiling_get(&c, 1.7) == 1, "another lowers it again");
|
|
fg_ceiling_trip(&c, 1, 1.8);
|
|
check(fg_ceiling_get(&c, 1.9) == 1, "never below one (the breaker stops generation)");
|
|
check(fg_ceiling_get(&c, 3.7) == 1 && fg_ceiling_get(&c, 3.9) == 2, "recovers one per 2 s");
|
|
check(fg_ceiling_get(&c, 7.9) == 4, "and keeps recovering");
|
|
fg_ceiling_trip(&c, 0, 8.0);
|
|
check(fg_ceiling_get(&c, 8.0) == 4, "a trip with nothing planned is not an overload");
|
|
check(fg_ceiling_get(&c, 100.0) == 7, "up to the maximum");
|
|
}
|
|
|
|
/* Any-rate presents: the grid fg_tick runs (fg_next_due, fg_clock_phase,
|
|
* fg_plan at fg_plan_hz), simulated over 10 s of 30 Hz game frames. */
|
|
static void test_any_rate(void) {
|
|
const double rates[] = { 60.0, 90.0, 100.0, 120.0, 144.0, 165.0, 240.0, 360.0 };
|
|
const double flip_s = 2.0 / 59.94;
|
|
const uint64_t flip_ns = (uint64_t)(flip_s * 1e9);
|
|
for (unsigned r = 0; r < sizeof rates / sizeof rates[0]; r++) {
|
|
const double hz = rates[r];
|
|
const uint64_t step = (uint64_t)(fg_step_s(flip_s, hz, 1) * 1e9);
|
|
const int n = fg_plan(flip_s, fg_plan_hz(flip_s, hz), 0.001, 0.0005, 0.85, 64);
|
|
uint64_t due = 0, last = 0, min_gap = UINT64_MAX;
|
|
unsigned presents = 0, frames = 300, gens = 0;
|
|
int phase_ok = 1, shown_ok = 1;
|
|
unsigned max_shown = 0;
|
|
/* A whole number of intervals a game frame (59.94 Hz content on
|
|
* 60 / 120 / 240 / 360 Hz): every game frame shows the same count. */
|
|
const double x = flip_s * hz;
|
|
const int whole = fabs(x - floor(x + 0.5)) < 0.01;
|
|
for (unsigned f = 0; f < frames; f++) {
|
|
const unsigned gens_before = gens;
|
|
const uint64_t t0 = (uint64_t)f * flip_ns, next = t0 + flip_ns;
|
|
if (due < t0 || due > t0 + step) due = t0;
|
|
double prev_t = 0.0;
|
|
for (int k = 1;; ) {
|
|
uint64_t now = due;
|
|
if (now >= next) { now = next; }
|
|
else if (k <= n) {
|
|
double t = fg_clock_phase(now - t0, step, flip_ns);
|
|
if (t > 0.0) {
|
|
if (!(t > prev_t && t < 1.0)) phase_ok = 0;
|
|
prev_t = t; k++; gens++;
|
|
if (presents && now - last < min_gap) min_gap = now - last;
|
|
last = now; presents++;
|
|
due = fg_next_due(due, now, step);
|
|
continue;
|
|
}
|
|
}
|
|
if (presents && now - last < min_gap && now != next) min_gap = now - last;
|
|
last = now; presents++;
|
|
due = fg_next_due(due, now, step);
|
|
break;
|
|
}
|
|
/* Every planned in-between frame is shown (the plan, and the
|
|
* dynamic resolution budget built on it, count no dropped one). */
|
|
if (f > 0 && gens - gens_before > max_shown) max_shown = gens - gens_before;
|
|
if (f > 0 && whole && gens - gens_before != (unsigned)n) shown_ok = 0;
|
|
}
|
|
const double secs = frames * flip_s, rate = presents / secs;
|
|
char what[96];
|
|
snprintf(what, sizeof what, "%.0f Hz: %.1f presents/s, phases rise", hz, rate);
|
|
check(phase_ok && rate > hz * 0.95 && rate < hz * 1.03, what);
|
|
snprintf(what, sizeof what, "%.0f Hz: no two presents closer than one interval", hz);
|
|
check(min_gap + 1000u >= step, what);
|
|
/* The plan (and the dynamic resolution budget built on it) is the
|
|
* most frames the clock shows in a game frame: none planned is
|
|
* dropped every frame, none shown is unbudgeted. */
|
|
snprintf(what, sizeof what, "%.0f Hz: the clock shows up to the %d planned frames", hz, n);
|
|
check(max_shown == (unsigned)n && shown_ok, what);
|
|
}
|
|
}
|
|
|
|
/* 59.94 Hz content (two VBlanks a game frame) on 60 / 120 Hz panels: 2.002
|
|
* and 4.004 intervals. The clock shows 1 / 3 in-between frames; the plan
|
|
* and the dynamic resolution share count exactly those (fg_slots). */
|
|
static void test_slots_5994(void) {
|
|
const double flip_s = 2.0 / 59.94;
|
|
check(fg_slots(flip_s, 60.0) == 2, "59.94 on 60 Hz: 2 slots (1 in-between frame)");
|
|
check(fg_slots(flip_s, 120.0) == 4, "59.94 on 120 Hz: 4 slots");
|
|
check(fg_slots(flip_s, 100.0) == 4, "59.94 on 100 Hz: 4 slots (3.34 intervals)");
|
|
check(fg_slots(2.0 / 60.0, 60.0) == 2, "60 on 60 Hz: 2 slots");
|
|
check(fg_plan(flip_s, fg_plan_hz(flip_s, 60.0), 0.001, 0.0005, 0.85, 64) == 1,
|
|
"59.94 on 60 Hz: one in-between frame planned");
|
|
check(fg_plan(flip_s, fg_plan_hz(flip_s, 120.0), 0.001, 0.0005, 0.85, 64) == 3,
|
|
"59.94 on 120 Hz: three planned");
|
|
}
|
|
|
|
static void test_hud_lerp(void) {
|
|
FgPrimList L = {0}, O = {0};
|
|
FgPrim p; memset(&p, 0, sizeof p);
|
|
p.key = 7; p.view = 1;
|
|
p.x[0] = 10; p.y[0] = 10; p.x[1] = 20; p.y[1] = 10; p.x[2] = 15; p.y[2] = 30;
|
|
fg_prims_add(&L, &p); /* needle */
|
|
p.key = 9; fg_prims_add(&L, &p); /* a digit */
|
|
FgPrim q = L.v[0]; q.x[2] = 25; fg_prims_add(&O, &q); /* needle tip moved 10 px */
|
|
q = L.v[1]; q.key = 10; fg_prims_add(&O, &q); /* the digit changed */
|
|
float x[6], y[6];
|
|
for (int i = 0; i < 6; i++) { x[i] = L.v[i / 3].x[i % 3]; y[i] = L.v[i / 3].y[i % 3]; }
|
|
fg_hud_lerp(&L, &O, 0.5, x, y, 24.0f);
|
|
check(x[2] == 20.0f && x[0] == 10.0f, "HUD needle halfway between the frames");
|
|
check(x[5] == 15.0f, "a changed digit stays as drawn");
|
|
fg_prims_free(&L); fg_prims_free(&O);
|
|
|
|
/* The match window is 8 2D triangles of the other frame, the 8th
|
|
* included: a needle found there still moves, one at the 9th does not. */
|
|
for (int at = 8; at <= 9; at++) {
|
|
FgPrimList A = {0}, B = {0};
|
|
memset(&p, 0, sizeof p);
|
|
p.key = 7; p.view = 1;
|
|
p.x[0] = 10; p.y[0] = 10; p.x[1] = 20; p.y[1] = 10; p.x[2] = 15; p.y[2] = 30;
|
|
fg_prims_add(&A, &p);
|
|
for (int i = 1; i < at; i++) { FgPrim o = p; o.key = 100 + i; fg_prims_add(&B, &o); }
|
|
FgPrim m = p; m.x[2] = 25; fg_prims_add(&B, &m);
|
|
float ax[3] = { 10, 20, 15 }, ay[3] = { 10, 10, 30 };
|
|
fg_hud_lerp(&A, &B, 0.5, ax, ay, 24.0f);
|
|
check(at == 8 ? ax[2] == 20.0f : ax[2] == 15.0f,
|
|
at == 8 ? "a match at the 8th 2D triangle moves" : "a match at the 9th stays");
|
|
fg_prims_free(&A); fg_prims_free(&B);
|
|
}
|
|
}
|
|
|
|
static void test_view_affine(void) {
|
|
FgCamFit f; memset(&f, 0, sizeof f);
|
|
f.nviews = 1;
|
|
const double a = 0.1; /* 0.2 rad about y */
|
|
f.v[0].q[0] = cos(a); f.v[0].q[2] = sin(a);
|
|
f.v[0].t[0] = 30; f.v[0].t[1] = -5; f.v[0].t[2] = 400;
|
|
float A[9], b[3];
|
|
check(fg_view_affine(&f, 0, 1.0, A, b), "affine");
|
|
check(fabsf(A[0] - 1) < 1e-5f && fabsf(A[4] - 1) < 1e-5f && fabsf(A[8] - 1) < 1e-5f &&
|
|
fabsf(A[1]) < 1e-5f && fabsf(b[0]) < 1e-3f && fabsf(b[2]) < 1e-3f, "t = 1: the newer camera itself");
|
|
/* t = 0 maps a newer point back to the older frame: P_old = M^T (P - T). */
|
|
fg_view_affine(&f, 0, 0.0, A, b);
|
|
const float P[3] = { 100, 20, 1000 };
|
|
const float o0 = A[0] * P[0] + A[1] * P[1] + A[2] * P[2] + b[0];
|
|
const float o2 = A[6] * P[0] + A[7] * P[1] + A[8] * P[2] + b[2];
|
|
/* M rotates about y by 0.2: M^T (P-T) */
|
|
const double c = cos(2 * a), sn = sin(2 * a), dx = P[0] - 30, dz = P[2] - 400;
|
|
check(fabs(o0 - (c * dx - sn * dz)) < 1e-2 && fabs(o2 - (sn * dx + c * dz)) < 1e-2,
|
|
"t = 0: the older frame's position");
|
|
/* Older frame (two-frame warp): t = 0 is the older camera itself, t = 1
|
|
* the newer one (P_new = M P_old + T), and at any t an older point lands
|
|
* where the same point seen from the newer frame lands. */
|
|
float Ao[9], bo[3];
|
|
check(fg_view_affine_older(&f, 0, 0.0, Ao, bo) &&
|
|
fabsf(Ao[0] - 1) < 1e-5f && fabsf(Ao[4] - 1) < 1e-5f && fabsf(Ao[8] - 1) < 1e-5f &&
|
|
fabsf(bo[0]) < 1e-5f && fabsf(bo[2]) < 1e-5f, "older, t = 0: the older camera itself");
|
|
for (int k = 0; k <= 4; k++) {
|
|
const double t = k / 4.0;
|
|
const float Po[3] = { -50, 10, 900 };
|
|
double M[9];
|
|
{ const double w = f.v[0].q[0], x = f.v[0].q[1], y = f.v[0].q[2], z = f.v[0].q[3];
|
|
M[0] = 1 - 2*(y*y + z*z); M[1] = 2*(x*y - w*z); M[2] = 2*(x*z + w*y);
|
|
M[3] = 2*(x*y + w*z); M[4] = 1 - 2*(x*x + z*z); M[5] = 2*(y*z - w*x);
|
|
M[6] = 2*(x*z - w*y); M[7] = 2*(y*z + w*x); M[8] = 1 - 2*(x*x + y*y); }
|
|
float Pn[3];
|
|
for (int r = 0; r < 3; r++)
|
|
Pn[r] = (float)(M[3*r] * Po[0] + M[3*r+1] * Po[1] + M[3*r+2] * Po[2] + f.v[0].t[r]);
|
|
fg_view_affine(&f, 0, t, A, b);
|
|
fg_view_affine_older(&f, 0, t, Ao, bo);
|
|
int same = 1;
|
|
for (int r = 0; r < 3; r++) {
|
|
const float vn = A[3*r] * Pn[0] + A[3*r+1] * Pn[1] + A[3*r+2] * Pn[2] + b[r];
|
|
const float vo = Ao[3*r] * Po[0] + Ao[3*r+1] * Po[1] + Ao[3*r+2] * Po[2] + bo[r];
|
|
same &= fabsf(vn - vo) < 0.05f;
|
|
}
|
|
check(same, "older and newer warps agree on the same point at every phase");
|
|
}
|
|
}
|
|
|
|
/* A 320x240 main view and a 64x48 inset with the same valid camera motion.
|
|
* The inset must still interpolate in redraw; only opt-in reprojection
|
|
* freezes it. Zero-initialized fits also keep the default redraw policy. */
|
|
static void test_small_view_policy(void) {
|
|
FgPrim p[2] = {0}; FgCamFit fit = {0}; FgVert verts[6] = {0};
|
|
FgCamParams cp; fg_cam_defaults(&cp);
|
|
check(!cp.freeze_small_views, "redraw defaults do not freeze small views");
|
|
fit.ok = 1; fit.nviews = 2;
|
|
for (int view = 0; view < 2; view++) {
|
|
const float h = view ? 48.0f : 240.0f;
|
|
const float cx = view ? 32.0f : 160.0f, cy = view ? 24.0f : 120.0f;
|
|
fit.v[view].ok = 1; fit.v[view].q[0] = 1.0; fit.v[view].t[0] = 20.0;
|
|
fit.v[view].area[2] = view ? 64.0f : 320.0f;
|
|
fit.v[view].area[3] = h;
|
|
for (int k = 0; k < 3; k++) {
|
|
p[view].p[k][0] = k == 1 ? 20.0f : 0.0f;
|
|
p[view].p[k][1] = k == 2 ? 20.0f : 0.0f;
|
|
p[view].p[k][2] = 200.0f; p[view].h[k] = h;
|
|
p[view].x[k] = cx + h * p[view].p[k][0] / 200.0f;
|
|
p[view].y[k] = cy + h * p[view].p[k][1] / 200.0f;
|
|
verts[view * 3 + k].mode = FG_PLACE_CAMERA;
|
|
verts[view * 3 + k].view = (int8_t)view;
|
|
}
|
|
}
|
|
FgPrimList list = {p, 2, 2}; float x[6], y[6];
|
|
for (int freeze = 0; freeze < 2; freeze++) {
|
|
fit.freeze_small_views = freeze;
|
|
fg_cam_place(&list, &fit, verts, 0.5, x, y, NULL);
|
|
check(fabsf(x[0] - p[0].x[0] + 12.0f) < 0.001f, "main view always interpolates");
|
|
check(fabsf(x[3] - p[1].x[0] + (freeze ? 0.0f : 2.4f)) < 0.001f,
|
|
freeze ? "opt-in reprojection freezes the inset" : "redraw interpolates the inset");
|
|
fg_cam_place(&list, &fit, verts, 1.0, x, y, NULL);
|
|
for (int i = 0; i < 6; i++)
|
|
check(x[i] == p[i / 3].x[i % 3] && y[i] == p[i / 3].y[i % 3],
|
|
"both policies preserve the real-frame endpoint");
|
|
}
|
|
/* The fit API propagates the policy; keep_partial alone must not set it. */
|
|
FgPrimList empty = {0}; cp.keep_partial = 1;
|
|
fg_cam_fit(&empty, &empty, &cp, &fit, verts);
|
|
check(!fit.freeze_small_views, "partial fit does not implicitly freeze insets");
|
|
cp.freeze_small_views = 1;
|
|
fg_cam_fit(&empty, &empty, &cp, &fit, verts);
|
|
check(fit.freeze_small_views, "fit propagates explicit reprojection policy");
|
|
}
|
|
|
|
int main(void) {
|
|
test_small_view_policy();
|
|
test_view_affine();
|
|
test_hud_lerp();
|
|
test_slots_5994();
|
|
test_any_rate();
|
|
test_ceiling();
|
|
test_cost();
|
|
test_pace();
|
|
test_camera_reordered();
|
|
test_objects_hud_neighbours();
|
|
test_verdict();
|
|
test_plan();
|
|
test_breaker();
|
|
printf("frame_gen_test: checks=%d failures=%d\n", checks, failures);
|
|
return failures ? 1 : 0;
|
|
}
|