Skip to content

Animation Development Guide~

Guide for developers creating custom animation classes in the Berry Animation Framework.

Overview~

Note: This guide is for developers who want to extend the framework by creating new animation classes. For using existing animations, see the DSL Reference which provides a declarative way to create animations without programming.

The Berry Animation Framework uses a unified architecture where all visual elements inherit from the base Animation class. This guide explains how to create custom animation classes that integrate seamlessly with the framework's parameter system, value providers, and rendering pipeline.

Animation Class Structure~

Basic Class Template~

#@ solidify:MyAnimation,weak
class MyAnimation : animation.animation
  # NO instance variables for parameters - they are handled by the virtual parameter system

  # Parameter definitions following the new specification
  static var PARAMS = {
    "my_param1": {"default": "default_value", "type": "string"},
    "my_param2": {"min": 0, "max": 255, "default": 100, "type": "int"}
    # Do NOT include inherited Animation parameters here
  }

  def init(engine)
    # Engine parameter is MANDATORY and cannot be nil
    super(self).init(engine)

    # Only initialize non-parameter instance variables (none in this example)
    # Parameters are handled by the virtual parameter system
  end

  # Handle parameter changes (optional)
  def on_param_changed(name, value)
    # Add custom logic for parameter changes if needed
    # Parameter validation is handled automatically by the framework
  end

  # Update animation state (no return value needed)
  def update(time_ms)
    super(self).update(time_ms)
    # Your update logic here
  end

  def render(frame, time_ms, strip_length)
    if !self.is_running || frame == nil
      return false
    end

    # Use virtual parameter access - automatically resolves ValueProviders
    var param1 = self.my_param1
    var param2 = self.my_param2

    # Use strip_length parameter instead of self.engine.strip_length for performance
    # Your rendering logic here
    # ...

    return true
  end

  # NO setter methods needed - use direct virtual parameter assignment:
  # obj.my_param1 = value
  # obj.my_param2 = value

  def tostring()
    return f"MyAnimation(param1={self.my_param1}, param2={self.my_param2}, running={self.is_running})"
  end
end

PARAMS System~

Static Parameter Definition~

The PARAMS static variable defines all parameters specific to your animation class. This system provides:

  • Parameter validation with min/max constraints and type checking
  • Default value handling for initialization
  • Virtual parameter access through getmember/setmember
  • Automatic ValueProvider resolution

Parameter Definition Format~

static var PARAMS = {
  "parameter_name": {
    "default": default_value,    # Default value (optional)
    "min": minimum_value,        # Minimum value for integers (optional)
    "max": maximum_value,        # Maximum value for integers (optional)
    "enum": [val1, val2, val3],  # Valid enum values (optional)
    "type": "parameter_type",    # Expected type (optional)
    "nillable": true             # Whether nil values are allowed (optional)
  }
}

Supported Types~

  • "int" - Integer values (default if not specified)
  • "string" - String values
  • "bool" - Boolean values (true/false)
  • "bytes" - Bytes objects (validated using isinstance())
  • "instance" - Object instances
  • "any" - Any type (no type validation)

Important Rules~

  • Do NOT include inherited parameters - Animation base class parameters are handled automatically
  • Only define class-specific parameters in your PARAMS
  • No constructor parameter mapping - the new system uses engine-only constructors
  • Parameters are accessed via virtual members: obj.param_name

Constructor Implementation~

Engine-Only Constructor Pattern~

def init(engine)
  # 1. ALWAYS call super with engine (engine is the ONLY parameter)
  super(self).init(engine)

  # 2. Initialize non-parameter instance variables only
  self.internal_state = initial_value
  self.buffer = nil
  # Do NOT initialize parameters here - they are handled by the virtual system
end

Parameter Change Handling~

def on_param_changed(name, value)
  # Optional method to handle parameter changes
  if name == "scale"
    # Recalculate internal state when scale changes
    self._update_internal_buffers()
  elif name == "color"
    # Handle color changes
    self._invalidate_color_cache()
  end
end

Key Changes from Old System~

  • Engine-only constructor: Constructor takes ONLY the engine parameter
  • No parameter initialization: Parameters are set by caller using virtual member assignment
  • No instance variables for parameters: Parameters are handled by the virtual system
  • Automatic validation: Parameter validation happens automatically based on PARAMS constraints

Value Provider Integration~

Automatic ValueProvider Resolution~

The virtual parameter system automatically resolves ValueProviders when you access parameters:

def render(frame, time_ms, strip_length)
  # Virtual parameter access automatically resolves ValueProviders
  var color = self.color      # Returns current color value, not the provider
  var position = self.pos     # Returns current position value
  var size = self.size        # Returns current size value

  # Use strip_length parameter (computed once by engine_proxy) instead of self.engine.strip_length
  # Use resolved values in rendering logic
  for i: position..(position + size - 1)
    if i >= 0 && i < strip_length
      frame.set_pixel_color(i, color)
    end
  end

  return true
end

Setting Dynamic Parameters~

Users can set both static values and ValueProviders using the same syntax:

# Create animation
var anim = animation.my_animation(engine)

# Static values
anim.color = 0xFFFF0000
anim.pos = 5
anim.size = 3

# Dynamic values
anim.color = animation.smooth(0xFF000000, 0xFFFFFFFF, 2000)
anim.pos = animation.triangle(0, 29, 3000)

Performance Optimization~

For performance-critical code, cache parameter values:

def render(frame, time_ms, strip_length)
  # Cache parameter values to avoid multiple virtual member access
  var current_color = self.color
  var current_pos = self.pos
  var current_size = self.size

  # Use cached values in loops
  for i: current_pos..(current_pos + current_size - 1)
    if i >= 0 && i < strip_length
      frame.set_pixel_color(i, current_color)
    end
  end

  return true
end

Color Provider LUT Optimization~

For color providers that perform expensive color calculations (like palette interpolation), the base ColorProvider class provides a Lookup Table (LUT) mechanism for caching pre-computed colors:

#@ solidify:MyColorProvider,weak
class MyColorProvider : animation.color_provider
  # Instance variables (all should start with underscore)
  var _cached_data    # Your custom cached data

  def init(engine)
    super(self).init(engine)  # Initializes _color_lut and _lut_dirty
    self._cached_data = nil
  end

  # Mark LUT as dirty when parameters change
  def on_param_changed(name, value)
    super(self).on_param_changed(name, value)
    if name == "colors" || name == "transition_type"
      self._lut_dirty = true  # Inherited from ColorProvider
    end
  end

  # Rebuild LUT when needed
  def _rebuild_color_lut()
    # Allocate LUT (e.g., 129 entries * 4 bytes = 516 bytes)
    if self._color_lut == nil
      self._color_lut = bytes()
      self._color_lut.resize(129 * 4)
    end

    # Pre-compute colors for values 0, 2, 4, ..., 254, 255
    var i = 0
    while i < 128
      var value = i * 2
      var color = self._compute_color_expensive(value)
      self._color_lut.set(i * 4, color, 4)
      i += 1
    end

    # Add final entry for value 255
    var color_255 = self._compute_color_expensive(255)
    self._color_lut.set(128 * 4, color_255, 4)

    self._lut_dirty = false
  end

  # Update method checks if LUT needs rebuilding
  def update(time_ms)
    if self._lut_dirty || self._color_lut == nil
      self._rebuild_color_lut()
    end
    return self.is_running
  end

  # Fast color lookup using LUT
  def get_color_for_value(value, time_ms)
    # Build LUT if needed (lazy initialization)
    if self._lut_dirty || self._color_lut == nil
      self._rebuild_color_lut()
    end

    # Map value to LUT index (divide by 2, special case for 255)
    var lut_index = value >> 1
    if value >= 255
      lut_index = 128
    end

    # Retrieve pre-computed color from LUT
    var color = self._color_lut.get(lut_index * 4, 4)

    # Apply brightness scaling using static method (only if not 255)
    var brightness = self.brightness
    if brightness != 255
      return animation.color_provider.apply_brightness(color, brightness)
    end

    return color
  end

  # Access LUT from outside (returns bytes() or nil)
  # Inherited from ColorProvider: get_lut()
end

LUT Benefits: - 5-10x speedup for expensive color calculations - Reduced CPU usage during rendering - Smooth animations even with complex color logic - Memory efficient (typically 516 bytes for 129 entries)

When to use LUT: - Palette interpolation with binary search - Complex color transformations - Brightness calculations - Any expensive per-pixel color computation

LUT Guidelines: - Store colors at maximum brightness, apply scaling after lookup - Use 2-step resolution (0, 2, 4, ..., 254, 255) to save memory - Invalidate LUT when parameters affecting color calculation change - Don't invalidate for brightness changes if brightness is applied post-lookup

Brightness Handling:

The ColorProvider base class includes a brightness parameter (0-255, default 255) and a static method for applying brightness scaling:

# Static method for brightness scaling (only scales if brightness != 255)
animation.color_provider.apply_brightness(color, brightness)

Best Practices: - Store LUT colors at maximum brightness (255) - Apply brightness scaling after LUT lookup using the static method - Only call the static method if brightness != 255 to avoid unnecessary overhead - For inline performance-critical code, you can inline the brightness calculation instead of calling the static method - Brightness changes do NOT invalidate the LUT since brightness is applied after lookup

Parameter Access~

