diff --git a/docs/Features/3D-Layers.md b/docs/Features/3D-Layers.md
index bd0fe4a70..6456d4de2 100644
--- a/docs/Features/3D-Layers.md
+++ b/docs/Features/3D-Layers.md
@@ -86,7 +86,9 @@ Camera clips expose their own Properties tab with:
- Near plane
- Far plane
-The Transform tab becomes scene-navigation controls for the active camera clip. In FPS mode, the preview accepts WASD/QE navigation plus mouse look. Free scene navigation now belongs to camera clips rather than gaussian-splat clips.
+The Transform tab becomes scene-navigation controls for the active camera clip. In FPS mode, the preview accepts WASD/QE navigation plus uncapped mouse look. Free scene navigation now belongs to camera clips rather than gaussian-splat clips.
+
+Camera rotation keyframes interpolate through the shortest angular path so timeline flights do not spin the long way around when yaw, pitch, or roll crosses a 360-degree wrap. FPS-look camera segments with keyed position/forward travel render through world-pose interpolation, keeping vertical-look roll moves from drifting away between keyframes.
## Gaussian Splats
@@ -98,6 +100,7 @@ Gaussian splat clips are imported through the SuperSplat-compatible `@playcanvas
- Realtime splat rendering uses a worker-backed back-to-front order buffer based on the SuperSplat/PlayCanvas sorter approach. Precise export can still fall back to the existing GPU sort path.
- Sequence splats follow the same shared runtime contract and are no longer treated as a permanent legacy-only scene path.
- The Transform tab now exposes normal object transforms for gaussian splats. Scene navigation lives on camera clips.
+- Large gaussian splats show viewport loading progress during project restore, URL fetch, parser work, normalization, and GPU upload.
Some gaussian-splat settings exist in the data model and export pipeline but are not yet surfaced as a full dedicated UI:
diff --git a/src/App.css b/src/App.css
index 40243c905..f81a2f425 100644
--- a/src/App.css
+++ b/src/App.css
@@ -10041,6 +10041,63 @@ input[type="checkbox"] {
pointer-events: none;
}
+.preview-splat-progress-overlay {
+ position: absolute;
+ left: 50%;
+ bottom: 42px;
+ width: min(460px, calc(100% - 32px));
+ transform: translateX(-50%);
+ padding: 10px 12px;
+ color: var(--text-primary);
+ background: rgba(10, 12, 16, 0.86);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ border-radius: 6px;
+ box-shadow: 0 10px 32px rgba(0, 0, 0, 0.32);
+ backdrop-filter: blur(8px);
+ z-index: 24;
+ pointer-events: none;
+}
+
+.preview-splat-progress-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.preview-splat-progress-name {
+ margin-top: 3px;
+ color: var(--text-secondary);
+ font-size: 11px;
+ line-height: 1.35;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.preview-splat-progress-track {
+ height: 5px;
+ margin-top: 8px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.14);
+ border-radius: 999px;
+}
+
+.preview-splat-progress-fill {
+ height: 100%;
+ width: 0;
+ background: var(--accent);
+ border-radius: inherit;
+ transition: width 0.16s ease-out;
+}
+
+.preview-splat-progress-overlay.error .preview-splat-progress-fill {
+ background: var(--danger);
+}
+
.preview-canvas-wrapper {
transform-origin: center center;
transition: transform 0.1s ease-out;
diff --git a/src/changelog-data.json b/src/changelog-data.json
index 51cdb023e..a687de356 100644
--- a/src/changelog-data.json
+++ b/src/changelog-data.json
@@ -1,4 +1,25 @@
[
+ {
+ "date": "2026-04-24",
+ "type": "fix",
+ "title": "3D Video Planes Now Render Through the Active Scene Camera",
+ "description": "Video clips converted into 3D layers now share the same renderable scene camera path as splats and nested compositions, so the 3D viewport no longer disappears or breaks when video planes are added.",
+ "section": "3D / Native Scene"
+ },
+ {
+ "date": "2026-04-24",
+ "type": "improve",
+ "title": "Gaussian Splat Loading Shows Viewport Progress",
+ "description": "Large splats now report fetch, read, parse, and upload progress directly in the preview viewport during import, refresh, and timeline restore.",
+ "section": "3D / Gaussian Splats"
+ },
+ {
+ "date": "2026-04-24",
+ "type": "fix",
+ "title": "FPS Camera Rotation Tweens as a Real World Pose",
+ "description": "FPS camera look is no longer pitch-capped, and camera keyframes now interpolate the recorded world pose so vertical look and roll-style moves avoid unwanted fly-out arcs between keyframes.",
+ "section": "3D / Camera Controls"
+ },
{
"date": "2026-04-24",
"type": "new",
diff --git a/src/components/preview/Preview.tsx b/src/components/preview/Preview.tsx
index c126a90fb..6a1ab4148 100644
--- a/src/components/preview/Preview.tsx
+++ b/src/components/preview/Preview.tsx
@@ -8,6 +8,7 @@ const log = Logger.create('Preview');
import { useEngine } from '../../hooks/useEngine';
import { useShortcut } from '../../hooks/useShortcut';
import {
+ selectActiveGaussianSplatLoadProgress,
selectSceneNavClipId,
selectSceneNavFpsMode,
selectSceneNavFpsMoveSpeed,
@@ -56,7 +57,34 @@ function getSharedSceneDefaultCameraDistance(fovDegrees: number): number {
}
const CAMERA_NAV_FPS_LOOK_SPEED = 0.18;
-const CAMERA_NAV_FPS_PITCH_LIMIT = 89.5;
+
+function formatSplatLoadPercent(percent: number): number {
+ if (!Number.isFinite(percent)) {
+ return 0;
+ }
+ return Math.round(Math.max(0, Math.min(1, percent)) * 100);
+}
+
+function getSplatLoadPhaseLabel(phase: string): string {
+ switch (phase) {
+ case 'fetching':
+ return 'Fetching splat';
+ case 'reading':
+ return 'Reading splat';
+ case 'parsing':
+ return 'Parsing splat';
+ case 'normalizing':
+ return 'Preparing splat';
+ case 'uploading':
+ return 'Uploading splat';
+ case 'complete':
+ return 'Splat loaded';
+ case 'error':
+ return 'Splat load failed';
+ default:
+ return 'Loading splat';
+ }
+}
interface PreviewProps {
panelId: string;
@@ -75,6 +103,7 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps)
const sceneNavClipId = useEngineStore(selectSceneNavClipId);
const sceneNavFpsMode = useEngineStore(selectSceneNavFpsMode);
const sceneNavFpsMoveSpeed = useEngineStore(selectSceneNavFpsMoveSpeed);
+ const activeSplatLoadProgress = useEngineStore(selectActiveGaussianSplatLoadProgress);
const setSceneNavFpsMoveSpeed = useEngineStore((s) => s.setSceneNavFpsMoveSpeed);
const { clips, selectedClipIds, primarySelectedClipId, selectClip, updateClipTransform, maskEditMode, layers, selectedLayerId, selectLayer, updateLayer, tracks } = useTimelineStore(useShallow(s => ({
clips: s.clips,
@@ -738,10 +767,7 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps)
if (deltaX === 0 && deltaY === 0) return;
- const nextPitch = Math.max(
- -CAMERA_NAV_FPS_PITCH_LIMIT,
- Math.min(CAMERA_NAV_FPS_PITCH_LIMIT, freshTransform.rotation.x + deltaY * CAMERA_NAV_FPS_LOOK_SPEED),
- );
+ const nextPitch = freshTransform.rotation.x + deltaY * CAMERA_NAV_FPS_LOOK_SPEED;
const nextYaw = freshTransform.rotation.y - deltaX * CAMERA_NAV_FPS_LOOK_SPEED;
const nextTranslation = resolveOrbitCameraTranslationForFixedEye(
freshTransform,
@@ -1131,6 +1157,12 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps)
const viewTransform = editMode ? {
transform: `scale(${viewZoom}) translate(${viewPan.x / viewZoom}px, ${viewPan.y / viewZoom}px)`,
} : {};
+ const splatLoadPercent = activeSplatLoadProgress
+ ? formatSplatLoadPercent(activeSplatLoadProgress.percent)
+ : 0;
+ const splatLoadPhaseLabel = activeSplatLoadProgress
+ ? getSplatLoadPhaseLabel(activeSplatLoadProgress.phase)
+ : '';
return (
+ {activeSplatLoadProgress && (
+
+
+ {splatLoadPhaseLabel}
+ {splatLoadPercent}%
+
+
+ {activeSplatLoadProgress.fileName}
+
+
+
+ )}
+
{/* Edit mode overlay - covers full container for pasteboard support */}
{editMode && isEngineReady && (