diff --git a/trinity/CMakeLists.txt b/trinity/CMakeLists.txt index ba40ebe53..4581c57b2 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 @@ -236,8 +237,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 diff --git a/trinity/Curves/Tr2CurveQuaternion.cpp b/trinity/Curves/Tr2CurveQuaternion.cpp index 6c9e7e238..6d77fa6c8 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[]; @@ -35,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; } @@ -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,13 @@ 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 ) + // 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 ) { - 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 ); - } + i = FindCurveSegment( m_keys, t, segmentCache ); } - m_lastSegment = count - 2; - return GetSegmentValue( t, m_keys[count - 2], m_keys[count - 1] ); + 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 62a323c30..87931c20d 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[]; @@ -96,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 ); } // -------------------------------------------------------------------------------- @@ -126,11 +127,17 @@ void Tr2CurveScalar::ScaleTime( float s ) // -------------------------------------------------------------------------------- float Tr2CurveScalar::GetValueAt( double time ) { - return GetValue( time ); + return SampleValue( time, m_lastSegment ); } // -------------------------------------------------------------------------------- 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,59 +176,13 @@ float Tr2CurveScalar::GetValue( double time ) const } float t = GetLocalTime( time ); - - if( m_lastSegment + 1 < count ) + // 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 ) { - // 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 ); - } + i = FindCurveSegment( m_keys, t, segmentCache ); } - m_lastSegment = count - 2; - return GetSegmentValue( t, m_keys[count - 2], m_keys[count - 1] ); + 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..5054b3eb3 --- /dev/null +++ b/trinity/Curves/Tr2CurveSegmentSearch.h @@ -0,0 +1,62 @@ +// 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 ) +{ + // 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 size_t cached = cachedSegment; + + if( cached + 1 < count ) + { + const float start = keys[cached].m_time; + const float end = keys[cached + 1].m_time; + if( t >= start && t < end ) + { + return cached; + } + if( t >= end && cached + 2 < count && t < keys[cached + 2].m_time ) + { + return cachedSegment = cached + 1; + } + if( t < start && cached > 0 && t >= keys[cached - 1].m_time ) + { + 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 && t >= keys[found - 1].m_time && t < keys[found].m_time ) + { + 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( t >= keys[i].m_time && t < keys[i + 1].m_time ) + { + return cachedSegment = i; + } + } + return cachedSegment = count - 2; +} + +#endif // Tr2CurveSegmentSearch_h diff --git a/trinity/Curves/TriCurveSampler.cpp b/trinity/Curves/TriCurveSampler.cpp new file mode 100644 index 000000000..79aaae19d --- /dev/null +++ b/trinity/Curves/TriCurveSampler.cpp @@ -0,0 +1,83 @@ +// Copyright © 2026 CCP ehf. + +#include "StdAfx.h" +#include "TriCurveSampler.h" +#include "Tr2CurveQuaternion.h" +#include "Tr2CurveScalar.h" + +TriCurveSampler TriCurveSampler::Find( IRoot* source, const Be::VarEntry* valueEntry ) +{ + // 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( 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; + } + } + else if( valueEntry->mType == Be::FLOATARRAY && valueEntry->GetFloatArraySize() == 3 ) + { + if( ITriVectorFunctionPtr vector = BlueCastPtr( source ) ) + { + sampler.m_kind = Kind::Vector; + sampler.m_curve.vector = vector.p; + } + } + else if( valueEntry->mType == Be::FLOATARRAY && valueEntry->GetFloatArraySize() == 4 ) + { + 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; + } + else if( ITriColorFunctionPtr color = BlueCastPtr( source ) ) + { + sampler.m_kind = Kind::Color; + sampler.m_curve.color = color.p; + } + } + + 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 new file mode 100644 index 000000000..4a7fade88 --- /dev/null +++ b/trinity/Curves/TriCurveSampler.h @@ -0,0 +1,64 @@ +// Copyright © 2026 CCP ehf. + +#pragma once +#ifndef TriCurveSampler_h +#define TriCurveSampler_h + +#include + +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 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: + // 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 ); + +private: + enum class Kind : uint8_t + { + None, + KeyedScalar, + KeyedQuaternion, + Scalar, + Vector, + Quaternion, + Color + }; + + Kind m_kind = Kind::None; + size_t m_segment = 0; + union + { + Tr2CurveScalar* keyedScalar; + Tr2CurveQuaternion* keyedQuaternion; + ITriScalarFunction* scalar; + ITriVectorFunction* vector; + ITriQuaternionFunction* quaternion; + ITriColorFunction* color; + } m_curve = { nullptr }; +}; + +#endif // TriCurveSampler_h diff --git a/trinity/Curves/TriCurveSet.cpp b/trinity/Curves/TriCurveSet.cpp index 2bf87b35f..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,9 +159,10 @@ 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 ) { - ( *it )->CopyValue(); + ( *it )->CopyValueAt( time ); } } 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 90f329ffe..ca5ba773e 100644 --- a/trinity/Include/ITr2ValueBinding.h +++ b/trinity/Include/ITr2ValueBinding.h @@ -4,10 +4,19 @@ #ifndef ITr2ValueBinding_H #define ITr2ValueBinding_H +struct TriCurveSetTime; + BLUE_INTERFACE( ITr2ValueBinding ) : public IRoot { virtual void CopyValue() = 0; + + // 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(); + } }; #endif \ No newline at end of file diff --git a/trinity/TriValueBinding.cpp b/trinity/TriValueBinding.cpp index 923868423..8fdbbcb83 100644 --- a/trinity/TriValueBinding.cpp +++ b/trinity/TriValueBinding.cpp @@ -7,6 +7,8 @@ #include "include/ITriColor.h" #include "include/ITriQuaternion.h" #include "include/ITriMatrix.h" +#include "Curves/TriCurveSampler.h" +#include "Curves/TriCurveSetTime.h" TriValueBinding::TriValueBinding( IRoot* lockobj ) : m_isWeak( false ), @@ -17,6 +19,8 @@ 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_sampleOffset( 0 ), m_sourceItemOffset( 0 ), m_destItemOffset( 0 ) { @@ -31,24 +35,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 ) { @@ -62,6 +62,35 @@ void TriValueBinding::CopyValue() } } +void TriValueBinding::CopyValue() +{ + if( CanCopy() ) + { + CopyFrom( m_source ); + } +} + +void TriValueBinding::CopyValueAt( const TriCurveSetTime& time ) +{ + if( !CanCopy() ) + { + return; + } + + if( m_timeOffset == 0.0f || !m_sampler ) + { + CopyFrom( m_source ); + return; + } + + // 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_sampler.Sample( time.Shifted( m_timeOffset ), sampled ); + CopyFrom( reinterpret_cast( &sampled ) + m_sampleOffset ); +} + bool TriValueBinding::OnModified( Be::Var* val ) { ITriReroutablePtr rp( BlueCastPtr( GetCurrentDestinationObject() ) ); @@ -325,6 +354,8 @@ void TriValueBinding::Initialize() m_destination = NULL; m_copyFunc = NULL; m_notifyPtr = nullptr; + m_sampler = TriCurveSampler(); + m_sampleOffset = 0; IRoot* sourceObject = GetCurrentSourceObject(); IRoot* destinationObject = GetCurrentDestinationObject(); @@ -387,9 +418,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_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 ); + } } else { @@ -428,6 +470,11 @@ void TriValueBinding::Initialize() } } } + + 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() ); + } } void TriValueBinding::RerouteDestination( void* dest ) diff --git a/trinity/TriValueBinding.h b/trinity/TriValueBinding.h index 8217b964a..ea4f5b066 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,6 +32,7 @@ BLUE_CLASS( TriValueBinding ) : ////////////////////////////////////////////////////////////////////////// // ITr2ValueBinding virtual void CopyValue(); + virtual void CopyValueAt( const TriCurveSetTime& time ); static const Be::VarEntry* FindEntry( const char* name, const Be::ClassInfo* type, ssize_t& offs ); @@ -48,6 +50,9 @@ BLUE_CLASS( TriValueBinding ) : bool IsValid() const; protected: + bool CanCopy() const; + void CopyFrom( void* source ); + IRoot* GetCurrentSourceObject() const; IRoot* GetCurrentDestinationObject() const; @@ -80,6 +85,18 @@ 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; + + // 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. + TriCurveSampler m_sampler; + 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 8adc68df8..c93b20b84 100644 --- a/trinity/TriValueBinding_Blue.cpp +++ b/trinity/TriValueBinding_Blue.cpp @@ -79,6 +79,23 @@ 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. 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" + "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( "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..b0c70f80c --- /dev/null +++ b/trinity/tests/Python/tests/test_curvesetbindings.py @@ -0,0 +1,172 @@ +# Copyright © 2026 CCP ehf. + +import math +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 _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 + 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_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_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_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. + 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) + _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()