linebender / linebender/resvg

`feSpecularLighting`: result written straight-alpha into a premultiplied pipeline? (doc comment says `dest` is premultiplied) — v0.47.0

Open
#1,130 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Rust
Stars
4.1k
Forks
348
Avg merge
1d 14h
Merged PRs (30d)
3

Description

Version: v0.47.0 (3a0fdba53ccf2d346b54cc53ba7adf0ee60d0707), the C API built with cargo cinstall as a static x86_64-pc-windows-msvc library (RUSTFLAGS=-C target-feature=+crt-static), consumed from C++ through resvg.h. Nothing about the build seems relevant to what follows — the observation reproduces through the plain render path.

What we see: An Inkscape-authored bevel — a gradient-filled rectangle under a filter chain of blur / xor / atop composites feeding feSpecularLighting with a distant light, then an arithmetic composite back over the blurred halo — renders noticeably brighter at every partially-transparent edge in resvg than in Inkscape 1.4.4's CLI export of the same file at the same pixel size. The interior, where the specular result's alpha is 255, agrees to a fraction of a level. Whole-object mean difference (resvg minus Inkscape, over covered pixels) is about +52 on each of R, G and B, and it is scale-independent (the same +52 at 1×, 2× and 8×), so it does not look like a normals/surfaceScale question.

Where it is born: Truncating the filter chain one primitive at a time in both engines (ten variants, same sizes), the divergence appears exactly at feSpecularLighting: +52.02 on all three channels at once, with the primitives before it agreeing to within a few levels. Pixel-level, at partial alpha resvg holds a straight RGB = 255 with A = 221 where Inkscape holds RGB ≈ A (222 @ 221, 170 @ 170, 34 @ 30); modelling the resvg output as RGB × (A / 255) brings the mean difference from 54 down to about 9.

The code, and the doc comment beside it: (crates/resvg/src/filter/lighting.rs, v0.47.0):

specular_lighting's doc says:

/// Renders a specular lighting.
///
/// - `src` pixels can have any alpha method, since only the alpha channel is used.
/// - `dest` will have a **premultiplied alpha**.

