// .+"+.+"+.+"+.+"+.+"+.+"+.+"+.+"+.
// : Echo https://echo.hughsk.io :
// .+"+.+"+.+"+.+"+.+"+.+"+.+"+.+"+.

Echo is a shader editor where the shader is your editor. It can be a bit to get your head around for the uninitiated, but fear not! Below you will find some good starting points, further reading, and an API reference that covers the specifics of writing shaders in Echo.

Currently, Echo is still a rough draft. There is still a pretty decent chance it will change and break things. The project is open source and licensed under AGPL-3.0: https://codeberg.org/hughsk/echo. Pull requests are welcome!

Examples

One of the easiest places to start is to take an example and modify it:

Shaders and WESL

Echo is built using WebGPU, and uses WESL/WGSL as its shader language. WESL adds the ability to import reusable shader modules, but should otherwise work identically to how you'd expect WebGPU's regular WGSL shaders to work.

If you've never worked with WGSL before, it's not too far from GLSL but with a more Rust-flavoured syntax. You can find a guide on how GLSL concepts translate to WGSL here.

If you've never worked with shaders or graphics programming, you can learn the foundations through The Book of Shaders or Nature of Code. It's also worth starting by making some changes to the code in Echo and seeing what it does: the editor updates instantly, and so it's quick to see whether something looks great or totally breaks.

In terms of shader modules, Echo currently supports importing code from the LYGIA library in addition to Echo's own internal modules. For example:

// Imports `snoise3` from LYGIA's ./generative/noise/snoise.wesl file:
import lygia::generative::noise::snoise::snoise3;
// Imports `snoise2` and `snoise` from that same file:
import lygia::generative::noise::snoise::{snoise2, snoise4};

// Imports `cell` and `prev_cell` from Echo's internal modules:
import echo::{cell, prev_cell};

You can find a listing of LYGIA's modules on its website, or browse the source on GitHub. You can find a listing of the code in Echo's modules in the API reference below.

Keyboard shortcuts

API reference

This is a list of every function, module and struct property you need to work with to use Echo. Each inline example below is interactive, so feel free to tweak and adjust them to get an idea of how each thing works.

Note: there are a few other hidden functions and properties you can use, but if they're not documented here then they are subject to change and may break in the future.

fn map_cell(i: Cell, ctx: Context) -> Cell

This is the main function you're working with in Echo. The editor data is stored as a grid of cells, with each cell having a foreground color, background color and character ID. Every frame the map_cell function is executed for every visible cell, allowing you to modify the colour and content of the editor. For example, to change all of the text red:

import echo::{Cell, Context};

fn map_cell(i: Cell, ctx: Context) -> Cell {
  var o = i;
  o.fg = vec3(1, 0, 0);
  return echo::map_cell_selection(o, i, ctx);
}

You can do a surprising amount with this. Because the contents of the editor are arranged out in a grid, it's not too different from a very constrained fragment shader. If you wanted to do full raymarching and draw 3D scenes in the style of Shadertoy, you definitely can... The key difference being that you're rendering to a coarse grid of text, of course.

Cell::fg

This is the text colour of the current cell, represented as an RGB vec3f.

Note that the input colour you receive from map_cell is the colour after syntax highlighting. You can modify this before passing it back as a way to change the colour without making the text completely uniform.

import echo::{Cell, Context};
import lygia::color::hueShift::hueShift;

fn map_cell(i: Cell, ctx: Context) -> Cell {
  var o = i;
  o.fg = hueShift(o.fg, ctx.time);
  return echo::map_cell_selection(o, i, ctx);
}

Cell::bg

This is the background colour of the current cell, represented as an RGB vec3f. Like fg, the background colour will come prepopulated with the background colour of the editor. This will be a single uniform colour by default, but can be changed to any colour you like. To make it a little more blue, for example:

import echo::{Cell, Context};

fn map_cell(i: Cell, ctx: Context) -> Cell {
  var o = i;
  o.bg.b += 0.3;
  return echo::map_cell_selection(o, i, ctx);
}

Cell::ch

This is the current character being displayed, represented as a u32 ASCII character code between 0 and 127. Blank space in the editor will be stored as zero, unlike actual space characters which will be stored as 32.

You can update and modify characters just the same as you can colors. You can easily get a text glitch effect by offsetting the character index by some value:

import echo::{Cell, Context};

