Monthly Archives: April 2015

XE8 and Radial Gradients in Windows FMX Applications

The Problem

(Update 2015-06-14: Note that the article below has been updated to set Radial Gradient Center Point and not GradientOriginOffset (or focal point) .  Setting GradientOriginOffset can skew the gradient and lead to incorrect results).

In Delphi XE8, Embarcadero broke (or perhaps more correctly, broke further) how radial gradients are rendered on Windows using their FMX.Canvas.D2D.pas file.  This issue affects anyone who uses a radial gradient and the center point/focal point to not be in the exact middle of the shape being rendered.  For the RiverSoftAVG SVG Component Library, SVG tend to define these types of gradients all the time so that is how I became aware of the regression.  Prior to XE8, the FMX.Canvas.D2D unit created a radial gradient brush whose center was modified by the Brush.Gradient.RadialTransform.RotationCenter.  This allowed the SVG library to correctly render radial gradients that had their center or focal point not equal to 50%, 50% (i.e., the center).  The screenshots below show the old pre-XE8 behavior:

Radial Gradient (Centered Origin 50%, 50%)

Radial Gradient (Centered Origin 50%, 50%)

Radial Gradient (Origin Down and Right 75%, 75%)

Radial Gradient (Origin Down and Right 75%, 75%)

In the screenshots above, we set up the top Ellipse’s gradient brushes using RotationCenter and copied the brush to the canvas to draw 2 additional ellipses:

procedure TForm24.PaintBox1Paint(Sender: TObject; Canvas: TCanvas);
var
 aSize: Single;
begin
 aSize := PaintBox1.Height*0.33;
 Canvas.Fill := Ellipse1.Fill;
 // draw upper left
 Canvas.FillEllipse(RectF(0,0,aSize,aSize), 1);
 // draw bottom right
 Canvas.FillEllipse(RectF(PaintBox1.Width-aSize,PaintBox1.Height-aSize,PaintBox1.Width,PaintBox1.Height), 1);
end;

The code from XE7 and before in the FMX.Canvas.D2D.pas looked like this:

rgradbrushprop.GradientOriginOffset := TD2D1Point2F(Point(0, 0));
 rgradbrushprop.Center := TD2D1Point2F(
 PointF(AGradient.RadialTransform.RotationCenter.X * RectWidth(ARect),
 AGradient.RadialTransform.RotationCenter.y * RectHeight(ARect)) + ARect.TopLeft);
 rgradbrushprop.RadiusX := RectWidth(ARect) / 2;
 rgradbrushprop.RadiusY := RectHeight(ARect) / 2;
 FTarget.CreateRadialGradientBrush(rgradbrushprop, nil, gradcol, ID2D1RadialGradientBrush(Result));

If you notice, Embarcadero set the center of the gradient to the center point of the rectangle (usually RotationCenter.Point := PointF(0.5, 0.5)) and translated the center point by the rectangle being drawn (by adding ARect.TopLeft). There were quite a few problems with this code:

  • First is a small cosmetic problem. Why was Embarcadero using RadialTransform.RotationCenter? Rotation Center seems a poor naming choice for the center of the gradient. Probably, the RadialTransform.Position would have been a better choice.
  • Why were they setting the Center of the gradient and not the GradientOriginOffset?
  • There is no means to set the radius of the gradient (and that is why the SVG library cannot set the radius of a gradient)

However, there were a couple of things sorta right about the code:

  • You could set the gradient focal point
  • The center of the gradient was specified by using a value from 0 to 1 and was translated by where the rectangle was, meaning that it scaled to the rectangle it was being drawn in. If you changed the rectangle size or location, the gradient would be drawn correctly.

In XE8, Embarcadero changed FMX.Canvas.D2D.pas to closely mirror their FMX.Canvas.GDIP.pas:

RadialGradBrushProp.GradientOriginOffset := TD2D1Point2F(TPointF.Create(0, 0));
 RadialGradBrushProp.Center := TD2D1Point2F(TPointF.Create(ARect.Width * 0.5, ARect.Height * 0.5));
 RadialGradBrushProp.RadiusX := ARect.Width / 2;
 RadialGradBrushProp.RadiusY := ARect.Height / 2;
 FTarget.CreateRadialGradientBrush(RadialGradBrushProp, nil, GradCol, ID2D1RadialGradientBrush(Result));
 UpdateBrushMatrix(Result, AGradient.RadialTransform.Matrix);

