CL游戏引擎 - 图形 - 弹簧网格 [完整实现]

弹簧网格 [Spring Grid]

几何效果中最酷的效果之一是扭曲的背景网格。 我们这章将研究如何在Shape Blaster中创建类似的效果。

我们将使用弹簧模拟制作网格。 在网格的每个交叉点,我们将放置一个拥有小重量并在每侧附加一个弹簧。 这些弹簧只会拉动而不会推动,就像橡皮筋一样。 为了使网格保持在适当位置,网格边界处的质量将锚定在适当位置。 下面是布局图。

CL游戏引擎 - 图形 - 弹簧网格 [完整实现]

我们将创建一个名为Grid的类来创建此效果。 但是,在我们处理网格本身之前,我们需要创建两个辅助类:SpringPointMass

PointMass类

PointMass类表示我们将弹簧附加到的质量。 弹簧永远不会直接连接到其他弹簧; 相反,它们对它们连接的质量施加力,这反过来可能会拉伸其他弹簧。

class PointMass
{
	public Vector3 position;
	public Vector3 velocity;
	public float inverseMass;

	Vector3 _acceleration;
	float _damping = 0.98f;

	public PointMass( Vector3 position, float invMass )
	{
		this.position = position;
		this.inverseMass = invMass;
	}


	public void applyForce( Vector3 force )
	{
		_acceleration += force * inverseMass;
	}


	public void increaseDamping( float factor )
	{
		_damping *= factor;
	}


	public void update()
	{
		velocity += _acceleration;
		position += velocity;
		_acceleration = Vector3.Zero;
		if( velocity.LengthSquared() < 0.001f * 0.001f )
			velocity = Vector3.Zero;

		velocity *= _damping;
		_damping = 0.98f;
	}
}

首先,请注意它存储质量的倒数,1 / mass。 这在物理模拟中很常用,因为物理方程倾向于更频繁地使用质量的倒数,并且因为它通过将反质量设置为零来为我们表示无限重,也是不可移动对象的简单方法。

其次,该类还包含阻尼变量。 用作表示摩擦或空气阻力; 它将逐渐减少质量。 这有助于使网格最终停下来并且还增加弹簧模拟的稳定性。

PointMass中的update()方法完成每帧移动点质量的工作。 它首先进行辛欧拉积分,我们将加速度加到速度上,然后将更新的速度添加到位置。 这与标准的Euler积分不同,我们会在更新位置后更新速度。

提示:辛格欧拉更适合弹簧模拟,因为它节省了能量。 如果你使用常规的Euler积分并创建没有阻尼的弹簧,它们会随着它们获得能量而进一步拉伸并进一步弹跳,最终会破坏模拟效果。

在更新速度和位置后,我们检查速度是否非常小,如果是,我们将其设置为零。 由于非规范化浮点数的性质,这对性能很重要。

(当浮点数变得非常小时,它们使用一种称为非规范化数字的特殊表示。这样做的优点是允许浮点数表示较小的数字,但是它有副作用。大多数芯片组不能使用它们的标准算术运算 非规格化的数字,而不是必须使用一系列步骤来模拟它们。这比在标准化的浮点数上执行操作慢几十到几百倍。由于我们将速度乘以每帧的阻尼因子,它最终会变得非常小 我们实际上并不关心这种微小的速度,所以我们只需将它设置为零。)

PointMass中的increaseDamping()方法用于临时增加阻尼量。 稍后我们将使用它来实现某些效果。

Spring

弹簧连接两个点质量,如果拉伸超过其自然长度,则施加将质量拉到一起的力。 弹簧遵循Hooke定律的修改版本,具有阻尼:

f=−kx−bv

  • f是弹簧产生的力。
  • k是弹簧常数,或弹簧的刚度。
  • x是弹簧拉伸超过其自然长度的距离。
  • b是阻尼系数。
  • v是速度。

Spring类的代码如下:

class Spring
{
	public PointMass end1;
	public PointMass end2;
	public float targetLength;
	public float stiffness;
	public float damping;


	public Spring( PointMass end1, PointMass end2, float stiffness, float damping )
	{
		this.end1 = end1;
		this.end2 = end2;
		this.stiffness = stiffness;
		this.damping = damping;
		targetLength = Vector3.Distance( end1.position, end2.position ) * 0.95f;
	}


	public void update()
	{
		var x = end1.position - end2.position;

		var length = x.Length();
		// 这些弹簧只能拉,不能推
		if( length <= targetLength )
			return;

		x = ( x / length ) * ( length - targetLength );
		var dv = end2.velocity - end1.velocity;
		var force = stiffness * x - dv * damping;

		end1.applyForce( -force );
		end2.applyForce( force );
	}
}

当我们创建弹簧时,我们将弹簧的自然长度设置为略小于两个端点之间的距离。 即使在静止时也能使网格保持拉紧,并且使得外观也能变得更加美观。

Spring中update()方法首先检查弹簧是否伸展超过其自然长度。 如果它没有伸展,没有任何反应。 如果是,我们使用修改后的胡克定律来找到弹簧的力并将其应用于两个连接的质量。

创建网格

现在我们已经有了必要的嵌套类,我们已经准备好创建网格了。 我们首先在网格上的每个交叉点创建PointMass对象。 我们还创建了一些不可移动的锚点PointMass对象来保持网格到位。然后,我们将他们连接起来。

public class SpringGrid
{
	/// <summary>
	/// 网格的宽度
	/// </summary>
	public override float width { get { return _gridSize.Width; } }

	/// <summary>
	/// 网格的高度
	/// </summary>
	public override float height { get { return _gridSize.Height; } }

	/// <summary>
	/// 所有主要网格线的颜色
	/// </summary>
	public Color gridMajorColor = Color.OrangeRed;

	/// <summary>
	/// 所有次要网格线的颜色
	/// </summary>
	public Color gridMinorColor = Color.PaleVioletRed;

	/// <summary>
	/// 所有主要网格线的粗细
	/// </summary>
	public float gridMajorThickness = 3f;

	/// <summary>
	/// 所有次要网格线的粗细
	/// </summary>
	public float gridMinorThickness = 1f;

	/// <summary>
	/// 主网格线应出现在x轴上的频率
	/// </summary>
	public int gridMajorPeriodX = 3;

	/// <summary>
	/// 主网格线应出现在y轴上的频率
	/// </summary>
	public int gridMajorPeriodY = 3;

	Spring[] _springs;
	PointMass[,] _points;
	Vector2 _screenSize;
	Rectangle _gridSize;


	public SpringGrid( Rectangle gridSize, Vector2 spacing )
	{
		_gridSize = gridSize;
		var springList = new List<Spring>();

		// 我们将gridSize位置偏移半个间距,以便在所有周围均匀地应用填充
		gridSize.Location -= spacing.ToPoint();
		gridSize.Width += (int)spacing.X;
		gridSize.Height += (int)spacing.Y;

		var numColumns = (int)( gridSize.Width / spacing.X ) + 1;
		var numRows = (int)( gridSize.Height / spacing.Y ) + 1;
		_points = new PointMass[numColumns, numRows];

		// 这些固定点将用于将网格锚定到屏幕上的固定位置
		var fixedPoints = new PointMass[numColumns, numRows];

		// 创造点质量
		int column = 0, row = 0;
		for( float y = gridSize.Top; y <= gridSize.Bottom; y += spacing.Y )
		{
			for( float x = gridSize.Left; x <= gridSize.Right; x += spacing.X )
			{
				_points[column, row] = new PointMass( new Vector3( x, y, 0 ), 1 );
				fixedPoints[column, row] = new PointMass( new Vector3( x, y, 0 ), 0 );
				column++;
			}
			row++;
			column = 0;
		}

		// 将点质量与弹簧连接起来
		for( var y = 0; y < numRows; y++ )
		{
			for( var x = 0; x < numColumns; x++ )
			{
				if( x == 0 || y == 0 || x == numColumns - 1 || y == numRows - 1 ) // 锚定网格的边框
					springList.Add( new Spring( fixedPoints[x, y], _points[x, y], 0.1f, 0.1f ) );
				else if( x % 3 == 0 && y % 3 == 0 ) // 松散地锚定点质量的1/9
					springList.Add( new Spring( fixedPoints[x, y], _points[x, y], 0.002f, 0.02f ) );

				const float stiffness = 0.28f;
				const float damping = 0.06f;

				if( x > 0 )
					springList.Add( new Spring( _points[x - 1, y], _points[x, y], stiffness, damping ) );
				if( y > 0 )
					springList.Add( new Spring( _points[x, y - 1], _points[x, y], stiffness, damping ) );
			}
		}

		_springs = springList.ToArray();
	}