fn map_cell(i: Cell, ctx: Context) -> Cell {
  var o = i;
  o.ch += u32(5f * sin(ctx.time + f32(i.cp.y * 8)));
  return echo::map_cell_selection(o, i, ctx);
}

Note that you'll need to convert whatever input value you want into a u32, or you'll get an error.

You can find ASCII character codes listed at https://www.asciitable.com/. Echo supports characters 32 – 126. Any values below that range will be rendered as a blank space, and any values above that range will be rendered as a tilde (~).

Cell::cp

This is the current absolute cell position, represented as an XY vec2u, i.e. a pair of unsigned integers. The X coordinate counts up from left to right, and the Y coordinate counts up from top to bottom.

import echo::{Cell, Context};

fn map_cell(i: Cell, ctx: Context) -> Cell {
  var o = i;
  o.fg = vec3(1);
  o.bg.r += f32(i.cp.x % 2) * 0.25;
  o.bg.r += f32(i.cp.y % 2) * 0.25;
  return echo::map_cell_selection(o, i, ctx);
}

Note that the characters are not square, but have a 1:2 aspect ratio: one unit along the Y axis is twice as large as one unit along the X axis.

Cell::sp

This is the screen position of the current cell, represented as an XY vec2f. In contrast to the cell position, this value is normalized so that the values fit within the screen. Both axes range from -1 on the bottom-left to +1 on the top-right.

This can be a useful coordinate to work with if you want your output to be scaled responsively to fit your screen.

import echo::{Cell, Context};

fn map_cell(i: Cell, ctx: Context) -> Cell {
  var o = i;
  o.fg = vec3(1);
  o.bg.r = i.sp.x * 0.5 + 0.5;
  o.bg.g = i.sp.y * 0.5 + 0.5;
  return echo::map_cell_selection(o, i, ctx);
}

If you use Cell::sp directly to render an image, the image's aspect ratio will be distorted. If you want a consistent image and aspect ratio across different screen sizes, you can use the echo::contain function to scale the coordinate to fit within a square in the center of the screen.

import echo::{Cell, Context, contain};

fn map_cell(i: Cell, ctx: Context) -> Cell {
  var p = contain(i.sp, ctx.resolution);
  var o = i;
  if (abs(p.x) > 1 || abs(p.y) > 1) {
    o.bg.b = 0.5;
  }
  return echo::map_cell_selection(o, i, ctx);
}

Context::time

This gives you the current time, in seconds since the page was first loaded.

import echo::{Cell, Context};

fn map_cell(i: Cell, ctx: Context) -> Cell {
  var o = i;
  o.bg = sin(vec3(ctx.time) + vec3(0f, 2f, 4f)) * 0.5 + 0.5;
  o.fg = 1 - o.bg;
  return echo::map_cell_selection(o, i, ctx);
}

Context::resolution

This gives you the width and height of the visible screen in pixels, as a vec2f. Usually you won't need to use this directly, but it can be useful in some cases. For example, echo::contain uses it to determine the aspect ratio for aspect ratio correction:

import echo::{Cell, Context};

fn contain(screen_coord: vec2f, resolution: vec2f) -> vec2f {
  return screen_coord * vec2f(
    max(1, resolution.x / resolution.y),
    max(1, resolution.y / resolution.x)
  );
}

fn map_cell(i: Cell, ctx: Context) -> Cell {
  var o = i;
  var p = contain(i.sp, ctx.resolution);
  o.bg = vec3(abs(p), 1);
  o.fg = vec3(0);
  return echo::map_cell_selection(o, i, ctx);
}

fn echo::map_cell_selection(o: Cell, i: Cell, ctx: Context) -> Cell

This function applies the default behaviour for selection and error reporting, making a Echo a bit more usable from the outset. You can import it using import echo::map_cell_selection, but an editable version is included in the default editor that you can override yourself.

It takes both your modified Cell, and the original value of the Cell, and returns an updated Cell that you can pass on.

import echo::{Cell, Context};

fn map_cell(i: Cell, ctx: Context) -> Cell {
  var o = i;
  return map_cell_selection(o, i, ctx);
}

