ResumeWritingProjects
Writing

Exponential Smoothing in Foundry VTT

How EZGlide went from a one-off smooth-zoom macro to a published Foundry VTT module: the exponential-smoothing formula, a competitor found while hunting for a name, the pan/zoom cancellation problem, and a v2 refactor that made module and macro share one camera runtime.

View on GitHub

EZGlide did not start with a problem. It started with a YouTube recommendation.

YouTube had decided I should be watching Theo Browne lately — he posts fast, opinionated web-dev videos — so when one of them turned out to be about exponential smoothing, I watched it. It is mostly a read-through of Lisyarus’s “My favourite animation trick: exponential smoothing”, which I went and read on its own afterward. What stuck was how little there is to the trick: keep a current value, keep a target value, measure the time between frames, and let the current value chase the target.

Theo Browne's video on exponential smoothing
Why Doesn’t Everyone Use This Animation??? · Theo - t3.gg

That left me with a technique and nothing to use it on, so I went looking, and Foundry’s mouse-wheel zoom came to mind. It works, but it arrives all at once — the view jumps, the camera is already wherever it was going, and nothing on screen connects the before to the after. It was not bothering me at the time; I just wanted to see whether the trick would do anything for it, and a macro was the fastest way to find out, so EZGlide was a macro before it was anything else. (It does bother me now, which is presumably the tax on fixing it. I have not turned the thing off since.)

The small formula

Lisyarus derives the useful form from one premise: movement speed should be proportional to distance from the target. If x is the current position, a is the target, and c is the speed, the continuous form is:

ddtx=(ax)c\frac{d}{dt}x = (a - x)c

Solving that gives the update shape I actually needed for animation:

x=x0+(ax0)(1ect)x = x_0 + (a - x_0)\left(1 - e^{-ct}\right)

The useful part, in code, is the interpolation factorThe value passed into lerp. Here it is computed from elapsed time, so each frame moves a stable fraction toward the target.:

1 - Math.exp(-speed * dt);

The factor is the whole trick. The obvious factor would be speed * dt, and it behaves until dt gets large — a dropped frame, a busy tab, anything that makes one frame take three frames’ worth of time — because nothing bounds it, and once it crosses one the value goes through the target instead of toward it. The exponential form stays below one for any dt, so a slow frame still moves the camera a large, correct fraction of the remaining distance and never more than all of it.

The demo has three switches: the linear one moves at constant speed and stops dead, which is Lisyarus’s baseline, and the other two are nearly indistinguishable at a smooth framerate, which is the point — the naive lerp is not wrong on a good day. Turn on the slow frames and flip them again, and the naive knob starts shooting past the end of its track and bouncing back while the exponential one just takes coarser steps toward the same place.

linear
naive lerp
exponential

EZGlide ships the trick as two small helpers in src/runtime/shared.mjs:

export function expDecay(speed, t) {
  return 1 - Math.exp(-speed * t);
}

export function lerpSnap(current, target, factor) {
  const remaining = target - current;
  if (Math.abs(remaining) <= STOP_EPS) {
    return { value: target, delta: 0 };
  }

  const value = current + remaining * factor;
  return { value, delta: Math.abs(target - value) };
}

The snap is not decoration. Exponential motion has an asymptotic tail, which is mathematically neat and practically annoying, because without a stop rule the camera can spend a long time being technically in motion while nothing useful is happening on screen; once the camera is inside a small epsilonA tiny tolerance used to decide that two floating-point values are close enough to treat as equal., the implementation should call that done and remove the tickerA callback registered with Foundry's PIXI application ticker so camera state updates once per rendered frame..

Those two functions are also the entire mathematical content of EZGlide. Everything past this point is about a different question — who gets to set target, and when — and that question, not the math, turned out to be the actual work.

The module that already existed

The macro was short and it felt good, which is a dangerous combination, because it made me want to give it away. A macro you paste into a world is fine for me; a module installs once and is then forgotten, which is the better way for other people to have it, so that was the next step.

A module needs a name, and I landed on EZGlide — easy, and a backronym I liked more than I should: (E)xponential (Z)oom and Glide, the glide being the pan. Checking that nothing already owned the idea is how I found Aeris Smooth Camera.

Someone had already built this, and they had built more of it — Aeris smoothed panning too, not just zoom — which was deflating for about a minute, in the way that finding prior art usually is when you write the tool before looking hard at the ecosystem around it.

