Skip to content

Time offset bindings - #129

Open
JuhDuh26 wants to merge 20 commits into
carbonengine:mainfrom
JuhDuh26:time_offset_bindings
Open

JuhDuh26 wants to merge 20 commits into
carbonengine:mainfrom
JuhDuh26:time_offset_bindings

Conversation

@JuhDuh26

@JuhDuh26 JuhDuh26 commented Oct 20, 2026 •

Copy link
Copy Markdown

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

  • Create a curve
  • Bind two different properties to the curve
  • On one of the bindings change the "Time Offset" to see the binding values jump relatively ahead or behind in the curve's timeline

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

  • Read CONTRIBUTING.md.
    • Commits follow the commit-message style described there.
    • Added or updated tests where it made sense.
    • Updated docs / inline API comments for any behaviour change.

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 tests

1 curve shared by offset bindings, where main needed one copy per offset
0.93–1.01× cost of two bindings on a shared curve, relative to main's two duplicated curves
up to 2.3× slower before the optimisations, on curves with 32 or more keys

What 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 calls CopyValue(), so other bindings don't change.
  • TriCurveSetTime carries the time plus the looped range. Shifted(offset) wraps the offset time back into that range.

Sampling

  • TriCurveSampler finds the source curve once, when the binding is set up, and samples it at any time without moving its currentValue.
  • Keyed curves get a SampleValue(time, cache) that takes the caller's key segment cache: Tr2CurveScalar, Tr2CurveQuaternion, Tr2CurveVector3, Tr2CurveColor and Tr2CurveEulerRotation.
  • FindCurveSegment is one shared key lookup: the cached segment, then its neighbours, then a binary search.

Rules

  • Only a currentValue source on a scalar, vector, quaternion or colour curve is offset. Any other source logs a warning and is copied as it is.
  • The shared curve, and any other curve set using it, sees no change.
  • Bindings applied outside a curve set (lens flares, child links, socket parameters) ignore the offset.

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 again
Loading

The fixes, in the order they landed:

  1. Find the curve once. The binding used to cast its source to a curve type on every frame. The sampler now finds the curve when the binding is set up and keeps the pointer.
  2. Binary search on a miss. A cache miss used to scan the keys from the start. FindCurveSegment checks the neighbouring segments and then binary searches.
  3. A key cache per binding. Each offset binding keeps its own segment cache, so it never disturbs the curve's cache. This fix closed the gap with duplicated curves.
  4. Keep normal evaluation call-free. Moving the lookup into a shared helper slowed every curve evaluation in the unoptimised build. The cached-segment check is now inline again, so curves without offset bindings run as they did before.
  5. Caches per channel. Vector, colour and Euler-rotation curves are made of 3 or 4 scalar channel curves. Offset bindings on them still shared the channels' caches, until the sampler held one cache per channel.

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

Keys main
single binding
main
2 duplicated curves
PR before
shared, offsets 0 + 2
PR final
single binding
PR final
2 duplicated curves
PR final
shared, offsets 0 + 2
PR final
shared, offsets 1 + 3
4 84 161 218 (1.23×) 93 182 169 (0.93×) 239 (1.31×)
32 83 169 396 (2.29×) 99 186 188 (1.01×) 241 (1.30×)
512 91 180 430 (2.32×) 102 202 196 (0.97×) 253 (1.25×)

"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

Tr2CurveVector3

Keys 2 duplicated curves Before
shared, offsets 0 + 2
After
shared, offsets 0 + 2
Before
shared, offsets 1 + 3
After
shared, offsets 1 + 3
4 446 456 (1.07×) 427 (0.96×) 634 592
32 449 951 (2.18×) 430 (0.96×) 1407 598
512 544 1183 (2.25×) 521 (0.96×) 1747 739

Tr2CurveColor

Keys 2 duplicated curves Before
shared, offsets 0 + 2
After
shared, offsets 0 + 2
Before
shared, offsets 1 + 3
After
shared, offsets 1 + 3
4 551 576 (1.06×) 554 (1.01×) 812 784
32 562 1251 (2.26×) 552 (0.98×) 1845 781
512 682 1558 (2.28×) 680 (1.00×) 2570 955

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

  • One binding at offset 0 is free. If every binding on a curve has an offset, the curve set still updates the curve's own value, which nobody reads. Offsets 1 + 3 cost about 55–70 ns more than 0 + 2 for scalars. Leave one binding at 0 where you can.
  • Plain curves cost slightly more. A single binding with no offset costs 9–16 ns more than on main in the unoptimised build. Inlining in optimised builds should shrink this, but that has not been measured.
  • Vector2 curves are not offset. There is no two-float sampler. A timeOffset on a Tr2CurveVector2 source logs a warning and copies the current value.

JuhDuh26 and others added 20 commits October 16, 2026 17:53
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
JuhDuh26 marked this pull request as ready for review October 21, 2026 11:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant