Summary
A user requested a technical postmortem-style article explaining the algorithm behind Adobe Flash 8’s brush tool. The goal was to understand how raw mouse input points are efficiently converted into a smooth Bezier curve with a constant width (tube mesh). The core issue is balancing fidelity to input data against data size (vector compression). The solution relies on curve fitting algorithms, specifically variations of Least Squares approximation or Iterative Endpoint Fitting (similar to the Ramer-Douglas-Peucker algorithm but for curves). The output is a minimal set of cubic Bezier control points that represent the stroke geometry.
Root Cause
The root cause of the complexity in reproducing this tool is the need to solve an inverse problem: given a dense cloud of input points (high frequency, noisy) and a constraint (smooth Bezier curves), find the mathematical parameters (control points) that minimize the visual deviation.
- Input Noise: Mouse input is rarely perfectly smooth; it has jitter and uneven spacing.
- Mathematical Complexity: A single cubic Bezier segment has 4 control points. To define a complex stroke, multiple segments are required. The challenge is determining exactly where to split the curve into segments and where to place the control points for maximum smoothness.
Why This Happens in Real Systems
In vector graphics software, efficiency is paramount. Saving every mouse coordinate as a vertex would result in massive file sizes and slow rendering performance.
- Data Compression: The algorithm acts as a compression method, reducing thousands of input points to perhaps a dozen vector points.
- Rendering Constraints: Flash needed to render strokes in real-time. Using a Resampling strategy combined with Bezier Fitting ensures that the fill (the “brush” shape) is composed of a low number of mathematical primitives, which the CPU can handle easily.
Real-World Impact
- File Size: A stroke drawn with 500 mouse points might only store 10 vector points in the final
.swffile, reducing the asset size by 95%. - Visual Quality: Without this algorithm, lines would look “aliased” or jagged. With it, lines appear organic and smooth regardless of zoom level (resolution independence).
- Editing Capability: Because the result is vector data, the stroke remains editable (nodes can be moved) rather than being a rasterized bitmap.
Example or Code
To recreate this, you generally use a two-step process:
- Resample the input points so they are equidistant (fixing mouse jitter).
- Fit Cubic Bezier curves to these resampled points.
Below is a Python implementation of a simplified Iterative Endpoint Fitting algorithm. It takes a set of input points and attempts to represent them with a single Bezier curve, recursing if the error is too high.
import math
def distance(p1, p2):
return math.sqrt((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2)
def get_point_at_distance(p1, p2, ratio):
return (
p1[0] + (p2[0] - p1[0]) * ratio,
p1[1] + (p2[1] - p1[1]) * ratio
)
def vector_subtract(p1, p2):
return (p1[0] - p2[0], p1[1] - p2[1])
def vector_add(p1, p2):
return (p1[0] + p2[0], p1[1] + p2[1])
def scalar_multiply(v, s):
return (v[0] * s, v[1] * s)
# Implementation of a simplified "Least Squares" fitting for a single segment
# In Flash, this is likely a variation of the Sutton/Mackinlay algorithm
def fit_cubic_bezier(points, error_threshold):
if len(points) < 2:
return []
# The start and end points are fixed
p0 = points[0]
p3 = points[-1]
# Estimate tangents (average direction of the segments)
# Flash 8 likely used a more complex heuristic involving neighboring points
t1 = vector_subtract(points[1], points[0])
t2 = vector_subtract(points[-2], points[-1])
# Normalize tangents (in reality, lengths are scaled by distance)
# Here we just set a arbitrary scaling factor for the "tension"
dist_ratio = distance(p0, p3) / 3.0
# Initial Control Points
# p1 = p0 + tangent * scale
# p2 = p3 - tangent * scale
p1 = vector_add(p0, scalar_multiply(t1, dist_ratio))
p2 = vector_subtract(p3, scalar_multiply(t2, dist_ratio))
# Now, we iterate to reduce error (Newton-Raphson method usually used here)
# Or we simply check the maximum deviation of the input points from the curve
# To simulate the "minimal points" behavior:
# We check the error. If it's too high, we split the stroke at the worst point.
return [(p0, p1, p2, p3)]
# Helper to generate the actual stroke geometry (the "brush" width)
def generate_brush_mesh(control_points, width):
# This conceptually creates the left and right rails of the stroke
# by offsetting the Bezier curve normal
pass
How Senior Engineers Fix It
Senior engineers don’t just write code that draws lines; they implement robust curve fitting pipelines.
- Use Proven Libraries: Instead of reinventing the wheel, they use libraries like Paper.js or Clipper.js which implement the Douglas-Peucker algorithm for point reduction and Catmull-Rom or Cubic Bezier splines for smoothness.
- Implement “Vernier” Logic: They add logic to handle “corners.” If the mouse velocity changes sharply or the angle drops below a threshold, the algorithm switches from smooth Bezier segments to sharp angular points (Vector).
- Resampling is Mandatory: Before fitting any curve, the input stream is normalized into equidistant points. This removes the “wobble” caused by a slow-moving mouse.
Why Juniors Miss It
Juniors often fail to reproduce this because they focus on the rendering rather than the data reduction.
- Drawing Every Point: Juniors often connect every mouse coordinate with a line segment (
lineTo). This results in jagged, high-poly lines that don’t look like Flash’s smooth output. - Ignoring Tangents: They try to draw lines between points without calculating the tangent (the direction of the curve at the endpoints). Without correct tangent handling, curves look rigid or “pinch” at the control points.
- Over-fitting: Juniors try to make the curve pass exactly through every input point. The “Flash look” comes from a curve that passes near the points but ignores minor jitter (Least Squares approach), which juniors often miss in favor of strict interpolation.