From 4190296273beb138edce352d4fe363980570484a Mon Sep 17 00:00:00 2001 From: Jonathan Dunn Date: Fri, 16 Oct 2026 17:53:25 +0000 Subject: [PATCH 01/10] Time offset for curve bindings --- trinity/CMakeLists.txt | 7 +++++++ trinity/Curves/TriCurveSet.cpp | 33 +++++++++++++++++++++++++++++- trinity/Curves/TriCurveSet.h | 4 ++++ trinity/Include/ITr2ValueBinding.h | 17 +++++++++++++++ trinity/TriValueBinding.cpp | 33 ++++++++++++++++++++++++++++++ trinity/TriValueBinding.h | 12 +++++++++++ trinity/TriValueBinding_Blue.cpp | 11 ++++++++++ 7 files changed, 116 insertions(+), 1 deletion(-) diff --git a/trinity/CMakeLists.txt b/trinity/CMakeLists.txt index a2bbcf8b3..ccab32c17 100644 --- a/trinity/CMakeLists.txt +++ b/trinity/CMakeLists.txt @@ -1857,6 +1857,9 @@ if(BUILD_FOR_PYTHON_2) add_library(CcpImageIO ALIAS ImageIO) find_package(CcpParser REQUIRED NO_CMAKE_PATH) find_package(CarbonMesh CONFIG REQUIRED) + # The vendored ImageIO is built with Tracy lock instrumentation, so its objects + # reference __tracy_*_lockable_ctx, which only the Tracy client defines. + find_package(Tracy CONFIG QUIET) else() find_package(Freetype REQUIRED) if(WITH_GRANNY) @@ -1892,6 +1895,10 @@ function(set_shared_trinity_properties target) CarbonMesh ) + if(TARGET Tracy::TracyClient) + target_link_libraries(${target} PRIVATE Tracy::TracyClient) + endif() + target_sources(${target} PRIVATE ${_SOURCES}) if(WITH_GRANNY) target_link_libraries(${target} PRIVATE RadGameTools::Granny) diff --git a/trinity/Curves/TriCurveSet.cpp b/trinity/Curves/TriCurveSet.cpp index 2bf87b35f..502d20f51 100644 --- a/trinity/Curves/TriCurveSet.cpp +++ b/trinity/Curves/TriCurveSet.cpp @@ -160,10 +160,41 @@ void TriCurveSet::Apply() for( ITr2ValueBindingVector::const_iterator it = m_bindings.begin(); it != m_bindings.end(); ++it ) { - ( *it )->CopyValue(); + const float timeOffset = ( *it )->GetTimeOffset(); + if( timeOffset != 0.0f ) + { + ( *it )->CopyValueSampledAt( GetOffsetTime( m_scaledTime, timeOffset ), m_scaledTime ); + } + else + { + ( *it )->CopyValue(); + } } } +double TriCurveSet::GetOffsetTime( double time, float timeOffset ) const +{ + double offsetTime = time - (double)timeOffset; + + // A looped time range is the window the curves play in, so an offset has to + // wrap inside it rather than walk off the start of the range. + if( m_hasTimeRange && m_loopedTimeRange ) + { + const double span = m_timeRangeMax - m_timeRangeMin; + if( span > 0.0 ) + { + offsetTime = std::fmod( offsetTime - m_timeRangeMin, span ); + if( offsetTime < 0.0 ) + { + offsetTime += span; + } + offsetTime += m_timeRangeMin; + } + } + + return offsetTime; +} + void TriCurveSet::ApplyTime( double time ) { m_scaledTime = time; diff --git a/trinity/Curves/TriCurveSet.h b/trinity/Curves/TriCurveSet.h index 4f2c6617a..911c6e8d0 100644 --- a/trinity/Curves/TriCurveSet.h +++ b/trinity/Curves/TriCurveSet.h @@ -115,6 +115,10 @@ BLUE_CLASS( TriCurveSet ) : private: void UpdateWithCurrentTime(); + // The time a binding with the given time offset samples its source curve + // at, wrapped into the active looped time range if there is one. + double GetOffsetTime( double time, float timeOffset ) const; + std::string m_name; PITr2ValueBindingVector m_bindings; PITriFunctionVector m_curves; diff --git a/trinity/Include/ITr2ValueBinding.h b/trinity/Include/ITr2ValueBinding.h index 90f329ffe..69937dfd4 100644 --- a/trinity/Include/ITr2ValueBinding.h +++ b/trinity/Include/ITr2ValueBinding.h @@ -8,6 +8,23 @@ BLUE_INTERFACE( ITr2ValueBinding ) : public IRoot { virtual void CopyValue() = 0; + + // Time offset, in seconds, that this binding applies to its source curve + // before copying the value. Zero for bindings that always read the source + // as it stands. + virtual float GetTimeOffset() const + { + return 0.0f; + } + + // Copies the value with the source curve evaluated at sampleTime, then puts + // the curve back to restoreTime so that a curve shared by several bindings + // is left as its curve set left it. Only called for bindings that report a + // non-zero time offset. + virtual void CopyValueSampledAt( double sampleTime, double restoreTime ) + { + CopyValue(); + } }; #endif \ No newline at end of file diff --git a/trinity/TriValueBinding.cpp b/trinity/TriValueBinding.cpp index 923868423..1f254939f 100644 --- a/trinity/TriValueBinding.cpp +++ b/trinity/TriValueBinding.cpp @@ -8,6 +8,8 @@ #include "include/ITriQuaternion.h" #include "include/ITriMatrix.h" +#include + TriValueBinding::TriValueBinding( IRoot* lockobj ) : m_isWeak( false ), m_isEnabled( true ), @@ -17,6 +19,7 @@ TriValueBinding::TriValueBinding( IRoot* lockobj ) : m_copyFunc( NULL ), m_scale( 1.0f ), m_offset( 0.0f, 0.0f, 0.0f, 0.0f ), + m_timeOffset( 0.0f ), m_sourceItemOffset( 0 ), m_destItemOffset( 0 ) { @@ -62,6 +65,31 @@ void TriValueBinding::CopyValue() } } +void TriValueBinding::CopyValueSampledAt( double sampleTime, double restoreTime ) +{ + if( !m_isEnabled ) + { + return; + } + + ITriFunctionPtr sourceCurve( BlueCastPtr( GetCurrentSourceObject() ) ); + if( !sourceCurve ) + { + // The source isn't a curve, so there is no time to sample it at - copy + // whatever it holds, exactly as an unoffset binding would. + CopyValue(); + return; + } + + sourceCurve->UpdateValue( sampleTime ); + CopyValue(); + + // Put the curve back where the curve set left it. Its current value is + // shared state: other bindings on the same curve, and anything reading + // currentValue, must not see this binding's time. + sourceCurve->UpdateValue( restoreTime ); +} + bool TriValueBinding::OnModified( Be::Var* val ) { ITriReroutablePtr rp( BlueCastPtr( GetCurrentDestinationObject() ) ); @@ -804,6 +832,11 @@ void TriValueBinding::SetScale( float scale ) m_scale = scale; } +void TriValueBinding::SetTimeOffset( float timeOffset ) +{ + m_timeOffset = timeOffset; +} + IRoot* TriValueBinding::GetCurrentSourceObject() const { return m_isWeak ? static_cast( m_sourceObjectWeak ) : m_sourceObject.p; diff --git a/trinity/TriValueBinding.h b/trinity/TriValueBinding.h index 8217b964a..88dce5ace 100644 --- a/trinity/TriValueBinding.h +++ b/trinity/TriValueBinding.h @@ -31,6 +31,11 @@ BLUE_CLASS( TriValueBinding ) : ////////////////////////////////////////////////////////////////////////// // ITr2ValueBinding virtual void CopyValue(); + virtual float GetTimeOffset() const + { + return m_timeOffset; + } + virtual void CopyValueSampledAt( double sampleTime, double restoreTime ); static const Be::VarEntry* FindEntry( const char* name, const Be::ClassInfo* type, ssize_t& offs ); @@ -42,6 +47,7 @@ BLUE_CLASS( TriValueBinding ) : void SetSource( const std::string& sourceAttribute, IRootPtr sourceObject ); void SetDestination( const std::string& destAttribute, IRootPtr destination ); void SetScale( float scale ); + void SetTimeOffset( float timeOffset ); void CreateWeakBinding( IRoot * source, const char* sourceAttr, IRoot* dest, const char* destAttr, float scale = 1.0f, const Vector4& offset = Vector4( 0.0f, 0.0f, 0.0f, 0.0f ) ); @@ -80,6 +86,12 @@ BLUE_CLASS( TriValueBinding ) : float m_scale; Vector4 m_offset; + + // Shifts the time this binding samples its source curve at, without + // touching the curve's own keys or time offset, so that several bindings + // can drive different targets from the same curve at different times. + float m_timeOffset; + INotify* m_notifyPtr; typedef bool ( *CopyFunc )( void* srcVar, void* dstVar, float scale, const Vector4& offset ); diff --git a/trinity/TriValueBinding_Blue.cpp b/trinity/TriValueBinding_Blue.cpp index 8adc68df8..aaee953fc 100644 --- a/trinity/TriValueBinding_Blue.cpp +++ b/trinity/TriValueBinding_Blue.cpp @@ -79,6 +79,17 @@ const Be::ClassInfo* TriValueBinding::ExposeToBlue() m_offset, "Offset of source value, added to it after the scaling, before being applied to destination", Be::READWRITE | Be::PERSIST ) + MAP_ATTRIBUTE( + "timeOffset", + m_timeOffset, + "Time offset, in seconds, applied to the source curve of this binding.\n" + "The curve is sampled at (curve set time - timeOffset), so a positive\n" + "value makes this binding lag behind the curve set, a negative one makes\n" + "it run ahead. This lets several bindings drive different targets from\n" + "the same curve at different times, without duplicating the curve.\n" + "Only applies to bindings whose source is a curve driven by the curve set\n" + "that owns them.", + Be::READWRITE | Be::PERSIST ) #if BLUE_WITH_PYTHON MAP_ATTRIBUTE( "copyValueCallable", From 99611ee201d772e4ee3ac99c47b9d9183d39b0b9 Mon Sep 17 00:00:00 2001 From: Jonathan Dunn Date: Tue, 20 Oct 2026 14:03:24 +0000 Subject: [PATCH 02/10] Sample offset bindings without moving the shared curve A time-offset binding used to call UpdateValue on its source curve at the offset time and then again at the curve set's time to put it back. That re-fired every event key on a TriEventCurve each frame, dragged curves owned by other curve sets to this set's time, and evaluated the curve twice more per frame. Bindings now sample through the typed curves' GetValueAt, which leaves the curve's state alone. The binding decides this when it resolves its source: only the currentValue of a scalar, vector, quaternion or color curve can be sampled, and an offset on anything else is ignored with a warning. The curve set no longer special-cases offset bindings. ITr2ValueBinding has a single CopyValueAt(TriCurveSetTime) instead of GetTimeOffset and CopyValueSampledAt, and TriCurveSetTime carries the looped range so the binding can wrap its own offset. The range wrapping is shared with the curve set's own time update, which also stops a zero-length looped range from producing NaN. Also drops the unused TriValueBinding::SetTimeOffset, moves the Tracy link fix to its own branch, and adds tests. Co-Authored-By: Claude Opus 5.5 --- trinity/CMakeLists.txt | 10 +-- trinity/Curves/TriCurveSampler.cpp | 59 +++++++++++++ trinity/Curves/TriCurveSampler.h | 16 ++++ trinity/Curves/TriCurveSet.cpp | 37 +------- trinity/Curves/TriCurveSet.h | 4 - trinity/Curves/TriCurveSetTime.h | 47 ++++++++++ trinity/Include/ITr2ValueBinding.h | 18 ++-- trinity/TriValueBinding.cpp | 81 +++++++++++------- trinity/TriValueBinding.h | 17 ++-- trinity/TriValueBinding_Blue.cpp | 11 ++- .../Python/tests/test_curvesetbindings.py | 85 +++++++++++++++++++ 11 files changed, 285 insertions(+), 100 deletions(-) create mode 100644 trinity/Curves/TriCurveSampler.cpp create mode 100644 trinity/Curves/TriCurveSampler.h create mode 100644 trinity/Curves/TriCurveSetTime.h create mode 100644 trinity/tests/Python/tests/test_curvesetbindings.py diff --git a/trinity/CMakeLists.txt b/trinity/CMakeLists.txt index ccab32c17..92eb1d5a9 100644 --- a/trinity/CMakeLists.txt +++ b/trinity/CMakeLists.txt @@ -236,8 +236,11 @@ set(_SOURCES Curves/Tr2TranslationAdapter.cpp Curves/Tr2TranslationAdapter.h Curves/Tr2TranslationAdapter_Blue.cpp + Curves/TriCurveSampler.cpp + Curves/TriCurveSampler.h Curves/TriCurveSet.cpp Curves/TriCurveSet.h + Curves/TriCurveSetTime.h Curves/TriCurveSet_Blue.cpp Curves/TriEventCurve.cpp Curves/TriEventCurve.h @@ -1857,9 +1860,6 @@ if(BUILD_FOR_PYTHON_2) add_library(CcpImageIO ALIAS ImageIO) find_package(CcpParser REQUIRED NO_CMAKE_PATH) find_package(CarbonMesh CONFIG REQUIRED) - # The vendored ImageIO is built with Tracy lock instrumentation, so its objects - # reference __tracy_*_lockable_ctx, which only the Tracy client defines. - find_package(Tracy CONFIG QUIET) else() find_package(Freetype REQUIRED) if(WITH_GRANNY) @@ -1895,10 +1895,6 @@ function(set_shared_trinity_properties target) CarbonMesh ) - if(TARGET Tracy::TracyClient) - target_link_libraries(${target} PRIVATE Tracy::TracyClient) - endif() - target_sources(${target} PRIVATE ${_SOURCES}) if(WITH_GRANNY) target_link_libraries(${target} PRIVATE RadGameTools::Granny) diff --git a/trinity/Curves/TriCurveSampler.cpp b/trinity/Curves/TriCurveSampler.cpp new file mode 100644 index 000000000..90a9066a2 --- /dev/null +++ b/trinity/Curves/TriCurveSampler.cpp @@ -0,0 +1,59 @@ +// Copyright © 2026 CCP ehf. + +#include "StdAfx.h" +#include "TriCurveSampler.h" + +#include + +namespace +{ +// Each goes through GetValueAt, which typed curves implement without touching +// the state UpdateValue keeps - so a curve shared with others, or one that +// fires events as it advances, is unaffected by being sampled. +void SampleScalar( IRoot* curve, double time, void* out ) +{ + *static_cast( out ) = ITriScalarFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( time ); +} + +void SampleVector( IRoot* curve, double time, void* out ) +{ + ITriVectorFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( static_cast( out ), time ); +} + +void SampleQuaternion( IRoot* curve, double time, void* out ) +{ + ITriQuaternionFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( static_cast( out ), time ); +} + +void SampleColor( IRoot* curve, double time, void* out ) +{ + ITriColorFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( static_cast( out ), time ); +} +} + +TriCurveSampleFunc FindCurveSampleFunc( IRoot* source, const Be::VarEntry* valueEntry ) +{ + if( valueEntry->mType == Be::FLOAT ) + { + return ITriScalarFunctionPtr( BlueCastPtr( source ) ) ? SampleScalar : nullptr; + } + + if( valueEntry->mType != Be::FLOATARRAY ) + { + return nullptr; + } + + switch( valueEntry->GetFloatArraySize() ) + { + case 3: + return ITriVectorFunctionPtr( BlueCastPtr( source ) ) ? SampleVector : nullptr; + case 4: + if( ITriQuaternionFunctionPtr( BlueCastPtr( source ) ) ) + { + return SampleQuaternion; + } + return ITriColorFunctionPtr( BlueCastPtr( source ) ) ? SampleColor : nullptr; + default: + return nullptr; + } +} diff --git a/trinity/Curves/TriCurveSampler.h b/trinity/Curves/TriCurveSampler.h new file mode 100644 index 000000000..b1f6f0904 --- /dev/null +++ b/trinity/Curves/TriCurveSampler.h @@ -0,0 +1,16 @@ +// Copyright © 2026 CCP ehf. + +#pragma once +#ifndef TriCurveSampler_h +#define TriCurveSampler_h + +// Evaluates a curve at a time into out, without changing the curve's own state. +// out must have room for a Vector4. +typedef void ( *TriCurveSampleFunc )( IRoot* curve, double time, void* out ); + +// How to sample source at an arbitrary time when valueEntry is its value +// attribute - a scalar, vector, quaternion or color curve - or nullptr if +// source is not a curve of the matching type. +TriCurveSampleFunc FindCurveSampleFunc( IRoot* source, const Be::VarEntry* valueEntry ); + +#endif // TriCurveSampler_h diff --git a/trinity/Curves/TriCurveSet.cpp b/trinity/Curves/TriCurveSet.cpp index 502d20f51..b85d60abc 100644 --- a/trinity/Curves/TriCurveSet.cpp +++ b/trinity/Curves/TriCurveSet.cpp @@ -2,6 +2,7 @@ #include "StdAfx.h" #include "TriCurveSet.h" +#include "TriCurveSetTime.h" #include "TriValueBinding.h" #include #include "include/ITriDuration.h" @@ -122,7 +123,7 @@ void TriCurveSet::Update( double time ) if( m_loopedTimeRange ) { - m_scaledTime = std::fmod( m_scaledTime - m_timeRangeMin, m_timeRangeMax - m_timeRangeMin ) + m_timeRangeMin; + m_scaledTime = WrapTimeIntoRange( m_scaledTime, m_timeRangeMin, m_timeRangeMax ); } else { @@ -158,43 +159,13 @@ void TriCurveSet::Apply() ( *it )->UpdateValue( m_scaledTime ); } + const TriCurveSetTime time{ m_scaledTime, m_hasTimeRange && m_loopedTimeRange, m_timeRangeMin, m_timeRangeMax }; for( ITr2ValueBindingVector::const_iterator it = m_bindings.begin(); it != m_bindings.end(); ++it ) { - const float timeOffset = ( *it )->GetTimeOffset(); - if( timeOffset != 0.0f ) - { - ( *it )->CopyValueSampledAt( GetOffsetTime( m_scaledTime, timeOffset ), m_scaledTime ); - } - else - { - ( *it )->CopyValue(); - } + ( *it )->CopyValueAt( time ); } } -double TriCurveSet::GetOffsetTime( double time, float timeOffset ) const -{ - double offsetTime = time - (double)timeOffset; - - // A looped time range is the window the curves play in, so an offset has to - // wrap inside it rather than walk off the start of the range. - if( m_hasTimeRange && m_loopedTimeRange ) - { - const double span = m_timeRangeMax - m_timeRangeMin; - if( span > 0.0 ) - { - offsetTime = std::fmod( offsetTime - m_timeRangeMin, span ); - if( offsetTime < 0.0 ) - { - offsetTime += span; - } - offsetTime += m_timeRangeMin; - } - } - - return offsetTime; -} - void TriCurveSet::ApplyTime( double time ) { m_scaledTime = time; diff --git a/trinity/Curves/TriCurveSet.h b/trinity/Curves/TriCurveSet.h index 911c6e8d0..4f2c6617a 100644 --- a/trinity/Curves/TriCurveSet.h +++ b/trinity/Curves/TriCurveSet.h @@ -115,10 +115,6 @@ BLUE_CLASS( TriCurveSet ) : private: void UpdateWithCurrentTime(); - // The time a binding with the given time offset samples its source curve - // at, wrapped into the active looped time range if there is one. - double GetOffsetTime( double time, float timeOffset ) const; - std::string m_name; PITr2ValueBindingVector m_bindings; PITriFunctionVector m_curves; diff --git a/trinity/Curves/TriCurveSetTime.h b/trinity/Curves/TriCurveSetTime.h new file mode 100644 index 000000000..5dc92019d --- /dev/null +++ b/trinity/Curves/TriCurveSetTime.h @@ -0,0 +1,47 @@ +// Copyright © 2026 CCP ehf. + +#pragma once +#ifndef TriCurveSetTime_h +#define TriCurveSetTime_h + +#include + +// Wraps time into [rangeMin, rangeMax), so a time before the start of a looped +// range comes round from its end instead of walking off it. A degenerate range +// collapses to its start. +inline double WrapTimeIntoRange( double time, double rangeMin, double rangeMax ) +{ + const double span = rangeMax - rangeMin; + if( span <= 0.0 ) + { + return rangeMin; + } + + double wrapped = std::fmod( time - rangeMin, span ); + if( wrapped < 0.0 ) + { + wrapped += span; + } + return wrapped + rangeMin; +} + +// The time a curve set is applying its bindings at, together with the looped +// range it plays in, so that a binding can sample its source at an offset from +// that time without knowing how the curve set keeps time. +struct TriCurveSetTime +{ + double time; + bool looped; + double rangeMin; + double rangeMax; + + // The curve set's time shifted back by offset seconds, wrapped into the + // looped range if there is one. + double Shifted( float offset ) const + { + const double shifted = time - (double)offset; + return looped ? WrapTimeIntoRange( shifted, rangeMin, rangeMax ) : shifted; + } +}; + +#endif // TriCurveSetTime_h diff --git a/trinity/Include/ITr2ValueBinding.h b/trinity/Include/ITr2ValueBinding.h index 69937dfd4..ca5ba773e 100644 --- a/trinity/Include/ITr2ValueBinding.h +++ b/trinity/Include/ITr2ValueBinding.h @@ -4,24 +4,16 @@ #ifndef ITr2ValueBinding_H #define ITr2ValueBinding_H +struct TriCurveSetTime; + BLUE_INTERFACE( ITr2ValueBinding ) : public IRoot { virtual void CopyValue() = 0; - // Time offset, in seconds, that this binding applies to its source curve - // before copying the value. Zero for bindings that always read the source - // as it stands. - virtual float GetTimeOffset() const - { - return 0.0f; - } - - // Copies the value with the source curve evaluated at sampleTime, then puts - // the curve back to restoreTime so that a curve shared by several bindings - // is left as its curve set left it. Only called for bindings that report a - // non-zero time offset. - virtual void CopyValueSampledAt( double sampleTime, double restoreTime ) + // Copies the value for a curve set applying its bindings at the given time. + // Bindings that don't depend on that time copy their source as it stands. + virtual void CopyValueAt( const TriCurveSetTime& ) { CopyValue(); } diff --git a/trinity/TriValueBinding.cpp b/trinity/TriValueBinding.cpp index 1f254939f..4cb7d5d54 100644 --- a/trinity/TriValueBinding.cpp +++ b/trinity/TriValueBinding.cpp @@ -7,8 +7,8 @@ #include "include/ITriColor.h" #include "include/ITriQuaternion.h" #include "include/ITriMatrix.h" - -#include +#include "Curves/TriCurveSampler.h" +#include "Curves/TriCurveSetTime.h" TriValueBinding::TriValueBinding( IRoot* lockobj ) : m_isWeak( false ), @@ -20,6 +20,8 @@ TriValueBinding::TriValueBinding( IRoot* lockobj ) : m_scale( 1.0f ), m_offset( 0.0f, 0.0f, 0.0f, 0.0f ), m_timeOffset( 0.0f ), + m_sampleFunc( nullptr ), + m_sampleOffset( 0 ), m_sourceItemOffset( 0 ), m_destItemOffset( 0 ) { @@ -34,24 +36,20 @@ TriValueBinding::~TriValueBinding() } } -void TriValueBinding::CopyValue() +bool TriValueBinding::CanCopy() const { if( !m_isEnabled ) { - return; - } - - if( m_isWeak ) - { - if( !m_sourceObjectWeak || !m_destinationObjectWeak ) - { - return; - } + return false; } + return !m_isWeak || ( m_sourceObjectWeak && m_destinationObjectWeak ); +} +void TriValueBinding::CopyFrom( void* source ) +{ if( m_copyFunc ) { - if( m_copyFunc( (Be::Var*)m_source, (Be::Var*)m_destination, m_scale, m_offset ) ) + if( m_copyFunc( (Be::Var*)source, (Be::Var*)m_destination, m_scale, m_offset ) ) { if( m_notifyPtr ) { @@ -65,29 +63,33 @@ void TriValueBinding::CopyValue() } } -void TriValueBinding::CopyValueSampledAt( double sampleTime, double restoreTime ) +void TriValueBinding::CopyValue() { - if( !m_isEnabled ) + if( CanCopy() ) { - return; + CopyFrom( m_source ); } +} - ITriFunctionPtr sourceCurve( BlueCastPtr( GetCurrentSourceObject() ) ); - if( !sourceCurve ) +void TriValueBinding::CopyValueAt( const TriCurveSetTime& time ) +{ + if( !CanCopy() ) { - // The source isn't a curve, so there is no time to sample it at - copy - // whatever it holds, exactly as an unoffset binding would. - CopyValue(); return; } - sourceCurve->UpdateValue( sampleTime ); - CopyValue(); + if( m_timeOffset == 0.0f || !m_sampleFunc ) + { + CopyFrom( m_source ); + return; + } - // Put the curve back where the curve set left it. Its current value is - // shared state: other bindings on the same curve, and anything reading - // currentValue, must not see this binding's time. - sourceCurve->UpdateValue( restoreTime ); + // Evaluate the source curve at our own time into a local value rather than + // moving the curve: its current value is shared with other bindings and + // readers, and stateful curves must only ever see their curve set's time. + Vector4 sampled; + m_sampleFunc( GetCurrentSourceObject(), time.Shifted( m_timeOffset ), &sampled ); + CopyFrom( reinterpret_cast( &sampled ) + m_sampleOffset ); } bool TriValueBinding::OnModified( Be::Var* val ) @@ -353,6 +355,8 @@ void TriValueBinding::Initialize() m_destination = NULL; m_copyFunc = NULL; m_notifyPtr = nullptr; + m_sampleFunc = nullptr; + m_sampleOffset = 0; IRoot* sourceObject = GetCurrentSourceObject(); IRoot* destinationObject = GetCurrentDestinationObject(); @@ -415,9 +419,20 @@ void TriValueBinding::Initialize() const Be::VarEntry* dstEntry = FindEntry( destAttr.c_str(), dstClassInfo, dstOffset ); if( srcEntry && dstEntry ) { - m_source = (void*)BLUEMAPMEMBEROFFSET( sourceObject, srcEntry, srcClassInfo, srcOffset ); + void* sourceMember = (void*)BLUEMAPMEMBEROFFSET( sourceObject, srcEntry, srcClassInfo, srcOffset ); + m_source = sourceMember; m_destination = (void*)BLUEMAPMEMBEROFFSET( destinationObject, dstEntry, dstClassInfo, dstOffset ); dataSize = DetermineCopyFunc( srcEntry, dstEntry, dataSize, sourceFloatArrayAsFloat, destFloatArrayAsFloat ); + + // A time offset only means something for the value a curve produces, so + // only a typed curve bound through its currentValue can be sampled. + if( m_copyFunc && sourceAttr == "currentValue" ) + { + m_sampleFunc = FindCurveSampleFunc( sourceObject, srcEntry ); + // m_source may already point at one component (currentValue.y), so + // copy from the same place inside the sampled value. + m_sampleOffset = (uint8_t)( (uint8_t*)m_source - (uint8_t*)sourceMember ); + } } else { @@ -456,6 +471,11 @@ void TriValueBinding::Initialize() } } } + + if( m_timeOffset != 0.0f && !m_sampleFunc ) + { + CCP_LOGWARN( "TriValueBinding \"%s\": timeOffset ignored - source '%s' is not the currentValue of a scalar, vector, quaternion or color curve", m_name.c_str(), m_sourceAttribute.c_str() ); + } } void TriValueBinding::RerouteDestination( void* dest ) @@ -832,11 +852,6 @@ void TriValueBinding::SetScale( float scale ) m_scale = scale; } -void TriValueBinding::SetTimeOffset( float timeOffset ) -{ - m_timeOffset = timeOffset; -} - IRoot* TriValueBinding::GetCurrentSourceObject() const { return m_isWeak ? static_cast( m_sourceObjectWeak ) : m_sourceObject.p; diff --git a/trinity/TriValueBinding.h b/trinity/TriValueBinding.h index 88dce5ace..83d707b4e 100644 --- a/trinity/TriValueBinding.h +++ b/trinity/TriValueBinding.h @@ -6,6 +6,7 @@ #include "include/ITr2ValueBinding.h" +#include "Curves/TriCurveSampler.h" BLUE_DECLARE( TriValueBinding ); BLUE_DECLARE_VECTOR( TriValueBinding ); @@ -31,11 +32,7 @@ BLUE_CLASS( TriValueBinding ) : ////////////////////////////////////////////////////////////////////////// // ITr2ValueBinding virtual void CopyValue(); - virtual float GetTimeOffset() const - { - return m_timeOffset; - } - virtual void CopyValueSampledAt( double sampleTime, double restoreTime ); + virtual void CopyValueAt( const TriCurveSetTime& time ); static const Be::VarEntry* FindEntry( const char* name, const Be::ClassInfo* type, ssize_t& offs ); @@ -47,13 +44,15 @@ BLUE_CLASS( TriValueBinding ) : void SetSource( const std::string& sourceAttribute, IRootPtr sourceObject ); void SetDestination( const std::string& destAttribute, IRootPtr destination ); void SetScale( float scale ); - void SetTimeOffset( float timeOffset ); void CreateWeakBinding( IRoot * source, const char* sourceAttr, IRoot* dest, const char* destAttr, float scale = 1.0f, const Vector4& offset = Vector4( 0.0f, 0.0f, 0.0f, 0.0f ) ); bool IsValid() const; protected: + bool CanCopy() const; + void CopyFrom( void* source ); + IRoot* GetCurrentSourceObject() const; IRoot* GetCurrentDestinationObject() const; @@ -92,6 +91,12 @@ BLUE_CLASS( TriValueBinding ) : // can drive different targets from the same curve at different times. float m_timeOffset; + // Set when the source is a curve bound through its currentValue, so an + // offset binding can evaluate it at its own time without moving the curve. + // m_sampleOffset is where m_source sits inside that value. + TriCurveSampleFunc m_sampleFunc; + uint8_t m_sampleOffset; + INotify* m_notifyPtr; typedef bool ( *CopyFunc )( void* srcVar, void* dstVar, float scale, const Vector4& offset ); diff --git a/trinity/TriValueBinding_Blue.cpp b/trinity/TriValueBinding_Blue.cpp index aaee953fc..fb6d21d1d 100644 --- a/trinity/TriValueBinding_Blue.cpp +++ b/trinity/TriValueBinding_Blue.cpp @@ -86,10 +86,13 @@ const Be::ClassInfo* TriValueBinding::ExposeToBlue() "The curve is sampled at (curve set time - timeOffset), so a positive\n" "value makes this binding lag behind the curve set, a negative one makes\n" "it run ahead. This lets several bindings drive different targets from\n" - "the same curve at different times, without duplicating the curve.\n" - "Only applies to bindings whose source is a curve driven by the curve set\n" - "that owns them.", - Be::READWRITE | Be::PERSIST ) + "the same curve at different times, without duplicating the curve. The\n" + "curve itself is not moved, so it can be shared freely. A looped time\n" + "range on the curve set wraps the offset time into the range.\n" + "Applies when the binding is run by a curve set and its source is the\n" + "currentValue of a scalar, vector, quaternion or color curve; otherwise\n" + "it is ignored with a warning.", + Be::READWRITE | Be::PERSIST | Be::NOTIFY ) #if BLUE_WITH_PYTHON MAP_ATTRIBUTE( "copyValueCallable", diff --git a/trinity/tests/Python/tests/test_curvesetbindings.py b/trinity/tests/Python/tests/test_curvesetbindings.py new file mode 100644 index 000000000..1ed839b8e --- /dev/null +++ b/trinity/tests/Python/tests/test_curvesetbindings.py @@ -0,0 +1,85 @@ +# Copyright © 2026 CCP ehf. + +import unittest + +import trinity + + +def _LinearCurve(): + # currentValue == time over [0, 10] + curve = trinity.Tr2CurveScalar() + curve.AddKey(0.0, 0.0, trinity.Tr2CurveInterpolation.LINEAR) + curve.AddKey(10.0, 10.0, trinity.Tr2CurveInterpolation.LINEAR) + return curve + + +def _Bind(curveSet, source, sourceAttribute, timeOffset=0.0): + destination = trinity.Tr2FloatParameter() + binding = trinity.TriValueBinding() + binding.sourceObject = source + binding.sourceAttribute = sourceAttribute + binding.destinationObject = destination + binding.destinationAttribute = 'value' + binding.timeOffset = timeOffset + curveSet.bindings.append(binding) + return destination + + +def _ApplyAt(curveSet, time): + curveSet.scaledTime = time + curveSet.Apply() + + +class TestCurveSetBindingTimeOffset(unittest.TestCase): + def setUp(self): + self.curve = _LinearCurve() + self.curveSet = trinity.TriCurveSet() + self.curveSet.curves.append(self.curve) + + def test_zero_offset_copies_curve_set_time(self): + destination = _Bind(self.curveSet, self.curve, 'currentValue') + _ApplyAt(self.curveSet, 5.0) + self.assertAlmostEqual(destination.value, 5.0, places=4) + + def test_offset_samples_curve_at_shifted_time(self): + lagging = _Bind(self.curveSet, self.curve, 'currentValue', timeOffset=2.0) + leading = _Bind(self.curveSet, self.curve, 'currentValue', timeOffset=-1.5) + _ApplyAt(self.curveSet, 5.0) + self.assertAlmostEqual(lagging.value, 3.0, places=4) + self.assertAlmostEqual(leading.value, 6.5, places=4) + + def test_offset_binding_leaves_shared_curve_alone(self): + _Bind(self.curveSet, self.curve, 'currentValue', timeOffset=2.0) + plain = _Bind(self.curveSet, self.curve, 'currentValue') + _ApplyAt(self.curveSet, 5.0) + self.assertAlmostEqual(self.curve.currentValue, 5.0, places=4) + self.assertAlmostEqual(plain.value, 5.0, places=4) + + def test_offset_wraps_into_looped_time_range(self): + destination = _Bind(self.curveSet, self.curve, 'currentValue', timeOffset=2.0) + self.curveSet.SetTimeRange(0.0, 4.0, True) + _ApplyAt(self.curveSet, 1.0) + # 1 - 2 = -1 wraps to 3 inside [0, 4) + self.assertAlmostEqual(destination.value, 3.0, places=4) + + def test_offset_without_range_is_not_wrapped(self): + destination = _Bind(self.curveSet, self.curve, 'currentValue', timeOffset=2.0) + _ApplyAt(self.curveSet, 7.0) + self.assertAlmostEqual(destination.value, 5.0, places=4) + + def test_offset_on_non_curve_source_copies_value_as_is(self): + source = trinity.Tr2FloatParameter() + source.value = 42.0 + destination = _Bind(self.curveSet, source, 'value', timeOffset=2.0) + _ApplyAt(self.curveSet, 5.0) + self.assertAlmostEqual(destination.value, 42.0, places=4) + + def test_offset_on_curve_attribute_other_than_current_value_is_ignored(self): + self.curve.timeOffset = 0.25 + destination = _Bind(self.curveSet, self.curve, 'timeOffset', timeOffset=2.0) + _ApplyAt(self.curveSet, 5.0) + self.assertAlmostEqual(destination.value, 0.25, places=4) + + +if __name__ == '__main__': + unittest.main() From a656136352f02c8d5b162c898739a1b6a46b3990 Mon Sep 17 00:00:00 2001 From: Jonathan Dunn Date: Tue, 20 Oct 2026 14:04:38 +0000 Subject: [PATCH 03/10] Test that offset bindings don't move a curve owned by another curve set Fails on the previous implementation, which restored the curve to the binding's own curve set's time. Co-Authored-By: Claude Opus 5.5 --- trinity/tests/Python/tests/test_curvesetbindings.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/trinity/tests/Python/tests/test_curvesetbindings.py b/trinity/tests/Python/tests/test_curvesetbindings.py index 1ed839b8e..1fc617afb 100644 --- a/trinity/tests/Python/tests/test_curvesetbindings.py +++ b/trinity/tests/Python/tests/test_curvesetbindings.py @@ -55,6 +55,18 @@ def test_offset_binding_leaves_shared_curve_alone(self): self.assertAlmostEqual(self.curve.currentValue, 5.0, places=4) self.assertAlmostEqual(plain.value, 5.0, places=4) + def test_offset_binding_leaves_curve_of_another_curve_set_alone(self): + owner = trinity.TriCurveSet() + curve = _LinearCurve() + owner.curves.append(curve) + _ApplyAt(owner, 8.0) + + destination = _Bind(self.curveSet, curve, 'currentValue', timeOffset=2.0) + _ApplyAt(self.curveSet, 5.0) + + self.assertAlmostEqual(destination.value, 3.0, places=4) + self.assertAlmostEqual(curve.currentValue, 8.0, places=4) + def test_offset_wraps_into_looped_time_range(self): destination = _Bind(self.curveSet, self.curve, 'currentValue', timeOffset=2.0) self.curveSet.SetTimeRange(0.0, 4.0, True) From a3e2abfdbc1c1aa0a91e152be63cde5c2f66323d Mon Sep 17 00:00:00 2001 From: Jonathan Dunn Date: Tue, 20 Oct 2026 15:22:18 +0000 Subject: [PATCH 04/10] Tighten time offset binding contract and cover all curve types - The timeOffset description claimed every ignored offset logs a warning. Bindings run without a curve set time (lens flares, child links, socket parameters, dynamic bindings) ignore it silently; say so. - TriCurveSampleFunc writes into a Vector4& instead of a void* whose size was only stated in a comment. - Test vector and color curves and a swizzled source (currentValue.y), which exercises the sample offset path. Co-Authored-By: Claude Opus 5.5 --- trinity/Curves/TriCurveSampler.cpp | 16 +++---- trinity/Curves/TriCurveSampler.h | 5 +- trinity/TriValueBinding.cpp | 2 +- trinity/TriValueBinding_Blue.cpp | 9 ++-- .../Python/tests/test_curvesetbindings.py | 48 ++++++++++++++++++- 5 files changed, 64 insertions(+), 16 deletions(-) diff --git a/trinity/Curves/TriCurveSampler.cpp b/trinity/Curves/TriCurveSampler.cpp index 90a9066a2..4ffa355c3 100644 --- a/trinity/Curves/TriCurveSampler.cpp +++ b/trinity/Curves/TriCurveSampler.cpp @@ -10,24 +10,24 @@ namespace // Each goes through GetValueAt, which typed curves implement without touching // the state UpdateValue keeps - so a curve shared with others, or one that // fires events as it advances, is unaffected by being sampled. -void SampleScalar( IRoot* curve, double time, void* out ) +void SampleScalar( IRoot* curve, double time, Vector4& out ) { - *static_cast( out ) = ITriScalarFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( time ); + out.x = ITriScalarFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( time ); } -void SampleVector( IRoot* curve, double time, void* out ) +void SampleVector( IRoot* curve, double time, Vector4& out ) { - ITriVectorFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( static_cast( out ), time ); + ITriVectorFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( reinterpret_cast( &out ), time ); } -void SampleQuaternion( IRoot* curve, double time, void* out ) +void SampleQuaternion( IRoot* curve, double time, Vector4& out ) { - ITriQuaternionFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( static_cast( out ), time ); + ITriQuaternionFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( reinterpret_cast( &out ), time ); } -void SampleColor( IRoot* curve, double time, void* out ) +void SampleColor( IRoot* curve, double time, Vector4& out ) { - ITriColorFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( static_cast( out ), time ); + ITriColorFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( reinterpret_cast( &out ), time ); } } diff --git a/trinity/Curves/TriCurveSampler.h b/trinity/Curves/TriCurveSampler.h index b1f6f0904..df816a9ad 100644 --- a/trinity/Curves/TriCurveSampler.h +++ b/trinity/Curves/TriCurveSampler.h @@ -5,8 +5,9 @@ #define TriCurveSampler_h // Evaluates a curve at a time into out, without changing the curve's own state. -// out must have room for a Vector4. -typedef void ( *TriCurveSampleFunc )( IRoot* curve, double time, void* out ); +// The value is laid out from out.x, as in the curve's currentValue: a scalar +// fills x, a vector xyz, a quaternion or color all four. +typedef void ( *TriCurveSampleFunc )( IRoot* curve, double time, Vector4& out ); // How to sample source at an arbitrary time when valueEntry is its value // attribute - a scalar, vector, quaternion or color curve - or nullptr if diff --git a/trinity/TriValueBinding.cpp b/trinity/TriValueBinding.cpp index 4cb7d5d54..6369be6e8 100644 --- a/trinity/TriValueBinding.cpp +++ b/trinity/TriValueBinding.cpp @@ -88,7 +88,7 @@ void TriValueBinding::CopyValueAt( const TriCurveSetTime& time ) // moving the curve: its current value is shared with other bindings and // readers, and stateful curves must only ever see their curve set's time. Vector4 sampled; - m_sampleFunc( GetCurrentSourceObject(), time.Shifted( m_timeOffset ), &sampled ); + m_sampleFunc( GetCurrentSourceObject(), time.Shifted( m_timeOffset ), sampled ); CopyFrom( reinterpret_cast( &sampled ) + m_sampleOffset ); } diff --git a/trinity/TriValueBinding_Blue.cpp b/trinity/TriValueBinding_Blue.cpp index fb6d21d1d..c93b20b84 100644 --- a/trinity/TriValueBinding_Blue.cpp +++ b/trinity/TriValueBinding_Blue.cpp @@ -89,9 +89,12 @@ const Be::ClassInfo* TriValueBinding::ExposeToBlue() "the same curve at different times, without duplicating the curve. The\n" "curve itself is not moved, so it can be shared freely. A looped time\n" "range on the curve set wraps the offset time into the range.\n" - "Applies when the binding is run by a curve set and its source is the\n" - "currentValue of a scalar, vector, quaternion or color curve; otherwise\n" - "it is ignored with a warning.", + "Only applies when the source is the currentValue of a scalar, vector,\n" + "quaternion or color curve - any other source logs a warning and is\n" + "copied as it stands - and only when a curve set applies the binding.\n" + "Bindings run without a curve set time (lens flares, child links, socket\n" + "parameters, dynamic bindings) copy the curve's current value and\n" + "silently ignore the offset.", Be::READWRITE | Be::PERSIST | Be::NOTIFY ) #if BLUE_WITH_PYTHON MAP_ATTRIBUTE( diff --git a/trinity/tests/Python/tests/test_curvesetbindings.py b/trinity/tests/Python/tests/test_curvesetbindings.py index 1fc617afb..ab9ecc2a3 100644 --- a/trinity/tests/Python/tests/test_curvesetbindings.py +++ b/trinity/tests/Python/tests/test_curvesetbindings.py @@ -13,8 +13,25 @@ def _LinearCurve(): return curve -def _Bind(curveSet, source, sourceAttribute, timeOffset=0.0): - destination = trinity.Tr2FloatParameter() +def _LinearVectorCurve(): + # currentValue == (t, 2t, 3t) over [0, 10] + curve = trinity.Tr2CurveVector3() + curve.AddKey(0.0, (0.0, 0.0, 0.0), trinity.Tr2CurveInterpolation.LINEAR) + curve.AddKey(10.0, (10.0, 20.0, 30.0), trinity.Tr2CurveInterpolation.LINEAR) + return curve + + +def _LinearColorCurve(): + # currentValue == (t, 2t, 3t, 4t) / 10 over [0, 10] + curve = trinity.Tr2CurveColor() + curve.AddKey(0.0, (0.0, 0.0, 0.0, 0.0), trinity.Tr2CurveInterpolation.LINEAR) + curve.AddKey(10.0, (1.0, 2.0, 3.0, 4.0), trinity.Tr2CurveInterpolation.LINEAR) + return curve + + +def _Bind(curveSet, source, sourceAttribute, timeOffset=0.0, destination=None): + if destination is None: + destination = trinity.Tr2FloatParameter() binding = trinity.TriValueBinding() binding.sourceObject = source binding.sourceAttribute = sourceAttribute @@ -67,6 +84,33 @@ def test_offset_binding_leaves_curve_of_another_curve_set_alone(self): self.assertAlmostEqual(destination.value, 3.0, places=4) self.assertAlmostEqual(curve.currentValue, 8.0, places=4) + def test_offset_samples_vector_curve(self): + curve = _LinearVectorCurve() + self.curveSet.curves.append(curve) + destination = _Bind(self.curveSet, curve, 'currentValue', timeOffset=2.0, + destination=trinity.Tr2Vector3Parameter()) + _ApplyAt(self.curveSet, 5.0) + for actual, expected in zip(destination.value, (3.0, 6.0, 9.0)): + self.assertAlmostEqual(actual, expected, places=4) + + def test_offset_samples_one_component_of_vector_curve(self): + curve = _LinearVectorCurve() + self.curveSet.curves.append(curve) + y = _Bind(self.curveSet, curve, 'currentValue.y', timeOffset=2.0) + z = _Bind(self.curveSet, curve, 'currentValue.z', timeOffset=2.0) + _ApplyAt(self.curveSet, 5.0) + self.assertAlmostEqual(y.value, 6.0, places=4) + self.assertAlmostEqual(z.value, 9.0, places=4) + + def test_offset_samples_color_curve(self): + curve = _LinearColorCurve() + self.curveSet.curves.append(curve) + destination = _Bind(self.curveSet, curve, 'currentValue', timeOffset=-5.0, + destination=trinity.Tr2Vector4Parameter()) + _ApplyAt(self.curveSet, 2.0) + for actual, expected in zip(destination.value, (0.7, 1.4, 2.1, 2.8)): + self.assertAlmostEqual(actual, expected, places=4) + def test_offset_wraps_into_looped_time_range(self): destination = _Bind(self.curveSet, self.curve, 'currentValue', timeOffset=2.0) self.curveSet.SetTimeRange(0.0, 4.0, True) From cf4f10e5434a489fd863c70cddc15621452a231b Mon Sep 17 00:00:00 2001 From: Jonathan Dunn Date: Tue, 20 Oct 2026 16:24:28 +0000 Subject: [PATCH 05/10] Speed up sampling offset bindings from a shared curve Timing on a TrinityDev build showed one curve driving two offset bindings costing 1.5x (4 keys) to 17x (512 keys) the time of two duplicated curves. Two causes: - Every sample looked up the curve's typed interface again (QueryInterface plus a reference count round trip). TriCurveSampler now finds it once when the binding resolves its source and keeps the raw pointer, which lives exactly as long as the binding's existing pointer into the source. - Tr2CurveScalar caches the last key segment it used. Sampling one curve at two times a frame keeps moving that cache, and every miss scanned all keys. A miss now binary searches the sorted keys, keeping the linear scan only for keys edited out of order without OnKeysChanged. Co-Authored-By: Claude Opus 5.5 --- trinity/Curves/Tr2CurveScalar.cpp | 13 +++++- trinity/Curves/TriCurveSampler.cpp | 69 +++++++++++----------------- trinity/Curves/TriCurveSampler.h | 72 ++++++++++++++++++++++++++---- trinity/TriValueBinding.cpp | 11 +++-- trinity/TriValueBinding.h | 2 +- 5 files changed, 108 insertions(+), 59 deletions(-) diff --git a/trinity/Curves/Tr2CurveScalar.cpp b/trinity/Curves/Tr2CurveScalar.cpp index 62a323c30..bf767fe9e 100644 --- a/trinity/Curves/Tr2CurveScalar.cpp +++ b/trinity/Curves/Tr2CurveScalar.cpp @@ -208,7 +208,18 @@ float Tr2CurveScalar::GetValue( double time ) const } } - // cache miss: try all segments + // cache miss: keys are sorted by time (OnKeysChanged), so binary search for + // the segment. That matters when one curve is sampled at several times a + // frame - bindings with time offsets - and the cached segment keeps moving. + auto next = std::upper_bound( m_keys.begin(), m_keys.end(), t, []( float time, const Tr2CurveScalarKey& key ) { return time < key.m_time; } ); + size_t found = size_t( next - m_keys.begin() ); + if( found > 0 && found < count && t >= m_keys[found - 1].m_time && t < m_keys[found].m_time ) + { + m_lastSegment = found - 1; + return GetSegmentValue( t, m_keys[found - 1], m_keys[found] ); + } + + // keys edited out of order without OnKeysChanged: try all segments for( size_t i = 0; i + 1 < count; ++i ) { auto& k0 = m_keys[i]; diff --git a/trinity/Curves/TriCurveSampler.cpp b/trinity/Curves/TriCurveSampler.cpp index 4ffa355c3..a73637e81 100644 --- a/trinity/Curves/TriCurveSampler.cpp +++ b/trinity/Curves/TriCurveSampler.cpp @@ -3,57 +3,42 @@ #include "StdAfx.h" #include "TriCurveSampler.h" -#include - -namespace -{ -// Each goes through GetValueAt, which typed curves implement without touching -// the state UpdateValue keeps - so a curve shared with others, or one that -// fires events as it advances, is unaffected by being sampled. -void SampleScalar( IRoot* curve, double time, Vector4& out ) -{ - out.x = ITriScalarFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( time ); -} - -void SampleVector( IRoot* curve, double time, Vector4& out ) -{ - ITriVectorFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( reinterpret_cast( &out ), time ); -} - -void SampleQuaternion( IRoot* curve, double time, Vector4& out ) +TriCurveSampler TriCurveSampler::Find( IRoot* source, const Be::VarEntry* valueEntry ) { - ITriQuaternionFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( reinterpret_cast( &out ), time ); -} + // The interface is looked up once here rather than on every sample. The raw + // pointer stays valid for as long as source does: the smart pointers only + // hold their own reference while we look. + TriCurveSampler sampler; -void SampleColor( IRoot* curve, double time, Vector4& out ) -{ - ITriColorFunctionPtr( BlueCastPtr( curve ) )->GetValueAt( reinterpret_cast( &out ), time ); -} -} - -TriCurveSampleFunc FindCurveSampleFunc( IRoot* source, const Be::VarEntry* valueEntry ) -{ if( valueEntry->mType == Be::FLOAT ) { - return ITriScalarFunctionPtr( BlueCastPtr( source ) ) ? SampleScalar : nullptr; + if( ITriScalarFunctionPtr scalar = BlueCastPtr( source ) ) + { + sampler.m_kind = Kind::Scalar; + sampler.m_curve.scalar = scalar.p; + } } - - if( valueEntry->mType != Be::FLOATARRAY ) + else if( valueEntry->mType == Be::FLOATARRAY && valueEntry->GetFloatArraySize() == 3 ) { - return nullptr; + if( ITriVectorFunctionPtr vector = BlueCastPtr( source ) ) + { + sampler.m_kind = Kind::Vector; + sampler.m_curve.vector = vector.p; + } } - - switch( valueEntry->GetFloatArraySize() ) + else if( valueEntry->mType == Be::FLOATARRAY && valueEntry->GetFloatArraySize() == 4 ) { - case 3: - return ITriVectorFunctionPtr( BlueCastPtr( source ) ) ? SampleVector : nullptr; - case 4: - if( ITriQuaternionFunctionPtr( BlueCastPtr( source ) ) ) + if( ITriQuaternionFunctionPtr quaternion = BlueCastPtr( source ) ) { - return SampleQuaternion; + sampler.m_kind = Kind::Quaternion; + sampler.m_curve.quaternion = quaternion.p; + } + else if( ITriColorFunctionPtr color = BlueCastPtr( source ) ) + { + sampler.m_kind = Kind::Color; + sampler.m_curve.color = color.p; } - return ITriColorFunctionPtr( BlueCastPtr( source ) ) ? SampleColor : nullptr; - default: - return nullptr; } + + return sampler; } diff --git a/trinity/Curves/TriCurveSampler.h b/trinity/Curves/TriCurveSampler.h index df816a9ad..73eefbd66 100644 --- a/trinity/Curves/TriCurveSampler.h +++ b/trinity/Curves/TriCurveSampler.h @@ -4,14 +4,68 @@ #ifndef TriCurveSampler_h #define TriCurveSampler_h -// Evaluates a curve at a time into out, without changing the curve's own state. -// The value is laid out from out.x, as in the curve's currentValue: a scalar -// fills x, a vector xyz, a quaternion or color all four. -typedef void ( *TriCurveSampleFunc )( IRoot* curve, double time, Vector4& out ); - -// How to sample source at an arbitrary time when valueEntry is its value -// attribute - a scalar, vector, quaternion or color curve - or nullptr if -// source is not a curve of the matching type. -TriCurveSampleFunc FindCurveSampleFunc( IRoot* source, const Be::VarEntry* valueEntry ); +#include + +// Samples a typed curve's value at any time through GetValueAt, without moving +// the curve's current value - so a curve shared with others, or one that fires +// events as it advances, is unaffected. (Curves may still update internal +// lookup caches, which never changes what they return.) +// +// Holds a raw interface pointer found once, so it is only valid while the +// source object is, and must be found again whenever the source changes. +class TriCurveSampler +{ +public: + // A sampler for source when valueEntry is its value attribute - a scalar, + // vector, quaternion or color curve - or an empty sampler otherwise. + static TriCurveSampler Find( IRoot* source, const Be::VarEntry* valueEntry ); + + explicit operator bool() const + { + return m_kind != Kind::None; + } + + // The curve's value at time, laid out from out.x as in its currentValue: a + // scalar fills x, a vector xyz, a quaternion or color all four. + void Sample( double time, Vector4& out ) const + { + switch( m_kind ) + { + case Kind::Scalar: + out.x = m_curve.scalar->GetValueAt( time ); + break; + case Kind::Vector: + m_curve.vector->GetValueAt( reinterpret_cast( &out ), time ); + break; + case Kind::Quaternion: + m_curve.quaternion->GetValueAt( reinterpret_cast( &out ), time ); + break; + case Kind::Color: + m_curve.color->GetValueAt( reinterpret_cast( &out ), time ); + break; + case Kind::None: + break; + } + } + +private: + enum class Kind : uint8_t + { + None, + Scalar, + Vector, + Quaternion, + Color + }; + + Kind m_kind = Kind::None; + union + { + ITriScalarFunction* scalar; + ITriVectorFunction* vector; + ITriQuaternionFunction* quaternion; + ITriColorFunction* color; + } m_curve = { nullptr }; +}; #endif // TriCurveSampler_h diff --git a/trinity/TriValueBinding.cpp b/trinity/TriValueBinding.cpp index 6369be6e8..8fdbbcb83 100644 --- a/trinity/TriValueBinding.cpp +++ b/trinity/TriValueBinding.cpp @@ -20,7 +20,6 @@ TriValueBinding::TriValueBinding( IRoot* lockobj ) : m_scale( 1.0f ), m_offset( 0.0f, 0.0f, 0.0f, 0.0f ), m_timeOffset( 0.0f ), - m_sampleFunc( nullptr ), m_sampleOffset( 0 ), m_sourceItemOffset( 0 ), m_destItemOffset( 0 ) @@ -78,7 +77,7 @@ void TriValueBinding::CopyValueAt( const TriCurveSetTime& time ) return; } - if( m_timeOffset == 0.0f || !m_sampleFunc ) + if( m_timeOffset == 0.0f || !m_sampler ) { CopyFrom( m_source ); return; @@ -88,7 +87,7 @@ void TriValueBinding::CopyValueAt( const TriCurveSetTime& time ) // moving the curve: its current value is shared with other bindings and // readers, and stateful curves must only ever see their curve set's time. Vector4 sampled; - m_sampleFunc( GetCurrentSourceObject(), time.Shifted( m_timeOffset ), sampled ); + m_sampler.Sample( time.Shifted( m_timeOffset ), sampled ); CopyFrom( reinterpret_cast( &sampled ) + m_sampleOffset ); } @@ -355,7 +354,7 @@ void TriValueBinding::Initialize() m_destination = NULL; m_copyFunc = NULL; m_notifyPtr = nullptr; - m_sampleFunc = nullptr; + m_sampler = TriCurveSampler(); m_sampleOffset = 0; IRoot* sourceObject = GetCurrentSourceObject(); @@ -428,7 +427,7 @@ void TriValueBinding::Initialize() // only a typed curve bound through its currentValue can be sampled. if( m_copyFunc && sourceAttr == "currentValue" ) { - m_sampleFunc = FindCurveSampleFunc( sourceObject, srcEntry ); + m_sampler = TriCurveSampler::Find( sourceObject, srcEntry ); // m_source may already point at one component (currentValue.y), so // copy from the same place inside the sampled value. m_sampleOffset = (uint8_t)( (uint8_t*)m_source - (uint8_t*)sourceMember ); @@ -472,7 +471,7 @@ void TriValueBinding::Initialize() } } - if( m_timeOffset != 0.0f && !m_sampleFunc ) + if( m_timeOffset != 0.0f && !m_sampler ) { CCP_LOGWARN( "TriValueBinding \"%s\": timeOffset ignored - source '%s' is not the currentValue of a scalar, vector, quaternion or color curve", m_name.c_str(), m_sourceAttribute.c_str() ); } diff --git a/trinity/TriValueBinding.h b/trinity/TriValueBinding.h index 83d707b4e..ea4f5b066 100644 --- a/trinity/TriValueBinding.h +++ b/trinity/TriValueBinding.h @@ -94,7 +94,7 @@ BLUE_CLASS( TriValueBinding ) : // Set when the source is a curve bound through its currentValue, so an // offset binding can evaluate it at its own time without moving the curve. // m_sampleOffset is where m_source sits inside that value. - TriCurveSampleFunc m_sampleFunc; + TriCurveSampler m_sampler; uint8_t m_sampleOffset; INotify* m_notifyPtr; From 462502354c68d4908bab31da8d03770e0a2f11b5 Mon Sep 17 00:00:00 2001 From: Jonathan Dunn Date: Tue, 20 Oct 2026 16:26:45 +0000 Subject: [PATCH 06/10] Test offset bindings on a long curve sampled far apart Exercises the scalar curve's key search on a cache miss. Co-Authored-By: Claude Opus 5.5 --- .../tests/Python/tests/test_curvesetbindings.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/trinity/tests/Python/tests/test_curvesetbindings.py b/trinity/tests/Python/tests/test_curvesetbindings.py index ab9ecc2a3..a87546cb8 100644 --- a/trinity/tests/Python/tests/test_curvesetbindings.py +++ b/trinity/tests/Python/tests/test_curvesetbindings.py @@ -111,6 +111,22 @@ def test_offset_samples_color_curve(self): for actual, expected in zip(destination.value, (0.7, 1.4, 2.1, 2.8)): self.assertAlmostEqual(actual, expected, places=4) + def test_offset_on_long_curve_finds_the_right_key_segment(self): + # Sampling one curve at two times far apart keeps missing its cached key + # segment, so this goes through the curve's key search each time. + curve = trinity.Tr2CurveScalar() + for key in range(64): + curve.AddKey(float(key), float(key * 2), trinity.Tr2CurveInterpolation.LINEAR) + curveSet = trinity.TriCurveSet() + curveSet.curves.append(curve) + now = _Bind(curveSet, curve, 'currentValue') + earlier = _Bind(curveSet, curve, 'currentValue', timeOffset=20.0) + for step in range(0, 400): + time = 20.0 + step * 0.1 + _ApplyAt(curveSet, time) + self.assertAlmostEqual(now.value, time * 2.0, places=3) + self.assertAlmostEqual(earlier.value, (time - 20.0) * 2.0, places=3) + def test_offset_wraps_into_looped_time_range(self): destination = _Bind(self.curveSet, self.curve, 'currentValue', timeOffset=2.0) self.curveSet.SetTimeRange(0.0, 4.0, True) From 919c5d234a4f1f4e256b5e3313de6aefc9337c8b Mon Sep 17 00:00:00 2001 From: Jonathan Dunn Date: Tue, 20 Oct 2026 17:18:35 +0000 Subject: [PATCH 07/10] Give offset bindings their own key segment cache A shared curve sampled at its curve set's time and at a binding's offset time kept moving its single cached key segment, so most lookups searched. That left one shared curve about 2.2x the cost of two duplicated curves. Tr2CurveScalar and Tr2CurveQuaternion now look their keys up through one FindCurveSegment helper that takes the cache to use, replacing the two copies of the same lookup. Their own GetValue passes the curve's cache as before; the new SampleValue takes the caller's. TriCurveSampler recognises these keyed curves and keeps a segment cache per binding, so an offset binding finds its keys as quickly as a duplicated curve would, and leaves the curve's own cache alone. The quaternion curve also gets the binary search on a cache miss, and both can now step back from segment 1 to segment 0 from the cache (the left neighbour check stopped at 1). Co-Authored-By: Claude Opus 5.5 --- trinity/CMakeLists.txt | 1 + trinity/Curves/Tr2CurveQuaternion.cpp | 62 +++------------- trinity/Curves/Tr2CurveQuaternion.h | 5 ++ trinity/Curves/Tr2CurveScalar.cpp | 73 +++---------------- trinity/Curves/Tr2CurveScalar.h | 5 ++ trinity/Curves/Tr2CurveSegmentSearch.h | 58 +++++++++++++++ trinity/Curves/TriCurveSampler.cpp | 45 +++++++++++- trinity/Curves/TriCurveSampler.h | 45 +++++------- .../Python/tests/test_curvesetbindings.py | 15 ++++ 9 files changed, 163 insertions(+), 146 deletions(-) create mode 100644 trinity/Curves/Tr2CurveSegmentSearch.h diff --git a/trinity/CMakeLists.txt b/trinity/CMakeLists.txt index 92eb1d5a9..47989aee9 100644 --- a/trinity/CMakeLists.txt +++ b/trinity/CMakeLists.txt @@ -200,6 +200,7 @@ set(_SOURCES Curves/Tr2CurveScalar.cpp Curves/Tr2CurveScalar.h Curves/Tr2CurveScalar_Blue.cpp + Curves/Tr2CurveSegmentSearch.h Curves/Tr2CurveScalarExpression.cpp Curves/Tr2CurveScalarExpression.h Curves/Tr2CurveScalarExpression_Blue.cpp diff --git a/trinity/Curves/Tr2CurveQuaternion.cpp b/trinity/Curves/Tr2CurveQuaternion.cpp index 6c9e7e238..1fe521be1 100644 --- a/trinity/Curves/Tr2CurveQuaternion.cpp +++ b/trinity/Curves/Tr2CurveQuaternion.cpp @@ -2,6 +2,7 @@ #include "StdAfx.h" #include "Tr2CurveQuaternion.h" +#include "Tr2CurveSegmentSearch.h" extern Be::VarChooser Tr2CurveInterpolationChooser[]; @@ -106,6 +107,12 @@ float Tr2CurveQuaternion::Length() // -------------------------------------------------------------------------------- Quaternion Tr2CurveQuaternion::GetValue( double time ) const +{ + return SampleValue( time, m_lastSegment ); +} + +// -------------------------------------------------------------------------------- +Quaternion Tr2CurveQuaternion::SampleValue( double time, size_t& segmentCache ) const { if( m_keys.empty() ) { @@ -130,59 +137,8 @@ Quaternion Tr2CurveQuaternion::GetValue( double time ) const } float t = GetLocalTime( time ); - - if( m_lastSegment + 1 < count ) - { - // try cached last segment first - auto& k0 = m_keys[m_lastSegment]; - auto& k1 = m_keys[m_lastSegment + 1]; - - if( t >= k0.m_time && t < k1.m_time ) - { - return GetSegmentValue( t, k0, k1 ); - } - - if( m_lastSegment + 2 < count ) - { - // try a segment to the right of the cached one - auto& k0 = m_keys[m_lastSegment + 1]; - auto& k1 = m_keys[m_lastSegment + 2]; - - if( t >= k0.m_time && t < k1.m_time ) - { - ++m_lastSegment; - return GetSegmentValue( t, k0, k1 ); - } - } - - if( m_lastSegment > 1 ) - { - // try a segment to the left of the cached one - auto& k0 = m_keys[m_lastSegment - 1]; - auto& k1 = m_keys[m_lastSegment]; - - if( t >= k0.m_time && t < k1.m_time ) - { - --m_lastSegment; - return GetSegmentValue( t, k0, k1 ); - } - } - } - - // cache miss: try all segments - for( size_t i = 0; i + 1 < count; ++i ) - { - auto& k0 = m_keys[i]; - auto& k1 = m_keys[i + 1]; - - if( t >= k0.m_time && t < k1.m_time ) - { - m_lastSegment = int32_t( i ); - return GetSegmentValue( t, k0, k1 ); - } - } - m_lastSegment = count - 2; - return GetSegmentValue( t, m_keys[count - 2], m_keys[count - 1] ); + size_t i = FindCurveSegment( m_keys, t, segmentCache ); + return GetSegmentValue( t, m_keys[i], m_keys[i + 1] ); } // -------------------------------------------------------------------------------- diff --git a/trinity/Curves/Tr2CurveQuaternion.h b/trinity/Curves/Tr2CurveQuaternion.h index d690f1171..3c8c41edd 100644 --- a/trinity/Curves/Tr2CurveQuaternion.h +++ b/trinity/Curves/Tr2CurveQuaternion.h @@ -43,6 +43,11 @@ BLUE_CLASS( Tr2CurveQuaternion ) : virtual float Length(); Quaternion GetValue( double time ) const; + // The value at time, like GetValue, but looking keys up from the caller's + // segment cache rather than the curve's own. For callers that sample the + // curve at times of their own (bindings with a time offset), so they and the + // curve's own UpdateValue don't keep moving each other's cache. + Quaternion SampleValue( double time, size_t& segmentCache ) const; Quaternion GetCurrentValue() const; void OnKeysChanged(); diff --git a/trinity/Curves/Tr2CurveScalar.cpp b/trinity/Curves/Tr2CurveScalar.cpp index bf767fe9e..5b935a573 100644 --- a/trinity/Curves/Tr2CurveScalar.cpp +++ b/trinity/Curves/Tr2CurveScalar.cpp @@ -2,6 +2,7 @@ #include "StdAfx.h" #include "Tr2CurveScalar.h" +#include "Tr2CurveSegmentSearch.h" extern Be::VarChooser Tr2CurveInterpolationChooser[]; extern Be::VarChooser Tr2CurveTangentTypeChooser[]; @@ -131,6 +132,12 @@ float Tr2CurveScalar::GetValueAt( double time ) // -------------------------------------------------------------------------------- float Tr2CurveScalar::GetValue( double time ) const +{ + return SampleValue( time, m_lastSegment ); +} + +// -------------------------------------------------------------------------------- +float Tr2CurveScalar::SampleValue( double time, size_t& segmentCache ) const { if( m_keys.empty() ) { @@ -169,70 +176,8 @@ float Tr2CurveScalar::GetValue( double time ) const } float t = GetLocalTime( time ); - - if( m_lastSegment + 1 < count ) - { - // try cached last segment first - auto& k0 = m_keys[m_lastSegment]; - auto& k1 = m_keys[m_lastSegment + 1]; - - if( t >= k0.m_time && t < k1.m_time ) - { - return GetSegmentValue( t, k0, k1 ); - } - - if( m_lastSegment + 2 < count ) - { - // try a segment to the right of the cached one - auto& k0 = m_keys[m_lastSegment + 1]; - auto& k1 = m_keys[m_lastSegment + 2]; - - if( t >= k0.m_time && t < k1.m_time ) - { - ++m_lastSegment; - return GetSegmentValue( t, k0, k1 ); - } - } - - if( m_lastSegment > 1 ) - { - // try a segment to the left of the cached one - auto& k0 = m_keys[m_lastSegment - 1]; - auto& k1 = m_keys[m_lastSegment]; - - if( t >= k0.m_time && t < k1.m_time ) - { - --m_lastSegment; - return GetSegmentValue( t, k0, k1 ); - } - } - } - - // cache miss: keys are sorted by time (OnKeysChanged), so binary search for - // the segment. That matters when one curve is sampled at several times a - // frame - bindings with time offsets - and the cached segment keeps moving. - auto next = std::upper_bound( m_keys.begin(), m_keys.end(), t, []( float time, const Tr2CurveScalarKey& key ) { return time < key.m_time; } ); - size_t found = size_t( next - m_keys.begin() ); - if( found > 0 && found < count && t >= m_keys[found - 1].m_time && t < m_keys[found].m_time ) - { - m_lastSegment = found - 1; - return GetSegmentValue( t, m_keys[found - 1], m_keys[found] ); - } - - // keys edited out of order without OnKeysChanged: try all segments - for( size_t i = 0; i + 1 < count; ++i ) - { - auto& k0 = m_keys[i]; - auto& k1 = m_keys[i + 1]; - - if( t >= k0.m_time && t < k1.m_time ) - { - m_lastSegment = int32_t( i ); - return GetSegmentValue( t, k0, k1 ); - } - } - m_lastSegment = count - 2; - return GetSegmentValue( t, m_keys[count - 2], m_keys[count - 1] ); + size_t i = FindCurveSegment( m_keys, t, segmentCache ); + return GetSegmentValue( t, m_keys[i], m_keys[i + 1] ); } // -------------------------------------------------------------------------------- diff --git a/trinity/Curves/Tr2CurveScalar.h b/trinity/Curves/Tr2CurveScalar.h index 86f356112..ebcc2e0d5 100644 --- a/trinity/Curves/Tr2CurveScalar.h +++ b/trinity/Curves/Tr2CurveScalar.h @@ -110,6 +110,11 @@ BLUE_CLASS( Tr2CurveScalar ) : void SetName( const char* name ); float GetValue( double time ) const; + // The value at time, like GetValue, but looking keys up from the caller's + // segment cache rather than the curve's own. For callers that sample the + // curve at times of their own (bindings with a time offset), so they and the + // curve's own UpdateValue don't keep moving each other's cache. + float SampleValue( double time, size_t& segmentCache ) const; float GetTangent( double time ) const; float GetCurrentValue() const; diff --git a/trinity/Curves/Tr2CurveSegmentSearch.h b/trinity/Curves/Tr2CurveSegmentSearch.h new file mode 100644 index 000000000..4faa6ce2d --- /dev/null +++ b/trinity/Curves/Tr2CurveSegmentSearch.h @@ -0,0 +1,58 @@ +// Copyright © 2026 CCP ehf. + +#pragma once +#ifndef Tr2CurveSegmentSearch_h +#define Tr2CurveSegmentSearch_h + +#include + +// Index i of the key segment [keys[i], keys[i + 1]) that contains t, for a curve +// with at least two keys. Falls back to the last segment when none contains t. +// +// cachedSegment is a hint, updated to the segment found: curves sampled at +// steadily moving times find their segment at or next to the cached one. Each +// caller sampling at its own times - a curve's own UpdateValue, or a binding +// with a time offset - should keep its own hint, or they keep moving each +// other's and every lookup has to search. +template +size_t FindCurveSegment( const Keys& keys, float t, size_t& cachedSegment ) +{ + const size_t count = keys.size(); + const auto contains = [&]( size_t i ) { return t >= keys[i].m_time && t < keys[i + 1].m_time; }; + + if( cachedSegment + 1 < count ) + { + if( contains( cachedSegment ) ) + { + return cachedSegment; + } + if( cachedSegment + 2 < count && contains( cachedSegment + 1 ) ) + { + return ++cachedSegment; + } + if( cachedSegment > 0 && contains( cachedSegment - 1 ) ) + { + return --cachedSegment; + } + } + + // Keys are kept sorted by time, so binary search for the segment. + const auto next = std::upper_bound( keys.begin(), keys.end(), t, []( float time, const auto& key ) { return time < key.m_time; } ); + const size_t found = size_t( next - keys.begin() ); + if( found > 0 && found < count && contains( found - 1 ) ) + { + return cachedSegment = found - 1; + } + + // Keys edited out of order without being re-sorted: try every segment. + for( size_t i = 0; i + 1 < count; ++i ) + { + if( contains( i ) ) + { + return cachedSegment = i; + } + } + return cachedSegment = count - 2; +} + +#endif // Tr2CurveSegmentSearch_h diff --git a/trinity/Curves/TriCurveSampler.cpp b/trinity/Curves/TriCurveSampler.cpp index a73637e81..79aaae19d 100644 --- a/trinity/Curves/TriCurveSampler.cpp +++ b/trinity/Curves/TriCurveSampler.cpp @@ -2,17 +2,24 @@ #include "StdAfx.h" #include "TriCurveSampler.h" +#include "Tr2CurveQuaternion.h" +#include "Tr2CurveScalar.h" TriCurveSampler TriCurveSampler::Find( IRoot* source, const Be::VarEntry* valueEntry ) { - // The interface is looked up once here rather than on every sample. The raw + // The curve is looked up once here rather than on every sample. The raw // pointer stays valid for as long as source does: the smart pointers only // hold their own reference while we look. TriCurveSampler sampler; if( valueEntry->mType == Be::FLOAT ) { - if( ITriScalarFunctionPtr scalar = BlueCastPtr( source ) ) + if( Tr2CurveScalarPtr keyed = BlueCastPtr( source ) ) + { + sampler.m_kind = Kind::KeyedScalar; + sampler.m_curve.keyedScalar = keyed.p; + } + else if( ITriScalarFunctionPtr scalar = BlueCastPtr( source ) ) { sampler.m_kind = Kind::Scalar; sampler.m_curve.scalar = scalar.p; @@ -28,7 +35,12 @@ TriCurveSampler TriCurveSampler::Find( IRoot* source, const Be::VarEntry* valueE } else if( valueEntry->mType == Be::FLOATARRAY && valueEntry->GetFloatArraySize() == 4 ) { - if( ITriQuaternionFunctionPtr quaternion = BlueCastPtr( source ) ) + if( Tr2CurveQuaternionPtr keyed = BlueCastPtr( source ) ) + { + sampler.m_kind = Kind::KeyedQuaternion; + sampler.m_curve.keyedQuaternion = keyed.p; + } + else if( ITriQuaternionFunctionPtr quaternion = BlueCastPtr( source ) ) { sampler.m_kind = Kind::Quaternion; sampler.m_curve.quaternion = quaternion.p; @@ -42,3 +54,30 @@ TriCurveSampler TriCurveSampler::Find( IRoot* source, const Be::VarEntry* valueE return sampler; } + +void TriCurveSampler::Sample( double time, Vector4& out ) +{ + switch( m_kind ) + { + case Kind::KeyedScalar: + out.x = m_curve.keyedScalar->SampleValue( time, m_segment ); + break; + case Kind::KeyedQuaternion: + *reinterpret_cast( &out ) = m_curve.keyedQuaternion->SampleValue( time, m_segment ); + break; + case Kind::Scalar: + out.x = m_curve.scalar->GetValueAt( time ); + break; + case Kind::Vector: + m_curve.vector->GetValueAt( reinterpret_cast( &out ), time ); + break; + case Kind::Quaternion: + m_curve.quaternion->GetValueAt( reinterpret_cast( &out ), time ); + break; + case Kind::Color: + m_curve.color->GetValueAt( reinterpret_cast( &out ), time ); + break; + case Kind::None: + break; + } +} diff --git a/trinity/Curves/TriCurveSampler.h b/trinity/Curves/TriCurveSampler.h index 73eefbd66..4a7fade88 100644 --- a/trinity/Curves/TriCurveSampler.h +++ b/trinity/Curves/TriCurveSampler.h @@ -6,13 +6,20 @@ #include -// Samples a typed curve's value at any time through GetValueAt, without moving -// the curve's current value - so a curve shared with others, or one that fires -// events as it advances, is unaffected. (Curves may still update internal -// lookup caches, which never changes what they return.) +BLUE_DECLARE( Tr2CurveScalar ); +BLUE_DECLARE( Tr2CurveQuaternion ); + +// Samples a typed curve's value at any time without moving the curve's current +// value - so a curve shared with others, or one that fires events as it +// advances, is unaffected. +// +// Keyed curves (Tr2CurveScalar, Tr2CurveQuaternion) are sampled through a key +// segment cache of the sampler's own, so a binding sampling a shared curve at +// its offset time finds its keys as quickly as if it had a copy of the curve, +// and leaves the curve's own cache to the curve's own UpdateValue. // -// Holds a raw interface pointer found once, so it is only valid while the -// source object is, and must be found again whenever the source changes. +// Holds a raw curve pointer found once, so it is only valid while the source +// object is, and must be found again whenever the source changes. class TriCurveSampler { public: @@ -27,31 +34,14 @@ class TriCurveSampler // The curve's value at time, laid out from out.x as in its currentValue: a // scalar fills x, a vector xyz, a quaternion or color all four. - void Sample( double time, Vector4& out ) const - { - switch( m_kind ) - { - case Kind::Scalar: - out.x = m_curve.scalar->GetValueAt( time ); - break; - case Kind::Vector: - m_curve.vector->GetValueAt( reinterpret_cast( &out ), time ); - break; - case Kind::Quaternion: - m_curve.quaternion->GetValueAt( reinterpret_cast( &out ), time ); - break; - case Kind::Color: - m_curve.color->GetValueAt( reinterpret_cast( &out ), time ); - break; - case Kind::None: - break; - } - } + void Sample( double time, Vector4& out ); private: enum class Kind : uint8_t { None, + KeyedScalar, + KeyedQuaternion, Scalar, Vector, Quaternion, @@ -59,8 +49,11 @@ class TriCurveSampler }; Kind m_kind = Kind::None; + size_t m_segment = 0; union { + Tr2CurveScalar* keyedScalar; + Tr2CurveQuaternion* keyedQuaternion; ITriScalarFunction* scalar; ITriVectorFunction* vector; ITriQuaternionFunction* quaternion; diff --git a/trinity/tests/Python/tests/test_curvesetbindings.py b/trinity/tests/Python/tests/test_curvesetbindings.py index a87546cb8..b0c70f80c 100644 --- a/trinity/tests/Python/tests/test_curvesetbindings.py +++ b/trinity/tests/Python/tests/test_curvesetbindings.py @@ -1,5 +1,6 @@ # Copyright © 2026 CCP ehf. +import math import unittest import trinity @@ -111,6 +112,20 @@ def test_offset_samples_color_curve(self): for actual, expected in zip(destination.value, (0.7, 1.4, 2.1, 2.8)): self.assertAlmostEqual(actual, expected, places=4) + def test_offset_samples_quaternion_curve(self): + # 0 to 90 degrees about z over 10 seconds: halfway is 45 degrees, + # whether the curve slerps or normalises a lerp. + curve = trinity.Tr2CurveQuaternion() + curve.AddKey(0.0, (0.0, 0.0, 0.0, 1.0)) + curve.AddKey(10.0, (0.0, 0.0, math.sin(math.pi / 4.0), math.cos(math.pi / 4.0))) + self.curveSet.curves.append(curve) + destination = _Bind(self.curveSet, curve, 'currentValue', timeOffset=2.0, + destination=trinity.Tr2Vector4Parameter()) + _ApplyAt(self.curveSet, 7.0) + expected = (0.0, 0.0, math.sin(math.pi / 8.0), math.cos(math.pi / 8.0)) + for actual, wanted in zip(destination.value, expected): + self.assertAlmostEqual(actual, wanted, places=4) + def test_offset_on_long_curve_finds_the_right_key_segment(self): # Sampling one curve at two times far apart keeps missing its cached key # segment, so this goes through the curve's key search each time. From 1ea5447e92d7481fec143cf8479ab5e77ce2c846 Mon Sep 17 00:00:00 2001 From: Jonathan Dunn Date: Tue, 20 Oct 2026 17:30:27 +0000 Subject: [PATCH 08/10] Keep curve evaluation's key lookup call-free on cache hits Timing showed the shared segment lookup slowed every curve evaluation by about 25-30 ns in unoptimised builds: GetValue, SampleValue, the lookup and a lambda each cost a real call there. The curves' own UpdateValue, Update and GetValueAt now call SampleValue directly, and the cache checks in FindCurveSegment are written out. Co-Authored-By: Claude Opus 5.5 --- trinity/Curves/Tr2CurveQuaternion.cpp | 10 +++++----- trinity/Curves/Tr2CurveScalar.cpp | 10 +++++----- trinity/Curves/Tr2CurveSegmentSearch.h | 24 ++++++++++++++---------- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/trinity/Curves/Tr2CurveQuaternion.cpp b/trinity/Curves/Tr2CurveQuaternion.cpp index 1fe521be1..c5c676031 100644 --- a/trinity/Curves/Tr2CurveQuaternion.cpp +++ b/trinity/Curves/Tr2CurveQuaternion.cpp @@ -36,34 +36,34 @@ Tr2CurveQuaternion::Tr2CurveQuaternion( IRoot* lockobj ) : // -------------------------------------------------------------------------------- void Tr2CurveQuaternion::UpdateValue( double time ) { - m_currentValue = GetValue( time ); + m_currentValue = SampleValue( time, m_lastSegment ); } // -------------------------------------------------------------------------------- Quaternion* Tr2CurveQuaternion::Update( Quaternion* in, Be::Time time ) { - *in = m_currentValue = GetValue( TimeAsDouble( time ) ); + *in = m_currentValue = SampleValue( TimeAsDouble( time ), m_lastSegment ); return in; } // -------------------------------------------------------------------------------- Quaternion* Tr2CurveQuaternion::Update( Quaternion* in, double time ) { - *in = m_currentValue = GetValue( time ); + *in = m_currentValue = SampleValue( time, m_lastSegment ); return in; } // -------------------------------------------------------------------------------- Quaternion* Tr2CurveQuaternion::GetValueAt( Quaternion* in, Be::Time time ) { - *in = GetValue( TimeAsDouble( time ) ); + *in = SampleValue( TimeAsDouble( time ), m_lastSegment ); return in; } // -------------------------------------------------------------------------------- Quaternion* Tr2CurveQuaternion::GetValueAt( Quaternion* in, double time ) { - *in = GetValue( time ); + *in = SampleValue( time, m_lastSegment ); return in; } diff --git a/trinity/Curves/Tr2CurveScalar.cpp b/trinity/Curves/Tr2CurveScalar.cpp index 5b935a573..9d19f5ce9 100644 --- a/trinity/Curves/Tr2CurveScalar.cpp +++ b/trinity/Curves/Tr2CurveScalar.cpp @@ -97,25 +97,25 @@ void Tr2CurveScalar::SetName( const char* name ) // -------------------------------------------------------------------------------- void Tr2CurveScalar::UpdateValue( double time ) { - m_currentValue = GetValue( time ); + m_currentValue = SampleValue( time, m_lastSegment ); } // -------------------------------------------------------------------------------- float Tr2CurveScalar::Update( Be::Time time ) { - return m_currentValue = GetValue( TimeAsDouble( time ) ); + return m_currentValue = SampleValue( TimeAsDouble( time ), m_lastSegment ); } // -------------------------------------------------------------------------------- float Tr2CurveScalar::Update( double time ) { - return m_currentValue = GetValue( time ); + return m_currentValue = SampleValue( time, m_lastSegment ); } // -------------------------------------------------------------------------------- float Tr2CurveScalar::GetValueAt( Be::Time time ) { - return GetValue( TimeAsDouble( time ) ); + return SampleValue( TimeAsDouble( time ), m_lastSegment ); } // -------------------------------------------------------------------------------- @@ -127,7 +127,7 @@ void Tr2CurveScalar::ScaleTime( float s ) // -------------------------------------------------------------------------------- float Tr2CurveScalar::GetValueAt( double time ) { - return GetValue( time ); + return SampleValue( time, m_lastSegment ); } // -------------------------------------------------------------------------------- diff --git a/trinity/Curves/Tr2CurveSegmentSearch.h b/trinity/Curves/Tr2CurveSegmentSearch.h index 4faa6ce2d..5054b3eb3 100644 --- a/trinity/Curves/Tr2CurveSegmentSearch.h +++ b/trinity/Curves/Tr2CurveSegmentSearch.h @@ -17,29 +17,33 @@ template size_t FindCurveSegment( const Keys& keys, float t, size_t& cachedSegment ) { + // This runs for every curve evaluation, so the cache checks are written out + // rather than going through helpers that unoptimised builds would call. const size_t count = keys.size(); - const auto contains = [&]( size_t i ) { return t >= keys[i].m_time && t < keys[i + 1].m_time; }; + const size_t cached = cachedSegment; - if( cachedSegment + 1 < count ) + if( cached + 1 < count ) { - if( contains( cachedSegment ) ) + const float start = keys[cached].m_time; + const float end = keys[cached + 1].m_time; + if( t >= start && t < end ) { - return cachedSegment; + return cached; } - if( cachedSegment + 2 < count && contains( cachedSegment + 1 ) ) + if( t >= end && cached + 2 < count && t < keys[cached + 2].m_time ) { - return ++cachedSegment; + return cachedSegment = cached + 1; } - if( cachedSegment > 0 && contains( cachedSegment - 1 ) ) + if( t < start && cached > 0 && t >= keys[cached - 1].m_time ) { - return --cachedSegment; + return cachedSegment = cached - 1; } } // Keys are kept sorted by time, so binary search for the segment. const auto next = std::upper_bound( keys.begin(), keys.end(), t, []( float time, const auto& key ) { return time < key.m_time; } ); const size_t found = size_t( next - keys.begin() ); - if( found > 0 && found < count && contains( found - 1 ) ) + if( found > 0 && found < count && t >= keys[found - 1].m_time && t < keys[found].m_time ) { return cachedSegment = found - 1; } @@ -47,7 +51,7 @@ size_t FindCurveSegment( const Keys& keys, float t, size_t& cachedSegment ) // Keys edited out of order without being re-sorted: try every segment. for( size_t i = 0; i + 1 < count; ++i ) { - if( contains( i ) ) + if( t >= keys[i].m_time && t < keys[i + 1].m_time ) { return cachedSegment = i; } From 2717b08ce39cca1b3dc4c07b85d3ca5f0a189077 Mon Sep 17 00:00:00 2001 From: Jonathan Dunn Date: Tue, 20 Oct 2026 17:31:58 +0000 Subject: [PATCH 09/10] Check the cached key segment inline before searching FindCurveSegment was still one extra call on every curve evaluation, which cost about 15 ns in unoptimised builds. Curves now check their cached segment inline and only call it on a miss. Co-Authored-By: Claude Opus 5.5 --- trinity/Curves/Tr2CurveQuaternion.cpp | 7 ++++++- trinity/Curves/Tr2CurveScalar.cpp | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/trinity/Curves/Tr2CurveQuaternion.cpp b/trinity/Curves/Tr2CurveQuaternion.cpp index c5c676031..6d77fa6c8 100644 --- a/trinity/Curves/Tr2CurveQuaternion.cpp +++ b/trinity/Curves/Tr2CurveQuaternion.cpp @@ -137,7 +137,12 @@ Quaternion Tr2CurveQuaternion::SampleValue( double time, size_t& segmentCache ) } float t = GetLocalTime( time ); - size_t i = FindCurveSegment( m_keys, t, segmentCache ); + // Most frames land in the cached segment: check it here, without a call + size_t i = segmentCache; + if( i + 1 >= count || t < m_keys[i].m_time || t >= m_keys[i + 1].m_time ) + { + i = FindCurveSegment( m_keys, t, segmentCache ); + } return GetSegmentValue( t, m_keys[i], m_keys[i + 1] ); } diff --git a/trinity/Curves/Tr2CurveScalar.cpp b/trinity/Curves/Tr2CurveScalar.cpp index 9d19f5ce9..87931c20d 100644 --- a/trinity/Curves/Tr2CurveScalar.cpp +++ b/trinity/Curves/Tr2CurveScalar.cpp @@ -176,7 +176,12 @@ float Tr2CurveScalar::SampleValue( double time, size_t& segmentCache ) const } float t = GetLocalTime( time ); - size_t i = FindCurveSegment( m_keys, t, segmentCache ); + // Most frames land in the cached segment: check it here, without a call + size_t i = segmentCache; + if( i + 1 >= count || t < m_keys[i].m_time || t >= m_keys[i + 1].m_time ) + { + i = FindCurveSegment( m_keys, t, segmentCache ); + } return GetSegmentValue( t, m_keys[i], m_keys[i + 1] ); } From ab290f621bf97c42a97f894a185e3e49d53d3309 Mon Sep 17 00:00:00 2001 From: Jonathan Dunn Date: Wed, 21 Oct 2026 10:57:21 +0000 Subject: [PATCH 10/10] Give offset bindings key caches for vector, colour and Euler curves Tr2CurveVector3, Tr2CurveColor and Tr2CurveEulerRotation are built from scalar channel curves. Offset bindings on them sampled through GetValueAt, which looks keys up from the channels' own segment caches, so a binding at an offset time and the curve's own UpdateValue kept moving each other's caches - the cost already fixed for Tr2CurveScalar and Tr2CurveQuaternion. Each now has a SampleValue taking one segment cache per channel, and the binding's sampler keeps those caches. Tr2CurveColor's sRGB conversion moves into a helper shared by GetValue and SampleValue. Co-Authored-By: Claude Opus 5.5 --- trinity/Curves/Tr2CurveColor.cpp | 18 +++++++++- trinity/Curves/Tr2CurveColor.h | 7 ++++ trinity/Curves/Tr2CurveEulerRotation.cpp | 8 +++++ trinity/Curves/Tr2CurveEulerRotation.h | 4 +++ trinity/Curves/Tr2CurveSegmentSearch.h | 6 ++++ trinity/Curves/Tr2CurveVector3.cpp | 8 +++++ trinity/Curves/Tr2CurveVector3.h | 4 +++ trinity/Curves/TriCurveSampler.cpp | 33 +++++++++++++++++-- trinity/Curves/TriCurveSampler.h | 23 ++++++++++--- .../Python/tests/test_curvesetbindings.py | 32 ++++++++++++++++++ 10 files changed, 134 insertions(+), 9 deletions(-) diff --git a/trinity/Curves/Tr2CurveColor.cpp b/trinity/Curves/Tr2CurveColor.cpp index c726bbfc4..b06fb1947 100644 --- a/trinity/Curves/Tr2CurveColor.cpp +++ b/trinity/Curves/Tr2CurveColor.cpp @@ -46,7 +46,23 @@ float Tr2CurveColor::Length() Color Tr2CurveColor::GetValue( double time ) const { double t = time - m_timeOffset; - Color color( m_r.GetValue( t ), m_g.GetValue( t ), m_b.GetValue( t ), m_a.IsEmpty() ? 1.0f : m_a.GetValue( t ) ); + return ToOutput( Color( m_r.GetValue( t ), m_g.GetValue( t ), m_b.GetValue( t ), m_a.IsEmpty() ? 1.0f : m_a.GetValue( t ) ) ); +} + +// -------------------------------------------------------------------------------- +Color Tr2CurveColor::SampleValue( double time, Tr2CurveChannelSegments& segmentCaches ) const +{ + double t = time - m_timeOffset; + return ToOutput( Color( + m_r.SampleValue( t, segmentCaches[0] ), + m_g.SampleValue( t, segmentCaches[1] ), + m_b.SampleValue( t, segmentCaches[2] ), + m_a.IsEmpty() ? 1.0f : m_a.SampleValue( t, segmentCaches[3] ) ) ); +} + +// -------------------------------------------------------------------------------- +Color Tr2CurveColor::ToOutput( Color color ) const +{ if( m_srgbOutput ) { color.r = std::max( color.r, 0.f ); diff --git a/trinity/Curves/Tr2CurveColor.h b/trinity/Curves/Tr2CurveColor.h index a3aa5ce0e..47402f1a4 100644 --- a/trinity/Curves/Tr2CurveColor.h +++ b/trinity/Curves/Tr2CurveColor.h @@ -3,6 +3,7 @@ #pragma once #include "Tr2CurveScalar.h" +#include "Tr2CurveSegmentSearch.h" #include #include @@ -26,6 +27,9 @@ BLUE_CLASS( Tr2CurveColor ) : virtual float Length(); Color GetValue( double time ) const; + // The value at time, like GetValue, but looking each channel's keys up from + // the caller's segment caches (see Tr2CurveScalar::SampleValue). + Color SampleValue( double time, Tr2CurveChannelSegments& segmentCaches ) const; void AddKey( float time, @@ -43,6 +47,9 @@ BLUE_CLASS( Tr2CurveColor ) : PTr2CurveScalar m_a; private: + // Channel values sampled at one time, converted to sRGB if m_srgbOutput is set + Color ToOutput( Color color ) const; + std::string m_name; Color m_currentValue; diff --git a/trinity/Curves/Tr2CurveEulerRotation.cpp b/trinity/Curves/Tr2CurveEulerRotation.cpp index cdea6d11f..fb9eaaec5 100644 --- a/trinity/Curves/Tr2CurveEulerRotation.cpp +++ b/trinity/Curves/Tr2CurveEulerRotation.cpp @@ -34,6 +34,14 @@ Quaternion Tr2CurveEulerRotation::GetValue( double time ) const return RotationQuaternion( yaw, pitch, roll ); } +Quaternion Tr2CurveEulerRotation::SampleValue( double time, Tr2CurveChannelSegments& segmentCaches ) const +{ + float yaw = m_yaw.SampleValue( time, segmentCaches[0] ); + float pitch = m_pitch.SampleValue( time, segmentCaches[1] ); + float roll = m_roll.SampleValue( time, segmentCaches[2] ); + return RotationQuaternion( yaw, pitch, roll ); +} + // -------------------------------------------------------------------------------- Quaternion* Tr2CurveEulerRotation::Update( Quaternion* in, Be::Time time ) { diff --git a/trinity/Curves/Tr2CurveEulerRotation.h b/trinity/Curves/Tr2CurveEulerRotation.h index 6afe032f9..f49e6c3e1 100644 --- a/trinity/Curves/Tr2CurveEulerRotation.h +++ b/trinity/Curves/Tr2CurveEulerRotation.h @@ -3,6 +3,7 @@ #pragma once #include "Tr2CurveScalar.h" +#include "Tr2CurveSegmentSearch.h" #include #include @@ -31,6 +32,9 @@ BLUE_CLASS( Tr2CurveEulerRotation ) : virtual float Length(); Quaternion GetValue( double time ) const; + // The value at time, like GetValue, but looking each channel's keys up from + // the caller's segment caches (see Tr2CurveScalar::SampleValue). + Quaternion SampleValue( double time, Tr2CurveChannelSegments& segmentCaches ) const; void AddKey( float time, diff --git a/trinity/Curves/Tr2CurveSegmentSearch.h b/trinity/Curves/Tr2CurveSegmentSearch.h index 5054b3eb3..e9a89a96f 100644 --- a/trinity/Curves/Tr2CurveSegmentSearch.h +++ b/trinity/Curves/Tr2CurveSegmentSearch.h @@ -5,6 +5,12 @@ #define Tr2CurveSegmentSearch_h #include +#include + +// One key segment cache per channel, for sampling curves built from up to four +// scalar channel curves (Tr2CurveVector3, Tr2CurveColor, Tr2CurveEulerRotation) +// at times of the caller's own. Channel i of the curve uses element i. +using Tr2CurveChannelSegments = std::array; // Index i of the key segment [keys[i], keys[i + 1]) that contains t, for a curve // with at least two keys. Falls back to the last segment when none contains t. diff --git a/trinity/Curves/Tr2CurveVector3.cpp b/trinity/Curves/Tr2CurveVector3.cpp index 1777c1a71..590f0328e 100644 --- a/trinity/Curves/Tr2CurveVector3.cpp +++ b/trinity/Curves/Tr2CurveVector3.cpp @@ -30,6 +30,14 @@ Vector3 Tr2CurveVector3::GetValue( double time ) const return Vector3( m_x.GetValue( time ), m_y.GetValue( time ), m_z.GetValue( time ) ); } +Vector3 Tr2CurveVector3::SampleValue( double time, Tr2CurveChannelSegments& segmentCaches ) const +{ + return Vector3( + m_x.SampleValue( time, segmentCaches[0] ), + m_y.SampleValue( time, segmentCaches[1] ), + m_z.SampleValue( time, segmentCaches[2] ) ); +} + // -------------------------------------------------------------------------------- void Tr2CurveVector3::AddKey( float time, diff --git a/trinity/Curves/Tr2CurveVector3.h b/trinity/Curves/Tr2CurveVector3.h index 3a03f1a27..8ea9043ab 100644 --- a/trinity/Curves/Tr2CurveVector3.h +++ b/trinity/Curves/Tr2CurveVector3.h @@ -3,6 +3,7 @@ #pragma once #include "Tr2CurveScalar.h" +#include "Tr2CurveSegmentSearch.h" #include #include @@ -22,6 +23,9 @@ BLUE_CLASS( Tr2CurveVector3 ) : virtual float Length(); Vector3 GetValue( double time ) const; + // The value at time, like GetValue, but looking each channel's keys up from + // the caller's segment caches (see Tr2CurveScalar::SampleValue). + Vector3 SampleValue( double time, Tr2CurveChannelSegments& segmentCaches ) const; void AddKey( float time, diff --git a/trinity/Curves/TriCurveSampler.cpp b/trinity/Curves/TriCurveSampler.cpp index 79aaae19d..3e045b34e 100644 --- a/trinity/Curves/TriCurveSampler.cpp +++ b/trinity/Curves/TriCurveSampler.cpp @@ -2,8 +2,11 @@ #include "StdAfx.h" #include "TriCurveSampler.h" +#include "Tr2CurveColor.h" +#include "Tr2CurveEulerRotation.h" #include "Tr2CurveQuaternion.h" #include "Tr2CurveScalar.h" +#include "Tr2CurveVector3.h" TriCurveSampler TriCurveSampler::Find( IRoot* source, const Be::VarEntry* valueEntry ) { @@ -27,7 +30,12 @@ TriCurveSampler TriCurveSampler::Find( IRoot* source, const Be::VarEntry* valueE } else if( valueEntry->mType == Be::FLOATARRAY && valueEntry->GetFloatArraySize() == 3 ) { - if( ITriVectorFunctionPtr vector = BlueCastPtr( source ) ) + if( Tr2CurveVector3Ptr keyed = BlueCastPtr( source ) ) + { + sampler.m_kind = Kind::KeyedVector3; + sampler.m_curve.keyedVector3 = keyed.p; + } + else if( ITriVectorFunctionPtr vector = BlueCastPtr( source ) ) { sampler.m_kind = Kind::Vector; sampler.m_curve.vector = vector.p; @@ -40,6 +48,16 @@ TriCurveSampler TriCurveSampler::Find( IRoot* source, const Be::VarEntry* valueE sampler.m_kind = Kind::KeyedQuaternion; sampler.m_curve.keyedQuaternion = keyed.p; } + else if( Tr2CurveEulerRotationPtr euler = BlueCastPtr( source ) ) + { + sampler.m_kind = Kind::KeyedEulerRotation; + sampler.m_curve.keyedEulerRotation = euler.p; + } + else if( Tr2CurveColorPtr keyedColor = BlueCastPtr( source ) ) + { + sampler.m_kind = Kind::KeyedColor; + sampler.m_curve.keyedColor = keyedColor.p; + } else if( ITriQuaternionFunctionPtr quaternion = BlueCastPtr( source ) ) { sampler.m_kind = Kind::Quaternion; @@ -60,10 +78,19 @@ void TriCurveSampler::Sample( double time, Vector4& out ) switch( m_kind ) { case Kind::KeyedScalar: - out.x = m_curve.keyedScalar->SampleValue( time, m_segment ); + out.x = m_curve.keyedScalar->SampleValue( time, m_segments[0] ); break; case Kind::KeyedQuaternion: - *reinterpret_cast( &out ) = m_curve.keyedQuaternion->SampleValue( time, m_segment ); + *reinterpret_cast( &out ) = m_curve.keyedQuaternion->SampleValue( time, m_segments[0] ); + break; + case Kind::KeyedVector3: + *reinterpret_cast( &out ) = m_curve.keyedVector3->SampleValue( time, m_segments ); + break; + case Kind::KeyedColor: + *reinterpret_cast( &out ) = m_curve.keyedColor->SampleValue( time, m_segments ); + break; + case Kind::KeyedEulerRotation: + *reinterpret_cast( &out ) = m_curve.keyedEulerRotation->SampleValue( time, m_segments ); break; case Kind::Scalar: out.x = m_curve.scalar->GetValueAt( time ); diff --git a/trinity/Curves/TriCurveSampler.h b/trinity/Curves/TriCurveSampler.h index 4a7fade88..03cbb44d3 100644 --- a/trinity/Curves/TriCurveSampler.h +++ b/trinity/Curves/TriCurveSampler.h @@ -5,18 +5,24 @@ #define TriCurveSampler_h #include +#include "Tr2CurveSegmentSearch.h" BLUE_DECLARE( Tr2CurveScalar ); BLUE_DECLARE( Tr2CurveQuaternion ); +BLUE_DECLARE( Tr2CurveVector3 ); +BLUE_DECLARE( Tr2CurveColor ); +BLUE_DECLARE( Tr2CurveEulerRotation ); // Samples a typed curve's value at any time without moving the curve's current // value - so a curve shared with others, or one that fires events as it // advances, is unaffected. // -// Keyed curves (Tr2CurveScalar, Tr2CurveQuaternion) are sampled through a key -// segment cache of the sampler's own, so a binding sampling a shared curve at -// its offset time finds its keys as quickly as if it had a copy of the curve, -// and leaves the curve's own cache to the curve's own UpdateValue. +// Keyed curves (Tr2CurveScalar, Tr2CurveQuaternion, and the Tr2CurveVector3, +// Tr2CurveColor and Tr2CurveEulerRotation built from scalar channels) are +// sampled through key segment caches of the sampler's own, so a binding +// sampling a shared curve at its offset time finds its keys as quickly as if it +// had a copy of the curve, and leaves the curve's own caches to the curve's own +// UpdateValue. // // Holds a raw curve pointer found once, so it is only valid while the source // object is, and must be found again whenever the source changes. @@ -42,6 +48,9 @@ class TriCurveSampler None, KeyedScalar, KeyedQuaternion, + KeyedVector3, + KeyedColor, + KeyedEulerRotation, Scalar, Vector, Quaternion, @@ -49,11 +58,15 @@ class TriCurveSampler }; Kind m_kind = Kind::None; - size_t m_segment = 0; + // Single-channel keyed curves use the first element + Tr2CurveChannelSegments m_segments = {}; union { Tr2CurveScalar* keyedScalar; Tr2CurveQuaternion* keyedQuaternion; + Tr2CurveVector3* keyedVector3; + Tr2CurveColor* keyedColor; + Tr2CurveEulerRotation* keyedEulerRotation; ITriScalarFunction* scalar; ITriVectorFunction* vector; ITriQuaternionFunction* quaternion; diff --git a/trinity/tests/Python/tests/test_curvesetbindings.py b/trinity/tests/Python/tests/test_curvesetbindings.py index b0c70f80c..7d67e1dac 100644 --- a/trinity/tests/Python/tests/test_curvesetbindings.py +++ b/trinity/tests/Python/tests/test_curvesetbindings.py @@ -126,6 +126,38 @@ def test_offset_samples_quaternion_curve(self): for actual, wanted in zip(destination.value, expected): self.assertAlmostEqual(actual, wanted, places=4) + def test_offset_samples_euler_rotation_curve(self): + curve = trinity.Tr2CurveEulerRotation() + curve.AddKey(0.0, (0.0, 0.0, 0.0), trinity.Tr2CurveInterpolation.LINEAR) + curve.AddKey(10.0, (1.0, 0.5, 0.25), trinity.Tr2CurveInterpolation.LINEAR) + self.curveSet.curves.append(curve) + destination = _Bind(self.curveSet, curve, 'currentValue', timeOffset=2.0, + destination=trinity.Tr2Vector4Parameter()) + _ApplyAt(self.curveSet, 7.0) + sampled = tuple(destination.value) + # The curve's own value two seconds earlier + _ApplyAt(self.curveSet, 5.0) + for actual, expected in zip(sampled, curve.currentValue): + self.assertAlmostEqual(actual, expected, places=4) + + def test_offset_on_long_vector_curve_finds_each_channels_key_segment(self): + curve = trinity.Tr2CurveVector3() + for key in range(64): + curve.AddKey(float(key), (float(key), float(key * 2), float(key * 3)), + trinity.Tr2CurveInterpolation.LINEAR) + curveSet = trinity.TriCurveSet() + curveSet.curves.append(curve) + now = _Bind(curveSet, curve, 'currentValue', destination=trinity.Tr2Vector3Parameter()) + earlier = _Bind(curveSet, curve, 'currentValue', timeOffset=20.0, + destination=trinity.Tr2Vector3Parameter()) + for step in range(0, 400): + time = 20.0 + step * 0.1 + _ApplyAt(curveSet, time) + for scale, actual in zip((1.0, 2.0, 3.0), now.value): + self.assertAlmostEqual(actual, time * scale, places=3) + for scale, actual in zip((1.0, 2.0, 3.0), earlier.value): + self.assertAlmostEqual(actual, (time - 20.0) * scale, places=3) + def test_offset_on_long_curve_finds_the_right_key_segment(self): # Sampling one curve at two times far apart keeps missing its cached key # segment, so this goes through the curve's key search each time.