Keep the Sound tab smooth while it pans (#360)
game-maker / gate (push) Successful in 3m51s
game-maker / suite (push) Successful in 15m55s

Panning the Sound tab drew the whole wave anew on every move, one
filled rectangle per pixel column, and gave the ruler's labels new
texts whenever a tick left the area. At 2560 px across that took the
tab down to 16 fps.

- The wave is drawn half the area's width past either side of it. A
  pan within that moves the drawing. Zoom, height, size or a pan
  past the margin draw it anew.
- The bars of the wave share one fill instead of one fill each.
- A ruler label keeps the second it already shows. Before, a pan
  shifted every label by one and each had to be rendered anew.
- A clip's name is given its style only when the style changes.
- The rule for what a drawing covers is pure in `waveView.ts`
  (`shownSeconds`, `drawnSeconds`, `coversShown`).

Measured with a copy of the user's project, "Take 1" with 4 clips,
240 pan moves with the middle button, headless on stein2:

- 2560x1440: main thread 712 -> 373 ms/s, texts rendered 694 -> 65
- 1280x800: main thread 429 -> 225 ms/s

What is left in the profile is Pixi's render pass and the pointer
events, which hovering pays as well.

Tests:
- Unit wave-view: 12 green (1 new), counter-probe red
- e2e 34 (stein2, melektaus-atx was down): ALL PASSED, 92 checks
  (1 new). Counter-probe with the labels given out by position: red,
  7 labels set anew instead of 1.
- Gate (stein2): 272 files, 4210 tests green

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 06:19:05 +02:00
1 parent 490fcba88f
commit cf96442835
5 files changed
+160 -15

No files matched your search

+2
View File
@@ -41,6 +41,8 @@ export function soundHooks({ state, editor }: HookContext) {
stripAt: (seconds: number) => soundEditor.canvas.stripAt(seconds),
/** The clips drawn faint over the wave, by name: all but the picked one. */
faintClips: () => soundEditor.canvas.faintClips(),
/** Wave draws, its shift from where it was drawn, and ruler labels given a text (#360). */
drawInfo: () => soundEditor.canvas.drawInfo(),
fadeInField: () => ({ ...centreOf(soundEditor.fadeInField), text: soundEditor.fadeInField.value }),
fadeOutField: () => ({ ...centreOf(soundEditor.fadeOutField), text: soundEditor.fadeOutField.value }),
/** The curve dropdowns show a picture of their curve; the tooltip names it. */
+83 -15
View File
@@ -18,8 +18,10 @@ import {
clampWave,
type ClipBand,
clipAt,
coversShown,
type CutSpan,
draggedSpan,
drawnSeconds,
edgeAt,
fileReach,
fitWave,
@@ -28,6 +30,7 @@ import {
rulerLabel,
rulerStep,
secondsToX,
shownSeconds,
spanOf,
spanPeak,
storedCut,
@@ -63,6 +66,8 @@ const LIT_LABEL = textStyle('small').clone();
LIT_LABEL.fill = 0x30343c;
/** Line pieces a fade's curve is drawn with at most. */
const RAMP_STEPS = 32;
/** The share of the area's width the wave is drawn past either side of it, for a pan to move (#360). */
const DRAWN_MARGIN = 0.5;
/**
* A drag that spans: an edge of the clip `clipId`, that clip anew, or with `clipId` null a new clip.
@@ -116,6 +121,18 @@ export class WaveCanvas extends Widget {
private readonly scaleLabels = new Container();
private readonly scaleTexts: Text[] = [];
private readonly waveG = new Graphics();
/** What `waveG` was drawn for: a pan within `seconds` only moves it. */
private drawn: {
wave: Wave;
panX: number;
pxPerSecond: number;
level: number;
w: number;
h: number;
seconds: CutSpan | null;
} | null = null;
private waveDraws = 0;
private rulerTexts = 0;
private readonly overlay = new Graphics();
private readonly labels = new Container();
private readonly stripLabels = new Container();
@@ -470,7 +487,6 @@ export class WaveCanvas extends Widget {
this.bg.rect(SCALE_WIDTH - 1, RULER_HEIGHT, 1, STRIP_HEIGHT + wh).fill(c.border);
this.clip.clear().rect(SCALE_WIDTH, 0, w, h).fill(0xffffff);
this.scaleG.clear();
this.waveG.clear();
this.overlay.clear();
const asset = this.asset();
this.hint.visible = !wave;
@@ -483,24 +499,21 @@ export class WaveCanvas extends Widget {
: 'Loading…';
this.hint.position.set(SCALE_WIDTH + Theme.padding * 2, WAVE_TOP + Theme.padding * 2);
if (!wave || !asset) {
this.waveG.clear();
this.drawn = null;
this.syncLabels([]);
this.syncScale([]);
this.syncStrip([]);
return;
}
const x0 = secondsToX(this.view, 0);
const x1 = secondsToX(this.view, wave.duration);
const mid = WAVE_TOP + wh / 2;
const half = this.level * this.fullHalf;
const room = wh / 2;
// The file itself a shade lighter than what lies around it, as high as the wave is zoomed out.
const reach = fileReach(this.level, this.fullHalf, WAVE_INSET, room);
const left = Math.max(0, x0);
const right = Math.min(w, x1);
if (right > left)
this.waveG.rect(left, mid - reach, right - left, 2 * reach).fill({ color: c.panel, alpha: 0.5 });
if (right > left) this.waveG.rect(left, mid, right - left, 1).fill({ color: c.border });
this.drawWave(wave, left, right, mid, half, room);
if (!this.waveStillDrawn(wave)) this.drawFile(wave, mid, half, room, reach);
this.waveG.x = this.view.panX - this.drawn!.panX;
const left = Math.max(0, secondsToX(this.view, 0));
const right = Math.min(w, secondsToX(this.view, wave.duration));
this.drawRuler(left, right);
this.drawStrip(asset, wave);
const span = this.shownClipSpan();
@@ -513,6 +526,48 @@ export class WaveCanvas extends Widget {
}
}
/** Whether the wave drawn last still serves: same file, zoom and size, and what is shown inside it. */
private waveStillDrawn(wave: Wave): boolean {
const d = this.drawn;
return (
d !== null &&
d.wave === wave &&
d.pxPerSecond === this.view.pxPerSecond &&
d.level === this.level &&
d.w === this.areaWidth &&
d.h === this.h &&
coversShown(d.seconds, shownSeconds(this.view, wave.duration, this.areaWidth))
);
}
/**
* The file a shade lighter than what lies around it, as high as the wave is zoomed out, its middle
* line and the wave – past the area by a margin either side, so a pan only moves them (#360).
*/
private drawFile(wave: Wave, mid: number, half: number, room: number, reach: number): void {
const c = Theme.colors;
const w = this.areaWidth;
const seconds = drawnSeconds(this.view, wave.duration, w, w * DRAWN_MARGIN);
const { panX, pxPerSecond } = this.view;
this.drawn = { wave, panX, pxPerSecond, level: this.level, w, h: this.h, seconds };
this.waveDraws++;
this.waveG.clear();
if (!seconds) return;
const left = secondsToX(this.view, seconds.start);
const right = secondsToX(this.view, seconds.end);
this.waveG.rect(left, mid - reach, right - left, 2 * reach).fill({ color: c.panel, alpha: 0.5 });
this.waveG.rect(left, mid, right - left, 1).fill({ color: c.border });
this.drawWave(wave, left, right, mid, half, room);
}
/**
* How often the wave has been drawn anew, how far it is moved from where it was drawn, and how often
* a ruler label was given a new text (test hook).
*/
drawInfo(): { waveDraws: number; shift: number; rulerTexts: number } {
return { waveDraws: this.waveDraws, shift: this.waveG.x, rulerTexts: this.rulerTexts };
}
/**
* A bar from the lowest to the highest sample per pixel, or the samples themselves zoomed far in.
* `half` is full scale, what lies beyond `room` stops at the edge of the area.
@@ -544,8 +599,10 @@ export class WaveCanvas extends Widget {
const { min, max } = spanPeak(wave.channels, wave.peaks, from, to);
const top = yOf(max);
const bottom = yOf(min);
this.waveG.rect(x, top, 1, Math.max(1, bottom - top)).fill(color);
this.waveG.rect(x, top, 1, Math.max(1, bottom - top));
}
// One fill for all the bars: a fill each costs more than the bar itself.
this.waveG.fill(color);
}
private drawRuler(left: number, right: number): void {
@@ -712,7 +769,8 @@ export class WaveCanvas extends Widget {
this.stripTexts.push(text);
this.stripLabels.addChild(text);
}
text.style = label.lit ? LIT_LABEL : textStyle('small');
const style = label.lit ? LIT_LABEL : textStyle('small');
if (text.style !== style) text.style = style;
if (text.text !== label.text) text.text = label.text;
for (let cut = label.text.length; text.width > label.room && cut > 1; cut--)
text.text = `${label.text.slice(0, cut - 1)}…`;
@@ -722,18 +780,28 @@ export class WaveCanvas extends Widget {
for (let i = placed.length; i < this.stripTexts.length; i++) this.stripTexts[i]!.visible = false;
}
/**
* The ruler's labels. A label already showing a second keeps it: a pan shifts which one is first,
* and a text set anew is drawn anew (#360).
*/
private syncLabels(placed: readonly { text: string; x: number }[]): void {
const shown = new Map(this.texts.filter((t) => t.visible).map((t) => [t.text, t]));
const kept = placed.map((label) => shown.get(label.text));
const spare = this.texts.filter((t) => !kept.includes(t));
for (const text of spare) text.visible = false;
placed.forEach((label, i) => {
let text = this.texts[i];
let text = kept[i] ?? spare.pop();
if (!text) {
text = crispText({ style: textStyle('small') });
this.texts.push(text);
this.labels.addChild(text);
}
text.visible = true;
if (text.text !== label.text) text.text = label.text;
if (text.text !== label.text) {
text.text = label.text;
this.rulerTexts++;
}
text.position.set(label.x, 6);
});
for (let i = placed.length; i < this.texts.length; i++) this.texts[i]!.visible = false;
}
}
+26
View File
@@ -49,6 +49,32 @@ export function clampWave(view: WaveView, duration: number, width: number): Wave
return { ...view, panX: clamp(view.panX, least, lead) };
}
/** The seconds of the file the area shows, held inside the file; null where none of it is in view. */
export function shownSeconds(view: WaveView, duration: number, width: number): CutSpan | null {
const start = Math.max(0, xToSeconds(view, 0));
const end = Math.min(duration, xToSeconds(view, width));
return end > start ? { start, end } : null;
}
/**
* The seconds a drawn wave covers: what the area shows and `margin` pixels either side, so a pan by
* less moves the drawing instead of drawing it anew (#360).
*/
export function drawnSeconds(
view: WaveView,
duration: number,
width: number,
margin: number,
): CutSpan | null {
return shownSeconds({ ...view, panX: view.panX + margin }, duration, width + 2 * margin);
}
/** Whether a wave drawn over `drawn` still covers what is `shown`. */
export function coversShown(drawn: CutSpan | null, shown: CutSpan | null): boolean {
if (!shown) return true;
return drawn !== null && drawn.start <= shown.start && drawn.end >= shown.end;
}
/** How far the wave's height zooms: out to a quarter, in to a sixteenth of full scale (#357). */
export const MIN_LEVEL = 0.25;
export const MAX_LEVEL = 16;
+20
View File
@@ -5,7 +5,9 @@
import { describe, expect, it } from 'vitest';
import {
clampWave,
coversShown,
draggedSpan,
drawnSeconds,
edgeAt,
fileReach,
fitWave,
@@ -15,6 +17,7 @@ import {
rulerLabel,
rulerStep,
secondsToX,
shownSeconds,
spanOf,
spanPeak,
storedCut,
@@ -83,6 +86,23 @@ describe('the wave view', () => {
expect(rulerLabel(0.25, 0.05)).toBe('0.25s');
expect(rulerLabel(75, 5)).toBe('1:15');
});
it('draws the wave past the area by a margin, and a pan within it keeps that drawing (#360)', () => {
// 20 s at 100 px/s, 1000 px across: seconds 5 to 15 shown, 0 to 20 with 500 px either side.
const view = { panX: -500, pxPerSecond: 100 };
expect(shownSeconds(view, 20, 1000)).toEqual({ start: 5, end: 15 });
const drawn = drawnSeconds(view, 20, 1000, 500);
expect(drawn).toEqual({ start: 0, end: 20 });
const near = drawnSeconds({ ...view, panX: -1000 }, 20, 1000, 300);
expect(near).toEqual({ start: 7, end: 20 });
// Panned 200 px it still covers what is shown, 400 px past it no longer.
expect(coversShown(near, shownSeconds({ ...view, panX: -800 }, 20, 1000))).toBe(true);
expect(coversShown(near, shownSeconds({ ...view, panX: -600 }, 20, 1000))).toBe(false);
// Nothing of the file in view: nothing to cover, and nothing drawn.
expect(shownSeconds({ panX: 1200, pxPerSecond: 100 }, 20, 1000)).toBeNull();
expect(coversShown(null, null)).toBe(true);
expect(coversShown(null, { start: 0, end: 1 })).toBe(false);
});
});
describe('the cut span', () => {
+29
View File
@@ -1980,6 +1980,35 @@ export async function sound(t) {
Math.abs(refitted.top - fullTop) < 0.5,
JSON.stringify({ levelScale, levelRect, fullTop, secondAt, zoomedOut, zoomedIn, refitted }),
);
// 34h-2b. A pan moves the wave drawn already and leaves each ruler label on its second (#360):
// drawing both anew on every move took the frames down.
const panFrom = await json('__gm.soundTab.at(0.375)');
await t.wheel(panFrom.x, panFrom.y, -924);
const panInfo = async () => ({
...(await json('__gm.soundTab.drawInfo()')),
at: (await json('__gm.soundTab.at(0.375)')).x,
});
const panBefore = await panInfo();
await t.mouse('mouseMoved', panFrom.x, panFrom.y);
await t.mouse('mousePressed', panFrom.x, panFrom.y, 'middle');
for (let i = 1; i <= 8; i++) {
await t.mouse('mouseMoved', panFrom.x - 30 * i, panFrom.y, 'middle', { buttons: 4 });
await sleep(20);
}
await t.mouse('mouseReleased', panFrom.x - 240, panFrom.y, 'middle');
await sleep(100);
const panAfter = await panInfo();
const panMoved = panAfter.at - panBefore.at;
await click(levelFit.x, levelFit.y);
await sleep(100);
check(
'a pan moves the wave drawn already, and the ruler labels keep their seconds',
Math.abs(panMoved + 240) < 1 &&
panAfter.waveDraws === panBefore.waveDraws &&
Math.abs(panAfter.shift - panBefore.shift - panMoved) < 0.5 &&
panAfter.rulerTexts - panBefore.rulerTexts <= 2,
JSON.stringify({ panBefore, panAfter, panMoved }),
);
const clipProps = async () => (await clips())[0]?.props;
let dragFrom = await json('__gm.soundTab.stripAt(0.1)');
let dragTo = await json('__gm.soundTab.stripAt(0.3)');