feat: render transport-ship trail cosmetic as a gradient (#4454)

## What

Renders the `transportShipTrail` cosmetic effect in-game. Transport
ships already left a trail, but it was always drawn in the player's
**territory color** — this wires the selected effect through to the
renderer so the trail shows the player's chosen **gradient**.

## How

- **Per-player effect texture** (`RGBA32F`, mirrors the palette texture)
keyed by `smallID`, sampled by the trail fragment shader. Each row holds
a gradient color; spare alpha channels carry the color count,
`colorSize`, and `movementSpeed`.
- **Shader**
([trail.frag.glsl](src/client/render/gl/shaders/map-overlay/trail.frag.glsl))
cycles a flowing gradient through the color list: 1 color → flat, 2+ →
animated bands scrolling along the trail. No effect (count 0) falls back
to the territory color; alt-view keeps affiliation colors.
- **WebGLFrameBuilder** resolves each player's catalog attributes (the
in-game cosmetic is only `{ name, effectType }`; the style/colors live
in the catalog) and encodes them. Resolution is decoupled from the
first-seen palette path so it retries until the catalog loads, and
unparseable colors are dropped so bad catalog data degrades to the
territory color rather than rendering black.

## Schema

Collapses the trail attributes to a single gradient shape:

```ts
{ type: "gradient", colors: string[], colorSize: number, movementSpeed: number }
```

- `colors` — solid = one color, rainbow = the spectrum, gradient = two
or more.
- `colorSize` — band width (tiles per color band; `1` is the default, ~4
tiles).
- `movementSpeed` — scroll rate along the trail (tiles/sec; `0` =
static).

## Notes

- Animation is render-only (local time), no simulation/determinism
impact.
- The catalog (`cosmetics.json`, served by the closed-source API) must
ship effects in this `{ type: "gradient", colors, colorSize,
movementSpeed }` shape.
- Band thickness (`4.0` base in the shader) and the gradient frequency
are visual constants picked without in-game verification — easy to tune.

## Testing

