feedback7 R22-HUD: the cached-atlas refreshes of a crowded battle take their turns from this frame's update order, not random phases — each frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units (step 1 of GitHub #327, PR #334 by @Cloudnyco); golden: unchanged

Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no dependency).
UnitView._updateImpostor refreshed a unit when (frame + phase) % interval === 0 with a random phase per unit, so the
refreshes of a frame were binomial: reproduced on the parent with 7 pen figures at interval 3 — 5 1 1 5 1 1 … and
2 4 1 2 4 1 … refreshes per frame instead of 2 / 3. Now render/app.js hands every impostor unit the next slot of the frame
(ctx.impostorSlot, a counter reset in frameBody), the unit refreshes when (frame + slot) % interval === 0, and a unit
whose slot keeps moving with the frame (the units before it culled on and off in step) is still refreshed after 2
intervals (imp.last); a context without slots keeps the random phase. Each unit's rate and the total work are unchanged;
no sim or pixel-as-state change.
Step 2 of #327 (a time budget by priority) is not part of this and still needs a decision.

- Tests: test/render/unitview.test.js 'impostor refresh slots' (3: slots in update order — fails on the parent, the
  starvation cap, the no-slots context); unit view / render suites unchanged.
- Docs: design/client.md. Golden: unchanged (client only).
This commit is contained in:
Cloudnyco authored and sganggs committed 2026-10-08 19:22:08 +08:00
1 parent 3484178e2e
commit bc11c0d61a
4 files changed
+86 -3

No files matched your search

