SquishyFace.cs
using System;
using SquishyTerry.Sim;

namespace SquishyTerry;

/// <summary>
/// The squishy face toy: runs the recovered soft-body simulation, lets the player grab and pull the
/// face with the mouse, and renders it with dynamic meshes.
///
/// Coordinates: the simulation works in its original space (+Y up, face looking along +Z, unit-sphere
/// head). This object's local space maps sim (x, y, z) to (z, x, y), so the face looks along the
/// object's forward (+X) and the head is 1 unit in radius - scale the GameObject to size it.
/// </summary>
[Title( "Squishy Face" )]
[Category( "Squishy Terry" )]
[Icon( "face" )]
public sealed class SquishyFace : Component
{
	public enum Look
	{
		/// <summary>The recovered game as it was.</summary>
		Original,
		/// <summary>Reshaped into the s&amp;box citizen.</summary>
		Terry,
	}

	[Property] public Look Style { get; set; } = Look.Terry;


	/// <summary>Linear skin albedo; the original's is e^2.2 = (1, 0.592, 0.394).</summary>
	[Property, Group( "Skin" )] public Vector3 SkinAlbedo { get; set; } = new( 1.0f, 0.592f, 0.394f );
	/// <summary>Linear mouth colour; the original's is ((126, 13, 12) * 0.5 / 255)^2.2.</summary>
	[Property, Group( "Skin" )] public Vector3 MouthAlbedo { get; set; } = new( 0.046f, 0.0003f, 0.0003f );
	/// <summary>Linear nose colour for the Terry style (the citizen's nose is redder than his skin).</summary>
	[Property, Group( "Skin" )] public Vector3 NoseAlbedo { get; set; } = new( 1.0f, 0.42f, 0.3f );
	[Property, Group( "Skin" ), Range( 0, 1 )] public float SkinRoughness { get; set; } = 0.3f;
	[Property, Group( "Skin" ), Range( 0, 1 )] public float SkinF0 { get; set; } = 0.35f;
	[Property, Group( "Skin" ), Range( 0, 4 )] public float Transmission { get; set; } = 0.5f;
	/// <summary>Widens the light-through-skin falloff; stands in for the original's screen-space SSS blur.</summary>
	[Property, Group( "Skin" ), Range( 0.05f, 1 )] public float ScatterWidth { get; set; } = 0.08f;
	[Property, Group( "Skin" )] public Color HairColor { get; set; } = new( 152 / 255f, 100 / 255f, 52 / 255f );

	[Property, Group( "Eyes" )] public Vector3 ScleraAlbedo { get; set; } = new( 0.85f, 0.83f, 0.8f );
	[Property, Group( "Eyes" )] public Vector3 IrisAlbedo { get; set; } = new( 0.05f, 0.028f, 0.016f );
	[Property, Group( "Eyes" )] public Vector3 PupilAlbedo { get; set; } = new( 0.004f, 0.003f, 0.003f );
	/// <summary>Iris radius as a fraction of the eyeball radius.</summary>
	[Property, Group( "Eyes" ), Range( 0, 1 )] public float IrisSize { get; set; } = 0.42f;
	[Property, Group( "Eyes" ), Range( 0, 1 )] public float PupilSize { get; set; } = 0.2f;

	/// <summary>Place the scene camera in front of the face like the original (camera fixed, head turns).</summary>
	[Property] public bool ControlCamera { get; set; } = true;

	/// <summary>Use the original's blue vignette background (the complement of the skin colour).</summary>
	[Property] public bool OriginalBackground { get; set; } = true;

	/// <summary>Picture drawn behind the head, cropped to fill the screen. Empty for the plain colour.</summary>
	[Property] public Material BackgroundMaterial { get; set; }

	/// <summary>The skin and eye materials (shaders/squishy_skin and squishy_eye).</summary>
	[Property, Group( "Materials" )] public Material SkinMaterial { get; set; }
	[Property, Group( "Materials" )] public Material EyeMaterial { get; set; }

	/// <summary>Width / height of the background picture, to crop it to the screen.</summary>
	[Property] public float BackgroundAspect { get; set; } = 16f / 9f;

	const string DefaultBackground = "materials/squishy_background.vmat";

	/// <summary>Background blur radius as a fraction of the screen height (0 = sharp).</summary>
	[Property, Range( 0, 0.1f )] public float BackgroundBlur { get; set; } = 0.025f;

	[Property, Range( 0, 2 )] public float BackgroundBrightness { get; set; } = 1f;

	/// <summary>Reverse triangle winding if the mesh renders inside-out.</summary>
	[Property] public bool FlipWinding { get; set; } = false;

	/// <summary>Upper limit on simulation steps per second (one step per rendered frame below this).</summary>
	const float MaxStepsPerSecond = 240f;

	public Face Sim { get; private set; }

	/// <summary>Triangle currently grabbed, or -1.</summary>
	public int DraggingTriangle { get; private set; } = -1;

