695 lines
20 KiB
Markdown
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. |