+1 -1
View File
@@ -32,7 +32,7 @@ view.resize() ; view.destroy()
`createFieldView(host, { …, signal })` takes an `AbortSignal` (0.2.2, GitHub PR #302 by @siruimei07): an abort during the build — the Pixi load, the manifest, the fonts, the board art and the 3D board waits — destroys what the attempt made (the canvas, the ticker, the listeners, the observers) and rejects, a failure while it is built releases the same partial resources, and `view.destroy()` is idempotent. `ui/fieldHost.js mountFieldView(host, { signal })` aborts that signal when the engine fails, times out (12 s) or the owner unmounts, disposes a view a factory returns after that, and never clears the other children of `host` — the simplified view and a newer mount share it. `createFieldView(host, { …, signal })` takes an `AbortSignal` (0.2.2, GitHub PR #302 by @siruimei07): an abort during the build — the Pixi load, the manifest, the fonts, the board art and the 3D board waits — destroys what the attempt made (the canvas, the ticker, the listeners, the observers) and rejects, a failure while it is built releases the same partial resources, and `view.destroy()` is idempotent. `ui/fieldHost.js mountFieldView(host, { signal })` aborts that signal when the engine fails, times out (12 s) or the owner unmounts, disposes a view a factory returns after that, and never clears the other children of `host` — the simplified view and a newer mount share it.
Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units (a crowd draws its Spine units through the cached atlas, `render/impostor.js`, each refreshed every k-th frame: the refresh turns follow this frame's update order — `ctx.impostorSlot()`, a counter reset at the start of every frame — so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units instead of whatever random per-unit phases gave, and a unit not refreshed for 2k frames is refreshed regardless; GitHub PR #334 by @Cloudnyco, issue #327 step 1 — its step 2, a time budget by priority, is not done). Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1).
--- ---
+6
View File
@@ -316,6 +316,7 @@ export async function createFieldView(host, options = {}) {
renderer: app.renderer, renderer: app.renderer,
frameNo: () => frameNo, frameNo: () => frameNo,
impostorInterval: () => impInterval, impostorInterval: () => impInterval,
impostorSlot: () => impSlot++,
clipAllowed: () => clipAllowed, clipAllowed: () => clipAllowed,
viewport: () => vp, viewport: () => vp,
loadLevel: () => loadLevel, loadLevel: () => loadLevel,
@@ -1565,6 +1566,10 @@ export async function createFieldView(host, options = {}) {
let cpuMs = 0; let cpuMs = 0;
let frameNo = 0; let frameNo = 0;
let impInterval = 0; let impInterval = 0;
// Each frame the impostor units take slots 0, 1, 2 … in update order (units.js _updateImpostor): a unit refreshes when
// (frame + slot) % interval === 0, so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units. Random per-unit phases left the
// busiest frame 20–66% above that (16–48 units, intervals 2–6) and set the frame-time peaks of a crowded battle.
let impSlot = 0;
let vp = { width: s0.width, height: s0.height }; // viewport (CSS px) of this frame: unit culling let vp = { width: s0.width, height: s0.height }; // viewport (CSS px) of this frame: unit culling
let culledCount = 0; let culledCount = 0;
// Adaptive load level 0–3: a device that cannot hold the frame rate with the current work switches crowds to // Adaptive load level 0–3: a device that cannot hold the frame rate with the current work switches crowds to
@@ -1605,6 +1610,7 @@ export async function createFieldView(host, options = {}) {
} }
function frameBody(now) { function frameBody(now) {
frameNo++; frameNo++;
impSlot = 0;
if (frameNo % 30 === 1) { if (frameNo % 30 === 1) {
impInterval = pickImpostorInterval(); clipAllowed = pickClipping(); impInterval = pickImpostorInterval(); clipAllowed = pickClipping();
culledCount = 0; culledCount = 0;
+8 -2
View File
@@ -1457,16 +1457,22 @@ export class UnitView {
if (this.actor.spine.parent) this.actor.spine.parent.removeChild(this.actor.spine); if (this.actor.spine.parent) this.actor.spine.parent.removeChild(this.actor.spine);
if (atlas) atlas.park(this.actor.spine); if (atlas) atlas.park(this.actor.spine);
this.body.addChild(sprite); this.body.addChild(sprite);
this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true }; this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true, last: 0 };
} }
const imp = this.imp; const imp = this.imp;
imp.acc += animDt; imp.acc += animDt;
const frame = this.ctx.frameNo ? this.ctx.frameNo() : 0; const frame = this.ctx.frameNo ? this.ctx.frameNo() : 0;
const due = imp.dirty || interval <= 1 || (frame + imp.phase) % interval === 0 || Math.abs(sc - imp.sc) > imp.sc * 0.12; // the view's slot in this frame's update order (app.js impostorSlot) spreads the refreshes evenly over the interval;
// a context without slots keeps the random phase. A unit whose slot keeps moving with the frame (the units before it
// culled on and off in step) is still refreshed after 2 intervals at the latest.
const turn = this.ctx.impostorSlot ? this.ctx.impostorSlot() : imp.phase;
const due = imp.dirty || interval <= 1 || (frame + turn) % interval === 0 || frame - imp.last >= 2 * interval
|| Math.abs(sc - imp.sc) > imp.sc * 0.12;
if (due) { if (due) {
this.actor.update(imp.acc); this.actor.update(imp.acc);
imp.acc = 0; imp.acc = 0;
imp.dirty = false; imp.dirty = false;
imp.last = frame;
this._renderImpostor(sc, atlas); this._renderImpostor(sc, atlas);
} }
const k = imp.sc > 0 ? sc / imp.sc : 1; const k = imp.sc > 0 ? sc / imp.sc : 1;
+71
View File
@@ -279,6 +279,77 @@ describe('enemy preview pen figures (lod idle)', () => {
}); });
}); });
// The impostor refreshes of a frame are spread evenly over the interval (app.js impostorSlot: each frame the impostor
// units take slots 0, 1, 2 … in update order): a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units. With a random phase per
// unit, the busiest frame did 20–66% more refreshes than that (16–48 units, intervals 2–6) — the frame-time peaks of a
// crowded battle on a slow device.
describe('impostor refresh slots', () => {
/** n pen figures (lod idle: interval 3) in one context; `slotOf(frame, i)` gives view i its slot, or null: no slots. */
async function crowd(n, slotOf) {
let frame = 0;
let i = 0;
const atlas = {
alloc: (w, h) => ({ w, h, tex: new fake.P.Texture(), clip: false }),
free() {}, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() {},
};
const ctx = fakeViewCtx(fake.P, {
assets: store({ spine: true }), cam, frameNo: () => frame, impostors: atlas,
renderer: { resolution: 1, render() {} },
...(slotOf ? { impostorSlot: () => slotOf(frame, i) } : {}),
});
const views = [];
for (let k = 0; k < n; k++) {
views.push(new UnitView(ctx, { id: `e:${k}`, side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', tier: 1, x: 9, y: 15, maxHp: 1, facing: -1 }, { prep: true, lod: 'idle' }));
}
await tick(); await tick();
const refreshed = views.map(() => []);
views.forEach((v, k) => {
assert.ok(v.spineReady, 'spine ready');
const upd = v.actor.update.bind(v.actor);
v.actor.update = (dt) => { refreshed[k].push(frame); return upd(dt); };
});
const step = () => { views.forEach((v, k) => { i = k; v.update(1 / 60, cam(), frame / 60); }); frame++; };
return { step, refreshed, frames: () => frame };
}
const perFrame = (refreshed, from, to) => {
const c = new Map();
for (const list of refreshed) for (const f of list) if (f >= from && f < to) c.set(f, (c.get(f) || 0) + 1);
return Array.from({ length: to - from }, (_, j) => c.get(from + j) || 0);
};
test('slots in update order: every frame refreshes ⌊n/3⌋ or ⌈n/3⌉ units, each unit every 3rd frame', async () => {
for (const n of [7, 16, 24]) {
const { step, refreshed } = await crowd(n, (frame, k) => k);
for (let f = 0; f < 31; f++) step();
// frame 0: every new impostor is drawn once (dirty); from frame 1 on, the interval rules
const counts = perFrame(refreshed, 1, 31);
assert.ok(counts.every((c) => c === Math.floor(n / 3) || c === Math.ceil(n / 3)), `${n} units: ${counts.join(' ')}`);
for (const list of refreshed) {
const after = list.filter((f) => f >= 1);
assert.equal(after.length, 10, `each unit refreshed 10 times in 30 frames (${after.join(',')})`);
assert.ok(after.every((f, j) => j === 0 || f - after[j - 1] === 3), 'exactly every 3rd frame');
}
}
});
test('a slot that moves with the frame never starves a unit: refreshed at least every 2 intervals', async () => {
// (frame + slot) % 3 is never 0: the turn alone would never come
const { step, refreshed } = await crowd(3, (frame) => 4 - (frame % 3));
for (let f = 0; f < 31; f++) step();
for (const list of refreshed) {
const after = list.filter((f) => f >= 1);
assert.ok(after.length >= 5, `refreshed ${after.length} times`);
assert.ok([0, ...after].every((f, j, a) => j === 0 || f - a[j - 1] <= 6), `gaps ≤ 6 frames (${after.join(',')})`);
}
});
test('without slots (a context that has none) the random phase still refreshes every 3rd frame', async () => {
const { step, refreshed } = await crowd(5, null);
for (let f = 0; f < 31; f++) step();
for (const list of refreshed) assert.equal(list.filter((f) => f >= 1).length, 10);
});
});
// Player report 2026-10-05: 「无人机等飞行单位贴图位置明显偏低」, and the follow-up "绝对不止 0.35" with an official // Player report 2026-10-05: 「无人机等飞行单位贴图位置明显偏低」, and the follow-up "绝对不止 0.35" with an official
// screenshot of 帝国炮火先兆者 over a tile (PR #211 by @xcdoge; the owner's decision of 2026-10-06). The lift is the // screenshot of 帝国炮火先兆者 over a tile (PR #211 by @xcdoge; the owner's decision of 2026-10-06). The lift is the
// official client's own single constant — Vector3(0, 0.35, 0) written by Torappu.Battle.CharacterAnimator's constructor // official client's own single constant — Vector3(0, 0.35, 0) written by Torappu.Battle.CharacterAnimator's constructor