Static helper that procedurally builds a two-sided rounded-rectangle card mesh (shared Model) at a global size. It constructs vertex/index lists for front face, back face and rim, computes UVs so the front samples left half of an atlas and back samples right half, and exposes SetSize/Invalidate to rebuild the shared mesh.
using System;
using System.Collections.Generic;
namespace CardGames;
/// <summary>
/// Builds the two-sided card model in code (no .vmdl asset needed). Each face is a rounded
/// rectangle (a triangle fan around the centre) so the card has real rounded corners like a
/// physical card. The top face (front) samples the LEFT half of the atlas, the bottom face
/// (back) the RIGHT half. Each card overrides the material per-instance.
///
/// The size lives here as shared static state on purpose: there is one mesh shared by every card, so
/// there's one size. The active game sets it (<see cref="SetSize"/>) when it enables. This would only
/// be wrong with two differently-sized games live at once - impossible today (one active game), so it
/// stays a global rather than threading a size through every layout/collider call site.
/// </summary>
public static class CardMesh
{
public const float DefaultWidth = 5.5f; // world units (s&box is inches)
/// <summary>
/// Current card width. Games can shrink/grow cards via <see cref="SetSize"/>; layout reads this.
/// </summary>
public static float Width { get; private set; } = DefaultWidth;
public static float Height => Width / CardFaceRenderer.Aspect;
private static float CornerRadius => Width * 0.08f;
private const int CornerSegments = 6; // smoothness of each rounded corner
private const float HalfThickness = 0.025f; // half the card's thickness (the side rim spans both faces)
private const float EdgeBevel = 0.45f; // how much the rim normal tilts toward the faces (soft rounded-edge highlight)
private static readonly Vector4 RimUv = new( 0.004f, 0.5f, 0, 0 ); // sample the face-colour border for the edge
private static Model _shared;
public static Model Shared => _shared ??= Build();
/// <summary>
/// Drop the cached model so it rebuilds (e.g. after changing the size while iterating).
/// </summary>
public static void Invalidate() => _shared = null;
/// <summary>
/// Set the card world size (all cards share one mesh). Rebuilds on the next access.
/// </summary>
public static void SetSize( float width )
{
width = MathF.Max( width, 0.1f );
if ( MathF.Abs( width - Width ) < 0.001f ) return;
Width = width;
_shared = null; // rebuild at the new size
}
private static Model Build()
{
var placeholder = Material.Create( "card_placeholder", "complex.shader" );
var mesh = new Mesh( placeholder );
float hw = Width / 2f, hh = Height / 2f;
float r = MathF.Min( CornerRadius, MathF.Min( hw, hh ) );
var outline = RoundedRectOutline( hw, hh, r );
var verts = new List<Vertex>();
var indices = new List<int>();
AddFan( verts, indices, outline, HalfThickness, Vector3.Up, new Vector3( 1, 0, 0 ), front: true, hw, hh );
AddFan( verts, indices, outline, -HalfThickness, Vector3.Down, new Vector3( -1, 0, 0 ), front: false, hw, hh );
AddRim( verts, indices, outline, HalfThickness, EdgeBevel );
mesh.CreateVertexBuffer( verts.Count, verts );
mesh.CreateIndexBuffer( indices.Count, indices );
mesh.Bounds = BBox.FromPositionAndSize( Vector3.Zero, new Vector3( Width, Height, HalfThickness * 2 ) );
return Model.Builder.AddMesh( mesh ).Create();
}
// Rounded-rectangle outline, counter-clockwise as seen from +Z.
private static List<Vector2> RoundedRectOutline( float hw, float hh, float r )
{
var corners = new (Vector2 center, float startDeg)[]
{
(new Vector2( hw - r, hh - r ), 0f), // top-right
(new Vector2( -(hw - r), hh - r ), 90f), // top-left
(new Vector2( -(hw - r), -(hh - r) ), 180f), // bottom-left
(new Vector2( hw - r, -(hh - r) ), 270f), // bottom-right
};
var pts = new List<Vector2>();
foreach ( var (center, startDeg) in corners )
{
for ( int j = 0; j <= CornerSegments; j++ )
{
float a = MathX.DegreeToRadian( startDeg + 90f * j / CornerSegments );
pts.Add( center + new Vector2( MathF.Cos( a ), MathF.Sin( a ) ) * r );
}
}
return pts;
}
// Triangle fan around the face centre. Back faces flip winding so the normal points the other way.
private static void AddFan( List<Vertex> verts, List<int> indices, List<Vector2> outline,
float z, Vector3 normal, Vector3 tangent, bool front, float hw, float hh )
{
int center = verts.Count;
verts.Add( MakeVertex( Vector2.Zero, z, normal, tangent, front, hw, hh ) );
int first = verts.Count;
foreach ( var p in outline )
verts.Add( MakeVertex( p, z, normal, tangent, front, hw, hh ) );
int n = outline.Count;
for ( int i = 0; i < n; i++ )
{
int a = first + i;
int b = first + (i + 1) % n;
if ( front ) { indices.Add( center ); indices.Add( a ); indices.Add( b ); }
else { indices.Add( center ); indices.Add( b ); indices.Add( a ); }
}
}
// Connects the front and back outlines with a side wall so the card has real thickness. The rim
// normals tilt slightly toward each face (a soft chamfer) so the sun catches the edge rather than
// it reading as a flat decal. The whole rim samples the face-colour border of the texture.
private static void AddRim( List<Vertex> verts, List<int> indices, List<Vector2> outline, float t, float bevel )
{
int n = outline.Count;
int top = verts.Count;
for ( int i = 0; i < n; i++ )
verts.Add( RimVertex( outline, i, n, t, bevel ) );
int bot = verts.Count;
for ( int i = 0; i < n; i++ )
verts.Add( RimVertex( outline, i, n, -t, bevel ) );
for ( int i = 0; i < n; i++ )
{
int a = i, b = (i + 1) % n;
// Outward-facing winding (verified against a +Y edge): (topA, botA, topB), (topB, botA, botB).
indices.Add( top + a ); indices.Add( bot + a ); indices.Add( top + b );
indices.Add( top + b ); indices.Add( bot + a ); indices.Add( bot + b );
}
}
private static Vertex RimVertex( List<Vector2> outline, int i, int n, float z, float bevel )
{
var p = outline[i];
var prev = outline[(i - 1 + n) % n];
var next = outline[(i + 1) % n];
var tan = next - prev; // CCW travel direction
var outward = new Vector2( tan.y, -tan.x ).Normal; // outward = to the right of CCW travel
var normal = new Vector3( outward.x, outward.y, z > 0 ? bevel : -bevel ).Normal;
return new Vertex( new Vector3( p.x, p.y, z ), normal, new Vector3( outward.x, outward.y, 0 ), RimUv );
}
private static Vertex MakeVertex( Vector2 p, float z, Vector3 normal, Vector3 tangent, bool front, float hw, float hh )
{
float u = (p.x + hw) / (2f * hw) * 0.5f; // front → [0 .. 0.5]
float v = (hh - p.y) / (2f * hh); // v=0 at the top (+Y)
if ( !front ) u = 1f - u; // back → [0.5 .. 1]
return new Vertex( new Vector3( p.x, p.y, z ), normal, tangent, new Vector4( u, v, 0, 0 ) );
}
}