feat(simulation): optimize and persist collision viewing
This commit is contained in:
@@ -11,16 +11,21 @@ import {
|
||||
StandardMaterial,
|
||||
Vec2,
|
||||
Vec3,
|
||||
WasmModule,
|
||||
type ContainerResource,
|
||||
type RenderComponent,
|
||||
type ScriptType,
|
||||
} from "playcanvas";
|
||||
import { CameraControls } from "playcanvas/scripts/esm/camera-controls.mjs";
|
||||
|
||||
import type { SimulationWorldManifest } from "../../core/simulation/projects";
|
||||
import type {
|
||||
SimulationTransformAxis,
|
||||
SimulationViewerQuality,
|
||||
SimulationWorldManifest,
|
||||
} from "../../core/simulation/projects";
|
||||
|
||||
export type SimulationViewMode = "visual" | "collision" | "combined";
|
||||
export type SimulationQuality = "low" | "medium" | "high" | "ultra" | "maximum";
|
||||
export type SimulationQuality = SimulationViewerQuality;
|
||||
export type SimulationLayer = "visual" | "collision";
|
||||
export type SimulationCameraAxis = "horizontal" | "vertical";
|
||||
|
||||
@@ -38,6 +43,27 @@ export const PLAYCANVAS_X_180_TRANSFORM = [
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
export const PLAYCANVAS_Y_180_TRANSFORM = [
|
||||
-1, 0, 0, 0,
|
||||
0, 1, 0, 0,
|
||||
0, 0, -1, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
export const PLAYCANVAS_Z_180_TRANSFORM = [
|
||||
-1, 0, 0, 0,
|
||||
0, -1, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
export function playCanvasAxisTransform(axis: SimulationTransformAxis, inverted: boolean): number[] {
|
||||
if (!inverted) return PLAYCANVAS_IDENTITY_TRANSFORM;
|
||||
if (axis === "x") return PLAYCANVAS_X_180_TRANSFORM;
|
||||
if (axis === "y") return PLAYCANVAS_Y_180_TRANSFORM;
|
||||
return PLAYCANVAS_Z_180_TRANSFORM;
|
||||
}
|
||||
|
||||
export interface SimulationRuntime {
|
||||
mount(canvas: HTMLCanvasElement): Promise<void>;
|
||||
loadWorld(manifest: SimulationWorldManifest): Promise<void>;
|
||||
@@ -60,6 +86,7 @@ type CameraController = ScriptType & Pick<CameraControls, "reset" | "focus"> & {
|
||||
|
||||
const HOME_POSITION = new Vec3(0, 1, 0);
|
||||
const HOME_FOCUS = new Vec3(1, 1, 0);
|
||||
const DRACO_DECODER_ROOT = "/vendor/playcanvas/draco/1.5.7";
|
||||
|
||||
const QUALITY_PROFILES: Record<SimulationQuality, {
|
||||
lodBaseDistance: number;
|
||||
@@ -109,6 +136,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
this.disposed = false;
|
||||
this.renderAccumulatorMs = 0;
|
||||
this.canvas = canvas;
|
||||
configureDracoDecoder();
|
||||
canvas.tabIndex = 0;
|
||||
canvas.addEventListener("contextmenu", preventContextMenu);
|
||||
canvas.addEventListener("pointerdown", focusCanvas, true);
|
||||
@@ -217,9 +245,13 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
async setViewMode(mode: SimulationViewMode): Promise<void> {
|
||||
this.viewMode = mode;
|
||||
this.applyViewMode();
|
||||
if (mode !== "visual" && !this.collisionEntity) {
|
||||
await this.ensureCollision();
|
||||
this.applyViewMode();
|
||||
try {
|
||||
if (mode !== "visual" && !this.collisionEntity) {
|
||||
await this.ensureCollision();
|
||||
this.applyViewMode();
|
||||
}
|
||||
} finally {
|
||||
this.canvas?.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +476,15 @@ function preventContextMenu(event: Event): void {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function configureDracoDecoder(): void {
|
||||
if (WasmModule.getConfig("DracoDecoderModule")) return;
|
||||
WasmModule.setConfig("DracoDecoderModule", {
|
||||
glueUrl: `${DRACO_DECODER_ROOT}/draco_wasm_wrapper_gltf.js`,
|
||||
wasmUrl: `${DRACO_DECODER_ROOT}/draco_decoder_gltf.wasm`,
|
||||
numWorkers: 2,
|
||||
});
|
||||
}
|
||||
|
||||
function focusCanvas(event: Event): void {
|
||||
(event.currentTarget as HTMLCanvasElement | null)?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
@@ -10,40 +10,101 @@ import {
|
||||
Switch,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { SimulationProject } from "../../core/simulation/projects";
|
||||
import {
|
||||
PLAYCANVAS_IDENTITY_TRANSFORM,
|
||||
PLAYCANVAS_X_180_TRANSFORM,
|
||||
saveSimulationViewerSettings,
|
||||
type SimulationProject,
|
||||
type SimulationTransformAxis,
|
||||
type SimulationViewerSettings,
|
||||
} from "../../core/simulation/projects";
|
||||
import {
|
||||
createLatestAsyncCommitter,
|
||||
type LatestAsyncCommitter,
|
||||
} from "../../core/runtime/latestAsyncCommitter";
|
||||
import {
|
||||
PlayCanvasRuntime,
|
||||
playCanvasAxisTransform,
|
||||
type SimulationQuality,
|
||||
type SimulationViewMode,
|
||||
} from "./PlayCanvasRuntime";
|
||||
|
||||
export function SimulationViewport({ project }: { project: SimulationProject }) {
|
||||
const AXIS_OPTIONS: Array<{ value: SimulationTransformAxis; label: string }> = [
|
||||
{ value: "x", label: "X" },
|
||||
{ value: "y", label: "Y" },
|
||||
{ value: "z", label: "Z" },
|
||||
];
|
||||
|
||||
export function SimulationViewport({
|
||||
project,
|
||||
onProjectChange,
|
||||
}: {
|
||||
project: SimulationProject;
|
||||
onProjectChange?: (project: SimulationProject) => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
|
||||
const cameraInversionRef = useRef({ horizontal: true, vertical: false });
|
||||
const viewerSettingsRef = useRef(project.viewerSettings);
|
||||
const settingsCommitterRef = useRef<LatestAsyncCommitter<SimulationViewerSettings> | null>(null);
|
||||
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>("high");
|
||||
const [quality, setQuality] = useState<SimulationQuality>(project.viewerSettings.quality);
|
||||
const [collisionState, setCollisionState] = useState<"idle" | "loading" | "ready" | "failed">("idle");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [visualInverted, setVisualInverted] = useState(
|
||||
() => isX180Transform(project.worldManifest?.transforms.worldFromVisual),
|
||||
const [visualInverted, setVisualInverted] = useState(project.viewerSettings.visual.inverted);
|
||||
const [visualAxis, setVisualAxis] = useState(project.viewerSettings.visual.axis);
|
||||
const [collisionInverted, setCollisionInverted] = useState(project.viewerSettings.collision.inverted);
|
||||
const [collisionAxis, setCollisionAxis] = useState(project.viewerSettings.collision.axis);
|
||||
const [horizontalInverted, setHorizontalInverted] = useState(
|
||||
project.viewerSettings.camera.invertHorizontal,
|
||||
);
|
||||
const [collisionInverted, setCollisionInverted] = useState(
|
||||
() => isX180Transform(project.worldManifest?.transforms.worldFromCollision),
|
||||
const [verticalInverted, setVerticalInverted] = useState(
|
||||
project.viewerSettings.camera.invertVertical,
|
||||
);
|
||||
const [horizontalInverted, setHorizontalInverted] = useState(true);
|
||||
const [verticalInverted, setVerticalInverted] = useState(false);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setVisualInverted(isX180Transform(project.worldManifest?.transforms.worldFromVisual));
|
||||
setCollisionInverted(isX180Transform(project.worldManifest?.transforms.worldFromCollision));
|
||||
const settings = project.viewerSettings;
|
||||
viewerSettingsRef.current = settings;
|
||||
setQuality(settings.quality);
|
||||
setVisualInverted(settings.visual.inverted);
|
||||
setVisualAxis(settings.visual.axis);
|
||||
setCollisionInverted(settings.collision.inverted);
|
||||
setCollisionAxis(settings.collision.axis);
|
||||
setHorizontalInverted(settings.camera.invertHorizontal);
|
||||
setVerticalInverted(settings.camera.invertVertical);
|
||||
setSettingsError(null);
|
||||
setSettingsOpen(false);
|
||||
}, [project.projectId, project.worldManifest]);
|
||||
}, [project.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const committer = createLatestAsyncCommitter<SimulationViewerSettings>({
|
||||
async commit(settings) {
|
||||
try {
|
||||
const saved = await saveSimulationViewerSettings(project.projectId, settings);
|
||||
if (active) onProjectChange?.(saved);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
if (active) {
|
||||
setSettingsError(
|
||||
caught instanceof Error ? caught.message : "Не удалось сохранить настройки сцены.",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onSettled({ applied, superseded }) {
|
||||
if (active && applied && !superseded) setSettingsError(null);
|
||||
},
|
||||
});
|
||||
settingsCommitterRef.current = committer;
|
||||
return () => {
|
||||
active = false;
|
||||
committer.dispose();
|
||||
if (settingsCommitterRef.current === committer) settingsCommitterRef.current = null;
|
||||
};
|
||||
}, [onProjectChange, project.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -56,10 +117,20 @@ 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);
|
||||
const settings = viewerSettingsRef.current;
|
||||
runtime.setQuality(settings.quality);
|
||||
runtime.setCameraInversion("horizontal", settings.camera.invertHorizontal);
|
||||
runtime.setCameraInversion("vertical", settings.camera.invertVertical);
|
||||
setState("loading");
|
||||
await runtime.loadWorld(manifest);
|
||||
runtime.setLayerWorldTransform(
|
||||
"visual",
|
||||
playCanvasAxisTransform(settings.visual.axis, settings.visual.inverted),
|
||||
);
|
||||
runtime.setLayerWorldTransform(
|
||||
"collision",
|
||||
playCanvasAxisTransform(settings.collision.axis, settings.collision.inverted),
|
||||
);
|
||||
if (!cancelled) setState("ready");
|
||||
}).catch((caught: unknown) => {
|
||||
if (cancelled) return;
|
||||
@@ -74,6 +145,10 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
||||
}, [project.projectId, project.worldManifest]);
|
||||
|
||||
const collisionAvailable = project.worldManifest?.collision.available ?? false;
|
||||
const commitViewerSettings = (settings: SimulationViewerSettings) => {
|
||||
viewerSettingsRef.current = settings;
|
||||
settingsCommitterRef.current?.enqueue(settings);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="simulation-viewport" aria-label={`Сцена ${project.name}`}>
|
||||
@@ -99,6 +174,7 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
||||
onChange={(next) => {
|
||||
setQuality(next);
|
||||
runtimeRef.current?.setQuality(next);
|
||||
commitViewerSettings({ ...viewerSettingsRef.current, quality: next });
|
||||
}}
|
||||
options={[
|
||||
{ value: "maximum", label: "Максимум", description: "До 4 млн сплатов · Retina до 2× · 60 FPS" },
|
||||
@@ -171,30 +247,82 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
||||
Коррекция слоёв не меняет координаты камеры, навигации и будущей физики.
|
||||
</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 className="simulation-viewport__settings-layer-row">
|
||||
<Switch
|
||||
checked={visualInverted}
|
||||
disabled={state !== "ready"}
|
||||
label="Инверсия визуального слоя"
|
||||
onChange={(checked) => {
|
||||
setVisualInverted(checked);
|
||||
runtimeRef.current?.setLayerWorldTransform(
|
||||
"visual",
|
||||
playCanvasAxisTransform(visualAxis, checked),
|
||||
);
|
||||
commitViewerSettings({
|
||||
...viewerSettingsRef.current,
|
||||
visual: { ...viewerSettingsRef.current.visual, inverted: checked },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
className="simulation-viewport__axis-select"
|
||||
label="Ось инверсии визуального слоя"
|
||||
value={visualAxis}
|
||||
options={AXIS_OPTIONS}
|
||||
disabled={state !== "ready"}
|
||||
minMenuWidth={72}
|
||||
menuWidth={72}
|
||||
onChange={(axis) => {
|
||||
setVisualAxis(axis);
|
||||
runtimeRef.current?.setLayerWorldTransform(
|
||||
"visual",
|
||||
playCanvasAxisTransform(axis, visualInverted),
|
||||
);
|
||||
commitViewerSettings({
|
||||
...viewerSettingsRef.current,
|
||||
visual: { ...viewerSettingsRef.current.visual, axis },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="simulation-viewport__settings-layer-row">
|
||||
<Switch
|
||||
checked={collisionInverted}
|
||||
disabled={state !== "ready"}
|
||||
label="Инверсия collision-слоя"
|
||||
onChange={(checked) => {
|
||||
setCollisionInverted(checked);
|
||||
runtimeRef.current?.setLayerWorldTransform(
|
||||
"collision",
|
||||
playCanvasAxisTransform(collisionAxis, checked),
|
||||
);
|
||||
commitViewerSettings({
|
||||
...viewerSettingsRef.current,
|
||||
collision: { ...viewerSettingsRef.current.collision, inverted: checked },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
className="simulation-viewport__axis-select"
|
||||
label="Ось инверсии collision-слоя"
|
||||
value={collisionAxis}
|
||||
options={AXIS_OPTIONS}
|
||||
disabled={state !== "ready"}
|
||||
minMenuWidth={72}
|
||||
menuWidth={72}
|
||||
onChange={(axis) => {
|
||||
setCollisionAxis(axis);
|
||||
runtimeRef.current?.setLayerWorldTransform(
|
||||
"collision",
|
||||
playCanvasAxisTransform(axis, collisionInverted),
|
||||
);
|
||||
commitViewerSettings({
|
||||
...viewerSettingsRef.current,
|
||||
collision: { ...viewerSettingsRef.current.collision, axis },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="simulation-viewport__settings-group">
|
||||
@@ -208,9 +336,12 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
||||
disabled={state !== "ready"}
|
||||
label="Инверсия управления по горизонтали"
|
||||
onChange={(checked) => {
|
||||
cameraInversionRef.current.horizontal = checked;
|
||||
setHorizontalInverted(checked);
|
||||
runtimeRef.current?.setCameraInversion("horizontal", checked);
|
||||
commitViewerSettings({
|
||||
...viewerSettingsRef.current,
|
||||
camera: { ...viewerSettingsRef.current.camera, invertHorizontal: checked },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
@@ -218,13 +349,21 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
||||
disabled={state !== "ready"}
|
||||
label="Инверсия управления по вертикали"
|
||||
onChange={(checked) => {
|
||||
cameraInversionRef.current.vertical = checked;
|
||||
setVerticalInverted(checked);
|
||||
runtimeRef.current?.setCameraInversion("vertical", checked);
|
||||
commitViewerSettings({
|
||||
...viewerSettingsRef.current,
|
||||
camera: { ...viewerSettingsRef.current.camera, invertVertical: checked },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{settingsError ? (
|
||||
<p className="simulation-viewport__settings-error" role="alert">
|
||||
Настройки не сохранены: {settingsError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -255,8 +394,3 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function isX180Transform(transform: number[] | undefined): boolean {
|
||||
if (!transform || transform.length !== 16) return false;
|
||||
return transform.every((value, index) => value === PLAYCANVAS_X_180_TRANSFORM[index]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user