(diffuse_lighting's doc, just above it, says dest will have an unpremultiplied alpha.)

The shared apply function computes the specular term and alpha per pixel and writes them as they are:

        let r = compute(light_color.red);
        let g = compute(light_color.green);
        let b = compute(light_color.blue);
        let a = calc_alpha(r, g, b);

        *dest.pixel_at_mut(nx, ny) = RGBA8 { b, g, r, a };

For specular, calc_alpha is max(r, g, b), so RGB <= A already holds at creation — the SVG spec's "may have RGB > A" case does not arise here. But the value written is the straight specular colour, and the surrounding filter pipeline treats every buffer as premultiplied (the colour-space conversions demultiply and re-multiply around their work; component_transfer / color_matrix do the same). If a downstream read takes that buffer as premultiplied, every partially-transparent specular pixel reads brighter by 255 / A — which is the shape of what we measure.

What made our output match Inkscape: Premultiplying at the write:

         let a = calc_alpha(r, g, b);
 
+        let r = ((r as u16 * a as u16 + 127) / 255) as u8;
+        let g = ((g as u16 * a as u16 + 127) / 255) as u8;
+        let b = ((b as u16 * a as u16 + 127) / 255) as u8;
+
         *dest.pixel_at_mut(nx, ny) = RGBA8 { b, g, r, a };

(a no-op for diffuse, whose alpha is always 255). With that, the same crops go from a mean channel difference of 38–58 levels against Inkscape to 5–19, and the whole-object mean from +52 to about +14. The remainder looks like a different, smaller divergence (normal-sampling banding on the bevel ramp and a flat-sheen region where resvg emits the flat-normal specular constant and Inkscape emits nothing) and is not what this issue is about.

The question: Is the doc comment or the code the intended behaviour? Either specular_lighting is meant to produce premultiplied output and the write is missing the multiply, or the output is meant to be straight and the doc comment (and possibly a downstream conversion) is what is off. We would rather follow resvg's intent than carry a local patch.

Minimal reproduction: The file below is the Inkscape original we measured (196.7 × 194.5 user units; the fixture is small enough to attach as is). Render at 393 × 389 with resvg and with inkscape --export-type=png --export-area-page --export-width=393 --export-height=389, and compare the rim of the bevel — the top-right corner shows it most clearly. Two renders at that size are attached: resvg_v0.47.0_unpatched_s7_x2.png and inkscape_1.4.4_s7_x2.png.

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="196.70955" height="194.48279" viewBox="0 0 196.70955 194.48279" version="1.1"
     xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <linearGradient id="grad"><stop style="stop-color:#26174d" offset="0"/><stop style="stop-color:#06050d" offset="1"/></linearGradient>
    <linearGradient xlink:href="#grad" id="fill" gradientUnits="userSpaceOnUse" x1="459.96143" y1="455.69214" x2="459.96143" y2="499.23914"/>
    <filter style="color-interpolation-filters:sRGB" id="bevel" x="-1.377821" y="-2.0031897" width="3.7556421" height="5.0063795">
      <feGaussianBlur result="result6" stdDeviation="12" in="SourceGraphic"/>
      <feComposite in2="SourceGraphic" in="result6" operator="xor" result="result10"/>
      <feGaussianBlur result="result2" stdDeviation="8"/>
      <feComposite in2="SourceGraphic" operator="atop" in="result10" result="result91"/>
      <feComposite result="result4" in="result2" operator="xor" in2="result91"/>
      <feGaussianBlur in="result4" result="result3" stdDeviation="4"/>
      <feSpecularLighting result="result5" specularExponent="5" specularConstant="1.10000002" surfaceScale="18">
        <feDistantLight azimuth="235" elevation="55"/>
      </feSpecularLighting>
      <feComposite in="result3" k3="1.1" k2="0.5" operator="arithmetic" result="result7" in2="result5" k1="0.5" k4="0"/>
      <feGaussianBlur in="result7" result="result8" stdDeviation="1"/>
      <feComposite in="result8" operator="atop" in2="SourceGraphic" result="result9"/>
    </filter>
  </defs>
  <g transform="translate(51.099243,-387.16876)">
    <g transform="matrix(1.2239849,0,0,1.3198405,-515.73036,-145.76835)">
      <path style="fill:url(#fill);stroke-width:2.31906;stroke-linecap:square;filter:url(#bevel)"
            transform="matrix(0.98266782,0,0,0.98266782,7.9721569,8.2755445)"
            d="m 450.32773,462.48953 h 19.26742 c 6.72545,0 12.1398,4.7471 12.1398,10.64372 v 8.66478 c 0,5.89663 -5.41435,10.64373 -12.1398,10.64373 h -19.26742 c -6.72545,0 -12.1398,-4.7471 -12.1398,-10.64373 v -8.66478 c 0,-5.89662 5.41435,-10.64372 12.1398,-10.64372 z"/>
    </g>
  </g>
</svg>

(The attached s7_lighting.svg is the Inkscape original; the snippet above is the same file with ids shortened and Inkscape's editor metadata removed.)

What we are not claiming: That Inkscape is the reference implementation — it is simply the tool the art was authored in, so it is what "looks right" was judged against. That the three-line change is correct for every consumer of resvg — it made our output match one authoring tool on one class of art. That this is a bug rather than a documented choice — the doc comment is what made us ask.

What we would do with an answer:

If a release changes the write (or the doc), we rebase to it and drop the local patch. If the current behaviour is intended, we keep the patch as a deliberate authoring-tool match and note it as such.

Thanks for resvg — it replaced our previous SVG path and this bevel is the only place it and Inkscape disagree by more than a few levels.

Image Image Image

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in crates/resvg/src/filter/lighting.rs with specular_lighting and its doc comment, then trace how its destination buffer is consumed by the surrounding filter pipeline. Render the provided s7_lighting.svg through the plain resvg path at the stated size and compare the partially transparent bevel edges. Done means the implementation and documentation agree on the alpha convention, with the observed mismatch explained or corrected.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
computer-graphics
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.