Tasmota/lib/libesp32/berry_animation/docs/ANIMATION_DEVELOPMENT.md
s-hadinger 8feff1148a
Berry animation meter (#24193)
* Remove tab from json

* Berry animation meter and other optimizations
2025-12-06 16:52:43 +01:00

695 lines
20 KiB
Markdown

# 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](DSL_REFERENCE.md) 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
```berry
#@ 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
```berry
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
```berry
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
```berry
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:
```berry
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:
```berry
# 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:
```berry
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:
```berry
#@ 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 == "palette" || 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:
```berry
# 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:
```berry
# 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:
```berry
# 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
```berry
# 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
```berry
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
```berry
# Fill entire frame with color
frame.fill_pixels(color)
```
#### Position-Based Effects
```berry
# 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
```berry
# 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:
```berry
#@ 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:
```berry
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:
```berry
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.