Skip to content

folz/hydra-ts

Repository files navigation

hydra-ts

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.

Installation

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 regl

Requires Node 22 or newer.

Background

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.

Documentation

For general information about using Hydra, refer to hydra's documentation.

Creating a Hydra instance:

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() });

Recreating the hydra-editor global environment

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 editor

hydra.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.

Per-frame callbacks (update/afterUpdate)

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.

Supplying extra per-frame values (e.g. mouse)

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.

Adding custom generator or modifier hydra functions (e.g. setFunction)

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.

Mouse input

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 done

mouse exposes the same properties as upstream's: x, y, buttons, mods, and enabled.

Recreating bidirectional global changes (e.g. assigning bpm/speed globals)

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.

Equivalence with upstream

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 2x2 render() 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.

Differences from the original hydra-synth

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.

Contributing

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.

About

A fork of ojack/hydra-synth in typescript, focusing on interoperability.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors