Skip to content

Commit d22842f

Browse files
authored
Merge pull request #97 from Sportinger/fixes
Fix 3D camera interpolation and splat loading
2 parents e4d6b64 + 993b937 commit d22842f

26 files changed

Lines changed: 1195 additions & 55 deletions

docs/Features/3D-Layers.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ Camera clips expose their own Properties tab with:
8686
- Near plane
8787
- Far plane
8888

89-
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.
89+
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.
90+
91+
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.
9092

9193
## Gaussian Splats
9294

@@ -98,6 +100,7 @@ Gaussian splat clips are imported through the SuperSplat-compatible `@playcanvas
98100
- 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.
99101
- Sequence splats follow the same shared runtime contract and are no longer treated as a permanent legacy-only scene path.
100102
- The Transform tab now exposes normal object transforms for gaussian splats. Scene navigation lives on camera clips.
103+
- Large gaussian splats show viewport loading progress during project restore, URL fetch, parser work, normalization, and GPU upload.
101104

102105
Some gaussian-splat settings exist in the data model and export pipeline but are not yet surfaced as a full dedicated UI:
103106

src/App.css

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10041,6 +10041,63 @@ input[type="checkbox"] {
1004110041
pointer-events: none;
1004210042
}
1004310043

10044+
.preview-splat-progress-overlay {
10045+
position: absolute;
10046+
left: 50%;
10047+
bottom: 42px;
10048+
width: min(460px, calc(100% - 32px));
10049+
transform: translateX(-50%);
10050+
padding: 10px 12px;
10051+
color: var(--text-primary);
10052+
background: rgba(10, 12, 16, 0.86);
10053+
border: 1px solid rgba(255, 255, 255, 0.14);
10054+
border-radius: 6px;
10055+
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.32);
10056+
backdrop-filter: blur(8px);
10057+
z-index: 24;
10058+
pointer-events: none;
10059+
}
10060+
10061+
.preview-splat-progress-header {
10062+
display: flex;
10063+
align-items: center;
10064+
justify-content: space-between;
10065+
gap: 12px;
10066+
font-size: 12px;
10067+
font-weight: 600;
10068+
line-height: 1.3;
10069+
}
10070+
10071+
.preview-splat-progress-name {
10072+
margin-top: 3px;
10073+
color: var(--text-secondary);
10074+
font-size: 11px;
10075+
line-height: 1.35;
10076+
overflow: hidden;
10077+
text-overflow: ellipsis;
10078+
white-space: nowrap;
10079+
}
10080+
10081+
.preview-splat-progress-track {
10082+
height: 5px;
10083+
margin-top: 8px;
10084+
overflow: hidden;
10085+
background: rgba(255, 255, 255, 0.14);
10086+
border-radius: 999px;
10087+
}
10088+
10089+
.preview-splat-progress-fill {
10090+
height: 100%;
10091+
width: 0;
10092+
background: var(--accent);
10093+
border-radius: inherit;
10094+
transition: width 0.16s ease-out;
10095+
}
10096+
10097+
.preview-splat-progress-overlay.error .preview-splat-progress-fill {
10098+
background: var(--danger);
10099+
}
10100+
1004410101
.preview-canvas-wrapper {
1004510102
transform-origin: center center;
1004610103
transition: transform 0.1s ease-out;

src/changelog-data.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,25 @@
11
[
2+
{
3+
"date": "2026-04-24",
4+
"type": "fix",
5+
"title": "3D Video Planes Now Render Through the Active Scene Camera",
6+
"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.",
7+
"section": "3D / Native Scene"
8+
},
9+
{
10+
"date": "2026-04-24",
11+
"type": "improve",
12+
"title": "Gaussian Splat Loading Shows Viewport Progress",
13+
"description": "Large splats now report fetch, read, parse, and upload progress directly in the preview viewport during import, refresh, and timeline restore.",
14+
"section": "3D / Gaussian Splats"
15+
},
16+
{
17+
"date": "2026-04-24",
18+
"type": "fix",
19+
"title": "FPS Camera Rotation Tweens as a Real World Pose",
20+
"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.",
21+
"section": "3D / Camera Controls"
22+
},
223
{
324
"date": "2026-04-24",
425
"type": "new",

src/components/preview/Preview.tsx

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const log = Logger.create('Preview');
88
import { useEngine } from '../../hooks/useEngine';
99
import { useShortcut } from '../../hooks/useShortcut';
1010
import {
11+
selectActiveGaussianSplatLoadProgress,
1112
selectSceneNavClipId,
1213
selectSceneNavFpsMode,
1314
selectSceneNavFpsMoveSpeed,
@@ -56,7 +57,34 @@ function getSharedSceneDefaultCameraDistance(fovDegrees: number): number {
5657
}
5758

5859
const CAMERA_NAV_FPS_LOOK_SPEED = 0.18;
59-
const CAMERA_NAV_FPS_PITCH_LIMIT = 89.5;
60+
61+
function formatSplatLoadPercent(percent: number): number {
62+
if (!Number.isFinite(percent)) {
63+
return 0;
64+
}
65+
return Math.round(Math.max(0, Math.min(1, percent)) * 100);
66+
}
67+
68+
function getSplatLoadPhaseLabel(phase: string): string {
69+
switch (phase) {
70+
case 'fetching':
71+
return 'Fetching splat';
72+
case 'reading':
73+
return 'Reading splat';
74+
case 'parsing':
75+
return 'Parsing splat';
76+
case 'normalizing':
77+
return 'Preparing splat';
78+
case 'uploading':
79+
return 'Uploading splat';
80+
case 'complete':
81+
return 'Splat loaded';
82+
case 'error':
83+
return 'Splat load failed';
84+
default:
85+
return 'Loading splat';
86+
}
87+
}
6088

6189
interface PreviewProps {
6290
panelId: string;
@@ -75,6 +103,7 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps)
75103
const sceneNavClipId = useEngineStore(selectSceneNavClipId);
76104
const sceneNavFpsMode = useEngineStore(selectSceneNavFpsMode);
77105
const sceneNavFpsMoveSpeed = useEngineStore(selectSceneNavFpsMoveSpeed);
106+
const activeSplatLoadProgress = useEngineStore(selectActiveGaussianSplatLoadProgress);
78107
const setSceneNavFpsMoveSpeed = useEngineStore((s) => s.setSceneNavFpsMoveSpeed);
79108
const { clips, selectedClipIds, primarySelectedClipId, selectClip, updateClipTransform, maskEditMode, layers, selectedLayerId, selectLayer, updateLayer, tracks } = useTimelineStore(useShallow(s => ({
80109
clips: s.clips,
@@ -738,10 +767,7 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps)
738767

739768
if (deltaX === 0 && deltaY === 0) return;
740769

741-
const nextPitch = Math.max(
742-
-CAMERA_NAV_FPS_PITCH_LIMIT,
743-
Math.min(CAMERA_NAV_FPS_PITCH_LIMIT, freshTransform.rotation.x + deltaY * CAMERA_NAV_FPS_LOOK_SPEED),
744-
);
770+
const nextPitch = freshTransform.rotation.x + deltaY * CAMERA_NAV_FPS_LOOK_SPEED;
745771
const nextYaw = freshTransform.rotation.y - deltaX * CAMERA_NAV_FPS_LOOK_SPEED;
746772
const nextTranslation = resolveOrbitCameraTranslationForFixedEye(
747773
freshTransform,
@@ -1131,6 +1157,12 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps)
11311157
const viewTransform = editMode ? {
11321158
transform: `scale(${viewZoom}) translate(${viewPan.x / viewZoom}px, ${viewPan.y / viewZoom}px)`,
11331159
} : {};
1160+
const splatLoadPercent = activeSplatLoadProgress
1161+
? formatSplatLoadPercent(activeSplatLoadProgress.percent)
1162+
: 0;
1163+
const splatLoadPhaseLabel = activeSplatLoadProgress
1164+
? getSplatLoadPhaseLabel(activeSplatLoadProgress.phase)
1165+
: '';
11341166

11351167
return (
11361168
<div
@@ -1248,6 +1280,28 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps)
12481280
)}
12491281
</div>
12501282

1283+
{activeSplatLoadProgress && (
1284+
<div
1285+
className={`preview-splat-progress-overlay ${activeSplatLoadProgress.phase === 'error' ? 'error' : ''}`}
1286+
role="status"
1287+
aria-live="polite"
1288+
>
1289+
<div className="preview-splat-progress-header">
1290+
<span>{splatLoadPhaseLabel}</span>
1291+
<span>{splatLoadPercent}%</span>
1292+
</div>
1293+
<div className="preview-splat-progress-name">
1294+
{activeSplatLoadProgress.fileName}
1295+
</div>
1296+
<div className="preview-splat-progress-track">
1297+
<div
1298+
className="preview-splat-progress-fill"
1299+
style={{ width: `${splatLoadPercent}%` }}
1300+
/>
1301+
</div>
1302+
</div>
1303+
)}
1304+
12511305
{/* Edit mode overlay - covers full container for pasteboard support */}
12521306
{editMode && isEngineReady && (
12531307
<canvas

src/components/timeline/hooks/useLayerSync.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,9 @@ export function useLayerSync({
168168

169169
// Interpolate transform using keyframes (supports opacity fades, position animations, etc.)
170170
const transform = keyframes.length > 0
171-
? getInterpolatedClipTransform(keyframes, nestedLocalTime, baseTransform)
171+
? getInterpolatedClipTransform(keyframes, nestedLocalTime, baseTransform, {
172+
rotationMode: nestedClip.source?.type === 'camera' ? 'shortest' : 'linear',
173+
})
172174
: baseTransform;
173175

174176
// Interpolate effect parameters if there are effect keyframes

src/engine/export/ExportLayerBuilder.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,9 @@ function buildNestedBaseLayer(nestedClip: TimelineClip, nestedClipLocalTime: num
636636

637637
// Interpolate transform using keyframes (supports opacity fades, position animations, etc.)
638638
const transform = keyframes.length > 0
639-
? getInterpolatedClipTransform(keyframes, nestedClipLocalTime, baseTransform)
639+
? getInterpolatedClipTransform(keyframes, nestedClipLocalTime, baseTransform, {
640+
rotationMode: nestedClip.source?.type === 'camera' ? 'shortest' : 'linear',
641+
})
640642
: baseTransform;
641643

642644
// Interpolate effect parameters if there are effect keyframes

src/engine/gaussian/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ export type {
1818
GaussianSplatBuffer,
1919
GaussianSplatFrame,
2020
GaussianSplatAsset,
21+
GaussianSplatLoadOptions,
22+
GaussianSplatLoadProgress,
23+
GaussianSplatLoadProgressCallback,
2124
SplatCache,
2225
} from './loaders';
2326

0 commit comments

Comments
 (0)