orb: startup self-check — verify float framebuffers complete and the simulation produces a sane particle, else fall back to the static layout with a console warning

This commit is contained in:
dangrubbb
2026-10-24 10:34:17 -04:00
parent 4b9ec8e395
commit a919fa38cb

View File

@ -724,7 +724,8 @@ export function startOrb(canvas, { onReady } = {}) {
if (!gl) return false;
const hasF32 = !!gl.getExtension('EXT_color_buffer_float');
const interactive = hasF32 || !!gl.getExtension('EXT_color_buffer_half_float');
// let: dropped to false if the simulation turns out not to work on this GPU/driver (see the checks below)
let interactive = hasF32 || !!gl.getExtension('EXT_color_buffer_half_float');
const stateFmt = hasF32
? { internal: gl.RGBA32F, type: gl.FLOAT }
: { internal: gl.RGBA16F, type: gl.HALF_FLOAT };
@ -770,6 +771,19 @@ export function startOrb(canvas, { onReady } = {}) {
// and the spawn point of the current life, so the costly spawn maths runs once per life
const state = interactive ? pingPong(gl, T, T, stateFmt.internal, stateFmt.type, gl.NEAREST, 3) : null;
let needInit = true;
const fallBack = (why) => {
if (!interactive) return;
interactive = false;
console.warn('orb: particle simulation unavailable on this GPU/driver, using the static layout.', why);
};
// some drivers accept float render targets but cannot complete a three-attachment framebuffer
if (state) {
const incomplete = [state.read, state.write].some((t) => {
gl.bindFramebuffer(gl.FRAMEBUFFER, t.fbo);
return gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE;
});
if (incomplete) fallBack('particle framebuffer incomplete');
}
// fluid fields; rebuilt when the viewport shape changes so the grid stays square-celled
let velocity, dye, pressure, divergence, curl, simW = 1, simH = 1;
@ -924,6 +938,23 @@ export function startOrb(canvas, { onReady } = {}) {
let simT = reduced ? 8 : 0;
let last = performance.now();
let ready = false;
let frameCount = 0;
// A few frames in, read one particle back: if the simulation produced nothing sane (all zeros, NaN),
// draw the static layout instead of an orb with no particles.
function verifySimulation() {
if (!hasF32) return; // reading half-float targets back as float is not guaranteed
try {
const px = new Float32Array(4);
gl.bindFramebuffer(gl.FRAMEBUFFER, state.read.fbo);
gl.readBuffer(gl.COLOR_ATTACHMENT0);
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.FLOAT, px); // particle 0: an inner particle, always inside the ball
const r = Math.hypot(px[0], px[1], px[2]);
if (!(Number.isFinite(r) && r > 0.2 && r < 1.0)) fallBack(`particle 0 read back as (${Array.from(px).join(', ')})`);
} catch (e) {
fallBack(String(e));
}
}
function commonUniforms(prog, t) {
gl.uniform1f(prog.u.uTime, t);
@ -1004,6 +1035,7 @@ export function startOrb(canvas, { onReady } = {}) {
gl.uniform1f(s.u.uDt, dt);
gl.uniform1f(s.u.uGain, 0.5);
stepState();
if (++frameCount === 5) verifySimulation();
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);