	/// <summary>Last released triangle until it snaps back (drives the "slap" sound).</summary>
	public int LastDraggingTriangle { get; set; } = -1;

	public bool IsDragging => DraggingTriangle != -1;

	/// <summary>Cartoon "dead" eyes: X pupils, staring straight at the player instead of following the cursor.</summary>
	[Property, Group( "Eyes" )] public bool XEyes { get; set; }
	float _xEyes; // animated 0..1

	/// <summary>No grabbing the face (during the fatality and the hook).</summary>
	public bool InputLocked { get; set; }

	/// <summary>
	/// Rigid offset of the whole head from its spot (world units): the stage hook drags him off and
	/// the next Terry slides back in. Everything that sits on the skin follows it.
	/// </summary>
	public Vector3 HeadOffset { get; set; }

	// The stage hook's grip on the skin: a triangle pulled towards a world point, like a mouse grab
	// but never counted as the player's.
	int _hookTriangle = -1;
	Vector3 _hookTarget;

	Transform HeadTransform => WorldTransform.WithPosition( WorldPosition + HeadOffset );
	const float XEyesDrop = 0.45f;

	/// <summary>Frames stepped since start (the original's framesSinceStart).</summary>
	public int FramesSinceStart { get; private set; }

	/// <summary>Frame of the first grab, or -1.</summary>
	public int FramesAtFirstDrag { get; private set; } = -1;

	/// <summary>Fade in from black over the first 30 frames.</summary>
	public float FadeIn => SmoothStep( 0, 30, FramesSinceStart );

	/// <summary>"pull" prompt opacity: fades in with the scene, fades out over 60 frames after the first grab.</summary>
	public float PromptAlpha
	{
		get
		{
			float a = SmoothStep( 0, 30, FramesSinceStart );
			if ( FramesAtFirstDrag == -1 ) return a;
			int since = FramesSinceStart - FramesAtFirstDrag;
			return since > 60 ? 0 : a * SmoothStep( 60, 0, since );
		}
	}

	/// <summary>World point just under the chin, where the prompt hangs.</summary>
	public Vector3 PromptAnchor => WorldTransform.PointToWorld( ToLocal( new Vec3( 0, _chinY - 0.05f, 0 ) ) );

	float _chinY = -0.85f;

	static float SmoothStep( float edge0, float edge1, float x )
	{
		float t = Math.Clamp( (x - edge0) / (edge1 - edge0), 0f, 1f );
		return t * t * (3f - 2f * t);
	}

	bool _wasDown;
	int _quietFrames;

	bool _renderersDirty = true;

	/// <summary>Face vertex positions right after initialization (the JS "restBasePositions").</summary>
	Vec3[] _initialPos;

	Quat _faceRotation = Quat.Identity;
	Vec3 _lookDirection = new( 0, 0, 1 );
	float _stepAccumulator;
	float _slowSteps = 1f;
	float _fov = 90f;
	float _targetFov = 90f;

	GameObject _head;
	DynamicMesh _skin, _hair, _nose;
	readonly DynamicMesh[] _eyes = new DynamicMesh[2];
	readonly DynamicMesh[] _lids = new DynamicMesh[2];
	readonly DynamicMesh[] _ears = new DynamicMesh[2];

	const float CameraDistance = 1.7f;

	protected override void OnStart()
	{
		Sim = new Face( Style == Look.Terry ? FaceStyle.Terry : FaceStyle.Original );

		// Reshaped faces keep settling after construction. Let them come to rest before the skin
		// layer records its rest spacing, or the settling compresses the skin into ripples.
		if ( Style != Look.Original )
		{
			for ( int i = 0; i < 300; i++ )
				Sim.Step( Quat.Identity, false, 0, Vec3.Zero, 0.97f );
		}

		_initialPos = Geometry.Copy( Sim.Pos );
		_volumeScale = 1.884478f / Sim.ComputeVolume();
		_areaScale = 7.386105f / Sim.ComputeArea();
		_chinY = MathF.Min( -0.85f, MinY( Sim.Pos ) );
		BuildRenderers();
		UploadMeshes();
		UpdateAttributes();
	}

	protected override void OnDestroy()
	{
		_gpuSkin?.Dispose();
		_gpuSkin = null;
		_head?.Destroy();
		_background?.Destroy();
	}

