Repository navigation
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Exercises the scalar curve's key search on a cache miss. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JuhDuh26
marked this pull request as ready for review
October 21, 2026 11:27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Allows designers/developers to reuse a curve (Noise over time for example) by applying a time offset to the binding. desyncronising the peaks and troughs of the curve with other bindings using the same curve. which enables much more naturalistic "random" looking patterns, or even phasing/sequencing without needing a discreet curve for each object.
2026-10-20.12-03-17.mp4
2026-10-20.15-14-17.mp4
Linked issue (optional)
https://fenriscreations.atlassian.net/browse/EF-20375
Testing
AI assistance
Claude wrote all the code for this feature, and I iteratively used the thermo-nuclear-code-quality-review skill to highlight and resolve any implementation issues
I read all the rationale and outputted code. I have a lot of experience with C#/Java/Python, but not so much C++, so bear that in mind.
I wanted to keep the scope of this feature low, but I also didn't want to introduce any performance regressions just to support it, so an optimization pass which touched some fundementals of the code appeared necessary. I've included an artefact of the optimizations to explain the rationale behind them
A lot of tests were also added which may or may not be overkill depending on your view
Checklist
Time Offset Bindings
A curve set binding can now read its source curve a set number of seconds ahead of or behind the curve set's time. One noise curve can drive many properties out of step with each other, without a copy of the curve per property.
time_offset_bindings@891ccd9cd· 21 files, +708 −127 · 15 Python testsWhat changes
Binding API
TriValueBinding.timeOffset(seconds, persisted). Positive values lag the curve set, negative values lead it.ITr2ValueBinding::CopyValueAt(const TriCurveSetTime&)replaces a plain copy when a curve set applies its bindings. Its default just callsCopyValue(), so other bindings don't change.TriCurveSetTimecarries the time plus the looped range.Shifted(offset)wraps the offset time back into that range.Sampling
TriCurveSamplerfinds the source curve once, when the binding is set up, and samples it at any time without moving itscurrentValue.SampleValue(time, cache)that takes the caller's key segment cache:Tr2CurveScalar,Tr2CurveQuaternion,Tr2CurveVector3,Tr2CurveColorandTr2CurveEulerRotation.FindCurveSegmentis one shared key lookup: the cached segment, then its neighbours, then a binary search.Rules
currentValuesource on a scalar, vector, quaternion or colour curve is offset. Any other source logs a warning and is copied as it is.Why it needed optimising
Every keyed curve remembers which pair of keys it last sampled between, so the next frame usually skips the search. The first version sampled the shared curve through the curve's own lookup. That gave one memory to two readers: the curve's own update at time t, and the binding at t − offset. Whenever the two times fell between different keys, each reader overwrote the other's memory, so every sample started over with a linear scan through the keys. Short curves hardly noticed. With 32 keys or more, two bindings on one shared curve cost more than twice as much as main's two separate curves.
sequenceDiagram participant U as Curve's own update (t) participant C as Curve's single key cache participant B as Offset binding (t − offset) U->>C: search, cache = segment near t B->>C: miss, search, cache = segment near t − offset Note over U,B: next frame U->>C: miss, search again B->>C: miss, search againThe fixes, in the order they landed:
FindCurveSegmentchecks the neighbouring segments and then binary searches.Performance
Times are nanoseconds per curve set
Apply(), measured on the stub renderer in the TrinityDev build (unoptimised, /Od). Each figure is the best of 5 runs of 20,000 applies over 50 curve sets, sweeping time across 3–7 s so key segments change. The empty curve set cost (about 285 ns) is subtracted. Expect ±10–15 ns between runs. Optimised Internal and Release builds were not measured.Ratios (in brackets) are relative to main's way of doing it in the same build: two copies of the curve, one with its own
timeOffset.Scalar curve: main against the PR before and after optimising
single binding
2 duplicated curves
shared, offsets 0 + 2
single binding
2 duplicated curves
shared, offsets 0 + 2
shared, offsets 1 + 3
"PR before" ratios are against that build's own duplicated-curve figures (177, 173 and 185 ns). All builds in this table are on main.
Vector and colour curves: before and after the per-channel caches
Tr2CurveVector3shared, offsets 0 + 2
shared, offsets 0 + 2
shared, offsets 1 + 3
shared, offsets 1 + 3
Tr2CurveColorshared, offsets 0 + 2
shared, offsets 0 + 2
shared, offsets 1 + 3
shared, offsets 1 + 3
Both builds share the same base, one commit apart. The duplicated column comes from the "after" build. "Before" ratios use that build's own duplicated figures (427, 436, 526 for vectors; 542, 553, 683 for colours). Euler-rotation curves go through the same per-channel path and were not timed separately.
Costs that remain
timeOffseton aTr2CurveVector2source logs a warning and copies the current value.