Merge p10-w1-renderer-fix into dev (P10 W1: blank ribbon at rest + WebGL init/draw diagnostics)
This commit is contained in:
@@ -67,6 +67,21 @@ const RIBBON_HALF_WIDTH_FRAC = 0.92;
|
|||||||
*/
|
*/
|
||||||
const MAX_DPR = 2;
|
const MAX_DPR = 2;
|
||||||
|
|
||||||
|
// ── Diagnostics ──────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Set true to surface the init/draw/datum/playback seams to the browser console
|
||||||
|
// (all prefixed `[MixVisualizer]`). The error/warn paths fire regardless of this
|
||||||
|
// flag — they only trigger on the abnormal path. The verbose `log` paths (datum
|
||||||
|
// received/uploaded, first-draw dimensions, GL error after first draw) are gated
|
||||||
|
// here so they can be silenced once the renderer is confirmed healthy. Leave it on
|
||||||
|
// while the runtime fix is being verified through the browser.
|
||||||
|
const DEBUG = true;
|
||||||
|
|
||||||
|
const TAG = '[MixVisualizer]';
|
||||||
|
function debugLog(...args: unknown[]): void {
|
||||||
|
if (DEBUG) console.log(TAG, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Theme: the gradient stop colours, read live from the active MudBlazor palette. ─
|
// ── Theme: the gradient stop colours, read live from the active MudBlazor palette. ─
|
||||||
//
|
//
|
||||||
// The shader cannot resolve `var(--mud-palette-*)` directly — uniforms are plain
|
// The shader cannot resolve `var(--mud-palette-*)` directly — uniforms are plain
|
||||||
@@ -90,10 +105,19 @@ interface ResolvedTheme {
|
|||||||
edge: [number, number, number];
|
edge: [number, number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read a CSS custom property off an element, falling back if it is empty/undefined. */
|
/**
|
||||||
|
* Read a CSS custom property off an element, falling back if it is empty/undefined.
|
||||||
|
* An empty value means the var did not inherit onto the canvas (e.g. the palette is
|
||||||
|
* scoped to a wrapper the canvas isn't under), which would silently swap the ribbon
|
||||||
|
* colour for the hardcoded default — so warn on it when diagnosing.
|
||||||
|
*/
|
||||||
function readVar(el: Element, name: string, fallback: string): string {
|
function readVar(el: Element, name: string, fallback: string): string {
|
||||||
const v = getComputedStyle(el).getPropertyValue(name).trim();
|
const v = getComputedStyle(el).getPropertyValue(name).trim();
|
||||||
return v.length > 0 ? v : fallback;
|
if (v.length === 0) {
|
||||||
|
if (DEBUG) console.warn(`${TAG} CSS var '${name}' did not resolve off the canvas — using fallback '${fallback}'; ribbon colour may be wrong.`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -278,6 +302,12 @@ void main() {
|
|||||||
// per-frame CPU blur. (No CSS backdrop-filter, no shadowBlur — spec §5.2.)
|
// per-frame CPU blur. (No CSS backdrop-filter, no shadowBlur — spec §5.2.)
|
||||||
float feather = 1.5;
|
float feather = 1.5;
|
||||||
float inside = 1.0 - smoothstep(halfWidth - feather, halfWidth + feather, distFromCentre);
|
float inside = 1.0 - smoothstep(halfWidth - feather, halfWidth + feather, distFromCentre);
|
||||||
|
// With no datum, amp is 0 everywhere and halfWidth collapses to 0 — but the
|
||||||
|
// feathered smoothstep still lights the ~feather-wide column at the exact centre,
|
||||||
|
// drawing a thin vertical line down the middle of an otherwise-empty backdrop.
|
||||||
|
// That is the at-rest "vertical bar" artifact. The predecessor drew nothing at
|
||||||
|
// rest; match it by forcing the silhouette empty whenever there is no datum.
|
||||||
|
if (uHasDatum < 0.5) inside = 0.0;
|
||||||
|
|
||||||
// Vertical gradient: brightest at the now line, dimming toward both edges. Same
|
// Vertical gradient: brightest at the now line, dimming toward both edges. Same
|
||||||
// luminosity cue the predecessor drew (edge -> accent -> edge by screen Y).
|
// luminosity cue the predecessor drew (edge -> accent -> edge by screen Y).
|
||||||
@@ -349,6 +379,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
// No WebGL2 (old engine / disabled): hand back a no-op handle so the
|
// No WebGL2 (old engine / disabled): hand back a no-op handle so the
|
||||||
// component still functions as a plain backdrop (mirrors the predecessor's
|
// component still functions as a plain backdrop (mirrors the predecessor's
|
||||||
// no-2d-context fallback, now guarding against no-WebGL2).
|
// no-2d-context fallback, now guarding against no-WebGL2).
|
||||||
|
console.error(`${TAG} getContext('webgl2') returned null — WebGL2 unavailable; rendering a plain backdrop.`);
|
||||||
return noopHandle();
|
return noopHandle();
|
||||||
}
|
}
|
||||||
// Non-null binding so the closures below keep the narrowing (TS does not carry
|
// Non-null binding so the closures below keep the narrowing (TS does not carry
|
||||||
@@ -361,7 +392,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A compile/link failure on an exotic driver should degrade to the plain
|
// A compile/link failure on an exotic driver should degrade to the plain
|
||||||
// backdrop, not crash the page. Log for diagnosis; return the no-op handle.
|
// backdrop, not crash the page. Log for diagnosis; return the no-op handle.
|
||||||
console.warn(err);
|
console.error(`${TAG} shader compile/link failed; rendering a plain backdrop.`, err);
|
||||||
return noopHandle();
|
return noopHandle();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,7 +400,12 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
// shader sources its positions from gl_VertexID, so no attribute buffers.
|
// shader sources its positions from gl_VertexID, so no attribute buffers.
|
||||||
const vao = gl.createVertexArray();
|
const vao = gl.createVertexArray();
|
||||||
|
|
||||||
// Cache uniform locations once.
|
// Cache uniform locations once. A null here for a uniform we actually upload
|
||||||
|
// means either the name is misspelled or the GLSL compiler dead-stripped it
|
||||||
|
// (it isn't reachable in the shader) — both of which silently break a uniform's
|
||||||
|
// effect, so surface them. `uTimeSeconds` is reserved for Wave 3 and currently
|
||||||
|
// unused by the fragment shader, so the compiler is free to strip it; we exempt
|
||||||
|
// it from the warning to avoid a false alarm.
|
||||||
const u = {
|
const u = {
|
||||||
resolution: gl.getUniformLocation(program, 'uResolution'),
|
resolution: gl.getUniformLocation(program, 'uResolution'),
|
||||||
playheadSeconds: gl.getUniformLocation(program, 'uPlayheadSeconds'),
|
playheadSeconds: gl.getUniformLocation(program, 'uPlayheadSeconds'),
|
||||||
@@ -381,6 +417,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
hasDatum: gl.getUniformLocation(program, 'uHasDatum'),
|
hasDatum: gl.getUniformLocation(program, 'uHasDatum'),
|
||||||
datum: gl.getUniformLocation(program, 'uDatum'),
|
datum: gl.getUniformLocation(program, 'uDatum'),
|
||||||
};
|
};
|
||||||
|
for (const [name, loc] of Object.entries(u)) {
|
||||||
|
if (loc === null && name !== 'timeSeconds') {
|
||||||
|
console.warn(`${TAG} uniform '${name}' resolved to null — it will have no effect (misspelled or dead-stripped from the shader).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Mutable state, fed by the component through the handle. ──────────────────
|
// ── Mutable state, fed by the component through the handle. ──────────────────
|
||||||
let datum: Datum | null = null;
|
let datum: Datum | null = null;
|
||||||
@@ -389,12 +430,14 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
|
|
||||||
/** Resolve the gradient stops from the live palette vars on the canvas. */
|
/** Resolve the gradient stops from the live palette vars on the canvas. */
|
||||||
function readTheme(): ResolvedTheme {
|
function readTheme(): ResolvedTheme {
|
||||||
return {
|
const resolved: ResolvedTheme = {
|
||||||
// Brightest stop at the "now" line — the bespoke themes' primary accent.
|
// Brightest stop at the "now" line — the bespoke themes' primary accent.
|
||||||
accent: parseColor(readVar(canvas, '--mud-palette-primary', '#b08d57')),
|
accent: parseColor(readVar(canvas, '--mud-palette-primary', '#b08d57')),
|
||||||
// Dim stop at the edges — the surface colour so the ribbon fades into the page.
|
// Dim stop at the edges — the surface colour so the ribbon fades into the page.
|
||||||
edge: parseColor(readVar(canvas, '--mud-palette-surface', '#1a1a1a')),
|
edge: parseColor(readVar(canvas, '--mud-palette-surface', '#1a1a1a')),
|
||||||
};
|
};
|
||||||
|
debugLog(`theme resolved — accent=[${resolved.accent.map((c) => c.toFixed(2)).join(', ')}], edge=[${resolved.edge.map((c) => c.toFixed(2)).join(', ')}].`);
|
||||||
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
let theme: ResolvedTheme = readTheme();
|
let theme: ResolvedTheme = readTheme();
|
||||||
@@ -403,6 +446,12 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
const startTimeMs = performance.now();
|
const startTimeMs = performance.now();
|
||||||
|
|
||||||
|
// One-shot diagnostics: log the canvas dimensions + a post-draw gl.getError() the
|
||||||
|
// first time we actually draw at a non-degenerate size. A 1×1 (or 300×150 default)
|
||||||
|
// backing store here means the canvas had no layout box when the first draw ran —
|
||||||
|
// the ResizeObserver will correct it, but the first paint would be degenerate.
|
||||||
|
let firstRealDrawLogged = false;
|
||||||
|
|
||||||
// Backing-store size in device pixels, tracked so we only resize the canvas
|
// Backing-store size in device pixels, tracked so we only resize the canvas
|
||||||
// (which clears it) when the CSS box actually changed.
|
// (which clears it) when the CSS box actually changed.
|
||||||
let cssWidth = 0;
|
let cssWidth = 0;
|
||||||
@@ -495,6 +544,21 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
// One full-screen triangle (3 vertices), positions from gl_VertexID.
|
// One full-screen triangle (3 vertices), positions from gl_VertexID.
|
||||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||||
gl.bindVertexArray(null);
|
gl.bindVertexArray(null);
|
||||||
|
|
||||||
|
// First draw at a real (laid-out) size: report dimensions and any accumulated
|
||||||
|
// GL error. We hold this log until cssWidth/cssHeight are populated so the
|
||||||
|
// dimensions Daniel sees are the meaningful ones, not a pre-layout 1×1.
|
||||||
|
// gl.getError() is a pipeline stall, so we only call it once, never per frame.
|
||||||
|
if (!firstRealDrawLogged && cssWidth > 0 && cssHeight > 0) {
|
||||||
|
firstRealDrawLogged = true;
|
||||||
|
debugLog(
|
||||||
|
`first draw — backing store ${canvas.width}x${canvas.height} px (css ${cssWidth}x${cssHeight} @ dpr ${dpr}), hasDatum=${datum ? 1 : 0}`,
|
||||||
|
);
|
||||||
|
const glErr = gl.getError();
|
||||||
|
if (glErr !== gl.NO_ERROR) {
|
||||||
|
console.error(`${TAG} gl.getError() after first draw: 0x${glErr.toString(16)} — the draw did not complete cleanly.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── rAF loop lifecycle (spec §E: cool when paused/backgrounded). ─────────────
|
// ── rAF loop lifecycle (spec §E: cool when paused/backgrounded). ─────────────
|
||||||
@@ -564,9 +628,18 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
* CLAMP_TO_EDGE. Returns the Datum, or null on an empty/invalid input.
|
* CLAMP_TO_EDGE. Returns the Datum, or null on an empty/invalid input.
|
||||||
*/
|
*/
|
||||||
function uploadDatum(samplesBase64: string, durationSeconds: number): Datum | null {
|
function uploadDatum(samplesBase64: string, durationSeconds: number): Datum | null {
|
||||||
if (durationSeconds <= 0 || !samplesBase64) return null;
|
if (durationSeconds <= 0 || !samplesBase64) {
|
||||||
|
// Expected before the player reports a duration: the bridge pushes an empty
|
||||||
|
// datum until then. Not an error, but worth seeing while diagnosing.
|
||||||
|
debugLog(`uploadDatum skipped — durationSeconds=${durationSeconds}, base64 length=${samplesBase64?.length ?? 0}.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const samples = decodeSamples(samplesBase64);
|
const samples = decodeSamples(samplesBase64);
|
||||||
if (samples.length === 0) return null;
|
if (samples.length === 0) {
|
||||||
|
console.warn(`${TAG} uploadDatum: decoded 0 samples from a non-empty base64 string — datum will not render.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
debugLog(`uploadDatum — ${samples.length} samples for ${durationSeconds.toFixed(2)}s mix (${(samples.length / durationSeconds).toFixed(1)} samples/s).`);
|
||||||
|
|
||||||
const texture = gl.createTexture();
|
const texture = gl.createTexture();
|
||||||
if (!texture) return null;
|
if (!texture) return null;
|
||||||
@@ -594,6 +667,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
setDatum(samplesBase64: string, durationSeconds: number): void {
|
setDatum(samplesBase64: string, durationSeconds: number): void {
|
||||||
|
debugLog(`setDatum received — base64 length ${samplesBase64?.length ?? 0}, durationSeconds ${durationSeconds}.`);
|
||||||
// Free the previous datum's GPU texture before replacing it (no leak
|
// Free the previous datum's GPU texture before replacing it (no leak
|
||||||
// across re-pushes / mix changes — spec §5.11).
|
// across re-pushes / mix changes — spec §5.11).
|
||||||
if (datum) {
|
if (datum) {
|
||||||
@@ -612,6 +686,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
|
|
||||||
if (isPlaying && !wasPlaying) {
|
if (isPlaying && !wasPlaying) {
|
||||||
// Transition paused/stopped → playing: start the rAF loop.
|
// Transition paused/stopped → playing: start the rAF loop.
|
||||||
|
debugLog(`playback started — position ${positionSeconds.toFixed(2)}s, datum ${datum ? 'present' : 'ABSENT'}; starting rAF loop.`);
|
||||||
startLoop();
|
startLoop();
|
||||||
} else if (!isPlaying && wasPlaying) {
|
} else if (!isPlaying && wasPlaying) {
|
||||||
// Transition playing → paused/stopped: the in-flight frame draws the
|
// Transition playing → paused/stopped: the in-flight frame draws the
|
||||||
|
|||||||
Reference in New Issue
Block a user