Skip to content

design: how parameters change over time — generic property animations, variables, or scripts #119

Description

@joschaschmiedt

The concrete trigger

There is no way to animate the outline width of a shape. Nor its fill colour, nor a
circle's radius, nor a grating's contrast, nor — now that it is a shared property —
its opacity.

The reason is structural. Every animation kind hardcodes the property it writes:

kind writes
FlashForNFrames, FlickerForNFrames, CoupleVisibilityToTriggerLine, EnableOnTriggerEdge flags.enabled
MoveAlongPath2D, MoveAlongSegments2D, ExternalPosition2D transform.pos

Seven kinds, two properties. Adding "ramp the outline width" means a proto message, a
Rust variant, a serde tag, a Python method, a TypeScript method and tests — and then
the same again for the opacity fade, the contrast sweep, the looming radius, the
colour ramp. That is a million animations, and most of them differ only in which
field they assign.

The root cause

Which property and what behaviour over time are conflated into one enum. Separate
them and the count collapses: a handful of behaviours × any property.

behaviour  ×  property        →  today: one kind per pair
ramp          outline_width
waveform      opacity
table         contrast
external      radius
script        pos

So the design question is really two questions, and the first one is unavoidable
whichever way the second is answered.

Layer 1 — how is a property named?

Every option below needs a way to address "the outline width of stimulus 7". The
choices:

a. A schema enum. ScalarProperty { OPACITY, OUTLINE_WIDTH, CIRCLE_RADIUS, GRATING_CONTRAST, … }. Typed, discoverable, completable in clients, and per-type
validity is checkable at create time with the mechanism that already exists —
err_wrong_type / ERROR_CODE_WRONG_STIMULUS_TYPE, which is how SetGratingSf
rejects a rect. Costs: a closed list, extended by hand, and it cannot address a
custom shader uniform.

b. A path string. "appearance.outline_width". Open-ended, reaches shader
uniforms, no schema churn. Costs: typos become runtime errors, clients lose
completion, and the config format gains stringly-typed keys.

c. A named variable with a binding (the MWorks model, below). Properties are not
addressed by the animation at all; they are bound to a variable, and the animation
drives the variable.

A mix is plausible: an enum arm for built-ins and a string arm for shader uniforms,
in one oneof — the string path being genuinely unavoidable there, since only the
shader itself knows its uniform names.

Layer 2 — how does a value change over time?