Direct Virtual Member Assignment~

The new system uses direct parameter assignment instead of setter methods:

# Create animation
var anim = animation.my_animation(engine)

# Direct parameter assignment (recommended)
anim.color = 0xFF00FF00
anim.pos = 10
anim.size = 5

# Method chaining is not needed - just set parameters directly

Parameter Validation~

The parameter system handles validation automatically based on PARAMS constraints:

# This will raise an exception due to min: 0 constraint
anim.size = -1  # Raises value_error

# This will be accepted
anim.size = 5   # Parameter updated successfully

# Method-based setting returns true/false for validation
var success = anim.set_param("size", -1)  # Returns false, no exception

Accessing Raw Parameters~

# Get current parameter value (resolved if ValueProvider)
var current_color = anim.color

# Get raw parameter (returns ValueProvider if set)
var raw_color = anim.get_param("color")

# Check if parameter is a ValueProvider
if animation.is_value_provider(raw_color)
  print("Color is dynamic")
else
  print("Color is static")
end

Rendering Implementation~

Frame Buffer Operations~

def render(frame, time_ms, strip_length)
  if !self.is_running || frame == nil
    return false
  end

  # Resolve dynamic parameters
  var color = self.resolve_value(self.color, "color", time_ms)
  var opacity = self.resolve_value(self.opacity, "opacity", time_ms)

  # Render your effect using strip_length parameter
  for i: 0..(strip_length-1)
    var pixel_color = calculate_pixel_color(i, time_ms)
    frame.set_pixel_color(i, pixel_color)
  end

  # Apply opacity if not full (supports numbers, animations)
  if opacity < 255
    frame.apply_opacity(opacity)
  end

  return true  # Frame was modified
end

Common Rendering Patterns~

Fill Pattern~

# Fill entire frame with color
frame.fill_pixels(color)

Position-Based Effects~

# Render at specific positions
var start_pos = self.resolve_value(self.pos, "pos", time_ms)
var size = self.resolve_value(self.size, "size", time_ms)

for i: 0..(size-1)
  var pixel_pos = start_pos + i
  if pixel_pos >= 0 && pixel_pos < frame.width
    frame.set_pixel_color(pixel_pos, color)
  end
end

Gradient Effects~

# Create gradient across frame
for i: 0..(frame.width-1)
  var progress = i / (frame.width - 1.0)  # 0.0 to 1.0
  var interpolated_color = interpolate_color(start_color, end_color, progress)
  frame.set_pixel_color(i, interpolated_color)
end

Complete Example: BeaconAnimation~

Here's a complete example showing all concepts:

#@ solidify:BeaconAnimation,weak
class BeaconAnimation : animation.animation
  # NO instance variables for parameters - they are handled by the virtual parameter system

  # Parameter definitions following the new specification
  static var PARAMS = {
    "color": {"default": 0xFFFFFFFF},
    "back_color": {"default": 0xFF000000},
    "pos": {"default": 0},
    "beacon_size": {"min": 0, "default": 1},
    "slew_size": {"min": 0, "default": 0}
  }

  # Initialize a new Pulse Position animation
  # Engine parameter is MANDATORY and cannot be nil
  def init(engine)
    # Call parent constructor with engine (engine is the ONLY parameter)
    super(self).init(engine)

    # Only initialize non-parameter instance variables (none in this case)
    # Parameters are handled by the virtual parameter system
  end

  # Handle parameter changes (optional - can be removed if no special handling needed)
  def on_param_changed(name, value)
    # No special handling needed for this animation
    # Parameter validation is handled automatically by the framework
  end

  # Render the pulse to the provided frame buffer
  def render(frame, time_ms, strip_length)
    if frame == nil
      return false
    end

    var pixel_size = strip_length
    # Use virtual parameter access - automatically resolves ValueProviders
    var back_color = self.back_color
    var pos = self.pos
    var slew_size = self.slew_size
    var beacon_size = self.beacon_size
    var color = self.color

    # Fill background if not transparent
    if back_color != 0xFF000000
      frame.fill_pixels(back_color)
    end

    # Calculate pulse boundaries
    var pulse_min = pos
    var pulse_max = pos + beacon_size

    # Clamp to frame boundaries
    if pulse_min < 0
      pulse_min = 0
    end
    if pulse_max >= pixel_size
      pulse_max = pixel_size
    end

    # Draw the main pulse
    var i = pulse_min
    while i < pulse_max
      frame.set_pixel_color(i, color)
      i += 1
    end

    # Draw slew regions if slew_size > 0
    if slew_size > 0
      # Left slew (fade from background to pulse color)
      var left_slew_min = pos - slew_size
      var left_slew_max = pos

      if left_slew_min < 0
        left_slew_min = 0
      end
      if left_slew_max >= pixel_size
        left_slew_max = pixel_size
      end

      i = left_slew_min
      while i < left_slew_max
        # Calculate blend factor
        var blend_factor = tasmota.scale_uint(i, pos - slew_size, pos - 1, 255, 0)
        var alpha = 255 - blend_factor
        var blend_color = (alpha << 24) | (color & 0x00FFFFFF)
        var blended_color = frame.blend(back_color, blend_color)
        frame.set_pixel_color(i, blended_color)
        i += 1
      end

      # Right slew (fade from pulse color to background)
      var right_slew_min = pos + beacon_size
      var right_slew_max = pos + beacon_size + slew_size

      if right_slew_min < 0
        right_slew_min = 0
      end
      if right_slew_max >= pixel_size
        right_slew_max = pixel_size
      end

      i = right_slew_min
      while i < right_slew_max
        # Calculate blend factor
        var blend_factor = tasmota.scale_uint(i, pos + beacon_size, pos + beacon_size + slew_size - 1, 0, 255)
        var alpha = 255 - blend_factor
        var blend_color = (alpha << 24) | (color & 0x00FFFFFF)
        var blended_color = frame.blend(back_color, blend_color)
        frame.set_pixel_color(i, blended_color)
        i += 1
      end
    end

    return true
  end

  # NO setter methods - use direct virtual parameter assignment instead:
  # obj.color = value
  # obj.pos = value  
  # obj.beacon_size = value
  # obj.slew_size = value

  # String representation of the animation
  def tostring()
    return f"BeaconAnimation(color=0x{self.color :08x}, pos={self.pos}, beacon_size={self.beacon_size}, slew_size={self.slew_size})"
  end
