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
@@ -12,6 +12,15 @@
pointer-events: none;
}
/** Общий host: клики проходят сквозь dim к сцене; кадры/кнопки ловят сами. */
.hostHitThrough {
pointer-events: none;
}
.captureZoom {
pointer-events: auto;
}
.cursorZoomIn {
cursor: zoom-in;
}
@@ -89,11 +98,22 @@
cursor: nwse-resize;
}
.close {
.closeStack {
position: absolute;
top: 14px;
right: 14px;
z-index: 3;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.close {
position: relative;
top: auto;
right: auto;
z-index: 3;
width: 36px;
height: 36px;
border: none;
@@ -105,12 +125,20 @@
cursor: pointer;
display: grid;
place-items: center;
pointer-events: auto;
}
.close:hover {
background: rgba(24, 24, 32, 0.9);
}
/** Standalone-оверлей (без host): одна кнопка в углу. */
.root > .close {
position: absolute;
top: 14px;
right: 14px;
}
.frameRotate {
position: absolute;
left: 50%;
+126 -66
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
import { useAssetUrl } from '../useAssetImageUrl';
import styles from './MaterialOverlay.module.css';
@@ -19,6 +20,8 @@ type MaterialOverlayProps = {
rotateLabel?: string;
onLayoutChange?: (layout: MaterialsOverlayLayout) => void;
onZoomAt?: (nx: number, ny: number) => void;
/** Без собственного root/dim — внутри `SceneOverlayHost`. */
embedded?: boolean;
};
function RotateIcon() {
@@ -95,11 +98,21 @@ export function MaterialOverlay({
rotateLabel = 'Rotate',
onLayoutChange,
onZoomAt,
embedded = false,
}: MaterialOverlayProps) {
const url = useAssetUrl(assetId);
const rootRef = useRef<HTMLDivElement | null>(null);
const host = useSceneOverlayView();
const localRootRef = useRef<HTMLDivElement | null>(null);
const rootRef = embedded && host ? host.rootRef : localRootRef;
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 });
const [view, setView] = useState({ w: 1, h: 1 });
const [localView, setLocalView] = useState({ w: 1, h: 1 });
/** Локальный layout + IPC не чаще 1/frame (лайв, без спама pointermove). */
const [draftLayout, setDraftLayout] = useState<MaterialsOverlayLayout | null>(null);
const pendingLayoutRef = useRef<MaterialsOverlayLayout | null>(null);
const draftRafRef = useRef(0);
const onLayoutChangeRef = useRef(onLayoutChange);
onLayoutChangeRef.current = onLayoutChange;
const view = embedded && host ? host.view : localView;
const dragRef = useRef<
| { mode: 'move'; startX: number; startY: number; origin: MaterialsOverlayLayout }
| {
@@ -124,14 +137,33 @@ export function MaterialOverlay({
>(null);
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();
}, [url]);
return () => {
ro.disconnect();
if (raf !== 0) window.cancelAnimationFrame(raf);
};
}, [embedded, url]);
useEffect(() => {
if (dragRef.current) return;
setDraftLayout(null);
pendingLayoutRef.current = null;
}, [layout]);
if (!assetId || !url) return null;
@@ -139,7 +171,7 @@ export function MaterialOverlay({
const zoomCursor =
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
const effectiveLayout = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT;
const effectiveLayout = draftLayout ?? layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT;
const rotationDeg = effectiveLayout.rotationDeg ?? 0;
const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
const w = base.w * effectiveLayout.scale;
@@ -167,6 +199,18 @@ export function MaterialOverlay({
};
};
const publishDraft = (next: MaterialsOverlayLayout): 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?.(pending);
});
};
const onPointerMove = (e: PointerEvent) => {
const drag = dragRef.current;
if (!drag || !onLayoutChange) return;
@@ -174,7 +218,7 @@ export function MaterialOverlay({
if (drag.mode === 'rotate') {
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
const delta = shortestAngleDelta(drag.startPointerAngle, angle);
onLayoutChange({
publishDraft({
...drag.origin,
rotationDeg: drag.origin.rotationDeg + delta,
});
@@ -188,7 +232,7 @@ export function MaterialOverlay({
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({
publishDraft({
...drag.origin,
cx: drag.origin.cx + dx,
cy: drag.origin.cy + dy,
@@ -247,7 +291,7 @@ export function MaterialOverlay({
const localCenterY = nextTop + hh / 2;
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
onLayoutChange({
publishDraft({
...origin,
cx: origin.cx + screenOffset.x / Math.max(1, viewW),
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
@@ -259,6 +303,15 @@ export function MaterialOverlay({
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?.(finalLayout);
}
};
const startDrag = (e: React.PointerEvent, mode: 'move' | 'resize', corner?: Corner) => {
@@ -306,9 +359,70 @@ export function MaterialOverlay({
window.addEventListener('pointerup', endDrag);
};
const frame = (
<div
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
data-overlay-kind="material"
style={{
left,
top,
width: w,
height: h,
transform: `rotate(${String(rotationDeg)}deg)`,
transformOrigin: 'center center',
}}
onPointerDown={(e) => {
if (zoomTool) return;
startDrag(e, 'move');
}}
>
<img
className={styles.image}
src={url}
alt=""
draggable={false}
style={{
width: w,
height: h,
transform: 'translate(-50%, -50%)',
}}
onLoad={(e) => {
const img = e.currentTarget;
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
}}
/>
{editable && !zoomTool
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
<button
key={corner}
type="button"
className={[styles.handle, styles[`handle_${corner}`]].join(' ')}
aria-label={corner}
onPointerDown={(e) => startDrag(e, 'resize', corner)}
/>
))
: null}
{editable && !zoomTool && onLayoutChange ? (
<button
type="button"
className={styles.frameRotate}
aria-label={rotateLabel}
title={rotateLabel}
onPointerDown={startRotate}
>
<RotateIcon />
</button>
) : null}
</div>
);
if (embedded) {
return frame;
}
return (
<div
ref={rootRef}
ref={localRootRef}
className={[styles.root, interactive ? styles.interactive : styles.passive, zoomCursor]
.filter(Boolean)
.join(' ')}
@@ -322,61 +436,7 @@ export function MaterialOverlay({
}}
>
<div className={styles.dim} />
<div
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : '']
.filter(Boolean)
.join(' ')}
style={{
left,
top,
width: w,
height: h,
transform: `rotate(${String(rotationDeg)}deg)`,
transformOrigin: 'center center',
}}
onPointerDown={(e) => {
if (zoomTool) return;
startDrag(e, 'move');
}}
>
<img
className={styles.image}
src={url}
alt=""
draggable={false}
style={{
width: w,
height: h,
transform: 'translate(-50%, -50%)',
}}
onLoad={(e) => {
const img = e.currentTarget;
setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
}}
/>
{editable && !zoomTool
? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
<button
key={corner}
type="button"
className={[styles.handle, styles[`handle_${corner}`]].join(' ')}
aria-label={corner}
onPointerDown={(e) => startDrag(e, 'resize', corner)}
/>
))
: null}
{editable && !zoomTool && onLayoutChange ? (
<button
type="button"
className={styles.frameRotate}
aria-label={rotateLabel}
title={rotateLabel}
onPointerDown={startRotate}
>
<RotateIcon />
</button>
) : null}
</div>
{frame}
{showClose ? (
<button
type="button"