	Vector2 projectToVector2( Vector3 v )
	{
		// 进行透视投影
		var factor = ( v.Z + 2000 ) * 0.0005f;
		return ( new Vector2( v.X, v.Y ) - _screenSize * 0.5f ) * factor + _screenSize * 0.5f;
	}
}

第一个for循环在网格的每个交叉点处创建规则质量和不可移动质量。我们实际上不会使用所有不可移动的质量,并且在构造函数结束后的一段时间内,未使用的质量将被垃圾收集。我们可以通过避免创建不必要的对象来进行优化,由于网格通常只创建一次,因此它不会产生太大的影响。

除了在网格边界周围使用锚点质量之外,我们还将在网格内使用一些锚质量。这些将帮助我们在网格变形后将拉回其原始位置。

由于锚点永远不会移动,因此不需要每帧更新;我们可以简单地将它们连接到弹簧上。所以,我们在Grid类中没有这些质量的成员变量。

我们可以在创建网格时调整许多值。最重要的是弹簧的刚度和阻尼。 (边界锚和内部锚的刚度和阻尼独立于主弹簧设置。)较高的刚度值将使弹簧更快地振荡,更高的阻尼值将导致弹簧更快地减速。

操纵网格

为了使网格移动,我们必须每帧更新它。 这非常简单,因为我们已经完成了PointMass和Spring类中的所有工作:

void IUpdatable.update()
{
	_screenSize.X = Screen.width;
	_screenSize.Y = Screen.height;

	foreach( var spring in _springs )
		spring.update();

	foreach( var mass in _points )
		mass.update();
}

现在我们将添加一些操纵网格的方法。 您可以为您能想到的任何类型的操作添加方法。 我们将在这里实现三种类型的操作:在给定方向上推动网格的一部分,从某个点向外推动网格,并将网格拉向某个点。 这三个都会影响某个目标点给定半径内的网格。 以下是这些操作的一些图像:
CL游戏引擎 - 图形 - 弹簧网格 [完整实现]

CL游戏引擎 - 图形 - 弹簧网格 [完整实现]

CL游戏引擎 - 图形 - 弹簧网格 [完整实现]

/// <summary>
/// 在三维方向上施加力
/// </summary>
public void applyDirectedForce( Vector2 force, Vector2 position, float radius )
{
	applyDirectedForce( new Vector3( force, 0 ), new Vector3( position, 0 ), radius );
}


/// <summary>
/// 在三维方向上施加力
/// </summary>
public void applyDirectedForce( Vector3 force, Vector3 position, float radius )
{
	// translate position into our coordinate space
	position -= new Vector3( entity.transform.position + localOffset, 0 );
	foreach( var mass in _points )
	{
		if( Vector3.DistanceSquared( position, mass.position ) < radius * radius )
			mass.applyForce( 10 * force / ( 10 + Vector3.Distance( position, mass.position ) ) );
	}
}


/// <summary>
///施加一个吸引网格朝向该点的力
/// </summary>
public void applyImplosiveForce( float force, Vector2 position, float radius )
{
	applyImplosiveForce( force, new Vector3( position, 0 ), radius );
}


