hydra-ts is a fork of ojack/hydra-synth in typescript, focusing on interoperability with other projects. It
seeks to be fully compatible with the original's end-user syntax (osc().out()) while rewriting much of the internal
implementation to make it easier to use as a library.
hydra-ts takes a regl instance you create and pass in, so regl is a
peer dependency — install it alongside:
# yarn
yarn add hydra-ts regl# npm
npm install hydra-ts reglRequires Node 22 or newer.
hydra-synth is a fantastically designed visual synth and shader compiler that I've wanted to use in a variety of other projects. However, I've found that its implementation is tightly coupled to ojack/hydra, the online editor created to showcase hydra-synth. I've also found that it generally assumes a single running instance and a modifiable global environment.
These things have caused unexpected behavior for me when I used hydra-synth outside of hydra-the-editor, or in multiple places on the same page where I wanted each place to be self-contained from the others. Although the hydra community has found workarounds to many of these behaviors, I wanted to create a fork which directly fixes root causes so that workarounds are no longer needed.
To address these, hydra-ts has rewritten internals to avoid globals and mutable state, removed non-shader-compilation features present in the original (such as audio analysis), and modified the public API to prefer referential equality over named lookup.
For general information about using Hydra, refer to hydra's documentation.
import REGL from 'regl';
import { Hydra } from 'hydra-ts';
const regl = REGL(/*...*/);
const hydra = new Hydra({
regl,
width: 1080,
height: 1080,
/*
numOutputs?: 4,
numSources?: 4,
precision?: 'mediump', // 'highp' | 'mediump' | 'lowp'
*/
});The Hydra constructor expects a regl instance, width, and height. The width and height are the internal buffer dimensions, not the dimensions of the canvas element. This means you can e.g. pass in double the size of the canvas dimensions to avoid sampling/pixelation of high-resolution sketches until finally rendering back out to the canvas.
You can optionally provide a non-negative number for numOutputs and numSources, as well as a Precision value.
The precision default is 'mediump'. hydra-synth instead auto-detects iOS
and uses 'highp' there (mediump fragment shaders are visibly
low-precision on iOS); hydra-ts never sniffs the environment implicitly,
but ships the same heuristic as an opt-in helper:
import { Hydra, detectPrecision } from 'hydra-ts';
const hydra = new Hydra({ regl, width, height, precision: detectPrecision() });import { Hydra } from 'hydra-ts';
const hydra = new Hydra(/* ... */);
const { src, osc, gradient, shape, voronoi, noise, solid, prev } =
hydra.generators;
const { sources, outputs } = hydra;
const [s0, s1, s2, s3] = sources;
const [o0, o1, o2, o3] = outputs;
const { hush, loop, render } = hydra;
loop.start();
osc(60).out(); // chains from hydra.generators default to o0, like the editorhydra.generators are bound to their instance: .out() with no argument
renders to that instance's first output, matching the editor.
Generators that do not depend on any Hydra environment can also be imported
directly from 'hydra-ts' (import { generators } from 'hydra-ts'); chains
built from those require an explicit output: osc(60).out(o0).
Sources and outputs may be named when destructuring from their respective properties on the hydra instance.
Helper methods may also be destructured from the hydra instance.
Calling render() with no argument tiles the first four outputs in a 2x2
grid, like the original hydra editor (requires at least four outputs);
render(o1) renders a single output.
Like upstream hydra-synth, update runs before each rendered frame, and
afterUpdate runs after it:
hydra.synth.update = (dt) => {
/* dt in milliseconds since the previous rendered frame */
};
hydra.synth.afterUpdate = (dt) => {};hush() resets both, clears all sources, and renders transparent black to
every output, matching upstream behavior.
Dynamic (function) arguments receive { time, bpm, resolution, ... } each
frame. hydra-synth additionally passes a global mouse; hydra-ts instead
lets you inject any values you like:
const hydra = new Hydra({
// ...
props: () => ({ mouse: { x: pointerX, y: pointerY } }),
});
osc(({ mouse }) => mouse.x / 100).out(o0);Injected values cannot override the synth's own per-frame values (time,
bpm, resolution, ...), and a throwing props callback is logged and
skipped for that frame rather than aborting it.
import {
createGenerators,
createTransformChainClass,
defaultGenerators,
defaultModifiers,
} from 'hydra-ts';
const chainClass = createTransformChainClass([
...defaultModifiers,
myModifierDefinition,
]);
const generators = createGenerators(
[...defaultGenerators, myGeneratorDefinition],
chainClass,
);
const { src, osc, /* ... , */ myGen } = generators;Where myGeneratorDefinition and myModifierDefinition match the object you would have passed to setFunction.
A "generator" is a definition with {type: 'src'}, and a "modifier" is a definition of any other type.
Definitions use hydra-synth's setFunction format: the implicit arguments
for each type are injected automatically and must not be declared in
inputs. In particular, combine/combineCoord definitions receive the
other source implicitly — reference it as _c1 (combine) or _c0
(combineCoord) in the glsl body:
const myModifierDefinition = {
name: 'myBlend',
type: 'combine',
inputs: [{ name: 'amount', type: 'float', default: 0.5 }],
glsl: `return _c0*(1.0-amount)+_c1*amount;`,
};(Versions of hydra-ts before this sync instead declared the source as an
explicit color input on combine definitions. That shape was never
compatible with hydra-synth's setFunction and is no longer supported —
remove the input and rename color to _c1/_c0 in the body.)
Also note that since hydra-synth 1.4.0, arguments for vec4 inputs must be
a source or texture; vector literals like sum([1, 1, 1, 1]) throw, in
hydra-synth and hydra-ts alike.
hydra-synth installs a global mouse listener on window at import time.
hydra-ts ships the same tracker as an explicit factory instead — nothing is
attached until you ask for it:
import { createMouse } from 'hydra-ts';
const mouse = createMouse(); // listens on window, like hydra-synth
// const mouse = createMouse(canvas); // or scope it to an element
osc(() => mouse.x / 100).out(o0);
mouse.enabled = false; // detach listeners when donemouse exposes the same properties as upstream's: x, y, buttons,
mods, and enabled.
In the hydra editor, speed, bpm, and fps are assignable globals. In
hydra-ts they are plain mutable fields on hydra.synth:
hydra.synth.speed = 2; // time advances twice as fast
hydra.synth.bpm = 120; // affects array sequencing ([1, 2].fast(...))
hydra.synth.fps = 30; // cap the render rate (undefined = uncapped)Reading them works the same way (hydra.synth.time, hydra.synth.stats.fps).
There are no window-level globals to assign; each instance owns its state.
hydra-ts tracks hydra-synth and aims to produce identical output for
the same sketch. This is enforced by the test suite (npm test), which runs
the real hydra-synth compiler (pinned as a devDependency, currently 1.4.0)
side-by-side with hydra-ts and asserts:
- every transform definition (inputs, defaults, and glsl) is byte-identical
to upstream's
glsl-functions.js, - the compiled fragment shader for a corpus covering every transform — plus
nesting, feedback (
src(o0),prev()), arrays, and function arguments — is byte-identical to the one hydra-synth compiles, - compiled uniforms resolve to the same values,
- the regl draw commands (vertex shader, fullscreen triangle, ping-pong
framebuffers,
prevBuffer, final canvas blit and 2x2render()view) are wired identically.
src/glsl/transformDefinitions.ts is generated from the hydra-synth
devDependency by scripts/generate-transform-definitions.mjs. To sync with a
new upstream release: bump the hydra-synth devDependency, re-run the
script, review the diff, and fix any equivalence-test failures in the
compiler.
Chains built from the environment-free generators (import { generators } from 'hydra-ts')
require an explicit output: .out(o0). Chains built from an instance's bound
generators (hydra.generators) default to that instance's first output, like
the original.
You must also call ArrayUtils.init() once before any instance of hydra is used.
Errors thrown while compiling a chain (for example, passing a number where a
texture is expected) propagate to the caller of .out(), rather than being
caught and logged as a warning like upstream does.
Contributions are welcome. In particular, contributions around tests, performance, correctness, and type safety are very appreciated. I'm also open to contributions which help you integrate this into your own projects.
Please remember that this fork has a goal of full compatibility with the original implementation, so if you're proposing new syntax or breaking changes, they will need to be upstreamed before being implemented here. If in doubt, feel free to open an issue discussing the changes before starting work on them.