Part I : Foundations of Form

Data comes from outside: a recording, a picture, a model, a film, a microphone, a camera. MayaFlux can play it as it is, or take it apart and rebuild it into something its maker never heard or saw.

How to use these cards:

  • Pick the kind of data you want to work with: sound, picture, film, a model
  • Run card 1 to bring it in
  • Jump to the card that works on it, and run its first snippet
  • Change one number at a time, and run it again
  • Open the explanations when you can follow what a change did, and change it again with more reach
  • Finish a card with Try It → Recap, for wilder examples and exercises
  • Then pick another kind of data and repeat

Some things take one line. Playing a recording does, and card 1 shows it. Other things don’t, and we won’t pretend otherwise: a fire you can feed with your hand, that answers a sound, is a piece of work a single line cannot hold. One line would show you a fire. It could not let you play with one.

So the longer blocks are not hiding the difficulty. They are the shortest working version of the thing, with the interaction already in it. Copy one and run it. Change a number and watch what moves. You can use all of it without understanding any of it, and read the code when you want to know why.

If you are going to paste something you don’t yet follow, paste this, and not a handful of triangles. A shape with nothing to answer to teaches you where the edge is. A fire you can reach into teaches you what is possible.

If a word or a call does not make sense yet, run the code and carry on. A later card often explains an earlier one better.

Form is data. It can come from anywhere, and it can go anywhere.

Bring It In

The Simplest First Step

Data comes from outside: a recording, a picture, a 3D model, a film. MayaFlux does not care where it came from. Bring it in, give it somewhere to go, and it plays or appears as it is.

Each block below is a complete compose() for your src/user_project.hpp. Pick one and run it. A file dialog opens: choose a file of the matching kind.

Sound

void compose() {
    vega.read_audio() | Audio;
}

Run this code. The file plays through your speakers.

To skip the dialog, give it a path: vega.read_audio("path/to/file.wav").

Also: Sound from a Microphone

Live sound is data from outside too. First tell the engine to open your sound card’s input, in settings(). Then listen to it in compose():

void settings() {
    Config::get_global_stream_info().input.enabled = true;
    Config::get_global_stream_info().input.channels = 1;
}

void compose() {
    create_input_listener_buffer(0, true);
}

What the microphone hears comes out of your speakers on the same channel. Use headphones, or the speakers will feed back into the microphone.

The input can also be turned on from a file, without recompiling. See Expansion 8.

Image

void compose() {
    auto window = create_window({ .title = "Image", .width = 1280, .height = 720 });

    auto image = vega.read_image() | Graphics;
    image->setup_rendering({ .target_window = window });

    window->show();
}

Run this code. The image appears in a window.

Change width and height and run again: the same picture, a different window.

Model

void compose() {
    auto window = create_window({ .title = "Model", .width = 1280, .height = 720 });

    auto meshes = vega.read_mesh() | Graphics;
    for (auto& mesh : meshes) {
        mesh->setup_rendering({ .target_window = window });
    }

    window->show();
}

Run this code. The model is drawn into the window.

A model file can hold several meshes, so the loader gives you a group and you set up each one.

Also: Model as a Network of Parts

The same file, read so that its parts stay together as one network:

void compose() {
    auto window = create_window({ .title = "Model parts", .width = 1280, .height = 720 });

    auto network = vega.read_mesh_network() | Graphics;
    auto form = vega.mint(StructureConfig::Model { .network = network, .render = { .target_window = window } });

    window->show();
}

It looks like the block above. The difference is underneath: each part of the model is a slot in one network, and each slot keeps its own transform, so parts can later be moved independently.

Video, with Sound

void compose() {
    auto window = create_window({ .title = "Video", .width = 1280, .height = 720 });

    auto [video, audio] = choose_video({ .video_options = IO::VideoReadOptions::EXTRACT_AUDIO });

    video | Graphics;
    auto picture = get_associated_buffer(video);
    picture->setup_rendering({ .target_window = window });

    window->show();
}

Run this code. EXTRACT_AUDIO asks the reader to decode the soundtrack; it does not send sound to the speakers. video | Graphics then hooks the picture and the extracted audio to their output buffers, so the picture appears and the sound plays.

Video, Picture Only

void compose() {
    auto window = create_window({ .title = "Video", .width = 1280, .height = 720 });

    auto [video, audio] = choose_video({});

    video | Graphics;
    auto picture = get_associated_buffer(video);
    picture->setup_rendering({ .target_window = window });

    window->show();
}

The only difference from the block above is the empty {}. Without EXTRACT_AUDIO the sound is never decoded, so audio is empty and there is nothing to hook.

Also: Picture from a Camera

A camera is a live video source. It has no file to browse to, so two small windows open instead: one to choose the camera, and one to choose a size and frame rate.

void compose() {
    auto window = create_window({ .title = "Camera", .width = 1280, .height = 720 });

    auto camera = vega.read_camera() | Graphics;
    auto picture = get_associated_buffer(camera);
    picture->setup_rendering({ .target_window = window });

    window->show();
}

Your live picture appears in the window.

Each example assumes you pick a file. If you cancel the dialog, nothing loads and the lines that follow have nothing to work on.

You have, in each case:

  • Data from outside, held in memory
  • A domain it was routed to: audio for the sound, graphics for the picture
  • A result you can hear or see after one run
Explanations
Expansion 1: What Is `vega`?

vega is a fluent interface: a convenience layer that takes MayaFlux’s power and hides the verbosity without hiding the machinery.

Making complex logic less verbose is a good way to encourage more people to explore. But complexity that is presented well is not an obstacle. It is what gives you something to choose between, and choices are where agency and creativity come from. So vega shortens the path to the first result and leaves every door on that path open.

If you didn’t have vega, loading a sound file would look like this:

auto reader = std::make_shared<IO::SoundFileReader>();

if (!reader->can_read("path/to/file.wav")) {
    return;
}

reader->set_target_sample_rate(Config::get_sample_rate());
reader->set_audio_options(IO::AudioReadOptions::DEINTERLEAVE);

auto options = IO::FileReadOptions::EXTRACT_METADATA | IO::FileReadOptions::EXTRACT_REGIONS;
if (!reader->open("path/to/file.wav", options)) {
    return;
}

auto container = std::dynamic_pointer_cast<Kakshya::SoundFileContainer>(reader->create_container());

if (!reader->load_into_container(container)) {
    return;
}

container->set_memory_layout(Kakshya::MemoryLayout::ROW_MAJOR);

auto processor = std::dynamic_pointer_cast<Kakshya::ContiguousAccessProcessor>(container->get_default_processor());
if (!processor) {
    processor = std::make_shared<Kakshya::ContiguousAccessProcessor>();
    container->set_default_processor(processor);
}
processor->set_output_size({ Config::get_buffer_size(), container->get_num_channels() });
processor->set_auto_advance(true);

Depending on your exposure to programming, this can feel complex or liberating. Being explicit means you decide what is created, when, and with which settings.

vega says: “You just want to load a file? Say so.”

auto container = vega.read_audio("path/to/file.wav");

Same machinery underneath: the same reader, the same resampling to your project’s sample rate, the same processor setup.

What vega does:

  • Picks the reader from the file
  • Applies sensible defaults
  • Checks for errors
  • Builds and configures the container
  • Returns the real object

What vega doesn’t do:

  • Hide the idea. It removes the verbosity, not the machinery.
  • Make the object less capable. What you get back is the full container.
  • Remove your ability to do it explicitly. You can always write the long version when you need control.

The short syntax is convenience. The long syntax is control. MayaFlux gives you both.

Expansion 2: Containers and Buffers

A container is a large collection of data held as a whole: every sample of a recording, every frame of a film. It knows its own shape and size, and it has a processor that knows how to hand out pieces of it.

A buffer holds one step of that, and the information needed to use it:

  • For sound, a buffer holds one block of samples.
  • For a picture, a buffer holds the data for one frame, plus the rendering information that goes with it: which shaders draw it, which window it goes to, and the fields those need, whether they apply to one frame or to all of them.

Containers are where material rests. Buffers are where it gets used.

This is why every example above has two parts: a loader that makes a container (or, for an image, a buffer that already holds its pixels), and a registration that gives a buffer somewhere to go.

Expansion 3: What Does `| Audio` or `| Graphics` Do?

The | operator is quick registration. It hands the object to a domain and gives you the same object back, so the line keeps working as a value:

auto image = vega.read_image() | Graphics;

Audio and Graphics are domains. A domain tells the engine when and where to process the object: audio at the sound card’s pace, graphics once per frame.

Registration is the part that is the same every time, so | does it for you. Written by hand:

  • container | Audio is get_io_manager()->hook_audio_container_to_buffers(container)
  • video | Graphics is get_io_manager()->hook_video_container_to_buffer(video), and the same for the sound that came with it. A camera takes hook_camera_to_buffer(camera)
  • buffer | Graphics is register_graphics_buffer(buffer)
  • network | Graphics is register_node_network(network, Nodes::ProcessingToken::VISUAL_RATE), after switching the network to graphics output if it is not already

Whenever the choice is load bearing, you do it by hand. That is why the image and the model need a second line to choose a window, and why the video is wired by hand: one file can feed more than one domain, and only you know where each part should go.

Expansion 4: Loading Is Separate from Routing

vega.read_audio() on its own does this:

  • Opens the file and decodes it
  • Resamples to your project’s sample rate
  • Builds a container with a processor attached
  • Returns the container

It does not start playback, create buffers, or connect to your audio hardware. Try it:

void compose() {
    auto container = vega.read_audio();
}

The file loads and nothing plays. The container sits in memory, and you decide what happens next: route it to your speakers with | Audio, or analyse it, or send its numbers somewhere else.

Loading is separate from routing. You can load a file and send it to hardware immediately, or spend the next twenty lines building something before it ever plays.

Expansion 5: Sound, the Container and Its Processor

vega.read_audio() opened the dialog, then decoded the whole file into memory:

  1. Created a reader and checked that the file is readable
  2. Resampled to the engine’s sample rate
  3. Deinterleaved the samples, so each channel is its own array
  4. Created a SoundFileContainer and loaded every sample into it
  5. Set the memory layout to row major
  6. Configured a ContiguousAccessProcessor: the container’s default processor, which knows how to hand out the samples block by block

That processor does the work of access. It:

  • Sets its output size to one buffer’s worth of samples, for every channel
  • Tracks where in the file you are
  • Auto-advances: each time it is asked, it moves forward
  • Gives channels separately, so each can be processed on its own

The processor is why a container is more than a data holder. It has built in logic for how it should be consumed.

Expansion 6: Sound, Per-Channel Buffers

| Audio hooked the container to buffers. By hand:

auto container = vega.read_audio();
auto buffers = get_io_manager()->hook_audio_container_to_buffers(container);

That call creates one buffer per channel, and the buffer manager owns them. Inside it is this loop:

auto buffer_manager = get_buffer_manager();

for (uint32_t channel = 0; channel < container->get_num_channels(); ++channel) {
    auto buffer = buffer_manager->create_audio_buffer<Buffers::SoundContainerBuffer>(
        Buffers::ProcessingToken::AUDIO_BACKEND, channel, container, channel);
    buffer->initialize();
}

Step by step, for each channel:

  1. Create a SoundContainerBuffer, a buffer that reads from a container
  2. Tag it with AUDIO_BACKEND: it feeds the sound card at the audio pace
  3. Put it on the output channel with that number
  4. Tell it which channel of the container to read
  5. Initialize it

A stereo file’s left channel feeds output 0 and its right channel feeds output 1. Channels stay separate so that each can later have its own processing.

Every audio cycle, each buffer asks the container’s processor for the next block of samples and passes it on. Get the buffers back later with get_io_manager()->get_audio_buffers(container).

Expansion 7: Sound, the Microphone

input.enabled and input.channels ask the engine to open the sound card’s input before anything runs. The engine then keeps one input buffer per hardware channel and fills it every audio cycle with the samples that arrived.

create_input_listener_buffer(0, true) does this by hand:

auto buffer = std::make_shared<Buffers::AudioBuffer>(0);
register_audio_buffer(buffer, 0);
read_from_audio_input(buffer, 0);
  1. Make an audio buffer for channel 0
  2. Register it on output channel 0, which is the true: it is why you hear it
  3. Register it as a listener of input channel 0

Each cycle the input buffer copies its samples into every listener. With false the listener exists and receives sound, but nothing sends it to the speakers.

If you leave input disabled there is no input channel to listen to, and the call logs an error that the channel is out of range.

This is the one sound example with no container. Live sound has no whole to hold: it exists one block at a time, and the buffer is all there is.

Expansion 8: Settings, in Code and in a File

Everything in this card ran on the engine’s defaults: 48 kHz, 512 frames per block, stereo output and 60 frames per second. settings() is where you change them.

settings() is a function in your src/user_project.hpp. It runs before the engine starts, and what you set there is fixed for the life of the program. By the time compose() runs the engine is already going, and changing a setting then logs a warning and has no effect. That is why the microphone needs settings(): the sound card’s input is opened when the engine starts, so it has to be asked for beforehand.

Settings come in five sections:

  • stream: sample rate, block size, and the input and output channels and devices
  • graphics: frame rate, the window surface, which GPU to use, the default font
  • input: MIDI, OSC and other devices
  • network: UDP and TCP
  • journal: how much the engine tells you, and where

In code:

void settings() {
    auto& stream = Config::get_global_stream_info();
    stream.buffer_size = 256;
    stream.input.enabled = true;
    stream.input.channels = 1;

    Config::get_global_graphics_config().target_frame_rate = 60;
}

The same settings in a file, mayaflux.json, in your project’s source folder:

{
  "stream": {
    "buffer_size": 256,
    "input": { "enabled": true, "channels": 1 }
  },
  "graphics": {
    "target_frame_rate": 60
  }
}

The file is read every time the program starts, so changing it needs no rebuild. The launcher picks up mayaflux.json by itself when it exists, so settings() can stay empty. The keys are the names of the config fields, and any field you leave out keeps its default.

Do not confuse "stream" and the "input" inside it, which is your sound card’s input, with the top level "input" section, which is for MIDI, OSC and other devices.

Choosing the file. To use a file from somewhere else, start the program with --config path/to/file.json.

Which one wins. The file is read before settings(), so when both set the same value, settings() wins. Start the program with --config-override to turn that around: the file is then read after settings() and replaces all of it, so anything the file does not mention goes back to its default.

Reading settings back. Once the engine is running you can read what was set:

void compose() {
    uint32_t rate = Config::get_sample_rate();
    uint32_t block = Config::get_buffer_size();
    uint32_t channels = Config::get_num_out_channels();
}

Logging is the one exception to the rule that settings are fixed at start: the journal settings take effect at once.

docs/Settings.md lists every field.

Expansion 9: Image, Pixels in a TextureBuffer

vega.read_image() decoded the file into pixels with four channels: red, green, blue and alpha. Common formats are eight bits per channel. An .exr file stays floating point. The pixels went into a TextureBuffer, which also holds a flat rectangle for the picture to be drawn on.

An image never becomes a container. It goes straight to a buffer, because a picture is one frame. By hand:

IO::ImageReader reader;
reader.open("path/to/image.png");
auto image = reader.create_texture_buffer();

| Graphics is then register_graphics_buffer(image). It initializes the buffer on the GPU and adds it to the buffers the engine processes each frame.

At that point the buffer exists and is processed, but nothing draws it, because nothing says where.

Expansion 10: What `setup_rendering` Does

setup_rendering says where. It is the line that turns a buffer holding a picture into a picture on screen. With the defaults written out:

image->setup_rendering({
    .target_window = window,
    .fragment_shader = "texture.frag.spv",
});

The TextureBuffer already carries its defaults: the vertex shader texture.vert.spv, the fragment shader texture.frag.spv, and a texture binding named texSampler. What setup_rendering does with them:

  1. Keeps the shaders, using yours where you give them
  2. Records the window the buffer draws to
  3. Declares the texture binding on the render pipeline
  4. Binds the buffer’s picture to that binding
  5. Adds a render processor to the buffer’s processing chain, aimed at the window

A TextureBuffer draws as a triangle strip and ignores any other topology. There is no default window: you choose one, and that is the second line.

The same call, with the same meaning, appears on every buffer that draws. MeshBuffer and MeshNetworkBuffer have their own, and the video buffer uses the TextureBuffer one.

Expansion 11: Model, One Buffer per Mesh

vega.read_mesh() read every mesh in the file. By hand:

const auto folder = std::filesystem::path("path/to/model.glb").parent_path();
auto resolver = [folder](const std::string& name) {
    return IO::ImageReader::load_texture((folder / name).generic_string());
};

IO::ModelReader reader;
reader.open("path/to/model.glb");
auto meshes = reader.create_mesh_buffers(resolver);
reader.close();

for (auto& mesh : meshes) {
    register_graphics_buffer(mesh);
}
  1. The reader imports the file, triangulating faces and generating normals when the file has none
  2. Each mesh becomes a MeshBuffer holding its vertices, with position, normal, tangent, texture coordinates and color, and its triangle indices
  3. The resolver finds each mesh’s diffuse texture next to the model, and the texture is bound to that buffer when it is found
  4. Registering each buffer is what | Graphics does on the group

setup_rendering then adds the render processor, with depth testing on so nearer surfaces hide farther ones. It picks the textured shader when the mesh has a texture and the plain one when it does not.

Expansion 12: Model as a Network

vega.read_mesh_network() read the same file, but kept its parts together. Each mesh became a slot in a MeshNetwork: a name, a node holding that mesh’s geometry, a transform of its own, and the diffuse texture when one was found.

| Graphics registered the network as a visual network, one the engine advances every frame, after switching it to graphics output if it was not already. A network has no way to draw itself, because drawing needs a buffer, so something still has to make one. By hand:

register_node_network(network, Nodes::ProcessingToken::VISUAL_RATE);

auto form = std::make_shared<Buffers::MeshNetworkBuffer>(network);
register_graphics_buffer(form);
form->setup_rendering({ .target_window = window });

vega.mint does that whole sequence: it registers the network if it is not registered, wraps it in a MeshNetworkBuffer, and sets up rendering aimed at the window you give it. Then it returns the real buffer. That is why the window goes inside the StructureConfig::Model and not in a separate line.

The network buffer draws every slot with its own transform, and uses each slot’s texture when it has one.

Expansion 13: Video, a Pair of Containers

choose_video returns a pair: the video container first, the audio second. Destructuring it with auto [video, audio] gives each half a name. With a path instead of the dialog:

auto [video, audio] = get_io_manager()->load_video(
    "path/to/film.mp4", { .video_options = IO::VideoReadOptions::EXTRACT_AUDIO });

What that load does:

  1. Checks the file is readable
  2. Applies the options: video options, audio options, and the size to decode at
  3. When EXTRACT_AUDIO is set, asks for audio at the engine’s sample rate
  4. Opens the file and registers the reader with the IO manager, which gives it an id the decode machinery uses to find it
  5. Creates a VideoFileContainer and loads it
  6. Configures its processor: a FrameAccessProcessor that auto-advances at your project’s frame rate
  7. When audio was asked for, takes the audio track, configures it as for any sound file, and returns it as the second half

The video container holds the film’s frames and information about them. The processor advances through the frames, a little each graphics cycle, scaled by the file’s own frame rate against the engine’s, so the picture moves at the speed the file was made for.

The audio is also kept by the IO manager, keyed by its video: get_io_manager()->get_extracted_audio(video) finds it later.

Expansion 14: Video, the Buffer and the Hook

video | Graphics hooks the picture and, when the file has sound you asked the reader to extract, hooks that sound for playback too. By hand, for each:

auto picture = get_io_manager()->hook_video_container_to_buffer(video);
picture->setup_rendering({ .target_window = window });

auto buffers = get_io_manager()->hook_audio_container_to_buffers(audio);

The last line is audio | Audio. This fragment assumes the film has a soundtrack, as in the video-with-sound block. If the file has none, load_video returns an empty audio result and no audio buffer is produced. Afterwards get_associated_buffer(video) finds the picture’s buffer, and get_associated_buffers(video) finds the sound’s, one per channel.

hook_video_container_to_buffer made a VideoContainerBuffer, which is a TextureBuffer that copies the current frame into its texture each cycle. That is why setup_rendering is the same line as for an image. When the container reaches its end the buffer removes itself, so the video plays once.

One pipe registers both, but they are still two objects. Nothing in this card ties their clocks together.

Expansion 15: Why the Video Is Wired by Hand

A recording only belongs to audio, and a picture only belongs to graphics, so one | is enough for each. A video file belongs to both. A shortcut would have to guess where the picture and the sound should go, and a wrong guess is invisible until it sounds or looks wrong.

So here you write both lines. Wiring by hand is not the hard way. It is the way that shows what the machinery is doing, and it is the way you keep when you want to send the sound somewhere else.

Expansion 16: Camera, the Same Hook for a Live Source

vega.read_camera is the camera version of vega.read_audio and the video steps:

auto camera = vega.read_camera();
auto buffer = get_io_manager()->hook_camera_to_buffer(camera);

The first line asks which camera and which mode, and opens it. It gives back the camera itself, the way vega.read_audio gives back the sound, and nothing is shown yet. The second hooks it to a buffer, and camera | Graphics does the same, after which get_associated_buffer(camera) finds the buffer. That is the same kind of hook as hook_video_container_to_buffer, so what you get back is the same kind of buffer and the same setup_rendering line applies. The buffer is already registered, so there is no | Graphics.

The camera is opened through FFmpeg. A camera has no file and no end: frames arrive as the device makes them, and a separate thread decodes one when the graphics cycle asks for it, so the device never holds up drawing. The mode you choose is a request, and the device falls back to the nearest one it accepts, so it may give something else. Frames arrive as four channels: red, green, blue and alpha.

To skip the windows, pass a CameraConfig with the device name: vega.read_camera({ .device_name = "/dev/video0" }). /dev/video0 is the first camera on Linux. On macOS use "0", and on Windows use "video=Integrated Camera" or the name your camera reports. choose_camera(false) skips only the mode window. If you close a window or the device cannot be opened, the call returns nothing.

A camera gives picture only. Its sound counterpart is the microphone.

Expansion 17: Every Choice the Dialog Was Making for You

vega.read_audio(), read_image() and read_mesh() with no argument open the dialog and load what you choose. With a path they skip it.

choose_video is the dialog form for video. It takes the options for loading: an empty {} is the defaults, and EXTRACT_AUDIO asks for the sound as well. Its path form is get_io_manager()->load_video(path, options), shown above.

If you cancel, nothing is loaded and the call returns an empty result. The examples in this card assume you chose a file.

The Fluent vs. Explicit Comparison

Fluent (What happens behind the scenes)

vega.read_audio() | Audio;

This single line loads the file into a container, creates a buffer for every channel, and registers them with the sound card. Nothing plays until the | Audio, which is when the connection happens.

Explicit (What’s actually happening)

auto container = vega.read_audio();
auto buffers = get_io_manager()->hook_audio_container_to_buffers(container);

Understanding the difference:

  • The fluent version loads and hooks in one line
  • The explicit version separates the two, so you can inspect or change the container before it is hooked
  • With a chosen, readable file, both create the same per-channel playback buffers

Recap

What You Achieved

You have:

  • Brought in a sound, an image, a model and a video as they are
  • Seen that loading and routing are separate steps, and why the video is wired by hand
  • A container for large data and a buffer for one step of it
  • A domain for each result: audio at the sound card’s pace, graphics once per frame
  • setup_rendering, the line that gives a buffer a window

Everything here ran the data as it is. In the next section the sound stops being a whole file. You will pick a region of it, play it faster or backwards, and let several readings of the same recording sound at once.


Many Readings of One Sound

The Next Step

In the last section a recording played from start to end. Here the same recording becomes something you read from: any region of it, as many times at once as you like, at any speed and in either direction.

A tape machine has one head and a motor. The head sits at one position, the motor moves the tape at one speed, and turning around means stopping the spool. A recording in memory has no head and no motor. It is a list of numbers, and every voice that reads it can have a position that is any function of time. That is what separates the first half of this section, which a patient person with enough machines could do, from the second half, which they could not.

Each block below is a complete compose(). A file dialog opens: choose a sound file at least three seconds long. To skip the dialog, give the first argument a path, for example create_sampler("path/to/file.wav").

A Region of a Sound

void compose() {
    const auto rate = Config::get_sample_rate();

    auto sampler = store(create_sampler());

    sampler->play_continuous(0, sampler->slice_from_range(0, rate - 1));
    sampler->play_continuous(1, sampler->slice_from_range(rate, rate * 5 / 2 - 1, 1));
}

Run this code. Two loops of the same recording play together: the first second, and the second and a half after it. One is a second long and the other a second and a half, so they drift in and out of step and only line up again every three seconds.

