diff --git a/editor/css/main.css b/editor/css/main.css
index 623653636ca396..cb22e740c25fa4 100644
--- a/editor/css/main.css
+++ b/editor/css/main.css
@@ -343,7 +343,7 @@ hr {
top: 36px;
left: 0;
right: 350px;
- bottom: 0;
+ bottom: 36px;
}
#viewport .Text {
@@ -351,6 +351,48 @@ hr {
pointer-events: none;
}
+#animation {
+ position: absolute;
+ left: 0;
+ right: 350px;
+ bottom: 0;
+ height: 36px;
+ background: #eee;
+ border-top: 1px solid #ccc;
+ display: none;
+ flex-direction: row;
+}
+
+ #animation .Panel {
+ color: #888;
+ }
+
+ #animation input[type="range"] {
+ accent-color: #08f;
+ }
+
+#animation-resizer {
+ position: absolute;
+ left: 0;
+ right: 350px;
+ bottom: 36px;
+ height: 5px;
+ transform: translateY(2.5px);
+ cursor: row-resize;
+ z-index: 2;
+}
+
+ #animation-resizer:hover {
+ background-color: #08f8;
+ transition-property: background-color;
+ transition-delay: 0.1s;
+ transition-duration: 0.2s;
+ }
+
+ #animation-resizer:active {
+ background-color: #08f;
+ }
+
#script {
position: absolute;
top: 36px;
@@ -536,7 +578,7 @@ hr {
position: absolute;
left: calc(50% - 175px);
transform: translateX(-50%);
- bottom: 20px;
+ bottom: 56px;
height: 32px;
background: #eee;
text-align: center;
@@ -621,6 +663,10 @@ hr {
display: none;
}
+ #animation-resizer {
+ display: none;
+ }
+
#menubar .menu .options {
max-height: calc(100% - 80px);
}
@@ -663,6 +709,10 @@ hr {
bottom: 330px;
}
+ #animation {
+ display: none !important;
+ }
+
}
/* DARK MODE */
@@ -740,6 +790,19 @@ hr {
border: solid 1px #5A5A5A;
}
+ #animation {
+ background-color: #111;
+ border-top: 1px solid #222;
+ }
+
+ #animation .Panel {
+ border-bottom: 1px solid #222;
+ }
+
+ #animation .timeline-container {
+ background: rgba(255, 255, 255, 0.05);
+ }
+
#tabs {
background-color: #1b1b1b;
border-top: 1px solid #222;
diff --git a/editor/index.html b/editor/index.html
index 9f7db7798dd531..0dd382b50546e1 100644
--- a/editor/index.html
+++ b/editor/index.html
@@ -69,6 +69,8 @@
import { Sidebar } from './js/Sidebar.js';
import { Menubar } from './js/Menubar.js';
import { Resizer } from './js/Resizer.js';
+ import { AnimationResizer } from './js/AnimationResizer.js';
+ import { Animation } from './js/Animation.js';
window.URL = window.URL || window.webkitURL;
window.BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder;
@@ -101,6 +103,35 @@
const resizer = new Resizer( editor );
document.body.appendChild( resizer.dom );
+ const animation = new Animation( editor );
+ document.body.appendChild( animation.dom );
+
+ const animationResizer = new AnimationResizer( editor );
+ document.body.appendChild( animationResizer.dom );
+
+ editor.signals.animationPanelChanged.add( function ( height ) {
+
+ const visible = height !== false;
+
+ viewport.dom.classList.toggle( 'with-animation', visible );
+ toolbar.dom.classList.toggle( 'with-animation', visible );
+
+ if ( visible ) {
+
+ viewport.dom.style.bottom = height + 'px';
+ toolbar.dom.style.bottom = ( height + 20 ) + 'px';
+
+ } else {
+
+ viewport.dom.style.bottom = '';
+ toolbar.dom.style.bottom = '';
+
+ }
+
+ editor.signals.windowResize.dispatch();
+
+ } );
+
//
editor.storage.init( function () {
diff --git a/editor/js/Animation.js b/editor/js/Animation.js
new file mode 100644
index 00000000000000..7be0cb1f04426d
--- /dev/null
+++ b/editor/js/Animation.js
@@ -0,0 +1,581 @@
+import { UIPanel, UIText, UIButton } from './libs/ui.js';
+
+import { AnimationPathHelper } from 'three/addons/helpers/AnimationPathHelper.js';
+
+function Animation( editor ) {
+
+ const signals = editor.signals;
+
+ const container = new UIPanel();
+ container.setId( 'animation' );
+ container.dom.style.flexDirection = 'column';
+
+ let panelHeight = 36;
+
+ // Listen for resizer changes
+ signals.animationPanelResized.add( function ( height ) {
+
+ panelHeight = height;
+ container.dom.style.height = height + 'px';
+ signals.animationPanelChanged.dispatch( height );
+
+ } );
+
+ // Top bar - playback controls
+ const controlsPanel = new UIPanel();
+ controlsPanel.dom.style.padding = '6px 10px';
+ controlsPanel.dom.style.borderBottom = '1px solid #ccc';
+ controlsPanel.dom.style.display = 'flex';
+ controlsPanel.dom.style.alignItems = 'center';
+ controlsPanel.dom.style.justifyContent = 'center';
+ controlsPanel.dom.style.gap = '6px';
+ controlsPanel.dom.style.flexShrink = '0';
+ container.add( controlsPanel );
+
+ // SVG icons
+ const playIcon = ``;
+ const pauseIcon = ``;
+ const stopIcon = ``;
+
+ const playButton = new UIButton();
+ playButton.dom.innerHTML = playIcon;
+ playButton.dom.style.width = '24px';
+ playButton.dom.style.height = '24px';
+ playButton.dom.style.padding = '0';
+ playButton.dom.style.borderRadius = '4px';
+ playButton.dom.style.display = 'flex';
+ playButton.dom.style.alignItems = 'center';
+ playButton.dom.style.justifyContent = 'center';
+ playButton.onClick( function () {
+
+ if ( currentAction ) {
+
+ if ( currentAction.paused ) {
+
+ currentAction.paused = false;
+
+ } else if ( ! currentAction.isRunning() ) {
+
+ currentAction.reset();
+ currentAction.play();
+
+ }
+
+ }
+
+ } );
+ controlsPanel.add( playButton );
+
+ const pauseButton = new UIButton();
+ pauseButton.dom.innerHTML = pauseIcon;
+ pauseButton.dom.style.width = '24px';
+ pauseButton.dom.style.height = '24px';
+ pauseButton.dom.style.padding = '0';
+ pauseButton.dom.style.borderRadius = '4px';
+ pauseButton.dom.style.display = 'flex';
+ pauseButton.dom.style.alignItems = 'center';
+ pauseButton.dom.style.justifyContent = 'center';
+ pauseButton.onClick( function () {
+
+ if ( currentAction ) {
+
+ currentAction.paused = true;
+
+ }
+
+ } );
+ controlsPanel.add( pauseButton );
+
+ const stopButton = new UIButton();
+ stopButton.dom.innerHTML = stopIcon;
+ stopButton.dom.style.width = '24px';
+ stopButton.dom.style.height = '24px';
+ stopButton.dom.style.padding = '0';
+ stopButton.dom.style.borderRadius = '4px';
+ stopButton.dom.style.display = 'flex';
+ stopButton.dom.style.alignItems = 'center';
+ stopButton.dom.style.justifyContent = 'center';
+ stopButton.onClick( function () {
+
+ if ( currentAction ) {
+
+ currentAction.stop();
+
+ }
+
+ } );
+ controlsPanel.add( stopButton );
+
+ // Time display
+ const timeDisplay = document.createElement( 'div' );
+ timeDisplay.style.display = 'flex';
+ timeDisplay.style.alignItems = 'center';
+ timeDisplay.style.justifyContent = 'center';
+ timeDisplay.style.gap = '4px';
+ timeDisplay.style.height = '24px';
+ timeDisplay.style.padding = '0 8px';
+ timeDisplay.style.background = 'rgba(0,0,0,0.05)';
+ timeDisplay.style.borderRadius = '4px';
+ timeDisplay.style.fontFamily = 'monospace';
+ timeDisplay.style.fontSize = '11px';
+ controlsPanel.dom.appendChild( timeDisplay );
+
+ const timeText = new UIText( '0.00' ).setWidth( '36px' );
+ timeText.dom.style.textAlign = 'right';
+ timeDisplay.appendChild( timeText.dom );
+
+ const separator = new UIText( '/' );
+ timeDisplay.appendChild( separator.dom );
+
+ const durationText = new UIText( '0.00' ).setWidth( '36px' );
+ timeDisplay.appendChild( durationText.dom );
+
+ // Timeline area with track rows
+ const timelineArea = document.createElement( 'div' );
+ timelineArea.style.flex = '1';
+ timelineArea.style.display = 'flex';
+ timelineArea.style.flexDirection = 'column';
+ timelineArea.style.overflow = 'hidden';
+ timelineArea.style.position = 'relative';
+ container.dom.appendChild( timelineArea );
+
+ // Scrollable track list
+ const trackListContainer = document.createElement( 'div' );
+ trackListContainer.style.flex = '1';
+ trackListContainer.style.overflowY = 'auto';
+ trackListContainer.style.overflowX = 'hidden';
+ timelineArea.appendChild( trackListContainer );
+
+ // Playhead (spans entire timeline area)
+ const playhead = document.createElement( 'div' );
+ playhead.style.position = 'absolute';
+ playhead.style.top = '0';
+ playhead.style.bottom = '0';
+ playhead.style.width = '2px';
+ playhead.style.background = '#f00';
+ playhead.style.left = '150px'; // Start at timeline start (after labels)
+ playhead.style.pointerEvents = 'none';
+ playhead.style.zIndex = '10';
+ timelineArea.appendChild( playhead );
+
+ // Timeline scrubbing
+ let isDragging = false;
+ const labelWidth = 150;
+
+ function updateTimeFromPosition( clientX ) {
+
+ const rect = timelineArea.getBoundingClientRect();
+ const timelineStart = labelWidth;
+ const timelineWidth = rect.width - labelWidth;
+ const x = Math.max( 0, Math.min( clientX - rect.left - timelineStart, timelineWidth ) );
+ const percent = x / timelineWidth;
+
+ if ( currentAction && currentClip ) {
+
+ const time = percent * currentClip.duration;
+ currentAction.play();
+ currentAction.time = time;
+ currentAction.paused = true;
+ editor.mixer.update( 0 );
+
+ }
+
+ }
+
+ timelineArea.addEventListener( 'mousedown', function ( event ) {
+
+ const rect = timelineArea.getBoundingClientRect();
+ if ( event.clientX - rect.left > labelWidth ) {
+
+ event.preventDefault();
+
+ isDragging = true;
+ updateTimeFromPosition( event.clientX );
+
+ }
+
+ } );
+
+ document.addEventListener( 'mousemove', function ( event ) {
+
+ if ( isDragging ) {
+
+ updateTimeFromPosition( event.clientX );
+
+ }
+
+ } );
+
+ document.addEventListener( 'mouseup', function () {
+
+ isDragging = false;
+
+ } );
+
+ // Track colors by type
+ const trackColors = {
+ position: '#4CAF50',
+ quaternion: '#2196F3',
+ rotation: '#2196F3',
+ scale: '#FF9800',
+ morphTargetInfluences: '#9C27B0',
+ default: '#607D8B'
+ };
+
+ function getTrackColor( trackName ) {
+
+ for ( const type in trackColors ) {
+
+ if ( trackName.endsWith( '.' + type ) ) {
+
+ return trackColors[ type ];
+
+ }
+
+ }
+
+ return trackColors.default;
+
+ }
+
+ function getTrackType( trackName ) {
+
+ const parts = trackName.split( '.' );
+ return parts[ parts.length - 1 ];
+
+ }
+
+ // Hover path helper
+ let hoverHelper = null;
+ let currentAction = null;
+ let currentClip = null;
+ let currentRoot = null;
+
+ // Get all clips from scene animations
+ function getAnimationClips() {
+
+ const scene = editor.scene;
+ const clips = [];
+ const seen = new Set();
+
+ scene.traverse( function ( object ) {
+
+ if ( object.animations && object.animations.length > 0 ) {
+
+ for ( const clip of object.animations ) {
+
+ if ( ! seen.has( clip.uuid ) ) {
+
+ seen.add( clip.uuid );
+ clips.push( { clip: clip, root: object } );
+
+ }
+
+ }
+
+ }
+
+ } );
+
+ // Also check scene.animations directly
+ for ( const clip of scene.animations ) {
+
+ if ( ! seen.has( clip.uuid ) ) {
+
+ seen.add( clip.uuid );
+ clips.push( { clip: clip, root: scene } );
+
+ }
+
+ }
+
+ return clips;
+
+ }
+
+ function getObjectName( trackName, root ) {
+
+ // Extract UUID from track name (format: uuid.property)
+ const dotIndex = trackName.lastIndexOf( '.' );
+ if ( dotIndex === - 1 ) return trackName;
+
+ const uuid = trackName.substring( 0, dotIndex );
+ const object = root.getObjectByProperty( 'uuid', uuid );
+
+ return object ? ( object.name || 'Object' ) : uuid.substring( 0, 8 );
+
+ }
+
+ function update() {
+
+ trackListContainer.innerHTML = '';
+
+ container.setDisplay( 'flex' );
+ container.dom.style.height = panelHeight + 'px';
+ signals.animationPanelChanged.dispatch( panelHeight );
+
+ const clips = getAnimationClips();
+
+ if ( clips.length === 0 ) {
+
+ return;
+
+ }
+
+ for ( const { clip, root } of clips ) {
+
+ // Clip header row
+ const clipRow = document.createElement( 'div' );
+ clipRow.style.display = 'flex';
+ clipRow.style.alignItems = 'center';
+ clipRow.style.height = '24px';
+ clipRow.style.borderBottom = '1px solid #ccc';
+ clipRow.style.cursor = 'pointer';
+ clipRow.style.background = currentClip === clip ? 'rgba(0, 136, 255, 0.1)' : '';
+
+ const clipLabel = document.createElement( 'div' );
+ clipLabel.style.width = labelWidth + 'px';
+ clipLabel.style.padding = '0 10px';
+ clipLabel.style.fontSize = '11px';
+ clipLabel.style.fontWeight = 'bold';
+ clipLabel.style.overflow = 'hidden';
+ clipLabel.style.textOverflow = 'ellipsis';
+ clipLabel.style.whiteSpace = 'nowrap';
+ clipLabel.style.flexShrink = '0';
+ clipLabel.style.boxSizing = 'border-box';
+ clipLabel.textContent = clip.name || 'Animation';
+ clipRow.appendChild( clipLabel );
+
+ const clipTimeline = document.createElement( 'div' );
+ clipTimeline.style.flex = '1';
+ clipTimeline.style.height = '100%';
+ clipTimeline.style.background = 'rgba(0,0,0,0.03)';
+ clipRow.appendChild( clipTimeline );
+
+ clipRow.addEventListener( 'click', function () {
+
+ editor.select( root );
+ selectClip( clip, root );
+ update(); // Refresh to update highlighting
+
+ } );
+
+ trackListContainer.appendChild( clipRow );
+
+ // Only show tracks for selected clip
+ if ( currentClip === clip ) {
+
+ const duration = clip.duration;
+
+ for ( const track of clip.tracks ) {
+
+ const times = track.times;
+ if ( times.length === 0 ) continue;
+
+ const startTime = times[ 0 ];
+ const endTime = times[ times.length - 1 ];
+ const startPercent = ( startTime / duration ) * 100;
+ const widthPercent = ( ( endTime - startTime ) / duration ) * 100;
+
+ const trackRow = document.createElement( 'div' );
+ trackRow.style.display = 'flex';
+ trackRow.style.alignItems = 'center';
+ trackRow.style.height = '20px';
+ trackRow.style.borderBottom = '1px solid #eee';
+
+ // Track label
+ const trackLabel = document.createElement( 'div' );
+ trackLabel.style.width = labelWidth + 'px';
+ trackLabel.style.padding = '0 10px 0 20px';
+ trackLabel.style.fontSize = '10px';
+ trackLabel.style.overflow = 'hidden';
+ trackLabel.style.textOverflow = 'ellipsis';
+ trackLabel.style.whiteSpace = 'nowrap';
+ trackLabel.style.flexShrink = '0';
+ trackLabel.style.boxSizing = 'border-box';
+ trackLabel.style.color = '#666';
+
+ const objectName = getObjectName( track.name, root );
+ const trackType = getTrackType( track.name );
+ trackLabel.textContent = objectName + '.' + trackType;
+ trackLabel.title = track.name;
+ trackRow.appendChild( trackLabel );
+
+ // Track timeline with block
+ const trackTimeline = document.createElement( 'div' );
+ trackTimeline.style.flex = '1';
+ trackTimeline.style.height = '100%';
+ trackTimeline.style.position = 'relative';
+ trackTimeline.style.background = 'rgba(0,0,0,0.02)';
+
+ const block = document.createElement( 'div' );
+ block.style.position = 'absolute';
+ block.style.left = startPercent + '%';
+ block.style.width = Math.max( 0.5, widthPercent ) + '%';
+ block.style.top = '3px';
+ block.style.bottom = '3px';
+ block.style.background = getTrackColor( track.name );
+ block.style.borderRadius = '2px';
+ block.style.opacity = '0.6';
+ block.title = trackType + ': ' + startTime.toFixed( 2 ) + 's - ' + endTime.toFixed( 2 ) + 's';
+
+ trackTimeline.appendChild( block );
+
+ // Add keyframe markers
+ for ( let i = 0; i < times.length; i ++ ) {
+
+ const keyframePercent = ( times[ i ] / duration ) * 100;
+ const keyframe = document.createElement( 'div' );
+ keyframe.style.position = 'absolute';
+ keyframe.style.left = keyframePercent + '%';
+ keyframe.style.top = '50%';
+ keyframe.style.width = '6px';
+ keyframe.style.height = '6px';
+ keyframe.style.marginLeft = '-3px';
+ keyframe.style.marginTop = '-3px';
+ keyframe.style.background = getTrackColor( track.name );
+ keyframe.style.borderRadius = '1px';
+ keyframe.style.transform = 'rotate(45deg)';
+ keyframe.title = times[ i ].toFixed( 3 ) + 's';
+ trackTimeline.appendChild( keyframe );
+
+ }
+ trackRow.appendChild( trackTimeline );
+
+ // Hover on position tracks to show path helper
+ if ( track.name.endsWith( '.position' ) && track.getValueSize() === 3 ) {
+
+ const uuid = track.name.replace( '.position', '' );
+ const object = root.getObjectByProperty( 'uuid', uuid );
+
+ if ( object ) {
+
+ trackRow.addEventListener( 'mouseenter', function () {
+
+ showPath( clip, object );
+
+ } );
+
+ trackRow.addEventListener( 'mouseleave', function () {
+
+ hidePath();
+
+ } );
+
+ }
+
+ }
+
+ trackListContainer.appendChild( trackRow );
+
+ }
+
+ }
+
+ }
+
+ }
+
+ function selectClip( clip, root ) {
+
+ // Stop current action
+ if ( currentAction ) {
+
+ currentAction.stop();
+
+ }
+
+ // Select clip without playing
+ currentClip = clip;
+ currentRoot = root;
+ currentAction = editor.mixer.clipAction( clip, root );
+
+ // Update duration display
+ durationText.setValue( clip.duration.toFixed( 2 ) );
+
+ }
+
+ function showPath( clip, object ) {
+
+ hidePath();
+
+ hoverHelper = new AnimationPathHelper( currentRoot, clip, object );
+ editor.sceneHelpers.add( hoverHelper );
+ signals.sceneGraphChanged.dispatch();
+
+ }
+
+ function hidePath() {
+
+ if ( hoverHelper ) {
+
+ editor.sceneHelpers.remove( hoverHelper );
+ hoverHelper.dispose();
+ hoverHelper = null;
+ signals.sceneGraphChanged.dispatch();
+
+ }
+
+ }
+
+ function clear() {
+
+ hidePath();
+ trackListContainer.innerHTML = '';
+ currentAction = null;
+ currentClip = null;
+ currentRoot = null;
+ timeText.setValue( '0.00' );
+ durationText.setValue( '0.00' );
+
+ }
+
+ // Update time display and playhead during playback
+ function updateTime() {
+
+ if ( currentAction && currentClip ) {
+
+ const time = currentAction.time % currentClip.duration;
+ timeText.setValue( time.toFixed( 2 ) );
+
+ // Update playhead position
+ const rect = timelineArea.getBoundingClientRect();
+ const timelineWidth = rect.width - labelWidth;
+ const playheadX = labelWidth + ( time / currentClip.duration ) * timelineWidth;
+ playhead.style.left = playheadX + 'px';
+
+ }
+
+ requestAnimationFrame( updateTime );
+
+ }
+
+ updateTime();
+
+ // Auto-select clip when an object with animations is selected
+ signals.objectSelected.add( function ( object ) {
+
+ if ( object !== null && object.animations && object.animations.length > 0 ) {
+
+ selectClip( object.animations[ 0 ], object );
+ update();
+
+ }
+
+ } );
+
+ // Update when scene changes
+ signals.editorCleared.add( clear );
+ signals.objectAdded.add( update );
+ signals.objectRemoved.add( update );
+
+ // Show panel on initial load
+ container.setDisplay( 'flex' );
+ container.dom.style.height = panelHeight + 'px';
+ signals.animationPanelChanged.dispatch( panelHeight );
+
+ return container;
+
+}
+
+export { Animation };
diff --git a/editor/js/AnimationResizer.js b/editor/js/AnimationResizer.js
new file mode 100644
index 00000000000000..a6dd8331a39d59
--- /dev/null
+++ b/editor/js/AnimationResizer.js
@@ -0,0 +1,73 @@
+import { UIElement } from './libs/ui.js';
+
+function AnimationResizer( editor ) {
+
+ const signals = editor.signals;
+
+ const dom = document.createElement( 'div' );
+ dom.id = 'animation-resizer';
+
+ let panelHeight = 36;
+ let startY = 0;
+ let startHeight = 0;
+
+ function onPointerDown( event ) {
+
+ if ( event.isPrimary === false ) return;
+
+ startY = event.clientY;
+ startHeight = panelHeight;
+
+ dom.ownerDocument.addEventListener( 'pointermove', onPointerMove );
+ dom.ownerDocument.addEventListener( 'pointerup', onPointerUp );
+
+ }
+
+ function onPointerUp( event ) {
+
+ if ( event.isPrimary === false ) return;
+
+ dom.ownerDocument.removeEventListener( 'pointermove', onPointerMove );
+ dom.ownerDocument.removeEventListener( 'pointerup', onPointerUp );
+
+ }
+
+ function onPointerMove( event ) {
+
+ if ( event.isPrimary === false ) return;
+
+ const deltaY = startY - event.clientY;
+ const newHeight = startHeight + deltaY;
+ const maxHeight = window.innerHeight / 2;
+
+ // Clamp between 36px (top bar only) and half the window height
+ panelHeight = Math.max( 36, Math.min( maxHeight, newHeight ) );
+
+ signals.animationPanelResized.dispatch( panelHeight );
+
+ }
+
+ dom.addEventListener( 'pointerdown', onPointerDown );
+
+ // Show/hide based on animation panel visibility
+ signals.animationPanelChanged.add( function ( height ) {
+
+ if ( height === false ) {
+
+ dom.style.display = 'none';
+
+ } else {
+
+ dom.style.display = 'block';
+ dom.style.bottom = height + 'px';
+ panelHeight = height;
+
+ }
+
+ } );
+
+ return new UIElement( dom );
+
+}
+
+export { AnimationResizer };
diff --git a/editor/js/Editor.js b/editor/js/Editor.js
index 4185b53616ac68..cfe7dbd01178df 100644
--- a/editor/js/Editor.js
+++ b/editor/js/Editor.js
@@ -93,8 +93,10 @@ function Editor() {
pathTracerUpdated: new Signal(),
- morphTargetsUpdated: new Signal()
+ animationPanelChanged: new Signal(),
+ animationPanelResized: new Signal(),
+ morphTargetsUpdated: new Signal()
};
diff --git a/editor/js/Resizer.js b/editor/js/Resizer.js
index 213d24d9be65f7..c595e0062077eb 100644
--- a/editor/js/Resizer.js
+++ b/editor/js/Resizer.js
@@ -44,6 +44,13 @@ function Resizer( editor ) {
document.getElementById( 'player' ).style.right = x + 'px';
document.getElementById( 'script' ).style.right = x + 'px';
document.getElementById( 'viewport' ).style.right = x + 'px';
+ document.getElementById( 'animation' ).style.right = x + 'px';
+ document.getElementById( 'animation-resizer' ).style.right = x + 'px';
+
+ // Center toolbar in viewport area
+ const toolbar = document.getElementById( 'toolbar' );
+ const viewportWidth = offsetWidth - x;
+ toolbar.style.left = ( viewportWidth / 2 ) + 'px';
signals.windowResize.dispatch();
diff --git a/editor/js/Sidebar.Object.Animation.js b/editor/js/Sidebar.Object.Animation.js
deleted file mode 100644
index 94253e6661ac5a..00000000000000
--- a/editor/js/Sidebar.Object.Animation.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import { UIBreak, UIButton, UIDiv, UIText, UINumber, UIRow } from './libs/ui.js';
-
-function SidebarObjectAnimation( editor ) {
-
- const strings = editor.strings;
- const signals = editor.signals;
- const mixer = editor.mixer;
-
- function getButtonText( action ) {
-
- return action.isRunning()
- ? strings.getKey( 'sidebar/animations/stop' )
- : strings.getKey( 'sidebar/animations/play' );
-
- }
-
- function Animation( animation, object ) {
-
- const action = mixer.clipAction( animation, object );
-
- const container = new UIRow();
-
- const name = new UIText( animation.name ).setWidth( '200px' );
- container.add( name );
-
- const button = new UIButton( getButtonText( action ) );
- button.onClick( function () {
-
- action.isRunning() ? action.stop() : action.play();
- button.setTextContent( getButtonText( action ) );
-
- } );
-
- container.add( button );
-
- return container;
-
- }
-
- signals.objectSelected.add( function ( object ) {
-
- if ( object !== null && object.animations.length > 0 ) {
-
- animationsList.clear();
-
- const animations = object.animations;
-
- for ( const animation of animations ) {
-
- animationsList.add( new Animation( animation, object ) );
-
- }
-
- container.setDisplay( '' );
-
- } else {
-
- container.setDisplay( 'none' );
-
- }
-
- } );
-
- signals.objectRemoved.add( function ( object ) {
-
- if ( object !== null && object.animations.length > 0 ) {
-
- mixer.uncacheRoot( object );
-
- }
-
- } );
-
- const container = new UIDiv();
- container.setMarginTop( '20px' );
- container.setDisplay( 'none' );
-
- container.add( new UIText( strings.getKey( 'sidebar/animations' ) ).setTextTransform( 'uppercase' ) );
- container.add( new UIBreak() );
- container.add( new UIBreak() );
-
- const animationsList = new UIDiv();
- container.add( animationsList );
-
- const mixerTimeScaleRow = new UIRow();
- const mixerTimeScaleNumber = new UINumber( 1 ).setWidth( '60px' ).setRange( - 10, 10 );
- mixerTimeScaleNumber.onChange( function () {
-
- mixer.timeScale = mixerTimeScaleNumber.getValue();
-
- } );
-
- mixerTimeScaleRow.add( new UIText( strings.getKey( 'sidebar/animations/timescale' ) ).setClass( 'Label' ) );
- mixerTimeScaleRow.add( mixerTimeScaleNumber );
-
- container.add( mixerTimeScaleRow );
-
- return container;
-
-}
-
-export { SidebarObjectAnimation };
diff --git a/editor/js/Sidebar.Object.js b/editor/js/Sidebar.Object.js
index 2edd0cca6fda01..de9fa1daa3662b 100644
--- a/editor/js/Sidebar.Object.js
+++ b/editor/js/Sidebar.Object.js
@@ -11,8 +11,6 @@ import { SetScaleCommand } from './commands/SetScaleCommand.js';
import { SetColorCommand } from './commands/SetColorCommand.js';
import { SetShadowValueCommand } from './commands/SetShadowValueCommand.js';
-import { SidebarObjectAnimation } from './Sidebar.Object.Animation.js';
-
function SidebarObject( editor ) {
const strings = editor.strings;
@@ -427,10 +425,6 @@ function SidebarObject( editor ) {
} );
container.add( exportJson );
- // Animations
-
- container.add( new SidebarObjectAnimation( editor ) );
-
//
function update() {
diff --git a/examples/jsm/exporters/GLTFExporter.js b/examples/jsm/exporters/GLTFExporter.js
index 40919bcacc9a17..a4c36a1005c5e6 100644
--- a/examples/jsm/exporters/GLTFExporter.js
+++ b/examples/jsm/exporters/GLTFExporter.js
@@ -2355,9 +2355,16 @@ class GLTFWriter {
if ( ! json.nodes ) json.nodes = [];
+ // Handle pivot by creating a container node
+ if ( object.pivot !== null ) {
+
+ return await this._processNodeWithPivotAsync( object );
+
+ }
+
const nodeDef = {};
- if ( options.trs && object.pivot === null ) {
+ if ( options.trs ) {
const rotation = object.quaternion.toArray();
const position = object.position.toArray();
@@ -2395,12 +2402,6 @@ class GLTFWriter {
}
- if ( object.pivot !== null ) {
-
- console.warn( 'THREE.GLTFExporter: Pivot not supported by GLTF. Baking into matrix.', object );
-
- }
-
}
// We don't export empty strings name because it represents no-name in Three.js.
@@ -2457,6 +2458,126 @@ class GLTFWriter {
}
+ /**
+ * Process Object3D node with pivot using container approach
+ * @param {THREE.Object3D} object Object3D with pivot
+ * @return {Promise} Index of the container node
+ */
+ async _processNodeWithPivotAsync( object ) {
+
+ const json = this.json;
+ const options = this.options;
+ const nodeMap = this.nodeMap;
+
+ const pivot = object.pivot;
+
+ // Container node: holds position + pivot offset, rotation, scale
+ // Animations will target this node
+ const containerDef = {};
+
+ const rotation = object.quaternion.toArray();
+ const position = [
+ object.position.x + pivot.x,
+ object.position.y + pivot.y,
+ object.position.z + pivot.z
+ ];
+ const scale = object.scale.toArray();
+
+ if ( ! equalArray( rotation, [ 0, 0, 0, 1 ] ) ) {
+
+ containerDef.rotation = rotation;
+
+ }
+
+ if ( ! equalArray( position, [ 0, 0, 0 ] ) ) {
+
+ containerDef.translation = position;
+
+ }
+
+ if ( ! equalArray( scale, [ 1, 1, 1 ] ) ) {
+
+ containerDef.scale = scale;
+
+ }
+
+ // Store pivot in extras for round-trip reconstruction
+ containerDef.extras = { pivot: pivot.toArray() };
+
+ if ( object.name !== '' ) containerDef.name = String( object.name );
+
+ this.serializeUserData( object, containerDef );
+
+ const containerIndex = json.nodes.push( containerDef ) - 1;
+
+ // Map original object to container so animations target it
+ nodeMap.set( object, containerIndex );
+
+ // Child node: holds mesh with -pivot offset
+ const childDef = {};
+
+ const childPosition = [ - pivot.x, - pivot.y, - pivot.z ];
+
+ if ( ! equalArray( childPosition, [ 0, 0, 0 ] ) ) {
+
+ childDef.translation = childPosition;
+
+ }
+
+ if ( object.isMesh || object.isLine || object.isPoints ) {
+
+ const meshIndex = await this.processMeshAsync( object );
+
+ if ( meshIndex !== null ) childDef.mesh = meshIndex;
+
+ } else if ( object.isCamera ) {
+
+ childDef.camera = this.processCamera( object );
+
+ }
+
+ if ( object.isSkinnedMesh ) this.skins.push( object );
+
+ const childIndex = json.nodes.push( childDef ) - 1;
+
+ // Build children array for container
+ const containerChildren = [ childIndex ];
+
+ // Process object's children as children of the child node
+ if ( object.children.length > 0 ) {
+
+ const grandchildren = [];
+
+ for ( let i = 0, l = object.children.length; i < l; i ++ ) {
+
+ const child = object.children[ i ];
+
+ if ( child.visible || options.onlyVisible === false ) {
+
+ const childNodeIndex = await this.processNodeAsync( child );
+
+ if ( childNodeIndex !== null ) grandchildren.push( childNodeIndex );
+
+ }
+
+ }
+
+ if ( grandchildren.length > 0 ) childDef.children = grandchildren;
+
+ }
+
+ containerDef.children = containerChildren;
+
+ await this._invokeAllAsync( function ( ext ) {
+
+ ext.writeNode && ext.writeNode( object, containerDef );
+
+ } );
+
+ return containerIndex;
+
+ }
+
/**
* Process Scene
* @param {Scene} scene Scene to process
diff --git a/examples/jsm/helpers/AnimationPathHelper.js b/examples/jsm/helpers/AnimationPathHelper.js
new file mode 100644
index 00000000000000..e169f63444e255
--- /dev/null
+++ b/examples/jsm/helpers/AnimationPathHelper.js
@@ -0,0 +1,302 @@
+import {
+ BufferGeometry,
+ Float32BufferAttribute,
+ Line,
+ LineBasicMaterial,
+ Object3D,
+ Points,
+ PointsMaterial
+} from 'three';
+
+/**
+ * Visualizes the motion path of an animated object based on position keyframes
+ * from an AnimationClip.
+ *
+ * ```js
+ * const clip = model.animations[ 0 ];
+ * const helper = new AnimationPathHelper( model, clip, object );
+ * scene.add( helper );
+ * ```
+ *
+ * @augments Object3D
+ * @three_import import { AnimationPathHelper } from 'three/addons/helpers/AnimationPathHelper.js';
+ */
+class AnimationPathHelper extends Object3D {
+
+ /**
+ * Constructs a new animation path helper.
+ *
+ * @param {Object3D} root - The root object containing the animation clips.
+ * @param {AnimationClip} clip - The animation clip containing position keyframes.
+ * @param {Object3D} object - The specific object to show the path for.
+ * @param {Object} [options={}] - Configuration options.
+ * @param {number|Color|string} [options.color=0x00ff00] - The path line color.
+ * @param {number|Color|string} [options.markerColor=0xff0000] - The keyframe marker color.
+ * @param {number} [options.divisions=100] - Number of samples for smooth path interpolation.
+ * @param {boolean} [options.showMarkers=true] - Whether to show markers at keyframe positions.
+ * @param {number} [options.markerSize=5] - Size of keyframe markers in pixels.
+ */
+ constructor( root, clip, object, options = {} ) {
+
+ super();
+
+ const {
+ color = 0x00ff00,
+ markerColor = 0xff0000,
+ divisions = 100,
+ showMarkers = true,
+ markerSize = 5
+ } = options;
+
+ /**
+ * This flag can be used for type testing.
+ *
+ * @type {boolean}
+ * @readonly
+ * @default true
+ */
+ this.isAnimationPathHelper = true;
+
+ this.type = 'AnimationPathHelper';
+
+ /**
+ * The root object containing the animation clips.
+ *
+ * @type {Object3D}
+ */
+ this.root = root;
+
+ /**
+ * The animation clip containing position keyframes.
+ *
+ * @type {AnimationClip}
+ */
+ this.clip = clip;
+
+ /**
+ * The object whose path is being visualized.
+ *
+ * @type {Object3D}
+ */
+ this.object = object;
+
+ /**
+ * Number of samples for smooth path interpolation.
+ *
+ * @type {number}
+ * @default 100
+ */
+ this.divisions = divisions;
+
+ /**
+ * The position track for the object.
+ *
+ * @type {KeyframeTrack|null}
+ * @private
+ */
+ this._track = this._findTrackForObject( object );
+
+ if ( this._track === null ) {
+
+ console.warn( 'AnimationPathHelper: No position track found for object', object.name );
+ return;
+
+ }
+
+ // Create line for path
+ const lineGeometry = new BufferGeometry();
+ const lineMaterial = new LineBasicMaterial( {
+ color: color,
+ toneMapped: false
+ } );
+
+ /**
+ * The line representing the animation path.
+ *
+ * @type {Line}
+ */
+ this.line = new Line( lineGeometry, lineMaterial );
+ this.add( this.line );
+
+ // Create points for keyframe markers
+ if ( showMarkers ) {
+
+ const pointsGeometry = new BufferGeometry();
+ const pointsMaterial = new PointsMaterial( {
+ color: markerColor,
+ size: markerSize,
+ sizeAttenuation: false,
+ toneMapped: false
+ } );
+
+ /**
+ * Points marking keyframe positions.
+ *
+ * @type {Points|null}
+ */
+ this.points = new Points( pointsGeometry, pointsMaterial );
+ this.add( this.points );
+
+ } else {
+
+ this.points = null;
+
+ }
+
+ // Sync matrix with object's parent
+ this.matrixAutoUpdate = false;
+
+ this._updateGeometry();
+
+ }
+
+ /**
+ * Finds the position track for the given object.
+ *
+ * @private
+ * @param {Object3D} object - The object to find the track for.
+ * @returns {KeyframeTrack|null} The position track, or null if not found.
+ */
+ _findTrackForObject( object ) {
+
+ const targetName = object.uuid + '.position';
+
+ for ( const track of this.clip.tracks ) {
+
+ if ( track.name === targetName && track.getValueSize() === 3 ) {
+
+ return track;
+
+ }
+
+ }
+
+ return null;
+
+ }
+
+ /**
+ * Samples the track at regular intervals.
+ *
+ * @private
+ * @returns {Float32Array} Array of sampled positions.
+ */
+ _sampleTrack() {
+
+ const track = this._track;
+ const interpolant = track.createInterpolant();
+ const duration = this.clip.duration;
+ const positions = [];
+
+ for ( let i = 0; i <= this.divisions; i ++ ) {
+
+ const t = ( i / this.divisions ) * duration;
+ const result = interpolant.evaluate( t );
+ positions.push( result[ 0 ], result[ 1 ], result[ 2 ] );
+
+ }
+
+ return new Float32Array( positions );
+
+ }
+
+ /**
+ * Updates the geometry with sampled path data.
+ *
+ * @private
+ */
+ _updateGeometry() {
+
+ if ( this._track === null ) return;
+
+ // Update line geometry
+ const sampledPositions = this._sampleTrack();
+ this.line.geometry.setAttribute( 'position', new Float32BufferAttribute( sampledPositions, 3 ) );
+ this.line.geometry.computeBoundingSphere();
+
+ // Update keyframe markers
+ if ( this.points !== null ) {
+
+ this.points.geometry.setAttribute( 'position', new Float32BufferAttribute( new Float32Array( this._track.values ), 3 ) );
+ this.points.geometry.computeBoundingSphere();
+
+ }
+
+ }
+
+ /**
+ * Updates the helper's transform to match the object's parent.
+ *
+ * @param {boolean} force - Force matrix update.
+ */
+ updateMatrixWorld( force ) {
+
+ // Position the helper at the object's parent so the path appears in correct local space
+ if ( this.object && this.object.parent ) {
+
+ this.object.parent.updateWorldMatrix( true, false );
+ this.matrix.copy( this.object.parent.matrixWorld );
+
+ } else {
+
+ this.matrix.identity();
+
+ }
+
+ this.matrixWorld.copy( this.matrix );
+
+ // Update children
+ for ( let i = 0; i < this.children.length; i ++ ) {
+
+ this.children[ i ].updateMatrixWorld( force );
+
+ }
+
+ }
+
+ /**
+ * Sets the path line color.
+ *
+ * @param {number|Color|string} color - The new color.
+ */
+ setColor( color ) {
+
+ if ( this.line ) this.line.material.color.set( color );
+
+ }
+
+ /**
+ * Sets the keyframe marker color.
+ *
+ * @param {number|Color|string} color - The new color.
+ */
+ setMarkerColor( color ) {
+
+ if ( this.points ) this.points.material.color.set( color );
+
+ }
+
+ /**
+ * Frees the GPU-related resources allocated by this instance.
+ */
+ dispose() {
+
+ if ( this.line ) {
+
+ this.line.geometry.dispose();
+ this.line.material.dispose();
+
+ }
+
+ if ( this.points ) {
+
+ this.points.geometry.dispose();
+ this.points.material.dispose();
+
+ }
+
+ }
+
+}
+
+export { AnimationPathHelper };
diff --git a/examples/jsm/loaders/GLTFLoader.js b/examples/jsm/loaders/GLTFLoader.js
index d0645d7a734308..d6ccea1cea6851 100644
--- a/examples/jsm/loaders/GLTFLoader.js
+++ b/examples/jsm/loaders/GLTFLoader.js
@@ -4235,6 +4235,28 @@ class GLTFParser {
}
+ // Reconstruct pivot from container pattern created by GLTFExporter
+ // The container has position+pivot, rotation, scale; child has -pivot offset and mesh
+ if ( node.userData.pivot !== undefined && children.length > 0 ) {
+
+ const pivot = node.userData.pivot;
+ const pivotChild = children[ 0 ];
+
+ // Set pivot on container and adjust transforms
+ node.pivot = new Vector3().fromArray( pivot );
+
+ // Adjust container position: stored as position + pivot, so subtract pivot
+ node.position.x -= pivot[ 0 ];
+ node.position.y -= pivot[ 1 ];
+ node.position.z -= pivot[ 2 ];
+
+ // Remove the child's -pivot offset since pivot now handles it
+ pivotChild.position.set( 0, 0, 0 );
+
+ delete node.userData.pivot;
+
+ }
+
return node;
} );
diff --git a/examples/jsm/tsl/display/SSRNode.js b/examples/jsm/tsl/display/SSRNode.js
index 52eb39769492e4..4d734f6c3e5c2e 100644
--- a/examples/jsm/tsl/display/SSRNode.js
+++ b/examples/jsm/tsl/display/SSRNode.js
@@ -262,6 +262,7 @@ class SSRNode extends TempNode {
this._copyMaterial = new NodeMaterial();
this._copyMaterial.name = 'SSRNode.Copy';
+
/**
* The result of the effect is represented as a separate texture node.
*
@@ -270,24 +271,13 @@ class SSRNode extends TempNode {
*/
this._textureNode = passTexture( this, this._ssrRenderTarget.texture );
- let blurredTextureNode = null;
-
- if ( this.roughnessNode !== null ) {
-
- const mips = this._blurRenderTarget.texture.mipmaps.length - 1;
- const lod = float( this.roughnessNode ).mul( mips ).clamp( 0, mips );
-
- blurredTextureNode = passTexture( this, this._blurRenderTarget.texture ).level( lod );
-
- }
-
/**
* Holds the blurred SSR reflections.
*
* @private
- * @type {?PassTextureNode}
+ * @type {?Node}
*/
- this._blurredTextureNode = blurredTextureNode;
+ this._blurredTextureNode = null;
}
@@ -585,9 +575,10 @@ class SSRNode extends TempNode {
const fresnelCoe = div( dot( viewIncidentDir, viewReflectDir ).add( 1 ), 2 );
op.mulAssign( fresnelCoe );
- // output
+ // output: RGB = color * opacity (premultiplied), A = normalized distance
const reflectColor = this.colorNode.sample( uvNode );
- output.assign( vec4( reflectColor.rgb, op ) );
+ const normalizedDistance = distance.div( this.maxDistance ).clamp( 0, 1 );
+ output.assign( vec4( reflectColor.rgb.mul( op ), normalizedDistance ) );
Break();
} );
@@ -616,6 +607,33 @@ class SSRNode extends TempNode {
this._copyMaterial.fragmentNode = reflectionBuffer;
this._copyMaterial.needsUpdate = true;
+ // distance-aware blur sampling
+
+ if ( this.roughnessNode !== null ) {
+
+ const blurBuffer = texture( this._blurRenderTarget.texture );
+ const mips = this._blurRenderTarget.texture.mipmaps.length - 1;
+
+ // sample distance from unblurred SSR alpha and use roughness² * distance for LOD
+ // roughness² matches GGX microfacet distribution behavior
+ const distanceAwareSample = Fn( () => {
+
+ // get distance from the unblurred SSR texture's alpha channel
+ const reflectionDistance = reflectionBuffer.sample( uvNode ).a;
+ const r = float( this.roughnessNode );
+ // squared roughness for more physically accurate falloff (GGX-like)
+ const lod = r.mul( r ).mul( reflectionDistance ).mul( mips ).clamp( 0, mips );
+ const blurred = blurBuffer.sample( uvNode ).level( lod );
+
+ // output: RGB is premultiplied color, keep alpha as distance for potential further use
+ return blurred;
+
+ } );
+
+ this._blurredTextureNode = distanceAwareSample();
+
+ }
+
//
return this.getTextureNode();
diff --git a/examples/screenshots/webgpu_postprocessing_ssr.jpg b/examples/screenshots/webgpu_postprocessing_ssr.jpg
index dfd67848508591..14688a8c3f0272 100644
Binary files a/examples/screenshots/webgpu_postprocessing_ssr.jpg and b/examples/screenshots/webgpu_postprocessing_ssr.jpg differ
diff --git a/examples/webgpu_postprocessing_ssr.html b/examples/webgpu_postprocessing_ssr.html
index db1c147ac5e788..6ffdd2588e8cba 100644
--- a/examples/webgpu_postprocessing_ssr.html
+++ b/examples/webgpu_postprocessing_ssr.html
@@ -40,7 +40,7 @@