/// <summary>
/// 施加一个吸引网格朝向该点的力
/// </summary>
public void applyImplosiveForce( float force, Vector3 position, float radius )
{
	// 将位置转换为我们的坐标空间
	position -= new Vector3( entity.transform.position + localOffset, 0 );
	foreach( var mass in _points )
	{
		var dist2 = Vector3.DistanceSquared( position, mass.position );
		if( dist2 < radius * radius )
		{
			mass.applyForce( 10 * force * ( position - mass.position ) / ( 100 + dist2 ) );
			mass.increaseDamping( 0.6f );
		}
	}
}


/// <summary>
/// 施加一个力,从该点推出网格
/// </summary>
public void applyExplosiveForce( float force, Vector2 position, float radius )
{
	applyExplosiveForce( force, new Vector3( position, 0 ), radius );
}


/// <summary>
/// 施加一个力,从该点推出网格
/// </summary>
public void applyExplosiveForce( float force, Vector3 position, float radius )
{
	// 将位置转换为我们的坐标空间
	position -= new Vector3( entity.transform.position + localOffset, 0 );
	foreach( var mass in _points )
	{
		var dist2 = Vector3.DistanceSquared( position, mass.position );
		if( dist2 < radius * radius )
		{
			mass.applyForce( 100 * force * ( mass.position - position ) / ( 10000 + dist2 ) );
			mass.increaseDamping( 0.6f );
		}
	}
}

我们将在Shape Blaster中使用所有这三种方法来实现不同的效果。

渲染网格

将以下方法添加到Extensions类

void drawLine( Batcher batcher, Vector2 start, Vector2 end, Color color, float thickness = 2f )
{
	var delta = end - start;
	var angle = (float)System.Math.Atan2( delta.Y, delta.X );
	batcher.draw( Graphics.Graphics.instance.pixelTexture, start + entity.transform.position + localOffset, Graphics.Graphics.instance.pixelTexture.sourceRect, color, angle, new Vector2( 0, 0.5f ), new Vector2( delta.Length(), thickness ), SpriteEffects.None, layerDepth );
}

该方法可以进行拉伸,旋转和着色像素纹理以生成我们想要的线。

接下来,我们需要一个方法将3D网格点投影到2D屏幕上。 这可以使用矩阵来完成,但在这里我们将手动转换坐标。

此变换将为网格提供一个透视图,其中远处的点在屏幕上显得更加紧密。 现在我们可以通过遍历行和列并在它们之间绘制线来绘制网格:

public void render( Graphics graphics, Camera camera )
{
	// TODO:还缺少剔除,只渲染实际在屏幕上的线而不是全部的线
	var width = _points.GetLength( 0 );
	var height = _points.GetLength( 1 );

	for( var y = 1; y < height; y++ )
	{
		for( var x = 1; x < width; x++ )
		{
			var left = new Vector2();
			var up = new Vector2();
			var p = projectToVector2( _points[x, y].position );

			if( x > 1 )
			{
				float thickness;
				Color gridColor;
				if( y % gridMajorPeriodY == 1 )
				{
					thickness = gridMajorThickness;
					gridColor = gridMajorColor;
				}
				else
				{
					thickness = gridMinorThickness;
					gridColor = gridMinorColor;
				}

				// todo: Catmull-Rom
			}

			if( y > 1 )
			{
				float thickness;
				Color gridColor;
				if( x % gridMajorPeriodX == 1 )
				{
					thickness = gridMajorThickness;
					gridColor = gridMajorColor;
				}
				else
				{
					thickness = gridMinorThickness;
					gridColor = gridMinorColor;
				}

				up = projectToVector2( _points[x, y - 1].position );
				var clampedY = System.Math.Min( y + 1, height - 1 );
				var mid = Vector2.CatmullRom( projectToVector2( _points[x, y - 2].position ), up, p, projectToVector2( _points[x, clampedY].position ), 0.5f );

				if( Vector2.DistanceSquared( mid, ( up + p ) / 2 ) > 1 )
				{
					drawLine( graphics.batcher, up, mid, gridColor, thickness );
					drawLine( graphics.batcher, mid, p, gridColor, thickness );
				}
				else
				{
					drawLine( graphics.batcher, up, p, gridColor, thickness );
				}
			}

			// todo: 添加插值线
		}
	}
}

