fix(effects): снизить лаги сцены на Mac

Pixi режется по contentRect, пульт идёт в preview (12 FPS, 0.5 DPR), штампы тумана/огня/дождя прореживаются, draft темноты и маркера не чаще 20 Гц.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
     Фонтош Иван Сергеевич
2026-09-07 17:13:37 +08:00
parent 34dd39791b
commit 48a9c933af
11 changed files with 215 additions and 34 deletions
+7
View File
@@ -95,6 +95,8 @@ import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
/** Длительность молнии: быстрый удар + акцент в точке попадания. */
const LIGHTNING_EFFECT_MS = 840;
/** Темнота/маркер на презентацию — не чаще 20 Гц (полный canvas replay в обоих окнах). */
const OVERLAY_DRAFT_IPC_MS = 50;
function dispositionMakeKey(d: NpcDisposition): string {
if (d === 'hostile') return 'npcs.makeHostile';
@@ -305,6 +307,7 @@ export function ControlApp() {
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
const draftPaintRafRef = useRef(0);
const overlayDraftIpcAtRef = useRef(0);
const effectsOverlayRef = useRef<PixiEffectsOverlayHandle | null>(null);
const [explosionDraft, setExplosionDraft] = useState<ExplosionInstance | null>(null);
const draftMetaRef = useRef<{ createdAtMs: number; seed: number } | null>(null);
@@ -1387,6 +1390,9 @@ export function ControlApp() {
draftPaintRafRef.current = 0;
pushDraftToPixi();
const b = brushRef.current;
const now = performance.now();
if (now - overlayDraftIpcAtRef.current < OVERLAY_DRAFT_IPC_MS) return;
overlayDraftIpcAtRef.current = now;
if (
(b?.tool === 'exploreBrush' || b?.tool === 'closeBrush') &&
b.points
@@ -2512,6 +2518,7 @@ export function ControlApp() {
<PixiEffectsOverlay
ref={effectsOverlayRef}
state={fxState}
perfProfile="preview"
style={{ zIndex: 6 }}
viewport={
previewContentRect
@@ -32,6 +32,8 @@ void test('ControlApp: draft кисти без корневого draftFxTick',
assert.ok(pixi.includes('setDraft'));
assert.ok(pixi.includes('mergeDraftState'));
assert.ok(pixi.includes('forwardRef'));
assert.ok(app.includes('perfProfile="preview"'));
assert.ok(app.includes('OVERLAY_DRAFT_IPC_MS'));
});
void test('ControlApp: path playback не гоняет setState корня', () => {
@@ -1,9 +1,16 @@
.host {
position: absolute;
inset: 0;
contain: layout paint;
}
.hostFill {
inset: 0;
}
.hostBound {
overflow: hidden;
}
.hostInteractive {
pointer-events: auto;
}
@@ -49,3 +49,16 @@ void test('PxiEffectsOverlay: эффекты перекладываются пр
assert.ok(src.includes('viewportSig'));
assert.match(src, /\$\{contentSig\}@\$\{viewportSig\(viewport\)\}/);
});
void test('PxiEffectsOverlay: canvas по contentRect + preview profile', () => {
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
const css = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.module.css'), 'utf8');
assert.ok(css.includes('.hostBound'));
assert.ok(css.includes('.hostFill'));
assert.match(src, /perfProfile\s*=\s*'live'/);
assert.match(src, /resolution:\s*preview\s*\?\s*0\.5\s*:\s*dpr/);
assert.match(src, /maxFPS\s*=\s*preview\s*\?\s*12\s*:\s*32/);
assert.ok(src.includes('decimateFieldStampPoints'));
assert.ok(src.includes('fieldStampPoints'));
assert.match(src, /viewportRef\.current = \{ x: 0, y: 0, w: viewport\.w/);
});
@@ -1,5 +1,6 @@
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { decimateFieldStampPoints, fieldStampMinSpacingN } from '../../../shared/fieldEffectStampPoints';
import type {
EffectInstance,
EffectInstanceType,
@@ -9,6 +10,10 @@ import type {
import styles from './PxiEffectsOverlay.module.css';
function fieldStampPoints(inst: Extract<EffectInstance, { type: 'fog' | 'fire' | 'rain' }>) {
return decimateFieldStampPoints(inst.points, fieldStampMinSpacingN(inst.type, inst.radiusN));
}
export type PixiEffectsOverlayHandle = {
/** Draft-штрих без React re-render родителя; null — сброс. */
setDraft: (instance: EffectInstance | null) => void;
@@ -193,6 +198,11 @@ type Props = {
style?: React.CSSProperties;
/** Область “контента” внутри контейнера (например при object-fit: contain). */
viewport?: { x: number; y: number; w: number; h: number } | undefined;
/**
* `preview` — пульт: ниже FPS, меньший backing store, без покачивания спрайтов.
* Презентация остаётся `live`.
*/
perfProfile?: 'live' | 'preview';
};
/**
@@ -203,7 +213,7 @@ type Props = {
* - Draft кисти идёт через `setDraft` (imperative), без re-render ControlApp.
*/
export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(function PixiEffectsOverlay(
{ state, interactive = false, style, viewport },
{ state, interactive = false, style, viewport, perfProfile = 'live' },
ref,
) {
const hostRef = useRef<HTMLDivElement | null>(null);
@@ -320,17 +330,18 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
const pixi = await import('pixi.js');
pixiRef.current = pixi;
if (destroyed) return;
const preview = perfProfile === 'preview';
app = new pixi.Application();
await app.init({
backgroundAlpha: 0,
antialias: false,
powerPreference: 'high-performance',
resolution: dpr,
resolution: preview ? 0.5 : dpr,
autoDensity: true,
preference: 'webgl',
});
// Меньше кадров — меньше CPU/GPU; анимации эффектов остаются плавными.
app.ticker.maxFPS = 32;
// live: 32 FPS; preview (пульт): 12 — второй WebGL поверх видео на Mac дешевле.
app.ticker.maxFPS = preview ? 12 : 32;
if (destroyed) return;
host.appendChild(app.canvas);
// Canvas по умолчанию перехватывает hit-test; оставляем клики «сквозь» оверлей для слоя кисти сверху.
@@ -370,12 +381,15 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
const s = stateRef.current;
if (!s || s.instances.length === 0) return;
const nowMs = Date.now() + timeOffsetRef.current;
animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current);
const motion = !preview;
animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current, {
motion,
});
// Лёгкое “потряхивание” сцены в момент удара молнии.
// Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику.
const rootNode = rootRef.current;
if (rootNode) {
if (rootNode && motion) {
const { x, y } = computeSceneShake(s, nowMs, sizeRef.current);
rootNode.x = x;
rootNode.y = y;
@@ -404,7 +418,7 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
host.replaceChildren();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gpuBoot]);
}, [gpuBoot, perfProfile]);
useEffect(() => {
const host = hostRef.current;
@@ -420,9 +434,9 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
}, [state]);
useEffect(() => {
// Если viewport не задан — используем “весь холст”.
// Host уже стоит на contentRect — в Pixi локальные координаты с (0,0).
viewportProvidedRef.current = Boolean(viewport);
if (viewport) viewportRef.current = viewport;
if (viewport) viewportRef.current = { x: 0, y: 0, w: viewport.w, h: viewport.h };
else viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
const pixi = pixiRef.current;
const root = rootRef.current;
@@ -437,9 +451,22 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
}
}, [viewport]);
const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' ');
const hostClass = [
styles.host,
viewport ? styles.hostBound : styles.hostFill,
interactive ? styles.hostInteractive : styles.hostPassthrough,
].join(' ');
const hostStyle: React.CSSProperties = viewport
? {
...style,
left: viewport.x,
top: viewport.y,
width: viewport.w,
height: viewport.h,
}
: { ...style };
return <div ref={hostRef} className={hostClass} style={style} />;
return <div ref={hostRef} className={hostClass} style={hostStyle} />;
});
function syncNodes(
@@ -530,8 +557,9 @@ function createInstanceNode(
const r = inst.radiusN * minDim;
const textures = getFogVfxFrameTextures(pixi);
const c = new pixi.Container();
for (let i = 0; i < inst.points.length; i += 1) {
const p = inst.points[i];
const stamps = fieldStampPoints(inst);
for (let i = 0; i < stamps.length; i += 1) {
const p = stamps[i];
if (!p) continue;
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
s.anchor?.set?.(0.5, 0.5);
@@ -570,8 +598,9 @@ function createInstanceNode(
const r = inst.radiusN * minDim;
const textures = getGroundFireVfxFrameTextures(pixi);
const c = new pixi.Container();
for (let i = 0; i < inst.points.length; i += 1) {
const p = inst.points[i];
const stamps = fieldStampPoints(inst);
for (let i = 0; i < stamps.length; i += 1) {
const p = stamps[i];
if (!p) continue;
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
s.anchor?.set?.(0.5, 0.62);
@@ -613,8 +642,9 @@ function createInstanceNode(
const r = inst.radiusN * minDim;
const textures = getRainVfxFrameTextures(pixi);
const c = new pixi.Container();
for (let i = 0; i < inst.points.length; i += 1) {
const p = inst.points[i];
const stamps = fieldStampPoints(inst);
for (let i = 0; i < stamps.length; i += 1) {
const p = stamps[i];
if (!p) continue;
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
s.anchor?.set?.(0.5, 0.5);
@@ -885,6 +915,7 @@ function animateNodes(
nowMs: number,
size: { w: number; h: number },
viewport: { x: number; y: number; w: number; h: number },
opts?: { motion?: boolean },
) {
const instById = new Map(state.instances.map((i) => [i.id, i]));
void pixi;
@@ -940,12 +971,14 @@ function animateNodes(
child.texture = frame;
}
}
const wobble = typeof fx.wobble === 'number' ? fx.wobble : 2;
child.x = fx.bx + Math.sin(0.0016 * t + (fx.phase ?? 0) * 6.28) * wobble;
child.y = fx.by + Math.cos(0.0019 * t + (fx.phase ?? 0) * 6.28) * wobble * 0.45;
const local = 0.93 + 0.07 * Math.sin(0.0042 * t + (fx.phase ?? 0) * 12.56);
child.width = (typeof fx.w0 === 'number' ? fx.w0 : child.width) * local;
child.height = (typeof fx.h0 === 'number' ? fx.h0 : child.height) * (0.96 + (local - 0.93) * 0.7);
if (opts?.motion !== false) {
const wobble = typeof fx.wobble === 'number' ? fx.wobble : 2;
child.x = fx.bx + Math.sin(0.0016 * t + (fx.phase ?? 0) * 6.28) * wobble;
child.y = fx.by + Math.cos(0.0019 * t + (fx.phase ?? 0) * 6.28) * wobble * 0.45;
const local = 0.93 + 0.07 * Math.sin(0.0042 * t + (fx.phase ?? 0) * 12.56);
child.width = (typeof fx.w0 === 'number' ? fx.w0 : child.width) * local;
child.height = (typeof fx.h0 === 'number' ? fx.h0 : child.height) * (0.96 + (local - 0.93) * 0.7);
}
child.alpha = 1;
}
}
@@ -991,9 +1024,11 @@ function animateNodes(
}
}
const alphaMul = typeof fx.alphaMul === 'number' ? fx.alphaMul : 1;
const wob = typeof fx.drift === 'number' ? fx.drift : 0;
child.x = fx.bx + Math.sin(0.0011 * t + (fx.phase ?? 0) * 6.28) * wob;
child.y = fx.by + Math.cos(0.0014 * t + (fx.phase ?? 0) * 6.28) * wob * 0.35;
if (opts?.motion !== false) {
const wob = typeof fx.drift === 'number' ? fx.drift : 0;
child.x = fx.bx + Math.sin(0.0011 * t + (fx.phase ?? 0) * 6.28) * wob;
child.y = fx.by + Math.cos(0.0014 * t + (fx.phase ?? 0) * 6.28) * wob * 0.35;
}
child.width = typeof fx.w0 === 'number' ? fx.w0 : child.width;
child.height = typeof fx.h0 === 'number' ? fx.h0 : child.height;
child.alpha = Math.max(0.25, Math.min(1, inst.opacity * base * alphaMul));
@@ -1867,10 +1902,11 @@ function relayoutInstanceNode(
if (inst.type === 'fog') {
const r = inst.radiusN * minDim;
const fogSize = Math.max(4, r * 2);
const stamps = fieldStampPoints(inst);
const children = node.children ?? [];
let pi = 0;
for (const child of children) {
const p = inst.points[pi];
const p = stamps[pi];
pi += 1;
if (!p) continue;
const fx = (child as any).__fx ?? {};
@@ -1891,10 +1927,11 @@ function relayoutInstanceNode(
const r = inst.radiusN * minDim;
const flameWidth = Math.max(r * 3.2, minDim * 0.05);
const flameHeight = flameWidth / GROUND_FIRE_VFX_FRAME_ASPECT;
const stamps = fieldStampPoints(inst);
const children = node.children ?? [];
let pi = 0;
for (const child of children) {
const p = inst.points[pi];
const p = stamps[pi];
pi += 1;
if (!p) continue;
const fx = (child as any).__fx ?? {};
@@ -1915,10 +1952,11 @@ function relayoutInstanceNode(
const r = inst.radiusN * minDim;
const rainWidth = Math.max(r * 3.8, minDim * 0.08);
const rainHeight = rainWidth / RAIN_VFX_FRAME_ASPECT;
const stamps = fieldStampPoints(inst);
const children = node.children ?? [];
let pi = 0;
for (const child of children) {
const p = inst.points[pi];
const p = stamps[pi];
pi += 1;
if (!p) continue;
const fx = (child as any).__fx ?? {};
@@ -114,7 +114,7 @@ export function SceneDarknessOverlay({
const h = Math.max(1, Math.round(viewport.h));
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
if (!ctx) return;
ctx.globalCompositeOperation = 'source-over';
@@ -98,7 +98,7 @@ export function SceneMarkerOverlay({ state, viewport, style }: SceneMarkerOverla
const h = Math.max(1, Math.round(viewport.h));
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
@@ -70,7 +70,7 @@ export function SceneGridOverlay({ grid, viewport }: Props) {
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);