Skip to content

Adding and updating filter logic to match Air/Flash - #10

Merged
m0rkeulv merged 21 commits into
feature/filters-fixfrom
test/filters-only
Sep 3, 2026
Merged

Adding and updating filter logic to match Air/Flash#10
m0rkeulv merged 21 commits into
feature/filters-fixfrom
test/filters-only

Conversation

@m0rkeulv

@m0rkeulv m0rkeulv commented Sep 1, 2026

Copy link
Copy Markdown
Owner

No description provided.

m0rkeulv and others added 21 commits September 1, 2026 18:53
OpenFL's blur is a fixed 7-tap Gaussian stacked round(blur*quality/4)+1
times at halving radii -- a heuristic that doesn't match Flash: too soft
at low quality, too tight/undersampled at large radii (visible faceting).

Add a fractional separable box blur (Ruffle's algorithm, faithful to
Flash Player) behind -Dflash_box_blur, keeping the Gaussian as the
default so the two can be A/B'd. Per axis, per pass: full_size = blur
(<=255); radius = (full_size-1)/2; m = ceil(radius)-1 interior
double-weighted bilinear pairs; alpha = fractional edge weight quantised
to 8 bits (imitating Flash's fixed point); first/last edge taps weighted
alpha and alpha+1; normalise by full_size; 8-bit round each pass. Passes
= quality*2 (one H + one V per quality iteration; separable box passes
commute). Shader loop is constant-bounded (64 pairs) with a dynamic
break for WebGL1 compatibility.

Verified on the filter grid vs the Flash/AIR reference: box blur matches
Flash's crispness at quality 1 and its directional/wide spread at
quality 3 and asymmetric radii, where the Gaussian did not. Glow/shadow/
bevel are unaffected (they use their own internal blur).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The box blur added to BlurFilter only fixed the standalone blur. Glow,
drop shadow and bevel each have their OWN copy of the old too-tight
Gaussian heuristic (round(blur*quality/4)+1 passes of a 6/7-tap Gaussian
at halving radii), so their blur did not match Flash: shadows read
weaker/tighter than the reference and bevels were soft/diffuse (looked
like a resolution loss), in both single and stacked use, on every target.

Extend the box blur to all three, behind the same -Dflash_box_blur gate:
- BlurFilter: extract __setupBoxBlur(horizontal, v) static helper.
- GlowFilter: BoxBlurAlphaShader (fractional box on the alpha channel,
  same colourise+strength as BlurAlphaShader) + __setupBoxBlur helper;
  passes = quality*2. Shared by DropShadowFilter (glow-with-offset).
- BevelFilter: reuse BlurFilter.__setupBoxBlur; passes = quality*2.

Default (Gaussian) path unchanged. Verified on the grid vs Flash/AIR:
bevels are now crisp (matching Flash), and drop/inner shadows spread
correctly, single and stacked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GradientGlowFilter did not exist in OpenFL (in the Flash IDE menu; the
SWF parser flagged it "not supported on native"). No Ruffle reference —
even Ruffle's GPU backend leaves it unimplemented — so matched against
Flash/AIR empirically.

Implementation (GL shader path): blur the object's alpha into a soft
distance field (reusing BlurFilter's box blur under -Dflash_box_blur, or
the Gaussian otherwise), build a 256-entry ramp from (colors, alphas,
ratios) with ratio 0 = outer / 255 = inner, then a combine shader indexes
the ramp by the field and composites per type: outer = ramp*(1-src.a)
over src; inner = ramp masked by src.a over src; full = ramp over src
everywhere; knockout drops src. distance/angle offset the field like a
drop shadow. Falls back to the flash.filters typedef on the flash target.

Verified vs Flash/AIR on a magenta->yellow->cyan gradient: outer, inner,
full, knockout, fade-out alpha, distance offset and strength all match.
Software __applyFilter is a no-op placeholder (GL path is what renders
on-screen). Registered via the API; SWF-tag wiring is separate/TODO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The second missing gradient filter (in the Flash IDE menu; SWF parser
flagged "not supported on native"; no Ruffle reference). Matched against
Flash/AIR empirically.

Implementation (GL shader path): blur the object's alpha (reusing
BlurFilter's box blur under -Dflash_box_blur, else the Gaussian), then a
shader samples the blurred field at +/- the bevel offset (distance,angle)
to form a signed distance field (blurLeft - blurRight)*strength, mapped
into a 256-entry ramp (ratio 0 = one edge, 128 = base/transparent, 255 =
other edge) built from (colors, alphas, ratios). Composites per type
inner/outer/full with knockout, same math as BevelFilter but the ramp
replaces the highlight/shadow colours. Falls back to the flash.filters
typedef on the flash target.

Verified vs Flash/AIR on a blue->transparent->white ramp: inner, outer,
full and strength all match. Software __applyFilter is a placeholder (GL
path renders on-screen). API-registered; SWF-tag wiring is separate/TODO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
At high quality the box-blur path (-Dflash_box_blur) applies `quality`
passes, each widening the shadow/glow/bevel support by ~half the blur, so
the effective reach is ~quality*blur/2 per side. __updateSize() only
reserved one blur radius, so the effect was hard-clipped to a rectangle at
the cache texture bounds (e.g. quality=15, blur=16) while Flash/AIR radiate
smoothly.

Reserve ceil(blur*0.5*quality)+4 per side under #if flash_box_blur across
DropShadow / Glow / Bevel / GradientGlow / GradientBevel; the Gaussian #else
path keeps its original formula for A/B testing. Also make set_quality on
DropShadow/Glow/Bevel recompute the extension (update __quality then call
__updateSize) so post-construction quality changes resize correctly.

Verified against the Flash/AIR baseline via a parameter sweep: the quality
cell now matches, and strength/distance/blur/bevel+DS all track the baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gradient bevel's coloured band rendered ~59% wider than Flash/AIR
(per-cell mean|diff| ~17 on outer/full/strength). Cause: because the ramp's
middle stop is opaque, flat regions map to the middle colour, so the visible
band edge is the cache-texture boundary -- and __updateSize reserved
ceil(blur*0.5*q + distance) + 4, whose +4 safety margin showed up as extra
opaque fill past where Flash draws.

Flash's band stops exactly at the box-blur support (quality*blur/2) plus the
transform offset (distance) -- the extent where the field bL-bR is non-zero
(verified by scanline: FLASH edge at 11px = 8 + 2.83). Set the extension to
that true extent, mirroring BevelFilter's asymmetric offset structure but
without the +4 margin (BevelFilter can afford it because its band fades to
transparent; the gradient band is opaque). Offset magnitude is
ceil(abs(distance*cos/sin)) so negative angles don't lose a pixel.

Removes the magic +4; adds no new constant (q*blur/2 is the box-blur support,
per the Change-5 derivation). Verified vs AIR: band start 199->199, outer-band
yellow area 4121->2596 px (= Flash 2596), mean|diff| 17->~1 across inner /
outer / full / angle / strength. GradientGlow left as-is (already within
tolerance: cyan centre pixel-identical, only sub-pixel transition stretch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The box blur has been validated against the AIR baseline across the full
parameter sweep (strength/distance/blur/quality, bevel/glow/gradient/stacks),
so the -Dflash_box_blur conditional-compilation gate is no longer needed. This
removes the flag from all six blur-based filters (Blur, Glow, DropShadow,
Bevel, GradientGlow, GradientBevel): the box-blur branch becomes the only path
and the old fixed-tap Gaussian #else branches are deleted, along with the now
-dead shader classes BlurShader (BlurFilter) and BlurAlphaShader (GlowFilter).

Filters now render correctly with a plain build, no define required. Verified:
the flag-free build is pixel-identical to the previous -Dflash_box_blur build
(mean|diff| = 0.0) and matches AIR in the same band as before. Net -326 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename for clarity, no behaviour change:
- BlurFilter.__setupBoxBlur    -> __setupBlurShader       (configures BoxBlurShader)
- GlowFilter.__setupBoxBlur    -> __setupBlurAlphaShader  (configures BoxBlurAlphaShader)
- GlowFilter.__boxBlurAlphaShader field -> __blurAlphaShader (symmetric with
  BlurFilter's existing __blurShader field)

Callers updated accordingly: Blur/Bevel/GradientGlow/GradientBevel use the colour
shader helper; Glow/DropShadow use the alpha shader helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…form)

Swap the implementation of BoxBlurShader and BoxBlurAlphaShader for a straight
per-texel fractional box: a box of width uFullSize (= the blur amount) built as
(2n+1) full-weight interior texels + one fractional-weight texel per edge,
divided by the width, 8-bit rounded per pass. The class/field/method names are
unchanged; all the box math now lives in the shader, so __setupBlurShader /
__setupBlurAlphaShader only pass the axis, width, colour and strength.

Weights are identical to the previous Ruffle-derived box, so output matches
Flash/AIR to within 8-bit rounding (verified across the blur sweep and every
blur-based filter: bevel, glow, gradient, stacks -- all unchanged). It also stays
correct above blur 127, where the old shader's fixed 64-tap interior loop
truncated the box. Trade-off: ~2x the texture fetches at high blur (no fused
bilinear pairs) -- correctness over speed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drops the redundant "box" from the field name, mirroring GlowFilter's
__blurAlphaShader. Pure rename, no behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CPU path (BitmapData.applyFilter, BitmapData.draw of a filtered object,
software surfaces) diverged badly from both the GL path and Flash. Measured
against AIR with a BitmapData.applyFilter harness, which reaches __applyFilter
directly on every target and is Flash's own implementation on AIR.

Four defects, all fixed:

- BitmapData.applyFilter ignored its sourceBitmapData argument: it validated the
  parameter, then filtered `this`. The documented usage produced an empty bitmap.
- Glow/DropShadow had no inner, no knockout, and ignored strength (lime's
  ImageDataUtil.gaussianBlur accepts strength/color but never uses them).
- Bevel only blurred -- no highlight/shadow derivation or composite at all.
- Both gradient filters were no-ops.

Structural cause for the last three: the GL path hands the unfiltered object to
its combine shader, which composites inner/knockout/full itself, while the
software path relied on the caller drawing the object back on top -- which can
only express "outer, non-knockout". Filters now composite inside __applyFilter
and set the new BitmapFilter.__softwareComposite so the two software call sites
skip that draw. The GL path never reads the flag and is unchanged.

Shared CPU helpers on BitmapFilter mirror the shaders: __alphaField, __blurField
(the same fractional box blur as BoxBlurShader) and __compositeEffect (the
combine formulas). Each filter builds its effect layer as its shader does --
including inverting the alpha before blurring for inner glow/shadow, matching
InvertAlphaShader. BlurFilter's software path is left alone: its StackBlur
already matches Flash (0.23).

Verified vs AIR across 12 cases: overall mean|diff| 14.95 -> 1.32, every case
now below 1.0. GPU render unchanged (diff 0.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On a HiDPI display every blur-based filter rendered 1/pixelRatio too small
relative to its content -- 0.67x at 1.5x DPI, 0.5x on a 2x panel. Measured
against AIR, the outward reach of glow/blur/bevel was a consistent 0.64-0.67x.

A filtered object is cached into a bitmap that is correctly sized and drawn at
pixelRatio, but the filter's distances were never scaled to match. The shaders
work in texels (direction = uDir / uTextureSize, uFullSize = blurX), so a blur
of 12 covered 12 device pixels = 8 logical pixels at 1.5x. This predates the box
blur: develop's Gaussian had the same flaw (vec2 r = uRadius / uTextureSize),
and no filter referenced pixelRatio at all.

Add BitmapFilter.__renderScale (default 1), set by the renderer to pixelRatio
before each filter is used, on both the GPU and software cache loops. Every
filter now scales its blur radii and its offsets/transforms by it, in both
__initShader and __applyFilter. BitmapData.applyFilter leaves it at 1 -- it
works on the bitmap's own pixels -- so that path is unaffected.

Also: BevelFilter.__updateTransform() was only called from the distance/angle
setters, i.e. at construction when the render scale is still 1, so scaling it
there never took effect; it is now also called from __initShader.

Verified vs AIR on a twelve-case harness: GPU mean|diff| 2.06 -> 0.36 (now
better than the software path), bevel band width exactly AIR's, software column
unchanged at 0.51.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
__updateCacheBitmap rounded the cache origin (offsetX/offsetY) to a whole
LOGICAL pixel. At a fractional pixelRatio (1.5x, 1.25x) that is a fractional
DEVICE pixel, so the entire cache bitmap was composited to screen at a
half-pixel phase and bilinearly smeared. Soft effects hide it; a thin
high-contrast band -- a bevel edge -- shows it as a visible displacement.

Found via gradientBevel: its extension of 11 logical px put the content at
16.5 device px, and a sub-pixel shift search showed the band displaced by
~0.75px diagonally, recoverable to 0.19 by translation alone. BevelFilter
escaped only because its extension is always even (+4), and even x 1.5 is
always an integer.

Round the origin in device space instead (ceil/floor of rect.x * pixelRatio,
then divide back). At pixelRatio 1 this is byte-identical to before; at 1.5x
it aligns both the content-into-cache and cache-onto-screen placements. It
never under-reserves: floor on the negative left offset only ever adds room.

Verified vs AIR on the twelve-case three-way harness: gradientBevel
1.23 -> 0.15 with zero residual shift, every other case unchanged to 2 dp,
software column unchanged, GPU average 0.36 -> 0.27. Its opaque-middle area
now matches Flash to within 2 px (24294 vs 24292), which also retires the
earlier "extent 1px short" observation -- that was the same smear.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
__alphaField -> __alphaMask, __blurField -> __blurMask,
__blurFieldAxis -> __blurMaskAxis, __fieldAt -> __maskAt, and the local
variables to match. "Field" was scalar-field jargon that needed explaining;
"mask" is what most readers expect a blurred single-channel alpha array to be
called. The helpers header now says what the array represents: the shape's
alpha, one Float per pixel, which blurred becomes the soft coverage map every
effect is derived from.

The shader comments that say "distance field" are left as-is: there the word
names the signed-distance concept the bevel maths actually uses.

Pure rename, no behaviour change; builds clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
BevelFilter.__applyFilter: hr/hg/hb and sr/sg/sb become highlightR/G/B and
shadowR/G/B (each premultiplied by its alpha, as uLightColor/uShadowColor).

bL/bR (inherited "blur left/right" from BevelShader) become
maskTowardShadow/maskTowardLight in BevelFilter and GradientBevelFilter
(CPU path and the GradientBevelShader GLSL): the samples are taken along
the light angle, +offset the way a shadow falls, -offset toward the light,
so left/right was misleading for any angle other than 0.

Comments explain the sign of the difference (positive = edge facing the
light = highlight). Pure rename, no behaviour change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@m0rkeulv m0rkeulv changed the title Test/filters only Adding and updating filter logic to match Air/Flash Sep 3, 2026
@m0rkeulv
m0rkeulv merged commit 96b0cf8 into feature/filters-fix Sep 3, 2026
48 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant