fix(simulation): bound rendering and expose camera inversion

This commit is contained in:
DCCONSTRUCTIONS
2026-08-26 14:30:25 +03:00
parent aba06086ac
commit 5e97fa293f
4 changed files with 173 additions and 58 deletions
@@ -22,6 +22,7 @@ import type { SimulationWorldManifest } from "../../core/simulation/projects";
export type SimulationViewMode = "visual" | "collision" | "combined"; export type SimulationViewMode = "visual" | "collision" | "combined";
export type SimulationQuality = "low" | "medium" | "high" | "ultra" | "maximum"; export type SimulationQuality = "low" | "medium" | "high" | "ultra" | "maximum";
export type SimulationLayer = "visual" | "collision"; export type SimulationLayer = "visual" | "collision";
export type SimulationCameraAxis = "horizontal" | "vertical";
export const PLAYCANVAS_IDENTITY_TRANSFORM = [ export const PLAYCANVAS_IDENTITY_TRANSFORM = [
1, 0, 0, 0, 1, 0, 0, 0,
@@ -43,6 +44,7 @@ export interface SimulationRuntime {
setViewMode(mode: SimulationViewMode): Promise<void>; setViewMode(mode: SimulationViewMode): Promise<void>;
setQuality(quality: SimulationQuality): void; setQuality(quality: SimulationQuality): void;
setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void; setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void;
setCameraInversion(axis: SimulationCameraAxis, inverted: boolean): void;
home(): void; home(): void;
focusBounds(): void; focusBounds(): void;
dispose(): void; dispose(): void;
@@ -65,12 +67,14 @@ const QUALITY_PROFILES: Record<SimulationQuality, {
lodRangeMin: number; lodRangeMin: number;
lodRangeMax: number; lodRangeMax: number;
pixelRatio: number; pixelRatio: number;
splatBudget: number;
targetFps: number;
}> = { }> = {
low: { lodBaseDistance: 5, lodMultiplier: 2, lodRangeMin: 3, lodRangeMax: 5, pixelRatio: 0.75 }, low: { lodBaseDistance: 5, lodMultiplier: 2, lodRangeMin: 3, lodRangeMax: 5, pixelRatio: 0.75, splatBudget: 350_000, targetFps: 30 },
medium: { lodBaseDistance: 5, lodMultiplier: 2, lodRangeMin: 2, lodRangeMax: 5, pixelRatio: 1 }, medium: { lodBaseDistance: 5, lodMultiplier: 2, lodRangeMin: 2, lodRangeMax: 5, pixelRatio: 1, splatBudget: 700_000, targetFps: 45 },
high: { lodBaseDistance: 5, lodMultiplier: 3, lodRangeMin: 1, lodRangeMax: 5, pixelRatio: 1.5 }, high: { lodBaseDistance: 5, lodMultiplier: 3, lodRangeMin: 1, lodRangeMax: 5, pixelRatio: 1.25, splatBudget: 1_200_000, targetFps: 60 },
ultra: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2 }, ultra: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 1.5, splatBudget: 2_500_000, targetFps: 60 },
maximum: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 0, pixelRatio: 2 }, maximum: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2, splatBudget: 4_000_000, targetFps: 60 },
}; };
export class PlayCanvasRuntime implements SimulationRuntime { export class PlayCanvasRuntime implements SimulationRuntime {
@@ -87,10 +91,23 @@ export class PlayCanvasRuntime implements SimulationRuntime {
private collisionLoadPromise: Promise<void> | null = null; private collisionLoadPromise: Promise<void> | null = null;
private resizeObserver: ResizeObserver | null = null; private resizeObserver: ResizeObserver | null = null;
private viewMode: SimulationViewMode = "visual"; private viewMode: SimulationViewMode = "visual";
private quality: SimulationQuality = "maximum"; private quality: SimulationQuality = "high";
private cameraInversion = { horizontal: true, vertical: false };
private renderIntervalMs = 1000 / 60;
private renderAccumulatorMs = 0;
private disposed = false;
private scheduleFrame = (deltaMs: number): void => {
if (!this.app || this.disposed) return;
this.renderAccumulatorMs += deltaMs;
if (this.renderAccumulatorMs < this.renderIntervalMs) return;
this.renderAccumulatorMs %= this.renderIntervalMs;
this.app.renderNextFrame = true;
};
async mount(canvas: HTMLCanvasElement): Promise<void> { async mount(canvas: HTMLCanvasElement): Promise<void> {
if (this.app) throw new Error("PlayCanvas runtime уже смонтирован."); if (this.app) throw new Error("PlayCanvas runtime уже смонтирован.");
this.disposed = false;
this.renderAccumulatorMs = 0;
this.canvas = canvas; this.canvas = canvas;
canvas.tabIndex = 0; canvas.tabIndex = 0;
canvas.addEventListener("contextmenu", preventContextMenu); canvas.addEventListener("contextmenu", preventContextMenu);
@@ -104,6 +121,8 @@ export class PlayCanvasRuntime implements SimulationRuntime {
}, },
}); });
this.app = app; this.app = app;
app.autoRender = false;
app.on("frameupdate", this.scheduleFrame);
app.setCanvasFillMode(FILLMODE_NONE, 1, 1); app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
app.setCanvasResolution(RESOLUTION_AUTO); app.setCanvasResolution(RESOLUTION_AUTO);
app.scene.ambientLight = new Color(0.35, 0.37, 0.42); app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
@@ -126,7 +145,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
zoomRange: new Vec2(0.05, 20_000), zoomRange: new Vec2(0.05, 20_000),
}, },
}) as CameraController | null; }) as CameraController | null;
if (controller) invertHorizontalCameraDrag(controller); if (controller) configureCameraDrag(controller, () => this.cameraInversion);
app.root.addChild(camera); app.root.addChild(camera);
this.camera = camera; this.camera = camera;
this.cameraController = controller; this.cameraController = controller;
@@ -158,6 +177,11 @@ export class PlayCanvasRuntime implements SimulationRuntime {
this.visualAsset = asset; this.visualAsset = asset;
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const ready = (loaded: Asset) => { const ready = (loaded: Asset) => {
if (this.disposed || !this.app) {
loaded.unload();
resolve();
return;
}
const visual = new Entity("GaussianWorld"); const visual = new Entity("GaussianWorld");
visual.enabled = false; visual.enabled = false;
applyWorldTransform(visual, manifest.transforms.worldFromVisual); applyWorldTransform(visual, manifest.transforms.worldFromVisual);
@@ -216,6 +240,10 @@ export class PlayCanvasRuntime implements SimulationRuntime {
if (this.collisionEntity) applyWorldTransform(this.collisionEntity, transform); if (this.collisionEntity) applyWorldTransform(this.collisionEntity, transform);
} }
setCameraInversion(axis: SimulationCameraAxis, inverted: boolean): void {
this.cameraInversion[axis] = inverted;
}
home(): void { home(): void {
if (this.cameraController) { if (this.cameraController) {
this.cameraController.reset(HOME_FOCUS, HOME_POSITION); this.cameraController.reset(HOME_FOCUS, HOME_POSITION);
@@ -241,10 +269,23 @@ export class PlayCanvasRuntime implements SimulationRuntime {
} }
dispose(): void { dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.resizeObserver?.disconnect(); this.resizeObserver?.disconnect();
this.resizeObserver = null; this.resizeObserver = null;
const webgl = this.canvas?.getContext("webgl2") ?? this.canvas?.getContext("webgl");
this.unloadWorld(); this.unloadWorld();
if (this.app) this.app.destroy(); if (this.app) {
this.app.autoRender = false;
this.app.renderNextFrame = false;
this.app.off("frameupdate", this.scheduleFrame);
this.app.destroy();
}
webgl?.getExtension("WEBGL_lose_context")?.loseContext();
if (this.canvas) {
this.canvas.width = 1;
this.canvas.height = 1;
}
if (this.canvas) { if (this.canvas) {
this.canvas.removeEventListener("contextmenu", preventContextMenu); this.canvas.removeEventListener("contextmenu", preventContextMenu);
this.canvas.removeEventListener("pointerdown", focusCanvas, true); this.canvas.removeEventListener("pointerdown", focusCanvas, true);
@@ -293,6 +334,8 @@ export class PlayCanvasRuntime implements SimulationRuntime {
gsplat.lodRangeMax = profile.lodRangeMax; gsplat.lodRangeMax = profile.lodRangeMax;
} }
if (this.app) { if (this.app) {
this.app.scene.gsplat.splatBudget = profile.splatBudget;
this.renderIntervalMs = 1000 / profile.targetFps;
const devicePixelRatio = window.devicePixelRatio || 1; const devicePixelRatio = window.devicePixelRatio || 1;
this.app.graphicsDevice.maxPixelRatio = Math.min(devicePixelRatio, profile.pixelRatio); this.app.graphicsDevice.maxPixelRatio = Math.min(devicePixelRatio, profile.pixelRatio);
this.resize(); this.resize();
@@ -310,6 +353,11 @@ export class PlayCanvasRuntime implements SimulationRuntime {
this.collisionAsset = asset; this.collisionAsset = asset;
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
asset.ready((loaded) => { asset.ready((loaded) => {
if (this.disposed || !this.app) {
loaded.unload();
resolve();
return;
}
const resource = loaded.resource as ContainerResource | null; const resource = loaded.resource as ContainerResource | null;
if (!resource) { if (!resource) {
reject(new Error("PlayCanvas не открыл collision GLB.")); reject(new Error("PlayCanvas не открыл collision GLB."));
@@ -400,15 +448,20 @@ function focusCanvas(event: Event): void {
(event.currentTarget as HTMLCanvasElement | null)?.focus({ preventScroll: true }); (event.currentTarget as HTMLCanvasElement | null)?.focus({ preventScroll: true });
} }
function invertHorizontalCameraDrag(controller: CameraController): void { function configureCameraDrag(
controller: CameraController,
inversion: () => { horizontal: boolean; vertical: boolean },
): void {
// CameraControls 2.21.4 deliberately exposes one pinned input source here. // CameraControls 2.21.4 deliberately exposes one pinned input source here.
// Keep its pointer-capture and fly/orbit behavior, changing only horizontal drag semantics. // Keep its pointer-capture and fly/orbit behavior; only the user-selected axes change sign.
const input = controller._desktopInput; const input = controller._desktopInput;
if (!input) return; if (!input) return;
const read = input.read.bind(input); const read = input.read.bind(input);
input.read = () => { input.read = () => {
const frame = read(); const frame = read();
if (frame.mouse.length > 0) frame.mouse[0] *= -1; const selected = inversion();
if (selected.horizontal && frame.mouse.length > 0) frame.mouse[0] *= -1;
if (selected.vertical && frame.mouse.length > 1) frame.mouse[1] *= -1;
return frame; return frame;
}; };
} }
@@ -22,11 +22,12 @@ import {
export function SimulationViewport({ project }: { project: SimulationProject }) { export function SimulationViewport({ project }: { project: SimulationProject }) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const runtimeRef = useRef<PlayCanvasRuntime | null>(null); const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
const cameraInversionRef = useRef({ horizontal: true, vertical: false });
const settingsId = useId(); const settingsId = useId();
const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting"); const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<SimulationViewMode>("visual"); const [viewMode, setViewMode] = useState<SimulationViewMode>("visual");
const [quality, setQuality] = useState<SimulationQuality>("maximum"); const [quality, setQuality] = useState<SimulationQuality>("high");
const [collisionState, setCollisionState] = useState<"idle" | "loading" | "ready" | "failed">("idle"); const [collisionState, setCollisionState] = useState<"idle" | "loading" | "ready" | "failed">("idle");
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [visualInverted, setVisualInverted] = useState( const [visualInverted, setVisualInverted] = useState(
@@ -35,6 +36,8 @@ export function SimulationViewport({ project }: { project: SimulationProject })
const [collisionInverted, setCollisionInverted] = useState( const [collisionInverted, setCollisionInverted] = useState(
() => isX180Transform(project.worldManifest?.transforms.worldFromCollision), () => isX180Transform(project.worldManifest?.transforms.worldFromCollision),
); );
const [horizontalInverted, setHorizontalInverted] = useState(true);
const [verticalInverted, setVerticalInverted] = useState(false);
useEffect(() => { useEffect(() => {
setVisualInverted(isX180Transform(project.worldManifest?.transforms.worldFromVisual)); setVisualInverted(isX180Transform(project.worldManifest?.transforms.worldFromVisual));
@@ -53,6 +56,8 @@ export function SimulationViewport({ project }: { project: SimulationProject })
setError(null); setError(null);
void runtime.mount(canvas).then(async () => { void runtime.mount(canvas).then(async () => {
if (cancelled) return; if (cancelled) return;
runtime.setCameraInversion("horizontal", cameraInversionRef.current.horizontal);
runtime.setCameraInversion("vertical", cameraInversionRef.current.vertical);
setState("loading"); setState("loading");
await runtime.loadWorld(manifest); await runtime.loadWorld(manifest);
if (!cancelled) setState("ready"); if (!cancelled) setState("ready");
@@ -96,11 +101,11 @@ export function SimulationViewport({ project }: { project: SimulationProject })
runtimeRef.current?.setQuality(next); runtimeRef.current?.setQuality(next);
}} }}
options={[ options={[
{ value: "maximum", label: "Максимум", description: "Только полный LOD 0, Retina до 2×" }, { value: "maximum", label: "Максимум", description: "До 4 млн сплатов · Retina до 2× · 60 FPS" },
{ value: "ultra", label: "Ультра", description: "LOD 0 и Retina до 2×" }, { value: "ultra", label: "Ультра", description: "До 2,5 млн · Retina до 1,5× · 60 FPS" },
{ value: "high", label: "Высокое", description: "LOD 1 и Retina до 1,5×" }, { value: "high", label: "Высокое", description: "До 1,2 млн · Retina до 1,25× · 60 FPS" },
{ value: "medium", label: "Среднее", description: "LOD 2 и обычное разрешение" }, { value: "medium", label: "Среднее", description: "До 700 тыс. · 1× · 45 FPS" },
{ value: "low", label: "Низкое", description: "LOD 3 для слабых устройств" }, { value: "low", label: "Низкое", description: "До 350 тыс. · 0,75× · 30 FPS" },
]} ]}
minMenuWidth={230} minMenuWidth={230}
menuWidth={230} menuWidth={230}
@@ -133,7 +138,7 @@ export function SimulationViewport({ project }: { project: SimulationProject })
</Button> </Button>
<div className="simulation-viewport__settings-anchor"> <div className="simulation-viewport__settings-anchor">
<IconButton <IconButton
label="Настройки системы координат" label="Настройки сцены"
aria-controls={settingsId} aria-controls={settingsId}
aria-expanded={settingsOpen} aria-expanded={settingsOpen}
aria-pressed={settingsOpen} aria-pressed={settingsOpen}
@@ -146,47 +151,80 @@ export function SimulationViewport({ project }: { project: SimulationProject })
id={settingsId} id={settingsId}
className="simulation-viewport__settings" className="simulation-viewport__settings"
role="dialog" role="dialog"
aria-label="Настройки системы координат" aria-label="Настройки сцены"
> >
<div className="simulation-viewport__settings-head"> <div className="simulation-viewport__settings-head">
<div> <div>
<strong>Система координат</strong> <strong>Настройки сцены</strong>
<span>Мир PlayCanvas · Y вверх</span> <span>Gaussian-мир и камера</span>
</div> </div>
<IconButton label="Закрыть настройки" onClick={() => setSettingsOpen(false)}> <IconButton label="Закрыть настройки" onClick={() => setSettingsOpen(false)}>
<Icon name="close" size={16} /> <Icon name="close" size={16} />
</IconButton> </IconButton>
</div> </div>
<p> <section className="simulation-viewport__settings-group">
Коррекция применяется к слоям независимо и не меняет координаты камеры, <div className="simulation-viewport__settings-group-head">
навигации и будущей физики. <strong>Система координат</strong>
</p> <span>Мир PlayCanvas · Y вверх</span>
<div className="simulation-viewport__settings-switches"> </div>
<Switch <p>
checked={visualInverted} Коррекция слоёв не меняет координаты камеры, навигации и будущей физики.
disabled={state !== "ready"} </p>
label="Инверсия визуального слоя" <div className="simulation-viewport__settings-switches">
onChange={(checked) => { <Switch
setVisualInverted(checked); checked={visualInverted}
runtimeRef.current?.setLayerWorldTransform( disabled={state !== "ready"}
"visual", label="Инверсия визуального слоя"
checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM, onChange={(checked) => {
); setVisualInverted(checked);
}} runtimeRef.current?.setLayerWorldTransform(
/> "visual",
<Switch checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM,
checked={collisionInverted} );
disabled={state !== "ready"} }}
label="Инверсия collision-слоя" />
onChange={(checked) => { <Switch
setCollisionInverted(checked); checked={collisionInverted}
runtimeRef.current?.setLayerWorldTransform( disabled={state !== "ready"}
"collision", label="Инверсия collision-слоя"
checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM, onChange={(checked) => {
); setCollisionInverted(checked);
}} runtimeRef.current?.setLayerWorldTransform(
/> "collision",
</div> checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM,
);
}}
/>
</div>
</section>
<section className="simulation-viewport__settings-group">
<div className="simulation-viewport__settings-group-head">
<strong>Управление камерой</strong>
<span>Мышь и тачпад</span>
</div>
<div className="simulation-viewport__settings-switches">
<Switch
checked={horizontalInverted}
disabled={state !== "ready"}
label="Инверсия управления по горизонтали"
onChange={(checked) => {
cameraInversionRef.current.horizontal = checked;
setHorizontalInverted(checked);
runtimeRef.current?.setCameraInversion("horizontal", checked);
}}
/>
<Switch
checked={verticalInverted}
disabled={state !== "ready"}
label="Инверсия управления по вертикали"
onChange={(checked) => {
cameraInversionRef.current.vertical = checked;
setVerticalInverted(checked);
runtimeRef.current?.setCameraInversion("vertical", checked);
}}
/>
</div>
</section>
</div> </div>
) : null} ) : null}
</div> </div>
+20 -3
View File
@@ -379,7 +379,7 @@
top: calc(100% + 0.65rem); top: calc(100% + 0.65rem);
right: 0; right: 0;
display: grid; display: grid;
width: min(19rem, calc(100cqw - 1.3rem)); width: min(21rem, calc(100cqw - 1.3rem));
gap: 0.75rem; gap: 0.75rem;
border-radius: 0.9rem; border-radius: 0.9rem;
background: rgb(25 27 31 / 0.98); background: rgb(25 27 31 / 0.98);
@@ -395,12 +395,13 @@
} }
.simulation-viewport__settings-head > div, .simulation-viewport__settings-head > div,
.simulation-viewport__settings-group,
.simulation-viewport__settings-switches { .simulation-viewport__settings-switches {
display: grid; display: grid;
gap: 0.32rem; gap: 0.32rem;
} }
.simulation-viewport__settings strong { .simulation-viewport__settings-head strong {
color: var(--nodedc-text-primary); color: var(--nodedc-text-primary);
font-size: 0.72rem; font-size: 0.72rem;
} }
@@ -416,13 +417,29 @@
margin: 0; margin: 0;
} }
.simulation-viewport__settings-switches { .simulation-viewport__settings-group {
gap: 0.55rem; gap: 0.55rem;
border-radius: 0.72rem; border-radius: 0.72rem;
background: rgb(255 255 255 / 0.035); background: rgb(255 255 255 / 0.035);
padding: 0.65rem; padding: 0.65rem;
} }
.simulation-viewport__settings-group-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.simulation-viewport__settings-group-head strong {
color: var(--nodedc-text-secondary);
font-size: 0.62rem;
}
.simulation-viewport__settings-switches {
gap: 0.55rem;
}
.simulation-viewport__toolbar-balance { .simulation-viewport__toolbar-balance {
min-width: 0; min-width: 0;
} }
@@ -69,8 +69,13 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
assert.match(runtime, /camera\.script\?\.create\(CameraControls/); assert.match(runtime, /camera\.script\?\.create\(CameraControls/);
assert.match(runtime, /const HOME_POSITION = new Vec3\(0, 1, 0\)/); assert.match(runtime, /const HOME_POSITION = new Vec3\(0, 1, 0\)/);
assert.match(runtime, /const HOME_FOCUS = new Vec3\(1, 1, 0\)/); assert.match(runtime, /const HOME_FOCUS = new Vec3\(1, 1, 0\)/);
assert.match(runtime, /maximum: \{[^}]*lodRangeMin: 0, lodRangeMax: 0, pixelRatio: 2/); assert.match(runtime, /maximum: \{[^}]*lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2, splatBudget: 4_000_000, targetFps: 60/);
assert.match(runtime, /frame\.mouse\[0\] \*= -1/); assert.match(runtime, /app\.scene\.gsplat\.splatBudget = profile\.splatBudget/);
assert.match(runtime, /app\.autoRender = false/);
assert.match(runtime, /WEBGL_lose_context/);
assert.match(runtime, /selected\.horizontal[^\n]+frame\.mouse\[0\] \*= -1/);
assert.match(runtime, /selected\.vertical[^\n]+frame\.mouse\[1\] \*= -1/);
assert.match(runtime, /setCameraInversion/);
assert.match(runtime, /new Asset\([\s\S]*"container"/); assert.match(runtime, /new Asset\([\s\S]*"container"/);
assert.match(runtime, /instantiateRenderEntity/); assert.match(runtime, /instantiateRenderEntity/);
assert.match(runtime, /applyWorldTransform/); assert.match(runtime, /applyWorldTransform/);
@@ -83,9 +88,11 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
assert.match(viewport, /Collision недоступен/); assert.match(viewport, /Collision недоступен/);
assert.match(viewport, /Для этой сборки collision GLB не был запрошен/); assert.match(viewport, /Для этой сборки collision GLB не был запрошен/);
assert.match(viewport, /<Select[\s\S]*Качество Gaussian-сцены/); assert.match(viewport, /<Select[\s\S]*Качество Gaussian-сцены/);
assert.match(viewport, /Максимум/); assert.match(viewport, /useState<SimulationQuality>\("high"\)/);
assert.match(viewport, /runtimeRef\.current\?\.home\(\)/); assert.match(viewport, /runtimeRef\.current\?\.home\(\)/);
assert.match(viewport, /Настройки системы координат/); assert.match(viewport, /Настройки сцены/);
assert.match(viewport, /Инверсия визуального слоя/); assert.match(viewport, /Инверсия визуального слоя/);
assert.match(viewport, /Инверсия collision-слоя/); assert.match(viewport, /Инверсия collision-слоя/);
assert.match(viewport, /Инверсия управления по горизонтали/);
assert.match(viewport, /Инверсия управления по вертикали/);
}); });