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:
@@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import type { AssetId, NpcId, NpcsOverlayLayout, NpcsZoomTool } from '../../../shared/types';
|
||||
import { DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../../shared/types';
|
||||
import styles from '../materials/MaterialOverlay.module.css';
|
||||
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
|
||||
import { useAssetUrl } from '../useAssetImageUrl';
|
||||
|
||||
type Corner = 'nw' | 'ne' | 'sw' | 'se';
|
||||
@@ -23,6 +24,8 @@ type NpcsSceneOverlayProps = {
|
||||
rotateLabel?: string;
|
||||
onLayoutChange?: (npcId: NpcId, layout: NpcsOverlayLayout) => void;
|
||||
onZoomAt?: (npcId: NpcId | undefined, nx: number, ny: number) => void;
|
||||
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
function RotateIcon() {
|
||||
@@ -107,6 +110,11 @@ function NpcAvatarFrame({
|
||||
}) {
|
||||
const url = useAssetUrl(item.assetId);
|
||||
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
|
||||
const [draftLayout, setDraftLayout] = useState<NpcsOverlayLayout | null>(null);
|
||||
const pendingLayoutRef = useRef<NpcsOverlayLayout | null>(null);
|
||||
const draftRafRef = useRef(0);
|
||||
const onLayoutChangeRef = useRef(onLayoutChange);
|
||||
onLayoutChangeRef.current = onLayoutChange;
|
||||
const dragRef = useRef<
|
||||
| { mode: 'move'; startX: number; startY: number; origin: NpcsOverlayLayout }
|
||||
| {
|
||||
@@ -130,9 +138,15 @@ function NpcAvatarFrame({
|
||||
| null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragRef.current) return;
|
||||
setDraftLayout(null);
|
||||
pendingLayoutRef.current = null;
|
||||
}, [item.layout]);
|
||||
|
||||
if (!item.assetId || !url) return null;
|
||||
|
||||
const layout = item.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT;
|
||||
const layout = draftLayout ?? item.layout ?? DEFAULT_NPCS_OVERLAY_LAYOUT;
|
||||
const rotationDeg = layout.rotationDeg ?? 0;
|
||||
const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
|
||||
const w = base.w * layout.scale;
|
||||
@@ -140,6 +154,18 @@ function NpcAvatarFrame({
|
||||
const left = layout.cx * view.w - w / 2;
|
||||
const top = layout.cy * view.h - h / 2;
|
||||
|
||||
const publishDraft = (next: NpcsOverlayLayout): void => {
|
||||
pendingLayoutRef.current = next;
|
||||
if (draftRafRef.current !== 0) return;
|
||||
draftRafRef.current = window.requestAnimationFrame(() => {
|
||||
draftRafRef.current = 0;
|
||||
const pending = pendingLayoutRef.current;
|
||||
if (!pending) return;
|
||||
setDraftLayout(pending);
|
||||
onLayoutChangeRef.current?.(item.npcId, pending);
|
||||
});
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || !onLayoutChange) return;
|
||||
@@ -147,7 +173,7 @@ function NpcAvatarFrame({
|
||||
if (drag.mode === 'rotate') {
|
||||
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
|
||||
const delta = shortestAngleDelta(drag.startPointerAngle, angle);
|
||||
onLayoutChange(item.npcId, {
|
||||
publishDraft({
|
||||
...drag.origin,
|
||||
rotationDeg: drag.origin.rotationDeg + delta,
|
||||
});
|
||||
@@ -161,7 +187,7 @@ function NpcAvatarFrame({
|
||||
if (drag.mode === 'move') {
|
||||
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
|
||||
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
|
||||
onLayoutChange(item.npcId, {
|
||||
publishDraft({
|
||||
...drag.origin,
|
||||
cx: drag.origin.cx + dx,
|
||||
cy: drag.origin.cy + dy,
|
||||
@@ -220,7 +246,7 @@ function NpcAvatarFrame({
|
||||
const localCenterY = nextTop + hh / 2;
|
||||
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
|
||||
|
||||
onLayoutChange(item.npcId, {
|
||||
publishDraft({
|
||||
...origin,
|
||||
cx: origin.cx + screenOffset.x / Math.max(1, viewW),
|
||||
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
|
||||
@@ -232,6 +258,15 @@ function NpcAvatarFrame({
|
||||
dragRef.current = null;
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', endDrag);
|
||||
if (draftRafRef.current !== 0) {
|
||||
window.cancelAnimationFrame(draftRafRef.current);
|
||||
draftRafRef.current = 0;
|
||||
}
|
||||
const finalLayout = pendingLayoutRef.current;
|
||||
if (finalLayout) {
|
||||
setDraftLayout(finalLayout);
|
||||
onLayoutChangeRef.current?.(item.npcId, finalLayout);
|
||||
}
|
||||
};
|
||||
|
||||
const layoutCenterClient = () => {
|
||||
@@ -288,6 +323,7 @@ function NpcAvatarFrame({
|
||||
<div
|
||||
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
|
||||
data-npc-id={item.npcId}
|
||||
data-overlay-kind="npc"
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
@@ -352,19 +388,36 @@ export function NpcsSceneOverlay({
|
||||
rotateLabel = 'Rotate',
|
||||
onLayoutChange,
|
||||
onZoomAt,
|
||||
embedded = false,
|
||||
}: NpcsSceneOverlayProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const [view, setView] = useState({ w: 1, h: 1 });
|
||||
const host = useSceneOverlayView();
|
||||
const localRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const rootRef = embedded && host ? host.rootRef : localRootRef;
|
||||
const [localView, setLocalView] = useState({ w: 1, h: 1 });
|
||||
const view = embedded && host ? host.view : localView;
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current;
|
||||
if (embedded) return;
|
||||
const el = localRootRef.current;
|
||||
if (!el) return;
|
||||
const sync = () => setView({ w: el.clientWidth, h: el.clientHeight });
|
||||
let raf = 0;
|
||||
const sync = () => {
|
||||
if (raf !== 0) return;
|
||||
raf = window.requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
const w = Math.max(1, el.clientWidth);
|
||||
const h = Math.max(1, el.clientHeight);
|
||||
setLocalView((prev) => (prev.w === w && prev.h === h ? prev : { w, h }));
|
||||
});
|
||||
};
|
||||
sync();
|
||||
const ro = new ResizeObserver(sync);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [items.length]);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
if (raf !== 0) window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [embedded, items.length]);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
@@ -389,9 +442,38 @@ export function NpcsSceneOverlay({
|
||||
return raw ? (raw as NpcId) : undefined;
|
||||
};
|
||||
|
||||
const frames = items.map((item) =>
|
||||
onLayoutChange ? (
|
||||
<NpcAvatarFrame
|
||||
key={item.npcId}
|
||||
item={item}
|
||||
view={view}
|
||||
rootRef={rootRef}
|
||||
editable={editable}
|
||||
zoomTool={zoomTool}
|
||||
rotateLabel={rotateLabel}
|
||||
onLayoutChange={onLayoutChange}
|
||||
/>
|
||||
) : (
|
||||
<NpcAvatarFrame
|
||||
key={item.npcId}
|
||||
item={item}
|
||||
view={view}
|
||||
rootRef={rootRef}
|
||||
editable={editable}
|
||||
zoomTool={zoomTool}
|
||||
rotateLabel={rotateLabel}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return <>{frames}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
ref={localRootRef}
|
||||
className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
@@ -405,30 +487,7 @@ export function NpcsSceneOverlay({
|
||||
}}
|
||||
>
|
||||
<div className={styles.dim} />
|
||||
{items.map((item) =>
|
||||
onLayoutChange ? (
|
||||
<NpcAvatarFrame
|
||||
key={item.npcId}
|
||||
item={item}
|
||||
view={view}
|
||||
rootRef={rootRef}
|
||||
editable={editable}
|
||||
zoomTool={zoomTool}
|
||||
rotateLabel={rotateLabel}
|
||||
onLayoutChange={onLayoutChange}
|
||||
/>
|
||||
) : (
|
||||
<NpcAvatarFrame
|
||||
key={item.npcId}
|
||||
item={item}
|
||||
view={view}
|
||||
rootRef={rootRef}
|
||||
editable={editable}
|
||||
zoomTool={zoomTool}
|
||||
rotateLabel={rotateLabel}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{frames}
|
||||
{showClose ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user