This commit is contained in:
Andras Schmelczer 2026-08-22 18:35:53 +01:00
commit 5017ff168a
2 changed files with 1004 additions and 2007 deletions

View file

@ -2,9 +2,6 @@ import { mat2d, vec2 } from 'gl-matrix';
import { Drawable, DrawableDescriptor } from 'sdf-2d';
import { Circle } from 'shared';
// A single character silhouette shared by every team (teams differ only by
// colour): a circular head sitting on two thin line-legs that run down to the
// two feet.
export class CharacterShape extends Drawable {
public static descriptor: DrawableDescriptor = {
sdf: {
@ -22,8 +19,7 @@ export class CharacterShape extends Drawable {
return distance(target, circleCenter) - radius;
}
// Distance to the segment a->b; subtract a radius to get a capsule (a
// rounded thick line).
// Distance to the segment a->b; subtract a radius to get a capsule (a rounded thick line).
float segmentDistance(vec2 a, vec2 b, vec2 target) {
vec2 pa = target - a;
vec2 ba = b - a;
@ -41,29 +37,20 @@ export class CharacterShape extends Drawable {
float headRadius = characterHeadRadii[i];
float footRadius = characterFootRadii[i];
// The server rotates the whole posture toward travel, so the head
// leads: forward is the facing direction, perp its 90deg rotation, so
// the face sits on the leading side and reads at any rotation.
// The server rotates the whole posture toward travel, so the head leads: forward is the facing direction, perp its 90deg rotation, so the face sits on the leading side at any rotation.
vec2 footAverage = (leftFoot + rightFoot) * 0.5;
vec2 toHead = head - footAverage;
float toHeadLength = length(toHead);
vec2 forward = toHeadLength > 0.001 ? toHead / toHeadLength : vec2(0.0, 1.0);
vec2 perp = vec2(-forward.y, forward.x);
// A circular head, drawn a bit smaller than the head->feet gap so the
// legs below it read as two distinct LINES rather than being swallowed
// by the head.
float renderRadius = headRadius * 0.75;
float headDistance = circleDistance(head, renderRadius, target);
// Legs as thin line-segments (capsules) from the head down to each
// foot, hard-min'd onto the head so the head stays a crisp circle.
float legThickness = footRadius * 0.35;
float leftLeg = segmentDistance(head, leftFoot, target) - legThickness;
float rightLeg = segmentDistance(head, rightFoot, target) - legThickness;
// Rounded feet at the ends of the lines, kept large enough to stay
// visible even when the leg is short and the head nearly reaches them.
float footRender = footRadius * 0.7;
float leftFootDistance = circleDistance(leftFoot, footRender, target);
float rightFootDistance = circleDistance(rightFoot, footRender, target);
@ -73,23 +60,13 @@ export class CharacterShape extends Drawable {
min(min(leftLeg, rightLeg), min(leftFootDistance, rightFootDistance))
);
// Real eyes painted on the leading face — a bright white sclera with a
// dark pupil — rather than carved holes, so the character reads as
// alive instead of hollow-socketed.
// Big white sclera, small pupil: the eye must read as mostly white so
// it looks like an eye, not a dark socket. (An 8-bit albedo caps the
// sclera at pure white, so its area — not its colour — is what makes
// the eye read brighter against the body.)
vec2 eyeBase = head + forward * (renderRadius * 0.26);
vec2 leftEyeCenter = eyeBase + perp * (renderRadius * 0.38);
vec2 rightEyeCenter = eyeBase - perp * (renderRadius * 0.38);
float scleraRadius = renderRadius * 0.26;
float pupilRadius = renderRadius * 0.11;
// The pupil slides toward the gaze target (the cursor for the local
// player, otherwise the travel direction), kept well inside the rim so
// a generous ring of white always frames it. The normalize is guarded
// so a cursor resting exactly on an eye can't produce NaNs.
// The normalize is guarded so a cursor resting exactly on an eye can't produce NaNs.
vec2 gazeTarget = characterGazeTargets[i];
float pupilReach = (scleraRadius - pupilRadius) * 0.6;
vec2 toLeftGaze = gazeTarget - leftEyeCenter;
@ -106,14 +83,7 @@ export class CharacterShape extends Drawable {
circleDistance(rightEyeCenter + rightGaze * pupilReach, pupilRadius, target)
);
// Soften each eye edge by at least one screen texel so the colour
// boundary antialiases instead of staircasing when the character is
// small on screen. The fixed world-space term sets a floor on the
// softness when zoomed in; fwidth widens the band to a texel as the
// head shrinks. Computed here in uniform control flow — NOT inside the
// body branch below — so the screen-space derivatives stay defined.
// fwidth needs WebGL2 derivatives (core in GLSL ES 3.00), so the
// WebGL1 fallback keeps the plain world-space band.
// Computed here in uniform control flow — NOT inside the body branch below — so the screen-space derivatives fwidth needs stay defined. WebGL1 fallback keeps the plain world-space band (no fwidth in GLSL ES 1.00).
float eyeAaBase = renderRadius * 0.025;
#ifdef WEBGL2_IS_AVAILABLE
float scleraAa = max(eyeAaBase, fwidth(sclera));
@ -125,21 +95,13 @@ export class CharacterShape extends Drawable {
if (body < minDistance) {
minDistance = body;
// Brief white punch when taking a hit.
color = mix(
readFromPalette(characterColors[i]),
vec4(1.0),
clamp(characterFlash[i], 0.0, 1.0)
);
// Paint the eyes over the body colour. The sclera albedo is HDR
// (>> 1): the shading pass multiplies it by the (often dim, reddish)
// scene light before clamping to the screen, so an over-bright white
// reads as a clean white eye everywhere instead of dimming into a
// dark socket. Needs the float colour buffer in sdf-2d; on 8-bit
// fallback it simply clamps back to plain white. The smoothstep
// widths (scleraAa / pupilAa, computed above) antialias the colour
// boundary in a zoom-aware way.
// The sclera albedo is HDR (>> 1) so the shading pass — which multiplies by dim scene light before clamping — still renders a clean white eye instead of dimming to a dark socket. Needs sdf-2d's float colour buffer; on 8-bit fallback it clamps to plain white.
color = mix(color, vec4(10.0, 10.0, 10.0, 1.0), 1.0 - smoothstep(-scleraAa, scleraAa, sclera));
color = mix(color, vec4(0.04, 0.04, 0.07, 1.0), 1.0 - smoothstep(-pupilAa, pupilAa, pupil));
}
@ -169,10 +131,7 @@ export class CharacterShape extends Drawable {
protected leftFoot!: Circle;
protected rightFoot!: Circle;
// 0..1 transient white flash on taking a hit. Set per frame.
public hitFlash = 0;
// World point the eyes look at; the pupils slide toward it. Set per frame
// (the cursor for the local player, the travel direction for everyone else).
public gazeTarget = vec2.create();
public constructor(private readonly color: number) {

View file

@ -58,9 +58,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
float cr = cos(rotation);
float sr = sin(rotation);
// Spin the whole planet: evaluate the SDF in the planet's own
// rotating frame so the polygon outline turns together with its
// terrain, instead of the terrain sliding over a fixed outline.
// Evaluate the SDF in the planet's own rotating frame so the outline turns with its terrain.
vec2 targetCenterDelta = target - center;
float targetDistance = length(targetCenterDelta);
vec2 localTarget = center + vec2(
@ -129,9 +127,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
public randomOffset = 0;
public rotation = 0;
// Circle about the rotation centre (the vertex centroid, which is what the
// shader spins around — see planetMinDistance above). The vertices never
// change after construction, so cache it once.
// Circle about the vertex centroid — the point the shader spins around (planetMinDistance above). Vertices are immutable after construction, so it is cached once.
private readonly cullCenter: vec2;
private readonly cullRadius: number;