<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Skia on Trevor's Blog</title><link>http://mings.in/en/tags/skia/</link><description>Recent content in Skia on Trevor's Blog</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Thu, 12 Nov 2026 08:40:00 +0800</lastBuildDate><atom:link href="http://mings.in/en/tags/skia/index.xml" rel="self" type="application/rss+xml"/><item><title>You Asked for Rounded Corners, Compose Multiplatform Desktop Drew a Spike</title><link>http://mings.in/en/posts/cmp-stroke-join/</link><pubDate>Thu, 12 Nov 2026 08:40:00 +0800</pubDate><guid>http://mings.in/en/posts/cmp-stroke-join/</guid><description>Stroke(join = Round) was drawn as Miter on Compose Multiplatform&amp;rsquo;s desktop, iOS and web targets while Android stayed fine. The root cause is a setter that never runs. I rendered both versions and diffed them pixel by pixel: 1,619 pixels apart.</description><content:encoded><![CDATA[<p>Write <code>Stroke(join = StrokeJoin.Round)</code> and it draws spiky. The bug applies to Compose Multiplatform on desktop, iOS and web. Android was never affected.</p>
<p>1.13.0-beta02 fixed it. Released November 4, 2026, with one line of release notes: <code>Stroke(join = Round)</code> and <code>Stroke(miter = 0f)</code> are no longer ignored.</p>
<p>I verified it locally, rendering a set of images with each version and comparing them pixel by pixel.</p>
<h2 id="how-to-reproduce-it-first">How to reproduce it first</h2>
<p>You do not need to open a window. Compose desktop has <code>ImageComposeScene</code>: give it a size and a density, render the Compose tree into an <code>Image</code>, encode it as PNG.</p>
<p>A 360×300 canvas at density 2 produces a 720×600 image, enough to see the corners clearly.</p>
<p>Only two things change between runs: the dependency version, and the <code>Stroke</code> parameters.</p>
<p><img alt="The project and how it renders" loading="lazy" src="/images/cmp-stroke-join/fig1.png"></p>
<h2 id="same-code-two-shapes">Same code, two shapes</h2>
<p>This first group is <code>join = Round</code>, width 30, with rounded caps as well.</p>
<p><img alt="join and miter across four renders in two versions" loading="lazy" src="/images/cmp-stroke-join/fig2.png"></p>
<p>On the left all three corners are sharp, with small spikes on top. On the right they are round and the spikes are gone.</p>
<p>The second group is <code>miter = 0f</code>, drawn as an acute-angled V on purpose. Left keeps the long spike, right gets cut flat.</p>
<p>By Skia&rsquo;s rules, a miter limit of 0 means no spike at all: every join falls back to a bevel. In 1.12.2 that 0 was written for nothing.</p>
<h2 id="numbers-are-harder-than-pictures">Numbers are harder than pictures</h2>
<p>I diffed the two images within each version against each other, then diffed corresponding images across versions.</p>
<p><img alt="Where and how many pixels differ" loading="lazy" src="/images/cmp-stroke-join/fig3.png"></p>
<p>In 1.12.2, the <code>join=Round</code> and <code>join=Miter</code> images are pixel-identical, identical in PNG byte count too, both 8,905 bytes, hashes matching character for character.</p>
<p><code>miter=0f</code> and <code>miter=4f</code> are likewise identical, both 4,176 bytes.</p>
<p>Under 1.13.0-beta02, the same two comparisons differ by 1,619 pixels and 414 pixels respectively. The full image is 108,000 pixels, so the first is 1.5%.</p>
<p>All the difference sits at the tips of three corners, the magenta patches in the figure above.</p>
<h2 id="root-cause-a-setter-that-never-runs">Root cause: a setter that never runs</h2>
<p>Compose&rsquo;s <code>Paint</code> is a wrapper around Skia&rsquo;s <code>Paint</code>. The properties look like this:</p>
<p><img alt="Defaults before and after the fix" loading="lazy" src="/images/cmp-stroke-join/fig4.png"></p>
<p><code>strokeJoin</code> has a custom setter that writes through to the Skia copy on assignment. But property initialization does not go through setters. It only fills the Kotlin field.</p>
<p>So the wrapper says the default is Round while Skia actually holds Miter. The two sides diverge.</p>
<p>What makes it fatal is the next step. During drawing, before assignment there is a check: if the value equals the current field, skip. You write <code>join = Round</code>, the field already holds Round, the check passes, the setter is never called, and Skia never learns you changed anything.</p>
<p><code>miter = 0f</code> is the same story. The field default is already 0f.</p>
<p>A side note on <code>filterQuality</code>: it has no setter at all, just a plain field, so changing it does not notify Skia either. This wrapper has more than one field like that. The ones that do not affect shape simply go unnoticed.</p>
<p>Tim Haywood filed the issue on YouTrack three months ago, CMP-10688, titled &ldquo;incorrectly renders StrokeJoin.Round as Miter&rdquo;. He laid out the root cause, the Skia source location, potential fixes, and even proposed deleting the Kotlin field and proxying directly to Skia.</p>
<p>Ivan Matkov fixed it. The PR is compose-multiplatform-core #3528, merged into release/1.13 on October 26, reviewed twice. Three months from issue to merge.</p>
<h2 id="the-fix-is-correcting-the-defaults">The fix is correcting the defaults</h2>
<p>The PR is surprisingly small: change three defaults so they match Android.</p>
<p><code>strokeJoin</code> from Round to Miter, <code>strokeMiterLimit</code> from 0f to 4f, <code>filterQuality</code> from Medium to Low.</p>
<p>After that, the wrapper&rsquo;s field and Skia&rsquo;s actual state agree. You write Round against a field holding Miter, the check fails, the setter runs normally, and the parameter goes through.</p>
<p>So this is not a drawing fix. It is reconciling books that were reporting the wrong numbers.</p>
<h2 id="the-control-groups-zero-says-nothing-else-broke">The control group&rsquo;s zero says nothing else broke</h2>
<p>My worry with a change at this level is that it drags something else off course. So I kept a control: <code>join = Miter</code>, compared across both versions.</p>
<p>Zero pixels differ. Not a single byte.</p>
<p>In 1.13.0-beta02 the round-versus-bevel changes land only on the affected path.</p>
<h2 id="why-it-took-so-long-to-be-seen">Why it took so long to be seen</h2>
<p>I suspect three reasons stacked up.</p>
<p>First, the default <code>join</code> is already Miter. Most charts, icons and progress bars never pass this parameter. The shape comes out right, so nobody digs.</p>
<p>Second, where <code>Round</code> is passed explicitly, the result is merely &ldquo;not round enough,&rdquo; not something loud like a crash. A designer says &ldquo;this corner looks a bit sharp,&rdquo; and nine engineers out of ten suspect their own path before they suspect the framework.</p>
<p>Third, it only happens off Android. The same code is correct on a real device and spiky in the desktop preview, and people readily attribute that to the preview tool&rsquo;s rendering rather than a framework bug.</p>
<h2 id="only-non-android-is-affected">Only non-Android is affected</h2>
<p>The diff lands in <code>compose/ui/ui-skiko/src/nonAndroidMain/</code>. Android goes through <code>android.graphics.Paint</code>, whose defaults were already Miter, 4f, Low.</p>
<p>Which gives this bug an ugly property: you cannot see it on an Android device, but the moment you run desktop or iOS, it deforms. People writing cross-platform UI see a different shape on their own machine than everyone else does.</p>
<h2 id="the-third-default-i-could-not-measure">The third default I could not measure</h2>
<p><code>filterQuality</code> changed from Medium to Low, which in theory affects sampling quality on image scaling. I took a 256-pixel checkerboard, scaled it down to 40 and to 80, and scaled an 8-pixel image up to 320.</p>
<p>Three comparisons, all zero pixels different.</p>
<p>The reason is that <code>drawImage</code> carries its own <code>filterQuality</code> parameter and does not route through <code>Paint</code>. This change only matters if you construct a <code>Paint</code> yourself to draw. So it is a latent hazard, not an observable symptom.</p>
<p>I looked for a path where it shows and found none, so I wrote it down as it is instead of inventing a scenario for it.</p>
<p><img alt="What the three defaults changed" loading="lazy" src="/images/cmp-stroke-join/fig5.png"></p>
<h2 id="what-you-should-do-now">What you should do now</h2>
<p>If your project is on 1.12 or earlier and you use parameters like <code>join = Round</code> or <code>miter = 0f</code> that appear to do nothing, you are affected.</p>
<p>Two paths. Upgrade to 1.13.0-beta02 and accept that it is still pre-release. Or rewrite the shape so it does not depend on those parameters, drawing a rounded polyline yourself.</p>
<p>To find out whether you are affected, you do not need to read code. Write an <code>ImageComposeScene</code>, render the same shape twice, once with <code>join = Round</code> and once with <code>join = Miter</code>, and diff the two PNGs. Zero pixels different means you are affected; a few thousand means you are fine. That method worked well for my images and should work for any drawing bug where a parameter appears not to take effect.</p>
<p>One thing to know before upgrading: in 1.13, <code>Paint()</code> gained a precondition and constructing one directly throws <code>No Compose UI graphics implementation is registered</code>. Either create a scene first or go through the normal drawing path. I hit this on my first run and only got going after rendering one image to warm things up.</p>
<p>As for how long this had been sitting in the wild, I do not know. The issue was filed three months ago, and the code may be older. Bugs where &ldquo;the parameter is written correctly but does nothing&rdquo; can only be seen with pixel-level comparison.</p>
]]></content:encoded></item></channel></rss>