diff --git a/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts b/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts index 85021c1..3201b56 100644 --- a/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts +++ b/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts @@ -22,6 +22,7 @@ import type { SimulationWorldManifest } from "../../core/simulation/projects"; export type SimulationViewMode = "visual" | "collision" | "combined"; export type SimulationQuality = "low" | "medium" | "high" | "ultra" | "maximum"; export type SimulationLayer = "visual" | "collision"; +export type SimulationCameraAxis = "horizontal" | "vertical"; export const PLAYCANVAS_IDENTITY_TRANSFORM = [ 1, 0, 0, 0, @@ -43,6 +44,7 @@ export interface SimulationRuntime { setViewMode(mode: SimulationViewMode): Promise; setQuality(quality: SimulationQuality): void; setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void; + setCameraInversion(axis: SimulationCameraAxis, inverted: boolean): void; home(): void; focusBounds(): void; dispose(): void; @@ -65,12 +67,14 @@ const QUALITY_PROFILES: Record = { - low: { lodBaseDistance: 5, lodMultiplier: 2, lodRangeMin: 3, lodRangeMax: 5, pixelRatio: 0.75 }, - medium: { lodBaseDistance: 5, lodMultiplier: 2, lodRangeMin: 2, lodRangeMax: 5, pixelRatio: 1 }, - high: { lodBaseDistance: 5, lodMultiplier: 3, lodRangeMin: 1, lodRangeMax: 5, pixelRatio: 1.5 }, - ultra: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2 }, - maximum: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 0, pixelRatio: 2 }, + 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, splatBudget: 700_000, targetFps: 45 }, + 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: 1.5, splatBudget: 2_500_000, targetFps: 60 }, + maximum: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2, splatBudget: 4_000_000, targetFps: 60 }, }; export class PlayCanvasRuntime implements SimulationRuntime { @@ -87,10 +91,23 @@ export class PlayCanvasRuntime implements SimulationRuntime { private collisionLoadPromise: Promise | null = null; private resizeObserver: ResizeObserver | null = null; 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 { if (this.app) throw new Error("PlayCanvas runtime уже смонтирован."); + this.disposed = false; + this.renderAccumulatorMs = 0; this.canvas = canvas; canvas.tabIndex = 0; canvas.addEventListener("contextmenu", preventContextMenu); @@ -104,6 +121,8 @@ export class PlayCanvasRuntime implements SimulationRuntime { }, }); this.app = app; + app.autoRender = false; + app.on("frameupdate", this.scheduleFrame); app.setCanvasFillMode(FILLMODE_NONE, 1, 1); app.setCanvasResolution(RESOLUTION_AUTO); 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), }, }) as CameraController | null; - if (controller) invertHorizontalCameraDrag(controller); + if (controller) configureCameraDrag(controller, () => this.cameraInversion); app.root.addChild(camera); this.camera = camera; this.cameraController = controller; @@ -158,6 +177,11 @@ export class PlayCanvasRuntime implements SimulationRuntime { this.visualAsset = asset; await new Promise((resolve, reject) => { const ready = (loaded: Asset) => { + if (this.disposed || !this.app) { + loaded.unload(); + resolve(); + return; + } const visual = new Entity("GaussianWorld"); visual.enabled = false; applyWorldTransform(visual, manifest.transforms.worldFromVisual); @@ -216,6 +240,10 @@ export class PlayCanvasRuntime implements SimulationRuntime { if (this.collisionEntity) applyWorldTransform(this.collisionEntity, transform); } + setCameraInversion(axis: SimulationCameraAxis, inverted: boolean): void { + this.cameraInversion[axis] = inverted; + } + home(): void { if (this.cameraController) { this.cameraController.reset(HOME_FOCUS, HOME_POSITION); @@ -241,10 +269,23 @@ export class PlayCanvasRuntime implements SimulationRuntime { } dispose(): void { + if (this.disposed) return; + this.disposed = true; this.resizeObserver?.disconnect(); this.resizeObserver = null; + const webgl = this.canvas?.getContext("webgl2") ?? this.canvas?.getContext("webgl"); 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) { this.canvas.removeEventListener("contextmenu", preventContextMenu); this.canvas.removeEventListener("pointerdown", focusCanvas, true); @@ -293,6 +334,8 @@ export class PlayCanvasRuntime implements SimulationRuntime { gsplat.lodRangeMax = profile.lodRangeMax; } if (this.app) { + this.app.scene.gsplat.splatBudget = profile.splatBudget; + this.renderIntervalMs = 1000 / profile.targetFps; const devicePixelRatio = window.devicePixelRatio || 1; this.app.graphicsDevice.maxPixelRatio = Math.min(devicePixelRatio, profile.pixelRatio); this.resize(); @@ -310,6 +353,11 @@ export class PlayCanvasRuntime implements SimulationRuntime { this.collisionAsset = asset; await new Promise((resolve, reject) => { asset.ready((loaded) => { + if (this.disposed || !this.app) { + loaded.unload(); + resolve(); + return; + } const resource = loaded.resource as ContainerResource | null; if (!resource) { reject(new Error("PlayCanvas не открыл collision GLB.")); @@ -400,15 +448,20 @@ function focusCanvas(event: Event): void { (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. - // 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; if (!input) return; const read = input.read.bind(input); input.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; }; } diff --git a/apps/control-station/src/components/simulation/SimulationViewport.tsx b/apps/control-station/src/components/simulation/SimulationViewport.tsx index f74360c..b6629ce 100644 --- a/apps/control-station/src/components/simulation/SimulationViewport.tsx +++ b/apps/control-station/src/components/simulation/SimulationViewport.tsx @@ -22,11 +22,12 @@ import { export function SimulationViewport({ project }: { project: SimulationProject }) { const canvasRef = useRef(null); const runtimeRef = useRef(null); + const cameraInversionRef = useRef({ horizontal: true, vertical: false }); const settingsId = useId(); const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting"); const [error, setError] = useState(null); const [viewMode, setViewMode] = useState("visual"); - const [quality, setQuality] = useState("maximum"); + const [quality, setQuality] = useState("high"); const [collisionState, setCollisionState] = useState<"idle" | "loading" | "ready" | "failed">("idle"); const [settingsOpen, setSettingsOpen] = useState(false); const [visualInverted, setVisualInverted] = useState( @@ -35,6 +36,8 @@ export function SimulationViewport({ project }: { project: SimulationProject }) const [collisionInverted, setCollisionInverted] = useState( () => isX180Transform(project.worldManifest?.transforms.worldFromCollision), ); + const [horizontalInverted, setHorizontalInverted] = useState(true); + const [verticalInverted, setVerticalInverted] = useState(false); useEffect(() => { setVisualInverted(isX180Transform(project.worldManifest?.transforms.worldFromVisual)); @@ -53,6 +56,8 @@ export function SimulationViewport({ project }: { project: SimulationProject }) setError(null); void runtime.mount(canvas).then(async () => { if (cancelled) return; + runtime.setCameraInversion("horizontal", cameraInversionRef.current.horizontal); + runtime.setCameraInversion("vertical", cameraInversionRef.current.vertical); setState("loading"); await runtime.loadWorld(manifest); if (!cancelled) setState("ready"); @@ -96,11 +101,11 @@ export function SimulationViewport({ project }: { project: SimulationProject }) runtimeRef.current?.setQuality(next); }} options={[ - { value: "maximum", label: "Максимум", description: "Только полный LOD 0, Retina до 2×" }, - { value: "ultra", label: "Ультра", description: "LOD 0 и Retina до 2×" }, - { value: "high", label: "Высокое", description: "LOD 1 и Retina до 1,5×" }, - { value: "medium", label: "Среднее", description: "LOD 2 и обычное разрешение" }, - { value: "low", label: "Низкое", description: "LOD 3 для слабых устройств" }, + { value: "maximum", label: "Максимум", description: "До 4 млн сплатов · Retina до 2× · 60 FPS" }, + { value: "ultra", label: "Ультра", description: "До 2,5 млн · Retina до 1,5× · 60 FPS" }, + { value: "high", label: "Высокое", description: "До 1,2 млн · Retina до 1,25× · 60 FPS" }, + { value: "medium", label: "Среднее", description: "До 700 тыс. · 1× · 45 FPS" }, + { value: "low", label: "Низкое", description: "До 350 тыс. · 0,75× · 30 FPS" }, ]} minMenuWidth={230} menuWidth={230} @@ -133,7 +138,7 @@ export function SimulationViewport({ project }: { project: SimulationProject })
- Система координат - Мир PlayCanvas · Y вверх + Настройки сцены + Gaussian-мир и камера
setSettingsOpen(false)}>
-

- Коррекция применяется к слоям независимо и не меняет координаты камеры, - навигации и будущей физики. -

-
- { - setVisualInverted(checked); - runtimeRef.current?.setLayerWorldTransform( - "visual", - checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM, - ); - }} - /> - { - setCollisionInverted(checked); - runtimeRef.current?.setLayerWorldTransform( - "collision", - checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM, - ); - }} - /> -
+
+
+ Система координат + Мир PlayCanvas · Y вверх +
+

+ Коррекция слоёв не меняет координаты камеры, навигации и будущей физики. +

+
+ { + setVisualInverted(checked); + runtimeRef.current?.setLayerWorldTransform( + "visual", + checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM, + ); + }} + /> + { + setCollisionInverted(checked); + runtimeRef.current?.setLayerWorldTransform( + "collision", + checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM, + ); + }} + /> +
+
+
+
+ Управление камерой + Мышь и тачпад +
+
+ { + cameraInversionRef.current.horizontal = checked; + setHorizontalInverted(checked); + runtimeRef.current?.setCameraInversion("horizontal", checked); + }} + /> + { + cameraInversionRef.current.vertical = checked; + setVerticalInverted(checked); + runtimeRef.current?.setCameraInversion("vertical", checked); + }} + /> +
+
) : null} diff --git a/apps/control-station/src/styles/simulation.css b/apps/control-station/src/styles/simulation.css index 96c0449..22716d1 100644 --- a/apps/control-station/src/styles/simulation.css +++ b/apps/control-station/src/styles/simulation.css @@ -379,7 +379,7 @@ top: calc(100% + 0.65rem); right: 0; display: grid; - width: min(19rem, calc(100cqw - 1.3rem)); + width: min(21rem, calc(100cqw - 1.3rem)); gap: 0.75rem; border-radius: 0.9rem; background: rgb(25 27 31 / 0.98); @@ -395,12 +395,13 @@ } .simulation-viewport__settings-head > div, +.simulation-viewport__settings-group, .simulation-viewport__settings-switches { display: grid; gap: 0.32rem; } -.simulation-viewport__settings strong { +.simulation-viewport__settings-head strong { color: var(--nodedc-text-primary); font-size: 0.72rem; } @@ -416,13 +417,29 @@ margin: 0; } -.simulation-viewport__settings-switches { +.simulation-viewport__settings-group { gap: 0.55rem; border-radius: 0.72rem; background: rgb(255 255 255 / 0.035); 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 { min-width: 0; } diff --git a/apps/control-station/test/simulationWorkspace.test.mjs b/apps/control-station/test/simulationWorkspace.test.mjs index a313cb6..ceb1ef0 100644 --- a/apps/control-station/test/simulationWorkspace.test.mjs +++ b/apps/control-station/test/simulationWorkspace.test.mjs @@ -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, /const HOME_POSITION = new Vec3\(0, 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, /frame\.mouse\[0\] \*= -1/); + assert.match(runtime, /maximum: \{[^}]*lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2, splatBudget: 4_000_000, targetFps: 60/); + 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, /instantiateRenderEntity/); 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 GLB не был запрошен/); assert.match(viewport, /\("high"\)/); assert.match(viewport, /runtimeRef\.current\?\.home\(\)/); - assert.match(viewport, /Настройки системы координат/); + assert.match(viewport, /Настройки сцены/); assert.match(viewport, /Инверсия визуального слоя/); assert.match(viewport, /Инверсия collision-слоя/); + assert.match(viewport, /Инверсия управления по горизонтали/); + assert.match(viewport, /Инверсия управления по вертикали/); });