Skip to content

Commit 5a2a001

Browse files
shai-almogclaude
andcommitted
Fix iOS Metal and Android SVG rendering bugs exposed by PR #5042
Three rendering-layer bugs the SVGStaticScreenshotTest / SVGAnimatedScreenshotTest captured in their goldens, now fixed at the port level so the goldens can be retaken on top: 1. iOS Metal clip on arc-decomposed paths -- gradient_circle.svg and clipped_badge.svg rendered as triangles. setClip(GeneralPath)'s native side (setNativeClippingShapeMutableImpl and setNativeClippingPolygonGlobalImpl) ignores the path command stream and treats the raw points buffer as a flat polygon. For a path with QUADTO segments, every control point appears as a polygon vertex, and the stencil triangle-fan that CN1MetalApplyPolygonStencilClip uses produces the degenerate triangle. Fix at the Java boundary: flatten any non-rect ClipShape via midpoint subdivision into a polyline GeneralPath before sending it down, so only true polygon vertices reach the native side. Works for both the global and mutable-image paths. 2. iOS Metal drawString skips the affine scale -- CoreText shapes the line and the atlas rasterises glyphs at font.pointSize, so a quad stretched by a 2x-4x viewBox transform smears the bitmap on the GPU. CN1MetalDrawString now reads the effective screen scale from currentTransform (column magnitudes of the 2x2), rasterises the atlas at font.pointSize * scale via [font fontWithSize:...], and divides every glyph position / bearing / bbox / slot dimension by the same factor so the vertex coords stay in caller-side space. The vertex shader re-applies the same scale via the transform and the result is a 1:1 atlas sample. Pure rotation / translation keeps the fast useScaledFont == NO path. 3. Android (and iOS Metal once #1 unmasked it) gradient_circle.svg double-circle -- the gradient fill landed below the dark-blue stroke instead of inside it. LinearGradientPaint.paint(g, w, h) captured g.getTranslateX()/Y(), zeroed them out, and baked them into t2 via t2.translate(startX + tx, startY + ty). On every active port (isTranslationSupported() == false) Graphics.setTransform already conjugates the user matrix with T(xTranslate) so the cell offset applies at the screen level; baking tx/ty inside a translate that sits before the SVG scale meant the offset went through that scale twice (sy * label_Y extra) and slid the gradient fill off the circle. Drop the dance entirely -- build t2 as T * Translate(startX, startY) * Rotate * Translate(0, -ph/2) and let the existing conjugation re-apply the screen-level offset. Also drops the "Known port-side rendering bugs the goldens encode" block from SVGStaticScreenshotTest's javadoc -- those items are this PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fd0516d commit 5a2a001

4 files changed

Lines changed: 324 additions & 49 deletions

File tree

