/* 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/. */ using System.Collections.Generic; using System.Linq; using CultMath; using GameCult.Caching; using static CultMath.math; // Pure presentation statics over sim state, never called by ServerShared, with no state of their own: the sim owns every // 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 // (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. public static class DrawAhead { // 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)); // 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; // 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); 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 // 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."); // 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 { public readonly int Rock; public readonly float Scale; public RockDraw(int rock, float scale) { Rock = rock; Scale = scale; } } // 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. public sealed class BeltDraw { private readonly float[] _radii; private readonly int[][] _rocks; public T[] Meshes { get; } public RockDraw[][] ByMesh { get; } public BeltDraw(IReadOnlyList unwornSizes, IReadOnlyList candidates, System.Func radius, int keep, string beltKey) { 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."); var radii = candidates.Select(radius).ToArray(); 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(); var rockByRank = Enumerable.Range(0, unwornSizes.Count).OrderBy(i => unwornSizes[i]).ThenBy(i => i).ToArray(); 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(); ByMesh = _rocks.Select(r => r.Select(rock => new RockDraw(rock, float.NaN)).ToArray()).ToArray(); } // 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 = 14695981039346656037UL; foreach (var ch in beltKey) { h ^= ch; h *= 1099511628211UL; } h ^= (uint)candidate; h *= 1099511628211UL; h ^= h >> 32; h *= 0x9E3779B97F4A7C15UL; h ^= h >> 29; return h; } public RockDraw[][] Draw(IReadOnlyList 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; } } public static BeltDraw DrawListOf(AsteroidBelt belt, PlanetSettings settings, IReadOnlyList meshes, System.Func 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); // 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. // 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); // 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); } // 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); } // 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; // The ship's rotation turned about its own up axis by the rate its last step turned, for lead seconds. public static quaternion Rotation(Ship ship, float lead) => ship.WormholeAnimationInProgress ? ship.Rotation : Turned(ship.Rotation, ship.TurnRate * lead); // 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) { 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))); var half = angle * .5f; 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)); } // 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 // 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. public static float3 Round(in PendingShot shot, ShotOutcome known, float3 barrel, float blendTime, double drawnTime) { var time = (float) drawnTime; 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) : Impact(known.Target, known.Cell, (float) (drawnTime - known.Target.Zone.ExactTime)); var commit = shot.ArrivalTime - known.ArrivalIn; var steer = shot.ArrivalTime > commit ? saturate((time - commit) / (shot.ArrivalTime - commit)) : 1f; return drawn + (end - drawn) * steer; } // The impact cell's centre on the target's drawn pose. public static float3 Impact(Entity target, int2 cell, float lead) { 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) { var at = Position(target, lead); var offset = float2(simPoint.x - target.Position.x, simPoint.z - target.Position.z); var turned = mul(offset, CultMath.float2x2.Rotate(Turn(target, lead))); return float3(at.x + turned.x, at.y + (simPoint.y - target.Position.y), at.z + turned.y); } // Whether the drawn flight is over by time: only a published miss ends by the clock. A hit or burst ends on ShotResolved. public static bool RoundOver(in PendingShot shot, ShotOutcome known, double drawnTime) => known != null && known.Result == ShotResult.Miss && (float) drawnTime >= RoundEnd(shot, ShotResult.Miss); // 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; 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; } }