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&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 );
}
}
}