	protected override void OnUpdate()
	{
		if ( Sim is null ) return;

		if ( ControlCamera ) UpdateCamera();

		// Keep the cursor visible so you can see where you are pulling.
		Mouse.Visibility = MouseVisibility.Visible;
		var knife = Components.Get<KnifeDrop>();
		Mouse.CursorType = IsDragging || knife is { Held: true } ? "grabbing" : "grab";

		GetMouseRay( out var camPos, out var rayDir );

		bool down = Input.Down( "Attack1" ) && !InputLocked;
		bool pressed = down && !_wasDown;
		_wasDown = down;
		if ( InputLocked ) DraggingTriangle = -1;
		if ( _head is not null ) _head.WorldPosition = WorldPosition + HeadOffset;

		// A click on the fatality knife grabs the knife instead of the face.
		if ( pressed && knife is not null && Scene.Camera is { } cam && knife.TryGrab( cam.ScreenPixelToRay( Mouse.Position ) ) ) { }
		else if ( pressed )
		{
			var inv = _faceRotation.Inverse;
			DraggingTriangle = Sim.RayIntersect( inv.Rotate( camPos ), inv.Rotate( rayDir ) );
			if ( DraggingTriangle != -1 )
			{
				LastDraggingTriangle = -1;
				if ( FramesAtFirstDrag == -1 ) FramesAtFirstDrag = FramesSinceStart;
			}
		}
		else if ( !down && DraggingTriangle != -1 )
		{
			LastDraggingTriangle = DraggingTriangle;
			DraggingTriangle = -1;
		}

		if ( Input.MouseWheel.y != 0 )
			_targetFov = Input.MouseWheel.y < 0 ? 111.6f : 90f;

		// One simulation step per rendered frame, like the original's requestAnimationFrame loop, so it
		// runs at your monitor's refresh rate. Capped so an uncapped frame rate can't run it absurdly fast.
		// In slow motion only that fraction of frames step, so the face moves in slow motion too.
		SlowMo.Update( RealTime.Now, RealTime.Delta );
		_stepAccumulator += Time.Delta;
		_slowSteps += SlowMo.Scale;
		bool stepped = false;
		if ( _stepAccumulator >= 1f / MaxStepsPerSecond && _slowSteps >= 1f )
		{
			_slowSteps -= 1f;
			_stepAccumulator = 0;
			FixedStep( camPos, rayDir );
			stepped = true;
			Stepped?.Invoke();
		}

		if ( !stepped ) return;

		// The skin cloth is the expensive part; run it once per rendered frame, not per sim step.
		UpdateSkinLayer();

		// Once everything has been still for half a second, stop re-uploading the meshes.
		if ( IsDragging || Sim.ComputeAverageSpeed() > 1e-5f ) _quietFrames = 0;
		else _quietFrames++;

		if ( _quietFrames < 30 || _renderersDirty )
		{
			UploadMeshes();
			_renderersDirty = false;
		}
		UpdateAttributes();

	}

	/// <summary>One 60 Hz frame of the original game loop (ie.prototype.update).</summary>
	void FixedStep( Vec3 camPos, Vec3 rayDir )
	{
		_fov += 0.05f * (_targetFov - _fov);

		// The stage hook grabs exactly like the mouse does: same physics, driven by the ray from the
		// camera through the hook instead of the cursor (and never counted as the player's squish).
		int dragTriangle = DraggingTriangle;
		if ( dragTriangle == -1 && _hookTriangle >= 0 )
		{
			dragTriangle = _hookTriangle;
			HookRay( out camPos, out rayDir );
		}
		bool dragging = dragTriangle != -1;

		var targetRotation = Quat.Identity;
		var target = Vec3.Zero;
		if ( dragging )
		{
			var s = RestPosition( dragTriangle );
			var u = s.Normalized;
			var h = new Vec3( u.X, u.Y, 0 ).Normalized;
			var hit = IntersectPlane( s, new Vec3( 0, 0, 1 ), camPos, rayDir );
			float lateral = Vec3.Dot( hit - s, h );
			float dz = 0;
			if ( lateral < 0 )
			{
				// Pulling across the face turns the whole head towards the pull.
				float maxAngle = MathF.Acos( u.Z ) + 0.1f;
				var axis = Vec3.Cross( h, new Vec3( 0, 0, 1 ) ).Normalized;
				float angle = MathF.Min( maxAngle, 2f * MathF.Abs( -lateral ) );
				targetRotation = Quat.FromAxisAngle( axis, angle );
				dz = 0.15f * angle;
			}

			var t = _faceRotation.Rotate( s );
			target = _faceRotation.Inverse.Rotate( IntersectPlane( t + new Vec3( 0, 0, dz ), new Vec3( 0, 0, 1 ), camPos, rayDir ) );
		}

		_faceRotation = Quat.Slerp( _faceRotation, targetRotation, dragging ? 0.1f : 0.05f );

		Sim.Step( Quat.Identity, dragging, dragging ? dragTriangle : 0, target, dragging ? 0.8f : 0.97f );

		// Snap-back detection for the slap sound.
		if ( LastDraggingTriangle != -1 )
		{
			int v = Sim.Indices[LastDraggingTriangle * 3];
			if ( Sim.Pos[v].Length < RestPosition( LastDraggingTriangle ).Length )
			{
				LastDraggingTriangle = -1;
				Slapped?.Invoke();
			}
		}

		UpdateLookDirection( camPos, rayDir );
		FramesSinceStart++;
	}

