Skip to content

Module

Main file: shaderflow/module.py

A ShaderModule is a common trait for classes acting on a bound Scene to follow.

  • While it may seem weird, the Scene itself is also a module, as it follows the same build, setup, update steps on the pipeline (eg. last to update) and is "bound to itself".
  • All modules are attrs dataclasses for the speed and unserializable nature.

Abstract Methods

Modules can optionally define a set of methods to run at certain points of a Scene lifecycle. While these are marked as @abstractmethod, they are not enforced (no ABC inheritance).

Very Important

All methods must call the inheritance one if it exists

@define
class MyScene(ShaderScene):
    def build(self) -> None:
        ShaderScene.build(self)
        # Your code here

Note: Class.method(self) is preferred over super().method() to avoid mistakes1


__attrs_post_init__

Avoid using this directly and prefer build, unless for reasons like spawning worker threads.

The default implementation must always be run and is a syntatic sugar for the following:

# Naive approach
class MyScene(ShaderScene):
    def build(self):
        self.camera = ShaderCamera(scene=self)
        self.modules.append(self.camera)
        self.camera.commands()
        self.camera.build()

# Automatically handled
class MyScene(ShaderScene):
    def build(self):
        self.camera = ShaderCamera(scene=self)

It also does important weakref management to avoid circular references on gc.collect(), ensures the passed scene= object is a ShaderScene instance, and calls the self.build() method.


build

-> Only ever called once on creation, similar to __init__.

Here, modules shall create auxiliary objects like Textures, Dynamics, set static settings, load shaders, etc. The Scene's OpenGL Context is guaranteed to exist at this point.

class MyScene(ShaderScene):
    def build(self):
        self.shader.fragment = (shaders/"main.frag")
        self.audio = ShaderAudio(scene=self, name="iAudio")
        self.audio.open_recorder()

Further or dynamic configuration is done in setup.


setup

-> Called every time before the main update event loop.

Whenever the Scene.main method is called, setup runs just after configuring it with incoming arguments (like time, width, height) for all modules. Command line options were just set.

Generally speaking, dynamic configuration should be done here:

class MyScene(ShaderScene):
    def setup(self):
        if self.image.is_empty():
            self.input(image=DEFAULT_IMAGE)
        self.piano.load_midi(self.config.midi)
        self.piano.height = self.config.height

destroy

-> Cleanup problematic leaky resources

Certain resources like OpenGL Contexts, Audio Recorders, Worker Threads, etc. might not be automatically cleaned up by Python and require explicit handling.

While __del__ is a terrible liability2, when it run it'll calls destroy() for you.

pipeline

-> Declare or set uniform variables in the shader pipeline.

Similar to update, but runs at least once before the event loop for shader compilation.

This method must yield ShaderVariable instances that will be injected in shaders code as uniforms, or value to be set on the OpenGL rendering pipeline.

class MyScene(ShaderScene):
    def pipeline(self) -> Iterable[ShaderVariable]:
        yield from ShaderScene.pipeline()
        yield Uniform("i",

Small data only

For large data throughput, use Textures instead


includes

🚧 To be reworked


defines

🚧 To be reworked


update

-> Called every frame in the main event loop

Certainly the most called method of all, and often the main implementation of a module. Note you can access self.scene.{dt,time} etc for state data.

import functools

@define
class Primer(ShaderModule):
    value: bool = False

    def update(self):
        self.prime = is_prime(self.scene.frame)

    def pipeline(self):
        yield Uniform("iPrime", self.value)

Optimization is the art of doing nothing

Be lazy, avoid doing work as much as possible here - this is "the main bottleneck" of ShaderFlow:

  • Audio DSP only runs on new audio data; Video only writes a new frame when due, etc.

Note: Actual shaders are run last, as the pipeline might change in a parent or global module


handle

-> Handle custom messages sent with relay from another module.

Lesser common, but important scene events are always sent here, like keyboard, mouse, window events, shader compilation, resizes, etc - check message.py

class MyScene(ShaderScene):
    def handle(self, message: ShaderMessage) -> None:
        ShaderScene.handle(self, message)

        if isinstance(message, ShaderMessage.Window.FileDrop):
            self.background.from_image(message.files[0])

duration

-> Self-reported time for full completion

When main is called with time=None (default), the Scene determines the total runtime based on the maximum value of all modules' duration. For example, a ShaderAudio returns the duration of the input audio file; a ShaderPiano the input's Midi file length, etc.

Fixed Methods

relay

-> Send any input object to all modules's handle method

# Force recompilation
scene.relay(ShaderMessage.Shader.Compile)

commands

-> Add custom commands to the scene's cyclopts app

from cyclopts import Parameter

@define
class SceneConfig:
    ...

class SceneConfig(BaseModel):
    ...

@define
class MyScene(ShaderScene):
    config: SceneConfig = Factory(SceneConfig)

    def smartset(self, object: Any) -> Any:
        if isinstance(object, SceneConfig):
            self.config = object
        return object

    def seed(self, value: int):
        print(f"Seed: {value}")
        random.seed(value)

    def commands(self):
        self.cli.help = pianola.__about__
        self.cli.version = pianola.__version__
        self.cli.command(self.seed)
        self.cli.command(
            SceneConfig, name="config",
            result_action=self.smartset
        )

find

-> Find all modules of a certain type in the scene

for module in scene.find(ShaderTexture):
    print(module.name)