So I opened the source, partly to see whether Aeris had reached for the same exponential smoothing and partly to see whether I should just use it and stop. The smoothing turned out not to be the interesting part. Two structural choices were.

The first is visible at install. src/mouseWheel.ts assigns the canvas wheel handler:

canvas._onMouseWheel = _onMouseWheel;

src/rightClick.ts does the same for right-drag panning:

c._onDragRightMove = _onDragRightMove;

It also replaces canvas.pan directly:

const originalPan = canvas.pan;
canvas.pan = function (options) {
  if (!options?.__aerisInternal) killCustomPan();
  return originalPan.call(this, options);
};

All three assign over methods that the canvas, core, and other modules share — direct monkey patchingReplacing or assigning an existing method at runtime. It can work, but it bypasses the coordination layer Foundry modules normally use. — which works, and is the easy path, but it skips the coordination layer Foundry has for exactly this case, and that matters far more for something you ask strangers to install than for a macro in your own world.

The second choice is the one you feel through the mouse. In Aeris the wheel path calls killCustomPan() and the drag path calls killCustomWheel(), so each motion is smooth on its own and the two cancel when run together: starting one kills the other, pan wins in practice, a zoom in the middle of a pan feels half-stuck, and the full pan-and-zoom matrixThe set of interaction combinations a camera has to support: pan only, zoom only, pan while zooming, and zoom while panning. is never really there. It looks great in a demo, because a demo pans.

Those two findings are what moved me from annoyed — I had maybe wasted an evening building a tool that already existed — to deciding to build the same thing without either problem, and better in every way I could manage. Spite is close to the right word for it. It also meant signing up for a harder problem than the macro had, because the formula could glide a single value toward a target, but the module had to decide what owned the camera while a user was panning, zooming, or doing both at once.

The Foundry problem

Foundry module code lives in shared territory: canvas methods already exist, other modules may wrap them, and core may change them, so a private macro can get away with assignment because the blast radius is one world, while a released module cannot.

The standard tool for that is libWrapperA Foundry VTT library that lets modules register wrappers and overrides around core methods without directly replacing those methods., which lets modules wrap, override, and unregister behavior in a coordinated way. It does not make conflicts impossible, but it replaces a race to overwrite the same method last with a shared mechanism.

Coexistence still has limits. Foundry lets a module declare relationships in module.json, but it does not resolve every runtime conflict, so EZGlide declares Aeris as a conflict and still checks at setup before registering its own wrappers:

const conflicts = game.modules.get(MODULE_ID).relationships.conflicts;
for (const conflict of conflicts) {
  if (game.modules.get(conflict.id)?.active) {
    errorState.conflictingPackage = conflict;
    return;
  }
}

Not much code, but it prevents a bad kind of cleverness: if two modules both want the same camera methods, I do not want EZGlide silently fighting over them and letting whichever package registered last decide the user’s camera, so warning clearly and stepping aside is less magical and far easier to reason about.

EZGlide’s v2 runtime uses named wrapper targets:

export const CANVAS_WHEEL = "foundry.canvas.Canvas.prototype._onMouseWheel";
export const CANVAS_PAN = "foundry.canvas.Canvas.prototype.pan";
export const CANVAS_DRAG_RIGHT_MOVE = "foundry.canvas.Canvas.prototype._onDragRightMove";

The wrapper registration is small on purpose:

export function registerWrapper(registry, target, fn, type) {
  const hookId = registry.libWrapper.register(registry.packageId, target, fn, type);
  registry.trackHookId?.(hookId);
}

Registering wrappers was the small part. The harder part was deciding what the camera state actually was, because a proper implementation has to handle zoom only, pan only, pan while zooming, zoom while panning, external pan calls, scene reloads, and user setting changes without making the cursor feel like it hit a bad gear — the formula moved a value, but it did not own a camera.

The working mess

The v1 module got there technically: it used libWrapper, it had mode-specific handlers, it tracked an internal pan symbol so EZGlide could call canvas.pan without treating its own animation as an external interruption, and it could pan and zoom at the same time. It was also too much in one file, with settings, animation state, error state, mode flags, wrappers, tickers, migration logic, and Foundry hooks all sitting together, and the macro had the same problem in its own shape — backporting the module behavior into it proved the idea was useful, and it also proved I had built two maintenance surfaces for one camera.

The code was not nonsense. That almost made it worse. It was a pile of reasonable decisions that had stayed too close together after the problem grew.

