Procedural kicker ramp generator. Builds a render Model of a curved ramp and constructs a stack of pitched convex BoxColliders (one per arc segment) so the top faces match a circular-arc profile; places the GameObject in the scene and tags it as road.
namespace VehicleProto;
/// <summary>
/// Builds a curved SOLID launch kicker. The VISUAL is a procedural mesh (the
/// <see cref="PlaygroundTerrain"/> <c>Model.Builder</c> idiom); the COLLISION is a stack of solid
/// convex TANGENT BOXES, one per arc segment, each pitched to the local surface slope.
///
/// PROFILE: a circular arc TANGENT to the ground at the base — zero lip where a wheel first meets it,
/// the same drivability law the flush roads follow — that steepens toward a vertical back lip of
/// height <c>H</c> over run <c>L</c>. The car drives up smoothly from grade and launches off the back
/// edge.
///
/// WHY SEGMENTED CONVEX BOXES (build 9 pass 2 — collision fix): the old collider was a single thin
/// triangle-mesh SHELL fed straight into <c>Model.Builder.AddCollisionMesh</c>. Against this map's
/// raycast/shapecast wheels that shell mis-behaves — a small kicker at full speed acted as a WALL
/// instead of a launch: a thin concave mesh gives the downward wheel shapecast an unreliable hit, the
/// chassis belly catches the face, and there is no solid volume beneath to ride on. Replacing it with
/// a set of SOLID convex boxes fixes every failure mode of that shell at once:
/// • Each box's TOP face lies exactly on its arc segment, so the collision surface follows the curve
/// face-for-face at every scale — the base segment is genuinely ~0° tangent, the way the render is.
/// • Boxes are primitive convex shapes the physics engine never hulls/AABB-simplifies (the mesh
/// shell was the simplification suspect).
/// • Each box is a SOLID volume buried below grade, so overlapping neighbours form one gapless solid
/// the wheel shapecast lands on cleanly and the belly rolls over — no thin-shell penetration.
/// The visual mesh is unchanged; only the collider architecture moved from one shell to N boxes.
///
/// LOCAL FRAME: the base front edge sits at the local origin, the run goes along +X to the lip at
/// x=L, the width W is centred on Y, and the surface rises in +Z. Position/orient the whole piece
/// with <paramref name="atM"/> (metres) + <paramref name="yawDeg"/> (a down-ramp is the same kicker
/// placed at its far end with yawDeg+180). Deterministic: pure function of the size args, no RNG.
/// </summary>
public static class RampKicker
{
const float M = Units.MetersToUnits;
/// <summary>Place a curved solid kicker under <paramref name="parent"/>. <paramref name="lengthM"/>
/// = ground run, <paramref name="widthM"/> = lateral width, <paramref name="heightM"/> = lip height.
/// Returns the placed GameObject.</summary>
public static GameObject Build( Scene scene, GameObject parent, Vector3 atM, float yawDeg,
float lengthM, float widthM, float heightM, Color color, int segments = 16 )
{
int segs = System.Math.Max( 3, segments );
var prof = Profile( lengthM, heightM, segs );
var model = BuildRenderModel( prof, widthM );
var go = scene.CreateObject();
go.Name = "Kicker";
go.SetParent( parent, true );
go.WorldPosition = atM * M;
go.WorldRotation = Rotation.FromYaw( yawDeg );
var renderer = go.Components.Create<ModelRenderer>();
renderer.Model = model;
renderer.Tint = color;
BuildSegmentColliders( go, prof, widthM );
go.Tags.Add( "road" ); // wheels treat the curved face as drivable ground
return go;
}
/// <summary>The sampled arc profile (x, z) in metres, tangent-to-ground at x=0. Circle centre at
/// (0,R), z(x) = R − √(R²−x²) with R = (L²+H²)/(2H) so z(L)=H exactly (slope 0 at the base).</summary>
static Vector2[] Profile( float L, float H, int segs )
{
float R = (L * L + H * H) / (2f * H);
var prof = new Vector2[segs + 1];
for ( int i = 0; i <= segs; i++ )
{
float x = L * i / segs;
float z = R - MathF.Sqrt( MathF.Max( 0f, R * R - x * x ) );
prof[i] = new Vector2( x, z );
}
return prof;
}
// ---------------------------------------------------------------- collision (segmented convex boxes)
/// <summary>One solid convex box per arc segment, pitched to the local slope, its top face lying on
/// the segment chord and its body buried below grade so overlapping neighbours make a gapless solid
/// whose only exposed surface is the faceted arc. This is the collider the wheels actually meet.</summary>
static void BuildSegmentColliders( GameObject kicker, Vector2[] prof, float W )
{
int segs = prof.Length - 1;
const float buryM = 1.5f; // how far each box reaches below grade → thick, overlapping solid
for ( int i = 0; i < segs; i++ )
{
var p0 = prof[i];
var p1 = prof[i + 1];
float dx = p1.x - p0.x;
float dz = p1.y - p0.y;
float segLen = MathF.Sqrt( dx * dx + dz * dz );
if ( segLen < 1e-4f ) continue;
float pitchDeg = MathF.Atan2( dz, dx ).RadianToDegree(); // segment slope above horizontal
float midX = (p0.x + p1.x) * 0.5f;
float midZ = (p0.y + p1.y) * 0.5f;
// upward surface normal (perp to the tangent, points up-and-back): (-dz, dx)/segLen
float nX = -dz / segLen;
float nZ = dx / segLen;
float boxThick = midZ + buryM; // surface → below grade
float cX = midX - nX * boxThick * 0.5f; // centre sits half a thickness below the surface
float cZ = midZ - nZ * boxThick * 0.5f;
var seg = kicker.Scene.CreateObject();
seg.Name = "KickerSeg";
seg.SetParent( kicker, false ); // keep parent yaw; add local pitch below
seg.LocalPosition = new Vector3( cX, 0f, cZ ) * M;
seg.LocalRotation = Rotation.FromPitch( -pitchDeg ); // +X points up the slope (nose-up)
var box = seg.Components.Create<BoxCollider>();
box.Scale = new Vector3( segLen, W, boxThick ) * M; // full box size in units
box.Static = true;
seg.Tags.Add( "road" );
}
}
// ---------------------------------------------------------------- render mesh (visual only)
/// <summary>Mesh the closed curved-kicker solid for RENDER (no collision — see
/// <see cref="BuildSegmentColliders"/>). Top curved surface, two side skirts down to grade, a flat
/// underside and the vertical back lip, wound so every face points outward via the self-check.</summary>
static Model BuildRenderModel( Vector2[] prof, float W )
{
int segs = prof.Length - 1;
float hw = W * 0.5f;
float L = prof[segs].x;
float H = prof[segs].y;
var verts = new List<Vertex>();
var positions = new List<Vector3>();
var indices = new List<int>();
int AddVert( Vector3 pMeters, Vector3 normal )
{
var p = pMeters * M;
positions.Add( p );
verts.Add( new Vertex( p, normal, new Vector3( 1f, 0f, 0f ), new Vector4( pMeters.x * 0.1f, pMeters.y * 0.1f, 0f, 0f ) ) );
return verts.Count - 1;
}
// Emit a quad A-B-C-D, picking the winding so the front face points along outN (keeps the
// single-sided default material facing out). Same self-checking trick avoids the terrain
// "face-down winding" trap by construction.
void Quad( Vector3 A, Vector3 B, Vector3 C, Vector3 D, Vector3 outN )
{
bool flip = Vector3.Dot( Vector3.Cross( B - A, C - A ), outN ) < 0f;
int a = AddVert( A, outN ), b = AddVert( B, outN ), c = AddVert( C, outN ), d = AddVert( D, outN );
if ( !flip ) { indices.Add( a ); indices.Add( b ); indices.Add( c ); indices.Add( a ); indices.Add( c ); indices.Add( d ); }
else { indices.Add( a ); indices.Add( c ); indices.Add( b ); indices.Add( a ); indices.Add( d ); indices.Add( c ); }
}
for ( int i = 0; i < segs; i++ )
{
var p0 = prof[i]; var p1 = prof[i + 1];
// per-segment up-normal from the arc tangent (leans back as it steepens): (-dz,0,dx)
var up = new Vector3( -(p1.y - p0.y), 0f, p1.x - p0.x ).Normal;
// TOP (drivable curved surface)
Quad( new Vector3( p0.x, -hw, p0.y ), new Vector3( p1.x, -hw, p1.y ),
new Vector3( p1.x, hw, p1.y ), new Vector3( p0.x, hw, p0.y ), up );
// LEFT skirt (y = -hw) and RIGHT skirt (y = +hw) — fill under the curve down to grade
Quad( new Vector3( p0.x, -hw, 0f ), new Vector3( p1.x, -hw, 0f ),
new Vector3( p1.x, -hw, p1.y ), new Vector3( p0.x, -hw, p0.y ), new Vector3( 0f, -1f, 0f ) );
Quad( new Vector3( p0.x, hw, 0f ), new Vector3( p1.x, hw, 0f ),
new Vector3( p1.x, hw, p1.y ), new Vector3( p0.x, hw, p0.y ), new Vector3( 0f, 1f, 0f ) );
}
// UNDERSIDE (flat on grade) and BACK LIP (vertical face at x=L) — seal the solid
Quad( new Vector3( 0f, -hw, 0f ), new Vector3( L, -hw, 0f ),
new Vector3( L, hw, 0f ), new Vector3( 0f, hw, 0f ), new Vector3( 0f, 0f, -1f ) );
Quad( new Vector3( L, -hw, 0f ), new Vector3( L, hw, 0f ),
new Vector3( L, hw, H ), new Vector3( L, -hw, H ), new Vector3( 1f, 0f, 0f ) );
var idx = new int[indices.Count];
for ( int k = 0; k < idx.Length; k++ ) idx[k] = indices[k];
var mesh = new Mesh( Material.Load( "materials/default.vmat" ) );
mesh.CreateVertexBuffer( verts.Count, verts );
mesh.CreateIndexBuffer( idx.Length, idx );
mesh.Bounds = BBox.FromPoints( positions );
return Model.Builder.AddMesh( mesh ).Create();
}
}