在上面的代码中,p是我们在网格上的当前点,left是直接指向其左边的点,而up是直接位于其上方的点。 我们在水平和垂直方向上绘制每三条较粗的线条以获得视觉效果。

插值

我们可以通过设置好的弹簧数量的质量来优化网格,而不会显着增加性能成本。 我们将进行两次这样的优化。

我们将通过在现有网格单元格内添加线段来使网格更密集。 我们通过从一侧的中点到相对侧的中点画线。 下图显示了红色的新插值线。

CL游戏引擎 - 图形 - 弹簧网格 [完整实现]

绘制插值线很简单。 如果你有两个点,a和b,它们的中点是(a + b)/ 2.因此,为了绘制插值线,我们在Grid 的draw()方法的for循环中添加以下代码:

// 在我们的点质量之间添加插值线。 
// 这使得网格看起来更密集,而无需模拟更多弹簧和点质量。
if( x > 1 && y > 1 )
{
	var upLeft = projectToVector2( _points[x - 1, y - 1].position );
	drawLine( graphics.batcher, 0.5f * ( upLeft + up ), 0.5f * ( left + p ), gridMinorColor, gridMinorThickness );  // 垂直线
	drawLine( graphics.batcher, 0.5f * ( upLeft + left ), 0.5f * ( up + p ), gridMinorColor, gridMinorThickness );  // 水平线
}

第二个改进是在我们的直线段上执行插值,使它们变得更平滑。 我们将执行Catmull-Rom插值。 在曲线上传递四个连续点,它将沿您提供的第二个和第三个点之间的平滑曲线返回点。

第五个参数是一个加权因子,它决定了它返回的插值曲线上的哪个点。 加权因子0或1将分别返回您提供的第二个或第三个点,加权因子0.5将返回两个点之间的插值曲线上的点。 通过逐渐将加权因子从零移动到1并在返回点之间绘制线条,我们可以生成完美平滑的曲线。 但是,为了保持性能,我们只考虑一个插值点,加权系数为0.5。 然后,我们用两条在插值点相交的直线替换网格中的原始直线。

由于网格中的线段已经很小,因此使用多个插值点通常不会产生明显的差异。

通常,我们网格中的线条非常直,不需要任何平滑。 我们可以检查这一点并避免必须绘制两条线而不是一条线:我们先检查插值点和直线中点之间的距离是否大于一个像素; 如果是,我们便假设线是弯曲的,否则我们绘制两个线段。

对于为水平线添加Catmull-Rom插值的Grid 中draw()方法的修改如下所示。

// 使用Catmull-Rom插值来帮助平滑网格中的弯曲
left = projectToVector2( _points[x - 1, y].position );
var clampedX = System.Math.Min( x + 1, width - 1 );
var mid = Vector2.CatmullRom( projectToVector2( _points[x - 2, y].position ), left, p, projectToVector2( _points[clampedX, y].position ), 0.5f );

// 如果网格非常直,则绘制一条直线。 否则,绘制线到我们新的插值中点
if( Vector2.DistanceSquared( mid, ( left + p ) / 2 ) > 1 )
{
	drawLine( graphics.batcher, left, mid, gridColor, thickness );
	drawLine( graphics.batcher, mid, p, gridColor, thickness );
}
else
{
	drawLine( graphics.batcher, left, p, gridColor, thickness );
}

下图显示了平滑效果。 在每个插值点绘制一个绿点,以更好地说明平滑线的位置。

CL游戏引擎 - 图形 - 弹簧网格 [完整实现]