帧节流内核(PacerCore / PacerSleeper / OperationArgs)
基本信息
| 属性 | 值 |
|---|---|
| 包 | com.gtnewhorizons.angelica.rendering |
| 本条目覆盖 | 3 个文件:PacerCore.java、PacerSleeper.java、OperationArgs.java |
| 行数 | 374 + 120 + 14 = 508 |
| 可见性 | 3 个全部包私有 final class(OperationArgs 是 public final,见下) |
| 上层门面 | rendering/FramePacer.java(已被 Celeritas 等条目引用) |
| 单元测试 | src/test/java/.../rendering/FramePacerTest.java 存在,通过反射替换 FramePacer 的 sleeper / core 静态字段 |
这 3 个文件是 FpsReducer 之外另一条独立的限帧路径。区别很重要:
- FpsReducer 降的是上限(
unfocusedFpsLimit/idleFpsLimit之类的「不许超过 N FPS」),并在最小化时整帧跳过渲染。 - 本子系统 做的是节奏对齐(把每帧 deadline 对齐到显示器刷新周期),是 GLSM 换显示后端(GLX / WGL / SDL)之后为了拿到稳定帧时间而加的。
两者都在 rendering/ 顶层,但互不调用。不要把 PacerCore 的常量当成 FpsReducer 的上限值。
可见性陷阱
| 文件 | 声明 | 实际可见性 |
|---|---|---|
PacerCore |
final class PacerCore |
包私有 |
PacerSleeper |
final class PacerSleeper |
包私有 |
OperationArgs |
public final class OperationArgs |
public |
PacerCore 的构造函数、全部状态字段和全部方法都是包私有。它不是对外 API;外部 mod 无法构造或读取它。唯一入口是同包的 FramePacer。
OperationArgs 是三者中唯一的 public,构造器私有,只有两个静态 boxed 重载。
PacerCore:节流状态机
输入源
PacerCore 不直接读硬件,只从两个回调拿数据(:36-37):
private final LongSupplier clock;
private final PacerSleeper sleeper;
由 FramePacer.java:57 注入 FramePacer::nanoTime,时钟源最终是 System.nanoTime()(FramePacer.java:38)。
外部事件通过 onGpuWait(:130)和 onGateSample(:134)进来,两者都只写 pending* 字段,真正的消费发生在 beginFrame(:141)。这是一个「生产者线程写 pending、渲染线程消费」的单槽模型。
关键常量(源码实值,:11-34)
| 常量 | 值 | 行 |
|---|---|---|
NANOS_PER_SECOND |
1_000_000_000L |
:11 |
MARGIN_BASE_NANOS |
500_000L |
:12 |
MARGIN_MIN_NANOS |
1_000_000L |
:13 |
MARGIN_CEILING_NANOS |
4_000_000L |
:14 |
GATE_THRESHOLD_FLOOR_NANOS |
100_000L |
:15 |
IDLE_MIN_BUDGET_NANOS |
500_000L |
:16 |
PRESENT_SLOP_NANOS |
50_000L |
:17 |
PROBE_LEAD_PERIODS |
2 |
:19 |
PROBE_LEAD_DIVISOR |
8 |
:20 |
LOCK_PAIRS |
3 |
:21 |
LOCK_FRAMES |
600 |
:22 |
LOCK_WINDOW |
16 |
:23 |
CATCHUP_DIVISOR |
8 |
:26 |
GATE_THRESHOLD_DIVISOR |
32 |
:27 |
MARGIN_MAX_DIVISOR |
4 |
:29 |
STALL_MULTIPLE |
2 |
:30 |
CPU_SLOTS |
16 |
:33 |
OVERRUN_SLOTS |
128 |
:34 |
注意:PROBE_LEAD_PERIODS、LOCK_PAIRS、LOCK_FRAMES、LOCK_WINDOW 里的 2 / 3 / 600 / 16 是帧数,不是秒或纳秒。LOCK_FRAMES = 600 表示「连续 600 帧没有出现候选才解锁」,按 60 FPS 算是 10 秒。
不要把 MARGIN_* 当成「毫秒」:这些是纳秒。MARGIN_MIN_NANOS = 1_000_000L 是 1 ms。
cadencePeriodNanos:帧周期怎么算(:309-322)
static long cadencePeriodNanos(int capHz, long refreshPeriodNanos, boolean tearFree) {
final long cap = Math.max(capHz, 0);
if (tearFree && refreshPeriodNanos > 0L) {
if (cap == 0L || cap * refreshPeriodNanos * CAP_EQ_DIVISOR >= NANOS_PER_SECOND * (CAP_EQ_DIVISOR - 1)) {
return refreshPeriodNanos;
}
...
}
return cap > 0L ? NANOS_PER_SECOND / cap : 0L;
}
三条分支:
- tearFree 开启且已知刷新周期时,把用户要的 cap 周期吸附到刷新周期的整数倍上,避免撕裂。
- 吸附判据在
:319:Math.abs(snapped - capPeriod) <= capPeriod / SNAP_TOL_DIVISOR(SNAP_TOL_DIVISOR = 8),即误差在 1/8 以内才吸附。 - 其余情况退化为朴素
1e9 / cap;cap == 0返回0L。
返回 0L 是「不限速」的哨兵值,paced()(:267)就是判 cadenceNanos > 0L。beginFrame 在 c == 0L 时(:168-180)把 probing / frameProbing / frameDriverPaced / frameAnchor / haveTarget 全部清掉并 armProbe(),然后直接 return now——一帧都不等。
锁定(lock)判定
updateLock(:333-356)是这个类里最容易读错的一段。它的语义是「连续 3 对采样间隔都稳定落在刷新周期的整数倍上,才认为跟上了显示器」:
| 变量 | 含义 |
|---|---|
candidate |
本帧有资格成为锁定样本。条件见 :162-163:必须有 sample、且 vsyncOn、且 tearFree、且 refreshPeriodNanos > 0、且 sampleDuration > gateThresholdNanos(c) |
gateThresholdNanos |
:324-326,max(100_000L, cadence / 32)。帧时间太短(低于 1/32 周期)就不算样本 |
consistentPairs |
连续「对得上」的对数,>= LOCK_PAIRS(3)即 locked = true(:344) |
inconsistentPairs |
连续「对不上」的对数,>= LOCK_WINDOW(16)即 dropLock() 且 probeExhausted = true(:347-350) |
framesSinceCandidate |
距上次候选的帧数。已锁定时若 >= LOCK_FRAMES(600)仍无候选,则 dropLock()(:336) |
单帧对不上的判据在 :342:
if (k >= 1L && Math.abs(delta - k * refreshPeriodNanos) <= refreshPeriodNanos / LOCK_TOL_DIVISOR)
LOCK_TOL_DIVISOR = 8(:28)。k 由 (delta + refreshPeriodNanos / 2) / refreshPeriodNanos 四舍五入得到。
候选条件是合取,不是析取。 关掉 vsync 或 tearFree,locked 永远不会变成 true(见 driverPaced() :279-281 还额外要求 cadenceNanos == refreshPeriodNanos)。
环形缓冲区
| 字段 | 容量 | 记什么 |
|---|---|---|
cpuRing / cpuSlot |
CPU_SLOTS = 16 |
每帧 CPU 侧耗时,:243-244 写入 |
residualRing / residualSlot |
OVERRUN_SLOTS = 128 |
超时残差,:245-246 写入 |
idleRing / idleSlot |
OVERRUN_SLOTS = 128 |
空闲工作超时量,:221-222 写入 |
取最大值用 Longs.max(...)(Guava,:209-210)。注意 residualRing / idleRing 的下标是 (slot + 1) % 128(:246、:222)而写的是旧 slot 位置——写 cpuRing[cpuSlot] 之后才自增,这是「写当前、推进」的顺序,不是 bug。
cpuRing[cpuSlot] = Math.min(cpu, c)(:243)——单帧 CPU 耗时被截到一整个周期,超过的进 else 分支触发 armProbe()(:248),视为失锁重探。
空闲工作(idleWork)
:214-224:只有 idleDeadline - now - Longs.max(idleRing) > IDLE_MIN_BUDGET_NANOS(500 µs)才真的跑 idleWork。这与 Celeritas 记录的 ClientProxy.java:163-166 接入点对应:FramePacer.setIdleWork(...) 把 CeleritasWorldRenderer.runFrameIdleWork(deadlineNanos) 接进来。
idleWork 是 LongConsumer,收到的是截止时间(绝对纳秒),不是时长。超时量记进 idleRing,参与下一帧的预算判断。
PacerSleeper:park + 自旋混合等待
常量(:7-12)
| 常量 | 值 | 行 |
|---|---|---|
MIN_PARK_NANOS |
1_000_000L(1 ms) |
:7 |
MAX_OVERSHOOT_NANOS |
4_000_000L(4 ms) |
:8 |
SLOTS |
10 |
:9 |
LATE_TOL_NANOS |
250_000L |
:10 |
MIN_SPIN_FLOOR_NANOS |
20_000L |
:11 |
MAX_SPIN_FLOOR_NANOS |
500_000L |
:12 |
MIN_PARK_NANOS / MAX_OVERSHOOT_NANOS / MAX_SPIN_FLOOR_NANOS 是 private,SLOTS / LATE_TOL_NANOS / MIN_SPIN_FLOOR_NANOS 是包私有。
自适应自旋下限
record(:101-116)维护一个 10 槽环形窗口,估算 park 的平均过冲(overshoot),再用抖动(max − avg)当自旋下限:
spinFloorNanos = Math.max(MIN_SPIN_FLOOR_NANOS, Math.min(MAX_SPIN_FLOOR_NANOS, jitter));
逻辑:park 唤醒时间很稳(抖动小)就少自旋;抖动大就多自旋补差。钳在 20 µs ~ 500 µs 之间。
单次过冲在 :64 被钳到 [0, MAX_OVERSHOOT_NANOS]:
record(Math.min(Math.max(0L, now - before - request), MAX_OVERSHOOT_NANOS));
sleepUntil 循环(:37-69)
while (remaining > spinFloor):
request = remaining - avgOvershoot - spinFloor
if request <= 0: break 或 request = MIN_PARK_NANOS(当 remaining > 1ms)
parker(request); 重新读时钟; record(过冲)
自旋段(仅当 spinWait):
while now < deadline:
若剩余 > spinFloor → Thread.yield()
否则 → Thread.onSpinWait()
⚠️ 两个容易读反的点:
Thread.yield()用在剩余还很多的时候,Thread.onSpinWait()(Java 9+)用在快到了的时候(:60-61)。这与直觉相反但正确:离 deadline 还远时让出 CPU 无害,快到了才需要真自旋。park请求量是remaining - avgOvershoot - spinFloor,即主动减去预计过冲和自旋量,只 park 掉「确定能睡」的部分。
Thread.currentThread().isInterrupted() 检查在 :43,被中断时返回当前时钟值而不是继续等——不抛异常。
统计
noteFrame(:71-83)每帧调一次,重置帧级计数并把本帧 park 次数累加进 parkTotal。summary(String config)(:88-92)输出人类可读串,frames == 0 时返回 "pacer: " + config + " no paced frames"。
输出的单位是微秒(:90-92 里除以 1000L),但 avgOvershootNanos()(:94-96)返回的是纳秒。读日志时别把两者搞混。
OperationArgs:装箱去重
14 行,2 个静态重载。作用是避免在渲染热路径上重复装箱同一个值:
public static Object boxed(Object prev, float v) {
return prev instanceof Float f && Float.floatToRawIntBits(f) == Float.floatToRawIntBits(v) ? prev : Float.valueOf(v);
}
| 重载 | 相等判据 | 行 |
|---|---|---|
boxed(Object, float) |
Float.floatToRawIntBits 逐位比较 —— 保留 NaN 载荷差异与 -0.0f |
:7-9 |
boxed(Object, int) |
== |
:10-12 |
⚠️ float 版故意不用 Float.equals。Float.equals 用 floatToIntBits,会把所有 NaN 折叠成一个、也会把 +0.0f 和 -0.0f 判成相等;这里用 floatToRawIntBits 是为了区分。这是有意的行为差异,不是笔误。
两个重载都是三目返回旧引用,不修改外部状态。
已知问题 / 风险
- 全包私有,无对外 API。
PacerCore的每个成员(含beginFrame、beforePresent)都是默认访问。第三方 mod 无法复用这套节流器,只能整体 fork。 - 单槽 pending 采样有丢失窗口。
onGateSample(:134-139)无条件覆盖pendingDurationNanos/pendingEndNanos(除durationNanos <= 0外)。若两次beginFrame之间来了两次 gate 采样,前一次被静默丢弃,且没有任何日志。beginFrame里的sample判定(:142)只能靠pendingEndNanos > lastGateEndNanos过滤掉倒退的样本,无法恢复被覆盖的样本。 idleWork抛异常会污染节流状态。:218直接work.accept(idleDeadline),无 try/catch。idleRing[idleSlot]写不进去,lastIdleOvershootNanos停在上一帧的值,下一帧的预算判断会偏。抛出的异常也会一路冒泡出beginFrame。sleeper字段在FramePacer里是private static但被测试反射改写(FramePacerTest.java:101),且PacerSleeper构造时捕获了sleeper之外的PacerCore引用关系——FramePacer.java:101-102必须同时替换两个字段,顺序错了会让PacerCore仍指向旧 sleeper。这是测试侧的隐式契约。- 数字全在代码里,没有配置项。
CPU_SLOTS、OVERRUN_SLOTS、SLOTS、LOCK_FRAMES等均不可调,帧率环境变了只能改代码重编。
相关条目
- FpsReducer - 另一条限帧路径(降上限),与本子系统互不调用
- Celeritas(内嵌地形渲染引擎) -
FramePacer.setIdleWork的接入方(ClientProxy.java:163-166) - AngelicaModulesConfig -
enableThreadedChunkBuilding等门控 - GpuFrameLagMeter - 同属
rendering//profiling/的时序测量