fn map_cell_selection(o: Cell, i: Cell, ctx: Context) -> Cell {
  var s = o;
  // This makes errors flash red
  if (i.error) {
    s.bg += vec3(sin(ctx.time * 30f) * 0.5 + 0.5, 0, 0);
  }
  // This makes your cursor pink
  if (i.selection.w > 0) {
    s.bg += vec3(1, 0.3, 0.9) * (sin(ctx.time * 10f) * 0.5 + 0.5);
  }
  // This makes selections rainbow
  if (i.selection.x > 0) {
    s.bg += vec3(sin((ctx.time + i.sp.x + i.sp.y) * 5f + vec3(0, 2, 4)) * 0.5 + 0.5);
    s.fg = 1 - s.bg;
  }

  return s;
}

fn echo::cell(offset: vec2i) -> Cell

Retrieves the contents of the neighbouring cell, before it's been processed by map_cell(). The coordinates are relative, so to get the contents of the cell to immediately to the current cell's left you would use cell(vec2(-1, 0)).

import echo::{Cell, Context, cell};

fn map_cell(i: Cell, ctx: Context) -> Cell {
  let offset = vec2i(i32(12 * sin(ctx.time + f32(i.cp.y) * 0.7)), 0);
  var o = i;
  var c = cell(offset);

  o.ch = c.ch;
  o.fg = c.fg;

  return echo::map_cell_selection(o, i, ctx);
}

This is useful for effects like offsetting text as seen above, but can also be used for cases like checking if the neighbouring cell is selected by the user.

fn echo::prev_cell(offset: vec2i) -> Cell

Like echo::cell, this function also retrieves the neighbouring cell but instead retrieves its contents after being modified by map_char in the previous frame. This makes it useful for implementing feedback effects such as adding trails to the background, including your cursor and selection:

import echo::{Cell, Context, prev_cell};

fn map_cell(i: Cell, ctx: Context) -> Cell {
  var c = prev_cell(vec2(0));
  var o = i;

  if (i.selection.x == 0) {
    o.bg = vec3(0.5, 0.1, 0.3);
  }

  if ((i.cp.y + i.cp.x + u32(ctx.time)) % 25 <= 4) {
    o.bg = vec3(0.8, 0.1, 0.5);
  }

  o.bg = mix(o.bg, c.bg, 0.97);

  return echo::map_cell_selection(o, i, ctx);
}

You can find some more elaborate effects in the examples listed above.

fn echo::cell_cp(offset: vec2i) -> Cell

This function works like echo::cell, except instead of retrieving a neighbouring cell it looks for a cell by its absolute cell position. The below example looks up the top-leftmost character and uses it to fill any unused spaces:

import echo::{Cell, Context, cell_cp};

fn map_cell(i: Cell, ctx: Context) -> Cell {
  var o = i;
  var c = cell_cp(vec2(2, 1));

  if (o.ch < 32) {
    o.ch = c.ch;
    o.fg = mix(o.bg, c.fg, 0.6);
  }

  return echo::map_cell_selection(o, i, ctx);
}

fn echo::prev_cell_cp(offset: vec2i) -> Cell

This function works like echo::prev_cell, except it uses the absolute cell position.

fn echo::audio::get_wave(i: f32) -> f32

Once the microphone is enabled, this function will lookup the value of the microphone's audio waveform. You can use this to do basic audio visualisation.

// Click to enable: [ ](#microphone)

import echo::{Cell, Context};
import echo::audio::get_wave;

fn map_cell(i: Cell, ctx: Context) -> Cell {
  var o = i;
  let t = get_wave(i.sp.x);

  o.bg = vec3(max(0, t), 0, max(0, -t)) * 8;

  return echo::map_cell_selection(o, i, ctx);
}

The lookup value, i, should be somewhere between zero — the beginning of the wave — and one — the end. Output values will range between -1 and +1.

fn echo::audio::get_freq(i: f32) -> f32

Once the microphone is enabled, this function will look up the FFT of the audio waveform. This allows you to read how loud the current input audio is at different frequencies, and is useful if you want to have visuals react to only bass or only treble sounds.

// Click to enable: [x](#microphone)

import echo::{Cell, Context};
import echo::audio::get_freq;

fn map_cell(i: Cell, ctx: Context) -> Cell {
  var o = i;
  let t = get_freq(abs(i.sp.x * 0.25));

  o.bg += vec3(0.4, 1, 0.5) * 2 * clamp(t - abs(i.sp.y), 0, 1);

  return echo::map_cell_selection(o, i, ctx);
}

The lookup value, i, should be somewhere between zero — the first FFT bin — and one — the last. Output values will range between 0 and 1.