Component that builds a single welded physics collider for a golf hole by collecting KitShape and ModelCollider pieces under the GameObject, snapping/welding nearby vertices into a grid, turning off the pieces' own colliders, and creating one static ModelCollider with the green surface.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
/// <summary>
/// One welded collision mesh for a whole hole. Lives on the "Hole N" group (and the Kit Test Hole):
/// collects every enabled Kit Shape piece under the group, takes their collision geometry in hole
/// space, welds shared vertices across the pieces and builds a single static mesh collider with the
/// golf surface. The pieces' own colliders are switched off, their visible meshes stay as they are.
/// A ball rolling over a seam between two pieces then never meets an edge, so it never hops.
///
/// Runs in the editor too and rebuilds itself whenever a piece is added, moved, changed or removed,
/// so a hole is built with pieces and this never needs touching. Nothing is networked: every machine
/// builds the same mesh from the same scene (the piece set and their transforms come from the scene
/// file, and welding is position based, so the result does not depend on order or on who is host).
/// </summary>
public sealed class GolfHoleCollision : Component, Component.ExecuteInEditor
{
/// <summary>Physics surface for the whole hole (Assets/surface/golf_green.surface).</summary>
public const string SurfaceName = "golf_green";
/// <summary>Vertices closer than 1/16 unit are the same vertex. Rotated pieces (walls, corners,
/// bumpers) land a hair off the grid, this snaps them back onto their neighbours.</summary>
const float WeldGrid = 16f;
/// <summary>How often the pieces are checked for changes, in seconds.</summary>
const float CheckInterval = 0.25f;
[Property, ReadOnly] public int Pieces { get; private set; }
[Property, ReadOnly] public int Vertices { get; private set; }
[Property, ReadOnly] public int Triangles { get; private set; }
/// <summary>How many hole colliders have been rebuilt since load (see golf_perf).</summary>
public static int RebuildCount;
bool _dirty = true;
int _lastHash;
TimeSince _sinceCheck;
ModelCollider _collider;
/// <summary>A piece under this hole changed: rebuild on the next update.</summary>
public void MarkDirty() => _dirty = true;
protected override void OnEnabled()
{
_dirty = true;
}
protected override void OnDisabled()
{
if ( _collider.IsValid() ) _collider.Enabled = false;
}
protected override void OnUpdate()
{
if ( !_dirty )
{
// Polling for moved or edited pieces is for the editor. In play nothing moves a piece
// except code, and that calls MarkDirty; every rebuild there cooks a physics mesh.
if ( Game.IsPlaying ) return;
if ( _sinceCheck < CheckInterval ) return;
_sinceCheck = 0;
if ( ComputeHash() == _lastHash ) return;
}
Rebuild();
}
IEnumerable<KitShape> PiecesBelow() => Components.GetAll<KitShape>( FindMode.EnabledInSelfAndDescendants );
/// <summary>Model pieces below (a Model Collider with a model, on an object that is not a Kit Shape): the Blender-made kit pieces.</summary>
// Disabled colliders count too: the merge switches a piece's own collider off, and a saved scene
// keeps it off, so the piece must still be found on the next load.
IEnumerable<ModelCollider> ModelPiecesBelow() => Components.GetAll<ModelCollider>( FindMode.EverythingInSelfAndDescendants )
.Where( c => c.IsValid() && c.GameObject != GameObject && c.GameObject.Enabled && c.Model is not null && c.GetComponent<KitShape>() is null );
/// <summary>Cheap fingerprint of every piece's transform and settings, for the change check.</summary>
int ComputeHash()
{
var hash = new HashCode();
hash.Add( 2 ); // bump to force a rebuild after a code change
var hole = WorldTransform;
foreach ( var piece in PiecesBelow() )
{
// Pieces without collision (flags, tee pads) never change the collider: the swaying flag
// must not count as a change.
hash.Add( piece.Collision );
if ( !piece.Collision ) continue;
var t = hole.ToLocal( piece.WorldTransform );
hash.Add( piece.Kind );
hash.Add( (int)MathF.Round( piece.Length * WeldGrid ) );
hash.Add( (int)MathF.Round( piece.Rise * WeldGrid ) );
hash.Add( (int)MathF.Round( t.Position.x * WeldGrid ) );
hash.Add( (int)MathF.Round( t.Position.y * WeldGrid ) );
hash.Add( (int)MathF.Round( t.Position.z * WeldGrid ) );
hash.Add( (int)MathF.Round( t.Rotation.x * 10000f ) );
hash.Add( (int)MathF.Round( t.Rotation.y * 10000f ) );
hash.Add( (int)MathF.Round( t.Rotation.z * 10000f ) );
hash.Add( (int)MathF.Round( t.Rotation.w * 10000f ) );
hash.Add( (int)MathF.Round( t.Scale.x * 1000f ) );
hash.Add( (int)MathF.Round( t.Scale.y * 1000f ) );
hash.Add( (int)MathF.Round( t.Scale.z * 1000f ) );
hash.Add( piece.CollisionIndices.Count );
}
foreach ( var piece in ModelPiecesBelow() )
{
var t = hole.ToLocal( piece.WorldTransform );
hash.Add( piece.Model.Name );
hash.Add( (int)MathF.Round( piece.Model.Bounds.Mins.z * WeldGrid ) );
hash.Add( (int)MathF.Round( piece.Model.Bounds.Maxs.x * WeldGrid ) );
hash.Add( (int)MathF.Round( piece.Model.Bounds.Maxs.y * WeldGrid ) );
hash.Add( (int)MathF.Round( t.Position.x * WeldGrid ) );
hash.Add( (int)MathF.Round( t.Position.y * WeldGrid ) );
hash.Add( (int)MathF.Round( t.Position.z * WeldGrid ) );
hash.Add( (int)MathF.Round( t.Rotation.x * 10000f ) );
hash.Add( (int)MathF.Round( t.Rotation.y * 10000f ) );
hash.Add( (int)MathF.Round( t.Rotation.z * 10000f ) );
hash.Add( (int)MathF.Round( t.Rotation.w * 10000f ) );
hash.Add( (int)MathF.Round( t.Scale.x * 1000f ) );
}
return hash.ToHashCode();
}
void Rebuild()
{
var watch = Stopwatch.StartNew();
RebuildCount++;
_dirty = false;
_lastHash = ComputeHash();
var hole = WorldTransform;
var positions = new List<Vector3>();
var indices = new List<int>();
var lookup = new Dictionary<(int, int, int), int>();
var pieces = 0;
var found = 0;
foreach ( var piece in PiecesBelow() )
{
found++;
// The piece keeps its own collider only when it is not part of a hole.
var own = piece.GetComponent<ModelCollider>();
if ( own.IsValid() && own.Enabled ) own.Enabled = false;
piece.EnsureBuilt();
if ( !piece.Collision || piece.CollisionIndices.Count == 0 ) continue;
pieces++;
var local = hole.ToLocal( piece.WorldTransform );
var source = piece.CollisionPositions;
var map = new int[source.Count];
for ( var i = 0; i < source.Count; i++ )
{
var p = local.PointToWorld( source[i] );
var key = ((int)MathF.Round( p.x * WeldGrid ), (int)MathF.Round( p.y * WeldGrid ), (int)MathF.Round( p.z * WeldGrid ));
if ( !lookup.TryGetValue( key, out var index ) )
{
index = positions.Count;
positions.Add( new Vector3( key.Item1 / WeldGrid, key.Item2 / WeldGrid, key.Item3 / WeldGrid ) );
lookup[key] = index;
}
map[i] = index;
}
var tris = piece.CollisionIndices;
for ( var i = 0; i + 2 < tris.Count; i += 3 )
{
int a = map[tris[i]], b = map[tris[i + 1]], c = map[tris[i + 2]];
if ( a == b || b == c || a == c ) continue; // collapsed by the weld
indices.Add( a ); indices.Add( b ); indices.Add( c );
}
}
// Blender-made pieces: their model's mesh, welded in the same way; their own collider goes off.
var modelPieces = 0;
foreach ( var piece in ModelPiecesBelow().ToList() )
{
found++;
var model = piece.Model;
var source = model.GetVertices();
var tris = model.GetIndices();
if ( source is null || tris is null || source.Length == 0 || tris.Length < 3 ) continue;
if ( piece.Enabled ) piece.Enabled = false;
modelPieces++;
pieces++;
var local = hole.ToLocal( piece.WorldTransform );
var map = new int[source.Length];
for ( var i = 0; i < source.Length; i++ )
{
var p = local.PointToWorld( source[i].Position );
var key = ((int)MathF.Round( p.x * WeldGrid ), (int)MathF.Round( p.y * WeldGrid ), (int)MathF.Round( p.z * WeldGrid ));
if ( !lookup.TryGetValue( key, out var index ) )
{
index = positions.Count;
positions.Add( new Vector3( key.Item1 / WeldGrid, key.Item2 / WeldGrid, key.Item3 / WeldGrid ) );
lookup[key] = index;
}
map[i] = index;
}
for ( var i = 0; i + 2 < tris.Length; i += 3 )
{
int a = map[tris[i]], b = map[tris[i + 1]], c = map[tris[i + 2]];
if ( a == b || b == c || a == c ) continue;
indices.Add( a ); indices.Add( b ); indices.Add( c );
}
}
Pieces = pieces;
Vertices = positions.Count;
Triangles = indices.Count / 3;
if ( !_collider.IsValid() )
{
_collider = Components.GetOrCreate<ModelCollider>( FindMode.EverythingInSelf );
_collider.Flags |= ComponentFlags.NotSaved; // rebuilt on load, never written to the scene file
}
if ( Triangles == 0 )
{
_collider.Enabled = false;
Log.Info( $"GolfHoleCollision: {GameObject.Name} has no collision: {found} pieces found, {Pieces} with collision data" );
return;
}
_collider.Model = Model.Builder.AddCollisionMesh( positions, indices ).Create();
_collider.Static = true;
_collider.Surface = Surface.FindByName( SurfaceName ) ?? _collider.Surface;
_collider.Enabled = true;
Log.Info( $"GolfHoleCollision: {GameObject.Name} built {Triangles} triangles from {Pieces} of {found} pieces, {modelPieces} of them models ({Vertices} welded vertices) in {watch.ElapsedMilliseconds} ms" );
}
}