Introduction
This article is about creating effects with shapes, and additionally, how to build a plug-in architecture for WPF as well as control XAML serialization through attributes. It began as a glowing neon star for use as an icon, and ended as a versatile visual brush factory. It soon became obvious that the solution could be extended. I decided to create an application for creating graphical effects with the following characteristics:
- Able to use a number of different sources of geometry (Geometry Sources) and styles (Effect Factories) in a mix and match fashion.
- Geometry Sources and Effect Factories should be able to be added at a later date without my needing to alter existing code.
- This was not just to be a pretty image generator. It would need to produce reasonably concise XAML that I could paste into another project and have the same images.
Most of the time, we create effects in Illustrator or Photoshop by taking an image and then making copies and stacking them on top of each other. Each layer is then transformed in some way. Some rather striking effects can be made rather easily in this way. The following is the basic plan for generating stacked shapes:
- A geometry is created from a Geometry Source class.
- A collection of effects is created from an Effect Factory.
- The geometry is used to create a collection of Paths.
- The effects are applied to the Paths.
- The Paths are added in order as Children of a Canvas.
- A visual brush is created from this Canvas and we are done.
Background
It might be useful, and potentially entertaining, to go to one of the Internet graphics training sites like TutVid.com and watch some of the videos. In particular, the comic Effect Factory was inspired by one of their videos. If you search YouTube under Illustrator tutorial, you will also find lots of excellent info.
It is also worth noting that this article originally came about as a means of improving the appearance of PolyStars. If you want a more detailed explanation of the polystar geometry, you can find it there.
Creating Geometries
The effects will be made by making identical copies of a shape and then modifying them. The following method will copy most shapes, and even though it was rejected, it is worth looking at for a moment. The only restriction is not to directly expose generic types as that will cause the XamlWriter
to fail.
private ObservableCollection<shape> CopyShapes(Shape shape, int copyCount)
{
ObservableCollection<shape> shapeStack = new ObservableCollection<shape>();
string pString = XamlWriter.Save(shape);
for (int i = 0; i < copyCount; i++)
{
StringReader sr = new StringReader(pString);
XmlTextReader xmlr = new XmlTextReader(sr);
Shape cShape = (Shape)XamlReader.Load(xmlr);
shapeStack.Add(cShape);
}
return shapeStack;
}
Originally, a method very similar to this one was used in this solution. It worked, but was unsatisfactory. Using serialization seems like overkill for this situation.
Among the classes that the framework provides that inherit from the shape class, the Path
is noteworthy in that it lets us insert its geometry. Fortunately, there is no problem with multiple Path
instances sharing the same geometry. The issue is how we create the geometry, of which there are three main cases:
- We have control of the class so that we can expose the protected geometry.
- The source of the geometry is a shape.
- The source of the geometry is some text.
The IGeometrySource
The IGeometrySource
interface was created to provide a source of Geometry
for instances of ExternalGeometryShape
.
[CustomXaml]
public interface IGeometrySource:IFactory
{
Geometry CreateGeometry();
Geometry Geometry { get; set; }
}
The CreateGeometry
method is enough to ensure that the class implementing the IGeometrySource
interface can be used to provide Geometry
objects while the Geometry
property is used by StackedGeometry
for caching. I have the option of modifying the class to support this interface as I did with the PolyStar
, or create a wrapper class that implements the interface as with the TextBlock
and the Shape
. The IFactory
interface is used for the plug-in system as well as for updating the display.
Shapes
All classes inheriting from Shape
implement the protected DefiningGeometry
method. Our first instinct would probably be to inherit from Shape
and then expose the method. Unfortunately, all of the framework classes that inherit from Shape
are sealed
. We can always call any class through Reflection if we really want to, regardless of how they are exposed.
private Geometry GetHiddenDefiningGeometry(Shape shape)
{
Type shptype = shape.GetType();
MethodInfo mi = shapeType.GetMethod("CacheDefiningGeometry",
BindingFlags.NonPublic | BindingFlags.Instance);
PropertyInfo pi = shapeType.GetProperty("DefiningGeometry",
BindingFlags.NonPublic | BindingFlags.Instance);
mi.Invoke(Shape, null);
return (Geometry)pi.GetValue(mShape, null);
}
Let's take a look at the methods from the Polygon
class (Thanks Reflector):
Almost all of the work is done in the CacheDefiningGeometry
method. If the shape in question is embedded in XAML, there is a very good chance that it has been rendered prior to our attempt to call its geometry, but it's safer to call the CacheDefiningGeometry
method first.
private void btValidate_Click(object sender, RoutedEventArgs e)
{
ValidationButton but = (ValidationButton)sender;
ShapeSource ss = (ShapeSource)but.DataObject;
TextBox tb = (TextBox)but.ObjectToValidate;
try
{
StringReader sr = new StringReader(tb.Text);
XmlReader xmlReader = XmlReader.Create(sr);
Shape shp = XamlReader.Load(xmlReader) as Shape;
if (shp != null)
{
ss.Shape = shp;
}
}
catch { }
}
This is the code that is used to take the user's input and create a shape from it. It is worth noting that if we call the DefiningGeometry
property without calling the CacheDefiningGeometry
method, it will always be null
. The mistake is unfortunately very easy to make. The above code will work; however, it is not very fast as it could be, since the GetProperty
and GetMethod
methods are slow. The use for Reflection that we usually hear advertised is to allow the use of assemblies and members not known at compile time, but in this case, we already know the members we want to invoke. They just happen to be other than public
, so let's do everything with static
s. Consider this code:
[GeometrySource]
public class ShapeSource:IGeometrySource
{
private static PropertyInfo sPolygonDefiningGeometry;
static ShapeSource()
{
Type shptype = typeof(Polygon);
sPolygonDefiningGeometry = shptype.GetProperty("DefiningGeometry",
BindingFlags.NonPublic | BindingFlags.Instance);
}
public Geometry CreateGeometry()
{
Type shapeType = mShape.GetType();
if (shapeType ==typeof(Polygon))
{
return (Geometry)sPolygonDefiningGeometry.GetValue(mShape, null);
}
else if else
{
Type shptype = mShape.GetType();
PropertyInfo pi = shptype.GetProperty("DefiningGeometry",
BindingFlags.NonPublic | BindingFlags.Instance);
return (Geometry)pi.GetValue(mShape, null);
}
}
}
This code should run at speeds comparable to that of a normal method as the slow methods only need to be called once. At this juncture, I feel obligated to state that methods are often marked as other than public
for a reason. In general, it is not a good idea to disregard an author's intention, but then again, there are times when we just have to go for it.
PolyStar
If you have control over the source code for the shape, all we need to do is expose the defining geometry that we had to create.
public Geometry CreateGeometry()
{
return DefiningGeometry;
}
As you can see, this is about as trivial as it can get. The StackedGeometry
class handles caching, so it's not much of a loss that it is not in the PolyStar
.
Text
Now, aside from its meaning, a block of text is just a set of curves. Fortunately, the framework provides us with easy access to the underlying geometry. The text information is stored within a TextBlock
for convenience.
public System.Windows.Media.Geometry CreateGeometry()
{
ICollection<typeface> faces = mTextBlock.FontFamily.GetTypefaces();
FormattedText fText
= new FormattedText(mTextBlock.Text, CultureInfo.CurrentCulture,
FlowDirection.LeftToRight, faces.First(),
mTextBlock.FontSize, Brushes.Black);
mTextGeometry = fText.BuildGeometry(new Point(0, 0));
return mTextGeometry;
}
I went for simple here. It takes a lot more than this application to make serious text effects. PhotoshopRoadmap has some really good ones. The Chrome and Radioactive text are awesome, but these would require custom shader effects and some fancy geometrical calculations.
Now, we might note that there is a potential problem here. Let's assume that you use some of this styled text as part of the branding for your app. After all, that is what it is intended for. We do not want it to be necessary for every client to have the appropriate fonts installed. That's why the IFactory
interface which IGeometrySource
implements has the IsFrozen
property. When the StackedGeometry
class uses the Geometry Source, it checks to see if it should use CreateGeometry
.
if (!GeometrySource.IsFrozen | GeometrySource.DesignMode)
{
mGeometry = GeometrySource.CreateGeometry();
GeometrySource.Geometry = mGeometry;
}
else
{
mGeometry = GeometrySource.Geometry;
}
We are allowed to use a geometry that is stored in the XAML instead. Be forewarned that this geometry can easily be large when expressed as XAML.
The IEffectfactory
Now that we have our geometries, we need to do something with them. The IEffectFactory
interface is very similar to the IGeometrySource
one.
[CustomXaml]
public interface IEffectFactory:IFactory
{
ShapeVisualPropsCollection CreateLayerCollection();
ShapeVisualPropsCollection ShapeVisualProps { get; set; }
}
Effect Factories produce collections of visual properties through CreateLayerCollection
, and are cached through the ShapeVisualProps
property. The ShapeVisualPropsCollection
class was created so that the XamlWriter
would not choke on Generics.
public class ShapeVisualPropsCollection:ObservableCollection<shapevisualprops>{}
The Shape
visual ShapeVisualProps
class contains all of the visual properties in the Shape
class. As I wanted the class to behave exactly like a Shape
, I simply copied the code out of Reflector and made some minor adjustments. This is something I strongly recommend. If you want to imitate something in the framework, it is worth considering. ShapeVisualProps
has a significant method Apply
which applies its properties to the shape in question.
public void Apply(Shape shape)
{
shape.Fill = Fill;
shape.StrokeDashArray = StrokeDashArray;
shape.StrokeDashCap = StrokeDashCap;
shape.StrokeDashOffset = StrokeDashOffset;
shape.StrokeEndLineCap = StrokeEndLineCap;
shape.StrokeLineJoin = StrokeLineJoin;
shape.StrokeMiterLimit = StrokeMiterLimit;
shape.Stroke = Stroke;
shape.StrokeStartLineCap = StrokeStartLineCap;
shape.StrokeThickness = StrokeThickness;
shape.Effect = mEffect;
shape.RenderTransform = mTransformGroup;
}
There are two factories created for this solution so far, the NeonFactory
and the ComicFactory
.
Neon Factory
A Neon light consists of a glass tube filled with neon or other noble gasses. The light has three main areas: the inner glowing column of gas, the edge of the glass where there is no gas, and the surrounding glow.
public ShapeVisualPropsCollection CreateLayerCollection()
{
ShapeVisualPropsCollection svps = new ShapeVisualPropsCollection();
ShapeVisualProps svp = new ShapeVisualProps();
double currentSaturation = StartingSaturation;
double currentBrightness = StartingBrightness;
svp.Stroke = new SolidColorBrush(MediaColor(
DevCorpColor.ColorSpaceHelper.HSBtoColor(Hue,
currentSaturation, currentBrightness)));
svp.StrokeThickness = StartingThickness * 4 * GlowMultiplier;
svp.StrokeLineJoin = PenLineJoin.Round;
System.Windows.Media.Effects.BlurEffect blur =
new System.Windows.Media.Effects.BlurEffect();
blur.Radius = 12;
svp.Effect=blur ;
svps.Add(svp);
svp = new ShapeVisualProps();
currentSaturation *= SaurationMultiplier[0];
currentBrightness *= BrightnessMultiplier[0];
svp.Stroke = new SolidColorBrush(MediaColor(
DevCorpColor.ColorSpaceHelper.HSBtoColor(Hue, currentSaturation,
currentBrightness)));
svp.StrokeThickness = StartingThickness * 2;
svp.StrokeLineJoin = PenLineJoin.Round;
svps.Add(svp);
svp = new ShapeVisualProps();
currentSaturation *= SaurationMultiplier[1];
currentBrightness *= BrightnessMultiplier[1];
svp.Stroke = new SolidColorBrush(MediaColor(
DevCorpColor.ColorSpaceHelper.HSBtoColor(Hue, currentSaturation,
currentBrightness)));
svp.StrokeThickness = StartingThickness ;
svp.StrokeLineJoin = PenLineJoin.Round;
svps.Add(svp);
return svps;
}
The gas layer is the brightest, followed by a step down to the glass layer, and finally, there is a diffuse glow. A special thanks should go to Guillaume Leparmentier for his project: Manipulating colors in .NET - Part 1. Some of his color manipulation code saved me considerable time. Neon is lightly saturated, and as we go from the column to the glow, the hue and saturation are unchanged, but the brightness varies. With very little effort, we can make things that look like this:
Comic Factory
I discovered this style on the internet and thought that it would be interesting. The concept is simple.
- A three color gradient
- A dark border around the gradient
- An additional border for the bottom gradient color
- A final border for the middle gradient color
The code to create this effect is straightforward:
public ShapeVisualPropsCollection CreateLayerCollection()
{
ShapeVisualPropsCollection svps = new ShapeVisualPropsCollection();
ShapeVisualProps svp;
svp = new ShapeVisualProps();
svp.Stroke = new SolidColorBrush(MiddleColor);
svp.StrokeThickness = 11;
svps.Add(svp);
svp = new ShapeVisualProps();
svp.Stroke = new SolidColorBrush(BottomColor);
svp.StrokeThickness = 7;
svps.Add(svp);
svp = new ShapeVisualProps();
svp.Stroke = new SolidColorBrush(OutlineColor);
svp.StrokeThickness = 3 ;
svps.Add(svp);
svp = new ShapeVisualProps();
LinearGradientBrush lgb = new LinearGradientBrush();
lgb.GradientStops.Add(new GradientStop(BottomColor, 0));
lgb.GradientStops.Add(new GradientStop(MiddleColor, .5));
lgb.GradientStops.Add(new GradientStop(TopColor, 1));
lgb.StartPoint = new System.Windows.Point(0, 1);
lgb.EndPoint = new System.Windows.Point(0, 0);
svp.Fill = lgb;
svps.Add(svp);
return svps;
}
Even though it was intended for text, I was surprised how well it came out with shapes.
Special thanks to Microsoft for their ColorPicker Custom Control Sample which was used to select colors for the comic factory.
Putting Them Together
The StackedGeometry
class manages the IEffectFactory
and the IGeometrySource
. The main method of this class is the PrepareShapeStack
method.
private void PrepareShapeStack()
{
if (GeometrySource != null )
{
if (!GeometrySource.IsFrozen | GeometrySource.DesignMode)
{
mGeometry = GeometrySource.CreateGeometry();
GeometrySource.Geometry = mGeometry;
}
else
{
mGeometry = GeometrySource.Geometry;
}
}
if (EffectFactory != null)
{
if (!EffectFactory.IsFrozen | EffectFactory.DesignMode)
{
mShapeVisualPropsCollection =
EffectFactory.CreateLayerCollection();
EffectFactory.ShapeVisualProps = mShapeVisualPropsCollection;
}
else
{
mShapeVisualPropsCollection = EffectFactory.ShapeVisualProps;
}
}
if (mGeometry != null && mShapeVisualPropsCollection != null)
{
foreach (ShapeVisualProps layer in mShapeVisualPropsCollection)
{
Path ext = new Path() { Data = mGeometry };
layer.Apply(ext);
this.Children.Add(ext);
}
}
}
This is where the look is put together. The DesignMode
properties are used by the application to make it so that we can both have the application frozen and also generate the properties so as to generate the desired XAML. The StackedGeometryBrushFactory
exposes a VisualBrush
whose visual is the StackedGeometry
.
Plug-in Architecture
The solution has eight assemblies in it: StackedGeometryDesign
, StackedGeometry
, GeometrySources
, EffectFactories
, PolygonImageLib
, PointTransformations
, ColorPicker
, and DevcorpColor
. A dependency diagram might be helpful. (Arrays point in the direction of dependency.)
Everything except the support libraries depend on StackedGeometry
, which contains all of the interfaces used as well as the custom attributes. Neither the StackedGeometryDesign
nor StackedGeometryAssemblies
know anything about the other assemblies. They depend on attributes and interfaces defined in StackedGeometry
. The following attributes are defined:
ContainsEffectFactoriesAttribute
- Used to flag an assembly as containing the IEffectFactory
types.
ContainsGeometrySourcesAttribute
- Used to flag an assembly as containing the IGeometrySource
types.
EffectFactoryAttribute
- Used to flag a class as being an IEffectFactory
.
GeometrySourceAttribute
- Used to flag a class as being an IGeometrySource
.
CustomXamlAttribute
- Used to flag a class or interface as having customized XAML.
XamlIgnoreAttribute
- Used to mark a property as either always being ignored for XAML purposes or conditionally ignored for XAML purposes.
Loading
The StackedGeometryDesign application searches its current directory and subdirectories for class libraries, then checks if they have the ContainsEffectFactoriesAttribute
or ContainsGeometrySourcesAttribute
attributes. If so, the types are checked for the EffectFactoryAttribute
and GeometrySourceAttribute
attributes. The found classes are then instantiated. The code was written and then rewritten in a more optimized way. Originally, the code looked something like this:
string[] files = Directory.GetFiles(currentAssemblyDirectoryName,
"*.dll", SearchOption.AllDirectories);
foreach (string str in files)
{
Assembly asm = Assembly.LoadFile(str);
ContainsGeometrySourcesAttribute containsGeom =
(ContainsGeometrySourcesAttribute)Attribute.GetCustomAttribute(asm,
typeof(ContainsGeometrySourcesAttribute));
f (containsGeom != null) {
foreach (Type t in asm.GetTypes())
{
GeometrySourceAttribute gsa = (GeometrySourceAttribute)
Attribute.GetCustomAttribute(t, typeof(GeometrySourceAttribute));
if (gsa != null)
{
IGeometrySource gs =
(IGeometrySource)Activator.CreateInstance(t, null);
geometrySources.Add(gs);
}
}
}
This method works, but is not so efficient when assemblies have both attributes. A more general solution was made for such cases, consisting of AttributeReflectionItem
s and an AttributeReflector
. The AttributeReflectionItem
just holds the three pieces of data for the attribute search.
class AttributeReflectionItem
{
public Type AssemblyAttribute { get; set; }
public Type ClassAttribute { get; set; }
public IList List { get; set; }
}
The AttributeReflector
goes through the assemblies one by one and fills the appropriate lists.
public void Reflect(Assembly assembly)
{
List<attributereflectionitem> assemblyReflectionItems =
new List<attributereflectionitem>();
foreach (AttributeReflectionItem ri in mReflectionItems)
{
if (Attribute.GetCustomAttribute(assembly,
ri.AssemblyAttribute) != null)
{
assemblyReflectionItems.Add(ri);
}
}
if (mReflectionItems.Count > 0)
{
foreach (Type t in assembly.GetTypes()) {
foreach (AttributeReflectionItem ri in mReflectionItems)
{
if (Attribute.GetCustomAttribute(t, ri.ClassAttribute) != null)
{
ri.List.Add(Activator.CreateInstance(t, null));
}
}
}
}
}
Since the Reflection calls are kept to a minimum, this one will be a bit more efficient at the cost of greater abstraction. Also, it becomes trivial to add additional search attributes.
ar.ReflectionItems.Add(new AttributeReflectionItem()
{
AssemblyAttribute = typeof(ContainsGeometrySourcesAttribute),
ClassAttribute = typeof(GeometrySourceAttribute),
List = geometrySources
});
Interacting
The classes that have been created interact either through their interfaces or through the UI. WPF provides multiple ways for classes to be visible in the user interface. We can use DataTemplate
s or make our classes Custom Controls. I chose something a little different. The classes have DataTemplate
s as properties.
public DataTemplate DataTemplate
{
get
{
ResourceDictionary rd = new ResourceDictionary();
rd.Source = new
Uri("EffectFactories;component/NeonResources.xaml",
UriKind.Relative);
DataTemplate dt = (DataTemplate)rd["NeonFactoryTemplate"];
return dt;
}
}
These DataTemplate
s are loaded from resource dictionaries in the same assemblies as the classes. The classes can then be placed in ContentControl
s with the ContentTemplate
being set to the DataTemplate
property of the IEffectFactory
or IGeometrySource
.
<ContentControl Name="cEffects" VerticalAlignment="Top"
Content="{Binding Source ={
StaticResource StackedGeometry} ,
Path=EffectFactory}"
ContentTemplate="{Binding Source ={
StaticResource StackedGeometry} ,
Path=EffectFactory.DataTemplate }"/>
This way, the hosting application does not need to know anything about the assembly that contains the classes and resource dictionaries. While under construction, these DataTemplate
s can be kept in the main assembly for easy editing and then moved later.
XAML Documentation
The StackedGeometryDesign application is meant to be a practical one. It needs to be easy for us to place these StackedGeometryBrushFactory
objects in their applications. To that end, the application produces the XAML for the StackedGeometryBrushFactory
. One can get XAML very easily by calling:
string xamlString = XamlWriter.Save(mUIElement);
However, the produced XAML is verbose to the point of being unusable. As it seems to be a Windows tradition to control serialization with attributes, I decided to go that route. The XamlGenerator
class does most of the work. The basic strategy is as follows:
- Create the XAML using the
XamlWriter
.
- Go through the object hierarchy recursively, looking for objects with the
CustomXamlAttribute
.
- Within these objects, check for the
XamlIgnoreAttribute
and possibly remove that object's serialization from the XAML.
Caching
Caching is remarkably effective at improving the performance of Reflection-based code. The objects are checked for which of their properties may need to be removed from the XAML.
Dictionary<type,> mSpecialInfoCache = new Dictionary<type,>(); private List<propertyinfo> SpecialXamlInfos(Type t)
{
if (mSpecialInfoCache.ContainsKey(t))
{
return mSpecialInfoCache[t];
}
else
{
PropertyInfo[] infos =t.GetProperties(BindingFlags.Public |
BindingFlags.Instance);
List<propertyinfo> specialInfos = new List<propertyinfo>();
foreach (PropertyInfo pi in infos)
{
if (HasCustomXaml(pi.PropertyType) | GetXamlIgnoreStatus(pi)!=
eXamlIgnoreStatus.noAttribute)
{
specialInfos.Add(pi);
}
}
mSpecialInfoCache.Add(t, specialInfos);
return specialInfos;
}
}
This code runs at about one thousand times faster on my machine after the first time through. Part of the reason is that controls have 70 plus dependency properties, but even so, this is an amazing difference. Code like the following is only accelerated by a factor of thirty:
Dictionary<type,> mHasCustomXaml = new Dictionary<type,>();private bool HasCustomXaml(Type t )
{
if (mHasCustomXaml.ContainsKey(t))
{
return mHasCustomXaml[t];
}
else
{
CustomXamlAttribute cxa = (
CustomXamlAttribute)Attribute.GetCustomAttribute(t,
typeof(CustomXamlAttribute));
mHasCustomXaml.Add(t, cxa != null);
return (cxa != null);
}
}
The Type
and PropertyInfo
classes are globally unique, at least within an AppDomain, so there should be no difficulty in using them for dictionary keys.
Generation Code
The GenerateXaml
method of the XamlGenerator
does all the heavy lifting for XAML creation. There are a few things we need to keep in mind concerning the XamlWriter
. First, it will place all of the potentially needed namespaces in the first element. There is nothing wrong with this, but it means that the string representing a sub-element of an object is not, by default, the same as what the XamlWriter
would produce from that element. Secondly, elements can be represented in three distinct ways. The first two are the standard ones from XML. The property can be represented as either an attribute or a sub-element. Those can be easily extracted via Regular Expressions. However, objects can have the ContentPropertyAttribute
. This makes them appear unlabeled and thus more difficult to extract. We need to create the XAML for the object, remove the namespaces, and then find it in the string to remove or customize. Finally, in this application, the Geometry
and ShapeVisualProps
only need to be written out to XAML if the IsFrozen
property is true
. If you are interested, please look at the code. The method is long and far from beautiful, but then again, some of the code the framework uses in this case is also challenging, System.Windows.Markup.Primitives.MarkupWriter.WriteItem
.
Conclusion
Thanks for your patience in reading all of this. What began as a simple Neon star grew to something completely different than originally envisioned. Making this easily extensible became a priority as I intend to create some other geometries in the near future and do not want to worry about integration. In particular, a PolyArc and some Fractal Geometries are on the way. As soon as I figure out a way to make a sufficiently pleasant Light Silver effect, I will see how much of this can go over to Silverlight, so stay tuned.
Updates
- 7/15/2010- Some optimization was made to the demo application.