fix(simulation): bound rendering and expose camera inversion
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user