end

# Export class directly - no redundant factory function needed
return {'beacon_animation': BeaconAnimation}

Testing Your Animation~

Unit Tests~

Create comprehensive tests for your animation:

import animation

def test_my_animation()
  # Create LED strip and engine for testing
  var strip = global.Leds(10)  # Use built-in LED strip for testing
  var engine = animation.create_engine(strip)

  # Test basic construction
  var anim = animation.my_animation(engine)
  assert(anim != nil, "Animation should be created")

  # Test parameter setting
  anim.color = 0xFFFF0000
  assert(anim.color == 0xFFFF0000, "Color should be set")

  # Test parameter updates
  anim.color = 0xFF00FF00
  assert(anim.color == 0xFF00FF00, "Color should be updated")

  # Test value providers
  var dynamic_color = animation.smooth(engine)
  dynamic_color.min_value = 0xFF000000
  dynamic_color.max_value = 0xFFFFFFFF
  dynamic_color.duration = 2000

  anim.color = dynamic_color
  var raw_color = anim.get_param("color")
  assert(animation.is_value_provider(raw_color), "Should accept value provider")

  # Test rendering
  var frame = animation.frame_buffer(10)
  anim.start()
  var result = anim.render(frame, 1000, engine.strip_length)
  assert(result == true, "Should render successfully")

  print("✓ All tests passed")
end

test_my_animation()

Integration Testing~

Test with the animation engine:

var strip = global.Leds(30)  # Use built-in LED strip
var engine = animation.create_engine(strip)
var anim = animation.my_animation(engine)

# Set parameters
anim.color = 0xFFFF0000
anim.pos = 5
anim.beacon_size = 3

engine.add(anim)  # Unified method for animations and sequence managers
engine.run()

# Let it run for a few seconds
tasmota.delay(3000)

engine.stop()
print("Integration test completed")

Best Practices~

Performance~

  • Minimize calculations in render() method
  • Cache resolved values when possible
  • Use integer math instead of floating point
  • Avoid memory allocation in render loops

Memory Management~

  • Reuse objects when possible
  • Clear references to large objects when done
  • Use static variables for constants

Code Organization~

  • Group related parameters together
  • Use descriptive variable names
  • Comment complex algorithms
  • Follow Berry naming conventions

Error Handling~

  • Validate parameters in constructor
  • Handle edge cases gracefully
  • Return false from render() on errors
  • Use meaningful error messages

Publishing Your Animation Class~

Once you've created a new animation class:

  1. Add it to the animation module by importing it in animation.be
  2. Create a factory function following the engine-first pattern
  3. Add DSL support by ensuring the transpiler recognizes your factory function
  4. Document parameters in the class hierarchy documentation
  5. Test with DSL to ensure users can access your animation declaratively

Remember: Users should primarily interact with animations through the DSL. The programmatic API is mainly for framework development and advanced integrations.

This guide provides everything needed to create professional-quality animation classes that integrate seamlessly with the Berry Animation Framework's parameter system and rendering pipeline.