feat(simulation): optimize and persist collision viewing

This commit is contained in:
DCCONSTRUCTIONS
2026-08-26 15:18:34 +03:00
parent 5e97fa293f
commit a65ae9c285
13 changed files with 662 additions and 65 deletions
@@ -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]);
}