Skip to content

Commit dc55366

Browse files
committed
fix: make radar phosphor simulation framerate-independent
Run the phosphor sim (sweep, decay, blip detection) at a fixed tick rate decoupled from display refresh. All parameters derive from a single PHOSPHOR_HZ constant (240) so the visual result is consistent across 30–240+ Hz displays.
1 parent f268b37 commit dc55366

File tree

1 file changed

+84
-63
lines changed

1 file changed

+84
-63
lines changed

src/ui/RotatorWindow.svelte

Lines changed: 84 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,18 @@
2424
import { palette, parseRgba } from './shared/theme';
2525
import { initHiDPICanvas } from './shared/canvas';
2626
27+
// Sweep line animation — phosphor sim runs at fixed tick rate
28+
// All original values (0.014 sweep, 0.028 decay) were tuned at 240fps
29+
const PHOSPHOR_HZ = 240;
30+
const PHOSPHOR_TICK = 1 / PHOSPHOR_HZ;
31+
const PHOSPHOR_MAX_TICKS = Math.ceil(PHOSPHOR_HZ / 15);
32+
const SWEEP_PER_TICK = 0.014 * (240 / PHOSPHOR_HZ);
33+
const DECAY_ALPHA = 1 - Math.pow(1 - 0.028, 240 / PHOSPHOR_HZ);
34+
2735
// ── CRT phosphor palette (derived from theme radar colors) ──
2836
// Rebuilt when CRT canvases init or theme changes; avoids per-frame parsing
2937
let crt = {
30-
decayFill: 'rgba(5,10,5,0.028)',
38+
decayFill: `rgba(5,10,5,${DECAY_ALPHA})`,
3139
sweepLine: 'rgba(60,200,60,0.2)',
3240
sweepTrail: 'rgba(50,180,50,0.07)',
3341
flashSelected: 'rgba(200,255,200,0.95)',
@@ -60,7 +68,7 @@
6068
const hg = Math.min(255, bg_ + Math.round((255 - bg_) * 0.6));
6169
const hb = Math.min(255, bb + Math.round((255 - bb) * 0.6));
6270
crt = {
63-
decayFill: `rgba(${bgr},${bgg},${bgb},0.028)`,
71+
decayFill: `rgba(${bgr},${bgg},${bgb},${DECAY_ALPHA})`,
6472
sweepLine: `rgba(${br},${bg_},${bb},0.2)`,
6573
sweepTrail: `rgba(${br},${bg_},${bb},0.07)`,
6674
flashSelected: `rgba(${hr},${hg},${hb},0.95)`,
@@ -114,8 +122,9 @@
114122
let ctx: CanvasRenderingContext2D | null = null;
115123
let animFrameId = 0;
116124
117-
// Sweep line animation
118125
let sweepAngle = 0;
126+
let lastFrameTime = 0;
127+
let phosphorAccum = 0;
119128
120129
// Info bar state (HTML overlay)
121130
let infoCount = $state(0);
@@ -334,8 +343,10 @@
334343
if (zoom <= 1.01) { panX = 0; panY = 0; zoom = 1; }
335344
}
336345
337-
function drawFrame() {
346+
function drawFrame(now: number) {
338347
if (!ctx || !canvasEl) { animFrameId = requestAnimationFrame(drawFrame); return; }
348+
const dt = lastFrameTime ? (now - lastFrameTime) / 1000 : 0;
349+
lastFrameTime = now;
339350
340351
const dpr = window.devicePixelRatio || 1;
341352
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
@@ -374,70 +385,80 @@
374385
const count = uiStore.radarBlipCount;
375386
const bAz = beamStore.aimAz, bEl = beamStore.aimEl, bW = beamStore.beamWidth;
376387
377-
// ═══ VFX: phosphor buffer approach ═══
388+
// ═══ VFX: phosphor buffer approach (fixed 60Hz tick) ═══
378389
if (vfx && phosphorCtx && phosphorCanvas) {
379390
const pc = phosphorCtx;
380391
381-
// Advance sweep
382-
sweepAngle += 0.014;
383-
if (sweepAngle > TWO_PI) sweepAngle -= TWO_PI;
384-
385-
// Fade the phosphor buffer — this creates the persistence decay
386-
pc.fillStyle = crt.decayFill;
387-
pc.fillRect(0, 0, SIZE, SIZE);
388-
389-
// Draw bright sweep line onto phosphor
390-
const edgeA = sweepAngle - Math.PI / 2;
391-
const sx = CX + sweepLen * Math.cos(edgeA);
392-
const sy = CY + sweepLen * Math.sin(edgeA);
393-
pc.strokeStyle = crt.sweepLine;
394-
pc.lineWidth = 1.5;
395-
pc.beginPath();
396-
pc.moveTo(CX, CY);
397-
pc.lineTo(sx, sy);
398-
pc.stroke();
399-
// Dimmer trailing line
400-
const trailA = sweepAngle - 0.04 - Math.PI / 2;
401-
pc.strokeStyle = crt.sweepTrail;
402-
pc.lineWidth = 1;
403-
pc.beginPath();
404-
pc.moveTo(CX, CY);
405-
pc.lineTo(CX + sweepLen * Math.cos(trailA), CY + sweepLen * Math.sin(trailA));
406-
pc.stroke();
407-
408-
// Draw blips onto phosphor ONLY when sweep passes them
409-
for (let i = 0; i < count; i++) {
410-
const off = i * 4;
411-
const az = blips[off];
412-
const el = blips[off + 1];
413-
const flags = blips[off + 3];
414-
const isSelected = (flags & 1) !== 0;
415-
const inBeam = isInsideBeam(az, el, bAz, bEl, bW);
416-
417-
// How far behind the sweep is this blip?
418-
const blipAngle = az * Math.PI / 180 - headingRad;
419-
const angDist = ((sweepAngle - blipAngle) % TWO_PI + TWO_PI) % TWO_PI;
420-
// Only paint when sweep is within ~3° of the blip
421-
if (angDist < 0.06 || angDist > TWO_PI - 0.02) {
422-
const r = R_MAX * (90 - Math.max(0, el)) / 90;
423-
const bx = r * Math.sin(blipAngle) + CX;
424-
const by = -r * Math.cos(blipAngle) + CY;
425-
const radius = isSelected ? 4 : inBeam ? 3 : 2.5;
426-
427-
// Bright white-green flash
428-
pc.fillStyle = isSelected ? crt.flashSelected
429-
: inBeam ? crt.flashBeam
430-
: crt.flashNormal;
431-
pc.beginPath();
432-
pc.arc(bx, by, radius, 0, TWO_PI);
433-
pc.fill();
434-
435-
// Bloom glow
436-
if (isSelected || inBeam) {
437-
pc.fillStyle = isSelected ? crt.bloomBright : crt.bloomDim;
392+
// Run phosphor simulation at fixed 60Hz — accumulate real time, consume in ticks
393+
phosphorAccum += dt;
394+
const maxTicks = PHOSPHOR_MAX_TICKS;
395+
let ticks = Math.floor(phosphorAccum / PHOSPHOR_TICK);
396+
if (ticks > maxTicks) ticks = maxTicks;
397+
phosphorAccum -= ticks * PHOSPHOR_TICK;
398+
399+
for (let t = 0; t < ticks; t++) {
400+
// Advance sweep
401+
// Advance sweep
402+
sweepAngle += SWEEP_PER_TICK;
403+
if (sweepAngle > TWO_PI) sweepAngle -= TWO_PI;
404+
405+
// Fade the phosphor buffer — this creates the persistence decay
406+
pc.fillStyle = crt.decayFill;
407+
pc.fillRect(0, 0, SIZE, SIZE);
408+
409+
// Draw bright sweep line onto phosphor
410+
const edgeA = sweepAngle - Math.PI / 2;
411+
const sx = CX + sweepLen * Math.cos(edgeA);
412+
const sy = CY + sweepLen * Math.sin(edgeA);
413+
pc.strokeStyle = crt.sweepLine;
414+
pc.lineWidth = 1.5;
415+
pc.beginPath();
416+
pc.moveTo(CX, CY);
417+
pc.lineTo(sx, sy);
418+
pc.stroke();
419+
// Dimmer trailing line
420+
const trailA = sweepAngle - 0.04 - Math.PI / 2;
421+
pc.strokeStyle = crt.sweepTrail;
422+
pc.lineWidth = 1;
423+
pc.beginPath();
424+
pc.moveTo(CX, CY);
425+
pc.lineTo(CX + sweepLen * Math.cos(trailA), CY + sweepLen * Math.sin(trailA));
426+
pc.stroke();
427+
428+
// Draw blips onto phosphor ONLY when sweep passes them
429+
for (let i = 0; i < count; i++) {
430+
const off = i * 4;
431+
const az = blips[off];
432+
const el = blips[off + 1];
433+
const flags = blips[off + 3];
434+
const isSelected = (flags & 1) !== 0;
435+
const inBeam = isInsideBeam(az, el, bAz, bEl, bW);
436+
437+
// How far behind the sweep is this blip?
438+
const blipAngle = az * Math.PI / 180 - headingRad;
439+
const angDist = ((sweepAngle - blipAngle) % TWO_PI + TWO_PI) % TWO_PI;
440+
// Only paint when sweep is within ~3° of the blip
441+
if (angDist < 0.06 || angDist > TWO_PI - 0.02) {
442+
const r = R_MAX * (90 - Math.max(0, el)) / 90;
443+
const bx = r * Math.sin(blipAngle) + CX;
444+
const by = -r * Math.cos(blipAngle) + CY;
445+
const radius = isSelected ? 4 : inBeam ? 3 : 2.5;
446+
447+
// Bright white-green flash
448+
pc.fillStyle = isSelected ? crt.flashSelected
449+
: inBeam ? crt.flashBeam
450+
: crt.flashNormal;
438451
pc.beginPath();
439-
pc.arc(bx, by, radius + 4, 0, TWO_PI);
452+
pc.arc(bx, by, radius, 0, TWO_PI);
440453
pc.fill();
454+
455+
// Bloom glow
456+
if (isSelected || inBeam) {
457+
pc.fillStyle = isSelected ? crt.bloomBright : crt.bloomDim;
458+
pc.beginPath();
459+
pc.arc(bx, by, radius + 4, 0, TWO_PI);
460+
pc.fill();
461+
}
441462
}
442463
}
443464
}

0 commit comments

Comments
 (0)