The arrival conditionThe rule that decides when an animated value has reached its target closely enough to stop updating. is one place the crowding shows. Aeris stops zoom by checking a hard-coded relative threshold:

if (Math.abs(ds) < canvas.stage.scale.x * 0.01) {
  killCustomWheel();
}

It handles the nearby zoom case, but the completion rule ends up hard-coded, relative to scale, and separate from the interpolation primitiveA small reusable operation that moves one value toward another and reports whether it has arrived., whereas EZGlide’s lerpSnap moves that decision into the motion helper, where pan and zoom share one epsilon:

if (rx.delta === 0 && ry.delta === 0 && rs.delta === 0) {
  this.stopViewTicker(canvas);
}

The callback is not inventing a stop rule; it reads the result of the motion primitive. Once pan and zoom were both in play that started to matter, because the old shape had forced each path to carry its own little piece of camera policy, and those pieces belonged somewhere smaller and reusable.

The build split

The first real v2 change was not a camera change — the camera was still the same overgrown thing. The project first had to stop pretending the macro and the module were two programs.

In v1, the module lived in scripts/ez-glide.mjs and the macro lived in macro/macro.js, separate files doing the same camera work under different Foundry constraints. The module could rely on module.json, registered settings, template paths, and normal lifecycle hooks, while the macro had to be one runnable script that carried its own localization, prompted for its own settings, and cleaned up whatever wrappers it had registered the last time the user ran it. Those differences are real. The duplicated camera code in the middle was not.

Vite+ made the distinction enforceable. The root config defines two build tasks:

const buildTasks = {
  "build:module": {
    command: "vp build --config vite.module.config.ts",
  },
  "build:macro": {
    command: "node tools/build-localized-macros.mjs",
  },
  build: {
    command: "vp run build:module && vp run build:macro",
  },
};

The module build is ordinary — src/module/main.mjs in, build/ez-glide.min.mjs out, and on release the workflow points module.json at the built file and zips it up with the manifest and language files. The macro build is not ordinary, because a Foundry macro is not a module graphThe set of files connected by import and export statements that a bundler follows from an entry point.; it has to arrive as one runnable script, so vite.macro.config.ts builds an IIFEAn immediately invoked function expression: a function wrapped so it runs as soon as the script loads, useful when output must be one self-contained browser script. and disables code splittingA bundler feature that splits output into multiple generated files. That is useful for apps, but wrong for a pasteable Foundry macro.:

lib: {
  entry: "src/macro/main.mjs",
  formats: ["iife"],
  name: "EzGlideMacro",
  fileName: () => `ez-glide-macro.${localeCode}.js`,
},
rolldownOptions: {
  output: {
    codeSplitting: false,
  },
},

