fix: start world view inside scene
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-06 01:54:45 +08:00
parent 94478e469f
commit b6b14d739f

View File

@ -6,11 +6,6 @@ import { Canvas, type ThreeEvent, useFrame, useThree } from "@react-three/fiber"
import { SparkRenderer, SplatMesh as SparkSplatMesh } from "@sparkjsdev/spark";
import {
AlertCircle,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
Box,
Crosshair,
Globe,
ImageIcon,
@ -206,6 +201,73 @@ function getSemanticFacts(metadata?: Record<string, unknown> | null) {
return facts;
}
function positionCameraInsideWorld({
camera,
controls,
splatMesh,
groundPlaneOffset,
}: {
camera: THREE.Camera;
controls: { target?: THREE.Vector3; update?: () => void } | null;
splatMesh: SparkSplatMesh;
groundPlaneOffset: number | null;
}) {
if (!(camera instanceof THREE.PerspectiveCamera)) return;
splatMesh.updateMatrixWorld(true);
const worldBounds = splatMesh.getBoundingBox(true).clone().applyMatrix4(splatMesh.matrixWorld);
if (worldBounds.isEmpty()) return;
const center = worldBounds.getCenter(new THREE.Vector3());
const size = worldBounds.getSize(new THREE.Vector3());
const horizontalAxis =
size.z >= size.x ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(1, 0, 0);
const majorSpan = Math.max(horizontalAxis.z === 1 ? size.z : size.x, 1.4);
const marginX = Math.max(size.x * 0.12, 0.35);
const marginZ = Math.max(size.z * 0.12, 0.35);
const eyeY =
groundPlaneOffset !== null
? groundPlaneOffset + 1.58
: center.y - size.y * 0.08 + Math.max(size.y * 0.22, 0.9);
const anchor = new THREE.Vector3(center.x, eyeY, center.z);
const cameraPosition = anchor
.clone()
.addScaledVector(horizontalAxis, Math.max(majorSpan * 0.12, 0.65));
const targetPosition = anchor
.clone()
.addScaledVector(horizontalAxis, -Math.max(majorSpan * 0.2, 1.1));
cameraPosition.x = THREE.MathUtils.clamp(
cameraPosition.x,
worldBounds.min.x + marginX,
worldBounds.max.x - marginX
);
cameraPosition.z = THREE.MathUtils.clamp(
cameraPosition.z,
worldBounds.min.z + marginZ,
worldBounds.max.z - marginZ
);
targetPosition.x = THREE.MathUtils.clamp(
targetPosition.x,
worldBounds.min.x + marginX * 0.5,
worldBounds.max.x - marginX * 0.5
);
targetPosition.z = THREE.MathUtils.clamp(
targetPosition.z,
worldBounds.min.z + marginZ * 0.5,
worldBounds.max.z - marginZ * 0.5
);
camera.position.copy(cameraPosition);
camera.lookAt(targetPosition);
camera.updateProjectionMatrix();
if (controls?.target) {
controls.target.copy(targetPosition);
controls.update?.();
}
}
function WorldEmptyState({
icon: Icon,
title,
@ -291,87 +353,13 @@ function createMovementState(): MovementState {
return { ...EMPTY_MOVEMENT_STATE };
}
function DirectionPad({
onCommandChange,
}: {
onCommandChange: (command: MovementCommand, active: boolean) => void;
}) {
const pressHandlers = (command: MovementCommand) => ({
onMouseDown: () => onCommandChange(command, true),
onMouseUp: () => onCommandChange(command, false),
onMouseLeave: () => onCommandChange(command, false),
onTouchStart: () => onCommandChange(command, true),
onTouchEnd: () => onCommandChange(command, false),
onTouchCancel: () => onCommandChange(command, false),
});
return (
<div className="pointer-events-auto rounded-2xl border border-white/10 bg-black/55 p-2 shadow-2xl backdrop-blur-sm">
<div className="mb-2 text-center text-[10px] text-zinc-400">方向移动</div>
<div className="grid grid-cols-3 gap-2">
<div />
<button
className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-zinc-100 transition hover:bg-white/10"
type="button"
{...pressHandlers("forward")}
>
<ArrowUp className="h-4 w-4" />
</button>
<button
className="flex h-10 min-w-[3.25rem] items-center justify-center rounded-xl border border-white/10 bg-white/5 px-2 text-xs text-zinc-100 transition hover:bg-white/10"
type="button"
{...pressHandlers("up")}
>
上
</button>
<button
className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-zinc-100 transition hover:bg-white/10"
type="button"
{...pressHandlers("left")}
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex h-10 items-center justify-center rounded-xl border border-dashed border-white/10 bg-white/[0.03] px-2 text-[10px] text-zinc-500">
前后左右
</div>
<button
className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-zinc-100 transition hover:bg-white/10"
type="button"
{...pressHandlers("right")}
>
<ArrowRight className="h-4 w-4" />
</button>
<div />
<button
className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-zinc-100 transition hover:bg-white/10"
type="button"
{...pressHandlers("backward")}
>
<ArrowDown className="h-4 w-4" />
</button>
<button
className="flex h-10 min-w-[3.25rem] items-center justify-center rounded-xl border border-white/10 bg-white/5 px-2 text-xs text-zinc-100 transition hover:bg-white/10"
type="button"
{...pressHandlers("down")}
>
下
</button>
</div>
</div>
);
}
function CameraMovementControls({
enabled,
firstPerson,
virtualMovement,
orbitControlsRef,
}: {
enabled: boolean;
firstPerson: boolean;
virtualMovement: MovementState;
orbitControlsRef: React.MutableRefObject<any>;
}) {
const { camera } = useThree();
@ -427,8 +415,7 @@ function CameraMovementControls({
if (!enabled) return;
const pressed = pressedKeysRef.current;
const hasMovement =
Object.values(pressed).some(Boolean) || Object.values(virtualMovement).some(Boolean);
const hasMovement = Object.values(pressed).some(Boolean);
if (!hasMovement) return;
const moveSpeed = 3.8;
@ -441,22 +428,22 @@ function CameraMovementControls({
moveRef.current.set(0, 0, 0);
if (pressed.forward || virtualMovement.forward) {
if (pressed.forward) {
moveRef.current.add(directionRef.current);
}
if (pressed.backward || virtualMovement.backward) {
if (pressed.backward) {
moveRef.current.addScaledVector(directionRef.current, -1);
}
if (pressed.left || virtualMovement.left) {
if (pressed.left) {
moveRef.current.addScaledVector(rightRef.current, -1);
}
if (pressed.right || virtualMovement.right) {
if (pressed.right) {
moveRef.current.add(rightRef.current);
}
if (pressed.up || virtualMovement.up) {
if (pressed.up) {
moveRef.current.add(upRef.current);
}
if (pressed.down || virtualMovement.down) {
if (pressed.down) {
moveRef.current.addScaledVector(upRef.current, -1);
}
@ -523,10 +510,10 @@ function WorldSplatScene({
semanticsMetadata?: Record<string, unknown> | null;
onLoadStateChange?: (state: "ready" | "error") => void;
}) {
const camera = useThree((state) => state.camera);
const renderer = useThree((state) => state.gl);
const orbitControlsRef = useRef<any>(null);
const [virtualMovement, setVirtualMovement] =
useState<MovementState>(createMovementState);
const hasAppliedInitialViewRef = useRef(false);
const semanticFacts = useMemo(
() => getSemanticFacts(semanticsMetadata),
[semanticsMetadata]
@ -543,6 +530,10 @@ function WorldSplatScene({
[renderer]
);
const splatMesh = useMemo(() => new SparkSplatMesh({ url: splatUrl }), [splatUrl]);
const groundPlaneOffset =
typeof semanticsMetadata?.ground_plane_offset === "number"
? -semanticsMetadata.ground_plane_offset
: null;
useEffect(() => {
sparkRenderer.frustumCulled = false;
@ -561,6 +552,7 @@ function WorldSplatScene({
}, [revealPhase]);
useEffect(() => {
hasAppliedInitialViewRef.current = false;
splatMesh.frustumCulled = false;
splatMesh.rotation.x = Math.PI;
applySparkRevealProfile(sparkRenderer, splatMesh, SPLAT_LOADING_PROFILE);
@ -569,7 +561,17 @@ function WorldSplatScene({
splatMesh.initialized
.then(() => {
if (cancelled) return;
onLoadStateChange?.("ready");
window.requestAnimationFrame(() => {
if (cancelled || hasAppliedInitialViewRef.current) return;
positionCameraInsideWorld({
camera,
controls: orbitControlsRef.current,
groundPlaneOffset,
splatMesh,
});
hasAppliedInitialViewRef.current = true;
onLoadStateChange?.("ready");
});
})
.catch(() => {
if (!cancelled) onLoadStateChange?.("error");
@ -578,7 +580,7 @@ function WorldSplatScene({
cancelled = true;
splatMesh.dispose();
};
}, [onLoadStateChange, sparkRenderer, splatMesh]);
}, [camera, groundPlaneOffset, onLoadStateChange, sparkRenderer, splatMesh]);
useFrame(() => {
const phase = revealPhaseRef.current;
@ -624,21 +626,6 @@ function WorldSplatScene({
sparkRenderer.needsUpdate = true;
});
const groundPlaneOffset =
typeof semanticsMetadata?.ground_plane_offset === "number"
? -semanticsMetadata.ground_plane_offset
: null;
const handleDirectionChange = (command: MovementCommand, active: boolean) => {
setVirtualMovement((prev) => {
if (prev[command] === active) return prev;
return {
...prev,
[command]: active,
};
});
};
const handlePointerDown = (event: ThreeEvent<PointerEvent>) => {
if (interactionMode !== "hotspot") return;
event.stopPropagation();
@ -675,11 +662,12 @@ function WorldSplatScene({
enabled={interactionMode === "navigate"}
firstPerson={firstPerson}
orbitControlsRef={orbitControlsRef}
virtualMovement={virtualMovement}
/>
{!firstPerson ? (
<OrbitControls
autoRotate={false}
dampingFactor={0}
enableDamping={false}
enablePan
enableRotate
enableZoom
@ -690,6 +678,8 @@ function WorldSplatScene({
/>
) : (
<OrbitControls
dampingFactor={0}
enableDamping={false}
enablePan={false}
enableRotate={false}
enableZoom={false}
@ -719,23 +709,13 @@ function WorldSplatScene({
</div>
) : null}
</div>
<div className="flex items-end justify-between">
<div className="rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-[11px] text-zinc-300 backdrop-blur-sm">
{firstPerson
? "方向键 / WASD 前后左右 · Q/E 或 PgUp/PgDn 上下 · 鼠标观察 · Esc 退出"
: "拖拽旋转 · 滚轮缩放 · 方向键 / 按钮可做六向移动"}
{(firstPerson || interactionMode === "hotspot") && (
<div className="flex items-end justify-end">
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-white/10 bg-black/55 text-zinc-100 shadow-xl backdrop-blur-sm">
<Crosshair className="h-5 w-5" />
</div>
</div>
<div className="flex items-end gap-3">
{interactionMode === "navigate" ? (
<DirectionPad onCommandChange={handleDirectionChange} />
) : null}
{(firstPerson || interactionMode === "hotspot") && (
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-white/10 bg-black/55 text-zinc-100 shadow-xl backdrop-blur-sm">
<Crosshair className="h-5 w-5" />
</div>
)}
</div>
</div>
)}
</div>
</Html>
</>