	void UpdateLookDirection( Vec3 camPos, Vec3 rayDir )
	{
		// Both eyes share one look direction in the original; it is computed per eye but the last wins.
		var focus = IntersectPlane( new Vec3( 0, 0, 1.2f ), new Vec3( 0, 0, 1 ), camPos, rayDir );
		for ( int e = 0; e < 2; e++ )
		{
			int v = Sim.EyeVertex[e];
			var pos = _faceRotation.Rotate( Sim.Pos[v] );
			var normal = _faceRotation.Rotate( Sim.Normals[v] );
			var dir = ConeClamp( normal, (focus - pos).Normalized, 1.3f );
			_lookDirection = SlerpVector( _lookDirection, dir, 0.7f );
		}
	}

	/// <summary>Rest position of a triangle's first vertex (the JS getRestPosition).</summary>
	public Vec3 RestPosition( int triangle ) => _initialPos[Sim.Indices[triangle * 3]];

	// --- World-space surface queries (for things stuck in the face, like the fatality knife) ---

	/// <summary>Where a world-space ray first hits the skin: the triangle and barycentric coordinates.</summary>
	public bool TraceSurface( Ray ray, out int triangle, out Vector3 bary )
	{
		triangle = -1;
		bary = default;
		if ( Sim is null ) return false;

		var inv = _faceRotation.Inverse;
		var origin = inv.Rotate( ToSim( HeadTransform.PointToLocal( ray.Position ) ) );
		var dir = inv.Rotate( ToSim( WorldRotation.Inverse * ray.Forward ) ).Normalized;

		float best = float.MaxValue;
		var idx = Sim.Indices;
		var pos = Sim.Pos;
		for ( int t = 0; t + 2 < idx.Length; t += 3 )
		{
			if ( Geometry.RayTriangle( origin, dir, pos[idx[t]], pos[idx[t + 1]], pos[idx[t + 2]], out float d ) && d > 0f && d < best )
			{
				best = d;
				triangle = t / 3;
			}
		}
		if ( triangle == -1 ) return false;

		// Barycentric coordinates of the hit point.
		var a = pos[idx[triangle * 3]];
		var v0 = pos[idx[triangle * 3 + 1]] - a;
		var v1 = pos[idx[triangle * 3 + 2]] - a;
		var v2 = origin + dir * best - a;
		float d00 = Vec3.Dot( v0, v0 ), d01 = Vec3.Dot( v0, v1 ), d11 = Vec3.Dot( v1, v1 );
		float d20 = Vec3.Dot( v2, v0 ), d21 = Vec3.Dot( v2, v1 );
		float denom = d00 * d11 - d01 * d01;
		if ( MathF.Abs( denom ) < 1e-12f ) return false;
		float v = (d11 * d20 - d01 * d21) / denom;
		float w = (d00 * d21 - d01 * d20) / denom;
		bary = new Vector3( 1f - v - w, v, w );
		return true;
	}

	/// <summary>The current world position of a point on the skin (it moves as the face squishes).</summary>
	public Vector3 SurfacePoint( int triangle, Vector3 bary )
	{
		var idx = Sim.Indices;
		var p = Sim.Pos[idx[triangle * 3]] * bary.x + Sim.Pos[idx[triangle * 3 + 1]] * bary.y + Sim.Pos[idx[triangle * 3 + 2]] * bary.z;
		return HeadTransform.PointToWorld( ToLocal( _faceRotation.Rotate( p ) ) );
	}

	/// <summary>The current outward world-space normal of a point on the skin.</summary>
	public Vector3 SurfaceNormal( int triangle, Vector3 bary )
	{
		var idx = Sim.Indices;
		var n = Sim.Normals[idx[triangle * 3]] * bary.x + Sim.Normals[idx[triangle * 3 + 1]] * bary.y + Sim.Normals[idx[triangle * 3 + 2]] * bary.z;
		return (WorldRotation * ToLocal( _faceRotation.Rotate( n ) )).Normal;
	}

	/// <summary>The stage hook grabs this triangle and pulls it towards <paramref name="worldTarget"/>.</summary>
	public void HookGrab( int triangle, Vector3 worldTarget )
	{
		_hookTriangle = triangle;
		_hookTarget = worldTarget;
	}

	public void HookRelease() => _hookTriangle = -1;

	/// <summary>
	/// The head is being accelerated as a whole (world units / s²): in the head's frame that's an
	/// inertial force the other way, so the soft body lags and sloshes like it has mass.
	/// </summary>
	public void Accelerate( Vector3 worldAcceleration, float dt )
	{
		if ( Sim is null ) return;
		// Simulation velocities are per 60 Hz frame, in head units.
		var dv = WorldDirectionToSim( worldAcceleration ) * (-dt / (60f * WorldScale.x));
		var vel = Sim.Vel;
		for ( int i = 0; i < vel.Length; i++ ) vel[i] += dv;
	}

