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:
@@ -95,6 +95,8 @@ import { getSunbeamEffectLifeMs, playSunbeamEffectSound } from './sunbeamSfx';
|
|||||||
|
|
||||||
/** Длительность молнии: быстрый удар + акцент в точке попадания. */
|
/** Длительность молнии: быстрый удар + акцент в точке попадания. */
|
||||||
const LIGHTNING_EFFECT_MS = 840;
|
const LIGHTNING_EFFECT_MS = 840;
|
||||||
|
/** Темнота/маркер на презентацию — не чаще 20 Гц (полный canvas replay в обоих окнах). */
|
||||||
|
const OVERLAY_DRAFT_IPC_MS = 50;
|
||||||
|
|
||||||
function dispositionMakeKey(d: NpcDisposition): string {
|
function dispositionMakeKey(d: NpcDisposition): string {
|
||||||
if (d === 'hostile') return 'npcs.makeHostile';
|
if (d === 'hostile') return 'npcs.makeHostile';
|
||||||
@@ -305,6 +307,7 @@ export function ControlApp() {
|
|||||||
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
|
const brushCursorElRef = useRef<HTMLDivElement | null>(null);
|
||||||
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
|
const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
const draftPaintRafRef = useRef(0);
|
const draftPaintRafRef = useRef(0);
|
||||||
|
const overlayDraftIpcAtRef = useRef(0);
|
||||||
const effectsOverlayRef = useRef<PixiEffectsOverlayHandle | null>(null);
|
const effectsOverlayRef = useRef<PixiEffectsOverlayHandle | null>(null);
|
||||||
const [explosionDraft, setExplosionDraft] = useState<ExplosionInstance | null>(null);
|
const [explosionDraft, setExplosionDraft] = useState<ExplosionInstance | null>(null);
|
||||||
const draftMetaRef = useRef<{ createdAtMs: number; seed: number } | null>(null);
|
const draftMetaRef = useRef<{ createdAtMs: number; seed: number } | null>(null);
|
||||||
@@ -1387,6 +1390,9 @@ export function ControlApp() {
|
|||||||
draftPaintRafRef.current = 0;
|
draftPaintRafRef.current = 0;
|
||||||
pushDraftToPixi();
|
pushDraftToPixi();
|
||||||
const b = brushRef.current;
|
const b = brushRef.current;
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - overlayDraftIpcAtRef.current < OVERLAY_DRAFT_IPC_MS) return;
|
||||||
|
overlayDraftIpcAtRef.current = now;
|
||||||
if (
|
if (
|
||||||
(b?.tool === 'exploreBrush' || b?.tool === 'closeBrush') &&
|
(b?.tool === 'exploreBrush' || b?.tool === 'closeBrush') &&
|
||||||
b.points
|
b.points
|
||||||
@@ -2512,6 +2518,7 @@ export function ControlApp() {
|
|||||||
<PixiEffectsOverlay
|
<PixiEffectsOverlay
|
||||||
ref={effectsOverlayRef}
|
ref={effectsOverlayRef}
|
||||||
state={fxState}
|
state={fxState}
|
||||||
|
perfProfile="preview"
|
||||||
style={{ zIndex: 6 }}
|
style={{ zIndex: 6 }}
|
||||||
viewport={
|
viewport={
|
||||||
previewContentRect
|
previewContentRect
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ void test('ControlApp: draft кисти без корневого draftFxTick',
|
|||||||
assert.ok(pixi.includes('setDraft'));
|
assert.ok(pixi.includes('setDraft'));
|
||||||
assert.ok(pixi.includes('mergeDraftState'));
|
assert.ok(pixi.includes('mergeDraftState'));
|
||||||
assert.ok(pixi.includes('forwardRef'));
|
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 корня', () => {
|
void test('ControlApp: path playback не гоняет setState корня', () => {
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
.host {
|
.host {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
|
||||||
contain: layout paint;
|
contain: layout paint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hostFill {
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hostBound {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.hostInteractive {
|
.hostInteractive {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,3 +49,16 @@ void test('PxiEffectsOverlay: эффекты перекладываются пр
|
|||||||
assert.ok(src.includes('viewportSig'));
|
assert.ok(src.includes('viewportSig'));
|
||||||
assert.match(src, /\$\{contentSig\}@\$\{viewportSig\(viewport\)\}/);
|
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 React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { decimateFieldStampPoints, fieldStampMinSpacingN } from '../../../shared/fieldEffectStampPoints';
|
||||||
import type {
|
import type {
|
||||||
EffectInstance,
|
EffectInstance,
|
||||||
EffectInstanceType,
|
EffectInstanceType,
|
||||||
@@ -9,6 +10,10 @@ import type {
|
|||||||
|
|
||||||
import styles from './PxiEffectsOverlay.module.css';
|
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 = {
|
export type PixiEffectsOverlayHandle = {
|
||||||
/** Draft-штрих без React re-render родителя; null — сброс. */
|
/** Draft-штрих без React re-render родителя; null — сброс. */
|
||||||
setDraft: (instance: EffectInstance | null) => void;
|
setDraft: (instance: EffectInstance | null) => void;
|
||||||
@@ -193,6 +198,11 @@ type Props = {
|
|||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
/** Область “контента” внутри контейнера (например при object-fit: contain). */
|
/** Область “контента” внутри контейнера (например при object-fit: contain). */
|
||||||
viewport?: { x: number; y: number; w: number; h: number } | undefined;
|
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.
|
* - Draft кисти идёт через `setDraft` (imperative), без re-render ControlApp.
|
||||||
*/
|
*/
|
||||||
export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(function PixiEffectsOverlay(
|
export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(function PixiEffectsOverlay(
|
||||||
{ state, interactive = false, style, viewport },
|
{ state, interactive = false, style, viewport, perfProfile = 'live' },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -320,17 +330,18 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|||||||
const pixi = await import('pixi.js');
|
const pixi = await import('pixi.js');
|
||||||
pixiRef.current = pixi;
|
pixiRef.current = pixi;
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
|
const preview = perfProfile === 'preview';
|
||||||
app = new pixi.Application();
|
app = new pixi.Application();
|
||||||
await app.init({
|
await app.init({
|
||||||
backgroundAlpha: 0,
|
backgroundAlpha: 0,
|
||||||
antialias: false,
|
antialias: false,
|
||||||
powerPreference: 'high-performance',
|
powerPreference: 'high-performance',
|
||||||
resolution: dpr,
|
resolution: preview ? 0.5 : dpr,
|
||||||
autoDensity: true,
|
autoDensity: true,
|
||||||
preference: 'webgl',
|
preference: 'webgl',
|
||||||
});
|
});
|
||||||
// Меньше кадров — меньше CPU/GPU; анимации эффектов остаются плавными.
|
// live: 32 FPS; preview (пульт): 12 — второй WebGL поверх видео на Mac дешевле.
|
||||||
app.ticker.maxFPS = 32;
|
app.ticker.maxFPS = preview ? 12 : 32;
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
host.appendChild(app.canvas);
|
host.appendChild(app.canvas);
|
||||||
// Canvas по умолчанию перехватывает hit-test; оставляем клики «сквозь» оверлей для слоя кисти сверху.
|
// Canvas по умолчанию перехватывает hit-test; оставляем клики «сквозь» оверлей для слоя кисти сверху.
|
||||||
@@ -370,12 +381,15 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|||||||
const s = stateRef.current;
|
const s = stateRef.current;
|
||||||
if (!s || s.instances.length === 0) return;
|
if (!s || s.instances.length === 0) return;
|
||||||
const nowMs = Date.now() + timeOffsetRef.current;
|
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;
|
const rootNode = rootRef.current;
|
||||||
if (rootNode) {
|
if (rootNode && motion) {
|
||||||
const { x, y } = computeSceneShake(s, nowMs, sizeRef.current);
|
const { x, y } = computeSceneShake(s, nowMs, sizeRef.current);
|
||||||
rootNode.x = x;
|
rootNode.x = x;
|
||||||
rootNode.y = y;
|
rootNode.y = y;
|
||||||
@@ -404,7 +418,7 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|||||||
host.replaceChildren();
|
host.replaceChildren();
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [gpuBoot]);
|
}, [gpuBoot, perfProfile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
@@ -420,9 +434,9 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Если viewport не задан — используем “весь холст”.
|
// Host уже стоит на contentRect — в Pixi локальные координаты с (0,0).
|
||||||
viewportProvidedRef.current = Boolean(viewport);
|
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 };
|
else viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
|
||||||
const pixi = pixiRef.current;
|
const pixi = pixiRef.current;
|
||||||
const root = rootRef.current;
|
const root = rootRef.current;
|
||||||
@@ -437,9 +451,22 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|||||||
}
|
}
|
||||||
}, [viewport]);
|
}, [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(
|
function syncNodes(
|
||||||
@@ -530,8 +557,9 @@ function createInstanceNode(
|
|||||||
const r = inst.radiusN * minDim;
|
const r = inst.radiusN * minDim;
|
||||||
const textures = getFogVfxFrameTextures(pixi);
|
const textures = getFogVfxFrameTextures(pixi);
|
||||||
const c = new pixi.Container();
|
const c = new pixi.Container();
|
||||||
for (let i = 0; i < inst.points.length; i += 1) {
|
const stamps = fieldStampPoints(inst);
|
||||||
const p = inst.points[i];
|
for (let i = 0; i < stamps.length; i += 1) {
|
||||||
|
const p = stamps[i];
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
|
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
|
||||||
s.anchor?.set?.(0.5, 0.5);
|
s.anchor?.set?.(0.5, 0.5);
|
||||||
@@ -570,8 +598,9 @@ function createInstanceNode(
|
|||||||
const r = inst.radiusN * minDim;
|
const r = inst.radiusN * minDim;
|
||||||
const textures = getGroundFireVfxFrameTextures(pixi);
|
const textures = getGroundFireVfxFrameTextures(pixi);
|
||||||
const c = new pixi.Container();
|
const c = new pixi.Container();
|
||||||
for (let i = 0; i < inst.points.length; i += 1) {
|
const stamps = fieldStampPoints(inst);
|
||||||
const p = inst.points[i];
|
for (let i = 0; i < stamps.length; i += 1) {
|
||||||
|
const p = stamps[i];
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
|
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
|
||||||
s.anchor?.set?.(0.5, 0.62);
|
s.anchor?.set?.(0.5, 0.62);
|
||||||
@@ -613,8 +642,9 @@ function createInstanceNode(
|
|||||||
const r = inst.radiusN * minDim;
|
const r = inst.radiusN * minDim;
|
||||||
const textures = getRainVfxFrameTextures(pixi);
|
const textures = getRainVfxFrameTextures(pixi);
|
||||||
const c = new pixi.Container();
|
const c = new pixi.Container();
|
||||||
for (let i = 0; i < inst.points.length; i += 1) {
|
const stamps = fieldStampPoints(inst);
|
||||||
const p = inst.points[i];
|
for (let i = 0; i < stamps.length; i += 1) {
|
||||||
|
const p = stamps[i];
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
|
const s = new pixi.Sprite(textures[0] ?? pixi.Texture.EMPTY);
|
||||||
s.anchor?.set?.(0.5, 0.5);
|
s.anchor?.set?.(0.5, 0.5);
|
||||||
@@ -885,6 +915,7 @@ function animateNodes(
|
|||||||
nowMs: number,
|
nowMs: number,
|
||||||
size: { w: number; h: number },
|
size: { w: number; h: number },
|
||||||
viewport: { x: number; y: number; 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]));
|
const instById = new Map(state.instances.map((i) => [i.id, i]));
|
||||||
void pixi;
|
void pixi;
|
||||||
@@ -940,12 +971,14 @@ function animateNodes(
|
|||||||
child.texture = frame;
|
child.texture = frame;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const wobble = typeof fx.wobble === 'number' ? fx.wobble : 2;
|
if (opts?.motion !== false) {
|
||||||
child.x = fx.bx + Math.sin(0.0016 * t + (fx.phase ?? 0) * 6.28) * wobble;
|
const wobble = typeof fx.wobble === 'number' ? fx.wobble : 2;
|
||||||
child.y = fx.by + Math.cos(0.0019 * t + (fx.phase ?? 0) * 6.28) * wobble * 0.45;
|
child.x = fx.bx + Math.sin(0.0016 * t + (fx.phase ?? 0) * 6.28) * wobble;
|
||||||
const local = 0.93 + 0.07 * Math.sin(0.0042 * t + (fx.phase ?? 0) * 12.56);
|
child.y = fx.by + Math.cos(0.0019 * t + (fx.phase ?? 0) * 6.28) * wobble * 0.45;
|
||||||
child.width = (typeof fx.w0 === 'number' ? fx.w0 : child.width) * local;
|
const local = 0.93 + 0.07 * Math.sin(0.0042 * t + (fx.phase ?? 0) * 12.56);
|
||||||
child.height = (typeof fx.h0 === 'number' ? fx.h0 : child.height) * (0.96 + (local - 0.93) * 0.7);
|
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;
|
child.alpha = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -991,9 +1024,11 @@ function animateNodes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const alphaMul = typeof fx.alphaMul === 'number' ? fx.alphaMul : 1;
|
const alphaMul = typeof fx.alphaMul === 'number' ? fx.alphaMul : 1;
|
||||||
const wob = typeof fx.drift === 'number' ? fx.drift : 0;
|
if (opts?.motion !== false) {
|
||||||
child.x = fx.bx + Math.sin(0.0011 * t + (fx.phase ?? 0) * 6.28) * wob;
|
const wob = typeof fx.drift === 'number' ? fx.drift : 0;
|
||||||
child.y = fx.by + Math.cos(0.0014 * t + (fx.phase ?? 0) * 6.28) * wob * 0.35;
|
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.width = typeof fx.w0 === 'number' ? fx.w0 : child.width;
|
||||||
child.height = typeof fx.h0 === 'number' ? fx.h0 : child.height;
|
child.height = typeof fx.h0 === 'number' ? fx.h0 : child.height;
|
||||||
child.alpha = Math.max(0.25, Math.min(1, inst.opacity * base * alphaMul));
|
child.alpha = Math.max(0.25, Math.min(1, inst.opacity * base * alphaMul));
|
||||||
@@ -1867,10 +1902,11 @@ function relayoutInstanceNode(
|
|||||||
if (inst.type === 'fog') {
|
if (inst.type === 'fog') {
|
||||||
const r = inst.radiusN * minDim;
|
const r = inst.radiusN * minDim;
|
||||||
const fogSize = Math.max(4, r * 2);
|
const fogSize = Math.max(4, r * 2);
|
||||||
|
const stamps = fieldStampPoints(inst);
|
||||||
const children = node.children ?? [];
|
const children = node.children ?? [];
|
||||||
let pi = 0;
|
let pi = 0;
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
const p = inst.points[pi];
|
const p = stamps[pi];
|
||||||
pi += 1;
|
pi += 1;
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
const fx = (child as any).__fx ?? {};
|
const fx = (child as any).__fx ?? {};
|
||||||
@@ -1891,10 +1927,11 @@ function relayoutInstanceNode(
|
|||||||
const r = inst.radiusN * minDim;
|
const r = inst.radiusN * minDim;
|
||||||
const flameWidth = Math.max(r * 3.2, minDim * 0.05);
|
const flameWidth = Math.max(r * 3.2, minDim * 0.05);
|
||||||
const flameHeight = flameWidth / GROUND_FIRE_VFX_FRAME_ASPECT;
|
const flameHeight = flameWidth / GROUND_FIRE_VFX_FRAME_ASPECT;
|
||||||
|
const stamps = fieldStampPoints(inst);
|
||||||
const children = node.children ?? [];
|
const children = node.children ?? [];
|
||||||
let pi = 0;
|
let pi = 0;
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
const p = inst.points[pi];
|
const p = stamps[pi];
|
||||||
pi += 1;
|
pi += 1;
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
const fx = (child as any).__fx ?? {};
|
const fx = (child as any).__fx ?? {};
|
||||||
@@ -1915,10 +1952,11 @@ function relayoutInstanceNode(
|
|||||||
const r = inst.radiusN * minDim;
|
const r = inst.radiusN * minDim;
|
||||||
const rainWidth = Math.max(r * 3.8, minDim * 0.08);
|
const rainWidth = Math.max(r * 3.8, minDim * 0.08);
|
||||||
const rainHeight = rainWidth / RAIN_VFX_FRAME_ASPECT;
|
const rainHeight = rainWidth / RAIN_VFX_FRAME_ASPECT;
|
||||||
|
const stamps = fieldStampPoints(inst);
|
||||||
const children = node.children ?? [];
|
const children = node.children ?? [];
|
||||||
let pi = 0;
|
let pi = 0;
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
const p = inst.points[pi];
|
const p = stamps[pi];
|
||||||
pi += 1;
|
pi += 1;
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
const fx = (child as any).__fx ?? {};
|
const fx = (child as any).__fx ?? {};
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function SceneDarknessOverlay({
|
|||||||
const h = Math.max(1, Math.round(viewport.h));
|
const h = Math.max(1, Math.round(viewport.h));
|
||||||
if (canvas.width !== w) canvas.width = w;
|
if (canvas.width !== w) canvas.width = w;
|
||||||
if (canvas.height !== h) canvas.height = h;
|
if (canvas.height !== h) canvas.height = h;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function SceneMarkerOverlay({ state, viewport, style }: SceneMarkerOverla
|
|||||||
const h = Math.max(1, Math.round(viewport.h));
|
const h = Math.max(1, Math.round(viewport.h));
|
||||||
if (canvas.width !== w) canvas.width = w;
|
if (canvas.width !== w) canvas.width = w;
|
||||||
if (canvas.height !== h) canvas.height = h;
|
if (canvas.height !== h) canvas.height = h;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function SceneGridOverlay({ grid, viewport }: Props) {
|
|||||||
canvas.style.width = `${w}px`;
|
canvas.style.width = `${w}px`;
|
||||||
canvas.style.height = `${h}px`;
|
canvas.style.height = `${h}px`;
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FIELD_STAMP_MAX_SPRITES,
|
||||||
|
decimateFieldStampPoints,
|
||||||
|
fieldStampMinSpacingN,
|
||||||
|
} from './fieldEffectStampPoints';
|
||||||
|
import type { NPoint } from './types/effects';
|
||||||
|
|
||||||
|
function pt(x: number, y: number, tMs = 0): NPoint {
|
||||||
|
return { x, y, tMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('decimateFieldStampPoints: пустой и одна точка', () => {
|
||||||
|
assert.deepEqual(decimateFieldStampPoints([], 0.05), []);
|
||||||
|
const one = [pt(0.2, 0.3)];
|
||||||
|
assert.deepEqual(decimateFieldStampPoints(one, 0.05), one);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('decimateFieldStampPoints: оставляет концы и выкидывает близкие середины', () => {
|
||||||
|
const points = [pt(0, 0.5), pt(0.01, 0.5), pt(0.02, 0.5), pt(0.5, 0.5), pt(0.51, 0.5), pt(1, 0.5)];
|
||||||
|
const out = decimateFieldStampPoints(points, 0.1);
|
||||||
|
assert.equal(out[0]?.x, 0);
|
||||||
|
assert.equal(out[out.length - 1]?.x, 1);
|
||||||
|
assert.ok(out.length < points.length);
|
||||||
|
assert.ok(out.length >= 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('decimateFieldStampPoints: maxPoints режет длинный штрих', () => {
|
||||||
|
const points: NPoint[] = [];
|
||||||
|
for (let i = 0; i <= 200; i += 1) {
|
||||||
|
points.push(pt(i / 200, 0.4, i));
|
||||||
|
}
|
||||||
|
const out = decimateFieldStampPoints(points, 0.001, 16);
|
||||||
|
assert.ok(out.length <= 16);
|
||||||
|
assert.equal(out[0]?.x, 0);
|
||||||
|
assert.equal(out[out.length - 1]?.x, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('fieldStampMinSpacingN: огонь реже тумана', () => {
|
||||||
|
const r = 0.04;
|
||||||
|
assert.ok(fieldStampMinSpacingN('fire', r) > fieldStampMinSpacingN('fog', r));
|
||||||
|
assert.ok(fieldStampMinSpacingN('fog', r) >= 0.006);
|
||||||
|
assert.equal(FIELD_STAMP_MAX_SPRITES, 40);
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { NPoint } from './types/effects';
|
||||||
|
|
||||||
|
/** Верхняя граница спрайтов на один штрих тумана/огня/дождя. */
|
||||||
|
export const FIELD_STAMP_MAX_SPRITES = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальное расстояние между центрами спрайтов (норм. 0..1).
|
||||||
|
* Огонь крупнее пятна радиуса — реже ставим штампы.
|
||||||
|
*/
|
||||||
|
export function fieldStampMinSpacingN(type: 'fog' | 'fire' | 'rain', radiusN: number): number {
|
||||||
|
const factor = type === 'fire' ? 0.75 : type === 'rain' ? 0.55 : 0.5;
|
||||||
|
return Math.max(0.006, radiusN * factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оставляет концы штриха и точки не ближе `minSpacingN`.
|
||||||
|
* Если точек всё ещё больше `maxPoints` — равномерный stride.
|
||||||
|
*/
|
||||||
|
export function decimateFieldStampPoints(
|
||||||
|
points: readonly NPoint[],
|
||||||
|
minSpacingN: number,
|
||||||
|
maxPoints: number = FIELD_STAMP_MAX_SPRITES,
|
||||||
|
): NPoint[] {
|
||||||
|
if (points.length <= 1) return points.slice();
|
||||||
|
const first = points[0];
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
if (!first || !last) {
|
||||||
|
return points.filter((p): p is NPoint => Boolean(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cap = Math.max(2, Math.floor(maxPoints));
|
||||||
|
const minDistSq = Math.max(0, minSpacingN) * Math.max(0, minSpacingN);
|
||||||
|
const kept: NPoint[] = [first];
|
||||||
|
for (let i = 1; i < points.length - 1; i += 1) {
|
||||||
|
const p = points[i];
|
||||||
|
const prev = kept[kept.length - 1];
|
||||||
|
if (!p || !prev) continue;
|
||||||
|
const dx = p.x - prev.x;
|
||||||
|
const dy = p.y - prev.y;
|
||||||
|
if (dx * dx + dy * dy >= minDistSq) kept.push(p);
|
||||||
|
}
|
||||||
|
const tail = kept[kept.length - 1];
|
||||||
|
if (!tail) {
|
||||||
|
kept.push(last);
|
||||||
|
} else {
|
||||||
|
const dx = last.x - tail.x;
|
||||||
|
const dy = last.y - tail.y;
|
||||||
|
if (dx * dx + dy * dy > 1e-12) kept.push(last);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kept.length <= cap) return kept;
|
||||||
|
|
||||||
|
const reduced: NPoint[] = [];
|
||||||
|
const lastIdx = kept.length - 1;
|
||||||
|
const stride = Math.max(1, Math.ceil(lastIdx / (cap - 1)));
|
||||||
|
for (let i = 0; i < kept.length; i += stride) {
|
||||||
|
const p = kept[i];
|
||||||
|
if (p) reduced.push(p);
|
||||||
|
}
|
||||||
|
const reducedTail = reduced[reduced.length - 1];
|
||||||
|
if (reducedTail !== last) reduced.push(last);
|
||||||
|
if (reduced.length > cap) {
|
||||||
|
const head = reduced[0];
|
||||||
|
if (!head) return kept.slice(0, cap);
|
||||||
|
return [head, ...reduced.slice(reduced.length - (cap - 1))];
|
||||||
|
}
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/shared/graph/activeStorylineScenes.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/shared/fieldEffectStampPoints.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/preferences/appPreferences.test.ts app/main/playlists/playlistsStore.test.ts app/main/sessionTimer/timerSessionStore.test.ts app/main/initiative/initiativeSessionStore.test.ts app/main/diceRoll/diceRollSessionStore.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/effects/sceneMarkerStore.test.ts app/shared/sceneMarkerEraser.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/sceneAudioRef.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts app/renderer/shared/tokens/tokenPathLiveDom.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
||||||
"format": "prettier . --check",
|
"format": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
|||||||
Reference in New Issue
Block a user