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。
總結
看代碼看代碼看代碼。這里只有思路。
文章列表