	/// <summary>Snaps the face back to its rest shape, still and facing forward (a fresh Terry).</summary>
	public void ResetFace()
	{
		if ( Sim is null ) return;
		DraggingTriangle = -1;
		LastDraggingTriangle = -1;
		_hookTriangle = -1;
		for ( int i = 0; i < Sim.Pos.Length; i++ )
		{
			Sim.Pos[i] = _initialPos[i];
			Sim.PrevPos[i] = _initialPos[i];
			Sim.Pred[i] = _initialPos[i];
			Sim.Vel[i] = Vec3.Zero;
			Sim.PrevVel[i] = Vec3.Zero;
		}
		_faceRotation = Quat.Identity;
		_quietFrames = 0;
		_renderersDirty = true;
	}

	/// <summary>A world direction in simulation space (the frame of <see cref="Face.Pos"/>).</summary>
	public Vec3 WorldDirectionToSim( Vector3 world ) => _faceRotation.Inverse.Rotate( ToSim( WorldRotation.Inverse * world ) );

	/// <summary>Converts a world direction into the head's own frame (so it turns with the head), and back.</summary>
	public Vector3 DirectionToHead( Vector3 world ) => ToLocal( _faceRotation.Inverse.Rotate( ToSim( WorldRotation.Inverse * world ) ) );
	public Vector3 DirectionFromHead( Vector3 head ) => WorldRotation * ToLocal( _faceRotation.Rotate( ToSim( head ) ) );

	/// <summary>Fired when a released point snaps back inside its rest radius (the "slap" sound).</summary>
	public event Action Slapped;

	/// <summary>Fired after every simulation step (one per rendered frame, capped).</summary>
	public event Action Stepped;

	/// <summary>The settled starting shape, per vertex (read-only use: scoring measures against it).</summary>
	internal Vec3[] InitialPositions => _initialPos;

	// The synth was tuned on the original face (rest volume 1.8845, area 7.3861). Scale the measured
	// values so a reshaped face at rest sounds the same; the original style's factors are ~1.
	float _volumeScale = 1f, _areaScale = 1f;

	/// <summary>Enclosed volume, normalised to the original face's rest volume, for the audio.</summary>
	public float AudioVolume => Sim.ComputeVolume() * _volumeScale;

	/// <summary>Surface area, normalised to the original face's rest area, for the audio.</summary>
	public float AudioArea => Sim.ComputeArea() * _areaScale;

	static float MinY( Vec3[] pos )
	{
		float y = float.MaxValue;
		foreach ( var p in pos ) y = MathF.Min( y, p.Y );
		return y;
	}

	static Vec3 IntersectPlane( Vec3 point, Vec3 normal, Vec3 origin, Vec3 dir )
	{
		float t = Vec3.Dot( point - origin, normal ) / Vec3.Dot( normal, dir );
		return origin + dir * t;
	}

	static Vec3 ConeClamp( Vec3 axis, Vec3 dir, float maxAngle )
	{
		float d = Vec3.Dot( axis, dir );
		if ( d < -0.9999f ) return dir;
		float angle = MathF.Acos( Math.Clamp( d, -1f, 1f ) );
		if ( angle <= maxAngle ) return dir;
		var rotAxis = Vec3.Cross( dir, axis ).Normalized;
		return Quat.FromAxisAngle( rotAxis, angle - maxAngle ).Rotate( dir );
	}

	static Vec3 SlerpVector( Vec3 a, Vec3 b, float t )
	{
		float d = Math.Clamp( Vec3.Dot( a, b ), -1f, 1f );
		float theta = MathF.Acos( d ) * t;
		var rel = (b - a * d);
		if ( rel.LengthSquared < 1e-12f ) return b;
		return a * MathF.Cos( theta ) + rel.Normalized * MathF.Sin( theta );
	}

	// --- Space conversion -------------------------------------------------------------------

	static Vector3 ToLocal( Vec3 v ) => new( v.Z, v.X, v.Y );
	static Vec3 ToSim( Vector3 v ) => new( v.y, v.z, v.x );
	static Rotation ToLocal( Quat q ) => new( q.Z, q.X, q.Y, q.W );

	/// <summary>The ray from the camera through the stage hook's grip, in simulation space (like <see cref="GetMouseRay"/>).</summary>
	void HookRay( out Vec3 origin, out Vec3 dir )
	{
		origin = new Vec3( 0, 0, CameraDistance );
		dir = new Vec3( 0, 0, -1 );
		var camera = Scene.Camera;
		if ( camera is null ) return;

		var eye = camera.WorldPosition;
		origin = ToSim( HeadTransform.PointToLocal( eye ) );
		dir = ToSim( WorldRotation.Inverse * (_hookTarget - eye).Normal ).Normalized;
	}

	void GetMouseRay( out Vec3 origin, out Vec3 dir )
	{
		origin = new Vec3( 0, 0, CameraDistance );
		dir = new Vec3( 0, 0, -1 );
		var camera = Scene.Camera;
		if ( camera is null ) return;

		var ray = camera.ScreenPixelToRay( Mouse.Position );
		origin = ToSim( HeadTransform.PointToLocal( ray.Position ) );
		dir = ToSim( WorldRotation.Inverse * ray.Forward ).Normalized;
	}