Four mechanisms cover everything currently on file, if the property is a parameter
rather than part of the kind:

  1. Ramp / waveform — from, to, duration, easing; or sine/square at a frequency.
  2. Table playback — one value per frame, uploaded ahead of time. This already
    exists:
    MoveAlongPath2D { coords } is a precomputed table. The client can
    evaluate arbitrary Python and upload the result.
  3. External — read the value each frame from shared memory or a device (feat(server/scene): device-driven animations — implement ExternalPosition2D, add DeviceDrivenTransform #79,
    feat(proto,client): input-device animations over the wire + Python shm writer #80; ExternalPosition2D is the unimplemented instance, ExternalPosition2D is a no-op stub: the server accepts it, never reads shm, and the stimulus never moves #84).
  4. Script — evaluate user code per frame.

Precomputable vs closed-loop — the line that matters

Mechanisms 1 and 2 are precomputable: the value at frame N does not depend on
anything the server learns at run time. They can be evaluated off-device, uploaded,
and played back with no logic on the render thread at all.

Mechanisms 3 and 4 are closed-loop: the value depends on live state — a VTL line, a
treadmill, the previous frame's value. Only these need on-device evaluation, and only
these justify paying the price of running code next to the render thread.

Worth stating plainly because it bounds the scripting problem: most "I need a custom
animation" requests are precomputable
, and for those, an arbitrary Python function
in the client plus a table upload is already the most flexible and the safest answer
available. The stimulus server should make that path good — arbitrary property, not
just position — before it makes the risky path good.

Could an animation simply be a script?

Yes, and it is attractive: Animation::Script { … } keeps everything the animation
system already provides — arming, waiting on a trigger edge, cancelling, REARM /
RESTART, pulsing a line on completion, DONE_LEVEL — and replaces only the fixed
per-frame behaviour with user code. One kind instead of a million.

The constraints it has to satisfy are the hard part, and they are the same ones
#109 (scriptable movie stimulus) is already weighing — language choice, sandboxing,
keeping a GC off the critical path. What this issue adds is where the script runs:

  • advance_one executes on the render thread, once per frame, under the write
    lock. The rule for that thread is: never block, never heap-allocate. A tick that
    allocates, GCs, or runs unbounded turns a dropped frame into the failure mode this
    whole server exists to prevent.
  • So a script animation needs: compilation to bytecode at create time (not per
    frame), a per-tick fuel/instruction budget, and a defined behaviour on budget
    exhaustion — kill the animation and log it, never stall the frame.
  • Determinism matters for replay (dev/EVENT_LOGGING.md §11): a script that reads
    the clock or an unseeded RNG is not replayable. Either forbid both, or log the
    values it read.
  • A script that only computes a curve is mechanism 2 with extra steps and extra
    risk. The case for evaluating on-device is closed loop.

The MWorks precedent — variables

Worth studying before committing, because it answers this question a different way
and it is the system our users are most likely to have come from.

In MWorks an experiment declares named variables, and stimulus parameters are
expressions over them rather than fixed values — a stimulus is declared with
x_size = stim_size, and when stim_size changes the stimulus follows. Variables are
assigned by the protocol's state system, by I/O devices, and from Python. Every
variable change is timestamped into the event file, so the same mechanism that drives
the display is the experimental record.

What that model buys, mapped onto vstimd:

What it costs here:

  • A second, dynamically-typed namespace beside a protobuf API that is currently
    statically typed end to end — the trade discussed at length on API consistency pass before 0.1: full sizes, clear commands, shared opacity, honest queries, reproducible timing #118.
  • An expression evaluator, if bindings are expressions rather than plain references,
    and expression evaluation on the render thread has the same real-time constraints
    as scripting.
  • Config format: variables and bindings become part of the saved scene, and part of
    what config load has to restore consistently.
  • A resolution order when both a variable binding and a direct SetOutlineWidth
    command write the same property. MWorks does not have this problem because there is
    no direct-write path; vstimd does, and it is the interesting failure mode.

Constraints any answer must satisfy

  • Writes go through the existing setters, never straight to fields. cmd_set_outline_width
    routes to the deferred copy or live slot, and marks the stimulus dirty because
    stroke geometry depends on the width. The dirty rule is not uniform: shapes bake
    opacity into vertex colours and must re-tessellate; grating and text read it into
    push constants and must not (see the fix in API consistency pass before 0.1: full sizes, clear commands, shared opacity, honest queries, reproducible timing #118). A generic writer that assigns
    fields directly gets one of those two cases wrong.
  • Deferred mode must keep working: a batch of property changes still has to flip
    on one frame.
  • Clamping and validation live in the setters; a second write path must not skip
    them.
  • Config round-trip: whatever is added is saved, loaded and re-serialized
    unchanged (demo_files_survive_a_reserialize_unchanged).
  • Per-type validity is a real check, not a formality: contrast on a rect,
    radius on a text stimulus, a uniform name on a stimulus with no shader.

Relationship to existing issues

Open questions

  1. Do we settle Layer 1 (property addressing) on its own, before choosing between
    ramps, scripts and variables? It is required by all three, and it is the piece
    that ends up in the config format and therefore the hardest to change later.
  2. Enum, path string, or variable name — or an enum with a string escape hatch for
    shader uniforms?
  3. Is a variable/binding layer the right destination, or is it a bigger conceptual
    surface than a stimulus server needs, given the client is already a real
    programming environment and can precompute?
  4. If both variables and direct commands can write a property, what wins?
  5. Does a script animation share a runtime with feat: scriptable movie stimulus — an on-device script (DSL/Lua) driving image presentation and triggers #109, and does that runtime run on
    the render thread with a fuel budget, or one frame ahead on another thread?
  6. What is the smallest useful step? A plausible answer: a generic
    RampScalar { property, from, to, duration_frames, easing } plus generalising
    table playback to any scalar property — both precomputable, both usable
    immediately, and neither foreclosing variables or scripts later.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions