Custom Effects¶
Build visual effect widgets
Create custom visual effects that work within Fluvie's frame-based rendering system.
Table of Contents¶
- Overview
- Effect Widget Pattern
- Particle Systems
- Post-Processing Effects
- Deterministic Randomness
- Examples
Overview¶
Custom effects in Fluvie must be:
- Deterministic: Same frame number = same visual output
- Efficient: Avoid expensive operations per frame
- Composable: Work as overlay or child wrapper
- Mode-aware: Support both preview and render modes
Effect Widget Pattern¶
Basic Structure¶
class CustomEffect extends StatelessWidget {
final Widget child;
final int startFrame;
final int durationInFrames;
const CustomEffect({
super.key,
required this.child,
this.startFrame = 0,
this.durationInFrames = 60,
});
@override
Widget build(BuildContext context) {
return TimeConsumer(
builder: (context, frame, child) {
// Calculate effect progress
final localFrame = frame - startFrame;
if (localFrame < 0 || localFrame >= durationInFrames) {
return child!; // Outside effect range
}
final progress = localFrame / durationInFrames;
// Apply effect
return _buildEffect(progress, child!);
},
child: child,
);
}
Widget _buildEffect(double progress, Widget child) {
// Override in subclass or implement here
return child;
}
}
Using Layer Stack for Overlays¶
class GlowEffect extends StatelessWidget {
final Widget child;
final Color glowColor;
final double intensity;
const GlowEffect({
super.key,
required this.child,
this.glowColor = Colors.cyan,
this.intensity = 1.0,
});
@override
Widget build(BuildContext context) {
return TimeConsumer(
builder: (context, frame, child) {
// Pulsing glow
final pulse = (sin(frame * 0.1) + 1) / 2 * intensity;
return Stack(
children: [
// Glow layer (behind)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: glowColor.withOpacity(0.5 * pulse),
blurRadius: 30 * pulse,
spreadRadius: 10 * pulse,
),
],
),
),
),
// Original content
child!,
],
);
},
child: child,
);
}
}
Particle Systems¶
Basic Particle System¶
class Particle {
final Offset position;
final Offset velocity;
final double size;
final Color color;
final double rotation;
const Particle({
required this.position,
required this.velocity,
required this.size,
required this.color,
required this.rotation,
});
Particle evolve(double dt) {
return Particle(
position: position + velocity * dt,
velocity: velocity + Offset(0, 9.8 * dt), // Gravity
size: size * 0.99, // Shrink
color: color,
rotation: rotation + dt,
);
}
}
class CustomParticleEffect extends StatelessWidget {
final int particleCount;
final int startFrame;
final int durationInFrames;
final int fps;
const CustomParticleEffect({
super.key,
this.particleCount = 50,
this.startFrame = 0,
this.durationInFrames = 90,
this.fps = 30,
});
@override
Widget build(BuildContext context) {
return TimeConsumer(
builder: (context, frame, _) {
final localFrame = frame - startFrame;
if (localFrame < 0 || localFrame >= durationInFrames) {
return const SizedBox.shrink();
}
// Generate particles deterministically
final particles = _generateParticles(localFrame);
return CustomPaint(
painter: ParticlePainter(particles: particles),
size: Size.infinite,
);
},
);
}
List<Particle> _generateParticles(int frame) {
final random = Random(42); // Fixed seed for determinism
final dt = 1.0 / fps;
// Create initial particles
var particles = List.generate(particleCount, (i) {
return Particle(
position: Offset(
random.nextDouble() * 400 + 100,
random.nextDouble() * 100 + 500,
),
velocity: Offset(
(random.nextDouble() - 0.5) * 100,
-random.nextDouble() * 200 - 100,
),
size: random.nextDouble() * 10 + 5,
color: Colors.primaries[random.nextInt(Colors.primaries.length)],
rotation: random.nextDouble() * 2 * pi,
);
});
// Evolve particles to current frame
for (var f = 0; f < frame; f++) {
particles = particles.map((p) => p.evolve(dt)).toList();
}
return particles;
}
}
class ParticlePainter extends CustomPainter {
final List<Particle> particles;
ParticlePainter({required this.particles});
@override
void paint(Canvas canvas, Size size) {
for (final particle in particles) {
final paint = Paint()..color = particle.color;
canvas.save();
canvas.translate(particle.position.dx, particle.position.dy);
canvas.rotate(particle.rotation);
canvas.drawCircle(Offset.zero, particle.size, paint);
canvas.restore();
}
}
@override
bool shouldRepaint(ParticlePainter oldDelegate) => true;
}
Optimized Particle System¶
For better performance, pre-calculate particle states:
class OptimizedParticleEffect extends StatefulWidget {
final int particleCount;
final int durationInFrames;
const OptimizedParticleEffect({
super.key,
this.particleCount = 100,
this.durationInFrames = 120,
});
@override
State<OptimizedParticleEffect> createState() => _OptimizedParticleEffectState();
}
class _OptimizedParticleEffectState extends State<OptimizedParticleEffect> {
late List<List<Particle>> _frameCache;
@override
void initState() {
super.initState();
_precomputeParticles();
}
void _precomputeParticles() {
final random = Random(42);
// Generate initial state
var particles = List.generate(widget.particleCount, (i) {
return Particle(/* ... */);
});
// Pre-compute all frames
_frameCache = [particles];
for (var f = 1; f < widget.durationInFrames; f++) {
particles = particles.map((p) => p.evolve(1.0 / 30)).toList();
_frameCache.add(particles);
}
}
@override
Widget build(BuildContext context) {
return TimeConsumer(
builder: (context, frame, _) {
if (frame < 0 || frame >= _frameCache.length) {
return const SizedBox.shrink();
}
return CustomPaint(
painter: ParticlePainter(particles: _frameCache[frame]),
);
},
);
}
}
Post-Processing Effects¶
Color Matrix Effect¶
class ColorMatrixEffect extends StatelessWidget {
final Widget child;
final List<double> matrix;
const ColorMatrixEffect({
super.key,
required this.child,
required this.matrix,
});
// Predefined matrices
static const grayscale = [
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0, 0, 0, 1, 0,
];
static const sepia = [
0.393, 0.769, 0.189, 0, 0,
0.349, 0.686, 0.168, 0, 0,
0.272, 0.534, 0.131, 0, 0,
0, 0, 0, 1, 0,
];
@override
Widget build(BuildContext context) {
return ColorFiltered(
colorFilter: ColorFilter.matrix(matrix),
child: child,
);
}
}
Animated Color Grading¶
class AnimatedColorGrade extends StatelessWidget {
final Widget child;
final int startFrame;
final int durationInFrames;
final List<double> startMatrix;
final List<double> endMatrix;
const AnimatedColorGrade({
super.key,
required this.child,
required this.startFrame,
required this.durationInFrames,
required this.startMatrix,
required this.endMatrix,
});
@override
Widget build(BuildContext context) {
return TimeConsumer(
builder: (context, frame, child) {
final localFrame = frame - startFrame;
final progress = (localFrame / durationInFrames).clamp(0.0, 1.0);
// Interpolate color matrices
final matrix = List.generate(
20,
(i) => lerpDouble(startMatrix[i], endMatrix[i], progress)!,
);
return ColorFiltered(
colorFilter: ColorFilter.matrix(matrix),
child: child!,
);
},
child: child,
);
}
}
Deterministic Randomness¶
Using Frame-Seeded Random¶
class NoiseEffect extends StatelessWidget {
final double intensity;
const NoiseEffect({
super.key,
this.intensity = 0.1,
});
@override
Widget build(BuildContext context) {
return TimeConsumer(
builder: (context, frame, _) {
// Frame-based seed ensures determinism
final random = Random(frame * 12345);
return CustomPaint(
painter: NoisePainter(
random: random,
intensity: intensity,
),
size: Size.infinite,
);
},
);
}
}
class NoisePainter extends CustomPainter {
final Random random;
final double intensity;
NoisePainter({required this.random, required this.intensity});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
// Draw noise pixels
for (var x = 0.0; x < size.width; x += 4) {
for (var y = 0.0; y < size.height; y += 4) {
final brightness = random.nextDouble();
paint.color = Colors.white.withOpacity(brightness * intensity);
canvas.drawRect(Rect.fromLTWH(x, y, 4, 4), paint);
}
}
}
@override
bool shouldRepaint(NoisePainter oldDelegate) => true;
}
Precomputed Noise Texture¶
class PrecomputedNoiseEffect extends StatefulWidget {
final int frameCount;
const PrecomputedNoiseEffect({
super.key,
this.frameCount = 30, // Loop every 30 frames
});
@override
State<PrecomputedNoiseEffect> createState() => _PrecomputedNoiseEffectState();
}
class _PrecomputedNoiseEffectState extends State<PrecomputedNoiseEffect> {
late List<ui.Image> _noiseFrames;
bool _isLoaded = false;
@override
void initState() {
super.initState();
_generateNoiseFrames();
}
Future<void> _generateNoiseFrames() async {
final frames = <ui.Image>[];
for (var i = 0; i < widget.frameCount; i++) {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final random = Random(i);
// Draw noise
for (var x = 0; x < 256; x++) {
for (var y = 0; y < 256; y++) {
final gray = random.nextInt(256);
canvas.drawRect(
Rect.fromLTWH(x.toDouble(), y.toDouble(), 1, 1),
Paint()..color = Color.fromARGB(50, gray, gray, gray),
);
}
}
final picture = recorder.endRecording();
final image = await picture.toImage(256, 256);
frames.add(image);
}
setState(() {
_noiseFrames = frames;
_isLoaded = true;
});
}
@override
Widget build(BuildContext context) {
if (!_isLoaded) return const SizedBox.shrink();
return TimeConsumer(
builder: (context, frame, _) {
final noiseFrame = frame % widget.frameCount;
return RawImage(
image: _noiseFrames[noiseFrame],
fit: BoxFit.cover,
);
},
);
}
}
Examples¶
Scanline Effect¶
class ScanlineEffect extends StatelessWidget {
final double lineSpacing;
final double lineOpacity;
const ScanlineEffect({
super.key,
this.lineSpacing = 4.0,
this.lineOpacity = 0.3,
});
@override
Widget build(BuildContext context) {
return TimeConsumer(
builder: (context, frame, _) {
// Animate scanline position
final offset = (frame % lineSpacing.toInt()) / lineSpacing;
return CustomPaint(
painter: ScanlinePainter(
spacing: lineSpacing,
opacity: lineOpacity,
offset: offset,
),
size: Size.infinite,
);
},
);
}
}
Glitch Effect¶
class GlitchEffect extends StatelessWidget {
final Widget child;
final double intensity;
final int glitchSeed;
const GlitchEffect({
super.key,
required this.child,
this.intensity = 0.5,
this.glitchSeed = 42,
});
@override
Widget build(BuildContext context) {
return TimeConsumer(
builder: (context, frame, child) {
final random = Random(frame * glitchSeed);
// Only glitch some frames
if (random.nextDouble() > intensity) {
return child!;
}
// Random displacement
final dx = (random.nextDouble() - 0.5) * 20;
final dy = (random.nextDouble() - 0.5) * 10;
return Stack(
children: [
// Red channel offset
Positioned(
left: dx,
top: dy,
child: ColorFiltered(
colorFilter: const ColorFilter.mode(
Colors.red,
BlendMode.modulate,
),
child: child!,
),
),
// Blue channel offset
Positioned(
left: -dx,
top: -dy,
child: ColorFiltered(
colorFilter: const ColorFilter.mode(
Colors.blue,
BlendMode.modulate,
),
child: child!,
),
),
// Original
Opacity(opacity: 0.5, child: child!),
],
);
},
child: child,
);
}
}
Vignette Effect¶
class VignetteEffect extends StatelessWidget {
final double intensity;
final double radius;
const VignetteEffect({
super.key,
this.intensity = 0.5,
this.radius = 0.8,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: VignettePainter(
intensity: intensity,
radius: radius,
),
size: Size.infinite,
);
}
}
class VignettePainter extends CustomPainter {
final double intensity;
final double radius;
VignettePainter({required this.intensity, required this.radius});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final maxRadius = size.longestSide / 2;
final gradient = RadialGradient(
center: Alignment.center,
radius: radius,
colors: [
Colors.transparent,
Colors.black.withOpacity(intensity),
],
stops: const [0.5, 1.0],
);
final paint = Paint()
..shader = gradient.createShader(
Rect.fromCircle(center: center, radius: maxRadius),
);
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(VignettePainter oldDelegate) =>
intensity != oldDelegate.intensity || radius != oldDelegate.radius;
}
Related¶
- Particle Effects - Built-in particles
- Effect Overlay - Built-in overlays
- TimeConsumer - Frame-based widget
- Custom Animations - Animation system