	void UpdateCamera()
	{
		var camera = Scene.Camera;
		if ( camera is null ) return;
		camera.WorldPosition = WorldTransform.PointToWorld( ToLocal( new Vec3( 0, 0, CameraDistance ) ) );
		camera.WorldRotation = Rotation.LookAt( -WorldRotation.Forward, WorldRotation.Up );
		camera.FovAxis = CameraComponent.Axis.Vertical;
		camera.FieldOfView = _fov;
		camera.ZNear = 0.1f * WorldScale.x;

		if ( OriginalBackground )
		{
			// background.frag: (u_color - vignette) * 0.6, u_color = hsv(skin hue + 0.5, 0.9, 0.4).
			camera.ClearFlags = ClearFlags.All;
			camera.BackgroundColor = BackgroundColor();
			var vignette = camera.GameObject.Components.Get<Vignette>() ?? camera.GameObject.Components.Create<Vignette>();
			vignette.Color = Color.Black;
			vignette.Intensity = 0.35f;
			vignette.Smoothness = 0.9f;
			vignette.Roundness = 1f;
		}

		UpdateBackground( camera );
	}

	GameObject _background;
	ModelRenderer _backgroundRenderer;
	Material _backgroundMaterial;

	/// <summary>Keeps the background picture on a quad far behind the head, exactly filling the view.</summary>
	void UpdateBackground( CameraComponent camera )
	{
		// A compiled material (the picture is baked into it), so it loads when published or hosted.
		BackgroundMaterial ??= Material.Load( DefaultBackground );

		bool show = BackgroundMaterial is not null;
		if ( !show )
		{
			if ( _background is not null ) _background.Enabled = false;
			return;
		}

		if ( _background is null )
		{
			_background = new GameObject( GameObject, true, "Background" );
			// Local only: each player builds their own face; never replicate these procedural meshes.
			_background.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
			_background.NetworkMode = NetworkMode.Never;
			var vertices = new List<Vertex>
			{
				new( new Vector3( 0, 0.5f, 0.5f ), Vector3.Backward, Vector3.Right, new Vector4( 0, 0, 0, 0 ) ),
				new( new Vector3( 0, -0.5f, 0.5f ), Vector3.Backward, Vector3.Right, new Vector4( 1, 0, 0, 0 ) ),
				new( new Vector3( 0, -0.5f, -0.5f ), Vector3.Backward, Vector3.Right, new Vector4( 1, 1, 0, 0 ) ),
				new( new Vector3( 0, 0.5f, -0.5f ), Vector3.Backward, Vector3.Right, new Vector4( 0, 1, 0, 0 ) ),
			};
			var indices = new List<int> { 0, 1, 2, 0, 2, 3 };
			var mesh = new Mesh( BackgroundMaterial );
			_backgroundMaterial = BackgroundMaterial;
			mesh.CreateVertexBuffer( vertices.Count, Vertex.Layout, vertices );
			mesh.CreateIndexBuffer( indices.Count, indices );
			mesh.Bounds = BBox.FromPositionAndSize( Vector3.Zero, 1f );
			_backgroundRenderer = _background.Components.Create<ModelRenderer>();
			_backgroundRenderer.Model = Model.Builder.AddMesh( mesh ).Create();
			_backgroundRenderer.RenderType = ModelRenderer.ShadowRenderType.Off;
		}
		_background.Enabled = true;

		// A quad far behind the head, generously oversized: the shader maps the picture to the
		// screen, so the quad only has to cover the view (even while the camera zooms).
		float distance = MathF.Min( camera.ZFar * 0.9f, 2000f );
		float aspect = Screen.Width / MathF.Max( Screen.Height, 1f );
		float height = 2f * distance * MathF.Tan( camera.FieldOfView.DegreeToRadian() * 0.5f ) * 3f;
		_background.WorldPosition = camera.WorldPosition + camera.WorldRotation.Forward * distance;
		_background.WorldRotation = camera.WorldRotation;
		_background.WorldScale = new Vector3( 1f, height * MathF.Max( aspect, 1f ), height );

		if ( _backgroundMaterial != BackgroundMaterial )
		{
			_backgroundRenderer.MaterialOverride = BackgroundMaterial;
			_backgroundMaterial = BackgroundMaterial;
		}

		var attributes = _backgroundRenderer.SceneObject?.Attributes;
		if ( attributes is null ) return;
		attributes.Set( "ImageAspect", BackgroundAspect );
		attributes.Set( "Brightness", BackgroundBrightness );
		attributes.Set( "Blur", BackgroundBlur );
	}

	Color BackgroundColor()
	{
		// Skin albedo is linear; the original took the hue of its gamma-corrected colour.
		var skin = new Color( MathF.Pow( SkinAlbedo.x, 1 / 2.2f ), MathF.Pow( SkinAlbedo.y, 1 / 2.2f ), MathF.Pow( SkinAlbedo.z, 1 / 2.2f ) );
		var hsv = skin.ToHsv();
		var bg = hsv.WithHue( (hsv.Hue + 180f) % 360f ).WithSaturation( 0.9f ).WithValue( 0.4f ).ToColor();
		return new Color( bg.r * 0.6f, bg.g * 0.6f, bg.b * 0.6f );
	}

