2026-10-08 18:26:02 +02:00
/ * This Source Code Form is subject to the terms of the Mozilla Public
* License , v . 2.0 . If a copy of the MPL was not distributed with this
* file , You can obtain one at https : //mozilla.org/MPL/2.0/. */
2026-10-17 20:44:13 +02:00
using System.Collections.Generic ;
using System.Linq ;
2026-10-08 18:26:02 +02:00
using CultMath ;
Draw every moving body at the drawn time T = ExactTime + Lead
Planets, belts, wells, loot, mines, stations, the chunk reticle and the flow
clock are evaluated through DrawAhead (Time, Orbit, Height, Position) at one
drawn time; the rail law lives once in Zone.Offset; OrbitPositionAt, GetHeight,
ChunkPose, EvaluateBelt and RideHeight take a time. Fixes the planet jumping
against a co-moving ship at display rates above the step rate.
Not yet built or run (Unity presenter files are verified by compile only on
Starfire).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:26:36 +02:00
using GameCult.Caching ;
2026-10-08 18:26:02 +02:00
using static CultMath . math ;
2026-10-09 23:27:22 +02:00
// Pure presentation statics over sim state, never called by ServerShared, with no state of their own: the sim owns every
Draw every moving body at the drawn time T = ExactTime + Lead
Planets, belts, wells, loot, mines, stations, the chunk reticle and the flow
clock are evaluated through DrawAhead (Time, Orbit, Height, Position) at one
drawn time; the rail law lives once in Zone.Offset; OrbitPositionAt, GetHeight,
ChunkPose, EvaluateBelt and RideHeight take a time. Fixes the planet jumping
against a co-moving ship at display rates above the step rate.
Not yet built or run (Unity presenter files are verified by compile only on
Starfire).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:26:36 +02:00
// pose and no presenter stores one, a previous state or a velocity of its own.
// The rule: a presenter draws every moving thing at the drawn time T = Zone.ExactTime + Clock.Lead. A pose the sim defines
// in closed form of time (an orbit, a belt chunk, a well, the field's height, the flow scroll) is that function evaluated
// at T; a pose the sim integrates (a ship, a kinematic body) is its last step plus its step velocity times Lead; a child
// is drawn where its parent is. DrawAhead.Time is the one definition of T. Ships ride the field at that drawn xz and T (a
// ship's height is the field's there, not the last step's). A ballistic round's flight is presentation
2026-10-09 23:27:22 +02:00
// (ruling projectile-flight-is-presentation), drawn from the sim's facts: its frozen line, the published outcome, the
// commit tick, the impact cell on the target's drawn pose, the burst point.
2026-10-08 18:26:02 +02:00
public static class DrawAhead
{
Draw every moving body at the drawn time T = ExactTime + Lead
Planets, belts, wells, loot, mines, stations, the chunk reticle and the flow
clock are evaluated through DrawAhead (Time, Orbit, Height, Position) at one
drawn time; the rail law lives once in Zone.Offset; OrbitPositionAt, GetHeight,
ChunkPose, EvaluateBelt and RideHeight take a time. Fixes the planet jumping
against a co-moving ship at display rates above the step rate.
Not yet built or run (Unity presenter files are verified by compile only on
Starfire).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:26:36 +02:00
// The drawn time: the one definition of T.
public static double Time ( Zone zone , float lead ) = > zone . ExactTime + lead ;
// An orbit's node at the drawn time: the rail law at T, not the step's memo.
public static float2 Orbit ( Zone zone , CultRecordKey orbit , float lead ) = > zone . OrbitPositionAt ( orbit , Time ( zone , lead ) ) ;
// The field's height at a point at the drawn time, its wells where their rails put them at T.
public static float Height ( Zone zone , float2 planar , float lead ) = > zone . GetHeight ( planar , Time ( zone , lead ) ) ;
2026-10-16 02:35:07 +02:00
// The drawn height of a floater (loot, mines): the field at the drawn time plus the floater layer.
public static float FloaterHeight ( Zone zone , GameplaySettings settings , float2 planar , float lead ) = > Height ( zone , planar , lead ) + settings . FloaterLayerHeight ;
2026-10-17 20:44:13 +02:00
// A belt rock's drawn position (rulings asteroids-on-floater-layer, asteroid-tops-at-layer): its planar pose at the drawn time, its top on
// the floater layer over the field, so its centre is a drawn radius below it. pose.w is that radius because the renderer scales each mesh by
// RockScale(pose.w, the mesh's own measured radius). The renderer's belt loop and the reticle both call this; nothing else derives a rock height.
public static float3 ChunkAt ( Zone zone , GameplaySettings settings , float4 pose , float lead ) = >
float3 ( pose . x , FloaterHeight ( zone , settings , pose . xy , lead ) - pose . w , pose . y ) ;
2026-10-17 13:48:34 +02:00
2026-10-17 20:44:13 +02:00
public static float3 Chunk ( Zone zone , GameplaySettings settings , ChunkId chunk , float lead ) = >
ChunkAt ( zone , settings , zone . ChunkPose ( chunk . Field , chunk . Index , Time ( zone , lead ) ) , lead ) ;
// The scale that draws a mesh of the given measured radius (its farthest vertex from the mesh origin, a bound under any rotation) at the
2026-10-17 21:44:43 +02:00
// rock's gameplay radius (ruling rock-meshes-by-size). A radius that is not set is refused by name, never divided by.
public static float RockScale ( float gameplayRadius , float meshRadius ) = >
meshRadius > 0f ? gameplayRadius / meshRadius : throw new System . ArgumentException ( "InstancedMesh.Radius is not set (zero, negative or not a number): run RockMeshRadius.Fill." ) ;
2026-10-17 20:44:13 +02:00
2026-10-17 23:12:33 +02:00
// One rock's entry in a belt's draw list: the rock (its index in the belt) and the scale that draws it at its gameplay radius on the mesh it is listed under.
public readonly struct RockDraw
2026-10-17 13:48:34 +02:00
{
2026-10-17 23:12:33 +02:00
public readonly int Rock ;
public readonly float Scale ;
public RockDraw ( int rock , float scale ) { Rock = rock ; Scale = scale ; }
}
2026-10-18 00:23:47 +02:00
// The whole per-rock draw list of a belt (ruling rock-meshes-by-size); the renderer decides nothing and indexes nothing. The belt keeps `keep` of
// the candidate meshes, chosen by the belt's key alone (a hash of key and candidate index, ties by index), so a belt looks the same on every load
// and every client. Meshes are the kept candidates ranked ascending by radius, and ByMesh[m] lists the rocks drawn on Meshes[m], so a renderer
// batching by mesh iterates the two together. Rocks ranked by unworn size split into one run per mesh, smallest rocks on the smallest mesh and the
// last run taking the remainder; equal sizes rank by index, equal radii by candidate index, and a mesh lists its rocks by index. Unworn, so a mined
// rock keeps its mesh and only shrinks. A list starts with every Scale NaN; Draw fills each entry's Scale from the poses (pose.w is the rock's
// gameplay radius) and returns the same arrays every call.
2026-10-17 23:12:33 +02:00
public sealed class BeltDraw < T >
{
private readonly float [ ] _radii ;
private readonly int [ ] [ ] _rocks ;
2026-10-17 21:44:43 +02:00
2026-10-17 23:12:33 +02:00
public T [ ] Meshes { get ; }
public RockDraw [ ] [ ] ByMesh { get ; }
2026-10-17 21:44:43 +02:00
2026-10-18 00:23:47 +02:00
public BeltDraw ( IReadOnlyList < float > unwornSizes , IReadOnlyList < T > candidates , System . Func < T , float > radius , int keep , string beltKey )
2026-10-17 21:44:43 +02:00
{
2026-10-18 00:23:47 +02:00
if ( candidates . Count = = 0 ) throw new System . ArgumentException ( "AsteroidMeshes is empty: a belt needs at least one rock mesh." ) ;
if ( keep < 1 ) throw new System . ArgumentException ( "AsteroidMeshCount is below 1: a belt keeps at least one rock mesh." ) ;
2026-10-17 23:12:33 +02:00
var radii = candidates . Select ( radius ) . ToArray ( ) ;
2026-10-18 00:23:47 +02:00
for ( var c = 0 ; c < radii . Length ; c + + )
if ( ! ( radii [ c ] > 0f ) ) throw new System . ArgumentException ( $"InstancedMesh.Radius of mesh #{c} is not set (zero, negative or not a number): run RockMeshRadius.Fill." ) ;
var meshByRank = Enumerable . Range ( 0 , radii . Length ) . OrderBy ( c = > KeepOrder ( beltKey , c ) ) . ThenBy ( c = > c ) . Take ( keep )
. OrderBy ( c = > radii [ c ] ) . ThenBy ( c = > c ) . ToArray ( ) ;
_radii = meshByRank . Select ( c = > radii [ c ] ) . ToArray ( ) ;
Meshes = meshByRank . Select ( c = > candidates [ c ] ) . ToArray ( ) ;
2026-10-17 21:44:43 +02:00
var rockByRank = Enumerable . Range ( 0 , unwornSizes . Count ) . OrderBy ( i = > unwornSizes [ i ] ) . ThenBy ( i = > i ) . ToArray ( ) ;
2026-10-18 00:23:47 +02:00
var run = System . Math . Max ( 1 , unwornSizes . Count / Meshes . Length ) ;
_rocks = Enumerable . Range ( 0 , Meshes . Length ) . Select ( m = > rockByRank
. Where ( ( _ , rank ) = > System . Math . Min ( rank / run , Meshes . Length - 1 ) = = m ) . OrderBy ( i = > i ) . ToArray ( ) ) . ToArray ( ) ;
2026-10-17 23:12:33 +02:00
ByMesh = _rocks . Select ( r = > r . Select ( rock = > new RockDraw ( rock , float . NaN ) ) . ToArray ( ) ) . ToArray ( ) ;
2026-10-17 21:44:43 +02:00
}
2026-10-18 00:23:47 +02:00
// FNV-1a over the belt's key, mixed with the candidate index: a fixed function of its inputs, no global random state.
private static ulong KeepOrder ( string beltKey , int candidate )
{
var h = 14695981039346656037 UL ;
foreach ( var ch in beltKey ) { h ^ = ch ; h * = 1099511628211 UL ; }
h ^ = ( uint ) candidate ; h * = 1099511628211 UL ;
h ^ = h > > 32 ; h * = 0x9E3779B97F4A7C15 UL ; h ^ = h > > 29 ;
return h ;
}
2026-10-17 23:12:33 +02:00
public RockDraw [ ] [ ] Draw ( IReadOnlyList < float4 > poses )
{
for ( var m = 0 ; m < _rocks . Length ; m + + )
for ( var k = 0 ; k < _rocks [ m ] . Length ; k + + )
ByMesh [ m ] [ k ] = new RockDraw ( _rocks [ m ] [ k ] , RockScale ( poses [ _rocks [ m ] [ k ] ] . w , _radii [ m ] ) ) ;
return ByMesh ;
}
2026-10-17 13:48:34 +02:00
}
2026-10-18 00:23:47 +02:00
public static BeltDraw < T > DrawListOf < T > ( AsteroidBelt belt , PlanetSettings settings , IReadOnlyList < T > meshes , System . Func < T , float > radius , int keep , CultRecordKey beltKey ) = >
new ( Enumerable . Range ( 0 , belt . Data . Asteroids . Length ) . Select ( i = > belt . UndamagedSize ( i , settings ) ) . ToArray ( ) , meshes , radius , keep , beltKey . Value ) ;
2026-10-17 21:44:43 +02:00
2026-10-08 23:39:30 +02:00
// A ship in a wormhole animation has its pose scripted by the step, not integrated from Velocity and TurnRate,
// so it is drawn where the step put it: extrapolating a scripted pose draws a streak that resets every step.
2026-10-15 17:45:56 +02:00
// A child is drawn where its parent is drawn, as the step copies the parent's pose onto it.
public static float3 Position ( Entity entity , float lead )
{
if ( entity is Ship { WormholeAnimationInProgress : true } ) return entity . Position ;
if ( entity . Parent ! = null ) return Position ( entity . Parent , lead ) ;
Draw every moving body at the drawn time T = ExactTime + Lead
Planets, belts, wells, loot, mines, stations, the chunk reticle and the flow
clock are evaluated through DrawAhead (Time, Orbit, Height, Position) at one
drawn time; the rail law lives once in Zone.Offset; OrbitPositionAt, GetHeight,
ChunkPose, EvaluateBelt and RideHeight take a time. Fixes the planet jumping
against a co-moving ship at display rates above the step rate.
Not yet built or run (Unity presenter files are verified by compile only on
Starfire).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:26:36 +02:00
// A body on a rail (a station) is on its orbit at T: the rail is closed form, not integrated.
var planar = entity is OrbitalEntity { OrbitData : var orbit } & & orbit . IsSet ( )
? Orbit ( entity . Zone , orbit , lead )
: entity . Position . xz + entity . Velocity * lead ;
return float3 ( planar . x , entity . RideHeight ( planar , Time ( entity . Zone , lead ) ) , planar . y ) ;
2026-10-15 17:45:56 +02:00
}
2026-10-08 18:26:02 +02:00
Presenters read the drawn pose: HUD markers, wells, rounds, ping, shield hit, compass, loot view, guided aim
HUD target, lock, incoming-lock, bearing and sweep markers anchor on DrawAhead.Position / AtBearing at Clock.Lead.
GravityLowering's record fill is DrawAhead.Well (pure, pinned at non-zero lead). Rounds take DrawAhead.Time
instead of float Zone.Time + lead. Sensor ping, shield hit, compass icons, wormhole bearing, loot ViewOrigin and
the guided-projectile aim read the drawn pose. Not built in Unity yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:41:49 +02:00
// The point `distance` out along a planar bearing from the ship's drawn position: where the HUD anchors a marker to the
// ship the camera follows (an incoming lock, a remembered contact, a ping's flash).
public static float3 AtBearing ( Entity origin , float2 bearing , float distance , float lead )
{
var at = Position ( origin , lead ) ;
return float3 ( at . x + bearing . x * distance , at . y , at . z + bearing . y * distance ) ;
}
2026-10-12 03:34:50 +02:00
// A free body (a vapour cloud) drawn where the frame falls lead sim seconds after the last step, by the velocity
// that step moved it with: the launch velocity and the drift together.
public static float2 Position ( KinematicBody body , float lead ) = > body . Position + body . TotalVelocity * lead ;
2026-10-08 18:26:02 +02:00
// The ship's rotation turned about its own up axis by the rate its last step turned, for lead seconds.
2026-10-08 23:39:30 +02:00
public static quaternion Rotation ( Ship ship , float lead ) = >
ship . WormholeAnimationInProgress ? ship . Rotation : Turned ( ship . Rotation , ship . TurnRate * lead ) ;
2026-10-08 21:00:16 +02:00
// q turned about its own up axis by the angle, in the sense Direction turns for a positive TurnRate.
public static quaternion Turned ( quaternion q , float angle )
2026-10-08 18:26:02 +02:00
{
var u = float3 ( q . x , q . y , q . z ) ;
var up = float3 ( 0 , 1 , 0 ) ;
up + = 2 * ( q . w * cross ( u , up ) + cross ( u , cross ( u , up ) ) ) ;
2026-10-08 21:00:16 +02:00
var half = angle * . 5f ;
2026-10-08 18:26:02 +02:00
var s = sin ( half ) ;
var t = new quaternion ( up . x * s , up . y * s , up . z * s , cos ( half ) ) ;
// Hamilton product t * q: CultMath carries no quaternion product (a gap in CultMath, not filled here).
return normalize ( new quaternion (
t . w * q . x + t . x * q . w + t . y * q . z - t . z * q . y ,
t . w * q . y - t . x * q . z + t . y * q . w + t . z * q . x ,
t . w * q . z + t . x * q . y - t . y * q . x + t . z * q . w ,
t . w * q . w - t . x * q . x - t . y * q . y - t . z * q . z ) ) ;
}
2026-10-09 22:31:12 +02:00
2026-10-16 00:50:29 +02:00
// A ballistic round at the drawn time (DrawAhead.Time, passed in by the caller; the target's lead is that time less its zone's step time, derived here, never a second input): on its frozen line while the outcome is unknown or a
2026-10-09 23:27:22 +02:00
// miss, with the barrel's offset from that line decaying to nothing over blendTime sim seconds from the moment of firing.
// A round the sim published as a hit or burst steers from its line, from the commit tick, onto where it ends by the
// time damage lands: a burst's frozen point, else the impact cell on the target's drawn pose; and is held there after.
2026-10-16 00:50:29 +02:00
public static float3 Round ( in PendingShot shot , ShotOutcome known , float3 barrel , float blendTime , double drawnTime )
2026-10-09 23:27:22 +02:00
{
Presenters read the drawn pose: HUD markers, wells, rounds, ping, shield hit, compass, loot view, guided aim
HUD target, lock, incoming-lock, bearing and sweep markers anchor on DrawAhead.Position / AtBearing at Clock.Lead.
GravityLowering's record fill is DrawAhead.Well (pure, pinned at non-zero lead). Rounds take DrawAhead.Time
instead of float Zone.Time + lead. Sensor ping, shield hit, compass icons, wormhole bearing, loot ViewOrigin and
the guided-projectile aim read the drawn pose. Not built in Unity yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:41:49 +02:00
var time = ( float ) drawnTime ;
2026-10-09 23:27:22 +02:00
var drawn = Lifted ( shot , Line ( shot , known ? . Result , time ) , barrel , blendTime , time ) ;
if ( known = = null | | known . Result = = ShotResult . Miss ) return drawn ;
var end = known . HasBurstPoint
? float3 ( known . BurstPoint . x , shot . FireOrigin . y , known . BurstPoint . y )
2026-10-16 00:50:29 +02:00
: Impact ( known . Target , known . Cell , ( float ) ( drawnTime - known . Target . Zone . ExactTime ) ) ;
2026-10-09 23:27:22 +02:00
var commit = shot . ArrivalTime - known . ArrivalIn ;
var steer = shot . ArrivalTime > commit ? saturate ( ( time - commit ) / ( shot . ArrivalTime - commit ) ) : 1f ;
return drawn + ( end - drawn ) * steer ;
}
2026-10-09 22:31:12 +02:00
2026-10-12 03:58:34 +02:00
// The impact cell's centre on the target's drawn pose.
2026-10-09 23:27:22 +02:00
public static float3 Impact ( Entity target , int2 cell , float lead )
2026-10-12 03:58:34 +02:00
{
var world = target . ToWorldPoint ( ( float2 ) cell ) ;
return Carried ( target , float3 ( world . x , target . Position . y , world . y ) , lead ) ;
}
// A world point on the target's sim pose, moved and turned with the target for lead seconds exactly as Position and
// Rotation draw the entity: the one owner of where a point on a hull is drawn, for the drawn round's end and for
// impact presenters.
public static float3 Carried ( Entity target , float3 simPoint , float lead )
2026-10-09 23:27:22 +02:00
{
var at = Position ( target , lead ) ;
2026-10-12 03:58:34 +02:00
var offset = float2 ( simPoint . x - target . Position . x , simPoint . z - target . Position . z ) ;
2026-10-09 23:29:32 +02:00
var turned = mul ( offset , CultMath . float2x2 . Rotate ( Turn ( target , lead ) ) ) ;
2026-10-12 03:58:34 +02:00
return float3 ( at . x + turned . x , at . y + ( simPoint . y - target . Position . y ) , at . z + turned . y ) ;
2026-10-09 23:27:22 +02:00
}
2026-10-09 22:31:12 +02:00
// Whether the drawn flight is over by time: only a published miss ends by the clock. A hit or burst ends on ShotResolved.
Presenters read the drawn pose: HUD markers, wells, rounds, ping, shield hit, compass, loot view, guided aim
HUD target, lock, incoming-lock, bearing and sweep markers anchor on DrawAhead.Position / AtBearing at Clock.Lead.
GravityLowering's record fill is DrawAhead.Well (pure, pinned at non-zero lead). Rounds take DrawAhead.Time
instead of float Zone.Time + lead. Sensor ping, shield hit, compass icons, wormhole bearing, loot ViewOrigin and
the guided-projectile aim read the drawn pose. Not built in Unity yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:41:49 +02:00
public static bool RoundOver ( in PendingShot shot , ShotOutcome known , double drawnTime ) = >
known ! = null & & known . Result = = ShotResult . Miss & & ( float ) drawnTime > = RoundEnd ( shot , ShotResult . Miss ) ;
2026-10-09 23:27:22 +02:00
// The sim time a round's drawn line ends, for what is known of its outcome. A round that hit or burst (or whose outcome
// is not yet published) ends at its ArrivalTime; a round the sim has published as a Miss flies on to the weapon's frozen
// range and never ends short of its own arrival (a direct round's FireRange is measured to any target, in range or not).
public static float RoundEnd ( in PendingShot shot , ShotResult ? known ) = >
known = = ShotResult . Miss
? max ( shot . ArrivalTime , shot . FireTime + ( shot . Speed > . 01f ? shot . MaxRange / shot . Speed : 0f ) )
: shot . ArrivalTime ;
// The frozen line a round follows until it steers: from its frozen origin along its frozen direction at its frozen
// speed, held at RoundEnd once it has flown that far. With no outcome known the round is drawn no further than its
// arrival point and waits there for the sim to publish one.
private static float2 Line ( in PendingShot shot , ShotResult ? known , float time )
{
if ( shot . Speed < = . 01f ) return shot . FireOrigin . xz ;
var flown = clamp ( time - shot . FireTime , 0f , RoundEnd ( shot , known ) - shot . FireTime ) ;
return shot . FireOrigin . xz + shot . TravelDirection * ( shot . Speed * flown ) ;
}
private static float Turn ( Entity entity , float lead ) = >
entity is Ship { WormholeAnimationInProgress : true } ? 0f : entity . TurnRate * lead ;
2026-10-09 22:31:12 +02:00
private static float3 Lifted ( in PendingShot shot , float2 planar , float3 barrel , float blendTime , float time )
{
var blend = blendTime > 0 ? saturate ( 1 - ( time - shot . FireTime ) / blendTime ) : 0f ;
return float3 ( planar . x , shot . FireOrigin . y , planar . y ) + ( barrel - shot . FireOrigin ) * blend ;
}
2026-10-08 18:26:02 +02:00
}