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 @@