	// --- Rendering ----------------------------------------------------------------------------

	// Rough thickness of each part in head units, for the skin shader's light transmission.
	const float HeadThickness = 1.0f;
	const float NoseThickness = 0.15f;
	const float EarThickness = 0.04f;
	const float LidThickness = 0.03f;

	SkinLayer _skinLayer;
	GpuSkin _gpuSkin;

	void BuildRenderers()
	{
		_head = new GameObject( GameObject, true, "Head" );
		// Local only: each player builds their own face; never replicate these procedural meshes.
		_head.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		_head.NetworkMode = NetworkMode.Never;

		// Real material assets (not created from shader paths) so they ship with the game and load
		// for joining players; per-part colours are set as render attributes, not on the material.
		SkinMaterial ??= Material.Load( "materials/squishy_skin.vmat" );
		EyeMaterial ??= Material.Load( "materials/squishy_eye.vmat" );
		var skinMaterial = SkinMaterial;
		var eyeMaterial = EyeMaterial;

		// The wrinkle cloth runs on the GPU at the original's high setting (two subdivisions).
		_skinLayer = new SkinLayer( Sim, 2 );
		_gpuSkin = new GpuSkin( Sim, _skinLayer );

		_skin = new DynamicMesh( _head, "Skin", _skinLayer.VertexCount, new List<int>( _skinLayer.Indices ), skinMaterial, FlipWinding );
		_skin.UploadIndexed( _skinLayer.Normals, _skinLayer.Mouthiness, HeadThickness );
		if ( Sim.Hair is not null )
		{
			_hair = new DynamicMesh( _head, "Hair", Sim.Hair.Pos.Length, new List<int>( Sim.Hair.Indices ), Material.Load( "materials/default.vmat" ), FlipWinding );
			_hair.Renderer.Tint = HairColor;
		}
		_nose = new DynamicMesh( _head, "Nose", Sim.Nose.VertexCount, new List<int>( Sim.Nose.Indices ), skinMaterial, FlipWinding );
		for ( int i = 0; i < 2; i++ )
		{
			_eyes[i] = new DynamicMesh( _head, $"Eye{i}", Sim.Eyes[i].VertexCount, new List<int>( Sim.Eyes[i].Indices ), eyeMaterial, FlipWinding );
			_ears[i] = new DynamicMesh( _head, $"Ear{i}", Sim.Ears[i].VertexCount, new List<int>( Sim.Ears[i].Indices ), skinMaterial, FlipWinding );
			if ( Sim.Lids[i] is not null )
				_lids[i] = new DynamicMesh( _head, $"Lid{i}", Sim.Lids[i].Pos.Length, new List<int>( Sim.Lids[i].Indices ), skinMaterial, FlipWinding );
		}
	}

	void UpdateSkinLayer()
	{
		_gpuSkin.AttachTo( Scene.Camera );
		_gpuSkin.Update( Sim, IsDragging, IsDragging ? DraggingTriangle : 0 );
	}

	void UploadMeshes()
	{
		if ( _head is null ) return;

		_hair?.Update( Sim.Hair.Pos, Sim.Hair.Normals, null, HeadThickness );
		_nose.Update( Sim.Nose.Pos, Sim.Nose.Normals, Sim.Nose.Mask, NoseThickness );
		for ( int i = 0; i < 2; i++ )
		{
			_eyes[i].Update( Sim.Eyes[i].Pos, Sim.Eyes[i].Normals, null, 0 );
			_ears[i].Update( Sim.Ears[i].Pos, Sim.Ears[i].Normals, null, EarThickness );
			_lids[i]?.Update( Sim.Lids[i].Pos, Sim.Lids[i].Normals, null, LidThickness );
		}
	}

