perf: cut ControlApp re-renders and lazy-load VFX packs

Move audio scrub/volume and brush drafts off root React ticks, coalesce overlay layout IPC, cache machine fingerprint and asset URLs, share one scene overlay host, and stop idle Pixi ticker. Also harden console against EPIPE on window load failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-23 11:58:16 +08:00
parent 32a5479086
commit d9fbecf5a7
29 changed files with 1825 additions and 585 deletions
+221 -27
View File
@@ -1,9 +1,140 @@
import React, { useEffect, useMemo, useRef } from 'react';
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import type { EffectsState, EffectInstance } from '../../../shared/types/effects';
import type {
EffectInstance,
EffectInstanceType,
EffectsState,
EffectToolType,
} from '../../../shared/types/effects';
import styles from './PxiEffectsOverlay.module.css';
export type PixiEffectsOverlayHandle = {
/** Draft-штрих без React re-render родителя; null — сброс. */
setDraft: (instance: EffectInstance | null) => void;
};
/** Наборы кадровых VFX — подгружаем только по tool / живым instances. */
type VfxFramePack =
| 'fog'
| 'fire'
| 'rain'
| 'water'
| 'lightning'
| 'sunbeam'
| 'poisonCloud';
function mergeDraftState(
state: EffectsState | null,
draft: EffectInstance | null,
): EffectsState | null {
if (!draft) return state;
if (!state) {
return {
revision: 0,
serverNowMs: Date.now(),
tool: { tool: 'fog', radiusN: 0.05, intensity: 1 },
instances: [draft],
};
}
const rest = state.instances.filter((i) => i.id !== '__draft__');
return { ...state, instances: [...rest, draft] };
}
function vfxPacksForTool(tool: EffectToolType): readonly VfxFramePack[] {
switch (tool) {
case 'fog':
return ['fog'];
case 'fire':
return ['fire'];
case 'rain':
return ['rain'];
case 'water':
return ['water'];
case 'lightning':
return ['lightning'];
case 'sunbeam':
return ['sunbeam'];
case 'poisonCloud':
return ['poisonCloud'];
default:
return [];
}
}
function vfxPacksForInstanceType(type: EffectInstanceType): readonly VfxFramePack[] {
switch (type) {
case 'fog':
return ['fog'];
case 'fire':
return ['fire'];
case 'rain':
return ['rain'];
case 'water':
return ['water'];
case 'lightning':
return ['lightning'];
case 'sunbeam':
return ['sunbeam'];
case 'poisonCloud':
return ['poisonCloud'];
default:
return [];
}
}
function collectNeededVfxPacks(state: EffectsState | null): VfxFramePack[] {
if (!state) return [];
const packs = new Set<VfxFramePack>();
for (const p of vfxPacksForTool(state.tool.tool)) packs.add(p);
for (const inst of state.instances) {
for (const p of vfxPacksForInstanceType(inst.type)) packs.add(p);
}
return [...packs];
}
function preloadVfxFramePack(pixi: any, pack: VfxFramePack): void {
switch (pack) {
case 'fog':
void preloadFogVfxFrameTextures(pixi);
break;
case 'fire':
void preloadGroundFireVfxFrameTextures(pixi);
break;
case 'rain':
void preloadRainVfxFrameTextures(pixi);
break;
case 'water':
void preloadWaterVfxFrameTextures(pixi);
break;
case 'lightning':
void preloadLightningVfxFrameTextures(pixi);
void preloadElectricAccentFrameTextures(pixi);
break;
case 'sunbeam':
void preloadPulseDischargeFrameTextures(pixi);
break;
case 'poisonCloud':
void preloadDustBurstFrameTextures(pixi);
break;
default: {
const _exhaustive: never = pack;
void _exhaustive;
}
}
}
function ensureVfxPacksForState(pixi: any, state: EffectsState | null): void {
if (!pixi) return;
for (const pack of collectNeededVfxPacks(state)) {
preloadVfxFramePack(pixi, pack);
}
}
function effectsHaveWork(state: EffectsState | null): boolean {
return Boolean(state && state.instances.length > 0);
}
const LIGHTNING_VFX_FRAME_COUNT = 19;
const LIGHTNING_VFX_FRAME_ASPECT = 420 / 473;
const LIGHTNING_VFX_STRIKE_MS = 320;
@@ -52,18 +183,81 @@ type Props = {
* - Pixi `Application` — это WebGL-рендерер + тикер.
* - Мы держим один `Application` на компонент, и при изменении `state` просто перерисовываем сцену.
* - Вариант A: рисуем "инстансы эффектов" (данные), а не пиксели.
* - Draft кисти идёт через `setDraft` (imperative), без re-render ControlApp.
*/
export function PixiEffectsOverlay({ state, interactive = false, style, viewport }: Props) {
export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(function PixiEffectsOverlay(
{ state, interactive = false, style, viewport },
ref,
) {
const hostRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<any>(null);
const rootRef = useRef<any>(null);
const pixiRef = useRef<any>(null);
const nodesRef = useRef<Map<string, any>>(new Map());
const committedStateRef = useRef<EffectsState | null>(null);
const draftRef = useRef<EffectInstance | null>(null);
const stateRef = useRef<EffectsState | null>(null);
const timeOffsetRef = useRef(0);
const sizeRef = useRef<{ w: number; h: number }>({ w: 1, h: 1 });
const viewportRef = useRef<{ x: number; y: number; w: number; h: number }>({ x: 0, y: 0, w: 1, h: 1 });
const viewportProvidedRef = useRef(false);
/** null = ещё не синхронизировали с Pixi (ticker по умолчанию бежит). */
const tickerWantedRef = useRef<boolean | null>(null);
const syncTickerForState = (merged: EffectsState | null): void => {
const app = appRef.current;
if (!app?.ticker) return;
const want = effectsHaveWork(merged);
if (tickerWantedRef.current === want) return;
tickerWantedRef.current = want;
if (want) {
try {
app.ticker.start();
} catch {
/* ignore */
}
return;
}
const root = rootRef.current;
if (root) {
root.x = 0;
root.y = 0;
}
try {
app.ticker.stop();
} catch {
/* ignore */
}
};
const applyMergedState = (committed: EffectsState | null, draft: EffectInstance | null): void => {
committedStateRef.current = committed;
draftRef.current = draft;
const merged = mergeDraftState(committed, draft);
stateRef.current = merged;
if (merged) {
timeOffsetRef.current = merged.serverNowMs - Date.now();
}
const pixi = pixiRef.current;
const root = rootRef.current;
if (!pixi || !root) {
syncTickerForState(merged);
return;
}
ensureVfxPacksForState(pixi, merged);
syncNodes(pixi, root, nodesRef.current, merged, sizeRef.current, viewportRef.current);
syncTickerForState(merged);
};
useImperativeHandle(
ref,
() => ({
setDraft: (instance) => {
applyMergedState(committedStateRef.current, instance);
},
}),
[],
);
/** Снижаем resolution на HiDPI — меньше пикселей в WebGL, визуально ок для оверлея эффектов. */
const dpr = useMemo(() => Math.min(1.5, window.devicePixelRatio || 1), []);
@@ -121,32 +315,28 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
if (!viewportProvidedRef.current) {
viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
}
// Lazy VFX: только pack'и для текущего tool / уже размещённых instances (не все наборы сразу).
ensureVfxPacksForState(pixi, stateRef.current);
syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
void preloadLightningVfxFrameTextures(pixi);
void preloadElectricAccentFrameTextures(pixi);
void preloadFogVfxFrameTextures(pixi);
void preloadGroundFireVfxFrameTextures(pixi);
void preloadRainVfxFrameTextures(pixi);
void preloadWaterVfxFrameTextures(pixi);
void preloadPulseDischargeFrameTextures(pixi);
void preloadDustBurstFrameTextures(pixi);
// Animation loop: на каждом кадре обновляем свойства инстансов (alpha/дрейф/фликер).
// В idle (нет instances/draft) ticker останавливается — см. syncTickerForState.
app.ticker.add(() => {
const s = stateRef.current;
if (!s) return;
if (!s || s.instances.length === 0) return;
const nowMs = Date.now() + timeOffsetRef.current;
animateNodes(pixi, nodesRef.current, s, nowMs, sizeRef.current, viewportRef.current);
// Лёгкое “потряхивание” сцены в момент удара молнии.
// Делаем через смещение корневого контейнера, чтобы не вмешиваться в рендерер/камера-логику.
const root = rootRef.current;
if (root) {
const rootNode = rootRef.current;
if (rootNode) {
const { x, y } = computeSceneShake(s, nowMs, sizeRef.current);
root.x = x;
root.y = y;
rootNode.x = x;
rootNode.y = y;
}
});
syncTickerForState(stateRef.current);
cleanup = () => ro.disconnect();
} catch (e) {
@@ -181,16 +371,7 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
}, [interactive]);
useEffect(() => {
const app = appRef.current;
const root = rootRef.current;
if (!app || !root) return;
stateRef.current = state;
if (state) {
timeOffsetRef.current = state.serverNowMs - Date.now();
}
const pixi = pixiRef.current;
if (!pixi) return;
syncNodes(pixi, root, nodesRef.current, state, sizeRef.current, viewportRef.current);
applyMergedState(state, draftRef.current);
}, [state]);
useEffect(() => {
@@ -207,7 +388,7 @@ export function PixiEffectsOverlay({ state, interactive = false, style, viewport
const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' ');
return <div ref={hostRef} className={hostClass} style={style} />;
}
});
function syncNodes(
pixi: any,
@@ -236,6 +417,19 @@ function syncNodes(
const sig = instanceSig(inst, viewport);
const existing = nodes.get(inst.id);
if (existing && (existing as any).__sig === sig) continue;
// Water draft: перерисовываем Graphics in-place (без destroy/create на каждую точку).
if (
existing &&
inst.id === '__draft__' &&
inst.type === 'water' &&
(existing as any).__fx?.kind === 'waterDraft'
) {
const halfW = Math.max(1.5, inst.radiusN * Math.min(viewport.w, viewport.h));
redrawWaterDraft((existing as any).__fx.g, inst, viewport, halfW);
existing.alpha = Math.max(0.35, Math.min(0.95, inst.opacity * 1.1));
(existing as any).__sig = sig;
continue;
}
if (existing) {
const fx = (existing as any).__fx;
fx?.video?.pause?.();