Skip to content

Commit 33e8e33

Browse files
Vertical Hold: stop a hold change teleporting the raster
The second half of stoatworks-labs/orrery#6, which came in from an external user, applied here. Orrery fixed it in 6457f74; the same arithmetic is in this plugin and in every other one in the fleet that drives a rate off an absolute clock. The signal shader rolled the raster with `VerticalHold * Time * 0.65`, so a change to the control moves the roll by `Time * delta` -- and `Time` is however long the composition has been open, which an hour in is a jump of hundreds of fields. Because the roll is wrapped by `fract`, the picture does not slide to the new rate: it lands at an unrelated vertical offset. It reads as the raster teleporting rather than as the hold slipping, which is a worse bug than the one the control is imitating. So the walk is now accumulated on the host side and handed to the shader as `VerticalRoll`, a position rather than a product. On a change to the control the roll reached so far is carried forward and the walk continues from there at the new rate. Once per change rather than per frame, so nothing accumulates and the frame rate still cannot move the raster. The anchor starts at time zero at roll zero, which leaves the value identical to the old product until the control is first touched -- that is what keeps tools/sweep.py and every rendered-frame comparison measuring what they measured before. `VerticalHold` still goes over as well, because the rolling bar's width is an amplitude and wants the raw value; only the phase use moved. The OpenFX build mirrors the shader on the CPU, so it gains the same `verticalRoll` field -- but sets it to the plain `verticalHold * time * 0.65` product, deliberately. That host renders arbitrary times in arbitrary order and can keyframe Vertical Hold, so a running carry there would make a frame depend on which frames happened to be rendered before it. Both sides say so at the point where the difference would otherwise look like drift between the mirrors. octest grows --roll, which reads the roll either side of a change rather than comparing rendered frames: `fract` wraps it, so a jump of a whole number of fields renders identically and two frames would match for entirely the wrong reason. It runs ahead of the GL context, so a machine without a GPU can still run it. Checked against the old arithmetic first: it fails there, and by 2224 fields on the step out of zero. sweep.py still finds all 34 parameters live, Vertical Hold among them, and --presets still passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d30e56a commit 33e8e33

5 files changed

Lines changed: 177 additions & 2 deletions

File tree

source/OldCathode.cpp

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,38 @@ void OldCathode::SetClockScaleForTest( double scale )
261261
clockScale = scale;
262262
}
263263

