CSharpGL(45)自制控件的思路
本文介绍CSharpGL实现自制控件的方法。
所谓自制控件,就是用纯OpenGL模仿WinForm里的Button、Label、TextBox、CheckBox等控件,支持布局、修改大小和文字等功能。
如上图所示,左下角就是一个显示二维图片的类似PictureBox的控件,我称之为CtrlImage。(所有的CSharpGL自制控件类型,都继承自GLControl,都添加前缀Ctrl)CtrlImage上方分别是一个CtrlButton和一个CtrlLabel。
下载
CSharpGL已在GitHub开源,欢迎对OpenGL有兴趣的同学加入(https://github.com/bitzhuwei/CSharpGL)
控件是什么?
一个控件,最基本的属性包括这几条:隶属关系(Parent、Children),布局属性(Location、Size、Anchor),渲染(Initialize、Render、BackgroundColor等),事件(Click、Resize等)。
隶属关系
WinForm里的控件们构成了一个树形关系,CSharpGL也是这样。有了这样的隶属关系,就可以以相对于Parent的位置来记录自己的位置。
而且,当我做好了CtrlLabel,就可以直接放到CtrlButton.Children里,于是CtrlButton上显示文字的功能就瞬间实现了(当然还要设置一下文字位置,但工作量已经可以忽略不计了)。
1 using System; 2 3 namespace CSharpGL 4 { 5 /// <summary> 6 /// Control(widget) in OpenGL window. 7 /// </summary> 8 public abstract partial class GLControl 9 { 10 internal GLControl parent; 11 [Description("Parent control. This node inherits parent's layout properties.")] 12 public GLControl Parent 13 { 14 get { return this.parent; } 15 set 16 { 17 GLControl old = this.parent; 18 if (old != value) 19 { 20 this.parent = value; 21 22 if (value == null) // parent != null 23 { 24 old.Children.Remove(this); 25 } 26 else // value != null && parent == null 27 { 28 value.Children.Add(this); 29 } 30 } 31 } 32 } 33 34 [Description("Children Nodes. Inherits this node's IWorldSpace properties.")] 35 public GLControlChildren Children { get; private set; } 36 37 public GLControl(GUIAnchorStyles anchor) 38 { 39 this.Children = new GLControlChildren(this); 40 41 this.Anchor = anchor; 42 } 43 } 44 }
布局属性
首先要有Location和Size。然后,在Parent的Size改变时,自己要相应的改变Location和Size,那么就需要Anchor来指定“是不是维持与某一边的距离不变”。
如何计算自己更新后的Location和Size?这是个简单的算法问题。
1 using System; 2 3 namespace CSharpGL 4 { 5 public partial class GLControl 6 { 7 /// <summary> 8 /// 获取或设置控件绑定到的容器的边缘并确定控件如何随其父级一起调整大小。 9 /// </summary> 10 public GUIAnchorStyles Anchor { get; set; } 11 12 private int x; 13 private int y; 14 15 /// <summary> 16 /// 相对于Parent左下角的位置(Left Down location) 17 /// </summary> 18 public GUIPoint Location 19 { 20 get { return new GUIPoint(x, y); } 21 set { this.x = value.X; this.y = value.Y; } 22 } 23 24 public GUISize Size 25 { 26 get { return new GUISize(width, height); } 27 set { this.width = value.Width; this.height = value.Height; } 28 } 29 30 private int width; 31 private int height; 32 33 /// <summary> 34 /// 上次更新之后,parent的Width属性值。 35 /// </summary> 36 private int parentLastWidth; 37 /// <summary> 38 /// 上次更新之后,parent的Height属性值。 39 /// </summary> 40 private int parentLastHeight; 41 42 /// <summary> 43 /// 44 /// </summary> 45 protected int absLeft; 46 /// <summary> 47 /// 48 /// </summary> 49 protected int absBottom; 50 51 /// <summary> 52 /// Layout for this control. 53 /// </summary> 54 public virtual void UpdateAbsoluteLocation() 55 { 56 GLControl parent = this.Parent; 57 if (parent != null) 58 { 59 this.absLeft = parent.absLeft + this.x; 60 this.absBottom = parent.absBottom + this.y; 61 } 62 else 63 { 64 this.absLeft = this.x; 65 this.absBottom = this.y; 66 } 67 } 68 69 /// <summary> 70 /// layout controls in OpenGL canvas.( 71 /// Updates absolute and relative (location and size) of specified node and its children nodes. 72 /// <para>This coordinate system is shown as below.</para> 73 /// <para> /\ y</para> 74 /// <para> |</para> 75 /// <para> |</para> 76 /// <para> |</para> 77 /// <para> |</para> 78 /// <para> |</para> 79 /// <para> |----------------->x</para> 80 /// <para>(0, 0)</para> 81 /// </summary> 82 /// <param name="node"></param> 83 public static void Layout(GLControl node) 84 { 85 if (node == null) { return; } 86 87 var parent = node.Parent; 88 if (parent != null) 89 { 90 NonRootNodeLayout(node, parent); 91 } 92 93 node.UpdateAbsoluteLocation(); 94 95 foreach (var item in node.Children) 96 { 97 GLControl.Layout(item); 98 } 99 100 if (parent != null) 101 { 102 node.parentLastWidth = parent.width; 103 node.parentLastHeight = parent.height; 104 } 105 } 106 107 private const GUIAnchorStyles leftRightAnchor = (GUIAnchorStyles.Left | GUIAnchorStyles.Right); 108 private const GUIAnchorStyles topBottomAnchor = (GUIAnchorStyles.Top | GUIAnchorStyles.Bottom); 109 110 /// <summary> 111 /// Updates <paramref name="currentNode"/>'s location and size according to its state and parent's information. 112 /// </summary> 113 /// <param name="currentNode"></param> 114 /// <param name="parent"></param> 115 private static void NonRootNodeLayout(GLControl currentNode, GLControl parent) 116 { 117 int x, y, width, height; 118 if ((currentNode.Anchor & leftRightAnchor) == leftRightAnchor) 119 { 120 width = parent.width - currentNode.parentLastWidth + currentNode.width; 121 if (width < 0) { width = 0; } 122 } 123 else 124 { 125 width = currentNode.width; 126 } 127 128 if ((currentNode.Anchor & topBottomAnchor) == topBottomAnchor) 129 { 130 height = parent.height - currentNode.parentLastHeight + currentNode.height; 131 if (height < 0) { height = 0; } 132 } 133 else 134 { 135 height = currentNode.height; 136 } 137 138 if ((currentNode.Anchor & leftRightAnchor) == GUIAnchorStyles.None) 139 { 140 int diff = parent.width - currentNode.parentLastWidth; 141 x = currentNode.x + diff / 2; 142 } 143 else if ((currentNode.Anchor & leftRightAnchor) == GUIAnchorStyles.Left) 144 { 145 x = currentNode.x; 146 } 147 else if ((currentNode.Anchor & leftRightAnchor) == GUIAnchorStyles.Right) 148 { 149 int diff = parent.width - currentNode.parentLastWidth; 150 x = currentNode.x + diff; 151 } 152 else if ((currentNode.Anchor & leftRightAnchor) == leftRightAnchor) 153 { 154 x = currentNode.x; 155 } 156 else 157 { throw new Exception(string.Format("Not expected Anchor:[{0}]!", currentNode.Anchor)); } 158 159 if ((currentNode.Anchor & topBottomAnchor) == GUIAnchorStyles.None) 160 { 161 int diff = parent.height - currentNode.parentLastHeight; 162 y = currentNode.y + diff / 2; 163 } 164 else if ((currentNode.Anchor & topBottomAnchor) == GUIAnchorStyles.Bottom) 165 { 166 y = currentNode.y; 167 } 168 else if ((currentNode.Anchor & topBottomAnchor) == GUIAnchorStyles.Top) 169 { 170 int diff = parent.height - currentNode.parentLastHeight; 171 y = currentNode.y + diff; 172 } 173 else if ((currentNode.Anchor & topBottomAnchor) == topBottomAnchor) 174 { 175 y = currentNode.y; 176 } 177 else 178 { throw new Exception(string.Format("Not expected Anchor:[{0}]!", currentNode.Anchor)); } 179 180 currentNode.x = x; currentNode.y = y; 181 currentNode.width = width; currentNode.height = height; 182 } 183 }}
为了降低对.net库的依赖,我根据.net自带的Size、Point、Anchor等基础的数据结构,复制了GUISize、GUIPoint、GUIAnchor……
渲染
用OpenGL渲染控件,实际上是如何在固定位置以固定大小画图的问题。
在OpenGL的渲染流水线上,描述顶点位置的坐标,依次要经过object space, world space, view/camera space, clip space, normalized device space, Screen/window space这几个状态。下表列出了各个状态的特点。
Space | Coordinate | feature |
object | (x, y, z, 1) | 从模型中读取的原始位置(x,y,z),可在shader中编辑 |
world | (x, y, z, w) | 可在shader中编辑 |
view/camera | (x, y, z, w) | 可在shader中编辑 |
clip | (x, y, z, w) | vertex shader中,赋给gl_Position的值 |
normalized device | (x, y, z, 1) | 上一步的(x, y, z, w)同时除以w。OpenGL自动完成。x, y, z的绝对值小于1时,此顶点在窗口可见范围内。即可见范围为[-1, -1, -1]到[1, 1, 1]。 |
screen/window | glViewport(x, y, width, height); glDepthRange(near, far) | OpenGL以窗口左下角为(0, 0)。 上一步的顶点为(-1, -1, z)时,screen上的顶点为(x, y)。 上一步的顶点为(1, 1, z)时,screen上的顶点为(x + width, y + height)。 |
根据上表来看,object space, world space, view space三步可以省略跳过,而normalized device space是无法跳过的,所以我们在shader中给控件指定的坐标,就应该在[-1,-1,-1]和[1,1,1]之间。然后通过glViewport(x, y, width, height);指定控件的位置(x, y)和大小(width, height)。
为了避免影响到控件范围外的东西,要启用GL_SCISSOR_TEST。
1 using System; 2 3 namespace CSharpGL 4 { 5 public abstract partial class GLControl 6 { 7 public virtual void RenderGUIBeforeChildren(GUIRenderEventArgs arg) 8 { 9 GL.Instance.Enable(GL.GL_SCISSOR_TEST); 10 GL.Instance.Scissor(this.absLeft, this.absBottom, this.width, this.height); 11 GL.Instance.Viewport(this.absLeft, this.absBottom, this.width, this.height); 12 13 if (this.RenderBackground) 14 { 15 vec4 color = this.BackgroundColor; 16 GL.Instance.ClearColor(color.x, color.y, color.z, color.w); 17 GL.Instance.Clear(GL.GL_COLOR_BUFFER_BIT); 18 } 19 } 20 } 21 }
事件
事件这个东西太复杂,我们来一点一点的说清楚其设计思路。
WinGLCanvas是一个WinForm控件,所有的OpenGL渲染的内容都在此显示。
当我的WinForm控件WinGLCanvas收到一个消息(以鼠标按下mouse down为例)时,他会遍历所有的GLControl,告诉他们“有mouse down消息来了”。每个控件都会调用自己关联的mouseDown事件(如果有的话)。
然而细想一下,只有鼠标所在位置的那个GLControl才应该响应mouse Down消息。所以,在WinGLCanvas遍历GLControl时,要分辨出哪个控件在mouse Down的位置,然后通知它;不通知其他控件。类似的,只有得到Focus的控件才会收到key down消息,从而调用自己的KeyDown事件。
绘制文字
做一个CtrlLabel,核心工作就是要在指定的位置绘制文字。
大致思路是这样的:
首先,做出这样的文字贴图。当要绘制的文字比较多的时候,就会出现不止一张贴图。这里为了便于演示,我故意把贴图尺寸设定得比较小,从而出现了第二张贴图;并且用金色边框把贴图的边沿描绘出来,用红色或绿色边框把各个Glyph的位置和大小都表示出来。
然后,用OpenGL创建一个GL_TEXTURE_2D_ARRAY的纹理Texture,把上面这些贴图都放进去。
最后,用一个Dictionary<char, GlyphInfo>字典记录每个字符的字形信息(在Texture中的位置、大小)。
思路就这三步,详情直接看代码比较好(GlyphMap)。
需要注意的是,之前规定了“控件的顶点范围应该在[-1,-1,-1]和[1,1,1]之间”。所以在给CtrlLabel设定好各个字形的位置后,还要按比例缩放到[-1,-1,-1]和[1,1,1]中,并且调整CtrlLabel的Width属性。这样才能正常显示文字。这其实就是WinForm里的Label的AutoSize=true。
总结
看代码看代码看代码。这里只有思路。