Unity:武器部件指示 / 高级自定义UI组件开发 / Unity Job加速
在本文中,我将使用UI Toolkit
实现自定义的插槽、指示器
等部件UI。
文章的子章节大致以由简至难排布。
与以往相同,在开始之前,我先给出本文所实现的效果。
-
高性能指示器
-
可控的过渡动画(指示线的逻辑)
-
高级属性面板(完全由自定义组件组成)
5. 属性来源指示器
ℹ️信息
- 此片文章着重讲解UI的实现,武器属性遗传系统非文章重点。
- 在每一节标题我会给出关键的注意事项,这样即使你对此效果不感兴趣,也应该能够挑选有价值的部分细读。
文章目录
- 部件指示器
- 指示线组件 IndicatorElement
- 属性设计
- 绘制自定义2D形状
- 插槽组件 SocketElement
- 使用代码生成结构与设置style
- 属性设计
- experimental.animation实现可控动画插值
- 实现无限滚动的背景动画
- 完善UxmlTraits与自绘逻辑
- 鼠标响应事件
- 动态字符标签 DynamicLabelElement
- 属性设计
- 总体逻辑
- 部件指示器UI更新逻辑
- 指示线管理器 IndicatorManager
- 更新指示线让其总指向场景物体
- 更新插槽让其沿着椭圆排布
- DebugDraw绘制目标椭圆
- 排布`插槽`到椭圆
- 力导向分散算法
- 使用力导向算法
- 预排布
- 使用Unity Job加速运算
- Job管理器:MapScreenToEllipseJob
- 计算Angle并排列顺序Job
- 预排布Job
- 力导向Job
- 整合Job,分配任务、获取结果
- 应用Job进行计算
- 效率对比
- 更新椭圆匹配物体形状
- 获取Renderer最小屏幕矩形
- 取得Rect[]最小矩形
- 同步椭圆到矩形
- 高级属性面板
- AttributeElement
- InheritedAttributeElement
- 属性来源指示器
- 属性指示器
- 绘制网格形状
- 更新网格形状
- AttributeIndicatorManager
- 生成屏幕上物体的独立渲染图
- 对图片实时施加Shader效果
- 自动化AttributeIndicator
- 使用方式
- 关于武器属性遗传算法
部件指示器
ℹ️实现两个组件:
指示线
、插槽
。
指示线
组件,根据起点与末点,在屏幕上绘制出直线。插槽
组件,可进行点击交互,处理鼠标相关的事件。ℹ️实现一种Label平替元素:
DynamicLabelElement
:实现逐字拼写、乱码特效。
指示线组件 IndicatorElement
ℹ️此节将会对实现自定义组件的基础进行详细介绍。
ℹ️在先前的文章中,我有几篇UI Toolkit的自定义组件的基础教程。实现自定义UI时买必须确保:
- 至少确保其继承自
VisualElement
或其子类。- 需要实现类型为
UxmlFactory<你的组件名>
的Factory。- 如果你希望能够在UI Builder中添加可调整的自定义的属性,需要实现
UxmlTraits
,并在将Factory的类型改为UxmlFactory<你的组件名,你的UxmlTraits>
。
⚠️注意:
UxmlFactory
、UxmlTraits
用于与UI Builder桥接。因此若完全不实现此两者,则无法在UI Builder中使用,但依然可以在代码中进行构造使用。
无论如何总是推荐至少实现UxmlFactory,这是官方的约定俗成。
指示线IndicatorElement
继承自VisualElement
。
指示线IndicatorElement
主要由进行代码控制生成,可以不用实现UxmlTraits
。但为方便直接在UI Builder中查看效果,此处依然实现UxmlTraits
。
IndicatorElement
的代码框架如下:
public class IndicatorElementUxmlFactory : UxmlFactory<IndicatorElement,IndicatorElementUxmlTraits> {}
public class IndicatorElementUxmlTraits : VisualElement.UxmlTraits
{
...元素向UI Builder暴露的属性...
}
public class IndicatorElement : VisualElement
{
...元素实际逻辑
}
IndicatorElement 指示线
将作为容器,承载关于此指示点的说明性元素。例如:
需要指示枪管时,指示线
的一端将指向场景中的枪管位置。而另一端将显示一个自定义的元素(例如Label
),此Label
将作为指示线
的子元素。
属性设计
为了描述线段的信息,我决定使用一个额外的Vector2
表示终点,元素本身的位置表示起点。
其属性如下:
变量名 | 类型 | 作用 |
---|---|---|
targetPoint | Vector | 记录指示线的终点 |
lineColor | Color | 表示线的颜色 |
lineWidth | float | 表示线的宽度 |
blockSize | float | 表示插槽的大小 |
为了能暴露上面的属性,需要在UxmlTraits
中添加对应的UxmlAttributeDescription
:
public class IndicatorElementUxmlTraits : VisualElement.UxmlTraits
{
public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc)
{
base.Init(ve, bag, cc);
var line = ((IndicatorElement)ve);
line.TargetPointX = _targetPointX.GetValueFromBag(bag, cc);
line.TargetPointY = _targetPointY.GetValueFromBag(bag, cc);
line.LineWidth = _lineWidth.GetValueFromBag(bag, cc);
line.BlockSize = _blockSize.GetValueFromBag(bag, cc);
line.LineColor = _lineColor.GetValueFromBag(bag, cc);
}
private readonly UxmlFloatAttributeDescription _lineWidth = new()
{
name = "line-width",
defaultValue = 1f
};
private readonly UxmlFloatAttributeDescription _targetPointX = new()
{
name = "target-point-x",
defaultValue = 0f
};
private readonly UxmlFloatAttributeDescription _targetPointY = new()
{
name = "target-point-y",
defaultValue = 0f
};
private readonly UxmlFloatAttributeDescription _blockSize = new ()
{
name = "block-size",
defaultValue = 20f
};
private readonly UxmlColorAttributeDescription _lineColor = new()
{
name = "line-color",
defaultValue = Color.black
};
}
⚠️注意:
UxmlAttributeDescription
的name
属性,必须采用连字符命名法(单词之间用连字符 - 分隔)。
UxmlAttributeDescription
的name
属性,必须与C#属性名相似(如test-p
与TestP
、testP
、testp
是相似的),否则UI Builder无法正确的读取内容。
UxmlAttributeDescription
不存在Vector2
的支持,因此需要将TargetPoint
拆分为两个float
。
IndicatorElement
类的C#属性
代码为:
public class IndicatorElement : VisualElement{
// 暴露的C#属性
public float TargetPointX{
get => _targetPoint.x;
set => _targetPoint.x = value;
}
public float TargetPointY{
get => _targetPoint.y;
set => _targetPoint.y = value;
}
public Vector2 TargetPoint {
get => _targetPoint;
set{
_targetPoint = value;
MarkDirtyRepaint(); // 标记为需要重绘
}
}
public float LineWidth{
get => _lineWidth;
set{
_lineWidth = value;
MarkDirtyRepaint(); // 标记为需要重绘
}
}
public float BlockSize{
get => _blockSize;
set => _blockSize = f;
}
public Color LineColor{
get => _lineColor;
set{
_lineColor = value;
MarkDirtyRepaint(); // 标记为需要重绘
}
}
//便捷属性,快速设置起点位置
public Vector2 Position{
get => new(style.left.value.value, style.top.value.value);
set{
style.left = value.x;
style.top = value.y;
}
}
private Vector2 _targetPoint;
private Color _lineColor;
private float _blockSize;
private float _lineWidth;
public IndicatorElement(){
//构造函数
}
}
⚠️注意:
所有被UxmlTraits
中UxmlAttributeDescription
的name
属性,不能与类内任何类型冲突的C#属性名相似,例如:
- 在
UxmlTraits
存在一个UxmlFloatAttributeDescription
的name
为test-p
的属性,则IndicatorElement
类内不允许任何类型与float
冲突、且名称与TestP
、testP
、testp
相同的C#属性
,即使此C#属性
与UxmlTraits
的属性完全无关、对外不可见。这个“特性”十分无厘头,或许可归结为Bug。
绘制自定义2D形状
通过订阅generateVisualContent
委托,实现绘制自定义2D形状。
借助上文规定的属性,绘制出预计形状。
//位于IndicatorElement类中
// ...省略其他代码
public IndicatorElement()
{
style.position = UnityEngine.UIElements.Position.Absolute;
style.width = 0;
style.height = 0;
//订阅委托
generateVisualContent += DrawIndicatorLine;
}
private void DrawIndicatorLine(MeshGenerationContext mgc)
{
var painter = mgc.painter2D;
painter.lineWidth = _lineWidth;
painter.strokeColor = _lineColor;
painter.lineCap = LineCap.Round;
var element = mgc.visualElement;
var blockCenter = element.WorldToLocal(_targetPoint);
// 绘制一条线
painter.BeginPath();
painter.MoveTo(element.contentRect.center);
painter.LineTo(blockCenter);
// 绘制线段时使用渐变的颜色
painter.strokeGradient = new Gradient()
{
colorKeys = new GradientColorKey[]
{
new() { color = _lineColor, time = 0.0f },
new() { color = _lineColor, time = 1.0f }
},
alphaKeys = new GradientAlphaKey[]
{
new (){alpha = 0.0f, time = 0.0f},
new (){alpha = 1.0f, time = 1.0f}
}
};
// 绘制线段
painter.Stroke();
// 接下来无需渐变
painter.strokeGradient = null;
//在targetPoint上绘制一个方块
painter.BeginPath();
painter.MoveTo(blockCenter - new Vector2(_blockSize,_blockSize));
painter.LineTo(blockCenter + new Vector2(_blockSize,-_blockSize));
painter.LineTo(blockCenter + new Vector2(_blockSize,_blockSize));
painter.LineTo(blockCenter + new Vector2(-_blockSize,_blockSize));
painter.LineTo(blockCenter - new Vector2(_blockSize,_blockSize));
painter.ClosePath();
// 绘制线段
painter.Stroke();
}
ℹ️ 在
generateVisualContent
的委托事件中,始终将VisualElement
视为“只读
”,并在不引起副作用的情况进行绘制相关的处理。在此事件期间对VisualElement
所做的更改可能会丢失或至少是滞后出现。
ℹ️ 仅当Unity检测到VisualElement
需要重新生成其可视内容时,Unity才调用generateVisualContent
委托。因此当自定义属性的数据改变时,画面可能无法及时更新,使用MarkDirtyRepaint()
方法,可以强制触发重绘。
generateVisualContent
中进行的任何绘制,其坐标始终基于委托所属元素的局部坐标系。 因此在绘制终点时,需要使用WorldToLocal
方法,将世界坐标转换回IndicatorElement
的本地坐标系中:
var element = mgc.visualElement;
var blockCenter = element.WorldToLocal(_targetPoint);
其中element
是本generateVisualContent
委托的所属VE,在这里,element
是IndicatorElement
实例。
ℹ️
UI Toolkit
的世界坐标系以左上角为原点
。
此时可以直接在UI Builder中查看效果,通过调整属性检查是否正常工作。
⚠️ 注意:
由于使用了世界坐标,而UI Builder
本身以UI Toolkit
构建,因此绘制的内容会突破UI Builder
的范围。
插槽组件 SocketElement
ℹ️此节将着重对使用代码生成元素结构、控制元素动态表现的技巧进行介绍。
结构 | 效果 |
---|---|
插槽 SocketElement
继承自VisualElement
。
插槽 SocketElement
主要由进行代码控制生成,可以不用实现UxmlTraits
。但为方便直接在UI Builder中查看效果,此处依然实现UxmlTraits
。
SocketElement
代码框架如下:
public class SocketElementUxmlFactory : UxmlFactory<SocketElement, SocketElementUxmlTraits> {}
public class SocketElementUxmlTraits : VisualElement.UxmlTraits{
...
}
public class SocketElement : VisualElement{
...
}
使用代码生成结构与设置style
与指示线组件不同,插槽组件的布局结构更复杂,因此先着手生成结构,其后再处理属性。其结构如下:
content(VisualElement
)
├─socket-area(Button
):主体按钮,用于接收点击事件
│ ├─stripe-background(StripeBackgroundElement
):插槽图标的背景(显示一个循环滚动的背景)
│ │ └─socket-image(Image
):插槽图标
│ └─content-image(Image
):安装的组件图标
├─label-background(VisualElement
):标签背景
│ └─socket-label (Label
):标题标签
└─second-label-background(VisualElement
):次标签背景
└─second-label(Label
):次标签
StripeBackgroundElement
是自定义的组件,用于显示无限上下滚动的条纹,将会在后文进行实现。Image
是内置的图片组件,用于显示图标。插槽图标
与安装的组件图标
只能显示其中一个,即stripe-background
与content-image
互斥。
通过代码直接在创建组件时分配style属性,若你有HTML编程基础,则应该很容易理清楚属性的意义。生成上述结构树的完整代码如下:
public class SocketElement : VisualElement{
private readonly VisualElement _content;
private readonly Button _button;
private readonly Image _contentImage;
private readonly Image _socketImage;
private readonly VectorImageElement _stripeBackground;
private readonly Label _label;
private readonly VisualElement _labelBackground;
private readonly Label _secondLabel;
private readonly VisualElement _secondLabelBackground;
// 绑定按钮的点击事件
public void BindClickEvent(Action<> clickEvent){
_button.clicked += clickEvent;
}
public SocketElement() {
_content = new VisualElement {
style= {
position = Position.Absolute,
width = 100, height = 100,
translate = new Translate(Length.Percent(-50f),Length.Percent(-50f)),
},
name = "socket-content"
};
_contentImage = new Image() {
style = {
position = Position.Absolute,
top = 0, left = 0, bottom = 0, right= 0,
marginBottom = 0, marginLeft = 0, marginRight = 0, marginTop = 0,
paddingBottom = 0, paddingTop = 0, paddingLeft = 0, paddingRight = 0
},
name = "content-image",
};
_labelBackground = new VisualElement()
{
style = {
position = Position.Absolute,
bottom = Length.Percent(100f),
marginBottom = 0, marginLeft = 0, marginRight = 0, marginTop = 0,
paddingBottom = 0, paddingTop = 0
},
name = "socket-name-background",
};
_secondLabelBackground = new VisualElement() {
style = {
position = Position.Absolute,
top = Length.Percent(100f),
marginBottom = 0, marginLeft = 0, marginRight = 0, marginTop = 0,
paddingBottom = 0, paddingTop = 0,
display = DisplayStyle.None
},
name = "second-name-background",
};
_label = new Label("") {
style = {
marginBottom = 0, marginLeft = 0, marginRight = 0, marginTop = 0,
paddingBottom = 0, paddingTop = 0,
unityFontStyleAndWeight = FontStyle.Bold
},
name = "socket-name",
};
_secondLabel = new Label("") {
style = {
marginBottom = 0, marginLeft = 0, marginRight = 0, marginTop = 0,
paddingBottom = 0, paddingTop = 0,
unityFontStyleAndWeight = FontStyle.Bold
},
name = "second-name",
};
_button = new Button {
style = {
position = Position.Absolute,
top = 0, left = 0, right = 0, bottom = 0,
marginBottom = 0, marginLeft = 0, marginRight = 0, marginTop = 0,
borderTopWidth = 0, borderRightWidth = 0, borderBottomWidth = 0, borderLeftWidth = 0,
backgroundColor = Color.clear
},
name = "socket-area"
};
_stripeBackground = new VectorImageElement {
name = "stripe-background"
};
_socketImage = new Image {
style = {
position = Position.Absolute,
top = 0, left = 0, right = 0, bottom = 0
},
name = "socket-image"
};
_stripeBackground.Add(_socketImage);
_button.Add(_stripeBackground);
_button.Add(_contentImage);
_content.Add(_button);
_labelBackground.Add(_label);
_secondLabelBackground.Add(_secondLabel);
_content.Add(_labelBackground);
_content.Add(_secondLabelBackground);
Add(_content);
_content.generateVisualContent += DrawBorder;
}
private void DrawBorder(MeshGenerationContext mgc){
...绘制边框代码...
}
}
ℹ️注意:
- 使用代码设置style的百分比值时,须使用
Length.Percent()
。- style的类型与
C#、Unity基础类型
不同,通常其基础类型
都能够隐式
的转换为对应的Style类型
,例如:
float
可转换为StyleFloat
Color
可转换为StyleColor
而反向转换则行不通,需要使用value进行拆箱,例如:StyleFloat.value
->float
StyleColor.value
->Color
其中的VectorImageElement
类型为自定义元素,用于播放动画背景图案,下一节中我将实现它的代码,目前可改为VisualElement
类型。
推荐生成元素时同时指定name
属性,由此可更灵活的通过USS控制样式,以便支持未来可能需要的USS样式换肤。
属性设计
目前,插槽
包含如下几个属性:
变量名 | 类型 | 作用 |
---|---|---|
HideSocket | bool | 是否隐藏插槽 |
Opacity | float | 透明度 |
AnimOpacity | float | 透明度,设置此将会使用动画过渡 来设置透明度 |
AnimPosition | Vector | 位置,设置此将会使用动画过渡 来设置位置 |
SocketName | string | 插槽名称 |
SecondName | string | 次级名称 |
LabelColor | Color | 文本颜色 |
StripeBackgroundColor | Color | 背景条纹颜色 |
ContentSize | float | content的大小(宽高一比一) |
IsEmpty | bool | 是否是空,空则显示插槽图标,反之显示内容图标 |
ContentImage | Texture2D | 内容图标 |
SocketImage | Texture2D | 插槽图标 |
LineColor | Color | 线条颜色(如果有指示器则是指示器的数值) |
LineWidth | float | 线条宽度(如果有指示器则是指示器的数值) |
注意其中的LineColor
、LineWidth
、Opacity
、AnimOpacity
、AnimPosition
属性,如果父级为指示线
,则其将会与指示线
的数值双向同步
,否则将会使用默认值,因此我们需要检测插槽是否被安置在指示器上,通过监听AttachToPanelEvent
回调来判断是否被安插到了指示器上。
//声明一个私有变量_parent,指示器(如果没有指示器则为null)
private IndicatorElement _parent;
//创建一个工具函数RegisterCallbacks,在这里面进行注册回调。在构造函数中调用此函数
private void RegisterCallbacks(){
//监听DetachFromPanelEvent,当插槽被从面板上分离时,将_parent设置为null
RegisterCallback<DetachFromPanelEvent>(_ =>{
_parent = null;
});
//监听AttachToPanelEvent,当插槽被安插到面板上时,获取其父元素,若其父元素是IndicatorElement类型,则将其赋值给_parent
RegisterCallback<AttachToPanelEvent>(_ =>{
_parent = parent as IndicatorElement;
});
}
添加一个带参构造函数,提供一个指示线
元素类型的参数,用于允许在构造时直接指定父级:
public SocketElement(IndicatorElement parent):this()
{
_parent = parent;
//下文将出现_opacity 的定义,为节省篇幅此处直接使用
_opacity = _parent.style.opacity.value;
}
接下来通过对_parent
的判断,我们就可以实现插槽
与指示线
的属性联动了:
#region Properties
private Color _stripeBackgroundColor;
private Color _labelColor;
private float _contentSize;
private float _opacity = 1.0f;
private bool _isEmpty;
private bool _hideSocket;
private Color _lineColor = Color.white;
private float _lineWidth = 1;
//透明度,与指示线联动
public float Opacity{
get => _opacity;
set{
_opacity = value;
if (_parent != null)
_parent.style.opacity = value;
else
style.opacity = value;
}
}
//线条颜色,与指示线联动
public Color LineColor{
get => _parent?.LineColor ?? _lineColor;
set{
if (_parent != null)
_parent.LineColor = value;
else
_lineColor = value;
_content.MarkDirtyRepaint();
}
}
//线条宽度,与指示线联动
public float LineWidth{
get => _parent?.LineWidth ?? _lineWidth;
set{
if (_parent != null)
_parent.LineWidth = value;
else
_lineWidth = value;
_content.MarkDirtyRepaint();
}
}
public string SocketName{
get => _label.text;
set => _label.text= value;
}
public string SecondName{
get => _secondLabel.text;
set{
_secondLabel.text= value;
_secondLabelBackground.style.display = value.Length == 0 ? DisplayStyle.None : DisplayStyle.Flex;
}
}
public Color LabelColor{
get => _labelColor;
set{
_labelColor = value;
_label.style.color = value;
_labelBackground.style.backgroundColor = new Color(1.0f - value.r, 1.0f - value.g, 1.0f - value.b, value.a);
_secondLabel.style.color = value;
_secondLabelBackground.style.backgroundColor = _labelBackground.style.backgroundColor;
}
}
public Color StripeBackgroundColor{
get => _stripeBackgroundColor;
set{
_stripeBackgroundColor = value;
_stripeBackground.BaseColor = value;
}
}
public float ContentSize{
get => _contentSize;
set{
_contentSize = value;
_content.style.width = value;
_content.style.height = value;
}
}
public bool IsEmpty{
get => _isEmpty;
set{
_isEmpty = value;
_stripeBackground.Visible = value;
_content.style.backgroundColor = value ? Color.clear : Color.black;
}
}
public Texture2D ContentImage{
set => _contentImage.image = value;
}
public Texture2D SocketImage{
set => _socketImage.image = value;
}
#endregion
属性AnimOpacity
、AnimPosition
、HideSocket
用到了experimental.animation
来进行动画差值,将会在下一节中进行介绍。
experimental.animation实现可控动画插值
⚠️此功能是实验性功能,未来可能会有所变化。
使用experimental.animation
来实现动画差值能够监听多种回调,从而做到更精确的控制。也能够直接进行纯数学插值回调(实现类似于DOTween的高级动画插值)。
若无需精确控制,则只需要使用
style.transitionProperty
、style.transitionDelay
、style.transitionDuration
、style.transitionTimingFunction
对某一个属性设置自动插值(核心思想与HTML编程一致)。
例如,实现对width进行自动插值,则使用代码进行设置的方式为:_picture.style.transitionProperty = new List<StylePropertyName> { "width" }; _picture.style.transitionDuration = new List<TimeValue> { 0.5f}; _picture.style.transitionDelay = new List<TimeValue> { 0f}; //默认为0,可以忽略 _picture.style.transitionTimingFunction = new List<EasingFunction> { EasingMode.Ease }; //默认为Ease,可以忽略
使用experimental.animation.Start(StyleValues to, int durationMs)
即可完成对style属性手动调用动画插值,起始值为当前style值,使用方法为:
//隐藏插槽
public bool HideSocket{
get => _hideSocket;
set{
_hideSocket = value;
var to = new StyleValues{
opacity = value ? 0 : 1
};
experimental.animation.Start(to, 1000);
}
}
//透明度,与指示器联动
public float AnimOpacity{
get => _opacity;
set{
if(Math.Abs(_opacity - value) < 0.01f)return;
_opacity = value;
var to = new StyleValues{
opacity = value
};
if (_parent != null)
_parent.experimental.animation.Start(to, 500);
else
experimental.animation.Start(to, 500);
}
}
//位置,与指示器联动
public Vector2 AnimPosition
{
set{
var to = new StyleValues{
left = value.x,
top = value.y
};
if (_parent != null)
_parent.experimental.animation.Start(to, 500);
else
experimental.animation.Start(to, 500);
}
}
对于更高级的用法,我将给出一个例子用于实现无限运动的背景动画。
实现无限滚动的背景动画
背景动画原理如图所示,通过生成条纹矢量图,使用动画控制其平移循环,从而实现无限滚动的视觉效果。
目前
UI Toolkit
尚不支持Shader
。
通过保存experimental.animation.Start
的返回值,来确保始终只有一个动画实例正在播放。
绑定播放结束回调,触发再次播放动画的动作。令播放时间曲线为线性,从而实现无缝的动画衔接。
public class VectorImageElement : VisualElement
{
private VectorImage _vectorImage;
private float _offset;
private Color _currentColor;
private bool _visible = true;
private Color _baseColor = Color.black * 0.5f;
public Color BaseColor{
get => _baseColor;
set{
_baseColor = value;
if(_currentColor != _baseColor)//颜色有变化,重新创建矢量图
CreateVectorImage(BaseColor);
}
}
public bool Visible{
get => _visible;
set{
_visible = value;
if (value){
style.display = DisplayStyle.Flex;
AnimationScroll();
}
else{
style.display = DisplayStyle.None;
}
}
}
//无限循环动画逻辑
private ValueAnimation<float> _animation;
private void AnimationScroll(){
if(_animation is { isRunning: true })return;
if(!Visible)return;
_animation = experimental.animation.Start(0f, 14.14f, 2000, (_, f) =>{
_offset = f;
MarkDirtyRepaint();//每一次移动后,刷新界面
}).Ease(Easing.Linear);
_animation.onAnimationCompleted = AnimationScroll;
}
public VectorImageElement(){
style.position = Position.Absolute;
style.top = 0;
style.left = 0;
style.right = 0;
style.bottom = 0;
generateVisualContent += OnGenerateVisualContent;
CreateVectorImage(BaseColor);
AnimationScroll();
}
//生成倾斜的矢量图
private void CreateVectorImage(Color color){
var p = new Painter2D();
p.lineWidth = 5;
p.strokeColor = color;
_currentColor = color;
var begin = new Vector2(0,0);
var end = new Vector2(141.4f,0);
for (var i = 0; i <= 120 * 1.414f; i+= 10){
p.BeginPath();
p.MoveTo(begin);
p.LineTo(end);
p.Stroke();
p.ClosePath();
begin.y = i;
end.y = i;
}
var tempVectorImage = ScriptableObject.CreateInstance<VectorImage>();
if (p.SaveToVectorImage(tempVectorImage)){
Object.DestroyImmediate(_vectorImage);
_vectorImage = tempVectorImage;
}
p.Dispose();
}
//绘制偏移的矢量图
private void OnGenerateVisualContent(MeshGenerationContext mgc){
var r = contentRect;
if (r.width < 0.01f || r.height < 0.01f)
return;
var scale = new Vector2(r.width / 100,r.height / 100);
var offset = new Vector2(50 + _offset, -50 - _offset) * scale;
mgc.DrawVectorImage(_vectorImage,offset,Angle.Degrees(45f),scale);
}
}
ℹ️虽然目前UI Toolkit未提供Shader接口,但UI Toolkit确实提供了一种可以实现自定义即时模式渲染的元素,称为
ImmediateModeElement
,通过重写ImmediateRepaint
方法,在其中通过即时图形API
如Graphics.DrawTexture
、Graphics.DrawMesh
等实现绘制。
但经过测试其Draw存在诡异的Y轴偏移(约20px),因此此处未使用Shader实现。此discussions提到了此问题,但没有解决方案
其中1.414是 √2的数值,因为content的宽高比率为1:1。则斜对角的长度比率为1:√2。
⚠️注意:
DrawVectorImage
是一个非常耗时的操作,请仅在鼠标悬停时渲染动画背景。
完善UxmlTraits与自绘逻辑
现在插槽
的所有属性均已实现,可以完成剩余的部分。
UxmlTraits
完整内容:
public class SocketElementUxmlTraits : VisualElement.UxmlTraits{
public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc){
var se = (SocketElement)ve;
se.HideSocket = _hideSocket.GetValueFromBag(bag, cc);
se.LabelColor = _labelColor.GetValueFromBag(bag, cc);
se.ContentSize = _contentSize.GetValueFromBag(bag, cc);
se.StripeBackgroundColor = _stripeBackgroundColor.GetValueFromBag(bag, cc);
se.IsEmpty = _isEmpty.GetValueFromBag(bag, cc);
se.SocketName = _socketName.GetValueFromBag(bag, cc);
se.SecondName = _secondName.GetValueFromBag(bag, cc);
base.Init(ve, bag, cc);
}
private readonly UxmlBoolAttributeDescription _hideSocket = new(){
name = "hide-socket",
defaultValue = false
};
private readonly UxmlFloatAttributeDescription _contentSize = new(){
name = "content-size",
defaultValue = 100f
};
private readonly UxmlColorAttributeDescription _labelColor = new(){
name = "label-color",
defaultValue = Color.black
};
private readonly UxmlColorAttributeDescription _stripeBackgroundColor = new(){
name = "stripe-background-color",
defaultValue = Color.black * 0.5f
};
private readonly UxmlBoolAttributeDescription _isEmpty = new(){
name = "is-empty",
defaultValue = true
};
private readonly UxmlStringAttributeDescription _socketName = new(){
name = "socket-name",
defaultValue = ""
};
private readonly UxmlStringAttributeDescription _secondName = new(){
name = "second-name",
defaultValue = ""
};
}
Texture2D
无法通过UxmlAttributeDescription
分配,因此无需为其指定。
⚠️再次强调:
name
属性需要与C#属性
对应。
generateVisualContent
委托:
public SocketElement(){
...忽略其他代码...
_content.generateVisualContent += DrawBorder;
RegisterCallbacks();
}
private void DrawBorder(MeshGenerationContext mgc)
{
var painter = mgc.painter2D;
var element = mgc.visualElement;
painter.lineWidth = LineWidth;
painter.strokeColor = LineColor;
//线段的端点使用圆形形状
painter.lineCap = LineCap.Round;
//绘制边框
var width = element.style.width.value.value;
var height = element.style.height.value.value;
painter.BeginPath();
painter.MoveTo(new Vector2(0,height * 0.2f));
painter.LineTo(Vector2.zero);
painter.LineTo(new Vector2(width,0));
painter.LineTo(new Vector2(width,height * 0.2f));
painter.Stroke();
painter.BeginPath();
painter.MoveTo(new Vector2(0,height * 0.8f));
painter.LineTo(new Vector2(0,height));
painter.LineTo(new Vector2(width,height));
painter.LineTo(new Vector2(width,height * 0.8f));
painter.Stroke();
}
鼠标响应事件
更新RegisterCallbacks
工具函数,添加对鼠标进入事件PointerEnterEvent
、鼠标移出事件PointerLeaveEvent
的侦听处理。修改相关的外观属性,让UI更具交互性。
private void RegisterCallbacks(){
// 原始样式样式
var oldColor = Color.white;
var oldWidth = 1f;
_content.RegisterCallback<PointerEnterEvent>(_ =>{
_button.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.5f));
oldColor = LineColor;
oldWidth = LineWidth;
LineColor = Color.white;
LineWidth = 5;
_content.MarkDirtyRepaint();
});
_content.RegisterCallback<PointerLeaveEvent>(_ => {
_button.style.backgroundColor = new StyleColor(Color.clear);
LineColor = oldColor;
LineWidth = oldWidth;
_content.MarkDirtyRepaint();
});
RegisterCallback<DetachFromPanelEvent>(_ =>{
_parent = null;
});
RegisterCallback<AttachToPanelEvent>(_ =>{
_parent = parent as IndicatorElement;
});
}
⚠️注意:
我们使用了_context
的generateVisualContent
委托,因此需要调用_context
的MarkDirtyRepaint
动态字符标签 DynamicLabelElement
ℹ️本节使用了
schedule
进行周期性调用某个函数,以实现预计功能
本项目的所有Label
均以平替为DynamicLabelElement
,用于显示动感的逐字浮现特效,例如上文的SocketElement
:
其核心逻辑是,当接受到设置目标字符串消息时,立刻以固定时间间隔对目标字符串逐字符进行处理:
- 对当前位置的字符(为当前字符串长度小于当前位置则附加,否则替换)随机的选择一个字符显示,重复n次。
- 显示正确的字符。
- 开始处理下一个字符。
与上文不同的是,虽然其确实需要一种时间回调来触发字符回显,但是其使用schedule
而不是experimental.animation
实现逻辑。
通过schedule
启动一个固定时间间隔的回调,在回调中进行处理。
DynamicLabelElement
继承自TextElement
DynamicLabelElement
需要UxmlTraits
属性设计
变量名 | 类型 | 作用 |
---|---|---|
TargetText | string | 目标字符串 |
RandomTimes | int | 每个字符随机显示其他字符的次数 |
DeleteSpeed | float | 删除字符的速度(秒) |
RevealSpeed | float | 回显到正确字符的速度(秒) |
DeleteBeforeReveal | bool | 开始之前清空当前字符串 |
SkipSameChar | bool | 跳过相同字符(如果当前字符与目标相同则直接处理下一个字符) |
UxmlFactory
& UxmlTraits
:
public class DynamicLabelUxmlFactory : UxmlFactory<DynamicLabelElement, DynamicLabelUxmlTraits> { }
public class DynamicLabelUxmlTraits : TextElement.UxmlTraits{
public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc){
var se = (DynamicLabelElement)ve;
se.RandomTimes = _randomTimes.GetValueFromBag(bag, cc);
se.DeleteBeforeReveal = _deleteBeforeReveal.GetValueFromBag(bag, cc);
se.DeleteSpeed = _deleteSpeed.GetValueFromBag(bag, cc);
se.SkipSameChar = _skipSameChar.GetValueFromBag(bag, cc);
se.RevealSpeed = _revealSpeed.GetValueFromBag(bag, cc);
se.TargetText = _targetText.GetValueFromBag(bag, cc);
base.Init(ve, bag, cc);
}
private readonly UxmlStringAttributeDescription _targetText = new(){
name = "target-text",
defaultValue = "DynamicText"
};
private readonly UxmlIntAttributeDescription _randomTimes = new(){
name = "random-times",
defaultValue = 5
};
private readonly UxmlBoolAttributeDescription _deleteBeforeReveal = new(){
name = "delete-before-reveal",
defaultValue = true
};
private readonly UxmlFloatAttributeDescription _deleteSpeed = new(){
name = "delete-speed",
defaultValue = 0.1f,
};
private readonly UxmlBoolAttributeDescription _skipSameChar = new(){
name = "skip-same-char",
defaultValue = false
};
private readonly UxmlFloatAttributeDescription _revealSpeed = new(){
name = "reveal-speed",
defaultValue = 0.1f,
};
}
总体逻辑
public class DynamicLabelElement : TextElement
{
private string _targetText = ""; // 动画目标文本
private int _randomTimes = 3;//随机次数
private int _currentIndex; // 当前字符索引
private int _currentRandomIndex;// 当前随机字符次数
private bool _deleteBeforeReveal;// 回显前清空
private float _deleteSpeed = 0.01f; // 删除每个字符的速度
private float _revealSpeed = 0.01f; // 显示每个字符的速度
private bool _isAnimating; // 标识是否正在执行动画
private readonly StringBuilder _tempTextBuilder = new(); // 临时文本构建器
private IVisualElementScheduledItem _animationScheduler;
private const string RandomChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; // 随机字符集
//C#属性
public string TargetText{
get => _targetText;
set{
if(_targetText.Equals(value))return;
_targetText = value;
AnimateTextTransition(_targetText);
}
}
public int RandomTimes{
get => _randomTimes;
set => _randomTimes = Mathf.Clamp(value, 0, 10);
}
public float DeleteSpeed{
get => _deleteSpeed;
set => _deleteSpeed = Mathf.Clamp(value, 0.01f, float.MaxValue);
}
public float RevealSpeed{
get => _revealSpeed;
set => _revealSpeed= Mathf.Clamp(value, 0.01f, float.MaxValue);
}
public bool DeleteBeforeReveal{
get => _deleteBeforeReveal;
set => _deleteBeforeReveal = value;
}
public bool SkipSameChar { get; set; }
//构造函数
public DynamicLabelElement() : this(string.Empty) { }
public DynamicLabelElement(string txt){
AddToClassList("dynamic-label");
enableRichText = false;
TargetText = txt;
}
// 设置新的目标文本
private void AnimateTextTransition(string newText){
// 设置新的目标文本和动画速度
_targetText = newText;
// 如果正在执行动画,无需再次启动
if (_isAnimating) return;
// 启动新的动画
_isAnimating = true;
StartDeleting();
}
// 开始删除文本
private void StartDeleting(){
//无需删除 直接回显
if (!_deleteBeforeReveal){
StartRevealing();
return;
}
_tempTextBuilder.Clear();
_animationScheduler?.Pause();
_animationScheduler = schedule.Execute(DeleteCharacter).Every((long)(DeleteSpeed * 1000)).StartingIn(0);
}
// 删除字符的逻辑
private void DeleteCharacter(){
if (text.Length > 0)
text = text[..^1];
else// 删除完成后,开始显示目标文本
StartRevealing();
}
// 开始显示新文本
private void StartRevealing(){
_animationScheduler?.Pause();
_currentIndex = 0; // 重置显示索引
_currentRandomIndex = 0;
_tempTextBuilder.Clear();
_tempTextBuilder.Append(text);
_animationScheduler = schedule.Execute(RevealCharacter).Every((long)(RevealSpeed / (_randomTimes + 1) * 1000)).StartingIn(0);
}
// 显示字符的逻辑
private void RevealCharacter(){
if (_currentRandomIndex == 0 && SkipSameChar){
while (_currentIndex < _tempTextBuilder.Length &&
_currentIndex < _targetText.Length &&
_tempTextBuilder[_currentIndex] == _targetText[_currentIndex])
_currentIndex++;
}
if (_currentIndex < _targetText.Length){
char targetChar;
var finished = false;
if (_currentRandomIndex < RandomTimes){
targetChar = RandomChars[Random.Range(0, RandomChars.Length)];
_currentRandomIndex++;
}
else{
targetChar = _targetText[_currentIndex];
_currentRandomIndex = 0;
finished = true;
}
if (_currentIndex == _tempTextBuilder.Length)
_tempTextBuilder.Append(targetChar);
else
_tempTextBuilder[_currentIndex] = targetChar;
if (finished)
_currentIndex++;
text = _tempTextBuilder.ToString();
}
else
{
if (_currentIndex < _tempTextBuilder.Length){
if (_currentRandomIndex < _randomTimes){
_currentRandomIndex++;
return;
}
_currentRandomIndex = 0;
_tempTextBuilder.Remove(_currentIndex, 1);
text = _tempTextBuilder.ToString();
return;
}
// 显示完成,停止动画
_isAnimating = false;
CancelCurrentAnimation();
}
}
// 取消当前的动画
private void CancelCurrentAnimation(){
// 清除定时器
_animationScheduler.Pause();
_animationScheduler = null;
// 重置状态
_isAnimating = false;
}
}
核心逻辑为RevealCharacter
,通过schedule.Execute(RevealCharacter).Every((long)(RevealSpeed / (_randomTimes + 1) * 1000))
,固定周期调用RevealCharacter
。
逻辑较为简单,因此不过多赘述。现在可将项目中所有Label
替换为DynamicLabelElement
。
⚠️警告:
UxmlTraits
中规定的初始值只对UI Builder
中的元素起作用。由代码构建的元素无法读取UxmlTraits
中的初始值,需要在代码中明确指定初始值,例如:_label = new DynamicLabelElement("") { style = { marginBottom = 0,marginLeft = 0,marginRight = 0,marginTop = 0, paddingBottom = 0,paddingTop = 0, unityFontStyleAndWeight = FontStyle.Bold }, name = "socket-name", RevealSpeed = 0.1f, //<===注意:给定初始值 RandomTimes = 5, //<===注意:给定初始值 }
部件指示器UI更新逻辑
ℹ️使用两种算法:
椭圆映射
、力导向算法
ℹ️使用Unity Job
加速计算
⚠️限于篇幅原因,结构经过简化,以避免涉及武器组件相关逻辑。因此指示线数量是静态的。
为了统一管理指示线相关的逻辑,建立一个IndicatorManager
指示线管理器 IndicatorManager
建立一个类型结构,用于存储IndicatorElement
,称其为Socket
:
public class Socket{
public Transform slotTransform; //指示线要指向的目标
public string socketName; //插槽名称
[NonSerialized] public float Angle;
[NonSerialized] public Vector3 OriginalPos;
[NonSerialized] public IndicatorElement IndicatorElement;
[NonSerialized] public SocketElement SocketElement;
}
之后在IndicatorManager
中,声明列表,存储所有要展示的Socket:
[SerializeField] private List<Socket> Sockets; //需要在Unity Editor中指定
private UIDocument _uiDocument; //UI布局
private VisualElement _rootElement;//根节点,将各种元素添加到此元素之下
private static IndicatorManager _instance;//单例
在Start中初始化这些数据
private void Start()
{
_instance = this;
_uiDocument = GetComponent<UIDocument>();
_rootElement = _uiDocument.rootVisualElement.Q<VisualElement>("interactiveLayer");
InitializeSocketArray();
}
其中InitializeSocketArray
用于初始化Socke
的IndicatorElement
,SocketElement
:
private void InitializeSocketArray(){
//Socket已经在Unity Editor中分配了slotTransform、socketName
foreach (var socket in Sockets){
var indicatorElement = new IndicatorElement {
LineColor = Color.white,
LineWidth = 1,
BlockSize = 20,
style ={
top = 100,
left = 100,
opacity = _soloTimeGap ? 0f : 1.0f,//在solo时间间隙之前,屏幕上不能出现任何组件
}
};
var socketElement = new SocketElement(indicatorElement){
SocketName = socket.socketName,
LabelColor = Color.black,
};
indicatorElement.Add(socketElement);
// socket.SocketElement.BindClickEvent(); //绑定事件
_rootElement.Add(indicatorElement);
socket.IndicatorElement = indicatorElement;
socket.SocketElement = socketElement;
}
}
⚠️:需在UnityEditor中手动分配
List<Socket>
的部分数据(transform
和socketName
)。
更新指示线让其总指向场景物体
ℹ️通过对
IndicatorElement
列表进行遍历,使用Camera.WorldToScreenPoint
方法计算屏幕坐标位置。更新对应IndicatorElement
组件。
建立一个函数TransformWorldToScreenCoordinates
,
private void TransformWorldToScreenCoordinates()
{
foreach (var socket in Sockets)
{
var worldToScreenPoint = (Vector2)_mainCamera.WorldToScreenPoint(socket.slotTransform.position);
socket.OriginalPos = worldToScreenPoint;
worldToScreenPoint.y = Screen.height - worldToScreenPoint.y; //颠倒Y轴
socket.IndicatorElement.TargetPoint = worldToScreenPoint;
}
}
其中,由于Unity的Screen坐标轴与GUI坐标轴原点不同(Screen坐标系原点位于左下角),需要进行一次减法,颠倒Y轴。
之后在Update中,每一帧都调用此函数即可:
private void Update(){
//更新指示线目标
TransformWorldToScreenCoordinates();
}
目前的实现效果如下,指示线的目标将会始终指向在Socket列表
中分配的transform
物体。(图中四个角落为一个空物体)
更新插槽让其沿着椭圆排布
首先插槽
的位置必定是由算法自动控制而非人工预指定,根据其他游戏中的表现,可以看出这些插槽大致是从中心点散发,沿着椭圆的形状排布:
首先介绍核心逻辑,假设对于四个点,我们需要将其映射到椭圆上,则最基本的步骤应该如下:
step1 | step2 | step3 |
---|---|---|
红色为圆心 | 从圆心向四周发射射线 | 射线与椭圆交点为预计位置 |
为了计算这个过程,我们需要首先计算射线与水平的夹角,进而通过夹角计算射线交点坐标:
如何计算某个水平角度射线与椭圆的交点?涉及到高中数学知识,下面我直接给出解法:
/// <summary>
/// 计算椭圆上的顶点位置:<br/>
/// 在椭圆原点上,夹角为 angleDeg 度的射线,交于椭圆上的位置
/// </summary>
/// <param name="angleDeg">角度 (与Vector.right形成的有符号夹角)</param>
/// <returns>椭圆上的位置</returns>
private static Vector2 CalculateEllipseIntersection(float angleDeg)
{
var theta = angleDeg * Mathf.Deg2Rad; // 角度转换为弧度
var cosTheta = Mathf.Cos(theta);
var sinTheta = Mathf.Sin(theta);
// 计算二次方程系数
var a = (cosTheta * cosTheta) / (EllipticalA * EllipticalA) + (sinTheta * sinTheta) / (EllipticalB * EllipticalB);
// 解二次方程
var discriminant = 4 * a;
if (discriminant < 0) return Vector2.zero;
var sqrtDiscriminant = Mathf.Sqrt(discriminant);
var t = Mathf.Abs(sqrtDiscriminant / (2 * a));
return new Vector2(t * cosTheta, t * sinTheta) + EllipticalCenter;
}
注意其中的ellipticalCenter
、ellipticalA
、ellipticalAB
,是全局的变量:
public Vector2 ellipticalCenter; // 椭圆的中心位置
public float ellipticalA = 300f; // 椭圆的长轴半径
public float ellipticalB = 100f; // 椭圆的短轴半径
DebugDraw绘制目标椭圆
为了能够直观的看出椭圆情况,我们可以考虑在屏幕上画出椭圆,绘制椭圆用到了上文所建立的CalculateEllipseIntersection
函数:
private void DrawEllipse(){
Vector3 previousPoint = CalculateEllipseIntersection(0); // 第一个点
var pointZ = _mainCamera.nearClipPlane + 0.01f;
previousPoint.z = pointZ;
previousPoint.y = Screen.height - previousPoint.y;
for (var i = 1; i <= 50; i++){
var angle = 360 * i / 50; // 每个分段的角度
Vector3 currentPoint = CalculateEllipseIntersection(angle);
currentPoint.y = Screen.height - currentPoint.y;
currentPoint.z = pointZ;
// 将屏幕坐标转换到世界坐标并绘制线段
Debug.DrawLine(_mainCamera.ScreenToWorldPoint(previousPoint), _mainCamera.ScreenToWorldPoint(currentPoint), Color.green);
previousPoint = currentPoint; // 更新前一点为当前点
}
}
其中_mainCamera
为Camera.main
,请自行建立全局变量。
在LateUpdate
中进行绘制:
private void LateUpdate(){
#if DEBUG_DRAW
DrawEllipse();
#endif
}
可以考虑在Update中,每帧更新椭圆的中心位置。
⚠️:查看Debug.Draw的绘制结果需要打开Gizmos可见性。
排布插槽
到椭圆
回到本节开头所阐述的内容,问题的关键是需要得知角度:
建立一个函数TransformSocketToEllipse
用于映射插槽到椭圆中:
private void TransformSocketToEllipse()
{
foreach (var socket in Sockets)
{
//这里我们直接使用OriginalPos,OriginalPos的值在TransformWorldToScreenCoordinates中设置了。
var worldToScreenPoint = socket.OriginalPos;
var direction = (Vector2)worldToScreenPoint - EllipticalCenter;
socket.Angle = Vector2.SignedAngle(direction, Vector2.right);
}
//此时 Angle即为α角度
foreach (var socket in Sockets)
{
var socketElementEndPoint = CalculateEllipseIntersection(socket.Angle);
socket.IndicatorElement.Position = socketElementEndPoint;
}
}
在第一个foreach循环
中,通过使用TransformWorldToScreenCoordinates
中计算的屏幕坐标OriginalPos
,来计算水平线与圆心-插槽
的角度α
在第二个foreach循环
中,我们使用了α
来计算射线与椭圆交点
作为插槽位置。
在这里我们没有将其整合在同一个foreach
中,因为α
需要进行处理,原因可从目前的效果中看出:
插槽之间过于自由,以至于会出现相互重叠的情况。
力导向分散算法
所谓力导向,即循环遍历所有位置,检查任意两个位置之间的距离是否过近,如果过近则将此两个位置相互远离一段距离。
在这里不同的是将两个角度的差异增大,例如:
建立一个函数SpreadSocketsByDistance
用于处理这个过程:
private void SpreadSocketsByDistance(List<Socket> socketList, float minDistance, int iterations = 100)
{
var count = socketList.Count;
if (count < 2) return;
//先进行排序
socketList.Sort((a, b) => a.Angle.CompareTo(b.Angle));
for (var i = 0; i < iterations; i++){
var adjusted1 = false;
// 遍历每一对相邻角度
for (var j = 0; j < count; j++){
var next = (j + 1) % count; // 循环的下一个角度
var currentAngle = socketList[j].Angle;
var nextAngle = socketList[next].Angle;
// 获取两个角度对应的椭圆上的点
var currentPoint = CalculateEllipseIntersection(currentAngle);
var nextPoint = CalculateEllipseIntersection(nextAngle);
// 计算两点间的实际距离
var actualDistance = Vector2.Distance(currentPoint, nextPoint);
// 如果距离小于最小距离,则施加力调整角度
if (actualDistance < minDistance){
//力值估算
var force = Mathf.Atan((minDistance - actualDistance) / Vector2.Distance(socketList[j].OriginalPos, currentPoint)) * Mathf.Rad2Deg * rate;
socketList[j].Angle -= force ;
socketList[next].Angle += force ;
adjusted1 = true;
}
}
// 如果没有任何调整,提早退出迭代
if (!adjusted1) break;
}
}
其中最重要的语句为(其中rate
为全局变量,下文中有声明):
var force = Mathf.Atan((minDistance - actualDistance) / Vector2.Distance(socketList[j].OriginalPos, currentPoint)) * Mathf.Rad2Deg * rate;
此句决定了此力导向算法的效率,影响稳定性、迭代次数。
越小的force导致更多的迭代次数,越大的force会增大不确定性(在不同的位置不停闪现)。
原句中代码的force大小依赖于模糊计算的差距值角度β:
ℹ️:此方式是我个人的观点,不能保证一定是最佳的效果,你可以使用其他的计算方式。
在开始循环前,我使用Sort
进行排序,从而在后续循环中,只比较最临近的两个元素,而不是进行列表循环逐一比较。
- 优点:减少了时间复杂度(减少一层循环)。
- 缺点:必须要确保force值要尽可能的小,防止打乱数组中Angle的大小顺序。否则会导致重叠(虽然数组索引临近的元素与自身保持了最小距离,但非索引相邻的元素未保持最小距离)
这种情况表现为(同色点为相邻点,过大的force导致Angle顺序被打乱,临近比较变得无效):
使用力导向算法
为了使用此函数,添加全局变量:
public float minDistance = 100; //最小距离
[Range(0, 1)] public float rate;//调整比率
调整TransformSocketToEllipse
函数中的代码,在foreach
循环之间调用此函数:
private void TransformSocketToEllipse()
{
foreach (var socket in Sockets)
{
//这里我们直接使用OriginalPos,OriginalPos的值在TransformWorldToScreenCoordinates中设置了。
var worldToScreenPoint = socket.OriginalPos;
var direction = (Vector2)worldToScreenPoint - EllipticalCenter;
socket.Angle = Vector2.SignedAngle(direction, Vector2.right);
}
//此时 Angle即为α角度
SpreadSocketsByDistance(Sockets, minDistance);
foreach (var socket in Sockets)
{
var socketElementEndPoint = CalculateEllipseIntersection(socket.Angle);
socket.IndicatorElement.Position = socketElementEndPoint;
}
}
下图是rate
从0调整到0.1的效果:
预排布
目前存在一个问题,插槽
只会根据指示点
与圆心
的相对位置来排布,某些情况下,显得过于局促:
一个方案是计算指示点
中心,从中心向四周扩散,计算交点:
为了实现此功能,我们需要实现一个新的CalculateEllipseIntersection
重载,支持任意位置发出的射线,而不是固定从圆心发出的射线:
private Vector2 CalculateEllipseIntersection(Vector2 origin,float angleDeg)
{
// 将椭圆中心平移到原点
var adjustedOrigin = origin - EllipticalCenter;
var theta = angleDeg * Mathf.Deg2Rad; // 角度转换为弧度
var cosTheta = Mathf.Cos(theta);
var sinTheta = Mathf.Sin(theta);
// 计算二次方程系数
var squareA = (EllipticalA * EllipticalA);
var squareB = (EllipticalB * EllipticalB);
var a = (cosTheta * cosTheta) / squareA + (sinTheta * sinTheta) / squareB;
var b = 2 * ((adjustedOrigin.x * cosTheta) / squareA + (adjustedOrigin.y * sinTheta) / squareB);
var c = (adjustedOrigin.x * adjustedOrigin.x) / squareA + (adjustedOrigin.y * adjustedOrigin.y) / squareB - 1;
// 解二次方程
var discriminant = squareB - 4 * a * c;
//Δ<0没有交点
if (discriminant < 0) return Vector2.zero;
var sqrtDiscriminant = Mathf.Sqrt(discriminant);
// 求正数解
var t = Mathf.Abs((-b + sqrtDiscriminant) / (2 * a));
var intersection = new Vector2(adjustedOrigin.x + t * cosTheta, adjustedOrigin.y + t * sinTheta);
// 逆向平移回原始位置
return intersection + EllipticalCenter;
}
该内容实际上就是为圆心添加偏移,其算法与无偏移射线算法完全一致。
之后更新TransformSocketToEllipse
,添加一个可选的参数advance
,指示使用采用预排布的算法:
private void TransformSocketToEllipse(bool advance = false)
{
if (advance){
var center = Sockets.Aggregate(Vector2.zero, (current, socket) => current + (Vector2)socket.OriginalPos);
center /= Sockets.Count;
foreach (var socket in Sockets){
var direction = (Vector2)socket.OriginalPos - center;
var angle = Vector2.SignedAngle(Vector2.right, direction);
var pointOnEllipse = CalculateEllipseIntersection(socket.OriginalPos, angle);
socket.Angle = Vector2.SignedAngle(pointOnEllipse - EllipticalCenter, Vector2.right);
}
}
else{
foreach (var socket in Sockets){
var worldToScreenPoint = socket.OriginalPos;
var direction = (Vector2)worldToScreenPoint - EllipticalCenter;
socket.Angle = Vector2.SignedAngle(direction, Vector2.right);
}
}
SpreadSocketsByDistance(Sockets, _instance.minDistance);
foreach (var socket in Sockets){
var socketElementEndPoint = CalculateEllipseIntersection(socket.Angle);
socket.IndicatorElement.Position = socketElementEndPoint;
}
}
在advance
分支中,提前进行了一次CalculateEllipseIntersection
,将插槽放置在椭圆上。之后更新Angle
使其与当前位置匹配。
运行效果:
使用Unity Job加速运算
目前所有的运算都集中于主线程中,会导致帧率降低,可以考虑使用Job多线程分担计算压力。
想要建立Job只需实现IJob
接口,之后对实例化的Job对象调用Schedule
即可开启Job。通过对其返回值持久保存,来判断运行状态、获取计算结果。
当前流程转为Job则应为如下关系:
其中每一个圆角矩形都是一个Job。
Job管理器:MapScreenToEllipseJob
为了便于管理Job,建立一个类MapScreenToEllipseJob
用于负责分配任务和获取结果。
之后在这个class(非MonoBehaviour)中实现所有的Job。
ℹ️下面内容都位于
MapScreenToEllipseJob
类中
计算Angle并排列顺序Job
首先是进行计算Angle并排列顺序
的Job:
⚠️:我们仅传入坐标数组,因此需要保证输出坐标数组与输入坐标数组的顺序应一致。
建立一个结构,用于存储原始的顺序:
private struct IndexAngle {
public int OriginalIndex; // 表示排序前的位置
public float Value; // 要排序的数值
}
Job:
[BurstCompile]
//计算Angle:输入屏幕坐标。输出排序后的角度及对应关系
private struct CalculateAngleJob : IJob
{
/// <b>【输入】</b> 输入的原始屏幕坐标 (无需颠倒Y轴)
[ReadOnly] private NativeArray<Vector2> _screenPoints;
/// <b>【输出】</b> 排序后的角度列表对应的原始序号
[WriteOnly] public NativeArray<int> IndexArray;
/// <b>【输出】</b> 预计顶点位于椭圆上的角度列表(排序后)
[WriteOnly] public NativeArray<float> AngleArray;
/// <b>【输入】</b> 椭圆的中心
[ReadOnly] private readonly Vector2 _ellipticalCenter;
public CalculateAngleJob(NativeArray<Vector2> screenPoints, Vector2 ellipticalCenter) : this()
{
_screenPoints = screenPoints;
_ellipticalCenter = ellipticalCenter;
}
public void Execute()
{
var data = new NativeArray<IndexAngle>(_screenPoints.Length, Allocator.Temp);
for (var index = 0; index < _screenPoints.Length; index++)
{
data[index] = new IndexAngle()
{
Value = Vector2.SignedAngle(_screenPoints[index] - _ellipticalCenter, Vector2.right),
OriginalIndex = index
};
}
// 快速排序
QuickSort(data, 0, data.Length - 1);
// 分离各个属性
for (var index = 0; index < _screenPoints.Length; index++)
{
IndexArray[index] = data[index].OriginalIndex;
AngleArray[index] = data[index].Value;
}
data.Dispose();
}
private void QuickSort(NativeArray<IndexAngle> array, int low, int high)
{
if (low >= high) return;
var pivotIndex = Partition(array, low, high);
QuickSort(array, low, pivotIndex - 1);
QuickSort(array, pivotIndex + 1, high);
}
private static int Partition(NativeArray<IndexAngle> array, int low, int high) {
var pivotValue = array[high].Value;
var i = low - 1;
for (var j = low; j < high; j++) {
if (array[j].Value < pivotValue) {
i++;
Swap(array, i, j);
}
}
Swap(array, i + 1, high);
return i + 1;
}
private static void Swap(NativeArray<IndexAngle> array, int indexA, int indexB) {
(array[indexA], array[indexB]) = (array[indexB], array[indexA]);
}
}
注意其中的WriteOnly
、ReadOnly
标识,若无标识,则表示变量是可读可写
的,合理运用标识可提高编译后代码的执行效率。
使用BurstCompile
标识可启用Burst编译,大幅提高执行效率,开发时可先不开启,因为这会导致无法有效进行Debug。
预排布Job
Job:
[BurstCompile]
//进阶先行步骤: 以所有坐标的平均中心为原点,向外扩散重映射屏幕坐标到椭圆之上
private struct AdvanceMapPointsJob : IJob
{
/// <b>【输入/输出】</b> 输入的原始屏幕坐标 (无需颠倒Y轴)
private NativeArray<Vector2> _screenPoints;
/// <b>【输入】</b> 椭圆的中心
[ReadOnly] private readonly Vector2 _ellipticalCenter;
/// <b>【输入】</b> 椭圆A轴
[ReadOnly] private readonly float _ellipticalA;
/// <b>【输入】</b> 椭圆B轴
[ReadOnly] private readonly float _ellipticalB;
public AdvanceMapPointsJob(NativeArray<Vector2> screenPoints, Vector2 ellipticalCenter,
float ellipticalA, float ellipticalB)
{
_screenPoints = screenPoints;
_ellipticalCenter = ellipticalCenter;
_ellipticalA = ellipticalA;
_ellipticalB = ellipticalB;
}
public void Execute()
{
//计算中心点
var center = Vector2.zero;
for (var index = 0; index < _screenPoints.Length; index++)
center += _screenPoints[index];
center /= _screenPoints.Length;
//将点映射到椭圆上
for (var index = 0; index < _screenPoints.Length; index++)
{
var angle = Vector2.SignedAngle(Vector2.right, _screenPoints[index] - center);
_screenPoints[index] = CalculateEllipseIntersection(_screenPoints[index], angle);
}
}
private Vector2 CalculateEllipseIntersection(Vector2 origin,float angle)
{
// 将椭圆中心平移到原点
var adjustedOrigin = origin - _ellipticalCenter;
var theta = angle * Mathf.Deg2Rad; // 角度转换为弧度
var cosTheta = Mathf.Cos(theta);
var sinTheta = Mathf.Sin(theta);
// 计算二次方程系数
var squareA = (_ellipticalA * _ellipticalA);
var squareB = (_ellipticalB * _ellipticalB);
var a = (cosTheta * cosTheta) / squareA + (sinTheta * sinTheta) / squareB;
var b = 2 * ((adjustedOrigin.x * cosTheta) / squareA + (adjustedOrigin.y * sinTheta) / squareB);
var c = (adjustedOrigin.x * adjustedOrigin.x) / squareA + (adjustedOrigin.y * adjustedOrigin.y) / squareB - 1;
// 解二次方程
var discriminant = squareB - 4 * a * c;
//Δ<0没有交点
if (discriminant < 0) return Vector2.zero;
var sqrtDiscriminant = Mathf.Sqrt(discriminant);
// 求正数解
var t = Mathf.Abs((-b + sqrtDiscriminant) / (2 * a));
var intersection = new Vector2(adjustedOrigin.x + t * cosTheta, adjustedOrigin.y + t * sinTheta);
// 逆向平移回原始位置
return intersection + _ellipticalCenter;
}
}
注意:在JobSystem
中进行数学运算时,可以考虑使用Mathematics
数学计算库代替Mathf
,可大幅提高计算效率。
力导向Job
[BurstCompile]
//力导向算法:根据最小距离再分布
private struct SpreadAngleByDistanceJob : IJob
{
/// <summary>
/// PointsOnEllipse作为输出
/// </summary>
[WriteOnly] public NativeArray<Vector2> PointsOnEllipse;
[ReadOnly] private NativeArray<int> _indexArray;
private NativeArray<float> _angleList;
[ReadOnly] private readonly float _minDistanceGap;
[ReadOnly] private readonly Vector2 _ellipticalCenter;
[ReadOnly] private readonly float _ellipticalA;
[ReadOnly] private readonly float _ellipticalB;
[ReadOnly] private readonly int _iterations;
[ReadOnly] private readonly float _rate;
public SpreadAngleByDistanceJob(NativeArray<float> angleList,NativeArray<int> indexArray, float minDistanceGap, Vector2 ellipticalCenter, float ellipticalA, float ellipticalB,int iterations = 100,float rate = 1.0f) : this()
{
_angleList = angleList;
_indexArray = indexArray;
_minDistanceGap = minDistanceGap;
_ellipticalCenter = ellipticalCenter;
_ellipticalA = ellipticalA;
_ellipticalB = ellipticalB;
_iterations = iterations;
_rate = rate;
}
public void Execute()
{
var count = _angleList.Length;
if (count > 1)
for (var i = 0; i < _iterations; i++)
{
var adjusted1 = false;
// 遍历每一对相邻角度
for (var j = 0; j < count; j++)
{
var next = (j + 1) % count; // 循环的下一个角度
var currentAngle = _angleList[j];
var nextAngle = _angleList[next];
// 获取两个角度对应的椭圆上的点
var currentPoint = CalculateEllipseIntersection(currentAngle);
var nextPoint = CalculateEllipseIntersection(nextAngle);
// 计算两点间的实际距离
var actualDistance = Vector2.Distance(currentPoint, nextPoint);
// 如果距离小于最小距离,则施加力调整角度
if (actualDistance < _minDistanceGap)
{
var diff = (_minDistanceGap - actualDistance) / Vector2.Distance((currentPoint + nextPoint) / 2,_ellipticalCenter);
_angleList[j] -= diff * _rate * 10;
_angleList[next] += diff * _rate * 10;
adjusted1 = true;
}
}
// 如果没有任何调整,提早退出迭代
if (!adjusted1) break;
}
// 映射椭圆点
for (var index = 0; index < _angleList.Length; index++)
{
var trueIndex = _indexArray[index];
PointsOnEllipse[trueIndex] = CalculateEllipseIntersection(_angleList[index]);
}
}
private Vector2 CalculateEllipseIntersection(float angleDeg)
{
var theta = angleDeg * Mathf.Deg2Rad; // 角度转换为弧度
var cosTheta = Mathf.Cos(theta);
var sinTheta = Mathf.Sin(theta);
// 计算二次方程系数
var a = (cosTheta * cosTheta) / (_ellipticalA * _ellipticalA) + (sinTheta * sinTheta) / (_ellipticalB * _ellipticalB);
// 解二次方程
var discriminant = 4 * a;
if (discriminant < 0)
{
return Vector2.zero;
}
var sqrtDiscriminant = Mathf.Sqrt(discriminant);
var t = Mathf.Abs(sqrtDiscriminant / (2 * a));
return new Vector2(t * cosTheta, t * sinTheta) + _ellipticalCenter;
}
}
力导向算法中,此处的force计算略有不同,你依然可以采取旧算法
整合Job,分配任务、获取结果
建立两个函数ScheduleJob
、TryGetResults
,以及相关变量,分别用于启动Job、获取Job的计算结果:
private JobHandle _jobHandle;
private bool _isJobScheduled;
private SpreadAngleJob _mapScreenToEllipseJob;
private NativeArray<Vector2> _results;
private NativeArray<int> _indexArray;
private NativeArray<float> _angleList;
public float minGap = 100;
public int iterations = 100;
public float rate = 0.1f;
public void ScheduleJob(Vector2[] screenPointList,bool advance = false)
{
if (_isJobScheduled) return;
_isJobScheduled = true;
var length = screenPointList.Length;
_results = new NativeArray<Vector2>(screenPointList, Allocator.Persistent);
_indexArray = new NativeArray<int>(length, Allocator.Persistent);
_angleList = new NativeArray<float>(length, Allocator.Persistent);
var calculateAngleJob = new CalculateAngleJob(_results, IndicatorManager.EllipticalCenter)
{
IndexArray = _indexArray,
AngleArray = _angleList
};
var mapScreenToEllipseJob = new SpreadAngleByDistanceJob(
_angleList,
_indexArray,
minGap,
IndicatorManager.EllipticalCenter,
IndicatorManager.EllipticalA,
IndicatorManager.EllipticalB,
iterations,
rate)
{
PointsOnEllipse = _results
};
JobHandle advanceJob = default;
if(advance)
{
var advanceMapJob = new AdvanceMapPointsJob(_results, IndicatorManager.EllipticalCenter,
IndicatorManager.EllipticalA, IndicatorManager.EllipticalB);
advanceJob = advanceMapJob.Schedule();
}
var jobHandle = calculateAngleJob.Schedule(advanceJob);
_jobHandle = mapScreenToEllipseJob.Schedule(jobHandle);
}
public bool TryGetResults(out Vector2[] results)
{
if (_isJobScheduled && _jobHandle.IsCompleted)
{
_isJobScheduled = false;
_jobHandle.Complete();
results = _results.ToArray();
_results.Dispose();
_indexArray.Dispose();
_angleList.Dispose();
return true;
}
results = default;
return false;
}
在构造NativeArray
时,我们使用了Allocator.Persistent
,这是最慢的分配方式,但能够防止未及时调用TryGetResults
而造成的内存警告。
应用Job进行计算
在IndicatorManager
中实例化一个MapScreenToEllipseJob
:
private readonly MapScreenToEllipse _mapper = new ();
添加一个方法StartJob
,用于传入数据、启动Job:
/// <summary>
/// 开始Job,计算UI位置.<br/>
/// 若当前有Job正在执行,则忽略请求
/// </summary>
private void StartJob()
{
var list = new Vector2[Sockets.Count];
for (var index = 0; index < Sockets.Count; index++)
{
var socket = Sockets[index];
var worldToScreenPoint = _mainCamera.WorldToScreenPoint(socket.slotTransform.position);
list[index] = worldToScreenPoint;
worldToScreenPoint.y = Screen.height - worldToScreenPoint.y;
socket.OriginalPos = worldToScreenPoint;
socket.IndicatorElement.TargetPoint = worldToScreenPoint;
}
_mapper.ScheduleJob(list,true);
}
添加一个方法TryApplyJobResult
用于获取Job计算结果,并更新UI:
/// <summary>
/// 尝试获取Job的结果,并应用结果数据.<br/>
/// 若Job未完成,则什么也不会发生.
/// </summary>
private void TryApplyJobResult()
{
if (!_mapper.TryGetResults(out var result)) return;
if(result.Length != Sockets.Count)return;
for (var index = 0; index < result.Length; index++)
{
Sockets[index].IndicatorElement.Position = result[index];
}
}
ℹ️:我们的Job会保证输入与输出相同索引所对应的元素一定相同。
在Update
与LateUpdate
分别中调用这两种方法:
private void Update()
{
StartJob();
// 原始更新方法:
// TransformWorldToScreenCoordinates();
// TransformSocketToEllipse(true);
}
private void LateUpdate()
{
TryApplyJobResult();
// 绘制Debug椭圆
#if DEBUG_DRAW
DrawEllipse();
#endif
}
效率对比
极端情况,屏幕上有100个指示线:
未启用Job - 70FPS | 启用Job - 110FPS |
---|---|
更新椭圆匹配物体形状
所谓匹配形状,即是根据Renderer
的BoundingBox
,获取屏幕最小矩形,从而设置椭圆的长短轴大小。
获取Renderer最小屏幕矩形
实现一个函数GetScreenBoundingBox
用于获取最小屏幕矩形:
private static Rect GetScreenBoundingBox(Renderer targetRenderer)
{
// 获取包围盒
var bounds = targetRenderer.localBounds;
// 包围盒的6个面中心点
var centerPoints = new Vector3[6];
centerPoints[0] = new Vector3((bounds.min.x + bounds.max.x) / 2, bounds.min.y, (bounds.min.z + bounds.max.z) / 2); // 底面
centerPoints[1] = new Vector3((bounds.min.x + bounds.max.x) / 2, bounds.max.y, (bounds.min.z + bounds.max.z) / 2); // 顶面
centerPoints[2] = new Vector3(bounds.min.x, (bounds.min.y + bounds.max.y) / 2, (bounds.min.z + bounds.max.z) / 2); // 左面
centerPoints[3] = new Vector3(bounds.max.x, (bounds.min.y + bounds.max.y) / 2, (bounds.min.z + bounds.max.z) / 2); // 右面
centerPoints[4] = new Vector3((bounds.min.x + bounds.max.x) / 2, (bounds.min.y + bounds.max.y) / 2, bounds.min.z); // 前面
centerPoints[5] = new Vector3((bounds.min.x + bounds.max.x) / 2, (bounds.min.y + bounds.max.y) / 2, bounds.max.z); // 后面
// 旋转这些中心点
targetRenderer.transform.TransformPoints(centerPoints);
// 将旋转后的中心点转换到屏幕空间
var screenPoints = new Vector2[6];
for (var i = 0; i < centerPoints.Length; i++)
{
screenPoints[i] = _mainCamera.WorldToScreenPoint(centerPoints[i]);
}
// 计算最小矩形
var minX = Mathf.Min(screenPoints[0].x, screenPoints[1].x, screenPoints[2].x, screenPoints[3].x, screenPoints[4].x, screenPoints[5].x);
var maxX = Mathf.Max(screenPoints[0].x, screenPoints[1].x, screenPoints[2].x, screenPoints[3].x, screenPoints[4].x, screenPoints[5].x);
var minY = Mathf.Min(screenPoints[0].y, screenPoints[1].y, screenPoints[2].y, screenPoints[3].y, screenPoints[4].y, screenPoints[5].y);
var maxY = Mathf.Max(screenPoints[0].y, screenPoints[1].y, screenPoints[2].y, screenPoints[3].y, screenPoints[4].y, screenPoints[5].y);
#if DEBUG_DRAW
//画出采样点
Debug.DrawLine(centerPoints[0], centerPoints[1], Color.white); // 底面 -> 顶面
Debug.DrawLine(centerPoints[2], centerPoints[3], Color.white); // 左面 -> 右面
Debug.DrawLine(centerPoints[4], centerPoints[5], Color.white); // 前面 -> 后面
// 绘制上下、左右前后
Debug.DrawLine(centerPoints[0], centerPoints[2], Color.white); // 底面 -> 左面
Debug.DrawLine(centerPoints[0], centerPoints[3], Color.white); // 底面 -> 右面
Debug.DrawLine(centerPoints[1], centerPoints[2], Color.white); // 顶面 -> 左面
Debug.DrawLine(centerPoints[1], centerPoints[3], Color.white); // 顶面 -> 右面
Debug.DrawLine(centerPoints[4], centerPoints[2], Color.white); // 前面 -> 左面
Debug.DrawLine(centerPoints[4], centerPoints[3], Color.white); // 前面 -> 右面
Debug.DrawLine(centerPoints[5], centerPoints[2], Color.white); // 后面 -> 左面
Debug.DrawLine(centerPoints[5], centerPoints[3], Color.white); // 后面 -> 右面
#endif
// 创建并返回 Rect
return Rect.MinMaxRect(minX, minY, maxX, maxY);
}
注意:函数获取每个面的中心,而不是直接取角点,可以有效消减无效的空间(尤其是当摄像机过于凑近Bounding时)。
取得Rect[]最小矩形
首先明确:物体可能由多个形状组成,因此应当获取父子的所有BoundingBox的最小矩形。建立函数GetBoundingRect用于获取包括所有Rect的最小Rect
private static Rect GetBoundingRect(Rect[] rects)
{
if (rects.Length == 0) return Rect.zero; // 如果没有 Rect,返回零矩形
// 初始化最小和最大值
var minX = rects[0].xMin;
var minY = rects[0].yMin;
var maxX = rects[0].xMax;
var maxY = rects[0].yMax;
// 遍历所有的 Rect,更新最小值和最大值
foreach (var rect in rects){
minX = Mathf.Min(minX, rect.xMin);
minY = Mathf.Min(minY, rect.yMin);
maxX = Mathf.Max(maxX, rect.xMax);
maxY = Mathf.Max(maxY, rect.yMax);
}
// 使用最小和最大值来创建包围矩形
// maxY = Screen.height - maxY;
// minY = Screen.height - minY;
return new Rect(minX, minY, maxX - minX, maxY - minY);
}
同步椭圆到矩形
建立一个全局变量,用于保存当前的Renderer
:
private Renderer[] _renderer;
在Start函数中初始化_renderer
:
private void Start(){
...其他代码...
_renderer = displayObject.GetComponentsInChildren<Renderer>();
}
其中displayObject
是当前正在展示的物体,请自行建立相关变量,并在Unity Editor中分配。
建立一个函数UpdateElliptical
用于同步矩形:
private void UpdateElliptical()
{
var enumerable = _renderer.Select(GetScreenBoundingBox).ToArray();
_ellipticalBounds = GetBoundingRect(enumerable);
var worldBound = _safeAreaElement.worldBound;
worldBound.y = Screen.height -worldBound.yMax;
safeArea = worldBound;
AdjustRectB(ref _ellipticalBounds);
var center = _ellipticalBounds.center;
center.y = Screen.height - _ellipticalBounds.center.y;
ellipticalCenter = center;
ellipticalA = _ellipticalBounds.width / 2 + 100;
ellipticalB = _ellipticalBounds.height / 2 + 100;
}
其中:
safeArea
是一个Rect
,用于标识安全范围。
_safeAreaElement
是一个VisualElement,用于标记安全区的范围大小,防止矩形超出屏幕距离。
限于篇幅原因,此处不给出声明方式,请读者自行申请全局变量和UI Element。
AdjustRectB
用于调整矩形在安全矩形safeArea
范围内:
private static void AdjustRectB(ref Rect targetRect)
{
// 确保 rectB 的左边界不小于 rectA 的左边界
if (targetRect.xMin < SafeArea.xMin){
targetRect.width -= (SafeArea.xMin - targetRect.xMin);
targetRect.x = SafeArea.xMin;
}
// 确保 rectB 的右边界不大于 rectA 的右边界
if (targetRect.xMax > SafeArea.xMax)
targetRect.width -= (targetRect.xMax - SafeArea.xMax);
// 确保 rectB 的上边界不大于 rectA 的上边界
if (targetRect.yMax > SafeArea.yMax)
targetRect.height -= (targetRect.yMax - SafeArea.yMax);
// 确保 rectB 的下边界不小于 rectA 的下边界
if (targetRect.yMin < SafeArea.yMin){
targetRect.height -= (SafeArea.yMin - targetRect.yMin);
targetRect.y = SafeArea.yMin;
}
}
在更新ellipticalA
与ellipticalB
时,我直接使用硬编码的方式,让其始终长100单位距离。这显然是欠妥的,不过安全区限制了其副作用,因此可以使用这种方式。
高级属性面板
ℹ️:本节给出一个元素的继承例子,最大化的复用相同代码。
⚠️:属性面板与我实现的武器属性遗传算法高度关联,而后者的实现复杂度远高于整篇文章,篇幅原因,我只给出UI的实现逻辑。
❌:本节代码仅供参考,无法在没有遗传算法的情况下发挥功能
属性面板的特别之处在于其是由多个子属性信息元素
组合而成,因此我们需要先实现子属性信息元素
,其中子属性信息元素
分为两类:
-
AttributeElement
:显示组件的固有属性,用于选择栏目中的属性预览。表现为文字+数字: -
InheritedAttributeElement
:显示组件的固有+被子组件影响的属性,用于武器的属性总览。表现为多个进度条:
这两类子属性信息元素
都继承于同一个基类AttributeValueElementBase
:
- 提供了基础信息展示:属性名称、属性说明文本、属性数值。
- 提供了基础事件:当鼠标移入时的事件(默认为展开属性说明文本)。
- 提供了虚函数,允许重写自定义属性名称、数值的更新显示逻辑。
考虑到InheritedAttributeElement
、AttributeElement
关键功能都是基于AttributeValueElementBase
虚函数实现的,在这里我必须给出AttributeValueElementBase
完整逻辑:
由于
AttributeValueElementBase
为抽象类,因此没有UxmlFactory
、UxmlTraits
public abstract class AttributeValueElementBase : VisualElement {
private AttributeValue _attributeDataBase;
protected AttributeValue AttributeDataBase {
get => _attributeDataBase;
set {
_attributeDataBase = value;
UpdateValue();
}
}
public AttributeHeader AttributeHeader { get; set; }
private readonly DynamicLabelElement _label;
private readonly DynamicLabelElement _value;
private readonly DynamicLabelElement _descriptionLabel;
protected readonly VisualElement ValueArea;
protected readonly VisualElement BodyArea;
protected abstract void OnMouseEnter();
protected abstract void OnMouseLeave();
protected void Init(AttributeHeader attributeHeader, AttributeValue attributeData) {
AttributeHeader = attributeHeader;
_attributeDataBase = attributeData;
}
/// <summary>
/// 渲染标题和值
/// </summary>
/// <param name="title">标题</param>
/// <param name="value">值</param>
/// <returns>是否进行接下来的值更新OnUpdateValue</returns>
protected virtual bool OnRenderLabel(DynamicLabelElement title,DynamicLabelElement value) {
title.TargetText = AttributeHeader.AttributeName;
value.TargetText = $"{_attributeDataBase.Value:F1}";
return true;
}
/// <summary>
/// 当值更新时的逻辑
/// </summary>
protected abstract void OnUpdateValue();
public void UpdateValue() {
if (OnRenderLabel(_label,_value))
OnUpdateValue();
}
private void RegisterEvents() {
RegisterCallback<MouseEnterEvent>(_ => {
style.backgroundColor = new Color(0, 0, 0, 0.2f);
_descriptionLabel.style.display = DisplayStyle.Flex;
_descriptionLabel.TargetText = AttributeHeader.AttributeDescription;
OnMouseEnter();
});
RegisterCallback<MouseLeaveEvent>(_ => {
style.backgroundColor = new Color(0, 0, 0, 0.0f);
_descriptionLabel.TargetText = "";
_descriptionLabel.style.display = DisplayStyle.None;
OnMouseLeave();
});
}
protected AttributeValueElementBase() {
style.paddingTop = 5;
style.paddingBottom = 5;
var title = new VisualElement() {
style = {
flexDirection = FlexDirection.Row, justifyContent = Justify.SpaceBetween
}
};
Add(title);
_label = new DynamicLabelElement {
style = {
color = Color.white, marginLeft = 10, marginRight = 10, marginTop = 5, marginBottom = 5,
},
RevealSpeed = 0.05f,
RandomTimes = 3,
};
title.Add(_label);
ValueArea = new VisualElement {
style = {
flexDirection = FlexDirection.Row,
}
};
title.Add(ValueArea);
_value = new DynamicLabelElement {
style = {
unityFontStyleAndWeight = FontStyle.Bold, color = Color.black, marginLeft = 10, marginRight = 10, marginTop = 5, marginBottom = 5, backgroundColor = Color.white
},
RevealSpeed = 0.1f,
RandomTimes = 5,
};
ValueArea.Add(_value);
BodyArea = new VisualElement {
style= {
marginTop = 5
}
};
Add(BodyArea);
_descriptionLabel = new DynamicLabelElement {
style = {
unityFontStyleAndWeight = FontStyle.Normal, color = Color.gray, marginLeft = 10, marginRight = 10, marginTop = 5, marginBottom = 5,
display = DisplayStyle.None
},
RevealSpeed = 0.05f,
RandomTimes = 3
};
Add(_descriptionLabel);
RegisterEvents();
style.transitionProperty = new List<StylePropertyName> { "all" };
style.transitionDelay = new List<TimeValue> { 0f};
style.transitionDuration = new List<TimeValue> { 0.3f};
style.transitionTimingFunction = new List<EasingFunction> { EasingMode.Ease };
}
}
其中:
AttributeHeader
是属性头,包含了属性标识符(用于支持跨语种翻译)、属性描述、属性名、属性数值类型。
AttributeValue
是属性值,其包含一个最关键的float
型变量value
,表示属性的值。以及其他的辅助成员用于标识该属性的计算法、影响范围等。
AttributeElement
例如:AttributeElement
中,需要在数值后面显示计算法(绿色的加法):
实现方式为重写OnUpdateValue方法:
protected override void OnUpdateValue()
{
switch (AttributeData.CalcMethod)
{
case CalculationMethod.Add:
_calc.TargetText = "+";
_calc.style.color = Color.green;
break;
case CalculationMethod.Subtract:
_calc.TargetText = "-";
_calc.style.color = Color.red;
break;
case CalculationMethod.Multiply:
_calc.TargetText = "*倍乘";
_calc.style.color = Color.white;
break;
case CalculationMethod.Override:
_calc.TargetText = "·覆盖";
_calc.style.color = Color.yellow;
break;
default:
break;
}
}
其中_calc是该子类新添加的DynamicLabelElement
元素:
public AttributeElement(){
_calc = new DynamicLabelElement{
style ={
color = Color.white,
marginRight = 10,
marginTop = 5,
marginBottom = 5,
},
RevealSpeed = 0.1f,
RandomTimes = 5,
};
ValueArea.Add(_calc);
}
有些时候属性是布尔值而非具体数值,例如是否防水:
此时不应显示任何数值信息,此时我们只需重写OnRenderLabel
并让其返回false即可阻止OnUpdateValue
发生:
protected override bool OnRenderLabel(DynamicLabelElement title, DynamicLabelElement value)
{
style.display = DisplayStyle.Flex;
switch (AttributeHeader.Type)
{
default:
case AttributeType.Float:
case AttributeType.Range100:
case AttributeType.Range01:
title.style.color = Color.white;
value.style.display = DisplayStyle.Flex;
base.OnRenderLabel(title, value);
break;
case AttributeType.Bool:
switch (AttributeDataBase.Value)
{
case < -0.5f:
title.style.color = Color.red;
_calc.TargetText = "减益";
_calc.style.color = Color.red;
break;
case > 0.5f:
title.style.color = Color.green;
_calc.TargetText = "增益";
_calc.style.color = Color.green;
break;
default:
//Bool为0 则直接隐藏本条属性
style.display = DisplayStyle.None;
break;
}
title.TargetText = AttributeHeader.AttributeName;
value.style.display = DisplayStyle.None;
return false;
}
return true;
}
从逻辑中也能一窥布尔类型的表示方法:通过判断浮点的绝对数值大小是否大于0.5。
至于正负是为了表示该属性对玩家的意义积极与否。
InheritedAttributeElement
这个元素最有趣的点之一莫过于进度条:
为了实现更高效的管理进度条,每个进度条实际上都是一层封装:
private class ProgressBar{
private readonly VisualElement _labelBackground;
private readonly VisualElement _progress;
private readonly Label _label;
private bool _displayLabel;
private float _percent;
private Color _color;
public WeaponComponentBase TargetComponent;
private static IVisualElementScheduledItem _indicatorScheduled;
public ProgressBar(Color color = default) {
Root = new VisualElement();
_progress = new VisualElement() {
style = { flexGrow = 1 }
};
_labelBackground = new VisualElement() {
name = "label-background",
style = {
display = DisplayStyle.None, position = Position.Absolute, right = 0,
bottom = new Length(100, LengthUnit.Percent)
}
};
_label = new Label("") {
style = {
marginBottom = 0, marginLeft = 0, marginRight = 0, marginTop = 0,
paddingLeft = 0, paddingRight = 0, paddingTop = 0, paddingBottom = 0
}
};
_labelBackground.Add(_label);
Root.Add(_labelBackground);
Root.Add(_progress);
Color = color;
Root.style.transitionProperty = new List<StylePropertyName> { "all" };
Root.style.transitionDuration = new List<TimeValue> { 0.3f};
var oldAlpha = _color.a;
Root.RegisterCallback<MouseOverEvent>(_ => {
if (AttributeIndicatorManager.IndicatorVisible) {
IndicatorTarget();
}
else {
_indicatorScheduled?.Pause();
_indicatorScheduled = Root.schedule.Execute(IndicatorTarget).StartingIn(500);
}
oldAlpha = _color.a;
Color = Color.WithAlpha(1f);
});
Root.RegisterCallback<MouseOutEvent>(_ => {
Color = Color.WithAlpha(oldAlpha);
if (_indicatorScheduled != null) {
_indicatorScheduled.Pause();
_indicatorScheduled = null;
}
else
AttributeIndicatorManager.ShrinkIndicator();
});
}
private void IndicatorTarget() {
_indicatorScheduled?.Pause();
_indicatorScheduled = null;
AttributeIndicatorManager.SetIndicatorTarget(
_progress.worldBound.min,
_progress.worldBound.max,
TargetComponent,
Color
);
}
private float FitMaxFontSize(string text, float maxHeight, float begin = 1f, float end = 50) {
var minFontSize = begin; // 最小字体大小
var maxFontSize = end; // 假定的最大字体大小
while (maxFontSize - minFontSize > 0.5f) {
var fontSize = (minFontSize + maxFontSize) / 2f;
FontSize = fontSize;
var textSize = _label.MeasureTextSize(text, 0, 0, 0, 0);
if (textSize.y <= maxHeight)
minFontSize = fontSize;
else
maxFontSize = fontSize;
}
return (minFontSize + maxFontSize) / 2f;
}
public bool DisplayLabel {
get => _displayLabel;
set {
_displayLabel = value;
_labelBackground.style.display = _displayLabel ? DisplayStyle.Flex : DisplayStyle.None;
}
}
public bool ShowEdge {
set => Root.style.paddingLeft = value ? 1 : 0;
}
public float Percent {
get => _percent;
set {
_percent = value;
LabelText = $"{value:F0}%";
Root.style.width = new Length(_percent, LengthUnit.Percent);
}
}
public Color Color {
get => _color;
set {
_color = value;
_progress.style.backgroundColor = _color;
Root.style.color = _color;
}
}
public string LabelText {
get => _label.text;
set => _label.text = value;
}
public float FontSize {
set => _label.style.fontSize = value;
}
public float UpdateFontSize(float height) {
if (height < 0)
height = Root.layout.height;
return FitMaxFontSize(LabelText, height);
}
public VisualElement Root { get; }
}
在这个进度条中,处理了鼠标交互事件,封装了百分比属性,更快速的设置进度条长度。
但有几点值得注意:
WeaponComponentBase
:是一个基类,你可以将其理解为GameObject
FitMaxFontSize
:计算某个空间中,所能容纳的最大的字体大小IndicatorTarget
:启动属性来源指示器_indicatorScheduled
:用于在启动属性来源指示器前进行计时,从而实现悬停0.5秒触发。
之后与AttributeElement
相同,重写OnRenderLabel
、OnUpdateValue
。并在其中处理进度条相关的逻辑,由于涉及到了属性值遗传计算,外加篇幅原因,在这里就不展开了。
属性来源指示器
ℹ️:通过UI+程序结合的方式实现功能
使用到了generateVisualContent
生成网格图形
属性来源指示器实现了一种类似于“3D场景中的物体浮现于UI元素之上”的视觉效果。
其原理是生成组件的渲染图,并将其设置为Visual Element的背景图,让Visual Element的位置与渲染图位置重合:
(图中绿色方框为Visual Element
)
至于黄色部分,则使用了generateVisualContent来绘制,不同的是它是一个三维的网格模型,而不是2D笔刷所绘制。
首先实现黄色部分的元素,称其为属性指示器 AttributeIndicator
:
属性指示器
属性指示器总是由程序控制属性,因此不需要
UxmlTraits
如图所示,起需要四个Vector2属性用于标定四个顶点:
基础框架为:
public class AttributeIndicator : VisualElement
{
public new class UxmlFactory : UxmlFactory<AttributeIndicator,UxmlTraits> {}
public Color Color { get; set;}//颜色
public bool IndicatorVisible; //是否可见
public Vector2 BeginPointA;
public Vector2 BeginPointB;
public Vector2 EndPointA;
public Vector2 EndPointB;
private VisualElement _overlay;//组件的覆盖图片
private DynamicLabelElement _overlayLabel;//组件的描述文字
private readonly RectangleMesh _rectangleMesh;//四边形网格 为节省篇幅直接在这里给出,类型定义见下文
public AttributeIndicator()
{
pickingMode = PickingMode.Ignore;
style.position = Position.Absolute;
style.top = 0;
style.left = 0;
style.right = 0;
style.bottom = 0;
//四边形网格 为节省篇幅直接在这里给出,类型定义见下文
_rectangleMesh = new RectangleMesh(BeginPointA, BeginPointB,EndPointA, EndPointB,Color.cyan); // 初始化矩形网格
_overlay = new VisualElement() {
style = {
position = Position.Absolute,
alignItems = Align.Center,
justifyContent = Justify.Center,
},
pickingMode = PickingMode.Ignore,
};
_overlayLabel = new DynamicLabelElement {
style = {
fontSize = 16,
color = Color.black,
backgroundColor = Color.white,
},
RevealSpeed = 0.1f,
RandomTimes = 5,
enableRichText = false
};
_overlay.Add(_overlayLabel);
Add(_overlay);
generateVisualContent += DrawMeshes;// 绘制网格
}
public void StickOverlay(Rect areaRect, RenderTexture texture,string title = ""){
areaRect = this.WorldToLocal(areaRect);
_overlayLabel.TargetText = title;
_overlay.style.backgroundImage = Background.FromRenderTexture(texture);
_overlay.style.width = areaRect.width;
_overlay.style.height = areaRect.height;
_overlay.style.top = areaRect.y;
_overlay.style.left = areaRect.x;
_overlay.style.display = DisplayStyle.Flex;
}
public void HideOverlay(){
_overlay.style.backgroundImage = null;
_overlayLabel.TargetText = "";
_overlay.style.display = DisplayStyle.None;
IndicatorVisible = false;
}
}
其中:
StickOverlay
用于显示组件的图片。
HideOverlay
用于隐藏组件图片。
该元素的大小为完全覆盖整个屏幕,由于设置了pickingMode = PickingMode.Ignore
因此不会阻挡鼠标、键盘的的事件。
其中generateVisualContent
委托绑定的是DrawMeshes
函数,用于绘制网格形状。
绘制网格形状
// 绘制网格
private void DrawMeshes(MeshGenerationContext context)
{
// 获取矩形的网格数据
_rectangleMesh.UpdateMesh();
// 分配网格内存
var meshWriteData = context.Allocate(RectangleMesh.NumVertices, RectangleMesh.NumIndices);
// 设置网格顶点
meshWriteData.SetAllVertices(_rectangleMesh.Vertices);
// 设置网格索引
meshWriteData.SetAllIndices(_rectangleMesh.Indices);
}
其中RectangleMesh
是一个自定义的类型,用于管理四边形网格数据:
public class RectangleMesh
{
public const int NumVertices = 4; // 矩形有4个顶点
public const int NumIndices = 6; // 2个三角形,每个三角形3个顶点,6个索引
private Vector2 _beginPointA;
private Vector2 _beginPointB;
private Vector2 _endPointA;
private Vector2 _endPointB;
public Color Color;
public readonly Vertex[] Vertices = new Vertex[NumVertices]; // 使用 Vertex 结构体数组来存储顶点
public readonly ushort[] Indices = new ushort[NumIndices]; // 存储三角形的索引
private bool _isDirty = true;
public RectangleMesh(Vector2 beginPointA, Vector2 beginPointB, Vector2 endPointA, Vector2 endPointB, Color color){
_beginPointA = beginPointA;
_beginPointB = beginPointB;
_endPointA = endPointA;
_endPointB = endPointB;
Color = color;
}
private static Vector3 GetV3(Vector2 v2){
return new Vector3(v2.x, v2.y, Vertex.nearZ);
}
public void UpdateData(Vector2 beginPointA, Vector2 beginPointB, Vector2 endPointA, Vector2 endPointB){
_beginPointA = beginPointA;
_beginPointB = beginPointB;
_endPointA = endPointA;
_endPointB = endPointB;
_isDirty = true;
}
// 更新矩形网格的顶点和索引
public void UpdateMesh(){
if (!_isDirty)
return;
// 计算矩形的4个顶点,并使用 Vertex 结构体存储位置和颜色
Vertices[0].position = GetV3(_beginPointA);
Vertices[0].tint = Color;
Vertices[1].position = GetV3(_beginPointB);
Vertices[1].tint = Color;
var endColor = new Color(Color.r, Color.g, Color.b, 0f);
Vertices[2].position = GetV3(_endPointA);
Vertices[2].tint = endColor;
Vertices[3].position = GetV3(_endPointB);
Vertices[3].tint = endColor;
// 计算矩形的索引,这里我们用2个三角形来填充矩形
Indices[0] = 0; // 左下角
Indices[1] = 1; // 右下角
Indices[2] = 2; // 左上角
Indices[3] = 1; // 右下角
Indices[4] = 3; // 右上角
Indices[5] = 2; // 左上角
// 计算第一个三角形(0, 1, 2)和第二个三角形(1, 3, 2)的法线
var normal1 = CalculateNormal2D(_beginPointA, _beginPointB, _endPointA);
var normal2 = CalculateNormal2D(_beginPointA, _endPointB, _endPointA);
// 判断法线方向与视线方向的点积,决定是否需要调整顺序
if (normal1 < 0){ // 如果第一个三角形的法线方向与视点方向不一致,则交换顶点顺序
Indices[0] = 0;
Indices[1] = 2; // 左下角
Indices[2] = 1; // 右下角
}
if (normal2 < 0){ // 如果第二个三角形的法线方向与视点方向不一致,则交换顶点顺序
Indices[3] = 1;
Indices[4] = 2; // 右上角
Indices[5] = 3; // 左上角
}
_isDirty = false;
}
// 计算二维法线
private static float CalculateNormal2D(Vector2 v0, Vector2 v1, Vector2 v2){
var edge1 = v1 - v0;
var edge2 = v2 - v0;
// 计算二维叉积
return edge1.x * edge2.y - edge1.y * edge2.x;
}
}
注意三角形绕旋方向十分重要,反向的法向将被剔除,因此需要手动进行纠正。
更新网格形状
为了能够快速修改形状,添加两个函数UpdateData
、ShrinkUpdate
用于支持动画化的修改网格形状:
//延展矩形网格
public void UpdateData(Vector2 minWorld, Vector2 maxWorld, Vector2 endPointA, Vector2 endPointB, Color color)
{
var minLocal = this.WorldToLocal(minWorld);
var maxLocal = this.WorldToLocal(maxWorld);
BeginPointA = minLocal;
BeginPointB = maxLocal;
EndPointA = endPointA;
EndPointB = endPointB;
Color = color;
DoUpdate();
}
//收缩矩形网格
public void ShrinkUpdate()
{
DoUpdate(true);
}
//执行真正的网格更新操作
private ValueAnimation<float> _animation;
private float _lastValue;
private void DoUpdate(bool backward = false)
{
_rectangleMesh.Color = Color;
var from = _lastValue;
var to = backward ? 0f : 1f;
if (!backward) IndicatorVisible = true;
if (_animation is { isRunning: true })
{
_animation.Stop();
}
_animation = experimental.animation.Start(from, to, 1000, (_, f) =>
{
var ep1 = Vector2.Lerp(BeginPointA, EndPointA, f);
var ep2 = Vector2.Lerp(BeginPointB, EndPointB, f);
_lastValue = f;
// _fontColor.a = f;
_rectangleMesh.UpdateData(BeginPointA, BeginPointB, ep1, ep2);
MarkDirtyRepaint();
if(backward) HideOverlay();
});
}
之后将此元素添加到UI 文档中,并命名便于查找。
AttributeIndicatorManager
仅使用AttributeIndicator
无法做到预期效果,我们需要能够截取屏幕上的物体单独渲染图,并实时更新图片的效果。
为了便于管理这个一个过程,建立一个MonoBehaviour
:AttributeIndicatorManager
生成屏幕上物体的独立渲染图
首先在场景中新建一个摄像机,要求与主摄像机属性与位置完全一致,且设置为主摄像机的子级。但其剔除层要选择一个没有其他物体的空白层,用于单独渲染物体
添加属性:
private static AttributeIndicatorManager _instance;//本身的全局单例
private static AttributeIndicator _indicator; //UI元素
public Camera renderCamera;
private Texture2D _texture;//裁剪后图像
private RenderTexture _rt;//可更新的RT
private bool _updateRT;//可以对rt进行更新
private bool _rtCreated;//rt已创建
public Shader renderShader;//后处理shader,为节省篇幅,这里提前给出定义
进行初始化(根据实际情况修改):
private void Awake()
{
_instance = this;
_mainCamera = Camera.main;
_uiDocument = GetComponent<UIDocument>();
_indicator = _uiDocument.rootVisualElement.Q<AttributeIndicator>("attributeIndicator");
_renderMaterial = new Material(renderShader);
}
添加函数RenderComponent
,用于单独渲染目标。
其原理是设置物体层为其他层(这里是UI层)触发渲染后立刻恢复物体到原始层:
/// <summary>
/// 单独渲染屏幕上的目标
/// </summary>
/// <param name="target">目标物体</param>
/// <param name="rect">屏幕上的区域(将屏幕裁剪为此区域)</param>
private void RenderComponent(GameObject target,Rect rect)
{
_updateRT = false;//不允许对rt进行更新
var oldLayer = target.layer;
var renderTexture = RenderTexture.GetTemporary(Screen.width, Screen.height, 0, RenderTextureFormat.ARGB32);
renderCamera.targetTexture = renderTexture;
target.layer = LayerMask.NameToLayer("UI");
renderCamera.Render();
target.layer = oldLayer;
renderCamera.targetTexture = null;
//初始化_texture 持久保存当前的画面
if(_texture == null)
_texture = new Texture2D((int)rect.width, (int)rect.height, TextureFormat.ARGB32, false);
else
_texture.Reinitialize((int)rect.width, (int)rect.height, TextureFormat.ARGB32, false);
Graphics.CopyTexture(renderTexture, 0,0,(int)rect.x,(int)rect.y,(int)rect.width,(int)rect.height,_texture,0,0,0,0);
RenderTexture.ReleaseTemporary(renderTexture);
if (_rtCreated || _rt is not null){
if (_rt.IsCreated())
_rt.Release();
}
_rt = new RenderTexture(_texture.width, _texture.height,0, RenderTextureFormat.ARGB32);
_rt.Create();
//允许rt进行更新
_updateRT = true;
_rtCreated = true;//rt已创建
}
在RenderComponent
中,将图像保存到_texture
。
其中最关键的在于
_texture
, 它用于保存渲染结果。
至于其中的_updateRT
、_rtCreated
、_rt
则为下文做铺垫,为节省篇幅我直接给出声明与更新,而不是在下文中再重复一次这个函数。
对图片实时施加Shader效果
我们希望图片能够有滚动条纹效果,此时使用Shader
是唯一简便的方法,因此我们需要在Update
中使用Graphics.Blit
:
private void Update()
{
if (_updateRT)
{
Graphics.Blit(_texture,_rt,_renderMaterial);
}
}
其中_renderMaterial
是你希望对其处理的shader
材质,请自行定义并绑定shader
,例如:
Shader "Custom/StripeEffect"{
Properties{
_MainTex ("Base Texture", 2D) = "white" {}
_Color("Color", Color) = (1,0,0,1)
}
SubShader{
Pass{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
float4 _Color;
v2f_img vert(appdata_base v){
v2f_img o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
return o;
}
half4 frag(v2f_img i) : SV_Target {
half4 color = tex2D(_MainTex, i.uv);
i.uv.y += i.uv.x + _Time.x;
i.uv.y = frac(i.uv.y *= 10);
color = lerp(color,_Color,step(.5,i.uv.y * color.a));
return color;
}
ENDCG
}
}
FallBack "Diffuse"
}
由此,一旦RenderComponent
执行完成,Update
能够立刻对_texture
进行处理,实时的施加一个Shader
效果。
自动化AttributeIndicator
添加一个静态SetIndicatorTarget
方法用于允许任何地方调用,同时简化调用,只需提供起点两个顶点坐标而无需终点坐标:
public static void SetIndicatorTarget(Vector2 worldBeginPointA, Vector2 worldBeginPointB, GameObject target, Color color)
{
var screenBoundingBox = GetScreenBoundingBox(target.GetComponent<Renderer>());
_instance._renderMaterial.SetColor(SColor,color);
_instance.RenderComponent(target.gameObject,screenBoundingBox);
var min = screenBoundingBox.min;
var max = screenBoundingBox.max;
min.y = Screen.height - min.y;
max.y = Screen.height - max.y;
screenBoundingBox.y = Screen.height - screenBoundingBox.y;
screenBoundingBox.y -= screenBoundingBox.height;
CalculatePointsBC(screenBoundingBox, (worldBeginPointA + worldBeginPointB) / 2,out var b,out var c );
b = _indicator.WorldToLocal(b);
c = _indicator.WorldToLocal(c);
_indicator.StickOverlay(screenBoundingBox, _instance._rt,"要显示的标题");
_indicator.UpdateData(worldBeginPointA, worldBeginPointB, b, c, color);
}
其中GetScreenBoundingBox
用于获取最小屏幕矩形,不过与之前不同的是,这里需要取拐点而不是取每个面的中点。(为了防止打乱重要性排布,代码在下文CalculatePointsBC
之后给出)
其中CalculatePointsBC
用于计算最佳的对角线。例如为了避免一下情况:
CalculatePointsBC
的解题方式是计算三角形面积,选择面积最大的一种情况:
private static void CalculatePointsBC(Rect rect, Vector2 pointA,out Vector2 bestB,out Vector2 bestC){
Vector2[] rectCorners = {
new(rect.xMin, rect.yMin), // (top-left)
new(rect.xMax, rect.yMax), // (bottom-right)
new(rect.xMax, rect.yMin), // (top-right)
new(rect.xMin, rect.yMax), // (bottom-left)
};
bestB = Vector2.zero;
bestC = Vector2.zero;
if (TriangleArea2(pointA,rectCorners[0],rectCorners[1]) > TriangleArea2(pointA,rectCorners[2],rectCorners[3])){
bestB = rectCorners[0];
bestC = rectCorners[1];
}
else{
bestB = rectCorners[2];
bestC = rectCorners[3];
}
}
// 计算三角形ABC的面积
private static float TriangleArea2(Vector2 a, Vector2 b, Vector2 c){
return Mathf.Abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x));
}
GetScreenBoundingBox
用于获取最小屏幕矩形(与自动更新椭圆章节相比,这里是取拐角而非取面中心):
private static Rect GetScreenBoundingBoxOld(Renderer targetRenderer)
{
// 获取物体边界框的八个顶点
var bounds = targetRenderer.bounds;
var vertices = new Vector3[8];
vertices[0] = bounds.min;
vertices[1] = new Vector3(bounds.min.x, bounds.min.y, bounds.max.z);
vertices[2] = new Vector3(bounds.min.x, bounds.max.y, bounds.min.z);
vertices[3] = new Vector3(bounds.min.x, bounds.max.y, bounds.max.z);
vertices[4] = new Vector3(bounds.max.x, bounds.min.y, bounds.min.z);
vertices[5] = new Vector3(bounds.max.x, bounds.min.y, bounds.max.z);
vertices[6] = new Vector3(bounds.max.x, bounds.max.y, bounds.min.z);
vertices[7] = bounds.max;
// 将每个顶点转换到屏幕坐标
var minScreenPoint = new Vector2(float.MaxValue, float.MaxValue);
var maxScreenPoint = new Vector2(0f, 0f);
foreach (var t in vertices){
var screenPoint = _mainCamera.WorldToScreenPoint(t);
// 更新最小和最大屏幕坐标
minScreenPoint.x = Mathf.Min(minScreenPoint.x, screenPoint.x);
minScreenPoint.y = Mathf.Min(minScreenPoint.y, screenPoint.y);
maxScreenPoint.x = Mathf.Max(maxScreenPoint.x, Mathf.Max(0, screenPoint.x));
maxScreenPoint.y = Mathf.Max(maxScreenPoint.y, Mathf.Max(0, screenPoint.y));
}
// 创建并返回 Rect
return Rect.MinMaxRect(minScreenPoint.x, minScreenPoint.y, maxScreenPoint.x, maxScreenPoint.y);
}
使用方式
正如前一节InheritedAttributeElement
中Progress
的介绍,只需要:
AttributeIndicatorManager.SetIndicatorTarget(
_progress.worldBound.min,
_progress.worldBound.max,
TargetComponent,
Color
);
其中_progress.worldBound.min
,_progress.worldBound.max
组成了进度条的对角线。
关于武器属性遗传算法
由于篇幅原因,这里就不展开,视情况更新相关的解析教程。
文章如有不当之处,还望指正