264+
//---------------------------------------------------------------------------
265+
float OldCathode::VerticalRoll( float seconds )
266+
{
267+
const float hold = params[ PT_VERTICAL_HOLD ];
268+
269+
// First frame: leave the anchor at time zero, roll zero. That makes this
270+
// exactly the old `hold * seconds * 0.65` product for as long as nobody
271+
// touches the control, which is what keeps tools/sweep.py and every
272+
// rendered-frame comparison measuring the same thing they measured before.
273+
if( rollAnchorHold < 0.0f )
274+
{
275+
rollAnchorHold = hold;
276+
}
277+
else if( hold != rollAnchorHold )
278+
{
279+
// Once per change, not once per frame: this carries the exact roll
280+
// forward rather than integrating it, so a long session cannot
281+
// accumulate rounding into a drift, and the frame rate still cannot
282+
// affect where the raster sits.
283+
rollAnchor += ( seconds - rollAnchorTime ) * rollAnchorHold * 0.65f;
284+
rollAnchorTime = seconds;
285+
rollAnchorHold = hold;
286+
}
287+
288+
return rollAnchor + ( seconds - rollAnchorTime ) * hold * 0.65f;
289+
}
290+
291+
float OldCathode::VerticalRollForTest( float seconds )
292+
{
293+
return VerticalRoll( seconds );
294+
}
295+
264296
double OldCathode::ClockScaleForTest() const
265297
{
266298
return clockScale;
@@ -436,6 +468,10 @@ FFResult OldCathode::ProcessOpenGL( ProcessOpenGLStruct* pGL )
436468
signalShader.Set( "Hum", params[ PT_HUM ] );
437469

438470
signalShader.Set( "VerticalHold", params[ PT_VERTICAL_HOLD ] );
471+
// The anchored walk, not `VerticalHold * Time`: see OldCathode.h. The
472+
// control itself still goes over as well, because the rolling bar's
473+
// width is an amplitude and wants the raw value.
474+
signalShader.Set( "VerticalRoll", VerticalRoll( time ) );
439475
signalShader.Set( "Jitter", params[ PT_JITTER ] );
440476
signalShader.Set( "Tracking", params[ PT_TRACKING ] );
441477
signalShader.Set( "HeadSwitch", params[ PT_HEAD_SWITCH ] );

source/OldCathode.h

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,14 @@ class OldCathode : public CFFGLPlugin
179179
void SetClockScaleForTest( double scale );
180180
double ClockScaleForTest() const;
181181

182+
/// How far the raster has walked at a given moment, in fractions of a field.
183+
/// `--roll` needs it: the thing being tested is that a Vertical Hold change
184+
/// does NOT jump the picture, and reading the roll either side of one says
185+
/// so directly -- where comparing rendered frames could not, because the
186+
/// roll is wrapped into 0..1 and a jump of a whole number of fields renders
187+
/// identically.
188+
float VerticalRollForTest( float seconds );
189+
182190
private:
183191

184192

@@ -198,6 +206,31 @@ class OldCathode : public CFFGLPlugin
198206
int phosphorIndex = 0; //!< Which half of the ping-pong this frame writes to.
199207
float frameIndex = 0.0f;//!< Drives the subcarrier's frame-to-frame phase walk.
200208

209+
//---------------------------------------------------------------------
210+
// Where the raster has walked to.
211+
//
212+
// The picture stays a pure function of the roll -- what changes here is
213+
// only which roll a given clock reading maps to.
214+
//
215+
// `roll = verticalHold * time * 0.65` means a Vertical Hold change moves the
216+
// roll by `time * delta`, and `time` is however long the composition has
217+
// been open. Nudging the control an hour in is a jump of hundreds of fields,
218+
// and because the roll wraps the picture lands at an unrelated offset: it
219+
// reads as the raster teleporting rather than as the hold slipping. That is
220+
// the same defect orrery issue #6 reported for its Speed control. So
221+
// remember the roll reached so far and carry on from there at the new rate.
222+
//
223+
// Not in the OpenFX build, which renders arbitrary times in arbitrary order
224+
// and can keyframe Vertical Hold: a running carry there would make a frame
225+
// depend on which frames were rendered before it. That build keeps the pure
226+
// product, and says so.
227+
//---------------------------------------------------------------------
228+
float VerticalRoll( float seconds );
229+
230+
float rollAnchor = 0.0f;///< roll already reached at `rollAnchorTime`
231+
float rollAnchorTime = 0.0f;///< the clock reading that roll belongs to
232+
float rollAnchorHold = -1.0f;///< hold in force since then; < 0 until the first frame
233+
201234
double clockScale = 0.0;///< 0 until decided; then 1.0 or 0.001
202235
double lastRawTime = -1.0;
203236
double lastWallTime = -1.0;

source/ofx/OldCathodeOFX.cpp

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,15 @@ struct SignalSettings
193193

194194
float noise = 0, dropouts = 0, ghostAmount = 0, ghostDelay = 0, interference = 0, hum = 0;
195195
float verticalHold = 0, jitter = 0, tracking = 0, headSwitch = 0;
196+
197+
/// How far the raster has walked, in fractions of a field: the shader's
198+
/// `VerticalRoll` uniform. The FFGL build anchors this so that nudging
199+
/// Vertical Hold live does not teleport the picture; here it stays the plain
200+
/// `verticalHold * time * 0.65` product, because this host renders arbitrary
201+
/// times in arbitrary order and can keyframe the control -- a running carry
202+
/// would make a frame depend on which frames happened to be rendered before
203+
/// it. See OldCathode.h.
204+
float verticalRoll = 0;
196205
bool interlace = false;
197206

198207
float time = 0.0f;
@@ -318,7 +327,7 @@ class SignalStage
318327
{
319328
const float texelY = 1.0f / st.signalH;
320329

321-
const float srcY = fractf( v + st.verticalHold * st.time * 0.65f );
330+
const float srcY = fractf( v + st.verticalRoll );
322331
const float lineIdx = std::floor( srcY * st.signalH );
323332
const float lineRnd = rnd( lineIdx, st.frameIndex, 5.0f ) - 0.5f;
324333

@@ -1100,6 +1109,7 @@ class OldCathodePlugin : public OFX::ImageEffect
11001109
sig.hum = float( hum->getValueAtTime( t ) );
11011110

11021111
sig.verticalHold = float( verticalHold->getValueAtTime( t ) );
1112+
sig.verticalRoll = 0.0f;//per-frame, below: it depends on the frame's own time
11031113
sig.jitter = float( jitter->getValueAtTime( t ) );
11041114
sig.tracking = float( tracking->getValueAtTime( t ) );
11051115
sig.headSwitch = float( headSwitch->getValueAtTime( t ) );
@@ -1112,6 +1122,7 @@ class OldCathodePlugin : public OFX::ImageEffect
11121122

11131123
SignalSettings frameSig = sig;
11141124
frameSig.time = float( frameTime / fps );
1125+
frameSig.verticalRoll = frameSig.verticalHold * frameSig.time * 0.65f;
11151126
frameSig.frameIndex = float( std::fmod( frameTime, 100000.0 ) );
11161127

11171128
SignalStage stage( sd, frameSig );

source/shaders/Signal.cpp

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ uniform float Hum;
6161
6262
//Timebase
6363
uniform float VerticalHold;
64+
65+
//How far the raster has walked, in fractions of a field. NOT `VerticalHold *
66+
//Time`: that is an absolute product, so moving the control an hour into a
67+
//composition jumps the picture by hundreds of fields at once. The host side
68+
//anchors it -- see OldCathode.h -- and hands over the position it has reached.
69+
uniform float VerticalRoll;
6470
uniform float Jitter;
6571
uniform float Tracking;
6672
uniform float HeadSwitch;
@@ -256,7 +262,7 @@ void main()
256262
257263
//Vertical hold: the field no longer starts where the flyback expects it to,
258264
//so the whole raster walks and takes the blanking interval with it.
259-
float srcY = fract( uv.y + VerticalHold * Time * 0.65 );
265+
float srcY = fract( uv.y + VerticalRoll );
260266
261267
float lineIdx = floor( srcY * SignalSize.y );
262268
float lineRnd = rnd( lineIdx, FrameIndex, 5.0 ) - 0.5;

tools/octest/main.cpp

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,86 @@ bool readExactly( void* into, size_t bytes )
335335
return true;
336336
}
337337

338+
//---------------------------------------------------------------------------
339+
/// Prove a Vertical Hold change does not jump the picture.
340+
///
341+
/// The roll either side of the change is read directly rather than comparing
342+
/// rendered frames: the roll is wrapped into 0..1 by `fract`, so a jump of a
343+
/// whole number of fields renders identically and two frames would match for
344+
/// entirely the wrong reason. The number says it outright.
345+
///
346+
/// Needs no GL, so it runs ahead of the context.
347+
//---------------------------------------------------------------------------
348+
int runRollTest()
349+
{
350+
int failures = 0;
351+
352+
auto check = [ &failures ]( const char* what, double got, double want, double tol ) {
353+
const bool ok = std::fabs( got - want ) <= tol;
354+
std::printf( "roll %-40s got=%-14.6f want=%-14.6f %s\n", what, got, want, ok ? "ok" : "FAILED" );
355+
if( !ok )
356+
++failures;
357+
};
358+
359+
const float holds[] = { 0.10f, 0.95f, 0.00f, 0.40f };
360+
361+
OldCathode plugin;
362+
plugin.SetClockScaleForTest( 1.0 );//seconds, said out loud rather than inferred
363+
364+
// By display name, as everything else in this harness does: the PT_ enum is
365+
// private to the plugin and the name is the host's own handle on the control.
366+
unsigned int holdIndex = 0;
367+
bool found = false;
368+
for( unsigned int i = 0; i < plugin.GetNumParams(); ++i )
369+
{
370+
const char* name = plugin.GetParamName( i );
371+
if( name != nullptr && std::string( name ) == "Vertical Hold" )
372+
{
373+
holdIndex = i;
374+
found = true;
375+
break;
376+
}
377+
}
378+
if( !found )
379+
{
380+
std::fprintf( stderr, "roll: no parameter named 'Vertical Hold'\n" );
381+
return 1;
382+
}
383+
384+
// An hour in, which is where the old arithmetic hurt most and where a live
385+
// operator actually is when they reach for the control. The clock only ever
386+
// goes forwards from here.
387+
float seconds = 3600.0f;
388+
389+
// Untouched, the anchor must leave the old expression exactly as it was --
390+
// this is what keeps tools/sweep.py and every rendered-frame comparison
391+
// measuring the same thing they measured before. Vertical Hold defaults to
392+
// zero, so this is zero, and the step below out of zero is the one that
393+
// used to hurt most.
394+
check( "untouched == hold * seconds * 0.65", plugin.VerticalRollForTest( seconds ),
395+
plugin.GetFloatParameter( holdIndex ) * seconds * 0.65, 1e-3 );
396+
397+
for( const float hold : holds )
398+
{
399+
const float before = plugin.VerticalRollForTest( seconds );
400+
401+
// The same instant, a new hold: nothing about the clock has moved, so
402+
// the raster may not move either.
403+
plugin.SetFloatParameter( holdIndex, hold );
404+
check( "a Vertical Hold change does not jump the raster",
405+
plugin.VerticalRollForTest( seconds ), before, 1e-3 );
406+
407+
// And then it must actually walk at the new rate.
408+
const float resumed = plugin.VerticalRollForTest( seconds );
409+
seconds += 1.0f;
410+
check( " and walks at the new rate afterwards",
411+
plugin.VerticalRollForTest( seconds ) - resumed, hold * 0.65, 1e-3 );
412+
}
413+
414+
std::printf( "%s\n", failures == 0 ? "roll: all ok" : "roll: FAILURES" );
415+
return failures == 0 ? 0 : 1;
416+
}
417+
338418
void usage()
339419
{
340420
std::printf(
@@ -350,6 +430,7 @@ void usage()
350430
" --measure print the mean RGB of the middle of the picture\n"
351431
" --list print every parameter and its default, then exit\n"
352432
" --presets every factory preset survives every host behaviour\n"
433+
" --roll a Vertical Hold change does not jump the picture\n"
353434
"\n"
354435
" --pipe read raw RGBA frames from stdin, write them to stdout,\n"
355436
" so real footage can be put through the chain:\n"
@@ -509,6 +590,7 @@ int main( int argc, char** argv )
509590
bool keepAlpha = false;
510591
bool listOnly = false;
511592
bool measure = false;
593+
bool rollOnly = false;
512594
bool pipeMode = false;
513595
float flatLevel = -1.0f;
514596
std::string scriptPath;
@@ -522,6 +604,8 @@ int main( int argc, char** argv )
522604

523605
if( arg == "--out" )
524606
outputPath = next();
607+
else if( arg == "--roll" )
608+
rollOnly = true;
525609
else if( arg == "--width" )
526610
width = std::atoi( next().c_str() );
527611
else if( arg == "--height" )
@@ -590,6 +674,11 @@ int main( int argc, char** argv )
590674
return -1;
591675
};
592676

677+
// Ahead of the GL context on purpose: this one needs no GPU, so it still
678+
// runs on a machine that cannot make a context at all.
679+
if( rollOnly )
680+
return runRollTest();
681+
593682
if( listOnly )
594683
{
595684
for( unsigned int i = 0; i < plugin.GetNumParams(); ++i )

0 commit comments

Comments
 (0)