	/// <summary>Head rotation and shader inputs; cheap, so done every frame.</summary>
	void UpdateAttributes()
	{
		if ( _head is null ) return;
		_head.LocalRotation = ToLocal( _faceRotation );

		// X eyes pop in with a little overshoot, and snap back out.
		_xEyes = XEyes ? MathF.Min( _xEyes + RealTime.Delta * 6f, 1f ) : MathF.Max( _xEyes - RealTime.Delta * 8f, 0f );
		float xShown = XEyes && _xEyes < 1f ? _xEyes * (1f + 0.35f * MathF.Sin( _xEyes * MathF.PI )) : _xEyes;

		var lookWorld = WorldRotation * ToLocal( _lookDirection );
		var cameraPos = Scene.Camera?.WorldPosition ?? WorldPosition;
		for ( int i = 0; i < 2; i++ )
		{
			var attributes = _eyes[i].Attributes;
			if ( attributes is null ) continue;
			var eyeCenter = _head.WorldTransform.PointToWorld( ToLocal( Sim.EyeCentre( i ) ) );
			attributes.Set( "EyeCenter", eyeCenter );
			// Dead eyes stare straight at the player (aimed a little low, into the part the lids leave open).
			attributes.Set( "LookDirection", XEyes ? ((cameraPos - eyeCenter).Normal + _head.WorldRotation.Down * XEyesDrop).Normal : lookWorld );
			attributes.Set( "XEyes", xShown );
			attributes.Set( "ScleraColor", ScleraAlbedo );
			attributes.Set( "IrisColor", IrisAlbedo );
			attributes.Set( "PupilColor", PupilAlbedo );
			attributes.Set( "IrisSize", IrisSize );
			attributes.Set( "PupilSize", PupilSize );
		}

		var skinAttributes = _skin.Attributes;
		if ( skinAttributes is not null )
		{
			skinAttributes.Set( "UseSkinBuffer", 1 );
			skinAttributes.Set( "SkinPositions", _gpuSkin.Positions );
			skinAttributes.Set( "SkinNormals", _gpuSkin.Normals );
		}

		foreach ( var mesh in new[] { _skin, _nose, _ears[0], _ears[1], _lids[0], _lids[1] } )
		{
			var attributes = mesh?.Attributes;
			if ( attributes is null ) continue;
			attributes.Set( "SkinAlbedo", mesh == _nose && Style == Look.Terry ? NoseAlbedo : SkinAlbedo );
			attributes.Set( "MouthColor", MouthAlbedo );
			attributes.Set( "SkinRoughness", SkinRoughness );
			attributes.Set( "SkinF0", SkinF0 );
			attributes.Set( "TransmissionScale", Transmission );
			attributes.Set( "ScatterWidth", ScatterWidth );
			attributes.Set( "MouthThreshold", Sim.Style.MouthShadeThreshold );
		}
	}

	/// <summary>A child GameObject with a ModelRenderer whose single mesh is rewritten every frame.</summary>
	sealed class DynamicMesh
	{
		public readonly GameObject GameObject;
		public readonly ModelRenderer Renderer;
		readonly Mesh _mesh;
		readonly List<Vertex> _vertices;

		public RenderAttributes Attributes => Renderer.SceneObject?.Attributes;

		public DynamicMesh( GameObject parent, string name, int vertexCount, List<int> indices, Material material, bool flip )
		{
			GameObject = new GameObject( parent, true, name );
			// Local only: each player builds their own face; never replicate these procedural meshes.
			GameObject.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
			GameObject.NetworkMode = NetworkMode.Never;

			if ( flip )
			{
				for ( int i = 0; i + 2 < indices.Count; i += 3 )
					(indices[i + 1], indices[i + 2]) = (indices[i + 2], indices[i + 1]);
			}

			_vertices = new List<Vertex>( vertexCount );
			for ( int i = 0; i < vertexCount; i++ )
				_vertices.Add( new Vertex( Vector3.Zero, Vector3.Up, Vector3.Forward, Vector4.Zero ) );

			_mesh = new Mesh( material );
			_mesh.CreateVertexBuffer( vertexCount, Vertex.Layout, _vertices );
			_mesh.CreateIndexBuffer( indices.Count, indices );
			_mesh.Bounds = BBox.FromPositionAndSize( Vector3.Zero, 6f );

			Renderer = GameObject.Components.Create<ModelRenderer>();
			Renderer.Model = Model.Builder.AddMesh( _mesh ).Create();
		}

		/// <summary>
		/// For meshes positioned on the GPU: store each vertex's buffer index in position.x (exact in a
		/// float) since the model may reorder vertices. Normals are only used for bounds/tangents.
		/// </summary>
		public void UploadIndexed( Vec3[] normals, float[] mouthiness, float thickness )
		{
			for ( int i = 0; i < _vertices.Count; i++ )
			{
				var n = ToLocal( normals[i] );
				var uv = new Vector4( mouthiness is null ? 0 : mouthiness[i], thickness, 0, 0 );
				_vertices[i] = new Vertex( new Vector3( i, 0, 0 ), n, Vector3.Forward, uv );
			}
			_mesh.SetVertexBufferData( _vertices, 0 );
		}

		/// <param name="mouthiness">Per-vertex mouthiness for the skin shader, or null.</param>
		/// <param name="thickness">Part thickness in head units for light transmission.</param>
		public void Update( Vec3[] pos, Vec3[] normals, float[] mouthiness, float thickness )
		{
			for ( int i = 0; i < _vertices.Count; i++ )
			{
				var n = ToLocal( normals[i] );
				var tangent = MathF.Abs( n.z ) < 0.99f ? Vector3.Cross( n, Vector3.Up ).Normal : Vector3.Forward;
				var uv = new Vector4( mouthiness is null ? 0 : mouthiness[i], thickness, 0, 0 );
				_vertices[i] = new Vertex( ToLocal( pos[i] ), n, tangent, uv );
			}
			_mesh.SetVertexBufferData( _vertices, 0 );
		}
	}
}