Taps on a Sound

void compose() {
    auto taps = create_tap_set()
        .tap().speed(1.0).on_channel(0)
        .tap().enters_after(2.0).speed(1.5).level(0.8).on_channel(1)
        .tap().enters_after(4.0).speed(2.0).level(0.6).on_channels({ 0, 1 })
        .start();

    store(taps);
}

Run this code. The recording plays on the left. Two seconds later a second reading joins on the right, a fifth higher. Two seconds after that a third reading joins on both sides, an octave higher. They are the same samples, read at different speeds, so each is a different pitch and finishes at a different time.

Change 1.5 to 1.001 and run again: the second reading still enters two seconds later, now at nearly the first reading’s speed.

You have, in each case:

  • One recording, loaded once and held in memory
  • Several independent readings of it, each with its own position
  • All of them mixed into your speakers
Explanations
Expansion 1: What `create_sampler` Does

create_sampler loads a recording for reading from, and gets a sampler ready to play it. Without it:

auto stream = get_io_manager()->load_audio_bounded("path/to/your/file.wav", 48000 * 5, true);

auto sampler = std::make_shared<Kriya::SamplingPipeline>(
    stream, *get_buffer_manager(), *get_scheduler(), 0, Config::get_buffer_size());
sampler->build();
  1. load_audio_bounded reads the file into a DynamicSoundStream, resampled to your project’s sample rate and laid out so any frame can be reached directly
  2. The SamplingPipeline is made with the stream, the engine’s managers, an output channel and the block size
  3. build() supplies the sampler’s buffer to that output channel and starts the pipeline that drives it every audio cycle

The numbers are the ones create_sampler uses by default: 48000 * 5 frames, and true to cut the file off there. That is five seconds at 48 kHz, 2.5 seconds at 96 kHz, or about 5.44 seconds at 44.1 kHz. To request a duration at the current rate, calculate its frame count: create_sampler("file.wav", Config::get_sample_rate() * 20) asks for twenty seconds.

A sampler plays one channel of the file. By default that is the channel with the same number as its output channel, which is channel 0 for a stereo file’s left side. create_sampler(path, frames, true, 1) plays channel 1 on output 1, and create_samplers(path) makes one sampler per channel.

store keeps the sampler alive. A sampler stops sounding when the last handle to it goes away, and the handle in compose() goes away when compose() returns.

Expansion 2: Streams, and Why Many Voices Can Share One

In the last card the sound lived in a SoundFileContainer: one reader walking through it from start to end. A sampler’s sound lives in a DynamicSoundStream. It holds the same samples, but any number of voices can read it at once, because each voice keeps its own position and its own slot.

That is why the card can play two loops of one recording together, and why the tap set can read the same samples three ways. Nothing is copied. The recording exists once.

A sampler and a tap set can even share one stream:

auto sampler = store(create_sampler());
auto taps = create_tap_set_from_stream(sampler->get_stream()).tap().speed(2.0).start();
store(taps);

create_sampler_from_stream and create_tap_set_from_stream take a stream you already have. The forms that load a file open a dialog, or take a path as their first argument, because they have to load first.

Expansion 3: Voices and Slices

Each voice of a sampler is a slice: a region of the stream together with how to play it. slice_from_range(start, end) makes one, and the end frame is included.

A slice carries its own settings:

  • looping and loop_count: whether it repeats, and how many times
  • scale: its level
  • speed: see the next expansion
  • time_map: a function from time to position, in the expansion after that
  • source_channel: which channel of the stream it plays

play_continuous(0, slice) is the short form of three steps:

auto slice = sampler->slice_from_range(0, rate - 1);
slice.looping = true;
sampler->load(0, slice);
sampler->play(0);
  1. load puts the slice in voice slot 0 and gives it its own cursor
  2. play rewinds that cursor to the start of the region and switches the voice on
  3. Every audio cycle, each voice that is on reads its next block, scales it, and adds it to the sampler’s buffer. A voice that is off adds silence

A voice that does not loop switches itself off when it reaches the end of its region. Edits to a playing voice are picked up at the next block: sampler->slice(0).looping = false;.

Expansion 4: What `speed` Means on a Voice

A plain voice reads one block of the recording, then moves its position forward by one block times speed. At speed = 1.0 that is exactly one block, so it plays the recording as it is.

At any other speed it skips or overlaps blocks. Above 1.0 it jumps over part of the recording. Below 1.0 each block starts before the previous one has finished, so stretches are heard twice. What you hear is not a change of pitch but a rhythmic skipping. Speed must also be above zero, so a plain voice cannot play backwards.

If you want the pitch to change, or to read backwards, give the voice a time map instead. That is what the tap set does.

Expansion 5: Time Maps

A time map is a function from the seconds since a voice started to a position in the recording, in frames. A voice with a time map no longer steps through blocks. Each audio cycle it works out where the map says it should be at the start and at the end of the block, and reads every frame in between, interpolating between neighbouring frames. Because it reads frames at a different rate than they were recorded, the pitch changes, and a map that runs backwards plays backwards.

A voice at half speed, looping a two second region:

#include "MayaFlux/Kinesis/Tendency/TimeMap.hpp"

void compose() {
    const auto rate = Config::get_sample_rate();

    auto sampler = store(create_sampler());
    sampler->play_continuous(0, sampler->slice_from_range(0, rate * 2 - 1)
        .with_time_map(Kinesis::TimeMaps::quadratic(0.0, rate * 0.5, 0.0)));
}

The map is quadratic(start, velocity, acceleration): it starts at frame 0 and moves at half the recording’s own rate, which is rate * 0.5 frames per second. The factories in Kinesis::TimeMaps:

  • linear(from, to, duration): a straight path, then stays
  • quadratic(start, velocity, acceleration): constant acceleration, so a speed that rises or falls
  • triangle(lo, hi, velocity, start): back and forth between two positions
  • piecewise_linear(points, duration): through a list of positions, such as a hand drawn curve
  • exponential(from, to, duration): a constant ratio each step, for values that shrink or grow geometrically

A map replaces speed. While a voice has a map, loop_count is ignored.

Expansion 6: What a Tap Is

A tap is a voice with a time map that moves at a speed you can change while it plays. Without the builder, one tap looks like this:

#include "MayaFlux/Kinesis/Tendency/TimeMap.hpp"

void compose() {
    const auto rate = static_cast<double>(Config::get_sample_rate());

    auto sampler = store(create_sampler());
    auto velocity = std::make_shared<double>(1.5 * rate);

    sampler->play_continuous(0, sampler->slice_from_range(0, static_cast<uint64_t>(rate * 4.0) - 1)
        .with_time_map(Kinesis::TimeMaps::integrated(0.0, velocity)));
}

integrated adds up a velocity as time passes, and reads that velocity from a value you keep. Write *velocity = rate * 0.5; while it plays and the voice slows from where it is, with no jump. Zero holds it still, and a negative number plays it backwards.

create_tap_set does this for every tap you declare, and keeps the velocities for you:

  • tap() begins a new tap, and the calls that follow describe it
  • speed(ratio), with a negative ratio meaning backwards, and backward() for reading from the end of the region
  • enters_after(seconds), level(gain), on_channel(n) and on_channels({ 0, 1 })
  • region(start_frame, end_frame), which applies to every tap and uses an exclusive end frame (unlike slice_from_range), and loop(false) to play once

Taps loop by default. After start() the set can still be changed:

taps.level(1, 0.3);
taps.speed(0, 0.5);
taps.stop();

speed(tap, ratio) carries on from where the tap is, so it never jumps. stop() silences every tap, including ones that have not entered yet.

One sampler is made for each output channel a set uses, and each tap is a voice on it. A mono file plays on whichever output a tap is given.

Expansion 7: Why Speed Is Pitch, and Why 1.001 Drifts

A tap reads the recording by interpolating between frames, so reading it faster or slower changes the pitch. A speed of 1.5 is a frequency ratio of 3 to 2, a fifth. A speed of 2.0 is an octave.

Two taps at 1.0 and 1.001 read the same samples a thousandth apart in speed. In the example above, the second enters two seconds after the first, so the readings start at different places in the recording. From then on, their positions drift by a thousandth of a second per second. How that sounds depends on the recording.

Expansion 8: How Output Mixing Sets the Level

Voices in one sampler add together inside its buffer; taps assigned to the same output channel share a sampler. By default the output channel divides its mix by the number of active buffers feeding it. Adding another voice to the same sampler does not increase that divisor. Adding a separate sampler buffer can make the existing buffers quieter.

To turn that off for the two stereo channels:

set_mix_normalization_for_channels({ 0, 1 }, false);

With it off, buffers add at full level. Lower a voice’s or tap’s level to keep the total under control; normalization by buffer count does not prevent several voices within one buffer from adding up.

Expansion 9: Stutter, a Repeat That Changes Length

repeat_every(seconds) restarts a tap from the start of its region every so many seconds, so a long recording becomes a short loop. Given a time map instead of a number, the length is read at the start of each repeat. A length that shrinks gives a stutter.

Each repeat restarts the tap’s own clock, and the cut between repeats is exact to the frame, not the block.

.repeat_every(Kinesis::TimeMaps::exponential(0.5, 0.03, 8.0))

This repeat starts at half a second and shrinks by the same ratio every step until it is 30 milliseconds, reached after eight seconds. Because each repeat is shorter than the one before, the sound speeds up into a buzz. linear(0.4, 0.05, 6.0) shrinks by the same amount every step instead, a more even slowing of the bounces.

A tap with lag ignores repeat_every and speed. A lagged tap trails the write head of a recording that is still being made, as in the last example of this section.

Expansion 10: Beyond Tape, Positions Are Functions

On tape, position follows a motor. One head sits at one place, the speed changes pitch and length together, turning around means stopping and reversing the spool, a canon needs one machine per voice, and an echo needs a second head at a fixed distance. A sound that is still happening cannot be played back until it has finished and been rewound.

In memory there is no head and no motor. A voice with a time map has a position that is whatever function of time you give it, returning a frame number. That is the whole mechanism, and it is why the examples above are possible:

  • A sine gives a smooth reversal, with no stop of the spool. The sound slows to a halt at the turn and glides away again
  • A list of positions, such as the random walk, gives any path you can compute or measure. The list can come from anywhere
  • A different function on each voice gives each voice its own time

You write the function directly, as a Kinesis::TimeMap:

Kinesis::TimeMap { .fn = [](const double& t) -> double { return 48000.0 * t; } }

t is the seconds since the voice started and the result is a position in frames. This one is just normal speed at 48 kHz. Every factory in Kinesis::TimeMaps is a function like this one.

A tap’s map can be replaced while it plays: taps.slice(0).with_time_map(map). Setting a new map restarts that voice’s clock at zero.

The live case. create_ring(8.0) makes a stream that keeps only the most recent eight seconds of whatever is written into it, overwriting the oldest, and record_into writes a live signal into it every audio cycle. A tap with lag reads that ring a certain distance behind the write head, and because the lag is a time map too, the distance itself can change. A lag that grows is a tap falling behind, so it plays slower and lower. A lag that swings is a pitch that wobbles. A lagged tap stays at least two blocks behind the head, so it never reads what is being written.

ring->snapshot(frames) copies the most recent frames into a new stream, and any tap set can read it. That is the fork: a stretch of the past, taken out of the flow and given to a voice that keeps going without it.

Try It → Recap

A Bouncing Ball

A recording that bounces, speeds up, and turns to a buzz, with a second reading running backwards behind it:

#include "MayaFlux/Kinesis/Tendency/TimeMap.hpp"

void compose() {
    const auto rate = Config::get_sample_rate();

    auto taps = create_tap_set()
        .region(rate / 2, rate * 3 / 2)
        .tap().speed(1.0).on_channel(0)
            .repeat_every(Kinesis::TimeMaps::exponential(0.5, 0.03, 8.0))
        .tap().enters_after(3.0).speed(0.5).backward().level(0.7).on_channel(1)
            .repeat_every(Kinesis::TimeMaps::exponential(0.4, 0.05, 8.0))
        .start();

    store(taps);
}

Run it. Both taps read the same one second of the file, from half a second in. The left bounces forward and the right bounces backward at half speed. Each repeat is shorter than the one before, so they both accelerate toward a buzz.

Then change one thing at a time:

  • The three numbers in exponential: start length, end length, and seconds to get there. Try 0.8, 0.01, 20.0 for a slow collapse.
  • .region(...): move the one second window to another part of the file.
  • exponential to linear: the bounces slow evenly rather than by ratio.
  • Delete .backward(): both taps now run forward, at different speeds.

Beyond the Tape

Everything above a tape music studio could do with enough machines and enough patience. What follows it could not. A motor cannot follow an equation, a spool cannot wander, and a recorder cannot play back a sound that has only just happened and is still going on.

A Playhead That Obeys an Equation

#include "MayaFlux/Kinesis/Tendency/TimeMap.hpp"

void compose() {
    const auto rate = static_cast<double>(Config::get_sample_rate());
    const double centre = rate * 2.0;
    const double reach = rate * 1.5;

    auto taps = create_tap_set(Config::get_sample_rate() * 10)
        .tap().on_channel(0)
        .tap().on_channel(1)
        .start();

    taps.slice(0).with_time_map(Kinesis::TimeMap {
        .fn = [centre, reach](const double& t) -> double { return centre + reach * std::sin(t * 0.50); } });
    taps.slice(1).with_time_map(Kinesis::TimeMap {
        .fn = [centre, reach](const double& t) -> double { return centre + reach * std::sin(t * 0.75); } });

    store(taps);
}

Run it. Each channel’s playhead swings along a sine curve across the three seconds of the file from half a second to three and a half seconds in. At each end of the swing it slows to a stop, reverses, and speeds up again, so the sound glides to a standstill, turns around, and glides back up through its pitch. The right side swings at three halves the rate of the left. Their positions cross more often, while the full pair of motions repeats about every twenty-five seconds.

Then change one thing at a time:

  • 0.50 and 0.75: how fast each side swings. Try 0.50 and 0.5003 for a slow phase shift.
  • reach: how far it swings. A wider swing is a faster one, and a higher pitch at the centre.
  • The equation itself: std::sin(t * 0.5) * std::sin(t * 0.13) is two swings multiplied, an irregular one that never quite repeats.

A Drunken Playhead

The same idea, with a path that nobody wrote:

#include "MayaFlux/Kinesis/Tendency/TimeMap.hpp"

void compose() {
    const auto rate = static_cast<double>(Config::get_sample_rate());

    std::vector<double> path { rate * 2.0 };
    for (int step = 0; step < 400; ++step) {
        path.push_back(std::clamp(path.back() + get_uniform_random(-0.2, 0.2) * rate, rate * 0.5, rate * 3.5));
    }

    auto taps = create_tap_set(Config::get_sample_rate() * 10).tap().start();
    taps.slice(0).with_time_map(Kinesis::TimeMaps::piecewise_linear(path, 120.0));

    store(taps);
}

Run it. The path is a random walk: 400 steps, each moving up to a fifth of a second forward or back from the last, kept between half a second and three and a half seconds. The playhead follows it over two minutes, wandering forward and backward through the recording at speeds that are never the same twice. Run it again and it is a different walk.

Forking the Past

This one needs a microphone, headphones, and the input switched on as in the first section. A tape machine can play a sound back only after it has finished and been rewound. This reads a sound that is still going on:

void compose() {
    auto ring = create_ring(8.0);
    auto recorder = record_into(ring, Kriya::BufferOperation::capture_input_from(get_buffer_manager(), 0));

    auto echoes = create_tap_set_from_stream(ring)
        .tap().lag(0.4)
        .tap().lag(Kinesis::TimeMaps::linear(0.3, 6.0, 40.0)).level(0.6)
        .tap().lag(Kinesis::TimeMaps::triangle(1.0, 5.0, 0.2, 1.0)).level(0.4)
        .start();

    auto forks = std::make_shared<std::vector<Kriya::TapSet>>();

    auto window = create_window({ "Fork the past", 800, 600 });
    window->show();

    on_key_pressed(window, IO::Keys::C, [ring, forks]() {
        auto held = ring->snapshot(static_cast<uint64_t>(3.0 * ring->get_sample_rate()));
        if (held) {
            forks->push_back(create_tap_set_from_stream(held).tap().start());
        }
    });

    store(recorder);
    store(echoes);
    store(forks);
}

Run it and speak or play. You hear yourself live, and three readings of your own past trail behind you. One is a short echo, 0.4 seconds behind. One falls further behind as it goes, from 0.3 seconds to 6, so it sounds lower and lower. One swings between 1 and 5 seconds behind, so its pitch wobbles.

Press C. The last three seconds are copied into a new voice that loops on its own, even after you have gone quiet. Press it again for another. The past of a live sound has become material you can keep, and you can fork as many as you like.

What You Achieved

You have:

  • Loaded one recording into a stream that many voices can read at once
  • Played regions of it as loops, in step or out of step
  • Read it at different speeds and in both directions, with the pitch following the speed
  • Made a repeat that shrinks, with a second reading running backward behind it
  • Given a playhead an equation and a random walk to follow, instead of a motor
  • Read a live sound a moment after it happened, and forked its past into a voice of its own

Everything here treated the recording as something to read. In the next section it is cut into grains, each grain is measured, and the measurements decide the order they play in.


Cut, Measure, Recompose

The Next Step

A recording is a sequence in time, and the order it was made in is only one of many. Cut it into hundreds of small grains, measure each grain in whatever way you care about, and the recording becomes a population you can order, thin out, thicken and rebuild.

By hand this is a great deal of arithmetic: windows and hops, a measurement per window, a sort, and the stitching of everything back into audio without clicks. Here you choose three things. How big a grain is, what to measure, and which way to order.

Each block below is a complete compose(). A file dialog opens when you run it. Choose a recording a minute or more long if you can, since the effects are about how a whole recording is rearranged.

Quiet to Loud

#include "MayaFlux/WorkflowIncludes.hpp"

void compose() {
    auto sound = vega.read_audio();
    const auto grain = Config::get_sample_rate() / 10;

    auto crescendo = Granular::process_to_container(
        sound, AnalysisType::FEATURE,
        Granular::GranularConfig { .grain_size = grain, .hop_size = grain, .feature_key = "rms" },
        "rms");

    crescendo | Audio;
}

Run this code. The recording is cut into tenths of a second, each tenth is measured for loudness, and all of them are laid end to end from the quietest to the loudest. A whole recording becomes one long crescendo, and every moment of it is a real moment from the original.

Add .ascending = false after .feature_key = "rms" and the arc runs from the loudest to the quietest.

Fewer to More Crossings

#include "MayaFlux/WorkflowIncludes.hpp"

void compose() {
    auto sound = vega.read_audio();

    auto config = Granular::GranularConfig {
        .grain_size = 4096,
        .hop_size = 1024,
        .feature_key = "crossings",
        .ascending = true,
        .taper = [](std::span<double> grain) { Kinesis::Discrete::apply_hann(grain); }
    };

    auto analyzer = std::make_shared<StandardEnergyAnalyzer>(
        EnergyAnalyzerConfig { .window_size = 512, .hop_size = 256, .method = EnergyMethod::ZERO_CROSSING });

    auto measure = Granular::AttributeExecutor([analyzer](std::span<const double> grain, const ExecutionContext&) -> double {
        std::vector<Kakshya::DataVariant> input { Kakshya::DataVariant(std::vector<double>(grain.begin(), grain.end())) };
        return extract_scalar_energy(analyzer->analyze_energy(input), "mean_energy");
    });

    auto dawn = Granular::process_to_container(
        sound, measure, config, Granular::GranularOutput::CONTAINER_ADDITIVE);

    dawn | Audio;
}

Run this code. This time you wrote the measurement yourself: how often each grain crosses zero. The grains are ordered from fewer crossings to more, and each overlaps the next by three quarters under a soft window. The taper softens the joins, though reordering can still be audible. Higher crossing rates can come from high pitches or noise, so what you hear depends on the recording.

Turbulence

#include "MayaFlux/WorkflowIncludes.hpp"

void compose() {
    auto sound = vega.read_audio();

    auto cloud = Granular::process_to_container(
        sound, AnalysisType::STATISTICAL,
        Granular::GranularConfig { .grain_size = 1536, .hop_size = 384, .feature_key = "variance", .ascending = false },
        "variance",
        Granular::GranularOutput::CONTAINER_ADDITIVE);

    cloud | Audio;
}

Run this code. Each grain is measured by how much its sample values vary, and those with the highest variance come first. There is no taper, and most output samples contain four overlapping grains, so their values add together. The result can be dense and loud, but its level depends on the source and on how overlapping waveforms combine. Variance alone does not tell you how agitated a sound feels.

You have, in each case:

  • A recording cut into grains, with a number measured for each
  • A new order, chosen by that number
  • A new recording, built from the old one, that plays through your speakers
Explanations
Expansion 1: The Four Steps

A granular call does four things in a row:

  1. Segment: cut the recording into grains of grain_size frames, starting a new one every hop_size frames
  2. Attribute: measure each grain and write the number onto it under the name feature_key
  3. Sort: order the grains by that number, lowest first or highest first
  4. Reconstruct: write the grains out in that order as a new recording

process_to_container runs all four. Granular::process runs the first three and gives you the ordered list, so you can look at it:

#include "MayaFlux/WorkflowIncludes.hpp"

void compose() {
    auto sound = vega.read_audio();
    auto config = Granular::GranularConfig { .grain_size = 4800, .hop_size = 4800, .feature_key = "rms" };

    auto grains = Granular::process(sound, AnalysisType::FEATURE, config, "rms");

    for (size_t i = 0; i < 4 && i < grains.data.regions.size(); ++i) {
        auto onset = grains.data.regions[i].start_coordinates[0];
        auto rms = grains.data.regions[i].get_attribute<double>("rms");
        std::cout << "grain at frame " << onset << ", rms " << rms.value_or(-1.0) << "\n";
    }
}

This prints the four quietest grains: where each came from in the original, and how loud it was. The ordered list is the whole composition. Reconstruction only reads it out.

Expansion 2: What a Grain Is

A grain is a region of the recording. It starts at a frame, called its onset, and covers grain_size frames across every channel. It carries its measurements as named attributes: the onset, and whatever you measured under feature_key.

Segmentation makes whole grains only. The last stretch that is too short for a full grain is dropped. With the default hop of half a grain, every sample lands in two grains. With hop_size equal to grain_size every sample lands in exactly one.

Grains are measured on one channel, channel in the config, which is 0 by default. The grains themselves are moved with all their channels together, so a stereo recording stays stereo.

Expansion 3: Choosing What to Measure

There are two ways to measure.

A named measurement. Pass an AnalysisType and a name. AnalysisType::FEATURE with "rms" is the loudness of each grain. AnalysisType::STATISTICAL with "variance" is how much its samples vary. The number is written on each grain under feature_key, and the sort reads that same name.

Your own measurement. Pass an AttributeExecutor: a function that receives one grain’s samples and returns one number. The second block above measures zero-crossing rate. The analyzer is made once, before the grains are measured, with its settings named, and the function reuses it for every grain. Change the method and the order changes:

auto analyzer = std::make_shared<StandardEnergyAnalyzer>(
    EnergyAnalyzerConfig { .window_size = 512, .hop_size = 256, .method = EnergyMethod::ZERO_CROSSING });

window_size and hop_size are how the analyzer walks along the grain. With ZERO_CROSSING, extract_scalar_energy(analysis, "mean_energy") takes the average zero-crossing rate across those windows; mean_energy is the analyzer’s generic field name. Other names it understands are "max_energy", "min_energy", "variance" and "dynamic_range". Zero crossings count how often the signal changes sign. They can rise with pitch or noise; they are not a reliable measure of brightness on their own. EnergyMethod::RMS measures signal level. Any function you can write over a span of samples will do.

The measure changes the order of the grains. RMS orders by level, zero crossings by sign changes, and variance by the spread of sample values. How each ordering sounds depends on the recording.

Expansion 4: Ordering

ascending = true puts the lowest measurement first. false puts the highest first. That one switch reverses the whole arc.

The sort runs on the CPU by default. Set gpu_sort_threshold to a grain count, and any call with at least that many grains sorts on the GPU instead:

Granular::GranularConfig { .grain_size = 512, .hop_size = 256, .feature_key = "rms", .gpu_sort_threshold = 1024 }

A minute of audio cut into grains of 256 frames is more than ten thousand of them, which is where the GPU path pays for itself. The default of 0 means never.

Expansion 5: Two Ways to Write the Grains Out

Concatenative output (CONTAINER, STREAM) places the grains end to end, with no overlap. hop_size then only decides which parts of the recording are cut, and a hop smaller than the grain repeats material. This is the first block above: tenth-second grains, each used once, in a new order.

