Smooth nuke point-light position per frame in ambient mode (#4311)

## Summary

Follow-up to #4255. That PR made nuke **sprites** glide per render frame
— `UnitPass.drawMissiles` lerps each nuke's `lastPos→pos` by wall-clock
progress through the current tick. But in ambient/night mode the glow
*behind* a nuke comes from a separate pass, `PointLightPass`, whose
instance buffer is packed once per tick in `updateLights()` from the raw
`unit.pos`. Its per-frame `draw()` (run every frame via `LightmapPass`)
only set uniforms and issued the instanced draw — it never repositioned
the lights. So the sprite moved at 60fps while its light jumped once per
100ms tick.

## Fix

Mirror `UnitPass`'s smoothing in `PointLightPass`:

- `updateLights()` records a `smoothSegs` tuple `(lightIdx, lastX,
lastY, x, y)` for each `SMOOTHED_NUKE_TYPES` unit whose `lastPos !==
pos`, and stamps `lastUnitsUpdateMs`.
- A new `applySmoothing()`, called at the top of `draw()`, lerps those
lights by wall-clock tick progress (`(now - lastUnitsUpdateMs) /
tickIntervalMs`, clamped to 1) and re-uploads **only** the affected
instances. Unlike `UnitPass` (which re-uploads its tiny missile buffer
wholesale), the light buffer can hold thousands of static structure
lights, so a full per-frame re-upload would be wasteful.
- `tickIntervalMs` comes from a new `config` constructor param, wired
through in `Renderer.ts` (the same `config` already passed to
`UnitPass`).

The light now uses the exact same `lastPos→pos` endpoints and alpha as
the sprite, so the two track together.

## Test plan

- `npx tsc --noEmit`, eslint, and prettier all clean.
- `npx vitest tests/client/render --run` — 40 passed.

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-16 16:54:11 -07:00
committed by GitHub
parent 8a8079b979
commit 6c84919801
2 changed files with 48 additions and 0 deletions
+1
View File
@@ -410,6 +410,7 @@ export class GPURenderer {
header,
paletteData,
this.settings,
config,
);
// --- Fallout light (needs tileTex + heatManager; particle flicker is
@@ -5,8 +5,10 @@
* draw() is pure GPU: uniforms + one drawArraysInstanced call.
*/
import type { Config } from "src/core/configuration/Config";
import type { RendererConfig, UnitState } from "../../types";
import {
SMOOTHED_NUKE_TYPES,
UT_ATOM_BOMB,
UT_CITY,
UT_DEFENSE_POST,
@@ -65,6 +67,11 @@ const BYTES_PER_LIGHT = FLOATS_PER_LIGHT * 4;
const MAX_LIGHT_TYPES = 64;
const MAX_LIGHTS = 12288; // units + structures combined
/** Values per smoothing segment in the flat `smoothSegs` array:
* (lightIdx, lastX, lastY, x, y). Mirrors UnitPass's nuke smoothing so the
* light tracks the smoothly-lerped sprite instead of jumping once per tick. */
const SMOOTH_SEG_STRIDE = 5;
export class PointLightPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
@@ -92,16 +99,26 @@ export class PointLightPass {
private intensityArr = new Float32Array(MAX_LIGHT_TYPES);
private paletteData: Float32Array;
// Per-frame nuke light smoothing: flat SMOOTH_SEG_STRIDE-wide tuples
// (lightIdx, lastX, lastY, x, y) recorded each tick, lerped into the light
// buffer in draw() so the glow tracks the per-frame-smoothed missile sprite.
private smoothSegs: number[] = [];
private lastUnitsUpdateMs = 0;
/** Simulation tick duration in ms (Config.msPerTick). */
private tickIntervalMs: number;
constructor(
gl: WebGL2RenderingContext,
header: RendererConfig,
paletteData: Float32Array,
settings: RenderSettings,
config: Config,
) {
this.gl = gl;
this.settings = settings;
this.paletteData = paletteData;
this.mapW = header.mapWidth;
this.tickIntervalMs = config.msPerTick();
// Build type → light config mapping
this.typeNames = header.unitTypes;
@@ -166,6 +183,8 @@ export class PointLightPass {
/** Pack all light-emitting entities into the instance buffer and upload. Called every tick. */
updateLights(units: Map<number, UnitState>): void {
let count = 0;
this.smoothSegs.length = 0;
this.lastUnitsUpdateMs = performance.now();
for (const unit of units.values()) {
if (!unit.isActive) continue;
@@ -177,6 +196,11 @@ export class PointLightPass {
const x = unit.pos % this.mapW;
const y = (unit.pos - x) / this.mapW;
if (SMOOTHED_NUKE_TYPES.has(unit.unitType) && unit.lastPos !== unit.pos) {
const lx = unit.lastPos % this.mapW;
const ly = (unit.lastPos - lx) / this.mapW;
this.smoothSegs.push(count, lx, ly, x, y);
}
const off = count * FLOATS_PER_LIGHT;
const pOff = unit.ownerID * 4;
this.lightData[off + 0] = x;
@@ -202,6 +226,27 @@ export class PointLightPass {
}
}
/** Lerp smoothed-nuke light positions lastPos→pos by wall-clock progress
* through the current tick and re-upload only the affected instances. */
private applySmoothing(): void {
const segs = this.smoothSegs;
if (segs.length === 0) return;
const alpha = Math.min(
1,
(performance.now() - this.lastUnitsUpdateMs) / this.tickIntervalMs,
);
const data = this.lightData;
const gl = this.gl;
gl.bindBuffer(gl.ARRAY_BUFFER, this.lightBuf);
for (let i = 0; i < segs.length; i += SMOOTH_SEG_STRIDE) {
const idx = segs[i];
const off = idx * FLOATS_PER_LIGHT;
data[off + 0] = segs[i + 1] + (segs[i + 3] - segs[i + 1]) * alpha;
data[off + 1] = segs[i + 2] + (segs[i + 4] - segs[i + 2]) * alpha;
gl.bufferSubData(gl.ARRAY_BUFFER, idx * BYTES_PER_LIGHT, data, off, 2);
}
}
/**
* Render instanced point lights into the currently bound FBO.
* Caller must set up additive blending and viewport.
@@ -209,6 +254,8 @@ export class PointLightPass {
draw(cameraMatrix: Float32Array): void {
if (this.lightCount === 0) return;
this.applySmoothing();
const gl = this.gl;
const dn = this.settings.lighting;