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 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<void>;
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<SimulationQuality, {
lodRangeMin: number;
lodRangeMax: number;
pixelRatio: number;
splatBudget: number;
targetFps: number;
}> = {
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<void> | 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<void> {
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<void>((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<void>((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;
};
}
@@ -22,11 +22,12 @@ import {
export function SimulationViewport({ project }: { project: SimulationProject }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
const cameraInversionRef = useRef({ horizontal: true, vertical: false });
const settingsId = useId();
const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting");
const [error, setError] = useState<string | null>(null);
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 [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 })
</Button>
<div className="simulation-viewport__settings-anchor">
<IconButton
label="Настройки системы координат"
label="Настройки сцены"
aria-controls={settingsId}
aria-expanded={settingsOpen}
aria-pressed={settingsOpen}
@@ -146,47 +151,80 @@ export function SimulationViewport({ project }: { project: SimulationProject })
id={settingsId}
className="simulation-viewport__settings"
role="dialog"
aria-label="Настройки системы координат"
aria-label="Настройки сцены"
>
<div className="simulation-viewport__settings-head">
<div>
<strong>Система координат</strong>
<span>Мир PlayCanvas · Y вверх</span>
<strong>Настройки сцены</strong>
<span>Gaussian-мир и камера</span>
</div>
<IconButton label="Закрыть настройки" onClick={() => setSettingsOpen(false)}>
<Icon name="close" size={16} />
</IconButton>
</div>
<p>
Коррекция применяется к слоям независимо и не меняет координаты камеры,
навигации и будущей физики.
</p>
<div className="simulation-viewport__settings-switches">
<Switch
checked={visualInverted}
disabled={state !== "ready"}
label="Инверсия визуального слоя"
onChange={(checked) => {
setVisualInverted(checked);
runtimeRef.current?.setLayerWorldTransform(
"visual",
checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM,
);
}}
/>
<Switch
checked={collisionInverted}
disabled={state !== "ready"}
label="Инверсия collision-слоя"
onChange={(checked) => {
setCollisionInverted(checked);
runtimeRef.current?.setLayerWorldTransform(
"collision",
checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM,
);
}}
/>
</div>
<section className="simulation-viewport__settings-group">
<div className="simulation-viewport__settings-group-head">
<strong>Система координат</strong>
<span>Мир PlayCanvas · Y вверх</span>
</div>
<p>
Коррекция слоёв не меняет координаты камеры, навигации и будущей физики.
</p>
<div className="simulation-viewport__settings-switches">
<Switch
checked={visualInverted}
disabled={state !== "ready"}
label="Инверсия визуального слоя"
onChange={(checked) => {
setVisualInverted(checked);
runtimeRef.current?.setLayerWorldTransform(
"visual",
checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM,
);
}}
/>
<Switch
checked={collisionInverted}
disabled={state !== "ready"}
label="Инверсия collision-слоя"
onChange={(checked) => {
setCollisionInverted(checked);
runtimeRef.current?.setLayerWorldTransform(
"collision",
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>
) : null}
</div>
+20 -3
View File
@@ -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;
}
@@ -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, /<Select[\s\S]*Качество Gaussian-сцены/);
assert.match(viewport, /Максимум/);
assert.match(viewport, /useState<SimulationQuality>\("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, /Инверсия управления по вертикали/);
});