Overlap-add output (CONTAINER_ADDITIVE, STREAM_ADDITIVE) places grain number i at i times hop_size and adds the grains together where they overlap. The second and third blocks use it. The grain taper decides how the overlaps sound:

  • A Hann window, Kinesis::Discrete::apply_hann, fades each grain in and out smoothly, reducing abrupt edges at the overlaps
  • A trapezoid, Kinesis::Discrete::apply_trapezoid(grain, grain.size() / 8), keeps the body of each grain and fades only the edges, which keeps more of the grain’s own character
  • No taper at all sums the grains raw. Where they overlap the level builds up, which is what makes the third block a saturated cloud rather than a smooth one

With a hop of a quarter of the grain, as in the second block, each moment of the output is four grains laid over each other.

Expansion 6: A Container, or a Stream

process_to_container returns a SoundFileContainer, the same kind of thing vega.read_audio() gives you. | Audio plays it from start to end, exactly as in the first card.

process_to_stream returns a DynamicSoundStream, the same kind of thing the samplers of the last card read from. That makes everything from that card available to the result:

auto stream = Granular::process_to_stream(
    sound, AnalysisType::FEATURE,
    Granular::GranularConfig { .grain_size = grain, .hop_size = grain, .feature_key = "rms" },
    "rms");

auto sampler = store(create_sampler_from_stream(stream, 0));
sampler->play_continuous(0, sampler->slice_from_stream());

create_sampler_from_stream takes the stream you already have, so no file path is needed. Loop it, read it at any speed or backwards, or give a tap set several readings at once. The next section does that.

Expansion 7: The Whole Configuration

Every field of GranularConfig, with its default:

  • grain_size = 1024, frames per grain
  • hop_size = 512, frames between the starts of grains
  • feature_key = "feature", the name the measurement is written under
  • channel = 0, which channel is measured
  • ascending = true, the order
  • gpu_sort_threshold = 0, grain count at which the sort moves to the GPU, 0 for never
  • attribution_context = ComputationContext::SPECTRAL, the context used to select the attribute rule
  • taper, empty by default, which means no taper

Fields you leave out keep their defaults. Fields you do name have to be written in the order above.

Expansion 8: Changing the Rules

The three first steps are rules in a grammar, not fixed code, and each can be replaced on its own while the others carry on. A rule that cuts at every rising zero crossing, instead of at fixed hops, gives grains that begin where the waveform crosses zero. A second measurement rule can add a roughness number to every grain alongside the first, to use later or to order by.

It is where the workflow stops being a function call and becomes a place to compose: the segmentation, the measure and the order are the three decisions of a piece, and each can be anything.

Expansion 9: Long Recordings Without Waiting

Segmenting and measuring a long recording takes time, and a call that returns a recording makes you wait for it. The asynchronous forms run in the background and hand you the result when it is ready, so something else can play meanwhile:

#include "MayaFlux/WorkflowIncludes.hpp"

void compose() {
    auto sound = vega.read_audio();
    auto window = create_window({ "Granular", 800, 600 });
    window->show();

    auto config = Granular::GranularConfig { .grain_size = 4800, .hop_size = 4800, .feature_key = "rms" };
    auto matrix = store(Granular::make_granular_matrix(ComputationContext::SPECTRAL, Granular::GranularOutput::STREAM));

    struct Playback {
        std::shared_ptr<Kakshya::DynamicSoundStream> stream;
        std::shared_ptr<Kriya::SamplingPipeline> sampler;
    };
    auto playback = std::make_shared<Playback>();

    Granular::process_to_stream_async(
        matrix, sound, AnalysisType::FEATURE,
        [playback](std::shared_ptr<Kakshya::DynamicSoundStream> stream) {
            playback->stream = std::move(stream);
        },
        config, "rms");

    sound | Audio;

    on_key_pressed(window, IO::Keys::Q, [playback] {
        if (!playback->stream) return;
        if (!playback->sampler) playback->sampler = create_sampler_from_stream(playback->stream);
        playback->sampler->play(0, playback->sampler->slice_from_stream());
    });
}

The original plays at once. The worker prepares the rearranged stream in the background. Press Q after it is ready to make a sampler and play it; before then, Q does nothing. The key callback keeps the sampler for later presses.

Try It → Recap

Contrary Motion

One ordering, read in both directions at once, so the sound brightens on one side as it dulls on the other:

#include "MayaFlux/WorkflowIncludes.hpp"

void compose() {
    auto sound = vega.read_audio();
    const auto grain = Config::get_sample_rate() / 10;

    auto stream = Granular::process_to_stream(
        sound, AnalysisType::FEATURE,
        Granular::GranularConfig { .grain_size = grain, .hop_size = grain, .feature_key = "rms" },
        "rms");

    auto taps = create_tap_set_from_stream(stream)
        .tap().speed(1.0).on_channel(0)
        .tap().speed(-1.0).on_channel(1)
        .start();

    store(taps);
}

Run it. The left channel plays the crescendo, quiet to loud. The right channel plays the same crescendo backwards, loud to quiet. They cross in the middle, and then loop. Nothing here is a second recording: it is one ordering of grains and two readings of it.

Then change one thing at a time:

  • .speed(-1.0) to .speed(-0.5): the descending side takes twice as long, so the two cross at a different point every time round.
  • grain: Config::get_sample_rate() / 40 makes the grains a fortieth of a second, and the arc turns to a texture.
  • "rms" to "variance" with AnalysisType::STATISTICAL: order by the spread of sample values instead of RMS level.

The Same Gesture at Three Scales

One crescendo, built three times with grains of different sizes, all sounding together:

#include "MayaFlux/WorkflowIncludes.hpp"

void compose() {
    auto sound = vega.read_audio();
    const auto rate = Config::get_sample_rate();

    for (const double seconds : { 0.05, 0.2, 0.8 }) {
        const auto grain = static_cast<uint32_t>(rate * seconds);

        auto stream = Granular::process_to_stream(
            sound, AnalysisType::FEATURE,
            Granular::GranularConfig { .grain_size = grain, .hop_size = grain, .feature_key = "rms" },
            "rms");

        store(create_tap_set_from_stream(stream).tap().level(0.5).on_channels({ 0, 1 }).start());
    }
}

Run it. Each of the three layers rises from quiet to loud over the whole recording, but built from different pieces. With grains of a fiftieth of a second the rise is a fine, smooth texture. With grains of eight tenths of a second it is a staircase of recognisable fragments. All three rise together and so they never quite agree.

Change the three numbers in { 0.05, 0.2, 0.8 } to move the scales, or add a fourth.

What You Achieved

You have:

  • Cut a recording into grains and measured each one
  • Ordered the grains by level, by zero-crossing rate and by variance
  • Chosen whether grains sit end to end or overlap, and how the overlap is windowed
  • Turned the result into a container to play, or a stream to read from many ways
  • Built one gesture at several scales, and one ordering that runs both ways

Everything here rearranged a recording that already existed. In the next section the numbers inside the recording become pixels.


Sound Moves the Picture

The Next Step

A picture is a grid of numbers, and a shader is the small program that decides, for every pixel, which of those numbers to read. Move the place it reads from and the picture moves, bends or tears. Nothing in the picture file changes.

A sound is also a list of numbers. Hand that list to the shader and the sound decides where each pixel looks. The picture is a still, and the sound is what moves it.

Each block below is a complete compose(), with the shader that goes with it. Save each shader in the data/shaders folder of your project, under the name written above it. The code asks for it by that file name alone. The program looks for data/shaders in the folder you run it from, and one folder up, so run it from the project folder or its build folder. If the window stays blank or never appears, the shader was not found: look in the terminal for an error about failing to read the shader, and check the folder and the name. A file dialog opens for the picture, and another for the sound.

Rows of Sound

data/shaders/audio_rows.frag

#version 460

layout(location = 0) in vec2 fragTexCoord;
layout(location = 0) out vec4 outColor;

layout(set = 0, binding = 1) uniform sampler2D texSampler;

layout(set = 1, binding = 0) readonly buffer Audio {
    float samples[];
};

void main()
{
    int n = samples.length();
    if (n == 0) {
        outColor = texture(texSampler, fragTexCoord);
        return;
    }

    int row = clamp(int(fragTexCoord.y * float(n)), 0, n - 1);

    vec2 uv = fragTexCoord;
    uv.x += samples[row] * 0.25;

    outColor = texture(texSampler, uv);
}
void compose() {
    auto window = create_window({ "Rows of Sound", 1280, 720 });
    window->show();

    auto picture = vega.read_image() | Graphics;

    auto sampler = store(create_sampler());
    sampler->play_continuous(0, sampler->slice_from_stream().with_looping(true));

    Buffers::ShaderConfig config { "audio_rows.frag" };
    config.bindings["audio_data"] = Buffers::ShaderBinding(1, 0, Portal::Graphics::DescriptorRole::STORAGE);

    auto reader = create_processor<Buffers::DescriptorBindingsProcessor>(picture, config);
    reader->bind_audio_buffer("audio", sampler->get_buffer(), "audio_data", 1, Portal::Graphics::DescriptorRole::STORAGE);
    picture->setup_rendering({ .target_window = window, .fragment_shader = "audio_rows.frag" });
}

Run this code. The sound plays and the picture is pulled sideways by it. The top of the picture reads the start of the sound’s current block, the bottom reads the end, so the whole waveform of the moment is drawn as a ripple running down the picture. Loud passages tear it apart, and in silence it settles back to exactly the picture you chose.

Also: Your Own Voice

The sound can be live. Replace the sampler lines with a listener on the microphone, and bind that instead:

auto microphone = create_input_listener_buffer(0);
reader->bind_audio_buffer("audio", microphone, "audio_data", 1, Portal::Graphics::DescriptorRole::STORAGE);

Speak and the picture bends as you speak. Input has to be switched on before the program starts, which Expansion 8 of the first section shows how to do without recompiling.

Also: A Film or a Camera as the Picture

The picture can move as well. Replace the read_image line with a film or a camera, and leave setup_rendering as the last line, after the reader. | Graphics hooks each one to a buffer:

auto film = choose_video({}).first;
film | Graphics;
auto picture = get_associated_buffer(film);
auto camera = vega.read_camera() | Graphics;
auto picture = get_associated_buffer(camera);

The film plays once. The camera asks which camera and which mode when it starts, and runs for as long as the program does.

Loudness as Weather

data/shaders/ripple.frag

#version 460

layout(location = 0) in vec2 fragTexCoord;
layout(location = 0) out vec4 outColor;

layout(set = 0, binding = 1) uniform sampler2D texSampler;

layout(push_constant) uniform Push {
    float level;
};

void main()
{
    vec2 centre = fragTexCoord - 0.5;
    float dist = length(centre);
    float angle = atan(centre.y, centre.x);

    float swell = level * 2.0;
    float r = dist + swell * 0.08 * sin(dist * 40.0);
    float theta = angle + swell * sin(dist * 8.0);
    float split = swell * 0.05;

    vec2 uv_r = 0.5 + r * vec2(cos(theta - split), sin(theta - split));
    vec2 uv_g = 0.5 + r * vec2(cos(theta), sin(theta));
    vec2 uv_b = 0.5 + r * vec2(cos(theta + split), sin(theta + split));

    outColor = vec4(
        texture(texSampler, uv_r).r,
        texture(texSampler, uv_g).g,
        texture(texSampler, uv_b).b,
        1.0);
}
struct Push {
    float level;
};

void compose() {
    auto window = create_window({ "Loudness as Weather", 1280, 720 });
    window->show();

    auto picture = vega.read_image() | Graphics;
    picture->setup_rendering({ .target_window = window, .fragment_shader = "ripple.frag" });

    auto sampler = store(create_sampler());
    sampler->play_continuous(0, sampler->slice_from_stream().with_looping(true));

    auto block = sampler->get_buffer();
    auto renderer = picture->get_render_processor();
    renderer->set_push_constant_size<Push>();
    renderer->feed("level", [block]() {
        const auto& samples = block->get_data();
        double sum = 0.0;
        for (const double value : samples) {
            sum += std::abs(value);
        }
        return sum / samples.size();
    }, offsetof(Push, level), sizeof(float));
}

Run this code. This time the shader gets one number per frame instead of the whole sound: the average level of the current block. The picture is twisted round its centre and its colours are pulled apart, as far as the sound is loud. Quiet passages leave it nearly still, and loud ones swirl it.

You have, in both cases:

  • A picture that a shader draws
  • A sound that reaches the shader, whole or as one number
  • A picture that only moves while the sound does
Explanations
Expansion 1: What the Shader Does, and Where It Lives

A fragment shader runs once for every pixel of the window. It receives fragTexCoord, the position of that pixel across the picture from 0 to 1, and it returns a colour. The line that matters is:

outColor = texture(texSampler, uv);

texture reads the picture at uv. If uv is fragTexCoord, you get the picture as it is. Add something to uv before the read and each pixel borrows its colour from a different place.

The file name in .fragment_shader is looked up when the program starts. A name ending in .frag is compiled from source then, with no extra build step, and a name ending in .spv is loaded as already compiled. The search looks beside where you run the program in shaders and data/shaders, and the same two names one folder up. A project made from the Weave template keeps its shaders in data/shaders. A name that is found nowhere is logged as an error and the shader is not built, which is how a blank window usually starts.

Expansion 2: Drawing the Picture

vega.read_image() returns a picture that is loaded but not yet on screen. | Graphics registers it, and setup_rendering says where and how to draw it. Both calls matter, in this order:

auto picture = vega.read_image() | Graphics;
picture->setup_rendering({ .target_window = window, .fragment_shader = "audio_rows.frag" });

target_window is the window to draw into. fragment_shader replaces the default shader, which draws the picture as it is. Leave it out and you have the first section again.

When your own shader reads extra data that you attach yourself, as the next expansion does, attach it between these two lines. setup_rendering lets the picture start drawing, so the first frame should already know everything the shader reads.

read_image() loads the whole image by default; pass an optional Kakshya::Region as its second argument when you want a bounded pixel rectangle instead.

Expansion 3: The Sound as a Storage Buffer

A storage buffer is a list of numbers that the shader can read at any position. bind_audio_buffer makes one from a block of sound:

Buffers::ShaderConfig config { "audio_rows.frag" };
config.bindings["audio_data"] = Buffers::ShaderBinding(1, 0, Portal::Graphics::DescriptorRole::STORAGE);

auto reader = create_processor<Buffers::DescriptorBindingsProcessor>(picture, config);
reader->bind_audio_buffer("audio", sampler->get_buffer(), "audio_data", 1, Portal::Graphics::DescriptorRole::STORAGE);

Three names are in play, and they are easy to mix up:

  • "audio_data" is the key in config.bindings. It declares which set and binding the shader reads, here set 1 and binding 0, and the shader says the same: layout(set = 1, binding = 0)
  • "audio" is the name you give this binding, in case you want to remove it later
  • Set 0 belongs to the picture itself. Your own data goes in set 1

Every time the picture is drawn, the latest block of the sound is converted from doubles to floats and copied to the GPU. The shader asks the buffer how long it is with samples.length(), and readonly tells it that it will not write. The block is the buffer size of the engine, 512 samples unless you changed it.

DescriptorRole::STORAGE suits a list that can be any length. DescriptorRole::UNIFORM is for a small fixed block of numbers.

Expansion 4: Where the Block of Sound Comes From

bind_audio_buffer takes any audio buffer. The sampler’s buffer, sampler->get_buffer(), is the block it is playing right now. A microphone listener, create_input_listener_buffer(0), is the block that just arrived. The shader does not know the difference.

A network can be bound too, with bind_network. A resonator, a bell or a string that you have sent to the speakers with | Audio already produces a block of sound every cycle, and that block goes to the shader:

reader->bind_network("bell", bell, "audio_data", 1, Portal::Graphics::DescriptorRole::STORAGE);

A network that produces neither sound nor geometry cannot be bound, and bind_network says so in the log.

Expansion 5: One Number per Frame

A push constant is a handful of numbers sent with every draw, small and fast. The shader declares them, and your program declares the same layout:

layout(push_constant) uniform Push {
    float level;
};
struct Push {
    float level;
};

renderer->set_push_constant_size<Push>();
renderer->feed("level", source, offsetof(Push, level), sizeof(float));

feed takes a function that is called once every frame. Whatever it returns is written at offsetof(Push, level). Return a double and it is stored as a float. Only four or eight bytes are written, so each field gets its own feed. If you add float tilt; to both the shader and C++ Push blocks, it gets a second feed:

renderer->feed("level", [block]() { return mean_level(block); }, offsetof(Push, level), sizeof(float));
renderer->feed("tilt", [state]() { return state->tilt; }, offsetof(Push, tilt), sizeof(float));

Anything that can be a number works as the source: the level of a block, a value a key press changed, the output of a node. Use push constants for a few numbers, and a storage buffer when the shader needs the whole shape.

Expansion 6: Three Ways to Read the Sound

The C++ is the same in every case, and the shader decides what the sound means. Three readings:

By position. The picture’s height is the block’s time. This is the first block:

int row = clamp(int(fragTexCoord.y * float(n)), 0, n - 1);
uv.x += samples[row] * 0.25;

A few points as strengths. Take eight evenly spaced samples and let each set the strength of one sine wave across the picture, a slow wave to a fast one. The sound’s shape becomes a bend with eight layers:

for (int i = 0; i < 8; ++i) {
    float freq = float(i + 1) * 3.14159;
    shift += samples[i * (n / 8)] * sin(uv.y * freq + float(i));
}

Cells with a mind of their own. Cut the picture into a grid. Give each cell its own offset from a hash of its position, and let the sound decide how far the offsets reach and how fine the grid is:

float grid_n = mix(4.0, 14.0, clamp(energy, 0.0, 1.0));
vec2 cell = floor(uv * grid_n);
vec2 cell_rand = hash22(cell) * 2.0 - 1.0;
uv += cell_rand * (0.02 + abs(cell_energy) * 0.12);

hash22 is a small function that turns a cell’s position into two numbers that look random. Here energy is the whole block’s level and cell_energy is a level chosen for that cell; a complete shader computes both. A fuller version also tears some cells off to random places when a strike arrives.

Expansion 7: Rewriting the Picture on the GPU

Everything above leaves the picture alone and changes where it is read. A compute shader can instead write a new picture. This one blends a stack of pictures into one, with a weight for each:

#include "MayaFlux/Yantra/Executors/TextureExecutionContext.hpp"

using namespace MayaFlux::Yantra;
using namespace MayaFlux::Portal::Graphics;

void compose() {
    auto window = create_window({ "Morph", 1280, 720 });
    window->show();

    auto square = Kakshya::Region::image_rect(0, 0, 511, 511);
    auto a = IO::ImageReader::load(std::string("path/to/a.png"), 4, square);
    auto b = IO::ImageReader::load(std::string("path/to/b.png"), 4, square);
    auto c = IO::ImageReader::load(std::string("path/to/c.png"), 4, square);

    const uint32_t width = a->width;
    const uint32_t height = a->height;
    const size_t layer_bytes = a->byte_size();

    std::vector<uint8_t> stack(layer_bytes * 3);
    std::memcpy(stack.data(), a->data(), layer_bytes);
    std::memcpy(stack.data() + layer_bytes, b->data(), layer_bytes);
    std::memcpy(stack.data() + layer_bytes * 2, c->data(), layer_bytes);

    auto& loom = Portal::Graphics::get_texture_manager();
    auto array = loom.create_2d_array(width, height, 3, ImageFormat::RGBA8, stack.data());

    auto layers = std::make_shared<Kakshya::TextureContainer>(width, height, ImageFormat::RGBA8, 3);
    layers->from_image_array(array);

    struct MorphPC {
        uint32_t layer_count;
        uint32_t normalise;
        uint32_t layer_offset;
        uint32_t pad;
    };

    const std::vector<GpuBufferBinding> weights_binding = {
        { .set = 0, .binding = 2, .direction = GpuBufferBinding::Direction::INPUT, .element_type = GpuBufferBinding::ElementType::FLOAT32 },
    };

    auto morph = std::make_shared<TextureExecutionContext>(
        GpuComputeConfig { "texture_array_morph.comp", { 16, 16, 1 }, sizeof(MorphPC) },
        ImageFormat::RGBA8, TextureExecutionContext::OutputMode::IMAGE, 1, weights_binding);

    morph->set_push_constants(MorphPC { .layer_count = 3, .normalise = 1, .layer_offset = 0, .pad = 0 });
    morph->set_binding_data(2, std::vector<float> { 1.0F, 0.2F, 0.6F });

    DataIO input;
    input.container = std::static_pointer_cast<Kakshya::SignalSourceContainer>(layers);
    morph->execute(input, {});

    auto result = vega.TextureBuffer(width, height, ImageFormat::RGBA8) | Graphics;
    result->setup_rendering({ .target_window = window });
    result->set_gpu_texture(morph->get_output_image(0));
}

Choose three pictures at least 512×512 pixels. The reader takes the same 512×512 square from each, so every layer has the same size. To use a larger area, increase the crop’s inclusive end coordinates to fit all three pictures; image_rect(0, 0, 1023, 767) takes 1024×768 pixels when each picture is at least that large. The shader reads one weight per layer from a list of floats and divides by their total, so the weights are proportions and not fixed amounts. Here the first picture is strongest, the third is at about half of it, and the second is faint. Change the numbers and run again to see a different blend.

The weights are just a list of numbers. They could as easily be the energy of a few bands of a sound as three constants.

Try It → Recap

Throw the Paint

data/shaders/splash.frag

#version 460

layout(location = 0) in vec2 fragTexCoord;
layout(location = 0) out vec4 outColor;

layout(set = 0, binding = 1) uniform sampler2D texSampler;

layout(push_constant) uniform Splash {
    float level;
    float x;
    float y;
};