All modifications to the radial gradient now occur in the UpdateBrushMatrix which applies the AGradient.RadialTransform’s transformation matrix to the brush’s matrix. In some ways, this could be seen as an improvement as theoretically, you can modify the radial gradient how ever you like by using the RadialTransform.Matrix. However, there are some BIG problems with this approach:

  • You cannot set TTransformation.Matrix property directly. It is read-only. The TTransformation.Matrix property is generated by the class when you modify the Position, Scale, and Skew properties. But the Skew property is protected. You can hack the class to get to the Skew property but it is certainly not straightforward.
  • Even worse, you need to set the transformation matrix using absolute values, not proportional values between 0 and 1. To translate the gradient center point, you need to know the rectangle it will be drawn in the future to create the gradient brush. And because the gradient center was not offset by the ARect.TopLeft, you have to take that into account too.
  • Even worse than that, since you must use absolute values for the gradient, that gradient can only be used correctly with one rectangle or object. In addition, even if the gradient on a TEllipse or other shape is set correctly, if that TEllipse is moved or resized, the gradient is wrong. You cannot share that gradient with another rectangle.

The screenshots below show the problems with the new, XE8 behavior:

Radial Gradient in XE8 (Centered Origin 50%, 50%).  Note how the bottom right ellipse has lost its centered gradient because it is drawn in a rectangle that is not at the origin.

Radial Gradient in XE8 (Centered Origin 50%, 50%). Note how the bottom right ellipse has lost its centered gradient because it is drawn in a rectangle that is not at the origin.

Radial Gradient in XE8 (Origin Down Right 75%, 75%).  By clever manipulation of the transformation matrix, we set the focal point to (114,94), which works for the top Ellipse.  However, the same gradient does not work for either of the other ellipses since their rectangles are different.

Radial Gradient in XE8 (Origin Down Right 75%, 75%). By clever manipulation of the transformation matrix, we set the focal point to (114,94), which works for the top Ellipse. However, the same gradient does not work for either of the other ellipses since their rectangles are different.

Note that the FMX GDI+ never worked, but since that canvas was used rarely, this issue was ignored by the RSCL.

Interestingly, Embarcadero only made this change on Windows. Testing on OSX and Android reveals that the previous behavior, using RotationCenter, is still in effect. (Note XE8 is giving us problems deploying to iOS so we could not test its behavior)

What does this mean for the RiverSoftAVG SVG Component Library?

First, because there is no easy way to set the gradient center point or focal point correctly and even if we did, the behavior would break as soon as a SVG element was moved or resized, we are not going to change the code for how radial gradient’s are set at this time. Using the default Embarcadero XE8 DirectX2D code on Windows for radial gradient with a non-centered focal point, the gradient will be displayed incorrectly. On other platforms and earlier versions of Delphi, the radial gradient will work as well as it ever did.

The Solution (for Everyone)

However, you can get back proper rendering of Radial Gradients on Windows for your compiled applications by hacking the FMX.Canvas.D2D.pas file. Perform the following steps:

  • Copy FMX.Canvas.D2D.pas from Delphi’s source\fmx directory to your project’s directory
  • Modify the TCanvasD2D.CreateD2DGradientBrush method by replacing the entire “begin { Radial }” with
begin
 { Radial }
 for I := 0 to AGradient.Points.Count + Count - 1 do
 Grad[I].Position := 1 - Grad[I].Position;
 FTarget.CreateGradientStopCollection(@Grad[0], AGradient.Points.Count + Count, D2D1_GAMMA_2_2,
 D2D1_EXTEND_MODE_CLAMP, GradCol);
 // assume RotationCenter in range 0-1, modify the gradient origin offset
 RadialGradBrushProp.GradientOriginOffset := TD2D1Point2F(TPointF.Create(0, 0));
 // assume RotationCenter in range 0-1, translate gradient center by rectangle.TopLeft
 RadialGradBrushProp.Center := TD2D1Point2F(TPointF.Create(AGradient.RadialTransform.RotationCenter.X * ARect.Width,
 AGradient.RadialTransform.RotationCenter.Y * ARect.Height) + ARect.TopLeft);
 // bonus points, assume scale contains the percent of the radius to display
 // i.e., usually r=1 for the whole rectangle
 RadialGradBrushProp.RadiusX := AGradient.RadialTransform.Scale.X*(ARect.Width / 2);
 RadialGradBrushProp.RadiusY := AGradient.RadialTransform.Scale.Y*(ARect.Height / 2);
 FTarget.CreateRadialGradientBrush(RadialGradBrushProp, nil, GradCol, ID2D1RadialGradientBrush(Result));
// UpdateBrushMatrix(Result, M);
 GradCol := nil;
 end;

Alternatively, you can download the changed file.  As a bonus, the above code uses the AGradient.RadialTransform.Scale property as a proxy for setting the radius of the gradient (the RSCL has set the scale based on a SVG element’s radius since February 2015 but it is unused without the above hack for each platform and version of Delphi you are using).

Well, that’s it for now.  I hope this helps others.  Happy CodeSmithing!