Files
OpenFrontIO/src/client/render/gl/MapRenderer.ts
T
828d7b1d96 feat(client): spiral nuke trail cosmetic effect (#4630)
## Summary

Adds a **spiral** `nukeTrail` effect type — a 3D vortex of glowing helix
strands projected onto the map, trailing behind nukes whose owner has
the cosmetic equipped.

Catalog attributes (`spiral_tail` shape):

| attribute | meaning |
| --- | --- |
| `colors` | palette, wrapped once around the vortex circumference |
| `radius` | helix amplitude in tiles |
| `strands` | helix strand count (renderer clamps to 8) |
| `rotationSpeed` | vortex spin, radians/sec |

## How it works

The per-tile trail texture holds persistent state; the vortex is a
transient animation that follows a path — so it renders as **ribbon
geometry**, not tile stamps.

**Path recording** (`SpiralTrails`): each live spiral-owner nuke gets an
append-only centerline polyline — ~2 samples per tile of travel carrying
position, a smoothed perpendicular (blended across tick segments so
curved paths don't kink), and cumulative distance. Params are pushed
once per player by `WebGLFrameBuilder` when the cosmetics catalog
resolves; a ribbon is dropped the moment its unit disappears, matching
stamped-trail cleanup.

**Rendering** (`SpiralRibbonPass`): one triangle-strip VBO per nuke (2
verts per sample, streamed append-only via `bufferSubData`, grown by
doubling); the vertex shader swings each sample sideways by the helix
offset and evaluates the head-cone convergence as a function of
`uHeadDist − d`, so uploaded vertices are immutable — the cone feeding
the strands into the missile needs no rewriting as the nuke flies. One
draw per strand reuses the same strip with a different phase offset
(`uPhase0`).

The glow look is a bloom-style split:

- **halo** — wide quadratic falloff, rendered premultiplied into a
quarter-resolution buffer (`mapOverlay.spiralResolutionScale = 0.25`,
~16× cheaper fragments) and composited **additively** over the scene, so
it reads as emitted light and the bilinear upsample keeps it soft;
- **core** — sharp full-resolution ribbons on top, with a white-hot
center on segments facing the viewer (neon-tube look).

Shading spins the helix angle with time: a `cos` depth cue brightens
facing segments and darkens receding ones, and the palette cross-fades
around the circumference.

The spiral nuke still stamps its plain centerline through the unchanged
`TrailManager` — `trail.frag` styleId 2 draws it flat in the first color
as the missile's spine, so alt view, death cleanup, and trail overlap
behave identically to non-cosmetic nukes. Ribbons draw above the plain
trails, below the missiles, and are skipped in alt view.

**Perf**: both ribbon stages are skipped entirely (CPU-side, before any
GL work) while no spiral nuke is in flight — games without the cosmetic,
and frames without a spiral nuke, pay nothing new. Vertex uploads stream
only newly appended samples; the halo's fragment cost is capped by the
quarter-res buffer. MIRV warheads are explicitly excluded from ribbons
(one MIRV splits into 350 of them).

**Store preview**: `TrailSwatch` mirrors the bloom split in SVG — a
screen-blended blurred halo, a crisp colored core, and a white-hot
center line — with a phase-offset per-strand fade matching the in-game
depth-shaded spin.

Note: requires the `spiral_tail` catalog entry on the API side to be
purchasable/selectable; without it nothing changes visually and the
schema tolerates its absence.

## Testing

- New `tests/SpiralTrails.test.ts`: ribbon gating by owner/unit type,
sample spacing + monotonic distances, strand clamp + pitch-derived
twist, death cleanup mutating the live array, params staying fixed for
in-flight ribbons
- New `tests/TrailManager.test.ts`: baseline stamping behavior (plain
boat trails, nuke-bit stamping up to lastPos, death cleanup with overlap
repaint)
- `tests/CosmeticSchemas.test.ts`: spiral attribute parsing incl. the
exact catalog shape, required-field/positivity rejections
- Full suite green; `tsc` and lint clean
- Verified visually: a standalone WebGL harness drove the real ribbon
shaders (glow split, palette colors, spin, cone convergence), and a real
solo game boots with zero GL errors

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:51:31 -07:00

326 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MapRenderer — public facade for the WebGL map renderer.
*
* Wraps GPURenderer as a private implementation detail and survives WebGL
* context loss: when the context is lost the renderer is disposed, and on
* restore a fresh GPURenderer is created and `onContextRestored` fires so
* the owner can re-upload all simulation state.
*
* This is a pure data sink. Input handling lives in InputHandler/EventBus;
* camera state is pushed in each frame via setCameraState. Consumers only
* touch MapRenderer — they never import GPURenderer or Camera.
*/
import type { Config } from "../../../core/configuration/Config";
import type { SpiralRibbon } from "../frame/SpiralTrails";
import type {
AttackRingInput,
BonusEvent,
ConquestFx,
DeadUnitFx,
GhostPreviewData,
NameEntry,
NukeTelegraphData,
NukeTrajectoryData,
PlayerState,
PlayerStatic,
PlayerStatusData,
RendererConfig,
UnitState,
} from "../types";
import type { SpawnCenter } from "./passes/SpawnOverlayPass";
import type { AttackTroopLabel } from "./passes/WorldTextPass";
import { GPURenderer } from "./Renderer";
import type { RenderSettings } from "./RenderSettings";
export class MapRenderer {
private renderer: GPURenderer | null = null;
private resizeObs: ResizeObserver | null = null;
// Persisted so a WebGL context restore (which recreates GPURenderer via
// initRenderer) reapplies the user's chosen glow strength instead of
// resetting it to the pass default until the next settings change.
private smallPlayerGlowStrength = 1;
/**
* Called after a lost WebGL context is restored and the renderer has been
* recreated. The owner must re-upload all simulation state (textures and
* geometry are gone).
*/
onContextRestored: (() => void) | null = null;
constructor(
private canvas: HTMLCanvasElement,
private header: RendererConfig,
// Called (not stored) whenever terrain bytes are needed — initial bake
// and every context restore. Regenerating on demand avoids retaining a
// map-sized buffer for the rare restore path.
private terrainSource: () => Uint8Array,
private paletteData: Float32Array,
private config: Config,
// Resolved render settings (defaults + overrides). Held so the same object
// is re-used when a GPURenderer is recreated after a context restore,
// preserving any user overrides that were applied to it.
private settings: RenderSettings,
private raf?: typeof requestAnimationFrame,
private caf?: typeof cancelAnimationFrame,
) {
this.initRenderer();
this.resizeObs = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) this.renderer?.resize(width, height);
}
});
this.resizeObs.observe(canvas);
canvas.addEventListener("webglcontextlost", this.handleContextLost, false);
canvas.addEventListener(
"webglcontextrestored",
this.handleContextRestored,
false,
);
}
private initRenderer = () => {
this.renderer = new GPURenderer(
this.canvas,
this.header,
this.terrainSource,
this.paletteData,
this.config,
this.settings,
this.raf,
this.caf,
);
const rect = this.canvas.getBoundingClientRect();
if (rect.width > 0) this.renderer.resize(rect.width, rect.height);
// Reapply state that lives outside RenderSettings so it survives a restore.
this.renderer.setSmallPlayerGlowStrength(this.smallPlayerGlowStrength);
};
private handleContextLost = (e: Event) => {
e.preventDefault();
if (this.renderer) {
this.renderer.dispose();
this.renderer = null;
}
};
private handleContextRestored = () => {
this.initRenderer();
this.onContextRestored?.();
};
/**
* Set when the context is hardware-accelerated but its MAX_TEXTURE_SIZE is
* below what the game needs (fingerprinting protection, #4357). The game
* runs, but the map may render with black areas — the owner should warn.
*/
get glLimited(): { renderer: string; maxTextureSize: number } | null {
return this.renderer?.glLimited ?? null;
}
// ---- Camera ----
setCameraState(x: number, y: number, z: number): void {
this.renderer?.setCameraState(x, y, z);
}
// ---- Data upload ----
uploadLiveDelta(
tileState: Uint16Array,
changedTiles: readonly number[],
): void {
this.renderer?.uploadLiveDelta(tileState, changedTiles);
}
uploadLiveTrailDelta(
trailState: Uint16Array,
dirtyRowMin: number,
dirtyRowMax: number,
): void {
this.renderer?.uploadLiveTrailDelta(trailState, dirtyRowMin, dirtyRowMax);
}
/** Upload full tile + trail state without resetting bloom (for live play). */
uploadTileAndTrailState(
tileState: Uint16Array,
trailState: Uint16Array,
): void {
this.renderer?.uploadTileAndTrailState(tileState, trailState);
}
updateSpiralRibbons(ribbons: readonly SpiralRibbon[]): void {
this.renderer?.updateSpiralRibbons(ribbons);
}
updatePalette(paletteData: Float32Array): void {
this.renderer?.updatePalette(paletteData);
}
updateEffectPalette(effectData: Float32Array): void {
this.renderer?.updateEffectPalette(effectData);
}
addPlayers(
players: PlayerStatic[],
paletteData: Float32Array,
patternMeta: Float32Array,
patternData: Uint8Array,
): void {
this.renderer?.addPlayers(players, paletteData, patternMeta, patternData);
}
setPlayerSkin(smallID: number, url: string): void {
this.renderer?.setPlayerSkin(smallID, url);
}
initSkinAtlas(urls: readonly string[]): void {
this.renderer?.initSkinAtlas(urls);
}
setPlayerSpawn(smallID: number, x: number, y: number): void {
this.renderer?.setPlayerSpawn(smallID, x, y);
}
uploadRailroadState(data: Uint8Array): void {
this.renderer?.uploadRailroadState(data);
}
updateUnits(units: Map<number, UnitState>, gameTick: number): void {
this.renderer?.updateUnits(units, gameTick);
}
updateNames(
names: Map<string, NameEntry>,
players: Map<number, PlayerState>,
snap: boolean,
statusData?: Map<number, PlayerStatusData>,
): void {
this.renderer?.updateNames(names, players, snap, statusData);
}
refreshNames(displayNames: Map<string, string>): void {
this.renderer?.refreshNames(displayNames);
}
updateRelations(data: Uint8Array, size: number): void {
this.renderer?.updateRelations(data, size);
}
updateStructures(units: Map<number, UnitState>): void {
this.renderer?.updateStructures(units);
}
applyDeadUnits(deadUnits: DeadUnitFx[]): void {
this.renderer?.applyDeadUnits(deadUnits);
}
applyConquestEvents(events: ConquestFx[]): void {
this.renderer?.applyConquestEvents(events);
}
setAttackTroopLabels(labels: AttackTroopLabel[]): void {
this.renderer?.setAttackTroopLabels(labels);
}
applyBonusEvents(events: BonusEvent[]): void {
this.renderer?.applyBonusEvents(events);
}
applyRailroadDust(tileRefs: number[]): void {
this.renderer?.applyRailroadDust(tileRefs);
}
/** Refresh terrain texels whose underlying terrain byte changed (water nukes). */
applyTerrainDelta(refs: readonly number[], terrainBytes: Uint8Array): void {
this.renderer?.applyTerrainDelta(refs, terrainBytes);
}
/** Rebuild the terrain texture from current settings (e.g. ocean color). */
rebuildTerrain(): void {
this.renderer?.rebuildTerrain();
}
updateAttackRings(rings: AttackRingInput[]): void {
this.renderer?.updateAttackRings(rings);
}
/** Update ghost structure preview (build-mode visualization). null = clear. */
updateGhostPreview(data: GhostPreviewData | null): void {
this.renderer?.updateGhostPreview(data);
}
// ---- Nuke UI ----
/** Update nuke trajectory preview arc. null = hide. */
updateNukeTrajectory(data: NukeTrajectoryData | null): void {
this.renderer?.updateNukeTrajectory(data);
}
/** Update in-flight nuke target telegraph circles. */
updateNukeTelegraphs(data: NukeTelegraphData[]): void {
this.renderer?.updateNukeTelegraphs(data);
}
/** Update spawn phase overlay (tile highlights + breathing rings). */
updateSpawnOverlay(inSpawnPhase: boolean, centers: SpawnCenter[]): void {
this.renderer?.updateSpawnOverlay(inSpawnPhase, centers);
}
/** Set the small-player glow set (1 byte per owner smallID), or null = off. */
updateSmallPlayerGlow(set: Uint8Array | null): void {
this.renderer?.updateSmallPlayerGlow(set);
}
/** Set the small-player glow Strength (0 = off, 1 = default, capped at 5). */
setSmallPlayerGlowStrength(strength: number): void {
this.smallPlayerGlowStrength = strength;
this.renderer?.setSmallPlayerGlowStrength(strength);
}
// ---- Selection box ----
/** Set multiple selected units (multi-select). Pass [] to clear. */
setSelectedUnits(unitIds: readonly number[]): void {
this.renderer?.setSelectedUnits(unitIds);
}
/** Flash converging-chevron animation at a warship move target. */
showMoveIndicator(tileX: number, tileY: number, ownerID: number): void {
this.renderer?.showMoveIndicator(tileX, tileY, ownerID);
}
// ---- SAM radius ----
setSAMAllianceClusters(clusters: Map<number, number>): void {
this.renderer?.setSAMAllianceClusters(clusters);
}
// ---- Other ----
setLocalPlayerID(id: number): void {
this.renderer?.setLocalPlayerID(id);
}
/** Rail color for the local player (01 RGB). */
setLocalRailColor(r: number, g: number, b: number): void {
this.renderer?.setLocalRailColor(r, g, b);
}
setAltView(active: boolean): void {
this.renderer?.setAltView(active);
}
setGridView(active: boolean): void {
this.renderer?.setGridView(active);
}
setShowPatterns(active: boolean): void {
this.renderer?.setShowPatterns(active);
}
setHighlightOwner(ownerID: number): void {
this.renderer?.setHighlightOwner(ownerID);
}
setMouseWorldPos(x: number, y: number): void {
this.renderer?.setMouseWorldPos(x, y);
}
setHighlightStructureTypes(unitTypes: string[] | null): void {
this.renderer?.setHighlightStructureTypes(unitTypes);
}
getSettings(): RenderSettings {
return this.renderer?.getSettings() ?? ({} as RenderSettings);
}
// ---- Lifecycle ----
dispose(): void {
this.resizeObs?.disconnect();
this.resizeObs = null;
this.onContextRestored = null;
this.renderer?.dispose();
this.canvas.removeEventListener("webglcontextlost", this.handleContextLost);
this.canvas.removeEventListener(
"webglcontextrestored",
this.handleContextRestored,
);
}
}