float hash21(vec2 p)
{
    p = fract(p * vec2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

vec2 hash22(vec2 p)
{
    return vec2(hash21(p), hash21(p + 17.0));
}

void main()
{
    const float grid = 32.0;
    vec2 origin = vec2(x, y);
    vec2 cell = floor(fragTexCoord * grid);
    vec2 centre = (cell + 0.5) / grid;

    float away = length(centre - origin);
    float reach = level * 6.0;
    float torn = step(hash21(cell + 91.7), clamp(reach - away, 0.0, 1.0));

    vec2 outward = normalize(centre - origin + vec2(0.0001, 0.00013));
    vec2 flung = outward * hash21(cell + 5.0) * 0.5 * reach;
    vec2 scatter = (hash22(cell + 271.8) - 0.5) * 0.2 * reach;

    vec2 uv = fragTexCoord - torn * (flung + scatter);
    outColor = texture(texSampler, uv);
}
struct Splash {
    float level;
    float x;
    float y;
};

void compose() {
    auto window = create_window({ "Throw the Paint", 1280, 720 });
    window->show();

    auto picture = vega.read_image() | Graphics;
    picture->setup_rendering({ .target_window = window, .fragment_shader = "splash.frag" });

    auto bell = vega.ModalNetwork(8, 110.0, ModalNetwork::Spectrum::INHARMONIC, 4.0) | Audio[{ 0, 1 }];
    bell->set_output_scale(0.25);
    bell->set_coupling_enabled(true);
    for (size_t mode = 0; mode + 1 < 8; ++mode) {
        bell->set_mode_coupling(mode, mode + 1, 0.00004);
    }
    bell->set_mode_coupling(0, 3, 0.00012);
    bell->set_mode_coupling(1, 6, 0.00008);
    auto origin = std::make_shared<glm::vec2>(0.5F, 0.5F);

    auto renderer = picture->get_render_processor();
    renderer->set_push_constant_size<Splash>();
    renderer->feed("level", [bell]() {
        const auto block = bell->get_audio_buffer();
        if (!block) {
            return 0.0;
        }
        double sum = 0.0;
        for (const double value : *block) {
            sum += std::abs(value);
        }
        return sum / block->size();
    }, offsetof(Splash, level), sizeof(float));
    renderer->feed("x", [origin]() { return static_cast<double>(origin->x); }, offsetof(Splash, x), sizeof(float));
    renderer->feed("y", [origin]() { return static_cast<double>(origin->y); }, offsetof(Splash, y), sizeof(float));

    on_mouse_pressed(window, IO::MouseButtons::Left, [bell, origin, window](double x, double y) {
        const auto ndc = normalize_coords(x, y, window);
        *origin = glm::vec2((ndc.x + 1.0F) * 0.5F, (1.0F - ndc.y) * 0.5F);
        bell->set_fundamental(get_uniform_random(60.0, 400.0));
        bell->excite(get_uniform_random(0.5, 1.0));
    });
}

Run it. The picture sits still and silent. Click, and a bell of a new pitch is struck, and a splash is thrown from where you clicked. The bell’s eight modes are linked to their neighbours, so while it rings, energy drifts from the loud low modes into the quieter high ones. set_output_scale(0.25) sets how loud the network is, and the splash measures the same scaled sound, so a quieter bell also means a smaller splash. The picture is cut into thirty-two by thirty-two cells, and the loudness of the bell decides how far the splash reaches. Inside that reach, cells are torn from where they were and flung outward from the click, each by its own distance and in its own slightly different direction. As the bell rings down, the splash reaches less and less, and the cells fall back into the picture.

Nothing is painted and nothing accumulates. The cells are the picture’s own, thrown by the sound.

Then change one thing at a time:

  • 32.0 to 10.0 in the shader: a few large slabs of picture, thrown like boards
  • 32.0 to 120.0: fine spatter, like flicked droplets
  • 4.0 to 12.0: the bell rings much longer, and the splash lingers
  • * 6.0 to * 2.0: a smaller reach for the same sound
  • 0.25 in set_output_scale to 1.0: a much louder bell, and the first splash covers the whole picture
  • 0.00004 to 0.0004: energy moves between the modes ten times faster, and the high modes fill in sooner
  • Delete the set_coupling_enabled(true) line: the modes ring independently, and the tone only dies away

The Red Room

data/shaders/red_room.frag

#version 460

layout(location = 0) in vec2 fragTexCoord;
layout(location = 0) out vec4 outColor;

layout(set = 0, binding = 1) uniform sampler2D texSampler;

layout(push_constant) uniform Push {
    float level;
};

void main()
{
    float swell = clamp(level * 4.0, 0.0, 1.0);

    vec2 uv = fragTexCoord;
    float zigzag = abs(fract(uv.y * 10.0) - 0.5) * 2.0;
    uv.x += (zigzag - 0.5) * swell * 0.2;

    vec3 near = texture(texSampler, uv).rgb;
    vec3 ghost = texture(texSampler, uv + vec2(swell * 0.06, 0.0)).rgb;
    vec3 colour = mix(near, ghost, 0.5 * swell);

    float grey = dot(colour, vec3(0.299, 0.587, 0.114));
    vec3 dull = vec3(grey) * 0.35;
    vec3 flooded = colour * vec3(1.4, 0.35, 0.3);

    outColor = vec4(mix(dull, flooded, swell), 1.0);
}

Use the C++ of Loudness as Weather, with ripple.frag changed to red_room.frag in setup_rendering. Choose a low, slow recording: a drone, a room tone, a voice far away.

Run it. In the quiet the picture is drained to a dull grey and darkened, and it waits. When the sound arrives the picture floods red and green and blue drain out of it, a second image slides out of the first, and every band of the picture shears sideways in a zigzag, like a floor of chevrons. When the sound drops away it goes back to grey, and it does not feel the same as before.

Change 4.0 to 10.0 and a soft sound is enough to flood it. Change 10.0 in uv.y * 10.0 to 3.0 for broad, slow chevrons, or 40.0 for a tight, nervous flicker.

Left Ear, Right Ear

data/shaders/two_axes.frag

#version 460

layout(location = 0) in vec2 fragTexCoord;
layout(location = 0) out vec4 outColor;

layout(set = 0, binding = 1) uniform sampler2D texSampler;

layout(set = 1, binding = 0) readonly buffer Left {
    float ch0[];
};

layout(set = 1, binding = 1) readonly buffer Right {
    float ch1[];
};

void main()
{
    int nl = ch0.length();
    int nr = ch1.length();
    if (nl == 0 || nr == 0) {
        outColor = texture(texSampler, fragTexCoord);
        return;
    }

    vec2 uv = fragTexCoord;
    uv.x += ch0[clamp(int(fragTexCoord.y * float(nl)), 0, nl - 1)] * 0.25;
    uv.y += ch1[clamp(int(fragTexCoord.x * float(nr)), 0, nr - 1)] * 0.25;

    outColor = texture(texSampler, uv);
}
void compose() {
    auto window = create_window({ "Left Ear, Right Ear", 1280, 720 });
    window->show();

    auto picture = vega.read_image() | Graphics;

    auto samplers = create_samplers(48000 * 10);
    store(samplers);
    if (samplers.size() < 2) {
        return;
    }

    for (const auto& sampler : samplers) {
        sampler->play_continuous(0, sampler->slice_from_stream().with_looping(true));
    }

    Buffers::ShaderConfig config { "two_axes.frag" };
    config.bindings["left_data"] = Buffers::ShaderBinding(1, 0, Portal::Graphics::DescriptorRole::STORAGE);
    config.bindings["right_data"] = Buffers::ShaderBinding(1, 1, Portal::Graphics::DescriptorRole::STORAGE);

    auto reader = create_processor<Buffers::DescriptorBindingsProcessor>(picture, config);
    reader->bind_audio_buffer("left", samplers[0]->get_buffer(), "left_data", 1, Portal::Graphics::DescriptorRole::STORAGE);
    reader->bind_audio_buffer("right", samplers[1]->get_buffer(), "right_data", 1, Portal::Graphics::DescriptorRole::STORAGE);
    picture->setup_rendering({ .target_window = window, .fragment_shader = "two_axes.frag" });
}

Run it with a stereo recording. Each channel of the file gets its own sampler, and each sampler’s block goes to its own storage buffer. The left channel pushes the rows sideways and the right channel pushes the columns up and down. A sound that sits left bends the picture one way, and a sound that sits right bends it the other. A sound that moves between the ears moves the distortion from one axis to the other.

What You Achieved

You have:

  • Drawn a picture with a shader of your own
  • Sent a sound to the shader as a whole list, and as a single number
  • Read the same sound three ways, by position, as a few strengths and cell by cell
  • Thrown a picture’s cells outward from a click with a bell, and drained and flooded one with a drone
  • Bent a picture differently on two axes with the two ears of a recording
  • Seen a compute shader write a new picture from a stack of pictures and a list of weights

Nothing here changed the picture or the sound. The shader was the meeting point, and the sound decided how the picture was read. In the next section many pictures are stacked at once, stills, films and moments of the past, and a shader decides what the pile becomes.


A Picture Made of Many

The Next Step

A picture is a grid of numbers. A stack of pictures is a much bigger block of numbers: a still, a film, a moment from a minute ago, all held at once as the layers of a single texture. The shader can read any layer at any pixel, so it can lay them out like stones, sort them, or let each corner of the picture live in a different moment.

A Chimera is what fills that stack. You say what each layer is made of, a photograph, a film, a moment from the past, and the shader says what the pile means.

Every block below needs one small helper, which lists the pictures in a folder. Paste it above compose() in each file, together with the include:

#include "MayaFlux/Kakshya/Source/DynamicVideoStream.hpp"

inline std::vector<std::string> pictures(const std::string& folder, size_t limit)
{
    std::vector<std::string> found;
    for (const auto& entry : std::filesystem::directory_iterator(folder)) {
        const auto extension = entry.path().extension().string();
        if (extension == ".png" || extension == ".jpg") {
            found.push_back(entry.path().string());
        }
    }
    std::sort(found.begin(), found.end());
    found.resize(std::min(found.size(), limit));
    return found;
}

Save each shader in the data/shaders folder of your project, under the name written above it. The code asks for it by that file name alone. The program looks for data/shaders in the folder you run it from, and one folder up, so run it from the project folder or its build folder. If the window stays blank or never appears, the shader was not found: look in the terminal for an error about failing to read the shader, and check the folder and the name. Use a folder with at least eight photographs for the first block and at least twenty-four for the second, written as path/to/stills.

A Pile of Pictures

void compose() {
    auto window = create_window({ "A Pile of Pictures", 1024, 1024 });
    window->show();

    const auto paths = pictures("path/to/stills", 8);
    const auto count = paths.size();

    auto builder = create_chimera(
        { .width = 512, .height = 512, .ring_frames = count },
        { .target_window = window },
        Portal::Graphics::FitMode::COVER);

    for (const auto& path : paths) {
        builder.layer().from(path);
    }

    auto clock = std::make_shared<double>(0.0);

    builder.every(1.0 / 60.0, [clock, count](Kriya::Chimera& set) {
        *clock += 1.0 / 60.0;
        for (size_t i = 0; i < count; ++i) {
            const double wave = 0.5 + 0.5 * std::sin(*clock * 0.35 + static_cast<double>(i) * 2.1);
            set.level(i, 0.02 + std::pow(wave, 6.0));
        }
    });

    store(builder.start());
}

Run this code. All eight pictures are in the window at once, as layers of one texture, and each breathes at its own pace. At any moment two or three of them are strong and the rest are a faint trace, so you see double and triple exposures, a face over a street over a field, and then one of them sinks away and another rises out of the mix. It never settles on one picture.

The default shader does the mixing. It takes a mean of all the layers, weighted by each layer’s level. Nothing else is happening: eight pictures, and eight numbers that change sixty times a second. The next blocks replace the shader, so that the pile means something other than a mix.

Stones in a Ring

data/shaders/stones.frag

#version 460

layout(location = 0) in vec2 fragTexCoord;
layout(location = 0) out vec4 outColor;

layout(set = 0, binding = 1) uniform sampler2DArray textureArray;

layout(set = 1, binding = 0) readonly buffer LayerData {
    vec4 layerData[];
};

const uint MAX_LAYERS = 30u;

layout(push_constant) uniform PC {
    uint layer_count;
    uint mode;
    float weights[MAX_LAYERS];
} pc;

void main()
{
    vec3 colour = vec3(0.93, 0.91, 0.86);
    uint count = min(pc.layer_count, MAX_LAYERS);

    for (uint i = 0u; i < count; ++i) {
        float presence = pc.weights[i];
        if (presence <= 0.0) {
            continue;
        }

        vec4 params = layerData[2u * i];
        float radius = max(params.z, 0.0001);
        float c = cos(params.w);
        float s = sin(params.w);

        vec2 p = (fragTexCoord - params.xy) / radius;
        p = vec2(c * p.x + s * p.y, -s * p.x + c * p.y);

        float edge = 1.0 - smoothstep(0.7, 1.0, length(p));
        if (edge <= 0.0) {
            continue;
        }

        vec4 stone = texture(textureArray, vec3(p * 0.5 + 0.5, float(i)));
        colour = mix(colour, stone.rgb, edge * stone.a * presence);
    }

    outColor = vec4(colour, 1.0);
}
void compose() {
    auto window = create_window({ "Stones in a Ring", 1024, 1024 });
    window->show();

    const auto paths = pictures("path/to/stills", 24);
    const auto stones = static_cast<uint32_t>(paths.size());

    auto builder = create_chimera(
        { .width = 512, .height = 512, .ring_frames = stones },
        { .target_window = window, .fragment_shader = "stones.frag" },
        Portal::Graphics::FitMode::COVER);

    builder.layer_data();

    for (uint32_t i = 0; i < stones; ++i) {
        const float angle = static_cast<float>(i) * 2.4F;
        const float reach = 0.09F * std::sqrt(static_cast<float>(i) + 0.5F);

        builder.layer().from(paths[i]).level(0.0)
            .params({ 0.5F + reach * std::cos(angle), 0.5F + reach * std::sin(angle), 0.07F, angle });
    }

    auto laid = std::make_shared<uint32_t>(0);
    auto presence = std::make_shared<std::vector<double>>(stones, 0.0);

    builder.every(1.0 / 60.0, [laid, presence](Kriya::Chimera& set) {
        for (size_t i = 0; i < presence->size(); ++i) {
            double& value = (*presence)[i];
            value = i < *laid ? std::min(value + 0.2, 1.0) : std::max(value - 0.01, 0.0);
            set.level(i, value);
        }
    });

    store(builder.start());

    auto pulse = vega.Impulse(2.0F) | Audio[0];
    pulse->exclude_output_from_graph();

    pulse->on_impulse([laid, stones](const auto&) {
        *laid = (*laid + 1) % (stones + 6);
    });
}

Run this code. A silent pulse every half second, and with each pulse a photograph is laid on a warm ground, each as a soft disc, spiralling outward from the middle the way seeds sit in a flower. They overlap like leaves. When the last stone is down the pulse goes on for six more beats, and then the tide takes them: every stone fades out slowly, and the laying starts again from the centre.

Each photograph is cropped to a disc by the shader, so pictures without a transparent background work. Where a stone lies and how big it is comes from params, one four-number set per layer. How visible it is comes from level.

Also: A Node That Only Keeps Time

The pulse is an Impulse, a node, so it needs to be processed to fire. Sending it to Audio does that, and it would also put its click in the speakers. exclude_output_from_graph() keeps the processing and leaves the click out: the node runs every sample and calls its hooks, but its output is not added to the sound.

auto pulse = vega.Impulse(2.0F) | Audio[0];
pulse->exclude_output_from_graph();

Leave the line out and the stones are laid to an audible click. Any node can be used this way, a Logic gate, a Random, a slow Sine, when you want what it does and not what it sounds like.

Then change one thing at a time:

  • 2.4F to 0.5F: the stones fall in tight curling arms instead of a sunflower
  • 0.07F to 0.14F: large stones that bury each other
  • 0.01 to 0.002: a very slow tide, so the next laying begins before the last has gone
  • Impulse(2.0F) to Impulse(0.4F): one stone every two and a half seconds

You have, in both blocks:

  • A folder of pictures, each one a layer of the same texture
  • A weight for each layer, changing as it runs
  • A shader that decides how the layers are read
  • Numbers per layer, placement and visibility, that you can change while it runs
Explanations
Expansion 1: A Layer Stack Is One Texture

An array texture is a texture with many layers of the same size. The shader reads one with a third coordinate, the layer:

texture(textureArray, vec3(uv, float(layer)))

create_chimera makes the array and a Chimera to fill it:

auto builder = create_chimera(
    { .width = 512, .height = 512, .ring_frames = 8 },
    { .target_window = window },
    Portal::Graphics::FitMode::CONTAIN);

The size is the size of every layer. ring_frames is the name the picture streams use for their length, and here it is the number of layers. Declaring more layers than that is an error and nothing starts. Declaring fewer is allowed, but the shader is told the array has eight, and the ones you never filled read as empty black. That is why the helper counts the pictures first and uses the count.

Layers are numbered in the order you declare them with layer(). The shipped push constants carry weights for the first thirty, so thirty is the practical limit for shaders that use level.

The shader is set through the render config, like any other:

{ .target_window = window, .fragment_shader = "stones.frag" }

A name ending in .frag is compiled from source when the program starts, and one ending in .frag.spv is loaded already compiled.

Expansion 2: Where Layers Come From

After layer(), one call says what fills it:

builder.layer().from("path/to/still.png");
builder.layer().from(image_data);
builder.layer().from(gpu_image);
builder.layer().from(ring);
builder.layer().from_pipeline();

A path is loaded once, now, as colour with transparency. An ImageData is a picture you already have in memory. A GPU image is copied into the layer every frame, so a layer can follow something that another shader is writing. A ring is a film or camera being recorded, and from_pipeline() means the ring that the builder’s own pipeline records, which the film blocks below use.

When a picture is not the size of the layer, a fit decides how it lands. The third argument of create_chimera sets it for every layer, and .fit(...) sets it for one:

builder.layer().from("path/to/wide.png").fit(Portal::Graphics::FitMode::COVER);
  • STRETCH squeezes the whole picture onto the layer
  • CONTAIN keeps its shape and centres it, the rest of the layer stays transparent black
  • COVER keeps its shape and fills the layer, cropping the picture
  • CENTER does not scale it, and clips what does not fit
  • TILE and TILE_MIRRORED repeat it at its own size

If a picture is a different size and no fit is given anywhere, the layer is not filled, and nothing is logged. When a layer stays empty, check the sizes first.

Expansion 3: The Ring and What It Costs

A film or a camera is brought in by recording it into a ring, a fixed number of frames that always holds the most recent ones. Layers then read moments out of the ring:

builder.record(Kriya::BufferOperation::capture_to_stream(
    get_io_manager(), "path/to/film.mkv",
    IO::VideoLoadConfig { .target_width = 640, .target_height = 360 }, 600));
builder.record(Kriya::BufferOperation::capture_to_stream(
    get_io_manager(), vega.read_camera(), 600));

The camera asks which camera and which mode, and the ring is sized from the mode you choose. The last number is the ring length in frames. The ring is filled once for every frame the window draws, 60 a second, whatever the film’s own rate, so 600 frames is ten seconds. Each frame is read back from the GPU, and each ring layer is copied up to the GPU again every frame.

What it costs. A frame is its width times its height times four bytes, and the ring keeps all of them in memory:

SizeOne frameA 600 frame ring (10 s)
640 by 3600.9 MB550 MB
640 by 4801.2 MB740 MB
1280 by 7203.7 MB2.2 GB
1920 by 10808.3 MB5 GB

Every layer costs a frame of video memory too, and every ring layer is uploaded every frame, which is a frame’s worth of bytes times the layer count times sixty each second. The examples here use 640 by 360 and ten seconds so that they start on modest hardware. If you have a strong graphics card and plenty of memory, make the size and the ring larger, and give target_width and target_height the same numbers as the Chimera’s width and height. every_n_frames(2) on a layer refreshes it every other frame and halves its cost.

A film plays once. When it ends the ring stops growing, so every layer that reads the past keeps whatever moment it was on. The camera runs for as long as the program does.

Expansion 4: The Verbs of a Ring Layer

Each call after layer().from_pipeline() says how that layer moves through the ring:

builder.layer().from_pipeline().lag(2.0);
builder.layer().from_pipeline().lag(Kinesis::TimeMaps::triangle(0.5, 6.0, 0.4, 0.0)).smooth();
builder.layer().from_pipeline().speed(0.0).enters_after(5.0);
builder.layer().from_pipeline().speed(-0.5);
  • lag(seconds) reads that many seconds behind the newest frame, never closer than two frames
  • lag(map) lets the lag change in time. The map takes seconds since the layer entered and returns a lag in seconds. The time maps of the second section work here
  • speed(ratio) plays freely instead. The layer starts at the newest frame and moves from there. Below 1 it falls further behind, 1 or above keeps it at the newest frame, 0 holds it on that moment, and a negative number plays backward
  • smooth() blends the two frames around a fractional position, so a slow or changing lag does not step
  • enters_after(seconds) keeps the layer empty until that long after the start
  • every_n_frames(n) refreshes the layer every n frames

A layer that reaches a moment the ring no longer holds keeps its last frame and says so once.

While it runs, cut jumps a layer to another moment:

set.cut(2, 4.0);

That moves layer 2 to four seconds behind the newest frame. A lagged layer carries on following its lag from there, and a free layer carries on playing.

Expansion 5: What the Shader Receives

Every array shader declares the same three things:

layout(set = 0, binding = 1) uniform sampler2DArray textureArray;

layout(set = 1, binding = 0) readonly buffer LayerData {
    vec4 layerData[];
};

layout(push_constant) uniform PC {
    uint layer_count;
    uint mode;
    float weights[30];
} pc;
  • textureArray is the stack
  • pc.layer_count is how many layers the array holds, and pc.weights[i] is the level of layer i, which starts at 1
  • pc.mode is a number only the shipped shaders use, and your own shader can ignore it
  • layerData holds two vec4 for each layer: layerData[2 * i] is its params, four numbers you set, and layerData[2 * i + 1] is its timing, which the Chimera writes

The timing has x as the seconds since the layer entered, y as how many frames behind the newest frame it reads, and w as 1 once it has been filled. A shader can use w to leave out layers that have not arrived.

layerData exists only when you ask for it. builder.layer_data() creates it, and so does giving any layer params. Call it before start(), and leave the block out of a shader that does not use it.

Nothing here has a meaning of its own. The weights and params are numbers. The stones shader reads weights as how visible a stone is and params as where it lies. The emergence shader below reads one param as a dial.

Expansion 6: Knobs, Controls and Staying Alive

The running Chimera has controls, and they are the way to change the stack while it runs:

set.level(3, 0.5);
set.params(3, glm::vec4(0.5F, 0.5F, 0.1F, 0.0F));
set.speed(2, -1.0);
set.cut(2, 4.0);
set.set(1, other_picture);
set.stop();

level and params write the numbers the shader reads. speed and cut move a layer through its ring. set gives a layer a new still, ring or GPU image. stop holds every layer on the frame it is on.

A knob does not have to belong to a layer. The emergence and paint blocks use set.params(0, ...) as one dial for the whole picture and read it as layerData[0].x.

The place to call the controls from is every:

builder.every(1.0 / 60.0, [](Kriya::Chimera& set) {
});

It runs your function on the frame clock, once per interval, and the shortest interval is one frame. Anything that should reach the picture, a decay, a sweep, a value from a sound, goes through it.

The Chimera runs as long as a copy of it exists. store(builder.start()) keeps one alive for the life of the program.

Expansion 7: Driving a Chimera From Sound

Sound is one way of deciding when and how far, not the only one. A node is a stream of numbers that can make a sound, and it can also say when something happens. These hooks fire as the node runs:

  • an Impulse calls on_impulse at each pulse
  • a Logic gate calls on_change_to(true, ...) and on_change_to(false, ...) when it opens and closes, while_true for as long as it is open, and on_tick_if(condition, ...) when a condition holds
  • a Counter calls on_increment, on_wrap and on_count(target, ...)

A node that you send to Audio runs with the sound, and its hooks run there too, silent or not, as in the Stones block. The Chimera runs with the picture. The simple, safe way to join them is a plain value that both can see:

auto laid = std::make_shared<uint32_t>(0);

pulse->on_impulse([laid](const auto&) { ++*laid; });

builder.every(1.0 / 60.0, [laid](Kriya::Chimera& set) {
    set.level(0, *laid > 0 ? 1.0 : 0.0);
});

The hook writes the number, and every reads it on the next frame and applies it. The Stones block is this, once per stone. The time block does it with a layer number and a distance.

A continuous number works the same way. The Paint Time block measures the loudness of the sound it is playing by reading the sampler’s buffer with get_buffer(), and the buffer of any other sound you have sent to the speakers can be measured in just the same way.

Expansion 8: Emergence by Order

Put many pictures of the same kind of thing on top of each other and the thing they have in common is what holds still. Mixing, as the first block does, lets every picture leave a trace, and the traces cloud each other. Choosing does better. At every pixel the shader collects the layers’ values, sorts them, and picks by rank: the lowest is the darkest of all the pictures at that pixel, the middle is the typical one, the highest is the brightest. Red, green and blue are sorted separately, so the three channels of a pixel can come from different pictures, and a rank between two whole numbers blends its two neighbours.

A shader of your own does this. emerge.frag below picks any rank with a dial, and can scatter the rank from grain to grain.

The default shader has modes too, chosen when the array is made with create_texture_array, and then handed to a Chimera:

auto array = create_texture_array(
    { .width = 512, .height = 512, .ring_frames = paths.size() },
    { .target_window = window },
    Portal::Graphics::FitMode::COVER, 2);

auto builder = create_chimera(array);

The last number is the mode: 0 the mean weighted by each layer’s level, 1 the sum, 2 the highest value at each pixel, 3 the layers laid over each other in order. create_chimera(array) takes an array that is already drawing, so you do not give it a render config again.

The sort is done for every pixel, every frame, so a lot of layers cost a lot. Thirty is the most the shipped weights cover.

Expansion 9: A Layer From the GPU

A layer can follow an image that a shader is writing. The compute morph of the previous section made one:

builder.layer().from(morph->get_output_image(0)).every_n_frames(2);

The picture the morph wrote is copied into that layer every second frame. A layer fed this way sits in the stack like any other, so the same shader can sort it, place it or fade it together with stills and films.

Try It → Recap

Emergence

data/shaders/emerge.frag

#version 460

layout(location = 0) in vec2 fragTexCoord;
layout(location = 0) out vec4 outColor;

layout(set = 0, binding = 1) uniform sampler2DArray textureArray;

layout(set = 1, binding = 0) readonly buffer LayerData {
    vec4 layerData[];
};

const uint MAX_LAYERS = 30u;

layout(push_constant) uniform PC {
    uint layer_count;
    uint mode;
    float weights[MAX_LAYERS];
} pc;

float hash21(vec2 p)
{
    p = fract(p * vec2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

vec2 hash22(vec2 p)
{
    return vec2(hash21(p), hash21(p + 17.0));
}

float grain_scatter(vec2 uv, float density, float seed)
{
    vec2 p = uv * density;
    vec2 cell = floor(p);
    float nearest = 8.0;
    vec2 owner = cell;

    for (int y = -1; y <= 1; ++y) {
        for (int x = -1; x <= 1; ++x) {
            vec2 c = cell + vec2(float(x), float(y));
            float d = distance(p, c + hash22(c));
            if (d < nearest) {
                nearest = d;
                owner = c;
            }
        }
    }

    return hash21(owner + vec2(seed, seed * 0.37)) - 0.5;
}

void sort_values(inout float v[MAX_LAYERS], uint n)
{
    for (uint i = 1u; i < n; ++i) {
        float key = v[i];
        int j = int(i) - 1;
        while (j >= 0 && v[j] > key) {
            v[j + 1] = v[j];
            --j;
        }
        v[j + 1] = key;
    }
}

void main()
{
    float r[MAX_LAYERS];
    float g[MAX_LAYERS];
    float b[MAX_LAYERS];
    uint n = min(pc.layer_count, MAX_LAYERS);

    for (uint i = 0u; i < n; ++i) {
        vec3 c = texture(textureArray, vec3(fragTexCoord, float(i))).rgb;
        r[i] = c.r;
        g[i] = c.g;
        b[i] = c.b;
    }

    sort_values(r, n);
    sort_values(g, n);
    sort_values(b, n);

    vec4 knobs = layerData[0];
    float scatter = grain_scatter(fragTexCoord, 90.0, floor(knobs.z * 8.0) * 7.31);

    float pick = clamp(knobs.x + scatter * knobs.y, 0.0, 1.0) * float(n - 1u);
    uint lower = uint(floor(pick));
    uint upper = min(lower + 1u, n - 1u);
    float f = fract(pick);

    outColor = vec4(
        mix(r[lower], r[upper], f),
        mix(g[lower], g[upper], f),
        mix(b[lower], b[upper], f),
        1.0);
}
void compose() {
    auto window = create_window({ "Emergence", 1024, 1024 });
    window->show();

    const auto paths = pictures("path/to/stills", 30);

    auto builder = create_chimera(
        { .width = 512, .height = 512, .ring_frames = paths.size() },
        { .target_window = window, .fragment_shader = "emerge.frag" },
        Portal::Graphics::FitMode::COVER);

    builder.layer_data();

    for (const auto& path : paths) {
        builder.layer().from(path);
    }

    auto clock = std::make_shared<double>(0.0);

    builder.every(1.0 / 60.0, [clock](Kriya::Chimera& set) {
        *clock += 1.0 / 60.0;
        const double t = *clock;
        const double dial = 0.5 + 0.5 * (0.6 * std::sin(t * 1.3) + 0.4 * std::sin(t * 3.7 + 1.0));
        const double spread = 0.5 + 0.5 * std::sin(t * 0.9 + 2.0);
        set.params(0, glm::vec4(static_cast<float>(dial), static_cast<float>(spread), static_cast<float>(t), 0.0F));
    });

    store(builder.start());
}

Use twenty to thirty photographs of the same kind of thing: windows, clouds, faces, a harbour at dawn, the same street on different days.

Run it. Two knobs move fast and out of step with each other. The first is the dial, which swings between the darkest of all your pictures and the brightest in a couple of seconds, with a second, quicker wobble on top, so it never repeats the same sweep. The second is the spread. The picture is scattered into a few thousand small, irregular grains, and at every moment each grain is given its own push away from the dial, drawn fresh eight times a second. When the spread is low the whole picture moves together. When it is high, neighbouring grains pick pictures of different ranks, and the window becomes a flickering dust where one grain shows the darkest picture, the next the typical one and the next the brightest.

What the pictures have in common keeps flashing out of the noise and being lost again. Nothing in the stack is averaged. At each pixel the shader reads one rank of the sorted values, and which picture that rank belongs to changes many times a second.

Change one thing at a time:

  • 1.3 and 3.7 to 0.2 and 0.5: the same piece, slowed to a drift
  • Replace the spread expression with const double spread = 0.0;: no spread, so the picture moves as one and the middle is clean
  • 90.0 in the shader to 300.0: the grains shrink to fine dust, like static
  • 90.0 to 20.0: coarse flakes, like torn paper
  • 8.0 in the shader to 1.0: the grains change once a second and hold their picture between
  • Fewer pictures, six: the ranks become coarse, and the pictures show through as separate wins
Also: A Film Instead of Stills

Replace the stills with moments of a film. Each layer reads the film a little further back, so each pixel sorts the last nine seconds of what was there:

auto builder = create_chimera(
    { .width = 640, .height = 360, .ring_frames = 24 },
    { .target_window = window, .fragment_shader = "emerge.frag" },
    Portal::Graphics::FitMode::STRETCH);

builder.layer_data();
builder.record(Kriya::BufferOperation::capture_to_stream(
    get_io_manager(), "path/to/film.mkv",
    IO::VideoLoadConfig { .target_width = 640, .target_height = 360 }, 600));

for (int i = 0; i < 24; ++i) {
    builder.layer().from_pipeline().lag(0.4 * i).every_n_frames(2);
}

Point it at a film of a street, a crowd or a room. The median of nine seconds is what stays put, and people who only pass through are gone, and at the extremes of the dial they leave trails, dark or light. To hold the clean middle, set the dial to a constant 0.5 and the spread to 0.0. This is 24 ring layers refreshed every other frame at 640 by 360. A ring of 600 frames takes about 550 MB, so make the picture and the ring larger if your machine can carry it.

Time Is Not One Place

data/shaders/uncanny.frag

#version 460

layout(location = 0) in vec2 fragTexCoord;
layout(location = 0) out vec4 outColor;

layout(set = 0, binding = 1) uniform sampler2DArray textureArray;

layout(set = 1, binding = 0) readonly buffer LayerData {
    vec4 layerData[];
};

const uint MAX_LAYERS = 30u;

layout(push_constant) uniform PC {
    uint layer_count;
    uint mode;
    float weights[MAX_LAYERS];
} pc;

float hash21(vec2 p)
{
    p = fract(p * vec2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

float value_noise(vec2 p)
{
    vec2 cell = floor(p);
    vec2 f = fract(p);
    f = f * f * (3.0 - 2.0 * f);
    return mix(
        mix(hash21(cell), hash21(cell + vec2(1.0, 0.0)), f.x),
        mix(hash21(cell + vec2(0.0, 1.0)), hash21(cell + vec2(1.0, 1.0)), f.x),
        f.y);
}

void main()
{
    float phase = layerData[0].x;
    vec2 p = fragTexCoord * vec2(3.0, 2.0);

    float n = value_noise(p + vec2(phase * 0.20, -phase * 0.13)) * 0.65
        + value_noise(p * 2.3 - vec2(phase * 0.07)) * 0.35;

    uint count = min(pc.layer_count, MAX_LAYERS);
    float pos = clamp((n - 0.25) / 0.5, 0.0, 0.999) * float(count);
    uint layer = min(uint(floor(pos)), count - 1u);

    if (layerData[2u * layer + 1u].w < 0.5) {
        layer = 0u;
    }

    float across = fract(pos);
    float seam = smoothstep(0.0, 0.05, across) * (1.0 - smoothstep(0.95, 1.0, across));

    vec3 colour = texture(textureArray, vec3(fragTexCoord, float(layer))).rgb;
    outColor = vec4(colour * mix(0.3, 1.0, seam), 1.0);
}
#include "MayaFlux/Kinesis/Tendency/TimeMap.hpp"

void compose() {
    auto window = create_window({ "Time Is Not One Place", 1280, 720 });
    window->show();

    auto builder = create_chimera(
        { .width = 640, .height = 360, .ring_frames = 6 },
        { .target_window = window, .fragment_shader = "uncanny.frag" },
        Portal::Graphics::FitMode::STRETCH);

    builder.layer_data();
    builder.record(Kriya::BufferOperation::capture_to_stream(
        get_io_manager(), "path/to/film.mkv",
        IO::VideoLoadConfig { .target_width = 640, .target_height = 360 }, 600));

    builder.layer().from_pipeline().lag(0.0);
    builder.layer().from_pipeline().lag(3.0);
    builder.layer().from_pipeline().speed(0.0).enters_after(5.0);
    builder.layer().from_pipeline().lag(Kinesis::TimeMaps::triangle(0.5, 6.0, 0.4, 0.0)).smooth();
    builder.layer().from_pipeline().speed(-0.5).enters_after(2.0);
    builder.layer().from_pipeline().lag(6.0).smooth();

    auto phase = std::make_shared<double>(0.0);
    auto next = std::make_shared<std::pair<int, double>>(-1, 0.0);

    builder.every(1.0 / 60.0, [phase, next](Kriya::Chimera& set) {
        *phase += 1.0 / 60.0;
        if (next->first >= 0) {
            set.cut(static_cast<size_t>(next->first), next->second);
            next->first = -1;
            *phase += 2.5;
        }
        set.params(0, glm::vec4(static_cast<float>(*phase), 0.0F, 0.0F, 0.0F));
    });

    store(builder.start());

    auto tick = vega.Impulse(0.37F) | Audio[0];
    tick->exclude_output_from_graph();

    tick->on_impulse([next](const auto&) {
        next->first = static_cast<int>(get_uniform_random(1.0, 6.0));
        next->second = get_uniform_random(0.5, 8.0);
    });
}

Run it with a film that has a person, a room or a street in it.

Six layers read the same film at six different times: now, three seconds ago, a moment held still, a lag that wanders between half a second and six, one that plays backward at half speed, and one six seconds back. A slow field of noise decides, pixel by pixel, which of the six you see, so the picture is made of patches of different times with a dark seam between them. The patches drift. Someone’s arm is where it is now, and the chair beside it is where it was.

Every few seconds the clock ticks, and one of the five layers after the first, chosen at random, is thrown to some other moment of the last eight seconds. The patch that belonged to it changes, and the whole field lurches. The room has reset in one place.

Memory and Size
This holds a ten second ring of 640 by 360 frames, about 550 MB, and uploads the six ring layers every frame. The size is modest on purpose. If your machine has the graphics memory and the processing for it, make the Chimera’s width and height, the film’s target_width and target_height and the ring length larger. A 1280 by 720 ring of 600 frames is 2.2 GB. Expansion 3 has the numbers.
Also: A Camera

A film plays once, and then every layer keeps the moment it was on. A camera does not end. Replace the recording line with:

builder.record(Kriya::BufferOperation::capture_to_stream(
    get_io_manager(), vega.read_camera(), 600));

Choose the 640 x 480 mode when asked, to keep the ring modest. The Chimera stretches the picture to its own size. Walk in front of it. You are in six places at six times, and every few seconds one of them is somewhere else. To skip the pickers, give vega.read_camera a CameraConfig with the device name, which Expansion 16 of the first section explains.

Change one thing at a time:

  • Impulse(0.37F) to Impulse(1.3F): a nervous room that cannot settle
  • 2.5 in the phase to 0.0: the cuts change the layers but not the map
  • vec2(3.0, 2.0) in the shader to vec2(12.0, 8.0): the patches become a fine mosaic of times
  • speed(0.0) to speed(0.05): the held layer creeps, and does not stay still

Paint Time

data/shaders/paint_time.frag

#version 460

layout(location = 0) in vec2 fragTexCoord;
layout(location = 0) out vec4 outColor;

layout(set = 0, binding = 1) uniform sampler2DArray textureArray;

layout(set = 1, binding = 0) readonly buffer LayerData {
    vec4 layerData[];
};

const uint MAX_LAYERS = 30u;

layout(push_constant) uniform PC {
    uint layer_count;
    uint mode;
    float weights[MAX_LAYERS];
} pc;

void main()
{
    vec3 paint = texture(textureArray, vec3(fragTexCoord, 0.0)).rgb;
    float brightness = dot(paint, vec3(0.299, 0.587, 0.114));
    float depth = clamp(layerData[0].x, 0.0, 1.0);

    uint moments = min(pc.layer_count, MAX_LAYERS) - 1u;
    float pos = clamp(brightness * depth, 0.0, 1.0) * float(moments - 1u);
    uint lower = uint(floor(pos));
    uint upper = min(lower + 1u, moments - 1u);

    vec4 a = texture(textureArray, vec3(fragTexCoord, float(1u + lower)));
    vec4 b = texture(textureArray, vec3(fragTexCoord, float(1u + upper)));

    outColor = mix(a, b, fract(pos));
}
void compose() {
    auto window = create_window({ "Paint Time", 1280, 720 });
    window->show();

    auto builder = create_chimera(
        { .width = 640, .height = 360, .ring_frames = 17 },
        { .target_window = window, .fragment_shader = "paint_time.frag" },
        Portal::Graphics::FitMode::STRETCH);

    builder.layer_data();
    builder.record(Kriya::BufferOperation::capture_to_stream(
        get_io_manager(), "path/to/film.mkv",
        IO::VideoLoadConfig { .target_width = 640, .target_height = 360 }, 600));

    builder.layer().from("path/to/painted.png").fit(Portal::Graphics::FitMode::COVER);

    for (int i = 0; i < 16; ++i) {
        builder.layer().from_pipeline().lag(0.25 * i).every_n_frames(2);
    }

    auto sampler = store(create_sampler());
    sampler->play_continuous(0, sampler->slice_from_stream().with_looping(true));

    auto block = sampler->get_buffer();
    auto depth = std::make_shared<double>(0.0);

    builder.every(1.0 / 60.0, [block, depth](Kriya::Chimera& set) {
        const auto& samples = block->get_data();
        double sum = 0.0;
        for (const double value : samples) {
            sum += std::abs(value);
        }
        const double level = sum / std::max<size_t>(1, samples.size());
        *depth += (level * 8.0 - *depth) * 0.1;
        set.params(0, glm::vec4(static_cast<float>(std::clamp(*depth, 0.0, 1.0)), 0.0F, 0.0F, 0.0F));
    });

    store(builder.start());
}

Before you run it, paint a picture. Any size, any program: a black background with white strokes, a grey wash, a gradient from one corner. This picture is never shown. It is a map of time.

Run it with a film. In the quiet, the painting does nothing and you see the film as it is now. As the sound gets louder, every pixel reads the past by as much as the painting is bright there: black stays in the present, and white reaches nearly four seconds back. A white stroke across a face lets the face live a few seconds late, so as someone crosses the picture the stroke drags behind them. A grey wash is a veil of the recent past. A gradient is a slow tear from now to then. When the sound drops, the picture closes back into the present.

The still and the film are two layers of one stack. The painting is read as numbers, never drawn, and sixteen moments of the film are what it picks between.

Change one thing at a time:

  • 8.0 to 2.0: only loud passages open the past
  • 0.25 * i to 0.5 * i: the last of sixteen moments reaches seven and a half seconds back; keep the 600 frame ring so it can hold that past
  • 0.1 to 0.01: the depth follows the sound slowly and smears its changes
  • A painting with soft edges, not hard ones: the past blends into the present like a glaze, a hard edge gives a cut

What You Achieved

You have:

  • Stacked a folder of photographs into one texture, and read them with a shader instead of a plain mix
  • Laid stones that appear with each pulse of a node, placed by numbers you gave every layer
  • Sorted a pile of pictures at each pixel, so what they share rose out of them
  • Read one film at six different times at once, and thrown one of them somewhere else on a tick
  • Painted a map of time, and let the loudness of a sound decide how far into it the film reaches
  • Seen how many megabytes a ring costs, and where to make it larger

Nothing here changed the pictures. The layers were data, the shader was the meeting point, and what the pile meant was a few lines of GLSL. In the next section the data stops being a picture: a sound becomes the vertices of a shape.


Sound Takes Shape

The Next Step

A shape is two lists. One is a list of points in space, and the other is a list of triangles that join them. That is all a vase, a mountain or a creature is to the computer, and it is data like any other.

A sound has many sides at once. It is loud or soft, high or low, bassy, sharp, steady, or struck. A shape has many sides too: joints that turn, a surface that can be pressed, a thread that can be drawn through space. This section gives each side of the sound to a different side of a shape, and lets the shape have weight and memory, so that the sound pushes it instead of dictating it.

vega.mint takes a shape and puts it on screen in one call. Each block below is a complete compose().

The first block needs nothing from you. A clock of nodes plucks a string and moves the figure, so the sound and the dance keep the same time. The next two listen to the microphone, because a shape that follows a voice or a clap in the room is the quickest way to see what each measurement means. Input has to be switched on before the program starts, which Expansion 8 of the first section shows how to do. The last one plays a whole recording instead, and listens to it as it plays.

Save each shader in the data/shaders folder of your project, under the name written above it. The code asks for it by that file name alone. The program looks for data/shaders in the folder you run it from, and one folder up, so run it from the project folder or its build folder. If the window stays blank or never appears, the shader was not found: look in the terminal for an error about failing to read the shader, and check the folder and the name.

A Figure That Dances

data/shaders/figure.frag

#version 460

layout(location = 0) in vec3 in_color;
layout(location = 1) in vec2 in_uv;
layout(location = 2) in vec3 in_normal;
layout(location = 3) flat in uint in_slot;
layout(location = 4) in vec3 in_world_pos;

layout(location = 0) out vec4 out_color;

void main()
{
    vec3 n = normalize(in_normal);
    float key = max(dot(n, normalize(vec3(0.5, 0.8, 0.6))), 0.0);
    float rim = pow(1.0 - max(n.z, 0.0), 3.0);
    float k = float(in_slot) / 10.0;
    vec3 base = 0.55 + 0.45 * cos(6.2831 * (vec3(0.0, 0.33, 0.67) + k));
    out_color = vec4(base * (0.18 + 0.82 * key) + 0.25 * rim, 1.0);
}
void compose() {
    auto window = create_window({ "A Figure That Dances", 1280, 800 });
    window->show();

    auto motion = [](float hz, std::function<double(double)> shape) {
        auto chain = vega.Phasor(hz) >> vega.Polynomial(shape);
        chain->exclude_output_from_graph();
        return chain;
    };

    auto sway = motion(0.4F, [](double x) { return std::sin(6.2832 * x); });
    auto reach = motion(0.12F, [](double x) { return 0.15 - 0.15 * std::cos(6.2832 * x); });
    auto thump = motion(2.0F, [](double x) { return 0.6 * std::exp(-6.0 * x); });
    auto whip = motion(2.0F, [](double x) { return std::exp(-4.0 * x) * std::sin(18.0 * x); });

    auto harp = vega.WaveguideNetwork(WaveguideNetwork::WaveguideType::STRING, 110.0) | Audio[{ 0, 1 }];
    harp->set_loss_factor(0.985);
    harp->set_exciter_type(WaveguideNetwork::ExciterType::NOISE_BURST);
    harp->set_exciter_duration(0.004);
    harp->set_output_scale(3.0);

    auto octave = std::make_shared<double>(1.0);
    auto raised = vega.Logic(0.2) | Audio[0];
    raised->set_input_node(reach);
    raised->exclude_output_from_graph();
    raised->on_change_to(true, [octave](const auto&) { *octave = 2.0; });
    raised->on_change_to(false, [octave](const auto&) { *octave = 1.0; });

    auto lead = std::make_shared<bool>(false);
    auto count = std::make_shared<int>(0);
    const std::array<double, 5> scale { 110.0, 123.5, 146.8, 165.0, 196.0 };

    auto step = vega.Impulse(2.0F) | Audio[0];
    step->exclude_output_from_graph();
    step->on_impulse([=](const auto&) {
        *lead = !*lead;
        harp->set_fundamental(scale[static_cast<size_t>((*count)++) % scale.size()] * *octave);
        harp->pluck(0.28, 0.9);
    });

    auto limb = [](float length, float radius) {
        return [=] {
            const std::vector<glm::vec3> path { { 0.0F, 0.0F, 0.0F }, { 0.0F, -length, 0.0F } };
            return Kinesis::generate_tube(std::span(path), [=](float) { return radius; }, 10, true);
        };
    };

    auto body = std::make_shared<Nodes::Network::MeshNetwork>();

    auto figure = vega.mint(StructureConfig::Model {
        .network = body,
        .components = {
            { .expression = limb(-0.6F, 0.14F) },
            { .expression = limb(-0.22F, 0.1F), .parent_component = 0 },
            { .expression = limb(0.55F, 0.06F), .parent_component = 0 },
            { .expression = limb(0.55F, 0.06F), .parent_component = 0 },
            { .expression = limb(0.8F, 0.08F), .parent_component = 0 },
            { .expression = limb(0.8F, 0.08F), .parent_component = 0 },
        },
        .render = { .target_window = window, .fragment_shader = "figure.frag" },
    });

    bind_orbit_preset(window, { .focal_point = { 0.0F, 0.9F, 0.0F }, .initial_distance = 4.5F });

    auto joint = [body](uint32_t part, const glm::vec3& at, const glm::vec3& angles) {
        auto& slot = body->get_slot(part);
        slot.local_transform = glm::translate(glm::mat4(1.0F), at) * glm::mat4_cast(glm::quat(angles));
        slot.dirty = true;
    };

    schedule_metro(1.0 / 60.0, [=]() {
        const auto lean = static_cast<float>(sway->get_last_output());
        const auto arms = 0.2F + 8.0F * static_cast<float>(reach->get_last_output());
        const auto bend = static_cast<float>(thump->get_last_output());
        const auto fling = 2.5F * static_cast<float>(whip->get_last_output());
        const float left = *lead ? 1.0F : 0.2F;

        joint(0, { 0.0F, 1.0F - 0.25F * bend, 0.0F }, { 0.0F, 0.0F, 0.2F * lean });
        joint(1, { 0.0F, 0.6F, 0.0F }, { 0.0F, 0.0F, -0.15F * lean });
        joint(2, { 0.2F, 0.5F, 0.0F }, { -left * fling, 0.0F, arms });
        joint(3, { -0.2F, 0.5F, 0.0F }, { -(1.2F - left) * fling, 0.0F, -arms });
        joint(4, { 0.1F, 0.0F, 0.0F }, { -0.8F * bend, 0.0F, 0.0F });
        joint(5, { -0.1F, 0.0F, 0.0F }, { -0.8F * bend, 0.0F, 0.0F });
    }, "dancer", Vruta::ProcessingToken::FRAME_ACCURATE);
}

Run this code. A figure of six parts stands in the window, and you hear a plucked string climbing a five note scale and starting over, two notes a second. Nothing is measured and nothing is loaded. A few nodes keep time, and everything else follows them:

  • The pulse is a silent clock that ticks twice a second. Each tick is an event, and one function answers it three ways: it picks the next note, plucks the string, and hands the lead to the other arm.
  • The thump is an envelope that jumps up on every tick and falls away. It is how far the knees dip and the body drops.
  • The whip is a damped swing that restarts on every tick. It throws the leading arm forward and lets it settle.
  • The reach is a slow swell over eight seconds that holds the arms higher and lower. A Logic node watches it, and when it passes a threshold on the way up, the next notes are an octave higher, and when it falls back they come down again. The arms are high when the notes are high.
  • The sway is a slow wave that leans the body.

The pulse, the thump and the whip run at the same two beats a second and start together, so the knees, the arm and the note land at the same moment. The sway and the reach are slower and keep their own time.

Drag with the middle mouse button to walk round it.

Change one thing at a time:

  • 2.0F to 3.0F in thump, whip and step: a faster dance and a faster melody
  • 0.12F to 0.5F: the arms rise and fall in two seconds, and the register jumps often
  • 18.0 to 6.0 in whip: a slower, lazier swing. 40.0 is a shiver
  • 0.985 to 0.9995: the strings ring on and the notes blur together
  • 3.0 in set_output_scale: how loud the strings are. 1.0 is the network’s own level
  • The numbers in scale: your own five notes. Put in 110.0, 220.0, 110.0, 220.0, 330.0 for a bounce
  • 0.2 to 0.1 in vega.Logic: the melody spends most of its time an octave up
Also: Dance to Your Own Sound

A node is a stream of numbers, and so is a recording or a microphone. To make the arms follow a sound you bring in, replace the reach chain with a measurement of a block of it:

auto block = create_input_listener_buffer(0);
const auto& samples = block->get_data();
const auto n = static_cast<uint32_t>(samples.size());
const auto arms = 0.2F + 8.0F * static_cast<float>(Kinesis::Discrete::rms(samples, 1, n, n)[0]);

The first line goes before the schedule_metro and the rest replace the arms line inside it. Input has to be switched on before the program starts, which Expansion 8 of the first section shows how to do. The next blocks listen to the microphone like this, and Expansion 8 below lists what else a block can tell you besides its level.

The Potter’s Wheel Remembers

data/shaders/glaze.frag

#version 460

layout(location = 0) in vec3 in_color;
layout(location = 1) in vec2 in_uv;
layout(location = 2) in vec3 in_world_pos;

layout(location = 0) out vec4 out_color;

void main()
{
    vec3 n = normalize(cross(dFdx(in_world_pos), dFdy(in_world_pos)));
    float light = abs(dot(n, normalize(vec3(0.5, 0.8, 0.6))));
    vec3 glaze = mix(vec3(0.93, 0.78, 0.55), vec3(0.42, 0.20, 0.14), in_uv.y);
    out_color = vec4(glaze * (0.25 + 0.75 * light), 1.0);
}
#include "MayaFlux/IO/Model/ModelExport.hpp"

struct Wheel {
    float turns = 0.0F;
    float height = 0.0F;
};

void compose() {
    auto window = create_window({ "The Potter's Wheel Remembers", 1280, 800 });
    window->show();

    auto block = create_input_listener_buffer(0);

    constexpr int columns = 96;
    constexpr int rows = 64;

    auto clay = std::make_shared<std::vector<float>>(columns * rows, 0.0F);

    auto vessel = [clay] {
        return Kinesis::generate_parametric_surface(
            [clay](float u, float v) {
                const int i = static_cast<int>(u * columns + 0.5F) % columns;
                const int j = std::clamp(static_cast<int>(v * (rows - 1) + 0.5F), 0, rows - 1);
                const float belly = 0.55F * (1.0F + 0.15F * std::sin(glm::pi<float>() * v));
                const float foot = std::sqrt(std::clamp(v * 12.0F, 0.0F, 1.0F));
                const float r = belly * foot * (1.0F - (*clay)[j * columns + i]);
                const float a = u * glm::two_pi<float>();
                return glm::vec3(r * std::cos(a), v * 2.0F - 1.0F, r * std::sin(a));
            },
            columns, rows - 1);
    };

    auto node = std::make_shared<MeshWriterNode>((columns + 1) * rows);
    node->set_mesh(vessel());

    auto pot = vega.mint(StructureConfig::Object {
        .writer = node,
        .render = { .target_window = window, .fragment_shader = "glaze.frag" },
    });

    bind_orbit_preset(window);

    auto wheel = std::make_shared<Wheel>();

    schedule_metro(1.0 / 60.0, [node, block, clay, wheel, vessel]() {
        const auto& samples = block->get_data();
        const auto n = static_cast<uint32_t>(samples.size());
        if (n < 16) {
            return;
        }

        constexpr float dt = 1.0F / 60.0F;

        const auto loud = static_cast<float>(Kinesis::Discrete::rms(samples, 1, n, n)[0]);
        const auto sharp = static_cast<float>(Kinesis::Discrete::kurtosis(samples, 1, n, n)[0]);
        const auto pitch = static_cast<float>(
            Kinesis::Discrete::estimate_frequency(samples, Config::get_sample_rate(), 0.0, 0.0));
        const bool heard = loud > 0.01F;

        wheel->turns += dt * 0.35F;
        wheel->turns -= std::floor(wheel->turns);

        if (heard && pitch > 0.0F) {
            const float target = std::clamp(std::log2(pitch / 110.0F) / 4.0F, 0.0F, 1.0F);
            wheel->height += (target - wheel->height) * 0.2F;
        }

        for (auto& cell : *clay) {
            cell *= 0.998F;
        }

        if (heard) {
            const float bend = std::clamp((sharp + 1.5F) / 8.0F, 0.0F, 1.0F);
            const float sigma = 1.5F + 5.0F * (1.0F - bend);
            const int reach = static_cast<int>(sigma * 2.5F);
            const int ic = static_cast<int>(wheel->turns * columns);
            const int jc = static_cast<int>(wheel->height * (rows - 1));

            for (int dj = -reach; dj <= reach; ++dj) {
                const int j = jc + dj;
                if (j < 0 || j >= rows) {
                    continue;
                }
                for (int di = -reach; di <= reach; ++di) {
                    const int i = (ic + di + columns) % columns;
                    const float falloff = std::exp(-static_cast<float>(di * di + dj * dj) / (2.0F * sigma * sigma));
                    float& cell = (*clay)[j * columns + i];
                    cell = std::min(0.6F, cell + loud * 3.0F * dt * falloff);
                }
            }
        }

        node->set_mesh(vessel());
    }, "potter", Vruta::ProcessingToken::FRAME_ACCURATE);

    on_key_pressed(window, IO::Keys::F, [node]() {
        if (!IO::save_mesh_snapshot(node, "pot_{}.gltf")) {
            MF_PRINT("pot: export failed");
        }
    });
}

Run this code. A plain pot stands in the window, and a hand you cannot see circles it once every three seconds or so. Make a sound and the hand presses into the clay, and the clay stays pressed:

  • How loud the sound is sets how deep the hand presses
  • How high the sound is sets where on the pot it presses. A low hum cuts near the foot and a whistle near the lip, and a note that glides leaves a slanting groove
  • How sharp the sound is sets the shape of the hand. A smooth tone is a flat palm that leaves a broad shallow groove, and a click is a fingertip that leaves a small pit

Sing a phrase and the notes are cut round the pot as a helix, high notes climbing and low notes sinking. Then the clay slowly relaxes and the marks soften over a few seconds. The pot remembers longer than the sound lasts, and a shorter memory than the whole performance is what lets you make more than one mark.

The pitch here is found by counting how often the sound crosses zero. It is clean for a hum, a whistle or a flute, and it wanders for a rich voice, which makes the groove wander too.

Press F and the pot on screen at that moment is saved as a pot_ file next to the program, with the time in its name. Open it in any 3D program, or print it. The sound has made an object.

Change one thing at a time:

  • 0.998F to 1.0F: the clay never relaxes, and everything you sing stays. Then 0.99F: marks last about a second
  • 0.35F to 1.5F: the hand circles faster, and each sound is cut shorter and sharper
  • 3.0F to 8.0F in the depth: deep grooves from soft sounds
  • 5.0F to 1.0F in sigma: the palm shrinks, and every sound is a pit
  • 110.0F to 220.0F: the lowest notes sit at the foot only for a higher voice
Explanations
Expansion 1: What a Mesh Is

A mesh is a list of vertices and a list of indices. Each vertex is a point with a few numbers attached:

  • position, where it is
  • color, three numbers
  • weight, one free number
  • uv, where it sits on a picture
  • normal, which way the surface faces
  • tangent, the direction along the surface

The indices are taken three at a time, and each triple is one triangle. A cube is eight vertices and thirty-six indices, twelve triangles. The two lists together are a MeshData.

You rarely write a mesh out by hand. The generators in Kinesis build one from a rule, and the whole mesh is the rule’s result.

Expansion 2: What vega.mint Does

vega.mint takes a description of a visible structure, builds everything it needs, and returns the real buffer, so you can still reach into it. It has five forms:

  • StructureConfig::Object, one shape
  • StructureConfig::Model, a set of parts, each with its own transform, optionally in a hierarchy
  • StructureConfig::Instances, one shape repeated many times, each repeat with its own transform
  • StructureConfig::Assembly, several different sources drawn together
  • StructureConfig::Isosurface, a surface pulled out of a field by a compute shader.

Every form needs .render.target_window. There is no default window, and a missing one is logged as an error.

An Object takes exactly one source:

vega.mint(StructureConfig::Object { .mesh = data, .render = { .target_window = window } });
vega.mint(StructureConfig::Object { .expression = [] { return Kinesis::generate_box({ 0.0F, 0.0F, 0.0F }, { 0.5F, 0.5F, 0.5F }, 1); }, .render = { .target_window = window } });
vega.mint(StructureConfig::Object { .writer = node, .render = { .target_window = window } });

A mesh is a shape you already have. An expression is a function mint calls once to get one. A writer is a live source you can change afterwards, and it is the one the pot uses. Giving none or more than one is an error.

Pictures for the shape go in .textures, and .render is the same render config as in the earlier sections, so .fragment_shader names your own shader.

Expansion 3: The Generators

Each generator returns a MeshData and takes the rule for the shape:

Parametric surface. A function from two numbers to a point. u and v each run from 0 to 1 across the surface, and the two segment counts say how many steps to take along each:

Kinesis::generate_parametric_surface(
    [](float u, float v) { return glm::vec3(u, std::sin(u * 6.0F) * v, v); }, 64, 64);

There are u_segs + 1 by v_segs + 1 vertices. The pot is this: u is the angle round it and v the height, and the function reads the clay table at that spot.

Explicit surface. A table of heights, one for each point of a grid. The surface is y = f(x, z), and the table is f:

Kinesis::generate_explicit_surface(heights, columns, rows, { 4.0F, 4.0F }, 1.0F);

heights holds columns * rows values, one row after another. The next arguments are the size of the surface in X and Z and how much a height is worth in the world. The membrane uses this. When your data is already a table, this is far cheaper than a parametric surface: the slopes are read from the table instead of calling a function three times for every vertex, the triangles are reused from one frame to the next, and a large grid is filled on several threads. A table of numbers from a file or the brightness of a picture makes a landscape the same way.

Revolution. A profile turned around the vertical axis. The function takes t from 0 at the foot to 1 at the lip and returns the radius and the height at that point. The sweep is how far round it goes, glm::two_pi<float>() for the full turn:

Kinesis::generate_revolution(
    [](float t) { return glm::vec2(0.5F + 0.3F * std::sin(t * 9.0F), t * 2.0F - 1.0F); }, 96, 128, glm::two_pi<float>());

A profile that does not reach a radius of 0 at its ends leaves the shape open there.

Tube. A thick thread along a list of points. It takes the points, a function that gives the radius at each position t along the thread, how many sides it has, and whether its ends are closed:

Kinesis::generate_tube(path, [](float t) { return 0.05F + 0.03F * t; }, 12, true);

Every limb of the figure and the spine of time are this. Points that sit on top of each other are allowed, so a thread that has collapsed to a point in a silent room does not break.

Box and grid. generate_box(center, half_extents, subdivisions) and generate_grid(center, extent_x, extent_z, columns, rows, normal) give the plain shapes.

In all of them the uv runs round the shape in one direction and along it in the other. The glaze uses uv.y as height up the pot, and the spine uses it as age along the thread.

Expansion 4: Shapes That Change

To change a shape while it runs, give mint a MeshWriterNode, keep hold of it, and give it a new mesh:

auto node = std::make_shared<MeshWriterNode>(vertex_count);
node->set_mesh(first_shape);

auto buffer = vega.mint(StructureConfig::Object { .writer = node, .render = { .target_window = window } });

node->set_mesh(next_shape);

Every frame, the buffer takes whatever the node holds and sends it to the graphics card. You do not register the node anywhere. set_mesh replaces the vertices and the triangles, and set_mesh_vertices replaces only the vertices when the triangles stay the same.

The shapes in this section keep the same number of vertices from frame to frame. Make the first one the size you want to keep.

Change it on the frame clock, so that the shape and the picture agree:

schedule_metro(1.0 / 60.0, [node]() { }, "name", Vruta::ProcessingToken::FRAME_ACCURATE);

The cost to watch is the size of the shape. It is rebuilt on the processor and uploaded in full every frame, so doubling the vertices doubles the work. Raise the counts of the shapes in this section until the frame rate starts to drop, and no further.

Expansion 5: Seeing the Shape

A shape is flat until you give it a view. With no view at all the window shows only the part of the shape inside a unit cube, from the front. Two presets give you a camera in one line:

bind_orbit_preset(window);
bind_fly_preset(window);

Orbit circles a point. Drag with the middle mouse button to turn around it, hold the pan modifier and drag to slide, and scroll to come in or out. It starts five units away, looking at the origin, which suits a shape a unit or two across.

Fly is a first-person camera. Drag with the right mouse button to look, and use the movement keys to travel. It also starts five units back from the origin.

Both take a config for a shape of another size. The figure stands a little under two units tall, from about 0.2 to 1.8, so it looks at a point nearer its middle:

bind_orbit_preset(window, { .focal_point = { 0.0F, 0.9F, 0.0F }, .initial_distance = 4.5F });

Models are usually far larger than a unit. A model about three hundred high needs this:

bind_orbit_preset(window, {
    .focal_point = { 0.0F, 80.0F, 0.0F },
    .initial_distance = 400.0F,
    .near_plane = 1.0F,
    .far_plane = 100000.0F,
});

If a model does not show, it is usually off to one side or too far away. Set the focal point to the middle of the model and the distance to about twice its height. Orbit and fly bind to every shape in the window, so call one after the shapes exist.

Expansion 6: Light and Colour

The default shader draws each vertex in its own colour and does no lighting, so a shape looks flat. What your fragment shader receives depends on what kind of shape it draws.

An Object passes three things:

layout(location = 0) in vec3 in_color;
layout(location = 1) in vec2 in_uv;
layout(location = 2) in vec3 in_world_pos;

It does not pass the normal. The shaders for the pot, the membrane and the spine get one anyway, from the screen:

vec3 n = normalize(cross(dFdx(in_world_pos), dFdy(in_world_pos)));

dFdx and dFdy say how the position changes from one pixel to the next, and their cross product is a line straight out of the surface. It gives each triangle a flat, faceted shade, and it points one way or the other depending on how the triangle is turned, so the light uses abs(dot(n, light)).

A Model passes the normal, and also the number of the part being drawn:

layout(location = 0) in vec3 in_color;
layout(location = 1) in vec2 in_uv;
layout(location = 2) in vec3 in_normal;
layout(location = 3) flat in uint in_slot;
layout(location = 4) in vec3 in_world_pos;

The figure uses in_slot to give every part its own colour. The number is the part’s place in drawing order, where a parent comes before its children, so it is not always the order you listed them in.

Instances pass the normal but no part number:

layout(location = 0) in vec3 in_color;
layout(location = 1) in vec2 in_uv;
layout(location = 2) in vec3 in_normal;
layout(location = 3) in vec3 in_world_pos;

Anything that is a function of position, normal or uv works the same way: stripes, rings, a glaze.

Expansion 7: Nodes That Make and Move

A node is one number per sample, made by a rule and given to whatever asks for it. The same node can go to the speakers, to another node, to an event, or to a shape. The figure uses all four.

A motion is a clock shaped by a function. A Phasor counts from 0 to 1 and starts again, as often as its frequency says. A Polynomial takes a number and returns a function of it. Chain them with >> and the clock’s number goes into the function:

auto thump = vega.Phasor(2.0F) >> vega.Polynomial([](double x) { return 0.6 * std::exp(-6.0 * x); });

Twice a second the clock runs from 0 to 1, and the function turns that into a number that jumps up and falls away. Any function of x from 0 to 1 is a motion. A sine is a wave, a rising line is a sweep, and exp(-4x) * sin(18x) is a swing that dies away.

a >> b gives back a chain node. It runs a, hands the result to b, and its output is the output of b. It is registered for you, so it runs without any more calls.

Silent nodes. A node that is registered adds its output to the speakers. chain->exclude_output_from_graph() keeps it running, with its events firing, and leaves it out of the mix. The motions of the figure and its pulse clock are silent.

One number, two places. thump->get_last_output() is the number as it is right now, and the frame callback reads it to dip the knees. The same node can go to the speakers instead. A Sine adds the output of its modulator to its own amplitude, so a Sine made with an amplitude of 0.0, the second argument, takes the modulator as its whole envelope:

auto kick = vega.Sine(80.0F, 0.0) | Audio[0];
kick->set_amplitude_modulator(thump);

Then the thump is a sound you hear and a distance you see, from one node. The figure keeps its sound to the string and gives the string its notes from an event instead.

Events. A node tells you when something happens, and you hand it a function to call:

step->on_impulse([=](const auto&) {
    *lead = !*lead;
    harp->set_fundamental(scale[static_cast<size_t>((*count)++) % scale.size()] * *octave);
    harp->pluck(0.28, 0.9);
});
raised->on_change_to(true, [octave](const auto&) { *octave = 2.0; });

The first function is the whole melody: it is called on every pulse, so it picks the next note of the scale and plucks. The second changes a number that the first one reads, which is how one event steers another.

  • Impulse::on_impulse fires on each pulse. Excluded from the graph, an Impulse is a clock that nobody hears
  • Logic turns the output of a node into on and off at a threshold. set_input_node(reach) feeds it, and on_change_to(true, ...), on_change_to(false, ...), while_true, while_false and on_change fire as it switches
  • Phasor::on_phase_wrap fires when the count starts again, and Counter::on_wrap does the same for a Counter, which counts once for every sample
  • on_tick fires on every sample, which is far more often than a frame needs

The functions run on the thread that processes the node, and that is the audio thread for a node in Audio. Keep them short: change a number or call a setter. The figure’s events do this, and the drawing reads the result on the frame clock.

A node can be changed while it runs. set_frequency in the event above takes effect on the next sample.

Expansion 8: Hearing a Block in Many Ways

The sound is a block of numbers. For a microphone it is the block that just arrived, and for a sampler it is the block now playing:

auto block = create_input_listener_buffer(0);
const auto& samples = block->get_data();

This is 512 numbers unless you changed the buffer size, and it is a new set every cycle. A block is very short, about ten milliseconds, so the measurements below are of the moment and not of a phrase.

A whole recording works the same way. vega.read_audio() returns the container, where you can turn on looping. Hooking it to the speakers gives back the buffers it plays through, one for each channel:

recording->set_looping(true);
auto block = get_io_manager()->hook_audio_container_to_buffers(recording)[0];

That buffer is the block that is playing right now, a new one every cycle, exactly like the microphone’s. The spine uses it, and every other block in this section would take it in the same one line, because they only ever read block->get_data().

Kinesis::Discrete measures a list of numbers. Each function takes the list, a number of windows, a hop and a window size, and gives one value per window. To measure the whole block as one window, ask for one window with the hop and the window both equal to its length:

const auto n = static_cast<uint32_t>(samples.size());
const double loud = Kinesis::Discrete::rms(samples, 1, n, n)[0];

What each measurement says about the moment:

  • rms and peak: how loud. peak is the largest single sample and reacts to a click. rms is the steady level
  • low_frequency_energy(samples, 1, n, n, 0.02): how much sound there is in the lowest two percent of the spectrum. For 48000 samples a second and a block of 512, that is below about 470 Hz. The size of the number depends on how loud the sound is, which is why the blocks that use it take its square root and divide by a guess
  • spectral_energy: all of the spectrum together
  • zero_crossing_rate: how often the sound crosses zero, as crossings per sample. A pure tone gives twice its frequency divided by the sample rate and noise gives about a half, so it rises with pitch and with noisiness. It is a rough stand-in for brightness, not a measure of it
  • estimate_frequency(samples, sample_rate, fallback, threshold): a pitch, from the same zero crossings. It is cheap and reads a clean tone well. A rich voice has many crossings that are not the pitch, so it wanders. It gives fallback when the block is too short or has too few crossings
  • kurtosis: how peaked the values are. A steady sine gives about -1.5, noise gives about 0, and a block holding a click gives a number many times larger. It is how the pot, the membrane and the spine tell a smooth sound from a sharp one
  • skewness, entropy, variance: more ways to say how the values are spread. entropy is higher the more evenly spread they are

Two are worth leaving alone for a block this short:

  • dynamic_range is the loudest sample over the quietest, in decibels. A block of real sound nearly always holds a sample very close to zero, so the answer is always huge and tells you nothing
  • onset_positions(samples, window, hop, threshold) finds where the spectrum jumps, but it scales its result by its own largest jump. Even a block of room noise has a largest jump, so it always finds an onset. Gate it by loudness. A simpler way to call a block a strike is to ask whether it is loud and more than twice the slow average of the level

These are the same measurements the granular section sorted by. There they were taken over a whole recording. Here they are taken over the moment.

The block can come from anywhere that gives an audio buffer. The microphone, the sampler of a recording, and the sampler of a granular stream all do, and the shape does not know which. Change the one line that makes block.

Expansion 9: A Model Is Many Parts

A model is a set of parts, and each part has a transform that places it. A Model takes a list of components, each a shape of its own:

auto net = std::make_shared<Nodes::Network::MeshNetwork>();

vega.mint(StructureConfig::Model {
    .network = net,
    .components = {
        { .expression = trunk },
        { .expression = branch, .parent_component = 0 },
    },
    .render = { .target_window = window },
});

Passing an empty network of your own is how you keep hold of the parts afterwards. Component i becomes slot i of the network.

A component names an earlier one as its parent. A part’s place in the world is its parent’s place times its own, worked out for you every frame:

auto& slot = net->get_slot(1);
slot.local_transform = glm::rotate(glm::mat4(1.0F), 0.3F, glm::vec3(0.0F, 0.0F, 1.0F));
slot.dirty = true;

Two things to know. local_transform is relative to the parent, so turning the trunk turns the branch with it, and turning an upper arm carries the forearm and the hand. And nothing shows until you set dirty = true, which is what tells the graphics card to take the new transforms.

A part turns about its own origin. So build each limb hanging from its joint, with the joint at the origin of its shape. The figure’s limb helper does this: the two points of the tube run from 0, 0, 0 down to 0, -length, 0, and the part is placed with a translation to the joint on its parent. A positive length hangs down, and a negative one rises, which is how the torso and the head are made.

The figure has one slot for each part, numbered in the order they were listed: torso, head, left arm, right arm, left leg, right leg. Its joint helper builds a transform from a place and three angles, and glm::quat(angles) turns the angles into a rotation.

Adding a forearm is one more component whose parent is the arm, and one more joint line that places it at the end of the arm. Turn the arm and the forearm turns with it.

A loaded file is the same thing. vega.read_mesh_network("path/to/model.fbx") | Graphics gives a network with one slot for each mesh in the file, and the same slots() and dirty apply. A part of a loaded model turns about the origin of the model, so a part that sits away from the middle swings round it. The pictures that came with the file are already on the parts.

Expansion 10: Weight and Memory

Setting a shape directly from a number makes it twitch with the number and stop with it. A body, or a piece of clay, does not do that. Four ways to let a sound push without dictating.

Follow slowly. The shape moves a fraction of the way to where the sound says, every frame. A fraction of 0.1 is slow and 0.5 is quick:

wheel->height += (target - wheel->height) * 0.2F;

The hand of the pot and the thread of the spine use this. It is a one pole filter, and it is the simplest weight there is.

Be kicked. A strike starts a motion, and the motion plays out by itself. The whip of the figure is this. It needs no spring code, because a function of the time since the strike already is a spring:

auto whip = vega.Phasor(2.0F) >> vega.Polynomial([](double x) { return std::exp(-4.0 * x) * std::sin(18.0 * x); });

The clock restarts the function on every cycle, so the arm is thrown, overshoots, swings back and settles, and is thrown again. A bigger number inside exp is a tighter spring, and a smaller one keeps it swinging. Change the speed, not the place. The sound does not set where the body is, only how fast it moves. A clock can be speeded up while it runs:

auto beat = vega.Phasor(2.0F);
auto thump = beat >> vega.Polynomial(decay);
raised->on_change_to(true, [beat](const auto&) { beat->set_frequency(3.0F); });

So a body keeps its rhythm and the sound only pushes it faster or slower. A shape that moves at a rate set by the sound looks nothing like one that sits at a place set by it.

Keep a table. The sound writes into a table and a rule slowly forgets it. This is memory:

for (auto& cell : *clay) {
    cell *= 0.998F;
}

The shape is built from the table every frame. The pot uses this, and so does the membrane below, where the table is a wave that spreads by itself. Everything the sound has written is still there, softening, for as long as the forgetting takes.

Which of these a mapping needs is the question to ask of each side of a sound. A strike wants a kick, a pitch wants a place, a bass wants a speed, and a gesture that should outlast the sound wants a table.

Expansion 11: One Shape, Many Times

Instances draw one shape many times in a single call, each with its own transform:

auto choir = vega.mint(StructureConfig::Instances {
    .prototype_expression = vase_shape,
    .transforms = places,
    .render = { .target_window = window, .fragment_shader = "choir.frag" },
});

auto net = choir->get_network();
net->get_slot(2).transform = glm::translate(glm::mat4(1.0F), glm::vec3(0.0F, 1.0F, 0.0F));
net->get_slot(2).dirty = true;

Every copy shares the one shape and holds only a transform, so twelve copies or twelve thousand copies cost one shape. The transform is a full matrix, so a copy can be moved, turned and stretched.

Give each copy its own side of the sound. Put the measurements in a list and let copy i read item i modulo the length of the list:

const std::array<float, 4> felt { loud, bass, sharp, pitch };
net->get_slot(i).transform = places[i] * glm::rotate(glm::mat4(1.0F), 3.0F * felt[i % 4], glm::vec3(0.0F, 1.0F, 0.0F));
net->get_slot(i).dirty = true;

A ring of copies then shows the character of the sound as a ring, where each position turns to a different aspect of it. The same list works on the slots of a loaded model.

When the copies should not all be the same shape, use a Model. When you want many and want them to move by a rule, an InstanceFieldOperator binds a function to each copy with bind_transform.

Expansion 12: Keeping a Shape

Whatever a shape has become can be saved. The pot does it on a key:

#include "MayaFlux/IO/Model/ModelExport.hpp"

if (!IO::save_mesh_snapshot(node, "pot_{}.gltf")) {
    MF_PRINT("pot: export failed");
}

The {} in the name is replaced by the time in milliseconds, so every press gives a new file and none is overwritten. It saves the shape as it is at that moment, and the shape is a regular gltf file that Blender and other programs open.

The same call takes a mint buffer, a loaded model’s parts, or a writer node. For a model, each part is saved where it currently stands, so a figure that has been dancing is saved in the pose it was in.

Sound made it, and a file keeps it.

Try It → Recap

A Membrane That Keeps Ringing

data/shaders/skin.frag

#version 460

layout(location = 0) in vec3 in_color;
layout(location = 1) in vec2 in_uv;
layout(location = 2) in vec3 in_world_pos;

layout(location = 0) out vec4 out_color;

void main()
{
    vec3 n = normalize(cross(dFdx(in_world_pos), dFdy(in_world_pos)));
    float light = abs(dot(n, normalize(vec3(-0.4, 0.9, 0.3))));
    float h = clamp(in_world_pos.y * 0.8 + 0.5, 0.0, 1.0);
    vec3 colour = mix(vec3(0.05, 0.10, 0.22), vec3(0.95, 0.85, 0.70), h);
    out_color = vec4(colour * (0.2 + 0.8 * light), 1.0);
}
#include "MayaFlux/IO/Model/ModelExport.hpp"

void compose() {
    auto window = create_window({ "A Membrane That Keeps Ringing", 1280, 800 });
    window->show();

    auto block = create_input_listener_buffer(0);

    constexpr int size = 64;
    auto height = std::make_shared<std::vector<float>>(size * size, 0.0F);
    auto speed = std::make_shared<std::vector<float>>(size * size, 0.0F);

    auto node = std::make_shared<MeshWriterNode>(size * size);
    node->set_mesh(Kinesis::generate_explicit_surface(*height, size, size, { 4.0F, 4.0F }, 1.0F));

    auto skin = vega.mint(StructureConfig::Object {
        .writer = node,
        .render = { .target_window = window, .fragment_shader = "skin.frag" },
    });

    bind_orbit_preset(window);

    schedule_metro(1.0 / 60.0, [node, block, height, speed]() {
        const auto& samples = block->get_data();
        const auto n = static_cast<uint32_t>(samples.size());
        if (n < 16) {
            return;
        }

        const auto loud = static_cast<float>(Kinesis::Discrete::rms(samples, 1, n, n)[0]);
        const auto sharp = static_cast<float>(Kinesis::Discrete::kurtosis(samples, 1, n, n)[0]);
        const auto pitch = static_cast<float>(
            Kinesis::Discrete::estimate_frequency(samples, Config::get_sample_rate(), 0.0, 0.0));

        if (loud > 0.01F && pitch > 0.0F) {
            const float across = std::clamp(std::log2(pitch / 110.0F) / 4.0F, 0.0F, 1.0F);
            const float depth = std::clamp((sharp + 1.5F) / 8.0F, 0.0F, 1.0F);
            const int cx = 3 + static_cast<int>(across * (size - 7));
            const int cy = 3 + static_cast<int>(depth * (size - 7));

            for (int dy = -2; dy <= 2; ++dy) {
                for (int dx = -2; dx <= 2; ++dx) {
                    const float falloff = std::exp(-0.5F * static_cast<float>(dx * dx + dy * dy));
                    (*speed)[(cy + dy) * size + cx + dx] += loud * 1.0F * falloff;
                }
            }
        }

        auto& h = *height;
        auto& v = *speed;

        for (int y = 1; y < size - 1; ++y) {
            for (int x = 1; x < size - 1; ++x) {
                const int k = y * size + x;
                const float curve = h[k - 1] + h[k + 1] + h[k - size] + h[k + size] - 4.0F * h[k];
                v[k] = (v[k] + 0.35F * curve) * 0.995F;
            }
        }
        for (int k = 0; k < size * size; ++k) {
            h[k] += v[k];
        }

        node->set_mesh(Kinesis::generate_explicit_surface(h, size, size, { 4.0F, 4.0F }, 1.0F));
    }, "membrane", Vruta::ProcessingToken::FRAME_ACCURATE);

    on_key_pressed(window, IO::Keys::F, [node]() {
        if (!IO::save_mesh_snapshot(node, "membrane_{}.gltf")) {
            MF_PRINT("membrane: export failed");
        }
    });
}

Run it. A flat skin lies in the window. Make a sound and it is struck, and the sound only strikes it:

  • How high the sound is picks where along the skin it lands, low at the left and high at the right
  • How sharp the sound is picks how near it lands, a smooth tone at the far edge and a click close to you
  • How loud the sound is sets how hard the blow is

Whatever lands makes rings that spread, reach the edge, come back, and cross the rings from the other blows. After the sound has stopped the skin is still moving, and a loud clap at one spot leaves a pattern that takes seconds to die. The surface is a map of what kind of sound it was, and also a record of it.

Each frame, every point is pulled toward the average of its four neighbours, which is what makes a ring travel. The sound only adds speed to a few points.

Press F while it rings and you have the ripple frozen as an object.

Change one thing at a time:

  • 0.995F to 0.9995F: it rings for a long time, and the patterns pile up
  • 0.35F to 0.1F: slow, heavy waves. Do not raise it above 0.5F, or the skin blows up
  • 1.0F to 4.0F in the blow: a tall surface from soft sounds
  • size = 64 to 128: a finer skin, four times the work each frame
  • Swap across and depth: pitch lands front to back, and sharpness left to right

A Spine of Time

data/shaders/ember.frag

#version 460

layout(location = 0) in vec3 in_color;
layout(location = 1) in vec2 in_uv;
layout(location = 2) in vec3 in_world_pos;

layout(location = 0) out vec4 out_color;

void main()
{
    vec3 n = normalize(cross(dFdx(in_world_pos), dFdy(in_world_pos)));
    float light = abs(dot(n, normalize(vec3(0.5, 0.8, 0.6))));
    float age = in_uv.y;
    vec3 old = vec3(0.10, 0.14, 0.35);
    vec3 now = vec3(1.0, 0.72, 0.35);
    vec3 colour = mix(old, now, age * age);
    out_color = vec4(colour * (0.25 + 0.75 * light), 1.0);
}
void compose() {
    auto window = create_window({ "A Spine of Time", 1280, 800 });
    window->show();

    auto recording = vega.read_audio();
    if (!recording) {
        return;
    }

    recording->set_looping(true);
    auto block = get_io_manager()->hook_audio_container_to_buffers(recording)[0];

    constexpr size_t points = 120;
    auto path = std::make_shared<std::vector<glm::vec3>>(points, glm::vec3(0.0F));
    auto loudness = std::make_shared<std::vector<double>>(points, 0.0);

    auto thread = [path, loudness] {
        const auto taper = Kinesis::TimeMaps::piecewise_linear(*loudness, 1.0);
        return Kinesis::generate_tube(
            std::span(*path),
            [&taper](float t) { return 0.02F + 0.2F * static_cast<float>(taper(t)); },
            12, true);
    };

    auto node = std::make_shared<MeshWriterNode>(points * 13 + 2);
    node->set_mesh(thread());

    auto spine = vega.mint(StructureConfig::Object {
        .writer = node,
        .render = { .target_window = window, .fragment_shader = "ember.frag" },
    });

    bind_orbit_preset(window);

    schedule_metro(1.0 / 60.0, [node, block, path, loudness, thread]() {
        const auto& samples = block->get_data();
        const auto n = static_cast<uint32_t>(samples.size());
        if (n < 16) {
            return;
        }

        const auto loud = static_cast<float>(Kinesis::Discrete::rms(samples, 1, n, n)[0]);
        const auto bass = static_cast<float>(Kinesis::Discrete::low_frequency_energy(samples, 1, n, n, 0.02)[0]);
        const auto sharp = static_cast<float>(Kinesis::Discrete::kurtosis(samples, 1, n, n)[0]);
        const auto pitch = static_cast<float>(
            Kinesis::Discrete::estimate_frequency(samples, Config::get_sample_rate(), 0.0, 0.0));
        const bool heard = loud > 0.01F;

        const float across = heard && pitch > 0.0F
            ? std::clamp(std::log2(pitch / 110.0F) / 4.0F, 0.0F, 1.0F)
            : 0.5F;
        const float depth = heard ? std::clamp((sharp + 1.5F) / 8.0F, 0.0F, 1.0F) : 0.5F;
        const float weight = heard ? std::min(1.0F, std::sqrt(bass) / 16.0F) : 0.5F;

        const glm::vec3 target(3.0F * (across - 0.5F), 3.0F * (depth - 0.5F), 3.0F * (weight - 0.5F));
        const glm::vec3 last = path->back();

        std::rotate(path->begin(), path->begin() + 1, path->end());
        path->back() = last + (target - last) * 0.25F;

        std::rotate(loudness->begin(), loudness->begin() + 1, loudness->end());
        loudness->back() = std::min(1.0, static_cast<double>(loud) * 4.0);

        node->set_mesh(thread());
    }, "spine", Vruta::ProcessingToken::FRAME_ACCURATE);
}

Run it. A dialog opens, and you choose a recording. It plays on a loop through the speakers, and the block that is playing right now is the one the thread listens to. A short thread grows in the window as the sound goes, about two seconds long, with the newest end hot and the oldest end cold. Its place in space is the character of the sound: pitch is left and right, sharpness is up and down, and how much low sound there is is forward and back. It is thick where the sound was loud and a wire where it was quiet.

This is a picture of what the sound was like, not of its waveform. A passage that holds one note is a short line, a rising scale is a long sweep to the right, and speech is a tangle that goes back on itself at every syllable. The same passage twice draws nearly the same loop twice. In a silence the thread shrinks into the middle.

recording->set_looping(true) is what keeps it going when the file ends. The hook gives back the buffer the file is playing through, one for each channel, and [0] is the first. From there it is the same kind of buffer as the microphone’s, and the measurements are the same.

Change one thing at a time:

  • 0.25F to 0.6F: the thread follows the sound jerkily. 0.05F is languid, and it only draws the long shape of a phrase
  • points = 120 to 300: a longer past, at a higher cost every frame
  • 3.0F to 6.0F in target: a thread that fills the room
  • 12 to 4 sides: a ribbon of a few flat faces, hard-edged

What You Achieved

You have:

  • Plucked a string from a clock and danced to the same clock, chained a clock into a function, and let events pick the notes and the arm that leads
  • Pressed a pot with a voice, so the pitch picked where, the loudness picked how deep, and the sharpness picked the shape of the hand
  • Struck a skin that goes on ringing after the sound has stopped
  • Drawn the character of a sound as a thread through space
  • Kept any of these as a file

Nothing here drew a shape by hand. A shape is a list of points and a list of triangles, a generator made the list from a rule, and the sound was one of the inputs to the rule. Each block heard a different side of the sound and let the shape have its own weight and memory. In the next section the form fills space as a volume.


Simulations You Can Play

The Next Step

A simulation sounds like something that belongs to heavy software and long renders. It does not. A simulation is a grid of numbers, a few rules that rewrite the grid sixty times a second, and a way to draw it. That is data, like a recording or a picture, and it can be loaded, listened to, steered and rewritten in the same way.

Smoke is a grid with a density in every cell. Heat is a second grid. The rules say that heat rises, that density is carried along, and that the air cannot be squeezed. Everything else is a choice. How thick the air is, how fast the heat fades, where something is added and how quickly, and what the whole thing looks like are numbers you set, and any of them can be driven by a sound, a pointer, or another number.

The two blocks below show it. The first loads density and temperature saved by another program, moves the smoke under a new flow, and lets a drone add heat and smoke. The second builds a fire from nothing, and puts your hands on it.

Fire From a File

#include "MayaFlux/SimulationIncludes.hpp"

void compose() {
    auto window = create_window({ "Fire From a File", 1600, 900 });
    window->show();

    auto volume = vega.read_volume();
    if (!volume) {
        return;
    }

    const auto bounds = volume->get_bounds();
    const glm::vec3 centre = (bounds.min + bounds.max) * 0.5F;
    const float radius = glm::length(bounds.max - bounds.min) * 0.5F;

    Buffers::ScalarRef density { .name = "density" };
    Buffers::ScalarRef temperature { .name = "temperature" };

    vega.evolve(
        volume,
        { .time_step = 1.0F / 60.0F,
            .viscosity = 0.00015F * radius,
            .jacobi_iterations = 28,
            .carried = { { density, 0.997F } },
            .buoyancy = Buffers::VolumeGridBuffer::FlowConfig::Buoyancy {
                .temperature = temperature,
                .density = density,
                .temperature_gain = 1.0F,
                .density_gain = 0.9F * radius } },
        { .target_window = window });

    volume->seed("velocity", Kinesis::VectorField { [](glm::vec3) { return glm::vec3(0.0F); } });
    volume->seed("pressure", Kinesis::SpatialField { [](glm::vec3) { return 0.0F; } });

    auto smoke = std::make_shared<Buffers::RaymarchBuffer>(
                     volume->get_lattice(),
                     [volume] { return volume->read_handle("density"); },
                     volume->get_field_bytes("density"))
        | Graphics;

    smoke->setup_rendering({ .target_window = window });
    smoke->get_render_processor()->set_view_transform(
        Kinesis::look_at_perspective(
            centre + glm::vec3(radius, radius * 0.6F, radius) * 1.5F, centre,
            0.9F, 16.0F / 9.0F, radius * 0.02F, radius * 10.0F));

    smoke->march_processor()->configure({
        .absorption = 10.0F,
        .emission = 2.0F,
        .cool = { 0.60F, 0.10F, 0.02F },
        .hot = { 1.00F, 0.85F, 0.40F },
        .threshold = 0.01F });

    const float height = bounds.max.y - bounds.min.y;
    const glm::vec3 base(centre.x, bounds.min.y + 0.15F * height, centre.z);

    auto heat = volume->influx("temperature", { .center = base, .rate = 0.0F });
    auto soot = volume->influx("density", { .center = base, .rate = 0.0F });

    constexpr std::array<double, 7> roots { 55.0, 110.0, 164.8, 220.0, 329.6, 440.0, 659.3 };

    auto drone = std::make_shared<std::vector<std::shared_ptr<Nodes::Generator::Sine>>>();
    auto tuning = std::make_shared<std::array<double, 7>>();
    for (size_t i = 0; i < roots.size(); ++i) {
        (*tuning)[i] = roots[i] + get_uniform_random(-3.0, 3.0);
        auto breath = vega.Sine(0.04F + 0.02F * static_cast<float>(i), 0.12);
        auto partial = vega.Sine(static_cast<float>((*tuning)[i]), 0.25) | Audio[i % 2];
        partial->set_amplitude_modulator(breath);
        drone->push_back(partial);
    }

    auto kick = std::make_shared<float>(0.0F);
    auto armed = std::make_shared<bool>(false);

    Kriya::EventChain chain(*get_scheduler());
    chain.then([armed]() { *armed = true; }, 12.0).start();

    schedule_metro(2.5, [drone, tuning, roots, kick, armed]() {
        double moved = 0.0;
        for (size_t i = 0; i < roots.size(); ++i) {
            const double next = roots[i] + get_uniform_random(-4.0, 4.0);
            moved += std::abs(next - (*tuning)[i]);
            (*tuning)[i] = next;
            (*drone)[i]->set_frequency(static_cast<float>(next));
        }
        if (*armed) {
            *kick = static_cast<float>(std::clamp(moved / 14.0, 0.25, 1.0));
        }
    });

    schedule_metro(
        1.0 / 60.0,
        [kick, heat, soot]() {
            *kick *= 0.97F;
            heat->set_rate(5.0F * *kick);
            soot->set_rate(2.4F * *kick);
        },
        "fire_kick", Vruta::ProcessingToken::FRAME_ACCURATE);
}

Run this code. A file dialog opens. Choose a .vdb file with a density field and a temperature field. The fire sample from the OpenVDB website works, and so does a fire you have exported from another program.

The loaded density and temperature supply the starting image. The new solver moves velocity and density; temperature affects buoyancy but is not in this block’s carried list. A drone of seven slightly detuned tones plays underneath it. For the first twelve seconds nothing connects the two. After that, every tuning change, scheduled every two and a half seconds, adds heat and smoke at the bottom. The amount added depends on how far the tuning moved.

You do nothing here. The sound and the fire do it between them.

Change one thing at a time:

  • 0.997F to 0.9995F: the smoke hangs around much longer
  • 0.00015F to 0.0015F: the air thickens, and the fire turns slow and heavy
  • 12.0 to 0.0: the first scheduled tuning change can feed the fire, about two and a half seconds in
  • 5.0F * *kick to 15.0F * *kick: each tuning change adds heat three times as fast while the kick lasts
  • roots: your own seven frequencies. Their spacing changes the drone’s beating; flares still follow the scheduled tuning changes
Also: Check What Is In the File

A file from another program names its fields however it likes. This block assumes density and temperature. To see what yours holds, add this after the file is loaded:

for (const auto& name : volume->get_field_names()) {
    MF_PRINT("field {} stride {}\n", name, volume->get_field_stride(name));
}

A stride of 4 is one number per cell. A stride of 16 is a direction. Use the name of a field with a stride of 4 for density.

Hands on the Fire

The box comes first. It is a fire that burns without end, with a camera, a pointer and a few keys, kept in one function so that the part that matters is short. Paste both parts into one file.

struct Hand {
    glm::vec3 at { 0.0F };
    bool down = false;
    bool smoke = false;
    float size = 1.0F;
    float burst = 0.0F;
};

struct Ember {
    std::shared_ptr<Core::Window> window;
    std::shared_ptr<Buffers::VolumeGridBuffer> volume;
    std::shared_ptr<Buffers::InfluxProcessor> heat;
    std::shared_ptr<Buffers::InfluxProcessor> soot;
    std::shared_ptr<Hand> hand = std::make_shared<Hand>();
    glm::vec3 source { 0.0F, -0.8F, 0.0F };
};

Ember make_ember() {
    constexpr float fov = 0.85F;
    constexpr float aspect = 16.0F / 9.0F;
    constexpr float distance = 2.9F;

    Ember e;
    e.window = create_window({ "Hands on the Fire", 1600, 900 });
    e.window->show();

    const Kinesis::Lattice3D lattice {
        .resolution = glm::uvec3(64),
        .bounds = { .min = glm::vec3(-1.0F), .max = glm::vec3(1.0F) }
    };

    auto volume = std::make_shared<Buffers::VolumeGridBuffer>(lattice);

    auto density = volume->declare_scalar("density");
    auto temperature = volume->declare_scalar("temperature");

    e.volume = vega.evolve(
        volume,
        { .time_step = 1.0F / 60.0F,
            .viscosity = 0.00012F,
            .jacobi_iterations = 32,
            .walls = true,
            .carried = { { temperature, 0.992F }, { density, 0.9985F } },
            .buoyancy = Buffers::VolumeGridBuffer::FlowConfig::Buoyancy {
                .temperature = temperature,
                .density = density,
                .temperature_gain = 3.4F,
                .density_gain = 1.1F } },
        { .target_window = e.window });

    volume->seed("velocity", Kinesis::VectorField { [](glm::vec3) { return glm::vec3(0.0F); } });
    volume->seed("pressure", Kinesis::SpatialField { [](glm::vec3) { return 0.0F; } });
    volume->seed(temperature, Kinesis::SpatialField { [](glm::vec3) { return 0.0F; } });
    volume->seed(density, Kinesis::SpatialField { [](glm::vec3) { return 0.0F; } });

    e.heat = volume->influx("temperature", { .center = e.source, .radius = 0.16F, .rate = 2.8F });
    e.soot = volume->influx("density", { .center = e.source, .radius = 0.2F, .rate = 1.2F });

    const auto view = [] {
        return Kinesis::look_at_perspective(
            glm::vec3(0.0F, 0.0F, distance), glm::vec3(0.0F),
            fov, aspect, 0.1F, 40.0F);
    };

    Portal::Graphics::BlendAttachmentConfig additive;
    additive.blend_enable = true;
    additive.src_color_factor = Portal::Graphics::BlendFactor::SRC_ALPHA;
    additive.dst_color_factor = Portal::Graphics::BlendFactor::ONE;
    additive.src_alpha_factor = Portal::Graphics::BlendFactor::ZERO;
    additive.dst_alpha_factor = Portal::Graphics::BlendFactor::ONE;

    auto fire = std::make_shared<Buffers::RaymarchBuffer>(
                    lattice,
                    [volume, temperature] { return volume->read_handle(temperature); },
                    volume->get_field_bytes(temperature))
        | Graphics;

    fire->setup_rendering({ .target_window = e.window });
    fire->get_render_processor()->set_view_transform_source(view, false, Portal::Graphics::CullMode::FRONT);
    fire->get_render_processor()->set_blend_attachment(additive);
    fire->march_processor()->configure({
        .step_scale = 0.55F,
        .absorption = 7.0F,
        .emission = 3.6F,
        .cool = { 0.60F, 0.08F, 0.01F },
        .hot = { 1.00F, 0.90F, 0.58F },
        .threshold = 0.05F });

    auto smoke = std::make_shared<Buffers::RaymarchBuffer>(
                     lattice,
                     [volume, density] { return volume->read_handle(density); },
                     volume->get_field_bytes(density))
        | Graphics;

    smoke->setup_rendering({ .target_window = e.window });
    smoke->get_render_processor()->set_view_transform_source(view, false, Portal::Graphics::CullMode::FRONT);
    smoke->march_processor()->configure({
        .absorption = 10.0F,
        .cool = { 0.28F, 0.28F, 0.32F },
        .hot = { 0.85F, 0.83F, 0.80F },
        .threshold = 0.006F });

    auto window = e.window;
    auto hand = e.hand;

    auto reach = [window](double x, double y) {
        const auto ndc = normalize_coords(x, y, window);
        const float half = std::tan(fov * 0.5F) * distance;
        return glm::vec3(
            std::clamp(ndc.x * half * aspect, -1.0F, 1.0F),
            std::clamp(ndc.y * half, -1.0F, 1.0F),
            0.0F);
    };

    on_mouse_move(window, [hand, reach](double x, double y) { hand->at = reach(x, y); });
    on_mouse_pressed(window, IO::MouseButtons::Left, [hand, reach](double x, double y) {
        hand->at = reach(x, y);
        hand->down = true;
    });
    on_mouse_released(window, IO::MouseButtons::Left, [hand](double, double) { hand->down = false; });
    on_mouse_pressed(window, IO::MouseButtons::Right, [hand, reach](double x, double y) {
        hand->at = reach(x, y);
        hand->smoke = true;
    });
    on_mouse_released(window, IO::MouseButtons::Right, [hand](double, double) { hand->smoke = false; });
    on_scroll(window, [hand](double, double dy) {
        hand->size = std::clamp(hand->size * (1.0F + 0.1F * static_cast<float>(dy)), 0.4F, 3.0F);
    });
    on_key_pressed(window, IO::Keys::Space, [hand]() { hand->burst = 1.0F; });

    return e;
}

Now the part that matters. It measures the fire, relates the measure to your hand, and turns the result into the fire’s rates and into sound:

#include "MayaFlux/SimulationIncludes.hpp"

void compose() {
    using namespace Nodes::Network;

    auto e = make_ember();
    auto hand = e.hand;

    auto cells = std::make_shared<std::vector<float>>(e.volume->get_lattice().cell_count());
    auto fill = std::make_shared<double>(0.0);

    schedule_metro(
        0.5,
        [e, cells, fill]() {
            e.volume->read_field("density", cells->data(), cells->size() * sizeof(float));

            size_t held = 0;
            for (const float v : *cells) {
                if (std::isfinite(v) && v > 0.05F) {
                    ++held;
                }
            }
            *fill = static_cast<double>(held) / static_cast<double>(cells->size());
        },
        "ember_survey", Vruta::ProcessingToken::FRAME_ACCURATE);

    auto relation = std::make_shared<RelationNetwork>();
    relation->set_output_mode(OutputMode::GRAPHICS_BIND);

    relation->add_slots({
        { .name = "x" },
        { .name = "y" },
        { .name = "down" },
        { .name = "speed" },
        { .name = "fill" },
        { .name = "heat" },
        { .name = "soot" },
        { .name = "size" },
        { .name = "smoke" },
        { .name = "burst" },
    });

    auto read = relation->add<StepOperator>([](const std::shared_ptr<StepOperator>& self, std::span<RelationSlot> slots, uint32_t) {
        const double x = self->parameter("x");
        const double y = self->parameter("y");
        slots[3].level = std::hypot(x - slots[0].level, y - slots[1].level) * 60.0;
        slots[0].level = x;
        slots[1].level = y;
        slots[2].level = self->parameter("down");
        slots[4].level = self->parameter("fill");
        slots[7].level = self->parameter("size");
        slots[8].level = self->parameter("smoke");
        slots[9].level = self->parameter("burst");
    });
    read->map("x", Source([hand] { return static_cast<double>(hand->at.x); }));
    read->map("y", Source([hand] { return static_cast<double>(hand->at.y); }));
    read->map("down", Source([hand] { return hand->down ? 1.0 : 0.0; }));
    read->map("fill", Source([fill] { return *fill; }));
    read->map("size", Source([hand] { return static_cast<double>(hand->size); }));
    read->map("smoke", Source([hand] { return hand->smoke ? 1.0 : 0.0; }));
    read->map("burst", Source([hand] { return static_cast<double>(hand->burst); }));

    relation->add<DeriveOperator>(5, std::vector<size_t> { 2, 3, 9 },
        [](std::span<const double> in) {
            return 2.8 + in[0] * (1.6 + 1.4 * std::min(in[1], 2.0)) + in[2] * 5.0;
        });

    relation->add<DeriveOperator>(6, std::vector<size_t> { 5, 4, 8 },
        [](std::span<const double> in) {
            return (in[0] * 0.43 + in[2] * 2.4) * (1.0 - std::clamp(in[1] * 2.5, 0.0, 1.0));
        });

    relation->add<StepOperator>([e](const std::shared_ptr<StepOperator>&, std::span<RelationSlot> slots, uint32_t) {
        const bool held = slots[2].level > 0.5 || slots[8].level > 0.5;
        const glm::vec3 at(static_cast<float>(slots[0].level), static_cast<float>(slots[1].level), 0.0F);
        const auto size = static_cast<float>(slots[7].level);

        e.heat->set_center(held ? at : e.source);
        e.soot->set_center(held ? at : e.source);
        e.heat->set_radius(0.16F * size);
        e.soot->set_radius(0.2F * size);
        e.heat->set_rate(static_cast<float>(slots[5].level));
        e.soot->set_rate(static_cast<float>(slots[6].level));
    });

    schedule_metro(
        1.0 / 60.0,
        [hand]() { hand->burst *= 0.93F; },
        "ember_burst", Vruta::ProcessingToken::FRAME_ACCURATE);

    auto net = relation | Graphics;

    auto low = vega.Sine(110.0F, 0.0);
    auto high = vega.Sine(660.0F, 0.0);

    auto sound = std::make_shared<RelationNetwork>();
    sound->set_output_mode(OutputMode::AUDIO_COMPUTE);
    sound->set_output_scale(2.0);
    sound->add_slots({ { .name = "low", .node = low }, { .name = "high", .node = high } });

    auto voice = sound->add<StepOperator>([low, high](const std::shared_ptr<StepOperator>& self, std::span<RelationSlot>, uint32_t) {
        const double lift = std::clamp((self->parameter("heat") - 2.8) / 4.0, 0.0, 1.0);
        const double stir = std::clamp(self->parameter("speed") / 2.0, 0.0, 1.0) * self->parameter("down");
        low->set_amplitude(0.04 + 0.14 * lift);
        high->set_amplitude(0.12 * stir);
        high->set_frequency(static_cast<float>(330.0 + 165.0 * (self->parameter("height") + 1.0)));
    });
    voice->map("heat", Source(net, 5));
    voice->map("speed", Source(net, 3));
    voice->map("down", Source(net, 2));
    voice->map("height", Source(net, 1));

    sound->add<AdvanceOperator>();
    sound->add<CombineOperator>(2);

    sound | Audio[0];
    auto speaker = vega.NetworkAudioBuffer(0, 512, sound) | Audio[{ 0, 1 }];
}

Run this code. A fire burns at the bottom of a box, and goes on burning. It never settles, because a small flame is always being added.

Move the pointer, and the fire follows it when you hold a button:

  • Hold the left button to move the flame to the pointer and feed it. The faster you drag, the hotter it burns.
  • Hold the right button to put smoke in and no heat, which is a cold dark plume.
  • Scroll to make the flame wider or narrower.
  • Press Space for a burst of heat that fades over about a second.

The fire is also its own limit. Every half second the box is measured for how much of it is filled with smoke, and the fuller it is, the less smoke the flame makes. A box that has filled up thins itself out, and nothing needs resetting.

You also hear it. A low tone swells as the flame gets hotter, and a high tone sounds while you drag and follows how fast you go. Its pitch follows how high the pointer is.

Everything between your hand and the fire passes through one small network of named values: where the pointer is, how fast it moves, how full the box is, and how hot and sooty the flame should be. You can read them, change them, and send them anywhere else.

Change one thing at a time:

  • 2.8 in the heat formula to 0.0: the steady heat source stops; a held button or a Space burst can still add heat
  • 0.992F to 0.98F: the heat fades faster, and the flame is short
  • 1.1F in density_gain to 0.2F: smoke stops dragging the plume and it rises lazily
  • 2.5 in the soot formula to 0.5: the box can fill almost completely before the soot is even halved
  • glm::uvec3(64) to glm::uvec3(96): a finer fire at several times the cost

You have, in both blocks:

  • A simulation that you loaded or built, running on its own
  • Ways to feed it that you can change while it runs: a sound, a pointer, a key
  • A picture of it that you chose, and not one that came with it
Explanations
Expansion 1: A Simulation Is Data

A fluid solver feels like a separate world with its own tools. In MayaFlux it is not one. The volume is a buffer of numbers, the rules are processors that run on it every frame, and the picture is another buffer that reads it. All three are objects you hold, and every setting on them is a number you can change at any moment.

That is why a drone can flare a fire, or a mouse can feed one, with no special support. A drone changing its tuning is a function that sets a rate. A pointer is a function that sets a position. The fire does not care where the number came from.

It also means you are not limited to a simulation as it ships. You can start from a file someone else made, as in the first block, or from a function you wrote, as in the second Try It. You can measure it while it runs, as in the second block, and let what you measure change how it behaves. You can save what it does and load it again later, and the file is the same kind of thing that arrived.

Expansion 2: What a Volume Is

A volume is a box divided into a grid of cells. Kinesis::Lattice3D says how many cells there are and what part of the world the box covers:

const Kinesis::Lattice3D lattice {
    .resolution = glm::uvec3(64),
    .bounds = { .min = glm::vec3(-1.0F), .max = glm::vec3(1.0F) }
};
auto volume = std::make_shared<Buffers::VolumeGridBuffer>(lattice);

Each thing the volume remembers is a field, a name with one value for every cell:

auto density = volume->declare_scalar("density");
auto flow = volume->declare_vector("velocity");

A scalar field is one number per cell, how much smoke there is. A vector field has a direction in every cell, here which way the air is moving. declare_scalar and declare_vector give a field that keeps the previous step as well, which the rules need to read from one and write to the other. declare_scratch gives one that does not.

Memory is the cost to watch. A 64 by 64 by 64 box has 262,144 cells. A scalar field is one megabyte for each copy it keeps, and a field that keeps two is two. A direction field takes four times a scalar field. A flow adds several of these. Raise the resolution a little and the cost rises with the cube of it, so 96 cells a side is more than three times 64. Lower 64 on a small graphics card, and raise it on a large one.

To give a field its starting values, hand it a function from a position to a number:

volume->seed(density, Kinesis::SpatialField { [](glm::vec3 p) { return std::exp(-glm::dot(p, p) / 0.05F); } });
volume->seed("velocity", Kinesis::VectorField { [](glm::vec3 p) { return glm::vec3(-p.z, 0.0F, p.x); } });

The first puts a soft ball of smoke at the middle. The second sets the whole box turning about the vertical. The function runs once for every cell.

Expansion 3: Loading a Volume

vega.read_volume() opens a file dialog, and vega.read_volume("path/to/file.vdb") takes a path. Either gives back a volume with one field for each grid in the file, already registered. A .vdb file can come from any program that writes OpenVDB.

A file you did not make has its own scale. Nothing says it is two units across, so the first block works out its size from what the volume reports:

const auto bounds = volume->get_bounds();
const glm::vec3 centre = (bounds.min + bounds.max) * 0.5F;
const float radius = glm::length(bounds.max - bounds.min) * 0.5F;

The camera sits at a multiple of that radius, and the thickness of the air and the pull of the smoke are multiplied by it. A flow tuned for a box two units across means nothing at another size, and this is what carries it over.

This block expects fields named density and temperature in the file. vega.evolve adds missing flow fields, including velocity, pressure and divergence; it reuses any that the file already supplies. The block then seeds velocity and pressure to zero, even if they came from the file. It carries density through the flow while temperature remains at its loaded positions, apart from the heat added by influx().

One line you will see in the log is expected: setup_rendering: no SurfaceConfig was supplied at construction. vega.evolve always tries to draw a volume as a solid surface, and this volume was not built for one. The flow is already running when the line appears, and the next expansion shows what draws it instead. Every other block here that gives vega.evolve a volume built without a SurfaceConfig logs the same line.

Expansion 4: The Flow

vega.evolve(volume, config, render) takes a volume that has not been drawn yet, and does the whole sequence of setting it going: it declares what is missing, registers it, builds the stages that move the fluid, and attaches rendering. The config says how the fluid behaves:

{ .time_step = 1.0F / 60.0F,
    .viscosity = 0.00012F,
    .jacobi_iterations = 32,
    .walls = true,
    .carried = { { temperature, 0.992F }, { density, 0.9985F } },
    .buoyancy = Buffers::VolumeGridBuffer::FlowConfig::Buoyancy {
        .temperature = temperature,
        .density = density,
        .temperature_gain = 3.4F,
        .density_gain = 1.1F } }
  • time_step is how much time one step covers. Keep it equal to the frame time.
  • viscosity is how thick the air is. Water is small and honey is large. It smooths the motion, and a large value kills fine swirls.
  • jacobi_iterations is how many passes make the air incompressible each step. Fewer passes are cheaper and let the air squash, which looks like tearing.
  • walls stops the flow leaking through the sides of the box.
  • carried lists the fields the air carries along, each with a number just under one. A field is multiplied by that number every step, so smoke fades slowly at 0.9985 and heat fades faster at 0.992. A number of exactly one never fades.
  • buoyancy makes heat rise and smoke weigh. The two gains say how strongly each pushes. Change them and the same fire becomes a lazy plume or a jet.

The stages run in a fixed order each step: the flow carries itself along, buoyancy pushes it, viscosity smooths it, the walls hold it, then the air is made incompressible, and last the carried fields are moved by the corrected flow. You do not build these one by one. They stay in the volume’s processing chain if you ever want to reach one.

Expansion 5: Feeding a Fire

A simulation with nothing added to it runs down. Heat cools and smoke thins until the box is quiet, which is what the examples without a source did. volume->influx adds a stage that puts something into one field every step:

auto heat = volume->influx("temperature", { .center = source, .radius = 0.16F, .rate = 2.8F });
  • center is where the soft round blob sits.
  • radius is how wide it is. Leave it out and it is 8 percent of the box’s smallest side.
  • rate is how much it adds each second at its middle. A rate of zero keeps the stage but adds nothing.
  • time_step and falloff are also there, for a different step or a harder edge.

Two sources feed the fire, heat into temperature and smoke into density. Their rates are balanced against the fading of the fields they feed. A source that adds faster than the field fades makes the box fill without end, and one that adds slower leaves it empty.

The stage object is what you keep. Its setters work while the fire runs, and the second block moves the centre to the pointer, widens the radius with the scroll wheel and sets the rate from the relation network every frame. The first block leaves the rate at zero and raises it for a moment each time the drone changes.

Stages added after vega.evolve run behind the flow stages, so what they add is moved by the flow one step later. It is not visible in the picture.

Expansion 6: Drawing a Volume

Buffers::RaymarchBuffer draws a field by looking through it. It is a box around the lattice, and every pixel of the box sends a ray in and adds up what the ray passes through, so thin smoke is faint and thick smoke is solid. Nothing is thresholded into a surface. A field that fades, fades in the picture.

auto smoke = std::make_shared<Buffers::RaymarchBuffer>(
                 lattice,
                 [volume, density] { return volume->read_handle(density); },
                 volume->get_field_bytes(density))
    | Graphics;

The function is called each frame because the field swaps between two copies as it runs, and the picture must read the current one. Several buffers can read one volume. The second block draws temperature as fire and density as smoke from the same box. They read the same cycle’s result, one frame behind the flow.

march_processor()->configure({...}) sets how it looks. Name what you want to change and leave the rest:

  • absorption is how quickly the volume turns opaque
  • emission is how brightly it glows
  • cool and hot are the colours at the faint end and the dense end
  • threshold drops everything fainter than it, which clears the smeared tail a fire leaves
  • density_scale is a quick way to make the whole thing thicker or thinner
  • step_scale is the length of a step as a fraction of one cell
  • max_steps is how many steps a ray may take. Left out, it is enough to cross the box from corner to corner.

Fire is drawn with additive blending so overlapping layers get brighter, and smoke without it, so it dims what is behind it. The box is drawn by its inside faces, so the camera can sit inside it.

There is another way to draw a volume. Give it a SurfaceConfig when you build it and it draws the shape of one value, a solid surface like the skin of a balloon. This looks right while the field has a firm edge. While a fire fades and smears, the edge is eaten away and the surface breaks into facets and ends. That is why the fire here is looked through and not thresholded.

Expansion 7: A Sound That Changes the Fire

The first block never touches the fire from the sound. It touches something the sound also reads.

The drone uses seven Sine nodes on two output channels. Each starts near one of the frequencies in roots, from 55 to about 659 hertz, and has a slower sine modulating its amplitude. The scheduled function retunes the seven nodes together.

Every two and a half seconds a scheduled function moves every tone to a new nearby pitch, and adds up how far they all moved. That number is the news. A large total becomes a large flare, a small one a small flare, and the flare is held in kick. A second function runs every frame, lets kick fall by 3 percent, and sets both sources’ rates from it. The fire sees only the rates.

The first twelve seconds are left alone with an event chain:

Kriya::EventChain chain(*get_scheduler());
chain.then([armed]() { *armed = true; }, 12.0).start();

then(action, delay) waits, then runs the action. Here the action switches on a flag, and the drone’s function only sets kick once the flag is up. It gives the loaded fire time to be seen as it was made before anything starts to interfere with it.

Any change is a good trigger. The measure here is how far the tuning moved. It could as easily be how loud the sound is, a note arriving, or a pointer moving. The earlier cards measured sounds in many ways, and any of those numbers could stand where moved is.

Also: Combine the Drone into Two Nodes

The trial version makes the same seven tones with one Polynomial node per stereo side. To try it, add #include "MayaFlux/Kinesis/Discrete/Kernels.hpp", and replace the drone creation and tuning loop in the first block with this, leaving roots in place:

const auto rate = static_cast<double>(Config::get_sample_rate());
auto tuning = std::make_shared<std::array<double, 7>>();
for (size_t i = 0; i < roots.size(); ++i) {
    (*tuning)[i] = roots[i] + get_uniform_random(-3.0, 3.0);
}

auto drone_side = [&](size_t side) {
    std::vector<size_t> members;
    std::vector<double> increments;
    std::vector<double> levels;
    std::vector<double> breaths;
    for (size_t i = side; i < roots.size(); i += 2) {
        members.push_back(i);
        increments.push_back(Kinesis::Discrete::radians_per_sample((*tuning)[i], rate));
        levels.push_back(0.5);
        breaths.push_back(Kinesis::Discrete::radians_per_sample(0.04 + 0.02 * static_cast<double>(i), rate));
    }

    return std::make_shared<Nodes::Generator::Polynomial>(
        [members, tuning, rate](std::span<double> window, std::span<double> coefs) -> double {
            auto steps = coefs.subspan(1, members.size());
            for (size_t k = 0; k < members.size(); ++k) {
                steps[k] = Kinesis::Discrete::radians_per_sample((*tuning)[members[k]], rate);
            }
            return Kinesis::Discrete::phasor_bank(window, coefs);
        },
        Nodes::Generator::PolynomialMode::FEEDFORWARD, 1,
        Kinesis::Discrete::phasor_bank_state(increments, levels, breaths));
};

auto left = drone_side(0) | Audio[0];
auto right = drone_side(1) | Audio[1];

Then remove drone from the schedule_metro(2.5, ...) capture and remove (*drone)[i]->set_frequency(...) from its loop. The Polynomial nodes read the changed tuning values on every sample; the flare calculation stays the same.

Expansion 8: The Relation Network

The second block keeps every number that connects your hands to the fire in one place, a RelationNetwork. It is a list of named slots, each holding one number, and a chain of operators that run in order every frame.

The slots are x, y, down, speed, fill, heat, soot, size, smoke and burst. The operators are three kinds:

  • A step is a function over all the slots. The first one reads the world into the slots. The last reads the slots out into the fire’s sources.
  • A derive makes one slot from others. The heat slot is the pointer being down, plus how fast it moves, plus any burst, on top of a steady base. The soot slot is the heat, plus any right button, reduced as fill rises.
  • A source is what a step reads its inputs from. read->map("x", Source(...)) ties a name to a function that returns a number once a frame, and the step reads it with self->parameter("x"). A source can also be a node, a constant, or a slot of another network.

The order is the point. The values are read, then the derives work out what they mean, then the last step applies them. To change how the fire answers your hand you change one derive, and the sources stay the same.

This network runs once a frame, on the same clock that draws the picture. That is why it may measure the volume and write straight into the fire’s sources. The measuring is a download from the graphics card, which is slow and must never be done from the audio thread. So it is done on a half second timer, and the network reads the result.

The sound is a second network of the audio kind. It has two tones as slots, and a step that sets their level and pitch from the first network’s slots, with Source(net, 5) reading slot 5 of it. It runs on the audio thread, once for each block of sound. It reads numbers the frame side wrote and does nothing else with the fire. sound->set_output_scale(2.0) sets how loud the network is. vega.NetworkAudioBuffer carries its block to the speakers, and registering the network with | Audio[0] as well lets the audio engine run it.

Nothing about the network is specific to fire. The same slots could hold a measure of a recording, a number from a tablet, or one from another volume, and the derives would relate them just the same.

Expansion 9: Pointer and Keys to Space

A mouse reports a position in the window. The fire lives in a box in the world. The block joins them with the simplest possible assumption. The camera looks straight at the middle of the box from a fixed distance, so every window position corresponds to a point on the plane through the middle of the box:

const auto ndc = normalize_coords(x, y, window);
const float half = std::tan(fov * 0.5F) * distance;
return glm::vec3(ndc.x * half * aspect, ndc.y * half, 0.0F);

normalize_coords gives -1 to 1 across the window, with +1 at the top, which is also up in the box. The result is clamped to the box. The numbers fov, aspect and distance are the same ones that place the camera, so the pointer and the picture stay in step. If you move the camera, move them together.

A pointer does not tell you where it is in depth, so the fire is always touched in the middle plane. That is enough to paint on, and a way to choose depth, such as a key or the scroll wheel, is easy to add to the hand.

The callbacks run when the mouse moves, and write plain values into a small Hand. The relation network reads them once a frame. No callback touches the fire. A burst is the same pattern: Space sets burst to one, and a frame function lets it fall by 7 percent each frame.

Expansion 10: Keeping a Take

A volume can be saved and loaded like any other data, in the same format that came in:

auto io = get_io_manager();
io->save_volume(volume, "take.vdb");
io->wait_for_pending_saves();

The download from the graphics card happens at once and the writing happens in the background, so wait_for_pending_saves() is needed before reading the file back. load_volume reads it as the first block did, and any program that reads OpenVDB can open it.

To record a stretch of the fire as a sequence of files, one for each frame it is sampled, there is capture_volume. The Try It shows it on a key.

Try It → Recap

A Start You Author

Nothing is loaded. A function says where the smoke is, a second function says how the air is moving, and the rules stir one into the other:

#include "MayaFlux/SimulationIncludes.hpp"

void compose() {
    auto window = create_window({ "A Start You Author", 1600, 900 });
    window->show();

    const Kinesis::Lattice3D lattice {
        .resolution = glm::uvec3(64),
        .bounds = { .min = glm::vec3(-1.0F), .max = glm::vec3(1.0F) }
    };

    auto volume = std::make_shared<Buffers::VolumeGridBuffer>(lattice);
    auto density = volume->declare_scalar("density");

    vega.evolve(
        volume,
        { .time_step = 1.0F / 60.0F,
            .viscosity = 0.00005F,
            .jacobi_iterations = 32,
            .walls = true,
            .carried = { { density, 0.9998F } } },
        { .target_window = window });

    volume->seed("pressure", Kinesis::SpatialField { [](glm::vec3) { return 0.0F; } });

    volume->seed(density, Kinesis::SpatialField { [](glm::vec3 p) {
        const float shell = std::exp(-std::pow(glm::length(p) - 0.6F, 2.0F) / 0.02F);
        const float bands = 0.5F + 0.5F * std::sin(p.y * 14.0F);
        return shell * bands;
    } });

    volume->seed("velocity", Kinesis::VectorField { [](glm::vec3 p) {
        return glm::vec3(-p.z, 0.4F * std::sin(p.x * 5.0F), p.x) * 1.4F;
    } });

    auto smoke = std::make_shared<Buffers::RaymarchBuffer>(
                     lattice,
                     [volume, density] { return volume->read_handle(density); },
                     volume->get_field_bytes(density))
        | Graphics;

    smoke->setup_rendering({ .target_window = window });
    smoke->get_render_processor()->set_view_transform(
        Kinesis::look_at_perspective(
            glm::vec3(1.6F, 1.0F, 2.6F), glm::vec3(0.0F),
            0.9F, 16.0F / 9.0F, 0.1F, 20.0F));

    smoke->march_processor()->configure({
        .absorption = 12.0F,
        .emission = 1.6F,
        .cool = { 0.10F, 0.20F, 0.35F },
        .hot = { 0.70F, 0.85F, 1.00F },
        .threshold = 0.02F });
}

Run it. A hollow shell of striped smoke hangs in the box, and the air begins to turn through it. The stripes are drawn out into long threads, then into finer ones, and the threads thin until the grid can no longer hold them. There is no source and no buoyancy, so this is a sculpture that is stirred, not a fire that burns, and nothing you do can bring back what it has smeared.

Change one thing at a time:

  • 14.0F to 30.0F: many thin stripes, which blur out sooner
  • 0.00005F to 0.002F: the air is thick, and the shell hardly moves
  • 1.4F to 4.0F: a violent stir
  • 0.6F to 0.3F: a small shell that rolls up on itself
  • The vector field: swap -p.z and p.x for your own function of p, and the air moves as you describe
Also: Keep a Take

Add this after the volume is running, and a key starts and stops a recording, one file for every second frame:

auto take = make_persistent_shared<uint32_t>(0);

on_key_pressed(window, IO::Keys::Enter, [take, volume] {
    auto io = get_io_manager();
    if (*take != 0) {
        io->stop_volume_capture(*take);
        *take = 0;
        return;
    }
    *take = io->capture_volume(volume, "take/smoke.{:04}.vdb", { "density" }, {}, 240, 2);
});

The {:04} in the name becomes the frame number. The last two numbers are the most frames to keep and the gap between them. A recording of that kind opens in other programs as an animated volume.

What You Achieved

You have:

  • Loaded a volume made elsewhere, put it back under rules, and drawn it by looking through it
  • Let a drone decide when a fire flares, and held the fire back until you had seen it as it was
  • Built a fire from nothing and fed it from a pointer, a key, and a measure of the fire itself
  • Kept the numbers between your hands and the fire in one small network you can read and rewrite
  • Started a simulation from a function of your own, and recorded one

A simulation is not a thing apart. Its fields, its rules and its picture are objects you hold, and every setting on them is a number that anything can drive.


You have reached the end, which means you ran things. That was the method.

What to carry out of here is not a list of names. create_sampler, grain_size and set_looping will fade, and each is a search away. What should stay is a feel for what a number does to a sound, a picture, a shape or a fire, the way a player knows an instrument without reciting its parts.

Two kinds of understanding are false, and both feel real. One is the English meaning of a name. A function called smooth or pluck suggests a story, and the story is easy to believe and often slightly wrong. The other is sight and sound moving together. When a picture bends as a sound swells it seems you can see why, but two things changing at once only show that they are tied, not how. Neither is knowledge until it predicts something.

So test it. Before you change a number, say what you expect to hear or see, then run it. If you were right, push the number further: to zero, to ten times, below zero. If you were wrong, you have found the part worth learning. Change one thing at a time. Break a working block on purpose. Swap its source, the file for the microphone, and see what stays.

Understanding comes from that loop, repeated until you stop predicting and simply know. Then put the blocks together: a pointer on a pile of pictures, a drone steering a shape. Nothing in these sections was written to stay as it was.