Localization moved into the build at the same time: tools/build-localized-macros.mjs loops over lang/*.json, checks that the macro has every string it needs, then rebuilds once per locale:

for (const file of localeFiles) {
  const localeCode = basename(file, ".json");
  spawnSync("vp", ["build", "--config", "vite.macro.config.ts"], {
    env: {
      ...process.env,
      EZ_GLIDE_MACRO_LOCALE: localeCode,
      EZ_GLIDE_MACRO_LOCALE_PATH: join(LANG_DIR, file),
    },
  });
}

The release action uploads every generated macro next to the module archive, so the README can point a user at a download instead of telling them to open a source file and copy whatever happens to be in it that day.

This is tooling bookkeeping, but it changed the code I was willing to write, because once one src tree produced both artifacts, the shared pieces could become actual shared modules instead of things I tried to keep synchronized by hand: src/runtime for camera behavior, src/settings for the DataModel-backed settings shape and the ApplicationV2 settings menu, and a thin entry on each side. The module entry migrates old client settings into one cameraSettings object and registers a menu; the macro entry injects localization, renders the same settings template as raw text, saves user choices on a flag, and tracks libWrapper hook ids so the next run can unregister the previous ones. Each side owns the annoying part that is actually different, and both call the same runtime factory afterward — which is the point where the project stops having two cameras.

The v2 shape

The v2 refactor moved the camera behavior into a shared runtime and demoted the module and macro to delivery surfaces. The relevant source files are src/runtime/create-camera-runtime.mjs, src/runtime/base.mjs, and the four files under src/runtime/strategies/: zoom.mjs, pan.mjs, pan-zoom.mjs, and noop.mjs.

The shape follows from the pan-and-zoom matrix. Two settings produce four meaningful states — zoom and pan enabled, zoom only, pan only, and neither — and each state needs the same outer lifecycle of attaching to a canvas, registering wrappers, starting and stopping a ticker, and detaching again, while the wrapper set and ticker behavior differ per state. A factory fits that problem because something has to turn settings into the concrete runtime:

export function createCameraRuntime(settings) {
  const hasZoom = settings.enabledFeatures.has("zoom");
  const hasPan = settings.enabledFeatures.has("pan");

  if (hasPan && hasZoom) return new PanZoomCameraRuntime(settings);
  if (hasPan) return new PanCameraRuntime(settings);
  if (hasZoom) return new ZoomCameraRuntime(settings);
  return new NoopCameraRuntime(settings);
}

The selection table lives in one function, so neither src/module/main.mjs nor src/macro/main.mjs needs to know how many runtime classes exist; each asks for the runtime that matches the current settings and calls the same methods on whatever comes back, and if a fifth camera mode ever exists, the new branch belongs here rather than in both delivery surfaces.

The pan-and-zoom matrixTwo settings, four cells, one runtime class per cell.
createCameraRuntime({ zoom: on, pan: on })PanZoomCameraRuntime
zoom offzoom onpan offpan on
PanZoomCameraRuntimezoom + pan
src/runtime/strategies/pan-zoom.mjs

The full case: wheel zoom, right-drag pan, external canvas.pan, and one shared target view.

ZoomCameraRuntimezoom
src/runtime/strategies/zoom.mjs

The zoom-only case: wrap the wheel handler and advance scale.

PanCameraRuntimepan
src/runtime/strategies/pan.mjs

The pan-only case: wrap right-drag panning and advance x/y.

NoopCameraRuntimenone
src/runtime/strategies/noop.mjs

The disabled case: keep the lifecycle shape and register no wrappers.

The stable shape comes from CameraRuntime in src/runtime/base.mjs, which owns lifecycle and animation state:

this.animationState = {
  currentView: { x: 0, y: 0, scale: 1 },
  targetView: { x: 0, y: 0, scale: 1 },
  viewTicker: null,
  lastViewTime: 0,
};

Each strategy is a swappable implementation of the same job — register the wrappers this runtime needs, then create a ticker that moves the camera toward its target — so ZoomCameraRuntime never touches the drag handler, PanCameraRuntime never touches the wheel handler, and NoopCameraRuntime inherits the shape and registers nothing, which is exactly what the disabled case should do.

The combined strategy owns the hard case, and the interesting code in it is not the ticker, because the ticker is just expDecay and lerpSnap pointed at three numbers. The interesting code is the pair of input handlers, because both of them write to one shared targetView, and each has to decide what the other axis means at the moment it fires. The wheel handler compounds the target scale and rebases the pan target onto wherever the camera currently is:

runtime.animationState.targetView = {
  x: runtime.animationState.currentView.x,
  y: runtime.animationState.currentView.y,
  scale: targetScale,
};

The drag handler makes the opposite call: it aims x and y at the drag destination and carries the in-flight zoom target forward untouched — it even computes that destination against the target scale rather than the current one, so a pan during a zoom aims where the zoom is heading:

runtime.animationState.targetView = {
  x: desired.x,
  y: desired.y,
  scale: runtime.animationState.targetView.scale,
};

That asymmetry is the design, and it is the thing the kill-function approach cannot express. A drag never interrupts a zoom, because every pan update carries the zoom target through. A wheel click does end whatever pan glide was still in flight, but it ends it by re-aiming the target at the camera’s current position, so nothing snaps and nothing jumps — the camera stops drifting and starts zooming from where it actually is, and if the user is still dragging, the next drag event re-aims the pan anyway. Cancellation became a per-input, per-axis decision about one shared target instead of one animation killing another outright.

External callers get the same courtesy. The canvas.pan wrapper checks for the runtime’s internal symbol, and when a call comes from anywhere else — a token focus, a scene transition, another module — it stops the ticker and aligns the targets to wherever the external caller put the camera, so EZGlide yields instead of fighting:

function panWrapper(wrapper, options = {}) {
  const isInternal = options?.[runtime.internalPan];

  if (!isInternal) {
    runtime.stopViewTicker(this);
    runtime.alignTargetsToCurrent(this);
  }
  return wrapper(options);
}

The macro now uses that same runtime. It still does its macro-specific work, and the module still does its module-specific work, but neither side has to remember how pan-only differs from zoom-only, because the factory remembers that and the strategies implement it. Zoom and glide are one state, owned in one place.

The project page is here: EZGlide.