- `tsc --noEmit`, ESLint, Prettier, `build-prod` all clean.
- Schema + Privilege test suites updated for the gradient shape (92
tests pass).
- Not yet visually verified in a running game (effect selection is
flare-gated).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-29 20:28:47 -07:00
committed by GitHub
parent f4b47ce06c
commit 7c151e76ad
11 changed files with 373 additions and 146 deletions
+89 -1
View File
@@ -1,12 +1,20 @@
import { Colord } from "colord";
import { Colord, colord } from "colord";
import { base64url } from "jose";
import { assetUrl } from "../core/AssetUrls";
import {
findEffect,
type TransportShipTrailAttributes,
} from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import { PlayerType } from "../core/game/Game";
import { getCachedCosmetics } from "./Cosmetics";
import { uploadFrameData } from "./render/frame/Upload";
// Type-only: a value import would pull GPURenderer and its `.glsl?raw` shader
// imports into any non-Vite consumer (e.g. the Node perf harness).
import type { MapRenderer, PlayerStatic, SpawnCenter } from "./render/gl";
// Value import from the leaf module (not the ./render/gl barrel) so non-Vite
// consumers don't pull in GPURenderer and its shaders — see note above.
import { MAX_TRAIL_COLORS } from "./render/gl/utils/ColorUtils";
import type { GameView } from "./view";
const PALETTE_SIZE = 4096;
@@ -24,10 +32,21 @@ const PALETTE_SIZE = 4096;
*/
export class WebGLFrameBuilder {
private readonly palette: Float32Array;
// Per-player transport-ship-trail gradient, keyed by smallID. Layout is
// 4096×MAX_TRAIL_COLORS: row 0 = (color0.rgb, colorCount), row r = (colorR.rgb).
// Consumed by TrailPass's effect texture.
private readonly effectPalette: Float32Array;
private readonly patternMeta: Float32Array;
private readonly patternData: Uint8Array;
private readonly knownSmallIDs = new Set<number>();
/**
* smallIDs whose trail effect has been resolved into the effect palette.
* Separate from knownSmallIDs because effect resolution depends on the
* cosmetics catalog, which may not be loaded the tick a player is first seen
* — keeping it separate lets us retry next tick instead of skipping forever.
*/
private readonly effectResolved = new Set<number>();
/**
* Last spawn tile pushed to the renderer per smallID. Players can re-pick
* spawn during the spawn phase, so this tracks the latest value rather than
@@ -45,6 +64,7 @@ export class WebGLFrameBuilder {
constructor(private readonly view: MapRenderer) {
this.palette = new Float32Array(PALETTE_SIZE * 2 * 4);
this.effectPalette = new Float32Array(PALETTE_SIZE * MAX_TRAIL_COLORS * 4);
this.patternMeta = new Float32Array(PALETTE_SIZE * 4);
this.patternData = new Uint8Array(PALETTE_SIZE * 1024);
}
@@ -52,6 +72,7 @@ export class WebGLFrameBuilder {
/** Drop internal caches to force a full re-upload of state on the next update(). */
clearCaches(): void {
this.knownSmallIDs.clear();
this.effectResolved.clear();
this.lastSpawnTile.clear();
this.localPlayerSmallID = 0;
this.skinsInitialized = false;
@@ -85,6 +106,7 @@ export class WebGLFrameBuilder {
update(gameView: GameView): void {
this.syncPlayers(gameView);
this.syncPlayerEffects(gameView);
this.syncPlayerSpawns(gameView);
this.syncLocalPlayer(gameView);
this.syncSpawnOverlay(gameView);
@@ -254,6 +276,72 @@ export class WebGLFrameBuilder {
}
}
/**
* Resolve each player's transport-ship-trail effect into the effect palette.
* A player's resolved cosmetic is just { name, effectType }; the style and
* colors live in the catalog, so we look them up via the cached cosmetics.
* Decoupled from syncPlayers' first-seen guard: if the catalog isn't loaded
* yet we leave the player unresolved and retry next tick (the trail keeps its
* territory color meanwhile). Re-uploads the effect texture only when a
* recognized style was actually written.
*/
private syncPlayerEffects(gameView: GameView): void {
const catalog = getCachedCosmetics();
if (!catalog) return; // Catalog not loaded yet — retry on a later tick.
let dirty = false;
for (const p of gameView.players()) {
const smallID = p.smallID();
if (this.effectResolved.has(smallID)) continue;
this.effectResolved.add(smallID);
const trailEffect = p.cosmetics.effects?.["transportShipTrail"];
if (!trailEffect) continue; // No effect — nothing to write or upload.
const effect = findEffect(
catalog,
"transportShipTrail",
trailEffect.name,
);
if (effect?.effectType !== "transportShipTrail") continue;
if (this.writeEffectEntry(smallID, effect.attributes)) dirty = true;
}
if (dirty) this.view.updateEffectPalette(this.effectPalette);
}
/**
* Encode a player's transport-ship-trail gradient into the effect palette.
* Layout matches trail.frag.glsl: row r holds color r's rgb, and the spare
* alpha channels carry the gradient's scalar params — row 0's alpha = color
* count (0 → the shader falls back to the territory color), row 1's alpha =
* colorSize (band width), row 2's alpha = movementSpeed (scroll rate).
* colord doesn't throw on a bad color string (it returns black), so unparseable
* colors are dropped — leaving an empty list, which falls back to the territory
* color rather than rendering black. Returns whether any color was written.
*/
private writeEffectEntry(
smallID: number,
attrs: TransportShipTrailAttributes,
): boolean {
const colors = attrs.colors
.map((s) => colord(s))
.filter((c) => c.isValid())
.slice(0, MAX_TRAIL_COLORS)
.map((c) => c.toRgb());
for (let r = 0; r < MAX_TRAIL_COLORS; r++) {
const off = (r * PALETTE_SIZE + smallID) * 4;
const c = colors[r] ?? { r: 0, g: 0, b: 0 };
this.effectPalette[off] = c.r / 255;
this.effectPalette[off + 1] = c.g / 255;
this.effectPalette[off + 2] = c.b / 255;
this.effectPalette[off + 3] = 0;
}
// Scalar params packed into spare alpha channels (rows 02 always exist).
this.effectPalette[(0 * PALETTE_SIZE + smallID) * 4 + 3] = colors.length;
this.effectPalette[(1 * PALETTE_SIZE + smallID) * 4 + 3] = attrs.colorSize;
this.effectPalette[(2 * PALETTE_SIZE + smallID) * 4 + 3] =
attrs.movementSpeed;
return colors.length > 0;
}
private writePaletteEntry(
smallID: number,
fill: Colord,
+16 -37
View File
@@ -1,48 +1,27 @@
import { html, TemplateResult } from "lit";
import { TransportShipTrailAttributes } from "../../core/CosmeticSchemas";
// A flowing spectrum used for the "rainbow" transport-ship-trail preview.
const RAINBOW_GRADIENT =
"linear-gradient(90deg,#ff0000,#ff8a00,#ffe600,#28c76f,#00a8ff,#7d5fff,#ff0000)";
// Neutral fallback for attribute types we don't recognize.
const UNKNOWN_BG = "#444";
// Neutral fallback when a trail has no usable colors.
const EMPTY_BG = "#444";
/**
* Render a swatch preview of a transport-ship-trail's attributes, filling its
* container: solid = flat color, pulse = same color pulsing, rainbow = full
* spectrum, gradient = two-color blend. Unknown attribute types render a neutral
* swatch (we ignore types we don't know about).
* container. A trail is a list of colors: one color renders as a flat swatch,
* two or more as a left-to-right gradient (a multi-color list reads as a
* rainbow). An empty list renders a neutral swatch.
*/
export function renderTransportShipTrailSwatch(
attributes: TransportShipTrailAttributes,
): TemplateResult {
switch (attributes.type) {
case "rainbow":
return html`<div
class="w-full h-full rounded-md"
style="background:${RAINBOW_GRADIENT};"
></div>`;
case "gradient":
return html`<div
class="w-full h-full rounded-md"
style="background:linear-gradient(90deg,${attributes.color},${attributes.color2});"
></div>`;
case "pulse":
return html`<div
class="w-full h-full rounded-md animate-pulse"
style="background:${attributes.color};"
></div>`;
case "solid":
return html`<div
class="w-full h-full rounded-md"
style="background:${attributes.color};"
></div>`;
default:
// Unknown / unrecognized style — neutral swatch.
return html`<div
class="w-full h-full rounded-md"
style="background:${UNKNOWN_BG};"
></div>`;
}
const colors = attributes.colors;
const background =
colors.length === 0
? EMPTY_BG
: colors.length === 1
? colors[0]
: `linear-gradient(90deg,${colors.join(",")})`;
return html`<div
class="w-full h-full rounded-md"
style="background:${background};"
></div>`;
}
+3
View File
@@ -132,6 +132,9 @@ export class MapRenderer {
updatePalette(paletteData: Float32Array): void {
this.renderer?.updatePalette(paletteData);
}
updateEffectPalette(effectData: Float32Array): void {
this.renderer?.updateEffectPalette(effectData);
}
addPlayers(
players: PlayerStatic[],
paletteData: Float32Array,
+38 -2
View File
@@ -59,7 +59,7 @@ import { UnitPass } from "./passes/UnitPass";
import { WorldTextPass } from "./passes/WorldTextPass";
import type { RenderSettings } from "./RenderSettings";
import { AffiliationPalette } from "./utils/Affiliation";
import { getPaletteSize, hexToRgb } from "./utils/ColorUtils";
import { getPaletteSize, hexToRgb, MAX_TRAIL_COLORS } from "./utils/ColorUtils";
import { renderDpr } from "./utils/Dpr";
import {
createTexture2D,
@@ -132,6 +132,10 @@ export class GPURenderer {
private paletteTex: WebGLTexture;
private paletteData: Float32Array;
// Per-player transport-ship-trail gradient, keyed by smallID (RGBA32F,
// 4096×MAX_TRAIL_COLORS): row r = color r's rgb; row 0's alpha = color count.
// Sampled by TrailPass.
private effectTex: WebGLTexture;
private patternMetaTex: WebGLTexture;
private patternDataTex: WebGLTexture;
private skinAtlas: SkinAtlasArray;
@@ -235,6 +239,18 @@ export class GPURenderer {
filter: gl.NEAREST,
});
// Per-player trail-effect texture (one row per gradient color). Starts zeroed
// (color count 0 everywhere = no effect → trail uses territory color).
this.effectTex = createTexture2D(gl, {
width: palW,
height: MAX_TRAIL_COLORS,
internalFormat: gl.RGBA32F,
format: gl.RGBA,
type: gl.FLOAT,
data: new Float32Array(palW * MAX_TRAIL_COLORS * 4),
filter: gl.NEAREST,
});
this.patternMetaTex = createTexture2D(gl, {
width: palW,
height: 1,
@@ -371,13 +387,14 @@ export class GPURenderer {
this.settings.spawnOverlay,
);
// --- Trail (needs trailTex, paletteTex) ---
// --- Trail (needs trailTex, paletteTex, effectTex) ---
this.trailPass = new TrailPass(
gl,
mapW,
mapH,
this.res.trailTex,
this.paletteTex,
this.effectTex,
this.settings,
);
@@ -629,6 +646,24 @@ export class GPURenderer {
this.namePass.refreshPlayerColors(this.paletteData);
}
/** Re-upload the per-player trail-effect texture (style + colors by smallID). */
updateEffectPalette(effectData: Float32Array): void {
const gl = this.gl;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.effectTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
getPaletteSize(),
MAX_TRAIL_COLORS,
gl.RGBA,
gl.FLOAT,
effectData,
);
}
/** Register late-arriving players (updates palette + NamePass lookup maps). */
addPlayers(
players: PlayerStatic[],
@@ -1228,6 +1263,7 @@ export class GPURenderer {
this.barPass.dispose();
disposeGPUResources(this.gl, this.res);
this.gl.deleteTexture(this.paletteTex);
this.gl.deleteTexture(this.effectTex);
this.gl.deleteTexture(this.patternMetaTex);
this.gl.deleteTexture(this.patternDataTex);
this.gl.deleteTexture(this.skinLayerTex);
+5 -1
View File
@@ -10,7 +10,11 @@ export { applyGraphicsOverrides } from "./RenderOverrides";
export { createRenderSettings, dumpSettings } from "./RenderSettings";
export type { RenderSettings } from "./RenderSettings";
export { deepAssign, deepDiff } from "./SettingsUtils";
export { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils";
export {
MAX_TRAIL_COLORS,
buildTerrainRGBA,
getPaletteSize,
} from "./utils/ColorUtils";
export { renderDpr } from "./utils/Dpr";
export { buildNukeTrajectory, samRange } from "./utils/NukeTrajectory";
export type { SAMInfo } from "./utils/NukeTrajectory";
+12
View File
@@ -24,13 +24,18 @@ export class TrailPass {
private uCamera: WebGLUniformLocation;
private uMapSize: WebGLUniformLocation;
private uTrailAlpha: WebGLUniformLocation;
private uTime: WebGLUniformLocation;
private uAltView: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private trailTex: WebGLTexture;
private paletteTex: WebGLTexture;
private effectTex: WebGLTexture;
private affiliationTex: WebGLTexture | null = null;
private altView = false;
// Anchor animation time at construction (like NukeTelegraphPass/SamRadiusPass)
// so the value stays small and sin()/fract() don't quantize over long sessions.
private readonly startTime = performance.now();
/** CPU-side trail state (R8UI, 0=none, 1255=ownerID). */
private cpuTrailState: Uint8Array;
@@ -49,6 +54,7 @@ export class TrailPass {
mapH: number,
trailTex: WebGLTexture,
paletteTex: WebGLTexture,
effectTex: WebGLTexture,
settings: RenderSettings,
) {
this.gl = gl;
@@ -57,6 +63,7 @@ export class TrailPass {
this.mapH = mapH;
this.trailTex = trailTex;
this.paletteTex = paletteTex;
this.effectTex = effectTex;
this.cpuTrailState = new Uint8Array(mapW * mapH);
this.program = createProgram(
@@ -70,12 +77,14 @@ export class TrailPass {
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!;
this.uTrailAlpha = gl.getUniformLocation(this.program, "uTrailAlpha")!;
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
this.uAltView = gl.getUniformLocation(this.program, "uAltView")!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTrailTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 2);
gl.uniform1i(gl.getUniformLocation(this.program, "uEffect"), 3);
this.vao = createMapQuad(gl, mapW, mapH);
}
@@ -168,6 +177,7 @@ export class TrailPass {
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
gl.uniform1f(this.uTrailAlpha, this.settings.mapOverlay.trailAlpha);
gl.uniform1f(this.uTime, (performance.now() - this.startTime) / 1000);
gl.uniform1i(this.uAltView, this.altView ? 1 : 0);
gl.activeTexture(gl.TEXTURE0);
@@ -178,6 +188,8 @@ export class TrailPass {
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex);
}
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this.effectTex);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
@@ -5,8 +5,13 @@ precision highp usampler2D;
uniform usampler2D uTrailTex; // R8UI — trail ownerID per cell (0 = none)
uniform sampler2D uPalette; // RGBA32F — player colors
uniform sampler2D uAffiliation; // RGBA8 — affiliation colors (row 0 = border, row 1 = unit)
uniform sampler2D uEffect; // RGBA32F — trail gradient, keyed by ownerID:
// row r = color r's rgb; spare alphas hold scalars:
// row 0.a = color count (0 = no effect → territory color),
// row 1.a = colorSize (band width), row 2.a = movementSpeed
uniform vec2 uMapSize;
uniform float uTrailAlpha;
uniform float uTime; // seconds, for the flowing gradient animation
uniform int uAltView;
in vec2 vWorldPos;
@@ -22,10 +27,37 @@ void main() {
vec3 color;
if (uAltView != 0) {
// Alt view recolors everything by affiliation — effects stay off so the
// strategic overlay reads consistently.
color = texelFetch(uAffiliation, ivec2(int(trailOwner), 1), 0).rgb;
} else {
float u = (float(trailOwner) + 0.5) / float(PALETTE_SIZE);
color = texture(uPalette, vec2(u, 0.25)).rgb;
int owner = int(trailOwner);
int count = int(texelFetch(uEffect, ivec2(owner, 0), 0).a + 0.5);
if (count <= 0) {
// No effect — fall back to the player's territory color.
float u = (float(trailOwner) + 0.5) / float(PALETTE_SIZE);
color = texture(uPalette, vec2(u, 0.25)).rgb;
} else if (count == 1) {
// Single color — flat trail.
color = texelFetch(uEffect, ivec2(owner, 0), 0).rgb;
} else {
// Multiple colors — cyclic gradient banded across the map (world-space
// diagonal), scrolling over time so a moving trail shifts hue along it.
// colorSize scales the band width (colorSize = 1 is the default size, ~4
// tiles per band); movementSpeed = tiles/sec the bands travel.
float colorSize = max(texelFetch(uEffect, ivec2(owner, 1), 0).a, 0.001);
float movementSpeed = texelFetch(uEffect, ivec2(owner, 2), 0).a;
// 4.0 = tiles per band at colorSize 1; tune for default band thickness.
float cycle = colorSize * 4.0 * float(count);
float phase =
fract((vWorldPos.x + vWorldPos.y - uTime * movementSpeed) / cycle);
float f = phase * float(count);
int i = int(f) % count;
int j = (i + 1) % count;
vec3 a = texelFetch(uEffect, ivec2(owner, i), 0).rgb;
vec3 b = texelFetch(uEffect, ivec2(owner, j), 0).rgb;
color = mix(a, b, fract(f));
}
}
fragColor = vec4(color, uTrailAlpha);
}
+7
View File
@@ -17,6 +17,13 @@ export function getPaletteSize(): number {
return PALETTE_SIZE;
}
/**
* Max colors per transport-ship-trail gradient = rows in the trail-effect
* texture. Longer catalog color lists are truncated. Shared so the CPU side
* that fills the texture and the GPU side that allocates it can't drift.
*/
export const MAX_TRAIL_COLORS = 8;
// ---------- Terrain ----------
/** Parse a "#rrggbb" (or "rrggbb") hex string into an RGB tuple, or null. */