CodenameOne/src/com/codename1/ui/LinearGradientPaint.java

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,6 @@ private float[] reverseFractions() {
181181
@Override
182182
@SuppressWarnings("UnusedFormalParameter")
183183
public void paint(Graphics g, double x, double y, double w, double h) {
184-
// TODO: x and y probably need to be taken into consideration here...
185184
paint(g, w, h, true);
186185
}
187186

@@ -200,11 +199,26 @@ private void paint(Graphics g, double w, double h, boolean processCycles) {
200199
if (getTransform() != null) {
201200
t2.concatenate(getTransform());
202201
}
203-
int tx = g.getTranslateX();
204-
int ty = g.getTranslateY();
205-
g.translate(-tx, -ty);
206-
207-
t2.translate((float) (startX + tx), (float) (startY + ty));
202+
// Build the gradient frame on top of the caller's transform. The
203+
// previous version captured `g.getTranslateX()/getTranslateY()` and
204+
// baked them into `t2.translate(startX + tx, startY + ty)`, then
205+
// zeroed `g.translate(-tx, -ty)` before `g.setTransform(t2)`. On
206+
// ports where `isTranslationSupported()` is false (iOS, Android,
207+
// JavaSE — every active port today), Graphics already conjugates
208+
// setTransform with `T(xTranslate)` so the user matrix operates in
209+
// local coordinates regardless of prior g.translate; that
210+
// conjugation re-applies the cell offset at the *screen* level
211+
// automatically. Baking `tx, ty` into a translate that sits
212+
// *inside* the SVG / theme scale meant the cell offset went
213+
// through the scale a second time, shifting the gradient fill
214+
// away from the stroke. Most visible on SVGStaticScreenshotTest's
215+
// gradient_circle, where the filled circle appeared stacked below
216+
// the dark-blue outline on Android (and would have appeared the
217+
// same on iOS Metal once the triangle-clip bug was unmasked).
218+
// Just build `t * Translate(startX, startY) * Rotate * Translate(0,
219+
// -ph/2)` and let Graphics.setTransform's existing conjugation
220+
// restore the screen-level offset.
221+
t2.translate((float) startX, (float) startY);
208222
t2.rotate((float) theta, 0, 0);
209223
t2.translate(0, -(float) ph / 2);
210224

@@ -293,7 +307,6 @@ private void paint(Graphics g, double w, double h, boolean processCycles) {
293307
}*/
294308
g.setAlpha(alpha);
295309
g.setTransform(t);
296-
g.translate(tx, ty);
297310
if (p != null) {
298311
g.setColor(p);
299312
}

Ports/iOSPort/nativeSources/CN1Metalcompat.m

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -755,13 +755,74 @@ void CN1MetalTileImage(id<MTLTexture> texture, int alpha,
755755
// the failure rather than papering over it with a different pipeline
756756
// that would silently mask the bug.
757757

758+
// Returns the effective screen-pixel scale baked into the current
759+
// transform. The vertex shader applies `projection * modelView *
760+
// transform * pos`; projection / modelView are stable per frame and
761+
// expressed in framebuffer units, so any *additional* scaling comes
762+
// from `currentTransform`. For text rendering we want to know that
763+
// effective scale up front so the glyph atlas can rasterise at the
764+
// matching pixel size; otherwise the atlas glyph art (rasterised at
765+
// font.pointSize) is sampled through a stretched quad and the glyph
766+
// turns into a smear at every `g.setTransform(scale)` site -- e.g.
767+
// the SVG transcoder painting `<text>` under a viewBox-to-display
768+
// scale, which is the most visible offender.
769+
//
770+
// Pulls a uniform scale by averaging the magnitudes of the two basis
771+
// vectors of the upper-left 2x2 (sx along the X column, sy along the
772+
// Y column). Shear-only or pure-rotation matrices return 1 because
773+
// both column magnitudes stay at 1; pure scale returns the scale.
774+
// We do *not* try to handle non-uniform scale separately -- the
775+
// glyph atlas slot key is one float (pointSize), so even if the
776+
// SVG draws with sx != sy we have to pick one. Going with the
777+
// geometric mean keeps the rasterised glyph close to either bound
778+
// and the residual GPU stretch only kicks in along the dimension
779+
// that's farther from the mean.
780+
static inline float currentTransformGlyphScale(void) {
781+
float c0x = currentTransform.columns[0].x;
782+
float c0y = currentTransform.columns[0].y;
783+
float c1x = currentTransform.columns[1].x;
784+
float c1y = currentTransform.columns[1].y;
785+
float sx = sqrtf(c0x * c0x + c0y * c0y);
786+
float sy = sqrtf(c1x * c1x + c1y * c1y);
787+
// Cap at 8x to keep the atlas from rasterising absurdly large
788+
// bitmaps for a runaway transform; well past 8x the difference
789+
// between "atlas-perfect" and "sampled-and-filtered" is below
790+
// what the user can see anyway.
791+
float s = (sx + sy) * 0.5f;
792+
if (s < 1.0f) s = 1.0f; // No down-rasterising; 1px atlas is fine for downscale.
793+
if (s > 8.0f) s = 8.0f;
794+
return s;
795+
}
796+
758797
void CN1MetalDrawString(NSString *str, UIFont *font, int color, int alpha, int x, int y) {
759798
if (str == nil || font == nil || str.length == 0) return;
760799

761-
CN1MetalGlyphAtlas *atlas = [CN1MetalGlyphAtlas atlasForFont:font];
800+
// CoreText shapes glyphs and the atlas rasterises them at font.pointSize
801+
// — but the active Graphics transform may be scaling the whole drawing
802+
// up before the framebuffer write. If we hand the shader a quad sized to
803+
// the unscaled glyph and let the transform stretch it on the GPU, the
804+
// result is a smeared/blurry glyph (Codename One's SVG transcoder paints
805+
// viewBox-relative text through `g.setTransform(scale*translate)`, so the
806+
// screen scale is routinely 2x-4x). Detect the scale baked into
807+
// `currentTransform` and rasterise the atlas at the effective pixel size
808+
// so the shader transform produces a 1:1 sample. We then divide the
809+
// returned glyph metrics back down by the same factor so the vertex
810+
// coords stay in unscaled space — the GPU re-applies `currentTransform`
811+
// for free and the final on-screen position matches the unscaled path.
812+
float glyphScale = currentTransformGlyphScale();
813+
BOOL useScaledFont = (glyphScale > 1.01f);
814+
UIFont *renderFont = useScaledFont
815+
? [font fontWithSize:font.pointSize * glyphScale]
816+
: font;
817+
if (renderFont == nil) {
818+
renderFont = font;
819+
useScaledFont = NO;
820+
}
821+
822+
CN1MetalGlyphAtlas *atlas = [CN1MetalGlyphAtlas atlasForFont:renderFont];
762823
if (atlas == nil) {
763824
NSLog(@"CN1MetalDrawString: no atlas available for font %@ pt=%g; string skipped",
764-
font.fontName, (double)font.pointSize);
825+
renderFont.fontName, (double)renderFont.pointSize);
765826
return;
766827
}
767828

@@ -774,7 +835,7 @@ void CN1MetalDrawString(NSString *str, UIFont *font, int color, int alpha, int x
774835
// fresh form, which surfaced as the TL panel of graphics-draw-string-
775836
// decorated rendering larger/wider glyphs than TR/BL/BR despite
776837
// identical Java state.
777-
NSDictionary *attrs = @{ (__bridge NSString *)kCTFontAttributeName: font };
838+
NSDictionary *attrs = @{ (__bridge NSString *)kCTFontAttributeName: renderFont };
778839
CFAttributedStringRef attrStr = CFAttributedStringCreate(NULL,
779840
(__bridge CFStringRef)str,
780841
(__bridge CFDictionaryRef)attrs);
@@ -798,7 +859,14 @@ void CN1MetalDrawString(NSString *str, UIFont *font, int color, int alpha, int x
798859
// (not CTFontGetAscent) is intentional — UIKit's metric is what
799860
// drawAtPoint references and the values can disagree slightly across
800861
// fonts.
862+
//
863+
// Use the ORIGINAL font's ascender (and the original pointSize) so the
864+
// baseline lands where the caller-side framework expects, even when we
865+
// upscaled the atlas. The atlas-internal metrics (renderFont) reflect
866+
// the rasterised size; we divide them by `glyphScale` below to bring
867+
// them back into caller-side coords.
801868
float baselineY = (float)y + (float)font.ascender;
869+
float invScale = useScaledFont ? (1.0f / glyphScale) : 1.0f;
802870

803871
simd_float4 colorV = premultipliedColor(color, alpha);
804872
int textureW = atlas.textureWidth;
@@ -838,11 +906,24 @@ void CN1MetalDrawString(NSString *str, UIFont *font, int color, int alpha, int x
838906
// bbox-left-on-screen = x + posX + bearingX
839907
// bbox-top-on-screen = baselineY - posY - (bearingY + bbox.height)
840908
// Slot extends 1px above and to the left of the bbox.
841-
float gx = (float)x + (float)posPtr[i].x + slot.bearingX - 1.0f;
842-
float gy = baselineY - (float)posPtr[i].y
843-
- (slot.bearingY + slot.bboxHeight) - 1.0f;
844-
float gw = (float)slot.width;
845-
float gh = (float)slot.height;
909+
//
910+
// When the atlas was rasterised at the upscaled size, the
911+
// CoreText positions and slot metrics are in renderFont-pixel
912+
// space (which is glyphScale times the caller-side pixel space).
913+
// Divide each one back down by glyphScale so the emitted vertex
914+
// coords live in caller-side space — the vertex shader will
915+
// re-apply currentTransform (the same scale we factored out) and
916+
// produce a quad of the correct on-screen size, sampling
917+
// 1:1 against the now-matching atlas.
918+
float posX = (float)posPtr[i].x * invScale;
919+
float posY = (float)posPtr[i].y * invScale;
920+
float bearingX = slot.bearingX * invScale;
921+
float bearingY = slot.bearingY * invScale;
922+
float bboxHeight = slot.bboxHeight * invScale;
923+
float gx = (float)x + posX + bearingX - invScale;
924+
float gy = baselineY - posY - (bearingY + bboxHeight) - invScale;
925+
float gw = (float)slot.width * invScale;
926+
float gh = (float)slot.height * invScale;
846927

847928
float vertices[8] = {
848929
gx, gy,

0 commit comments

Comments
 (0)